146 lines
4.6 KiB
TypeScript
146 lines
4.6 KiB
TypeScript
/**
|
|
* Query ergonomics built on the `Db` client: offset pagination and a batched
|
|
* relation loader (avoids N+1). Both are dialect-aware — placeholders follow the
|
|
* driver's style (`$N` for Postgres, `?` for SQLite/MySQL).
|
|
*/
|
|
|
|
import type { Db, Row } from "./driver.ts";
|
|
import type { Model } from "./schema.ts";
|
|
import type { Dialect } from "./sql.ts";
|
|
|
|
const IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
|
|
function placeholder(dialect: Dialect, index: number): string {
|
|
return dialect === "postgres" ? `$${index}` : "?";
|
|
}
|
|
|
|
function assertIdent(name: string, what: string): void {
|
|
if (!IDENT_RE.test(name)) throw new Error(`Unsafe ${what}: ${JSON.stringify(name)}`);
|
|
}
|
|
|
|
// --- Pagination ------------------------------------------------------------
|
|
|
|
export interface PageOptions {
|
|
page?: number;
|
|
perPage?: number;
|
|
/** Upper bound on perPage. Default 100. */
|
|
maxPerPage?: number;
|
|
}
|
|
|
|
export interface Paginated<T> {
|
|
items: T[];
|
|
page: number;
|
|
perPage: number;
|
|
total: number;
|
|
totalPages: number;
|
|
hasNext: boolean;
|
|
hasPrev: boolean;
|
|
}
|
|
|
|
/**
|
|
* Paginate a SELECT. Pass the base query WITHOUT a LIMIT; the helper appends the
|
|
* page window and derives the total with a COUNT over the same query.
|
|
*
|
|
* await paginate(db, { sql: "SELECT * FROM users ORDER BY name", model: users }, { page: 2 })
|
|
*/
|
|
export async function paginate<T = Row>(
|
|
db: Db,
|
|
query: { sql: string; params?: unknown[]; countSql?: string; model?: Model<T> },
|
|
opts: PageOptions = {},
|
|
): Promise<Paginated<T>> {
|
|
const dialect = db.driver.dialect;
|
|
const params = query.params ?? [];
|
|
const maxPerPage = opts.maxPerPage ?? 100;
|
|
const page = Math.max(1, Math.floor(opts.page ?? 1));
|
|
const perPage = Math.min(maxPerPage, Math.max(1, Math.floor(opts.perPage ?? 20)));
|
|
const offset = (page - 1) * perPage;
|
|
|
|
const countSql = query.countSql ?? `SELECT COUNT(*) AS n FROM (${query.sql}) AS __wrn_sub`;
|
|
const countRow = await db.one<{ n: number | string }>(countSql, params);
|
|
const total = Number(countRow?.n ?? 0);
|
|
|
|
const limitPh = placeholder(dialect, params.length + 1);
|
|
const offsetPh = placeholder(dialect, params.length + 2);
|
|
const items = await db.all<T>(
|
|
`${query.sql} LIMIT ${limitPh} OFFSET ${offsetPh}`,
|
|
[...params, perPage, offset],
|
|
query.model,
|
|
);
|
|
|
|
const totalPages = perPage > 0 ? Math.ceil(total / perPage) : 0;
|
|
return {
|
|
items,
|
|
page,
|
|
perPage,
|
|
total,
|
|
totalPages,
|
|
hasNext: page < totalPages,
|
|
hasPrev: page > 1,
|
|
};
|
|
}
|
|
|
|
// --- Relations (batched, no N+1) -------------------------------------------
|
|
|
|
export interface RelationOptions<C> {
|
|
/** Parent field whose value matches the child's foreign key. Default "id". */
|
|
localKey?: string;
|
|
/** Child table to load from. */
|
|
table: string;
|
|
/** Child column that references the parent. */
|
|
foreignKey: string;
|
|
/** Property name to attach on each parent. */
|
|
as: string;
|
|
/** true → attach a single child (belongsTo); false → an array (hasMany). */
|
|
single?: boolean;
|
|
/** Map child rows through a model. */
|
|
model?: Model<C>;
|
|
}
|
|
|
|
/**
|
|
* Load a relation for a set of parent rows in ONE query and attach it to each
|
|
* parent (no N+1). Returns the same parents, each with `opts.as` populated.
|
|
*
|
|
* await loadRelated(db, users, { table: "posts", foreignKey: "userId", as: "posts" })
|
|
*/
|
|
export async function loadRelated<P extends Row, C extends Row = Row>(
|
|
db: Db,
|
|
parents: P[],
|
|
opts: RelationOptions<C>,
|
|
): Promise<(P & Record<string, C | C[] | null>)[]> {
|
|
const localKey = opts.localKey ?? "id";
|
|
assertIdent(opts.table, "table name");
|
|
assertIdent(opts.foreignKey, "foreign key");
|
|
|
|
const results = parents as (P & Record<string, C | C[] | null>)[];
|
|
if (parents.length === 0) return results;
|
|
|
|
const keys = [...new Set(parents.map((p) => p[localKey]).filter((k) => k != null))];
|
|
if (keys.length === 0) {
|
|
for (const parent of results)
|
|
(parent as Record<string, unknown>)[opts.as] = opts.single ? null : [];
|
|
return results;
|
|
}
|
|
|
|
const dialect = db.driver.dialect;
|
|
const placeholders = keys.map((_, i) => placeholder(dialect, i + 1)).join(", ");
|
|
const children = await db.all<C>(
|
|
`SELECT * FROM ${opts.table} WHERE ${opts.foreignKey} IN (${placeholders})`,
|
|
keys,
|
|
opts.model,
|
|
);
|
|
|
|
const grouped = new Map<unknown, C[]>();
|
|
for (const child of children) {
|
|
const fk = (child as Row)[opts.foreignKey];
|
|
const bucket = grouped.get(fk);
|
|
if (bucket) bucket.push(child);
|
|
else grouped.set(fk, [child]);
|
|
}
|
|
|
|
for (const parent of results) {
|
|
const matches = grouped.get(parent[localKey]) ?? [];
|
|
(parent as Record<string, unknown>)[opts.as] = opts.single ? (matches[0] ?? null) : matches;
|
|
}
|
|
return results;
|
|
}
|