turbine-orm 0.33.0 → 0.35.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/README.md +2 -2
- package/dist/cjs/client.js +26 -4
- package/dist/cjs/dialect.js +1 -0
- package/dist/cjs/errors.js +41 -1
- package/dist/cjs/index-advisor.js +0 -0
- package/dist/cjs/index.js +4 -2
- package/dist/cjs/mssql.js +5 -0
- package/dist/cjs/mysql.js +4 -0
- package/dist/cjs/optional-peer-import.cjs +28 -0
- package/dist/cjs/powdb-introspect.js +222 -0
- package/dist/cjs/powdb.js +592 -72
- package/dist/cjs/powql.js +998 -134
- package/dist/cjs/query/builder.js +72 -1
- package/dist/cjs/schema-builder.js +16 -0
- package/dist/cjs/schema-metadata.js +81 -10
- package/dist/cjs/sqlite.js +3 -0
- package/dist/client.d.ts +32 -5
- package/dist/client.js +26 -4
- package/dist/dialect.d.ts +13 -0
- package/dist/dialect.js +1 -0
- package/dist/errors.d.ts +36 -0
- package/dist/errors.js +39 -0
- package/dist/index-advisor.d.ts +15 -1
- package/dist/index-advisor.js +0 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.js +2 -2
- package/dist/mssql.js +5 -0
- package/dist/mysql.js +4 -0
- package/dist/optional-peer-import.cjs +28 -0
- package/dist/optional-peer-import.d.cts +19 -0
- package/dist/powdb-introspect.d.ts +84 -0
- package/dist/powdb-introspect.js +219 -0
- package/dist/powdb.d.ts +361 -19
- package/dist/powdb.js +585 -72
- package/dist/powql.d.ts +245 -8
- package/dist/powql.js +1001 -137
- package/dist/query/builder.d.ts +36 -1
- package/dist/query/builder.js +72 -1
- package/dist/query/deferred.d.ts +6 -2
- package/dist/query/types.d.ts +49 -12
- package/dist/schema-builder.d.ts +46 -1
- package/dist/schema-builder.js +15 -0
- package/dist/schema-metadata.d.ts +13 -7
- package/dist/schema-metadata.js +82 -11
- package/dist/schema.d.ts +25 -0
- package/dist/sqlite.js +3 -0
- package/package.json +3 -3
package/dist/cjs/powql.js
CHANGED
|
@@ -84,6 +84,38 @@ const schema_js_1 = require("./schema.js");
|
|
|
84
84
|
* before grouping. Mirrors the chunking the parity matrix documents.
|
|
85
85
|
*/
|
|
86
86
|
const MAX_RELATION_KEYS = 1000;
|
|
87
|
+
/**
|
|
88
|
+
* Read-shaped actions whose statement may be transparently replayed once on a
|
|
89
|
+
* stale wire frame when `retryStaleReads` is enabled (see
|
|
90
|
+
* {@link PowqlInterface.execOnce}). Writes are deliberately absent: replaying a
|
|
91
|
+
* mutation after an ambiguous reply can double-execute, matching the client's
|
|
92
|
+
* own native-path policy.
|
|
93
|
+
*/
|
|
94
|
+
const POWQL_READ_ACTIONS = new Set([
|
|
95
|
+
'findMany',
|
|
96
|
+
'findUnique',
|
|
97
|
+
'findFirst',
|
|
98
|
+
'count',
|
|
99
|
+
'aggregate',
|
|
100
|
+
'groupBy',
|
|
101
|
+
'explain',
|
|
102
|
+
]);
|
|
103
|
+
/**
|
|
104
|
+
* Mutating actions the {@link PowqlInterface} readonly guard refuses locally
|
|
105
|
+
* (before the wire) on a read-only pool. A transaction-control `begin` is
|
|
106
|
+
* guarded separately in {@link PowqlInterface.runInImplicitTx}. Kept keyed on
|
|
107
|
+
* the per-call action string (never `this`-state) so a concurrent read can
|
|
108
|
+
* never be mistaken for one of these.
|
|
109
|
+
*/
|
|
110
|
+
const POWQL_WRITE_ACTIONS = new Set([
|
|
111
|
+
'create',
|
|
112
|
+
'createMany',
|
|
113
|
+
'update',
|
|
114
|
+
'updateMany',
|
|
115
|
+
'delete',
|
|
116
|
+
'deleteMany',
|
|
117
|
+
'upsert',
|
|
118
|
+
]);
|
|
87
119
|
/** Operator keys recognised inside a `WhereOperator` object. */
|
|
88
120
|
const OPERATOR_KEYS = new Set([
|
|
89
121
|
'equals',
|
|
@@ -129,7 +161,6 @@ class PowqlInterface {
|
|
|
129
161
|
defaultLimit;
|
|
130
162
|
warnOnUnlimited;
|
|
131
163
|
onQuery;
|
|
132
|
-
currentAction = 'raw';
|
|
133
164
|
warnedUnlimited = false;
|
|
134
165
|
constructor(pool, table, schema, middlewares = [], options = {}) {
|
|
135
166
|
this.pool = pool;
|
|
@@ -167,9 +198,19 @@ class PowqlInterface {
|
|
|
167
198
|
}
|
|
168
199
|
return col;
|
|
169
200
|
}
|
|
170
|
-
/**
|
|
171
|
-
|
|
172
|
-
|
|
201
|
+
/**
|
|
202
|
+
* PowQL column reference for a field. Unqualified it is a dotted field
|
|
203
|
+
* reference (`.snake_name`), which bypasses keyword lookup. When an `alias`
|
|
204
|
+
* is supplied (the F2 join path) it is qualified (`alias.snake_name`) and the
|
|
205
|
+
* column name is backtick-quoted if it is a reserved word (a qualified
|
|
206
|
+
* `p.order` does NOT bypass keyword lookup, unlike the dotted `.order`).
|
|
207
|
+
*/
|
|
208
|
+
ref(field, alias) {
|
|
209
|
+
return this.colRefName(this.column(field).name, alias);
|
|
210
|
+
}
|
|
211
|
+
/** Render a raw column name as a PowQL reference, qualified with `alias` when given. */
|
|
212
|
+
colRefName(name, alias) {
|
|
213
|
+
return alias ? `${alias}.${(0, powdb_js_1.quotePowqlIdent)(name)}` : `.${name}`;
|
|
173
214
|
}
|
|
174
215
|
/**
|
|
175
216
|
* Push a value into the param array and return its `$N` placeholder. When the
|
|
@@ -180,7 +221,17 @@ class PowqlInterface {
|
|
|
180
221
|
* {@link toPowdbParam}, so the wire param is unchanged.
|
|
181
222
|
*/
|
|
182
223
|
param(value, params, col) {
|
|
183
|
-
|
|
224
|
+
let tagged = value;
|
|
225
|
+
if (col && typeof value === 'number' && this.isFloatCol(col)) {
|
|
226
|
+
// Float column: force a float-form literal even for an integer value.
|
|
227
|
+
tagged = new powdb_js_1.PowdbFloatParam(value);
|
|
228
|
+
}
|
|
229
|
+
else if (col && value !== null && typeof value === 'object' && !(value instanceof Date) && (0, powdb_js_1.isJsonColumn)(col)) {
|
|
230
|
+
// json document column: a JS object/array is serialized to canonical JSON
|
|
231
|
+
// text and stored as a json document (a JS string passes through raw, same
|
|
232
|
+
// contract as pg jsonb; `null` stays `null`).
|
|
233
|
+
tagged = new powdb_js_1.PowdbJsonParam(value);
|
|
234
|
+
}
|
|
184
235
|
params.push(tagged);
|
|
185
236
|
return `$${params.length}`;
|
|
186
237
|
}
|
|
@@ -208,6 +259,15 @@ class PowqlInterface {
|
|
|
208
259
|
return false;
|
|
209
260
|
}
|
|
210
261
|
}
|
|
262
|
+
/**
|
|
263
|
+
* The bound pool's {@link PowdbCapabilities}. Falls back to the trusted-caller
|
|
264
|
+
* default (all feature gates on, `nativeRaw` off) when a directly-constructed
|
|
265
|
+
* pool did not carry them, matching {@link PowdbPool}'s own constructor
|
|
266
|
+
* default so a hand-built test pool never crashes the version gates.
|
|
267
|
+
*/
|
|
268
|
+
get capabilities() {
|
|
269
|
+
return this.pool.capabilities ?? powdb_js_1.ALL_POWDB_CAPABILITIES;
|
|
270
|
+
}
|
|
211
271
|
/** A predicate that is always false — the empty-`in` / contradiction sentinel. */
|
|
212
272
|
alwaysFalse() {
|
|
213
273
|
const pk = this.meta.primaryKey[0] ?? this.meta.columns[0]?.name;
|
|
@@ -219,8 +279,15 @@ class PowqlInterface {
|
|
|
219
279
|
/**
|
|
220
280
|
* Compile a {@link WhereClause} into a PowQL filter expression, pushing every
|
|
221
281
|
* value as a positional `$N` param. Returns `''` when there are no conditions.
|
|
282
|
+
*
|
|
283
|
+
* When `alias` is supplied (the F2 native-join path) every field reference is
|
|
284
|
+
* qualified with it (`.col` → `alias.col`, JSON path bases too); params bind
|
|
285
|
+
* exactly as in the unqualified path. The caller only ever passes an alias for
|
|
286
|
+
* an already-RESOLVED where (relation filters pre-resolved to literal in-lists
|
|
287
|
+
* by {@link resolveRelationFilters}): the relation-key branch below still
|
|
288
|
+
* throws, so an unresolved relation filter can never leak into a join.
|
|
222
289
|
*/
|
|
223
|
-
buildWhere(where, params) {
|
|
290
|
+
buildWhere(where, params, alias) {
|
|
224
291
|
if (!where)
|
|
225
292
|
return '';
|
|
226
293
|
const parts = [];
|
|
@@ -228,17 +295,17 @@ class PowqlInterface {
|
|
|
228
295
|
if (value === undefined)
|
|
229
296
|
continue;
|
|
230
297
|
if (key === 'AND') {
|
|
231
|
-
const sub = value.map((w) => this.buildWhere(w, params)).filter(Boolean);
|
|
298
|
+
const sub = value.map((w) => this.buildWhere(w, params, alias)).filter(Boolean);
|
|
232
299
|
if (sub.length)
|
|
233
300
|
parts.push(`(${sub.join(' and ')})`);
|
|
234
301
|
}
|
|
235
302
|
else if (key === 'OR') {
|
|
236
|
-
const sub = value.map((w) => this.buildWhere(w, params)).filter(Boolean);
|
|
303
|
+
const sub = value.map((w) => this.buildWhere(w, params, alias)).filter(Boolean);
|
|
237
304
|
if (sub.length)
|
|
238
305
|
parts.push(`(${sub.join(' or ')})`);
|
|
239
306
|
}
|
|
240
307
|
else if (key === 'NOT') {
|
|
241
|
-
const sub = this.buildWhere(value, params);
|
|
308
|
+
const sub = this.buildWhere(value, params, alias);
|
|
242
309
|
if (sub)
|
|
243
310
|
parts.push(`not (${sub})`);
|
|
244
311
|
}
|
|
@@ -249,20 +316,33 @@ class PowqlInterface {
|
|
|
249
316
|
throw new errors_js_1.ValidationError(`[turbine] internal: relation filter "${key}" reached buildWhere unresolved (missing resolveRelationFilters()).`);
|
|
250
317
|
}
|
|
251
318
|
else {
|
|
252
|
-
|
|
319
|
+
// A JsonFilter (or a bare `{ path }`) can compile to zero clauses; skip
|
|
320
|
+
// empty results so buildWhere never emits a dangling ` and `.
|
|
321
|
+
const cond = this.buildFieldCondition(key, value, params, alias);
|
|
322
|
+
if (cond)
|
|
323
|
+
parts.push(cond);
|
|
253
324
|
}
|
|
254
325
|
}
|
|
255
326
|
return parts.join(' and ');
|
|
256
327
|
}
|
|
257
328
|
/** Build a single `field: value | operator` condition. */
|
|
258
|
-
buildFieldCondition(field, value, params) {
|
|
259
|
-
const
|
|
329
|
+
buildFieldCondition(field, value, params, alias) {
|
|
330
|
+
const colMeta = this.column(field);
|
|
331
|
+
const ref = this.ref(field, alias);
|
|
260
332
|
if (value === null)
|
|
261
333
|
return `${ref} is null`;
|
|
262
334
|
if (value instanceof Date || typeof value !== 'object') {
|
|
263
|
-
return `${ref} = ${this.param(value, params)}`;
|
|
335
|
+
return `${ref} = ${this.param(value, params, colMeta)}`;
|
|
264
336
|
}
|
|
265
337
|
const op = value;
|
|
338
|
+
// JSON path / key filters on a json document column compile to PowQL `->`
|
|
339
|
+
// path filters (≥ 0.12). `isJsonFilter` matches `path`/`equals`/`contains`/
|
|
340
|
+
// `hasKey`; on a NON-json column those fall through to the scalar operator
|
|
341
|
+
// path below (e.g. `equals` stays a plain equality), exactly like SQL.
|
|
342
|
+
if ((0, powdb_js_1.isJsonColumn)(colMeta) && (0, filters_js_1.isJsonFilter)(value)) {
|
|
343
|
+
(0, powdb_js_1.requireCapability)(this.capabilities, 'jsonDocs', 'JSON path filters');
|
|
344
|
+
return this.buildJsonPathCondition(colMeta, value, params, alias);
|
|
345
|
+
}
|
|
266
346
|
rejectUnsupportedFilter(op, field);
|
|
267
347
|
if (!Object.keys(op).some((k) => OPERATOR_KEYS.has(k))) {
|
|
268
348
|
// A bare object that is not an operator set — equality by value.
|
|
@@ -314,6 +394,100 @@ class PowqlInterface {
|
|
|
314
394
|
}
|
|
315
395
|
return conds.length > 1 ? `(${conds.join(' and ')})` : (conds[0] ?? this.alwaysFalse());
|
|
316
396
|
}
|
|
397
|
+
/**
|
|
398
|
+
* PowQL JSON path expression `.col->$a->$b…`, binding EVERY path segment as a
|
|
399
|
+
* positional param (a string segment as a `str` token, an integer index as an
|
|
400
|
+
* `int` token). `->` binds tighter than every operator, so no parens are
|
|
401
|
+
* needed around the path in a comparison. Segments are bound (never inlined)
|
|
402
|
+
* to keep {@link materializePowql}'s `$N`-scan invariant intact: a segment
|
|
403
|
+
* that literally contained `$1` would otherwise be rewritten. Shared by the
|
|
404
|
+
* F1 where-filter path and the F2 orderBy / groupBy path emitters.
|
|
405
|
+
*
|
|
406
|
+
* A digit-only STRING segment (`'0'`) binds as an `int` array index, matching
|
|
407
|
+
* the SQL engines: `JsonFilter.path` is typed `string[]`, so an array index
|
|
408
|
+
* can only be expressed as a digit string, and the SQL builder converts it the
|
|
409
|
+
* same way (`/^\d+$/ → [n]`, query/builder.ts). Without this, PowDB's typed
|
|
410
|
+
* `->` treats `'0'` as a string KEY and silently matches nothing on an array
|
|
411
|
+
* (a wrong result, not an error). Same object-key-`'0'` caveat SQL accepts: a
|
|
412
|
+
* json object whose key is literally `"0"` is addressed as an array index.
|
|
413
|
+
*/
|
|
414
|
+
jsonPathExpr(col, path, params, alias) {
|
|
415
|
+
let expr = this.colRefName(col.name, alias);
|
|
416
|
+
for (const seg of path) {
|
|
417
|
+
const bound = typeof seg === 'string' && /^\d+$/.test(seg) ? Number(seg) : seg;
|
|
418
|
+
expr += `->${this.param(bound, params)}`;
|
|
419
|
+
}
|
|
420
|
+
return expr;
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* Compile a {@link JsonFilter} on a json document column into a PowQL filter
|
|
424
|
+
* (≥ 0.12). Operators PowQL cannot express EXACTLY throw a per-operator E017
|
|
425
|
+
* (never a wrong result): containment (`contains`, and `equals` without a
|
|
426
|
+
* `path`) has no PowQL operator. The mapped shapes:
|
|
427
|
+
* - `{ path, equals: v }` → `P = $n` (typed: string→str, bool→bool,
|
|
428
|
+
* integral number→int, fractional→float; NOT stringified)
|
|
429
|
+
* - `{ path, equals: null }` → `P is null` (matches JSON null AND a missing
|
|
430
|
+
* key, a deliberate divergence from the PG driver, documented on
|
|
431
|
+
* {@link JsonFilter})
|
|
432
|
+
* - `{ path, gt|gte|lt|lte: v }` → `P > $n` … (range ops require `path`; the
|
|
433
|
+
* engine coerces int/float numerically)
|
|
434
|
+
* - `{ hasKey: k }` → `json_type(.col->$n) is not null` (top-level key test,
|
|
435
|
+
* ignoring `path`, mirroring PG `col ? key`; includes keys holding JSON
|
|
436
|
+
* null)
|
|
437
|
+
* A bare `{ path }` with no operators compiles to zero clauses (byte-parity
|
|
438
|
+
* with SQL), so a mutation whose only `where` is a bare `{ path }` is refused
|
|
439
|
+
* by the empty-where guard.
|
|
440
|
+
*/
|
|
441
|
+
buildJsonPathCondition(col, filter, params, alias) {
|
|
442
|
+
const conds = [];
|
|
443
|
+
// Bind the path segments at most once and reuse the expression string across
|
|
444
|
+
// equals + range comparisons (they share the same `path`).
|
|
445
|
+
let pathExpr = null;
|
|
446
|
+
const pathP = () => {
|
|
447
|
+
pathExpr ??= this.jsonPathExpr(col, filter.path, params, alias);
|
|
448
|
+
return pathExpr;
|
|
449
|
+
};
|
|
450
|
+
if (filter.contains !== undefined) {
|
|
451
|
+
throw new errors_js_1.UnsupportedFeatureError('JSON containment filters (contains)', 'PowDB', `column "${col.name}": PowQL has no JSON containment operator`);
|
|
452
|
+
}
|
|
453
|
+
if (filter.equals !== undefined) {
|
|
454
|
+
if (filter.path === undefined || filter.path.length === 0) {
|
|
455
|
+
throw new errors_js_1.UnsupportedFeatureError('JSON containment (equals without path)', 'PowDB', `column "${col.name}": pass a \`path\` to compare a specific json value; PowQL has no whole-document containment`);
|
|
456
|
+
}
|
|
457
|
+
conds.push(filter.equals === null ? `${pathP()} is null` : `${pathP()} = ${this.param(filter.equals, params)}`);
|
|
458
|
+
}
|
|
459
|
+
if (filter.hasKey !== undefined) {
|
|
460
|
+
// Top-level key existence, independent of `path` (mirrors PG `col ? key`).
|
|
461
|
+
conds.push(`json_type(${this.colRefName(col.name, alias)}->${this.param(filter.hasKey, params)}) is not null`);
|
|
462
|
+
}
|
|
463
|
+
// Range comparisons on the extracted path, in the fixed gt/gte/lt/lte order.
|
|
464
|
+
// Same validation as SQL `jsonRangeEntries`: `path` required, value a finite
|
|
465
|
+
// number or a string.
|
|
466
|
+
for (const [op, powOp] of [
|
|
467
|
+
['gt', '>'],
|
|
468
|
+
['gte', '>='],
|
|
469
|
+
['lt', '<'],
|
|
470
|
+
['lte', '<='],
|
|
471
|
+
]) {
|
|
472
|
+
const v = filter[op];
|
|
473
|
+
if (v === undefined)
|
|
474
|
+
continue;
|
|
475
|
+
if (filter.path === undefined) {
|
|
476
|
+
throw new errors_js_1.ValidationError(`[turbine] JSON range operator '${op}' on ${col.name} requires a \`path\` ` +
|
|
477
|
+
`(e.g. { path: ['meta', 'score'], ${op}: ${JSON.stringify(v)} }).`);
|
|
478
|
+
}
|
|
479
|
+
if (typeof v !== 'number' && typeof v !== 'string') {
|
|
480
|
+
throw new errors_js_1.ValidationError(`[turbine] JSON range operator '${op}' on ${col.name} requires a number or string, got ${JSON.stringify(v)}.`);
|
|
481
|
+
}
|
|
482
|
+
if (typeof v === 'number' && !Number.isFinite(v)) {
|
|
483
|
+
throw new errors_js_1.ValidationError(`[turbine] JSON range operator '${op}' on ${col.name} requires a finite number.`);
|
|
484
|
+
}
|
|
485
|
+
conds.push(`${pathP()} ${powOp} ${this.param(v, params)}`);
|
|
486
|
+
}
|
|
487
|
+
if (!conds.length)
|
|
488
|
+
return '';
|
|
489
|
+
return conds.length > 1 ? `(${conds.join(' and ')})` : conds[0];
|
|
490
|
+
}
|
|
317
491
|
/** Bind a value, lowercasing for case-insensitive comparisons. */
|
|
318
492
|
bind(value, params, insensitive) {
|
|
319
493
|
const ph = this.param(value, params);
|
|
@@ -455,7 +629,7 @@ class PowqlInterface {
|
|
|
455
629
|
const chunk = targetPks.slice(i, i + MAX_RELATION_KEYS);
|
|
456
630
|
const params = [];
|
|
457
631
|
const ph = chunk.map((v) => this.param(v, params)).join(', ');
|
|
458
|
-
const { rows } = await this.exec(`${(0, powdb_js_1.quotePowqlIdent)(through.table)} filter .${targetJCol} in (${ph}) { .${sourceJCol} }`, params, timeout);
|
|
632
|
+
const { rows } = await this.exec(`${(0, powdb_js_1.quotePowqlIdent)(through.table)} filter .${targetJCol} in (${ph}) { .${sourceJCol} }`, params, timeout, 'findMany');
|
|
459
633
|
for (const r of rows) {
|
|
460
634
|
const v = r[sourceJCol];
|
|
461
635
|
if (v != null)
|
|
@@ -503,8 +677,20 @@ class PowqlInterface {
|
|
|
503
677
|
projection(cols) {
|
|
504
678
|
return `{ ${cols.map((c) => `.${c}`).join(', ')} }`;
|
|
505
679
|
}
|
|
506
|
-
/**
|
|
507
|
-
|
|
680
|
+
/**
|
|
681
|
+
* `order .c1 asc, .c2 desc` clause (empty string when no orderBy). Supports,
|
|
682
|
+
* besides a plain direction:
|
|
683
|
+
* - {@link JsonPathOrderBy} on a json column (≥ 0.12): `{ data: { path: […],
|
|
684
|
+
* type?, direction? } }` → `order .data->$n asc` (or
|
|
685
|
+
* `cast(.data->$n, "float")` for `type: 'numeric'`);
|
|
686
|
+
* - {@link OrderBySpec} `{ sort, nulls }`: `nulls: 'last'` is accepted as a
|
|
687
|
+
* no-op (PowDB is always nulls-last), `nulls: 'first'` throws E017.
|
|
688
|
+
*
|
|
689
|
+
* PowDB orders missing / JSON-null keys LAST in BOTH directions (an engine
|
|
690
|
+
* contract): for identical cross-engine results pass `nulls: 'last'`
|
|
691
|
+
* explicitly on Postgres, which defaults nulls-first for `desc`.
|
|
692
|
+
*/
|
|
693
|
+
buildOrder(orderBy, params, alias) {
|
|
508
694
|
if (!orderBy)
|
|
509
695
|
return '';
|
|
510
696
|
const keys = Object.entries(orderBy).filter(([, dir]) => dir !== undefined);
|
|
@@ -512,32 +698,97 @@ class PowqlInterface {
|
|
|
512
698
|
return '';
|
|
513
699
|
const parts = keys.map(([field, dir]) => {
|
|
514
700
|
if (dir && typeof dir === 'object') {
|
|
701
|
+
const o = dir;
|
|
702
|
+
// JSON-path ordering on a json column.
|
|
703
|
+
if (Array.isArray(o.path)) {
|
|
704
|
+
return this.buildJsonPathOrder(field, dir, params, alias);
|
|
705
|
+
}
|
|
706
|
+
// OrderBySpec { sort, nulls }: accept nulls-last (PowDB default), refuse
|
|
707
|
+
// nulls-first (no placement grammar). Distinct from vector/pick/_count.
|
|
708
|
+
if ('sort' in o && !('distance' in o) && !('_count' in o) && !(0, filters_js_1.isRelationPickOrderBy)(dir)) {
|
|
709
|
+
const spec = dir;
|
|
710
|
+
if (spec.nulls === 'first') {
|
|
711
|
+
throw new errors_js_1.UnsupportedFeatureError('NULLS FIRST placement', 'PowDB', `field "${field}": PowDB orders NULLs / missing keys LAST in both directions`);
|
|
712
|
+
}
|
|
713
|
+
return `${this.ref(field, alias)} ${spec.sort === 'desc' ? 'desc' : 'asc'}`;
|
|
714
|
+
}
|
|
515
715
|
// Name the actual feature in the refusal — a pick-row ordering
|
|
516
716
|
// reported as "vector / distance ordering" sends users hunting for
|
|
517
|
-
// pgvector docs.
|
|
518
|
-
const o = dir;
|
|
717
|
+
// pgvector docs. Everything else stays E017 on PowDB.
|
|
519
718
|
const feature = (0, filters_js_1.isRelationPickOrderBy)(dir)
|
|
520
719
|
? 'relation pick-row ordering'
|
|
521
720
|
: 'distance' in o
|
|
522
721
|
? 'vector / distance ordering'
|
|
523
|
-
:
|
|
524
|
-
? '
|
|
525
|
-
: '
|
|
526
|
-
? '
|
|
527
|
-
: '
|
|
528
|
-
? 'NULLS placement / sort-spec ordering'
|
|
529
|
-
: 'object-valued ordering';
|
|
722
|
+
: '_count' in o
|
|
723
|
+
? 'relation _count ordering'
|
|
724
|
+
: 'nulls' in o
|
|
725
|
+
? 'NULLS placement / sort-spec ordering'
|
|
726
|
+
: 'object-valued ordering';
|
|
530
727
|
throw new errors_js_1.UnsupportedFeatureError(feature, 'PowDB', `field "${field}"`);
|
|
531
728
|
}
|
|
532
|
-
return `${this.ref(field)} ${dir === 'desc' ? 'desc' : 'asc'}`;
|
|
729
|
+
return `${this.ref(field, alias)} ${dir === 'desc' ? 'desc' : 'asc'}`;
|
|
533
730
|
});
|
|
534
731
|
return ` order ${parts.join(', ')}`;
|
|
535
732
|
}
|
|
733
|
+
/** Compile one {@link JsonPathOrderBy} entry to `order .col->$n asc` (+ optional numeric cast). */
|
|
734
|
+
buildJsonPathOrder(field, spec, params, alias) {
|
|
735
|
+
const col = this.column(field);
|
|
736
|
+
if (!(0, powdb_js_1.isJsonColumn)(col)) {
|
|
737
|
+
throw new errors_js_1.UnsupportedFeatureError('JSON-path ordering', 'PowDB', `field "${field}" is not a json column`);
|
|
738
|
+
}
|
|
739
|
+
(0, powdb_js_1.requireCapability)(this.capabilities, 'jsonDocs', 'JSON path ordering');
|
|
740
|
+
if (spec.nulls === 'first') {
|
|
741
|
+
throw new errors_js_1.UnsupportedFeatureError('NULLS FIRST placement', 'PowDB', `field "${field}": PowDB orders missing / JSON-null keys LAST in both directions`);
|
|
742
|
+
}
|
|
743
|
+
const pathExpr = this.jsonPathExpr(col, spec.path, params, alias);
|
|
744
|
+
// `type: 'numeric'` casts a JSON STRING number for numeric ordering; native
|
|
745
|
+
// JSON numbers already order numerically without a cast.
|
|
746
|
+
const expr = spec.type === 'numeric' ? `cast(${pathExpr}, "float")` : pathExpr;
|
|
747
|
+
return `${expr} ${spec.direction === 'desc' ? 'desc' : 'asc'}`;
|
|
748
|
+
}
|
|
536
749
|
// -------------------------------------------------------------------------
|
|
537
750
|
// Execution plumbing
|
|
538
751
|
// -------------------------------------------------------------------------
|
|
539
|
-
/**
|
|
540
|
-
|
|
752
|
+
/**
|
|
753
|
+
* Run PowQL with optional timeout, emitting a query event either way. The
|
|
754
|
+
* `action` is passed PER CALL (never read from shared instance state) so the
|
|
755
|
+
* retry-eligibility and the emitted event action stay correct even when a
|
|
756
|
+
* concurrent operation runs on the same cached interface: a WRITE statement
|
|
757
|
+
* carries a write action and can therefore never be mistaken for a replayable
|
|
758
|
+
* read. Read statements pass a read-shaped action from {@link POWQL_READ_ACTIONS}.
|
|
759
|
+
*/
|
|
760
|
+
async exec(powql, params, timeout, action = 'raw') {
|
|
761
|
+
return this.execOnce(powql, params, timeout, action, false);
|
|
762
|
+
}
|
|
763
|
+
/** Build the E018 refusal for a write / `begin` on a read-only pool. */
|
|
764
|
+
readOnlyError(operation) {
|
|
765
|
+
// Pass a clean detail: the ReadOnlyError constructor owns both the
|
|
766
|
+
// `[turbine] ` prefix and the "Route writes to a writable primary." hint,
|
|
767
|
+
// so adding either here would double them.
|
|
768
|
+
return new errors_js_1.ReadOnlyError(`${operation} on "${this.table}" refused: this PowDB connection is read-only.`);
|
|
769
|
+
}
|
|
770
|
+
/**
|
|
771
|
+
* Execute one statement, with the opt-in single stale-frame READ replay. When
|
|
772
|
+
* `retryStaleReads` is on and a first-statement READ fails with the stale-wire
|
|
773
|
+
* {@link isStaleFramePowdbError} ConnectionError (a socket idle-gap "received
|
|
774
|
+
* unexpected frame" that the client cannot recover), the statement is retried
|
|
775
|
+
* exactly once on a fresh pooled connection (the broken one was destroyed).
|
|
776
|
+
* The replay is refused for writes (an ambiguous mutation reply is unsafe to
|
|
777
|
+
* replay) and inside a transaction (a mid-tx statement cannot move connection),
|
|
778
|
+
* so only the read-shaped actions in {@link POWQL_READ_ACTIONS}, outside a
|
|
779
|
+
* `_txScoped` interface, are eligible. `action` is a per-call argument (never
|
|
780
|
+
* `this`-state), so a concurrent op flipping instance fields cannot turn a
|
|
781
|
+
* write into a retryable read.
|
|
782
|
+
*/
|
|
783
|
+
async execOnce(powql, params, timeout, action, isRetry) {
|
|
784
|
+
// Read-only pool guard: refuse a write action locally, before the wire, so a
|
|
785
|
+
// read-only target never even attempts the mutation (the engine refusal, if
|
|
786
|
+
// any, is only the backstop for raw/injected paths). `action` is per-call,
|
|
787
|
+
// so a concurrent read is never mistaken for a write. Reads (incl. explain)
|
|
788
|
+
// and non-classified `raw` fall through unchanged.
|
|
789
|
+
if (this.pool.readonly === true && POWQL_WRITE_ACTIONS.has(action)) {
|
|
790
|
+
throw this.readOnlyError(action);
|
|
791
|
+
}
|
|
541
792
|
const start = performance.now();
|
|
542
793
|
const run = this.pool.query(powql, params);
|
|
543
794
|
try {
|
|
@@ -547,15 +798,37 @@ class PowqlInterface {
|
|
|
547
798
|
new Promise((_, reject) => setTimeout(() => reject(new errors_js_1.TimeoutError(timeout)), timeout)),
|
|
548
799
|
])
|
|
549
800
|
: await run;
|
|
550
|
-
this.emit(powql, params, performance.now() - start, result.rowCount ?? result.rows.length);
|
|
551
|
-
|
|
801
|
+
this.emit(action, powql, params, performance.now() - start, result.rowCount ?? result.rows.length);
|
|
802
|
+
// The pool tags each result with the wire that actually served it
|
|
803
|
+
// (adaptResult → false, adaptNativeResult → true). A heterogeneous
|
|
804
|
+
// injected pool can fall back to the legacy wire per call, so read the
|
|
805
|
+
// per-result flag and only fall back to the pool-level capability when a
|
|
806
|
+
// hand-built pool (tests) omits the tag; never coerce legacy rows with
|
|
807
|
+
// the native policy just because the pool reports nativeRaw.
|
|
808
|
+
const native = result.native ?? Boolean(this.capabilities.nativeRaw);
|
|
809
|
+
return { rows: result.rows, rowCount: result.rowCount ?? result.rows.length, native };
|
|
552
810
|
}
|
|
553
811
|
catch (err) {
|
|
554
|
-
|
|
812
|
+
if (!isRetry && this.shouldRetryStaleRead(err, action)) {
|
|
813
|
+
// Transparent single replay on a fresh connection; the first (swallowed)
|
|
814
|
+
// failure is not emitted, only the retried outcome is observed.
|
|
815
|
+
return this.execOnce(powql, params, timeout, action, true);
|
|
816
|
+
}
|
|
817
|
+
this.emit(action, powql, params, performance.now() - start, 0, err);
|
|
555
818
|
throw err;
|
|
556
819
|
}
|
|
557
820
|
}
|
|
558
|
-
|
|
821
|
+
/** Is `err` a replayable stale-frame failure for THIS (per-call) read-shaped, non-tx action? */
|
|
822
|
+
shouldRetryStaleRead(err, action) {
|
|
823
|
+
if (!this.pool.retryStaleReads)
|
|
824
|
+
return false;
|
|
825
|
+
if (this.isTxScoped())
|
|
826
|
+
return false;
|
|
827
|
+
if (!POWQL_READ_ACTIONS.has(action))
|
|
828
|
+
return false;
|
|
829
|
+
return (0, powdb_js_1.isStaleFramePowdbError)(err);
|
|
830
|
+
}
|
|
831
|
+
emit(action, sql, params, duration, rows, error) {
|
|
559
832
|
if (!this.onQuery)
|
|
560
833
|
return;
|
|
561
834
|
try {
|
|
@@ -564,7 +837,7 @@ class PowqlInterface {
|
|
|
564
837
|
params,
|
|
565
838
|
duration,
|
|
566
839
|
model: this.table,
|
|
567
|
-
action
|
|
840
|
+
action,
|
|
568
841
|
rows,
|
|
569
842
|
timestamp: new Date(),
|
|
570
843
|
error,
|
|
@@ -576,7 +849,6 @@ class PowqlInterface {
|
|
|
576
849
|
}
|
|
577
850
|
/** Run a method body through the middleware chain (mirrors QueryInterface). */
|
|
578
851
|
async withMiddleware(action, args, executor) {
|
|
579
|
-
this.currentAction = action;
|
|
580
852
|
if (this.middlewares.length === 0)
|
|
581
853
|
return executor();
|
|
582
854
|
let index = 0;
|
|
@@ -587,34 +859,48 @@ class PowqlInterface {
|
|
|
587
859
|
};
|
|
588
860
|
return next({ model: this.table, action, args: { ...args } });
|
|
589
861
|
}
|
|
590
|
-
/** Map raw rows to typed entities.
|
|
591
|
-
|
|
592
|
-
|
|
862
|
+
/** Map raw rows to typed entities. `native` is the wire that ACTUALLY served
|
|
863
|
+
* this result (threaded from {@link execOnce}, not the pool-level capability),
|
|
864
|
+
* so cells that arrived pre-typed over `queryNativeRaw` (F3) skip the legacy
|
|
865
|
+
* string coercion (a genuine str `"null"` stays `"null"` instead of collapsing
|
|
866
|
+
* to null) while a per-call legacy fallback on a native-capable pool still
|
|
867
|
+
* coerces its string cells correctly. Defaults to the pool capability for the
|
|
868
|
+
* rare caller with no per-result flag (hand-built test pools). */
|
|
869
|
+
shape(rows, native = Boolean(this.capabilities.nativeRaw)) {
|
|
870
|
+
return rows.map((r) => (0, powdb_js_1.rowToEntity)(r, this.meta, native));
|
|
593
871
|
}
|
|
594
872
|
// -------------------------------------------------------------------------
|
|
595
873
|
// Reads
|
|
596
874
|
// -------------------------------------------------------------------------
|
|
597
875
|
async findMany(args = {}) {
|
|
598
876
|
return this.withMiddleware('findMany', args, async () => {
|
|
599
|
-
const rows = await this.runFind(args);
|
|
600
|
-
const entities = this.shape(rows);
|
|
601
|
-
if (args.with)
|
|
602
|
-
await this.loadRelations(entities, args.with, args.timeout
|
|
877
|
+
const { rows, native, resolvedWhere } = await this.runFind(args, 'findMany');
|
|
878
|
+
const entities = this.shape(rows, native);
|
|
879
|
+
if (args.with) {
|
|
880
|
+
await this.loadRelations(entities, args.with, args.timeout, 0, {
|
|
881
|
+
args,
|
|
882
|
+
resolvedWhere,
|
|
883
|
+
});
|
|
884
|
+
}
|
|
603
885
|
return entities;
|
|
604
886
|
});
|
|
605
887
|
}
|
|
606
|
-
/**
|
|
607
|
-
|
|
888
|
+
/**
|
|
889
|
+
* Compile the flat findMany select into PowQL (no execution), pushing values
|
|
890
|
+
* into `params`. Returns the query plus the RESOLVED where (relation filters
|
|
891
|
+
* already collapsed to literal in-lists) so the F2 join path can re-emit the
|
|
892
|
+
* exact parent predicate alias-qualified, and so {@link explain} can wrap it.
|
|
893
|
+
*/
|
|
894
|
+
async buildFind(args, params) {
|
|
608
895
|
if (args.cursor) {
|
|
609
896
|
throw new errors_js_1.UnsupportedFeatureError('cursor pagination', 'PowDB', 'use limit/offset instead');
|
|
610
897
|
}
|
|
611
|
-
const params = [];
|
|
612
898
|
const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
|
|
613
899
|
const where = this.buildWhere(resolvedWhere, params);
|
|
614
900
|
const cols = this.projectedColumns(args.select, args.omit);
|
|
615
901
|
const distinct = args.distinct?.length ? ' distinct' : '';
|
|
616
902
|
const filter = where ? ` filter ${where}` : '';
|
|
617
|
-
const order = this.buildOrder(args.orderBy);
|
|
903
|
+
const order = this.buildOrder(args.orderBy, params);
|
|
618
904
|
const limit = args.limit ?? args.take ?? this.defaultLimit;
|
|
619
905
|
if (limit === undefined && this.warnOnUnlimited && !this.warnedUnlimited) {
|
|
620
906
|
this.warnedUnlimited = true;
|
|
@@ -623,15 +909,45 @@ class PowqlInterface {
|
|
|
623
909
|
const limitClause = limit !== undefined ? ` limit ${this.param(limit, params)}` : '';
|
|
624
910
|
const offsetClause = args.offset ? ` offset ${this.param(args.offset, params)}` : '';
|
|
625
911
|
const powql = `${this.qt}${distinct}${filter}${order}${limitClause}${offsetClause} ${this.projection(cols)}`;
|
|
626
|
-
|
|
627
|
-
|
|
912
|
+
return { powql, resolvedWhere };
|
|
913
|
+
}
|
|
914
|
+
/** Build + run the flat findMany select; returns raw rows, the serving wire, and the resolved where. */
|
|
915
|
+
async runFind(args, action = 'findMany') {
|
|
916
|
+
const params = [];
|
|
917
|
+
const { powql, resolvedWhere } = await this.buildFind(args, params);
|
|
918
|
+
const { rows, native } = await this.exec(powql, params, args.timeout, action);
|
|
919
|
+
return { rows, native, resolvedWhere };
|
|
920
|
+
}
|
|
921
|
+
/**
|
|
922
|
+
* Diagnostic surface: compile the same PowQL {@link findMany} would run for
|
|
923
|
+
* `args` (no cache) and return the engine's plan as one string per line.
|
|
924
|
+
*
|
|
925
|
+
* Runs as a READ (`explain <query>`), so it is safe on a read-only pool and
|
|
926
|
+
* eligible for the stale-read replay. The line content is engine-owned and is
|
|
927
|
+
* NOT covered by semver (match plan node names / tree shape, never exact
|
|
928
|
+
* bytes; mirrors PowDB's own `explain` contract).
|
|
929
|
+
*
|
|
930
|
+
* Does NOT run through the middleware chain: plan text is a diagnostic, not
|
|
931
|
+
* entity rows, and `QueryInterface.explain` deliberately bypasses middleware
|
|
932
|
+
* too, so both engines agree.
|
|
933
|
+
*/
|
|
934
|
+
async explain(args = {}) {
|
|
935
|
+
const params = [];
|
|
936
|
+
const { powql } = await this.buildFind(args, params);
|
|
937
|
+
const { rows } = await this.exec(`explain ${powql}`, params, args.timeout, 'explain');
|
|
938
|
+
return rows
|
|
939
|
+
.map((r) => {
|
|
940
|
+
const line = r.plan ?? Object.values(r)[0];
|
|
941
|
+
return line == null ? '' : String(line);
|
|
942
|
+
})
|
|
943
|
+
.filter((line) => line.length > 0);
|
|
628
944
|
}
|
|
629
945
|
async findUnique(args) {
|
|
630
946
|
return this.withMiddleware('findUnique', args, async () => {
|
|
631
|
-
const rows = await this.runFind({ ...args, limit: 1 });
|
|
947
|
+
const { rows, native } = await this.runFind({ ...args, limit: 1 }, 'findUnique');
|
|
632
948
|
if (!rows.length)
|
|
633
949
|
return null;
|
|
634
|
-
const entities = this.shape(rows);
|
|
950
|
+
const entities = this.shape(rows, native);
|
|
635
951
|
if (args.with)
|
|
636
952
|
await this.loadRelations(entities, args.with, args.timeout);
|
|
637
953
|
return entities[0];
|
|
@@ -639,10 +955,10 @@ class PowqlInterface {
|
|
|
639
955
|
}
|
|
640
956
|
async findFirst(args = {}) {
|
|
641
957
|
return this.withMiddleware('findFirst', args, async () => {
|
|
642
|
-
const rows = await this.runFind({ ...args, limit: 1 });
|
|
958
|
+
const { rows, native } = await this.runFind({ ...args, limit: 1 }, 'findFirst');
|
|
643
959
|
if (!rows.length)
|
|
644
960
|
return null;
|
|
645
|
-
const entities = this.shape(rows);
|
|
961
|
+
const entities = this.shape(rows, native);
|
|
646
962
|
if (args.with)
|
|
647
963
|
await this.loadRelations(entities, args.with, args.timeout);
|
|
648
964
|
return entities[0];
|
|
@@ -663,19 +979,48 @@ class PowqlInterface {
|
|
|
663
979
|
// -------------------------------------------------------------------------
|
|
664
980
|
// Nested relations — batched N+1 loaders (hasMany / hasOne / belongsTo)
|
|
665
981
|
// -------------------------------------------------------------------------
|
|
666
|
-
/**
|
|
667
|
-
|
|
982
|
+
/**
|
|
983
|
+
* Load each requested relation for `parents` and attach it onto each row.
|
|
984
|
+
*
|
|
985
|
+
* `parent` is supplied ONLY by the top-level {@link findMany} (its args +
|
|
986
|
+
* resolved where). When the effective `relationLoadStrategy` resolves to an
|
|
987
|
+
* explicit `'join'` and the pool advertises `serverJoins`, an eligible
|
|
988
|
+
* top-level relation is loaded with a native PowQL join instead of the keyed
|
|
989
|
+
* loaders (F2); everything else (nested `with` levels, ineligible shapes, and
|
|
990
|
+
* the default `'batched'` strategy) keeps the loaders. Output is byte-equal
|
|
991
|
+
* either way (the join reuses the same stitch / shape helpers).
|
|
992
|
+
*/
|
|
993
|
+
async loadRelations(parents, withClause, timeout, depth = 0, parent) {
|
|
668
994
|
if (depth >= 10) {
|
|
669
995
|
throw new errors_js_1.ValidationError(`[turbine] Nested 'with' on PowDB exceeded depth 10 (relation cycle?).`);
|
|
670
996
|
}
|
|
671
997
|
if (!parents.length)
|
|
672
998
|
return;
|
|
999
|
+
// The resolved strategy is 'join' only for an EXPLICIT 'join' (per-query arg
|
|
1000
|
+
// or a client config the user set). The serverJoins capability is consulted
|
|
1001
|
+
// PER RELATION below, AFTER joinEligible, so a relation that would have
|
|
1002
|
+
// fallen back to the loaders anyway (paged parent, nested `with`, composite
|
|
1003
|
+
// key, …) never triggers the capability's E017.
|
|
1004
|
+
const strategyIsJoin = parent ? this.resolveStrategy(parent.args) === 'join' : false;
|
|
673
1005
|
for (const [relName, opt] of Object.entries(withClause)) {
|
|
674
1006
|
if (!opt)
|
|
675
1007
|
continue;
|
|
676
1008
|
const rel = this.meta.relations[relName];
|
|
677
1009
|
if (!rel)
|
|
678
1010
|
throw new errors_js_1.ValidationError(`[turbine] Unknown relation "${relName}" on "${this.table}".`);
|
|
1011
|
+
if (strategyIsJoin && parent && this.joinEligible(rel, opt, parent.args, parents.length)) {
|
|
1012
|
+
if (this.capabilities.serverJoins) {
|
|
1013
|
+
await this.loadRelationViaJoin(parents, rel, relName, opt, parent, timeout);
|
|
1014
|
+
continue;
|
|
1015
|
+
}
|
|
1016
|
+
// An otherwise-eligible relation the engine cannot join: a PER-QUERY
|
|
1017
|
+
// `relationLoadStrategy: 'join'` is an explicit request, so throw a typed
|
|
1018
|
+
// E017; a CLIENT-LEVEL default silently falls back to the keyed loaders
|
|
1019
|
+
// (so pointing an existing app at an older engine keeps working).
|
|
1020
|
+
if (parent.args.relationLoadStrategy === 'join') {
|
|
1021
|
+
(0, powdb_js_1.requireCapability)(this.capabilities, 'serverJoins', 'native PowQL relation joins');
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
679
1024
|
if (rel.type === 'manyToMany') {
|
|
680
1025
|
await this.loadManyToMany(parents, rel, relName, opt, timeout);
|
|
681
1026
|
continue;
|
|
@@ -696,23 +1041,49 @@ class PowqlInterface {
|
|
|
696
1041
|
const keys = [
|
|
697
1042
|
...new Set(parents.map((p) => p[parentKeyField]).filter((k) => k != null)),
|
|
698
1043
|
];
|
|
699
|
-
|
|
1044
|
+
// The loader buckets children by their correlation column, so that column
|
|
1045
|
+
// MUST be in the fetched projection even when the user's select/omit drops
|
|
1046
|
+
// it. Force it into the fetch here and strip it back off the entities after
|
|
1047
|
+
// stitching (the join path already gets this for free via `__tpk`).
|
|
1048
|
+
const userSelect = options.select;
|
|
1049
|
+
const userOmit = options.omit;
|
|
1050
|
+
const fkProjected = userSelect ? Boolean(userSelect[childKeyField]) : userOmit ? !userOmit[childKeyField] : true;
|
|
1051
|
+
let fetchOptions = options;
|
|
1052
|
+
if (!fkProjected) {
|
|
1053
|
+
if (userSelect) {
|
|
1054
|
+
fetchOptions = {
|
|
1055
|
+
...options,
|
|
1056
|
+
select: { ...userSelect, [childKeyField]: true },
|
|
1057
|
+
};
|
|
1058
|
+
}
|
|
1059
|
+
else if (userOmit) {
|
|
1060
|
+
const omitWithoutFk = { ...userOmit };
|
|
1061
|
+
delete omitWithoutFk[childKeyField];
|
|
1062
|
+
fetchOptions = { ...options, omit: omitWithoutFk };
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
700
1065
|
// Chunk the key set so a single `in (…)` never exceeds PowDB's
|
|
701
|
-
// per-statement param / row limits; merge each chunk's children.
|
|
1066
|
+
// per-statement param / row limits; merge each chunk's children. Keys are
|
|
1067
|
+
// normalized through joinKey (a Date maps to micros, matching the child
|
|
1068
|
+
// cell) so a datetime correlation column stitches instead of silently
|
|
1069
|
+
// returning [].
|
|
1070
|
+
const childByKey = new Map();
|
|
702
1071
|
for (let i = 0; i < keys.length; i += MAX_RELATION_KEYS) {
|
|
703
1072
|
const chunk = keys.slice(i, i + MAX_RELATION_KEYS);
|
|
704
1073
|
const childWhere = {
|
|
705
|
-
...
|
|
1074
|
+
...fetchOptions.where,
|
|
706
1075
|
[childKeyField]: { in: chunk },
|
|
707
1076
|
};
|
|
708
1077
|
const children = (await targetQi.findMany({
|
|
709
|
-
...
|
|
1078
|
+
...fetchOptions,
|
|
710
1079
|
where: childWhere,
|
|
711
1080
|
with: options.with,
|
|
712
1081
|
timeout: options.timeout ?? timeout,
|
|
713
1082
|
}));
|
|
714
1083
|
for (const child of children) {
|
|
715
|
-
const k = child[childKeyField];
|
|
1084
|
+
const k = this.joinKey(child[childKeyField]);
|
|
1085
|
+
if (k == null)
|
|
1086
|
+
continue;
|
|
716
1087
|
const bucket = childByKey.get(k);
|
|
717
1088
|
if (bucket)
|
|
718
1089
|
bucket.push(child);
|
|
@@ -720,10 +1091,18 @@ class PowqlInterface {
|
|
|
720
1091
|
childByKey.set(k, [child]);
|
|
721
1092
|
}
|
|
722
1093
|
}
|
|
1094
|
+
// Strip the forced correlation column back off if the user excluded it,
|
|
1095
|
+
// so the emitted entities match their select/omit exactly.
|
|
1096
|
+
if (!fkProjected) {
|
|
1097
|
+
for (const bucket of childByKey.values()) {
|
|
1098
|
+
for (const child of bucket)
|
|
1099
|
+
delete child[childKeyField];
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
723
1102
|
const single = rel.type === 'belongsTo' || rel.type === 'hasOne';
|
|
724
1103
|
for (const parent of parents) {
|
|
725
|
-
const k = parent[parentKeyField];
|
|
726
|
-
const matches = childByKey.get(k) ?? [];
|
|
1104
|
+
const k = this.joinKey(parent[parentKeyField]);
|
|
1105
|
+
const matches = (k == null ? undefined : childByKey.get(k)) ?? [];
|
|
727
1106
|
parent[relName] = single ? (matches[0] ?? null) : matches;
|
|
728
1107
|
}
|
|
729
1108
|
}
|
|
@@ -772,7 +1151,7 @@ class PowqlInterface {
|
|
|
772
1151
|
const params = [];
|
|
773
1152
|
const placeholders = chunk.map((v) => this.param(v, params)).join(', ');
|
|
774
1153
|
const powql = `${(0, powdb_js_1.quotePowqlIdent)(through.table)} filter .${sourceJCol} in (${placeholders}) { .${sourceJCol}, .${targetJCol} }`;
|
|
775
|
-
const { rows } = await this.exec(powql, params, timeout);
|
|
1154
|
+
const { rows } = await this.exec(powql, params, timeout, 'findMany');
|
|
776
1155
|
for (const row of rows) {
|
|
777
1156
|
const sv = String(row[sourceJCol]);
|
|
778
1157
|
const tv = String(row[targetJCol]);
|
|
@@ -818,6 +1197,257 @@ class PowqlInterface {
|
|
|
818
1197
|
}
|
|
819
1198
|
}
|
|
820
1199
|
// -------------------------------------------------------------------------
|
|
1200
|
+
// Nested relations: native PowQL joins (F2, opt-in via relationLoadStrategy)
|
|
1201
|
+
// -------------------------------------------------------------------------
|
|
1202
|
+
/**
|
|
1203
|
+
* Resolve the effective relation-load strategy: the per-query arg wins, then
|
|
1204
|
+
* the client config, then the PowDB default of `'batched'` (the keyed
|
|
1205
|
+
* loaders). PowDB deliberately does NOT inherit the SQL-side implicit `'join'`
|
|
1206
|
+
* default (that would silently flip every existing PowDB user onto brand-new
|
|
1207
|
+
* join generation). Only a value the user actually set to `'join'` activates it.
|
|
1208
|
+
*/
|
|
1209
|
+
resolveStrategy(args) {
|
|
1210
|
+
const s = args.relationLoadStrategy ?? this.options.relationLoadStrategy ?? 'batched';
|
|
1211
|
+
return s === 'join' ? 'join' : 'batched';
|
|
1212
|
+
}
|
|
1213
|
+
/**
|
|
1214
|
+
* Per-relation eligibility for the join path (checked before the serverJoins
|
|
1215
|
+
* capability). Any `false` here is a SILENT fallback to the keyed loaders (it
|
|
1216
|
+
* is never an error), so an off-page or nested-`with` shape still returns
|
|
1217
|
+
* correct rows:
|
|
1218
|
+
* - the parent query must not be paged (`limit`/`offset`/`take`, including the
|
|
1219
|
+
* configured `defaultLimit`): a parent-filter join under a page would scan
|
|
1220
|
+
* children of off-page parents, where the loaders are strictly better;
|
|
1221
|
+
* - the relation must not request a nested `with` (its subtree stays on the
|
|
1222
|
+
* loaders this round) or a `distinct`;
|
|
1223
|
+
* - single-column relation keys only (a composite key falls to the loader,
|
|
1224
|
+
* which throws the same E017 as today);
|
|
1225
|
+
* - the PARENT-SIDE correlation column must be a single-column PK or unique
|
|
1226
|
+
* column, or the INNER join would re-emit one child copy per matching
|
|
1227
|
+
* parent row (a non-unique correlation key produces duplicate children the
|
|
1228
|
+
* loader never would). For hasMany/hasOne/m2m that column is the relation's
|
|
1229
|
+
* `referenceKey` on THIS (fetched) table; for belongsTo it is the
|
|
1230
|
+
* `referenceKey` on the TARGET table (the join's non-fetched side);
|
|
1231
|
+
* - m2m keeps any `orderBy`/`limit`/`offset` on the loader (the junction-order
|
|
1232
|
+
* stitch can't be reproduced by the 3-table join deterministically);
|
|
1233
|
+
* - a to-one relation `limit`/`offset` (meaningless) stays on the loader, as
|
|
1234
|
+
* does a to-many relation `limit`/`offset` when the parent set spills past
|
|
1235
|
+
* one loader chunk (the loader limits per chunk, the join once globally).
|
|
1236
|
+
*/
|
|
1237
|
+
joinEligible(rel, opt, args, parentCount) {
|
|
1238
|
+
const effLimit = args.limit ?? args.take ?? this.defaultLimit;
|
|
1239
|
+
if (effLimit !== undefined || args.offset)
|
|
1240
|
+
return false;
|
|
1241
|
+
const options = (opt === true ? {} : opt);
|
|
1242
|
+
if (options.with)
|
|
1243
|
+
return false;
|
|
1244
|
+
if (options.distinct?.length)
|
|
1245
|
+
return false;
|
|
1246
|
+
if (rel.type === 'manyToMany') {
|
|
1247
|
+
const through = rel.through;
|
|
1248
|
+
if (!through)
|
|
1249
|
+
return false;
|
|
1250
|
+
if ((0, schema_js_1.normalizeKeyColumns)(through.sourceKey).length > 1 ||
|
|
1251
|
+
(0, schema_js_1.normalizeKeyColumns)(through.targetKey).length > 1 ||
|
|
1252
|
+
(0, schema_js_1.normalizeKeyColumns)(rel.referenceKey).length > 1 ||
|
|
1253
|
+
(this.schema.tables[rel.to]?.primaryKey.length ?? 2) > 1) {
|
|
1254
|
+
return false;
|
|
1255
|
+
}
|
|
1256
|
+
if (options.orderBy || options.limit !== undefined || options.offset)
|
|
1257
|
+
return false;
|
|
1258
|
+
// The parent joins on its referenceKey; a non-unique one duplicates.
|
|
1259
|
+
if (!this.isSingleColumnUnique(this.meta, (0, schema_js_1.normalizeKeyColumns)(rel.referenceKey)[0]))
|
|
1260
|
+
return false;
|
|
1261
|
+
return true;
|
|
1262
|
+
}
|
|
1263
|
+
if ((0, schema_js_1.normalizeKeyColumns)(rel.foreignKey).length > 1 || (0, schema_js_1.normalizeKeyColumns)(rel.referenceKey).length > 1) {
|
|
1264
|
+
return false;
|
|
1265
|
+
}
|
|
1266
|
+
// Reject a non-unique correlation key: on belongsTo the fetched side joins on
|
|
1267
|
+
// the target's referenceKey, otherwise the fetched side joins on its own.
|
|
1268
|
+
if (rel.type === 'belongsTo') {
|
|
1269
|
+
const targetMeta = this.schema.tables[rel.to];
|
|
1270
|
+
if (!targetMeta || !this.isSingleColumnUnique(targetMeta, (0, schema_js_1.normalizeKeyColumns)(rel.referenceKey)[0])) {
|
|
1271
|
+
return false;
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
else if (!this.isSingleColumnUnique(this.meta, (0, schema_js_1.normalizeKeyColumns)(rel.referenceKey)[0])) {
|
|
1275
|
+
return false;
|
|
1276
|
+
}
|
|
1277
|
+
const single = rel.type === 'belongsTo' || rel.type === 'hasOne';
|
|
1278
|
+
if ((options.limit !== undefined || options.offset) && (single || parentCount > MAX_RELATION_KEYS)) {
|
|
1279
|
+
return false;
|
|
1280
|
+
}
|
|
1281
|
+
return true;
|
|
1282
|
+
}
|
|
1283
|
+
/**
|
|
1284
|
+
* True when `col` is a single-column unique key of `tableMeta`: the sole
|
|
1285
|
+
* primary-key column, a single-column entry in `uniqueColumns` (where a
|
|
1286
|
+
* per-column `unique: true` and an introspected single-column unique constraint
|
|
1287
|
+
* both land), or a single-column unique index. Used by {@link joinEligible} to
|
|
1288
|
+
* keep the INNER-join path off relations whose parent-side correlation column
|
|
1289
|
+
* can repeat (which would duplicate children).
|
|
1290
|
+
*/
|
|
1291
|
+
isSingleColumnUnique(tableMeta, col) {
|
|
1292
|
+
if (tableMeta.primaryKey.length === 1 && tableMeta.primaryKey[0] === col)
|
|
1293
|
+
return true;
|
|
1294
|
+
if (tableMeta.uniqueColumns.some((cols) => cols.length === 1 && cols[0] === col))
|
|
1295
|
+
return true;
|
|
1296
|
+
return tableMeta.indexes.some((idx) => idx.unique && !idx.docPath && idx.columns.length === 1 && idx.columns[0] === col);
|
|
1297
|
+
}
|
|
1298
|
+
/** Dispatch one eligible relation to the correct native-join loader. */
|
|
1299
|
+
async loadRelationViaJoin(parents, rel, relName, opt, parent, timeout) {
|
|
1300
|
+
if (rel.type === 'manyToMany') {
|
|
1301
|
+
await this.loadManyToManyViaJoin(parents, rel, relName, opt, parent, timeout);
|
|
1302
|
+
return;
|
|
1303
|
+
}
|
|
1304
|
+
const options = (opt === true ? {} : opt);
|
|
1305
|
+
const targetMeta = this.schema.tables[rel.to];
|
|
1306
|
+
if (!targetMeta)
|
|
1307
|
+
throw new errors_js_1.ValidationError(`[turbine] Relation "${relName}" targets unknown table "${rel.to}".`);
|
|
1308
|
+
const targetQi = new PowqlInterface(this.pool, rel.to, this.schema, [], this.options);
|
|
1309
|
+
const fk = (0, schema_js_1.normalizeKeyColumns)(rel.foreignKey);
|
|
1310
|
+
const rk = (0, schema_js_1.normalizeKeyColumns)(rel.referenceKey);
|
|
1311
|
+
// Correlation math is identical to the keyed loaders, only the transport
|
|
1312
|
+
// (join vs in-list) changes. Always join the RELATION TARGET (alias `c`) to
|
|
1313
|
+
// the already-fetched side (alias `p`), correlating on the fetched side's key
|
|
1314
|
+
// and projecting `__tpk` from the fetched side's correlation column.
|
|
1315
|
+
const parentKeyCol = rel.type === 'belongsTo' ? fk[0] : rk[0];
|
|
1316
|
+
const childKeyCol = rel.type === 'belongsTo' ? rk[0] : fk[0];
|
|
1317
|
+
const parentKeyField = this.meta.reverseColumnMap[parentKeyCol] ?? parentKeyCol;
|
|
1318
|
+
const params = [];
|
|
1319
|
+
const childCols = this.joinChildCols(targetQi, options);
|
|
1320
|
+
const filter = await this.joinFilter(targetQi, parent.resolvedWhere, options.where, 'c', params, options.timeout ?? timeout);
|
|
1321
|
+
const order = targetQi.buildOrder(options.orderBy, params, 'c');
|
|
1322
|
+
const limitClause = options.limit !== undefined ? ` limit ${this.param(options.limit, params)}` : '';
|
|
1323
|
+
const offsetClause = options.offset ? ` offset ${this.param(options.offset, params)}` : '';
|
|
1324
|
+
const proj = this.joinProjection(childCols, `p.${(0, powdb_js_1.quotePowqlIdent)(parentKeyCol)}`, 'c');
|
|
1325
|
+
const powql = `${targetQi.qt} as c join ${this.qt} as p ` +
|
|
1326
|
+
`on c.${(0, powdb_js_1.quotePowqlIdent)(childKeyCol)} = p.${(0, powdb_js_1.quotePowqlIdent)(parentKeyCol)}` +
|
|
1327
|
+
`${filter}${order}${limitClause}${offsetClause} ${proj}`;
|
|
1328
|
+
// A READ: thread a read-shaped action through the exec seam.
|
|
1329
|
+
const { rows, native } = await targetQi.exec(powql, params, timeout, 'findMany');
|
|
1330
|
+
const single = rel.type === 'belongsTo' || rel.type === 'hasOne';
|
|
1331
|
+
const byKey = this.bucketByTpk(targetQi, rows, native);
|
|
1332
|
+
for (const p of parents) {
|
|
1333
|
+
const key = this.joinKey(p[parentKeyField]);
|
|
1334
|
+
const matches = (key == null ? undefined : byKey.get(key)) ?? [];
|
|
1335
|
+
p[relName] = single ? (matches[0] ?? null) : matches;
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
/**
|
|
1339
|
+
* manyToMany via chained joins: the target (alias `t`) → junction (alias `j`)
|
|
1340
|
+
* → the already-fetched side (alias `p`), correlating `__tpk` from the
|
|
1341
|
+
* junction's source key. Always a list, stitched exactly like the loader.
|
|
1342
|
+
*/
|
|
1343
|
+
async loadManyToManyViaJoin(parents, rel, relName, opt, parent, timeout) {
|
|
1344
|
+
const through = rel.through;
|
|
1345
|
+
if (!through)
|
|
1346
|
+
throw new errors_js_1.ValidationError(`[turbine] manyToMany relation "${relName}" is missing its junction (\`through\`).`);
|
|
1347
|
+
const options = (opt === true ? {} : opt);
|
|
1348
|
+
const targetMeta = this.schema.tables[rel.to];
|
|
1349
|
+
if (!targetMeta)
|
|
1350
|
+
throw new errors_js_1.ValidationError(`[turbine] Relation "${relName}" targets unknown table "${rel.to}".`);
|
|
1351
|
+
const targetQi = new PowqlInterface(this.pool, rel.to, this.schema, [], this.options);
|
|
1352
|
+
const sourceJCol = (0, schema_js_1.normalizeKeyColumns)(through.sourceKey)[0];
|
|
1353
|
+
const targetJCol = (0, schema_js_1.normalizeKeyColumns)(through.targetKey)[0];
|
|
1354
|
+
const sourceRefCol = (0, schema_js_1.normalizeKeyColumns)(rel.referenceKey)[0];
|
|
1355
|
+
const targetPkCol = targetMeta.primaryKey[0];
|
|
1356
|
+
const parentRefField = this.meta.reverseColumnMap[sourceRefCol] ?? sourceRefCol;
|
|
1357
|
+
const params = [];
|
|
1358
|
+
const childCols = this.joinChildCols(targetQi, options);
|
|
1359
|
+
const filter = await this.joinFilter(targetQi, parent.resolvedWhere, options.where, 't', params, options.timeout ?? timeout);
|
|
1360
|
+
const proj = this.joinProjection(childCols, `j.${(0, powdb_js_1.quotePowqlIdent)(sourceJCol)}`, 't');
|
|
1361
|
+
const powql = `${targetQi.qt} as t ` +
|
|
1362
|
+
`join ${(0, powdb_js_1.quotePowqlIdent)(through.table)} as j on t.${(0, powdb_js_1.quotePowqlIdent)(targetPkCol)} = j.${(0, powdb_js_1.quotePowqlIdent)(targetJCol)} ` +
|
|
1363
|
+
`join ${this.qt} as p on j.${(0, powdb_js_1.quotePowqlIdent)(sourceJCol)} = p.${(0, powdb_js_1.quotePowqlIdent)(sourceRefCol)}` +
|
|
1364
|
+
`${filter} ${proj}`;
|
|
1365
|
+
const { rows, native } = await targetQi.exec(powql, params, timeout, 'findMany');
|
|
1366
|
+
const byKey = this.bucketByTpk(targetQi, rows, native);
|
|
1367
|
+
for (const p of parents) {
|
|
1368
|
+
const key = this.joinKey(p[parentRefField]);
|
|
1369
|
+
p[relName] = (key == null ? undefined : byKey.get(key)) ?? [];
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
/**
|
|
1373
|
+
* The target column list to project through the join (honouring select/omit),
|
|
1374
|
+
* with a loud guard: a real column named `__tpk` would collide with the
|
|
1375
|
+
* reserved correlation alias, so refuse rather than silently mis-stitch.
|
|
1376
|
+
*/
|
|
1377
|
+
joinChildCols(targetQi, options) {
|
|
1378
|
+
const cols = targetQi.projectedColumns(options.select, options.omit);
|
|
1379
|
+
if (cols.includes('__tpk')) {
|
|
1380
|
+
throw new errors_js_1.ValidationError(`[turbine] relation target "${targetQi.table}" has a column named "__tpk", which collides with the reserved ` +
|
|
1381
|
+
`join correlation alias. Rename the column or load this relation with relationLoadStrategy: 'batched'.`);
|
|
1382
|
+
}
|
|
1383
|
+
return cols;
|
|
1384
|
+
}
|
|
1385
|
+
/**
|
|
1386
|
+
* `{ __tpk: <tpkExpr>, <col>: <childAlias>.<col>, … }`. Each child column is
|
|
1387
|
+
* ALIASED to its bare name (a bare qualified ref `c.col` would come back named
|
|
1388
|
+
* `c.col`, not `col`) so the stitched rows shape identically to a flat select.
|
|
1389
|
+
*/
|
|
1390
|
+
joinProjection(childCols, tpkExpr, childAlias) {
|
|
1391
|
+
const parts = [
|
|
1392
|
+
`__tpk: ${tpkExpr}`,
|
|
1393
|
+
...childCols.map((c) => `${(0, powdb_js_1.quotePowqlIdent)(c)}: ${childAlias}.${(0, powdb_js_1.quotePowqlIdent)(c)}`),
|
|
1394
|
+
];
|
|
1395
|
+
return `{ ${parts.join(', ')} }`;
|
|
1396
|
+
}
|
|
1397
|
+
/**
|
|
1398
|
+
* `filter <parentWhere qualified p> [and <relationWhere qualified childAlias>]`.
|
|
1399
|
+
* The parent where is the ALREADY-RESOLVED predicate (relation filters collapsed
|
|
1400
|
+
* to literal in-lists before the base query ran); the relation where is resolved
|
|
1401
|
+
* on the target the same way before qualifying, so a nested relation filter in
|
|
1402
|
+
* the relation `where` never reaches the join unresolved. Params bind in order.
|
|
1403
|
+
*/
|
|
1404
|
+
async joinFilter(targetQi, parentResolvedWhere, relWhere, childAlias, params, timeout) {
|
|
1405
|
+
const parts = [];
|
|
1406
|
+
const pw = this.buildWhere(parentResolvedWhere, params, 'p');
|
|
1407
|
+
if (pw)
|
|
1408
|
+
parts.push(pw);
|
|
1409
|
+
const relResolved = await targetQi.resolveRelationFilters(relWhere, timeout);
|
|
1410
|
+
const rw = targetQi.buildWhere(relResolved, params, childAlias);
|
|
1411
|
+
if (rw)
|
|
1412
|
+
parts.push(rw);
|
|
1413
|
+
return parts.length ? ` filter ${parts.join(' and ')}` : '';
|
|
1414
|
+
}
|
|
1415
|
+
/** Group join rows by their (normalized) `__tpk`, stripping it and shaping each child. */
|
|
1416
|
+
bucketByTpk(targetQi, rows, native) {
|
|
1417
|
+
const byKey = new Map();
|
|
1418
|
+
for (const raw of rows) {
|
|
1419
|
+
const tpk = this.joinKey(raw.__tpk);
|
|
1420
|
+
delete raw.__tpk;
|
|
1421
|
+
const child = targetQi.shape([raw], native)[0];
|
|
1422
|
+
if (tpk == null)
|
|
1423
|
+
continue;
|
|
1424
|
+
const bucket = byKey.get(tpk);
|
|
1425
|
+
if (bucket)
|
|
1426
|
+
bucket.push(child);
|
|
1427
|
+
else
|
|
1428
|
+
byKey.set(tpk, [child]);
|
|
1429
|
+
}
|
|
1430
|
+
return byKey;
|
|
1431
|
+
}
|
|
1432
|
+
/**
|
|
1433
|
+
* Normalize a correlation key to a stable string map key so a parent's key
|
|
1434
|
+
* value (a shaped entity field) and a child row's `__tpk` cell match across
|
|
1435
|
+
* wires and column types. A `Date` maps to microseconds
|
|
1436
|
+
* (`getTime()` ms times 1000), because a datetime correlation cell arrives as
|
|
1437
|
+
* raw micros (bigint on the native wire, a micros string on the legacy wire),
|
|
1438
|
+
* never as ms. bigint / number / string all stringify to the same digits, so
|
|
1439
|
+
* an int key matches whether it came back typed or as text.
|
|
1440
|
+
*/
|
|
1441
|
+
joinKey(v) {
|
|
1442
|
+
if (v == null)
|
|
1443
|
+
return null;
|
|
1444
|
+
if (v instanceof Date)
|
|
1445
|
+
return (BigInt(v.getTime()) * 1000n).toString();
|
|
1446
|
+
if (typeof v === 'bigint')
|
|
1447
|
+
return v.toString();
|
|
1448
|
+
return String(v);
|
|
1449
|
+
}
|
|
1450
|
+
// -------------------------------------------------------------------------
|
|
821
1451
|
// Writes (reselect — PowDB has no RETURNING)
|
|
822
1452
|
// -------------------------------------------------------------------------
|
|
823
1453
|
/** Split `data` into scalar assignments; reject relation (nested-write) keys. */
|
|
@@ -873,8 +1503,8 @@ class PowqlInterface {
|
|
|
873
1503
|
.map((a) => `${(0, powdb_js_1.quotePowqlIdent)(a.col.name)} := ${this.writeRef(a.value, a.col, params)}`)
|
|
874
1504
|
.join(', ');
|
|
875
1505
|
// `returning` surfaces the inserted row (all columns, schema order) in one round-trip.
|
|
876
|
-
const { rows } = await this.exec(`insert ${this.qt} { ${body} } returning`, params, args.timeout);
|
|
877
|
-
const row = rows.length ? this.shape(rows)[0] : null;
|
|
1506
|
+
const { rows, native } = await this.exec(`insert ${this.qt} { ${body} } returning`, params, args.timeout, 'create');
|
|
1507
|
+
const row = rows.length ? this.shape(rows, native)[0] : null;
|
|
878
1508
|
if (!row)
|
|
879
1509
|
throw new errors_js_1.NotFoundError({ table: this.table, where: data });
|
|
880
1510
|
return row;
|
|
@@ -891,8 +1521,8 @@ class PowqlInterface {
|
|
|
891
1521
|
return `{ ${assigns.map((a) => `${(0, powdb_js_1.quotePowqlIdent)(a.col.name)} := ${this.writeRef(a.value, a.col, params)}`).join(', ')} }`;
|
|
892
1522
|
});
|
|
893
1523
|
// Multi-row insert with `returning` hands back every inserted row in one round-trip.
|
|
894
|
-
const { rows } = await this.exec(`insert ${this.qt} ${tuples.join(', ')} returning`, params, args.timeout);
|
|
895
|
-
return this.shape(rows);
|
|
1524
|
+
const { rows, native } = await this.exec(`insert ${this.qt} ${tuples.join(', ')} returning`, params, args.timeout, 'createMany');
|
|
1525
|
+
return this.shape(rows, native);
|
|
896
1526
|
});
|
|
897
1527
|
}
|
|
898
1528
|
async update(args) {
|
|
@@ -906,8 +1536,8 @@ class PowqlInterface {
|
|
|
906
1536
|
this.assertCompiledWhere(where, false, 'update');
|
|
907
1537
|
const setClause = this.buildUpdateAssignments(args.data, params);
|
|
908
1538
|
// `returning` hands back the post-update row(s); take the first (single-row contract).
|
|
909
|
-
const { rows } = await this.exec(`${this.qt} filter ${where} update { ${setClause} } returning`, params, args.timeout);
|
|
910
|
-
const row = rows.length ? this.shape(rows)[0] : null;
|
|
1539
|
+
const { rows, native } = await this.exec(`${this.qt} filter ${where} update { ${setClause} } returning`, params, args.timeout, 'update');
|
|
1540
|
+
const row = rows.length ? this.shape(rows, native)[0] : null;
|
|
911
1541
|
if (!row)
|
|
912
1542
|
throw new errors_js_1.NotFoundError({ table: this.table, where: args.where });
|
|
913
1543
|
return row;
|
|
@@ -921,7 +1551,7 @@ class PowqlInterface {
|
|
|
921
1551
|
this.assertCompiledWhere(where, args.allowFullTableScan, 'updateMany');
|
|
922
1552
|
const setClause = this.buildUpdateAssignments(args.data, params);
|
|
923
1553
|
const filter = where ? ` filter ${where}` : '';
|
|
924
|
-
const { rowCount } = await this.exec(`${this.qt}${filter} update { ${setClause} }`, params, args.timeout);
|
|
1554
|
+
const { rowCount } = await this.exec(`${this.qt}${filter} update { ${setClause} }`, params, args.timeout, 'updateMany');
|
|
925
1555
|
return { count: rowCount };
|
|
926
1556
|
});
|
|
927
1557
|
}
|
|
@@ -990,6 +1620,11 @@ class PowqlInterface {
|
|
|
990
1620
|
}
|
|
991
1621
|
/** Open a flat PowDB transaction on a pinned connection and run `fn` inside it. */
|
|
992
1622
|
async runInImplicitTx(fn) {
|
|
1623
|
+
// A transaction-control `begin` is a write on a read-only pool: refuse it
|
|
1624
|
+
// locally before checking out a connection (zero wire / pool activity), the
|
|
1625
|
+
// same guard the exec seam applies to plain writes.
|
|
1626
|
+
if (this.pool.readonly === true)
|
|
1627
|
+
throw this.readOnlyError('transaction (begin)');
|
|
993
1628
|
// Route tx keywords through the dialect (like the SQL path) so this never
|
|
994
1629
|
// drifts from `powdbDialect`; falls back to the literal lowercase keywords.
|
|
995
1630
|
const d = this.options.dialect;
|
|
@@ -999,7 +1634,10 @@ class PowqlInterface {
|
|
|
999
1634
|
await client.query(d?.beginStatement?.() ?? 'begin');
|
|
1000
1635
|
began = true;
|
|
1001
1636
|
const { TransactionClient } = await Promise.resolve().then(() => __importStar(require('./client.js')));
|
|
1002
|
-
const tx = new TransactionClient(client, this.schema, this.middlewares, this.options
|
|
1637
|
+
const tx = new TransactionClient(client, this.schema, this.middlewares, this.options,
|
|
1638
|
+
// Pass the PowDB pool so its read-only guard + capabilities carry into
|
|
1639
|
+
// the transaction-scoped proxy pool (see createTxPool).
|
|
1640
|
+
this.pool);
|
|
1003
1641
|
const ctx = { schema: this.schema, tx: tx };
|
|
1004
1642
|
// Plant the single-writer re-entrancy marker for the implicit tx's
|
|
1005
1643
|
// subtree (same seam TurbineClient.$transaction uses) — user code that
|
|
@@ -1044,8 +1682,8 @@ class PowqlInterface {
|
|
|
1044
1682
|
const where = this.buildWhere(resolvedWhere, params);
|
|
1045
1683
|
this.assertCompiledWhere(where, false, 'delete');
|
|
1046
1684
|
// `returning` hands back the deleted row(s) — no separate pre-image reselect needed.
|
|
1047
|
-
const { rows } = await this.exec(`${this.qt} filter ${where} delete returning`, params, args.timeout);
|
|
1048
|
-
const row = rows.length ? this.shape(rows)[0] : null;
|
|
1685
|
+
const { rows, native } = await this.exec(`${this.qt} filter ${where} delete returning`, params, args.timeout, 'delete');
|
|
1686
|
+
const row = rows.length ? this.shape(rows, native)[0] : null;
|
|
1049
1687
|
if (!row)
|
|
1050
1688
|
throw new errors_js_1.NotFoundError({ table: this.table, where: args.where });
|
|
1051
1689
|
return row;
|
|
@@ -1058,7 +1696,7 @@ class PowqlInterface {
|
|
|
1058
1696
|
const where = this.buildWhere(resolvedWhere, params);
|
|
1059
1697
|
this.assertCompiledWhere(where, args.allowFullTableScan, 'deleteMany');
|
|
1060
1698
|
const filter = where ? ` filter ${where}` : '';
|
|
1061
|
-
const { rowCount } = await this.exec(`${this.qt}${filter} delete`, params, args.timeout);
|
|
1699
|
+
const { rowCount } = await this.exec(`${this.qt}${filter} delete`, params, args.timeout, 'deleteMany');
|
|
1062
1700
|
return { count: rowCount };
|
|
1063
1701
|
});
|
|
1064
1702
|
}
|
|
@@ -1080,7 +1718,7 @@ class PowqlInterface {
|
|
|
1080
1718
|
// (verified: "unexpected trailing token … 'returning'"), because it is one
|
|
1081
1719
|
// atomic insert-or-update, not two branches. So upsert alone keeps the
|
|
1082
1720
|
// reselect-by-PK fetch; create/update/delete all use `returning`.
|
|
1083
|
-
await this.exec(`upsert ${this.qt} on .${pkCol} { ${createBody} } on conflict { ${updateBody} }`, params, args.timeout);
|
|
1721
|
+
await this.exec(`upsert ${this.qt} on .${pkCol} { ${createBody} } on conflict { ${updateBody} }`, params, args.timeout, 'upsert');
|
|
1084
1722
|
const pkField = this.meta.reverseColumnMap[pkCol] ?? pkCol;
|
|
1085
1723
|
const row = await this.reselectByPk(createData[pkField], args.timeout);
|
|
1086
1724
|
if (!row)
|
|
@@ -1125,7 +1763,7 @@ class PowqlInterface {
|
|
|
1125
1763
|
const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
|
|
1126
1764
|
const where = this.buildWhere(resolvedWhere, params);
|
|
1127
1765
|
const filter = where ? ` filter ${where}` : '';
|
|
1128
|
-
const { rows } = await this.exec(`count(${this.qt}${filter})`, params, args.timeout);
|
|
1766
|
+
const { rows } = await this.exec(`count(${this.qt}${filter})`, params, args.timeout, 'count');
|
|
1129
1767
|
return Number((rows[0]?.value ?? rows[0]?.count ?? 0));
|
|
1130
1768
|
});
|
|
1131
1769
|
}
|
|
@@ -1139,7 +1777,7 @@ class PowqlInterface {
|
|
|
1139
1777
|
const filter = where ? ` filter ${where}` : '';
|
|
1140
1778
|
const scalar = async (expr) => {
|
|
1141
1779
|
const params = [...filterParams];
|
|
1142
|
-
const { rows } = await this.exec(expr, params, args.timeout);
|
|
1780
|
+
const { rows } = await this.exec(expr, params, args.timeout, 'aggregate');
|
|
1143
1781
|
const v = rows[0]?.value;
|
|
1144
1782
|
return v == null || v === 'null' ? null : Number(v);
|
|
1145
1783
|
};
|
|
@@ -1171,90 +1809,196 @@ class PowqlInterface {
|
|
|
1171
1809
|
}
|
|
1172
1810
|
async groupBy(args) {
|
|
1173
1811
|
return this.withMiddleware('groupBy', args, async () => {
|
|
1174
|
-
//
|
|
1175
|
-
// group keys / aggregate targets) have no PowQL equivalent: refuse
|
|
1176
|
-
// clearly instead of emitting broken PowQL.
|
|
1812
|
+
// DISTINCT ON has no PowQL equivalent (no DISTINCT ON row source).
|
|
1177
1813
|
if (args.distinctOn) {
|
|
1178
1814
|
throw new errors_js_1.UnsupportedFeatureError('groupBy distinctOn row source', 'PowDB');
|
|
1179
1815
|
}
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
continue;
|
|
1189
|
-
for (const value of Object.values(spec)) {
|
|
1190
|
-
if (value !== undefined && typeof value !== 'boolean') {
|
|
1191
|
-
throw new errors_js_1.UnsupportedFeatureError(`JSON-path ${fn} aggregate targets`, 'PowDB');
|
|
1192
|
-
}
|
|
1193
|
-
}
|
|
1816
|
+
// JSON-path group keys / aggregate targets (≥ 0.12) are gated once here.
|
|
1817
|
+
const usesJson = args.by.some((e) => typeof e !== 'string') ||
|
|
1818
|
+
['_sum', '_avg', '_min', '_max'].some((fn) => {
|
|
1819
|
+
const spec = args[fn];
|
|
1820
|
+
return spec !== undefined && Object.values(spec).some((v) => v != null && typeof v === 'object');
|
|
1821
|
+
});
|
|
1822
|
+
if (usesJson) {
|
|
1823
|
+
(0, powdb_js_1.requireCapability)(this.capabilities, 'jsonDocs', 'JSON-path groupBy keys / aggregate targets');
|
|
1194
1824
|
}
|
|
1825
|
+
// `emitNative` decides whether the query GENERATION needs the legacy-wire
|
|
1826
|
+
// `json_type` discriminator (pool-level capability). The DECODE side reads
|
|
1827
|
+
// the wire that ACTUALLY served the result (`resultNative`, from exec), so
|
|
1828
|
+
// a per-call legacy fallback on a native-capable pool still decodes right.
|
|
1829
|
+
const emitNative = Boolean(this.capabilities.nativeRaw);
|
|
1195
1830
|
const params = [];
|
|
1196
1831
|
const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
|
|
1197
1832
|
const where = this.buildWhere(resolvedWhere, params);
|
|
1198
1833
|
const filter = where ? ` filter ${where}` : '';
|
|
1199
|
-
|
|
1200
|
-
//
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
const
|
|
1204
|
-
|
|
1205
|
-
|
|
1834
|
+
// Result-key namespace, mirroring the SQL builder's `claimResultKey`
|
|
1835
|
+
// (query/builder.ts): a group-key / aggregate output-name collision (with
|
|
1836
|
+
// `_count`, another key, or an aggregate output) throws E003.
|
|
1837
|
+
const usedKeys = new Set();
|
|
1838
|
+
const claim = (key, what) => {
|
|
1839
|
+
if (key === '_count' || usedKeys.has(key)) {
|
|
1840
|
+
throw new errors_js_1.ValidationError(`[turbine] groupBy output name "${key}" (${what}) collides with another output column on table ` +
|
|
1841
|
+
`"${this.table}": set an explicit \`alias\` (or rename the aggregate key) to disambiguate.`);
|
|
1842
|
+
}
|
|
1843
|
+
usedKeys.add(key);
|
|
1844
|
+
};
|
|
1845
|
+
const groupExprs = [];
|
|
1846
|
+
const proj = [];
|
|
1847
|
+
const byOrderExprs = new Map();
|
|
1848
|
+
const byReaders = [];
|
|
1849
|
+
let gkN = 0;
|
|
1850
|
+
let gtN = 0;
|
|
1851
|
+
for (const entry of args.by) {
|
|
1852
|
+
if (typeof entry === 'string') {
|
|
1853
|
+
const col = this.column(entry);
|
|
1854
|
+
claim(entry, `column "${col.name}"`);
|
|
1855
|
+
if (col.name !== entry)
|
|
1856
|
+
claim(col.name, `column "${col.name}"`);
|
|
1857
|
+
groupExprs.push(`.${col.name}`);
|
|
1858
|
+
proj.push(`.${col.name}`);
|
|
1859
|
+
byOrderExprs.set(entry, `.${col.name}`);
|
|
1860
|
+
byReaders.push({ kind: 'plain', resultKey: entry, rowKey: col.name, col });
|
|
1861
|
+
}
|
|
1862
|
+
else {
|
|
1863
|
+
const col = this.column(entry.field);
|
|
1864
|
+
if (!(0, powdb_js_1.isJsonColumn)(col)) {
|
|
1865
|
+
throw new errors_js_1.ValidationError(`[turbine] groupBy JSON group key on "${entry.field}" (table "${this.table}") requires a json column.`);
|
|
1866
|
+
}
|
|
1867
|
+
this.assertJsonPath('group key', entry.field, entry.path);
|
|
1868
|
+
const pathExpr = this.jsonPathExpr(col, entry.path, params);
|
|
1869
|
+
const alias = entry.alias ?? String(entry.path[entry.path.length - 1]);
|
|
1870
|
+
claim(alias, `JSON path on "${entry.field}"`);
|
|
1871
|
+
const gkAlias = `gk_${gkN++}`;
|
|
1872
|
+
groupExprs.push(pathExpr);
|
|
1873
|
+
proj.push(`${gkAlias}: ${pathExpr}`);
|
|
1874
|
+
byOrderExprs.set(alias, `.${gkAlias}`);
|
|
1875
|
+
let discrim;
|
|
1876
|
+
if (!emitNative) {
|
|
1877
|
+
// Legacy wire renders a missing value, JSON null, AND the string
|
|
1878
|
+
// "null" all as the cell "null". `min(json_type(path))` over the
|
|
1879
|
+
// group is "string" ONLY for the string-"null" group and "null"
|
|
1880
|
+
// otherwise (a bare `json_type` projection is not group-correlated).
|
|
1881
|
+
discrim = `gt_${gtN++}`;
|
|
1882
|
+
proj.push(`${discrim}: min(json_type(${pathExpr}))`);
|
|
1883
|
+
}
|
|
1884
|
+
byReaders.push({ kind: 'json', resultKey: alias, rowKey: gkAlias, discrim });
|
|
1885
|
+
}
|
|
1886
|
+
}
|
|
1887
|
+
// Aggregates: `agg_N` internal aliases (PowQL rejects reserved-word
|
|
1888
|
+
// aliases like `count:`). `aggInner` lets HAVING re-emit the exact inner
|
|
1889
|
+
// expression by user key; `aggOrderExprs` lets orderBy reference the alias.
|
|
1890
|
+
const aggReaders = [];
|
|
1891
|
+
const aggOrderExprs = new Map();
|
|
1892
|
+
const aggInner = new Map();
|
|
1893
|
+
let aggN = 0;
|
|
1894
|
+
// Parity with the SQL builder (query/builder.ts): `_count` is selected by
|
|
1895
|
+
// DEFAULT unless the caller explicitly opts out with `_count: false`, so
|
|
1896
|
+
// every groupBy row carries `_count` and `orderBy: { _count }` works
|
|
1897
|
+
// without requesting it (the alias is seeded into `aggOrderExprs`).
|
|
1898
|
+
const countSelected = args._count === true || args._count === undefined;
|
|
1899
|
+
if (countSelected) {
|
|
1900
|
+
const alias = `agg_${aggN++}`;
|
|
1901
|
+
proj.push(`${alias}: count(*)`);
|
|
1902
|
+
aggReaders.push({ alias, outKey: '_count', numeric: true });
|
|
1903
|
+
aggOrderExprs.set('_count', `.${alias}`);
|
|
1206
1904
|
}
|
|
1207
1905
|
for (const fn of ['_sum', '_avg', '_min', '_max']) {
|
|
1208
1906
|
const spec = args[fn];
|
|
1209
1907
|
if (!spec)
|
|
1210
1908
|
continue;
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1909
|
+
const powfn = fn.slice(1); // sum/avg/min/max
|
|
1910
|
+
for (const [key, target] of Object.entries(spec)) {
|
|
1911
|
+
if (!target)
|
|
1912
|
+
continue;
|
|
1913
|
+
const alias = `agg_${aggN++}`;
|
|
1914
|
+
if (target === true) {
|
|
1915
|
+
const col = this.column(key);
|
|
1916
|
+
claim(`${fn}_${col.name}`, `${fn} of column "${col.name}"`);
|
|
1917
|
+
const inner = `.${col.name}`;
|
|
1918
|
+
proj.push(`${alias}: ${powfn}(${inner})`);
|
|
1919
|
+
aggReaders.push({ alias, outKey: `${fn}:${key}`, numeric: true });
|
|
1920
|
+
aggOrderExprs.set(`${fn}:${key}`, `.${alias}`);
|
|
1921
|
+
aggInner.set(key, inner);
|
|
1922
|
+
}
|
|
1923
|
+
else {
|
|
1924
|
+
const col = this.column(target.field);
|
|
1925
|
+
if (!(0, powdb_js_1.isJsonColumn)(col)) {
|
|
1926
|
+
throw new errors_js_1.ValidationError(`[turbine] groupBy ${fn} target "${key}" on "${target.field}" (table "${this.table}") requires a json column.`);
|
|
1927
|
+
}
|
|
1928
|
+
this.assertJsonPath(`${fn} target "${key}"`, target.field, target.path);
|
|
1929
|
+
const alwaysNumeric = fn === '_sum' || fn === '_avg';
|
|
1930
|
+
if (alwaysNumeric && target.type === 'text') {
|
|
1931
|
+
throw new errors_js_1.ValidationError(`[turbine] groupBy ${fn} target "${key}" on table "${this.table}": ` +
|
|
1932
|
+
`${fn} over a JSON path is always numeric: remove \`type: 'text'\`.`);
|
|
1933
|
+
}
|
|
1934
|
+
const numeric = alwaysNumeric || target.type === 'numeric';
|
|
1935
|
+
claim(`${fn}_${key}`, `${fn} JSON target "${key}"`);
|
|
1936
|
+
const pathExpr = this.jsonPathExpr(col, target.path, params);
|
|
1937
|
+
const inner = numeric ? `cast(${pathExpr}, "float")` : pathExpr;
|
|
1938
|
+
proj.push(`${alias}: ${powfn}(${inner})`);
|
|
1939
|
+
aggReaders.push({ alias, outKey: `${fn}:${key}`, numeric });
|
|
1940
|
+
aggOrderExprs.set(`${fn}:${key}`, `.${alias}`);
|
|
1941
|
+
aggInner.set(key, inner);
|
|
1227
1942
|
}
|
|
1228
1943
|
}
|
|
1229
1944
|
}
|
|
1230
|
-
const
|
|
1231
|
-
const
|
|
1232
|
-
const
|
|
1233
|
-
|
|
1945
|
+
const having = this.buildHaving(args.having, params, aggInner);
|
|
1946
|
+
const order = this.buildGroupOrder(args.orderBy, byOrderExprs, aggOrderExprs);
|
|
1947
|
+
const powql = `${this.qt}${filter} group ${groupExprs.join(', ')}${having}${order} { ${proj.join(', ')} }`;
|
|
1948
|
+
const { rows, native: resultNative } = await this.exec(powql, params, args.timeout, 'groupBy');
|
|
1949
|
+
// Reshape: group keys → user fields (coerced / null-disambiguated),
|
|
1950
|
+
// aggregates → nested `{ _sum: { field } }`; discriminators are stripped.
|
|
1951
|
+
// Group-key cells go through the SAME coercion policy `rowToEntity` uses,
|
|
1952
|
+
// by the wire that actually served this result, so a native-wire int cell
|
|
1953
|
+
// (bigint) or datetime cell (micros) never leaks into the result: it
|
|
1954
|
+
// becomes the same number / Date / PG-text-parity string an embedded /
|
|
1955
|
+
// legacy / SQL groupBy returns for the identical query.
|
|
1234
1956
|
return rows.map((raw) => {
|
|
1235
1957
|
const out = {};
|
|
1236
|
-
for (const
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1958
|
+
for (const r of byReaders) {
|
|
1959
|
+
if (r.kind === 'plain') {
|
|
1960
|
+
const cell = raw[r.rowKey];
|
|
1961
|
+
out[r.resultKey] = resultNative
|
|
1962
|
+
? (0, powdb_js_1.coerceNativeValue)(cell, r.col)
|
|
1963
|
+
: typeof cell === 'string'
|
|
1964
|
+
? coerceScalar(cell, r.col.tsType)
|
|
1965
|
+
: cell;
|
|
1966
|
+
}
|
|
1967
|
+
else {
|
|
1968
|
+
out[r.resultKey] = decodeGroupKeyCell(raw[r.rowKey], r.discrim ? raw[r.discrim] : undefined, resultNative);
|
|
1969
|
+
}
|
|
1240
1970
|
}
|
|
1241
|
-
for (const a of
|
|
1242
|
-
const
|
|
1243
|
-
const
|
|
1971
|
+
for (const a of aggReaders) {
|
|
1972
|
+
const cell = raw[a.alias];
|
|
1973
|
+
const v = cell == null || cell === 'null' ? null : a.numeric ? Number(cell) : cell;
|
|
1244
1974
|
if (a.outKey === '_count')
|
|
1245
|
-
out._count =
|
|
1975
|
+
out._count = v ?? 0;
|
|
1246
1976
|
else {
|
|
1247
1977
|
const [bucket, field] = a.outKey.split(':');
|
|
1248
1978
|
out[bucket] ??= {};
|
|
1249
|
-
out[bucket][field] =
|
|
1979
|
+
out[bucket][field] = v;
|
|
1250
1980
|
}
|
|
1251
1981
|
}
|
|
1252
1982
|
return out;
|
|
1253
1983
|
});
|
|
1254
1984
|
});
|
|
1255
1985
|
}
|
|
1256
|
-
/**
|
|
1257
|
-
|
|
1986
|
+
/** Validate a JSON-path target (group key / aggregate target): non-empty array of keys/indexes. */
|
|
1987
|
+
assertJsonPath(context, field, path) {
|
|
1988
|
+
if (!Array.isArray(path) ||
|
|
1989
|
+
path.length === 0 ||
|
|
1990
|
+
path.some((el) => typeof el !== 'string' && !(typeof el === 'number' && Number.isFinite(el)))) {
|
|
1991
|
+
throw new errors_js_1.ValidationError(`[turbine] groupBy ${context} on "${field}" (table "${this.table}") requires a non-empty \`path\` ` +
|
|
1992
|
+
`array of keys/indexes (e.g. { field: '${field}', path: ['category'] }).`);
|
|
1993
|
+
}
|
|
1994
|
+
}
|
|
1995
|
+
/**
|
|
1996
|
+
* `having <expr>` over group aggregates. `_count` compares `count(*)` (parity
|
|
1997
|
+
* with the projection); a per-field aggregate re-emits its inner expression
|
|
1998
|
+
* (from `aggInner` when the field is a requested aggregate, so a JSON-path
|
|
1999
|
+
* aggregate reuses its bound placeholders, else `.field` for a plain column).
|
|
2000
|
+
*/
|
|
2001
|
+
buildHaving(having, params, aggInner) {
|
|
1258
2002
|
if (!having)
|
|
1259
2003
|
return '';
|
|
1260
2004
|
const conds = [];
|
|
@@ -1272,18 +2016,88 @@ class PowqlInterface {
|
|
|
1272
2016
|
if (spec == null)
|
|
1273
2017
|
continue;
|
|
1274
2018
|
if (key === '_count') {
|
|
1275
|
-
conds.push(cmp(
|
|
2019
|
+
conds.push(cmp('count(*)', spec));
|
|
1276
2020
|
}
|
|
1277
2021
|
else {
|
|
1278
2022
|
for (const [fn, filter] of Object.entries(spec)) {
|
|
1279
2023
|
if (filter == null)
|
|
1280
2024
|
continue;
|
|
1281
|
-
|
|
2025
|
+
const inner = aggInner.get(key) ?? this.ref(key);
|
|
2026
|
+
conds.push(cmp(`${fn.slice(1)}(${inner})`, filter));
|
|
1282
2027
|
}
|
|
1283
2028
|
}
|
|
1284
2029
|
}
|
|
1285
2030
|
return conds.length ? ` having ${conds.join(' and ')}` : '';
|
|
1286
2031
|
}
|
|
2032
|
+
/**
|
|
2033
|
+
* Compile a groupBy `orderBy` into a PowQL `order` body over the group RESULT
|
|
2034
|
+
* columns (by-fields, JSON group-key aliases, and requested aggregates). PowQL
|
|
2035
|
+
* cannot re-emit an aggregate EXPRESSION in `order` (engine error), but CAN
|
|
2036
|
+
* order by a projection alias on a grouped query (probed), so each key maps to
|
|
2037
|
+
* its projected alias (`.agg_N` / `.gk_N` / `.col`). Semantics and error
|
|
2038
|
+
* surface mirror the SQL `buildGroupByOrderBy` (0.32.2 R3-1): an aggregate not
|
|
2039
|
+
* requested in this call, or an unknown by-key, throws E003 listing the valid
|
|
2040
|
+
* keys. `nulls: 'first'` stays E017 (PowDB has no NULLS placement grammar).
|
|
2041
|
+
*/
|
|
2042
|
+
buildGroupOrder(orderBy, byOrderExprs, aggOrderExprs) {
|
|
2043
|
+
if (!orderBy)
|
|
2044
|
+
return '';
|
|
2045
|
+
const aggBlocks = new Set(['_count', '_sum', '_avg', '_min', '_max']);
|
|
2046
|
+
const validKeys = () => {
|
|
2047
|
+
const keys = [...byOrderExprs.keys()];
|
|
2048
|
+
for (const k of aggOrderExprs.keys())
|
|
2049
|
+
keys.push(k.includes(':') ? k.replace(':', '.') : k);
|
|
2050
|
+
return keys.join(', ') || '(none)';
|
|
2051
|
+
};
|
|
2052
|
+
const parts = [];
|
|
2053
|
+
for (const [key, value] of Object.entries(orderBy)) {
|
|
2054
|
+
if (value === undefined)
|
|
2055
|
+
continue;
|
|
2056
|
+
if (aggBlocks.has(key)) {
|
|
2057
|
+
if (key === '_count') {
|
|
2058
|
+
const expr = aggOrderExprs.get('_count');
|
|
2059
|
+
if (!expr) {
|
|
2060
|
+
throw new errors_js_1.ValidationError(`[turbine] Cannot order groupBy by "_count" on table "${this.table}": _count is not selected. ` +
|
|
2061
|
+
`Orderable keys: ${validKeys()}.`);
|
|
2062
|
+
}
|
|
2063
|
+
parts.push(`${expr} ${this.groupOrderDir(value, '_count')}`);
|
|
2064
|
+
continue;
|
|
2065
|
+
}
|
|
2066
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
2067
|
+
throw new errors_js_1.ValidationError(`[turbine] Invalid groupBy orderBy for "${key}" on table "${this.table}": ` +
|
|
2068
|
+
`expected a field map like { ${key}: { amount: 'desc' } }.`);
|
|
2069
|
+
}
|
|
2070
|
+
for (const [field, dirSpec] of Object.entries(value)) {
|
|
2071
|
+
if (dirSpec === undefined)
|
|
2072
|
+
continue;
|
|
2073
|
+
const expr = aggOrderExprs.get(`${key}:${field}`);
|
|
2074
|
+
if (!expr) {
|
|
2075
|
+
throw new errors_js_1.ValidationError(`[turbine] Cannot order groupBy by "${key}.${field}" on table "${this.table}": ` +
|
|
2076
|
+
`that aggregate is not requested in this call. Orderable keys: ${validKeys()}.`);
|
|
2077
|
+
}
|
|
2078
|
+
parts.push(`${expr} ${this.groupOrderDir(dirSpec, `${key}.${field}`)}`);
|
|
2079
|
+
}
|
|
2080
|
+
continue;
|
|
2081
|
+
}
|
|
2082
|
+
const expr = byOrderExprs.get(key);
|
|
2083
|
+
if (!expr) {
|
|
2084
|
+
throw new errors_js_1.ValidationError(`[turbine] Unknown field "${key}" in groupBy orderBy on table "${this.table}". Orderable keys: ${validKeys()}.`);
|
|
2085
|
+
}
|
|
2086
|
+
parts.push(`${expr} ${this.groupOrderDir(value, key)}`);
|
|
2087
|
+
}
|
|
2088
|
+
return parts.length ? ` order ${parts.join(', ')}` : '';
|
|
2089
|
+
}
|
|
2090
|
+
/** Resolve a groupBy order direction, refusing `nulls: 'first'` (E017); `nulls: 'last'` is a no-op. */
|
|
2091
|
+
groupOrderDir(value, keyForMsg) {
|
|
2092
|
+
if (value !== null && typeof value === 'object') {
|
|
2093
|
+
const spec = value;
|
|
2094
|
+
if (spec.nulls === 'first') {
|
|
2095
|
+
throw new errors_js_1.UnsupportedFeatureError('NULLS FIRST placement', 'PowDB', `groupBy orderBy "${keyForMsg}": PowDB orders NULLs / missing keys LAST in both directions`);
|
|
2096
|
+
}
|
|
2097
|
+
return spec.sort === 'desc' ? 'desc' : 'asc';
|
|
2098
|
+
}
|
|
2099
|
+
return value === 'desc' ? 'desc' : 'asc';
|
|
2100
|
+
}
|
|
1287
2101
|
// -------------------------------------------------------------------------
|
|
1288
2102
|
// Streaming / unsupported
|
|
1289
2103
|
// -------------------------------------------------------------------------
|
|
@@ -1297,12 +2111,12 @@ class PowqlInterface {
|
|
|
1297
2111
|
/** Reselect a single row by its single-column primary key value. */
|
|
1298
2112
|
async reselectByPk(pkValue, timeout) {
|
|
1299
2113
|
const pkField = this.meta.reverseColumnMap[this.meta.primaryKey[0]] ?? this.meta.primaryKey[0];
|
|
1300
|
-
const rows = await this.runFind({
|
|
2114
|
+
const { rows, native } = await this.runFind({
|
|
1301
2115
|
where: { [pkField]: pkValue },
|
|
1302
2116
|
limit: 1,
|
|
1303
2117
|
timeout,
|
|
1304
2118
|
});
|
|
1305
|
-
return rows.length ? this.shape(rows)[0] : null;
|
|
2119
|
+
return rows.length ? this.shape(rows, native)[0] : null;
|
|
1306
2120
|
}
|
|
1307
2121
|
/**
|
|
1308
2122
|
* Empty-where guard — blocks accidental whole-table writes. Mirrors the SQL
|
|
@@ -1338,3 +2152,53 @@ function coerceScalar(raw, tsType) {
|
|
|
1338
2152
|
return new Date(Number(raw) / 1000);
|
|
1339
2153
|
return raw;
|
|
1340
2154
|
}
|
|
2155
|
+
/**
|
|
2156
|
+
* Decode a JSON group-key cell, resolving the legacy-wire `null` ambiguity AND
|
|
2157
|
+
* normalizing the native typed wire to the SAME PG-`#>>`-text-parity shape.
|
|
2158
|
+
*
|
|
2159
|
+
* On the native typed wire (`native`) a cell arrives pre-typed (a JSON int as a
|
|
2160
|
+
* `bigint`, a bool as `boolean`, an unset value as `null`). Returned as-is that
|
|
2161
|
+
* would diverge from every other transport: the embedded / legacy / SQL wire
|
|
2162
|
+
* all yield the extracted TEXT (`'7'`, `'true'`), and a raw `bigint` even throws
|
|
2163
|
+
* on `JSON.stringify`. So a native scalar cell is rendered to its text form
|
|
2164
|
+
* ({@link nativeJsonKeyText}); `null`/`empty` stays `null`, and a genuine string
|
|
2165
|
+
* `"null"` stays the string (the wart the native wire was adopted to fix).
|
|
2166
|
+
*
|
|
2167
|
+
* On the legacy string wire a missing value, JSON null, AND the string `"null"`
|
|
2168
|
+
* all render the cell `"null"`; the group's `min(json_type(…))` discriminator is
|
|
2169
|
+
* `"string"` ONLY for the string-`"null"` group, so the cell is the string
|
|
2170
|
+
* `"null"` iff the discriminator is `"string"`, else `null`. Other cell values
|
|
2171
|
+
* pass through as the extracted string.
|
|
2172
|
+
*/
|
|
2173
|
+
function decodeGroupKeyCell(cell, discrim, native) {
|
|
2174
|
+
if (native)
|
|
2175
|
+
return nativeJsonKeyText(cell);
|
|
2176
|
+
if (cell == null)
|
|
2177
|
+
return null;
|
|
2178
|
+
if (cell === 'null')
|
|
2179
|
+
return discrim === 'string' ? 'null' : null;
|
|
2180
|
+
return cell;
|
|
2181
|
+
}
|
|
2182
|
+
/**
|
|
2183
|
+
* Render a native-wire JSON group-key cell to the extracted-text shape the other
|
|
2184
|
+
* transports return (PG `#>>` / embedded legacy / SQL all give text keys). Keeps
|
|
2185
|
+
* `null` as `null`; a scalar (`bigint`/`number`/`boolean`/`string`) becomes its
|
|
2186
|
+
* string form; an object/array json sub-document is stringified (best-effort
|
|
2187
|
+
* parity: canonical byte-for-byte matching is not guaranteed for nested docs).
|
|
2188
|
+
*/
|
|
2189
|
+
function nativeJsonKeyText(cell) {
|
|
2190
|
+
if (cell === null || cell === undefined)
|
|
2191
|
+
return null;
|
|
2192
|
+
if (typeof cell === 'bigint')
|
|
2193
|
+
return cell.toString();
|
|
2194
|
+
if (typeof cell === 'number' || typeof cell === 'boolean')
|
|
2195
|
+
return String(cell);
|
|
2196
|
+
if (typeof cell === 'string')
|
|
2197
|
+
return cell;
|
|
2198
|
+
try {
|
|
2199
|
+
return JSON.stringify(cell);
|
|
2200
|
+
}
|
|
2201
|
+
catch {
|
|
2202
|
+
return String(cell);
|
|
2203
|
+
}
|
|
2204
|
+
}
|