turbine-orm 0.33.0 → 0.35.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/cjs/client.js +26 -4
- package/dist/cjs/dialect.js +1 -0
- package/dist/cjs/errors.js +41 -1
- package/dist/cjs/index-advisor.js +0 -0
- package/dist/cjs/index.js +4 -2
- package/dist/cjs/mssql.js +5 -0
- package/dist/cjs/mysql.js +4 -0
- package/dist/cjs/optional-peer-import.cjs +28 -0
- package/dist/cjs/powdb-introspect.js +222 -0
- package/dist/cjs/powdb.js +592 -72
- package/dist/cjs/powql.js +998 -134
- package/dist/cjs/query/builder.js +72 -1
- package/dist/cjs/schema-builder.js +16 -0
- package/dist/cjs/schema-metadata.js +81 -10
- package/dist/cjs/sqlite.js +3 -0
- package/dist/client.d.ts +32 -5
- package/dist/client.js +26 -4
- package/dist/dialect.d.ts +13 -0
- package/dist/dialect.js +1 -0
- package/dist/errors.d.ts +36 -0
- package/dist/errors.js +39 -0
- package/dist/index-advisor.d.ts +15 -1
- package/dist/index-advisor.js +0 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.js +2 -2
- package/dist/mssql.js +5 -0
- package/dist/mysql.js +4 -0
- package/dist/optional-peer-import.cjs +28 -0
- package/dist/optional-peer-import.d.cts +19 -0
- package/dist/powdb-introspect.d.ts +84 -0
- package/dist/powdb-introspect.js +219 -0
- package/dist/powdb.d.ts +361 -19
- package/dist/powdb.js +585 -72
- package/dist/powql.d.ts +245 -8
- package/dist/powql.js +1001 -137
- package/dist/query/builder.d.ts +36 -1
- package/dist/query/builder.js +72 -1
- package/dist/query/deferred.d.ts +6 -2
- package/dist/query/types.d.ts +49 -12
- package/dist/schema-builder.d.ts +46 -1
- package/dist/schema-builder.js +15 -0
- package/dist/schema-metadata.d.ts +13 -7
- package/dist/schema-metadata.js +82 -11
- package/dist/schema.d.ts +25 -0
- package/dist/sqlite.js +3 -0
- package/package.json +3 -3
package/dist/powql.js
CHANGED
|
@@ -35,10 +35,10 @@
|
|
|
35
35
|
* @module
|
|
36
36
|
*/
|
|
37
37
|
import { randomUUID } from 'node:crypto';
|
|
38
|
-
import { NotFoundError, TimeoutError, UnsupportedFeatureError, ValidationError } from './errors.js';
|
|
38
|
+
import { NotFoundError, ReadOnlyError, 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,38 @@ 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
|
+
'explain',
|
|
66
|
+
]);
|
|
67
|
+
/**
|
|
68
|
+
* Mutating actions the {@link PowqlInterface} readonly guard refuses locally
|
|
69
|
+
* (before the wire) on a read-only pool. A transaction-control `begin` is
|
|
70
|
+
* guarded separately in {@link PowqlInterface.runInImplicitTx}. Kept keyed on
|
|
71
|
+
* the per-call action string (never `this`-state) so a concurrent read can
|
|
72
|
+
* never be mistaken for one of these.
|
|
73
|
+
*/
|
|
74
|
+
const POWQL_WRITE_ACTIONS = new Set([
|
|
75
|
+
'create',
|
|
76
|
+
'createMany',
|
|
77
|
+
'update',
|
|
78
|
+
'updateMany',
|
|
79
|
+
'delete',
|
|
80
|
+
'deleteMany',
|
|
81
|
+
'upsert',
|
|
82
|
+
]);
|
|
51
83
|
/** Operator keys recognised inside a `WhereOperator` object. */
|
|
52
84
|
const OPERATOR_KEYS = new Set([
|
|
53
85
|
'equals',
|
|
@@ -93,7 +125,6 @@ export class PowqlInterface {
|
|
|
93
125
|
defaultLimit;
|
|
94
126
|
warnOnUnlimited;
|
|
95
127
|
onQuery;
|
|
96
|
-
currentAction = 'raw';
|
|
97
128
|
warnedUnlimited = false;
|
|
98
129
|
constructor(pool, table, schema, middlewares = [], options = {}) {
|
|
99
130
|
this.pool = pool;
|
|
@@ -131,9 +162,19 @@ export class PowqlInterface {
|
|
|
131
162
|
}
|
|
132
163
|
return col;
|
|
133
164
|
}
|
|
134
|
-
/**
|
|
135
|
-
|
|
136
|
-
|
|
165
|
+
/**
|
|
166
|
+
* PowQL column reference for a field. Unqualified it is a dotted field
|
|
167
|
+
* reference (`.snake_name`), which bypasses keyword lookup. When an `alias`
|
|
168
|
+
* is supplied (the F2 join path) it is qualified (`alias.snake_name`) and the
|
|
169
|
+
* column name is backtick-quoted if it is a reserved word (a qualified
|
|
170
|
+
* `p.order` does NOT bypass keyword lookup, unlike the dotted `.order`).
|
|
171
|
+
*/
|
|
172
|
+
ref(field, alias) {
|
|
173
|
+
return this.colRefName(this.column(field).name, alias);
|
|
174
|
+
}
|
|
175
|
+
/** Render a raw column name as a PowQL reference, qualified with `alias` when given. */
|
|
176
|
+
colRefName(name, alias) {
|
|
177
|
+
return alias ? `${alias}.${quotePowqlIdent(name)}` : `.${name}`;
|
|
137
178
|
}
|
|
138
179
|
/**
|
|
139
180
|
* Push a value into the param array and return its `$N` placeholder. When the
|
|
@@ -144,7 +185,17 @@ export class PowqlInterface {
|
|
|
144
185
|
* {@link toPowdbParam}, so the wire param is unchanged.
|
|
145
186
|
*/
|
|
146
187
|
param(value, params, col) {
|
|
147
|
-
|
|
188
|
+
let tagged = value;
|
|
189
|
+
if (col && typeof value === 'number' && this.isFloatCol(col)) {
|
|
190
|
+
// Float column: force a float-form literal even for an integer value.
|
|
191
|
+
tagged = new PowdbFloatParam(value);
|
|
192
|
+
}
|
|
193
|
+
else if (col && value !== null && typeof value === 'object' && !(value instanceof Date) && isJsonColumn(col)) {
|
|
194
|
+
// json document column: a JS object/array is serialized to canonical JSON
|
|
195
|
+
// text and stored as a json document (a JS string passes through raw, same
|
|
196
|
+
// contract as pg jsonb; `null` stays `null`).
|
|
197
|
+
tagged = new PowdbJsonParam(value);
|
|
198
|
+
}
|
|
148
199
|
params.push(tagged);
|
|
149
200
|
return `$${params.length}`;
|
|
150
201
|
}
|
|
@@ -172,6 +223,15 @@ export class PowqlInterface {
|
|
|
172
223
|
return false;
|
|
173
224
|
}
|
|
174
225
|
}
|
|
226
|
+
/**
|
|
227
|
+
* The bound pool's {@link PowdbCapabilities}. Falls back to the trusted-caller
|
|
228
|
+
* default (all feature gates on, `nativeRaw` off) when a directly-constructed
|
|
229
|
+
* pool did not carry them, matching {@link PowdbPool}'s own constructor
|
|
230
|
+
* default so a hand-built test pool never crashes the version gates.
|
|
231
|
+
*/
|
|
232
|
+
get capabilities() {
|
|
233
|
+
return this.pool.capabilities ?? ALL_POWDB_CAPABILITIES;
|
|
234
|
+
}
|
|
175
235
|
/** A predicate that is always false — the empty-`in` / contradiction sentinel. */
|
|
176
236
|
alwaysFalse() {
|
|
177
237
|
const pk = this.meta.primaryKey[0] ?? this.meta.columns[0]?.name;
|
|
@@ -183,8 +243,15 @@ export class PowqlInterface {
|
|
|
183
243
|
/**
|
|
184
244
|
* Compile a {@link WhereClause} into a PowQL filter expression, pushing every
|
|
185
245
|
* value as a positional `$N` param. Returns `''` when there are no conditions.
|
|
246
|
+
*
|
|
247
|
+
* When `alias` is supplied (the F2 native-join path) every field reference is
|
|
248
|
+
* qualified with it (`.col` → `alias.col`, JSON path bases too); params bind
|
|
249
|
+
* exactly as in the unqualified path. The caller only ever passes an alias for
|
|
250
|
+
* an already-RESOLVED where (relation filters pre-resolved to literal in-lists
|
|
251
|
+
* by {@link resolveRelationFilters}): the relation-key branch below still
|
|
252
|
+
* throws, so an unresolved relation filter can never leak into a join.
|
|
186
253
|
*/
|
|
187
|
-
buildWhere(where, params) {
|
|
254
|
+
buildWhere(where, params, alias) {
|
|
188
255
|
if (!where)
|
|
189
256
|
return '';
|
|
190
257
|
const parts = [];
|
|
@@ -192,17 +259,17 @@ export class PowqlInterface {
|
|
|
192
259
|
if (value === undefined)
|
|
193
260
|
continue;
|
|
194
261
|
if (key === 'AND') {
|
|
195
|
-
const sub = value.map((w) => this.buildWhere(w, params)).filter(Boolean);
|
|
262
|
+
const sub = value.map((w) => this.buildWhere(w, params, alias)).filter(Boolean);
|
|
196
263
|
if (sub.length)
|
|
197
264
|
parts.push(`(${sub.join(' and ')})`);
|
|
198
265
|
}
|
|
199
266
|
else if (key === 'OR') {
|
|
200
|
-
const sub = value.map((w) => this.buildWhere(w, params)).filter(Boolean);
|
|
267
|
+
const sub = value.map((w) => this.buildWhere(w, params, alias)).filter(Boolean);
|
|
201
268
|
if (sub.length)
|
|
202
269
|
parts.push(`(${sub.join(' or ')})`);
|
|
203
270
|
}
|
|
204
271
|
else if (key === 'NOT') {
|
|
205
|
-
const sub = this.buildWhere(value, params);
|
|
272
|
+
const sub = this.buildWhere(value, params, alias);
|
|
206
273
|
if (sub)
|
|
207
274
|
parts.push(`not (${sub})`);
|
|
208
275
|
}
|
|
@@ -213,20 +280,33 @@ export class PowqlInterface {
|
|
|
213
280
|
throw new ValidationError(`[turbine] internal: relation filter "${key}" reached buildWhere unresolved (missing resolveRelationFilters()).`);
|
|
214
281
|
}
|
|
215
282
|
else {
|
|
216
|
-
|
|
283
|
+
// A JsonFilter (or a bare `{ path }`) can compile to zero clauses; skip
|
|
284
|
+
// empty results so buildWhere never emits a dangling ` and `.
|
|
285
|
+
const cond = this.buildFieldCondition(key, value, params, alias);
|
|
286
|
+
if (cond)
|
|
287
|
+
parts.push(cond);
|
|
217
288
|
}
|
|
218
289
|
}
|
|
219
290
|
return parts.join(' and ');
|
|
220
291
|
}
|
|
221
292
|
/** Build a single `field: value | operator` condition. */
|
|
222
|
-
buildFieldCondition(field, value, params) {
|
|
223
|
-
const
|
|
293
|
+
buildFieldCondition(field, value, params, alias) {
|
|
294
|
+
const colMeta = this.column(field);
|
|
295
|
+
const ref = this.ref(field, alias);
|
|
224
296
|
if (value === null)
|
|
225
297
|
return `${ref} is null`;
|
|
226
298
|
if (value instanceof Date || typeof value !== 'object') {
|
|
227
|
-
return `${ref} = ${this.param(value, params)}`;
|
|
299
|
+
return `${ref} = ${this.param(value, params, colMeta)}`;
|
|
228
300
|
}
|
|
229
301
|
const op = value;
|
|
302
|
+
// JSON path / key filters on a json document column compile to PowQL `->`
|
|
303
|
+
// path filters (≥ 0.12). `isJsonFilter` matches `path`/`equals`/`contains`/
|
|
304
|
+
// `hasKey`; on a NON-json column those fall through to the scalar operator
|
|
305
|
+
// path below (e.g. `equals` stays a plain equality), exactly like SQL.
|
|
306
|
+
if (isJsonColumn(colMeta) && isJsonFilter(value)) {
|
|
307
|
+
requireCapability(this.capabilities, 'jsonDocs', 'JSON path filters');
|
|
308
|
+
return this.buildJsonPathCondition(colMeta, value, params, alias);
|
|
309
|
+
}
|
|
230
310
|
rejectUnsupportedFilter(op, field);
|
|
231
311
|
if (!Object.keys(op).some((k) => OPERATOR_KEYS.has(k))) {
|
|
232
312
|
// A bare object that is not an operator set — equality by value.
|
|
@@ -278,6 +358,100 @@ export class PowqlInterface {
|
|
|
278
358
|
}
|
|
279
359
|
return conds.length > 1 ? `(${conds.join(' and ')})` : (conds[0] ?? this.alwaysFalse());
|
|
280
360
|
}
|
|
361
|
+
/**
|
|
362
|
+
* PowQL JSON path expression `.col->$a->$b…`, binding EVERY path segment as a
|
|
363
|
+
* positional param (a string segment as a `str` token, an integer index as an
|
|
364
|
+
* `int` token). `->` binds tighter than every operator, so no parens are
|
|
365
|
+
* needed around the path in a comparison. Segments are bound (never inlined)
|
|
366
|
+
* to keep {@link materializePowql}'s `$N`-scan invariant intact: a segment
|
|
367
|
+
* that literally contained `$1` would otherwise be rewritten. Shared by the
|
|
368
|
+
* F1 where-filter path and the F2 orderBy / groupBy path emitters.
|
|
369
|
+
*
|
|
370
|
+
* A digit-only STRING segment (`'0'`) binds as an `int` array index, matching
|
|
371
|
+
* the SQL engines: `JsonFilter.path` is typed `string[]`, so an array index
|
|
372
|
+
* can only be expressed as a digit string, and the SQL builder converts it the
|
|
373
|
+
* same way (`/^\d+$/ → [n]`, query/builder.ts). Without this, PowDB's typed
|
|
374
|
+
* `->` treats `'0'` as a string KEY and silently matches nothing on an array
|
|
375
|
+
* (a wrong result, not an error). Same object-key-`'0'` caveat SQL accepts: a
|
|
376
|
+
* json object whose key is literally `"0"` is addressed as an array index.
|
|
377
|
+
*/
|
|
378
|
+
jsonPathExpr(col, path, params, alias) {
|
|
379
|
+
let expr = this.colRefName(col.name, alias);
|
|
380
|
+
for (const seg of path) {
|
|
381
|
+
const bound = typeof seg === 'string' && /^\d+$/.test(seg) ? Number(seg) : seg;
|
|
382
|
+
expr += `->${this.param(bound, params)}`;
|
|
383
|
+
}
|
|
384
|
+
return expr;
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* Compile a {@link JsonFilter} on a json document column into a PowQL filter
|
|
388
|
+
* (≥ 0.12). Operators PowQL cannot express EXACTLY throw a per-operator E017
|
|
389
|
+
* (never a wrong result): containment (`contains`, and `equals` without a
|
|
390
|
+
* `path`) has no PowQL operator. The mapped shapes:
|
|
391
|
+
* - `{ path, equals: v }` → `P = $n` (typed: string→str, bool→bool,
|
|
392
|
+
* integral number→int, fractional→float; NOT stringified)
|
|
393
|
+
* - `{ path, equals: null }` → `P is null` (matches JSON null AND a missing
|
|
394
|
+
* key, a deliberate divergence from the PG driver, documented on
|
|
395
|
+
* {@link JsonFilter})
|
|
396
|
+
* - `{ path, gt|gte|lt|lte: v }` → `P > $n` … (range ops require `path`; the
|
|
397
|
+
* engine coerces int/float numerically)
|
|
398
|
+
* - `{ hasKey: k }` → `json_type(.col->$n) is not null` (top-level key test,
|
|
399
|
+
* ignoring `path`, mirroring PG `col ? key`; includes keys holding JSON
|
|
400
|
+
* null)
|
|
401
|
+
* A bare `{ path }` with no operators compiles to zero clauses (byte-parity
|
|
402
|
+
* with SQL), so a mutation whose only `where` is a bare `{ path }` is refused
|
|
403
|
+
* by the empty-where guard.
|
|
404
|
+
*/
|
|
405
|
+
buildJsonPathCondition(col, filter, params, alias) {
|
|
406
|
+
const conds = [];
|
|
407
|
+
// Bind the path segments at most once and reuse the expression string across
|
|
408
|
+
// equals + range comparisons (they share the same `path`).
|
|
409
|
+
let pathExpr = null;
|
|
410
|
+
const pathP = () => {
|
|
411
|
+
pathExpr ??= this.jsonPathExpr(col, filter.path, params, alias);
|
|
412
|
+
return pathExpr;
|
|
413
|
+
};
|
|
414
|
+
if (filter.contains !== undefined) {
|
|
415
|
+
throw new UnsupportedFeatureError('JSON containment filters (contains)', 'PowDB', `column "${col.name}": PowQL has no JSON containment operator`);
|
|
416
|
+
}
|
|
417
|
+
if (filter.equals !== undefined) {
|
|
418
|
+
if (filter.path === undefined || filter.path.length === 0) {
|
|
419
|
+
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`);
|
|
420
|
+
}
|
|
421
|
+
conds.push(filter.equals === null ? `${pathP()} is null` : `${pathP()} = ${this.param(filter.equals, params)}`);
|
|
422
|
+
}
|
|
423
|
+
if (filter.hasKey !== undefined) {
|
|
424
|
+
// Top-level key existence, independent of `path` (mirrors PG `col ? key`).
|
|
425
|
+
conds.push(`json_type(${this.colRefName(col.name, alias)}->${this.param(filter.hasKey, params)}) is not null`);
|
|
426
|
+
}
|
|
427
|
+
// Range comparisons on the extracted path, in the fixed gt/gte/lt/lte order.
|
|
428
|
+
// Same validation as SQL `jsonRangeEntries`: `path` required, value a finite
|
|
429
|
+
// number or a string.
|
|
430
|
+
for (const [op, powOp] of [
|
|
431
|
+
['gt', '>'],
|
|
432
|
+
['gte', '>='],
|
|
433
|
+
['lt', '<'],
|
|
434
|
+
['lte', '<='],
|
|
435
|
+
]) {
|
|
436
|
+
const v = filter[op];
|
|
437
|
+
if (v === undefined)
|
|
438
|
+
continue;
|
|
439
|
+
if (filter.path === undefined) {
|
|
440
|
+
throw new ValidationError(`[turbine] JSON range operator '${op}' on ${col.name} requires a \`path\` ` +
|
|
441
|
+
`(e.g. { path: ['meta', 'score'], ${op}: ${JSON.stringify(v)} }).`);
|
|
442
|
+
}
|
|
443
|
+
if (typeof v !== 'number' && typeof v !== 'string') {
|
|
444
|
+
throw new ValidationError(`[turbine] JSON range operator '${op}' on ${col.name} requires a number or string, got ${JSON.stringify(v)}.`);
|
|
445
|
+
}
|
|
446
|
+
if (typeof v === 'number' && !Number.isFinite(v)) {
|
|
447
|
+
throw new ValidationError(`[turbine] JSON range operator '${op}' on ${col.name} requires a finite number.`);
|
|
448
|
+
}
|
|
449
|
+
conds.push(`${pathP()} ${powOp} ${this.param(v, params)}`);
|
|
450
|
+
}
|
|
451
|
+
if (!conds.length)
|
|
452
|
+
return '';
|
|
453
|
+
return conds.length > 1 ? `(${conds.join(' and ')})` : conds[0];
|
|
454
|
+
}
|
|
281
455
|
/** Bind a value, lowercasing for case-insensitive comparisons. */
|
|
282
456
|
bind(value, params, insensitive) {
|
|
283
457
|
const ph = this.param(value, params);
|
|
@@ -419,7 +593,7 @@ export class PowqlInterface {
|
|
|
419
593
|
const chunk = targetPks.slice(i, i + MAX_RELATION_KEYS);
|
|
420
594
|
const params = [];
|
|
421
595
|
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);
|
|
596
|
+
const { rows } = await this.exec(`${quotePowqlIdent(through.table)} filter .${targetJCol} in (${ph}) { .${sourceJCol} }`, params, timeout, 'findMany');
|
|
423
597
|
for (const r of rows) {
|
|
424
598
|
const v = r[sourceJCol];
|
|
425
599
|
if (v != null)
|
|
@@ -467,8 +641,20 @@ export class PowqlInterface {
|
|
|
467
641
|
projection(cols) {
|
|
468
642
|
return `{ ${cols.map((c) => `.${c}`).join(', ')} }`;
|
|
469
643
|
}
|
|
470
|
-
/**
|
|
471
|
-
|
|
644
|
+
/**
|
|
645
|
+
* `order .c1 asc, .c2 desc` clause (empty string when no orderBy). Supports,
|
|
646
|
+
* besides a plain direction:
|
|
647
|
+
* - {@link JsonPathOrderBy} on a json column (≥ 0.12): `{ data: { path: […],
|
|
648
|
+
* type?, direction? } }` → `order .data->$n asc` (or
|
|
649
|
+
* `cast(.data->$n, "float")` for `type: 'numeric'`);
|
|
650
|
+
* - {@link OrderBySpec} `{ sort, nulls }`: `nulls: 'last'` is accepted as a
|
|
651
|
+
* no-op (PowDB is always nulls-last), `nulls: 'first'` throws E017.
|
|
652
|
+
*
|
|
653
|
+
* PowDB orders missing / JSON-null keys LAST in BOTH directions (an engine
|
|
654
|
+
* contract): for identical cross-engine results pass `nulls: 'last'`
|
|
655
|
+
* explicitly on Postgres, which defaults nulls-first for `desc`.
|
|
656
|
+
*/
|
|
657
|
+
buildOrder(orderBy, params, alias) {
|
|
472
658
|
if (!orderBy)
|
|
473
659
|
return '';
|
|
474
660
|
const keys = Object.entries(orderBy).filter(([, dir]) => dir !== undefined);
|
|
@@ -476,32 +662,97 @@ export class PowqlInterface {
|
|
|
476
662
|
return '';
|
|
477
663
|
const parts = keys.map(([field, dir]) => {
|
|
478
664
|
if (dir && typeof dir === 'object') {
|
|
665
|
+
const o = dir;
|
|
666
|
+
// JSON-path ordering on a json column.
|
|
667
|
+
if (Array.isArray(o.path)) {
|
|
668
|
+
return this.buildJsonPathOrder(field, dir, params, alias);
|
|
669
|
+
}
|
|
670
|
+
// OrderBySpec { sort, nulls }: accept nulls-last (PowDB default), refuse
|
|
671
|
+
// nulls-first (no placement grammar). Distinct from vector/pick/_count.
|
|
672
|
+
if ('sort' in o && !('distance' in o) && !('_count' in o) && !isRelationPickOrderBy(dir)) {
|
|
673
|
+
const spec = dir;
|
|
674
|
+
if (spec.nulls === 'first') {
|
|
675
|
+
throw new UnsupportedFeatureError('NULLS FIRST placement', 'PowDB', `field "${field}": PowDB orders NULLs / missing keys LAST in both directions`);
|
|
676
|
+
}
|
|
677
|
+
return `${this.ref(field, alias)} ${spec.sort === 'desc' ? 'desc' : 'asc'}`;
|
|
678
|
+
}
|
|
479
679
|
// Name the actual feature in the refusal — a pick-row ordering
|
|
480
680
|
// reported as "vector / distance ordering" sends users hunting for
|
|
481
|
-
// pgvector docs.
|
|
482
|
-
const o = dir;
|
|
681
|
+
// pgvector docs. Everything else stays E017 on PowDB.
|
|
483
682
|
const feature = isRelationPickOrderBy(dir)
|
|
484
683
|
? 'relation pick-row ordering'
|
|
485
684
|
: 'distance' in o
|
|
486
685
|
? 'vector / distance ordering'
|
|
487
|
-
:
|
|
488
|
-
? '
|
|
489
|
-
: '
|
|
490
|
-
? '
|
|
491
|
-
: '
|
|
492
|
-
? 'NULLS placement / sort-spec ordering'
|
|
493
|
-
: 'object-valued ordering';
|
|
686
|
+
: '_count' in o
|
|
687
|
+
? 'relation _count ordering'
|
|
688
|
+
: 'nulls' in o
|
|
689
|
+
? 'NULLS placement / sort-spec ordering'
|
|
690
|
+
: 'object-valued ordering';
|
|
494
691
|
throw new UnsupportedFeatureError(feature, 'PowDB', `field "${field}"`);
|
|
495
692
|
}
|
|
496
|
-
return `${this.ref(field)} ${dir === 'desc' ? 'desc' : 'asc'}`;
|
|
693
|
+
return `${this.ref(field, alias)} ${dir === 'desc' ? 'desc' : 'asc'}`;
|
|
497
694
|
});
|
|
498
695
|
return ` order ${parts.join(', ')}`;
|
|
499
696
|
}
|
|
697
|
+
/** Compile one {@link JsonPathOrderBy} entry to `order .col->$n asc` (+ optional numeric cast). */
|
|
698
|
+
buildJsonPathOrder(field, spec, params, alias) {
|
|
699
|
+
const col = this.column(field);
|
|
700
|
+
if (!isJsonColumn(col)) {
|
|
701
|
+
throw new UnsupportedFeatureError('JSON-path ordering', 'PowDB', `field "${field}" is not a json column`);
|
|
702
|
+
}
|
|
703
|
+
requireCapability(this.capabilities, 'jsonDocs', 'JSON path ordering');
|
|
704
|
+
if (spec.nulls === 'first') {
|
|
705
|
+
throw new UnsupportedFeatureError('NULLS FIRST placement', 'PowDB', `field "${field}": PowDB orders missing / JSON-null keys LAST in both directions`);
|
|
706
|
+
}
|
|
707
|
+
const pathExpr = this.jsonPathExpr(col, spec.path, params, alias);
|
|
708
|
+
// `type: 'numeric'` casts a JSON STRING number for numeric ordering; native
|
|
709
|
+
// JSON numbers already order numerically without a cast.
|
|
710
|
+
const expr = spec.type === 'numeric' ? `cast(${pathExpr}, "float")` : pathExpr;
|
|
711
|
+
return `${expr} ${spec.direction === 'desc' ? 'desc' : 'asc'}`;
|
|
712
|
+
}
|
|
500
713
|
// -------------------------------------------------------------------------
|
|
501
714
|
// Execution plumbing
|
|
502
715
|
// -------------------------------------------------------------------------
|
|
503
|
-
/**
|
|
504
|
-
|
|
716
|
+
/**
|
|
717
|
+
* Run PowQL with optional timeout, emitting a query event either way. The
|
|
718
|
+
* `action` is passed PER CALL (never read from shared instance state) so the
|
|
719
|
+
* retry-eligibility and the emitted event action stay correct even when a
|
|
720
|
+
* concurrent operation runs on the same cached interface: a WRITE statement
|
|
721
|
+
* carries a write action and can therefore never be mistaken for a replayable
|
|
722
|
+
* read. Read statements pass a read-shaped action from {@link POWQL_READ_ACTIONS}.
|
|
723
|
+
*/
|
|
724
|
+
async exec(powql, params, timeout, action = 'raw') {
|
|
725
|
+
return this.execOnce(powql, params, timeout, action, false);
|
|
726
|
+
}
|
|
727
|
+
/** Build the E018 refusal for a write / `begin` on a read-only pool. */
|
|
728
|
+
readOnlyError(operation) {
|
|
729
|
+
// Pass a clean detail: the ReadOnlyError constructor owns both the
|
|
730
|
+
// `[turbine] ` prefix and the "Route writes to a writable primary." hint,
|
|
731
|
+
// so adding either here would double them.
|
|
732
|
+
return new ReadOnlyError(`${operation} on "${this.table}" refused: this PowDB connection is read-only.`);
|
|
733
|
+
}
|
|
734
|
+
/**
|
|
735
|
+
* Execute one statement, with the opt-in single stale-frame READ replay. When
|
|
736
|
+
* `retryStaleReads` is on and a first-statement READ fails with the stale-wire
|
|
737
|
+
* {@link isStaleFramePowdbError} ConnectionError (a socket idle-gap "received
|
|
738
|
+
* unexpected frame" that the client cannot recover), the statement is retried
|
|
739
|
+
* exactly once on a fresh pooled connection (the broken one was destroyed).
|
|
740
|
+
* The replay is refused for writes (an ambiguous mutation reply is unsafe to
|
|
741
|
+
* replay) and inside a transaction (a mid-tx statement cannot move connection),
|
|
742
|
+
* so only the read-shaped actions in {@link POWQL_READ_ACTIONS}, outside a
|
|
743
|
+
* `_txScoped` interface, are eligible. `action` is a per-call argument (never
|
|
744
|
+
* `this`-state), so a concurrent op flipping instance fields cannot turn a
|
|
745
|
+
* write into a retryable read.
|
|
746
|
+
*/
|
|
747
|
+
async execOnce(powql, params, timeout, action, isRetry) {
|
|
748
|
+
// Read-only pool guard: refuse a write action locally, before the wire, so a
|
|
749
|
+
// read-only target never even attempts the mutation (the engine refusal, if
|
|
750
|
+
// any, is only the backstop for raw/injected paths). `action` is per-call,
|
|
751
|
+
// so a concurrent read is never mistaken for a write. Reads (incl. explain)
|
|
752
|
+
// and non-classified `raw` fall through unchanged.
|
|
753
|
+
if (this.pool.readonly === true && POWQL_WRITE_ACTIONS.has(action)) {
|
|
754
|
+
throw this.readOnlyError(action);
|
|
755
|
+
}
|
|
505
756
|
const start = performance.now();
|
|
506
757
|
const run = this.pool.query(powql, params);
|
|
507
758
|
try {
|
|
@@ -511,15 +762,37 @@ export class PowqlInterface {
|
|
|
511
762
|
new Promise((_, reject) => setTimeout(() => reject(new TimeoutError(timeout)), timeout)),
|
|
512
763
|
])
|
|
513
764
|
: await run;
|
|
514
|
-
this.emit(powql, params, performance.now() - start, result.rowCount ?? result.rows.length);
|
|
515
|
-
|
|
765
|
+
this.emit(action, powql, params, performance.now() - start, result.rowCount ?? result.rows.length);
|
|
766
|
+
// The pool tags each result with the wire that actually served it
|
|
767
|
+
// (adaptResult → false, adaptNativeResult → true). A heterogeneous
|
|
768
|
+
// injected pool can fall back to the legacy wire per call, so read the
|
|
769
|
+
// per-result flag and only fall back to the pool-level capability when a
|
|
770
|
+
// hand-built pool (tests) omits the tag; never coerce legacy rows with
|
|
771
|
+
// the native policy just because the pool reports nativeRaw.
|
|
772
|
+
const native = result.native ?? Boolean(this.capabilities.nativeRaw);
|
|
773
|
+
return { rows: result.rows, rowCount: result.rowCount ?? result.rows.length, native };
|
|
516
774
|
}
|
|
517
775
|
catch (err) {
|
|
518
|
-
|
|
776
|
+
if (!isRetry && this.shouldRetryStaleRead(err, action)) {
|
|
777
|
+
// Transparent single replay on a fresh connection; the first (swallowed)
|
|
778
|
+
// failure is not emitted, only the retried outcome is observed.
|
|
779
|
+
return this.execOnce(powql, params, timeout, action, true);
|
|
780
|
+
}
|
|
781
|
+
this.emit(action, powql, params, performance.now() - start, 0, err);
|
|
519
782
|
throw err;
|
|
520
783
|
}
|
|
521
784
|
}
|
|
522
|
-
|
|
785
|
+
/** Is `err` a replayable stale-frame failure for THIS (per-call) read-shaped, non-tx action? */
|
|
786
|
+
shouldRetryStaleRead(err, action) {
|
|
787
|
+
if (!this.pool.retryStaleReads)
|
|
788
|
+
return false;
|
|
789
|
+
if (this.isTxScoped())
|
|
790
|
+
return false;
|
|
791
|
+
if (!POWQL_READ_ACTIONS.has(action))
|
|
792
|
+
return false;
|
|
793
|
+
return isStaleFramePowdbError(err);
|
|
794
|
+
}
|
|
795
|
+
emit(action, sql, params, duration, rows, error) {
|
|
523
796
|
if (!this.onQuery)
|
|
524
797
|
return;
|
|
525
798
|
try {
|
|
@@ -528,7 +801,7 @@ export class PowqlInterface {
|
|
|
528
801
|
params,
|
|
529
802
|
duration,
|
|
530
803
|
model: this.table,
|
|
531
|
-
action
|
|
804
|
+
action,
|
|
532
805
|
rows,
|
|
533
806
|
timestamp: new Date(),
|
|
534
807
|
error,
|
|
@@ -540,7 +813,6 @@ export class PowqlInterface {
|
|
|
540
813
|
}
|
|
541
814
|
/** Run a method body through the middleware chain (mirrors QueryInterface). */
|
|
542
815
|
async withMiddleware(action, args, executor) {
|
|
543
|
-
this.currentAction = action;
|
|
544
816
|
if (this.middlewares.length === 0)
|
|
545
817
|
return executor();
|
|
546
818
|
let index = 0;
|
|
@@ -551,34 +823,48 @@ export class PowqlInterface {
|
|
|
551
823
|
};
|
|
552
824
|
return next({ model: this.table, action, args: { ...args } });
|
|
553
825
|
}
|
|
554
|
-
/** Map raw rows to typed entities.
|
|
555
|
-
|
|
556
|
-
|
|
826
|
+
/** Map raw rows to typed entities. `native` is the wire that ACTUALLY served
|
|
827
|
+
* this result (threaded from {@link execOnce}, not the pool-level capability),
|
|
828
|
+
* so cells that arrived pre-typed over `queryNativeRaw` (F3) skip the legacy
|
|
829
|
+
* string coercion (a genuine str `"null"` stays `"null"` instead of collapsing
|
|
830
|
+
* to null) while a per-call legacy fallback on a native-capable pool still
|
|
831
|
+
* coerces its string cells correctly. Defaults to the pool capability for the
|
|
832
|
+
* rare caller with no per-result flag (hand-built test pools). */
|
|
833
|
+
shape(rows, native = Boolean(this.capabilities.nativeRaw)) {
|
|
834
|
+
return rows.map((r) => rowToEntity(r, this.meta, native));
|
|
557
835
|
}
|
|
558
836
|
// -------------------------------------------------------------------------
|
|
559
837
|
// Reads
|
|
560
838
|
// -------------------------------------------------------------------------
|
|
561
839
|
async findMany(args = {}) {
|
|
562
840
|
return this.withMiddleware('findMany', args, async () => {
|
|
563
|
-
const rows = await this.runFind(args);
|
|
564
|
-
const entities = this.shape(rows);
|
|
565
|
-
if (args.with)
|
|
566
|
-
await this.loadRelations(entities, args.with, args.timeout
|
|
841
|
+
const { rows, native, resolvedWhere } = await this.runFind(args, 'findMany');
|
|
842
|
+
const entities = this.shape(rows, native);
|
|
843
|
+
if (args.with) {
|
|
844
|
+
await this.loadRelations(entities, args.with, args.timeout, 0, {
|
|
845
|
+
args,
|
|
846
|
+
resolvedWhere,
|
|
847
|
+
});
|
|
848
|
+
}
|
|
567
849
|
return entities;
|
|
568
850
|
});
|
|
569
851
|
}
|
|
570
|
-
/**
|
|
571
|
-
|
|
852
|
+
/**
|
|
853
|
+
* Compile the flat findMany select into PowQL (no execution), pushing values
|
|
854
|
+
* into `params`. Returns the query plus the RESOLVED where (relation filters
|
|
855
|
+
* already collapsed to literal in-lists) so the F2 join path can re-emit the
|
|
856
|
+
* exact parent predicate alias-qualified, and so {@link explain} can wrap it.
|
|
857
|
+
*/
|
|
858
|
+
async buildFind(args, params) {
|
|
572
859
|
if (args.cursor) {
|
|
573
860
|
throw new UnsupportedFeatureError('cursor pagination', 'PowDB', 'use limit/offset instead');
|
|
574
861
|
}
|
|
575
|
-
const params = [];
|
|
576
862
|
const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
|
|
577
863
|
const where = this.buildWhere(resolvedWhere, params);
|
|
578
864
|
const cols = this.projectedColumns(args.select, args.omit);
|
|
579
865
|
const distinct = args.distinct?.length ? ' distinct' : '';
|
|
580
866
|
const filter = where ? ` filter ${where}` : '';
|
|
581
|
-
const order = this.buildOrder(args.orderBy);
|
|
867
|
+
const order = this.buildOrder(args.orderBy, params);
|
|
582
868
|
const limit = args.limit ?? args.take ?? this.defaultLimit;
|
|
583
869
|
if (limit === undefined && this.warnOnUnlimited && !this.warnedUnlimited) {
|
|
584
870
|
this.warnedUnlimited = true;
|
|
@@ -587,15 +873,45 @@ export class PowqlInterface {
|
|
|
587
873
|
const limitClause = limit !== undefined ? ` limit ${this.param(limit, params)}` : '';
|
|
588
874
|
const offsetClause = args.offset ? ` offset ${this.param(args.offset, params)}` : '';
|
|
589
875
|
const powql = `${this.qt}${distinct}${filter}${order}${limitClause}${offsetClause} ${this.projection(cols)}`;
|
|
590
|
-
|
|
591
|
-
|
|
876
|
+
return { powql, resolvedWhere };
|
|
877
|
+
}
|
|
878
|
+
/** Build + run the flat findMany select; returns raw rows, the serving wire, and the resolved where. */
|
|
879
|
+
async runFind(args, action = 'findMany') {
|
|
880
|
+
const params = [];
|
|
881
|
+
const { powql, resolvedWhere } = await this.buildFind(args, params);
|
|
882
|
+
const { rows, native } = await this.exec(powql, params, args.timeout, action);
|
|
883
|
+
return { rows, native, resolvedWhere };
|
|
884
|
+
}
|
|
885
|
+
/**
|
|
886
|
+
* Diagnostic surface: compile the same PowQL {@link findMany} would run for
|
|
887
|
+
* `args` (no cache) and return the engine's plan as one string per line.
|
|
888
|
+
*
|
|
889
|
+
* Runs as a READ (`explain <query>`), so it is safe on a read-only pool and
|
|
890
|
+
* eligible for the stale-read replay. The line content is engine-owned and is
|
|
891
|
+
* NOT covered by semver (match plan node names / tree shape, never exact
|
|
892
|
+
* bytes; mirrors PowDB's own `explain` contract).
|
|
893
|
+
*
|
|
894
|
+
* Does NOT run through the middleware chain: plan text is a diagnostic, not
|
|
895
|
+
* entity rows, and `QueryInterface.explain` deliberately bypasses middleware
|
|
896
|
+
* too, so both engines agree.
|
|
897
|
+
*/
|
|
898
|
+
async explain(args = {}) {
|
|
899
|
+
const params = [];
|
|
900
|
+
const { powql } = await this.buildFind(args, params);
|
|
901
|
+
const { rows } = await this.exec(`explain ${powql}`, params, args.timeout, 'explain');
|
|
902
|
+
return rows
|
|
903
|
+
.map((r) => {
|
|
904
|
+
const line = r.plan ?? Object.values(r)[0];
|
|
905
|
+
return line == null ? '' : String(line);
|
|
906
|
+
})
|
|
907
|
+
.filter((line) => line.length > 0);
|
|
592
908
|
}
|
|
593
909
|
async findUnique(args) {
|
|
594
910
|
return this.withMiddleware('findUnique', args, async () => {
|
|
595
|
-
const rows = await this.runFind({ ...args, limit: 1 });
|
|
911
|
+
const { rows, native } = await this.runFind({ ...args, limit: 1 }, 'findUnique');
|
|
596
912
|
if (!rows.length)
|
|
597
913
|
return null;
|
|
598
|
-
const entities = this.shape(rows);
|
|
914
|
+
const entities = this.shape(rows, native);
|
|
599
915
|
if (args.with)
|
|
600
916
|
await this.loadRelations(entities, args.with, args.timeout);
|
|
601
917
|
return entities[0];
|
|
@@ -603,10 +919,10 @@ export class PowqlInterface {
|
|
|
603
919
|
}
|
|
604
920
|
async findFirst(args = {}) {
|
|
605
921
|
return this.withMiddleware('findFirst', args, async () => {
|
|
606
|
-
const rows = await this.runFind({ ...args, limit: 1 });
|
|
922
|
+
const { rows, native } = await this.runFind({ ...args, limit: 1 }, 'findFirst');
|
|
607
923
|
if (!rows.length)
|
|
608
924
|
return null;
|
|
609
|
-
const entities = this.shape(rows);
|
|
925
|
+
const entities = this.shape(rows, native);
|
|
610
926
|
if (args.with)
|
|
611
927
|
await this.loadRelations(entities, args.with, args.timeout);
|
|
612
928
|
return entities[0];
|
|
@@ -627,19 +943,48 @@ export class PowqlInterface {
|
|
|
627
943
|
// -------------------------------------------------------------------------
|
|
628
944
|
// Nested relations — batched N+1 loaders (hasMany / hasOne / belongsTo)
|
|
629
945
|
// -------------------------------------------------------------------------
|
|
630
|
-
/**
|
|
631
|
-
|
|
946
|
+
/**
|
|
947
|
+
* Load each requested relation for `parents` and attach it onto each row.
|
|
948
|
+
*
|
|
949
|
+
* `parent` is supplied ONLY by the top-level {@link findMany} (its args +
|
|
950
|
+
* resolved where). When the effective `relationLoadStrategy` resolves to an
|
|
951
|
+
* explicit `'join'` and the pool advertises `serverJoins`, an eligible
|
|
952
|
+
* top-level relation is loaded with a native PowQL join instead of the keyed
|
|
953
|
+
* loaders (F2); everything else (nested `with` levels, ineligible shapes, and
|
|
954
|
+
* the default `'batched'` strategy) keeps the loaders. Output is byte-equal
|
|
955
|
+
* either way (the join reuses the same stitch / shape helpers).
|
|
956
|
+
*/
|
|
957
|
+
async loadRelations(parents, withClause, timeout, depth = 0, parent) {
|
|
632
958
|
if (depth >= 10) {
|
|
633
959
|
throw new ValidationError(`[turbine] Nested 'with' on PowDB exceeded depth 10 (relation cycle?).`);
|
|
634
960
|
}
|
|
635
961
|
if (!parents.length)
|
|
636
962
|
return;
|
|
963
|
+
// The resolved strategy is 'join' only for an EXPLICIT 'join' (per-query arg
|
|
964
|
+
// or a client config the user set). The serverJoins capability is consulted
|
|
965
|
+
// PER RELATION below, AFTER joinEligible, so a relation that would have
|
|
966
|
+
// fallen back to the loaders anyway (paged parent, nested `with`, composite
|
|
967
|
+
// key, …) never triggers the capability's E017.
|
|
968
|
+
const strategyIsJoin = parent ? this.resolveStrategy(parent.args) === 'join' : false;
|
|
637
969
|
for (const [relName, opt] of Object.entries(withClause)) {
|
|
638
970
|
if (!opt)
|
|
639
971
|
continue;
|
|
640
972
|
const rel = this.meta.relations[relName];
|
|
641
973
|
if (!rel)
|
|
642
974
|
throw new ValidationError(`[turbine] Unknown relation "${relName}" on "${this.table}".`);
|
|
975
|
+
if (strategyIsJoin && parent && this.joinEligible(rel, opt, parent.args, parents.length)) {
|
|
976
|
+
if (this.capabilities.serverJoins) {
|
|
977
|
+
await this.loadRelationViaJoin(parents, rel, relName, opt, parent, timeout);
|
|
978
|
+
continue;
|
|
979
|
+
}
|
|
980
|
+
// An otherwise-eligible relation the engine cannot join: a PER-QUERY
|
|
981
|
+
// `relationLoadStrategy: 'join'` is an explicit request, so throw a typed
|
|
982
|
+
// E017; a CLIENT-LEVEL default silently falls back to the keyed loaders
|
|
983
|
+
// (so pointing an existing app at an older engine keeps working).
|
|
984
|
+
if (parent.args.relationLoadStrategy === 'join') {
|
|
985
|
+
requireCapability(this.capabilities, 'serverJoins', 'native PowQL relation joins');
|
|
986
|
+
}
|
|
987
|
+
}
|
|
643
988
|
if (rel.type === 'manyToMany') {
|
|
644
989
|
await this.loadManyToMany(parents, rel, relName, opt, timeout);
|
|
645
990
|
continue;
|
|
@@ -660,23 +1005,49 @@ export class PowqlInterface {
|
|
|
660
1005
|
const keys = [
|
|
661
1006
|
...new Set(parents.map((p) => p[parentKeyField]).filter((k) => k != null)),
|
|
662
1007
|
];
|
|
663
|
-
|
|
1008
|
+
// The loader buckets children by their correlation column, so that column
|
|
1009
|
+
// MUST be in the fetched projection even when the user's select/omit drops
|
|
1010
|
+
// it. Force it into the fetch here and strip it back off the entities after
|
|
1011
|
+
// stitching (the join path already gets this for free via `__tpk`).
|
|
1012
|
+
const userSelect = options.select;
|
|
1013
|
+
const userOmit = options.omit;
|
|
1014
|
+
const fkProjected = userSelect ? Boolean(userSelect[childKeyField]) : userOmit ? !userOmit[childKeyField] : true;
|
|
1015
|
+
let fetchOptions = options;
|
|
1016
|
+
if (!fkProjected) {
|
|
1017
|
+
if (userSelect) {
|
|
1018
|
+
fetchOptions = {
|
|
1019
|
+
...options,
|
|
1020
|
+
select: { ...userSelect, [childKeyField]: true },
|
|
1021
|
+
};
|
|
1022
|
+
}
|
|
1023
|
+
else if (userOmit) {
|
|
1024
|
+
const omitWithoutFk = { ...userOmit };
|
|
1025
|
+
delete omitWithoutFk[childKeyField];
|
|
1026
|
+
fetchOptions = { ...options, omit: omitWithoutFk };
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
664
1029
|
// Chunk the key set so a single `in (…)` never exceeds PowDB's
|
|
665
|
-
// per-statement param / row limits; merge each chunk's children.
|
|
1030
|
+
// per-statement param / row limits; merge each chunk's children. Keys are
|
|
1031
|
+
// normalized through joinKey (a Date maps to micros, matching the child
|
|
1032
|
+
// cell) so a datetime correlation column stitches instead of silently
|
|
1033
|
+
// returning [].
|
|
1034
|
+
const childByKey = new Map();
|
|
666
1035
|
for (let i = 0; i < keys.length; i += MAX_RELATION_KEYS) {
|
|
667
1036
|
const chunk = keys.slice(i, i + MAX_RELATION_KEYS);
|
|
668
1037
|
const childWhere = {
|
|
669
|
-
...
|
|
1038
|
+
...fetchOptions.where,
|
|
670
1039
|
[childKeyField]: { in: chunk },
|
|
671
1040
|
};
|
|
672
1041
|
const children = (await targetQi.findMany({
|
|
673
|
-
...
|
|
1042
|
+
...fetchOptions,
|
|
674
1043
|
where: childWhere,
|
|
675
1044
|
with: options.with,
|
|
676
1045
|
timeout: options.timeout ?? timeout,
|
|
677
1046
|
}));
|
|
678
1047
|
for (const child of children) {
|
|
679
|
-
const k = child[childKeyField];
|
|
1048
|
+
const k = this.joinKey(child[childKeyField]);
|
|
1049
|
+
if (k == null)
|
|
1050
|
+
continue;
|
|
680
1051
|
const bucket = childByKey.get(k);
|
|
681
1052
|
if (bucket)
|
|
682
1053
|
bucket.push(child);
|
|
@@ -684,10 +1055,18 @@ export class PowqlInterface {
|
|
|
684
1055
|
childByKey.set(k, [child]);
|
|
685
1056
|
}
|
|
686
1057
|
}
|
|
1058
|
+
// Strip the forced correlation column back off if the user excluded it,
|
|
1059
|
+
// so the emitted entities match their select/omit exactly.
|
|
1060
|
+
if (!fkProjected) {
|
|
1061
|
+
for (const bucket of childByKey.values()) {
|
|
1062
|
+
for (const child of bucket)
|
|
1063
|
+
delete child[childKeyField];
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
687
1066
|
const single = rel.type === 'belongsTo' || rel.type === 'hasOne';
|
|
688
1067
|
for (const parent of parents) {
|
|
689
|
-
const k = parent[parentKeyField];
|
|
690
|
-
const matches = childByKey.get(k) ?? [];
|
|
1068
|
+
const k = this.joinKey(parent[parentKeyField]);
|
|
1069
|
+
const matches = (k == null ? undefined : childByKey.get(k)) ?? [];
|
|
691
1070
|
parent[relName] = single ? (matches[0] ?? null) : matches;
|
|
692
1071
|
}
|
|
693
1072
|
}
|
|
@@ -736,7 +1115,7 @@ export class PowqlInterface {
|
|
|
736
1115
|
const params = [];
|
|
737
1116
|
const placeholders = chunk.map((v) => this.param(v, params)).join(', ');
|
|
738
1117
|
const powql = `${quotePowqlIdent(through.table)} filter .${sourceJCol} in (${placeholders}) { .${sourceJCol}, .${targetJCol} }`;
|
|
739
|
-
const { rows } = await this.exec(powql, params, timeout);
|
|
1118
|
+
const { rows } = await this.exec(powql, params, timeout, 'findMany');
|
|
740
1119
|
for (const row of rows) {
|
|
741
1120
|
const sv = String(row[sourceJCol]);
|
|
742
1121
|
const tv = String(row[targetJCol]);
|
|
@@ -782,6 +1161,257 @@ export class PowqlInterface {
|
|
|
782
1161
|
}
|
|
783
1162
|
}
|
|
784
1163
|
// -------------------------------------------------------------------------
|
|
1164
|
+
// Nested relations: native PowQL joins (F2, opt-in via relationLoadStrategy)
|
|
1165
|
+
// -------------------------------------------------------------------------
|
|
1166
|
+
/**
|
|
1167
|
+
* Resolve the effective relation-load strategy: the per-query arg wins, then
|
|
1168
|
+
* the client config, then the PowDB default of `'batched'` (the keyed
|
|
1169
|
+
* loaders). PowDB deliberately does NOT inherit the SQL-side implicit `'join'`
|
|
1170
|
+
* default (that would silently flip every existing PowDB user onto brand-new
|
|
1171
|
+
* join generation). Only a value the user actually set to `'join'` activates it.
|
|
1172
|
+
*/
|
|
1173
|
+
resolveStrategy(args) {
|
|
1174
|
+
const s = args.relationLoadStrategy ?? this.options.relationLoadStrategy ?? 'batched';
|
|
1175
|
+
return s === 'join' ? 'join' : 'batched';
|
|
1176
|
+
}
|
|
1177
|
+
/**
|
|
1178
|
+
* Per-relation eligibility for the join path (checked before the serverJoins
|
|
1179
|
+
* capability). Any `false` here is a SILENT fallback to the keyed loaders (it
|
|
1180
|
+
* is never an error), so an off-page or nested-`with` shape still returns
|
|
1181
|
+
* correct rows:
|
|
1182
|
+
* - the parent query must not be paged (`limit`/`offset`/`take`, including the
|
|
1183
|
+
* configured `defaultLimit`): a parent-filter join under a page would scan
|
|
1184
|
+
* children of off-page parents, where the loaders are strictly better;
|
|
1185
|
+
* - the relation must not request a nested `with` (its subtree stays on the
|
|
1186
|
+
* loaders this round) or a `distinct`;
|
|
1187
|
+
* - single-column relation keys only (a composite key falls to the loader,
|
|
1188
|
+
* which throws the same E017 as today);
|
|
1189
|
+
* - the PARENT-SIDE correlation column must be a single-column PK or unique
|
|
1190
|
+
* column, or the INNER join would re-emit one child copy per matching
|
|
1191
|
+
* parent row (a non-unique correlation key produces duplicate children the
|
|
1192
|
+
* loader never would). For hasMany/hasOne/m2m that column is the relation's
|
|
1193
|
+
* `referenceKey` on THIS (fetched) table; for belongsTo it is the
|
|
1194
|
+
* `referenceKey` on the TARGET table (the join's non-fetched side);
|
|
1195
|
+
* - m2m keeps any `orderBy`/`limit`/`offset` on the loader (the junction-order
|
|
1196
|
+
* stitch can't be reproduced by the 3-table join deterministically);
|
|
1197
|
+
* - a to-one relation `limit`/`offset` (meaningless) stays on the loader, as
|
|
1198
|
+
* does a to-many relation `limit`/`offset` when the parent set spills past
|
|
1199
|
+
* one loader chunk (the loader limits per chunk, the join once globally).
|
|
1200
|
+
*/
|
|
1201
|
+
joinEligible(rel, opt, args, parentCount) {
|
|
1202
|
+
const effLimit = args.limit ?? args.take ?? this.defaultLimit;
|
|
1203
|
+
if (effLimit !== undefined || args.offset)
|
|
1204
|
+
return false;
|
|
1205
|
+
const options = (opt === true ? {} : opt);
|
|
1206
|
+
if (options.with)
|
|
1207
|
+
return false;
|
|
1208
|
+
if (options.distinct?.length)
|
|
1209
|
+
return false;
|
|
1210
|
+
if (rel.type === 'manyToMany') {
|
|
1211
|
+
const through = rel.through;
|
|
1212
|
+
if (!through)
|
|
1213
|
+
return false;
|
|
1214
|
+
if (normalizeKeyColumns(through.sourceKey).length > 1 ||
|
|
1215
|
+
normalizeKeyColumns(through.targetKey).length > 1 ||
|
|
1216
|
+
normalizeKeyColumns(rel.referenceKey).length > 1 ||
|
|
1217
|
+
(this.schema.tables[rel.to]?.primaryKey.length ?? 2) > 1) {
|
|
1218
|
+
return false;
|
|
1219
|
+
}
|
|
1220
|
+
if (options.orderBy || options.limit !== undefined || options.offset)
|
|
1221
|
+
return false;
|
|
1222
|
+
// The parent joins on its referenceKey; a non-unique one duplicates.
|
|
1223
|
+
if (!this.isSingleColumnUnique(this.meta, normalizeKeyColumns(rel.referenceKey)[0]))
|
|
1224
|
+
return false;
|
|
1225
|
+
return true;
|
|
1226
|
+
}
|
|
1227
|
+
if (normalizeKeyColumns(rel.foreignKey).length > 1 || normalizeKeyColumns(rel.referenceKey).length > 1) {
|
|
1228
|
+
return false;
|
|
1229
|
+
}
|
|
1230
|
+
// Reject a non-unique correlation key: on belongsTo the fetched side joins on
|
|
1231
|
+
// the target's referenceKey, otherwise the fetched side joins on its own.
|
|
1232
|
+
if (rel.type === 'belongsTo') {
|
|
1233
|
+
const targetMeta = this.schema.tables[rel.to];
|
|
1234
|
+
if (!targetMeta || !this.isSingleColumnUnique(targetMeta, normalizeKeyColumns(rel.referenceKey)[0])) {
|
|
1235
|
+
return false;
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
else if (!this.isSingleColumnUnique(this.meta, normalizeKeyColumns(rel.referenceKey)[0])) {
|
|
1239
|
+
return false;
|
|
1240
|
+
}
|
|
1241
|
+
const single = rel.type === 'belongsTo' || rel.type === 'hasOne';
|
|
1242
|
+
if ((options.limit !== undefined || options.offset) && (single || parentCount > MAX_RELATION_KEYS)) {
|
|
1243
|
+
return false;
|
|
1244
|
+
}
|
|
1245
|
+
return true;
|
|
1246
|
+
}
|
|
1247
|
+
/**
|
|
1248
|
+
* True when `col` is a single-column unique key of `tableMeta`: the sole
|
|
1249
|
+
* primary-key column, a single-column entry in `uniqueColumns` (where a
|
|
1250
|
+
* per-column `unique: true` and an introspected single-column unique constraint
|
|
1251
|
+
* both land), or a single-column unique index. Used by {@link joinEligible} to
|
|
1252
|
+
* keep the INNER-join path off relations whose parent-side correlation column
|
|
1253
|
+
* can repeat (which would duplicate children).
|
|
1254
|
+
*/
|
|
1255
|
+
isSingleColumnUnique(tableMeta, col) {
|
|
1256
|
+
if (tableMeta.primaryKey.length === 1 && tableMeta.primaryKey[0] === col)
|
|
1257
|
+
return true;
|
|
1258
|
+
if (tableMeta.uniqueColumns.some((cols) => cols.length === 1 && cols[0] === col))
|
|
1259
|
+
return true;
|
|
1260
|
+
return tableMeta.indexes.some((idx) => idx.unique && !idx.docPath && idx.columns.length === 1 && idx.columns[0] === col);
|
|
1261
|
+
}
|
|
1262
|
+
/** Dispatch one eligible relation to the correct native-join loader. */
|
|
1263
|
+
async loadRelationViaJoin(parents, rel, relName, opt, parent, timeout) {
|
|
1264
|
+
if (rel.type === 'manyToMany') {
|
|
1265
|
+
await this.loadManyToManyViaJoin(parents, rel, relName, opt, parent, timeout);
|
|
1266
|
+
return;
|
|
1267
|
+
}
|
|
1268
|
+
const options = (opt === true ? {} : opt);
|
|
1269
|
+
const targetMeta = this.schema.tables[rel.to];
|
|
1270
|
+
if (!targetMeta)
|
|
1271
|
+
throw new ValidationError(`[turbine] Relation "${relName}" targets unknown table "${rel.to}".`);
|
|
1272
|
+
const targetQi = new PowqlInterface(this.pool, rel.to, this.schema, [], this.options);
|
|
1273
|
+
const fk = normalizeKeyColumns(rel.foreignKey);
|
|
1274
|
+
const rk = normalizeKeyColumns(rel.referenceKey);
|
|
1275
|
+
// Correlation math is identical to the keyed loaders, only the transport
|
|
1276
|
+
// (join vs in-list) changes. Always join the RELATION TARGET (alias `c`) to
|
|
1277
|
+
// the already-fetched side (alias `p`), correlating on the fetched side's key
|
|
1278
|
+
// and projecting `__tpk` from the fetched side's correlation column.
|
|
1279
|
+
const parentKeyCol = rel.type === 'belongsTo' ? fk[0] : rk[0];
|
|
1280
|
+
const childKeyCol = rel.type === 'belongsTo' ? rk[0] : fk[0];
|
|
1281
|
+
const parentKeyField = this.meta.reverseColumnMap[parentKeyCol] ?? parentKeyCol;
|
|
1282
|
+
const params = [];
|
|
1283
|
+
const childCols = this.joinChildCols(targetQi, options);
|
|
1284
|
+
const filter = await this.joinFilter(targetQi, parent.resolvedWhere, options.where, 'c', params, options.timeout ?? timeout);
|
|
1285
|
+
const order = targetQi.buildOrder(options.orderBy, params, 'c');
|
|
1286
|
+
const limitClause = options.limit !== undefined ? ` limit ${this.param(options.limit, params)}` : '';
|
|
1287
|
+
const offsetClause = options.offset ? ` offset ${this.param(options.offset, params)}` : '';
|
|
1288
|
+
const proj = this.joinProjection(childCols, `p.${quotePowqlIdent(parentKeyCol)}`, 'c');
|
|
1289
|
+
const powql = `${targetQi.qt} as c join ${this.qt} as p ` +
|
|
1290
|
+
`on c.${quotePowqlIdent(childKeyCol)} = p.${quotePowqlIdent(parentKeyCol)}` +
|
|
1291
|
+
`${filter}${order}${limitClause}${offsetClause} ${proj}`;
|
|
1292
|
+
// A READ: thread a read-shaped action through the exec seam.
|
|
1293
|
+
const { rows, native } = await targetQi.exec(powql, params, timeout, 'findMany');
|
|
1294
|
+
const single = rel.type === 'belongsTo' || rel.type === 'hasOne';
|
|
1295
|
+
const byKey = this.bucketByTpk(targetQi, rows, native);
|
|
1296
|
+
for (const p of parents) {
|
|
1297
|
+
const key = this.joinKey(p[parentKeyField]);
|
|
1298
|
+
const matches = (key == null ? undefined : byKey.get(key)) ?? [];
|
|
1299
|
+
p[relName] = single ? (matches[0] ?? null) : matches;
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
/**
|
|
1303
|
+
* manyToMany via chained joins: the target (alias `t`) → junction (alias `j`)
|
|
1304
|
+
* → the already-fetched side (alias `p`), correlating `__tpk` from the
|
|
1305
|
+
* junction's source key. Always a list, stitched exactly like the loader.
|
|
1306
|
+
*/
|
|
1307
|
+
async loadManyToManyViaJoin(parents, rel, relName, opt, parent, timeout) {
|
|
1308
|
+
const through = rel.through;
|
|
1309
|
+
if (!through)
|
|
1310
|
+
throw new ValidationError(`[turbine] manyToMany relation "${relName}" is missing its junction (\`through\`).`);
|
|
1311
|
+
const options = (opt === true ? {} : opt);
|
|
1312
|
+
const targetMeta = this.schema.tables[rel.to];
|
|
1313
|
+
if (!targetMeta)
|
|
1314
|
+
throw new ValidationError(`[turbine] Relation "${relName}" targets unknown table "${rel.to}".`);
|
|
1315
|
+
const targetQi = new PowqlInterface(this.pool, rel.to, this.schema, [], this.options);
|
|
1316
|
+
const sourceJCol = normalizeKeyColumns(through.sourceKey)[0];
|
|
1317
|
+
const targetJCol = normalizeKeyColumns(through.targetKey)[0];
|
|
1318
|
+
const sourceRefCol = normalizeKeyColumns(rel.referenceKey)[0];
|
|
1319
|
+
const targetPkCol = targetMeta.primaryKey[0];
|
|
1320
|
+
const parentRefField = this.meta.reverseColumnMap[sourceRefCol] ?? sourceRefCol;
|
|
1321
|
+
const params = [];
|
|
1322
|
+
const childCols = this.joinChildCols(targetQi, options);
|
|
1323
|
+
const filter = await this.joinFilter(targetQi, parent.resolvedWhere, options.where, 't', params, options.timeout ?? timeout);
|
|
1324
|
+
const proj = this.joinProjection(childCols, `j.${quotePowqlIdent(sourceJCol)}`, 't');
|
|
1325
|
+
const powql = `${targetQi.qt} as t ` +
|
|
1326
|
+
`join ${quotePowqlIdent(through.table)} as j on t.${quotePowqlIdent(targetPkCol)} = j.${quotePowqlIdent(targetJCol)} ` +
|
|
1327
|
+
`join ${this.qt} as p on j.${quotePowqlIdent(sourceJCol)} = p.${quotePowqlIdent(sourceRefCol)}` +
|
|
1328
|
+
`${filter} ${proj}`;
|
|
1329
|
+
const { rows, native } = await targetQi.exec(powql, params, timeout, 'findMany');
|
|
1330
|
+
const byKey = this.bucketByTpk(targetQi, rows, native);
|
|
1331
|
+
for (const p of parents) {
|
|
1332
|
+
const key = this.joinKey(p[parentRefField]);
|
|
1333
|
+
p[relName] = (key == null ? undefined : byKey.get(key)) ?? [];
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
/**
|
|
1337
|
+
* The target column list to project through the join (honouring select/omit),
|
|
1338
|
+
* with a loud guard: a real column named `__tpk` would collide with the
|
|
1339
|
+
* reserved correlation alias, so refuse rather than silently mis-stitch.
|
|
1340
|
+
*/
|
|
1341
|
+
joinChildCols(targetQi, options) {
|
|
1342
|
+
const cols = targetQi.projectedColumns(options.select, options.omit);
|
|
1343
|
+
if (cols.includes('__tpk')) {
|
|
1344
|
+
throw new ValidationError(`[turbine] relation target "${targetQi.table}" has a column named "__tpk", which collides with the reserved ` +
|
|
1345
|
+
`join correlation alias. Rename the column or load this relation with relationLoadStrategy: 'batched'.`);
|
|
1346
|
+
}
|
|
1347
|
+
return cols;
|
|
1348
|
+
}
|
|
1349
|
+
/**
|
|
1350
|
+
* `{ __tpk: <tpkExpr>, <col>: <childAlias>.<col>, … }`. Each child column is
|
|
1351
|
+
* ALIASED to its bare name (a bare qualified ref `c.col` would come back named
|
|
1352
|
+
* `c.col`, not `col`) so the stitched rows shape identically to a flat select.
|
|
1353
|
+
*/
|
|
1354
|
+
joinProjection(childCols, tpkExpr, childAlias) {
|
|
1355
|
+
const parts = [
|
|
1356
|
+
`__tpk: ${tpkExpr}`,
|
|
1357
|
+
...childCols.map((c) => `${quotePowqlIdent(c)}: ${childAlias}.${quotePowqlIdent(c)}`),
|
|
1358
|
+
];
|
|
1359
|
+
return `{ ${parts.join(', ')} }`;
|
|
1360
|
+
}
|
|
1361
|
+
/**
|
|
1362
|
+
* `filter <parentWhere qualified p> [and <relationWhere qualified childAlias>]`.
|
|
1363
|
+
* The parent where is the ALREADY-RESOLVED predicate (relation filters collapsed
|
|
1364
|
+
* to literal in-lists before the base query ran); the relation where is resolved
|
|
1365
|
+
* on the target the same way before qualifying, so a nested relation filter in
|
|
1366
|
+
* the relation `where` never reaches the join unresolved. Params bind in order.
|
|
1367
|
+
*/
|
|
1368
|
+
async joinFilter(targetQi, parentResolvedWhere, relWhere, childAlias, params, timeout) {
|
|
1369
|
+
const parts = [];
|
|
1370
|
+
const pw = this.buildWhere(parentResolvedWhere, params, 'p');
|
|
1371
|
+
if (pw)
|
|
1372
|
+
parts.push(pw);
|
|
1373
|
+
const relResolved = await targetQi.resolveRelationFilters(relWhere, timeout);
|
|
1374
|
+
const rw = targetQi.buildWhere(relResolved, params, childAlias);
|
|
1375
|
+
if (rw)
|
|
1376
|
+
parts.push(rw);
|
|
1377
|
+
return parts.length ? ` filter ${parts.join(' and ')}` : '';
|
|
1378
|
+
}
|
|
1379
|
+
/** Group join rows by their (normalized) `__tpk`, stripping it and shaping each child. */
|
|
1380
|
+
bucketByTpk(targetQi, rows, native) {
|
|
1381
|
+
const byKey = new Map();
|
|
1382
|
+
for (const raw of rows) {
|
|
1383
|
+
const tpk = this.joinKey(raw.__tpk);
|
|
1384
|
+
delete raw.__tpk;
|
|
1385
|
+
const child = targetQi.shape([raw], native)[0];
|
|
1386
|
+
if (tpk == null)
|
|
1387
|
+
continue;
|
|
1388
|
+
const bucket = byKey.get(tpk);
|
|
1389
|
+
if (bucket)
|
|
1390
|
+
bucket.push(child);
|
|
1391
|
+
else
|
|
1392
|
+
byKey.set(tpk, [child]);
|
|
1393
|
+
}
|
|
1394
|
+
return byKey;
|
|
1395
|
+
}
|
|
1396
|
+
/**
|
|
1397
|
+
* Normalize a correlation key to a stable string map key so a parent's key
|
|
1398
|
+
* value (a shaped entity field) and a child row's `__tpk` cell match across
|
|
1399
|
+
* wires and column types. A `Date` maps to microseconds
|
|
1400
|
+
* (`getTime()` ms times 1000), because a datetime correlation cell arrives as
|
|
1401
|
+
* raw micros (bigint on the native wire, a micros string on the legacy wire),
|
|
1402
|
+
* never as ms. bigint / number / string all stringify to the same digits, so
|
|
1403
|
+
* an int key matches whether it came back typed or as text.
|
|
1404
|
+
*/
|
|
1405
|
+
joinKey(v) {
|
|
1406
|
+
if (v == null)
|
|
1407
|
+
return null;
|
|
1408
|
+
if (v instanceof Date)
|
|
1409
|
+
return (BigInt(v.getTime()) * 1000n).toString();
|
|
1410
|
+
if (typeof v === 'bigint')
|
|
1411
|
+
return v.toString();
|
|
1412
|
+
return String(v);
|
|
1413
|
+
}
|
|
1414
|
+
// -------------------------------------------------------------------------
|
|
785
1415
|
// Writes (reselect — PowDB has no RETURNING)
|
|
786
1416
|
// -------------------------------------------------------------------------
|
|
787
1417
|
/** Split `data` into scalar assignments; reject relation (nested-write) keys. */
|
|
@@ -837,8 +1467,8 @@ export class PowqlInterface {
|
|
|
837
1467
|
.map((a) => `${quotePowqlIdent(a.col.name)} := ${this.writeRef(a.value, a.col, params)}`)
|
|
838
1468
|
.join(', ');
|
|
839
1469
|
// `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;
|
|
1470
|
+
const { rows, native } = await this.exec(`insert ${this.qt} { ${body} } returning`, params, args.timeout, 'create');
|
|
1471
|
+
const row = rows.length ? this.shape(rows, native)[0] : null;
|
|
842
1472
|
if (!row)
|
|
843
1473
|
throw new NotFoundError({ table: this.table, where: data });
|
|
844
1474
|
return row;
|
|
@@ -855,8 +1485,8 @@ export class PowqlInterface {
|
|
|
855
1485
|
return `{ ${assigns.map((a) => `${quotePowqlIdent(a.col.name)} := ${this.writeRef(a.value, a.col, params)}`).join(', ')} }`;
|
|
856
1486
|
});
|
|
857
1487
|
// 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);
|
|
1488
|
+
const { rows, native } = await this.exec(`insert ${this.qt} ${tuples.join(', ')} returning`, params, args.timeout, 'createMany');
|
|
1489
|
+
return this.shape(rows, native);
|
|
860
1490
|
});
|
|
861
1491
|
}
|
|
862
1492
|
async update(args) {
|
|
@@ -870,8 +1500,8 @@ export class PowqlInterface {
|
|
|
870
1500
|
this.assertCompiledWhere(where, false, 'update');
|
|
871
1501
|
const setClause = this.buildUpdateAssignments(args.data, params);
|
|
872
1502
|
// `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;
|
|
1503
|
+
const { rows, native } = await this.exec(`${this.qt} filter ${where} update { ${setClause} } returning`, params, args.timeout, 'update');
|
|
1504
|
+
const row = rows.length ? this.shape(rows, native)[0] : null;
|
|
875
1505
|
if (!row)
|
|
876
1506
|
throw new NotFoundError({ table: this.table, where: args.where });
|
|
877
1507
|
return row;
|
|
@@ -885,7 +1515,7 @@ export class PowqlInterface {
|
|
|
885
1515
|
this.assertCompiledWhere(where, args.allowFullTableScan, 'updateMany');
|
|
886
1516
|
const setClause = this.buildUpdateAssignments(args.data, params);
|
|
887
1517
|
const filter = where ? ` filter ${where}` : '';
|
|
888
|
-
const { rowCount } = await this.exec(`${this.qt}${filter} update { ${setClause} }`, params, args.timeout);
|
|
1518
|
+
const { rowCount } = await this.exec(`${this.qt}${filter} update { ${setClause} }`, params, args.timeout, 'updateMany');
|
|
889
1519
|
return { count: rowCount };
|
|
890
1520
|
});
|
|
891
1521
|
}
|
|
@@ -954,6 +1584,11 @@ export class PowqlInterface {
|
|
|
954
1584
|
}
|
|
955
1585
|
/** Open a flat PowDB transaction on a pinned connection and run `fn` inside it. */
|
|
956
1586
|
async runInImplicitTx(fn) {
|
|
1587
|
+
// A transaction-control `begin` is a write on a read-only pool: refuse it
|
|
1588
|
+
// locally before checking out a connection (zero wire / pool activity), the
|
|
1589
|
+
// same guard the exec seam applies to plain writes.
|
|
1590
|
+
if (this.pool.readonly === true)
|
|
1591
|
+
throw this.readOnlyError('transaction (begin)');
|
|
957
1592
|
// Route tx keywords through the dialect (like the SQL path) so this never
|
|
958
1593
|
// drifts from `powdbDialect`; falls back to the literal lowercase keywords.
|
|
959
1594
|
const d = this.options.dialect;
|
|
@@ -963,7 +1598,10 @@ export class PowqlInterface {
|
|
|
963
1598
|
await client.query(d?.beginStatement?.() ?? 'begin');
|
|
964
1599
|
began = true;
|
|
965
1600
|
const { TransactionClient } = await import('./client.js');
|
|
966
|
-
const tx = new TransactionClient(client, this.schema, this.middlewares, this.options
|
|
1601
|
+
const tx = new TransactionClient(client, this.schema, this.middlewares, this.options,
|
|
1602
|
+
// Pass the PowDB pool so its read-only guard + capabilities carry into
|
|
1603
|
+
// the transaction-scoped proxy pool (see createTxPool).
|
|
1604
|
+
this.pool);
|
|
967
1605
|
const ctx = { schema: this.schema, tx: tx };
|
|
968
1606
|
// Plant the single-writer re-entrancy marker for the implicit tx's
|
|
969
1607
|
// subtree (same seam TurbineClient.$transaction uses) — user code that
|
|
@@ -1008,8 +1646,8 @@ export class PowqlInterface {
|
|
|
1008
1646
|
const where = this.buildWhere(resolvedWhere, params);
|
|
1009
1647
|
this.assertCompiledWhere(where, false, 'delete');
|
|
1010
1648
|
// `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;
|
|
1649
|
+
const { rows, native } = await this.exec(`${this.qt} filter ${where} delete returning`, params, args.timeout, 'delete');
|
|
1650
|
+
const row = rows.length ? this.shape(rows, native)[0] : null;
|
|
1013
1651
|
if (!row)
|
|
1014
1652
|
throw new NotFoundError({ table: this.table, where: args.where });
|
|
1015
1653
|
return row;
|
|
@@ -1022,7 +1660,7 @@ export class PowqlInterface {
|
|
|
1022
1660
|
const where = this.buildWhere(resolvedWhere, params);
|
|
1023
1661
|
this.assertCompiledWhere(where, args.allowFullTableScan, 'deleteMany');
|
|
1024
1662
|
const filter = where ? ` filter ${where}` : '';
|
|
1025
|
-
const { rowCount } = await this.exec(`${this.qt}${filter} delete`, params, args.timeout);
|
|
1663
|
+
const { rowCount } = await this.exec(`${this.qt}${filter} delete`, params, args.timeout, 'deleteMany');
|
|
1026
1664
|
return { count: rowCount };
|
|
1027
1665
|
});
|
|
1028
1666
|
}
|
|
@@ -1044,7 +1682,7 @@ export class PowqlInterface {
|
|
|
1044
1682
|
// (verified: "unexpected trailing token … 'returning'"), because it is one
|
|
1045
1683
|
// atomic insert-or-update, not two branches. So upsert alone keeps the
|
|
1046
1684
|
// 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);
|
|
1685
|
+
await this.exec(`upsert ${this.qt} on .${pkCol} { ${createBody} } on conflict { ${updateBody} }`, params, args.timeout, 'upsert');
|
|
1048
1686
|
const pkField = this.meta.reverseColumnMap[pkCol] ?? pkCol;
|
|
1049
1687
|
const row = await this.reselectByPk(createData[pkField], args.timeout);
|
|
1050
1688
|
if (!row)
|
|
@@ -1089,7 +1727,7 @@ export class PowqlInterface {
|
|
|
1089
1727
|
const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
|
|
1090
1728
|
const where = this.buildWhere(resolvedWhere, params);
|
|
1091
1729
|
const filter = where ? ` filter ${where}` : '';
|
|
1092
|
-
const { rows } = await this.exec(`count(${this.qt}${filter})`, params, args.timeout);
|
|
1730
|
+
const { rows } = await this.exec(`count(${this.qt}${filter})`, params, args.timeout, 'count');
|
|
1093
1731
|
return Number((rows[0]?.value ?? rows[0]?.count ?? 0));
|
|
1094
1732
|
});
|
|
1095
1733
|
}
|
|
@@ -1103,7 +1741,7 @@ export class PowqlInterface {
|
|
|
1103
1741
|
const filter = where ? ` filter ${where}` : '';
|
|
1104
1742
|
const scalar = async (expr) => {
|
|
1105
1743
|
const params = [...filterParams];
|
|
1106
|
-
const { rows } = await this.exec(expr, params, args.timeout);
|
|
1744
|
+
const { rows } = await this.exec(expr, params, args.timeout, 'aggregate');
|
|
1107
1745
|
const v = rows[0]?.value;
|
|
1108
1746
|
return v == null || v === 'null' ? null : Number(v);
|
|
1109
1747
|
};
|
|
@@ -1135,90 +1773,196 @@ export class PowqlInterface {
|
|
|
1135
1773
|
}
|
|
1136
1774
|
async groupBy(args) {
|
|
1137
1775
|
return this.withMiddleware('groupBy', args, async () => {
|
|
1138
|
-
//
|
|
1139
|
-
// group keys / aggregate targets) have no PowQL equivalent: refuse
|
|
1140
|
-
// clearly instead of emitting broken PowQL.
|
|
1776
|
+
// DISTINCT ON has no PowQL equivalent (no DISTINCT ON row source).
|
|
1141
1777
|
if (args.distinctOn) {
|
|
1142
1778
|
throw new UnsupportedFeatureError('groupBy distinctOn row source', 'PowDB');
|
|
1143
1779
|
}
|
|
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
|
-
}
|
|
1780
|
+
// JSON-path group keys / aggregate targets (≥ 0.12) are gated once here.
|
|
1781
|
+
const usesJson = args.by.some((e) => typeof e !== 'string') ||
|
|
1782
|
+
['_sum', '_avg', '_min', '_max'].some((fn) => {
|
|
1783
|
+
const spec = args[fn];
|
|
1784
|
+
return spec !== undefined && Object.values(spec).some((v) => v != null && typeof v === 'object');
|
|
1785
|
+
});
|
|
1786
|
+
if (usesJson) {
|
|
1787
|
+
requireCapability(this.capabilities, 'jsonDocs', 'JSON-path groupBy keys / aggregate targets');
|
|
1158
1788
|
}
|
|
1789
|
+
// `emitNative` decides whether the query GENERATION needs the legacy-wire
|
|
1790
|
+
// `json_type` discriminator (pool-level capability). The DECODE side reads
|
|
1791
|
+
// the wire that ACTUALLY served the result (`resultNative`, from exec), so
|
|
1792
|
+
// a per-call legacy fallback on a native-capable pool still decodes right.
|
|
1793
|
+
const emitNative = Boolean(this.capabilities.nativeRaw);
|
|
1159
1794
|
const params = [];
|
|
1160
1795
|
const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
|
|
1161
1796
|
const where = this.buildWhere(resolvedWhere, params);
|
|
1162
1797
|
const filter = where ? ` filter ${where}` : '';
|
|
1163
|
-
|
|
1164
|
-
//
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
const
|
|
1168
|
-
|
|
1169
|
-
|
|
1798
|
+
// Result-key namespace, mirroring the SQL builder's `claimResultKey`
|
|
1799
|
+
// (query/builder.ts): a group-key / aggregate output-name collision (with
|
|
1800
|
+
// `_count`, another key, or an aggregate output) throws E003.
|
|
1801
|
+
const usedKeys = new Set();
|
|
1802
|
+
const claim = (key, what) => {
|
|
1803
|
+
if (key === '_count' || usedKeys.has(key)) {
|
|
1804
|
+
throw new ValidationError(`[turbine] groupBy output name "${key}" (${what}) collides with another output column on table ` +
|
|
1805
|
+
`"${this.table}": set an explicit \`alias\` (or rename the aggregate key) to disambiguate.`);
|
|
1806
|
+
}
|
|
1807
|
+
usedKeys.add(key);
|
|
1808
|
+
};
|
|
1809
|
+
const groupExprs = [];
|
|
1810
|
+
const proj = [];
|
|
1811
|
+
const byOrderExprs = new Map();
|
|
1812
|
+
const byReaders = [];
|
|
1813
|
+
let gkN = 0;
|
|
1814
|
+
let gtN = 0;
|
|
1815
|
+
for (const entry of args.by) {
|
|
1816
|
+
if (typeof entry === 'string') {
|
|
1817
|
+
const col = this.column(entry);
|
|
1818
|
+
claim(entry, `column "${col.name}"`);
|
|
1819
|
+
if (col.name !== entry)
|
|
1820
|
+
claim(col.name, `column "${col.name}"`);
|
|
1821
|
+
groupExprs.push(`.${col.name}`);
|
|
1822
|
+
proj.push(`.${col.name}`);
|
|
1823
|
+
byOrderExprs.set(entry, `.${col.name}`);
|
|
1824
|
+
byReaders.push({ kind: 'plain', resultKey: entry, rowKey: col.name, col });
|
|
1825
|
+
}
|
|
1826
|
+
else {
|
|
1827
|
+
const col = this.column(entry.field);
|
|
1828
|
+
if (!isJsonColumn(col)) {
|
|
1829
|
+
throw new ValidationError(`[turbine] groupBy JSON group key on "${entry.field}" (table "${this.table}") requires a json column.`);
|
|
1830
|
+
}
|
|
1831
|
+
this.assertJsonPath('group key', entry.field, entry.path);
|
|
1832
|
+
const pathExpr = this.jsonPathExpr(col, entry.path, params);
|
|
1833
|
+
const alias = entry.alias ?? String(entry.path[entry.path.length - 1]);
|
|
1834
|
+
claim(alias, `JSON path on "${entry.field}"`);
|
|
1835
|
+
const gkAlias = `gk_${gkN++}`;
|
|
1836
|
+
groupExprs.push(pathExpr);
|
|
1837
|
+
proj.push(`${gkAlias}: ${pathExpr}`);
|
|
1838
|
+
byOrderExprs.set(alias, `.${gkAlias}`);
|
|
1839
|
+
let discrim;
|
|
1840
|
+
if (!emitNative) {
|
|
1841
|
+
// Legacy wire renders a missing value, JSON null, AND the string
|
|
1842
|
+
// "null" all as the cell "null". `min(json_type(path))` over the
|
|
1843
|
+
// group is "string" ONLY for the string-"null" group and "null"
|
|
1844
|
+
// otherwise (a bare `json_type` projection is not group-correlated).
|
|
1845
|
+
discrim = `gt_${gtN++}`;
|
|
1846
|
+
proj.push(`${discrim}: min(json_type(${pathExpr}))`);
|
|
1847
|
+
}
|
|
1848
|
+
byReaders.push({ kind: 'json', resultKey: alias, rowKey: gkAlias, discrim });
|
|
1849
|
+
}
|
|
1850
|
+
}
|
|
1851
|
+
// Aggregates: `agg_N` internal aliases (PowQL rejects reserved-word
|
|
1852
|
+
// aliases like `count:`). `aggInner` lets HAVING re-emit the exact inner
|
|
1853
|
+
// expression by user key; `aggOrderExprs` lets orderBy reference the alias.
|
|
1854
|
+
const aggReaders = [];
|
|
1855
|
+
const aggOrderExprs = new Map();
|
|
1856
|
+
const aggInner = new Map();
|
|
1857
|
+
let aggN = 0;
|
|
1858
|
+
// Parity with the SQL builder (query/builder.ts): `_count` is selected by
|
|
1859
|
+
// DEFAULT unless the caller explicitly opts out with `_count: false`, so
|
|
1860
|
+
// every groupBy row carries `_count` and `orderBy: { _count }` works
|
|
1861
|
+
// without requesting it (the alias is seeded into `aggOrderExprs`).
|
|
1862
|
+
const countSelected = args._count === true || args._count === undefined;
|
|
1863
|
+
if (countSelected) {
|
|
1864
|
+
const alias = `agg_${aggN++}`;
|
|
1865
|
+
proj.push(`${alias}: count(*)`);
|
|
1866
|
+
aggReaders.push({ alias, outKey: '_count', numeric: true });
|
|
1867
|
+
aggOrderExprs.set('_count', `.${alias}`);
|
|
1170
1868
|
}
|
|
1171
1869
|
for (const fn of ['_sum', '_avg', '_min', '_max']) {
|
|
1172
1870
|
const spec = args[fn];
|
|
1173
1871
|
if (!spec)
|
|
1174
1872
|
continue;
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1873
|
+
const powfn = fn.slice(1); // sum/avg/min/max
|
|
1874
|
+
for (const [key, target] of Object.entries(spec)) {
|
|
1875
|
+
if (!target)
|
|
1876
|
+
continue;
|
|
1877
|
+
const alias = `agg_${aggN++}`;
|
|
1878
|
+
if (target === true) {
|
|
1879
|
+
const col = this.column(key);
|
|
1880
|
+
claim(`${fn}_${col.name}`, `${fn} of column "${col.name}"`);
|
|
1881
|
+
const inner = `.${col.name}`;
|
|
1882
|
+
proj.push(`${alias}: ${powfn}(${inner})`);
|
|
1883
|
+
aggReaders.push({ alias, outKey: `${fn}:${key}`, numeric: true });
|
|
1884
|
+
aggOrderExprs.set(`${fn}:${key}`, `.${alias}`);
|
|
1885
|
+
aggInner.set(key, inner);
|
|
1886
|
+
}
|
|
1887
|
+
else {
|
|
1888
|
+
const col = this.column(target.field);
|
|
1889
|
+
if (!isJsonColumn(col)) {
|
|
1890
|
+
throw new ValidationError(`[turbine] groupBy ${fn} target "${key}" on "${target.field}" (table "${this.table}") requires a json column.`);
|
|
1891
|
+
}
|
|
1892
|
+
this.assertJsonPath(`${fn} target "${key}"`, target.field, target.path);
|
|
1893
|
+
const alwaysNumeric = fn === '_sum' || fn === '_avg';
|
|
1894
|
+
if (alwaysNumeric && target.type === 'text') {
|
|
1895
|
+
throw new ValidationError(`[turbine] groupBy ${fn} target "${key}" on table "${this.table}": ` +
|
|
1896
|
+
`${fn} over a JSON path is always numeric: remove \`type: 'text'\`.`);
|
|
1897
|
+
}
|
|
1898
|
+
const numeric = alwaysNumeric || target.type === 'numeric';
|
|
1899
|
+
claim(`${fn}_${key}`, `${fn} JSON target "${key}"`);
|
|
1900
|
+
const pathExpr = this.jsonPathExpr(col, target.path, params);
|
|
1901
|
+
const inner = numeric ? `cast(${pathExpr}, "float")` : pathExpr;
|
|
1902
|
+
proj.push(`${alias}: ${powfn}(${inner})`);
|
|
1903
|
+
aggReaders.push({ alias, outKey: `${fn}:${key}`, numeric });
|
|
1904
|
+
aggOrderExprs.set(`${fn}:${key}`, `.${alias}`);
|
|
1905
|
+
aggInner.set(key, inner);
|
|
1191
1906
|
}
|
|
1192
1907
|
}
|
|
1193
1908
|
}
|
|
1194
|
-
const
|
|
1195
|
-
const
|
|
1196
|
-
const
|
|
1197
|
-
|
|
1909
|
+
const having = this.buildHaving(args.having, params, aggInner);
|
|
1910
|
+
const order = this.buildGroupOrder(args.orderBy, byOrderExprs, aggOrderExprs);
|
|
1911
|
+
const powql = `${this.qt}${filter} group ${groupExprs.join(', ')}${having}${order} { ${proj.join(', ')} }`;
|
|
1912
|
+
const { rows, native: resultNative } = await this.exec(powql, params, args.timeout, 'groupBy');
|
|
1913
|
+
// Reshape: group keys → user fields (coerced / null-disambiguated),
|
|
1914
|
+
// aggregates → nested `{ _sum: { field } }`; discriminators are stripped.
|
|
1915
|
+
// Group-key cells go through the SAME coercion policy `rowToEntity` uses,
|
|
1916
|
+
// by the wire that actually served this result, so a native-wire int cell
|
|
1917
|
+
// (bigint) or datetime cell (micros) never leaks into the result: it
|
|
1918
|
+
// becomes the same number / Date / PG-text-parity string an embedded /
|
|
1919
|
+
// legacy / SQL groupBy returns for the identical query.
|
|
1198
1920
|
return rows.map((raw) => {
|
|
1199
1921
|
const out = {};
|
|
1200
|
-
for (const
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1922
|
+
for (const r of byReaders) {
|
|
1923
|
+
if (r.kind === 'plain') {
|
|
1924
|
+
const cell = raw[r.rowKey];
|
|
1925
|
+
out[r.resultKey] = resultNative
|
|
1926
|
+
? coerceNativeValue(cell, r.col)
|
|
1927
|
+
: typeof cell === 'string'
|
|
1928
|
+
? coerceScalar(cell, r.col.tsType)
|
|
1929
|
+
: cell;
|
|
1930
|
+
}
|
|
1931
|
+
else {
|
|
1932
|
+
out[r.resultKey] = decodeGroupKeyCell(raw[r.rowKey], r.discrim ? raw[r.discrim] : undefined, resultNative);
|
|
1933
|
+
}
|
|
1204
1934
|
}
|
|
1205
|
-
for (const a of
|
|
1206
|
-
const
|
|
1207
|
-
const
|
|
1935
|
+
for (const a of aggReaders) {
|
|
1936
|
+
const cell = raw[a.alias];
|
|
1937
|
+
const v = cell == null || cell === 'null' ? null : a.numeric ? Number(cell) : cell;
|
|
1208
1938
|
if (a.outKey === '_count')
|
|
1209
|
-
out._count =
|
|
1939
|
+
out._count = v ?? 0;
|
|
1210
1940
|
else {
|
|
1211
1941
|
const [bucket, field] = a.outKey.split(':');
|
|
1212
1942
|
out[bucket] ??= {};
|
|
1213
|
-
out[bucket][field] =
|
|
1943
|
+
out[bucket][field] = v;
|
|
1214
1944
|
}
|
|
1215
1945
|
}
|
|
1216
1946
|
return out;
|
|
1217
1947
|
});
|
|
1218
1948
|
});
|
|
1219
1949
|
}
|
|
1220
|
-
/**
|
|
1221
|
-
|
|
1950
|
+
/** Validate a JSON-path target (group key / aggregate target): non-empty array of keys/indexes. */
|
|
1951
|
+
assertJsonPath(context, field, path) {
|
|
1952
|
+
if (!Array.isArray(path) ||
|
|
1953
|
+
path.length === 0 ||
|
|
1954
|
+
path.some((el) => typeof el !== 'string' && !(typeof el === 'number' && Number.isFinite(el)))) {
|
|
1955
|
+
throw new ValidationError(`[turbine] groupBy ${context} on "${field}" (table "${this.table}") requires a non-empty \`path\` ` +
|
|
1956
|
+
`array of keys/indexes (e.g. { field: '${field}', path: ['category'] }).`);
|
|
1957
|
+
}
|
|
1958
|
+
}
|
|
1959
|
+
/**
|
|
1960
|
+
* `having <expr>` over group aggregates. `_count` compares `count(*)` (parity
|
|
1961
|
+
* with the projection); a per-field aggregate re-emits its inner expression
|
|
1962
|
+
* (from `aggInner` when the field is a requested aggregate, so a JSON-path
|
|
1963
|
+
* aggregate reuses its bound placeholders, else `.field` for a plain column).
|
|
1964
|
+
*/
|
|
1965
|
+
buildHaving(having, params, aggInner) {
|
|
1222
1966
|
if (!having)
|
|
1223
1967
|
return '';
|
|
1224
1968
|
const conds = [];
|
|
@@ -1236,18 +1980,88 @@ export class PowqlInterface {
|
|
|
1236
1980
|
if (spec == null)
|
|
1237
1981
|
continue;
|
|
1238
1982
|
if (key === '_count') {
|
|
1239
|
-
conds.push(cmp(
|
|
1983
|
+
conds.push(cmp('count(*)', spec));
|
|
1240
1984
|
}
|
|
1241
1985
|
else {
|
|
1242
1986
|
for (const [fn, filter] of Object.entries(spec)) {
|
|
1243
1987
|
if (filter == null)
|
|
1244
1988
|
continue;
|
|
1245
|
-
|
|
1989
|
+
const inner = aggInner.get(key) ?? this.ref(key);
|
|
1990
|
+
conds.push(cmp(`${fn.slice(1)}(${inner})`, filter));
|
|
1246
1991
|
}
|
|
1247
1992
|
}
|
|
1248
1993
|
}
|
|
1249
1994
|
return conds.length ? ` having ${conds.join(' and ')}` : '';
|
|
1250
1995
|
}
|
|
1996
|
+
/**
|
|
1997
|
+
* Compile a groupBy `orderBy` into a PowQL `order` body over the group RESULT
|
|
1998
|
+
* columns (by-fields, JSON group-key aliases, and requested aggregates). PowQL
|
|
1999
|
+
* cannot re-emit an aggregate EXPRESSION in `order` (engine error), but CAN
|
|
2000
|
+
* order by a projection alias on a grouped query (probed), so each key maps to
|
|
2001
|
+
* its projected alias (`.agg_N` / `.gk_N` / `.col`). Semantics and error
|
|
2002
|
+
* surface mirror the SQL `buildGroupByOrderBy` (0.32.2 R3-1): an aggregate not
|
|
2003
|
+
* requested in this call, or an unknown by-key, throws E003 listing the valid
|
|
2004
|
+
* keys. `nulls: 'first'` stays E017 (PowDB has no NULLS placement grammar).
|
|
2005
|
+
*/
|
|
2006
|
+
buildGroupOrder(orderBy, byOrderExprs, aggOrderExprs) {
|
|
2007
|
+
if (!orderBy)
|
|
2008
|
+
return '';
|
|
2009
|
+
const aggBlocks = new Set(['_count', '_sum', '_avg', '_min', '_max']);
|
|
2010
|
+
const validKeys = () => {
|
|
2011
|
+
const keys = [...byOrderExprs.keys()];
|
|
2012
|
+
for (const k of aggOrderExprs.keys())
|
|
2013
|
+
keys.push(k.includes(':') ? k.replace(':', '.') : k);
|
|
2014
|
+
return keys.join(', ') || '(none)';
|
|
2015
|
+
};
|
|
2016
|
+
const parts = [];
|
|
2017
|
+
for (const [key, value] of Object.entries(orderBy)) {
|
|
2018
|
+
if (value === undefined)
|
|
2019
|
+
continue;
|
|
2020
|
+
if (aggBlocks.has(key)) {
|
|
2021
|
+
if (key === '_count') {
|
|
2022
|
+
const expr = aggOrderExprs.get('_count');
|
|
2023
|
+
if (!expr) {
|
|
2024
|
+
throw new ValidationError(`[turbine] Cannot order groupBy by "_count" on table "${this.table}": _count is not selected. ` +
|
|
2025
|
+
`Orderable keys: ${validKeys()}.`);
|
|
2026
|
+
}
|
|
2027
|
+
parts.push(`${expr} ${this.groupOrderDir(value, '_count')}`);
|
|
2028
|
+
continue;
|
|
2029
|
+
}
|
|
2030
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
2031
|
+
throw new ValidationError(`[turbine] Invalid groupBy orderBy for "${key}" on table "${this.table}": ` +
|
|
2032
|
+
`expected a field map like { ${key}: { amount: 'desc' } }.`);
|
|
2033
|
+
}
|
|
2034
|
+
for (const [field, dirSpec] of Object.entries(value)) {
|
|
2035
|
+
if (dirSpec === undefined)
|
|
2036
|
+
continue;
|
|
2037
|
+
const expr = aggOrderExprs.get(`${key}:${field}`);
|
|
2038
|
+
if (!expr) {
|
|
2039
|
+
throw new ValidationError(`[turbine] Cannot order groupBy by "${key}.${field}" on table "${this.table}": ` +
|
|
2040
|
+
`that aggregate is not requested in this call. Orderable keys: ${validKeys()}.`);
|
|
2041
|
+
}
|
|
2042
|
+
parts.push(`${expr} ${this.groupOrderDir(dirSpec, `${key}.${field}`)}`);
|
|
2043
|
+
}
|
|
2044
|
+
continue;
|
|
2045
|
+
}
|
|
2046
|
+
const expr = byOrderExprs.get(key);
|
|
2047
|
+
if (!expr) {
|
|
2048
|
+
throw new ValidationError(`[turbine] Unknown field "${key}" in groupBy orderBy on table "${this.table}". Orderable keys: ${validKeys()}.`);
|
|
2049
|
+
}
|
|
2050
|
+
parts.push(`${expr} ${this.groupOrderDir(value, key)}`);
|
|
2051
|
+
}
|
|
2052
|
+
return parts.length ? ` order ${parts.join(', ')}` : '';
|
|
2053
|
+
}
|
|
2054
|
+
/** Resolve a groupBy order direction, refusing `nulls: 'first'` (E017); `nulls: 'last'` is a no-op. */
|
|
2055
|
+
groupOrderDir(value, keyForMsg) {
|
|
2056
|
+
if (value !== null && typeof value === 'object') {
|
|
2057
|
+
const spec = value;
|
|
2058
|
+
if (spec.nulls === 'first') {
|
|
2059
|
+
throw new UnsupportedFeatureError('NULLS FIRST placement', 'PowDB', `groupBy orderBy "${keyForMsg}": PowDB orders NULLs / missing keys LAST in both directions`);
|
|
2060
|
+
}
|
|
2061
|
+
return spec.sort === 'desc' ? 'desc' : 'asc';
|
|
2062
|
+
}
|
|
2063
|
+
return value === 'desc' ? 'desc' : 'asc';
|
|
2064
|
+
}
|
|
1251
2065
|
// -------------------------------------------------------------------------
|
|
1252
2066
|
// Streaming / unsupported
|
|
1253
2067
|
// -------------------------------------------------------------------------
|
|
@@ -1261,12 +2075,12 @@ export class PowqlInterface {
|
|
|
1261
2075
|
/** Reselect a single row by its single-column primary key value. */
|
|
1262
2076
|
async reselectByPk(pkValue, timeout) {
|
|
1263
2077
|
const pkField = this.meta.reverseColumnMap[this.meta.primaryKey[0]] ?? this.meta.primaryKey[0];
|
|
1264
|
-
const rows = await this.runFind({
|
|
2078
|
+
const { rows, native } = await this.runFind({
|
|
1265
2079
|
where: { [pkField]: pkValue },
|
|
1266
2080
|
limit: 1,
|
|
1267
2081
|
timeout,
|
|
1268
2082
|
});
|
|
1269
|
-
return rows.length ? this.shape(rows)[0] : null;
|
|
2083
|
+
return rows.length ? this.shape(rows, native)[0] : null;
|
|
1270
2084
|
}
|
|
1271
2085
|
/**
|
|
1272
2086
|
* Empty-where guard — blocks accidental whole-table writes. Mirrors the SQL
|
|
@@ -1301,3 +2115,53 @@ function coerceScalar(raw, tsType) {
|
|
|
1301
2115
|
return new Date(Number(raw) / 1000);
|
|
1302
2116
|
return raw;
|
|
1303
2117
|
}
|
|
2118
|
+
/**
|
|
2119
|
+
* Decode a JSON group-key cell, resolving the legacy-wire `null` ambiguity AND
|
|
2120
|
+
* normalizing the native typed wire to the SAME PG-`#>>`-text-parity shape.
|
|
2121
|
+
*
|
|
2122
|
+
* On the native typed wire (`native`) a cell arrives pre-typed (a JSON int as a
|
|
2123
|
+
* `bigint`, a bool as `boolean`, an unset value as `null`). Returned as-is that
|
|
2124
|
+
* would diverge from every other transport: the embedded / legacy / SQL wire
|
|
2125
|
+
* all yield the extracted TEXT (`'7'`, `'true'`), and a raw `bigint` even throws
|
|
2126
|
+
* on `JSON.stringify`. So a native scalar cell is rendered to its text form
|
|
2127
|
+
* ({@link nativeJsonKeyText}); `null`/`empty` stays `null`, and a genuine string
|
|
2128
|
+
* `"null"` stays the string (the wart the native wire was adopted to fix).
|
|
2129
|
+
*
|
|
2130
|
+
* On the legacy string wire a missing value, JSON null, AND the string `"null"`
|
|
2131
|
+
* all render the cell `"null"`; the group's `min(json_type(…))` discriminator is
|
|
2132
|
+
* `"string"` ONLY for the string-`"null"` group, so the cell is the string
|
|
2133
|
+
* `"null"` iff the discriminator is `"string"`, else `null`. Other cell values
|
|
2134
|
+
* pass through as the extracted string.
|
|
2135
|
+
*/
|
|
2136
|
+
function decodeGroupKeyCell(cell, discrim, native) {
|
|
2137
|
+
if (native)
|
|
2138
|
+
return nativeJsonKeyText(cell);
|
|
2139
|
+
if (cell == null)
|
|
2140
|
+
return null;
|
|
2141
|
+
if (cell === 'null')
|
|
2142
|
+
return discrim === 'string' ? 'null' : null;
|
|
2143
|
+
return cell;
|
|
2144
|
+
}
|
|
2145
|
+
/**
|
|
2146
|
+
* Render a native-wire JSON group-key cell to the extracted-text shape the other
|
|
2147
|
+
* transports return (PG `#>>` / embedded legacy / SQL all give text keys). Keeps
|
|
2148
|
+
* `null` as `null`; a scalar (`bigint`/`number`/`boolean`/`string`) becomes its
|
|
2149
|
+
* string form; an object/array json sub-document is stringified (best-effort
|
|
2150
|
+
* parity: canonical byte-for-byte matching is not guaranteed for nested docs).
|
|
2151
|
+
*/
|
|
2152
|
+
function nativeJsonKeyText(cell) {
|
|
2153
|
+
if (cell === null || cell === undefined)
|
|
2154
|
+
return null;
|
|
2155
|
+
if (typeof cell === 'bigint')
|
|
2156
|
+
return cell.toString();
|
|
2157
|
+
if (typeof cell === 'number' || typeof cell === 'boolean')
|
|
2158
|
+
return String(cell);
|
|
2159
|
+
if (typeof cell === 'string')
|
|
2160
|
+
return cell;
|
|
2161
|
+
try {
|
|
2162
|
+
return JSON.stringify(cell);
|
|
2163
|
+
}
|
|
2164
|
+
catch {
|
|
2165
|
+
return String(cell);
|
|
2166
|
+
}
|
|
2167
|
+
}
|