@jarenjs/db 0.73.0 → 0.83.2
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 +70 -7
- package/README.md +69 -6
- package/docs/HOSTS.md +17 -0
- package/docs/JOBS-FORMAT.md +26 -0
- package/docs/LIVE-FORMAT.md +52 -13
- package/docs/MIGRATION-FORMAT.md +34 -0
- package/docs/MODEL-FORMAT.md +163 -15
- package/docs/NATIVE-PLANS.md +111 -0
- package/docs/REPLICATION-FORMAT.md +19 -13
- package/docs/SEARCH.md +55 -0
- package/package.json +8 -4
- package/schemas/jaren-migration.draft-07.schema.json +54 -5
- package/schemas/jaren-migration.schema.json +49 -0
- package/schemas/jaren-model.authoring.schema.json +360 -0
- package/schemas/jaren-model.draft-07.schema.json +128 -0
- package/schemas/jaren-model.schema.json +128 -0
- package/src/algebra.js +26 -4
- package/src/backup.js +12 -7
- package/src/cursor.js +27 -4
- package/src/dag-job.js +2 -1
- package/src/ddl.js +13 -0
- package/src/derive.js +14 -3
- package/src/dialect.js +12 -0
- package/src/dialects/check-read.js +151 -0
- package/src/dialects/invariant-sql.js +117 -0
- package/src/dialects/postgres.js +28 -4
- package/src/dialects/sqlite.js +23 -3
- package/src/driver.js +1 -0
- package/src/drivers/bun.js +22 -4
- package/src/emit.js +133 -25
- package/src/entity.js +98 -41
- package/src/errors.js +8 -0
- package/src/graph.js +8 -1
- package/src/index.js +3 -0
- package/src/introspect.js +81 -12
- package/src/invariants.js +45 -0
- package/src/jobs.js +39 -6
- package/src/live-nested.js +27 -10
- package/src/live.js +51 -136
- package/src/migrate.js +136 -22
- package/src/model.js +12 -0
- package/src/mutation.js +165 -0
- package/src/physical.js +147 -0
- package/src/plan.js +275 -64
- package/src/query.js +175 -78
- package/src/search.js +144 -0
- package/src/sql.js +60 -0
- package/src/store.js +49 -13
- package/src/tracker.js +63 -39
- package/src/window.js +1 -0
- package/types/index.d.ts +59 -4
- package/types/search.d.ts +20 -0
- package/types/typed.d.ts +1 -0
package/src/drivers/bun.js
CHANGED
|
@@ -27,7 +27,7 @@ import { PRAGMA_NAMES } from '../pragmas.js';
|
|
|
27
27
|
* with its shape) into a probed connection. Exported so the adapter is
|
|
28
28
|
* exercisable without the builtin.
|
|
29
29
|
* @param {any} db - A Bun `Database`-shaped database
|
|
30
|
-
* @param {{ queueTimeout?: number }} [options]
|
|
30
|
+
* @param {{ queueTimeout?: number, backup?: any }} [options]
|
|
31
31
|
* @returns {any} a Connection, or a promise of one
|
|
32
32
|
*/
|
|
33
33
|
export function adaptBunDatabase(db, options) {
|
|
@@ -38,6 +38,7 @@ export function adaptBunDatabase(db, options) {
|
|
|
38
38
|
const statements = new Set();
|
|
39
39
|
const collected = new FinalizationRegistry((ref) => statements.delete(ref));
|
|
40
40
|
const raw = {
|
|
41
|
+
...(options?.backup ? { backup: options.backup } : {}),
|
|
41
42
|
/** @param {string} sql */
|
|
42
43
|
exec: (sql) => db.run(sql),
|
|
43
44
|
/** @param {string} sql */
|
|
@@ -91,6 +92,7 @@ export function adaptBunDatabase(db, options) {
|
|
|
91
92
|
synchronous: true,
|
|
92
93
|
queueTimeout: options?.queueTimeout,
|
|
93
94
|
declared: {
|
|
95
|
+
backup: options?.backup !== undefined,
|
|
94
96
|
sessions: false,
|
|
95
97
|
userFunctions: false,
|
|
96
98
|
deterministicIndexableFunctions: false,
|
|
@@ -112,9 +114,25 @@ export function adaptBunDatabase(db, options) {
|
|
|
112
114
|
* @returns {any}
|
|
113
115
|
*/
|
|
114
116
|
export function fromBunModule(mod, path, options) {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
:
|
|
117
|
+
const db = options?.readOnly === true ? new mod.Database(path, { readonly: true }) : new mod.Database(path);
|
|
118
|
+
const backup = typeof db.serialize !== 'function' ? undefined : {
|
|
119
|
+
snapshot: true,
|
|
120
|
+
copy: async (target, copyOptions) => {
|
|
121
|
+
const bytes = db.serialize();
|
|
122
|
+
const pageSize = ((bytes[16] << 8) | bytes[17]) || 65536;
|
|
123
|
+
const pages = bytes.length / (pageSize === 1 ? 65536 : pageSize);
|
|
124
|
+
copyOptions?.progress?.({ totalPages: pages, remainingPages: pages });
|
|
125
|
+
const fs = await import('node:fs/promises');
|
|
126
|
+
const file = await fs.open(target, 'wx');
|
|
127
|
+
try { await file.writeFile(bytes); await file.sync(); }
|
|
128
|
+
finally { await file.close(); }
|
|
129
|
+
copyOptions?.progress?.({ totalPages: pages, remainingPages: 0 });
|
|
130
|
+
return pages;
|
|
131
|
+
},
|
|
132
|
+
rename: (from, to) => import('node:fs/promises').then((fs) => fs.rename(from, to)),
|
|
133
|
+
remove: (target) => import('node:fs/promises').then((fs) => fs.rm(target, { force: true })),
|
|
134
|
+
};
|
|
135
|
+
return adaptBunDatabase(db, { ...options, ...(backup ? { backup } : {}) });
|
|
118
136
|
}
|
|
119
137
|
|
|
120
138
|
/**
|
package/src/emit.js
CHANGED
|
@@ -17,6 +17,11 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { codePointPrefixSuccessor } from '@jarenjs/core/string';
|
|
20
|
+
import { physicalSelection } from './physical.js';
|
|
21
|
+
|
|
22
|
+
/** Physical text expressions must not inherit an application's collation. */
|
|
23
|
+
const physicalComparable = (ref, sql, dialect) => ref.codec !== undefined
|
|
24
|
+
&& ['text', 'date', 'datetime'].includes(ref.codec) ? dialect.codepoint(sql) : sql;
|
|
20
25
|
|
|
21
26
|
/**
|
|
22
27
|
* A promoted path the dialect's JSON path grammar cannot spell (a
|
|
@@ -26,10 +31,21 @@ import { codePointPrefixSuccessor } from '@jarenjs/core/string';
|
|
|
26
31
|
*/
|
|
27
32
|
export class UnrepresentablePath extends Error {}
|
|
28
33
|
|
|
34
|
+
/** A comparison of schema-compatible paths is false if either is absent.
|
|
35
|
+
* @param {any} pred @param {(ref: any) => { value: string, present: string }} read */
|
|
36
|
+
function compareRefs(pred, read) {
|
|
37
|
+
const left = read(pred.left);
|
|
38
|
+
const right = read(pred.right);
|
|
39
|
+
const symbol = { eq: '=', ne: '<>', lt: '<', le: '<=', gt: '>', ge: '>=' }[pred.op];
|
|
40
|
+
return `(${left.present} AND ${right.present} AND ${left.value} ${symbol} ${right.value})`;
|
|
41
|
+
}
|
|
42
|
+
|
|
29
43
|
/**
|
|
30
44
|
* @typedef {{ external: string } | { literal: unknown } |
|
|
31
45
|
* { derived: { kind: 'bboxAxis', external: string,
|
|
32
46
|
* axis: 'w' | 's' | 'e' | 'n' } } |
|
|
47
|
+
* { derived: { kind: 'circleAxis', centre: { external: string } | { literal: unknown },
|
|
48
|
+
* radius: { external: string } | { literal: unknown }, axis: 'w' | 's' | 'e' | 'n' } } |
|
|
33
49
|
* { typed: { seek: string, type: 'number' | 'text' } }} ParamSlot
|
|
34
50
|
* Four kinds, closed. A DERIVED slot is the escape for a value SQL
|
|
35
51
|
* cannot bind at all: a GeoJSON region arrives as an external object,
|
|
@@ -85,7 +101,7 @@ function stropForm(dialect, param, valueSql, pred) {
|
|
|
85
101
|
*/
|
|
86
102
|
function slotName(slot) {
|
|
87
103
|
if ('external' in slot) return slot.external;
|
|
88
|
-
if ('derived' in slot) return slot.derived.external;
|
|
104
|
+
if ('derived' in slot) return slot.derived.kind === 'bboxAxis' ? slot.derived.external : 'circle';
|
|
89
105
|
if ('typed' in slot) return slot.typed.seek;
|
|
90
106
|
return 'value';
|
|
91
107
|
}
|
|
@@ -107,6 +123,8 @@ const BOX_AT = { w: 0, s: 1, e: 2, n: 3 };
|
|
|
107
123
|
* @returns {string}
|
|
108
124
|
*/
|
|
109
125
|
function probeEdge(probe, param, axis) {
|
|
126
|
+
if ('circle' in probe)
|
|
127
|
+
return param({ derived: { kind: 'circleAxis', ...probe.circle, axis } });
|
|
110
128
|
return 'box' in probe
|
|
111
129
|
? param({ literal: probe.box[BOX_AT[axis]] })
|
|
112
130
|
: param({ derived: { kind: 'bboxAxis', external: probe.ext, axis } });
|
|
@@ -120,6 +138,11 @@ function probeEdge(probe, param, axis) {
|
|
|
120
138
|
* @returns {{ sql: string, slots: ParamSlot[] }}
|
|
121
139
|
*/
|
|
122
140
|
export function emitPlan(plan, dialect, physical) {
|
|
141
|
+
if (plan.group !== null && plan.aggregate?.fn === 'count') {
|
|
142
|
+
const inner = emitPlan({ ...plan, aggregate: null }, dialect, physical);
|
|
143
|
+
return { ...inner, sql: `SELECT COUNT(*) AS ${dialect.quoteIdentifier('value')} `
|
|
144
|
+
+ `FROM (${inner.sql}) AS ${dialect.quoteIdentifier('_groups')}` };
|
|
145
|
+
}
|
|
123
146
|
const q = dialect.quoteIdentifier;
|
|
124
147
|
const docColumn = q(physical.docColumn);
|
|
125
148
|
/** @type {ParamSlot[]} */
|
|
@@ -297,6 +320,9 @@ export function emitPlan(plan, dialect, physical) {
|
|
|
297
320
|
*/
|
|
298
321
|
const emitPred = (pred) => {
|
|
299
322
|
switch (pred.p) {
|
|
323
|
+
case 'refCmp':
|
|
324
|
+
return compareRefs(pred, (ref) => ({ value: valueOf(ref, kindOf(ref)),
|
|
325
|
+
present: `${typeOf(ref)} IS NOT NULL` }));
|
|
300
326
|
case 'and':
|
|
301
327
|
return `(${pred.items.map(emitPred).join(' AND ')})`;
|
|
302
328
|
case 'or':
|
|
@@ -439,9 +465,12 @@ export function emitPlan(plan, dialect, physical) {
|
|
|
439
465
|
+ `${q(plan.rank.alternatives[0].column)} AS ${q('vec')}`
|
|
440
466
|
: plan.group !== null
|
|
441
467
|
? [...plan.group.keys.map((key, i) => projectedPair(key.ref, `k${i}`)),
|
|
442
|
-
...plan.group.aggregates.map((entry, i) =>
|
|
443
|
-
|
|
444
|
-
|
|
468
|
+
...plan.group.aggregates.map((entry, i) => {
|
|
469
|
+
const value = dialect.groupAggregate(entry.fn, foldValue(entry.fn, entry.ref));
|
|
470
|
+
// Empty sums order as zero, the same value reconstruction
|
|
471
|
+
// returns. Naming that value also works in PostgreSQL ORDER BY.
|
|
472
|
+
return `${entry.empty === 'zero' ? `COALESCE(${value}, 0)` : value} AS ${q(`a${i}`)}`;
|
|
473
|
+
})].join(', ')
|
|
445
474
|
: plan.bucket !== null
|
|
446
475
|
? [`${bucketSql} AS ${q(plan.bucket.as)}`,
|
|
447
476
|
...plan.bucket.aggregates.map((entry) =>
|
|
@@ -461,7 +490,8 @@ export function emitPlan(plan, dialect, physical) {
|
|
|
461
490
|
// a projection TREE: the same value/type pair per DISTINCT
|
|
462
491
|
// leaf, numbered, and nothing else — the document blob is
|
|
463
492
|
// never selected, and a leaf named twice is fetched once
|
|
464
|
-
: plan.project.leaves.map((ref, i) => projectedPair(ref, String(i))).join(', ')
|
|
493
|
+
: (plan.project.leaves.map((ref, i) => projectedPair(ref, String(i))).join(', ')
|
|
494
|
+
|| `1 AS ${q('_row')}`))
|
|
465
495
|
: plan.aggregate.fn === 'count'
|
|
466
496
|
? `COUNT(*) AS ${q('value')}`
|
|
467
497
|
// a REGISTERED aggregate calls the function the store
|
|
@@ -486,10 +516,11 @@ export function emitPlan(plan, dialect, physical) {
|
|
|
486
516
|
// the groups' order: the engine's own order of first appearance —
|
|
487
517
|
// over a collection, each group's earliest row identity — or the
|
|
488
518
|
// key ordering an `$orderby` declared
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
519
|
+
const order = plan.group.order === 'first-seen' ? []
|
|
520
|
+
: plan.group.order.map((term) => `${q(term.aggregate === undefined ? `vk${term.index}` : `a${term.aggregate}`)} `
|
|
521
|
+
+ `${term.desc ? 'DESC' : 'ASC'}${dialect.orderNulls(term.nullsFirst)}`);
|
|
522
|
+
order.push(dialect.groupAggregate('min', dialect.rowIdentity()));
|
|
523
|
+
sql += ` ORDER BY ${order.join(', ')}`;
|
|
493
524
|
}
|
|
494
525
|
if (plan.bucket !== null) {
|
|
495
526
|
// `first-seen` is the engine's own group order (§6.5, first
|
|
@@ -518,7 +549,7 @@ export function emitPlan(plan, dialect, physical) {
|
|
|
518
549
|
terms.push(dialect.rowIdentity());
|
|
519
550
|
sql += ` ORDER BY ${terms.join(', ')}`;
|
|
520
551
|
}
|
|
521
|
-
if (plan.window !== null && plan.aggregate === null
|
|
552
|
+
if (plan.window !== null && plan.aggregate === null) {
|
|
522
553
|
sql += ` ${dialect.limitClause(plan.window.limit, plan.window.offset)}`;
|
|
523
554
|
}
|
|
524
555
|
return { sql, slots, seeks: (plan.seeks ?? []).map((seek) => emitSeek(seek)) };
|
|
@@ -627,7 +658,7 @@ export function createEntityPredicateEmitters(dialect, param) {
|
|
|
627
658
|
};
|
|
628
659
|
|
|
629
660
|
const emitColumnPred = (aliasSql, pred) => {
|
|
630
|
-
const column = `${aliasSql}.${q(pred.ref.column)}
|
|
661
|
+
const column = physicalComparable(pred.ref, `${aliasSql}.${q(pred.ref.column)}`, dialect);
|
|
631
662
|
if (pred.p === 'typeIs') {
|
|
632
663
|
if (pred.types.length === 0)
|
|
633
664
|
return pred.positive ? `${column} IS NOT NULL` : `${column} IS NULL`;
|
|
@@ -682,6 +713,15 @@ export function createEntityPredicateEmitters(dialect, param) {
|
|
|
682
713
|
};
|
|
683
714
|
|
|
684
715
|
const emitPred = (aliasSql, docSql, pred) => {
|
|
716
|
+
if (pred.p === 'refCmp') return compareRefs(pred, (ref) => {
|
|
717
|
+
if (ref.flavor === 'entity-column') {
|
|
718
|
+
const value = physicalComparable(ref, `${aliasSql}.${q(ref.column)}`, dialect);
|
|
719
|
+
return { value, present: `${value} IS NOT NULL` };
|
|
720
|
+
}
|
|
721
|
+
// Epoch columns retain the document's lexical comparison rule.
|
|
722
|
+
return { value: memberAt(docSql, ref, ref.type === 'string' ? 'text' : 'number'),
|
|
723
|
+
present: `${dialect.jsonTypeOf(docSql, pathTextOf(ref))} IS NOT NULL` };
|
|
724
|
+
});
|
|
685
725
|
if (pred.p === 'and')
|
|
686
726
|
return `(${pred.items.map((item) => emitPred(aliasSql, docSql, item)).join(' AND ')})`;
|
|
687
727
|
if (pred.p === 'or')
|
|
@@ -745,8 +785,13 @@ export function emitEntityPlan(plan, dialect, physicalOf) {
|
|
|
745
785
|
|
|
746
786
|
const entityOf = new Map(plan.bindings.map((binding) => [binding.name, binding.entity]));
|
|
747
787
|
const emitters = createEntityPredicateEmitters(dialect, param);
|
|
748
|
-
const emitPred = (bindingName, pred) =>
|
|
749
|
-
|
|
788
|
+
const emitPred = (bindingName, pred) => {
|
|
789
|
+
if (pred.p === 'binding') return emitPred(pred.binding, pred.filter);
|
|
790
|
+
if (pred.p === 'and' || pred.p === 'or') return `(${pred.items
|
|
791
|
+
.map((item) => emitPred(bindingName, item)).join(pred.p === 'and' ? ' AND ' : ' OR ')})`;
|
|
792
|
+
if (pred.p === 'not') return `NOT (${emitPred(bindingName, pred.item)})`;
|
|
793
|
+
return emitters.emitPred(aliasOf(bindingName), docOf(bindingName), pred);
|
|
794
|
+
};
|
|
750
795
|
|
|
751
796
|
/**
|
|
752
797
|
* One projected member of a binding: its value beside its JSON type,
|
|
@@ -764,12 +809,27 @@ export function emitEntityPlan(plan, dialect, physicalOf) {
|
|
|
764
809
|
const projectedPair = (leaf, suffix) => {
|
|
765
810
|
const names = `${q(`v${suffix}`)}`;
|
|
766
811
|
const typeName = `${q(`t${suffix}`)}`;
|
|
812
|
+
if (leaf.count) {
|
|
813
|
+
const count = leaf.count;
|
|
814
|
+
const alias = q(`c${suffix}`);
|
|
815
|
+
const value = (side) => physicalComparable(side.ref,
|
|
816
|
+
`${side.binding === count.binding ? alias : aliasOf(side.binding)}.${q(side.ref.column)}`, dialect);
|
|
817
|
+
const predicates = count.edges.map((edge) => `${value(edge.left)} = ${value(edge.right)}`);
|
|
818
|
+
if (count.filter) predicates.push(emitters.emitPred(alias, `${alias}.${q('doc')}`, count.filter));
|
|
819
|
+
return `(SELECT COUNT(*) FROM ${q(physicalOf(count.entity).table)} AS ${alias} `
|
|
820
|
+
+ `WHERE ${predicates.join(' AND ')}) AS ${names}, ${sl('integer')} AS ${typeName}`;
|
|
821
|
+
}
|
|
767
822
|
if (leaf.ref.flavor === 'entity-column') {
|
|
768
|
-
const
|
|
823
|
+
const raw = `${aliasOf(leaf.binding)}.${q(leaf.ref.column)}`;
|
|
824
|
+
const column = physicalComparable(leaf.ref,
|
|
825
|
+
leaf.ref.codec === 'integer'
|
|
826
|
+
? `CASE WHEN ${dialect.valueTypeOf(raw)} = ${sl('integer')} THEN CAST(${raw} AS REAL) ELSE ${raw} END`
|
|
827
|
+
: raw, dialect);
|
|
828
|
+
const emptyType = leaf.ref.nullPolicy === 'null' ? sl('null') : 'NULL';
|
|
769
829
|
const type = leaf.ref.storage === 'boolean'
|
|
770
|
-
? `CASE WHEN ${column} IS NULL THEN
|
|
830
|
+
? `CASE WHEN ${column} IS NULL THEN ${emptyType} WHEN ${column} = 0 `
|
|
771
831
|
+ `THEN ${sl('false')} ELSE ${sl('true')} END`
|
|
772
|
-
: `CASE WHEN ${column} IS NULL THEN
|
|
832
|
+
: `CASE WHEN ${column} IS NULL THEN ${emptyType} ELSE ${sl(
|
|
773
833
|
leaf.ref.storage === 'string' ? 'text'
|
|
774
834
|
: leaf.ref.storage === 'integer'
|
|
775
835
|
? dialect.numericTypeNames[0]
|
|
@@ -789,24 +849,54 @@ export function emitEntityPlan(plan, dialect, physicalOf) {
|
|
|
789
849
|
// every returned column plus the document rendered to text; the
|
|
790
850
|
// caller merges them back into the entity shape — or, for a projected
|
|
791
851
|
// shape, one value/type pair per DISTINCT leaf and no document at all
|
|
792
|
-
const
|
|
852
|
+
const group = plan.group;
|
|
853
|
+
const groupValue = (ref) => physicalComparable(ref,
|
|
854
|
+
`${aliasOf(plan.bindings[0].name)}.${q(ref.column)}`, dialect);
|
|
855
|
+
// Every integer addition is exact when the largest magnitude times
|
|
856
|
+
// the number of terms fits in the safe range. Sum as REAL so even a
|
|
857
|
+
// rejected group cannot overflow SQLite's integer accumulator first.
|
|
858
|
+
const numericGroup = (entry) => ['sum', 'avg'].includes(entry.fn);
|
|
859
|
+
const groupSafe = (entry) => `(COUNT(${groupValue(entry.ref)}) = 0 OR `
|
|
860
|
+
+ `MAX(ABS(CAST(${groupValue(entry.ref)} AS REAL))) <= 9007199254740991 / COUNT(${groupValue(entry.ref)}))`;
|
|
861
|
+
const groupValid = (ref) => {
|
|
862
|
+
const value = groupValue(ref);
|
|
863
|
+
const type = dialect.valueTypeOf(value);
|
|
864
|
+
const valid = ref.codec === 'text' ? `${type} = ${sl('text')}`
|
|
865
|
+
: ref.codec === 'boolean' ? `${type} = ${sl('integer')} AND ${value} IN (0, 1)`
|
|
866
|
+
: `${type} ${ref.codec === 'integer' ? `= ${sl('integer')}` : `IN (${sl('integer')}, ${sl('real')})`}`
|
|
867
|
+
+ ` AND ABS(CAST(${value} AS REAL)) <= 9007199254740991`;
|
|
868
|
+
return `MIN(CASE WHEN ${ref.nullPolicy === 'reject' ? '' : `${value} IS NULL OR `}(${valid}) THEN 1 ELSE 0 END)`;
|
|
869
|
+
};
|
|
870
|
+
const groupRefs = group ? [...group.keys, ...group.aggregates].map((entry) => entry.ref).filter((ref) => ref?.codecColumn) : [];
|
|
871
|
+
const selection = group
|
|
872
|
+
? [...group.keys.map((key, i) => projectedPair({ binding: plan.bindings[0].name, ref: key.ref }, `k${i}`)),
|
|
873
|
+
...group.aggregates.map((entry, i) => `${entry.fn === 'rows' ? 'COUNT(*)'
|
|
874
|
+
: dialect.groupAggregate(entry.fn, numericGroup(entry) || entry.ref.codec === 'integer'
|
|
875
|
+
? `CAST(${groupValue(entry.ref)} AS REAL)` : groupValue(entry.ref))} AS ${q(`a${i}`)}`),
|
|
876
|
+
...group.aggregates.flatMap((entry, i) => numericGroup(entry) ? [`${groupSafe(entry)} AS ${q(`_safe${i}`)}`] : []),
|
|
877
|
+
...(groupRefs.length ? [`${groupRefs.map(groupValid).join(' AND ')} AS ${q('_valid')}`] : [])].join(', ')
|
|
878
|
+
: plan.aggregate === 'count'
|
|
793
879
|
? `COUNT(*) AS ${q('value')}`
|
|
794
880
|
: plan.project != null
|
|
795
881
|
// `p`-prefixed, because a bare `t0` would collide with this
|
|
796
882
|
// plan's own binding aliases
|
|
797
|
-
? plan.project.leaves.map((leaf, i) => projectedPair(leaf, `p${i}`)).join(', ')
|
|
883
|
+
? (plan.project.leaves.map((leaf, i) => projectedPair(leaf, `p${i}`)).join(', ')
|
|
884
|
+
|| `1 AS ${q('_row')}`)
|
|
798
885
|
// a join-table root IS its two key columns: it has no document
|
|
799
886
|
// column, so the merge is handed an empty one
|
|
800
887
|
: physicalOf(entityOf.get(ret)).document === false
|
|
801
|
-
?
|
|
888
|
+
? physicalOf(entityOf.get(ret)).mapping
|
|
889
|
+
? physicalSelection(physicalOf(entityOf.get(ret)).mapping, dialect, `${aliasOf(ret)}.`)
|
|
890
|
+
: `${aliasOf(ret)}.*, ${sl('{}')} AS ${q('__doc')}`
|
|
802
891
|
: `${aliasOf(ret)}.*, ${dialect.jsonText(docOf(ret))} AS ${q('__doc')}`;
|
|
803
892
|
|
|
804
893
|
const tableOf = (name) =>
|
|
805
894
|
`${q(physicalOf(entityOf.get(name)).table)} AS ${aliasOf(name)}`;
|
|
806
895
|
const JOIN_OPS = { eq: '=', ne: '<>', lt: '<', le: '<=', gt: '>', ge: '>=' };
|
|
807
896
|
const onSql = (edge) =>
|
|
808
|
-
`${aliasOf(edge.left.binding)}.${q(edge.left.column)}
|
|
809
|
-
+ ` ${JOIN_OPS[edge.op ?? 'eq']}
|
|
897
|
+
physicalComparable(edge.left, `${aliasOf(edge.left.binding)}.${q(edge.left.column)}`, dialect)
|
|
898
|
+
+ ` ${JOIN_OPS[edge.op ?? 'eq']} `
|
|
899
|
+
+ physicalComparable(edge.right, `${aliasOf(edge.right.binding)}.${q(edge.right.column)}`, dialect);
|
|
810
900
|
|
|
811
901
|
let sql = `SELECT ${selection} FROM `;
|
|
812
902
|
// the JOIN order the planner settled: the first binding, then each
|
|
@@ -822,21 +912,39 @@ export function emitEntityPlan(plan, dialect, physicalOf) {
|
|
|
822
912
|
.map((entry) => emitPred(entry.binding, entry.filter));
|
|
823
913
|
if (filterSql.length > 0) sql += ` WHERE ${filterSql.join(' AND ')}`;
|
|
824
914
|
|
|
825
|
-
if (
|
|
915
|
+
if (group) {
|
|
916
|
+
sql += ` GROUP BY ${group.keys.map((key, i) => `${q(`vk${i}`)}, ${q(`tk${i}`)}`).join(', ')}`;
|
|
917
|
+
const terms = group.order === 'first-seen' ? [] : group.order.map((term) =>
|
|
918
|
+
`${q(term.aggregate === undefined ? `vk${term.index}` : `a${term.aggregate}`)} ${term.desc ? 'DESC' : 'ASC'}${dialect.orderNulls(term.nullsFirst)}`);
|
|
919
|
+
const physical = physicalOf(plan.bindings[0].entity);
|
|
920
|
+
for (const key of physical.keys ?? [null]) {
|
|
921
|
+
const value = `${aliasOf(plan.bindings[0].name)}.${key === null ? dialect.rowIdentity() : q(key)}`;
|
|
922
|
+
const column = physical.mapping?.columns.find((c) => c.physical === key);
|
|
923
|
+
terms.push(`MIN(${column ? physicalComparable(column, value, dialect) : value})`);
|
|
924
|
+
}
|
|
925
|
+
sql += ` ORDER BY ${terms.join(', ')}`;
|
|
926
|
+
if (plan.window !== null) sql += ` ${dialect.limitClause(plan.window.limit, plan.window.offset)}`;
|
|
927
|
+
}
|
|
928
|
+
else if (plan.aggregate === null) {
|
|
826
929
|
const terms = (plan.order ?? []).map((term) => {
|
|
827
930
|
// only a plain mapped column orders by its column; an epoch
|
|
828
931
|
// path orders by the document string — codepoint order, exactly
|
|
829
932
|
// the engine's — because mixed stored precisions would let the
|
|
830
933
|
// integer column sort differently
|
|
831
934
|
const value = term.ref.flavor === 'entity-column'
|
|
832
|
-
? `${aliasOf(term.binding)}.${q(term.ref.column)}
|
|
935
|
+
? physicalComparable(term.ref, `${aliasOf(term.binding)}.${q(term.ref.column)}`, dialect)
|
|
833
936
|
: dialect.jsonExtract(docOf(term.binding), pathTextOf(term.ref), 'text');
|
|
834
937
|
const nullsFirst = term.emptyGreatest === term.desc;
|
|
835
938
|
return `${value} ${term.desc ? 'DESC' : 'ASC'}${dialect.orderNulls(nullsFirst)}`;
|
|
836
939
|
});
|
|
837
940
|
// the engine's nested-loop order: binding-order row identities
|
|
838
|
-
for (const binding of plan.bindings)
|
|
839
|
-
|
|
941
|
+
for (const binding of plan.bindings) {
|
|
942
|
+
const keys = physicalOf(binding.entity).keys;
|
|
943
|
+
if (keys) terms.push(...keys.map((key) => physicalComparable(
|
|
944
|
+
physicalOf(binding.entity).mapping.columns.find((c) => c.physical === key),
|
|
945
|
+
`${aliasOf(binding.name)}.${q(key)}`, dialect)));
|
|
946
|
+
else terms.push(`${aliasOf(binding.name)}.${dialect.rowIdentity()}`);
|
|
947
|
+
}
|
|
840
948
|
sql += ` ORDER BY ${terms.join(', ')}`;
|
|
841
949
|
if (plan.window !== null)
|
|
842
950
|
sql += ` ${dialect.limitClause(plan.window.limit, plan.window.offset)}`;
|
package/src/entity.js
CHANGED
|
@@ -22,6 +22,11 @@ import { resolveRuntime } from '@jarenjs/core/runtime';
|
|
|
22
22
|
|
|
23
23
|
import { DbRuntimeError, wrapDriverError } from './errors.js';
|
|
24
24
|
import { chain, attempt } from './driver.js';
|
|
25
|
+
import { checkInvariants } from './invariants.js';
|
|
26
|
+
import { columnCodec, physicalRead } from './physical.js';
|
|
27
|
+
import { mergeEntityRow } from './graph.js';
|
|
28
|
+
import { createJSONPatch } from '@jarenjs/json/patch';
|
|
29
|
+
import { createEntityMutation } from './mutation.js';
|
|
25
30
|
|
|
26
31
|
/**
|
|
27
32
|
* The write/read machinery for one entity, prepared once.
|
|
@@ -40,6 +45,11 @@ export function entityCore(connection, entity, entityMapping, validate, runtime
|
|
|
40
45
|
const q = dialect.quoteIdentifier;
|
|
41
46
|
const table = entityMapping.table;
|
|
42
47
|
const docPath = entity.docPath;
|
|
48
|
+
const physical = entityMapping.document === false;
|
|
49
|
+
const physicalName = (name) => entityMapping.columns.find((c) => c.name === name)?.physical ?? name;
|
|
50
|
+
const writable = () => {
|
|
51
|
+
if (entityMapping.kind === 'view') throw new DbRuntimeError('JD2003', `entity '${entity.name}' is a read-only view`);
|
|
52
|
+
};
|
|
43
53
|
|
|
44
54
|
// the column plan: mapped scalars (epoch ones derived), then FKs;
|
|
45
55
|
// everything else lives in the JSONB document
|
|
@@ -47,6 +57,7 @@ export function entityCore(connection, entity, entityMapping, validate, runtime
|
|
|
47
57
|
...column,
|
|
48
58
|
epoch: column.source === 'epoch(document)',
|
|
49
59
|
property: entity.properties.get(column.name),
|
|
60
|
+
codecPlan: physical ? columnCodec(column) : null,
|
|
50
61
|
}));
|
|
51
62
|
// a declared via property is ALREADY a scalar column — the foreign
|
|
52
63
|
// key adds a column only when no property claims it
|
|
@@ -90,7 +101,7 @@ export function entityCore(connection, entity, entityMapping, validate, runtime
|
|
|
90
101
|
|
|
91
102
|
/** Split a completed document into bound column values + the rest.
|
|
92
103
|
* Relation members are PROJECTIONS (§10.1) — never stored. */
|
|
93
|
-
const split = (doc) => {
|
|
104
|
+
const split = (doc, { updating = false } = {}) => {
|
|
94
105
|
const values = [];
|
|
95
106
|
/** @type {any} */
|
|
96
107
|
const rest = {};
|
|
@@ -99,7 +110,12 @@ export function entityCore(connection, entity, entityMapping, validate, runtime
|
|
|
99
110
|
}
|
|
100
111
|
for (const column of scalarColumns) {
|
|
101
112
|
if (column.name === autoKey && doc[column.name] === undefined) continue;
|
|
113
|
+
if (physical && (column.generated || (!updating && column.databaseDefault && doc[column.name] === undefined))) continue;
|
|
102
114
|
const value = doc[column.name];
|
|
115
|
+
if (physical) {
|
|
116
|
+
values.push({ name: column.name, value: column.codecPlan.encode(value) });
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
103
119
|
if (column.epoch) {
|
|
104
120
|
// derived: the string stays in the document, the epoch rides
|
|
105
121
|
// the column
|
|
@@ -118,24 +134,13 @@ export function entityCore(connection, entity, entityMapping, validate, runtime
|
|
|
118
134
|
const value = doc[fk];
|
|
119
135
|
values.push({ name: fk, value: value === undefined || value === null ? null : value });
|
|
120
136
|
}
|
|
137
|
+
if (physical && Object.keys(rest).length > 0) throw new DbRuntimeError('JD2003',
|
|
138
|
+
`column-only entity '${entity.name}' cannot store undeclared members: ${Object.keys(rest).join(', ')}`);
|
|
121
139
|
return { values, rest };
|
|
122
140
|
};
|
|
123
141
|
|
|
124
142
|
/** Merge a row back into a document. */
|
|
125
|
-
const merge = (row) =>
|
|
126
|
-
const doc = JSON.parse(row.doc);
|
|
127
|
-
for (const column of scalarColumns) {
|
|
128
|
-
if (column.epoch) continue; // the string is already in the doc
|
|
129
|
-
const value = row[column.name];
|
|
130
|
-
if (value === null || value === undefined) continue; // absent (§9.3)
|
|
131
|
-
doc[column.name] = column.storage === 'boolean' ? value === 1 : value;
|
|
132
|
-
}
|
|
133
|
-
for (const fk of fkColumns) {
|
|
134
|
-
const value = row[fk];
|
|
135
|
-
if (value !== null && value !== undefined) doc[fk] = value;
|
|
136
|
-
}
|
|
137
|
-
return doc;
|
|
138
|
-
};
|
|
143
|
+
const merge = (row) => mergeEntityRow(entityMapping, row);
|
|
139
144
|
|
|
140
145
|
/** The document as a read will answer it. For a column-mapped scalar,
|
|
141
146
|
* JSON `null` and absence both store as SQL NULL and read back ABSENT
|
|
@@ -145,6 +150,7 @@ export function entityCore(connection, entity, entityMapping, validate, runtime
|
|
|
145
150
|
* `column: "json"` and stays in the document, where it survives; an
|
|
146
151
|
* epoch column keeps its string in the document for the same reason. */
|
|
147
152
|
const asStored = (doc) => {
|
|
153
|
+
if (physical) return doc;
|
|
148
154
|
let out = doc;
|
|
149
155
|
const drop = (name) => {
|
|
150
156
|
if (!(name in out) || (out[name] !== null && out[name] !== undefined)) return;
|
|
@@ -158,6 +164,17 @@ export function entityCore(connection, entity, entityMapping, validate, runtime
|
|
|
158
164
|
for (const fk of fkColumns) drop(fk);
|
|
159
165
|
return out;
|
|
160
166
|
};
|
|
167
|
+
const normalizePhysicalDoc = (doc) => {
|
|
168
|
+
if (!physical) return doc;
|
|
169
|
+
const normalized = { ...doc };
|
|
170
|
+
for (const column of scalarColumns) {
|
|
171
|
+
if (column.generated || !Object.hasOwn(doc, column.name)) continue;
|
|
172
|
+
const value = column.codecPlan.normalize(doc[column.name]);
|
|
173
|
+
if (value === undefined) delete normalized[column.name];
|
|
174
|
+
else normalized[column.name] = value;
|
|
175
|
+
}
|
|
176
|
+
return normalized;
|
|
177
|
+
};
|
|
161
178
|
|
|
162
179
|
// defaults, compiled once
|
|
163
180
|
const defaulters = [];
|
|
@@ -255,22 +272,24 @@ export function entityCore(connection, entity, entityMapping, validate, runtime
|
|
|
255
272
|
};
|
|
256
273
|
const parameterAt = (i) => dialect.parameterRef(i, 'v');
|
|
257
274
|
const keyWhere = (offset) => keys
|
|
258
|
-
.map((key, i) => `${q(key)} = ${parameterAt(offset + i + 1)}`).join(' AND ');
|
|
275
|
+
.map((key, i) => `${q(physicalName(key))} = ${parameterAt(offset + i + 1)}`).join(' AND ');
|
|
259
276
|
const selectColumns = [
|
|
260
|
-
...scalarColumns.filter((column) => !column.epoch).map((column) =>
|
|
277
|
+
...scalarColumns.filter((column) => !column.epoch).map((column) => physical
|
|
278
|
+
? `${physicalRead(column, dialect)} AS ${q(physicalName(column.name))}` : q(column.name)),
|
|
261
279
|
...fkColumns.map((column) => q(column)),
|
|
262
|
-
`${dialect.jsonText(q('doc'))} AS ${q('doc')}
|
|
280
|
+
...(physical ? [] : [`${dialect.jsonText(q('doc'))} AS ${q('doc')}`]),
|
|
263
281
|
].join(', ');
|
|
264
282
|
|
|
265
283
|
const insertSqlFor = (names) => {
|
|
266
|
-
const withDoc = [...names, 'doc'];
|
|
267
|
-
const refs = withDoc.map((name, i) => (name === 'doc'
|
|
284
|
+
const withDoc = physical ? names : [...names, 'doc'];
|
|
285
|
+
const refs = withDoc.map((name, i) => (!physical && name === 'doc'
|
|
268
286
|
? dialect.jsonEncode(parameterAt(i + 1))
|
|
269
287
|
: parameterAt(i + 1)));
|
|
270
288
|
const returning = autoKey !== null && !names.includes(autoKey)
|
|
271
|
-
? ` RETURNING ${q(autoKey)} AS ${q('key')}`
|
|
289
|
+
? ` RETURNING ${q(physicalName(autoKey))} AS ${q('key')}`
|
|
272
290
|
: '';
|
|
273
|
-
return `INSERT INTO ${q(table)}
|
|
291
|
+
if (withDoc.length === 0) return `INSERT INTO ${q(table)} DEFAULT VALUES${returning}`;
|
|
292
|
+
return `INSERT INTO ${q(table)} (${withDoc.map((n) => q(physicalName(n))).join(', ')}) `
|
|
274
293
|
+ `VALUES (${refs.join(', ')})${returning}`;
|
|
275
294
|
};
|
|
276
295
|
|
|
@@ -303,17 +322,22 @@ export function entityCore(connection, entity, entityMapping, validate, runtime
|
|
|
303
322
|
/** Encode ONE column assignment the way {@link split} would. */
|
|
304
323
|
const encodeColumn = (name, value) => {
|
|
305
324
|
const column = columnByName.get(name);
|
|
325
|
+
if (physical && column) return column.codecPlan.encode(value);
|
|
306
326
|
if (column !== undefined && column.epoch)
|
|
307
327
|
return value === undefined ? null : epochOf(column.property, value);
|
|
308
328
|
if (value === undefined || value === null) return null;
|
|
309
329
|
return typeof value === 'boolean' ? (value ? 1 : 0) : value;
|
|
310
330
|
};
|
|
311
331
|
|
|
312
|
-
|
|
332
|
+
const core = {
|
|
313
333
|
// the unit-of-work exposure (tracker.js): the column plan and the
|
|
314
334
|
// completion/validation/stamping machinery, one source of truth
|
|
315
335
|
plan: {
|
|
316
336
|
table,
|
|
337
|
+
document: !physical,
|
|
338
|
+
physicalName,
|
|
339
|
+
writable,
|
|
340
|
+
checkMutation: (op, before, after) => checkInvariants(entity.invariants, op, before, after),
|
|
317
341
|
keys,
|
|
318
342
|
autoKey,
|
|
319
343
|
version: entity.version ?? null,
|
|
@@ -325,7 +349,10 @@ export function entityCore(connection, entity, entityMapping, validate, runtime
|
|
|
325
349
|
encodeColumn,
|
|
326
350
|
},
|
|
327
351
|
complete: (doc, { updating }) => {
|
|
328
|
-
|
|
352
|
+
writable();
|
|
353
|
+
if (!updating && physical && scalarColumns.some((c) => c.generated && Object.hasOwn(doc, c.name)))
|
|
354
|
+
throw new DbRuntimeError('JD2003', 'generated columns are database-owned');
|
|
355
|
+
const completed = applyDefaults(normalizePhysicalDoc(doc), { updating });
|
|
329
356
|
checkValid(completed);
|
|
330
357
|
return asStored(completed);
|
|
331
358
|
},
|
|
@@ -338,23 +365,33 @@ export function entityCore(connection, entity, entityMapping, validate, runtime
|
|
|
338
365
|
},
|
|
339
366
|
normalizeKey: (key) => normalizeKeyArg(key),
|
|
340
367
|
create(doc) {
|
|
368
|
+
writable();
|
|
341
369
|
refuseProjections(doc, 'create', true);
|
|
342
370
|
const completed = applyDefaults(doc, { updating: false });
|
|
343
371
|
checkValid(completed);
|
|
372
|
+
if (!physical) checkInvariants(entity.invariants, 'insert', null, completed);
|
|
373
|
+
if (physical && scalarColumns.some((c) => c.generated && Object.hasOwn(doc, c.name)))
|
|
374
|
+
throw new DbRuntimeError('JD2003', 'generated columns are database-owned');
|
|
344
375
|
const { values, rest } = split(completed);
|
|
345
376
|
const names = values.map((value) => value.name);
|
|
346
377
|
const sql = insertSqlFor(names);
|
|
347
|
-
|
|
348
|
-
const params = [...values.map((value) => value.value), JSON.stringify(rest)];
|
|
378
|
+
const insert = () => chain(prepared(`insert:${names.join(',')}`, sql), (statement) => {
|
|
379
|
+
const params = [...values.map((value) => value.value), ...(physical ? [] : [JSON.stringify(rest)])];
|
|
349
380
|
const returning = autoKey !== null && !names.includes(autoKey);
|
|
350
381
|
return chain(
|
|
351
382
|
attempt(() => (returning ? statement.get(params) : statement.run(params)),
|
|
352
383
|
(error) => wrapWrite(error, completed[keys[0]])),
|
|
353
|
-
(out) =>
|
|
384
|
+
(out) => {
|
|
385
|
+
const made = returning ? { ...completed, [autoKey]: out.key } : completed;
|
|
386
|
+
return physical ? chain(this.get(made), (stored) => {
|
|
387
|
+
checkValid(stored); checkInvariants(entity.invariants, 'insert', null, stored); return stored;
|
|
388
|
+
}) : asStored(made);
|
|
389
|
+
});
|
|
354
390
|
});
|
|
391
|
+
return physical ? connection.transaction(insert) : insert();
|
|
355
392
|
},
|
|
356
393
|
get(key) {
|
|
357
|
-
const parts = normalizeKeyArg(key);
|
|
394
|
+
const parts = normalizeKeyArg(key).map((v, i) => physical ? columnByName.get(keys[i]).codecPlan.encode(v) : v);
|
|
358
395
|
const sql = `SELECT ${selectColumns} FROM ${q(table)} WHERE ${keyWhere(0)}`;
|
|
359
396
|
// classified like every read of the query engines, never raw
|
|
360
397
|
return attempt(() => chain(prepared('get', sql), (statement) =>
|
|
@@ -362,9 +399,10 @@ export function entityCore(connection, entity, entityMapping, validate, runtime
|
|
|
362
399
|
(error) => wrapDriverError(error, { docPath, collection: entity.name, key }));
|
|
363
400
|
},
|
|
364
401
|
update(key, changes) {
|
|
365
|
-
|
|
402
|
+
writable();
|
|
403
|
+
const parts = normalizeKeyArg(key).map((v, i) => physical ? columnByName.get(keys[i]).codecPlan.encode(v) : v);
|
|
366
404
|
refuseProjections(changes, 'update', false);
|
|
367
|
-
|
|
405
|
+
const update = () => chain(this.get(key), (current) => {
|
|
368
406
|
if (current === undefined) {
|
|
369
407
|
throw new DbRuntimeError('JD2006',
|
|
370
408
|
`no '${entity.name}' to update under that key`,
|
|
@@ -379,32 +417,51 @@ export function entityCore(connection, entity, entityMapping, validate, runtime
|
|
|
379
417
|
{ docPath, collection: entity.name, key: parts[0] });
|
|
380
418
|
}
|
|
381
419
|
}
|
|
382
|
-
const
|
|
420
|
+
const candidate = normalizePhysicalDoc({ ...current, ...changes });
|
|
421
|
+
if (physical && createJSONPatch(current, candidate).length === 0) return current;
|
|
422
|
+
if (physical && scalarColumns.some((c) => c.generated && Object.hasOwn(changes, c.name)
|
|
423
|
+
&& changes[c.name] !== current[c.name])) throw new DbRuntimeError('JD2003', 'generated columns are database-owned');
|
|
424
|
+
const next = applyDefaults(candidate, { updating: true });
|
|
383
425
|
// an explicit update is last-write-wins by contract (§11.2),
|
|
384
426
|
// but it still moves a declared version token so optimistic
|
|
385
427
|
// savers see the row changed
|
|
386
428
|
if (entity.version !== null && entity.version !== undefined)
|
|
387
429
|
next[entity.version] = (Number(current[entity.version]) || 0) + 1;
|
|
388
430
|
checkValid(next);
|
|
389
|
-
|
|
431
|
+
if (!physical) checkInvariants(entity.invariants, 'update', current, next);
|
|
432
|
+
const { values, rest } = split(next, { updating: true });
|
|
390
433
|
const assignments = [
|
|
391
|
-
...values.map((value, i) => `${q(value.name)} = ${parameterAt(i + 1)}`),
|
|
392
|
-
`${q('doc')} = ${dialect.jsonEncode(parameterAt(values.length + 1))}
|
|
434
|
+
...values.map((value, i) => `${q(physicalName(value.name))} = ${parameterAt(i + 1)}`),
|
|
435
|
+
...(physical ? [] : [`${q('doc')} = ${dialect.jsonEncode(parameterAt(values.length + 1))}`]),
|
|
393
436
|
].join(', ');
|
|
394
437
|
const sql = `UPDATE ${q(table)} SET ${assignments} `
|
|
395
|
-
+ `WHERE ${keyWhere(values.length + 1)}`;
|
|
396
|
-
return chain(prepared(`update:${values.
|
|
438
|
+
+ `WHERE ${keyWhere(values.length + (physical ? 0 : 1))}`;
|
|
439
|
+
return chain(prepared(`update:${values.map((v) => v.name).join(',')}`, sql), (statement) =>
|
|
397
440
|
chain(attempt(() => statement.run([...values.map((value) => value.value),
|
|
398
|
-
JSON.stringify(rest), ...parts]), (error) => wrapWrite(error, parts[0])),
|
|
399
|
-
() =>
|
|
441
|
+
...(physical ? [] : [JSON.stringify(rest)]), ...parts]), (error) => wrapWrite(error, parts[0])),
|
|
442
|
+
() => physical ? chain(this.get(key), (stored) => {
|
|
443
|
+
checkValid(stored); checkInvariants(entity.invariants, 'update', current, stored); return stored;
|
|
444
|
+
}) : asStored(next)));
|
|
400
445
|
});
|
|
446
|
+
return physical ? connection.transaction(update) : update();
|
|
401
447
|
},
|
|
402
448
|
delete(key) {
|
|
403
|
-
|
|
449
|
+
writable();
|
|
450
|
+
if (entity.invariants.some((r) => r.enforcement === 'store' && r.on.includes('delete')))
|
|
451
|
+
return chain(this.get(key), (before) => {
|
|
452
|
+
checkInvariants(entity.invariants, 'delete', before, null);
|
|
453
|
+
return remove(key);
|
|
454
|
+
});
|
|
455
|
+
return remove(key);
|
|
456
|
+
},
|
|
457
|
+
};
|
|
458
|
+
core.mutate = createEntityMutation(connection, entity, entityMapping, core);
|
|
459
|
+
return core;
|
|
460
|
+
function remove(key) {
|
|
461
|
+
const parts = normalizeKeyArg(key).map((v, i) => physical ? columnByName.get(keys[i]).codecPlan.encode(v) : v);
|
|
404
462
|
const sql = `DELETE FROM ${q(table)} WHERE ${keyWhere(0)}`;
|
|
405
463
|
return chain(prepared('delete', sql), (statement) =>
|
|
406
464
|
chain(attempt(() => statement.run(parts), (error) => wrapWrite(error, parts[0])),
|
|
407
465
|
(result) => Number(result?.changes ?? 0) > 0));
|
|
408
|
-
|
|
409
|
-
};
|
|
466
|
+
}
|
|
410
467
|
}
|