@jarenjs/db 0.83.3 → 0.85.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 +20 -3
- package/README.md +12 -1
- package/docs/MODEL-FORMAT.md +14 -4
- package/docs/NATIVE-PLANS.md +24 -7
- package/docs/SQLITE-RELATIONAL.md +190 -0
- package/package.json +20 -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/drivers/bun.js +26 -15
- package/src/drivers/node.js +2 -0
- package/src/drivers/snapshot.js +49 -0
- package/src/emit.js +37 -5
- package/src/engine-metadata.js +18 -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 +9 -2
- package/types/model.d.ts +1 -0
- package/types/node.d.ts +3 -0
- package/types/query.d.ts +2 -0
- package/types/relational.d.ts +114 -0
package/src/emit.js
CHANGED
|
@@ -41,7 +41,7 @@ function compareRefs(pred, read) {
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
/**
|
|
44
|
-
* @typedef {{ external: string } | { literal: unknown } |
|
|
44
|
+
* @typedef {{ external: string, nullable?: boolean } | { literal: unknown } |
|
|
45
45
|
* { derived: { kind: 'bboxAxis', external: string,
|
|
46
46
|
* axis: 'w' | 's' | 'e' | 'n' } } |
|
|
47
47
|
* { derived: { kind: 'circleAxis', centre: { external: string } | { literal: unknown },
|
|
@@ -659,15 +659,20 @@ export function createEntityPredicateEmitters(dialect, param) {
|
|
|
659
659
|
|
|
660
660
|
const emitColumnPred = (aliasSql, pred) => {
|
|
661
661
|
const column = physicalComparable(pred.ref, `${aliasSql}.${q(pred.ref.column)}`, dialect);
|
|
662
|
+
const presentNull = pred.ref.nullPolicy === 'null';
|
|
663
|
+
const present = presentNull ? dialect.booleanLiteral(true) : `${column} IS NOT NULL`;
|
|
662
664
|
if (pred.p === 'typeIs') {
|
|
663
665
|
if (pred.types.length === 0)
|
|
664
|
-
return pred.positive ?
|
|
666
|
+
return pred.positive ? present : presentNull ? dialect.booleanLiteral(false) : `${column} IS NULL`;
|
|
665
667
|
if (pred.types[0] === 'null')
|
|
666
|
-
return pred.positive ? dialect.booleanLiteral(false) : `${column} IS NOT NULL`;
|
|
668
|
+
return pred.positive ? presentNull ? `${column} IS NULL` : dialect.booleanLiteral(false) : `${column} IS NOT NULL`;
|
|
669
|
+
if (pred.ref.storage !== 'boolean')
|
|
670
|
+
return pred.positive ? dialect.booleanLiteral(false) : present;
|
|
667
671
|
const wanted = pred.types[0] === 'true' ? 1 : 0;
|
|
668
672
|
return pred.positive
|
|
669
673
|
? `(${column} IS NOT NULL AND ${column} = ${param({ literal: wanted })})`
|
|
670
|
-
:
|
|
674
|
+
: presentNull ? `${column} IS NOT ${param({ literal: wanted })}`
|
|
675
|
+
: `(${column} IS NOT NULL AND ${column} <> ${param({ literal: wanted })})`;
|
|
671
676
|
}
|
|
672
677
|
if (pred.p === 'strop') {
|
|
673
678
|
const form = stropForm(dialect, param, column, pred);
|
|
@@ -675,6 +680,26 @@ export function createEntityPredicateEmitters(dialect, param) {
|
|
|
675
680
|
}
|
|
676
681
|
const symbol = { eq: '=', ne: '<>', lt: '<', le: '<=', gt: '>', ge: '>=' }[pred.op];
|
|
677
682
|
if ('ext' in pred.operand) {
|
|
683
|
+
if (dialect.name === 'sqlite') {
|
|
684
|
+
// SQL NULL is a present Jaren null only for an explicit null
|
|
685
|
+
// policy. Keep type guards: SQLite affinity must not turn a
|
|
686
|
+
// numeric lookup into a string lookup, or invert inequality.
|
|
687
|
+
const value = () => param({ external: pred.operand.ext, nullable: true });
|
|
688
|
+
const kind = pred.ref.storage === 'string' ? 'text' : 'number';
|
|
689
|
+
const guard = () => kind === 'text'
|
|
690
|
+
? `${dialect.valueTypeOf(value())} = ${sl('text')}`
|
|
691
|
+
: pred.ref.storage === 'boolean' ? dialect.booleanLiteral(false)
|
|
692
|
+
: `${dialect.valueTypeOf(value())} IN ${NUMERIC()}`;
|
|
693
|
+
if (pred.op === 'eq') {
|
|
694
|
+
const nullCase = presentNull ? `(${column} IS NULL AND ${value()} IS NULL) OR ` : '';
|
|
695
|
+
return `(${nullCase}(${column} IS NOT NULL AND ${guard()} AND ${column} = ${value()}))`;
|
|
696
|
+
}
|
|
697
|
+
if (pred.op === 'ne') {
|
|
698
|
+
const nullCase = presentNull ? `(${column} IS NULL AND ${value()} IS NOT NULL) OR ` : '';
|
|
699
|
+
return `(${nullCase}(${column} IS NOT NULL AND (NOT (${guard()}) OR ${column} <> ${value()})))`;
|
|
700
|
+
}
|
|
701
|
+
return `(${column} IS NOT NULL AND ${guard()} AND ${column} ${symbol} ${value()})`;
|
|
702
|
+
}
|
|
678
703
|
const kind = pred.ref.storage === 'string' ? 'text' : 'number';
|
|
679
704
|
const guard = kind === 'text'
|
|
680
705
|
? `${dialect.valueTypeOf(externalSlot(pred.operand.ext))} = ${sl('text')}`
|
|
@@ -688,7 +713,8 @@ export function createEntityPredicateEmitters(dialect, param) {
|
|
|
688
713
|
const storageKind = pred.ref.storage === 'string' ? 'string'
|
|
689
714
|
: pred.ref.storage === 'boolean' ? 'boolean' : 'number';
|
|
690
715
|
if (storageKind === 'boolean' || litKind === 'other' || storageKind !== litKind)
|
|
691
|
-
return pred.op === 'ne' ?
|
|
716
|
+
return pred.op === 'ne' ? present : dialect.booleanLiteral(false);
|
|
717
|
+
if (presentNull && pred.op === 'ne') return `${column} IS NOT ${param({ literal: lit })}`;
|
|
692
718
|
return `(${column} IS NOT NULL AND ${column} ${symbol} ${param({ literal: lit })})`;
|
|
693
719
|
};
|
|
694
720
|
|
|
@@ -875,6 +901,12 @@ export function emitEntityPlan(plan, dialect, physicalOf) {
|
|
|
875
901
|
? `CAST(${groupValue(entry.ref)} AS REAL)` : groupValue(entry.ref))} AS ${q(`a${i}`)}`),
|
|
876
902
|
...group.aggregates.flatMap((entry, i) => numericGroup(entry) ? [`${groupSafe(entry)} AS ${q(`_safe${i}`)}`] : []),
|
|
877
903
|
...(groupRefs.length ? [`${groupRefs.map(groupValid).join(' AND ')} AS ${q('_valid')}`] : [])].join(', ')
|
|
904
|
+
: plan.scalarAggregate
|
|
905
|
+
? `${dialect.groupAggregate(plan.scalarAggregate.fn, plan.scalarAggregate.ref.type === 'string'
|
|
906
|
+
? groupValue(plan.scalarAggregate.ref) : `CAST(${groupValue(plan.scalarAggregate.ref)} AS REAL)`)} AS ${q('value')}, `
|
|
907
|
+
+ `${groupValid(plan.scalarAggregate.ref)} AS ${q('_valid')}, `
|
|
908
|
+
+ `${plan.scalarAggregate.ref.nullPolicy === 'null' ? `COUNT(*) - COUNT(${groupValue(plan.scalarAggregate.ref)})` : '0'} AS ${q('_nulls')}, `
|
|
909
|
+
+ `${numericGroup(plan.scalarAggregate) ? groupSafe(plan.scalarAggregate) : '1'} AS ${q('_safe')}`
|
|
878
910
|
: plan.aggregate === 'count'
|
|
879
911
|
? `COUNT(*) AS ${q('value')}`
|
|
880
912
|
: plan.project != null
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/** Inert metadata: inspecting a schema must not load its runtime owners. */
|
|
3
|
+
import { REPLICATION_TABLES } from './replication-tables.js';
|
|
4
|
+
/** Current model document version. */
|
|
5
|
+
export const MODEL_VERSION = '0.1';
|
|
6
|
+
/** Migration receipt table. */
|
|
7
|
+
export const HISTORY_TABLE = '_jaren_migrations';
|
|
8
|
+
/** Change ledger table. */
|
|
9
|
+
export const CHANGES_TABLE = '_jaren_changes';
|
|
10
|
+
/** Change ledger retention state. */
|
|
11
|
+
export const CHANGES_STATE_TABLE = '_jaren_changes_state';
|
|
12
|
+
/** Durable job queue. */
|
|
13
|
+
export const JOBS_TABLE = '_jaren_jobs';
|
|
14
|
+
/** Durable job checkpoints. */
|
|
15
|
+
export const JOB_CHECKPOINTS_TABLE = '_jaren_job_checkpoints';
|
|
16
|
+
/** Tables excluded from model adoption and schema drift comparisons. */
|
|
17
|
+
export const ENGINE_TABLES = new Set([HISTORY_TABLE, CHANGES_TABLE, CHANGES_STATE_TABLE,
|
|
18
|
+
JOBS_TABLE, JOB_CHECKPOINTS_TABLE, ...Object.values(REPLICATION_TABLES)]);
|
package/src/index.js
CHANGED
|
@@ -66,7 +66,7 @@ export {
|
|
|
66
66
|
applyMandatoryPredicate, applyRowBound,
|
|
67
67
|
} from './profile.js';
|
|
68
68
|
export { translatePatch } from './patch-sql.js';
|
|
69
|
-
export { normalizeEntities, explainMapping, relationTables } from './model.js';
|
|
69
|
+
export { normalizeEntities, explainMapping, compileEntityModel, relationTables } from './model.js';
|
|
70
70
|
export { planEntity, planJoinTable } from './ddl.js';
|
|
71
71
|
export { entityCore } from './entity.js';
|
|
72
72
|
export { entityEmitModel } from './emit-model.js';
|
|
@@ -103,3 +103,6 @@ export { REPLICATION_VERSION, REPLICATION_DEFAULTS, normalizeFrontier,
|
|
|
103
103
|
|
|
104
104
|
export { planInvariants } from './ddl.js';
|
|
105
105
|
export { planPhysicalMigration } from './migrate.js';
|
|
106
|
+
export { sql, planRelational, relational } from './dialects/sqlite-relational.js';
|
|
107
|
+
export { defineTable, planTable } from './dialects/sqlite-schema.js';
|
|
108
|
+
export { planTableMigration, applyTableMigration, withForeignKeysSuspended } from './table-migration.js';
|
package/src/introspect.js
CHANGED
|
@@ -30,8 +30,7 @@
|
|
|
30
30
|
import { chain } from './driver.js';
|
|
31
31
|
import { DbCompileError } from './errors.js';
|
|
32
32
|
import { KEY_COLUMN, DOC_COLUMN } from './ddl.js';
|
|
33
|
-
import { MODEL_VERSION } from './
|
|
34
|
-
import { ENGINE_TABLES } from './migrate.js';
|
|
33
|
+
import { MODEL_VERSION, ENGINE_TABLES } from './engine-metadata.js';
|
|
35
34
|
import { registeredName, expressionMembers } from './expression.js';
|
|
36
35
|
|
|
37
36
|
/**
|
package/src/jobs.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
//@ts-check
|
|
2
|
+
import { JOBS_TABLE, JOB_CHECKPOINTS_TABLE } from './engine-metadata.js';
|
|
3
|
+
export { JOBS_TABLE, JOB_CHECKPOINTS_TABLE };
|
|
2
4
|
/**
|
|
3
5
|
* @file The durable job queue (JOBS-FORMAT): enqueue, the
|
|
4
6
|
* single-statement guarded claim (§3 — one statement is one
|
|
@@ -44,8 +46,8 @@ import { DbCompileError, DbRuntimeError, wrapDriverError } from './errors.js';
|
|
|
44
46
|
import { createCursor, rowClassOf, PAGE_LIMIT_DEFAULT } from './cursor.js';
|
|
45
47
|
import { refuseCancelled } from './cancellation.js';
|
|
46
48
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
+
|
|
50
|
+
|
|
49
51
|
|
|
50
52
|
/** §4 defaults, all overridable per worker. */
|
|
51
53
|
export const JOB_DEFAULTS = Object.freeze({
|
package/src/migrate.js
CHANGED
|
@@ -34,9 +34,8 @@ import { chain, toPromise } from './driver.js';
|
|
|
34
34
|
import { normalizeModel } from './store.js';
|
|
35
35
|
import { planQuery } from './plan.js';
|
|
36
36
|
import { createQueryEngine, createQueryState } from './query.js';
|
|
37
|
-
import {
|
|
38
|
-
|
|
39
|
-
import { REPLICATION_TABLES } from './replication-tables.js';
|
|
37
|
+
import { HISTORY_TABLE, ENGINE_TABLES } from './engine-metadata.js';
|
|
38
|
+
export { HISTORY_TABLE, ENGINE_TABLES };
|
|
40
39
|
import { planCollection, verifyShape, planEntity, planJoinTable } from './ddl.js';
|
|
41
40
|
import { normalizeEntities, explainMapping } from './model.js';
|
|
42
41
|
import { derivedValue, memberAt, registerDeriveFunctions } from './derive.js';
|
|
@@ -74,17 +73,6 @@ function mappingFor(connection, expressions = undefined) {
|
|
|
74
73
|
};
|
|
75
74
|
}
|
|
76
75
|
|
|
77
|
-
/** The history table name (outside the model's identifier namespace
|
|
78
|
-
* conventions on purpose — a collection cannot collide with it). */
|
|
79
|
-
export const HISTORY_TABLE = '_jaren_migrations';
|
|
80
|
-
/** The tables the engine owns beside a model's: never a shape-drift finding. */
|
|
81
|
-
/** The tables this package owns. A model never declared one, so one
|
|
82
|
-
* found in a database is the engine's own bookkeeping rather than
|
|
83
|
-
* anybody's drift — the drift check skips them and the introspector
|
|
84
|
-
* does not derive them. */
|
|
85
|
-
export const ENGINE_TABLES = new Set([HISTORY_TABLE, CHANGES_TABLE, CHANGES_STATE_TABLE,
|
|
86
|
-
JOBS_TABLE, JOB_CHECKPOINTS_TABLE, ...Object.values(REPLICATION_TABLES)]);
|
|
87
|
-
|
|
88
76
|
/**
|
|
89
77
|
* The signature-grade identity of a model SHAPE.
|
|
90
78
|
* @param {any} model - A jaren-model document
|
|
@@ -198,8 +186,16 @@ export function planMigration(fromModel, toModel, options = undefined) {
|
|
|
198
186
|
const dialect = options?.dialect ?? null;
|
|
199
187
|
if (dialect === null || typeof dialect !== 'object')
|
|
200
188
|
throw new TypeError('planMigration needs { dialect } (the store dialect renders the DDL)');
|
|
201
|
-
if ([fromModel, toModel].some((m) => Object.values(m.entities ?? {}).some((e) => e.physical !== undefined)))
|
|
202
|
-
|
|
189
|
+
if ([fromModel, toModel].some((m) => Object.values(m.entities ?? {}).some((e) => e.physical !== undefined))) {
|
|
190
|
+
normalizeEntities(fromModel);
|
|
191
|
+
normalizeEntities(toModel);
|
|
192
|
+
if (canonicalizeJson(fromModel) !== canonicalizeJson(toModel))
|
|
193
|
+
throw refuse('JD0021', 'changed column layouts require planTableMigration on the open connection or planPhysicalMigration with explicit preservation dispositions');
|
|
194
|
+
return { migration: { $migration: MIGRATION_VERSION,
|
|
195
|
+
id: options?.id ?? `to-${shapeHash(toModel).slice(0, 8)}`,
|
|
196
|
+
from: shapeHash(fromModel), to: shapeHash(toModel), steps: [] },
|
|
197
|
+
report: { renamed: [], added: [], removed: [], schemaChanged: [], drafts: [], destructive: false } };
|
|
198
|
+
}
|
|
203
199
|
const mapping = { derived: options?.derived ?? 'virtual', rtree: options?.rtree !== false,
|
|
204
200
|
// a model that declares an index EXPRESSION resolves its functions
|
|
205
201
|
// here too: a plan is DDL, and DDL over a function this planner was
|
package/src/model-api.js
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/** Model metadata and read-only schema inspection without opening a store. */
|
|
3
|
+
export { normalizeEntities, explainMapping, compileEntityModel, relationTables } from './model.js';
|
|
4
|
+
export { readSchema, introspectModel, INTROSPECT_CODES } from './introspect.js';
|
package/src/model.js
CHANGED
|
@@ -557,7 +557,19 @@ export function relationTables(entities) {
|
|
|
557
557
|
* @returns {any}
|
|
558
558
|
*/
|
|
559
559
|
export function explainMapping(model) {
|
|
560
|
+
return mappingOf(model, normalizeEntities(model));
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/** Normalize and map once for lightweight engines sharing model metadata.
|
|
564
|
+
* No process-global cache retains the model or any connection.
|
|
565
|
+
* @param {any} model @returns {{entities:Map<string,any>,mapping:any}} */
|
|
566
|
+
export function compileEntityModel(model) {
|
|
560
567
|
const entities = normalizeEntities(model);
|
|
568
|
+
return { entities, mapping: mappingOf(model, entities) };
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/** @param {any} model @param {Map<string,any>} entities @returns {any} */
|
|
572
|
+
function mappingOf(model, entities) {
|
|
561
573
|
/** @type {any} */
|
|
562
574
|
const mapping = { entities: {}, joinTables: {} };
|
|
563
575
|
|
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';
|