@ultimat3/entity 11.3.0 → 13.0.0

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.
package/src/pg-sql.ts CHANGED
@@ -4,11 +4,17 @@
4
4
  // declared. That is the whole reason this file exists instead of a template literal per method.
5
5
 
6
6
  import { identifier, join, raw, type SqlFragment, sql } from '@ultimat3/db';
7
- import { columnFor, columnName } from './column';
7
+ import type { AggregateFn } from './aggregate';
8
+ import { AVG_SCALE } from './aggregate';
9
+ import { columnFor } from './column';
10
+ import { isNullableKey, kindOf } from './cursor';
8
11
  import type { EntityCore } from './entity';
9
12
  import { SOFT_DELETE_COLUMN } from './entity';
10
- import { allColumns, columnsOf, physicalName } from './pg-row';
13
+ import { searchUndeclared } from './feature-errors';
14
+ import { microsToIso, seekAlias } from './instant';
15
+ import { allColumns, arrayLiteral, columnsOf, physicalName } from './pg-row';
11
16
  import type { Predicate, QueryPlan, SortKey } from './tenancy';
17
+ import type { ColumnKind } from './types';
12
18
 
13
19
  /** Nothing matches. `in ()` is a syntax error in Postgres, so an empty set needs a constant. */
14
20
  const NEVER = sql`1 = 0`;
