@rindle/query-compiler 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +42 -0
  3. package/dist/ast.d.ts +2 -0
  4. package/dist/ast.d.ts.map +1 -0
  5. package/dist/ast.js +2 -0
  6. package/dist/ast.js.map +1 -0
  7. package/dist/catalog.d.ts +51 -0
  8. package/dist/catalog.d.ts.map +1 -0
  9. package/dist/catalog.js +7 -0
  10. package/dist/catalog.js.map +1 -0
  11. package/dist/complete-ordering.d.ts +3 -0
  12. package/dist/complete-ordering.d.ts.map +1 -0
  13. package/dist/complete-ordering.js +44 -0
  14. package/dist/complete-ordering.js.map +1 -0
  15. package/dist/dialect.d.ts +49 -0
  16. package/dist/dialect.d.ts.map +1 -0
  17. package/dist/dialect.js +35 -0
  18. package/dist/dialect.js.map +1 -0
  19. package/dist/errors.d.ts +13 -0
  20. package/dist/errors.d.ts.map +1 -0
  21. package/dist/errors.js +26 -0
  22. package/dist/errors.js.map +1 -0
  23. package/dist/index.d.ts +31 -0
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.js +34 -0
  26. package/dist/index.js.map +1 -0
  27. package/dist/pg-types.d.ts +20 -0
  28. package/dist/pg-types.d.ts.map +1 -0
  29. package/dist/pg-types.js +107 -0
  30. package/dist/pg-types.js.map +1 -0
  31. package/dist/postgres.d.ts +7 -0
  32. package/dist/postgres.d.ts.map +1 -0
  33. package/dist/postgres.js +134 -0
  34. package/dist/postgres.js.map +1 -0
  35. package/dist/reject.d.ts +3 -0
  36. package/dist/reject.d.ts.map +1 -0
  37. package/dist/reject.js +74 -0
  38. package/dist/reject.js.map +1 -0
  39. package/dist/walker.d.ts +11 -0
  40. package/dist/walker.d.ts.map +1 -0
  41. package/dist/walker.js +350 -0
  42. package/dist/walker.js.map +1 -0
  43. package/package.json +39 -0
  44. package/src/ast.ts +18 -0
  45. package/src/catalog.ts +59 -0
  46. package/src/complete-ordering.ts +48 -0
  47. package/src/dialect.ts +85 -0
  48. package/src/errors.ts +30 -0
  49. package/src/index.ts +74 -0
  50. package/src/pg-types.ts +119 -0
  51. package/src/postgres.ts +152 -0
  52. package/src/reject.ts +71 -0
  53. package/src/walker.ts +418 -0
