turbine-orm 0.62.1 → 0.64.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.d.ts +9 -0
- package/dist/cjs/powql.js +33 -8
- package/dist/cjs/query/batched-loader.js +88 -2
- package/dist/cjs/query/relations.d.ts +42 -3
- package/dist/cjs/query/relations.js +77 -34
- package/dist/cjs/query/utils.d.ts +16 -0
- package/dist/cjs/query/utils.js +25 -0
- package/dist/cjs/query/writes.js +28 -4
- package/dist/nested-write.js +12 -0
- package/dist/powql.d.ts +9 -0
- package/dist/powql.js +34 -9
- package/dist/query/batched-loader.js +88 -2
- package/dist/query/relations.d.ts +42 -3
- package/dist/query/relations.js +77 -35
- package/dist/query/utils.d.ts +16 -0
- package/dist/query/utils.js +24 -0
- 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.d.ts
CHANGED
|
@@ -307,6 +307,15 @@ export declare class PowqlInterface<T extends object = Record<string, unknown>>
|
|
|
307
307
|
* `includePii` is true; an explicit `select` naming a PII column IS the opt-in
|
|
308
308
|
* and returns it regardless. Untagged tables project exactly as before.
|
|
309
309
|
*/
|
|
310
|
+
/**
|
|
311
|
+
* Resolve one projection field name, or throw. PowQL already refused an
|
|
312
|
+
* unresolvable name here (via {@link column}), unlike the SQL engines' join
|
|
313
|
+
* path, which filtered it out silently until 0.64. What this adds is the
|
|
314
|
+
* RELATION case: naming a relation inside `select` is a Prisma habit rather
|
|
315
|
+
* than a typo, and the generic "unknown column" text sends the reader looking
|
|
316
|
+
* for a misspelling that is not there. Same message as the SQL engines.
|
|
317
|
+
*/
|
|
318
|
+
private projectionColumn;
|
|
310
319
|
private projectedColumns;
|
|
311
320
|
/**
|
|
312
321
|
* The snake_case names of this table's PII-tagged columns. Empty for a table
|
package/dist/cjs/powql.js
CHANGED
|
@@ -908,31 +908,56 @@ class PowqlInterface {
|
|
|
908
908
|
* `includePii` is true; an explicit `select` naming a PII column IS the opt-in
|
|
909
909
|
* and returns it regardless. Untagged tables project exactly as before.
|
|
910
910
|
*/
|
|
911
|
+
/**
|
|
912
|
+
* Resolve one projection field name, or throw. PowQL already refused an
|
|
913
|
+
* unresolvable name here (via {@link column}), unlike the SQL engines' join
|
|
914
|
+
* path, which filtered it out silently until 0.64. What this adds is the
|
|
915
|
+
* RELATION case: naming a relation inside `select` is a Prisma habit rather
|
|
916
|
+
* than a typo, and the generic "unknown column" text sends the reader looking
|
|
917
|
+
* for a misspelling that is not there. Same message as the SQL engines.
|
|
918
|
+
*/
|
|
919
|
+
projectionColumn(field, clause) {
|
|
920
|
+
if ((0, utils_js_1.ownLookup)(this.meta.relations, field)) {
|
|
921
|
+
throw new errors_js_1.ValidationError((0, utils_js_1.relationInProjectionMessage)(this.table, field, clause));
|
|
922
|
+
}
|
|
923
|
+
return this.column(field).name;
|
|
924
|
+
}
|
|
911
925
|
projectedColumns(select, omit, includePii) {
|
|
926
|
+
const pk = new Set(this.meta.primaryKey);
|
|
912
927
|
let cols = this.meta.columns.map((c) => c.name);
|
|
913
928
|
const hasSelect = select && Object.keys(select).length;
|
|
914
929
|
if (hasSelect) {
|
|
915
930
|
const picked = new Set(Object.entries(select)
|
|
916
931
|
.filter(([, v]) => v)
|
|
917
|
-
.map(([k]) => this.
|
|
932
|
+
.map(([k]) => this.projectionColumn(k, 'select')));
|
|
918
933
|
// Always keep the PK so reselect / relation stitching has a key to work with.
|
|
919
|
-
for (const
|
|
920
|
-
picked.add(
|
|
934
|
+
for (const key of pk)
|
|
935
|
+
picked.add(key);
|
|
921
936
|
cols = cols.filter((c) => picked.has(c));
|
|
922
937
|
}
|
|
923
938
|
else if (!includePii) {
|
|
924
939
|
// Default / omit-only projection: drop PII columns (kept above only when a
|
|
925
|
-
// caller names them in `select`)
|
|
926
|
-
// tagged
|
|
940
|
+
// caller names them in `select`), EXCEPT a PK column. This used to drop a
|
|
941
|
+
// tagged PK, on the reasoning that keys should not be tagged in the first
|
|
942
|
+
// place. That is good advice and a bad guarantee: the row it returns
|
|
943
|
+
// cannot address itself, so writing it back builds a partial predicate
|
|
944
|
+
// and mutates every row sharing the rest of the key. Same rule and same
|
|
945
|
+
// reason as `piiColumns` on the SQL engines.
|
|
927
946
|
const pii = this.piiColumnNames();
|
|
928
947
|
if (pii.size)
|
|
929
|
-
cols = cols.filter((c) => !pii.has(c));
|
|
948
|
+
cols = cols.filter((c) => !pii.has(c) || pk.has(c));
|
|
930
949
|
}
|
|
931
950
|
if (omit && Object.keys(omit).length) {
|
|
932
951
|
const dropped = new Set(Object.entries(omit)
|
|
933
952
|
.filter(([, v]) => v)
|
|
934
|
-
.map(([k]) => this.
|
|
935
|
-
|
|
953
|
+
.map(([k]) => this.projectionColumn(k, 'omit')));
|
|
954
|
+
// The PK survives `omit` too. This filter ran unconditionally and after
|
|
955
|
+
// the `select` branch's force-add, so `omit: { id: true }` undid the very
|
|
956
|
+
// guarantee that force-add exists to provide: the m2m loader keys its
|
|
957
|
+
// target map on the PK (`targetByPk`), so every target collapsed onto the
|
|
958
|
+
// single bucket "undefined", no parent matched, and the relation came
|
|
959
|
+
// back `[]` for every row with no error.
|
|
960
|
+
cols = cols.filter((c) => !dropped.has(c) || pk.has(c));
|
|
936
961
|
}
|
|
937
962
|
return cols;
|
|
938
963
|
}
|
|
@@ -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();
|
|
@@ -34,8 +34,41 @@ export interface RelationShape {
|
|
|
34
34
|
cardinality: 'many' | 'one';
|
|
35
35
|
}
|
|
36
36
|
/**
|
|
37
|
-
* Resolve select/omit
|
|
38
|
-
*
|
|
37
|
+
* Resolve `select` / `omit` into a list of snake_case column names, for the
|
|
38
|
+
* query's own table and for a relation target alike. `null` means "no
|
|
39
|
+
* projection", i.e. all columns, which keeps the `*` fast path.
|
|
40
|
+
*
|
|
41
|
+
* ## Why this is one function
|
|
42
|
+
*
|
|
43
|
+
* It used to be two, `resolveColumns` for the top level and
|
|
44
|
+
* `resolveTargetColumns` for a relation target, doing the same job against
|
|
45
|
+
* different metadata. They drifted, and the drift was invisible from either
|
|
46
|
+
* side: the top level resolved every name through a throwing lookup, while the
|
|
47
|
+
* relation side filtered unresolvable names out and emitted SQL for whatever
|
|
48
|
+
* survived. So the SAME key in the SAME query threw at the top and was silently
|
|
49
|
+
* ignored one level down, where `select: { titel: true }` returned `{}` rows
|
|
50
|
+
* and `omit: { titel: true }` returned the column it was asked to hide.
|
|
51
|
+
*
|
|
52
|
+
* It was worse than an inconsistency between depths. The batched loader runs
|
|
53
|
+
* each relation as a real query against the target table, so it went through
|
|
54
|
+
* the THROWING path, while the join plan went through the silent one. The two
|
|
55
|
+
* strategies therefore disagreed about whether the query was even valid, and
|
|
56
|
+
* under `relationLoadStrategy: 'auto'` which one runs is decided by a cost
|
|
57
|
+
* heuristic reading index coverage and table size. The same code threw on one
|
|
58
|
+
* table and quietly returned the wrong shape on another.
|
|
59
|
+
*
|
|
60
|
+
* Merging them is the fix that outlives this bug. Two functions that must agree
|
|
61
|
+
* are kept in step by whoever remembers; one function cannot disagree with
|
|
62
|
+
* itself. This mirrors `walkWhere` in where-compile.ts, which exists for the
|
|
63
|
+
* same reason after the top-level and relation-scoped WHERE walkers drifted
|
|
64
|
+
* twice, and it is why a new projection site is safe by default: PII exclusion,
|
|
65
|
+
* the `*` fast path and name resolution all live here, so reimplementing the
|
|
66
|
+
* name handling would mean reimplementing those too.
|
|
67
|
+
*/
|
|
68
|
+
export declare function resolveProjection(qi: BuilderCtx, table: string, meta: TableMetadata, select?: Record<string, boolean>, omit?: Record<string, boolean>, includePii?: boolean): string[] | null;
|
|
69
|
+
/**
|
|
70
|
+
* The query's own table. Thin wrapper over {@link resolveProjection} kept for
|
|
71
|
+
* the existing call sites in builder.ts.
|
|
39
72
|
*/
|
|
40
73
|
export declare function resolveColumns(qi: BuilderCtx, select?: Record<string, boolean>, omit?: Record<string, boolean>, includePii?: boolean): string[] | null;
|
|
41
74
|
/**
|
|
@@ -254,8 +287,14 @@ export declare function parseNestedRow(qi: BuilderCtx, row: Record<string, unkno
|
|
|
254
287
|
* Resolve the emitted column list for a relation, honoring `select` / `omit`.
|
|
255
288
|
* Shared by {@link buildRelationSubquery} (json order) and
|
|
256
289
|
* {@link buildRelationShape} (decode key order) so they can never diverge.
|
|
290
|
+
*
|
|
291
|
+
* A relation always projects SOMETHING, so the `null` that
|
|
292
|
+
* {@link resolveProjection} uses for the top level's `SELECT *` fast path
|
|
293
|
+
* becomes the target's full column list here. That is the only difference
|
|
294
|
+
* between the two, and it is why this is a four-line wrapper rather than a
|
|
295
|
+
* second implementation.
|
|
257
296
|
*/
|
|
258
|
-
export declare function resolveTargetColumns(qi: BuilderCtx, spec: true | WithOptions, targetMeta: TableMetadata, includePii?: boolean): string[];
|
|
297
|
+
export declare function resolveTargetColumns(qi: BuilderCtx, spec: true | WithOptions, targetMeta: TableMetadata, includePii?: boolean, targetTable?: string): string[];
|
|
259
298
|
/**
|
|
260
299
|
* Render a single relation row's JSON: a keyed object (`'object'`) or a
|
|
261
300
|
* positional array (`'positional'`). The array drops the keys but keeps the
|
|
@@ -47,6 +47,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
47
47
|
};
|
|
48
48
|
})();
|
|
49
49
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
50
|
+
exports.resolveProjection = resolveProjection;
|
|
50
51
|
exports.resolveColumns = resolveColumns;
|
|
51
52
|
exports.withFingerprint = withFingerprint;
|
|
52
53
|
exports.collectWithParams = collectWithParams;
|
|
@@ -100,10 +101,58 @@ const warn_registry_js_1 = require("./warn-registry.js");
|
|
|
100
101
|
const whereMod = __importStar(require("./where.js"));
|
|
101
102
|
const writesMod = __importStar(require("./writes.js"));
|
|
102
103
|
/**
|
|
103
|
-
*
|
|
104
|
-
*
|
|
104
|
+
* Turn ONE caller-supplied projection field name into a column, or throw.
|
|
105
|
+
*
|
|
106
|
+
* The whole point of this function is that it has no third outcome. The
|
|
107
|
+
* relation-side projection used to filter unresolvable names out
|
|
108
|
+
* (`.filter((col) => allColumns.includes(col))`) instead of rejecting them, and
|
|
109
|
+
* a filter that discards is exactly how a name typed by a human becomes SQL
|
|
110
|
+
* that no longer reflects what was asked for.
|
|
105
111
|
*/
|
|
106
|
-
function
|
|
112
|
+
function projectionColumn(table, meta, field, clause) {
|
|
113
|
+
const column = (0, utils_js_1.resolveColumnName)(meta, field);
|
|
114
|
+
if (column)
|
|
115
|
+
return column;
|
|
116
|
+
// A relation named in a projection is a habit, not a typo, so it gets its own
|
|
117
|
+
// message pointing at `with`. Checked BEFORE the generic throw because the
|
|
118
|
+
// generic one degrades into "Did you mean <exactly what you typed>?".
|
|
119
|
+
if ((0, utils_js_1.ownLookup)(meta.relations, field))
|
|
120
|
+
throw new errors_js_1.ValidationError((0, utils_js_1.relationInProjectionMessage)(table, field, clause));
|
|
121
|
+
throw new errors_js_1.ValidationError((0, utils_js_1.unknownFieldMessage)(table, field, meta));
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Resolve `select` / `omit` into a list of snake_case column names, for the
|
|
125
|
+
* query's own table and for a relation target alike. `null` means "no
|
|
126
|
+
* projection", i.e. all columns, which keeps the `*` fast path.
|
|
127
|
+
*
|
|
128
|
+
* ## Why this is one function
|
|
129
|
+
*
|
|
130
|
+
* It used to be two, `resolveColumns` for the top level and
|
|
131
|
+
* `resolveTargetColumns` for a relation target, doing the same job against
|
|
132
|
+
* different metadata. They drifted, and the drift was invisible from either
|
|
133
|
+
* side: the top level resolved every name through a throwing lookup, while the
|
|
134
|
+
* relation side filtered unresolvable names out and emitted SQL for whatever
|
|
135
|
+
* survived. So the SAME key in the SAME query threw at the top and was silently
|
|
136
|
+
* ignored one level down, where `select: { titel: true }` returned `{}` rows
|
|
137
|
+
* and `omit: { titel: true }` returned the column it was asked to hide.
|
|
138
|
+
*
|
|
139
|
+
* It was worse than an inconsistency between depths. The batched loader runs
|
|
140
|
+
* each relation as a real query against the target table, so it went through
|
|
141
|
+
* the THROWING path, while the join plan went through the silent one. The two
|
|
142
|
+
* strategies therefore disagreed about whether the query was even valid, and
|
|
143
|
+
* under `relationLoadStrategy: 'auto'` which one runs is decided by a cost
|
|
144
|
+
* heuristic reading index coverage and table size. The same code threw on one
|
|
145
|
+
* table and quietly returned the wrong shape on another.
|
|
146
|
+
*
|
|
147
|
+
* Merging them is the fix that outlives this bug. Two functions that must agree
|
|
148
|
+
* are kept in step by whoever remembers; one function cannot disagree with
|
|
149
|
+
* itself. This mirrors `walkWhere` in where-compile.ts, which exists for the
|
|
150
|
+
* same reason after the top-level and relation-scoped WHERE walkers drifted
|
|
151
|
+
* twice, and it is why a new projection site is safe by default: PII exclusion,
|
|
152
|
+
* the `*` fast path and name resolution all live here, so reimplementing the
|
|
153
|
+
* name handling would mean reimplementing those too.
|
|
154
|
+
*/
|
|
155
|
+
function resolveProjection(qi, table, meta, select, omit, includePii) {
|
|
107
156
|
if (select) {
|
|
108
157
|
// An array here means a caller wrote `select: ['id', 'name']` (Drizzle/SQL
|
|
109
158
|
// style) instead of the object shape. Object.entries() would iterate the
|
|
@@ -117,12 +166,12 @@ function resolveColumns(qi, select, omit, includePii) {
|
|
|
117
166
|
// PII column IS the opt-in: it comes back regardless of `includePii`.
|
|
118
167
|
return Object.entries(select)
|
|
119
168
|
.filter(([, v]) => v)
|
|
120
|
-
.map(([k]) =>
|
|
169
|
+
.map(([k]) => projectionColumn(table, meta, k, 'select'));
|
|
121
170
|
}
|
|
122
171
|
// Default / omit-only projection: PII-tagged columns are excluded unless the
|
|
123
172
|
// caller opted in with `includePii: UNSAFE`. An empty set (untagged schema) keeps the
|
|
124
173
|
// `null`/`*` fast path so the emitted SQL is byte-identical to before.
|
|
125
|
-
const piiCols = includePii ? undefined : writesMod.piiColumns(qi,
|
|
174
|
+
const piiCols = includePii ? undefined : writesMod.piiColumns(qi, meta);
|
|
126
175
|
const hasPii = piiCols !== undefined && piiCols.size > 0;
|
|
127
176
|
if (omit) {
|
|
128
177
|
if (Array.isArray(omit)) {
|
|
@@ -131,14 +180,21 @@ function resolveColumns(qi, select, omit, includePii) {
|
|
|
131
180
|
// Include all columns except those where value is true (and PII columns).
|
|
132
181
|
const omitCols = new Set(Object.entries(omit)
|
|
133
182
|
.filter(([, v]) => v)
|
|
134
|
-
.map(([k]) =>
|
|
135
|
-
return
|
|
183
|
+
.map(([k]) => projectionColumn(table, meta, k, 'omit')));
|
|
184
|
+
return meta.allColumns.filter((col) => !omitCols.has(col) && !(hasPii && piiCols.has(col)));
|
|
136
185
|
}
|
|
137
186
|
if (hasPii) {
|
|
138
|
-
return
|
|
187
|
+
return meta.allColumns.filter((col) => !piiCols.has(col));
|
|
139
188
|
}
|
|
140
189
|
return null;
|
|
141
190
|
}
|
|
191
|
+
/**
|
|
192
|
+
* The query's own table. Thin wrapper over {@link resolveProjection} kept for
|
|
193
|
+
* the existing call sites in builder.ts.
|
|
194
|
+
*/
|
|
195
|
+
function resolveColumns(qi, select, omit, includePii) {
|
|
196
|
+
return resolveProjection(qi, qi.table, qi.tableMeta, select, omit, includePii);
|
|
197
|
+
}
|
|
142
198
|
/**
|
|
143
199
|
* Produce a fingerprint for a `with` clause tree. Recursion mirrors
|
|
144
200
|
* buildSelectWithRelations / buildRelationSubquery.
|
|
@@ -1210,30 +1266,17 @@ function parseNestedRow(qi, row, table, fromJson = false) {
|
|
|
1210
1266
|
* Resolve the emitted column list for a relation, honoring `select` / `omit`.
|
|
1211
1267
|
* Shared by {@link buildRelationSubquery} (json order) and
|
|
1212
1268
|
* {@link buildRelationShape} (decode key order) so they can never diverge.
|
|
1269
|
+
*
|
|
1270
|
+
* A relation always projects SOMETHING, so the `null` that
|
|
1271
|
+
* {@link resolveProjection} uses for the top level's `SELECT *` fast path
|
|
1272
|
+
* becomes the target's full column list here. That is the only difference
|
|
1273
|
+
* between the two, and it is why this is a four-line wrapper rather than a
|
|
1274
|
+
* second implementation.
|
|
1213
1275
|
*/
|
|
1214
|
-
function resolveTargetColumns(qi, spec, targetMeta, includePii) {
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
const selectedFields = Object.entries(spec.select)
|
|
1219
|
-
.filter(([, v]) => v)
|
|
1220
|
-
.map(([k]) => (0, utils_js_1.ownLookup)(targetMeta.columnMap, k) ?? (0, schema_js_1.camelToSnake)(k));
|
|
1221
|
-
return selectedFields.filter((col) => targetMeta.allColumns.includes(col));
|
|
1222
|
-
}
|
|
1223
|
-
// Default / omit-only relation projection: PII columns are excluded unless
|
|
1224
|
-
// the query opted in via `includePii`.
|
|
1225
|
-
const piiCols = includePii ? undefined : writesMod.piiColumns(qi, targetMeta);
|
|
1226
|
-
const hasPii = piiCols !== undefined && piiCols.size > 0;
|
|
1227
|
-
if (spec !== true && spec.omit) {
|
|
1228
|
-
const omittedFields = new Set(Object.entries(spec.omit)
|
|
1229
|
-
.filter(([, v]) => v)
|
|
1230
|
-
.map(([k]) => (0, utils_js_1.ownLookup)(targetMeta.columnMap, k) ?? (0, schema_js_1.camelToSnake)(k)));
|
|
1231
|
-
return targetMeta.allColumns.filter((col) => !omittedFields.has(col) && !(hasPii && piiCols.has(col)));
|
|
1232
|
-
}
|
|
1233
|
-
if (hasPii) {
|
|
1234
|
-
return targetMeta.allColumns.filter((col) => !piiCols.has(col));
|
|
1235
|
-
}
|
|
1236
|
-
return targetMeta.allColumns;
|
|
1276
|
+
function resolveTargetColumns(qi, spec, targetMeta, includePii, targetTable = targetMeta.name) {
|
|
1277
|
+
const select = spec === true ? undefined : spec.select;
|
|
1278
|
+
const omit = spec === true ? undefined : spec.omit;
|
|
1279
|
+
return resolveProjection(qi, targetTable, targetMeta, select, omit, includePii) ?? targetMeta.allColumns;
|
|
1237
1280
|
}
|
|
1238
1281
|
/**
|
|
1239
1282
|
* Render a single relation row's JSON: a keyed object (`'object'`) or a
|
|
@@ -1384,7 +1427,7 @@ function buildRelationShape(qi, relDef, spec, parentMeta, includePii) {
|
|
|
1384
1427
|
const targetMeta = qi.schema.tables[relDef.to];
|
|
1385
1428
|
if (!targetMeta)
|
|
1386
1429
|
return { keys: [], nested: {}, cardinality: 'many' };
|
|
1387
|
-
const targetColumns = resolveTargetColumns(qi, spec, targetMeta, includePii);
|
|
1430
|
+
const targetColumns = resolveTargetColumns(qi, spec, targetMeta, includePii, relDef.to);
|
|
1388
1431
|
const keys = targetColumns.map((col) => targetMeta.reverseColumnMap[col] ?? (0, schema_js_1.snakeToCamel)(col));
|
|
1389
1432
|
const nested = {};
|
|
1390
1433
|
if (spec !== true && spec.with) {
|
|
@@ -1642,7 +1685,7 @@ function planFlattenNode(qi, counter, relName, relDef, spec, depth, path, includ
|
|
|
1642
1685
|
}
|
|
1643
1686
|
}
|
|
1644
1687
|
const alias = `${FLATTEN_ALIAS_PREFIX}${counter.n++}`;
|
|
1645
|
-
const cols = resolveTargetColumns(qi, spec, targetMeta, includePii);
|
|
1688
|
+
const cols = resolveTargetColumns(qi, spec, targetMeta, includePii, relDef.to);
|
|
1646
1689
|
const discAlias = `${alias}__${FLATTEN_DISCRIMINATOR}`;
|
|
1647
1690
|
const node = {
|
|
1648
1691
|
relName,
|
|
@@ -2212,7 +2255,7 @@ function buildRelationSubquery(qi, relDef, spec, params, parentRef, aliasCounter
|
|
|
2212
2255
|
// `includePii` opt-in). Shared with the positional-shape builder so the
|
|
2213
2256
|
// emitted json_build_array column order and the decode-side key order can
|
|
2214
2257
|
// never drift apart.
|
|
2215
|
-
const targetColumns = resolveTargetColumns(qi, spec, targetMeta, includePii);
|
|
2258
|
+
const targetColumns = resolveTargetColumns(qi, spec, targetMeta, includePii, relDef.to);
|
|
2216
2259
|
// Engine override seam (additive): a dialect whose JSON-aggregation shape does
|
|
2217
2260
|
// not map onto buildJsonObject/buildJsonArrayAgg (SQL Server FOR JSON PATH) owns
|
|
2218
2261
|
// the WHOLE subquery. Absent for PG/MySQL/SQLite → the native path below runs
|
|
@@ -451,3 +451,19 @@ export declare function unknownFieldMessage(table: string, field: string, meta:
|
|
|
451
451
|
columnMap: Record<string, string>;
|
|
452
452
|
relations?: Record<string, unknown>;
|
|
453
453
|
}): string;
|
|
454
|
+
/**
|
|
455
|
+
* The error text for a RELATION named inside `select` / `omit`.
|
|
456
|
+
*
|
|
457
|
+
* Separate from {@link unknownFieldMessage} because the generic text degrades
|
|
458
|
+
* into nonsense here: `closestName` matches an exactly-spelled relation name at
|
|
459
|
+
* distance zero, so the message would read `Unknown field "comments". Did you
|
|
460
|
+
* mean "comments" (a relation)?`, which answers a question nobody asked and
|
|
461
|
+
* hides the actual fix.
|
|
462
|
+
*
|
|
463
|
+
* It is worth its own message for a second reason: this is not really a typo,
|
|
464
|
+
* it is a habit. Prisma nests a relation inside `select`, so writing
|
|
465
|
+
* `select: { comments: true }` is the natural first guess, and in Turbine a
|
|
466
|
+
* relation is loaded by `with`, which sits BESIDE `select` rather than inside
|
|
467
|
+
* it. Naming the fix costs one sentence and saves a search.
|
|
468
|
+
*/
|
|
469
|
+
export declare function relationInProjectionMessage(table: string, field: string, clause: 'select' | 'omit'): string;
|
package/dist/cjs/query/utils.js
CHANGED
|
@@ -36,6 +36,7 @@ exports.coerceJsonWireValue = coerceJsonWireValue;
|
|
|
36
36
|
exports.closestName = closestName;
|
|
37
37
|
exports.suggestKey = suggestKey;
|
|
38
38
|
exports.unknownFieldMessage = unknownFieldMessage;
|
|
39
|
+
exports.relationInProjectionMessage = relationInProjectionMessage;
|
|
39
40
|
const pg_1 = __importDefault(require("pg"));
|
|
40
41
|
const schema_js_1 = require("../schema.js");
|
|
41
42
|
const warn_registry_js_1 = require("./warn-registry.js");
|
|
@@ -886,3 +887,27 @@ function unknownFieldMessage(table, field, meta) {
|
|
|
886
887
|
` Known columns: ${columns.join(', ') || '(none)'}.` +
|
|
887
888
|
(relations.length ? ` Known relations (valid in \`where\` and \`with\`): ${relations.join(', ')}.` : ''));
|
|
888
889
|
}
|
|
890
|
+
/**
|
|
891
|
+
* The error text for a RELATION named inside `select` / `omit`.
|
|
892
|
+
*
|
|
893
|
+
* Separate from {@link unknownFieldMessage} because the generic text degrades
|
|
894
|
+
* into nonsense here: `closestName` matches an exactly-spelled relation name at
|
|
895
|
+
* distance zero, so the message would read `Unknown field "comments". Did you
|
|
896
|
+
* mean "comments" (a relation)?`, which answers a question nobody asked and
|
|
897
|
+
* hides the actual fix.
|
|
898
|
+
*
|
|
899
|
+
* It is worth its own message for a second reason: this is not really a typo,
|
|
900
|
+
* it is a habit. Prisma nests a relation inside `select`, so writing
|
|
901
|
+
* `select: { comments: true }` is the natural first guess, and in Turbine a
|
|
902
|
+
* relation is loaded by `with`, which sits BESIDE `select` rather than inside
|
|
903
|
+
* it. Naming the fix costs one sentence and saves a search.
|
|
904
|
+
*/
|
|
905
|
+
function relationInProjectionMessage(table, field, clause) {
|
|
906
|
+
const head = `[turbine] "${field}" is a relation on table "${table}", not a column, so it cannot be named in \`${clause}\`.`;
|
|
907
|
+
return clause === 'select'
|
|
908
|
+
? `${head} Load it with \`with: { ${field}: true }\`, which is a sibling of \`select\`, not a member of it.` +
|
|
909
|
+
" To narrow the relation's own columns, put a `select` inside that relation's options:" +
|
|
910
|
+
` \`with: { ${field}: { select: { … } } }\`.`
|
|
911
|
+
: `${head} A relation is only present when you ask for it in \`with\`, so leave it out of \`with\` to leave it` +
|
|
912
|
+
' out of the result.';
|
|
913
|
+
}
|
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.d.ts
CHANGED
|
@@ -307,6 +307,15 @@ export declare class PowqlInterface<T extends object = Record<string, unknown>>
|
|
|
307
307
|
* `includePii` is true; an explicit `select` naming a PII column IS the opt-in
|
|
308
308
|
* and returns it regardless. Untagged tables project exactly as before.
|
|
309
309
|
*/
|
|
310
|
+
/**
|
|
311
|
+
* Resolve one projection field name, or throw. PowQL already refused an
|
|
312
|
+
* unresolvable name here (via {@link column}), unlike the SQL engines' join
|
|
313
|
+
* path, which filtered it out silently until 0.64. What this adds is the
|
|
314
|
+
* RELATION case: naming a relation inside `select` is a Prisma habit rather
|
|
315
|
+
* than a typo, and the generic "unknown column" text sends the reader looking
|
|
316
|
+
* for a misspelling that is not there. Same message as the SQL engines.
|
|
317
|
+
*/
|
|
318
|
+
private projectionColumn;
|
|
310
319
|
private projectedColumns;
|
|
311
320
|
/**
|
|
312
321
|
* The snake_case names of this table's PII-tagged columns. Empty for a table
|
package/dist/powql.js
CHANGED
|
@@ -45,7 +45,7 @@ import { isJsonFilter, isRelationPickOrderBy, orderByEntries } from './query/fil
|
|
|
45
45
|
// are unlocked ONLY by the UNSAFE symbol, on this engine exactly as on the SQL
|
|
46
46
|
// engines, so a spread request body cannot turn either on here either.
|
|
47
47
|
import { assertDirectionToken, resolveUnsafeFlag, UNSAFE } from './query/types.js';
|
|
48
|
-
import { escapeLike } from './query/utils.js';
|
|
48
|
+
import { escapeLike, ownLookup, relationInProjectionMessage } from './query/utils.js';
|
|
49
49
|
import { assertJsonFilterKeys, jsonStringEntries } from './query/where.js';
|
|
50
50
|
import { normalizeKeyColumns, snakeToCamel, } from './schema.js';
|
|
51
51
|
/**
|
|
@@ -872,31 +872,56 @@ export class PowqlInterface {
|
|
|
872
872
|
* `includePii` is true; an explicit `select` naming a PII column IS the opt-in
|
|
873
873
|
* and returns it regardless. Untagged tables project exactly as before.
|
|
874
874
|
*/
|
|
875
|
+
/**
|
|
876
|
+
* Resolve one projection field name, or throw. PowQL already refused an
|
|
877
|
+
* unresolvable name here (via {@link column}), unlike the SQL engines' join
|
|
878
|
+
* path, which filtered it out silently until 0.64. What this adds is the
|
|
879
|
+
* RELATION case: naming a relation inside `select` is a Prisma habit rather
|
|
880
|
+
* than a typo, and the generic "unknown column" text sends the reader looking
|
|
881
|
+
* for a misspelling that is not there. Same message as the SQL engines.
|
|
882
|
+
*/
|
|
883
|
+
projectionColumn(field, clause) {
|
|
884
|
+
if (ownLookup(this.meta.relations, field)) {
|
|
885
|
+
throw new ValidationError(relationInProjectionMessage(this.table, field, clause));
|
|
886
|
+
}
|
|
887
|
+
return this.column(field).name;
|
|
888
|
+
}
|
|
875
889
|
projectedColumns(select, omit, includePii) {
|
|
890
|
+
const pk = new Set(this.meta.primaryKey);
|
|
876
891
|
let cols = this.meta.columns.map((c) => c.name);
|
|
877
892
|
const hasSelect = select && Object.keys(select).length;
|
|
878
893
|
if (hasSelect) {
|
|
879
894
|
const picked = new Set(Object.entries(select)
|
|
880
895
|
.filter(([, v]) => v)
|
|
881
|
-
.map(([k]) => this.
|
|
896
|
+
.map(([k]) => this.projectionColumn(k, 'select')));
|
|
882
897
|
// Always keep the PK so reselect / relation stitching has a key to work with.
|
|
883
|
-
for (const
|
|
884
|
-
picked.add(
|
|
898
|
+
for (const key of pk)
|
|
899
|
+
picked.add(key);
|
|
885
900
|
cols = cols.filter((c) => picked.has(c));
|
|
886
901
|
}
|
|
887
902
|
else if (!includePii) {
|
|
888
903
|
// Default / omit-only projection: drop PII columns (kept above only when a
|
|
889
|
-
// caller names them in `select`)
|
|
890
|
-
// tagged
|
|
904
|
+
// caller names them in `select`), EXCEPT a PK column. This used to drop a
|
|
905
|
+
// tagged PK, on the reasoning that keys should not be tagged in the first
|
|
906
|
+
// place. That is good advice and a bad guarantee: the row it returns
|
|
907
|
+
// cannot address itself, so writing it back builds a partial predicate
|
|
908
|
+
// and mutates every row sharing the rest of the key. Same rule and same
|
|
909
|
+
// reason as `piiColumns` on the SQL engines.
|
|
891
910
|
const pii = this.piiColumnNames();
|
|
892
911
|
if (pii.size)
|
|
893
|
-
cols = cols.filter((c) => !pii.has(c));
|
|
912
|
+
cols = cols.filter((c) => !pii.has(c) || pk.has(c));
|
|
894
913
|
}
|
|
895
914
|
if (omit && Object.keys(omit).length) {
|
|
896
915
|
const dropped = new Set(Object.entries(omit)
|
|
897
916
|
.filter(([, v]) => v)
|
|
898
|
-
.map(([k]) => this.
|
|
899
|
-
|
|
917
|
+
.map(([k]) => this.projectionColumn(k, 'omit')));
|
|
918
|
+
// The PK survives `omit` too. This filter ran unconditionally and after
|
|
919
|
+
// the `select` branch's force-add, so `omit: { id: true }` undid the very
|
|
920
|
+
// guarantee that force-add exists to provide: the m2m loader keys its
|
|
921
|
+
// target map on the PK (`targetByPk`), so every target collapsed onto the
|
|
922
|
+
// single bucket "undefined", no parent matched, and the relation came
|
|
923
|
+
// back `[]` for every row with no error.
|
|
924
|
+
cols = cols.filter((c) => !dropped.has(c) || pk.has(c));
|
|
900
925
|
}
|
|
901
926
|
return cols;
|
|
902
927
|
}
|
|
@@ -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();
|
|
@@ -34,8 +34,41 @@ export interface RelationShape {
|
|
|
34
34
|
cardinality: 'many' | 'one';
|
|
35
35
|
}
|
|
36
36
|
/**
|
|
37
|
-
* Resolve select/omit
|
|
38
|
-
*
|
|
37
|
+
* Resolve `select` / `omit` into a list of snake_case column names, for the
|
|
38
|
+
* query's own table and for a relation target alike. `null` means "no
|
|
39
|
+
* projection", i.e. all columns, which keeps the `*` fast path.
|
|
40
|
+
*
|
|
41
|
+
* ## Why this is one function
|
|
42
|
+
*
|
|
43
|
+
* It used to be two, `resolveColumns` for the top level and
|
|
44
|
+
* `resolveTargetColumns` for a relation target, doing the same job against
|
|
45
|
+
* different metadata. They drifted, and the drift was invisible from either
|
|
46
|
+
* side: the top level resolved every name through a throwing lookup, while the
|
|
47
|
+
* relation side filtered unresolvable names out and emitted SQL for whatever
|
|
48
|
+
* survived. So the SAME key in the SAME query threw at the top and was silently
|
|
49
|
+
* ignored one level down, where `select: { titel: true }` returned `{}` rows
|
|
50
|
+
* and `omit: { titel: true }` returned the column it was asked to hide.
|
|
51
|
+
*
|
|
52
|
+
* It was worse than an inconsistency between depths. The batched loader runs
|
|
53
|
+
* each relation as a real query against the target table, so it went through
|
|
54
|
+
* the THROWING path, while the join plan went through the silent one. The two
|
|
55
|
+
* strategies therefore disagreed about whether the query was even valid, and
|
|
56
|
+
* under `relationLoadStrategy: 'auto'` which one runs is decided by a cost
|
|
57
|
+
* heuristic reading index coverage and table size. The same code threw on one
|
|
58
|
+
* table and quietly returned the wrong shape on another.
|
|
59
|
+
*
|
|
60
|
+
* Merging them is the fix that outlives this bug. Two functions that must agree
|
|
61
|
+
* are kept in step by whoever remembers; one function cannot disagree with
|
|
62
|
+
* itself. This mirrors `walkWhere` in where-compile.ts, which exists for the
|
|
63
|
+
* same reason after the top-level and relation-scoped WHERE walkers drifted
|
|
64
|
+
* twice, and it is why a new projection site is safe by default: PII exclusion,
|
|
65
|
+
* the `*` fast path and name resolution all live here, so reimplementing the
|
|
66
|
+
* name handling would mean reimplementing those too.
|
|
67
|
+
*/
|
|
68
|
+
export declare function resolveProjection(qi: BuilderCtx, table: string, meta: TableMetadata, select?: Record<string, boolean>, omit?: Record<string, boolean>, includePii?: boolean): string[] | null;
|
|
69
|
+
/**
|
|
70
|
+
* The query's own table. Thin wrapper over {@link resolveProjection} kept for
|
|
71
|
+
* the existing call sites in builder.ts.
|
|
39
72
|
*/
|
|
40
73
|
export declare function resolveColumns(qi: BuilderCtx, select?: Record<string, boolean>, omit?: Record<string, boolean>, includePii?: boolean): string[] | null;
|
|
41
74
|
/**
|
|
@@ -254,8 +287,14 @@ export declare function parseNestedRow(qi: BuilderCtx, row: Record<string, unkno
|
|
|
254
287
|
* Resolve the emitted column list for a relation, honoring `select` / `omit`.
|
|
255
288
|
* Shared by {@link buildRelationSubquery} (json order) and
|
|
256
289
|
* {@link buildRelationShape} (decode key order) so they can never diverge.
|
|
290
|
+
*
|
|
291
|
+
* A relation always projects SOMETHING, so the `null` that
|
|
292
|
+
* {@link resolveProjection} uses for the top level's `SELECT *` fast path
|
|
293
|
+
* becomes the target's full column list here. That is the only difference
|
|
294
|
+
* between the two, and it is why this is a four-line wrapper rather than a
|
|
295
|
+
* second implementation.
|
|
257
296
|
*/
|
|
258
|
-
export declare function resolveTargetColumns(qi: BuilderCtx, spec: true | WithOptions, targetMeta: TableMetadata, includePii?: boolean): string[];
|
|
297
|
+
export declare function resolveTargetColumns(qi: BuilderCtx, spec: true | WithOptions, targetMeta: TableMetadata, includePii?: boolean, targetTable?: string): string[];
|
|
259
298
|
/**
|
|
260
299
|
* Render a single relation row's JSON: a keyed object (`'object'`) or a
|
|
261
300
|
* positional array (`'positional'`). The array drops the keys but keeps the
|
package/dist/query/relations.js
CHANGED
|
@@ -18,15 +18,63 @@ import { camelToSnake, normalizeKeyColumns, snakeToCamel } from '../schema.js';
|
|
|
18
18
|
import { resolveCountRelations } from './batched-loader.js';
|
|
19
19
|
import { isJsonPathOrderBy, isOrderBySpec, isRelationPickOrderBy, isVectorOrderBy, normalizeOrderBy, orderByEntries, sortedEntries, } from './filters.js';
|
|
20
20
|
import { assertDirectionToken, assertOrderDirection } from './types.js';
|
|
21
|
-
import { ownLookup } from './utils.js';
|
|
21
|
+
import { ownLookup, relationInProjectionMessage, resolveColumnName, unknownFieldMessage } from './utils.js';
|
|
22
22
|
import { hasWarnedOnce, shouldWarnOnce, WARN_NS } from './warn-registry.js';
|
|
23
23
|
import * as whereMod from './where.js';
|
|
24
24
|
import * as writesMod from './writes.js';
|
|
25
25
|
/**
|
|
26
|
-
*
|
|
27
|
-
*
|
|
26
|
+
* Turn ONE caller-supplied projection field name into a column, or throw.
|
|
27
|
+
*
|
|
28
|
+
* The whole point of this function is that it has no third outcome. The
|
|
29
|
+
* relation-side projection used to filter unresolvable names out
|
|
30
|
+
* (`.filter((col) => allColumns.includes(col))`) instead of rejecting them, and
|
|
31
|
+
* a filter that discards is exactly how a name typed by a human becomes SQL
|
|
32
|
+
* that no longer reflects what was asked for.
|
|
28
33
|
*/
|
|
29
|
-
|
|
34
|
+
function projectionColumn(table, meta, field, clause) {
|
|
35
|
+
const column = resolveColumnName(meta, field);
|
|
36
|
+
if (column)
|
|
37
|
+
return column;
|
|
38
|
+
// A relation named in a projection is a habit, not a typo, so it gets its own
|
|
39
|
+
// message pointing at `with`. Checked BEFORE the generic throw because the
|
|
40
|
+
// generic one degrades into "Did you mean <exactly what you typed>?".
|
|
41
|
+
if (ownLookup(meta.relations, field))
|
|
42
|
+
throw new ValidationError(relationInProjectionMessage(table, field, clause));
|
|
43
|
+
throw new ValidationError(unknownFieldMessage(table, field, meta));
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Resolve `select` / `omit` into a list of snake_case column names, for the
|
|
47
|
+
* query's own table and for a relation target alike. `null` means "no
|
|
48
|
+
* projection", i.e. all columns, which keeps the `*` fast path.
|
|
49
|
+
*
|
|
50
|
+
* ## Why this is one function
|
|
51
|
+
*
|
|
52
|
+
* It used to be two, `resolveColumns` for the top level and
|
|
53
|
+
* `resolveTargetColumns` for a relation target, doing the same job against
|
|
54
|
+
* different metadata. They drifted, and the drift was invisible from either
|
|
55
|
+
* side: the top level resolved every name through a throwing lookup, while the
|
|
56
|
+
* relation side filtered unresolvable names out and emitted SQL for whatever
|
|
57
|
+
* survived. So the SAME key in the SAME query threw at the top and was silently
|
|
58
|
+
* ignored one level down, where `select: { titel: true }` returned `{}` rows
|
|
59
|
+
* and `omit: { titel: true }` returned the column it was asked to hide.
|
|
60
|
+
*
|
|
61
|
+
* It was worse than an inconsistency between depths. The batched loader runs
|
|
62
|
+
* each relation as a real query against the target table, so it went through
|
|
63
|
+
* the THROWING path, while the join plan went through the silent one. The two
|
|
64
|
+
* strategies therefore disagreed about whether the query was even valid, and
|
|
65
|
+
* under `relationLoadStrategy: 'auto'` which one runs is decided by a cost
|
|
66
|
+
* heuristic reading index coverage and table size. The same code threw on one
|
|
67
|
+
* table and quietly returned the wrong shape on another.
|
|
68
|
+
*
|
|
69
|
+
* Merging them is the fix that outlives this bug. Two functions that must agree
|
|
70
|
+
* are kept in step by whoever remembers; one function cannot disagree with
|
|
71
|
+
* itself. This mirrors `walkWhere` in where-compile.ts, which exists for the
|
|
72
|
+
* same reason after the top-level and relation-scoped WHERE walkers drifted
|
|
73
|
+
* twice, and it is why a new projection site is safe by default: PII exclusion,
|
|
74
|
+
* the `*` fast path and name resolution all live here, so reimplementing the
|
|
75
|
+
* name handling would mean reimplementing those too.
|
|
76
|
+
*/
|
|
77
|
+
export function resolveProjection(qi, table, meta, select, omit, includePii) {
|
|
30
78
|
if (select) {
|
|
31
79
|
// An array here means a caller wrote `select: ['id', 'name']` (Drizzle/SQL
|
|
32
80
|
// style) instead of the object shape. Object.entries() would iterate the
|
|
@@ -40,12 +88,12 @@ export function resolveColumns(qi, select, omit, includePii) {
|
|
|
40
88
|
// PII column IS the opt-in: it comes back regardless of `includePii`.
|
|
41
89
|
return Object.entries(select)
|
|
42
90
|
.filter(([, v]) => v)
|
|
43
|
-
.map(([k]) =>
|
|
91
|
+
.map(([k]) => projectionColumn(table, meta, k, 'select'));
|
|
44
92
|
}
|
|
45
93
|
// Default / omit-only projection: PII-tagged columns are excluded unless the
|
|
46
94
|
// caller opted in with `includePii: UNSAFE`. An empty set (untagged schema) keeps the
|
|
47
95
|
// `null`/`*` fast path so the emitted SQL is byte-identical to before.
|
|
48
|
-
const piiCols = includePii ? undefined : writesMod.piiColumns(qi,
|
|
96
|
+
const piiCols = includePii ? undefined : writesMod.piiColumns(qi, meta);
|
|
49
97
|
const hasPii = piiCols !== undefined && piiCols.size > 0;
|
|
50
98
|
if (omit) {
|
|
51
99
|
if (Array.isArray(omit)) {
|
|
@@ -54,14 +102,21 @@ export function resolveColumns(qi, select, omit, includePii) {
|
|
|
54
102
|
// Include all columns except those where value is true (and PII columns).
|
|
55
103
|
const omitCols = new Set(Object.entries(omit)
|
|
56
104
|
.filter(([, v]) => v)
|
|
57
|
-
.map(([k]) =>
|
|
58
|
-
return
|
|
105
|
+
.map(([k]) => projectionColumn(table, meta, k, 'omit')));
|
|
106
|
+
return meta.allColumns.filter((col) => !omitCols.has(col) && !(hasPii && piiCols.has(col)));
|
|
59
107
|
}
|
|
60
108
|
if (hasPii) {
|
|
61
|
-
return
|
|
109
|
+
return meta.allColumns.filter((col) => !piiCols.has(col));
|
|
62
110
|
}
|
|
63
111
|
return null;
|
|
64
112
|
}
|
|
113
|
+
/**
|
|
114
|
+
* The query's own table. Thin wrapper over {@link resolveProjection} kept for
|
|
115
|
+
* the existing call sites in builder.ts.
|
|
116
|
+
*/
|
|
117
|
+
export function resolveColumns(qi, select, omit, includePii) {
|
|
118
|
+
return resolveProjection(qi, qi.table, qi.tableMeta, select, omit, includePii);
|
|
119
|
+
}
|
|
65
120
|
/**
|
|
66
121
|
* Produce a fingerprint for a `with` clause tree. Recursion mirrors
|
|
67
122
|
* buildSelectWithRelations / buildRelationSubquery.
|
|
@@ -1133,30 +1188,17 @@ export function parseNestedRow(qi, row, table, fromJson = false) {
|
|
|
1133
1188
|
* Resolve the emitted column list for a relation, honoring `select` / `omit`.
|
|
1134
1189
|
* Shared by {@link buildRelationSubquery} (json order) and
|
|
1135
1190
|
* {@link buildRelationShape} (decode key order) so they can never diverge.
|
|
1191
|
+
*
|
|
1192
|
+
* A relation always projects SOMETHING, so the `null` that
|
|
1193
|
+
* {@link resolveProjection} uses for the top level's `SELECT *` fast path
|
|
1194
|
+
* becomes the target's full column list here. That is the only difference
|
|
1195
|
+
* between the two, and it is why this is a four-line wrapper rather than a
|
|
1196
|
+
* second implementation.
|
|
1136
1197
|
*/
|
|
1137
|
-
export function resolveTargetColumns(qi, spec, targetMeta, includePii) {
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
const selectedFields = Object.entries(spec.select)
|
|
1142
|
-
.filter(([, v]) => v)
|
|
1143
|
-
.map(([k]) => ownLookup(targetMeta.columnMap, k) ?? camelToSnake(k));
|
|
1144
|
-
return selectedFields.filter((col) => targetMeta.allColumns.includes(col));
|
|
1145
|
-
}
|
|
1146
|
-
// Default / omit-only relation projection: PII columns are excluded unless
|
|
1147
|
-
// the query opted in via `includePii`.
|
|
1148
|
-
const piiCols = includePii ? undefined : writesMod.piiColumns(qi, targetMeta);
|
|
1149
|
-
const hasPii = piiCols !== undefined && piiCols.size > 0;
|
|
1150
|
-
if (spec !== true && spec.omit) {
|
|
1151
|
-
const omittedFields = new Set(Object.entries(spec.omit)
|
|
1152
|
-
.filter(([, v]) => v)
|
|
1153
|
-
.map(([k]) => ownLookup(targetMeta.columnMap, k) ?? camelToSnake(k)));
|
|
1154
|
-
return targetMeta.allColumns.filter((col) => !omittedFields.has(col) && !(hasPii && piiCols.has(col)));
|
|
1155
|
-
}
|
|
1156
|
-
if (hasPii) {
|
|
1157
|
-
return targetMeta.allColumns.filter((col) => !piiCols.has(col));
|
|
1158
|
-
}
|
|
1159
|
-
return targetMeta.allColumns;
|
|
1198
|
+
export function resolveTargetColumns(qi, spec, targetMeta, includePii, targetTable = targetMeta.name) {
|
|
1199
|
+
const select = spec === true ? undefined : spec.select;
|
|
1200
|
+
const omit = spec === true ? undefined : spec.omit;
|
|
1201
|
+
return resolveProjection(qi, targetTable, targetMeta, select, omit, includePii) ?? targetMeta.allColumns;
|
|
1160
1202
|
}
|
|
1161
1203
|
/**
|
|
1162
1204
|
* Render a single relation row's JSON: a keyed object (`'object'`) or a
|
|
@@ -1307,7 +1349,7 @@ export function buildRelationShape(qi, relDef, spec, parentMeta, includePii) {
|
|
|
1307
1349
|
const targetMeta = qi.schema.tables[relDef.to];
|
|
1308
1350
|
if (!targetMeta)
|
|
1309
1351
|
return { keys: [], nested: {}, cardinality: 'many' };
|
|
1310
|
-
const targetColumns = resolveTargetColumns(qi, spec, targetMeta, includePii);
|
|
1352
|
+
const targetColumns = resolveTargetColumns(qi, spec, targetMeta, includePii, relDef.to);
|
|
1311
1353
|
const keys = targetColumns.map((col) => targetMeta.reverseColumnMap[col] ?? snakeToCamel(col));
|
|
1312
1354
|
const nested = {};
|
|
1313
1355
|
if (spec !== true && spec.with) {
|
|
@@ -1565,7 +1607,7 @@ function planFlattenNode(qi, counter, relName, relDef, spec, depth, path, includ
|
|
|
1565
1607
|
}
|
|
1566
1608
|
}
|
|
1567
1609
|
const alias = `${FLATTEN_ALIAS_PREFIX}${counter.n++}`;
|
|
1568
|
-
const cols = resolveTargetColumns(qi, spec, targetMeta, includePii);
|
|
1610
|
+
const cols = resolveTargetColumns(qi, spec, targetMeta, includePii, relDef.to);
|
|
1569
1611
|
const discAlias = `${alias}__${FLATTEN_DISCRIMINATOR}`;
|
|
1570
1612
|
const node = {
|
|
1571
1613
|
relName,
|
|
@@ -2135,7 +2177,7 @@ export function buildRelationSubquery(qi, relDef, spec, params, parentRef, alias
|
|
|
2135
2177
|
// `includePii` opt-in). Shared with the positional-shape builder so the
|
|
2136
2178
|
// emitted json_build_array column order and the decode-side key order can
|
|
2137
2179
|
// never drift apart.
|
|
2138
|
-
const targetColumns = resolveTargetColumns(qi, spec, targetMeta, includePii);
|
|
2180
|
+
const targetColumns = resolveTargetColumns(qi, spec, targetMeta, includePii, relDef.to);
|
|
2139
2181
|
// Engine override seam (additive): a dialect whose JSON-aggregation shape does
|
|
2140
2182
|
// not map onto buildJsonObject/buildJsonArrayAgg (SQL Server FOR JSON PATH) owns
|
|
2141
2183
|
// the WHOLE subquery. Absent for PG/MySQL/SQLite → the native path below runs
|
package/dist/query/utils.d.ts
CHANGED
|
@@ -451,3 +451,19 @@ export declare function unknownFieldMessage(table: string, field: string, meta:
|
|
|
451
451
|
columnMap: Record<string, string>;
|
|
452
452
|
relations?: Record<string, unknown>;
|
|
453
453
|
}): string;
|
|
454
|
+
/**
|
|
455
|
+
* The error text for a RELATION named inside `select` / `omit`.
|
|
456
|
+
*
|
|
457
|
+
* Separate from {@link unknownFieldMessage} because the generic text degrades
|
|
458
|
+
* into nonsense here: `closestName` matches an exactly-spelled relation name at
|
|
459
|
+
* distance zero, so the message would read `Unknown field "comments". Did you
|
|
460
|
+
* mean "comments" (a relation)?`, which answers a question nobody asked and
|
|
461
|
+
* hides the actual fix.
|
|
462
|
+
*
|
|
463
|
+
* It is worth its own message for a second reason: this is not really a typo,
|
|
464
|
+
* it is a habit. Prisma nests a relation inside `select`, so writing
|
|
465
|
+
* `select: { comments: true }` is the natural first guess, and in Turbine a
|
|
466
|
+
* relation is loaded by `with`, which sits BESIDE `select` rather than inside
|
|
467
|
+
* it. Naming the fix costs one sentence and saves a search.
|
|
468
|
+
*/
|
|
469
|
+
export declare function relationInProjectionMessage(table: string, field: string, clause: 'select' | 'omit'): string;
|
package/dist/query/utils.js
CHANGED
|
@@ -852,3 +852,27 @@ export function unknownFieldMessage(table, field, meta) {
|
|
|
852
852
|
` Known columns: ${columns.join(', ') || '(none)'}.` +
|
|
853
853
|
(relations.length ? ` Known relations (valid in \`where\` and \`with\`): ${relations.join(', ')}.` : ''));
|
|
854
854
|
}
|
|
855
|
+
/**
|
|
856
|
+
* The error text for a RELATION named inside `select` / `omit`.
|
|
857
|
+
*
|
|
858
|
+
* Separate from {@link unknownFieldMessage} because the generic text degrades
|
|
859
|
+
* into nonsense here: `closestName` matches an exactly-spelled relation name at
|
|
860
|
+
* distance zero, so the message would read `Unknown field "comments". Did you
|
|
861
|
+
* mean "comments" (a relation)?`, which answers a question nobody asked and
|
|
862
|
+
* hides the actual fix.
|
|
863
|
+
*
|
|
864
|
+
* It is worth its own message for a second reason: this is not really a typo,
|
|
865
|
+
* it is a habit. Prisma nests a relation inside `select`, so writing
|
|
866
|
+
* `select: { comments: true }` is the natural first guess, and in Turbine a
|
|
867
|
+
* relation is loaded by `with`, which sits BESIDE `select` rather than inside
|
|
868
|
+
* it. Naming the fix costs one sentence and saves a search.
|
|
869
|
+
*/
|
|
870
|
+
export function relationInProjectionMessage(table, field, clause) {
|
|
871
|
+
const head = `[turbine] "${field}" is a relation on table "${table}", not a column, so it cannot be named in \`${clause}\`.`;
|
|
872
|
+
return clause === 'select'
|
|
873
|
+
? `${head} Load it with \`with: { ${field}: true }\`, which is a sibling of \`select\`, not a member of it.` +
|
|
874
|
+
" To narrow the relation's own columns, put a `select` inside that relation's options:" +
|
|
875
|
+
` \`with: { ${field}: { select: { … } } }\`.`
|
|
876
|
+
: `${head} A relation is only present when you ask for it in \`with\`, so leave it out of \`with\` to leave it` +
|
|
877
|
+
' out of the result.';
|
|
878
|
+
}
|
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.64.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).",
|