@jarenjs/db 0.46.5 → 0.56.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 +133 -17
- package/README.md +270 -36
- package/docs/JOBS-FORMAT.md +24 -8
- package/docs/LIVE-FORMAT.md +139 -7
- package/docs/MIGRATION-FORMAT.md +118 -36
- package/docs/MODEL-FORMAT.md +251 -30
- package/package.json +4 -5
- package/schemas/jaren-migration.draft-07.schema.json +73 -0
- package/schemas/jaren-migration.schema.json +73 -0
- package/src/algebra.js +22 -3
- package/src/capture.js +66 -28
- package/src/cli.js +225 -44
- package/src/ddl.js +23 -3
- package/src/dialect.js +13 -0
- package/src/dialects/sqlite.js +21 -1
- package/src/driver.js +63 -16
- package/src/drivers/wasm.js +1 -0
- package/src/emit-model.js +14 -0
- package/src/emit.js +42 -9
- package/src/entity.js +92 -47
- package/src/errors.js +28 -0
- package/src/index.js +2 -2
- package/src/jobs.js +40 -5
- package/src/live-time.js +605 -0
- package/src/live.js +52 -9
- package/src/migrate.js +397 -191
- package/src/model.js +173 -8
- package/src/plan.js +834 -47
- package/src/query.js +296 -22
- package/src/residual.js +15 -6
- package/src/series.js +349 -0
- package/src/store.js +243 -69
- package/src/tracker.js +173 -48
- package/types/index.d.ts +206 -12
- package/types/node.d.ts +3 -1
- package/types/typed.d.ts +58 -2
- package/types/wasm.d.ts +7 -0
- package/dist/types/algebra.d.ts +0 -199
- 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 -149
- 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 -167
- 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.d.ts +0 -62
- 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 -140
- package/dist/types/profile.d.ts +0 -80
- package/dist/types/query.d.ts +0 -111
- package/dist/types/residual.d.ts +0 -61
- package/dist/types/store.d.ts +0 -53
- 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
|
@@ -41,15 +41,21 @@ import { analyzeQuery, AST_VERSION, NODE_KINDS } from '@jarenjs/json/query';
|
|
|
41
41
|
import {
|
|
42
42
|
getEpochOfDateTimeRFC3339, getEpochOfDateOnlyRFC3339,
|
|
43
43
|
} from '@jarenjs/core/dates/rfc3339';
|
|
44
|
+
import { compileBuckets, resampleSeries, toEpoch } from '@jarenjs/core/series';
|
|
44
45
|
|
|
45
46
|
import { selectPlan, conjoin, PLAN_VERSION } from './algebra.js';
|
|
46
47
|
import { typeOfPath, isNumericType } from './types.js';
|
|
47
|
-
import { schemaNodeAt } from './ddl.js';
|
|
48
|
+
import { schemaNodeAt, canonicalOf } from './ddl.js';
|
|
48
49
|
import {
|
|
49
50
|
BBOX_COMPONENTS, BBOX_INDEX_ORDER, PRECISION_MIN, PRECISION_MAX,
|
|
50
51
|
probeBox, probePosition, probeCircleBox, cellNeighbourhood, probeVector,
|
|
51
52
|
} from './derive.js';
|
|
52
53
|
import { KNN_MARGIN } from './knn.js';
|
|
54
|
+
import {
|
|
55
|
+
SERIES_ROOT_OPS, NATIVE_AGGREGATES, seriesReason,
|
|
56
|
+
instantIndexesOver, seekingIndexFor, filterFacts, fixedLadder, instantRefusal,
|
|
57
|
+
valueRefusal, seriesRecord, singularSelector,
|
|
58
|
+
} from './series.js';
|
|
53
59
|
|
|
54
60
|
/** Comparison operator names → plan ops. */
|
|
55
61
|
const COMPARISONS = new Map([
|
|
@@ -119,6 +125,31 @@ for (const kind of NODE_KINDS) {
|
|
|
119
125
|
* @param {string} reason
|
|
120
126
|
* @returns {{ construct: string, reason: string }}
|
|
121
127
|
*/
|
|
128
|
+
/** Whether a literal window bound is one SQL takes as written: a
|
|
129
|
+
* non-negative safe integer. A negative, fractional or non-finite bound
|
|
130
|
+
* is the ENGINE's to interpret (it answers `[]`, a truncation or a
|
|
131
|
+
* refusal), and interpolated into `LIMIT`/`OFFSET` it answered other
|
|
132
|
+
* rows or a raw database error. */
|
|
133
|
+
function isWindowBound(value) {
|
|
134
|
+
return Number.isSafeInteger(value) && value >= 0;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Whether a schema node admits `null` beside its scalar type. A present
|
|
138
|
+
* null orders and aggregates in SQL (NULLS FIRST, skipped by SUM) where
|
|
139
|
+
* the engine refuses it (`JQ2005`, `JQ2001`), so a promotion over such a
|
|
140
|
+
* path answers where the reference semantics throw. */
|
|
141
|
+
function admitsNull(schema, segments) {
|
|
142
|
+
const node = schemaNodeAt(schema, segments);
|
|
143
|
+
return Array.isArray(node?.type) && node.type.includes('null');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Whether a typed reference orders natively: numbers and strings that
|
|
147
|
+
* cannot hold a null. A boolean orders as 0/1 in SQL and is `JQ2005` in
|
|
148
|
+
* the engine. */
|
|
149
|
+
function orderable(ref, schema) {
|
|
150
|
+
return ref.type !== 'unknown' && ref.type !== 'boolean' && !admitsNull(schema, ref.segments);
|
|
151
|
+
}
|
|
152
|
+
|
|
122
153
|
function refusal(construct, reason) {
|
|
123
154
|
return { construct, reason };
|
|
124
155
|
}
|
|
@@ -262,9 +293,7 @@ function memberPath(node, itSlot) {
|
|
|
262
293
|
else return null;
|
|
263
294
|
}
|
|
264
295
|
if (segments.length === 0) return null;
|
|
265
|
-
|
|
266
|
-
.map((s) => ('name' in s ? `.${s.name}` : `[${s.index}]`)).join('');
|
|
267
|
-
return { segments, canonical };
|
|
296
|
+
return { segments, canonical: canonicalOf(segments) };
|
|
268
297
|
}
|
|
269
298
|
|
|
270
299
|
/**
|
|
@@ -628,6 +657,10 @@ function planDistanceBound(node, itSlot, shape) {
|
|
|
628
657
|
if (constant === null) return { refusal: refusal('$distance', SPATIAL_REASONS.operand) };
|
|
629
658
|
const at = probePosition(constant.value);
|
|
630
659
|
if (at === null) return { refusal: refusal('$distance', SPATIAL_REASONS.unbounded) };
|
|
660
|
+
if (!Number.isFinite(radius.value) || radius.value < 0) {
|
|
661
|
+
return { refusal: refusal('$distance',
|
|
662
|
+
'a distance bound is a finite, non-negative number of metres') };
|
|
663
|
+
}
|
|
631
664
|
const box = probeCircleBox(at, radius.value);
|
|
632
665
|
if (box === null) return { refusal: refusal('$distance', SPATIAL_REASONS.pole) };
|
|
633
666
|
if (box[0] < -180 || box[2] > 180)
|
|
@@ -1010,6 +1043,625 @@ function planPredicate(node, itSlot, shape) {
|
|
|
1010
1043
|
'no native spelling of this operator is proven equivalent') };
|
|
1011
1044
|
}
|
|
1012
1045
|
|
|
1046
|
+
// ————— Time series: the three closed shapes over a declared index —————
|
|
1047
|
+
//
|
|
1048
|
+
// The physical feature is one a model already has: a composite index
|
|
1049
|
+
// over `[$.series, $.at]`. There is no `derive: 'series'`, no column
|
|
1050
|
+
// type and no host function — D9's whole point is that a declared
|
|
1051
|
+
// numeric epoch column is already 52× reading the instant back out of
|
|
1052
|
+
// the document, so the work here is recognizing which questions that
|
|
1053
|
+
// index can answer rather than inventing a place to put time.
|
|
1054
|
+
//
|
|
1055
|
+
// Three shapes are recognized, and they are CLOSED:
|
|
1056
|
+
//
|
|
1057
|
+
// 1. **range** — every leading column of an instant index pinned by an
|
|
1058
|
+
// equality, a half-open range on the instant column, ordered by it.
|
|
1059
|
+
// Already a native selection; what this adds is the NAME of the
|
|
1060
|
+
// operation, the index it seeks, and the honest reason when the
|
|
1061
|
+
// prefix is missing.
|
|
1062
|
+
// 2. **as-of** — the same prefix with ONE instant bound, ordered by
|
|
1063
|
+
// the instant, cut to a finite window. §2.2's 0.003 ms row.
|
|
1064
|
+
// 3. **bucket** — a fixed-width ladder over the instant column with
|
|
1065
|
+
// the exact `sum|mean|min|max|count` aggregates, in both spellings
|
|
1066
|
+
// the language has for it: a `$groupby` whose key is
|
|
1067
|
+
// `$time-bucket`, and a `$resample` whose spec asks for nothing a
|
|
1068
|
+
// `GROUP BY` cannot do.
|
|
1069
|
+
//
|
|
1070
|
+
// Everything else is a NAMED core refinement: the fetch narrows through
|
|
1071
|
+
// the index and the residual — which is the engine running the caller's
|
|
1072
|
+
// own document — decides. That is what keeps a refinement idempotent,
|
|
1073
|
+
// and it is why a calendar ladder, a fill policy, a rolling window and
|
|
1074
|
+
// an as-of JOIN cost a bounded fetch rather than a wrong answer.
|
|
1075
|
+
|
|
1076
|
+
/**
|
|
1077
|
+
* The source a packed spelling stands for. `["$[*]"]` — an array
|
|
1078
|
+
* constructor of exactly one element — is what a `$for` unpacks back
|
|
1079
|
+
* into the rows (QUERY-FORMAT §6.2, D4): `@jarenjs/linq` binds every
|
|
1080
|
+
* iterated source that way so an array-valued ROW stays one item. Over
|
|
1081
|
+
* a collection or an entity array the rows are objects, so the packed
|
|
1082
|
+
* and the bare spelling are the same rows, and the planner reads
|
|
1083
|
+
* through the packing rather than sending the document to the residual.
|
|
1084
|
+
* @param {any} node
|
|
1085
|
+
* @returns {any}
|
|
1086
|
+
*/
|
|
1087
|
+
function unpacked(node) {
|
|
1088
|
+
return node?.kind === 'array' && node.elements?.length === 1 ? node.elements[0] : node;
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
/** Is this node the whole collection — `$[*]` over the input document,
|
|
1092
|
+
* bare or packed? */
|
|
1093
|
+
function isCollectionSource(node) {
|
|
1094
|
+
const path = unpacked(node);
|
|
1095
|
+
return path?.kind === 'path' && path.name === '$' && path.external !== true
|
|
1096
|
+
&& path.rootSlot === 0 && path.singular !== true
|
|
1097
|
+
&& path.segments.length === 1 && path.segments[0].descendant !== true
|
|
1098
|
+
&& path.segments[0].selectors.length === 1
|
|
1099
|
+
&& path.segments[0].selectors[0].kind === 'wildcard';
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
/**
|
|
1103
|
+
* The collection-side operand of a root series operator: the bare
|
|
1104
|
+
* `$[*]`, or a FLWOR over it whose `$where` is the narrowing.
|
|
1105
|
+
* @param {any} node
|
|
1106
|
+
* @returns {{ flwor: any } | null}
|
|
1107
|
+
*/
|
|
1108
|
+
function collectionOperand(node) {
|
|
1109
|
+
if (isCollectionSource(node)) return { flwor: null };
|
|
1110
|
+
if (node?.kind === 'flwor' && node.forBindings.length === 1
|
|
1111
|
+
&& isCollectionSource(node.forBindings[0]?.expr))
|
|
1112
|
+
return { flwor: node };
|
|
1113
|
+
return null;
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
/**
|
|
1117
|
+
* A typed reference to one top-level member of the collection's
|
|
1118
|
+
* documents — what a spec's row selector ultimately names.
|
|
1119
|
+
* @param {any} shape
|
|
1120
|
+
* @param {string} name
|
|
1121
|
+
* @returns {import('./algebra.js').PlanRef}
|
|
1122
|
+
*/
|
|
1123
|
+
function memberRef(shape, name) {
|
|
1124
|
+
const segments = [{ name }];
|
|
1125
|
+
return {
|
|
1126
|
+
segments,
|
|
1127
|
+
type: typeOfPath(shape.schema, segments),
|
|
1128
|
+
column: shape.columnByCanonical.get(canonicalOf(segments)) ?? null,
|
|
1129
|
+
};
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
/**
|
|
1133
|
+
* The instants a plan-time literal series carries, under one selector.
|
|
1134
|
+
* `null` when the operand is not a literal array of records, or when
|
|
1135
|
+
* one of them names no instant — either way the planner has no bound to
|
|
1136
|
+
* add and says so rather than guessing one.
|
|
1137
|
+
* @param {any} node
|
|
1138
|
+
* @param {any} selector - the spec's `leftAt`/`rightAt`, or undefined
|
|
1139
|
+
* @returns {{ min: number, max: number, keys: any[] | null } | null}
|
|
1140
|
+
*/
|
|
1141
|
+
function literalInstants(node, selector, keySelector) {
|
|
1142
|
+
const constant = constantOf(node);
|
|
1143
|
+
if (constant === null || !Array.isArray(constant.value) || constant.value.length === 0)
|
|
1144
|
+
return null;
|
|
1145
|
+
const at = selector === undefined ? 'at' : singularSelector(selector);
|
|
1146
|
+
const by = keySelector === undefined ? null : singularSelector(keySelector);
|
|
1147
|
+
if (at === null || (keySelector !== undefined && by === null)) return null;
|
|
1148
|
+
let min = Infinity;
|
|
1149
|
+
let max = -Infinity;
|
|
1150
|
+
const keys = by === null ? null : [];
|
|
1151
|
+
for (const row of constant.value) {
|
|
1152
|
+
if (row === null || typeof row !== 'object') return null;
|
|
1153
|
+
const instant = row[at];
|
|
1154
|
+
if (typeof instant !== 'number' || !Number.isFinite(instant)) return null;
|
|
1155
|
+
if (instant < min) min = instant;
|
|
1156
|
+
if (instant > max) max = instant;
|
|
1157
|
+
if (keys !== null) {
|
|
1158
|
+
const key = row[by];
|
|
1159
|
+
if (typeof key !== 'string' && typeof key !== 'number') return null;
|
|
1160
|
+
if (!keys.includes(key)) keys.push(key);
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
return { min, max, keys };
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
/**
|
|
1167
|
+
* The native bucket a `$resample` spec asks for, or the FIRST reason it
|
|
1168
|
+
* is a refinement instead. The rules are asked in one fixed order, so a
|
|
1169
|
+
* spec always names the same reason on every host.
|
|
1170
|
+
* @param {any} spec - the frozen literal
|
|
1171
|
+
* @param {any} shape
|
|
1172
|
+
* @returns {{ bucket: import('./algebra.js').PlanBucket } | { code: string }}
|
|
1173
|
+
*/
|
|
1174
|
+
function resampleBucket(spec, shape) {
|
|
1175
|
+
// the clock first, because a named zone is resolved by HOST code the
|
|
1176
|
+
// planner does not have: asking the kernel about it would report the
|
|
1177
|
+
// missing provider rather than the reason a ladder is not native
|
|
1178
|
+
if (spec.zone !== undefined && spec.zone !== 'UTC') return { code: 'named-zone' };
|
|
1179
|
+
// The kernel's own rules, asked once, by running it over NO rows —
|
|
1180
|
+
// order 04's trick, for order 04's reason. `analyzeQuery` does not
|
|
1181
|
+
// compile an operator, so a spec the kernel refuses reaches the
|
|
1182
|
+
// planner before the engine has had its say, and a plan that answered
|
|
1183
|
+
// where the engine raises is the one thing a pushdown may never do.
|
|
1184
|
+
try {
|
|
1185
|
+
resampleSeries([], spec);
|
|
1186
|
+
}
|
|
1187
|
+
catch {
|
|
1188
|
+
return { code: 'invalid-spec' };
|
|
1189
|
+
}
|
|
1190
|
+
if (spec.fill !== undefined && spec.fill !== 'omit') return { code: 'fill-policy' };
|
|
1191
|
+
const fn = NATIVE_AGGREGATES[spec.aggregate ?? 'mean'];
|
|
1192
|
+
if (fn === undefined) return { code: 'unsupported-aggregate' };
|
|
1193
|
+
const ladder = fixedLadder(spec, compileBuckets);
|
|
1194
|
+
if ('code' in ladder) return ladder;
|
|
1195
|
+
// a row selector is a singular path whose `$` is the ROW, so it names
|
|
1196
|
+
// a member — and a member is what a declared column stands for. One
|
|
1197
|
+
// that names a path INTO a member names no column, and says so
|
|
1198
|
+
const atName = spec.at === undefined ? 'at' : singularSelector(spec.at);
|
|
1199
|
+
const valueName = spec.value === undefined ? 'value' : singularSelector(spec.value);
|
|
1200
|
+
if (atName === null || valueName === null) return { code: 'row-selector' };
|
|
1201
|
+
const at = memberRef(shape, atName);
|
|
1202
|
+
const instantBad = instantRefusal(at);
|
|
1203
|
+
if (instantBad !== null) return { code: instantBad };
|
|
1204
|
+
const value = memberRef(shape, valueName);
|
|
1205
|
+
if (fn !== 'rows') {
|
|
1206
|
+
const valueBad = valueRefusal(value);
|
|
1207
|
+
if (valueBad !== null) return { code: valueBad };
|
|
1208
|
+
}
|
|
1209
|
+
// D5's shape, exactly: the bucket's start, its reading, and the count
|
|
1210
|
+
// of SOURCE rows — which is `COUNT(*)` whether or not it is also the
|
|
1211
|
+
// answer, because `aggregate: 'count'` returns that same number
|
|
1212
|
+
return { bucket: {
|
|
1213
|
+
ref: at,
|
|
1214
|
+
every: ladder.every,
|
|
1215
|
+
origin: ladder.origin,
|
|
1216
|
+
as: 'at',
|
|
1217
|
+
order: 'asc',
|
|
1218
|
+
aggregates: [
|
|
1219
|
+
{ fn: /** @type {any} */ (fn), ref: fn === 'rows' ? null : value, as: 'value' },
|
|
1220
|
+
{ fn: /** @type {any} */ ('rows'), ref: null, as: 'count' },
|
|
1221
|
+
],
|
|
1222
|
+
} };
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
/**
|
|
1226
|
+
* The ladder a `$groupby` key spells, when the key is a `$time-bucket`
|
|
1227
|
+
* over a member path with literal width and origin.
|
|
1228
|
+
* @param {any} key - the grouping key expression node
|
|
1229
|
+
* @param {number} itSlot
|
|
1230
|
+
* @param {any} shape
|
|
1231
|
+
* @returns {{ ref: any, every: number, origin: number } | { code: string } | null}
|
|
1232
|
+
* `null` when the key is not a `$time-bucket` at all
|
|
1233
|
+
*/
|
|
1234
|
+
function groupLadder(key, itSlot, shape) {
|
|
1235
|
+
if (key?.kind !== 'op' || key.name !== '$time-bucket') return null;
|
|
1236
|
+
const [atNode, everyNode, originNode, contextNode] = key.args;
|
|
1237
|
+
if (everyNode.kind !== 'literal') return { code: 'nonliteral-spec' };
|
|
1238
|
+
if (originNode !== undefined && originNode.kind !== 'literal')
|
|
1239
|
+
return { code: 'nonliteral-spec' };
|
|
1240
|
+
/** @type {any} */
|
|
1241
|
+
const spec = { every: everyNode.value };
|
|
1242
|
+
if (originNode !== undefined && originNode.value !== null) spec.origin = originNode.value;
|
|
1243
|
+
if (contextNode !== undefined) {
|
|
1244
|
+
if (contextNode.kind !== 'raw') return { code: 'nonliteral-spec' };
|
|
1245
|
+
const context = contextNode.value;
|
|
1246
|
+
if (context?.zone !== undefined) return { code: 'named-zone' };
|
|
1247
|
+
if (context?.offset !== undefined) spec.offset = context.offset;
|
|
1248
|
+
}
|
|
1249
|
+
const ladder = fixedLadder(spec, compileBuckets);
|
|
1250
|
+
if ('code' in ladder) return ladder;
|
|
1251
|
+
const ref = pathRef(atNode, itSlot, shape);
|
|
1252
|
+
const instantBad = instantRefusal(ref);
|
|
1253
|
+
if (instantBad !== null) return { code: instantBad };
|
|
1254
|
+
return { ref, every: ladder.every, origin: ladder.origin };
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
/**
|
|
1258
|
+
* The closed projection of a bucket grouping: one member per answered
|
|
1259
|
+
* value, each of them the group key, a `$count` of the whole binding,
|
|
1260
|
+
* or one of the four value aggregates over a schema-typed numeric path.
|
|
1261
|
+
* @param {any} ret - the `$return` node
|
|
1262
|
+
* @param {number} itSlot
|
|
1263
|
+
* @param {number} keySlot
|
|
1264
|
+
* @param {any} shape
|
|
1265
|
+
* @returns {{ as: string, aggregates: any[] } | { code: string }}
|
|
1266
|
+
*/
|
|
1267
|
+
function bucketProjection(ret, itSlot, keySlot, shape) {
|
|
1268
|
+
if (ret?.kind !== 'object') return { code: 'nonnative-grouping' };
|
|
1269
|
+
let as = null;
|
|
1270
|
+
const aggregates = [];
|
|
1271
|
+
for (const entry of ret.entries) {
|
|
1272
|
+
const expr = entry.expr;
|
|
1273
|
+
if (expr.kind === 'var' && expr.external !== true && expr.slot === keySlot) {
|
|
1274
|
+
if (as !== null) return { code: 'nonnative-grouping' };
|
|
1275
|
+
as = entry.name;
|
|
1276
|
+
continue;
|
|
1277
|
+
}
|
|
1278
|
+
if (expr.kind !== 'op') return { code: 'nonnative-grouping' };
|
|
1279
|
+
if (expr.name === '$count') {
|
|
1280
|
+
// `$count` over the BINDING is the group's row count; over a path
|
|
1281
|
+
// it counts the rows that HAVE the member, which SQL's
|
|
1282
|
+
// `COUNT(column)` does not reproduce for a JSON `null`
|
|
1283
|
+
if (!isItVar(expr.args[0], itSlot)) return { code: 'nonnative-grouping' };
|
|
1284
|
+
aggregates.push({ fn: 'rows', ref: null, as: entry.name, empty: 'null' });
|
|
1285
|
+
continue;
|
|
1286
|
+
}
|
|
1287
|
+
const fn = { $sum: 'sum', $avg: 'avg', $min: 'min', $max: 'max' }[expr.name];
|
|
1288
|
+
if (fn === undefined) return { code: 'nonnative-grouping' };
|
|
1289
|
+
const ref = pathRef(expr.args[0], itSlot, shape);
|
|
1290
|
+
const valueBad = valueRefusal(ref);
|
|
1291
|
+
if (valueBad !== null) return { code: valueBad };
|
|
1292
|
+
// what an aggregate over NO numbers says, in the ENGINE's words:
|
|
1293
|
+
// `$sum` of an empty sequence is 0 and the other three are the
|
|
1294
|
+
// empty sequence, which an object constructor leaves the member out
|
|
1295
|
+
// for. SQL answers `NULL` for all four, so the mapping is the plan's
|
|
1296
|
+
aggregates.push({ fn, ref, as: entry.name, empty: fn === 'sum' ? 'zero' : 'omit' });
|
|
1297
|
+
}
|
|
1298
|
+
if (as === null || aggregates.length === 0) return { code: 'nonnative-grouping' };
|
|
1299
|
+
return { as, aggregates };
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
/**
|
|
1303
|
+
* Which way the groups come out. The engine's own rule is order of
|
|
1304
|
+
* FIRST APPEARANCE (§6.5), which over a collection is the earliest row
|
|
1305
|
+
* identity in each group; an `$orderby` on the key alone replaces it.
|
|
1306
|
+
* @param {any} orderby
|
|
1307
|
+
* @param {number} keySlot
|
|
1308
|
+
* @returns {'asc' | 'desc' | 'first-seen' | null} `null` when the
|
|
1309
|
+
* ordering is one this plan cannot reproduce
|
|
1310
|
+
*/
|
|
1311
|
+
function bucketOrder(orderby, keySlot) {
|
|
1312
|
+
if (orderby === null) return 'first-seen';
|
|
1313
|
+
if (orderby.specs.length !== 1) return null;
|
|
1314
|
+
const spec = orderby.specs[0];
|
|
1315
|
+
if (spec.collation !== null || spec.collationName !== null) return null;
|
|
1316
|
+
const key = spec.key;
|
|
1317
|
+
if (key.kind !== 'var' || key.external === true || key.slot !== keySlot) return null;
|
|
1318
|
+
return spec.desc === true ? 'desc' : 'asc';
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
/**
|
|
1322
|
+
* The record a refused grouping leaves behind: the same question, named
|
|
1323
|
+
* and reasoned, over whatever the fetch still narrows.
|
|
1324
|
+
* @param {import('./algebra.js').Plan} plan
|
|
1325
|
+
* @param {any} shape
|
|
1326
|
+
* @param {string} code
|
|
1327
|
+
* @param {string} construct
|
|
1328
|
+
* @returns {any}
|
|
1329
|
+
*/
|
|
1330
|
+
function refinedGrouping(plan, shape, code, construct) {
|
|
1331
|
+
const facts = filterFacts(plan.filter);
|
|
1332
|
+
let column = null;
|
|
1333
|
+
for (const [name] of facts.bounds) {
|
|
1334
|
+
if (instantIndexesOver(shape, name, 2).length > 0) column = name;
|
|
1335
|
+
}
|
|
1336
|
+
const index = seekingIndexFor(shape, column, facts, 2);
|
|
1337
|
+
const bound = column === null ? null : facts.bounds.get(column);
|
|
1338
|
+
return seriesRecord({
|
|
1339
|
+
mode: plan.filter === null ? 'engine' : 'hybrid',
|
|
1340
|
+
operation: 'bucket',
|
|
1341
|
+
index: index === null ? null : index.name,
|
|
1342
|
+
prefix: index === null ? [] : index.prefix,
|
|
1343
|
+
range: bound === undefined || bound === null ? null : { column, ...bound },
|
|
1344
|
+
refinement: 'resampleSeries',
|
|
1345
|
+
reasons: [seriesReason(code, construct)],
|
|
1346
|
+
});
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
/**
|
|
1350
|
+
* Classify a planned selection as a temporal range or as-of lookup, or
|
|
1351
|
+
* answer `null` when the document asked no such question. The plan is
|
|
1352
|
+
* NOT changed: this names what the selection already is, and which
|
|
1353
|
+
* declared index it seeks through.
|
|
1354
|
+
* @param {import('./algebra.js').Plan} plan
|
|
1355
|
+
* @param {any} shape
|
|
1356
|
+
* @param {boolean} ordered - the ordering was pushed whole
|
|
1357
|
+
* @returns {any} the series record, or null
|
|
1358
|
+
*/
|
|
1359
|
+
function classifySelection(plan, shape, ordered) {
|
|
1360
|
+
const facts = filterFacts(plan.filter);
|
|
1361
|
+
// the instant column is the one a DECLARED index ends with; without
|
|
1362
|
+
// such an index the collection has no instant and the question was
|
|
1363
|
+
// an ordinary one
|
|
1364
|
+
const candidates = [];
|
|
1365
|
+
for (const [column, bound] of facts.bounds) {
|
|
1366
|
+
if (instantIndexesOver(shape, column, 2).length === 0) continue;
|
|
1367
|
+
candidates.push([column, bound]);
|
|
1368
|
+
}
|
|
1369
|
+
if (candidates.length !== 1) return null;
|
|
1370
|
+
const [column, bound] = candidates[0];
|
|
1371
|
+
const index = seekingIndexFor(shape, column, facts, 2);
|
|
1372
|
+
const bounded = bound.from !== null || bound.to !== null;
|
|
1373
|
+
const twoSided = bound.from !== null && bound.to !== null;
|
|
1374
|
+
const orderedByInstant = ordered && plan.order !== null && plan.order.length === 1
|
|
1375
|
+
&& plan.order[0].ref.column === column;
|
|
1376
|
+
const operation = twoSided ? 'range'
|
|
1377
|
+
: (orderedByInstant && plan.window !== null && plan.window.limit !== null) ? 'asof'
|
|
1378
|
+
: bounded ? 'range' : null;
|
|
1379
|
+
if (operation === null) return null;
|
|
1380
|
+
const reasons = index === null ? [seriesReason('missing-series-prefix', '$where')] : [];
|
|
1381
|
+
return seriesRecord({
|
|
1382
|
+
mode: index === null ? 'engine' : 'native',
|
|
1383
|
+
operation,
|
|
1384
|
+
index: index === null ? null : index.name,
|
|
1385
|
+
prefix: index === null ? [] : index.prefix,
|
|
1386
|
+
range: {
|
|
1387
|
+
from: bound.from, fromOp: bound.fromOp, to: bound.to, toOp: bound.toOp, column,
|
|
1388
|
+
},
|
|
1389
|
+
reasons,
|
|
1390
|
+
});
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
|
|
1394
|
+
/**
|
|
1395
|
+
* The bucket a `$groupby` phrase spells, or the reason it is not one.
|
|
1396
|
+
* @param {any} node - the flwor node
|
|
1397
|
+
* @param {number} itSlot
|
|
1398
|
+
* @param {any} shape
|
|
1399
|
+
* @returns {{ bucket: any } | { code: string }}
|
|
1400
|
+
*/
|
|
1401
|
+
function planBucketGrouping(node, itSlot, shape) {
|
|
1402
|
+
if (node.groupby.keys.length !== 1) return { code: 'nonnative-grouping' };
|
|
1403
|
+
const key = node.groupby.keys[0];
|
|
1404
|
+
const ladder = groupLadder(key.expr, itSlot, shape);
|
|
1405
|
+
if (ladder === null) return { code: 'nonnative-grouping' };
|
|
1406
|
+
if ('code' in ladder) return ladder;
|
|
1407
|
+
const projection = bucketProjection(node.ret, itSlot, key.slot, shape);
|
|
1408
|
+
if ('code' in projection) return projection;
|
|
1409
|
+
const order = bucketOrder(node.orderby, key.slot);
|
|
1410
|
+
if (order === null) return { code: 'nonnative-grouping' };
|
|
1411
|
+
return { bucket: {
|
|
1412
|
+
ref: ladder.ref,
|
|
1413
|
+
every: ladder.every,
|
|
1414
|
+
origin: ladder.origin,
|
|
1415
|
+
as: projection.as,
|
|
1416
|
+
order,
|
|
1417
|
+
aggregates: projection.aggregates,
|
|
1418
|
+
} };
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
/**
|
|
1422
|
+
* The tolerance of an as-of spec in milliseconds, or `null` for one
|
|
1423
|
+
* that bounds nothing. A NEGATIVE tolerance is a broken document the
|
|
1424
|
+
* engine refuses, and narrowing by it would move the bounds INWARD —
|
|
1425
|
+
* so it bounds nothing here and the engine raises, which is the same
|
|
1426
|
+
* rule `safeEpoch` follows for an instant that names none.
|
|
1427
|
+
*/
|
|
1428
|
+
function toleranceMs(tolerance) {
|
|
1429
|
+
if (tolerance === undefined) return null;
|
|
1430
|
+
if (typeof tolerance === 'number')
|
|
1431
|
+
return Number.isFinite(tolerance) && tolerance >= 0 ? tolerance : null;
|
|
1432
|
+
try {
|
|
1433
|
+
const span = compileBuckets({ every: tolerance }, {});
|
|
1434
|
+
return span.calendar || span.width < 0 ? null : span.width;
|
|
1435
|
+
}
|
|
1436
|
+
catch {
|
|
1437
|
+
return null;
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
/**
|
|
1442
|
+
* The epoch a spec member names, or `null` when it names none.
|
|
1443
|
+
*
|
|
1444
|
+
* A planner may never raise on the ENGINE's behalf: `analyzeQuery` does
|
|
1445
|
+
* not compile an operator, so a spec whose `start` is not an instant
|
|
1446
|
+
* reaches here before the engine has had its say. Refusing to narrow is
|
|
1447
|
+
* the right answer — the residual compiles the caller's own document
|
|
1448
|
+
* and raises the `JQ0003` it would have raised anyway.
|
|
1449
|
+
* @param {any} value
|
|
1450
|
+
* @returns {number | null}
|
|
1451
|
+
*/
|
|
1452
|
+
function safeEpoch(value) {
|
|
1453
|
+
if (value === undefined) return null;
|
|
1454
|
+
try {
|
|
1455
|
+
const at = toEpoch(value);
|
|
1456
|
+
return Number.isFinite(at) ? at : null;
|
|
1457
|
+
}
|
|
1458
|
+
catch {
|
|
1459
|
+
return null;
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
/** An instant bound as a pushable conjunct over the instant column. */
|
|
1464
|
+
function instantBound(ref, op, value) {
|
|
1465
|
+
return { p: 'cmp', op, ref, operand: { lit: value } };
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
/**
|
|
1469
|
+
* The bounds a frozen spec implies for the collection side, as pushable
|
|
1470
|
+
* conjuncts. Every one of them is an IMPLIED conjunct: it narrows the
|
|
1471
|
+
* fetch and decides nothing, because the residual re-runs the caller's
|
|
1472
|
+
* own document — the whole operator — over what comes back.
|
|
1473
|
+
* @returns {{ preds: any[], range: any }}
|
|
1474
|
+
*/
|
|
1475
|
+
function impliedInstantBounds(ref, from, to) {
|
|
1476
|
+
const preds = [];
|
|
1477
|
+
if (from !== null) preds.push(instantBound(ref, 'ge', from));
|
|
1478
|
+
if (to !== null) preds.push(instantBound(ref, 'le', to));
|
|
1479
|
+
return { preds, range: { column: ref.column, from, fromOp: from === null ? null : 'ge',
|
|
1480
|
+
to, toOp: to === null ? null : 'le' } };
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
/**
|
|
1484
|
+
* Plan a document that IS a series operator over the collection.
|
|
1485
|
+
*
|
|
1486
|
+
* The collection is one of the operator's operands, so the narrowing is
|
|
1487
|
+
* that operand's own `$where` plus what the frozen spec implies, and
|
|
1488
|
+
* the kernel — the engine running the caller's document over the
|
|
1489
|
+
* fetched candidates — decides. A `$resample` whose spec asks for
|
|
1490
|
+
* nothing a `GROUP BY` cannot do is the one exception: it is native,
|
|
1491
|
+
* and answers the bucket records itself.
|
|
1492
|
+
* @param {any} root
|
|
1493
|
+
* @param {any} shape
|
|
1494
|
+
* @returns {any} `null` when the operator is not over this collection
|
|
1495
|
+
*/
|
|
1496
|
+
function planSeriesOperator(root, shape) {
|
|
1497
|
+
const name = root.name;
|
|
1498
|
+
/** @type {any} */
|
|
1499
|
+
let operandNode = null;
|
|
1500
|
+
/** @type {any} */
|
|
1501
|
+
let probesNode = null;
|
|
1502
|
+
/** @type {any} */
|
|
1503
|
+
let spec = null;
|
|
1504
|
+
if (name === '$resample' || name === '$rolling') {
|
|
1505
|
+
operandNode = root.args[0];
|
|
1506
|
+
spec = root.args[1]?.kind === 'raw' ? root.args[1].value : null;
|
|
1507
|
+
}
|
|
1508
|
+
else {
|
|
1509
|
+
spec = root.args.length === 3
|
|
1510
|
+
? (root.args[2].kind === 'raw' ? root.args[2].value : null) : {};
|
|
1511
|
+
// narrowing is only sound on the RIGHT side: an as-of join answers
|
|
1512
|
+
// once per LEFT row, so every left row is needed whatever it matches
|
|
1513
|
+
if (collectionOperand(root.args[1]) !== null) {
|
|
1514
|
+
operandNode = root.args[1];
|
|
1515
|
+
probesNode = root.args[0];
|
|
1516
|
+
}
|
|
1517
|
+
else if (collectionOperand(root.args[0]) !== null) {
|
|
1518
|
+
operandNode = root.args[0];
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
const operand = operandNode === null ? null : collectionOperand(operandNode);
|
|
1522
|
+
if (operand === null || spec === null || typeof spec !== 'object') return null;
|
|
1523
|
+
|
|
1524
|
+
const inner = operand.flwor === null
|
|
1525
|
+
? { plan: selectPlan(shape.collection), reasons: [], prefilters: [] }
|
|
1526
|
+
: planFlwor(operand.flwor, shape, undefined, undefined);
|
|
1527
|
+
const plan = inner.plan;
|
|
1528
|
+
// the operand's own clauses stay the engine's: the residual runs the
|
|
1529
|
+
// WHOLE document, so a projection or an ordering inside it is applied
|
|
1530
|
+
// there and only its pushed conjuncts narrow
|
|
1531
|
+
plan.order = null;
|
|
1532
|
+
plan.window = null;
|
|
1533
|
+
|
|
1534
|
+
const reasons = [];
|
|
1535
|
+
const prefilters = [...(inner.prefilters ?? [])];
|
|
1536
|
+
const at = memberRef(shape,
|
|
1537
|
+
spec.at !== undefined ? (singularSelector(spec.at) ?? 'at')
|
|
1538
|
+
: (name === '$asof' && probesNode !== null && spec.rightAt !== undefined
|
|
1539
|
+
? (singularSelector(spec.rightAt) ?? 'at') : 'at'));
|
|
1540
|
+
|
|
1541
|
+
if (name === '$resample') {
|
|
1542
|
+
const outcome = resampleBucket(spec, shape);
|
|
1543
|
+
if ('bucket' in outcome) {
|
|
1544
|
+
// native: the ladder, the aggregate and the count are the plan's
|
|
1545
|
+
const bucket = outcome.bucket;
|
|
1546
|
+
bucket.aggregates[0].empty = 'null';
|
|
1547
|
+
bucket.aggregates[1].empty = 'null';
|
|
1548
|
+
plan.bucket = bucket;
|
|
1549
|
+
const windowFrom = safeEpoch(spec.start);
|
|
1550
|
+
const windowTo = safeEpoch(spec.end);
|
|
1551
|
+
if (windowFrom !== null) plan.filter = conjoin(plan.filter,
|
|
1552
|
+
instantBound(bucket.ref, 'ge', windowFrom));
|
|
1553
|
+
if (windowTo !== null) plan.filter = conjoin(plan.filter,
|
|
1554
|
+
instantBound(bucket.ref, 'lt', windowTo));
|
|
1555
|
+
const facts = filterFacts(plan.filter);
|
|
1556
|
+
const index = seekingIndexFor(shape, bucket.ref.column, facts);
|
|
1557
|
+
const bound = facts.bounds.get(bucket.ref.column) ?? null;
|
|
1558
|
+
return {
|
|
1559
|
+
plan,
|
|
1560
|
+
native: inner.reasons.length === 0,
|
|
1561
|
+
reasons: inner.reasons,
|
|
1562
|
+
prefilters,
|
|
1563
|
+
series: seriesRecord({
|
|
1564
|
+
mode: 'native',
|
|
1565
|
+
operation: 'resample',
|
|
1566
|
+
index: index === null ? null : index.name,
|
|
1567
|
+
prefix: index === null ? [] : index.prefix,
|
|
1568
|
+
range: bound === null ? null : { column: bucket.ref.column, ...bound },
|
|
1569
|
+
ladder: { every: bucket.every, origin: bucket.origin, calendar: false },
|
|
1570
|
+
aggregates: bucket.aggregates.map((a) => a.as),
|
|
1571
|
+
reasons: index === null ? [seriesReason('missing-series-prefix', name)] : [],
|
|
1572
|
+
}),
|
|
1573
|
+
};
|
|
1574
|
+
}
|
|
1575
|
+
reasons.push(seriesReason(outcome.code, name));
|
|
1576
|
+
}
|
|
1577
|
+
else if (name === '$rolling') {
|
|
1578
|
+
reasons.push(seriesReason('rolling-refinement', name));
|
|
1579
|
+
}
|
|
1580
|
+
else {
|
|
1581
|
+
reasons.push(seriesReason('asof-refinement', name));
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
// the refinement: narrow through the index by whatever the spec makes
|
|
1585
|
+
// provable, and let the engine's own kernel decide over what comes back
|
|
1586
|
+
let range = null;
|
|
1587
|
+
if (name === '$resample' && at.column !== null) {
|
|
1588
|
+
const from = safeEpoch(spec.start);
|
|
1589
|
+
const to = safeEpoch(spec.end);
|
|
1590
|
+
if (from !== null || to !== null) {
|
|
1591
|
+
if (from !== null) plan.filter = conjoin(plan.filter, instantBound(at, 'ge', from));
|
|
1592
|
+
if (to !== null) plan.filter = conjoin(plan.filter, instantBound(at, 'lt', to));
|
|
1593
|
+
range = { column: at.column, from, fromOp: from === null ? null : 'ge',
|
|
1594
|
+
to, toOp: to === null ? null : 'lt' };
|
|
1595
|
+
prefilters.push({ construct: name, via: 'columns', columns: [at.column], exact: false });
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1598
|
+
else if (name === '$asof' && probesNode !== null) {
|
|
1599
|
+
const probes = literalInstants(probesNode, spec.leftAt, spec.by);
|
|
1600
|
+
if (probes !== null && at.column !== null) {
|
|
1601
|
+
const tolerance = toleranceMs(spec.tolerance);
|
|
1602
|
+
const direction = spec.direction ?? 'backward';
|
|
1603
|
+
let from = null;
|
|
1604
|
+
let to = null;
|
|
1605
|
+
if (direction === 'backward') {
|
|
1606
|
+
to = probes.max;
|
|
1607
|
+
if (tolerance !== null) from = probes.min - tolerance;
|
|
1608
|
+
}
|
|
1609
|
+
else if (direction === 'forward') {
|
|
1610
|
+
from = probes.min;
|
|
1611
|
+
if (tolerance !== null) to = probes.max + tolerance;
|
|
1612
|
+
}
|
|
1613
|
+
else if (tolerance !== null) {
|
|
1614
|
+
from = probes.min - tolerance;
|
|
1615
|
+
to = probes.max + tolerance;
|
|
1616
|
+
}
|
|
1617
|
+
const bounds = impliedInstantBounds(at, from, to);
|
|
1618
|
+
for (const pred of bounds.preds) plan.filter = conjoin(plan.filter, pred);
|
|
1619
|
+
if (bounds.preds.length > 0) {
|
|
1620
|
+
range = bounds.range;
|
|
1621
|
+
prefilters.push({ construct: name, via: 'columns', columns: [at.column], exact: false });
|
|
1622
|
+
}
|
|
1623
|
+
}
|
|
1624
|
+
// and the keys, whether or not the instant has a column of its own:
|
|
1625
|
+
// a right row whose group no left row names can match nothing, so a
|
|
1626
|
+
// membership test over the probes' own keys narrows and never drops
|
|
1627
|
+
if (probes !== null && probes.keys !== null && probes.keys.length > 0) {
|
|
1628
|
+
const byRef = memberRef(shape, /** @type {string} */ (singularSelector(spec.by)));
|
|
1629
|
+
if (byRef.column !== null) {
|
|
1630
|
+
plan.filter = conjoin(plan.filter, probes.keys.length === 1
|
|
1631
|
+
? { p: 'cmp', op: 'eq', ref: byRef, operand: { lit: probes.keys[0] } }
|
|
1632
|
+
: { p: 'or', items: probes.keys.map((key) =>
|
|
1633
|
+
({ p: 'cmp', op: 'eq', ref: byRef, operand: { lit: key } })) });
|
|
1634
|
+
prefilters.push({ construct: name, via: 'columns',
|
|
1635
|
+
columns: [byRef.column], exact: false });
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
const facts = filterFacts(plan.filter);
|
|
1641
|
+
const index = seekingIndexFor(shape, at.column, facts);
|
|
1642
|
+
// one code, once: an instant with no column of its own already said
|
|
1643
|
+
// this when the ladder refused, and saying it twice reads as two facts
|
|
1644
|
+
if (index === null && !reasons.some((r) => r.code === 'missing-series-prefix'))
|
|
1645
|
+
reasons.push(seriesReason('missing-series-prefix', name));
|
|
1646
|
+
const narrowed = plan.filter !== null;
|
|
1647
|
+
return {
|
|
1648
|
+
plan,
|
|
1649
|
+
native: false,
|
|
1650
|
+
reasons: [...reasons, ...inner.reasons],
|
|
1651
|
+
prefilters,
|
|
1652
|
+
series: seriesRecord({
|
|
1653
|
+
mode: narrowed ? 'hybrid' : 'engine',
|
|
1654
|
+
operation: { $resample: 'resample', $rolling: 'rolling', $asof: 'asof-join' }[name],
|
|
1655
|
+
index: narrowed && index !== null ? index.name : null,
|
|
1656
|
+
prefix: narrowed && index !== null ? index.prefix : [],
|
|
1657
|
+
range,
|
|
1658
|
+
refinement: { $resample: 'resampleSeries', $rolling: 'rollingSeries',
|
|
1659
|
+
$asof: 'asOfJoin' }[name],
|
|
1660
|
+
reasons,
|
|
1661
|
+
}),
|
|
1662
|
+
};
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1013
1665
|
/**
|
|
1014
1666
|
* Plan a FLWOR node into a select plan, recording refusals. When a
|
|
1015
1667
|
* conjunct refuses native translation, the injected `udf` hook may
|
|
@@ -1035,20 +1687,18 @@ function planFlwor(node, shape, rawFlwor, udfHook) {
|
|
|
1035
1687
|
const plan = selectPlan(shape.collection);
|
|
1036
1688
|
|
|
1037
1689
|
// the one recognised source shape: a single plain binding over $[*]
|
|
1690
|
+
// (bare or packed — one check, `isCollectionSource`, for every site)
|
|
1038
1691
|
const binding = node.forBindings[0];
|
|
1039
|
-
const source = binding?.expr;
|
|
1040
1692
|
const sourceIsCollection = node.forBindings.length === 1
|
|
1041
|
-
&&
|
|
1042
|
-
&& source.segments.length === 1 && source.segments[0].descendant !== true
|
|
1043
|
-
&& source.segments[0].selectors.length === 1
|
|
1044
|
-
&& source.segments[0].selectors[0].kind === 'wildcard'
|
|
1693
|
+
&& isCollectionSource(binding?.expr)
|
|
1045
1694
|
&& binding.window === null && binding.atSlot === -1
|
|
1046
1695
|
&& binding.allowingEmpty === false;
|
|
1047
1696
|
if (!sourceIsCollection) {
|
|
1048
1697
|
reasons.push(refusal('$for',
|
|
1049
1698
|
'only a single plain binding over the whole collection is translated'));
|
|
1050
1699
|
return { plan, reasons, whereFullyPushed: false, orderPushed: false,
|
|
1051
|
-
projectionNative: false, itSlot: -1, itName: null, udfs: [], prefilters: [],
|
|
1700
|
+
projectionNative: false, itSlot: -1, itName: null, udfs: [], prefilters: [],
|
|
1701
|
+
knn: null, bucket: null, bucketRefusal: null };
|
|
1052
1702
|
}
|
|
1053
1703
|
const itSlot = binding.slot;
|
|
1054
1704
|
// the document's own name for the collection binding. The residual and
|
|
@@ -1060,7 +1710,22 @@ function planFlwor(node, shape, rawFlwor, udfHook) {
|
|
|
1060
1710
|
if (node.fold !== null) reasons.push(refusal('$fold', KIND_REASONS.let));
|
|
1061
1711
|
if (node.letBindings.length > 0) reasons.push(refusal('$let', KIND_REASONS.let));
|
|
1062
1712
|
if (node.asChecks !== null) reasons.push(refusal('$as', 'type assertions run in the engine'));
|
|
1063
|
-
|
|
1713
|
+
// A grouping is an unconditional residual EXCEPT in one closed shape:
|
|
1714
|
+
// a fixed-width `$time-bucket` key with the exact aggregates, which
|
|
1715
|
+
// is a `GROUP BY` over integer arithmetic. The bucket then owns the
|
|
1716
|
+
// ordering and the projection too, so it is decided before either.
|
|
1717
|
+
let bucket = null;
|
|
1718
|
+
let bucketRefusal = null;
|
|
1719
|
+
if (node.groupby !== null && node.fold === null && node.letBindings.length === 0
|
|
1720
|
+
&& node.asChecks === null && node.count === null) {
|
|
1721
|
+
const grouped = planBucketGrouping(node, itSlot, shape);
|
|
1722
|
+
if ('bucket' in grouped) bucket = grouped.bucket;
|
|
1723
|
+
else {
|
|
1724
|
+
bucketRefusal = grouped.code;
|
|
1725
|
+
reasons.push(seriesReason(grouped.code, '$groupby'));
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1728
|
+
else if (node.groupby !== null) reasons.push(refusal('$groupby', KIND_REASONS.let));
|
|
1064
1729
|
if (node.count !== null) reasons.push(refusal('$count clause', KIND_REASONS.let));
|
|
1065
1730
|
const structureClean = reasons.length === 0;
|
|
1066
1731
|
// $let and $as run BEFORE $where in clause order: a row our pushed
|
|
@@ -1116,9 +1781,10 @@ function planFlwor(node, shape, rawFlwor, udfHook) {
|
|
|
1116
1781
|
// caller's to name once the window is known)
|
|
1117
1782
|
let orderPushed = false;
|
|
1118
1783
|
let knn = null;
|
|
1119
|
-
const ranked = node.orderby === null ? null
|
|
1784
|
+
const ranked = bucket !== null || node.orderby === null ? null
|
|
1120
1785
|
: planKnnOrder(node.orderby, itSlot, shape, whereFullyPushed && structureClean);
|
|
1121
|
-
if (
|
|
1786
|
+
if (bucket !== null) orderPushed = true; // the groups' order is the bucket's
|
|
1787
|
+
else if (ranked !== null) {
|
|
1122
1788
|
if ('rank' in ranked) knn = ranked.rank;
|
|
1123
1789
|
else reasons.push(ranked.refusal);
|
|
1124
1790
|
}
|
|
@@ -1127,9 +1793,9 @@ function planFlwor(node, shape, rawFlwor, udfHook) {
|
|
|
1127
1793
|
let refused = null;
|
|
1128
1794
|
for (const spec of node.orderby.specs) {
|
|
1129
1795
|
const ref = pathRef(spec.key, itSlot, shape);
|
|
1130
|
-
if (ref === null || ref.
|
|
1796
|
+
if (ref === null || !orderable(ref, shape.schema)) {
|
|
1131
1797
|
refused = refusal('$orderby',
|
|
1132
|
-
'ordering translates only over singular schema-typed paths');
|
|
1798
|
+
'ordering translates only over singular schema-typed paths (numbers and strings that cannot hold null)');
|
|
1133
1799
|
break;
|
|
1134
1800
|
}
|
|
1135
1801
|
if (spec.collation !== null || spec.collationName !== null) {
|
|
@@ -1152,7 +1818,8 @@ function planFlwor(node, shape, rawFlwor, udfHook) {
|
|
|
1152
1818
|
// RETURN: the bare binding is the native whole-document projection
|
|
1153
1819
|
let projectionNative = false;
|
|
1154
1820
|
assertDecidedKind(node.ret);
|
|
1155
|
-
if (
|
|
1821
|
+
if (bucket !== null) projectionNative = true; // the bucket IS the projection
|
|
1822
|
+
else if (isItVar(node.ret, itSlot)) projectionNative = true;
|
|
1156
1823
|
else {
|
|
1157
1824
|
reasons.push(refusal('$return',
|
|
1158
1825
|
'projections other than the bare binding run per row (the row residual)'));
|
|
@@ -1169,6 +1836,8 @@ function planFlwor(node, shape, rawFlwor, udfHook) {
|
|
|
1169
1836
|
udfs,
|
|
1170
1837
|
prefilters,
|
|
1171
1838
|
knn,
|
|
1839
|
+
bucket,
|
|
1840
|
+
bucketRefusal,
|
|
1172
1841
|
};
|
|
1173
1842
|
}
|
|
1174
1843
|
|
|
@@ -1212,7 +1881,10 @@ function composeWindows(windows) {
|
|
|
1212
1881
|
* udfs: string[],
|
|
1213
1882
|
* prefilters: { construct: string, via: 'columns' | 'rtree',
|
|
1214
1883
|
* columns: string[], exact: boolean }[],
|
|
1884
|
+
* series: any,
|
|
1215
1885
|
* }}
|
|
1886
|
+
* `series` is the temporal record (`series.js`) when the document
|
|
1887
|
+
* asked a §8.16 question, and `null` when it did not.
|
|
1216
1888
|
*/
|
|
1217
1889
|
function planCollectionCore(document, shape, options = undefined) {
|
|
1218
1890
|
const analysis = analyzeQuery(document, analyzeOptionsFor(shape?.operators));
|
|
@@ -1224,13 +1896,13 @@ function planCollectionCore(document, shape, options = undefined) {
|
|
|
1224
1896
|
const windows = [];
|
|
1225
1897
|
while (root.kind === 'op' && root.name === '$subsequence') {
|
|
1226
1898
|
const [inner, start, length] = root.args;
|
|
1227
|
-
if (start?.kind !== 'literal' ||
|
|
1228
|
-
|| (length !== undefined && (length.kind !== 'literal' ||
|
|
1899
|
+
if (start?.kind !== 'literal' || !isWindowBound(start.value)
|
|
1900
|
+
|| (length !== undefined && (length.kind !== 'literal' || !isWindowBound(length.value)))) {
|
|
1229
1901
|
// non-literal bounds: the whole document is a set residual
|
|
1230
1902
|
return {
|
|
1231
1903
|
analysis, plan: null, mode: 'set',
|
|
1232
|
-
reasons: [refusal('$subsequence', 'window bounds must be literal numbers to push')],
|
|
1233
|
-
rowReturn: null, udfs: [], prefilters: [],
|
|
1904
|
+
reasons: [refusal('$subsequence', 'window bounds must be literal numbers to push (non-negative integers)')],
|
|
1905
|
+
rowReturn: null, udfs: [], prefilters: [], series: null,
|
|
1234
1906
|
};
|
|
1235
1907
|
}
|
|
1236
1908
|
windows.push({ offset: start.value, limit: length === undefined ? null : length.value });
|
|
@@ -1246,7 +1918,7 @@ function planCollectionCore(document, shape, options = undefined) {
|
|
|
1246
1918
|
return {
|
|
1247
1919
|
analysis, plan: null, mode: 'set',
|
|
1248
1920
|
reasons: [refusal(root.name, 'a windowed aggregate is not translated')],
|
|
1249
|
-
rowReturn: null, udfs: [], prefilters: [],
|
|
1921
|
+
rowReturn: null, udfs: [], prefilters: [], series: null,
|
|
1250
1922
|
};
|
|
1251
1923
|
}
|
|
1252
1924
|
aggregate = { name: root.name, fn: AGGREGATES.get(root.name) };
|
|
@@ -1255,12 +1927,37 @@ function planCollectionCore(document, shape, options = undefined) {
|
|
|
1255
1927
|
assertDecidedKind(root);
|
|
1256
1928
|
}
|
|
1257
1929
|
|
|
1930
|
+
// a document that IS a series operator over the collection: the
|
|
1931
|
+
// operand's own conjuncts (and what the frozen spec implies) narrow
|
|
1932
|
+
// through the index, and the kernel decides over what comes back
|
|
1933
|
+
if (aggregate === null && root.kind === 'op' && SERIES_ROOT_OPS.includes(root.name)) {
|
|
1934
|
+
const temporal = planSeriesOperator(root, shape);
|
|
1935
|
+
if (temporal !== null) {
|
|
1936
|
+
// a peeled `$subsequence` composes as it does everywhere — over a
|
|
1937
|
+
// NATIVE bucket it is a LIMIT on the ascending groups, which is
|
|
1938
|
+
// the same items the kernel's own window would have kept; over a
|
|
1939
|
+
// refinement the residual applies it, so the plan keeps none
|
|
1940
|
+
const window = windows.length === 0 ? null : composeWindows(windows);
|
|
1941
|
+
if (temporal.native && window !== null) temporal.plan.window = window;
|
|
1942
|
+
return {
|
|
1943
|
+
analysis,
|
|
1944
|
+
plan: temporal.plan,
|
|
1945
|
+
mode: temporal.native ? 'native' : 'set',
|
|
1946
|
+
reasons: temporal.native ? [] : temporal.reasons,
|
|
1947
|
+
rowReturn: null,
|
|
1948
|
+
udfs: [],
|
|
1949
|
+
prefilters: temporal.prefilters,
|
|
1950
|
+
series: temporal.series,
|
|
1951
|
+
};
|
|
1952
|
+
}
|
|
1953
|
+
}
|
|
1954
|
+
|
|
1258
1955
|
if (root.kind !== 'flwor') {
|
|
1259
1956
|
return {
|
|
1260
1957
|
analysis, plan: null, mode: 'set',
|
|
1261
1958
|
reasons: [refusal(root.kind, KIND_REASONS[root.kind]
|
|
1262
1959
|
?? 'only a FLWOR over the collection is translated')],
|
|
1263
|
-
rowReturn: null, udfs: [], prefilters: [],
|
|
1960
|
+
rowReturn: null, udfs: [], prefilters: [], series: null,
|
|
1264
1961
|
};
|
|
1265
1962
|
}
|
|
1266
1963
|
|
|
@@ -1279,7 +1976,7 @@ function planCollectionCore(document, shape, options = undefined) {
|
|
|
1279
1976
|
margin: KNN_MARGIN };
|
|
1280
1977
|
return { analysis, plan, mode: 'knn',
|
|
1281
1978
|
reasons: [refusal('$orderby', KNN_REASONS.rank), ...flwor.reasons],
|
|
1282
|
-
rowReturn: null, udfs: flwor.udfs, prefilters: flwor.prefilters };
|
|
1979
|
+
rowReturn: null, udfs: flwor.udfs, prefilters: flwor.prefilters, series: null };
|
|
1283
1980
|
}
|
|
1284
1981
|
flwor.reasons.unshift(refusal('$subsequence', KNN_REASONS.window));
|
|
1285
1982
|
}
|
|
@@ -1289,7 +1986,17 @@ function planCollectionCore(document, shape, options = undefined) {
|
|
|
1289
1986
|
// full sequence, not a narrowed candidate set)
|
|
1290
1987
|
if (!fullyPushed) {
|
|
1291
1988
|
return { analysis, plan: null, mode: 'set', reasons: flwor.reasons,
|
|
1292
|
-
rowReturn: null, udfs: [], prefilters: flwor.prefilters };
|
|
1989
|
+
rowReturn: null, udfs: [], prefilters: flwor.prefilters, series: null };
|
|
1990
|
+
}
|
|
1991
|
+
if (flwor.bucket !== null || flwor.bucketRefusal != null) {
|
|
1992
|
+
// the phrase's items are its GROUPS; a COUNT(*) over the rows
|
|
1993
|
+
// answered the row count for a `$count` of the groups
|
|
1994
|
+
return {
|
|
1995
|
+
analysis, plan: null, mode: 'set',
|
|
1996
|
+
reasons: [refusal(aggregate.name,
|
|
1997
|
+
'an aggregate over a grouped phrase folds its groups, which the engine does')],
|
|
1998
|
+
rowReturn: null, udfs: [], prefilters: [], series: null,
|
|
1999
|
+
};
|
|
1293
2000
|
}
|
|
1294
2001
|
if (aggregate.fn === 'count') {
|
|
1295
2002
|
if (!flwor.projectionNative) {
|
|
@@ -1297,36 +2004,64 @@ function planCollectionCore(document, shape, options = undefined) {
|
|
|
1297
2004
|
analysis, plan: null, mode: 'set',
|
|
1298
2005
|
reasons: [refusal('$count',
|
|
1299
2006
|
'count translates only over the bare binding (a projected return can change the item count)')],
|
|
1300
|
-
rowReturn: null, udfs: [], prefilters: [],
|
|
2007
|
+
rowReturn: null, udfs: [], prefilters: [], series: null,
|
|
1301
2008
|
};
|
|
1302
2009
|
}
|
|
1303
2010
|
plan.aggregate = { fn: 'count', ref: null };
|
|
1304
2011
|
return { analysis, plan, mode: 'native', reasons: [], rowReturn: null,
|
|
1305
|
-
udfs: flwor.udfs, prefilters: flwor.prefilters
|
|
2012
|
+
udfs: flwor.udfs, prefilters: flwor.prefilters,
|
|
2013
|
+
series: classifySelection(plan, shape, true) };
|
|
1306
2014
|
}
|
|
1307
2015
|
const ref = pathRef(root.ret, flwor.itSlot, shape);
|
|
1308
2016
|
const numeric = aggregate.fn === 'sum' || aggregate.fn === 'avg';
|
|
1309
2017
|
const acceptable = ref !== null
|
|
1310
|
-
&& (numeric ? isNumericType(ref.type) : ref.type !== 'unknown')
|
|
2018
|
+
&& (numeric ? isNumericType(ref.type) : ref.type !== 'unknown')
|
|
2019
|
+
&& ref.type !== 'boolean' && !admitsNull(shape.schema, ref.segments);
|
|
1311
2020
|
if (!acceptable) {
|
|
1312
2021
|
return {
|
|
1313
2022
|
analysis, plan: null, mode: 'set',
|
|
1314
2023
|
reasons: [refusal(aggregate.name,
|
|
1315
2024
|
'aggregates translate only over a singular schema-typed path (the engine ERRORS on non-conforming operands)')],
|
|
1316
|
-
rowReturn: null, udfs: [], prefilters: [],
|
|
2025
|
+
rowReturn: null, udfs: [], prefilters: [], series: null,
|
|
1317
2026
|
};
|
|
1318
2027
|
}
|
|
1319
2028
|
plan.aggregate = { fn: /** @type {any} */ (aggregate.fn), ref };
|
|
1320
2029
|
return { analysis, plan, mode: 'native', reasons: [], rowReturn: null,
|
|
1321
|
-
udfs: flwor.udfs, prefilters: flwor.prefilters
|
|
2030
|
+
udfs: flwor.udfs, prefilters: flwor.prefilters,
|
|
2031
|
+
series: classifySelection(plan, shape, true) };
|
|
1322
2032
|
}
|
|
1323
2033
|
|
|
1324
2034
|
// windows push only onto a fully pushed selection
|
|
1325
2035
|
if (windows.length > 0 && fullyPushed) plan.window = composeWindows(windows);
|
|
1326
2036
|
|
|
2037
|
+
// the temporal bucket: only over a WHOLE pushed selection, because a
|
|
2038
|
+
// conjunct the residual would still apply would arrive after the rows
|
|
2039
|
+
// were already summed
|
|
2040
|
+
if (flwor.bucket !== null && fullyPushed && (windows.length === 0 || plan.window !== null)) {
|
|
2041
|
+
plan.bucket = flwor.bucket;
|
|
2042
|
+
const facts = filterFacts(plan.filter);
|
|
2043
|
+
const index = seekingIndexFor(shape, plan.bucket.ref.column, facts);
|
|
2044
|
+
const bound = facts.bounds.get(plan.bucket.ref.column) ?? null;
|
|
2045
|
+
return {
|
|
2046
|
+
analysis, plan, mode: 'native', reasons: [], rowReturn: null,
|
|
2047
|
+
udfs: flwor.udfs, prefilters: flwor.prefilters,
|
|
2048
|
+
series: seriesRecord({
|
|
2049
|
+
mode: 'native',
|
|
2050
|
+
operation: 'bucket',
|
|
2051
|
+
index: index === null ? null : index.name,
|
|
2052
|
+
prefix: index === null ? [] : index.prefix,
|
|
2053
|
+
range: bound === null ? null : { column: plan.bucket.ref.column, ...bound },
|
|
2054
|
+
ladder: { every: plan.bucket.every, origin: plan.bucket.origin, calendar: false },
|
|
2055
|
+
aggregates: plan.bucket.aggregates.map((a) => a.as),
|
|
2056
|
+
reasons: index === null ? [seriesReason('missing-series-prefix', '$groupby')] : [],
|
|
2057
|
+
}),
|
|
2058
|
+
};
|
|
2059
|
+
}
|
|
2060
|
+
|
|
1327
2061
|
if (fullyPushed && flwor.projectionNative && (windows.length === 0 || plan.window !== null)) {
|
|
1328
2062
|
return { analysis, plan, mode: 'native', reasons: [], rowReturn: null,
|
|
1329
|
-
udfs: flwor.udfs, prefilters: flwor.prefilters
|
|
2063
|
+
udfs: flwor.udfs, prefilters: flwor.prefilters,
|
|
2064
|
+
series: classifySelection(plan, shape, flwor.orderPushed) };
|
|
1330
2065
|
}
|
|
1331
2066
|
|
|
1332
2067
|
// the row residual: everything but the projection pushed
|
|
@@ -1348,14 +2083,20 @@ function planCollectionCore(document, shape, options = undefined) {
|
|
|
1348
2083
|
},
|
|
1349
2084
|
udfs: flwor.udfs,
|
|
1350
2085
|
prefilters: flwor.prefilters,
|
|
2086
|
+
series: flwor.bucketRefusal == null
|
|
2087
|
+
? classifySelection(plan, shape, flwor.orderPushed)
|
|
2088
|
+
: refinedGrouping(plan, shape, flwor.bucketRefusal, '$groupby'),
|
|
1351
2089
|
};
|
|
1352
2090
|
}
|
|
1353
2091
|
|
|
1354
2092
|
// the set residual: pushed conjuncts narrow, the engine answers
|
|
2093
|
+
const narrowing = flwor.bucketRefusal == null
|
|
2094
|
+
? classifySelection(plan, shape, false)
|
|
2095
|
+
: refinedGrouping(plan, shape, flwor.bucketRefusal, '$groupby');
|
|
1355
2096
|
plan.order = null;
|
|
1356
2097
|
plan.window = null;
|
|
1357
2098
|
return { analysis, plan, mode: 'set', reasons: flwor.reasons, rowReturn: null,
|
|
1358
|
-
udfs: flwor.udfs, prefilters: flwor.prefilters };
|
|
2099
|
+
udfs: flwor.udfs, prefilters: flwor.prefilters, series: narrowing };
|
|
1359
2100
|
}
|
|
1360
2101
|
|
|
1361
2102
|
/**
|
|
@@ -1376,13 +2117,34 @@ function planCollectionCore(document, shape, options = undefined) {
|
|
|
1376
2117
|
* udfs: string[],
|
|
1377
2118
|
* prefilters: { construct: string, via: 'columns' | 'rtree',
|
|
1378
2119
|
* columns: string[], exact: boolean }[],
|
|
2120
|
+
* series: any,
|
|
1379
2121
|
* }}
|
|
1380
2122
|
*/
|
|
1381
2123
|
export function planQuery(document, shape, options = undefined) {
|
|
1382
|
-
const
|
|
2124
|
+
const peeled = peelWrappedResult(document);
|
|
2125
|
+
const planned = { ...planCollectionCore(peeled.document, shape, options), wrapped: peeled.wrapped };
|
|
1383
2126
|
return prependRegisteredReason(planned, document, shape?.operators);
|
|
1384
2127
|
}
|
|
1385
2128
|
|
|
2129
|
+
/**
|
|
2130
|
+
* A chain's element terminal wraps its phrase in a one-item array
|
|
2131
|
+
* constructor — `[<phrase>]`, the window that keeps an array-valued item
|
|
2132
|
+
* one item (QUERY-PEN §6) — so the document a store receives from
|
|
2133
|
+
* `toArray()`/`first()` is that constructor around the phrase. Read
|
|
2134
|
+
* through it: the phrase inside plans as it would bare, and the engines
|
|
2135
|
+
* answer its rows as the ONE array the constructor yields (`wrapped`),
|
|
2136
|
+
* which is exactly the engine's own answer for the document. Anything
|
|
2137
|
+
* else inside the brackets plans as itself and falls to the residual,
|
|
2138
|
+
* where the whole document — brackets included — runs in the engine.
|
|
2139
|
+
* @param {any} document
|
|
2140
|
+
* @returns {{ document: any, wrapped: boolean }}
|
|
2141
|
+
*/
|
|
2142
|
+
function peelWrappedResult(document) {
|
|
2143
|
+
return Array.isArray(document) && document.length === 1
|
|
2144
|
+
? { document: document[0], wrapped: true }
|
|
2145
|
+
: { document, wrapped: false };
|
|
2146
|
+
}
|
|
2147
|
+
|
|
1386
2148
|
// ————— The entity document kind (one planner, two document kinds) —————
|
|
1387
2149
|
|
|
1388
2150
|
/**
|
|
@@ -1400,7 +2162,7 @@ export function entityShape(entity, entityMapping) {
|
|
|
1400
2162
|
const flavors = new Map();
|
|
1401
2163
|
for (const column of entityMapping.columns) {
|
|
1402
2164
|
const epoch = column.source === 'epoch(document)';
|
|
1403
|
-
flavors.set(
|
|
2165
|
+
flavors.set(canonicalOf([{ name: column.name }]), {
|
|
1404
2166
|
column: column.name,
|
|
1405
2167
|
flavor: epoch ? 'entity-epoch' : 'entity-column',
|
|
1406
2168
|
storage: column.storage,
|
|
@@ -1408,8 +2170,9 @@ export function entityShape(entity, entityMapping) {
|
|
|
1408
2170
|
});
|
|
1409
2171
|
}
|
|
1410
2172
|
for (const fk of entityMapping.foreignKeys) {
|
|
1411
|
-
|
|
1412
|
-
|
|
2173
|
+
const fkCanonical = canonicalOf([{ name: fk.column }]);
|
|
2174
|
+
if (!flavors.has(fkCanonical))
|
|
2175
|
+
flavors.set(fkCanonical, { column: fk.column, flavor: 'entity-column', storage: 'string' });
|
|
1413
2176
|
}
|
|
1414
2177
|
return {
|
|
1415
2178
|
collection: entity.name,
|
|
@@ -1430,8 +2193,7 @@ export function entityShape(entity, entityMapping) {
|
|
|
1430
2193
|
export function entityPathRef(node, slot, shape) {
|
|
1431
2194
|
const ref = pathRef(node, slot, shape);
|
|
1432
2195
|
if (ref === null) return null;
|
|
1433
|
-
const canonical = ref.segments
|
|
1434
|
-
.map((s) => ('name' in s ? `.${s.name}` : `[${s.index}]`)).join('');
|
|
2196
|
+
const canonical = canonicalOf(ref.segments);
|
|
1435
2197
|
const flavored = shape.entityFlavors.get(canonical);
|
|
1436
2198
|
if (flavored !== undefined) {
|
|
1437
2199
|
return {
|
|
@@ -1467,8 +2229,7 @@ export function planEntityPredicate(node, slot, shape) {
|
|
|
1467
2229
|
return { ...pred, items: pred.items.map(reflavor) };
|
|
1468
2230
|
if (pred.p === 'not') return { ...pred, item: reflavor(pred.item) };
|
|
1469
2231
|
if (!('ref' in pred) || pred.ref === null) return pred;
|
|
1470
|
-
const canonical = pred.ref.segments
|
|
1471
|
-
.map((s) => ('name' in s ? `.${s.name}` : `[${s.index}]`)).join('');
|
|
2232
|
+
const canonical = canonicalOf(pred.ref.segments);
|
|
1472
2233
|
const flavored = shape.entityFlavors.get(canonical);
|
|
1473
2234
|
if (flavored === undefined) {
|
|
1474
2235
|
// externals against DOC paths are not translated here (the
|
|
@@ -1538,9 +2299,9 @@ function planEntityQueryCore(document, entities, mapping, operators) {
|
|
|
1538
2299
|
const windows = [];
|
|
1539
2300
|
while (root.kind === 'op' && root.name === '$subsequence') {
|
|
1540
2301
|
const [inner, start, length] = root.args;
|
|
1541
|
-
if (start?.kind !== 'literal' ||
|
|
1542
|
-
|| (length !== undefined && (length.kind !== 'literal' ||
|
|
1543
|
-
return residual('$subsequence', 'window bounds must be literal numbers to push');
|
|
2302
|
+
if (start?.kind !== 'literal' || !isWindowBound(start.value)
|
|
2303
|
+
|| (length !== undefined && (length.kind !== 'literal' || !isWindowBound(length.value))))
|
|
2304
|
+
return residual('$subsequence', 'window bounds must be literal numbers to push (non-negative integers)');
|
|
1544
2305
|
windows.push({ offset: start.value, limit: length === undefined ? null : length.value });
|
|
1545
2306
|
root = inner;
|
|
1546
2307
|
assertDecidedKind(root);
|
|
@@ -1560,7 +2321,7 @@ function planEntityQueryCore(document, entities, mapping, operators) {
|
|
|
1560
2321
|
// bindings must each range over one entity's array
|
|
1561
2322
|
const bindings = [];
|
|
1562
2323
|
for (const binding of root.forBindings) {
|
|
1563
|
-
const source = binding.expr;
|
|
2324
|
+
const source = unpacked(binding.expr);
|
|
1564
2325
|
const sourceEntity = source?.kind === 'path' && source.name === '$'
|
|
1565
2326
|
&& source.external !== true && source.segments.length === 2
|
|
1566
2327
|
&& source.segments[0].descendant !== true
|
|
@@ -1654,11 +2415,16 @@ function planEntityQueryCore(document, entities, mapping, operators) {
|
|
|
1654
2415
|
const binding = byName.get(slot);
|
|
1655
2416
|
const ref = binding === undefined
|
|
1656
2417
|
? null : entityPathRef(spec.key, slot, binding.shape);
|
|
2418
|
+
// a boolean orders in SQL and is `JQ2005` in the engine on either
|
|
2419
|
+
// flavor; a document path that admits null stores a present null
|
|
2420
|
+
// (a COLUMN stores it absent, §9.3, so a nullable column pushes)
|
|
1657
2421
|
if (ref === null || (ref.flavor === 'entity-doc' && ref.type === 'unknown')
|
|
2422
|
+
|| ref.type === 'boolean'
|
|
2423
|
+
|| (ref.flavor === 'entity-doc' && admitsNull(binding.shape.schema, ref.segments))
|
|
1658
2424
|
|| spec.collation !== null || spec.collationName !== null) {
|
|
1659
2425
|
orderPushed = false;
|
|
1660
2426
|
reasons.push({ construct: '$orderby',
|
|
1661
|
-
reason: 'ordering translates only over typed entity paths' });
|
|
2427
|
+
reason: 'ordering translates only over typed entity paths (never a boolean, never a document path that admits null)' });
|
|
1662
2428
|
break;
|
|
1663
2429
|
}
|
|
1664
2430
|
terms.push({ binding, ref, desc: spec.desc === true, emptyGreatest: spec.emptyGreatest === true });
|
|
@@ -1724,7 +2490,11 @@ function planEntityQueryCore(document, entities, mapping, operators) {
|
|
|
1724
2490
|
* @returns {any}
|
|
1725
2491
|
*/
|
|
1726
2492
|
export function planEntityQuery(document, entities, mapping, operators = null) {
|
|
1727
|
-
const
|
|
2493
|
+
const peeled = peelWrappedResult(document);
|
|
2494
|
+
const planned = {
|
|
2495
|
+
...planEntityQueryCore(peeled.document, entities, mapping, operators),
|
|
2496
|
+
wrapped: peeled.wrapped,
|
|
2497
|
+
};
|
|
1728
2498
|
return prependRegisteredReason(planned, document, operators);
|
|
1729
2499
|
}
|
|
1730
2500
|
|
|
@@ -1742,13 +2512,30 @@ function collectBindingSlots(node, byName, slots) {
|
|
|
1742
2512
|
}
|
|
1743
2513
|
}
|
|
1744
2514
|
|
|
2515
|
+
/**
|
|
2516
|
+
* The root expression an entity's rows are bound through — the ONE
|
|
2517
|
+
* spelling of `$.<Name>[*]`: what an entity set exposes as its `root`
|
|
2518
|
+
* (the hint a chain reads), and what {@link collectEntityRoots}
|
|
2519
|
+
* recognises in a document. Two spellings would let a handle publish a
|
|
2520
|
+
* root the planner does not read.
|
|
2521
|
+
* @param {string} name - a declared entity name
|
|
2522
|
+
* @returns {string}
|
|
2523
|
+
*/
|
|
2524
|
+
export function entityRoot(name) {
|
|
2525
|
+
return `$.${name}[*]`;
|
|
2526
|
+
}
|
|
2527
|
+
|
|
1745
2528
|
/** The entity names a document's root paths reference (`$.Name[*]`). */
|
|
1746
2529
|
export function collectEntityRoots(document, entities) {
|
|
1747
2530
|
const found = new Set();
|
|
1748
2531
|
const walk = (node) => {
|
|
1749
2532
|
if (typeof node === 'string') {
|
|
1750
|
-
const
|
|
1751
|
-
|
|
2533
|
+
for (const name of entities.keys()) {
|
|
2534
|
+
if (node.startsWith(entityRoot(name))) {
|
|
2535
|
+
found.add(name);
|
|
2536
|
+
break;
|
|
2537
|
+
}
|
|
2538
|
+
}
|
|
1752
2539
|
return;
|
|
1753
2540
|
}
|
|
1754
2541
|
if (Array.isArray(node)) {
|