@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.
@@ -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
+ }
@@ -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' "
@@ -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
- `${dialect.groupAggregate(entry.fn, foldValue(entry.fn, entry.ref))} `
444
- + `AS ${q(`a${i}`)}`)].join(', ')
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
- sql += ` ORDER BY ${plan.group.order === 'first-seen'
490
- ? dialect.groupAggregate('min', dialect.rowIdentity())
491
- : plan.group.order.map((term) => `${q(`vk${term.index}`)} `
492
- + `${term.desc ? 'DESC' : 'ASC'}${dialect.orderNulls(term.nullsFirst)}`).join(', ')}`;
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 && plan.group === 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
- emitters.emitPred(aliasOf(bindingName), docOf(bindingName), pred);
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
- columns: rows.map((row) => String(row.name)),
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.length !== 1) {
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 composite index over an entity is not a property-level declaration'));
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);
@@ -1,5 +1,5 @@
1
1
  //@ts-check
2
- /** Two-level groups use bounded per-parent recomputation over maintained leaves. */
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) => stableStringify(keyOf([doc], context.externals));
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 += bytesOf(doc);
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 = () => [...groups.keys()].sort((a, b) =>
91
- positions.get(sorted(groups.get(a))[0]) - positions.get(sorted(groups.get(b))[0]))
92
- .flatMap((group) => results.get(group) ?? []);
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 source = [{ $subsequence: [{ $for: { it: '$[*]' }, $return: '$it' }, 0, context.maxMaintained + 1] }];
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) affected.add(add(token, doc));
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);