@jarenjs/db 0.56.0 → 0.67.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 +412 -56
- package/README.md +600 -57
- package/docs/HOSTS.md +269 -0
- package/docs/JOBS-FORMAT.md +293 -45
- package/docs/LIVE-FORMAT.md +169 -20
- package/docs/MIGRATION-FORMAT.md +142 -17
- package/docs/MODEL-FORMAT.md +752 -64
- package/docs/REPLICATION-FORMAT.md +208 -0
- package/package.json +21 -7
- package/schemas/jaren-model.draft-07.schema.json +224 -162
- package/schemas/jaren-model.schema.json +224 -162
- package/schemas/jaren-replication-snapshot.draft-07.schema.json +83 -0
- package/schemas/jaren-replication-snapshot.schema.json +83 -0
- package/schemas/jaren-replication.draft-07.schema.json +82 -0
- package/schemas/jaren-replication.schema.json +82 -0
- package/src/algebra.js +227 -9
- package/src/backup.js +161 -0
- package/src/cancellation.js +48 -0
- package/src/capture.js +230 -47
- package/src/cli.js +165 -59
- package/src/cursor.js +417 -0
- package/src/dag-job.js +154 -21
- package/src/ddl.js +102 -8
- package/src/dialect.js +268 -113
- package/src/dialects/expression-read.js +158 -0
- package/src/dialects/postgres.js +618 -0
- package/src/dialects/rtree-ddl.js +129 -0
- package/src/dialects/sqlite.js +244 -11
- package/src/document-files.js +311 -0
- package/src/document-steps.js +422 -0
- package/src/documents.js +335 -0
- package/src/driver.js +448 -61
- package/src/drivers/bun.js +37 -1
- package/src/drivers/indexeddb-snapshot.js +149 -0
- package/src/drivers/node-pool.js +11 -0
- package/src/drivers/node-worker-endpoint.js +105 -0
- package/src/drivers/node-worker.js +204 -0
- package/src/drivers/node.js +41 -7
- package/src/drivers/postgres.js +331 -0
- package/src/drivers/wasm-oo1.js +97 -0
- package/src/drivers/wasm-session.js +67 -0
- package/src/drivers/wasm.js +17 -83
- package/src/drivers/worker-pool.js +183 -0
- package/src/drivers/worker-protocol.js +79 -0
- package/src/drivers/worker-queue.js +60 -0
- package/src/emit.js +339 -48
- package/src/entity.js +20 -22
- package/src/errors.js +430 -19
- package/src/expression.js +284 -0
- package/src/graph.js +64 -8
- package/src/index.js +48 -17
- package/src/introspect.js +583 -0
- package/src/jobs.js +843 -107
- package/src/json-bytes.js +58 -0
- package/src/live-join.js +250 -0
- package/src/live-nested.js +120 -0
- package/src/live.js +18 -4
- package/src/logical-rows.js +90 -0
- package/src/maintenance.js +175 -0
- package/src/migrate.js +248 -181
- package/src/model.js +68 -0
- package/src/plan.js +1119 -138
- package/src/pragmas.js +314 -0
- package/src/profile.js +151 -3
- package/src/query.js +1634 -323
- package/src/replication-format.js +115 -0
- package/src/replication.js +332 -0
- package/src/residual.js +17 -0
- package/src/series.js +12 -4
- package/src/store.js +1567 -273
- package/src/tracker.js +203 -29
- package/src/udf.js +88 -7
- package/types/index.d.ts +1158 -27
- package/types/node-pool.d.ts +28 -0
- package/types/node-worker.d.ts +54 -0
- package/types/node.d.ts +69 -2
- package/types/postgres.d.ts +46 -0
- package/types/typed.d.ts +27 -4
- package/types/wasm.d.ts +14 -0
package/src/emit.js
CHANGED
|
@@ -29,8 +29,9 @@ export class UnrepresentablePath extends Error {}
|
|
|
29
29
|
/**
|
|
30
30
|
* @typedef {{ external: string } | { literal: unknown } |
|
|
31
31
|
* { derived: { kind: 'bboxAxis', external: string,
|
|
32
|
-
* axis: 'w' | 's' | 'e' | 'n' } }
|
|
33
|
-
*
|
|
32
|
+
* axis: 'w' | 's' | 'e' | 'n' } } |
|
|
33
|
+
* { typed: { seek: string, type: 'number' | 'text' } }} ParamSlot
|
|
34
|
+
* Four kinds, closed. A DERIVED slot is the escape for a value SQL
|
|
34
35
|
* cannot bind at all: a GeoJSON region arrives as an external object,
|
|
35
36
|
* and what the statement needs is one edge of its bounding box, so
|
|
36
37
|
* the binder computes that edge from the bound value. It is the same
|
|
@@ -38,6 +39,14 @@ export class UnrepresentablePath extends Error {}
|
|
|
38
39
|
* cannot, bind an ordinary parameter — and it stays closed on
|
|
39
40
|
* purpose: a general expression slot would be a second query language
|
|
40
41
|
* living in the emitter.
|
|
42
|
+
*
|
|
43
|
+
* A TYPED slot is the other direction: a scalar whose JSON type is
|
|
44
|
+
* PROVEN before the statement binds — the database's own answer to
|
|
45
|
+
* one of the plan's seeks, read from a column of declared type. An
|
|
46
|
+
* external's type is only knowable at bind time, so its comparison
|
|
47
|
+
* carries a text branch beside a number branch; a typed slot carries
|
|
48
|
+
* one guarded comparison, the same shape a literal gets, and the
|
|
49
|
+
* binder refuses a value of the wrong type before any SQL runs.
|
|
41
50
|
*/
|
|
42
51
|
|
|
43
52
|
/**
|
|
@@ -77,6 +86,7 @@ function stropForm(dialect, param, valueSql, pred) {
|
|
|
77
86
|
function slotName(slot) {
|
|
78
87
|
if ('external' in slot) return slot.external;
|
|
79
88
|
if ('derived' in slot) return slot.derived.external;
|
|
89
|
+
if ('typed' in slot) return slot.typed.seek;
|
|
80
90
|
return 'value';
|
|
81
91
|
}
|
|
82
92
|
|
|
@@ -114,14 +124,29 @@ export function emitPlan(plan, dialect, physical) {
|
|
|
114
124
|
const docColumn = q(physical.docColumn);
|
|
115
125
|
/** @type {ParamSlot[]} */
|
|
116
126
|
const slots = [];
|
|
127
|
+
// the statement being emitted owns its slots: a positional dialect
|
|
128
|
+
// numbers by that statement's own text order, so a SEEK emitted
|
|
129
|
+
// beside the main statement numbers from one again
|
|
130
|
+
let sink = slots;
|
|
117
131
|
const param = (slot) => {
|
|
118
|
-
|
|
119
|
-
return dialect.parameterRef(
|
|
132
|
+
sink.push(slot);
|
|
133
|
+
return dialect.parameterRef(sink.length, slotName(slot));
|
|
120
134
|
};
|
|
121
135
|
|
|
122
|
-
/**
|
|
123
|
-
|
|
124
|
-
|
|
136
|
+
/**
|
|
137
|
+
* SQL for a ref's VALUE: the generated column when one exists AND
|
|
138
|
+
* this dialect can compare that column's declared type against a
|
|
139
|
+
* value of `kind`. Where it cannot — an engine whose columns carry a
|
|
140
|
+
* real SQL type, asked to compare a text column with a number — the
|
|
141
|
+
* member is read out of the document instead. Same answer, unindexed,
|
|
142
|
+
* and never a type error the row's own type guard already excludes.
|
|
143
|
+
* @param {any} ref
|
|
144
|
+
* @param {'any' | 'text' | 'number' | 'boolean'} [kind]
|
|
145
|
+
*/
|
|
146
|
+
const valueOf = (ref, kind = 'any') =>
|
|
147
|
+
(ref.column !== null && dialect.columnUsableFor(ref.type, kind)
|
|
148
|
+
? q(ref.column)
|
|
149
|
+
: dialect.jsonExtract(docColumn, pathTextOf(ref), kind));
|
|
125
150
|
const pathTextOf = (ref) => {
|
|
126
151
|
const text = dialect.jsonPathText(ref.segments);
|
|
127
152
|
if (text === null) {
|
|
@@ -132,9 +157,79 @@ export function emitPlan(plan, dialect, physical) {
|
|
|
132
157
|
};
|
|
133
158
|
/** The presence/type discriminator, always over the document column. */
|
|
134
159
|
const typeOf = (ref) => dialect.jsonTypeOf(docColumn, pathTextOf(ref));
|
|
160
|
+
/** One projected member: its value beside its JSON type, under a
|
|
161
|
+
* suffixed pair of names the decoder reads back. */
|
|
162
|
+
const projectedPair = (ref, suffix) =>
|
|
163
|
+
`CASE WHEN ${typeOf(ref)} IN (${sl('object')}, ${sl('array')}) `
|
|
164
|
+
+ `THEN ${dialect.jsonText(dialect.jsonExtract(docColumn, pathTextOf(ref)))} `
|
|
165
|
+
// a SCALAR leaf comes back as the value the decoder reads: the
|
|
166
|
+
// engine's own scalar where it has one, its text where every
|
|
167
|
+
// member is one JSON type and a CASE could not answer two
|
|
168
|
+
+ `ELSE ${dialect.jsonExtract(docColumn, pathTextOf(ref), 'scalar')} `
|
|
169
|
+
+ `END AS ${q(`v${suffix}`)}, ${typeOf(ref)} AS ${q(`t${suffix}`)}`;
|
|
135
170
|
|
|
136
171
|
const sl = dialect.stringLiteral;
|
|
137
|
-
|
|
172
|
+
/** The discriminator's spellings for a JSON NUMBER, as this engine
|
|
173
|
+
* answers them: SQLite keeps `integer` and `real` apart, an engine
|
|
174
|
+
* with one JSON number type answers one name. */
|
|
175
|
+
const NUMERIC = () => `(${dialect.numericTypeNames.map(sl).join(', ')})`;
|
|
176
|
+
|
|
177
|
+
/** The comparison kind a member's DECLARED schema type implies —
|
|
178
|
+
* what a fold or an ordering over it reads the member as. An
|
|
179
|
+
* undeclared type has none, and the member is read whole. */
|
|
180
|
+
const kindOf = (ref) => {
|
|
181
|
+
switch (ref?.type) {
|
|
182
|
+
case 'integer': case 'number': return 'number';
|
|
183
|
+
case 'string': return 'text';
|
|
184
|
+
case 'boolean': return 'boolean';
|
|
185
|
+
default: return 'any';
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* One aggregate's argument, read at the kind the FOLD needs rather
|
|
191
|
+
* than at the member's own: a sum or an average is arithmetic
|
|
192
|
+
* whatever the schema says, an extreme is the member's own ordering.
|
|
193
|
+
*
|
|
194
|
+
* An engine whose columns carry a real SQL type has no fold over a
|
|
195
|
+
* member with no declared type — there is no `SUM` of a JSON value —
|
|
196
|
+
* so the whole document runs in the set residual, named, rather than
|
|
197
|
+
* reaching the database as SQL it will refuse.
|
|
198
|
+
* @param {string} fn
|
|
199
|
+
* @param {any} ref
|
|
200
|
+
* @returns {string | null}
|
|
201
|
+
*/
|
|
202
|
+
const foldValue = (fn, ref) => {
|
|
203
|
+
if (ref === null || ref === undefined) return null;
|
|
204
|
+
const kind = fn === 'sum' || fn === 'avg' ? 'number' : kindOf(ref);
|
|
205
|
+
if (kind === 'any' && dialect.capabilities.untypedColumns !== true) {
|
|
206
|
+
throw new UnrepresentablePath(
|
|
207
|
+
`a ${fn} over a member the schema does not type has no fold on this engine`);
|
|
208
|
+
}
|
|
209
|
+
return valueOf(ref, kind);
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* One reference to an EXTERNAL's bound value. The slot carries the
|
|
214
|
+
* dialect's encoding with it, because the binder has no dialect: a
|
|
215
|
+
* `json` slot binds the value's JSON text, which is the one encoding
|
|
216
|
+
* a placeholder can hold whatever the member's type turns out to be.
|
|
217
|
+
* @param {string} name
|
|
218
|
+
* @returns {string}
|
|
219
|
+
*/
|
|
220
|
+
const externalSlot = (name) => param(dialect.externalEncoding === 'json'
|
|
221
|
+
? { external: name, json: true }
|
|
222
|
+
: { external: name });
|
|
223
|
+
|
|
224
|
+
/** The slot a bare column comparison binds through: a plan-time
|
|
225
|
+
* literal, or the scalar one of the plan's own seeks answers. An
|
|
226
|
+
* external is not among them — its type is unknowable at plan time,
|
|
227
|
+
* which is exactly what the guarded form exists for. */
|
|
228
|
+
const slotFor = (operand) => {
|
|
229
|
+
if ('lit' in operand) return { literal: operand.lit };
|
|
230
|
+
const seek = (plan.seeks ?? []).find((entry) => entry.name === operand.seek);
|
|
231
|
+
return { typed: { seek: seek.name, type: seek.kind } };
|
|
232
|
+
};
|
|
138
233
|
|
|
139
234
|
/**
|
|
140
235
|
* The guarded, total comparison forms of the truth table.
|
|
@@ -143,11 +238,11 @@ export function emitPlan(plan, dialect, physical) {
|
|
|
143
238
|
*/
|
|
144
239
|
const emitCmp = (pred) => {
|
|
145
240
|
const jt = typeOf(pred.ref);
|
|
146
|
-
const value = valueOf(pred.ref);
|
|
147
241
|
const symbol = { eq: '=', ne: '<>', lt: '<', le: '<=', gt: '>', ge: '>=' }[pred.op];
|
|
148
242
|
if ('lit' in pred.operand) {
|
|
149
243
|
const lit = pred.operand.lit;
|
|
150
|
-
const kind = typeof lit === 'number' ? 'number' : '
|
|
244
|
+
const kind = typeof lit === 'number' ? 'number' : 'text';
|
|
245
|
+
const value = valueOf(pred.ref, kind);
|
|
151
246
|
const typeGuard = kind === 'number'
|
|
152
247
|
? `${jt} IN ${NUMERIC()}`
|
|
153
248
|
: `${jt} = ${sl('text')}`;
|
|
@@ -163,15 +258,33 @@ export function emitPlan(plan, dialect, physical) {
|
|
|
163
258
|
// NULL, and a NULL escaping through a NOT flips a row's fate
|
|
164
259
|
return `(${jt} IS NOT NULL AND ${typeGuard} AND ${value} ${symbol} ${param({ literal: lit })})`;
|
|
165
260
|
}
|
|
261
|
+
if ('seek' in pred.operand) {
|
|
262
|
+
// a seek's scalar comes from a column of DECLARED type, so its
|
|
263
|
+
// JSON type is known here: one guarded comparison, no branch
|
|
264
|
+
const seek = (plan.seeks ?? []).find((entry) => entry.name === pred.operand.seek);
|
|
265
|
+
const value = valueOf(pred.ref, seek.kind);
|
|
266
|
+
const typeGuard = seek.kind === 'number'
|
|
267
|
+
? `${jt} IN ${NUMERIC()}`
|
|
268
|
+
: `${jt} = ${sl('text')}`;
|
|
269
|
+
return `(${jt} IS NOT NULL AND ${typeGuard} AND ${value} ${symbol} `
|
|
270
|
+
+ `${param({ typed: { seek: seek.name, type: seek.kind } })})`;
|
|
271
|
+
}
|
|
166
272
|
// external operand: its JSON type is only knowable at bind time —
|
|
167
|
-
// guard BOTH sides per branch (text with text, number with number)
|
|
273
|
+
// guard BOTH sides per branch (text with text, number with number),
|
|
274
|
+
// and read the member at the branch's own kind. Both sides go
|
|
275
|
+
// through the dialect's external forms, because on an engine whose
|
|
276
|
+
// parameters carry a type the guard does not stop the coercion: the
|
|
277
|
+
// value is bound as JSON there and compared in JSON space
|
|
168
278
|
const name = pred.operand.ext;
|
|
279
|
+
const external = () => externalSlot(name);
|
|
280
|
+
const compare = (kind) => dialect.externalCompare(valueOf(pred.ref, kind), kind);
|
|
281
|
+
const against = (kind) => dialect.externalRef(external(), kind);
|
|
169
282
|
const textBranch = `(${jt} IS NOT NULL AND ${jt} = ${sl('text')} AND `
|
|
170
|
-
+ `${dialect.valueTypeOf(
|
|
171
|
-
+ `${
|
|
283
|
+
+ `${dialect.valueTypeOf(external())} = ${sl('text')} AND `
|
|
284
|
+
+ `${compare('text')} ${pred.op === 'ne' ? '=' : symbol} ${against('text')})`;
|
|
172
285
|
const numberBranch = `(${jt} IS NOT NULL AND ${jt} IN ${NUMERIC()} AND `
|
|
173
|
-
+ `${dialect.valueTypeOf(
|
|
174
|
-
+ `${
|
|
286
|
+
+ `${dialect.valueTypeOf(external())} IN ${NUMERIC()} AND `
|
|
287
|
+
+ `${compare('number')} ${pred.op === 'ne' ? '=' : symbol} ${against('number')})`;
|
|
175
288
|
const equalInSomeBranch = `(${textBranch} OR ${numberBranch})`;
|
|
176
289
|
return pred.op === 'ne'
|
|
177
290
|
? `(${jt} IS NOT NULL AND NOT ${equalInSomeBranch})`
|
|
@@ -194,6 +307,23 @@ export function emitPlan(plan, dialect, physical) {
|
|
|
194
307
|
return pred.value ? dialect.booleanLiteral(true) : dialect.booleanLiteral(false);
|
|
195
308
|
case 'cmp':
|
|
196
309
|
return emitCmp(pred);
|
|
310
|
+
case 'colCmp': {
|
|
311
|
+
const column = q(pred.column);
|
|
312
|
+
const symbol = { eq: '=', lt: '<', le: '<=', gt: '>', ge: '>=' }[pred.op];
|
|
313
|
+
return `(${column} IS NOT NULL AND ${column} ${symbol} `
|
|
314
|
+
+ `${param(slotFor(pred.operand))})`;
|
|
315
|
+
}
|
|
316
|
+
case 'interval': {
|
|
317
|
+
// the declared bounds ARE the values (§8.16's precondition is a
|
|
318
|
+
// schema one), so no `json_type` guard reads the document per
|
|
319
|
+
// row; `IS NOT NULL` keeps the form total for a row with no span
|
|
320
|
+
const start = q(pred.columns.start);
|
|
321
|
+
const end = q(pred.columns.end);
|
|
322
|
+
return `(${start} IS NOT NULL AND ${end} IS NOT NULL AND `
|
|
323
|
+
+ `((${start} < ${param({ literal: pred.probe.to })} `
|
|
324
|
+
+ `AND ${end} > ${param({ literal: pred.probe.from })}) `
|
|
325
|
+
+ `OR ${start} >= ${end}))`;
|
|
326
|
+
}
|
|
197
327
|
case 'typeIs': {
|
|
198
328
|
const jt = typeOf(pred.ref);
|
|
199
329
|
if (pred.types.length === 0) {
|
|
@@ -209,11 +339,13 @@ export function emitPlan(plan, dialect, physical) {
|
|
|
209
339
|
}
|
|
210
340
|
case 'udf':
|
|
211
341
|
// the registered deterministic predicate: reads the row's
|
|
212
|
-
// document as JSON text, answers 1 or 0 (always total)
|
|
213
|
-
|
|
342
|
+
// document as JSON text, answers 1 or 0 (always total); the
|
|
343
|
+
// second argument is the conjunct's place in the caller's
|
|
344
|
+
// document, a literal the function reports an engine error at
|
|
345
|
+
return `${pred.name}(${dialect.jsonText(docColumn)}, ${sl(pred.mount ?? '/$where')})`;
|
|
214
346
|
case 'strop': {
|
|
215
347
|
const jt = typeOf(pred.ref);
|
|
216
|
-
const form = stropForm(dialect, param, valueOf(pred.ref), pred);
|
|
348
|
+
const form = stropForm(dialect, param, valueOf(pred.ref, 'text'), pred);
|
|
217
349
|
return `(${jt} IS NOT NULL AND ${jt} = ${sl('text')} AND ${form})`;
|
|
218
350
|
}
|
|
219
351
|
case 'bboxOverlap': {
|
|
@@ -297,20 +429,64 @@ export function emitPlan(plan, dialect, physical) {
|
|
|
297
429
|
// engine scores, cuts and ranks (measured: every SQL spelling of
|
|
298
430
|
// the rank loses to fetching the column and ranking in the engine,
|
|
299
431
|
// and none of them runs where no function can be registered)
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
432
|
+
// one alternative per emitted statement: the caller emits the plan
|
|
433
|
+
// once per declared width, and each carries its own column
|
|
434
|
+
? `${dialect.rowIdentity()} AS ${q('rid')}, `
|
|
435
|
+
+ `${q(plan.rank.alternatives[0].column)} AS ${q('vec')}`
|
|
436
|
+
: plan.group !== null
|
|
437
|
+
? [...plan.group.keys.map((key, i) => projectedPair(key.ref, `k${i}`)),
|
|
438
|
+
...plan.group.aggregates.map((entry, i) =>
|
|
439
|
+
`${dialect.groupAggregate(entry.fn, foldValue(entry.fn, entry.ref))} `
|
|
440
|
+
+ `AS ${q(`a${i}`)}`)].join(', ')
|
|
441
|
+
: plan.bucket !== null
|
|
442
|
+
? [`${bucketSql} AS ${q(plan.bucket.as)}`,
|
|
303
443
|
...plan.bucket.aggregates.map((entry) =>
|
|
304
|
-
`${dialect.groupAggregate(entry.fn,
|
|
305
|
-
|
|
444
|
+
`${dialect.groupAggregate(entry.fn, foldValue(entry.fn, entry.ref))} `
|
|
445
|
+
+ `AS ${q(entry.as)}`)].join(', ')
|
|
306
446
|
: plan.aggregate === null
|
|
307
|
-
?
|
|
447
|
+
? (plan.project === 'document'
|
|
448
|
+
? `${dialect.jsonText(docColumn)} AS ${q('doc')}`
|
|
449
|
+
// one member path: its value and its JSON type. A scalar is
|
|
450
|
+
// the extracted SQL value itself; an object or array is
|
|
451
|
+
// rendered to JSON text, since the binary extraction of a
|
|
452
|
+
// compound is a blob. `NULL` type is an absent member (no
|
|
453
|
+
// item), 'null' a present null, 'true'/'false' a boolean the
|
|
454
|
+
// integer rendering would otherwise lose
|
|
455
|
+
: 'path' in plan.project
|
|
456
|
+
? projectedPair(plan.project.path, '')
|
|
457
|
+
// a projection TREE: the same value/type pair per DISTINCT
|
|
458
|
+
// leaf, numbered, and nothing else — the document blob is
|
|
459
|
+
// never selected, and a leaf named twice is fetched once
|
|
460
|
+
: plan.project.leaves.map((ref, i) => projectedPair(ref, String(i))).join(', '))
|
|
308
461
|
: plan.aggregate.fn === 'count'
|
|
309
462
|
? `COUNT(*) AS ${q('value')}`
|
|
310
|
-
|
|
463
|
+
// a REGISTERED aggregate calls the function the store
|
|
464
|
+
// registered under the plan's name; the fold is the pack's own
|
|
465
|
+
: plan.aggregate.fn === 'registered'
|
|
466
|
+
? `${plan.aggregate.sql}(${valueOf(plan.aggregate.ref,
|
|
467
|
+
kindOf(plan.aggregate.ref))}) AS ${q('value')}`
|
|
468
|
+
: `${plan.aggregate.fn.toUpperCase()}(`
|
|
469
|
+
+ `${foldValue(plan.aggregate.fn, plan.aggregate.ref)}) AS ${q('value')}`;
|
|
311
470
|
|
|
312
471
|
let sql = `SELECT ${selection} FROM ${q(physical.table)}`;
|
|
313
472
|
if (plan.filter !== null) sql += ` WHERE ${emitPred(plan.filter)}`;
|
|
473
|
+
if (plan.group !== null) {
|
|
474
|
+
// BY THE ALIASES the selection named, not by a second spelling of
|
|
475
|
+
// the same member. Two reasons, and the second is the load-bearing
|
|
476
|
+
// one: a key is a value/type PAIR (a JSON `1` and a JSON `"1"` are
|
|
477
|
+
// different keys and render the same text), and an engine that
|
|
478
|
+
// checks its grouping refuses a selected expression the GROUP BY
|
|
479
|
+
// does not cover — which every key's type discriminator would be.
|
|
480
|
+
sql += ` GROUP BY ${plan.group.keys
|
|
481
|
+
.map((key, i) => `${q(`vk${i}`)}, ${q(`tk${i}`)}`).join(', ')}`;
|
|
482
|
+
// the groups' order: the engine's own order of first appearance —
|
|
483
|
+
// over a collection, each group's earliest row identity — or the
|
|
484
|
+
// key ordering an `$orderby` declared
|
|
485
|
+
sql += ` ORDER BY ${plan.group.order === 'first-seen'
|
|
486
|
+
? dialect.groupAggregate('min', dialect.rowIdentity())
|
|
487
|
+
: plan.group.order.map((term) => `${q(`vk${term.index}`)} `
|
|
488
|
+
+ `${term.desc ? 'DESC' : 'ASC'}${dialect.orderNulls(term.nullsFirst)}`).join(', ')}`;
|
|
489
|
+
}
|
|
314
490
|
if (plan.bucket !== null) {
|
|
315
491
|
// `first-seen` is the engine's own group order (§6.5, first
|
|
316
492
|
// appearance), which over a collection is the group's earliest row
|
|
@@ -321,13 +497,15 @@ export function emitPlan(plan, dialect, physical) {
|
|
|
321
497
|
: `${alias} ${plan.bucket.order === 'desc' ? 'DESC' : 'ASC'}`;
|
|
322
498
|
sql += ` GROUP BY ${alias} ORDER BY ${order}`;
|
|
323
499
|
}
|
|
324
|
-
if (plan.aggregate === null && plan.rank === null && plan.bucket === null
|
|
500
|
+
if (plan.aggregate === null && plan.rank === null && plan.bucket === null
|
|
501
|
+
&& plan.group === null) {
|
|
325
502
|
const terms = (plan.order ?? []).map((term) => {
|
|
326
503
|
// Jaren's default sorts an empty key least: NULLS FIRST when
|
|
327
504
|
// ascending, NULLS LAST when descending — and mirrored for
|
|
328
505
|
// $empty: 'greatest' (probed against the engine)
|
|
329
506
|
const nullsFirst = term.emptyGreatest === term.desc;
|
|
330
|
-
return `${valueOf(term.ref
|
|
507
|
+
return `${valueOf(term.ref, kindOf(term.ref))} `
|
|
508
|
+
+ `${term.desc ? 'DESC' : 'ASC'}${dialect.orderNulls(nullsFirst)}`;
|
|
331
509
|
});
|
|
332
510
|
// the collection is a SEQUENCE: its order is insertion (row
|
|
333
511
|
// identity) order, and the engine's sort is stable — the identity
|
|
@@ -336,10 +514,52 @@ export function emitPlan(plan, dialect, physical) {
|
|
|
336
514
|
terms.push(dialect.rowIdentity());
|
|
337
515
|
sql += ` ORDER BY ${terms.join(', ')}`;
|
|
338
516
|
}
|
|
339
|
-
if (plan.window !== null && plan.aggregate === null) {
|
|
517
|
+
if (plan.window !== null && plan.aggregate === null && plan.group === null) {
|
|
340
518
|
sql += ` ${dialect.limitClause(plan.window.limit, plan.window.offset)}`;
|
|
341
519
|
}
|
|
342
|
-
return { sql, slots };
|
|
520
|
+
return { sql, slots, seeks: (plan.seeks ?? []).map((seek) => emitSeek(seek)) };
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* One seek statement: the extreme instant each group carries on the
|
|
524
|
+
* near side of the probe, folded to the one scalar every group's
|
|
525
|
+
* answer is beyond. Ungrouped, the inner fold IS the answer.
|
|
526
|
+
* @param {import('./algebra.js').PlanSeek} seek
|
|
527
|
+
* @returns {{ name: string, kind: 'number' | 'text',
|
|
528
|
+
* sql: string, slots: ParamSlot[] }}
|
|
529
|
+
*/
|
|
530
|
+
function emitSeek(seek) {
|
|
531
|
+
/** @type {ParamSlot[]} */
|
|
532
|
+
const own = [];
|
|
533
|
+
const outer = sink;
|
|
534
|
+
sink = own;
|
|
535
|
+
try {
|
|
536
|
+
// the seek reads the same declared columns the bound it fills does
|
|
537
|
+
const colCmp = (column, op, lit) =>
|
|
538
|
+
({ p: 'colCmp', op, column, operand: { lit } });
|
|
539
|
+
/** @type {import('./algebra.js').PlanPredicate} */
|
|
540
|
+
let filter = colCmp(seek.ref.column, seek.bound.op, seek.bound.lit);
|
|
541
|
+
if (seek.group !== null && seek.keys !== null && seek.keys.length > 0) {
|
|
542
|
+
filter = { p: 'and', items: [filter, seek.keys.length === 1
|
|
543
|
+
? colCmp(seek.group.column, 'eq', seek.keys[0])
|
|
544
|
+
: { p: 'or', items: seek.keys.map((key) =>
|
|
545
|
+
colCmp(seek.group.column, 'eq', key)) }] };
|
|
546
|
+
}
|
|
547
|
+
const inner = `${seek.inner.toUpperCase()}(${valueOf(seek.ref, seek.kind)})`;
|
|
548
|
+
const where = ` FROM ${q(physical.table)} WHERE ${emitPred(filter)}`;
|
|
549
|
+
const text = seek.group === null
|
|
550
|
+
? `SELECT ${inner} AS ${q('anchor')}${where}`
|
|
551
|
+
: `SELECT ${seek.outer.toUpperCase()}(${q('a')}) AS ${q('anchor')} FROM `
|
|
552
|
+
+ `(SELECT ${inner} AS ${q('a')}${where} `
|
|
553
|
+
+ `GROUP BY ${valueOf(seek.group, kindOf(seek.group))})`;
|
|
554
|
+
// a seek that finds nothing binds its own probe: it proved there
|
|
555
|
+
// is no row on that side, so the bound excludes only what is absent
|
|
556
|
+
return { name: seek.name, kind: seek.kind, fallback: seek.bound.lit,
|
|
557
|
+
sql: text, slots: own };
|
|
558
|
+
}
|
|
559
|
+
finally {
|
|
560
|
+
sink = outer;
|
|
561
|
+
}
|
|
562
|
+
}
|
|
343
563
|
}
|
|
344
564
|
|
|
345
565
|
// ————— The entity document kind (one emitter layer, two kinds) —————
|
|
@@ -355,7 +575,11 @@ export function emitPlan(plan, dialect, physical) {
|
|
|
355
575
|
export function createEntityPredicateEmitters(dialect, param) {
|
|
356
576
|
const q = dialect.quoteIdentifier;
|
|
357
577
|
const sl = dialect.stringLiteral;
|
|
358
|
-
const NUMERIC = () => `(${sl('
|
|
578
|
+
const NUMERIC = () => `(${dialect.numericTypeNames.map(sl).join(', ')})`;
|
|
579
|
+
const externalSlot = (name) => param(dialect.externalEncoding === 'json'
|
|
580
|
+
? { external: name, json: true }
|
|
581
|
+
: { external: name });
|
|
582
|
+
|
|
359
583
|
const pathTextOf = (ref) => {
|
|
360
584
|
const text = dialect.jsonPathText(ref.segments);
|
|
361
585
|
if (text === null)
|
|
@@ -363,9 +587,14 @@ export function createEntityPredicateEmitters(dialect, param) {
|
|
|
363
587
|
return text;
|
|
364
588
|
};
|
|
365
589
|
|
|
590
|
+
/** The member at a ref, read as the SQL a comparison of that KIND
|
|
591
|
+
* needs. On a dynamically typed engine every kind is the same read;
|
|
592
|
+
* on one whose columns carry a real type they are four. */
|
|
593
|
+
const memberAt = (docSql, ref, kind) =>
|
|
594
|
+
dialect.jsonExtract(docSql, pathTextOf(ref), kind);
|
|
595
|
+
|
|
366
596
|
const emitDocPred = (docSql, pred) => {
|
|
367
597
|
const jt = dialect.jsonTypeOf(docSql, pathTextOf(pred.ref));
|
|
368
|
-
const value = dialect.jsonExtract(docSql, pathTextOf(pred.ref));
|
|
369
598
|
if (pred.p === 'typeIs') {
|
|
370
599
|
if (pred.types.length === 0)
|
|
371
600
|
return pred.positive ? `${jt} IS NOT NULL` : `${jt} IS NULL`;
|
|
@@ -376,12 +605,13 @@ export function createEntityPredicateEmitters(dialect, param) {
|
|
|
376
605
|
: `(${jt} IS NOT NULL AND ${jt} NOT IN (${list}))`;
|
|
377
606
|
}
|
|
378
607
|
if (pred.p === 'strop') {
|
|
379
|
-
const form = stropForm(dialect, param,
|
|
608
|
+
const form = stropForm(dialect, param, memberAt(docSql, pred.ref, 'text'), pred);
|
|
380
609
|
return `(${jt} IS NOT NULL AND ${jt} = ${sl('text')} AND ${form})`;
|
|
381
610
|
}
|
|
382
611
|
const lit = pred.operand.lit;
|
|
383
612
|
const symbol = { eq: '=', ne: '<>', lt: '<', le: '<=', gt: '>', ge: '>=' }[pred.op];
|
|
384
|
-
const kind = typeof lit === 'number' ? 'number' : '
|
|
613
|
+
const kind = typeof lit === 'number' ? 'number' : 'text';
|
|
614
|
+
const value = memberAt(docSql, pred.ref, kind);
|
|
385
615
|
if (pred.op === 'ne') {
|
|
386
616
|
const notType = kind === 'number'
|
|
387
617
|
? `${jt} NOT IN ${NUMERIC()}` : `${jt} <> ${sl('text')}`;
|
|
@@ -410,10 +640,13 @@ export function createEntityPredicateEmitters(dialect, param) {
|
|
|
410
640
|
}
|
|
411
641
|
const symbol = { eq: '=', ne: '<>', lt: '<', le: '<=', gt: '>', ge: '>=' }[pred.op];
|
|
412
642
|
if ('ext' in pred.operand) {
|
|
413
|
-
const
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
643
|
+
const kind = pred.ref.storage === 'string' ? 'text' : 'number';
|
|
644
|
+
const guard = kind === 'text'
|
|
645
|
+
? `${dialect.valueTypeOf(externalSlot(pred.operand.ext))} = ${sl('text')}`
|
|
646
|
+
: `${dialect.valueTypeOf(externalSlot(pred.operand.ext))} IN ${NUMERIC()}`;
|
|
647
|
+
return `(${column} IS NOT NULL AND ${guard} AND `
|
|
648
|
+
+ `${dialect.externalCompare(column, kind)} ${pred.op === 'ne' ? '<>' : symbol} `
|
|
649
|
+
+ `${dialect.externalRef(externalSlot(pred.operand.ext), kind)})`;
|
|
417
650
|
}
|
|
418
651
|
const lit = pred.operand.lit;
|
|
419
652
|
const litKind = typeof lit === 'number' ? 'number' : typeof lit === 'string' ? 'string' : 'other';
|
|
@@ -432,7 +665,9 @@ export function createEntityPredicateEmitters(dialect, param) {
|
|
|
432
665
|
// stored values carry
|
|
433
666
|
const emitEpochPred = (aliasSql, docSql, pred) => {
|
|
434
667
|
const column = `${aliasSql}.${q(pred.ref.column)}`;
|
|
435
|
-
|
|
668
|
+
// the stored TEXT decides: an instant's codepoint comparison is
|
|
669
|
+
// exactly the engine's, whatever precision the value carries
|
|
670
|
+
const value = memberAt(docSql, pred.ref, 'text');
|
|
436
671
|
const symbol = { eq: '=', lt: '<', le: '<=', gt: '>', ge: '>=' }[pred.op];
|
|
437
672
|
const range = pred.op === 'gt' || pred.op === 'ge'
|
|
438
673
|
? `${column} >= ${param({ literal: pred.epoch - 1000 })}`
|
|
@@ -483,6 +718,7 @@ export function createEntityPredicateEmitters(dialect, param) {
|
|
|
483
718
|
*/
|
|
484
719
|
export function emitEntityPlan(plan, dialect, physicalOf) {
|
|
485
720
|
const q = dialect.quoteIdentifier;
|
|
721
|
+
const sl = dialect.stringLiteral;
|
|
486
722
|
/** @type {ParamSlot[]} */
|
|
487
723
|
const slots = [];
|
|
488
724
|
const param = (slot) => {
|
|
@@ -503,25 +739,80 @@ export function emitEntityPlan(plan, dialect, physicalOf) {
|
|
|
503
739
|
return text;
|
|
504
740
|
};
|
|
505
741
|
|
|
742
|
+
const entityOf = new Map(plan.bindings.map((binding) => [binding.name, binding.entity]));
|
|
506
743
|
const emitters = createEntityPredicateEmitters(dialect, param);
|
|
507
744
|
const emitPred = (bindingName, pred) =>
|
|
508
745
|
emitters.emitPred(aliasOf(bindingName), docOf(bindingName), pred);
|
|
509
746
|
|
|
747
|
+
/**
|
|
748
|
+
* One projected member of a binding: its value beside its JSON type,
|
|
749
|
+
* under a suffixed pair of names the decoder reads back.
|
|
750
|
+
*
|
|
751
|
+
* Which SOURCE the pair reads is the entity mapping's rule (§9.3),
|
|
752
|
+
* not a choice: a mapped scalar lives in its COLUMN and is absent
|
|
753
|
+
* from the document, so reading the document for it would answer
|
|
754
|
+
* nothing; an epoch column keeps its string IN the document, because
|
|
755
|
+
* the integer is derived; everything else is document only. The type
|
|
756
|
+
* of a column value is the column's declared storage — SQL has no
|
|
757
|
+
* `json_type` for it — with `NULL` meaning the member is absent,
|
|
758
|
+
* which is exactly what the merge reads back.
|
|
759
|
+
*/
|
|
760
|
+
const projectedPair = (leaf, suffix) => {
|
|
761
|
+
const names = `${q(`v${suffix}`)}`;
|
|
762
|
+
const typeName = `${q(`t${suffix}`)}`;
|
|
763
|
+
if (leaf.ref.flavor === 'entity-column') {
|
|
764
|
+
const column = `${aliasOf(leaf.binding)}.${q(leaf.ref.column)}`;
|
|
765
|
+
const type = leaf.ref.storage === 'boolean'
|
|
766
|
+
? `CASE WHEN ${column} IS NULL THEN NULL WHEN ${column} = 0 `
|
|
767
|
+
+ `THEN ${sl('false')} ELSE ${sl('true')} END`
|
|
768
|
+
: `CASE WHEN ${column} IS NULL THEN NULL ELSE ${sl(
|
|
769
|
+
leaf.ref.storage === 'string' ? 'text'
|
|
770
|
+
: leaf.ref.storage === 'integer'
|
|
771
|
+
? dialect.numericTypeNames[0]
|
|
772
|
+
: dialect.numericTypeNames[dialect.numericTypeNames.length - 1])} END`;
|
|
773
|
+
return `${column} AS ${names}, ${type} AS ${typeName}`;
|
|
774
|
+
}
|
|
775
|
+
const docSql = docOf(leaf.binding);
|
|
776
|
+
const text = pathTextOf(leaf.ref);
|
|
777
|
+
const type = dialect.jsonTypeOf(docSql, text);
|
|
778
|
+
return `CASE WHEN ${type} IN (${sl('object')}, ${sl('array')}) `
|
|
779
|
+
+ `THEN ${dialect.jsonText(dialect.jsonExtract(docSql, text))} `
|
|
780
|
+
+ `ELSE ${dialect.jsonExtract(docSql, text, 'scalar')} END AS ${names}, `
|
|
781
|
+
+ `${type} AS ${typeName}`;
|
|
782
|
+
};
|
|
783
|
+
|
|
510
784
|
const ret = plan.ret;
|
|
511
785
|
// every returned column plus the document rendered to text; the
|
|
512
|
-
// caller merges them back into the entity shape
|
|
786
|
+
// caller merges them back into the entity shape — or, for a projected
|
|
787
|
+
// shape, one value/type pair per DISTINCT leaf and no document at all
|
|
513
788
|
const selection = plan.aggregate === 'count'
|
|
514
789
|
? `COUNT(*) AS ${q('value')}`
|
|
515
|
-
:
|
|
790
|
+
: plan.project != null
|
|
791
|
+
// `p`-prefixed, because a bare `t0` would collide with this
|
|
792
|
+
// plan's own binding aliases
|
|
793
|
+
? plan.project.leaves.map((leaf, i) => projectedPair(leaf, `p${i}`)).join(', ')
|
|
794
|
+
// a join-table root IS its two key columns: it has no document
|
|
795
|
+
// column, so the merge is handed an empty one
|
|
796
|
+
: physicalOf(entityOf.get(ret)).document === false
|
|
797
|
+
? `${aliasOf(ret)}.*, ${sl('{}')} AS ${q('__doc')}`
|
|
798
|
+
: `${aliasOf(ret)}.*, ${dialect.jsonText(docOf(ret))} AS ${q('__doc')}`;
|
|
799
|
+
|
|
800
|
+
const tableOf = (name) =>
|
|
801
|
+
`${q(physicalOf(entityOf.get(name)).table)} AS ${aliasOf(name)}`;
|
|
802
|
+
const JOIN_OPS = { eq: '=', ne: '<>', lt: '<', le: '<=', gt: '>', ge: '>=' };
|
|
803
|
+
const onSql = (edge) =>
|
|
804
|
+
`${aliasOf(edge.left.binding)}.${q(edge.left.column)}`
|
|
805
|
+
+ ` ${JOIN_OPS[edge.op ?? 'eq']} ${aliasOf(edge.right.binding)}.${q(edge.right.column)}`;
|
|
516
806
|
|
|
517
807
|
let sql = `SELECT ${selection} FROM `;
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
808
|
+
// the JOIN order the planner settled: the first binding, then each
|
|
809
|
+
// one an edge attaches to what is already joined. A binding nothing
|
|
810
|
+
// attached never reaches here — that graph is the residual
|
|
811
|
+
sql += plan.joins.length === 0
|
|
812
|
+
? tableOf(plan.bindings[0].name)
|
|
813
|
+
: plan.joins.map((join, i) => (i === 0
|
|
814
|
+
? tableOf(join.binding)
|
|
815
|
+
: `${tableOf(join.binding)} ON ${join.on.map(onSql).join(' AND ')}`)).join(' JOIN ');
|
|
525
816
|
const filterSql = plan.filters
|
|
526
817
|
.filter((entry) => entry.filter !== null)
|
|
527
818
|
.map((entry) => emitPred(entry.binding, entry.filter));
|
|
@@ -535,7 +826,7 @@ export function emitEntityPlan(plan, dialect, physicalOf) {
|
|
|
535
826
|
// integer column sort differently
|
|
536
827
|
const value = term.ref.flavor === 'entity-column'
|
|
537
828
|
? `${aliasOf(term.binding)}.${q(term.ref.column)}`
|
|
538
|
-
: dialect.jsonExtract(docOf(term.binding), pathTextOf(term.ref));
|
|
829
|
+
: dialect.jsonExtract(docOf(term.binding), pathTextOf(term.ref), 'text');
|
|
539
830
|
const nullsFirst = term.emptyGreatest === term.desc;
|
|
540
831
|
return `${value} ${term.desc ? 'DESC' : 'ASC'}${dialect.orderNulls(nullsFirst)}`;
|
|
541
832
|
});
|
package/src/entity.js
CHANGED
|
@@ -18,8 +18,9 @@ import { compileJsonQuery } from '@jarenjs/json/query';
|
|
|
18
18
|
import {
|
|
19
19
|
getEpochOfDateTimeRFC3339, getEpochOfDateOnlyRFC3339,
|
|
20
20
|
} from '@jarenjs/core/dates/rfc3339';
|
|
21
|
+
import { resolveRuntime } from '@jarenjs/core/runtime';
|
|
21
22
|
|
|
22
|
-
import { DbRuntimeError,
|
|
23
|
+
import { DbRuntimeError, wrapDriverError } from './errors.js';
|
|
23
24
|
import { chain, attempt } from './driver.js';
|
|
24
25
|
|
|
25
26
|
/**
|
|
@@ -28,9 +29,13 @@ import { chain, attempt } from './driver.js';
|
|
|
28
29
|
* @param {any} entity - the normalized entity (model.js)
|
|
29
30
|
* @param {any} entityMapping - `explainMapping(...).entities[name]`
|
|
30
31
|
* @param {((doc: any) => any) | null} validate
|
|
32
|
+
* @param {Partial<import('@jarenjs/core/runtime').Runtime>} [runtime] - the
|
|
33
|
+
* host's runtime record: the clock a `default: 'now'` stamps and the
|
|
34
|
+
* identifier a `default: 'uuid'` allocates; the platform's own when absent
|
|
31
35
|
* @returns {any}
|
|
32
36
|
*/
|
|
33
|
-
export function entityCore(connection, entity, entityMapping, validate) {
|
|
37
|
+
export function entityCore(connection, entity, entityMapping, validate, runtime = undefined) {
|
|
38
|
+
const host = resolveRuntime(runtime);
|
|
34
39
|
const dialect = connection.dialect;
|
|
35
40
|
const q = dialect.quoteIdentifier;
|
|
36
41
|
const table = entityMapping.table;
|
|
@@ -164,14 +169,14 @@ export function entityCore(connection, entity, entityMapping, validate) {
|
|
|
164
169
|
// a `date` property takes the calendar date; a date-time stamp on
|
|
165
170
|
// it was invalid under its own format and refused by an epoch column
|
|
166
171
|
const stamp = property.format === 'date'
|
|
167
|
-
? () => new Date().toISOString().slice(0, 10)
|
|
168
|
-
: () => new Date().toISOString();
|
|
172
|
+
? () => new Date(host.now()).toISOString().slice(0, 10)
|
|
173
|
+
: () => new Date(host.now()).toISOString();
|
|
169
174
|
defaulters.push({ name: property.name, fill: stamp });
|
|
170
175
|
if (declared === 'updated') updateStamps.push({ name: property.name, fill: stamp });
|
|
171
176
|
continue;
|
|
172
177
|
}
|
|
173
178
|
if (declared === 'uuid') {
|
|
174
|
-
defaulters.push({ name: property.name, fill: () =>
|
|
179
|
+
defaulters.push({ name: property.name, fill: () => host.uuid() });
|
|
175
180
|
continue;
|
|
176
181
|
}
|
|
177
182
|
if (declared === 'auto') continue; // the database allocates
|
|
@@ -243,7 +248,7 @@ export function entityCore(connection, entity, entityMapping, validate) {
|
|
|
243
248
|
const prepared = (name, sql) => {
|
|
244
249
|
let statement = statements.get(name);
|
|
245
250
|
if (statement === undefined) {
|
|
246
|
-
statement = connection.prepare(sql);
|
|
251
|
+
statement = connection.prepare(sql, { readOnly: name === 'get' });
|
|
247
252
|
statements.set(name, statement);
|
|
248
253
|
}
|
|
249
254
|
return statement;
|
|
@@ -288,20 +293,11 @@ export function entityCore(connection, entity, entityMapping, validate) {
|
|
|
288
293
|
{ docPath, collection: entity.name });
|
|
289
294
|
};
|
|
290
295
|
|
|
291
|
-
const wrapWrite = (error, key) => {
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
`a '${entity.name}' already exists under key ${JSON.stringify(key)}`,
|
|
297
|
-
{ docPath, collection: entity.name, key, cause: error });
|
|
298
|
-
}
|
|
299
|
-
return new DbRuntimeError('JD2005',
|
|
300
|
-
`the database rejected the operation: ${/** @type {any} */ (error)?.message ?? String(error)}`,
|
|
301
|
-
key === undefined
|
|
302
|
-
? { docPath, collection: entity.name, cause: error }
|
|
303
|
-
: { docPath, collection: entity.name, key, cause: error });
|
|
304
|
-
};
|
|
296
|
+
const wrapWrite = (error, key) => wrapDriverError(error, {
|
|
297
|
+
docPath, collection: entity.name, ...(key === undefined ? undefined : { key }),
|
|
298
|
+
...(keys.length === 1 ? { unique: { table, column: keys[0] } } : undefined),
|
|
299
|
+
duplicateReason: `a '${entity.name}' already exists under key ${JSON.stringify(key)}`,
|
|
300
|
+
});
|
|
305
301
|
|
|
306
302
|
const columnByName = new Map(scalarColumns.map((column) => [column.name, column]));
|
|
307
303
|
/** Encode ONE column assignment the way {@link split} would. */
|
|
@@ -360,8 +356,10 @@ export function entityCore(connection, entity, entityMapping, validate) {
|
|
|
360
356
|
get(key) {
|
|
361
357
|
const parts = normalizeKeyArg(key);
|
|
362
358
|
const sql = `SELECT ${selectColumns} FROM ${q(table)} WHERE ${keyWhere(0)}`;
|
|
363
|
-
|
|
364
|
-
|
|
359
|
+
// classified like every read of the query engines, never raw
|
|
360
|
+
return attempt(() => chain(prepared('get', sql), (statement) =>
|
|
361
|
+
chain(statement.get(parts), (row) => (row === undefined ? undefined : merge(row)))),
|
|
362
|
+
(error) => wrapDriverError(error, { docPath, collection: entity.name, key }));
|
|
365
363
|
},
|
|
366
364
|
update(key, changes) {
|
|
367
365
|
const parts = normalizeKeyArg(key);
|