turbine-orm 0.52.0 → 0.53.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.
@@ -10,7 +10,7 @@
10
10
  */
11
11
  import { UnsupportedFeatureError, ValidationError } from '../errors.js';
12
12
  import { snakeToCamel } from '../schema.js';
13
- import { isJsonPathOrderBy, isVectorOrderBy, normalizeOrderBy, orderByEntries } from './filters.js';
13
+ import { isJsonPathOrderBy, isUnmatchedPlainObject, isVectorOrderBy, isWhereOperator, normalizeOrderBy, orderByEntries, } from './filters.js';
14
14
  import { ownLookup } from './utils.js';
15
15
  import * as whereMod from './where.js';
16
16
  /**
@@ -83,6 +83,12 @@ export function buildGroupBy(qi, args) {
83
83
  // expression (including any already-bound JSON-path placeholder, reused
84
84
  // exactly like HAVING since ORDER BY is appended after all other params).
85
85
  const byOrderExprs = new Map();
86
+ // The group-key set a `having` SCALAR filter may reference, keyed the same
87
+ // way (by-field name / JSON group-key alias). Separate from `byOrderExprs`
88
+ // because HAVING needs to know HOW the key is addressed: a plain by-field
89
+ // routes through the shared WHERE compiler by field name, a JSON group key
90
+ // re-emits its extract expression. See {@link buildHavingClauses}.
91
+ const havingGroupKeys = new Map();
86
92
  const usedResultKeys = new Set();
87
93
  const claimResultKey = (key, what) => {
88
94
  if (key === '_count' || usedResultKeys.has(key)) {
@@ -105,6 +111,7 @@ export function buildGroupBy(qi, args) {
105
111
  selectExprs.push(qi.q(col));
106
112
  byReaders.push({ resultKey: entry, rowKey: col, raw: false });
107
113
  byOrderExprs.set(entry, qi.q(col));
114
+ havingGroupKeys.set(entry, { kind: 'column', field: entry });
108
115
  }
109
116
  else {
110
117
  const col = resolveJsonPathTarget(qi, 'group key', entry.field, entry.path);
@@ -120,6 +127,9 @@ export function buildGroupBy(qi, args) {
120
127
  // ORDER BY by this JSON alias re-emits the extract expression (with its
121
128
  // already-bound $n): the same reuse HAVING does for JSON aggregates.
122
129
  byOrderExprs.set(alias, extract);
130
+ // Parenthesized: a scalar having predicate appends comparison / IS NULL
131
+ // operators to this expression, and the extract is emitted bare here.
132
+ havingGroupKeys.set(alias, { kind: 'expr', expr: `(${extract})`, label: `JSON group key "${alias}"` });
123
133
  }
124
134
  }
125
135
  // _count
@@ -215,7 +225,7 @@ export function buildGroupBy(qi, args) {
215
225
  // Appends to the same `params` array, so placeholders continue from the
216
226
  // WHERE clause's parameter positions (qi.p(params.length) below).
217
227
  if (args.having) {
218
- const havingClauses = buildHavingClauses(qi, args.having, params, jsonAggExprs);
228
+ const havingClauses = buildHavingClauses(qi, args.having, params, jsonAggExprs, havingGroupKeys);
219
229
  if (havingClauses.length > 0) {
220
230
  sql += ` HAVING ${havingClauses.join(' AND ')}`;
221
231
  }
@@ -459,35 +469,46 @@ export function buildDistinctOnSource(qi, distinctOn, whereSql, params) {
459
469
  return (`(SELECT DISTINCT ON (${distinctCols.join(', ')}) * FROM ${qi.q(qi.table)}${whereSql} ` +
460
470
  `ORDER BY ${orderParts.join(', ')}) AS ${qi.q(qi.table)}`);
461
471
  }
472
+ /**
473
+ * Maps a per-field aggregate key to its SQL function name. The set of allowed
474
+ * keys is fixed here: any OTHER underscore-prefixed key on a field's filter
475
+ * object is rejected by {@link ValidationError} (never interpolated), and every
476
+ * non-underscore key is a scalar operator on the grouped value itself.
477
+ */
478
+ const HAVING_AGGREGATE_FNS = {
479
+ _sum: 'SUM',
480
+ _avg: 'AVG',
481
+ _min: 'MIN',
482
+ _max: 'MAX',
483
+ _count: 'COUNT',
484
+ };
462
485
  /**
463
486
  * Build the SQL fragments for a {@link HavingClause}.
464
487
  *
488
+ * A field entry carries an AGGREGATE filter (`{ _sum: { gt: 100 } }`), a
489
+ * SCALAR filter on the grouped value itself (`{ not: null }`, `{ in: [...] }`,
490
+ * or a bare value as equality shorthand), or both in one object (ANDed,
491
+ * scalar first). `AND` / `OR` / `NOT` combine predicates at any depth.
492
+ *
465
493
  * Each aggregate expression (`COUNT(*)`, `SUM("col")`, etc.) is constructed
466
494
  * from a **schema-validated, quoted** column identifier: `qi.toColumn()`
467
495
  * throws {@link ValidationError} for unknown fields and `qi.q()` quotes via
468
496
  * the dialect, so no unvalidated identifier ever reaches the SQL string. Every
469
497
  * comparison value is pushed onto the shared `params` array and referenced by
470
- * a `$N` placeholder via {@link buildHavingNumericClauses}, there is no string
471
- * interpolation of user values.
498
+ * a `$N` placeholder via {@link buildHavingNumericClauses} (aggregates) or the
499
+ * shared WHERE compiler (scalars), there is no string interpolation of user
500
+ * values.
472
501
  *
473
502
  * `jsonAggExprs` (from {@link buildGroupBy}) maps `alias:aggKey` to the
474
503
  * exact aggregate expression a JSON-path aggregate emitted in SELECT
475
504
  * (including its already-bound path placeholder), so HAVING on a JSON-path
476
505
  * aggregate alias reuses the same expression instead of resolving the alias
477
- * as a column.
506
+ * as a column. `groupKeys` is the resolved `by` key set (see
507
+ * {@link HavingGroupKey}): a scalar filter is legal ONLY on a group key,
508
+ * because a non-grouped column cannot be referenced in HAVING at all.
478
509
  */
