@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/emit.js
ADDED
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file Plan → SQL through a dialect. This is the query layer's shared
|
|
4
|
+
* emitter, the same division of labour as `createDialect`'s DDL/DML
|
|
5
|
+
* builders: structural SQL composition lives here, every
|
|
6
|
+
* dialect-varying spelling (identifiers, parameters, string literals,
|
|
7
|
+
* JSON access, `json_type`, `typeof`, string-operator forms, NULLS
|
|
8
|
+
* placement, EXPLAIN phrasing) comes from the dialect. Values are
|
|
9
|
+
* NEVER interpolated into the text: every literal and every external
|
|
10
|
+
* becomes an ordered parameter slot, which is what makes injection
|
|
11
|
+
* structurally impossible.
|
|
12
|
+
*
|
|
13
|
+
* Every emitted predicate is TOTAL (two-valued) by construction — the
|
|
14
|
+
* `json_type` guards from the truth table in ARCHITECTURE.md — so
|
|
15
|
+
* `NOT`/`AND`/`OR` compose classically and SQL's three-valued NULL
|
|
16
|
+
* logic never decides a row.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @typedef {{ external: string } | { literal: unknown }} ParamSlot
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Emit one plan as SQL plus its ordered parameter slots.
|
|
25
|
+
* @param {import('./algebra.js').Plan} plan
|
|
26
|
+
* @param {any} dialect
|
|
27
|
+
* @param {{ table: string, keyColumn: string, docColumn: string }} physical
|
|
28
|
+
* @returns {{ sql: string, slots: ParamSlot[] }}
|
|
29
|
+
*/
|
|
30
|
+
export function emitPlan(plan, dialect, physical) {
|
|
31
|
+
const q = dialect.quoteIdentifier;
|
|
32
|
+
const docColumn = q(physical.docColumn);
|
|
33
|
+
/** @type {ParamSlot[]} */
|
|
34
|
+
const slots = [];
|
|
35
|
+
const param = (slot) => {
|
|
36
|
+
slots.push(slot);
|
|
37
|
+
return dialect.parameterRef(slots.length, 'external' in slot ? slot.external : 'value');
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** SQL for a ref's VALUE: the generated column when one exists. */
|
|
41
|
+
const valueOf = (ref) =>
|
|
42
|
+
(ref.column !== null ? q(ref.column) : dialect.jsonExtract(docColumn, pathTextOf(ref)));
|
|
43
|
+
const pathTextOf = (ref) => {
|
|
44
|
+
const text = dialect.jsonPathText(ref.segments);
|
|
45
|
+
if (text === null) {
|
|
46
|
+
// the planner never promotes an unrepresentable path; reaching
|
|
47
|
+
// this is an internal inconsistency, not a user error
|
|
48
|
+
throw new Error('emit: a promoted path is not representable in the dialect JSON path grammar');
|
|
49
|
+
}
|
|
50
|
+
return text;
|
|
51
|
+
};
|
|
52
|
+
/** The presence/type discriminator, always over the document column. */
|
|
53
|
+
const typeOf = (ref) => dialect.jsonTypeOf(docColumn, pathTextOf(ref));
|
|
54
|
+
|
|
55
|
+
const sl = dialect.stringLiteral;
|
|
56
|
+
const NUMERIC = () => `(${sl('integer')}, ${sl('real')})`;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The guarded, total comparison forms of the truth table.
|
|
60
|
+
* @param {any} pred
|
|
61
|
+
* @returns {string}
|
|
62
|
+
*/
|
|
63
|
+
const emitCmp = (pred) => {
|
|
64
|
+
const jt = typeOf(pred.ref);
|
|
65
|
+
const value = valueOf(pred.ref);
|
|
66
|
+
const symbol = { eq: '=', ne: '<>', lt: '<', le: '<=', gt: '>', ge: '>=' }[pred.op];
|
|
67
|
+
if ('lit' in pred.operand) {
|
|
68
|
+
const lit = pred.operand.lit;
|
|
69
|
+
const kind = typeof lit === 'number' ? 'number' : 'string';
|
|
70
|
+
const typeGuard = kind === 'number'
|
|
71
|
+
? `${jt} IN ${NUMERIC()}`
|
|
72
|
+
: `${jt} = ${sl('text')}`;
|
|
73
|
+
if (pred.op === 'ne') {
|
|
74
|
+
// present AND (other type OR value differs): cross-type ne is
|
|
75
|
+
// true for a PRESENT value, false for a missing one
|
|
76
|
+
const notType = kind === 'number'
|
|
77
|
+
? `${jt} NOT IN ${NUMERIC()}`
|
|
78
|
+
: `${jt} <> ${sl('text')}`;
|
|
79
|
+
return `(${jt} IS NOT NULL AND (${notType} OR ${value} <> ${param({ literal: lit })}))`;
|
|
80
|
+
}
|
|
81
|
+
// the presence prefix keeps the form TOTAL: `NULL IN (...)` is
|
|
82
|
+
// NULL, and a NULL escaping through a NOT flips a row's fate
|
|
83
|
+
return `(${jt} IS NOT NULL AND ${typeGuard} AND ${value} ${symbol} ${param({ literal: lit })})`;
|
|
84
|
+
}
|
|
85
|
+
// external operand: its JSON type is only knowable at bind time —
|
|
86
|
+
// guard BOTH sides per branch (text with text, number with number)
|
|
87
|
+
const name = pred.operand.ext;
|
|
88
|
+
const textBranch = `(${jt} IS NOT NULL AND ${jt} = ${sl('text')} AND `
|
|
89
|
+
+ `${dialect.valueTypeOf(param({ external: name }))} = ${sl('text')} AND `
|
|
90
|
+
+ `${value} ${pred.op === 'ne' ? '=' : symbol} ${param({ external: name })})`;
|
|
91
|
+
const numberBranch = `(${jt} IS NOT NULL AND ${jt} IN ${NUMERIC()} AND `
|
|
92
|
+
+ `${dialect.valueTypeOf(param({ external: name }))} IN ${NUMERIC()} AND `
|
|
93
|
+
+ `${value} ${pred.op === 'ne' ? '=' : symbol} ${param({ external: name })})`;
|
|
94
|
+
const equalInSomeBranch = `(${textBranch} OR ${numberBranch})`;
|
|
95
|
+
return pred.op === 'ne'
|
|
96
|
+
? `(${jt} IS NOT NULL AND NOT ${equalInSomeBranch})`
|
|
97
|
+
: equalInSomeBranch;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* @param {import('./algebra.js').PlanPredicate} pred
|
|
102
|
+
* @returns {string}
|
|
103
|
+
*/
|
|
104
|
+
const emitPred = (pred) => {
|
|
105
|
+
switch (pred.p) {
|
|
106
|
+
case 'and':
|
|
107
|
+
return `(${pred.items.map(emitPred).join(' AND ')})`;
|
|
108
|
+
case 'or':
|
|
109
|
+
return `(${pred.items.map(emitPred).join(' OR ')})`;
|
|
110
|
+
case 'not':
|
|
111
|
+
return `NOT ${emitPred(pred.item)}`;
|
|
112
|
+
case 'const':
|
|
113
|
+
return pred.value ? dialect.booleanLiteral(true) : dialect.booleanLiteral(false);
|
|
114
|
+
case 'cmp':
|
|
115
|
+
return emitCmp(pred);
|
|
116
|
+
case 'typeIs': {
|
|
117
|
+
const jt = typeOf(pred.ref);
|
|
118
|
+
if (pred.types.length === 0) {
|
|
119
|
+
// bare existence: positive is $exists, negative is $empty
|
|
120
|
+
return pred.positive ? `${jt} IS NOT NULL` : `${jt} IS NULL`;
|
|
121
|
+
}
|
|
122
|
+
const list = pred.types.map(sl).join(', ');
|
|
123
|
+
return pred.positive
|
|
124
|
+
? (pred.types.length === 1
|
|
125
|
+
? `(${jt} IS NOT NULL AND ${jt} = ${sl(pred.types[0])})`
|
|
126
|
+
: `(${jt} IS NOT NULL AND ${jt} IN (${list}))`)
|
|
127
|
+
: `(${jt} IS NOT NULL AND ${jt} NOT IN (${list}))`;
|
|
128
|
+
}
|
|
129
|
+
case 'udf':
|
|
130
|
+
// the registered deterministic predicate: reads the row's
|
|
131
|
+
// document as JSON text, answers 1 or 0 (always total)
|
|
132
|
+
return `${pred.name}(${dialect.jsonText(docColumn)})`;
|
|
133
|
+
case 'strop': {
|
|
134
|
+
const jt = typeOf(pred.ref);
|
|
135
|
+
const value = valueOf(pred.ref);
|
|
136
|
+
const bind = () => param({ literal: /** @type {any} */ (pred.operand).lit });
|
|
137
|
+
const form = pred.kind === 'starts'
|
|
138
|
+
? dialect.strStartsWith(value, bind(), bind())
|
|
139
|
+
: pred.kind === 'ends'
|
|
140
|
+
? dialect.strEndsWith(value, bind(), bind(), bind())
|
|
141
|
+
: dialect.strContains(value, bind());
|
|
142
|
+
return `(${jt} IS NOT NULL AND ${jt} = ${sl('text')} AND ${form})`;
|
|
143
|
+
}
|
|
144
|
+
default:
|
|
145
|
+
throw new Error(`emit: unknown predicate node '${/** @type {any} */ (pred).p}'`);
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const selection = plan.aggregate === null
|
|
150
|
+
? `${dialect.jsonText(docColumn)} AS ${q('doc')}`
|
|
151
|
+
: plan.aggregate.fn === 'count'
|
|
152
|
+
? `COUNT(*) AS ${q('value')}`
|
|
153
|
+
: `${plan.aggregate.fn.toUpperCase()}(${valueOf(plan.aggregate.ref)}) AS ${q('value')}`;
|
|
154
|
+
|
|
155
|
+
let sql = `SELECT ${selection} FROM ${q(physical.table)}`;
|
|
156
|
+
if (plan.filter !== null) sql += ` WHERE ${emitPred(plan.filter)}`;
|
|
157
|
+
if (plan.aggregate === null) {
|
|
158
|
+
const terms = (plan.order ?? []).map((term) => {
|
|
159
|
+
// Jaren's default sorts an empty key least: NULLS FIRST when
|
|
160
|
+
// ascending, NULLS LAST when descending — and mirrored for
|
|
161
|
+
// $empty: 'greatest' (probed against the engine)
|
|
162
|
+
const nullsFirst = term.emptyGreatest === term.desc;
|
|
163
|
+
return `${valueOf(term.ref)} ${term.desc ? 'DESC' : 'ASC'}${dialect.orderNulls(nullsFirst)}`;
|
|
164
|
+
});
|
|
165
|
+
// the collection is a SEQUENCE: its order is insertion (row
|
|
166
|
+
// identity) order, and the engine's sort is stable — the identity
|
|
167
|
+
// tiebreaker reproduces both, and without it the database is free
|
|
168
|
+
// to answer in index order
|
|
169
|
+
terms.push(dialect.rowIdentity());
|
|
170
|
+
sql += ` ORDER BY ${terms.join(', ')}`;
|
|
171
|
+
}
|
|
172
|
+
if (plan.window !== null && plan.aggregate === null) {
|
|
173
|
+
sql += ` ${dialect.limitClause(plan.window.limit, plan.window.offset)}`;
|
|
174
|
+
}
|
|
175
|
+
return { sql, slots };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ————— The entity document kind (one emitter layer, two kinds) —————
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The entity predicate emitters, shared by the entity plan emitter
|
|
182
|
+
* and the graph-load builder: given an alias and its document column,
|
|
183
|
+
* emit one predicate with the flavor-correct forms.
|
|
184
|
+
* @param {any} dialect
|
|
185
|
+
* @param {(slot: ParamSlot) => string} param
|
|
186
|
+
* @returns {{ emitPred: (aliasSql: string, docSql: string, pred: any) => string }}
|
|
187
|
+
*/
|
|
188
|
+
export function createEntityPredicateEmitters(dialect, param) {
|
|
189
|
+
const q = dialect.quoteIdentifier;
|
|
190
|
+
const sl = dialect.stringLiteral;
|
|
191
|
+
const NUMERIC = () => `(${sl('integer')}, ${sl('real')})`;
|
|
192
|
+
const pathTextOf = (ref) => {
|
|
193
|
+
const text = dialect.jsonPathText(ref.segments);
|
|
194
|
+
if (text === null)
|
|
195
|
+
throw new Error('emit: a promoted entity path is not representable');
|
|
196
|
+
return text;
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const emitDocPred = (docSql, pred) => {
|
|
200
|
+
const jt = dialect.jsonTypeOf(docSql, pathTextOf(pred.ref));
|
|
201
|
+
const value = dialect.jsonExtract(docSql, pathTextOf(pred.ref));
|
|
202
|
+
if (pred.p === 'typeIs') {
|
|
203
|
+
if (pred.types.length === 0)
|
|
204
|
+
return pred.positive ? `${jt} IS NOT NULL` : `${jt} IS NULL`;
|
|
205
|
+
const list = pred.types.map(sl).join(', ');
|
|
206
|
+
return pred.positive
|
|
207
|
+
? `(${jt} IS NOT NULL AND ${pred.types.length === 1
|
|
208
|
+
? `${jt} = ${sl(pred.types[0])}` : `${jt} IN (${list})`})`
|
|
209
|
+
: `(${jt} IS NOT NULL AND ${jt} NOT IN (${list}))`;
|
|
210
|
+
}
|
|
211
|
+
if (pred.p === 'strop') {
|
|
212
|
+
const bind = () => param({ literal: pred.operand.lit });
|
|
213
|
+
const form = pred.kind === 'starts'
|
|
214
|
+
? dialect.strStartsWith(value, bind(), bind())
|
|
215
|
+
: pred.kind === 'ends'
|
|
216
|
+
? dialect.strEndsWith(value, bind(), bind(), bind())
|
|
217
|
+
: dialect.strContains(value, bind());
|
|
218
|
+
return `(${jt} IS NOT NULL AND ${jt} = ${sl('text')} AND ${form})`;
|
|
219
|
+
}
|
|
220
|
+
const lit = pred.operand.lit;
|
|
221
|
+
const symbol = { eq: '=', ne: '<>', lt: '<', le: '<=', gt: '>', ge: '>=' }[pred.op];
|
|
222
|
+
const kind = typeof lit === 'number' ? 'number' : 'string';
|
|
223
|
+
if (pred.op === 'ne') {
|
|
224
|
+
const notType = kind === 'number'
|
|
225
|
+
? `${jt} NOT IN ${NUMERIC()}` : `${jt} <> ${sl('text')}`;
|
|
226
|
+
return `(${jt} IS NOT NULL AND (${notType} OR ${value} <> ${param({ literal: lit })}))`;
|
|
227
|
+
}
|
|
228
|
+
const typeGuard = kind === 'number'
|
|
229
|
+
? `${jt} IN ${NUMERIC()}` : `${jt} = ${sl('text')}`;
|
|
230
|
+
return `(${jt} IS NOT NULL AND ${typeGuard} AND ${value} ${symbol} ${param({ literal: lit })})`;
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
const emitColumnPred = (aliasSql, pred) => {
|
|
234
|
+
const column = `${aliasSql}.${q(pred.ref.column)}`;
|
|
235
|
+
if (pred.p === 'typeIs') {
|
|
236
|
+
if (pred.types.length === 0)
|
|
237
|
+
return pred.positive ? `${column} IS NOT NULL` : `${column} IS NULL`;
|
|
238
|
+
if (pred.types[0] === 'null')
|
|
239
|
+
return pred.positive ? dialect.booleanLiteral(false) : `${column} IS NOT NULL`;
|
|
240
|
+
const wanted = pred.types[0] === 'true' ? 1 : 0;
|
|
241
|
+
return pred.positive
|
|
242
|
+
? `(${column} IS NOT NULL AND ${column} = ${param({ literal: wanted })})`
|
|
243
|
+
: `(${column} IS NOT NULL AND ${column} <> ${param({ literal: wanted })})`;
|
|
244
|
+
}
|
|
245
|
+
if (pred.p === 'strop') {
|
|
246
|
+
const bind = () => param({ literal: pred.operand.lit });
|
|
247
|
+
const form = pred.kind === 'starts'
|
|
248
|
+
? dialect.strStartsWith(column, bind(), bind())
|
|
249
|
+
: pred.kind === 'ends'
|
|
250
|
+
? dialect.strEndsWith(column, bind(), bind(), bind())
|
|
251
|
+
: dialect.strContains(column, bind());
|
|
252
|
+
return `(${column} IS NOT NULL AND ${form})`;
|
|
253
|
+
}
|
|
254
|
+
const symbol = { eq: '=', ne: '<>', lt: '<', le: '<=', gt: '>', ge: '>=' }[pred.op];
|
|
255
|
+
if ('ext' in pred.operand) {
|
|
256
|
+
const guard = pred.ref.storage === 'string'
|
|
257
|
+
? `${dialect.valueTypeOf(param({ external: pred.operand.ext }))} = ${sl('text')}`
|
|
258
|
+
: `${dialect.valueTypeOf(param({ external: pred.operand.ext }))} IN ${NUMERIC()}`;
|
|
259
|
+
return `(${column} IS NOT NULL AND ${guard} AND ${column} ${pred.op === 'ne' ? '<>' : symbol} ${param({ external: pred.operand.ext })})`;
|
|
260
|
+
}
|
|
261
|
+
const lit = pred.operand.lit;
|
|
262
|
+
const litKind = typeof lit === 'number' ? 'number' : typeof lit === 'string' ? 'string' : 'other';
|
|
263
|
+
const storageKind = pred.ref.storage === 'string' ? 'string'
|
|
264
|
+
: pred.ref.storage === 'boolean' ? 'boolean' : 'number';
|
|
265
|
+
if (storageKind === 'boolean' || litKind === 'other' || storageKind !== litKind)
|
|
266
|
+
return pred.op === 'ne' ? `${column} IS NOT NULL` : dialect.booleanLiteral(false);
|
|
267
|
+
return `(${column} IS NOT NULL AND ${column} ${symbol} ${param({ literal: lit })})`;
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
// an epoch comparison: the derived integer column narrows through
|
|
271
|
+
// its index with ±1s slack (Z-normalized strings sharing a second
|
|
272
|
+
// prefix sit within one second, so the range is a superset of the
|
|
273
|
+
// codepoint comparison), and the document string decides exactly —
|
|
274
|
+
// the engine's lexicographic semantics, whatever precision the
|
|
275
|
+
// stored values carry
|
|
276
|
+
const emitEpochPred = (aliasSql, docSql, pred) => {
|
|
277
|
+
const column = `${aliasSql}.${q(pred.ref.column)}`;
|
|
278
|
+
const value = dialect.jsonExtract(docSql, pathTextOf(pred.ref));
|
|
279
|
+
const symbol = { eq: '=', lt: '<', le: '<=', gt: '>', ge: '>=' }[pred.op];
|
|
280
|
+
const range = pred.op === 'gt' || pred.op === 'ge'
|
|
281
|
+
? `${column} >= ${param({ literal: pred.epoch - 1000 })}`
|
|
282
|
+
: pred.op === 'lt' || pred.op === 'le'
|
|
283
|
+
? `${column} <= ${param({ literal: pred.epoch + 1000 })}`
|
|
284
|
+
: `${column} >= ${param({ literal: pred.epoch - 1000 })} AND ${column} <= ${param({ literal: pred.epoch + 1000 })}`;
|
|
285
|
+
return `(${column} IS NOT NULL AND ${range} AND ${value} ${symbol} ${param({ literal: pred.operand.lit })})`;
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
const emitPred = (aliasSql, docSql, pred) => {
|
|
289
|
+
if (pred.p === 'and')
|
|
290
|
+
return `(${pred.items.map((item) => emitPred(aliasSql, docSql, item)).join(' AND ')})`;
|
|
291
|
+
if (pred.p === 'or')
|
|
292
|
+
return `(${pred.items.map((item) => emitPred(aliasSql, docSql, item)).join(' OR ')})`;
|
|
293
|
+
if (pred.p === 'not') return `NOT ${emitPred(aliasSql, docSql, pred.item)}`;
|
|
294
|
+
if (pred.p === 'const')
|
|
295
|
+
return pred.value ? dialect.booleanLiteral(true) : dialect.booleanLiteral(false);
|
|
296
|
+
if (pred.ref?.flavor === 'entity-column')
|
|
297
|
+
return emitColumnPred(aliasSql, pred);
|
|
298
|
+
if (pred.ref?.flavor === 'entity-epoch') {
|
|
299
|
+
// only an instant comparison uses the column; presence, type
|
|
300
|
+
// tests, string operators and non-instant literals ride the
|
|
301
|
+
// guarded document forms, which stay sound for any stored value
|
|
302
|
+
if (pred.p === 'cmp' && 'epoch' in pred)
|
|
303
|
+
return emitEpochPred(aliasSql, docSql, pred);
|
|
304
|
+
return emitDocPred(docSql, pred);
|
|
305
|
+
}
|
|
306
|
+
return emitDocPred(docSql, pred);
|
|
307
|
+
};
|
|
308
|
+
return { emitPred };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Emit an entity plan (`entity-select` or `entity-join`) as SQL plus
|
|
313
|
+
* ordered parameter slots. Entity-COLUMN refs compare real typed
|
|
314
|
+
* columns with TOTAL forms and no `json_type` guard — a column-mapped
|
|
315
|
+
* property has no present-`null` (§9.3), so presence IS `IS NOT
|
|
316
|
+
* NULL`; entity-EPOCH refs compare the derived integer column against
|
|
317
|
+
* a plan-time epoch translation; entity-DOC refs ride the phase-A
|
|
318
|
+
* guarded truth table over the entity's JSONB column. Join emission
|
|
319
|
+
* appends BOTH bindings' row identities in binding order, which is
|
|
320
|
+
* exactly the engine's nested-loop order — determinism the oracle
|
|
321
|
+
* depends on.
|
|
322
|
+
* @param {any} plan - from `planEntityQuery`
|
|
323
|
+
* @param {any} dialect
|
|
324
|
+
* @param {(entity: string) => { table: string }} physicalOf
|
|
325
|
+
* @returns {{ sql: string, slots: ParamSlot[] }}
|
|
326
|
+
*/
|
|
327
|
+
export function emitEntityPlan(plan, dialect, physicalOf) {
|
|
328
|
+
const q = dialect.quoteIdentifier;
|
|
329
|
+
/** @type {ParamSlot[]} */
|
|
330
|
+
const slots = [];
|
|
331
|
+
const param = (slot) => {
|
|
332
|
+
slots.push(slot);
|
|
333
|
+
return dialect.parameterRef(slots.length, 'external' in slot ? slot.external : 'value');
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
const aliases = new Map(plan.bindings.map((binding, i) => [
|
|
337
|
+
binding.name, { alias: q(`t${i}`), entity: binding.entity },
|
|
338
|
+
]));
|
|
339
|
+
const aliasOf = (bindingName) => aliases.get(bindingName).alias;
|
|
340
|
+
const docOf = (bindingName) => `${aliasOf(bindingName)}.${q('doc')}`;
|
|
341
|
+
|
|
342
|
+
const pathTextOf = (ref) => {
|
|
343
|
+
const text = dialect.jsonPathText(ref.segments);
|
|
344
|
+
if (text === null)
|
|
345
|
+
throw new Error('emit: a promoted entity path is not representable');
|
|
346
|
+
return text;
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
const emitters = createEntityPredicateEmitters(dialect, param);
|
|
350
|
+
const emitPred = (bindingName, pred) =>
|
|
351
|
+
emitters.emitPred(aliasOf(bindingName), docOf(bindingName), pred);
|
|
352
|
+
|
|
353
|
+
const ret = plan.ret;
|
|
354
|
+
// every returned column plus the document rendered to text; the
|
|
355
|
+
// caller merges them back into the entity shape
|
|
356
|
+
const selection = plan.aggregate === 'count'
|
|
357
|
+
? `COUNT(*) AS ${q('value')}`
|
|
358
|
+
: `${aliasOf(ret)}.*, ${dialect.jsonText(docOf(ret))} AS ${q('__doc')}`;
|
|
359
|
+
|
|
360
|
+
let sql = `SELECT ${selection} FROM `;
|
|
361
|
+
sql += plan.bindings
|
|
362
|
+
.map((binding) => `${q(physicalOf(binding.entity).table)} AS ${aliasOf(binding.name)}`)
|
|
363
|
+
.join(' JOIN ');
|
|
364
|
+
if (plan.joinOn !== null) {
|
|
365
|
+
sql += ` ON ${aliasOf(plan.joinOn.left.binding)}.${q(plan.joinOn.left.column)}`
|
|
366
|
+
+ ` = ${aliasOf(plan.joinOn.right.binding)}.${q(plan.joinOn.right.column)}`;
|
|
367
|
+
}
|
|
368
|
+
const filterSql = plan.filters
|
|
369
|
+
.filter((entry) => entry.filter !== null)
|
|
370
|
+
.map((entry) => emitPred(entry.binding, entry.filter));
|
|
371
|
+
if (filterSql.length > 0) sql += ` WHERE ${filterSql.join(' AND ')}`;
|
|
372
|
+
|
|
373
|
+
if (plan.aggregate === null) {
|
|
374
|
+
const terms = (plan.order ?? []).map((term) => {
|
|
375
|
+
// only a plain mapped column orders by its column; an epoch
|
|
376
|
+
// path orders by the document string — codepoint order, exactly
|
|
377
|
+
// the engine's — because mixed stored precisions would let the
|
|
378
|
+
// integer column sort differently
|
|
379
|
+
const value = term.ref.flavor === 'entity-column'
|
|
380
|
+
? `${aliasOf(term.binding)}.${q(term.ref.column)}`
|
|
381
|
+
: dialect.jsonExtract(docOf(term.binding), pathTextOf(term.ref));
|
|
382
|
+
const nullsFirst = term.emptyGreatest === term.desc;
|
|
383
|
+
return `${value} ${term.desc ? 'DESC' : 'ASC'}${dialect.orderNulls(nullsFirst)}`;
|
|
384
|
+
});
|
|
385
|
+
// the engine's nested-loop order: binding-order row identities
|
|
386
|
+
for (const binding of plan.bindings)
|
|
387
|
+
terms.push(`${aliasOf(binding.name)}.${dialect.rowIdentity()}`);
|
|
388
|
+
sql += ` ORDER BY ${terms.join(', ')}`;
|
|
389
|
+
if (plan.window !== null)
|
|
390
|
+
sql += ` ${dialect.limitClause(plan.window.limit, plan.window.offset)}`;
|
|
391
|
+
}
|
|
392
|
+
return { sql, slots };
|
|
393
|
+
}
|