@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/ARCHITECTURE.md +50 -7
- package/README.md +44 -6
- package/docs/LIVE-FORMAT.md +48 -13
- package/docs/MODEL-FORMAT.md +31 -14
- package/docs/REPLICATION-FORMAT.md +19 -13
- package/package.json +4 -4
- package/src/algebra.js +9 -3
- package/src/dag-job.js +1 -1
- package/src/derive.js +14 -3
- package/src/dialect.js +2 -0
- package/src/dialects/check-read.js +151 -0
- package/src/dialects/postgres.js +14 -2
- package/src/dialects/sqlite.js +5 -1
- package/src/emit.js +54 -13
- package/src/introspect.js +37 -5
- package/src/live-nested.js +27 -10
- package/src/live.js +47 -135
- package/src/plan.js +163 -46
- package/src/query.js +31 -8
- package/types/index.d.ts +1 -1
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
|
|
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
|
-
|
|
134
|
-
|
|
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
|
-
|
|
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
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
if (
|
|
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
|
-
|
|
782
|
-
if (
|
|
783
|
-
|
|
784
|
-
|
|
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),
|
|
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
|
-
|
|
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
|
|
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'
|
|
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.
|
|
2168
|
-
*
|
|
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
|
-
* @
|
|
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
|
-
|
|
2181
|
-
|
|
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;
|
|
2191
|
-
* function call,
|
|
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
|
|
2198
|
-
*
|
|
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 =
|
|
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
|
|
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 &&
|
|
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)
|
|
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
|
-
&&
|
|
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
|
|
2934
|
-
*
|
|
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
|
|
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
|
|
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,
|
|
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,
|
|
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
|
|
183
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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. */
|