169 lines
4.5 KiB
TypeScript
169 lines
4.5 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;
|
||
|
|
|
||
|
|
// --- 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>,
|
||
|
|
};
|
||
|
|
}
|