@jarenjs/db 0.46.5 → 0.49.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +106 -2
- package/README.md +136 -2
- package/dist/types/algebra.d.ts +34 -3
- package/dist/types/dialect.d.ts +5 -0
- package/dist/types/errors.d.ts +3 -0
- package/dist/types/live-time.d.ts +141 -0
- package/dist/types/live.d.ts +3 -1
- package/dist/types/plan.d.ts +2 -0
- package/dist/types/query.d.ts +2 -1
- package/dist/types/residual.d.ts +5 -2
- package/dist/types/series.d.ts +227 -0
- package/dist/types/store.d.ts +8 -1
- package/docs/LIVE-FORMAT.md +103 -0
- package/docs/MODEL-FORMAT.md +19 -0
- package/package.json +4 -4
- package/src/algebra.js +22 -3
- package/src/dialect.js +13 -0
- package/src/dialects/sqlite.js +19 -0
- package/src/emit.js +32 -6
- package/src/errors.js +3 -0
- package/src/live-time.js +596 -0
- package/src/live.js +41 -8
- package/src/plan.js +706 -16
- package/src/query.js +160 -11
- package/src/residual.js +15 -6
- package/src/series.js +349 -0
- package/src/store.js +22 -3
- package/types/index.d.ts +54 -2
package/src/plan.js
CHANGED
|
@@ -41,6 +41,7 @@ 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';
|
|
@@ -50,6 +51,11 @@ import {
|
|
|
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([
|
|
@@ -1010,6 +1016,608 @@ function planPredicate(node, itSlot, shape) {
|
|
|
1010
1016
|
'no native spelling of this operator is proven equivalent') };
|
|
1011
1017
|
}
|
|
1012
1018
|
|
|
1019
|
+
// ————— Time series: the three closed shapes over a declared index —————
|
|
1020
|
+
//
|
|
1021
|
+
// The physical feature is one a model already has: a composite index
|
|
1022
|
+
// over `[$.series, $.at]`. There is no `derive: 'series'`, no column
|
|
1023
|
+
// type and no host function — D9's whole point is that a declared
|
|
1024
|
+
// numeric epoch column is already 52× reading the instant back out of
|
|
1025
|
+
// the document, so the work here is recognizing which questions that
|
|
1026
|
+
// index can answer rather than inventing a place to put time.
|
|
1027
|
+
//
|
|
1028
|
+
// Three shapes are recognized, and they are CLOSED:
|
|
1029
|
+
//
|
|
1030
|
+
// 1. **range** — every leading column of an instant index pinned by an
|
|
1031
|
+
// equality, a half-open range on the instant column, ordered by it.
|
|
1032
|
+
// Already a native selection; what this adds is the NAME of the
|
|
1033
|
+
// operation, the index it seeks, and the honest reason when the
|
|
1034
|
+
// prefix is missing.
|
|
1035
|
+
// 2. **as-of** — the same prefix with ONE instant bound, ordered by
|
|
1036
|
+
// the instant, cut to a finite window. §2.2's 0.003 ms row.
|
|
1037
|
+
// 3. **bucket** — a fixed-width ladder over the instant column with
|
|
1038
|
+
// the exact `sum|mean|min|max|count` aggregates, in both spellings
|
|
1039
|
+
// the language has for it: a `$groupby` whose key is
|
|
1040
|
+
// `$time-bucket`, and a `$resample` whose spec asks for nothing a
|
|
1041
|
+
// `GROUP BY` cannot do.
|
|
1042
|
+
//
|
|
1043
|
+
// Everything else is a NAMED core refinement: the fetch narrows through
|
|
1044
|
+
// the index and the residual — which is the engine running the caller's
|
|
1045
|
+
// own document — decides. That is what keeps a refinement idempotent,
|
|
1046
|
+
// and it is why a calendar ladder, a fill policy, a rolling window and
|
|
1047
|
+
// an as-of JOIN cost a bounded fetch rather than a wrong answer.
|
|
1048
|
+
|
|
1049
|
+
/** Is this node the whole collection — `$[*]` over the input document? */
|
|
1050
|
+
function isCollectionSource(node) {
|
|
1051
|
+
return node?.kind === 'path' && node.name === '$' && node.external !== true
|
|
1052
|
+
&& node.rootSlot === 0 && node.singular !== true
|
|
1053
|
+
&& node.segments.length === 1 && node.segments[0].descendant !== true
|
|
1054
|
+
&& node.segments[0].selectors.length === 1
|
|
1055
|
+
&& node.segments[0].selectors[0].kind === 'wildcard';
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
/**
|
|
1059
|
+
* The collection-side operand of a root series operator: the bare
|
|
1060
|
+
* `$[*]`, or a FLWOR over it whose `$where` is the narrowing.
|
|
1061
|
+
* @param {any} node
|
|
1062
|
+
* @returns {{ flwor: any } | null}
|
|
1063
|
+
*/
|
|
1064
|
+
function collectionOperand(node) {
|
|
1065
|
+
if (isCollectionSource(node)) return { flwor: null };
|
|
1066
|
+
if (node?.kind === 'flwor' && node.forBindings.length === 1
|
|
1067
|
+
&& isCollectionSource(node.forBindings[0]?.expr))
|
|
1068
|
+
return { flwor: node };
|
|
1069
|
+
return null;
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
/**
|
|
1073
|
+
* A typed reference to one top-level member of the collection's
|
|
1074
|
+
* documents — what a spec's row selector ultimately names.
|
|
1075
|
+
* @param {any} shape
|
|
1076
|
+
* @param {string} name
|
|
1077
|
+
* @returns {import('./algebra.js').PlanRef}
|
|
1078
|
+
*/
|
|
1079
|
+
function memberRef(shape, name) {
|
|
1080
|
+
const segments = [{ name }];
|
|
1081
|
+
return {
|
|
1082
|
+
segments,
|
|
1083
|
+
type: typeOfPath(shape.schema, segments),
|
|
1084
|
+
column: shape.columnByCanonical.get(`.${name}`) ?? null,
|
|
1085
|
+
};
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
/**
|
|
1089
|
+
* The instants a plan-time literal series carries, under one selector.
|
|
1090
|
+
* `null` when the operand is not a literal array of records, or when
|
|
1091
|
+
* one of them names no instant — either way the planner has no bound to
|
|
1092
|
+
* add and says so rather than guessing one.
|
|
1093
|
+
* @param {any} node
|
|
1094
|
+
* @param {any} selector - the spec's `leftAt`/`rightAt`, or undefined
|
|
1095
|
+
* @returns {{ min: number, max: number, keys: any[] | null } | null}
|
|
1096
|
+
*/
|
|
1097
|
+
function literalInstants(node, selector, keySelector) {
|
|
1098
|
+
const constant = constantOf(node);
|
|
1099
|
+
if (constant === null || !Array.isArray(constant.value) || constant.value.length === 0)
|
|
1100
|
+
return null;
|
|
1101
|
+
const at = selector === undefined ? 'at' : singularSelector(selector);
|
|
1102
|
+
const by = keySelector === undefined ? null : singularSelector(keySelector);
|
|
1103
|
+
if (at === null || (keySelector !== undefined && by === null)) return null;
|
|
1104
|
+
let min = Infinity;
|
|
1105
|
+
let max = -Infinity;
|
|
1106
|
+
const keys = by === null ? null : [];
|
|
1107
|
+
for (const row of constant.value) {
|
|
1108
|
+
if (row === null || typeof row !== 'object') return null;
|
|
1109
|
+
const instant = row[at];
|
|
1110
|
+
if (typeof instant !== 'number' || !Number.isFinite(instant)) return null;
|
|
1111
|
+
if (instant < min) min = instant;
|
|
1112
|
+
if (instant > max) max = instant;
|
|
1113
|
+
if (keys !== null) {
|
|
1114
|
+
const key = row[by];
|
|
1115
|
+
if (typeof key !== 'string' && typeof key !== 'number') return null;
|
|
1116
|
+
if (!keys.includes(key)) keys.push(key);
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
return { min, max, keys };
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
/**
|
|
1123
|
+
* The native bucket a `$resample` spec asks for, or the FIRST reason it
|
|
1124
|
+
* is a refinement instead. The rules are asked in one fixed order, so a
|
|
1125
|
+
* spec always names the same reason on every host.
|
|
1126
|
+
* @param {any} spec - the frozen literal
|
|
1127
|
+
* @param {any} shape
|
|
1128
|
+
* @returns {{ bucket: import('./algebra.js').PlanBucket } | { code: string }}
|
|
1129
|
+
*/
|
|
1130
|
+
function resampleBucket(spec, shape) {
|
|
1131
|
+
// the clock first, because a named zone is resolved by HOST code the
|
|
1132
|
+
// planner does not have: asking the kernel about it would report the
|
|
1133
|
+
// missing provider rather than the reason a ladder is not native
|
|
1134
|
+
if (spec.zone !== undefined && spec.zone !== 'UTC') return { code: 'named-zone' };
|
|
1135
|
+
// The kernel's own rules, asked once, by running it over NO rows —
|
|
1136
|
+
// order 04's trick, for order 04's reason. `analyzeQuery` does not
|
|
1137
|
+
// compile an operator, so a spec the kernel refuses reaches the
|
|
1138
|
+
// planner before the engine has had its say, and a plan that answered
|
|
1139
|
+
// where the engine raises is the one thing a pushdown may never do.
|
|
1140
|
+
try {
|
|
1141
|
+
resampleSeries([], spec);
|
|
1142
|
+
}
|
|
1143
|
+
catch {
|
|
1144
|
+
return { code: 'invalid-spec' };
|
|
1145
|
+
}
|
|
1146
|
+
if (spec.fill !== undefined && spec.fill !== 'omit') return { code: 'fill-policy' };
|
|
1147
|
+
const fn = NATIVE_AGGREGATES[spec.aggregate ?? 'mean'];
|
|
1148
|
+
if (fn === undefined) return { code: 'unsupported-aggregate' };
|
|
1149
|
+
const ladder = fixedLadder(spec, compileBuckets);
|
|
1150
|
+
if ('code' in ladder) return ladder;
|
|
1151
|
+
// a row selector is a singular path whose `$` is the ROW, so it names
|
|
1152
|
+
// a member — and a member is what a declared column stands for. One
|
|
1153
|
+
// that names a path INTO a member names no column, and says so
|
|
1154
|
+
const atName = spec.at === undefined ? 'at' : singularSelector(spec.at);
|
|
1155
|
+
const valueName = spec.value === undefined ? 'value' : singularSelector(spec.value);
|
|
1156
|
+
if (atName === null || valueName === null) return { code: 'row-selector' };
|
|
1157
|
+
const at = memberRef(shape, atName);
|
|
1158
|
+
const instantBad = instantRefusal(at);
|
|
1159
|
+
if (instantBad !== null) return { code: instantBad };
|
|
1160
|
+
const value = memberRef(shape, valueName);
|
|
1161
|
+
if (fn !== 'rows') {
|
|
1162
|
+
const valueBad = valueRefusal(value);
|
|
1163
|
+
if (valueBad !== null) return { code: valueBad };
|
|
1164
|
+
}
|
|
1165
|
+
// D5's shape, exactly: the bucket's start, its reading, and the count
|
|
1166
|
+
// of SOURCE rows — which is `COUNT(*)` whether or not it is also the
|
|
1167
|
+
// answer, because `aggregate: 'count'` returns that same number
|
|
1168
|
+
return { bucket: {
|
|
1169
|
+
ref: at,
|
|
1170
|
+
every: ladder.every,
|
|
1171
|
+
origin: ladder.origin,
|
|
1172
|
+
as: 'at',
|
|
1173
|
+
order: 'asc',
|
|
1174
|
+
aggregates: [
|
|
1175
|
+
{ fn: /** @type {any} */ (fn), ref: fn === 'rows' ? null : value, as: 'value' },
|
|
1176
|
+
{ fn: /** @type {any} */ ('rows'), ref: null, as: 'count' },
|
|
1177
|
+
],
|
|
1178
|
+
} };
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
/**
|
|
1182
|
+
* The ladder a `$groupby` key spells, when the key is a `$time-bucket`
|
|
1183
|
+
* over a member path with literal width and origin.
|
|
1184
|
+
* @param {any} key - the grouping key expression node
|
|
1185
|
+
* @param {number} itSlot
|
|
1186
|
+
* @param {any} shape
|
|
1187
|
+
* @returns {{ ref: any, every: number, origin: number } | { code: string } | null}
|
|
1188
|
+
* `null` when the key is not a `$time-bucket` at all
|
|
1189
|
+
*/
|
|
1190
|
+
function groupLadder(key, itSlot, shape) {
|
|
1191
|
+
if (key?.kind !== 'op' || key.name !== '$time-bucket') return null;
|
|
1192
|
+
const [atNode, everyNode, originNode, contextNode] = key.args;
|
|
1193
|
+
if (everyNode.kind !== 'literal') return { code: 'nonliteral-spec' };
|
|
1194
|
+
if (originNode !== undefined && originNode.kind !== 'literal')
|
|
1195
|
+
return { code: 'nonliteral-spec' };
|
|
1196
|
+
/** @type {any} */
|
|
1197
|
+
const spec = { every: everyNode.value };
|
|
1198
|
+
if (originNode !== undefined && originNode.value !== null) spec.origin = originNode.value;
|
|
1199
|
+
if (contextNode !== undefined) {
|
|
1200
|
+
if (contextNode.kind !== 'raw') return { code: 'nonliteral-spec' };
|
|
1201
|
+
const context = contextNode.value;
|
|
1202
|
+
if (context?.zone !== undefined) return { code: 'named-zone' };
|
|
1203
|
+
if (context?.offset !== undefined) spec.offset = context.offset;
|
|
1204
|
+
}
|
|
1205
|
+
const ladder = fixedLadder(spec, compileBuckets);
|
|
1206
|
+
if ('code' in ladder) return ladder;
|
|
1207
|
+
const ref = pathRef(atNode, itSlot, shape);
|
|
1208
|
+
const instantBad = instantRefusal(ref);
|
|
1209
|
+
if (instantBad !== null) return { code: instantBad };
|
|
1210
|
+
return { ref, every: ladder.every, origin: ladder.origin };
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
/**
|
|
1214
|
+
* The closed projection of a bucket grouping: one member per answered
|
|
1215
|
+
* value, each of them the group key, a `$count` of the whole binding,
|
|
1216
|
+
* or one of the four value aggregates over a schema-typed numeric path.
|
|
1217
|
+
* @param {any} ret - the `$return` node
|
|
1218
|
+
* @param {number} itSlot
|
|
1219
|
+
* @param {number} keySlot
|
|
1220
|
+
* @param {any} shape
|
|
1221
|
+
* @returns {{ as: string, aggregates: any[] } | { code: string }}
|
|
1222
|
+
*/
|
|
1223
|
+
function bucketProjection(ret, itSlot, keySlot, shape) {
|
|
1224
|
+
if (ret?.kind !== 'object') return { code: 'nonnative-grouping' };
|
|
1225
|
+
let as = null;
|
|
1226
|
+
const aggregates = [];
|
|
1227
|
+
for (const entry of ret.entries) {
|
|
1228
|
+
const expr = entry.expr;
|
|
1229
|
+
if (expr.kind === 'var' && expr.external !== true && expr.slot === keySlot) {
|
|
1230
|
+
if (as !== null) return { code: 'nonnative-grouping' };
|
|
1231
|
+
as = entry.name;
|
|
1232
|
+
continue;
|
|
1233
|
+
}
|
|
1234
|
+
if (expr.kind !== 'op') return { code: 'nonnative-grouping' };
|
|
1235
|
+
if (expr.name === '$count') {
|
|
1236
|
+
// `$count` over the BINDING is the group's row count; over a path
|
|
1237
|
+
// it counts the rows that HAVE the member, which SQL's
|
|
1238
|
+
// `COUNT(column)` does not reproduce for a JSON `null`
|
|
1239
|
+
if (!isItVar(expr.args[0], itSlot)) return { code: 'nonnative-grouping' };
|
|
1240
|
+
aggregates.push({ fn: 'rows', ref: null, as: entry.name, empty: 'null' });
|
|
1241
|
+
continue;
|
|
1242
|
+
}
|
|
1243
|
+
const fn = { $sum: 'sum', $avg: 'avg', $min: 'min', $max: 'max' }[expr.name];
|
|
1244
|
+
if (fn === undefined) return { code: 'nonnative-grouping' };
|
|
1245
|
+
const ref = pathRef(expr.args[0], itSlot, shape);
|
|
1246
|
+
const valueBad = valueRefusal(ref);
|
|
1247
|
+
if (valueBad !== null) return { code: valueBad };
|
|
1248
|
+
// what an aggregate over NO numbers says, in the ENGINE's words:
|
|
1249
|
+
// `$sum` of an empty sequence is 0 and the other three are the
|
|
1250
|
+
// empty sequence, which an object constructor leaves the member out
|
|
1251
|
+
// for. SQL answers `NULL` for all four, so the mapping is the plan's
|
|
1252
|
+
aggregates.push({ fn, ref, as: entry.name, empty: fn === 'sum' ? 'zero' : 'omit' });
|
|
1253
|
+
}
|
|
1254
|
+
if (as === null || aggregates.length === 0) return { code: 'nonnative-grouping' };
|
|
1255
|
+
return { as, aggregates };
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
/**
|
|
1259
|
+
* Which way the groups come out. The engine's own rule is order of
|
|
1260
|
+
* FIRST APPEARANCE (§6.5), which over a collection is the earliest row
|
|
1261
|
+
* identity in each group; an `$orderby` on the key alone replaces it.
|
|
1262
|
+
* @param {any} orderby
|
|
1263
|
+
* @param {number} keySlot
|
|
1264
|
+
* @returns {'asc' | 'desc' | 'first-seen' | null} `null` when the
|
|
1265
|
+
* ordering is one this plan cannot reproduce
|
|
1266
|
+
*/
|
|
1267
|
+
function bucketOrder(orderby, keySlot) {
|
|
1268
|
+
if (orderby === null) return 'first-seen';
|
|
1269
|
+
if (orderby.specs.length !== 1) return null;
|
|
1270
|
+
const spec = orderby.specs[0];
|
|
1271
|
+
if (spec.collation !== null || spec.collationName !== null) return null;
|
|
1272
|
+
const key = spec.key;
|
|
1273
|
+
if (key.kind !== 'var' || key.external === true || key.slot !== keySlot) return null;
|
|
1274
|
+
return spec.desc === true ? 'desc' : 'asc';
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
/**
|
|
1278
|
+
* The record a refused grouping leaves behind: the same question, named
|
|
1279
|
+
* and reasoned, over whatever the fetch still narrows.
|
|
1280
|
+
* @param {import('./algebra.js').Plan} plan
|
|
1281
|
+
* @param {any} shape
|
|
1282
|
+
* @param {string} code
|
|
1283
|
+
* @param {string} construct
|
|
1284
|
+
* @returns {any}
|
|
1285
|
+
*/
|
|
1286
|
+
function refinedGrouping(plan, shape, code, construct) {
|
|
1287
|
+
const facts = filterFacts(plan.filter);
|
|
1288
|
+
let column = null;
|
|
1289
|
+
for (const [name] of facts.bounds) {
|
|
1290
|
+
if (instantIndexesOver(shape, name, 2).length > 0) column = name;
|
|
1291
|
+
}
|
|
1292
|
+
const index = seekingIndexFor(shape, column, facts, 2);
|
|
1293
|
+
const bound = column === null ? null : facts.bounds.get(column);
|
|
1294
|
+
return seriesRecord({
|
|
1295
|
+
mode: plan.filter === null ? 'engine' : 'hybrid',
|
|
1296
|
+
operation: 'bucket',
|
|
1297
|
+
index: index === null ? null : index.name,
|
|
1298
|
+
prefix: index === null ? [] : index.prefix,
|
|
1299
|
+
range: bound === undefined || bound === null ? null : { column, ...bound },
|
|
1300
|
+
refinement: 'resampleSeries',
|
|
1301
|
+
reasons: [seriesReason(code, construct)],
|
|
1302
|
+
});
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
/**
|
|
1306
|
+
* Classify a planned selection as a temporal range or as-of lookup, or
|
|
1307
|
+
* answer `null` when the document asked no such question. The plan is
|
|
1308
|
+
* NOT changed: this names what the selection already is, and which
|
|
1309
|
+
* declared index it seeks through.
|
|
1310
|
+
* @param {import('./algebra.js').Plan} plan
|
|
1311
|
+
* @param {any} shape
|
|
1312
|
+
* @param {boolean} ordered - the ordering was pushed whole
|
|
1313
|
+
* @returns {any} the series record, or null
|
|
1314
|
+
*/
|
|
1315
|
+
function classifySelection(plan, shape, ordered) {
|
|
1316
|
+
const facts = filterFacts(plan.filter);
|
|
1317
|
+
// the instant column is the one a DECLARED index ends with; without
|
|
1318
|
+
// such an index the collection has no instant and the question was
|
|
1319
|
+
// an ordinary one
|
|
1320
|
+
const candidates = [];
|
|
1321
|
+
for (const [column, bound] of facts.bounds) {
|
|
1322
|
+
if (instantIndexesOver(shape, column, 2).length === 0) continue;
|
|
1323
|
+
candidates.push([column, bound]);
|
|
1324
|
+
}
|
|
1325
|
+
if (candidates.length !== 1) return null;
|
|
1326
|
+
const [column, bound] = candidates[0];
|
|
1327
|
+
const index = seekingIndexFor(shape, column, facts, 2);
|
|
1328
|
+
const bounded = bound.from !== null || bound.to !== null;
|
|
1329
|
+
const twoSided = bound.from !== null && bound.to !== null;
|
|
1330
|
+
const orderedByInstant = ordered && plan.order !== null && plan.order.length === 1
|
|
1331
|
+
&& plan.order[0].ref.column === column;
|
|
1332
|
+
const operation = twoSided ? 'range'
|
|
1333
|
+
: (orderedByInstant && plan.window !== null && plan.window.limit !== null) ? 'asof'
|
|
1334
|
+
: bounded ? 'range' : null;
|
|
1335
|
+
if (operation === null) return null;
|
|
1336
|
+
const reasons = index === null ? [seriesReason('missing-series-prefix', '$where')] : [];
|
|
1337
|
+
return seriesRecord({
|
|
1338
|
+
mode: index === null ? 'engine' : 'native',
|
|
1339
|
+
operation,
|
|
1340
|
+
index: index === null ? null : index.name,
|
|
1341
|
+
prefix: index === null ? [] : index.prefix,
|
|
1342
|
+
range: {
|
|
1343
|
+
from: bound.from, fromOp: bound.fromOp, to: bound.to, toOp: bound.toOp, column,
|
|
1344
|
+
},
|
|
1345
|
+
reasons,
|
|
1346
|
+
});
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
|
|
1350
|
+
/**
|
|
1351
|
+
* The bucket a `$groupby` phrase spells, or the reason it is not one.
|
|
1352
|
+
* @param {any} node - the flwor node
|
|
1353
|
+
* @param {number} itSlot
|
|
1354
|
+
* @param {any} shape
|
|
1355
|
+
* @returns {{ bucket: any } | { code: string }}
|
|
1356
|
+
*/
|
|
1357
|
+
function planBucketGrouping(node, itSlot, shape) {
|
|
1358
|
+
if (node.groupby.keys.length !== 1) return { code: 'nonnative-grouping' };
|
|
1359
|
+
const key = node.groupby.keys[0];
|
|
1360
|
+
const ladder = groupLadder(key.expr, itSlot, shape);
|
|
1361
|
+
if (ladder === null) return { code: 'nonnative-grouping' };
|
|
1362
|
+
if ('code' in ladder) return ladder;
|
|
1363
|
+
const projection = bucketProjection(node.ret, itSlot, key.slot, shape);
|
|
1364
|
+
if ('code' in projection) return projection;
|
|
1365
|
+
const order = bucketOrder(node.orderby, key.slot);
|
|
1366
|
+
if (order === null) return { code: 'nonnative-grouping' };
|
|
1367
|
+
return { bucket: {
|
|
1368
|
+
ref: ladder.ref,
|
|
1369
|
+
every: ladder.every,
|
|
1370
|
+
origin: ladder.origin,
|
|
1371
|
+
as: projection.as,
|
|
1372
|
+
order,
|
|
1373
|
+
aggregates: projection.aggregates,
|
|
1374
|
+
} };
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
/**
|
|
1378
|
+
* The tolerance of an as-of spec in milliseconds, or `null` for one
|
|
1379
|
+
* that bounds nothing. A NEGATIVE tolerance is a broken document the
|
|
1380
|
+
* engine refuses, and narrowing by it would move the bounds INWARD —
|
|
1381
|
+
* so it bounds nothing here and the engine raises, which is the same
|
|
1382
|
+
* rule `safeEpoch` follows for an instant that names none.
|
|
1383
|
+
*/
|
|
1384
|
+
function toleranceMs(tolerance) {
|
|
1385
|
+
if (tolerance === undefined) return null;
|
|
1386
|
+
if (typeof tolerance === 'number')
|
|
1387
|
+
return Number.isFinite(tolerance) && tolerance >= 0 ? tolerance : null;
|
|
1388
|
+
try {
|
|
1389
|
+
const span = compileBuckets({ every: tolerance }, {});
|
|
1390
|
+
return span.calendar || span.width < 0 ? null : span.width;
|
|
1391
|
+
}
|
|
1392
|
+
catch {
|
|
1393
|
+
return null;
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
/**
|
|
1398
|
+
* The epoch a spec member names, or `null` when it names none.
|
|
1399
|
+
*
|
|
1400
|
+
* A planner may never raise on the ENGINE's behalf: `analyzeQuery` does
|
|
1401
|
+
* not compile an operator, so a spec whose `start` is not an instant
|
|
1402
|
+
* reaches here before the engine has had its say. Refusing to narrow is
|
|
1403
|
+
* the right answer — the residual compiles the caller's own document
|
|
1404
|
+
* and raises the `JQ0003` it would have raised anyway.
|
|
1405
|
+
* @param {any} value
|
|
1406
|
+
* @returns {number | null}
|
|
1407
|
+
*/
|
|
1408
|
+
function safeEpoch(value) {
|
|
1409
|
+
if (value === undefined) return null;
|
|
1410
|
+
try {
|
|
1411
|
+
const at = toEpoch(value);
|
|
1412
|
+
return Number.isFinite(at) ? at : null;
|
|
1413
|
+
}
|
|
1414
|
+
catch {
|
|
1415
|
+
return null;
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
/** An instant bound as a pushable conjunct over the instant column. */
|
|
1420
|
+
function instantBound(ref, op, value) {
|
|
1421
|
+
return { p: 'cmp', op, ref, operand: { lit: value } };
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
/**
|
|
1425
|
+
* The bounds a frozen spec implies for the collection side, as pushable
|
|
1426
|
+
* conjuncts. Every one of them is an IMPLIED conjunct: it narrows the
|
|
1427
|
+
* fetch and decides nothing, because the residual re-runs the caller's
|
|
1428
|
+
* own document — the whole operator — over what comes back.
|
|
1429
|
+
* @returns {{ preds: any[], range: any }}
|
|
1430
|
+
*/
|
|
1431
|
+
function impliedInstantBounds(ref, from, to) {
|
|
1432
|
+
const preds = [];
|
|
1433
|
+
if (from !== null) preds.push(instantBound(ref, 'ge', from));
|
|
1434
|
+
if (to !== null) preds.push(instantBound(ref, 'le', to));
|
|
1435
|
+
return { preds, range: { column: ref.column, from, fromOp: from === null ? null : 'ge',
|
|
1436
|
+
to, toOp: to === null ? null : 'le' } };
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
/**
|
|
1440
|
+
* Plan a document that IS a series operator over the collection.
|
|
1441
|
+
*
|
|
1442
|
+
* The collection is one of the operator's operands, so the narrowing is
|
|
1443
|
+
* that operand's own `$where` plus what the frozen spec implies, and
|
|
1444
|
+
* the kernel — the engine running the caller's document over the
|
|
1445
|
+
* fetched candidates — decides. A `$resample` whose spec asks for
|
|
1446
|
+
* nothing a `GROUP BY` cannot do is the one exception: it is native,
|
|
1447
|
+
* and answers the bucket records itself.
|
|
1448
|
+
* @param {any} root
|
|
1449
|
+
* @param {any} shape
|
|
1450
|
+
* @returns {any} `null` when the operator is not over this collection
|
|
1451
|
+
*/
|
|
1452
|
+
function planSeriesOperator(root, shape) {
|
|
1453
|
+
const name = root.name;
|
|
1454
|
+
/** @type {any} */
|
|
1455
|
+
let operandNode = null;
|
|
1456
|
+
/** @type {any} */
|
|
1457
|
+
let probesNode = null;
|
|
1458
|
+
/** @type {any} */
|
|
1459
|
+
let spec = null;
|
|
1460
|
+
if (name === '$resample' || name === '$rolling') {
|
|
1461
|
+
operandNode = root.args[0];
|
|
1462
|
+
spec = root.args[1]?.kind === 'raw' ? root.args[1].value : null;
|
|
1463
|
+
}
|
|
1464
|
+
else {
|
|
1465
|
+
spec = root.args.length === 3
|
|
1466
|
+
? (root.args[2].kind === 'raw' ? root.args[2].value : null) : {};
|
|
1467
|
+
// narrowing is only sound on the RIGHT side: an as-of join answers
|
|
1468
|
+
// once per LEFT row, so every left row is needed whatever it matches
|
|
1469
|
+
if (collectionOperand(root.args[1]) !== null) {
|
|
1470
|
+
operandNode = root.args[1];
|
|
1471
|
+
probesNode = root.args[0];
|
|
1472
|
+
}
|
|
1473
|
+
else if (collectionOperand(root.args[0]) !== null) {
|
|
1474
|
+
operandNode = root.args[0];
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
const operand = operandNode === null ? null : collectionOperand(operandNode);
|
|
1478
|
+
if (operand === null || spec === null || typeof spec !== 'object') return null;
|
|
1479
|
+
|
|
1480
|
+
const inner = operand.flwor === null
|
|
1481
|
+
? { plan: selectPlan(shape.collection), reasons: [], prefilters: [] }
|
|
1482
|
+
: planFlwor(operand.flwor, shape, undefined, undefined);
|
|
1483
|
+
const plan = inner.plan;
|
|
1484
|
+
// the operand's own clauses stay the engine's: the residual runs the
|
|
1485
|
+
// WHOLE document, so a projection or an ordering inside it is applied
|
|
1486
|
+
// there and only its pushed conjuncts narrow
|
|
1487
|
+
plan.order = null;
|
|
1488
|
+
plan.window = null;
|
|
1489
|
+
|
|
1490
|
+
const reasons = [];
|
|
1491
|
+
const prefilters = [...(inner.prefilters ?? [])];
|
|
1492
|
+
const at = memberRef(shape,
|
|
1493
|
+
spec.at !== undefined ? (singularSelector(spec.at) ?? 'at')
|
|
1494
|
+
: (name === '$asof' && probesNode !== null && spec.rightAt !== undefined
|
|
1495
|
+
? (singularSelector(spec.rightAt) ?? 'at') : 'at'));
|
|
1496
|
+
|
|
1497
|
+
if (name === '$resample') {
|
|
1498
|
+
const outcome = resampleBucket(spec, shape);
|
|
1499
|
+
if ('bucket' in outcome) {
|
|
1500
|
+
// native: the ladder, the aggregate and the count are the plan's
|
|
1501
|
+
const bucket = outcome.bucket;
|
|
1502
|
+
bucket.aggregates[0].empty = 'null';
|
|
1503
|
+
bucket.aggregates[1].empty = 'null';
|
|
1504
|
+
plan.bucket = bucket;
|
|
1505
|
+
const windowFrom = safeEpoch(spec.start);
|
|
1506
|
+
const windowTo = safeEpoch(spec.end);
|
|
1507
|
+
if (windowFrom !== null) plan.filter = conjoin(plan.filter,
|
|
1508
|
+
instantBound(bucket.ref, 'ge', windowFrom));
|
|
1509
|
+
if (windowTo !== null) plan.filter = conjoin(plan.filter,
|
|
1510
|
+
instantBound(bucket.ref, 'lt', windowTo));
|
|
1511
|
+
const facts = filterFacts(plan.filter);
|
|
1512
|
+
const index = seekingIndexFor(shape, bucket.ref.column, facts);
|
|
1513
|
+
const bound = facts.bounds.get(bucket.ref.column) ?? null;
|
|
1514
|
+
return {
|
|
1515
|
+
plan,
|
|
1516
|
+
native: inner.reasons.length === 0,
|
|
1517
|
+
reasons: inner.reasons,
|
|
1518
|
+
prefilters,
|
|
1519
|
+
series: seriesRecord({
|
|
1520
|
+
mode: 'native',
|
|
1521
|
+
operation: 'resample',
|
|
1522
|
+
index: index === null ? null : index.name,
|
|
1523
|
+
prefix: index === null ? [] : index.prefix,
|
|
1524
|
+
range: bound === null ? null : { column: bucket.ref.column, ...bound },
|
|
1525
|
+
ladder: { every: bucket.every, origin: bucket.origin, calendar: false },
|
|
1526
|
+
aggregates: bucket.aggregates.map((a) => a.as),
|
|
1527
|
+
reasons: index === null ? [seriesReason('missing-series-prefix', name)] : [],
|
|
1528
|
+
}),
|
|
1529
|
+
};
|
|
1530
|
+
}
|
|
1531
|
+
reasons.push(seriesReason(outcome.code, name));
|
|
1532
|
+
}
|
|
1533
|
+
else if (name === '$rolling') {
|
|
1534
|
+
reasons.push(seriesReason('rolling-refinement', name));
|
|
1535
|
+
}
|
|
1536
|
+
else {
|
|
1537
|
+
reasons.push(seriesReason('asof-refinement', name));
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
// the refinement: narrow through the index by whatever the spec makes
|
|
1541
|
+
// provable, and let the engine's own kernel decide over what comes back
|
|
1542
|
+
let range = null;
|
|
1543
|
+
if (name === '$resample' && at.column !== null) {
|
|
1544
|
+
const from = safeEpoch(spec.start);
|
|
1545
|
+
const to = safeEpoch(spec.end);
|
|
1546
|
+
if (from !== null || to !== null) {
|
|
1547
|
+
if (from !== null) plan.filter = conjoin(plan.filter, instantBound(at, 'ge', from));
|
|
1548
|
+
if (to !== null) plan.filter = conjoin(plan.filter, instantBound(at, 'lt', to));
|
|
1549
|
+
range = { column: at.column, from, fromOp: from === null ? null : 'ge',
|
|
1550
|
+
to, toOp: to === null ? null : 'lt' };
|
|
1551
|
+
prefilters.push({ construct: name, via: 'columns', columns: [at.column], exact: false });
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
else if (name === '$asof' && probesNode !== null) {
|
|
1555
|
+
const probes = literalInstants(probesNode, spec.leftAt, spec.by);
|
|
1556
|
+
if (probes !== null && at.column !== null) {
|
|
1557
|
+
const tolerance = toleranceMs(spec.tolerance);
|
|
1558
|
+
const direction = spec.direction ?? 'backward';
|
|
1559
|
+
let from = null;
|
|
1560
|
+
let to = null;
|
|
1561
|
+
if (direction === 'backward') {
|
|
1562
|
+
to = probes.max;
|
|
1563
|
+
if (tolerance !== null) from = probes.min - tolerance;
|
|
1564
|
+
}
|
|
1565
|
+
else if (direction === 'forward') {
|
|
1566
|
+
from = probes.min;
|
|
1567
|
+
if (tolerance !== null) to = probes.max + tolerance;
|
|
1568
|
+
}
|
|
1569
|
+
else if (tolerance !== null) {
|
|
1570
|
+
from = probes.min - tolerance;
|
|
1571
|
+
to = probes.max + tolerance;
|
|
1572
|
+
}
|
|
1573
|
+
const bounds = impliedInstantBounds(at, from, to);
|
|
1574
|
+
for (const pred of bounds.preds) plan.filter = conjoin(plan.filter, pred);
|
|
1575
|
+
if (bounds.preds.length > 0) {
|
|
1576
|
+
range = bounds.range;
|
|
1577
|
+
prefilters.push({ construct: name, via: 'columns', columns: [at.column], exact: false });
|
|
1578
|
+
}
|
|
1579
|
+
}
|
|
1580
|
+
// and the keys, whether or not the instant has a column of its own:
|
|
1581
|
+
// a right row whose group no left row names can match nothing, so a
|
|
1582
|
+
// membership test over the probes' own keys narrows and never drops
|
|
1583
|
+
if (probes !== null && probes.keys !== null && probes.keys.length > 0) {
|
|
1584
|
+
const byRef = memberRef(shape, /** @type {string} */ (singularSelector(spec.by)));
|
|
1585
|
+
if (byRef.column !== null) {
|
|
1586
|
+
plan.filter = conjoin(plan.filter, probes.keys.length === 1
|
|
1587
|
+
? { p: 'cmp', op: 'eq', ref: byRef, operand: { lit: probes.keys[0] } }
|
|
1588
|
+
: { p: 'or', items: probes.keys.map((key) =>
|
|
1589
|
+
({ p: 'cmp', op: 'eq', ref: byRef, operand: { lit: key } })) });
|
|
1590
|
+
prefilters.push({ construct: name, via: 'columns',
|
|
1591
|
+
columns: [byRef.column], exact: false });
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
const facts = filterFacts(plan.filter);
|
|
1597
|
+
const index = seekingIndexFor(shape, at.column, facts);
|
|
1598
|
+
// one code, once: an instant with no column of its own already said
|
|
1599
|
+
// this when the ladder refused, and saying it twice reads as two facts
|
|
1600
|
+
if (index === null && !reasons.some((r) => r.code === 'missing-series-prefix'))
|
|
1601
|
+
reasons.push(seriesReason('missing-series-prefix', name));
|
|
1602
|
+
const narrowed = plan.filter !== null;
|
|
1603
|
+
return {
|
|
1604
|
+
plan,
|
|
1605
|
+
native: false,
|
|
1606
|
+
reasons: [...reasons, ...inner.reasons],
|
|
1607
|
+
prefilters,
|
|
1608
|
+
series: seriesRecord({
|
|
1609
|
+
mode: narrowed ? 'hybrid' : 'engine',
|
|
1610
|
+
operation: { $resample: 'resample', $rolling: 'rolling', $asof: 'asof-join' }[name],
|
|
1611
|
+
index: narrowed && index !== null ? index.name : null,
|
|
1612
|
+
prefix: narrowed && index !== null ? index.prefix : [],
|
|
1613
|
+
range,
|
|
1614
|
+
refinement: { $resample: 'resampleSeries', $rolling: 'rollingSeries',
|
|
1615
|
+
$asof: 'asOfJoin' }[name],
|
|
1616
|
+
reasons,
|
|
1617
|
+
}),
|
|
1618
|
+
};
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1013
1621
|
/**
|
|
1014
1622
|
* Plan a FLWOR node into a select plan, recording refusals. When a
|
|
1015
1623
|
* conjunct refuses native translation, the injected `udf` hook may
|
|
@@ -1048,7 +1656,8 @@ function planFlwor(node, shape, rawFlwor, udfHook) {
|
|
|
1048
1656
|
reasons.push(refusal('$for',
|
|
1049
1657
|
'only a single plain binding over the whole collection is translated'));
|
|
1050
1658
|
return { plan, reasons, whereFullyPushed: false, orderPushed: false,
|
|
1051
|
-
projectionNative: false, itSlot: -1, itName: null, udfs: [], prefilters: [],
|
|
1659
|
+
projectionNative: false, itSlot: -1, itName: null, udfs: [], prefilters: [],
|
|
1660
|
+
knn: null, bucket: null, bucketRefusal: null };
|
|
1052
1661
|
}
|
|
1053
1662
|
const itSlot = binding.slot;
|
|
1054
1663
|
// the document's own name for the collection binding. The residual and
|
|
@@ -1060,7 +1669,22 @@ function planFlwor(node, shape, rawFlwor, udfHook) {
|
|
|
1060
1669
|
if (node.fold !== null) reasons.push(refusal('$fold', KIND_REASONS.let));
|
|
1061
1670
|
if (node.letBindings.length > 0) reasons.push(refusal('$let', KIND_REASONS.let));
|
|
1062
1671
|
if (node.asChecks !== null) reasons.push(refusal('$as', 'type assertions run in the engine'));
|
|
1063
|
-
|
|
1672
|
+
// A grouping is an unconditional residual EXCEPT in one closed shape:
|
|
1673
|
+
// a fixed-width `$time-bucket` key with the exact aggregates, which
|
|
1674
|
+
// is a `GROUP BY` over integer arithmetic. The bucket then owns the
|
|
1675
|
+
// ordering and the projection too, so it is decided before either.
|
|
1676
|
+
let bucket = null;
|
|
1677
|
+
let bucketRefusal = null;
|
|
1678
|
+
if (node.groupby !== null && node.fold === null && node.letBindings.length === 0
|
|
1679
|
+
&& node.asChecks === null && node.count === null) {
|
|
1680
|
+
const grouped = planBucketGrouping(node, itSlot, shape);
|
|
1681
|
+
if ('bucket' in grouped) bucket = grouped.bucket;
|
|
1682
|
+
else {
|
|
1683
|
+
bucketRefusal = grouped.code;
|
|
1684
|
+
reasons.push(seriesReason(grouped.code, '$groupby'));
|
|
1685
|
+
}
|
|
1686
|
+
}
|
|
1687
|
+
else if (node.groupby !== null) reasons.push(refusal('$groupby', KIND_REASONS.let));
|
|
1064
1688
|
if (node.count !== null) reasons.push(refusal('$count clause', KIND_REASONS.let));
|
|
1065
1689
|
const structureClean = reasons.length === 0;
|
|
1066
1690
|
// $let and $as run BEFORE $where in clause order: a row our pushed
|
|
@@ -1116,9 +1740,10 @@ function planFlwor(node, shape, rawFlwor, udfHook) {
|
|
|
1116
1740
|
// caller's to name once the window is known)
|
|
1117
1741
|
let orderPushed = false;
|
|
1118
1742
|
let knn = null;
|
|
1119
|
-
const ranked = node.orderby === null ? null
|
|
1743
|
+
const ranked = bucket !== null || node.orderby === null ? null
|
|
1120
1744
|
: planKnnOrder(node.orderby, itSlot, shape, whereFullyPushed && structureClean);
|
|
1121
|
-
if (
|
|
1745
|
+
if (bucket !== null) orderPushed = true; // the groups' order is the bucket's
|
|
1746
|
+
else if (ranked !== null) {
|
|
1122
1747
|
if ('rank' in ranked) knn = ranked.rank;
|
|
1123
1748
|
else reasons.push(ranked.refusal);
|
|
1124
1749
|
}
|
|
@@ -1152,7 +1777,8 @@ function planFlwor(node, shape, rawFlwor, udfHook) {
|
|
|
1152
1777
|
// RETURN: the bare binding is the native whole-document projection
|
|
1153
1778
|
let projectionNative = false;
|
|
1154
1779
|
assertDecidedKind(node.ret);
|
|
1155
|
-
if (
|
|
1780
|
+
if (bucket !== null) projectionNative = true; // the bucket IS the projection
|
|
1781
|
+
else if (isItVar(node.ret, itSlot)) projectionNative = true;
|
|
1156
1782
|
else {
|
|
1157
1783
|
reasons.push(refusal('$return',
|
|
1158
1784
|
'projections other than the bare binding run per row (the row residual)'));
|
|
@@ -1169,6 +1795,8 @@ function planFlwor(node, shape, rawFlwor, udfHook) {
|
|
|
1169
1795
|
udfs,
|
|
1170
1796
|
prefilters,
|
|
1171
1797
|
knn,
|
|
1798
|
+
bucket,
|
|
1799
|
+
bucketRefusal,
|
|
1172
1800
|
};
|
|
1173
1801
|
}
|
|
1174
1802
|
|
|
@@ -1212,7 +1840,10 @@ function composeWindows(windows) {
|
|
|
1212
1840
|
* udfs: string[],
|
|
1213
1841
|
* prefilters: { construct: string, via: 'columns' | 'rtree',
|
|
1214
1842
|
* columns: string[], exact: boolean }[],
|
|
1843
|
+
* series: any,
|
|
1215
1844
|
* }}
|
|
1845
|
+
* `series` is the temporal record (`series.js`) when the document
|
|
1846
|
+
* asked a §8.16 question, and `null` when it did not.
|
|
1216
1847
|
*/
|
|
1217
1848
|
function planCollectionCore(document, shape, options = undefined) {
|
|
1218
1849
|
const analysis = analyzeQuery(document, analyzeOptionsFor(shape?.operators));
|
|
@@ -1230,7 +1861,7 @@ function planCollectionCore(document, shape, options = undefined) {
|
|
|
1230
1861
|
return {
|
|
1231
1862
|
analysis, plan: null, mode: 'set',
|
|
1232
1863
|
reasons: [refusal('$subsequence', 'window bounds must be literal numbers to push')],
|
|
1233
|
-
rowReturn: null, udfs: [], prefilters: [],
|
|
1864
|
+
rowReturn: null, udfs: [], prefilters: [], series: null,
|
|
1234
1865
|
};
|
|
1235
1866
|
}
|
|
1236
1867
|
windows.push({ offset: start.value, limit: length === undefined ? null : length.value });
|
|
@@ -1246,7 +1877,7 @@ function planCollectionCore(document, shape, options = undefined) {
|
|
|
1246
1877
|
return {
|
|
1247
1878
|
analysis, plan: null, mode: 'set',
|
|
1248
1879
|
reasons: [refusal(root.name, 'a windowed aggregate is not translated')],
|
|
1249
|
-
rowReturn: null, udfs: [], prefilters: [],
|
|
1880
|
+
rowReturn: null, udfs: [], prefilters: [], series: null,
|
|
1250
1881
|
};
|
|
1251
1882
|
}
|
|
1252
1883
|
aggregate = { name: root.name, fn: AGGREGATES.get(root.name) };
|
|
@@ -1255,12 +1886,37 @@ function planCollectionCore(document, shape, options = undefined) {
|
|
|
1255
1886
|
assertDecidedKind(root);
|
|
1256
1887
|
}
|
|
1257
1888
|
|
|
1889
|
+
// a document that IS a series operator over the collection: the
|
|
1890
|
+
// operand's own conjuncts (and what the frozen spec implies) narrow
|
|
1891
|
+
// through the index, and the kernel decides over what comes back
|
|
1892
|
+
if (aggregate === null && root.kind === 'op' && SERIES_ROOT_OPS.includes(root.name)) {
|
|
1893
|
+
const temporal = planSeriesOperator(root, shape);
|
|
1894
|
+
if (temporal !== null) {
|
|
1895
|
+
// a peeled `$subsequence` composes as it does everywhere — over a
|
|
1896
|
+
// NATIVE bucket it is a LIMIT on the ascending groups, which is
|
|
1897
|
+
// the same items the kernel's own window would have kept; over a
|
|
1898
|
+
// refinement the residual applies it, so the plan keeps none
|
|
1899
|
+
const window = windows.length === 0 ? null : composeWindows(windows);
|
|
1900
|
+
if (temporal.native && window !== null) temporal.plan.window = window;
|
|
1901
|
+
return {
|
|
1902
|
+
analysis,
|
|
1903
|
+
plan: temporal.plan,
|
|
1904
|
+
mode: temporal.native ? 'native' : 'set',
|
|
1905
|
+
reasons: temporal.native ? [] : temporal.reasons,
|
|
1906
|
+
rowReturn: null,
|
|
1907
|
+
udfs: [],
|
|
1908
|
+
prefilters: temporal.prefilters,
|
|
1909
|
+
series: temporal.series,
|
|
1910
|
+
};
|
|
1911
|
+
}
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1258
1914
|
if (root.kind !== 'flwor') {
|
|
1259
1915
|
return {
|
|
1260
1916
|
analysis, plan: null, mode: 'set',
|
|
1261
1917
|
reasons: [refusal(root.kind, KIND_REASONS[root.kind]
|
|
1262
1918
|
?? 'only a FLWOR over the collection is translated')],
|
|
1263
|
-
rowReturn: null, udfs: [], prefilters: [],
|
|
1919
|
+
rowReturn: null, udfs: [], prefilters: [], series: null,
|
|
1264
1920
|
};
|
|
1265
1921
|
}
|
|
1266
1922
|
|
|
@@ -1279,7 +1935,7 @@ function planCollectionCore(document, shape, options = undefined) {
|
|
|
1279
1935
|
margin: KNN_MARGIN };
|
|
1280
1936
|
return { analysis, plan, mode: 'knn',
|
|
1281
1937
|
reasons: [refusal('$orderby', KNN_REASONS.rank), ...flwor.reasons],
|
|
1282
|
-
rowReturn: null, udfs: flwor.udfs, prefilters: flwor.prefilters };
|
|
1938
|
+
rowReturn: null, udfs: flwor.udfs, prefilters: flwor.prefilters, series: null };
|
|
1283
1939
|
}
|
|
1284
1940
|
flwor.reasons.unshift(refusal('$subsequence', KNN_REASONS.window));
|
|
1285
1941
|
}
|
|
@@ -1289,7 +1945,7 @@ function planCollectionCore(document, shape, options = undefined) {
|
|
|
1289
1945
|
// full sequence, not a narrowed candidate set)
|
|
1290
1946
|
if (!fullyPushed) {
|
|
1291
1947
|
return { analysis, plan: null, mode: 'set', reasons: flwor.reasons,
|
|
1292
|
-
rowReturn: null, udfs: [], prefilters: flwor.prefilters };
|
|
1948
|
+
rowReturn: null, udfs: [], prefilters: flwor.prefilters, series: null };
|
|
1293
1949
|
}
|
|
1294
1950
|
if (aggregate.fn === 'count') {
|
|
1295
1951
|
if (!flwor.projectionNative) {
|
|
@@ -1297,12 +1953,13 @@ function planCollectionCore(document, shape, options = undefined) {
|
|
|
1297
1953
|
analysis, plan: null, mode: 'set',
|
|
1298
1954
|
reasons: [refusal('$count',
|
|
1299
1955
|
'count translates only over the bare binding (a projected return can change the item count)')],
|
|
1300
|
-
rowReturn: null, udfs: [], prefilters: [],
|
|
1956
|
+
rowReturn: null, udfs: [], prefilters: [], series: null,
|
|
1301
1957
|
};
|
|
1302
1958
|
}
|
|
1303
1959
|
plan.aggregate = { fn: 'count', ref: null };
|
|
1304
1960
|
return { analysis, plan, mode: 'native', reasons: [], rowReturn: null,
|
|
1305
|
-
udfs: flwor.udfs, prefilters: flwor.prefilters
|
|
1961
|
+
udfs: flwor.udfs, prefilters: flwor.prefilters,
|
|
1962
|
+
series: classifySelection(plan, shape, true) };
|
|
1306
1963
|
}
|
|
1307
1964
|
const ref = pathRef(root.ret, flwor.itSlot, shape);
|
|
1308
1965
|
const numeric = aggregate.fn === 'sum' || aggregate.fn === 'avg';
|
|
@@ -1313,20 +1970,46 @@ function planCollectionCore(document, shape, options = undefined) {
|
|
|
1313
1970
|
analysis, plan: null, mode: 'set',
|
|
1314
1971
|
reasons: [refusal(aggregate.name,
|
|
1315
1972
|
'aggregates translate only over a singular schema-typed path (the engine ERRORS on non-conforming operands)')],
|
|
1316
|
-
rowReturn: null, udfs: [], prefilters: [],
|
|
1973
|
+
rowReturn: null, udfs: [], prefilters: [], series: null,
|
|
1317
1974
|
};
|
|
1318
1975
|
}
|
|
1319
1976
|
plan.aggregate = { fn: /** @type {any} */ (aggregate.fn), ref };
|
|
1320
1977
|
return { analysis, plan, mode: 'native', reasons: [], rowReturn: null,
|
|
1321
|
-
udfs: flwor.udfs, prefilters: flwor.prefilters
|
|
1978
|
+
udfs: flwor.udfs, prefilters: flwor.prefilters,
|
|
1979
|
+
series: classifySelection(plan, shape, true) };
|
|
1322
1980
|
}
|
|
1323
1981
|
|
|
1324
1982
|
// windows push only onto a fully pushed selection
|
|
1325
1983
|
if (windows.length > 0 && fullyPushed) plan.window = composeWindows(windows);
|
|
1326
1984
|
|
|
1985
|
+
// the temporal bucket: only over a WHOLE pushed selection, because a
|
|
1986
|
+
// conjunct the residual would still apply would arrive after the rows
|
|
1987
|
+
// were already summed
|
|
1988
|
+
if (flwor.bucket !== null && fullyPushed && (windows.length === 0 || plan.window !== null)) {
|
|
1989
|
+
plan.bucket = flwor.bucket;
|
|
1990
|
+
const facts = filterFacts(plan.filter);
|
|
1991
|
+
const index = seekingIndexFor(shape, plan.bucket.ref.column, facts);
|
|
1992
|
+
const bound = facts.bounds.get(plan.bucket.ref.column) ?? null;
|
|
1993
|
+
return {
|
|
1994
|
+
analysis, plan, mode: 'native', reasons: [], rowReturn: null,
|
|
1995
|
+
udfs: flwor.udfs, prefilters: flwor.prefilters,
|
|
1996
|
+
series: seriesRecord({
|
|
1997
|
+
mode: 'native',
|
|
1998
|
+
operation: 'bucket',
|
|
1999
|
+
index: index === null ? null : index.name,
|
|
2000
|
+
prefix: index === null ? [] : index.prefix,
|
|
2001
|
+
range: bound === null ? null : { column: plan.bucket.ref.column, ...bound },
|
|
2002
|
+
ladder: { every: plan.bucket.every, origin: plan.bucket.origin, calendar: false },
|
|
2003
|
+
aggregates: plan.bucket.aggregates.map((a) => a.as),
|
|
2004
|
+
reasons: index === null ? [seriesReason('missing-series-prefix', '$groupby')] : [],
|
|
2005
|
+
}),
|
|
2006
|
+
};
|
|
2007
|
+
}
|
|
2008
|
+
|
|
1327
2009
|
if (fullyPushed && flwor.projectionNative && (windows.length === 0 || plan.window !== null)) {
|
|
1328
2010
|
return { analysis, plan, mode: 'native', reasons: [], rowReturn: null,
|
|
1329
|
-
udfs: flwor.udfs, prefilters: flwor.prefilters
|
|
2011
|
+
udfs: flwor.udfs, prefilters: flwor.prefilters,
|
|
2012
|
+
series: classifySelection(plan, shape, flwor.orderPushed) };
|
|
1330
2013
|
}
|
|
1331
2014
|
|
|
1332
2015
|
// the row residual: everything but the projection pushed
|
|
@@ -1348,14 +2031,20 @@ function planCollectionCore(document, shape, options = undefined) {
|
|
|
1348
2031
|
},
|
|
1349
2032
|
udfs: flwor.udfs,
|
|
1350
2033
|
prefilters: flwor.prefilters,
|
|
2034
|
+
series: flwor.bucketRefusal == null
|
|
2035
|
+
? classifySelection(plan, shape, flwor.orderPushed)
|
|
2036
|
+
: refinedGrouping(plan, shape, flwor.bucketRefusal, '$groupby'),
|
|
1351
2037
|
};
|
|
1352
2038
|
}
|
|
1353
2039
|
|
|
1354
2040
|
// the set residual: pushed conjuncts narrow, the engine answers
|
|
2041
|
+
const narrowing = flwor.bucketRefusal == null
|
|
2042
|
+
? classifySelection(plan, shape, false)
|
|
2043
|
+
: refinedGrouping(plan, shape, flwor.bucketRefusal, '$groupby');
|
|
1355
2044
|
plan.order = null;
|
|
1356
2045
|
plan.window = null;
|
|
1357
2046
|
return { analysis, plan, mode: 'set', reasons: flwor.reasons, rowReturn: null,
|
|
1358
|
-
udfs: flwor.udfs, prefilters: flwor.prefilters };
|
|
2047
|
+
udfs: flwor.udfs, prefilters: flwor.prefilters, series: narrowing };
|
|
1359
2048
|
}
|
|
1360
2049
|
|
|
1361
2050
|
/**
|
|
@@ -1376,6 +2065,7 @@ function planCollectionCore(document, shape, options = undefined) {
|
|
|
1376
2065
|
* udfs: string[],
|
|
1377
2066
|
* prefilters: { construct: string, via: 'columns' | 'rtree',
|
|
1378
2067
|
* columns: string[], exact: boolean }[],
|
|
2068
|
+
* series: any,
|
|
1379
2069
|
* }}
|
|
1380
2070
|
*/
|
|
1381
2071
|
export function planQuery(document, shape, options = undefined) {
|