@jarenjs/db 0.49.2 → 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 +27 -15
- package/README.md +141 -41
- package/docs/JOBS-FORMAT.md +24 -8
- package/docs/LIVE-FORMAT.md +38 -9
- package/docs/MIGRATION-FORMAT.md +118 -36
- package/docs/MODEL-FORMAT.md +232 -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/capture.js +66 -28
- package/src/cli.js +225 -44
- package/src/ddl.js +23 -3
- package/src/dialects/sqlite.js +2 -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 +10 -3
- package/src/entity.js +92 -47
- package/src/errors.js +25 -0
- package/src/index.js +2 -2
- package/src/jobs.js +40 -5
- package/src/live-time.js +12 -3
- package/src/live.js +11 -1
- package/src/migrate.js +397 -191
- package/src/model.js +173 -8
- package/src/plan.js +135 -38
- package/src/query.js +138 -13
- package/src/store.js +221 -66
- package/src/tracker.js +173 -48
- package/types/index.d.ts +152 -10
- 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 -230
- package/dist/types/app.d.ts +0 -49
- package/dist/types/capture.d.ts +0 -85
- package/dist/types/cli.d.ts +0 -2
- package/dist/types/dag-job.d.ts +0 -40
- package/dist/types/ddl.d.ts +0 -229
- package/dist/types/derive.d.ts +0 -250
- package/dist/types/dialect.d.ts +0 -154
- package/dist/types/dialects/sqlite.d.ts +0 -9
- package/dist/types/driver.d.ts +0 -110
- package/dist/types/drivers/bun.d.ts +0 -47
- package/dist/types/drivers/node.d.ts +0 -37
- package/dist/types/drivers/wasm.d.ts +0 -65
- package/dist/types/emit-model.d.ts +0 -44
- package/dist/types/emit.d.ts +0 -75
- package/dist/types/entity.d.ts +0 -23
- package/dist/types/errors.d.ts +0 -170
- package/dist/types/graph.d.ts +0 -28
- package/dist/types/index.d.ts +0 -37
- package/dist/types/jobs.d.ts +0 -140
- package/dist/types/knn.d.ts +0 -69
- package/dist/types/live-time.d.ts +0 -141
- package/dist/types/live.d.ts +0 -64
- package/dist/types/migrate.d.ts +0 -170
- package/dist/types/model.d.ts +0 -36
- package/dist/types/patch-sql.d.ts +0 -37
- package/dist/types/plan.d.ts +0 -142
- package/dist/types/profile.d.ts +0 -80
- package/dist/types/query.d.ts +0 -112
- package/dist/types/residual.d.ts +0 -64
- package/dist/types/series.d.ts +0 -227
- package/dist/types/store.d.ts +0 -60
- package/dist/types/tracker.d.ts +0 -43
- package/dist/types/typed.d.ts +0 -15
- package/dist/types/types.d.ts +0 -26
- package/dist/types/udf.d.ts +0 -75
- package/dist/types/window.d.ts +0 -52
package/src/model.js
CHANGED
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
import {
|
|
25
25
|
collectSameDocumentAnchors, resolveSameDocumentRef,
|
|
26
26
|
} from '@jarenjs/validate/normalize';
|
|
27
|
+
import { compileJsonQuery } from '@jarenjs/json/query';
|
|
27
28
|
|
|
28
29
|
import { DbCompileError } from './errors.js';
|
|
29
30
|
|
|
@@ -123,12 +124,65 @@ function checkDefault(declared, docPath) {
|
|
|
123
124
|
|| declared === 'auto') return;
|
|
124
125
|
if (declared !== null && typeof declared === 'object' && !Array.isArray(declared)
|
|
125
126
|
&& (Object.hasOwn(declared, 'value') !== Object.hasOwn(declared, 'query'))
|
|
126
|
-
&& Object.keys(declared).length === 1)
|
|
127
|
+
&& Object.keys(declared).length === 1) {
|
|
128
|
+
if (Object.hasOwn(declared, 'query')) {
|
|
129
|
+
// compiled where the model is checked, not at the first
|
|
130
|
+
// `store.entity()` call — a default that cannot compile is a model
|
|
131
|
+
// defect, and the engine's reason travels with the model position
|
|
132
|
+
try {
|
|
133
|
+
compileJsonQuery(declared.query);
|
|
134
|
+
}
|
|
135
|
+
catch (cause) {
|
|
136
|
+
throw new DbCompileError('JD0005',
|
|
137
|
+
`the { query } default does not compile: ${/** @type {Error} */ (cause).message}`,
|
|
138
|
+
docPath, /** @type {Error} */ (cause));
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
127
143
|
throw modelError(
|
|
128
144
|
"a default is 'now', 'updated', 'uuid', 'auto', { value: … } or { query: … }",
|
|
129
145
|
docPath);
|
|
130
146
|
}
|
|
131
147
|
|
|
148
|
+
/** The schema positions whose `x-entity` block the one-level walk READS:
|
|
149
|
+
* a top-level property, an `allOf` branch of one (shallow-merged), and
|
|
150
|
+
* a `$defs`/anchor target (a property's `$ref` resolves there). */
|
|
151
|
+
const READ_BLOCK_KEYS = new Set(['allOf', '$defs', 'definitions']);
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Find an `x-entity` block the entity walk would never read — nested
|
|
155
|
+
* inside a property's `properties`, `items`, `anyOf`, … — so it fails
|
|
156
|
+
* the model instead of being ignored. §9.2's promise: a silently
|
|
157
|
+
* ignored mapping directive is a data-loss bug, so the vocabulary is
|
|
158
|
+
* closed in POSITION as well as in name.
|
|
159
|
+
* @param {any} node
|
|
160
|
+
* @param {string} path
|
|
161
|
+
* @param {boolean} read - whether a block AT this node is read
|
|
162
|
+
* @returns {string | null} the docPath of an unread block
|
|
163
|
+
*/
|
|
164
|
+
function unreadEntityBlock(node, path, read) {
|
|
165
|
+
if (node === null || typeof node !== 'object') return null;
|
|
166
|
+
if (Array.isArray(node)) {
|
|
167
|
+
for (let i = 0; i < node.length; i++) {
|
|
168
|
+
const found = unreadEntityBlock(node[i], `${path}/${i}`, read);
|
|
169
|
+
if (found !== null) return found;
|
|
170
|
+
}
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
for (const key of Object.keys(node)) {
|
|
174
|
+
if (key === 'x-entity') {
|
|
175
|
+
if (!read) return `${path}/x-entity`;
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
// a block one level under a read position is read only through
|
|
179
|
+
// `allOf`/`$defs`; under anything else it is out of the walk
|
|
180
|
+
const found = unreadEntityBlock(node[key], `${path}/${key}`, read && READ_BLOCK_KEYS.has(key));
|
|
181
|
+
if (found !== null) return found;
|
|
182
|
+
}
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
|
|
132
186
|
/**
|
|
133
187
|
* Normalize the `entities` member of a model document.
|
|
134
188
|
* @param {any} model
|
|
@@ -169,14 +223,54 @@ export function normalizeEntities(model) {
|
|
|
169
223
|
? raw['x-entity'] : undefined),
|
|
170
224
|
`${propertyPath}/x-entity`);
|
|
171
225
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
226
|
+
// a nullable scalar (`['string', 'null']`) is that scalar; a union
|
|
227
|
+
// of two or more scalar types has no one column type and lives in
|
|
228
|
+
// the document (§9.3) — mapping it by its FIRST member stored `5`
|
|
229
|
+
// as `"5.0"` in a text column
|
|
230
|
+
const scalarMembers = Array.isArray(effective.type)
|
|
231
|
+
? effective.type.filter((t) => t !== 'null')
|
|
232
|
+
: [effective.type];
|
|
233
|
+
const type = scalarMembers.length === 1 && typeof scalarMembers[0] === 'string'
|
|
234
|
+
? scalarMembers[0]
|
|
235
|
+
: undefined;
|
|
236
|
+
const union = scalarMembers.length > 1;
|
|
177
237
|
|
|
178
238
|
if (entityBlock.default !== undefined)
|
|
179
239
|
checkDefault(entityBlock.default, `${propertyPath}/x-entity/default`);
|
|
240
|
+
if (entityBlock.default !== undefined && entityBlock.relation !== undefined) {
|
|
241
|
+
throw modelError('a relation member takes no default — it is a projection, not stored state',
|
|
242
|
+
`${propertyPath}/x-entity/default`);
|
|
243
|
+
}
|
|
244
|
+
if (entityBlock.default === 'auto' && entityBlock.key !== true) {
|
|
245
|
+
throw modelError("default: 'auto' is allocated by the database for a single integer key only",
|
|
246
|
+
`${propertyPath}/x-entity/default`);
|
|
247
|
+
}
|
|
248
|
+
// the mapping directives that need a column of their own: on a
|
|
249
|
+
// property the document keeps — `column: 'json'`, a non-scalar, a
|
|
250
|
+
// union — they were accepted and never applied (a key with no key
|
|
251
|
+
// column let duplicates in and broke every read)
|
|
252
|
+
const columnMapped = SCALARS.has(type ?? '') && entityBlock.column !== 'json';
|
|
253
|
+
const needsColumn = ['key', 'unique', 'index'].filter((member) => entityBlock[member] === true);
|
|
254
|
+
if (!columnMapped && needsColumn.length > 0) {
|
|
255
|
+
const why = entityBlock.column === 'json'
|
|
256
|
+
? "column: 'json' keeps the property in the document"
|
|
257
|
+
: union ? 'a union of scalar types lives in the document'
|
|
258
|
+
: 'only a top-level scalar takes a column';
|
|
259
|
+
throw modelError(entityBlock.key === true
|
|
260
|
+
? `a key property must be a scalar with a column of its own — ${why}`
|
|
261
|
+
: `'${propertyName}' declares ${needsColumn.join('/')} but has no column — ${why}`,
|
|
262
|
+
propertyPath);
|
|
263
|
+
}
|
|
264
|
+
for (const key of Object.keys(raw !== null && typeof raw === 'object' ? raw : {})) {
|
|
265
|
+
if (key === 'x-entity') continue;
|
|
266
|
+
const unread = unreadEntityBlock(raw[key], `${propertyPath}/${key}`, READ_BLOCK_KEYS.has(key));
|
|
267
|
+
if (unread !== null) {
|
|
268
|
+
throw new DbCompileError('JD0030',
|
|
269
|
+
'x-entity applies to an entity\'s top-level properties (and their allOf/$ref '
|
|
270
|
+
+ 'targets) only — a nested block is never read, so it is refused rather than ignored',
|
|
271
|
+
unread);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
180
274
|
if (entityBlock.column !== undefined
|
|
181
275
|
&& entityBlock.column !== 'integer' && entityBlock.column !== 'json') {
|
|
182
276
|
throw modelError("x-entity.column is 'integer' (epoch date column) or 'json' (stay in the document)",
|
|
@@ -289,6 +383,16 @@ function resolveRelations(entities) {
|
|
|
289
383
|
// many-to-many: a join table
|
|
290
384
|
relation.kind = 'manyToMany';
|
|
291
385
|
const other = relation.to;
|
|
386
|
+
if (other === owner) {
|
|
387
|
+
throw modelError('a many-to-many relation to the entity itself is not supported — '
|
|
388
|
+
+ 'both endpoint columns would carry the same name', docPath);
|
|
389
|
+
}
|
|
390
|
+
for (const endpoint of [owner, other]) {
|
|
391
|
+
if (entities.get(endpoint).keys.length !== 1) {
|
|
392
|
+
throw modelError(`a many-to-many relation needs single-key endpoints; '${endpoint}' `
|
|
393
|
+
+ 'declares a composite key', docPath);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
292
396
|
relation.joinTable = typeof relation.through === 'string'
|
|
293
397
|
? relation.through
|
|
294
398
|
: [owner, other].sort().join('_');
|
|
@@ -313,6 +417,13 @@ function resolveRelations(entities) {
|
|
|
313
417
|
const holder = entities.get(relation.fkEntity);
|
|
314
418
|
const declaredVia = holder.properties.get(relation.via);
|
|
315
419
|
const targetEntity = entities.get(relation.fkTargets);
|
|
420
|
+
if (targetEntity.keys.length !== 1) {
|
|
421
|
+
// a foreign key references ONE column; a composite-key target
|
|
422
|
+
// rendered a reference to its first key alone, which SQLite
|
|
423
|
+
// refused at the first write with a raw "foreign key mismatch"
|
|
424
|
+
throw modelError(`a foreign-key relation must reference a single-key entity; `
|
|
425
|
+
+ `'${relation.fkTargets}' declares a composite key`, docPath);
|
|
426
|
+
}
|
|
316
427
|
const targetKeyType = targetEntity.properties.get(targetEntity.keys[0]).type;
|
|
317
428
|
if (declaredVia !== undefined) {
|
|
318
429
|
if (declaredVia.type !== targetKeyType || declaredVia.column === 'json') {
|
|
@@ -355,7 +466,8 @@ function resolveRelations(entities) {
|
|
|
355
466
|
if (relationA.kind !== relationB.kind) {
|
|
356
467
|
throw new DbCompileError('JD0031',
|
|
357
468
|
`relation '${a.owner}.${a.property.name}' and '${b.owner}.${b.property.name}' `
|
|
358
|
-
+ `disagree on the foreign key ('${relationA.via}' vs '${relationB.via}')
|
|
469
|
+
+ `disagree on the foreign key ('${relationA.via}' vs '${relationB.via}') `
|
|
470
|
+
+ '— two edges between the same pair declare their inverse on one side only (§9.4)',
|
|
359
471
|
`${a.property.docPath}/x-entity/relation`);
|
|
360
472
|
}
|
|
361
473
|
continue;
|
|
@@ -377,6 +489,53 @@ function resolveRelations(entities) {
|
|
|
377
489
|
}
|
|
378
490
|
}
|
|
379
491
|
|
|
492
|
+
/**
|
|
493
|
+
* The relation table a producer may read (§10.1): per entity, one
|
|
494
|
+
* frozen record per declared relation member — `to`, `kind`, and for a
|
|
495
|
+
* foreign-key relation `via`, `fkEntity`, `fkTargets` and `targetKey`
|
|
496
|
+
* (the key property the foreign key references on `fkTargets`, which
|
|
497
|
+
* is what a hop's equality compares `via` against); for a many-to-many
|
|
498
|
+
* `joinTable` and the target's `targetKey`. Plain data, keyed by
|
|
499
|
+
* entity name and then by member name, so a query producer can lower
|
|
500
|
+
* a relation hop to the phrases the translator runs without a second
|
|
501
|
+
* vocabulary and without importing this package.
|
|
502
|
+
* @param {Map<string, any>} entities - normalized entities
|
|
503
|
+
* @returns {Readonly<Record<string, Readonly<Record<string, any>>>>}
|
|
504
|
+
*/
|
|
505
|
+
export function relationTables(entities) {
|
|
506
|
+
/** @type {Record<string, any>} */
|
|
507
|
+
const tables = {};
|
|
508
|
+
for (const entity of entities.values()) {
|
|
509
|
+
/** @type {Record<string, any>} */
|
|
510
|
+
const table = {};
|
|
511
|
+
for (const property of entity.relations) {
|
|
512
|
+
const relation = property.relation;
|
|
513
|
+
const entry = relation.kind === 'manyToMany'
|
|
514
|
+
? {
|
|
515
|
+
to: relation.to,
|
|
516
|
+
kind: relation.kind,
|
|
517
|
+
joinTable: relation.joinTable,
|
|
518
|
+
targetKey: entities.get(relation.to).keys[0],
|
|
519
|
+
}
|
|
520
|
+
: {
|
|
521
|
+
to: relation.to,
|
|
522
|
+
kind: relation.kind,
|
|
523
|
+
via: relation.via,
|
|
524
|
+
fkEntity: relation.fkEntity,
|
|
525
|
+
fkTargets: relation.fkTargets,
|
|
526
|
+
targetKey: entities.get(relation.fkTargets).keys[0],
|
|
527
|
+
};
|
|
528
|
+
// an own member, whatever the property is named: `__proto__` as
|
|
529
|
+
// a member name would otherwise rewrite the record's prototype
|
|
530
|
+
Object.defineProperty(table, property.name,
|
|
531
|
+
{ value: Object.freeze(entry), writable: true, enumerable: true, configurable: true });
|
|
532
|
+
}
|
|
533
|
+
Object.defineProperty(tables, entity.name,
|
|
534
|
+
{ value: Object.freeze(table), writable: true, enumerable: true, configurable: true });
|
|
535
|
+
}
|
|
536
|
+
return Object.freeze(tables);
|
|
537
|
+
}
|
|
538
|
+
|
|
380
539
|
/**
|
|
381
540
|
* The hybrid mapping, derived mechanically from §9.3's table and
|
|
382
541
|
* returned as DATA: per entity, the columns (name, type, source),
|
|
@@ -408,7 +567,13 @@ export function explainMapping(model) {
|
|
|
408
567
|
source: epoch ? 'epoch(document)' : 'document',
|
|
409
568
|
key: property.key,
|
|
410
569
|
};
|
|
411
|
-
if (property.enum !== undefined)
|
|
570
|
+
if (property.enum !== undefined) {
|
|
571
|
+
// `null` never fails a CHECK (NULL IN (…) is unknown, which
|
|
572
|
+
// passes), but rendered into the list it made the whole CHECK
|
|
573
|
+
// unknown for EVERY value — a nullable enum accepted anything
|
|
574
|
+
const values = property.enum.filter((value) => value !== null);
|
|
575
|
+
if (values.length > 0) column.check = values;
|
|
576
|
+
}
|
|
412
577
|
columns.push(column);
|
|
413
578
|
if (property.unique) indexes.push({ property: property.name, unique: true });
|
|
414
579
|
else if (property.index) indexes.push({ property: property.name, unique: false });
|
package/src/plan.js
CHANGED
|
@@ -45,7 +45,7 @@ import { compileBuckets, resampleSeries, toEpoch } from '@jarenjs/core/series';
|
|
|
45
45
|
|
|
46
46
|
import { selectPlan, conjoin, PLAN_VERSION } from './algebra.js';
|
|
47
47
|
import { typeOfPath, isNumericType } from './types.js';
|
|
48
|
-
import { schemaNodeAt } from './ddl.js';
|
|
48
|
+
import { schemaNodeAt, canonicalOf } from './ddl.js';
|
|
49
49
|
import {
|
|
50
50
|
BBOX_COMPONENTS, BBOX_INDEX_ORDER, PRECISION_MIN, PRECISION_MAX,
|
|
51
51
|
probeBox, probePosition, probeCircleBox, cellNeighbourhood, probeVector,
|
|
@@ -125,6 +125,31 @@ for (const kind of NODE_KINDS) {
|
|
|
125
125
|
* @param {string} reason
|
|
126
126
|
* @returns {{ construct: string, reason: string }}
|
|
127
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
|
+
|
|
128
153
|
function refusal(construct, reason) {
|
|
129
154
|
return { construct, reason };
|
|
130
155
|
}
|
|
@@ -268,9 +293,7 @@ function memberPath(node, itSlot) {
|
|
|
268
293
|
else return null;
|
|
269
294
|
}
|
|
270
295
|
if (segments.length === 0) return null;
|
|
271
|
-
|
|
272
|
-
.map((s) => ('name' in s ? `.${s.name}` : `[${s.index}]`)).join('');
|
|
273
|
-
return { segments, canonical };
|
|
296
|
+
return { segments, canonical: canonicalOf(segments) };
|
|
274
297
|
}
|
|
275
298
|
|
|
276
299
|
/**
|
|
@@ -634,6 +657,10 @@ function planDistanceBound(node, itSlot, shape) {
|
|
|
634
657
|
if (constant === null) return { refusal: refusal('$distance', SPATIAL_REASONS.operand) };
|
|
635
658
|
const at = probePosition(constant.value);
|
|
636
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
|
+
}
|
|
637
664
|
const box = probeCircleBox(at, radius.value);
|
|
638
665
|
if (box === null) return { refusal: refusal('$distance', SPATIAL_REASONS.pole) };
|
|
639
666
|
if (box[0] < -180 || box[2] > 180)
|
|
@@ -1046,13 +1073,30 @@ function planPredicate(node, itSlot, shape) {
|
|
|
1046
1073
|
// and it is why a calendar ladder, a fill policy, a rolling window and
|
|
1047
1074
|
// an as-of JOIN cost a bounded fetch rather than a wrong answer.
|
|
1048
1075
|
|
|
1049
|
-
/**
|
|
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? */
|
|
1050
1093
|
function isCollectionSource(node) {
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
&&
|
|
1054
|
-
&&
|
|
1055
|
-
&&
|
|
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';
|
|
1056
1100
|
}
|
|
1057
1101
|
|
|
1058
1102
|
/**
|
|
@@ -1081,7 +1125,7 @@ function memberRef(shape, name) {
|
|
|
1081
1125
|
return {
|
|
1082
1126
|
segments,
|
|
1083
1127
|
type: typeOfPath(shape.schema, segments),
|
|
1084
|
-
column: shape.columnByCanonical.get(
|
|
1128
|
+
column: shape.columnByCanonical.get(canonicalOf(segments)) ?? null,
|
|
1085
1129
|
};
|
|
1086
1130
|
}
|
|
1087
1131
|
|
|
@@ -1643,13 +1687,10 @@ function planFlwor(node, shape, rawFlwor, udfHook) {
|
|
|
1643
1687
|
const plan = selectPlan(shape.collection);
|
|
1644
1688
|
|
|
1645
1689
|
// the one recognised source shape: a single plain binding over $[*]
|
|
1690
|
+
// (bare or packed — one check, `isCollectionSource`, for every site)
|
|
1646
1691
|
const binding = node.forBindings[0];
|
|
1647
|
-
const source = binding?.expr;
|
|
1648
1692
|
const sourceIsCollection = node.forBindings.length === 1
|
|
1649
|
-
&&
|
|
1650
|
-
&& source.segments.length === 1 && source.segments[0].descendant !== true
|
|
1651
|
-
&& source.segments[0].selectors.length === 1
|
|
1652
|
-
&& source.segments[0].selectors[0].kind === 'wildcard'
|
|
1693
|
+
&& isCollectionSource(binding?.expr)
|
|
1653
1694
|
&& binding.window === null && binding.atSlot === -1
|
|
1654
1695
|
&& binding.allowingEmpty === false;
|
|
1655
1696
|
if (!sourceIsCollection) {
|
|
@@ -1752,9 +1793,9 @@ function planFlwor(node, shape, rawFlwor, udfHook) {
|
|
|
1752
1793
|
let refused = null;
|
|
1753
1794
|
for (const spec of node.orderby.specs) {
|
|
1754
1795
|
const ref = pathRef(spec.key, itSlot, shape);
|
|
1755
|
-
if (ref === null || ref.
|
|
1796
|
+
if (ref === null || !orderable(ref, shape.schema)) {
|
|
1756
1797
|
refused = refusal('$orderby',
|
|
1757
|
-
'ordering translates only over singular schema-typed paths');
|
|
1798
|
+
'ordering translates only over singular schema-typed paths (numbers and strings that cannot hold null)');
|
|
1758
1799
|
break;
|
|
1759
1800
|
}
|
|
1760
1801
|
if (spec.collation !== null || spec.collationName !== null) {
|
|
@@ -1855,12 +1896,12 @@ function planCollectionCore(document, shape, options = undefined) {
|
|
|
1855
1896
|
const windows = [];
|
|
1856
1897
|
while (root.kind === 'op' && root.name === '$subsequence') {
|
|
1857
1898
|
const [inner, start, length] = root.args;
|
|
1858
|
-
if (start?.kind !== 'literal' ||
|
|
1859
|
-
|| (length !== undefined && (length.kind !== 'literal' ||
|
|
1899
|
+
if (start?.kind !== 'literal' || !isWindowBound(start.value)
|
|
1900
|
+
|| (length !== undefined && (length.kind !== 'literal' || !isWindowBound(length.value)))) {
|
|
1860
1901
|
// non-literal bounds: the whole document is a set residual
|
|
1861
1902
|
return {
|
|
1862
1903
|
analysis, plan: null, mode: 'set',
|
|
1863
|
-
reasons: [refusal('$subsequence', 'window bounds must be literal numbers to push')],
|
|
1904
|
+
reasons: [refusal('$subsequence', 'window bounds must be literal numbers to push (non-negative integers)')],
|
|
1864
1905
|
rowReturn: null, udfs: [], prefilters: [], series: null,
|
|
1865
1906
|
};
|
|
1866
1907
|
}
|
|
@@ -1947,6 +1988,16 @@ function planCollectionCore(document, shape, options = undefined) {
|
|
|
1947
1988
|
return { analysis, plan: null, mode: 'set', reasons: flwor.reasons,
|
|
1948
1989
|
rowReturn: null, udfs: [], prefilters: flwor.prefilters, series: null };
|
|
1949
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
|
+
};
|
|
2000
|
+
}
|
|
1950
2001
|
if (aggregate.fn === 'count') {
|
|
1951
2002
|
if (!flwor.projectionNative) {
|
|
1952
2003
|
return {
|
|
@@ -1964,7 +2015,8 @@ function planCollectionCore(document, shape, options = undefined) {
|
|
|
1964
2015
|
const ref = pathRef(root.ret, flwor.itSlot, shape);
|
|
1965
2016
|
const numeric = aggregate.fn === 'sum' || aggregate.fn === 'avg';
|
|
1966
2017
|
const acceptable = ref !== null
|
|
1967
|
-
&& (numeric ? isNumericType(ref.type) : ref.type !== 'unknown')
|
|
2018
|
+
&& (numeric ? isNumericType(ref.type) : ref.type !== 'unknown')
|
|
2019
|
+
&& ref.type !== 'boolean' && !admitsNull(shape.schema, ref.segments);
|
|
1968
2020
|
if (!acceptable) {
|
|
1969
2021
|
return {
|
|
1970
2022
|
analysis, plan: null, mode: 'set',
|
|
@@ -2069,10 +2121,30 @@ function planCollectionCore(document, shape, options = undefined) {
|
|
|
2069
2121
|
* }}
|
|
2070
2122
|
*/
|
|
2071
2123
|
export function planQuery(document, shape, options = undefined) {
|
|
2072
|
-
const
|
|
2124
|
+
const peeled = peelWrappedResult(document);
|
|
2125
|
+
const planned = { ...planCollectionCore(peeled.document, shape, options), wrapped: peeled.wrapped };
|
|
2073
2126
|
return prependRegisteredReason(planned, document, shape?.operators);
|
|
2074
2127
|
}
|
|
2075
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
|
+
|
|
2076
2148
|
// ————— The entity document kind (one planner, two document kinds) —————
|
|
2077
2149
|
|
|
2078
2150
|
/**
|
|
@@ -2090,7 +2162,7 @@ export function entityShape(entity, entityMapping) {
|
|
|
2090
2162
|
const flavors = new Map();
|
|
2091
2163
|
for (const column of entityMapping.columns) {
|
|
2092
2164
|
const epoch = column.source === 'epoch(document)';
|
|
2093
|
-
flavors.set(
|
|
2165
|
+
flavors.set(canonicalOf([{ name: column.name }]), {
|
|
2094
2166
|
column: column.name,
|
|
2095
2167
|
flavor: epoch ? 'entity-epoch' : 'entity-column',
|
|
2096
2168
|
storage: column.storage,
|
|
@@ -2098,8 +2170,9 @@ export function entityShape(entity, entityMapping) {
|
|
|
2098
2170
|
});
|
|
2099
2171
|
}
|
|
2100
2172
|
for (const fk of entityMapping.foreignKeys) {
|
|
2101
|
-
|
|
2102
|
-
|
|
2173
|
+
const fkCanonical = canonicalOf([{ name: fk.column }]);
|
|
2174
|
+
if (!flavors.has(fkCanonical))
|
|
2175
|
+
flavors.set(fkCanonical, { column: fk.column, flavor: 'entity-column', storage: 'string' });
|
|
2103
2176
|
}
|
|
2104
2177
|
return {
|
|
2105
2178
|
collection: entity.name,
|
|
@@ -2120,8 +2193,7 @@ export function entityShape(entity, entityMapping) {
|
|
|
2120
2193
|
export function entityPathRef(node, slot, shape) {
|
|
2121
2194
|
const ref = pathRef(node, slot, shape);
|
|
2122
2195
|
if (ref === null) return null;
|
|
2123
|
-
const canonical = ref.segments
|
|
2124
|
-
.map((s) => ('name' in s ? `.${s.name}` : `[${s.index}]`)).join('');
|
|
2196
|
+
const canonical = canonicalOf(ref.segments);
|
|
2125
2197
|
const flavored = shape.entityFlavors.get(canonical);
|
|
2126
2198
|
if (flavored !== undefined) {
|
|
2127
2199
|
return {
|
|
@@ -2157,8 +2229,7 @@ export function planEntityPredicate(node, slot, shape) {
|
|
|
2157
2229
|
return { ...pred, items: pred.items.map(reflavor) };
|
|
2158
2230
|
if (pred.p === 'not') return { ...pred, item: reflavor(pred.item) };
|
|
2159
2231
|
if (!('ref' in pred) || pred.ref === null) return pred;
|
|
2160
|
-
const canonical = pred.ref.segments
|
|
2161
|
-
.map((s) => ('name' in s ? `.${s.name}` : `[${s.index}]`)).join('');
|
|
2232
|
+
const canonical = canonicalOf(pred.ref.segments);
|
|
2162
2233
|
const flavored = shape.entityFlavors.get(canonical);
|
|
2163
2234
|
if (flavored === undefined) {
|
|
2164
2235
|
// externals against DOC paths are not translated here (the
|
|
@@ -2228,9 +2299,9 @@ function planEntityQueryCore(document, entities, mapping, operators) {
|
|
|
2228
2299
|
const windows = [];
|
|
2229
2300
|
while (root.kind === 'op' && root.name === '$subsequence') {
|
|
2230
2301
|
const [inner, start, length] = root.args;
|
|
2231
|
-
if (start?.kind !== 'literal' ||
|
|
2232
|
-
|| (length !== undefined && (length.kind !== 'literal' ||
|
|
2233
|
-
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)');
|
|
2234
2305
|
windows.push({ offset: start.value, limit: length === undefined ? null : length.value });
|
|
2235
2306
|
root = inner;
|
|
2236
2307
|
assertDecidedKind(root);
|
|
@@ -2250,7 +2321,7 @@ function planEntityQueryCore(document, entities, mapping, operators) {
|
|
|
2250
2321
|
// bindings must each range over one entity's array
|
|
2251
2322
|
const bindings = [];
|
|
2252
2323
|
for (const binding of root.forBindings) {
|
|
2253
|
-
const source = binding.expr;
|
|
2324
|
+
const source = unpacked(binding.expr);
|
|
2254
2325
|
const sourceEntity = source?.kind === 'path' && source.name === '$'
|
|
2255
2326
|
&& source.external !== true && source.segments.length === 2
|
|
2256
2327
|
&& source.segments[0].descendant !== true
|
|
@@ -2344,11 +2415,16 @@ function planEntityQueryCore(document, entities, mapping, operators) {
|
|
|
2344
2415
|
const binding = byName.get(slot);
|
|
2345
2416
|
const ref = binding === undefined
|
|
2346
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)
|
|
2347
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))
|
|
2348
2424
|
|| spec.collation !== null || spec.collationName !== null) {
|
|
2349
2425
|
orderPushed = false;
|
|
2350
2426
|
reasons.push({ construct: '$orderby',
|
|
2351
|
-
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)' });
|
|
2352
2428
|
break;
|
|
2353
2429
|
}
|
|
2354
2430
|
terms.push({ binding, ref, desc: spec.desc === true, emptyGreatest: spec.emptyGreatest === true });
|
|
@@ -2414,7 +2490,11 @@ function planEntityQueryCore(document, entities, mapping, operators) {
|
|
|
2414
2490
|
* @returns {any}
|
|
2415
2491
|
*/
|
|
2416
2492
|
export function planEntityQuery(document, entities, mapping, operators = null) {
|
|
2417
|
-
const
|
|
2493
|
+
const peeled = peelWrappedResult(document);
|
|
2494
|
+
const planned = {
|
|
2495
|
+
...planEntityQueryCore(peeled.document, entities, mapping, operators),
|
|
2496
|
+
wrapped: peeled.wrapped,
|
|
2497
|
+
};
|
|
2418
2498
|
return prependRegisteredReason(planned, document, operators);
|
|
2419
2499
|
}
|
|
2420
2500
|
|
|
@@ -2432,13 +2512,30 @@ function collectBindingSlots(node, byName, slots) {
|
|
|
2432
2512
|
}
|
|
2433
2513
|
}
|
|
2434
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
|
+
|
|
2435
2528
|
/** The entity names a document's root paths reference (`$.Name[*]`). */
|
|
2436
2529
|
export function collectEntityRoots(document, entities) {
|
|
2437
2530
|
const found = new Set();
|
|
2438
2531
|
const walk = (node) => {
|
|
2439
2532
|
if (typeof node === 'string') {
|
|
2440
|
-
const
|
|
2441
|
-
|
|
2533
|
+
for (const name of entities.keys()) {
|
|
2534
|
+
if (node.startsWith(entityRoot(name))) {
|
|
2535
|
+
found.add(name);
|
|
2536
|
+
break;
|
|
2537
|
+
}
|
|
2538
|
+
}
|
|
2442
2539
|
return;
|
|
2443
2540
|
}
|
|
2444
2541
|
if (Array.isArray(node)) {
|