@jarenjs/db 0.56.0 → 0.67.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/ARCHITECTURE.md +412 -56
- package/README.md +600 -57
- package/docs/HOSTS.md +269 -0
- package/docs/JOBS-FORMAT.md +293 -45
- package/docs/LIVE-FORMAT.md +169 -20
- package/docs/MIGRATION-FORMAT.md +142 -17
- package/docs/MODEL-FORMAT.md +752 -64
- package/docs/REPLICATION-FORMAT.md +208 -0
- package/package.json +21 -7
- package/schemas/jaren-model.draft-07.schema.json +224 -162
- package/schemas/jaren-model.schema.json +224 -162
- package/schemas/jaren-replication-snapshot.draft-07.schema.json +83 -0
- package/schemas/jaren-replication-snapshot.schema.json +83 -0
- package/schemas/jaren-replication.draft-07.schema.json +82 -0
- package/schemas/jaren-replication.schema.json +82 -0
- package/src/algebra.js +227 -9
- package/src/backup.js +161 -0
- package/src/cancellation.js +48 -0
- package/src/capture.js +230 -47
- package/src/cli.js +165 -59
- package/src/cursor.js +417 -0
- package/src/dag-job.js +154 -21
- package/src/ddl.js +102 -8
- package/src/dialect.js +268 -113
- package/src/dialects/expression-read.js +158 -0
- package/src/dialects/postgres.js +618 -0
- package/src/dialects/rtree-ddl.js +129 -0
- package/src/dialects/sqlite.js +244 -11
- package/src/document-files.js +311 -0
- package/src/document-steps.js +422 -0
- package/src/documents.js +335 -0
- package/src/driver.js +448 -61
- package/src/drivers/bun.js +37 -1
- package/src/drivers/indexeddb-snapshot.js +149 -0
- package/src/drivers/node-pool.js +11 -0
- package/src/drivers/node-worker-endpoint.js +105 -0
- package/src/drivers/node-worker.js +204 -0
- package/src/drivers/node.js +41 -7
- package/src/drivers/postgres.js +331 -0
- package/src/drivers/wasm-oo1.js +97 -0
- package/src/drivers/wasm-session.js +67 -0
- package/src/drivers/wasm.js +17 -83
- package/src/drivers/worker-pool.js +183 -0
- package/src/drivers/worker-protocol.js +79 -0
- package/src/drivers/worker-queue.js +60 -0
- package/src/emit.js +339 -48
- package/src/entity.js +20 -22
- package/src/errors.js +430 -19
- package/src/expression.js +284 -0
- package/src/graph.js +64 -8
- package/src/index.js +48 -17
- package/src/introspect.js +583 -0
- package/src/jobs.js +843 -107
- package/src/json-bytes.js +58 -0
- package/src/live-join.js +250 -0
- package/src/live-nested.js +120 -0
- package/src/live.js +18 -4
- package/src/logical-rows.js +90 -0
- package/src/maintenance.js +175 -0
- package/src/migrate.js +248 -181
- package/src/model.js +68 -0
- package/src/plan.js +1119 -138
- package/src/pragmas.js +314 -0
- package/src/profile.js +151 -3
- package/src/query.js +1634 -323
- package/src/replication-format.js +115 -0
- package/src/replication.js +332 -0
- package/src/residual.js +17 -0
- package/src/series.js +12 -4
- package/src/store.js +1567 -273
- package/src/tracker.js +203 -29
- package/src/udf.js +88 -7
- package/types/index.d.ts +1158 -27
- package/types/node-pool.d.ts +28 -0
- package/types/node-worker.d.ts +54 -0
- package/types/node.d.ts +69 -2
- package/types/postgres.d.ts +46 -0
- package/types/typed.d.ts +27 -4
- package/types/wasm.d.ts +14 -0
|
@@ -0,0 +1,618 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file The PostgreSQL dialect — the second spelling of the dialect
|
|
4
|
+
* contract, and the reason the contract exists.
|
|
5
|
+
*
|
|
6
|
+
* It imports no PostgreSQL client and no runtime builtin: it is pure
|
|
7
|
+
* text, exactly like the SQLite dialect beside it, so
|
|
8
|
+
* `@jarenjs/db/postgres` resolves in a browser bundle and type-checks
|
|
9
|
+
* with nothing installed. Transport is the driver's business
|
|
10
|
+
* (`src/drivers/postgres.js`), and the driver is INJECTED — a
|
|
11
|
+
* standards-shaped client or pool the host supplies.
|
|
12
|
+
*
|
|
13
|
+
* The mapping, in one paragraph. Documents are stored `jsonb`. An
|
|
14
|
+
* indexed path becomes a STORED generated column, because PostgreSQL
|
|
15
|
+
* has no indexable virtual one; its type is the schema's, and the
|
|
16
|
+
* expression is guarded by `jsonb_typeof` inside a `CASE` so a document
|
|
17
|
+
* whose member is the wrong JSON type stores `NULL` rather than failing
|
|
18
|
+
* the INSERT — a cast that can raise is not a legal generated-column
|
|
19
|
+
* expression. Text columns and text comparisons carry `COLLATE "C"`,
|
|
20
|
+
* which is byte order, which is what SQLite's default `BINARY`
|
|
21
|
+
* collation is: without it the same prefix range would hold different
|
|
22
|
+
* rows on a database initialised in another locale. Every table this
|
|
23
|
+
* dialect creates carries `rid bigserial`, because PostgreSQL has no
|
|
24
|
+
* per-row identity that survives an UPDATE (`ctid` moves) and a
|
|
25
|
+
* collection is a SEQUENCE — its order is insertion order, and the
|
|
26
|
+
* store orders by `rowIdentity()` wherever the model says so.
|
|
27
|
+
*
|
|
28
|
+
* What it does NOT do, declared rather than approximated: no
|
|
29
|
+
* configuration vocabulary (`pragmas: false` — a PostgreSQL server is
|
|
30
|
+
* configured by its operator, not by a store at open), no stored CREATE
|
|
31
|
+
* text (`declaredSqlText: false` — the drift check is the structural
|
|
32
|
+
* one), no virtual tables and no triggers (so a `physical: 'rtree'`
|
|
33
|
+
* column set maps back onto the B-tree over its four edge columns), no
|
|
34
|
+
* up-front write lock (`BEGIN IMMEDIATE` has no analogue), and no
|
|
35
|
+
* column without a scalar type — which is why a comparison against a
|
|
36
|
+
* member the schema does not type reads the document rather than the
|
|
37
|
+
* column.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import { createDialect } from '../dialect.js';
|
|
41
|
+
import { readExpression } from './expression-read.js';
|
|
42
|
+
|
|
43
|
+
/** PostgreSQL truncates an identifier past this many BYTES, silently
|
|
44
|
+
* and with only a notice — so two long generated names would collide
|
|
45
|
+
* and one index would quietly serve another column's path. */
|
|
46
|
+
export const IDENTIFIER_BYTES = 63;
|
|
47
|
+
|
|
48
|
+
const UTF8 = new TextEncoder();
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @param {string} name
|
|
52
|
+
* @returns {string}
|
|
53
|
+
*/
|
|
54
|
+
function quoteIdentifier(name) {
|
|
55
|
+
const text = String(name);
|
|
56
|
+
if (UTF8.encode(text).length > IDENTIFIER_BYTES) {
|
|
57
|
+
throw new TypeError(
|
|
58
|
+
`postgres dialect: the identifier '${text}' is longer than ${IDENTIFIER_BYTES} bytes, `
|
|
59
|
+
+ 'which PostgreSQL truncates silently — two names that share a prefix would become one');
|
|
60
|
+
}
|
|
61
|
+
return `"${text.replace(/"/g, '""')}"`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* @param {string} value
|
|
66
|
+
* @returns {string}
|
|
67
|
+
*/
|
|
68
|
+
function stringLiteral(value) {
|
|
69
|
+
return `'${String(value).replace(/'/g, "''")}'`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* A member path as the text of a PostgreSQL `text[]`, which is what
|
|
74
|
+
* `#>` and `jsonb_set` navigate by. Every element is quoted and its
|
|
75
|
+
* backslashes and quotes escaped, so a member named `a,b` or `}` is one
|
|
76
|
+
* element rather than two.
|
|
77
|
+
*
|
|
78
|
+
* Two shapes return `null`, and the caller then falls back to a
|
|
79
|
+
* whole-document strategy rather than addressing the wrong member: a
|
|
80
|
+
* NEGATIVE array index (`#>` counts from the front only — `jsonb_set`
|
|
81
|
+
* would accept one, and a path that meant different members to the read
|
|
82
|
+
* and the write would be worse than no path), and a name carrying a NUL,
|
|
83
|
+
* which no PostgreSQL text value can hold.
|
|
84
|
+
* @param {import('../dialect.js').JsonPathSegment[]} segments
|
|
85
|
+
* @returns {string | null}
|
|
86
|
+
*/
|
|
87
|
+
function jsonPathText(segments) {
|
|
88
|
+
const parts = [];
|
|
89
|
+
for (const segment of segments) {
|
|
90
|
+
if ('index' in segment) {
|
|
91
|
+
if (segment.index < 0) return null;
|
|
92
|
+
parts.push(`"${segment.index}"`);
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
// eslint-disable-next-line no-control-regex
|
|
96
|
+
if (/[\u0000]/.test(segment.name)) return null;
|
|
97
|
+
parts.push(`"${segment.name.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`);
|
|
98
|
+
}
|
|
99
|
+
return `{${parts.join(',')}}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The same path with one more element on the end — how an append
|
|
104
|
+
* addresses the position after an array's last.
|
|
105
|
+
* @param {string} pathText
|
|
106
|
+
* @param {string} element
|
|
107
|
+
* @returns {string}
|
|
108
|
+
*/
|
|
109
|
+
function pathWith(pathText, element) {
|
|
110
|
+
const body = pathText.slice(1, -1);
|
|
111
|
+
return `{${body.length === 0 ? '' : `${body},`}${element}}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The declared column type for a schema-declared value type.
|
|
116
|
+
*
|
|
117
|
+
* `numeric` carries BOTH JSON number types. PostgreSQL would take
|
|
118
|
+
* `bigint` for an integer member, and it would be the better index —
|
|
119
|
+
* but a document whose `integer` member holds `3.5` (a schema this
|
|
120
|
+
* store validates against only when a validator is injected) would then
|
|
121
|
+
* fail its INSERT inside a generated column's cast, and a storage
|
|
122
|
+
* decision that can reject a document the model accepts is not a
|
|
123
|
+
* storage decision. The cost is stated: `integer` and `number` are one
|
|
124
|
+
* column type here, and introspection cannot tell them apart.
|
|
125
|
+
*
|
|
126
|
+
* A path the schema does not type has no honest scalar type at all, so
|
|
127
|
+
* it is `jsonb` — indexable, comparable with another `jsonb`, and
|
|
128
|
+
* comparable with nothing else, which is exactly what
|
|
129
|
+
* `capabilities.untypedColumns: false` says.
|
|
130
|
+
* @param {string | undefined} schemaType
|
|
131
|
+
* @param {string} hint - `'key'` or `'generated'`
|
|
132
|
+
* @returns {string}
|
|
133
|
+
*/
|
|
134
|
+
function typeFor(schemaType, hint) {
|
|
135
|
+
switch (schemaType) {
|
|
136
|
+
case 'string': return 'text COLLATE "C"';
|
|
137
|
+
case 'integer': return 'numeric';
|
|
138
|
+
case 'number': return 'numeric';
|
|
139
|
+
// 1 and 0, not TRUE and FALSE. A boolean MEMBER is 1 or 0 in the
|
|
140
|
+
// SQLite mapping, every shared form that touches one compares it
|
|
141
|
+
// against those integers (a type test, a projected pair's own type
|
|
142
|
+
// name), and a column that answered `true` would make each of those
|
|
143
|
+
// an operator-resolution error rather than a row. The loss is
|
|
144
|
+
// stated: introspection reads this column back as a number.
|
|
145
|
+
case 'boolean': return 'smallint';
|
|
146
|
+
default: return hint === 'key' ? 'text COLLATE "C"' : 'jsonb';
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The catalog's own spelling of a type this dialect declares, so the
|
|
151
|
+
* drift check compares like with like: a collation, an allocation
|
|
152
|
+
* clause and a serial's expansion are all how a type is WRITTEN, not
|
|
153
|
+
* what the catalog reports it as. */
|
|
154
|
+
const TYPE_SYNONYMS = Object.freeze({
|
|
155
|
+
BIGSERIAL: 'BIGINT', SERIAL: 'INTEGER', SMALLSERIAL: 'SMALLINT',
|
|
156
|
+
INT8: 'BIGINT', INT4: 'INTEGER', INT2: 'SMALLINT', INT: 'INTEGER',
|
|
157
|
+
FLOAT8: 'DOUBLE PRECISION', FLOAT4: 'REAL', BOOL: 'BOOLEAN',
|
|
158
|
+
'CHARACTER VARYING': 'VARCHAR', DECIMAL: 'NUMERIC',
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* @param {string} declaredType
|
|
163
|
+
* @returns {string}
|
|
164
|
+
*/
|
|
165
|
+
function comparableColumnType(declaredType) {
|
|
166
|
+
const bare = String(declaredType)
|
|
167
|
+
.replace(/\s+COLLATE\s+("[^"]*"|\S+)/i, '')
|
|
168
|
+
.replace(/\s+GENERATED\s+.*$/i, '')
|
|
169
|
+
.replace(/\s+NOT\s+NULL$/i, '')
|
|
170
|
+
.trim()
|
|
171
|
+
.toUpperCase();
|
|
172
|
+
return TYPE_SYNONYMS[bare] ?? bare;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Whether a column of `declaredType` can be compared with a value of
|
|
177
|
+
* `kind` at all. PostgreSQL resolves an operator by type, so a text
|
|
178
|
+
* column against a numeric parameter is a parse error rather than a row
|
|
179
|
+
* that fails its guard — the emitter reads the member out of the
|
|
180
|
+
* document instead, which answers the same and merely does not seek.
|
|
181
|
+
* @param {string | undefined} declaredType
|
|
182
|
+
* @param {string} kind
|
|
183
|
+
* @returns {boolean}
|
|
184
|
+
*/
|
|
185
|
+
function columnUsableFor(declaredType, kind) {
|
|
186
|
+
if (kind === 'any') return true;
|
|
187
|
+
switch (declaredType) {
|
|
188
|
+
case 'string': return kind === 'text';
|
|
189
|
+
case 'integer': case 'number': return kind === 'number';
|
|
190
|
+
case 'boolean': return kind === 'boolean';
|
|
191
|
+
default: return false;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* The type discriminator, in the vocabulary the whole store shares —
|
|
197
|
+
* SQLite's, because the row decoder reads these names in JavaScript.
|
|
198
|
+
* `jsonb_typeof` answers six names and this maps the two that differ:
|
|
199
|
+
* a JSON string is `text`, and a JSON boolean is `true` or `false`
|
|
200
|
+
* (which is how a type test spells an equality against one). A JSON
|
|
201
|
+
* number stays `number`, and `numericTypeNames` is what tells the
|
|
202
|
+
* emitter so.
|
|
203
|
+
* @param {string} atSql - SQL for the member, as `jsonb`
|
|
204
|
+
* @returns {string}
|
|
205
|
+
*/
|
|
206
|
+
function typeOfJsonb(atSql) {
|
|
207
|
+
return `(CASE WHEN jsonb_typeof(${atSql}) = 'string' THEN 'text' `
|
|
208
|
+
+ `WHEN jsonb_typeof(${atSql}) = 'boolean' `
|
|
209
|
+
+ `THEN (CASE WHEN (${atSql})::boolean THEN 'true' ELSE 'false' END) `
|
|
210
|
+
+ `ELSE jsonb_typeof(${atSql}) END)`;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* The member at a path, read as the SQL type a comparison of the given
|
|
215
|
+
* KIND needs. Every typed form is a `CASE` over `jsonb_typeof`, and the
|
|
216
|
+
* guard is INSIDE it for two reasons: a cast that can raise is not a
|
|
217
|
+
* legal generated-column expression, and `AND` does not short-circuit,
|
|
218
|
+
* so a guard beside the cast would not stop it either.
|
|
219
|
+
* @param {string} columnSql
|
|
220
|
+
* @param {string} pathText
|
|
221
|
+
* @param {string} [kind]
|
|
222
|
+
* @returns {string}
|
|
223
|
+
*/
|
|
224
|
+
function jsonExtract(columnSql, pathText, kind) {
|
|
225
|
+
const at = `(${columnSql} #> ${stringLiteral(pathText)})`;
|
|
226
|
+
switch (kind) {
|
|
227
|
+
case 'text':
|
|
228
|
+
return `((CASE WHEN jsonb_typeof(${at}) = 'string' `
|
|
229
|
+
+ `THEN ${columnSql} #>> ${stringLiteral(pathText)} END) COLLATE "C")`;
|
|
230
|
+
case 'number':
|
|
231
|
+
return `(CASE WHEN jsonb_typeof(${at}) = 'number' THEN (${at})::numeric END)`;
|
|
232
|
+
case 'boolean':
|
|
233
|
+
return `(CASE WHEN jsonb_typeof(${at}) = 'boolean' `
|
|
234
|
+
+ `THEN (CASE WHEN (${at})::boolean THEN 1 ELSE 0 END) END)`;
|
|
235
|
+
// a projected SCALAR leaf: its text, whatever JSON type it is. The
|
|
236
|
+
// decoder reads the type name beside it and rebuilds the value, so
|
|
237
|
+
// one column that can carry every scalar is what it needs
|
|
238
|
+
case 'scalar':
|
|
239
|
+
return `(${columnSql} #>> ${stringLiteral(pathText)})`;
|
|
240
|
+
default:
|
|
241
|
+
return at;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* The schema type a declared column type came from. Two mappings here
|
|
247
|
+
* are LOSSY on the way back and the introspector's report says so:
|
|
248
|
+
* `numeric` carries both `integer` and `number`, and `smallint` carries
|
|
249
|
+
* a boolean member's 1 and 0. `jsonb` carries no scalar type at all.
|
|
250
|
+
* @param {string} declaredType
|
|
251
|
+
* @returns {string | undefined}
|
|
252
|
+
*/
|
|
253
|
+
function schemaTypeOf(declaredType) {
|
|
254
|
+
switch (comparableColumnType(declaredType)) {
|
|
255
|
+
case 'TEXT': case 'VARCHAR': case 'CHARACTER': return 'string';
|
|
256
|
+
case 'NUMERIC': case 'DOUBLE PRECISION': case 'REAL': return 'number';
|
|
257
|
+
case 'BIGINT': case 'INTEGER': case 'SMALLINT': return 'integer';
|
|
258
|
+
case 'BOOLEAN': return 'boolean';
|
|
259
|
+
default: return undefined;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** The words a re-rendered expression puts before a parenthesis that
|
|
264
|
+
* are not function calls: `WHEN (`, `AND (`, and their kin. */
|
|
265
|
+
const SQL_WORDS = new Set(['CASE', 'WHEN', 'THEN', 'ELSE', 'END', 'AND', 'OR', 'NOT',
|
|
266
|
+
'IN', 'IS', 'BETWEEN', 'LIKE', 'COLLATE', 'SELECT', 'WHERE']);
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* One PostgreSQL array literal's elements. The server RE-RENDERS the
|
|
270
|
+
* literal it stored — dropping the quotes a member did not need — so
|
|
271
|
+
* the parse has to take both forms, and a member named `a,b` comes back
|
|
272
|
+
* quoted for exactly that reason.
|
|
273
|
+
* @param {string} body - between the braces
|
|
274
|
+
* @returns {string[] | null}
|
|
275
|
+
*/
|
|
276
|
+
function parseArrayLiteral(body) {
|
|
277
|
+
const out = [];
|
|
278
|
+
let i = 0;
|
|
279
|
+
if (body.length === 0) return out;
|
|
280
|
+
while (i <= body.length) {
|
|
281
|
+
if (body[i] === '"') {
|
|
282
|
+
let text = '';
|
|
283
|
+
i += 1;
|
|
284
|
+
while (i < body.length && body[i] !== '"') {
|
|
285
|
+
if (body[i] === '\\') { text += body[i + 1] ?? ''; i += 2; continue; }
|
|
286
|
+
text += body[i];
|
|
287
|
+
i += 1;
|
|
288
|
+
}
|
|
289
|
+
if (body[i] !== '"') return null;
|
|
290
|
+
out.push(text);
|
|
291
|
+
i += 1;
|
|
292
|
+
}
|
|
293
|
+
else {
|
|
294
|
+
const comma = body.indexOf(',', i);
|
|
295
|
+
const end = comma < 0 ? body.length : comma;
|
|
296
|
+
out.push(body.slice(i, end));
|
|
297
|
+
i = end;
|
|
298
|
+
}
|
|
299
|
+
if (i >= body.length) return out;
|
|
300
|
+
if (body[i] !== ',') return null;
|
|
301
|
+
i += 1;
|
|
302
|
+
}
|
|
303
|
+
return out;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* The member path a generated column's expression reads, recovered from
|
|
308
|
+
* this dialect's own spelling. Every form it writes navigates with
|
|
309
|
+
* `#>` or `#>>` over an array literal, and the FIRST one is the member
|
|
310
|
+
* — the guard reads the same path, and a `CASE` cannot come before it.
|
|
311
|
+
* Anything else answers `null`, and the caller reports the column
|
|
312
|
+
* rather than inventing a path for it.
|
|
313
|
+
* @param {string} expression
|
|
314
|
+
* @returns {import('../dialect.js').JsonPathSegment[] | null}
|
|
315
|
+
*/
|
|
316
|
+
function memberPathOf(expression) {
|
|
317
|
+
const text = String(expression);
|
|
318
|
+
// and NOTHING else calls: every form `jsonExtract` writes navigates
|
|
319
|
+
// with `#>` under at most a `jsonb_typeof` guard, so an expression
|
|
320
|
+
// that calls anything else is a DECLARED index expression rather than
|
|
321
|
+
// a member read — and reading it as a member would lose the index
|
|
322
|
+
for (const call of text.matchAll(/\b([A-Za-z_][A-Za-z0-9_]*)\s*\(/g)) {
|
|
323
|
+
if (call[1] === 'jsonb_typeof' || SQL_WORDS.has(call[1].toUpperCase())) continue;
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
const match = /#>>?\s*'\{((?:[^']|'')*)\}'/.exec(text);
|
|
327
|
+
if (match === null) return null;
|
|
328
|
+
const elements = parseArrayLiteral(match[1].replace(/''/g, "'"));
|
|
329
|
+
if (elements === null || elements.length === 0) return null;
|
|
330
|
+
// an element that spells a whole non-negative integer is an ARRAY
|
|
331
|
+
// INDEX here, exactly as it was on the way in
|
|
332
|
+
return elements.map((element) => (/^(?:0|[1-9][0-9]*)$/.test(element)
|
|
333
|
+
? { index: Number(element) }
|
|
334
|
+
: { name: element }));
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* The declared index EXPRESSION a generated column computes, out of the
|
|
339
|
+
* SQL this dialect wrote for it. Here the engine calls its OWN
|
|
340
|
+
* immutable function, so `byName` maps the host's `sql` name back to the
|
|
341
|
+
* model's — a mapping only the host's declarations carry.
|
|
342
|
+
* @param {string} expression
|
|
343
|
+
* @param {Record<string, string>} byName
|
|
344
|
+
* @returns {any | null}
|
|
345
|
+
*/
|
|
346
|
+
function expressionOf(expression, byName) {
|
|
347
|
+
return readExpression(expression, {
|
|
348
|
+
memberOf: (text) => {
|
|
349
|
+
// a member read here is `doc #>> '{…}'` and nothing else: the
|
|
350
|
+
// typed forms belong to a plain path index, not to an expression
|
|
351
|
+
if (!/#>>/.test(text)) return null;
|
|
352
|
+
const segments = memberPathOf(text);
|
|
353
|
+
return segments === null ? null : { member: pathOf(segments) };
|
|
354
|
+
},
|
|
355
|
+
nameOf: (name) => byName[name] ?? null,
|
|
356
|
+
stringOf: (text) => (/^'(?:[^']|'')*'(?:::text)?$/.test(text)
|
|
357
|
+
? text.replace(/::text$/, '').slice(1, -1).replace(/''/g, "'") : null),
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** A recovered segment list, in the spelling a model's path takes. */
|
|
362
|
+
function pathOf(segments) {
|
|
363
|
+
let text = '$';
|
|
364
|
+
for (const segment of segments) {
|
|
365
|
+
if ('index' in segment) { text += `[${segment.index}]`; continue; }
|
|
366
|
+
text += /^[A-Za-z_][A-Za-z0-9_]*$/.test(segment.name)
|
|
367
|
+
? `.${segment.name}` : `[${JSON.stringify(segment.name)}]`;
|
|
368
|
+
}
|
|
369
|
+
return text;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Build the dialect. A function rather than a constant because a host
|
|
374
|
+
* may narrow it — today only the schema search path is worth naming,
|
|
375
|
+
* and the default is the connection's own, which is what a disposable
|
|
376
|
+
* per-run schema needs.
|
|
377
|
+
* @param {{ searchPath?: string }} [options]
|
|
378
|
+
* @returns {any}
|
|
379
|
+
*/
|
|
380
|
+
export function postgresDialect(options = undefined) {
|
|
381
|
+
// the namespaces a catalog query looks in: the connection's own
|
|
382
|
+
// search path unless the host names one, so a store opened against a
|
|
383
|
+
// disposable schema introspects that schema and not `public`
|
|
384
|
+
const namespaces = options?.searchPath === undefined
|
|
385
|
+
? 'ANY(current_schemas(false))'
|
|
386
|
+
: stringLiteral(options.searchPath);
|
|
387
|
+
const inNamespace = `n.nspname = ${namespaces}`;
|
|
388
|
+
|
|
389
|
+
return createDialect({
|
|
390
|
+
name: 'postgres',
|
|
391
|
+
capabilities: {
|
|
392
|
+
jsonb: true,
|
|
393
|
+
generatedColumns: true,
|
|
394
|
+
// STORED ones are; PostgreSQL 18's VIRTUAL ones are not, which is
|
|
395
|
+
// why this dialect declares STORED and this capability true
|
|
396
|
+
indexableGeneratedColumns: true,
|
|
397
|
+
untypedColumns: false,
|
|
398
|
+
returning: true,
|
|
399
|
+
upsert: true,
|
|
400
|
+
savepoints: true,
|
|
401
|
+
// no `BEGIN IMMEDIATE`: a PostgreSQL transaction takes its locks
|
|
402
|
+
// as it needs them, and a read-then-write body meets a
|
|
403
|
+
// serialization failure rather than a busy database
|
|
404
|
+
immediateTransactions: false,
|
|
405
|
+
// a GROUP BY / ORDER BY term may name an output column
|
|
406
|
+
groupByAlias: true,
|
|
407
|
+
alterTableFull: true,
|
|
408
|
+
virtualTables: false,
|
|
409
|
+
triggers: false,
|
|
410
|
+
pragmas: false,
|
|
411
|
+
declaredSqlText: false,
|
|
412
|
+
foreignKeysAlwaysOn: true,
|
|
413
|
+
// through the declared `rid` column, not through `ctid`
|
|
414
|
+
rowIdentity: true,
|
|
415
|
+
},
|
|
416
|
+
tableSuffix: '',
|
|
417
|
+
// PostgreSQL has no indexable virtual generated column, so an
|
|
418
|
+
// indexed path is materialised
|
|
419
|
+
generatedStorage: 'STORED',
|
|
420
|
+
// the identity every table this dialect creates carries. NOT
|
|
421
|
+
// `GENERATED ALWAYS AS IDENTITY`: PostgreSQL allows one identity
|
|
422
|
+
// column per table, and a collection with a database-allocated
|
|
423
|
+
// integer key needs that slot for its key
|
|
424
|
+
identityColumn: { name: 'rid', type: 'bigserial' },
|
|
425
|
+
autoKeyType: 'bigserial',
|
|
426
|
+
epochFromRfc3339: (valueSql) =>
|
|
427
|
+
`round(EXTRACT(EPOCH FROM (${valueSql})::timestamptz) * 1000)::bigint`,
|
|
428
|
+
docColumnType: 'jsonb',
|
|
429
|
+
// the packed little-endian binary32 form of a `derive: 'vector'`
|
|
430
|
+
// column. Deliberately NOT `vector` from pgvector: a model may not
|
|
431
|
+
// name a vendor type, and the k-nearest cut is the engine's
|
|
432
|
+
packedVectorType: 'bytea',
|
|
433
|
+
quoteIdentifier,
|
|
434
|
+
parameterRef: (i) => `$${i}`,
|
|
435
|
+
stringLiteral,
|
|
436
|
+
booleanLiteral: (b) => (b ? 'TRUE' : 'FALSE'),
|
|
437
|
+
typeFor,
|
|
438
|
+
comparableColumnType,
|
|
439
|
+
columnUsableFor,
|
|
440
|
+
limitClause: (limit, offset) => (offset !== undefined && offset > 0
|
|
441
|
+
? `LIMIT ${limit === null ? 'ALL' : limit} OFFSET ${offset}`
|
|
442
|
+
: `LIMIT ${limit === null ? 'ALL' : limit}`),
|
|
443
|
+
jsonPathText,
|
|
444
|
+
jsonExtract,
|
|
445
|
+
// a DERIVED column's expression names a function the HOST supplies;
|
|
446
|
+
// this dialect creates none and assumes none. Every driver this
|
|
447
|
+
// package ships for PostgreSQL declares
|
|
448
|
+
// `deterministicIndexableFunctions: false`, so the store computes
|
|
449
|
+
// these values and writes them into ordinary columns — which is why
|
|
450
|
+
// asking for the expression is a planner defect, exactly as it is
|
|
451
|
+
// for a vector column on every driver
|
|
452
|
+
derivedExpression: (memberSql, column) => {
|
|
453
|
+
throw new TypeError(
|
|
454
|
+
`postgres dialect: no generated-column expression for derive kind '${column.derive}' `
|
|
455
|
+
+ '— a derived column is STORED here, and the store writes its value');
|
|
456
|
+
},
|
|
457
|
+
jsonSet: (exprSql, pathText, valueSql) =>
|
|
458
|
+
`jsonb_set(${exprSql}, ${stringLiteral(pathText)}, ${valueSql}, true)`,
|
|
459
|
+
jsonRemove: (exprSql, pathText) =>
|
|
460
|
+
`(${exprSql} #- ${stringLiteral(pathText)})`,
|
|
461
|
+
// `-1` is the position AFTER the array's last element when
|
|
462
|
+
// `insert_after` is true, which is the append this translates
|
|
463
|
+
jsonAppend: (exprSql, arrayPathText, valueSql) =>
|
|
464
|
+
`jsonb_insert(${exprSql}, ${stringLiteral(pathWith(arrayPathText, '-1'))}, ${valueSql}, true)`,
|
|
465
|
+
jsonEncode: (paramSql) => `(${paramSql})::jsonb`,
|
|
466
|
+
jsonText: (columnSql) => `(${columnSql})::text`,
|
|
467
|
+
jsonEmbed: (columnSql) => `(${columnSql})`,
|
|
468
|
+
jsonAgg: (exprSql) => `jsonb_agg(${exprSql})`,
|
|
469
|
+
jsonObject: (pairsSql) => `jsonb_build_object(${pairsSql})`,
|
|
470
|
+
jsonTypeOf: (columnSql, pathText) =>
|
|
471
|
+
typeOfJsonb(`(${columnSql} #> ${stringLiteral(pathText)})`),
|
|
472
|
+
// one JSON number type, so one name
|
|
473
|
+
numericTypeNames: ['number'],
|
|
474
|
+
// An external's value is bound as its JSON TEXT. A PostgreSQL
|
|
475
|
+
// parameter's type is resolved where it is used, once, for the
|
|
476
|
+
// whole statement — so one placeholder cannot be a text member's
|
|
477
|
+
// operand in one branch and a numeric member's in another, and the
|
|
478
|
+
// guard that keeps a row out of the wrong branch does not keep the
|
|
479
|
+
// COERCION out. Bound as JSON, both branches compare in a space
|
|
480
|
+
// that holds every scalar: text against text under `C`, and number
|
|
481
|
+
// against number in `jsonb`'s own numeric order.
|
|
482
|
+
externalEncoding: 'json',
|
|
483
|
+
externalCompare: (valueSql, kind) =>
|
|
484
|
+
(kind === 'number' ? `to_jsonb(${valueSql})` : valueSql),
|
|
485
|
+
externalRef: (paramSql, kind) => (kind === 'text'
|
|
486
|
+
? `((((${paramSql})::jsonb) #>> '{}') COLLATE "C")`
|
|
487
|
+
: `((${paramSql})::jsonb)`),
|
|
488
|
+
valueTypeOf: (paramSql) => typeOfJsonb(`((${paramSql})::jsonb)`),
|
|
489
|
+
// the half-open range over the prefix, seekable through a B-tree on
|
|
490
|
+
// a `C`-collated column. Both operands are byte-ordered — the column
|
|
491
|
+
// by its declaration, the extracted member by the `COLLATE` in
|
|
492
|
+
// `jsonExtract` — which is what makes the range hold exactly the
|
|
493
|
+
// values that begin with the prefix
|
|
494
|
+
strStartsWith: (valueSql, lowerParamSql, upperParamSql) =>
|
|
495
|
+
`(${valueSql} >= ${lowerParamSql} AND ${valueSql} < ${upperParamSql})`,
|
|
496
|
+
strStartsWithExact: (valueSql, patternA, patternB) =>
|
|
497
|
+
`substr(${valueSql}, 1, length(${patternA}::text)) = ${patternB}`,
|
|
498
|
+
strEndsWith: (valueSql, patternA, patternB, patternC) =>
|
|
499
|
+
`(length(${patternA}::text) = 0 OR right(${valueSql}, length(${patternB}::text)) = ${patternC})`,
|
|
500
|
+
strContains: (valueSql, patternSql) => `strpos(${valueSql}, ${patternSql}::text) > 0`,
|
|
501
|
+
orderNulls: (nullsFirst) => (nullsFirst ? ' NULLS FIRST' : ' NULLS LAST'),
|
|
502
|
+
// the same fixed ladder, in exact arithmetic: `%` truncates towards
|
|
503
|
+
// zero here as it does in C, so `((x % m) + m) % m` is the
|
|
504
|
+
// non-negative remainder and an instant before 1970 lands in its own
|
|
505
|
+
// bucket rather than the one after it
|
|
506
|
+
timeBucket: (instantSql, originSql, everyA, everyB, everyC) =>
|
|
507
|
+
`(${instantSql} - (((${instantSql} - ${originSql}) % ${everyA} + ${everyB}) % ${everyC}))`,
|
|
508
|
+
groupAggregate: (fn, valueSql) => (valueSql === null
|
|
509
|
+
? 'COUNT(*)'
|
|
510
|
+
: `${{ sum: 'SUM', avg: 'AVG', min: 'MIN', max: 'MAX' }[fn]}(${valueSql})`),
|
|
511
|
+
rowIdentity: () => '"rid"',
|
|
512
|
+
identityIn: (identitySql, paramSqls) => `${identitySql} IN (${paramSqls.join(', ')})`,
|
|
513
|
+
schemaTypeOf,
|
|
514
|
+
memberPathOf,
|
|
515
|
+
expressionOf,
|
|
516
|
+
// the catalog already answers one row per generated column
|
|
517
|
+
readGenerated: (rows) => rows.map((row) => ({
|
|
518
|
+
name: String(row.name), expression: String(row.expression ?? ''),
|
|
519
|
+
})),
|
|
520
|
+
explainQuery: (sql) => `EXPLAIN ${sql}`,
|
|
521
|
+
// PostgreSQL's plan is prose too, under a column whose name has a
|
|
522
|
+
// space in it
|
|
523
|
+
explainLines: (rows) => rows.map((row) => String(row['QUERY PLAN'] ?? row.plan ?? '')),
|
|
524
|
+
isFullScan: (line, tables) => {
|
|
525
|
+
const match = /\bSeq Scan on (?:\w+\.)?"?([^\s"]+)"?/.exec(line);
|
|
526
|
+
if (match === null) return false;
|
|
527
|
+
// a join statement aliases its tables `t0`, `t1`, …, and the plan
|
|
528
|
+
// names the relation with the alias after it
|
|
529
|
+
return tables.includes(match[1]) || /\bSeq Scan on \S+ t\d+\b/.test(line);
|
|
530
|
+
},
|
|
531
|
+
usesIndex: (line, index) =>
|
|
532
|
+
line.includes(`Index Scan using ${index}`)
|
|
533
|
+
|| line.includes(`Index Only Scan using ${index}`)
|
|
534
|
+
|| line.includes(`Bitmap Index Scan on ${index}`),
|
|
535
|
+
excludedRef: (columnSql) => `excluded.${columnSql}`,
|
|
536
|
+
tx: {
|
|
537
|
+
begin: 'BEGIN',
|
|
538
|
+
// no up-front write lock exists; the capability says so and the
|
|
539
|
+
// store's `mode: 'immediate'` is the same transaction here
|
|
540
|
+
beginImmediate: 'BEGIN',
|
|
541
|
+
commit: 'COMMIT',
|
|
542
|
+
rollback: 'ROLLBACK',
|
|
543
|
+
savepoint: (n) => `SAVEPOINT ${quoteIdentifier(n)}`,
|
|
544
|
+
release: (n) => `RELEASE SAVEPOINT ${quoteIdentifier(n)}`,
|
|
545
|
+
rollbackTo: (n) => `ROLLBACK TO SAVEPOINT ${quoteIdentifier(n)}`,
|
|
546
|
+
},
|
|
547
|
+
introspect: {
|
|
548
|
+
version: () => "SELECT current_setting('server_version') AS version",
|
|
549
|
+
// the table probe binds its name, so a hostile collection name is
|
|
550
|
+
// a value and never syntax
|
|
551
|
+
tableExists: () =>
|
|
552
|
+
'SELECT c.relname AS name FROM pg_class c '
|
|
553
|
+
+ 'JOIN pg_namespace n ON n.oid = c.relnamespace '
|
|
554
|
+
+ `WHERE c.relkind IN ('r', 'p') AND ${inNamespace} AND c.relname = $1`,
|
|
555
|
+
// `hidden` is non-zero for a GENERATED column, which is the one
|
|
556
|
+
// fact the shape check reads beside the name and the type
|
|
557
|
+
columns: (table) =>
|
|
558
|
+
'SELECT a.attname AS name, format_type(a.atttypid, a.atttypmod) AS type, '
|
|
559
|
+
+ "CASE WHEN a.attgenerated <> '' THEN 1 ELSE 0 END AS hidden "
|
|
560
|
+
+ 'FROM pg_attribute a JOIN pg_class c ON c.oid = a.attrelid '
|
|
561
|
+
+ 'JOIN pg_namespace n ON n.oid = c.relnamespace '
|
|
562
|
+
+ `WHERE c.relname = ${stringLiteral(table)} AND ${inNamespace} `
|
|
563
|
+
+ 'AND a.attnum > 0 AND NOT a.attisdropped ORDER BY a.attnum',
|
|
564
|
+
// `origin` mirrors the vocabulary the shape check filters on:
|
|
565
|
+
// only an index the model DECLARED is compared, and the primary
|
|
566
|
+
// key's is the engine's own
|
|
567
|
+
indexes: (table) =>
|
|
568
|
+
'SELECT ci.relname AS name, CASE WHEN i.indisunique THEN 1 ELSE 0 END AS uniq, '
|
|
569
|
+
+ "CASE WHEN i.indisprimary THEN 'pk' ELSE 'c' END AS origin "
|
|
570
|
+
+ 'FROM pg_index i JOIN pg_class ci ON ci.oid = i.indexrelid '
|
|
571
|
+
+ 'JOIN pg_class ct ON ct.oid = i.indrelid '
|
|
572
|
+
+ 'JOIN pg_namespace n ON n.oid = ct.relnamespace '
|
|
573
|
+
+ `WHERE ct.relname = ${stringLiteral(table)} AND ${inNamespace} `
|
|
574
|
+
+ 'ORDER BY ci.relname',
|
|
575
|
+
// in the index's OWN column order: `(a, b)` and `(b, a)` are
|
|
576
|
+
// different indexes, and only one of them serves an `a` prefix
|
|
577
|
+
indexColumns: (index) =>
|
|
578
|
+
'SELECT a.attname AS name FROM pg_index i '
|
|
579
|
+
+ 'JOIN pg_class ci ON ci.oid = i.indexrelid '
|
|
580
|
+
+ 'JOIN pg_namespace n ON n.oid = ci.relnamespace '
|
|
581
|
+
+ 'CROSS JOIN LATERAL unnest(i.indkey::int2[]) WITH ORDINALITY AS k(attnum, ord) '
|
|
582
|
+
+ 'JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = k.attnum '
|
|
583
|
+
+ `WHERE ci.relname = ${stringLiteral(index)} AND ${inNamespace} ORDER BY k.ord`,
|
|
584
|
+
// every table this store might own; a view is reported rather
|
|
585
|
+
// than derived, and the engine's own schemas are never in scope
|
|
586
|
+
tables: () =>
|
|
587
|
+
"SELECT c.relname AS name, CASE WHEN c.relkind IN ('v', 'm') THEN 'view' "
|
|
588
|
+
+ "ELSE 'table' END AS type FROM pg_class c "
|
|
589
|
+
+ 'JOIN pg_namespace n ON n.oid = c.relnamespace '
|
|
590
|
+
+ `WHERE c.relkind IN ('r', 'p', 'v', 'm') AND ${inNamespace} `
|
|
591
|
+
+ 'ORDER BY type, c.relname',
|
|
592
|
+
generated: (table) =>
|
|
593
|
+
'SELECT a.attname AS name, pg_get_expr(d.adbin, d.adrelid) AS expression '
|
|
594
|
+
+ 'FROM pg_attrdef d '
|
|
595
|
+
+ 'JOIN pg_attribute a ON a.attrelid = d.adrelid AND a.attnum = d.adnum '
|
|
596
|
+
+ 'JOIN pg_class c ON c.oid = d.adrelid '
|
|
597
|
+
+ 'JOIN pg_namespace n ON n.oid = c.relnamespace '
|
|
598
|
+
+ `WHERE c.relname = ${stringLiteral(table)} AND ${inNamespace} `
|
|
599
|
+
+ "AND a.attgenerated <> '' ORDER BY a.attnum",
|
|
600
|
+
foreignKeyList: (table) => {
|
|
601
|
+
const action = (column) => `CASE ${column} `
|
|
602
|
+
+ "WHEN 'a' THEN 'NO ACTION' WHEN 'r' THEN 'RESTRICT' WHEN 'c' THEN 'CASCADE' "
|
|
603
|
+
+ "WHEN 'n' THEN 'SET NULL' WHEN 'd' THEN 'SET DEFAULT' ELSE 'NO ACTION' END";
|
|
604
|
+
return 'SELECT tt.relname AS target, sa.attname AS source_column, '
|
|
605
|
+
+ `ta.attname AS target_column, ${action('c.confdeltype')} AS on_delete, `
|
|
606
|
+
+ `${action('c.confupdtype')} AS on_update, k.ord - 1 AS seq `
|
|
607
|
+
+ 'FROM pg_constraint c JOIN pg_class ct ON ct.oid = c.conrelid '
|
|
608
|
+
+ 'JOIN pg_namespace n ON n.oid = ct.relnamespace '
|
|
609
|
+
+ 'JOIN pg_class tt ON tt.oid = c.confrelid '
|
|
610
|
+
+ 'CROSS JOIN LATERAL unnest(c.conkey, c.confkey) WITH ORDINALITY AS k(src, tgt, ord) '
|
|
611
|
+
+ 'JOIN pg_attribute sa ON sa.attrelid = c.conrelid AND sa.attnum = k.src '
|
|
612
|
+
+ 'JOIN pg_attribute ta ON ta.attrelid = c.confrelid AND ta.attnum = k.tgt '
|
|
613
|
+
+ `WHERE c.contype = 'f' AND ct.relname = ${stringLiteral(table)} AND ${inNamespace} `
|
|
614
|
+
+ 'ORDER BY c.conname, k.ord';
|
|
615
|
+
},
|
|
616
|
+
},
|
|
617
|
+
});
|
|
618
|
+
}
|