turbine-orm 0.63.0 → 0.64.1

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.
@@ -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,6 +908,20 @@ 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) {
912
926
  const pk = new Set(this.meta.primaryKey);
913
927
  let cols = this.meta.columns.map((c) => c.name);
@@ -915,7 +929,7 @@ class PowqlInterface {
915
929
  if (hasSelect) {
916
930
  const picked = new Set(Object.entries(select)
917
931
  .filter(([, v]) => v)
918
- .map(([k]) => this.column(k).name));
932
+ .map(([k]) => this.projectionColumn(k, 'select')));
919
933
  // Always keep the PK so reselect / relation stitching has a key to work with.
920
934
  for (const key of pk)
921
935
  picked.add(key);
@@ -936,7 +950,7 @@ class PowqlInterface {
936
950
  if (omit && Object.keys(omit).length) {
937
951
  const dropped = new Set(Object.entries(omit)
938
952
  .filter(([, v]) => v)
939
- .map(([k]) => this.column(k).name));
953
+ .map(([k]) => this.projectionColumn(k, 'omit')));
940
954
  // The PK survives `omit` too. This filter ran unconditionally and after
941
955
  // the `select` branch's force-add, so `omit: { id: true }` undid the very
942
956
  // guarantee that force-add exists to provide: the m2m loader keys its
@@ -34,8 +34,41 @@ export interface RelationShape {
34
34
  cardinality: 'many' | 'one';
35
35
  }
36
36
  /**
37
- * Resolve select/omit options into a list of snake_case column names.
38
- * Returns null if neither is provided (meaning all columns).
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
- * Resolve select/omit options into a list of snake_case column names.
104
- * Returns null if neither is provided (meaning all columns).
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 resolveColumns(qi, select, omit, includePii) {
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]) => qi.toColumn(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, qi.tableMeta);
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]) => qi.toColumn(k)));
135
- return qi.tableMeta.allColumns.filter((col) => !omitCols.has(col) && !(hasPii && piiCols.has(col)));
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 qi.tableMeta.allColumns.filter((col) => !piiCols.has(col));
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.
@@ -403,20 +459,17 @@ function orderByEntryFingerprint(qi, d, targetTable) {
403
459
  return String(d);
404
460
  }
405
461
  function buildOrderBy(qi, orderBy, params, lateralSink) {
406
- // Dev-only: validate that orderBy fields exist in the table schema. Relation
407
- // orderBy keys (object values that are neither a vector nor an OrderBySpec)
408
- // are validated in the relation branch below, so skip them here.
409
- if (process.env.NODE_ENV !== 'production') {
410
- for (const [key, value] of (0, filters_js_1.orderByEntries)(orderBy)) {
411
- if (isRelationOrderByValue(qi, value) && (0, utils_js_1.ownLookup)(qi.tableMeta.relations, key))
412
- continue;
413
- const snakeKey = (0, schema_js_1.camelToSnake)(key);
414
- if (!qi.tableMeta.columns.some((c) => c.name === snakeKey) && !Object.hasOwn(qi.tableMeta.columnMap, key)) {
415
- console.warn(`[turbine] Unknown orderBy field "${key}" for table "${qi.tableMeta.name}". ` +
416
- 'This will cause a runtime error.');
417
- }
418
- }
419
- }
462
+ // There used to be a dev-only pre-scan here that printed `Unknown orderBy
463
+ // field "x" for table "y". This will cause a runtime error.` and then let
464
+ // compilation continue into the code below, which throws for the same key
465
+ // with a better message (it names the table, suggests the closest column and
466
+ // lists the valid relations). Every unknown-key shape was measured: plain
467
+ // direction, OrderBySpec, JSON path, both relation-shaped values, and array
468
+ // form. All six warn-and-then-throw; none reaches the end of this function.
469
+ // A warning whose entire content is a prediction of the exception on the next
470
+ // line is noise in dev logs and a second place to keep the key-resolution
471
+ // rules in step, so it is gone. See orderby-unknown-field.test.ts, which pins
472
+ // the refusal itself across that surface.
420
473
  const meta = qi.schema.tables[qi.table];
421
474
  let relOrdCounter = 0;
422
475
  return (0, filters_js_1.orderByEntries)(orderBy)
@@ -1210,30 +1263,17 @@ function parseNestedRow(qi, row, table, fromJson = false) {
1210
1263
  * Resolve the emitted column list for a relation, honoring `select` / `omit`.
1211
1264
  * Shared by {@link buildRelationSubquery} (json order) and
1212
1265
  * {@link buildRelationShape} (decode key order) so they can never diverge.
1266
+ *
1267
+ * A relation always projects SOMETHING, so the `null` that
1268
+ * {@link resolveProjection} uses for the top level's `SELECT *` fast path
1269
+ * becomes the target's full column list here. That is the only difference
1270
+ * between the two, and it is why this is a four-line wrapper rather than a
1271
+ * second implementation.
1213
1272
  */
1214
- function resolveTargetColumns(qi, spec, targetMeta, includePii) {
1215
- if (spec !== true && spec.select) {
1216
- // Explicit `select` names the columns: a PII column named here IS the
1217
- // opt-in and comes back regardless of the query's `includePii`.
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;
1273
+ function resolveTargetColumns(qi, spec, targetMeta, includePii, targetTable = targetMeta.name) {
1274
+ const select = spec === true ? undefined : spec.select;
1275
+ const omit = spec === true ? undefined : spec.omit;
1276
+ return resolveProjection(qi, targetTable, targetMeta, select, omit, includePii) ?? targetMeta.allColumns;
1237
1277
  }
1238
1278
  /**
1239
1279
  * Render a single relation row's JSON: a keyed object (`'object'`) or a
@@ -1384,7 +1424,7 @@ function buildRelationShape(qi, relDef, spec, parentMeta, includePii) {
1384
1424
  const targetMeta = qi.schema.tables[relDef.to];
1385
1425
  if (!targetMeta)
1386
1426
  return { keys: [], nested: {}, cardinality: 'many' };
1387
- const targetColumns = resolveTargetColumns(qi, spec, targetMeta, includePii);
1427
+ const targetColumns = resolveTargetColumns(qi, spec, targetMeta, includePii, relDef.to);
1388
1428
  const keys = targetColumns.map((col) => targetMeta.reverseColumnMap[col] ?? (0, schema_js_1.snakeToCamel)(col));
1389
1429
  const nested = {};
1390
1430
  if (spec !== true && spec.with) {
@@ -1642,7 +1682,7 @@ function planFlattenNode(qi, counter, relName, relDef, spec, depth, path, includ
1642
1682
  }
1643
1683
  }
1644
1684
  const alias = `${FLATTEN_ALIAS_PREFIX}${counter.n++}`;
1645
- const cols = resolveTargetColumns(qi, spec, targetMeta, includePii);
1685
+ const cols = resolveTargetColumns(qi, spec, targetMeta, includePii, relDef.to);
1646
1686
  const discAlias = `${alias}__${FLATTEN_DISCRIMINATOR}`;
1647
1687
  const node = {
1648
1688
  relName,
@@ -2212,7 +2252,7 @@ function buildRelationSubquery(qi, relDef, spec, params, parentRef, aliasCounter
2212
2252
  // `includePii` opt-in). Shared with the positional-shape builder so the
2213
2253
  // emitted json_build_array column order and the decode-side key order can
2214
2254
  // never drift apart.
2215
- const targetColumns = resolveTargetColumns(qi, spec, targetMeta, includePii);
2255
+ const targetColumns = resolveTargetColumns(qi, spec, targetMeta, includePii, relDef.to);
2216
2256
  // Engine override seam (additive): a dialect whose JSON-aggregation shape does
2217
2257
  // not map onto buildJsonObject/buildJsonArrayAgg (SQL Server FOR JSON PATH) owns
2218
2258
  // 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;
@@ -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/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,6 +872,20 @@ 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) {
876
890
  const pk = new Set(this.meta.primaryKey);
877
891
  let cols = this.meta.columns.map((c) => c.name);
@@ -879,7 +893,7 @@ export class PowqlInterface {
879
893
  if (hasSelect) {
880
894
  const picked = new Set(Object.entries(select)
881
895
  .filter(([, v]) => v)
882
- .map(([k]) => this.column(k).name));
896
+ .map(([k]) => this.projectionColumn(k, 'select')));
883
897
  // Always keep the PK so reselect / relation stitching has a key to work with.
884
898
  for (const key of pk)
885
899
  picked.add(key);
@@ -900,7 +914,7 @@ export class PowqlInterface {
900
914
  if (omit && Object.keys(omit).length) {
901
915
  const dropped = new Set(Object.entries(omit)
902
916
  .filter(([, v]) => v)
903
- .map(([k]) => this.column(k).name));
917
+ .map(([k]) => this.projectionColumn(k, 'omit')));
904
918
  // The PK survives `omit` too. This filter ran unconditionally and after
905
919
  // the `select` branch's force-add, so `omit: { id: true }` undid the very
906
920
  // guarantee that force-add exists to provide: the m2m loader keys its
@@ -34,8 +34,41 @@ export interface RelationShape {
34
34
  cardinality: 'many' | 'one';
35
35
  }
36
36
  /**
37
- * Resolve select/omit options into a list of snake_case column names.
38
- * Returns null if neither is provided (meaning all columns).
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
@@ -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
- * Resolve select/omit options into a list of snake_case column names.
27
- * Returns null if neither is provided (meaning all columns).
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
- export function resolveColumns(qi, select, omit, includePii) {
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]) => qi.toColumn(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, qi.tableMeta);
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]) => qi.toColumn(k)));
58
- return qi.tableMeta.allColumns.filter((col) => !omitCols.has(col) && !(hasPii && piiCols.has(col)));
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 qi.tableMeta.allColumns.filter((col) => !piiCols.has(col));
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.
@@ -326,20 +381,17 @@ export function orderByEntryFingerprint(qi, d, targetTable) {
326
381
  return String(d);
327
382
  }
328
383
  export function buildOrderBy(qi, orderBy, params, lateralSink) {
329
- // Dev-only: validate that orderBy fields exist in the table schema. Relation
330
- // orderBy keys (object values that are neither a vector nor an OrderBySpec)
331
- // are validated in the relation branch below, so skip them here.
332
- if (process.env.NODE_ENV !== 'production') {
333
- for (const [key, value] of orderByEntries(orderBy)) {
334
- if (isRelationOrderByValue(qi, value) && ownLookup(qi.tableMeta.relations, key))
335
- continue;
336
- const snakeKey = camelToSnake(key);
337
- if (!qi.tableMeta.columns.some((c) => c.name === snakeKey) && !Object.hasOwn(qi.tableMeta.columnMap, key)) {
338
- console.warn(`[turbine] Unknown orderBy field "${key}" for table "${qi.tableMeta.name}". ` +
339
- 'This will cause a runtime error.');
340
- }
341
- }
342
- }
384
+ // There used to be a dev-only pre-scan here that printed `Unknown orderBy
385
+ // field "x" for table "y". This will cause a runtime error.` and then let
386
+ // compilation continue into the code below, which throws for the same key
387
+ // with a better message (it names the table, suggests the closest column and
388
+ // lists the valid relations). Every unknown-key shape was measured: plain
389
+ // direction, OrderBySpec, JSON path, both relation-shaped values, and array
390
+ // form. All six warn-and-then-throw; none reaches the end of this function.
391
+ // A warning whose entire content is a prediction of the exception on the next
392
+ // line is noise in dev logs and a second place to keep the key-resolution
393
+ // rules in step, so it is gone. See orderby-unknown-field.test.ts, which pins
394
+ // the refusal itself across that surface.
343
395
  const meta = qi.schema.tables[qi.table];
344
396
  let relOrdCounter = 0;
345
397
  return orderByEntries(orderBy)
@@ -1133,30 +1185,17 @@ export function parseNestedRow(qi, row, table, fromJson = false) {
1133
1185
  * Resolve the emitted column list for a relation, honoring `select` / `omit`.
1134
1186
  * Shared by {@link buildRelationSubquery} (json order) and
1135
1187
  * {@link buildRelationShape} (decode key order) so they can never diverge.
1188
+ *
1189
+ * A relation always projects SOMETHING, so the `null` that
1190
+ * {@link resolveProjection} uses for the top level's `SELECT *` fast path
1191
+ * becomes the target's full column list here. That is the only difference
1192
+ * between the two, and it is why this is a four-line wrapper rather than a
1193
+ * second implementation.
1136
1194
  */
1137
- export function resolveTargetColumns(qi, spec, targetMeta, includePii) {
1138
- if (spec !== true && spec.select) {
1139
- // Explicit `select` names the columns: a PII column named here IS the
1140
- // opt-in and comes back regardless of the query's `includePii`.
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;
1195
+ export function resolveTargetColumns(qi, spec, targetMeta, includePii, targetTable = targetMeta.name) {
1196
+ const select = spec === true ? undefined : spec.select;
1197
+ const omit = spec === true ? undefined : spec.omit;
1198
+ return resolveProjection(qi, targetTable, targetMeta, select, omit, includePii) ?? targetMeta.allColumns;
1160
1199
  }
1161
1200
  /**
1162
1201
  * Render a single relation row's JSON: a keyed object (`'object'`) or a
@@ -1307,7 +1346,7 @@ export function buildRelationShape(qi, relDef, spec, parentMeta, includePii) {
1307
1346
  const targetMeta = qi.schema.tables[relDef.to];
1308
1347
  if (!targetMeta)
1309
1348
  return { keys: [], nested: {}, cardinality: 'many' };
1310
- const targetColumns = resolveTargetColumns(qi, spec, targetMeta, includePii);
1349
+ const targetColumns = resolveTargetColumns(qi, spec, targetMeta, includePii, relDef.to);
1311
1350
  const keys = targetColumns.map((col) => targetMeta.reverseColumnMap[col] ?? snakeToCamel(col));
1312
1351
  const nested = {};
1313
1352
  if (spec !== true && spec.with) {
@@ -1565,7 +1604,7 @@ function planFlattenNode(qi, counter, relName, relDef, spec, depth, path, includ
1565
1604
  }
1566
1605
  }
1567
1606
  const alias = `${FLATTEN_ALIAS_PREFIX}${counter.n++}`;
1568
- const cols = resolveTargetColumns(qi, spec, targetMeta, includePii);
1607
+ const cols = resolveTargetColumns(qi, spec, targetMeta, includePii, relDef.to);
1569
1608
  const discAlias = `${alias}__${FLATTEN_DISCRIMINATOR}`;
1570
1609
  const node = {
1571
1610
  relName,
@@ -2135,7 +2174,7 @@ export function buildRelationSubquery(qi, relDef, spec, params, parentRef, alias
2135
2174
  // `includePii` opt-in). Shared with the positional-shape builder so the
2136
2175
  // emitted json_build_array column order and the decode-side key order can
2137
2176
  // never drift apart.
2138
- const targetColumns = resolveTargetColumns(qi, spec, targetMeta, includePii);
2177
+ const targetColumns = resolveTargetColumns(qi, spec, targetMeta, includePii, relDef.to);
2139
2178
  // Engine override seam (additive): a dialect whose JSON-aggregation shape does
2140
2179
  // not map onto buildJsonObject/buildJsonArrayAgg (SQL Server FOR JSON PATH) owns
2141
2180
  // 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;
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "turbine-orm",
3
- "version": "0.63.0",
3
+ "version": "0.64.1",
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).",