@jarenjs/db 0.43.3 → 0.46.4
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 +319 -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/src/dialect.js
CHANGED
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
* capabilities: Record<string, any>,
|
|
35
35
|
* tableSuffix: string,
|
|
36
36
|
* docColumnType: string,
|
|
37
|
+
* packedVectorType: string,
|
|
37
38
|
* quoteIdentifier: (s: string) => string,
|
|
38
39
|
* parameterRef: (i: number, name: string) => string,
|
|
39
40
|
* stringLiteral: (s: string) => string,
|
|
@@ -43,7 +44,7 @@
|
|
|
43
44
|
* jsonPathText: (segments: JsonPathSegment[]) => string | null,
|
|
44
45
|
* jsonExtract: (columnSql: string, pathText: string) => string,
|
|
45
46
|
* derivedExpression?: (memberSql: string, column: { derive: string,
|
|
46
|
-
* precision?: number, component?: string }) => string,
|
|
47
|
+
* precision?: number, component?: string, dims?: number }) => string,
|
|
47
48
|
* jsonSet: (exprSql: string, pathText: string, valueSql: string) => string,
|
|
48
49
|
* jsonRemove: (exprSql: string, pathText: string) => string,
|
|
49
50
|
* jsonAppend: (exprSql: string, arrayPathText: string, valueSql: string) => string,
|
|
@@ -58,6 +59,8 @@
|
|
|
58
59
|
* strContains: (valueSql: string, patternSql: string) => string,
|
|
59
60
|
* orderNulls: (nullsFirst: boolean) => string,
|
|
60
61
|
* rowIdentity: () => string,
|
|
62
|
+
* identityIn: (identitySql: string, paramSqls: string[]) => string,
|
|
63
|
+
* rtree?: { module: string, columns: readonly string[] },
|
|
61
64
|
* explainQuery: (sql: string) => string,
|
|
62
65
|
* excludedRef: (columnSql: string) => string,
|
|
63
66
|
* tx: { begin: string, beginImmediate: string, commit: string,
|
|
@@ -85,7 +88,9 @@ export function createDialect(spec) {
|
|
|
85
88
|
* generated column over the document; a DERIVED column is the same
|
|
86
89
|
* shape over a registered deterministic function, except where the
|
|
87
90
|
* driver cannot index one — there the store writes the value and the
|
|
88
|
-
* column is an ordinary one.
|
|
91
|
+
* column is an ordinary one. A packed vector column is ALWAYS that
|
|
92
|
+
* ordinary stored column (its type is `packedVectorType`), on every
|
|
93
|
+
* driver: it never has an expression to spell.
|
|
89
94
|
* @param {string} docColumn
|
|
90
95
|
* @param {{ name: string, type: string, pathText: string,
|
|
91
96
|
* expression?: string | null, stored?: boolean }} column
|
|
@@ -167,6 +172,104 @@ export function createDialect(spec) {
|
|
|
167
172
|
dropIndex(name) {
|
|
168
173
|
return `DROP INDEX ${q(name)}`;
|
|
169
174
|
},
|
|
175
|
+
/**
|
|
176
|
+
* The second physical realization of a `derive: 'bbox'` column set
|
|
177
|
+
* (MODEL-FORMAT §2.1, `physical: 'rtree'`): an R\*Tree virtual
|
|
178
|
+
* table beside the collection, keyed by the collection's row id and
|
|
179
|
+
* carrying the four box edges as `(minx, maxx, miny, maxy)`.
|
|
180
|
+
*
|
|
181
|
+
* The coordinates are 32-bit floats rounded OUTWARD, so the stored
|
|
182
|
+
* box is a superset of the row's — no false negatives, which is
|
|
183
|
+
* what an implied conjunct needs, and the reason `$bbox-intersects`
|
|
184
|
+
* stops being exact under this mapping.
|
|
185
|
+
* @param {{ name: string }} shape
|
|
186
|
+
* @returns {string}
|
|
187
|
+
*/
|
|
188
|
+
createVirtualTable({ name }) {
|
|
189
|
+
return `CREATE VIRTUAL TABLE ${q(name)} USING ${spec.rtree.module}(`
|
|
190
|
+
+ `${spec.rtree.columns.map(q).join(', ')})`;
|
|
191
|
+
},
|
|
192
|
+
/**
|
|
193
|
+
* @param {string} name
|
|
194
|
+
* @returns {string}
|
|
195
|
+
*/
|
|
196
|
+
dropVirtualTable(name) {
|
|
197
|
+
return `DROP TABLE ${q(name)}`;
|
|
198
|
+
},
|
|
199
|
+
/**
|
|
200
|
+
* @param {string} name
|
|
201
|
+
* @returns {string}
|
|
202
|
+
*/
|
|
203
|
+
dropTrigger(name) {
|
|
204
|
+
return `DROP TRIGGER ${q(name)}`;
|
|
205
|
+
},
|
|
206
|
+
/**
|
|
207
|
+
* Fill an R\*Tree from the documents already stored — the migration
|
|
208
|
+
* step that turns a `columns` collection into an `rtree` one. The
|
|
209
|
+
* `IS NOT NULL` is §3.2's rule in SQL: a row with no bounded
|
|
210
|
+
* position is ABSENT from the index, not at `[0, 0]`.
|
|
211
|
+
* @param {{ table: string, virtualTable: string,
|
|
212
|
+
* edges: { name: string }[] }} shape
|
|
213
|
+
* @returns {string}
|
|
214
|
+
*/
|
|
215
|
+
fillVirtualTable({ table, virtualTable, edges }) {
|
|
216
|
+
const columns = spec.rtree.columns;
|
|
217
|
+
const sources = [spec.rowIdentity(), ...edges.map((edge) => q(edge.name))];
|
|
218
|
+
return `INSERT INTO ${q(virtualTable)} (${columns.map(q).join(', ')}) `
|
|
219
|
+
+ `SELECT ${sources.join(', ')} FROM ${q(table)} `
|
|
220
|
+
+ `WHERE ${q(edges[0].name)} IS NOT NULL`;
|
|
221
|
+
},
|
|
222
|
+
/**
|
|
223
|
+
* The three triggers that keep an R\*Tree in sync with its
|
|
224
|
+
* collection — insert, update, delete — as DECLARED objects of the
|
|
225
|
+
* collection table.
|
|
226
|
+
*
|
|
227
|
+
* Declared, and not a second write path in JavaScript: a trigger is
|
|
228
|
+
* inside the writing transaction by construction (SQLite cannot
|
|
229
|
+
* separate them), no write path can bypass it (`insert`,
|
|
230
|
+
* `insertAllocated`, `upsert`, a translated patch, the patch
|
|
231
|
+
* fallback, a delete and a migration backfill all fire it), and it
|
|
232
|
+
* belongs to the collection table, so the existing declared-text
|
|
233
|
+
* drift check sees it for free.
|
|
234
|
+
*
|
|
235
|
+
* The body reads the DERIVED COLUMNS through `NEW` rather than
|
|
236
|
+
* restating the box expression, so the columns stay the box's one
|
|
237
|
+
* definition — and that text works unchanged on the stored-column
|
|
238
|
+
* branch, where those columns are ordinary ones.
|
|
239
|
+
*
|
|
240
|
+
* The `IS NOT NULL` guard is load-bearing: an R\*Tree coerces a
|
|
241
|
+
* `NULL` coordinate to `0.0` without complaint, so without it every
|
|
242
|
+
* unbounded document would land on Null Island instead of being
|
|
243
|
+
* absent (MODEL-FORMAT §3.2).
|
|
244
|
+
* @param {{ table: string, virtualTable: string, prefix: string,
|
|
245
|
+
* edges: { name: string }[] }} shape - `edges` are the four
|
|
246
|
+
* derived columns in `(w, e, s, n)` order, which is the order the
|
|
247
|
+
* virtual table's `(minx, maxx, miny, maxy)` carry
|
|
248
|
+
* @returns {{ name: string, sql: string }[]}
|
|
249
|
+
*/
|
|
250
|
+
createSyncTriggers({ table, virtualTable, prefix, edges }) {
|
|
251
|
+
const rid = spec.rowIdentity();
|
|
252
|
+
const target = `${q(virtualTable)} (${spec.rtree.columns.map(q).join(', ')})`;
|
|
253
|
+
const guard = `${q(edges[0].name)} IS NOT NULL`;
|
|
254
|
+
const values = (row) => [`${row}.${rid}`,
|
|
255
|
+
...edges.map((edge) => `${row}.${q(edge.name)}`)].join(', ');
|
|
256
|
+
return [
|
|
257
|
+
{ name: `${prefix}_ai`,
|
|
258
|
+
sql: `CREATE TRIGGER ${q(`${prefix}_ai`)} AFTER INSERT ON ${q(table)} `
|
|
259
|
+
+ `WHEN NEW.${guard} BEGIN `
|
|
260
|
+
+ `INSERT INTO ${target} VALUES (${values('NEW')}); END` },
|
|
261
|
+
// one trigger, not two: the old id leaves and the new box
|
|
262
|
+
// arrives only when it exists, so a document that loses its
|
|
263
|
+
// geometry leaves the index rather than keeping a stale box
|
|
264
|
+
{ name: `${prefix}_au`,
|
|
265
|
+
sql: `CREATE TRIGGER ${q(`${prefix}_au`)} AFTER UPDATE ON ${q(table)} BEGIN `
|
|
266
|
+
+ `DELETE FROM ${q(virtualTable)} WHERE ${q(spec.rtree.columns[0])} = OLD.${rid}; `
|
|
267
|
+
+ `INSERT INTO ${target} SELECT ${values('NEW')} WHERE NEW.${guard}; END` },
|
|
268
|
+
{ name: `${prefix}_ad`,
|
|
269
|
+
sql: `CREATE TRIGGER ${q(`${prefix}_ad`)} AFTER DELETE ON ${q(table)} BEGIN `
|
|
270
|
+
+ `DELETE FROM ${q(virtualTable)} WHERE ${q(spec.rtree.columns[0])} = OLD.${rid}; END` },
|
|
271
|
+
];
|
|
272
|
+
},
|
|
170
273
|
/**
|
|
171
274
|
* @param {string} table
|
|
172
275
|
* @returns {string}
|
|
@@ -286,6 +389,25 @@ export function createDialect(spec) {
|
|
|
286
389
|
del({ table, keyColumn }) {
|
|
287
390
|
return `DELETE FROM ${q(table)} WHERE ${q(keyColumn)} = ${p(1, 'key')}`;
|
|
288
391
|
},
|
|
392
|
+
/**
|
|
393
|
+
* The documents of a list of row identities, in identity order —
|
|
394
|
+
* the fetch of a k-nearest plan's candidates after the engine's
|
|
395
|
+
* cut. `count` placeholders; a caller with fewer identities binds
|
|
396
|
+
* `null` for the rest, which the membership test matches to no row.
|
|
397
|
+
* Identity order is the collection's own order, so the engine's
|
|
398
|
+
* stable sort over the fetched documents sees what it would have
|
|
399
|
+
* seen over the whole collection.
|
|
400
|
+
* @param {{ table: string, docColumn: string }} s
|
|
401
|
+
* @param {number} count
|
|
402
|
+
* @returns {string}
|
|
403
|
+
*/
|
|
404
|
+
selectByIdentities({ table, docColumn }, count) {
|
|
405
|
+
const rid = spec.rowIdentity();
|
|
406
|
+
const placeholders = [];
|
|
407
|
+
for (let i = 1; i <= count; i++) placeholders.push(p(i, 'rid'));
|
|
408
|
+
return `SELECT ${spec.jsonText(q(docColumn))} AS ${q('doc')} FROM ${q(table)} `
|
|
409
|
+
+ `WHERE ${spec.identityIn(rid, placeholders)} ORDER BY ${rid}`;
|
|
410
|
+
},
|
|
289
411
|
/**
|
|
290
412
|
* Rewrite the document column through a JSON-set expression chain
|
|
291
413
|
* (the translated-patch path) or a bound parameter (the fallback).
|
|
@@ -316,6 +438,9 @@ export function createDialect(spec) {
|
|
|
316
438
|
name: spec.name,
|
|
317
439
|
capabilities: Object.freeze({ ...spec.capabilities }),
|
|
318
440
|
docColumnType: spec.docColumnType,
|
|
441
|
+
// the declared type of a `derive: 'vector'` column — the bytes of
|
|
442
|
+
// the packed form, spelled by the dialect like every other type
|
|
443
|
+
packedVectorType: spec.packedVectorType,
|
|
319
444
|
quoteIdentifier: q,
|
|
320
445
|
parameterRef: p,
|
|
321
446
|
stringLiteral: spec.stringLiteral,
|
|
@@ -349,6 +474,13 @@ export function createDialect(spec) {
|
|
|
349
474
|
strContains: spec.strContains,
|
|
350
475
|
orderNulls: spec.orderNulls,
|
|
351
476
|
rowIdentity: spec.rowIdentity,
|
|
477
|
+
/**
|
|
478
|
+
* The R\*Tree spelling: the module name and the virtual table's own
|
|
479
|
+
* column list, in `(id, minx, maxx, miny, maxy)` order. Read only
|
|
480
|
+
* where a `physical: 'rtree'` column set is planned, so a spelling
|
|
481
|
+
* spec that omits it simply cannot carry that mapping.
|
|
482
|
+
*/
|
|
483
|
+
rtree: Object.freeze({ ...spec.rtree }),
|
|
352
484
|
explainQuery: spec.explainQuery,
|
|
353
485
|
excludedRef: spec.excludedRef,
|
|
354
486
|
epochFromRfc3339: spec.epochFromRfc3339,
|
package/src/dialects/sqlite.js
CHANGED
|
@@ -90,6 +90,10 @@ export const sqliteDialect = createDialect({
|
|
|
90
90
|
epochFromRfc3339: (valueSql) =>
|
|
91
91
|
`CAST(round((julianday(${valueSql}) - 2440587.5) * 86400000.0) AS INTEGER)`,
|
|
92
92
|
docColumnType: 'BLOB',
|
|
93
|
+
// a `derive: 'vector'` column holds the packed little-endian binary32
|
|
94
|
+
// form (`4·dims` bytes); it is a stored column on every driver, so
|
|
95
|
+
// this is the whole of its SQL
|
|
96
|
+
packedVectorType: 'BLOB',
|
|
93
97
|
quoteIdentifier,
|
|
94
98
|
parameterRef: () => '?',
|
|
95
99
|
stringLiteral,
|
|
@@ -105,10 +109,24 @@ export const sqliteDialect = createDialect({
|
|
|
105
109
|
// the deterministic function the store registers at open. The
|
|
106
110
|
// precision is a LITERAL, not a parameter — a generated column's
|
|
107
111
|
// expression takes none — which is also what makes two precisions
|
|
108
|
-
// over one path two different columns by declared text.
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
+
// over one path two different columns by declared text. Exhaustive
|
|
113
|
+
// over the kind: a vector column is stored, never generated, so
|
|
114
|
+
// asking for its expression is a planner defect, and an unknown kind
|
|
115
|
+
// is refused rather than spelled as `jaren_bbox_undefined(...)`
|
|
116
|
+
derivedExpression: (memberSql, column) => {
|
|
117
|
+
switch (column.derive) {
|
|
118
|
+
case 'geohash':
|
|
119
|
+
return `jaren_geohash(${memberSql}, ${Math.trunc(Number(column.precision))})`;
|
|
120
|
+
case 'bbox':
|
|
121
|
+
return `jaren_bbox_${column.component}(${memberSql})`;
|
|
122
|
+
case 'vector':
|
|
123
|
+
throw new TypeError(
|
|
124
|
+
"sqlite dialect: a derive: 'vector' column is stored on every driver and has no generated expression");
|
|
125
|
+
default:
|
|
126
|
+
throw new TypeError(
|
|
127
|
+
`sqlite dialect: no generated-column expression for derive kind '${column.derive}'`);
|
|
128
|
+
}
|
|
129
|
+
},
|
|
112
130
|
jsonSet: (exprSql, pathText, valueSql) =>
|
|
113
131
|
`jsonb_set(${exprSql}, ${stringLiteral(pathText)}, ${valueSql})`,
|
|
114
132
|
jsonRemove: (exprSql, pathText) =>
|
|
@@ -135,6 +153,17 @@ export const sqliteDialect = createDialect({
|
|
|
135
153
|
strContains: (valueSql, patternSql) => `instr(${valueSql}, ${patternSql}) > 0`,
|
|
136
154
|
orderNulls: (nullsFirst) => (nullsFirst ? ' NULLS FIRST' : ' NULLS LAST'),
|
|
137
155
|
rowIdentity: () => '"rowid"',
|
|
156
|
+
// membership of the row identity in a bound list — the fetch of a
|
|
157
|
+
// k-nearest plan's candidates. `IN` over the rowid is a primary-key
|
|
158
|
+
// lookup per value; a NULL in the list matches no row, which is what
|
|
159
|
+
// lets a caller pad a batch
|
|
160
|
+
identityIn: (identitySql, paramSqls) => `${identitySql} IN (${paramSqls.join(', ')})`,
|
|
161
|
+
// the R*Tree module and the shape this store gives it: the row id
|
|
162
|
+
// and the four box edges in (minx, maxx, miny, maxy) order, which is
|
|
163
|
+
// the (w, e, s, n) a bbox index covers its columns in. The three
|
|
164
|
+
// shadow tables SQLite creates beside a virtual table are its own
|
|
165
|
+
// storage — deterministic from the name, and dropped with it.
|
|
166
|
+
rtree: { module: 'rtree', columns: ['id', 'minx', 'maxx', 'miny', 'maxy'] },
|
|
138
167
|
explainQuery: (sql) => `EXPLAIN QUERY PLAN ${sql}`,
|
|
139
168
|
excludedRef: (columnSql) => `excluded.${columnSql}`,
|
|
140
169
|
tx: {
|
package/src/emit.js
CHANGED
|
@@ -228,6 +228,23 @@ export function emitPlan(plan, dialect, physical) {
|
|
|
228
228
|
+ ` AND ${q(c.e)} >= ${edge('w')}`
|
|
229
229
|
+ ` AND ${q(c.s)} <= ${edge('n')} AND ${q(c.n)} >= ${edge('s')})`;
|
|
230
230
|
}
|
|
231
|
+
case 'bboxRtree': {
|
|
232
|
+
// The same box test, over the same box, in the shape a
|
|
233
|
+
// `physical: 'rtree'` column set stores it: a LIST subquery over
|
|
234
|
+
// the virtual table, which is a conjunct on the collection table
|
|
235
|
+
// — so the FROM clause, the residual machinery and `prefilters`
|
|
236
|
+
// are all untouched, and only this one case knows the mapping.
|
|
237
|
+
//
|
|
238
|
+
// Total by construction: a row with no box was never inserted
|
|
239
|
+
// into the virtual table (the sync trigger's `IS NOT NULL`
|
|
240
|
+
// guard), so it is simply not in the list — the same answer the
|
|
241
|
+
// column mapping's leading `IS NOT NULL` produces.
|
|
242
|
+
const [id, minx, maxx, miny, maxy] = [dialect.rtree.columns[0], ...pred.columns];
|
|
243
|
+
const edge = (axis) => probeEdge(pred.probe, param, axis);
|
|
244
|
+
return `${dialect.rowIdentity()} IN (SELECT ${q(id)} FROM ${q(pred.table)}`
|
|
245
|
+
+ ` WHERE ${q(minx)} <= ${edge('e')} AND ${q(maxx)} >= ${edge('w')}`
|
|
246
|
+
+ ` AND ${q(miny)} <= ${edge('n')} AND ${q(maxy)} >= ${edge('s')})`;
|
|
247
|
+
}
|
|
231
248
|
case 'cellIn': {
|
|
232
249
|
// The cells are whole values of the column, so this is an
|
|
233
250
|
// equality set — and `IN` is the spelling that keeps it one:
|
|
@@ -256,15 +273,22 @@ export function emitPlan(plan, dialect, physical) {
|
|
|
256
273
|
}
|
|
257
274
|
};
|
|
258
275
|
|
|
259
|
-
const selection = plan.
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
276
|
+
const selection = plan.rank !== null
|
|
277
|
+
// the k-nearest fetch: the row identity and the packed column
|
|
278
|
+
// under the pushed WHERE, and nothing that orders or limits — the
|
|
279
|
+
// engine scores, cuts and ranks (measured: every SQL spelling of
|
|
280
|
+
// the rank loses to fetching the column and ranking in the engine,
|
|
281
|
+
// and none of them runs where no function can be registered)
|
|
282
|
+
? `${dialect.rowIdentity()} AS ${q('rid')}, ${q(plan.rank.column)} AS ${q('vec')}`
|
|
283
|
+
: plan.aggregate === null
|
|
284
|
+
? `${dialect.jsonText(docColumn)} AS ${q('doc')}`
|
|
285
|
+
: plan.aggregate.fn === 'count'
|
|
286
|
+
? `COUNT(*) AS ${q('value')}`
|
|
287
|
+
: `${plan.aggregate.fn.toUpperCase()}(${valueOf(plan.aggregate.ref)}) AS ${q('value')}`;
|
|
264
288
|
|
|
265
289
|
let sql = `SELECT ${selection} FROM ${q(physical.table)}`;
|
|
266
290
|
if (plan.filter !== null) sql += ` WHERE ${emitPred(plan.filter)}`;
|
|
267
|
-
if (plan.aggregate === null) {
|
|
291
|
+
if (plan.aggregate === null && plan.rank === null) {
|
|
268
292
|
const terms = (plan.order ?? []).map((term) => {
|
|
269
293
|
// Jaren's default sorts an empty key least: NULLS FIRST when
|
|
270
294
|
// ascending, NULLS LAST when descending — and mirrored for
|
package/src/index.js
CHANGED
|
@@ -30,10 +30,12 @@ export { typeOfPath, isNumericType } from './types.js';
|
|
|
30
30
|
export { compileSetResidual, compileRowResidual, sequenceResult } from './residual.js';
|
|
31
31
|
export { deterministicFragment, registerFragment } from './udf.js';
|
|
32
32
|
export {
|
|
33
|
-
DERIVE_KINDS,
|
|
34
|
-
|
|
35
|
-
|
|
33
|
+
DERIVE_KINDS, DERIVE_MAPPING, PHYSICAL_KINDS, BBOX_COMPONENTS, BBOX_INDEX_ORDER,
|
|
34
|
+
PRECISION_MIN, PRECISION_MAX, DIMS_MIN, DIMS_MAX,
|
|
35
|
+
deriveGeohash, deriveBboxEdge, deriveVector, derivedValue, derivedMappingFor, memberAt,
|
|
36
|
+
storedMemberForm, registerDeriveFunctions, probeVector, columnScore,
|
|
36
37
|
} from './derive.js';
|
|
38
|
+
export { KNN_MARGIN, IDENTITY_CHUNK, cutCandidates, identityBatches } from './knn.js';
|
|
37
39
|
export {
|
|
38
40
|
createQueryEngine, createQueryState, createEntityQueryEngine,
|
|
39
41
|
createLoadEngine, INCLUDE_DEPTH_DEFAULT,
|
package/src/knn.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file The candidate cut of a k-nearest plan: the arithmetic between
|
|
4
|
+
* the scores a statement fetched and the row identities the engine
|
|
5
|
+
* will decide over. No vector arithmetic lives here — a score is
|
|
6
|
+
* `derive.js`'s, through `@jarenjs/core/vector` — and no SQL: the
|
|
7
|
+
* fetch of the winners is the dialect's. This is the one place the
|
|
8
|
+
* margin is applied and the one place candidate identities are
|
|
9
|
+
* batched for it.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The inclusive score margin of the cut. The column's score is a dot
|
|
14
|
+
* product over binary32-normalized forms; the engine's key is the
|
|
15
|
+
* cosine of the raw doubles; measured, the two differ by at most
|
|
16
|
+
* ~1e-8. Any margin of at least twice that makes the engine's top
|
|
17
|
+
* `offset + limit` a SUBSET of the candidates: were a row the engine
|
|
18
|
+
* ranks inside the window cut, some candidate the engine ranks outside
|
|
19
|
+
* it would have to score higher by the column and lower by the engine,
|
|
20
|
+
* which two scores within half the margin of each other cannot do.
|
|
21
|
+
* This is a hundred times that bound — it admits, in practice, only
|
|
22
|
+
* true ties, and those the engine breaks by the document's own keys.
|
|
23
|
+
*/
|
|
24
|
+
export const KNN_MARGIN = 1e-6;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The most identities one fetch statement binds: under the parameter
|
|
28
|
+
* cap of every SQLite build the store runs on. A guard, not a design —
|
|
29
|
+
* a k-nearest window is a handful of rows, and this only matters for a
|
|
30
|
+
* collection of many exact duplicates.
|
|
31
|
+
*/
|
|
32
|
+
export const IDENTITY_CHUNK = 512;
|
|
33
|
+
|
|
34
|
+
/** @param {any} a @param {any} b */
|
|
35
|
+
const byIdentity = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The rows a k-nearest window can contain, from the scored fetch.
|
|
39
|
+
*
|
|
40
|
+
* `m = offset + limit` rows are needed. When at least `m` rows scored,
|
|
41
|
+
* the candidates are every scored row within `margin` of the m-th best
|
|
42
|
+
* score — ties at the boundary included by construction. When fewer
|
|
43
|
+
* did, the window reaches the unrankable tail (NULL columns, or a
|
|
44
|
+
* collection smaller than the window), which only the documents can
|
|
45
|
+
* order: every row is then a candidate, and the collection is no
|
|
46
|
+
* larger than the window.
|
|
47
|
+
* @param {{ identity: any, score: number | null }[]} rows - one per
|
|
48
|
+
* fetched row; `score` is `null` where the column held no vector
|
|
49
|
+
* @param {number} m - `offset + limit`
|
|
50
|
+
* @param {number} margin
|
|
51
|
+
* @returns {{ identities: any[], scored: number, full: boolean }} the
|
|
52
|
+
* candidate identities in ascending identity order — the
|
|
53
|
+
* collection's own order, which a stable sort over the candidates
|
|
54
|
+
* must see — with how many rows scored and whether every row was
|
|
55
|
+
* taken
|
|
56
|
+
*/
|
|
57
|
+
export function cutCandidates(rows, m, margin) {
|
|
58
|
+
let scored = 0;
|
|
59
|
+
for (const row of rows) if (row.score !== null) scored++;
|
|
60
|
+
if (scored < m) {
|
|
61
|
+
return { identities: rows.map((row) => row.identity).sort(byIdentity), scored, full: true };
|
|
62
|
+
}
|
|
63
|
+
if (m <= 0) return { identities: [], scored, full: false };
|
|
64
|
+
const scores = new Float64Array(scored);
|
|
65
|
+
let at = 0;
|
|
66
|
+
for (const row of rows) if (row.score !== null) scores[at++] = row.score;
|
|
67
|
+
scores.sort();
|
|
68
|
+
const threshold = scores[scored - m] - margin;
|
|
69
|
+
const identities = [];
|
|
70
|
+
for (const row of rows) {
|
|
71
|
+
if (row.score !== null && row.score >= threshold) identities.push(row.identity);
|
|
72
|
+
}
|
|
73
|
+
identities.sort(byIdentity);
|
|
74
|
+
return { identities, scored, full: false };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The identities sliced into fetch batches: at most `IDENTITY_CHUNK`
|
|
79
|
+
* each, and each padded with `null` to the next power of two — a NULL
|
|
80
|
+
* in an IN list matches no row — so a handful of prepared statements
|
|
81
|
+
* serve every candidate count instead of one per count seen.
|
|
82
|
+
* @param {any[]} identities - in the order they should be fetched
|
|
83
|
+
* @returns {{ size: number, params: any[] }[]}
|
|
84
|
+
*/
|
|
85
|
+
export function identityBatches(identities) {
|
|
86
|
+
const batches = [];
|
|
87
|
+
for (let start = 0; start < identities.length; start += IDENTITY_CHUNK) {
|
|
88
|
+
const slice = identities.slice(start, start + IDENTITY_CHUNK);
|
|
89
|
+
let size = 1;
|
|
90
|
+
while (size < slice.length) size *= 2;
|
|
91
|
+
const params = [...slice];
|
|
92
|
+
while (params.length < size) params.push(null);
|
|
93
|
+
batches.push({ size, params });
|
|
94
|
+
}
|
|
95
|
+
return batches;
|
|
96
|
+
}
|
package/src/live.js
CHANGED
|
@@ -46,7 +46,8 @@ const segmentsOf = (path) => path.split('/').slice(1).map(decodeJSONPointerSegme
|
|
|
46
46
|
* @param {any} document
|
|
47
47
|
*/
|
|
48
48
|
function unwrapDocument(document) {
|
|
49
|
-
|
|
49
|
+
const whole = Array.isArray(document) && document.length === 1 ? document[0] : document;
|
|
50
|
+
let doc = whole;
|
|
50
51
|
let offset = 0;
|
|
51
52
|
let limit = null;
|
|
52
53
|
let windowed = false;
|
|
@@ -74,7 +75,7 @@ function unwrapDocument(document) {
|
|
|
74
75
|
}
|
|
75
76
|
}
|
|
76
77
|
}
|
|
77
|
-
return { inner: doc, windowed, offset, limit, aggregate };
|
|
78
|
+
return { inner: doc, whole, windowed, offset, limit, aggregate };
|
|
78
79
|
}
|
|
79
80
|
|
|
80
81
|
/** The single for-binding name of a canonical flwor, or null. */
|
|
@@ -179,6 +180,55 @@ function memberDeps(root) {
|
|
|
179
180
|
return deps;
|
|
180
181
|
}
|
|
181
182
|
|
|
183
|
+
/**
|
|
184
|
+
* The residual reasons a plan carries once every spatial REFINEMENT is
|
|
185
|
+
* taken out. A refinement is a pushed, non-exact pre-filter (`$within`,
|
|
186
|
+
* a bounded `$distance`, an over-long cell prefix): the fetch is still
|
|
187
|
+
* SQL-narrowed and the exact predicate stays in the residual, which is
|
|
188
|
+
* the per-row evaluation maintenance runs anyway. One reason is removed
|
|
189
|
+
* per non-exact pre-filter, matched by construct, so a spatial
|
|
190
|
+
* predicate the planner REFUSED (no derived index on the member) still
|
|
191
|
+
* counts as a reason and still re-runs.
|
|
192
|
+
* @param {any} planned
|
|
193
|
+
* @returns {{ construct: string, reason: string }[]}
|
|
194
|
+
*/
|
|
195
|
+
function unrefinedReasons(planned) {
|
|
196
|
+
const remaining = [...planned.reasons];
|
|
197
|
+
for (const prefilter of planned.prefilters) {
|
|
198
|
+
if (prefilter.exact) continue;
|
|
199
|
+
const index = remaining.findIndex((reason) => reason.construct === prefilter.construct);
|
|
200
|
+
if (index !== -1) remaining.splice(index, 1);
|
|
201
|
+
}
|
|
202
|
+
return remaining;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Whether a plan fell to the set residual ONLY because a spatial
|
|
207
|
+
* pre-filter refines in the engine (§7's spatial rows). `allowReturn`
|
|
208
|
+
* admits the per-row projection reason beside the refinement — the
|
|
209
|
+
* rows strategy re-evaluates the whole document per row, so a
|
|
210
|
+
* projected return is exact there and nowhere else.
|
|
211
|
+
* @param {any} planned
|
|
212
|
+
* @param {boolean} allowReturn
|
|
213
|
+
*/
|
|
214
|
+
function refinedOnly(planned, allowReturn) {
|
|
215
|
+
if (planned.mode !== 'set' || !planned.prefilters.some((prefilter) => !prefilter.exact)) {
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
const remaining = unrefinedReasons(planned)
|
|
219
|
+
.filter((reason) => !(allowReturn && reason.construct === '$return'));
|
|
220
|
+
return remaining.length === 0;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const SPATIAL_RERUN = {
|
|
224
|
+
aggregate: 'a spatial aggregate re-runs: the accumulator needs a fully translated '
|
|
225
|
+
+ 'selection, and a refined spatial predicate leaves the exact test to the engine',
|
|
226
|
+
group: 'a spatial group re-runs: the group filter needs a fully translated selection, '
|
|
227
|
+
+ 'and a refined spatial predicate leaves the exact test to the engine',
|
|
228
|
+
order: "'$orderby' — an ordering over a refined spatial selection re-runs "
|
|
229
|
+
+ '(no window is maintained beside a pre-filter that only narrows)',
|
|
230
|
+
};
|
|
231
|
+
|
|
182
232
|
/**
|
|
183
233
|
* Classify a collection query document against §7's table. Pure —
|
|
184
234
|
* given the document and the collection's planner shape, returns the
|
|
@@ -192,18 +242,20 @@ function memberDeps(root) {
|
|
|
192
242
|
*/
|
|
193
243
|
export function classifyLiveQuery(document, queryShape, keyed) {
|
|
194
244
|
const rerun = (reason) => ({ strategy: 'rerun', reason });
|
|
245
|
+
// the reason named is the first one that is NOT a spatial refinement:
|
|
246
|
+
// a refinement narrows and never forces a re-run by itself
|
|
195
247
|
const plannerReason = (planned) => {
|
|
196
|
-
const forcing = planned.reasons[0]
|
|
248
|
+
const forcing = unrefinedReasons(planned)[0] ?? planned.reasons[0]
|
|
197
249
|
?? { construct: 'residual', reason: 'the document did not translate' };
|
|
198
250
|
return `'${forcing.construct}' — ${forcing.reason}`;
|
|
199
251
|
};
|
|
200
|
-
const { inner, windowed, offset, limit, aggregate } = unwrapDocument(document);
|
|
252
|
+
const { inner, whole, windowed, offset, limit, aggregate } = unwrapDocument(document);
|
|
201
253
|
|
|
202
254
|
if (aggregate !== null) {
|
|
203
255
|
if (windowed) return rerun('a windowed aggregate maintains no accumulator');
|
|
204
256
|
const planned = planQuery({ [aggregate.name]: inner }, queryShape, {});
|
|
205
257
|
if (planned.mode !== 'native' || planned.plan.aggregate === null) {
|
|
206
|
-
return rerun(plannerReason(planned));
|
|
258
|
+
return rerun(refinedOnly(planned, false) ? SPATIAL_RERUN.aggregate : plannerReason(planned));
|
|
207
259
|
}
|
|
208
260
|
if (!keyed) return rerun('rows without a document key cannot be tracked');
|
|
209
261
|
return {
|
|
@@ -220,7 +272,9 @@ export function classifyLiveQuery(document, queryShape, keyed) {
|
|
|
220
272
|
const carrier = documentsSource(group.binding, group.where);
|
|
221
273
|
const planned = planQuery(carrier, queryShape, {});
|
|
222
274
|
if (planned.mode !== 'native') {
|
|
223
|
-
return rerun(
|
|
275
|
+
return rerun(refinedOnly(planned, false)
|
|
276
|
+
? SPATIAL_RERUN.group
|
|
277
|
+
: `the group filter did not translate: ${plannerReason(planned)}`);
|
|
224
278
|
}
|
|
225
279
|
if (!keyed) return rerun('rows without a document key cannot be tracked');
|
|
226
280
|
const rowDocument = {
|
|
@@ -239,8 +293,34 @@ export function classifyLiveQuery(document, queryShape, keyed) {
|
|
|
239
293
|
};
|
|
240
294
|
}
|
|
241
295
|
|
|
296
|
+
// a k-nearest window is planned WITH its window (the cut needs the
|
|
297
|
+
// limit), so the whole document is asked before the inner one: the
|
|
298
|
+
// column cuts the candidates and the engine orders them, and no
|
|
299
|
+
// maintained window has that shape — a ranking re-runs, named
|
|
300
|
+
if (windowed && planQuery(whole, queryShape, {}).mode === 'knn')
|
|
301
|
+
return rerun('a k-nearest ranking re-runs (the vector column cuts the candidates and the engine orders them)');
|
|
302
|
+
|
|
242
303
|
const planned = planQuery(inner, queryShape, {});
|
|
243
|
-
if (planned.mode === 'set')
|
|
304
|
+
if (planned.mode === 'set') {
|
|
305
|
+
// §7's spatial rows: the fetch is SQL-narrowed by the pushed box or
|
|
306
|
+
// cell range and the exact predicate is what per-row re-evaluation
|
|
307
|
+
// runs — so a `$where` that fell to the set residual ONLY for a
|
|
308
|
+
// refinement is maintained as rows. An ordering beside it is not
|
|
309
|
+
// (the set residual drops the planner's order terms), and neither
|
|
310
|
+
// is a window; both re-run, named.
|
|
311
|
+
if (!refinedOnly(planned, true)) return rerun(plannerReason(planned));
|
|
312
|
+
if (!keyed) return rerun('rows without a document key cannot be tracked');
|
|
313
|
+
// a translated ordering leaves no reason behind, but the set residual
|
|
314
|
+
// dropped its terms — the window is not maintainable here
|
|
315
|
+
if (isJsonObject(inner) && inner.$orderby !== undefined) return rerun(SPATIAL_RERUN.order);
|
|
316
|
+
if (windowed) return rerun('a limit without an order is not deterministic to maintain');
|
|
317
|
+
return {
|
|
318
|
+
strategy: 'rows',
|
|
319
|
+
inner,
|
|
320
|
+
projected: planned.reasons.some((reason) => reason.construct === '$return'),
|
|
321
|
+
deps: { whole: true, members: new Set() },
|
|
322
|
+
};
|
|
323
|
+
}
|
|
244
324
|
if (!keyed) return rerun('rows without a document key cannot be tracked');
|
|
245
325
|
|
|
246
326
|
if (planned.plan.order !== null) {
|