@ultimat3/entity 11.2.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/CLAUDE.md +190 -20
- package/README.md +120 -1
- package/package.json +5 -5
- package/src/aggregate-decode.ts +35 -0
- package/src/aggregate-fold.ts +91 -0
- package/src/aggregate.ts +232 -0
- package/src/batch.ts +2 -1
- package/src/column.ts +15 -1
- package/src/containment.ts +94 -0
- package/src/cursor.ts +100 -14
- package/src/database.ts +1 -1
- package/src/describe.ts +3 -0
- package/src/entity.ts +153 -7
- package/src/errors.ts +6 -0
- package/src/index.ts +3 -1
- package/src/instant.ts +93 -0
- package/src/memory-match.ts +67 -0
- package/src/memory-repo.ts +357 -0
- package/src/pg-driver.ts +94 -7
- package/src/pg-row.ts +38 -1
- package/src/pg-sql.ts +279 -133
- package/src/pg-write-sql.ts +129 -0
- package/src/plan.ts +71 -10
- package/src/query.ts +59 -3
- package/src/registry.ts +7 -0
- package/src/repo.ts +16 -328
- package/src/tenancy.ts +18 -2
- package/src/types.ts +10 -0
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 {
|
|
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 {
|
|
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
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
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
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
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
|
|
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
|
-
/**
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
/**
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
|
101
|
-
*
|
|
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
|
|
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) =>
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
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)} ${
|
|
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
|
-
|
|
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
|
-
*
|
|
224
|
-
*
|
|
225
|
-
*
|
|
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
|
-
*
|
|
247
|
-
*
|
|
248
|
-
*
|
|
249
|
-
*
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
|
395
|
+
export const aggregateStatement = <Row>(
|
|
275
396
|
entity: EntityCore<Row>,
|
|
276
|
-
|
|
277
|
-
shape:
|
|
397
|
+
plan: QueryPlan,
|
|
398
|
+
shape: ReadShape,
|
|
399
|
+
fn: AggregateFn,
|
|
400
|
+
column: string,
|
|
278
401
|
): SqlFragment => {
|
|
279
|
-
const
|
|
280
|
-
const
|
|
281
|
-
|
|
282
|
-
sql`(${
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
-
*
|
|
296
|
-
*
|
|
297
|
-
*
|
|
298
|
-
*
|
|
299
|
-
*
|
|
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
|
|
429
|
+
export const currenciesStatement = <Row>(
|
|
302
430
|
entity: EntityCore<Row>,
|
|
303
431
|
plan: QueryPlan,
|
|
304
|
-
values: ReadonlyMap<string, unknown>,
|
|
305
432
|
shape: ReadShape,
|
|
306
|
-
|
|
433
|
+
currencyColumn: string,
|
|
434
|
+
scaleColumn: string | null,
|
|
307
435
|
): SqlFragment => {
|
|
308
|
-
const
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
-
/**
|
|
315
|
-
export
|
|
316
|
-
|
|
317
|
-
|
|
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
|
+
})}`;
|