@jarenjs/db 0.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +397 -0
- package/README.md +218 -0
- package/dist/types/algebra.d.ts +133 -0
- package/dist/types/app.d.ts +49 -0
- package/dist/types/capture.d.ts +85 -0
- package/dist/types/cli.d.ts +2 -0
- package/dist/types/dag-job.d.ts +40 -0
- package/dist/types/ddl.d.ts +170 -0
- package/dist/types/dialect.d.ts +130 -0
- package/dist/types/dialects/sqlite.d.ts +9 -0
- package/dist/types/driver.d.ts +128 -0
- package/dist/types/drivers/bun.d.ts +47 -0
- package/dist/types/drivers/node.d.ts +37 -0
- package/dist/types/drivers/wasm.d.ts +65 -0
- package/dist/types/emit-model.d.ts +44 -0
- package/dist/types/emit.d.ts +72 -0
- package/dist/types/entity.d.ts +23 -0
- package/dist/types/errors.d.ts +165 -0
- package/dist/types/graph.d.ts +28 -0
- package/dist/types/index.d.ts +35 -0
- package/dist/types/jobs.d.ts +134 -0
- package/dist/types/live.d.ts +62 -0
- package/dist/types/migrate.d.ts +163 -0
- package/dist/types/model.d.ts +36 -0
- package/dist/types/patch-sql.d.ts +37 -0
- package/dist/types/plan.d.ts +119 -0
- package/dist/types/profile.d.ts +80 -0
- package/dist/types/query.d.ts +100 -0
- package/dist/types/residual.d.ts +50 -0
- package/dist/types/store.d.ts +53 -0
- package/dist/types/tracker.d.ts +43 -0
- package/dist/types/typed.d.ts +15 -0
- package/dist/types/types.d.ts +26 -0
- package/dist/types/udf.d.ts +70 -0
- package/dist/types/window.d.ts +52 -0
- package/docs/JOBS-FORMAT.md +218 -0
- package/docs/LIVE-FORMAT.md +348 -0
- package/docs/MIGRATION-FORMAT.md +302 -0
- package/docs/MODEL-FORMAT.md +928 -0
- package/package.json +81 -0
- package/schemas/jaren-migration.draft-07.schema.json +144 -0
- package/schemas/jaren-migration.schema.json +144 -0
- package/schemas/jaren-model.draft-07.schema.json +149 -0
- package/schemas/jaren-model.schema.json +149 -0
- package/src/algebra.js +105 -0
- package/src/app.js +108 -0
- package/src/capture.js +584 -0
- package/src/cli.js +264 -0
- package/src/dag-job.js +86 -0
- package/src/ddl.js +588 -0
- package/src/dialect.js +297 -0
- package/src/dialects/sqlite.js +175 -0
- package/src/driver.js +419 -0
- package/src/drivers/bun.js +101 -0
- package/src/drivers/node.js +93 -0
- package/src/drivers/wasm.js +178 -0
- package/src/emit-model.js +208 -0
- package/src/emit.js +393 -0
- package/src/entity.js +367 -0
- package/src/errors.js +173 -0
- package/src/graph.js +101 -0
- package/src/index.js +64 -0
- package/src/jobs.js +507 -0
- package/src/live.js +899 -0
- package/src/migrate.js +1411 -0
- package/src/model.js +476 -0
- package/src/patch-sql.js +150 -0
- package/src/plan.js +1038 -0
- package/src/profile.js +131 -0
- package/src/query.js +1010 -0
- package/src/residual.js +91 -0
- package/src/store.js +1422 -0
- package/src/tracker.js +776 -0
- package/src/typed.js +19 -0
- package/src/types.js +36 -0
- package/src/udf.js +132 -0
- package/src/window.js +125 -0
- package/types/app.d.ts +36 -0
- package/types/bun.d.ts +9 -0
- package/types/index.d.ts +592 -0
- package/types/node.d.ts +15 -0
- package/types/typed.d.ts +108 -0
- package/types/wasm.d.ts +5 -0
package/src/plan.js
ADDED
|
@@ -0,0 +1,1038 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file AST → Plan. The planner walks the engine's PUBLISHED normalized
|
|
4
|
+
* AST (never the raw document), dispatches EXHAUSTIVELY on node kind —
|
|
5
|
+
* an unrecognised kind is an internal error naming the kind and the
|
|
6
|
+
* `AST_VERSION`, never a silent residual — and promotes constructs to
|
|
7
|
+
* native form strictly residual-by-default: everything starts as a
|
|
8
|
+
* residual and earns native status only where the equivalence proof
|
|
9
|
+
* exists (the truth table in ARCHITECTURE.md, pinned by the
|
|
10
|
+
* differential tests).
|
|
11
|
+
*
|
|
12
|
+
* The outcome of planning one document:
|
|
13
|
+
*
|
|
14
|
+
* { plan, mode: 'native' | 'row' | 'set', reasons, rowReturn }
|
|
15
|
+
*
|
|
16
|
+
* - `native` — everything translated; the plan alone answers.
|
|
17
|
+
* - `row` — predicates, ordering and window pushed; only the
|
|
18
|
+
* projection runs in the engine, per fetched row (streams).
|
|
19
|
+
* - `set` — the pushed conjuncts narrow candidates; the WHOLE
|
|
20
|
+
* compiled document runs over the materialized candidates.
|
|
21
|
+
*
|
|
22
|
+
* `reasons` names every construct that forced work off the database,
|
|
23
|
+
* with reason text drawn from the deliberate-residual table.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { analyzeQuery, AST_VERSION, NODE_KINDS } from '@jarenjs/json/query';
|
|
27
|
+
|
|
28
|
+
import {
|
|
29
|
+
getEpochOfDateTimeRFC3339, getEpochOfDateOnlyRFC3339,
|
|
30
|
+
} from '@jarenjs/core/dates/rfc3339';
|
|
31
|
+
|
|
32
|
+
import { selectPlan, conjoin, PLAN_VERSION } from './algebra.js';
|
|
33
|
+
import { typeOfPath, isNumericType } from './types.js';
|
|
34
|
+
|
|
35
|
+
/** Comparison operator names → plan ops. */
|
|
36
|
+
const COMPARISONS = new Map([
|
|
37
|
+
['$eq', 'eq'], ['$ne', 'ne'],
|
|
38
|
+
['$lt', 'lt'], ['$le', 'le'], ['$gt', 'gt'], ['$ge', 'ge'],
|
|
39
|
+
]);
|
|
40
|
+
const ORDERING_OPS = new Set(['lt', 'le', 'gt', 'ge']);
|
|
41
|
+
const STRING_OPS = new Map([
|
|
42
|
+
['$starts-with', 'starts'], ['$ends-with', 'ends'], ['$contains', 'contains'],
|
|
43
|
+
]);
|
|
44
|
+
const AGGREGATES = new Map([
|
|
45
|
+
['$count', 'count'], ['$sum', 'sum'], ['$avg', 'avg'],
|
|
46
|
+
['$min', 'min'], ['$max', 'max'],
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The exhaustiveness backstop: every kind the AST can produce must be
|
|
51
|
+
* DECIDED here — handled by the planner or listed as a deliberate
|
|
52
|
+
* residual. A kind outside this union is a language change this
|
|
53
|
+
* planner has not seen, and it throws rather than degrades.
|
|
54
|
+
*/
|
|
55
|
+
const DECIDED_KINDS = new Set([
|
|
56
|
+
'flwor', 'op', 'path', 'var', 'literal',
|
|
57
|
+
// deliberate residuals, one reason each
|
|
58
|
+
'object', 'map', 'array', 'raw', 'call', 'let', 'quant',
|
|
59
|
+
]);
|
|
60
|
+
|
|
61
|
+
const KIND_REASONS = {
|
|
62
|
+
object: 'an object constructor runs in the engine (projection territory)',
|
|
63
|
+
map: 'a computed-member constructor runs in the engine',
|
|
64
|
+
array: 'an array constructor runs in the engine (projection territory)',
|
|
65
|
+
raw: 'a raw value passes through the engine untouched',
|
|
66
|
+
call: 'a host function call cannot run in the database',
|
|
67
|
+
let: 'no equivalence proof exists yet; residual by default',
|
|
68
|
+
quant: 'a quantifier over a nested sequence runs in the engine',
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Assert a node kind is one this planner has decided. Called on every
|
|
73
|
+
* dispatch; the throw names the kind and the AST version so a language
|
|
74
|
+
* change breaks the build instead of becoming an accidental residual.
|
|
75
|
+
* Exported so the throw itself is pinned by a test.
|
|
76
|
+
* @param {any} node
|
|
77
|
+
*/
|
|
78
|
+
export function assertDecidedKind(node) {
|
|
79
|
+
const kind = node?.kind;
|
|
80
|
+
if (!DECIDED_KINDS.has(kind)) {
|
|
81
|
+
throw new Error(
|
|
82
|
+
`pushdown planner: unrecognised AST node kind '${String(kind)}' `
|
|
83
|
+
+ `(AST_VERSION ${AST_VERSION}) — the planner must be taught this construct`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// the exhaustiveness pact: if the engine adds a kind, this module
|
|
88
|
+
// fails to load until the planner decides it
|
|
89
|
+
for (const kind of NODE_KINDS) {
|
|
90
|
+
if (!DECIDED_KINDS.has(kind)) {
|
|
91
|
+
throw new Error(
|
|
92
|
+
`pushdown planner: NODE_KINDS declares '${kind}' (AST_VERSION ${AST_VERSION}) `
|
|
93
|
+
+ 'but the planner has not decided it');
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* One named refusal.
|
|
99
|
+
* @param {string} construct
|
|
100
|
+
* @param {string} reason
|
|
101
|
+
* @returns {{ construct: string, reason: string }}
|
|
102
|
+
*/
|
|
103
|
+
function refusal(construct, reason) {
|
|
104
|
+
return { construct, reason };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ————— Registered operators (Ring 2) —————
|
|
108
|
+
//
|
|
109
|
+
// A store may open with a registry (createJsltRegistry()) whose
|
|
110
|
+
// operators become engine vocabulary. Ring 2 treats every registered
|
|
111
|
+
// operator as CORRECT but UN-pushable: the planner must KNOW its name
|
|
112
|
+
// (so the AST analysis does not fail `JQ0002`) yet still route it to the
|
|
113
|
+
// residual, where the compilation carries the same `{ functions,
|
|
114
|
+
// extensions }`. Ring 3 will promote the pushable subset to SQL; here
|
|
115
|
+
// everything registered runs in JavaScript over the fetched rows.
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The analyze options carrying the store's registered operators. A
|
|
119
|
+
* registered operator is engine vocabulary, so the AST analysis must be
|
|
120
|
+
* told its `{ functions, extensions }` or it rejects the document as an
|
|
121
|
+
* unknown operator. `null`/absent operators → `undefined`, so a store
|
|
122
|
+
* without a registry analyses byte-identically to before.
|
|
123
|
+
* @param {{ functions?: any, extensions?: any } | null | undefined} operators
|
|
124
|
+
* @returns {any}
|
|
125
|
+
*/
|
|
126
|
+
function analyzeOptionsFor(operators) {
|
|
127
|
+
if (operators == null) return undefined;
|
|
128
|
+
/** @type {any} */
|
|
129
|
+
const options = {};
|
|
130
|
+
if (operators.functions !== undefined) options.functions = operators.functions;
|
|
131
|
+
if (operators.extensions !== undefined) options.extensions = operators.extensions;
|
|
132
|
+
return options;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The set of registered first-class operator names (the `op`/`agg`
|
|
137
|
+
* entries that appear as document keys), or `null` when none.
|
|
138
|
+
* @param {{ extensions?: any } | null | undefined} operators
|
|
139
|
+
* @returns {Set<string> | null}
|
|
140
|
+
*/
|
|
141
|
+
function registeredNamesOf(operators) {
|
|
142
|
+
if (operators == null) return null;
|
|
143
|
+
const names = new Set(Object.keys(operators.extensions ?? {}));
|
|
144
|
+
return names.size === 0 ? null : names;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Which registered operator names a raw document mentions (a `$`-key is
|
|
149
|
+
* an operator call). Robust across where/return/root placement, since it
|
|
150
|
+
* walks the document rather than the AST.
|
|
151
|
+
* @param {any} document
|
|
152
|
+
* @param {Set<string>} registered
|
|
153
|
+
* @returns {string[]}
|
|
154
|
+
*/
|
|
155
|
+
function registeredOpsUsed(document, registered) {
|
|
156
|
+
const found = new Set();
|
|
157
|
+
const walk = (node) => {
|
|
158
|
+
if (Array.isArray(node)) { for (const item of node) walk(item); return; }
|
|
159
|
+
if (node !== null && typeof node === 'object') {
|
|
160
|
+
for (const key of Object.keys(node)) {
|
|
161
|
+
if (registered.has(key)) found.add(key);
|
|
162
|
+
walk(node[key]);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
walk(document);
|
|
167
|
+
return [...found];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Prepend an honest, named residual reason when a non-native plan used a
|
|
172
|
+
* registered operator: `explain()` then says plainly that the operator
|
|
173
|
+
* forced the residual, never silently. Native plans and no-registry
|
|
174
|
+
* stores pass through untouched.
|
|
175
|
+
* @param {any} planned - a planner result carrying `mode` and `reasons`
|
|
176
|
+
* @param {any} document
|
|
177
|
+
* @param {{ extensions?: any } | null | undefined} operators
|
|
178
|
+
* @returns {any}
|
|
179
|
+
*/
|
|
180
|
+
function prependRegisteredReason(planned, document, operators) {
|
|
181
|
+
if (planned.mode === 'native') return planned;
|
|
182
|
+
const registered = registeredNamesOf(operators);
|
|
183
|
+
if (registered === null) return planned;
|
|
184
|
+
// only the part that actually runs in the residual can name an operator
|
|
185
|
+
// as "residual": in `row` mode the where/order are pushed (a scalar
|
|
186
|
+
// operator there may even be a Ring 3 UDF) and just the projection runs
|
|
187
|
+
// per row; in `set` mode the whole document re-runs over the candidates
|
|
188
|
+
const residualPart = planned.mode === 'row' ? planned.rowReturn : document;
|
|
189
|
+
const used = registeredOpsUsed(residualPart, registered);
|
|
190
|
+
if (used.length === 0) return planned;
|
|
191
|
+
const many = used.length > 1;
|
|
192
|
+
return {
|
|
193
|
+
...planned,
|
|
194
|
+
reasons: [
|
|
195
|
+
{
|
|
196
|
+
construct: used.join(', '),
|
|
197
|
+
reason: `registered operator${many ? 's' : ''} `
|
|
198
|
+
+ `${used.map((n) => `'${n}'`).join(', ')} run${many ? '' : 's'} in the residual `
|
|
199
|
+
+ '(Ring 2 — correct, not pushed to SQL)',
|
|
200
|
+
},
|
|
201
|
+
...planned.reasons,
|
|
202
|
+
],
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Is this node the bare binding variable (the whole item)?
|
|
208
|
+
* @param {any} node
|
|
209
|
+
* @param {number} itSlot
|
|
210
|
+
*/
|
|
211
|
+
function isItVar(node, itSlot) {
|
|
212
|
+
return node.kind === 'var' && node.external !== true && node.slot === itSlot;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* A singular member path rooted on the binding → a PlanRef, or null.
|
|
217
|
+
* @param {any} node
|
|
218
|
+
* @param {number} itSlot
|
|
219
|
+
* @param {any} shape - { schema, columnByCanonical }
|
|
220
|
+
* @returns {import('./algebra.js').PlanRef | null}
|
|
221
|
+
*/
|
|
222
|
+
function pathRef(node, itSlot, shape) {
|
|
223
|
+
if (node.kind !== 'path' || node.external === true) return null;
|
|
224
|
+
if (node.rootSlot !== itSlot || node.singular !== true) return null;
|
|
225
|
+
/** @type {({ name: string } | { index: number })[]} */
|
|
226
|
+
const segments = [];
|
|
227
|
+
for (const segment of node.segments) {
|
|
228
|
+
if (segment.descendant === true || segment.selectors.length !== 1) return null;
|
|
229
|
+
const selector = segment.selectors[0];
|
|
230
|
+
if (selector.kind === 'name') segments.push({ name: selector.name });
|
|
231
|
+
else if (selector.kind === 'index') segments.push({ index: selector.index });
|
|
232
|
+
else return null;
|
|
233
|
+
}
|
|
234
|
+
if (segments.length === 0) return null;
|
|
235
|
+
const canonical = segments
|
|
236
|
+
.map((s) => ('name' in s ? `.${s.name}` : `[${s.index}]`)).join('');
|
|
237
|
+
return {
|
|
238
|
+
segments,
|
|
239
|
+
type: typeOfPath(shape.schema, segments),
|
|
240
|
+
column: shape.columnByCanonical.get(canonical) ?? null,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* A literal or external operand, or null.
|
|
246
|
+
* @param {any} node
|
|
247
|
+
* @returns {import('./algebra.js').PlanOperand | null}
|
|
248
|
+
*/
|
|
249
|
+
function operandOf(node) {
|
|
250
|
+
if (node.kind === 'literal') return { lit: node.value };
|
|
251
|
+
if (node.kind === 'var' && node.external === true) return { ext: node.name };
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** @param {any} value */
|
|
256
|
+
function isScalarLiteral(value) {
|
|
257
|
+
return value === null || typeof value === 'string'
|
|
258
|
+
|| typeof value === 'number' || typeof value === 'boolean';
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Translate one predicate node, or explain why not.
|
|
263
|
+
* @param {any} node
|
|
264
|
+
* @param {number} itSlot
|
|
265
|
+
* @param {any} shape
|
|
266
|
+
* @returns {{ pred: import('./algebra.js').PlanPredicate } |
|
|
267
|
+
* { refusal: { construct: string, reason: string } }}
|
|
268
|
+
*/
|
|
269
|
+
function planPredicate(node, itSlot, shape) {
|
|
270
|
+
assertDecidedKind(node);
|
|
271
|
+
if (node.kind !== 'op') {
|
|
272
|
+
return { refusal: refusal(node.kind, KIND_REASONS[node.kind]
|
|
273
|
+
?? 'not a predicate the planner translates') };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (node.name === '$and' || node.name === '$or') {
|
|
277
|
+
const items = [];
|
|
278
|
+
for (const arg of node.args) {
|
|
279
|
+
const inner = planPredicate(arg, itSlot, shape);
|
|
280
|
+
if ('refusal' in inner) return inner; // partial $or/$and is not splittable here
|
|
281
|
+
items.push(inner.pred);
|
|
282
|
+
}
|
|
283
|
+
return { pred: { p: node.name === '$and' ? 'and' : 'or', items } };
|
|
284
|
+
}
|
|
285
|
+
if (node.name === '$not') {
|
|
286
|
+
const inner = planPredicate(node.args[0], itSlot, shape);
|
|
287
|
+
if ('refusal' in inner) return inner;
|
|
288
|
+
return { pred: { p: 'not', item: inner.pred } };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (node.name === '$exists' || node.name === '$empty') {
|
|
292
|
+
const ref = pathRef(node.args[0], itSlot, shape);
|
|
293
|
+
if (ref === null) {
|
|
294
|
+
return { refusal: refusal(node.name,
|
|
295
|
+
'existence tests translate only over a singular member path on the binding') };
|
|
296
|
+
}
|
|
297
|
+
return { pred: { p: 'typeIs', ref, types: [], positive: node.name === '$exists' } };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const comparison = COMPARISONS.get(node.name);
|
|
301
|
+
if (comparison !== undefined) {
|
|
302
|
+
let [left, right] = node.args;
|
|
303
|
+
let op = comparison;
|
|
304
|
+
// literal/external on the left: flip the operator around the path
|
|
305
|
+
if (pathRef(left, itSlot, shape) === null && operandOf(left) !== null) {
|
|
306
|
+
[left, right] = [right, left];
|
|
307
|
+
op = /** @type {any} */ ({ lt: 'gt', le: 'ge', gt: 'lt', ge: 'le' })[op] ?? op;
|
|
308
|
+
}
|
|
309
|
+
const ref = pathRef(left, itSlot, shape);
|
|
310
|
+
const operand = operandOf(right);
|
|
311
|
+
if (ref === null || operand === null) {
|
|
312
|
+
if (pathRef(left, itSlot, shape) !== null && pathRef(right, itSlot, shape) !== null)
|
|
313
|
+
return { refusal: refusal(node.name, 'comparisons where both sides are paths are join territory') };
|
|
314
|
+
return { refusal: refusal(node.name,
|
|
315
|
+
'comparisons translate only between a singular member path and a literal or external') };
|
|
316
|
+
}
|
|
317
|
+
if ('lit' in operand) {
|
|
318
|
+
if (!isScalarLiteral(operand.lit)) {
|
|
319
|
+
return { refusal: refusal(node.name,
|
|
320
|
+
'array and object literals have no guarded native comparison form') };
|
|
321
|
+
}
|
|
322
|
+
const lit = operand.lit;
|
|
323
|
+
if (typeof lit === 'boolean' || lit === null) {
|
|
324
|
+
if (ORDERING_OPS.has(op)) return { pred: { p: 'const', value: false } };
|
|
325
|
+
const typeName = lit === null ? 'null' : lit ? 'true' : 'false';
|
|
326
|
+
return { pred: { p: 'typeIs', ref, types: [typeName], positive: op === 'eq' } };
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
return { pred: { p: 'cmp', op: /** @type {any} */ (op), ref, operand } };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const stringOp = STRING_OPS.get(node.name);
|
|
333
|
+
if (stringOp !== undefined) {
|
|
334
|
+
const ref = pathRef(node.args[0], itSlot, shape);
|
|
335
|
+
const operand = operandOf(node.args[1]);
|
|
336
|
+
if (ref === null || ref.type !== 'string') {
|
|
337
|
+
return { refusal: refusal(node.name,
|
|
338
|
+
'string operators translate only over schema-typed string paths (the engine ERRORS on non-string subjects)') };
|
|
339
|
+
}
|
|
340
|
+
if (operand === null || !('lit' in operand) || typeof operand.lit !== 'string') {
|
|
341
|
+
return { refusal: refusal(node.name,
|
|
342
|
+
"string operators translate only with literal string patterns (an external pattern's type is unknowable at plan time)") };
|
|
343
|
+
}
|
|
344
|
+
if (operand.lit === '') {
|
|
345
|
+
return { refusal: refusal(node.name,
|
|
346
|
+
"the empty pattern's vacuous-truth corner (true even on a missing member) is not translated") };
|
|
347
|
+
}
|
|
348
|
+
return { pred: { p: 'strop', kind: /** @type {any} */ (stringOp), ref, operand } };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return { refusal: refusal(node.name,
|
|
352
|
+
'no native spelling of this operator is proven equivalent') };
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Plan a FLWOR node into a select plan, recording refusals. When a
|
|
357
|
+
* conjunct refuses native translation, the injected `udf` hook may
|
|
358
|
+
* promote it to a deterministic-function predicate instead (the D9
|
|
359
|
+
* hatch — the hook is supplied by the query layer, capability-gated,
|
|
360
|
+
* and absent means no hatch).
|
|
361
|
+
* @param {any} node
|
|
362
|
+
* @param {any} shape - { collection, schema, columnByCanonical }
|
|
363
|
+
* @param {any} rawFlwor - The raw FLWOR document (conjunct fragments
|
|
364
|
+
* for the hook — the AST has no unparser)
|
|
365
|
+
* @param {((fragment: any) => { name: string, key: string } | null) | undefined} udfHook
|
|
366
|
+
* @returns {{ plan: import('./algebra.js').Plan,
|
|
367
|
+
* reasons: { construct: string, reason: string }[],
|
|
368
|
+
* whereFullyPushed: boolean, orderPushed: boolean,
|
|
369
|
+
* projectionNative: boolean, itSlot: number, udfs: string[] }}
|
|
370
|
+
*/
|
|
371
|
+
function planFlwor(node, shape, rawFlwor, udfHook) {
|
|
372
|
+
const reasons = [];
|
|
373
|
+
const plan = selectPlan(shape.collection);
|
|
374
|
+
|
|
375
|
+
// the one recognised source shape: a single plain binding over $[*]
|
|
376
|
+
const binding = node.forBindings[0];
|
|
377
|
+
const source = binding?.expr;
|
|
378
|
+
const sourceIsCollection = node.forBindings.length === 1
|
|
379
|
+
&& source?.kind === 'path' && source.name === '$' && source.external !== true
|
|
380
|
+
&& source.segments.length === 1 && source.segments[0].descendant !== true
|
|
381
|
+
&& source.segments[0].selectors.length === 1
|
|
382
|
+
&& source.segments[0].selectors[0].kind === 'wildcard'
|
|
383
|
+
&& binding.window === null && binding.atSlot === -1
|
|
384
|
+
&& binding.allowingEmpty === false;
|
|
385
|
+
if (!sourceIsCollection) {
|
|
386
|
+
reasons.push(refusal('$for',
|
|
387
|
+
'only a single plain binding over the whole collection is translated'));
|
|
388
|
+
return { plan, reasons, whereFullyPushed: false, orderPushed: false,
|
|
389
|
+
projectionNative: false, itSlot: -1, udfs: [] };
|
|
390
|
+
}
|
|
391
|
+
const itSlot = binding.slot;
|
|
392
|
+
|
|
393
|
+
if (node.fold !== null) reasons.push(refusal('$fold', KIND_REASONS.let));
|
|
394
|
+
if (node.letBindings.length > 0) reasons.push(refusal('$let', KIND_REASONS.let));
|
|
395
|
+
if (node.asChecks !== null) reasons.push(refusal('$as', 'type assertions run in the engine'));
|
|
396
|
+
if (node.groupby !== null) reasons.push(refusal('$groupby', KIND_REASONS.let));
|
|
397
|
+
if (node.count !== null) reasons.push(refusal('$count clause', KIND_REASONS.let));
|
|
398
|
+
const structureClean = reasons.length === 0;
|
|
399
|
+
// $let and $as run BEFORE $where in clause order: a row our pushed
|
|
400
|
+
// conjunct would exclude could still make the engine throw inside a
|
|
401
|
+
// binding — narrowing is only sound when nothing precedes the where
|
|
402
|
+
const narrowingSound = node.letBindings.length === 0 && node.asChecks === null;
|
|
403
|
+
|
|
404
|
+
// WHERE: a top-level $and splits — translated conjuncts push, the
|
|
405
|
+
// rest stay for the residual (pure narrowing). A refused conjunct
|
|
406
|
+
// may still ride the deterministic-function hatch when the hook
|
|
407
|
+
// accepts its raw fragment.
|
|
408
|
+
let whereFullyPushed = true;
|
|
409
|
+
const udfs = [];
|
|
410
|
+
if (!narrowingSound) whereFullyPushed = false;
|
|
411
|
+
else if (node.where !== null) {
|
|
412
|
+
const split = node.where.kind === 'op' && node.where.name === '$and';
|
|
413
|
+
const conjuncts = split ? node.where.args : [node.where];
|
|
414
|
+
const rawWhere = rawFlwor?.$where;
|
|
415
|
+
const rawConjuncts = split ? rawWhere?.$and ?? [] : [rawWhere];
|
|
416
|
+
for (let i = 0; i < conjuncts.length; i++) {
|
|
417
|
+
const outcome = planPredicate(conjuncts[i], itSlot, shape);
|
|
418
|
+
if ('refusal' in outcome) {
|
|
419
|
+
const promoted = udfHook !== undefined && rawConjuncts[i] !== undefined
|
|
420
|
+
? udfHook(rawConjuncts[i])
|
|
421
|
+
: null;
|
|
422
|
+
if (promoted !== null) {
|
|
423
|
+
plan.filter = conjoin(plan.filter,
|
|
424
|
+
{ p: 'udf', name: promoted.name, key: promoted.key });
|
|
425
|
+
udfs.push(promoted.name);
|
|
426
|
+
}
|
|
427
|
+
else {
|
|
428
|
+
reasons.push(outcome.refusal);
|
|
429
|
+
whereFullyPushed = false;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
else {
|
|
433
|
+
plan.filter = conjoin(plan.filter, outcome.pred);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// ORDER BY: all terms or none — a partially pushed ordering is wrong
|
|
439
|
+
let orderPushed = false;
|
|
440
|
+
if (node.orderby !== null) {
|
|
441
|
+
const terms = [];
|
|
442
|
+
let refused = null;
|
|
443
|
+
for (const spec of node.orderby.specs) {
|
|
444
|
+
const ref = pathRef(spec.key, itSlot, shape);
|
|
445
|
+
if (ref === null || ref.type === 'unknown') {
|
|
446
|
+
refused = refusal('$orderby',
|
|
447
|
+
'ordering translates only over singular schema-typed paths');
|
|
448
|
+
break;
|
|
449
|
+
}
|
|
450
|
+
if (spec.collation !== null || spec.collationName !== null) {
|
|
451
|
+
refused = refusal('$collation',
|
|
452
|
+
'a collation the dialect cannot reproduce is refused, not approximated');
|
|
453
|
+
break;
|
|
454
|
+
}
|
|
455
|
+
terms.push({ ref, desc: spec.desc === true, emptyGreatest: spec.emptyGreatest === true });
|
|
456
|
+
}
|
|
457
|
+
if (refused !== null) reasons.push(refused);
|
|
458
|
+
else if (terms.length > 0) {
|
|
459
|
+
plan.order = terms;
|
|
460
|
+
orderPushed = true;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
else {
|
|
464
|
+
orderPushed = true; // nothing to push
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// RETURN: the bare binding is the native whole-document projection
|
|
468
|
+
let projectionNative = false;
|
|
469
|
+
assertDecidedKind(node.ret);
|
|
470
|
+
if (isItVar(node.ret, itSlot)) projectionNative = true;
|
|
471
|
+
else {
|
|
472
|
+
reasons.push(refusal('$return',
|
|
473
|
+
'projections other than the bare binding run per row (the row residual)'));
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
return {
|
|
477
|
+
plan,
|
|
478
|
+
reasons,
|
|
479
|
+
whereFullyPushed: whereFullyPushed && structureClean,
|
|
480
|
+
orderPushed: orderPushed && structureClean,
|
|
481
|
+
projectionNative,
|
|
482
|
+
itSlot,
|
|
483
|
+
udfs,
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* Plan a whole document against one collection.
|
|
489
|
+
* @param {any} document - The raw query document (kept beside the AST
|
|
490
|
+
* for residual construction — the AST has no unparser)
|
|
491
|
+
* @param {any} shape - { collection, schema, columnByCanonical }
|
|
492
|
+
* @param {{ udf?: (fragment: any) => { name: string, key: string } | null }} [options]
|
|
493
|
+
* @returns {{
|
|
494
|
+
* analysis: any,
|
|
495
|
+
* plan: import('./algebra.js').Plan | null,
|
|
496
|
+
* mode: 'native' | 'row' | 'set',
|
|
497
|
+
* reasons: { construct: string, reason: string }[],
|
|
498
|
+
* rowReturn: any,
|
|
499
|
+
* udfs: string[],
|
|
500
|
+
* }}
|
|
501
|
+
*/
|
|
502
|
+
function planCollectionCore(document, shape, options = undefined) {
|
|
503
|
+
const analysis = analyzeQuery(document, analyzeOptionsFor(shape?.operators));
|
|
504
|
+
let root = analysis.root;
|
|
505
|
+
assertDecidedKind(root);
|
|
506
|
+
|
|
507
|
+
// peel top-level $subsequence windows (0-based start[, length])
|
|
508
|
+
let rawInner = document;
|
|
509
|
+
const windows = [];
|
|
510
|
+
while (root.kind === 'op' && root.name === '$subsequence') {
|
|
511
|
+
const [inner, start, length] = root.args;
|
|
512
|
+
if (start?.kind !== 'literal' || typeof start.value !== 'number'
|
|
513
|
+
|| (length !== undefined && (length.kind !== 'literal' || typeof length.value !== 'number'))) {
|
|
514
|
+
// non-literal bounds: the whole document is a set residual
|
|
515
|
+
return {
|
|
516
|
+
analysis, plan: null, mode: 'set',
|
|
517
|
+
reasons: [refusal('$subsequence', 'window bounds must be literal numbers to push')],
|
|
518
|
+
rowReturn: null, udfs: [],
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
windows.push({ offset: start.value, limit: length === undefined ? null : length.value });
|
|
522
|
+
root = inner;
|
|
523
|
+
rawInner = Array.isArray(rawInner?.$subsequence) ? rawInner.$subsequence[0] : rawInner;
|
|
524
|
+
assertDecidedKind(root);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// a top-level aggregate over a FLWOR
|
|
528
|
+
let aggregate = null;
|
|
529
|
+
if (root.kind === 'op' && AGGREGATES.has(root.name)) {
|
|
530
|
+
if (windows.length > 0) {
|
|
531
|
+
return {
|
|
532
|
+
analysis, plan: null, mode: 'set',
|
|
533
|
+
reasons: [refusal(root.name, 'a windowed aggregate is not translated')],
|
|
534
|
+
rowReturn: null, udfs: [],
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
aggregate = { name: root.name, fn: AGGREGATES.get(root.name) };
|
|
538
|
+
root = root.args[0];
|
|
539
|
+
rawInner = rawInner?.[aggregate.name] ?? rawInner;
|
|
540
|
+
assertDecidedKind(root);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
if (root.kind !== 'flwor') {
|
|
544
|
+
return {
|
|
545
|
+
analysis, plan: null, mode: 'set',
|
|
546
|
+
reasons: [refusal(root.kind, KIND_REASONS[root.kind]
|
|
547
|
+
?? 'only a FLWOR over the collection is translated')],
|
|
548
|
+
rowReturn: null, udfs: [],
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
const flwor = planFlwor(root, shape, rawInner, options?.udf);
|
|
553
|
+
const { plan } = flwor;
|
|
554
|
+
const fullyPushed = flwor.whereFullyPushed && flwor.orderPushed;
|
|
555
|
+
|
|
556
|
+
if (aggregate !== null) {
|
|
557
|
+
// aggregates need the WHOLE selection native (their input is the
|
|
558
|
+
// full sequence, not a narrowed candidate set)
|
|
559
|
+
if (!fullyPushed) {
|
|
560
|
+
return { analysis, plan: null, mode: 'set', reasons: flwor.reasons,
|
|
561
|
+
rowReturn: null, udfs: [] };
|
|
562
|
+
}
|
|
563
|
+
if (aggregate.fn === 'count') {
|
|
564
|
+
if (!flwor.projectionNative) {
|
|
565
|
+
return {
|
|
566
|
+
analysis, plan: null, mode: 'set',
|
|
567
|
+
reasons: [refusal('$count',
|
|
568
|
+
'count translates only over the bare binding (a projected return can change the item count)')],
|
|
569
|
+
rowReturn: null, udfs: [],
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
plan.aggregate = { fn: 'count', ref: null };
|
|
573
|
+
return { analysis, plan, mode: 'native', reasons: [], rowReturn: null,
|
|
574
|
+
udfs: flwor.udfs };
|
|
575
|
+
}
|
|
576
|
+
const ref = pathRef(root.ret, flwor.itSlot, shape);
|
|
577
|
+
const numeric = aggregate.fn === 'sum' || aggregate.fn === 'avg';
|
|
578
|
+
const acceptable = ref !== null
|
|
579
|
+
&& (numeric ? isNumericType(ref.type) : ref.type !== 'unknown');
|
|
580
|
+
if (!acceptable) {
|
|
581
|
+
return {
|
|
582
|
+
analysis, plan: null, mode: 'set',
|
|
583
|
+
reasons: [refusal(aggregate.name,
|
|
584
|
+
'aggregates translate only over a singular schema-typed path (the engine ERRORS on non-conforming operands)')],
|
|
585
|
+
rowReturn: null, udfs: [],
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
plan.aggregate = { fn: /** @type {any} */ (aggregate.fn), ref };
|
|
589
|
+
return { analysis, plan, mode: 'native', reasons: [], rowReturn: null,
|
|
590
|
+
udfs: flwor.udfs };
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// windows push only onto a fully pushed selection
|
|
594
|
+
if (windows.length > 0 && fullyPushed) {
|
|
595
|
+
// innermost window applies first; compose offsets/limits
|
|
596
|
+
let offset = 0;
|
|
597
|
+
let limit = null;
|
|
598
|
+
for (let i = windows.length - 1; i >= 0; i--) {
|
|
599
|
+
const w = windows[i];
|
|
600
|
+
offset += w.offset;
|
|
601
|
+
if (w.limit !== null) {
|
|
602
|
+
limit = limit === null ? w.limit : Math.min(Math.max(limit - w.offset, 0), w.limit);
|
|
603
|
+
}
|
|
604
|
+
else if (limit !== null) {
|
|
605
|
+
limit = Math.max(limit - w.offset, 0);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
plan.window = { offset, limit };
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
if (fullyPushed && flwor.projectionNative && (windows.length === 0 || plan.window !== null)) {
|
|
612
|
+
return { analysis, plan, mode: 'native', reasons: [], rowReturn: null,
|
|
613
|
+
udfs: flwor.udfs };
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// the row residual: everything but the projection pushed
|
|
617
|
+
if (fullyPushed && !flwor.projectionNative
|
|
618
|
+
&& (windows.length === 0 || plan.window !== null)) {
|
|
619
|
+
const rawFlwor = rawInner;
|
|
620
|
+
return {
|
|
621
|
+
analysis,
|
|
622
|
+
plan,
|
|
623
|
+
mode: 'row',
|
|
624
|
+
reasons: flwor.reasons,
|
|
625
|
+
rowReturn: rawFlwor?.$return ?? '$it',
|
|
626
|
+
udfs: flwor.udfs,
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// the set residual: pushed conjuncts narrow, the engine answers
|
|
631
|
+
plan.order = null;
|
|
632
|
+
plan.window = null;
|
|
633
|
+
return { analysis, plan, mode: 'set', reasons: flwor.reasons, rowReturn: null,
|
|
634
|
+
udfs: flwor.udfs };
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* Plan a whole document against one collection. The store's registered
|
|
639
|
+
* operators (Ring 2) ride in `shape.operators` — the planner recognises
|
|
640
|
+
* them as vocabulary but keeps them in the residual, and names them in
|
|
641
|
+
* the reasons when it does.
|
|
642
|
+
* @param {any} document - The raw query document (kept beside the AST
|
|
643
|
+
* for residual construction — the AST has no unparser)
|
|
644
|
+
* @param {any} shape - { collection, schema, columnByCanonical, operators? }
|
|
645
|
+
* @param {{ udf?: (fragment: any) => { name: string, key: string } | null }} [options]
|
|
646
|
+
* @returns {{
|
|
647
|
+
* analysis: any,
|
|
648
|
+
* plan: import('./algebra.js').Plan | null,
|
|
649
|
+
* mode: 'native' | 'row' | 'set',
|
|
650
|
+
* reasons: { construct: string, reason: string }[],
|
|
651
|
+
* rowReturn: any,
|
|
652
|
+
* udfs: string[],
|
|
653
|
+
* }}
|
|
654
|
+
*/
|
|
655
|
+
export function planQuery(document, shape, options = undefined) {
|
|
656
|
+
const planned = planCollectionCore(document, shape, options);
|
|
657
|
+
return prependRegisteredReason(planned, document, shape?.operators);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// ————— The entity document kind (one planner, two document kinds) —————
|
|
661
|
+
|
|
662
|
+
/**
|
|
663
|
+
* Build the planner shape for one entity: canonical top-level paths
|
|
664
|
+
* map to REAL columns (flavor `entity-column`), epoch date columns to
|
|
665
|
+
* their derived integer twins (flavor `entity-epoch`), and everything
|
|
666
|
+
* else stays a document path over the entity's JSONB column (the
|
|
667
|
+
* phase-A guarded forms).
|
|
668
|
+
* @param {any} entity - normalized entity (model.js)
|
|
669
|
+
* @param {any} entityMapping - explainMapping(...).entities[name]
|
|
670
|
+
* @returns {any}
|
|
671
|
+
*/
|
|
672
|
+
export function entityShape(entity, entityMapping) {
|
|
673
|
+
/** @type {Map<string, any>} */
|
|
674
|
+
const flavors = new Map();
|
|
675
|
+
for (const column of entityMapping.columns) {
|
|
676
|
+
const epoch = column.source === 'epoch(document)';
|
|
677
|
+
flavors.set(`.${column.name}`, {
|
|
678
|
+
column: column.name,
|
|
679
|
+
flavor: epoch ? 'entity-epoch' : 'entity-column',
|
|
680
|
+
storage: column.storage,
|
|
681
|
+
format: epoch ? entity.properties.get(column.name)?.format : undefined,
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
for (const fk of entityMapping.foreignKeys) {
|
|
685
|
+
if (!flavors.has(`.${fk.column}`))
|
|
686
|
+
flavors.set(`.${fk.column}`, { column: fk.column, flavor: 'entity-column', storage: 'string' });
|
|
687
|
+
}
|
|
688
|
+
return {
|
|
689
|
+
collection: entity.name,
|
|
690
|
+
schema: entity.schema,
|
|
691
|
+
columnByCanonical: new Map(),
|
|
692
|
+
entityFlavors: flavors,
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* Resolve a singular member path on an entity binding to a flavored
|
|
698
|
+
* PlanRef.
|
|
699
|
+
* @param {any} node - a path AST node
|
|
700
|
+
* @param {number} slot
|
|
701
|
+
* @param {any} shape - from {@link entityShape}
|
|
702
|
+
* @returns {any | null}
|
|
703
|
+
*/
|
|
704
|
+
export function entityPathRef(node, slot, shape) {
|
|
705
|
+
const ref = pathRef(node, slot, shape);
|
|
706
|
+
if (ref === null) return null;
|
|
707
|
+
const canonical = ref.segments
|
|
708
|
+
.map((s) => ('name' in s ? `.${s.name}` : `[${s.index}]`)).join('');
|
|
709
|
+
const flavored = shape.entityFlavors.get(canonical);
|
|
710
|
+
if (flavored !== undefined) {
|
|
711
|
+
return {
|
|
712
|
+
...ref,
|
|
713
|
+
column: flavored.column,
|
|
714
|
+
flavor: flavored.flavor,
|
|
715
|
+
storage: flavored.storage,
|
|
716
|
+
format: flavored.format,
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
// a nested path rides the JSONB document with the phase-A guards;
|
|
720
|
+
// strip nothing — jsonb_extract addresses the doc column directly
|
|
721
|
+
return { ...ref, flavor: 'entity-doc' };
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/**
|
|
725
|
+
* Plan one predicate over an entity binding: the same operator
|
|
726
|
+
* grammar as phase A, with entity-flavored refs. Reuses
|
|
727
|
+
* {@link planPredicate} for the recognition, then re-resolves refs
|
|
728
|
+
* through the flavor table.
|
|
729
|
+
* @param {any} node
|
|
730
|
+
* @param {number} slot
|
|
731
|
+
* @param {any} shape
|
|
732
|
+
* @returns {{ pred: any } | { refusal: { construct: string, reason: string } }}
|
|
733
|
+
*/
|
|
734
|
+
export function planEntityPredicate(node, slot, shape) {
|
|
735
|
+
const outcome = planPredicate(node, slot, shape);
|
|
736
|
+
if ('refusal' in outcome) return outcome;
|
|
737
|
+
/** @type {{ construct: string, reason: string } | null} */
|
|
738
|
+
let blocked = null;
|
|
739
|
+
const reflavor = (pred) => {
|
|
740
|
+
if (pred.p === 'and' || pred.p === 'or')
|
|
741
|
+
return { ...pred, items: pred.items.map(reflavor) };
|
|
742
|
+
if (pred.p === 'not') return { ...pred, item: reflavor(pred.item) };
|
|
743
|
+
if (!('ref' in pred) || pred.ref === null) return pred;
|
|
744
|
+
const canonical = pred.ref.segments
|
|
745
|
+
.map((s) => ('name' in s ? `.${s.name}` : `[${s.index}]`)).join('');
|
|
746
|
+
const flavored = shape.entityFlavors.get(canonical);
|
|
747
|
+
if (flavored === undefined) {
|
|
748
|
+
// externals against DOC paths are not translated here (the
|
|
749
|
+
// phase-A external forms assume the collection layout)
|
|
750
|
+
if (pred.p === 'cmp' && 'ext' in pred.operand) {
|
|
751
|
+
blocked = { construct: '$eq',
|
|
752
|
+
reason: 'externals compare only against entity columns in this version' };
|
|
753
|
+
}
|
|
754
|
+
return { ...pred, ref: { ...pred.ref, flavor: 'entity-doc' } };
|
|
755
|
+
}
|
|
756
|
+
const ref = { ...pred.ref, column: flavored.column,
|
|
757
|
+
flavor: flavored.flavor, storage: flavored.storage, format: flavored.format };
|
|
758
|
+
if (flavored.flavor === 'entity-epoch' && pred.p === 'cmp') {
|
|
759
|
+
if ('ext' in pred.operand) {
|
|
760
|
+
blocked = { construct: pred.op,
|
|
761
|
+
reason: 'externals compare only against entity columns in this version' };
|
|
762
|
+
return { ...pred, ref };
|
|
763
|
+
}
|
|
764
|
+
// the plan-time instant translation: an ordering comparison
|
|
765
|
+
// against a literal of the column's own family (Z-normalized
|
|
766
|
+
// date-time, or a plain date on a date column) gains the ±1s
|
|
767
|
+
// epoch range the emitter narrows the index with; anything else
|
|
768
|
+
// simply keeps the guarded document forms — sound, unassisted
|
|
769
|
+
if (pred.op !== 'ne' && typeof pred.operand.lit === 'string') {
|
|
770
|
+
const lit = pred.operand.lit;
|
|
771
|
+
const epoch = flavored.format === 'date'
|
|
772
|
+
? (/^\d{4}-\d{2}-\d{2}$/.test(lit) ? getEpochOfDateOnlyRFC3339(lit) : NaN)
|
|
773
|
+
: (lit.includes('T') && lit.endsWith('Z') ? getEpochOfDateTimeRFC3339(lit) : NaN);
|
|
774
|
+
if (typeof epoch === 'number' && Number.isFinite(epoch))
|
|
775
|
+
return { ...pred, ref, epoch };
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
return { ...pred, ref };
|
|
779
|
+
};
|
|
780
|
+
const pred = reflavor(outcome.pred);
|
|
781
|
+
if (blocked !== null) return { refusal: blocked };
|
|
782
|
+
return { pred };
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/**
|
|
786
|
+
* Plan an ENTITY query document: a FLWOR whose bindings range over
|
|
787
|
+
* `$.<Entity>[*]` arrays of the multi-entity root. One binding is a
|
|
788
|
+
* guarded selection; two bindings joined by a key equality become an
|
|
789
|
+
* INNER equijoin (exactly the engine's cross-product-plus-filter
|
|
790
|
+
* semantics, which is what keeps the oracle honest). Everything else
|
|
791
|
+
* is the set residual over the fetched root.
|
|
792
|
+
* @param {any} document
|
|
793
|
+
* @param {Map<string, any>} entities - normalized entities
|
|
794
|
+
* @param {any} mapping - explainMapping result
|
|
795
|
+
* @param {{ functions?: any, extensions?: any } | null} [operators] -
|
|
796
|
+
* the store's registered operators (Ring 2); recognised as vocabulary,
|
|
797
|
+
* kept in the set residual over the fetched root
|
|
798
|
+
* @returns {any}
|
|
799
|
+
*/
|
|
800
|
+
function planEntityQueryCore(document, entities, mapping, operators) {
|
|
801
|
+
const analysis = analyzeQuery(document, analyzeOptionsFor(operators));
|
|
802
|
+
let root = analysis.root;
|
|
803
|
+
assertDecidedKind(root);
|
|
804
|
+
|
|
805
|
+
const referenced = [...collectEntityRoots(document, entities)];
|
|
806
|
+
const residual = (construct, reason) => ({
|
|
807
|
+
analysis, mode: 'set', plan: null, referenced,
|
|
808
|
+
reasons: [{ construct, reason }],
|
|
809
|
+
});
|
|
810
|
+
|
|
811
|
+
// peel literal windows exactly as the collection planner does
|
|
812
|
+
const windows = [];
|
|
813
|
+
while (root.kind === 'op' && root.name === '$subsequence') {
|
|
814
|
+
const [inner, start, length] = root.args;
|
|
815
|
+
if (start?.kind !== 'literal' || typeof start.value !== 'number'
|
|
816
|
+
|| (length !== undefined && (length.kind !== 'literal' || typeof length.value !== 'number')))
|
|
817
|
+
return residual('$subsequence', 'window bounds must be literal numbers to push');
|
|
818
|
+
windows.push({ offset: start.value, limit: length === undefined ? null : length.value });
|
|
819
|
+
root = inner;
|
|
820
|
+
assertDecidedKind(root);
|
|
821
|
+
}
|
|
822
|
+
let aggregate = null;
|
|
823
|
+
if (root.kind === 'op' && root.name === '$count' && windows.length === 0) {
|
|
824
|
+
aggregate = 'count';
|
|
825
|
+
root = root.args[0];
|
|
826
|
+
assertDecidedKind(root);
|
|
827
|
+
}
|
|
828
|
+
if (root.kind !== 'flwor')
|
|
829
|
+
return residual(root.kind, 'only a FLWOR over entity arrays is translated');
|
|
830
|
+
if (root.fold !== null || root.letBindings.length > 0 || root.asChecks !== null
|
|
831
|
+
|| root.groupby !== null || root.count !== null)
|
|
832
|
+
return residual('$let', 'no equivalence proof exists yet; residual by default');
|
|
833
|
+
|
|
834
|
+
// bindings must each range over one entity's array
|
|
835
|
+
const bindings = [];
|
|
836
|
+
for (const binding of root.forBindings) {
|
|
837
|
+
const source = binding.expr;
|
|
838
|
+
const sourceEntity = source?.kind === 'path' && source.name === '$'
|
|
839
|
+
&& source.external !== true && source.segments.length === 2
|
|
840
|
+
&& source.segments[0].descendant !== true
|
|
841
|
+
&& source.segments[0].selectors.length === 1
|
|
842
|
+
&& source.segments[0].selectors[0].kind === 'name'
|
|
843
|
+
&& source.segments[1].selectors?.length === 1
|
|
844
|
+
&& source.segments[1].selectors[0].kind === 'wildcard'
|
|
845
|
+
? source.segments[0].selectors[0].name
|
|
846
|
+
: null;
|
|
847
|
+
if (sourceEntity === null || !entities.has(sourceEntity)
|
|
848
|
+
|| binding.window !== null || binding.atSlot !== -1 || binding.allowingEmpty !== false)
|
|
849
|
+
return residual('$for', 'bindings must each range over one declared entity array ($.Entity[*])');
|
|
850
|
+
bindings.push({
|
|
851
|
+
name: binding.name,
|
|
852
|
+
slot: binding.slot,
|
|
853
|
+
entity: sourceEntity,
|
|
854
|
+
shape: entityShape(entities.get(sourceEntity), mapping.entities[sourceEntity]),
|
|
855
|
+
});
|
|
856
|
+
}
|
|
857
|
+
if (bindings.length > 2)
|
|
858
|
+
return residual('$for', 'at most two bindings are translated (one join per statement)');
|
|
859
|
+
|
|
860
|
+
const byName = new Map(bindings.map((binding) => [binding.slot, binding]));
|
|
861
|
+
const conjuncts = root.where === null
|
|
862
|
+
? []
|
|
863
|
+
: root.where.kind === 'op' && root.where.name === '$and'
|
|
864
|
+
? root.where.args
|
|
865
|
+
: [root.where];
|
|
866
|
+
|
|
867
|
+
let joinOn = null;
|
|
868
|
+
const filters = new Map(bindings.map((binding) => [binding.slot, null]));
|
|
869
|
+
const reasons = [];
|
|
870
|
+
let whereFullyPushed = true;
|
|
871
|
+
for (const conjunct of conjuncts) {
|
|
872
|
+
// a key equality between the two bindings is the join condition
|
|
873
|
+
if (bindings.length === 2 && joinOn === null
|
|
874
|
+
&& conjunct.kind === 'op' && conjunct.name === '$eq') {
|
|
875
|
+
const [left, right] = conjunct.args;
|
|
876
|
+
const leftBinding = left.kind === 'path' ? byName.get(left.rootSlot) : undefined;
|
|
877
|
+
const rightBinding = right.kind === 'path' ? byName.get(right.rootSlot) : undefined;
|
|
878
|
+
if (leftBinding !== undefined && rightBinding !== undefined
|
|
879
|
+
&& leftBinding !== rightBinding) {
|
|
880
|
+
const leftRef = entityPathRef(left, left.rootSlot, leftBinding.shape);
|
|
881
|
+
const rightRef = entityPathRef(right, right.rootSlot, rightBinding.shape);
|
|
882
|
+
if (leftRef?.flavor === 'entity-column' && rightRef?.flavor === 'entity-column') {
|
|
883
|
+
joinOn = {
|
|
884
|
+
left: { binding: leftBinding, ref: leftRef },
|
|
885
|
+
right: { binding: rightBinding, ref: rightRef },
|
|
886
|
+
};
|
|
887
|
+
continue;
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
// otherwise the conjunct must belong wholly to ONE binding
|
|
892
|
+
const slots = new Set();
|
|
893
|
+
collectBindingSlots(conjunct, byName, slots);
|
|
894
|
+
if (slots.size !== 1) {
|
|
895
|
+
reasons.push({ construct: '$where',
|
|
896
|
+
reason: 'a conjunct must belong to one binding (or be the single join equality)' });
|
|
897
|
+
whereFullyPushed = false;
|
|
898
|
+
continue;
|
|
899
|
+
}
|
|
900
|
+
const slot = [...slots][0];
|
|
901
|
+
const binding = byName.get(slot);
|
|
902
|
+
const outcome = planEntityPredicate(conjunct, slot, binding.shape);
|
|
903
|
+
if ('refusal' in outcome) {
|
|
904
|
+
reasons.push(outcome.refusal);
|
|
905
|
+
whereFullyPushed = false;
|
|
906
|
+
continue;
|
|
907
|
+
}
|
|
908
|
+
filters.set(slot, conjoin(filters.get(slot), outcome.pred));
|
|
909
|
+
}
|
|
910
|
+
if (bindings.length === 2 && joinOn === null)
|
|
911
|
+
return residual('$for', 'two bindings need a key equality between them (the join condition)');
|
|
912
|
+
|
|
913
|
+
// the return must be one bare binding
|
|
914
|
+
const retBinding = root.ret.kind === 'var' && root.ret.external !== true
|
|
915
|
+
? byName.get(root.ret.slot) : undefined;
|
|
916
|
+
if (retBinding === undefined) {
|
|
917
|
+
reasons.push({ construct: '$return',
|
|
918
|
+
reason: 'entity queries return one bare binding natively; projections run in the engine' });
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
// ordering over flavored refs of either binding
|
|
922
|
+
let order = null;
|
|
923
|
+
let orderPushed = true;
|
|
924
|
+
if (root.orderby !== null) {
|
|
925
|
+
const terms = [];
|
|
926
|
+
for (const spec of root.orderby.specs) {
|
|
927
|
+
const slot = spec.key.kind === 'path' ? spec.key.rootSlot : -1;
|
|
928
|
+
const binding = byName.get(slot);
|
|
929
|
+
const ref = binding === undefined
|
|
930
|
+
? null : entityPathRef(spec.key, slot, binding.shape);
|
|
931
|
+
if (ref === null || (ref.flavor === 'entity-doc' && ref.type === 'unknown')
|
|
932
|
+
|| spec.collation !== null || spec.collationName !== null) {
|
|
933
|
+
orderPushed = false;
|
|
934
|
+
reasons.push({ construct: '$orderby',
|
|
935
|
+
reason: 'ordering translates only over typed entity paths' });
|
|
936
|
+
break;
|
|
937
|
+
}
|
|
938
|
+
terms.push({ binding, ref, desc: spec.desc === true, emptyGreatest: spec.emptyGreatest === true });
|
|
939
|
+
}
|
|
940
|
+
if (orderPushed) order = terms;
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
const fullyPushed = whereFullyPushed && orderPushed && retBinding !== undefined
|
|
944
|
+
&& (aggregate === null || retBinding !== undefined);
|
|
945
|
+
if (!fullyPushed) {
|
|
946
|
+
return { analysis, mode: 'set', plan: null, referenced, reasons };
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
let window = null;
|
|
950
|
+
if (windows.length > 0) {
|
|
951
|
+
let offset = 0;
|
|
952
|
+
let limit = null;
|
|
953
|
+
for (let i = windows.length - 1; i >= 0; i--) {
|
|
954
|
+
const w = windows[i];
|
|
955
|
+
offset += w.offset;
|
|
956
|
+
if (w.limit !== null) limit = limit === null ? w.limit : Math.min(Math.max(limit - w.offset, 0), w.limit);
|
|
957
|
+
else if (limit !== null) limit = Math.max(limit - w.offset, 0);
|
|
958
|
+
}
|
|
959
|
+
window = { offset, limit };
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
return {
|
|
963
|
+
analysis,
|
|
964
|
+
mode: 'native',
|
|
965
|
+
referenced,
|
|
966
|
+
reasons: [],
|
|
967
|
+
plan: {
|
|
968
|
+
planVersion: PLAN_VERSION,
|
|
969
|
+
alg: bindings.length === 2 ? 'entity-join' : 'entity-select',
|
|
970
|
+
bindings: bindings.map((binding) => ({ name: binding.name, entity: binding.entity })),
|
|
971
|
+
joinOn: joinOn === null ? null : {
|
|
972
|
+
left: { binding: joinOn.left.binding.name, column: joinOn.left.ref.column },
|
|
973
|
+
right: { binding: joinOn.right.binding.name, column: joinOn.right.ref.column },
|
|
974
|
+
},
|
|
975
|
+
filters: bindings.map((binding) => ({
|
|
976
|
+
binding: binding.name,
|
|
977
|
+
filter: filters.get(binding.slot),
|
|
978
|
+
})),
|
|
979
|
+
order: order === null ? null : order.map((term) => ({
|
|
980
|
+
binding: term.binding.name, ref: term.ref,
|
|
981
|
+
desc: term.desc, emptyGreatest: term.emptyGreatest,
|
|
982
|
+
})),
|
|
983
|
+
window,
|
|
984
|
+
aggregate,
|
|
985
|
+
ret: retBinding.name,
|
|
986
|
+
},
|
|
987
|
+
};
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
/**
|
|
991
|
+
* Plan an ENTITY query document (one planner, two document kinds). The
|
|
992
|
+
* store's registered operators (Ring 2) are recognised as vocabulary and
|
|
993
|
+
* kept in the set residual over the fetched root, named in the reasons.
|
|
994
|
+
* @param {any} document
|
|
995
|
+
* @param {Map<string, any>} entities - normalized entities
|
|
996
|
+
* @param {any} mapping - explainMapping result
|
|
997
|
+
* @param {{ functions?: any, extensions?: any } | null} [operators]
|
|
998
|
+
* @returns {any}
|
|
999
|
+
*/
|
|
1000
|
+
export function planEntityQuery(document, entities, mapping, operators = null) {
|
|
1001
|
+
const planned = planEntityQueryCore(document, entities, mapping, operators);
|
|
1002
|
+
return prependRegisteredReason(planned, document, operators);
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
/** Which binding slots a subtree references (via path roots). */
|
|
1006
|
+
function collectBindingSlots(node, byName, slots) {
|
|
1007
|
+
if (node === null || typeof node !== 'object') return;
|
|
1008
|
+
if (Array.isArray(node)) {
|
|
1009
|
+
for (const item of node) collectBindingSlots(item, byName, slots);
|
|
1010
|
+
return;
|
|
1011
|
+
}
|
|
1012
|
+
if (node.kind === 'path' && byName.has(node.rootSlot)) slots.add(node.rootSlot);
|
|
1013
|
+
for (const key of Object.keys(node)) {
|
|
1014
|
+
if (key === 'docPath') continue;
|
|
1015
|
+
collectBindingSlots(node[key], byName, slots);
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
/** The entity names a document's root paths reference (`$.Name[*]`). */
|
|
1020
|
+
export function collectEntityRoots(document, entities) {
|
|
1021
|
+
const found = new Set();
|
|
1022
|
+
const walk = (node) => {
|
|
1023
|
+
if (typeof node === 'string') {
|
|
1024
|
+
const match = /^\$\.([A-Za-z_][A-Za-z0-9_]*)\[\*\]/.exec(node);
|
|
1025
|
+
if (match !== null && entities.has(match[1])) found.add(match[1]);
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
1028
|
+
if (Array.isArray(node)) {
|
|
1029
|
+
node.forEach(walk);
|
|
1030
|
+
return;
|
|
1031
|
+
}
|
|
1032
|
+
if (node !== null && typeof node === 'object') {
|
|
1033
|
+
for (const key of Object.keys(node)) walk(node[key]);
|
|
1034
|
+
}
|
|
1035
|
+
};
|
|
1036
|
+
walk(document);
|
|
1037
|
+
return found;
|
|
1038
|
+
}
|