@jarenjs/db 0.43.3 → 0.46.5
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 +157 -13
- package/README.md +328 -33
- package/dist/types/algebra.d.ts +32 -2
- package/dist/types/ddl.d.ts +31 -6
- package/dist/types/derive.d.ts +105 -16
- package/dist/types/dialect.d.ts +11 -1
- package/dist/types/index.d.ts +2 -1
- package/dist/types/knn.d.ts +69 -0
- package/dist/types/migrate.d.ts +2 -1
- package/dist/types/plan.d.ts +11 -4
- package/dist/types/query.d.ts +11 -0
- package/docs/LIVE-FORMAT.md +30 -0
- package/docs/MIGRATION-FORMAT.md +10 -0
- package/docs/MODEL-FORMAT.md +298 -27
- package/package.json +4 -4
- package/schemas/jaren-migration.draft-07.schema.json +8 -2
- package/schemas/jaren-migration.schema.json +8 -2
- package/schemas/jaren-model.draft-07.schema.json +16 -2
- package/schemas/jaren-model.schema.json +16 -2
- package/src/algebra.js +20 -2
- package/src/ddl.js +234 -26
- package/src/derive.js +158 -19
- package/src/dialect.js +134 -2
- package/src/dialects/sqlite.js +33 -4
- package/src/emit.js +30 -6
- package/src/index.js +5 -3
- package/src/knn.js +96 -0
- package/src/live.js +87 -7
- package/src/migrate.js +55 -4
- package/src/plan.js +280 -36
- package/src/query.js +177 -44
- package/src/store.js +151 -60
- package/types/index.d.ts +53 -11
- package/types/typed.d.ts +4 -4
package/src/migrate.js
CHANGED
|
@@ -39,12 +39,15 @@ import { derivedValue, memberAt, registerDeriveFunctions } from './derive.js';
|
|
|
39
39
|
* generates them; one that cannot has them written, which is why the
|
|
40
40
|
* planner emits a backfill for the second and not the first.
|
|
41
41
|
* @param {any} connection
|
|
42
|
-
* @returns {{ derived: 'virtual' | 'stored' }}
|
|
42
|
+
* @returns {{ derived: 'virtual' | 'stored', rtree: boolean }}
|
|
43
43
|
*/
|
|
44
44
|
function mappingFor(connection) {
|
|
45
45
|
return {
|
|
46
46
|
derived: connection.capabilities?.deterministicIndexableFunctions === true
|
|
47
47
|
? 'virtual' : 'stored',
|
|
48
|
+
// the same reasoning for the R*Tree mapping: a build without the
|
|
49
|
+
// module plans (and verifies) the B-tree shape
|
|
50
|
+
rtree: connection.capabilities?.rtree === true,
|
|
48
51
|
};
|
|
49
52
|
}
|
|
50
53
|
|
|
@@ -102,6 +105,7 @@ function deriveStep(collection, plan, columnNames, note) {
|
|
|
102
105
|
const entry = { name: column.name, derive: column.derive, segments: column.segments };
|
|
103
106
|
if (column.precision !== undefined) entry.precision = column.precision;
|
|
104
107
|
if (column.component !== undefined) entry.component = column.component;
|
|
108
|
+
if (column.dims !== undefined) entry.dims = column.dims;
|
|
105
109
|
return entry;
|
|
106
110
|
});
|
|
107
111
|
return { kind: 'derive', collection, columns, note };
|
|
@@ -121,7 +125,7 @@ function deriveStep(collection, plan, columnNames, note) {
|
|
|
121
125
|
* @param {any} fromModel
|
|
122
126
|
* @param {any} toModel
|
|
123
127
|
* @param {{ id?: string, dialect?: any,
|
|
124
|
-
* derived?: 'virtual' | 'stored' }} [options]
|
|
128
|
+
* derived?: 'virtual' | 'stored', rtree?: boolean }} [options]
|
|
125
129
|
* @returns {{ migration: any, report: {
|
|
126
130
|
* renamed: { from: string, to: string }[],
|
|
127
131
|
* added: string[], removed: string[],
|
|
@@ -132,7 +136,7 @@ export function planMigration(fromModel, toModel, options = undefined) {
|
|
|
132
136
|
const dialect = options?.dialect ?? null;
|
|
133
137
|
if (dialect === null || typeof dialect !== 'object')
|
|
134
138
|
throw new TypeError('planMigration needs { dialect } (the store dialect renders the DDL)');
|
|
135
|
-
const mapping = { derived: options?.derived ?? 'virtual' };
|
|
139
|
+
const mapping = { derived: options?.derived ?? 'virtual', rtree: options?.rtree !== false };
|
|
136
140
|
const fromCollections = normalizeModel(fromModel);
|
|
137
141
|
const toCollections = normalizeModel(toModel);
|
|
138
142
|
|
|
@@ -215,6 +219,33 @@ export function planMigration(fromModel, toModel, options = undefined) {
|
|
|
215
219
|
// database refuses to drop a column under a live index) — then
|
|
216
220
|
// everything runs in dependency order: drop indexes, drop columns,
|
|
217
221
|
// add columns, create indexes
|
|
222
|
+
// the R*Tree half of the physical shape (MODEL-FORMAT §2.1,
|
|
223
|
+
// `physical`). Changing it in either direction is a physical change
|
|
224
|
+
// and needs a migration, not an open-time alteration: the virtual
|
|
225
|
+
// table and its three triggers leave BEFORE the columns they read
|
|
226
|
+
// are disturbed, and arrive AFTER them with a backfill — a
|
|
227
|
+
// generated column arrives populated, an R*Tree does not.
|
|
228
|
+
//
|
|
229
|
+
// A virtual table is compared by NAME and not by declared text,
|
|
230
|
+
// which is where a column and an index are different: the name is
|
|
231
|
+
// built from the column stem, the module and its column list are
|
|
232
|
+
// fixed, and the three triggers are built from that same stem — so
|
|
233
|
+
// under this dialect a table present on both sides cannot differ,
|
|
234
|
+
// and one whose stem moved has a different name. `verifyShape`
|
|
235
|
+
// compares the declared text at open, which is the backstop if that
|
|
236
|
+
// ever stops being true.
|
|
237
|
+
const fromVirtual = new Map(fromPlan.virtualTables.map((v) => [v.name, v]));
|
|
238
|
+
const toVirtual = new Map(toPlan.virtualTables.map((v) => [v.name, v]));
|
|
239
|
+
for (const [virtualName, from] of fromVirtual) {
|
|
240
|
+
if (toVirtual.has(virtualName)) continue;
|
|
241
|
+
for (const trigger of from.triggers) {
|
|
242
|
+
steps.push({ kind: 'ddl', sql: dialect.ddl.dropTrigger(trigger.name),
|
|
243
|
+
note: `drop sync trigger '${trigger.name}' on '${name}'` });
|
|
244
|
+
}
|
|
245
|
+
steps.push({ kind: 'ddl', sql: dialect.ddl.dropVirtualTable(virtualName),
|
|
246
|
+
note: `drop the R*Tree '${virtualName}' (and its shadow tables) on '${name}'` });
|
|
247
|
+
}
|
|
248
|
+
|
|
218
249
|
const disturbedColumns = new Set();
|
|
219
250
|
for (const [columnName, fromColumn] of fromColumns) {
|
|
220
251
|
const target = toColumns.get(columnName);
|
|
@@ -271,6 +302,19 @@ export function planMigration(fromModel, toModel, options = undefined) {
|
|
|
271
302
|
});
|
|
272
303
|
}
|
|
273
304
|
}
|
|
305
|
+
for (const [virtualName, target] of toVirtual) {
|
|
306
|
+
if (fromVirtual.has(virtualName)) continue;
|
|
307
|
+
steps.push({ kind: 'ddl', sql: target.createSql,
|
|
308
|
+
note: `create the R*Tree '${virtualName}' on '${name}'` });
|
|
309
|
+
for (const trigger of target.triggers) {
|
|
310
|
+
steps.push({ kind: 'ddl', sql: trigger.sql,
|
|
311
|
+
note: `create sync trigger '${trigger.name}' on '${name}'` });
|
|
312
|
+
}
|
|
313
|
+
// the triggers fire on WRITES; the rows already stored need the
|
|
314
|
+
// backfill, and without it a probe silently returns nothing
|
|
315
|
+
steps.push({ kind: 'sql', sql: target.fillSql,
|
|
316
|
+
note: `backfill the R*Tree '${virtualName}' from the stored documents` });
|
|
317
|
+
}
|
|
274
318
|
|
|
275
319
|
if (canonicalizeJson(fromCollection.schema) !== canonicalizeJson(toCollection.schema)) {
|
|
276
320
|
report.schemaChanged.push(name);
|
|
@@ -298,7 +342,7 @@ export function planMigration(fromModel, toModel, options = undefined) {
|
|
|
298
342
|
}
|
|
299
343
|
}
|
|
300
344
|
|
|
301
|
-
for (const [name] of fromCollections) {
|
|
345
|
+
for (const [name, fromCollection] of fromCollections) {
|
|
302
346
|
if (toCollections.has(name) || consumedOldNames.has(name)) continue;
|
|
303
347
|
report.removed.push(name);
|
|
304
348
|
report.destructive = true;
|
|
@@ -309,6 +353,13 @@ export function planMigration(fromModel, toModel, options = undefined) {
|
|
|
309
353
|
+ 'A rename is declared with x-rename on the target collection; without '
|
|
310
354
|
+ 'one, this is a drop plus a create.',
|
|
311
355
|
});
|
|
356
|
+
// DROP TABLE takes the collection's own triggers with it and leaves
|
|
357
|
+
// the R*Tree — and its three shadow tables — standing. A leftover
|
|
358
|
+
// virtual table is a stale index a recreated collection would probe
|
|
359
|
+
for (const virtual of planCollection(name, fromCollection, dialect, mapping).virtualTables) {
|
|
360
|
+
steps.push({ kind: 'ddl', sql: dialect.ddl.dropVirtualTable(virtual.name),
|
|
361
|
+
note: `drop the R*Tree '${virtual.name}' that belonged to '${name}'` });
|
|
362
|
+
}
|
|
312
363
|
}
|
|
313
364
|
|
|
314
365
|
planEntityChanges(fromModel, toModel, dialect, steps, report);
|
package/src/plan.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*
|
|
12
12
|
* The outcome of planning one document:
|
|
13
13
|
*
|
|
14
|
-
* { plan, mode: 'native' | 'row' | 'set', reasons, rowReturn,
|
|
14
|
+
* { plan, mode: 'native' | 'row' | 'set' | 'knn', reasons, rowReturn,
|
|
15
15
|
* prefilters }
|
|
16
16
|
*
|
|
17
17
|
* - `native` — everything translated; the plan alone answers.
|
|
@@ -22,6 +22,11 @@
|
|
|
22
22
|
* wrapper built anywhere else would have to guess it.
|
|
23
23
|
* - `set` — the pushed conjuncts narrow candidates; the WHOLE
|
|
24
24
|
* compiled document runs over the materialized candidates.
|
|
25
|
+
* - `knn` — the pushed conjuncts narrow, the vector column CUTS the
|
|
26
|
+
* candidates of a k-nearest window (`plan.rank`), and the whole
|
|
27
|
+
* compiled document runs over the cut — a set residual whose
|
|
28
|
+
* candidate set an ordering, not a predicate, chose (see "The
|
|
29
|
+
* k-nearest promotion" below).
|
|
25
30
|
*
|
|
26
31
|
* `reasons` names every construct that forced work off the database,
|
|
27
32
|
* with reason text drawn from the deliberate-residual table.
|
|
@@ -42,8 +47,9 @@ import { typeOfPath, isNumericType } from './types.js';
|
|
|
42
47
|
import { schemaNodeAt } from './ddl.js';
|
|
43
48
|
import {
|
|
44
49
|
BBOX_COMPONENTS, BBOX_INDEX_ORDER, PRECISION_MIN, PRECISION_MAX,
|
|
45
|
-
probeBox, probePosition, probeCircleBox, cellNeighbourhood,
|
|
50
|
+
probeBox, probePosition, probeCircleBox, cellNeighbourhood, probeVector,
|
|
46
51
|
} from './derive.js';
|
|
52
|
+
import { KNN_MARGIN } from './knn.js';
|
|
47
53
|
|
|
48
54
|
/** Comparison operator names → plan ops. */
|
|
49
55
|
const COMPARISONS = new Map([
|
|
@@ -368,6 +374,9 @@ const SPATIAL_REASONS = {
|
|
|
368
374
|
+ 'disjuncts this suite\'s box convention does not carry — '
|
|
369
375
|
+ 'pushing nothing is correct, pushing a wrong box is not',
|
|
370
376
|
precision: "the cell length must match the derived column's precision",
|
|
377
|
+
rtreeBox: "the box is stored in an R*Tree, whose coordinates are 32-bit floats rounded "
|
|
378
|
+
+ 'OUTWARD, so the stored box is a superset of the row\'s; the exact box test refines '
|
|
379
|
+
+ 'in the engine',
|
|
371
380
|
};
|
|
372
381
|
|
|
373
382
|
/**
|
|
@@ -416,16 +425,77 @@ function constantOf(node) {
|
|
|
416
425
|
* @param {number} [precision]
|
|
417
426
|
* @returns {string | { w: string, s: string, e: string, n: string } | null}
|
|
418
427
|
*/
|
|
428
|
+
/**
|
|
429
|
+
* The R\*Tree virtual table a `bbox` column set is realized as, or
|
|
430
|
+
* `null` when it is realized as four columns under a B-tree — which is
|
|
431
|
+
* also the answer on a driver whose build carries no R\*Tree module,
|
|
432
|
+
* because the physical plan already fell back there (MODEL-FORMAT §4).
|
|
433
|
+
* @param {any} shape
|
|
434
|
+
* @param {string} canonical
|
|
435
|
+
* @returns {{ name: string, columns: string[] } | null}
|
|
436
|
+
*/
|
|
437
|
+
function rtreeTableOf(shape, canonical) {
|
|
438
|
+
const stem = shape.columnByCanonical?.get(`${canonical}|bbox|`);
|
|
439
|
+
if (stem === undefined) return null;
|
|
440
|
+
return shape.virtualByStem?.get(stem) ?? null;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* The pushed box conjunct for one column set, under whichever physical
|
|
445
|
+
* mapping the collection declares — the same box either way, because
|
|
446
|
+
* the implied-conjunct proof is a proof about BOXES and not about SQL.
|
|
447
|
+
*
|
|
448
|
+
* Under `'rtree'` it is a `rowid` subquery over the virtual table: a
|
|
449
|
+
* conjunct on the collection table, so the `FROM` clause, the residual
|
|
450
|
+
* machinery and `prefilters` are all untouched. A join would be 6 %
|
|
451
|
+
* faster and would need join support the emitter does not have; a
|
|
452
|
+
* correlated `EXISTS` defeats the virtual table's index entirely and is
|
|
453
|
+
* 85x worse than the subquery.
|
|
454
|
+
* @param {{ w: string, s: string, e: string, n: string }} columns
|
|
455
|
+
* @param {{ name: string, columns: string[] } | null} virtual
|
|
456
|
+
* @param {any} probe
|
|
457
|
+
* @returns {{ pred: any, via: 'columns' | 'rtree', columns: string[],
|
|
458
|
+
* inexact: boolean }}
|
|
459
|
+
*/
|
|
460
|
+
function boxConjunct(columns, virtual, probe) {
|
|
461
|
+
if (virtual === null) {
|
|
462
|
+
return { pred: { p: 'bboxOverlap', columns, probe },
|
|
463
|
+
via: 'columns', columns: boxColumnList(columns), inexact: false };
|
|
464
|
+
}
|
|
465
|
+
return { pred: { p: 'bboxRtree', table: virtual.name, columns: virtual.columns, probe },
|
|
466
|
+
via: 'rtree', columns: [...virtual.columns], inexact: true };
|
|
467
|
+
}
|
|
468
|
+
|
|
419
469
|
function derivedColumnsOf(shape, canonical, derive, precision) {
|
|
470
|
+
// `precision` is the identity's third component: a geohash column's
|
|
471
|
+
// precision, a vector column's `dims`
|
|
420
472
|
const stem = shape.columnByCanonical?.get(`${canonical}|${derive}|${precision ?? ''}`);
|
|
421
473
|
if (stem === undefined) return null;
|
|
422
|
-
if (derive === 'geohash') return stem;
|
|
474
|
+
if (derive === 'geohash' || derive === 'vector') return stem;
|
|
423
475
|
/** @type {any} */
|
|
424
476
|
const columns = {};
|
|
425
477
|
for (const component of BBOX_COMPONENTS) columns[`${component}`] = `${stem}_${component}`;
|
|
426
478
|
return columns;
|
|
427
479
|
}
|
|
428
480
|
|
|
481
|
+
/**
|
|
482
|
+
* The vector columns declared over one canonical path, by width. The
|
|
483
|
+
* identity key is `<canonical>|vector|<dims>`, so one path may carry
|
|
484
|
+
* one column per declared width and a probe chooses by its own.
|
|
485
|
+
* @param {any} shape
|
|
486
|
+
* @param {string} canonical
|
|
487
|
+
* @returns {{ column: string, dims: number }[]}
|
|
488
|
+
*/
|
|
489
|
+
function vectorColumnsOf(shape, canonical) {
|
|
490
|
+
const prefix = `${canonical}|vector|`;
|
|
491
|
+
const found = [];
|
|
492
|
+
for (const [key, column] of shape.columnByCanonical ?? []) {
|
|
493
|
+
if (key.startsWith(prefix))
|
|
494
|
+
found.push({ column, dims: Number(key.slice(prefix.length)) });
|
|
495
|
+
}
|
|
496
|
+
return found;
|
|
497
|
+
}
|
|
498
|
+
|
|
429
499
|
/**
|
|
430
500
|
* A spatial SUBJECT: a singular member path on the binding that the
|
|
431
501
|
* schema types as geography. Answers the canonical path a derived
|
|
@@ -499,11 +569,19 @@ function planBoxPredicate(node, itSlot, shape) {
|
|
|
499
569
|
if (box === null) return { refusal: refusal(construct, SPATIAL_REASONS.unbounded) };
|
|
500
570
|
probe = { box };
|
|
501
571
|
}
|
|
502
|
-
const
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
572
|
+
const conjunct = boxConjunct(columns, rtreeTableOf(shape, subject.canonical), probe);
|
|
573
|
+
// `$bbox-intersects` is EXACT over the four columns — they ARE
|
|
574
|
+
// `B(row)` — and only IMPLIED over an R*Tree, whose 32-bit float
|
|
575
|
+
// coordinates round outward: the stored box is a superset. No false
|
|
576
|
+
// negatives either way, which is what D8 needs; but a superset does
|
|
577
|
+
// not DECIDE, so the exact box test keeps its refinement and
|
|
578
|
+
// `strict: true` is JD0010 where the column mapping ran native
|
|
579
|
+
const exact = construct === '$bbox-intersects' && !conjunct.inexact;
|
|
580
|
+
const refinements = construct === '$bbox-intersects'
|
|
581
|
+
? (conjunct.inexact ? [refusal(construct, SPATIAL_REASONS.rtreeBox)] : [])
|
|
582
|
+
: [refusal('$within', SPATIAL_REASONS.within)];
|
|
583
|
+
return promotion(conjunct.pred,
|
|
584
|
+
{ construct, via: conjunct.via, columns: conjunct.columns, exact }, refinements);
|
|
507
585
|
}
|
|
508
586
|
|
|
509
587
|
/**
|
|
@@ -555,8 +633,9 @@ function planDistanceBound(node, itSlot, shape) {
|
|
|
555
633
|
if (box[0] < -180 || box[2] > 180)
|
|
556
634
|
return { refusal: refusal('$distance', SPATIAL_REASONS.wrapped) };
|
|
557
635
|
|
|
558
|
-
|
|
559
|
-
|
|
636
|
+
const conjunct = boxConjunct(columns, rtreeTableOf(shape, subject.canonical), { box });
|
|
637
|
+
return promotion(conjunct.pred,
|
|
638
|
+
{ construct: '$distance', via: conjunct.via, columns: conjunct.columns, exact: false },
|
|
560
639
|
[refusal('$distance', SPATIAL_REASONS.distance)]);
|
|
561
640
|
}
|
|
562
641
|
|
|
@@ -617,7 +696,7 @@ function planCellPrefix(node, itSlot, shape) {
|
|
|
617
696
|
? { p: 'cellIn', column: derivation.column, cells: [cell] }
|
|
618
697
|
: { p: 'cellPrefix', column: derivation.column, prefix: cell };
|
|
619
698
|
return promotion(pred,
|
|
620
|
-
{ construct: '$starts-with', columns: [derivation.column], exact },
|
|
699
|
+
{ construct: '$starts-with', via: 'columns', columns: [derivation.column], exact },
|
|
621
700
|
exact ? [] : [refusal('$starts-with', SPATIAL_REASONS.prefix)]);
|
|
622
701
|
}
|
|
623
702
|
|
|
@@ -652,7 +731,130 @@ function planCellNeighbourhood(node, itSlot, shape) {
|
|
|
652
731
|
if (cells.length === 0)
|
|
653
732
|
return { refusal: refusal('$geohash-neighbours', SPATIAL_REASONS.operand) };
|
|
654
733
|
return promotion({ p: 'cellIn', column: derivation.column, cells },
|
|
655
|
-
{ construct: '$geohash-neighbours', columns: [derivation.column],
|
|
734
|
+
{ construct: '$geohash-neighbours', via: 'columns', columns: [derivation.column],
|
|
735
|
+
exact: true });
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// ————— The k-nearest promotion: an ORDERING the column pre-filters —————
|
|
739
|
+
//
|
|
740
|
+
// `$orderby` on a `$similarity` key, descending, `$empty: 'least'`,
|
|
741
|
+
// under a `$subsequence` window with a finite limit, over a member a
|
|
742
|
+
// `derive: 'vector'` column stores: the one ordering the planner
|
|
743
|
+
// promotes. Not to SQL — no ORDER BY is emitted, no per-row similarity
|
|
744
|
+
// call, no LIMIT: every SQL spelling of the rank measured slower than
|
|
745
|
+
// fetching the packed column and ranking in the engine, and none of
|
|
746
|
+
// them runs where no function can be registered — but to a CUT. The
|
|
747
|
+
// statement projects (identity, column) under the pushed WHERE; the
|
|
748
|
+
// engine scores every row's column against the probe; the rows whose
|
|
749
|
+
// score is within `KNN_MARGIN` of the `offset + limit`-th best are the
|
|
750
|
+
// candidates. Then the ENGINE decides: the original document — its
|
|
751
|
+
// whole `$orderby` (the similarity key over the raw member, every
|
|
752
|
+
// secondary key, `$empty`), its window, its `$return` — runs as the set
|
|
753
|
+
// residual over exactly those documents.
|
|
754
|
+
//
|
|
755
|
+
// What makes that exact is the implied-conjunct argument, applied to an
|
|
756
|
+
// ordering. The column's score (a dot product over binary32-normalized
|
|
757
|
+
// forms) and the engine's key (the cosine of the raw doubles) differ by
|
|
758
|
+
// at most ~1e-8, measured; with a margin two orders of magnitude wider
|
|
759
|
+
// the engine's top `offset + limit` is a SUBSET of the candidates — a
|
|
760
|
+
// row the engine ranks inside the window that the column left out would
|
|
761
|
+
// need two true cosines to differ by more than the column can mis-order
|
|
762
|
+
// them. Ties at the boundary are included by construction, and the
|
|
763
|
+
// engine breaks them by the document's own secondary keys, which is why
|
|
764
|
+
// no row-identity tie rule exists here: row identity serves the fetch,
|
|
765
|
+
// never the order.
|
|
766
|
+
//
|
|
767
|
+
// Two shapes reach the window's tail that no cut can order: fewer than
|
|
768
|
+
// `offset + limit` rows scored — a small collection, or NULL columns
|
|
769
|
+
// (absent, wrong-width, non-finite members) — so the window reaches the
|
|
770
|
+
// unrankable rows, which `$empty: 'least'` places LAST in the engine's
|
|
771
|
+
// own secondary order. The candidate set is then every row, and the
|
|
772
|
+
// collection is no larger than the window.
|
|
773
|
+
//
|
|
774
|
+
// Preconditions, each a named refusal. The selection must be pushed
|
|
775
|
+
// WHOLE — every `$where` conjunct exact, nothing before it — because a
|
|
776
|
+
// conjunct left to the residual could drop a candidate the cut counted,
|
|
777
|
+
// and an implied one narrows to a superset the residual then shrinks;
|
|
778
|
+
// either could leave the window short. The probe must be a literal
|
|
779
|
+
// vector of the column's width, or an external: what an external
|
|
780
|
+
// carries is the binder's to check at call time, and a bound value that
|
|
781
|
+
// is not a vector of that width DIVERTS the call to the residual, where
|
|
782
|
+
// the engine answers what it answers everywhere (empty keys for another
|
|
783
|
+
// width, its own refusal of a non-array) — never a plan-side error the
|
|
784
|
+
// engine would not raise.
|
|
785
|
+
|
|
786
|
+
const KNN_REASONS = {
|
|
787
|
+
rank: 'k-nearest rank is engine work: the vector column cuts the candidates and the engine orders them',
|
|
788
|
+
direction: 'k-nearest is promoted only descending (higher similarity first)',
|
|
789
|
+
empties: "k-nearest is promoted only under $empty: 'least' (unrankable rows last, where the cut can reach them)",
|
|
790
|
+
collation: 'a collation on a similarity key is refused, not approximated',
|
|
791
|
+
subject: 'the similarity key must compare a singular member path on the binding with a probe',
|
|
792
|
+
probe: 'the probe must be a literal vector (an array of finite numbers) or an external',
|
|
793
|
+
selection: 'k-nearest ranks over the column only when the selection is pushed whole (every $where conjunct exact, no $let or $as before it)',
|
|
794
|
+
window: 'k-nearest needs a window with a finite limit; an unbounded ranking is a full sort in the engine',
|
|
795
|
+
/** @param {string} path @param {number | null} dims */
|
|
796
|
+
noColumn: (path, dims) =>
|
|
797
|
+
`no vector column over ${path}${dims === null ? '' : ` at width ${dims}`}`,
|
|
798
|
+
/** @param {string} path @param {number} want @param {number[]} have */
|
|
799
|
+
dims: (path, want, have) =>
|
|
800
|
+
`the literal probe has ${want} components but the vector column over ${path} is declared at ${have.join(', ')}`,
|
|
801
|
+
/** @param {string} path */
|
|
802
|
+
widths: (path) =>
|
|
803
|
+
`several vector widths are declared over ${path}; an external probe cannot choose one at plan time`,
|
|
804
|
+
};
|
|
805
|
+
|
|
806
|
+
/**
|
|
807
|
+
* Recognize the k-nearest ordering, or say why not. `null` when the
|
|
808
|
+
* first key is not a `$similarity` at all — an ordinary ordering the
|
|
809
|
+
* caller plans as before. Further key specs are the engine's business
|
|
810
|
+
* (they order the candidates), so nothing here reads them.
|
|
811
|
+
* @param {any} orderby - the flwor's orderby node
|
|
812
|
+
* @param {number} itSlot
|
|
813
|
+
* @param {any} shape
|
|
814
|
+
* @param {boolean} selectionPushed - the WHERE pushed whole and exact,
|
|
815
|
+
* with nothing before it
|
|
816
|
+
* @returns {{ rank: { column: string, dims: number,
|
|
817
|
+
* probe: { lit: number[] } | { ext: string } } }
|
|
818
|
+
* | { refusal: { construct: string, reason: string } } | null}
|
|
819
|
+
*/
|
|
820
|
+
function planKnnOrder(orderby, itSlot, shape, selectionPushed) {
|
|
821
|
+
const first = orderby.specs[0];
|
|
822
|
+
const key = first.key;
|
|
823
|
+
if (key.kind !== 'op' || key.name !== '$similarity') return null;
|
|
824
|
+
const refuse = (reason) => ({ refusal: refusal('$similarity', reason) });
|
|
825
|
+
if (first.desc !== true) return refuse(KNN_REASONS.direction);
|
|
826
|
+
if (first.emptyGreatest === true) return refuse(KNN_REASONS.empties);
|
|
827
|
+
if (first.collation !== null || first.collationName !== null)
|
|
828
|
+
return refuse(KNN_REASONS.collation);
|
|
829
|
+
// the subject may be either operand; the other is the probe
|
|
830
|
+
let subjectAt = 0;
|
|
831
|
+
if (memberPath(key.args[0], itSlot) === null && memberPath(key.args[1], itSlot) !== null)
|
|
832
|
+
subjectAt = 1;
|
|
833
|
+
const subject = memberPath(key.args[subjectAt], itSlot);
|
|
834
|
+
if (subject === null) return refuse(KNN_REASONS.subject);
|
|
835
|
+
const path = `$${subject.canonical}`;
|
|
836
|
+
const probeNode = key.args[subjectAt === 0 ? 1 : 0];
|
|
837
|
+
const declared = vectorColumnsOf(shape, subject.canonical);
|
|
838
|
+
|
|
839
|
+
if (probeNode.kind === 'var' && probeNode.external === true) {
|
|
840
|
+
if (declared.length === 0) return refuse(KNN_REASONS.noColumn(path, null));
|
|
841
|
+
if (declared.length > 1) return refuse(KNN_REASONS.widths(path));
|
|
842
|
+
if (!selectionPushed) return refuse(KNN_REASONS.selection);
|
|
843
|
+
return { rank: { column: declared[0].column, dims: declared[0].dims,
|
|
844
|
+
probe: { ext: probeNode.name } } };
|
|
845
|
+
}
|
|
846
|
+
const constant = constantOf(probeNode);
|
|
847
|
+
if (constant === null || !Array.isArray(constant.value)) return refuse(KNN_REASONS.probe);
|
|
848
|
+
const dims = constant.value.length;
|
|
849
|
+
if (probeVector(constant.value, dims) === null) return refuse(KNN_REASONS.probe);
|
|
850
|
+
const column = derivedColumnsOf(shape, subject.canonical, 'vector', dims);
|
|
851
|
+
if (column === null) {
|
|
852
|
+
return refuse(declared.length === 0
|
|
853
|
+
? KNN_REASONS.noColumn(path, dims)
|
|
854
|
+
: KNN_REASONS.dims(path, dims, declared.map((entry) => entry.dims)));
|
|
855
|
+
}
|
|
856
|
+
if (!selectionPushed) return refuse(KNN_REASONS.selection);
|
|
857
|
+
return { rank: { column: /** @type {string} */ (column), dims, probe: { lit: constant.value } } };
|
|
656
858
|
}
|
|
657
859
|
|
|
658
860
|
/**
|
|
@@ -823,7 +1025,10 @@ function planPredicate(node, itSlot, shape) {
|
|
|
823
1025
|
* reasons: { construct: string, reason: string }[],
|
|
824
1026
|
* whereFullyPushed: boolean, orderPushed: boolean,
|
|
825
1027
|
* projectionNative: boolean, itSlot: number, itName: string | null,
|
|
826
|
-
* udfs: string[], prefilters: any[]
|
|
1028
|
+
* udfs: string[], prefilters: any[],
|
|
1029
|
+
* knn: { column: string, dims: number, probe: any } | null }}
|
|
1030
|
+
* `knn` is the recognized k-nearest ordering, pending the window
|
|
1031
|
+
* the caller peels; its ordering is then never pushed
|
|
827
1032
|
*/
|
|
828
1033
|
function planFlwor(node, shape, rawFlwor, udfHook) {
|
|
829
1034
|
const reasons = [];
|
|
@@ -843,7 +1048,7 @@ function planFlwor(node, shape, rawFlwor, udfHook) {
|
|
|
843
1048
|
reasons.push(refusal('$for',
|
|
844
1049
|
'only a single plain binding over the whole collection is translated'));
|
|
845
1050
|
return { plan, reasons, whereFullyPushed: false, orderPushed: false,
|
|
846
|
-
projectionNative: false, itSlot: -1, itName: null, udfs: [], prefilters: [] };
|
|
1051
|
+
projectionNative: false, itSlot: -1, itName: null, udfs: [], prefilters: [], knn: null };
|
|
847
1052
|
}
|
|
848
1053
|
const itSlot = binding.slot;
|
|
849
1054
|
// the document's own name for the collection binding. The residual and
|
|
@@ -905,9 +1110,19 @@ function planFlwor(node, shape, rawFlwor, udfHook) {
|
|
|
905
1110
|
}
|
|
906
1111
|
}
|
|
907
1112
|
|
|
908
|
-
// ORDER BY: all terms or none — a partially pushed ordering is wrong
|
|
1113
|
+
// ORDER BY: all terms or none — a partially pushed ordering is wrong.
|
|
1114
|
+
// A k-nearest ordering is the third outcome: not pushed, but
|
|
1115
|
+
// recognized for the column to pre-filter (the reason is the
|
|
1116
|
+
// caller's to name once the window is known)
|
|
909
1117
|
let orderPushed = false;
|
|
910
|
-
|
|
1118
|
+
let knn = null;
|
|
1119
|
+
const ranked = node.orderby === null ? null
|
|
1120
|
+
: planKnnOrder(node.orderby, itSlot, shape, whereFullyPushed && structureClean);
|
|
1121
|
+
if (ranked !== null) {
|
|
1122
|
+
if ('rank' in ranked) knn = ranked.rank;
|
|
1123
|
+
else reasons.push(ranked.refusal);
|
|
1124
|
+
}
|
|
1125
|
+
else if (node.orderby !== null) {
|
|
911
1126
|
const terms = [];
|
|
912
1127
|
let refused = null;
|
|
913
1128
|
for (const spec of node.orderby.specs) {
|
|
@@ -953,9 +1168,35 @@ function planFlwor(node, shape, rawFlwor, udfHook) {
|
|
|
953
1168
|
itName,
|
|
954
1169
|
udfs,
|
|
955
1170
|
prefilters,
|
|
1171
|
+
knn,
|
|
956
1172
|
};
|
|
957
1173
|
}
|
|
958
1174
|
|
|
1175
|
+
/**
|
|
1176
|
+
* Compose peeled `$subsequence` windows into one: the innermost applies
|
|
1177
|
+
* first, so offsets add and each outer limit is cut to what the inner
|
|
1178
|
+
* one left. `null` when nothing was peeled.
|
|
1179
|
+
* @param {{ offset: number, limit: number | null }[]} windows -
|
|
1180
|
+
* outermost first, as peeled
|
|
1181
|
+
* @returns {{ offset: number, limit: number | null } | null}
|
|
1182
|
+
*/
|
|
1183
|
+
function composeWindows(windows) {
|
|
1184
|
+
if (windows.length === 0) return null;
|
|
1185
|
+
let offset = 0;
|
|
1186
|
+
let limit = null;
|
|
1187
|
+
for (let i = windows.length - 1; i >= 0; i--) {
|
|
1188
|
+
const w = windows[i];
|
|
1189
|
+
offset += w.offset;
|
|
1190
|
+
if (w.limit !== null) {
|
|
1191
|
+
limit = limit === null ? w.limit : Math.min(Math.max(limit - w.offset, 0), w.limit);
|
|
1192
|
+
}
|
|
1193
|
+
else if (limit !== null) {
|
|
1194
|
+
limit = Math.max(limit - w.offset, 0);
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
return { offset, limit };
|
|
1198
|
+
}
|
|
1199
|
+
|
|
959
1200
|
/**
|
|
960
1201
|
* Plan a whole document against one collection.
|
|
961
1202
|
* @param {any} document - The raw query document (kept beside the AST
|
|
@@ -965,11 +1206,12 @@ function planFlwor(node, shape, rawFlwor, udfHook) {
|
|
|
965
1206
|
* @returns {{
|
|
966
1207
|
* analysis: any,
|
|
967
1208
|
* plan: import('./algebra.js').Plan | null,
|
|
968
|
-
* mode: 'native' | 'row' | 'set',
|
|
1209
|
+
* mode: 'native' | 'row' | 'set' | 'knn',
|
|
969
1210
|
* reasons: { construct: string, reason: string }[],
|
|
970
1211
|
* rowReturn: any,
|
|
971
1212
|
* udfs: string[],
|
|
972
|
-
* prefilters: { construct: string,
|
|
1213
|
+
* prefilters: { construct: string, via: 'columns' | 'rtree',
|
|
1214
|
+
* columns: string[], exact: boolean }[],
|
|
973
1215
|
* }}
|
|
974
1216
|
*/
|
|
975
1217
|
function planCollectionCore(document, shape, options = undefined) {
|
|
@@ -1026,6 +1268,22 @@ function planCollectionCore(document, shape, options = undefined) {
|
|
|
1026
1268
|
const { plan } = flwor;
|
|
1027
1269
|
const fullyPushed = flwor.whereFullyPushed && flwor.orderPushed;
|
|
1028
1270
|
|
|
1271
|
+
if (flwor.knn !== null) {
|
|
1272
|
+
// the k-nearest mode: the recognized ordering under a window with
|
|
1273
|
+
// a finite limit, composed exactly as pushed windows are. The plan
|
|
1274
|
+
// keeps no order and no window — both are the engine's over the
|
|
1275
|
+
// cut — and the reason strict mode names is the rank itself
|
|
1276
|
+
const window = aggregate === null ? composeWindows(windows) : null;
|
|
1277
|
+
if (window !== null && window.limit !== null) {
|
|
1278
|
+
plan.rank = { ...flwor.knn, offset: window.offset, limit: window.limit,
|
|
1279
|
+
margin: KNN_MARGIN };
|
|
1280
|
+
return { analysis, plan, mode: 'knn',
|
|
1281
|
+
reasons: [refusal('$orderby', KNN_REASONS.rank), ...flwor.reasons],
|
|
1282
|
+
rowReturn: null, udfs: flwor.udfs, prefilters: flwor.prefilters };
|
|
1283
|
+
}
|
|
1284
|
+
flwor.reasons.unshift(refusal('$subsequence', KNN_REASONS.window));
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1029
1287
|
if (aggregate !== null) {
|
|
1030
1288
|
// aggregates need the WHOLE selection native (their input is the
|
|
1031
1289
|
// full sequence, not a narrowed candidate set)
|
|
@@ -1064,22 +1322,7 @@ function planCollectionCore(document, shape, options = undefined) {
|
|
|
1064
1322
|
}
|
|
1065
1323
|
|
|
1066
1324
|
// windows push only onto a fully pushed selection
|
|
1067
|
-
if (windows.length > 0 && fullyPushed)
|
|
1068
|
-
// innermost window applies first; compose offsets/limits
|
|
1069
|
-
let offset = 0;
|
|
1070
|
-
let limit = null;
|
|
1071
|
-
for (let i = windows.length - 1; i >= 0; i--) {
|
|
1072
|
-
const w = windows[i];
|
|
1073
|
-
offset += w.offset;
|
|
1074
|
-
if (w.limit !== null) {
|
|
1075
|
-
limit = limit === null ? w.limit : Math.min(Math.max(limit - w.offset, 0), w.limit);
|
|
1076
|
-
}
|
|
1077
|
-
else if (limit !== null) {
|
|
1078
|
-
limit = Math.max(limit - w.offset, 0);
|
|
1079
|
-
}
|
|
1080
|
-
}
|
|
1081
|
-
plan.window = { offset, limit };
|
|
1082
|
-
}
|
|
1325
|
+
if (windows.length > 0 && fullyPushed) plan.window = composeWindows(windows);
|
|
1083
1326
|
|
|
1084
1327
|
if (fullyPushed && flwor.projectionNative && (windows.length === 0 || plan.window !== null)) {
|
|
1085
1328
|
return { analysis, plan, mode: 'native', reasons: [], rowReturn: null,
|
|
@@ -1127,11 +1370,12 @@ function planCollectionCore(document, shape, options = undefined) {
|
|
|
1127
1370
|
* @returns {{
|
|
1128
1371
|
* analysis: any,
|
|
1129
1372
|
* plan: import('./algebra.js').Plan | null,
|
|
1130
|
-
* mode: 'native' | 'row' | 'set',
|
|
1373
|
+
* mode: 'native' | 'row' | 'set' | 'knn',
|
|
1131
1374
|
* reasons: { construct: string, reason: string }[],
|
|
1132
1375
|
* rowReturn: any,
|
|
1133
1376
|
* udfs: string[],
|
|
1134
|
-
* prefilters: { construct: string,
|
|
1377
|
+
* prefilters: { construct: string, via: 'columns' | 'rtree',
|
|
1378
|
+
* columns: string[], exact: boolean }[],
|
|
1135
1379
|
* }}
|
|
1136
1380
|
*/
|
|
1137
1381
|
export function planQuery(document, shape, options = undefined) {
|