@jarenjs/db 0.46.5 → 0.56.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +133 -17
- package/README.md +270 -36
- package/docs/JOBS-FORMAT.md +24 -8
- package/docs/LIVE-FORMAT.md +139 -7
- package/docs/MIGRATION-FORMAT.md +118 -36
- package/docs/MODEL-FORMAT.md +251 -30
- package/package.json +4 -5
- package/schemas/jaren-migration.draft-07.schema.json +73 -0
- package/schemas/jaren-migration.schema.json +73 -0
- package/src/algebra.js +22 -3
- package/src/capture.js +66 -28
- package/src/cli.js +225 -44
- package/src/ddl.js +23 -3
- package/src/dialect.js +13 -0
- package/src/dialects/sqlite.js +21 -1
- package/src/driver.js +63 -16
- package/src/drivers/wasm.js +1 -0
- package/src/emit-model.js +14 -0
- package/src/emit.js +42 -9
- package/src/entity.js +92 -47
- package/src/errors.js +28 -0
- package/src/index.js +2 -2
- package/src/jobs.js +40 -5
- package/src/live-time.js +605 -0
- package/src/live.js +52 -9
- package/src/migrate.js +397 -191
- package/src/model.js +173 -8
- package/src/plan.js +834 -47
- package/src/query.js +296 -22
- package/src/residual.js +15 -6
- package/src/series.js +349 -0
- package/src/store.js +243 -69
- package/src/tracker.js +173 -48
- package/types/index.d.ts +206 -12
- package/types/node.d.ts +3 -1
- package/types/typed.d.ts +58 -2
- package/types/wasm.d.ts +7 -0
- package/dist/types/algebra.d.ts +0 -199
- 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 -149
- 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 -167
- 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.d.ts +0 -62
- 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 -140
- package/dist/types/profile.d.ts +0 -80
- package/dist/types/query.d.ts +0 -111
- package/dist/types/residual.d.ts +0 -61
- package/dist/types/store.d.ts +0 -53
- 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
|
@@ -39,7 +39,7 @@ import { chain } from './driver.js';
|
|
|
39
39
|
import {
|
|
40
40
|
planQuery, planEntityQuery, entityShape, planEntityPredicate, entityPathRef,
|
|
41
41
|
} from './plan.js';
|
|
42
|
-
import { emitPlan, emitEntityPlan, createEntityPredicateEmitters } from './emit.js';
|
|
42
|
+
import { emitPlan, emitEntityPlan, createEntityPredicateEmitters, UnrepresentablePath } from './emit.js';
|
|
43
43
|
import { selectPlan } from './algebra.js';
|
|
44
44
|
import { compileSetResidual, compileRowResidual, sequenceResult } from './residual.js';
|
|
45
45
|
import { derivedSlotValue, probeBox, probeVector, columnScore } from './derive.js';
|
|
@@ -58,18 +58,52 @@ import {
|
|
|
58
58
|
* residual.
|
|
59
59
|
* @param {number} [bound]
|
|
60
60
|
* @param {{ functions?: any, extensions?: any } | null} [operators]
|
|
61
|
+
* @param {any} [zoneProvider] - D7's injected clock, or absent
|
|
61
62
|
* @returns {any}
|
|
62
63
|
*/
|
|
63
|
-
export function createQueryState(bound = undefined, operators = null
|
|
64
|
+
export function createQueryState(bound = undefined, operators = null,
|
|
65
|
+
zoneProvider = undefined) {
|
|
64
66
|
return {
|
|
65
67
|
cache: createSemanticCache(bound ?? 128),
|
|
66
68
|
counters: { hits: 0, misses: 0, evictions: 0 },
|
|
67
69
|
/** Fragment identity → the SQL function name registered for it. */
|
|
68
70
|
registered: new Map(),
|
|
69
71
|
operators: operators ?? null,
|
|
72
|
+
// D7's injected clock: a named zone is host code a database does
|
|
73
|
+
// not have, so a calendar ladder over one walks in the residual —
|
|
74
|
+
// and the residual is the caller's OWN document, so the frozen spec
|
|
75
|
+
// reaches the kernel unchanged rather than being rebuilt in UTC
|
|
76
|
+
zoneProvider: zoneProvider ?? null,
|
|
70
77
|
};
|
|
71
78
|
}
|
|
72
79
|
|
|
80
|
+
/**
|
|
81
|
+
* The answer for a native selection's items: the engine's result shape
|
|
82
|
+
* (`undefined | item | items`), or — when the document is a chain's
|
|
83
|
+
* element WINDOW, `[<phrase>]` (plan.js, `wrapped`) — the items as the
|
|
84
|
+
* ONE array that constructor yields, never singleton-unwrapped: an empty
|
|
85
|
+
* selection is `[]`, one row is `[row]`. Exactly the engine's answer for
|
|
86
|
+
* the same document, which is what lets a chain's `toArray()` push.
|
|
87
|
+
* @param {any} entry
|
|
88
|
+
* @param {any[]} items
|
|
89
|
+
* @returns {any}
|
|
90
|
+
*/
|
|
91
|
+
function answerOf(entry, items) {
|
|
92
|
+
return entry.planned.wrapped === true ? items : sequenceResult(items);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The same for one aggregate value: `[value]` under the window, and `[]`
|
|
97
|
+
* for an aggregate that answers nothing.
|
|
98
|
+
* @param {any} entry
|
|
99
|
+
* @param {any} value
|
|
100
|
+
* @returns {any}
|
|
101
|
+
*/
|
|
102
|
+
function wrapValue(entry, value) {
|
|
103
|
+
if (entry.planned.wrapped !== true) return value;
|
|
104
|
+
return value === undefined ? [] : [value];
|
|
105
|
+
}
|
|
106
|
+
|
|
73
107
|
/** @param {any} value - a bindable native parameter? */
|
|
74
108
|
function bindable(value) {
|
|
75
109
|
return typeof value === 'string'
|
|
@@ -135,11 +169,16 @@ export function createQueryEngine(context) {
|
|
|
135
169
|
// residual compilation here. `null` when the store opened with no
|
|
136
170
|
// registry — the whole engine is then byte-identical to before.
|
|
137
171
|
const operators = state.operators ?? null;
|
|
172
|
+
const zoneProvider = state.zoneProvider ?? null;
|
|
138
173
|
const dialect = connection.dialect;
|
|
139
174
|
const shape = {
|
|
140
175
|
collection: collection.name,
|
|
141
176
|
schema: collection.schema,
|
|
142
177
|
columnByCanonical: physicalPlan.columnByCanonical,
|
|
178
|
+
// the DECLARED indexes, in their declared column order: a temporal
|
|
179
|
+
// plan is recognized by the index a model already has, so the
|
|
180
|
+
// planner needs to see the list rather than guess a column's role
|
|
181
|
+
indexes: physicalPlan.expected?.indexes ?? [],
|
|
143
182
|
// the R*Tree virtual tables this collection's `bbox` column sets are
|
|
144
183
|
// realized as, by stem — empty under `physical: 'columns'`, and
|
|
145
184
|
// empty on a driver whose build carries no R*Tree module, because
|
|
@@ -163,6 +202,14 @@ export function createQueryEngine(context) {
|
|
|
163
202
|
* a k-nearest query into a full scan that `explain()` still calls
|
|
164
203
|
* `knn`, which is the one thing this mode may not do quietly. */
|
|
165
204
|
const knnStats = { queries: 0, rows: 0, candidates: 0, fullFetches: 0, diverted: 0 };
|
|
205
|
+
/** The temporal counters `stats()` reports, and the ONLY place a
|
|
206
|
+
* candidate or a result count comes from: `explain()` reads the LAST
|
|
207
|
+
* ACTUAL run's numbers off the cache entry rather than estimating
|
|
208
|
+
* any of them. `statements` is what makes the as-of bound checkable —
|
|
209
|
+
* one narrowing fetch per call, whatever the probes number — and
|
|
210
|
+
* `diverted` counts the calls whose native bucket met a group with no
|
|
211
|
+
* instant and handed the whole question back to the engine. */
|
|
212
|
+
const seriesStats = { queries: 0, statements: 0, candidates: 0, results: 0, diverted: 0 };
|
|
166
213
|
/** The by-identities fetch statements, one per batch size. */
|
|
167
214
|
const identityFetch = new Map();
|
|
168
215
|
|
|
@@ -171,13 +218,15 @@ export function createQueryEngine(context) {
|
|
|
171
218
|
const residualCompileOptions = (limits) => {
|
|
172
219
|
const functions = operators?.functions;
|
|
173
220
|
const extensions = operators?.extensions;
|
|
174
|
-
if (limits === undefined && functions === undefined && extensions === undefined
|
|
221
|
+
if (limits === undefined && functions === undefined && extensions === undefined
|
|
222
|
+
&& zoneProvider === null)
|
|
175
223
|
return undefined;
|
|
176
224
|
/** @type {any} */
|
|
177
225
|
const options = {};
|
|
178
226
|
if (limits !== undefined) options.limits = limits;
|
|
179
227
|
if (functions !== undefined) options.functions = functions;
|
|
180
228
|
if (extensions !== undefined) options.extensions = extensions;
|
|
229
|
+
if (zoneProvider !== null) options.zoneProvider = zoneProvider;
|
|
181
230
|
return options;
|
|
182
231
|
};
|
|
183
232
|
|
|
@@ -277,8 +326,26 @@ export function createQueryEngine(context) {
|
|
|
277
326
|
return out;
|
|
278
327
|
};
|
|
279
328
|
|
|
280
|
-
|
|
281
|
-
|
|
329
|
+
let plan = shapePlan(planned.plan ?? selectPlan(collection.name));
|
|
330
|
+
let emitted;
|
|
331
|
+
try {
|
|
332
|
+
emitted = emitPlan(plan, dialect, physical);
|
|
333
|
+
}
|
|
334
|
+
catch (error) {
|
|
335
|
+
if (!(error instanceof UnrepresentablePath)) throw error;
|
|
336
|
+
// a member name the dialect cannot spell: the whole document runs
|
|
337
|
+
// in the set residual, named — and strict mode refuses it by name
|
|
338
|
+
if (strict) {
|
|
339
|
+
throw new DbCompileError('JD0010',
|
|
340
|
+
`strict mode refused a residual: 'path' — ${error.message}`, collection.docPath);
|
|
341
|
+
}
|
|
342
|
+
planned = {
|
|
343
|
+
...planned, plan: null, mode: 'set', rowReturn: null, udfs: [], prefilters: [],
|
|
344
|
+
series: null, reasons: [{ construct: 'path', reason: error.message }, ...planned.reasons],
|
|
345
|
+
};
|
|
346
|
+
plan = shapePlan(selectPlan(collection.name));
|
|
347
|
+
emitted = emitPlan(plan, dialect, physical);
|
|
348
|
+
}
|
|
282
349
|
const externalNames = planned.analysis.externals.map((e) => e.name);
|
|
283
350
|
const limits = profile === null ? undefined : profile.limits;
|
|
284
351
|
const entry = {
|
|
@@ -302,10 +369,13 @@ export function createQueryEngine(context) {
|
|
|
302
369
|
setResidual: null,
|
|
303
370
|
packedResidual: null,
|
|
304
371
|
rowResidual: planned.mode === 'row'
|
|
305
|
-
? compileRowResidual(planned.rowReturn, limits, operators)
|
|
372
|
+
? compileRowResidual(planned.rowReturn, limits, operators, zoneProvider)
|
|
306
373
|
: null,
|
|
307
374
|
fullScanSql: null,
|
|
308
375
|
fullScanShape: () => shapePlan(selectPlan(collection.name)),
|
|
376
|
+
// the LAST actual execution's numbers, never an estimate: `null`
|
|
377
|
+
// until this document has run once
|
|
378
|
+
seriesCounts: null,
|
|
309
379
|
};
|
|
310
380
|
|
|
311
381
|
const sizeBefore = state.cache.size();
|
|
@@ -320,7 +390,8 @@ export function createQueryEngine(context) {
|
|
|
320
390
|
};
|
|
321
391
|
const setResidualOf = (entry, document) => {
|
|
322
392
|
if (entry.setResidual === null)
|
|
323
|
-
entry.setResidual = compileSetResidual(document, entry.residualLimits, operators
|
|
393
|
+
entry.setResidual = compileSetResidual(document, entry.residualLimits, operators,
|
|
394
|
+
zoneProvider);
|
|
324
395
|
return entry.setResidual;
|
|
325
396
|
};
|
|
326
397
|
/** The item-packing variant for cursors: `[document]` packs the
|
|
@@ -397,6 +468,68 @@ export function createQueryEngine(context) {
|
|
|
397
468
|
|
|
398
469
|
const rowsToDocs = (rows) => rows.map((row) => JSON.parse(row.doc));
|
|
399
470
|
|
|
471
|
+
/**
|
|
472
|
+
* The bucket records a native temporal group answers: the ladder's
|
|
473
|
+
* start under the name the document asked for it by, then one member
|
|
474
|
+
* per aggregate with the caller's own word for "no numbers".
|
|
475
|
+
*
|
|
476
|
+
* `null` when a group has no instant at all — a row whose instant
|
|
477
|
+
* member is missing or is not a number groups under SQL `NULL`, and
|
|
478
|
+
* the kernel REFUSES such a row (`JQ2001`). SQL cannot refuse, so the
|
|
479
|
+
* call diverts and the engine answers, exactly as it does everywhere.
|
|
480
|
+
* @param {any} entry
|
|
481
|
+
* @param {any[]} rows
|
|
482
|
+
* @returns {any[] | null}
|
|
483
|
+
*/
|
|
484
|
+
const bucketItems = (entry, rows) => {
|
|
485
|
+
const bucket = entry.plan.bucket;
|
|
486
|
+
const items = [];
|
|
487
|
+
for (const row of rows) {
|
|
488
|
+
const start = row[bucket.as] ?? null;
|
|
489
|
+
if (start === null) return null;
|
|
490
|
+
/** @type {any} */
|
|
491
|
+
const item = { [bucket.as]: start };
|
|
492
|
+
for (const aggregate of bucket.aggregates) {
|
|
493
|
+
const value = row[aggregate.as] ?? null;
|
|
494
|
+
if (value === null && aggregate.empty === 'omit') continue;
|
|
495
|
+
item[aggregate.as] = value === null && aggregate.empty === 'zero' ? 0 : value;
|
|
496
|
+
}
|
|
497
|
+
items.push(item);
|
|
498
|
+
}
|
|
499
|
+
return items;
|
|
500
|
+
};
|
|
501
|
+
|
|
502
|
+
/** How many ITEMS an engine result carries (its own shape rule). */
|
|
503
|
+
const itemCount = (answer) => (answer === undefined ? 0
|
|
504
|
+
: Array.isArray(answer) ? answer.length : 1);
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* A native bucket met a group with no instant. The kernel refuses
|
|
508
|
+
* such a row, and SQL cannot, so the whole question goes back to the
|
|
509
|
+
* engine over the whole collection — the same diversion a k-nearest
|
|
510
|
+
* probe of the wrong width takes, and counted the same way.
|
|
511
|
+
*/
|
|
512
|
+
const divertBucket = (entry, document, externals) => {
|
|
513
|
+
seriesStats.diverted++;
|
|
514
|
+
return chain(fullScanOf(entry), (statement) =>
|
|
515
|
+
chain(statement.all(fullScanParams(entry)), (rows) => {
|
|
516
|
+
const docs = rowsToDocs(checkRowBound(entry, rows));
|
|
517
|
+
const answer = setResidualOf(entry, document)(docs, externals);
|
|
518
|
+
countSeries(entry, 2, docs.length, itemCount(answer));
|
|
519
|
+
return answer;
|
|
520
|
+
}));
|
|
521
|
+
};
|
|
522
|
+
|
|
523
|
+
/** Record one actual execution against the entry and the store. */
|
|
524
|
+
const countSeries = (entry, statements, candidates, results) => {
|
|
525
|
+
if (entry.planned.series === null) return;
|
|
526
|
+
seriesStats.queries++;
|
|
527
|
+
seriesStats.statements += statements;
|
|
528
|
+
seriesStats.candidates += candidates;
|
|
529
|
+
seriesStats.results += results;
|
|
530
|
+
entry.seriesCounts = { statements, candidates, results };
|
|
531
|
+
};
|
|
532
|
+
|
|
400
533
|
/**
|
|
401
534
|
* The documents of the given row identities, in identity order,
|
|
402
535
|
* through the dialect's by-identities statement — batched, and each
|
|
@@ -461,6 +594,12 @@ export function createQueryEngine(context) {
|
|
|
461
594
|
*/
|
|
462
595
|
const candidatesOf = (entry, externals, diverted) => {
|
|
463
596
|
if (diverted) {
|
|
597
|
+
// a diversion IS a full-table scan; the plan-shape check above
|
|
598
|
+
// only ever saw the native statement
|
|
599
|
+
if (entry.needsScanCheck) {
|
|
600
|
+
throw profileRefusal(`the profile refuses a full-table scan of '${collection.name}' `
|
|
601
|
+
+ '(a bound external the database cannot take diverted the call to the whole collection)');
|
|
602
|
+
}
|
|
464
603
|
if (entry.planned.mode === 'knn') knnStats.diverted++;
|
|
465
604
|
return chain(fullScanOf(entry), (statement) =>
|
|
466
605
|
chain(statement.all(fullScanParams(entry)), (rows) =>
|
|
@@ -512,8 +651,11 @@ export function createQueryEngine(context) {
|
|
|
512
651
|
// the k-nearest cut — and the full document over them, which
|
|
513
652
|
// re-applies its own predicates and ordering (idempotent
|
|
514
653
|
// narrowing: the fetch decided nothing)
|
|
515
|
-
return chain(candidatesOf(entry, externals, diverted), (docs) =>
|
|
516
|
-
setResidualOf(entry, document)(docs, externals)
|
|
654
|
+
return chain(candidatesOf(entry, externals, diverted), (docs) => {
|
|
655
|
+
const answer = setResidualOf(entry, document)(docs, externals);
|
|
656
|
+
countSeries(entry, 1, docs.length, itemCount(answer));
|
|
657
|
+
return answer;
|
|
658
|
+
});
|
|
517
659
|
}
|
|
518
660
|
if (entry.planned.mode === 'row') {
|
|
519
661
|
return chain(statementOf(entry), (statement) =>
|
|
@@ -521,16 +663,27 @@ export function createQueryEngine(context) {
|
|
|
521
663
|
const items = [];
|
|
522
664
|
for (const row of checkRowBound(entry, rows))
|
|
523
665
|
items.push(...entry.rowResidual(JSON.parse(row.doc), externals));
|
|
524
|
-
return
|
|
666
|
+
return answerOf(entry, items);
|
|
525
667
|
}));
|
|
526
668
|
}
|
|
527
669
|
return chain(statementOf(entry), (statement) => {
|
|
528
670
|
if (entry.plan.aggregate !== null) {
|
|
529
671
|
return chain(statement.get(bindParams(entry, externals)),
|
|
530
|
-
(row) => aggregateResult(entry, row));
|
|
672
|
+
(row) => wrapValue(entry, aggregateResult(entry, row)));
|
|
673
|
+
}
|
|
674
|
+
if (entry.plan.bucket !== null) {
|
|
675
|
+
return chain(statement.all(bindParams(entry, externals)), (rows) => {
|
|
676
|
+
const items = bucketItems(entry, checkRowBound(entry, rows));
|
|
677
|
+
if (items === null) return divertBucket(entry, document, externals);
|
|
678
|
+
countSeries(entry, 1, rows.length, items.length);
|
|
679
|
+
return answerOf(entry, items);
|
|
680
|
+
});
|
|
531
681
|
}
|
|
532
|
-
return chain(statement.all(bindParams(entry, externals)),
|
|
533
|
-
|
|
682
|
+
return chain(statement.all(bindParams(entry, externals)), (rows) => {
|
|
683
|
+
const docs = rowsToDocs(checkRowBound(entry, rows));
|
|
684
|
+
countSeries(entry, 1, docs.length, docs.length);
|
|
685
|
+
return answerOf(entry, docs);
|
|
686
|
+
});
|
|
534
687
|
});
|
|
535
688
|
});
|
|
536
689
|
};
|
|
@@ -562,6 +715,21 @@ export function createQueryEngine(context) {
|
|
|
562
715
|
if (done) return Promise.resolve({ done: true, value: undefined });
|
|
563
716
|
if (bufferedAt < buffered.length) return Promise.resolve(nextFromBuffer());
|
|
564
717
|
|
|
718
|
+
if (entry.planned.wrapped === true) {
|
|
719
|
+
// a chain's element window is ONE item — the array — whatever
|
|
720
|
+
// the plan mode; the cursor hands it over as `execute` answers it
|
|
721
|
+
if (materialized === null) {
|
|
722
|
+
materialized = Promise.resolve(chain(execute(document, options), (value) => {
|
|
723
|
+
buffered = [value];
|
|
724
|
+
bufferedAt = 0;
|
|
725
|
+
}));
|
|
726
|
+
}
|
|
727
|
+
return materialized.then(() => {
|
|
728
|
+
if (bufferedAt < buffered.length) return nextFromBuffer();
|
|
729
|
+
done = true;
|
|
730
|
+
return { done: true, value: undefined };
|
|
731
|
+
});
|
|
732
|
+
}
|
|
565
733
|
if (entry.planned.mode === 'set' || entry.planned.mode === 'knn'
|
|
566
734
|
|| mustDivert(entry, externals)) {
|
|
567
735
|
// the barrier: materialize candidates, pack the result items
|
|
@@ -571,6 +739,7 @@ export function createQueryEngine(context) {
|
|
|
571
739
|
chain(candidatesOf(entry, externals, diverted), (docs) => {
|
|
572
740
|
buffered = packedResidualOf(entry, document)(docs, externals);
|
|
573
741
|
bufferedAt = 0;
|
|
742
|
+
countSeries(entry, 1, docs.length, buffered.length);
|
|
574
743
|
})));
|
|
575
744
|
}
|
|
576
745
|
return materialized.then(() => {
|
|
@@ -579,6 +748,33 @@ export function createQueryEngine(context) {
|
|
|
579
748
|
return { done: true, value: undefined };
|
|
580
749
|
});
|
|
581
750
|
}
|
|
751
|
+
if (entry.plan.bucket !== null) {
|
|
752
|
+
// a native bucket is a barrier: the groups are the answer
|
|
753
|
+
if (materialized === null) {
|
|
754
|
+
materialized = Promise.resolve(chain(guardScan(entry), () =>
|
|
755
|
+
chain(statementOf(entry), (statement) =>
|
|
756
|
+
chain(statement.all(bindParams(entry, externals)), (rows) => {
|
|
757
|
+
const items = bucketItems(entry, checkRowBound(entry, rows));
|
|
758
|
+
if (items === null) {
|
|
759
|
+
const answer = divertBucket(entry, document, externals);
|
|
760
|
+
return chain(answer, (value) => {
|
|
761
|
+
buffered = value === undefined ? []
|
|
762
|
+
: Array.isArray(value) ? value : [value];
|
|
763
|
+
bufferedAt = 0;
|
|
764
|
+
});
|
|
765
|
+
}
|
|
766
|
+
countSeries(entry, 1, rows.length, items.length);
|
|
767
|
+
buffered = items;
|
|
768
|
+
bufferedAt = 0;
|
|
769
|
+
return null;
|
|
770
|
+
}))));
|
|
771
|
+
}
|
|
772
|
+
return materialized.then(() => {
|
|
773
|
+
if (bufferedAt < buffered.length) return nextFromBuffer();
|
|
774
|
+
done = true;
|
|
775
|
+
return { done: true, value: undefined };
|
|
776
|
+
});
|
|
777
|
+
}
|
|
582
778
|
if (entry.plan.aggregate !== null) {
|
|
583
779
|
// a native aggregate yields exactly one item
|
|
584
780
|
if (materialized === null) {
|
|
@@ -639,7 +835,9 @@ export function createQueryEngine(context) {
|
|
|
639
835
|
* The explanation record: the engine explain shape plus the pushdown
|
|
640
836
|
* facts. `estimatedRows` is deliberately ABSENT — the capability
|
|
641
837
|
* slot is empty on SQLite and no number is fabricated; the
|
|
642
|
-
* database's own plan prose rides in `scanNarrative` instead.
|
|
838
|
+
* database's own plan prose rides in `scanNarrative` instead. So is
|
|
839
|
+
* every count in `series`: they are the LAST ACTUAL execution's, and
|
|
840
|
+
* `null` until this document has run once.
|
|
643
841
|
* @param {any} document
|
|
644
842
|
* @param {{ externals?: any, strict?: boolean }} [options]
|
|
645
843
|
*/
|
|
@@ -668,6 +866,12 @@ export function createQueryEngine(context) {
|
|
|
668
866
|
if (term.ref.column !== null) touchedColumns.add(term.ref.column);
|
|
669
867
|
}
|
|
670
868
|
if (entry.plan.aggregate?.ref?.column) touchedColumns.add(entry.plan.aggregate.ref.column);
|
|
869
|
+
if (entry.plan.bucket !== null) {
|
|
870
|
+
if (entry.plan.bucket.ref.column) touchedColumns.add(entry.plan.bucket.ref.column);
|
|
871
|
+
for (const aggregate of entry.plan.bucket.aggregates) {
|
|
872
|
+
if (aggregate.ref?.column) touchedColumns.add(aggregate.ref.column);
|
|
873
|
+
}
|
|
874
|
+
}
|
|
671
875
|
const indexes = [
|
|
672
876
|
...physicalPlan.expected.indexes
|
|
673
877
|
.filter((index) => index.columns.some((column) => touchedColumns.has(column)))
|
|
@@ -689,11 +893,14 @@ export function createQueryEngine(context) {
|
|
|
689
893
|
return chain(connection.prepare(dialect.explainQuery(entry.sql)), (statement) =>
|
|
690
894
|
chain(statement.all(eqpParams), (rows) => ({
|
|
691
895
|
mode: entry.planned.mode,
|
|
896
|
+
// a chain's element window (`[<phrase>]`): the phrase planned as
|
|
897
|
+
// if bare, its rows answered as the one array item
|
|
898
|
+
wrapped: entry.planned.wrapped === true,
|
|
692
899
|
externals: [...entry.externalNames],
|
|
693
900
|
operators: [...entry.dependencies.operators],
|
|
694
901
|
functions: [...entry.dependencies.functions],
|
|
695
902
|
collations: [...entry.dependencies.collations],
|
|
696
|
-
limits: entry.limits,
|
|
903
|
+
limits: entry.residualLimits ?? entry.limits,
|
|
697
904
|
sql: entry.sql,
|
|
698
905
|
params,
|
|
699
906
|
indexes,
|
|
@@ -718,16 +925,27 @@ export function createQueryEngine(context) {
|
|
|
718
925
|
? entry.planned.reasons.map((r) => ({ operator: r.construct, reason: r.reason }))
|
|
719
926
|
: [],
|
|
720
927
|
udfs: [...entry.planned.udfs],
|
|
928
|
+
// the temporal record: what the document asked, which declared
|
|
929
|
+
// index the fetch seeks through, and which kernel finished it.
|
|
930
|
+
// The counts are the LAST ACTUAL execution's — `null` before
|
|
931
|
+
// this document has run — because an estimate mislabelled as a
|
|
932
|
+
// count is exactly the thing an honest explain may not print
|
|
933
|
+
series: entry.planned.series === null ? null : {
|
|
934
|
+
...entry.planned.series,
|
|
935
|
+
counts: entry.seriesCounts === null ? null : { ...entry.seriesCounts },
|
|
936
|
+
},
|
|
721
937
|
scanNarrative: rows.map((row) => String(row.detail)).join('; '),
|
|
722
938
|
})));
|
|
723
939
|
};
|
|
724
940
|
|
|
725
|
-
return { execute, query, explain, shape,
|
|
941
|
+
return { execute, query, explain, shape,
|
|
942
|
+
stats: () => ({ knn: { ...knnStats }, series: { ...seriesStats } }) };
|
|
726
943
|
}
|
|
727
944
|
|
|
728
945
|
// ————— The entity query surface (the second document kind) —————
|
|
729
946
|
|
|
730
947
|
import { mergeEntityRow, parseGraphRow } from './graph.js';
|
|
948
|
+
import { relationTables } from './model.js';
|
|
731
949
|
|
|
732
950
|
/** The default include depth bound (D14: printed, never silent). */
|
|
733
951
|
export const INCLUDE_DEPTH_DEFAULT = 3;
|
|
@@ -745,9 +963,14 @@ export const INCLUDE_DEPTH_DEFAULT = 3;
|
|
|
745
963
|
export function createEntityQueryEngine(context) {
|
|
746
964
|
const { connection, entities, mapping, state } = context;
|
|
747
965
|
const operators = state.operators ?? null;
|
|
966
|
+
const zoneProvider = state.zoneProvider ?? null;
|
|
748
967
|
const dialect = connection.dialect;
|
|
749
968
|
const q = dialect.quoteIdentifier;
|
|
750
969
|
const physicalOf = (name) => ({ table: mapping.entities[name].table });
|
|
970
|
+
// the relation tables of every root this engine serves (§10.1): the
|
|
971
|
+
// engine is the scope every entity set of the store shares, so a
|
|
972
|
+
// producer holding one set can follow a hop into another root
|
|
973
|
+
const relations = relationTables(entities);
|
|
751
974
|
|
|
752
975
|
const entryFor = (document, pushdown) => {
|
|
753
976
|
const key = ['E', document, dialect.name, pushdown];
|
|
@@ -758,6 +981,14 @@ export function createEntityQueryEngine(context) {
|
|
|
758
981
|
}
|
|
759
982
|
state.counters.misses++;
|
|
760
983
|
let planned = planEntityQuery(document, entities, mapping, operators);
|
|
984
|
+
if (planned.referenced.length === 0) {
|
|
985
|
+
// `$[*]` over the entity MAP answered the rows of every entity,
|
|
986
|
+
// mixed, and explain() named no table read; the root is the map
|
|
987
|
+
// of entity arrays, and a query ranges over one of them by name
|
|
988
|
+
throw new DbCompileError('JD0033',
|
|
989
|
+
'an entity query ranges over a declared entity array ($.<Entity>[*]); this '
|
|
990
|
+
+ 'document names none, so it has no rows to answer', '/entities');
|
|
991
|
+
}
|
|
761
992
|
if (!pushdown) {
|
|
762
993
|
planned = { ...planned, mode: 'set', plan: null,
|
|
763
994
|
reasons: [{ construct: 'pushdown', reason: 'disabled by the harness switch' }] };
|
|
@@ -810,7 +1041,7 @@ export function createEntityQueryEngine(context) {
|
|
|
810
1041
|
|
|
811
1042
|
const runResidual = (entry, document, externals) => {
|
|
812
1043
|
if (entry.setResidual === null)
|
|
813
|
-
entry.setResidual = compileSetResidual(document, undefined, operators);
|
|
1044
|
+
entry.setResidual = compileSetResidual(document, undefined, operators, zoneProvider);
|
|
814
1045
|
return chain(fetchRoot(entry), (root) => entry.setResidual(root, externals));
|
|
815
1046
|
};
|
|
816
1047
|
|
|
@@ -837,11 +1068,11 @@ export function createEntityQueryEngine(context) {
|
|
|
837
1068
|
if (entry.statement === null) entry.statement = connection.prepare(entry.sql);
|
|
838
1069
|
return chain(entry.statement, (statement) => {
|
|
839
1070
|
if (entry.planned.plan.aggregate === 'count')
|
|
840
|
-
return chain(statement.get(params), (row) => row?.value ?? 0);
|
|
1071
|
+
return chain(statement.get(params), (row) => wrapValue(entry, row?.value ?? 0));
|
|
841
1072
|
return chain(statement.all(params), (rows) => {
|
|
842
1073
|
const retEntity = entry.planned.plan.bindings
|
|
843
1074
|
.find((binding) => binding.name === entry.planned.plan.ret).entity;
|
|
844
|
-
return
|
|
1075
|
+
return answerOf(entry, rows.map((row) =>
|
|
845
1076
|
mergeEntityRow(mapping.entities[retEntity], row, '__doc')));
|
|
846
1077
|
});
|
|
847
1078
|
});
|
|
@@ -852,6 +1083,7 @@ export function createEntityQueryEngine(context) {
|
|
|
852
1083
|
const entry = entryFor(document, pushdown);
|
|
853
1084
|
const base = {
|
|
854
1085
|
mode: entry.planned.mode,
|
|
1086
|
+
wrapped: entry.planned.wrapped === true,
|
|
855
1087
|
referenced: [...entry.planned.referenced],
|
|
856
1088
|
reasons: entry.planned.reasons,
|
|
857
1089
|
sql: entry.sql,
|
|
@@ -869,7 +1101,7 @@ export function createEntityQueryEngine(context) {
|
|
|
869
1101
|
})));
|
|
870
1102
|
};
|
|
871
1103
|
|
|
872
|
-
return { execute, explain };
|
|
1104
|
+
return { execute, explain, relations };
|
|
873
1105
|
}
|
|
874
1106
|
|
|
875
1107
|
/**
|
|
@@ -899,6 +1131,11 @@ export function createLoadEngine(context, entityName) {
|
|
|
899
1131
|
const refuse = (reason, path) => new DbCompileError('JD0032',
|
|
900
1132
|
`${reason} (include path: ${path.join('.') || '<root>'})`,
|
|
901
1133
|
entities.get(entityName)?.docPath);
|
|
1134
|
+
const isWindowBound = (value) => Number.isSafeInteger(value) && value >= 0;
|
|
1135
|
+
/** An include's window inside its subquery: LIMIT, and OFFSET for a
|
|
1136
|
+
* `skip` — per parent row, since the subquery is correlated (§10.4). */
|
|
1137
|
+
const windowClause = (child) => (child.take !== undefined || (child.skip !== undefined && child.skip > 0)
|
|
1138
|
+
? ` ${dialect.limitClause(child.take ?? null, child.skip)}` : '');
|
|
902
1139
|
|
|
903
1140
|
/** Compile a where EXPRESSION over `$it` against one entity. */
|
|
904
1141
|
const compileWhere = (expression, entity, path) => {
|
|
@@ -969,6 +1206,7 @@ export function createLoadEngine(context, entityName) {
|
|
|
969
1206
|
where: spec?.where !== undefined ? compileWhere(spec.where, entity, path) : null,
|
|
970
1207
|
order: spec?.orderBy !== undefined ? compileOrder(spec.orderBy, entity, path) : null,
|
|
971
1208
|
take: spec?.take,
|
|
1209
|
+
skip: spec?.skip,
|
|
972
1210
|
includes: [],
|
|
973
1211
|
};
|
|
974
1212
|
const includeSpec = spec?.include;
|
|
@@ -984,6 +1222,26 @@ export function createLoadEngine(context, entityName) {
|
|
|
984
1222
|
[...path, relationName]);
|
|
985
1223
|
}
|
|
986
1224
|
const childSpec = includeSpec[relationName] === true ? {} : includeSpec[relationName];
|
|
1225
|
+
if (childSpec.count === true) {
|
|
1226
|
+
// a count counts EVERY related row; a where/take beside it was
|
|
1227
|
+
// dropped without a word, and the number answered was the total
|
|
1228
|
+
const dropped = ['where', 'orderBy', 'take', 'skip', 'include', 'after']
|
|
1229
|
+
.filter((member) => childSpec[member] !== undefined);
|
|
1230
|
+
if (dropped.length > 0) {
|
|
1231
|
+
throw refuse(`count: true counts every related row and takes no ${dropped.join('/')} — `
|
|
1232
|
+
+ 'load the rows to count a subset', [...path, relationName]);
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
for (const member of ['take', 'skip']) {
|
|
1236
|
+
if (childSpec[member] !== undefined && !isWindowBound(childSpec[member]))
|
|
1237
|
+
throw refuse(`${member} must be a non-negative integer`, [...path, relationName]);
|
|
1238
|
+
}
|
|
1239
|
+
// a keyset cursor is one position in ONE ordered set; an include is
|
|
1240
|
+
// a set per parent, so it windows with skip/take and never seeks
|
|
1241
|
+
if (childSpec.after !== undefined) {
|
|
1242
|
+
throw refuse("'after' (keyset pagination) paginates the root — an include windows with skip and take",
|
|
1243
|
+
[...path, relationName]);
|
|
1244
|
+
}
|
|
987
1245
|
const childName = relation.to;
|
|
988
1246
|
const include = {
|
|
989
1247
|
name: relationName,
|
|
@@ -1037,6 +1295,17 @@ export function createLoadEngine(context, entityName) {
|
|
|
1037
1295
|
const parentKey = parentNode.entityMapping.keys[0];
|
|
1038
1296
|
if (include.count === true) {
|
|
1039
1297
|
const childTable = mapping.entities[relation.to].table;
|
|
1298
|
+
if (relation.kind === 'manyToMany') {
|
|
1299
|
+
const join = mapping.joinTables[relation.joinTable];
|
|
1300
|
+
const own = join.left.entity === parentNode.entity.name ? join.left : join.right;
|
|
1301
|
+
return `(SELECT COUNT(*) FROM ${q(relation.joinTable)} AS ${q(childAlias)} `
|
|
1302
|
+
+ `WHERE ${q(childAlias)}.${q(own.column)} = ${q(parentAlias)}.${q(parentKey)})`;
|
|
1303
|
+
}
|
|
1304
|
+
if (relation.kind === 'oneToOne') {
|
|
1305
|
+
const childKey = mapping.entities[relation.to].keys[0];
|
|
1306
|
+
return `(SELECT COUNT(*) FROM ${q(childTable)} AS ${q(childAlias)} `
|
|
1307
|
+
+ `WHERE ${q(childAlias)}.${q(childKey)} = ${q(parentAlias)}.${q(relation.via)})`;
|
|
1308
|
+
}
|
|
1040
1309
|
return `(SELECT COUNT(*) FROM ${q(childTable)} AS ${q(childAlias)} `
|
|
1041
1310
|
+ `WHERE ${q(childAlias)}.${q(relation.via)} = ${q(parentAlias)}.${q(parentKey)})`;
|
|
1042
1311
|
}
|
|
@@ -1071,10 +1340,10 @@ export function createLoadEngine(context, entityName) {
|
|
|
1071
1340
|
+ (child.where !== null
|
|
1072
1341
|
? ` AND ${emitters.emitPred(rendered.aliasSql, rendered.docSql, child.where)}` : '')
|
|
1073
1342
|
+ ` ORDER BY ${orderSql.join(', ')}`
|
|
1074
|
-
+ (child
|
|
1343
|
+
+ windowClause(child)
|
|
1075
1344
|
: `SELECT ${rendered.aliasSql}.* FROM ${q(childTable)} AS ${q(childAlias)} `
|
|
1076
1345
|
+ `WHERE ${conditions.join(' AND ')} ORDER BY ${orderSql.join(', ')}`
|
|
1077
|
-
+ (child
|
|
1346
|
+
+ windowClause(child);
|
|
1078
1347
|
if (relation.kind === 'oneToOne') {
|
|
1079
1348
|
return `(SELECT json_object(${rendered.projection()}) FROM `
|
|
1080
1349
|
+ `(${inner} ${dialect.limitClause(1, undefined)}) AS ${q(childAlias)})`;
|
|
@@ -1101,6 +1370,11 @@ export function createLoadEngine(context, entityName) {
|
|
|
1101
1370
|
};
|
|
1102
1371
|
const emitters = createEntityPredicateEmitters(dialect, param);
|
|
1103
1372
|
const maxDepth = spec?.maxDepth ?? INCLUDE_DEPTH_DEFAULT;
|
|
1373
|
+
for (const member of ['take', 'skip']) {
|
|
1374
|
+
// interpolated into LIMIT/OFFSET as written: a string ran as SQL
|
|
1375
|
+
if (spec?.[member] !== undefined && !isWindowBound(spec[member]))
|
|
1376
|
+
throw refuse(`${member} must be a non-negative integer`, []);
|
|
1377
|
+
}
|
|
1104
1378
|
const tree = buildTree(entityName, spec ?? {}, 0, maxDepth, [], new Set());
|
|
1105
1379
|
const rendered = render(tree, 'r', param, emitters);
|
|
1106
1380
|
|
package/src/residual.js
CHANGED
|
@@ -36,16 +36,22 @@ import { compileJsonQuery } from '@jarenjs/json/query';
|
|
|
36
36
|
* @param {{ functions?: any, extensions?: any } | null} [operators]
|
|
37
37
|
* @returns {any}
|
|
38
38
|
*/
|
|
39
|
-
function residualOptions(limits, operators) {
|
|
39
|
+
function residualOptions(limits, operators, zoneProvider) {
|
|
40
40
|
const functions = operators?.functions;
|
|
41
41
|
const extensions = operators?.extensions;
|
|
42
|
-
|
|
42
|
+
const clock = zoneProvider ?? null;
|
|
43
|
+
if (limits === undefined && functions === undefined && extensions === undefined
|
|
44
|
+
&& clock === null)
|
|
43
45
|
return undefined;
|
|
44
46
|
/** @type {any} */
|
|
45
47
|
const options = {};
|
|
46
48
|
if (limits !== undefined) options.limits = limits;
|
|
47
49
|
if (functions !== undefined) options.functions = functions;
|
|
48
50
|
if (extensions !== undefined) options.extensions = extensions;
|
|
51
|
+
// D7's injected clock: without it a named zone is a compile refusal,
|
|
52
|
+
// which is what the language wants — being right for eight months of
|
|
53
|
+
// the year is exactly what a silent UTC fallback would be
|
|
54
|
+
if (clock !== null) options.zoneProvider = clock;
|
|
49
55
|
return options;
|
|
50
56
|
}
|
|
51
57
|
|
|
@@ -57,10 +63,12 @@ function residualOptions(limits, operators) {
|
|
|
57
63
|
* @param {any} [limits]
|
|
58
64
|
* @param {{ functions?: any, extensions?: any } | null} [operators] -
|
|
59
65
|
* the store's registered operators, so the residual can evaluate them
|
|
66
|
+
* @param {any} [zoneProvider] - D7's injected clock, so a calendar
|
|
67
|
+
* ladder on a named zone compiles rather than being refused
|
|
60
68
|
* @returns {(candidates: any[], externals: any) => any}
|
|
61
69
|
*/
|
|
62
|
-
export function compileSetResidual(document, limits, operators) {
|
|
63
|
-
const compiled = compileJsonQuery(document, residualOptions(limits, operators));
|
|
70
|
+
export function compileSetResidual(document, limits, operators, zoneProvider) {
|
|
71
|
+
const compiled = compileJsonQuery(document, residualOptions(limits, operators, zoneProvider));
|
|
64
72
|
return (candidates, externals) => compiled(candidates, externals);
|
|
65
73
|
}
|
|
66
74
|
|
|
@@ -73,10 +81,11 @@ export function compileSetResidual(document, limits, operators) {
|
|
|
73
81
|
* the only place that knows the name.
|
|
74
82
|
* @param {any} [limits]
|
|
75
83
|
* @param {{ functions?: any, extensions?: any } | null} [operators]
|
|
84
|
+
* @param {any} [zoneProvider] - D7's injected clock
|
|
76
85
|
* @returns {(row: any, externals: any) => any[]} the row's items
|
|
77
86
|
*/
|
|
78
|
-
export function compileRowResidual(rowDocument, limits, operators) {
|
|
79
|
-
const compiled = compileJsonQuery(rowDocument, residualOptions(limits, operators));
|
|
87
|
+
export function compileRowResidual(rowDocument, limits, operators, zoneProvider) {
|
|
88
|
+
const compiled = compileJsonQuery(rowDocument, residualOptions(limits, operators, zoneProvider));
|
|
80
89
|
return (row, externals) => {
|
|
81
90
|
const packed = compiled([row], externals);
|
|
82
91
|
// one binding → exactly one packed array of that row's items
|