@@ -23,7 +29,38 @@ export interface ReadShape {
23
29
  const columnRef = <Row>(entity: EntityCore<Row>, path: string): SqlFragment =>
24
30
  identifier(physicalName(entity, path));
25
31
 
32
+ /**
33
+ * `websearch_to_tsquery`, and the choice is the point of the whole feature.
34
+ *
35
+ * `to_tsquery` reads its argument as tsquery SYNTAX — `&`, `|`, `!`, `<->`, `:*`, parentheses — so
36
+ * a term straight out of a search box is either a `42601` on the first unbalanced paren or, worse,
37
+ * an operator the caller did not write. `plainto_tsquery` is safe but ANDs every word and throws
38
+ * the user's own operators away silently. `websearch_to_tsquery` is the parser Postgres ships for
39
+ * untrusted input: it never raises a syntax error, and it gives `"quoted phrase"`, `or` and a
40
+ * leading `-` the meaning every search box on the web already has. Cats & dogs is three terms.
41
+ *
42
+ * The term crosses as a BOUND PARAMETER either way — nothing here interpolates it — so the choice
43
+ * is not what stops an injection; the parameter is. What the parser decides is whether the user's
44
+ * punctuation is read as syntax, which is the second half of the same question.
45
+ *
46
+ * The configuration is spliced through `raw()`, from `SEARCH_LANGUAGES`, exactly as `asc|desc` is:
47
+ * a closed set of one word, chosen by this file from the ENTITY's declaration and never from a
48
+ * value on the wire. `regconfig` cannot be a bound parameter and stay index-matchable anyway.
49
+ */
50
+ const searchSql = <Row>(entity: EntityCore<Row>, predicate: Predicate): SqlFragment => {
51
+ const vector = entity.$search;
52
+ if (vector === null) throw searchUndeclared(entity.$name);
53
+ const column = identifier(vector.column);
54
+ // The term as TEXT, whatever arrived: `websearch_to_tsquery` takes text, and a number or a null
55
+ // reaching it as a parameter is a `42883` where the caller is owed "no rows".
56
+ const term = predicate.value === null || predicate.value === undefined ? '' : predicate.value;
57
+ return sql`${column} @@ websearch_to_tsquery(${raw(`'${vector.language}'`)}, ${String(term)})`;
58
+ };
59
+
26
60
  const predicateSql = <Row>(entity: EntityCore<Row>, predicate: Predicate): SqlFragment => {
61
+ // BEFORE the column is resolved: a `matches` predicate names `SEARCH_PROPERTY`, which is not a
62
+ // column and must never be looked up as one.
63
+ if (predicate.op === 'matches') return searchSql(entity, predicate);
27
64
  const column = columnRef(entity, predicate.column);
28
65
  const value = predicate.value;
29
66
  switch (predicate.op) {
@@ -65,59 +102,187 @@ const predicateSql = <Row>(entity: EntityCore<Row>, predicate: Predicate): SqlFr
65
102
  return sql`${column} is null`;
66
103
  case 'is-not-null':
67
104
  return sql`${column} is not null`;
105
+ // The containment half. The OPERAND crosses as a bound parameter in every one of them — a
106
+ // jsonb operand as its TEXT with the same `::text::jsonb` cast an insert cell uses (the driver
107
+ // seam refuses a plain object as a parameter), an array operand as the array literal
108
+ // `bindValues` already builds. What is written into the statement is the operator, and the
109
+ // operator is chosen here from a closed set of four.
110
+ case 'contains':
111
+ return containmentSql(entity, predicate, 'contains');
112
+ case 'contained-by':
113
+ return containmentSql(entity, predicate, 'contained-by');
114
+ case 'overlaps':
115
+ return containmentSql(entity, predicate, 'overlaps');
116
+ // The `?` OPERATOR, schema-qualified — not `jsonb_exists(col, $1)`, which is the same test and
117
+ // is **not indexable**. Measured on Postgres 16 over 20,000 rows with a GIN index and
118
+ // `enable_seqscan = off`: `data ? 'k'` plans as a Bitmap Index Scan and
119
+ // `jsonb_exists(data, 'k')` is a Seq Scan the planner will not convert, because an index is
120
+ // matched against an OPERATOR expression and a bare function call is not one. Shipping the
121
+ // function form would have made `has-key` the one containment operator a declared GIN index
122
+ // cannot serve — which is the whole reason the index is declarable.
123
+ //
124
+ // `operator(pg_catalog.?)` rather than a bare `?`: both round-trip through Bun's client today
125
+ // (measured), and the qualified spelling is immune to any client that reads `?` as a
126
+ // placeholder and to a search_path that shadows the operator. Postgres matches the index
127
+ // against it identically — verified in the same EXPLAIN run.
128
+ case 'has-key':
129
+ return sql`${column} operator(pg_catalog.?) ${String(predicate.value)}`;
68
130
  }
69
131
  };
70
132
 
71
133
  /**
72
- * A cursor's timestamp is the row's own value FLOORED: a `Date` holds milliseconds and a
73
- * `timestamptz` column holds microseconds, so `created_at > '…123'` is satisfied by the very row
74
- * at `…123456` the cursor was minted from the same row, returned again, on every page boundary.
75
- * Under `desc` the same gap does the opposite and silently drops every row inside that
76
- * millisecond, which no `id` tiebreak can recover because the first `or` term never matched.
134
+ * A containment operand, cast to the column's own type. `jsonb` is the one that cannot bind as
135
+ * itself the seam refuses a plain object (`X_SQL_UNSAFE`) so it crosses as TEXT and the cast
136
+ * turns it back, exactly as an insert cell does; `::text::jsonb` and not `::jsonb`, because with
137
+ * the single cast the client JSON-encodes the string it was given and `{"a":1}` is stored as the
138
+ * JSON *string*.
139
+ */
140
+ const containmentSql = <Row>(
141
+ entity: EntityCore<Row>,
142
+ predicate: Predicate,
143
+ op: 'contains' | 'contained-by' | 'overlaps',
144
+ ): SqlFragment => {
145
+ const column = columnRef(entity, predicate.column);
146
+ const kind = kindOf(entity, predicate.column);
147
+ const operand =
148
+ kind === 'jsonb'
149
+ ? sql`${JSON.stringify(predicate.value ?? null)}::text::jsonb`
150
+ : sql`${arrayLiteral(predicate.value)}`;
151
+ if (op === 'contains') return sql`${column} @> ${operand}`;
152
+ if (op === 'contained-by') return sql`${column} <@ ${operand}`;
153
+ return sql`${column} && ${operand}`;
154
+ };
155
+
156
+ /**
157
+ * The bind a seek compares against, at the precision the COLUMN keeps. Every kind but one binds
158
+ * the revived value itself; a `timestamptz` cursor carries MICROSECONDS since the epoch
159
+ * (`cursor.ts`), because binding a JS `Date` is the row's own position floored to the millisecond
160
+ * — and the `order by` beside it sorts at microseconds, so the two ranked rows differently and a
161
+ * `desc` page dropped every row inside the boundary millisecond. Proven against a real server:
162
+ * `pg-cursor-precision.live.test.ts`.
77
163
  *
78
- * So a timestamp seek compares against the millisecond WINDOW its value stands for what
79
- * `date_trunc('milliseconds', …)` would say, spelled as a half-open range so the column stays
80
- * bare and an index can still range-scan it. `timestamptz` is the only sort kind revived as a
81
- * `Date` (`cursor.ts`), so the type test IS the kind test.
164
+ * The cast is part of this template and not a `raw()` call: what crosses as a parameter is the ISO
165
+ * text, and `${}::timestamptz` is what makes the server parse it as an instant rather than infer
166
+ * a type for it. The column stays BARE on the left, so an index can still range-scan.
82
167
  */
83
- const nextMillisecond = (value: Date): Date => new Date(value.getTime() + 1);
168
+ const seekBind = (kind: ColumnKind | undefined, value: unknown): SqlFragment | null => {
169
+ // `null` and not a bound parameter: NULL is never the VALUE being tested, it is the shape of the
170
+ // term. `col = $n` and `col > $n` are both unknown against a NULL bind, so binding one would
171
+ // answer no rows where the ordering says there are some.
172
+ if (value === null || value === undefined) return null;
173
+ return kind === 'timestamptz' && typeof value === 'bigint'
174
+ ? sql`${microsToIso(value)}::timestamptz`
175
+ : sql`${value}`;
176
+ };
84
177
 
85
- /** Strictly past the cursor's position in this key's direction. */
86
- const seekAfter = (column: SqlFragment, direction: string, value: unknown): SqlFragment => {
87
- if (direction === 'desc') return sql`${column} < ${value}`;
88
- // `>= v + 1ms` is `trunc(col) > v`; `< v` already is `trunc(col) < v`, so only asc moves.
89
- if (value instanceof Date) return sql`${column} >= ${nextMillisecond(value)}`;
90
- return sql`${column} > ${value}`;
178
+ /**
179
+ * Strictly past the cursor's position in this key's direction, under the ordering `orderSql`
180
+ * writes so NULL is the largest value on both sides of the comparison.
181
+ *
182
+ * `desc` is `nulls first`: a NULL cursor is at the very top, and every non-null row follows it
183
+ * (`is not null`); a value cursor's `< $n` already excludes the NULLs above it. `asc` is
184
+ * `nulls last`: the NULLs follow every value, so a value cursor has to REACH them explicitly or
185
+ * page two ends at the first NULL — and a NULL cursor is the end of the listing, which is why the
186
+ * ascending null case answers `undefined` and `seekSql` drops the whole term rather than emitting
187
+ * SQL that can never be true.
188
+ *
189
+ * `or col is null` only when the column can actually hold one: on a not-null column it is dead SQL
190
+ * the planner has to defeat before it can seek the index, on every paged read.
191
+ */
192
+ const seekAfter = (
193
+ column: SqlFragment,
194
+ direction: string,
195
+ bind: SqlFragment | null,
196
+ nullable: boolean,
197
+ ): SqlFragment | undefined => {
198
+ if (direction === 'desc') {
199
+ return bind === null ? sql`${column} is not null` : sql`${column} < ${bind}`;
200
+ }
201
+ if (bind === null) return undefined;
202
+ return nullable ? sql`(${column} > ${bind} or ${column} is null)` : sql`${column} > ${bind}`;
91
203
  };
92
204
 
93
- /** At the cursor's position for this key — the prefix a later key's tiebreak hangs off. */
94
- const seekEqual = (column: SqlFragment, value: unknown): SqlFragment =>
95
- value instanceof Date
96
- ? sql`(${column} >= ${value} and ${column} < ${nextMillisecond(value)})`
97
- : sql`${column} = ${value}`;
205
+ /**
206
+ * At the cursor's position for this key — the prefix a later key's tiebreak hangs off. `= $n` is
207
+ * never true of a NULL in Postgres, so an absent position is `is null`: the same pair
208
+ * `predicateSql` above already emits for `eq`, one page later.
209
+ */
210
+ const seekEqual = (column: SqlFragment, bind: SqlFragment | null): SqlFragment =>
211
+ bind === null ? sql`${column} is null` : sql`${column} = ${bind}`;
98
212
 
99
213
  /**
100
- * The keyset seek, spelled out rather than as a row comparison: `(a, b) > (x, y)` requires every
101
- * key to sort the same way, and a listing that is `published_at desc, id asc` does not.
214
+ * The keyset seek. Two shapes, and which one is legal is decided by the ORDER, never by taste.
215
+ *
216
+ * Every key sorting the same way is a ROW COMPARISON — `(a, b) < ($1, $2)`. That is the shape
217
+ * Postgres can push into a multicolumn index: measured on Postgres 16 over 20,000 rows with an
218
+ * index on `(org, at desc, id desc)`, the row form plans as an Index Only Scan carrying the whole
219
+ * seek as its Index Cond, while the or-chain below plans as a BitmapOr of two index scans plus a
220
+ * Sort over everything they matched.
221
+ *
222
+ * A MIXED order — `published_at desc, id asc` — has no row comparison, so it is spelled out as the
223
+ * or-chain instead. `totalOrder` no longer produces one by accident (the tiebreak follows the last
224
+ * declared key's direction), so this branch is now reached only by a caller who wrote the mixed
225
+ * order themselves.
226
+ *
227
+ * Either way every term is a plain comparison against a bare column, which is what carrying the
228
+ * cursor at the column's own precision bought: the equality class this seek cuts on is exactly the
229
+ * one `orderSql` sorts by, so no row can fall between the two.
230
+ */
231
+ /**
232
+ * A row comparison `(a, b) < ($1, $2)` is legal only when the ordering it stands for is the one
233
+ * Postgres gives it — and a row comparison has NO null ordering: a NULL anywhere in either side
234
+ * makes the whole comparison unknown, so under `asc nulls last` every NULL row would be excluded
235
+ * from the very page the ordering puts it on. Uniform direction is therefore not enough; every key
236
+ * has to be a column that cannot hold a NULL.
102
237
  */
238
+ const rowComparable = <Row>(entity: EntityCore<Row>, orderBy: readonly SortKey[]): boolean => {
239
+ const [first] = orderBy;
240
+ if (first === undefined || orderBy.length < 2) return false;
241
+ return orderBy.every(
242
+ (entry) => entry.direction === first.direction && !isNullableKey(entity, entry.column),
243
+ );
244
+ };
245
+
103
246
  const seekSql = <Row>(
104
247
  entity: EntityCore<Row>,
105
248
  orderBy: readonly SortKey[],
106
249
  seek: readonly unknown[],
107
250
  ): SqlFragment => {
108
- const terms = orderBy.map((entry, index) => {
251
+ const binds = orderBy.map((entry, index) => seekBind(kindOf(entity, entry.column), seek[index]));
252
+ // One key is already a scalar comparison; `(("id") > ($1))` is the same plan spelled worse.
253
+ if (rowComparable(entity, orderBy)) {
254
+ const columns = join(orderBy.map((entry) => columnRef(entity, entry.column)));
255
+ // Not-null columns, so no bind here can be `null` — but the type says it can, and `sql`null``
256
+ // for one would be a seek from a position no row is after.
257
+ const values = join(binds.map((bind) => bind ?? sql`null`));
258
+ return orderBy[0]?.direction === 'desc'
259
+ ? sql`((${columns}) < (${values}))`
260
+ : sql`((${columns}) > (${values}))`;
261
+ }
262
+ const terms = orderBy.flatMap((entry, index) => {
263
+ const after = seekAfter(
264
+ columnRef(entity, entry.column),
265
+ entry.direction,
266
+ binds[index] ?? null,
267
+ isNullableKey(entity, entry.column),
268
+ );
269
+ // Nothing sorts after a NULL under `nulls last`, so this key's term is dead SQL — dropped
270
+ // rather than emitted. The remaining keys still carry the page: the equality prefix below
271
+ // reaches them as `col is null`.
272
+ if (after === undefined) return [];
109
273
  const equal = orderBy
110
274
  .slice(0, index)
111
- .map((earlier, position) => seekEqual(columnRef(entity, earlier.column), seek[position]));
112
- return sql`(${join(
113
- [...equal, seekAfter(columnRef(entity, entry.column), entry.direction, seek[index])],
114
- ' and ',
115
- )})`;
275
+ .map((earlier, position) =>
276
+ seekEqual(columnRef(entity, earlier.column), binds[position] ?? null),
277
+ );
278
+ return [sql`(${join([...equal, after], ' and ')})`];
116
279
  });
117
- return sql`(${join(terms, ' or ')})`;
280
+ // Every key NULL under an ascending order is the very end of the listing — no row follows it,
281
+ // and `()` is a syntax error.
282
+ return terms.length === 0 ? NEVER : sql`(${join(terms, ' or ')})`;
118
283
  };
119
284
 
120
- const conditions = <Row>(
285
+ export const conditions = <Row>(
121
286
  entity: EntityCore<Row>,
122
287
  plan: QueryPlan,
123
288
  shape: ReadShape,
@@ -130,20 +295,60 @@ const conditions = <Row>(
130
295
  return parts.length === 0 ? sql`true` : join(parts, ' and ');
131
296
  };
132
297
 
298
+ /**
299
+ * NULL's place in the ordering, WRITTEN DOWN rather than inherited from the server's default —
300
+ * `asc nulls last`, `desc nulls first`. Identical to `@ultimat3/query`'s `orderTerm`, deliberately:
301
+ * two pagination systems in one framework disagreeing about where a NULL sorts is the ambiguity
302
+ * axiom 1 exists to forbid, and until 2026-08-24 this package refused a nullable sort key outright
303
+ * rather than answer the question. Saying it out loud is also what keeps a driver whose default
304
+ * differs from re-opening the divergence.
305
+ *
306
+ * `raw()` is the same closed set of one word it always was — now four words instead of two, all
307
+ * written here and never derived from a value.
308
+ */
309
+ const NULLS_LAST = raw('asc nulls last');
310
+ const NULLS_FIRST = raw('desc nulls first');
311
+
133
312
  const orderSql = <Row>(entity: EntityCore<Row>, orderBy: readonly SortKey[]): SqlFragment =>
134
313
  join(
135
314
  orderBy.map(
136
315
  (entry) =>
137
- sql`${columnRef(entity, entry.column)} ${raw(entry.direction === 'desc' ? 'desc' : 'asc')}`,
316
+ sql`${columnRef(entity, entry.column)} ${entry.direction === 'desc' ? NULLS_FIRST : NULLS_LAST}`,
138
317
  ),
139
318
  );
140
319
 
320
+ /**
321
+ * The microsecond half of every `timestamptz` sort key, under an output name no entity can declare
322
+ * (`seekAlias`). Bun's client hands a `timestamptz` back as a JS `Date`, which is milliseconds, so
323
+ * the row itself CANNOT carry the value the `order by` actually sorted by — a cursor minted from
324
+ * it cuts the page at a position no row occupies, and every row inside the boundary millisecond is
325
+ * then served on no page at all.
326
+ *
327
+ * `at time zone 'UTC'` rather than a bare `::text`: the bare cast renders in the session's
328
+ * `TimeZone`, and a page position must not depend on a connection setting.
329
+ */
330
+ const seekPrecision = <Row>(entity: EntityCore<Row>, plan: QueryPlan): readonly SqlFragment[] => {
331
+ const seen = new Set<string>();
332
+ return plan.orderBy.flatMap((entry) => {
333
+ if (kindOf(entity, entry.column) !== 'timestamptz') return [];
334
+ const physical = physicalName(entity, entry.column);
335
+ if (seen.has(physical)) return [];
336
+ seen.add(physical);
337
+ return [
338
+ sql`(${identifier(physical)} at time zone 'UTC')::text as ${identifier(seekAlias(physical))}`,
339
+ ];
340
+ });
341
+ };
342
+
141
343
  /**
142
344
  * A projection always carries the primary key and the sort keys even when the caller did not
143
345
  * ask for them: without those values the page cannot produce the cursor that continues it.
144
346
  */
145
347
  const projection = <Row>(entity: EntityCore<Row>, plan: QueryPlan): SqlFragment => {
146
- if (plan.select === undefined) return join(allColumns(entity).map(identifier));
348
+ const precise = seekPrecision(entity, plan);
349
+ if (plan.select === undefined) {
350
+ return join([...allColumns(entity).map(identifier), ...precise]);
351
+ }
147
352
  const wanted = new Set([
148
353
  ...plan.select,
149
354
  ...entity.$primaryKey,
@@ -153,7 +358,7 @@ const projection = <Row>(entity: EntityCore<Row>, plan: QueryPlan): SqlFragment
153
358
  const column = columnFor(entity.$columns, property);
154
359
  return column === undefined ? [] : columnsOf(property, column);
155
360
  });
156
- return join(names.map(identifier));
361
+ return join([...names.map(identifier), ...precise]);
157
362
  };
158
363
 
159
364
  export const selectStatement = <Row>(
@@ -204,115 +409,88 @@ export const countByStatement = <Row>(
204
409
  )} where ${conditions(entity, plan, shape)} group by ${grouped} limit ${limit}`;
205
410
  };
206
411
 
207
- /** `on conflict (…) do update set …`, or `do nothing` when there is nothing to overwrite. */
208
- export interface ConflictTarget {
209
- /** Physical columns of the unique index a collision is judged against. */
210
- readonly columns: readonly string[];
211
- /** Physical columns a colliding row takes from the incoming one. Empty is `do nothing`. */
212
- readonly set: readonly string[];
213
- }
214
-
215
- export interface InsertShape {
216
- /** Every physical column written — one list, shared by every row of the statement. */
217
- readonly columns: readonly string[];
218
- /** How a collision resolves. Absent, it is the caller's error, exactly as it is for one row. */
219
- readonly conflict?: ConflictTarget | undefined;
220
- }
221
-
222
412
  /**
223
- * The cell of a row that did not name this column. `default` is the second and last `raw()` in
224
- * this file and, like `asc|desc` above it, a closed set of one word: it is what makes a row inside
225
- * a many-row `insert` mean what the same row means on its own, where an unnamed column is simply
226
- * left out. The seek operator used to be a third — it is chosen in TypeScript now
227
- * (`seekAfter`/`seekEqual`), because a timestamp seek is not one operator.
228
- */
229
- const DEFAULT_CELL = raw('default');
230
-
231
- const conflictSql = (conflict: ConflictTarget): SqlFragment => {
232
- const target = join(conflict.columns.map(identifier));
233
- return conflict.set.length === 0
234
- ? sql` on conflict (${target}) do nothing`
235
- : sql` on conflict (${target}) do update set ${join(
236
- conflict.set.map((column) => sql`${identifier(column)} = excluded.${identifier(column)}`),
237
- )}`;
238
- };
239
-
240
- /**
241
- * The one column that cannot be bound as itself. A `jsonb` value is a plain object, and the
242
- * driver seam refuses one as a parameter (`X_SQL_UNSAFE` — `isBoundValue` takes scalars, a `Date`,
243
- * a `Uint8Array` and arrays of those); so `bindValues` hands over the JSON TEXT and the cell says
244
- * what to do with it.
413
+ * One aggregate over exactly the rows `countStatement` would have counted the same predicates,
414
+ * the same soft-delete filter, one function more. Four outputs and always the same four names, so
415
+ * neither driver reads a column an entity could also have declared:
245
416
  *
246
- * `::text::jsonb` and not `::jsonb`, and the double cast is load-bearing rather than defensive.
247
- * Measured against Postgres 17.10 through Bun's `sql`: with `$1::jsonb` the server describes the
248
- * parameter as `jsonb`, the client JSON-ENCODES the string it was given, and `{"a":1}` is stored
249
- * as the JSON *string* `"{\"a\":1}"` — `jsonb_typeof` says `string`. Pinning the parameter to
250
- * `text` first makes the client send the characters and the server parse them, which is the one
251
- * spelling that stores an object.
252
- */
253
- /** Physical names of this entity's `jsonb` columns. Resolved ONCE per statement, never per cell. */
254
- const jsonColumns = <Row>(entity: EntityCore<Row>): ReadonlySet<string> => {
255
- const names = new Set<string>();
256
- for (const [property, column] of Object.entries(entity.$columns)) {
257
- if (column.$meta.kind === 'jsonb') names.add(columnName(property, column.$meta));
258
- }
259
- return names;
260
- };
261
-
262
- /**
263
- * `${value}`, plus the cast that column needs. The `raw()` argument is a literal written here and
264
- * nowhere else — the audit point that call is stays a two-word constant, never a value.
265
- */
266
- const cell = (json: ReadonlySet<string>, column: string, value: unknown): SqlFragment =>
267
- json.has(column) ? sql`${value}${raw('::text::jsonb')}` : sql`${value}`;
268
-
269
- /**
270
- * One statement for any number of rows. A single row compiles to exactly the text it always did,
271
- * which is the point: `insertAll([row])` and `insert(row)` are one code path, so there is no
272
- * second insert builder for the two to drift apart in.
417
+ * - `agg_value` the aggregate itself, as TEXT. `::text` and never a float: `sum(bigint)` is a
418
+ * `numeric` Bun would hand back as a string anyway, and pinning it makes `integer` behave the
419
+ * same. `aggregate.ts` re-parses it by the column's kind.
420
+ * - `agg_count` — how many non-null values went in, which is what tells `null` ("no rows") from a
421
+ * legitimate zero, and what `avg` divides by.
422
+ *
423
+ * `avg` is `round(avg(...), AVG_SCALE)` rather than the server's own scale, because the in-memory
424
+ * driver has to reach the same digits and "whatever numeric division gives you" is not a rule two
425
+ * implementations can share.
273
426
  */
274
- export const insertStatement = <Row>(
427
+ export const aggregateStatement = <Row>(
275
428
  entity: EntityCore<Row>,
276
- rows: readonly ReadonlyMap<string, unknown>[],
277
- shape: InsertShape,
429
+ plan: QueryPlan,
430
+ shape: ReadShape,
431
+ fn: AggregateFn,
432
+ column: string,
278
433
  ): SqlFragment => {
279
- const json = jsonColumns(entity);
280
- const tuples = rows.map(
281
- (row) =>
282
- sql`(${join(
283
- shape.columns.map((column) =>
284
- row.has(column) ? cell(json, column, row.get(column)) : DEFAULT_CELL,
285
- ),
286
- )})`,
287
- );
288
- const conflict = shape.conflict === undefined ? sql`` : conflictSql(shape.conflict);
289
- return sql`insert into ${identifier(entity.$table)} (${join(
290
- shape.columns.map(identifier),
291
- )}) values ${join(tuples)}${conflict} returning *`;
434
+ const target = columnRef(entity, column);
435
+ const value =
436
+ fn === 'sum'
437
+ ? sql`sum(${target})`
438
+ : fn === 'avg'
439
+ ? sql`round(avg(${target}), ${AVG_SCALE})`
440
+ : fn === 'min'
441
+ ? sql`min(${target})`
442
+ : sql`max(${target})`;
443
+ return sql`select ${value}::text as agg_value, count(${target}) as agg_count from ${identifier(
444
+ entity.$table,
445
+ )} where ${conditions(entity, plan, shape)}`;
292
446
  };
293
447
 
448
+ /** What an aggregate comes back as. Both names are fixed, so neither can be a column's. */
449
+ export interface AggregateRow {
450
+ readonly agg_value: unknown;
451
+ readonly agg_count: unknown;
452
+ }
453
+
294
454
  /**
295
- * `returning` is a parameter and has no default, because the three callers want three different
296
- * answers and the wrong one is not visible in the result: `update(id, patch)` needs the stored row,
297
- * a soft delete and a filtered write need a count, and `returning *` on a filtered write over a
298
- * whole tenant streams every matched row into the process for nobody to read. A default would make
299
- * that the quiet case.
455
+ * The distinct currencies among the rows an aggregate is about to cover. A separate statement
456
+ * rather than a clever one: `sum(minor)` over two currencies is a number in neither, and the only
457
+ * honest answer is to refuse which needs the list, not a boolean.
458
+ *
459
+ * Bounded at three, because the refusal names them and a caller with three already knows.
300
460
  */
301
- export const updateStatement = <Row>(
461
+ export const currenciesStatement = <Row>(
302
462
  entity: EntityCore<Row>,
303
463
  plan: QueryPlan,
304
- values: ReadonlyMap<string, unknown>,
305
464
  shape: ReadShape,
306
- returning: boolean,
465
+ currencyColumn: string,
466
+ scaleColumn: string | null,
307
467
  ): SqlFragment => {
308
- const json = jsonColumns(entity);
309
- return sql`update ${identifier(entity.$table)} set ${join(
310
- [...values].map(([column, value]) => sql`${identifier(column)} = ${cell(json, column, value)}`),
311
- )} where ${conditions(entity, plan, shape)}${returning ? sql` returning *` : sql``}`;
468
+ const currency = identifier(currencyColumn);
469
+ // The SCALE is half of what makes two amounts incomparable and it is the half with no symptom:
470
+ // `{ minor: 5, currency: 'USD' }` is five cents and the same row at `scale: 6` is five millionths
471
+ // of a dollar. A table with no scale column has one unit per currency by construction.
472
+ const scale = scaleColumn === null ? sql`null` : identifier(scaleColumn);
473
+ return sql`select distinct ${currency} as group_value, ${scale} as group_scale from ${identifier(
474
+ entity.$table,
475
+ )} where ${conditions(entity, plan, shape)} and ${currency} is not null limit 3`;
312
476
  };
313
477
 
314
- /** Only reached when the entity has no soft-delete column, so there is no filter to apply. */
315
- export const deleteStatement = <Row>(entity: EntityCore<Row>, plan: QueryPlan): SqlFragment =>
316
- sql`delete from ${identifier(entity.$table)} where ${conditions(entity, plan, {
317
- includeDeleted: true,
318
- })}`;
478
+ /** One `(currency, scale)` pair the rows an aggregate covers actually use. */
479
+ export interface MoneyUnitRow {
480
+ readonly group_value: unknown;
481
+ readonly group_scale: unknown;
482
+ }
483
+
484
+ /**
485
+ * The planner's own row estimate for a table — `reltuples`, which is what `ANALYZE` last wrote and
486
+ * what every query plan in the database is already costed against. `count(*)` walks every visible
487
+ * row (MVCC gives no shortcut), so on a large table it is the read that exceeds a web role's
488
+ * `statement_timeout`, and no index can make it cheaper: the `fix:` on that timeout tells an author
489
+ * to add one, and following it changes nothing.
490
+ *
491
+ * `to_regclass` rather than a name comparison, so a search_path change cannot silently answer for a
492
+ * different schema's table of the same name — and `-1` is what Postgres 14+ stores for a table that
493
+ * has never been analysed, which is an answer, not an estimate.
494
+ */
495
+ export const estimateStatement = (table: string): SqlFragment =>
496
+ sql`select reltuples::bigint as estimate from pg_class where oid = to_regclass(${table})`;