@jarenjs/db 0.34.2 → 0.43.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/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
- * A singular member path rooted on the binding → a PlanRef, or null.
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
- * @param {any} shape - { schema, columnByCanonical }
220
- * @returns {import('./algebra.js').PlanRef | null}
243
+ * @returns {{ segments: ({ name: string } | { index: number })[],
244
+ * canonical: string } | null}
221
245
  */
222
- function pathRef(node, itSlot, shape) {
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
- return { pred: { p: node.name === '$and' ? 'and' : 'or', items } };
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
- return { pred: { p: 'not', item: inner.pred } };
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 { pred: { p: 'typeIs', ref, types: [], positive: node.name === '$exists' } };
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 { pred: { p: 'const', value: false } };
780
+ if (ORDERING_OPS.has(op)) return exactly({ p: 'const', value: false });
325
781
  const typeName = lit === null ? 'null' : lit ? 'true' : 'false';
326
- return { pred: { p: 'typeIs', ref, types: [typeName], positive: op === 'eq' } };
782
+ return exactly({ p: 'typeIs', ref, types: [typeName], positive: op === 'eq' });
327
783
  }
328
784
  }
329
- return { pred: { p: 'cmp', op: /** @type {any} */ (op), ref, operand } };
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 { pred: { p: 'strop', kind: /** @type {any} */ (stringOp), ref, operand } };
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, udfs: string[] }}
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
- rowReturn: rawFlwor?.$return ?? '$it',
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) {