@jarenjs/db 0.49.2 → 0.66.1
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 +420 -71
- package/README.md +711 -79
- package/docs/HOSTS.md +269 -0
- package/docs/JOBS-FORMAT.md +309 -45
- package/docs/LIVE-FORMAT.md +156 -19
- package/docs/MIGRATION-FORMAT.md +247 -40
- package/docs/MODEL-FORMAT.md +968 -86
- package/package.json +21 -8
- package/schemas/jaren-migration.draft-07.schema.json +73 -0
- package/schemas/jaren-migration.schema.json +73 -0
- package/schemas/jaren-model.draft-07.schema.json +224 -162
- package/schemas/jaren-model.schema.json +224 -162
- package/src/algebra.js +227 -9
- package/src/backup.js +161 -0
- package/src/cancellation.js +48 -0
- package/src/capture.js +255 -44
- package/src/cli.js +337 -50
- package/src/cursor.js +411 -0
- package/src/dag-job.js +154 -21
- package/src/ddl.js +125 -11
- package/src/dialect.js +267 -112
- 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 +245 -12
- package/src/document-files.js +311 -0
- package/src/document-steps.js +422 -0
- package/src/documents.js +335 -0
- package/src/driver.js +503 -69
- 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 +18 -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-model.js +14 -0
- package/src/emit.js +349 -51
- package/src/entity.js +102 -59
- package/src/errors.js +430 -2
- package/src/expression.js +284 -0
- package/src/graph.js +64 -8
- package/src/index.js +48 -19
- package/src/introspect.js +583 -0
- package/src/jobs.js +870 -99
- package/src/json-bytes.js +58 -0
- package/src/live-time.js +12 -3
- package/src/live.js +11 -1
- package/src/maintenance.js +175 -0
- package/src/migrate.js +606 -333
- package/src/model.js +241 -8
- package/src/plan.js +1238 -160
- package/src/pragmas.js +314 -0
- package/src/profile.js +151 -3
- package/src/query.js +1748 -312
- package/src/residual.js +17 -0
- package/src/series.js +12 -4
- package/src/store.js +1672 -276
- package/src/tracker.js +367 -68
- package/src/udf.js +88 -7
- package/types/index.d.ts +1246 -32
- package/types/node-pool.d.ts +28 -0
- package/types/node-worker.d.ts +54 -0
- package/types/node.d.ts +72 -3
- package/types/postgres.d.ts +46 -0
- package/types/typed.d.ts +81 -3
- package/types/wasm.d.ts +21 -0
- package/dist/types/algebra.d.ts +0 -230
- package/dist/types/app.d.ts +0 -49
- package/dist/types/capture.d.ts +0 -85
- package/dist/types/cli.d.ts +0 -2
- package/dist/types/dag-job.d.ts +0 -40
- package/dist/types/ddl.d.ts +0 -229
- package/dist/types/derive.d.ts +0 -250
- package/dist/types/dialect.d.ts +0 -154
- package/dist/types/dialects/sqlite.d.ts +0 -9
- package/dist/types/driver.d.ts +0 -110
- package/dist/types/drivers/bun.d.ts +0 -47
- package/dist/types/drivers/node.d.ts +0 -37
- package/dist/types/drivers/wasm.d.ts +0 -65
- package/dist/types/emit-model.d.ts +0 -44
- package/dist/types/emit.d.ts +0 -75
- package/dist/types/entity.d.ts +0 -23
- package/dist/types/errors.d.ts +0 -170
- package/dist/types/graph.d.ts +0 -28
- package/dist/types/index.d.ts +0 -37
- package/dist/types/jobs.d.ts +0 -140
- package/dist/types/knn.d.ts +0 -69
- package/dist/types/live-time.d.ts +0 -141
- package/dist/types/live.d.ts +0 -64
- package/dist/types/migrate.d.ts +0 -170
- package/dist/types/model.d.ts +0 -36
- package/dist/types/patch-sql.d.ts +0 -37
- package/dist/types/plan.d.ts +0 -142
- package/dist/types/profile.d.ts +0 -80
- package/dist/types/query.d.ts +0 -112
- package/dist/types/residual.d.ts +0 -64
- package/dist/types/series.d.ts +0 -227
- package/dist/types/store.d.ts +0 -60
- package/dist/types/tracker.d.ts +0 -43
- package/dist/types/typed.d.ts +0 -15
- package/dist/types/types.d.ts +0 -26
- package/dist/types/udf.d.ts +0 -75
- package/dist/types/window.d.ts +0 -52
package/src/emit.js
CHANGED
|
@@ -18,11 +18,20 @@
|
|
|
18
18
|
|
|
19
19
|
import { codePointPrefixSuccessor } from '@jarenjs/core/string';
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* A promoted path the dialect's JSON path grammar cannot spell (a
|
|
23
|
+
* member name holding a double quote or a control character). The
|
|
24
|
+
* planner promotes by SCHEMA, not by grammar, so the query engine
|
|
25
|
+
* catches this and runs the document in the set residual instead.
|
|
26
|
+
*/
|
|
27
|
+
export class UnrepresentablePath extends Error {}
|
|
28
|
+
|
|
21
29
|
/**
|
|
22
30
|
* @typedef {{ external: string } | { literal: unknown } |
|
|
23
31
|
* { derived: { kind: 'bboxAxis', external: string,
|
|
24
|
-
* axis: 'w' | 's' | 'e' | 'n' } }
|
|
25
|
-
*
|
|
32
|
+
* axis: 'w' | 's' | 'e' | 'n' } } |
|
|
33
|
+
* { typed: { seek: string, type: 'number' | 'text' } }} ParamSlot
|
|
34
|
+
* Four kinds, closed. A DERIVED slot is the escape for a value SQL
|
|
26
35
|
* cannot bind at all: a GeoJSON region arrives as an external object,
|
|
27
36
|
* and what the statement needs is one edge of its bounding box, so
|
|
28
37
|
* the binder computes that edge from the bound value. It is the same
|
|
@@ -30,6 +39,14 @@ import { codePointPrefixSuccessor } from '@jarenjs/core/string';
|
|
|
30
39
|
* cannot, bind an ordinary parameter — and it stays closed on
|
|
31
40
|
* purpose: a general expression slot would be a second query language
|
|
32
41
|
* living in the emitter.
|
|
42
|
+
*
|
|
43
|
+
* A TYPED slot is the other direction: a scalar whose JSON type is
|
|
44
|
+
* PROVEN before the statement binds — the database's own answer to
|
|
45
|
+
* one of the plan's seeks, read from a column of declared type. An
|
|
46
|
+
* external's type is only knowable at bind time, so its comparison
|
|
47
|
+
* carries a text branch beside a number branch; a typed slot carries
|
|
48
|
+
* one guarded comparison, the same shape a literal gets, and the
|
|
49
|
+
* binder refuses a value of the wrong type before any SQL runs.
|
|
33
50
|
*/
|
|
34
51
|
|
|
35
52
|
/**
|
|
@@ -69,6 +86,7 @@ function stropForm(dialect, param, valueSql, pred) {
|
|
|
69
86
|
function slotName(slot) {
|
|
70
87
|
if ('external' in slot) return slot.external;
|
|
71
88
|
if ('derived' in slot) return slot.derived.external;
|
|
89
|
+
if ('typed' in slot) return slot.typed.seek;
|
|
72
90
|
return 'value';
|
|
73
91
|
}
|
|
74
92
|
|
|
@@ -106,28 +124,112 @@ export function emitPlan(plan, dialect, physical) {
|
|
|
106
124
|
const docColumn = q(physical.docColumn);
|
|
107
125
|
/** @type {ParamSlot[]} */
|
|
108
126
|
const slots = [];
|
|
127
|
+
// the statement being emitted owns its slots: a positional dialect
|
|
128
|
+
// numbers by that statement's own text order, so a SEEK emitted
|
|
129
|
+
// beside the main statement numbers from one again
|
|
130
|
+
let sink = slots;
|
|
109
131
|
const param = (slot) => {
|
|
110
|
-
|
|
111
|
-
return dialect.parameterRef(
|
|
132
|
+
sink.push(slot);
|
|
133
|
+
return dialect.parameterRef(sink.length, slotName(slot));
|
|
112
134
|
};
|
|
113
135
|
|
|
114
|
-
/**
|
|
115
|
-
|
|
116
|
-
|
|
136
|
+
/**
|
|
137
|
+
* SQL for a ref's VALUE: the generated column when one exists AND
|
|
138
|
+
* this dialect can compare that column's declared type against a
|
|
139
|
+
* value of `kind`. Where it cannot — an engine whose columns carry a
|
|
140
|
+
* real SQL type, asked to compare a text column with a number — the
|
|
141
|
+
* member is read out of the document instead. Same answer, unindexed,
|
|
142
|
+
* and never a type error the row's own type guard already excludes.
|
|
143
|
+
* @param {any} ref
|
|
144
|
+
* @param {'any' | 'text' | 'number' | 'boolean'} [kind]
|
|
145
|
+
*/
|
|
146
|
+
const valueOf = (ref, kind = 'any') =>
|
|
147
|
+
(ref.column !== null && dialect.columnUsableFor(ref.type, kind)
|
|
148
|
+
? q(ref.column)
|
|
149
|
+
: dialect.jsonExtract(docColumn, pathTextOf(ref), kind));
|
|
117
150
|
const pathTextOf = (ref) => {
|
|
118
151
|
const text = dialect.jsonPathText(ref.segments);
|
|
119
152
|
if (text === null) {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
throw new Error('emit: a promoted path is not representable in the dialect JSON path grammar');
|
|
153
|
+
throw new UnrepresentablePath('a member name the dialect\'s JSON path grammar cannot '
|
|
154
|
+
+ 'carry (a double quote or a control character) runs in the residual');
|
|
123
155
|
}
|
|
124
156
|
return text;
|
|
125
157
|
};
|
|
126
158
|
/** The presence/type discriminator, always over the document column. */
|
|
127
159
|
const typeOf = (ref) => dialect.jsonTypeOf(docColumn, pathTextOf(ref));
|
|
160
|
+
/** One projected member: its value beside its JSON type, under a
|
|
161
|
+
* suffixed pair of names the decoder reads back. */
|
|
162
|
+
const projectedPair = (ref, suffix) =>
|
|
163
|
+
`CASE WHEN ${typeOf(ref)} IN (${sl('object')}, ${sl('array')}) `
|
|
164
|
+
+ `THEN ${dialect.jsonText(dialect.jsonExtract(docColumn, pathTextOf(ref)))} `
|
|
165
|
+
// a SCALAR leaf comes back as the value the decoder reads: the
|
|
166
|
+
// engine's own scalar where it has one, its text where every
|
|
167
|
+
// member is one JSON type and a CASE could not answer two
|
|
168
|
+
+ `ELSE ${dialect.jsonExtract(docColumn, pathTextOf(ref), 'scalar')} `
|
|
169
|
+
+ `END AS ${q(`v${suffix}`)}, ${typeOf(ref)} AS ${q(`t${suffix}`)}`;
|
|
128
170
|
|
|
129
171
|
const sl = dialect.stringLiteral;
|
|
130
|
-
|
|
172
|
+
/** The discriminator's spellings for a JSON NUMBER, as this engine
|
|
173
|
+
* answers them: SQLite keeps `integer` and `real` apart, an engine
|
|
174
|
+
* with one JSON number type answers one name. */
|
|
175
|
+
const NUMERIC = () => `(${dialect.numericTypeNames.map(sl).join(', ')})`;
|
|
176
|
+
|
|
177
|
+
/** The comparison kind a member's DECLARED schema type implies —
|
|
178
|
+
* what a fold or an ordering over it reads the member as. An
|
|
179
|
+
* undeclared type has none, and the member is read whole. */
|
|
180
|
+
const kindOf = (ref) => {
|
|
181
|
+
switch (ref?.type) {
|
|
182
|
+
case 'integer': case 'number': return 'number';
|
|
183
|
+
case 'string': return 'text';
|
|
184
|
+
case 'boolean': return 'boolean';
|
|
185
|
+
default: return 'any';
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* One aggregate's argument, read at the kind the FOLD needs rather
|
|
191
|
+
* than at the member's own: a sum or an average is arithmetic
|
|
192
|
+
* whatever the schema says, an extreme is the member's own ordering.
|
|
193
|
+
*
|
|
194
|
+
* An engine whose columns carry a real SQL type has no fold over a
|
|
195
|
+
* member with no declared type — there is no `SUM` of a JSON value —
|
|
196
|
+
* so the whole document runs in the set residual, named, rather than
|
|
197
|
+
* reaching the database as SQL it will refuse.
|
|
198
|
+
* @param {string} fn
|
|
199
|
+
* @param {any} ref
|
|
200
|
+
* @returns {string | null}
|
|
201
|
+
*/
|
|
202
|
+
const foldValue = (fn, ref) => {
|
|
203
|
+
if (ref === null || ref === undefined) return null;
|
|
204
|
+
const kind = fn === 'sum' || fn === 'avg' ? 'number' : kindOf(ref);
|
|
205
|
+
if (kind === 'any' && dialect.capabilities.untypedColumns !== true) {
|
|
206
|
+
throw new UnrepresentablePath(
|
|
207
|
+
`a ${fn} over a member the schema does not type has no fold on this engine`);
|
|
208
|
+
}
|
|
209
|
+
return valueOf(ref, kind);
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* One reference to an EXTERNAL's bound value. The slot carries the
|
|
214
|
+
* dialect's encoding with it, because the binder has no dialect: a
|
|
215
|
+
* `json` slot binds the value's JSON text, which is the one encoding
|
|
216
|
+
* a placeholder can hold whatever the member's type turns out to be.
|
|
217
|
+
* @param {string} name
|
|
218
|
+
* @returns {string}
|
|
219
|
+
*/
|
|
220
|
+
const externalSlot = (name) => param(dialect.externalEncoding === 'json'
|
|
221
|
+
? { external: name, json: true }
|
|
222
|
+
: { external: name });
|
|
223
|
+
|
|
224
|
+
/** The slot a bare column comparison binds through: a plan-time
|
|
225
|
+
* literal, or the scalar one of the plan's own seeks answers. An
|
|
226
|
+
* external is not among them — its type is unknowable at plan time,
|
|
227
|
+
* which is exactly what the guarded form exists for. */
|
|
228
|
+
const slotFor = (operand) => {
|
|
229
|
+
if ('lit' in operand) return { literal: operand.lit };
|
|
230
|
+
const seek = (plan.seeks ?? []).find((entry) => entry.name === operand.seek);
|
|
231
|
+
return { typed: { seek: seek.name, type: seek.kind } };
|
|
232
|
+
};
|
|
131
233
|
|
|
132
234
|
/**
|
|
133
235
|
* The guarded, total comparison forms of the truth table.
|
|
@@ -136,11 +238,11 @@ export function emitPlan(plan, dialect, physical) {
|
|
|
136
238
|
*/
|
|
137
239
|
const emitCmp = (pred) => {
|
|
138
240
|
const jt = typeOf(pred.ref);
|
|
139
|
-
const value = valueOf(pred.ref);
|
|
140
241
|
const symbol = { eq: '=', ne: '<>', lt: '<', le: '<=', gt: '>', ge: '>=' }[pred.op];
|
|
141
242
|
if ('lit' in pred.operand) {
|
|
142
243
|
const lit = pred.operand.lit;
|
|
143
|
-
const kind = typeof lit === 'number' ? 'number' : '
|
|
244
|
+
const kind = typeof lit === 'number' ? 'number' : 'text';
|
|
245
|
+
const value = valueOf(pred.ref, kind);
|
|
144
246
|
const typeGuard = kind === 'number'
|
|
145
247
|
? `${jt} IN ${NUMERIC()}`
|
|
146
248
|
: `${jt} = ${sl('text')}`;
|
|
@@ -156,15 +258,33 @@ export function emitPlan(plan, dialect, physical) {
|
|
|
156
258
|
// NULL, and a NULL escaping through a NOT flips a row's fate
|
|
157
259
|
return `(${jt} IS NOT NULL AND ${typeGuard} AND ${value} ${symbol} ${param({ literal: lit })})`;
|
|
158
260
|
}
|
|
261
|
+
if ('seek' in pred.operand) {
|
|
262
|
+
// a seek's scalar comes from a column of DECLARED type, so its
|
|
263
|
+
// JSON type is known here: one guarded comparison, no branch
|
|
264
|
+
const seek = (plan.seeks ?? []).find((entry) => entry.name === pred.operand.seek);
|
|
265
|
+
const value = valueOf(pred.ref, seek.kind);
|
|
266
|
+
const typeGuard = seek.kind === 'number'
|
|
267
|
+
? `${jt} IN ${NUMERIC()}`
|
|
268
|
+
: `${jt} = ${sl('text')}`;
|
|
269
|
+
return `(${jt} IS NOT NULL AND ${typeGuard} AND ${value} ${symbol} `
|
|
270
|
+
+ `${param({ typed: { seek: seek.name, type: seek.kind } })})`;
|
|
271
|
+
}
|
|
159
272
|
// external operand: its JSON type is only knowable at bind time —
|
|
160
|
-
// guard BOTH sides per branch (text with text, number with number)
|
|
273
|
+
// guard BOTH sides per branch (text with text, number with number),
|
|
274
|
+
// and read the member at the branch's own kind. Both sides go
|
|
275
|
+
// through the dialect's external forms, because on an engine whose
|
|
276
|
+
// parameters carry a type the guard does not stop the coercion: the
|
|
277
|
+
// value is bound as JSON there and compared in JSON space
|
|
161
278
|
const name = pred.operand.ext;
|
|
279
|
+
const external = () => externalSlot(name);
|
|
280
|
+
const compare = (kind) => dialect.externalCompare(valueOf(pred.ref, kind), kind);
|
|
281
|
+
const against = (kind) => dialect.externalRef(external(), kind);
|
|
162
282
|
const textBranch = `(${jt} IS NOT NULL AND ${jt} = ${sl('text')} AND `
|
|
163
|
-
+ `${dialect.valueTypeOf(
|
|
164
|
-
+ `${
|
|
283
|
+
+ `${dialect.valueTypeOf(external())} = ${sl('text')} AND `
|
|
284
|
+
+ `${compare('text')} ${pred.op === 'ne' ? '=' : symbol} ${against('text')})`;
|
|
165
285
|
const numberBranch = `(${jt} IS NOT NULL AND ${jt} IN ${NUMERIC()} AND `
|
|
166
|
-
+ `${dialect.valueTypeOf(
|
|
167
|
-
+ `${
|
|
286
|
+
+ `${dialect.valueTypeOf(external())} IN ${NUMERIC()} AND `
|
|
287
|
+
+ `${compare('number')} ${pred.op === 'ne' ? '=' : symbol} ${against('number')})`;
|
|
168
288
|
const equalInSomeBranch = `(${textBranch} OR ${numberBranch})`;
|
|
169
289
|
return pred.op === 'ne'
|
|
170
290
|
? `(${jt} IS NOT NULL AND NOT ${equalInSomeBranch})`
|
|
@@ -187,6 +307,23 @@ export function emitPlan(plan, dialect, physical) {
|
|
|
187
307
|
return pred.value ? dialect.booleanLiteral(true) : dialect.booleanLiteral(false);
|
|
188
308
|
case 'cmp':
|
|
189
309
|
return emitCmp(pred);
|
|
310
|
+
case 'colCmp': {
|
|
311
|
+
const column = q(pred.column);
|
|
312
|
+
const symbol = { eq: '=', lt: '<', le: '<=', gt: '>', ge: '>=' }[pred.op];
|
|
313
|
+
return `(${column} IS NOT NULL AND ${column} ${symbol} `
|
|
314
|
+
+ `${param(slotFor(pred.operand))})`;
|
|
315
|
+
}
|
|
316
|
+
case 'interval': {
|
|
317
|
+
// the declared bounds ARE the values (§8.16's precondition is a
|
|
318
|
+
// schema one), so no `json_type` guard reads the document per
|
|
319
|
+
// row; `IS NOT NULL` keeps the form total for a row with no span
|
|
320
|
+
const start = q(pred.columns.start);
|
|
321
|
+
const end = q(pred.columns.end);
|
|
322
|
+
return `(${start} IS NOT NULL AND ${end} IS NOT NULL AND `
|
|
323
|
+
+ `((${start} < ${param({ literal: pred.probe.to })} `
|
|
324
|
+
+ `AND ${end} > ${param({ literal: pred.probe.from })}) `
|
|
325
|
+
+ `OR ${start} >= ${end}))`;
|
|
326
|
+
}
|
|
190
327
|
case 'typeIs': {
|
|
191
328
|
const jt = typeOf(pred.ref);
|
|
192
329
|
if (pred.types.length === 0) {
|
|
@@ -202,11 +339,13 @@ export function emitPlan(plan, dialect, physical) {
|
|
|
202
339
|
}
|
|
203
340
|
case 'udf':
|
|
204
341
|
// the registered deterministic predicate: reads the row's
|
|
205
|
-
// document as JSON text, answers 1 or 0 (always total)
|
|
206
|
-
|
|
342
|
+
// document as JSON text, answers 1 or 0 (always total); the
|
|
343
|
+
// second argument is the conjunct's place in the caller's
|
|
344
|
+
// document, a literal the function reports an engine error at
|
|
345
|
+
return `${pred.name}(${dialect.jsonText(docColumn)}, ${sl(pred.mount ?? '/$where')})`;
|
|
207
346
|
case 'strop': {
|
|
208
347
|
const jt = typeOf(pred.ref);
|
|
209
|
-
const form = stropForm(dialect, param, valueOf(pred.ref), pred);
|
|
348
|
+
const form = stropForm(dialect, param, valueOf(pred.ref, 'text'), pred);
|
|
210
349
|
return `(${jt} IS NOT NULL AND ${jt} = ${sl('text')} AND ${form})`;
|
|
211
350
|
}
|
|
212
351
|
case 'bboxOverlap': {
|
|
@@ -290,20 +429,64 @@ export function emitPlan(plan, dialect, physical) {
|
|
|
290
429
|
// engine scores, cuts and ranks (measured: every SQL spelling of
|
|
291
430
|
// the rank loses to fetching the column and ranking in the engine,
|
|
292
431
|
// and none of them runs where no function can be registered)
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
432
|
+
// one alternative per emitted statement: the caller emits the plan
|
|
433
|
+
// once per declared width, and each carries its own column
|
|
434
|
+
? `${dialect.rowIdentity()} AS ${q('rid')}, `
|
|
435
|
+
+ `${q(plan.rank.alternatives[0].column)} AS ${q('vec')}`
|
|
436
|
+
: plan.group !== null
|
|
437
|
+
? [...plan.group.keys.map((key, i) => projectedPair(key.ref, `k${i}`)),
|
|
438
|
+
...plan.group.aggregates.map((entry, i) =>
|
|
439
|
+
`${dialect.groupAggregate(entry.fn, foldValue(entry.fn, entry.ref))} `
|
|
440
|
+
+ `AS ${q(`a${i}`)}`)].join(', ')
|
|
441
|
+
: plan.bucket !== null
|
|
442
|
+
? [`${bucketSql} AS ${q(plan.bucket.as)}`,
|
|
296
443
|
...plan.bucket.aggregates.map((entry) =>
|
|
297
|
-
`${dialect.groupAggregate(entry.fn,
|
|
298
|
-
|
|
444
|
+
`${dialect.groupAggregate(entry.fn, foldValue(entry.fn, entry.ref))} `
|
|
445
|
+
+ `AS ${q(entry.as)}`)].join(', ')
|
|
299
446
|
: plan.aggregate === null
|
|
300
|
-
?
|
|
447
|
+
? (plan.project === 'document'
|
|
448
|
+
? `${dialect.jsonText(docColumn)} AS ${q('doc')}`
|
|
449
|
+
// one member path: its value and its JSON type. A scalar is
|
|
450
|
+
// the extracted SQL value itself; an object or array is
|
|
451
|
+
// rendered to JSON text, since the binary extraction of a
|
|
452
|
+
// compound is a blob. `NULL` type is an absent member (no
|
|
453
|
+
// item), 'null' a present null, 'true'/'false' a boolean the
|
|
454
|
+
// integer rendering would otherwise lose
|
|
455
|
+
: 'path' in plan.project
|
|
456
|
+
? projectedPair(plan.project.path, '')
|
|
457
|
+
// a projection TREE: the same value/type pair per DISTINCT
|
|
458
|
+
// leaf, numbered, and nothing else — the document blob is
|
|
459
|
+
// never selected, and a leaf named twice is fetched once
|
|
460
|
+
: plan.project.leaves.map((ref, i) => projectedPair(ref, String(i))).join(', '))
|
|
301
461
|
: plan.aggregate.fn === 'count'
|
|
302
462
|
? `COUNT(*) AS ${q('value')}`
|
|
303
|
-
|
|
463
|
+
// a REGISTERED aggregate calls the function the store
|
|
464
|
+
// registered under the plan's name; the fold is the pack's own
|
|
465
|
+
: plan.aggregate.fn === 'registered'
|
|
466
|
+
? `${plan.aggregate.sql}(${valueOf(plan.aggregate.ref,
|
|
467
|
+
kindOf(plan.aggregate.ref))}) AS ${q('value')}`
|
|
468
|
+
: `${plan.aggregate.fn.toUpperCase()}(`
|
|
469
|
+
+ `${foldValue(plan.aggregate.fn, plan.aggregate.ref)}) AS ${q('value')}`;
|
|
304
470
|
|
|
305
471
|
let sql = `SELECT ${selection} FROM ${q(physical.table)}`;
|
|
306
472
|
if (plan.filter !== null) sql += ` WHERE ${emitPred(plan.filter)}`;
|
|
473
|
+
if (plan.group !== null) {
|
|
474
|
+
// BY THE ALIASES the selection named, not by a second spelling of
|
|
475
|
+
// the same member. Two reasons, and the second is the load-bearing
|
|
476
|
+
// one: a key is a value/type PAIR (a JSON `1` and a JSON `"1"` are
|
|
477
|
+
// different keys and render the same text), and an engine that
|
|
478
|
+
// checks its grouping refuses a selected expression the GROUP BY
|
|
479
|
+
// does not cover — which every key's type discriminator would be.
|
|
480
|
+
sql += ` GROUP BY ${plan.group.keys
|
|
481
|
+
.map((key, i) => `${q(`vk${i}`)}, ${q(`tk${i}`)}`).join(', ')}`;
|
|
482
|
+
// the groups' order: the engine's own order of first appearance —
|
|
483
|
+
// over a collection, each group's earliest row identity — or the
|
|
484
|
+
// key ordering an `$orderby` declared
|
|
485
|
+
sql += ` ORDER BY ${plan.group.order === 'first-seen'
|
|
486
|
+
? dialect.groupAggregate('min', dialect.rowIdentity())
|
|
487
|
+
: plan.group.order.map((term) => `${q(`vk${term.index}`)} `
|
|
488
|
+
+ `${term.desc ? 'DESC' : 'ASC'}${dialect.orderNulls(term.nullsFirst)}`).join(', ')}`;
|
|
489
|
+
}
|
|
307
490
|
if (plan.bucket !== null) {
|
|
308
491
|
// `first-seen` is the engine's own group order (§6.5, first
|
|
309
492
|
// appearance), which over a collection is the group's earliest row
|
|
@@ -314,13 +497,15 @@ export function emitPlan(plan, dialect, physical) {
|
|
|
314
497
|
: `${alias} ${plan.bucket.order === 'desc' ? 'DESC' : 'ASC'}`;
|
|
315
498
|
sql += ` GROUP BY ${alias} ORDER BY ${order}`;
|
|
316
499
|
}
|
|
317
|
-
if (plan.aggregate === null && plan.rank === null && plan.bucket === null
|
|
500
|
+
if (plan.aggregate === null && plan.rank === null && plan.bucket === null
|
|
501
|
+
&& plan.group === null) {
|
|
318
502
|
const terms = (plan.order ?? []).map((term) => {
|
|
319
503
|
// Jaren's default sorts an empty key least: NULLS FIRST when
|
|
320
504
|
// ascending, NULLS LAST when descending — and mirrored for
|
|
321
505
|
// $empty: 'greatest' (probed against the engine)
|
|
322
506
|
const nullsFirst = term.emptyGreatest === term.desc;
|
|
323
|
-
return `${valueOf(term.ref
|
|
507
|
+
return `${valueOf(term.ref, kindOf(term.ref))} `
|
|
508
|
+
+ `${term.desc ? 'DESC' : 'ASC'}${dialect.orderNulls(nullsFirst)}`;
|
|
324
509
|
});
|
|
325
510
|
// the collection is a SEQUENCE: its order is insertion (row
|
|
326
511
|
// identity) order, and the engine's sort is stable — the identity
|
|
@@ -329,10 +514,52 @@ export function emitPlan(plan, dialect, physical) {
|
|
|
329
514
|
terms.push(dialect.rowIdentity());
|
|
330
515
|
sql += ` ORDER BY ${terms.join(', ')}`;
|
|
331
516
|
}
|
|
332
|
-
if (plan.window !== null && plan.aggregate === null) {
|
|
517
|
+
if (plan.window !== null && plan.aggregate === null && plan.group === null) {
|
|
333
518
|
sql += ` ${dialect.limitClause(plan.window.limit, plan.window.offset)}`;
|
|
334
519
|
}
|
|
335
|
-
return { sql, slots };
|
|
520
|
+
return { sql, slots, seeks: (plan.seeks ?? []).map((seek) => emitSeek(seek)) };
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* One seek statement: the extreme instant each group carries on the
|
|
524
|
+
* near side of the probe, folded to the one scalar every group's
|
|
525
|
+
* answer is beyond. Ungrouped, the inner fold IS the answer.
|
|
526
|
+
* @param {import('./algebra.js').PlanSeek} seek
|
|
527
|
+
* @returns {{ name: string, kind: 'number' | 'text',
|
|
528
|
+
* sql: string, slots: ParamSlot[] }}
|
|
529
|
+
*/
|
|
530
|
+
function emitSeek(seek) {
|
|
531
|
+
/** @type {ParamSlot[]} */
|
|
532
|
+
const own = [];
|
|
533
|
+
const outer = sink;
|
|
534
|
+
sink = own;
|
|
535
|
+
try {
|
|
536
|
+
// the seek reads the same declared columns the bound it fills does
|
|
537
|
+
const colCmp = (column, op, lit) =>
|
|
538
|
+
({ p: 'colCmp', op, column, operand: { lit } });
|
|
539
|
+
/** @type {import('./algebra.js').PlanPredicate} */
|
|
540
|
+
let filter = colCmp(seek.ref.column, seek.bound.op, seek.bound.lit);
|
|
541
|
+
if (seek.group !== null && seek.keys !== null && seek.keys.length > 0) {
|
|
542
|
+
filter = { p: 'and', items: [filter, seek.keys.length === 1
|
|
543
|
+
? colCmp(seek.group.column, 'eq', seek.keys[0])
|
|
544
|
+
: { p: 'or', items: seek.keys.map((key) =>
|
|
545
|
+
colCmp(seek.group.column, 'eq', key)) }] };
|
|
546
|
+
}
|
|
547
|
+
const inner = `${seek.inner.toUpperCase()}(${valueOf(seek.ref, seek.kind)})`;
|
|
548
|
+
const where = ` FROM ${q(physical.table)} WHERE ${emitPred(filter)}`;
|
|
549
|
+
const text = seek.group === null
|
|
550
|
+
? `SELECT ${inner} AS ${q('anchor')}${where}`
|
|
551
|
+
: `SELECT ${seek.outer.toUpperCase()}(${q('a')}) AS ${q('anchor')} FROM `
|
|
552
|
+
+ `(SELECT ${inner} AS ${q('a')}${where} `
|
|
553
|
+
+ `GROUP BY ${valueOf(seek.group, kindOf(seek.group))})`;
|
|
554
|
+
// a seek that finds nothing binds its own probe: it proved there
|
|
555
|
+
// is no row on that side, so the bound excludes only what is absent
|
|
556
|
+
return { name: seek.name, kind: seek.kind, fallback: seek.bound.lit,
|
|
557
|
+
sql: text, slots: own };
|
|
558
|
+
}
|
|
559
|
+
finally {
|
|
560
|
+
sink = outer;
|
|
561
|
+
}
|
|
562
|
+
}
|
|
336
563
|
}
|
|
337
564
|
|
|
338
565
|
// ————— The entity document kind (one emitter layer, two kinds) —————
|
|
@@ -348,7 +575,11 @@ export function emitPlan(plan, dialect, physical) {
|
|
|
348
575
|
export function createEntityPredicateEmitters(dialect, param) {
|
|
349
576
|
const q = dialect.quoteIdentifier;
|
|
350
577
|
const sl = dialect.stringLiteral;
|
|
351
|
-
const NUMERIC = () => `(${sl('
|
|
578
|
+
const NUMERIC = () => `(${dialect.numericTypeNames.map(sl).join(', ')})`;
|
|
579
|
+
const externalSlot = (name) => param(dialect.externalEncoding === 'json'
|
|
580
|
+
? { external: name, json: true }
|
|
581
|
+
: { external: name });
|
|
582
|
+
|
|
352
583
|
const pathTextOf = (ref) => {
|
|
353
584
|
const text = dialect.jsonPathText(ref.segments);
|
|
354
585
|
if (text === null)
|
|
@@ -356,9 +587,14 @@ export function createEntityPredicateEmitters(dialect, param) {
|
|
|
356
587
|
return text;
|
|
357
588
|
};
|
|
358
589
|
|
|
590
|
+
/** The member at a ref, read as the SQL a comparison of that KIND
|
|
591
|
+
* needs. On a dynamically typed engine every kind is the same read;
|
|
592
|
+
* on one whose columns carry a real type they are four. */
|
|
593
|
+
const memberAt = (docSql, ref, kind) =>
|
|
594
|
+
dialect.jsonExtract(docSql, pathTextOf(ref), kind);
|
|
595
|
+
|
|
359
596
|
const emitDocPred = (docSql, pred) => {
|
|
360
597
|
const jt = dialect.jsonTypeOf(docSql, pathTextOf(pred.ref));
|
|
361
|
-
const value = dialect.jsonExtract(docSql, pathTextOf(pred.ref));
|
|
362
598
|
if (pred.p === 'typeIs') {
|
|
363
599
|
if (pred.types.length === 0)
|
|
364
600
|
return pred.positive ? `${jt} IS NOT NULL` : `${jt} IS NULL`;
|
|
@@ -369,12 +605,13 @@ export function createEntityPredicateEmitters(dialect, param) {
|
|
|
369
605
|
: `(${jt} IS NOT NULL AND ${jt} NOT IN (${list}))`;
|
|
370
606
|
}
|
|
371
607
|
if (pred.p === 'strop') {
|
|
372
|
-
const form = stropForm(dialect, param,
|
|
608
|
+
const form = stropForm(dialect, param, memberAt(docSql, pred.ref, 'text'), pred);
|
|
373
609
|
return `(${jt} IS NOT NULL AND ${jt} = ${sl('text')} AND ${form})`;
|
|
374
610
|
}
|
|
375
611
|
const lit = pred.operand.lit;
|
|
376
612
|
const symbol = { eq: '=', ne: '<>', lt: '<', le: '<=', gt: '>', ge: '>=' }[pred.op];
|
|
377
|
-
const kind = typeof lit === 'number' ? 'number' : '
|
|
613
|
+
const kind = typeof lit === 'number' ? 'number' : 'text';
|
|
614
|
+
const value = memberAt(docSql, pred.ref, kind);
|
|
378
615
|
if (pred.op === 'ne') {
|
|
379
616
|
const notType = kind === 'number'
|
|
380
617
|
? `${jt} NOT IN ${NUMERIC()}` : `${jt} <> ${sl('text')}`;
|
|
@@ -403,10 +640,13 @@ export function createEntityPredicateEmitters(dialect, param) {
|
|
|
403
640
|
}
|
|
404
641
|
const symbol = { eq: '=', ne: '<>', lt: '<', le: '<=', gt: '>', ge: '>=' }[pred.op];
|
|
405
642
|
if ('ext' in pred.operand) {
|
|
406
|
-
const
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
643
|
+
const kind = pred.ref.storage === 'string' ? 'text' : 'number';
|
|
644
|
+
const guard = kind === 'text'
|
|
645
|
+
? `${dialect.valueTypeOf(externalSlot(pred.operand.ext))} = ${sl('text')}`
|
|
646
|
+
: `${dialect.valueTypeOf(externalSlot(pred.operand.ext))} IN ${NUMERIC()}`;
|
|
647
|
+
return `(${column} IS NOT NULL AND ${guard} AND `
|
|
648
|
+
+ `${dialect.externalCompare(column, kind)} ${pred.op === 'ne' ? '<>' : symbol} `
|
|
649
|
+
+ `${dialect.externalRef(externalSlot(pred.operand.ext), kind)})`;
|
|
410
650
|
}
|
|
411
651
|
const lit = pred.operand.lit;
|
|
412
652
|
const litKind = typeof lit === 'number' ? 'number' : typeof lit === 'string' ? 'string' : 'other';
|
|
@@ -425,7 +665,9 @@ export function createEntityPredicateEmitters(dialect, param) {
|
|
|
425
665
|
// stored values carry
|
|
426
666
|
const emitEpochPred = (aliasSql, docSql, pred) => {
|
|
427
667
|
const column = `${aliasSql}.${q(pred.ref.column)}`;
|
|
428
|
-
|
|
668
|
+
// the stored TEXT decides: an instant's codepoint comparison is
|
|
669
|
+
// exactly the engine's, whatever precision the value carries
|
|
670
|
+
const value = memberAt(docSql, pred.ref, 'text');
|
|
429
671
|
const symbol = { eq: '=', lt: '<', le: '<=', gt: '>', ge: '>=' }[pred.op];
|
|
430
672
|
const range = pred.op === 'gt' || pred.op === 'ge'
|
|
431
673
|
? `${column} >= ${param({ literal: pred.epoch - 1000 })}`
|
|
@@ -476,6 +718,7 @@ export function createEntityPredicateEmitters(dialect, param) {
|
|
|
476
718
|
*/
|
|
477
719
|
export function emitEntityPlan(plan, dialect, physicalOf) {
|
|
478
720
|
const q = dialect.quoteIdentifier;
|
|
721
|
+
const sl = dialect.stringLiteral;
|
|
479
722
|
/** @type {ParamSlot[]} */
|
|
480
723
|
const slots = [];
|
|
481
724
|
const param = (slot) => {
|
|
@@ -496,25 +739,80 @@ export function emitEntityPlan(plan, dialect, physicalOf) {
|
|
|
496
739
|
return text;
|
|
497
740
|
};
|
|
498
741
|
|
|
742
|
+
const entityOf = new Map(plan.bindings.map((binding) => [binding.name, binding.entity]));
|
|
499
743
|
const emitters = createEntityPredicateEmitters(dialect, param);
|
|
500
744
|
const emitPred = (bindingName, pred) =>
|
|
501
745
|
emitters.emitPred(aliasOf(bindingName), docOf(bindingName), pred);
|
|
502
746
|
|
|
747
|
+
/**
|
|
748
|
+
* One projected member of a binding: its value beside its JSON type,
|
|
749
|
+
* under a suffixed pair of names the decoder reads back.
|
|
750
|
+
*
|
|
751
|
+
* Which SOURCE the pair reads is the entity mapping's rule (§9.3),
|
|
752
|
+
* not a choice: a mapped scalar lives in its COLUMN and is absent
|
|
753
|
+
* from the document, so reading the document for it would answer
|
|
754
|
+
* nothing; an epoch column keeps its string IN the document, because
|
|
755
|
+
* the integer is derived; everything else is document only. The type
|
|
756
|
+
* of a column value is the column's declared storage — SQL has no
|
|
757
|
+
* `json_type` for it — with `NULL` meaning the member is absent,
|
|
758
|
+
* which is exactly what the merge reads back.
|
|
759
|
+
*/
|
|
760
|
+
const projectedPair = (leaf, suffix) => {
|
|
761
|
+
const names = `${q(`v${suffix}`)}`;
|
|
762
|
+
const typeName = `${q(`t${suffix}`)}`;
|
|
763
|
+
if (leaf.ref.flavor === 'entity-column') {
|
|
764
|
+
const column = `${aliasOf(leaf.binding)}.${q(leaf.ref.column)}`;
|
|
765
|
+
const type = leaf.ref.storage === 'boolean'
|
|
766
|
+
? `CASE WHEN ${column} IS NULL THEN NULL WHEN ${column} = 0 `
|
|
767
|
+
+ `THEN ${sl('false')} ELSE ${sl('true')} END`
|
|
768
|
+
: `CASE WHEN ${column} IS NULL THEN NULL ELSE ${sl(
|
|
769
|
+
leaf.ref.storage === 'string' ? 'text'
|
|
770
|
+
: leaf.ref.storage === 'integer'
|
|
771
|
+
? dialect.numericTypeNames[0]
|
|
772
|
+
: dialect.numericTypeNames[dialect.numericTypeNames.length - 1])} END`;
|
|
773
|
+
return `${column} AS ${names}, ${type} AS ${typeName}`;
|
|
774
|
+
}
|
|
775
|
+
const docSql = docOf(leaf.binding);
|
|
776
|
+
const text = pathTextOf(leaf.ref);
|
|
777
|
+
const type = dialect.jsonTypeOf(docSql, text);
|
|
778
|
+
return `CASE WHEN ${type} IN (${sl('object')}, ${sl('array')}) `
|
|
779
|
+
+ `THEN ${dialect.jsonText(dialect.jsonExtract(docSql, text))} `
|
|
780
|
+
+ `ELSE ${dialect.jsonExtract(docSql, text, 'scalar')} END AS ${names}, `
|
|
781
|
+
+ `${type} AS ${typeName}`;
|
|
782
|
+
};
|
|
783
|
+
|
|
503
784
|
const ret = plan.ret;
|
|
504
785
|
// every returned column plus the document rendered to text; the
|
|
505
|
-
// caller merges them back into the entity shape
|
|
786
|
+
// caller merges them back into the entity shape — or, for a projected
|
|
787
|
+
// shape, one value/type pair per DISTINCT leaf and no document at all
|
|
506
788
|
const selection = plan.aggregate === 'count'
|
|
507
789
|
? `COUNT(*) AS ${q('value')}`
|
|
508
|
-
:
|
|
790
|
+
: plan.project != null
|
|
791
|
+
// `p`-prefixed, because a bare `t0` would collide with this
|
|
792
|
+
// plan's own binding aliases
|
|
793
|
+
? plan.project.leaves.map((leaf, i) => projectedPair(leaf, `p${i}`)).join(', ')
|
|
794
|
+
// a join-table root IS its two key columns: it has no document
|
|
795
|
+
// column, so the merge is handed an empty one
|
|
796
|
+
: physicalOf(entityOf.get(ret)).document === false
|
|
797
|
+
? `${aliasOf(ret)}.*, ${sl('{}')} AS ${q('__doc')}`
|
|
798
|
+
: `${aliasOf(ret)}.*, ${dialect.jsonText(docOf(ret))} AS ${q('__doc')}`;
|
|
799
|
+
|
|
800
|
+
const tableOf = (name) =>
|
|
801
|
+
`${q(physicalOf(entityOf.get(name)).table)} AS ${aliasOf(name)}`;
|
|
802
|
+
const JOIN_OPS = { eq: '=', ne: '<>', lt: '<', le: '<=', gt: '>', ge: '>=' };
|
|
803
|
+
const onSql = (edge) =>
|
|
804
|
+
`${aliasOf(edge.left.binding)}.${q(edge.left.column)}`
|
|
805
|
+
+ ` ${JOIN_OPS[edge.op ?? 'eq']} ${aliasOf(edge.right.binding)}.${q(edge.right.column)}`;
|
|
509
806
|
|
|
510
807
|
let sql = `SELECT ${selection} FROM `;
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
808
|
+
// the JOIN order the planner settled: the first binding, then each
|
|
809
|
+
// one an edge attaches to what is already joined. A binding nothing
|
|
810
|
+
// attached never reaches here — that graph is the residual
|
|
811
|
+
sql += plan.joins.length === 0
|
|
812
|
+
? tableOf(plan.bindings[0].name)
|
|
813
|
+
: plan.joins.map((join, i) => (i === 0
|
|
814
|
+
? tableOf(join.binding)
|
|
815
|
+
: `${tableOf(join.binding)} ON ${join.on.map(onSql).join(' AND ')}`)).join(' JOIN ');
|
|
518
816
|
const filterSql = plan.filters
|
|
519
817
|
.filter((entry) => entry.filter !== null)
|
|
520
818
|
.map((entry) => emitPred(entry.binding, entry.filter));
|
|
@@ -528,7 +826,7 @@ export function emitEntityPlan(plan, dialect, physicalOf) {
|
|
|
528
826
|
// integer column sort differently
|
|
529
827
|
const value = term.ref.flavor === 'entity-column'
|
|
530
828
|
? `${aliasOf(term.binding)}.${q(term.ref.column)}`
|
|
531
|
-
: dialect.jsonExtract(docOf(term.binding), pathTextOf(term.ref));
|
|
829
|
+
: dialect.jsonExtract(docOf(term.binding), pathTextOf(term.ref), 'text');
|
|
532
830
|
const nullsFirst = term.emptyGreatest === term.desc;
|
|
533
831
|
return `${value} ${term.desc ? 'DESC' : 'ASC'}${dialect.orderNulls(nullsFirst)}`;
|
|
534
832
|
});
|