@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/query.js
CHANGED
|
@@ -32,22 +32,30 @@
|
|
|
32
32
|
*/
|
|
33
33
|
|
|
34
34
|
import { createSemanticCache } from '@jarenjs/core/cache';
|
|
35
|
-
import {
|
|
35
|
+
import { analyzeQuery } from '@jarenjs/json/query';
|
|
36
36
|
|
|
37
|
-
import { DbCompileError, DbRuntimeError } from './errors.js';
|
|
38
|
-
import { chain } from './driver.js';
|
|
37
|
+
import { DbCompileError, DbRuntimeError, wrapDriverError, classifyDriverError } from './errors.js';
|
|
38
|
+
import { chain, attempt, isThenable } from './driver.js';
|
|
39
39
|
import {
|
|
40
40
|
planQuery, planEntityQuery, entityShape, planEntityPredicate, entityPathRef,
|
|
41
|
+
isRootScanSource, isEntityRootSource, BIND_REASONS,
|
|
41
42
|
} from './plan.js';
|
|
42
|
-
import { emitPlan, emitEntityPlan, createEntityPredicateEmitters } from './emit.js';
|
|
43
|
-
import { selectPlan } from './algebra.js';
|
|
44
|
-
import {
|
|
43
|
+
import { emitPlan, emitEntityPlan, createEntityPredicateEmitters, UnrepresentablePath } from './emit.js';
|
|
44
|
+
import { selectPlan, conjoin, effectiveOrder, planOrder } from './algebra.js';
|
|
45
|
+
import {
|
|
46
|
+
compileSetResidual, compileRowResidual, compilePackedResidual, sequenceResult,
|
|
47
|
+
} from './residual.js';
|
|
48
|
+
import { createCursor, createSyncCursor, drainPage, utf8Length, PAGE_LIMIT_DEFAULT, rowClassOf } from './cursor.js';
|
|
49
|
+
import { deepFreeze } from '@jarenjs/core/object';
|
|
45
50
|
import { derivedSlotValue, probeBox, probeVector, columnScore } from './derive.js';
|
|
46
51
|
import { cutCandidates, identityBatches } from './knn.js';
|
|
47
|
-
import { deterministicFragment, registerFragment } from './udf.js';
|
|
48
52
|
import {
|
|
49
|
-
|
|
50
|
-
|
|
53
|
+
deterministicFragment, registerFragment, registerAggregateOperator,
|
|
54
|
+
} from './udf.js';
|
|
55
|
+
import { refuseCancelled } from './cancellation.js';
|
|
56
|
+
import {
|
|
57
|
+
normalizeProfile, translateProfilePredicate, assertProfileRoots, memberDenial,
|
|
58
|
+
applyMandatoryPredicate, applyRowBound, SAFE_PROFILE,
|
|
51
59
|
} from './profile.js';
|
|
52
60
|
|
|
53
61
|
/**
|
|
@@ -58,25 +66,61 @@ import {
|
|
|
58
66
|
* residual.
|
|
59
67
|
* @param {number} [bound]
|
|
60
68
|
* @param {{ functions?: any, extensions?: any } | null} [operators]
|
|
61
|
-
* @param {any} [zoneProvider] - D7's injected
|
|
69
|
+
* @param {any} [zoneProvider] - D7's injected zone provider, or absent
|
|
70
|
+
* @param {(() => number) | undefined} [now] - the store's clock, the one a
|
|
71
|
+
* `deadline` is compared against before a call and at every row
|
|
72
|
+
* boundary; the platform's own when the store threads none
|
|
62
73
|
* @returns {any}
|
|
63
74
|
*/
|
|
64
75
|
export function createQueryState(bound = undefined, operators = null,
|
|
65
|
-
zoneProvider = undefined) {
|
|
76
|
+
zoneProvider = undefined, now = undefined) {
|
|
66
77
|
return {
|
|
67
78
|
cache: createSemanticCache(bound ?? 128),
|
|
68
79
|
counters: { hits: 0, misses: 0, evictions: 0 },
|
|
69
80
|
/** Fragment identity → the SQL function name registered for it. */
|
|
70
81
|
registered: new Map(),
|
|
82
|
+
/** Registry operator name → the SQL AGGREGATE registered for it. */
|
|
83
|
+
registeredAggregates: new Map(),
|
|
71
84
|
operators: operators ?? null,
|
|
72
85
|
// D7's injected clock: a named zone is host code a database does
|
|
73
86
|
// not have, so a calendar ladder over one walks in the residual —
|
|
74
87
|
// and the residual is the caller's OWN document, so the frozen spec
|
|
75
88
|
// reaches the kernel unchanged rather than being rebuilt in UTC
|
|
76
89
|
zoneProvider: zoneProvider ?? null,
|
|
90
|
+
// the clock every deadline is read against: the store's runtime
|
|
91
|
+
// record's, so an injected clock and a caller's deadline agree on
|
|
92
|
+
// what time it is — a compiled query never captures the instant
|
|
93
|
+
now: now ?? Date.now,
|
|
77
94
|
};
|
|
78
95
|
}
|
|
79
96
|
|
|
97
|
+
/**
|
|
98
|
+
* The answer for a native selection's items: the engine's result shape
|
|
99
|
+
* (`undefined | item | items`), or — when the document is a chain's
|
|
100
|
+
* element WINDOW, `[<phrase>]` (plan.js, `wrapped`) — the items as the
|
|
101
|
+
* ONE array that constructor yields, never singleton-unwrapped: an empty
|
|
102
|
+
* selection is `[]`, one row is `[row]`. Exactly the engine's answer for
|
|
103
|
+
* the same document, which is what lets a chain's `toArray()` push.
|
|
104
|
+
* @param {any} entry
|
|
105
|
+
* @param {any[]} items
|
|
106
|
+
* @returns {any}
|
|
107
|
+
*/
|
|
108
|
+
function answerOf(entry, items) {
|
|
109
|
+
return entry.planned.wrapped === true ? items : sequenceResult(items);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The same for one aggregate value: `[value]` under the window, and `[]`
|
|
114
|
+
* for an aggregate that answers nothing.
|
|
115
|
+
* @param {any} entry
|
|
116
|
+
* @param {any} value
|
|
117
|
+
* @returns {any}
|
|
118
|
+
*/
|
|
119
|
+
function wrapValue(entry, value) {
|
|
120
|
+
if (entry.planned.wrapped !== true) return value;
|
|
121
|
+
return value === undefined ? [] : [value];
|
|
122
|
+
}
|
|
123
|
+
|
|
80
124
|
/** @param {any} value - a bindable native parameter? */
|
|
81
125
|
function bindable(value) {
|
|
82
126
|
return typeof value === 'string'
|
|
@@ -92,10 +136,28 @@ function bindable(value) {
|
|
|
92
136
|
* @param {any} externals
|
|
93
137
|
* @returns {any}
|
|
94
138
|
*/
|
|
95
|
-
function slotValue(slot, externals) {
|
|
139
|
+
function slotValue(slot, externals, anchors = null) {
|
|
96
140
|
if ('literal' in slot) return slot.literal;
|
|
141
|
+
// a JSON-encoded external: the dialect asked for the value's JSON
|
|
142
|
+
// text because one placeholder has to hold whatever type the member
|
|
143
|
+
// turns out to be, and a statically typed parameter cannot. An
|
|
144
|
+
// ABSENT external stays absent — encoding it as `null` would answer
|
|
145
|
+
// a query the caller never bound instead of raising the engine's own
|
|
146
|
+
// missing-external error
|
|
147
|
+
if ('external' in slot && slot.json === true) {
|
|
148
|
+
const value = externals[slot.external];
|
|
149
|
+
return value === undefined ? undefined : JSON.stringify(value);
|
|
150
|
+
}
|
|
97
151
|
if ('derived' in slot)
|
|
98
152
|
return derivedSlotValue(slot.derived, externals[slot.derived.external]);
|
|
153
|
+
if ('typed' in slot) {
|
|
154
|
+
// a typed slot is only ever emitted beside the seek that fills it,
|
|
155
|
+
// so a bind that never resolved the seeks is a defect in the
|
|
156
|
+
// engine, not a value the caller could have got wrong
|
|
157
|
+
if (anchors === null || !(slot.typed.seek in anchors))
|
|
158
|
+
throw new Error(`the seek '${slot.typed.seek}' was not resolved before the bind`);
|
|
159
|
+
return anchors[slot.typed.seek];
|
|
160
|
+
}
|
|
99
161
|
return externals[slot.external];
|
|
100
162
|
}
|
|
101
163
|
|
|
@@ -125,6 +187,150 @@ function externalSlotKinds(slots, rank) {
|
|
|
125
187
|
return kinds;
|
|
126
188
|
}
|
|
127
189
|
|
|
190
|
+
/**
|
|
191
|
+
* The per-call preflight every engine runs: a call already aborted
|
|
192
|
+
* issues no statement (`JD2072`); a deadline already passed issues none
|
|
193
|
+
* either (`JD2075`). A deadline is an epoch-millisecond number, checked
|
|
194
|
+
* here and at every row boundary of a cursor — never inside a statement,
|
|
195
|
+
* because the shipped drivers expose no interrupt — against the clock
|
|
196
|
+
* the store was opened with, never the platform's directly.
|
|
197
|
+
* @param {any} options
|
|
198
|
+
* @param {() => number} now - the store's clock
|
|
199
|
+
*/
|
|
200
|
+
function requireCallable(options, now) {
|
|
201
|
+
refuseCancelled(options, now, { abortCode: 'JD2072', aborted: 'it ran', passed: 'the call ran' });
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Run a call whose driver failure classes as an int64 overflow through
|
|
206
|
+
* `fallback` instead — the pushed aggregate's coded residual — and let
|
|
207
|
+
* every other failure propagate. Value-or-promise aware.
|
|
208
|
+
* @param {() => any} call
|
|
209
|
+
* @param {() => any} fallback
|
|
210
|
+
* @returns {any}
|
|
211
|
+
*/
|
|
212
|
+
function recoverOverflow(call, fallback) {
|
|
213
|
+
let out;
|
|
214
|
+
try {
|
|
215
|
+
out = call();
|
|
216
|
+
}
|
|
217
|
+
catch (error) {
|
|
218
|
+
if (classifyDriverError(error).class === 'overflow') return fallback();
|
|
219
|
+
throw error;
|
|
220
|
+
}
|
|
221
|
+
return isThenable(out)
|
|
222
|
+
? out.then(undefined, (error) => (classifyDriverError(error).class === 'overflow'
|
|
223
|
+
? fallback()
|
|
224
|
+
: Promise.reject(error)))
|
|
225
|
+
: out;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** The boundary every engine member answers through: a driver failure
|
|
229
|
+
* arrives classified, anything else as it is.
|
|
230
|
+
* @param {(...args: any[]) => any} fn
|
|
231
|
+
* @param {(error: any) => Error} wrap */
|
|
232
|
+
const bounded = (fn, wrap) => (...args) => attempt(() => fn(...args), wrap);
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* The budget provenance `explain()` carries (D7): which profile applied
|
|
236
|
+
* and from where, every bound it imposed — each one the engine COUNTS
|
|
237
|
+
* and enforces — and, by name, the two the driver cannot measure:
|
|
238
|
+
* elapsed statement time and visited rows are empty capability slots on
|
|
239
|
+
* SQLite, so they are reported `unavailable`, never approximated.
|
|
240
|
+
* @param {any} profile - the normalized profile, or null
|
|
241
|
+
* @param {'call' | 'store' | null} source
|
|
242
|
+
* @param {any} capabilities - the connection's capability table
|
|
243
|
+
*/
|
|
244
|
+
function budgetOf(profile, source, capabilities) {
|
|
245
|
+
return {
|
|
246
|
+
profile: profile === null ? null : { source, name: profile === SAFE_PROFILE ? 'safe' : 'custom' },
|
|
247
|
+
rows: profile === null ? null : profile.maxRows,
|
|
248
|
+
includedRows: profile === null ? null : profile.maxIncludedRows,
|
|
249
|
+
depth: profile === null ? null : profile.maxDepth,
|
|
250
|
+
bytes: profile === null ? null : profile.maxBytes,
|
|
251
|
+
limits: profile === null ? null : { ...profile.limits },
|
|
252
|
+
scan: profile !== null && profile.refuseFullScan === true ? 'refused-by-shape' : 'unbounded',
|
|
253
|
+
time: capabilities?.statementTimeout === true ? 'enforced' : 'unavailable',
|
|
254
|
+
estimatedRows: capabilities?.rowEstimates === true ? 'available' : 'unavailable',
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* The items one projected row answers (a plan whose `project` is a
|
|
260
|
+
* member path): the JSON type decides — an absent member yields no
|
|
261
|
+
* item, a present `null` a null, `true`/`false` the boolean the integer
|
|
262
|
+
* rendering would have lost, anything else the parsed JSON text.
|
|
263
|
+
* @param {any} row
|
|
264
|
+
* @returns {any[]}
|
|
265
|
+
*/
|
|
266
|
+
function projectedItems(row) {
|
|
267
|
+
return leafItems(row, '');
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* A plan ref's path as the explanation publishes it: member names and
|
|
272
|
+
* array indexes, in order.
|
|
273
|
+
* @param {any} ref
|
|
274
|
+
* @returns {(string | number)[]}
|
|
275
|
+
*/
|
|
276
|
+
function segmentsOf(ref) {
|
|
277
|
+
return ref.segments.map((segment) => ('name' in segment ? segment.name : segment.index));
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* One projected LEAF of a row, by the suffix its value/type pair was
|
|
282
|
+
* named under: the empty suffix for the single-path plan, `0`, `1`, …
|
|
283
|
+
* for a projection tree's leaves.
|
|
284
|
+
* @param {any} row
|
|
285
|
+
* @param {string} suffix
|
|
286
|
+
* @returns {any[]} the item, or nothing where the member is absent
|
|
287
|
+
*/
|
|
288
|
+
function leafItems(row, suffix) {
|
|
289
|
+
const type = row[`t${suffix}`];
|
|
290
|
+
if (type === null || type === undefined) return [];
|
|
291
|
+
const value = row[`v${suffix}`];
|
|
292
|
+
if (type === 'true') return [true];
|
|
293
|
+
if (type === 'false') return [false];
|
|
294
|
+
if (type === 'null') return [null];
|
|
295
|
+
if (type === 'object' || type === 'array') return [JSON.parse(value)];
|
|
296
|
+
if (type === 'text') return [String(value)];
|
|
297
|
+
return [Number(value)];
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* The one item a projection TREE answers for one row, rebuilt from the
|
|
302
|
+
* leaves the statement fetched. The engine's own rules decide what an
|
|
303
|
+
* absent leaf does: a member whose value is the empty sequence is
|
|
304
|
+
* OMITTED from its object and SKIPPED in its array — which is why a
|
|
305
|
+
* literal `null` (present in every row) and a path that finds nothing
|
|
306
|
+
* (present in none) cannot share a representation here.
|
|
307
|
+
* @param {any} project - the plan's `{ tree, leaves }`
|
|
308
|
+
* @param {any} row
|
|
309
|
+
* @param {string} [prefix] - what the statement named its value/type
|
|
310
|
+
* pairs: empty on a collection, `'p'` on an entity plan, whose own
|
|
311
|
+
* binding aliases are `t0`, `t1`, … and would collide with a bare one
|
|
312
|
+
* @returns {any[]} exactly one item; a tree always constructs something
|
|
313
|
+
*/
|
|
314
|
+
function projectedTreeItems(project, row, prefix = '') {
|
|
315
|
+
const build = (node) => {
|
|
316
|
+
if (node.p === 'lit') return [node.value];
|
|
317
|
+
if (node.p === 'leaf') return leafItems(row, `${prefix}${node.index}`);
|
|
318
|
+
if (node.p === 'object') {
|
|
319
|
+
/** @type {any} */
|
|
320
|
+
const out = {};
|
|
321
|
+
for (const member of node.members) {
|
|
322
|
+
const items = build(member.node);
|
|
323
|
+
if (items.length > 0) out[member.name] = items[0];
|
|
324
|
+
}
|
|
325
|
+
return [out];
|
|
326
|
+
}
|
|
327
|
+
const items = [];
|
|
328
|
+
for (const item of node.items) items.push(...build(item));
|
|
329
|
+
return [items];
|
|
330
|
+
};
|
|
331
|
+
return build(project.tree);
|
|
332
|
+
}
|
|
333
|
+
|
|
128
334
|
/**
|
|
129
335
|
* The query engine for one collection.
|
|
130
336
|
* @param {{ connection: any, state: any, collection: any,
|
|
@@ -137,6 +343,10 @@ function externalSlotKinds(slots, rank) {
|
|
|
137
343
|
export function createQueryEngine(context) {
|
|
138
344
|
const { connection, state, collection, physicalPlan } = context;
|
|
139
345
|
const storeProfile = context.profile ?? null;
|
|
346
|
+
// every root a profile's member allow-list may name: the model's
|
|
347
|
+
// collections and entities, so a typo in the policy is refused rather
|
|
348
|
+
// than applied to nothing
|
|
349
|
+
const roots = context.roots ?? [collection.name];
|
|
140
350
|
// the store's registered operators (Ring 2): recognised by the planner
|
|
141
351
|
// as vocabulary, evaluated in the residual, threaded into every
|
|
142
352
|
// residual compilation here. `null` when the store opened with no
|
|
@@ -183,26 +393,57 @@ export function createQueryEngine(context) {
|
|
|
183
393
|
* `diverted` counts the calls whose native bucket met a group with no
|
|
184
394
|
* instant and handed the whole question back to the engine. */
|
|
185
395
|
const seriesStats = { queries: 0, statements: 0, candidates: 0, results: 0, diverted: 0 };
|
|
396
|
+
/** The PLAIN bind-time diversion counter: calls whose plan was native
|
|
397
|
+
* or row-mode and whose bound external the database could not take (a
|
|
398
|
+
* boolean, a null, a missing name, a region with no box), so the whole
|
|
399
|
+
* collection was read and the engine answered. The k-nearest and the
|
|
400
|
+
* temporal diversions have their own counters; this is the one the
|
|
401
|
+
* ordinary predicate takes, and it is what proves the diversion in
|
|
402
|
+
* production where nobody calls `explain()`. */
|
|
403
|
+
const bindStats = { diverted: 0 };
|
|
186
404
|
/** The by-identities fetch statements, one per batch size. */
|
|
187
405
|
const identityFetch = new Map();
|
|
188
406
|
|
|
189
|
-
/**
|
|
190
|
-
*
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
if (
|
|
203
|
-
|
|
407
|
+
/** Every driver failure this engine meets, classified under the
|
|
408
|
+
* collection it belongs to. */
|
|
409
|
+
const driverWrap = (/** @type {any} */ error) =>
|
|
410
|
+
wrapDriverError(error, { docPath: collection.docPath, collection: collection.name });
|
|
411
|
+
/**
|
|
412
|
+
* The coded residual for a pushed aggregate that overflowed int64:
|
|
413
|
+
* the engine answers the caller's document over the fetched rows —
|
|
414
|
+
* exactly what an unpushed run always answered — and the entry
|
|
415
|
+
* remembers that it did, for `explain()`. The fetch is a full scan,
|
|
416
|
+
* so a profile that refuses one refuses here too.
|
|
417
|
+
* @param {any} entry @param {any} externals @param {any} document
|
|
418
|
+
*/
|
|
419
|
+
const overflowResidual = (entry, externals, document) => {
|
|
420
|
+
if (entry.needsScanCheck) {
|
|
421
|
+
throw profileRefusal(`the profile refuses a full-table scan of '${collection.name}' `
|
|
422
|
+
+ '(the pushed aggregate overflowed int64 and the engine would read the whole collection)');
|
|
423
|
+
}
|
|
424
|
+
entry.overflowRuns = (entry.overflowRuns ?? 0) + 1;
|
|
425
|
+
return chain(fullScanOf(entry), (statement) =>
|
|
426
|
+
chain(statement.all(fullScanParams(entry)), (rows) =>
|
|
427
|
+
setResidualOf(entry, document)(rowsToDocs(checkRowBound(entry, rows)), externals)));
|
|
204
428
|
};
|
|
205
429
|
|
|
430
|
+
/**
|
|
431
|
+
* Ring 3 for AGGREGATES: a registry `agg` operator the pack marked
|
|
432
|
+
* `pushable: 'aggregate'` becomes a SQL aggregate over the member's
|
|
433
|
+
* column, folded by the same pure function the residual would call.
|
|
434
|
+
* Two independent gates, never one inferred from the other: the
|
|
435
|
+
* DRIVER must have an aggregate API (bun:sqlite does not), and the
|
|
436
|
+
* pack must have declared the operator poolable that way.
|
|
437
|
+
*/
|
|
438
|
+
const aggregateHook = connection.capabilities.aggregateFunctions === true
|
|
439
|
+
&& operators !== null && operators.pushableAggregate?.size > 0
|
|
440
|
+
? (/** @type {string} */ name) => {
|
|
441
|
+
const spec = operators.pushableAggregate.get(name);
|
|
442
|
+
if (spec === undefined) return null;
|
|
443
|
+
return { sql: registerAggregateOperator(connection, state.registeredAggregates, name, spec) };
|
|
444
|
+
}
|
|
445
|
+
: undefined;
|
|
446
|
+
|
|
206
447
|
const udfHook = connection.capabilities.userFunctions
|
|
207
448
|
? (/** @type {any} */ fragment, /** @type {string} */ binding) => {
|
|
208
449
|
// Ring 3: admit the registry's pushable:'scalar' operators too
|
|
@@ -248,23 +489,35 @@ export function createQueryEngine(context) {
|
|
|
248
489
|
`the profile does not allow querying collection '${collection.name}'`);
|
|
249
490
|
}
|
|
250
491
|
|
|
251
|
-
// no
|
|
252
|
-
// cause
|
|
492
|
+
// no host-side registration under a profile: a foreign document must
|
|
493
|
+
// not cause one, for a predicate fragment or an aggregate alike
|
|
494
|
+
const registering = profile === null && pushdown;
|
|
253
495
|
let planned = planQuery(document, shape,
|
|
254
|
-
{ udf:
|
|
496
|
+
{ udf: registering ? udfHook : undefined,
|
|
497
|
+
aggregate: registering ? aggregateHook : undefined });
|
|
255
498
|
if (!pushdown) {
|
|
256
499
|
planned = {
|
|
257
500
|
...planned,
|
|
258
501
|
plan: null,
|
|
259
502
|
mode: 'set',
|
|
260
|
-
reasons: [{ construct: 'pushdown', reason:
|
|
503
|
+
reasons: [{ construct: 'pushdown', reason: BIND_REASONS.pushdown }],
|
|
261
504
|
rowReturn: null,
|
|
262
505
|
udfs: [],
|
|
263
506
|
prefilters: [],
|
|
507
|
+
// the whole collection is fetched and the engine answers: the
|
|
508
|
+
// temporal record says so, whatever index the plan would have used
|
|
509
|
+
series: planned.series === null ? null
|
|
510
|
+
: { ...planned.series, mode: 'engine', index: null, prefix: [] },
|
|
264
511
|
};
|
|
265
512
|
}
|
|
266
513
|
|
|
267
514
|
if (profile !== null) {
|
|
515
|
+
// the member allow-list: what the caller may OBTAIN, checked
|
|
516
|
+
// against every member path the document references, before a
|
|
517
|
+
// statement exists
|
|
518
|
+
const denied = memberDenial(profile, collection.name,
|
|
519
|
+
planned.analysis.root, isRootScanSource);
|
|
520
|
+
if (denied !== null) throw profileRefusal(denied);
|
|
268
521
|
const deps = planned.analysis.dependencies;
|
|
269
522
|
for (const name of deps.functions) {
|
|
270
523
|
if (!profile.functions.includes(name))
|
|
@@ -299,8 +552,26 @@ export function createQueryEngine(context) {
|
|
|
299
552
|
return out;
|
|
300
553
|
};
|
|
301
554
|
|
|
302
|
-
|
|
303
|
-
|
|
555
|
+
let plan = shapePlan(planned.plan ?? selectPlan(collection.name));
|
|
556
|
+
let emitted;
|
|
557
|
+
try {
|
|
558
|
+
emitted = emitPlan(plan, dialect, physical);
|
|
559
|
+
}
|
|
560
|
+
catch (error) {
|
|
561
|
+
if (!(error instanceof UnrepresentablePath)) throw error;
|
|
562
|
+
// a member name the dialect cannot spell: the whole document runs
|
|
563
|
+
// in the set residual, named — and strict mode refuses it by name
|
|
564
|
+
if (strict) {
|
|
565
|
+
throw new DbCompileError('JD0010',
|
|
566
|
+
`strict mode refused a residual: 'path' — ${error.message}`, collection.docPath);
|
|
567
|
+
}
|
|
568
|
+
planned = {
|
|
569
|
+
...planned, plan: null, mode: 'set', rowReturn: null, udfs: [], prefilters: [],
|
|
570
|
+
series: null, reasons: [{ construct: 'path', reason: error.message }, ...planned.reasons],
|
|
571
|
+
};
|
|
572
|
+
plan = shapePlan(selectPlan(collection.name));
|
|
573
|
+
emitted = emitPlan(plan, dialect, physical);
|
|
574
|
+
}
|
|
304
575
|
const externalNames = planned.analysis.externals.map((e) => e.name);
|
|
305
576
|
const limits = profile === null ? undefined : profile.limits;
|
|
306
577
|
const entry = {
|
|
@@ -308,16 +579,29 @@ export function createQueryEngine(context) {
|
|
|
308
579
|
plan,
|
|
309
580
|
sql: emitted.sql,
|
|
310
581
|
slots: emitted.slots,
|
|
582
|
+
// the plan's own anchor reads, prepared on first use and kept
|
|
583
|
+
// with it: each one binds a TYPED slot in the statement above
|
|
584
|
+
seeks: (emitted.seeks ?? []).map((seek) => ({ ...seek, statement: null })),
|
|
311
585
|
externalNames,
|
|
312
586
|
externalSlotKinds: externalSlotKinds(emitted.slots, plan.rank),
|
|
313
587
|
// a literal probe is normalized once, here; an external one per
|
|
314
588
|
// call, from the bound value
|
|
315
589
|
probe: plan.rank !== null && 'lit' in plan.rank.probe
|
|
316
|
-
? probeVector(plan.rank.probe.lit, plan.rank.dims) : null,
|
|
590
|
+
? probeVector(plan.rank.probe.lit, plan.rank.alternatives[0].dims) : null,
|
|
591
|
+
// one emitted statement per declared width, prepared on first use
|
|
592
|
+
// and cached with the plan: the bind picks the alternative the
|
|
593
|
+
// probe's own width names, and prepares nothing per call
|
|
594
|
+
rankAlternatives: plan.rank === null ? null
|
|
595
|
+
: plan.rank.alternatives.map((alternative) => {
|
|
596
|
+
const one = emitPlan({ ...plan, rank: { ...plan.rank, alternatives: [alternative] } },
|
|
597
|
+
dialect, physical);
|
|
598
|
+
return { ...alternative, sql: one.sql, slots: one.slots, statement: null };
|
|
599
|
+
}),
|
|
317
600
|
dependencies: planned.analysis.dependencies,
|
|
318
601
|
limits: planned.analysis.limits,
|
|
319
602
|
residualLimits: limits,
|
|
320
603
|
rowBound: maxRows,
|
|
604
|
+
byteBound: profile === null ? null : profile.maxBytes,
|
|
321
605
|
needsScanCheck: profile !== null && profile.refuseFullScan === true,
|
|
322
606
|
scanChecked: false,
|
|
323
607
|
statement: null,
|
|
@@ -340,7 +624,7 @@ export function createQueryEngine(context) {
|
|
|
340
624
|
};
|
|
341
625
|
|
|
342
626
|
const statementOf = (entry) => {
|
|
343
|
-
if (entry.statement === null) entry.statement = connection.prepare(entry.sql);
|
|
627
|
+
if (entry.statement === null) entry.statement = connection.prepare(entry.sql, { readOnly: true });
|
|
344
628
|
return entry.statement;
|
|
345
629
|
};
|
|
346
630
|
const setResidualOf = (entry, document) => {
|
|
@@ -353,8 +637,8 @@ export function createQueryEngine(context) {
|
|
|
353
637
|
* whole result sequence into one unambiguous array. */
|
|
354
638
|
const packedResidualOf = (entry, document) => {
|
|
355
639
|
if (entry.packedResidual === null) {
|
|
356
|
-
|
|
357
|
-
|
|
640
|
+
entry.packedResidual = compilePackedResidual(document, entry.residualLimits, operators,
|
|
641
|
+
zoneProvider);
|
|
358
642
|
}
|
|
359
643
|
return entry.packedResidual;
|
|
360
644
|
};
|
|
@@ -363,27 +647,42 @@ export function createQueryEngine(context) {
|
|
|
363
647
|
* profile's mandatory predicate and row bound — a diverted call must
|
|
364
648
|
* not escape either.
|
|
365
649
|
*/
|
|
366
|
-
const
|
|
650
|
+
const fullScanEmitted = (entry) => {
|
|
367
651
|
if (entry.fullScanSql === null) {
|
|
368
652
|
const emitted = emitPlan(entry.fullScanShape(), dialect, physical);
|
|
369
653
|
entry.fullScanSql = { sql: emitted.sql, slots: emitted.slots, statement: null };
|
|
370
654
|
}
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
655
|
+
return entry.fullScanSql;
|
|
656
|
+
};
|
|
657
|
+
const fullScanOf = (entry) => {
|
|
658
|
+
const emitted = fullScanEmitted(entry);
|
|
659
|
+
if (emitted.statement === null) emitted.statement = connection.prepare(emitted.sql, { readOnly: true });
|
|
660
|
+
return emitted.statement;
|
|
374
661
|
};
|
|
375
662
|
const fullScanParams = (entry) =>
|
|
376
|
-
entry.
|
|
663
|
+
fullScanEmitted(entry).slots.map((slot) => ('literal' in slot ? slot.literal : null));
|
|
377
664
|
|
|
378
|
-
/** Refuse a fetch that crossed the profile's row bound (JD2007)
|
|
665
|
+
/** Refuse a fetch that crossed the profile's row bound (JD2007), or
|
|
666
|
+
* a row whose document is larger than its byte bound (JD2076). */
|
|
379
667
|
const checkRowBound = (entry, rows) => {
|
|
380
668
|
if (entry.rowBound !== null && rows.length > entry.rowBound) {
|
|
381
669
|
throw new DbRuntimeError('JD2007',
|
|
382
670
|
`the fetch crossed the profile's maxRows bound of ${entry.rowBound}`,
|
|
383
671
|
{ docPath: collection.docPath, collection: collection.name });
|
|
384
672
|
}
|
|
673
|
+
if (entry.byteBound !== null) for (const row of rows) checkByteBound(entry, row);
|
|
385
674
|
return rows;
|
|
386
675
|
};
|
|
676
|
+
/** One row's document against the profile's byte bound. */
|
|
677
|
+
const checkByteBound = (entry, row) => {
|
|
678
|
+
if (entry.byteBound === null || typeof row.doc !== 'string') return;
|
|
679
|
+
const bytes = utf8Length(row.doc);
|
|
680
|
+
if (bytes > entry.byteBound) {
|
|
681
|
+
throw new DbRuntimeError('JD2076',
|
|
682
|
+
`an item of ${bytes} serialised bytes exceeds the profile's maxBytes bound of ${entry.byteBound}`,
|
|
683
|
+
{ docPath: collection.docPath, collection: collection.name });
|
|
684
|
+
}
|
|
685
|
+
};
|
|
387
686
|
|
|
388
687
|
/** The optional plan-shape refusal: a full-table SCAN of a profiled
|
|
389
688
|
* collection is refused when the profile says so, verified against
|
|
@@ -391,35 +690,116 @@ export function createQueryEngine(context) {
|
|
|
391
690
|
const guardScan = (entry) => {
|
|
392
691
|
if (!entry.needsScanCheck || entry.scanChecked) return null;
|
|
393
692
|
const eqpParams = entry.slots.map((slot) => ('literal' in slot ? slot.literal : null));
|
|
394
|
-
return chain(connection.prepare(dialect.explainQuery(entry.sql)), (statement) =>
|
|
693
|
+
return chain(connection.prepare(dialect.explainQuery(entry.sql), { readOnly: true }), (statement) =>
|
|
395
694
|
chain(statement.all(eqpParams), (rows) => {
|
|
396
|
-
const
|
|
397
|
-
|
|
398
|
-
return detail.startsWith(`SCAN ${physical.table}`)
|
|
399
|
-
&& !detail.includes('USING INDEX');
|
|
400
|
-
});
|
|
401
|
-
if (fullScan) {
|
|
695
|
+
const lines = dialect.explainLines(rows);
|
|
696
|
+
if (lines.some((line) => dialect.isFullScan(line, [physical.table]))) {
|
|
402
697
|
throw profileRefusal(
|
|
403
698
|
`the profile refuses a full-table scan of '${collection.name}' `
|
|
404
|
-
+ `(${
|
|
699
|
+
+ `(${lines.join('; ')})`);
|
|
405
700
|
}
|
|
406
701
|
entry.scanChecked = true;
|
|
407
702
|
return null;
|
|
408
703
|
}));
|
|
409
704
|
};
|
|
410
705
|
|
|
411
|
-
/**
|
|
412
|
-
|
|
413
|
-
|
|
706
|
+
/**
|
|
707
|
+
* The declared width a probe names, as the emitted alternative that
|
|
708
|
+
* reads it — or `null` when no declared width takes this value, which
|
|
709
|
+
* is the diversion a wrong-width probe has always been. A `null`
|
|
710
|
+
* probe, a probe of the wrong shape and a probe of an undeclared
|
|
711
|
+
* width are one answer here: the database has no column for it.
|
|
712
|
+
* @param {any} entry
|
|
713
|
+
* @param {any} value - the bound probe
|
|
714
|
+
*/
|
|
715
|
+
const rankAlternativeFor = (entry, value) =>
|
|
716
|
+
entry.rankAlternatives.find((alternative) =>
|
|
717
|
+
probeVector(value, alternative.dims) !== null) ?? null;
|
|
718
|
+
|
|
719
|
+
/** One alternative's statement, prepared once and kept with the plan. */
|
|
720
|
+
const alternativeStatement = (alternative) => {
|
|
721
|
+
if (alternative.statement === null)
|
|
722
|
+
alternative.statement = connection.prepare(alternative.sql, { readOnly: true });
|
|
723
|
+
return alternative.statement;
|
|
724
|
+
};
|
|
414
725
|
|
|
415
|
-
/**
|
|
416
|
-
|
|
417
|
-
|
|
726
|
+
/**
|
|
727
|
+
* The anchors this entry's seeks answer, read before the statement
|
|
728
|
+
* that binds them. Each seek is one aggregate read through the same
|
|
729
|
+
* declared index, prepared once and kept with the plan; a seek that
|
|
730
|
+
* finds nothing binds its own probe, which excludes exactly the rows
|
|
731
|
+
* it proved are not there.
|
|
732
|
+
* @param {any} entry
|
|
733
|
+
* @returns {any} value-or-promise of the name → anchor map
|
|
734
|
+
*/
|
|
735
|
+
const seekAnchors = (entry) => {
|
|
736
|
+
/** @type {Record<string, any>} */
|
|
737
|
+
const anchors = Object.create(null);
|
|
738
|
+
const next = (i) => {
|
|
739
|
+
if (i >= entry.seeks.length) return anchors;
|
|
740
|
+
const seek = entry.seeks[i];
|
|
741
|
+
if (seek.statement === null) seek.statement = connection.prepare(seek.sql, { readOnly: true });
|
|
742
|
+
return chain(seek.statement, (prepared) =>
|
|
743
|
+
chain(prepared.get(seek.slots.map((slot) => slotValue(slot, {}))), (row) => {
|
|
744
|
+
anchors[seek.name] = anchorValue(seek, row);
|
|
745
|
+
return next(i + 1);
|
|
746
|
+
}));
|
|
747
|
+
};
|
|
748
|
+
return next(0);
|
|
749
|
+
};
|
|
750
|
+
|
|
751
|
+
/**
|
|
752
|
+
* One seek's answer, type-checked before it can reach a statement.
|
|
753
|
+
* The column is of declared type, so the only way a value of another
|
|
754
|
+
* type arrives is a defect below the store (a driver handing back a
|
|
755
|
+
* BigInt, a column written past the declaration) — and a bind that
|
|
756
|
+
* silently compared a number against text would answer WRONG rather
|
|
757
|
+
* than fail, so it is refused here, before the SQL it would bind.
|
|
758
|
+
* @param {any} seek
|
|
759
|
+
* @param {any} row
|
|
760
|
+
*/
|
|
761
|
+
const anchorValue = (seek, row) => {
|
|
762
|
+
const value = row === undefined || row === null ? null : row.anchor;
|
|
763
|
+
if (value === null || value === undefined) return seek.fallback;
|
|
764
|
+
const kind = typeof value === 'number' && Number.isFinite(value) ? 'number'
|
|
765
|
+
: typeof value === 'string' ? 'text' : null;
|
|
766
|
+
if (kind !== seek.kind) {
|
|
767
|
+
throw new DbRuntimeError('JD2086',
|
|
768
|
+
`the seek '${seek.name}' declared ${seek.kind} and the database answered `
|
|
769
|
+
+ `${typeof value}`, collection.name);
|
|
770
|
+
}
|
|
771
|
+
return value;
|
|
772
|
+
};
|
|
773
|
+
|
|
774
|
+
/** Bind slots against the call's externals, and the seeks' anchors. */
|
|
775
|
+
const bindParams = (entry, externals) =>
|
|
776
|
+
(entry.seeks.length === 0
|
|
777
|
+
? entry.slots.map((slot) => slotValue(slot, externals))
|
|
778
|
+
: chain(seekAnchors(entry), (anchors) =>
|
|
779
|
+
entry.slots.map((slot) => slotValue(slot, externals, anchors))));
|
|
780
|
+
|
|
781
|
+
/** Run a bound statement: the bind itself may have to READ first. */
|
|
782
|
+
const runAll = (entry, externals, statement) =>
|
|
783
|
+
chain(bindParams(entry, externals), (params) => statement.all(params));
|
|
784
|
+
const runGet = (entry, externals, statement) =>
|
|
785
|
+
chain(bindParams(entry, externals), (params) => statement.get(params));
|
|
786
|
+
/** How many statements one execution of this entry costs. */
|
|
787
|
+
const statementCost = (entry) => 1 + entry.seeks.length;
|
|
788
|
+
|
|
789
|
+
/** The external whose bound value sends this call to the residual —
|
|
790
|
+
* a value the database cannot take, a region with no box, a probe of
|
|
791
|
+
* the wrong width — or `null` when every external binds. */
|
|
792
|
+
const divertingExternal = (entry, externals) =>
|
|
793
|
+
entry.externalNames.find((name) => {
|
|
418
794
|
const kind = entry.externalSlotKinds.get(name);
|
|
419
795
|
if (kind === 'derived') return probeBox(externals[name]) === null;
|
|
420
|
-
|
|
796
|
+
// a probe binds when SOME declared width takes it; a width the
|
|
797
|
+
// model does not declare is the diversion it always was
|
|
798
|
+
if (kind === 'probe') return rankAlternativeFor(entry, externals[name]) === null;
|
|
421
799
|
return !bindable(externals[name]);
|
|
422
|
-
});
|
|
800
|
+
}) ?? null;
|
|
801
|
+
/** Must this call divert to the residual? */
|
|
802
|
+
const mustDivert = (entry, externals) => divertingExternal(entry, externals) !== null;
|
|
423
803
|
|
|
424
804
|
const rowsToDocs = (rows) => rows.map((row) => JSON.parse(row.doc));
|
|
425
805
|
|
|
@@ -454,6 +834,46 @@ export function createQueryEngine(context) {
|
|
|
454
834
|
return items;
|
|
455
835
|
};
|
|
456
836
|
|
|
837
|
+
/**
|
|
838
|
+
* The items a GENERAL grouping answers: one per group row, built from
|
|
839
|
+
* the group's keys and aggregates through the plan's own tree. A key
|
|
840
|
+
* comes back with its JSON type beside it, so an absent key leaves
|
|
841
|
+
* its member out exactly as the object constructor does; an aggregate
|
|
842
|
+
* over no values follows the mapping the plan recorded — `0` for a
|
|
843
|
+
* count or a sum, the empty sequence for the other three, which is
|
|
844
|
+
* the ENGINE's answer, not SQL's `NULL`.
|
|
845
|
+
* @param {any} entry
|
|
846
|
+
* @param {any[]} rows
|
|
847
|
+
* @returns {any[]}
|
|
848
|
+
*/
|
|
849
|
+
const groupItems = (entry, rows) => {
|
|
850
|
+
const group = entry.plan.group;
|
|
851
|
+
const aggregateItems = (row, index) => {
|
|
852
|
+
const value = row[`a${index}`] ?? null;
|
|
853
|
+
const empty = group.aggregates[index].empty;
|
|
854
|
+
if (value === null) return empty === 'omit' ? [] : [empty === 'zero' ? 0 : null];
|
|
855
|
+
return [value];
|
|
856
|
+
};
|
|
857
|
+
const build = (node, row) => {
|
|
858
|
+
if (node.p === 'lit') return [node.value];
|
|
859
|
+
if (node.p === 'key') return leafItems(row, `k${node.index}`);
|
|
860
|
+
if (node.p === 'agg') return aggregateItems(row, node.index);
|
|
861
|
+
if (node.p === 'object') {
|
|
862
|
+
/** @type {any} */
|
|
863
|
+
const out = {};
|
|
864
|
+
for (const member of node.members) {
|
|
865
|
+
const items = build(member.node, row);
|
|
866
|
+
if (items.length > 0) out[member.name] = items[0];
|
|
867
|
+
}
|
|
868
|
+
return [out];
|
|
869
|
+
}
|
|
870
|
+
const items = [];
|
|
871
|
+
for (const item of node.items) items.push(...build(item, row));
|
|
872
|
+
return [items];
|
|
873
|
+
};
|
|
874
|
+
return rows.flatMap((row) => build(group.tree, row));
|
|
875
|
+
};
|
|
876
|
+
|
|
457
877
|
/** How many ITEMS an engine result carries (its own shape rule). */
|
|
458
878
|
const itemCount = (answer) => (answer === undefined ? 0
|
|
459
879
|
: Array.isArray(answer) ? answer.length : 1);
|
|
@@ -475,14 +895,35 @@ export function createQueryEngine(context) {
|
|
|
475
895
|
}));
|
|
476
896
|
};
|
|
477
897
|
|
|
478
|
-
/** Record one actual execution against the entry and the store.
|
|
898
|
+
/** Record one actual execution against the entry and the store. A
|
|
899
|
+
* `null` candidate count is a run the database finished on its own —
|
|
900
|
+
* an aggregate — where no row reached the engine and SQLite reports no
|
|
901
|
+
* visited-row count: the slot stays empty rather than estimated. */
|
|
479
902
|
const countSeries = (entry, statements, candidates, results) => {
|
|
480
903
|
if (entry.planned.series === null) return;
|
|
481
904
|
seriesStats.queries++;
|
|
482
905
|
seriesStats.statements += statements;
|
|
483
|
-
seriesStats.candidates += candidates;
|
|
906
|
+
if (candidates !== null) seriesStats.candidates += candidates;
|
|
484
907
|
seriesStats.results += results;
|
|
485
|
-
entry.seriesCounts = { statements, candidates, results };
|
|
908
|
+
entry.seriesCounts = { statements, candidates, results, partial: false };
|
|
909
|
+
};
|
|
910
|
+
/** A cursor's run accounting: counted as it is drained, final when it
|
|
911
|
+
* settles; a mid-iteration `explain()` reads the numbers so far and
|
|
912
|
+
* says so (`partial: true`). */
|
|
913
|
+
const seriesTally = (entry) => {
|
|
914
|
+
if (entry.planned.series === null) return null;
|
|
915
|
+
const live = { statements: statementCost(entry), candidates: 0, results: 0, partial: true };
|
|
916
|
+
entry.seriesCounts = live;
|
|
917
|
+
return {
|
|
918
|
+
row: (items) => {
|
|
919
|
+
live.candidates++;
|
|
920
|
+
live.results += items;
|
|
921
|
+
},
|
|
922
|
+
settle: (opened) => {
|
|
923
|
+
if (entry.seriesCounts === live) entry.seriesCounts = { ...live, partial: false };
|
|
924
|
+
if (opened) countSeries(entry, statementCost(entry), live.candidates, live.results);
|
|
925
|
+
},
|
|
926
|
+
};
|
|
486
927
|
};
|
|
487
928
|
|
|
488
929
|
/**
|
|
@@ -501,7 +942,7 @@ export function createQueryEngine(context) {
|
|
|
501
942
|
const batch = batches[i];
|
|
502
943
|
let statement = identityFetch.get(batch.size);
|
|
503
944
|
if (statement === undefined) {
|
|
504
|
-
statement = connection.prepare(dialect.dml.selectByIdentities(physical, batch.size));
|
|
945
|
+
statement = connection.prepare(dialect.dml.selectByIdentities(physical, batch.size), { readOnly: true });
|
|
505
946
|
identityFetch.set(batch.size, statement);
|
|
506
947
|
}
|
|
507
948
|
return chain(statement, (prepared) => chain(prepared.all(batch.params), (rows) => {
|
|
@@ -523,12 +964,15 @@ export function createQueryEngine(context) {
|
|
|
523
964
|
*/
|
|
524
965
|
const knnCandidates = (entry, externals) => {
|
|
525
966
|
const rank = entry.plan.rank;
|
|
526
|
-
const
|
|
527
|
-
|
|
528
|
-
|
|
967
|
+
const chosen = 'lit' in rank.probe
|
|
968
|
+
? entry.rankAlternatives[0]
|
|
969
|
+
: rankAlternativeFor(entry, externals[rank.probe.ext]);
|
|
970
|
+
const probe = entry.probe ?? probeVector(externals[rank.probe.ext], chosen.dims);
|
|
971
|
+
return chain(alternativeStatement(chosen), (statement) =>
|
|
972
|
+
chain(statement.all(chosen.slots.map((slot) => slotValue(slot, externals))), (rows) => {
|
|
529
973
|
checkRowBound(entry, rows);
|
|
530
974
|
const scored = rows.map((row) =>
|
|
531
|
-
({ identity: row.rid, score: columnScore(row.vec,
|
|
975
|
+
({ identity: row.rid, score: columnScore(row.vec, chosen.dims, probe) }));
|
|
532
976
|
const cut = cutCandidates(scored, rank.offset + rank.limit, rank.margin);
|
|
533
977
|
knnStats.queries++;
|
|
534
978
|
knnStats.rows += rows.length;
|
|
@@ -549,14 +993,21 @@ export function createQueryEngine(context) {
|
|
|
549
993
|
*/
|
|
550
994
|
const candidatesOf = (entry, externals, diverted) => {
|
|
551
995
|
if (diverted) {
|
|
996
|
+
// a diversion IS a full-table scan; the plan-shape check above
|
|
997
|
+
// only ever saw the native statement
|
|
998
|
+
if (entry.needsScanCheck) {
|
|
999
|
+
throw profileRefusal(`the profile refuses a full-table scan of '${collection.name}' `
|
|
1000
|
+
+ '(a bound external the database cannot take diverted the call to the whole collection)');
|
|
1001
|
+
}
|
|
552
1002
|
if (entry.planned.mode === 'knn') knnStats.diverted++;
|
|
1003
|
+
else bindStats.diverted++;
|
|
553
1004
|
return chain(fullScanOf(entry), (statement) =>
|
|
554
1005
|
chain(statement.all(fullScanParams(entry)), (rows) =>
|
|
555
1006
|
rowsToDocs(checkRowBound(entry, rows))));
|
|
556
1007
|
}
|
|
557
1008
|
if (entry.planned.mode === 'knn') return knnCandidates(entry, externals);
|
|
558
1009
|
return chain(statementOf(entry), (statement) =>
|
|
559
|
-
chain(
|
|
1010
|
+
chain(runAll(entry, externals, statement), (rows) =>
|
|
560
1011
|
rowsToDocs(checkRowBound(entry, rows))));
|
|
561
1012
|
};
|
|
562
1013
|
|
|
@@ -568,6 +1019,21 @@ export function createQueryEngine(context) {
|
|
|
568
1019
|
return value === null ? undefined : value;
|
|
569
1020
|
};
|
|
570
1021
|
|
|
1022
|
+
/**
|
|
1023
|
+
* The profile one call runs under, with its member allow-list checked
|
|
1024
|
+
* against the model's declared roots — a policy naming a root the
|
|
1025
|
+
* model does not have applies to nothing, which is a policy failing
|
|
1026
|
+
* open, so it is refused here rather than at the first query that
|
|
1027
|
+
* happens to name that root.
|
|
1028
|
+
* @param {any} options
|
|
1029
|
+
*/
|
|
1030
|
+
const resolveProfile = (options) => {
|
|
1031
|
+
const resolved = options?.profile !== undefined
|
|
1032
|
+
? normalizeProfile(options.profile) : storeProfile;
|
|
1033
|
+
assertProfileRoots(resolved, roots, collection.docPath);
|
|
1034
|
+
return resolved;
|
|
1035
|
+
};
|
|
1036
|
+
|
|
571
1037
|
/**
|
|
572
1038
|
* Resolve the profile and pushdown switches for one call.
|
|
573
1039
|
* @param {any} options
|
|
@@ -575,9 +1041,8 @@ export function createQueryEngine(context) {
|
|
|
575
1041
|
const callState = (options) => ({
|
|
576
1042
|
externals: options?.externals ?? {},
|
|
577
1043
|
strict: options?.strict === true,
|
|
578
|
-
profile: options
|
|
579
|
-
|
|
580
|
-
: storeProfile,
|
|
1044
|
+
profile: resolveProfile(options),
|
|
1045
|
+
profileSource: options?.profile !== undefined ? 'call' : (storeProfile === null ? null : 'store'),
|
|
581
1046
|
pushdown: options?.pushdown !== false,
|
|
582
1047
|
});
|
|
583
1048
|
|
|
@@ -590,6 +1055,11 @@ export function createQueryEngine(context) {
|
|
|
590
1055
|
* synchronous driver synchronous)
|
|
591
1056
|
*/
|
|
592
1057
|
const execute = (document, options = undefined) => {
|
|
1058
|
+
if (options?.strictStreaming === true) {
|
|
1059
|
+
throw new TypeError('strictStreaming applies to a cursor (query()); execute() answers '
|
|
1060
|
+
+ 'the whole result by contract, so there is no stream to hold it to');
|
|
1061
|
+
}
|
|
1062
|
+
requireCallable(options, state.now);
|
|
593
1063
|
const { externals, strict, profile, pushdown } = callState(options);
|
|
594
1064
|
const entry = entryFor(document, strict, profile, pushdown);
|
|
595
1065
|
|
|
@@ -602,167 +1072,188 @@ export function createQueryEngine(context) {
|
|
|
602
1072
|
// narrowing: the fetch decided nothing)
|
|
603
1073
|
return chain(candidatesOf(entry, externals, diverted), (docs) => {
|
|
604
1074
|
const answer = setResidualOf(entry, document)(docs, externals);
|
|
605
|
-
countSeries(entry, 1, docs.length, itemCount(answer));
|
|
1075
|
+
countSeries(entry, diverted ? 1 : statementCost(entry), docs.length, itemCount(answer));
|
|
606
1076
|
return answer;
|
|
607
1077
|
});
|
|
608
1078
|
}
|
|
609
1079
|
if (entry.planned.mode === 'row') {
|
|
610
1080
|
return chain(statementOf(entry), (statement) =>
|
|
611
|
-
chain(
|
|
1081
|
+
chain(runAll(entry, externals, statement), (rows) => {
|
|
612
1082
|
const items = [];
|
|
613
1083
|
for (const row of checkRowBound(entry, rows))
|
|
614
1084
|
items.push(...entry.rowResidual(JSON.parse(row.doc), externals));
|
|
615
|
-
|
|
1085
|
+
countSeries(entry, statementCost(entry), rows.length, items.length);
|
|
1086
|
+
return answerOf(entry, items);
|
|
616
1087
|
}));
|
|
617
1088
|
}
|
|
618
1089
|
return chain(statementOf(entry), (statement) => {
|
|
619
1090
|
if (entry.plan.aggregate !== null) {
|
|
620
|
-
return chain(
|
|
621
|
-
|
|
1091
|
+
return recoverOverflow(() => chain(runGet(entry, externals, statement), (row) => {
|
|
1092
|
+
const value = aggregateResult(entry, row);
|
|
1093
|
+
countSeries(entry, statementCost(entry), null, value === undefined ? 0 : 1);
|
|
1094
|
+
return wrapValue(entry, value);
|
|
1095
|
+
}), () => overflowResidual(entry, externals, document));
|
|
1096
|
+
}
|
|
1097
|
+
if (entry.plan.group !== null) {
|
|
1098
|
+
return chain(runAll(entry, externals, statement), (rows) =>
|
|
1099
|
+
answerOf(entry, groupItems(entry, checkRowBound(entry, rows))));
|
|
622
1100
|
}
|
|
623
1101
|
if (entry.plan.bucket !== null) {
|
|
624
|
-
return chain(
|
|
1102
|
+
return chain(runAll(entry, externals, statement), (rows) => {
|
|
625
1103
|
const items = bucketItems(entry, checkRowBound(entry, rows));
|
|
626
1104
|
if (items === null) return divertBucket(entry, document, externals);
|
|
627
|
-
countSeries(entry,
|
|
628
|
-
return
|
|
1105
|
+
countSeries(entry, statementCost(entry), rows.length, items.length);
|
|
1106
|
+
return answerOf(entry, items);
|
|
629
1107
|
});
|
|
630
1108
|
}
|
|
631
|
-
return chain(
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
1109
|
+
return chain(runAll(entry, externals, statement), (rows) => {
|
|
1110
|
+
checkRowBound(entry, rows);
|
|
1111
|
+
const items = entry.plan.project === 'document'
|
|
1112
|
+
? rowsToDocs(rows)
|
|
1113
|
+
: 'path' in entry.plan.project
|
|
1114
|
+
? rows.flatMap(projectedItems)
|
|
1115
|
+
: rows.flatMap((row) => projectedTreeItems(entry.plan.project, row));
|
|
1116
|
+
countSeries(entry, statementCost(entry), rows.length, items.length);
|
|
1117
|
+
return answerOf(entry, items);
|
|
635
1118
|
});
|
|
636
1119
|
});
|
|
637
1120
|
});
|
|
638
1121
|
};
|
|
639
1122
|
|
|
1123
|
+
/**
|
|
1124
|
+
* What a cursor over this call will do — one database row per pull,
|
|
1125
|
+
* or a buffer the first pull fills — and the construct that forces
|
|
1126
|
+
* the buffer. `explain()` repeats this classification for the same
|
|
1127
|
+
* externals, so the two can never disagree about one run.
|
|
1128
|
+
* @param {any} entry
|
|
1129
|
+
* @param {any} externals
|
|
1130
|
+
* @returns {{ streaming: 'row' | 'buffered',
|
|
1131
|
+
* barrier: import('./cursor.js').CursorBarrier | null }}
|
|
1132
|
+
*/
|
|
1133
|
+
const cursorClass = (entry, externals) => {
|
|
1134
|
+
const buffered = (construct, reason) => ({ streaming: 'buffered', barrier: { construct, reason } });
|
|
1135
|
+
if (entry.planned.wrapped === true) {
|
|
1136
|
+
return buffered('window', BIND_REASONS.wrappedWindow);
|
|
1137
|
+
}
|
|
1138
|
+
// a plan that is a residual already buffers for its own reason,
|
|
1139
|
+
// whatever its externals bind to; that reason stays first
|
|
1140
|
+
if (entry.planned.mode === 'set' || entry.planned.mode === 'knn') {
|
|
1141
|
+
const forcing = entry.planned.reasons[0]
|
|
1142
|
+
?? { construct: 'residual', reason: BIND_REASONS.untranslated };
|
|
1143
|
+
return buffered(forcing.construct, forcing.reason);
|
|
1144
|
+
}
|
|
1145
|
+
// `null` externals is the ABSTRACT question — the plan as planned,
|
|
1146
|
+
// every external assumed bindable — which `explain()` answers when it
|
|
1147
|
+
// is given no externals at all; a call always binds real ones
|
|
1148
|
+
const unbindable = externals === null ? null : divertingExternal(entry, externals);
|
|
1149
|
+
if (unbindable !== null) {
|
|
1150
|
+
return buffered('external', BIND_REASONS.external(unbindable, 'collection'));
|
|
1151
|
+
}
|
|
1152
|
+
if (entry.plan.bucket !== null || entry.plan.group !== null) {
|
|
1153
|
+
return buffered('$groupby', BIND_REASONS.bucketWhole);
|
|
1154
|
+
}
|
|
1155
|
+
return rowClassOf(connection);
|
|
1156
|
+
};
|
|
1157
|
+
|
|
640
1158
|
/**
|
|
641
1159
|
* A streaming cursor over the document's result ITEMS (`next()` /
|
|
642
|
-
* `return()` plus `Symbol.asyncIterator`)
|
|
643
|
-
* Native and row modes
|
|
644
|
-
*
|
|
1160
|
+
* `return()` plus `Symbol.asyncIterator`), built on the one cursor
|
|
1161
|
+
* mechanism (cursor.js). Native and row modes pull one row per
|
|
1162
|
+
* `next()` from an open statement; a set residual, a k-nearest cut, a
|
|
1163
|
+
* diverting external, a native bucket and a chain's window
|
|
1164
|
+
* materialise first and say so (`streaming: 'buffered'`, with the
|
|
1165
|
+
* `barrier`); `signal` cancels at a row boundary.
|
|
645
1166
|
* @param {any} document
|
|
646
|
-
* @param {{ externals?: any, strict?: boolean
|
|
1167
|
+
* @param {{ externals?: any, strict?: boolean, profile?: any,
|
|
1168
|
+
* pushdown?: boolean, signal?: AbortSignal }} [options]
|
|
647
1169
|
*/
|
|
648
1170
|
const query = (document, options = undefined) => {
|
|
1171
|
+
requireCallable(options, state.now);
|
|
649
1172
|
const { externals, strict, profile, pushdown } = callState(options);
|
|
650
1173
|
const entry = entryFor(document, strict, profile, pushdown);
|
|
1174
|
+
const classified = cursorClass(entry, externals);
|
|
1175
|
+
refuseBuffered(options, classified, collection.docPath);
|
|
1176
|
+
const signal = options?.signal;
|
|
1177
|
+
const deadline = options?.deadline;
|
|
1178
|
+
|
|
1179
|
+
if (entry.planned.wrapped === true) {
|
|
1180
|
+
// a chain's element window is ONE item — the array — whatever
|
|
1181
|
+
// the plan mode; the cursor hands it over as `execute` answers it
|
|
1182
|
+
return createCursor({ ...classified, signal, deadline, now: state.now, wrap: driverWrap,
|
|
1183
|
+
materialize: () => chain(execute(document, options), (value) => [value]) });
|
|
1184
|
+
}
|
|
1185
|
+
const diverted = mustDivert(entry, externals);
|
|
1186
|
+
if (diverted || entry.planned.mode === 'set' || entry.planned.mode === 'knn') {
|
|
1187
|
+
// the barrier: materialize candidates, pack the result items
|
|
1188
|
+
return createCursor({ ...classified, signal, deadline, now: state.now, wrap: driverWrap,
|
|
1189
|
+
materialize: () => chain(guardScan(entry), () =>
|
|
1190
|
+
chain(candidatesOf(entry, externals, diverted), (docs) => {
|
|
1191
|
+
const items = packedResidualOf(entry, document)(docs, externals);
|
|
1192
|
+
countSeries(entry, diverted ? 1 : statementCost(entry), docs.length, items.length);
|
|
1193
|
+
return items;
|
|
1194
|
+
})) });
|
|
1195
|
+
}
|
|
1196
|
+
if (entry.plan.group !== null) {
|
|
1197
|
+
// a native grouping is a barrier: the groups are the answer
|
|
1198
|
+
return createCursor({ ...classified, signal, deadline, now: state.now, wrap: driverWrap,
|
|
1199
|
+
materialize: () => chain(guardScan(entry), () => chain(statementOf(entry), (statement) =>
|
|
1200
|
+
chain(runAll(entry, externals, statement), (rows) =>
|
|
1201
|
+
groupItems(entry, checkRowBound(entry, rows))))) });
|
|
1202
|
+
}
|
|
1203
|
+
if (entry.plan.bucket !== null) {
|
|
1204
|
+
// a native bucket is a barrier: the groups are the answer
|
|
1205
|
+
return createCursor({ ...classified, signal, deadline, now: state.now, wrap: driverWrap,
|
|
1206
|
+
materialize: () => chain(guardScan(entry), () => chain(statementOf(entry), (statement) =>
|
|
1207
|
+
chain(runAll(entry, externals, statement), (rows) => {
|
|
1208
|
+
const items = bucketItems(entry, checkRowBound(entry, rows));
|
|
1209
|
+
if (items === null) {
|
|
1210
|
+
return chain(divertBucket(entry, document, externals), (value) =>
|
|
1211
|
+
(value === undefined ? [] : Array.isArray(value) ? value : [value]));
|
|
1212
|
+
}
|
|
1213
|
+
countSeries(entry, statementCost(entry), rows.length, items.length);
|
|
1214
|
+
return items;
|
|
1215
|
+
}))) });
|
|
1216
|
+
}
|
|
1217
|
+
if (entry.plan.aggregate !== null) {
|
|
1218
|
+
// a native aggregate yields exactly one item; an int64 overflow
|
|
1219
|
+
// answers the engine's item instead
|
|
1220
|
+
return createCursor({ ...classified, signal, deadline, now: state.now, wrap: driverWrap,
|
|
1221
|
+
materialize: () => chain(guardScan(entry), () => chain(statementOf(entry), (statement) =>
|
|
1222
|
+
recoverOverflow(() => chain(runGet(entry, externals, statement), (row) => {
|
|
1223
|
+
const value = aggregateResult(entry, row);
|
|
1224
|
+
countSeries(entry, statementCost(entry), null, value === undefined ? 0 : 1);
|
|
1225
|
+
return value === undefined ? [] : [value];
|
|
1226
|
+
}), () => chain(overflowResidual(entry, externals, document), (answer) =>
|
|
1227
|
+
(answer === undefined ? [] : Array.isArray(answer) ? answer : [answer]))))) });
|
|
1228
|
+
}
|
|
651
1229
|
let pulledRows = 0;
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
const pull = () => {
|
|
664
|
-
if (done) return Promise.resolve({ done: true, value: undefined });
|
|
665
|
-
if (bufferedAt < buffered.length) return Promise.resolve(nextFromBuffer());
|
|
666
|
-
|
|
667
|
-
if (entry.planned.mode === 'set' || entry.planned.mode === 'knn'
|
|
668
|
-
|| mustDivert(entry, externals)) {
|
|
669
|
-
// the barrier: materialize candidates, pack the result items
|
|
670
|
-
if (materialized === null) {
|
|
671
|
-
const diverted = mustDivert(entry, externals);
|
|
672
|
-
materialized = Promise.resolve(chain(guardScan(entry), () =>
|
|
673
|
-
chain(candidatesOf(entry, externals, diverted), (docs) => {
|
|
674
|
-
buffered = packedResidualOf(entry, document)(docs, externals);
|
|
675
|
-
bufferedAt = 0;
|
|
676
|
-
countSeries(entry, 1, docs.length, buffered.length);
|
|
677
|
-
})));
|
|
678
|
-
}
|
|
679
|
-
return materialized.then(() => {
|
|
680
|
-
if (bufferedAt < buffered.length) return nextFromBuffer();
|
|
681
|
-
done = true;
|
|
682
|
-
return { done: true, value: undefined };
|
|
683
|
-
});
|
|
684
|
-
}
|
|
685
|
-
if (entry.plan.bucket !== null) {
|
|
686
|
-
// a native bucket is a barrier: the groups are the answer
|
|
687
|
-
if (materialized === null) {
|
|
688
|
-
materialized = Promise.resolve(chain(guardScan(entry), () =>
|
|
689
|
-
chain(statementOf(entry), (statement) =>
|
|
690
|
-
chain(statement.all(bindParams(entry, externals)), (rows) => {
|
|
691
|
-
const items = bucketItems(entry, checkRowBound(entry, rows));
|
|
692
|
-
if (items === null) {
|
|
693
|
-
const answer = divertBucket(entry, document, externals);
|
|
694
|
-
return chain(answer, (value) => {
|
|
695
|
-
buffered = value === undefined ? []
|
|
696
|
-
: Array.isArray(value) ? value : [value];
|
|
697
|
-
bufferedAt = 0;
|
|
698
|
-
});
|
|
699
|
-
}
|
|
700
|
-
countSeries(entry, 1, rows.length, items.length);
|
|
701
|
-
buffered = items;
|
|
702
|
-
bufferedAt = 0;
|
|
703
|
-
return null;
|
|
704
|
-
}))));
|
|
705
|
-
}
|
|
706
|
-
return materialized.then(() => {
|
|
707
|
-
if (bufferedAt < buffered.length) return nextFromBuffer();
|
|
708
|
-
done = true;
|
|
709
|
-
return { done: true, value: undefined };
|
|
710
|
-
});
|
|
711
|
-
}
|
|
712
|
-
if (entry.plan.aggregate !== null) {
|
|
713
|
-
// a native aggregate yields exactly one item
|
|
714
|
-
if (materialized === null) {
|
|
715
|
-
materialized = Promise.resolve(chain(guardScan(entry), () =>
|
|
716
|
-
chain(statementOf(entry), (statement) =>
|
|
717
|
-
chain(statement.get(bindParams(entry, externals)), (row) => {
|
|
718
|
-
const value = aggregateResult(entry, row);
|
|
719
|
-
buffered = value === undefined ? [] : [value];
|
|
720
|
-
bufferedAt = 0;
|
|
721
|
-
}))));
|
|
722
|
-
}
|
|
723
|
-
return materialized.then(() => {
|
|
724
|
-
if (bufferedAt < buffered.length) return nextFromBuffer();
|
|
725
|
-
done = true;
|
|
726
|
-
return { done: true, value: undefined };
|
|
727
|
-
});
|
|
728
|
-
}
|
|
729
|
-
|
|
730
|
-
return Promise.resolve(chain(underlying === null
|
|
731
|
-
? chain(guardScan(entry), () => chain(statementOf(entry),
|
|
732
|
-
(statement) => { underlying = statement.iterate(bindParams(entry, externals)); return underlying; }))
|
|
733
|
-
: underlying, (iterator) => chain(iterator.next(), (step) => {
|
|
734
|
-
if (step.done === true) {
|
|
735
|
-
done = true;
|
|
736
|
-
return { done: true, value: undefined };
|
|
737
|
-
}
|
|
1230
|
+
const tally = seriesTally(entry);
|
|
1231
|
+
// a cursor iterates a statement of its OWN: two cursors over one
|
|
1232
|
+
// cached statement invalidate each other's iterator at the driver
|
|
1233
|
+
return createCursor({ ...classified, signal, deadline, now: state.now, wrap: driverWrap,
|
|
1234
|
+
// the bind may have to READ first (a seek's anchor), so it settles
|
|
1235
|
+
// before the statement it binds is prepared
|
|
1236
|
+
open: () => chain(guardScan(entry), () => chain(bindParams(entry, externals),
|
|
1237
|
+
(params) => chain(connection.prepare(entry.sql, { readOnly: true, ephemeral: true }),
|
|
1238
|
+
(statement) => statement.iterate(params)))),
|
|
1239
|
+
items: (row) => {
|
|
738
1240
|
pulledRows++;
|
|
739
1241
|
if (entry.rowBound !== null && pulledRows > entry.rowBound) {
|
|
740
|
-
done = true;
|
|
741
|
-
if (typeof iterator.return === 'function') iterator.return(undefined);
|
|
742
1242
|
throw new DbRuntimeError('JD2007',
|
|
743
1243
|
`the fetch crossed the profile's maxRows bound of ${entry.rowBound}`,
|
|
744
1244
|
{ docPath: collection.docPath, collection: collection.name });
|
|
745
1245
|
}
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
underlying.return(undefined);
|
|
758
|
-
return Promise.resolve({ done: true, value: undefined });
|
|
759
|
-
};
|
|
760
|
-
|
|
761
|
-
return {
|
|
762
|
-
next: () => pull(),
|
|
763
|
-
return: () => close(),
|
|
764
|
-
[Symbol.asyncIterator]() { return this; },
|
|
765
|
-
};
|
|
1246
|
+
checkByteBound(entry, row);
|
|
1247
|
+
const items = entry.plan.project === 'document'
|
|
1248
|
+
? (entry.rowResidual === null ? [JSON.parse(row.doc)]
|
|
1249
|
+
: entry.rowResidual(JSON.parse(row.doc), externals))
|
|
1250
|
+
: 'path' in entry.plan.project
|
|
1251
|
+
? projectedItems(row)
|
|
1252
|
+
: projectedTreeItems(entry.plan.project, row);
|
|
1253
|
+
tally?.row(items.length);
|
|
1254
|
+
return items;
|
|
1255
|
+
},
|
|
1256
|
+
onSettle: tally === null ? undefined : tally.settle });
|
|
766
1257
|
};
|
|
767
1258
|
|
|
768
1259
|
/**
|
|
@@ -776,8 +1267,19 @@ export function createQueryEngine(context) {
|
|
|
776
1267
|
* @param {{ externals?: any, strict?: boolean }} [options]
|
|
777
1268
|
*/
|
|
778
1269
|
const explain = (document, options = undefined) => {
|
|
779
|
-
const { externals, strict, profile, pushdown } = callState(options);
|
|
1270
|
+
const { externals, strict, profile, profileSource, pushdown } = callState(options);
|
|
780
1271
|
const entry = entryFor(document, strict, profile, pushdown);
|
|
1272
|
+
// the run this call would make: bound against the externals it was
|
|
1273
|
+
// given, a diversion reads the whole collection through the
|
|
1274
|
+
// diversion statement and answers in the set residual — so that is
|
|
1275
|
+
// the mode, the SQL and the barrier reported, not the native plan's
|
|
1276
|
+
// no externals at all is the abstract plan, as it always was; given
|
|
1277
|
+
// externals — even a partial set — are bound as execute would bind them
|
|
1278
|
+
const bound = options?.externals !== undefined;
|
|
1279
|
+
const classified = cursorClass(entry, bound ? externals : null);
|
|
1280
|
+
const diverted = bound && mustDivert(entry, externals);
|
|
1281
|
+
const chosen = diverted ? fullScanEmitted(entry) : entry;
|
|
1282
|
+
const mode = diverted ? 'set' : entry.planned.mode;
|
|
781
1283
|
|
|
782
1284
|
const touchedColumns = new Set();
|
|
783
1285
|
// an R*Tree probe touches no generated column at all — the index it
|
|
@@ -806,6 +1308,14 @@ export function createQueryEngine(context) {
|
|
|
806
1308
|
if (aggregate.ref?.column) touchedColumns.add(aggregate.ref.column);
|
|
807
1309
|
}
|
|
808
1310
|
}
|
|
1311
|
+
if (entry.plan.group !== null) {
|
|
1312
|
+
for (const key of entry.plan.group.keys) {
|
|
1313
|
+
if (key.ref.column) touchedColumns.add(key.ref.column);
|
|
1314
|
+
}
|
|
1315
|
+
for (const aggregate of entry.plan.group.aggregates) {
|
|
1316
|
+
if (aggregate.ref?.column) touchedColumns.add(aggregate.ref.column);
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
809
1319
|
const indexes = [
|
|
810
1320
|
...physicalPlan.expected.indexes
|
|
811
1321
|
.filter((index) => index.columns.some((column) => touchedColumns.has(column)))
|
|
@@ -813,26 +1323,77 @@ export function createQueryEngine(context) {
|
|
|
813
1323
|
...[...touchedVirtual].sort(),
|
|
814
1324
|
];
|
|
815
1325
|
|
|
816
|
-
const params =
|
|
1326
|
+
const params = chosen.slots.map((slot) => {
|
|
817
1327
|
if ('external' in slot) return { external: slot.external };
|
|
818
1328
|
if ('derived' in slot) return { derived: { ...slot.derived } };
|
|
1329
|
+
if ('typed' in slot) return { typed: { ...slot.typed } };
|
|
819
1330
|
return { literal: slot.literal };
|
|
820
1331
|
});
|
|
821
|
-
|
|
1332
|
+
// EXPLAIN reads the statement's SHAPE: a seek's anchor is a value
|
|
1333
|
+
// the database would answer for a run, and explaining a plan runs
|
|
1334
|
+
// nothing, so the slot it fills is described and left unbound
|
|
1335
|
+
const eqpParams = chosen.slots.map((slot) => {
|
|
1336
|
+
if ('typed' in slot) return null;
|
|
822
1337
|
const value = slotValue(slot, externals);
|
|
823
1338
|
return bindable(value) ? value : null;
|
|
824
1339
|
});
|
|
1340
|
+
// a diversion is one more reason the engine answers, appended after
|
|
1341
|
+
// the plan's own: a residual stays a residual for its own reason,
|
|
1342
|
+
// and a native plan's only reason is the value that would not bind
|
|
1343
|
+
const reasons = diverted
|
|
1344
|
+
? [...entry.planned.reasons, { construct: 'external',
|
|
1345
|
+
reason: BIND_REASONS.external(divertingExternal(entry, externals), 'collection') }]
|
|
1346
|
+
: entry.planned.reasons;
|
|
825
1347
|
|
|
826
1348
|
const rank = entry.plan.rank;
|
|
827
|
-
return chain(connection.prepare(dialect.explainQuery(
|
|
1349
|
+
return chain(connection.prepare(dialect.explainQuery(chosen.sql), { readOnly: true }), (statement) =>
|
|
828
1350
|
chain(statement.all(eqpParams), (rows) => ({
|
|
829
|
-
mode
|
|
1351
|
+
mode,
|
|
1352
|
+
// what a cursor over this call does — one row per pull, or a
|
|
1353
|
+
// buffer — and the construct that forces the buffer: the same
|
|
1354
|
+
// classification the cursor itself carries, so the two agree
|
|
1355
|
+
streaming: classified.streaming,
|
|
1356
|
+
barrier: classified.barrier,
|
|
1357
|
+
// the profile that applied and every bound it imposed (D7)
|
|
1358
|
+
budget: budgetOf(profile, profileSource, connection.capabilities),
|
|
1359
|
+
// the order the STATEMENT executes under — the plan's declared
|
|
1360
|
+
// terms and the tie-breaker the emitter appends, in the same
|
|
1361
|
+
// normalized form the emitter renders (D6: read from the plan,
|
|
1362
|
+
// never parsed back out of SQL). `null` is the honest answer for
|
|
1363
|
+
// a statement that orders nothing at all
|
|
1364
|
+
order: planOrder(diverted ? entry.fullScanShape() : entry.plan),
|
|
1365
|
+
// what the statement projects: `null` when it reads the whole
|
|
1366
|
+
// document, one `path` when it projects a single member, and
|
|
1367
|
+
// `paths` — one per DISTINCT leaf, in fetch order — when it
|
|
1368
|
+
// projects a nested shape the decoder rebuilds
|
|
1369
|
+
projection: entry.plan.project === 'document' ? null
|
|
1370
|
+
: 'path' in entry.plan.project
|
|
1371
|
+
? { path: segmentsOf(entry.plan.project.path) }
|
|
1372
|
+
: { paths: entry.plan.project.leaves.map(segmentsOf) },
|
|
1373
|
+
// the projection that stayed behind, when one did: the one-row
|
|
1374
|
+
// document the row residual runs per fetched row
|
|
1375
|
+
residualProjection: entry.planned.rowReturn?.$return?.[0] ?? null,
|
|
1376
|
+
// the GROUPING the statement performs, when it performs one:
|
|
1377
|
+
// the key names with the member each reads, the aggregates with
|
|
1378
|
+
// the function and the member each folds, and how the groups
|
|
1379
|
+
// come out. `null` when nothing is grouped natively
|
|
1380
|
+
group: entry.plan.group === null ? null : {
|
|
1381
|
+
keys: entry.plan.group.keys.map((key) => ({ as: key.as, path: segmentsOf(key.ref) })),
|
|
1382
|
+
aggregates: entry.plan.group.aggregates.map((entry2) => ({
|
|
1383
|
+
fn: entry2.fn, path: entry2.ref === null ? null : segmentsOf(entry2.ref) })),
|
|
1384
|
+
order: entry.plan.group.order === 'first-seen' ? 'first-seen'
|
|
1385
|
+
: entry.plan.group.order.map((term) => ({
|
|
1386
|
+
key: entry.plan.group.keys[term.index].as, desc: term.desc })),
|
|
1387
|
+
},
|
|
1388
|
+
// a chain's element window (`[<phrase>]`): the phrase planned as
|
|
1389
|
+
// if bare, its rows answered as the one array item
|
|
1390
|
+
wrapped: entry.planned.wrapped === true,
|
|
830
1391
|
externals: [...entry.externalNames],
|
|
831
1392
|
operators: [...entry.dependencies.operators],
|
|
832
1393
|
functions: [...entry.dependencies.functions],
|
|
833
1394
|
collations: [...entry.dependencies.collations],
|
|
834
|
-
limits: entry.limits,
|
|
835
|
-
sql:
|
|
1395
|
+
limits: entry.residualLimits ?? entry.limits,
|
|
1396
|
+
sql: chosen.sql,
|
|
836
1397
|
params,
|
|
837
1398
|
indexes,
|
|
838
1399
|
prefilters: entry.planned.prefilters.map((prefilter) => ({ ...prefilter,
|
|
@@ -841,21 +1402,34 @@ export function createQueryEngine(context) {
|
|
|
841
1402
|
// reads, the window the cut serves, the margin it keeps, and
|
|
842
1403
|
// who decides the order — always the engine
|
|
843
1404
|
rank: rank === null ? null : {
|
|
844
|
-
|
|
845
|
-
|
|
1405
|
+
// every declared width the plan can bind, and — when the call
|
|
1406
|
+
// was given its externals — the one it selected. The probe is
|
|
1407
|
+
// named, never printed
|
|
1408
|
+
alternatives: entry.rankAlternatives.map((alternative) =>
|
|
1409
|
+
({ column: alternative.column, dims: alternative.dims })),
|
|
1410
|
+
selected: !bound || 'lit' in rank.probe
|
|
1411
|
+
? (('lit' in rank.probe) ? rank.alternatives[0].dims : null)
|
|
1412
|
+
: rankAlternativeFor(entry, externals[rank.probe.ext])?.dims ?? null,
|
|
846
1413
|
probe: 'lit' in rank.probe ? { literal: [...rank.probe.lit] } : { external: rank.probe.ext },
|
|
847
1414
|
limit: rank.limit,
|
|
848
1415
|
offset: rank.offset,
|
|
849
1416
|
margin: rank.margin,
|
|
850
1417
|
decides: 'engine',
|
|
851
1418
|
},
|
|
852
|
-
residual:
|
|
1419
|
+
residual: mode === 'native'
|
|
853
1420
|
? null
|
|
854
|
-
: { mode
|
|
855
|
-
barriers:
|
|
856
|
-
?
|
|
1421
|
+
: { mode, reasons },
|
|
1422
|
+
barriers: mode === 'set' || mode === 'knn'
|
|
1423
|
+
? reasons.map((r) => ({ operator: r.construct, reason: r.reason }))
|
|
857
1424
|
: [],
|
|
858
1425
|
udfs: [...entry.planned.udfs],
|
|
1426
|
+
// a pushed aggregate that overflowed int64 at run time: the
|
|
1427
|
+
// engine answered the document instead, and says so here
|
|
1428
|
+
fallback: entry.overflowRuns === undefined ? null : {
|
|
1429
|
+
construct: 'overflow',
|
|
1430
|
+
runs: entry.overflowRuns,
|
|
1431
|
+
reason: BIND_REASONS.overflow,
|
|
1432
|
+
},
|
|
859
1433
|
// the temporal record: what the document asked, which declared
|
|
860
1434
|
// index the fetch seeks through, and which kernel finished it.
|
|
861
1435
|
// The counts are the LAST ACTUAL execution's — `null` before
|
|
@@ -865,20 +1439,107 @@ export function createQueryEngine(context) {
|
|
|
865
1439
|
...entry.planned.series,
|
|
866
1440
|
counts: entry.seriesCounts === null ? null : { ...entry.seriesCounts },
|
|
867
1441
|
},
|
|
868
|
-
scanNarrative:
|
|
1442
|
+
scanNarrative: dialect.explainLines(rows).join('; '),
|
|
869
1443
|
})));
|
|
870
1444
|
};
|
|
871
1445
|
|
|
872
|
-
return { execute, query, explain, shape,
|
|
873
|
-
stats: () => ({ knn: { ...knnStats }, series: { ...seriesStats } }) };
|
|
1446
|
+
return { execute: bounded(execute, driverWrap), query, explain: bounded(explain, driverWrap), shape,
|
|
1447
|
+
stats: () => ({ knn: { ...knnStats }, series: { ...seriesStats }, bind: { ...bindStats } }) };
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
/**
|
|
1451
|
+
* `strictStreaming` (D6 applied to memory): a plan that would buffer is
|
|
1452
|
+
* declined by name before any statement runs, never run with its memory
|
|
1453
|
+
* behaviour quietly changed.
|
|
1454
|
+
* @param {any} options
|
|
1455
|
+
* @param {{ streaming: string, barrier: import('./cursor.js').CursorBarrier | null }} classified
|
|
1456
|
+
* @param {string | undefined} docPath
|
|
1457
|
+
*/
|
|
1458
|
+
function refuseBuffered(options, classified, docPath) {
|
|
1459
|
+
if (options?.strictStreaming !== true || classified.streaming !== 'buffered') return;
|
|
1460
|
+
const barrier = classified.barrier;
|
|
1461
|
+
throw new DbCompileError('JD0037',
|
|
1462
|
+
`strictStreaming refused a plan that buffers: '${barrier?.construct}' — ${barrier?.reason}`,
|
|
1463
|
+
docPath);
|
|
874
1464
|
}
|
|
875
1465
|
|
|
876
1466
|
// ————— The entity query surface (the second document kind) —————
|
|
877
1467
|
|
|
878
1468
|
import { mergeEntityRow, parseGraphRow } from './graph.js';
|
|
1469
|
+
import { relationTables, joinTableRoots } from './model.js';
|
|
879
1470
|
|
|
880
1471
|
/** The default include depth bound (D14: printed, never silent). */
|
|
881
1472
|
export const INCLUDE_DEPTH_DEFAULT = 3;
|
|
1473
|
+
/** The default per-root bounds of an included to-many relation
|
|
1474
|
+
* (MODEL-FORMAT §10.4): rows per parent, and serialised bytes per
|
|
1475
|
+
* parent. A bound always exists — one root that aggregates an unbounded
|
|
1476
|
+
* relation is not a bounded item — and the unbounded case is spelled
|
|
1477
|
+
* (`maxRows: Infinity`), never inherited. An explicit `take` is the row
|
|
1478
|
+
* bound of the include it windows. */
|
|
1479
|
+
export const INCLUDE_ROWS_DEFAULT = 1000;
|
|
1480
|
+
export const INCLUDE_BYTES_DEFAULT = 1_048_576;
|
|
1481
|
+
|
|
1482
|
+
/**
|
|
1483
|
+
* Plan one `where` EXPRESSION over `$it` against one entity: the plan
|
|
1484
|
+
* predicate when every conjunct translates, else the first refusal —
|
|
1485
|
+
* the one translation the include tree, the profile's mandatory
|
|
1486
|
+
* predicates and the entity residual's narrowing all share.
|
|
1487
|
+
* @param {any} expression
|
|
1488
|
+
* @param {any} entity - the normalized entity
|
|
1489
|
+
* @param {any} entityMapping - `explainMapping(...).entities[name]`
|
|
1490
|
+
* @param {any} analyzeOpts
|
|
1491
|
+
* @returns {{ filter: any } | { refusal: { construct: string, reason: string } } | { error: Error }}
|
|
1492
|
+
*/
|
|
1493
|
+
function planEntityWhere(expression, entity, entityMapping, analyzeOpts) {
|
|
1494
|
+
const wrapper = { $for: { it: '$[*]' }, $where: expression, $return: '$it' };
|
|
1495
|
+
let analysis;
|
|
1496
|
+
try {
|
|
1497
|
+
analysis = analyzeQuery(wrapper, analyzeOpts);
|
|
1498
|
+
}
|
|
1499
|
+
catch (cause) {
|
|
1500
|
+
return { error: /** @type {Error} */ (cause) };
|
|
1501
|
+
}
|
|
1502
|
+
const flwor = analysis.root;
|
|
1503
|
+
const slot = flwor.forBindings[0].slot;
|
|
1504
|
+
const shape = entityShape(entity, entityMapping);
|
|
1505
|
+
const conjuncts = flwor.where.kind === 'op' && flwor.where.name === '$and'
|
|
1506
|
+
? flwor.where.args : [flwor.where];
|
|
1507
|
+
let filter = null;
|
|
1508
|
+
for (const conjunct of conjuncts) {
|
|
1509
|
+
const outcome = planEntityPredicate(conjunct, slot, shape);
|
|
1510
|
+
if ('refusal' in outcome) return { refusal: outcome.refusal };
|
|
1511
|
+
filter = conjoin(filter, outcome.pred);
|
|
1512
|
+
}
|
|
1513
|
+
return { filter };
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
/**
|
|
1517
|
+
* The profile's mandatory predicate for one entity, translated — a
|
|
1518
|
+
* host-configured predicate that cannot translate is a host programming
|
|
1519
|
+
* error (`TypeError`), exactly as on a collection: there is no residual
|
|
1520
|
+
* to hide it in, because the point is that it binds the fetch.
|
|
1521
|
+
* @param {any} profile
|
|
1522
|
+
* @param {any} entity
|
|
1523
|
+
* @param {any} entityMapping
|
|
1524
|
+
* @param {any} analyzeOpts
|
|
1525
|
+
* @returns {any} a plan predicate, or null
|
|
1526
|
+
*/
|
|
1527
|
+
function mandatoryEntityPredicate(profile, entity, entityMapping, analyzeOpts) {
|
|
1528
|
+
const expression = profile?.predicates?.[entity.name];
|
|
1529
|
+
if (expression === undefined) return null;
|
|
1530
|
+
const planned = planEntityWhere(expression, entity, entityMapping, analyzeOpts);
|
|
1531
|
+
if ('filter' in planned && planned.filter !== null) return planned.filter;
|
|
1532
|
+
throw new TypeError(`a profile predicate must translate natively (it binds the database-side fetch of '${
|
|
1533
|
+
entity.name}'); this one refused: ${'refusal' in planned ? planned.refusal.reason
|
|
1534
|
+
: 'error' in planned ? planned.error.message : 'it selects nothing'}`);
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
/**
|
|
1538
|
+
* The profile's compile-time refusal for an entity document (`JD0011`).
|
|
1539
|
+
* @param {string} reason
|
|
1540
|
+
* @param {string | undefined} docPath
|
|
1541
|
+
*/
|
|
1542
|
+
const profileEntityRefusal = (reason, docPath) => new DbCompileError('JD0011', reason, docPath);
|
|
882
1543
|
|
|
883
1544
|
/**
|
|
884
1545
|
* The store-level entity query engine: documents over the
|
|
@@ -891,15 +1552,55 @@ export const INCLUDE_DEPTH_DEFAULT = 3;
|
|
|
891
1552
|
* @returns {any}
|
|
892
1553
|
*/
|
|
893
1554
|
export function createEntityQueryEngine(context) {
|
|
894
|
-
const { connection,
|
|
1555
|
+
const { connection, state } = context;
|
|
1556
|
+
// the query roots: the model's entities, plus the read-only
|
|
1557
|
+
// pseudo-entity each declared join table contributes (§10.7). They
|
|
1558
|
+
// are visible to the PLANNER and to `$.<Name>[*]`, and to nothing
|
|
1559
|
+
// that writes — `store.entity(name)` reads the model's own map
|
|
1560
|
+
const joinRoots = joinTableRoots(context.entities, context.mapping);
|
|
1561
|
+
const entities = joinRoots.entities.size === 0
|
|
1562
|
+
? context.entities
|
|
1563
|
+
: new Map([...context.entities, ...joinRoots.entities]);
|
|
1564
|
+
const mapping = joinRoots.entities.size === 0
|
|
1565
|
+
? context.mapping
|
|
1566
|
+
: { ...context.mapping,
|
|
1567
|
+
entities: { ...context.mapping.entities, ...joinRoots.mappings } };
|
|
1568
|
+
const storeProfile = context.profile ?? null;
|
|
1569
|
+
const roots = context.roots ?? [...entities.keys()];
|
|
895
1570
|
const operators = state.operators ?? null;
|
|
1571
|
+
const analyzeOpts = operators ?? undefined;
|
|
896
1572
|
const zoneProvider = state.zoneProvider ?? null;
|
|
1573
|
+
/** Every driver failure this engine meets, classified under the
|
|
1574
|
+
* entity root. */
|
|
1575
|
+
const driverWrap = (/** @type {any} */ error) => wrapDriverError(error, { docPath: '/entities' });
|
|
897
1576
|
const dialect = connection.dialect;
|
|
898
1577
|
const q = dialect.quoteIdentifier;
|
|
899
|
-
const physicalOf = (name) => ({ table: mapping.entities[name].table
|
|
1578
|
+
const physicalOf = (name) => ({ table: mapping.entities[name].table,
|
|
1579
|
+
// a join-table root has no document column of its own (§10.7)
|
|
1580
|
+
document: mapping.entities[name].document !== false });
|
|
1581
|
+
// the relation tables of every root this engine serves (§10.1): the
|
|
1582
|
+
// engine is the scope every entity set of the store shares, so a
|
|
1583
|
+
// producer holding one set can follow a hop into another root
|
|
1584
|
+
const relations = relationTables(entities);
|
|
1585
|
+
|
|
1586
|
+
/** The switches one call resolves: the profile per call replaces the
|
|
1587
|
+
* store's, normalized over the safe defaults, as on a collection. */
|
|
1588
|
+
const callState = (options) => {
|
|
1589
|
+
const profile = options?.profile !== undefined
|
|
1590
|
+
? normalizeProfile(options.profile) : storeProfile;
|
|
1591
|
+
assertProfileRoots(profile, roots, '/entities');
|
|
1592
|
+
return {
|
|
1593
|
+
externals: options?.externals ?? {},
|
|
1594
|
+
strict: options?.strict === true,
|
|
1595
|
+
pushdown: options?.pushdown !== false,
|
|
1596
|
+
profile,
|
|
1597
|
+
profileSource: options?.profile !== undefined ? 'call'
|
|
1598
|
+
: (storeProfile === null ? null : 'store'),
|
|
1599
|
+
};
|
|
1600
|
+
};
|
|
900
1601
|
|
|
901
|
-
const entryFor = (document, pushdown) => {
|
|
902
|
-
const key = ['E', document, dialect.name, pushdown];
|
|
1602
|
+
const entryFor = (document, pushdown, profile = null) => {
|
|
1603
|
+
const key = ['E', document, dialect.name, pushdown, profile];
|
|
903
1604
|
const cached = state.cache.get(key);
|
|
904
1605
|
if (cached !== undefined) {
|
|
905
1606
|
state.counters.hits++;
|
|
@@ -907,9 +1608,61 @@ export function createEntityQueryEngine(context) {
|
|
|
907
1608
|
}
|
|
908
1609
|
state.counters.misses++;
|
|
909
1610
|
let planned = planEntityQuery(document, entities, mapping, operators);
|
|
1611
|
+
if (planned.referenced.length === 0) {
|
|
1612
|
+
// `$[*]` over the entity MAP answered the rows of every entity,
|
|
1613
|
+
// mixed, and explain() named no table read; the root is the map
|
|
1614
|
+
// of entity arrays, and a query ranges over one of them by name
|
|
1615
|
+
throw new DbCompileError('JD0033',
|
|
1616
|
+
'an entity query ranges over a declared entity array ($.<Entity>[*]); this '
|
|
1617
|
+
+ 'document names none, so it has no rows to answer', '/entities');
|
|
1618
|
+
}
|
|
910
1619
|
if (!pushdown) {
|
|
911
1620
|
planned = { ...planned, mode: 'set', plan: null,
|
|
912
|
-
reasons: [{ construct: 'pushdown', reason:
|
|
1621
|
+
reasons: [{ construct: 'pushdown', reason: BIND_REASONS.pushdown }] };
|
|
1622
|
+
}
|
|
1623
|
+
const docPath = entities.get(planned.referenced[0])?.docPath;
|
|
1624
|
+
// the profile, applied exactly as on a collection (MODEL-FORMAT §8):
|
|
1625
|
+
// the roots a document may read, the references it may make, the
|
|
1626
|
+
// predicate every fetch of a root must wear, the row bound every
|
|
1627
|
+
// fetch carries, and the shape refusal of a whole-root residual
|
|
1628
|
+
if (profile !== null) {
|
|
1629
|
+
for (const name of planned.referenced) {
|
|
1630
|
+
if (profile.collections !== null && !profile.collections.includes(name))
|
|
1631
|
+
throw profileEntityRefusal(`the profile does not allow querying entity '${name}'`, docPath);
|
|
1632
|
+
// the member allow-list, per referenced root: every member path
|
|
1633
|
+
// the document reads on a binding over that entity's array
|
|
1634
|
+
const denied = memberDenial(profile, name,
|
|
1635
|
+
planned.analysis.root, isEntityRootSource(name));
|
|
1636
|
+
if (denied !== null) throw profileEntityRefusal(denied, entities.get(name)?.docPath);
|
|
1637
|
+
}
|
|
1638
|
+
const deps = planned.analysis.dependencies;
|
|
1639
|
+
for (const name of deps.functions) {
|
|
1640
|
+
if (!profile.functions.includes(name))
|
|
1641
|
+
throw profileEntityRefusal(`the profile does not allow the host function '${name}'`, docPath);
|
|
1642
|
+
}
|
|
1643
|
+
for (const name of deps.collations) {
|
|
1644
|
+
if (!profile.collations.includes(name))
|
|
1645
|
+
throw profileEntityRefusal(`the profile does not allow the collation '${name}'`, docPath);
|
|
1646
|
+
}
|
|
1647
|
+
for (const external of planned.analysis.externals) {
|
|
1648
|
+
if (!profile.externals.includes(external.name))
|
|
1649
|
+
throw profileEntityRefusal(`the profile does not declare the external '${external.name}'`, docPath);
|
|
1650
|
+
}
|
|
1651
|
+
if (planned.mode !== 'native' && profile.refuseFullScan === true) {
|
|
1652
|
+
// the residual reads every row of every referenced root before
|
|
1653
|
+
// the engine decides — a full-table scan by shape, refused at
|
|
1654
|
+
// preflight rather than estimated (D6)
|
|
1655
|
+
throw profileEntityRefusal('the profile refuses a full-table scan, and the residual this '
|
|
1656
|
+
+ `document needs fetches every row of ${planned.referenced.join(', ')} `
|
|
1657
|
+
+ `('${planned.reasons[0]?.construct}' — ${planned.reasons[0]?.reason})`, docPath);
|
|
1658
|
+
}
|
|
1659
|
+
}
|
|
1660
|
+
const mandatory = new Map();
|
|
1661
|
+
if (profile !== null) {
|
|
1662
|
+
for (const name of planned.referenced) {
|
|
1663
|
+
const predicate = mandatoryEntityPredicate(profile, entities.get(name), mapping.entities[name], analyzeOpts);
|
|
1664
|
+
if (predicate !== null) mandatory.set(name, predicate);
|
|
1665
|
+
}
|
|
913
1666
|
}
|
|
914
1667
|
const entry = {
|
|
915
1668
|
planned,
|
|
@@ -917,10 +1670,30 @@ export function createEntityQueryEngine(context) {
|
|
|
917
1670
|
slots: null,
|
|
918
1671
|
statement: null,
|
|
919
1672
|
setResidual: null,
|
|
1673
|
+
packedResidual: null,
|
|
920
1674
|
fetchers: null,
|
|
1675
|
+
mandatory,
|
|
1676
|
+
rowBound: profile === null ? null : profile.maxRows,
|
|
1677
|
+
byteBound: profile === null ? null : profile.maxBytes,
|
|
1678
|
+
residualLimits: profile === null ? undefined : profile.limits,
|
|
1679
|
+
needsScanCheck: profile !== null && profile.refuseFullScan === true,
|
|
1680
|
+
scanChecked: false,
|
|
921
1681
|
};
|
|
922
1682
|
if (planned.mode === 'native') {
|
|
923
|
-
|
|
1683
|
+
let plan = planned.plan;
|
|
1684
|
+
if (mandatory.size > 0) {
|
|
1685
|
+
plan = { ...plan, filters: plan.filters.map((entry) => {
|
|
1686
|
+
const binding = plan.bindings.find((candidate) => candidate.name === entry.binding);
|
|
1687
|
+
const predicate = mandatory.get(binding.entity);
|
|
1688
|
+
return predicate === undefined ? entry : { ...entry, filter: conjoin(entry.filter, predicate) };
|
|
1689
|
+
}) };
|
|
1690
|
+
}
|
|
1691
|
+
if (entry.rowBound !== null && plan.aggregate === null) {
|
|
1692
|
+
const cap = entry.rowBound + 1;
|
|
1693
|
+
plan = { ...plan, window: plan.window === null ? { offset: 0, limit: cap }
|
|
1694
|
+
: { offset: plan.window.offset, limit: plan.window.limit === null ? cap : Math.min(plan.window.limit, cap) } };
|
|
1695
|
+
}
|
|
1696
|
+
const emitted = emitEntityPlan(plan, dialect, physicalOf);
|
|
924
1697
|
entry.sql = emitted.sql;
|
|
925
1698
|
entry.slots = emitted.slots;
|
|
926
1699
|
}
|
|
@@ -930,27 +1703,90 @@ export function createEntityQueryEngine(context) {
|
|
|
930
1703
|
return entry;
|
|
931
1704
|
};
|
|
932
1705
|
|
|
933
|
-
/**
|
|
1706
|
+
/** Refuse a native fetch that crossed the profile's row bound (JD2007). */
|
|
1707
|
+
const checkRows = (entry, rows, name) => {
|
|
1708
|
+
if (entry.rowBound !== null && rows.length > entry.rowBound) {
|
|
1709
|
+
throw new DbRuntimeError('JD2007',
|
|
1710
|
+
`the fetch crossed the profile's maxRows bound of ${entry.rowBound}`,
|
|
1711
|
+
{ docPath: entities.get(name)?.docPath, collection: name });
|
|
1712
|
+
}
|
|
1713
|
+
return rows;
|
|
1714
|
+
};
|
|
1715
|
+
/** One merged entity document against the profile's byte bound
|
|
1716
|
+
* (JD2076): measured after the merge, because the mapped scalars live
|
|
1717
|
+
* in columns and the row's own JSON text holds only the rest. */
|
|
1718
|
+
const checkBytes = (entry, doc, name) => {
|
|
1719
|
+
if (entry.byteBound === null) return doc;
|
|
1720
|
+
const bytes = utf8Length(JSON.stringify(doc));
|
|
1721
|
+
if (bytes > entry.byteBound) {
|
|
1722
|
+
throw new DbRuntimeError('JD2076',
|
|
1723
|
+
`an item of ${bytes} serialised bytes exceeds the profile's maxBytes bound of ${entry.byteBound}`,
|
|
1724
|
+
{ docPath: entities.get(name)?.docPath, collection: name });
|
|
1725
|
+
}
|
|
1726
|
+
return doc;
|
|
1727
|
+
};
|
|
1728
|
+
/** The plan-shape refusal on a native plan: a full-table SCAN of any
|
|
1729
|
+
* referenced root, verified against the database's own plan output. */
|
|
1730
|
+
const guardEntityScan = (entry) => {
|
|
1731
|
+
if (!entry.needsScanCheck || entry.scanChecked) return null;
|
|
1732
|
+
const eqpParams = entry.slots.map((slot) => ('literal' in slot ? slot.literal : null));
|
|
1733
|
+
return chain(connection.prepare(dialect.explainQuery(entry.sql), { readOnly: true }), (statement) =>
|
|
1734
|
+
chain(statement.all(eqpParams), (rows) => {
|
|
1735
|
+
// the entity statement aliases its tables `t0`, `t1`, … and the
|
|
1736
|
+
// database's narrative names the alias; a bare table name is
|
|
1737
|
+
// the residual fetcher's spelling
|
|
1738
|
+
const tables = entry.planned.referenced.map((name) => mapping.entities[name].table);
|
|
1739
|
+
const lines = dialect.explainLines(rows);
|
|
1740
|
+
if (lines.some((line) => dialect.isFullScan(line, tables))) {
|
|
1741
|
+
throw profileEntityRefusal('the profile refuses a full-table scan of '
|
|
1742
|
+
+ `${entry.planned.referenced.join(', ')} (${lines.join('; ')})`,
|
|
1743
|
+
entities.get(entry.planned.referenced[0])?.docPath);
|
|
1744
|
+
}
|
|
1745
|
+
entry.scanChecked = true;
|
|
1746
|
+
return null;
|
|
1747
|
+
}));
|
|
1748
|
+
};
|
|
1749
|
+
|
|
1750
|
+
/** Fetch every referenced entity's rows and build the in-memory
|
|
1751
|
+
* root — each fetch wearing the profile's mandatory predicate for its
|
|
1752
|
+
* entity and its row bound (`LIMIT maxRows + 1`, refused when crossed),
|
|
1753
|
+
* so a residual's input is as bounded as a native answer. */
|
|
934
1754
|
const fetchRoot = (entry) => {
|
|
935
1755
|
if (entry.fetchers === null) {
|
|
936
1756
|
entry.fetchers = [...(entry.planned.referenced.length === 0
|
|
937
|
-
? entities.keys() : entry.planned.referenced)].map((name) =>
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
1757
|
+
? entities.keys() : entry.planned.referenced)].map((name) => {
|
|
1758
|
+
/** @type {any[]} */
|
|
1759
|
+
const slots = [];
|
|
1760
|
+
const emitters = createEntityPredicateEmitters(dialect, (slot) => {
|
|
1761
|
+
slots.push(slot);
|
|
1762
|
+
return dialect.parameterRef(slots.length, 'v');
|
|
1763
|
+
});
|
|
1764
|
+
const predicate = entry.mandatory.get(name);
|
|
1765
|
+
const where = predicate === undefined ? ''
|
|
1766
|
+
: ` WHERE ${emitters.emitPred(q('t'), `${q('t')}.${q('doc')}`, predicate)}`;
|
|
1767
|
+
const limit = entry.rowBound === null ? '' : ` ${dialect.limitClause(entry.rowBound + 1, undefined)}`;
|
|
1768
|
+
return {
|
|
1769
|
+
name,
|
|
1770
|
+
sql: `SELECT ${q('t')}.*, ${mapping.entities[name].document === false
|
|
1771
|
+
? dialect.stringLiteral('{}')
|
|
1772
|
+
: dialect.jsonText(`${q('t')}.${q('doc')}`)} AS ${q('__doc')} `
|
|
1773
|
+
+ `FROM ${q(mapping.entities[name].table)} AS ${q('t')}${where} `
|
|
1774
|
+
+ `ORDER BY ${q('t')}.${dialect.rowIdentity()}${limit}`,
|
|
1775
|
+
params: slots.map((slot) => slot.literal),
|
|
1776
|
+
statement: null,
|
|
1777
|
+
};
|
|
1778
|
+
});
|
|
943
1779
|
}
|
|
944
1780
|
/** @type {any} */
|
|
945
1781
|
const root = {};
|
|
946
1782
|
const next = (i) => {
|
|
947
1783
|
if (i >= entry.fetchers.length) return root;
|
|
948
1784
|
const fetcher = entry.fetchers[i];
|
|
949
|
-
if (fetcher.statement === null) fetcher.statement = connection.prepare(fetcher.sql);
|
|
1785
|
+
if (fetcher.statement === null) fetcher.statement = connection.prepare(fetcher.sql, { readOnly: true });
|
|
950
1786
|
return chain(fetcher.statement, (statement) =>
|
|
951
|
-
chain(statement.all(
|
|
952
|
-
root[fetcher.name] = rows.map((row) =>
|
|
953
|
-
mergeEntityRow(mapping.entities[fetcher.name], row, '__doc'));
|
|
1787
|
+
chain(statement.all(fetcher.params), (rows) => {
|
|
1788
|
+
root[fetcher.name] = checkRows(entry, rows, fetcher.name).map((row) =>
|
|
1789
|
+
checkBytes(entry, mergeEntityRow(mapping.entities[fetcher.name], row, '__doc'), fetcher.name));
|
|
954
1790
|
return next(i + 1);
|
|
955
1791
|
}));
|
|
956
1792
|
};
|
|
@@ -959,16 +1795,16 @@ export function createEntityQueryEngine(context) {
|
|
|
959
1795
|
|
|
960
1796
|
const runResidual = (entry, document, externals) => {
|
|
961
1797
|
if (entry.setResidual === null)
|
|
962
|
-
entry.setResidual = compileSetResidual(document,
|
|
1798
|
+
entry.setResidual = compileSetResidual(document, entry.residualLimits, operators, zoneProvider);
|
|
963
1799
|
return chain(fetchRoot(entry), (root) => entry.setResidual(root, externals));
|
|
964
1800
|
};
|
|
965
1801
|
|
|
966
1802
|
const execute = (document, options = undefined) => {
|
|
967
|
-
|
|
968
|
-
const pushdown = options
|
|
969
|
-
const entry = entryFor(document, pushdown);
|
|
1803
|
+
requireCallable(options, state.now);
|
|
1804
|
+
const { externals, strict, pushdown, profile } = callState(options);
|
|
1805
|
+
const entry = entryFor(document, pushdown, profile);
|
|
970
1806
|
if (entry.planned.mode !== 'native') {
|
|
971
|
-
if (
|
|
1807
|
+
if (strict) {
|
|
972
1808
|
const forcing = entry.planned.reasons[0];
|
|
973
1809
|
throw new DbCompileError('JD0010',
|
|
974
1810
|
`strict mode refused a residual: '${forcing.construct}' — ${forcing.reason}`);
|
|
@@ -983,42 +1819,200 @@ export function createEntityQueryEngine(context) {
|
|
|
983
1819
|
const params = entry.slots.map((slot) => slotValue(slot, externals));
|
|
984
1820
|
if (params.some((value) => !bindable(value)))
|
|
985
1821
|
return runResidual(entry, document, externals);
|
|
986
|
-
if (entry.statement === null) entry.statement = connection.prepare(entry.sql);
|
|
987
|
-
return chain(entry.statement, (statement) => {
|
|
1822
|
+
if (entry.statement === null) entry.statement = connection.prepare(entry.sql, { readOnly: true });
|
|
1823
|
+
return chain(guardEntityScan(entry), () => chain(entry.statement, (statement) => {
|
|
988
1824
|
if (entry.planned.plan.aggregate === 'count')
|
|
989
|
-
return chain(statement.get(params), (row) => row?.value ?? 0);
|
|
1825
|
+
return chain(statement.get(params), (row) => wrapValue(entry, row?.value ?? 0));
|
|
990
1826
|
return chain(statement.all(params), (rows) => {
|
|
1827
|
+
const project = entry.planned.plan.project;
|
|
1828
|
+
if (project != null) {
|
|
1829
|
+
return answerOf(entry,
|
|
1830
|
+
rows.flatMap((row) => projectedTreeItems(project, row, 'p')));
|
|
1831
|
+
}
|
|
991
1832
|
const retEntity = entry.planned.plan.bindings
|
|
992
1833
|
.find((binding) => binding.name === entry.planned.plan.ret).entity;
|
|
993
|
-
return
|
|
994
|
-
mergeEntityRow(mapping.entities[retEntity], row, '__doc')));
|
|
1834
|
+
return answerOf(entry, checkRows(entry, rows, retEntity).map((row) =>
|
|
1835
|
+
checkBytes(entry, mergeEntityRow(mapping.entities[retEntity], row, '__doc'), retEntity)));
|
|
995
1836
|
});
|
|
996
|
-
});
|
|
1837
|
+
}));
|
|
1838
|
+
};
|
|
1839
|
+
|
|
1840
|
+
/** The item-packing residual for a cursor over the fetched root. */
|
|
1841
|
+
const packedResidualOf = (entry, document) => {
|
|
1842
|
+
if (entry.packedResidual === null)
|
|
1843
|
+
entry.packedResidual = compilePackedResidual(document, entry.residualLimits, operators, zoneProvider);
|
|
1844
|
+
return entry.packedResidual;
|
|
1845
|
+
};
|
|
1846
|
+
|
|
1847
|
+
/**
|
|
1848
|
+
* The bind-time diversion, named: the first parameter slot whose
|
|
1849
|
+
* value the database cannot take — a missing external, a boolean, a
|
|
1850
|
+
* null, a region with no box — or `null` when the statement binds.
|
|
1851
|
+
* @param {any} entry
|
|
1852
|
+
* @param {any} externals
|
|
1853
|
+
* @returns {{ construct: string, reason: string } | null}
|
|
1854
|
+
*/
|
|
1855
|
+
const divertReason = (entry, externals) => {
|
|
1856
|
+
for (const slot of entry.slots) {
|
|
1857
|
+
if (bindable(slotValue(slot, externals))) continue;
|
|
1858
|
+
const name = 'external' in slot ? slot.external
|
|
1859
|
+
: 'derived' in slot ? slot.derived.external : null;
|
|
1860
|
+
return { construct: 'external', reason: BIND_REASONS.external(name, 'root') };
|
|
1861
|
+
}
|
|
1862
|
+
return null;
|
|
1863
|
+
};
|
|
1864
|
+
|
|
1865
|
+
/**
|
|
1866
|
+
* What a cursor over this call will do, and why — the collection
|
|
1867
|
+
* engine's classification over the entity plan shapes: a chain's
|
|
1868
|
+
* window, a set residual (its first reason names the construct), a
|
|
1869
|
+
* diverting external, else one row per pull.
|
|
1870
|
+
* @param {any} entry
|
|
1871
|
+
* @param {any} externals
|
|
1872
|
+
* @returns {{ streaming: 'row' | 'buffered',
|
|
1873
|
+
* barrier: import('./cursor.js').CursorBarrier | null }}
|
|
1874
|
+
*/
|
|
1875
|
+
const cursorClass = (entry, externals) => {
|
|
1876
|
+
const buffered = (barrier) => ({ streaming: 'buffered', barrier });
|
|
1877
|
+
if (entry.planned.wrapped === true) {
|
|
1878
|
+
return buffered({ construct: 'window', reason: BIND_REASONS.wrappedWindow });
|
|
1879
|
+
}
|
|
1880
|
+
if (entry.planned.mode !== 'native') {
|
|
1881
|
+
const forcing = entry.planned.reasons[0]
|
|
1882
|
+
?? { construct: 'residual', reason: BIND_REASONS.untranslated };
|
|
1883
|
+
return buffered({ construct: forcing.construct, reason: forcing.reason });
|
|
1884
|
+
}
|
|
1885
|
+
// `null` externals is the abstract question: the plan as planned
|
|
1886
|
+
const diverted = externals === null ? null : divertReason(entry, externals);
|
|
1887
|
+
return diverted === null ? rowClassOf(connection) : buffered(diverted);
|
|
1888
|
+
};
|
|
1889
|
+
|
|
1890
|
+
/**
|
|
1891
|
+
* The item cursor over an entity document — the collection engine's
|
|
1892
|
+
* `query()` over the second document kind, on the one cursor
|
|
1893
|
+
* mechanism (cursor.js): a native selection or join pulls one row
|
|
1894
|
+
* per `next()` from an open statement and merges it into its entity
|
|
1895
|
+
* document; a count yields its one item; a set residual and a
|
|
1896
|
+
* diverting external materialise the fetched root first and say so.
|
|
1897
|
+
* `register`, when given, is the unit of work's registration: every
|
|
1898
|
+
* yielded entity document passes through it, which is why the
|
|
1899
|
+
* document must return a bare entity binding (`JD0034` otherwise) —
|
|
1900
|
+
* a projection is not a snapshot anything could save.
|
|
1901
|
+
* @param {any} document
|
|
1902
|
+
* @param {{ externals?: any, strict?: boolean, pushdown?: boolean,
|
|
1903
|
+
* signal?: AbortSignal }} [options]
|
|
1904
|
+
* @param {((entity: string, doc: any) => any) | undefined} [register]
|
|
1905
|
+
*/
|
|
1906
|
+
const query = (document, options = undefined, register = undefined, cursorFactory = createCursor) => {
|
|
1907
|
+
requireCallable(options, state.now);
|
|
1908
|
+
const { externals, strict, pushdown, profile } = callState(options);
|
|
1909
|
+
const entry = entryFor(document, pushdown, profile);
|
|
1910
|
+
if (strict && entry.planned.mode !== 'native') {
|
|
1911
|
+
const forcing = entry.planned.reasons[0];
|
|
1912
|
+
throw new DbCompileError('JD0010',
|
|
1913
|
+
`strict mode refused a residual: '${forcing.construct}' — ${forcing.reason}`);
|
|
1914
|
+
}
|
|
1915
|
+
const retEntity = entry.planned.retEntity;
|
|
1916
|
+
if (register !== undefined && retEntity === null) {
|
|
1917
|
+
throw new DbCompileError('JD0034',
|
|
1918
|
+
'a tracked cursor registers the entity documents it yields, and this document '
|
|
1919
|
+
+ 'yields none: it returns a projection, a count or a window rather than one bare '
|
|
1920
|
+
+ 'entity binding — read it untracked, or return the binding itself');
|
|
1921
|
+
}
|
|
1922
|
+
const each = register === undefined ? (item) => item : (item) => register(retEntity, item);
|
|
1923
|
+
const classified = cursorClass(entry, externals);
|
|
1924
|
+
refuseBuffered(options, classified, entities.get(entry.planned.retEntity ?? '')?.docPath);
|
|
1925
|
+
const signal = options?.signal;
|
|
1926
|
+
const deadline = options?.deadline;
|
|
1927
|
+
if (entry.planned.wrapped === true) {
|
|
1928
|
+
return cursorFactory({ ...classified, signal, deadline, now: state.now, wrap: driverWrap,
|
|
1929
|
+
materialize: () => chain(execute(document, options), (value) => [value]) });
|
|
1930
|
+
}
|
|
1931
|
+
if (classified.barrier !== null) {
|
|
1932
|
+
return cursorFactory({ ...classified, signal, deadline, now: state.now, wrap: driverWrap,
|
|
1933
|
+
materialize: () => chain(fetchRoot(entry), (root) =>
|
|
1934
|
+
packedResidualOf(entry, document)(root, externals).map(each)) });
|
|
1935
|
+
}
|
|
1936
|
+
const params = entry.slots.map((slot) => slotValue(slot, externals));
|
|
1937
|
+
// the statement is prepared by the first PULL, not here: a root
|
|
1938
|
+
// cursor's construction touches no connection, so it can be handed
|
|
1939
|
+
// back before the pull is admitted (MODEL-FORMAT §5.1)
|
|
1940
|
+
const prepared = () => {
|
|
1941
|
+
if (entry.statement === null) entry.statement = connection.prepare(entry.sql, { readOnly: true });
|
|
1942
|
+
return entry.statement;
|
|
1943
|
+
};
|
|
1944
|
+
if (entry.planned.plan.aggregate === 'count') {
|
|
1945
|
+
return cursorFactory({ ...classified, signal, deadline, now: state.now, wrap: driverWrap,
|
|
1946
|
+
materialize: () => chain(guardEntityScan(entry), () => chain(prepared(), (statement) =>
|
|
1947
|
+
chain(statement.get(params), (row) => [row?.value ?? 0]))) });
|
|
1948
|
+
}
|
|
1949
|
+
const rowEntity = entry.planned.plan.ret === null ? null
|
|
1950
|
+
: entry.planned.plan.bindings
|
|
1951
|
+
.find((binding) => binding.name === entry.planned.plan.ret).entity;
|
|
1952
|
+
let pulledRows = 0;
|
|
1953
|
+
// a statement of its own per cursor: two live iterators over one
|
|
1954
|
+
// cached statement invalidate each other at the driver
|
|
1955
|
+
return cursorFactory({ ...classified, signal, deadline, now: state.now, wrap: driverWrap,
|
|
1956
|
+
open: () => chain(guardEntityScan(entry), () => chain(connection.prepare(entry.sql, { readOnly: true, ephemeral: true }),
|
|
1957
|
+
(statement) => statement.iterate(params))),
|
|
1958
|
+
items: (row) => {
|
|
1959
|
+
pulledRows++;
|
|
1960
|
+
if (entry.rowBound !== null && pulledRows > entry.rowBound) {
|
|
1961
|
+
throw new DbRuntimeError('JD2007',
|
|
1962
|
+
`the fetch crossed the profile's maxRows bound of ${entry.rowBound}`,
|
|
1963
|
+
{ docPath: entities.get(rowEntity)?.docPath, collection: rowEntity });
|
|
1964
|
+
}
|
|
1965
|
+
const project = entry.planned.plan.project;
|
|
1966
|
+
if (project != null) return projectedTreeItems(project, row, 'p');
|
|
1967
|
+
return [each(checkBytes(entry, mergeEntityRow(mapping.entities[rowEntity], row, '__doc'), rowEntity))];
|
|
1968
|
+
} });
|
|
997
1969
|
};
|
|
998
1970
|
|
|
999
1971
|
const explain = (document, options = undefined) => {
|
|
1000
|
-
const pushdown = options
|
|
1001
|
-
const entry = entryFor(document, pushdown);
|
|
1972
|
+
const { pushdown, profile, profileSource } = callState(options);
|
|
1973
|
+
const entry = entryFor(document, pushdown, profile);
|
|
1974
|
+
// bound against the externals it was given: a value the database
|
|
1975
|
+
// cannot take sends the run to the residual over the fetched root,
|
|
1976
|
+
// and that is the mode reported, with the diversion named first; no
|
|
1977
|
+
// externals at all is the abstract plan, as it always was
|
|
1978
|
+
const classified = cursorClass(entry, options?.externals ?? null);
|
|
1979
|
+
const diverted = entry.planned.mode === 'native' && classified.barrier !== null
|
|
1980
|
+
&& classified.barrier.construct === 'external';
|
|
1981
|
+
const mode = diverted ? 'set' : entry.planned.mode;
|
|
1982
|
+
const reasons = diverted && classified.barrier !== null
|
|
1983
|
+
? [classified.barrier, ...entry.planned.reasons] : entry.planned.reasons;
|
|
1002
1984
|
const base = {
|
|
1003
|
-
mode
|
|
1985
|
+
mode,
|
|
1986
|
+
streaming: classified.streaming,
|
|
1987
|
+
barrier: classified.barrier,
|
|
1988
|
+
budget: budgetOf(profile, profileSource, connection.capabilities),
|
|
1989
|
+
wrapped: entry.planned.wrapped === true,
|
|
1004
1990
|
referenced: [...entry.planned.referenced],
|
|
1005
|
-
reasons
|
|
1006
|
-
|
|
1007
|
-
|
|
1991
|
+
reasons,
|
|
1992
|
+
// the same effective-order vocabulary the collection engine and
|
|
1993
|
+
// the graph loader report; `null` when no statement answers
|
|
1994
|
+
order: diverted ? null : planOrder(entry.planned.plan),
|
|
1995
|
+
sql: diverted ? null : entry.sql,
|
|
1996
|
+
residual: mode === 'native'
|
|
1008
1997
|
? null
|
|
1009
|
-
: { mode: 'set', reasons
|
|
1998
|
+
: { mode: 'set', reasons },
|
|
1010
1999
|
};
|
|
1011
|
-
if (
|
|
1012
|
-
return chain(connection.prepare(dialect.explainQuery(entry.sql)), (statement) =>
|
|
2000
|
+
if (mode !== 'native') return base;
|
|
2001
|
+
return chain(connection.prepare(dialect.explainQuery(entry.sql), { readOnly: true }), (statement) =>
|
|
1013
2002
|
chain(statement.all(entry.slots.map((slot) =>
|
|
1014
2003
|
('literal' in slot ? slot.literal : null))), (rows) => ({
|
|
1015
2004
|
...base,
|
|
1016
|
-
join:
|
|
1017
|
-
|
|
2005
|
+
// the join graph, in the order the FROM clause builds it: the
|
|
2006
|
+
// first binding, then each one and the equalities that attached
|
|
2007
|
+
// it. Empty for a single-binding plan
|
|
2008
|
+
joins: entry.planned.plan.joins,
|
|
2009
|
+
scanNarrative: dialect.explainLines(rows).join('; '),
|
|
1018
2010
|
})));
|
|
1019
2011
|
};
|
|
1020
2012
|
|
|
1021
|
-
return { execute,
|
|
2013
|
+
return { execute: bounded(execute, driverWrap), query,
|
|
2014
|
+
syncQuery: (document, options, register) => query(document, options, register, createSyncCursor),
|
|
2015
|
+
explain: bounded(explain, driverWrap), relations };
|
|
1022
2016
|
}
|
|
1023
2017
|
|
|
1024
2018
|
/**
|
|
@@ -1038,6 +2032,17 @@ export function createEntityQueryEngine(context) {
|
|
|
1038
2032
|
*/
|
|
1039
2033
|
export function createLoadEngine(context, entityName) {
|
|
1040
2034
|
const { connection, entities, mapping, state } = context;
|
|
2035
|
+
const storeProfile = context.profile ?? null;
|
|
2036
|
+
const roots = context.roots ?? [...entities.keys()];
|
|
2037
|
+
/** Every driver failure this loader meets, classified under its entity. */
|
|
2038
|
+
const driverWrap = (/** @type {any} */ error) =>
|
|
2039
|
+
wrapDriverError(error, { docPath: '/entities', collection: entityName });
|
|
2040
|
+
// the entity core's column encoding (booleans to integers, an epoch
|
|
2041
|
+
// column's string to its epoch): what a continuation's DOCUMENT values
|
|
2042
|
+
// bind as when the keyset compares them with the stored columns
|
|
2043
|
+
const encodeColumn = context.coreFor === undefined
|
|
2044
|
+
? (/** @type {string} */ column, /** @type {any} */ value) => value
|
|
2045
|
+
: (column, value) => context.coreFor(entityName).plan.encodeColumn(column, value);
|
|
1041
2046
|
// a registered operator (Ring 2) is recognised as vocabulary so a
|
|
1042
2047
|
// where/orderBy that uses one refuses cleanly (JD0032 — the load path
|
|
1043
2048
|
// is all-SQL, with no residual), never as an unknown operator
|
|
@@ -1048,36 +2053,49 @@ export function createLoadEngine(context, entityName) {
|
|
|
1048
2053
|
const refuse = (reason, path) => new DbCompileError('JD0032',
|
|
1049
2054
|
`${reason} (include path: ${path.join('.') || '<root>'})`,
|
|
1050
2055
|
entities.get(entityName)?.docPath);
|
|
2056
|
+
const isWindowBound = (value) => Number.isSafeInteger(value) && value >= 0;
|
|
2057
|
+
/** A per-root bound as declared: a positive integer, or `Infinity` /
|
|
2058
|
+
* `null` for the unbounded case a caller spelled on purpose. */
|
|
2059
|
+
const isBound = (value) => value === null || value === Infinity
|
|
2060
|
+
|| (Number.isSafeInteger(value) && value >= 1);
|
|
2061
|
+
/** An include's window inside its subquery: LIMIT, and OFFSET for a
|
|
2062
|
+
* `skip` — per parent row, since the subquery is correlated (§10.4).
|
|
2063
|
+
* A to-many include with no `take` still carries `LIMIT maxRows + 1`,
|
|
2064
|
+
* so a relation past its bound is DETECTED at the bound instead of
|
|
2065
|
+
* aggregated whole and then refused — the profile's row-bound rule,
|
|
2066
|
+
* applied per root. */
|
|
2067
|
+
const windowClause = (child) => {
|
|
2068
|
+
const limit = child.take ?? (child.rowLimit === null ? null : child.rowLimit + 1);
|
|
2069
|
+
return limit !== null || (child.skip !== undefined && child.skip > 0)
|
|
2070
|
+
? ` ${dialect.limitClause(limit, child.skip)}` : '';
|
|
2071
|
+
};
|
|
1051
2072
|
|
|
1052
2073
|
/** Compile a where EXPRESSION over `$it` against one entity. */
|
|
1053
2074
|
const compileWhere = (expression, entity, path) => {
|
|
1054
|
-
const
|
|
1055
|
-
|
|
1056
|
-
try {
|
|
1057
|
-
analysis = analyzeQuery(wrapper, analyzeOpts);
|
|
1058
|
-
}
|
|
1059
|
-
catch (cause) {
|
|
2075
|
+
const planned = planEntityWhere(expression, entity, mapping.entities[entity.name], analyzeOpts);
|
|
2076
|
+
if ('error' in planned) {
|
|
1060
2077
|
throw new DbCompileError('JD0032',
|
|
1061
2078
|
`the where expression does not compile (include path: ${path.join('.')})`,
|
|
1062
|
-
entity.docPath,
|
|
2079
|
+
entity.docPath, planned.error);
|
|
1063
2080
|
}
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
: { p: 'and', items: [filter, outcome.pred] };
|
|
2081
|
+
if ('refusal' in planned) {
|
|
2082
|
+
throw refuse(`the where expression is not translatable: ${planned.refusal.reason}`, path);
|
|
2083
|
+
}
|
|
2084
|
+
return planned.filter;
|
|
2085
|
+
};
|
|
2086
|
+
/** The profile applied to one node of the include tree (§8): the
|
|
2087
|
+
* entity must be allowed, and its mandatory predicate — when the
|
|
2088
|
+
* profile carries one — is conjoined into the node's own filter, at
|
|
2089
|
+
* the root and inside every include subquery alike. */
|
|
2090
|
+
const applyProfileToNode = (node, name, profile, path) => {
|
|
2091
|
+
if (profile === null) return node;
|
|
2092
|
+
if (profile.collections !== null && !profile.collections.includes(name)) {
|
|
2093
|
+
throw profileEntityRefusal(`the profile does not allow loading entity '${name}'`
|
|
2094
|
+
+ ` (include path: ${path.join('.') || '<root>'})`, entities.get(name)?.docPath);
|
|
1079
2095
|
}
|
|
1080
|
-
|
|
2096
|
+
const predicate = mandatoryEntityPredicate(profile, entities.get(name), mapping.entities[name], analyzeOpts);
|
|
2097
|
+
if (predicate === null) return node;
|
|
2098
|
+
return { ...node, where: conjoin(node.where, predicate) };
|
|
1081
2099
|
};
|
|
1082
2100
|
|
|
1083
2101
|
const compileOrder = (orderBy, entity, path) => {
|
|
@@ -1106,22 +2124,28 @@ export function createLoadEngine(context, entityName) {
|
|
|
1106
2124
|
};
|
|
1107
2125
|
|
|
1108
2126
|
/** Build the include tree, validating names, depth and cycles. */
|
|
1109
|
-
const buildTree = (name, spec, depth, maxDepth, path, seen) => {
|
|
2127
|
+
const buildTree = (name, spec, depth, maxDepth, path, seen, profile = null) => {
|
|
1110
2128
|
const entity = entities.get(name);
|
|
1111
2129
|
if (depth > maxDepth) {
|
|
1112
2130
|
throw refuse(`the include graph exceeds its depth bound of ${maxDepth} `
|
|
1113
2131
|
+ '(raise it explicitly with maxDepth)', path);
|
|
1114
2132
|
}
|
|
2133
|
+
/** @type {any} */
|
|
1115
2134
|
const node = {
|
|
1116
2135
|
entity,
|
|
1117
2136
|
entityMapping: mapping.entities[name],
|
|
1118
2137
|
where: spec?.where !== undefined ? compileWhere(spec.where, entity, path) : null,
|
|
1119
2138
|
order: spec?.orderBy !== undefined ? compileOrder(spec.orderBy, entity, path) : null,
|
|
1120
2139
|
take: spec?.take,
|
|
2140
|
+
skip: spec?.skip,
|
|
2141
|
+
/** the per-root row bound a to-many include's subquery detects at;
|
|
2142
|
+
* set by the parent, `null` at the root and for a to-one */
|
|
2143
|
+
rowLimit: null,
|
|
1121
2144
|
includes: [],
|
|
1122
2145
|
};
|
|
2146
|
+
const shaped = applyProfileToNode(node, name, profile, path);
|
|
1123
2147
|
const includeSpec = spec?.include;
|
|
1124
|
-
if (includeSpec === undefined) return
|
|
2148
|
+
if (includeSpec === undefined) return shaped;
|
|
1125
2149
|
if (seen.has(includeSpec))
|
|
1126
2150
|
throw refuse('the include specification cycles', path);
|
|
1127
2151
|
seen.add(includeSpec);
|
|
@@ -1133,12 +2157,58 @@ export function createLoadEngine(context, entityName) {
|
|
|
1133
2157
|
[...path, relationName]);
|
|
1134
2158
|
}
|
|
1135
2159
|
const childSpec = includeSpec[relationName] === true ? {} : includeSpec[relationName];
|
|
2160
|
+
if (childSpec.count === true) {
|
|
2161
|
+
// a count counts EVERY related row; a where/take beside it was
|
|
2162
|
+
// dropped without a word, and the number answered was the total
|
|
2163
|
+
const dropped = ['where', 'orderBy', 'take', 'skip', 'include', 'after', 'maxRows', 'maxBytes']
|
|
2164
|
+
.filter((member) => childSpec[member] !== undefined);
|
|
2165
|
+
if (dropped.length > 0) {
|
|
2166
|
+
throw refuse(`count: true counts every related row and takes no ${dropped.join('/')} — `
|
|
2167
|
+
+ 'load the rows to count a subset', [...path, relationName]);
|
|
2168
|
+
}
|
|
2169
|
+
}
|
|
2170
|
+
for (const member of ['take', 'skip']) {
|
|
2171
|
+
if (childSpec[member] !== undefined && !isWindowBound(childSpec[member]))
|
|
2172
|
+
throw refuse(`${member} must be a non-negative integer`, [...path, relationName]);
|
|
2173
|
+
}
|
|
2174
|
+
for (const member of ['maxRows', 'maxBytes']) {
|
|
2175
|
+
if (childSpec[member] !== undefined && !isBound(childSpec[member]))
|
|
2176
|
+
throw refuse(`${member} must be a positive integer, or Infinity (null in JSON) to load the relation unbounded by decision`,
|
|
2177
|
+
[...path, relationName]);
|
|
2178
|
+
}
|
|
2179
|
+
// a keyset cursor is one position in ONE ordered set; an include is
|
|
2180
|
+
// a set per parent, so it windows with skip/take and never seeks
|
|
2181
|
+
if (childSpec.after !== undefined) {
|
|
2182
|
+
throw refuse("'after' (keyset pagination) paginates the root — an include windows with skip and take",
|
|
2183
|
+
[...path, relationName]);
|
|
2184
|
+
}
|
|
1136
2185
|
const childName = relation.to;
|
|
2186
|
+
const many = relation.kind !== 'oneToOne';
|
|
2187
|
+
// the per-root bounds (§10.4): declared, else the include's own
|
|
2188
|
+
// `take` (a window IS a row bound), else the store default; `null`
|
|
2189
|
+
// is the unbounded case, spelled
|
|
2190
|
+
const boundOf = (member, fallback) => {
|
|
2191
|
+
const declared = childSpec[member];
|
|
2192
|
+
if (declared === undefined) return fallback;
|
|
2193
|
+
return declared === Infinity ? null : declared;
|
|
2194
|
+
};
|
|
2195
|
+
const maxRows = many ? boundOf('maxRows', childSpec.take ?? INCLUDE_ROWS_DEFAULT) : null;
|
|
2196
|
+
if (profile !== null && profile.maxIncludedRows !== null && many && childSpec.count !== true
|
|
2197
|
+
&& (maxRows === null || maxRows > profile.maxIncludedRows)) {
|
|
2198
|
+
// the profile's cap on included rows per root is a hard maximum
|
|
2199
|
+
// the include's own declaration cannot exceed (D6: refused, not
|
|
2200
|
+
// narrowed quietly)
|
|
2201
|
+
throw profileEntityRefusal(`the profile caps included rows per root at ${profile.maxIncludedRows}; `
|
|
2202
|
+
+ `the include '${relationName}' declares ${maxRows === null ? 'no bound (Infinity)' : maxRows}`
|
|
2203
|
+
+ ` (include path: ${[...path, relationName].join('.')})`, entity.docPath);
|
|
2204
|
+
}
|
|
1137
2205
|
const include = {
|
|
1138
2206
|
name: relationName,
|
|
1139
2207
|
field: `__${relationName}`,
|
|
1140
2208
|
relation,
|
|
1141
|
-
many
|
|
2209
|
+
many,
|
|
2210
|
+
maxRows,
|
|
2211
|
+
maxBytes: childSpec.count === true ? null : boundOf('maxBytes', INCLUDE_BYTES_DEFAULT),
|
|
1142
2212
|
count: childSpec.count === true,
|
|
1143
2213
|
// the join kind is derivable from the schema: a required
|
|
1144
2214
|
// foreign key means the parent always exists
|
|
@@ -1149,11 +2219,13 @@ export function createLoadEngine(context, entityName) {
|
|
|
1149
2219
|
child: childSpec.count === true
|
|
1150
2220
|
? null
|
|
1151
2221
|
: buildTree(childName, childSpec, depth + 1, maxDepth,
|
|
1152
|
-
[...path, relationName], seen),
|
|
2222
|
+
[...path, relationName], seen, profile),
|
|
1153
2223
|
};
|
|
1154
|
-
|
|
2224
|
+
// the subquery's own LIMIT detects the bound (windowClause)
|
|
2225
|
+
if (include.child !== null) include.child.rowLimit = many ? include.maxRows : null;
|
|
2226
|
+
shaped.includes.push(include);
|
|
1155
2227
|
}
|
|
1156
|
-
return
|
|
2228
|
+
return shaped;
|
|
1157
2229
|
};
|
|
1158
2230
|
|
|
1159
2231
|
/** Render one node's subquery-projection SQL. */
|
|
@@ -1171,7 +2243,10 @@ export function createLoadEngine(context, entityName) {
|
|
|
1171
2243
|
if (named.has(fk.column)) continue; // a declared via property
|
|
1172
2244
|
parts.push(`${slText(fk.column)}, ${aliasSql}.${q(fk.column)}`);
|
|
1173
2245
|
}
|
|
1174
|
-
|
|
2246
|
+
// EMBEDDED, not rendered: the enclosing object carries the
|
|
2247
|
+
// document as a nested JSON value, which on an engine with one
|
|
2248
|
+
// JSON type is the column itself and on SQLite is `json()`
|
|
2249
|
+
parts.push(`${slText('__doc')}, ${dialect.jsonEmbed(docSql)}`);
|
|
1175
2250
|
for (const include of node.includes)
|
|
1176
2251
|
parts.push(`${slText(include.field)}, ${renderInclude(node, include, alias, param, emitters)}`);
|
|
1177
2252
|
return parts.join(', ');
|
|
@@ -1186,6 +2261,17 @@ export function createLoadEngine(context, entityName) {
|
|
|
1186
2261
|
const parentKey = parentNode.entityMapping.keys[0];
|
|
1187
2262
|
if (include.count === true) {
|
|
1188
2263
|
const childTable = mapping.entities[relation.to].table;
|
|
2264
|
+
if (relation.kind === 'manyToMany') {
|
|
2265
|
+
const join = mapping.joinTables[relation.joinTable];
|
|
2266
|
+
const own = join.left.entity === parentNode.entity.name ? join.left : join.right;
|
|
2267
|
+
return `(SELECT COUNT(*) FROM ${q(relation.joinTable)} AS ${q(childAlias)} `
|
|
2268
|
+
+ `WHERE ${q(childAlias)}.${q(own.column)} = ${q(parentAlias)}.${q(parentKey)})`;
|
|
2269
|
+
}
|
|
2270
|
+
if (relation.kind === 'oneToOne') {
|
|
2271
|
+
const childKey = mapping.entities[relation.to].keys[0];
|
|
2272
|
+
return `(SELECT COUNT(*) FROM ${q(childTable)} AS ${q(childAlias)} `
|
|
2273
|
+
+ `WHERE ${q(childAlias)}.${q(childKey)} = ${q(parentAlias)}.${q(relation.via)})`;
|
|
2274
|
+
}
|
|
1189
2275
|
return `(SELECT COUNT(*) FROM ${q(childTable)} AS ${q(childAlias)} `
|
|
1190
2276
|
+ `WHERE ${q(childAlias)}.${q(relation.via)} = ${q(parentAlias)}.${q(parentKey)})`;
|
|
1191
2277
|
}
|
|
@@ -1207,7 +2293,7 @@ export function createLoadEngine(context, entityName) {
|
|
|
1207
2293
|
// engine's order); only plain mapped columns order natively
|
|
1208
2294
|
const value = term.ref.flavor === 'entity-column'
|
|
1209
2295
|
? `${rendered.aliasSql}.${q(term.ref.column)}`
|
|
1210
|
-
: dialect.jsonExtract(rendered.docSql, dialect.jsonPathText(term.ref.segments));
|
|
2296
|
+
: dialect.jsonExtract(rendered.docSql, dialect.jsonPathText(term.ref.segments), 'text');
|
|
1211
2297
|
const nullsFirst = term.emptyGreatest === term.desc;
|
|
1212
2298
|
return `${value} ${term.desc ? 'DESC' : 'ASC'}${dialect.orderNulls(nullsFirst)}`;
|
|
1213
2299
|
});
|
|
@@ -1220,22 +2306,103 @@ export function createLoadEngine(context, entityName) {
|
|
|
1220
2306
|
+ (child.where !== null
|
|
1221
2307
|
? ` AND ${emitters.emitPred(rendered.aliasSql, rendered.docSql, child.where)}` : '')
|
|
1222
2308
|
+ ` ORDER BY ${orderSql.join(', ')}`
|
|
1223
|
-
+ (child
|
|
2309
|
+
+ windowClause(child)
|
|
1224
2310
|
: `SELECT ${rendered.aliasSql}.* FROM ${q(childTable)} AS ${q(childAlias)} `
|
|
1225
2311
|
+ `WHERE ${conditions.join(' AND ')} ORDER BY ${orderSql.join(', ')}`
|
|
1226
|
-
+ (child
|
|
2312
|
+
+ windowClause(child);
|
|
1227
2313
|
if (relation.kind === 'oneToOne') {
|
|
1228
|
-
return `(SELECT
|
|
2314
|
+
return `(SELECT ${dialect.jsonObject(rendered.projection())} FROM `
|
|
1229
2315
|
+ `(${inner} ${dialect.limitClause(1, undefined)}) AS ${q(childAlias)})`;
|
|
1230
2316
|
}
|
|
1231
|
-
return `(SELECT ${dialect.jsonAgg(
|
|
2317
|
+
return `(SELECT ${dialect.jsonAgg(dialect.jsonObject(rendered.projection()))} `
|
|
1232
2318
|
+ `FROM (${inner}) AS ${q(childAlias)})`;
|
|
1233
2319
|
};
|
|
1234
2320
|
|
|
1235
|
-
|
|
2321
|
+
/**
|
|
2322
|
+
* The ordering's IDENTITY in keyset mode: the declared column terms
|
|
2323
|
+
* with their direction and null placement, then the primary-key
|
|
2324
|
+
* column(s) not already named, ascending — the tie-breaker the plan
|
|
2325
|
+
* appends whether or not the caller named it, since the key is the
|
|
2326
|
+
* one column guaranteed unique. A document-path term cannot carry a
|
|
2327
|
+
* keyset (`JD0032`).
|
|
2328
|
+
* @param {any[]} order - the compiled order terms
|
|
2329
|
+
* @param {readonly string[]} keyColumns
|
|
2330
|
+
* @returns {{ column: string, desc: boolean, nullsFirst: boolean }[]}
|
|
2331
|
+
*/
|
|
2332
|
+
const orderIdentity = (order, keyColumns) => {
|
|
2333
|
+
const terms = order.map((term) => {
|
|
2334
|
+
if (term.ref.flavor !== 'entity-column') {
|
|
2335
|
+
throw refuse('a keyset orders by mapped columns — '
|
|
2336
|
+
+ `'${term.ref.segments.join('.')}' is a document path`, []);
|
|
2337
|
+
}
|
|
2338
|
+
return { column: term.ref.column, desc: term.desc, nullsFirst: term.emptyGreatest === term.desc };
|
|
2339
|
+
});
|
|
2340
|
+
for (const column of keyColumns) {
|
|
2341
|
+
// a key column is NOT NULL: `nullsFirst` is SQLite's own ASC
|
|
2342
|
+
// default, spelled so the identity is explicit, never a branch
|
|
2343
|
+
if (!terms.some((term) => term.column === column))
|
|
2344
|
+
terms.push({ column, desc: false, nullsFirst: true });
|
|
2345
|
+
}
|
|
2346
|
+
return terms;
|
|
2347
|
+
};
|
|
2348
|
+
|
|
2349
|
+
/**
|
|
2350
|
+
* A structural continuation, checked against THIS ordering: the
|
|
2351
|
+
* `{ order, keys, key }` a page emitted, whose `order` must be this
|
|
2352
|
+
* graph's identity exactly — a continuation replayed against another
|
|
2353
|
+
* ordering is `JD0035`, never a wrong page — and whose values are
|
|
2354
|
+
* returned aligned with the identity, encoded as the columns store them.
|
|
2355
|
+
* @param {any} after
|
|
2356
|
+
* @param {ReturnType<typeof orderIdentity>} identity
|
|
2357
|
+
* @param {number} declared - how many terms the caller declared
|
|
2358
|
+
* @param {readonly string[]} keyColumns
|
|
2359
|
+
* @returns {any[]} one value per identity term
|
|
2360
|
+
*/
|
|
2361
|
+
const continuationValues = (after, identity, declared, keyColumns) => {
|
|
2362
|
+
const mismatch = (reason) => new DbCompileError('JD0035',
|
|
2363
|
+
`the continuation does not belong to this ordering: ${reason}`,
|
|
2364
|
+
entities.get(entityName)?.docPath);
|
|
2365
|
+
const spell = (terms) => terms.map((term) => `${term.column} ${term.desc ? 'desc' : 'asc'}`
|
|
2366
|
+
+ `${term.nullsFirst ? ' nulls first' : ''}`).join(', ');
|
|
2367
|
+
if (after === null || typeof after !== 'object' || !Array.isArray(after.order)
|
|
2368
|
+
|| !Array.isArray(after.keys) || !('key' in after)) {
|
|
2369
|
+
throw mismatch('a continuation is the { order, keys, key } value a page emitted');
|
|
2370
|
+
}
|
|
2371
|
+
if (JSON.stringify(after.order) !== JSON.stringify(identity)) {
|
|
2372
|
+
throw mismatch(`it was emitted for the ordering (${spell(after.order)}); this graph orders `
|
|
2373
|
+
+ `by (${spell(identity)})`);
|
|
2374
|
+
}
|
|
2375
|
+
if (after.keys.length !== declared) {
|
|
2376
|
+
throw mismatch(`it carries ${after.keys.length} order-key value(s); the ordering declares ${declared}`);
|
|
2377
|
+
}
|
|
2378
|
+
const keyOf = (column) => {
|
|
2379
|
+
if (keyColumns.length === 1) {
|
|
2380
|
+
if (typeof after.key !== 'string' && typeof after.key !== 'number')
|
|
2381
|
+
throw mismatch("'key' must be the row's primary key, a scalar");
|
|
2382
|
+
return after.key;
|
|
2383
|
+
}
|
|
2384
|
+
const value = after.key?.[column];
|
|
2385
|
+
if (typeof value !== 'string' && typeof value !== 'number')
|
|
2386
|
+
throw mismatch(`'key' must carry every key column { ${keyColumns.join(', ')} }`);
|
|
2387
|
+
return value;
|
|
2388
|
+
};
|
|
2389
|
+
return identity.map((term, i) => encodeColumn(term.column,
|
|
2390
|
+
i < declared ? (after.keys[i] ?? null) : keyOf(term.column)));
|
|
2391
|
+
};
|
|
2392
|
+
|
|
2393
|
+
/**
|
|
2394
|
+
* Build the load: one statement, cached by spec. `keyset` forces
|
|
2395
|
+
* keyset mode — the primary key as the ORDER BY tie-breaker in place of
|
|
2396
|
+
* the row identity, so a continuation can resume exactly — which a
|
|
2397
|
+
* structural `after` implies; a scalar `after` keeps the single
|
|
2398
|
+
* unique-column keyset it always was.
|
|
2399
|
+
* @param {any} spec
|
|
2400
|
+
* @param {boolean} [keyset]
|
|
2401
|
+
*/
|
|
2402
|
+
const buildLoad = (spec, keyset = false, profile = null) => {
|
|
1236
2403
|
// a cyclic specification cannot be keyed, so the cache reports a
|
|
1237
2404
|
// permanent miss and buildTree gets to NAME the cycle
|
|
1238
|
-
const key = ['L', entityName, spec ?? {}, dialect.name];
|
|
2405
|
+
const key = ['L', entityName, spec ?? {}, dialect.name, keyset, profile];
|
|
1239
2406
|
const cached = state.cache.get(key);
|
|
1240
2407
|
if (cached !== undefined) {
|
|
1241
2408
|
state.counters.hits++;
|
|
@@ -1250,7 +2417,16 @@ export function createLoadEngine(context, entityName) {
|
|
|
1250
2417
|
};
|
|
1251
2418
|
const emitters = createEntityPredicateEmitters(dialect, param);
|
|
1252
2419
|
const maxDepth = spec?.maxDepth ?? INCLUDE_DEPTH_DEFAULT;
|
|
1253
|
-
|
|
2420
|
+
if (profile !== null && profile.maxDepth !== null && maxDepth > profile.maxDepth) {
|
|
2421
|
+
throw profileEntityRefusal(`the profile caps the include depth at ${profile.maxDepth}; `
|
|
2422
|
+
+ `this load asks for ${maxDepth}`, entities.get(entityName)?.docPath);
|
|
2423
|
+
}
|
|
2424
|
+
for (const member of ['take', 'skip']) {
|
|
2425
|
+
// interpolated into LIMIT/OFFSET as written: a string ran as SQL
|
|
2426
|
+
if (spec?.[member] !== undefined && !isWindowBound(spec[member]))
|
|
2427
|
+
throw refuse(`${member} must be a non-negative integer`, []);
|
|
2428
|
+
}
|
|
2429
|
+
const tree = buildTree(entityName, spec ?? {}, 0, maxDepth, [], new Set(), profile);
|
|
1254
2430
|
const rendered = render(tree, 'r', param, emitters);
|
|
1255
2431
|
|
|
1256
2432
|
// anonymous placeholders bind by position, so slots must be
|
|
@@ -1263,16 +2439,69 @@ export function createLoadEngine(context, entityName) {
|
|
|
1263
2439
|
if (tree.where !== null)
|
|
1264
2440
|
conditions.push(emitters.emitPred(rendered.aliasSql, rendered.docSql, tree.where));
|
|
1265
2441
|
|
|
1266
|
-
// pagination: keyset
|
|
1267
|
-
//
|
|
2442
|
+
// pagination: keyset beats a growing OFFSET; the choice is reported,
|
|
2443
|
+
// never silent. A scalar `after` is the single unique-column keyset;
|
|
2444
|
+
// a structural one — or a page — is the composite keyset: the
|
|
2445
|
+
// lexicographic expansion over the declared terms with the primary
|
|
2446
|
+
// key appended, null placement agreeing with the ORDER BY (§10.5)
|
|
1268
2447
|
let pagination = 'none';
|
|
1269
2448
|
const order = tree.order ?? [];
|
|
2449
|
+
const after = spec?.after === null ? undefined : spec?.after;
|
|
2450
|
+
const structural = after !== undefined && typeof after === 'object';
|
|
2451
|
+
const keysetMode = keyset || structural;
|
|
2452
|
+
const keyColumns = tree.entityMapping.keys;
|
|
1270
2453
|
const uniqueColumns = new Set([
|
|
1271
|
-
|
|
2454
|
+
keyColumns.length === 1 ? keyColumns[0] : null,
|
|
1272
2455
|
...tree.entityMapping.indexes.filter((index) => index.unique)
|
|
1273
2456
|
.map((index) => index.property),
|
|
1274
2457
|
]);
|
|
1275
|
-
|
|
2458
|
+
/** @type {ReturnType<typeof orderIdentity> | null} */
|
|
2459
|
+
let identity = null;
|
|
2460
|
+
if (keysetMode) {
|
|
2461
|
+
identity = orderIdentity(order, keyColumns);
|
|
2462
|
+
if (after !== undefined) {
|
|
2463
|
+
if (!structural) {
|
|
2464
|
+
throw new DbCompileError('JD0035',
|
|
2465
|
+
'the continuation does not belong to this ordering: a page resumes from the '
|
|
2466
|
+
+ '{ order, keys, key } value a page emitted, not a bare key',
|
|
2467
|
+
entities.get(entityName)?.docPath);
|
|
2468
|
+
}
|
|
2469
|
+
pagination = 'keyset';
|
|
2470
|
+
const values = continuationValues(after, identity, order.length, keyColumns);
|
|
2471
|
+
const column = (term) => `${rendered.aliasSql}.${q(term.column)}`;
|
|
2472
|
+
const equal = (i) => (values[i] === null
|
|
2473
|
+
? `${column(identity[i])} IS NULL`
|
|
2474
|
+
: `${column(identity[i])} = ${param({ literal: values[i] })}`);
|
|
2475
|
+
// "comes after the value in this term's order": a null value is
|
|
2476
|
+
// followed by the non-nulls when nulls sort first and by nothing
|
|
2477
|
+
// when they sort last; a non-null value is followed by the greater
|
|
2478
|
+
// (or lesser, descending) values, and by the nulls when they sort
|
|
2479
|
+
// last — the comparison alone would drop them, since SQL's
|
|
2480
|
+
// `col > ?` is neither true nor false for NULL
|
|
2481
|
+
const beyond = (i) => {
|
|
2482
|
+
const term = identity[i];
|
|
2483
|
+
if (values[i] === null) return `${column(term)} IS NOT NULL`;
|
|
2484
|
+
const base = `${column(term)} ${term.desc ? '<' : '>'} ${param({ literal: values[i] })}`;
|
|
2485
|
+
// a key column is NOT NULL and needs no null branch
|
|
2486
|
+
return term.nullsFirst || keyColumns.includes(term.column)
|
|
2487
|
+
? base : `(${base} OR ${column(term)} IS NULL)`;
|
|
2488
|
+
};
|
|
2489
|
+
const branches = [];
|
|
2490
|
+
for (let i = 0; i < identity.length; i++) {
|
|
2491
|
+
// after a null that sorts last comes nothing in this term: the
|
|
2492
|
+
// branch is empty, and only the tie-break branches remain
|
|
2493
|
+
if (values[i] === null && !identity[i].nullsFirst) continue;
|
|
2494
|
+
// parameters bind by position, so the parts are built in SQL
|
|
2495
|
+
// text order: the equalities first, then the strict comparison
|
|
2496
|
+
const parts = [];
|
|
2497
|
+
for (let j = 0; j < i; j++) parts.push(equal(j));
|
|
2498
|
+
parts.push(beyond(i));
|
|
2499
|
+
branches.push(parts.length === 1 ? parts[0] : `(${parts.join(' AND ')})`);
|
|
2500
|
+
}
|
|
2501
|
+
conditions.push(branches.length === 0 ? '0' : `(${branches.join(' OR ')})`);
|
|
2502
|
+
}
|
|
2503
|
+
}
|
|
2504
|
+
else if (after !== undefined) {
|
|
1276
2505
|
const term = order.length === 1 ? order[0] : null;
|
|
1277
2506
|
if (term === null || term.ref.flavor === 'entity-doc'
|
|
1278
2507
|
|| !uniqueColumns.has(term.ref.column)) {
|
|
@@ -1280,7 +2509,7 @@ export function createLoadEngine(context, entityName) {
|
|
|
1280
2509
|
}
|
|
1281
2510
|
pagination = 'keyset';
|
|
1282
2511
|
conditions.push(`${rendered.aliasSql}.${q(term.ref.column)} `
|
|
1283
|
-
+ `${term.desc ? '<' : '>'} ${param({ literal:
|
|
2512
|
+
+ `${term.desc ? '<' : '>'} ${param({ literal: after })}`);
|
|
1284
2513
|
}
|
|
1285
2514
|
else if (spec?.skip !== undefined && spec.skip > 0) {
|
|
1286
2515
|
pagination = 'offset';
|
|
@@ -1290,53 +2519,260 @@ export function createLoadEngine(context, entityName) {
|
|
|
1290
2519
|
+ includeSql
|
|
1291
2520
|
+ ` FROM ${q(tree.entityMapping.table)} AS ${rendered.aliasSql}`;
|
|
1292
2521
|
if (conditions.length > 0) sql += ` WHERE ${conditions.join(' AND ')}`;
|
|
1293
|
-
const orderSql =
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
2522
|
+
const orderSql = identity !== null
|
|
2523
|
+
? identity.map((term) => `${rendered.aliasSql}.${q(term.column)} `
|
|
2524
|
+
+ `${term.desc ? 'DESC' : 'ASC'}${dialect.orderNulls(term.nullsFirst)}`)
|
|
2525
|
+
: order.map((term) => {
|
|
2526
|
+
const value = term.ref.flavor === 'entity-column'
|
|
2527
|
+
? `${rendered.aliasSql}.${q(term.ref.column)}`
|
|
2528
|
+
: dialect.jsonExtract(rendered.docSql, dialect.jsonPathText(term.ref.segments));
|
|
2529
|
+
const nullsFirst = term.emptyGreatest === term.desc;
|
|
2530
|
+
return `${value} ${term.desc ? 'DESC' : 'ASC'}${dialect.orderNulls(nullsFirst)}`;
|
|
2531
|
+
});
|
|
2532
|
+
if (identity === null) orderSql.push(`${rendered.aliasSql}.${dialect.rowIdentity()}`);
|
|
1301
2533
|
sql += ` ORDER BY ${orderSql.join(', ')}`;
|
|
1302
|
-
|
|
1303
|
-
|
|
2534
|
+
// the profile's row bound rides the root as LIMIT maxRows + 1, so
|
|
2535
|
+
// a load past it is detected at the bound and refused (JD2007)
|
|
2536
|
+
const rowBound = profile === null ? null : profile.maxRows;
|
|
2537
|
+
const take = rowBound === null ? spec?.take
|
|
2538
|
+
: Math.min(spec?.take ?? Infinity, rowBound + 1);
|
|
2539
|
+
if (take !== undefined || pagination === 'offset') {
|
|
2540
|
+
sql += ` ${dialect.limitClause(take === undefined ? null : take,
|
|
1304
2541
|
pagination === 'offset' ? spec.skip : undefined)}`;
|
|
1305
2542
|
}
|
|
1306
2543
|
|
|
1307
|
-
const entry = {
|
|
2544
|
+
const entry = {
|
|
2545
|
+
sql, slots, tree, pagination, statement: null,
|
|
2546
|
+
rowBound,
|
|
2547
|
+
byteBound: profile === null ? null : profile.maxBytes,
|
|
2548
|
+
profile,
|
|
2549
|
+
identity: identity === null ? null : deepFreeze(identity),
|
|
2550
|
+
// the order the statement executes under, in the vocabulary both
|
|
2551
|
+
// query engines report: the declared terms and the tie-breaker the
|
|
2552
|
+
// ORDER BY above appends — the primary key in keyset mode, the row
|
|
2553
|
+
// identity otherwise. Built from the same normalized terms, so the
|
|
2554
|
+
// explanation cannot drift from the clause
|
|
2555
|
+
effective: deepFreeze(effectiveOrder(order,
|
|
2556
|
+
identity === null ? undefined : { keyColumns })),
|
|
2557
|
+
declared: order.map((term) => term.ref.column),
|
|
2558
|
+
keyColumns,
|
|
2559
|
+
// a page over this ordering is a snapshot only when every order key
|
|
2560
|
+
// is immutable, and the primary key is the one column the engine
|
|
2561
|
+
// itself guarantees never moves (`update()` refuses to rewrite it)
|
|
2562
|
+
snapshot: identity === null ? null : identity.every((term) => keyColumns.includes(term.column)),
|
|
2563
|
+
};
|
|
1308
2564
|
const sizeBefore = state.cache.size();
|
|
1309
2565
|
if (state.cache.set(key, entry) && state.cache.size() === sizeBefore)
|
|
1310
2566
|
state.counters.evictions++;
|
|
1311
2567
|
return entry;
|
|
1312
2568
|
};
|
|
1313
2569
|
|
|
1314
|
-
|
|
2570
|
+
/**
|
|
2571
|
+
* A graph load answers whole entity documents, and a member
|
|
2572
|
+
* allow-list cannot cover a whole document — so a policed root is
|
|
2573
|
+
* refused here rather than answered past its policy. The refusal
|
|
2574
|
+
* names the members that ARE allowed, because the document query
|
|
2575
|
+
* engine can project exactly those.
|
|
2576
|
+
* @param {any} profile
|
|
2577
|
+
*/
|
|
2578
|
+
const refuseMemberPolicy = (profile) => {
|
|
2579
|
+
const policy = profile === null || profile.members === null
|
|
2580
|
+
? undefined : profile.members[entityName];
|
|
2581
|
+
if (policy === undefined) return;
|
|
2582
|
+
throw new DbCompileError('JD0011',
|
|
2583
|
+
`the profile allows only the members (${policy.declared.join(', ')}) of `
|
|
2584
|
+
+ `'${entityName}', and a graph load answers whole documents — query the members `
|
|
2585
|
+
+ 'the policy allows instead', entities.get(entityName)?.docPath);
|
|
2586
|
+
};
|
|
2587
|
+
|
|
2588
|
+
/** One loaded root against the profile's row and byte bounds. */
|
|
2589
|
+
const checkRoot = (entry, doc, pulled) => {
|
|
2590
|
+
if (entry.rowBound !== null && pulled > entry.rowBound) {
|
|
2591
|
+
throw new DbRuntimeError('JD2007',
|
|
2592
|
+
`the load crossed the profile's maxRows bound of ${entry.rowBound}`,
|
|
2593
|
+
{ docPath: entities.get(entityName)?.docPath, collection: entityName });
|
|
2594
|
+
}
|
|
2595
|
+
if (entry.byteBound !== null) {
|
|
2596
|
+
const bytes = utf8Length(JSON.stringify(doc));
|
|
2597
|
+
if (bytes > entry.byteBound) {
|
|
2598
|
+
throw new DbRuntimeError('JD2076',
|
|
2599
|
+
`an item of ${bytes} serialised bytes exceeds the profile's maxBytes bound of ${entry.byteBound}`,
|
|
2600
|
+
{ docPath: entities.get(entityName)?.docPath, collection: entityName });
|
|
2601
|
+
}
|
|
2602
|
+
}
|
|
2603
|
+
return doc;
|
|
2604
|
+
};
|
|
2605
|
+
/** The profile one call resolves, as on the query engines. */
|
|
2606
|
+
const profileOf = (options) => {
|
|
2607
|
+
const resolved = options?.profile !== undefined
|
|
2608
|
+
? normalizeProfile(options.profile) : storeProfile;
|
|
2609
|
+
assertProfileRoots(resolved, roots, entities.get(entityName)?.docPath);
|
|
2610
|
+
refuseMemberPolicy(resolved);
|
|
2611
|
+
return resolved;
|
|
2612
|
+
};
|
|
2613
|
+
const profileSourceOf = (options) => (options?.profile !== undefined ? 'call'
|
|
2614
|
+
: (storeProfile === null ? null : 'store'));
|
|
2615
|
+
|
|
2616
|
+
/** The graph cursor over one built load: one root row per pull. */
|
|
2617
|
+
const openCursor = (entry, signal, register, deadline = undefined, cursorFactory = createCursor) => {
|
|
2618
|
+
const params = entry.slots.map((slot) => slot.literal);
|
|
2619
|
+
const each = register === undefined ? (doc) => doc : (doc) => register(entry.tree, doc);
|
|
2620
|
+
let pulled = 0;
|
|
2621
|
+
// prepared by the first pull, never at construction (MODEL-FORMAT
|
|
2622
|
+
// §5.1), and a statement of this cursor's own: two live iterators
|
|
2623
|
+
// over one cached statement invalidate each other at the driver
|
|
2624
|
+
return cursorFactory({ ...rowClassOf(connection), signal, deadline, now: state.now, wrap: driverWrap,
|
|
2625
|
+
open: () => chain(connection.prepare(entry.sql, { readOnly: true, ephemeral: true }), (statement) => statement.iterate(params)),
|
|
2626
|
+
items: (row) => [each(checkRoot(entry, parseGraphRow(entry.tree, row, '__doc'), ++pulled))] });
|
|
2627
|
+
};
|
|
2628
|
+
|
|
2629
|
+
/**
|
|
2630
|
+
* The continuation one root emits: unsigned, structural, opaque —
|
|
2631
|
+
* the ordering's identity (so it cannot be replayed against another
|
|
2632
|
+
* ordering), the declared order-key values as the DOCUMENT carries
|
|
2633
|
+
* them, and the row's primary key, the tie-breaker. Signing, tenant
|
|
2634
|
+
* scoping, expiry and wire encoding are the host's: the store has no
|
|
2635
|
+
* principal and no key, and a signature it invented would be theatre.
|
|
2636
|
+
*/
|
|
2637
|
+
const continuationOf = (entry, doc) => deepFreeze({
|
|
2638
|
+
order: entry.identity,
|
|
2639
|
+
keys: entry.declared.map((column) => doc[column] ?? null),
|
|
2640
|
+
key: entry.keyColumns.length === 1
|
|
2641
|
+
? doc[entry.keyColumns[0]]
|
|
2642
|
+
: Object.fromEntries(entry.keyColumns.map((column) => [column, doc[column]])),
|
|
2643
|
+
});
|
|
2644
|
+
|
|
2645
|
+
const surface = {
|
|
1315
2646
|
treeFor(spec) {
|
|
1316
2647
|
return buildLoad(spec).tree;
|
|
1317
2648
|
},
|
|
1318
|
-
load(spec) {
|
|
1319
|
-
|
|
1320
|
-
|
|
2649
|
+
load(spec, options = undefined) {
|
|
2650
|
+
requireCallable(options, state.now);
|
|
2651
|
+
const entry = buildLoad(spec, false, profileOf(options));
|
|
2652
|
+
if (entry.statement === null) entry.statement = connection.prepare(entry.sql, { readOnly: true });
|
|
1321
2653
|
const params = entry.slots.map((slot) => slot.literal);
|
|
1322
2654
|
return chain(entry.statement, (statement) =>
|
|
1323
2655
|
chain(statement.all(params), (rows) =>
|
|
1324
|
-
rows.map((row) => parseGraphRow(entry.tree, row, '__doc'))));
|
|
2656
|
+
rows.map((row, i) => checkRoot(entry, parseGraphRow(entry.tree, row, '__doc'), i + 1))));
|
|
1325
2657
|
},
|
|
1326
|
-
|
|
1327
|
-
|
|
2658
|
+
/**
|
|
2659
|
+
* The graph cursor: ONE root graph per pull, its includes attached
|
|
2660
|
+
* and bounded, from the same one statement `load` runs — the include
|
|
2661
|
+
* rows ride inside each root row as the JSON the database projected,
|
|
2662
|
+
* so the window is the row itself and no second statement per level
|
|
2663
|
+
* exists to hold or release. `register`, when given, is the unit of
|
|
2664
|
+
* work's graph registration, applied per root as it is yielded.
|
|
2665
|
+
* @param {any} spec
|
|
2666
|
+
* @param {{ signal?: AbortSignal }} [options]
|
|
2667
|
+
* @param {((tree: any, doc: any) => any) | undefined} [register]
|
|
2668
|
+
*/
|
|
2669
|
+
loadCursor(spec, options = undefined, register = undefined, cursorFactory = createCursor) {
|
|
2670
|
+
requireCallable(options, state.now);
|
|
2671
|
+
return openCursor(buildLoad(spec, false, profileOf(options)), options?.signal, register,
|
|
2672
|
+
options?.deadline, cursorFactory);
|
|
2673
|
+
},
|
|
2674
|
+
/**
|
|
2675
|
+
* One page: a bounded drain of the graph cursor in keyset mode —
|
|
2676
|
+
* `limit` roots at most, `maxBytes` serialised bytes at most, the
|
|
2677
|
+
* continuation of the last delivered root, `hasMore` by one peek —
|
|
2678
|
+
* plus `snapshot`, true only over an immutable ordering (§10.5).
|
|
2679
|
+
* `consistency: 'snapshot'` over a mutable ordering is refused
|
|
2680
|
+
* (`JD0036`) rather than mislabelled; the default `'live'` reports
|
|
2681
|
+
* the truth either way.
|
|
2682
|
+
* @param {any} spec
|
|
2683
|
+
* @param {{ limit?: number, after?: any, maxBytes?: number | null,
|
|
2684
|
+
* consistency?: 'live' | 'snapshot', signal?: AbortSignal }} [options]
|
|
2685
|
+
* @param {((tree: any, doc: any) => any) | undefined} [register]
|
|
2686
|
+
*/
|
|
2687
|
+
page(spec, options = undefined, register = undefined, cursorFactory = createCursor) {
|
|
2688
|
+
requireCallable(options, state.now);
|
|
2689
|
+
const limit = options?.limit ?? PAGE_LIMIT_DEFAULT;
|
|
2690
|
+
if (!Number.isSafeInteger(limit) || limit < 1)
|
|
2691
|
+
throw refuse('page() limit must be a positive integer', []);
|
|
2692
|
+
const declaredBytes = options?.maxBytes;
|
|
2693
|
+
const maxBytes = declaredBytes === undefined || declaredBytes === null || declaredBytes === Infinity
|
|
2694
|
+
? null : declaredBytes;
|
|
2695
|
+
if (maxBytes !== null && !(Number.isSafeInteger(maxBytes) && maxBytes >= 1))
|
|
2696
|
+
throw refuse('page() maxBytes must be a positive integer, or Infinity for no byte bound', []);
|
|
2697
|
+
const consistency = options?.consistency ?? 'live';
|
|
2698
|
+
if (consistency !== 'live' && consistency !== 'snapshot')
|
|
2699
|
+
throw refuse("page() consistency is 'live' or 'snapshot'", []);
|
|
2700
|
+
if (spec?.take !== undefined || spec?.skip !== undefined)
|
|
2701
|
+
throw refuse('page() windows by its limit and continuation — a take or skip in the spec is refused', []);
|
|
2702
|
+
const after = options?.after ?? spec?.after ?? undefined;
|
|
2703
|
+
const paged = { ...(spec ?? {}), take: limit + 1 };
|
|
2704
|
+
if (after === undefined) delete paged.after;
|
|
2705
|
+
else paged.after = after;
|
|
2706
|
+
const entry = buildLoad(paged, true, profileOf(options));
|
|
2707
|
+
if (consistency === 'snapshot' && entry.snapshot !== true) {
|
|
2708
|
+
throw new DbCompileError('JD0036',
|
|
2709
|
+
`a snapshot page needs an ordering over immutable keys; this graph orders by (${
|
|
2710
|
+
entry.declared.join(', ')}), which a write may change, so it is LIVE pagination — a `
|
|
2711
|
+
+ 'row whose order key changes can move across the cursor. Order by the primary key, '
|
|
2712
|
+
+ "or ask for consistency: 'live' and read snapshot: false",
|
|
2713
|
+
entities.get(entityName)?.docPath);
|
|
2714
|
+
}
|
|
2715
|
+
// the drain peeks one root past the page to decide `hasMore`, so
|
|
2716
|
+
// registration happens on the DELIVERED roots after the drain — a
|
|
2717
|
+
// peeked root the caller never received must not enter the unit
|
|
2718
|
+
// of work
|
|
2719
|
+
const cursor = openCursor(entry, options?.signal, undefined, options?.deadline, cursorFactory);
|
|
2720
|
+
return chain(drainPage(cursor, {
|
|
2721
|
+
limit, maxBytes, after: after ?? null,
|
|
2722
|
+
sizeOf: (doc) => utf8Length(JSON.stringify(doc)),
|
|
2723
|
+
continuationOf: (doc) => continuationOf(entry, doc),
|
|
2724
|
+
}), (page) => ({
|
|
2725
|
+
...page,
|
|
2726
|
+
items: register === undefined ? page.items : page.items.map((doc) => register(entry.tree, doc)),
|
|
2727
|
+
snapshot: entry.snapshot === true,
|
|
2728
|
+
}));
|
|
2729
|
+
},
|
|
2730
|
+
explainLoad(spec, options = undefined) {
|
|
2731
|
+
const entry = buildLoad(spec, spec?.after !== undefined && typeof spec.after === 'object', profileOf(options));
|
|
1328
2732
|
const describe = (node, path) => node.includes.flatMap((include) => [
|
|
1329
2733
|
{ path: [...path, include.name].join('.'), kind: include.kind,
|
|
1330
2734
|
count: include.count === true },
|
|
1331
2735
|
...(include.child === null ? [] : describe(include.child, [...path, include.name])),
|
|
1332
2736
|
]);
|
|
2737
|
+
// the per-root bounds every include runs under (§10.4): `null` is
|
|
2738
|
+
// the unbounded case a caller spelled; a count carries none
|
|
2739
|
+
const bounds = (node, path) => node.includes.flatMap((include) => [
|
|
2740
|
+
...(include.count === true ? [] : [{ path: [...path, include.name].join('.'),
|
|
2741
|
+
maxRows: include.maxRows, maxBytes: include.maxBytes }]),
|
|
2742
|
+
...(include.child === null ? [] : bounds(include.child, [...path, include.name])),
|
|
2743
|
+
]);
|
|
1333
2744
|
return {
|
|
1334
2745
|
sql: entry.sql,
|
|
1335
2746
|
pagination: entry.pagination,
|
|
1336
2747
|
includes: describe(entry.tree, []),
|
|
2748
|
+
bounds: bounds(entry.tree, []),
|
|
2749
|
+
// the effective deterministic order the statement executes
|
|
2750
|
+
// under, in every load mode — a load outside keyset mode orders
|
|
2751
|
+
// by its declared terms and the row identity, and saying so is
|
|
2752
|
+
// not the same as having no order at all
|
|
2753
|
+
order: entry.effective,
|
|
2754
|
+
// the keyset ordering's IDENTITY, the value a continuation
|
|
2755
|
+
// carries and is checked against (`JD0035`) — `null` for a load
|
|
2756
|
+
// that is not in keyset mode, which has no continuation to emit
|
|
2757
|
+
identity: entry.identity,
|
|
2758
|
+
snapshot: entry.snapshot,
|
|
2759
|
+
// a graph load pulls one root row per statement row — where the
|
|
2760
|
+
// binding can hand rows over one at a time
|
|
2761
|
+
...rowClassOf(connection),
|
|
2762
|
+
// the profile that applied and every bound it imposed (D7)
|
|
2763
|
+
budget: budgetOf(entry.profile, profileSourceOf(options), connection.capabilities),
|
|
1337
2764
|
};
|
|
1338
2765
|
},
|
|
1339
2766
|
};
|
|
2767
|
+
// the loader's members answer through the boundary: a driver failure
|
|
2768
|
+
// arrives classified, a coded refusal as it is; the cursors carry the
|
|
2769
|
+
// same wrap
|
|
2770
|
+
return { ...surface,
|
|
2771
|
+
load: bounded(surface.load, driverWrap),
|
|
2772
|
+
syncLoadCursor: (spec, options, register) => surface.loadCursor(spec, options, register, createSyncCursor),
|
|
2773
|
+
syncPage: bounded((spec, options, register) => surface.page(spec, options, register, createSyncCursor), driverWrap),
|
|
2774
|
+
page: bounded(surface.page, driverWrap),
|
|
2775
|
+
explainLoad: bounded(surface.explainLoad, driverWrap) };
|
|
1340
2776
|
}
|
|
1341
2777
|
|
|
1342
2778
|
/**
|