@jarenjs/db 0.56.0 → 0.66.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +393 -56
- package/README.md +585 -53
- package/docs/HOSTS.md +269 -0
- package/docs/JOBS-FORMAT.md +293 -45
- package/docs/LIVE-FORMAT.md +122 -14
- package/docs/MIGRATION-FORMAT.md +142 -17
- package/docs/MODEL-FORMAT.md +744 -64
- package/package.json +21 -7
- package/schemas/jaren-model.draft-07.schema.json +224 -162
- package/schemas/jaren-model.schema.json +224 -162
- package/src/algebra.js +227 -9
- package/src/backup.js +161 -0
- package/src/cancellation.js +48 -0
- package/src/capture.js +218 -45
- package/src/cli.js +165 -59
- package/src/cursor.js +411 -0
- package/src/dag-job.js +154 -21
- package/src/ddl.js +102 -8
- package/src/dialect.js +267 -112
- package/src/dialects/expression-read.js +158 -0
- package/src/dialects/postgres.js +618 -0
- package/src/dialects/rtree-ddl.js +129 -0
- package/src/dialects/sqlite.js +243 -11
- package/src/document-files.js +311 -0
- package/src/document-steps.js +422 -0
- package/src/documents.js +335 -0
- package/src/driver.js +448 -61
- package/src/drivers/bun.js +37 -1
- package/src/drivers/indexeddb-snapshot.js +149 -0
- package/src/drivers/node-pool.js +11 -0
- package/src/drivers/node-worker-endpoint.js +105 -0
- package/src/drivers/node-worker.js +204 -0
- package/src/drivers/node.js +41 -7
- package/src/drivers/postgres.js +331 -0
- package/src/drivers/wasm-oo1.js +97 -0
- package/src/drivers/wasm-session.js +67 -0
- package/src/drivers/wasm.js +17 -83
- package/src/drivers/worker-pool.js +183 -0
- package/src/drivers/worker-protocol.js +79 -0
- package/src/drivers/worker-queue.js +60 -0
- package/src/emit.js +339 -48
- package/src/entity.js +20 -22
- package/src/errors.js +422 -19
- package/src/expression.js +284 -0
- package/src/graph.js +64 -8
- package/src/index.js +46 -17
- package/src/introspect.js +583 -0
- package/src/jobs.js +843 -107
- package/src/json-bytes.js +58 -0
- package/src/maintenance.js +175 -0
- package/src/migrate.js +248 -181
- package/src/model.js +68 -0
- package/src/plan.js +1119 -138
- package/src/pragmas.js +314 -0
- package/src/profile.js +151 -3
- package/src/query.js +1634 -323
- package/src/residual.js +17 -0
- package/src/series.js +12 -4
- package/src/store.js +1505 -264
- package/src/tracker.js +203 -29
- package/src/udf.js +88 -7
- package/types/index.d.ts +1097 -25
- package/types/node-pool.d.ts +28 -0
- package/types/node-worker.d.ts +54 -0
- package/types/node.d.ts +69 -2
- package/types/postgres.d.ts +46 -0
- package/types/typed.d.ts +25 -3
- package/types/wasm.d.ts +14 -0
package/src/plan.js
CHANGED
|
@@ -52,7 +52,7 @@ import {
|
|
|
52
52
|
} from './derive.js';
|
|
53
53
|
import { KNN_MARGIN } from './knn.js';
|
|
54
54
|
import {
|
|
55
|
-
SERIES_ROOT_OPS, NATIVE_AGGREGATES, seriesReason,
|
|
55
|
+
SERIES_ROOT_OPS, NATIVE_AGGREGATES, SERIES_REASONS, seriesReason,
|
|
56
56
|
instantIndexesOver, seekingIndexFor, filterFacts, fixedLadder, instantRefusal,
|
|
57
57
|
valueRefusal, seriesRecord, singularSelector,
|
|
58
58
|
} from './series.js';
|
|
@@ -93,6 +93,105 @@ const KIND_REASONS = {
|
|
|
93
93
|
quant: 'a quantifier over a nested sequence runs in the engine',
|
|
94
94
|
};
|
|
95
95
|
|
|
96
|
+
/** Why one PREDICATE stayed in the engine. */
|
|
97
|
+
const PREDICATE_REASONS = {
|
|
98
|
+
notPredicate: 'not a predicate the planner translates',
|
|
99
|
+
negatedPrefilter: 'a negated predicate cannot ride an implied pre-filter '
|
|
100
|
+
+ '(negating a superset drops rows)',
|
|
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',
|
|
103
|
+
operands: 'comparisons translate only between a singular member path and a literal or external',
|
|
104
|
+
compoundLiteral: 'array and object literals have no guarded native comparison form',
|
|
105
|
+
stringSubject: 'string operators translate only over schema-typed string paths '
|
|
106
|
+
+ '(the engine ERRORS on non-string subjects)',
|
|
107
|
+
stringPattern: 'string operators translate only with literal string patterns '
|
|
108
|
+
+ "(an external pattern's type is unknowable at plan time)",
|
|
109
|
+
emptyPattern: "the empty pattern's vacuous-truth corner (true even on a missing member) "
|
|
110
|
+
+ 'is not translated',
|
|
111
|
+
noSpelling: 'no native spelling of this operator is proven equivalent',
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/** Why one FLWOR clause stayed in the engine. */
|
|
115
|
+
const FLWOR_REASONS = {
|
|
116
|
+
binding: 'only a single plain binding over the whole collection is translated',
|
|
117
|
+
as: 'type assertions run in the engine',
|
|
118
|
+
orderPath: 'ordering translates only over singular schema-typed paths '
|
|
119
|
+
+ '(numbers and strings that cannot hold null)',
|
|
120
|
+
collation: 'a collation the dialect cannot reproduce is refused, not approximated',
|
|
121
|
+
projection: 'projections other than the bare binding or one member path run per row '
|
|
122
|
+
+ '(the row residual)',
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
/** Why a whole DOCUMENT stayed in the engine, decided above the FLWOR. */
|
|
126
|
+
const PLAN_REASONS = {
|
|
127
|
+
windowBounds: 'window bounds must be literal numbers to push (non-negative integers)',
|
|
128
|
+
windowedAggregate: 'a windowed aggregate is not translated',
|
|
129
|
+
notFlwor: 'only a FLWOR over the collection is translated',
|
|
130
|
+
countProjection: 'count translates only over the bare binding or one member path '
|
|
131
|
+
+ '(a projected return can change the item count)',
|
|
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',
|
|
135
|
+
aggregatePath: 'aggregates translate only over a singular schema-typed path '
|
|
136
|
+
+ '(the engine ERRORS on non-conforming operands)',
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Why the statement a call RUNS is not the one the planner planned:
|
|
141
|
+
* causes decided at bind time or forced by the harness, which the query
|
|
142
|
+
* engines report through the same `{ construct, reason }` shape a plan
|
|
143
|
+
* carries. They live beside the planner's own so the whole explanation
|
|
144
|
+
* vocabulary is one closed set (see {@link PLANNER_REASONS}).
|
|
145
|
+
*/
|
|
146
|
+
export const BIND_REASONS = Object.freeze({
|
|
147
|
+
pushdown: 'disabled by the harness switch',
|
|
148
|
+
untranslated: 'the document did not translate',
|
|
149
|
+
wrappedWindow: "a chain's element window is one item — the whole array — "
|
|
150
|
+
+ 'whatever the plan mode',
|
|
151
|
+
bucketWhole: 'a native bucket answers its groups whole: the groups are the result',
|
|
152
|
+
overflow: 'the pushed aggregate overflowed int64; the engine answered the document '
|
|
153
|
+
+ 'over the fetched rows',
|
|
154
|
+
/**
|
|
155
|
+
* A value the database cannot bind: the call reads its whole source
|
|
156
|
+
* and the engine answers. `over` names what that source is — a
|
|
157
|
+
* collection's rows, or an entity query's fetched root.
|
|
158
|
+
* @param {string | null} name - the external, or `null` for a literal
|
|
159
|
+
* @param {'collection' | 'root'} over
|
|
160
|
+
*/
|
|
161
|
+
external: (name, over) =>
|
|
162
|
+
`${name === null ? 'a literal' : `the external '${name}'`} is not a value the `
|
|
163
|
+
+ 'database binds; the call runs in the residual over the '
|
|
164
|
+
+ `${over === 'root' ? 'fetched root' : 'whole collection'}`,
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Why a REGISTERED operator stayed in the engine. The sentence quotes
|
|
169
|
+
* the operators the document used, so the entry is the function that
|
|
170
|
+
* builds it — the vocabulary claims it by its stable opening.
|
|
171
|
+
*/
|
|
172
|
+
const OPERATOR_REASONS = {
|
|
173
|
+
/** @param {string[]} used */
|
|
174
|
+
registered: (used) => {
|
|
175
|
+
const many = used.length > 1;
|
|
176
|
+
return `registered operator${many ? 's' : ''} `
|
|
177
|
+
+ `${used.map((name) => `'${name}'`).join(', ')} run${many ? '' : 's'} in the residual `
|
|
178
|
+
+ '(Ring 2 — correct, not pushed to SQL)';
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
/** Why an ENTITY document, or one of its clauses, stayed in the engine. */
|
|
183
|
+
const ENTITY_REASONS = {
|
|
184
|
+
notFlwor: 'only a FLWOR over entity arrays is translated',
|
|
185
|
+
bindingRoot: 'bindings must each range over one declared entity array ($.Entity[*])',
|
|
186
|
+
joinKey: 'every binding past the first needs a column equality to one already joined — '
|
|
187
|
+
+ 'a binding nothing connects is a cartesian product, which is engine work',
|
|
188
|
+
conjunctBinding: 'a conjunct must belong to one binding (or be the single join equality)',
|
|
189
|
+
external: 'externals compare only against entity columns in this version',
|
|
190
|
+
projection: 'entity queries return one bare binding natively; projections run in the engine',
|
|
191
|
+
order: 'ordering translates only over typed entity paths (never a boolean, never a document '
|
|
192
|
+
+ 'path that admits null)',
|
|
193
|
+
};
|
|
194
|
+
|
|
96
195
|
/**
|
|
97
196
|
* Assert a node kind is one this planner has decided. Called on every
|
|
98
197
|
* dispatch; the throw names the kind and the AST version so a language
|
|
@@ -119,12 +218,6 @@ for (const kind of NODE_KINDS) {
|
|
|
119
218
|
}
|
|
120
219
|
}
|
|
121
220
|
|
|
122
|
-
/**
|
|
123
|
-
* One named refusal.
|
|
124
|
-
* @param {string} construct
|
|
125
|
-
* @param {string} reason
|
|
126
|
-
* @returns {{ construct: string, reason: string }}
|
|
127
|
-
*/
|
|
128
221
|
/** Whether a literal window bound is one SQL takes as written: a
|
|
129
222
|
* non-negative safe integer. A negative, fractional or non-finite bound
|
|
130
223
|
* is the ENGINE's to interpret (it answers `[]`, a truncation or a
|
|
@@ -150,6 +243,14 @@ function orderable(ref, schema) {
|
|
|
150
243
|
return ref.type !== 'unknown' && ref.type !== 'boolean' && !admitsNull(schema, ref.segments);
|
|
151
244
|
}
|
|
152
245
|
|
|
246
|
+
/**
|
|
247
|
+
* One named refusal: the construct that stayed behind and the cause,
|
|
248
|
+
* drawn from {@link PLANNER_REASONS} so an explanation's vocabulary is
|
|
249
|
+
* one closed set rather than prose invented at each site.
|
|
250
|
+
* @param {string} construct
|
|
251
|
+
* @param {string} reason
|
|
252
|
+
* @returns {{ construct: string, reason: string }}
|
|
253
|
+
*/
|
|
153
254
|
function refusal(construct, reason) {
|
|
154
255
|
return { construct, reason };
|
|
155
256
|
}
|
|
@@ -247,16 +348,10 @@ function prependRegisteredReason(planned, document, operators) {
|
|
|
247
348
|
const residualPart = planned.mode === 'row' ? planned.rowReturn : document;
|
|
248
349
|
const used = registeredOpsUsed(residualPart, registered);
|
|
249
350
|
if (used.length === 0) return planned;
|
|
250
|
-
const many = used.length > 1;
|
|
251
351
|
return {
|
|
252
352
|
...planned,
|
|
253
353
|
reasons: [
|
|
254
|
-
|
|
255
|
-
construct: used.join(', '),
|
|
256
|
-
reason: `registered operator${many ? 's' : ''} `
|
|
257
|
-
+ `${used.map((n) => `'${n}'`).join(', ')} run${many ? '' : 's'} in the residual `
|
|
258
|
-
+ '(Ring 2 — correct, not pushed to SQL)',
|
|
259
|
-
},
|
|
354
|
+
refusal(used.join(', '), OPERATOR_REASONS.registered(used)),
|
|
260
355
|
...planned.reasons,
|
|
261
356
|
],
|
|
262
357
|
};
|
|
@@ -386,6 +481,25 @@ function isGeographicSchema(node) {
|
|
|
386
481
|
/** `$geohash`'s default precision (QUERY-FORMAT §8.14). */
|
|
387
482
|
const GEOHASH_DEFAULT_PRECISION = 9;
|
|
388
483
|
|
|
484
|
+
/**
|
|
485
|
+
* The interval promotion's reasons. `$overlaps` is §8.16's half-open
|
|
486
|
+
* interval test, and the same rule the spatial promotions live under
|
|
487
|
+
* applies to it: a pre-filter narrows, it never decides, and it may
|
|
488
|
+
* never exclude a row the engine would have RAISED on.
|
|
489
|
+
*/
|
|
490
|
+
const INTERVAL_REASONS = {
|
|
491
|
+
overlap: 'a half-open bound pre-filter is pushed over the declared interval columns; '
|
|
492
|
+
+ 'the exact overlap refines in the engine',
|
|
493
|
+
operands: '$overlaps translates only with one member path and one literal interval',
|
|
494
|
+
probe: 'the literal interval is not a half-open span of two instants '
|
|
495
|
+
+ '(the engine ERRORS on an empty or reversed one, whatever the row holds)',
|
|
496
|
+
notInterval: 'the schema does not type the member as an object whose start and end are '
|
|
497
|
+
+ 'both REQUIRED and both numeric (a bound the engine ERRORS on must not be '
|
|
498
|
+
+ 'silently filtered away)',
|
|
499
|
+
noColumns: 'the interval bounds are not both declared columns (declare an index over '
|
|
500
|
+
+ 'each of them)',
|
|
501
|
+
};
|
|
502
|
+
|
|
389
503
|
const SPATIAL_REASONS = {
|
|
390
504
|
within: 'a bounding-box pre-filter is pushed; exact containment refines in the engine',
|
|
391
505
|
distance: 'a geodesic-circle box pre-filter is pushed; the exact distance refines in the engine',
|
|
@@ -406,6 +520,8 @@ const SPATIAL_REASONS = {
|
|
|
406
520
|
rtreeBox: "the box is stored in an R*Tree, whose coordinates are 32-bit floats rounded "
|
|
407
521
|
+ 'OUTWARD, so the stored box is a superset of the row\'s; the exact box test refines '
|
|
408
522
|
+ 'in the engine',
|
|
523
|
+
radius: 'a distance bound is a finite, non-negative number of metres',
|
|
524
|
+
unboundedFar: 'only a BOUNDED distance is promoted; no box narrows "farther than r"',
|
|
409
525
|
};
|
|
410
526
|
|
|
411
527
|
/**
|
|
@@ -658,8 +774,7 @@ function planDistanceBound(node, itSlot, shape) {
|
|
|
658
774
|
const at = probePosition(constant.value);
|
|
659
775
|
if (at === null) return { refusal: refusal('$distance', SPATIAL_REASONS.unbounded) };
|
|
660
776
|
if (!Number.isFinite(radius.value) || radius.value < 0) {
|
|
661
|
-
return { refusal: refusal('$distance',
|
|
662
|
-
'a distance bound is a finite, non-negative number of metres') };
|
|
777
|
+
return { refusal: refusal('$distance', SPATIAL_REASONS.radius) };
|
|
663
778
|
}
|
|
664
779
|
const box = probeCircleBox(at, radius.value);
|
|
665
780
|
if (box === null) return { refusal: refusal('$distance', SPATIAL_REASONS.pole) };
|
|
@@ -831,11 +946,74 @@ const KNN_REASONS = {
|
|
|
831
946
|
/** @param {string} path @param {number} want @param {number[]} have */
|
|
832
947
|
dims: (path, want, have) =>
|
|
833
948
|
`the literal probe has ${want} components but the vector column over ${path} is declared at ${have.join(', ')}`,
|
|
834
|
-
/** @param {string} path */
|
|
835
|
-
widths: (path) =>
|
|
836
|
-
`several vector widths are declared over ${path}; an external probe cannot choose one at plan time`,
|
|
837
949
|
};
|
|
838
950
|
|
|
951
|
+
/**
|
|
952
|
+
* The planner's reason VOCABULARY (D6): every cause a plan can name for
|
|
953
|
+
* work it left in the engine, under a stable identifier. An explanation
|
|
954
|
+
* is public behaviour, so the sentences are a closed set — a new
|
|
955
|
+
* promotion adds an entry here, it does not write prose at the refusal
|
|
956
|
+
* site — and a test can then assert that every reason a corpus observes
|
|
957
|
+
* is one of these and nothing else.
|
|
958
|
+
*
|
|
959
|
+
* Most entries are the whole sentence. The four that quote the caller's
|
|
960
|
+
* own values — a vector width, a member path, the registered operators
|
|
961
|
+
* a document used — carry instead the stable opening they always begin
|
|
962
|
+
* with, which is what {@link reasonId} matches them by.
|
|
963
|
+
*/
|
|
964
|
+
export const PLANNER_REASONS = Object.freeze({
|
|
965
|
+
...Object.fromEntries(Object.entries(KIND_REASONS)
|
|
966
|
+
.map(([key, text]) => [`kind.${key}`, { text }])),
|
|
967
|
+
...Object.fromEntries(Object.entries(PREDICATE_REASONS)
|
|
968
|
+
.map(([key, text]) => [`predicate.${key}`, { text }])),
|
|
969
|
+
...Object.fromEntries(Object.entries(FLWOR_REASONS)
|
|
970
|
+
.map(([key, text]) => [`flwor.${key}`, { text }])),
|
|
971
|
+
...Object.fromEntries(Object.entries(PLAN_REASONS)
|
|
972
|
+
.map(([key, text]) => [`plan.${key}`, { text }])),
|
|
973
|
+
...Object.fromEntries(Object.entries(ENTITY_REASONS)
|
|
974
|
+
.map(([key, text]) => [`entity.${key}`, { text }])),
|
|
975
|
+
...Object.fromEntries(Object.entries(SPATIAL_REASONS)
|
|
976
|
+
.map(([key, text]) => [`spatial.${key}`, { text }])),
|
|
977
|
+
...Object.fromEntries(Object.entries(INTERVAL_REASONS)
|
|
978
|
+
.map(([key, text]) => [`interval.${key}`, { text }])),
|
|
979
|
+
...Object.fromEntries(Object.entries(KNN_REASONS)
|
|
980
|
+
.filter(([, text]) => typeof text === 'string')
|
|
981
|
+
.map(([key, text]) => [`knn.${key}`, { text }])),
|
|
982
|
+
...Object.fromEntries(Object.entries(SERIES_REASONS)
|
|
983
|
+
// the temporal planner spells its code into the sentence, so the
|
|
984
|
+
// reason a plan carries is the code and the sentence at once
|
|
985
|
+
.map(([code, text]) => [`series.${code}`, { text: `${code}: ${text}` }])),
|
|
986
|
+
...Object.fromEntries(Object.entries(BIND_REASONS)
|
|
987
|
+
.filter(([, text]) => typeof text === 'string')
|
|
988
|
+
.map(([key, text]) => [`bind.${key}`, { text }])),
|
|
989
|
+
'knn.noColumn': { prefix: 'no vector column over ' },
|
|
990
|
+
'knn.dims': { prefix: 'the literal probe has ' },
|
|
991
|
+
'operators.registered': { prefix: 'registered operator' },
|
|
992
|
+
'bind.external': { prefix: 'the external ' },
|
|
993
|
+
'bind.externalLiteral': { prefix: 'a literal is not a value the database binds' },
|
|
994
|
+
// the sentence itself is the dialect's, raised where the path is
|
|
995
|
+
// spelled; the vocabulary claims its stable opening
|
|
996
|
+
'bind.path': { prefix: 'a member name the dialect' },
|
|
997
|
+
});
|
|
998
|
+
|
|
999
|
+
/**
|
|
1000
|
+
* The vocabulary identifier of one reason sentence, or `null` when no
|
|
1001
|
+
* entry claims it — an unnamed reason, which the explanation contract
|
|
1002
|
+
* treats as a defect rather than a variation.
|
|
1003
|
+
* @param {string} reason
|
|
1004
|
+
* @returns {string | null}
|
|
1005
|
+
*/
|
|
1006
|
+
export function reasonId(reason) {
|
|
1007
|
+
for (const [id, entry] of Object.entries(PLANNER_REASONS)) {
|
|
1008
|
+
if (entry.text !== undefined) {
|
|
1009
|
+
if (entry.text === reason) return id;
|
|
1010
|
+
}
|
|
1011
|
+
else if (reason.startsWith(entry.prefix)) return id;
|
|
1012
|
+
}
|
|
1013
|
+
return null;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
|
|
839
1017
|
/**
|
|
840
1018
|
* Recognize the k-nearest ordering, or say why not. `null` when the
|
|
841
1019
|
* first key is not a `$similarity` at all — an ordinary ordering the
|
|
@@ -871,10 +1049,14 @@ function planKnnOrder(orderby, itSlot, shape, selectionPushed) {
|
|
|
871
1049
|
|
|
872
1050
|
if (probeNode.kind === 'var' && probeNode.external === true) {
|
|
873
1051
|
if (declared.length === 0) return refuse(KNN_REASONS.noColumn(path, null));
|
|
874
|
-
if (declared.length > 1) return refuse(KNN_REASONS.widths(path));
|
|
875
1052
|
if (!selectionPushed) return refuse(KNN_REASONS.selection);
|
|
876
|
-
|
|
877
|
-
|
|
1053
|
+
// EVERY declared width is an alternative: the plan carries them all
|
|
1054
|
+
// and the BIND picks the one the probe's own width names. A `CASE`
|
|
1055
|
+
// across the columns would read every one of them per row, and a
|
|
1056
|
+
// statement per call would give up the prepared cache
|
|
1057
|
+
return { rank: { alternatives: declared.map((entry) =>
|
|
1058
|
+
({ column: entry.column, dims: entry.dims })),
|
|
1059
|
+
probe: { ext: probeNode.name } } };
|
|
878
1060
|
}
|
|
879
1061
|
const constant = constantOf(probeNode);
|
|
880
1062
|
if (constant === null || !Array.isArray(constant.value)) return refuse(KNN_REASONS.probe);
|
|
@@ -887,7 +1069,8 @@ function planKnnOrder(orderby, itSlot, shape, selectionPushed) {
|
|
|
887
1069
|
: KNN_REASONS.dims(path, dims, declared.map((entry) => entry.dims)));
|
|
888
1070
|
}
|
|
889
1071
|
if (!selectionPushed) return refuse(KNN_REASONS.selection);
|
|
890
|
-
return { rank: { column: /** @type {string} */ (column), dims
|
|
1072
|
+
return { rank: { alternatives: [{ column: /** @type {string} */ (column), dims }],
|
|
1073
|
+
probe: { lit: constant.value } } };
|
|
891
1074
|
}
|
|
892
1075
|
|
|
893
1076
|
/**
|
|
@@ -910,8 +1093,7 @@ function planSpatial(node, itSlot, shape) {
|
|
|
910
1093
|
// `$distance >= r` — "farther than" — is narrowed by no box at all
|
|
911
1094
|
const other = upperOnLeft ? node.args[1] : node.args[0];
|
|
912
1095
|
if (other?.kind === 'op' && other.name === '$distance') {
|
|
913
|
-
return { refusal: refusal('$distance',
|
|
914
|
-
'only a BOUNDED distance is promoted; no box narrows "farther than r"') };
|
|
1096
|
+
return { refusal: refusal('$distance', SPATIAL_REASONS.unboundedFar) };
|
|
915
1097
|
}
|
|
916
1098
|
return null;
|
|
917
1099
|
}
|
|
@@ -926,6 +1108,108 @@ function planSpatial(node, itSlot, shape) {
|
|
|
926
1108
|
return null;
|
|
927
1109
|
}
|
|
928
1110
|
|
|
1111
|
+
/**
|
|
1112
|
+
* P4 — `$overlaps` over a declared interval.
|
|
1113
|
+
*
|
|
1114
|
+
* The engine's rule (`overlapsInterval`) is one conjunction: two
|
|
1115
|
+
* half-open spans share an instant when each starts before the other
|
|
1116
|
+
* ends. Over a row whose bounds are declared columns that is two
|
|
1117
|
+
* ordinary comparisons, which is why the promotion exists at all.
|
|
1118
|
+
*
|
|
1119
|
+
* What makes it a PRE-FILTER rather than an exact translation is the
|
|
1120
|
+
* kernel's other half: a span that is empty or reversed is not `false`,
|
|
1121
|
+
* it RAISES (`JQ2001`, through `requireInterval`). A conjunction alone
|
|
1122
|
+
* would drop `[500, 100)` for a probe of `[100, 200)` — answering where
|
|
1123
|
+
* the engine errors, the one thing a pushdown may never do. JSON Schema
|
|
1124
|
+
* has no keyword that compares two members, so the store cannot make an
|
|
1125
|
+
* inverted span unstorable the way it makes a missing or mistyped bound
|
|
1126
|
+
* unstorable; the fetch therefore keeps every inverted row, and the
|
|
1127
|
+
* engine raises over the candidates exactly as it would have.
|
|
1128
|
+
*
|
|
1129
|
+
* That disjunct is a comparison between two COLUMNS, which no index
|
|
1130
|
+
* bounds, so the statement scans. What it still buys is the decode: the
|
|
1131
|
+
* rows that come back are the ones the operator can be true for, plus
|
|
1132
|
+
* the ones it must raise on, rather than the whole collection. A store
|
|
1133
|
+
* that could declare the pair as an interval — a `CHECK` on the two
|
|
1134
|
+
* columns — would make the conjunction exact and the fetch a seek;
|
|
1135
|
+
* ROADMAP carries that as open work.
|
|
1136
|
+
*
|
|
1137
|
+
* The rest of the malformed cases ARE schema ones, exactly as the
|
|
1138
|
+
* spatial promotions' precondition is: a bound the engine errors on
|
|
1139
|
+
* (absent, textual, null) cannot be written to a collection whose
|
|
1140
|
+
* schema requires two numeric bounds, because a write is validated
|
|
1141
|
+
* against that schema (`JD2003`).
|
|
1142
|
+
* @param {any} node
|
|
1143
|
+
* @param {number} itSlot
|
|
1144
|
+
* @param {any} shape
|
|
1145
|
+
* @returns {any}
|
|
1146
|
+
*/
|
|
1147
|
+
function planIntervalOverlap(node, itSlot, shape) {
|
|
1148
|
+
if (node.args.length !== 2)
|
|
1149
|
+
return { refusal: refusal('$overlaps', INTERVAL_REASONS.operands) };
|
|
1150
|
+
// the operator is symmetric, so either side may be the row's
|
|
1151
|
+
let [subject, probeNode] = node.args;
|
|
1152
|
+
if (pathRef(subject, itSlot, shape) === null) [subject, probeNode] = [probeNode, subject];
|
|
1153
|
+
const spanRef = pathRef(subject, itSlot, shape);
|
|
1154
|
+
const probe = constantOf(probeNode);
|
|
1155
|
+
if (spanRef === null || probe === null || pathRef(probeNode, itSlot, shape) !== null)
|
|
1156
|
+
return { refusal: refusal('$overlaps', INTERVAL_REASONS.operands) };
|
|
1157
|
+
|
|
1158
|
+
const span = probe.value;
|
|
1159
|
+
const from = span === null || typeof span !== 'object' || Array.isArray(span)
|
|
1160
|
+
? null : safeEpoch(span.start);
|
|
1161
|
+
const to = span === null || typeof span !== 'object' || Array.isArray(span)
|
|
1162
|
+
? null : safeEpoch(span.end);
|
|
1163
|
+
// the probe is the caller's own literal: an empty or reversed one
|
|
1164
|
+
// raises for every row, and a plan that answered would hide it
|
|
1165
|
+
if (from === null || to === null || !(from < to))
|
|
1166
|
+
return { refusal: refusal('$overlaps', INTERVAL_REASONS.probe) };
|
|
1167
|
+
|
|
1168
|
+
const bounds = intervalBounds(spanRef, shape);
|
|
1169
|
+
if (bounds === null) return { refusal: refusal('$overlaps', INTERVAL_REASONS.notInterval) };
|
|
1170
|
+
if (bounds.start.column === null || bounds.end.column === null)
|
|
1171
|
+
return { refusal: refusal('$overlaps', INTERVAL_REASONS.noColumns) };
|
|
1172
|
+
|
|
1173
|
+
const pred = { p: 'interval',
|
|
1174
|
+
columns: { start: bounds.start.column, end: bounds.end.column },
|
|
1175
|
+
probe: { from, to } };
|
|
1176
|
+
return { ...promotion(pred,
|
|
1177
|
+
{ construct: '$overlaps', via: 'columns',
|
|
1178
|
+
columns: [bounds.start.column, bounds.end.column], exact: false },
|
|
1179
|
+
[refusal('$overlaps', INTERVAL_REASONS.overlap)]), composable: true };
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
/**
|
|
1183
|
+
* The two bound refs of a member the schema types as a half-open
|
|
1184
|
+
* interval, or `null` when it does not type it as one.
|
|
1185
|
+
*
|
|
1186
|
+
* "Types it as one" is the whole precondition: an object and only an
|
|
1187
|
+
* object, carrying `start` and `end`, both REQUIRED and both typed
|
|
1188
|
+
* numeric and only numeric. A union with `null` or `string` is not an
|
|
1189
|
+
* interval here — an RFC 3339 bound is a perfectly good instant to the
|
|
1190
|
+
* engine and no epoch column can compare against it.
|
|
1191
|
+
* @param {any} spanRef
|
|
1192
|
+
* @param {any} shape
|
|
1193
|
+
* @returns {{ start: any, end: any } | null}
|
|
1194
|
+
*/
|
|
1195
|
+
function intervalBounds(spanRef, shape) {
|
|
1196
|
+
const node = schemaNodeAt(shape.schema, spanRef.segments);
|
|
1197
|
+
if (node === undefined || node.type !== 'object') return null;
|
|
1198
|
+
const required = Array.isArray(node.required) ? node.required : [];
|
|
1199
|
+
if (!required.includes('start') || !required.includes('end')) return null;
|
|
1200
|
+
const boundRef = (name) => {
|
|
1201
|
+
const bound = node.properties?.[name];
|
|
1202
|
+
const type = bound?.type;
|
|
1203
|
+
if (typeof type !== 'string' || !isNumericType(type)) return null;
|
|
1204
|
+
const segments = [...spanRef.segments, { name }];
|
|
1205
|
+
return { segments, type,
|
|
1206
|
+
column: shape.columnByCanonical.get(canonicalOf(segments)) ?? null };
|
|
1207
|
+
};
|
|
1208
|
+
const start = boundRef('start');
|
|
1209
|
+
const end = boundRef('end');
|
|
1210
|
+
return start === null || end === null ? null : { start, end };
|
|
1211
|
+
}
|
|
1212
|
+
|
|
929
1213
|
/**
|
|
930
1214
|
* Translate one predicate node, or explain why not.
|
|
931
1215
|
*
|
|
@@ -943,8 +1227,8 @@ function planSpatial(node, itSlot, shape) {
|
|
|
943
1227
|
function planPredicate(node, itSlot, shape) {
|
|
944
1228
|
assertDecidedKind(node);
|
|
945
1229
|
if (node.kind !== 'op') {
|
|
946
|
-
return { refusal: refusal(node.kind,
|
|
947
|
-
??
|
|
1230
|
+
return { refusal: refusal(node.kind,
|
|
1231
|
+
KIND_REASONS[node.kind] ?? PREDICATE_REASONS.notPredicate) };
|
|
948
1232
|
}
|
|
949
1233
|
|
|
950
1234
|
if (node.name === '$and' || node.name === '$or') {
|
|
@@ -969,8 +1253,7 @@ function planPredicate(node, itSlot, shape) {
|
|
|
969
1253
|
if (inner.refinements.length > 0) {
|
|
970
1254
|
// negating a superset is a SUBSET, which drops matching rows —
|
|
971
1255
|
// the one composition an implied conjunct may never enter
|
|
972
|
-
return { refusal: refusal('$not',
|
|
973
|
-
'a negated predicate cannot ride an implied pre-filter (negating a superset drops rows)') };
|
|
1256
|
+
return { refusal: refusal('$not', PREDICATE_REASONS.negatedPrefilter) };
|
|
974
1257
|
}
|
|
975
1258
|
return { pred: { p: 'not', item: inner.pred },
|
|
976
1259
|
exact: true, prefilters: inner.prefilters, refinements: [] };
|
|
@@ -979,11 +1262,12 @@ function planPredicate(node, itSlot, shape) {
|
|
|
979
1262
|
const spatial = planSpatial(node, itSlot, shape);
|
|
980
1263
|
if (spatial !== null) return spatial;
|
|
981
1264
|
|
|
1265
|
+
if (node.name === '$overlaps') return planIntervalOverlap(node, itSlot, shape);
|
|
1266
|
+
|
|
982
1267
|
if (node.name === '$exists' || node.name === '$empty') {
|
|
983
1268
|
const ref = pathRef(node.args[0], itSlot, shape);
|
|
984
1269
|
if (ref === null) {
|
|
985
|
-
return { refusal: refusal(node.name,
|
|
986
|
-
'existence tests translate only over a singular member path on the binding') };
|
|
1270
|
+
return { refusal: refusal(node.name, PREDICATE_REASONS.existence) };
|
|
987
1271
|
}
|
|
988
1272
|
return exactly({ p: 'typeIs', ref, types: [], positive: node.name === '$exists' });
|
|
989
1273
|
}
|
|
@@ -1001,14 +1285,12 @@ function planPredicate(node, itSlot, shape) {
|
|
|
1001
1285
|
const operand = operandOf(right);
|
|
1002
1286
|
if (ref === null || operand === null) {
|
|
1003
1287
|
if (pathRef(left, itSlot, shape) !== null && pathRef(right, itSlot, shape) !== null)
|
|
1004
|
-
return { refusal: refusal(node.name,
|
|
1005
|
-
return { refusal: refusal(node.name,
|
|
1006
|
-
'comparisons translate only between a singular member path and a literal or external') };
|
|
1288
|
+
return { refusal: refusal(node.name, PREDICATE_REASONS.joinTerritory) };
|
|
1289
|
+
return { refusal: refusal(node.name, PREDICATE_REASONS.operands) };
|
|
1007
1290
|
}
|
|
1008
1291
|
if ('lit' in operand) {
|
|
1009
1292
|
if (!isScalarLiteral(operand.lit)) {
|
|
1010
|
-
return { refusal: refusal(node.name,
|
|
1011
|
-
'array and object literals have no guarded native comparison form') };
|
|
1293
|
+
return { refusal: refusal(node.name, PREDICATE_REASONS.compoundLiteral) };
|
|
1012
1294
|
}
|
|
1013
1295
|
const lit = operand.lit;
|
|
1014
1296
|
if (typeof lit === 'boolean' || lit === null) {
|
|
@@ -1025,22 +1307,18 @@ function planPredicate(node, itSlot, shape) {
|
|
|
1025
1307
|
const ref = pathRef(node.args[0], itSlot, shape);
|
|
1026
1308
|
const operand = operandOf(node.args[1]);
|
|
1027
1309
|
if (ref === null || ref.type !== 'string') {
|
|
1028
|
-
return { refusal: refusal(node.name,
|
|
1029
|
-
'string operators translate only over schema-typed string paths (the engine ERRORS on non-string subjects)') };
|
|
1310
|
+
return { refusal: refusal(node.name, PREDICATE_REASONS.stringSubject) };
|
|
1030
1311
|
}
|
|
1031
1312
|
if (operand === null || !('lit' in operand) || typeof operand.lit !== 'string') {
|
|
1032
|
-
return { refusal: refusal(node.name,
|
|
1033
|
-
"string operators translate only with literal string patterns (an external pattern's type is unknowable at plan time)") };
|
|
1313
|
+
return { refusal: refusal(node.name, PREDICATE_REASONS.stringPattern) };
|
|
1034
1314
|
}
|
|
1035
1315
|
if (operand.lit === '') {
|
|
1036
|
-
return { refusal: refusal(node.name,
|
|
1037
|
-
"the empty pattern's vacuous-truth corner (true even on a missing member) is not translated") };
|
|
1316
|
+
return { refusal: refusal(node.name, PREDICATE_REASONS.emptyPattern) };
|
|
1038
1317
|
}
|
|
1039
1318
|
return exactly({ p: 'strop', kind: /** @type {any} */ (stringOp), ref, operand });
|
|
1040
1319
|
}
|
|
1041
1320
|
|
|
1042
|
-
return { refusal: refusal(node.name,
|
|
1043
|
-
'no native spelling of this operator is proven equivalent') };
|
|
1321
|
+
return { refusal: refusal(node.name, PREDICATE_REASONS.noSpelling) };
|
|
1044
1322
|
}
|
|
1045
1323
|
|
|
1046
1324
|
// ————— Time series: the three closed shapes over a declared index —————
|
|
@@ -1460,9 +1738,30 @@ function safeEpoch(value) {
|
|
|
1460
1738
|
}
|
|
1461
1739
|
}
|
|
1462
1740
|
|
|
1463
|
-
/**
|
|
1741
|
+
/**
|
|
1742
|
+
* An instant bound as a pushable conjunct over the instant column.
|
|
1743
|
+
*
|
|
1744
|
+
* Every caller is a REFINEMENT — the bound narrows and the kernel
|
|
1745
|
+
* decides over what comes back — and the member is one the model
|
|
1746
|
+
* declared a column for, so the bound reads that column and nothing
|
|
1747
|
+
* else. Without the guard the statement stops parsing every row's
|
|
1748
|
+
* document to discriminate a member whose column already carries it,
|
|
1749
|
+
* which is the whole cost of the fetch on a large series.
|
|
1750
|
+
*/
|
|
1464
1751
|
function instantBound(ref, op, value) {
|
|
1465
|
-
return
|
|
1752
|
+
return ref.column !== null
|
|
1753
|
+
? { p: 'colCmp', op, column: ref.column, operand: { lit: value } }
|
|
1754
|
+
: { p: 'cmp', op, ref, operand: { lit: value } };
|
|
1755
|
+
}
|
|
1756
|
+
|
|
1757
|
+
/** The same, for a bound whose value the database itself answers. */
|
|
1758
|
+
function seekBound(ref, op, name) {
|
|
1759
|
+
return { p: 'colCmp', op, column: ref.column, operand: { seek: name } };
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
/** One key of the probes' own membership test, over the key column. */
|
|
1763
|
+
function keyBound(ref, key) {
|
|
1764
|
+
return { p: 'colCmp', op: 'eq', column: ref.column, operand: { lit: key } };
|
|
1466
1765
|
}
|
|
1467
1766
|
|
|
1468
1767
|
/**
|
|
@@ -1480,6 +1779,56 @@ function impliedInstantBounds(ref, from, to) {
|
|
|
1480
1779
|
to, toOp: to === null ? null : 'le' } };
|
|
1481
1780
|
}
|
|
1482
1781
|
|
|
1782
|
+
/**
|
|
1783
|
+
* The anchors an UNTOLERANCED as-of batch can ask the store for.
|
|
1784
|
+
*
|
|
1785
|
+
* A tolerance already closes both sides by arithmetic — `probes.min -
|
|
1786
|
+
* tolerance` is a real instant bound — so a seek would buy nothing and
|
|
1787
|
+
* none is built. Without one, the open side has no arithmetic bound at
|
|
1788
|
+
* all: the row that answers the earliest probe is the last row at or
|
|
1789
|
+
* before it, however far back that lies, and today the whole history
|
|
1790
|
+
* below the probes is fetched.
|
|
1791
|
+
*
|
|
1792
|
+
* The tight bound is the data's own. Per group, the row answering the
|
|
1793
|
+
* earliest probe sits at `MAX(at) WHERE at <= probes.min`; matches only
|
|
1794
|
+
* move FORWARD as the probe does, so no row below that group's anchor
|
|
1795
|
+
* can answer any probe. A single global `MAX` would be unsound — it can
|
|
1796
|
+
* come from a group whose anchor is later than another's, dropping that
|
|
1797
|
+
* other group's only candidate — so the fold is the LEAST of the
|
|
1798
|
+
* groups' anchors, which every group's answer is at or above. The
|
|
1799
|
+
* comparison stays inclusive, so rows sharing the anchor instant reach
|
|
1800
|
+
* the kernel and its duplicate rule decides among them.
|
|
1801
|
+
*
|
|
1802
|
+
* Ungrouped (no `by`), there is one group and the inner fold is the
|
|
1803
|
+
* answer. A keyed batch whose key names no column of its own gets no
|
|
1804
|
+
* seek: grouping by an extracted member would read the very rows the
|
|
1805
|
+
* anchor exists to skip.
|
|
1806
|
+
* @param {any} at - the instant ref, known to carry a column
|
|
1807
|
+
* @param {any} byRef - the key ref, or null when the batch has no key
|
|
1808
|
+
* @param {{ min: number, max: number, keys: any[] | null }} probes
|
|
1809
|
+
* @param {number | null} tolerance
|
|
1810
|
+
* @param {string} direction
|
|
1811
|
+
* @returns {any[]}
|
|
1812
|
+
*/
|
|
1813
|
+
function anchorSeeks(at, byRef, probes, tolerance, direction) {
|
|
1814
|
+
if (tolerance !== null) return [];
|
|
1815
|
+
if (byRef !== null && byRef.column === null) return [];
|
|
1816
|
+
const group = byRef === null ? null : byRef;
|
|
1817
|
+
const keys = byRef === null ? null : probes.keys;
|
|
1818
|
+
const kind = at.type === 'string' ? 'text' : 'number';
|
|
1819
|
+
/** @type {any[]} */
|
|
1820
|
+
const found = [];
|
|
1821
|
+
if (direction === 'backward' || direction === 'nearest') {
|
|
1822
|
+
found.push({ name: 'asof.lower', kind, ref: at,
|
|
1823
|
+
bound: { op: 'le', lit: probes.min }, inner: 'max', outer: 'min', group, keys });
|
|
1824
|
+
}
|
|
1825
|
+
if (direction === 'forward' || direction === 'nearest') {
|
|
1826
|
+
found.push({ name: 'asof.upper', kind, ref: at,
|
|
1827
|
+
bound: { op: 'ge', lit: probes.max }, inner: 'min', outer: 'max', group, keys });
|
|
1828
|
+
}
|
|
1829
|
+
return found;
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1483
1832
|
/**
|
|
1484
1833
|
* Plan a document that IS a series operator over the collection.
|
|
1485
1834
|
*
|
|
@@ -1584,6 +1933,8 @@ function planSeriesOperator(root, shape) {
|
|
|
1584
1933
|
// the refinement: narrow through the index by whatever the spec makes
|
|
1585
1934
|
// provable, and let the engine's own kernel decide over what comes back
|
|
1586
1935
|
let range = null;
|
|
1936
|
+
/** @type {any[]} the anchors the plan asks the store for, for explain */
|
|
1937
|
+
const seeks = [];
|
|
1587
1938
|
if (name === '$resample' && at.column !== null) {
|
|
1588
1939
|
const from = safeEpoch(spec.start);
|
|
1589
1940
|
const to = safeEpoch(spec.end);
|
|
@@ -1597,6 +1948,8 @@ function planSeriesOperator(root, shape) {
|
|
|
1597
1948
|
}
|
|
1598
1949
|
else if (name === '$asof' && probesNode !== null) {
|
|
1599
1950
|
const probes = literalInstants(probesNode, spec.leftAt, spec.by);
|
|
1951
|
+
const byRef = probes === null || spec.by === undefined ? null
|
|
1952
|
+
: memberRef(shape, /** @type {string} */ (singularSelector(spec.by)));
|
|
1600
1953
|
if (probes !== null && at.column !== null) {
|
|
1601
1954
|
const tolerance = toleranceMs(spec.tolerance);
|
|
1602
1955
|
const direction = spec.direction ?? 'backward';
|
|
@@ -1620,17 +1973,26 @@ function planSeriesOperator(root, shape) {
|
|
|
1620
1973
|
range = bounds.range;
|
|
1621
1974
|
prefilters.push({ construct: name, via: 'columns', columns: [at.column], exact: false });
|
|
1622
1975
|
}
|
|
1976
|
+
// the OPEN side, which no arithmetic over the probes can close:
|
|
1977
|
+
// the row answering the earliest probe may lie arbitrarily far
|
|
1978
|
+
// before it, so the tight bound is the data's own anchor
|
|
1979
|
+
for (const seek of anchorSeeks(at, byRef, probes, tolerance, direction)) {
|
|
1980
|
+
plan.seeks.push(seek);
|
|
1981
|
+
plan.filter = conjoin(plan.filter,
|
|
1982
|
+
seekBound(at, seek.bound.op === 'le' ? 'ge' : 'le', seek.name));
|
|
1983
|
+
seeks.push({ side: seek.bound.op === 'le' ? 'lower' : 'upper',
|
|
1984
|
+
column: at.column, probe: seek.bound.lit, op: seek.bound.op === 'le' ? 'ge' : 'le' });
|
|
1985
|
+
prefilters.push({ construct: name, via: 'columns', columns: [at.column], exact: false });
|
|
1986
|
+
}
|
|
1623
1987
|
}
|
|
1624
1988
|
// and the keys, whether or not the instant has a column of its own:
|
|
1625
1989
|
// a right row whose group no left row names can match nothing, so a
|
|
1626
1990
|
// membership test over the probes' own keys narrows and never drops
|
|
1627
1991
|
if (probes !== null && probes.keys !== null && probes.keys.length > 0) {
|
|
1628
|
-
|
|
1629
|
-
if (byRef.column !== null) {
|
|
1992
|
+
if (byRef !== null && byRef.column !== null) {
|
|
1630
1993
|
plan.filter = conjoin(plan.filter, probes.keys.length === 1
|
|
1631
|
-
?
|
|
1632
|
-
: { p: 'or', items: probes.keys.map((key) =>
|
|
1633
|
-
({ p: 'cmp', op: 'eq', ref: byRef, operand: { lit: key } })) });
|
|
1994
|
+
? keyBound(byRef, probes.keys[0])
|
|
1995
|
+
: { p: 'or', items: probes.keys.map((key) => keyBound(byRef, key)) });
|
|
1634
1996
|
prefilters.push({ construct: name, via: 'columns',
|
|
1635
1997
|
columns: [byRef.column], exact: false });
|
|
1636
1998
|
}
|
|
@@ -1655,6 +2017,7 @@ function planSeriesOperator(root, shape) {
|
|
|
1655
2017
|
index: narrowed && index !== null ? index.name : null,
|
|
1656
2018
|
prefix: narrowed && index !== null ? index.prefix : [],
|
|
1657
2019
|
range,
|
|
2020
|
+
seeks,
|
|
1658
2021
|
refinement: { $resample: 'resampleSeries', $rolling: 'rollingSeries',
|
|
1659
2022
|
$asof: 'asOfJoin' }[name],
|
|
1660
2023
|
reasons,
|
|
@@ -1662,6 +2025,206 @@ function planSeriesOperator(root, shape) {
|
|
|
1662
2025
|
};
|
|
1663
2026
|
}
|
|
1664
2027
|
|
|
2028
|
+
/**
|
|
2029
|
+
* The GENERAL grouping: a `$groupby` whose keys are safe member paths
|
|
2030
|
+
* and whose `$return` is built from those keys, the closed aggregate
|
|
2031
|
+
* set, literals and constructors. `null` when the shape is not one the
|
|
2032
|
+
* plan can rebuild — the fixed temporal bucket is tried FIRST and is a
|
|
2033
|
+
* different, narrower promotion; this is what the rest of the groupings
|
|
2034
|
+
* fall to instead of the engine.
|
|
2035
|
+
*
|
|
2036
|
+
* The rules that make it agree with the engine, each one a refusal
|
|
2037
|
+
* rather than an approximation:
|
|
2038
|
+
*
|
|
2039
|
+
* - a key is a singular schema-typed path that cannot hold `null`, so
|
|
2040
|
+
* the group SQL forms is the group the engine forms. An ABSENT key is
|
|
2041
|
+
* its own group, and its value comes back with its JSON type beside
|
|
2042
|
+
* it, so the decoder can leave the member out exactly as the object
|
|
2043
|
+
* constructor does;
|
|
2044
|
+
* - the `$return` may not read the binding: after a grouping the tuple
|
|
2045
|
+
* variable holds the group's rows, and an object member of several
|
|
2046
|
+
* items is the engine's own error, not something to reproduce;
|
|
2047
|
+
* - an aggregate over no values is the ENGINE's answer: `$count` and
|
|
2048
|
+
* `$sum` are `0`, the other three are the empty sequence and their
|
|
2049
|
+
* member is omitted. SQL answers `NULL` for all of them, so the
|
|
2050
|
+
* mapping rides the plan.
|
|
2051
|
+
* @param {any} node - the FLWOR node
|
|
2052
|
+
* @param {number} itSlot
|
|
2053
|
+
* @param {any} shape
|
|
2054
|
+
* @returns {any | null}
|
|
2055
|
+
*/
|
|
2056
|
+
function planGeneralGrouping(node, itSlot, shape) {
|
|
2057
|
+
const keys = [];
|
|
2058
|
+
/** @type {Map<number, number>} */
|
|
2059
|
+
const keySlot = new Map();
|
|
2060
|
+
for (const key of node.groupby.keys) {
|
|
2061
|
+
const ref = pathRef(key.expr, itSlot, shape);
|
|
2062
|
+
if (ref === null || ref.type === 'unknown' || admitsNull(shape.schema, ref.segments))
|
|
2063
|
+
return null;
|
|
2064
|
+
keySlot.set(key.slot, keys.length);
|
|
2065
|
+
keys.push({ as: key.name, ref });
|
|
2066
|
+
}
|
|
2067
|
+
const aggregates = [];
|
|
2068
|
+
/** @type {Map<string, number>} */
|
|
2069
|
+
const byAggregate = new Map();
|
|
2070
|
+
const build = (child) => {
|
|
2071
|
+
assertDecidedKind(child);
|
|
2072
|
+
if (child.kind === 'literal') return { p: 'lit', value: child.value };
|
|
2073
|
+
if (child.kind === 'var' && child.external !== true && keySlot.has(child.slot))
|
|
2074
|
+
return { p: 'key', index: keySlot.get(child.slot) };
|
|
2075
|
+
if (child.kind === 'op') {
|
|
2076
|
+
const entry = groupAggregate(child, itSlot, shape);
|
|
2077
|
+
if (entry === null) return null;
|
|
2078
|
+
// one aggregate per distinct (function, path): two members that
|
|
2079
|
+
// ask the same question are one SQL aggregate
|
|
2080
|
+
const identity = `${entry.fn}:${entry.ref === null ? '' : canonicalOf(entry.ref.segments)}`;
|
|
2081
|
+
let index = byAggregate.get(identity);
|
|
2082
|
+
if (index === undefined) {
|
|
2083
|
+
index = aggregates.length;
|
|
2084
|
+
aggregates.push(entry);
|
|
2085
|
+
byAggregate.set(identity, index);
|
|
2086
|
+
}
|
|
2087
|
+
return { p: 'agg', index };
|
|
2088
|
+
}
|
|
2089
|
+
if (child.kind === 'object') {
|
|
2090
|
+
const members = [];
|
|
2091
|
+
for (const entry of child.entries) {
|
|
2092
|
+
const built = build(entry.expr);
|
|
2093
|
+
if (built === null) return null;
|
|
2094
|
+
members.push({ name: entry.name, node: built });
|
|
2095
|
+
}
|
|
2096
|
+
return { p: 'object', members };
|
|
2097
|
+
}
|
|
2098
|
+
if (child.kind === 'array') {
|
|
2099
|
+
const items = [];
|
|
2100
|
+
for (const element of child.elements) {
|
|
2101
|
+
const built = build(element);
|
|
2102
|
+
if (built === null) return null;
|
|
2103
|
+
items.push(built);
|
|
2104
|
+
}
|
|
2105
|
+
return { p: 'array', items };
|
|
2106
|
+
}
|
|
2107
|
+
return null;
|
|
2108
|
+
};
|
|
2109
|
+
const tree = build(node.ret);
|
|
2110
|
+
if (tree === null) return null;
|
|
2111
|
+
const order = groupOrder(node.orderby, keySlot);
|
|
2112
|
+
if (order === null) return null;
|
|
2113
|
+
return { keys, aggregates, tree, order };
|
|
2114
|
+
}
|
|
2115
|
+
|
|
2116
|
+
/**
|
|
2117
|
+
* One aggregate of a grouped `$return`, or `null`. `$count` over the
|
|
2118
|
+
* BINDING is the group's row count; over a path it counts the rows that
|
|
2119
|
+
* HAVE the member, which `COUNT(column)` does not reproduce for a
|
|
2120
|
+
* stored `null`.
|
|
2121
|
+
* @param {any} node - an `op` node
|
|
2122
|
+
* @param {number} itSlot
|
|
2123
|
+
* @param {any} shape
|
|
2124
|
+
* @returns {{ as: string, fn: string, ref: any, empty: string } | null}
|
|
2125
|
+
*/
|
|
2126
|
+
function groupAggregate(node, itSlot, shape) {
|
|
2127
|
+
if (node.name === '$count') {
|
|
2128
|
+
return isItVar(node.args[0], itSlot)
|
|
2129
|
+
? { fn: 'rows', ref: null, empty: 'zero' } : null;
|
|
2130
|
+
}
|
|
2131
|
+
const fn = AGGREGATES.get(node.name);
|
|
2132
|
+
if (fn === undefined || fn === 'count') return null;
|
|
2133
|
+
const ref = pathRef(node.args[0], itSlot, shape);
|
|
2134
|
+
const numeric = fn === 'sum' || fn === 'avg';
|
|
2135
|
+
const acceptable = ref !== null
|
|
2136
|
+
&& (numeric ? isNumericType(ref.type) : ref.type !== 'unknown')
|
|
2137
|
+
&& ref.type !== 'boolean' && !admitsNull(shape.schema, ref.segments);
|
|
2138
|
+
if (!acceptable) return null;
|
|
2139
|
+
// what an aggregate over NO values says, in the ENGINE's words
|
|
2140
|
+
return { fn, ref, empty: fn === 'sum' ? 'zero' : 'omit' };
|
|
2141
|
+
}
|
|
2142
|
+
|
|
2143
|
+
/**
|
|
2144
|
+
* How the groups come out: the engine's order of FIRST APPEARANCE
|
|
2145
|
+
* (§6.5) when nothing declares otherwise, else the group-key ordering
|
|
2146
|
+
* an `$orderby` asked for. `null` when the ordering names anything but
|
|
2147
|
+
* group keys — after a grouping there is nothing else a row can order
|
|
2148
|
+
* by that the plan could reproduce.
|
|
2149
|
+
* @param {any} orderby
|
|
2150
|
+
* @param {Map<number, number>} keySlot
|
|
2151
|
+
* @returns {'first-seen' | { index: number, desc: boolean, nullsFirst: boolean }[] | null}
|
|
2152
|
+
*/
|
|
2153
|
+
function groupOrder(orderby, keySlot) {
|
|
2154
|
+
if (orderby === null) return 'first-seen';
|
|
2155
|
+
const terms = [];
|
|
2156
|
+
for (const spec of orderby.specs) {
|
|
2157
|
+
if (spec.collation !== null || spec.collationName !== null) return null;
|
|
2158
|
+
const key = spec.key;
|
|
2159
|
+
if (key.kind !== 'var' || key.external === true || !keySlot.has(key.slot)) return null;
|
|
2160
|
+
terms.push({ index: keySlot.get(key.slot), desc: spec.desc === true,
|
|
2161
|
+
nullsFirst: (spec.emptyGreatest === true) === (spec.desc === true) });
|
|
2162
|
+
}
|
|
2163
|
+
return terms;
|
|
2164
|
+
}
|
|
2165
|
+
|
|
2166
|
+
/**
|
|
2167
|
+
* The projection TREE one `$return` compiles to, or `null` when the
|
|
2168
|
+
* shape is not one the plan can rebuild. Object and array constructors,
|
|
2169
|
+
* literals and singular member paths compose; anything else — a
|
|
2170
|
+
* function call, a conditional, a dynamic member, a reference to the
|
|
2171
|
+
* binding itself — refuses the WHOLE projection, because a projection
|
|
2172
|
+
* that dropped part of what the caller asked for would be a wrong
|
|
2173
|
+
* answer, not a partial one.
|
|
2174
|
+
*
|
|
2175
|
+
* Distinct paths are collected once: a path named twice is one fetched
|
|
2176
|
+
* column and two leaves pointing at it. A projection with NO path is
|
|
2177
|
+
* refused too — a statement needs a column to select, and a projection
|
|
2178
|
+
* of pure literals has nothing the database could contribute.
|
|
2179
|
+
* @param {any} node - the `$return` AST node
|
|
2180
|
+
* @param {number} itSlot
|
|
2181
|
+
* @param {any} shape
|
|
2182
|
+
* @returns {{ tree: any, leaves: import('./algebra.js').PlanRef[] } | null}
|
|
2183
|
+
*/
|
|
2184
|
+
function projectionTree(node, itSlot, shape) {
|
|
2185
|
+
/** @type {import('./algebra.js').PlanRef[]} */
|
|
2186
|
+
const leaves = [];
|
|
2187
|
+
/** @type {Map<string, number>} */
|
|
2188
|
+
const byCanonical = new Map();
|
|
2189
|
+
const build = (child) => {
|
|
2190
|
+
assertDecidedKind(child);
|
|
2191
|
+
if (child.kind === 'literal') return { p: 'lit', value: child.value };
|
|
2192
|
+
if (child.kind === 'path') {
|
|
2193
|
+
const ref = pathRef(child, itSlot, shape);
|
|
2194
|
+
if (ref === null) return null;
|
|
2195
|
+
const canonical = canonicalOf(ref.segments);
|
|
2196
|
+
let index = byCanonical.get(canonical);
|
|
2197
|
+
if (index === undefined) {
|
|
2198
|
+
index = leaves.length;
|
|
2199
|
+
leaves.push(ref);
|
|
2200
|
+
byCanonical.set(canonical, index);
|
|
2201
|
+
}
|
|
2202
|
+
return { p: 'leaf', index };
|
|
2203
|
+
}
|
|
2204
|
+
if (child.kind === 'object') {
|
|
2205
|
+
const members = [];
|
|
2206
|
+
for (const entry of child.entries) {
|
|
2207
|
+
const built = build(entry.expr);
|
|
2208
|
+
if (built === null) return null;
|
|
2209
|
+
members.push({ name: entry.name, node: built });
|
|
2210
|
+
}
|
|
2211
|
+
return { p: 'object', members };
|
|
2212
|
+
}
|
|
2213
|
+
if (child.kind === 'array') {
|
|
2214
|
+
const items = [];
|
|
2215
|
+
for (const element of child.elements) {
|
|
2216
|
+
const built = build(element);
|
|
2217
|
+
if (built === null) return null;
|
|
2218
|
+
items.push(built);
|
|
2219
|
+
}
|
|
2220
|
+
return { p: 'array', items };
|
|
2221
|
+
}
|
|
2222
|
+
return null;
|
|
2223
|
+
};
|
|
2224
|
+
const tree = build(node);
|
|
2225
|
+
return tree === null || leaves.length === 0 ? null : { tree, leaves };
|
|
2226
|
+
}
|
|
2227
|
+
|
|
1665
2228
|
/**
|
|
1666
2229
|
* Plan a FLWOR node into a select plan, recording refusals. When a
|
|
1667
2230
|
* conjunct refuses native translation, the injected `udf` hook may
|
|
@@ -1694,11 +2257,11 @@ function planFlwor(node, shape, rawFlwor, udfHook) {
|
|
|
1694
2257
|
&& binding.window === null && binding.atSlot === -1
|
|
1695
2258
|
&& binding.allowingEmpty === false;
|
|
1696
2259
|
if (!sourceIsCollection) {
|
|
1697
|
-
reasons.push(refusal('$for',
|
|
1698
|
-
'only a single plain binding over the whole collection is translated'));
|
|
2260
|
+
reasons.push(refusal('$for', FLWOR_REASONS.binding));
|
|
1699
2261
|
return { plan, reasons, whereFullyPushed: false, orderPushed: false,
|
|
1700
|
-
projectionNative: false,
|
|
1701
|
-
|
|
2262
|
+
projectionNative: false, projectedTree: null,
|
|
2263
|
+
itSlot: -1, itName: null, udfs: [], prefilters: [],
|
|
2264
|
+
knn: null, bucket: null, group: null, bucketRefusal: null };
|
|
1702
2265
|
}
|
|
1703
2266
|
const itSlot = binding.slot;
|
|
1704
2267
|
// the document's own name for the collection binding. The residual and
|
|
@@ -1709,20 +2272,26 @@ function planFlwor(node, shape, rawFlwor, udfHook) {
|
|
|
1709
2272
|
|
|
1710
2273
|
if (node.fold !== null) reasons.push(refusal('$fold', KIND_REASONS.let));
|
|
1711
2274
|
if (node.letBindings.length > 0) reasons.push(refusal('$let', KIND_REASONS.let));
|
|
1712
|
-
if (node.asChecks !== null) reasons.push(refusal('$as',
|
|
2275
|
+
if (node.asChecks !== null) reasons.push(refusal('$as', FLWOR_REASONS.as));
|
|
1713
2276
|
// A grouping is an unconditional residual EXCEPT in one closed shape:
|
|
1714
2277
|
// a fixed-width `$time-bucket` key with the exact aggregates, which
|
|
1715
2278
|
// is a `GROUP BY` over integer arithmetic. The bucket then owns the
|
|
1716
2279
|
// ordering and the projection too, so it is decided before either.
|
|
1717
2280
|
let bucket = null;
|
|
2281
|
+
let group = null;
|
|
1718
2282
|
let bucketRefusal = null;
|
|
1719
2283
|
if (node.groupby !== null && node.fold === null && node.letBindings.length === 0
|
|
1720
2284
|
&& node.asChecks === null && node.count === null) {
|
|
1721
2285
|
const grouped = planBucketGrouping(node, itSlot, shape);
|
|
1722
2286
|
if ('bucket' in grouped) bucket = grouped.bucket;
|
|
1723
2287
|
else {
|
|
1724
|
-
|
|
1725
|
-
|
|
2288
|
+
// not the fixed temporal ladder: the GENERAL grouping is the next
|
|
2289
|
+
// question, and only when it refuses too does the engine group
|
|
2290
|
+
group = planGeneralGrouping(node, itSlot, shape);
|
|
2291
|
+
if (group === null) {
|
|
2292
|
+
bucketRefusal = grouped.code;
|
|
2293
|
+
reasons.push(seriesReason(grouped.code, '$groupby'));
|
|
2294
|
+
}
|
|
1726
2295
|
}
|
|
1727
2296
|
}
|
|
1728
2297
|
else if (node.groupby !== null) reasons.push(refusal('$groupby', KIND_REASONS.let));
|
|
@@ -1753,8 +2322,12 @@ function planFlwor(node, shape, rawFlwor, udfHook) {
|
|
|
1753
2322
|
? udfHook(rawConjuncts[i], itName)
|
|
1754
2323
|
: null;
|
|
1755
2324
|
if (promoted !== null) {
|
|
2325
|
+
// the conjunct's own place in the caller's document rides the
|
|
2326
|
+
// call as a literal, so the shared function can report an
|
|
2327
|
+
// engine error where the caller wrote the fragment
|
|
1756
2328
|
plan.filter = conjoin(plan.filter,
|
|
1757
|
-
{ p: 'udf', name: promoted.name, key: promoted.key
|
|
2329
|
+
{ p: 'udf', name: promoted.name, key: promoted.key,
|
|
2330
|
+
mount: split ? `/$where/$and/${i}` : '/$where' });
|
|
1758
2331
|
udfs.push(promoted.name);
|
|
1759
2332
|
}
|
|
1760
2333
|
else {
|
|
@@ -1766,7 +2339,24 @@ function planFlwor(node, shape, rawFlwor, udfHook) {
|
|
|
1766
2339
|
plan.filter = conjoin(plan.filter, outcome.pred);
|
|
1767
2340
|
prefilters.push(...outcome.prefilters);
|
|
1768
2341
|
// an IMPLIED conjunct narrows and leaves the original predicate
|
|
1769
|
-
//
|
|
2342
|
+
// to be decided — by the residual, or, when the hatch takes the
|
|
2343
|
+
// same fragment, by the engine's own operator IN the statement.
|
|
2344
|
+
// The composition is the ordinary index-friendly one: the cheap
|
|
2345
|
+
// conjunct prunes, the exact one decides, and every row the
|
|
2346
|
+
// exact one would RAISE on is still handed to it. Only the
|
|
2347
|
+
// interval promotion asks for it today; the spatial ones have
|
|
2348
|
+
// not been proven under the hatch and keep their residual
|
|
2349
|
+
const composed = outcome.refinements.length > 0 && outcome.composable === true
|
|
2350
|
+
&& udfHook !== undefined && rawConjuncts[i] !== undefined
|
|
2351
|
+
? udfHook(rawConjuncts[i], itName)
|
|
2352
|
+
: null;
|
|
2353
|
+
if (composed !== null) {
|
|
2354
|
+
plan.filter = conjoin(plan.filter,
|
|
2355
|
+
{ p: 'udf', name: composed.name, key: composed.key,
|
|
2356
|
+
mount: split ? `/$where/$and/${i}` : '/$where' });
|
|
2357
|
+
udfs.push(composed.name);
|
|
2358
|
+
continue;
|
|
2359
|
+
}
|
|
1770
2360
|
for (const refinement of outcome.refinements) {
|
|
1771
2361
|
reasons.push(refinement);
|
|
1772
2362
|
whereFullyPushed = false;
|
|
@@ -1781,9 +2371,12 @@ function planFlwor(node, shape, rawFlwor, udfHook) {
|
|
|
1781
2371
|
// caller's to name once the window is known)
|
|
1782
2372
|
let orderPushed = false;
|
|
1783
2373
|
let knn = null;
|
|
1784
|
-
const
|
|
2374
|
+
const grouped = bucket !== null || group !== null;
|
|
2375
|
+
const ranked = grouped || node.orderby === null ? null
|
|
1785
2376
|
: planKnnOrder(node.orderby, itSlot, shape, whereFullyPushed && structureClean);
|
|
1786
|
-
|
|
2377
|
+
// a grouping owns its own ordering: the groups' order is the bucket's
|
|
2378
|
+
// or the group's, and an `$orderby` over the keys is inside it
|
|
2379
|
+
if (grouped) orderPushed = true;
|
|
1787
2380
|
else if (ranked !== null) {
|
|
1788
2381
|
if ('rank' in ranked) knn = ranked.rank;
|
|
1789
2382
|
else reasons.push(ranked.refusal);
|
|
@@ -1794,13 +2387,11 @@ function planFlwor(node, shape, rawFlwor, udfHook) {
|
|
|
1794
2387
|
for (const spec of node.orderby.specs) {
|
|
1795
2388
|
const ref = pathRef(spec.key, itSlot, shape);
|
|
1796
2389
|
if (ref === null || !orderable(ref, shape.schema)) {
|
|
1797
|
-
refused = refusal('$orderby',
|
|
1798
|
-
'ordering translates only over singular schema-typed paths (numbers and strings that cannot hold null)');
|
|
2390
|
+
refused = refusal('$orderby', FLWOR_REASONS.orderPath);
|
|
1799
2391
|
break;
|
|
1800
2392
|
}
|
|
1801
2393
|
if (spec.collation !== null || spec.collationName !== null) {
|
|
1802
|
-
refused = refusal('$collation',
|
|
1803
|
-
'a collation the dialect cannot reproduce is refused, not approximated');
|
|
2394
|
+
refused = refusal('$collation', FLWOR_REASONS.collation);
|
|
1804
2395
|
break;
|
|
1805
2396
|
}
|
|
1806
2397
|
terms.push({ ref, desc: spec.desc === true, emptyGreatest: spec.emptyGreatest === true });
|
|
@@ -1815,14 +2406,38 @@ function planFlwor(node, shape, rawFlwor, udfHook) {
|
|
|
1815
2406
|
orderPushed = true; // nothing to push
|
|
1816
2407
|
}
|
|
1817
2408
|
|
|
1818
|
-
// RETURN: the bare binding is the native whole-document projection
|
|
2409
|
+
// RETURN: the bare binding is the native whole-document projection,
|
|
2410
|
+
// and a SINGLE member path over the binding projects that path into
|
|
2411
|
+
// the statement (its value beside its JSON type, so a present null,
|
|
2412
|
+
// a boolean and an absent member each read back as the engine
|
|
2413
|
+
// answers them). Nothing else is projected: the residual rules that
|
|
2414
|
+
// make a projection safe hold only when the whole selection is
|
|
2415
|
+
// pushed, which the caller decides — a projection that dropped a
|
|
2416
|
+
// member a residual conjunct still needs would be a wrong answer
|
|
1819
2417
|
let projectionNative = false;
|
|
2418
|
+
/** @type {import('./algebra.js').PlanRef | null} */
|
|
2419
|
+
let projectedPath = null;
|
|
2420
|
+
/** @type {{ tree: any, leaves: any[] } | null} */
|
|
2421
|
+
let projectedTree = null;
|
|
1820
2422
|
assertDecidedKind(node.ret);
|
|
1821
|
-
|
|
2423
|
+
const tree = grouped || isItVar(node.ret, itSlot)
|
|
2424
|
+
? null : projectionTree(node.ret, itSlot, shape);
|
|
2425
|
+
// a grouping IS the projection: its own tree rebuilds each group
|
|
2426
|
+
if (grouped) projectionNative = true;
|
|
1822
2427
|
else if (isItVar(node.ret, itSlot)) projectionNative = true;
|
|
1823
2428
|
else {
|
|
1824
|
-
|
|
1825
|
-
|
|
2429
|
+
const ref = pathRef(node.ret, itSlot, shape);
|
|
2430
|
+
if (ref !== null) {
|
|
2431
|
+
projectionNative = true;
|
|
2432
|
+
projectedPath = ref;
|
|
2433
|
+
}
|
|
2434
|
+
else if (tree !== null) {
|
|
2435
|
+
projectionNative = true;
|
|
2436
|
+
projectedTree = tree;
|
|
2437
|
+
}
|
|
2438
|
+
else {
|
|
2439
|
+
reasons.push(refusal('$return', FLWOR_REASONS.projection));
|
|
2440
|
+
}
|
|
1826
2441
|
}
|
|
1827
2442
|
|
|
1828
2443
|
return {
|
|
@@ -1831,12 +2446,15 @@ function planFlwor(node, shape, rawFlwor, udfHook) {
|
|
|
1831
2446
|
whereFullyPushed: whereFullyPushed && structureClean,
|
|
1832
2447
|
orderPushed: orderPushed && structureClean,
|
|
1833
2448
|
projectionNative,
|
|
2449
|
+
projectedPath,
|
|
2450
|
+
projectedTree,
|
|
1834
2451
|
itSlot,
|
|
1835
2452
|
itName,
|
|
1836
2453
|
udfs,
|
|
1837
2454
|
prefilters,
|
|
1838
2455
|
knn,
|
|
1839
2456
|
bucket,
|
|
2457
|
+
group,
|
|
1840
2458
|
bucketRefusal,
|
|
1841
2459
|
};
|
|
1842
2460
|
}
|
|
@@ -1886,7 +2504,17 @@ function composeWindows(windows) {
|
|
|
1886
2504
|
* `series` is the temporal record (`series.js`) when the document
|
|
1887
2505
|
* asked a §8.16 question, and `null` when it did not.
|
|
1888
2506
|
*/
|
|
2507
|
+
/**
|
|
2508
|
+
* The whole-collection scan a bare root wildcard means: the degenerate
|
|
2509
|
+
* query of every pen (`'$[*]'`, LINQ-FORMAT's "the degenerate query is a
|
|
2510
|
+
* JSONPath string") is planned as the `$for` phrase over the collection
|
|
2511
|
+
* it abbreviates — a row cursor from an open statement — instead of the
|
|
2512
|
+
* path barrier that would fetch the collection whole and walk it in JS.
|
|
2513
|
+
*/
|
|
2514
|
+
const ROOT_SCAN = Object.freeze({ $for: Object.freeze({ it: '$[*]' }), $return: '$it' });
|
|
2515
|
+
|
|
1889
2516
|
function planCollectionCore(document, shape, options = undefined) {
|
|
2517
|
+
if (document === '$[*]') document = ROOT_SCAN;
|
|
1890
2518
|
const analysis = analyzeQuery(document, analyzeOptionsFor(shape?.operators));
|
|
1891
2519
|
let root = analysis.root;
|
|
1892
2520
|
assertDecidedKind(root);
|
|
@@ -1901,7 +2529,7 @@ function planCollectionCore(document, shape, options = undefined) {
|
|
|
1901
2529
|
// non-literal bounds: the whole document is a set residual
|
|
1902
2530
|
return {
|
|
1903
2531
|
analysis, plan: null, mode: 'set',
|
|
1904
|
-
reasons: [refusal('$subsequence',
|
|
2532
|
+
reasons: [refusal('$subsequence', PLAN_REASONS.windowBounds)],
|
|
1905
2533
|
rowReturn: null, udfs: [], prefilters: [], series: null,
|
|
1906
2534
|
};
|
|
1907
2535
|
}
|
|
@@ -1911,17 +2539,26 @@ function planCollectionCore(document, shape, options = undefined) {
|
|
|
1911
2539
|
assertDecidedKind(root);
|
|
1912
2540
|
}
|
|
1913
2541
|
|
|
1914
|
-
// a top-level aggregate over a FLWOR
|
|
2542
|
+
// a top-level aggregate over a FLWOR: one of the core five, or a
|
|
2543
|
+
// REGISTERED aggregate the store declared pushable and the driver can
|
|
2544
|
+
// register (Ring 3). Only a one-operand aggregate can be declared
|
|
2545
|
+
// pushable at all — a SQL fold over zero rows never sees a second
|
|
2546
|
+
// operand, and `aggregateSpec` refuses the declaration at open — so
|
|
2547
|
+
// the recognizer here reads the phrase and nothing else
|
|
1915
2548
|
let aggregate = null;
|
|
1916
|
-
|
|
2549
|
+
const registeredAggregate = root.kind === 'op' && !AGGREGATES.has(root.name)
|
|
2550
|
+
? (options?.aggregate?.(root.name) ?? null) : null;
|
|
2551
|
+
if (root.kind === 'op' && (AGGREGATES.has(root.name) || registeredAggregate !== null)) {
|
|
1917
2552
|
if (windows.length > 0) {
|
|
1918
2553
|
return {
|
|
1919
2554
|
analysis, plan: null, mode: 'set',
|
|
1920
|
-
reasons: [refusal(root.name,
|
|
2555
|
+
reasons: [refusal(root.name, PLAN_REASONS.windowedAggregate)],
|
|
1921
2556
|
rowReturn: null, udfs: [], prefilters: [], series: null,
|
|
1922
2557
|
};
|
|
1923
2558
|
}
|
|
1924
|
-
aggregate =
|
|
2559
|
+
aggregate = registeredAggregate === null
|
|
2560
|
+
? { name: root.name, fn: AGGREGATES.get(root.name) }
|
|
2561
|
+
: { name: root.name, fn: 'registered', sql: registeredAggregate.sql };
|
|
1925
2562
|
root = root.args[0];
|
|
1926
2563
|
rawInner = rawInner?.[aggregate.name] ?? rawInner;
|
|
1927
2564
|
assertDecidedKind(root);
|
|
@@ -1955,8 +2592,7 @@ function planCollectionCore(document, shape, options = undefined) {
|
|
|
1955
2592
|
if (root.kind !== 'flwor') {
|
|
1956
2593
|
return {
|
|
1957
2594
|
analysis, plan: null, mode: 'set',
|
|
1958
|
-
reasons: [refusal(root.kind, KIND_REASONS[root.kind]
|
|
1959
|
-
?? 'only a FLWOR over the collection is translated')],
|
|
2595
|
+
reasons: [refusal(root.kind, KIND_REASONS[root.kind] ?? PLAN_REASONS.notFlwor)],
|
|
1960
2596
|
rowReturn: null, udfs: [], prefilters: [], series: null,
|
|
1961
2597
|
};
|
|
1962
2598
|
}
|
|
@@ -1988,13 +2624,12 @@ function planCollectionCore(document, shape, options = undefined) {
|
|
|
1988
2624
|
return { analysis, plan: null, mode: 'set', reasons: flwor.reasons,
|
|
1989
2625
|
rowReturn: null, udfs: [], prefilters: flwor.prefilters, series: null };
|
|
1990
2626
|
}
|
|
1991
|
-
if (flwor.bucket !== null || flwor.bucketRefusal != null) {
|
|
2627
|
+
if (flwor.bucket !== null || flwor.group !== null || flwor.bucketRefusal != null) {
|
|
1992
2628
|
// the phrase's items are its GROUPS; a COUNT(*) over the rows
|
|
1993
2629
|
// answered the row count for a `$count` of the groups
|
|
1994
2630
|
return {
|
|
1995
2631
|
analysis, plan: null, mode: 'set',
|
|
1996
|
-
reasons: [refusal(aggregate.name,
|
|
1997
|
-
'an aggregate over a grouped phrase folds its groups, which the engine does')],
|
|
2632
|
+
reasons: [refusal(aggregate.name, PLAN_REASONS.groupedAggregate)],
|
|
1998
2633
|
rowReturn: null, udfs: [], prefilters: [], series: null,
|
|
1999
2634
|
};
|
|
2000
2635
|
}
|
|
@@ -2002,32 +2637,46 @@ function planCollectionCore(document, shape, options = undefined) {
|
|
|
2002
2637
|
if (!flwor.projectionNative) {
|
|
2003
2638
|
return {
|
|
2004
2639
|
analysis, plan: null, mode: 'set',
|
|
2005
|
-
reasons: [refusal('$count',
|
|
2006
|
-
'count translates only over the bare binding (a projected return can change the item count)')],
|
|
2640
|
+
reasons: [refusal('$count', PLAN_REASONS.countProjection)],
|
|
2007
2641
|
rowReturn: null, udfs: [], prefilters: [], series: null,
|
|
2008
2642
|
};
|
|
2009
2643
|
}
|
|
2644
|
+
// a count over one member path counts the rows where the member
|
|
2645
|
+
// is PRESENT — an absent member yields no item — so the presence
|
|
2646
|
+
// test rides the WHERE and the count stays a COUNT(*)
|
|
2647
|
+
if (flwor.projectedPath !== null) {
|
|
2648
|
+
plan.filter = conjoin(plan.filter,
|
|
2649
|
+
{ p: 'typeIs', ref: flwor.projectedPath, types: [], positive: true });
|
|
2650
|
+
}
|
|
2010
2651
|
plan.aggregate = { fn: 'count', ref: null };
|
|
2011
2652
|
return { analysis, plan, mode: 'native', reasons: [], rowReturn: null,
|
|
2012
2653
|
udfs: flwor.udfs, prefilters: flwor.prefilters,
|
|
2013
2654
|
series: classifySelection(plan, shape, true) };
|
|
2014
2655
|
}
|
|
2015
2656
|
const ref = pathRef(root.ret, flwor.itSlot, shape);
|
|
2016
|
-
|
|
2657
|
+
// a registered aggregate declares `seq<number>`, so its input is the
|
|
2658
|
+
// numeric family too — and a path that admits `null` is refused for
|
|
2659
|
+
// every aggregate alike: SQL cannot tell a stored null from an
|
|
2660
|
+
// absent member, and the engine's sequence has an item for one and
|
|
2661
|
+
// not the other
|
|
2662
|
+
const numeric = aggregate.fn === 'sum' || aggregate.fn === 'avg'
|
|
2663
|
+
|| aggregate.fn === 'registered';
|
|
2017
2664
|
const acceptable = ref !== null
|
|
2018
2665
|
&& (numeric ? isNumericType(ref.type) : ref.type !== 'unknown')
|
|
2019
2666
|
&& ref.type !== 'boolean' && !admitsNull(shape.schema, ref.segments);
|
|
2020
2667
|
if (!acceptable) {
|
|
2021
2668
|
return {
|
|
2022
2669
|
analysis, plan: null, mode: 'set',
|
|
2023
|
-
reasons: [refusal(aggregate.name,
|
|
2024
|
-
'aggregates translate only over a singular schema-typed path (the engine ERRORS on non-conforming operands)')],
|
|
2670
|
+
reasons: [refusal(aggregate.name, PLAN_REASONS.aggregatePath)],
|
|
2025
2671
|
rowReturn: null, udfs: [], prefilters: [], series: null,
|
|
2026
2672
|
};
|
|
2027
2673
|
}
|
|
2028
|
-
plan.aggregate =
|
|
2674
|
+
plan.aggregate = aggregate.fn === 'registered'
|
|
2675
|
+
? { fn: 'registered', ref, operator: aggregate.name, sql: aggregate.sql }
|
|
2676
|
+
: { fn: /** @type {any} */ (aggregate.fn), ref };
|
|
2029
2677
|
return { analysis, plan, mode: 'native', reasons: [], rowReturn: null,
|
|
2030
|
-
udfs: flwor.udfs,
|
|
2678
|
+
udfs: aggregate.fn === 'registered' ? [...flwor.udfs, aggregate.sql] : flwor.udfs,
|
|
2679
|
+
prefilters: flwor.prefilters,
|
|
2031
2680
|
series: classifySelection(plan, shape, true) };
|
|
2032
2681
|
}
|
|
2033
2682
|
|
|
@@ -2037,6 +2686,12 @@ function planCollectionCore(document, shape, options = undefined) {
|
|
|
2037
2686
|
// the temporal bucket: only over a WHOLE pushed selection, because a
|
|
2038
2687
|
// conjunct the residual would still apply would arrive after the rows
|
|
2039
2688
|
// were already summed
|
|
2689
|
+
if (flwor.group !== null && fullyPushed && windows.length === 0 && aggregate === null) {
|
|
2690
|
+
plan.group = flwor.group;
|
|
2691
|
+
return { analysis, plan, mode: 'native', reasons: [], rowReturn: null,
|
|
2692
|
+
udfs: flwor.udfs, prefilters: flwor.prefilters, series: null };
|
|
2693
|
+
}
|
|
2694
|
+
|
|
2040
2695
|
if (flwor.bucket !== null && fullyPushed && (windows.length === 0 || plan.window !== null)) {
|
|
2041
2696
|
plan.bucket = flwor.bucket;
|
|
2042
2697
|
const facts = filterFacts(plan.filter);
|
|
@@ -2058,14 +2713,25 @@ function planCollectionCore(document, shape, options = undefined) {
|
|
|
2058
2713
|
};
|
|
2059
2714
|
}
|
|
2060
2715
|
|
|
2061
|
-
|
|
2716
|
+
// a RECOGNIZED grouping that did not lower: the engine groups, and the
|
|
2717
|
+
// projection branches below must not claim the shape as their own —
|
|
2718
|
+
// a grouping IS the projection, and answering it as one would answer
|
|
2719
|
+
// per row instead of per group
|
|
2720
|
+
const groupedResidual = flwor.group !== null || flwor.bucket !== null;
|
|
2721
|
+
if (groupedResidual && windows.length > 0)
|
|
2722
|
+
flwor.reasons.push(refusal('$groupby', PLAN_REASONS.windowedGroup));
|
|
2723
|
+
|
|
2724
|
+
if (!groupedResidual && fullyPushed && flwor.projectionNative
|
|
2725
|
+
&& (windows.length === 0 || plan.window !== null)) {
|
|
2726
|
+
if (flwor.projectedPath !== null) plan.project = { path: flwor.projectedPath };
|
|
2727
|
+
else if (flwor.projectedTree !== null) plan.project = flwor.projectedTree;
|
|
2062
2728
|
return { analysis, plan, mode: 'native', reasons: [], rowReturn: null,
|
|
2063
2729
|
udfs: flwor.udfs, prefilters: flwor.prefilters,
|
|
2064
2730
|
series: classifySelection(plan, shape, flwor.orderPushed) };
|
|
2065
2731
|
}
|
|
2066
2732
|
|
|
2067
2733
|
// the row residual: everything but the projection pushed
|
|
2068
|
-
if (fullyPushed && !flwor.projectionNative
|
|
2734
|
+
if (!groupedResidual && fullyPushed && !flwor.projectionNative
|
|
2069
2735
|
&& (windows.length === 0 || plan.window !== null)) {
|
|
2070
2736
|
const rawFlwor = rawInner;
|
|
2071
2737
|
const name = flwor.itName ?? 'it';
|
|
@@ -2089,9 +2755,12 @@ function planCollectionCore(document, shape, options = undefined) {
|
|
|
2089
2755
|
};
|
|
2090
2756
|
}
|
|
2091
2757
|
|
|
2092
|
-
// the set residual: pushed conjuncts narrow, the engine answers
|
|
2758
|
+
// the set residual: pushed conjuncts narrow, the engine answers — so
|
|
2759
|
+
// a temporal selection here is never `native`, whatever index it
|
|
2760
|
+
// seeks through: the index narrows the fetch (hybrid) or nothing does
|
|
2761
|
+
// (engine), and the record follows the PLAN mode, not the index alone
|
|
2093
2762
|
const narrowing = flwor.bucketRefusal == null
|
|
2094
|
-
? classifySelection(plan, shape, false)
|
|
2763
|
+
? underSetMode(classifySelection(plan, shape, false))
|
|
2095
2764
|
: refinedGrouping(plan, shape, flwor.bucketRefusal, '$groupby');
|
|
2096
2765
|
plan.order = null;
|
|
2097
2766
|
plan.window = null;
|
|
@@ -2099,6 +2768,17 @@ function planCollectionCore(document, shape, options = undefined) {
|
|
|
2099
2768
|
udfs: flwor.udfs, prefilters: flwor.prefilters, series: narrowing };
|
|
2100
2769
|
}
|
|
2101
2770
|
|
|
2771
|
+
/**
|
|
2772
|
+
* A temporal record under a set-mode plan: the database only narrows,
|
|
2773
|
+
* the engine answers.
|
|
2774
|
+
* @param {any} series - a `classifySelection` record, or null
|
|
2775
|
+
* @returns {any}
|
|
2776
|
+
*/
|
|
2777
|
+
function underSetMode(series) {
|
|
2778
|
+
if (series === null || series.mode !== 'native') return series;
|
|
2779
|
+
return { ...series, mode: series.index === null ? 'engine' : 'hybrid' };
|
|
2780
|
+
}
|
|
2781
|
+
|
|
2102
2782
|
/**
|
|
2103
2783
|
* Plan a whole document against one collection. The store's registered
|
|
2104
2784
|
* operators (Ring 2) ride in `shape.operators` — the planner recognises
|
|
@@ -2209,6 +2889,99 @@ export function entityPathRef(node, slot, shape) {
|
|
|
2209
2889
|
return { ...ref, flavor: 'entity-doc' };
|
|
2210
2890
|
}
|
|
2211
2891
|
|
|
2892
|
+
/**
|
|
2893
|
+
* The projection TREE of an ENTITY query: the same closed shape a
|
|
2894
|
+
* collection projection composes — objects, arrays, literals and
|
|
2895
|
+
* singular member paths — with each leaf carrying the BINDING it reads
|
|
2896
|
+
* from, so the statement extracts it from that binding's alias. `null`
|
|
2897
|
+
* when the shape is not one the plan can rebuild, and a projection with
|
|
2898
|
+
* no path at all is refused for the same reason a collection's is: a
|
|
2899
|
+
* statement needs a column to select.
|
|
2900
|
+
* @param {any} node - the `$return` AST node
|
|
2901
|
+
* @param {Map<number, any>} byName - binding slot → binding
|
|
2902
|
+
* @returns {{ tree: any, leaves: { binding: string, ref: any }[] } | null}
|
|
2903
|
+
*/
|
|
2904
|
+
function entityProjectionTree(node, byName) {
|
|
2905
|
+
const leaves = [];
|
|
2906
|
+
/** @type {Map<string, number>} */
|
|
2907
|
+
const byCanonical = new Map();
|
|
2908
|
+
const build = (child) => {
|
|
2909
|
+
assertDecidedKind(child);
|
|
2910
|
+
if (child.kind === 'literal') return { p: 'lit', value: child.value };
|
|
2911
|
+
if (child.kind === 'path') {
|
|
2912
|
+
const binding = child.external === true ? undefined : byName.get(child.rootSlot);
|
|
2913
|
+
if (binding === undefined) return null;
|
|
2914
|
+
const ref = entityPathRef(child, child.rootSlot, binding.shape);
|
|
2915
|
+
if (ref === null) return null;
|
|
2916
|
+
const identity = `${binding.name}${canonicalOf(ref.segments)}`;
|
|
2917
|
+
let index = byCanonical.get(identity);
|
|
2918
|
+
if (index === undefined) {
|
|
2919
|
+
index = leaves.length;
|
|
2920
|
+
leaves.push({ binding: binding.name, ref });
|
|
2921
|
+
byCanonical.set(identity, index);
|
|
2922
|
+
}
|
|
2923
|
+
return { p: 'leaf', index };
|
|
2924
|
+
}
|
|
2925
|
+
if (child.kind === 'object') {
|
|
2926
|
+
const members = [];
|
|
2927
|
+
for (const entry of child.entries) {
|
|
2928
|
+
const built = build(entry.expr);
|
|
2929
|
+
if (built === null) return null;
|
|
2930
|
+
members.push({ name: entry.name, node: built });
|
|
2931
|
+
}
|
|
2932
|
+
return { p: 'object', members };
|
|
2933
|
+
}
|
|
2934
|
+
if (child.kind === 'array') {
|
|
2935
|
+
const items = [];
|
|
2936
|
+
for (const element of child.elements) {
|
|
2937
|
+
const built = build(element);
|
|
2938
|
+
if (built === null) return null;
|
|
2939
|
+
items.push(built);
|
|
2940
|
+
}
|
|
2941
|
+
return { p: 'array', items };
|
|
2942
|
+
}
|
|
2943
|
+
return null;
|
|
2944
|
+
};
|
|
2945
|
+
const tree = build(node);
|
|
2946
|
+
return tree === null || leaves.length === 0 ? null : { tree, leaves };
|
|
2947
|
+
}
|
|
2948
|
+
|
|
2949
|
+
/**
|
|
2950
|
+
* A comparison whose two sides are member paths on DIFFERENT bindings,
|
|
2951
|
+
* as a join refinement — or `null` when it is not one this plan can
|
|
2952
|
+
* prove. Both sides must be mapped columns of the same comparison
|
|
2953
|
+
* family: SQL compares by column affinity where the engine compares by
|
|
2954
|
+
* JSON type, so a string column against a number column would answer
|
|
2955
|
+
* differently on the two sides, and a column that admits `null` would
|
|
2956
|
+
* make the comparison neither true nor false where the engine has an
|
|
2957
|
+
* answer. An epoch column is refused too — it stores an integer beside
|
|
2958
|
+
* a document string the engine reads, and the two need not order alike
|
|
2959
|
+
* across mixed stored precisions.
|
|
2960
|
+
* @param {any} node - an `op` node whose name is a comparison
|
|
2961
|
+
* @param {Map<number, any>} byName - binding slot → binding
|
|
2962
|
+
* @returns {{ op: string, left: any, right: any } | null}
|
|
2963
|
+
*/
|
|
2964
|
+
function crossBindingComparison(node, byName) {
|
|
2965
|
+
const [left, right] = node.args;
|
|
2966
|
+
const leftBinding = left?.kind === 'path' ? byName.get(left.rootSlot) : undefined;
|
|
2967
|
+
const rightBinding = right?.kind === 'path' ? byName.get(right.rootSlot) : undefined;
|
|
2968
|
+
if (leftBinding === undefined || rightBinding === undefined
|
|
2969
|
+
|| leftBinding === rightBinding) return null;
|
|
2970
|
+
const leftRef = entityPathRef(left, left.rootSlot, leftBinding.shape);
|
|
2971
|
+
const rightRef = entityPathRef(right, right.rootSlot, rightBinding.shape);
|
|
2972
|
+
const usable = (binding, ref) => ref !== null && ref.flavor === 'entity-column'
|
|
2973
|
+
&& ref.type !== 'unknown' && ref.type !== 'boolean'
|
|
2974
|
+
&& !admitsNull(binding.shape.schema, ref.segments);
|
|
2975
|
+
if (!usable(leftBinding, leftRef) || !usable(rightBinding, rightRef)) return null;
|
|
2976
|
+
const family = (ref) => (isNumericType(ref.type) ? 'number' : ref.type);
|
|
2977
|
+
if (family(leftRef) !== family(rightRef)) return null;
|
|
2978
|
+
return {
|
|
2979
|
+
op: COMPARISONS.get(node.name),
|
|
2980
|
+
left: { binding: leftBinding, ref: leftRef },
|
|
2981
|
+
right: { binding: rightBinding, ref: rightRef },
|
|
2982
|
+
};
|
|
2983
|
+
}
|
|
2984
|
+
|
|
2212
2985
|
/**
|
|
2213
2986
|
* Plan one predicate over an entity binding: the same operator
|
|
2214
2987
|
* grammar as phase A, with entity-flavored refs. Reuses
|
|
@@ -2235,8 +3008,7 @@ export function planEntityPredicate(node, slot, shape) {
|
|
|
2235
3008
|
// externals against DOC paths are not translated here (the
|
|
2236
3009
|
// phase-A external forms assume the collection layout)
|
|
2237
3010
|
if (pred.p === 'cmp' && 'ext' in pred.operand) {
|
|
2238
|
-
blocked =
|
|
2239
|
-
reason: 'externals compare only against entity columns in this version' };
|
|
3011
|
+
blocked = refusal('$eq', ENTITY_REASONS.external);
|
|
2240
3012
|
}
|
|
2241
3013
|
return { ...pred, ref: { ...pred.ref, flavor: 'entity-doc' } };
|
|
2242
3014
|
}
|
|
@@ -2244,8 +3016,7 @@ export function planEntityPredicate(node, slot, shape) {
|
|
|
2244
3016
|
flavor: flavored.flavor, storage: flavored.storage, format: flavored.format };
|
|
2245
3017
|
if (flavored.flavor === 'entity-epoch' && pred.p === 'cmp') {
|
|
2246
3018
|
if ('ext' in pred.operand) {
|
|
2247
|
-
blocked =
|
|
2248
|
-
reason: 'externals compare only against entity columns in this version' };
|
|
3019
|
+
blocked = refusal(pred.op, ENTITY_REASONS.external);
|
|
2249
3020
|
return { ...pred, ref };
|
|
2250
3021
|
}
|
|
2251
3022
|
// the plan-time instant translation: an ordering comparison
|
|
@@ -2301,7 +3072,7 @@ function planEntityQueryCore(document, entities, mapping, operators) {
|
|
|
2301
3072
|
const [inner, start, length] = root.args;
|
|
2302
3073
|
if (start?.kind !== 'literal' || !isWindowBound(start.value)
|
|
2303
3074
|
|| (length !== undefined && (length.kind !== 'literal' || !isWindowBound(length.value))))
|
|
2304
|
-
return residual('$subsequence',
|
|
3075
|
+
return residual('$subsequence', PLAN_REASONS.windowBounds);
|
|
2305
3076
|
windows.push({ offset: start.value, limit: length === undefined ? null : length.value });
|
|
2306
3077
|
root = inner;
|
|
2307
3078
|
assertDecidedKind(root);
|
|
@@ -2313,27 +3084,18 @@ function planEntityQueryCore(document, entities, mapping, operators) {
|
|
|
2313
3084
|
assertDecidedKind(root);
|
|
2314
3085
|
}
|
|
2315
3086
|
if (root.kind !== 'flwor')
|
|
2316
|
-
return residual(root.kind,
|
|
3087
|
+
return residual(root.kind, ENTITY_REASONS.notFlwor);
|
|
2317
3088
|
if (root.fold !== null || root.letBindings.length > 0 || root.asChecks !== null
|
|
2318
3089
|
|| root.groupby !== null || root.count !== null)
|
|
2319
|
-
return residual('$let',
|
|
3090
|
+
return residual('$let', KIND_REASONS.let);
|
|
2320
3091
|
|
|
2321
3092
|
// bindings must each range over one entity's array
|
|
2322
3093
|
const bindings = [];
|
|
2323
3094
|
for (const binding of root.forBindings) {
|
|
2324
|
-
const
|
|
2325
|
-
|
|
2326
|
-
&& source.external !== true && source.segments.length === 2
|
|
2327
|
-
&& source.segments[0].descendant !== true
|
|
2328
|
-
&& source.segments[0].selectors.length === 1
|
|
2329
|
-
&& source.segments[0].selectors[0].kind === 'name'
|
|
2330
|
-
&& source.segments[1].selectors?.length === 1
|
|
2331
|
-
&& source.segments[1].selectors[0].kind === 'wildcard'
|
|
2332
|
-
? source.segments[0].selectors[0].name
|
|
2333
|
-
: null;
|
|
2334
|
-
if (sourceEntity === null || !entities.has(sourceEntity)
|
|
3095
|
+
const sourceEntity = bindingEntity(binding, entities);
|
|
3096
|
+
if (sourceEntity === null
|
|
2335
3097
|
|| binding.window !== null || binding.atSlot !== -1 || binding.allowingEmpty !== false)
|
|
2336
|
-
return residual('$for',
|
|
3098
|
+
return residual('$for', ENTITY_REASONS.bindingRoot);
|
|
2337
3099
|
bindings.push({
|
|
2338
3100
|
name: binding.name,
|
|
2339
3101
|
slot: binding.slot,
|
|
@@ -2341,9 +3103,6 @@ function planEntityQueryCore(document, entities, mapping, operators) {
|
|
|
2341
3103
|
shape: entityShape(entities.get(sourceEntity), mapping.entities[sourceEntity]),
|
|
2342
3104
|
});
|
|
2343
3105
|
}
|
|
2344
|
-
if (bindings.length > 2)
|
|
2345
|
-
return residual('$for', 'at most two bindings are translated (one join per statement)');
|
|
2346
|
-
|
|
2347
3106
|
const byName = new Map(bindings.map((binding) => [binding.slot, binding]));
|
|
2348
3107
|
const conjuncts = root.where === null
|
|
2349
3108
|
? []
|
|
@@ -2351,14 +3110,21 @@ function planEntityQueryCore(document, entities, mapping, operators) {
|
|
|
2351
3110
|
? root.where.args
|
|
2352
3111
|
: [root.where];
|
|
2353
3112
|
|
|
2354
|
-
|
|
3113
|
+
/** Every column equality between two DIFFERENT bindings: the edges of
|
|
3114
|
+
* the relation graph the join order is built over. */
|
|
3115
|
+
const edges = [];
|
|
3116
|
+
/** Cross-binding comparisons that are not equalities. They REFINE a
|
|
3117
|
+
* match, they never make one: a binding still attaches by an
|
|
3118
|
+
* equality, so a range between two bindings can never be the thing
|
|
3119
|
+
* that turns a product into a join. */
|
|
3120
|
+
const refinements = [];
|
|
2355
3121
|
const filters = new Map(bindings.map((binding) => [binding.slot, null]));
|
|
2356
3122
|
const reasons = [];
|
|
2357
3123
|
let whereFullyPushed = true;
|
|
2358
3124
|
for (const conjunct of conjuncts) {
|
|
2359
|
-
// a
|
|
2360
|
-
|
|
2361
|
-
|
|
3125
|
+
// a column equality between two bindings is a join edge, whatever
|
|
3126
|
+
// the binding count — several between one pair simply conjoin
|
|
3127
|
+
if (bindings.length > 1 && conjunct.kind === 'op' && conjunct.name === '$eq') {
|
|
2362
3128
|
const [left, right] = conjunct.args;
|
|
2363
3129
|
const leftBinding = left.kind === 'path' ? byName.get(left.rootSlot) : undefined;
|
|
2364
3130
|
const rightBinding = right.kind === 'path' ? byName.get(right.rootSlot) : undefined;
|
|
@@ -2367,20 +3133,28 @@ function planEntityQueryCore(document, entities, mapping, operators) {
|
|
|
2367
3133
|
const leftRef = entityPathRef(left, left.rootSlot, leftBinding.shape);
|
|
2368
3134
|
const rightRef = entityPathRef(right, right.rootSlot, rightBinding.shape);
|
|
2369
3135
|
if (leftRef?.flavor === 'entity-column' && rightRef?.flavor === 'entity-column') {
|
|
2370
|
-
|
|
3136
|
+
edges.push({
|
|
2371
3137
|
left: { binding: leftBinding, ref: leftRef },
|
|
2372
3138
|
right: { binding: rightBinding, ref: rightRef },
|
|
2373
|
-
};
|
|
3139
|
+
});
|
|
2374
3140
|
continue;
|
|
2375
3141
|
}
|
|
2376
3142
|
}
|
|
2377
3143
|
}
|
|
3144
|
+
// a cross-binding comparison that is not an equality: a refinement
|
|
3145
|
+
if (bindings.length > 1 && conjunct.kind === 'op'
|
|
3146
|
+
&& COMPARISONS.has(conjunct.name) && conjunct.name !== '$eq') {
|
|
3147
|
+
const refinement = crossBindingComparison(conjunct, byName);
|
|
3148
|
+
if (refinement !== null) {
|
|
3149
|
+
refinements.push(refinement);
|
|
3150
|
+
continue;
|
|
3151
|
+
}
|
|
3152
|
+
}
|
|
2378
3153
|
// otherwise the conjunct must belong wholly to ONE binding
|
|
2379
3154
|
const slots = new Set();
|
|
2380
3155
|
collectBindingSlots(conjunct, byName, slots);
|
|
2381
3156
|
if (slots.size !== 1) {
|
|
2382
|
-
reasons.push(
|
|
2383
|
-
reason: 'a conjunct must belong to one binding (or be the single join equality)' });
|
|
3157
|
+
reasons.push(refusal('$where', ENTITY_REASONS.conjunctBinding));
|
|
2384
3158
|
whereFullyPushed = false;
|
|
2385
3159
|
continue;
|
|
2386
3160
|
}
|
|
@@ -2394,15 +3168,61 @@ function planEntityQueryCore(document, entities, mapping, operators) {
|
|
|
2394
3168
|
}
|
|
2395
3169
|
filters.set(slot, conjoin(filters.get(slot), outcome.pred));
|
|
2396
3170
|
}
|
|
2397
|
-
|
|
2398
|
-
|
|
3171
|
+
// the join ORDER: start at the first binding and attach, one at a
|
|
3172
|
+
// time, any binding an edge connects to what is already attached.
|
|
3173
|
+
// A binding nothing connects would be a CARTESIAN product — the one
|
|
3174
|
+
// thing a nested-loop plan must never emit by accident — so a graph
|
|
3175
|
+
// that does not close is the residual, named
|
|
3176
|
+
const joins = [];
|
|
3177
|
+
if (bindings.length > 1) {
|
|
3178
|
+
const attached = new Set([bindings[0].name]);
|
|
3179
|
+
joins.push({ binding: bindings[0].name, on: [] });
|
|
3180
|
+
let progress = true;
|
|
3181
|
+
while (attached.size < bindings.length && progress) {
|
|
3182
|
+
progress = false;
|
|
3183
|
+
for (const binding of bindings) {
|
|
3184
|
+
if (attached.has(binding.name)) continue;
|
|
3185
|
+
const on = edges.filter((edge) =>
|
|
3186
|
+
(edge.left.binding === binding && attached.has(edge.right.binding.name))
|
|
3187
|
+
|| (edge.right.binding === binding && attached.has(edge.left.binding.name)));
|
|
3188
|
+
if (on.length === 0) continue;
|
|
3189
|
+
joins.push({ binding: binding.name, on });
|
|
3190
|
+
attached.add(binding.name);
|
|
3191
|
+
progress = true;
|
|
3192
|
+
break;
|
|
3193
|
+
}
|
|
3194
|
+
}
|
|
3195
|
+
if (attached.size < bindings.length)
|
|
3196
|
+
return residual('$for', ENTITY_REASONS.joinKey);
|
|
3197
|
+
// an edge between two bindings that were BOTH already attached is a
|
|
3198
|
+
// further equality, not another join: it rides the later one's ON,
|
|
3199
|
+
// which is where a nested loop can use it. A non-equality refinement
|
|
3200
|
+
// rides the same place, for the same reason
|
|
3201
|
+
const place = (entry) => {
|
|
3202
|
+
const later = joins.findLast((join) =>
|
|
3203
|
+
join.binding === entry.left.binding.name || join.binding === entry.right.binding.name);
|
|
3204
|
+
later.on.push(entry);
|
|
3205
|
+
};
|
|
3206
|
+
for (const edge of edges) {
|
|
3207
|
+
if (!joins.some((join) => join.on.includes(edge))) place(edge);
|
|
3208
|
+
}
|
|
3209
|
+
for (const refinement of refinements) place(refinement);
|
|
3210
|
+
}
|
|
3211
|
+
else if (refinements.length > 0) {
|
|
3212
|
+
// one binding cannot have a cross-binding comparison; this is
|
|
3213
|
+
// unreachable, and the graph walk above is what makes it so
|
|
3214
|
+
reasons.push(refusal('$where', ENTITY_REASONS.conjunctBinding));
|
|
3215
|
+
whereFullyPushed = false;
|
|
3216
|
+
}
|
|
2399
3217
|
|
|
2400
|
-
// the return
|
|
3218
|
+
// the return is one bare binding — the entity's own documents — or a
|
|
3219
|
+
// SHAPE the projection tree rebuilds from the bindings' members
|
|
2401
3220
|
const retBinding = root.ret.kind === 'var' && root.ret.external !== true
|
|
2402
3221
|
? byName.get(root.ret.slot) : undefined;
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
|
|
3222
|
+
const projection = retBinding === undefined && aggregate === null
|
|
3223
|
+
? entityProjectionTree(root.ret, byName) : null;
|
|
3224
|
+
if (retBinding === undefined && projection === null) {
|
|
3225
|
+
reasons.push(refusal('$return', ENTITY_REASONS.projection));
|
|
2406
3226
|
}
|
|
2407
3227
|
|
|
2408
3228
|
// ordering over flavored refs of either binding
|
|
@@ -2423,8 +3243,7 @@ function planEntityQueryCore(document, entities, mapping, operators) {
|
|
|
2423
3243
|
|| (ref.flavor === 'entity-doc' && admitsNull(binding.shape.schema, ref.segments))
|
|
2424
3244
|
|| spec.collation !== null || spec.collationName !== null) {
|
|
2425
3245
|
orderPushed = false;
|
|
2426
|
-
reasons.push(
|
|
2427
|
-
reason: 'ordering translates only over typed entity paths (never a boolean, never a document path that admits null)' });
|
|
3246
|
+
reasons.push(refusal('$orderby', ENTITY_REASONS.order));
|
|
2428
3247
|
break;
|
|
2429
3248
|
}
|
|
2430
3249
|
terms.push({ binding, ref, desc: spec.desc === true, emptyGreatest: spec.emptyGreatest === true });
|
|
@@ -2432,8 +3251,8 @@ function planEntityQueryCore(document, entities, mapping, operators) {
|
|
|
2432
3251
|
if (orderPushed) order = terms;
|
|
2433
3252
|
}
|
|
2434
3253
|
|
|
2435
|
-
const fullyPushed = whereFullyPushed && orderPushed
|
|
2436
|
-
&& (
|
|
3254
|
+
const fullyPushed = whereFullyPushed && orderPushed
|
|
3255
|
+
&& (retBinding !== undefined || projection !== null);
|
|
2437
3256
|
if (!fullyPushed) {
|
|
2438
3257
|
return { analysis, mode: 'set', plan: null, referenced, reasons };
|
|
2439
3258
|
}
|
|
@@ -2458,12 +3277,19 @@ function planEntityQueryCore(document, entities, mapping, operators) {
|
|
|
2458
3277
|
reasons: [],
|
|
2459
3278
|
plan: {
|
|
2460
3279
|
planVersion: PLAN_VERSION,
|
|
2461
|
-
alg: bindings.length
|
|
3280
|
+
alg: bindings.length > 1 ? 'entity-join' : 'entity-select',
|
|
2462
3281
|
bindings: bindings.map((binding) => ({ name: binding.name, entity: binding.entity })),
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
3282
|
+
// the FROM order and each binding's join conditions; `bindings`
|
|
3283
|
+
// stays in the DOCUMENT's order, which is the nested-loop order
|
|
3284
|
+
// the ORDER BY reproduces
|
|
3285
|
+
joins: joins.map((join) => ({
|
|
3286
|
+
binding: join.binding,
|
|
3287
|
+
on: join.on.map((edge) => ({
|
|
3288
|
+
op: edge.op ?? 'eq',
|
|
3289
|
+
left: { binding: edge.left.binding.name, column: edge.left.ref.column },
|
|
3290
|
+
right: { binding: edge.right.binding.name, column: edge.right.ref.column },
|
|
3291
|
+
})),
|
|
3292
|
+
})),
|
|
2467
3293
|
filters: bindings.map((binding) => ({
|
|
2468
3294
|
binding: binding.name,
|
|
2469
3295
|
filter: filters.get(binding.slot),
|
|
@@ -2474,7 +3300,13 @@ function planEntityQueryCore(document, entities, mapping, operators) {
|
|
|
2474
3300
|
})),
|
|
2475
3301
|
window,
|
|
2476
3302
|
aggregate,
|
|
2477
|
-
ret: retBinding.name,
|
|
3303
|
+
ret: retBinding === undefined ? null : retBinding.name,
|
|
3304
|
+
// the projected shape, when the return is one: leaves that name
|
|
3305
|
+
// the binding they read from, and the tree the decoder rebuilds
|
|
3306
|
+
project: projection === null ? null : {
|
|
3307
|
+
tree: projection.tree,
|
|
3308
|
+
leaves: projection.leaves.map((leaf) => ({ binding: leaf.binding, ref: leaf.ref })),
|
|
3309
|
+
},
|
|
2478
3310
|
},
|
|
2479
3311
|
};
|
|
2480
3312
|
}
|
|
@@ -2491,13 +3323,59 @@ function planEntityQueryCore(document, entities, mapping, operators) {
|
|
|
2491
3323
|
*/
|
|
2492
3324
|
export function planEntityQuery(document, entities, mapping, operators = null) {
|
|
2493
3325
|
const peeled = peelWrappedResult(document);
|
|
3326
|
+
const core = planEntityQueryCore(peeled.document, entities, mapping, operators);
|
|
2494
3327
|
const planned = {
|
|
2495
|
-
...
|
|
3328
|
+
...core,
|
|
2496
3329
|
wrapped: peeled.wrapped,
|
|
3330
|
+
// the entity whose documents the query yields, in EITHER mode: what
|
|
3331
|
+
// a tracked cursor registers, and `null` when there is nothing to
|
|
3332
|
+
// register — a projection, a count, a window handed over whole
|
|
3333
|
+
retEntity: peeled.wrapped ? null : returnedEntity(core.analysis, entities),
|
|
2497
3334
|
};
|
|
2498
3335
|
return prependRegisteredReason(planned, document, operators);
|
|
2499
3336
|
}
|
|
2500
3337
|
|
|
3338
|
+
/**
|
|
3339
|
+
* The declared entity a `$for` binding ranges over — the array
|
|
3340
|
+
* `$.<Entity>[*]`, spelled exactly so — or `null` for any other source.
|
|
3341
|
+
* @param {any} binding - an analysed `$for` binding
|
|
3342
|
+
* @param {Map<string, any>} entities
|
|
3343
|
+
* @returns {string | null}
|
|
3344
|
+
*/
|
|
3345
|
+
function bindingEntity(binding, entities) {
|
|
3346
|
+
const source = unpacked(binding.expr);
|
|
3347
|
+
const name = source?.kind === 'path' && source.name === '$'
|
|
3348
|
+
&& source.external !== true && source.segments.length === 2
|
|
3349
|
+
&& source.segments[0].descendant !== true
|
|
3350
|
+
&& source.segments[0].selectors.length === 1
|
|
3351
|
+
&& source.segments[0].selectors[0].kind === 'name'
|
|
3352
|
+
&& source.segments[1].selectors?.length === 1
|
|
3353
|
+
&& source.segments[1].selectors[0].kind === 'wildcard'
|
|
3354
|
+
? source.segments[0].selectors[0].name
|
|
3355
|
+
: null;
|
|
3356
|
+
return name !== null && entities.has(name) ? name : null;
|
|
3357
|
+
}
|
|
3358
|
+
|
|
3359
|
+
/**
|
|
3360
|
+
* The entity whose documents a query RETURNS: under any literal
|
|
3361
|
+
* windows, a FLWOR whose `$return` is one bare binding over a declared
|
|
3362
|
+
* entity array. `null` for a projection, a count, or a binding over
|
|
3363
|
+
* anything else — the items are then not entity documents, and a
|
|
3364
|
+
* tracked cursor has nothing it may register.
|
|
3365
|
+
* @param {any} analysis - the engine's analysis of the document
|
|
3366
|
+
* @param {Map<string, any>} entities
|
|
3367
|
+
* @returns {string | null}
|
|
3368
|
+
*/
|
|
3369
|
+
function returnedEntity(analysis, entities) {
|
|
3370
|
+
let root = analysis.root;
|
|
3371
|
+
while (root.kind === 'op' && root.name === '$subsequence') root = root.args[0];
|
|
3372
|
+
if (root.kind !== 'flwor') return null;
|
|
3373
|
+
const ret = root.ret;
|
|
3374
|
+
if (ret.kind !== 'var' || ret.external === true) return null;
|
|
3375
|
+
const binding = root.forBindings.find((candidate) => candidate.slot === ret.slot);
|
|
3376
|
+
return binding === undefined ? null : bindingEntity(binding, entities);
|
|
3377
|
+
}
|
|
3378
|
+
|
|
2501
3379
|
/** Which binding slots a subtree references (via path roots). */
|
|
2502
3380
|
function collectBindingSlots(node, byName, slots) {
|
|
2503
3381
|
if (node === null || typeof node !== 'object') return;
|
|
@@ -2512,6 +3390,109 @@ function collectBindingSlots(node, byName, slots) {
|
|
|
2512
3390
|
}
|
|
2513
3391
|
}
|
|
2514
3392
|
|
|
3393
|
+
/**
|
|
3394
|
+
* Every member of one root a document READS, and whether it reads a
|
|
3395
|
+
* root item WHOLE. This walks the ANALYSIS — the normalized AST, where
|
|
3396
|
+
* a path is already resolved to the binding slot it hangs off — not the
|
|
3397
|
+
* document text, which cannot tell the path `$it.name` from a member
|
|
3398
|
+
* literally called `$it.name`.
|
|
3399
|
+
*
|
|
3400
|
+
* A path that is not singular (a wildcard, a slice, a descendant) reads
|
|
3401
|
+
* a SUBTREE, and its longest singular prefix is what a policy sees:
|
|
3402
|
+
* allowing a member allows everything under it, so the prefix is the
|
|
3403
|
+
* honest unit. A path with no singular prefix at all, and a bare
|
|
3404
|
+
* reference to the binding itself, read the whole item — reported as
|
|
3405
|
+
* `whole` with the construct that did it, because no member list can
|
|
3406
|
+
* cover them and narrowing one silently would be the wrong answer.
|
|
3407
|
+
*
|
|
3408
|
+
* @param {any} root - the analysis root node
|
|
3409
|
+
* @param {(expr: any) => boolean} isRootSource - whether one
|
|
3410
|
+
* `$for` binding ranges over the root being policed
|
|
3411
|
+
* @returns {{ members: { canonical: string, member: string, docPath: string }[],
|
|
3412
|
+
* whole: { construct: string, docPath: string } | null }}
|
|
3413
|
+
*/
|
|
3414
|
+
export function collectMemberReads(root, isRootSource) {
|
|
3415
|
+
/** @type {Set<number>} */
|
|
3416
|
+
const slots = new Set();
|
|
3417
|
+
const walk = (node, visit) => {
|
|
3418
|
+
if (node === null || typeof node !== 'object') return;
|
|
3419
|
+
if (Array.isArray(node)) {
|
|
3420
|
+
for (const item of node) walk(item, visit);
|
|
3421
|
+
return;
|
|
3422
|
+
}
|
|
3423
|
+
visit(node);
|
|
3424
|
+
for (const key of Object.keys(node)) {
|
|
3425
|
+
if (key === 'docPath') continue;
|
|
3426
|
+
walk(node[key], visit);
|
|
3427
|
+
}
|
|
3428
|
+
};
|
|
3429
|
+
walk(root, (node) => {
|
|
3430
|
+
if (node.kind !== 'flwor' || !Array.isArray(node.forBindings)) return;
|
|
3431
|
+
for (const binding of node.forBindings) {
|
|
3432
|
+
if (isRootSource(binding?.expr)) slots.add(binding.slot);
|
|
3433
|
+
}
|
|
3434
|
+
});
|
|
3435
|
+
|
|
3436
|
+
/** @type {Map<string, { canonical: string, member: string, docPath: string }>} */
|
|
3437
|
+
const members = new Map();
|
|
3438
|
+
/** @type {{ construct: string, docPath: string } | null} */
|
|
3439
|
+
let whole = null;
|
|
3440
|
+
const readsWhole = (construct, docPath) => {
|
|
3441
|
+
if (whole === null) whole = { construct, docPath: docPath ?? '$' };
|
|
3442
|
+
};
|
|
3443
|
+
walk(root, (node) => {
|
|
3444
|
+
if (node.external === true) return;
|
|
3445
|
+
if (node.kind === 'var' && slots.has(node.slot)) {
|
|
3446
|
+
readsWhole('$' + (node.name ?? ''), node.docPath);
|
|
3447
|
+
return;
|
|
3448
|
+
}
|
|
3449
|
+
if (node.kind !== 'path' || !slots.has(node.rootSlot)) return;
|
|
3450
|
+
/** @type {({ name: string } | { index: number })[]} */
|
|
3451
|
+
const segments = [];
|
|
3452
|
+
for (const segment of node.segments ?? []) {
|
|
3453
|
+
if (segment.descendant === true || segment.selectors.length !== 1) break;
|
|
3454
|
+
const selector = segment.selectors[0];
|
|
3455
|
+
if (selector.kind === 'name') segments.push({ name: selector.name });
|
|
3456
|
+
else if (selector.kind === 'index') segments.push({ index: selector.index });
|
|
3457
|
+
else break;
|
|
3458
|
+
}
|
|
3459
|
+
if (segments.length === 0) {
|
|
3460
|
+
readsWhole('$' + (node.name ?? ''), node.docPath);
|
|
3461
|
+
return;
|
|
3462
|
+
}
|
|
3463
|
+
const canonical = canonicalOf(segments);
|
|
3464
|
+
if (!members.has(canonical)) {
|
|
3465
|
+
members.set(canonical, { canonical,
|
|
3466
|
+
member: segments.map((segment) => ('name' in segment ? segment.name : segment.index))
|
|
3467
|
+
.join('.'),
|
|
3468
|
+
docPath: node.docPath ?? '$' });
|
|
3469
|
+
}
|
|
3470
|
+
});
|
|
3471
|
+
return { members: [...members.values()], whole };
|
|
3472
|
+
}
|
|
3473
|
+
|
|
3474
|
+
/**
|
|
3475
|
+
* Whether one `$for` binding ranges over the whole collection — the one
|
|
3476
|
+
* spelling `$[*]`, bare or packed, that {@link collectMemberReads}
|
|
3477
|
+
* policies against on the collection side.
|
|
3478
|
+
* @param {any} expr
|
|
3479
|
+
* @returns {boolean}
|
|
3480
|
+
*/
|
|
3481
|
+
export function isRootScanSource(expr) {
|
|
3482
|
+
return isCollectionSource(expr);
|
|
3483
|
+
}
|
|
3484
|
+
|
|
3485
|
+
/**
|
|
3486
|
+
* Whether one `$for` binding ranges over a named entity's array — the
|
|
3487
|
+
* entity-side counterpart of {@link isRootScanSource}, reading the one
|
|
3488
|
+
* spelling {@link entityRoot} publishes.
|
|
3489
|
+
* @param {string} name - a declared entity name
|
|
3490
|
+
* @returns {(expr: any) => boolean}
|
|
3491
|
+
*/
|
|
3492
|
+
export function isEntityRootSource(name) {
|
|
3493
|
+
return (expr) => bindingEntity({ expr }, new Map([[name, true]])) === name;
|
|
3494
|
+
}
|
|
3495
|
+
|
|
2515
3496
|
/**
|
|
2516
3497
|
* The root expression an entity's rows are bound through — the ONE
|
|
2517
3498
|
* spelling of `$.<Name>[*]`: what an entity set exposes as its `root`
|