390 lines
12 KiB
TypeScript
390 lines
12 KiB
TypeScript
|
|
import type { KyselyPlugin, UnknownRow } from "kysely";
|
|||
|
|
import {
|
|||
|
|
createQueryId,
|
|||
|
|
DummyDriver,
|
|||
|
|
Kysely,
|
|||
|
|
NoResultError,
|
|||
|
|
PostgresAdapter,
|
|||
|
|
PostgresIntrospector,
|
|||
|
|
PostgresQueryCompiler,
|
|||
|
|
} 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;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---------------------------------------------------------------------------
|
|||
|
|
// 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 }[] }[]>();
|
|||
|
|
});
|
|||
|
|
});
|