@jarenjs/db 0.84.3 → 0.86.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +27 -3
- package/README.md +35 -14
- package/docs/HOSTS.md +131 -4
- package/docs/MODEL-FORMAT.md +15 -4
- package/docs/NATIVE-PLANS.md +24 -7
- package/docs/SQLITE-RELATIONAL.md +190 -0
- package/package.json +24 -4
- package/schemas/jaren-model.authoring.schema.json +157 -0
- package/schemas/jaren-model.draft-07.schema.json +157 -0
- package/schemas/jaren-model.schema.json +157 -0
- package/src/capture.js +4 -2
- package/src/ddl.js +8 -3
- package/src/dialects/sqlite-relational.js +314 -0
- package/src/dialects/sqlite-schema.js +142 -0
- package/src/dialects/sqlite.js +17 -0
- package/src/driver.js +3 -0
- package/src/drivers/bun.js +26 -15
- package/src/drivers/node-process-endpoint.js +13 -0
- package/src/drivers/node-process.js +177 -0
- package/src/drivers/node-worker-endpoint.js +3 -103
- package/src/drivers/node-worker.js +6 -178
- package/src/drivers/node.js +3 -0
- package/src/drivers/snapshot.js +49 -0
- package/src/drivers/sqlite-endpoint.js +113 -0
- package/src/drivers/worker-client.js +185 -0
- package/src/drivers/worker-protocol.js +15 -0
- package/src/emit.js +37 -5
- package/src/engine-metadata.js +18 -0
- package/src/errors.js +1 -0
- package/src/index.js +4 -1
- package/src/introspect.js +1 -2
- package/src/jobs.js +4 -2
- package/src/migrate.js +12 -16
- package/src/model-api.js +4 -0
- package/src/model.js +12 -0
- package/src/mutation.js +66 -14
- package/src/physical.js +37 -7
- package/src/plan.js +66 -3
- package/src/query-api.js +5 -0
- package/src/query.js +39 -6
- package/src/relational-api.js +6 -0
- package/src/store.js +6 -4
- package/src/table-migration.js +158 -0
- package/types/bun.d.ts +3 -0
- package/types/entity.d.ts +1 -0
- package/types/index.d.ts +11 -2
- package/types/model.d.ts +1 -0
- package/types/node-process.d.ts +33 -0
- package/types/node.d.ts +3 -0
- package/types/query.d.ts +2 -0
- package/types/relational.d.ts +114 -0
package/src/mutation.js
CHANGED
|
@@ -8,6 +8,7 @@ import { entityShape, planEntityPredicate } from './plan.js';
|
|
|
8
8
|
import { createEntityPredicateEmitters } from './emit.js';
|
|
9
9
|
import { physicalSelection } from './physical.js';
|
|
10
10
|
import { utf8Length } from './cursor.js';
|
|
11
|
+
import { relationalEmitter } from './dialects/sqlite-relational.js';
|
|
11
12
|
|
|
12
13
|
/** Compile once per document, execute within the existing guarded transaction.
|
|
13
14
|
* @param {any} connection @param {any} entity @param {any} mapping @param {any} core */
|
|
@@ -35,7 +36,9 @@ export function createEntityMutation(connection, entity, mapping, core) {
|
|
|
35
36
|
if (entity.physical == null || dialect.name !== 'sqlite') fail('native mutations require a declared SQLite column layout');
|
|
36
37
|
core.plan.writable();
|
|
37
38
|
if (!document || typeof document !== 'object' || Array.isArray(document)) fail('a mutation is an object');
|
|
38
|
-
const allowed = { update: ['key', 'expectedRevision', 'set'
|
|
39
|
+
const allowed = { update: ['key', 'expectedRevision', 'set', 'where', 'reporting', 'expressions'],
|
|
40
|
+
delete: ['key', 'where', 'expectedRevision'],
|
|
41
|
+
upsert: ['values', 'conflict', 'conflictWhere', 'update', 'onConflict', 'reporting'],
|
|
39
42
|
'insert-select': ['source', 'where', 'select', 'conflict', 'onConflict'] }[document.op];
|
|
40
43
|
if (!allowed) fail('op must be update, upsert or insert-select');
|
|
41
44
|
for (const key of Object.keys(document))
|
|
@@ -52,29 +55,75 @@ export function createEntityMutation(connection, entity, mapping, core) {
|
|
|
52
55
|
const params = [];
|
|
53
56
|
const param = (value) => { params.push(value); return dialect.parameterRef(params.length, 'v'); };
|
|
54
57
|
const table = q(mapping.table);
|
|
58
|
+
const sqlExpression = (expression, inline = false) => {
|
|
59
|
+
const mapped = (value) => {
|
|
60
|
+
if (Array.isArray(value)) return value.map(mapped);
|
|
61
|
+
if (value === null || typeof value !== 'object') return value;
|
|
62
|
+
if (value.$sql === 'value') return value;
|
|
63
|
+
if (value.$sql === 'column') {
|
|
64
|
+
if (value.table !== undefined && value.table !== 'it') fail('mutation column expressions refer to the current entity');
|
|
65
|
+
return { $sql: 'column', name: column(value.name).physical };
|
|
66
|
+
}
|
|
67
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, mapped(item)]));
|
|
68
|
+
};
|
|
69
|
+
const emitter = relationalEmitter({ inline });
|
|
70
|
+
const result = emitter.expr(mapped(expression));
|
|
71
|
+
params.push(...emitter.params);
|
|
72
|
+
return result;
|
|
73
|
+
};
|
|
74
|
+
const predicate = (expression) => {
|
|
75
|
+
if (expression?.$sql !== undefined || typeof expression === 'number') return sqlExpression(expression);
|
|
76
|
+
const analyzed = analyzeQuery({ $for: { it: '$[*]' }, $where: expression, $return: '$it' });
|
|
77
|
+
const planned = planEntityPredicate(analyzed.root.where, analyzed.root.forBindings[0].slot, entityShape(entity, mapping));
|
|
78
|
+
if ('refusal' in planned) fail(planned.refusal.reason);
|
|
79
|
+
const emitter = createEntityPredicateEmitters(dialect, (slot) => {
|
|
80
|
+
if (!Object.hasOwn(slot, 'literal')) return fail('mutation predicates use literal values');
|
|
81
|
+
return param(slot.literal);
|
|
82
|
+
});
|
|
83
|
+
return emitter.emitPred(table, `${table}.${q('doc')}`, planned.pred);
|
|
84
|
+
};
|
|
55
85
|
let sql;
|
|
56
86
|
let prefix = '';
|
|
57
|
-
if (document.
|
|
58
|
-
|
|
59
|
-
const
|
|
87
|
+
if (document.reporting !== undefined && !['matched', 'changed'].includes(document.reporting)) fail('reporting is matched or changed');
|
|
88
|
+
if (document.op === 'update' || document.op === 'delete') {
|
|
89
|
+
const set = document.set ?? {}, expressions = document.expressions ?? {};
|
|
90
|
+
if (document.op === 'update' && (!set || typeof set !== 'object' || Array.isArray(set)
|
|
91
|
+
|| !expressions || typeof expressions !== 'object' || Array.isArray(expressions)
|
|
92
|
+
|| !Object.keys(set).length && !Object.keys(expressions).length)) fail('update needs set or expressions assignments');
|
|
93
|
+
const assignments = Object.entries(set).map(([name, value]) => {
|
|
60
94
|
const c = writableColumn(name);
|
|
61
95
|
const encoded = core.plan.encodeColumn(name, value);
|
|
62
|
-
return { name: q(c.physical), compare: comparison(c), value: param(encoded), encoded };
|
|
96
|
+
return { name: q(c.physical), compare: comparison(c), value: param(encoded), different: () => param(encoded) };
|
|
63
97
|
});
|
|
64
|
-
const
|
|
65
|
-
|
|
98
|
+
for (const [name, expression] of Object.entries(expressions)) {
|
|
99
|
+
if (Object.hasOwn(set, name)) fail('an assignment has exactly one owner');
|
|
100
|
+
const c = writableColumn(name);
|
|
101
|
+
assignments.push({ name: q(c.physical), compare: comparison(c), value: sqlExpression(expression), different: () => sqlExpression(expression) });
|
|
102
|
+
}
|
|
103
|
+
const where = [];
|
|
104
|
+
if (Object.hasOwn(document, 'key')) {
|
|
105
|
+
const parts = core.normalizeKey(document.key);
|
|
106
|
+
where.push(...core.plan.keys.map((key, i) => `${q(column(key).physical)} = ${param(core.plan.encodeColumn(key, parts[i]))}`));
|
|
107
|
+
}
|
|
108
|
+
if (Object.hasOwn(document, 'where')) where.push(predicate(document.where));
|
|
109
|
+
if (!where.length) fail('update/delete require a key or an explicit predicate');
|
|
66
110
|
if (entity.version !== null) {
|
|
67
111
|
if (!Number.isSafeInteger(document.expectedRevision) || document.expectedRevision < 0) fail('a versioned update needs expectedRevision');
|
|
68
112
|
where.push(`${q(column(entity.version).physical)} = ${param(document.expectedRevision)}`);
|
|
69
113
|
}
|
|
70
114
|
else if (document.expectedRevision !== undefined) fail('expectedRevision needs a declared version member');
|
|
71
|
-
|
|
115
|
+
if (document.op === 'update' && document.reporting !== 'matched')
|
|
116
|
+
where.push(`(${assignments.map((a) => `${a.compare} IS NOT ${a.different()}`).join(' OR ')})`);
|
|
72
117
|
const sets = assignments.map((a) => `${a.name} = ${a.value}`);
|
|
73
118
|
if (entity.version !== null) sets.push(`${q(column(entity.version).physical)} = ${q(column(entity.version).physical)} + 1`);
|
|
74
|
-
sql = `
|
|
119
|
+
sql = document.op === 'delete' ? `DELETE FROM ${table} WHERE ${where.map((part) => `(${part})`).join(' AND ')}`
|
|
120
|
+
: `UPDATE ${table} SET ${sets.join(', ')} WHERE ${where.map((part) => `(${part})`).join(' AND ')}`;
|
|
75
121
|
}
|
|
76
122
|
else {
|
|
77
|
-
if (
|
|
123
|
+
if (!Array.isArray(document.conflict) || !document.conflict.length || new Set(document.conflict).size !== document.conflict.length)
|
|
124
|
+
fail('conflict must name distinct mapped columns');
|
|
125
|
+
document.conflict.forEach(column);
|
|
126
|
+
if (document.op === 'insert-select' && JSON.stringify(document.conflict) !== JSON.stringify(core.plan.keys)) fail('insert-select conflict must name the complete ordered primary key');
|
|
78
127
|
let names;
|
|
79
128
|
let source;
|
|
80
129
|
if (document.op === 'upsert') {
|
|
@@ -82,7 +131,7 @@ export function createEntityMutation(connection, entity, mapping, core) {
|
|
|
82
131
|
const complete = core.complete(document.values, { updating: false });
|
|
83
132
|
const split = core.plan.split(complete);
|
|
84
133
|
names = split.values.map((v) => v.name);
|
|
85
|
-
if (
|
|
134
|
+
if (document.conflict.some((key) => !names.includes(key))) fail('upsert requires every conflict value');
|
|
86
135
|
source = `VALUES (${split.values.map((v) => param(v.value)).join(', ')})`;
|
|
87
136
|
}
|
|
88
137
|
else {
|
|
@@ -120,8 +169,10 @@ export function createEntityMutation(connection, entity, mapping, core) {
|
|
|
120
169
|
+ dialect.mutationRowGuard(`(SELECT COUNT(*) FROM ${q('_jaren_source')})`, maxRows);
|
|
121
170
|
}
|
|
122
171
|
sql = `INSERT INTO ${table} (${names.map((name) => q(column(name).physical)).join(', ')}) ${source}`
|
|
123
|
-
+ ` ON CONFLICT (${
|
|
124
|
-
if (document.
|
|
172
|
+
+ ` ON CONFLICT (${document.conflict.map((key) => q(column(key).physical)).join(', ')})`;
|
|
173
|
+
if (document.conflictWhere !== undefined) sql += ` WHERE ${sqlExpression(document.conflictWhere, true)}`;
|
|
174
|
+
if (document.onConflict !== undefined && !['nothing', 'update'].includes(document.onConflict)) fail('onConflict is nothing or update');
|
|
175
|
+
if (document.op === 'insert-select' || document.onConflict === 'nothing') sql += ' DO NOTHING';
|
|
125
176
|
else {
|
|
126
177
|
if (!Array.isArray(document.update) || !document.update.length || new Set(document.update).size !== document.update.length) fail('upsert update names distinct stored members');
|
|
127
178
|
const changes = document.update.map((name) => {
|
|
@@ -131,7 +182,8 @@ export function createEntityMutation(connection, entity, mapping, core) {
|
|
|
131
182
|
});
|
|
132
183
|
const sets = changes.map(({ name }) => `${name} = excluded.${name}`);
|
|
133
184
|
if (entity.version !== null) sets.push(`${q(column(entity.version).physical)} = ${table}.${q(column(entity.version).physical)} + 1`);
|
|
134
|
-
sql += ` DO UPDATE SET ${sets.join(', ')}
|
|
185
|
+
sql += ` DO UPDATE SET ${sets.join(', ')}`;
|
|
186
|
+
if (document.reporting !== 'matched') sql += ` WHERE ${changes.map(({ name, compare }) => `${compare} IS NOT excluded.${name}`).join(' OR ')}`;
|
|
135
187
|
}
|
|
136
188
|
}
|
|
137
189
|
sql = prefix + sql + ` RETURNING ${physicalSelection(mapping, dialect)}`;
|
package/src/physical.js
CHANGED
|
@@ -4,6 +4,8 @@ import { getEpochOfDateTimeRFC3339, getEpochOfDateOnlyRFC3339 } from '@jarenjs/c
|
|
|
4
4
|
import { DbCompileError, DbRuntimeError } from './errors.js';
|
|
5
5
|
import { chain } from './driver.js';
|
|
6
6
|
import { canonicalizeJson } from '@jarenjs/json/canonical';
|
|
7
|
+
import { planTable } from './dialects/sqlite-schema.js';
|
|
8
|
+
import { sqlitePhysicalColumnType } from './dialects/sqlite.js';
|
|
7
9
|
|
|
8
10
|
const compiledCodecs = new WeakMap();
|
|
9
11
|
const CODECS = new Set(['text', 'integer', 'number', 'boolean', 'json', 'date', 'datetime', 'epoch-ms', 'bigint', 'decimal', 'blob-hex']);
|
|
@@ -19,7 +21,7 @@ export function normalizePhysical(physical, properties, keys, path) {
|
|
|
19
21
|
const fail = (reason) => { throw new DbCompileError('JD0005', reason, `${path}/physical`); };
|
|
20
22
|
if (!physical || typeof physical !== 'object' || Array.isArray(physical)) fail('physical must be an object');
|
|
21
23
|
for (const key of Object.keys(physical))
|
|
22
|
-
if (!['table', 'kind', 'keys', 'columns'].includes(key)) fail(`unknown physical member '${key}'`);
|
|
24
|
+
if (!['table', 'kind', 'keys', 'columns', 'constraints', 'indexes', 'triggers', 'strict', 'withoutRowid'].includes(key)) fail(`unknown physical member '${key}'`);
|
|
23
25
|
if (!identifier(physical.table)) fail('physical.table must be a nonempty SQL identifier');
|
|
24
26
|
if (physical.kind !== undefined && !['table', 'view'].includes(physical.kind)) fail('physical.kind is table or view');
|
|
25
27
|
if (!physical.columns || typeof physical.columns !== 'object' || Array.isArray(physical.columns)) fail('physical.columns is required');
|
|
@@ -28,26 +30,54 @@ export function normalizePhysical(physical, properties, keys, path) {
|
|
|
28
30
|
|| ordered.some((key) => !keys.includes(key))) fail('physical.keys must order every declared key exactly once');
|
|
29
31
|
const used = new Set();
|
|
30
32
|
const columns = [];
|
|
31
|
-
for (const
|
|
33
|
+
for (const name of Object.keys(physical.columns))
|
|
34
|
+
if (!properties.has(name) || properties.get(name).relation) fail(`column '${name}' is not a stored property`);
|
|
35
|
+
for (const [name, property] of properties)
|
|
36
|
+
if (!property.relation && !Object.hasOwn(physical.columns, name)) fail(`'${name}' needs an explicit supported column codec`);
|
|
37
|
+
const definitions = [];
|
|
38
|
+
for (const name of Object.keys(physical.columns)) {
|
|
39
|
+
const property = properties.get(name);
|
|
32
40
|
if (property.relation) continue;
|
|
33
41
|
const c = physical.columns[name];
|
|
34
42
|
if (!c || typeof c !== 'object' || Array.isArray(c) || !CODECS.has(c.codec)) fail(`'${name}' needs an explicit supported column codec`);
|
|
35
43
|
for (const key of Object.keys(c))
|
|
36
|
-
if (!['name', 'codec', 'null', 'default', 'generated'].includes(key)) fail(`unknown column member '${key}'`);
|
|
44
|
+
if (!['name', 'codec', 'null', 'default', 'generated', 'type', 'defaultValue', 'collation', 'identity', 'check', 'generatedExpression', 'stored'].includes(key)) fail(`unknown column member '${key}'`);
|
|
37
45
|
if (!identifier(c.name) || used.has(c.name.toLowerCase())) fail(`'${name}' needs a distinct physical column name`);
|
|
38
46
|
if (!['null', 'absent', 'reject'].includes(c.null)) fail(`'${name}' must declare SQL NULL as null, absent or reject`);
|
|
47
|
+
if (c.type !== undefined && c.type !== sqlitePhysicalColumnType(c.codec)) fail(`'${name}' declares an affinity incompatible with its codec`);
|
|
48
|
+
if (c.stored !== undefined && (typeof c.stored !== 'boolean' || c.generatedExpression === undefined))
|
|
49
|
+
fail(`'${name}' needs an explicit generated expression for stored ownership`);
|
|
39
50
|
if (c.default !== undefined && c.default !== 'database') fail('column default ownership is database');
|
|
40
51
|
if (c.generated !== undefined && typeof c.generated !== 'boolean') fail('generated must be boolean');
|
|
41
52
|
if (property.key && (!['text', 'integer', 'bigint'].includes(c.codec) || c.null !== 'reject')) fail('keys require non-null text, integer or bigint codecs');
|
|
42
53
|
if (property.column !== undefined) fail('physical codecs replace hybrid column overrides');
|
|
43
|
-
if (c.default === 'database' && property.default !== undefined && property.default !== 'auto') fail('a default has exactly one owner');
|
|
54
|
+
if ((c.default === 'database' || Object.hasOwn(c, 'defaultValue')) && property.default !== undefined && property.default !== 'auto') fail('a default has exactly one owner');
|
|
55
|
+
if (c.generatedExpression !== undefined && c.generated === false) fail('a generated expression requires generated ownership');
|
|
44
56
|
used.add(c.name.toLowerCase());
|
|
45
|
-
columns.push({ name, physical: c.name, codec: c.codec, null: c.null, databaseDefault: c.default === 'database',
|
|
46
|
-
generated: c.generated === true, storage: STORAGE[c.codec], source: 'column', key: property.key });
|
|
57
|
+
columns.push({ name, physical: c.name, codec: c.codec, null: c.null, databaseDefault: c.default === 'database' || Object.hasOwn(c, 'defaultValue'),
|
|
58
|
+
generated: c.generated === true || c.generatedExpression !== undefined, storage: STORAGE[c.codec], source: 'column', key: property.key });
|
|
59
|
+
definitions.push({ name: c.name,
|
|
60
|
+
type: c.type ?? sqlitePhysicalColumnType(c.codec),
|
|
61
|
+
nullable: c.null !== 'reject',
|
|
62
|
+
...(Object.hasOwn(c, 'defaultValue') ? { default: c.defaultValue } : {}),
|
|
63
|
+
...(c.identity !== undefined || property.default === 'auto' ? { identity: c.identity ?? 'rowid' } : {}),
|
|
64
|
+
...(c.collation === undefined ? {} : { collation: c.collation }),
|
|
65
|
+
...(c.check === undefined ? {} : { check: c.check }),
|
|
66
|
+
...(c.generatedExpression === undefined ? {} : { generated: c.generatedExpression, stored: c.stored === true }),
|
|
67
|
+
});
|
|
47
68
|
}
|
|
48
69
|
for (const name of Object.keys(physical.columns))
|
|
49
70
|
if (!properties.has(name) || properties.get(name).relation) fail(`column '${name}' is not a stored property`);
|
|
50
|
-
|
|
71
|
+
const definition = { name: physical.table, columns: definitions,
|
|
72
|
+
primaryKey: ordered.map((key) => physical.columns[key].name),
|
|
73
|
+
...Object.fromEntries(['constraints', 'indexes', 'triggers', 'strict', 'withoutRowid']
|
|
74
|
+
.filter((key) => physical[key] !== undefined).map((key) => [key, physical[key]])) };
|
|
75
|
+
if (physical.kind !== 'view') planTable(definition);
|
|
76
|
+
// Database-owned expressions must be explicit before DDL can own them.
|
|
77
|
+
const ddl = physical.kind === 'view' || columns.some((c, i) => (c.generated && definitions[i].generated === undefined)
|
|
78
|
+
|| (c.databaseDefault && !Object.hasOwn(definitions[i], 'default')))
|
|
79
|
+
? null : definition;
|
|
80
|
+
return { table: physical.table, kind: physical.kind ?? 'table', keys: [...ordered], columns, ddl };
|
|
51
81
|
}
|
|
52
82
|
|
|
53
83
|
/** Compile one codec once, with a JSON-safe public value and a bound SQL value.
|
package/src/plan.js
CHANGED
|
@@ -182,6 +182,7 @@ const OPERATOR_REASONS = {
|
|
|
182
182
|
|
|
183
183
|
/** Why an ENTITY document, or one of its clauses, stayed in the engine. */
|
|
184
184
|
const ENTITY_REASONS = {
|
|
185
|
+
scalarAggregate: 'native scalar aggregation requires a scalar physical projection; sums and averages require exact integer accumulation',
|
|
185
186
|
notFlwor: 'only a FLWOR over entity arrays is translated',
|
|
186
187
|
bindingRoot: 'bindings must each range over one declared entity array ($.Entity[*])',
|
|
187
188
|
joinKey: 'every binding past the first needs a column equality to one already joined — '
|
|
@@ -3190,7 +3191,7 @@ export function planEntityPredicate(node, slot, shape) {
|
|
|
3190
3191
|
if (!('ref' in pred) || pred.ref === null) return pred;
|
|
3191
3192
|
const canonical = canonicalOf(pred.ref.segments);
|
|
3192
3193
|
const flavored = shape.entityFlavors.get(canonical);
|
|
3193
|
-
if (shape.columnOnly && (flavored === undefined || flavored.unsafe
|
|
3194
|
+
if (shape.columnOnly && (flavored === undefined || flavored.unsafe)) {
|
|
3194
3195
|
blocked = refusal('physical', PREDICATE_REASONS.physicalCodec);
|
|
3195
3196
|
return pred;
|
|
3196
3197
|
}
|
|
@@ -3246,6 +3247,57 @@ export function planEntityPredicate(node, slot, shape) {
|
|
|
3246
3247
|
* kept in the set residual over the fetched root
|
|
3247
3248
|
* @returns {any}
|
|
3248
3249
|
*/
|
|
3250
|
+
function flattenEntityProjection(root) {
|
|
3251
|
+
const simple = (node) => node.kind === 'literal' || node.kind === 'var'
|
|
3252
|
+
|| (node.kind === 'path' && node.singular)
|
|
3253
|
+
|| (node.kind === 'object' && node.entries.every((e) => simple(e.expr)));
|
|
3254
|
+
const plain = (node) => node?.kind === 'flwor' && node.fold === null
|
|
3255
|
+
&& node.letBindings.length === 0 && node.asChecks === null && node.count === null
|
|
3256
|
+
&& node.groupby === null && node.limits === null;
|
|
3257
|
+
if (!plain(root)) return root;
|
|
3258
|
+
while (root.forBindings.length === 1) {
|
|
3259
|
+
const outer = root.forBindings[0];
|
|
3260
|
+
if (outer.atSlot !== -1 || outer.allowingEmpty || outer.window !== null) break;
|
|
3261
|
+
const inner = flattenEntityProjection(unpacked(outer.expr));
|
|
3262
|
+
if (!plain(inner) || inner.orderby !== null || !simple(inner.ret)
|
|
3263
|
+
|| !['object', 'var'].includes(inner.ret.kind)) break;
|
|
3264
|
+
let blocked = false;
|
|
3265
|
+
const resolve = (projection, segments) => {
|
|
3266
|
+
if (!segments.length) return projection;
|
|
3267
|
+
if (projection.kind === 'object') {
|
|
3268
|
+
const [part, ...rest] = segments;
|
|
3269
|
+
const name = !part.descendant && part.selectors.length === 1 && part.selectors[0].kind === 'name'
|
|
3270
|
+
? part.selectors[0].name : null;
|
|
3271
|
+
const member = projection.entries.find((e) => e.name === name);
|
|
3272
|
+
if (member) return resolve(member.expr, rest);
|
|
3273
|
+
}
|
|
3274
|
+
if (projection.kind === 'var' && !projection.external)
|
|
3275
|
+
return { kind: 'path', card: 2, name: projection.name, rootSlot: projection.slot,
|
|
3276
|
+
external: false, rootCard: 1, segments, singular: true, docPath: projection.docPath };
|
|
3277
|
+
if (projection.kind === 'path' && projection.singular)
|
|
3278
|
+
return { ...projection, segments: [...projection.segments, ...segments] };
|
|
3279
|
+
blocked = true; return projection;
|
|
3280
|
+
};
|
|
3281
|
+
const substitute = (node) => {
|
|
3282
|
+
if (node === null || typeof node !== 'object') return node;
|
|
3283
|
+
if (Array.isArray(node)) return node.map(substitute);
|
|
3284
|
+
if (node.kind === 'literal') return node;
|
|
3285
|
+
if (node.kind === 'var' && !node.external && node.slot === outer.slot) return inner.ret;
|
|
3286
|
+
if (node.kind === 'path' && !node.external && node.rootSlot === outer.slot) {
|
|
3287
|
+
if (!node.singular) { blocked = true; return node; }
|
|
3288
|
+
return resolve(inner.ret, node.segments);
|
|
3289
|
+
}
|
|
3290
|
+
return Object.fromEntries(Object.entries(node).map(([key, value]) => [key, substitute(value)]));
|
|
3291
|
+
};
|
|
3292
|
+
const where = substitute(root.where), ret = substitute(root.ret), orderby = substitute(root.orderby);
|
|
3293
|
+
if (blocked) break;
|
|
3294
|
+
root = { ...root, forBindings: inner.forBindings, ret, orderby,
|
|
3295
|
+
where: inner.where === null ? where : where === null ? inner.where
|
|
3296
|
+
: { kind: 'op', card: 1, name: '$and', args: [inner.where, where], docPath: root.docPath } };
|
|
3297
|
+
}
|
|
3298
|
+
return root;
|
|
3299
|
+
}
|
|
3300
|
+
|
|
3249
3301
|
function planEntityQueryCore(document, entities, mapping, operators) {
|
|
3250
3302
|
const analysis = analyzeQuery(document, analyzeOptionsFor(operators));
|
|
3251
3303
|
let root = analysis.root;
|
|
@@ -3269,13 +3321,14 @@ function planEntityQueryCore(document, entities, mapping, operators) {
|
|
|
3269
3321
|
assertDecidedKind(root);
|
|
3270
3322
|
}
|
|
3271
3323
|
let aggregate = null;
|
|
3272
|
-
if (root.kind === 'op' &&
|
|
3273
|
-
aggregate =
|
|
3324
|
+
if (root.kind === 'op' && ['$count', '$sum', '$avg', '$min', '$max'].includes(root.name) && windows.length === 0) {
|
|
3325
|
+
aggregate = root.name.slice(1);
|
|
3274
3326
|
root = root.args[0];
|
|
3275
3327
|
assertDecidedKind(root);
|
|
3276
3328
|
}
|
|
3277
3329
|
if (root.kind !== 'flwor')
|
|
3278
3330
|
return residual(root.kind, ENTITY_REASONS.notFlwor);
|
|
3331
|
+
root = flattenEntityProjection(root);
|
|
3279
3332
|
if (root.fold !== null || root.letBindings.length > 0 || root.asChecks !== null
|
|
3280
3333
|
|| root.count !== null)
|
|
3281
3334
|
return residual('$let', KIND_REASONS.let);
|
|
@@ -3453,6 +3506,15 @@ function planEntityQueryCore(document, entities, mapping, operators) {
|
|
|
3453
3506
|
if (retBinding === undefined && projection === null && group === null) {
|
|
3454
3507
|
reasons.push(refusal('$return', ENTITY_REASONS.projection));
|
|
3455
3508
|
}
|
|
3509
|
+
let scalarAggregate = null;
|
|
3510
|
+
if (aggregate !== null && aggregate !== 'count') {
|
|
3511
|
+
const leaf = projection?.tree.p === 'leaf' ? projection.leaves[projection.tree.index] : null;
|
|
3512
|
+
if (bindings.length !== 1 || !leaf?.ref || leaf.ref.flavor !== 'entity-column'
|
|
3513
|
+
|| !['text', 'integer', 'number'].includes(leaf.ref.codec)
|
|
3514
|
+
|| (['sum', 'avg'].includes(aggregate) && leaf.ref.type !== 'integer'))
|
|
3515
|
+
return residual(`$${aggregate}`, ENTITY_REASONS.scalarAggregate);
|
|
3516
|
+
scalarAggregate = { fn: aggregate, ref: leaf.ref };
|
|
3517
|
+
}
|
|
3456
3518
|
if (projection?.tree.p === 'leaf' && !projection.leaves[projection.tree.index].count && (aggregate === 'count' || windows.length > 0)) {
|
|
3457
3519
|
const leaf = projection.leaves[projection.tree.index];
|
|
3458
3520
|
const binding = bindings.find((entry) => entry.name === leaf.binding);
|
|
@@ -3546,6 +3608,7 @@ function planEntityQueryCore(document, entities, mapping, operators) {
|
|
|
3546
3608
|
})),
|
|
3547
3609
|
window,
|
|
3548
3610
|
aggregate,
|
|
3611
|
+
...(scalarAggregate === null ? {} : { scalarAggregate }),
|
|
3549
3612
|
group,
|
|
3550
3613
|
ret: retBinding === undefined ? null : retBinding.name,
|
|
3551
3614
|
// the projected shape, when the return is one: leaves that name
|
package/src/query-api.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/** Public query engines without store, jobs, replication or host bindings. */
|
|
3
|
+
export { createQueryEngine, createEntityQueryEngine, createQueryState, createLoadEngine,
|
|
4
|
+
INCLUDE_DEPTH_DEFAULT, INCLUDE_ROWS_DEFAULT, INCLUDE_BYTES_DEFAULT } from './query.js';
|
|
5
|
+
export { collectEntityRoots, entityRoot } from './plan.js';
|
package/src/query.js
CHANGED
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
import { physicalSelection, columnCodec } from './physical.js';
|
|
35
35
|
|
|
36
36
|
import { createSemanticCache } from '@jarenjs/core/cache';
|
|
37
|
-
import { analyzeQuery } from '@jarenjs/json/query';
|
|
37
|
+
import { analyzeQuery, JsonQueryRuntimeError } from '@jarenjs/json/query';
|
|
38
38
|
|
|
39
39
|
import { DbCompileError, DbRuntimeError, wrapDriverError, classifyDriverError } from './errors.js';
|
|
40
40
|
import { chain, attempt, isThenable } from './driver.js';
|
|
@@ -1653,7 +1653,7 @@ export function createEntityQueryEngine(context) {
|
|
|
1653
1653
|
planned = { ...planned, mode: 'set', plan: null,
|
|
1654
1654
|
reasons: [{ construct: 'pushdown', reason: BIND_REASONS.pushdown }] };
|
|
1655
1655
|
}
|
|
1656
|
-
if (planned.plan?.group && dialect.name !== 'sqlite') {
|
|
1656
|
+
if ((planned.plan?.group || planned.plan?.scalarAggregate) && dialect.name !== 'sqlite') {
|
|
1657
1657
|
planned = { ...planned, mode: 'set', plan: null,
|
|
1658
1658
|
reasons: [{ construct: '$groupby', reason: 'entity grouping runtime guards are qualified for SQLite' }] };
|
|
1659
1659
|
}
|
|
@@ -1872,11 +1872,30 @@ export function createEntityQueryEngine(context) {
|
|
|
1872
1872
|
// the database cannot take (a missing external, a boolean, a null,
|
|
1873
1873
|
// a region with no box) sends the call to the residual, where the
|
|
1874
1874
|
// ENGINE raises its own error or answers with its own semantics
|
|
1875
|
-
const
|
|
1876
|
-
if (
|
|
1875
|
+
const diverted = divertReason(entry, externals);
|
|
1876
|
+
if (diverted !== null) {
|
|
1877
|
+
entry.runtimeReason = diverted;
|
|
1878
|
+
if (strict) throw new DbCompileError('JD0010',
|
|
1879
|
+
`strict mode refused a residual: '${diverted.construct}' — ${diverted.reason}`);
|
|
1880
|
+
if (profile?.refuseFullScan) throw profileEntityRefusal(
|
|
1881
|
+
'the profile refuses the decoded scan required by the external binding', '/entities');
|
|
1877
1882
|
return runResidual(entry, document, externals);
|
|
1883
|
+
}
|
|
1884
|
+
const params = entry.slots.map((slot) => slotValue(slot, externals));
|
|
1878
1885
|
if (entry.statement === null) entry.statement = connection.prepare(entry.sql, { readOnly: true });
|
|
1879
1886
|
return chain(guardEntityScan(entry), () => chain(entry.statement, (statement) => {
|
|
1887
|
+
if (entry.planned.plan.scalarAggregate) return chain(statement.get(params), (row) => {
|
|
1888
|
+
admittedRows(entry, row ? [row] : []);
|
|
1889
|
+
if (row?._valid === 0) throw new DbRuntimeError('JD2003', 'an aggregate column refuses a lossy or invalid value');
|
|
1890
|
+
if (row?._nulls > 0) throw new JsonQueryRuntimeError('JQ2001', 'an aggregate requires numbers or strings, got null');
|
|
1891
|
+
if (row?._safe === 0) {
|
|
1892
|
+
entry.runtimeReason = { construct: 'aggregate', reason: 'integer accumulation exceeded its runtime exactness bound' };
|
|
1893
|
+
if (strict) throw new DbCompileError('JD0010', entry.runtimeReason.reason);
|
|
1894
|
+
if (profile?.refuseFullScan) throw profileEntityRefusal('the profile refuses the decoded scan required by integer accumulation', '/entities');
|
|
1895
|
+
return runResidual(entry, document, externals);
|
|
1896
|
+
}
|
|
1897
|
+
return wrapValue(entry, row?.value ?? (entry.planned.plan.aggregate === 'sum' ? 0 : undefined));
|
|
1898
|
+
});
|
|
1880
1899
|
if (entry.planned.plan.aggregate === 'count')
|
|
1881
1900
|
return chain(statement.get(params), (row) => { admittedRows(entry, row ? [row] : []); return wrapValue(entry, row?.value ?? 0); });
|
|
1882
1901
|
return chain(statement.all(params), (rows) => {
|
|
@@ -1928,7 +1947,8 @@ export function createEntityQueryEngine(context) {
|
|
|
1928
1947
|
*/
|
|
1929
1948
|
const divertReason = (entry, externals) => {
|
|
1930
1949
|
for (const slot of entry.slots) {
|
|
1931
|
-
|
|
1950
|
+
const value = slotValue(slot, externals);
|
|
1951
|
+
if (bindable(value) || (slot.nullable === true && value === null)) continue;
|
|
1932
1952
|
const name = 'external' in slot ? slot.external
|
|
1933
1953
|
: 'derived' in slot ? (slot.derived.kind === 'bboxAxis' ? slot.derived.external
|
|
1934
1954
|
: [slot.derived.centre, slot.derived.radius].find((input) => 'external' in input)?.external) : null;
|
|
@@ -1949,6 +1969,10 @@ export function createEntityQueryEngine(context) {
|
|
|
1949
1969
|
*/
|
|
1950
1970
|
const cursorClass = (entry, externals) => {
|
|
1951
1971
|
const buffered = (barrier) => ({ streaming: 'buffered', barrier });
|
|
1972
|
+
if (entry.planned.mode === 'native' && externals !== null) {
|
|
1973
|
+
const diverted = divertReason(entry, externals);
|
|
1974
|
+
if (diverted !== null) return buffered(diverted);
|
|
1975
|
+
}
|
|
1952
1976
|
if (entry.planned.wrapped === true) {
|
|
1953
1977
|
return buffered({ construct: 'window', reason: BIND_REASONS.wrappedWindow });
|
|
1954
1978
|
}
|
|
@@ -1997,11 +2021,20 @@ export function createEntityQueryEngine(context) {
|
|
|
1997
2021
|
+ 'entity binding — read it untracked, or return the binding itself');
|
|
1998
2022
|
}
|
|
1999
2023
|
const each = register === undefined ? (item) => item : (item) => register(retEntity, item);
|
|
2024
|
+
entry.runtimeReason = null;
|
|
2000
2025
|
const classified = cursorClass(entry, externals);
|
|
2026
|
+
if (classified.barrier?.construct === 'external') {
|
|
2027
|
+
entry.admitted = { statements: 0, rows: 0, bytes: 0 };
|
|
2028
|
+
entry.runtimeReason = classified.barrier;
|
|
2029
|
+
if (strict) throw new DbCompileError('JD0010',
|
|
2030
|
+
`strict mode refused a residual: 'external' — ${classified.barrier.reason}`);
|
|
2031
|
+
if (profile?.refuseFullScan) throw profileEntityRefusal(
|
|
2032
|
+
'the profile refuses the decoded scan required by the external binding', '/entities');
|
|
2033
|
+
}
|
|
2001
2034
|
refuseBuffered(options, classified, entities.get(entry.planned.retEntity ?? '')?.docPath);
|
|
2002
2035
|
const signal = options?.signal;
|
|
2003
2036
|
const deadline = options?.deadline;
|
|
2004
|
-
if (entry.planned.wrapped === true || entry.planned.plan?.group) {
|
|
2037
|
+
if (entry.planned.wrapped === true || entry.planned.plan?.group || entry.planned.plan?.scalarAggregate) {
|
|
2005
2038
|
return cursorFactory({ ...classified, signal, deadline, now: state.now, wrap: driverWrap,
|
|
2006
2039
|
materialize: () => chain(execute(document, options), (value) => entry.planned.wrapped === true
|
|
2007
2040
|
? [value] : value === undefined ? [] : Array.isArray(value) ? value : [value]) });
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/** Lightweight column-first SQLite authoring and execution. */
|
|
3
|
+
export { sql, planRelational, relational } from './dialects/sqlite-relational.js';
|
|
4
|
+
export { defineTable, planTable } from './dialects/sqlite-schema.js';
|
|
5
|
+
export { planTableMigration, applyTableMigration, withForeignKeysSuspended } from './table-migration.js';
|
|
6
|
+
export { sqliteDialect } from './dialects/sqlite.js';
|
package/src/store.js
CHANGED
|
@@ -34,7 +34,7 @@ import { refuseUnsupportedPragmaKeys, resolvePragmaRequests, configurePragmas }
|
|
|
34
34
|
import { createMaintenance } from './maintenance.js';
|
|
35
35
|
import { createBackup } from './backup.js';
|
|
36
36
|
import { normalizeProfile, assertProfileRoots } from './profile.js';
|
|
37
|
-
import {
|
|
37
|
+
import { compileEntityModel, joinTableRoots } from './model.js';
|
|
38
38
|
import { entityCore } from './entity.js';
|
|
39
39
|
import { verifyPhysical } from './physical.js';
|
|
40
40
|
import { trustedSql, synchronousBody } from './sql.js';
|
|
@@ -59,7 +59,8 @@ import {
|
|
|
59
59
|
} from './expression.js';
|
|
60
60
|
|
|
61
61
|
/** The model format version this store implements. */
|
|
62
|
-
|
|
62
|
+
import { MODEL_VERSION } from './engine-metadata.js';
|
|
63
|
+
export { MODEL_VERSION };
|
|
63
64
|
|
|
64
65
|
const COLLECTION_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
65
66
|
const IDENTITIES = new Set(['uuid', 'integer']);
|
|
@@ -975,8 +976,9 @@ export function openStore(model, options) {
|
|
|
975
976
|
let mapping;
|
|
976
977
|
try {
|
|
977
978
|
collections = normalizeModel(model, options.expressions);
|
|
978
|
-
|
|
979
|
-
|
|
979
|
+
const compiled = compileEntityModel(model);
|
|
980
|
+
entities = compiled.entities;
|
|
981
|
+
mapping = entities.size > 0 ? compiled.mapping : null;
|
|
980
982
|
if ([...entities.values()].some((e) => e.physical !== null) && (options.capture || options.replication))
|
|
981
983
|
throw new DbCompileError('JD0051', 'column adoption preserves application triggers; complete capture is not qualified');
|
|
982
984
|
if (options.adopt === true && (options.capture || options.replication))
|