@jarenjs/db 0.34.2 → 0.43.3
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 +115 -12
- package/README.md +47 -0
- package/dist/types/algebra.d.ts +38 -2
- package/dist/types/ddl.d.ts +40 -6
- package/dist/types/derive.d.ts +161 -0
- package/dist/types/dialect.d.ts +11 -2
- package/dist/types/driver.d.ts +2 -20
- package/dist/types/emit.d.ts +6 -3
- package/dist/types/errors.d.ts +6 -4
- package/dist/types/index.d.ts +1 -0
- package/dist/types/migrate.d.ts +7 -1
- package/dist/types/plan.d.ts +15 -1
- package/dist/types/residual.d.ts +17 -6
- package/dist/types/udf.d.ts +6 -1
- package/docs/MIGRATION-FORMAT.md +30 -1
- package/docs/MODEL-FORMAT.md +155 -4
- package/package.json +4 -4
- package/schemas/jaren-migration.draft-07.schema.json +71 -0
- package/schemas/jaren-migration.schema.json +71 -0
- package/schemas/jaren-model.draft-07.schema.json +14 -1
- package/schemas/jaren-model.schema.json +18 -5
- package/src/algebra.js +17 -2
- package/src/ddl.js +146 -17
- package/src/derive.js +284 -0
- package/src/dialect.js +89 -25
- package/src/dialects/sqlite.js +16 -1
- package/src/driver.js +6 -28
- package/src/emit.js +122 -22
- package/src/errors.js +6 -4
- package/src/index.js +5 -0
- package/src/migrate.js +132 -19
- package/src/plan.js +514 -32
- package/src/query.js +61 -9
- package/src/residual.js +18 -10
- package/src/store.js +122 -8
- package/src/udf.js +12 -3
package/src/plan.js
CHANGED
|
@@ -11,16 +11,24 @@
|
|
|
11
11
|
*
|
|
12
12
|
* The outcome of planning one document:
|
|
13
13
|
*
|
|
14
|
-
* { plan, mode: 'native' | 'row' | 'set', reasons, rowReturn
|
|
14
|
+
* { plan, mode: 'native' | 'row' | 'set', reasons, rowReturn,
|
|
15
|
+
* prefilters }
|
|
15
16
|
*
|
|
16
17
|
* - `native` — everything translated; the plan alone answers.
|
|
17
18
|
* - `row` — predicates, ordering and window pushed; only the
|
|
18
19
|
* projection runs in the engine, per fetched row (streams).
|
|
20
|
+
* `rowReturn` is the COMPLETE one-row document to run, binding
|
|
21
|
+
* included — the collection binding is named by the document, so a
|
|
22
|
+
* wrapper built anywhere else would have to guess it.
|
|
19
23
|
* - `set` — the pushed conjuncts narrow candidates; the WHOLE
|
|
20
24
|
* compiled document runs over the materialized candidates.
|
|
21
25
|
*
|
|
22
26
|
* `reasons` names every construct that forced work off the database,
|
|
23
27
|
* with reason text drawn from the deliberate-residual table.
|
|
28
|
+
* `prefilters` names the IMPLIED conjuncts — predicates the planner
|
|
29
|
+
* ADDED because a spatial one provably implies them (see "Spatial
|
|
30
|
+
* promotions" below) — with the columns each reads and whether it
|
|
31
|
+
* decided or merely narrowed.
|
|
24
32
|
*/
|
|
25
33
|
|
|
26
34
|
import { analyzeQuery, AST_VERSION, NODE_KINDS } from '@jarenjs/json/query';
|
|
@@ -31,6 +39,11 @@ import {
|
|
|
31
39
|
|
|
32
40
|
import { selectPlan, conjoin, PLAN_VERSION } from './algebra.js';
|
|
33
41
|
import { typeOfPath, isNumericType } from './types.js';
|
|
42
|
+
import { schemaNodeAt } from './ddl.js';
|
|
43
|
+
import {
|
|
44
|
+
BBOX_COMPONENTS, BBOX_INDEX_ORDER, PRECISION_MIN, PRECISION_MAX,
|
|
45
|
+
probeBox, probePosition, probeCircleBox, cellNeighbourhood,
|
|
46
|
+
} from './derive.js';
|
|
34
47
|
|
|
35
48
|
/** Comparison operator names → plan ops. */
|
|
36
49
|
const COMPARISONS = new Map([
|
|
@@ -104,6 +117,15 @@ function refusal(construct, reason) {
|
|
|
104
117
|
return { construct, reason };
|
|
105
118
|
}
|
|
106
119
|
|
|
120
|
+
/**
|
|
121
|
+
* A translated predicate that DECIDES: no pre-filter, nothing left for
|
|
122
|
+
* a residual to refine.
|
|
123
|
+
* @param {import('./algebra.js').PlanPredicate} pred
|
|
124
|
+
*/
|
|
125
|
+
function exactly(pred) {
|
|
126
|
+
return { pred, exact: true, prefilters: [], refinements: [] };
|
|
127
|
+
}
|
|
128
|
+
|
|
107
129
|
// ————— Registered operators (Ring 2) —————
|
|
108
130
|
//
|
|
109
131
|
// A store may open with a registry (createJsltRegistry()) whose
|
|
@@ -213,13 +235,15 @@ function isItVar(node, itSlot) {
|
|
|
213
235
|
}
|
|
214
236
|
|
|
215
237
|
/**
|
|
216
|
-
*
|
|
238
|
+
* The typed segments of a singular member path rooted on the binding,
|
|
239
|
+
* with the canonical spelling the physical mapping keys columns by.
|
|
240
|
+
* `null` when the node is not such a path.
|
|
217
241
|
* @param {any} node
|
|
218
242
|
* @param {number} itSlot
|
|
219
|
-
* @
|
|
220
|
-
*
|
|
243
|
+
* @returns {{ segments: ({ name: string } | { index: number })[],
|
|
244
|
+
* canonical: string } | null}
|
|
221
245
|
*/
|
|
222
|
-
function
|
|
246
|
+
function memberPath(node, itSlot) {
|
|
223
247
|
if (node.kind !== 'path' || node.external === true) return null;
|
|
224
248
|
if (node.rootSlot !== itSlot || node.singular !== true) return null;
|
|
225
249
|
/** @type {({ name: string } | { index: number })[]} */
|
|
@@ -234,10 +258,23 @@ function pathRef(node, itSlot, shape) {
|
|
|
234
258
|
if (segments.length === 0) return null;
|
|
235
259
|
const canonical = segments
|
|
236
260
|
.map((s) => ('name' in s ? `.${s.name}` : `[${s.index}]`)).join('');
|
|
261
|
+
return { segments, canonical };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* A singular member path rooted on the binding → a PlanRef, or null.
|
|
266
|
+
* @param {any} node
|
|
267
|
+
* @param {number} itSlot
|
|
268
|
+
* @param {any} shape - { schema, columnByCanonical }
|
|
269
|
+
* @returns {import('./algebra.js').PlanRef | null}
|
|
270
|
+
*/
|
|
271
|
+
function pathRef(node, itSlot, shape) {
|
|
272
|
+
const path = memberPath(node, itSlot);
|
|
273
|
+
if (path === null) return null;
|
|
237
274
|
return {
|
|
238
|
-
segments,
|
|
239
|
-
type: typeOfPath(shape.schema, segments),
|
|
240
|
-
column: shape.columnByCanonical.get(canonical) ?? null,
|
|
275
|
+
segments: path.segments,
|
|
276
|
+
type: typeOfPath(shape.schema, path.segments),
|
|
277
|
+
column: shape.columnByCanonical.get(path.canonical) ?? null,
|
|
241
278
|
};
|
|
242
279
|
}
|
|
243
280
|
|
|
@@ -258,12 +295,414 @@ function isScalarLiteral(value) {
|
|
|
258
295
|
|| typeof value === 'number' || typeof value === 'boolean';
|
|
259
296
|
}
|
|
260
297
|
|
|
298
|
+
|
|
299
|
+
// ————— Spatial promotions: the implied conjunct —————
|
|
300
|
+
//
|
|
301
|
+
// Every other conjunct the planner pushes is a conjunct OF the document:
|
|
302
|
+
// it translates exactly or it does not. A spatial predicate cannot be
|
|
303
|
+
// translated exactly — there is no `ST_Within` in SQLite and this
|
|
304
|
+
// package does not build one — but it IMPLIES one that can be, over the
|
|
305
|
+
// derived columns a model declares (`indexes[].derive`): a bounding-box
|
|
306
|
+
// overlap, or a geohash-cell range.
|
|
307
|
+
//
|
|
308
|
+
// An IMPLIED conjunct narrows; it never decides. Three properties make
|
|
309
|
+
// that safe, and each is asserted by test:
|
|
310
|
+
//
|
|
311
|
+
// 1. No false negatives — every row the document's predicate keeps
|
|
312
|
+
// passes the implied one. The proof per rule is in ARCHITECTURE.md's
|
|
313
|
+
// truth table. A row with no derived value at all is not an
|
|
314
|
+
// exception: the box is missing in exactly the cases §8.14's
|
|
315
|
+
// representative position is, so the engine answers false for it
|
|
316
|
+
// too. What a pre-filter DOES change is which rows can raise —
|
|
317
|
+
// one it excludes never reaches the engine — and that is why the
|
|
318
|
+
// precondition below is a schema one.
|
|
319
|
+
// 2. Idempotent refinement — the residual re-runs the ORIGINAL
|
|
320
|
+
// predicate over the narrowed candidates, so the answer is the
|
|
321
|
+
// engine's. That is why an implied conjunct forces the SET residual
|
|
322
|
+
// rather than the row one.
|
|
323
|
+
// 3. `strict: true` refuses it — an implied conjunct leaves its own
|
|
324
|
+
// reason behind, so the plan is not native and `JD0010` names it.
|
|
325
|
+
//
|
|
326
|
+
// A promotion reads a member the schema types as an array or an object.
|
|
327
|
+
// That precondition is the string operators' rule again: §8.14 answers
|
|
328
|
+
// `JQ2001` for a non-geographic operand, so on a member the schema does
|
|
329
|
+
// not type as geography a pushed filter could silently answer where the
|
|
330
|
+
// engine would throw.
|
|
331
|
+
|
|
332
|
+
/** The geographic schema types a spatial promotion is allowed over. */
|
|
333
|
+
const GEO_TYPES = new Set(['array', 'object']);
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Does the schema type this member as geography and ONLY as geography?
|
|
337
|
+
* A union of `array` and `object` is the natural declaration for a
|
|
338
|
+
* §8.14 operand (a bare position or a geometry); one that also admits
|
|
339
|
+
* `null` is not, because §8.14 answers `JQ2001` for a `null` operand
|
|
340
|
+
* while a pushed filter would simply not see the row.
|
|
341
|
+
* @param {any} node - a subschema, or `undefined`
|
|
342
|
+
* @returns {boolean}
|
|
343
|
+
*/
|
|
344
|
+
function isGeographicSchema(node) {
|
|
345
|
+
const declared = node?.type;
|
|
346
|
+
if (typeof declared === 'string') return GEO_TYPES.has(declared);
|
|
347
|
+
return Array.isArray(declared) && declared.length > 0
|
|
348
|
+
&& declared.every((type) => GEO_TYPES.has(type));
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** `$geohash`'s default precision (QUERY-FORMAT §8.14). */
|
|
352
|
+
const GEOHASH_DEFAULT_PRECISION = 9;
|
|
353
|
+
|
|
354
|
+
const SPATIAL_REASONS = {
|
|
355
|
+
within: 'a bounding-box pre-filter is pushed; exact containment refines in the engine',
|
|
356
|
+
distance: 'a geodesic-circle box pre-filter is pushed; the exact distance refines in the engine',
|
|
357
|
+
prefix: "a cell-range pre-filter over the derived column's precision is pushed; "
|
|
358
|
+
+ 'the longer prefix refines in the engine',
|
|
359
|
+
noIndex: 'no derived spatial index on this member covers the predicate '
|
|
360
|
+
+ '(declare indexes[].derive on it)',
|
|
361
|
+
notGeographic: 'spatial predicates translate only over a member the schema types as an '
|
|
362
|
+
+ 'array or an object (the engine ERRORS on a non-geographic operand)',
|
|
363
|
+
operand: 'a spatial predicate translates only against a literal value or an external',
|
|
364
|
+
unbounded: 'the probe has no bounding box, so no conservative pre-filter exists',
|
|
365
|
+
pole: 'the circle reaches a pole, where a box has no longitude bound at all — '
|
|
366
|
+
+ 'pushing nothing is correct, pushing a wrong box is not',
|
|
367
|
+
wrapped: 'the circle crosses the antimeridian, and a box that crosses it would need two '
|
|
368
|
+
+ 'disjuncts this suite\'s box convention does not carry — '
|
|
369
|
+
+ 'pushing nothing is correct, pushing a wrong box is not',
|
|
370
|
+
precision: "the cell length must match the derived column's precision",
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* The constant value an AST subtree denotes, or `null` when it denotes
|
|
375
|
+
* anything else. A literal GeoJSON region in a document is NOT a
|
|
376
|
+
* `literal` node — it is the object/array constructor tree the engine
|
|
377
|
+
* builds from the same members — so the planner folds it here to get
|
|
378
|
+
* the value it must compute a probe box from.
|
|
379
|
+
* @param {any} node
|
|
380
|
+
* @returns {{ value: any } | null}
|
|
381
|
+
*/
|
|
382
|
+
function constantOf(node) {
|
|
383
|
+
if (node.kind === 'literal') return { value: node.value };
|
|
384
|
+
if (node.kind === 'array') {
|
|
385
|
+
const value = [];
|
|
386
|
+
for (const element of node.elements) {
|
|
387
|
+
const item = constantOf(element);
|
|
388
|
+
if (item === null) return null;
|
|
389
|
+
value.push(item.value);
|
|
390
|
+
}
|
|
391
|
+
return { value };
|
|
392
|
+
}
|
|
393
|
+
if (node.kind === 'object') {
|
|
394
|
+
/** @type {any} */
|
|
395
|
+
const value = {};
|
|
396
|
+
for (const entry of node.entries) {
|
|
397
|
+
if (typeof entry.name !== 'string') return null;
|
|
398
|
+
const member = constantOf(entry.expr);
|
|
399
|
+
if (member === null) return null;
|
|
400
|
+
value[entry.name] = member.value;
|
|
401
|
+
}
|
|
402
|
+
return { value };
|
|
403
|
+
}
|
|
404
|
+
return null;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* The derived columns one `(member, derivation)` pair maps to, or
|
|
409
|
+
* `null` when the collection declares no such index. The identity is
|
|
410
|
+
* the same one the physical mapping keys its column set by — path,
|
|
411
|
+
* kind AND precision — so a query at a different precision finds no
|
|
412
|
+
* column rather than the wrong one.
|
|
413
|
+
* @param {any} shape
|
|
414
|
+
* @param {string} canonical
|
|
415
|
+
* @param {'geohash' | 'bbox'} derive
|
|
416
|
+
* @param {number} [precision]
|
|
417
|
+
* @returns {string | { w: string, s: string, e: string, n: string } | null}
|
|
418
|
+
*/
|
|
419
|
+
function derivedColumnsOf(shape, canonical, derive, precision) {
|
|
420
|
+
const stem = shape.columnByCanonical?.get(`${canonical}|${derive}|${precision ?? ''}`);
|
|
421
|
+
if (stem === undefined) return null;
|
|
422
|
+
if (derive === 'geohash') return stem;
|
|
423
|
+
/** @type {any} */
|
|
424
|
+
const columns = {};
|
|
425
|
+
for (const component of BBOX_COMPONENTS) columns[`${component}`] = `${stem}_${component}`;
|
|
426
|
+
return columns;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* A spatial SUBJECT: a singular member path on the binding that the
|
|
431
|
+
* schema types as geography. Answers the canonical path a derived
|
|
432
|
+
* column set is looked up by, or a named refusal.
|
|
433
|
+
* @param {any} node
|
|
434
|
+
* @param {number} itSlot
|
|
435
|
+
* @param {any} shape
|
|
436
|
+
* @param {string} construct
|
|
437
|
+
* @returns {{ canonical: string } | { refusal: { construct: string, reason: string } }}
|
|
438
|
+
*/
|
|
439
|
+
function spatialSubject(node, itSlot, shape, construct) {
|
|
440
|
+
const path = memberPath(node, itSlot);
|
|
441
|
+
if (path === null || !isGeographicSchema(schemaNodeAt(shape.schema, path.segments)))
|
|
442
|
+
return { refusal: refusal(construct, SPATIAL_REASONS.notGeographic) };
|
|
443
|
+
return { canonical: path.canonical };
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** A promotion outcome: a pushed predicate that may still need refining. */
|
|
447
|
+
function promotion(pred, prefilter, refinements = []) {
|
|
448
|
+
return { pred, exact: refinements.length === 0, prefilters: [prefilter], refinements };
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** The four bbox columns in the order the declared index covers them. */
|
|
452
|
+
function boxColumnList(columns) {
|
|
453
|
+
return BBOX_INDEX_ORDER.map((component) => columns[component]);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* P1/P2 — a box predicate over a `bbox`-derived index.
|
|
458
|
+
*
|
|
459
|
+
* `$bbox-intersects(path, probe)` is EXACT: the derived columns ARE the
|
|
460
|
+
* row's box, so overlap is fully decidable in SQL. `<=`/`>=` and not
|
|
461
|
+
* `<`/`>` because the kernel counts touching edges as intersecting.
|
|
462
|
+
*
|
|
463
|
+
* `$within(path, area)` is IMPLIED by the same overlap: the subject's
|
|
464
|
+
* representative position lies inside its own box (a bare position IS
|
|
465
|
+
* the box; a centroid is a mean of positions and a mean lies within
|
|
466
|
+
* their min/max), and inside the area's surface implies inside the
|
|
467
|
+
* area's box — so the two boxes share at least that position.
|
|
468
|
+
* @param {any} node
|
|
469
|
+
* @param {number} itSlot
|
|
470
|
+
* @param {any} shape
|
|
471
|
+
* @returns {any}
|
|
472
|
+
*/
|
|
473
|
+
function planBoxPredicate(node, itSlot, shape) {
|
|
474
|
+
const construct = node.name;
|
|
475
|
+
const symmetric = construct === '$bbox-intersects';
|
|
476
|
+
// `$within` asks whether the FIRST value is inside the second, so only
|
|
477
|
+
// the first may be the row; box overlap is symmetric, so either may be
|
|
478
|
+
let subjectAt = 0;
|
|
479
|
+
if (symmetric && memberPath(node.args[0], itSlot) === null
|
|
480
|
+
&& memberPath(node.args[1], itSlot) !== null) subjectAt = 1;
|
|
481
|
+
const subject = spatialSubject(node.args[subjectAt], itSlot, shape, construct);
|
|
482
|
+
if ('refusal' in subject) return subject;
|
|
483
|
+
const columns = derivedColumnsOf(shape, subject.canonical, 'bbox');
|
|
484
|
+
if (columns === null) return { refusal: refusal(construct, SPATIAL_REASONS.noIndex) };
|
|
485
|
+
|
|
486
|
+
const probeNode = node.args[subjectAt === 0 ? 1 : 0];
|
|
487
|
+
/** @type {any} */
|
|
488
|
+
let probe = null;
|
|
489
|
+
if (probeNode.kind === 'var' && probeNode.external === true) {
|
|
490
|
+
// a GeoJSON object is not a value any database binds, so its four
|
|
491
|
+
// box edges bind instead — computed at bind time from the same
|
|
492
|
+
// kernel the stored columns were computed with
|
|
493
|
+
probe = { ext: probeNode.name };
|
|
494
|
+
}
|
|
495
|
+
else {
|
|
496
|
+
const constant = constantOf(probeNode);
|
|
497
|
+
if (constant === null) return { refusal: refusal(construct, SPATIAL_REASONS.operand) };
|
|
498
|
+
const box = probeBox(constant.value);
|
|
499
|
+
if (box === null) return { refusal: refusal(construct, SPATIAL_REASONS.unbounded) };
|
|
500
|
+
probe = { box };
|
|
501
|
+
}
|
|
502
|
+
const pred = { p: 'bboxOverlap', columns, probe };
|
|
503
|
+
const exact = construct === '$bbox-intersects';
|
|
504
|
+
return promotion(pred,
|
|
505
|
+
{ construct, columns: boxColumnList(columns), exact },
|
|
506
|
+
exact ? [] : [refusal('$within', SPATIAL_REASONS.within)]);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* P3 — a BOUNDED `$distance` is implied by the box of its circle.
|
|
511
|
+
*
|
|
512
|
+
* Recognized: `{$le|$lt: [{$distance: [path, probe]}, r]}` and the
|
|
513
|
+
* mirrored `{$ge|$gt: [r, {$distance: …}]}`. A "farther than r"
|
|
514
|
+
* predicate is deliberately NOT promoted: no box narrows it.
|
|
515
|
+
*
|
|
516
|
+
* The box comes from `circleBounds` on the same sphere and the same
|
|
517
|
+
* `EARTH_RADIUS` the engine's `$distance` measures with, so the two
|
|
518
|
+
* cannot disagree by model rather than by rounding — there is no
|
|
519
|
+
* padding constant here, because no honest value could be chosen for
|
|
520
|
+
* one. Two refusals instead: a circle reaching a pole has no longitude
|
|
521
|
+
* bound at all, and one crossing the antimeridian would need two
|
|
522
|
+
* disjoint boxes this suite's box convention cannot carry.
|
|
523
|
+
* @param {any} node
|
|
524
|
+
* @param {number} itSlot
|
|
525
|
+
* @param {any} shape
|
|
526
|
+
* @returns {any}
|
|
527
|
+
*/
|
|
528
|
+
function planDistanceBound(node, itSlot, shape) {
|
|
529
|
+
const op = COMPARISONS.get(node.name);
|
|
530
|
+
const upperOnLeft = op === 'le' || op === 'lt';
|
|
531
|
+
const distanceNode = upperOnLeft ? node.args[0] : node.args[1];
|
|
532
|
+
const radiusNode = upperOnLeft ? node.args[1] : node.args[0];
|
|
533
|
+
const radius = constantOf(radiusNode);
|
|
534
|
+
if (radius === null || typeof radius.value !== 'number')
|
|
535
|
+
return { refusal: refusal('$distance', SPATIAL_REASONS.operand) };
|
|
536
|
+
|
|
537
|
+
let subjectAt = 0;
|
|
538
|
+
if (memberPath(distanceNode.args[0], itSlot) === null
|
|
539
|
+
&& memberPath(distanceNode.args[1], itSlot) !== null) subjectAt = 1;
|
|
540
|
+
const subject = spatialSubject(distanceNode.args[subjectAt], itSlot, shape, '$distance');
|
|
541
|
+
if ('refusal' in subject) return subject;
|
|
542
|
+
const columns = derivedColumnsOf(shape, subject.canonical, 'bbox');
|
|
543
|
+
if (columns === null) return { refusal: refusal('$distance', SPATIAL_REASONS.noIndex) };
|
|
544
|
+
|
|
545
|
+
const probeNode = distanceNode.args[subjectAt === 0 ? 1 : 0];
|
|
546
|
+
const constant = constantOf(probeNode);
|
|
547
|
+
// an EXTERNAL centre would need a slot that composes the bound value
|
|
548
|
+
// with the radius, and the derived slot kind is closed at one axis of
|
|
549
|
+
// one bound value; such a query diverts to the full scan as before
|
|
550
|
+
if (constant === null) return { refusal: refusal('$distance', SPATIAL_REASONS.operand) };
|
|
551
|
+
const at = probePosition(constant.value);
|
|
552
|
+
if (at === null) return { refusal: refusal('$distance', SPATIAL_REASONS.unbounded) };
|
|
553
|
+
const box = probeCircleBox(at, radius.value);
|
|
554
|
+
if (box === null) return { refusal: refusal('$distance', SPATIAL_REASONS.pole) };
|
|
555
|
+
if (box[0] < -180 || box[2] > 180)
|
|
556
|
+
return { refusal: refusal('$distance', SPATIAL_REASONS.wrapped) };
|
|
557
|
+
|
|
558
|
+
return promotion({ p: 'bboxOverlap', columns, probe: { box } },
|
|
559
|
+
{ construct: '$distance', columns: boxColumnList(columns), exact: false },
|
|
560
|
+
[refusal('$distance', SPATIAL_REASONS.distance)]);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* The `(canonical, precision)` a `$geohash` call over the binding
|
|
565
|
+
* denotes, or a refusal. The precision must be a literal — it decides
|
|
566
|
+
* WHICH column the derivation maps to.
|
|
567
|
+
* @param {any} node
|
|
568
|
+
* @param {number} itSlot
|
|
569
|
+
* @param {any} shape
|
|
570
|
+
* @param {string} construct
|
|
571
|
+
* @returns {any}
|
|
572
|
+
*/
|
|
573
|
+
function geohashDerivation(node, itSlot, shape, construct) {
|
|
574
|
+
if (node.kind !== 'op' || node.name !== '$geohash')
|
|
575
|
+
return { refusal: refusal(construct, SPATIAL_REASONS.operand) };
|
|
576
|
+
let precision = GEOHASH_DEFAULT_PRECISION;
|
|
577
|
+
if (node.args.length > 1) {
|
|
578
|
+
const declared = constantOf(node.args[1]);
|
|
579
|
+
if (declared === null || !Number.isInteger(declared.value)
|
|
580
|
+
|| declared.value < PRECISION_MIN || declared.value > PRECISION_MAX)
|
|
581
|
+
return { refusal: refusal(construct, SPATIAL_REASONS.operand) };
|
|
582
|
+
precision = declared.value;
|
|
583
|
+
}
|
|
584
|
+
const subject = spatialSubject(node.args[0], itSlot, shape, construct);
|
|
585
|
+
if ('refusal' in subject) return subject;
|
|
586
|
+
const column = derivedColumnsOf(shape, subject.canonical, 'geohash', precision);
|
|
587
|
+
if (column === null) return { refusal: refusal(construct, SPATIAL_REASONS.noIndex) };
|
|
588
|
+
return { column: /** @type {string} */ (column), precision };
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* P4, bucketing — `{$starts-with: [{$geohash: [path, k]}, "cell"]}` over
|
|
593
|
+
* a `geohash` index declared at exactly `k`. The column HOLDS the k-
|
|
594
|
+
* character cell, so a prefix test on the expression is a prefix test
|
|
595
|
+
* on the column: exact while the literal is no longer than k, and
|
|
596
|
+
* merely implied beyond it, where the column can only confirm its own
|
|
597
|
+
* first k characters.
|
|
598
|
+
*
|
|
599
|
+
* A prefix range is what order 01 made sargable; `LIKE` and `substr`
|
|
600
|
+
* both scan.
|
|
601
|
+
* @param {any} node
|
|
602
|
+
* @param {number} itSlot
|
|
603
|
+
* @param {any} shape
|
|
604
|
+
* @returns {any}
|
|
605
|
+
*/
|
|
606
|
+
function planCellPrefix(node, itSlot, shape) {
|
|
607
|
+
const derivation = geohashDerivation(node.args[0], itSlot, shape, '$starts-with');
|
|
608
|
+
if ('refusal' in derivation) return derivation;
|
|
609
|
+
const pattern = constantOf(node.args[1]);
|
|
610
|
+
if (pattern === null || typeof pattern.value !== 'string' || pattern.value === '')
|
|
611
|
+
return { refusal: refusal('$starts-with', SPATIAL_REASONS.operand) };
|
|
612
|
+
const exact = pattern.value.length <= derivation.precision;
|
|
613
|
+
const cell = exact ? pattern.value : pattern.value.slice(0, derivation.precision);
|
|
614
|
+
// a pattern as long as the column's own cell is an EQUALITY on it;
|
|
615
|
+
// a shorter one is the half-open range order 01 made sargable
|
|
616
|
+
const pred = cell.length === derivation.precision
|
|
617
|
+
? { p: 'cellIn', column: derivation.column, cells: [cell] }
|
|
618
|
+
: { p: 'cellPrefix', column: derivation.column, prefix: cell };
|
|
619
|
+
return promotion(pred,
|
|
620
|
+
{ construct: '$starts-with', columns: [derivation.column], exact },
|
|
621
|
+
exact ? [] : [refusal('$starts-with', SPATIAL_REASONS.prefix)]);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* P4, proximity — the nine-cell probe (D7), promoted to a membership
|
|
626
|
+
* test over one column. The single-cell version misses a point ten metres
|
|
627
|
+
* away across a cell edge, so a single prefix is BUCKETING and only the
|
|
628
|
+
* neighbourhood is proximity; this is the shape §8.14 publishes with
|
|
629
|
+
* the cells inline, where the planner can see them.
|
|
630
|
+
*
|
|
631
|
+
* Recognized: `{$exists: {$index-of: [{$geohash-neighbours: "cell"},
|
|
632
|
+
* {$geohash: [path, k]}]}}`. Exact only when the cell's length IS k —
|
|
633
|
+
* the membership test compares whole strings, so any other length makes
|
|
634
|
+
* the document's own predicate constantly false and the promotion would
|
|
635
|
+
* be answering a different question.
|
|
636
|
+
* @param {any} node
|
|
637
|
+
* @param {number} itSlot
|
|
638
|
+
* @param {any} shape
|
|
639
|
+
* @returns {any}
|
|
640
|
+
*/
|
|
641
|
+
function planCellNeighbourhood(node, itSlot, shape) {
|
|
642
|
+
const membership = node.args[0];
|
|
643
|
+
const neighbours = membership.args[0];
|
|
644
|
+
const cell = constantOf(neighbours.args[0]);
|
|
645
|
+
if (cell === null || typeof cell.value !== 'string' || cell.value === '')
|
|
646
|
+
return { refusal: refusal('$geohash-neighbours', SPATIAL_REASONS.operand) };
|
|
647
|
+
const derivation = geohashDerivation(membership.args[1], itSlot, shape, '$geohash-neighbours');
|
|
648
|
+
if ('refusal' in derivation) return derivation;
|
|
649
|
+
if (cell.value.length !== derivation.precision)
|
|
650
|
+
return { refusal: refusal('$geohash-neighbours', SPATIAL_REASONS.precision) };
|
|
651
|
+
const cells = cellNeighbourhood(cell.value);
|
|
652
|
+
if (cells.length === 0)
|
|
653
|
+
return { refusal: refusal('$geohash-neighbours', SPATIAL_REASONS.operand) };
|
|
654
|
+
return promotion({ p: 'cellIn', column: derivation.column, cells },
|
|
655
|
+
{ construct: '$geohash-neighbours', columns: [derivation.column], exact: true });
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* Dispatch the spatial shapes. Answers `null` when the node is not one
|
|
660
|
+
* of them, so the caller falls through to the rest of the grammar.
|
|
661
|
+
* @param {any} node
|
|
662
|
+
* @param {number} itSlot
|
|
663
|
+
* @param {any} shape
|
|
664
|
+
* @returns {any}
|
|
665
|
+
*/
|
|
666
|
+
function planSpatial(node, itSlot, shape) {
|
|
667
|
+
if (node.name === '$within' || node.name === '$bbox-intersects')
|
|
668
|
+
return planBoxPredicate(node, itSlot, shape);
|
|
669
|
+
const op = COMPARISONS.get(node.name);
|
|
670
|
+
if (op !== undefined && ORDERING_OPS.has(op)) {
|
|
671
|
+
const upperOnLeft = op === 'le' || op === 'lt';
|
|
672
|
+
const distanceNode = upperOnLeft ? node.args[0] : node.args[1];
|
|
673
|
+
if (distanceNode?.kind === 'op' && distanceNode.name === '$distance')
|
|
674
|
+
return planDistanceBound(node, itSlot, shape);
|
|
675
|
+
// `$distance >= r` — "farther than" — is narrowed by no box at all
|
|
676
|
+
const other = upperOnLeft ? node.args[1] : node.args[0];
|
|
677
|
+
if (other?.kind === 'op' && other.name === '$distance') {
|
|
678
|
+
return { refusal: refusal('$distance',
|
|
679
|
+
'only a BOUNDED distance is promoted; no box narrows "farther than r"') };
|
|
680
|
+
}
|
|
681
|
+
return null;
|
|
682
|
+
}
|
|
683
|
+
if (node.name === '$starts-with' && node.args[0]?.kind === 'op'
|
|
684
|
+
&& node.args[0].name === '$geohash')
|
|
685
|
+
return planCellPrefix(node, itSlot, shape);
|
|
686
|
+
if (node.name === '$exists' && node.args[0]?.kind === 'op'
|
|
687
|
+
&& node.args[0].name === '$index-of'
|
|
688
|
+
&& node.args[0].args[0]?.kind === 'op'
|
|
689
|
+
&& node.args[0].args[0].name === '$geohash-neighbours')
|
|
690
|
+
return planCellNeighbourhood(node, itSlot, shape);
|
|
691
|
+
return null;
|
|
692
|
+
}
|
|
693
|
+
|
|
261
694
|
/**
|
|
262
695
|
* Translate one predicate node, or explain why not.
|
|
696
|
+
*
|
|
697
|
+
* A translated predicate is EXACT unless it carries `refinements`: an
|
|
698
|
+
* implied spatial conjunct narrows the fetch and leaves the original
|
|
699
|
+
* predicate to the residual, and `prefilters` records what it reads so
|
|
700
|
+
* `explain()` can say whether a declared index is earning its keep.
|
|
263
701
|
* @param {any} node
|
|
264
702
|
* @param {number} itSlot
|
|
265
703
|
* @param {any} shape
|
|
266
|
-
* @returns {{ pred: import('./algebra.js').PlanPredicate
|
|
704
|
+
* @returns {{ pred: import('./algebra.js').PlanPredicate, exact: boolean,
|
|
705
|
+
* prefilters: any[], refinements: { construct: string, reason: string }[] } |
|
|
267
706
|
* { refusal: { construct: string, reason: string } }}
|
|
268
707
|
*/
|
|
269
708
|
function planPredicate(node, itSlot, shape) {
|
|
@@ -275,26 +714,43 @@ function planPredicate(node, itSlot, shape) {
|
|
|
275
714
|
|
|
276
715
|
if (node.name === '$and' || node.name === '$or') {
|
|
277
716
|
const items = [];
|
|
717
|
+
const prefilters = [];
|
|
718
|
+
const refinements = [];
|
|
278
719
|
for (const arg of node.args) {
|
|
279
720
|
const inner = planPredicate(arg, itSlot, shape);
|
|
280
721
|
if ('refusal' in inner) return inner; // partial $or/$and is not splittable here
|
|
281
722
|
items.push(inner.pred);
|
|
723
|
+
prefilters.push(...inner.prefilters);
|
|
724
|
+
refinements.push(...inner.refinements);
|
|
282
725
|
}
|
|
283
|
-
|
|
726
|
+
// an implied child makes the composition a SUPERSET either way, so
|
|
727
|
+
// it still narrows honestly — it just stops deciding
|
|
728
|
+
return { pred: { p: node.name === '$and' ? 'and' : 'or', items },
|
|
729
|
+
exact: refinements.length === 0, prefilters, refinements };
|
|
284
730
|
}
|
|
285
731
|
if (node.name === '$not') {
|
|
286
732
|
const inner = planPredicate(node.args[0], itSlot, shape);
|
|
287
733
|
if ('refusal' in inner) return inner;
|
|
288
|
-
|
|
734
|
+
if (inner.refinements.length > 0) {
|
|
735
|
+
// negating a superset is a SUBSET, which drops matching rows —
|
|
736
|
+
// the one composition an implied conjunct may never enter
|
|
737
|
+
return { refusal: refusal('$not',
|
|
738
|
+
'a negated predicate cannot ride an implied pre-filter (negating a superset drops rows)') };
|
|
739
|
+
}
|
|
740
|
+
return { pred: { p: 'not', item: inner.pred },
|
|
741
|
+
exact: true, prefilters: inner.prefilters, refinements: [] };
|
|
289
742
|
}
|
|
290
743
|
|
|
744
|
+
const spatial = planSpatial(node, itSlot, shape);
|
|
745
|
+
if (spatial !== null) return spatial;
|
|
746
|
+
|
|
291
747
|
if (node.name === '$exists' || node.name === '$empty') {
|
|
292
748
|
const ref = pathRef(node.args[0], itSlot, shape);
|
|
293
749
|
if (ref === null) {
|
|
294
750
|
return { refusal: refusal(node.name,
|
|
295
751
|
'existence tests translate only over a singular member path on the binding') };
|
|
296
752
|
}
|
|
297
|
-
return {
|
|
753
|
+
return exactly({ p: 'typeIs', ref, types: [], positive: node.name === '$exists' });
|
|
298
754
|
}
|
|
299
755
|
|
|
300
756
|
const comparison = COMPARISONS.get(node.name);
|
|
@@ -321,12 +777,12 @@ function planPredicate(node, itSlot, shape) {
|
|
|
321
777
|
}
|
|
322
778
|
const lit = operand.lit;
|
|
323
779
|
if (typeof lit === 'boolean' || lit === null) {
|
|
324
|
-
if (ORDERING_OPS.has(op)) return {
|
|
780
|
+
if (ORDERING_OPS.has(op)) return exactly({ p: 'const', value: false });
|
|
325
781
|
const typeName = lit === null ? 'null' : lit ? 'true' : 'false';
|
|
326
|
-
return {
|
|
782
|
+
return exactly({ p: 'typeIs', ref, types: [typeName], positive: op === 'eq' });
|
|
327
783
|
}
|
|
328
784
|
}
|
|
329
|
-
return {
|
|
785
|
+
return exactly({ p: 'cmp', op: /** @type {any} */ (op), ref, operand });
|
|
330
786
|
}
|
|
331
787
|
|
|
332
788
|
const stringOp = STRING_OPS.get(node.name);
|
|
@@ -345,7 +801,7 @@ function planPredicate(node, itSlot, shape) {
|
|
|
345
801
|
return { refusal: refusal(node.name,
|
|
346
802
|
"the empty pattern's vacuous-truth corner (true even on a missing member) is not translated") };
|
|
347
803
|
}
|
|
348
|
-
return {
|
|
804
|
+
return exactly({ p: 'strop', kind: /** @type {any} */ (stringOp), ref, operand });
|
|
349
805
|
}
|
|
350
806
|
|
|
351
807
|
return { refusal: refusal(node.name,
|
|
@@ -362,11 +818,12 @@ function planPredicate(node, itSlot, shape) {
|
|
|
362
818
|
* @param {any} shape - { collection, schema, columnByCanonical }
|
|
363
819
|
* @param {any} rawFlwor - The raw FLWOR document (conjunct fragments
|
|
364
820
|
* for the hook — the AST has no unparser)
|
|
365
|
-
* @param {((fragment: any) => { name: string, key: string } | null) | undefined} udfHook
|
|
821
|
+
* @param {((fragment: any, binding: string) => { name: string, key: string } | null) | undefined} udfHook
|
|
366
822
|
* @returns {{ plan: import('./algebra.js').Plan,
|
|
367
823
|
* reasons: { construct: string, reason: string }[],
|
|
368
824
|
* whereFullyPushed: boolean, orderPushed: boolean,
|
|
369
|
-
* projectionNative: boolean, itSlot: number,
|
|
825
|
+
* projectionNative: boolean, itSlot: number, itName: string | null,
|
|
826
|
+
* udfs: string[], prefilters: any[] }}
|
|
370
827
|
*/
|
|
371
828
|
function planFlwor(node, shape, rawFlwor, udfHook) {
|
|
372
829
|
const reasons = [];
|
|
@@ -386,9 +843,14 @@ function planFlwor(node, shape, rawFlwor, udfHook) {
|
|
|
386
843
|
reasons.push(refusal('$for',
|
|
387
844
|
'only a single plain binding over the whole collection is translated'));
|
|
388
845
|
return { plan, reasons, whereFullyPushed: false, orderPushed: false,
|
|
389
|
-
projectionNative: false, itSlot: -1, udfs: [] };
|
|
846
|
+
projectionNative: false, itSlot: -1, itName: null, udfs: [], prefilters: [] };
|
|
390
847
|
}
|
|
391
848
|
const itSlot = binding.slot;
|
|
849
|
+
// the document's own name for the collection binding. The residual and
|
|
850
|
+
// the UDF hatch both wrap raw fragments in a synthetic one-row query,
|
|
851
|
+
// and that wrapper must bind what the fragments actually reference —
|
|
852
|
+
// the name is the document's to choose, never this package's.
|
|
853
|
+
const itName = binding.name;
|
|
392
854
|
|
|
393
855
|
if (node.fold !== null) reasons.push(refusal('$fold', KIND_REASONS.let));
|
|
394
856
|
if (node.letBindings.length > 0) reasons.push(refusal('$let', KIND_REASONS.let));
|
|
@@ -407,6 +869,7 @@ function planFlwor(node, shape, rawFlwor, udfHook) {
|
|
|
407
869
|
// accepts its raw fragment.
|
|
408
870
|
let whereFullyPushed = true;
|
|
409
871
|
const udfs = [];
|
|
872
|
+
const prefilters = [];
|
|
410
873
|
if (!narrowingSound) whereFullyPushed = false;
|
|
411
874
|
else if (node.where !== null) {
|
|
412
875
|
const split = node.where.kind === 'op' && node.where.name === '$and';
|
|
@@ -417,7 +880,7 @@ function planFlwor(node, shape, rawFlwor, udfHook) {
|
|
|
417
880
|
const outcome = planPredicate(conjuncts[i], itSlot, shape);
|
|
418
881
|
if ('refusal' in outcome) {
|
|
419
882
|
const promoted = udfHook !== undefined && rawConjuncts[i] !== undefined
|
|
420
|
-
? udfHook(rawConjuncts[i])
|
|
883
|
+
? udfHook(rawConjuncts[i], itName)
|
|
421
884
|
: null;
|
|
422
885
|
if (promoted !== null) {
|
|
423
886
|
plan.filter = conjoin(plan.filter,
|
|
@@ -431,6 +894,13 @@ function planFlwor(node, shape, rawFlwor, udfHook) {
|
|
|
431
894
|
}
|
|
432
895
|
else {
|
|
433
896
|
plan.filter = conjoin(plan.filter, outcome.pred);
|
|
897
|
+
prefilters.push(...outcome.prefilters);
|
|
898
|
+
// an IMPLIED conjunct narrows and leaves the original predicate
|
|
899
|
+
// for the residual, which is why it is reported as forcing one
|
|
900
|
+
for (const refinement of outcome.refinements) {
|
|
901
|
+
reasons.push(refinement);
|
|
902
|
+
whereFullyPushed = false;
|
|
903
|
+
}
|
|
434
904
|
}
|
|
435
905
|
}
|
|
436
906
|
}
|
|
@@ -480,7 +950,9 @@ function planFlwor(node, shape, rawFlwor, udfHook) {
|
|
|
480
950
|
orderPushed: orderPushed && structureClean,
|
|
481
951
|
projectionNative,
|
|
482
952
|
itSlot,
|
|
953
|
+
itName,
|
|
483
954
|
udfs,
|
|
955
|
+
prefilters,
|
|
484
956
|
};
|
|
485
957
|
}
|
|
486
958
|
|
|
@@ -489,7 +961,7 @@ function planFlwor(node, shape, rawFlwor, udfHook) {
|
|
|
489
961
|
* @param {any} document - The raw query document (kept beside the AST
|
|
490
962
|
* for residual construction — the AST has no unparser)
|
|
491
963
|
* @param {any} shape - { collection, schema, columnByCanonical }
|
|
492
|
-
* @param {{ udf?: (fragment: any) => { name: string, key: string } | null }} [options]
|
|
964
|
+
* @param {{ udf?: (fragment: any, binding: string) => { name: string, key: string } | null }} [options]
|
|
493
965
|
* @returns {{
|
|
494
966
|
* analysis: any,
|
|
495
967
|
* plan: import('./algebra.js').Plan | null,
|
|
@@ -497,6 +969,7 @@ function planFlwor(node, shape, rawFlwor, udfHook) {
|
|
|
497
969
|
* reasons: { construct: string, reason: string }[],
|
|
498
970
|
* rowReturn: any,
|
|
499
971
|
* udfs: string[],
|
|
972
|
+
* prefilters: { construct: string, columns: string[], exact: boolean }[],
|
|
500
973
|
* }}
|
|
501
974
|
*/
|
|
502
975
|
function planCollectionCore(document, shape, options = undefined) {
|
|
@@ -515,7 +988,7 @@ function planCollectionCore(document, shape, options = undefined) {
|
|
|
515
988
|
return {
|
|
516
989
|
analysis, plan: null, mode: 'set',
|
|
517
990
|
reasons: [refusal('$subsequence', 'window bounds must be literal numbers to push')],
|
|
518
|
-
rowReturn: null, udfs: [],
|
|
991
|
+
rowReturn: null, udfs: [], prefilters: [],
|
|
519
992
|
};
|
|
520
993
|
}
|
|
521
994
|
windows.push({ offset: start.value, limit: length === undefined ? null : length.value });
|
|
@@ -531,7 +1004,7 @@ function planCollectionCore(document, shape, options = undefined) {
|
|
|
531
1004
|
return {
|
|
532
1005
|
analysis, plan: null, mode: 'set',
|
|
533
1006
|
reasons: [refusal(root.name, 'a windowed aggregate is not translated')],
|
|
534
|
-
rowReturn: null, udfs: [],
|
|
1007
|
+
rowReturn: null, udfs: [], prefilters: [],
|
|
535
1008
|
};
|
|
536
1009
|
}
|
|
537
1010
|
aggregate = { name: root.name, fn: AGGREGATES.get(root.name) };
|
|
@@ -545,7 +1018,7 @@ function planCollectionCore(document, shape, options = undefined) {
|
|
|
545
1018
|
analysis, plan: null, mode: 'set',
|
|
546
1019
|
reasons: [refusal(root.kind, KIND_REASONS[root.kind]
|
|
547
1020
|
?? 'only a FLWOR over the collection is translated')],
|
|
548
|
-
rowReturn: null, udfs: [],
|
|
1021
|
+
rowReturn: null, udfs: [], prefilters: [],
|
|
549
1022
|
};
|
|
550
1023
|
}
|
|
551
1024
|
|
|
@@ -558,7 +1031,7 @@ function planCollectionCore(document, shape, options = undefined) {
|
|
|
558
1031
|
// full sequence, not a narrowed candidate set)
|
|
559
1032
|
if (!fullyPushed) {
|
|
560
1033
|
return { analysis, plan: null, mode: 'set', reasons: flwor.reasons,
|
|
561
|
-
rowReturn: null, udfs: [] };
|
|
1034
|
+
rowReturn: null, udfs: [], prefilters: flwor.prefilters };
|
|
562
1035
|
}
|
|
563
1036
|
if (aggregate.fn === 'count') {
|
|
564
1037
|
if (!flwor.projectionNative) {
|
|
@@ -566,12 +1039,12 @@ function planCollectionCore(document, shape, options = undefined) {
|
|
|
566
1039
|
analysis, plan: null, mode: 'set',
|
|
567
1040
|
reasons: [refusal('$count',
|
|
568
1041
|
'count translates only over the bare binding (a projected return can change the item count)')],
|
|
569
|
-
rowReturn: null, udfs: [],
|
|
1042
|
+
rowReturn: null, udfs: [], prefilters: [],
|
|
570
1043
|
};
|
|
571
1044
|
}
|
|
572
1045
|
plan.aggregate = { fn: 'count', ref: null };
|
|
573
1046
|
return { analysis, plan, mode: 'native', reasons: [], rowReturn: null,
|
|
574
|
-
udfs: flwor.udfs };
|
|
1047
|
+
udfs: flwor.udfs, prefilters: flwor.prefilters };
|
|
575
1048
|
}
|
|
576
1049
|
const ref = pathRef(root.ret, flwor.itSlot, shape);
|
|
577
1050
|
const numeric = aggregate.fn === 'sum' || aggregate.fn === 'avg';
|
|
@@ -582,12 +1055,12 @@ function planCollectionCore(document, shape, options = undefined) {
|
|
|
582
1055
|
analysis, plan: null, mode: 'set',
|
|
583
1056
|
reasons: [refusal(aggregate.name,
|
|
584
1057
|
'aggregates translate only over a singular schema-typed path (the engine ERRORS on non-conforming operands)')],
|
|
585
|
-
rowReturn: null, udfs: [],
|
|
1058
|
+
rowReturn: null, udfs: [], prefilters: [],
|
|
586
1059
|
};
|
|
587
1060
|
}
|
|
588
1061
|
plan.aggregate = { fn: /** @type {any} */ (aggregate.fn), ref };
|
|
589
1062
|
return { analysis, plan, mode: 'native', reasons: [], rowReturn: null,
|
|
590
|
-
udfs: flwor.udfs };
|
|
1063
|
+
udfs: flwor.udfs, prefilters: flwor.prefilters };
|
|
591
1064
|
}
|
|
592
1065
|
|
|
593
1066
|
// windows push only onto a fully pushed selection
|
|
@@ -610,20 +1083,28 @@ function planCollectionCore(document, shape, options = undefined) {
|
|
|
610
1083
|
|
|
611
1084
|
if (fullyPushed && flwor.projectionNative && (windows.length === 0 || plan.window !== null)) {
|
|
612
1085
|
return { analysis, plan, mode: 'native', reasons: [], rowReturn: null,
|
|
613
|
-
udfs: flwor.udfs };
|
|
1086
|
+
udfs: flwor.udfs, prefilters: flwor.prefilters };
|
|
614
1087
|
}
|
|
615
1088
|
|
|
616
1089
|
// the row residual: everything but the projection pushed
|
|
617
1090
|
if (fullyPushed && !flwor.projectionNative
|
|
618
1091
|
&& (windows.length === 0 || plan.window !== null)) {
|
|
619
1092
|
const rawFlwor = rawInner;
|
|
1093
|
+
const name = flwor.itName ?? 'it';
|
|
620
1094
|
return {
|
|
621
1095
|
analysis,
|
|
622
1096
|
plan,
|
|
623
1097
|
mode: 'row',
|
|
624
1098
|
reasons: flwor.reasons,
|
|
625
|
-
|
|
1099
|
+
// a COMPLETE one-row document, not a bare expression the caller
|
|
1100
|
+
// must re-wrap: the binding and the projection that references it
|
|
1101
|
+
// travel together, so the two cannot be paired up wrongly
|
|
1102
|
+
rowReturn: {
|
|
1103
|
+
$for: { [name]: '$[*]' },
|
|
1104
|
+
$return: [rawFlwor?.$return ?? `$${name}`],
|
|
1105
|
+
},
|
|
626
1106
|
udfs: flwor.udfs,
|
|
1107
|
+
prefilters: flwor.prefilters,
|
|
627
1108
|
};
|
|
628
1109
|
}
|
|
629
1110
|
|
|
@@ -631,7 +1112,7 @@ function planCollectionCore(document, shape, options = undefined) {
|
|
|
631
1112
|
plan.order = null;
|
|
632
1113
|
plan.window = null;
|
|
633
1114
|
return { analysis, plan, mode: 'set', reasons: flwor.reasons, rowReturn: null,
|
|
634
|
-
udfs: flwor.udfs };
|
|
1115
|
+
udfs: flwor.udfs, prefilters: flwor.prefilters };
|
|
635
1116
|
}
|
|
636
1117
|
|
|
637
1118
|
/**
|
|
@@ -650,6 +1131,7 @@ function planCollectionCore(document, shape, options = undefined) {
|
|
|
650
1131
|
* reasons: { construct: string, reason: string }[],
|
|
651
1132
|
* rowReturn: any,
|
|
652
1133
|
* udfs: string[],
|
|
1134
|
+
* prefilters: { construct: string, columns: string[], exact: boolean }[],
|
|
653
1135
|
* }}
|
|
654
1136
|
*/
|
|
655
1137
|
export function planQuery(document, shape, options = undefined) {
|