feat: add DateJsonParserPlugin to convert date strings to Date objects in Kysely queries
This commit is contained in:
parent
1d1051f25a
commit
d306548209
2 changed files with 565 additions and 0 deletions
397
apps/infoalloggi/src/lib/dateParserJsonKysely.test.ts
Normal file
397
apps/infoalloggi/src/lib/dateParserJsonKysely.test.ts
Normal 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 }[] }[]>();
|
||||||
|
});
|
||||||
|
});
|
||||||
168
apps/infoalloggi/src/lib/dateParserJsonKysely.ts
Normal file
168
apps/infoalloggi/src/lib/dateParserJsonKysely.ts
Normal 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>,
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue