turbine-orm 0.62.1 → 0.63.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/dist/cjs/nested-write.js +12 -0
- package/dist/cjs/powql.js +17 -6
- package/dist/cjs/query/batched-loader.js +88 -2
- package/dist/cjs/query/writes.js +28 -4
- package/dist/nested-write.js +12 -0
- package/dist/powql.js +17 -6
- package/dist/query/batched-loader.js +88 -2
- package/dist/query/writes.js +28 -4
- package/package.json +1 -1
package/dist/cjs/nested-write.js
CHANGED
|
@@ -237,6 +237,18 @@ function pkWhere(tableMeta, row) {
|
|
|
237
237
|
const where = {};
|
|
238
238
|
for (const col of tableMeta.primaryKey) {
|
|
239
239
|
const field = tableMeta.reverseColumnMap[col] ?? col;
|
|
240
|
+
// A PARTIAL primary key here is not a filter, it is a mass mutation.
|
|
241
|
+
// `undefined` values are dropped when the where compiles, and the
|
|
242
|
+
// empty-where guard only fires when NOTHING survives, so a composite PK
|
|
243
|
+
// missing one member compiles to a predicate on the remaining member and
|
|
244
|
+
// the statement rewrites every row that shares it. The row is supposed to
|
|
245
|
+
// be one this engine just read, so a missing member is an internal fault,
|
|
246
|
+
// and the only safe response is to refuse rather than to run.
|
|
247
|
+
if (row[field] === undefined) {
|
|
248
|
+
throw new errors_js_1.ValidationError(`[turbine] Cannot address a row of "${tableMeta.name}" by primary key: "${field}" is missing from the ` +
|
|
249
|
+
'row this nested write is operating on, so the generated predicate would match more rows than intended. ' +
|
|
250
|
+
'This is a bug in turbine, please report it.');
|
|
251
|
+
}
|
|
240
252
|
where[field] = row[field];
|
|
241
253
|
}
|
|
242
254
|
return where;
|
package/dist/cjs/powql.js
CHANGED
|
@@ -909,6 +909,7 @@ class PowqlInterface {
|
|
|
909
909
|
* and returns it regardless. Untagged tables project exactly as before.
|
|
910
910
|
*/
|
|
911
911
|
projectedColumns(select, omit, includePii) {
|
|
912
|
+
const pk = new Set(this.meta.primaryKey);
|
|
912
913
|
let cols = this.meta.columns.map((c) => c.name);
|
|
913
914
|
const hasSelect = select && Object.keys(select).length;
|
|
914
915
|
if (hasSelect) {
|
|
@@ -916,23 +917,33 @@ class PowqlInterface {
|
|
|
916
917
|
.filter(([, v]) => v)
|
|
917
918
|
.map(([k]) => this.column(k).name));
|
|
918
919
|
// Always keep the PK so reselect / relation stitching has a key to work with.
|
|
919
|
-
for (const
|
|
920
|
-
picked.add(
|
|
920
|
+
for (const key of pk)
|
|
921
|
+
picked.add(key);
|
|
921
922
|
cols = cols.filter((c) => picked.has(c));
|
|
922
923
|
}
|
|
923
924
|
else if (!includePii) {
|
|
924
925
|
// Default / omit-only projection: drop PII columns (kept above only when a
|
|
925
|
-
// caller names them in `select`)
|
|
926
|
-
// tagged
|
|
926
|
+
// caller names them in `select`), EXCEPT a PK column. This used to drop a
|
|
927
|
+
// tagged PK, on the reasoning that keys should not be tagged in the first
|
|
928
|
+
// place. That is good advice and a bad guarantee: the row it returns
|
|
929
|
+
// cannot address itself, so writing it back builds a partial predicate
|
|
930
|
+
// and mutates every row sharing the rest of the key. Same rule and same
|
|
931
|
+
// reason as `piiColumns` on the SQL engines.
|
|
927
932
|
const pii = this.piiColumnNames();
|
|
928
933
|
if (pii.size)
|
|
929
|
-
cols = cols.filter((c) => !pii.has(c));
|
|
934
|
+
cols = cols.filter((c) => !pii.has(c) || pk.has(c));
|
|
930
935
|
}
|
|
931
936
|
if (omit && Object.keys(omit).length) {
|
|
932
937
|
const dropped = new Set(Object.entries(omit)
|
|
933
938
|
.filter(([, v]) => v)
|
|
934
939
|
.map(([k]) => this.column(k).name));
|
|
935
|
-
|
|
940
|
+
// The PK survives `omit` too. This filter ran unconditionally and after
|
|
941
|
+
// the `select` branch's force-add, so `omit: { id: true }` undid the very
|
|
942
|
+
// guarantee that force-add exists to provide: the m2m loader keys its
|
|
943
|
+
// target map on the PK (`targetByPk`), so every target collapsed onto the
|
|
944
|
+
// single bucket "undefined", no parent matched, and the relation came
|
|
945
|
+
// back `[]` for every row with no error.
|
|
946
|
+
cols = cols.filter((c) => !dropped.has(c) || pk.has(c));
|
|
936
947
|
}
|
|
937
948
|
return cols;
|
|
938
949
|
}
|
|
@@ -304,6 +304,30 @@ async function loadRelationsBatched(ctx, parents, withClause, timeout, depth = 0
|
|
|
304
304
|
// A falsy `_count` opts out, exactly like a falsy relation spec.
|
|
305
305
|
const countSpec = withClause._count;
|
|
306
306
|
const hasCount = Boolean(countSpec);
|
|
307
|
+
// NESTED `_count` is refused here so the two strategies answer alike.
|
|
308
|
+
//
|
|
309
|
+
// The join builder emits `_count` only for the TOP-LEVEL `with`; one level
|
|
310
|
+
// down, `buildRelationSubquery` looks `_count` up as an ordinary relation
|
|
311
|
+
// name, misses, and throws E005. This loader handled it at every depth, so
|
|
312
|
+
// one query with one set of args either threw or returned populated counts
|
|
313
|
+
// depending purely on which plan ran, and under the `'auto'` default that
|
|
314
|
+
// choice is made by a cost heuristic reading index coverage and table size.
|
|
315
|
+
// The same code therefore worked on a small table and threw on a large one.
|
|
316
|
+
//
|
|
317
|
+
// That is the same non-determinism as the correlation-key bug this module was
|
|
318
|
+
// just fixed for, only louder, so it is closed the same way: by agreement.
|
|
319
|
+
// Refusing is the direction that is a no-op for anyone on the default, since
|
|
320
|
+
// there the shape already fails whenever the relation is NOT demoted. Nested
|
|
321
|
+
// `_count` is a real feature (Prisma has it) and teaching the join path is
|
|
322
|
+
// the right end state, but that means the four json_build_object emission
|
|
323
|
+
// sites, the positional encoding, SQL Server's FOR JSON override and PowDB's
|
|
324
|
+
// nested projections all learning it together, which is a feature release,
|
|
325
|
+
// not a line in a correctness fix. Until then both strategies say no.
|
|
326
|
+
if (hasCount && depth > 0) {
|
|
327
|
+
throw new errors_js_1.RelationError(`[turbine] Unknown relation "_count" on table "${ctx.parentMeta.name}". ` +
|
|
328
|
+
`Available: ${Object.keys(ctx.parentMeta.relations).join(', ')}. ` +
|
|
329
|
+
'(`_count` is supported on the top-level `with` only, on every relationLoadStrategy.)');
|
|
330
|
+
}
|
|
307
331
|
// Fix key order BEFORE anything is awaited: the loads below all write their
|
|
308
332
|
// key on completion, and completion order is a race between concurrent
|
|
309
333
|
// statements.
|
|
@@ -395,6 +419,7 @@ async function loadToOneOrMany(ctx, parents, rel, relName, options, timeout, dep
|
|
|
395
419
|
const childKeyCol = rel.type === 'belongsTo' ? rk[0] : fk[0];
|
|
396
420
|
const parentKeyField = ctx.parentMeta.reverseColumnMap[parentKeyCol] ?? parentKeyCol;
|
|
397
421
|
const childKeyField = targetMeta.reverseColumnMap[childKeyCol] ?? childKeyCol;
|
|
422
|
+
assertCorrelationKeyProjected(parents, parentKeyField, relName, ctx.parentMeta.name);
|
|
398
423
|
const keys = uniqueKeys(parents, parentKeyField);
|
|
399
424
|
const single = rel.type === 'belongsTo' || rel.type === 'hasOne';
|
|
400
425
|
if (keys.length === 0) {
|
|
@@ -404,7 +429,16 @@ async function loadToOneOrMany(ctx, parents, rel, relName, options, timeout, dep
|
|
|
404
429
|
}
|
|
405
430
|
// The follow-up must project the child correlation key even if the caller's
|
|
406
431
|
// select/omit excluded it; strip it back off afterwards so the shape matches join.
|
|
407
|
-
|
|
432
|
+
//
|
|
433
|
+
// AND the keys the child's OWN nested relations will correlate on. Passing
|
|
434
|
+
// only `childKeyField` here was the whole of the silent-null bug: this level
|
|
435
|
+
// stitched fine, then the recursion below asked the children for a key that
|
|
436
|
+
// this projection had just dropped. The root call sites in builder.ts have
|
|
437
|
+
// always resolved the full set through `neededParentKeyFields`; these nested
|
|
438
|
+
// ones did not, so the defect needed a `select` (or an `omit` of the FK) on a
|
|
439
|
+
// to-many with a to-one inside it, which is why `include` and `join` were both
|
|
440
|
+
// clean and seventeen rounds of parity capture missed it.
|
|
441
|
+
const proj = includeKeysForBatching(options.select, options.omit, [childKeyField, ...neededParentKeyFields(targetMeta, (options.with ?? {}))], defaultProjectionFields(targetMeta, ctx.includePii));
|
|
408
442
|
const child = ctx.makeChild(rel.to);
|
|
409
443
|
const chunks = [];
|
|
410
444
|
for (let i = 0; i < keys.length; i += MAX_RELATION_KEYS)
|
|
@@ -430,6 +464,12 @@ async function loadToOneOrMany(ctx, parents, rel, relName, options, timeout, dep
|
|
|
430
464
|
if (options.with && allChildren.length > 0) {
|
|
431
465
|
await loadRelationsBatched({ ...ctx, parentMeta: targetMeta }, allChildren, options.with, timeout, depth + 1, [...path, relName]);
|
|
432
466
|
}
|
|
467
|
+
// Symmetric to the parent-side assertion above. If the CHILD key were ever
|
|
468
|
+
// missing, `groupBy` would bucket every child under the string "undefined",
|
|
469
|
+
// no parent would match, and the relation would come back empty just as
|
|
470
|
+
// silently. `includeKeysForBatching` makes that unreachable, which is exactly
|
|
471
|
+
// what was true of the parent side until this week.
|
|
472
|
+
assertCorrelationKeyProjected(allChildren, childKeyField, relName, targetMeta.name);
|
|
433
473
|
const byKey = groupBy(allChildren, childKeyField);
|
|
434
474
|
const limit = options.limit;
|
|
435
475
|
for (const parent of parents) {
|
|
@@ -468,6 +508,7 @@ async function loadManyToMany(ctx, parents, rel, relName, options, timeout, dept
|
|
|
468
508
|
const targetPkCol = targetMeta.primaryKey[0];
|
|
469
509
|
const parentRefField = ctx.parentMeta.reverseColumnMap[sourceRefCol] ?? sourceRefCol;
|
|
470
510
|
const targetPkField = targetMeta.reverseColumnMap[targetPkCol] ?? targetPkCol;
|
|
511
|
+
assertCorrelationKeyProjected(parents, parentRefField, relName, ctx.parentMeta.name);
|
|
471
512
|
const parentKeys = uniqueKeys(parents, parentRefField);
|
|
472
513
|
if (parentKeys.length === 0) {
|
|
473
514
|
for (const parent of parents)
|
|
@@ -506,7 +547,10 @@ async function loadManyToMany(ctx, parents, rel, relName, options, timeout, dept
|
|
|
506
547
|
}
|
|
507
548
|
}
|
|
508
549
|
// (2) Target rows by PK, honouring the relation's own where/select/omit/orderBy.
|
|
509
|
-
|
|
550
|
+
// Plus the keys the target's own nested relations need, same rule and same
|
|
551
|
+
// reason as the to-many loader above: this level's PK is not the only key the
|
|
552
|
+
// recursion below will ask these rows for.
|
|
553
|
+
const proj = includeKeysForBatching(options.select, options.omit, [targetPkField, ...neededParentKeyFields(targetMeta, (options.with ?? {}))], defaultProjectionFields(targetMeta, ctx.includePii));
|
|
510
554
|
const child = ctx.makeChild(rel.to);
|
|
511
555
|
const targetVals = [...targetValSet];
|
|
512
556
|
const tChunks = [];
|
|
@@ -688,6 +732,48 @@ function mergeChildWhere(where, keyField, chunk) {
|
|
|
688
732
|
return { AND: [where, correlation] };
|
|
689
733
|
return { ...where, ...correlation };
|
|
690
734
|
}
|
|
735
|
+
/**
|
|
736
|
+
* Refuse to stitch a relation whose correlation key was never projected.
|
|
737
|
+
*
|
|
738
|
+
* THE FAILURE THIS EXISTS TO REMOVE. `uniqueKeys` skips a row whose key is
|
|
739
|
+
* `null` OR `undefined`, and a column the projection left out is `undefined`
|
|
740
|
+
* for exactly the same reason it is for a genuinely null FK. So a loader that
|
|
741
|
+
* has lost its key cannot tell itself apart from a page of parents that
|
|
742
|
+
* legitimately point at nothing: both produce zero keys, and the empty-key
|
|
743
|
+
* branch hands back `null` / `[]` for every parent. That is a wrong answer with
|
|
744
|
+
* an HTTP 200 on it: a reporting endpoint summed a money column across the
|
|
745
|
+
* relation, every row of it read as absent, and the total came back 0 for a
|
|
746
|
+
* large fraction of a page. Nothing in the payload, the logs or the response
|
|
747
|
+
* status distinguished it from the right answer.
|
|
748
|
+
*
|
|
749
|
+
* The two states ARE distinguishable, just not by the value: a column that was
|
|
750
|
+
* not selected is ABSENT from the parsed entity (`'fk' in row === false`),
|
|
751
|
+
* while a selected column holding SQL NULL is PRESENT with the value `null`.
|
|
752
|
+
* Verified against a live database in both directions. So the discriminator is
|
|
753
|
+
* property presence (`Object.hasOwn`, not `in`: the rest of this file uses it,
|
|
754
|
+
* and a column named `constructor` or `toString` passes an `in` check on any
|
|
755
|
+
* plain object), and it is checked over ALL parents rather than the first:
|
|
756
|
+
* every row on one level shares one projection, so a field missing from every
|
|
757
|
+
* row is a projection fault, while a field missing from some rows is not a
|
|
758
|
+
* state this loader can produce at all.
|
|
759
|
+
*
|
|
760
|
+
* After {@link neededParentKeyFields} is applied at every nesting level this is
|
|
761
|
+
* an unreachable invariant, which is the point: if it ever fires it is a bug in
|
|
762
|
+
* Turbine, not in the caller's query, and the caller gets told so along with the
|
|
763
|
+
* one-line workaround. E017 with the same remedy as the composite-key refusal a
|
|
764
|
+
* few lines up, deliberately: to the caller both are "this strategy cannot serve
|
|
765
|
+
* this shape, use 'join'", and inventing a code for an unreachable state would
|
|
766
|
+
* be a new public error nobody can trigger.
|
|
767
|
+
*/
|
|
768
|
+
function assertCorrelationKeyProjected(parents, field, relName, parentTable) {
|
|
769
|
+
if (parents.length === 0)
|
|
770
|
+
return;
|
|
771
|
+
if (parents.some((parent) => Object.hasOwn(parent, field)))
|
|
772
|
+
return;
|
|
773
|
+
throw new errors_js_1.UnsupportedFeatureError(`batched loading of relation "${relName}" on "${parentTable}"`, 'relationLoadStrategy: "batched"', `the correlation key "${field}" is missing from every parent row, so the relation cannot be stitched and ` +
|
|
774
|
+
'would silently come back empty. This is a bug in turbine, please report it. ' +
|
|
775
|
+
`Workaround: pass \`relationLoadStrategy: 'join'\` on this query.`);
|
|
776
|
+
}
|
|
691
777
|
/** Distinct, non-null values of `field` across `rows`. */
|
|
692
778
|
function uniqueKeys(rows, field) {
|
|
693
779
|
const seen = new Set();
|
package/dist/cjs/query/writes.js
CHANGED
|
@@ -706,8 +706,25 @@ function buildDeleteMany(qi, args) {
|
|
|
706
706
|
*/
|
|
707
707
|
function piiColumns(_qi, meta) {
|
|
708
708
|
const out = new Set();
|
|
709
|
+
const pk = new Set(meta.primaryKey);
|
|
709
710
|
for (const col of meta.columns) {
|
|
710
|
-
|
|
711
|
+
// A PII-tagged PRIMARY KEY column is NEVER stripped. The exemption lives
|
|
712
|
+
// here, in the helper every projection reads, rather than at the call
|
|
713
|
+
// sites: it was written once at the write site (`|| pk.has(col)`) and no
|
|
714
|
+
// read path had it, so a table whose PK member is tagged returned rows
|
|
715
|
+
// that could not address themselves. Round-tripping such a row into an
|
|
716
|
+
// update produced a PARTIAL predicate (the missing PK member is
|
|
717
|
+
// `undefined`, which the where compiler drops, and the empty-where guard
|
|
718
|
+
// does not fire because the OTHER member is present), so `update` silently
|
|
719
|
+
// rewrote every row sharing the remaining key instead of one. Measured on
|
|
720
|
+
// a composite `(org_id, email)` PK: three rows changed where one was
|
|
721
|
+
// asked for, no error.
|
|
722
|
+
//
|
|
723
|
+
// The policy this implements is the one already stated on
|
|
724
|
+
// {@link writeReturningColumns}: tag sensitive data, not keys; a PII PK is
|
|
725
|
+
// out of scope for stripping because the returned row must stay
|
|
726
|
+
// addressable. Only the enforcement was missing.
|
|
727
|
+
if (col.pii && !pk.has(col.name))
|
|
711
728
|
out.add(col.name);
|
|
712
729
|
}
|
|
713
730
|
return out;
|
|
@@ -721,8 +738,14 @@ function piiColumns(_qi, meta) {
|
|
|
721
738
|
*/
|
|
722
739
|
function piiFields(_qi, meta) {
|
|
723
740
|
const out = [];
|
|
741
|
+
const pk = new Set(meta.primaryKey);
|
|
742
|
+
// Same PK exemption as {@link piiColumns}, and it MATTERS here rather than
|
|
743
|
+
// being belt-and-braces: this strip runs on an already-fetched row, so
|
|
744
|
+
// without it the SQL kept the PK addressable and this deleted it again,
|
|
745
|
+
// which is the exact contradiction between this function's own "no-op"
|
|
746
|
+
// docstring and writeReturningColumns' "must stay addressable".
|
|
724
747
|
for (const col of meta.columns) {
|
|
725
|
-
if (col.pii)
|
|
748
|
+
if (col.pii && !pk.has(col.name))
|
|
726
749
|
out.push(col.field);
|
|
727
750
|
}
|
|
728
751
|
return out;
|
|
@@ -794,11 +817,12 @@ function namedColumns(meta, data) {
|
|
|
794
817
|
return out;
|
|
795
818
|
}
|
|
796
819
|
function writeReturningColumns(qi) {
|
|
820
|
+
// The PK exemption used to be re-stated here as `|| pk.has(col)`. It now
|
|
821
|
+
// lives in `piiColumns` so every projection inherits it and none can drift.
|
|
797
822
|
const piiCols = piiColumns(qi, qi.tableMeta);
|
|
798
823
|
if (piiCols.size === 0)
|
|
799
824
|
return '*';
|
|
800
|
-
|
|
801
|
-
return qi.tableMeta.allColumns.filter((col) => !piiCols.has(col) || pk.has(col)).map((col) => qi.q(col));
|
|
825
|
+
return qi.tableMeta.allColumns.filter((col) => !piiCols.has(col)).map((col) => qi.q(col));
|
|
802
826
|
}
|
|
803
827
|
/**
|
|
804
828
|
* String form of {@link writeReturningColumns} for a `SELECT` list (the
|
package/dist/nested-write.js
CHANGED
|
@@ -229,6 +229,18 @@ function pkWhere(tableMeta, row) {
|
|
|
229
229
|
const where = {};
|
|
230
230
|
for (const col of tableMeta.primaryKey) {
|
|
231
231
|
const field = tableMeta.reverseColumnMap[col] ?? col;
|
|
232
|
+
// A PARTIAL primary key here is not a filter, it is a mass mutation.
|
|
233
|
+
// `undefined` values are dropped when the where compiles, and the
|
|
234
|
+
// empty-where guard only fires when NOTHING survives, so a composite PK
|
|
235
|
+
// missing one member compiles to a predicate on the remaining member and
|
|
236
|
+
// the statement rewrites every row that shares it. The row is supposed to
|
|
237
|
+
// be one this engine just read, so a missing member is an internal fault,
|
|
238
|
+
// and the only safe response is to refuse rather than to run.
|
|
239
|
+
if (row[field] === undefined) {
|
|
240
|
+
throw new ValidationError(`[turbine] Cannot address a row of "${tableMeta.name}" by primary key: "${field}" is missing from the ` +
|
|
241
|
+
'row this nested write is operating on, so the generated predicate would match more rows than intended. ' +
|
|
242
|
+
'This is a bug in turbine, please report it.');
|
|
243
|
+
}
|
|
232
244
|
where[field] = row[field];
|
|
233
245
|
}
|
|
234
246
|
return where;
|
package/dist/powql.js
CHANGED
|
@@ -873,6 +873,7 @@ export class PowqlInterface {
|
|
|
873
873
|
* and returns it regardless. Untagged tables project exactly as before.
|
|
874
874
|
*/
|
|
875
875
|
projectedColumns(select, omit, includePii) {
|
|
876
|
+
const pk = new Set(this.meta.primaryKey);
|
|
876
877
|
let cols = this.meta.columns.map((c) => c.name);
|
|
877
878
|
const hasSelect = select && Object.keys(select).length;
|
|
878
879
|
if (hasSelect) {
|
|
@@ -880,23 +881,33 @@ export class PowqlInterface {
|
|
|
880
881
|
.filter(([, v]) => v)
|
|
881
882
|
.map(([k]) => this.column(k).name));
|
|
882
883
|
// Always keep the PK so reselect / relation stitching has a key to work with.
|
|
883
|
-
for (const
|
|
884
|
-
picked.add(
|
|
884
|
+
for (const key of pk)
|
|
885
|
+
picked.add(key);
|
|
885
886
|
cols = cols.filter((c) => picked.has(c));
|
|
886
887
|
}
|
|
887
888
|
else if (!includePii) {
|
|
888
889
|
// Default / omit-only projection: drop PII columns (kept above only when a
|
|
889
|
-
// caller names them in `select`)
|
|
890
|
-
// tagged
|
|
890
|
+
// caller names them in `select`), EXCEPT a PK column. This used to drop a
|
|
891
|
+
// tagged PK, on the reasoning that keys should not be tagged in the first
|
|
892
|
+
// place. That is good advice and a bad guarantee: the row it returns
|
|
893
|
+
// cannot address itself, so writing it back builds a partial predicate
|
|
894
|
+
// and mutates every row sharing the rest of the key. Same rule and same
|
|
895
|
+
// reason as `piiColumns` on the SQL engines.
|
|
891
896
|
const pii = this.piiColumnNames();
|
|
892
897
|
if (pii.size)
|
|
893
|
-
cols = cols.filter((c) => !pii.has(c));
|
|
898
|
+
cols = cols.filter((c) => !pii.has(c) || pk.has(c));
|
|
894
899
|
}
|
|
895
900
|
if (omit && Object.keys(omit).length) {
|
|
896
901
|
const dropped = new Set(Object.entries(omit)
|
|
897
902
|
.filter(([, v]) => v)
|
|
898
903
|
.map(([k]) => this.column(k).name));
|
|
899
|
-
|
|
904
|
+
// The PK survives `omit` too. This filter ran unconditionally and after
|
|
905
|
+
// the `select` branch's force-add, so `omit: { id: true }` undid the very
|
|
906
|
+
// guarantee that force-add exists to provide: the m2m loader keys its
|
|
907
|
+
// target map on the PK (`targetByPk`), so every target collapsed onto the
|
|
908
|
+
// single bucket "undefined", no parent matched, and the relation came
|
|
909
|
+
// back `[]` for every row with no error.
|
|
910
|
+
cols = cols.filter((c) => !dropped.has(c) || pk.has(c));
|
|
900
911
|
}
|
|
901
912
|
return cols;
|
|
902
913
|
}
|
|
@@ -295,6 +295,30 @@ export async function loadRelationsBatched(ctx, parents, withClause, timeout, de
|
|
|
295
295
|
// A falsy `_count` opts out, exactly like a falsy relation spec.
|
|
296
296
|
const countSpec = withClause._count;
|
|
297
297
|
const hasCount = Boolean(countSpec);
|
|
298
|
+
// NESTED `_count` is refused here so the two strategies answer alike.
|
|
299
|
+
//
|
|
300
|
+
// The join builder emits `_count` only for the TOP-LEVEL `with`; one level
|
|
301
|
+
// down, `buildRelationSubquery` looks `_count` up as an ordinary relation
|
|
302
|
+
// name, misses, and throws E005. This loader handled it at every depth, so
|
|
303
|
+
// one query with one set of args either threw or returned populated counts
|
|
304
|
+
// depending purely on which plan ran, and under the `'auto'` default that
|
|
305
|
+
// choice is made by a cost heuristic reading index coverage and table size.
|
|
306
|
+
// The same code therefore worked on a small table and threw on a large one.
|
|
307
|
+
//
|
|
308
|
+
// That is the same non-determinism as the correlation-key bug this module was
|
|
309
|
+
// just fixed for, only louder, so it is closed the same way: by agreement.
|
|
310
|
+
// Refusing is the direction that is a no-op for anyone on the default, since
|
|
311
|
+
// there the shape already fails whenever the relation is NOT demoted. Nested
|
|
312
|
+
// `_count` is a real feature (Prisma has it) and teaching the join path is
|
|
313
|
+
// the right end state, but that means the four json_build_object emission
|
|
314
|
+
// sites, the positional encoding, SQL Server's FOR JSON override and PowDB's
|
|
315
|
+
// nested projections all learning it together, which is a feature release,
|
|
316
|
+
// not a line in a correctness fix. Until then both strategies say no.
|
|
317
|
+
if (hasCount && depth > 0) {
|
|
318
|
+
throw new RelationError(`[turbine] Unknown relation "_count" on table "${ctx.parentMeta.name}". ` +
|
|
319
|
+
`Available: ${Object.keys(ctx.parentMeta.relations).join(', ')}. ` +
|
|
320
|
+
'(`_count` is supported on the top-level `with` only, on every relationLoadStrategy.)');
|
|
321
|
+
}
|
|
298
322
|
// Fix key order BEFORE anything is awaited: the loads below all write their
|
|
299
323
|
// key on completion, and completion order is a race between concurrent
|
|
300
324
|
// statements.
|
|
@@ -386,6 +410,7 @@ async function loadToOneOrMany(ctx, parents, rel, relName, options, timeout, dep
|
|
|
386
410
|
const childKeyCol = rel.type === 'belongsTo' ? rk[0] : fk[0];
|
|
387
411
|
const parentKeyField = ctx.parentMeta.reverseColumnMap[parentKeyCol] ?? parentKeyCol;
|
|
388
412
|
const childKeyField = targetMeta.reverseColumnMap[childKeyCol] ?? childKeyCol;
|
|
413
|
+
assertCorrelationKeyProjected(parents, parentKeyField, relName, ctx.parentMeta.name);
|
|
389
414
|
const keys = uniqueKeys(parents, parentKeyField);
|
|
390
415
|
const single = rel.type === 'belongsTo' || rel.type === 'hasOne';
|
|
391
416
|
if (keys.length === 0) {
|
|
@@ -395,7 +420,16 @@ async function loadToOneOrMany(ctx, parents, rel, relName, options, timeout, dep
|
|
|
395
420
|
}
|
|
396
421
|
// The follow-up must project the child correlation key even if the caller's
|
|
397
422
|
// select/omit excluded it; strip it back off afterwards so the shape matches join.
|
|
398
|
-
|
|
423
|
+
//
|
|
424
|
+
// AND the keys the child's OWN nested relations will correlate on. Passing
|
|
425
|
+
// only `childKeyField` here was the whole of the silent-null bug: this level
|
|
426
|
+
// stitched fine, then the recursion below asked the children for a key that
|
|
427
|
+
// this projection had just dropped. The root call sites in builder.ts have
|
|
428
|
+
// always resolved the full set through `neededParentKeyFields`; these nested
|
|
429
|
+
// ones did not, so the defect needed a `select` (or an `omit` of the FK) on a
|
|
430
|
+
// to-many with a to-one inside it, which is why `include` and `join` were both
|
|
431
|
+
// clean and seventeen rounds of parity capture missed it.
|
|
432
|
+
const proj = includeKeysForBatching(options.select, options.omit, [childKeyField, ...neededParentKeyFields(targetMeta, (options.with ?? {}))], defaultProjectionFields(targetMeta, ctx.includePii));
|
|
399
433
|
const child = ctx.makeChild(rel.to);
|
|
400
434
|
const chunks = [];
|
|
401
435
|
for (let i = 0; i < keys.length; i += MAX_RELATION_KEYS)
|
|
@@ -421,6 +455,12 @@ async function loadToOneOrMany(ctx, parents, rel, relName, options, timeout, dep
|
|
|
421
455
|
if (options.with && allChildren.length > 0) {
|
|
422
456
|
await loadRelationsBatched({ ...ctx, parentMeta: targetMeta }, allChildren, options.with, timeout, depth + 1, [...path, relName]);
|
|
423
457
|
}
|
|
458
|
+
// Symmetric to the parent-side assertion above. If the CHILD key were ever
|
|
459
|
+
// missing, `groupBy` would bucket every child under the string "undefined",
|
|
460
|
+
// no parent would match, and the relation would come back empty just as
|
|
461
|
+
// silently. `includeKeysForBatching` makes that unreachable, which is exactly
|
|
462
|
+
// what was true of the parent side until this week.
|
|
463
|
+
assertCorrelationKeyProjected(allChildren, childKeyField, relName, targetMeta.name);
|
|
424
464
|
const byKey = groupBy(allChildren, childKeyField);
|
|
425
465
|
const limit = options.limit;
|
|
426
466
|
for (const parent of parents) {
|
|
@@ -459,6 +499,7 @@ async function loadManyToMany(ctx, parents, rel, relName, options, timeout, dept
|
|
|
459
499
|
const targetPkCol = targetMeta.primaryKey[0];
|
|
460
500
|
const parentRefField = ctx.parentMeta.reverseColumnMap[sourceRefCol] ?? sourceRefCol;
|
|
461
501
|
const targetPkField = targetMeta.reverseColumnMap[targetPkCol] ?? targetPkCol;
|
|
502
|
+
assertCorrelationKeyProjected(parents, parentRefField, relName, ctx.parentMeta.name);
|
|
462
503
|
const parentKeys = uniqueKeys(parents, parentRefField);
|
|
463
504
|
if (parentKeys.length === 0) {
|
|
464
505
|
for (const parent of parents)
|
|
@@ -497,7 +538,10 @@ async function loadManyToMany(ctx, parents, rel, relName, options, timeout, dept
|
|
|
497
538
|
}
|
|
498
539
|
}
|
|
499
540
|
// (2) Target rows by PK, honouring the relation's own where/select/omit/orderBy.
|
|
500
|
-
|
|
541
|
+
// Plus the keys the target's own nested relations need, same rule and same
|
|
542
|
+
// reason as the to-many loader above: this level's PK is not the only key the
|
|
543
|
+
// recursion below will ask these rows for.
|
|
544
|
+
const proj = includeKeysForBatching(options.select, options.omit, [targetPkField, ...neededParentKeyFields(targetMeta, (options.with ?? {}))], defaultProjectionFields(targetMeta, ctx.includePii));
|
|
501
545
|
const child = ctx.makeChild(rel.to);
|
|
502
546
|
const targetVals = [...targetValSet];
|
|
503
547
|
const tChunks = [];
|
|
@@ -679,6 +723,48 @@ function mergeChildWhere(where, keyField, chunk) {
|
|
|
679
723
|
return { AND: [where, correlation] };
|
|
680
724
|
return { ...where, ...correlation };
|
|
681
725
|
}
|
|
726
|
+
/**
|
|
727
|
+
* Refuse to stitch a relation whose correlation key was never projected.
|
|
728
|
+
*
|
|
729
|
+
* THE FAILURE THIS EXISTS TO REMOVE. `uniqueKeys` skips a row whose key is
|
|
730
|
+
* `null` OR `undefined`, and a column the projection left out is `undefined`
|
|
731
|
+
* for exactly the same reason it is for a genuinely null FK. So a loader that
|
|
732
|
+
* has lost its key cannot tell itself apart from a page of parents that
|
|
733
|
+
* legitimately point at nothing: both produce zero keys, and the empty-key
|
|
734
|
+
* branch hands back `null` / `[]` for every parent. That is a wrong answer with
|
|
735
|
+
* an HTTP 200 on it: a reporting endpoint summed a money column across the
|
|
736
|
+
* relation, every row of it read as absent, and the total came back 0 for a
|
|
737
|
+
* large fraction of a page. Nothing in the payload, the logs or the response
|
|
738
|
+
* status distinguished it from the right answer.
|
|
739
|
+
*
|
|
740
|
+
* The two states ARE distinguishable, just not by the value: a column that was
|
|
741
|
+
* not selected is ABSENT from the parsed entity (`'fk' in row === false`),
|
|
742
|
+
* while a selected column holding SQL NULL is PRESENT with the value `null`.
|
|
743
|
+
* Verified against a live database in both directions. So the discriminator is
|
|
744
|
+
* property presence (`Object.hasOwn`, not `in`: the rest of this file uses it,
|
|
745
|
+
* and a column named `constructor` or `toString` passes an `in` check on any
|
|
746
|
+
* plain object), and it is checked over ALL parents rather than the first:
|
|
747
|
+
* every row on one level shares one projection, so a field missing from every
|
|
748
|
+
* row is a projection fault, while a field missing from some rows is not a
|
|
749
|
+
* state this loader can produce at all.
|
|
750
|
+
*
|
|
751
|
+
* After {@link neededParentKeyFields} is applied at every nesting level this is
|
|
752
|
+
* an unreachable invariant, which is the point: if it ever fires it is a bug in
|
|
753
|
+
* Turbine, not in the caller's query, and the caller gets told so along with the
|
|
754
|
+
* one-line workaround. E017 with the same remedy as the composite-key refusal a
|
|
755
|
+
* few lines up, deliberately: to the caller both are "this strategy cannot serve
|
|
756
|
+
* this shape, use 'join'", and inventing a code for an unreachable state would
|
|
757
|
+
* be a new public error nobody can trigger.
|
|
758
|
+
*/
|
|
759
|
+
function assertCorrelationKeyProjected(parents, field, relName, parentTable) {
|
|
760
|
+
if (parents.length === 0)
|
|
761
|
+
return;
|
|
762
|
+
if (parents.some((parent) => Object.hasOwn(parent, field)))
|
|
763
|
+
return;
|
|
764
|
+
throw new UnsupportedFeatureError(`batched loading of relation "${relName}" on "${parentTable}"`, 'relationLoadStrategy: "batched"', `the correlation key "${field}" is missing from every parent row, so the relation cannot be stitched and ` +
|
|
765
|
+
'would silently come back empty. This is a bug in turbine, please report it. ' +
|
|
766
|
+
`Workaround: pass \`relationLoadStrategy: 'join'\` on this query.`);
|
|
767
|
+
}
|
|
682
768
|
/** Distinct, non-null values of `field` across `rows`. */
|
|
683
769
|
function uniqueKeys(rows, field) {
|
|
684
770
|
const seen = new Set();
|
package/dist/query/writes.js
CHANGED
|
@@ -649,8 +649,25 @@ export function buildDeleteMany(qi, args) {
|
|
|
649
649
|
*/
|
|
650
650
|
export function piiColumns(_qi, meta) {
|
|
651
651
|
const out = new Set();
|
|
652
|
+
const pk = new Set(meta.primaryKey);
|
|
652
653
|
for (const col of meta.columns) {
|
|
653
|
-
|
|
654
|
+
// A PII-tagged PRIMARY KEY column is NEVER stripped. The exemption lives
|
|
655
|
+
// here, in the helper every projection reads, rather than at the call
|
|
656
|
+
// sites: it was written once at the write site (`|| pk.has(col)`) and no
|
|
657
|
+
// read path had it, so a table whose PK member is tagged returned rows
|
|
658
|
+
// that could not address themselves. Round-tripping such a row into an
|
|
659
|
+
// update produced a PARTIAL predicate (the missing PK member is
|
|
660
|
+
// `undefined`, which the where compiler drops, and the empty-where guard
|
|
661
|
+
// does not fire because the OTHER member is present), so `update` silently
|
|
662
|
+
// rewrote every row sharing the remaining key instead of one. Measured on
|
|
663
|
+
// a composite `(org_id, email)` PK: three rows changed where one was
|
|
664
|
+
// asked for, no error.
|
|
665
|
+
//
|
|
666
|
+
// The policy this implements is the one already stated on
|
|
667
|
+
// {@link writeReturningColumns}: tag sensitive data, not keys; a PII PK is
|
|
668
|
+
// out of scope for stripping because the returned row must stay
|
|
669
|
+
// addressable. Only the enforcement was missing.
|
|
670
|
+
if (col.pii && !pk.has(col.name))
|
|
654
671
|
out.add(col.name);
|
|
655
672
|
}
|
|
656
673
|
return out;
|
|
@@ -664,8 +681,14 @@ export function piiColumns(_qi, meta) {
|
|
|
664
681
|
*/
|
|
665
682
|
export function piiFields(_qi, meta) {
|
|
666
683
|
const out = [];
|
|
684
|
+
const pk = new Set(meta.primaryKey);
|
|
685
|
+
// Same PK exemption as {@link piiColumns}, and it MATTERS here rather than
|
|
686
|
+
// being belt-and-braces: this strip runs on an already-fetched row, so
|
|
687
|
+
// without it the SQL kept the PK addressable and this deleted it again,
|
|
688
|
+
// which is the exact contradiction between this function's own "no-op"
|
|
689
|
+
// docstring and writeReturningColumns' "must stay addressable".
|
|
667
690
|
for (const col of meta.columns) {
|
|
668
|
-
if (col.pii)
|
|
691
|
+
if (col.pii && !pk.has(col.name))
|
|
669
692
|
out.push(col.field);
|
|
670
693
|
}
|
|
671
694
|
return out;
|
|
@@ -737,11 +760,12 @@ function namedColumns(meta, data) {
|
|
|
737
760
|
return out;
|
|
738
761
|
}
|
|
739
762
|
export function writeReturningColumns(qi) {
|
|
763
|
+
// The PK exemption used to be re-stated here as `|| pk.has(col)`. It now
|
|
764
|
+
// lives in `piiColumns` so every projection inherits it and none can drift.
|
|
740
765
|
const piiCols = piiColumns(qi, qi.tableMeta);
|
|
741
766
|
if (piiCols.size === 0)
|
|
742
767
|
return '*';
|
|
743
|
-
|
|
744
|
-
return qi.tableMeta.allColumns.filter((col) => !piiCols.has(col) || pk.has(col)).map((col) => qi.q(col));
|
|
768
|
+
return qi.tableMeta.allColumns.filter((col) => !piiCols.has(col)).map((col) => qi.q(col));
|
|
745
769
|
}
|
|
746
770
|
/**
|
|
747
771
|
* String form of {@link writeReturningColumns} for a `SELECT` list (the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "turbine-orm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.63.0",
|
|
4
4
|
"description": "Postgres-native TypeScript ORM, runs on Neon, Vercel Postgres, Cloudflare, Supabase. Streaming cursors, typed errors, single-query nested relations. One dependency, no WASM engine",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"//exports": "Each subpath declares its types PER CONDITION. A single shared top-level \"types\" resolves to the ESM declarations for `require` too, which is TS1479 (\"is an ES module ... cannot be require()d\") for any CJS consumer on moduleResolution node16/nodenext. The require condition points at dist/cjs, which ships its own {\"type\":\"commonjs\"} package.json, so those declarations are CJS declarations. Gated in CI by publint + @arethetypeswrong/cli + a real .cts consumer typecheck (see the package-types job in ci.yml).",
|