package/src/walker.ts ADDED
@@ -0,0 +1,418 @@
1
+ // The compiler core — a syntax-directed `Ast → SELECT` lowering, ported one-to-one from
2
+ // `rindle-d2s` (rust/rindle-d2s/src/lib.rs) and bound to our exact wire `Ast`. The one
3
+ // deliberate change from `rindle-d2s` is that filter/paging **values are parameterized**
4
+ // (pushed onto `params`, emitted as placeholders by the dialect) instead of inlined — so the
5
+ // output is injection-safe and plan-cacheable (Invariant 4). Everything else — the walker
6
+ // structure, ordering completion, keyset-paging predicate, empty-fold — matches the oracle.
7
+
8
+ import type {
9
+ Ast,
10
+ Bound,
11
+ Condition,
12
+ CorrelatedSubquery,
13
+ Correlation,
14
+ LitValue,
15
+ OrderPart,
16
+ ValuePosition,
17
+ } from "./ast.ts";
18
+ import type { Catalog, ColumnType } from "./catalog.ts";
19
+ import type { Dialect, LitScalar } from "./dialect.ts";
20
+ import { completeOrdering } from "./complete-ordering.ts";
21
+ import { missingAlias, unknownRelationship, unknownTable } from "./errors.ts";
22
+ import { rejectUnsupported } from "./reject.ts";
23
+
24
+ /** A `simple` comparison condition. */
25
+ type SimpleCond = Extract<Condition, { type: "simple" }>;
26
+ /** An EXISTS/NOT-EXISTS condition. */
27
+ type ExistsCond = Extract<Condition, { type: "correlatedSubquery" }>;
28
+
29
+ /** The table + alias a condition/projection is evaluated against (for column-type lookup). */
30
+ interface Scope {
31
+ table: string;
32
+ alias: string;
33
+ }
34
+
35
+ export interface Compiled {
36
+ sql: string;
37
+ params: unknown[];
38
+ }
39
+
40
+ /** Compile `ast` against `catalog` for `dialect`. `singular` ⇒ a `.one()` root (single
41
+ * object or `null`); else a plural root (a JSON array). Pure: mutates only a clone. */
42
+ export function compileWith(
43
+ ast: Ast,
44
+ catalog: Catalog,
45
+ dialect: Dialect,
46
+ singular: boolean,
47
+ ): Compiled {
48
+ rejectUnsupported(ast);
49
+ const cloned = structuredClone(ast);
50
+ completeOrdering(cloned, (t) => pkOf(catalog, t));
51
+ const cx = new Cx(catalog, dialect);
52
+ const expr = cx.collection(cloned, singular, null);
53
+ return { sql: `SELECT ${expr} AS "rindle_result"`, params: cx.params };
54
+ }
55
+
56
+ /** Compile context: the catalog, dialect, the accumulating params, and an alias counter. */
57
+ class Cx {
58
+ readonly params: unknown[] = [];
59
+ #n = 0;
60
+ // Explicit fields (not constructor parameter properties): Node's strip-only type stripping
61
+ // does not support parameter properties, and this source runs directly from `.ts`.
62
+ readonly #catalog: Catalog;
63
+ readonly #d: Dialect;
64
+
65
+ constructor(catalog: Catalog, d: Dialect) {
66
+ this.#catalog = catalog;
67
+ this.#d = d;
68
+ }
69
+
70
+ #alias(table: string): string {
71
+ return `${table}_${this.#n++}`;
72
+ }
73
+
74
+ /** A scalar-subquery expression producing the JSON for `ast` as a single object
75
+ * (`singular`) or a `json_group_array`/`jsonb_agg` of objects, correlated to a parent
76
+ * when `correlate` is set (`[parentAlias, Correlation]`). */
77
+ collection(ast: Ast, singular: boolean, correlate: [string, Correlation] | null): string {
78
+ const table = ast.table;
79
+ const cols = columnsOf(this.#catalog, table);
80
+ const alias = this.#alias(table);
81
+ const scope: Scope = { table, alias };
82
+ const row = this.#rowObject(ast, scope, cols);
83
+
84
+ const conds: string[] = [];
85
+ if (correlate) conds.push(correlateSql(this.#d, correlate[0], alias, correlate[1]));
86
+ if (ast.where) conds.push(this.#whereSql(ast.where, scope));
87
+ // Keyset paging: keep rows at/after `start` under the already-PK-completed sort — the
88
+ // exact comparator the engine's `Skip` uses.
89
+ if (ast.start) conds.push(this.#startSql(ast.start, ast.orderBy ?? [], scope));
90
+
91
+ const from = `${this.#d.quoteIdent(table)} AS ${this.#d.quoteIdent(alias)}`;
92
+ return this.#pluralOrSingular(singular, row, from, whereClause(conds), ast.orderBy ?? [], alias, ast.limit);
93
+ }
94
+
95
+ /** `json_object('c', a."c", …, 'rel', <child expr>, …)`. A column shadowed by a
96
+ * same-named relationship is dropped (the relationship wins, as in the View). */
97
+ #rowObject(ast: Ast, scope: Scope, cols: string[]): string {
98
+ const relNames = new Set(
99
+ (ast.related ?? []).map((r) => r.subquery.alias).filter((a): a is string => a != null),
100
+ );
101
+ const selected = ast.select ? new Set(ast.select) : null;
102
+
103
+ const parts: string[] = [];
104
+ for (const col of cols) {
105
+ if (relNames.has(col)) continue;
106
+ if (selected && !selected.has(col)) continue;
107
+ const colRef = `${this.#d.quoteIdent(scope.alias)}.${this.#d.quoteIdent(col)}`;
108
+ const projected = this.#d.projectColumn(colRef, columnTypeOf(this.#catalog, scope.table, col));
109
+ parts.push(`${jsonKey(col)}, ${projected}`);
110
+ }
111
+ for (const rel of ast.related ?? []) {
112
+ const name = rel.subquery.alias;
113
+ if (name == null) throw missingAlias(rel.subquery.table);
114
+ // A relationship aggregate (`count(child)`) is a scalar column, not a nested collection.
115
+ const expr =
116
+ rel.subquery.aggregate === "count"
117
+ ? this.#countExpr(rel, scope.alias)
118
+ : this.#relationExpr(scope.table, rel, scope.alias);
119
+ parts.push(`${jsonKey(name)}, ${expr}`);
120
+ }
121
+ return this.#d.objectBuild(parts);
122
+ }
123
+
124
+ /** One materialized relationship as a nested-JSON column. */
125
+ #relationExpr(parentTable: string, rel: CorrelatedSubquery, parentAlias: string): string {
126
+ const name = rel.subquery.alias;
127
+ if (name == null) throw missingAlias(rel.subquery.table);
128
+ const singular = cardinalityOf(this.#catalog, parentTable, name) === "one";
129
+ return this.collection(rel.subquery, singular, [parentAlias, rel.correlation]);
130
+ }
131
+
132
+ /** A scalar `count(*)` for a relationship aggregate: `(SELECT count(*) FROM child AS a
133
+ * WHERE <correlation> [AND <child.where>])`. `0` when no child matches. */
134
+ #countExpr(rel: CorrelatedSubquery, parentAlias: string): string {
135
+ const sub = rel.subquery;
136
+ const alias = this.#alias(sub.table);
137
+ const conds = [correlateSql(this.#d, parentAlias, alias, rel.correlation)];
138
+ if (sub.where) conds.push(this.#whereSql(sub.where, { table: sub.table, alias }));
139
+ return `(SELECT count(*) FROM ${this.#d.quoteIdent(sub.table)} AS ${this.#d.quoteIdent(alias)} WHERE ${conds.join(" AND ")})`;
140
+ }
141
+
142
+ /** A boolean SQL expression for a `where` condition tree. */
143
+ #whereSql(cond: Condition, scope: Scope): string {
144
+ switch (cond.type) {
145
+ case "and":
146
+ return cond.conditions.length === 0
147
+ ? this.#d.trueLit
148
+ : `(${cond.conditions.map((c) => this.#whereSql(c, scope)).join(" AND ")})`;
149
+ case "or":
150
+ return cond.conditions.length === 0
151
+ ? this.#d.falseLit
152
+ : `(${cond.conditions.map((c) => this.#whereSql(c, scope)).join(" OR ")})`;
153
+ case "correlatedSubquery":
154
+ return this.#existsSql(cond, scope.alias);
155
+ case "simple":
156
+ return this.#simpleSql(cond, scope);
157
+ }
158
+ }
159
+
160
+ /** `[NOT] EXISTS (SELECT 1 FROM child AS a WHERE <correlation> [AND <child.where>])`.
161
+ * `flip`/`scalar` are IVM routing flags with no effect on the SQL. */
162
+ #existsSql(c: ExistsCond, parentAlias: string): string {
163
+ const sub = c.related.subquery;
164
+ const alias = this.#alias(sub.table);
165
+ const conds = [correlateSql(this.#d, parentAlias, alias, c.related.correlation)];
166
+ if (sub.where) conds.push(this.#whereSql(sub.where, { table: sub.table, alias }));
167
+ const inner = `SELECT 1 FROM ${this.#d.quoteIdent(sub.table)} AS ${this.#d.quoteIdent(alias)} WHERE ${conds.join(" AND ")}`;
168
+ return c.op === "EXISTS" ? `EXISTS (${inner})` : `NOT EXISTS (${inner})`;
169
+ }
170
+
171
+ /** A single comparison. Operators render verbatim except: `IS`/`IS NOT` render the RHS as
172
+ * a SQL keyword (`NULL`/`TRUE`/`FALSE`) not a param; `ILIKE` folds via `lower()`; `IN`/
173
+ * `NOT IN` bind the list (empty ⇒ the constant truth value). */
174
+ #simpleSql(sc: SimpleCond, scope: Scope): string {
175
+ // A literal is bound with the *other* side's column type when that side is a column
176
+ // (drives Postgres casts); `null` ⇒ infer from the JS type.
177
+ const leftColType = this.#colTypeOf(scope, sc.left);
178
+ const rightColType = this.#colTypeOf(scope, sc.right);
179
+ const l = this.#valuePos(scope, sc.left, rightColType);
180
+ const r = () => this.#valuePos(scope, sc.right, leftColType);
181
+ switch (sc.op) {
182
+ case "=":
183
+ return `${l} = ${r()}`;
184
+ case "!=":
185
+ return `${l} != ${r()}`;
186
+ case "<":
187
+ return `${l} < ${r()}`;
188
+ case "<=":
189
+ return `${l} <= ${r()}`;
190
+ case ">":
191
+ return `${l} > ${r()}`;
192
+ case ">=":
193
+ return `${l} >= ${r()}`;
194
+ case "IS":
195
+ return `${l} IS ${this.#isRhs(scope, sc.right)}`;
196
+ case "IS NOT":
197
+ return `${l} IS NOT ${this.#isRhs(scope, sc.right)}`;
198
+ case "LIKE":
199
+ return `${l} LIKE ${r()} ESCAPE '\\'`;
200
+ case "NOT LIKE":
201
+ return `${l} NOT LIKE ${r()} ESCAPE '\\'`;
202
+ case "ILIKE":
203
+ return `lower(${l}) LIKE lower(${r()}) ESCAPE '\\'`;
204
+ case "NOT ILIKE":
205
+ return `lower(${l}) NOT LIKE lower(${r()}) ESCAPE '\\'`;
206
+ case "IN":
207
+ return this.#inSql(scope, l, sc.right, false, leftColType);
208
+ case "NOT IN":
209
+ return this.#inSql(scope, l, sc.right, true, leftColType);
210
+ }
211
+ }
212
+
213
+ /** The RHS of `IS`/`IS NOT` — a SQL keyword position (Postgres forbids a param here), so
214
+ * `NULL`/`TRUE`/`FALSE` render literally. Any other RHS (unusual) falls back to a value. */
215
+ #isRhs(scope: Scope, vp: ValuePosition): string {
216
+ if (vp.type === "literal") {
217
+ if (vp.value === null) return "NULL";
218
+ if (vp.value === true) return this.#d.trueLit;
219
+ if (vp.value === false) return this.#d.falseLit;
220
+ }
221
+ return this.#valuePos(scope, vp, null);
222
+ }
223
+
224
+ /** The column type of `vp` when it is a column reference; `null` for a literal. */
225
+ #colTypeOf(scope: Scope, vp: ValuePosition): ColumnType | null {
226
+ return vp.type === "column" ? columnTypeOf(this.#catalog, scope.table, vp.name) : null;
227
+ }
228
+
229
+ /** A column reference (`a."c"`) or a bound literal (`?` / `$N::text::<type>`). `siblingCol`
230
+ * is the compared column's type, used when `vp` is a literal. */
231
+ #valuePos(scope: Scope, vp: ValuePosition, siblingCol: ColumnType | null): string {
232
+ if (vp.type === "column") {
233
+ return `${this.#d.quoteIdent(scope.alias)}.${this.#d.quoteIdent(vp.name)}`;
234
+ }
235
+ return this.#d.bindValue(this.params, vp.value as LitScalar, siblingCol, true);
236
+ }
237
+
238
+ /** `lhs [NOT] IN (v0, v1, …)`. An empty list folds to the constant truth value; a non-array
239
+ * RHS falls back to `=`/`!=` (as `rindle-d2s`'s `in_sql`). */
240
+ #inSql(
241
+ scope: Scope,
242
+ lhs: string,
243
+ right: ValuePosition,
244
+ negate: boolean,
245
+ lhsCol: ColumnType | null,
246
+ ): string {
247
+ if (right.type === "literal" && Array.isArray(right.value)) {
248
+ const items = right.value;
249
+ if (items.length === 0) return negate ? this.#d.trueLit : this.#d.falseLit;
250
+ const list = items
251
+ .map((it) => this.#d.bindValue(this.params, it as LitScalar, lhsCol, true))
252
+ .join(", ");
253
+ return `${lhs} ${negate ? "NOT IN" : "IN"} (${list})`;
254
+ }
255
+ const rr = this.#valuePos(scope, right, lhsCol);
256
+ return negate ? `${lhs} != ${rr}` : `${lhs} = ${rr}`;
257
+ }
258
+
259
+ /** Emit the plural (`json_group_array`/`jsonb_agg`) or singular (`… LIMIT 1`) form. When a
260
+ * `LIMIT` is present the rows are selected (ordered + limited) in an inner subquery and
261
+ * then array-aggregated in declared order, so both the top-N selection and the array order
262
+ * are correct. */
263
+ #pluralOrSingular(
264
+ singular: boolean,
265
+ row: string,
266
+ from: string,
267
+ where: string,
268
+ order: OrderPart[],
269
+ alias: string,
270
+ limit: number | undefined,
271
+ ): string {
272
+ if (singular) {
273
+ return `(SELECT ${row} FROM ${from}${where}${this.#orderClause(order, alias)} LIMIT 1)`;
274
+ }
275
+ if (limit !== undefined) {
276
+ const innerOrder = this.#orderClause(order, alias);
277
+ const proj = this.#orderProj(order, alias);
278
+ const agg = this.#aggOrderProjected(order);
279
+ // Re-assert the JSON subtype: the object round-trips through the inner subquery's
280
+ // `AS __r` column, which SQLite would otherwise re-encode as a string.
281
+ const elem = this.#d.reassertJson("__r");
282
+ const lim = Math.max(0, Math.trunc(Number(limit)));
283
+ return `COALESCE((SELECT ${this.#d.groupArray(elem, agg)} FROM (SELECT ${row} AS __r${proj} FROM ${from}${where}${innerOrder} LIMIT ${lim})), ${this.#d.emptyArray})`;
284
+ }
285
+ const agg = this.#aggOrderInline(order, alias);
286
+ return `COALESCE((SELECT ${this.#d.groupArray(row, agg)} FROM ${from}${where}), ${this.#d.emptyArray})`;
287
+ }
288
+
289
+ /** ` ORDER BY a."c0" ASC, a."c1" DESC` (empty when `order` is empty). */
290
+ #orderClause(order: OrderPart[], alias: string): string {
291
+ return order.length === 0 ? "" : ` ORDER BY ${this.#orderTerms(order, alias)}`;
292
+ }
293
+
294
+ /** In-aggregate ordering referencing the FROM columns. */
295
+ #aggOrderInline(order: OrderPart[], alias: string): string {
296
+ return order.length === 0 ? "" : ` ORDER BY ${this.#orderTerms(order, alias)}`;
297
+ }
298
+
299
+ /** In-aggregate ordering over the inner subquery's projected order columns (`__o0`, …). */
300
+ #aggOrderProjected(order: OrderPart[]): string {
301
+ if (order.length === 0) return "";
302
+ return ` ORDER BY ${order.map(([, dir], i) => `__o${i} ${this.#d.orderDir(dir)}`).join(", ")}`;
303
+ }
304
+
305
+ /** Project the order columns into the inner subquery: `, a."c0" AS __o0, …`. */
306
+ #orderProj(order: OrderPart[], alias: string): string {
307
+ return order
308
+ .map(([field], i) => `, ${this.#d.quoteIdent(alias)}.${this.#d.quoteIdent(field)} AS __o${i}`)
309
+ .join("");
310
+ }
311
+
312
+ #orderTerms(order: OrderPart[], alias: string): string {
313
+ return order
314
+ .map(([field, dir]) => `${this.#d.quoteIdent(alias)}.${this.#d.quoteIdent(field)} ${this.#d.orderDir(dir)}`)
315
+ .join(", ");
316
+ }
317
+
318
+ /** The keyset paging predicate for a `start` bound: keep a row iff the bound sorts before
319
+ * it (`B < R`), or sorts equal with an inclusive bound (`B <= R`), under the already-PK-
320
+ * completed sort. Lexicographic with **null-low** ordering (NULL before any value on ASC,
321
+ * after every value on DESC) — faithful to the engine's `Skip`. */
322
+ #startSql(start: Bound, order: OrderPart[], scope: Scope): string {
323
+ // Per sort column: the column ref, direction, bound value (absent/NULL ⇒ null-low), type.
324
+ const cols = order.map(([field, dir]) => ({
325
+ x: `${this.#d.quoteIdent(scope.alias)}.${this.#d.quoteIdent(field)}`,
326
+ asc: dir === "asc",
327
+ b: nullLowBound(start.row[field]),
328
+ col: columnTypeOf(this.#catalog, scope.table, field),
329
+ }));
330
+ if (cols.length === 0) return this.#d.trueLit; // no sort columns (unreachable post-completion)
331
+
332
+ // `B < R` = OR_i ( AND_{j<i} eq_j AND before_i ). Each `eq`/`before` fragment is
333
+ // (re)emitted exactly where it appears — regenerated, not string-reused — so every
334
+ // placeholder gets its own bound param, in left-to-right order. (`rindle-d2s` can reuse a
335
+ // fragment string because its literals are inlined and side-effect-free; our params are
336
+ // positional, so a reused or discarded fragment would desync placeholders from params.)
337
+ const orTerms: string[] = [];
338
+ for (let i = 0; i < cols.length; i++) {
339
+ const conj: string[] = [];
340
+ for (let j = 0; j < i; j++) conj.push(this.#eqSql(cols[j].x, cols[j].b, cols[j].col));
341
+ conj.push(this.#beforeSql(cols[i].x, cols[i].asc, cols[i].b, cols[i].col));
342
+ orTerms.push(`(${conj.join(" AND ")})`);
343
+ }
344
+ const strictLt = `(${orTerms.join(" OR ")})`;
345
+ if (start.exclusive) return strictLt;
346
+ // `B <= R` adds the all-columns-equal term.
347
+ const allEq = cols.map((c) => this.#eqSql(c.x, c.b, c.col)).join(" AND ");
348
+ return `(${strictLt} OR (${allEq}))`;
349
+ }
350
+
351
+ /** `x` strictly after the bound value `b` in this column's direction, null-low. `b` is
352
+ * `undefined` for an omitted/NULL bound. */
353
+ #beforeSql(x: string, asc: boolean, b: LitScalar | undefined, col: ColumnType | null): string {
354
+ if (b === undefined) {
355
+ // NULL bound: before every non-null `x` on ASC; never before on DESC.
356
+ return asc ? `${x} IS NOT NULL` : this.#d.falseLit;
357
+ }
358
+ const v = this.#d.bindValue(this.params, b, col, true);
359
+ // ASC: `v < x` (NULL `x` ⇒ NULL ⇒ false, i.e. a null row is not after a non-null bound).
360
+ // DESC: a null `x` sorts last, so it is after the bound; else `v > x`.
361
+ return asc ? `${v} < ${x}` : `(${x} IS NULL OR ${v} > ${x})`;
362
+ }
363
+
364
+ /** `x` equal to the bound value `b` under the sort's null-identical equality. */
365
+ #eqSql(x: string, b: LitScalar | undefined, col: ColumnType | null): string {
366
+ if (b === undefined) return `${x} IS NULL`;
367
+ return `${x} = ${this.#d.bindValue(this.params, b, col, true)}`;
368
+ }
369
+ }
370
+
371
+ // ── free helpers (dialect-independent) ────────────────────────────────────────────────────
372
+
373
+ /** `parent."p0" = child."c0" AND parent."p1" = child."c1" …`. */
374
+ function correlateSql(d: Dialect, parentAlias: string, childAlias: string, corr: Correlation): string {
375
+ return corr.parentField
376
+ .map((p, i) => {
377
+ const c = corr.childField[i];
378
+ return `${d.quoteIdent(parentAlias)}.${d.quoteIdent(p)} = ${d.quoteIdent(childAlias)}.${d.quoteIdent(c)}`;
379
+ })
380
+ .join(" AND ");
381
+ }
382
+
383
+ function whereClause(conds: string[]): string {
384
+ return conds.length === 0 ? "" : ` WHERE ${conds.join(" AND ")}`;
385
+ }
386
+
387
+ /** A `json_object`/`jsonb_build_object` key: a single-quoted string literal. */
388
+ function jsonKey(name: string): string {
389
+ return `'${name.replace(/'/g, "''")}'`;
390
+ }
391
+
392
+ /** A paging bound value, with an absent or explicit-NULL value normalized to `undefined`
393
+ * (the null-low sentinel `beforeSql`/`eqSql` treat as "sorts below everything"). */
394
+ function nullLowBound(raw: LitValue | undefined): LitScalar | undefined {
395
+ return raw === undefined || raw === null ? undefined : (raw as LitScalar);
396
+ }
397
+
398
+ // ── catalog accessors ─────────────────────────────────────────────────────────────────────
399
+
400
+ function columnsOf(catalog: Catalog, table: string): string[] {
401
+ const t = catalog.tables[table];
402
+ if (!t) throw unknownTable(table);
403
+ return t.columns;
404
+ }
405
+
406
+ function pkOf(catalog: Catalog, table: string): string[] {
407
+ return catalog.tables[table]?.primaryKey ?? [];
408
+ }
409
+
410
+ function cardinalityOf(catalog: Catalog, table: string, rel: string): "one" | "many" {
411
+ const card = catalog.tables[table]?.relationships[rel];
412
+ if (!card) throw unknownRelationship(table, rel);
413
+ return card;
414
+ }
415
+
416
+ function columnTypeOf(catalog: Catalog, table: string, col: string): ColumnType | null {
417
+ return catalog.tables[table]?.columnTypes[col] ?? null;
418
+ }