@jarenjs/db 0.34.2 → 0.43.3
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 +115 -12
- package/README.md +47 -0
- package/dist/types/algebra.d.ts +38 -2
- package/dist/types/ddl.d.ts +40 -6
- package/dist/types/derive.d.ts +161 -0
- package/dist/types/dialect.d.ts +11 -2
- package/dist/types/driver.d.ts +2 -20
- package/dist/types/emit.d.ts +6 -3
- package/dist/types/errors.d.ts +6 -4
- package/dist/types/index.d.ts +1 -0
- package/dist/types/migrate.d.ts +7 -1
- package/dist/types/plan.d.ts +15 -1
- package/dist/types/residual.d.ts +17 -6
- package/dist/types/udf.d.ts +6 -1
- package/docs/MIGRATION-FORMAT.md +30 -1
- package/docs/MODEL-FORMAT.md +155 -4
- package/package.json +4 -4
- package/schemas/jaren-migration.draft-07.schema.json +71 -0
- package/schemas/jaren-migration.schema.json +71 -0
- package/schemas/jaren-model.draft-07.schema.json +14 -1
- package/schemas/jaren-model.schema.json +18 -5
- package/src/algebra.js +17 -2
- package/src/ddl.js +146 -17
- package/src/derive.js +284 -0
- package/src/dialect.js +89 -25
- package/src/dialects/sqlite.js +16 -1
- package/src/driver.js +6 -28
- package/src/emit.js +122 -22
- package/src/errors.js +6 -4
- package/src/index.js +5 -0
- package/src/migrate.js +132 -19
- package/src/plan.js +514 -32
- package/src/query.js +61 -9
- package/src/residual.js +18 -10
- package/src/store.js +122 -8
- package/src/udf.js +12 -3
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
}
|
|
22
22
|
},
|
|
23
23
|
"entities": {
|
|
24
|
-
"description": "Entity name (an identifier) to entity declaration. The x-entity vocabulary lives INSIDE each entity schema's property subschemas and is documented in MODEL-FORMAT.md
|
|
24
|
+
"description": "Entity name (an identifier) to entity declaration. The x-entity vocabulary lives INSIDE each entity schema's property subschemas and is documented in MODEL-FORMAT.md §9; stripping it leaves a plain JSON Schema.",
|
|
25
25
|
"type": "object",
|
|
26
26
|
"minProperties": 1,
|
|
27
27
|
"propertyNames": {
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"type": "object"
|
|
47
47
|
},
|
|
48
48
|
"key": {
|
|
49
|
-
"description": "Where the caller-supplied key lives in the document, as an RFC 6901 pointer selecting at least one member
|
|
49
|
+
"description": "Where the caller-supplied key lives in the document, as an RFC 6901 pointer selecting at least one member — or null when the store allocates.",
|
|
50
50
|
"type": [
|
|
51
51
|
"string",
|
|
52
52
|
"null"
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"pattern": "^/"
|
|
55
55
|
},
|
|
56
56
|
"identity": {
|
|
57
|
-
"description": "How keys come to be: 'caller' (the default when a key pointer is declared), or
|
|
57
|
+
"description": "How keys come to be: 'caller' (the default when a key pointer is declared), or — only with \"key\": null — 'uuid' (crypto.randomUUID) or 'integer' (database-allocated).",
|
|
58
58
|
"enum": [
|
|
59
59
|
"caller",
|
|
60
60
|
"uuid",
|
|
@@ -80,7 +80,7 @@
|
|
|
80
80
|
"additionalProperties": false
|
|
81
81
|
},
|
|
82
82
|
"index": {
|
|
83
|
-
"description": "One index over a singular JSONPath expression (or a composite over several). A non-singular path is rejected at open (JD0004).",
|
|
83
|
+
"description": "One index over a singular JSONPath expression (or a composite over several). A non-singular path is rejected at open (JD0004). A derived index computes indexable scalars from the selected member instead of indexing it directly.",
|
|
84
84
|
"type": "object",
|
|
85
85
|
"properties": {
|
|
86
86
|
"name": {
|
|
@@ -108,6 +108,19 @@
|
|
|
108
108
|
"unique": {
|
|
109
109
|
"description": "Whether the index enforces uniqueness. Defaults to false.",
|
|
110
110
|
"type": "boolean"
|
|
111
|
+
},
|
|
112
|
+
"derive": {
|
|
113
|
+
"description": "Derive indexable columns from the selected spatial member instead of indexing it: 'geohash' (one TEXT cell column, precision required) or 'bbox' (four REAL columns — west, south, east, north). A derived index is never unique.",
|
|
114
|
+
"enum": [
|
|
115
|
+
"geohash",
|
|
116
|
+
"bbox"
|
|
117
|
+
]
|
|
118
|
+
},
|
|
119
|
+
"precision": {
|
|
120
|
+
"description": "Geohash cell length in characters, 1-12. Required beside derive: 'geohash' and refused anywhere else — there is no safe default, because the right cell size depends on the query radius, which the model cannot know.",
|
|
121
|
+
"type": "integer",
|
|
122
|
+
"minimum": 1,
|
|
123
|
+
"maximum": 12
|
|
111
124
|
}
|
|
112
125
|
},
|
|
113
126
|
"required": [
|
|
@@ -125,7 +138,7 @@
|
|
|
125
138
|
},
|
|
126
139
|
"x-rename": {
|
|
127
140
|
"type": "string",
|
|
128
|
-
"description": "Declares this entity as the rename of the named from-model entity (MIGRATION-FORMAT
|
|
141
|
+
"description": "Declares this entity as the rename of the named from-model entity (MIGRATION-FORMAT §9): a rename is declared, never inferred."
|
|
129
142
|
}
|
|
130
143
|
},
|
|
131
144
|
"required": [
|
package/src/algebra.js
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
/** The plan format version, carried on every plan. */
|
|
18
|
-
export const PLAN_VERSION =
|
|
18
|
+
export const PLAN_VERSION = 2;
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
21
|
* @typedef {{ segments: ({ name: string } | { index: number })[],
|
|
@@ -35,8 +35,23 @@ export const PLAN_VERSION = 1;
|
|
|
35
35
|
* { p: 'strop', kind: 'starts' | 'ends' | 'contains',
|
|
36
36
|
* ref: PlanRef, operand: PlanOperand } |
|
|
37
37
|
* { p: 'const', value: boolean } |
|
|
38
|
-
* { p: 'udf', name: string, key: string }
|
|
38
|
+
* { p: 'udf', name: string, key: string } |
|
|
39
|
+
* { p: 'bboxOverlap', columns: { w: string, s: string, e: string,
|
|
40
|
+
* n: string }, probe: { box: number[] } | { ext: string } } |
|
|
41
|
+
* { p: 'cellIn', column: string, cells: string[] } |
|
|
42
|
+
* { p: 'cellPrefix', column: string, prefix: string }
|
|
39
43
|
* )} PlanPredicate
|
|
44
|
+
* The last three are the SPATIAL forms: predicates over the derived
|
|
45
|
+
* index columns a model declares, which a spatial conjunct either
|
|
46
|
+
* translates to exactly or is proven to IMPLY. `bboxOverlap` is true
|
|
47
|
+
* when the row's stored box meets the probe's (touching edges count,
|
|
48
|
+
* as the kernel's `bboxIntersects` does); `cellIn` when the row's
|
|
49
|
+
* cell is one of the listed ones (the nine-cell neighbourhood, or a
|
|
50
|
+
* single whole cell); `cellPrefix` when it begins with a shorter one.
|
|
51
|
+
* None carries a `json_type` guard — the derived column IS the value
|
|
52
|
+
* — but each is TOTAL through its own `IS NOT NULL`, so a row with no
|
|
53
|
+
* box or no cell answers FALSE rather than SQL's NULL and negation
|
|
54
|
+
* still composes classically.
|
|
40
55
|
*
|
|
41
56
|
* @typedef {{ ref: PlanRef, desc: boolean, emptyGreatest: boolean }} PlanOrderTerm
|
|
42
57
|
*
|
package/src/ddl.js
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
import { analyzeQuery } from '@jarenjs/json/query';
|
|
18
18
|
import { DbCompileError } from './errors.js';
|
|
19
19
|
import { chain } from './driver.js';
|
|
20
|
+
import { BBOX_COMPONENTS, BBOX_INDEX_ORDER } from './derive.js';
|
|
20
21
|
|
|
21
22
|
/** The fixed physical column names of the 0.1 mapping. */
|
|
22
23
|
export const KEY_COLUMN = 'key';
|
|
@@ -76,15 +77,15 @@ export function compileIndexPath(expression, docPath) {
|
|
|
76
77
|
}
|
|
77
78
|
|
|
78
79
|
/**
|
|
79
|
-
* The
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
80
|
+
* The schema subschema at a segment path, walked structurally through
|
|
81
|
+
* `properties` / `items` / `prefixItems`. The collection's schema is
|
|
82
|
+
* the type source — that is why the physical mapping needs no
|
|
83
|
+
* engine-side inference. `undefined` where the walk leaves the schema.
|
|
83
84
|
* @param {any} schema
|
|
84
85
|
* @param {import('./dialect.js').JsonPathSegment[]} segments
|
|
85
|
-
* @returns {
|
|
86
|
+
* @returns {any}
|
|
86
87
|
*/
|
|
87
|
-
export function
|
|
88
|
+
export function schemaNodeAt(schema, segments) {
|
|
88
89
|
let node = schema;
|
|
89
90
|
for (const segment of segments) {
|
|
90
91
|
if (node === null || typeof node !== 'object') return undefined;
|
|
@@ -92,7 +93,21 @@ export function schemaTypeAt(schema, segments) {
|
|
|
92
93
|
? node.properties?.[segment.name]
|
|
93
94
|
: node.prefixItems?.[segment.index] ?? node.items;
|
|
94
95
|
}
|
|
95
|
-
|
|
96
|
+
return node === null || typeof node !== 'object' ? undefined : node;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The declared schema type at a segment path: the first non-`null`
|
|
101
|
+
* member of a union, which is the type a COLUMN takes its storage
|
|
102
|
+
* from. A caller that must know the whole union (a promotion refusing
|
|
103
|
+
* a member that may also be `null`) reads {@link schemaNodeAt}.
|
|
104
|
+
* @param {any} schema
|
|
105
|
+
* @param {import('./dialect.js').JsonPathSegment[]} segments
|
|
106
|
+
* @returns {string | undefined}
|
|
107
|
+
*/
|
|
108
|
+
export function schemaTypeAt(schema, segments) {
|
|
109
|
+
const node = schemaNodeAt(schema, segments);
|
|
110
|
+
if (node === undefined) return undefined;
|
|
96
111
|
if (typeof node.type === 'string') return node.type;
|
|
97
112
|
if (Array.isArray(node.type)) {
|
|
98
113
|
return node.type.find((t) => typeof t === 'string' && t !== 'null');
|
|
@@ -100,39 +115,139 @@ export function schemaTypeAt(schema, segments) {
|
|
|
100
115
|
return undefined;
|
|
101
116
|
}
|
|
102
117
|
|
|
118
|
+
/** The scalar schema types a derived index cannot be declared over. */
|
|
119
|
+
const SCALAR_TYPES = new Set(['string', 'integer', 'number', 'boolean']);
|
|
120
|
+
|
|
103
121
|
/**
|
|
104
122
|
* A stable generated-column name for a canonical path: readable where
|
|
105
123
|
* the path is tame, disambiguated by suffix where sanitizing collides.
|
|
124
|
+
*
|
|
125
|
+
* A DERIVED index names its columns from the same stem plus what makes
|
|
126
|
+
* the derivation distinct — the kind, and the geohash precision, since
|
|
127
|
+
* two precisions over one path are legitimately two column sets (a
|
|
128
|
+
* coarse bucketing index and a fine proximity one). A bbox derivation
|
|
129
|
+
* owns FOUR columns under one stem, so every one of them is claimed
|
|
130
|
+
* before the stem is accepted.
|
|
106
131
|
* @param {string} canonical
|
|
107
|
-
* @param {Map<string, string>}
|
|
132
|
+
* @param {Map<string, string>} byKey - identity -> column name (or stem)
|
|
108
133
|
* @param {Set<string>} taken
|
|
134
|
+
* @param {{ key?: string, suffix?: string, parts?: readonly string[] }} [options]
|
|
109
135
|
* @returns {string}
|
|
110
136
|
*/
|
|
111
|
-
function generatedColumnName(canonical,
|
|
112
|
-
const
|
|
137
|
+
function generatedColumnName(canonical, byKey, taken, options = undefined) {
|
|
138
|
+
const key = options?.key ?? canonical;
|
|
139
|
+
const existing = byKey.get(key);
|
|
113
140
|
if (existing !== undefined) return existing;
|
|
114
|
-
const
|
|
141
|
+
const stem = `gx_${canonical.replace(/[^A-Za-z0-9]+/g, '_').replace(/^_+|_+$/g, '')}`;
|
|
142
|
+
const base = options?.suffix === undefined ? stem : `${stem}_${options.suffix}`;
|
|
143
|
+
const parts = options?.parts ?? [''];
|
|
144
|
+
const claims = (/** @type {string} */ candidate) =>
|
|
145
|
+
parts.map((part) => (part === '' ? candidate : `${candidate}_${part}`));
|
|
115
146
|
let name = base;
|
|
116
|
-
for (let i = 2; taken.has(
|
|
117
|
-
|
|
118
|
-
|
|
147
|
+
for (let i = 2; claims(name).some((claimed) => taken.has(claimed)); i++)
|
|
148
|
+
name = `${base}_${i}`;
|
|
149
|
+
byKey.set(key, name);
|
|
150
|
+
for (const claimed of claims(name)) taken.add(claimed);
|
|
119
151
|
return name;
|
|
120
152
|
}
|
|
121
153
|
|
|
154
|
+
/**
|
|
155
|
+
* The columns one derived index contributes, appended to the plan's
|
|
156
|
+
* `generated` (the physical column list) and `derived` (what the write
|
|
157
|
+
* path and the migration backfill need to recompute a value).
|
|
158
|
+
*
|
|
159
|
+
* `derive` changes what is COMPUTED from the member, never how the
|
|
160
|
+
* member is selected — so the schema is still the type source, and a
|
|
161
|
+
* derivation over a path the schema types as a scalar is refused here
|
|
162
|
+
* rather than at the first query that returns nothing.
|
|
163
|
+
* @param {any} index - the normalized index declaration
|
|
164
|
+
* @param {any} context
|
|
165
|
+
* @returns {string[]} the column names the index covers, in order
|
|
166
|
+
*/
|
|
167
|
+
function deriveColumns(index, context) {
|
|
168
|
+
const {
|
|
169
|
+
collection, dialect, stored, segments, canonical, pathText,
|
|
170
|
+
columnByCanonical, taken, generated, derived,
|
|
171
|
+
} = context;
|
|
172
|
+
const declaredType = schemaTypeAt(collection.schema, segments);
|
|
173
|
+
if (declaredType !== undefined && SCALAR_TYPES.has(declaredType)) {
|
|
174
|
+
throw new DbCompileError('JD0004',
|
|
175
|
+
`the index path '${index.paths[0]}' is typed '${declaredType}' by the schema, and a `
|
|
176
|
+
+ `${index.derive} index derives from a position or a geometry — an array or an object`,
|
|
177
|
+
`${index.docPath}/derive`);
|
|
178
|
+
}
|
|
179
|
+
const isGeohash = index.derive === 'geohash';
|
|
180
|
+
// the identity that decides column SHARING: two indexes over the same
|
|
181
|
+
// path with the same derivation and the same precision are one column
|
|
182
|
+
// set; two precisions over one path are two, and legitimately so
|
|
183
|
+
const key = `${canonical}|${index.derive}|${index.precision ?? ''}`;
|
|
184
|
+
const stem = generatedColumnName(canonical, columnByCanonical, taken, {
|
|
185
|
+
key,
|
|
186
|
+
suffix: isGeohash ? `gh${index.precision}` : 'bbox',
|
|
187
|
+
parts: isGeohash ? undefined : BBOX_COMPONENTS,
|
|
188
|
+
});
|
|
189
|
+
const type = dialect.typeFor(isGeohash ? 'string' : 'number', 'generated');
|
|
190
|
+
const components = isGeohash ? [undefined] : BBOX_COMPONENTS;
|
|
191
|
+
const nameOf = (/** @type {string | undefined} */ component) =>
|
|
192
|
+
(component === undefined ? stem : `${stem}_${component}`);
|
|
193
|
+
if (!generated.some((column) => column.name === nameOf(components[0]))) {
|
|
194
|
+
for (const component of components) {
|
|
195
|
+
const column = {
|
|
196
|
+
name: nameOf(component),
|
|
197
|
+
type,
|
|
198
|
+
pathText,
|
|
199
|
+
canonical,
|
|
200
|
+
derive: index.derive,
|
|
201
|
+
precision: index.precision,
|
|
202
|
+
component,
|
|
203
|
+
stored,
|
|
204
|
+
};
|
|
205
|
+
generated.push({
|
|
206
|
+
...column,
|
|
207
|
+
expression: stored ? null : dialect.derivedColumn(
|
|
208
|
+
dialect.quoteIdentifier(DOC_COLUMN), pathText, column),
|
|
209
|
+
});
|
|
210
|
+
derived.push({
|
|
211
|
+
name: column.name,
|
|
212
|
+
derive: index.derive,
|
|
213
|
+
precision: index.precision,
|
|
214
|
+
component,
|
|
215
|
+
segments,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
// the index COVERS its columns in (w, e, s, n) order, which is not
|
|
220
|
+
// their declaration order — see BBOX_INDEX_ORDER
|
|
221
|
+
return isGeohash ? [stem] : BBOX_INDEX_ORDER.map((component) => nameOf(component));
|
|
222
|
+
}
|
|
223
|
+
|
|
122
224
|
/**
|
|
123
225
|
* Plan one collection's physical shape: the DDL statements to create
|
|
124
226
|
* it and the structural facts an existing table must match (the
|
|
125
227
|
* `JD0002` comparison set).
|
|
228
|
+
* A derived index (`derive: 'geohash' | 'bbox'`) maps to the same
|
|
229
|
+
* shape through a registered deterministic function, EXCEPT where the
|
|
230
|
+
* driver cannot index one (`capabilities.deterministicIndexableFunctions`
|
|
231
|
+
* is false): there the columns are ordinary ones the store writes. The
|
|
232
|
+
* two mappings produce different declared text on purpose — a database
|
|
233
|
+
* built under one and opened under the other really does disagree, and
|
|
234
|
+
* `verifyShape` says so rather than papering over it.
|
|
126
235
|
* @param {string} name - The collection name (also the table name)
|
|
127
236
|
* @param {{ schema: any, keySegments: { name: string }[] | null,
|
|
128
237
|
* identity: string, indexes: { name: string, paths: string[],
|
|
129
|
-
* unique: boolean,
|
|
238
|
+
* unique: boolean, derive?: string | null, precision?: number,
|
|
239
|
+
* docPath: string }[] }} collection - normalized
|
|
130
240
|
* @param {any} dialect
|
|
241
|
+
* @param {{ derived?: 'virtual' | 'stored' }} [options] - the physical
|
|
242
|
+
* mapping for derived columns; `'virtual'` (a generated column over a
|
|
243
|
+
* registered function) unless the driver says it cannot index one
|
|
131
244
|
* @returns {{
|
|
132
245
|
* table: string, keyColumn: string, docColumn: string,
|
|
133
246
|
* keyType: string,
|
|
134
247
|
* generated: { name: string, type: string, pathText: string,
|
|
135
248
|
* canonical: string }[],
|
|
249
|
+
* derived: { name: string, derive: string, precision?: number,
|
|
250
|
+
* component?: string, segments: any[] }[],
|
|
136
251
|
* columnByCanonical: Map<string, string>,
|
|
137
252
|
* createSql: string[],
|
|
138
253
|
* expected: { columns: { name: string, type: string,
|
|
@@ -140,7 +255,8 @@ function generatedColumnName(canonical, byCanonical, taken) {
|
|
|
140
255
|
* columns: string[] }[] },
|
|
141
256
|
* }}
|
|
142
257
|
*/
|
|
143
|
-
export function planCollection(name, collection, dialect) {
|
|
258
|
+
export function planCollection(name, collection, dialect, options = undefined) {
|
|
259
|
+
const stored = options?.derived === 'stored';
|
|
144
260
|
const keyType = collection.identity === 'integer'
|
|
145
261
|
? dialect.typeFor('integer', 'key')
|
|
146
262
|
: collection.identity === 'uuid'
|
|
@@ -154,6 +270,8 @@ export function planCollection(name, collection, dialect) {
|
|
|
154
270
|
const taken = new Set([KEY_COLUMN, DOC_COLUMN]);
|
|
155
271
|
/** @type {{ name: string, type: string, pathText: string, canonical: string }[]} */
|
|
156
272
|
const generated = [];
|
|
273
|
+
/** @type {any[]} */
|
|
274
|
+
const derived = [];
|
|
157
275
|
/** @type {{ name: string, unique: boolean, columns: string[] }[]} */
|
|
158
276
|
const indexes = [];
|
|
159
277
|
|
|
@@ -168,6 +286,13 @@ export function planCollection(name, collection, dialect) {
|
|
|
168
286
|
`the index path '${index.paths[i]}' names a member the dialect's JSON path grammar cannot carry`,
|
|
169
287
|
pathDocPath);
|
|
170
288
|
}
|
|
289
|
+
if (index.derive) {
|
|
290
|
+
columns.push(...deriveColumns(index, {
|
|
291
|
+
collection, dialect, stored, segments, canonical, pathText,
|
|
292
|
+
columnByCanonical, taken, generated, derived,
|
|
293
|
+
}));
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
171
296
|
const known = columnByCanonical.has(canonical);
|
|
172
297
|
const columnName = generatedColumnName(canonical, columnByCanonical, taken);
|
|
173
298
|
if (!known) {
|
|
@@ -210,13 +335,17 @@ export function planCollection(name, collection, dialect) {
|
|
|
210
335
|
docColumn: DOC_COLUMN,
|
|
211
336
|
keyType,
|
|
212
337
|
generated,
|
|
338
|
+
derived,
|
|
213
339
|
columnByCanonical,
|
|
214
340
|
createSql,
|
|
215
341
|
expected: {
|
|
216
342
|
columns: [
|
|
217
343
|
{ name: KEY_COLUMN, type: keyType, generated: false },
|
|
218
344
|
{ name: DOC_COLUMN, type: dialect.docColumnType, generated: false },
|
|
219
|
-
|
|
345
|
+
// a STORED derived column is an ordinary one: the flag is what
|
|
346
|
+
// `pragma_table_xinfo` reports, and it is the difference a file
|
|
347
|
+
// moved between the two physical mappings shows up as
|
|
348
|
+
...generated.map((g) => ({ name: g.name, type: g.type, generated: g.stored !== true })),
|
|
220
349
|
],
|
|
221
350
|
indexes: indexes
|
|
222
351
|
// the COLUMNS keep their declared order — an index is ordered, and
|
package/src/derive.js
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file Derived index columns: the one place a declared
|
|
4
|
+
* `indexes[].derive` becomes a value. A spatial member is an array of
|
|
5
|
+
* numbers or an object, and a generated column must be a scalar, so a
|
|
6
|
+
* geohash cell or a bounding-box edge is what actually gets indexed.
|
|
7
|
+
*
|
|
8
|
+
* Every cell and every box comes from `@jarenjs/core/geo`; nothing
|
|
9
|
+
* here computes spatial arithmetic of its own. The same functions
|
|
10
|
+
* serve BOTH physical mappings — registered as deterministic SQL
|
|
11
|
+
* functions inside a virtual generated column's expression where the
|
|
12
|
+
* driver can index them, and called directly on the write path where
|
|
13
|
+
* it cannot — so the two branches cannot drift into different answers.
|
|
14
|
+
*
|
|
15
|
+
* It is also this package's ONLY seam onto `@jarenjs/core/geo` (D1 —
|
|
16
|
+
* one home for spatial arithmetic, grep-proven by test): the planner's
|
|
17
|
+
* probe geometry — the box of a literal or bound region, the box of a
|
|
18
|
+
* bounded-distance circle, a cell's neighbourhood — is computed by the
|
|
19
|
+
* helpers below rather than by an import of its own.
|
|
20
|
+
*
|
|
21
|
+
* Determinism is the contract, not a convenience: a value here is a
|
|
22
|
+
* pure function of the document bytes. Nothing reads the clock, a
|
|
23
|
+
* random source or store state, because an INDEX over a function that
|
|
24
|
+
* did would make the database unreadable from a connection whose
|
|
25
|
+
* function answered differently — and unreadable, not merely wrong:
|
|
26
|
+
* a connection that has not registered the function at all cannot even
|
|
27
|
+
* SELECT the table (probed). That hazard is why the mapping is
|
|
28
|
+
* capability-gated rather than always-on.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import {
|
|
32
|
+
bboxOf, centroidOf, circleBounds, geohashEncode, geohashNeighbours,
|
|
33
|
+
} from '@jarenjs/core/geo';
|
|
34
|
+
|
|
35
|
+
import { chain } from './driver.js';
|
|
36
|
+
|
|
37
|
+
/** The closed set of derive kinds. */
|
|
38
|
+
export const DERIVE_KINDS = new Set(['geohash', 'bbox']);
|
|
39
|
+
|
|
40
|
+
/** A bbox index's four columns, in the order they are declared —
|
|
41
|
+
* `[west, south, east, north]`, the order the kernel's boxes carry. */
|
|
42
|
+
export const BBOX_COMPONENTS = Object.freeze(['w', 's', 'e', 'n']);
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The order a bbox index COVERS its four columns, which is not the
|
|
46
|
+
* order they are declared in: an intersection test reads
|
|
47
|
+
* `w <= ? AND e >= ? AND s <= ? AND n >= ?`, so the two longitude
|
|
48
|
+
* bounds sit together at the front of the index where a leading-column
|
|
49
|
+
* range can use them. `(a,b)` and `(b,a)` are different indexes.
|
|
50
|
+
*/
|
|
51
|
+
export const BBOX_INDEX_ORDER = Object.freeze(['w', 'e', 's', 'n']);
|
|
52
|
+
|
|
53
|
+
/** Where each component sits in `[west, south, east, north]`. */
|
|
54
|
+
const BBOX_AT = { w: 0, s: 1, e: 2, n: 3 };
|
|
55
|
+
|
|
56
|
+
/** The declared geohash precision range (characters). */
|
|
57
|
+
export const PRECISION_MIN = 1;
|
|
58
|
+
export const PRECISION_MAX = 12;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The representative position of any GeoJSON value: the position
|
|
62
|
+
* itself for a bare `[lon, lat]` or a Point, the mean vertex
|
|
63
|
+
* otherwise. Null when there is no position, and null when any
|
|
64
|
+
* position is non-finite — `centroidOf` propagates a NaN through the
|
|
65
|
+
* mean, so the finiteness of the result is the whole-value test.
|
|
66
|
+
* @param {any} value
|
|
67
|
+
* @returns {number[] | null}
|
|
68
|
+
*/
|
|
69
|
+
function representativePosition(value) {
|
|
70
|
+
const centre = centroidOf(value);
|
|
71
|
+
if (centre === null) return null;
|
|
72
|
+
return Number.isFinite(centre[0]) && Number.isFinite(centre[1]) ? centre : null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The geohash cell of a value at a precision, or null when the value
|
|
77
|
+
* carries no bounded position.
|
|
78
|
+
* @param {any} value
|
|
79
|
+
* @param {number} precision
|
|
80
|
+
* @returns {string | null}
|
|
81
|
+
*/
|
|
82
|
+
export function deriveGeohash(value, precision) {
|
|
83
|
+
const position = representativePosition(value);
|
|
84
|
+
return position === null ? null : geohashEncode(position[0], position[1], precision);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* One edge of a value's bounding box, or null when it has none —
|
|
89
|
+
* including the D6 case where a non-finite coordinate refuses the box
|
|
90
|
+
* rather than producing one that does not bound its input.
|
|
91
|
+
* @param {any} value
|
|
92
|
+
* @param {string} component - `'w'`, `'s'`, `'e'` or `'n'`
|
|
93
|
+
* @returns {number | null}
|
|
94
|
+
*/
|
|
95
|
+
export function deriveBboxEdge(value, component) {
|
|
96
|
+
const box = bboxOf(value);
|
|
97
|
+
return box === null ? null : box[BBOX_AT[component]];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* A member as the database will hold it. A derived value must be a
|
|
102
|
+
* function of the STORED document, not of the object handed to the
|
|
103
|
+
* write: JSON has no `NaN` and no `Infinity`, so a non-finite
|
|
104
|
+
* coordinate becomes `null` on the way in and is no longer a position
|
|
105
|
+
* at all. Computing from the in-memory value would make the two
|
|
106
|
+
* physical mappings answer differently for the same document, which is
|
|
107
|
+
* the one thing they may never do.
|
|
108
|
+
*
|
|
109
|
+
* Only the indexed MEMBER round-trips, not the whole document — it is
|
|
110
|
+
* the only part a derived column reads.
|
|
111
|
+
* @param {any} member
|
|
112
|
+
* @returns {any} the member as stored, or `undefined` when there is none
|
|
113
|
+
*/
|
|
114
|
+
export function storedMemberForm(member) {
|
|
115
|
+
if (member === undefined) return undefined;
|
|
116
|
+
const text = JSON.stringify(member);
|
|
117
|
+
return text === undefined ? undefined : JSON.parse(text);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The value of one derived column for a document member.
|
|
122
|
+
* @param {{ derive: string, precision?: number, component?: string }} column
|
|
123
|
+
* @param {any} member - the value at the index path, or `undefined`
|
|
124
|
+
* @returns {string | number | null}
|
|
125
|
+
*/
|
|
126
|
+
export function derivedValue(column, member) {
|
|
127
|
+
if (member === undefined || member === null) return null;
|
|
128
|
+
return column.derive === 'geohash'
|
|
129
|
+
? deriveGeohash(member, /** @type {number} */ (column.precision))
|
|
130
|
+
: deriveBboxEdge(member, /** @type {string} */ (column.component));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Read the member one derived column is computed from, walking the
|
|
135
|
+
* same typed segments the physical mapping was planned over.
|
|
136
|
+
* @param {any} doc
|
|
137
|
+
* @param {import('./dialect.js').JsonPathSegment[]} segments
|
|
138
|
+
* @returns {any} the member, or `undefined`
|
|
139
|
+
*/
|
|
140
|
+
export function memberAt(doc, segments) {
|
|
141
|
+
let node = doc;
|
|
142
|
+
for (const segment of segments) {
|
|
143
|
+
if (node === null || typeof node !== 'object') return undefined;
|
|
144
|
+
node = 'name' in segment ? node[segment.name] : node[segment.index];
|
|
145
|
+
}
|
|
146
|
+
return node;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Parse the JSON text a derived column's expression hands the function.
|
|
151
|
+
* SQLite passes SQL NULL for a member the document does not have, and
|
|
152
|
+
* `json()` of an extracted member is unambiguous JSON text otherwise.
|
|
153
|
+
* @param {any} text
|
|
154
|
+
* @returns {any} the value, or `undefined` when there is none
|
|
155
|
+
*/
|
|
156
|
+
function parseMember(text) {
|
|
157
|
+
if (typeof text !== 'string') return undefined;
|
|
158
|
+
try {
|
|
159
|
+
return JSON.parse(text);
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
return undefined;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Register the deterministic functions the virtual generated columns
|
|
168
|
+
* call. Idempotent per connection by SQLite's own semantics (a second
|
|
169
|
+
* registration replaces the first with the identical implementation),
|
|
170
|
+
* and a no-op where the driver cannot index a registered function —
|
|
171
|
+
* that branch stores the columns instead.
|
|
172
|
+
*
|
|
173
|
+
* The four bbox edges share a one-entry memo of the last text they
|
|
174
|
+
* were asked about, because they are called back to back with the same
|
|
175
|
+
* argument for one row and the box costs a full walk of the geometry.
|
|
176
|
+
* The memo is keyed on the argument itself, so it changes no answer.
|
|
177
|
+
* @param {any} connection
|
|
178
|
+
* @returns {any} value-or-promise
|
|
179
|
+
*/
|
|
180
|
+
export function registerDeriveFunctions(connection) {
|
|
181
|
+
if (connection.capabilities?.deterministicIndexableFunctions !== true
|
|
182
|
+
|| typeof connection.registerFunction !== 'function') return null;
|
|
183
|
+
/** @type {any} */
|
|
184
|
+
let memoText = null;
|
|
185
|
+
/** @type {number[] | null} */
|
|
186
|
+
let memoBox = null;
|
|
187
|
+
const boxOf = (/** @type {any} */ text) => {
|
|
188
|
+
if (text === memoText) return memoBox;
|
|
189
|
+
const value = parseMember(text);
|
|
190
|
+
memoBox = value === undefined ? null : bboxOf(value);
|
|
191
|
+
memoText = text;
|
|
192
|
+
return memoBox;
|
|
193
|
+
};
|
|
194
|
+
/** @type {[string, Function][]} */
|
|
195
|
+
const registrations = [
|
|
196
|
+
['jaren_geohash', (/** @type {any} */ text, /** @type {any} */ precision) => {
|
|
197
|
+
const value = parseMember(text);
|
|
198
|
+
return value === undefined ? null : deriveGeohash(value, Number(precision));
|
|
199
|
+
}],
|
|
200
|
+
];
|
|
201
|
+
for (const component of BBOX_COMPONENTS) {
|
|
202
|
+
registrations.push([`jaren_bbox_${component}`, (/** @type {any} */ text) => {
|
|
203
|
+
const box = boxOf(text);
|
|
204
|
+
return box === null ? null : box[BBOX_AT[component]];
|
|
205
|
+
}]);
|
|
206
|
+
}
|
|
207
|
+
const step = (i) => (i >= registrations.length
|
|
208
|
+
? null
|
|
209
|
+
: chain(connection.registerFunction(registrations[i][0],
|
|
210
|
+
{ deterministic: true }, registrations[i][1]), () => step(i + 1)));
|
|
211
|
+
return step(0);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ————— Probe geometry: what a PUSHED predicate compares against —————
|
|
215
|
+
//
|
|
216
|
+
// A promoted spatial predicate narrows through the derived columns by
|
|
217
|
+
// comparing them with a box (or a cell) computed from the query's own
|
|
218
|
+
// operand. That operand is known either at plan time (a literal region)
|
|
219
|
+
// or at bind time (an external one), and both go through here so the
|
|
220
|
+
// two answer identically and neither reaches past this file for its
|
|
221
|
+
// arithmetic.
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* The bounding box of a probe value, or `null` when it has none — the
|
|
225
|
+
* D6 refusal included, which is what makes an unbounded probe divert to
|
|
226
|
+
* the full scan instead of narrowing with a box that does not bound it.
|
|
227
|
+
* @param {any} value - a GeoJSON value or a `[lon, lat]` position
|
|
228
|
+
* @returns {number[] | null} `[west, south, east, north]`
|
|
229
|
+
*/
|
|
230
|
+
export function probeBox(value) {
|
|
231
|
+
return bboxOf(value);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* The representative position §8.14 measures a probe value by — the
|
|
236
|
+
* same rule the derived columns use, so the pushed filter and the
|
|
237
|
+
* engine cannot disagree about where a value IS. `null` when it has no
|
|
238
|
+
* bounded position.
|
|
239
|
+
* @param {any} value
|
|
240
|
+
* @returns {number[] | null}
|
|
241
|
+
*/
|
|
242
|
+
export function probePosition(value) {
|
|
243
|
+
return representativePosition(value);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* The bounding box of the circle of `metres` around a position — the
|
|
248
|
+
* box a `$distance <= r` predicate narrows with, on the same sphere and
|
|
249
|
+
* the same radius constant the engine measures with. `null` when the
|
|
250
|
+
* radius is not a finite non-negative number, or when the circle
|
|
251
|
+
* reaches a pole, where there is no longitude bound to give.
|
|
252
|
+
* @param {number[]} position
|
|
253
|
+
* @param {number} metres
|
|
254
|
+
* @returns {number[] | null} `[west, south, east, north]`, NOT wrapped
|
|
255
|
+
* into `[-180, 180]`: a circle spanning the antimeridian answers a
|
|
256
|
+
* west below -180, which is how the planner detects it
|
|
257
|
+
*/
|
|
258
|
+
export function probeCircleBox(position, metres) {
|
|
259
|
+
return circleBounds(position[0], position[1], metres);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* A cell and its neighbours, the nine-cell probe D7 requires — a single
|
|
264
|
+
* prefix is bucketing, never proximity.
|
|
265
|
+
* @param {string} cell
|
|
266
|
+
* @returns {string[]} up to nine cells (fewer past a pole)
|
|
267
|
+
*/
|
|
268
|
+
export function cellNeighbourhood(cell) {
|
|
269
|
+
return geohashNeighbours(cell);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* The value one DERIVED parameter slot binds: an axis of a bound
|
|
274
|
+
* external's bounding box, computed at bind time because a GeoJSON
|
|
275
|
+
* object is not a value any database can bind. `null` when the value
|
|
276
|
+
* has no box, which is what tells the caller to divert.
|
|
277
|
+
* @param {{ kind: string, external: string, axis: string }} derived
|
|
278
|
+
* @param {any} value - the bound external
|
|
279
|
+
* @returns {number | null}
|
|
280
|
+
*/
|
|
281
|
+
export function derivedSlotValue(derived, value) {
|
|
282
|
+
const box = bboxOf(value);
|
|
283
|
+
return box === null ? null : box[BBOX_AT[derived.axis]];
|
|
284
|
+
}
|