189 lines
5.1 KiB
TypeScript
189 lines
5.1 KiB
TypeScript
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;
|
|
|
|
/** Extracts dot-separated paths leading to a `string` or `Date` leaf, auto-traversing arrays. */
|
|
type DateConvertiblePaths<
|
|
T,
|
|
Depth extends unknown[] = [],
|
|
> = Depth["length"] extends 5
|
|
? never
|
|
: T extends Record<string, unknown>
|
|
? {
|
|
[K in keyof T & string]:
|
|
| (NonNullable<T[K]> extends string | Date ? K : never)
|
|
| (NonNullable<T[K]> extends readonly (infer U)[]
|
|
? `${K}.${DateConvertiblePaths<U, [...Depth, unknown]>}`
|
|
: NonNullable<T[K]> extends Record<string, unknown>
|
|
? `${K}.${DateConvertiblePaths<NonNullable<T[K]>, [...Depth, unknown]>}`
|
|
: never);
|
|
}[keyof T & string]
|
|
: never;
|
|
|
|
// --- 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<
|
|
T,
|
|
const Paths extends readonly DateConvertiblePaths<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>,
|
|
};
|
|
}
|