@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/CLAUDE.md +243 -20
- package/README.md +206 -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-values.ts +29 -0
- package/src/column.ts +37 -1
- package/src/columns.ts +5 -46
- package/src/containment.ts +94 -0
- package/src/cursor.ts +100 -14
- package/src/database.ts +1 -1
- package/src/describe.ts +38 -3
- package/src/entity.ts +117 -8
- package/src/enum-column.ts +81 -0
- package/src/errors.ts +16 -0
- package/src/feature-errors.ts +121 -0
- package/src/index-name.ts +83 -0
- package/src/index.ts +42 -4
- package/src/instant.ts +93 -0
- package/src/memory-match.ts +72 -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 +311 -133
- package/src/pg-write-sql.ts +129 -0
- package/src/plan.ts +71 -10
- package/src/query.ts +120 -3
- package/src/registry.ts +16 -0
- package/src/repo.ts +16 -328
- package/src/search.ts +153 -0
- package/src/state-machine.ts +132 -0
- package/src/tenancy.ts +23 -2
- package/src/transition.ts +119 -0
- package/src/types.ts +60 -0
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 {
|
|
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 { 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
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
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
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
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
|
|
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
|
-
/**
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
/**
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
|
101
|
-
*
|
|
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
|
|
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) =>
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
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)} ${
|
|
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
|
-
|
|
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
|
-
*
|
|
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.
|
|
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
|
-
*
|
|
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.
|
|
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
|
|
427
|
+
export const aggregateStatement = <Row>(
|
|
275
428
|
entity: EntityCore<Row>,
|
|
276
|
-
|
|
277
|
-
shape:
|
|
429
|
+
plan: QueryPlan,
|
|
430
|
+
shape: ReadShape,
|
|
431
|
+
fn: AggregateFn,
|
|
432
|
+
column: string,
|
|
278
433
|
): SqlFragment => {
|
|
279
|
-
const
|
|
280
|
-
const
|
|
281
|
-
|
|
282
|
-
sql`(${
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
-
*
|
|
296
|
-
*
|
|
297
|
-
*
|
|
298
|
-
*
|
|
299
|
-
*
|
|
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
|
|
461
|
+
export const currenciesStatement = <Row>(
|
|
302
462
|
entity: EntityCore<Row>,
|
|
303
463
|
plan: QueryPlan,
|
|
304
|
-
values: ReadonlyMap<string, unknown>,
|
|
305
464
|
shape: ReadShape,
|
|
306
|
-
|
|
465
|
+
currencyColumn: string,
|
|
466
|
+
scaleColumn: string | null,
|
|
307
467
|
): SqlFragment => {
|
|
308
|
-
const
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
-
/**
|
|
315
|
-
export
|
|
316
|
-
|
|
317
|
-
|
|
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})`;
|