Files
WRNexusJS/packages/db/src/query.ts
T
Clintchiz 2c960fc1dc
Quality / quality (ubuntu-latest) (push) Failing after 9m49s
Quality / quality (windows-latest) (push) Canceled after 0s
refactor: migrate legacy wire namespace to wrn
2026-08-12 18:51:15 +05:30

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;
}