479
- export function buildHavingClauses(qi, having, params, jsonAggExprs) {
510
+ export function buildHavingClauses(qi, having, params, jsonAggExprs, groupKeys) {
480
511
  const clauses = [];
481
- // Maps the per-field aggregate key to its SQL function name. The set of
482
- // allowed keys is fixed here, any other key on a field's filter object is
483
- // rejected by ValidationError below (never interpolated).
484
- const aggFnByKey = {
485
- _sum: 'SUM',
486
- _avg: 'AVG',
487
- _min: 'MIN',
488
- _max: 'MAX',
489
- _count: 'COUNT',
490
- };
491
512
  for (const [key, value] of Object.entries(having)) {
492
513
  if (value === undefined)
493
514
  continue;
@@ -496,11 +517,20 @@ export function buildHavingClauses(qi, having, params, jsonAggExprs) {
496
517
  clauses.push(...buildHavingNumericClauses(qi, 'COUNT(*)', value, params));
497
518
  continue;
498
519
  }
499
- // Otherwise `key` is a field name mapping to a per-aggregate filter object.
500
- if (typeof value !== 'object' || value === null) {
501
- throw new ValidationError(`[turbine] Invalid having filter for field "${key}" on table "${qi.table}": ` +
502
- `expected an aggregate object like { _sum: { gt: 100 } }.`);
520
+ // AND / OR / NOT, mixing scalar and aggregate predicates at any depth.
521
+ if (key === 'AND' || key === 'OR' || key === 'NOT') {
522
+ clauses.push(...buildHavingCombinator(qi, key, value, params, jsonAggExprs, groupKeys));
523
+ continue;
524
+ }
525
+ // Otherwise `key` is a field name. Split its aggregate keys from its
526
+ // scalar operator keys: everything the fixed aggregate map does not name
527
+ // filters the grouped value itself.
528
+ const { aggEntries, scalarFilter } = splitHavingField(qi, key, value);
529
+ if (scalarFilter !== undefined) {
530
+ clauses.push(...buildHavingScalarClauses(qi, key, scalarFilter, params, groupKeys));
503
531
  }
532
+ if (aggEntries.length === 0)
533
+ continue;
504
534
  // toColumn validates the field against schema metadata (throws
505
535
  // ValidationError on unknown columns) and q() quotes the identifier, no
506
536
  // unvalidated identifier ever reaches the SQL string. Resolution is lazy:
@@ -511,37 +541,147 @@ export function buildHavingClauses(qi, having, params, jsonAggExprs) {
511
541
  quotedCol ??= qi.q(qi.toColumn(key));
512
542
  return quotedCol;
513
543
  };
514
- for (const [aggKey, filter] of Object.entries(value)) {
515
- if (filter === undefined)
516
- continue;
517
- // ownLookup, not a bare index: an inherited Object.prototype member
518
- // ("constructor", "toString", …) would otherwise resolve to a truthy
519
- // builtin and be spliced into the HAVING clause as its source text.
520
- const fn = ownLookup(aggFnByKey, aggKey);
521
- if (!fn) {
522
- throw new ValidationError(`[turbine] Unknown aggregate "${aggKey}" in having for field "${key}" on table "${qi.table}". ` +
523
- `Supported: ${Object.keys(aggFnByKey).join(', ')}.`);
524
- }
525
- const expr = jsonAggExprs?.get(`${key}:${aggKey}`) ?? `${fn}(${columnExpr()})`;
526
- clauses.push(...buildHavingNumericClauses(qi, expr, filter, params));
544
+ for (const agg of aggEntries) {
545
+ const expr = jsonAggExprs?.get(`${key}:${agg.key}`) ?? `${agg.fn}(${columnExpr()})`;
546
+ clauses.push(...buildHavingNumericClauses(qi, expr, agg.filter, params));
547
+ }
548
+ }
549
+ return clauses;
550
+ }
551
+ /**
552
+ * Partition one `having` field entry into its aggregate filters and its scalar
553
+ * filter. A non-object value (or an object naming no aggregate key) is scalar
554
+ * in full, so the whole value keeps its original shape (operator object, JSON
555
+ * filter, bare value, `null`). An unknown UNDERSCORE-prefixed key is a
556
+ * misspelled aggregate, not a scalar operator, and throws E003 naming it.
557
+ */
558
+ function splitHavingField(qi, field, value) {
559
+ if (!isUnmatchedPlainObject(value))
560
+ return { aggEntries: [], scalarFilter: value };
561
+ const aggEntries = [];
562
+ const scalarKeys = {};
563
+ for (const [k, v] of Object.entries(value)) {
564
+ if (v === undefined)
565
+ continue;
566
+ // ownLookup, not a bare index: an inherited Object.prototype member
567
+ // ("constructor", "toString", …) would otherwise resolve to a truthy
568
+ // builtin and be spliced into the HAVING clause as its source text.
569
+ const fn = ownLookup(HAVING_AGGREGATE_FNS, k);
570
+ if (fn) {
571
+ aggEntries.push({ key: k, fn, filter: v });
572
+ }
573
+ else if (k.startsWith('_')) {
574
+ throw new ValidationError(`[turbine] Unknown aggregate "${k}" in having for field "${field}" on table "${qi.table}". ` +
575
+ `Supported: ${Object.keys(HAVING_AGGREGATE_FNS).join(', ')}.`);
576
+ }
577
+ else {
578
+ scalarKeys[k] = v;
579
+ }
580
+ }
581
+ if (aggEntries.length === 0)
582
+ return { aggEntries, scalarFilter: value };
583
+ return { aggEntries, scalarFilter: Object.keys(scalarKeys).length > 0 ? scalarKeys : undefined };
584
+ }
585
+ /**
586
+ * Compile a `having` AND / OR / NOT branch. Each condition is a nested
587
+ * {@link HavingClause}; its own clauses are ANDed (parenthesized when there is
588
+ * more than one) before being combined. `AND` contributes its parts directly
589
+ * (the caller ANDs them), mirroring {@link buildWhereClause}'s combinator
590
+ * shapes so HAVING and WHERE read the same way.
591
+ */
592
+ function buildHavingCombinator(qi, key, value, params, jsonAggExprs, groupKeys) {
593
+ const conditions = Array.isArray(value) ? value : [value];
594
+ const parts = [];
595
+ for (const condition of conditions) {
596
+ if (!isUnmatchedPlainObject(condition)) {
597
+ throw new ValidationError(`[turbine] Invalid having "${key}" on table "${qi.table}": expected ` +
598
+ `${key === 'OR' ? 'an array of having objects' : 'a having object (or an array of them)'}.`);
527
599
  }
600
+ const sub = buildHavingClauses(qi, condition, params, jsonAggExprs, groupKeys);
601
+ if (sub.length === 0)
602
+ continue;
603
+ parts.push(sub.length === 1 ? sub[0] : `(${sub.join(' AND ')})`);
604
+ }
605
+ if (parts.length === 0)
606
+ return [];
607
+ if (key === 'AND')
608
+ return parts;
609
+ if (key === 'OR')
610
+ return [`(${parts.join(' OR ')})`];
611
+ return [`NOT (${parts.join(' AND ')})`];
612
+ }
613
+ /**
614
+ * Compile a SCALAR `having` filter: a predicate on the GROUPED value itself,
615
+ * as Prisma's groupBy allows (`having: { typeId: { not: null } }` →
616
+ * `HAVING "type_id" IS NOT NULL`).
617
+ *
618
+ * Placement is always HAVING, never WHERE. For a group key the two are
619
+ * result-equivalent (the value is constant within the group), but a scalar
620
+ * predicate ORed with an aggregate one is only expressible in HAVING, so one
621
+ * placement covers every shape and matches Prisma's emitted SQL.
622
+ *
623
+ * The field MUST be one of the `by` group keys: a predicate on any other
624
+ * column cannot appear in HAVING (Postgres answers "column must appear in the
625
+ * GROUP BY clause"), so it throws {@link ValidationError} E003 pointing at
626
+ * `where` / `by` / an aggregate filter instead of emitting invalid SQL.
627
+ *
628
+ * A plain by-column routes through the shared WHERE compiler
629
+ * ({@link whereMod.buildScalarClause}), so the operator set, enum casts, LIKE
630
+ * escaping, `mode: 'insensitive'`, and the dialect IN-clause form are
631
+ * inherited rather than reimplemented. A JSON-path group key compiles against
632
+ * its re-emitted extract expression.
633
+ */
634
+ function buildHavingScalarClauses(qi, field, value, params, groupKeys) {
635
+ const ref = groupKeys?.get(field);
636
+ if (!ref) {
637
+ const known = groupKeys ? [...groupKeys.keys()] : [];
638
+ throw new ValidationError(`[turbine] having on "${field}" (table "${qi.table}") filters the grouped value itself, but ` +
639
+ `"${field}" is not one of the \`by\` group keys [${known.join(', ') || 'none'}]. A predicate on a ` +
640
+ 'non-grouped column cannot go in HAVING: move it to `where` (it filters rows, not groups), add ' +
641
+ `"${field}" to \`by\`, or filter an aggregate of it instead (e.g. { ${field}: { _count: { gt: 0 } } }).`);
642
+ }
643
+ const clauses = [];
644
+ if (ref.kind === 'column') {
645
+ whereMod.buildScalarClause(qi, ref.field, value, params, clauses);
646
+ return clauses;
647
+ }
648
+ // JSON-path group key: the extract expression IS the group key, so it can
649
+ // carry a predicate in HAVING. It is not a column, so the column-typed
650
+ // surface (enum casts, temporal rewrites, column references) does not apply.
651
+ if (value === null) {
652
+ clauses.push(`${ref.expr} IS NULL`);
653
+ }
654
+ else if (isWhereOperator(value)) {
655
+ clauses.push(...whereMod.buildOperatorClauses(qi, ref.expr, value, params));
656
+ }
657
+ else if (isUnmatchedPlainObject(value)) {
658
+ throw new ValidationError(`[turbine] Unknown operator${Object.keys(value).length > 1 ? 's' : ''} ` +
659
+ `${Object.keys(value)
660
+ .map((k) => `"${k}"`)
661
+ .join(', ')} on ${ref.label} in having for table "${qi.table}".`);
662
+ }
663
+ else {
664
+ params.push(value);
665
+ clauses.push(`${ref.expr} = ${qi.p(params.length)}`);
528
666
  }
529
667
  return clauses;
530
668
  }
531
669
  /**
532
- * Convert a single having filter into one or more parameterized SQL
533
- * comparisons against the given aggregate expression. A bare number is
534
- * shorthand for equality. Unknown operator keys throw {@link ValidationError}.
670
+ * Convert a single having aggregate filter into one or more parameterized SQL
671
+ * comparisons against the given aggregate expression. A bare value is
672
+ * shorthand for equality. Operands are not numeric-only: `_min` / `_max`
673
+ * return a stored cell, so `MIN("title") > 'm'` is as valid as
674
+ * `SUM("views") > 10`. Unknown operator keys throw {@link ValidationError}.
535
675
  */
536
676
  export function buildHavingNumericClauses(qi, expr, filter, params) {
537
- // Bare number equality.
538
- if (typeof filter === 'number') {
677
+ if (filter === null) {
678
+ throw new ValidationError(`[turbine] Invalid having filter on "${expr}" for table "${qi.table}": expected a value or operator object.`);
679
+ }
680
+ // Bare value (number, string, boolean, Date, …) → equality.
681
+ if (typeof filter !== 'object' || filter instanceof Date) {
539
682
  params.push(filter);
540
683
  return [`${expr} = ${qi.p(params.length)}`];
541
684
  }
542
- if (typeof filter !== 'object' || filter === null) {
543
- throw new ValidationError(`[turbine] Invalid having filter on "${expr}" for table "${qi.table}": expected a number or operator object.`);
544
- }
545
685
  const op = filter;
546
686
  const allowedKeys = new Set(['equals', 'not', 'gt', 'gte', 'lt', 'lte', 'in', 'notIn']);
547
687
  for (const k of Object.keys(op)) {
@@ -605,7 +605,15 @@ export declare class QueryInterface<T extends object, R extends object = {}> {
605
605
  * cache keys) when nothing qualifies.
606
606
  */
607
607
  private planAuto;
608
- /** Dev-only once-per-relation note that `'auto'` engaged the batched fallback. */
608
+ /**
609
+ * Dev-only once-per-relation note that `'auto'` engaged the batched fallback.
610
+ *
611
+ * Both lines follow the same four parts: the CONDITION that tripped the rule,
612
+ * the MECHANISM (which plan shape was replaced by which, and what that costs),
613
+ * the fix, and the escape hatch. Naming only the condition is what lets a
614
+ * reader build a wrong model of the mechanism and read a correct optimization
615
+ * as a bug, so the mechanism sentence is not optional.
616
+ */
609
617
  private emitAutoNotes;
610
618
  /**
611
619
  * Execute a findMany/findUnique `'auto'` split: run the base query with the
@@ -942,7 +950,20 @@ export declare class QueryInterface<T extends object, R extends object = {}> {
942
950
  private planFlatten;
943
951
  /** Dev-only once-only note that `'flatten'` was refused for the whole query. */
944
952
  private warnFlattenBlocked;
945
- /** Convert camelCase field name to snake_case column name (unquoted, for non-SQL uses) */
953
+ /**
954
+ * Convert a field name to its snake_case column name (unquoted, for non-SQL
955
+ * uses), throwing E003 when the key names no column.
956
+ *
957
+ * The resolution rule itself lives in {@link resolveColumnName} (query/utils.ts)
958
+ * so the value-side passes that must NOT throw, write coercion above all, can
959
+ * share it instead of re-deriving it. Accepting `camelToSnake(field)` only
960
+ * when it is a real column preserves the convenience of writing `userId` when
961
+ * the schema exposes `user_id` under an unusual field name (and of writing the
962
+ * column name outright), while rejecting arbitrary strings, closing the
963
+ * defense-in-depth gap for SQL injection and catching typos like
964
+ * `where: { emial: 'x' }` with a clear error instead of a cryptic Postgres
965
+ * "column does not exist".
966
+ */
946
967
  private toColumn;
947
968
  /** Convert camelCase field name to a double-quoted SQL identifier */
948
969
  private toSqlColumn;
@@ -14,13 +14,13 @@ import { postgresDialect } from '../dialect.js';
14
14
  import { NotFoundError, TimeoutError, UnsupportedFeatureError, ValidationError, wrapPgError } from '../errors.js';
15
15
  import { missingIndexForRelation, schemaHasIndexInfo } from '../index-advisor.js';
16
16
  import { executeNestedCreate, executeNestedUpdate, hasRelationFields, } from '../nested-write.js';
17
- import { camelToSnake, normalizeKeyColumns, snakeToCamel } from '../schema.js';
17
+ import { normalizeKeyColumns, snakeToCamel } from '../schema.js';
18
18
  import * as aggMod from './aggregates.js';
19
19
  import { defaultProjectionFields, includeKeysForBatching, loadRelationsBatched, neededParentKeyFields, rejectNestedPickOrder, resolveCountRelations, stripFields, } from './batched-loader.js';
20
20
  import { expandCompoundUniqueWhere } from './compound-unique.js';
21
21
  import { isJsonPathOrderBy, isOrderBySpec, isRelationPickOrderBy, isVectorOrderBy, isWhereOperator, orderByEntries, sortedEntries, } from './filters.js';
22
22
  import * as relationsMod from './relations.js';
23
- import { LRUCache, ownLookup, parseDbDate, sqlToPreparedName, unknownFieldMessage, } from './utils.js';
23
+ import { LRUCache, ownLookup, parseDbDate, resolveColumnName, sqlToPreparedName, unknownFieldMessage, } from './utils.js';
24
24
  import { shouldWarnOnce, WARN_NS } from './warn-registry.js';
25
25
  import * as whereMod from './where.js';
26
26
  import * as writesMod from './writes.js';
@@ -672,12 +672,12 @@ export class QueryInterface {
672
672
  const rest = untyped.length > MAX_NAMED ? ` (+${untyped.length - MAX_NAMED} more)` : '';
673
673
  console.warn(`[turbine] table "${this.table}": no database type in metadata for column(s) ${named}${rest} (neither the ` +
674
674
  "column entry's `dialectType`/`pgType` nor the table-level `dialectTypes`/`pgTypes` map). Turbine cannot " +
675
- 'tell which of them are zone-less, so a `Date` written to one is bound by the driver as-is: right for ' +
676
- "`timestamptz`, but a zone-less `date`/`timestamp` column then stores the PROCESS's local calendar fields " +
677
- 'rather than UTC (silently, since the read path shifts back by the same offset), and a `time`/`timetz` ' +
678
- 'column rejects the value outright (`22007 invalid input syntax for type time`). Columns whose type IS ' +
679
- 'resolved, every `timestamptz` among them, are unaffected. Regenerate the metadata with ' +
680
- '`npx turbine generate`, or set the column types in your `defineSchema` definition.');
675
+ 'tell which of them are zone-less, so it skips the UTC bind rewrite and a `Date` goes to the driver ' +
676
+ "as-is: right for `timestamptz`, but a zone-less `date`/`timestamp` then stores the PROCESS's local " +
677
+ 'calendar fields rather than UTC (silently, since the read path shifts back by the same offset), and a ' +
678
+ '`time`/`timetz` column rejects the value outright (`22007 invalid input syntax for type time`). ' +
679
+ 'Columns whose type IS resolved, every `timestamptz` among them, are unaffected. Fix: regenerate with ' +
680
+ '`npx turbine generate`, or set the column types in `defineSchema`.');
681
681
  }
682
682
  /** Quote an identifier through the active SQL dialect. */
683
683
  q(name) {
@@ -1252,7 +1252,15 @@ export class QueryInterface {
1252
1252
  return null;
1253
1253
  return split;
1254
1254
  }
1255
- /** Dev-only once-per-relation note that `'auto'` engaged the batched fallback. */
1255
+ /**
1256
+ * Dev-only once-per-relation note that `'auto'` engaged the batched fallback.
1257
+ *
1258
+ * Both lines follow the same four parts: the CONDITION that tripped the rule,
1259
+ * the MECHANISM (which plan shape was replaced by which, and what that costs),
1260
+ * the fix, and the escape hatch. Naming only the condition is what lets a
1261
+ * reader build a wrong model of the mechanism and read a correct optimization
1262
+ * as a bug, so the mechanism sentence is not optional.
1263
+ */
1256
1264
  emitAutoNotes(engaged) {
1257
1265
  if (process.env.NODE_ENV === 'production')
1258
1266
  return;
@@ -1261,22 +1269,30 @@ export class QueryInterface {
1261
1269
  continue;
1262
1270
  if (e.reason === 'to-one-cardinality') {
1263
1271
  console.warn(`[turbine] auto strategy: to-one relation "${e.relation}" on "${this.table}" loads batched ` +
1264
- `(the query is unbounded or its limit exceeds ${this.autoToOneThreshold()} rows, and a correlated ` +
1265
- 'to-one subquery is re-evaluated per parent row). Bound the query with a smaller `limit`, tune ' +
1266
- "`autoToOneJoinMaxRows`, or set `relationLoadStrategy: 'join'` to force the single-statement plan.");
1272
+ `(the query is unbounded or its limit exceeds ${this.autoToOneThreshold()} rows). On the join plan ` +
1273
+ 'a to-one relation is a correlated subquery the engine re-evaluates once per parent row, a per-row ' +
1274
+ 'cost that stands even on a unique index; batched replaces it with ONE follow-up statement ' +
1275
+ '(`key = ANY(...)` for the whole page), so it trades that per-row cost for a single extra round ' +
1276
+ 'trip. Bound the query with a smaller `limit`, tune `autoToOneJoinMaxRows` (the break-even is ' +
1277
+ "round-trip time / per-row cost), or set `relationLoadStrategy: 'join'` to force the " +
1278
+ 'single-statement plan.');
1267
1279
  continue;
1268
1280
  }
1269
1281
  const probe = e.miss
1270
1282
  ? `probe "${e.miss.table}"(${e.miss.columns.join(', ')}) has no covering index`
1271
1283
  : 'a probe in its subtree has no covering index';
1272
- // The `_count` case is the one people read as a needless demotion, because
1273
- // the follow-up statement is a grouped COUNT and the inline form looks
1274
- // like it would be one too. It is not: state the shape it replaced.
1284
+ const child = e.miss?.table ?? 'the child table';
1285
+ // Say which shape was replaced, not just the condition. The `_count` case
1286
+ // is the one people read as a needless demotion, because the follow-up
1287
+ // statement is a grouped COUNT and the inline form looks like it would be
1288
+ // one too. It is not.
1275
1289
  const why = e.relation === '_count'
1276
1290
  ? ' The inline form is one correlated COUNT(*) re-evaluated per parent row, so on an unindexed ' +
1277
- `probe it is one full scan of "${e.miss?.table ?? 'the child table'}" per parent row; the ` +
1278
- 'follow-up is one grouped scan for the whole page.'
1279
- : '';
1291
+ `probe it is one full scan of "${child}" per parent row; the follow-up is one grouped scan for ` +
1292
+ 'the whole page.'
1293
+ : ' On the join plan that relation is a correlated subquery re-evaluated once per parent row, so an ' +
1294
+ `unindexed probe is one full scan of "${child}" per parent row; the batched follow-up scans it ` +
1295
+ 'once for the whole page.';
1280
1296
  console.warn(`[turbine] auto strategy: relation "${e.relation}" on "${this.table}" loads batched (${probe}).${why} ` +
1281
1297
  "Create the covering index (or set `relationLoadStrategy: 'join'` to force the single-statement " +
1282
1298
  'plan); run `npx turbine doctor` for the exact CREATE INDEX SQL.');
@@ -1777,12 +1793,21 @@ export class QueryInterface {
1777
1793
  const v = whereObj[k];
1778
1794
  return v !== null && !isWhereOperator(v) && !ownLookup(this.tableMeta.relations, k);
1779
1795
  });
1780
- // Simple path: plain equality, no operators/null/OR
1796
+ // Simple path: plain equality, no operators/null/OR.
1797
+ //
1798
+ // This path pushes its own params instead of going through
1799
+ // `buildWhereClause`, so every VALUE transform the general walker applies
1800
+ // has to be mirrored here or the two paths disagree on the same predicate.
1801
+ // `coerceWhereOperand` is the one that matters: without it a `Date` keyed on
1802
+ // a zone-less `date`/`timestamp`/`time` column binds raw, and a row that
1803
+ // `findFirst` matches, `findUnique` silently misses. It is a value-only
1804
+ // transform, so the emitted SQL and the cache key are untouched.
1781
1805
  if (!args.with && isSimpleWhere) {
1806
+ const coerce = (k, v) => whereMod.coerceWhereOperand(this.ctx, this.tableMeta, this.toColumn(k), v);
1782
1807
  const buildSql = (freshParams) => {
1783
1808
  const qt = this.q(this.table);
1784
1809
  const whereClauses = whereKeys.map((k, i) => {
1785
- freshParams.push(whereObj[k]);
1810
+ freshParams.push(coerce(k, whereObj[k]));
1786
1811
  return `${this.toSqlColumn(k)} = ${this.p(i + 1)}`;
1787
1812
  });
1788
1813
  const whereSql = whereClauses.length > 0 ? ` WHERE ${whereClauses.join(' AND ')}` : '';
@@ -1790,9 +1815,9 @@ export class QueryInterface {
1790
1815
  return `SELECT ${selectExpr} FROM ${qt}${whereSql}${this.limitOneClause()}`;
1791
1816
  };
1792
1817
  const entry = this.acquireSql(ck, buildSql);
1793
- // Collect params (same order as build)
1818
+ // Collect params (same order and same coercion as build)
1794
1819
  for (const k of whereKeys) {
1795
- params.push(whereObj[k]);
1820
+ params.push(coerce(k, whereObj[k]));
1796
1821
  }
1797
1822
  this.crossCheckCache('findUnique', ck, entry, buildSql, params);
1798
1823
  return {
@@ -1869,8 +1894,11 @@ export class QueryInterface {
1869
1894
  if (args?.with) {
1870
1895
  const depth = this.measureWithDepth(args.with);
1871
1896
  if (depth > 5 && shouldWarnOnce(WARN_NS.deepWith, this.table)) {
1872
- console.warn(`[turbine] Deep with clause (depth ${depth}) on "${this.tableMeta.name}", ` +
1873
- 'consider splitting into separate queries for better performance.');
1897
+ console.warn(`[turbine] Deep with clause (depth ${depth}) on "${this.tableMeta.name}": every level is a ` +
1898
+ 'correlated subquery the engine re-evaluates once per row of the level above, so the work ' +
1899
+ 'multiplies down the tree and the whole subtree is built as JSON inside each parent row. Split ' +
1900
+ "into separate queries, or use `relationLoadStrategy: 'batched'` (one flat statement per " +
1901
+ 'relation, no per-parent re-evaluation). Dev-only: silent under `NODE_ENV=production`.');
1874
1902
  }
1875
1903
  }
1876
1904
  }
@@ -1974,8 +2002,11 @@ export class QueryInterface {
1974
2002
  if (this.warnedTables.has(this.table))
1975
2003
  return;
1976
2004
  this.warnedTables.add(this.table);
1977
- console.warn(`[turbine] warning: findMany on "${this.table}" has no limit: this will fetch every row. ` +
1978
- 'Pass `limit`, or silence with `warnOnUnlimited: false` (per call, per table, or in config).');
2005
+ console.warn(`[turbine] warning: findMany on "${this.table}" has no limit: the statement is emitted with no row limit, ` +
2006
+ 'so the engine returns every matching row and the driver materializes all of them as objects before ' +
2007
+ 'this call resolves (the cost grows with the table, not with the rows you use). Pass `limit`/`take`, ' +
2008
+ 'or set `defaultLimit` in the client config; silence with `warnOnUnlimited: false` (per call, per ' +
2009
+ 'table, or in config).');
1979
2010
  }
1980
2011
  /**
1981
2012
  * Whether `where` can match at most one row, because it pins every column of
@@ -2819,28 +2850,24 @@ export class QueryInterface {
2819
2850
  console.warn(`[turbine] relationLoadStrategy: 'flatten' did not engage on "${this.table}": ${reason}. ` +
2820
2851
  'Every relation loads via the correlated subquery instead (same rows, same values, different plan).');
2821
2852
  }
2822
- /** Convert camelCase field name to snake_case column name (unquoted, for non-SQL uses) */
2853
+ /**
2854
+ * Convert a field name to its snake_case column name (unquoted, for non-SQL
2855
+ * uses), throwing E003 when the key names no column.
2856
+ *
2857
+ * The resolution rule itself lives in {@link resolveColumnName} (query/utils.ts)
2858
+ * so the value-side passes that must NOT throw, write coercion above all, can
2859
+ * share it instead of re-deriving it. Accepting `camelToSnake(field)` only
2860
+ * when it is a real column preserves the convenience of writing `userId` when
2861
+ * the schema exposes `user_id` under an unusual field name (and of writing the
2862
+ * column name outright), while rejecting arbitrary strings, closing the
2863
+ * defense-in-depth gap for SQL injection and catching typos like
2864
+ * `where: { emial: 'x' }` with a clear error instead of a cryptic Postgres
2865
+ * "column does not exist".
2866
+ */
2823
2867
  toColumn(field) {
2824
- // Prototype-safe lookup: a plain-object `columnMap` would otherwise return
2825
- // an inherited member (e.g. Object.prototype.constructor) for a field named
2826
- // "constructor" / "toString" / "__proto__", bypassing the unknown-field
2827
- // check below and returning a non-string as the column name.
2828
- const mapped = ownLookup(this.tableMeta.columnMap, field);
2829
- if (mapped)
2830
- return mapped;
2831
- // Fall back to camelToSnake ONLY if that snake_cased name also exists as a
2832
- // real column on the table. This preserves the convenience of writing
2833
- // `userId` when the schema exposes `user_id` under an unusual field name,
2834
- // but rejects arbitrary strings, closing the defense-in-depth gap for
2835
- // SQL injection and catching typos like `where: { emial: 'x' }` with a
2836
- // clear error instead of a cryptic Postgres "column does not exist".
2837
- const snake = camelToSnake(field);
2838
- if (this.tableMeta.reverseColumnMap && ownLookup(this.tableMeta.reverseColumnMap, snake)) {
2839
- return snake;
2840
- }
2841
- if (this.tableMeta.allColumns?.includes(snake)) {
2842
- return snake;
2843
- }
2868
+ const column = resolveColumnName(this.tableMeta, field);
2869
+ if (column)
2870
+ return column;
2844
2871
  throw new ValidationError(unknownFieldMessage(this.table, field, this.tableMeta));
2845
2872
  }
2846
2873
  /** Convert camelCase field name to a double-quoted SQL identifier */