@jarenjs/db 0.73.0 → 0.83.2
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 +70 -7
- package/README.md +69 -6
- package/docs/HOSTS.md +17 -0
- package/docs/JOBS-FORMAT.md +26 -0
- package/docs/LIVE-FORMAT.md +52 -13
- package/docs/MIGRATION-FORMAT.md +34 -0
- package/docs/MODEL-FORMAT.md +163 -15
- package/docs/NATIVE-PLANS.md +111 -0
- package/docs/REPLICATION-FORMAT.md +19 -13
- package/docs/SEARCH.md +55 -0
- package/package.json +8 -4
- package/schemas/jaren-migration.draft-07.schema.json +54 -5
- package/schemas/jaren-migration.schema.json +49 -0
- package/schemas/jaren-model.authoring.schema.json +360 -0
- package/schemas/jaren-model.draft-07.schema.json +128 -0
- package/schemas/jaren-model.schema.json +128 -0
- package/src/algebra.js +26 -4
- package/src/backup.js +12 -7
- package/src/cursor.js +27 -4
- package/src/dag-job.js +2 -1
- package/src/ddl.js +13 -0
- package/src/derive.js +14 -3
- package/src/dialect.js +12 -0
- package/src/dialects/check-read.js +151 -0
- package/src/dialects/invariant-sql.js +117 -0
- package/src/dialects/postgres.js +28 -4
- package/src/dialects/sqlite.js +23 -3
- package/src/driver.js +1 -0
- package/src/drivers/bun.js +22 -4
- package/src/emit.js +133 -25
- package/src/entity.js +98 -41
- package/src/errors.js +8 -0
- package/src/graph.js +8 -1
- package/src/index.js +3 -0
- package/src/introspect.js +81 -12
- package/src/invariants.js +45 -0
- package/src/jobs.js +39 -6
- package/src/live-nested.js +27 -10
- package/src/live.js +51 -136
- package/src/migrate.js +136 -22
- package/src/model.js +12 -0
- package/src/mutation.js +165 -0
- package/src/physical.js +147 -0
- package/src/plan.js +275 -64
- package/src/query.js +175 -78
- package/src/search.js +144 -0
- package/src/sql.js +60 -0
- package/src/store.js +49 -13
- package/src/tracker.js +63 -39
- package/src/window.js +1 -0
- package/types/index.d.ts +59 -4
- package/types/search.d.ts +20 -0
- package/types/typed.d.ts +1 -0
package/src/plan.js
CHANGED
|
@@ -95,11 +95,12 @@ const KIND_REASONS = {
|
|
|
95
95
|
|
|
96
96
|
/** Why one PREDICATE stayed in the engine. */
|
|
97
97
|
const PREDICATE_REASONS = {
|
|
98
|
+
physicalCodec: 'this codec or NULL policy requires decoded-row evaluation',
|
|
98
99
|
notPredicate: 'not a predicate the planner translates',
|
|
99
100
|
negatedPrefilter: 'a negated predicate cannot ride an implied pre-filter '
|
|
100
101
|
+ '(negating a superset drops rows)',
|
|
101
102
|
existence: 'existence tests translate only over a singular member path on the binding',
|
|
102
|
-
joinTerritory: 'comparisons
|
|
103
|
+
joinTerritory: 'path comparisons require the same non-null numeric or string family',
|
|
103
104
|
operands: 'comparisons translate only between a singular member path and a literal or external',
|
|
104
105
|
compoundLiteral: 'array and object literals have no guarded native comparison form',
|
|
105
106
|
stringSubject: 'string operators translate only over schema-typed string paths '
|
|
@@ -130,8 +131,8 @@ const PLAN_REASONS = {
|
|
|
130
131
|
countProjection: 'count translates only over the bare binding or one member path '
|
|
131
132
|
+ '(a projected return can change the item count)',
|
|
132
133
|
groupedAggregate: 'an aggregate over a grouped phrase folds its groups, which the engine does',
|
|
133
|
-
|
|
134
|
-
|
|
134
|
+
distinctProjection: 'distinct translates over one typed scalar member path, unordered or ordered by that same path',
|
|
135
|
+
windowedGroup: 'a window over groups whose return can omit an item needs the engine cardinality',
|
|
135
136
|
aggregatePath: 'aggregates translate only over a singular schema-typed path '
|
|
136
137
|
+ '(the engine ERRORS on non-conforming operands)',
|
|
137
138
|
};
|
|
@@ -187,7 +188,8 @@ const ENTITY_REASONS = {
|
|
|
187
188
|
+ 'a binding nothing connects is a cartesian product, which is engine work',
|
|
188
189
|
conjunctBinding: 'a conjunct must belong to one binding (or be the single join equality)',
|
|
189
190
|
external: 'externals compare only against entity columns in this version',
|
|
190
|
-
projection: 'entity
|
|
191
|
+
projection: 'this entity return or grouping needs decoded-row evaluation',
|
|
192
|
+
groupOrder: 'first-seen grouping over a compound physical key requires tuple ordering',
|
|
191
193
|
order: 'ordering translates only over typed entity paths (never a boolean, never a document '
|
|
192
194
|
+ 'path that admits null)',
|
|
193
195
|
};
|
|
@@ -756,7 +758,8 @@ function planDistanceBound(node, itSlot, shape) {
|
|
|
756
758
|
const distanceNode = upperOnLeft ? node.args[0] : node.args[1];
|
|
757
759
|
const radiusNode = upperOnLeft ? node.args[1] : node.args[0];
|
|
758
760
|
const radius = constantOf(radiusNode);
|
|
759
|
-
|
|
761
|
+
const radiusExternal = radiusNode.kind === 'var' && radiusNode.external === true;
|
|
762
|
+
if (!radiusExternal && (radius === null || typeof radius.value !== 'number'))
|
|
760
763
|
return { refusal: refusal('$distance', SPATIAL_REASONS.operand) };
|
|
761
764
|
|
|
762
765
|
let subjectAt = 0;
|
|
@@ -769,21 +772,33 @@ function planDistanceBound(node, itSlot, shape) {
|
|
|
769
772
|
|
|
770
773
|
const probeNode = distanceNode.args[subjectAt === 0 ? 1 : 0];
|
|
771
774
|
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) {
|
|
775
|
+
const centreExternal = probeNode.kind === 'var' && probeNode.external === true;
|
|
776
|
+
if (!centreExternal && constant === null)
|
|
777
|
+
return { refusal: refusal('$distance', SPATIAL_REASONS.operand) };
|
|
778
|
+
if (!radiusExternal && (!Number.isFinite(radius.value) || radius.value < 0)) {
|
|
779
779
|
return { refusal: refusal('$distance', SPATIAL_REASONS.radius) };
|
|
780
780
|
}
|
|
781
|
-
|
|
782
|
-
if (
|
|
783
|
-
|
|
784
|
-
|
|
781
|
+
let probe;
|
|
782
|
+
if (centreExternal || radiusExternal) {
|
|
783
|
+
// Both inputs belong to one closed derived slot. Bind-time failure
|
|
784
|
+
// diverts the whole query, preserving the engine's errors and the
|
|
785
|
+
// candidates on the far side of a pole or the antimeridian.
|
|
786
|
+
probe = { circle: {
|
|
787
|
+
centre: centreExternal ? { external: probeNode.name } : { literal: constant.value },
|
|
788
|
+
radius: radiusExternal ? { external: radiusNode.name } : { literal: radius.value },
|
|
789
|
+
} };
|
|
790
|
+
}
|
|
791
|
+
else {
|
|
792
|
+
const at = probePosition(constant.value);
|
|
793
|
+
if (at === null) return { refusal: refusal('$distance', SPATIAL_REASONS.unbounded) };
|
|
794
|
+
const box = probeCircleBox(at, radius.value);
|
|
795
|
+
if (box === null) return { refusal: refusal('$distance', SPATIAL_REASONS.pole) };
|
|
796
|
+
if (box[0] < -180 || box[2] > 180)
|
|
797
|
+
return { refusal: refusal('$distance', SPATIAL_REASONS.wrapped) };
|
|
798
|
+
probe = { box };
|
|
799
|
+
}
|
|
785
800
|
|
|
786
|
-
const conjunct = boxConjunct(columns, rtreeTableOf(shape, subject.canonical),
|
|
801
|
+
const conjunct = boxConjunct(columns, rtreeTableOf(shape, subject.canonical), probe);
|
|
787
802
|
return promotion(conjunct.pred,
|
|
788
803
|
{ construct: '$distance', via: conjunct.via, columns: conjunct.columns, exact: false },
|
|
789
804
|
[refusal('$distance', SPATIAL_REASONS.distance)]);
|
|
@@ -1305,8 +1320,14 @@ function planPredicate(node, itSlot, shape) {
|
|
|
1305
1320
|
const ref = pathRef(left, itSlot, shape);
|
|
1306
1321
|
const operand = operandOf(right);
|
|
1307
1322
|
if (ref === null || operand === null) {
|
|
1308
|
-
|
|
1323
|
+
const other = pathRef(right, itSlot, shape);
|
|
1324
|
+
if (ref !== null && other !== null) {
|
|
1325
|
+
const family = (item) => isNumericType(item.type) ? 'number' : item.type;
|
|
1326
|
+
if (orderable(ref, shape.schema) && orderable(other, shape.schema)
|
|
1327
|
+
&& family(ref) === family(other))
|
|
1328
|
+
return exactly({ p: 'refCmp', op, left: ref, right: other });
|
|
1309
1329
|
return { refusal: refusal(node.name, PREDICATE_REASONS.joinTerritory) };
|
|
1330
|
+
}
|
|
1310
1331
|
return { refusal: refusal(node.name, PREDICATE_REASONS.operands) };
|
|
1311
1332
|
}
|
|
1312
1333
|
if ('lit' in operand) {
|
|
@@ -2057,7 +2078,7 @@ function planSeriesOperator(root, shape) {
|
|
|
2057
2078
|
* The rules that make it agree with the engine, each one a refusal
|
|
2058
2079
|
* rather than an approximation:
|
|
2059
2080
|
*
|
|
2060
|
-
* - a key is a singular schema-typed path
|
|
2081
|
+
* - a key is a singular schema-typed scalar path, so
|
|
2061
2082
|
* the group SQL forms is the group the engine forms. An ABSENT key is
|
|
2062
2083
|
* its own group, and its value comes back with its JSON type beside
|
|
2063
2084
|
* it, so the decoder can leave the member out exactly as the object
|
|
@@ -2074,13 +2095,13 @@ function planSeriesOperator(root, shape) {
|
|
|
2074
2095
|
* @param {any} shape
|
|
2075
2096
|
* @returns {any | null}
|
|
2076
2097
|
*/
|
|
2077
|
-
function planGeneralGrouping(node, itSlot, shape) {
|
|
2098
|
+
function planGeneralGrouping(node, itSlot, shape, resolve = pathRef) {
|
|
2078
2099
|
const keys = [];
|
|
2079
2100
|
/** @type {Map<number, number>} */
|
|
2080
2101
|
const keySlot = new Map();
|
|
2081
2102
|
for (const key of node.groupby.keys) {
|
|
2082
|
-
const ref =
|
|
2083
|
-
if (ref === null || ref.type === 'unknown'
|
|
2103
|
+
const ref = resolve(key.expr, itSlot, shape);
|
|
2104
|
+
if (ref === null || ref.type === 'unknown')
|
|
2084
2105
|
return null;
|
|
2085
2106
|
keySlot.set(key.slot, keys.length);
|
|
2086
2107
|
keys.push({ as: key.name, ref });
|
|
@@ -2094,7 +2115,7 @@ function planGeneralGrouping(node, itSlot, shape) {
|
|
|
2094
2115
|
if (child.kind === 'var' && child.external !== true && keySlot.has(child.slot))
|
|
2095
2116
|
return { p: 'key', index: keySlot.get(child.slot) };
|
|
2096
2117
|
if (child.kind === 'op') {
|
|
2097
|
-
const entry = groupAggregate(child, itSlot, shape);
|
|
2118
|
+
const entry = groupAggregate(child, itSlot, shape, resolve);
|
|
2098
2119
|
if (entry === null) return null;
|
|
2099
2120
|
// one aggregate per distinct (function, path): two members that
|
|
2100
2121
|
// ask the same question are one SQL aggregate
|
|
@@ -2129,8 +2150,12 @@ function planGeneralGrouping(node, itSlot, shape) {
|
|
|
2129
2150
|
};
|
|
2130
2151
|
const tree = build(node.ret);
|
|
2131
2152
|
if (tree === null) return null;
|
|
2132
|
-
const order = groupOrder(node.orderby, keySlot);
|
|
2153
|
+
const order = groupOrder(node.orderby, keySlot, build);
|
|
2133
2154
|
if (order === null) return null;
|
|
2155
|
+
// Group equality accepts null and boolean keys; ordering them raises
|
|
2156
|
+
// JQ2005. SQL ordering must not hide that engine error.
|
|
2157
|
+
if (order !== 'first-seen' && order.some((term) =>
|
|
2158
|
+
term.aggregate === undefined && !orderable(keys[term.index].ref, shape.schema))) return null;
|
|
2134
2159
|
return { keys, aggregates, tree, order };
|
|
2135
2160
|
}
|
|
2136
2161
|
|
|
@@ -2144,14 +2169,36 @@ function planGeneralGrouping(node, itSlot, shape) {
|
|
|
2144
2169
|
* @param {any} shape
|
|
2145
2170
|
* @returns {{ as: string, fn: string, ref: any, empty: string } | null}
|
|
2146
2171
|
*/
|
|
2147
|
-
function groupAggregate(node, itSlot, shape) {
|
|
2172
|
+
function groupAggregate(node, itSlot, shape, resolve = pathRef) {
|
|
2173
|
+
// SQL's nullable sum is authored explicitly: test for any non-null
|
|
2174
|
+
// values, sum exactly that sequence, otherwise return a literal null.
|
|
2175
|
+
if (node.name === '$if' && node.args[0]?.name === '$exists'
|
|
2176
|
+
&& node.args[1]?.name === '$sum' && node.args[2]?.kind === 'literal' && node.args[2].value === null) {
|
|
2177
|
+
const filtered = (phrase) => {
|
|
2178
|
+
if (phrase?.kind !== 'flwor' || phrase.forBindings.length !== 1 || phrase.letBindings.length
|
|
2179
|
+
|| phrase.groupby !== null || phrase.orderby !== null || phrase.fold !== null
|
|
2180
|
+
|| phrase.count !== null || phrase.asChecks !== null) return null;
|
|
2181
|
+
const binding = phrase.forBindings[0];
|
|
2182
|
+
if (!isItVar(unpacked(binding.expr), itSlot) || binding.window !== null || binding.atSlot !== -1 || binding.allowingEmpty) return null;
|
|
2183
|
+
const ref = resolve(phrase.ret, binding.slot, shape);
|
|
2184
|
+
const where = phrase.where;
|
|
2185
|
+
if (ref === null || !isNumericType(ref.type) || where?.name !== '$ne') return null;
|
|
2186
|
+
const other = resolve(where.args[0], binding.slot, shape);
|
|
2187
|
+
return other !== null && canonicalOf(other.segments) === canonicalOf(ref.segments)
|
|
2188
|
+
&& where.args[1]?.kind === 'literal' && where.args[1].value === null ? ref : null;
|
|
2189
|
+
};
|
|
2190
|
+
const test = filtered(node.args[0].args[0]);
|
|
2191
|
+
const sum = filtered(node.args[1].args[0]);
|
|
2192
|
+
return test !== null && sum !== null && canonicalOf(test.segments) === canonicalOf(sum.segments)
|
|
2193
|
+
? { fn: 'sum', ref: sum, empty: 'null' } : null;
|
|
2194
|
+
}
|
|
2148
2195
|
if (node.name === '$count') {
|
|
2149
2196
|
return isItVar(node.args[0], itSlot)
|
|
2150
2197
|
? { fn: 'rows', ref: null, empty: 'zero' } : null;
|
|
2151
2198
|
}
|
|
2152
2199
|
const fn = AGGREGATES.get(node.name);
|
|
2153
2200
|
if (fn === undefined || fn === 'count') return null;
|
|
2154
|
-
const ref =
|
|
2201
|
+
const ref = resolve(node.args[0], itSlot, shape);
|
|
2155
2202
|
const numeric = fn === 'sum' || fn === 'avg';
|
|
2156
2203
|
const acceptable = ref !== null
|
|
2157
2204
|
&& (numeric ? isNumericType(ref.type) : ref.type !== 'unknown')
|
|
@@ -2164,21 +2211,32 @@ function groupAggregate(node, itSlot, shape) {
|
|
|
2164
2211
|
/**
|
|
2165
2212
|
* How the groups come out: the engine's order of FIRST APPEARANCE
|
|
2166
2213
|
* (§6.5) when nothing declares otherwise, else the group-key ordering
|
|
2167
|
-
* an `$orderby` asked for.
|
|
2168
|
-
*
|
|
2169
|
-
* by that the plan could reproduce.
|
|
2214
|
+
* an `$orderby` asked for. Proven aggregate expressions share the
|
|
2215
|
+
* return's aggregate slots, including aggregates used only to order.
|
|
2170
2216
|
* @param {any} orderby
|
|
2171
2217
|
* @param {Map<number, number>} keySlot
|
|
2172
|
-
* @
|
|
2218
|
+
* @param {(node: any) => any} build
|
|
2219
|
+
* @returns {any}
|
|
2173
2220
|
*/
|
|
2174
|
-
function groupOrder(orderby, keySlot) {
|
|
2221
|
+
function groupOrder(orderby, keySlot, build) {
|
|
2175
2222
|
if (orderby === null) return 'first-seen';
|
|
2176
2223
|
const terms = [];
|
|
2177
2224
|
for (const spec of orderby.specs) {
|
|
2178
2225
|
if (spec.collation !== null || spec.collationName !== null) return null;
|
|
2179
2226
|
const key = spec.key;
|
|
2180
|
-
|
|
2181
|
-
|
|
2227
|
+
let target;
|
|
2228
|
+
if (key.kind === 'var' && key.external !== true && keySlot.has(key.slot))
|
|
2229
|
+
target = { index: keySlot.get(key.slot) };
|
|
2230
|
+
else {
|
|
2231
|
+
// SQL SUM/AVG may accumulate in a different order (SQLite also
|
|
2232
|
+
// compensates rounding). Such a value cannot safely decide group
|
|
2233
|
+
// order. Counts and extrema have no accumulation-order ambiguity.
|
|
2234
|
+
if (!['$count', '$min', '$max'].includes(key.name)) return null;
|
|
2235
|
+
const aggregate = key.kind === 'op' ? build(key) : null;
|
|
2236
|
+
if (aggregate?.p !== 'agg') return null;
|
|
2237
|
+
target = { aggregate: aggregate.index };
|
|
2238
|
+
}
|
|
2239
|
+
terms.push({ ...target, desc: spec.desc === true,
|
|
2182
2240
|
nullsFirst: (spec.emptyGreatest === true) === (spec.desc === true) });
|
|
2183
2241
|
}
|
|
2184
2242
|
return terms;
|
|
@@ -2187,16 +2245,14 @@ function groupOrder(orderby, keySlot) {
|
|
|
2187
2245
|
/**
|
|
2188
2246
|
* The projection TREE one `$return` compiles to, or `null` when the
|
|
2189
2247
|
* 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
|
|
2248
|
+
* literals, whole collection bindings and singular member paths compose;
|
|
2249
|
+
* a function call, conditional or dynamic member refuses the WHOLE projection, because a projection
|
|
2193
2250
|
* that dropped part of what the caller asked for would be a wrong
|
|
2194
2251
|
* answer, not a partial one.
|
|
2195
2252
|
*
|
|
2196
2253
|
* 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.
|
|
2254
|
+
* column and two leaves pointing at it. A constant tree needs only a
|
|
2255
|
+
* row marker from SQL: the selection contributes its cardinality.
|
|
2200
2256
|
* @param {any} node - the `$return` AST node
|
|
2201
2257
|
* @param {number} itSlot
|
|
2202
2258
|
* @param {any} shape
|
|
@@ -2210,8 +2266,9 @@ function projectionTree(node, itSlot, shape) {
|
|
|
2210
2266
|
const build = (child) => {
|
|
2211
2267
|
assertDecidedKind(child);
|
|
2212
2268
|
if (child.kind === 'literal') return { p: 'lit', value: child.value };
|
|
2213
|
-
if (child.kind === 'path') {
|
|
2214
|
-
const ref =
|
|
2269
|
+
if (child.kind === 'path' || isItVar(child, itSlot)) {
|
|
2270
|
+
const ref = isItVar(child, itSlot)
|
|
2271
|
+
? { segments: [], type: 'unknown', column: null } : pathRef(child, itSlot, shape);
|
|
2215
2272
|
if (ref === null) return null;
|
|
2216
2273
|
const canonical = canonicalOf(ref.segments);
|
|
2217
2274
|
let index = byCanonical.get(canonical);
|
|
@@ -2243,7 +2300,7 @@ function projectionTree(node, itSlot, shape) {
|
|
|
2243
2300
|
return null;
|
|
2244
2301
|
};
|
|
2245
2302
|
const tree = build(node);
|
|
2246
|
-
return tree === null
|
|
2303
|
+
return tree === null ? null : { tree, leaves };
|
|
2247
2304
|
}
|
|
2248
2305
|
|
|
2249
2306
|
/**
|
|
@@ -2581,10 +2638,16 @@ function planCollectionCore(document, shape, options = undefined) {
|
|
|
2581
2638
|
// pushable at all — a SQL fold over zero rows never sees a second
|
|
2582
2639
|
// operand, and `aggregateSpec` refuses the declaration at open — so
|
|
2583
2640
|
// the recognizer here reads the phrase and nothing else
|
|
2641
|
+
const distinct = root.kind === 'op' && root.name === '$distinct';
|
|
2642
|
+
if (distinct) {
|
|
2643
|
+
root = root.args[0];
|
|
2644
|
+
rawInner = rawInner?.$distinct;
|
|
2645
|
+
assertDecidedKind(root);
|
|
2646
|
+
}
|
|
2584
2647
|
let aggregate = null;
|
|
2585
2648
|
const registeredAggregate = root.kind === 'op' && !AGGREGATES.has(root.name)
|
|
2586
2649
|
? (options?.aggregate?.(root.name) ?? null) : null;
|
|
2587
|
-
if (root.kind === 'op' && (AGGREGATES.has(root.name) || registeredAggregate !== null)) {
|
|
2650
|
+
if (!distinct && root.kind === 'op' && (AGGREGATES.has(root.name) || registeredAggregate !== null)) {
|
|
2588
2651
|
if (windows.length > 0) {
|
|
2589
2652
|
return {
|
|
2590
2653
|
analysis, plan: null, mode: 'set',
|
|
@@ -2603,7 +2666,7 @@ function planCollectionCore(document, shape, options = undefined) {
|
|
|
2603
2666
|
// a document that IS a series operator over the collection: the
|
|
2604
2667
|
// operand's own conjuncts (and what the frozen spec implies) narrow
|
|
2605
2668
|
// through the index, and the kernel decides over what comes back
|
|
2606
|
-
if (aggregate === null && root.kind === 'op' && SERIES_ROOT_OPS.includes(root.name)) {
|
|
2669
|
+
if (!distinct && aggregate === null && root.kind === 'op' && SERIES_ROOT_OPS.includes(root.name)) {
|
|
2607
2670
|
const temporal = planSeriesOperator(root, shape);
|
|
2608
2671
|
if (temporal !== null) {
|
|
2609
2672
|
// a peeled `$subsequence` composes as it does everywhere — over a
|
|
@@ -2637,6 +2700,29 @@ function planCollectionCore(document, shape, options = undefined) {
|
|
|
2637
2700
|
const { plan } = flwor;
|
|
2638
2701
|
const fullyPushed = flwor.whereFullyPushed && flwor.orderPushed;
|
|
2639
2702
|
|
|
2703
|
+
if (distinct) {
|
|
2704
|
+
const ref = flwor.projectedPath;
|
|
2705
|
+
const sameOrder = root.orderby === null || (ref !== null && plan.order !== null
|
|
2706
|
+
&& plan.order.every((term) => canonicalOf(term.ref.segments) === canonicalOf(ref.segments)));
|
|
2707
|
+
if (fullyPushed && sameOrder && ref !== null && ref.type !== 'unknown'
|
|
2708
|
+
&& flwor.group === null && flwor.bucket === null) {
|
|
2709
|
+
// DISTINCT and GROUP BY share the query language's key relation.
|
|
2710
|
+
// An absent path contributes no item, including under a window.
|
|
2711
|
+
plan.filter = conjoin(plan.filter, { p: 'typeIs', ref, types: [], positive: true });
|
|
2712
|
+
plan.group = { keys: [{ as: 'distinct', ref }], aggregates: [],
|
|
2713
|
+
tree: { p: 'key', index: 0 }, order: root.orderby === null ? 'first-seen'
|
|
2714
|
+
: plan.order.map((term) => ({ index: 0, desc: term.desc,
|
|
2715
|
+
nullsFirst: (term.emptyGreatest === true) === (term.desc === true) })) };
|
|
2716
|
+
plan.order = null;
|
|
2717
|
+
plan.window = windows.length === 0 ? null : composeWindows(windows);
|
|
2718
|
+
return { analysis, plan, mode: 'native', reasons: [], rowReturn: null,
|
|
2719
|
+
udfs: flwor.udfs, prefilters: flwor.prefilters, series: null };
|
|
2720
|
+
}
|
|
2721
|
+
return { analysis, plan: null, mode: 'set',
|
|
2722
|
+
reasons: [refusal('$distinct', PLAN_REASONS.distinctProjection)],
|
|
2723
|
+
rowReturn: null, udfs: [], prefilters: [], series: null };
|
|
2724
|
+
}
|
|
2725
|
+
|
|
2640
2726
|
if (flwor.knn !== null) {
|
|
2641
2727
|
// the k-nearest mode: the recognized ordering under a window with
|
|
2642
2728
|
// a finite limit, composed exactly as pushed windows are. The plan
|
|
@@ -2661,6 +2747,14 @@ function planCollectionCore(document, shape, options = undefined) {
|
|
|
2661
2747
|
rowReturn: null, udfs: [], prefilters: flwor.prefilters, series: null };
|
|
2662
2748
|
}
|
|
2663
2749
|
if (flwor.bucket !== null || flwor.group !== null || flwor.bucketRefusal != null) {
|
|
2750
|
+
if (aggregate.fn === 'count' && flwor.group !== null
|
|
2751
|
+
&& ['object', 'array', 'lit'].includes(flwor.group.tree.p)
|
|
2752
|
+
&& flwor.group.aggregates.every((entry) => entry.fn === 'rows')) {
|
|
2753
|
+
plan.group = flwor.group;
|
|
2754
|
+
plan.aggregate = { fn: 'count', ref: null };
|
|
2755
|
+
return { analysis, plan, mode: 'native', reasons: [], rowReturn: null,
|
|
2756
|
+
udfs: flwor.udfs, prefilters: flwor.prefilters, series: null };
|
|
2757
|
+
}
|
|
2664
2758
|
// the phrase's items are its GROUPS; a COUNT(*) over the rows
|
|
2665
2759
|
// answered the row count for a `$count` of the groups
|
|
2666
2760
|
return {
|
|
@@ -2722,7 +2816,10 @@ function planCollectionCore(document, shape, options = undefined) {
|
|
|
2722
2816
|
// the temporal bucket: only over a WHOLE pushed selection, because a
|
|
2723
2817
|
// conjunct the residual would still apply would arrive after the rows
|
|
2724
2818
|
// were already summed
|
|
2725
|
-
if (flwor.group !== null && fullyPushed &&
|
|
2819
|
+
if (flwor.group !== null && fullyPushed && aggregate === null
|
|
2820
|
+
&& (windows.length === 0 || ['object', 'array', 'lit'].includes(flwor.group.tree.p)
|
|
2821
|
+
|| (flwor.group.tree.p === 'agg'
|
|
2822
|
+
&& flwor.group.aggregates[flwor.group.tree.index].empty === 'zero'))) {
|
|
2726
2823
|
plan.group = flwor.group;
|
|
2727
2824
|
return { analysis, plan, mode: 'native', reasons: [], rowReturn: null,
|
|
2728
2825
|
udfs: flwor.udfs, prefilters: flwor.prefilters, series: null };
|
|
@@ -2759,7 +2856,13 @@ function planCollectionCore(document, shape, options = undefined) {
|
|
|
2759
2856
|
|
|
2760
2857
|
if (!groupedResidual && fullyPushed && flwor.projectionNative
|
|
2761
2858
|
&& (windows.length === 0 || plan.window !== null)) {
|
|
2762
|
-
if (flwor.projectedPath !== null)
|
|
2859
|
+
if (flwor.projectedPath !== null) {
|
|
2860
|
+
plan.project = { path: flwor.projectedPath };
|
|
2861
|
+
// A path yields no item for an absent member. Windows count the
|
|
2862
|
+
// projected items, so discard those rows before applying LIMIT.
|
|
2863
|
+
if (windows.length > 0) plan.filter = conjoin(plan.filter,
|
|
2864
|
+
{ p: 'typeIs', ref: flwor.projectedPath, types: [], positive: true });
|
|
2865
|
+
}
|
|
2763
2866
|
else if (flwor.projectedTree !== null) plan.project = flwor.projectedTree;
|
|
2764
2867
|
return { analysis, plan, mode: 'native', reasons: [], rowReturn: null,
|
|
2765
2868
|
udfs: flwor.udfs, prefilters: flwor.prefilters,
|
|
@@ -2768,7 +2871,7 @@ function planCollectionCore(document, shape, options = undefined) {
|
|
|
2768
2871
|
|
|
2769
2872
|
// the row residual: everything but the projection pushed
|
|
2770
2873
|
if (!groupedResidual && fullyPushed && !flwor.projectionNative
|
|
2771
|
-
&&
|
|
2874
|
+
&& windows.length === 0) {
|
|
2772
2875
|
const rawFlwor = rawInner;
|
|
2773
2876
|
const name = flwor.itName ?? 'it';
|
|
2774
2877
|
return {
|
|
@@ -2879,10 +2982,14 @@ export function entityShape(entity, entityMapping) {
|
|
|
2879
2982
|
for (const column of entityMapping.columns) {
|
|
2880
2983
|
const epoch = column.source === 'epoch(document)';
|
|
2881
2984
|
flavors.set(canonicalOf([{ name: column.name }]), {
|
|
2882
|
-
column: column.name,
|
|
2985
|
+
column: column.physical ?? column.name,
|
|
2883
2986
|
flavor: epoch ? 'entity-epoch' : 'entity-column',
|
|
2884
2987
|
storage: column.storage,
|
|
2988
|
+
codec: column.codec,
|
|
2989
|
+
codecColumn: column.codec === undefined ? undefined : column,
|
|
2990
|
+
nullPolicy: column.null,
|
|
2885
2991
|
format: epoch ? entity.properties.get(column.name)?.format : undefined,
|
|
2992
|
+
unsafe: column.codec !== undefined && !['text', 'integer', 'number', 'boolean', 'date', 'datetime'].includes(column.codec),
|
|
2886
2993
|
});
|
|
2887
2994
|
}
|
|
2888
2995
|
for (const fk of entityMapping.foreignKeys) {
|
|
@@ -2895,6 +3002,7 @@ export function entityShape(entity, entityMapping) {
|
|
|
2895
3002
|
schema: entity.schema,
|
|
2896
3003
|
columnByCanonical: new Map(),
|
|
2897
3004
|
entityFlavors: flavors,
|
|
3005
|
+
columnOnly: entityMapping.document === false,
|
|
2898
3006
|
};
|
|
2899
3007
|
}
|
|
2900
3008
|
|
|
@@ -2911,6 +3019,7 @@ export function entityPathRef(node, slot, shape) {
|
|
|
2911
3019
|
if (ref === null) return null;
|
|
2912
3020
|
const canonical = canonicalOf(ref.segments);
|
|
2913
3021
|
const flavored = shape.entityFlavors.get(canonical);
|
|
3022
|
+
if (flavored?.unsafe || (shape.columnOnly && flavored === undefined)) return null;
|
|
2914
3023
|
if (flavored !== undefined) {
|
|
2915
3024
|
return {
|
|
2916
3025
|
...ref,
|
|
@@ -2918,6 +3027,9 @@ export function entityPathRef(node, slot, shape) {
|
|
|
2918
3027
|
flavor: flavored.flavor,
|
|
2919
3028
|
storage: flavored.storage,
|
|
2920
3029
|
format: flavored.format,
|
|
3030
|
+
nullPolicy: flavored.nullPolicy,
|
|
3031
|
+
codec: flavored.codec,
|
|
3032
|
+
codecColumn: flavored.codecColumn,
|
|
2921
3033
|
};
|
|
2922
3034
|
}
|
|
2923
3035
|
// a nested path rides the JSONB document with the phase-A guards;
|
|
@@ -2930,20 +3042,54 @@ export function entityPathRef(node, slot, shape) {
|
|
|
2930
3042
|
* collection projection composes — objects, arrays, literals and
|
|
2931
3043
|
* singular member paths — with each leaf carrying the BINDING it reads
|
|
2932
3044
|
* 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.
|
|
3045
|
+
* when the shape is not one the plan can rebuild. A constant tree
|
|
3046
|
+
* fetches a row marker, preserving the selection's cardinality.
|
|
2936
3047
|
* @param {any} node - the `$return` AST node
|
|
2937
3048
|
* @param {Map<number, any>} byName - binding slot → binding
|
|
2938
3049
|
* @returns {{ tree: any, leaves: { binding: string, ref: any }[] } | null}
|
|
2939
3050
|
*/
|
|
2940
|
-
function entityProjectionTree(node, byName) {
|
|
3051
|
+
function entityProjectionTree(node, byName, entities, mapping) {
|
|
2941
3052
|
const leaves = [];
|
|
2942
3053
|
/** @type {Map<string, number>} */
|
|
2943
3054
|
const byCanonical = new Map();
|
|
2944
3055
|
const build = (child) => {
|
|
2945
3056
|
assertDecidedKind(child);
|
|
2946
3057
|
if (child.kind === 'literal') return { p: 'lit', value: child.value };
|
|
3058
|
+
if (child.kind === 'op' && child.name === '$count') {
|
|
3059
|
+
const inner = child.args[0];
|
|
3060
|
+
if (inner?.kind !== 'flwor' || inner.forBindings.length !== 1 || inner.groupby !== null
|
|
3061
|
+
|| inner.fold !== null || inner.orderby !== null || inner.letBindings.length
|
|
3062
|
+
|| inner.asChecks !== null || inner.count !== null) return null;
|
|
3063
|
+
const binding = inner.forBindings[0];
|
|
3064
|
+
const entity = bindingEntity(binding, entities);
|
|
3065
|
+
if (entity === null || binding.window !== null || binding.atSlot !== -1 || binding.allowingEmpty
|
|
3066
|
+
|| inner.ret.kind !== 'var' || inner.ret.slot !== binding.slot) return null;
|
|
3067
|
+
const shape = entityShape(entities.get(entity), mapping.entities[entity]);
|
|
3068
|
+
const innerBinding = { name: binding.name, slot: binding.slot, entity, shape };
|
|
3069
|
+
const scope = new Map([...byName, [binding.slot, innerBinding]]);
|
|
3070
|
+
const conjuncts = inner.where?.kind === 'op' && inner.where.name === '$and' ? inner.where.args : [inner.where];
|
|
3071
|
+
const edges = [];
|
|
3072
|
+
let filter = null;
|
|
3073
|
+
for (const conjunct of conjuncts) {
|
|
3074
|
+
if (conjunct === null) continue;
|
|
3075
|
+
const cross = conjunct.kind === 'op' && conjunct.name === '$eq'
|
|
3076
|
+
? crossBindingComparison(conjunct, scope) : null;
|
|
3077
|
+
if (cross !== null && (cross.left.binding === innerBinding || cross.right.binding === innerBinding)) {
|
|
3078
|
+
edges.push({ left: { binding: cross.left.binding.name, ref: cross.left.ref },
|
|
3079
|
+
right: { binding: cross.right.binding.name, ref: cross.right.ref } });
|
|
3080
|
+
continue;
|
|
3081
|
+
}
|
|
3082
|
+
const slots = new Set(); collectBindingSlots(conjunct, scope, slots);
|
|
3083
|
+
if (slots.size !== 1 || !slots.has(binding.slot)) return null;
|
|
3084
|
+
const planned = planEntityPredicate(conjunct, binding.slot, shape);
|
|
3085
|
+
if ('refusal' in planned) return null;
|
|
3086
|
+
filter = conjoin(filter, planned.pred);
|
|
3087
|
+
}
|
|
3088
|
+
if (!edges.length) return null;
|
|
3089
|
+
const index = leaves.length;
|
|
3090
|
+
leaves.push({ count: { entity, binding: binding.name, edges, filter } });
|
|
3091
|
+
return { p: 'leaf', index };
|
|
3092
|
+
}
|
|
2947
3093
|
if (child.kind === 'path') {
|
|
2948
3094
|
const binding = child.external === true ? undefined : byName.get(child.rootSlot);
|
|
2949
3095
|
if (binding === undefined) return null;
|
|
@@ -2979,7 +3125,7 @@ function entityProjectionTree(node, byName) {
|
|
|
2979
3125
|
return null;
|
|
2980
3126
|
};
|
|
2981
3127
|
const tree = build(node);
|
|
2982
|
-
return tree === null
|
|
3128
|
+
return tree === null ? null : { tree, leaves };
|
|
2983
3129
|
}
|
|
2984
3130
|
|
|
2985
3131
|
/**
|
|
@@ -3037,9 +3183,17 @@ export function planEntityPredicate(node, slot, shape) {
|
|
|
3037
3183
|
if (pred.p === 'and' || pred.p === 'or')
|
|
3038
3184
|
return { ...pred, items: pred.items.map(reflavor) };
|
|
3039
3185
|
if (pred.p === 'not') return { ...pred, item: reflavor(pred.item) };
|
|
3186
|
+
if (pred.p === 'refCmp') {
|
|
3187
|
+
const flavor = (ref) => reflavor({ p: 'typeIs', ref, types: [], positive: true }).ref;
|
|
3188
|
+
return { ...pred, left: flavor(pred.left), right: flavor(pred.right) };
|
|
3189
|
+
}
|
|
3040
3190
|
if (!('ref' in pred) || pred.ref === null) return pred;
|
|
3041
3191
|
const canonical = canonicalOf(pred.ref.segments);
|
|
3042
3192
|
const flavored = shape.entityFlavors.get(canonical);
|
|
3193
|
+
if (shape.columnOnly && (flavored === undefined || flavored.unsafe || flavored.nullPolicy === 'null')) {
|
|
3194
|
+
blocked = refusal('physical', PREDICATE_REASONS.physicalCodec);
|
|
3195
|
+
return pred;
|
|
3196
|
+
}
|
|
3043
3197
|
if (flavored === undefined) {
|
|
3044
3198
|
// externals against DOC paths are not translated here (the
|
|
3045
3199
|
// phase-A external forms assume the collection layout)
|
|
@@ -3049,7 +3203,8 @@ export function planEntityPredicate(node, slot, shape) {
|
|
|
3049
3203
|
return { ...pred, ref: { ...pred.ref, flavor: 'entity-doc' } };
|
|
3050
3204
|
}
|
|
3051
3205
|
const ref = { ...pred.ref, column: flavored.column,
|
|
3052
|
-
flavor: flavored.flavor, storage: flavored.storage, format: flavored.format
|
|
3206
|
+
flavor: flavored.flavor, storage: flavored.storage, format: flavored.format,
|
|
3207
|
+
codec: flavored.codec, codecColumn: flavored.codecColumn, nullPolicy: flavored.nullPolicy };
|
|
3053
3208
|
if (flavored.flavor === 'entity-epoch' && pred.p === 'cmp') {
|
|
3054
3209
|
if ('ext' in pred.operand) {
|
|
3055
3210
|
blocked = refusal(pred.op, ENTITY_REASONS.external);
|
|
@@ -3122,7 +3277,7 @@ function planEntityQueryCore(document, entities, mapping, operators) {
|
|
|
3122
3277
|
if (root.kind !== 'flwor')
|
|
3123
3278
|
return residual(root.kind, ENTITY_REASONS.notFlwor);
|
|
3124
3279
|
if (root.fold !== null || root.letBindings.length > 0 || root.asChecks !== null
|
|
3125
|
-
|| root.
|
|
3280
|
+
|| root.count !== null)
|
|
3126
3281
|
return residual('$let', KIND_REASONS.let);
|
|
3127
3282
|
|
|
3128
3283
|
// bindings must each range over one entity's array
|
|
@@ -3140,6 +3295,19 @@ function planEntityQueryCore(document, entities, mapping, operators) {
|
|
|
3140
3295
|
});
|
|
3141
3296
|
}
|
|
3142
3297
|
const byName = new Map(bindings.map((binding) => [binding.slot, binding]));
|
|
3298
|
+
const group = root.groupby === null ? null : bindings.length === 1 && aggregate === null && !windows.length
|
|
3299
|
+
? planGeneralGrouping(root, bindings[0].slot, bindings[0].shape, entityPathRef) : null;
|
|
3300
|
+
if (root.groupby !== null && group === null) return residual('$groupby', ENTITY_REASONS.projection);
|
|
3301
|
+
if (group && [...group.keys, ...group.aggregates].some((entry) => entry.ref && entry.ref.flavor !== 'entity-column'))
|
|
3302
|
+
return residual('$groupby', ENTITY_REASONS.projection);
|
|
3303
|
+
// Integer accumulations carry a runtime exactness proof. Floating
|
|
3304
|
+
// accumulations and date-codec validation need the decoded evaluator.
|
|
3305
|
+
if (group && [...group.keys, ...group.aggregates].some((entry) => entry.ref
|
|
3306
|
+
&& (['date', 'datetime'].includes(entry.ref.codec)
|
|
3307
|
+
|| (['sum', 'avg'].includes(entry.fn) && entry.ref.type !== 'integer'))))
|
|
3308
|
+
return residual('$groupby', ENTITY_REASONS.projection);
|
|
3309
|
+
if (group?.order === 'first-seen' && entities.get(bindings[0].entity).physical?.keys.length > 1)
|
|
3310
|
+
return residual('$groupby', ENTITY_REASONS.groupOrder);
|
|
3143
3311
|
const conjuncts = root.where === null
|
|
3144
3312
|
? []
|
|
3145
3313
|
: root.where.kind === 'op' && root.where.name === '$and'
|
|
@@ -3155,6 +3323,29 @@ function planEntityQueryCore(document, entities, mapping, operators) {
|
|
|
3155
3323
|
* that turns a product into a join. */
|
|
3156
3324
|
const refinements = [];
|
|
3157
3325
|
const filters = new Map(bindings.map((binding) => [binding.slot, null]));
|
|
3326
|
+
const scopedFilters = [];
|
|
3327
|
+
// Boolean composition may span bindings once mandatory equijoin
|
|
3328
|
+
// edges establish the candidate tuples. Each leaf still has exactly
|
|
3329
|
+
// one owner and uses that binding's existing total predicate forms.
|
|
3330
|
+
const scopedPredicate = (node) => {
|
|
3331
|
+
if (node.kind === 'op' && (node.name === '$and' || node.name === '$or')) {
|
|
3332
|
+
const items = node.args.map(scopedPredicate);
|
|
3333
|
+
return items.some((item) => item === null) ? null
|
|
3334
|
+
: { p: node.name === '$and' ? 'and' : 'or', items };
|
|
3335
|
+
}
|
|
3336
|
+
if (node.kind === 'op' && node.name === '$not') {
|
|
3337
|
+
const item = scopedPredicate(node.args[0]);
|
|
3338
|
+
return item === null ? null : { p: 'not', item };
|
|
3339
|
+
}
|
|
3340
|
+
const slots = new Set();
|
|
3341
|
+
collectBindingSlots(node, byName, slots);
|
|
3342
|
+
if (slots.size !== 1) return null;
|
|
3343
|
+
const slot = [...slots][0];
|
|
3344
|
+
const binding = byName.get(slot);
|
|
3345
|
+
const outcome = planEntityPredicate(node, slot, binding.shape);
|
|
3346
|
+
return 'refusal' in outcome ? null
|
|
3347
|
+
: { p: 'binding', binding: binding.name, filter: outcome.pred };
|
|
3348
|
+
};
|
|
3158
3349
|
const reasons = [];
|
|
3159
3350
|
let whereFullyPushed = true;
|
|
3160
3351
|
for (const conjunct of conjuncts) {
|
|
@@ -3190,6 +3381,8 @@ function planEntityQueryCore(document, entities, mapping, operators) {
|
|
|
3190
3381
|
const slots = new Set();
|
|
3191
3382
|
collectBindingSlots(conjunct, byName, slots);
|
|
3192
3383
|
if (slots.size !== 1) {
|
|
3384
|
+
const scoped = scopedPredicate(conjunct);
|
|
3385
|
+
if (scoped !== null) { scopedFilters.push(scoped); continue; }
|
|
3193
3386
|
reasons.push(refusal('$where', ENTITY_REASONS.conjunctBinding));
|
|
3194
3387
|
whereFullyPushed = false;
|
|
3195
3388
|
continue;
|
|
@@ -3255,16 +3448,22 @@ function planEntityQueryCore(document, entities, mapping, operators) {
|
|
|
3255
3448
|
// SHAPE the projection tree rebuilds from the bindings' members
|
|
3256
3449
|
const retBinding = root.ret.kind === 'var' && root.ret.external !== true
|
|
3257
3450
|
? byName.get(root.ret.slot) : undefined;
|
|
3258
|
-
const projection = retBinding === undefined &&
|
|
3259
|
-
? entityProjectionTree(root.ret, byName) : null;
|
|
3260
|
-
if (retBinding === undefined && projection === null) {
|
|
3451
|
+
const projection = retBinding === undefined && group === null
|
|
3452
|
+
? entityProjectionTree(root.ret, byName, entities, mapping) : null;
|
|
3453
|
+
if (retBinding === undefined && projection === null && group === null) {
|
|
3261
3454
|
reasons.push(refusal('$return', ENTITY_REASONS.projection));
|
|
3262
3455
|
}
|
|
3456
|
+
if (projection?.tree.p === 'leaf' && !projection.leaves[projection.tree.index].count && (aggregate === 'count' || windows.length > 0)) {
|
|
3457
|
+
const leaf = projection.leaves[projection.tree.index];
|
|
3458
|
+
const binding = bindings.find((entry) => entry.name === leaf.binding);
|
|
3459
|
+
filters.set(binding.slot, conjoin(filters.get(binding.slot),
|
|
3460
|
+
{ p: 'typeIs', ref: leaf.ref, types: [], positive: true }));
|
|
3461
|
+
}
|
|
3263
3462
|
|
|
3264
3463
|
// ordering over flavored refs of either binding
|
|
3265
3464
|
let order = null;
|
|
3266
3465
|
let orderPushed = true;
|
|
3267
|
-
if (root.orderby !== null) {
|
|
3466
|
+
if (root.orderby !== null && group === null) {
|
|
3268
3467
|
const terms = [];
|
|
3269
3468
|
for (const spec of root.orderby.specs) {
|
|
3270
3469
|
const slot = spec.key.kind === 'path' ? spec.key.rootSlot : -1;
|
|
@@ -3276,6 +3475,7 @@ function planEntityQueryCore(document, entities, mapping, operators) {
|
|
|
3276
3475
|
// (a COLUMN stores it absent, §9.3, so a nullable column pushes)
|
|
3277
3476
|
if (ref === null || (ref.flavor === 'entity-doc' && ref.type === 'unknown')
|
|
3278
3477
|
|| ref.type === 'boolean'
|
|
3478
|
+
|| ref.nullPolicy === 'null'
|
|
3279
3479
|
|| (ref.flavor === 'entity-doc' && admitsNull(binding.shape.schema, ref.segments))
|
|
3280
3480
|
|| spec.collation !== null || spec.collationName !== null) {
|
|
3281
3481
|
orderPushed = false;
|
|
@@ -3288,10 +3488,14 @@ function planEntityQueryCore(document, entities, mapping, operators) {
|
|
|
3288
3488
|
}
|
|
3289
3489
|
|
|
3290
3490
|
const fullyPushed = whereFullyPushed && orderPushed
|
|
3291
|
-
&& (retBinding !== undefined || projection !== null);
|
|
3491
|
+
&& (retBinding !== undefined || projection !== null || group !== null);
|
|
3292
3492
|
if (!fullyPushed) {
|
|
3293
3493
|
return { analysis, mode: 'set', plan: null, referenced, reasons };
|
|
3294
3494
|
}
|
|
3495
|
+
for (const filter of scopedFilters) {
|
|
3496
|
+
const slot = bindings[0].slot;
|
|
3497
|
+
filters.set(slot, conjoin(filters.get(slot), filter));
|
|
3498
|
+
}
|
|
3295
3499
|
|
|
3296
3500
|
let window = null;
|
|
3297
3501
|
if (windows.length > 0) {
|
|
@@ -3314,7 +3518,11 @@ function planEntityQueryCore(document, entities, mapping, operators) {
|
|
|
3314
3518
|
plan: {
|
|
3315
3519
|
planVersion: PLAN_VERSION,
|
|
3316
3520
|
alg: bindings.length > 1 ? 'entity-join' : 'entity-select',
|
|
3317
|
-
bindings: bindings.map((binding) => ({ name: binding.name, entity: binding.entity
|
|
3521
|
+
bindings: bindings.map((binding) => ({ name: binding.name, entity: binding.entity,
|
|
3522
|
+
...(entities.get(binding.entity).physical == null ? {} : {
|
|
3523
|
+
keys: mapping.entities[binding.entity].keys.map((key) =>
|
|
3524
|
+
mapping.entities[binding.entity].columns.find((c) => c.name === key).physical),
|
|
3525
|
+
}) })),
|
|
3318
3526
|
// the FROM order and each binding's join conditions; `bindings`
|
|
3319
3527
|
// stays in the DOCUMENT's order, which is the nested-loop order
|
|
3320
3528
|
// the ORDER BY reproduces
|
|
@@ -3322,8 +3530,10 @@ function planEntityQueryCore(document, entities, mapping, operators) {
|
|
|
3322
3530
|
binding: join.binding,
|
|
3323
3531
|
on: join.on.map((edge) => ({
|
|
3324
3532
|
op: edge.op ?? 'eq',
|
|
3325
|
-
left: { binding: edge.left.binding.name, column: edge.left.ref.column
|
|
3326
|
-
|
|
3533
|
+
left: { binding: edge.left.binding.name, column: edge.left.ref.column,
|
|
3534
|
+
...(edge.left.ref.codec === undefined ? {} : { codec: edge.left.ref.codec }) },
|
|
3535
|
+
right: { binding: edge.right.binding.name, column: edge.right.ref.column,
|
|
3536
|
+
...(edge.right.ref.codec === undefined ? {} : { codec: edge.right.ref.codec }) },
|
|
3327
3537
|
})),
|
|
3328
3538
|
})),
|
|
3329
3539
|
filters: bindings.map((binding) => ({
|
|
@@ -3336,12 +3546,13 @@ function planEntityQueryCore(document, entities, mapping, operators) {
|
|
|
3336
3546
|
})),
|
|
3337
3547
|
window,
|
|
3338
3548
|
aggregate,
|
|
3549
|
+
group,
|
|
3339
3550
|
ret: retBinding === undefined ? null : retBinding.name,
|
|
3340
3551
|
// the projected shape, when the return is one: leaves that name
|
|
3341
3552
|
// the binding they read from, and the tree the decoder rebuilds
|
|
3342
3553
|
project: projection === null ? null : {
|
|
3343
3554
|
tree: projection.tree,
|
|
3344
|
-
leaves: projection.leaves
|
|
3555
|
+
leaves: projection.leaves,
|
|
3345
3556
|
},
|
|
3346
3557
|
},
|
|
3347
3558
|
};
|