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/powql.js
CHANGED
|
@@ -37,8 +37,8 @@
|
|
|
37
37
|
import { randomUUID } from 'node:crypto';
|
|
38
38
|
import { NotFoundError, TimeoutError, UnsupportedFeatureError, ValidationError } from './errors.js';
|
|
39
39
|
import { executeNestedCreate, executeNestedUpdate, hasRelationFields, } from './nested-write.js';
|
|
40
|
-
import { PowdbFloatParam, powqlColumnType, quotePowqlIdent, rowToEntity } from './powdb.js';
|
|
41
|
-
import { isRelationPickOrderBy } from './query/filters.js';
|
|
40
|
+
import { ALL_POWDB_CAPABILITIES, coerceNativeValue, isJsonColumn, isStaleFramePowdbError, PowdbFloatParam, PowdbJsonParam, powqlColumnType, quotePowqlIdent, requireCapability, rowToEntity, } from './powdb.js';
|
|
41
|
+
import { isJsonFilter, isRelationPickOrderBy } from './query/filters.js';
|
|
42
42
|
import { escapeLike } from './query/utils.js';
|
|
43
43
|
import { normalizeKeyColumns, snakeToCamel, } from './schema.js';
|
|
44
44
|
/**
|
|
@@ -48,6 +48,21 @@ import { normalizeKeyColumns, snakeToCamel, } from './schema.js';
|
|
|
48
48
|
* before grouping. Mirrors the chunking the parity matrix documents.
|
|
49
49
|
*/
|
|
50
50
|
const MAX_RELATION_KEYS = 1000;
|
|
51
|
+
/**
|
|
52
|
+
* Read-shaped actions whose statement may be transparently replayed once on a
|
|
53
|
+
* stale wire frame when `retryStaleReads` is enabled (see
|
|
54
|
+
* {@link PowqlInterface.execOnce}). Writes are deliberately absent: replaying a
|
|
55
|
+
* mutation after an ambiguous reply can double-execute, matching the client's
|
|
56
|
+
* own native-path policy.
|
|
57
|
+
*/
|
|
58
|
+
const POWQL_READ_ACTIONS = new Set([
|
|
59
|
+
'findMany',
|
|
60
|
+
'findUnique',
|
|
61
|
+
'findFirst',
|
|
62
|
+
'count',
|
|
63
|
+
'aggregate',
|
|
64
|
+
'groupBy',
|
|
65
|
+
]);
|
|
51
66
|
/** Operator keys recognised inside a `WhereOperator` object. */
|
|
52
67
|
const OPERATOR_KEYS = new Set([
|
|
53
68
|
'equals',
|
|
@@ -93,7 +108,6 @@ export class PowqlInterface {
|
|
|
93
108
|
defaultLimit;
|
|
94
109
|
warnOnUnlimited;
|
|
95
110
|
onQuery;
|
|
96
|
-
currentAction = 'raw';
|
|
97
111
|
warnedUnlimited = false;
|
|
98
112
|
constructor(pool, table, schema, middlewares = [], options = {}) {
|
|
99
113
|
this.pool = pool;
|
|
@@ -144,7 +158,17 @@ export class PowqlInterface {
|
|
|
144
158
|
* {@link toPowdbParam}, so the wire param is unchanged.
|
|
145
159
|
*/
|
|
146
160
|
param(value, params, col) {
|
|
147
|
-
|
|
161
|
+
let tagged = value;
|
|
162
|
+
if (col && typeof value === 'number' && this.isFloatCol(col)) {
|
|
163
|
+
// Float column: force a float-form literal even for an integer value.
|
|
164
|
+
tagged = new PowdbFloatParam(value);
|
|
165
|
+
}
|
|
166
|
+
else if (col && value !== null && typeof value === 'object' && !(value instanceof Date) && isJsonColumn(col)) {
|
|
167
|
+
// json document column: a JS object/array is serialized to canonical JSON
|
|
168
|
+
// text and stored as a json document (a JS string passes through raw, same
|
|
169
|
+
// contract as pg jsonb; `null` stays `null`).
|
|
170
|
+
tagged = new PowdbJsonParam(value);
|
|
171
|
+
}
|
|
148
172
|
params.push(tagged);
|
|
149
173
|
return `$${params.length}`;
|
|
150
174
|
}
|
|
@@ -172,6 +196,15 @@ export class PowqlInterface {
|
|
|
172
196
|
return false;
|
|
173
197
|
}
|
|
174
198
|
}
|
|
199
|
+
/**
|
|
200
|
+
* The bound pool's {@link PowdbCapabilities}. Falls back to the trusted-caller
|
|
201
|
+
* default (all feature gates on, `nativeRaw` off) when a directly-constructed
|
|
202
|
+
* pool did not carry them, matching {@link PowdbPool}'s own constructor
|
|
203
|
+
* default so a hand-built test pool never crashes the version gates.
|
|
204
|
+
*/
|
|
205
|
+
get capabilities() {
|
|
206
|
+
return this.pool.capabilities ?? ALL_POWDB_CAPABILITIES;
|
|
207
|
+
}
|
|
175
208
|
/** A predicate that is always false — the empty-`in` / contradiction sentinel. */
|
|
176
209
|
alwaysFalse() {
|
|
177
210
|
const pk = this.meta.primaryKey[0] ?? this.meta.columns[0]?.name;
|
|
@@ -213,20 +246,33 @@ export class PowqlInterface {
|
|
|
213
246
|
throw new ValidationError(`[turbine] internal: relation filter "${key}" reached buildWhere unresolved (missing resolveRelationFilters()).`);
|
|
214
247
|
}
|
|
215
248
|
else {
|
|
216
|
-
|
|
249
|
+
// A JsonFilter (or a bare `{ path }`) can compile to zero clauses; skip
|
|
250
|
+
// empty results so buildWhere never emits a dangling ` and `.
|
|
251
|
+
const cond = this.buildFieldCondition(key, value, params);
|
|
252
|
+
if (cond)
|
|
253
|
+
parts.push(cond);
|
|
217
254
|
}
|
|
218
255
|
}
|
|
219
256
|
return parts.join(' and ');
|
|
220
257
|
}
|
|
221
258
|
/** Build a single `field: value | operator` condition. */
|
|
222
259
|
buildFieldCondition(field, value, params) {
|
|
260
|
+
const colMeta = this.column(field);
|
|
223
261
|
const ref = this.ref(field);
|
|
224
262
|
if (value === null)
|
|
225
263
|
return `${ref} is null`;
|
|
226
264
|
if (value instanceof Date || typeof value !== 'object') {
|
|
227
|
-
return `${ref} = ${this.param(value, params)}`;
|
|
265
|
+
return `${ref} = ${this.param(value, params, colMeta)}`;
|
|
228
266
|
}
|
|
229
267
|
const op = value;
|
|
268
|
+
// JSON path / key filters on a json document column compile to PowQL `->`
|
|
269
|
+
// path filters (≥ 0.12). `isJsonFilter` matches `path`/`equals`/`contains`/
|
|
270
|
+
// `hasKey`; on a NON-json column those fall through to the scalar operator
|
|
271
|
+
// path below (e.g. `equals` stays a plain equality), exactly like SQL.
|
|
272
|
+
if (isJsonColumn(colMeta) && isJsonFilter(value)) {
|
|
273
|
+
requireCapability(this.capabilities, 'jsonDocs', 'JSON path filters');
|
|
274
|
+
return this.buildJsonPathCondition(colMeta, value, params);
|
|
275
|
+
}
|
|
230
276
|
rejectUnsupportedFilter(op, field);
|
|
231
277
|
if (!Object.keys(op).some((k) => OPERATOR_KEYS.has(k))) {
|
|
232
278
|
// A bare object that is not an operator set — equality by value.
|
|
@@ -278,6 +324,100 @@ export class PowqlInterface {
|
|
|
278
324
|
}
|
|
279
325
|
return conds.length > 1 ? `(${conds.join(' and ')})` : (conds[0] ?? this.alwaysFalse());
|
|
280
326
|
}
|
|
327
|
+
/**
|
|
328
|
+
* PowQL JSON path expression `.col->$a->$b…`, binding EVERY path segment as a
|
|
329
|
+
* positional param (a string segment as a `str` token, an integer index as an
|
|
330
|
+
* `int` token). `->` binds tighter than every operator, so no parens are
|
|
331
|
+
* needed around the path in a comparison. Segments are bound (never inlined)
|
|
332
|
+
* to keep {@link materializePowql}'s `$N`-scan invariant intact: a segment
|
|
333
|
+
* that literally contained `$1` would otherwise be rewritten. Shared by the
|
|
334
|
+
* F1 where-filter path and the F2 orderBy / groupBy path emitters.
|
|
335
|
+
*
|
|
336
|
+
* A digit-only STRING segment (`'0'`) binds as an `int` array index, matching
|
|
337
|
+
* the SQL engines: `JsonFilter.path` is typed `string[]`, so an array index
|
|
338
|
+
* can only be expressed as a digit string, and the SQL builder converts it the
|
|
339
|
+
* same way (`/^\d+$/ → [n]`, query/builder.ts). Without this, PowDB's typed
|
|
340
|
+
* `->` treats `'0'` as a string KEY and silently matches nothing on an array
|
|
341
|
+
* (a wrong result, not an error). Same object-key-`'0'` caveat SQL accepts: a
|
|
342
|
+
* json object whose key is literally `"0"` is addressed as an array index.
|
|
343
|
+
*/
|
|
344
|
+
jsonPathExpr(col, path, params) {
|
|
345
|
+
let expr = `.${col.name}`;
|
|
346
|
+
for (const seg of path) {
|
|
347
|
+
const bound = typeof seg === 'string' && /^\d+$/.test(seg) ? Number(seg) : seg;
|
|
348
|
+
expr += `->${this.param(bound, params)}`;
|
|
349
|
+
}
|
|
350
|
+
return expr;
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* Compile a {@link JsonFilter} on a json document column into a PowQL filter
|
|
354
|
+
* (≥ 0.12). Operators PowQL cannot express EXACTLY throw a per-operator E017
|
|
355
|
+
* (never a wrong result): containment (`contains`, and `equals` without a
|
|
356
|
+
* `path`) has no PowQL operator. The mapped shapes:
|
|
357
|
+
* - `{ path, equals: v }` → `P = $n` (typed: string→str, bool→bool,
|
|
358
|
+
* integral number→int, fractional→float; NOT stringified)
|
|
359
|
+
* - `{ path, equals: null }` → `P is null` (matches JSON null AND a missing
|
|
360
|
+
* key, a deliberate divergence from the PG driver, documented on
|
|
361
|
+
* {@link JsonFilter})
|
|
362
|
+
* - `{ path, gt|gte|lt|lte: v }` → `P > $n` … (range ops require `path`; the
|
|
363
|
+
* engine coerces int/float numerically)
|
|
364
|
+
* - `{ hasKey: k }` → `json_type(.col->$n) is not null` (top-level key test,
|
|
365
|
+
* ignoring `path`, mirroring PG `col ? key`; includes keys holding JSON
|
|
366
|
+
* null)
|
|
367
|
+
* A bare `{ path }` with no operators compiles to zero clauses (byte-parity
|
|
368
|
+
* with SQL), so a mutation whose only `where` is a bare `{ path }` is refused
|
|
369
|
+
* by the empty-where guard.
|
|
370
|
+
*/
|
|
371
|
+
buildJsonPathCondition(col, filter, params) {
|
|
372
|
+
const conds = [];
|
|
373
|
+
// Bind the path segments at most once and reuse the expression string across
|
|
374
|
+
// equals + range comparisons (they share the same `path`).
|
|
375
|
+
let pathExpr = null;
|
|
376
|
+
const pathP = () => {
|
|
377
|
+
pathExpr ??= this.jsonPathExpr(col, filter.path, params);
|
|
378
|
+
return pathExpr;
|
|
379
|
+
};
|
|
380
|
+
if (filter.contains !== undefined) {
|
|
381
|
+
throw new UnsupportedFeatureError('JSON containment filters (contains)', 'PowDB', `column "${col.name}": PowQL has no JSON containment operator`);
|
|
382
|
+
}
|
|
383
|
+
if (filter.equals !== undefined) {
|
|
384
|
+
if (filter.path === undefined || filter.path.length === 0) {
|
|
385
|
+
throw new 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`);
|
|
386
|
+
}
|
|
387
|
+
conds.push(filter.equals === null ? `${pathP()} is null` : `${pathP()} = ${this.param(filter.equals, params)}`);
|
|
388
|
+
}
|
|
389
|
+
if (filter.hasKey !== undefined) {
|
|
390
|
+
// Top-level key existence, independent of `path` (mirrors PG `col ? key`).
|
|
391
|
+
conds.push(`json_type(.${col.name}->${this.param(filter.hasKey, params)}) is not null`);
|
|
392
|
+
}
|
|
393
|
+
// Range comparisons on the extracted path, in the fixed gt/gte/lt/lte order.
|
|
394
|
+
// Same validation as SQL `jsonRangeEntries`: `path` required, value a finite
|
|
395
|
+
// number or a string.
|
|
396
|
+
for (const [op, powOp] of [
|
|
397
|
+
['gt', '>'],
|
|
398
|
+
['gte', '>='],
|
|
399
|
+
['lt', '<'],
|
|
400
|
+
['lte', '<='],
|
|
401
|
+
]) {
|
|
402
|
+
const v = filter[op];
|
|
403
|
+
if (v === undefined)
|
|
404
|
+
continue;
|
|
405
|
+
if (filter.path === undefined) {
|
|
406
|
+
throw new ValidationError(`[turbine] JSON range operator '${op}' on ${col.name} requires a \`path\` ` +
|
|
407
|
+
`(e.g. { path: ['meta', 'score'], ${op}: ${JSON.stringify(v)} }).`);
|
|
408
|
+
}
|
|
409
|
+
if (typeof v !== 'number' && typeof v !== 'string') {
|
|
410
|
+
throw new ValidationError(`[turbine] JSON range operator '${op}' on ${col.name} requires a number or string, got ${JSON.stringify(v)}.`);
|
|
411
|
+
}
|
|
412
|
+
if (typeof v === 'number' && !Number.isFinite(v)) {
|
|
413
|
+
throw new ValidationError(`[turbine] JSON range operator '${op}' on ${col.name} requires a finite number.`);
|
|
414
|
+
}
|
|
415
|
+
conds.push(`${pathP()} ${powOp} ${this.param(v, params)}`);
|
|
416
|
+
}
|
|
417
|
+
if (!conds.length)
|
|
418
|
+
return '';
|
|
419
|
+
return conds.length > 1 ? `(${conds.join(' and ')})` : conds[0];
|
|
420
|
+
}
|
|
281
421
|
/** Bind a value, lowercasing for case-insensitive comparisons. */
|
|
282
422
|
bind(value, params, insensitive) {
|
|
283
423
|
const ph = this.param(value, params);
|
|
@@ -419,7 +559,7 @@ export class PowqlInterface {
|
|
|
419
559
|
const chunk = targetPks.slice(i, i + MAX_RELATION_KEYS);
|
|
420
560
|
const params = [];
|
|
421
561
|
const ph = chunk.map((v) => this.param(v, params)).join(', ');
|
|
422
|
-
const { rows } = await this.exec(`${quotePowqlIdent(through.table)} filter .${targetJCol} in (${ph}) { .${sourceJCol} }`, params, timeout);
|
|
562
|
+
const { rows } = await this.exec(`${quotePowqlIdent(through.table)} filter .${targetJCol} in (${ph}) { .${sourceJCol} }`, params, timeout, 'findMany');
|
|
423
563
|
for (const r of rows) {
|
|
424
564
|
const v = r[sourceJCol];
|
|
425
565
|
if (v != null)
|
|
@@ -467,8 +607,20 @@ export class PowqlInterface {
|
|
|
467
607
|
projection(cols) {
|
|
468
608
|
return `{ ${cols.map((c) => `.${c}`).join(', ')} }`;
|
|
469
609
|
}
|
|
470
|
-
/**
|
|
471
|
-
|
|
610
|
+
/**
|
|
611
|
+
* `order .c1 asc, .c2 desc` clause (empty string when no orderBy). Supports,
|
|
612
|
+
* besides a plain direction:
|
|
613
|
+
* - {@link JsonPathOrderBy} on a json column (≥ 0.12): `{ data: { path: […],
|
|
614
|
+
* type?, direction? } }` → `order .data->$n asc` (or
|
|
615
|
+
* `cast(.data->$n, "float")` for `type: 'numeric'`);
|
|
616
|
+
* - {@link OrderBySpec} `{ sort, nulls }`: `nulls: 'last'` is accepted as a
|
|
617
|
+
* no-op (PowDB is always nulls-last), `nulls: 'first'` throws E017.
|
|
618
|
+
*
|
|
619
|
+
* PowDB orders missing / JSON-null keys LAST in BOTH directions (an engine
|
|
620
|
+
* contract): for identical cross-engine results pass `nulls: 'last'`
|
|
621
|
+
* explicitly on Postgres, which defaults nulls-first for `desc`.
|
|
622
|
+
*/
|
|
623
|
+
buildOrder(orderBy, params) {
|
|
472
624
|
if (!orderBy)
|
|
473
625
|
return '';
|
|
474
626
|
const keys = Object.entries(orderBy).filter(([, dir]) => dir !== undefined);
|
|
@@ -476,32 +628,82 @@ export class PowqlInterface {
|
|
|
476
628
|
return '';
|
|
477
629
|
const parts = keys.map(([field, dir]) => {
|
|
478
630
|
if (dir && typeof dir === 'object') {
|
|
631
|
+
const o = dir;
|
|
632
|
+
// JSON-path ordering on a json column.
|
|
633
|
+
if (Array.isArray(o.path)) {
|
|
634
|
+
return this.buildJsonPathOrder(field, dir, params);
|
|
635
|
+
}
|
|
636
|
+
// OrderBySpec { sort, nulls }: accept nulls-last (PowDB default), refuse
|
|
637
|
+
// nulls-first (no placement grammar). Distinct from vector/pick/_count.
|
|
638
|
+
if ('sort' in o && !('distance' in o) && !('_count' in o) && !isRelationPickOrderBy(dir)) {
|
|
639
|
+
const spec = dir;
|
|
640
|
+
if (spec.nulls === 'first') {
|
|
641
|
+
throw new UnsupportedFeatureError('NULLS FIRST placement', 'PowDB', `field "${field}": PowDB orders NULLs / missing keys LAST in both directions`);
|
|
642
|
+
}
|
|
643
|
+
return `${this.ref(field)} ${spec.sort === 'desc' ? 'desc' : 'asc'}`;
|
|
644
|
+
}
|
|
479
645
|
// Name the actual feature in the refusal — a pick-row ordering
|
|
480
646
|
// reported as "vector / distance ordering" sends users hunting for
|
|
481
|
-
// pgvector docs.
|
|
482
|
-
const o = dir;
|
|
647
|
+
// pgvector docs. Everything else stays E017 on PowDB.
|
|
483
648
|
const feature = isRelationPickOrderBy(dir)
|
|
484
649
|
? 'relation pick-row ordering'
|
|
485
650
|
: 'distance' in o
|
|
486
651
|
? 'vector / distance ordering'
|
|
487
|
-
:
|
|
488
|
-
? '
|
|
489
|
-
: '
|
|
490
|
-
? '
|
|
491
|
-
: '
|
|
492
|
-
? 'NULLS placement / sort-spec ordering'
|
|
493
|
-
: 'object-valued ordering';
|
|
652
|
+
: '_count' in o
|
|
653
|
+
? 'relation _count ordering'
|
|
654
|
+
: 'nulls' in o
|
|
655
|
+
? 'NULLS placement / sort-spec ordering'
|
|
656
|
+
: 'object-valued ordering';
|
|
494
657
|
throw new UnsupportedFeatureError(feature, 'PowDB', `field "${field}"`);
|
|
495
658
|
}
|
|
496
659
|
return `${this.ref(field)} ${dir === 'desc' ? 'desc' : 'asc'}`;
|
|
497
660
|
});
|
|
498
661
|
return ` order ${parts.join(', ')}`;
|
|
499
662
|
}
|
|
663
|
+
/** Compile one {@link JsonPathOrderBy} entry to `order .col->$n asc` (+ optional numeric cast). */
|
|
664
|
+
buildJsonPathOrder(field, spec, params) {
|
|
665
|
+
const col = this.column(field);
|
|
666
|
+
if (!isJsonColumn(col)) {
|
|
667
|
+
throw new UnsupportedFeatureError('JSON-path ordering', 'PowDB', `field "${field}" is not a json column`);
|
|
668
|
+
}
|
|
669
|
+
requireCapability(this.capabilities, 'jsonDocs', 'JSON path ordering');
|
|
670
|
+
if (spec.nulls === 'first') {
|
|
671
|
+
throw new UnsupportedFeatureError('NULLS FIRST placement', 'PowDB', `field "${field}": PowDB orders missing / JSON-null keys LAST in both directions`);
|
|
672
|
+
}
|
|
673
|
+
const pathExpr = this.jsonPathExpr(col, spec.path, params);
|
|
674
|
+
// `type: 'numeric'` casts a JSON STRING number for numeric ordering; native
|
|
675
|
+
// JSON numbers already order numerically without a cast.
|
|
676
|
+
const expr = spec.type === 'numeric' ? `cast(${pathExpr}, "float")` : pathExpr;
|
|
677
|
+
return `${expr} ${spec.direction === 'desc' ? 'desc' : 'asc'}`;
|
|
678
|
+
}
|
|
500
679
|
// -------------------------------------------------------------------------
|
|
501
680
|
// Execution plumbing
|
|
502
681
|
// -------------------------------------------------------------------------
|
|
503
|
-
/**
|
|
504
|
-
|
|
682
|
+
/**
|
|
683
|
+
* Run PowQL with optional timeout, emitting a query event either way. The
|
|
684
|
+
* `action` is passed PER CALL (never read from shared instance state) so the
|
|
685
|
+
* retry-eligibility and the emitted event action stay correct even when a
|
|
686
|
+
* concurrent operation runs on the same cached interface: a WRITE statement
|
|
687
|
+
* carries a write action and can therefore never be mistaken for a replayable
|
|
688
|
+
* read. Read statements pass a read-shaped action from {@link POWQL_READ_ACTIONS}.
|
|
689
|
+
*/
|
|
690
|
+
async exec(powql, params, timeout, action = 'raw') {
|
|
691
|
+
return this.execOnce(powql, params, timeout, action, false);
|
|
692
|
+
}
|
|
693
|
+
/**
|
|
694
|
+
* Execute one statement, with the opt-in single stale-frame READ replay. When
|
|
695
|
+
* `retryStaleReads` is on and a first-statement READ fails with the stale-wire
|
|
696
|
+
* {@link isStaleFramePowdbError} ConnectionError (a socket idle-gap "received
|
|
697
|
+
* unexpected frame" that the client cannot recover), the statement is retried
|
|
698
|
+
* exactly once on a fresh pooled connection (the broken one was destroyed).
|
|
699
|
+
* The replay is refused for writes (an ambiguous mutation reply is unsafe to
|
|
700
|
+
* replay) and inside a transaction (a mid-tx statement cannot move connection),
|
|
701
|
+
* so only the read-shaped actions in {@link POWQL_READ_ACTIONS}, outside a
|
|
702
|
+
* `_txScoped` interface, are eligible. `action` is a per-call argument (never
|
|
703
|
+
* `this`-state), so a concurrent op flipping instance fields cannot turn a
|
|
704
|
+
* write into a retryable read.
|
|
705
|
+
*/
|
|
706
|
+
async execOnce(powql, params, timeout, action, isRetry) {
|
|
505
707
|
const start = performance.now();
|
|
506
708
|
const run = this.pool.query(powql, params);
|
|
507
709
|
try {
|
|
@@ -511,15 +713,37 @@ export class PowqlInterface {
|
|
|
511
713
|
new Promise((_, reject) => setTimeout(() => reject(new TimeoutError(timeout)), timeout)),
|
|
512
714
|
])
|
|
513
715
|
: await run;
|
|
514
|
-
this.emit(powql, params, performance.now() - start, result.rowCount ?? result.rows.length);
|
|
515
|
-
|
|
716
|
+
this.emit(action, powql, params, performance.now() - start, result.rowCount ?? result.rows.length);
|
|
717
|
+
// The pool tags each result with the wire that actually served it
|
|
718
|
+
// (adaptResult → false, adaptNativeResult → true). A heterogeneous
|
|
719
|
+
// injected pool can fall back to the legacy wire per call, so read the
|
|
720
|
+
// per-result flag and only fall back to the pool-level capability when a
|
|
721
|
+
// hand-built pool (tests) omits the tag; never coerce legacy rows with
|
|
722
|
+
// the native policy just because the pool reports nativeRaw.
|
|
723
|
+
const native = result.native ?? Boolean(this.capabilities.nativeRaw);
|
|
724
|
+
return { rows: result.rows, rowCount: result.rowCount ?? result.rows.length, native };
|
|
516
725
|
}
|
|
517
726
|
catch (err) {
|
|
518
|
-
|
|
727
|
+
if (!isRetry && this.shouldRetryStaleRead(err, action)) {
|
|
728
|
+
// Transparent single replay on a fresh connection; the first (swallowed)
|
|
729
|
+
// failure is not emitted, only the retried outcome is observed.
|
|
730
|
+
return this.execOnce(powql, params, timeout, action, true);
|
|
731
|
+
}
|
|
732
|
+
this.emit(action, powql, params, performance.now() - start, 0, err);
|
|
519
733
|
throw err;
|
|
520
734
|
}
|
|
521
735
|
}
|
|
522
|
-
|
|
736
|
+
/** Is `err` a replayable stale-frame failure for THIS (per-call) read-shaped, non-tx action? */
|
|
737
|
+
shouldRetryStaleRead(err, action) {
|
|
738
|
+
if (!this.pool.retryStaleReads)
|
|
739
|
+
return false;
|
|
740
|
+
if (this.isTxScoped())
|
|
741
|
+
return false;
|
|
742
|
+
if (!POWQL_READ_ACTIONS.has(action))
|
|
743
|
+
return false;
|
|
744
|
+
return isStaleFramePowdbError(err);
|
|
745
|
+
}
|
|
746
|
+
emit(action, sql, params, duration, rows, error) {
|
|
523
747
|
if (!this.onQuery)
|
|
524
748
|
return;
|
|
525
749
|
try {
|
|
@@ -528,7 +752,7 @@ export class PowqlInterface {
|
|
|
528
752
|
params,
|
|
529
753
|
duration,
|
|
530
754
|
model: this.table,
|
|
531
|
-
action
|
|
755
|
+
action,
|
|
532
756
|
rows,
|
|
533
757
|
timestamp: new Date(),
|
|
534
758
|
error,
|
|
@@ -540,7 +764,6 @@ export class PowqlInterface {
|
|
|
540
764
|
}
|
|
541
765
|
/** Run a method body through the middleware chain (mirrors QueryInterface). */
|
|
542
766
|
async withMiddleware(action, args, executor) {
|
|
543
|
-
this.currentAction = action;
|
|
544
767
|
if (this.middlewares.length === 0)
|
|
545
768
|
return executor();
|
|
546
769
|
let index = 0;
|
|
@@ -551,24 +774,30 @@ export class PowqlInterface {
|
|
|
551
774
|
};
|
|
552
775
|
return next({ model: this.table, action, args: { ...args } });
|
|
553
776
|
}
|
|
554
|
-
/** Map raw rows to typed entities.
|
|
555
|
-
|
|
556
|
-
|
|
777
|
+
/** Map raw rows to typed entities. `native` is the wire that ACTUALLY served
|
|
778
|
+
* this result (threaded from {@link execOnce}, not the pool-level capability),
|
|
779
|
+
* so cells that arrived pre-typed over `queryNativeRaw` (F3) skip the legacy
|
|
780
|
+
* string coercion (a genuine str `"null"` stays `"null"` instead of collapsing
|
|
781
|
+
* to null) while a per-call legacy fallback on a native-capable pool still
|
|
782
|
+
* coerces its string cells correctly. Defaults to the pool capability for the
|
|
783
|
+
* rare caller with no per-result flag (hand-built test pools). */
|
|
784
|
+
shape(rows, native = Boolean(this.capabilities.nativeRaw)) {
|
|
785
|
+
return rows.map((r) => rowToEntity(r, this.meta, native));
|
|
557
786
|
}
|
|
558
787
|
// -------------------------------------------------------------------------
|
|
559
788
|
// Reads
|
|
560
789
|
// -------------------------------------------------------------------------
|
|
561
790
|
async findMany(args = {}) {
|
|
562
791
|
return this.withMiddleware('findMany', args, async () => {
|
|
563
|
-
const rows = await this.runFind(args);
|
|
564
|
-
const entities = this.shape(rows);
|
|
792
|
+
const { rows, native } = await this.runFind(args, 'findMany');
|
|
793
|
+
const entities = this.shape(rows, native);
|
|
565
794
|
if (args.with)
|
|
566
795
|
await this.loadRelations(entities, args.with, args.timeout);
|
|
567
796
|
return entities;
|
|
568
797
|
});
|
|
569
798
|
}
|
|
570
|
-
/** Build + run the flat findMany select; returns raw rows. */
|
|
571
|
-
async runFind(args) {
|
|
799
|
+
/** Build + run the flat findMany select; returns raw rows + the serving wire. */
|
|
800
|
+
async runFind(args, action = 'findMany') {
|
|
572
801
|
if (args.cursor) {
|
|
573
802
|
throw new UnsupportedFeatureError('cursor pagination', 'PowDB', 'use limit/offset instead');
|
|
574
803
|
}
|
|
@@ -578,7 +807,7 @@ export class PowqlInterface {
|
|
|
578
807
|
const cols = this.projectedColumns(args.select, args.omit);
|
|
579
808
|
const distinct = args.distinct?.length ? ' distinct' : '';
|
|
580
809
|
const filter = where ? ` filter ${where}` : '';
|
|
581
|
-
const order = this.buildOrder(args.orderBy);
|
|
810
|
+
const order = this.buildOrder(args.orderBy, params);
|
|
582
811
|
const limit = args.limit ?? args.take ?? this.defaultLimit;
|
|
583
812
|
if (limit === undefined && this.warnOnUnlimited && !this.warnedUnlimited) {
|
|
584
813
|
this.warnedUnlimited = true;
|
|
@@ -587,15 +816,15 @@ export class PowqlInterface {
|
|
|
587
816
|
const limitClause = limit !== undefined ? ` limit ${this.param(limit, params)}` : '';
|
|
588
817
|
const offsetClause = args.offset ? ` offset ${this.param(args.offset, params)}` : '';
|
|
589
818
|
const powql = `${this.qt}${distinct}${filter}${order}${limitClause}${offsetClause} ${this.projection(cols)}`;
|
|
590
|
-
const { rows } = await this.exec(powql, params, args.timeout);
|
|
591
|
-
return rows;
|
|
819
|
+
const { rows, native } = await this.exec(powql, params, args.timeout, action);
|
|
820
|
+
return { rows, native };
|
|
592
821
|
}
|
|
593
822
|
async findUnique(args) {
|
|
594
823
|
return this.withMiddleware('findUnique', args, async () => {
|
|
595
|
-
const rows = await this.runFind({ ...args, limit: 1 });
|
|
824
|
+
const { rows, native } = await this.runFind({ ...args, limit: 1 }, 'findUnique');
|
|
596
825
|
if (!rows.length)
|
|
597
826
|
return null;
|
|
598
|
-
const entities = this.shape(rows);
|
|
827
|
+
const entities = this.shape(rows, native);
|
|
599
828
|
if (args.with)
|
|
600
829
|
await this.loadRelations(entities, args.with, args.timeout);
|
|
601
830
|
return entities[0];
|
|
@@ -603,10 +832,10 @@ export class PowqlInterface {
|
|
|
603
832
|
}
|
|
604
833
|
async findFirst(args = {}) {
|
|
605
834
|
return this.withMiddleware('findFirst', args, async () => {
|
|
606
|
-
const rows = await this.runFind({ ...args, limit: 1 });
|
|
835
|
+
const { rows, native } = await this.runFind({ ...args, limit: 1 }, 'findFirst');
|
|
607
836
|
if (!rows.length)
|
|
608
837
|
return null;
|
|
609
|
-
const entities = this.shape(rows);
|
|
838
|
+
const entities = this.shape(rows, native);
|
|
610
839
|
if (args.with)
|
|
611
840
|
await this.loadRelations(entities, args.with, args.timeout);
|
|
612
841
|
return entities[0];
|
|
@@ -736,7 +965,7 @@ export class PowqlInterface {
|
|
|
736
965
|
const params = [];
|
|
737
966
|
const placeholders = chunk.map((v) => this.param(v, params)).join(', ');
|
|
738
967
|
const powql = `${quotePowqlIdent(through.table)} filter .${sourceJCol} in (${placeholders}) { .${sourceJCol}, .${targetJCol} }`;
|
|
739
|
-
const { rows } = await this.exec(powql, params, timeout);
|
|
968
|
+
const { rows } = await this.exec(powql, params, timeout, 'findMany');
|
|
740
969
|
for (const row of rows) {
|
|
741
970
|
const sv = String(row[sourceJCol]);
|
|
742
971
|
const tv = String(row[targetJCol]);
|
|
@@ -837,8 +1066,8 @@ export class PowqlInterface {
|
|
|
837
1066
|
.map((a) => `${quotePowqlIdent(a.col.name)} := ${this.writeRef(a.value, a.col, params)}`)
|
|
838
1067
|
.join(', ');
|
|
839
1068
|
// `returning` surfaces the inserted row (all columns, schema order) in one round-trip.
|
|
840
|
-
const { rows } = await this.exec(`insert ${this.qt} { ${body} } returning`, params, args.timeout);
|
|
841
|
-
const row = rows.length ? this.shape(rows)[0] : null;
|
|
1069
|
+
const { rows, native } = await this.exec(`insert ${this.qt} { ${body} } returning`, params, args.timeout, 'create');
|
|
1070
|
+
const row = rows.length ? this.shape(rows, native)[0] : null;
|
|
842
1071
|
if (!row)
|
|
843
1072
|
throw new NotFoundError({ table: this.table, where: data });
|
|
844
1073
|
return row;
|
|
@@ -855,8 +1084,8 @@ export class PowqlInterface {
|
|
|
855
1084
|
return `{ ${assigns.map((a) => `${quotePowqlIdent(a.col.name)} := ${this.writeRef(a.value, a.col, params)}`).join(', ')} }`;
|
|
856
1085
|
});
|
|
857
1086
|
// Multi-row insert with `returning` hands back every inserted row in one round-trip.
|
|
858
|
-
const { rows } = await this.exec(`insert ${this.qt} ${tuples.join(', ')} returning`, params, args.timeout);
|
|
859
|
-
return this.shape(rows);
|
|
1087
|
+
const { rows, native } = await this.exec(`insert ${this.qt} ${tuples.join(', ')} returning`, params, args.timeout, 'createMany');
|
|
1088
|
+
return this.shape(rows, native);
|
|
860
1089
|
});
|
|
861
1090
|
}
|
|
862
1091
|
async update(args) {
|
|
@@ -870,8 +1099,8 @@ export class PowqlInterface {
|
|
|
870
1099
|
this.assertCompiledWhere(where, false, 'update');
|
|
871
1100
|
const setClause = this.buildUpdateAssignments(args.data, params);
|
|
872
1101
|
// `returning` hands back the post-update row(s); take the first (single-row contract).
|
|
873
|
-
const { rows } = await this.exec(`${this.qt} filter ${where} update { ${setClause} } returning`, params, args.timeout);
|
|
874
|
-
const row = rows.length ? this.shape(rows)[0] : null;
|
|
1102
|
+
const { rows, native } = await this.exec(`${this.qt} filter ${where} update { ${setClause} } returning`, params, args.timeout, 'update');
|
|
1103
|
+
const row = rows.length ? this.shape(rows, native)[0] : null;
|
|
875
1104
|
if (!row)
|
|
876
1105
|
throw new NotFoundError({ table: this.table, where: args.where });
|
|
877
1106
|
return row;
|
|
@@ -885,7 +1114,7 @@ export class PowqlInterface {
|
|
|
885
1114
|
this.assertCompiledWhere(where, args.allowFullTableScan, 'updateMany');
|
|
886
1115
|
const setClause = this.buildUpdateAssignments(args.data, params);
|
|
887
1116
|
const filter = where ? ` filter ${where}` : '';
|
|
888
|
-
const { rowCount } = await this.exec(`${this.qt}${filter} update { ${setClause} }`, params, args.timeout);
|
|
1117
|
+
const { rowCount } = await this.exec(`${this.qt}${filter} update { ${setClause} }`, params, args.timeout, 'updateMany');
|
|
889
1118
|
return { count: rowCount };
|
|
890
1119
|
});
|
|
891
1120
|
}
|
|
@@ -1008,8 +1237,8 @@ export class PowqlInterface {
|
|
|
1008
1237
|
const where = this.buildWhere(resolvedWhere, params);
|
|
1009
1238
|
this.assertCompiledWhere(where, false, 'delete');
|
|
1010
1239
|
// `returning` hands back the deleted row(s) — no separate pre-image reselect needed.
|
|
1011
|
-
const { rows } = await this.exec(`${this.qt} filter ${where} delete returning`, params, args.timeout);
|
|
1012
|
-
const row = rows.length ? this.shape(rows)[0] : null;
|
|
1240
|
+
const { rows, native } = await this.exec(`${this.qt} filter ${where} delete returning`, params, args.timeout, 'delete');
|
|
1241
|
+
const row = rows.length ? this.shape(rows, native)[0] : null;
|
|
1013
1242
|
if (!row)
|
|
1014
1243
|
throw new NotFoundError({ table: this.table, where: args.where });
|
|
1015
1244
|
return row;
|
|
@@ -1022,7 +1251,7 @@ export class PowqlInterface {
|
|
|
1022
1251
|
const where = this.buildWhere(resolvedWhere, params);
|
|
1023
1252
|
this.assertCompiledWhere(where, args.allowFullTableScan, 'deleteMany');
|
|
1024
1253
|
const filter = where ? ` filter ${where}` : '';
|
|
1025
|
-
const { rowCount } = await this.exec(`${this.qt}${filter} delete`, params, args.timeout);
|
|
1254
|
+
const { rowCount } = await this.exec(`${this.qt}${filter} delete`, params, args.timeout, 'deleteMany');
|
|
1026
1255
|
return { count: rowCount };
|
|
1027
1256
|
});
|
|
1028
1257
|
}
|
|
@@ -1044,7 +1273,7 @@ export class PowqlInterface {
|
|
|
1044
1273
|
// (verified: "unexpected trailing token … 'returning'"), because it is one
|
|
1045
1274
|
// atomic insert-or-update, not two branches. So upsert alone keeps the
|
|
1046
1275
|
// reselect-by-PK fetch; create/update/delete all use `returning`.
|
|
1047
|
-
await this.exec(`upsert ${this.qt} on .${pkCol} { ${createBody} } on conflict { ${updateBody} }`, params, args.timeout);
|
|
1276
|
+
await this.exec(`upsert ${this.qt} on .${pkCol} { ${createBody} } on conflict { ${updateBody} }`, params, args.timeout, 'upsert');
|
|
1048
1277
|
const pkField = this.meta.reverseColumnMap[pkCol] ?? pkCol;
|
|
1049
1278
|
const row = await this.reselectByPk(createData[pkField], args.timeout);
|
|
1050
1279
|
if (!row)
|
|
@@ -1089,7 +1318,7 @@ export class PowqlInterface {
|
|
|
1089
1318
|
const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
|
|
1090
1319
|
const where = this.buildWhere(resolvedWhere, params);
|
|
1091
1320
|
const filter = where ? ` filter ${where}` : '';
|
|
1092
|
-
const { rows } = await this.exec(`count(${this.qt}${filter})`, params, args.timeout);
|
|
1321
|
+
const { rows } = await this.exec(`count(${this.qt}${filter})`, params, args.timeout, 'count');
|
|
1093
1322
|
return Number((rows[0]?.value ?? rows[0]?.count ?? 0));
|
|
1094
1323
|
});
|
|
1095
1324
|
}
|
|
@@ -1103,7 +1332,7 @@ export class PowqlInterface {
|
|
|
1103
1332
|
const filter = where ? ` filter ${where}` : '';
|
|
1104
1333
|
const scalar = async (expr) => {
|
|
1105
1334
|
const params = [...filterParams];
|
|
1106
|
-
const { rows } = await this.exec(expr, params, args.timeout);
|
|
1335
|
+
const { rows } = await this.exec(expr, params, args.timeout, 'aggregate');
|
|
1107
1336
|
const v = rows[0]?.value;
|
|
1108
1337
|
return v == null || v === 'null' ? null : Number(v);
|
|
1109
1338
|
};
|
|
@@ -1135,90 +1364,196 @@ export class PowqlInterface {
|
|
|
1135
1364
|
}
|
|
1136
1365
|
async groupBy(args) {
|
|
1137
1366
|
return this.withMiddleware('groupBy', args, async () => {
|
|
1138
|
-
//
|
|
1139
|
-
// group keys / aggregate targets) have no PowQL equivalent: refuse
|
|
1140
|
-
// clearly instead of emitting broken PowQL.
|
|
1367
|
+
// DISTINCT ON has no PowQL equivalent (no DISTINCT ON row source).
|
|
1141
1368
|
if (args.distinctOn) {
|
|
1142
1369
|
throw new UnsupportedFeatureError('groupBy distinctOn row source', 'PowDB');
|
|
1143
1370
|
}
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
continue;
|
|
1153
|
-
for (const value of Object.values(spec)) {
|
|
1154
|
-
if (value !== undefined && typeof value !== 'boolean') {
|
|
1155
|
-
throw new UnsupportedFeatureError(`JSON-path ${fn} aggregate targets`, 'PowDB');
|
|
1156
|
-
}
|
|
1157
|
-
}
|
|
1371
|
+
// JSON-path group keys / aggregate targets (≥ 0.12) are gated once here.
|
|
1372
|
+
const usesJson = args.by.some((e) => typeof e !== 'string') ||
|
|
1373
|
+
['_sum', '_avg', '_min', '_max'].some((fn) => {
|
|
1374
|
+
const spec = args[fn];
|
|
1375
|
+
return spec !== undefined && Object.values(spec).some((v) => v != null && typeof v === 'object');
|
|
1376
|
+
});
|
|
1377
|
+
if (usesJson) {
|
|
1378
|
+
requireCapability(this.capabilities, 'jsonDocs', 'JSON-path groupBy keys / aggregate targets');
|
|
1158
1379
|
}
|
|
1380
|
+
// `emitNative` decides whether the query GENERATION needs the legacy-wire
|
|
1381
|
+
// `json_type` discriminator (pool-level capability). The DECODE side reads
|
|
1382
|
+
// the wire that ACTUALLY served the result (`resultNative`, from exec), so
|
|
1383
|
+
// a per-call legacy fallback on a native-capable pool still decodes right.
|
|
1384
|
+
const emitNative = Boolean(this.capabilities.nativeRaw);
|
|
1159
1385
|
const params = [];
|
|
1160
1386
|
const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
|
|
1161
1387
|
const where = this.buildWhere(resolvedWhere, params);
|
|
1162
1388
|
const filter = where ? ` filter ${where}` : '';
|
|
1163
|
-
|
|
1164
|
-
//
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
const
|
|
1168
|
-
|
|
1169
|
-
|
|
1389
|
+
// Result-key namespace, mirroring the SQL builder's `claimResultKey`
|
|
1390
|
+
// (query/builder.ts): a group-key / aggregate output-name collision (with
|
|
1391
|
+
// `_count`, another key, or an aggregate output) throws E003.
|
|
1392
|
+
const usedKeys = new Set();
|
|
1393
|
+
const claim = (key, what) => {
|
|
1394
|
+
if (key === '_count' || usedKeys.has(key)) {
|
|
1395
|
+
throw new ValidationError(`[turbine] groupBy output name "${key}" (${what}) collides with another output column on table ` +
|
|
1396
|
+
`"${this.table}": set an explicit \`alias\` (or rename the aggregate key) to disambiguate.`);
|
|
1397
|
+
}
|
|
1398
|
+
usedKeys.add(key);
|
|
1399
|
+
};
|
|
1400
|
+
const groupExprs = [];
|
|
1401
|
+
const proj = [];
|
|
1402
|
+
const byOrderExprs = new Map();
|
|
1403
|
+
const byReaders = [];
|
|
1404
|
+
let gkN = 0;
|
|
1405
|
+
let gtN = 0;
|
|
1406
|
+
for (const entry of args.by) {
|
|
1407
|
+
if (typeof entry === 'string') {
|
|
1408
|
+
const col = this.column(entry);
|
|
1409
|
+
claim(entry, `column "${col.name}"`);
|
|
1410
|
+
if (col.name !== entry)
|
|
1411
|
+
claim(col.name, `column "${col.name}"`);
|
|
1412
|
+
groupExprs.push(`.${col.name}`);
|
|
1413
|
+
proj.push(`.${col.name}`);
|
|
1414
|
+
byOrderExprs.set(entry, `.${col.name}`);
|
|
1415
|
+
byReaders.push({ kind: 'plain', resultKey: entry, rowKey: col.name, col });
|
|
1416
|
+
}
|
|
1417
|
+
else {
|
|
1418
|
+
const col = this.column(entry.field);
|
|
1419
|
+
if (!isJsonColumn(col)) {
|
|
1420
|
+
throw new ValidationError(`[turbine] groupBy JSON group key on "${entry.field}" (table "${this.table}") requires a json column.`);
|
|
1421
|
+
}
|
|
1422
|
+
this.assertJsonPath('group key', entry.field, entry.path);
|
|
1423
|
+
const pathExpr = this.jsonPathExpr(col, entry.path, params);
|
|
1424
|
+
const alias = entry.alias ?? String(entry.path[entry.path.length - 1]);
|
|
1425
|
+
claim(alias, `JSON path on "${entry.field}"`);
|
|
1426
|
+
const gkAlias = `gk_${gkN++}`;
|
|
1427
|
+
groupExprs.push(pathExpr);
|
|
1428
|
+
proj.push(`${gkAlias}: ${pathExpr}`);
|
|
1429
|
+
byOrderExprs.set(alias, `.${gkAlias}`);
|
|
1430
|
+
let discrim;
|
|
1431
|
+
if (!emitNative) {
|
|
1432
|
+
// Legacy wire renders a missing value, JSON null, AND the string
|
|
1433
|
+
// "null" all as the cell "null". `min(json_type(path))` over the
|
|
1434
|
+
// group is "string" ONLY for the string-"null" group and "null"
|
|
1435
|
+
// otherwise (a bare `json_type` projection is not group-correlated).
|
|
1436
|
+
discrim = `gt_${gtN++}`;
|
|
1437
|
+
proj.push(`${discrim}: min(json_type(${pathExpr}))`);
|
|
1438
|
+
}
|
|
1439
|
+
byReaders.push({ kind: 'json', resultKey: alias, rowKey: gkAlias, discrim });
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
// Aggregates: `agg_N` internal aliases (PowQL rejects reserved-word
|
|
1443
|
+
// aliases like `count:`). `aggInner` lets HAVING re-emit the exact inner
|
|
1444
|
+
// expression by user key; `aggOrderExprs` lets orderBy reference the alias.
|
|
1445
|
+
const aggReaders = [];
|
|
1446
|
+
const aggOrderExprs = new Map();
|
|
1447
|
+
const aggInner = new Map();
|
|
1448
|
+
let aggN = 0;
|
|
1449
|
+
// Parity with the SQL builder (query/builder.ts): `_count` is selected by
|
|
1450
|
+
// DEFAULT unless the caller explicitly opts out with `_count: false`, so
|
|
1451
|
+
// every groupBy row carries `_count` and `orderBy: { _count }` works
|
|
1452
|
+
// without requesting it (the alias is seeded into `aggOrderExprs`).
|
|
1453
|
+
const countSelected = args._count === true || args._count === undefined;
|
|
1454
|
+
if (countSelected) {
|
|
1455
|
+
const alias = `agg_${aggN++}`;
|
|
1456
|
+
proj.push(`${alias}: count(*)`);
|
|
1457
|
+
aggReaders.push({ alias, outKey: '_count', numeric: true });
|
|
1458
|
+
aggOrderExprs.set('_count', `.${alias}`);
|
|
1170
1459
|
}
|
|
1171
1460
|
for (const fn of ['_sum', '_avg', '_min', '_max']) {
|
|
1172
1461
|
const spec = args[fn];
|
|
1173
1462
|
if (!spec)
|
|
1174
1463
|
continue;
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1464
|
+
const powfn = fn.slice(1); // sum/avg/min/max
|
|
1465
|
+
for (const [key, target] of Object.entries(spec)) {
|
|
1466
|
+
if (!target)
|
|
1467
|
+
continue;
|
|
1468
|
+
const alias = `agg_${aggN++}`;
|
|
1469
|
+
if (target === true) {
|
|
1470
|
+
const col = this.column(key);
|
|
1471
|
+
claim(`${fn}_${col.name}`, `${fn} of column "${col.name}"`);
|
|
1472
|
+
const inner = `.${col.name}`;
|
|
1473
|
+
proj.push(`${alias}: ${powfn}(${inner})`);
|
|
1474
|
+
aggReaders.push({ alias, outKey: `${fn}:${key}`, numeric: true });
|
|
1475
|
+
aggOrderExprs.set(`${fn}:${key}`, `.${alias}`);
|
|
1476
|
+
aggInner.set(key, inner);
|
|
1477
|
+
}
|
|
1478
|
+
else {
|
|
1479
|
+
const col = this.column(target.field);
|
|
1480
|
+
if (!isJsonColumn(col)) {
|
|
1481
|
+
throw new ValidationError(`[turbine] groupBy ${fn} target "${key}" on "${target.field}" (table "${this.table}") requires a json column.`);
|
|
1482
|
+
}
|
|
1483
|
+
this.assertJsonPath(`${fn} target "${key}"`, target.field, target.path);
|
|
1484
|
+
const alwaysNumeric = fn === '_sum' || fn === '_avg';
|
|
1485
|
+
if (alwaysNumeric && target.type === 'text') {
|
|
1486
|
+
throw new ValidationError(`[turbine] groupBy ${fn} target "${key}" on table "${this.table}": ` +
|
|
1487
|
+
`${fn} over a JSON path is always numeric: remove \`type: 'text'\`.`);
|
|
1488
|
+
}
|
|
1489
|
+
const numeric = alwaysNumeric || target.type === 'numeric';
|
|
1490
|
+
claim(`${fn}_${key}`, `${fn} JSON target "${key}"`);
|
|
1491
|
+
const pathExpr = this.jsonPathExpr(col, target.path, params);
|
|
1492
|
+
const inner = numeric ? `cast(${pathExpr}, "float")` : pathExpr;
|
|
1493
|
+
proj.push(`${alias}: ${powfn}(${inner})`);
|
|
1494
|
+
aggReaders.push({ alias, outKey: `${fn}:${key}`, numeric });
|
|
1495
|
+
aggOrderExprs.set(`${fn}:${key}`, `.${alias}`);
|
|
1496
|
+
aggInner.set(key, inner);
|
|
1191
1497
|
}
|
|
1192
1498
|
}
|
|
1193
1499
|
}
|
|
1194
|
-
const
|
|
1195
|
-
const
|
|
1196
|
-
const
|
|
1197
|
-
|
|
1500
|
+
const having = this.buildHaving(args.having, params, aggInner);
|
|
1501
|
+
const order = this.buildGroupOrder(args.orderBy, byOrderExprs, aggOrderExprs);
|
|
1502
|
+
const powql = `${this.qt}${filter} group ${groupExprs.join(', ')}${having}${order} { ${proj.join(', ')} }`;
|
|
1503
|
+
const { rows, native: resultNative } = await this.exec(powql, params, args.timeout, 'groupBy');
|
|
1504
|
+
// Reshape: group keys → user fields (coerced / null-disambiguated),
|
|
1505
|
+
// aggregates → nested `{ _sum: { field } }`; discriminators are stripped.
|
|
1506
|
+
// Group-key cells go through the SAME coercion policy `rowToEntity` uses,
|
|
1507
|
+
// by the wire that actually served this result, so a native-wire int cell
|
|
1508
|
+
// (bigint) or datetime cell (micros) never leaks into the result: it
|
|
1509
|
+
// becomes the same number / Date / PG-text-parity string an embedded /
|
|
1510
|
+
// legacy / SQL groupBy returns for the identical query.
|
|
1198
1511
|
return rows.map((raw) => {
|
|
1199
1512
|
const out = {};
|
|
1200
|
-
for (const
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1513
|
+
for (const r of byReaders) {
|
|
1514
|
+
if (r.kind === 'plain') {
|
|
1515
|
+
const cell = raw[r.rowKey];
|
|
1516
|
+
out[r.resultKey] = resultNative
|
|
1517
|
+
? coerceNativeValue(cell, r.col)
|
|
1518
|
+
: typeof cell === 'string'
|
|
1519
|
+
? coerceScalar(cell, r.col.tsType)
|
|
1520
|
+
: cell;
|
|
1521
|
+
}
|
|
1522
|
+
else {
|
|
1523
|
+
out[r.resultKey] = decodeGroupKeyCell(raw[r.rowKey], r.discrim ? raw[r.discrim] : undefined, resultNative);
|
|
1524
|
+
}
|
|
1204
1525
|
}
|
|
1205
|
-
for (const a of
|
|
1206
|
-
const
|
|
1207
|
-
const
|
|
1526
|
+
for (const a of aggReaders) {
|
|
1527
|
+
const cell = raw[a.alias];
|
|
1528
|
+
const v = cell == null || cell === 'null' ? null : a.numeric ? Number(cell) : cell;
|
|
1208
1529
|
if (a.outKey === '_count')
|
|
1209
|
-
out._count =
|
|
1530
|
+
out._count = v ?? 0;
|
|
1210
1531
|
else {
|
|
1211
1532
|
const [bucket, field] = a.outKey.split(':');
|
|
1212
1533
|
out[bucket] ??= {};
|
|
1213
|
-
out[bucket][field] =
|
|
1534
|
+
out[bucket][field] = v;
|
|
1214
1535
|
}
|
|
1215
1536
|
}
|
|
1216
1537
|
return out;
|
|
1217
1538
|
});
|
|
1218
1539
|
});
|
|
1219
1540
|
}
|
|
1220
|
-
/**
|
|
1221
|
-
|
|
1541
|
+
/** Validate a JSON-path target (group key / aggregate target): non-empty array of keys/indexes. */
|
|
1542
|
+
assertJsonPath(context, field, path) {
|
|
1543
|
+
if (!Array.isArray(path) ||
|
|
1544
|
+
path.length === 0 ||
|
|
1545
|
+
path.some((el) => typeof el !== 'string' && !(typeof el === 'number' && Number.isFinite(el)))) {
|
|
1546
|
+
throw new ValidationError(`[turbine] groupBy ${context} on "${field}" (table "${this.table}") requires a non-empty \`path\` ` +
|
|
1547
|
+
`array of keys/indexes (e.g. { field: '${field}', path: ['category'] }).`);
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1550
|
+
/**
|
|
1551
|
+
* `having <expr>` over group aggregates. `_count` compares `count(*)` (parity
|
|
1552
|
+
* with the projection); a per-field aggregate re-emits its inner expression
|
|
1553
|
+
* (from `aggInner` when the field is a requested aggregate, so a JSON-path
|
|
1554
|
+
* aggregate reuses its bound placeholders, else `.field` for a plain column).
|
|
1555
|
+
*/
|
|
1556
|
+
buildHaving(having, params, aggInner) {
|
|
1222
1557
|
if (!having)
|
|
1223
1558
|
return '';
|
|
1224
1559
|
const conds = [];
|
|
@@ -1236,18 +1571,88 @@ export class PowqlInterface {
|
|
|
1236
1571
|
if (spec == null)
|
|
1237
1572
|
continue;
|
|
1238
1573
|
if (key === '_count') {
|
|
1239
|
-
conds.push(cmp(
|
|
1574
|
+
conds.push(cmp('count(*)', spec));
|
|
1240
1575
|
}
|
|
1241
1576
|
else {
|
|
1242
1577
|
for (const [fn, filter] of Object.entries(spec)) {
|
|
1243
1578
|
if (filter == null)
|
|
1244
1579
|
continue;
|
|
1245
|
-
|
|
1580
|
+
const inner = aggInner.get(key) ?? this.ref(key);
|
|
1581
|
+
conds.push(cmp(`${fn.slice(1)}(${inner})`, filter));
|
|
1246
1582
|
}
|
|
1247
1583
|
}
|
|
1248
1584
|
}
|
|
1249
1585
|
return conds.length ? ` having ${conds.join(' and ')}` : '';
|
|
1250
1586
|
}
|
|
1587
|
+
/**
|
|
1588
|
+
* Compile a groupBy `orderBy` into a PowQL `order` body over the group RESULT
|
|
1589
|
+
* columns (by-fields, JSON group-key aliases, and requested aggregates). PowQL
|
|
1590
|
+
* cannot re-emit an aggregate EXPRESSION in `order` (engine error), but CAN
|
|
1591
|
+
* order by a projection alias on a grouped query (probed), so each key maps to
|
|
1592
|
+
* its projected alias (`.agg_N` / `.gk_N` / `.col`). Semantics and error
|
|
1593
|
+
* surface mirror the SQL `buildGroupByOrderBy` (0.32.2 R3-1): an aggregate not
|
|
1594
|
+
* requested in this call, or an unknown by-key, throws E003 listing the valid
|
|
1595
|
+
* keys. `nulls: 'first'` stays E017 (PowDB has no NULLS placement grammar).
|
|
1596
|
+
*/
|
|
1597
|
+
buildGroupOrder(orderBy, byOrderExprs, aggOrderExprs) {
|
|
1598
|
+
if (!orderBy)
|
|
1599
|
+
return '';
|
|
1600
|
+
const aggBlocks = new Set(['_count', '_sum', '_avg', '_min', '_max']);
|
|
1601
|
+
const validKeys = () => {
|
|
1602
|
+
const keys = [...byOrderExprs.keys()];
|
|
1603
|
+
for (const k of aggOrderExprs.keys())
|
|
1604
|
+
keys.push(k.includes(':') ? k.replace(':', '.') : k);
|
|
1605
|
+
return keys.join(', ') || '(none)';
|
|
1606
|
+
};
|
|
1607
|
+
const parts = [];
|
|
1608
|
+
for (const [key, value] of Object.entries(orderBy)) {
|
|
1609
|
+
if (value === undefined)
|
|
1610
|
+
continue;
|
|
1611
|
+
if (aggBlocks.has(key)) {
|
|
1612
|
+
if (key === '_count') {
|
|
1613
|
+
const expr = aggOrderExprs.get('_count');
|
|
1614
|
+
if (!expr) {
|
|
1615
|
+
throw new ValidationError(`[turbine] Cannot order groupBy by "_count" on table "${this.table}": _count is not selected. ` +
|
|
1616
|
+
`Orderable keys: ${validKeys()}.`);
|
|
1617
|
+
}
|
|
1618
|
+
parts.push(`${expr} ${this.groupOrderDir(value, '_count')}`);
|
|
1619
|
+
continue;
|
|
1620
|
+
}
|
|
1621
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
1622
|
+
throw new ValidationError(`[turbine] Invalid groupBy orderBy for "${key}" on table "${this.table}": ` +
|
|
1623
|
+
`expected a field map like { ${key}: { amount: 'desc' } }.`);
|
|
1624
|
+
}
|
|
1625
|
+
for (const [field, dirSpec] of Object.entries(value)) {
|
|
1626
|
+
if (dirSpec === undefined)
|
|
1627
|
+
continue;
|
|
1628
|
+
const expr = aggOrderExprs.get(`${key}:${field}`);
|
|
1629
|
+
if (!expr) {
|
|
1630
|
+
throw new ValidationError(`[turbine] Cannot order groupBy by "${key}.${field}" on table "${this.table}": ` +
|
|
1631
|
+
`that aggregate is not requested in this call. Orderable keys: ${validKeys()}.`);
|
|
1632
|
+
}
|
|
1633
|
+
parts.push(`${expr} ${this.groupOrderDir(dirSpec, `${key}.${field}`)}`);
|
|
1634
|
+
}
|
|
1635
|
+
continue;
|
|
1636
|
+
}
|
|
1637
|
+
const expr = byOrderExprs.get(key);
|
|
1638
|
+
if (!expr) {
|
|
1639
|
+
throw new ValidationError(`[turbine] Unknown field "${key}" in groupBy orderBy on table "${this.table}". Orderable keys: ${validKeys()}.`);
|
|
1640
|
+
}
|
|
1641
|
+
parts.push(`${expr} ${this.groupOrderDir(value, key)}`);
|
|
1642
|
+
}
|
|
1643
|
+
return parts.length ? ` order ${parts.join(', ')}` : '';
|
|
1644
|
+
}
|
|
1645
|
+
/** Resolve a groupBy order direction, refusing `nulls: 'first'` (E017); `nulls: 'last'` is a no-op. */
|
|
1646
|
+
groupOrderDir(value, keyForMsg) {
|
|
1647
|
+
if (value !== null && typeof value === 'object') {
|
|
1648
|
+
const spec = value;
|
|
1649
|
+
if (spec.nulls === 'first') {
|
|
1650
|
+
throw new UnsupportedFeatureError('NULLS FIRST placement', 'PowDB', `groupBy orderBy "${keyForMsg}": PowDB orders NULLs / missing keys LAST in both directions`);
|
|
1651
|
+
}
|
|
1652
|
+
return spec.sort === 'desc' ? 'desc' : 'asc';
|
|
1653
|
+
}
|
|
1654
|
+
return value === 'desc' ? 'desc' : 'asc';
|
|
1655
|
+
}
|
|
1251
1656
|
// -------------------------------------------------------------------------
|
|
1252
1657
|
// Streaming / unsupported
|
|
1253
1658
|
// -------------------------------------------------------------------------
|
|
@@ -1261,12 +1666,12 @@ export class PowqlInterface {
|
|
|
1261
1666
|
/** Reselect a single row by its single-column primary key value. */
|
|
1262
1667
|
async reselectByPk(pkValue, timeout) {
|
|
1263
1668
|
const pkField = this.meta.reverseColumnMap[this.meta.primaryKey[0]] ?? this.meta.primaryKey[0];
|
|
1264
|
-
const rows = await this.runFind({
|
|
1669
|
+
const { rows, native } = await this.runFind({
|
|
1265
1670
|
where: { [pkField]: pkValue },
|
|
1266
1671
|
limit: 1,
|
|
1267
1672
|
timeout,
|
|
1268
1673
|
});
|
|
1269
|
-
return rows.length ? this.shape(rows)[0] : null;
|
|
1674
|
+
return rows.length ? this.shape(rows, native)[0] : null;
|
|
1270
1675
|
}
|
|
1271
1676
|
/**
|
|
1272
1677
|
* Empty-where guard — blocks accidental whole-table writes. Mirrors the SQL
|
|
@@ -1301,3 +1706,53 @@ function coerceScalar(raw, tsType) {
|
|
|
1301
1706
|
return new Date(Number(raw) / 1000);
|
|
1302
1707
|
return raw;
|
|
1303
1708
|
}
|
|
1709
|
+
/**
|
|
1710
|
+
* Decode a JSON group-key cell, resolving the legacy-wire `null` ambiguity AND
|
|
1711
|
+
* normalizing the native typed wire to the SAME PG-`#>>`-text-parity shape.
|
|
1712
|
+
*
|
|
1713
|
+
* On the native typed wire (`native`) a cell arrives pre-typed (a JSON int as a
|
|
1714
|
+
* `bigint`, a bool as `boolean`, an unset value as `null`). Returned as-is that
|
|
1715
|
+
* would diverge from every other transport: the embedded / legacy / SQL wire
|
|
1716
|
+
* all yield the extracted TEXT (`'7'`, `'true'`), and a raw `bigint` even throws
|
|
1717
|
+
* on `JSON.stringify`. So a native scalar cell is rendered to its text form
|
|
1718
|
+
* ({@link nativeJsonKeyText}); `null`/`empty` stays `null`, and a genuine string
|
|
1719
|
+
* `"null"` stays the string (the wart the native wire was adopted to fix).
|
|
1720
|
+
*
|
|
1721
|
+
* On the legacy string wire a missing value, JSON null, AND the string `"null"`
|
|
1722
|
+
* all render the cell `"null"`; the group's `min(json_type(…))` discriminator is
|
|
1723
|
+
* `"string"` ONLY for the string-`"null"` group, so the cell is the string
|
|
1724
|
+
* `"null"` iff the discriminator is `"string"`, else `null`. Other cell values
|
|
1725
|
+
* pass through as the extracted string.
|
|
1726
|
+
*/
|
|
1727
|
+
function decodeGroupKeyCell(cell, discrim, native) {
|
|
1728
|
+
if (native)
|
|
1729
|
+
return nativeJsonKeyText(cell);
|
|
1730
|
+
if (cell == null)
|
|
1731
|
+
return null;
|
|
1732
|
+
if (cell === 'null')
|
|
1733
|
+
return discrim === 'string' ? 'null' : null;
|
|
1734
|
+
return cell;
|
|
1735
|
+
}
|
|
1736
|
+
/**
|
|
1737
|
+
* Render a native-wire JSON group-key cell to the extracted-text shape the other
|
|
1738
|
+
* transports return (PG `#>>` / embedded legacy / SQL all give text keys). Keeps
|
|
1739
|
+
* `null` as `null`; a scalar (`bigint`/`number`/`boolean`/`string`) becomes its
|
|
1740
|
+
* string form; an object/array json sub-document is stringified (best-effort
|
|
1741
|
+
* parity: canonical byte-for-byte matching is not guaranteed for nested docs).
|
|
1742
|
+
*/
|
|
1743
|
+
function nativeJsonKeyText(cell) {
|
|
1744
|
+
if (cell === null || cell === undefined)
|
|
1745
|
+
return null;
|
|
1746
|
+
if (typeof cell === 'bigint')
|
|
1747
|
+
return cell.toString();
|
|
1748
|
+
if (typeof cell === 'number' || typeof cell === 'boolean')
|
|
1749
|
+
return String(cell);
|
|
1750
|
+
if (typeof cell === 'string')
|
|
1751
|
+
return cell;
|
|
1752
|
+
try {
|
|
1753
|
+
return JSON.stringify(cell);
|
|
1754
|
+
}
|
|
1755
|
+
catch {
|
|
1756
|
+
return String(cell);
|
|
1757
|
+
}
|
|
1758
|
+
}
|