@jarenjs/db 0.72.3 → 0.75.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/src/plan.js CHANGED
@@ -99,7 +99,7 @@ const PREDICATE_REASONS = {
99
99
  negatedPrefilter: 'a negated predicate cannot ride an implied pre-filter '
100
100
  + '(negating a superset drops rows)',
101
101
  existence: 'existence tests translate only over a singular member path on the binding',
102
- joinTerritory: 'comparisons where both sides are paths are join territory',
102
+ joinTerritory: 'path comparisons require the same non-null numeric or string family',
103
103
  operands: 'comparisons translate only between a singular member path and a literal or external',
104
104
  compoundLiteral: 'array and object literals have no guarded native comparison form',
105
105
  stringSubject: 'string operators translate only over schema-typed string paths '
@@ -130,8 +130,8 @@ const PLAN_REASONS = {
130
130
  countProjection: 'count translates only over the bare binding or one member path '
131
131
  + '(a projected return can change the item count)',
132
132
  groupedAggregate: 'an aggregate over a grouped phrase folds its groups, which the engine does',
133
- windowedGroup: 'a window over the GROUPS is engine work: the plan groups whole, '
134
- + 'and a LIMIT over the groups would cut a different set',
133
+ distinctProjection: 'distinct translates over one typed scalar member path, unordered or ordered by that same path',
134
+ windowedGroup: 'a window over groups whose return can omit an item needs the engine cardinality',
135
135
  aggregatePath: 'aggregates translate only over a singular schema-typed path '
136
136
  + '(the engine ERRORS on non-conforming operands)',
137
137
  };
@@ -756,7 +756,8 @@ function planDistanceBound(node, itSlot, shape) {
756
756
  const distanceNode = upperOnLeft ? node.args[0] : node.args[1];
757
757
  const radiusNode = upperOnLeft ? node.args[1] : node.args[0];
758
758
  const radius = constantOf(radiusNode);
759
- if (radius === null || typeof radius.value !== 'number')
759
+ const radiusExternal = radiusNode.kind === 'var' && radiusNode.external === true;
760
+ if (!radiusExternal && (radius === null || typeof radius.value !== 'number'))
760
761
  return { refusal: refusal('$distance', SPATIAL_REASONS.operand) };
761
762
 
762
763
  let subjectAt = 0;
@@ -769,21 +770,33 @@ function planDistanceBound(node, itSlot, shape) {
769
770
 
770
771
  const probeNode = distanceNode.args[subjectAt === 0 ? 1 : 0];
771
772
  const constant = constantOf(probeNode);
772
- // an EXTERNAL centre would need a slot that composes the bound value
773
- // with the radius, and the derived slot kind is closed at one axis of
774
- // one bound value; such a query diverts to the full scan as before
775
- if (constant === null) return { refusal: refusal('$distance', SPATIAL_REASONS.operand) };
776
- const at = probePosition(constant.value);
777
- if (at === null) return { refusal: refusal('$distance', SPATIAL_REASONS.unbounded) };
778
- if (!Number.isFinite(radius.value) || radius.value < 0) {
773
+ const centreExternal = probeNode.kind === 'var' && probeNode.external === true;
774
+ if (!centreExternal && constant === null)
775
+ return { refusal: refusal('$distance', SPATIAL_REASONS.operand) };
776
+ if (!radiusExternal && (!Number.isFinite(radius.value) || radius.value < 0)) {
779
777
  return { refusal: refusal('$distance', SPATIAL_REASONS.radius) };
780
778
  }
781
- const box = probeCircleBox(at, radius.value);
782
- if (box === null) return { refusal: refusal('$distance', SPATIAL_REASONS.pole) };
783
- if (box[0] < -180 || box[2] > 180)
784
- return { refusal: refusal('$distance', SPATIAL_REASONS.wrapped) };
779
+ let probe;
780
+ if (centreExternal || radiusExternal) {
781
+ // Both inputs belong to one closed derived slot. Bind-time failure
782
+ // diverts the whole query, preserving the engine's errors and the
783
+ // candidates on the far side of a pole or the antimeridian.
784
+ probe = { circle: {
785
+ centre: centreExternal ? { external: probeNode.name } : { literal: constant.value },
786
+ radius: radiusExternal ? { external: radiusNode.name } : { literal: radius.value },
787
+ } };
788
+ }
789
+ else {
790
+ const at = probePosition(constant.value);
791
+ if (at === null) return { refusal: refusal('$distance', SPATIAL_REASONS.unbounded) };
792
+ const box = probeCircleBox(at, radius.value);
793
+ if (box === null) return { refusal: refusal('$distance', SPATIAL_REASONS.pole) };
794
+ if (box[0] < -180 || box[2] > 180)
795
+ return { refusal: refusal('$distance', SPATIAL_REASONS.wrapped) };
796
+ probe = { box };
797
+ }
785
798
 
786
- const conjunct = boxConjunct(columns, rtreeTableOf(shape, subject.canonical), { box });
799
+ const conjunct = boxConjunct(columns, rtreeTableOf(shape, subject.canonical), probe);
787
800
  return promotion(conjunct.pred,
788
801
  { construct: '$distance', via: conjunct.via, columns: conjunct.columns, exact: false },
789
802
  [refusal('$distance', SPATIAL_REASONS.distance)]);
@@ -1305,8 +1318,14 @@ function planPredicate(node, itSlot, shape) {
1305
1318
  const ref = pathRef(left, itSlot, shape);
1306
1319
  const operand = operandOf(right);
1307
1320
  if (ref === null || operand === null) {
1308
- if (pathRef(left, itSlot, shape) !== null && pathRef(right, itSlot, shape) !== null)
1321
+ const other = pathRef(right, itSlot, shape);
1322
+ if (ref !== null && other !== null) {
1323
+ const family = (item) => isNumericType(item.type) ? 'number' : item.type;
1324
+ if (orderable(ref, shape.schema) && orderable(other, shape.schema)
1325
+ && family(ref) === family(other))
1326
+ return exactly({ p: 'refCmp', op, left: ref, right: other });
1309
1327
  return { refusal: refusal(node.name, PREDICATE_REASONS.joinTerritory) };
1328
+ }
1310
1329
  return { refusal: refusal(node.name, PREDICATE_REASONS.operands) };
1311
1330
  }
1312
1331
  if ('lit' in operand) {
@@ -2057,7 +2076,7 @@ function planSeriesOperator(root, shape) {
2057
2076
  * The rules that make it agree with the engine, each one a refusal
2058
2077
  * rather than an approximation:
2059
2078
  *
2060
- * - a key is a singular schema-typed path that cannot hold `null`, so
2079
+ * - a key is a singular schema-typed scalar path, so
2061
2080
  * the group SQL forms is the group the engine forms. An ABSENT key is
2062
2081
  * its own group, and its value comes back with its JSON type beside
2063
2082
  * it, so the decoder can leave the member out exactly as the object
@@ -2080,7 +2099,7 @@ function planGeneralGrouping(node, itSlot, shape) {
2080
2099
  const keySlot = new Map();
2081
2100
  for (const key of node.groupby.keys) {
2082
2101
  const ref = pathRef(key.expr, itSlot, shape);
2083
- if (ref === null || ref.type === 'unknown' || admitsNull(shape.schema, ref.segments))
2102
+ if (ref === null || ref.type === 'unknown')
2084
2103
  return null;
2085
2104
  keySlot.set(key.slot, keys.length);
2086
2105
  keys.push({ as: key.name, ref });
@@ -2129,8 +2148,12 @@ function planGeneralGrouping(node, itSlot, shape) {
2129
2148
  };
2130
2149
  const tree = build(node.ret);
2131
2150
  if (tree === null) return null;
2132
- const order = groupOrder(node.orderby, keySlot);
2151
+ const order = groupOrder(node.orderby, keySlot, build);
2133
2152
  if (order === null) return null;
2153
+ // Group equality accepts null and boolean keys; ordering them raises
2154
+ // JQ2005. SQL ordering must not hide that engine error.
2155
+ if (order !== 'first-seen' && order.some((term) =>
2156
+ term.aggregate === undefined && !orderable(keys[term.index].ref, shape.schema))) return null;
2134
2157
  return { keys, aggregates, tree, order };
2135
2158
  }
2136
2159
 
@@ -2164,21 +2187,32 @@ function groupAggregate(node, itSlot, shape) {
2164
2187
  /**
2165
2188
  * How the groups come out: the engine's order of FIRST APPEARANCE
2166
2189
  * (§6.5) when nothing declares otherwise, else the group-key ordering
2167
- * an `$orderby` asked for. `null` when the ordering names anything but
2168
- * group keys after a grouping there is nothing else a row can order
2169
- * by that the plan could reproduce.
2190
+ * an `$orderby` asked for. Proven aggregate expressions share the
2191
+ * return's aggregate slots, including aggregates used only to order.
2170
2192
  * @param {any} orderby
2171
2193
  * @param {Map<number, number>} keySlot
2172
- * @returns {'first-seen' | { index: number, desc: boolean, nullsFirst: boolean }[] | null}
2194
+ * @param {(node: any) => any} build
2195
+ * @returns {any}
2173
2196
  */
2174
- function groupOrder(orderby, keySlot) {
2197
+ function groupOrder(orderby, keySlot, build) {
2175
2198
  if (orderby === null) return 'first-seen';
2176
2199
  const terms = [];
2177
2200
  for (const spec of orderby.specs) {
2178
2201
  if (spec.collation !== null || spec.collationName !== null) return null;
2179
2202
  const key = spec.key;
2180
- if (key.kind !== 'var' || key.external === true || !keySlot.has(key.slot)) return null;
2181
- terms.push({ index: keySlot.get(key.slot), desc: spec.desc === true,
2203
+ let target;
2204
+ if (key.kind === 'var' && key.external !== true && keySlot.has(key.slot))
2205
+ target = { index: keySlot.get(key.slot) };
2206
+ else {
2207
+ // SQL SUM/AVG may accumulate in a different order (SQLite also
2208
+ // compensates rounding). Such a value cannot safely decide group
2209
+ // order. Counts and extrema have no accumulation-order ambiguity.
2210
+ if (!['$count', '$min', '$max'].includes(key.name)) return null;
2211
+ const aggregate = key.kind === 'op' ? build(key) : null;
2212
+ if (aggregate?.p !== 'agg') return null;
2213
+ target = { aggregate: aggregate.index };
2214
+ }
2215
+ terms.push({ ...target, desc: spec.desc === true,
2182
2216
  nullsFirst: (spec.emptyGreatest === true) === (spec.desc === true) });
2183
2217
  }
2184
2218
  return terms;
@@ -2187,16 +2221,14 @@ function groupOrder(orderby, keySlot) {
2187
2221
  /**
2188
2222
  * The projection TREE one `$return` compiles to, or `null` when the
2189
2223
  * shape is not one the plan can rebuild. Object and array constructors,
2190
- * literals and singular member paths compose; anything else — a
2191
- * function call, a conditional, a dynamic member, a reference to the
2192
- * binding itself — refuses the WHOLE projection, because a projection
2224
+ * literals, whole collection bindings and singular member paths compose;
2225
+ * a function call, conditional or dynamic member refuses the WHOLE projection, because a projection
2193
2226
  * that dropped part of what the caller asked for would be a wrong
2194
2227
  * answer, not a partial one.
2195
2228
  *
2196
2229
  * Distinct paths are collected once: a path named twice is one fetched
2197
- * column and two leaves pointing at it. A projection with NO path is
2198
- * refused too a statement needs a column to select, and a projection
2199
- * of pure literals has nothing the database could contribute.
2230
+ * column and two leaves pointing at it. A constant tree needs only a
2231
+ * row marker from SQL: the selection contributes its cardinality.
2200
2232
  * @param {any} node - the `$return` AST node
2201
2233
  * @param {number} itSlot
2202
2234
  * @param {any} shape
@@ -2210,8 +2242,9 @@ function projectionTree(node, itSlot, shape) {
2210
2242
  const build = (child) => {
2211
2243
  assertDecidedKind(child);
2212
2244
  if (child.kind === 'literal') return { p: 'lit', value: child.value };
2213
- if (child.kind === 'path') {
2214
- const ref = pathRef(child, itSlot, shape);
2245
+ if (child.kind === 'path' || isItVar(child, itSlot)) {
2246
+ const ref = isItVar(child, itSlot)
2247
+ ? { segments: [], type: 'unknown', column: null } : pathRef(child, itSlot, shape);
2215
2248
  if (ref === null) return null;
2216
2249
  const canonical = canonicalOf(ref.segments);
2217
2250
  let index = byCanonical.get(canonical);
@@ -2243,7 +2276,7 @@ function projectionTree(node, itSlot, shape) {
2243
2276
  return null;
2244
2277
  };
2245
2278
  const tree = build(node);
2246
- return tree === null || leaves.length === 0 ? null : { tree, leaves };
2279
+ return tree === null ? null : { tree, leaves };
2247
2280
  }
2248
2281
 
2249
2282
  /**
@@ -2581,10 +2614,16 @@ function planCollectionCore(document, shape, options = undefined) {
2581
2614
  // pushable at all — a SQL fold over zero rows never sees a second
2582
2615
  // operand, and `aggregateSpec` refuses the declaration at open — so
2583
2616
  // the recognizer here reads the phrase and nothing else
2617
+ const distinct = root.kind === 'op' && root.name === '$distinct';
2618
+ if (distinct) {
2619
+ root = root.args[0];
2620
+ rawInner = rawInner?.$distinct;
2621
+ assertDecidedKind(root);
2622
+ }
2584
2623
  let aggregate = null;
2585
2624
  const registeredAggregate = root.kind === 'op' && !AGGREGATES.has(root.name)
2586
2625
  ? (options?.aggregate?.(root.name) ?? null) : null;
2587
- if (root.kind === 'op' && (AGGREGATES.has(root.name) || registeredAggregate !== null)) {
2626
+ if (!distinct && root.kind === 'op' && (AGGREGATES.has(root.name) || registeredAggregate !== null)) {
2588
2627
  if (windows.length > 0) {
2589
2628
  return {
2590
2629
  analysis, plan: null, mode: 'set',
@@ -2603,7 +2642,7 @@ function planCollectionCore(document, shape, options = undefined) {
2603
2642
  // a document that IS a series operator over the collection: the
2604
2643
  // operand's own conjuncts (and what the frozen spec implies) narrow
2605
2644
  // through the index, and the kernel decides over what comes back
2606
- if (aggregate === null && root.kind === 'op' && SERIES_ROOT_OPS.includes(root.name)) {
2645
+ if (!distinct && aggregate === null && root.kind === 'op' && SERIES_ROOT_OPS.includes(root.name)) {
2607
2646
  const temporal = planSeriesOperator(root, shape);
2608
2647
  if (temporal !== null) {
2609
2648
  // a peeled `$subsequence` composes as it does everywhere — over a
@@ -2637,6 +2676,29 @@ function planCollectionCore(document, shape, options = undefined) {
2637
2676
  const { plan } = flwor;
2638
2677
  const fullyPushed = flwor.whereFullyPushed && flwor.orderPushed;
2639
2678
 
2679
+ if (distinct) {
2680
+ const ref = flwor.projectedPath;
2681
+ const sameOrder = root.orderby === null || (ref !== null && plan.order !== null
2682
+ && plan.order.every((term) => canonicalOf(term.ref.segments) === canonicalOf(ref.segments)));
2683
+ if (fullyPushed && sameOrder && ref !== null && ref.type !== 'unknown'
2684
+ && flwor.group === null && flwor.bucket === null) {
2685
+ // DISTINCT and GROUP BY share the query language's key relation.
2686
+ // An absent path contributes no item, including under a window.
2687
+ plan.filter = conjoin(plan.filter, { p: 'typeIs', ref, types: [], positive: true });
2688
+ plan.group = { keys: [{ as: 'distinct', ref }], aggregates: [],
2689
+ tree: { p: 'key', index: 0 }, order: root.orderby === null ? 'first-seen'
2690
+ : plan.order.map((term) => ({ index: 0, desc: term.desc,
2691
+ nullsFirst: (term.emptyGreatest === true) === (term.desc === true) })) };
2692
+ plan.order = null;
2693
+ plan.window = windows.length === 0 ? null : composeWindows(windows);
2694
+ return { analysis, plan, mode: 'native', reasons: [], rowReturn: null,
2695
+ udfs: flwor.udfs, prefilters: flwor.prefilters, series: null };
2696
+ }
2697
+ return { analysis, plan: null, mode: 'set',
2698
+ reasons: [refusal('$distinct', PLAN_REASONS.distinctProjection)],
2699
+ rowReturn: null, udfs: [], prefilters: [], series: null };
2700
+ }
2701
+
2640
2702
  if (flwor.knn !== null) {
2641
2703
  // the k-nearest mode: the recognized ordering under a window with
2642
2704
  // a finite limit, composed exactly as pushed windows are. The plan
@@ -2661,6 +2723,14 @@ function planCollectionCore(document, shape, options = undefined) {
2661
2723
  rowReturn: null, udfs: [], prefilters: flwor.prefilters, series: null };
2662
2724
  }
2663
2725
  if (flwor.bucket !== null || flwor.group !== null || flwor.bucketRefusal != null) {
2726
+ if (aggregate.fn === 'count' && flwor.group !== null
2727
+ && ['object', 'array', 'lit'].includes(flwor.group.tree.p)
2728
+ && flwor.group.aggregates.every((entry) => entry.fn === 'rows')) {
2729
+ plan.group = flwor.group;
2730
+ plan.aggregate = { fn: 'count', ref: null };
2731
+ return { analysis, plan, mode: 'native', reasons: [], rowReturn: null,
2732
+ udfs: flwor.udfs, prefilters: flwor.prefilters, series: null };
2733
+ }
2664
2734
  // the phrase's items are its GROUPS; a COUNT(*) over the rows
2665
2735
  // answered the row count for a `$count` of the groups
2666
2736
  return {
@@ -2722,7 +2792,10 @@ function planCollectionCore(document, shape, options = undefined) {
2722
2792
  // the temporal bucket: only over a WHOLE pushed selection, because a
2723
2793
  // conjunct the residual would still apply would arrive after the rows
2724
2794
  // were already summed
2725
- if (flwor.group !== null && fullyPushed && windows.length === 0 && aggregate === null) {
2795
+ if (flwor.group !== null && fullyPushed && aggregate === null
2796
+ && (windows.length === 0 || ['object', 'array', 'lit'].includes(flwor.group.tree.p)
2797
+ || (flwor.group.tree.p === 'agg'
2798
+ && flwor.group.aggregates[flwor.group.tree.index].empty === 'zero'))) {
2726
2799
  plan.group = flwor.group;
2727
2800
  return { analysis, plan, mode: 'native', reasons: [], rowReturn: null,
2728
2801
  udfs: flwor.udfs, prefilters: flwor.prefilters, series: null };
@@ -2759,7 +2832,13 @@ function planCollectionCore(document, shape, options = undefined) {
2759
2832
 
2760
2833
  if (!groupedResidual && fullyPushed && flwor.projectionNative
2761
2834
  && (windows.length === 0 || plan.window !== null)) {
2762
- if (flwor.projectedPath !== null) plan.project = { path: flwor.projectedPath };
2835
+ if (flwor.projectedPath !== null) {
2836
+ plan.project = { path: flwor.projectedPath };
2837
+ // A path yields no item for an absent member. Windows count the
2838
+ // projected items, so discard those rows before applying LIMIT.
2839
+ if (windows.length > 0) plan.filter = conjoin(plan.filter,
2840
+ { p: 'typeIs', ref: flwor.projectedPath, types: [], positive: true });
2841
+ }
2763
2842
  else if (flwor.projectedTree !== null) plan.project = flwor.projectedTree;
2764
2843
  return { analysis, plan, mode: 'native', reasons: [], rowReturn: null,
2765
2844
  udfs: flwor.udfs, prefilters: flwor.prefilters,
@@ -2768,7 +2847,7 @@ function planCollectionCore(document, shape, options = undefined) {
2768
2847
 
2769
2848
  // the row residual: everything but the projection pushed
2770
2849
  if (!groupedResidual && fullyPushed && !flwor.projectionNative
2771
- && (windows.length === 0 || plan.window !== null)) {
2850
+ && windows.length === 0) {
2772
2851
  const rawFlwor = rawInner;
2773
2852
  const name = flwor.itName ?? 'it';
2774
2853
  return {
@@ -2930,9 +3009,8 @@ export function entityPathRef(node, slot, shape) {
2930
3009
  * collection projection composes — objects, arrays, literals and
2931
3010
  * singular member paths — with each leaf carrying the BINDING it reads
2932
3011
  * from, so the statement extracts it from that binding's alias. `null`
2933
- * when the shape is not one the plan can rebuild, and a projection with
2934
- * no path at all is refused for the same reason a collection's is: a
2935
- * statement needs a column to select.
3012
+ * when the shape is not one the plan can rebuild. A constant tree
3013
+ * fetches a row marker, preserving the selection's cardinality.
2936
3014
  * @param {any} node - the `$return` AST node
2937
3015
  * @param {Map<number, any>} byName - binding slot → binding
2938
3016
  * @returns {{ tree: any, leaves: { binding: string, ref: any }[] } | null}
@@ -2979,7 +3057,7 @@ function entityProjectionTree(node, byName) {
2979
3057
  return null;
2980
3058
  };
2981
3059
  const tree = build(node);
2982
- return tree === null || leaves.length === 0 ? null : { tree, leaves };
3060
+ return tree === null ? null : { tree, leaves };
2983
3061
  }
2984
3062
 
2985
3063
  /**
@@ -3037,6 +3115,10 @@ export function planEntityPredicate(node, slot, shape) {
3037
3115
  if (pred.p === 'and' || pred.p === 'or')
3038
3116
  return { ...pred, items: pred.items.map(reflavor) };
3039
3117
  if (pred.p === 'not') return { ...pred, item: reflavor(pred.item) };
3118
+ if (pred.p === 'refCmp') {
3119
+ const flavor = (ref) => reflavor({ p: 'typeIs', ref, types: [], positive: true }).ref;
3120
+ return { ...pred, left: flavor(pred.left), right: flavor(pred.right) };
3121
+ }
3040
3122
  if (!('ref' in pred) || pred.ref === null) return pred;
3041
3123
  const canonical = canonicalOf(pred.ref.segments);
3042
3124
  const flavored = shape.entityFlavors.get(canonical);
@@ -3155,6 +3237,29 @@ function planEntityQueryCore(document, entities, mapping, operators) {
3155
3237
  * that turns a product into a join. */
3156
3238
  const refinements = [];
3157
3239
  const filters = new Map(bindings.map((binding) => [binding.slot, null]));
3240
+ const scopedFilters = [];
3241
+ // Boolean composition may span bindings once mandatory equijoin
3242
+ // edges establish the candidate tuples. Each leaf still has exactly
3243
+ // one owner and uses that binding's existing total predicate forms.
3244
+ const scopedPredicate = (node) => {
3245
+ if (node.kind === 'op' && (node.name === '$and' || node.name === '$or')) {
3246
+ const items = node.args.map(scopedPredicate);
3247
+ return items.some((item) => item === null) ? null
3248
+ : { p: node.name === '$and' ? 'and' : 'or', items };
3249
+ }
3250
+ if (node.kind === 'op' && node.name === '$not') {
3251
+ const item = scopedPredicate(node.args[0]);
3252
+ return item === null ? null : { p: 'not', item };
3253
+ }
3254
+ const slots = new Set();
3255
+ collectBindingSlots(node, byName, slots);
3256
+ if (slots.size !== 1) return null;
3257
+ const slot = [...slots][0];
3258
+ const binding = byName.get(slot);
3259
+ const outcome = planEntityPredicate(node, slot, binding.shape);
3260
+ return 'refusal' in outcome ? null
3261
+ : { p: 'binding', binding: binding.name, filter: outcome.pred };
3262
+ };
3158
3263
  const reasons = [];
3159
3264
  let whereFullyPushed = true;
3160
3265
  for (const conjunct of conjuncts) {
@@ -3190,6 +3295,8 @@ function planEntityQueryCore(document, entities, mapping, operators) {
3190
3295
  const slots = new Set();
3191
3296
  collectBindingSlots(conjunct, byName, slots);
3192
3297
  if (slots.size !== 1) {
3298
+ const scoped = scopedPredicate(conjunct);
3299
+ if (scoped !== null) { scopedFilters.push(scoped); continue; }
3193
3300
  reasons.push(refusal('$where', ENTITY_REASONS.conjunctBinding));
3194
3301
  whereFullyPushed = false;
3195
3302
  continue;
@@ -3255,11 +3362,17 @@ function planEntityQueryCore(document, entities, mapping, operators) {
3255
3362
  // SHAPE the projection tree rebuilds from the bindings' members
3256
3363
  const retBinding = root.ret.kind === 'var' && root.ret.external !== true
3257
3364
  ? byName.get(root.ret.slot) : undefined;
3258
- const projection = retBinding === undefined && aggregate === null
3365
+ const projection = retBinding === undefined
3259
3366
  ? entityProjectionTree(root.ret, byName) : null;
3260
3367
  if (retBinding === undefined && projection === null) {
3261
3368
  reasons.push(refusal('$return', ENTITY_REASONS.projection));
3262
3369
  }
3370
+ if (projection?.tree.p === 'leaf' && (aggregate === 'count' || windows.length > 0)) {
3371
+ const leaf = projection.leaves[projection.tree.index];
3372
+ const binding = bindings.find((entry) => entry.name === leaf.binding);
3373
+ filters.set(binding.slot, conjoin(filters.get(binding.slot),
3374
+ { p: 'typeIs', ref: leaf.ref, types: [], positive: true }));
3375
+ }
3263
3376
 
3264
3377
  // ordering over flavored refs of either binding
3265
3378
  let order = null;
@@ -3292,6 +3405,10 @@ function planEntityQueryCore(document, entities, mapping, operators) {
3292
3405
  if (!fullyPushed) {
3293
3406
  return { analysis, mode: 'set', plan: null, referenced, reasons };
3294
3407
  }
3408
+ for (const filter of scopedFilters) {
3409
+ const slot = bindings[0].slot;
3410
+ filters.set(slot, conjoin(filters.get(slot), filter));
3411
+ }
3295
3412
 
3296
3413
  let window = null;
3297
3414
  if (windows.length > 0) {
package/src/query.js CHANGED
@@ -47,7 +47,7 @@ import {
47
47
  } from './residual.js';
48
48
  import { createCursor, createSyncCursor, drainPage, utf8Length, PAGE_LIMIT_DEFAULT, rowClassOf } from './cursor.js';
49
49
  import { deepFreeze } from '@jarenjs/core/object';
50
- import { derivedSlotValue, probeBox, probeVector, columnScore } from './derive.js';
50
+ import { derivedSlotValue, probeVector, columnScore } from './derive.js';
51
51
  import { cutCandidates, identityBatches } from './knn.js';
52
52
  import {
53
53
  deterministicFragment, registerFragment, registerAggregateOperator,
@@ -149,7 +149,8 @@ function slotValue(slot, externals, anchors = null) {
149
149
  return value === undefined ? undefined : JSON.stringify(value);
150
150
  }
151
151
  if ('derived' in slot)
152
- return derivedSlotValue(slot.derived, externals[slot.derived.external]);
152
+ return derivedSlotValue(slot.derived, slot.derived.kind === 'bboxAxis'
153
+ ? externals[slot.derived.external] : externals);
153
154
  if ('typed' in slot) {
154
155
  // a typed slot is only ever emitted beside the seek that fills it,
155
156
  // so a bind that never resolved the seeks is a defect in the
@@ -179,8 +180,14 @@ function externalSlotKinds(slots, rank) {
179
180
  const kinds = new Map();
180
181
  for (const slot of slots) {
181
182
  if ('external' in slot) kinds.set(slot.external, 'plain');
182
- else if ('derived' in slot && !kinds.has(slot.derived.external))
183
- kinds.set(slot.derived.external, 'derived');
183
+ else if ('derived' in slot) {
184
+ const inputs = slot.derived.kind === 'bboxAxis'
185
+ ? [slot.derived] : [slot.derived.centre, slot.derived.radius];
186
+ for (const input of inputs) {
187
+ if ('external' in input && !kinds.has(input.external))
188
+ kinds.set(input.external, 'derived');
189
+ }
190
+ }
184
191
  }
185
192
  if (rank !== null && 'ext' in rank.probe && !kinds.has(rank.probe.ext))
186
193
  kinds.set(rank.probe.ext, 'probe');
@@ -792,7 +799,15 @@ export function createQueryEngine(context) {
792
799
  const divertingExternal = (entry, externals) =>
793
800
  entry.externalNames.find((name) => {
794
801
  const kind = entry.externalSlotKinds.get(name);
795
- if (kind === 'derived') return probeBox(externals[name]) === null;
802
+ const invalidDerived = entry.slots.some((slot) => {
803
+ if (!('derived' in slot)) return false;
804
+ const inputs = slot.derived.kind === 'bboxAxis'
805
+ ? [slot.derived] : [slot.derived.centre, slot.derived.radius];
806
+ return inputs.some((input) => 'external' in input && input.external === name)
807
+ && !bindable(slotValue(slot, externals));
808
+ });
809
+ if (invalidDerived) return true;
810
+ if (kind === 'derived') return false;
796
811
  // a probe binds when SOME declared width takes it; a width the
797
812
  // model does not declare is the diversion it always was
798
813
  if (kind === 'probe') return rankAlternativeFor(entry, externals[name]) === null;
@@ -1193,7 +1208,7 @@ export function createQueryEngine(context) {
1193
1208
  return items;
1194
1209
  })) });
1195
1210
  }
1196
- if (entry.plan.group !== null) {
1211
+ if (entry.plan.group !== null && entry.plan.aggregate === null) {
1197
1212
  // a native grouping is a barrier: the groups are the answer
1198
1213
  return createCursor({ ...classified, signal, deadline, now: state.now, wrap: driverWrap,
1199
1214
  materialize: () => chain(guardScan(entry), () => chain(statementOf(entry), (statement) =>
@@ -1295,6 +1310,10 @@ export function createQueryEngine(context) {
1295
1310
  else if (pred.p === 'bboxRtree') touchedVirtual.add(pred.table);
1296
1311
  else if (pred.p === 'cellIn' || pred.p === 'cellPrefix')
1297
1312
  touchedColumns.add(pred.column);
1313
+ else if (pred.p === 'refCmp') {
1314
+ if (pred.left.column) touchedColumns.add(pred.left.column);
1315
+ if (pred.right.column) touchedColumns.add(pred.right.column);
1316
+ }
1298
1317
  else if ('ref' in pred && pred.ref?.column) touchedColumns.add(pred.ref.column);
1299
1318
  };
1300
1319
  collectColumns(entry.plan.filter);
@@ -1383,7 +1402,10 @@ export function createQueryEngine(context) {
1383
1402
  fn: entry2.fn, path: entry2.ref === null ? null : segmentsOf(entry2.ref) })),
1384
1403
  order: entry.plan.group.order === 'first-seen' ? 'first-seen'
1385
1404
  : entry.plan.group.order.map((term) => ({
1386
- key: entry.plan.group.keys[term.index].as, desc: term.desc })),
1405
+ ...(term.aggregate === undefined ? { key: entry.plan.group.keys[term.index].as }
1406
+ : { aggregate: entry.plan.group.aggregates[term.aggregate].fn,
1407
+ path: entry.plan.group.aggregates[term.aggregate].ref?.segments ?? null }),
1408
+ desc: term.desc })),
1387
1409
  },
1388
1410
  // a chain's element window (`[<phrase>]`): the phrase planned as
1389
1411
  // if bare, its rows answered as the one array item
@@ -1856,7 +1878,8 @@ export function createEntityQueryEngine(context) {
1856
1878
  for (const slot of entry.slots) {
1857
1879
  if (bindable(slotValue(slot, externals))) continue;
1858
1880
  const name = 'external' in slot ? slot.external
1859
- : 'derived' in slot ? slot.derived.external : null;
1881
+ : 'derived' in slot ? (slot.derived.kind === 'bboxAxis' ? slot.derived.external
1882
+ : [slot.derived.centre, slot.derived.radius].find((input) => 'external' in input)?.external) : null;
1860
1883
  return { construct: 'external', reason: BIND_REASONS.external(name, 'root') };
1861
1884
  }
1862
1885
  return null;
package/types/index.d.ts CHANGED
@@ -978,7 +978,7 @@ export interface LiveEventTime {
978
978
  }
979
979
 
980
980
  export interface LiveMode {
981
- readonly strategy: 'rows' | 'window' | 'accumulator' | 'group'
981
+ readonly strategy: 'rows' | 'window' | 'accumulator' | 'group' | 'distinct'
982
982
  | 'bucket' | 'rolling' | 'join' | 'graph' | 'nested-group' | 'rerun';
983
983
  readonly mode: 'incremental' | 'rerun';
984
984
  /** Present exactly when the strategy is 'rerun': the named reason. */