feat: add DateJsonParserPlugin to convert date strings to Date objects in Kysely queries

This commit is contained in:
Marco Pedone 2026-03-23 19:32:32 +01:00
parent 1d1051f25a
commit d306548209
2 changed files with 565 additions and 0 deletions

View file

@ -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<BoxTable>;
export type Item = Selectable<ItemTable>;
export type NewItem = Insertable<ItemTable>;
export type ItemUpdate = Updateable<ItemTable>;
// ---------------------------------------------------------------------------
// DummyDriver db — used only for compile-time type tests
// ---------------------------------------------------------------------------
const db = new Kysely<Database>({
dialect: {
createAdapter: () => new PostgresAdapter(),
createDriver: () => new DummyDriver(),
// biome-ignore lint/suspicious/noExplicitAny: required by Kysely dialect contract
createIntrospector: (db: Kysely<any>) => 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<T>(rows: T[]) {
return {
withPlugin(plugin: KyselyPlugin) {
async function run(): Promise<T[]> {
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<ReturnType<typeof query.execute>>;
expectTypeOf<Result>().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<ReturnType<typeof query.execute>>;
expectTypeOf<Result>().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<ReturnType<typeof query.executeTakeFirst>>;
expectTypeOf<Result>().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<ReturnType<typeof query.executeTakeFirstOrThrow>>;
expectTypeOf<Result>().toEqualTypeOf<{ items: { created_at: Date }[] }>();
});
it("WithParsedDates utility type transforms correctly", () => {
type Input = { items: { created_at: string; id: number }[] };
type Output = WithParsedDates<Input, ["items.created_at"]>;
expectTypeOf<Output>().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<Input, ["items.created_at"]>;
expectTypeOf<Output>().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<ReturnType<typeof query.execute>>;
expectTypeOf<Result>().toExtend<{ items: { created_at: Date }[] }[]>();
});
});

View file

@ -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> = 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<T[K]> extends (infer U)[]
?
| SetNestedPath<U, Rest>[]
| (null extends T[K] ? null : never)
| (undefined extends T[K] ? undefined : never)
:
| SetNestedPath<NonNullable<T[K]>, 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]> : 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<SetNestedPath<T, Head>, Tail>
: T;
// --- Runtime ---
type ParsedPath = string[];
function applyDatePath(obj: Record<string, unknown>, 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<string, unknown>, rest);
}
}
} else if (
value !== null &&
value !== undefined &&
typeof value === "object"
) {
applyDatePath(value as Record<string, unknown>, 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<const Paths extends readonly string[]>
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<QueryResult<UnknownRow>> {
return {
...args.result,
rows: args.result.rows.map((row) => {
const newRow = { ...row } as Record<string, unknown>;
for (const path of this.parsedPaths) {
applyDatePath(newRow, path);
}
return newRow;
}),
};
}
}
type NoResultErrorArg = NoResultErrorConstructor | ((node: QueryNode) => Error);
type PluggedQuery<T> = {
execute(): Promise<T[]>;
executeTakeFirst(): Promise<T | undefined>;
executeTakeFirstOrThrow(errorConstructor?: NoResultErrorArg): Promise<T>;
};
/**
* 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<const Paths extends readonly string[], T>(
query: {
withPlugin(plugin: KyselyPlugin): PluggedQuery<T>;
},
paths: Paths,
): {
execute(): Promise<WithParsedDates<T, Paths>[]>;
executeTakeFirst(): Promise<WithParsedDates<T, Paths> | undefined>;
executeTakeFirstOrThrow(
errorConstructor?: NoResultErrorArg,
): Promise<WithParsedDates<T, Paths>>;
} {
type Out = WithParsedDates<T, Paths>;
const plugged = query.withPlugin(new DateJsonParserPlugin(paths));
return {
execute: () => plugged.execute() as Promise<Out[]>,
executeTakeFirst: () =>
plugged.executeTakeFirst() as Promise<Out | undefined>,
executeTakeFirstOrThrow: (errorConstructor) =>
plugged.executeTakeFirstOrThrow(errorConstructor) as Promise<Out>,
};
}