/** * 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 { 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( db: Db, query: { sql: string; params?: unknown[]; countSql?: string; model?: Model }, opts: PageOptions = {}, ): Promise> { 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( `${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 { /** 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; } /** * 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

( db: Db, parents: P[], opts: RelationOptions, ): Promise<(P & Record)[]> { const localKey = opts.localKey ?? "id"; assertIdent(opts.table, "table name"); assertIdent(opts.foreignKey, "foreign key"); const results = parents as (P & Record)[]; 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)[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( `SELECT * FROM ${opts.table} WHERE ${opts.foreignKey} IN (${placeholders})`, keys, opts.model, ); const grouped = new Map(); 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)[opts.as] = opts.single ? (matches[0] ?? null) : matches; } return results; }