turbine-orm 0.34.0 → 0.36.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 +18 -16
- package/dist/cjs/cli/index.js +109 -16
- package/dist/cjs/cli/migrate.js +78 -3
- package/dist/cjs/cli/studio-ui.generated.js +1 -1
- package/dist/cjs/cli/studio.js +333 -22
- package/dist/cjs/cli/ui.js +7 -1
- package/dist/cjs/client.js +26 -4
- package/dist/cjs/dialect.js +2 -1
- package/dist/cjs/errors.js +41 -1
- package/dist/cjs/generate.js +23 -2
- package/dist/cjs/index.js +4 -2
- package/dist/cjs/mssql.js +27 -5
- package/dist/cjs/mysql.js +4 -0
- package/dist/cjs/powdb.js +197 -25
- package/dist/cjs/powql.js +515 -51
- package/dist/cjs/query/aggregates.js +683 -0
- package/dist/cjs/query/batched-loader.js +2 -0
- package/dist/cjs/query/builder.js +361 -4508
- package/dist/cjs/query/filters.js +12 -0
- package/dist/cjs/query/relations.js +1698 -0
- package/dist/cjs/query/where-compile.js +180 -0
- package/dist/cjs/query/where.js +1491 -0
- package/dist/cjs/query/writes.js +680 -0
- package/dist/cjs/schema-builder.js +6 -0
- package/dist/cjs/schema-metadata.js +4 -0
- package/dist/cjs/schema-sql.js +265 -3
- package/dist/cjs/sqlite.js +4 -1
- package/dist/cli/index.d.ts +8 -2
- package/dist/cli/index.js +111 -18
- package/dist/cli/migrate.d.ts +24 -1
- package/dist/cli/migrate.js +77 -3
- package/dist/cli/studio-ui.generated.js +1 -1
- package/dist/cli/studio.d.ts +46 -13
- package/dist/cli/studio.js +331 -23
- package/dist/cli/ui.js +7 -1
- package/dist/client.d.ts +32 -5
- package/dist/client.js +26 -4
- package/dist/dialect.d.ts +28 -6
- package/dist/dialect.js +2 -1
- package/dist/errors.d.ts +36 -0
- package/dist/errors.js +39 -0
- package/dist/generate.js +23 -2
- package/dist/index.d.ts +3 -3
- package/dist/index.js +2 -2
- package/dist/mssql.js +27 -5
- package/dist/mysql.js +4 -0
- package/dist/powdb.d.ts +135 -9
- package/dist/powdb.js +197 -25
- package/dist/powql.d.ts +166 -4
- package/dist/powql.js +516 -52
- package/dist/query/aggregates.d.ts +74 -0
- package/dist/query/aggregates.js +641 -0
- package/dist/query/batched-loader.d.ts +6 -0
- package/dist/query/batched-loader.js +2 -0
- package/dist/query/builder.d.ts +98 -830
- package/dist/query/builder.js +366 -4513
- package/dist/query/deferred.d.ts +13 -2
- package/dist/query/filters.d.ts +7 -0
- package/dist/query/filters.js +11 -0
- package/dist/query/relations.d.ts +441 -0
- package/dist/query/relations.js +1627 -0
- package/dist/query/types.d.ts +25 -6
- package/dist/query/where-compile.d.ts +139 -0
- package/dist/query/where-compile.js +175 -0
- package/dist/query/where.d.ts +494 -0
- package/dist/query/where.js +1431 -0
- package/dist/query/writes.d.ts +131 -0
- package/dist/query/writes.js +626 -0
- package/dist/schema-builder.d.ts +18 -3
- package/dist/schema-builder.js +6 -0
- package/dist/schema-metadata.js +4 -0
- package/dist/schema-sql.d.ts +60 -3
- package/dist/schema-sql.js +261 -4
- package/dist/schema.d.ts +10 -0
- package/dist/sqlite.js +4 -1
- package/package.json +4 -4
package/dist/powql.js
CHANGED
|
@@ -35,7 +35,7 @@
|
|
|
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
40
|
import { ALL_POWDB_CAPABILITIES, coerceNativeValue, isJsonColumn, isStaleFramePowdbError, PowdbFloatParam, PowdbJsonParam, powqlColumnType, quotePowqlIdent, requireCapability, rowToEntity, } from './powdb.js';
|
|
41
41
|
import { isJsonFilter, isRelationPickOrderBy } from './query/filters.js';
|
|
@@ -62,6 +62,23 @@ const POWQL_READ_ACTIONS = new Set([
|
|
|
62
62
|
'count',
|
|
63
63
|
'aggregate',
|
|
64
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',
|
|
65
82
|
]);
|
|
66
83
|
/** Operator keys recognised inside a `WhereOperator` object. */
|
|
67
84
|
const OPERATOR_KEYS = new Set([
|
|
@@ -145,9 +162,19 @@ export class PowqlInterface {
|
|
|
145
162
|
}
|
|
146
163
|
return col;
|
|
147
164
|
}
|
|
148
|
-
/**
|
|
149
|
-
|
|
150
|
-
|
|
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}`;
|
|
151
178
|
}
|
|
152
179
|
/**
|
|
153
180
|
* Push a value into the param array and return its `$N` placeholder. When the
|
|
@@ -216,8 +243,15 @@ export class PowqlInterface {
|
|
|
216
243
|
/**
|
|
217
244
|
* Compile a {@link WhereClause} into a PowQL filter expression, pushing every
|
|
218
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.
|
|
219
253
|
*/
|
|
220
|
-
buildWhere(where, params) {
|
|
254
|
+
buildWhere(where, params, alias) {
|
|
221
255
|
if (!where)
|
|
222
256
|
return '';
|
|
223
257
|
const parts = [];
|
|
@@ -225,17 +259,17 @@ export class PowqlInterface {
|
|
|
225
259
|
if (value === undefined)
|
|
226
260
|
continue;
|
|
227
261
|
if (key === 'AND') {
|
|
228
|
-
const sub = value.map((w) => this.buildWhere(w, params)).filter(Boolean);
|
|
262
|
+
const sub = value.map((w) => this.buildWhere(w, params, alias)).filter(Boolean);
|
|
229
263
|
if (sub.length)
|
|
230
264
|
parts.push(`(${sub.join(' and ')})`);
|
|
231
265
|
}
|
|
232
266
|
else if (key === 'OR') {
|
|
233
|
-
const sub = value.map((w) => this.buildWhere(w, params)).filter(Boolean);
|
|
267
|
+
const sub = value.map((w) => this.buildWhere(w, params, alias)).filter(Boolean);
|
|
234
268
|
if (sub.length)
|
|
235
269
|
parts.push(`(${sub.join(' or ')})`);
|
|
236
270
|
}
|
|
237
271
|
else if (key === 'NOT') {
|
|
238
|
-
const sub = this.buildWhere(value, params);
|
|
272
|
+
const sub = this.buildWhere(value, params, alias);
|
|
239
273
|
if (sub)
|
|
240
274
|
parts.push(`not (${sub})`);
|
|
241
275
|
}
|
|
@@ -248,7 +282,7 @@ export class PowqlInterface {
|
|
|
248
282
|
else {
|
|
249
283
|
// A JsonFilter (or a bare `{ path }`) can compile to zero clauses; skip
|
|
250
284
|
// empty results so buildWhere never emits a dangling ` and `.
|
|
251
|
-
const cond = this.buildFieldCondition(key, value, params);
|
|
285
|
+
const cond = this.buildFieldCondition(key, value, params, alias);
|
|
252
286
|
if (cond)
|
|
253
287
|
parts.push(cond);
|
|
254
288
|
}
|
|
@@ -256,9 +290,9 @@ export class PowqlInterface {
|
|
|
256
290
|
return parts.join(' and ');
|
|
257
291
|
}
|
|
258
292
|
/** Build a single `field: value | operator` condition. */
|
|
259
|
-
buildFieldCondition(field, value, params) {
|
|
293
|
+
buildFieldCondition(field, value, params, alias) {
|
|
260
294
|
const colMeta = this.column(field);
|
|
261
|
-
const ref = this.ref(field);
|
|
295
|
+
const ref = this.ref(field, alias);
|
|
262
296
|
if (value === null)
|
|
263
297
|
return `${ref} is null`;
|
|
264
298
|
if (value instanceof Date || typeof value !== 'object') {
|
|
@@ -271,7 +305,7 @@ export class PowqlInterface {
|
|
|
271
305
|
// path below (e.g. `equals` stays a plain equality), exactly like SQL.
|
|
272
306
|
if (isJsonColumn(colMeta) && isJsonFilter(value)) {
|
|
273
307
|
requireCapability(this.capabilities, 'jsonDocs', 'JSON path filters');
|
|
274
|
-
return this.buildJsonPathCondition(colMeta, value, params);
|
|
308
|
+
return this.buildJsonPathCondition(colMeta, value, params, alias);
|
|
275
309
|
}
|
|
276
310
|
rejectUnsupportedFilter(op, field);
|
|
277
311
|
if (!Object.keys(op).some((k) => OPERATOR_KEYS.has(k))) {
|
|
@@ -341,8 +375,8 @@ export class PowqlInterface {
|
|
|
341
375
|
* (a wrong result, not an error). Same object-key-`'0'` caveat SQL accepts: a
|
|
342
376
|
* json object whose key is literally `"0"` is addressed as an array index.
|
|
343
377
|
*/
|
|
344
|
-
jsonPathExpr(col, path, params) {
|
|
345
|
-
let expr =
|
|
378
|
+
jsonPathExpr(col, path, params, alias) {
|
|
379
|
+
let expr = this.colRefName(col.name, alias);
|
|
346
380
|
for (const seg of path) {
|
|
347
381
|
const bound = typeof seg === 'string' && /^\d+$/.test(seg) ? Number(seg) : seg;
|
|
348
382
|
expr += `->${this.param(bound, params)}`;
|
|
@@ -368,13 +402,13 @@ export class PowqlInterface {
|
|
|
368
402
|
* with SQL), so a mutation whose only `where` is a bare `{ path }` is refused
|
|
369
403
|
* by the empty-where guard.
|
|
370
404
|
*/
|
|
371
|
-
buildJsonPathCondition(col, filter, params) {
|
|
405
|
+
buildJsonPathCondition(col, filter, params, alias) {
|
|
372
406
|
const conds = [];
|
|
373
407
|
// Bind the path segments at most once and reuse the expression string across
|
|
374
408
|
// equals + range comparisons (they share the same `path`).
|
|
375
409
|
let pathExpr = null;
|
|
376
410
|
const pathP = () => {
|
|
377
|
-
pathExpr ??= this.jsonPathExpr(col, filter.path, params);
|
|
411
|
+
pathExpr ??= this.jsonPathExpr(col, filter.path, params, alias);
|
|
378
412
|
return pathExpr;
|
|
379
413
|
};
|
|
380
414
|
if (filter.contains !== undefined) {
|
|
@@ -388,7 +422,7 @@ export class PowqlInterface {
|
|
|
388
422
|
}
|
|
389
423
|
if (filter.hasKey !== undefined) {
|
|
390
424
|
// Top-level key existence, independent of `path` (mirrors PG `col ? key`).
|
|
391
|
-
conds.push(`json_type(
|
|
425
|
+
conds.push(`json_type(${this.colRefName(col.name, alias)}->${this.param(filter.hasKey, params)}) is not null`);
|
|
392
426
|
}
|
|
393
427
|
// Range comparisons on the extracted path, in the fixed gt/gte/lt/lte order.
|
|
394
428
|
// Same validation as SQL `jsonRangeEntries`: `path` required, value a finite
|
|
@@ -583,10 +617,17 @@ export class PowqlInterface {
|
|
|
583
617
|
// -------------------------------------------------------------------------
|
|
584
618
|
// Projection / order
|
|
585
619
|
// -------------------------------------------------------------------------
|
|
586
|
-
/**
|
|
587
|
-
|
|
620
|
+
/**
|
|
621
|
+
* Resolve the set of columns to project, honouring `select` / `omit` and the
|
|
622
|
+
* query-level `includePii` opt-in. PII-tagged (`defineSchema` `pii: true`)
|
|
623
|
+
* columns are EXCLUDED from a default (or omit-only) projection unless
|
|
624
|
+
* `includePii` is true; an explicit `select` naming a PII column IS the opt-in
|
|
625
|
+
* and returns it regardless. Untagged tables project exactly as before.
|
|
626
|
+
*/
|
|
627
|
+
projectedColumns(select, omit, includePii) {
|
|
588
628
|
let cols = this.meta.columns.map((c) => c.name);
|
|
589
|
-
|
|
629
|
+
const hasSelect = select && Object.keys(select).length;
|
|
630
|
+
if (hasSelect) {
|
|
590
631
|
const picked = new Set(Object.entries(select)
|
|
591
632
|
.filter(([, v]) => v)
|
|
592
633
|
.map(([k]) => this.column(k).name));
|
|
@@ -595,6 +636,14 @@ export class PowqlInterface {
|
|
|
595
636
|
picked.add(pk);
|
|
596
637
|
cols = cols.filter((c) => picked.has(c));
|
|
597
638
|
}
|
|
639
|
+
else if (!includePii) {
|
|
640
|
+
// Default / omit-only projection: drop PII columns (kept above only when a
|
|
641
|
+
// caller names them in `select`). PK is never PII in practice; if one is
|
|
642
|
+
// tagged it is still dropped here, so tag sensitive data, not keys.
|
|
643
|
+
const pii = this.piiColumnNames();
|
|
644
|
+
if (pii.size)
|
|
645
|
+
cols = cols.filter((c) => !pii.has(c));
|
|
646
|
+
}
|
|
598
647
|
if (omit && Object.keys(omit).length) {
|
|
599
648
|
const dropped = new Set(Object.entries(omit)
|
|
600
649
|
.filter(([, v]) => v)
|
|
@@ -603,6 +652,47 @@ export class PowqlInterface {
|
|
|
603
652
|
}
|
|
604
653
|
return cols;
|
|
605
654
|
}
|
|
655
|
+
/**
|
|
656
|
+
* The snake_case names of this table's PII-tagged columns. Empty for a table
|
|
657
|
+
* with no `pii: true` column, so untagged tables keep their prior projection.
|
|
658
|
+
*/
|
|
659
|
+
piiColumnNames() {
|
|
660
|
+
const out = new Set();
|
|
661
|
+
for (const col of this.meta.columns) {
|
|
662
|
+
if (col.pii)
|
|
663
|
+
out.add(col.name);
|
|
664
|
+
}
|
|
665
|
+
return out;
|
|
666
|
+
}
|
|
667
|
+
/**
|
|
668
|
+
* The camelCase field names of this table's PII-tagged columns: the read
|
|
669
|
+
* policy applied to a write's returned row (create/update/upsert/delete accept
|
|
670
|
+
* no `includePii`/`select`, so their result always drops PII; you may still
|
|
671
|
+
* write PII fields freely).
|
|
672
|
+
*
|
|
673
|
+
* SPEC LIMITATION (PowQL): the driver contract
|
|
674
|
+
* (`docs/integrations/powql-for-drivers.md`) exposes `returning` only as a
|
|
675
|
+
* bare keyword that hands back every column; it accepts NO column list, so
|
|
676
|
+
* (unlike the SQL engines, which emit an explicit non-PII `RETURNING`/`OUTPUT`
|
|
677
|
+
* projection) the create/update/delete `returning` paths cannot exclude PII at
|
|
678
|
+
* the query-language level and must strip it here after the fact. This is the
|
|
679
|
+
* client-side strip of last resort, not defense-in-depth, for those paths; we
|
|
680
|
+
* do NOT reverse-engineer an undocumented projection form. The upsert path is
|
|
681
|
+
* different: it has no `returning` and reselects by PK through the read
|
|
682
|
+
* projection ({@link projectedColumns}), which already omits PII, so PII never
|
|
683
|
+
* crosses the wire there. If a future spec revision lets `returning` take a
|
|
684
|
+
* projection, switch the write paths to emit the non-PII list and this strip
|
|
685
|
+
* becomes a no-op like {@link parseWriteRow} on the SQL engines.
|
|
686
|
+
*/
|
|
687
|
+
stripWritePii(entity) {
|
|
688
|
+
if (!entity)
|
|
689
|
+
return entity;
|
|
690
|
+
for (const col of this.meta.columns) {
|
|
691
|
+
if (col.pii)
|
|
692
|
+
delete entity[col.field];
|
|
693
|
+
}
|
|
694
|
+
return entity;
|
|
695
|
+
}
|
|
606
696
|
/** `{ .c1, .c2, … }` projection clause. */
|
|
607
697
|
projection(cols) {
|
|
608
698
|
return `{ ${cols.map((c) => `.${c}`).join(', ')} }`;
|
|
@@ -620,7 +710,7 @@ export class PowqlInterface {
|
|
|
620
710
|
* contract): for identical cross-engine results pass `nulls: 'last'`
|
|
621
711
|
* explicitly on Postgres, which defaults nulls-first for `desc`.
|
|
622
712
|
*/
|
|
623
|
-
buildOrder(orderBy, params) {
|
|
713
|
+
buildOrder(orderBy, params, alias) {
|
|
624
714
|
if (!orderBy)
|
|
625
715
|
return '';
|
|
626
716
|
const keys = Object.entries(orderBy).filter(([, dir]) => dir !== undefined);
|
|
@@ -631,7 +721,7 @@ export class PowqlInterface {
|
|
|
631
721
|
const o = dir;
|
|
632
722
|
// JSON-path ordering on a json column.
|
|
633
723
|
if (Array.isArray(o.path)) {
|
|
634
|
-
return this.buildJsonPathOrder(field, dir, params);
|
|
724
|
+
return this.buildJsonPathOrder(field, dir, params, alias);
|
|
635
725
|
}
|
|
636
726
|
// OrderBySpec { sort, nulls }: accept nulls-last (PowDB default), refuse
|
|
637
727
|
// nulls-first (no placement grammar). Distinct from vector/pick/_count.
|
|
@@ -640,7 +730,7 @@ export class PowqlInterface {
|
|
|
640
730
|
if (spec.nulls === 'first') {
|
|
641
731
|
throw new UnsupportedFeatureError('NULLS FIRST placement', 'PowDB', `field "${field}": PowDB orders NULLs / missing keys LAST in both directions`);
|
|
642
732
|
}
|
|
643
|
-
return `${this.ref(field)} ${spec.sort === 'desc' ? 'desc' : 'asc'}`;
|
|
733
|
+
return `${this.ref(field, alias)} ${spec.sort === 'desc' ? 'desc' : 'asc'}`;
|
|
644
734
|
}
|
|
645
735
|
// Name the actual feature in the refusal — a pick-row ordering
|
|
646
736
|
// reported as "vector / distance ordering" sends users hunting for
|
|
@@ -656,12 +746,12 @@ export class PowqlInterface {
|
|
|
656
746
|
: 'object-valued ordering';
|
|
657
747
|
throw new UnsupportedFeatureError(feature, 'PowDB', `field "${field}"`);
|
|
658
748
|
}
|
|
659
|
-
return `${this.ref(field)} ${dir === 'desc' ? 'desc' : 'asc'}`;
|
|
749
|
+
return `${this.ref(field, alias)} ${dir === 'desc' ? 'desc' : 'asc'}`;
|
|
660
750
|
});
|
|
661
751
|
return ` order ${parts.join(', ')}`;
|
|
662
752
|
}
|
|
663
753
|
/** Compile one {@link JsonPathOrderBy} entry to `order .col->$n asc` (+ optional numeric cast). */
|
|
664
|
-
buildJsonPathOrder(field, spec, params) {
|
|
754
|
+
buildJsonPathOrder(field, spec, params, alias) {
|
|
665
755
|
const col = this.column(field);
|
|
666
756
|
if (!isJsonColumn(col)) {
|
|
667
757
|
throw new UnsupportedFeatureError('JSON-path ordering', 'PowDB', `field "${field}" is not a json column`);
|
|
@@ -670,7 +760,7 @@ export class PowqlInterface {
|
|
|
670
760
|
if (spec.nulls === 'first') {
|
|
671
761
|
throw new UnsupportedFeatureError('NULLS FIRST placement', 'PowDB', `field "${field}": PowDB orders missing / JSON-null keys LAST in both directions`);
|
|
672
762
|
}
|
|
673
|
-
const pathExpr = this.jsonPathExpr(col, spec.path, params);
|
|
763
|
+
const pathExpr = this.jsonPathExpr(col, spec.path, params, alias);
|
|
674
764
|
// `type: 'numeric'` casts a JSON STRING number for numeric ordering; native
|
|
675
765
|
// JSON numbers already order numerically without a cast.
|
|
676
766
|
const expr = spec.type === 'numeric' ? `cast(${pathExpr}, "float")` : pathExpr;
|
|
@@ -690,6 +780,13 @@ export class PowqlInterface {
|
|
|
690
780
|
async exec(powql, params, timeout, action = 'raw') {
|
|
691
781
|
return this.execOnce(powql, params, timeout, action, false);
|
|
692
782
|
}
|
|
783
|
+
/** Build the E018 refusal for a write / `begin` on a read-only pool. */
|
|
784
|
+
readOnlyError(operation) {
|
|
785
|
+
// Pass a clean detail: the ReadOnlyError constructor owns both the
|
|
786
|
+
// `[turbine] ` prefix and the "Route writes to a writable primary." hint,
|
|
787
|
+
// so adding either here would double them.
|
|
788
|
+
return new ReadOnlyError(`${operation} on "${this.table}" refused: this PowDB connection is read-only.`);
|
|
789
|
+
}
|
|
693
790
|
/**
|
|
694
791
|
* Execute one statement, with the opt-in single stale-frame READ replay. When
|
|
695
792
|
* `retryStaleReads` is on and a first-statement READ fails with the stale-wire
|
|
@@ -704,6 +801,14 @@ export class PowqlInterface {
|
|
|
704
801
|
* write into a retryable read.
|
|
705
802
|
*/
|
|
706
803
|
async execOnce(powql, params, timeout, action, isRetry) {
|
|
804
|
+
// Read-only pool guard: refuse a write action locally, before the wire, so a
|
|
805
|
+
// read-only target never even attempts the mutation (the engine refusal, if
|
|
806
|
+
// any, is only the backstop for raw/injected paths). `action` is per-call,
|
|
807
|
+
// so a concurrent read is never mistaken for a write. Reads (incl. explain)
|
|
808
|
+
// and non-classified `raw` fall through unchanged.
|
|
809
|
+
if (this.pool.readonly === true && POWQL_WRITE_ACTIONS.has(action)) {
|
|
810
|
+
throw this.readOnlyError(action);
|
|
811
|
+
}
|
|
707
812
|
const start = performance.now();
|
|
708
813
|
const run = this.pool.query(powql, params);
|
|
709
814
|
try {
|
|
@@ -789,22 +894,27 @@ export class PowqlInterface {
|
|
|
789
894
|
// -------------------------------------------------------------------------
|
|
790
895
|
async findMany(args = {}) {
|
|
791
896
|
return this.withMiddleware('findMany', args, async () => {
|
|
792
|
-
const { rows, native } = await this.runFind(args, 'findMany');
|
|
897
|
+
const { rows, native, resolvedWhere } = await this.runFind(args, 'findMany');
|
|
793
898
|
const entities = this.shape(rows, native);
|
|
794
|
-
if (args.with)
|
|
795
|
-
await this.loadRelations(entities, args.with, args.timeout);
|
|
899
|
+
if (args.with) {
|
|
900
|
+
await this.loadRelations(entities, args.with, args.timeout, 0, { args, resolvedWhere }, args.includePii === true);
|
|
901
|
+
}
|
|
796
902
|
return entities;
|
|
797
903
|
});
|
|
798
904
|
}
|
|
799
|
-
/**
|
|
800
|
-
|
|
905
|
+
/**
|
|
906
|
+
* Compile the flat findMany select into PowQL (no execution), pushing values
|
|
907
|
+
* into `params`. Returns the query plus the RESOLVED where (relation filters
|
|
908
|
+
* already collapsed to literal in-lists) so the F2 join path can re-emit the
|
|
909
|
+
* exact parent predicate alias-qualified, and so {@link explain} can wrap it.
|
|
910
|
+
*/
|
|
911
|
+
async buildFind(args, params) {
|
|
801
912
|
if (args.cursor) {
|
|
802
913
|
throw new UnsupportedFeatureError('cursor pagination', 'PowDB', 'use limit/offset instead');
|
|
803
914
|
}
|
|
804
|
-
const params = [];
|
|
805
915
|
const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
|
|
806
916
|
const where = this.buildWhere(resolvedWhere, params);
|
|
807
|
-
const cols = this.projectedColumns(args.select, args.omit);
|
|
917
|
+
const cols = this.projectedColumns(args.select, args.omit, args.includePii === true);
|
|
808
918
|
const distinct = args.distinct?.length ? ' distinct' : '';
|
|
809
919
|
const filter = where ? ` filter ${where}` : '';
|
|
810
920
|
const order = this.buildOrder(args.orderBy, params);
|
|
@@ -816,8 +926,38 @@ export class PowqlInterface {
|
|
|
816
926
|
const limitClause = limit !== undefined ? ` limit ${this.param(limit, params)}` : '';
|
|
817
927
|
const offsetClause = args.offset ? ` offset ${this.param(args.offset, params)}` : '';
|
|
818
928
|
const powql = `${this.qt}${distinct}${filter}${order}${limitClause}${offsetClause} ${this.projection(cols)}`;
|
|
929
|
+
return { powql, resolvedWhere };
|
|
930
|
+
}
|
|
931
|
+
/** Build + run the flat findMany select; returns raw rows, the serving wire, and the resolved where. */
|
|
932
|
+
async runFind(args, action = 'findMany') {
|
|
933
|
+
const params = [];
|
|
934
|
+
const { powql, resolvedWhere } = await this.buildFind(args, params);
|
|
819
935
|
const { rows, native } = await this.exec(powql, params, args.timeout, action);
|
|
820
|
-
return { rows, native };
|
|
936
|
+
return { rows, native, resolvedWhere };
|
|
937
|
+
}
|
|
938
|
+
/**
|
|
939
|
+
* Diagnostic surface: compile the same PowQL {@link findMany} would run for
|
|
940
|
+
* `args` (no cache) and return the engine's plan as one string per line.
|
|
941
|
+
*
|
|
942
|
+
* Runs as a READ (`explain <query>`), so it is safe on a read-only pool and
|
|
943
|
+
* eligible for the stale-read replay. The line content is engine-owned and is
|
|
944
|
+
* NOT covered by semver (match plan node names / tree shape, never exact
|
|
945
|
+
* bytes; mirrors PowDB's own `explain` contract).
|
|
946
|
+
*
|
|
947
|
+
* Does NOT run through the middleware chain: plan text is a diagnostic, not
|
|
948
|
+
* entity rows, and `QueryInterface.explain` deliberately bypasses middleware
|
|
949
|
+
* too, so both engines agree.
|
|
950
|
+
*/
|
|
951
|
+
async explain(args = {}) {
|
|
952
|
+
const params = [];
|
|
953
|
+
const { powql } = await this.buildFind(args, params);
|
|
954
|
+
const { rows } = await this.exec(`explain ${powql}`, params, args.timeout, 'explain');
|
|
955
|
+
return rows
|
|
956
|
+
.map((r) => {
|
|
957
|
+
const line = r.plan ?? Object.values(r)[0];
|
|
958
|
+
return line == null ? '' : String(line);
|
|
959
|
+
})
|
|
960
|
+
.filter((line) => line.length > 0);
|
|
821
961
|
}
|
|
822
962
|
async findUnique(args) {
|
|
823
963
|
return this.withMiddleware('findUnique', args, async () => {
|
|
@@ -826,7 +966,7 @@ export class PowqlInterface {
|
|
|
826
966
|
return null;
|
|
827
967
|
const entities = this.shape(rows, native);
|
|
828
968
|
if (args.with)
|
|
829
|
-
await this.loadRelations(entities, args.with, args.timeout);
|
|
969
|
+
await this.loadRelations(entities, args.with, args.timeout, 0, undefined, args.includePii === true);
|
|
830
970
|
return entities[0];
|
|
831
971
|
});
|
|
832
972
|
}
|
|
@@ -837,7 +977,7 @@ export class PowqlInterface {
|
|
|
837
977
|
return null;
|
|
838
978
|
const entities = this.shape(rows, native);
|
|
839
979
|
if (args.with)
|
|
840
|
-
await this.loadRelations(entities, args.with, args.timeout);
|
|
980
|
+
await this.loadRelations(entities, args.with, args.timeout, 0, undefined, args.includePii === true);
|
|
841
981
|
return entities[0];
|
|
842
982
|
});
|
|
843
983
|
}
|
|
@@ -856,21 +996,50 @@ export class PowqlInterface {
|
|
|
856
996
|
// -------------------------------------------------------------------------
|
|
857
997
|
// Nested relations — batched N+1 loaders (hasMany / hasOne / belongsTo)
|
|
858
998
|
// -------------------------------------------------------------------------
|
|
859
|
-
/**
|
|
860
|
-
|
|
999
|
+
/**
|
|
1000
|
+
* Load each requested relation for `parents` and attach it onto each row.
|
|
1001
|
+
*
|
|
1002
|
+
* `parent` is supplied ONLY by the top-level {@link findMany} (its args +
|
|
1003
|
+
* resolved where). When the effective `relationLoadStrategy` resolves to an
|
|
1004
|
+
* explicit `'join'` and the pool advertises `serverJoins`, an eligible
|
|
1005
|
+
* top-level relation is loaded with a native PowQL join instead of the keyed
|
|
1006
|
+
* loaders (F2); everything else (nested `with` levels, ineligible shapes, and
|
|
1007
|
+
* the default `'batched'` strategy) keeps the loaders. Output is byte-equal
|
|
1008
|
+
* either way (the join reuses the same stitch / shape helpers).
|
|
1009
|
+
*/
|
|
1010
|
+
async loadRelations(parents, withClause, timeout, depth = 0, parent, includePii = false) {
|
|
861
1011
|
if (depth >= 10) {
|
|
862
1012
|
throw new ValidationError(`[turbine] Nested 'with' on PowDB exceeded depth 10 (relation cycle?).`);
|
|
863
1013
|
}
|
|
864
1014
|
if (!parents.length)
|
|
865
1015
|
return;
|
|
1016
|
+
// The resolved strategy is 'join' only for an EXPLICIT 'join' (per-query arg
|
|
1017
|
+
// or a client config the user set). The serverJoins capability is consulted
|
|
1018
|
+
// PER RELATION below, AFTER joinEligible, so a relation that would have
|
|
1019
|
+
// fallen back to the loaders anyway (paged parent, nested `with`, composite
|
|
1020
|
+
// key, …) never triggers the capability's E017.
|
|
1021
|
+
const strategyIsJoin = parent ? this.resolveStrategy(parent.args) === 'join' : false;
|
|
866
1022
|
for (const [relName, opt] of Object.entries(withClause)) {
|
|
867
1023
|
if (!opt)
|
|
868
1024
|
continue;
|
|
869
1025
|
const rel = this.meta.relations[relName];
|
|
870
1026
|
if (!rel)
|
|
871
1027
|
throw new ValidationError(`[turbine] Unknown relation "${relName}" on "${this.table}".`);
|
|
1028
|
+
if (strategyIsJoin && parent && this.joinEligible(rel, opt, parent.args, parents.length)) {
|
|
1029
|
+
if (this.capabilities.serverJoins) {
|
|
1030
|
+
await this.loadRelationViaJoin(parents, rel, relName, opt, parent, timeout, includePii);
|
|
1031
|
+
continue;
|
|
1032
|
+
}
|
|
1033
|
+
// An otherwise-eligible relation the engine cannot join: a PER-QUERY
|
|
1034
|
+
// `relationLoadStrategy: 'join'` is an explicit request, so throw a typed
|
|
1035
|
+
// E017; a CLIENT-LEVEL default silently falls back to the keyed loaders
|
|
1036
|
+
// (so pointing an existing app at an older engine keeps working).
|
|
1037
|
+
if (parent.args.relationLoadStrategy === 'join') {
|
|
1038
|
+
requireCapability(this.capabilities, 'serverJoins', 'native PowQL relation joins');
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
872
1041
|
if (rel.type === 'manyToMany') {
|
|
873
|
-
await this.loadManyToMany(parents, rel, relName, opt, timeout);
|
|
1042
|
+
await this.loadManyToMany(parents, rel, relName, opt, timeout, includePii);
|
|
874
1043
|
continue;
|
|
875
1044
|
}
|
|
876
1045
|
const fk = normalizeKeyColumns(rel.foreignKey);
|
|
@@ -889,23 +1058,50 @@ export class PowqlInterface {
|
|
|
889
1058
|
const keys = [
|
|
890
1059
|
...new Set(parents.map((p) => p[parentKeyField]).filter((k) => k != null)),
|
|
891
1060
|
];
|
|
892
|
-
|
|
1061
|
+
// The loader buckets children by their correlation column, so that column
|
|
1062
|
+
// MUST be in the fetched projection even when the user's select/omit drops
|
|
1063
|
+
// it. Force it into the fetch here and strip it back off the entities after
|
|
1064
|
+
// stitching (the join path already gets this for free via `__tpk`).
|
|
1065
|
+
const userSelect = options.select;
|
|
1066
|
+
const userOmit = options.omit;
|
|
1067
|
+
const fkProjected = userSelect ? Boolean(userSelect[childKeyField]) : userOmit ? !userOmit[childKeyField] : true;
|
|
1068
|
+
let fetchOptions = options;
|
|
1069
|
+
if (!fkProjected) {
|
|
1070
|
+
if (userSelect) {
|
|
1071
|
+
fetchOptions = {
|
|
1072
|
+
...options,
|
|
1073
|
+
select: { ...userSelect, [childKeyField]: true },
|
|
1074
|
+
};
|
|
1075
|
+
}
|
|
1076
|
+
else if (userOmit) {
|
|
1077
|
+
const omitWithoutFk = { ...userOmit };
|
|
1078
|
+
delete omitWithoutFk[childKeyField];
|
|
1079
|
+
fetchOptions = { ...options, omit: omitWithoutFk };
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
893
1082
|
// Chunk the key set so a single `in (…)` never exceeds PowDB's
|
|
894
|
-
// per-statement param / row limits; merge each chunk's children.
|
|
1083
|
+
// per-statement param / row limits; merge each chunk's children. Keys are
|
|
1084
|
+
// normalized through joinKey (a Date maps to micros, matching the child
|
|
1085
|
+
// cell) so a datetime correlation column stitches instead of silently
|
|
1086
|
+
// returning [].
|
|
1087
|
+
const childByKey = new Map();
|
|
895
1088
|
for (let i = 0; i < keys.length; i += MAX_RELATION_KEYS) {
|
|
896
1089
|
const chunk = keys.slice(i, i + MAX_RELATION_KEYS);
|
|
897
1090
|
const childWhere = {
|
|
898
|
-
...
|
|
1091
|
+
...fetchOptions.where,
|
|
899
1092
|
[childKeyField]: { in: chunk },
|
|
900
1093
|
};
|
|
901
1094
|
const children = (await targetQi.findMany({
|
|
902
|
-
...
|
|
1095
|
+
...fetchOptions,
|
|
903
1096
|
where: childWhere,
|
|
904
1097
|
with: options.with,
|
|
905
1098
|
timeout: options.timeout ?? timeout,
|
|
1099
|
+
includePii,
|
|
906
1100
|
}));
|
|
907
1101
|
for (const child of children) {
|
|
908
|
-
const k = child[childKeyField];
|
|
1102
|
+
const k = this.joinKey(child[childKeyField]);
|
|
1103
|
+
if (k == null)
|
|
1104
|
+
continue;
|
|
909
1105
|
const bucket = childByKey.get(k);
|
|
910
1106
|
if (bucket)
|
|
911
1107
|
bucket.push(child);
|
|
@@ -913,10 +1109,18 @@ export class PowqlInterface {
|
|
|
913
1109
|
childByKey.set(k, [child]);
|
|
914
1110
|
}
|
|
915
1111
|
}
|
|
1112
|
+
// Strip the forced correlation column back off if the user excluded it,
|
|
1113
|
+
// so the emitted entities match their select/omit exactly.
|
|
1114
|
+
if (!fkProjected) {
|
|
1115
|
+
for (const bucket of childByKey.values()) {
|
|
1116
|
+
for (const child of bucket)
|
|
1117
|
+
delete child[childKeyField];
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
916
1120
|
const single = rel.type === 'belongsTo' || rel.type === 'hasOne';
|
|
917
1121
|
for (const parent of parents) {
|
|
918
|
-
const k = parent[parentKeyField];
|
|
919
|
-
const matches = childByKey.get(k) ?? [];
|
|
1122
|
+
const k = this.joinKey(parent[parentKeyField]);
|
|
1123
|
+
const matches = (k == null ? undefined : childByKey.get(k)) ?? [];
|
|
920
1124
|
parent[relName] = single ? (matches[0] ?? null) : matches;
|
|
921
1125
|
}
|
|
922
1126
|
}
|
|
@@ -929,7 +1133,7 @@ export class PowqlInterface {
|
|
|
929
1133
|
* single-key N+1 loaders; the junction's source/target columns must be single
|
|
930
1134
|
* (composite junction keys would need PowQL tuple-`in`, which it lacks).
|
|
931
1135
|
*/
|
|
932
|
-
async loadManyToMany(parents, rel, relName, opt, timeout) {
|
|
1136
|
+
async loadManyToMany(parents, rel, relName, opt, timeout, includePii = false) {
|
|
933
1137
|
const through = rel.through;
|
|
934
1138
|
if (!through)
|
|
935
1139
|
throw new ValidationError(`[turbine] manyToMany relation "${relName}" is missing its junction (\`through\`).`);
|
|
@@ -993,6 +1197,7 @@ export class PowqlInterface {
|
|
|
993
1197
|
where,
|
|
994
1198
|
with: options.with,
|
|
995
1199
|
timeout: options.timeout ?? timeout,
|
|
1200
|
+
includePii,
|
|
996
1201
|
}));
|
|
997
1202
|
for (const t of targets)
|
|
998
1203
|
targetByPk.set(String(t[targetPkField]), t);
|
|
@@ -1011,6 +1216,257 @@ export class PowqlInterface {
|
|
|
1011
1216
|
}
|
|
1012
1217
|
}
|
|
1013
1218
|
// -------------------------------------------------------------------------
|
|
1219
|
+
// Nested relations: native PowQL joins (F2, opt-in via relationLoadStrategy)
|
|
1220
|
+
// -------------------------------------------------------------------------
|
|
1221
|
+
/**
|
|
1222
|
+
* Resolve the effective relation-load strategy: the per-query arg wins, then
|
|
1223
|
+
* the client config, then the PowDB default of `'batched'` (the keyed
|
|
1224
|
+
* loaders). PowDB deliberately does NOT inherit the SQL-side implicit `'join'`
|
|
1225
|
+
* default (that would silently flip every existing PowDB user onto brand-new
|
|
1226
|
+
* join generation). Only a value the user actually set to `'join'` activates it.
|
|
1227
|
+
*/
|
|
1228
|
+
resolveStrategy(args) {
|
|
1229
|
+
const s = args.relationLoadStrategy ?? this.options.relationLoadStrategy ?? 'batched';
|
|
1230
|
+
return s === 'join' ? 'join' : 'batched';
|
|
1231
|
+
}
|
|
1232
|
+
/**
|
|
1233
|
+
* Per-relation eligibility for the join path (checked before the serverJoins
|
|
1234
|
+
* capability). Any `false` here is a SILENT fallback to the keyed loaders (it
|
|
1235
|
+
* is never an error), so an off-page or nested-`with` shape still returns
|
|
1236
|
+
* correct rows:
|
|
1237
|
+
* - the parent query must not be paged (`limit`/`offset`/`take`, including the
|
|
1238
|
+
* configured `defaultLimit`): a parent-filter join under a page would scan
|
|
1239
|
+
* children of off-page parents, where the loaders are strictly better;
|
|
1240
|
+
* - the relation must not request a nested `with` (its subtree stays on the
|
|
1241
|
+
* loaders this round) or a `distinct`;
|
|
1242
|
+
* - single-column relation keys only (a composite key falls to the loader,
|
|
1243
|
+
* which throws the same E017 as today);
|
|
1244
|
+
* - the PARENT-SIDE correlation column must be a single-column PK or unique
|
|
1245
|
+
* column, or the INNER join would re-emit one child copy per matching
|
|
1246
|
+
* parent row (a non-unique correlation key produces duplicate children the
|
|
1247
|
+
* loader never would). For hasMany/hasOne/m2m that column is the relation's
|
|
1248
|
+
* `referenceKey` on THIS (fetched) table; for belongsTo it is the
|
|
1249
|
+
* `referenceKey` on the TARGET table (the join's non-fetched side);
|
|
1250
|
+
* - m2m keeps any `orderBy`/`limit`/`offset` on the loader (the junction-order
|
|
1251
|
+
* stitch can't be reproduced by the 3-table join deterministically);
|
|
1252
|
+
* - a to-one relation `limit`/`offset` (meaningless) stays on the loader, as
|
|
1253
|
+
* does a to-many relation `limit`/`offset` when the parent set spills past
|
|
1254
|
+
* one loader chunk (the loader limits per chunk, the join once globally).
|
|
1255
|
+
*/
|
|
1256
|
+
joinEligible(rel, opt, args, parentCount) {
|
|
1257
|
+
const effLimit = args.limit ?? args.take ?? this.defaultLimit;
|
|
1258
|
+
if (effLimit !== undefined || args.offset)
|
|
1259
|
+
return false;
|
|
1260
|
+
const options = (opt === true ? {} : opt);
|
|
1261
|
+
if (options.with)
|
|
1262
|
+
return false;
|
|
1263
|
+
if (options.distinct?.length)
|
|
1264
|
+
return false;
|
|
1265
|
+
if (rel.type === 'manyToMany') {
|
|
1266
|
+
const through = rel.through;
|
|
1267
|
+
if (!through)
|
|
1268
|
+
return false;
|
|
1269
|
+
if (normalizeKeyColumns(through.sourceKey).length > 1 ||
|
|
1270
|
+
normalizeKeyColumns(through.targetKey).length > 1 ||
|
|
1271
|
+
normalizeKeyColumns(rel.referenceKey).length > 1 ||
|
|
1272
|
+
(this.schema.tables[rel.to]?.primaryKey.length ?? 2) > 1) {
|
|
1273
|
+
return false;
|
|
1274
|
+
}
|
|
1275
|
+
if (options.orderBy || options.limit !== undefined || options.offset)
|
|
1276
|
+
return false;
|
|
1277
|
+
// The parent joins on its referenceKey; a non-unique one duplicates.
|
|
1278
|
+
if (!this.isSingleColumnUnique(this.meta, normalizeKeyColumns(rel.referenceKey)[0]))
|
|
1279
|
+
return false;
|
|
1280
|
+
return true;
|
|
1281
|
+
}
|
|
1282
|
+
if (normalizeKeyColumns(rel.foreignKey).length > 1 || normalizeKeyColumns(rel.referenceKey).length > 1) {
|
|
1283
|
+
return false;
|
|
1284
|
+
}
|
|
1285
|
+
// Reject a non-unique correlation key: on belongsTo the fetched side joins on
|
|
1286
|
+
// the target's referenceKey, otherwise the fetched side joins on its own.
|
|
1287
|
+
if (rel.type === 'belongsTo') {
|
|
1288
|
+
const targetMeta = this.schema.tables[rel.to];
|
|
1289
|
+
if (!targetMeta || !this.isSingleColumnUnique(targetMeta, normalizeKeyColumns(rel.referenceKey)[0])) {
|
|
1290
|
+
return false;
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
else if (!this.isSingleColumnUnique(this.meta, normalizeKeyColumns(rel.referenceKey)[0])) {
|
|
1294
|
+
return false;
|
|
1295
|
+
}
|
|
1296
|
+
const single = rel.type === 'belongsTo' || rel.type === 'hasOne';
|
|
1297
|
+
if ((options.limit !== undefined || options.offset) && (single || parentCount > MAX_RELATION_KEYS)) {
|
|
1298
|
+
return false;
|
|
1299
|
+
}
|
|
1300
|
+
return true;
|
|
1301
|
+
}
|
|
1302
|
+
/**
|
|
1303
|
+
* True when `col` is a single-column unique key of `tableMeta`: the sole
|
|
1304
|
+
* primary-key column, a single-column entry in `uniqueColumns` (where a
|
|
1305
|
+
* per-column `unique: true` and an introspected single-column unique constraint
|
|
1306
|
+
* both land), or a single-column unique index. Used by {@link joinEligible} to
|
|
1307
|
+
* keep the INNER-join path off relations whose parent-side correlation column
|
|
1308
|
+
* can repeat (which would duplicate children).
|
|
1309
|
+
*/
|
|
1310
|
+
isSingleColumnUnique(tableMeta, col) {
|
|
1311
|
+
if (tableMeta.primaryKey.length === 1 && tableMeta.primaryKey[0] === col)
|
|
1312
|
+
return true;
|
|
1313
|
+
if (tableMeta.uniqueColumns.some((cols) => cols.length === 1 && cols[0] === col))
|
|
1314
|
+
return true;
|
|
1315
|
+
return tableMeta.indexes.some((idx) => idx.unique && !idx.docPath && idx.columns.length === 1 && idx.columns[0] === col);
|
|
1316
|
+
}
|
|
1317
|
+
/** Dispatch one eligible relation to the correct native-join loader. */
|
|
1318
|
+
async loadRelationViaJoin(parents, rel, relName, opt, parent, timeout, includePii = false) {
|
|
1319
|
+
if (rel.type === 'manyToMany') {
|
|
1320
|
+
await this.loadManyToManyViaJoin(parents, rel, relName, opt, parent, timeout, includePii);
|
|
1321
|
+
return;
|
|
1322
|
+
}
|
|
1323
|
+
const options = (opt === true ? {} : opt);
|
|
1324
|
+
const targetMeta = this.schema.tables[rel.to];
|
|
1325
|
+
if (!targetMeta)
|
|
1326
|
+
throw new ValidationError(`[turbine] Relation "${relName}" targets unknown table "${rel.to}".`);
|
|
1327
|
+
const targetQi = new PowqlInterface(this.pool, rel.to, this.schema, [], this.options);
|
|
1328
|
+
const fk = normalizeKeyColumns(rel.foreignKey);
|
|
1329
|
+
const rk = normalizeKeyColumns(rel.referenceKey);
|
|
1330
|
+
// Correlation math is identical to the keyed loaders, only the transport
|
|
1331
|
+
// (join vs in-list) changes. Always join the RELATION TARGET (alias `c`) to
|
|
1332
|
+
// the already-fetched side (alias `p`), correlating on the fetched side's key
|
|
1333
|
+
// and projecting `__tpk` from the fetched side's correlation column.
|
|
1334
|
+
const parentKeyCol = rel.type === 'belongsTo' ? fk[0] : rk[0];
|
|
1335
|
+
const childKeyCol = rel.type === 'belongsTo' ? rk[0] : fk[0];
|
|
1336
|
+
const parentKeyField = this.meta.reverseColumnMap[parentKeyCol] ?? parentKeyCol;
|
|
1337
|
+
const params = [];
|
|
1338
|
+
const childCols = this.joinChildCols(targetQi, options, includePii);
|
|
1339
|
+
const filter = await this.joinFilter(targetQi, parent.resolvedWhere, options.where, 'c', params, options.timeout ?? timeout);
|
|
1340
|
+
const order = targetQi.buildOrder(options.orderBy, params, 'c');
|
|
1341
|
+
const limitClause = options.limit !== undefined ? ` limit ${this.param(options.limit, params)}` : '';
|
|
1342
|
+
const offsetClause = options.offset ? ` offset ${this.param(options.offset, params)}` : '';
|
|
1343
|
+
const proj = this.joinProjection(childCols, `p.${quotePowqlIdent(parentKeyCol)}`, 'c');
|
|
1344
|
+
const powql = `${targetQi.qt} as c join ${this.qt} as p ` +
|
|
1345
|
+
`on c.${quotePowqlIdent(childKeyCol)} = p.${quotePowqlIdent(parentKeyCol)}` +
|
|
1346
|
+
`${filter}${order}${limitClause}${offsetClause} ${proj}`;
|
|
1347
|
+
// A READ: thread a read-shaped action through the exec seam.
|
|
1348
|
+
const { rows, native } = await targetQi.exec(powql, params, timeout, 'findMany');
|
|
1349
|
+
const single = rel.type === 'belongsTo' || rel.type === 'hasOne';
|
|
1350
|
+
const byKey = this.bucketByTpk(targetQi, rows, native);
|
|
1351
|
+
for (const p of parents) {
|
|
1352
|
+
const key = this.joinKey(p[parentKeyField]);
|
|
1353
|
+
const matches = (key == null ? undefined : byKey.get(key)) ?? [];
|
|
1354
|
+
p[relName] = single ? (matches[0] ?? null) : matches;
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
/**
|
|
1358
|
+
* manyToMany via chained joins: the target (alias `t`) → junction (alias `j`)
|
|
1359
|
+
* → the already-fetched side (alias `p`), correlating `__tpk` from the
|
|
1360
|
+
* junction's source key. Always a list, stitched exactly like the loader.
|
|
1361
|
+
*/
|
|
1362
|
+
async loadManyToManyViaJoin(parents, rel, relName, opt, parent, timeout, includePii = false) {
|
|
1363
|
+
const through = rel.through;
|
|
1364
|
+
if (!through)
|
|
1365
|
+
throw new ValidationError(`[turbine] manyToMany relation "${relName}" is missing its junction (\`through\`).`);
|
|
1366
|
+
const options = (opt === true ? {} : opt);
|
|
1367
|
+
const targetMeta = this.schema.tables[rel.to];
|
|
1368
|
+
if (!targetMeta)
|
|
1369
|
+
throw new ValidationError(`[turbine] Relation "${relName}" targets unknown table "${rel.to}".`);
|
|
1370
|
+
const targetQi = new PowqlInterface(this.pool, rel.to, this.schema, [], this.options);
|
|
1371
|
+
const sourceJCol = normalizeKeyColumns(through.sourceKey)[0];
|
|
1372
|
+
const targetJCol = normalizeKeyColumns(through.targetKey)[0];
|
|
1373
|
+
const sourceRefCol = normalizeKeyColumns(rel.referenceKey)[0];
|
|
1374
|
+
const targetPkCol = targetMeta.primaryKey[0];
|
|
1375
|
+
const parentRefField = this.meta.reverseColumnMap[sourceRefCol] ?? sourceRefCol;
|
|
1376
|
+
const params = [];
|
|
1377
|
+
const childCols = this.joinChildCols(targetQi, options, includePii);
|
|
1378
|
+
const filter = await this.joinFilter(targetQi, parent.resolvedWhere, options.where, 't', params, options.timeout ?? timeout);
|
|
1379
|
+
const proj = this.joinProjection(childCols, `j.${quotePowqlIdent(sourceJCol)}`, 't');
|
|
1380
|
+
const powql = `${targetQi.qt} as t ` +
|
|
1381
|
+
`join ${quotePowqlIdent(through.table)} as j on t.${quotePowqlIdent(targetPkCol)} = j.${quotePowqlIdent(targetJCol)} ` +
|
|
1382
|
+
`join ${this.qt} as p on j.${quotePowqlIdent(sourceJCol)} = p.${quotePowqlIdent(sourceRefCol)}` +
|
|
1383
|
+
`${filter} ${proj}`;
|
|
1384
|
+
const { rows, native } = await targetQi.exec(powql, params, timeout, 'findMany');
|
|
1385
|
+
const byKey = this.bucketByTpk(targetQi, rows, native);
|
|
1386
|
+
for (const p of parents) {
|
|
1387
|
+
const key = this.joinKey(p[parentRefField]);
|
|
1388
|
+
p[relName] = (key == null ? undefined : byKey.get(key)) ?? [];
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
/**
|
|
1392
|
+
* The target column list to project through the join (honouring select/omit),
|
|
1393
|
+
* with a loud guard: a real column named `__tpk` would collide with the
|
|
1394
|
+
* reserved correlation alias, so refuse rather than silently mis-stitch.
|
|
1395
|
+
*/
|
|
1396
|
+
joinChildCols(targetQi, options, includePii = false) {
|
|
1397
|
+
const cols = targetQi.projectedColumns(options.select, options.omit, includePii);
|
|
1398
|
+
if (cols.includes('__tpk')) {
|
|
1399
|
+
throw new ValidationError(`[turbine] relation target "${targetQi.table}" has a column named "__tpk", which collides with the reserved ` +
|
|
1400
|
+
`join correlation alias. Rename the column or load this relation with relationLoadStrategy: 'batched'.`);
|
|
1401
|
+
}
|
|
1402
|
+
return cols;
|
|
1403
|
+
}
|
|
1404
|
+
/**
|
|
1405
|
+
* `{ __tpk: <tpkExpr>, <col>: <childAlias>.<col>, … }`. Each child column is
|
|
1406
|
+
* ALIASED to its bare name (a bare qualified ref `c.col` would come back named
|
|
1407
|
+
* `c.col`, not `col`) so the stitched rows shape identically to a flat select.
|
|
1408
|
+
*/
|
|
1409
|
+
joinProjection(childCols, tpkExpr, childAlias) {
|
|
1410
|
+
const parts = [
|
|
1411
|
+
`__tpk: ${tpkExpr}`,
|
|
1412
|
+
...childCols.map((c) => `${quotePowqlIdent(c)}: ${childAlias}.${quotePowqlIdent(c)}`),
|
|
1413
|
+
];
|
|
1414
|
+
return `{ ${parts.join(', ')} }`;
|
|
1415
|
+
}
|
|
1416
|
+
/**
|
|
1417
|
+
* `filter <parentWhere qualified p> [and <relationWhere qualified childAlias>]`.
|
|
1418
|
+
* The parent where is the ALREADY-RESOLVED predicate (relation filters collapsed
|
|
1419
|
+
* to literal in-lists before the base query ran); the relation where is resolved
|
|
1420
|
+
* on the target the same way before qualifying, so a nested relation filter in
|
|
1421
|
+
* the relation `where` never reaches the join unresolved. Params bind in order.
|
|
1422
|
+
*/
|
|
1423
|
+
async joinFilter(targetQi, parentResolvedWhere, relWhere, childAlias, params, timeout) {
|
|
1424
|
+
const parts = [];
|
|
1425
|
+
const pw = this.buildWhere(parentResolvedWhere, params, 'p');
|
|
1426
|
+
if (pw)
|
|
1427
|
+
parts.push(pw);
|
|
1428
|
+
const relResolved = await targetQi.resolveRelationFilters(relWhere, timeout);
|
|
1429
|
+
const rw = targetQi.buildWhere(relResolved, params, childAlias);
|
|
1430
|
+
if (rw)
|
|
1431
|
+
parts.push(rw);
|
|
1432
|
+
return parts.length ? ` filter ${parts.join(' and ')}` : '';
|
|
1433
|
+
}
|
|
1434
|
+
/** Group join rows by their (normalized) `__tpk`, stripping it and shaping each child. */
|
|
1435
|
+
bucketByTpk(targetQi, rows, native) {
|
|
1436
|
+
const byKey = new Map();
|
|
1437
|
+
for (const raw of rows) {
|
|
1438
|
+
const tpk = this.joinKey(raw.__tpk);
|
|
1439
|
+
delete raw.__tpk;
|
|
1440
|
+
const child = targetQi.shape([raw], native)[0];
|
|
1441
|
+
if (tpk == null)
|
|
1442
|
+
continue;
|
|
1443
|
+
const bucket = byKey.get(tpk);
|
|
1444
|
+
if (bucket)
|
|
1445
|
+
bucket.push(child);
|
|
1446
|
+
else
|
|
1447
|
+
byKey.set(tpk, [child]);
|
|
1448
|
+
}
|
|
1449
|
+
return byKey;
|
|
1450
|
+
}
|
|
1451
|
+
/**
|
|
1452
|
+
* Normalize a correlation key to a stable string map key so a parent's key
|
|
1453
|
+
* value (a shaped entity field) and a child row's `__tpk` cell match across
|
|
1454
|
+
* wires and column types. A `Date` maps to microseconds
|
|
1455
|
+
* (`getTime()` ms times 1000), because a datetime correlation cell arrives as
|
|
1456
|
+
* raw micros (bigint on the native wire, a micros string on the legacy wire),
|
|
1457
|
+
* never as ms. bigint / number / string all stringify to the same digits, so
|
|
1458
|
+
* an int key matches whether it came back typed or as text.
|
|
1459
|
+
*/
|
|
1460
|
+
joinKey(v) {
|
|
1461
|
+
if (v == null)
|
|
1462
|
+
return null;
|
|
1463
|
+
if (v instanceof Date)
|
|
1464
|
+
return (BigInt(v.getTime()) * 1000n).toString();
|
|
1465
|
+
if (typeof v === 'bigint')
|
|
1466
|
+
return v.toString();
|
|
1467
|
+
return String(v);
|
|
1468
|
+
}
|
|
1469
|
+
// -------------------------------------------------------------------------
|
|
1014
1470
|
// Writes (reselect — PowDB has no RETURNING)
|
|
1015
1471
|
// -------------------------------------------------------------------------
|
|
1016
1472
|
/** Split `data` into scalar assignments; reject relation (nested-write) keys. */
|
|
@@ -1067,7 +1523,7 @@ export class PowqlInterface {
|
|
|
1067
1523
|
.join(', ');
|
|
1068
1524
|
// `returning` surfaces the inserted row (all columns, schema order) in one round-trip.
|
|
1069
1525
|
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;
|
|
1526
|
+
const row = rows.length ? this.stripWritePii(this.shape(rows, native)[0]) : null;
|
|
1071
1527
|
if (!row)
|
|
1072
1528
|
throw new NotFoundError({ table: this.table, where: data });
|
|
1073
1529
|
return row;
|
|
@@ -1085,7 +1541,7 @@ export class PowqlInterface {
|
|
|
1085
1541
|
});
|
|
1086
1542
|
// Multi-row insert with `returning` hands back every inserted row in one round-trip.
|
|
1087
1543
|
const { rows, native } = await this.exec(`insert ${this.qt} ${tuples.join(', ')} returning`, params, args.timeout, 'createMany');
|
|
1088
|
-
return this.shape(rows, native);
|
|
1544
|
+
return this.shape(rows, native).map((r) => this.stripWritePii(r));
|
|
1089
1545
|
});
|
|
1090
1546
|
}
|
|
1091
1547
|
async update(args) {
|
|
@@ -1100,7 +1556,7 @@ export class PowqlInterface {
|
|
|
1100
1556
|
const setClause = this.buildUpdateAssignments(args.data, params);
|
|
1101
1557
|
// `returning` hands back the post-update row(s); take the first (single-row contract).
|
|
1102
1558
|
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;
|
|
1559
|
+
const row = rows.length ? this.stripWritePii(this.shape(rows, native)[0]) : null;
|
|
1104
1560
|
if (!row)
|
|
1105
1561
|
throw new NotFoundError({ table: this.table, where: args.where });
|
|
1106
1562
|
return row;
|
|
@@ -1183,6 +1639,11 @@ export class PowqlInterface {
|
|
|
1183
1639
|
}
|
|
1184
1640
|
/** Open a flat PowDB transaction on a pinned connection and run `fn` inside it. */
|
|
1185
1641
|
async runInImplicitTx(fn) {
|
|
1642
|
+
// A transaction-control `begin` is a write on a read-only pool: refuse it
|
|
1643
|
+
// locally before checking out a connection (zero wire / pool activity), the
|
|
1644
|
+
// same guard the exec seam applies to plain writes.
|
|
1645
|
+
if (this.pool.readonly === true)
|
|
1646
|
+
throw this.readOnlyError('transaction (begin)');
|
|
1186
1647
|
// Route tx keywords through the dialect (like the SQL path) so this never
|
|
1187
1648
|
// drifts from `powdbDialect`; falls back to the literal lowercase keywords.
|
|
1188
1649
|
const d = this.options.dialect;
|
|
@@ -1192,7 +1653,10 @@ export class PowqlInterface {
|
|
|
1192
1653
|
await client.query(d?.beginStatement?.() ?? 'begin');
|
|
1193
1654
|
began = true;
|
|
1194
1655
|
const { TransactionClient } = await import('./client.js');
|
|
1195
|
-
const tx = new TransactionClient(client, this.schema, this.middlewares, this.options
|
|
1656
|
+
const tx = new TransactionClient(client, this.schema, this.middlewares, this.options,
|
|
1657
|
+
// Pass the PowDB pool so its read-only guard + capabilities carry into
|
|
1658
|
+
// the transaction-scoped proxy pool (see createTxPool).
|
|
1659
|
+
this.pool);
|
|
1196
1660
|
const ctx = { schema: this.schema, tx: tx };
|
|
1197
1661
|
// Plant the single-writer re-entrancy marker for the implicit tx's
|
|
1198
1662
|
// subtree (same seam TurbineClient.$transaction uses) — user code that
|
|
@@ -1238,7 +1702,7 @@ export class PowqlInterface {
|
|
|
1238
1702
|
this.assertCompiledWhere(where, false, 'delete');
|
|
1239
1703
|
// `returning` hands back the deleted row(s) — no separate pre-image reselect needed.
|
|
1240
1704
|
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;
|
|
1705
|
+
const row = rows.length ? this.stripWritePii(this.shape(rows, native)[0]) : null;
|
|
1242
1706
|
if (!row)
|
|
1243
1707
|
throw new NotFoundError({ table: this.table, where: args.where });
|
|
1244
1708
|
return row;
|