diff --git a/apps/infoalloggi/src/lib/dateParserJsonKysely.test.ts b/apps/infoalloggi/src/lib/dateParserJsonKysely.test.ts new file mode 100644 index 0000000..e867a24 --- /dev/null +++ b/apps/infoalloggi/src/lib/dateParserJsonKysely.test.ts @@ -0,0 +1,397 @@ +import type { KyselyPlugin, UnknownRow } from "kysely"; +import { + createQueryId, + DummyDriver, + type Insertable, + Kysely, + NoResultError, + PostgresAdapter, + PostgresIntrospector, + PostgresQueryCompiler, + type Selectable, + type Updateable, +} from "kysely"; +import { jsonArrayFrom } from "kysely/helpers/postgres"; +import { describe, expect, expectTypeOf, it } from "vitest"; +import { + DateJsonParserPlugin, + type WithParsedDates, + withParsedDates, +} from "~/lib/dateParserJsonKysely"; + +// --------------------------------------------------------------------------- +// Schema +// --------------------------------------------------------------------------- + +interface Database { + box: BoxTable; + item: ItemTable; +} + +interface BoxTable { + id: number; +} + +interface ItemTable { + id: number; + box_id: number; + created_at: Date; + updated_at: Date | null; +} + +export type Box = Selectable; +export type Item = Selectable; +export type NewItem = Insertable; +export type ItemUpdate = Updateable; + +// --------------------------------------------------------------------------- +// DummyDriver db — used only for compile-time type tests +// --------------------------------------------------------------------------- + +const db = new Kysely({ + dialect: { + createAdapter: () => new PostgresAdapter(), + createDriver: () => new DummyDriver(), + // biome-ignore lint/suspicious/noExplicitAny: required by Kysely dialect contract + createIntrospector: (db: Kysely) => new PostgresIntrospector(db), + createQueryCompiler: () => new PostgresQueryCompiler(), + }, +}); + +// --------------------------------------------------------------------------- +// Fake query helper — pipes synthetic rows through the plugin at runtime +// --------------------------------------------------------------------------- + +class TestNoResultError extends Error { + constructor() { + super("No result (test)"); + this.name = "TestNoResultError"; + } +} + +function makeFakeQuery(rows: T[]) { + return { + withPlugin(plugin: KyselyPlugin) { + async function run(): Promise { + const result = await plugin.transformResult({ + queryId: createQueryId(), + result: { rows: rows as UnknownRow[] }, + }); + return result.rows as T[]; + } + return { + execute: run, + async executeTakeFirst() { + return (await run())[0]; + }, + async executeTakeFirstOrThrow( + errorConstructor?: (new () => Error) | ((node: unknown) => Error), + ) { + const row = (await run())[0]; + if (row !== undefined) return row; + if (typeof errorConstructor === "function") { + try { + // try constructor form first + throw new (errorConstructor as new () => Error)(); + } catch (e) { + if (e instanceof Error) throw e; + // it was a factory function, not a constructor + throw (errorConstructor as (node: unknown) => Error)({}); + } + } + throw new NoResultError({} as never); + }, + }; + }, + }; +} + +// --------------------------------------------------------------------------- +// DateJsonParserPlugin — direct unit tests +// --------------------------------------------------------------------------- + +describe("DateJsonParserPlugin", () => { + it("parses a top-level date string", async () => { + const plugin = new DateJsonParserPlugin(["created_at"]); + const { rows } = await plugin.transformResult({ + queryId: createQueryId(), + result: { rows: [{ created_at: "2024-01-15T10:00:00.000Z" }] }, + }); + expect(rows[0]?.created_at).toBeInstanceOf(Date); + expect((rows[0]?.created_at as Date).toISOString()).toBe( + "2024-01-15T10:00:00.000Z", + ); + }); + + it("parses dates nested inside a JSON array", async () => { + const plugin = new DateJsonParserPlugin(["items.created_at"]); + const { rows } = await plugin.transformResult({ + queryId: createQueryId(), + result: { + rows: [ + { + items: [ + { id: 1, created_at: "2024-01-01T00:00:00.000Z" }, + { id: 2, created_at: "2024-06-15T12:30:00.000Z" }, + ], + }, + ], + }, + }); + const items = rows[0]?.items as { id: number; created_at: Date }[]; + expect(items[0]?.created_at).toBeInstanceOf(Date); + expect(items[0]?.created_at.toISOString()).toBe("2024-01-01T00:00:00.000Z"); + expect(items[1]?.created_at).toBeInstanceOf(Date); + expect(items[1]?.created_at.toISOString()).toBe("2024-06-15T12:30:00.000Z"); + }); + + it("parses a date nested inside a JSON object", async () => { + const plugin = new DateJsonParserPlugin(["meta.published_at"]); + const { rows } = await plugin.transformResult({ + queryId: createQueryId(), + result: { + rows: [{ meta: { published_at: "2024-03-01T00:00:00.000Z" } }], + }, + }); + const meta = rows[0]?.meta as { published_at: Date }; + expect(meta.published_at).toBeInstanceOf(Date); + }); + + it("leaves null values untouched", async () => { + const plugin = new DateJsonParserPlugin(["items.created_at"]); + const { rows } = await plugin.transformResult({ + queryId: createQueryId(), + result: { rows: [{ items: [{ id: 1, created_at: null }] }] }, + }); + const items = rows[0]?.items as { created_at: null }[]; + expect(items[0]?.created_at).toBeNull(); + }); + + it("leaves undefined values untouched", async () => { + const plugin = new DateJsonParserPlugin(["created_at"]); + const { rows } = await plugin.transformResult({ + queryId: createQueryId(), + result: { rows: [{ created_at: undefined }] }, + }); + expect(rows[0]?.created_at).toBeUndefined(); + }); + + it("leaves non-string primitive values untouched", async () => { + const plugin = new DateJsonParserPlugin(["count"]); + const { rows } = await plugin.transformResult({ + queryId: createQueryId(), + result: { rows: [{ count: 42 }] }, + }); + expect(rows[0]?.count).toBe(42); + }); + + it("applies multiple paths independently", async () => { + const plugin = new DateJsonParserPlugin(["created_at", "updated_at"]); + const { rows } = await plugin.transformResult({ + queryId: createQueryId(), + result: { + rows: [ + { + created_at: "2024-01-01T00:00:00.000Z", + updated_at: "2024-06-01T00:00:00.000Z", + }, + ], + }, + }); + expect(rows[0]?.created_at).toBeInstanceOf(Date); + expect(rows[0]?.updated_at).toBeInstanceOf(Date); + }); + + it("parses the same path across multiple result rows", async () => { + const plugin = new DateJsonParserPlugin(["created_at"]); + const { rows } = await plugin.transformResult({ + queryId: createQueryId(), + result: { + rows: [ + { created_at: "2024-01-01T00:00:00.000Z" }, + { created_at: "2024-02-01T00:00:00.000Z" }, + { created_at: "2024-03-01T00:00:00.000Z" }, + ], + }, + }); + for (const row of rows) { + expect(row.created_at).toBeInstanceOf(Date); + } + }); + + it("does not mutate the original rows array", async () => { + const original = [{ created_at: "2024-01-01T00:00:00.000Z" }]; + const plugin = new DateJsonParserPlugin(["created_at"]); + await plugin.transformResult({ + queryId: createQueryId(), + result: { rows: original }, + }); + // original row object should be untouched (plugin shallow-copies each row) + expect(original[0]?.created_at).toBe("2024-01-01T00:00:00.000Z"); + }); +}); + +// --------------------------------------------------------------------------- +// withParsedDates — runtime behaviour +// --------------------------------------------------------------------------- + +describe("withParsedDates – runtime", () => { + it("execute() returns all rows with dates parsed", async () => { + const rows = await withParsedDates( + makeFakeQuery([ + { id: 1, items: [{ created_at: "2024-01-01T00:00:00.000Z" }] }, + { id: 2, items: [{ created_at: "2024-06-01T00:00:00.000Z" }] }, + ]), + ["items.created_at"], + ).execute(); + + expect(rows).toHaveLength(2); + expect(rows[0]?.items[0]?.created_at).toBeInstanceOf(Date); + expect(rows[1]?.items[0]?.created_at).toBeInstanceOf(Date); + }); + + it("executeTakeFirst() returns the first row with date parsed", async () => { + const row = await withParsedDates( + makeFakeQuery([ + { items: [{ created_at: "2024-01-01T00:00:00.000Z" }] }, + { items: [{ created_at: "2099-01-01T00:00:00.000Z" }] }, + ]), + ["items.created_at"], + ).executeTakeFirst(); + + expect(row).toBeDefined(); + expect(row?.items[0]?.created_at).toBeInstanceOf(Date); + expect(row?.items[0]?.created_at.toISOString()).toBe( + "2024-01-01T00:00:00.000Z", + ); + }); + + it("executeTakeFirst() returns undefined when there are no rows", async () => { + const row = await withParsedDates( + makeFakeQuery<{ items: { created_at: string }[] }>([]), + ["items.created_at"], + ).executeTakeFirst(); + + expect(row).toBeUndefined(); + }); + + it("executeTakeFirstOrThrow() returns the first row with date parsed", async () => { + const row = await withParsedDates( + makeFakeQuery([{ items: [{ created_at: "2024-03-23T00:00:00.000Z" }] }]), + ["items.created_at"], + ).executeTakeFirstOrThrow(); + + expect(row.items[0]?.created_at).toBeInstanceOf(Date); + expect(row.items[0]?.created_at.toISOString()).toBe( + "2024-03-23T00:00:00.000Z", + ); + }); + + it("executeTakeFirstOrThrow() throws NoResultError by default when empty", async () => { + await expect( + withParsedDates(makeFakeQuery<{ items: { created_at: string }[] }>([]), [ + "items.created_at", + ]).executeTakeFirstOrThrow(), + ).rejects.toBeInstanceOf(NoResultError); + }); + + it("executeTakeFirstOrThrow() throws a custom error class when provided", async () => { + await expect( + withParsedDates(makeFakeQuery<{ items: { created_at: string }[] }>([]), [ + "items.created_at", + ]).executeTakeFirstOrThrow(TestNoResultError), + ).rejects.toBeInstanceOf(TestNoResultError); + }); + + it("executeTakeFirstOrThrow() custom error has the expected message", async () => { + await expect( + withParsedDates(makeFakeQuery<{ items: { created_at: string }[] }>([]), [ + "items.created_at", + ]).executeTakeFirstOrThrow(TestNoResultError), + ).rejects.toThrow("No result (test)"); + }); +}); + +// --------------------------------------------------------------------------- +// withParsedDates — type-level tests (compile-time only, no runtime assertions) +// --------------------------------------------------------------------------- + +describe("withParsedDates – types", () => { + it("transforms string → Date for a nested array field", () => { + const fakeRows: { items: { created_at: string }[] }[] = []; + const query = withParsedDates(makeFakeQuery(fakeRows), [ + "items.created_at", + ]); + type Result = Awaited>; + expectTypeOf().toEqualTypeOf<{ items: { created_at: Date }[] }[]>(); + }); + + it("preserves unrelated fields unchanged", () => { + const fakeRows: { + id: number; + items: { created_at: string; name: string }[]; + }[] = []; + const query = withParsedDates(makeFakeQuery(fakeRows), [ + "items.created_at", + ]); + type Result = Awaited>; + expectTypeOf().toEqualTypeOf< + { id: number; items: { created_at: Date; name: string }[] }[] + >(); + }); + + it("executeTakeFirst() returns Row | undefined", () => { + const fakeRows: { items: { created_at: string }[] }[] = []; + const query = withParsedDates(makeFakeQuery(fakeRows), [ + "items.created_at", + ]); + type Result = Awaited>; + expectTypeOf().toEqualTypeOf< + { items: { created_at: Date }[] } | undefined + >(); + }); + + it("executeTakeFirstOrThrow() returns Row (never undefined)", () => { + const fakeRows: { items: { created_at: string }[] }[] = []; + const query = withParsedDates(makeFakeQuery(fakeRows), [ + "items.created_at", + ]); + type Result = Awaited>; + expectTypeOf().toEqualTypeOf<{ items: { created_at: Date }[] }>(); + }); + + it("WithParsedDates utility type transforms correctly", () => { + type Input = { items: { created_at: string; id: number }[] }; + type Output = WithParsedDates; + expectTypeOf().toEqualTypeOf<{ + items: { created_at: Date; id: number }[]; + }>(); + }); + + it("WithParsedDates preserves null unions on the array field", () => { + type Input = { items: { created_at: string }[] | null }; + type Output = WithParsedDates; + expectTypeOf().toEqualTypeOf<{ + items: { created_at: Date }[] | null; + }>(); + }); + + it("type-checks against real DummyDriver Kysely instance", () => { + const query = withParsedDates( + db + .selectFrom("box") + .select((eb) => [ + jsonArrayFrom( + eb + .selectFrom("item") + .selectAll() + .whereRef("item.box_id", "=", "box.id"), + ).as("items"), + ]), + ["items.created_at"], + ); + // created_at inside items must be Date, not string — compile-time check only + type Result = Awaited>; + expectTypeOf().toExtend<{ items: { created_at: Date }[] }[]>(); + }); +}); diff --git a/apps/infoalloggi/src/lib/dateParserJsonKysely.ts b/apps/infoalloggi/src/lib/dateParserJsonKysely.ts new file mode 100644 index 0000000..db8bc39 --- /dev/null +++ b/apps/infoalloggi/src/lib/dateParserJsonKysely.ts @@ -0,0 +1,168 @@ +import type { + KyselyPlugin, + NoResultErrorConstructor, + PluginTransformQueryArgs, + PluginTransformResultArgs, + QueryNode, + QueryResult, + RootOperationNode, + UnknownRow, +} from "kysely"; + +// --- Type helpers --- + +/** Converts `string` (and nullable/undefinable variants) to `Date` variants. */ +type StringToDate = T extends string ? Date : T; + +/** + * Given type T and a dot-separated path, returns T with the field at that + * path converted from `string` to `Date`. Automatically traverses arrays. + */ +type SetNestedPath< + T, + Path extends string, +> = Path extends `${infer Key}.${infer Rest}` + ? { + [K in keyof T]: K extends Key + ? NonNullable extends (infer U)[] + ? + | SetNestedPath[] + | (null extends T[K] ? null : never) + | (undefined extends T[K] ? undefined : never) + : + | SetNestedPath, Rest> + | (null extends T[K] ? null : never) + | (undefined extends T[K] ? undefined : never) + : T[K]; + } + : { + [K in keyof T]: K extends Path ? StringToDate : T[K]; + }; + +/** Applies all date-path transformations in the Paths tuple to type T. */ +export type WithParsedDates< + T, + Paths extends readonly string[], +> = Paths extends readonly [ + infer Head extends string, + ...infer Tail extends string[], +] + ? WithParsedDates, Tail> + : T; + +// --- Runtime --- + +type ParsedPath = string[]; + +function applyDatePath(obj: Record, path: ParsedPath): void { + const [key, ...rest] = path; + if (key === undefined) return; + + const value = obj[key]; + + if (rest.length === 0) { + if (typeof value === "string") { + obj[key] = new Date(value); + } + return; + } + + if (Array.isArray(value)) { + for (const item of value) { + if (item !== null && typeof item === "object") { + applyDatePath(item as Record, rest); + } + } + } else if ( + value !== null && + value !== undefined && + typeof value === "object" + ) { + applyDatePath(value as Record, rest); + } +} + +/** + * Kysely plugin that converts date strings to `Date` objects for fields + * returned inside JSON (e.g. from `jsonArrayFrom` / `jsonObjectFrom`). + * + * Note: `withPlugin` does not transform TypeScript types. + * Use `executeWithParsedDates` for a fully type-safe result. + */ +export class DateJsonParserPlugin + implements KyselyPlugin +{ + private readonly parsedPaths: ParsedPath[]; + + constructor(readonly paths: Paths) { + this.parsedPaths = paths.map((p) => p.split(".")); + } + + transformQuery(args: PluginTransformQueryArgs): RootOperationNode { + return args.node; + } + + async transformResult( + args: PluginTransformResultArgs, + ): Promise> { + return { + ...args.result, + rows: args.result.rows.map((row) => { + const newRow = { ...row } as Record; + for (const path of this.parsedPaths) { + applyDatePath(newRow, path); + } + return newRow; + }), + }; + } +} + +type NoResultErrorArg = NoResultErrorConstructor | ((node: QueryNode) => Error); + +type PluggedQuery = { + execute(): Promise; + executeTakeFirst(): Promise; + executeTakeFirstOrThrow(errorConstructor?: NoResultErrorArg): Promise; +}; + +/** + * Wraps a Kysely query with date string → `Date` parsing, exposing + * `execute`, `executeTakeFirst`, and `executeTakeFirstOrThrow` with + * correctly inferred TypeScript types. + * + * @example + * const rows = await withParsedDates( + * db + * .selectFrom("box") + * .select((eb) => [ + * jsonArrayFrom( + * eb.selectFrom("item").selectAll().whereRef("item.box_id", "=", "box.id"), + * ).as("items"), + * ]), + * ["items.created_at"], + * ).execute(); + * // rows: { items: { id: number; box_id: number; created_at: Date }[] }[] + */ +export function withParsedDates( + query: { + withPlugin(plugin: KyselyPlugin): PluggedQuery; + }, + paths: Paths, +): { + execute(): Promise[]>; + executeTakeFirst(): Promise | undefined>; + executeTakeFirstOrThrow( + errorConstructor?: NoResultErrorArg, + ): Promise>; +} { + type Out = WithParsedDates; + const plugged = query.withPlugin(new DateJsonParserPlugin(paths)); + return { + execute: () => plugged.execute() as Promise, + executeTakeFirst: () => + plugged.executeTakeFirst() as Promise, + executeTakeFirstOrThrow: (errorConstructor) => + plugged.executeTakeFirstOrThrow(errorConstructor) as Promise, + }; +}