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/cjs/powql.js
CHANGED
|
@@ -98,6 +98,23 @@ const POWQL_READ_ACTIONS = new Set([
|
|
|
98
98
|
'count',
|
|
99
99
|
'aggregate',
|
|
100
100
|
'groupBy',
|
|
101
|
+
'explain',
|
|
102
|
+
]);
|
|
103
|
+
/**
|
|
104
|
+
* Mutating actions the {@link PowqlInterface} readonly guard refuses locally
|
|
105
|
+
* (before the wire) on a read-only pool. A transaction-control `begin` is
|
|
106
|
+
* guarded separately in {@link PowqlInterface.runInImplicitTx}. Kept keyed on
|
|
107
|
+
* the per-call action string (never `this`-state) so a concurrent read can
|
|
108
|
+
* never be mistaken for one of these.
|
|
109
|
+
*/
|
|
110
|
+
const POWQL_WRITE_ACTIONS = new Set([
|
|
111
|
+
'create',
|
|
112
|
+
'createMany',
|
|
113
|
+
'update',
|
|
114
|
+
'updateMany',
|
|
115
|
+
'delete',
|
|
116
|
+
'deleteMany',
|
|
117
|
+
'upsert',
|
|
101
118
|
]);
|
|
102
119
|
/** Operator keys recognised inside a `WhereOperator` object. */
|
|
103
120
|
const OPERATOR_KEYS = new Set([
|
|
@@ -181,9 +198,19 @@ class PowqlInterface {
|
|
|
181
198
|
}
|
|
182
199
|
return col;
|
|
183
200
|
}
|
|
184
|
-
/**
|
|
185
|
-
|
|
186
|
-
|
|
201
|
+
/**
|
|
202
|
+
* PowQL column reference for a field. Unqualified it is a dotted field
|
|
203
|
+
* reference (`.snake_name`), which bypasses keyword lookup. When an `alias`
|
|
204
|
+
* is supplied (the F2 join path) it is qualified (`alias.snake_name`) and the
|
|
205
|
+
* column name is backtick-quoted if it is a reserved word (a qualified
|
|
206
|
+
* `p.order` does NOT bypass keyword lookup, unlike the dotted `.order`).
|
|
207
|
+
*/
|
|
208
|
+
ref(field, alias) {
|
|
209
|
+
return this.colRefName(this.column(field).name, alias);
|
|
210
|
+
}
|
|
211
|
+
/** Render a raw column name as a PowQL reference, qualified with `alias` when given. */
|
|
212
|
+
colRefName(name, alias) {
|
|
213
|
+
return alias ? `${alias}.${(0, powdb_js_1.quotePowqlIdent)(name)}` : `.${name}`;
|
|
187
214
|
}
|
|
188
215
|
/**
|
|
189
216
|
* Push a value into the param array and return its `$N` placeholder. When the
|
|
@@ -252,8 +279,15 @@ class PowqlInterface {
|
|
|
252
279
|
/**
|
|
253
280
|
* Compile a {@link WhereClause} into a PowQL filter expression, pushing every
|
|
254
281
|
* value as a positional `$N` param. Returns `''` when there are no conditions.
|
|
282
|
+
*
|
|
283
|
+
* When `alias` is supplied (the F2 native-join path) every field reference is
|
|
284
|
+
* qualified with it (`.col` → `alias.col`, JSON path bases too); params bind
|
|
285
|
+
* exactly as in the unqualified path. The caller only ever passes an alias for
|
|
286
|
+
* an already-RESOLVED where (relation filters pre-resolved to literal in-lists
|
|
287
|
+
* by {@link resolveRelationFilters}): the relation-key branch below still
|
|
288
|
+
* throws, so an unresolved relation filter can never leak into a join.
|
|
255
289
|
*/
|
|
256
|
-
buildWhere(where, params) {
|
|
290
|
+
buildWhere(where, params, alias) {
|
|
257
291
|
if (!where)
|
|
258
292
|
return '';
|
|
259
293
|
const parts = [];
|
|
@@ -261,17 +295,17 @@ class PowqlInterface {
|
|
|
261
295
|
if (value === undefined)
|
|
262
296
|
continue;
|
|
263
297
|
if (key === 'AND') {
|
|
264
|
-
const sub = value.map((w) => this.buildWhere(w, params)).filter(Boolean);
|
|
298
|
+
const sub = value.map((w) => this.buildWhere(w, params, alias)).filter(Boolean);
|
|
265
299
|
if (sub.length)
|
|
266
300
|
parts.push(`(${sub.join(' and ')})`);
|
|
267
301
|
}
|
|
268
302
|
else if (key === 'OR') {
|
|
269
|
-
const sub = value.map((w) => this.buildWhere(w, params)).filter(Boolean);
|
|
303
|
+
const sub = value.map((w) => this.buildWhere(w, params, alias)).filter(Boolean);
|
|
270
304
|
if (sub.length)
|
|
271
305
|
parts.push(`(${sub.join(' or ')})`);
|
|
272
306
|
}
|
|
273
307
|
else if (key === 'NOT') {
|
|
274
|
-
const sub = this.buildWhere(value, params);
|
|
308
|
+
const sub = this.buildWhere(value, params, alias);
|
|
275
309
|
if (sub)
|
|
276
310
|
parts.push(`not (${sub})`);
|
|
277
311
|
}
|
|
@@ -284,7 +318,7 @@ class PowqlInterface {
|
|
|
284
318
|
else {
|
|
285
319
|
// A JsonFilter (or a bare `{ path }`) can compile to zero clauses; skip
|
|
286
320
|
// empty results so buildWhere never emits a dangling ` and `.
|
|
287
|
-
const cond = this.buildFieldCondition(key, value, params);
|
|
321
|
+
const cond = this.buildFieldCondition(key, value, params, alias);
|
|
288
322
|
if (cond)
|
|
289
323
|
parts.push(cond);
|
|
290
324
|
}
|
|
@@ -292,9 +326,9 @@ class PowqlInterface {
|
|
|
292
326
|
return parts.join(' and ');
|
|
293
327
|
}
|
|
294
328
|
/** Build a single `field: value | operator` condition. */
|
|
295
|
-
buildFieldCondition(field, value, params) {
|
|
329
|
+
buildFieldCondition(field, value, params, alias) {
|
|
296
330
|
const colMeta = this.column(field);
|
|
297
|
-
const ref = this.ref(field);
|
|
331
|
+
const ref = this.ref(field, alias);
|
|
298
332
|
if (value === null)
|
|
299
333
|
return `${ref} is null`;
|
|
300
334
|
if (value instanceof Date || typeof value !== 'object') {
|
|
@@ -307,7 +341,7 @@ class PowqlInterface {
|
|
|
307
341
|
// path below (e.g. `equals` stays a plain equality), exactly like SQL.
|
|
308
342
|
if ((0, powdb_js_1.isJsonColumn)(colMeta) && (0, filters_js_1.isJsonFilter)(value)) {
|
|
309
343
|
(0, powdb_js_1.requireCapability)(this.capabilities, 'jsonDocs', 'JSON path filters');
|
|
310
|
-
return this.buildJsonPathCondition(colMeta, value, params);
|
|
344
|
+
return this.buildJsonPathCondition(colMeta, value, params, alias);
|
|
311
345
|
}
|
|
312
346
|
rejectUnsupportedFilter(op, field);
|
|
313
347
|
if (!Object.keys(op).some((k) => OPERATOR_KEYS.has(k))) {
|
|
@@ -377,8 +411,8 @@ class PowqlInterface {
|
|
|
377
411
|
* (a wrong result, not an error). Same object-key-`'0'` caveat SQL accepts: a
|
|
378
412
|
* json object whose key is literally `"0"` is addressed as an array index.
|
|
379
413
|
*/
|
|
380
|
-
jsonPathExpr(col, path, params) {
|
|
381
|
-
let expr =
|
|
414
|
+
jsonPathExpr(col, path, params, alias) {
|
|
415
|
+
let expr = this.colRefName(col.name, alias);
|
|
382
416
|
for (const seg of path) {
|
|
383
417
|
const bound = typeof seg === 'string' && /^\d+$/.test(seg) ? Number(seg) : seg;
|
|
384
418
|
expr += `->${this.param(bound, params)}`;
|
|
@@ -404,13 +438,13 @@ class PowqlInterface {
|
|
|
404
438
|
* with SQL), so a mutation whose only `where` is a bare `{ path }` is refused
|
|
405
439
|
* by the empty-where guard.
|
|
406
440
|
*/
|
|
407
|
-
buildJsonPathCondition(col, filter, params) {
|
|
441
|
+
buildJsonPathCondition(col, filter, params, alias) {
|
|
408
442
|
const conds = [];
|
|
409
443
|
// Bind the path segments at most once and reuse the expression string across
|
|
410
444
|
// equals + range comparisons (they share the same `path`).
|
|
411
445
|
let pathExpr = null;
|
|
412
446
|
const pathP = () => {
|
|
413
|
-
pathExpr ??= this.jsonPathExpr(col, filter.path, params);
|
|
447
|
+
pathExpr ??= this.jsonPathExpr(col, filter.path, params, alias);
|
|
414
448
|
return pathExpr;
|
|
415
449
|
};
|
|
416
450
|
if (filter.contains !== undefined) {
|
|
@@ -424,7 +458,7 @@ class PowqlInterface {
|
|
|
424
458
|
}
|
|
425
459
|
if (filter.hasKey !== undefined) {
|
|
426
460
|
// Top-level key existence, independent of `path` (mirrors PG `col ? key`).
|
|
427
|
-
conds.push(`json_type(
|
|
461
|
+
conds.push(`json_type(${this.colRefName(col.name, alias)}->${this.param(filter.hasKey, params)}) is not null`);
|
|
428
462
|
}
|
|
429
463
|
// Range comparisons on the extracted path, in the fixed gt/gte/lt/lte order.
|
|
430
464
|
// Same validation as SQL `jsonRangeEntries`: `path` required, value a finite
|
|
@@ -619,10 +653,17 @@ class PowqlInterface {
|
|
|
619
653
|
// -------------------------------------------------------------------------
|
|
620
654
|
// Projection / order
|
|
621
655
|
// -------------------------------------------------------------------------
|
|
622
|
-
/**
|
|
623
|
-
|
|
656
|
+
/**
|
|
657
|
+
* Resolve the set of columns to project, honouring `select` / `omit` and the
|
|
658
|
+
* query-level `includePii` opt-in. PII-tagged (`defineSchema` `pii: true`)
|
|
659
|
+
* columns are EXCLUDED from a default (or omit-only) projection unless
|
|
660
|
+
* `includePii` is true; an explicit `select` naming a PII column IS the opt-in
|
|
661
|
+
* and returns it regardless. Untagged tables project exactly as before.
|
|
662
|
+
*/
|
|
663
|
+
projectedColumns(select, omit, includePii) {
|
|
624
664
|
let cols = this.meta.columns.map((c) => c.name);
|
|
625
|
-
|
|
665
|
+
const hasSelect = select && Object.keys(select).length;
|
|
666
|
+
if (hasSelect) {
|
|
626
667
|
const picked = new Set(Object.entries(select)
|
|
627
668
|
.filter(([, v]) => v)
|
|
628
669
|
.map(([k]) => this.column(k).name));
|
|
@@ -631,6 +672,14 @@ class PowqlInterface {
|
|
|
631
672
|
picked.add(pk);
|
|
632
673
|
cols = cols.filter((c) => picked.has(c));
|
|
633
674
|
}
|
|
675
|
+
else if (!includePii) {
|
|
676
|
+
// Default / omit-only projection: drop PII columns (kept above only when a
|
|
677
|
+
// caller names them in `select`). PK is never PII in practice; if one is
|
|
678
|
+
// tagged it is still dropped here, so tag sensitive data, not keys.
|
|
679
|
+
const pii = this.piiColumnNames();
|
|
680
|
+
if (pii.size)
|
|
681
|
+
cols = cols.filter((c) => !pii.has(c));
|
|
682
|
+
}
|
|
634
683
|
if (omit && Object.keys(omit).length) {
|
|
635
684
|
const dropped = new Set(Object.entries(omit)
|
|
636
685
|
.filter(([, v]) => v)
|
|
@@ -639,6 +688,47 @@ class PowqlInterface {
|
|
|
639
688
|
}
|
|
640
689
|
return cols;
|
|
641
690
|
}
|
|
691
|
+
/**
|
|
692
|
+
* The snake_case names of this table's PII-tagged columns. Empty for a table
|
|
693
|
+
* with no `pii: true` column, so untagged tables keep their prior projection.
|
|
694
|
+
*/
|
|
695
|
+
piiColumnNames() {
|
|
696
|
+
const out = new Set();
|
|
697
|
+
for (const col of this.meta.columns) {
|
|
698
|
+
if (col.pii)
|
|
699
|
+
out.add(col.name);
|
|
700
|
+
}
|
|
701
|
+
return out;
|
|
702
|
+
}
|
|
703
|
+
/**
|
|
704
|
+
* The camelCase field names of this table's PII-tagged columns: the read
|
|
705
|
+
* policy applied to a write's returned row (create/update/upsert/delete accept
|
|
706
|
+
* no `includePii`/`select`, so their result always drops PII; you may still
|
|
707
|
+
* write PII fields freely).
|
|
708
|
+
*
|
|
709
|
+
* SPEC LIMITATION (PowQL): the driver contract
|
|
710
|
+
* (`docs/integrations/powql-for-drivers.md`) exposes `returning` only as a
|
|
711
|
+
* bare keyword that hands back every column; it accepts NO column list, so
|
|
712
|
+
* (unlike the SQL engines, which emit an explicit non-PII `RETURNING`/`OUTPUT`
|
|
713
|
+
* projection) the create/update/delete `returning` paths cannot exclude PII at
|
|
714
|
+
* the query-language level and must strip it here after the fact. This is the
|
|
715
|
+
* client-side strip of last resort, not defense-in-depth, for those paths; we
|
|
716
|
+
* do NOT reverse-engineer an undocumented projection form. The upsert path is
|
|
717
|
+
* different: it has no `returning` and reselects by PK through the read
|
|
718
|
+
* projection ({@link projectedColumns}), which already omits PII, so PII never
|
|
719
|
+
* crosses the wire there. If a future spec revision lets `returning` take a
|
|
720
|
+
* projection, switch the write paths to emit the non-PII list and this strip
|
|
721
|
+
* becomes a no-op like {@link parseWriteRow} on the SQL engines.
|
|
722
|
+
*/
|
|
723
|
+
stripWritePii(entity) {
|
|
724
|
+
if (!entity)
|
|
725
|
+
return entity;
|
|
726
|
+
for (const col of this.meta.columns) {
|
|
727
|
+
if (col.pii)
|
|
728
|
+
delete entity[col.field];
|
|
729
|
+
}
|
|
730
|
+
return entity;
|
|
731
|
+
}
|
|
642
732
|
/** `{ .c1, .c2, … }` projection clause. */
|
|
643
733
|
projection(cols) {
|
|
644
734
|
return `{ ${cols.map((c) => `.${c}`).join(', ')} }`;
|
|
@@ -656,7 +746,7 @@ class PowqlInterface {
|
|
|
656
746
|
* contract): for identical cross-engine results pass `nulls: 'last'`
|
|
657
747
|
* explicitly on Postgres, which defaults nulls-first for `desc`.
|
|
658
748
|
*/
|
|
659
|
-
buildOrder(orderBy, params) {
|
|
749
|
+
buildOrder(orderBy, params, alias) {
|
|
660
750
|
if (!orderBy)
|
|
661
751
|
return '';
|
|
662
752
|
const keys = Object.entries(orderBy).filter(([, dir]) => dir !== undefined);
|
|
@@ -667,7 +757,7 @@ class PowqlInterface {
|
|
|
667
757
|
const o = dir;
|
|
668
758
|
// JSON-path ordering on a json column.
|
|
669
759
|
if (Array.isArray(o.path)) {
|
|
670
|
-
return this.buildJsonPathOrder(field, dir, params);
|
|
760
|
+
return this.buildJsonPathOrder(field, dir, params, alias);
|
|
671
761
|
}
|
|
672
762
|
// OrderBySpec { sort, nulls }: accept nulls-last (PowDB default), refuse
|
|
673
763
|
// nulls-first (no placement grammar). Distinct from vector/pick/_count.
|
|
@@ -676,7 +766,7 @@ class PowqlInterface {
|
|
|
676
766
|
if (spec.nulls === 'first') {
|
|
677
767
|
throw new errors_js_1.UnsupportedFeatureError('NULLS FIRST placement', 'PowDB', `field "${field}": PowDB orders NULLs / missing keys LAST in both directions`);
|
|
678
768
|
}
|
|
679
|
-
return `${this.ref(field)} ${spec.sort === 'desc' ? 'desc' : 'asc'}`;
|
|
769
|
+
return `${this.ref(field, alias)} ${spec.sort === 'desc' ? 'desc' : 'asc'}`;
|
|
680
770
|
}
|
|
681
771
|
// Name the actual feature in the refusal — a pick-row ordering
|
|
682
772
|
// reported as "vector / distance ordering" sends users hunting for
|
|
@@ -692,12 +782,12 @@ class PowqlInterface {
|
|
|
692
782
|
: 'object-valued ordering';
|
|
693
783
|
throw new errors_js_1.UnsupportedFeatureError(feature, 'PowDB', `field "${field}"`);
|
|
694
784
|
}
|
|
695
|
-
return `${this.ref(field)} ${dir === 'desc' ? 'desc' : 'asc'}`;
|
|
785
|
+
return `${this.ref(field, alias)} ${dir === 'desc' ? 'desc' : 'asc'}`;
|
|
696
786
|
});
|
|
697
787
|
return ` order ${parts.join(', ')}`;
|
|
698
788
|
}
|
|
699
789
|
/** Compile one {@link JsonPathOrderBy} entry to `order .col->$n asc` (+ optional numeric cast). */
|
|
700
|
-
buildJsonPathOrder(field, spec, params) {
|
|
790
|
+
buildJsonPathOrder(field, spec, params, alias) {
|
|
701
791
|
const col = this.column(field);
|
|
702
792
|
if (!(0, powdb_js_1.isJsonColumn)(col)) {
|
|
703
793
|
throw new errors_js_1.UnsupportedFeatureError('JSON-path ordering', 'PowDB', `field "${field}" is not a json column`);
|
|
@@ -706,7 +796,7 @@ class PowqlInterface {
|
|
|
706
796
|
if (spec.nulls === 'first') {
|
|
707
797
|
throw new errors_js_1.UnsupportedFeatureError('NULLS FIRST placement', 'PowDB', `field "${field}": PowDB orders missing / JSON-null keys LAST in both directions`);
|
|
708
798
|
}
|
|
709
|
-
const pathExpr = this.jsonPathExpr(col, spec.path, params);
|
|
799
|
+
const pathExpr = this.jsonPathExpr(col, spec.path, params, alias);
|
|
710
800
|
// `type: 'numeric'` casts a JSON STRING number for numeric ordering; native
|
|
711
801
|
// JSON numbers already order numerically without a cast.
|
|
712
802
|
const expr = spec.type === 'numeric' ? `cast(${pathExpr}, "float")` : pathExpr;
|
|
@@ -726,6 +816,13 @@ class PowqlInterface {
|
|
|
726
816
|
async exec(powql, params, timeout, action = 'raw') {
|
|
727
817
|
return this.execOnce(powql, params, timeout, action, false);
|
|
728
818
|
}
|
|
819
|
+
/** Build the E018 refusal for a write / `begin` on a read-only pool. */
|
|
820
|
+
readOnlyError(operation) {
|
|
821
|
+
// Pass a clean detail: the ReadOnlyError constructor owns both the
|
|
822
|
+
// `[turbine] ` prefix and the "Route writes to a writable primary." hint,
|
|
823
|
+
// so adding either here would double them.
|
|
824
|
+
return new errors_js_1.ReadOnlyError(`${operation} on "${this.table}" refused: this PowDB connection is read-only.`);
|
|
825
|
+
}
|
|
729
826
|
/**
|
|
730
827
|
* Execute one statement, with the opt-in single stale-frame READ replay. When
|
|
731
828
|
* `retryStaleReads` is on and a first-statement READ fails with the stale-wire
|
|
@@ -740,6 +837,14 @@ class PowqlInterface {
|
|
|
740
837
|
* write into a retryable read.
|
|
741
838
|
*/
|
|
742
839
|
async execOnce(powql, params, timeout, action, isRetry) {
|
|
840
|
+
// Read-only pool guard: refuse a write action locally, before the wire, so a
|
|
841
|
+
// read-only target never even attempts the mutation (the engine refusal, if
|
|
842
|
+
// any, is only the backstop for raw/injected paths). `action` is per-call,
|
|
843
|
+
// so a concurrent read is never mistaken for a write. Reads (incl. explain)
|
|
844
|
+
// and non-classified `raw` fall through unchanged.
|
|
845
|
+
if (this.pool.readonly === true && POWQL_WRITE_ACTIONS.has(action)) {
|
|
846
|
+
throw this.readOnlyError(action);
|
|
847
|
+
}
|
|
743
848
|
const start = performance.now();
|
|
744
849
|
const run = this.pool.query(powql, params);
|
|
745
850
|
try {
|
|
@@ -825,22 +930,27 @@ class PowqlInterface {
|
|
|
825
930
|
// -------------------------------------------------------------------------
|
|
826
931
|
async findMany(args = {}) {
|
|
827
932
|
return this.withMiddleware('findMany', args, async () => {
|
|
828
|
-
const { rows, native } = await this.runFind(args, 'findMany');
|
|
933
|
+
const { rows, native, resolvedWhere } = await this.runFind(args, 'findMany');
|
|
829
934
|
const entities = this.shape(rows, native);
|
|
830
|
-
if (args.with)
|
|
831
|
-
await this.loadRelations(entities, args.with, args.timeout);
|
|
935
|
+
if (args.with) {
|
|
936
|
+
await this.loadRelations(entities, args.with, args.timeout, 0, { args, resolvedWhere }, args.includePii === true);
|
|
937
|
+
}
|
|
832
938
|
return entities;
|
|
833
939
|
});
|
|
834
940
|
}
|
|
835
|
-
/**
|
|
836
|
-
|
|
941
|
+
/**
|
|
942
|
+
* Compile the flat findMany select into PowQL (no execution), pushing values
|
|
943
|
+
* into `params`. Returns the query plus the RESOLVED where (relation filters
|
|
944
|
+
* already collapsed to literal in-lists) so the F2 join path can re-emit the
|
|
945
|
+
* exact parent predicate alias-qualified, and so {@link explain} can wrap it.
|
|
946
|
+
*/
|
|
947
|
+
async buildFind(args, params) {
|
|
837
948
|
if (args.cursor) {
|
|
838
949
|
throw new errors_js_1.UnsupportedFeatureError('cursor pagination', 'PowDB', 'use limit/offset instead');
|
|
839
950
|
}
|
|
840
|
-
const params = [];
|
|
841
951
|
const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
|
|
842
952
|
const where = this.buildWhere(resolvedWhere, params);
|
|
843
|
-
const cols = this.projectedColumns(args.select, args.omit);
|
|
953
|
+
const cols = this.projectedColumns(args.select, args.omit, args.includePii === true);
|
|
844
954
|
const distinct = args.distinct?.length ? ' distinct' : '';
|
|
845
955
|
const filter = where ? ` filter ${where}` : '';
|
|
846
956
|
const order = this.buildOrder(args.orderBy, params);
|
|
@@ -852,8 +962,38 @@ class PowqlInterface {
|
|
|
852
962
|
const limitClause = limit !== undefined ? ` limit ${this.param(limit, params)}` : '';
|
|
853
963
|
const offsetClause = args.offset ? ` offset ${this.param(args.offset, params)}` : '';
|
|
854
964
|
const powql = `${this.qt}${distinct}${filter}${order}${limitClause}${offsetClause} ${this.projection(cols)}`;
|
|
965
|
+
return { powql, resolvedWhere };
|
|
966
|
+
}
|
|
967
|
+
/** Build + run the flat findMany select; returns raw rows, the serving wire, and the resolved where. */
|
|
968
|
+
async runFind(args, action = 'findMany') {
|
|
969
|
+
const params = [];
|
|
970
|
+
const { powql, resolvedWhere } = await this.buildFind(args, params);
|
|
855
971
|
const { rows, native } = await this.exec(powql, params, args.timeout, action);
|
|
856
|
-
return { rows, native };
|
|
972
|
+
return { rows, native, resolvedWhere };
|
|
973
|
+
}
|
|
974
|
+
/**
|
|
975
|
+
* Diagnostic surface: compile the same PowQL {@link findMany} would run for
|
|
976
|
+
* `args` (no cache) and return the engine's plan as one string per line.
|
|
977
|
+
*
|
|
978
|
+
* Runs as a READ (`explain <query>`), so it is safe on a read-only pool and
|
|
979
|
+
* eligible for the stale-read replay. The line content is engine-owned and is
|
|
980
|
+
* NOT covered by semver (match plan node names / tree shape, never exact
|
|
981
|
+
* bytes; mirrors PowDB's own `explain` contract).
|
|
982
|
+
*
|
|
983
|
+
* Does NOT run through the middleware chain: plan text is a diagnostic, not
|
|
984
|
+
* entity rows, and `QueryInterface.explain` deliberately bypasses middleware
|
|
985
|
+
* too, so both engines agree.
|
|
986
|
+
*/
|
|
987
|
+
async explain(args = {}) {
|
|
988
|
+
const params = [];
|
|
989
|
+
const { powql } = await this.buildFind(args, params);
|
|
990
|
+
const { rows } = await this.exec(`explain ${powql}`, params, args.timeout, 'explain');
|
|
991
|
+
return rows
|
|
992
|
+
.map((r) => {
|
|
993
|
+
const line = r.plan ?? Object.values(r)[0];
|
|
994
|
+
return line == null ? '' : String(line);
|
|
995
|
+
})
|
|
996
|
+
.filter((line) => line.length > 0);
|
|
857
997
|
}
|
|
858
998
|
async findUnique(args) {
|
|
859
999
|
return this.withMiddleware('findUnique', args, async () => {
|
|
@@ -862,7 +1002,7 @@ class PowqlInterface {
|
|
|
862
1002
|
return null;
|
|
863
1003
|
const entities = this.shape(rows, native);
|
|
864
1004
|
if (args.with)
|
|
865
|
-
await this.loadRelations(entities, args.with, args.timeout);
|
|
1005
|
+
await this.loadRelations(entities, args.with, args.timeout, 0, undefined, args.includePii === true);
|
|
866
1006
|
return entities[0];
|
|
867
1007
|
});
|
|
868
1008
|
}
|
|
@@ -873,7 +1013,7 @@ class PowqlInterface {
|
|
|
873
1013
|
return null;
|
|
874
1014
|
const entities = this.shape(rows, native);
|
|
875
1015
|
if (args.with)
|
|
876
|
-
await this.loadRelations(entities, args.with, args.timeout);
|
|
1016
|
+
await this.loadRelations(entities, args.with, args.timeout, 0, undefined, args.includePii === true);
|
|
877
1017
|
return entities[0];
|
|
878
1018
|
});
|
|
879
1019
|
}
|
|
@@ -892,21 +1032,50 @@ class PowqlInterface {
|
|
|
892
1032
|
// -------------------------------------------------------------------------
|
|
893
1033
|
// Nested relations — batched N+1 loaders (hasMany / hasOne / belongsTo)
|
|
894
1034
|
// -------------------------------------------------------------------------
|
|
895
|
-
/**
|
|
896
|
-
|
|
1035
|
+
/**
|
|
1036
|
+
* Load each requested relation for `parents` and attach it onto each row.
|
|
1037
|
+
*
|
|
1038
|
+
* `parent` is supplied ONLY by the top-level {@link findMany} (its args +
|
|
1039
|
+
* resolved where). When the effective `relationLoadStrategy` resolves to an
|
|
1040
|
+
* explicit `'join'` and the pool advertises `serverJoins`, an eligible
|
|
1041
|
+
* top-level relation is loaded with a native PowQL join instead of the keyed
|
|
1042
|
+
* loaders (F2); everything else (nested `with` levels, ineligible shapes, and
|
|
1043
|
+
* the default `'batched'` strategy) keeps the loaders. Output is byte-equal
|
|
1044
|
+
* either way (the join reuses the same stitch / shape helpers).
|
|
1045
|
+
*/
|
|
1046
|
+
async loadRelations(parents, withClause, timeout, depth = 0, parent, includePii = false) {
|
|
897
1047
|
if (depth >= 10) {
|
|
898
1048
|
throw new errors_js_1.ValidationError(`[turbine] Nested 'with' on PowDB exceeded depth 10 (relation cycle?).`);
|
|
899
1049
|
}
|
|
900
1050
|
if (!parents.length)
|
|
901
1051
|
return;
|
|
1052
|
+
// The resolved strategy is 'join' only for an EXPLICIT 'join' (per-query arg
|
|
1053
|
+
// or a client config the user set). The serverJoins capability is consulted
|
|
1054
|
+
// PER RELATION below, AFTER joinEligible, so a relation that would have
|
|
1055
|
+
// fallen back to the loaders anyway (paged parent, nested `with`, composite
|
|
1056
|
+
// key, …) never triggers the capability's E017.
|
|
1057
|
+
const strategyIsJoin = parent ? this.resolveStrategy(parent.args) === 'join' : false;
|
|
902
1058
|
for (const [relName, opt] of Object.entries(withClause)) {
|
|
903
1059
|
if (!opt)
|
|
904
1060
|
continue;
|
|
905
1061
|
const rel = this.meta.relations[relName];
|
|
906
1062
|
if (!rel)
|
|
907
1063
|
throw new errors_js_1.ValidationError(`[turbine] Unknown relation "${relName}" on "${this.table}".`);
|
|
1064
|
+
if (strategyIsJoin && parent && this.joinEligible(rel, opt, parent.args, parents.length)) {
|
|
1065
|
+
if (this.capabilities.serverJoins) {
|
|
1066
|
+
await this.loadRelationViaJoin(parents, rel, relName, opt, parent, timeout, includePii);
|
|
1067
|
+
continue;
|
|
1068
|
+
}
|
|
1069
|
+
// An otherwise-eligible relation the engine cannot join: a PER-QUERY
|
|
1070
|
+
// `relationLoadStrategy: 'join'` is an explicit request, so throw a typed
|
|
1071
|
+
// E017; a CLIENT-LEVEL default silently falls back to the keyed loaders
|
|
1072
|
+
// (so pointing an existing app at an older engine keeps working).
|
|
1073
|
+
if (parent.args.relationLoadStrategy === 'join') {
|
|
1074
|
+
(0, powdb_js_1.requireCapability)(this.capabilities, 'serverJoins', 'native PowQL relation joins');
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
908
1077
|
if (rel.type === 'manyToMany') {
|
|
909
|
-
await this.loadManyToMany(parents, rel, relName, opt, timeout);
|
|
1078
|
+
await this.loadManyToMany(parents, rel, relName, opt, timeout, includePii);
|
|
910
1079
|
continue;
|
|
911
1080
|
}
|
|
912
1081
|
const fk = (0, schema_js_1.normalizeKeyColumns)(rel.foreignKey);
|
|
@@ -925,23 +1094,50 @@ class PowqlInterface {
|
|
|
925
1094
|
const keys = [
|
|
926
1095
|
...new Set(parents.map((p) => p[parentKeyField]).filter((k) => k != null)),
|
|
927
1096
|
];
|
|
928
|
-
|
|
1097
|
+
// The loader buckets children by their correlation column, so that column
|
|
1098
|
+
// MUST be in the fetched projection even when the user's select/omit drops
|
|
1099
|
+
// it. Force it into the fetch here and strip it back off the entities after
|
|
1100
|
+
// stitching (the join path already gets this for free via `__tpk`).
|
|
1101
|
+
const userSelect = options.select;
|
|
1102
|
+
const userOmit = options.omit;
|
|
1103
|
+
const fkProjected = userSelect ? Boolean(userSelect[childKeyField]) : userOmit ? !userOmit[childKeyField] : true;
|
|
1104
|
+
let fetchOptions = options;
|
|
1105
|
+
if (!fkProjected) {
|
|
1106
|
+
if (userSelect) {
|
|
1107
|
+
fetchOptions = {
|
|
1108
|
+
...options,
|
|
1109
|
+
select: { ...userSelect, [childKeyField]: true },
|
|
1110
|
+
};
|
|
1111
|
+
}
|
|
1112
|
+
else if (userOmit) {
|
|
1113
|
+
const omitWithoutFk = { ...userOmit };
|
|
1114
|
+
delete omitWithoutFk[childKeyField];
|
|
1115
|
+
fetchOptions = { ...options, omit: omitWithoutFk };
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
929
1118
|
// Chunk the key set so a single `in (…)` never exceeds PowDB's
|
|
930
|
-
// per-statement param / row limits; merge each chunk's children.
|
|
1119
|
+
// per-statement param / row limits; merge each chunk's children. Keys are
|
|
1120
|
+
// normalized through joinKey (a Date maps to micros, matching the child
|
|
1121
|
+
// cell) so a datetime correlation column stitches instead of silently
|
|
1122
|
+
// returning [].
|
|
1123
|
+
const childByKey = new Map();
|
|
931
1124
|
for (let i = 0; i < keys.length; i += MAX_RELATION_KEYS) {
|
|
932
1125
|
const chunk = keys.slice(i, i + MAX_RELATION_KEYS);
|
|
933
1126
|
const childWhere = {
|
|
934
|
-
...
|
|
1127
|
+
...fetchOptions.where,
|
|
935
1128
|
[childKeyField]: { in: chunk },
|
|
936
1129
|
};
|
|
937
1130
|
const children = (await targetQi.findMany({
|
|
938
|
-
...
|
|
1131
|
+
...fetchOptions,
|
|
939
1132
|
where: childWhere,
|
|
940
1133
|
with: options.with,
|
|
941
1134
|
timeout: options.timeout ?? timeout,
|
|
1135
|
+
includePii,
|
|
942
1136
|
}));
|
|
943
1137
|
for (const child of children) {
|
|
944
|
-
const k = child[childKeyField];
|
|
1138
|
+
const k = this.joinKey(child[childKeyField]);
|
|
1139
|
+
if (k == null)
|
|
1140
|
+
continue;
|
|
945
1141
|
const bucket = childByKey.get(k);
|
|
946
1142
|
if (bucket)
|
|
947
1143
|
bucket.push(child);
|
|
@@ -949,10 +1145,18 @@ class PowqlInterface {
|
|
|
949
1145
|
childByKey.set(k, [child]);
|
|
950
1146
|
}
|
|
951
1147
|
}
|
|
1148
|
+
// Strip the forced correlation column back off if the user excluded it,
|
|
1149
|
+
// so the emitted entities match their select/omit exactly.
|
|
1150
|
+
if (!fkProjected) {
|
|
1151
|
+
for (const bucket of childByKey.values()) {
|
|
1152
|
+
for (const child of bucket)
|
|
1153
|
+
delete child[childKeyField];
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
952
1156
|
const single = rel.type === 'belongsTo' || rel.type === 'hasOne';
|
|
953
1157
|
for (const parent of parents) {
|
|
954
|
-
const k = parent[parentKeyField];
|
|
955
|
-
const matches = childByKey.get(k) ?? [];
|
|
1158
|
+
const k = this.joinKey(parent[parentKeyField]);
|
|
1159
|
+
const matches = (k == null ? undefined : childByKey.get(k)) ?? [];
|
|
956
1160
|
parent[relName] = single ? (matches[0] ?? null) : matches;
|
|
957
1161
|
}
|
|
958
1162
|
}
|
|
@@ -965,7 +1169,7 @@ class PowqlInterface {
|
|
|
965
1169
|
* single-key N+1 loaders; the junction's source/target columns must be single
|
|
966
1170
|
* (composite junction keys would need PowQL tuple-`in`, which it lacks).
|
|
967
1171
|
*/
|
|
968
|
-
async loadManyToMany(parents, rel, relName, opt, timeout) {
|
|
1172
|
+
async loadManyToMany(parents, rel, relName, opt, timeout, includePii = false) {
|
|
969
1173
|
const through = rel.through;
|
|
970
1174
|
if (!through)
|
|
971
1175
|
throw new errors_js_1.ValidationError(`[turbine] manyToMany relation "${relName}" is missing its junction (\`through\`).`);
|
|
@@ -1029,6 +1233,7 @@ class PowqlInterface {
|
|
|
1029
1233
|
where,
|
|
1030
1234
|
with: options.with,
|
|
1031
1235
|
timeout: options.timeout ?? timeout,
|
|
1236
|
+
includePii,
|
|
1032
1237
|
}));
|
|
1033
1238
|
for (const t of targets)
|
|
1034
1239
|
targetByPk.set(String(t[targetPkField]), t);
|
|
@@ -1047,6 +1252,257 @@ class PowqlInterface {
|
|
|
1047
1252
|
}
|
|
1048
1253
|
}
|
|
1049
1254
|
// -------------------------------------------------------------------------
|
|
1255
|
+
// Nested relations: native PowQL joins (F2, opt-in via relationLoadStrategy)
|
|
1256
|
+
// -------------------------------------------------------------------------
|
|
1257
|
+
/**
|
|
1258
|
+
* Resolve the effective relation-load strategy: the per-query arg wins, then
|
|
1259
|
+
* the client config, then the PowDB default of `'batched'` (the keyed
|
|
1260
|
+
* loaders). PowDB deliberately does NOT inherit the SQL-side implicit `'join'`
|
|
1261
|
+
* default (that would silently flip every existing PowDB user onto brand-new
|
|
1262
|
+
* join generation). Only a value the user actually set to `'join'` activates it.
|
|
1263
|
+
*/
|
|
1264
|
+
resolveStrategy(args) {
|
|
1265
|
+
const s = args.relationLoadStrategy ?? this.options.relationLoadStrategy ?? 'batched';
|
|
1266
|
+
return s === 'join' ? 'join' : 'batched';
|
|
1267
|
+
}
|
|
1268
|
+
/**
|
|
1269
|
+
* Per-relation eligibility for the join path (checked before the serverJoins
|
|
1270
|
+
* capability). Any `false` here is a SILENT fallback to the keyed loaders (it
|
|
1271
|
+
* is never an error), so an off-page or nested-`with` shape still returns
|
|
1272
|
+
* correct rows:
|
|
1273
|
+
* - the parent query must not be paged (`limit`/`offset`/`take`, including the
|
|
1274
|
+
* configured `defaultLimit`): a parent-filter join under a page would scan
|
|
1275
|
+
* children of off-page parents, where the loaders are strictly better;
|
|
1276
|
+
* - the relation must not request a nested `with` (its subtree stays on the
|
|
1277
|
+
* loaders this round) or a `distinct`;
|
|
1278
|
+
* - single-column relation keys only (a composite key falls to the loader,
|
|
1279
|
+
* which throws the same E017 as today);
|
|
1280
|
+
* - the PARENT-SIDE correlation column must be a single-column PK or unique
|
|
1281
|
+
* column, or the INNER join would re-emit one child copy per matching
|
|
1282
|
+
* parent row (a non-unique correlation key produces duplicate children the
|
|
1283
|
+
* loader never would). For hasMany/hasOne/m2m that column is the relation's
|
|
1284
|
+
* `referenceKey` on THIS (fetched) table; for belongsTo it is the
|
|
1285
|
+
* `referenceKey` on the TARGET table (the join's non-fetched side);
|
|
1286
|
+
* - m2m keeps any `orderBy`/`limit`/`offset` on the loader (the junction-order
|
|
1287
|
+
* stitch can't be reproduced by the 3-table join deterministically);
|
|
1288
|
+
* - a to-one relation `limit`/`offset` (meaningless) stays on the loader, as
|
|
1289
|
+
* does a to-many relation `limit`/`offset` when the parent set spills past
|
|
1290
|
+
* one loader chunk (the loader limits per chunk, the join once globally).
|
|
1291
|
+
*/
|
|
1292
|
+
joinEligible(rel, opt, args, parentCount) {
|
|
1293
|
+
const effLimit = args.limit ?? args.take ?? this.defaultLimit;
|
|
1294
|
+
if (effLimit !== undefined || args.offset)
|
|
1295
|
+
return false;
|
|
1296
|
+
const options = (opt === true ? {} : opt);
|
|
1297
|
+
if (options.with)
|
|
1298
|
+
return false;
|
|
1299
|
+
if (options.distinct?.length)
|
|
1300
|
+
return false;
|
|
1301
|
+
if (rel.type === 'manyToMany') {
|
|
1302
|
+
const through = rel.through;
|
|
1303
|
+
if (!through)
|
|
1304
|
+
return false;
|
|
1305
|
+
if ((0, schema_js_1.normalizeKeyColumns)(through.sourceKey).length > 1 ||
|
|
1306
|
+
(0, schema_js_1.normalizeKeyColumns)(through.targetKey).length > 1 ||
|
|
1307
|
+
(0, schema_js_1.normalizeKeyColumns)(rel.referenceKey).length > 1 ||
|
|
1308
|
+
(this.schema.tables[rel.to]?.primaryKey.length ?? 2) > 1) {
|
|
1309
|
+
return false;
|
|
1310
|
+
}
|
|
1311
|
+
if (options.orderBy || options.limit !== undefined || options.offset)
|
|
1312
|
+
return false;
|
|
1313
|
+
// The parent joins on its referenceKey; a non-unique one duplicates.
|
|
1314
|
+
if (!this.isSingleColumnUnique(this.meta, (0, schema_js_1.normalizeKeyColumns)(rel.referenceKey)[0]))
|
|
1315
|
+
return false;
|
|
1316
|
+
return true;
|
|
1317
|
+
}
|
|
1318
|
+
if ((0, schema_js_1.normalizeKeyColumns)(rel.foreignKey).length > 1 || (0, schema_js_1.normalizeKeyColumns)(rel.referenceKey).length > 1) {
|
|
1319
|
+
return false;
|
|
1320
|
+
}
|
|
1321
|
+
// Reject a non-unique correlation key: on belongsTo the fetched side joins on
|
|
1322
|
+
// the target's referenceKey, otherwise the fetched side joins on its own.
|
|
1323
|
+
if (rel.type === 'belongsTo') {
|
|
1324
|
+
const targetMeta = this.schema.tables[rel.to];
|
|
1325
|
+
if (!targetMeta || !this.isSingleColumnUnique(targetMeta, (0, schema_js_1.normalizeKeyColumns)(rel.referenceKey)[0])) {
|
|
1326
|
+
return false;
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
else if (!this.isSingleColumnUnique(this.meta, (0, schema_js_1.normalizeKeyColumns)(rel.referenceKey)[0])) {
|
|
1330
|
+
return false;
|
|
1331
|
+
}
|
|
1332
|
+
const single = rel.type === 'belongsTo' || rel.type === 'hasOne';
|
|
1333
|
+
if ((options.limit !== undefined || options.offset) && (single || parentCount > MAX_RELATION_KEYS)) {
|
|
1334
|
+
return false;
|
|
1335
|
+
}
|
|
1336
|
+
return true;
|
|
1337
|
+
}
|
|
1338
|
+
/**
|
|
1339
|
+
* True when `col` is a single-column unique key of `tableMeta`: the sole
|
|
1340
|
+
* primary-key column, a single-column entry in `uniqueColumns` (where a
|
|
1341
|
+
* per-column `unique: true` and an introspected single-column unique constraint
|
|
1342
|
+
* both land), or a single-column unique index. Used by {@link joinEligible} to
|
|
1343
|
+
* keep the INNER-join path off relations whose parent-side correlation column
|
|
1344
|
+
* can repeat (which would duplicate children).
|
|
1345
|
+
*/
|
|
1346
|
+
isSingleColumnUnique(tableMeta, col) {
|
|
1347
|
+
if (tableMeta.primaryKey.length === 1 && tableMeta.primaryKey[0] === col)
|
|
1348
|
+
return true;
|
|
1349
|
+
if (tableMeta.uniqueColumns.some((cols) => cols.length === 1 && cols[0] === col))
|
|
1350
|
+
return true;
|
|
1351
|
+
return tableMeta.indexes.some((idx) => idx.unique && !idx.docPath && idx.columns.length === 1 && idx.columns[0] === col);
|
|
1352
|
+
}
|
|
1353
|
+
/** Dispatch one eligible relation to the correct native-join loader. */
|
|
1354
|
+
async loadRelationViaJoin(parents, rel, relName, opt, parent, timeout, includePii = false) {
|
|
1355
|
+
if (rel.type === 'manyToMany') {
|
|
1356
|
+
await this.loadManyToManyViaJoin(parents, rel, relName, opt, parent, timeout, includePii);
|
|
1357
|
+
return;
|
|
1358
|
+
}
|
|
1359
|
+
const options = (opt === true ? {} : opt);
|
|
1360
|
+
const targetMeta = this.schema.tables[rel.to];
|
|
1361
|
+
if (!targetMeta)
|
|
1362
|
+
throw new errors_js_1.ValidationError(`[turbine] Relation "${relName}" targets unknown table "${rel.to}".`);
|
|
1363
|
+
const targetQi = new PowqlInterface(this.pool, rel.to, this.schema, [], this.options);
|
|
1364
|
+
const fk = (0, schema_js_1.normalizeKeyColumns)(rel.foreignKey);
|
|
1365
|
+
const rk = (0, schema_js_1.normalizeKeyColumns)(rel.referenceKey);
|
|
1366
|
+
// Correlation math is identical to the keyed loaders, only the transport
|
|
1367
|
+
// (join vs in-list) changes. Always join the RELATION TARGET (alias `c`) to
|
|
1368
|
+
// the already-fetched side (alias `p`), correlating on the fetched side's key
|
|
1369
|
+
// and projecting `__tpk` from the fetched side's correlation column.
|
|
1370
|
+
const parentKeyCol = rel.type === 'belongsTo' ? fk[0] : rk[0];
|
|
1371
|
+
const childKeyCol = rel.type === 'belongsTo' ? rk[0] : fk[0];
|
|
1372
|
+
const parentKeyField = this.meta.reverseColumnMap[parentKeyCol] ?? parentKeyCol;
|
|
1373
|
+
const params = [];
|
|
1374
|
+
const childCols = this.joinChildCols(targetQi, options, includePii);
|
|
1375
|
+
const filter = await this.joinFilter(targetQi, parent.resolvedWhere, options.where, 'c', params, options.timeout ?? timeout);
|
|
1376
|
+
const order = targetQi.buildOrder(options.orderBy, params, 'c');
|
|
1377
|
+
const limitClause = options.limit !== undefined ? ` limit ${this.param(options.limit, params)}` : '';
|
|
1378
|
+
const offsetClause = options.offset ? ` offset ${this.param(options.offset, params)}` : '';
|
|
1379
|
+
const proj = this.joinProjection(childCols, `p.${(0, powdb_js_1.quotePowqlIdent)(parentKeyCol)}`, 'c');
|
|
1380
|
+
const powql = `${targetQi.qt} as c join ${this.qt} as p ` +
|
|
1381
|
+
`on c.${(0, powdb_js_1.quotePowqlIdent)(childKeyCol)} = p.${(0, powdb_js_1.quotePowqlIdent)(parentKeyCol)}` +
|
|
1382
|
+
`${filter}${order}${limitClause}${offsetClause} ${proj}`;
|
|
1383
|
+
// A READ: thread a read-shaped action through the exec seam.
|
|
1384
|
+
const { rows, native } = await targetQi.exec(powql, params, timeout, 'findMany');
|
|
1385
|
+
const single = rel.type === 'belongsTo' || rel.type === 'hasOne';
|
|
1386
|
+
const byKey = this.bucketByTpk(targetQi, rows, native);
|
|
1387
|
+
for (const p of parents) {
|
|
1388
|
+
const key = this.joinKey(p[parentKeyField]);
|
|
1389
|
+
const matches = (key == null ? undefined : byKey.get(key)) ?? [];
|
|
1390
|
+
p[relName] = single ? (matches[0] ?? null) : matches;
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
/**
|
|
1394
|
+
* manyToMany via chained joins: the target (alias `t`) → junction (alias `j`)
|
|
1395
|
+
* → the already-fetched side (alias `p`), correlating `__tpk` from the
|
|
1396
|
+
* junction's source key. Always a list, stitched exactly like the loader.
|
|
1397
|
+
*/
|
|
1398
|
+
async loadManyToManyViaJoin(parents, rel, relName, opt, parent, timeout, includePii = false) {
|
|
1399
|
+
const through = rel.through;
|
|
1400
|
+
if (!through)
|
|
1401
|
+
throw new errors_js_1.ValidationError(`[turbine] manyToMany relation "${relName}" is missing its junction (\`through\`).`);
|
|
1402
|
+
const options = (opt === true ? {} : opt);
|
|
1403
|
+
const targetMeta = this.schema.tables[rel.to];
|
|
1404
|
+
if (!targetMeta)
|
|
1405
|
+
throw new errors_js_1.ValidationError(`[turbine] Relation "${relName}" targets unknown table "${rel.to}".`);
|
|
1406
|
+
const targetQi = new PowqlInterface(this.pool, rel.to, this.schema, [], this.options);
|
|
1407
|
+
const sourceJCol = (0, schema_js_1.normalizeKeyColumns)(through.sourceKey)[0];
|
|
1408
|
+
const targetJCol = (0, schema_js_1.normalizeKeyColumns)(through.targetKey)[0];
|
|
1409
|
+
const sourceRefCol = (0, schema_js_1.normalizeKeyColumns)(rel.referenceKey)[0];
|
|
1410
|
+
const targetPkCol = targetMeta.primaryKey[0];
|
|
1411
|
+
const parentRefField = this.meta.reverseColumnMap[sourceRefCol] ?? sourceRefCol;
|
|
1412
|
+
const params = [];
|
|
1413
|
+
const childCols = this.joinChildCols(targetQi, options, includePii);
|
|
1414
|
+
const filter = await this.joinFilter(targetQi, parent.resolvedWhere, options.where, 't', params, options.timeout ?? timeout);
|
|
1415
|
+
const proj = this.joinProjection(childCols, `j.${(0, powdb_js_1.quotePowqlIdent)(sourceJCol)}`, 't');
|
|
1416
|
+
const powql = `${targetQi.qt} as t ` +
|
|
1417
|
+
`join ${(0, powdb_js_1.quotePowqlIdent)(through.table)} as j on t.${(0, powdb_js_1.quotePowqlIdent)(targetPkCol)} = j.${(0, powdb_js_1.quotePowqlIdent)(targetJCol)} ` +
|
|
1418
|
+
`join ${this.qt} as p on j.${(0, powdb_js_1.quotePowqlIdent)(sourceJCol)} = p.${(0, powdb_js_1.quotePowqlIdent)(sourceRefCol)}` +
|
|
1419
|
+
`${filter} ${proj}`;
|
|
1420
|
+
const { rows, native } = await targetQi.exec(powql, params, timeout, 'findMany');
|
|
1421
|
+
const byKey = this.bucketByTpk(targetQi, rows, native);
|
|
1422
|
+
for (const p of parents) {
|
|
1423
|
+
const key = this.joinKey(p[parentRefField]);
|
|
1424
|
+
p[relName] = (key == null ? undefined : byKey.get(key)) ?? [];
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
/**
|
|
1428
|
+
* The target column list to project through the join (honouring select/omit),
|
|
1429
|
+
* with a loud guard: a real column named `__tpk` would collide with the
|
|
1430
|
+
* reserved correlation alias, so refuse rather than silently mis-stitch.
|
|
1431
|
+
*/
|
|
1432
|
+
joinChildCols(targetQi, options, includePii = false) {
|
|
1433
|
+
const cols = targetQi.projectedColumns(options.select, options.omit, includePii);
|
|
1434
|
+
if (cols.includes('__tpk')) {
|
|
1435
|
+
throw new errors_js_1.ValidationError(`[turbine] relation target "${targetQi.table}" has a column named "__tpk", which collides with the reserved ` +
|
|
1436
|
+
`join correlation alias. Rename the column or load this relation with relationLoadStrategy: 'batched'.`);
|
|
1437
|
+
}
|
|
1438
|
+
return cols;
|
|
1439
|
+
}
|
|
1440
|
+
/**
|
|
1441
|
+
* `{ __tpk: <tpkExpr>, <col>: <childAlias>.<col>, … }`. Each child column is
|
|
1442
|
+
* ALIASED to its bare name (a bare qualified ref `c.col` would come back named
|
|
1443
|
+
* `c.col`, not `col`) so the stitched rows shape identically to a flat select.
|
|
1444
|
+
*/
|
|
1445
|
+
joinProjection(childCols, tpkExpr, childAlias) {
|
|
1446
|
+
const parts = [
|
|
1447
|
+
`__tpk: ${tpkExpr}`,
|
|
1448
|
+
...childCols.map((c) => `${(0, powdb_js_1.quotePowqlIdent)(c)}: ${childAlias}.${(0, powdb_js_1.quotePowqlIdent)(c)}`),
|
|
1449
|
+
];
|
|
1450
|
+
return `{ ${parts.join(', ')} }`;
|
|
1451
|
+
}
|
|
1452
|
+
/**
|
|
1453
|
+
* `filter <parentWhere qualified p> [and <relationWhere qualified childAlias>]`.
|
|
1454
|
+
* The parent where is the ALREADY-RESOLVED predicate (relation filters collapsed
|
|
1455
|
+
* to literal in-lists before the base query ran); the relation where is resolved
|
|
1456
|
+
* on the target the same way before qualifying, so a nested relation filter in
|
|
1457
|
+
* the relation `where` never reaches the join unresolved. Params bind in order.
|
|
1458
|
+
*/
|
|
1459
|
+
async joinFilter(targetQi, parentResolvedWhere, relWhere, childAlias, params, timeout) {
|
|
1460
|
+
const parts = [];
|
|
1461
|
+
const pw = this.buildWhere(parentResolvedWhere, params, 'p');
|
|
1462
|
+
if (pw)
|
|
1463
|
+
parts.push(pw);
|
|
1464
|
+
const relResolved = await targetQi.resolveRelationFilters(relWhere, timeout);
|
|
1465
|
+
const rw = targetQi.buildWhere(relResolved, params, childAlias);
|
|
1466
|
+
if (rw)
|
|
1467
|
+
parts.push(rw);
|
|
1468
|
+
return parts.length ? ` filter ${parts.join(' and ')}` : '';
|
|
1469
|
+
}
|
|
1470
|
+
/** Group join rows by their (normalized) `__tpk`, stripping it and shaping each child. */
|
|
1471
|
+
bucketByTpk(targetQi, rows, native) {
|
|
1472
|
+
const byKey = new Map();
|
|
1473
|
+
for (const raw of rows) {
|
|
1474
|
+
const tpk = this.joinKey(raw.__tpk);
|
|
1475
|
+
delete raw.__tpk;
|
|
1476
|
+
const child = targetQi.shape([raw], native)[0];
|
|
1477
|
+
if (tpk == null)
|
|
1478
|
+
continue;
|
|
1479
|
+
const bucket = byKey.get(tpk);
|
|
1480
|
+
if (bucket)
|
|
1481
|
+
bucket.push(child);
|
|
1482
|
+
else
|
|
1483
|
+
byKey.set(tpk, [child]);
|
|
1484
|
+
}
|
|
1485
|
+
return byKey;
|
|
1486
|
+
}
|
|
1487
|
+
/**
|
|
1488
|
+
* Normalize a correlation key to a stable string map key so a parent's key
|
|
1489
|
+
* value (a shaped entity field) and a child row's `__tpk` cell match across
|
|
1490
|
+
* wires and column types. A `Date` maps to microseconds
|
|
1491
|
+
* (`getTime()` ms times 1000), because a datetime correlation cell arrives as
|
|
1492
|
+
* raw micros (bigint on the native wire, a micros string on the legacy wire),
|
|
1493
|
+
* never as ms. bigint / number / string all stringify to the same digits, so
|
|
1494
|
+
* an int key matches whether it came back typed or as text.
|
|
1495
|
+
*/
|
|
1496
|
+
joinKey(v) {
|
|
1497
|
+
if (v == null)
|
|
1498
|
+
return null;
|
|
1499
|
+
if (v instanceof Date)
|
|
1500
|
+
return (BigInt(v.getTime()) * 1000n).toString();
|
|
1501
|
+
if (typeof v === 'bigint')
|
|
1502
|
+
return v.toString();
|
|
1503
|
+
return String(v);
|
|
1504
|
+
}
|
|
1505
|
+
// -------------------------------------------------------------------------
|
|
1050
1506
|
// Writes (reselect — PowDB has no RETURNING)
|
|
1051
1507
|
// -------------------------------------------------------------------------
|
|
1052
1508
|
/** Split `data` into scalar assignments; reject relation (nested-write) keys. */
|
|
@@ -1103,7 +1559,7 @@ class PowqlInterface {
|
|
|
1103
1559
|
.join(', ');
|
|
1104
1560
|
// `returning` surfaces the inserted row (all columns, schema order) in one round-trip.
|
|
1105
1561
|
const { rows, native } = await this.exec(`insert ${this.qt} { ${body} } returning`, params, args.timeout, 'create');
|
|
1106
|
-
const row = rows.length ? this.shape(rows, native)[0] : null;
|
|
1562
|
+
const row = rows.length ? this.stripWritePii(this.shape(rows, native)[0]) : null;
|
|
1107
1563
|
if (!row)
|
|
1108
1564
|
throw new errors_js_1.NotFoundError({ table: this.table, where: data });
|
|
1109
1565
|
return row;
|
|
@@ -1121,7 +1577,7 @@ class PowqlInterface {
|
|
|
1121
1577
|
});
|
|
1122
1578
|
// Multi-row insert with `returning` hands back every inserted row in one round-trip.
|
|
1123
1579
|
const { rows, native } = await this.exec(`insert ${this.qt} ${tuples.join(', ')} returning`, params, args.timeout, 'createMany');
|
|
1124
|
-
return this.shape(rows, native);
|
|
1580
|
+
return this.shape(rows, native).map((r) => this.stripWritePii(r));
|
|
1125
1581
|
});
|
|
1126
1582
|
}
|
|
1127
1583
|
async update(args) {
|
|
@@ -1136,7 +1592,7 @@ class PowqlInterface {
|
|
|
1136
1592
|
const setClause = this.buildUpdateAssignments(args.data, params);
|
|
1137
1593
|
// `returning` hands back the post-update row(s); take the first (single-row contract).
|
|
1138
1594
|
const { rows, native } = await this.exec(`${this.qt} filter ${where} update { ${setClause} } returning`, params, args.timeout, 'update');
|
|
1139
|
-
const row = rows.length ? this.shape(rows, native)[0] : null;
|
|
1595
|
+
const row = rows.length ? this.stripWritePii(this.shape(rows, native)[0]) : null;
|
|
1140
1596
|
if (!row)
|
|
1141
1597
|
throw new errors_js_1.NotFoundError({ table: this.table, where: args.where });
|
|
1142
1598
|
return row;
|
|
@@ -1219,6 +1675,11 @@ class PowqlInterface {
|
|
|
1219
1675
|
}
|
|
1220
1676
|
/** Open a flat PowDB transaction on a pinned connection and run `fn` inside it. */
|
|
1221
1677
|
async runInImplicitTx(fn) {
|
|
1678
|
+
// A transaction-control `begin` is a write on a read-only pool: refuse it
|
|
1679
|
+
// locally before checking out a connection (zero wire / pool activity), the
|
|
1680
|
+
// same guard the exec seam applies to plain writes.
|
|
1681
|
+
if (this.pool.readonly === true)
|
|
1682
|
+
throw this.readOnlyError('transaction (begin)');
|
|
1222
1683
|
// Route tx keywords through the dialect (like the SQL path) so this never
|
|
1223
1684
|
// drifts from `powdbDialect`; falls back to the literal lowercase keywords.
|
|
1224
1685
|
const d = this.options.dialect;
|
|
@@ -1228,7 +1689,10 @@ class PowqlInterface {
|
|
|
1228
1689
|
await client.query(d?.beginStatement?.() ?? 'begin');
|
|
1229
1690
|
began = true;
|
|
1230
1691
|
const { TransactionClient } = await Promise.resolve().then(() => __importStar(require('./client.js')));
|
|
1231
|
-
const tx = new TransactionClient(client, this.schema, this.middlewares, this.options
|
|
1692
|
+
const tx = new TransactionClient(client, this.schema, this.middlewares, this.options,
|
|
1693
|
+
// Pass the PowDB pool so its read-only guard + capabilities carry into
|
|
1694
|
+
// the transaction-scoped proxy pool (see createTxPool).
|
|
1695
|
+
this.pool);
|
|
1232
1696
|
const ctx = { schema: this.schema, tx: tx };
|
|
1233
1697
|
// Plant the single-writer re-entrancy marker for the implicit tx's
|
|
1234
1698
|
// subtree (same seam TurbineClient.$transaction uses) — user code that
|
|
@@ -1274,7 +1738,7 @@ class PowqlInterface {
|
|
|
1274
1738
|
this.assertCompiledWhere(where, false, 'delete');
|
|
1275
1739
|
// `returning` hands back the deleted row(s) — no separate pre-image reselect needed.
|
|
1276
1740
|
const { rows, native } = await this.exec(`${this.qt} filter ${where} delete returning`, params, args.timeout, 'delete');
|
|
1277
|
-
const row = rows.length ? this.shape(rows, native)[0] : null;
|
|
1741
|
+
const row = rows.length ? this.stripWritePii(this.shape(rows, native)[0]) : null;
|
|
1278
1742
|
if (!row)
|
|
1279
1743
|
throw new errors_js_1.NotFoundError({ table: this.table, where: args.where });
|
|
1280
1744
|
return row;
|