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