first commit
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* 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 __wire_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;
|
||||
}
|
||||
Reference in New Issue
Block a user