@jarenjs/db 0.49.2 → 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 +420 -71
- package/README.md +711 -79
- package/docs/HOSTS.md +269 -0
- package/docs/JOBS-FORMAT.md +309 -45
- package/docs/LIVE-FORMAT.md +156 -19
- package/docs/MIGRATION-FORMAT.md +247 -40
- package/docs/MODEL-FORMAT.md +968 -86
- package/package.json +21 -8
- package/schemas/jaren-migration.draft-07.schema.json +73 -0
- package/schemas/jaren-migration.schema.json +73 -0
- 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 +255 -44
- package/src/cli.js +337 -50
- package/src/cursor.js +411 -0
- package/src/dag-job.js +154 -21
- package/src/ddl.js +125 -11
- 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 +245 -12
- package/src/document-files.js +311 -0
- package/src/document-steps.js +422 -0
- package/src/documents.js +335 -0
- package/src/driver.js +503 -69
- 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 +18 -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-model.js +14 -0
- package/src/emit.js +349 -51
- package/src/entity.js +102 -59
- package/src/errors.js +430 -2
- package/src/expression.js +284 -0
- package/src/graph.js +64 -8
- package/src/index.js +48 -19
- package/src/introspect.js +583 -0
- package/src/jobs.js +870 -99
- package/src/json-bytes.js +58 -0
- package/src/live-time.js +12 -3
- package/src/live.js +11 -1
- package/src/maintenance.js +175 -0
- package/src/migrate.js +606 -333
- package/src/model.js +241 -8
- package/src/plan.js +1238 -160
- package/src/pragmas.js +314 -0
- package/src/profile.js +151 -3
- package/src/query.js +1748 -312
- package/src/residual.js +17 -0
- package/src/series.js +12 -4
- package/src/store.js +1672 -276
- package/src/tracker.js +367 -68
- package/src/udf.js +88 -7
- package/types/index.d.ts +1246 -32
- package/types/node-pool.d.ts +28 -0
- package/types/node-worker.d.ts +54 -0
- package/types/node.d.ts +72 -3
- package/types/postgres.d.ts +46 -0
- package/types/typed.d.ts +81 -3
- package/types/wasm.d.ts +21 -0
- package/dist/types/algebra.d.ts +0 -230
- package/dist/types/app.d.ts +0 -49
- package/dist/types/capture.d.ts +0 -85
- package/dist/types/cli.d.ts +0 -2
- package/dist/types/dag-job.d.ts +0 -40
- package/dist/types/ddl.d.ts +0 -229
- package/dist/types/derive.d.ts +0 -250
- package/dist/types/dialect.d.ts +0 -154
- package/dist/types/dialects/sqlite.d.ts +0 -9
- package/dist/types/driver.d.ts +0 -110
- package/dist/types/drivers/bun.d.ts +0 -47
- package/dist/types/drivers/node.d.ts +0 -37
- package/dist/types/drivers/wasm.d.ts +0 -65
- package/dist/types/emit-model.d.ts +0 -44
- package/dist/types/emit.d.ts +0 -75
- package/dist/types/entity.d.ts +0 -23
- package/dist/types/errors.d.ts +0 -170
- package/dist/types/graph.d.ts +0 -28
- package/dist/types/index.d.ts +0 -37
- package/dist/types/jobs.d.ts +0 -140
- package/dist/types/knn.d.ts +0 -69
- package/dist/types/live-time.d.ts +0 -141
- package/dist/types/live.d.ts +0 -64
- package/dist/types/migrate.d.ts +0 -170
- package/dist/types/model.d.ts +0 -36
- package/dist/types/patch-sql.d.ts +0 -37
- package/dist/types/plan.d.ts +0 -142
- package/dist/types/profile.d.ts +0 -80
- package/dist/types/query.d.ts +0 -112
- package/dist/types/residual.d.ts +0 -64
- package/dist/types/series.d.ts +0 -227
- package/dist/types/store.d.ts +0 -60
- package/dist/types/tracker.d.ts +0 -43
- package/dist/types/typed.d.ts +0 -15
- package/dist/types/types.d.ts +0 -26
- package/dist/types/udf.d.ts +0 -75
- package/dist/types/window.d.ts +0 -52
package/src/plan.js
CHANGED
|
@@ -45,14 +45,14 @@ import { compileBuckets, resampleSeries, toEpoch } from '@jarenjs/core/series';
|
|
|
45
45
|
|
|
46
46
|
import { selectPlan, conjoin, PLAN_VERSION } from './algebra.js';
|
|
47
47
|
import { typeOfPath, isNumericType } from './types.js';
|
|
48
|
-
import { schemaNodeAt } from './ddl.js';
|
|
48
|
+
import { schemaNodeAt, canonicalOf } from './ddl.js';
|
|
49
49
|
import {
|
|
50
50
|
BBOX_COMPONENTS, BBOX_INDEX_ORDER, PRECISION_MIN, PRECISION_MAX,
|
|
51
51
|
probeBox, probePosition, probeCircleBox, cellNeighbourhood, probeVector,
|
|
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,8 +218,35 @@ for (const kind of NODE_KINDS) {
|
|
|
119
218
|
}
|
|
120
219
|
}
|
|
121
220
|
|
|
221
|
+
/** Whether a literal window bound is one SQL takes as written: a
|
|
222
|
+
* non-negative safe integer. A negative, fractional or non-finite bound
|
|
223
|
+
* is the ENGINE's to interpret (it answers `[]`, a truncation or a
|
|
224
|
+
* refusal), and interpolated into `LIMIT`/`OFFSET` it answered other
|
|
225
|
+
* rows or a raw database error. */
|
|
226
|
+
function isWindowBound(value) {
|
|
227
|
+
return Number.isSafeInteger(value) && value >= 0;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Whether a schema node admits `null` beside its scalar type. A present
|
|
231
|
+
* null orders and aggregates in SQL (NULLS FIRST, skipped by SUM) where
|
|
232
|
+
* the engine refuses it (`JQ2005`, `JQ2001`), so a promotion over such a
|
|
233
|
+
* path answers where the reference semantics throw. */
|
|
234
|
+
function admitsNull(schema, segments) {
|
|
235
|
+
const node = schemaNodeAt(schema, segments);
|
|
236
|
+
return Array.isArray(node?.type) && node.type.includes('null');
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Whether a typed reference orders natively: numbers and strings that
|
|
240
|
+
* cannot hold a null. A boolean orders as 0/1 in SQL and is `JQ2005` in
|
|
241
|
+
* the engine. */
|
|
242
|
+
function orderable(ref, schema) {
|
|
243
|
+
return ref.type !== 'unknown' && ref.type !== 'boolean' && !admitsNull(schema, ref.segments);
|
|
244
|
+
}
|
|
245
|
+
|
|
122
246
|
/**
|
|
123
|
-
* One named refusal
|
|
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.
|
|
124
250
|
* @param {string} construct
|
|
125
251
|
* @param {string} reason
|
|
126
252
|
* @returns {{ construct: string, reason: string }}
|
|
@@ -222,16 +348,10 @@ function prependRegisteredReason(planned, document, operators) {
|
|
|
222
348
|
const residualPart = planned.mode === 'row' ? planned.rowReturn : document;
|
|
223
349
|
const used = registeredOpsUsed(residualPart, registered);
|
|
224
350
|
if (used.length === 0) return planned;
|
|
225
|
-
const many = used.length > 1;
|
|
226
351
|
return {
|
|
227
352
|
...planned,
|
|
228
353
|
reasons: [
|
|
229
|
-
|
|
230
|
-
construct: used.join(', '),
|
|
231
|
-
reason: `registered operator${many ? 's' : ''} `
|
|
232
|
-
+ `${used.map((n) => `'${n}'`).join(', ')} run${many ? '' : 's'} in the residual `
|
|
233
|
-
+ '(Ring 2 — correct, not pushed to SQL)',
|
|
234
|
-
},
|
|
354
|
+
refusal(used.join(', '), OPERATOR_REASONS.registered(used)),
|
|
235
355
|
...planned.reasons,
|
|
236
356
|
],
|
|
237
357
|
};
|
|
@@ -268,9 +388,7 @@ function memberPath(node, itSlot) {
|
|
|
268
388
|
else return null;
|
|
269
389
|
}
|
|
270
390
|
if (segments.length === 0) return null;
|
|
271
|
-
|
|
272
|
-
.map((s) => ('name' in s ? `.${s.name}` : `[${s.index}]`)).join('');
|
|
273
|
-
return { segments, canonical };
|
|
391
|
+
return { segments, canonical: canonicalOf(segments) };
|
|
274
392
|
}
|
|
275
393
|
|
|
276
394
|
/**
|
|
@@ -363,6 +481,25 @@ function isGeographicSchema(node) {
|
|
|
363
481
|
/** `$geohash`'s default precision (QUERY-FORMAT §8.14). */
|
|
364
482
|
const GEOHASH_DEFAULT_PRECISION = 9;
|
|
365
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
|
+
|
|
366
503
|
const SPATIAL_REASONS = {
|
|
367
504
|
within: 'a bounding-box pre-filter is pushed; exact containment refines in the engine',
|
|
368
505
|
distance: 'a geodesic-circle box pre-filter is pushed; the exact distance refines in the engine',
|
|
@@ -383,6 +520,8 @@ const SPATIAL_REASONS = {
|
|
|
383
520
|
rtreeBox: "the box is stored in an R*Tree, whose coordinates are 32-bit floats rounded "
|
|
384
521
|
+ 'OUTWARD, so the stored box is a superset of the row\'s; the exact box test refines '
|
|
385
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"',
|
|
386
525
|
};
|
|
387
526
|
|
|
388
527
|
/**
|
|
@@ -634,6 +773,9 @@ function planDistanceBound(node, itSlot, shape) {
|
|
|
634
773
|
if (constant === null) return { refusal: refusal('$distance', SPATIAL_REASONS.operand) };
|
|
635
774
|
const at = probePosition(constant.value);
|
|
636
775
|
if (at === null) return { refusal: refusal('$distance', SPATIAL_REASONS.unbounded) };
|
|
776
|
+
if (!Number.isFinite(radius.value) || radius.value < 0) {
|
|
777
|
+
return { refusal: refusal('$distance', SPATIAL_REASONS.radius) };
|
|
778
|
+
}
|
|
637
779
|
const box = probeCircleBox(at, radius.value);
|
|
638
780
|
if (box === null) return { refusal: refusal('$distance', SPATIAL_REASONS.pole) };
|
|
639
781
|
if (box[0] < -180 || box[2] > 180)
|
|
@@ -804,11 +946,74 @@ const KNN_REASONS = {
|
|
|
804
946
|
/** @param {string} path @param {number} want @param {number[]} have */
|
|
805
947
|
dims: (path, want, have) =>
|
|
806
948
|
`the literal probe has ${want} components but the vector column over ${path} is declared at ${have.join(', ')}`,
|
|
807
|
-
/** @param {string} path */
|
|
808
|
-
widths: (path) =>
|
|
809
|
-
`several vector widths are declared over ${path}; an external probe cannot choose one at plan time`,
|
|
810
949
|
};
|
|
811
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
|
+
|
|
812
1017
|
/**
|
|
813
1018
|
* Recognize the k-nearest ordering, or say why not. `null` when the
|
|
814
1019
|
* first key is not a `$similarity` at all — an ordinary ordering the
|
|
@@ -844,10 +1049,14 @@ function planKnnOrder(orderby, itSlot, shape, selectionPushed) {
|
|
|
844
1049
|
|
|
845
1050
|
if (probeNode.kind === 'var' && probeNode.external === true) {
|
|
846
1051
|
if (declared.length === 0) return refuse(KNN_REASONS.noColumn(path, null));
|
|
847
|
-
if (declared.length > 1) return refuse(KNN_REASONS.widths(path));
|
|
848
1052
|
if (!selectionPushed) return refuse(KNN_REASONS.selection);
|
|
849
|
-
|
|
850
|
-
|
|
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 } } };
|
|
851
1060
|
}
|
|
852
1061
|
const constant = constantOf(probeNode);
|
|
853
1062
|
if (constant === null || !Array.isArray(constant.value)) return refuse(KNN_REASONS.probe);
|
|
@@ -860,7 +1069,8 @@ function planKnnOrder(orderby, itSlot, shape, selectionPushed) {
|
|
|
860
1069
|
: KNN_REASONS.dims(path, dims, declared.map((entry) => entry.dims)));
|
|
861
1070
|
}
|
|
862
1071
|
if (!selectionPushed) return refuse(KNN_REASONS.selection);
|
|
863
|
-
return { rank: { column: /** @type {string} */ (column), dims
|
|
1072
|
+
return { rank: { alternatives: [{ column: /** @type {string} */ (column), dims }],
|
|
1073
|
+
probe: { lit: constant.value } } };
|
|
864
1074
|
}
|
|
865
1075
|
|
|
866
1076
|
/**
|
|
@@ -883,8 +1093,7 @@ function planSpatial(node, itSlot, shape) {
|
|
|
883
1093
|
// `$distance >= r` — "farther than" — is narrowed by no box at all
|
|
884
1094
|
const other = upperOnLeft ? node.args[1] : node.args[0];
|
|
885
1095
|
if (other?.kind === 'op' && other.name === '$distance') {
|
|
886
|
-
return { refusal: refusal('$distance',
|
|
887
|
-
'only a BOUNDED distance is promoted; no box narrows "farther than r"') };
|
|
1096
|
+
return { refusal: refusal('$distance', SPATIAL_REASONS.unboundedFar) };
|
|
888
1097
|
}
|
|
889
1098
|
return null;
|
|
890
1099
|
}
|
|
@@ -899,6 +1108,108 @@ function planSpatial(node, itSlot, shape) {
|
|
|
899
1108
|
return null;
|
|
900
1109
|
}
|
|
901
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
|
+
|
|
902
1213
|
/**
|
|
903
1214
|
* Translate one predicate node, or explain why not.
|
|
904
1215
|
*
|
|
@@ -916,8 +1227,8 @@ function planSpatial(node, itSlot, shape) {
|
|
|
916
1227
|
function planPredicate(node, itSlot, shape) {
|
|
917
1228
|
assertDecidedKind(node);
|
|
918
1229
|
if (node.kind !== 'op') {
|
|
919
|
-
return { refusal: refusal(node.kind,
|
|
920
|
-
??
|
|
1230
|
+
return { refusal: refusal(node.kind,
|
|
1231
|
+
KIND_REASONS[node.kind] ?? PREDICATE_REASONS.notPredicate) };
|
|
921
1232
|
}
|
|
922
1233
|
|
|
923
1234
|
if (node.name === '$and' || node.name === '$or') {
|
|
@@ -942,8 +1253,7 @@ function planPredicate(node, itSlot, shape) {
|
|
|
942
1253
|
if (inner.refinements.length > 0) {
|
|
943
1254
|
// negating a superset is a SUBSET, which drops matching rows —
|
|
944
1255
|
// the one composition an implied conjunct may never enter
|
|
945
|
-
return { refusal: refusal('$not',
|
|
946
|
-
'a negated predicate cannot ride an implied pre-filter (negating a superset drops rows)') };
|
|
1256
|
+
return { refusal: refusal('$not', PREDICATE_REASONS.negatedPrefilter) };
|
|
947
1257
|
}
|
|
948
1258
|
return { pred: { p: 'not', item: inner.pred },
|
|
949
1259
|
exact: true, prefilters: inner.prefilters, refinements: [] };
|
|
@@ -952,11 +1262,12 @@ function planPredicate(node, itSlot, shape) {
|
|
|
952
1262
|
const spatial = planSpatial(node, itSlot, shape);
|
|
953
1263
|
if (spatial !== null) return spatial;
|
|
954
1264
|
|
|
1265
|
+
if (node.name === '$overlaps') return planIntervalOverlap(node, itSlot, shape);
|
|
1266
|
+
|
|
955
1267
|
if (node.name === '$exists' || node.name === '$empty') {
|
|
956
1268
|
const ref = pathRef(node.args[0], itSlot, shape);
|
|
957
1269
|
if (ref === null) {
|
|
958
|
-
return { refusal: refusal(node.name,
|
|
959
|
-
'existence tests translate only over a singular member path on the binding') };
|
|
1270
|
+
return { refusal: refusal(node.name, PREDICATE_REASONS.existence) };
|
|
960
1271
|
}
|
|
961
1272
|
return exactly({ p: 'typeIs', ref, types: [], positive: node.name === '$exists' });
|
|
962
1273
|
}
|
|
@@ -974,14 +1285,12 @@ function planPredicate(node, itSlot, shape) {
|
|
|
974
1285
|
const operand = operandOf(right);
|
|
975
1286
|
if (ref === null || operand === null) {
|
|
976
1287
|
if (pathRef(left, itSlot, shape) !== null && pathRef(right, itSlot, shape) !== null)
|
|
977
|
-
return { refusal: refusal(node.name,
|
|
978
|
-
return { refusal: refusal(node.name,
|
|
979
|
-
'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) };
|
|
980
1290
|
}
|
|
981
1291
|
if ('lit' in operand) {
|
|
982
1292
|
if (!isScalarLiteral(operand.lit)) {
|
|
983
|
-
return { refusal: refusal(node.name,
|
|
984
|
-
'array and object literals have no guarded native comparison form') };
|
|
1293
|
+
return { refusal: refusal(node.name, PREDICATE_REASONS.compoundLiteral) };
|
|
985
1294
|
}
|
|
986
1295
|
const lit = operand.lit;
|
|
987
1296
|
if (typeof lit === 'boolean' || lit === null) {
|
|
@@ -998,22 +1307,18 @@ function planPredicate(node, itSlot, shape) {
|
|
|
998
1307
|
const ref = pathRef(node.args[0], itSlot, shape);
|
|
999
1308
|
const operand = operandOf(node.args[1]);
|
|
1000
1309
|
if (ref === null || ref.type !== 'string') {
|
|
1001
|
-
return { refusal: refusal(node.name,
|
|
1002
|
-
'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) };
|
|
1003
1311
|
}
|
|
1004
1312
|
if (operand === null || !('lit' in operand) || typeof operand.lit !== 'string') {
|
|
1005
|
-
return { refusal: refusal(node.name,
|
|
1006
|
-
"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) };
|
|
1007
1314
|
}
|
|
1008
1315
|
if (operand.lit === '') {
|
|
1009
|
-
return { refusal: refusal(node.name,
|
|
1010
|
-
"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) };
|
|
1011
1317
|
}
|
|
1012
1318
|
return exactly({ p: 'strop', kind: /** @type {any} */ (stringOp), ref, operand });
|
|
1013
1319
|
}
|
|
1014
1320
|
|
|
1015
|
-
return { refusal: refusal(node.name,
|
|
1016
|
-
'no native spelling of this operator is proven equivalent') };
|
|
1321
|
+
return { refusal: refusal(node.name, PREDICATE_REASONS.noSpelling) };
|
|
1017
1322
|
}
|
|
1018
1323
|
|
|
1019
1324
|
// ————— Time series: the three closed shapes over a declared index —————
|
|
@@ -1046,13 +1351,30 @@ function planPredicate(node, itSlot, shape) {
|
|
|
1046
1351
|
// and it is why a calendar ladder, a fill policy, a rolling window and
|
|
1047
1352
|
// an as-of JOIN cost a bounded fetch rather than a wrong answer.
|
|
1048
1353
|
|
|
1049
|
-
/**
|
|
1354
|
+
/**
|
|
1355
|
+
* The source a packed spelling stands for. `["$[*]"]` — an array
|
|
1356
|
+
* constructor of exactly one element — is what a `$for` unpacks back
|
|
1357
|
+
* into the rows (QUERY-FORMAT §6.2, D4): `@jarenjs/linq` binds every
|
|
1358
|
+
* iterated source that way so an array-valued ROW stays one item. Over
|
|
1359
|
+
* a collection or an entity array the rows are objects, so the packed
|
|
1360
|
+
* and the bare spelling are the same rows, and the planner reads
|
|
1361
|
+
* through the packing rather than sending the document to the residual.
|
|
1362
|
+
* @param {any} node
|
|
1363
|
+
* @returns {any}
|
|
1364
|
+
*/
|
|
1365
|
+
function unpacked(node) {
|
|
1366
|
+
return node?.kind === 'array' && node.elements?.length === 1 ? node.elements[0] : node;
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
/** Is this node the whole collection — `$[*]` over the input document,
|
|
1370
|
+
* bare or packed? */
|
|
1050
1371
|
function isCollectionSource(node) {
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
&&
|
|
1054
|
-
&&
|
|
1055
|
-
&&
|
|
1372
|
+
const path = unpacked(node);
|
|
1373
|
+
return path?.kind === 'path' && path.name === '$' && path.external !== true
|
|
1374
|
+
&& path.rootSlot === 0 && path.singular !== true
|
|
1375
|
+
&& path.segments.length === 1 && path.segments[0].descendant !== true
|
|
1376
|
+
&& path.segments[0].selectors.length === 1
|
|
1377
|
+
&& path.segments[0].selectors[0].kind === 'wildcard';
|
|
1056
1378
|
}
|
|
1057
1379
|
|
|
1058
1380
|
/**
|
|
@@ -1081,7 +1403,7 @@ function memberRef(shape, name) {
|
|
|
1081
1403
|
return {
|
|
1082
1404
|
segments,
|
|
1083
1405
|
type: typeOfPath(shape.schema, segments),
|
|
1084
|
-
column: shape.columnByCanonical.get(
|
|
1406
|
+
column: shape.columnByCanonical.get(canonicalOf(segments)) ?? null,
|
|
1085
1407
|
};
|
|
1086
1408
|
}
|
|
1087
1409
|
|
|
@@ -1416,9 +1738,30 @@ function safeEpoch(value) {
|
|
|
1416
1738
|
}
|
|
1417
1739
|
}
|
|
1418
1740
|
|
|
1419
|
-
/**
|
|
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
|
+
*/
|
|
1420
1751
|
function instantBound(ref, op, value) {
|
|
1421
|
-
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 } };
|
|
1422
1765
|
}
|
|
1423
1766
|
|
|
1424
1767
|
/**
|
|
@@ -1436,6 +1779,56 @@ function impliedInstantBounds(ref, from, to) {
|
|
|
1436
1779
|
to, toOp: to === null ? null : 'le' } };
|
|
1437
1780
|
}
|
|
1438
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
|
+
|
|
1439
1832
|
/**
|
|
1440
1833
|
* Plan a document that IS a series operator over the collection.
|
|
1441
1834
|
*
|
|
@@ -1540,6 +1933,8 @@ function planSeriesOperator(root, shape) {
|
|
|
1540
1933
|
// the refinement: narrow through the index by whatever the spec makes
|
|
1541
1934
|
// provable, and let the engine's own kernel decide over what comes back
|
|
1542
1935
|
let range = null;
|
|
1936
|
+
/** @type {any[]} the anchors the plan asks the store for, for explain */
|
|
1937
|
+
const seeks = [];
|
|
1543
1938
|
if (name === '$resample' && at.column !== null) {
|
|
1544
1939
|
const from = safeEpoch(spec.start);
|
|
1545
1940
|
const to = safeEpoch(spec.end);
|
|
@@ -1553,6 +1948,8 @@ function planSeriesOperator(root, shape) {
|
|
|
1553
1948
|
}
|
|
1554
1949
|
else if (name === '$asof' && probesNode !== null) {
|
|
1555
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)));
|
|
1556
1953
|
if (probes !== null && at.column !== null) {
|
|
1557
1954
|
const tolerance = toleranceMs(spec.tolerance);
|
|
1558
1955
|
const direction = spec.direction ?? 'backward';
|
|
@@ -1576,17 +1973,26 @@ function planSeriesOperator(root, shape) {
|
|
|
1576
1973
|
range = bounds.range;
|
|
1577
1974
|
prefilters.push({ construct: name, via: 'columns', columns: [at.column], exact: false });
|
|
1578
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
|
+
}
|
|
1579
1987
|
}
|
|
1580
1988
|
// and the keys, whether or not the instant has a column of its own:
|
|
1581
1989
|
// a right row whose group no left row names can match nothing, so a
|
|
1582
1990
|
// membership test over the probes' own keys narrows and never drops
|
|
1583
1991
|
if (probes !== null && probes.keys !== null && probes.keys.length > 0) {
|
|
1584
|
-
|
|
1585
|
-
if (byRef.column !== null) {
|
|
1992
|
+
if (byRef !== null && byRef.column !== null) {
|
|
1586
1993
|
plan.filter = conjoin(plan.filter, probes.keys.length === 1
|
|
1587
|
-
?
|
|
1588
|
-
: { p: 'or', items: probes.keys.map((key) =>
|
|
1589
|
-
({ 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)) });
|
|
1590
1996
|
prefilters.push({ construct: name, via: 'columns',
|
|
1591
1997
|
columns: [byRef.column], exact: false });
|
|
1592
1998
|
}
|
|
@@ -1611,6 +2017,7 @@ function planSeriesOperator(root, shape) {
|
|
|
1611
2017
|
index: narrowed && index !== null ? index.name : null,
|
|
1612
2018
|
prefix: narrowed && index !== null ? index.prefix : [],
|
|
1613
2019
|
range,
|
|
2020
|
+
seeks,
|
|
1614
2021
|
refinement: { $resample: 'resampleSeries', $rolling: 'rollingSeries',
|
|
1615
2022
|
$asof: 'asOfJoin' }[name],
|
|
1616
2023
|
reasons,
|
|
@@ -1618,6 +2025,206 @@ function planSeriesOperator(root, shape) {
|
|
|
1618
2025
|
};
|
|
1619
2026
|
}
|
|
1620
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
|
+
|
|
1621
2228
|
/**
|
|
1622
2229
|
* Plan a FLWOR node into a select plan, recording refusals. When a
|
|
1623
2230
|
* conjunct refuses native translation, the injected `udf` hook may
|
|
@@ -1643,21 +2250,18 @@ function planFlwor(node, shape, rawFlwor, udfHook) {
|
|
|
1643
2250
|
const plan = selectPlan(shape.collection);
|
|
1644
2251
|
|
|
1645
2252
|
// the one recognised source shape: a single plain binding over $[*]
|
|
2253
|
+
// (bare or packed — one check, `isCollectionSource`, for every site)
|
|
1646
2254
|
const binding = node.forBindings[0];
|
|
1647
|
-
const source = binding?.expr;
|
|
1648
2255
|
const sourceIsCollection = node.forBindings.length === 1
|
|
1649
|
-
&&
|
|
1650
|
-
&& source.segments.length === 1 && source.segments[0].descendant !== true
|
|
1651
|
-
&& source.segments[0].selectors.length === 1
|
|
1652
|
-
&& source.segments[0].selectors[0].kind === 'wildcard'
|
|
2256
|
+
&& isCollectionSource(binding?.expr)
|
|
1653
2257
|
&& binding.window === null && binding.atSlot === -1
|
|
1654
2258
|
&& binding.allowingEmpty === false;
|
|
1655
2259
|
if (!sourceIsCollection) {
|
|
1656
|
-
reasons.push(refusal('$for',
|
|
1657
|
-
'only a single plain binding over the whole collection is translated'));
|
|
2260
|
+
reasons.push(refusal('$for', FLWOR_REASONS.binding));
|
|
1658
2261
|
return { plan, reasons, whereFullyPushed: false, orderPushed: false,
|
|
1659
|
-
projectionNative: false,
|
|
1660
|
-
|
|
2262
|
+
projectionNative: false, projectedTree: null,
|
|
2263
|
+
itSlot: -1, itName: null, udfs: [], prefilters: [],
|
|
2264
|
+
knn: null, bucket: null, group: null, bucketRefusal: null };
|
|
1661
2265
|
}
|
|
1662
2266
|
const itSlot = binding.slot;
|
|
1663
2267
|
// the document's own name for the collection binding. The residual and
|
|
@@ -1668,20 +2272,26 @@ function planFlwor(node, shape, rawFlwor, udfHook) {
|
|
|
1668
2272
|
|
|
1669
2273
|
if (node.fold !== null) reasons.push(refusal('$fold', KIND_REASONS.let));
|
|
1670
2274
|
if (node.letBindings.length > 0) reasons.push(refusal('$let', KIND_REASONS.let));
|
|
1671
|
-
if (node.asChecks !== null) reasons.push(refusal('$as',
|
|
2275
|
+
if (node.asChecks !== null) reasons.push(refusal('$as', FLWOR_REASONS.as));
|
|
1672
2276
|
// A grouping is an unconditional residual EXCEPT in one closed shape:
|
|
1673
2277
|
// a fixed-width `$time-bucket` key with the exact aggregates, which
|
|
1674
2278
|
// is a `GROUP BY` over integer arithmetic. The bucket then owns the
|
|
1675
2279
|
// ordering and the projection too, so it is decided before either.
|
|
1676
2280
|
let bucket = null;
|
|
2281
|
+
let group = null;
|
|
1677
2282
|
let bucketRefusal = null;
|
|
1678
2283
|
if (node.groupby !== null && node.fold === null && node.letBindings.length === 0
|
|
1679
2284
|
&& node.asChecks === null && node.count === null) {
|
|
1680
2285
|
const grouped = planBucketGrouping(node, itSlot, shape);
|
|
1681
2286
|
if ('bucket' in grouped) bucket = grouped.bucket;
|
|
1682
2287
|
else {
|
|
1683
|
-
|
|
1684
|
-
|
|
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
|
+
}
|
|
1685
2295
|
}
|
|
1686
2296
|
}
|
|
1687
2297
|
else if (node.groupby !== null) reasons.push(refusal('$groupby', KIND_REASONS.let));
|
|
@@ -1712,8 +2322,12 @@ function planFlwor(node, shape, rawFlwor, udfHook) {
|
|
|
1712
2322
|
? udfHook(rawConjuncts[i], itName)
|
|
1713
2323
|
: null;
|
|
1714
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
|
|
1715
2328
|
plan.filter = conjoin(plan.filter,
|
|
1716
|
-
{ p: 'udf', name: promoted.name, key: promoted.key
|
|
2329
|
+
{ p: 'udf', name: promoted.name, key: promoted.key,
|
|
2330
|
+
mount: split ? `/$where/$and/${i}` : '/$where' });
|
|
1717
2331
|
udfs.push(promoted.name);
|
|
1718
2332
|
}
|
|
1719
2333
|
else {
|
|
@@ -1725,7 +2339,24 @@ function planFlwor(node, shape, rawFlwor, udfHook) {
|
|
|
1725
2339
|
plan.filter = conjoin(plan.filter, outcome.pred);
|
|
1726
2340
|
prefilters.push(...outcome.prefilters);
|
|
1727
2341
|
// an IMPLIED conjunct narrows and leaves the original predicate
|
|
1728
|
-
//
|
|
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
|
+
}
|
|
1729
2360
|
for (const refinement of outcome.refinements) {
|
|
1730
2361
|
reasons.push(refinement);
|
|
1731
2362
|
whereFullyPushed = false;
|
|
@@ -1740,9 +2371,12 @@ function planFlwor(node, shape, rawFlwor, udfHook) {
|
|
|
1740
2371
|
// caller's to name once the window is known)
|
|
1741
2372
|
let orderPushed = false;
|
|
1742
2373
|
let knn = null;
|
|
1743
|
-
const
|
|
2374
|
+
const grouped = bucket !== null || group !== null;
|
|
2375
|
+
const ranked = grouped || node.orderby === null ? null
|
|
1744
2376
|
: planKnnOrder(node.orderby, itSlot, shape, whereFullyPushed && structureClean);
|
|
1745
|
-
|
|
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;
|
|
1746
2380
|
else if (ranked !== null) {
|
|
1747
2381
|
if ('rank' in ranked) knn = ranked.rank;
|
|
1748
2382
|
else reasons.push(ranked.refusal);
|
|
@@ -1752,14 +2386,12 @@ function planFlwor(node, shape, rawFlwor, udfHook) {
|
|
|
1752
2386
|
let refused = null;
|
|
1753
2387
|
for (const spec of node.orderby.specs) {
|
|
1754
2388
|
const ref = pathRef(spec.key, itSlot, shape);
|
|
1755
|
-
if (ref === null || ref.
|
|
1756
|
-
refused = refusal('$orderby',
|
|
1757
|
-
'ordering translates only over singular schema-typed paths');
|
|
2389
|
+
if (ref === null || !orderable(ref, shape.schema)) {
|
|
2390
|
+
refused = refusal('$orderby', FLWOR_REASONS.orderPath);
|
|
1758
2391
|
break;
|
|
1759
2392
|
}
|
|
1760
2393
|
if (spec.collation !== null || spec.collationName !== null) {
|
|
1761
|
-
refused = refusal('$collation',
|
|
1762
|
-
'a collation the dialect cannot reproduce is refused, not approximated');
|
|
2394
|
+
refused = refusal('$collation', FLWOR_REASONS.collation);
|
|
1763
2395
|
break;
|
|
1764
2396
|
}
|
|
1765
2397
|
terms.push({ ref, desc: spec.desc === true, emptyGreatest: spec.emptyGreatest === true });
|
|
@@ -1774,14 +2406,38 @@ function planFlwor(node, shape, rawFlwor, udfHook) {
|
|
|
1774
2406
|
orderPushed = true; // nothing to push
|
|
1775
2407
|
}
|
|
1776
2408
|
|
|
1777
|
-
// 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
|
|
1778
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;
|
|
1779
2422
|
assertDecidedKind(node.ret);
|
|
1780
|
-
|
|
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;
|
|
1781
2427
|
else if (isItVar(node.ret, itSlot)) projectionNative = true;
|
|
1782
2428
|
else {
|
|
1783
|
-
|
|
1784
|
-
|
|
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
|
+
}
|
|
1785
2441
|
}
|
|
1786
2442
|
|
|
1787
2443
|
return {
|
|
@@ -1790,12 +2446,15 @@ function planFlwor(node, shape, rawFlwor, udfHook) {
|
|
|
1790
2446
|
whereFullyPushed: whereFullyPushed && structureClean,
|
|
1791
2447
|
orderPushed: orderPushed && structureClean,
|
|
1792
2448
|
projectionNative,
|
|
2449
|
+
projectedPath,
|
|
2450
|
+
projectedTree,
|
|
1793
2451
|
itSlot,
|
|
1794
2452
|
itName,
|
|
1795
2453
|
udfs,
|
|
1796
2454
|
prefilters,
|
|
1797
2455
|
knn,
|
|
1798
2456
|
bucket,
|
|
2457
|
+
group,
|
|
1799
2458
|
bucketRefusal,
|
|
1800
2459
|
};
|
|
1801
2460
|
}
|
|
@@ -1845,7 +2504,17 @@ function composeWindows(windows) {
|
|
|
1845
2504
|
* `series` is the temporal record (`series.js`) when the document
|
|
1846
2505
|
* asked a §8.16 question, and `null` when it did not.
|
|
1847
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
|
+
|
|
1848
2516
|
function planCollectionCore(document, shape, options = undefined) {
|
|
2517
|
+
if (document === '$[*]') document = ROOT_SCAN;
|
|
1849
2518
|
const analysis = analyzeQuery(document, analyzeOptionsFor(shape?.operators));
|
|
1850
2519
|
let root = analysis.root;
|
|
1851
2520
|
assertDecidedKind(root);
|
|
@@ -1855,12 +2524,12 @@ function planCollectionCore(document, shape, options = undefined) {
|
|
|
1855
2524
|
const windows = [];
|
|
1856
2525
|
while (root.kind === 'op' && root.name === '$subsequence') {
|
|
1857
2526
|
const [inner, start, length] = root.args;
|
|
1858
|
-
if (start?.kind !== 'literal' ||
|
|
1859
|
-
|| (length !== undefined && (length.kind !== 'literal' ||
|
|
2527
|
+
if (start?.kind !== 'literal' || !isWindowBound(start.value)
|
|
2528
|
+
|| (length !== undefined && (length.kind !== 'literal' || !isWindowBound(length.value)))) {
|
|
1860
2529
|
// non-literal bounds: the whole document is a set residual
|
|
1861
2530
|
return {
|
|
1862
2531
|
analysis, plan: null, mode: 'set',
|
|
1863
|
-
reasons: [refusal('$subsequence',
|
|
2532
|
+
reasons: [refusal('$subsequence', PLAN_REASONS.windowBounds)],
|
|
1864
2533
|
rowReturn: null, udfs: [], prefilters: [], series: null,
|
|
1865
2534
|
};
|
|
1866
2535
|
}
|
|
@@ -1870,17 +2539,26 @@ function planCollectionCore(document, shape, options = undefined) {
|
|
|
1870
2539
|
assertDecidedKind(root);
|
|
1871
2540
|
}
|
|
1872
2541
|
|
|
1873
|
-
// 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
|
|
1874
2548
|
let aggregate = null;
|
|
1875
|
-
|
|
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)) {
|
|
1876
2552
|
if (windows.length > 0) {
|
|
1877
2553
|
return {
|
|
1878
2554
|
analysis, plan: null, mode: 'set',
|
|
1879
|
-
reasons: [refusal(root.name,
|
|
2555
|
+
reasons: [refusal(root.name, PLAN_REASONS.windowedAggregate)],
|
|
1880
2556
|
rowReturn: null, udfs: [], prefilters: [], series: null,
|
|
1881
2557
|
};
|
|
1882
2558
|
}
|
|
1883
|
-
aggregate =
|
|
2559
|
+
aggregate = registeredAggregate === null
|
|
2560
|
+
? { name: root.name, fn: AGGREGATES.get(root.name) }
|
|
2561
|
+
: { name: root.name, fn: 'registered', sql: registeredAggregate.sql };
|
|
1884
2562
|
root = root.args[0];
|
|
1885
2563
|
rawInner = rawInner?.[aggregate.name] ?? rawInner;
|
|
1886
2564
|
assertDecidedKind(root);
|
|
@@ -1914,8 +2592,7 @@ function planCollectionCore(document, shape, options = undefined) {
|
|
|
1914
2592
|
if (root.kind !== 'flwor') {
|
|
1915
2593
|
return {
|
|
1916
2594
|
analysis, plan: null, mode: 'set',
|
|
1917
|
-
reasons: [refusal(root.kind, KIND_REASONS[root.kind]
|
|
1918
|
-
?? 'only a FLWOR over the collection is translated')],
|
|
2595
|
+
reasons: [refusal(root.kind, KIND_REASONS[root.kind] ?? PLAN_REASONS.notFlwor)],
|
|
1919
2596
|
rowReturn: null, udfs: [], prefilters: [], series: null,
|
|
1920
2597
|
};
|
|
1921
2598
|
}
|
|
@@ -1947,35 +2624,59 @@ function planCollectionCore(document, shape, options = undefined) {
|
|
|
1947
2624
|
return { analysis, plan: null, mode: 'set', reasons: flwor.reasons,
|
|
1948
2625
|
rowReturn: null, udfs: [], prefilters: flwor.prefilters, series: null };
|
|
1949
2626
|
}
|
|
2627
|
+
if (flwor.bucket !== null || flwor.group !== null || flwor.bucketRefusal != null) {
|
|
2628
|
+
// the phrase's items are its GROUPS; a COUNT(*) over the rows
|
|
2629
|
+
// answered the row count for a `$count` of the groups
|
|
2630
|
+
return {
|
|
2631
|
+
analysis, plan: null, mode: 'set',
|
|
2632
|
+
reasons: [refusal(aggregate.name, PLAN_REASONS.groupedAggregate)],
|
|
2633
|
+
rowReturn: null, udfs: [], prefilters: [], series: null,
|
|
2634
|
+
};
|
|
2635
|
+
}
|
|
1950
2636
|
if (aggregate.fn === 'count') {
|
|
1951
2637
|
if (!flwor.projectionNative) {
|
|
1952
2638
|
return {
|
|
1953
2639
|
analysis, plan: null, mode: 'set',
|
|
1954
|
-
reasons: [refusal('$count',
|
|
1955
|
-
'count translates only over the bare binding (a projected return can change the item count)')],
|
|
2640
|
+
reasons: [refusal('$count', PLAN_REASONS.countProjection)],
|
|
1956
2641
|
rowReturn: null, udfs: [], prefilters: [], series: null,
|
|
1957
2642
|
};
|
|
1958
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
|
+
}
|
|
1959
2651
|
plan.aggregate = { fn: 'count', ref: null };
|
|
1960
2652
|
return { analysis, plan, mode: 'native', reasons: [], rowReturn: null,
|
|
1961
2653
|
udfs: flwor.udfs, prefilters: flwor.prefilters,
|
|
1962
2654
|
series: classifySelection(plan, shape, true) };
|
|
1963
2655
|
}
|
|
1964
2656
|
const ref = pathRef(root.ret, flwor.itSlot, shape);
|
|
1965
|
-
|
|
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';
|
|
1966
2664
|
const acceptable = ref !== null
|
|
1967
|
-
&& (numeric ? isNumericType(ref.type) : ref.type !== 'unknown')
|
|
2665
|
+
&& (numeric ? isNumericType(ref.type) : ref.type !== 'unknown')
|
|
2666
|
+
&& ref.type !== 'boolean' && !admitsNull(shape.schema, ref.segments);
|
|
1968
2667
|
if (!acceptable) {
|
|
1969
2668
|
return {
|
|
1970
2669
|
analysis, plan: null, mode: 'set',
|
|
1971
|
-
reasons: [refusal(aggregate.name,
|
|
1972
|
-
'aggregates translate only over a singular schema-typed path (the engine ERRORS on non-conforming operands)')],
|
|
2670
|
+
reasons: [refusal(aggregate.name, PLAN_REASONS.aggregatePath)],
|
|
1973
2671
|
rowReturn: null, udfs: [], prefilters: [], series: null,
|
|
1974
2672
|
};
|
|
1975
2673
|
}
|
|
1976
|
-
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 };
|
|
1977
2677
|
return { analysis, plan, mode: 'native', reasons: [], rowReturn: null,
|
|
1978
|
-
udfs: flwor.udfs,
|
|
2678
|
+
udfs: aggregate.fn === 'registered' ? [...flwor.udfs, aggregate.sql] : flwor.udfs,
|
|
2679
|
+
prefilters: flwor.prefilters,
|
|
1979
2680
|
series: classifySelection(plan, shape, true) };
|
|
1980
2681
|
}
|
|
1981
2682
|
|
|
@@ -1985,6 +2686,12 @@ function planCollectionCore(document, shape, options = undefined) {
|
|
|
1985
2686
|
// the temporal bucket: only over a WHOLE pushed selection, because a
|
|
1986
2687
|
// conjunct the residual would still apply would arrive after the rows
|
|
1987
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
|
+
|
|
1988
2695
|
if (flwor.bucket !== null && fullyPushed && (windows.length === 0 || plan.window !== null)) {
|
|
1989
2696
|
plan.bucket = flwor.bucket;
|
|
1990
2697
|
const facts = filterFacts(plan.filter);
|
|
@@ -2006,14 +2713,25 @@ function planCollectionCore(document, shape, options = undefined) {
|
|
|
2006
2713
|
};
|
|
2007
2714
|
}
|
|
2008
2715
|
|
|
2009
|
-
|
|
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;
|
|
2010
2728
|
return { analysis, plan, mode: 'native', reasons: [], rowReturn: null,
|
|
2011
2729
|
udfs: flwor.udfs, prefilters: flwor.prefilters,
|
|
2012
2730
|
series: classifySelection(plan, shape, flwor.orderPushed) };
|
|
2013
2731
|
}
|
|
2014
2732
|
|
|
2015
2733
|
// the row residual: everything but the projection pushed
|
|
2016
|
-
if (fullyPushed && !flwor.projectionNative
|
|
2734
|
+
if (!groupedResidual && fullyPushed && !flwor.projectionNative
|
|
2017
2735
|
&& (windows.length === 0 || plan.window !== null)) {
|
|
2018
2736
|
const rawFlwor = rawInner;
|
|
2019
2737
|
const name = flwor.itName ?? 'it';
|
|
@@ -2037,9 +2755,12 @@ function planCollectionCore(document, shape, options = undefined) {
|
|
|
2037
2755
|
};
|
|
2038
2756
|
}
|
|
2039
2757
|
|
|
2040
|
-
// 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
|
|
2041
2762
|
const narrowing = flwor.bucketRefusal == null
|
|
2042
|
-
? classifySelection(plan, shape, false)
|
|
2763
|
+
? underSetMode(classifySelection(plan, shape, false))
|
|
2043
2764
|
: refinedGrouping(plan, shape, flwor.bucketRefusal, '$groupby');
|
|
2044
2765
|
plan.order = null;
|
|
2045
2766
|
plan.window = null;
|
|
@@ -2047,6 +2768,17 @@ function planCollectionCore(document, shape, options = undefined) {
|
|
|
2047
2768
|
udfs: flwor.udfs, prefilters: flwor.prefilters, series: narrowing };
|
|
2048
2769
|
}
|
|
2049
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
|
+
|
|
2050
2782
|
/**
|
|
2051
2783
|
* Plan a whole document against one collection. The store's registered
|
|
2052
2784
|
* operators (Ring 2) ride in `shape.operators` — the planner recognises
|
|
@@ -2069,10 +2801,30 @@ function planCollectionCore(document, shape, options = undefined) {
|
|
|
2069
2801
|
* }}
|
|
2070
2802
|
*/
|
|
2071
2803
|
export function planQuery(document, shape, options = undefined) {
|
|
2072
|
-
const
|
|
2804
|
+
const peeled = peelWrappedResult(document);
|
|
2805
|
+
const planned = { ...planCollectionCore(peeled.document, shape, options), wrapped: peeled.wrapped };
|
|
2073
2806
|
return prependRegisteredReason(planned, document, shape?.operators);
|
|
2074
2807
|
}
|
|
2075
2808
|
|
|
2809
|
+
/**
|
|
2810
|
+
* A chain's element terminal wraps its phrase in a one-item array
|
|
2811
|
+
* constructor — `[<phrase>]`, the window that keeps an array-valued item
|
|
2812
|
+
* one item (QUERY-PEN §6) — so the document a store receives from
|
|
2813
|
+
* `toArray()`/`first()` is that constructor around the phrase. Read
|
|
2814
|
+
* through it: the phrase inside plans as it would bare, and the engines
|
|
2815
|
+
* answer its rows as the ONE array the constructor yields (`wrapped`),
|
|
2816
|
+
* which is exactly the engine's own answer for the document. Anything
|
|
2817
|
+
* else inside the brackets plans as itself and falls to the residual,
|
|
2818
|
+
* where the whole document — brackets included — runs in the engine.
|
|
2819
|
+
* @param {any} document
|
|
2820
|
+
* @returns {{ document: any, wrapped: boolean }}
|
|
2821
|
+
*/
|
|
2822
|
+
function peelWrappedResult(document) {
|
|
2823
|
+
return Array.isArray(document) && document.length === 1
|
|
2824
|
+
? { document: document[0], wrapped: true }
|
|
2825
|
+
: { document, wrapped: false };
|
|
2826
|
+
}
|
|
2827
|
+
|
|
2076
2828
|
// ————— The entity document kind (one planner, two document kinds) —————
|
|
2077
2829
|
|
|
2078
2830
|
/**
|
|
@@ -2090,7 +2842,7 @@ export function entityShape(entity, entityMapping) {
|
|
|
2090
2842
|
const flavors = new Map();
|
|
2091
2843
|
for (const column of entityMapping.columns) {
|
|
2092
2844
|
const epoch = column.source === 'epoch(document)';
|
|
2093
|
-
flavors.set(
|
|
2845
|
+
flavors.set(canonicalOf([{ name: column.name }]), {
|
|
2094
2846
|
column: column.name,
|
|
2095
2847
|
flavor: epoch ? 'entity-epoch' : 'entity-column',
|
|
2096
2848
|
storage: column.storage,
|
|
@@ -2098,8 +2850,9 @@ export function entityShape(entity, entityMapping) {
|
|
|
2098
2850
|
});
|
|
2099
2851
|
}
|
|
2100
2852
|
for (const fk of entityMapping.foreignKeys) {
|
|
2101
|
-
|
|
2102
|
-
|
|
2853
|
+
const fkCanonical = canonicalOf([{ name: fk.column }]);
|
|
2854
|
+
if (!flavors.has(fkCanonical))
|
|
2855
|
+
flavors.set(fkCanonical, { column: fk.column, flavor: 'entity-column', storage: 'string' });
|
|
2103
2856
|
}
|
|
2104
2857
|
return {
|
|
2105
2858
|
collection: entity.name,
|
|
@@ -2120,8 +2873,7 @@ export function entityShape(entity, entityMapping) {
|
|
|
2120
2873
|
export function entityPathRef(node, slot, shape) {
|
|
2121
2874
|
const ref = pathRef(node, slot, shape);
|
|
2122
2875
|
if (ref === null) return null;
|
|
2123
|
-
const canonical = ref.segments
|
|
2124
|
-
.map((s) => ('name' in s ? `.${s.name}` : `[${s.index}]`)).join('');
|
|
2876
|
+
const canonical = canonicalOf(ref.segments);
|
|
2125
2877
|
const flavored = shape.entityFlavors.get(canonical);
|
|
2126
2878
|
if (flavored !== undefined) {
|
|
2127
2879
|
return {
|
|
@@ -2137,6 +2889,99 @@ export function entityPathRef(node, slot, shape) {
|
|
|
2137
2889
|
return { ...ref, flavor: 'entity-doc' };
|
|
2138
2890
|
}
|
|
2139
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
|
+
|
|
2140
2985
|
/**
|
|
2141
2986
|
* Plan one predicate over an entity binding: the same operator
|
|
2142
2987
|
* grammar as phase A, with entity-flavored refs. Reuses
|
|
@@ -2157,15 +3002,13 @@ export function planEntityPredicate(node, slot, shape) {
|
|
|
2157
3002
|
return { ...pred, items: pred.items.map(reflavor) };
|
|
2158
3003
|
if (pred.p === 'not') return { ...pred, item: reflavor(pred.item) };
|
|
2159
3004
|
if (!('ref' in pred) || pred.ref === null) return pred;
|
|
2160
|
-
const canonical = pred.ref.segments
|
|
2161
|
-
.map((s) => ('name' in s ? `.${s.name}` : `[${s.index}]`)).join('');
|
|
3005
|
+
const canonical = canonicalOf(pred.ref.segments);
|
|
2162
3006
|
const flavored = shape.entityFlavors.get(canonical);
|
|
2163
3007
|
if (flavored === undefined) {
|
|
2164
3008
|
// externals against DOC paths are not translated here (the
|
|
2165
3009
|
// phase-A external forms assume the collection layout)
|
|
2166
3010
|
if (pred.p === 'cmp' && 'ext' in pred.operand) {
|
|
2167
|
-
blocked =
|
|
2168
|
-
reason: 'externals compare only against entity columns in this version' };
|
|
3011
|
+
blocked = refusal('$eq', ENTITY_REASONS.external);
|
|
2169
3012
|
}
|
|
2170
3013
|
return { ...pred, ref: { ...pred.ref, flavor: 'entity-doc' } };
|
|
2171
3014
|
}
|
|
@@ -2173,8 +3016,7 @@ export function planEntityPredicate(node, slot, shape) {
|
|
|
2173
3016
|
flavor: flavored.flavor, storage: flavored.storage, format: flavored.format };
|
|
2174
3017
|
if (flavored.flavor === 'entity-epoch' && pred.p === 'cmp') {
|
|
2175
3018
|
if ('ext' in pred.operand) {
|
|
2176
|
-
blocked =
|
|
2177
|
-
reason: 'externals compare only against entity columns in this version' };
|
|
3019
|
+
blocked = refusal(pred.op, ENTITY_REASONS.external);
|
|
2178
3020
|
return { ...pred, ref };
|
|
2179
3021
|
}
|
|
2180
3022
|
// the plan-time instant translation: an ordering comparison
|
|
@@ -2228,9 +3070,9 @@ function planEntityQueryCore(document, entities, mapping, operators) {
|
|
|
2228
3070
|
const windows = [];
|
|
2229
3071
|
while (root.kind === 'op' && root.name === '$subsequence') {
|
|
2230
3072
|
const [inner, start, length] = root.args;
|
|
2231
|
-
if (start?.kind !== 'literal' ||
|
|
2232
|
-
|| (length !== undefined && (length.kind !== 'literal' ||
|
|
2233
|
-
return residual('$subsequence',
|
|
3073
|
+
if (start?.kind !== 'literal' || !isWindowBound(start.value)
|
|
3074
|
+
|| (length !== undefined && (length.kind !== 'literal' || !isWindowBound(length.value))))
|
|
3075
|
+
return residual('$subsequence', PLAN_REASONS.windowBounds);
|
|
2234
3076
|
windows.push({ offset: start.value, limit: length === undefined ? null : length.value });
|
|
2235
3077
|
root = inner;
|
|
2236
3078
|
assertDecidedKind(root);
|
|
@@ -2242,27 +3084,18 @@ function planEntityQueryCore(document, entities, mapping, operators) {
|
|
|
2242
3084
|
assertDecidedKind(root);
|
|
2243
3085
|
}
|
|
2244
3086
|
if (root.kind !== 'flwor')
|
|
2245
|
-
return residual(root.kind,
|
|
3087
|
+
return residual(root.kind, ENTITY_REASONS.notFlwor);
|
|
2246
3088
|
if (root.fold !== null || root.letBindings.length > 0 || root.asChecks !== null
|
|
2247
3089
|
|| root.groupby !== null || root.count !== null)
|
|
2248
|
-
return residual('$let',
|
|
3090
|
+
return residual('$let', KIND_REASONS.let);
|
|
2249
3091
|
|
|
2250
3092
|
// bindings must each range over one entity's array
|
|
2251
3093
|
const bindings = [];
|
|
2252
3094
|
for (const binding of root.forBindings) {
|
|
2253
|
-
const
|
|
2254
|
-
|
|
2255
|
-
&& source.external !== true && source.segments.length === 2
|
|
2256
|
-
&& source.segments[0].descendant !== true
|
|
2257
|
-
&& source.segments[0].selectors.length === 1
|
|
2258
|
-
&& source.segments[0].selectors[0].kind === 'name'
|
|
2259
|
-
&& source.segments[1].selectors?.length === 1
|
|
2260
|
-
&& source.segments[1].selectors[0].kind === 'wildcard'
|
|
2261
|
-
? source.segments[0].selectors[0].name
|
|
2262
|
-
: null;
|
|
2263
|
-
if (sourceEntity === null || !entities.has(sourceEntity)
|
|
3095
|
+
const sourceEntity = bindingEntity(binding, entities);
|
|
3096
|
+
if (sourceEntity === null
|
|
2264
3097
|
|| binding.window !== null || binding.atSlot !== -1 || binding.allowingEmpty !== false)
|
|
2265
|
-
return residual('$for',
|
|
3098
|
+
return residual('$for', ENTITY_REASONS.bindingRoot);
|
|
2266
3099
|
bindings.push({
|
|
2267
3100
|
name: binding.name,
|
|
2268
3101
|
slot: binding.slot,
|
|
@@ -2270,9 +3103,6 @@ function planEntityQueryCore(document, entities, mapping, operators) {
|
|
|
2270
3103
|
shape: entityShape(entities.get(sourceEntity), mapping.entities[sourceEntity]),
|
|
2271
3104
|
});
|
|
2272
3105
|
}
|
|
2273
|
-
if (bindings.length > 2)
|
|
2274
|
-
return residual('$for', 'at most two bindings are translated (one join per statement)');
|
|
2275
|
-
|
|
2276
3106
|
const byName = new Map(bindings.map((binding) => [binding.slot, binding]));
|
|
2277
3107
|
const conjuncts = root.where === null
|
|
2278
3108
|
? []
|
|
@@ -2280,14 +3110,21 @@ function planEntityQueryCore(document, entities, mapping, operators) {
|
|
|
2280
3110
|
? root.where.args
|
|
2281
3111
|
: [root.where];
|
|
2282
3112
|
|
|
2283
|
-
|
|
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 = [];
|
|
2284
3121
|
const filters = new Map(bindings.map((binding) => [binding.slot, null]));
|
|
2285
3122
|
const reasons = [];
|
|
2286
3123
|
let whereFullyPushed = true;
|
|
2287
3124
|
for (const conjunct of conjuncts) {
|
|
2288
|
-
// a
|
|
2289
|
-
|
|
2290
|
-
|
|
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') {
|
|
2291
3128
|
const [left, right] = conjunct.args;
|
|
2292
3129
|
const leftBinding = left.kind === 'path' ? byName.get(left.rootSlot) : undefined;
|
|
2293
3130
|
const rightBinding = right.kind === 'path' ? byName.get(right.rootSlot) : undefined;
|
|
@@ -2296,20 +3133,28 @@ function planEntityQueryCore(document, entities, mapping, operators) {
|
|
|
2296
3133
|
const leftRef = entityPathRef(left, left.rootSlot, leftBinding.shape);
|
|
2297
3134
|
const rightRef = entityPathRef(right, right.rootSlot, rightBinding.shape);
|
|
2298
3135
|
if (leftRef?.flavor === 'entity-column' && rightRef?.flavor === 'entity-column') {
|
|
2299
|
-
|
|
3136
|
+
edges.push({
|
|
2300
3137
|
left: { binding: leftBinding, ref: leftRef },
|
|
2301
3138
|
right: { binding: rightBinding, ref: rightRef },
|
|
2302
|
-
};
|
|
3139
|
+
});
|
|
2303
3140
|
continue;
|
|
2304
3141
|
}
|
|
2305
3142
|
}
|
|
2306
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
|
+
}
|
|
2307
3153
|
// otherwise the conjunct must belong wholly to ONE binding
|
|
2308
3154
|
const slots = new Set();
|
|
2309
3155
|
collectBindingSlots(conjunct, byName, slots);
|
|
2310
3156
|
if (slots.size !== 1) {
|
|
2311
|
-
reasons.push(
|
|
2312
|
-
reason: 'a conjunct must belong to one binding (or be the single join equality)' });
|
|
3157
|
+
reasons.push(refusal('$where', ENTITY_REASONS.conjunctBinding));
|
|
2313
3158
|
whereFullyPushed = false;
|
|
2314
3159
|
continue;
|
|
2315
3160
|
}
|
|
@@ -2323,15 +3168,61 @@ function planEntityQueryCore(document, entities, mapping, operators) {
|
|
|
2323
3168
|
}
|
|
2324
3169
|
filters.set(slot, conjoin(filters.get(slot), outcome.pred));
|
|
2325
3170
|
}
|
|
2326
|
-
|
|
2327
|
-
|
|
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
|
+
}
|
|
2328
3217
|
|
|
2329
|
-
// 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
|
|
2330
3220
|
const retBinding = root.ret.kind === 'var' && root.ret.external !== true
|
|
2331
3221
|
? byName.get(root.ret.slot) : undefined;
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
|
|
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));
|
|
2335
3226
|
}
|
|
2336
3227
|
|
|
2337
3228
|
// ordering over flavored refs of either binding
|
|
@@ -2344,11 +3235,15 @@ function planEntityQueryCore(document, entities, mapping, operators) {
|
|
|
2344
3235
|
const binding = byName.get(slot);
|
|
2345
3236
|
const ref = binding === undefined
|
|
2346
3237
|
? null : entityPathRef(spec.key, slot, binding.shape);
|
|
3238
|
+
// a boolean orders in SQL and is `JQ2005` in the engine on either
|
|
3239
|
+
// flavor; a document path that admits null stores a present null
|
|
3240
|
+
// (a COLUMN stores it absent, §9.3, so a nullable column pushes)
|
|
2347
3241
|
if (ref === null || (ref.flavor === 'entity-doc' && ref.type === 'unknown')
|
|
3242
|
+
|| ref.type === 'boolean'
|
|
3243
|
+
|| (ref.flavor === 'entity-doc' && admitsNull(binding.shape.schema, ref.segments))
|
|
2348
3244
|
|| spec.collation !== null || spec.collationName !== null) {
|
|
2349
3245
|
orderPushed = false;
|
|
2350
|
-
reasons.push(
|
|
2351
|
-
reason: 'ordering translates only over typed entity paths' });
|
|
3246
|
+
reasons.push(refusal('$orderby', ENTITY_REASONS.order));
|
|
2352
3247
|
break;
|
|
2353
3248
|
}
|
|
2354
3249
|
terms.push({ binding, ref, desc: spec.desc === true, emptyGreatest: spec.emptyGreatest === true });
|
|
@@ -2356,8 +3251,8 @@ function planEntityQueryCore(document, entities, mapping, operators) {
|
|
|
2356
3251
|
if (orderPushed) order = terms;
|
|
2357
3252
|
}
|
|
2358
3253
|
|
|
2359
|
-
const fullyPushed = whereFullyPushed && orderPushed
|
|
2360
|
-
&& (
|
|
3254
|
+
const fullyPushed = whereFullyPushed && orderPushed
|
|
3255
|
+
&& (retBinding !== undefined || projection !== null);
|
|
2361
3256
|
if (!fullyPushed) {
|
|
2362
3257
|
return { analysis, mode: 'set', plan: null, referenced, reasons };
|
|
2363
3258
|
}
|
|
@@ -2382,12 +3277,19 @@ function planEntityQueryCore(document, entities, mapping, operators) {
|
|
|
2382
3277
|
reasons: [],
|
|
2383
3278
|
plan: {
|
|
2384
3279
|
planVersion: PLAN_VERSION,
|
|
2385
|
-
alg: bindings.length
|
|
3280
|
+
alg: bindings.length > 1 ? 'entity-join' : 'entity-select',
|
|
2386
3281
|
bindings: bindings.map((binding) => ({ name: binding.name, entity: binding.entity })),
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
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
|
+
})),
|
|
2391
3293
|
filters: bindings.map((binding) => ({
|
|
2392
3294
|
binding: binding.name,
|
|
2393
3295
|
filter: filters.get(binding.slot),
|
|
@@ -2398,7 +3300,13 @@ function planEntityQueryCore(document, entities, mapping, operators) {
|
|
|
2398
3300
|
})),
|
|
2399
3301
|
window,
|
|
2400
3302
|
aggregate,
|
|
2401
|
-
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
|
+
},
|
|
2402
3310
|
},
|
|
2403
3311
|
};
|
|
2404
3312
|
}
|
|
@@ -2414,10 +3322,60 @@ function planEntityQueryCore(document, entities, mapping, operators) {
|
|
|
2414
3322
|
* @returns {any}
|
|
2415
3323
|
*/
|
|
2416
3324
|
export function planEntityQuery(document, entities, mapping, operators = null) {
|
|
2417
|
-
const
|
|
3325
|
+
const peeled = peelWrappedResult(document);
|
|
3326
|
+
const core = planEntityQueryCore(peeled.document, entities, mapping, operators);
|
|
3327
|
+
const planned = {
|
|
3328
|
+
...core,
|
|
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),
|
|
3334
|
+
};
|
|
2418
3335
|
return prependRegisteredReason(planned, document, operators);
|
|
2419
3336
|
}
|
|
2420
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
|
+
|
|
2421
3379
|
/** Which binding slots a subtree references (via path roots). */
|
|
2422
3380
|
function collectBindingSlots(node, byName, slots) {
|
|
2423
3381
|
if (node === null || typeof node !== 'object') return;
|
|
@@ -2432,13 +3390,133 @@ function collectBindingSlots(node, byName, slots) {
|
|
|
2432
3390
|
}
|
|
2433
3391
|
}
|
|
2434
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
|
+
|
|
3496
|
+
/**
|
|
3497
|
+
* The root expression an entity's rows are bound through — the ONE
|
|
3498
|
+
* spelling of `$.<Name>[*]`: what an entity set exposes as its `root`
|
|
3499
|
+
* (the hint a chain reads), and what {@link collectEntityRoots}
|
|
3500
|
+
* recognises in a document. Two spellings would let a handle publish a
|
|
3501
|
+
* root the planner does not read.
|
|
3502
|
+
* @param {string} name - a declared entity name
|
|
3503
|
+
* @returns {string}
|
|
3504
|
+
*/
|
|
3505
|
+
export function entityRoot(name) {
|
|
3506
|
+
return `$.${name}[*]`;
|
|
3507
|
+
}
|
|
3508
|
+
|
|
2435
3509
|
/** The entity names a document's root paths reference (`$.Name[*]`). */
|
|
2436
3510
|
export function collectEntityRoots(document, entities) {
|
|
2437
3511
|
const found = new Set();
|
|
2438
3512
|
const walk = (node) => {
|
|
2439
3513
|
if (typeof node === 'string') {
|
|
2440
|
-
const
|
|
2441
|
-
|
|
3514
|
+
for (const name of entities.keys()) {
|
|
3515
|
+
if (node.startsWith(entityRoot(name))) {
|
|
3516
|
+
found.add(name);
|
|
3517
|
+
break;
|
|
3518
|
+
}
|
|
3519
|
+
}
|
|
2442
3520
|
return;
|
|
2443
3521
|
}
|
|
2444
3522
|
if (Array.isArray(node)) {
|