turbine-orm 0.32.2 → 0.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/cjs/dialect.js +1 -0
- package/dist/cjs/index-advisor.js +0 -0
- package/dist/cjs/index.js +2 -1
- package/dist/cjs/mssql.js +3 -0
- package/dist/cjs/mysql.js +3 -0
- package/dist/cjs/optional-peer-import.cjs +28 -0
- package/dist/cjs/powdb-introspect.js +222 -0
- package/dist/cjs/powdb.js +446 -55
- package/dist/cjs/powql.js +566 -111
- package/dist/cjs/query/builder.js +136 -53
- package/dist/cjs/query/filters.js +4 -4
- package/dist/cjs/schema-builder.js +16 -0
- package/dist/cjs/schema-metadata.js +81 -10
- package/dist/cjs/sqlite.js +2 -0
- package/dist/dialect.d.ts +7 -0
- package/dist/dialect.js +1 -0
- package/dist/index-advisor.d.ts +15 -1
- package/dist/index-advisor.js +0 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/mssql.js +3 -0
- package/dist/mysql.js +3 -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 +249 -13
- package/dist/powdb.js +438 -54
- package/dist/powql.d.ts +113 -6
- package/dist/powql.js +568 -113
- package/dist/query/builder.d.ts +11 -0
- package/dist/query/builder.js +136 -53
- package/dist/query/filters.d.ts +3 -3
- package/dist/query/filters.js +4 -4
- package/dist/query/types.d.ts +50 -6
- 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 +2 -0
- package/package.json +3 -3
package/dist/cjs/powql.js
CHANGED
|
@@ -84,6 +84,21 @@ 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
|
+
]);
|
|
87
102
|
/** Operator keys recognised inside a `WhereOperator` object. */
|
|
88
103
|
const OPERATOR_KEYS = new Set([
|
|
89
104
|
'equals',
|
|
@@ -129,7 +144,6 @@ class PowqlInterface {
|
|
|
129
144
|
defaultLimit;
|
|
130
145
|
warnOnUnlimited;
|
|
131
146
|
onQuery;
|
|
132
|
-
currentAction = 'raw';
|
|
133
147
|
warnedUnlimited = false;
|
|
134
148
|
constructor(pool, table, schema, middlewares = [], options = {}) {
|
|
135
149
|
this.pool = pool;
|
|
@@ -180,7 +194,17 @@ class PowqlInterface {
|
|
|
180
194
|
* {@link toPowdbParam}, so the wire param is unchanged.
|
|
181
195
|
*/
|
|
182
196
|
param(value, params, col) {
|
|
183
|
-
|
|
197
|
+
let tagged = value;
|
|
198
|
+
if (col && typeof value === 'number' && this.isFloatCol(col)) {
|
|
199
|
+
// Float column: force a float-form literal even for an integer value.
|
|
200
|
+
tagged = new powdb_js_1.PowdbFloatParam(value);
|
|
201
|
+
}
|
|
202
|
+
else if (col && value !== null && typeof value === 'object' && !(value instanceof Date) && (0, powdb_js_1.isJsonColumn)(col)) {
|
|
203
|
+
// json document column: a JS object/array is serialized to canonical JSON
|
|
204
|
+
// text and stored as a json document (a JS string passes through raw, same
|
|
205
|
+
// contract as pg jsonb; `null` stays `null`).
|
|
206
|
+
tagged = new powdb_js_1.PowdbJsonParam(value);
|
|
207
|
+
}
|
|
184
208
|
params.push(tagged);
|
|
185
209
|
return `$${params.length}`;
|
|
186
210
|
}
|
|
@@ -208,6 +232,15 @@ class PowqlInterface {
|
|
|
208
232
|
return false;
|
|
209
233
|
}
|
|
210
234
|
}
|
|
235
|
+
/**
|
|
236
|
+
* The bound pool's {@link PowdbCapabilities}. Falls back to the trusted-caller
|
|
237
|
+
* default (all feature gates on, `nativeRaw` off) when a directly-constructed
|
|
238
|
+
* pool did not carry them, matching {@link PowdbPool}'s own constructor
|
|
239
|
+
* default so a hand-built test pool never crashes the version gates.
|
|
240
|
+
*/
|
|
241
|
+
get capabilities() {
|
|
242
|
+
return this.pool.capabilities ?? powdb_js_1.ALL_POWDB_CAPABILITIES;
|
|
243
|
+
}
|
|
211
244
|
/** A predicate that is always false — the empty-`in` / contradiction sentinel. */
|
|
212
245
|
alwaysFalse() {
|
|
213
246
|
const pk = this.meta.primaryKey[0] ?? this.meta.columns[0]?.name;
|
|
@@ -249,20 +282,33 @@ class PowqlInterface {
|
|
|
249
282
|
throw new errors_js_1.ValidationError(`[turbine] internal: relation filter "${key}" reached buildWhere unresolved (missing resolveRelationFilters()).`);
|
|
250
283
|
}
|
|
251
284
|
else {
|
|
252
|
-
|
|
285
|
+
// A JsonFilter (or a bare `{ path }`) can compile to zero clauses; skip
|
|
286
|
+
// empty results so buildWhere never emits a dangling ` and `.
|
|
287
|
+
const cond = this.buildFieldCondition(key, value, params);
|
|
288
|
+
if (cond)
|
|
289
|
+
parts.push(cond);
|
|
253
290
|
}
|
|
254
291
|
}
|
|
255
292
|
return parts.join(' and ');
|
|
256
293
|
}
|
|
257
294
|
/** Build a single `field: value | operator` condition. */
|
|
258
295
|
buildFieldCondition(field, value, params) {
|
|
296
|
+
const colMeta = this.column(field);
|
|
259
297
|
const ref = this.ref(field);
|
|
260
298
|
if (value === null)
|
|
261
299
|
return `${ref} is null`;
|
|
262
300
|
if (value instanceof Date || typeof value !== 'object') {
|
|
263
|
-
return `${ref} = ${this.param(value, params)}`;
|
|
301
|
+
return `${ref} = ${this.param(value, params, colMeta)}`;
|
|
264
302
|
}
|
|
265
303
|
const op = value;
|
|
304
|
+
// JSON path / key filters on a json document column compile to PowQL `->`
|
|
305
|
+
// path filters (≥ 0.12). `isJsonFilter` matches `path`/`equals`/`contains`/
|
|
306
|
+
// `hasKey`; on a NON-json column those fall through to the scalar operator
|
|
307
|
+
// path below (e.g. `equals` stays a plain equality), exactly like SQL.
|
|
308
|
+
if ((0, powdb_js_1.isJsonColumn)(colMeta) && (0, filters_js_1.isJsonFilter)(value)) {
|
|
309
|
+
(0, powdb_js_1.requireCapability)(this.capabilities, 'jsonDocs', 'JSON path filters');
|
|
310
|
+
return this.buildJsonPathCondition(colMeta, value, params);
|
|
311
|
+
}
|
|
266
312
|
rejectUnsupportedFilter(op, field);
|
|
267
313
|
if (!Object.keys(op).some((k) => OPERATOR_KEYS.has(k))) {
|
|
268
314
|
// A bare object that is not an operator set — equality by value.
|
|
@@ -314,6 +360,100 @@ class PowqlInterface {
|
|
|
314
360
|
}
|
|
315
361
|
return conds.length > 1 ? `(${conds.join(' and ')})` : (conds[0] ?? this.alwaysFalse());
|
|
316
362
|
}
|
|
363
|
+
/**
|
|
364
|
+
* PowQL JSON path expression `.col->$a->$b…`, binding EVERY path segment as a
|
|
365
|
+
* positional param (a string segment as a `str` token, an integer index as an
|
|
366
|
+
* `int` token). `->` binds tighter than every operator, so no parens are
|
|
367
|
+
* needed around the path in a comparison. Segments are bound (never inlined)
|
|
368
|
+
* to keep {@link materializePowql}'s `$N`-scan invariant intact: a segment
|
|
369
|
+
* that literally contained `$1` would otherwise be rewritten. Shared by the
|
|
370
|
+
* F1 where-filter path and the F2 orderBy / groupBy path emitters.
|
|
371
|
+
*
|
|
372
|
+
* A digit-only STRING segment (`'0'`) binds as an `int` array index, matching
|
|
373
|
+
* the SQL engines: `JsonFilter.path` is typed `string[]`, so an array index
|
|
374
|
+
* can only be expressed as a digit string, and the SQL builder converts it the
|
|
375
|
+
* same way (`/^\d+$/ → [n]`, query/builder.ts). Without this, PowDB's typed
|
|
376
|
+
* `->` treats `'0'` as a string KEY and silently matches nothing on an array
|
|
377
|
+
* (a wrong result, not an error). Same object-key-`'0'` caveat SQL accepts: a
|
|
378
|
+
* json object whose key is literally `"0"` is addressed as an array index.
|
|
379
|
+
*/
|
|
380
|
+
jsonPathExpr(col, path, params) {
|
|
381
|
+
let expr = `.${col.name}`;
|
|
382
|
+
for (const seg of path) {
|
|
383
|
+
const bound = typeof seg === 'string' && /^\d+$/.test(seg) ? Number(seg) : seg;
|
|
384
|
+
expr += `->${this.param(bound, params)}`;
|
|
385
|
+
}
|
|
386
|
+
return expr;
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* Compile a {@link JsonFilter} on a json document column into a PowQL filter
|
|
390
|
+
* (≥ 0.12). Operators PowQL cannot express EXACTLY throw a per-operator E017
|
|
391
|
+
* (never a wrong result): containment (`contains`, and `equals` without a
|
|
392
|
+
* `path`) has no PowQL operator. The mapped shapes:
|
|
393
|
+
* - `{ path, equals: v }` → `P = $n` (typed: string→str, bool→bool,
|
|
394
|
+
* integral number→int, fractional→float; NOT stringified)
|
|
395
|
+
* - `{ path, equals: null }` → `P is null` (matches JSON null AND a missing
|
|
396
|
+
* key, a deliberate divergence from the PG driver, documented on
|
|
397
|
+
* {@link JsonFilter})
|
|
398
|
+
* - `{ path, gt|gte|lt|lte: v }` → `P > $n` … (range ops require `path`; the
|
|
399
|
+
* engine coerces int/float numerically)
|
|
400
|
+
* - `{ hasKey: k }` → `json_type(.col->$n) is not null` (top-level key test,
|
|
401
|
+
* ignoring `path`, mirroring PG `col ? key`; includes keys holding JSON
|
|
402
|
+
* null)
|
|
403
|
+
* A bare `{ path }` with no operators compiles to zero clauses (byte-parity
|
|
404
|
+
* with SQL), so a mutation whose only `where` is a bare `{ path }` is refused
|
|
405
|
+
* by the empty-where guard.
|
|
406
|
+
*/
|
|
407
|
+
buildJsonPathCondition(col, filter, params) {
|
|
408
|
+
const conds = [];
|
|
409
|
+
// Bind the path segments at most once and reuse the expression string across
|
|
410
|
+
// equals + range comparisons (they share the same `path`).
|
|
411
|
+
let pathExpr = null;
|
|
412
|
+
const pathP = () => {
|
|
413
|
+
pathExpr ??= this.jsonPathExpr(col, filter.path, params);
|
|
414
|
+
return pathExpr;
|
|
415
|
+
};
|
|
416
|
+
if (filter.contains !== undefined) {
|
|
417
|
+
throw new errors_js_1.UnsupportedFeatureError('JSON containment filters (contains)', 'PowDB', `column "${col.name}": PowQL has no JSON containment operator`);
|
|
418
|
+
}
|
|
419
|
+
if (filter.equals !== undefined) {
|
|
420
|
+
if (filter.path === undefined || filter.path.length === 0) {
|
|
421
|
+
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`);
|
|
422
|
+
}
|
|
423
|
+
conds.push(filter.equals === null ? `${pathP()} is null` : `${pathP()} = ${this.param(filter.equals, params)}`);
|
|
424
|
+
}
|
|
425
|
+
if (filter.hasKey !== undefined) {
|
|
426
|
+
// Top-level key existence, independent of `path` (mirrors PG `col ? key`).
|
|
427
|
+
conds.push(`json_type(.${col.name}->${this.param(filter.hasKey, params)}) is not null`);
|
|
428
|
+
}
|
|
429
|
+
// Range comparisons on the extracted path, in the fixed gt/gte/lt/lte order.
|
|
430
|
+
// Same validation as SQL `jsonRangeEntries`: `path` required, value a finite
|
|
431
|
+
// number or a string.
|
|
432
|
+
for (const [op, powOp] of [
|
|
433
|
+
['gt', '>'],
|
|
434
|
+
['gte', '>='],
|
|
435
|
+
['lt', '<'],
|
|
436
|
+
['lte', '<='],
|
|
437
|
+
]) {
|
|
438
|
+
const v = filter[op];
|
|
439
|
+
if (v === undefined)
|
|
440
|
+
continue;
|
|
441
|
+
if (filter.path === undefined) {
|
|
442
|
+
throw new errors_js_1.ValidationError(`[turbine] JSON range operator '${op}' on ${col.name} requires a \`path\` ` +
|
|
443
|
+
`(e.g. { path: ['meta', 'score'], ${op}: ${JSON.stringify(v)} }).`);
|
|
444
|
+
}
|
|
445
|
+
if (typeof v !== 'number' && typeof v !== 'string') {
|
|
446
|
+
throw new errors_js_1.ValidationError(`[turbine] JSON range operator '${op}' on ${col.name} requires a number or string, got ${JSON.stringify(v)}.`);
|
|
447
|
+
}
|
|
448
|
+
if (typeof v === 'number' && !Number.isFinite(v)) {
|
|
449
|
+
throw new errors_js_1.ValidationError(`[turbine] JSON range operator '${op}' on ${col.name} requires a finite number.`);
|
|
450
|
+
}
|
|
451
|
+
conds.push(`${pathP()} ${powOp} ${this.param(v, params)}`);
|
|
452
|
+
}
|
|
453
|
+
if (!conds.length)
|
|
454
|
+
return '';
|
|
455
|
+
return conds.length > 1 ? `(${conds.join(' and ')})` : conds[0];
|
|
456
|
+
}
|
|
317
457
|
/** Bind a value, lowercasing for case-insensitive comparisons. */
|
|
318
458
|
bind(value, params, insensitive) {
|
|
319
459
|
const ph = this.param(value, params);
|
|
@@ -455,7 +595,7 @@ class PowqlInterface {
|
|
|
455
595
|
const chunk = targetPks.slice(i, i + MAX_RELATION_KEYS);
|
|
456
596
|
const params = [];
|
|
457
597
|
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);
|
|
598
|
+
const { rows } = await this.exec(`${(0, powdb_js_1.quotePowqlIdent)(through.table)} filter .${targetJCol} in (${ph}) { .${sourceJCol} }`, params, timeout, 'findMany');
|
|
459
599
|
for (const r of rows) {
|
|
460
600
|
const v = r[sourceJCol];
|
|
461
601
|
if (v != null)
|
|
@@ -503,8 +643,20 @@ class PowqlInterface {
|
|
|
503
643
|
projection(cols) {
|
|
504
644
|
return `{ ${cols.map((c) => `.${c}`).join(', ')} }`;
|
|
505
645
|
}
|
|
506
|
-
/**
|
|
507
|
-
|
|
646
|
+
/**
|
|
647
|
+
* `order .c1 asc, .c2 desc` clause (empty string when no orderBy). Supports,
|
|
648
|
+
* besides a plain direction:
|
|
649
|
+
* - {@link JsonPathOrderBy} on a json column (≥ 0.12): `{ data: { path: […],
|
|
650
|
+
* type?, direction? } }` → `order .data->$n asc` (or
|
|
651
|
+
* `cast(.data->$n, "float")` for `type: 'numeric'`);
|
|
652
|
+
* - {@link OrderBySpec} `{ sort, nulls }`: `nulls: 'last'` is accepted as a
|
|
653
|
+
* no-op (PowDB is always nulls-last), `nulls: 'first'` throws E017.
|
|
654
|
+
*
|
|
655
|
+
* PowDB orders missing / JSON-null keys LAST in BOTH directions (an engine
|
|
656
|
+
* contract): for identical cross-engine results pass `nulls: 'last'`
|
|
657
|
+
* explicitly on Postgres, which defaults nulls-first for `desc`.
|
|
658
|
+
*/
|
|
659
|
+
buildOrder(orderBy, params) {
|
|
508
660
|
if (!orderBy)
|
|
509
661
|
return '';
|
|
510
662
|
const keys = Object.entries(orderBy).filter(([, dir]) => dir !== undefined);
|
|
@@ -512,32 +664,82 @@ class PowqlInterface {
|
|
|
512
664
|
return '';
|
|
513
665
|
const parts = keys.map(([field, dir]) => {
|
|
514
666
|
if (dir && typeof dir === 'object') {
|
|
667
|
+
const o = dir;
|
|
668
|
+
// JSON-path ordering on a json column.
|
|
669
|
+
if (Array.isArray(o.path)) {
|
|
670
|
+
return this.buildJsonPathOrder(field, dir, params);
|
|
671
|
+
}
|
|
672
|
+
// OrderBySpec { sort, nulls }: accept nulls-last (PowDB default), refuse
|
|
673
|
+
// nulls-first (no placement grammar). Distinct from vector/pick/_count.
|
|
674
|
+
if ('sort' in o && !('distance' in o) && !('_count' in o) && !(0, filters_js_1.isRelationPickOrderBy)(dir)) {
|
|
675
|
+
const spec = dir;
|
|
676
|
+
if (spec.nulls === 'first') {
|
|
677
|
+
throw new errors_js_1.UnsupportedFeatureError('NULLS FIRST placement', 'PowDB', `field "${field}": PowDB orders NULLs / missing keys LAST in both directions`);
|
|
678
|
+
}
|
|
679
|
+
return `${this.ref(field)} ${spec.sort === 'desc' ? 'desc' : 'asc'}`;
|
|
680
|
+
}
|
|
515
681
|
// Name the actual feature in the refusal — a pick-row ordering
|
|
516
682
|
// reported as "vector / distance ordering" sends users hunting for
|
|
517
|
-
// pgvector docs.
|
|
518
|
-
const o = dir;
|
|
683
|
+
// pgvector docs. Everything else stays E017 on PowDB.
|
|
519
684
|
const feature = (0, filters_js_1.isRelationPickOrderBy)(dir)
|
|
520
685
|
? 'relation pick-row ordering'
|
|
521
686
|
: 'distance' in o
|
|
522
687
|
? 'vector / distance ordering'
|
|
523
|
-
:
|
|
524
|
-
? '
|
|
525
|
-
: '
|
|
526
|
-
? '
|
|
527
|
-
: '
|
|
528
|
-
? 'NULLS placement / sort-spec ordering'
|
|
529
|
-
: 'object-valued ordering';
|
|
688
|
+
: '_count' in o
|
|
689
|
+
? 'relation _count ordering'
|
|
690
|
+
: 'nulls' in o
|
|
691
|
+
? 'NULLS placement / sort-spec ordering'
|
|
692
|
+
: 'object-valued ordering';
|
|
530
693
|
throw new errors_js_1.UnsupportedFeatureError(feature, 'PowDB', `field "${field}"`);
|
|
531
694
|
}
|
|
532
695
|
return `${this.ref(field)} ${dir === 'desc' ? 'desc' : 'asc'}`;
|
|
533
696
|
});
|
|
534
697
|
return ` order ${parts.join(', ')}`;
|
|
535
698
|
}
|
|
699
|
+
/** Compile one {@link JsonPathOrderBy} entry to `order .col->$n asc` (+ optional numeric cast). */
|
|
700
|
+
buildJsonPathOrder(field, spec, params) {
|
|
701
|
+
const col = this.column(field);
|
|
702
|
+
if (!(0, powdb_js_1.isJsonColumn)(col)) {
|
|
703
|
+
throw new errors_js_1.UnsupportedFeatureError('JSON-path ordering', 'PowDB', `field "${field}" is not a json column`);
|
|
704
|
+
}
|
|
705
|
+
(0, powdb_js_1.requireCapability)(this.capabilities, 'jsonDocs', 'JSON path ordering');
|
|
706
|
+
if (spec.nulls === 'first') {
|
|
707
|
+
throw new errors_js_1.UnsupportedFeatureError('NULLS FIRST placement', 'PowDB', `field "${field}": PowDB orders missing / JSON-null keys LAST in both directions`);
|
|
708
|
+
}
|
|
709
|
+
const pathExpr = this.jsonPathExpr(col, spec.path, params);
|
|
710
|
+
// `type: 'numeric'` casts a JSON STRING number for numeric ordering; native
|
|
711
|
+
// JSON numbers already order numerically without a cast.
|
|
712
|
+
const expr = spec.type === 'numeric' ? `cast(${pathExpr}, "float")` : pathExpr;
|
|
713
|
+
return `${expr} ${spec.direction === 'desc' ? 'desc' : 'asc'}`;
|
|
714
|
+
}
|
|
536
715
|
// -------------------------------------------------------------------------
|
|
537
716
|
// Execution plumbing
|
|
538
717
|
// -------------------------------------------------------------------------
|
|
539
|
-
/**
|
|
540
|
-
|
|
718
|
+
/**
|
|
719
|
+
* Run PowQL with optional timeout, emitting a query event either way. The
|
|
720
|
+
* `action` is passed PER CALL (never read from shared instance state) so the
|
|
721
|
+
* retry-eligibility and the emitted event action stay correct even when a
|
|
722
|
+
* concurrent operation runs on the same cached interface: a WRITE statement
|
|
723
|
+
* carries a write action and can therefore never be mistaken for a replayable
|
|
724
|
+
* read. Read statements pass a read-shaped action from {@link POWQL_READ_ACTIONS}.
|
|
725
|
+
*/
|
|
726
|
+
async exec(powql, params, timeout, action = 'raw') {
|
|
727
|
+
return this.execOnce(powql, params, timeout, action, false);
|
|
728
|
+
}
|
|
729
|
+
/**
|
|
730
|
+
* Execute one statement, with the opt-in single stale-frame READ replay. When
|
|
731
|
+
* `retryStaleReads` is on and a first-statement READ fails with the stale-wire
|
|
732
|
+
* {@link isStaleFramePowdbError} ConnectionError (a socket idle-gap "received
|
|
733
|
+
* unexpected frame" that the client cannot recover), the statement is retried
|
|
734
|
+
* exactly once on a fresh pooled connection (the broken one was destroyed).
|
|
735
|
+
* The replay is refused for writes (an ambiguous mutation reply is unsafe to
|
|
736
|
+
* replay) and inside a transaction (a mid-tx statement cannot move connection),
|
|
737
|
+
* so only the read-shaped actions in {@link POWQL_READ_ACTIONS}, outside a
|
|
738
|
+
* `_txScoped` interface, are eligible. `action` is a per-call argument (never
|
|
739
|
+
* `this`-state), so a concurrent op flipping instance fields cannot turn a
|
|
740
|
+
* write into a retryable read.
|
|
741
|
+
*/
|
|
742
|
+
async execOnce(powql, params, timeout, action, isRetry) {
|
|
541
743
|
const start = performance.now();
|
|
542
744
|
const run = this.pool.query(powql, params);
|
|
543
745
|
try {
|
|
@@ -547,15 +749,37 @@ class PowqlInterface {
|
|
|
547
749
|
new Promise((_, reject) => setTimeout(() => reject(new errors_js_1.TimeoutError(timeout)), timeout)),
|
|
548
750
|
])
|
|
549
751
|
: await run;
|
|
550
|
-
this.emit(powql, params, performance.now() - start, result.rowCount ?? result.rows.length);
|
|
551
|
-
|
|
752
|
+
this.emit(action, powql, params, performance.now() - start, result.rowCount ?? result.rows.length);
|
|
753
|
+
// The pool tags each result with the wire that actually served it
|
|
754
|
+
// (adaptResult → false, adaptNativeResult → true). A heterogeneous
|
|
755
|
+
// injected pool can fall back to the legacy wire per call, so read the
|
|
756
|
+
// per-result flag and only fall back to the pool-level capability when a
|
|
757
|
+
// hand-built pool (tests) omits the tag; never coerce legacy rows with
|
|
758
|
+
// the native policy just because the pool reports nativeRaw.
|
|
759
|
+
const native = result.native ?? Boolean(this.capabilities.nativeRaw);
|
|
760
|
+
return { rows: result.rows, rowCount: result.rowCount ?? result.rows.length, native };
|
|
552
761
|
}
|
|
553
762
|
catch (err) {
|
|
554
|
-
|
|
763
|
+
if (!isRetry && this.shouldRetryStaleRead(err, action)) {
|
|
764
|
+
// Transparent single replay on a fresh connection; the first (swallowed)
|
|
765
|
+
// failure is not emitted, only the retried outcome is observed.
|
|
766
|
+
return this.execOnce(powql, params, timeout, action, true);
|
|
767
|
+
}
|
|
768
|
+
this.emit(action, powql, params, performance.now() - start, 0, err);
|
|
555
769
|
throw err;
|
|
556
770
|
}
|
|
557
771
|
}
|
|
558
|
-
|
|
772
|
+
/** Is `err` a replayable stale-frame failure for THIS (per-call) read-shaped, non-tx action? */
|
|
773
|
+
shouldRetryStaleRead(err, action) {
|
|
774
|
+
if (!this.pool.retryStaleReads)
|
|
775
|
+
return false;
|
|
776
|
+
if (this.isTxScoped())
|
|
777
|
+
return false;
|
|
778
|
+
if (!POWQL_READ_ACTIONS.has(action))
|
|
779
|
+
return false;
|
|
780
|
+
return (0, powdb_js_1.isStaleFramePowdbError)(err);
|
|
781
|
+
}
|
|
782
|
+
emit(action, sql, params, duration, rows, error) {
|
|
559
783
|
if (!this.onQuery)
|
|
560
784
|
return;
|
|
561
785
|
try {
|
|
@@ -564,7 +788,7 @@ class PowqlInterface {
|
|
|
564
788
|
params,
|
|
565
789
|
duration,
|
|
566
790
|
model: this.table,
|
|
567
|
-
action
|
|
791
|
+
action,
|
|
568
792
|
rows,
|
|
569
793
|
timestamp: new Date(),
|
|
570
794
|
error,
|
|
@@ -576,7 +800,6 @@ class PowqlInterface {
|
|
|
576
800
|
}
|
|
577
801
|
/** Run a method body through the middleware chain (mirrors QueryInterface). */
|
|
578
802
|
async withMiddleware(action, args, executor) {
|
|
579
|
-
this.currentAction = action;
|
|
580
803
|
if (this.middlewares.length === 0)
|
|
581
804
|
return executor();
|
|
582
805
|
let index = 0;
|
|
@@ -587,24 +810,30 @@ class PowqlInterface {
|
|
|
587
810
|
};
|
|
588
811
|
return next({ model: this.table, action, args: { ...args } });
|
|
589
812
|
}
|
|
590
|
-
/** Map raw rows to typed entities.
|
|
591
|
-
|
|
592
|
-
|
|
813
|
+
/** Map raw rows to typed entities. `native` is the wire that ACTUALLY served
|
|
814
|
+
* this result (threaded from {@link execOnce}, not the pool-level capability),
|
|
815
|
+
* so cells that arrived pre-typed over `queryNativeRaw` (F3) skip the legacy
|
|
816
|
+
* string coercion (a genuine str `"null"` stays `"null"` instead of collapsing
|
|
817
|
+
* to null) while a per-call legacy fallback on a native-capable pool still
|
|
818
|
+
* coerces its string cells correctly. Defaults to the pool capability for the
|
|
819
|
+
* rare caller with no per-result flag (hand-built test pools). */
|
|
820
|
+
shape(rows, native = Boolean(this.capabilities.nativeRaw)) {
|
|
821
|
+
return rows.map((r) => (0, powdb_js_1.rowToEntity)(r, this.meta, native));
|
|
593
822
|
}
|
|
594
823
|
// -------------------------------------------------------------------------
|
|
595
824
|
// Reads
|
|
596
825
|
// -------------------------------------------------------------------------
|
|
597
826
|
async findMany(args = {}) {
|
|
598
827
|
return this.withMiddleware('findMany', args, async () => {
|
|
599
|
-
const rows = await this.runFind(args);
|
|
600
|
-
const entities = this.shape(rows);
|
|
828
|
+
const { rows, native } = await this.runFind(args, 'findMany');
|
|
829
|
+
const entities = this.shape(rows, native);
|
|
601
830
|
if (args.with)
|
|
602
831
|
await this.loadRelations(entities, args.with, args.timeout);
|
|
603
832
|
return entities;
|
|
604
833
|
});
|
|
605
834
|
}
|
|
606
|
-
/** Build + run the flat findMany select; returns raw rows. */
|
|
607
|
-
async runFind(args) {
|
|
835
|
+
/** Build + run the flat findMany select; returns raw rows + the serving wire. */
|
|
836
|
+
async runFind(args, action = 'findMany') {
|
|
608
837
|
if (args.cursor) {
|
|
609
838
|
throw new errors_js_1.UnsupportedFeatureError('cursor pagination', 'PowDB', 'use limit/offset instead');
|
|
610
839
|
}
|
|
@@ -614,7 +843,7 @@ class PowqlInterface {
|
|
|
614
843
|
const cols = this.projectedColumns(args.select, args.omit);
|
|
615
844
|
const distinct = args.distinct?.length ? ' distinct' : '';
|
|
616
845
|
const filter = where ? ` filter ${where}` : '';
|
|
617
|
-
const order = this.buildOrder(args.orderBy);
|
|
846
|
+
const order = this.buildOrder(args.orderBy, params);
|
|
618
847
|
const limit = args.limit ?? args.take ?? this.defaultLimit;
|
|
619
848
|
if (limit === undefined && this.warnOnUnlimited && !this.warnedUnlimited) {
|
|
620
849
|
this.warnedUnlimited = true;
|
|
@@ -623,15 +852,15 @@ class PowqlInterface {
|
|
|
623
852
|
const limitClause = limit !== undefined ? ` limit ${this.param(limit, params)}` : '';
|
|
624
853
|
const offsetClause = args.offset ? ` offset ${this.param(args.offset, params)}` : '';
|
|
625
854
|
const powql = `${this.qt}${distinct}${filter}${order}${limitClause}${offsetClause} ${this.projection(cols)}`;
|
|
626
|
-
const { rows } = await this.exec(powql, params, args.timeout);
|
|
627
|
-
return rows;
|
|
855
|
+
const { rows, native } = await this.exec(powql, params, args.timeout, action);
|
|
856
|
+
return { rows, native };
|
|
628
857
|
}
|
|
629
858
|
async findUnique(args) {
|
|
630
859
|
return this.withMiddleware('findUnique', args, async () => {
|
|
631
|
-
const rows = await this.runFind({ ...args, limit: 1 });
|
|
860
|
+
const { rows, native } = await this.runFind({ ...args, limit: 1 }, 'findUnique');
|
|
632
861
|
if (!rows.length)
|
|
633
862
|
return null;
|
|
634
|
-
const entities = this.shape(rows);
|
|
863
|
+
const entities = this.shape(rows, native);
|
|
635
864
|
if (args.with)
|
|
636
865
|
await this.loadRelations(entities, args.with, args.timeout);
|
|
637
866
|
return entities[0];
|
|
@@ -639,10 +868,10 @@ class PowqlInterface {
|
|
|
639
868
|
}
|
|
640
869
|
async findFirst(args = {}) {
|
|
641
870
|
return this.withMiddleware('findFirst', args, async () => {
|
|
642
|
-
const rows = await this.runFind({ ...args, limit: 1 });
|
|
871
|
+
const { rows, native } = await this.runFind({ ...args, limit: 1 }, 'findFirst');
|
|
643
872
|
if (!rows.length)
|
|
644
873
|
return null;
|
|
645
|
-
const entities = this.shape(rows);
|
|
874
|
+
const entities = this.shape(rows, native);
|
|
646
875
|
if (args.with)
|
|
647
876
|
await this.loadRelations(entities, args.with, args.timeout);
|
|
648
877
|
return entities[0];
|
|
@@ -772,7 +1001,7 @@ class PowqlInterface {
|
|
|
772
1001
|
const params = [];
|
|
773
1002
|
const placeholders = chunk.map((v) => this.param(v, params)).join(', ');
|
|
774
1003
|
const powql = `${(0, powdb_js_1.quotePowqlIdent)(through.table)} filter .${sourceJCol} in (${placeholders}) { .${sourceJCol}, .${targetJCol} }`;
|
|
775
|
-
const { rows } = await this.exec(powql, params, timeout);
|
|
1004
|
+
const { rows } = await this.exec(powql, params, timeout, 'findMany');
|
|
776
1005
|
for (const row of rows) {
|
|
777
1006
|
const sv = String(row[sourceJCol]);
|
|
778
1007
|
const tv = String(row[targetJCol]);
|
|
@@ -873,8 +1102,8 @@ class PowqlInterface {
|
|
|
873
1102
|
.map((a) => `${(0, powdb_js_1.quotePowqlIdent)(a.col.name)} := ${this.writeRef(a.value, a.col, params)}`)
|
|
874
1103
|
.join(', ');
|
|
875
1104
|
// `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;
|
|
1105
|
+
const { rows, native } = await this.exec(`insert ${this.qt} { ${body} } returning`, params, args.timeout, 'create');
|
|
1106
|
+
const row = rows.length ? this.shape(rows, native)[0] : null;
|
|
878
1107
|
if (!row)
|
|
879
1108
|
throw new errors_js_1.NotFoundError({ table: this.table, where: data });
|
|
880
1109
|
return row;
|
|
@@ -891,8 +1120,8 @@ class PowqlInterface {
|
|
|
891
1120
|
return `{ ${assigns.map((a) => `${(0, powdb_js_1.quotePowqlIdent)(a.col.name)} := ${this.writeRef(a.value, a.col, params)}`).join(', ')} }`;
|
|
892
1121
|
});
|
|
893
1122
|
// 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);
|
|
1123
|
+
const { rows, native } = await this.exec(`insert ${this.qt} ${tuples.join(', ')} returning`, params, args.timeout, 'createMany');
|
|
1124
|
+
return this.shape(rows, native);
|
|
896
1125
|
});
|
|
897
1126
|
}
|
|
898
1127
|
async update(args) {
|
|
@@ -906,8 +1135,8 @@ class PowqlInterface {
|
|
|
906
1135
|
this.assertCompiledWhere(where, false, 'update');
|
|
907
1136
|
const setClause = this.buildUpdateAssignments(args.data, params);
|
|
908
1137
|
// `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;
|
|
1138
|
+
const { rows, native } = await this.exec(`${this.qt} filter ${where} update { ${setClause} } returning`, params, args.timeout, 'update');
|
|
1139
|
+
const row = rows.length ? this.shape(rows, native)[0] : null;
|
|
911
1140
|
if (!row)
|
|
912
1141
|
throw new errors_js_1.NotFoundError({ table: this.table, where: args.where });
|
|
913
1142
|
return row;
|
|
@@ -921,7 +1150,7 @@ class PowqlInterface {
|
|
|
921
1150
|
this.assertCompiledWhere(where, args.allowFullTableScan, 'updateMany');
|
|
922
1151
|
const setClause = this.buildUpdateAssignments(args.data, params);
|
|
923
1152
|
const filter = where ? ` filter ${where}` : '';
|
|
924
|
-
const { rowCount } = await this.exec(`${this.qt}${filter} update { ${setClause} }`, params, args.timeout);
|
|
1153
|
+
const { rowCount } = await this.exec(`${this.qt}${filter} update { ${setClause} }`, params, args.timeout, 'updateMany');
|
|
925
1154
|
return { count: rowCount };
|
|
926
1155
|
});
|
|
927
1156
|
}
|
|
@@ -1044,8 +1273,8 @@ class PowqlInterface {
|
|
|
1044
1273
|
const where = this.buildWhere(resolvedWhere, params);
|
|
1045
1274
|
this.assertCompiledWhere(where, false, 'delete');
|
|
1046
1275
|
// `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;
|
|
1276
|
+
const { rows, native } = await this.exec(`${this.qt} filter ${where} delete returning`, params, args.timeout, 'delete');
|
|
1277
|
+
const row = rows.length ? this.shape(rows, native)[0] : null;
|
|
1049
1278
|
if (!row)
|
|
1050
1279
|
throw new errors_js_1.NotFoundError({ table: this.table, where: args.where });
|
|
1051
1280
|
return row;
|
|
@@ -1058,7 +1287,7 @@ class PowqlInterface {
|
|
|
1058
1287
|
const where = this.buildWhere(resolvedWhere, params);
|
|
1059
1288
|
this.assertCompiledWhere(where, args.allowFullTableScan, 'deleteMany');
|
|
1060
1289
|
const filter = where ? ` filter ${where}` : '';
|
|
1061
|
-
const { rowCount } = await this.exec(`${this.qt}${filter} delete`, params, args.timeout);
|
|
1290
|
+
const { rowCount } = await this.exec(`${this.qt}${filter} delete`, params, args.timeout, 'deleteMany');
|
|
1062
1291
|
return { count: rowCount };
|
|
1063
1292
|
});
|
|
1064
1293
|
}
|
|
@@ -1080,7 +1309,7 @@ class PowqlInterface {
|
|
|
1080
1309
|
// (verified: "unexpected trailing token … 'returning'"), because it is one
|
|
1081
1310
|
// atomic insert-or-update, not two branches. So upsert alone keeps the
|
|
1082
1311
|
// 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);
|
|
1312
|
+
await this.exec(`upsert ${this.qt} on .${pkCol} { ${createBody} } on conflict { ${updateBody} }`, params, args.timeout, 'upsert');
|
|
1084
1313
|
const pkField = this.meta.reverseColumnMap[pkCol] ?? pkCol;
|
|
1085
1314
|
const row = await this.reselectByPk(createData[pkField], args.timeout);
|
|
1086
1315
|
if (!row)
|
|
@@ -1125,7 +1354,7 @@ class PowqlInterface {
|
|
|
1125
1354
|
const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
|
|
1126
1355
|
const where = this.buildWhere(resolvedWhere, params);
|
|
1127
1356
|
const filter = where ? ` filter ${where}` : '';
|
|
1128
|
-
const { rows } = await this.exec(`count(${this.qt}${filter})`, params, args.timeout);
|
|
1357
|
+
const { rows } = await this.exec(`count(${this.qt}${filter})`, params, args.timeout, 'count');
|
|
1129
1358
|
return Number((rows[0]?.value ?? rows[0]?.count ?? 0));
|
|
1130
1359
|
});
|
|
1131
1360
|
}
|
|
@@ -1139,7 +1368,7 @@ class PowqlInterface {
|
|
|
1139
1368
|
const filter = where ? ` filter ${where}` : '';
|
|
1140
1369
|
const scalar = async (expr) => {
|
|
1141
1370
|
const params = [...filterParams];
|
|
1142
|
-
const { rows } = await this.exec(expr, params, args.timeout);
|
|
1371
|
+
const { rows } = await this.exec(expr, params, args.timeout, 'aggregate');
|
|
1143
1372
|
const v = rows[0]?.value;
|
|
1144
1373
|
return v == null || v === 'null' ? null : Number(v);
|
|
1145
1374
|
};
|
|
@@ -1171,90 +1400,196 @@ class PowqlInterface {
|
|
|
1171
1400
|
}
|
|
1172
1401
|
async groupBy(args) {
|
|
1173
1402
|
return this.withMiddleware('groupBy', args, async () => {
|
|
1174
|
-
//
|
|
1175
|
-
// group keys / aggregate targets) have no PowQL equivalent: refuse
|
|
1176
|
-
// clearly instead of emitting broken PowQL.
|
|
1403
|
+
// DISTINCT ON has no PowQL equivalent (no DISTINCT ON row source).
|
|
1177
1404
|
if (args.distinctOn) {
|
|
1178
1405
|
throw new errors_js_1.UnsupportedFeatureError('groupBy distinctOn row source', 'PowDB');
|
|
1179
1406
|
}
|
|
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
|
-
}
|
|
1407
|
+
// JSON-path group keys / aggregate targets (≥ 0.12) are gated once here.
|
|
1408
|
+
const usesJson = args.by.some((e) => typeof e !== 'string') ||
|
|
1409
|
+
['_sum', '_avg', '_min', '_max'].some((fn) => {
|
|
1410
|
+
const spec = args[fn];
|
|
1411
|
+
return spec !== undefined && Object.values(spec).some((v) => v != null && typeof v === 'object');
|
|
1412
|
+
});
|
|
1413
|
+
if (usesJson) {
|
|
1414
|
+
(0, powdb_js_1.requireCapability)(this.capabilities, 'jsonDocs', 'JSON-path groupBy keys / aggregate targets');
|
|
1194
1415
|
}
|
|
1416
|
+
// `emitNative` decides whether the query GENERATION needs the legacy-wire
|
|
1417
|
+
// `json_type` discriminator (pool-level capability). The DECODE side reads
|
|
1418
|
+
// the wire that ACTUALLY served the result (`resultNative`, from exec), so
|
|
1419
|
+
// a per-call legacy fallback on a native-capable pool still decodes right.
|
|
1420
|
+
const emitNative = Boolean(this.capabilities.nativeRaw);
|
|
1195
1421
|
const params = [];
|
|
1196
1422
|
const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
|
|
1197
1423
|
const where = this.buildWhere(resolvedWhere, params);
|
|
1198
1424
|
const filter = where ? ` filter ${where}` : '';
|
|
1199
|
-
|
|
1200
|
-
//
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
const
|
|
1204
|
-
|
|
1205
|
-
|
|
1425
|
+
// Result-key namespace, mirroring the SQL builder's `claimResultKey`
|
|
1426
|
+
// (query/builder.ts): a group-key / aggregate output-name collision (with
|
|
1427
|
+
// `_count`, another key, or an aggregate output) throws E003.
|
|
1428
|
+
const usedKeys = new Set();
|
|
1429
|
+
const claim = (key, what) => {
|
|
1430
|
+
if (key === '_count' || usedKeys.has(key)) {
|
|
1431
|
+
throw new errors_js_1.ValidationError(`[turbine] groupBy output name "${key}" (${what}) collides with another output column on table ` +
|
|
1432
|
+
`"${this.table}": set an explicit \`alias\` (or rename the aggregate key) to disambiguate.`);
|
|
1433
|
+
}
|
|
1434
|
+
usedKeys.add(key);
|
|
1435
|
+
};
|
|
1436
|
+
const groupExprs = [];
|
|
1437
|
+
const proj = [];
|
|
1438
|
+
const byOrderExprs = new Map();
|
|
1439
|
+
const byReaders = [];
|
|
1440
|
+
let gkN = 0;
|
|
1441
|
+
let gtN = 0;
|
|
1442
|
+
for (const entry of args.by) {
|
|
1443
|
+
if (typeof entry === 'string') {
|
|
1444
|
+
const col = this.column(entry);
|
|
1445
|
+
claim(entry, `column "${col.name}"`);
|
|
1446
|
+
if (col.name !== entry)
|
|
1447
|
+
claim(col.name, `column "${col.name}"`);
|
|
1448
|
+
groupExprs.push(`.${col.name}`);
|
|
1449
|
+
proj.push(`.${col.name}`);
|
|
1450
|
+
byOrderExprs.set(entry, `.${col.name}`);
|
|
1451
|
+
byReaders.push({ kind: 'plain', resultKey: entry, rowKey: col.name, col });
|
|
1452
|
+
}
|
|
1453
|
+
else {
|
|
1454
|
+
const col = this.column(entry.field);
|
|
1455
|
+
if (!(0, powdb_js_1.isJsonColumn)(col)) {
|
|
1456
|
+
throw new errors_js_1.ValidationError(`[turbine] groupBy JSON group key on "${entry.field}" (table "${this.table}") requires a json column.`);
|
|
1457
|
+
}
|
|
1458
|
+
this.assertJsonPath('group key', entry.field, entry.path);
|
|
1459
|
+
const pathExpr = this.jsonPathExpr(col, entry.path, params);
|
|
1460
|
+
const alias = entry.alias ?? String(entry.path[entry.path.length - 1]);
|
|
1461
|
+
claim(alias, `JSON path on "${entry.field}"`);
|
|
1462
|
+
const gkAlias = `gk_${gkN++}`;
|
|
1463
|
+
groupExprs.push(pathExpr);
|
|
1464
|
+
proj.push(`${gkAlias}: ${pathExpr}`);
|
|
1465
|
+
byOrderExprs.set(alias, `.${gkAlias}`);
|
|
1466
|
+
let discrim;
|
|
1467
|
+
if (!emitNative) {
|
|
1468
|
+
// Legacy wire renders a missing value, JSON null, AND the string
|
|
1469
|
+
// "null" all as the cell "null". `min(json_type(path))` over the
|
|
1470
|
+
// group is "string" ONLY for the string-"null" group and "null"
|
|
1471
|
+
// otherwise (a bare `json_type` projection is not group-correlated).
|
|
1472
|
+
discrim = `gt_${gtN++}`;
|
|
1473
|
+
proj.push(`${discrim}: min(json_type(${pathExpr}))`);
|
|
1474
|
+
}
|
|
1475
|
+
byReaders.push({ kind: 'json', resultKey: alias, rowKey: gkAlias, discrim });
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
// Aggregates: `agg_N` internal aliases (PowQL rejects reserved-word
|
|
1479
|
+
// aliases like `count:`). `aggInner` lets HAVING re-emit the exact inner
|
|
1480
|
+
// expression by user key; `aggOrderExprs` lets orderBy reference the alias.
|
|
1481
|
+
const aggReaders = [];
|
|
1482
|
+
const aggOrderExprs = new Map();
|
|
1483
|
+
const aggInner = new Map();
|
|
1484
|
+
let aggN = 0;
|
|
1485
|
+
// Parity with the SQL builder (query/builder.ts): `_count` is selected by
|
|
1486
|
+
// DEFAULT unless the caller explicitly opts out with `_count: false`, so
|
|
1487
|
+
// every groupBy row carries `_count` and `orderBy: { _count }` works
|
|
1488
|
+
// without requesting it (the alias is seeded into `aggOrderExprs`).
|
|
1489
|
+
const countSelected = args._count === true || args._count === undefined;
|
|
1490
|
+
if (countSelected) {
|
|
1491
|
+
const alias = `agg_${aggN++}`;
|
|
1492
|
+
proj.push(`${alias}: count(*)`);
|
|
1493
|
+
aggReaders.push({ alias, outKey: '_count', numeric: true });
|
|
1494
|
+
aggOrderExprs.set('_count', `.${alias}`);
|
|
1206
1495
|
}
|
|
1207
1496
|
for (const fn of ['_sum', '_avg', '_min', '_max']) {
|
|
1208
1497
|
const spec = args[fn];
|
|
1209
1498
|
if (!spec)
|
|
1210
1499
|
continue;
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1500
|
+
const powfn = fn.slice(1); // sum/avg/min/max
|
|
1501
|
+
for (const [key, target] of Object.entries(spec)) {
|
|
1502
|
+
if (!target)
|
|
1503
|
+
continue;
|
|
1504
|
+
const alias = `agg_${aggN++}`;
|
|
1505
|
+
if (target === true) {
|
|
1506
|
+
const col = this.column(key);
|
|
1507
|
+
claim(`${fn}_${col.name}`, `${fn} of column "${col.name}"`);
|
|
1508
|
+
const inner = `.${col.name}`;
|
|
1509
|
+
proj.push(`${alias}: ${powfn}(${inner})`);
|
|
1510
|
+
aggReaders.push({ alias, outKey: `${fn}:${key}`, numeric: true });
|
|
1511
|
+
aggOrderExprs.set(`${fn}:${key}`, `.${alias}`);
|
|
1512
|
+
aggInner.set(key, inner);
|
|
1513
|
+
}
|
|
1514
|
+
else {
|
|
1515
|
+
const col = this.column(target.field);
|
|
1516
|
+
if (!(0, powdb_js_1.isJsonColumn)(col)) {
|
|
1517
|
+
throw new errors_js_1.ValidationError(`[turbine] groupBy ${fn} target "${key}" on "${target.field}" (table "${this.table}") requires a json column.`);
|
|
1518
|
+
}
|
|
1519
|
+
this.assertJsonPath(`${fn} target "${key}"`, target.field, target.path);
|
|
1520
|
+
const alwaysNumeric = fn === '_sum' || fn === '_avg';
|
|
1521
|
+
if (alwaysNumeric && target.type === 'text') {
|
|
1522
|
+
throw new errors_js_1.ValidationError(`[turbine] groupBy ${fn} target "${key}" on table "${this.table}": ` +
|
|
1523
|
+
`${fn} over a JSON path is always numeric: remove \`type: 'text'\`.`);
|
|
1524
|
+
}
|
|
1525
|
+
const numeric = alwaysNumeric || target.type === 'numeric';
|
|
1526
|
+
claim(`${fn}_${key}`, `${fn} JSON target "${key}"`);
|
|
1527
|
+
const pathExpr = this.jsonPathExpr(col, target.path, params);
|
|
1528
|
+
const inner = numeric ? `cast(${pathExpr}, "float")` : pathExpr;
|
|
1529
|
+
proj.push(`${alias}: ${powfn}(${inner})`);
|
|
1530
|
+
aggReaders.push({ alias, outKey: `${fn}:${key}`, numeric });
|
|
1531
|
+
aggOrderExprs.set(`${fn}:${key}`, `.${alias}`);
|
|
1532
|
+
aggInner.set(key, inner);
|
|
1227
1533
|
}
|
|
1228
1534
|
}
|
|
1229
1535
|
}
|
|
1230
|
-
const
|
|
1231
|
-
const
|
|
1232
|
-
const
|
|
1233
|
-
|
|
1536
|
+
const having = this.buildHaving(args.having, params, aggInner);
|
|
1537
|
+
const order = this.buildGroupOrder(args.orderBy, byOrderExprs, aggOrderExprs);
|
|
1538
|
+
const powql = `${this.qt}${filter} group ${groupExprs.join(', ')}${having}${order} { ${proj.join(', ')} }`;
|
|
1539
|
+
const { rows, native: resultNative } = await this.exec(powql, params, args.timeout, 'groupBy');
|
|
1540
|
+
// Reshape: group keys → user fields (coerced / null-disambiguated),
|
|
1541
|
+
// aggregates → nested `{ _sum: { field } }`; discriminators are stripped.
|
|
1542
|
+
// Group-key cells go through the SAME coercion policy `rowToEntity` uses,
|
|
1543
|
+
// by the wire that actually served this result, so a native-wire int cell
|
|
1544
|
+
// (bigint) or datetime cell (micros) never leaks into the result: it
|
|
1545
|
+
// becomes the same number / Date / PG-text-parity string an embedded /
|
|
1546
|
+
// legacy / SQL groupBy returns for the identical query.
|
|
1234
1547
|
return rows.map((raw) => {
|
|
1235
1548
|
const out = {};
|
|
1236
|
-
for (const
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1549
|
+
for (const r of byReaders) {
|
|
1550
|
+
if (r.kind === 'plain') {
|
|
1551
|
+
const cell = raw[r.rowKey];
|
|
1552
|
+
out[r.resultKey] = resultNative
|
|
1553
|
+
? (0, powdb_js_1.coerceNativeValue)(cell, r.col)
|
|
1554
|
+
: typeof cell === 'string'
|
|
1555
|
+
? coerceScalar(cell, r.col.tsType)
|
|
1556
|
+
: cell;
|
|
1557
|
+
}
|
|
1558
|
+
else {
|
|
1559
|
+
out[r.resultKey] = decodeGroupKeyCell(raw[r.rowKey], r.discrim ? raw[r.discrim] : undefined, resultNative);
|
|
1560
|
+
}
|
|
1240
1561
|
}
|
|
1241
|
-
for (const a of
|
|
1242
|
-
const
|
|
1243
|
-
const
|
|
1562
|
+
for (const a of aggReaders) {
|
|
1563
|
+
const cell = raw[a.alias];
|
|
1564
|
+
const v = cell == null || cell === 'null' ? null : a.numeric ? Number(cell) : cell;
|
|
1244
1565
|
if (a.outKey === '_count')
|
|
1245
|
-
out._count =
|
|
1566
|
+
out._count = v ?? 0;
|
|
1246
1567
|
else {
|
|
1247
1568
|
const [bucket, field] = a.outKey.split(':');
|
|
1248
1569
|
out[bucket] ??= {};
|
|
1249
|
-
out[bucket][field] =
|
|
1570
|
+
out[bucket][field] = v;
|
|
1250
1571
|
}
|
|
1251
1572
|
}
|
|
1252
1573
|
return out;
|
|
1253
1574
|
});
|
|
1254
1575
|
});
|
|
1255
1576
|
}
|
|
1256
|
-
/**
|
|
1257
|
-
|
|
1577
|
+
/** Validate a JSON-path target (group key / aggregate target): non-empty array of keys/indexes. */
|
|
1578
|
+
assertJsonPath(context, field, path) {
|
|
1579
|
+
if (!Array.isArray(path) ||
|
|
1580
|
+
path.length === 0 ||
|
|
1581
|
+
path.some((el) => typeof el !== 'string' && !(typeof el === 'number' && Number.isFinite(el)))) {
|
|
1582
|
+
throw new errors_js_1.ValidationError(`[turbine] groupBy ${context} on "${field}" (table "${this.table}") requires a non-empty \`path\` ` +
|
|
1583
|
+
`array of keys/indexes (e.g. { field: '${field}', path: ['category'] }).`);
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
/**
|
|
1587
|
+
* `having <expr>` over group aggregates. `_count` compares `count(*)` (parity
|
|
1588
|
+
* with the projection); a per-field aggregate re-emits its inner expression
|
|
1589
|
+
* (from `aggInner` when the field is a requested aggregate, so a JSON-path
|
|
1590
|
+
* aggregate reuses its bound placeholders, else `.field` for a plain column).
|
|
1591
|
+
*/
|
|
1592
|
+
buildHaving(having, params, aggInner) {
|
|
1258
1593
|
if (!having)
|
|
1259
1594
|
return '';
|
|
1260
1595
|
const conds = [];
|
|
@@ -1272,18 +1607,88 @@ class PowqlInterface {
|
|
|
1272
1607
|
if (spec == null)
|
|
1273
1608
|
continue;
|
|
1274
1609
|
if (key === '_count') {
|
|
1275
|
-
conds.push(cmp(
|
|
1610
|
+
conds.push(cmp('count(*)', spec));
|
|
1276
1611
|
}
|
|
1277
1612
|
else {
|
|
1278
1613
|
for (const [fn, filter] of Object.entries(spec)) {
|
|
1279
1614
|
if (filter == null)
|
|
1280
1615
|
continue;
|
|
1281
|
-
|
|
1616
|
+
const inner = aggInner.get(key) ?? this.ref(key);
|
|
1617
|
+
conds.push(cmp(`${fn.slice(1)}(${inner})`, filter));
|
|
1282
1618
|
}
|
|
1283
1619
|
}
|
|
1284
1620
|
}
|
|
1285
1621
|
return conds.length ? ` having ${conds.join(' and ')}` : '';
|
|
1286
1622
|
}
|
|
1623
|
+
/**
|
|
1624
|
+
* Compile a groupBy `orderBy` into a PowQL `order` body over the group RESULT
|
|
1625
|
+
* columns (by-fields, JSON group-key aliases, and requested aggregates). PowQL
|
|
1626
|
+
* cannot re-emit an aggregate EXPRESSION in `order` (engine error), but CAN
|
|
1627
|
+
* order by a projection alias on a grouped query (probed), so each key maps to
|
|
1628
|
+
* its projected alias (`.agg_N` / `.gk_N` / `.col`). Semantics and error
|
|
1629
|
+
* surface mirror the SQL `buildGroupByOrderBy` (0.32.2 R3-1): an aggregate not
|
|
1630
|
+
* requested in this call, or an unknown by-key, throws E003 listing the valid
|
|
1631
|
+
* keys. `nulls: 'first'` stays E017 (PowDB has no NULLS placement grammar).
|
|
1632
|
+
*/
|
|
1633
|
+
buildGroupOrder(orderBy, byOrderExprs, aggOrderExprs) {
|
|
1634
|
+
if (!orderBy)
|
|
1635
|
+
return '';
|
|
1636
|
+
const aggBlocks = new Set(['_count', '_sum', '_avg', '_min', '_max']);
|
|
1637
|
+
const validKeys = () => {
|
|
1638
|
+
const keys = [...byOrderExprs.keys()];
|
|
1639
|
+
for (const k of aggOrderExprs.keys())
|
|
1640
|
+
keys.push(k.includes(':') ? k.replace(':', '.') : k);
|
|
1641
|
+
return keys.join(', ') || '(none)';
|
|
1642
|
+
};
|
|
1643
|
+
const parts = [];
|
|
1644
|
+
for (const [key, value] of Object.entries(orderBy)) {
|
|
1645
|
+
if (value === undefined)
|
|
1646
|
+
continue;
|
|
1647
|
+
if (aggBlocks.has(key)) {
|
|
1648
|
+
if (key === '_count') {
|
|
1649
|
+
const expr = aggOrderExprs.get('_count');
|
|
1650
|
+
if (!expr) {
|
|
1651
|
+
throw new errors_js_1.ValidationError(`[turbine] Cannot order groupBy by "_count" on table "${this.table}": _count is not selected. ` +
|
|
1652
|
+
`Orderable keys: ${validKeys()}.`);
|
|
1653
|
+
}
|
|
1654
|
+
parts.push(`${expr} ${this.groupOrderDir(value, '_count')}`);
|
|
1655
|
+
continue;
|
|
1656
|
+
}
|
|
1657
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
1658
|
+
throw new errors_js_1.ValidationError(`[turbine] Invalid groupBy orderBy for "${key}" on table "${this.table}": ` +
|
|
1659
|
+
`expected a field map like { ${key}: { amount: 'desc' } }.`);
|
|
1660
|
+
}
|
|
1661
|
+
for (const [field, dirSpec] of Object.entries(value)) {
|
|
1662
|
+
if (dirSpec === undefined)
|
|
1663
|
+
continue;
|
|
1664
|
+
const expr = aggOrderExprs.get(`${key}:${field}`);
|
|
1665
|
+
if (!expr) {
|
|
1666
|
+
throw new errors_js_1.ValidationError(`[turbine] Cannot order groupBy by "${key}.${field}" on table "${this.table}": ` +
|
|
1667
|
+
`that aggregate is not requested in this call. Orderable keys: ${validKeys()}.`);
|
|
1668
|
+
}
|
|
1669
|
+
parts.push(`${expr} ${this.groupOrderDir(dirSpec, `${key}.${field}`)}`);
|
|
1670
|
+
}
|
|
1671
|
+
continue;
|
|
1672
|
+
}
|
|
1673
|
+
const expr = byOrderExprs.get(key);
|
|
1674
|
+
if (!expr) {
|
|
1675
|
+
throw new errors_js_1.ValidationError(`[turbine] Unknown field "${key}" in groupBy orderBy on table "${this.table}". Orderable keys: ${validKeys()}.`);
|
|
1676
|
+
}
|
|
1677
|
+
parts.push(`${expr} ${this.groupOrderDir(value, key)}`);
|
|
1678
|
+
}
|
|
1679
|
+
return parts.length ? ` order ${parts.join(', ')}` : '';
|
|
1680
|
+
}
|
|
1681
|
+
/** Resolve a groupBy order direction, refusing `nulls: 'first'` (E017); `nulls: 'last'` is a no-op. */
|
|
1682
|
+
groupOrderDir(value, keyForMsg) {
|
|
1683
|
+
if (value !== null && typeof value === 'object') {
|
|
1684
|
+
const spec = value;
|
|
1685
|
+
if (spec.nulls === 'first') {
|
|
1686
|
+
throw new errors_js_1.UnsupportedFeatureError('NULLS FIRST placement', 'PowDB', `groupBy orderBy "${keyForMsg}": PowDB orders NULLs / missing keys LAST in both directions`);
|
|
1687
|
+
}
|
|
1688
|
+
return spec.sort === 'desc' ? 'desc' : 'asc';
|
|
1689
|
+
}
|
|
1690
|
+
return value === 'desc' ? 'desc' : 'asc';
|
|
1691
|
+
}
|
|
1287
1692
|
// -------------------------------------------------------------------------
|
|
1288
1693
|
// Streaming / unsupported
|
|
1289
1694
|
// -------------------------------------------------------------------------
|
|
@@ -1297,12 +1702,12 @@ class PowqlInterface {
|
|
|
1297
1702
|
/** Reselect a single row by its single-column primary key value. */
|
|
1298
1703
|
async reselectByPk(pkValue, timeout) {
|
|
1299
1704
|
const pkField = this.meta.reverseColumnMap[this.meta.primaryKey[0]] ?? this.meta.primaryKey[0];
|
|
1300
|
-
const rows = await this.runFind({
|
|
1705
|
+
const { rows, native } = await this.runFind({
|
|
1301
1706
|
where: { [pkField]: pkValue },
|
|
1302
1707
|
limit: 1,
|
|
1303
1708
|
timeout,
|
|
1304
1709
|
});
|
|
1305
|
-
return rows.length ? this.shape(rows)[0] : null;
|
|
1710
|
+
return rows.length ? this.shape(rows, native)[0] : null;
|
|
1306
1711
|
}
|
|
1307
1712
|
/**
|
|
1308
1713
|
* Empty-where guard — blocks accidental whole-table writes. Mirrors the SQL
|
|
@@ -1338,3 +1743,53 @@ function coerceScalar(raw, tsType) {
|
|
|
1338
1743
|
return new Date(Number(raw) / 1000);
|
|
1339
1744
|
return raw;
|
|
1340
1745
|
}
|
|
1746
|
+
/**
|
|
1747
|
+
* Decode a JSON group-key cell, resolving the legacy-wire `null` ambiguity AND
|
|
1748
|
+
* normalizing the native typed wire to the SAME PG-`#>>`-text-parity shape.
|
|
1749
|
+
*
|
|
1750
|
+
* On the native typed wire (`native`) a cell arrives pre-typed (a JSON int as a
|
|
1751
|
+
* `bigint`, a bool as `boolean`, an unset value as `null`). Returned as-is that
|
|
1752
|
+
* would diverge from every other transport: the embedded / legacy / SQL wire
|
|
1753
|
+
* all yield the extracted TEXT (`'7'`, `'true'`), and a raw `bigint` even throws
|
|
1754
|
+
* on `JSON.stringify`. So a native scalar cell is rendered to its text form
|
|
1755
|
+
* ({@link nativeJsonKeyText}); `null`/`empty` stays `null`, and a genuine string
|
|
1756
|
+
* `"null"` stays the string (the wart the native wire was adopted to fix).
|
|
1757
|
+
*
|
|
1758
|
+
* On the legacy string wire a missing value, JSON null, AND the string `"null"`
|
|
1759
|
+
* all render the cell `"null"`; the group's `min(json_type(…))` discriminator is
|
|
1760
|
+
* `"string"` ONLY for the string-`"null"` group, so the cell is the string
|
|
1761
|
+
* `"null"` iff the discriminator is `"string"`, else `null`. Other cell values
|
|
1762
|
+
* pass through as the extracted string.
|
|
1763
|
+
*/
|
|
1764
|
+
function decodeGroupKeyCell(cell, discrim, native) {
|
|
1765
|
+
if (native)
|
|
1766
|
+
return nativeJsonKeyText(cell);
|
|
1767
|
+
if (cell == null)
|
|
1768
|
+
return null;
|
|
1769
|
+
if (cell === 'null')
|
|
1770
|
+
return discrim === 'string' ? 'null' : null;
|
|
1771
|
+
return cell;
|
|
1772
|
+
}
|
|
1773
|
+
/**
|
|
1774
|
+
* Render a native-wire JSON group-key cell to the extracted-text shape the other
|
|
1775
|
+
* transports return (PG `#>>` / embedded legacy / SQL all give text keys). Keeps
|
|
1776
|
+
* `null` as `null`; a scalar (`bigint`/`number`/`boolean`/`string`) becomes its
|
|
1777
|
+
* string form; an object/array json sub-document is stringified (best-effort
|
|
1778
|
+
* parity: canonical byte-for-byte matching is not guaranteed for nested docs).
|
|
1779
|
+
*/
|
|
1780
|
+
function nativeJsonKeyText(cell) {
|
|
1781
|
+
if (cell === null || cell === undefined)
|
|
1782
|
+
return null;
|
|
1783
|
+
if (typeof cell === 'bigint')
|
|
1784
|
+
return cell.toString();
|
|
1785
|
+
if (typeof cell === 'number' || typeof cell === 'boolean')
|
|
1786
|
+
return String(cell);
|
|
1787
|
+
if (typeof cell === 'string')
|
|
1788
|
+
return cell;
|
|
1789
|
+
try {
|
|
1790
|
+
return JSON.stringify(cell);
|
|
1791
|
+
}
|
|
1792
|
+
catch {
|
|
1793
|
+
return String(cell);
|
|
1794
|
+
}
|
|
1795
|
+
}
|