@jarenjs/db 0.34.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 +397 -0
- package/README.md +218 -0
- package/dist/types/algebra.d.ts +133 -0
- package/dist/types/app.d.ts +49 -0
- package/dist/types/capture.d.ts +85 -0
- package/dist/types/cli.d.ts +2 -0
- package/dist/types/dag-job.d.ts +40 -0
- package/dist/types/ddl.d.ts +170 -0
- package/dist/types/dialect.d.ts +130 -0
- package/dist/types/dialects/sqlite.d.ts +9 -0
- package/dist/types/driver.d.ts +128 -0
- package/dist/types/drivers/bun.d.ts +47 -0
- package/dist/types/drivers/node.d.ts +37 -0
- package/dist/types/drivers/wasm.d.ts +65 -0
- package/dist/types/emit-model.d.ts +44 -0
- package/dist/types/emit.d.ts +72 -0
- package/dist/types/entity.d.ts +23 -0
- package/dist/types/errors.d.ts +165 -0
- package/dist/types/graph.d.ts +28 -0
- package/dist/types/index.d.ts +35 -0
- package/dist/types/jobs.d.ts +134 -0
- package/dist/types/live.d.ts +62 -0
- package/dist/types/migrate.d.ts +163 -0
- package/dist/types/model.d.ts +36 -0
- package/dist/types/patch-sql.d.ts +37 -0
- package/dist/types/plan.d.ts +119 -0
- package/dist/types/profile.d.ts +80 -0
- package/dist/types/query.d.ts +100 -0
- package/dist/types/residual.d.ts +50 -0
- package/dist/types/store.d.ts +53 -0
- package/dist/types/tracker.d.ts +43 -0
- package/dist/types/typed.d.ts +15 -0
- package/dist/types/types.d.ts +26 -0
- package/dist/types/udf.d.ts +70 -0
- package/dist/types/window.d.ts +52 -0
- package/docs/JOBS-FORMAT.md +218 -0
- package/docs/LIVE-FORMAT.md +348 -0
- package/docs/MIGRATION-FORMAT.md +302 -0
- package/docs/MODEL-FORMAT.md +928 -0
- package/package.json +81 -0
- package/schemas/jaren-migration.draft-07.schema.json +144 -0
- package/schemas/jaren-migration.schema.json +144 -0
- package/schemas/jaren-model.draft-07.schema.json +149 -0
- package/schemas/jaren-model.schema.json +149 -0
- package/src/algebra.js +105 -0
- package/src/app.js +108 -0
- package/src/capture.js +584 -0
- package/src/cli.js +264 -0
- package/src/dag-job.js +86 -0
- package/src/ddl.js +588 -0
- package/src/dialect.js +297 -0
- package/src/dialects/sqlite.js +175 -0
- package/src/driver.js +419 -0
- package/src/drivers/bun.js +101 -0
- package/src/drivers/node.js +93 -0
- package/src/drivers/wasm.js +178 -0
- package/src/emit-model.js +208 -0
- package/src/emit.js +393 -0
- package/src/entity.js +367 -0
- package/src/errors.js +173 -0
- package/src/graph.js +101 -0
- package/src/index.js +64 -0
- package/src/jobs.js +507 -0
- package/src/live.js +899 -0
- package/src/migrate.js +1411 -0
- package/src/model.js +476 -0
- package/src/patch-sql.js +150 -0
- package/src/plan.js +1038 -0
- package/src/profile.js +131 -0
- package/src/query.js +1010 -0
- package/src/residual.js +91 -0
- package/src/store.js +1422 -0
- package/src/tracker.js +776 -0
- package/src/typed.js +19 -0
- package/src/types.js +36 -0
- package/src/udf.js +132 -0
- package/src/window.js +125 -0
- package/types/app.d.ts +36 -0
- package/types/bun.d.ts +9 -0
- package/types/index.d.ts +592 -0
- package/types/node.d.ts +15 -0
- package/types/typed.d.ts +108 -0
- package/types/wasm.d.ts +5 -0
package/src/query.js
ADDED
|
@@ -0,0 +1,1010 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file The query surface over one collection: the D2 provider
|
|
4
|
+
* (`execute(document, options)` — how a linq chain runs here with no
|
|
5
|
+
* import edge), the streaming cursor (`query`), and `explain()`.
|
|
6
|
+
*
|
|
7
|
+
* The statement cache is a CALLER of the core primitives:
|
|
8
|
+
* `createSemanticCache` keyed by the whole discriminating tuple —
|
|
9
|
+
* document plus collection, dialect, strictness, pushdown and profile.
|
|
10
|
+
* The identity is the tuple's COMPLETE serialization, never a
|
|
11
|
+
* fingerprint of it: a 32-bit content hash collides after tens of
|
|
12
|
+
* thousands of documents, and a collision here answers one query with
|
|
13
|
+
* another query's plan and rows. `store.stats()` exposes hits, misses
|
|
14
|
+
* and evictions so the cache is proven rather than assumed.
|
|
15
|
+
*
|
|
16
|
+
* Bind-time diversion: if any referenced external is missing or not a
|
|
17
|
+
* string or finite number, the call runs the always-compilable set
|
|
18
|
+
* residual over the full collection instead of the native statement —
|
|
19
|
+
* SQLite cannot bind a boolean, a `null` needs Jaren's semantics, and
|
|
20
|
+
* a missing external must raise the ENGINE's error, not a driver's.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { createSemanticCache } from '@jarenjs/core/cache';
|
|
24
|
+
import { compileJsonQuery, analyzeQuery } from '@jarenjs/json/query';
|
|
25
|
+
|
|
26
|
+
import { DbCompileError, DbRuntimeError } from './errors.js';
|
|
27
|
+
import { chain } from './driver.js';
|
|
28
|
+
import {
|
|
29
|
+
planQuery, planEntityQuery, entityShape, planEntityPredicate, entityPathRef,
|
|
30
|
+
} from './plan.js';
|
|
31
|
+
import { emitPlan, emitEntityPlan, createEntityPredicateEmitters } from './emit.js';
|
|
32
|
+
import { selectPlan } from './algebra.js';
|
|
33
|
+
import { compileSetResidual, compileRowResidual, sequenceResult } from './residual.js';
|
|
34
|
+
import { deterministicFragment, registerFragment } from './udf.js';
|
|
35
|
+
import {
|
|
36
|
+
normalizeProfile, translateProfilePredicate,
|
|
37
|
+
applyMandatoryPredicate, applyRowBound,
|
|
38
|
+
} from './profile.js';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The store-wide query state shared by every collection's engine: one
|
|
42
|
+
* bounded statement cache, its counters, the UDF registration set, and
|
|
43
|
+
* the store's resolved registered operators (Ring 2 — `{ functions,
|
|
44
|
+
* extensions }` or `null`), threaded to every engine that builds a
|
|
45
|
+
* residual.
|
|
46
|
+
* @param {number} [bound]
|
|
47
|
+
* @param {{ functions?: any, extensions?: any } | null} [operators]
|
|
48
|
+
* @returns {any}
|
|
49
|
+
*/
|
|
50
|
+
export function createQueryState(bound = undefined, operators = null) {
|
|
51
|
+
return {
|
|
52
|
+
cache: createSemanticCache(bound ?? 128),
|
|
53
|
+
counters: { hits: 0, misses: 0, evictions: 0 },
|
|
54
|
+
/** Fragment identity → the SQL function name registered for it. */
|
|
55
|
+
registered: new Map(),
|
|
56
|
+
operators: operators ?? null,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** @param {any} value - a bindable native parameter? */
|
|
61
|
+
function bindable(value) {
|
|
62
|
+
return typeof value === 'string'
|
|
63
|
+
|| (typeof value === 'number' && Number.isFinite(value));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The query engine for one collection.
|
|
68
|
+
* @param {{ connection: any, state: any, collection: any,
|
|
69
|
+
* physicalPlan: any, profile?: any }} context - `collection` is the
|
|
70
|
+
* normalized collection; `physicalPlan` is the DDL plan
|
|
71
|
+
* (columns, indexes); `profile` is the store-level normalized
|
|
72
|
+
* profile, if one was opened with
|
|
73
|
+
* @returns {{ execute: Function, query: Function, explain: Function }}
|
|
74
|
+
*/
|
|
75
|
+
export function createQueryEngine(context) {
|
|
76
|
+
const { connection, state, collection, physicalPlan } = context;
|
|
77
|
+
const storeProfile = context.profile ?? null;
|
|
78
|
+
// the store's registered operators (Ring 2): recognised by the planner
|
|
79
|
+
// as vocabulary, evaluated in the residual, threaded into every
|
|
80
|
+
// residual compilation here. `null` when the store opened with no
|
|
81
|
+
// registry — the whole engine is then byte-identical to before.
|
|
82
|
+
const operators = state.operators ?? null;
|
|
83
|
+
const dialect = connection.dialect;
|
|
84
|
+
const shape = {
|
|
85
|
+
collection: collection.name,
|
|
86
|
+
schema: collection.schema,
|
|
87
|
+
columnByCanonical: physicalPlan.columnByCanonical,
|
|
88
|
+
operators,
|
|
89
|
+
};
|
|
90
|
+
const physical = {
|
|
91
|
+
table: physicalPlan.table,
|
|
92
|
+
keyColumn: physicalPlan.keyColumn,
|
|
93
|
+
docColumn: physicalPlan.docColumn,
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/** The `compileJsonQuery` options for an inline residual: the
|
|
97
|
+
* profile's engine limits plus the store's registered operators. */
|
|
98
|
+
const residualCompileOptions = (limits) => {
|
|
99
|
+
const functions = operators?.functions;
|
|
100
|
+
const extensions = operators?.extensions;
|
|
101
|
+
if (limits === undefined && functions === undefined && extensions === undefined)
|
|
102
|
+
return undefined;
|
|
103
|
+
/** @type {any} */
|
|
104
|
+
const options = {};
|
|
105
|
+
if (limits !== undefined) options.limits = limits;
|
|
106
|
+
if (functions !== undefined) options.functions = functions;
|
|
107
|
+
if (extensions !== undefined) options.extensions = extensions;
|
|
108
|
+
return options;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const udfHook = connection.capabilities.userFunctions
|
|
112
|
+
? (fragment) => {
|
|
113
|
+
// Ring 3: admit the registry's pushable:'scalar' operators too
|
|
114
|
+
const qualified = deterministicFragment(fragment, operators);
|
|
115
|
+
if (qualified === null) return null;
|
|
116
|
+
// the store owns the final name: a fingerprint clash between two
|
|
117
|
+
// distinct fragments is disambiguated at registration
|
|
118
|
+
return { ...qualified, name: registerFragment(connection, state.registered, qualified) };
|
|
119
|
+
}
|
|
120
|
+
: undefined;
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The profile's compile-time refusal.
|
|
124
|
+
* @param {string} reason
|
|
125
|
+
* @returns {DbCompileError}
|
|
126
|
+
*/
|
|
127
|
+
const profileRefusal = (reason) =>
|
|
128
|
+
new DbCompileError('JD0011', reason, collection.docPath);
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Build (or fetch) the cached entry for one document, under one
|
|
132
|
+
* resolved profile and pushdown setting.
|
|
133
|
+
* @param {any} document
|
|
134
|
+
* @param {boolean} strict
|
|
135
|
+
* @param {any} profile - normalized profile or null
|
|
136
|
+
* @param {boolean} pushdown - false forces the whole document to the
|
|
137
|
+
* set residual (the oracle's forced-residual mode)
|
|
138
|
+
*/
|
|
139
|
+
const entryFor = (document, strict, profile, pushdown) => {
|
|
140
|
+
// one TUPLE, not a `|`-joined string: a document may itself contain
|
|
141
|
+
// the separator, so concatenation is not injective and the tuple is
|
|
142
|
+
const key = ['C', document, collection.name, dialect.name, strict, pushdown, profile];
|
|
143
|
+
const cached = state.cache.get(key);
|
|
144
|
+
if (cached !== undefined) {
|
|
145
|
+
state.counters.hits++;
|
|
146
|
+
return cached;
|
|
147
|
+
}
|
|
148
|
+
state.counters.misses++;
|
|
149
|
+
|
|
150
|
+
if (profile !== null && profile.collections !== null
|
|
151
|
+
&& !profile.collections.includes(collection.name)) {
|
|
152
|
+
throw profileRefusal(
|
|
153
|
+
`the profile does not allow querying collection '${collection.name}'`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// no UDF registration under a profile: a foreign document must not
|
|
157
|
+
// cause host-side function registration
|
|
158
|
+
let planned = planQuery(document, shape,
|
|
159
|
+
{ udf: profile === null && pushdown ? udfHook : undefined });
|
|
160
|
+
if (!pushdown) {
|
|
161
|
+
planned = {
|
|
162
|
+
...planned,
|
|
163
|
+
plan: null,
|
|
164
|
+
mode: 'set',
|
|
165
|
+
reasons: [{ construct: 'pushdown', reason: 'disabled by the harness switch' }],
|
|
166
|
+
rowReturn: null,
|
|
167
|
+
udfs: [],
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (profile !== null) {
|
|
172
|
+
const deps = planned.analysis.dependencies;
|
|
173
|
+
for (const name of deps.functions) {
|
|
174
|
+
if (!profile.functions.includes(name))
|
|
175
|
+
throw profileRefusal(`the profile does not allow the host function '${name}'`);
|
|
176
|
+
}
|
|
177
|
+
for (const name of deps.collations) {
|
|
178
|
+
if (!profile.collations.includes(name))
|
|
179
|
+
throw profileRefusal(`the profile does not allow the collation '${name}'`);
|
|
180
|
+
}
|
|
181
|
+
for (const external of planned.analysis.externals) {
|
|
182
|
+
if (!profile.externals.includes(external.name))
|
|
183
|
+
throw profileRefusal(`the profile does not declare the external '${external.name}'`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (strict && planned.mode !== 'native') {
|
|
188
|
+
const forcing = planned.reasons[0]
|
|
189
|
+
?? { construct: 'residual', reason: 'the document did not translate' };
|
|
190
|
+
throw new DbCompileError('JD0010',
|
|
191
|
+
`strict mode refused a residual: '${forcing.construct}' — ${forcing.reason}`,
|
|
192
|
+
collection.docPath);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const mandatory = profile !== null
|
|
196
|
+
&& profile.predicates[collection.name] !== undefined
|
|
197
|
+
? translateProfilePredicate(profile.predicates[collection.name], shape)
|
|
198
|
+
: null;
|
|
199
|
+
const maxRows = profile === null ? null : profile.maxRows;
|
|
200
|
+
const shapePlan = (base) => {
|
|
201
|
+
let out = applyMandatoryPredicate(base, mandatory);
|
|
202
|
+
if (maxRows !== null) out = applyRowBound(out, maxRows);
|
|
203
|
+
return out;
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const plan = shapePlan(planned.plan ?? selectPlan(collection.name));
|
|
207
|
+
const emitted = emitPlan(plan, dialect, physical);
|
|
208
|
+
const externalNames = planned.analysis.externals.map((e) => e.name);
|
|
209
|
+
const limits = profile === null ? undefined : profile.limits;
|
|
210
|
+
const entry = {
|
|
211
|
+
planned,
|
|
212
|
+
plan,
|
|
213
|
+
sql: emitted.sql,
|
|
214
|
+
slots: emitted.slots,
|
|
215
|
+
externalNames,
|
|
216
|
+
dependencies: planned.analysis.dependencies,
|
|
217
|
+
limits: planned.analysis.limits,
|
|
218
|
+
residualLimits: limits,
|
|
219
|
+
rowBound: maxRows,
|
|
220
|
+
needsScanCheck: profile !== null && profile.refuseFullScan === true,
|
|
221
|
+
scanChecked: false,
|
|
222
|
+
statement: null,
|
|
223
|
+
setResidual: null,
|
|
224
|
+
packedResidual: null,
|
|
225
|
+
rowResidual: planned.mode === 'row'
|
|
226
|
+
? compileRowResidual(planned.rowReturn, limits, operators)
|
|
227
|
+
: null,
|
|
228
|
+
fullScanSql: null,
|
|
229
|
+
fullScanShape: () => shapePlan(selectPlan(collection.name)),
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const sizeBefore = state.cache.size();
|
|
233
|
+
if (state.cache.set(key, entry) && state.cache.size() === sizeBefore)
|
|
234
|
+
state.counters.evictions++;
|
|
235
|
+
return entry;
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
const statementOf = (entry) => {
|
|
239
|
+
if (entry.statement === null) entry.statement = connection.prepare(entry.sql);
|
|
240
|
+
return entry.statement;
|
|
241
|
+
};
|
|
242
|
+
const setResidualOf = (entry, document) => {
|
|
243
|
+
if (entry.setResidual === null)
|
|
244
|
+
entry.setResidual = compileSetResidual(document, entry.residualLimits, operators);
|
|
245
|
+
return entry.setResidual;
|
|
246
|
+
};
|
|
247
|
+
/** The item-packing variant for cursors: `[document]` packs the
|
|
248
|
+
* whole result sequence into one unambiguous array. */
|
|
249
|
+
const packedResidualOf = (entry, document) => {
|
|
250
|
+
if (entry.packedResidual === null) {
|
|
251
|
+
const compiled = compileJsonQuery([document], residualCompileOptions(entry.residualLimits));
|
|
252
|
+
entry.packedResidual = (candidates, externals) => compiled(candidates, externals);
|
|
253
|
+
}
|
|
254
|
+
return entry.packedResidual;
|
|
255
|
+
};
|
|
256
|
+
/**
|
|
257
|
+
* The diversion fetch: the whole collection, still wearing the
|
|
258
|
+
* profile's mandatory predicate and row bound — a diverted call must
|
|
259
|
+
* not escape either.
|
|
260
|
+
*/
|
|
261
|
+
const fullScanOf = (entry) => {
|
|
262
|
+
if (entry.fullScanSql === null) {
|
|
263
|
+
const emitted = emitPlan(entry.fullScanShape(), dialect, physical);
|
|
264
|
+
entry.fullScanSql = { sql: emitted.sql, slots: emitted.slots, statement: null };
|
|
265
|
+
}
|
|
266
|
+
if (entry.fullScanSql.statement === null)
|
|
267
|
+
entry.fullScanSql.statement = connection.prepare(entry.fullScanSql.sql);
|
|
268
|
+
return entry.fullScanSql.statement;
|
|
269
|
+
};
|
|
270
|
+
const fullScanParams = (entry) =>
|
|
271
|
+
entry.fullScanSql.slots.map((slot) => ('literal' in slot ? slot.literal : null));
|
|
272
|
+
|
|
273
|
+
/** Refuse a fetch that crossed the profile's row bound (JD2007). */
|
|
274
|
+
const checkRowBound = (entry, rows) => {
|
|
275
|
+
if (entry.rowBound !== null && rows.length > entry.rowBound) {
|
|
276
|
+
throw new DbRuntimeError('JD2007',
|
|
277
|
+
`the fetch crossed the profile's maxRows bound of ${entry.rowBound}`,
|
|
278
|
+
{ docPath: collection.docPath, collection: collection.name });
|
|
279
|
+
}
|
|
280
|
+
return rows;
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
/** The optional plan-shape refusal: a full-table SCAN of a profiled
|
|
284
|
+
* collection is refused when the profile says so, verified against
|
|
285
|
+
* the database's own plan output. */
|
|
286
|
+
const guardScan = (entry) => {
|
|
287
|
+
if (!entry.needsScanCheck || entry.scanChecked) return null;
|
|
288
|
+
const eqpParams = entry.slots.map((slot) => ('literal' in slot ? slot.literal : null));
|
|
289
|
+
return chain(connection.prepare(dialect.explainQuery(entry.sql)), (statement) =>
|
|
290
|
+
chain(statement.all(eqpParams), (rows) => {
|
|
291
|
+
const fullScan = rows.some((row) => {
|
|
292
|
+
const detail = String(row.detail);
|
|
293
|
+
return detail.startsWith(`SCAN ${physical.table}`)
|
|
294
|
+
&& !detail.includes('USING INDEX');
|
|
295
|
+
});
|
|
296
|
+
if (fullScan) {
|
|
297
|
+
throw profileRefusal(
|
|
298
|
+
`the profile refuses a full-table scan of '${collection.name}' `
|
|
299
|
+
+ `(${rows.map((row) => String(row.detail)).join('; ')})`);
|
|
300
|
+
}
|
|
301
|
+
entry.scanChecked = true;
|
|
302
|
+
return null;
|
|
303
|
+
}));
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
/** Bind slots against the call's externals. */
|
|
307
|
+
const bindParams = (entry, externals) =>
|
|
308
|
+
entry.slots.map((slot) => ('literal' in slot ? slot.literal : externals[slot.external]));
|
|
309
|
+
|
|
310
|
+
/** Must this call divert to the residual? */
|
|
311
|
+
const mustDivert = (entry, externals) =>
|
|
312
|
+
entry.externalNames.some((name) => !bindable(externals[name]));
|
|
313
|
+
|
|
314
|
+
const rowsToDocs = (rows) => rows.map((row) => JSON.parse(row.doc));
|
|
315
|
+
|
|
316
|
+
const aggregateResult = (entry, row) => {
|
|
317
|
+
const fn = entry.plan.aggregate.fn;
|
|
318
|
+
const value = row?.value ?? null;
|
|
319
|
+
if (fn === 'count') return value ?? 0;
|
|
320
|
+
if (fn === 'sum') return value === null ? 0 : value;
|
|
321
|
+
return value === null ? undefined : value;
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Resolve the profile and pushdown switches for one call.
|
|
326
|
+
* @param {any} options
|
|
327
|
+
*/
|
|
328
|
+
const callState = (options) => ({
|
|
329
|
+
externals: options?.externals ?? {},
|
|
330
|
+
strict: options?.strict === true,
|
|
331
|
+
profile: options?.profile !== undefined
|
|
332
|
+
? normalizeProfile(options.profile)
|
|
333
|
+
: storeProfile,
|
|
334
|
+
pushdown: options?.pushdown !== false,
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Run the document and answer in the ENGINE's result shape.
|
|
339
|
+
* @param {any} document
|
|
340
|
+
* @param {{ externals?: any, strict?: boolean, profile?: any,
|
|
341
|
+
* pushdown?: boolean }} [options]
|
|
342
|
+
* @returns {any} value-or-promise (the provider contract keeps a
|
|
343
|
+
* synchronous driver synchronous)
|
|
344
|
+
*/
|
|
345
|
+
const execute = (document, options = undefined) => {
|
|
346
|
+
const { externals, strict, profile, pushdown } = callState(options);
|
|
347
|
+
const entry = entryFor(document, strict, profile, pushdown);
|
|
348
|
+
|
|
349
|
+
return chain(guardScan(entry), () => {
|
|
350
|
+
if (mustDivert(entry, externals)) {
|
|
351
|
+
return chain(fullScanOf(entry), (statement) =>
|
|
352
|
+
chain(statement.all(fullScanParams(entry)), (rows) =>
|
|
353
|
+
setResidualOf(entry, document)(rowsToDocs(checkRowBound(entry, rows)), externals)));
|
|
354
|
+
}
|
|
355
|
+
if (entry.planned.mode === 'set') {
|
|
356
|
+
// the narrowed statement fetches candidates; the full document
|
|
357
|
+
// then re-applies its own predicates (idempotent narrowing)
|
|
358
|
+
return chain(statementOf(entry), (statement) =>
|
|
359
|
+
chain(statement.all(bindParams(entry, externals)), (rows) =>
|
|
360
|
+
setResidualOf(entry, document)(rowsToDocs(checkRowBound(entry, rows)), externals)));
|
|
361
|
+
}
|
|
362
|
+
if (entry.planned.mode === 'row') {
|
|
363
|
+
return chain(statementOf(entry), (statement) =>
|
|
364
|
+
chain(statement.all(bindParams(entry, externals)), (rows) => {
|
|
365
|
+
const items = [];
|
|
366
|
+
for (const row of checkRowBound(entry, rows))
|
|
367
|
+
items.push(...entry.rowResidual(JSON.parse(row.doc), externals));
|
|
368
|
+
return sequenceResult(items);
|
|
369
|
+
}));
|
|
370
|
+
}
|
|
371
|
+
return chain(statementOf(entry), (statement) => {
|
|
372
|
+
if (entry.plan.aggregate !== null) {
|
|
373
|
+
return chain(statement.get(bindParams(entry, externals)),
|
|
374
|
+
(row) => aggregateResult(entry, row));
|
|
375
|
+
}
|
|
376
|
+
return chain(statement.all(bindParams(entry, externals)),
|
|
377
|
+
(rows) => sequenceResult(rowsToDocs(checkRowBound(entry, rows))));
|
|
378
|
+
});
|
|
379
|
+
});
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* A streaming cursor over the document's result ITEMS (`next()` /
|
|
384
|
+
* `return()` plus `Symbol.asyncIterator`).
|
|
385
|
+
* Native and row modes stream row by row; a set residual
|
|
386
|
+
* materializes first (it is a barrier and `explain()` says so).
|
|
387
|
+
* @param {any} document
|
|
388
|
+
* @param {{ externals?: any, strict?: boolean }} [options]
|
|
389
|
+
*/
|
|
390
|
+
const query = (document, options = undefined) => {
|
|
391
|
+
const { externals, strict, profile, pushdown } = callState(options);
|
|
392
|
+
const entry = entryFor(document, strict, profile, pushdown);
|
|
393
|
+
let pulledRows = 0;
|
|
394
|
+
|
|
395
|
+
/** @type {any} */
|
|
396
|
+
let underlying = null;
|
|
397
|
+
/** @type {any[]} */
|
|
398
|
+
let buffered = [];
|
|
399
|
+
let bufferedAt = 0;
|
|
400
|
+
let materialized = null;
|
|
401
|
+
let done = false;
|
|
402
|
+
|
|
403
|
+
const nextFromBuffer = () => ({ done: false, value: buffered[bufferedAt++] });
|
|
404
|
+
|
|
405
|
+
const pull = () => {
|
|
406
|
+
if (done) return Promise.resolve({ done: true, value: undefined });
|
|
407
|
+
if (bufferedAt < buffered.length) return Promise.resolve(nextFromBuffer());
|
|
408
|
+
|
|
409
|
+
if (entry.planned.mode === 'set' || mustDivert(entry, externals)) {
|
|
410
|
+
// the barrier: materialize candidates, pack the result items
|
|
411
|
+
if (materialized === null) {
|
|
412
|
+
const diverted = mustDivert(entry, externals);
|
|
413
|
+
materialized = Promise.resolve(chain(guardScan(entry), () => chain(
|
|
414
|
+
diverted ? fullScanOf(entry) : statementOf(entry),
|
|
415
|
+
(statement) => chain(
|
|
416
|
+
statement.all(diverted ? fullScanParams(entry) : bindParams(entry, externals)),
|
|
417
|
+
(rows) => {
|
|
418
|
+
buffered = packedResidualOf(entry, document)(
|
|
419
|
+
rowsToDocs(checkRowBound(entry, rows)), externals);
|
|
420
|
+
bufferedAt = 0;
|
|
421
|
+
}))));
|
|
422
|
+
}
|
|
423
|
+
return materialized.then(() => {
|
|
424
|
+
if (bufferedAt < buffered.length) return nextFromBuffer();
|
|
425
|
+
done = true;
|
|
426
|
+
return { done: true, value: undefined };
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
if (entry.plan.aggregate !== null) {
|
|
430
|
+
// a native aggregate yields exactly one item
|
|
431
|
+
if (materialized === null) {
|
|
432
|
+
materialized = Promise.resolve(chain(guardScan(entry), () =>
|
|
433
|
+
chain(statementOf(entry), (statement) =>
|
|
434
|
+
chain(statement.get(bindParams(entry, externals)), (row) => {
|
|
435
|
+
const value = aggregateResult(entry, row);
|
|
436
|
+
buffered = value === undefined ? [] : [value];
|
|
437
|
+
bufferedAt = 0;
|
|
438
|
+
}))));
|
|
439
|
+
}
|
|
440
|
+
return materialized.then(() => {
|
|
441
|
+
if (bufferedAt < buffered.length) return nextFromBuffer();
|
|
442
|
+
done = true;
|
|
443
|
+
return { done: true, value: undefined };
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
return Promise.resolve(chain(underlying === null
|
|
448
|
+
? chain(guardScan(entry), () => chain(statementOf(entry),
|
|
449
|
+
(statement) => { underlying = statement.iterate(bindParams(entry, externals)); return underlying; }))
|
|
450
|
+
: underlying, (iterator) => chain(iterator.next(), (step) => {
|
|
451
|
+
if (step.done === true) {
|
|
452
|
+
done = true;
|
|
453
|
+
return { done: true, value: undefined };
|
|
454
|
+
}
|
|
455
|
+
pulledRows++;
|
|
456
|
+
if (entry.rowBound !== null && pulledRows > entry.rowBound) {
|
|
457
|
+
done = true;
|
|
458
|
+
if (typeof iterator.return === 'function') iterator.return(undefined);
|
|
459
|
+
throw new DbRuntimeError('JD2007',
|
|
460
|
+
`the fetch crossed the profile's maxRows bound of ${entry.rowBound}`,
|
|
461
|
+
{ docPath: collection.docPath, collection: collection.name });
|
|
462
|
+
}
|
|
463
|
+
const doc = JSON.parse(step.value.doc);
|
|
464
|
+
if (entry.rowResidual === null) return { done: false, value: doc };
|
|
465
|
+
buffered = entry.rowResidual(doc, externals);
|
|
466
|
+
bufferedAt = 0;
|
|
467
|
+
return bufferedAt < buffered.length ? nextFromBuffer() : pull();
|
|
468
|
+
})));
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
const close = () => {
|
|
472
|
+
done = true;
|
|
473
|
+
if (underlying !== null && typeof underlying.return === 'function')
|
|
474
|
+
underlying.return(undefined);
|
|
475
|
+
return Promise.resolve({ done: true, value: undefined });
|
|
476
|
+
};
|
|
477
|
+
|
|
478
|
+
return {
|
|
479
|
+
next: () => pull(),
|
|
480
|
+
return: () => close(),
|
|
481
|
+
[Symbol.asyncIterator]() { return this; },
|
|
482
|
+
};
|
|
483
|
+
};
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* The explanation record: the engine explain shape plus the pushdown
|
|
487
|
+
* facts. `estimatedRows` is deliberately ABSENT — the capability
|
|
488
|
+
* slot is empty on SQLite and no number is fabricated; the
|
|
489
|
+
* database's own plan prose rides in `scanNarrative` instead.
|
|
490
|
+
* @param {any} document
|
|
491
|
+
* @param {{ externals?: any, strict?: boolean }} [options]
|
|
492
|
+
*/
|
|
493
|
+
const explain = (document, options = undefined) => {
|
|
494
|
+
const { externals, strict, profile, pushdown } = callState(options);
|
|
495
|
+
const entry = entryFor(document, strict, profile, pushdown);
|
|
496
|
+
|
|
497
|
+
const touchedColumns = new Set();
|
|
498
|
+
const collectColumns = (pred) => {
|
|
499
|
+
if (pred === null) return;
|
|
500
|
+
if (pred.p === 'and' || pred.p === 'or') pred.items.forEach(collectColumns);
|
|
501
|
+
else if (pred.p === 'not') collectColumns(pred.item);
|
|
502
|
+
else if ('ref' in pred && pred.ref?.column) touchedColumns.add(pred.ref.column);
|
|
503
|
+
};
|
|
504
|
+
collectColumns(entry.plan.filter);
|
|
505
|
+
for (const term of entry.plan.order ?? []) {
|
|
506
|
+
if (term.ref.column !== null) touchedColumns.add(term.ref.column);
|
|
507
|
+
}
|
|
508
|
+
if (entry.plan.aggregate?.ref?.column) touchedColumns.add(entry.plan.aggregate.ref.column);
|
|
509
|
+
const indexes = physicalPlan.expected.indexes
|
|
510
|
+
.filter((index) => index.columns.some((column) => touchedColumns.has(column)))
|
|
511
|
+
.map((index) => index.name);
|
|
512
|
+
|
|
513
|
+
const params = entry.slots.map((slot) =>
|
|
514
|
+
('external' in slot ? { external: slot.external } : { literal: slot.literal }));
|
|
515
|
+
const eqpParams = entry.slots.map((slot) => ('literal' in slot
|
|
516
|
+
? slot.literal
|
|
517
|
+
: bindable(externals[slot.external]) ? externals[slot.external] : null));
|
|
518
|
+
|
|
519
|
+
return chain(connection.prepare(dialect.explainQuery(entry.sql)), (statement) =>
|
|
520
|
+
chain(statement.all(eqpParams), (rows) => ({
|
|
521
|
+
externals: [...entry.externalNames],
|
|
522
|
+
operators: [...entry.dependencies.operators],
|
|
523
|
+
functions: [...entry.dependencies.functions],
|
|
524
|
+
collations: [...entry.dependencies.collations],
|
|
525
|
+
limits: entry.limits,
|
|
526
|
+
sql: entry.sql,
|
|
527
|
+
params,
|
|
528
|
+
indexes,
|
|
529
|
+
residual: entry.planned.mode === 'native'
|
|
530
|
+
? null
|
|
531
|
+
: { mode: entry.planned.mode, reasons: entry.planned.reasons },
|
|
532
|
+
barriers: entry.planned.mode === 'set'
|
|
533
|
+
? entry.planned.reasons.map((r) => ({ operator: r.construct, reason: r.reason }))
|
|
534
|
+
: [],
|
|
535
|
+
udfs: [...entry.planned.udfs],
|
|
536
|
+
scanNarrative: rows.map((row) => String(row.detail)).join('; '),
|
|
537
|
+
})));
|
|
538
|
+
};
|
|
539
|
+
|
|
540
|
+
return { execute, query, explain, shape };
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// ————— The entity query surface (the second document kind) —————
|
|
544
|
+
|
|
545
|
+
import { mergeEntityRow, parseGraphRow } from './graph.js';
|
|
546
|
+
|
|
547
|
+
/** The default include depth bound (D14: printed, never silent). */
|
|
548
|
+
export const INCLUDE_DEPTH_DEFAULT = 3;
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* The store-level entity query engine: documents over the
|
|
552
|
+
* multi-entity root (`$.<Entity>[*]` bindings), planned to guarded
|
|
553
|
+
* selections and INNER equijoins, with the set residual running the
|
|
554
|
+
* whole document over the fetched root — the same honesty contract as
|
|
555
|
+
* phase A.
|
|
556
|
+
* @param {{ connection: any, entities: Map<string, any>, mapping: any,
|
|
557
|
+
* state: any }} context
|
|
558
|
+
* @returns {any}
|
|
559
|
+
*/
|
|
560
|
+
export function createEntityQueryEngine(context) {
|
|
561
|
+
const { connection, entities, mapping, state } = context;
|
|
562
|
+
const operators = state.operators ?? null;
|
|
563
|
+
const dialect = connection.dialect;
|
|
564
|
+
const q = dialect.quoteIdentifier;
|
|
565
|
+
const physicalOf = (name) => ({ table: mapping.entities[name].table });
|
|
566
|
+
|
|
567
|
+
const entryFor = (document, pushdown) => {
|
|
568
|
+
const key = ['E', document, dialect.name, pushdown];
|
|
569
|
+
const cached = state.cache.get(key);
|
|
570
|
+
if (cached !== undefined) {
|
|
571
|
+
state.counters.hits++;
|
|
572
|
+
return cached;
|
|
573
|
+
}
|
|
574
|
+
state.counters.misses++;
|
|
575
|
+
let planned = planEntityQuery(document, entities, mapping, operators);
|
|
576
|
+
if (!pushdown) {
|
|
577
|
+
planned = { ...planned, mode: 'set', plan: null,
|
|
578
|
+
reasons: [{ construct: 'pushdown', reason: 'disabled by the harness switch' }] };
|
|
579
|
+
}
|
|
580
|
+
const entry = {
|
|
581
|
+
planned,
|
|
582
|
+
sql: null,
|
|
583
|
+
slots: null,
|
|
584
|
+
statement: null,
|
|
585
|
+
setResidual: null,
|
|
586
|
+
fetchers: null,
|
|
587
|
+
};
|
|
588
|
+
if (planned.mode === 'native') {
|
|
589
|
+
const emitted = emitEntityPlan(planned.plan, dialect, physicalOf);
|
|
590
|
+
entry.sql = emitted.sql;
|
|
591
|
+
entry.slots = emitted.slots;
|
|
592
|
+
}
|
|
593
|
+
const sizeBefore = state.cache.size();
|
|
594
|
+
if (state.cache.set(key, entry) && state.cache.size() === sizeBefore)
|
|
595
|
+
state.counters.evictions++;
|
|
596
|
+
return entry;
|
|
597
|
+
};
|
|
598
|
+
|
|
599
|
+
/** Fetch every referenced entity's rows and build the in-memory root. */
|
|
600
|
+
const fetchRoot = (entry) => {
|
|
601
|
+
if (entry.fetchers === null) {
|
|
602
|
+
entry.fetchers = [...(entry.planned.referenced.length === 0
|
|
603
|
+
? entities.keys() : entry.planned.referenced)].map((name) => ({
|
|
604
|
+
name,
|
|
605
|
+
sql: `SELECT ${q('t')}.*, ${dialect.jsonText(`${q('t')}.${q('doc')}`)} AS ${q('__doc')} `
|
|
606
|
+
+ `FROM ${q(mapping.entities[name].table)} AS ${q('t')} ORDER BY ${q('t')}.${dialect.rowIdentity()}`,
|
|
607
|
+
statement: null,
|
|
608
|
+
}));
|
|
609
|
+
}
|
|
610
|
+
/** @type {any} */
|
|
611
|
+
const root = {};
|
|
612
|
+
const next = (i) => {
|
|
613
|
+
if (i >= entry.fetchers.length) return root;
|
|
614
|
+
const fetcher = entry.fetchers[i];
|
|
615
|
+
if (fetcher.statement === null) fetcher.statement = connection.prepare(fetcher.sql);
|
|
616
|
+
return chain(fetcher.statement, (statement) =>
|
|
617
|
+
chain(statement.all([]), (rows) => {
|
|
618
|
+
root[fetcher.name] = rows.map((row) =>
|
|
619
|
+
mergeEntityRow(mapping.entities[fetcher.name], row, '__doc'));
|
|
620
|
+
return next(i + 1);
|
|
621
|
+
}));
|
|
622
|
+
};
|
|
623
|
+
return next(0);
|
|
624
|
+
};
|
|
625
|
+
|
|
626
|
+
const runResidual = (entry, document, externals) => {
|
|
627
|
+
if (entry.setResidual === null)
|
|
628
|
+
entry.setResidual = compileSetResidual(document, undefined, operators);
|
|
629
|
+
return chain(fetchRoot(entry), (root) => entry.setResidual(root, externals));
|
|
630
|
+
};
|
|
631
|
+
|
|
632
|
+
const execute = (document, options = undefined) => {
|
|
633
|
+
const externals = options?.externals ?? {};
|
|
634
|
+
const pushdown = options?.pushdown !== false;
|
|
635
|
+
const entry = entryFor(document, pushdown);
|
|
636
|
+
if (entry.planned.mode !== 'native') {
|
|
637
|
+
if (options?.strict === true) {
|
|
638
|
+
const forcing = entry.planned.reasons[0];
|
|
639
|
+
throw new DbCompileError('JD0010',
|
|
640
|
+
`strict mode refused a residual: '${forcing.construct}' — ${forcing.reason}`);
|
|
641
|
+
}
|
|
642
|
+
return runResidual(entry, document, externals);
|
|
643
|
+
}
|
|
644
|
+
// bind-time diversion, exactly phase A's: a missing external must
|
|
645
|
+
// raise the ENGINE's error, a boolean or null cannot bind natively
|
|
646
|
+
for (const slot of entry.slots) {
|
|
647
|
+
if ('external' in slot && !bindable(externals[slot.external]))
|
|
648
|
+
return runResidual(entry, document, externals);
|
|
649
|
+
}
|
|
650
|
+
if (entry.statement === null) entry.statement = connection.prepare(entry.sql);
|
|
651
|
+
const params = entry.slots.map((slot) =>
|
|
652
|
+
('literal' in slot ? slot.literal : externals[slot.external]));
|
|
653
|
+
return chain(entry.statement, (statement) => {
|
|
654
|
+
if (entry.planned.plan.aggregate === 'count')
|
|
655
|
+
return chain(statement.get(params), (row) => row?.value ?? 0);
|
|
656
|
+
return chain(statement.all(params), (rows) => {
|
|
657
|
+
const retEntity = entry.planned.plan.bindings
|
|
658
|
+
.find((binding) => binding.name === entry.planned.plan.ret).entity;
|
|
659
|
+
return sequenceResult(rows.map((row) =>
|
|
660
|
+
mergeEntityRow(mapping.entities[retEntity], row, '__doc')));
|
|
661
|
+
});
|
|
662
|
+
});
|
|
663
|
+
};
|
|
664
|
+
|
|
665
|
+
const explain = (document, options = undefined) => {
|
|
666
|
+
const pushdown = options?.pushdown !== false;
|
|
667
|
+
const entry = entryFor(document, pushdown);
|
|
668
|
+
const base = {
|
|
669
|
+
mode: entry.planned.mode,
|
|
670
|
+
referenced: [...entry.planned.referenced],
|
|
671
|
+
reasons: entry.planned.reasons,
|
|
672
|
+
sql: entry.sql,
|
|
673
|
+
residual: entry.planned.mode === 'native'
|
|
674
|
+
? null
|
|
675
|
+
: { mode: 'set', reasons: entry.planned.reasons },
|
|
676
|
+
};
|
|
677
|
+
if (entry.planned.mode !== 'native') return base;
|
|
678
|
+
return chain(connection.prepare(dialect.explainQuery(entry.sql)), (statement) =>
|
|
679
|
+
chain(statement.all(entry.slots.map((slot) =>
|
|
680
|
+
('literal' in slot ? slot.literal : null))), (rows) => ({
|
|
681
|
+
...base,
|
|
682
|
+
join: entry.planned.plan.joinOn,
|
|
683
|
+
scanNarrative: rows.map((row) => String(row.detail)).join('; '),
|
|
684
|
+
})));
|
|
685
|
+
};
|
|
686
|
+
|
|
687
|
+
return { execute, explain };
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/**
|
|
691
|
+
* The one-statement graph loader: `entity.load(spec)` compiles an
|
|
692
|
+
* include tree to correlated subqueries projected as JSON — one
|
|
693
|
+
* statement regardless of depth (asserted by a counting driver in the
|
|
694
|
+
* tests, because N+1 is a test, not a promise). Per-relation `where`,
|
|
695
|
+
* `orderBy` and `take` are applied INSIDE the subquery; depth is
|
|
696
|
+
* bounded with the default printed in the refusal; cycles in the
|
|
697
|
+
* specification are rejected; and keyset pagination is chosen over a
|
|
698
|
+
* growing OFFSET whenever the top-level ordering is a single unique
|
|
699
|
+
* column, with the choice reported by `explainLoad`.
|
|
700
|
+
* @param {{ connection: any, entities: Map<string, any>, mapping: any,
|
|
701
|
+
* state: any }} context
|
|
702
|
+
* @param {string} entityName
|
|
703
|
+
* @returns {any}
|
|
704
|
+
*/
|
|
705
|
+
export function createLoadEngine(context, entityName) {
|
|
706
|
+
const { connection, entities, mapping, state } = context;
|
|
707
|
+
// a registered operator (Ring 2) is recognised as vocabulary so a
|
|
708
|
+
// where/orderBy that uses one refuses cleanly (JD0032 — the load path
|
|
709
|
+
// is all-SQL, with no residual), never as an unknown operator
|
|
710
|
+
const analyzeOpts = state.operators ?? undefined;
|
|
711
|
+
const dialect = connection.dialect;
|
|
712
|
+
const q = dialect.quoteIdentifier;
|
|
713
|
+
|
|
714
|
+
const refuse = (reason, path) => new DbCompileError('JD0032',
|
|
715
|
+
`${reason} (include path: ${path.join('.') || '<root>'})`,
|
|
716
|
+
entities.get(entityName)?.docPath);
|
|
717
|
+
|
|
718
|
+
/** Compile a where EXPRESSION over `$it` against one entity. */
|
|
719
|
+
const compileWhere = (expression, entity, path) => {
|
|
720
|
+
const wrapper = { $for: { it: '$[*]' }, $where: expression, $return: '$it' };
|
|
721
|
+
let analysis;
|
|
722
|
+
try {
|
|
723
|
+
analysis = analyzeQuery(wrapper, analyzeOpts);
|
|
724
|
+
}
|
|
725
|
+
catch (cause) {
|
|
726
|
+
throw new DbCompileError('JD0032',
|
|
727
|
+
`the where expression does not compile (include path: ${path.join('.')})`,
|
|
728
|
+
entity.docPath, /** @type {Error} */ (cause));
|
|
729
|
+
}
|
|
730
|
+
const flwor = analysis.root;
|
|
731
|
+
const slot = flwor.forBindings[0].slot;
|
|
732
|
+
const shape = entityShape(entity, mapping.entities[entity.name]);
|
|
733
|
+
const conjuncts = flwor.where.kind === 'op' && flwor.where.name === '$and'
|
|
734
|
+
? flwor.where.args : [flwor.where];
|
|
735
|
+
let filter = null;
|
|
736
|
+
for (const conjunct of conjuncts) {
|
|
737
|
+
const outcome = planEntityPredicate(conjunct, slot, shape);
|
|
738
|
+
if ('refusal' in outcome) {
|
|
739
|
+
throw refuse(`the where expression is not translatable: ${outcome.refusal.reason}`, path);
|
|
740
|
+
}
|
|
741
|
+
filter = filter === null ? outcome.pred
|
|
742
|
+
: filter.p === 'and'
|
|
743
|
+
? { p: 'and', items: [...filter.items, outcome.pred] }
|
|
744
|
+
: { p: 'and', items: [filter, outcome.pred] };
|
|
745
|
+
}
|
|
746
|
+
return filter;
|
|
747
|
+
};
|
|
748
|
+
|
|
749
|
+
const compileOrder = (orderBy, entity, path) => {
|
|
750
|
+
const specs = Array.isArray(orderBy) ? orderBy : [orderBy];
|
|
751
|
+
const wrapper = { $for: { it: '$[*]' }, $orderby: specs, $return: '$it' };
|
|
752
|
+
let analysis;
|
|
753
|
+
try {
|
|
754
|
+
analysis = analyzeQuery(wrapper, analyzeOpts);
|
|
755
|
+
}
|
|
756
|
+
catch (cause) {
|
|
757
|
+
throw new DbCompileError('JD0032',
|
|
758
|
+
`the orderBy does not compile (include path: ${path.join('.')})`,
|
|
759
|
+
entity.docPath, /** @type {Error} */ (cause));
|
|
760
|
+
}
|
|
761
|
+
const flwor = analysis.root;
|
|
762
|
+
const slot = flwor.forBindings[0].slot;
|
|
763
|
+
const shape = entityShape(entity, mapping.entities[entity.name]);
|
|
764
|
+
const terms = [];
|
|
765
|
+
for (const spec of flwor.orderby.specs) {
|
|
766
|
+
const ref = entityPathRef(spec.key, slot, shape);
|
|
767
|
+
if (ref === null || (ref.flavor === 'entity-doc' && ref.type === 'unknown'))
|
|
768
|
+
throw refuse('orderBy must address typed entity paths', path);
|
|
769
|
+
terms.push({ ref, desc: spec.desc === true, emptyGreatest: spec.emptyGreatest === true });
|
|
770
|
+
}
|
|
771
|
+
return terms;
|
|
772
|
+
};
|
|
773
|
+
|
|
774
|
+
/** Build the include tree, validating names, depth and cycles. */
|
|
775
|
+
const buildTree = (name, spec, depth, maxDepth, path, seen) => {
|
|
776
|
+
const entity = entities.get(name);
|
|
777
|
+
if (depth > maxDepth) {
|
|
778
|
+
throw refuse(`the include graph exceeds its depth bound of ${maxDepth} `
|
|
779
|
+
+ '(raise it explicitly with maxDepth)', path);
|
|
780
|
+
}
|
|
781
|
+
const node = {
|
|
782
|
+
entity,
|
|
783
|
+
entityMapping: mapping.entities[name],
|
|
784
|
+
where: spec?.where !== undefined ? compileWhere(spec.where, entity, path) : null,
|
|
785
|
+
order: spec?.orderBy !== undefined ? compileOrder(spec.orderBy, entity, path) : null,
|
|
786
|
+
take: spec?.take,
|
|
787
|
+
includes: [],
|
|
788
|
+
};
|
|
789
|
+
const includeSpec = spec?.include;
|
|
790
|
+
if (includeSpec === undefined) return node;
|
|
791
|
+
if (seen.has(includeSpec))
|
|
792
|
+
throw refuse('the include specification cycles', path);
|
|
793
|
+
seen.add(includeSpec);
|
|
794
|
+
for (const relationName of Object.keys(includeSpec)) {
|
|
795
|
+
const property = entity.properties.get(relationName);
|
|
796
|
+
const relation = property?.relation;
|
|
797
|
+
if (relation === undefined) {
|
|
798
|
+
throw refuse(`'${name}' declares no relation '${relationName}'`,
|
|
799
|
+
[...path, relationName]);
|
|
800
|
+
}
|
|
801
|
+
const childSpec = includeSpec[relationName] === true ? {} : includeSpec[relationName];
|
|
802
|
+
const childName = relation.to;
|
|
803
|
+
const include = {
|
|
804
|
+
name: relationName,
|
|
805
|
+
field: `__${relationName}`,
|
|
806
|
+
relation,
|
|
807
|
+
many: relation.kind !== 'oneToOne',
|
|
808
|
+
count: childSpec.count === true,
|
|
809
|
+
// the join kind is derivable from the schema: a required
|
|
810
|
+
// foreign key means the parent always exists
|
|
811
|
+
kind: relation.kind === 'oneToOne'
|
|
812
|
+
? ((entity.schema.required ?? []).includes(relation.via)
|
|
813
|
+
? 'inner (fk required)' : 'left (fk optional)')
|
|
814
|
+
: relation.kind,
|
|
815
|
+
child: childSpec.count === true
|
|
816
|
+
? null
|
|
817
|
+
: buildTree(childName, childSpec, depth + 1, maxDepth,
|
|
818
|
+
[...path, relationName], seen),
|
|
819
|
+
};
|
|
820
|
+
node.includes.push(include);
|
|
821
|
+
}
|
|
822
|
+
return node;
|
|
823
|
+
};
|
|
824
|
+
|
|
825
|
+
/** Render one node's subquery-projection SQL. */
|
|
826
|
+
const render = (node, alias, param, emitters) => {
|
|
827
|
+
const aliasSql = q(alias);
|
|
828
|
+
const docSql = `${aliasSql}.${q('doc')}`;
|
|
829
|
+
const projection = () => {
|
|
830
|
+
const parts = [];
|
|
831
|
+
const named = new Set();
|
|
832
|
+
for (const column of node.entityMapping.columns) {
|
|
833
|
+
named.add(column.name);
|
|
834
|
+
parts.push(`${slText(column.name)}, ${aliasSql}.${q(column.name)}`);
|
|
835
|
+
}
|
|
836
|
+
for (const fk of node.entityMapping.foreignKeys) {
|
|
837
|
+
if (named.has(fk.column)) continue; // a declared via property
|
|
838
|
+
parts.push(`${slText(fk.column)}, ${aliasSql}.${q(fk.column)}`);
|
|
839
|
+
}
|
|
840
|
+
parts.push(`${slText('__doc')}, ${dialect.jsonText(docSql)}`);
|
|
841
|
+
for (const include of node.includes)
|
|
842
|
+
parts.push(`${slText(include.field)}, ${renderInclude(node, include, alias, param, emitters)}`);
|
|
843
|
+
return parts.join(', ');
|
|
844
|
+
};
|
|
845
|
+
return { aliasSql, docSql, projection };
|
|
846
|
+
};
|
|
847
|
+
const slText = (s) => dialect.stringLiteral(s);
|
|
848
|
+
|
|
849
|
+
const renderInclude = (parentNode, include, parentAlias, param, emitters) => {
|
|
850
|
+
const relation = include.relation;
|
|
851
|
+
const childAlias = `${parentAlias}_${include.name}`;
|
|
852
|
+
const parentKey = parentNode.entityMapping.keys[0];
|
|
853
|
+
if (include.count === true) {
|
|
854
|
+
const childTable = mapping.entities[relation.to].table;
|
|
855
|
+
return `(SELECT COUNT(*) FROM ${q(childTable)} AS ${q(childAlias)} `
|
|
856
|
+
+ `WHERE ${q(childAlias)}.${q(relation.via)} = ${q(parentAlias)}.${q(parentKey)})`;
|
|
857
|
+
}
|
|
858
|
+
const child = include.child;
|
|
859
|
+
const childTable = child.entityMapping.table;
|
|
860
|
+
const childKey = child.entityMapping.keys[0];
|
|
861
|
+
const rendered = render(child, childAlias, param, emitters);
|
|
862
|
+
const conditions = [];
|
|
863
|
+
if (relation.kind === 'oneToMany') {
|
|
864
|
+
conditions.push(`${q(childAlias)}.${q(relation.via)} = ${q(parentAlias)}.${q(parentKey)}`);
|
|
865
|
+
}
|
|
866
|
+
else if (relation.kind === 'oneToOne') {
|
|
867
|
+
conditions.push(`${q(childAlias)}.${q(childKey)} = ${q(parentAlias)}.${q(relation.via)}`);
|
|
868
|
+
}
|
|
869
|
+
if (child.where !== null)
|
|
870
|
+
conditions.push(emitters.emitPred(rendered.aliasSql, rendered.docSql, child.where));
|
|
871
|
+
const orderSql = (child.order ?? []).map((term) => {
|
|
872
|
+
// epoch paths order by the document string (codepoint = the
|
|
873
|
+
// engine's order); only plain mapped columns order natively
|
|
874
|
+
const value = term.ref.flavor === 'entity-column'
|
|
875
|
+
? `${rendered.aliasSql}.${q(term.ref.column)}`
|
|
876
|
+
: dialect.jsonExtract(rendered.docSql, dialect.jsonPathText(term.ref.segments));
|
|
877
|
+
const nullsFirst = term.emptyGreatest === term.desc;
|
|
878
|
+
return `${value} ${term.desc ? 'DESC' : 'ASC'}${dialect.orderNulls(nullsFirst)}`;
|
|
879
|
+
});
|
|
880
|
+
orderSql.push(`${rendered.aliasSql}.${dialect.rowIdentity()}`);
|
|
881
|
+
const inner = relation.kind === 'manyToMany'
|
|
882
|
+
? `SELECT ${rendered.aliasSql}.* FROM ${q(childTable)} AS ${q(childAlias)} `
|
|
883
|
+
+ `JOIN ${q(relation.joinTable)} AS ${q(`${childAlias}_j`)} `
|
|
884
|
+
+ `ON ${q(`${childAlias}_j`)}.${q(`${relation.to}_key`)} = ${q(childAlias)}.${q(childKey)} `
|
|
885
|
+
+ `WHERE ${q(`${childAlias}_j`)}.${q(`${parentNode.entity.name}_key`)} = ${q(parentAlias)}.${q(parentKey)}`
|
|
886
|
+
+ (child.where !== null
|
|
887
|
+
? ` AND ${emitters.emitPred(rendered.aliasSql, rendered.docSql, child.where)}` : '')
|
|
888
|
+
+ ` ORDER BY ${orderSql.join(', ')}`
|
|
889
|
+
+ (child.take !== undefined ? ` ${dialect.limitClause(child.take, undefined)}` : '')
|
|
890
|
+
: `SELECT ${rendered.aliasSql}.* FROM ${q(childTable)} AS ${q(childAlias)} `
|
|
891
|
+
+ `WHERE ${conditions.join(' AND ')} ORDER BY ${orderSql.join(', ')}`
|
|
892
|
+
+ (child.take !== undefined ? ` ${dialect.limitClause(child.take, undefined)}` : '');
|
|
893
|
+
if (relation.kind === 'oneToOne') {
|
|
894
|
+
return `(SELECT json_object(${rendered.projection()}) FROM `
|
|
895
|
+
+ `(${inner} ${dialect.limitClause(1, undefined)}) AS ${q(childAlias)})`;
|
|
896
|
+
}
|
|
897
|
+
return `(SELECT ${dialect.jsonAgg(`json_object(${rendered.projection()})`)} `
|
|
898
|
+
+ `FROM (${inner}) AS ${q(childAlias)})`;
|
|
899
|
+
};
|
|
900
|
+
|
|
901
|
+
const buildLoad = (spec) => {
|
|
902
|
+
// a cyclic specification cannot be keyed, so the cache reports a
|
|
903
|
+
// permanent miss and buildTree gets to NAME the cycle
|
|
904
|
+
const key = ['L', entityName, spec ?? {}, dialect.name];
|
|
905
|
+
const cached = state.cache.get(key);
|
|
906
|
+
if (cached !== undefined) {
|
|
907
|
+
state.counters.hits++;
|
|
908
|
+
return cached;
|
|
909
|
+
}
|
|
910
|
+
state.counters.misses++;
|
|
911
|
+
/** @type {ParamCollector} */
|
|
912
|
+
const slots = [];
|
|
913
|
+
const param = (slot) => {
|
|
914
|
+
slots.push(slot);
|
|
915
|
+
return dialect.parameterRef(slots.length, 'v');
|
|
916
|
+
};
|
|
917
|
+
const emitters = createEntityPredicateEmitters(dialect, param);
|
|
918
|
+
const maxDepth = spec?.maxDepth ?? INCLUDE_DEPTH_DEFAULT;
|
|
919
|
+
const tree = buildTree(entityName, spec ?? {}, 0, maxDepth, [], new Set());
|
|
920
|
+
const rendered = render(tree, 'r', param, emitters);
|
|
921
|
+
|
|
922
|
+
// anonymous placeholders bind by position, so slots must be
|
|
923
|
+
// collected in SQL text order: the SELECT-list include subqueries
|
|
924
|
+
// come before the root WHERE
|
|
925
|
+
const includeSql = tree.includes.map((include) =>
|
|
926
|
+
`, ${renderInclude(tree, include, 'r', param, emitters)} AS ${q(include.field)}`).join('');
|
|
927
|
+
|
|
928
|
+
const conditions = [];
|
|
929
|
+
if (tree.where !== null)
|
|
930
|
+
conditions.push(emitters.emitPred(rendered.aliasSql, rendered.docSql, tree.where));
|
|
931
|
+
|
|
932
|
+
// pagination: keyset over a single unique ordering column beats a
|
|
933
|
+
// growing OFFSET; the choice is reported, never silent
|
|
934
|
+
let pagination = 'none';
|
|
935
|
+
const order = tree.order ?? [];
|
|
936
|
+
const uniqueColumns = new Set([
|
|
937
|
+
tree.entityMapping.keys.length === 1 ? tree.entityMapping.keys[0] : null,
|
|
938
|
+
...tree.entityMapping.indexes.filter((index) => index.unique)
|
|
939
|
+
.map((index) => index.property),
|
|
940
|
+
]);
|
|
941
|
+
if (spec?.after !== undefined) {
|
|
942
|
+
const term = order.length === 1 ? order[0] : null;
|
|
943
|
+
if (term === null || term.ref.flavor === 'entity-doc'
|
|
944
|
+
|| !uniqueColumns.has(term.ref.column)) {
|
|
945
|
+
throw refuse("'after' (keyset pagination) needs a single orderBy over a unique column", []);
|
|
946
|
+
}
|
|
947
|
+
pagination = 'keyset';
|
|
948
|
+
conditions.push(`${rendered.aliasSql}.${q(term.ref.column)} `
|
|
949
|
+
+ `${term.desc ? '<' : '>'} ${param({ literal: spec.after })}`);
|
|
950
|
+
}
|
|
951
|
+
else if (spec?.skip !== undefined && spec.skip > 0) {
|
|
952
|
+
pagination = 'offset';
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
let sql = `SELECT ${rendered.aliasSql}.*, ${dialect.jsonText(rendered.docSql)} AS ${q('__doc')}`
|
|
956
|
+
+ includeSql
|
|
957
|
+
+ ` FROM ${q(tree.entityMapping.table)} AS ${rendered.aliasSql}`;
|
|
958
|
+
if (conditions.length > 0) sql += ` WHERE ${conditions.join(' AND ')}`;
|
|
959
|
+
const orderSql = order.map((term) => {
|
|
960
|
+
const value = term.ref.flavor === 'entity-column'
|
|
961
|
+
? `${rendered.aliasSql}.${q(term.ref.column)}`
|
|
962
|
+
: dialect.jsonExtract(rendered.docSql, dialect.jsonPathText(term.ref.segments));
|
|
963
|
+
const nullsFirst = term.emptyGreatest === term.desc;
|
|
964
|
+
return `${value} ${term.desc ? 'DESC' : 'ASC'}${dialect.orderNulls(nullsFirst)}`;
|
|
965
|
+
});
|
|
966
|
+
orderSql.push(`${rendered.aliasSql}.${dialect.rowIdentity()}`);
|
|
967
|
+
sql += ` ORDER BY ${orderSql.join(', ')}`;
|
|
968
|
+
if (spec?.take !== undefined || pagination === 'offset') {
|
|
969
|
+
sql += ` ${dialect.limitClause(spec?.take ?? null,
|
|
970
|
+
pagination === 'offset' ? spec.skip : undefined)}`;
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
const entry = { sql, slots, tree, pagination, statement: null };
|
|
974
|
+
const sizeBefore = state.cache.size();
|
|
975
|
+
if (state.cache.set(key, entry) && state.cache.size() === sizeBefore)
|
|
976
|
+
state.counters.evictions++;
|
|
977
|
+
return entry;
|
|
978
|
+
};
|
|
979
|
+
|
|
980
|
+
return {
|
|
981
|
+
treeFor(spec) {
|
|
982
|
+
return buildLoad(spec).tree;
|
|
983
|
+
},
|
|
984
|
+
load(spec) {
|
|
985
|
+
const entry = buildLoad(spec);
|
|
986
|
+
if (entry.statement === null) entry.statement = connection.prepare(entry.sql);
|
|
987
|
+
const params = entry.slots.map((slot) => slot.literal);
|
|
988
|
+
return chain(entry.statement, (statement) =>
|
|
989
|
+
chain(statement.all(params), (rows) =>
|
|
990
|
+
rows.map((row) => parseGraphRow(entry.tree, row, '__doc'))));
|
|
991
|
+
},
|
|
992
|
+
explainLoad(spec) {
|
|
993
|
+
const entry = buildLoad(spec);
|
|
994
|
+
const describe = (node, path) => node.includes.flatMap((include) => [
|
|
995
|
+
{ path: [...path, include.name].join('.'), kind: include.kind,
|
|
996
|
+
count: include.count === true },
|
|
997
|
+
...(include.child === null ? [] : describe(include.child, [...path, include.name])),
|
|
998
|
+
]);
|
|
999
|
+
return {
|
|
1000
|
+
sql: entry.sql,
|
|
1001
|
+
pagination: entry.pagination,
|
|
1002
|
+
includes: describe(entry.tree, []),
|
|
1003
|
+
};
|
|
1004
|
+
},
|
|
1005
|
+
};
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
/**
|
|
1009
|
+
* @typedef {{ literal?: any, external?: string }[]} ParamCollector
|
|
1010
|
+
*/
|