@jarenjs/db 0.75.0 → 0.83.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +20 -0
- package/README.md +25 -0
- package/docs/HOSTS.md +17 -0
- package/docs/JOBS-FORMAT.md +26 -0
- package/docs/LIVE-FORMAT.md +4 -0
- package/docs/MIGRATION-FORMAT.md +34 -0
- package/docs/MODEL-FORMAT.md +132 -1
- package/docs/NATIVE-PLANS.md +111 -0
- 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 +17 -1
- 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/dialect.js +10 -0
- package/src/dialects/check-read.js +3 -3
- package/src/dialects/invariant-sql.js +117 -0
- package/src/dialects/postgres.js +14 -2
- package/src/dialects/sqlite.js +18 -2
- package/src/driver.js +1 -0
- package/src/drivers/bun.js +22 -4
- package/src/emit.js +80 -13
- 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 +44 -7
- package/src/invariants.js +45 -0
- package/src/jobs.js +39 -6
- package/src/live.js +4 -1
- 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 +114 -20
- package/src/query.js +144 -70
- 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 +58 -3
- package/types/search.d.ts +20 -0
- package/types/typed.d.ts +1 -0
package/src/physical.js
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/** Explicit column layouts and lossless JSON-facing column codecs. */
|
|
3
|
+
import { getEpochOfDateTimeRFC3339, getEpochOfDateOnlyRFC3339 } from '@jarenjs/core/dates/rfc3339';
|
|
4
|
+
import { DbCompileError, DbRuntimeError } from './errors.js';
|
|
5
|
+
import { chain } from './driver.js';
|
|
6
|
+
import { canonicalizeJson } from '@jarenjs/json/canonical';
|
|
7
|
+
|
|
8
|
+
const compiledCodecs = new WeakMap();
|
|
9
|
+
const CODECS = new Set(['text', 'integer', 'number', 'boolean', 'json', 'date', 'datetime', 'epoch-ms', 'bigint', 'decimal', 'blob-hex']);
|
|
10
|
+
const STORAGE = { text: 'string', integer: 'integer', number: 'number', boolean: 'boolean',
|
|
11
|
+
json: 'string', date: 'string', datetime: 'string', 'epoch-ms': 'integer', bigint: 'integer', decimal: 'string', 'blob-hex': 'string' };
|
|
12
|
+
const identifier = (v) => typeof v === 'string' && v.length > 0 && !v.includes('\0');
|
|
13
|
+
|
|
14
|
+
/** Normalize a declared layout; implicit conversions are never adoption policy.
|
|
15
|
+
* @param {any} physical @param {Map<string, any>} properties @param {string[]} keys
|
|
16
|
+
* @param {string} path @returns {any} */
|
|
17
|
+
export function normalizePhysical(physical, properties, keys, path) {
|
|
18
|
+
if (physical === undefined) return null;
|
|
19
|
+
const fail = (reason) => { throw new DbCompileError('JD0005', reason, `${path}/physical`); };
|
|
20
|
+
if (!physical || typeof physical !== 'object' || Array.isArray(physical)) fail('physical must be an object');
|
|
21
|
+
for (const key of Object.keys(physical))
|
|
22
|
+
if (!['table', 'kind', 'keys', 'columns'].includes(key)) fail(`unknown physical member '${key}'`);
|
|
23
|
+
if (!identifier(physical.table)) fail('physical.table must be a nonempty SQL identifier');
|
|
24
|
+
if (physical.kind !== undefined && !['table', 'view'].includes(physical.kind)) fail('physical.kind is table or view');
|
|
25
|
+
if (!physical.columns || typeof physical.columns !== 'object' || Array.isArray(physical.columns)) fail('physical.columns is required');
|
|
26
|
+
const ordered = physical.keys ?? keys;
|
|
27
|
+
if (!Array.isArray(ordered) || ordered.length !== keys.length || new Set(ordered).size !== keys.length
|
|
28
|
+
|| ordered.some((key) => !keys.includes(key))) fail('physical.keys must order every declared key exactly once');
|
|
29
|
+
const used = new Set();
|
|
30
|
+
const columns = [];
|
|
31
|
+
for (const [name, property] of properties) {
|
|
32
|
+
if (property.relation) continue;
|
|
33
|
+
const c = physical.columns[name];
|
|
34
|
+
if (!c || typeof c !== 'object' || Array.isArray(c) || !CODECS.has(c.codec)) fail(`'${name}' needs an explicit supported column codec`);
|
|
35
|
+
for (const key of Object.keys(c))
|
|
36
|
+
if (!['name', 'codec', 'null', 'default', 'generated'].includes(key)) fail(`unknown column member '${key}'`);
|
|
37
|
+
if (!identifier(c.name) || used.has(c.name.toLowerCase())) fail(`'${name}' needs a distinct physical column name`);
|
|
38
|
+
if (!['null', 'absent', 'reject'].includes(c.null)) fail(`'${name}' must declare SQL NULL as null, absent or reject`);
|
|
39
|
+
if (c.default !== undefined && c.default !== 'database') fail('column default ownership is database');
|
|
40
|
+
if (c.generated !== undefined && typeof c.generated !== 'boolean') fail('generated must be boolean');
|
|
41
|
+
if (property.key && (!['text', 'integer', 'bigint'].includes(c.codec) || c.null !== 'reject')) fail('keys require non-null text, integer or bigint codecs');
|
|
42
|
+
if (property.column !== undefined) fail('physical codecs replace hybrid column overrides');
|
|
43
|
+
if (c.default === 'database' && property.default !== undefined && property.default !== 'auto') fail('a default has exactly one owner');
|
|
44
|
+
used.add(c.name.toLowerCase());
|
|
45
|
+
columns.push({ name, physical: c.name, codec: c.codec, null: c.null, databaseDefault: c.default === 'database',
|
|
46
|
+
generated: c.generated === true, storage: STORAGE[c.codec], source: 'column', key: property.key });
|
|
47
|
+
}
|
|
48
|
+
for (const name of Object.keys(physical.columns))
|
|
49
|
+
if (!properties.has(name) || properties.get(name).relation) fail(`column '${name}' is not a stored property`);
|
|
50
|
+
return { table: physical.table, kind: physical.kind ?? 'table', keys: [...ordered], columns };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Compile one codec once, with a JSON-safe public value and a bound SQL value.
|
|
54
|
+
* @param {any} column @returns {{ encode: Function, decode: Function, normalize: Function }} */
|
|
55
|
+
export function columnCodec(column) {
|
|
56
|
+
if (compiledCodecs.has(column)) return compiledCodecs.get(column);
|
|
57
|
+
const fail = () => { throw new DbRuntimeError('JD2003', `column '${column.name}' refuses a lossy or invalid ${column.codec} value`); };
|
|
58
|
+
const date = (v, only) => typeof v === 'string' && Number.isFinite(only
|
|
59
|
+
? getEpochOfDateOnlyRFC3339(v) : getEpochOfDateTimeRFC3339(v));
|
|
60
|
+
const convert = (v, reading) => {
|
|
61
|
+
if (v === undefined || v === null) {
|
|
62
|
+
if (!reading && v === null && column.codec === 'json') return 'null';
|
|
63
|
+
if (column.null === 'reject') return fail();
|
|
64
|
+
return reading ? (column.null === 'absent' ? undefined : null) : null;
|
|
65
|
+
}
|
|
66
|
+
switch (column.codec) {
|
|
67
|
+
case 'text': if (typeof v === 'string') return v; break;
|
|
68
|
+
case 'integer': if (typeof v === 'number' && Number.isSafeInteger(v)) return v; break;
|
|
69
|
+
case 'number': if (typeof v === 'number' && Number.isFinite(v) && (!Number.isInteger(v) || Number.isSafeInteger(v))) return v; break;
|
|
70
|
+
case 'boolean':
|
|
71
|
+
if (reading && (v === 0 || v === 1 || typeof v === 'boolean')) return v === 1 || v === true;
|
|
72
|
+
if (!reading && typeof v === 'boolean') return v ? 1 : 0;
|
|
73
|
+
break;
|
|
74
|
+
case 'bigint': if (typeof v === 'string' && /^(?:0|-?[1-9][0-9]*)$/.test(v)
|
|
75
|
+
&& BigInt(v) >= -9223372036854775808n && BigInt(v) <= 9223372036854775807n) return v; break;
|
|
76
|
+
case 'decimal': if (typeof v === 'string' && /^-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?$/.test(v)) return v; break;
|
|
77
|
+
case 'date': if (date(v, true)) return v; break;
|
|
78
|
+
case 'datetime': if (date(v, false)) return v; break;
|
|
79
|
+
case 'epoch-ms':
|
|
80
|
+
if (reading && typeof v === 'number' && Number.isSafeInteger(v) && Number.isFinite(new Date(v).getTime())) return new Date(v).toISOString();
|
|
81
|
+
if (!reading && date(v, false) && new Date(v).toISOString() === v) return getEpochOfDateTimeRFC3339(v);
|
|
82
|
+
break;
|
|
83
|
+
case 'blob-hex':
|
|
84
|
+
if (typeof v === 'string' && /^(?:[0-9a-fA-F]{2})*$/.test(v))
|
|
85
|
+
return reading ? v.toLowerCase() : Uint8Array.from(v.match(/../g) ?? [], (b) => parseInt(b, 16));
|
|
86
|
+
break;
|
|
87
|
+
case 'json':
|
|
88
|
+
try {
|
|
89
|
+
if (reading) return JSON.parse(v);
|
|
90
|
+
canonicalizeJson(v);
|
|
91
|
+
const text = JSON.stringify(v);
|
|
92
|
+
if (text !== undefined && JSON.stringify(JSON.parse(text)) === text) return text;
|
|
93
|
+
}
|
|
94
|
+
catch { return fail(); }
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
return fail();
|
|
98
|
+
};
|
|
99
|
+
const codec = { encode: (v) => convert(v, false), decode: (v) => convert(v, true), normalize: (v) => {
|
|
100
|
+
const encoded = convert(v, false);
|
|
101
|
+
return column.codec === 'blob-hex' && encoded !== null ? v.toLowerCase() : convert(encoded, true);
|
|
102
|
+
} };
|
|
103
|
+
compiledCodecs.set(column, codec);
|
|
104
|
+
return codec;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** SQL selection keeps unsafe integers and byte handles out of application state.
|
|
108
|
+
* @param {any} column @param {any} dialect @param {string} [prefix] @returns {string} */
|
|
109
|
+
export function physicalRead(column, dialect, prefix = '') {
|
|
110
|
+
const sql = `${prefix}${dialect.quoteIdentifier(column.physical ?? column.name)}`;
|
|
111
|
+
return dialect.physicalRead(column.codec, sql);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Verify an existing mapped object without executing DDL. Unmapped columns and
|
|
115
|
+
* all application-owned programs remain physical facts, never inferred drops.
|
|
116
|
+
* @param {any} connection @param {any} mapping @param {any} schema @returns {any} */
|
|
117
|
+
export function verifyPhysical(connection, mapping, schema) {
|
|
118
|
+
const fail = (why) => { throw new DbCompileError('JD0002', `physical '${mapping.table}': ${why}`); };
|
|
119
|
+
if (connection.dialect.name !== 'sqlite') fail('column adoption is qualified only for SQLite');
|
|
120
|
+
const object = schema.objects.find((o) => o.name === mapping.table && o.type === mapping.kind);
|
|
121
|
+
if (!object) fail(`declared ${mapping.kind} does not exist`);
|
|
122
|
+
for (const trigger of mapping.triggers ?? []) {
|
|
123
|
+
const actual = schema.objects.find((o) => o.type === 'trigger' && o.name === trigger.name);
|
|
124
|
+
if (!actual || actual.sql?.trim().replace(/;$/, '') !== trigger.sql.trim().replace(/;$/, '')) fail(`invariant trigger '${trigger.name}' is missing or changed; apply an explicit migration`);
|
|
125
|
+
}
|
|
126
|
+
const read = mapping.kind === 'view'
|
|
127
|
+
? chain(connection.prepare(connection.dialect.introspect.columns(mapping.table)), (s) =>
|
|
128
|
+
chain(s.all([]), (columns) => ({ columns: columns.map((c) => ({ ...c, generated: !!c.hidden })), primaryKey: [] })))
|
|
129
|
+
: schema.tables.find((t) => t.name === mapping.table);
|
|
130
|
+
return chain(read, (table) => {
|
|
131
|
+
if (mapping.kind !== 'view' && JSON.stringify(table.primaryKey) !== JSON.stringify(mapping.keys.map((k) => mapping.columns.find((c) => c.name === k).physical))) fail('ordered primary key disagrees');
|
|
132
|
+
for (const column of mapping.columns) {
|
|
133
|
+
const actual = table.columns.find((c) => c.name === column.physical);
|
|
134
|
+
if (!actual) fail(`column '${column.physical}' does not exist`);
|
|
135
|
+
const type = actual.type.toUpperCase();
|
|
136
|
+
if (!connection.dialect.physicalTypeMatches(column.codec, type)) fail(`'${column.physical}' type ${type} cannot guarantee codec ${column.codec}`);
|
|
137
|
+
if (mapping.kind !== 'view' && actual.generated !== column.generated) fail(`'${column.physical}' generated ownership disagrees`);
|
|
138
|
+
}
|
|
139
|
+
return null;
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Select mapped columns with their physical aliases for the shared row merger.
|
|
144
|
+
* @param {any} mapping @param {any} dialect @param {string} [prefix] @returns {string} */
|
|
145
|
+
export function physicalSelection(mapping, dialect, prefix = '') {
|
|
146
|
+
return mapping.columns.map((c) => `${physicalRead(c, dialect, prefix)} AS ${dialect.quoteIdentifier(c.physical ?? c.name)}`).join(', ');
|
|
147
|
+
}
|
package/src/plan.js
CHANGED
|
@@ -95,6 +95,7 @@ const KIND_REASONS = {
|
|
|
95
95
|
|
|
96
96
|
/** Why one PREDICATE stayed in the engine. */
|
|
97
97
|
const PREDICATE_REASONS = {
|
|
98
|
+
physicalCodec: 'this codec or NULL policy requires decoded-row evaluation',
|
|
98
99
|
notPredicate: 'not a predicate the planner translates',
|
|
99
100
|
negatedPrefilter: 'a negated predicate cannot ride an implied pre-filter '
|
|
100
101
|
+ '(negating a superset drops rows)',
|
|
@@ -187,7 +188,8 @@ const ENTITY_REASONS = {
|
|
|
187
188
|
+ 'a binding nothing connects is a cartesian product, which is engine work',
|
|
188
189
|
conjunctBinding: 'a conjunct must belong to one binding (or be the single join equality)',
|
|
189
190
|
external: 'externals compare only against entity columns in this version',
|
|
190
|
-
projection: 'entity
|
|
191
|
+
projection: 'this entity return or grouping needs decoded-row evaluation',
|
|
192
|
+
groupOrder: 'first-seen grouping over a compound physical key requires tuple ordering',
|
|
191
193
|
order: 'ordering translates only over typed entity paths (never a boolean, never a document '
|
|
192
194
|
+ 'path that admits null)',
|
|
193
195
|
};
|
|
@@ -2093,12 +2095,12 @@ function planSeriesOperator(root, shape) {
|
|
|
2093
2095
|
* @param {any} shape
|
|
2094
2096
|
* @returns {any | null}
|
|
2095
2097
|
*/
|
|
2096
|
-
function planGeneralGrouping(node, itSlot, shape) {
|
|
2098
|
+
function planGeneralGrouping(node, itSlot, shape, resolve = pathRef) {
|
|
2097
2099
|
const keys = [];
|
|
2098
2100
|
/** @type {Map<number, number>} */
|
|
2099
2101
|
const keySlot = new Map();
|
|
2100
2102
|
for (const key of node.groupby.keys) {
|
|
2101
|
-
const ref =
|
|
2103
|
+
const ref = resolve(key.expr, itSlot, shape);
|
|
2102
2104
|
if (ref === null || ref.type === 'unknown')
|
|
2103
2105
|
return null;
|
|
2104
2106
|
keySlot.set(key.slot, keys.length);
|
|
@@ -2113,7 +2115,7 @@ function planGeneralGrouping(node, itSlot, shape) {
|
|
|
2113
2115
|
if (child.kind === 'var' && child.external !== true && keySlot.has(child.slot))
|
|
2114
2116
|
return { p: 'key', index: keySlot.get(child.slot) };
|
|
2115
2117
|
if (child.kind === 'op') {
|
|
2116
|
-
const entry = groupAggregate(child, itSlot, shape);
|
|
2118
|
+
const entry = groupAggregate(child, itSlot, shape, resolve);
|
|
2117
2119
|
if (entry === null) return null;
|
|
2118
2120
|
// one aggregate per distinct (function, path): two members that
|
|
2119
2121
|
// ask the same question are one SQL aggregate
|
|
@@ -2167,14 +2169,36 @@ function planGeneralGrouping(node, itSlot, shape) {
|
|
|
2167
2169
|
* @param {any} shape
|
|
2168
2170
|
* @returns {{ as: string, fn: string, ref: any, empty: string } | null}
|
|
2169
2171
|
*/
|
|
2170
|
-
function groupAggregate(node, itSlot, shape) {
|
|
2172
|
+
function groupAggregate(node, itSlot, shape, resolve = pathRef) {
|
|
2173
|
+
// SQL's nullable sum is authored explicitly: test for any non-null
|
|
2174
|
+
// values, sum exactly that sequence, otherwise return a literal null.
|
|
2175
|
+
if (node.name === '$if' && node.args[0]?.name === '$exists'
|
|
2176
|
+
&& node.args[1]?.name === '$sum' && node.args[2]?.kind === 'literal' && node.args[2].value === null) {
|
|
2177
|
+
const filtered = (phrase) => {
|
|
2178
|
+
if (phrase?.kind !== 'flwor' || phrase.forBindings.length !== 1 || phrase.letBindings.length
|
|
2179
|
+
|| phrase.groupby !== null || phrase.orderby !== null || phrase.fold !== null
|
|
2180
|
+
|| phrase.count !== null || phrase.asChecks !== null) return null;
|
|
2181
|
+
const binding = phrase.forBindings[0];
|
|
2182
|
+
if (!isItVar(unpacked(binding.expr), itSlot) || binding.window !== null || binding.atSlot !== -1 || binding.allowingEmpty) return null;
|
|
2183
|
+
const ref = resolve(phrase.ret, binding.slot, shape);
|
|
2184
|
+
const where = phrase.where;
|
|
2185
|
+
if (ref === null || !isNumericType(ref.type) || where?.name !== '$ne') return null;
|
|
2186
|
+
const other = resolve(where.args[0], binding.slot, shape);
|
|
2187
|
+
return other !== null && canonicalOf(other.segments) === canonicalOf(ref.segments)
|
|
2188
|
+
&& where.args[1]?.kind === 'literal' && where.args[1].value === null ? ref : null;
|
|
2189
|
+
};
|
|
2190
|
+
const test = filtered(node.args[0].args[0]);
|
|
2191
|
+
const sum = filtered(node.args[1].args[0]);
|
|
2192
|
+
return test !== null && sum !== null && canonicalOf(test.segments) === canonicalOf(sum.segments)
|
|
2193
|
+
? { fn: 'sum', ref: sum, empty: 'null' } : null;
|
|
2194
|
+
}
|
|
2171
2195
|
if (node.name === '$count') {
|
|
2172
2196
|
return isItVar(node.args[0], itSlot)
|
|
2173
2197
|
? { fn: 'rows', ref: null, empty: 'zero' } : null;
|
|
2174
2198
|
}
|
|
2175
2199
|
const fn = AGGREGATES.get(node.name);
|
|
2176
2200
|
if (fn === undefined || fn === 'count') return null;
|
|
2177
|
-
const ref =
|
|
2201
|
+
const ref = resolve(node.args[0], itSlot, shape);
|
|
2178
2202
|
const numeric = fn === 'sum' || fn === 'avg';
|
|
2179
2203
|
const acceptable = ref !== null
|
|
2180
2204
|
&& (numeric ? isNumericType(ref.type) : ref.type !== 'unknown')
|
|
@@ -2958,10 +2982,14 @@ export function entityShape(entity, entityMapping) {
|
|
|
2958
2982
|
for (const column of entityMapping.columns) {
|
|
2959
2983
|
const epoch = column.source === 'epoch(document)';
|
|
2960
2984
|
flavors.set(canonicalOf([{ name: column.name }]), {
|
|
2961
|
-
column: column.name,
|
|
2985
|
+
column: column.physical ?? column.name,
|
|
2962
2986
|
flavor: epoch ? 'entity-epoch' : 'entity-column',
|
|
2963
2987
|
storage: column.storage,
|
|
2988
|
+
codec: column.codec,
|
|
2989
|
+
codecColumn: column.codec === undefined ? undefined : column,
|
|
2990
|
+
nullPolicy: column.null,
|
|
2964
2991
|
format: epoch ? entity.properties.get(column.name)?.format : undefined,
|
|
2992
|
+
unsafe: column.codec !== undefined && !['text', 'integer', 'number', 'boolean', 'date', 'datetime'].includes(column.codec),
|
|
2965
2993
|
});
|
|
2966
2994
|
}
|
|
2967
2995
|
for (const fk of entityMapping.foreignKeys) {
|
|
@@ -2974,6 +3002,7 @@ export function entityShape(entity, entityMapping) {
|
|
|
2974
3002
|
schema: entity.schema,
|
|
2975
3003
|
columnByCanonical: new Map(),
|
|
2976
3004
|
entityFlavors: flavors,
|
|
3005
|
+
columnOnly: entityMapping.document === false,
|
|
2977
3006
|
};
|
|
2978
3007
|
}
|
|
2979
3008
|
|
|
@@ -2990,6 +3019,7 @@ export function entityPathRef(node, slot, shape) {
|
|
|
2990
3019
|
if (ref === null) return null;
|
|
2991
3020
|
const canonical = canonicalOf(ref.segments);
|
|
2992
3021
|
const flavored = shape.entityFlavors.get(canonical);
|
|
3022
|
+
if (flavored?.unsafe || (shape.columnOnly && flavored === undefined)) return null;
|
|
2993
3023
|
if (flavored !== undefined) {
|
|
2994
3024
|
return {
|
|
2995
3025
|
...ref,
|
|
@@ -2997,6 +3027,9 @@ export function entityPathRef(node, slot, shape) {
|
|
|
2997
3027
|
flavor: flavored.flavor,
|
|
2998
3028
|
storage: flavored.storage,
|
|
2999
3029
|
format: flavored.format,
|
|
3030
|
+
nullPolicy: flavored.nullPolicy,
|
|
3031
|
+
codec: flavored.codec,
|
|
3032
|
+
codecColumn: flavored.codecColumn,
|
|
3000
3033
|
};
|
|
3001
3034
|
}
|
|
3002
3035
|
// a nested path rides the JSONB document with the phase-A guards;
|
|
@@ -3015,13 +3048,48 @@ export function entityPathRef(node, slot, shape) {
|
|
|
3015
3048
|
* @param {Map<number, any>} byName - binding slot → binding
|
|
3016
3049
|
* @returns {{ tree: any, leaves: { binding: string, ref: any }[] } | null}
|
|
3017
3050
|
*/
|
|
3018
|
-
function entityProjectionTree(node, byName) {
|
|
3051
|
+
function entityProjectionTree(node, byName, entities, mapping) {
|
|
3019
3052
|
const leaves = [];
|
|
3020
3053
|
/** @type {Map<string, number>} */
|
|
3021
3054
|
const byCanonical = new Map();
|
|
3022
3055
|
const build = (child) => {
|
|
3023
3056
|
assertDecidedKind(child);
|
|
3024
3057
|
if (child.kind === 'literal') return { p: 'lit', value: child.value };
|
|
3058
|
+
if (child.kind === 'op' && child.name === '$count') {
|
|
3059
|
+
const inner = child.args[0];
|
|
3060
|
+
if (inner?.kind !== 'flwor' || inner.forBindings.length !== 1 || inner.groupby !== null
|
|
3061
|
+
|| inner.fold !== null || inner.orderby !== null || inner.letBindings.length
|
|
3062
|
+
|| inner.asChecks !== null || inner.count !== null) return null;
|
|
3063
|
+
const binding = inner.forBindings[0];
|
|
3064
|
+
const entity = bindingEntity(binding, entities);
|
|
3065
|
+
if (entity === null || binding.window !== null || binding.atSlot !== -1 || binding.allowingEmpty
|
|
3066
|
+
|| inner.ret.kind !== 'var' || inner.ret.slot !== binding.slot) return null;
|
|
3067
|
+
const shape = entityShape(entities.get(entity), mapping.entities[entity]);
|
|
3068
|
+
const innerBinding = { name: binding.name, slot: binding.slot, entity, shape };
|
|
3069
|
+
const scope = new Map([...byName, [binding.slot, innerBinding]]);
|
|
3070
|
+
const conjuncts = inner.where?.kind === 'op' && inner.where.name === '$and' ? inner.where.args : [inner.where];
|
|
3071
|
+
const edges = [];
|
|
3072
|
+
let filter = null;
|
|
3073
|
+
for (const conjunct of conjuncts) {
|
|
3074
|
+
if (conjunct === null) continue;
|
|
3075
|
+
const cross = conjunct.kind === 'op' && conjunct.name === '$eq'
|
|
3076
|
+
? crossBindingComparison(conjunct, scope) : null;
|
|
3077
|
+
if (cross !== null && (cross.left.binding === innerBinding || cross.right.binding === innerBinding)) {
|
|
3078
|
+
edges.push({ left: { binding: cross.left.binding.name, ref: cross.left.ref },
|
|
3079
|
+
right: { binding: cross.right.binding.name, ref: cross.right.ref } });
|
|
3080
|
+
continue;
|
|
3081
|
+
}
|
|
3082
|
+
const slots = new Set(); collectBindingSlots(conjunct, scope, slots);
|
|
3083
|
+
if (slots.size !== 1 || !slots.has(binding.slot)) return null;
|
|
3084
|
+
const planned = planEntityPredicate(conjunct, binding.slot, shape);
|
|
3085
|
+
if ('refusal' in planned) return null;
|
|
3086
|
+
filter = conjoin(filter, planned.pred);
|
|
3087
|
+
}
|
|
3088
|
+
if (!edges.length) return null;
|
|
3089
|
+
const index = leaves.length;
|
|
3090
|
+
leaves.push({ count: { entity, binding: binding.name, edges, filter } });
|
|
3091
|
+
return { p: 'leaf', index };
|
|
3092
|
+
}
|
|
3025
3093
|
if (child.kind === 'path') {
|
|
3026
3094
|
const binding = child.external === true ? undefined : byName.get(child.rootSlot);
|
|
3027
3095
|
if (binding === undefined) return null;
|
|
@@ -3122,6 +3190,10 @@ export function planEntityPredicate(node, slot, shape) {
|
|
|
3122
3190
|
if (!('ref' in pred) || pred.ref === null) return pred;
|
|
3123
3191
|
const canonical = canonicalOf(pred.ref.segments);
|
|
3124
3192
|
const flavored = shape.entityFlavors.get(canonical);
|
|
3193
|
+
if (shape.columnOnly && (flavored === undefined || flavored.unsafe || flavored.nullPolicy === 'null')) {
|
|
3194
|
+
blocked = refusal('physical', PREDICATE_REASONS.physicalCodec);
|
|
3195
|
+
return pred;
|
|
3196
|
+
}
|
|
3125
3197
|
if (flavored === undefined) {
|
|
3126
3198
|
// externals against DOC paths are not translated here (the
|
|
3127
3199
|
// phase-A external forms assume the collection layout)
|
|
@@ -3131,7 +3203,8 @@ export function planEntityPredicate(node, slot, shape) {
|
|
|
3131
3203
|
return { ...pred, ref: { ...pred.ref, flavor: 'entity-doc' } };
|
|
3132
3204
|
}
|
|
3133
3205
|
const ref = { ...pred.ref, column: flavored.column,
|
|
3134
|
-
flavor: flavored.flavor, storage: flavored.storage, format: flavored.format
|
|
3206
|
+
flavor: flavored.flavor, storage: flavored.storage, format: flavored.format,
|
|
3207
|
+
codec: flavored.codec, codecColumn: flavored.codecColumn, nullPolicy: flavored.nullPolicy };
|
|
3135
3208
|
if (flavored.flavor === 'entity-epoch' && pred.p === 'cmp') {
|
|
3136
3209
|
if ('ext' in pred.operand) {
|
|
3137
3210
|
blocked = refusal(pred.op, ENTITY_REASONS.external);
|
|
@@ -3204,7 +3277,7 @@ function planEntityQueryCore(document, entities, mapping, operators) {
|
|
|
3204
3277
|
if (root.kind !== 'flwor')
|
|
3205
3278
|
return residual(root.kind, ENTITY_REASONS.notFlwor);
|
|
3206
3279
|
if (root.fold !== null || root.letBindings.length > 0 || root.asChecks !== null
|
|
3207
|
-
|| root.
|
|
3280
|
+
|| root.count !== null)
|
|
3208
3281
|
return residual('$let', KIND_REASONS.let);
|
|
3209
3282
|
|
|
3210
3283
|
// bindings must each range over one entity's array
|
|
@@ -3222,6 +3295,19 @@ function planEntityQueryCore(document, entities, mapping, operators) {
|
|
|
3222
3295
|
});
|
|
3223
3296
|
}
|
|
3224
3297
|
const byName = new Map(bindings.map((binding) => [binding.slot, binding]));
|
|
3298
|
+
const group = root.groupby === null ? null : bindings.length === 1 && aggregate === null && !windows.length
|
|
3299
|
+
? planGeneralGrouping(root, bindings[0].slot, bindings[0].shape, entityPathRef) : null;
|
|
3300
|
+
if (root.groupby !== null && group === null) return residual('$groupby', ENTITY_REASONS.projection);
|
|
3301
|
+
if (group && [...group.keys, ...group.aggregates].some((entry) => entry.ref && entry.ref.flavor !== 'entity-column'))
|
|
3302
|
+
return residual('$groupby', ENTITY_REASONS.projection);
|
|
3303
|
+
// Integer accumulations carry a runtime exactness proof. Floating
|
|
3304
|
+
// accumulations and date-codec validation need the decoded evaluator.
|
|
3305
|
+
if (group && [...group.keys, ...group.aggregates].some((entry) => entry.ref
|
|
3306
|
+
&& (['date', 'datetime'].includes(entry.ref.codec)
|
|
3307
|
+
|| (['sum', 'avg'].includes(entry.fn) && entry.ref.type !== 'integer'))))
|
|
3308
|
+
return residual('$groupby', ENTITY_REASONS.projection);
|
|
3309
|
+
if (group?.order === 'first-seen' && entities.get(bindings[0].entity).physical?.keys.length > 1)
|
|
3310
|
+
return residual('$groupby', ENTITY_REASONS.groupOrder);
|
|
3225
3311
|
const conjuncts = root.where === null
|
|
3226
3312
|
? []
|
|
3227
3313
|
: root.where.kind === 'op' && root.where.name === '$and'
|
|
@@ -3362,12 +3448,12 @@ function planEntityQueryCore(document, entities, mapping, operators) {
|
|
|
3362
3448
|
// SHAPE the projection tree rebuilds from the bindings' members
|
|
3363
3449
|
const retBinding = root.ret.kind === 'var' && root.ret.external !== true
|
|
3364
3450
|
? byName.get(root.ret.slot) : undefined;
|
|
3365
|
-
const projection = retBinding === undefined
|
|
3366
|
-
? entityProjectionTree(root.ret, byName) : null;
|
|
3367
|
-
if (retBinding === undefined && projection === null) {
|
|
3451
|
+
const projection = retBinding === undefined && group === null
|
|
3452
|
+
? entityProjectionTree(root.ret, byName, entities, mapping) : null;
|
|
3453
|
+
if (retBinding === undefined && projection === null && group === null) {
|
|
3368
3454
|
reasons.push(refusal('$return', ENTITY_REASONS.projection));
|
|
3369
3455
|
}
|
|
3370
|
-
if (projection?.tree.p === 'leaf' && (aggregate === 'count' || windows.length > 0)) {
|
|
3456
|
+
if (projection?.tree.p === 'leaf' && !projection.leaves[projection.tree.index].count && (aggregate === 'count' || windows.length > 0)) {
|
|
3371
3457
|
const leaf = projection.leaves[projection.tree.index];
|
|
3372
3458
|
const binding = bindings.find((entry) => entry.name === leaf.binding);
|
|
3373
3459
|
filters.set(binding.slot, conjoin(filters.get(binding.slot),
|
|
@@ -3377,7 +3463,7 @@ function planEntityQueryCore(document, entities, mapping, operators) {
|
|
|
3377
3463
|
// ordering over flavored refs of either binding
|
|
3378
3464
|
let order = null;
|
|
3379
3465
|
let orderPushed = true;
|
|
3380
|
-
if (root.orderby !== null) {
|
|
3466
|
+
if (root.orderby !== null && group === null) {
|
|
3381
3467
|
const terms = [];
|
|
3382
3468
|
for (const spec of root.orderby.specs) {
|
|
3383
3469
|
const slot = spec.key.kind === 'path' ? spec.key.rootSlot : -1;
|
|
@@ -3389,6 +3475,7 @@ function planEntityQueryCore(document, entities, mapping, operators) {
|
|
|
3389
3475
|
// (a COLUMN stores it absent, §9.3, so a nullable column pushes)
|
|
3390
3476
|
if (ref === null || (ref.flavor === 'entity-doc' && ref.type === 'unknown')
|
|
3391
3477
|
|| ref.type === 'boolean'
|
|
3478
|
+
|| ref.nullPolicy === 'null'
|
|
3392
3479
|
|| (ref.flavor === 'entity-doc' && admitsNull(binding.shape.schema, ref.segments))
|
|
3393
3480
|
|| spec.collation !== null || spec.collationName !== null) {
|
|
3394
3481
|
orderPushed = false;
|
|
@@ -3401,7 +3488,7 @@ function planEntityQueryCore(document, entities, mapping, operators) {
|
|
|
3401
3488
|
}
|
|
3402
3489
|
|
|
3403
3490
|
const fullyPushed = whereFullyPushed && orderPushed
|
|
3404
|
-
&& (retBinding !== undefined || projection !== null);
|
|
3491
|
+
&& (retBinding !== undefined || projection !== null || group !== null);
|
|
3405
3492
|
if (!fullyPushed) {
|
|
3406
3493
|
return { analysis, mode: 'set', plan: null, referenced, reasons };
|
|
3407
3494
|
}
|
|
@@ -3431,7 +3518,11 @@ function planEntityQueryCore(document, entities, mapping, operators) {
|
|
|
3431
3518
|
plan: {
|
|
3432
3519
|
planVersion: PLAN_VERSION,
|
|
3433
3520
|
alg: bindings.length > 1 ? 'entity-join' : 'entity-select',
|
|
3434
|
-
bindings: bindings.map((binding) => ({ name: binding.name, entity: binding.entity
|
|
3521
|
+
bindings: bindings.map((binding) => ({ name: binding.name, entity: binding.entity,
|
|
3522
|
+
...(entities.get(binding.entity).physical == null ? {} : {
|
|
3523
|
+
keys: mapping.entities[binding.entity].keys.map((key) =>
|
|
3524
|
+
mapping.entities[binding.entity].columns.find((c) => c.name === key).physical),
|
|
3525
|
+
}) })),
|
|
3435
3526
|
// the FROM order and each binding's join conditions; `bindings`
|
|
3436
3527
|
// stays in the DOCUMENT's order, which is the nested-loop order
|
|
3437
3528
|
// the ORDER BY reproduces
|
|
@@ -3439,8 +3530,10 @@ function planEntityQueryCore(document, entities, mapping, operators) {
|
|
|
3439
3530
|
binding: join.binding,
|
|
3440
3531
|
on: join.on.map((edge) => ({
|
|
3441
3532
|
op: edge.op ?? 'eq',
|
|
3442
|
-
left: { binding: edge.left.binding.name, column: edge.left.ref.column
|
|
3443
|
-
|
|
3533
|
+
left: { binding: edge.left.binding.name, column: edge.left.ref.column,
|
|
3534
|
+
...(edge.left.ref.codec === undefined ? {} : { codec: edge.left.ref.codec }) },
|
|
3535
|
+
right: { binding: edge.right.binding.name, column: edge.right.ref.column,
|
|
3536
|
+
...(edge.right.ref.codec === undefined ? {} : { codec: edge.right.ref.codec }) },
|
|
3444
3537
|
})),
|
|
3445
3538
|
})),
|
|
3446
3539
|
filters: bindings.map((binding) => ({
|
|
@@ -3453,12 +3546,13 @@ function planEntityQueryCore(document, entities, mapping, operators) {
|
|
|
3453
3546
|
})),
|
|
3454
3547
|
window,
|
|
3455
3548
|
aggregate,
|
|
3549
|
+
group,
|
|
3456
3550
|
ret: retBinding === undefined ? null : retBinding.name,
|
|
3457
3551
|
// the projected shape, when the return is one: leaves that name
|
|
3458
3552
|
// the binding they read from, and the tree the decoder rebuilds
|
|
3459
3553
|
project: projection === null ? null : {
|
|
3460
3554
|
tree: projection.tree,
|
|
3461
|
-
leaves: projection.leaves
|
|
3555
|
+
leaves: projection.leaves,
|
|
3462
3556
|
},
|
|
3463
3557
|
},
|
|
3464
3558
|
};
|