@jarenjs/db 0.73.0 → 0.75.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 +50 -7
- package/README.md +44 -6
- package/docs/LIVE-FORMAT.md +48 -13
- package/docs/MODEL-FORMAT.md +31 -14
- package/docs/REPLICATION-FORMAT.md +19 -13
- package/package.json +4 -4
- package/src/algebra.js +9 -3
- package/src/derive.js +14 -3
- package/src/dialect.js +2 -0
- package/src/dialects/check-read.js +151 -0
- package/src/dialects/postgres.js +14 -2
- package/src/dialects/sqlite.js +5 -1
- package/src/emit.js +54 -13
- package/src/introspect.js +37 -5
- package/src/live-nested.js +27 -10
- package/src/live.js +47 -135
- package/src/plan.js +163 -46
- package/src/query.js +31 -8
- package/types/index.d.ts +1 -1
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/** Recover only a complete scalar enum CHECK; unfamiliar SQL remains a loss. */
|
|
3
|
+
|
|
4
|
+
/** Tokenize catalog SQL without treating quoted text or comments as syntax.
|
|
5
|
+
* @param {string} sql @returns {{ kind: string, value: string }[]} */
|
|
6
|
+
function tokensOf(sql) {
|
|
7
|
+
const tokens = [];
|
|
8
|
+
for (let i = 0; i < sql.length;) {
|
|
9
|
+
const c = sql[i];
|
|
10
|
+
if (/\s/.test(c)) { i++; continue; }
|
|
11
|
+
if (sql.startsWith('--', i)) {
|
|
12
|
+
const end = sql.indexOf('\n', i + 2); i = end < 0 ? sql.length : end + 1; continue;
|
|
13
|
+
}
|
|
14
|
+
if (sql.startsWith('/*', i)) {
|
|
15
|
+
const end = sql.indexOf('*/', i + 2);
|
|
16
|
+
if (end < 0) return [];
|
|
17
|
+
i = end + 2; continue;
|
|
18
|
+
}
|
|
19
|
+
if (c === "'" || c === '"' || c === '`') {
|
|
20
|
+
let value = '';
|
|
21
|
+
let closed = false;
|
|
22
|
+
for (i++; i < sql.length; i++) {
|
|
23
|
+
if (sql[i] !== c) { value += sql[i]; continue; }
|
|
24
|
+
if (sql[i + 1] === c) { value += c; i++; continue; }
|
|
25
|
+
i++; closed = true; break;
|
|
26
|
+
}
|
|
27
|
+
if (!closed) return [];
|
|
28
|
+
tokens.push({ kind: c === "'" ? 'string' : 'identifier', value });
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
const number = /^(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:[eE][+-]?[0-9]+)?/.exec(sql.slice(i));
|
|
32
|
+
if (number) { tokens.push({ kind: 'number', value: number[0] }); i += number[0].length; continue; }
|
|
33
|
+
const word = /^[A-Za-z_][A-Za-z_0-9$]*/.exec(sql.slice(i));
|
|
34
|
+
if (word) { tokens.push({ kind: 'word', value: word[0] }); i += word[0].length; continue; }
|
|
35
|
+
const value = sql.startsWith('::', i) ? '::' : c;
|
|
36
|
+
tokens.push({ kind: 'symbol', value }); i += value.length;
|
|
37
|
+
}
|
|
38
|
+
return tokens;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** @param {any[]} tokens @returns {{ column: string, values: any[] } | null} */
|
|
42
|
+
function enumOf(tokens) {
|
|
43
|
+
let at = 0;
|
|
44
|
+
const take = (value) => {
|
|
45
|
+
const token = tokens[at];
|
|
46
|
+
if (token && token.kind !== 'string' && token.kind !== 'identifier'
|
|
47
|
+
&& token.value.toUpperCase() === value) { at++; return true; }
|
|
48
|
+
return false;
|
|
49
|
+
};
|
|
50
|
+
const scalar = () => {
|
|
51
|
+
if (take('(')) {
|
|
52
|
+
const value = scalar();
|
|
53
|
+
return value === undefined || !take(')') ? undefined : cast(value);
|
|
54
|
+
}
|
|
55
|
+
const negative = take('-');
|
|
56
|
+
const token = tokens[at++];
|
|
57
|
+
if (!token) return undefined;
|
|
58
|
+
let value;
|
|
59
|
+
if (token.kind === 'number') value = Number(`${negative ? '-' : ''}${token.value}`);
|
|
60
|
+
else if (negative) return undefined;
|
|
61
|
+
else if (token.kind === 'string') value = token.value;
|
|
62
|
+
else if (token.kind === 'word' && /^(true|false)$/i.test(token.value)) value = token.value.toLowerCase() === 'true';
|
|
63
|
+
else return undefined;
|
|
64
|
+
if (typeof value === 'number' && !Number.isFinite(value)) return undefined;
|
|
65
|
+
return cast(value);
|
|
66
|
+
};
|
|
67
|
+
const cast = (value) => {
|
|
68
|
+
if (!take('::')) return value;
|
|
69
|
+
// Only exact scalar casts emitted by the catalog are understood.
|
|
70
|
+
// Rounding casts could change the enum's members.
|
|
71
|
+
const type = tokens[at++];
|
|
72
|
+
if (type?.kind !== 'word') return undefined;
|
|
73
|
+
const name = type.value.toLowerCase();
|
|
74
|
+
if (typeof value === 'string' && name === 'text') return value;
|
|
75
|
+
if (typeof value === 'boolean' && name === 'boolean') return value;
|
|
76
|
+
// PostgreSQL renders negative constants as quoted numeric casts,
|
|
77
|
+
// e.g. ('-1'::integer)::numeric. Decode only finite numeric syntax.
|
|
78
|
+
if (typeof value === 'string' && ['numeric', 'integer', 'bigint', 'smallint'].includes(name)
|
|
79
|
+
&& /^-?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:[eE][+-]?[0-9]+)?$/.test(value)) {
|
|
80
|
+
const number = Number(value);
|
|
81
|
+
if (!Number.isFinite(number) || (['integer', 'bigint', 'smallint'].includes(name)
|
|
82
|
+
&& !Number.isSafeInteger(number))) return undefined;
|
|
83
|
+
return number;
|
|
84
|
+
}
|
|
85
|
+
if (typeof value === 'number' && name === 'numeric') return value;
|
|
86
|
+
if (typeof value === 'number' && ['integer', 'bigint', 'smallint'].includes(name)
|
|
87
|
+
&& Number.isSafeInteger(value)) return value;
|
|
88
|
+
if (typeof value === 'number' && name === 'double' && take('PRECISION')) return value;
|
|
89
|
+
return undefined;
|
|
90
|
+
};
|
|
91
|
+
const expression = () => {
|
|
92
|
+
if (take('(')) {
|
|
93
|
+
const result = expression();
|
|
94
|
+
return result === null || !take(')') ? null : result;
|
|
95
|
+
}
|
|
96
|
+
const column = tokens[at++];
|
|
97
|
+
if (!column || !['identifier', 'word'].includes(column.kind)) return null;
|
|
98
|
+
const any = take('=');
|
|
99
|
+
// A singleton IN list is normalized to scalar equality by PostgreSQL.
|
|
100
|
+
if (any && !take('ANY')) {
|
|
101
|
+
const value = scalar();
|
|
102
|
+
return value === undefined ? null : { column: column.value, values: [value] };
|
|
103
|
+
}
|
|
104
|
+
if (any ? !(take('(') && take('ARRAY') && take('[')) : !(take('IN') && take('('))) return null;
|
|
105
|
+
const values = [];
|
|
106
|
+
do {
|
|
107
|
+
const value = scalar();
|
|
108
|
+
if (value === undefined) return null;
|
|
109
|
+
if (!values.includes(value)) values.push(value);
|
|
110
|
+
} while (take(','));
|
|
111
|
+
if (any ? !(take(']') && take(')')) : !take(')')) return null;
|
|
112
|
+
return { column: column.value, values };
|
|
113
|
+
};
|
|
114
|
+
const result = expression();
|
|
115
|
+
return at === tokens.length ? result : null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** SQLite retains the CREATE text, including inline and table CHECKs.
|
|
119
|
+
* @param {any[]} rows @returns {any[]} neutral constraints */
|
|
120
|
+
export function sqliteChecks(rows) {
|
|
121
|
+
const checks = [];
|
|
122
|
+
for (const row of rows) {
|
|
123
|
+
const tokens = tokensOf(String(row.sql ?? ''));
|
|
124
|
+
// A column's inherited collation changes IN equality even when the
|
|
125
|
+
// CHECK itself names no collation. Refuse conservatively per table.
|
|
126
|
+
const collated = tokens.some((token, i) => token.kind === 'word'
|
|
127
|
+
&& token.value.toUpperCase() === 'COLLATE'
|
|
128
|
+
&& tokens[i + 1]?.value.toUpperCase() !== 'BINARY');
|
|
129
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
130
|
+
if (tokens[i].kind !== 'word' || tokens[i].value.toUpperCase() !== 'CHECK'
|
|
131
|
+
|| tokens[i + 1]?.value !== '(') continue;
|
|
132
|
+
const start = i + 2;
|
|
133
|
+
let depth = 1;
|
|
134
|
+
for (i = start; i < tokens.length; i++) {
|
|
135
|
+
if (tokens[i].kind !== 'symbol') continue;
|
|
136
|
+
if (tokens[i].value === '(') depth++;
|
|
137
|
+
if (tokens[i].value === ')' && --depth === 0) break;
|
|
138
|
+
}
|
|
139
|
+
checks.push({ name: `check_${checks.length + 1}`,
|
|
140
|
+
...(collated ? null : enumOf(tokens.slice(start, i))) });
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return checks;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** PostgreSQL exposes each CHECK expression separately.
|
|
147
|
+
* @param {any[]} rows @returns {any[]} neutral constraints */
|
|
148
|
+
export function postgresChecks(rows) {
|
|
149
|
+
return rows.map((row) => ({ name: String(row.name),
|
|
150
|
+
...(row.unsafe_collation ? null : enumOf(tokensOf(String(row.expression ?? '')))) }));
|
|
151
|
+
}
|
package/src/dialects/postgres.js
CHANGED
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
|
|
40
40
|
import { createDialect } from '../dialect.js';
|
|
41
41
|
import { readExpression } from './expression-read.js';
|
|
42
|
+
import { postgresChecks } from './check-read.js';
|
|
42
43
|
|
|
43
44
|
/** PostgreSQL truncates an identifier past this many BYTES, silently
|
|
44
45
|
* and with only a notice — so two long generated names would collide
|
|
@@ -519,6 +520,7 @@ export function postgresDialect(options = undefined) {
|
|
|
519
520
|
memberPathOf,
|
|
520
521
|
expressionOf,
|
|
521
522
|
// the catalog already answers one row per generated column
|
|
523
|
+
readChecks: postgresChecks,
|
|
522
524
|
readGenerated: (rows) => rows.map((row) => ({
|
|
523
525
|
name: String(row.name), expression: String(row.expression ?? ''),
|
|
524
526
|
})),
|
|
@@ -571,7 +573,8 @@ export function postgresDialect(options = undefined) {
|
|
|
571
573
|
// key's is the engine's own
|
|
572
574
|
indexes: (table) =>
|
|
573
575
|
'SELECT ci.relname AS name, CASE WHEN i.indisunique THEN 1 ELSE 0 END AS uniq, '
|
|
574
|
-
+ "CASE WHEN i.indisprimary THEN 'pk' ELSE 'c' END AS origin "
|
|
576
|
+
+ "CASE WHEN i.indisprimary THEN 'pk' ELSE 'c' END AS origin, "
|
|
577
|
+
+ 'CASE WHEN i.indpred IS NULL THEN 0 ELSE 1 END AS partial '
|
|
575
578
|
+ 'FROM pg_index i JOIN pg_class ci ON ci.oid = i.indexrelid '
|
|
576
579
|
+ 'JOIN pg_class ct ON ct.oid = i.indrelid '
|
|
577
580
|
+ 'JOIN pg_namespace n ON n.oid = ct.relnamespace '
|
|
@@ -584,7 +587,7 @@ export function postgresDialect(options = undefined) {
|
|
|
584
587
|
+ 'JOIN pg_class ci ON ci.oid = i.indexrelid '
|
|
585
588
|
+ 'JOIN pg_namespace n ON n.oid = ci.relnamespace '
|
|
586
589
|
+ 'CROSS JOIN LATERAL unnest(i.indkey::int2[]) WITH ORDINALITY AS k(attnum, ord) '
|
|
587
|
-
+ 'JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = k.attnum '
|
|
590
|
+
+ 'LEFT JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = k.attnum '
|
|
588
591
|
+ `WHERE ci.relname = ${stringLiteral(index)} AND ${inNamespace} ORDER BY k.ord`,
|
|
589
592
|
// every table this store might own; a view is reported rather
|
|
590
593
|
// than derived, and the engine's own schemas are never in scope
|
|
@@ -602,6 +605,15 @@ export function postgresDialect(options = undefined) {
|
|
|
602
605
|
+ 'JOIN pg_namespace n ON n.oid = c.relnamespace '
|
|
603
606
|
+ `WHERE c.relname = ${stringLiteral(table)} AND ${inNamespace} `
|
|
604
607
|
+ "AND a.attgenerated <> '' ORDER BY a.attnum",
|
|
608
|
+
checks: (table) =>
|
|
609
|
+
'SELECT c.conname AS name, pg_get_expr(c.conbin, c.conrelid) AS expression, '
|
|
610
|
+
+ 'EXISTS (SELECT 1 FROM pg_attribute ca JOIN pg_collation co ON co.oid = ca.attcollation '
|
|
611
|
+
+ 'WHERE ca.attrelid = c.conrelid AND ca.attnum = ANY(c.conkey) '
|
|
612
|
+
+ 'AND NOT co.collisdeterministic) AS unsafe_collation '
|
|
613
|
+
+ 'FROM pg_constraint c JOIN pg_class ct ON ct.oid = c.conrelid '
|
|
614
|
+
+ 'JOIN pg_namespace n ON n.oid = ct.relnamespace '
|
|
615
|
+
+ `WHERE c.contype = 'c' AND ct.relname = ${stringLiteral(table)} AND ${inNamespace} `
|
|
616
|
+
+ 'ORDER BY c.conname',
|
|
605
617
|
foreignKeyList: (table) => {
|
|
606
618
|
const action = (column) => `CASE ${column} `
|
|
607
619
|
+ "WHEN 'a' THEN 'NO ACTION' WHEN 'r' THEN 'RESTRICT' WHEN 'c' THEN 'CASCADE' "
|
package/src/dialects/sqlite.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import { createDialect } from '../dialect.js';
|
|
12
12
|
import { rtreeDdl } from './rtree-ddl.js';
|
|
13
13
|
import { readExpression } from './expression-read.js';
|
|
14
|
+
import { sqliteChecks } from './check-read.js';
|
|
14
15
|
|
|
15
16
|
/** @param {string} s */
|
|
16
17
|
function quoteIdentifier(s) {
|
|
@@ -439,7 +440,7 @@ export const sqliteDialect = createDialect({
|
|
|
439
440
|
columns: (table) =>
|
|
440
441
|
`SELECT name, type, hidden FROM pragma_table_xinfo(${stringLiteral(table)})`,
|
|
441
442
|
indexes: (table) =>
|
|
442
|
-
`SELECT name, "unique" AS uniq, origin FROM pragma_index_list(${stringLiteral(table)})`,
|
|
443
|
+
`SELECT name, "unique" AS uniq, origin, partial FROM pragma_index_list(${stringLiteral(table)})`,
|
|
443
444
|
indexColumns: (index) =>
|
|
444
445
|
`SELECT name FROM pragma_index_info(${stringLiteral(index)})`,
|
|
445
446
|
foreignKeysOn: () => 'SELECT foreign_keys AS enabled FROM pragma_foreign_keys',
|
|
@@ -467,10 +468,13 @@ export const sqliteDialect = createDialect({
|
|
|
467
468
|
// the CREATE text is where a generated column's expression lives
|
|
468
469
|
generated: (table) =>
|
|
469
470
|
`SELECT sql FROM sqlite_schema WHERE type = 'table' AND name = ${stringLiteral(table)}`,
|
|
471
|
+
checks: (table) =>
|
|
472
|
+
`SELECT sql FROM sqlite_schema WHERE type = 'table' AND name = ${stringLiteral(table)}`,
|
|
470
473
|
// the whole declared schema, for shape-equality comparison after a
|
|
471
474
|
// rebuild: every object that carries SQL text, in a stable order
|
|
472
475
|
schemaDump: () =>
|
|
473
476
|
"SELECT type, name, tbl_name AS owner, sql FROM sqlite_schema "
|
|
474
477
|
+ "WHERE sql IS NOT NULL ORDER BY type, name",
|
|
475
478
|
},
|
|
479
|
+
readChecks: sqliteChecks,
|
|
476
480
|
});
|
package/src/emit.js
CHANGED
|
@@ -26,10 +26,21 @@ import { codePointPrefixSuccessor } from '@jarenjs/core/string';
|
|
|
26
26
|
*/
|
|
27
27
|
export class UnrepresentablePath extends Error {}
|
|
28
28
|
|
|
29
|
+
/** A comparison of schema-compatible paths is false if either is absent.
|
|
30
|
+
* @param {any} pred @param {(ref: any) => { value: string, present: string }} read */
|
|
31
|
+
function compareRefs(pred, read) {
|
|
32
|
+
const left = read(pred.left);
|
|
33
|
+
const right = read(pred.right);
|
|
34
|
+
const symbol = { eq: '=', ne: '<>', lt: '<', le: '<=', gt: '>', ge: '>=' }[pred.op];
|
|
35
|
+
return `(${left.present} AND ${right.present} AND ${left.value} ${symbol} ${right.value})`;
|
|
36
|
+
}
|
|
37
|
+
|
|
29
38
|
/**
|
|
30
39
|
* @typedef {{ external: string } | { literal: unknown } |
|
|
31
40
|
* { derived: { kind: 'bboxAxis', external: string,
|
|
32
41
|
* axis: 'w' | 's' | 'e' | 'n' } } |
|
|
42
|
+
* { derived: { kind: 'circleAxis', centre: { external: string } | { literal: unknown },
|
|
43
|
+
* radius: { external: string } | { literal: unknown }, axis: 'w' | 's' | 'e' | 'n' } } |
|
|
33
44
|
* { typed: { seek: string, type: 'number' | 'text' } }} ParamSlot
|
|
34
45
|
* Four kinds, closed. A DERIVED slot is the escape for a value SQL
|
|
35
46
|
* cannot bind at all: a GeoJSON region arrives as an external object,
|
|
@@ -85,7 +96,7 @@ function stropForm(dialect, param, valueSql, pred) {
|
|
|
85
96
|
*/
|
|
86
97
|
function slotName(slot) {
|
|
87
98
|
if ('external' in slot) return slot.external;
|
|
88
|
-
if ('derived' in slot) return slot.derived.external;
|
|
99
|
+
if ('derived' in slot) return slot.derived.kind === 'bboxAxis' ? slot.derived.external : 'circle';
|
|
89
100
|
if ('typed' in slot) return slot.typed.seek;
|
|
90
101
|
return 'value';
|
|
91
102
|
}
|
|
@@ -107,6 +118,8 @@ const BOX_AT = { w: 0, s: 1, e: 2, n: 3 };
|
|
|
107
118
|
* @returns {string}
|
|
108
119
|
*/
|
|
109
120
|
function probeEdge(probe, param, axis) {
|
|
121
|
+
if ('circle' in probe)
|
|
122
|
+
return param({ derived: { kind: 'circleAxis', ...probe.circle, axis } });
|
|
110
123
|
return 'box' in probe
|
|
111
124
|
? param({ literal: probe.box[BOX_AT[axis]] })
|
|
112
125
|
: param({ derived: { kind: 'bboxAxis', external: probe.ext, axis } });
|
|
@@ -120,6 +133,11 @@ function probeEdge(probe, param, axis) {
|
|
|
120
133
|
* @returns {{ sql: string, slots: ParamSlot[] }}
|
|
121
134
|
*/
|
|
122
135
|
export function emitPlan(plan, dialect, physical) {
|
|
136
|
+
if (plan.group !== null && plan.aggregate?.fn === 'count') {
|
|
137
|
+
const inner = emitPlan({ ...plan, aggregate: null }, dialect, physical);
|
|
138
|
+
return { ...inner, sql: `SELECT COUNT(*) AS ${dialect.quoteIdentifier('value')} `
|
|
139
|
+
+ `FROM (${inner.sql}) AS ${dialect.quoteIdentifier('_groups')}` };
|
|
140
|
+
}
|
|
123
141
|
const q = dialect.quoteIdentifier;
|
|
124
142
|
const docColumn = q(physical.docColumn);
|
|
125
143
|
/** @type {ParamSlot[]} */
|
|
@@ -297,6 +315,9 @@ export function emitPlan(plan, dialect, physical) {
|
|
|
297
315
|
*/
|
|
298
316
|
const emitPred = (pred) => {
|
|
299
317
|
switch (pred.p) {
|
|
318
|
+
case 'refCmp':
|
|
319
|
+
return compareRefs(pred, (ref) => ({ value: valueOf(ref, kindOf(ref)),
|
|
320
|
+
present: `${typeOf(ref)} IS NOT NULL` }));
|
|
300
321
|
case 'and':
|
|
301
322
|
return `(${pred.items.map(emitPred).join(' AND ')})`;
|
|
302
323
|
case 'or':
|
|
@@ -439,9 +460,12 @@ export function emitPlan(plan, dialect, physical) {
|
|
|
439
460
|
+ `${q(plan.rank.alternatives[0].column)} AS ${q('vec')}`
|
|
440
461
|
: plan.group !== null
|
|
441
462
|
? [...plan.group.keys.map((key, i) => projectedPair(key.ref, `k${i}`)),
|
|
442
|
-
...plan.group.aggregates.map((entry, i) =>
|
|
443
|
-
|
|
444
|
-
|
|
463
|
+
...plan.group.aggregates.map((entry, i) => {
|
|
464
|
+
const value = dialect.groupAggregate(entry.fn, foldValue(entry.fn, entry.ref));
|
|
465
|
+
// Empty sums order as zero, the same value reconstruction
|
|
466
|
+
// returns. Naming that value also works in PostgreSQL ORDER BY.
|
|
467
|
+
return `${entry.empty === 'zero' ? `COALESCE(${value}, 0)` : value} AS ${q(`a${i}`)}`;
|
|
468
|
+
})].join(', ')
|
|
445
469
|
: plan.bucket !== null
|
|
446
470
|
? [`${bucketSql} AS ${q(plan.bucket.as)}`,
|
|
447
471
|
...plan.bucket.aggregates.map((entry) =>
|
|
@@ -461,7 +485,8 @@ export function emitPlan(plan, dialect, physical) {
|
|
|
461
485
|
// a projection TREE: the same value/type pair per DISTINCT
|
|
462
486
|
// leaf, numbered, and nothing else — the document blob is
|
|
463
487
|
// never selected, and a leaf named twice is fetched once
|
|
464
|
-
: plan.project.leaves.map((ref, i) => projectedPair(ref, String(i))).join(', ')
|
|
488
|
+
: (plan.project.leaves.map((ref, i) => projectedPair(ref, String(i))).join(', ')
|
|
489
|
+
|| `1 AS ${q('_row')}`))
|
|
465
490
|
: plan.aggregate.fn === 'count'
|
|
466
491
|
? `COUNT(*) AS ${q('value')}`
|
|
467
492
|
// a REGISTERED aggregate calls the function the store
|
|
@@ -486,10 +511,11 @@ export function emitPlan(plan, dialect, physical) {
|
|
|
486
511
|
// the groups' order: the engine's own order of first appearance —
|
|
487
512
|
// over a collection, each group's earliest row identity — or the
|
|
488
513
|
// key ordering an `$orderby` declared
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
514
|
+
const order = plan.group.order === 'first-seen' ? []
|
|
515
|
+
: plan.group.order.map((term) => `${q(term.aggregate === undefined ? `vk${term.index}` : `a${term.aggregate}`)} `
|
|
516
|
+
+ `${term.desc ? 'DESC' : 'ASC'}${dialect.orderNulls(term.nullsFirst)}`);
|
|
517
|
+
order.push(dialect.groupAggregate('min', dialect.rowIdentity()));
|
|
518
|
+
sql += ` ORDER BY ${order.join(', ')}`;
|
|
493
519
|
}
|
|
494
520
|
if (plan.bucket !== null) {
|
|
495
521
|
// `first-seen` is the engine's own group order (§6.5, first
|
|
@@ -518,7 +544,7 @@ export function emitPlan(plan, dialect, physical) {
|
|
|
518
544
|
terms.push(dialect.rowIdentity());
|
|
519
545
|
sql += ` ORDER BY ${terms.join(', ')}`;
|
|
520
546
|
}
|
|
521
|
-
if (plan.window !== null && plan.aggregate === null
|
|
547
|
+
if (plan.window !== null && plan.aggregate === null) {
|
|
522
548
|
sql += ` ${dialect.limitClause(plan.window.limit, plan.window.offset)}`;
|
|
523
549
|
}
|
|
524
550
|
return { sql, slots, seeks: (plan.seeks ?? []).map((seek) => emitSeek(seek)) };
|
|
@@ -682,6 +708,15 @@ export function createEntityPredicateEmitters(dialect, param) {
|
|
|
682
708
|
};
|
|
683
709
|
|
|
684
710
|
const emitPred = (aliasSql, docSql, pred) => {
|
|
711
|
+
if (pred.p === 'refCmp') return compareRefs(pred, (ref) => {
|
|
712
|
+
if (ref.flavor === 'entity-column') {
|
|
713
|
+
const value = `${aliasSql}.${q(ref.column)}`;
|
|
714
|
+
return { value, present: `${value} IS NOT NULL` };
|
|
715
|
+
}
|
|
716
|
+
// Epoch columns retain the document's lexical comparison rule.
|
|
717
|
+
return { value: memberAt(docSql, ref, ref.type === 'string' ? 'text' : 'number'),
|
|
718
|
+
present: `${dialect.jsonTypeOf(docSql, pathTextOf(ref))} IS NOT NULL` };
|
|
719
|
+
});
|
|
685
720
|
if (pred.p === 'and')
|
|
686
721
|
return `(${pred.items.map((item) => emitPred(aliasSql, docSql, item)).join(' AND ')})`;
|
|
687
722
|
if (pred.p === 'or')
|
|
@@ -745,8 +780,13 @@ export function emitEntityPlan(plan, dialect, physicalOf) {
|
|
|
745
780
|
|
|
746
781
|
const entityOf = new Map(plan.bindings.map((binding) => [binding.name, binding.entity]));
|
|
747
782
|
const emitters = createEntityPredicateEmitters(dialect, param);
|
|
748
|
-
const emitPred = (bindingName, pred) =>
|
|
749
|
-
|
|
783
|
+
const emitPred = (bindingName, pred) => {
|
|
784
|
+
if (pred.p === 'binding') return emitPred(pred.binding, pred.filter);
|
|
785
|
+
if (pred.p === 'and' || pred.p === 'or') return `(${pred.items
|
|
786
|
+
.map((item) => emitPred(bindingName, item)).join(pred.p === 'and' ? ' AND ' : ' OR ')})`;
|
|
787
|
+
if (pred.p === 'not') return `NOT (${emitPred(bindingName, pred.item)})`;
|
|
788
|
+
return emitters.emitPred(aliasOf(bindingName), docOf(bindingName), pred);
|
|
789
|
+
};
|
|
750
790
|
|
|
751
791
|
/**
|
|
752
792
|
* One projected member of a binding: its value beside its JSON type,
|
|
@@ -794,7 +834,8 @@ export function emitEntityPlan(plan, dialect, physicalOf) {
|
|
|
794
834
|
: plan.project != null
|
|
795
835
|
// `p`-prefixed, because a bare `t0` would collide with this
|
|
796
836
|
// plan's own binding aliases
|
|
797
|
-
? plan.project.leaves.map((leaf, i) => projectedPair(leaf, `p${i}`)).join(', ')
|
|
837
|
+
? (plan.project.leaves.map((leaf, i) => projectedPair(leaf, `p${i}`)).join(', ')
|
|
838
|
+
|| `1 AS ${q('_row')}`)
|
|
798
839
|
// a join-table root IS its two key columns: it has no document
|
|
799
840
|
// column, so the merge is handed an empty one
|
|
800
841
|
: physicalOf(entityOf.get(ret)).document === false
|
package/src/introspect.js
CHANGED
|
@@ -95,13 +95,16 @@ function readTable(connection, table) {
|
|
|
95
95
|
(rows) => withColumns(i + 1, [...out, {
|
|
96
96
|
name: String(declared[i].name),
|
|
97
97
|
unique: Number(declared[i].uniq) !== 0,
|
|
98
|
-
|
|
98
|
+
partial: Number(declared[i].partial ?? 0) !== 0,
|
|
99
|
+
columns: rows.map((row) => row.name == null ? null : String(row.name)),
|
|
99
100
|
}]));
|
|
100
101
|
};
|
|
101
102
|
return chain(withColumns(0, []), (indexes) =>
|
|
102
103
|
chain(keyIndex === undefined
|
|
103
104
|
? []
|
|
104
|
-
: all(dialect.introspect.indexColumns(String(keyIndex.name))), (keyRows) =>
|
|
105
|
+
: all(dialect.introspect.indexColumns(String(keyIndex.name))), (keyRows) =>
|
|
106
|
+
chain(dialect.introspect.checks === undefined ? []
|
|
107
|
+
: all(dialect.introspect.checks(table)), (checkRows) => ({
|
|
105
108
|
name: table,
|
|
106
109
|
primaryKey: keyRows.map((row) => String(row.name)),
|
|
107
110
|
columns: columnRows.map((row) => ({
|
|
@@ -111,6 +114,7 @@ function readTable(connection, table) {
|
|
|
111
114
|
})),
|
|
112
115
|
generated: dialect.readGenerated(generatedRows),
|
|
113
116
|
indexes,
|
|
117
|
+
checks: dialect.readChecks?.(checkRows) ?? [],
|
|
114
118
|
foreignKeys: fkRows.map((row) => ({
|
|
115
119
|
column: String(row.source_column),
|
|
116
120
|
target: String(row.target),
|
|
@@ -118,7 +122,7 @@ function readTable(connection, table) {
|
|
|
118
122
|
? null : String(row.target_column),
|
|
119
123
|
onDelete: String(row.on_delete ?? 'NO ACTION').toUpperCase(),
|
|
120
124
|
})),
|
|
121
|
-
})));
|
|
125
|
+
}))));
|
|
122
126
|
}))));
|
|
123
127
|
}
|
|
124
128
|
|
|
@@ -229,6 +233,11 @@ function deriveCollection(dialect, table, report, keys, functionNames) {
|
|
|
229
233
|
|
|
230
234
|
const indexes = [];
|
|
231
235
|
for (const index of table.indexes) {
|
|
236
|
+
if (index.partial || index.columns.some((column) => column === null)) {
|
|
237
|
+
report(loss('unmapped-index', `${table.name}.${index.name}`,
|
|
238
|
+
'a partial predicate or expression term cannot be represented by an unconditional member index'));
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
232
241
|
if (index.columns.length === 1 && computed.has(index.columns[0])) {
|
|
233
242
|
const declared = { name: indexName(table.name, index.name),
|
|
234
243
|
expression: computed.get(index.columns[0]) };
|
|
@@ -416,9 +425,10 @@ function deriveEntity(dialect, table, report) {
|
|
|
416
425
|
const uniqueColumns = new Set();
|
|
417
426
|
const indexedColumns = new Set();
|
|
418
427
|
for (const index of table.indexes) {
|
|
419
|
-
if (index.columns.
|
|
428
|
+
if (index.partial || index.columns.some((column) => column === null)
|
|
429
|
+
|| index.columns.length !== 1) {
|
|
420
430
|
report(loss('unmapped-index', `${table.name}.${index.name}`,
|
|
421
|
-
'a
|
|
431
|
+
'a partial, expression or composite entity index is not a property-level declaration'));
|
|
422
432
|
continue;
|
|
423
433
|
}
|
|
424
434
|
(index.unique ? uniqueColumns : indexedColumns).add(index.columns[0]);
|
|
@@ -461,6 +471,26 @@ function deriveEntity(dialect, table, report) {
|
|
|
461
471
|
if (Object.keys(entity).length > 0) property['x-entity'] = entity;
|
|
462
472
|
properties[column.name] = property;
|
|
463
473
|
}
|
|
474
|
+
for (const check of table.checks) {
|
|
475
|
+
const property = properties[check.column];
|
|
476
|
+
const family = property?.type === 'integer' ? 'number'
|
|
477
|
+
: property?.type === 'string' ? 'string' : property?.type;
|
|
478
|
+
if (check.values === undefined || property === undefined
|
|
479
|
+
|| check.values.some((value) => typeof value !== family
|
|
480
|
+
|| (property.type === 'integer' && !Number.isInteger(value)))) {
|
|
481
|
+
report(loss('unmapped-constraint', `${table.name}.${check.name}`,
|
|
482
|
+
'the CHECK is not a complete scalar enum of the mapped column type'));
|
|
483
|
+
continue;
|
|
484
|
+
}
|
|
485
|
+
// Several enum CHECKs constrain the same column by intersection.
|
|
486
|
+
const values = property.enum === undefined ? check.values
|
|
487
|
+
: property.enum.filter((value) => check.values.includes(value));
|
|
488
|
+
if (values.length === 0) {
|
|
489
|
+
report(loss('unmapped-constraint', `${table.name}.${check.name}`,
|
|
490
|
+
'the enum CHECKs have an empty intersection, which a non-empty schema enum cannot declare'));
|
|
491
|
+
}
|
|
492
|
+
else property.enum = values;
|
|
493
|
+
}
|
|
464
494
|
report(loss('document-members', table.name,
|
|
465
495
|
'an entity\'s document column holds every property the mapping did not give a column, '
|
|
466
496
|
+ 'and those are not in the physical shape'));
|
|
@@ -530,6 +560,8 @@ export function introspectModel(connection, options = undefined) {
|
|
|
530
560
|
if (joinTables.has(table.name)) continue;
|
|
531
561
|
if (looksLikeCollection(dialect, table)) {
|
|
532
562
|
collections[table.name] = deriveCollection(dialect, table, add, options?.keys, byName);
|
|
563
|
+
for (const check of table.checks) add(loss('unmapped-constraint', `${table.name}.${check.name}`,
|
|
564
|
+
'a CHECK over physical collection columns does not constrain the document schema'));
|
|
533
565
|
continue;
|
|
534
566
|
}
|
|
535
567
|
const hasDocument = table.columns.some((column) => column.name === DOC_COLUMN);
|
package/src/live-nested.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
//@ts-check
|
|
2
|
-
/**
|
|
2
|
+
/** Collection groups use bounded per-group recomputation over maintained leaves. */
|
|
3
3
|
import { compileJsonQuery } from '@jarenjs/json/query';
|
|
4
4
|
import { stableStringify } from '@jarenjs/core/object';
|
|
5
5
|
import { DbRuntimeError } from './errors.js';
|
|
@@ -47,6 +47,8 @@ export function nestedGroupStrategy(description, context) {
|
|
|
47
47
|
const keyOf = compileJsonQuery([{ $for: { [binding]: '$[*]' },
|
|
48
48
|
...(inner.$where === undefined ? {} : { $where: inner.$where }), $return: [key] }]);
|
|
49
49
|
const evaluate = compileJsonQuery([inner]);
|
|
50
|
+
const aggregate = description.aggregate === undefined ? null
|
|
51
|
+
: compileJsonQuery([{ [description.aggregate]: '$[*]' }]);
|
|
50
52
|
const docs = new Map();
|
|
51
53
|
const groups = new Map();
|
|
52
54
|
const results = new Map();
|
|
@@ -55,18 +57,25 @@ export function nestedGroupStrategy(description, context) {
|
|
|
55
57
|
let resultCount = 0;
|
|
56
58
|
const bytesOf = (value) => utf8Length(stableStringify(value));
|
|
57
59
|
const stats = { refreshedGroups: 0, dependencyReads: 0, reruns: 0 };
|
|
58
|
-
const groupOf = (doc) =>
|
|
60
|
+
const groupOf = (doc) => {
|
|
61
|
+
const keys = keyOf([doc], context.externals);
|
|
62
|
+
return keys.length === 0 ? null : stableStringify(keys);
|
|
63
|
+
};
|
|
59
64
|
const sorted = (keys) => [...keys].sort((a, b) => positions.get(a) - positions.get(b));
|
|
60
65
|
const check = () => {
|
|
61
|
-
const entries = docs.size + resultCount;
|
|
66
|
+
const entries = docs.size + resultCount + (aggregate === null ? 0 : 1);
|
|
62
67
|
if (entries > context.maxMaintained) throw new DbRuntimeError('JD2060', 'nested group dependencies exceed live.maxMaintained');
|
|
63
68
|
if (maintainedBytes > context.maxBytes) throw new DbRuntimeError('JD2060', 'nested group dependencies exceed live.maxBytes');
|
|
64
69
|
return entries;
|
|
65
70
|
};
|
|
66
71
|
const add = (token, doc) => {
|
|
67
72
|
const group = groupOf(doc);
|
|
73
|
+
if (group === null) return null;
|
|
74
|
+
const bytes = bytesOf(doc);
|
|
75
|
+
if (docs.size + resultCount + 1 > context.maxMaintained || maintainedBytes + bytes > context.maxBytes)
|
|
76
|
+
throw new DbRuntimeError('JD2060', 'group dependencies exceed live state credits');
|
|
68
77
|
docs.set(token, doc);
|
|
69
|
-
maintainedBytes +=
|
|
78
|
+
maintainedBytes += bytes;
|
|
70
79
|
positions.set(token, context.rowPosition(token));
|
|
71
80
|
if (!groups.has(group)) groups.set(group, new Set());
|
|
72
81
|
groups.get(group).add(token);
|
|
@@ -87,20 +96,25 @@ export function nestedGroupStrategy(description, context) {
|
|
|
87
96
|
stats.refreshedGroups++;
|
|
88
97
|
check();
|
|
89
98
|
};
|
|
90
|
-
const flatten = () =>
|
|
91
|
-
|
|
92
|
-
|
|
99
|
+
const flatten = () => {
|
|
100
|
+
const rows = [...groups.keys()].sort((a, b) =>
|
|
101
|
+
positions.get(sorted(groups.get(a))[0]) - positions.get(sorted(groups.get(b))[0]))
|
|
102
|
+
.flatMap((group) => results.get(group) ?? []);
|
|
103
|
+
return aggregate === null ? rows : aggregate(rows, context.externals);
|
|
104
|
+
};
|
|
93
105
|
return {
|
|
94
106
|
close() { docs.clear(); groups.clear(); results.clear(); positions.clear(); },
|
|
95
107
|
init() {
|
|
96
|
-
const
|
|
108
|
+
const carrier = description.carrier ?? { $for: { [binding]: '$[*]' },
|
|
109
|
+
...(inner.$where === undefined ? {} : { $where: inner.$where }), $return: `$${binding}` };
|
|
110
|
+
const source = [{ $subsequence: [carrier, 0, context.maxMaintained + 1] }];
|
|
97
111
|
for (const doc of context.execute(source, { externals: context.externals })) add(context.keyOf(doc), doc);
|
|
98
112
|
check();
|
|
99
113
|
for (const group of groups.keys()) refresh(group);
|
|
100
114
|
return flatten();
|
|
101
115
|
}, entries: check, stats: () => ({ ...stats }),
|
|
102
116
|
apply(record, previousRows) {
|
|
103
|
-
const changes = context.touchedKeys(record, { whole: true, members: new Set() });
|
|
117
|
+
const changes = context.touchedKeys(record, description.deps ?? { whole: true, members: new Set() });
|
|
104
118
|
if (changes === null) return null;
|
|
105
119
|
const affected = new Set();
|
|
106
120
|
for (const token of changes.keys()) {
|
|
@@ -110,7 +124,10 @@ export function nestedGroupStrategy(description, context) {
|
|
|
110
124
|
docs.delete(token); positions.delete(token); maintainedBytes -= bytesOf(previous);
|
|
111
125
|
}
|
|
112
126
|
const doc = context.readRow(token); stats.dependencyReads++;
|
|
113
|
-
if (doc !== undefined)
|
|
127
|
+
if (doc !== undefined) {
|
|
128
|
+
const group = add(token, doc);
|
|
129
|
+
if (group !== null) affected.add(group);
|
|
130
|
+
}
|
|
114
131
|
}
|
|
115
132
|
check();
|
|
116
133
|
for (const group of affected) refresh(group);
|