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.
@@ -126,6 +126,12 @@ function buildGroupBy(qi, args) {
126
126
  // expression (including any already-bound JSON-path placeholder, reused
127
127
  // exactly like HAVING since ORDER BY is appended after all other params).
128
128
  const byOrderExprs = new Map();
129
+ // The group-key set a `having` SCALAR filter may reference, keyed the same
130
+ // way (by-field name / JSON group-key alias). Separate from `byOrderExprs`
131
+ // because HAVING needs to know HOW the key is addressed: a plain by-field
132
+ // routes through the shared WHERE compiler by field name, a JSON group key
133
+ // re-emits its extract expression. See {@link buildHavingClauses}.
134
+ const havingGroupKeys = new Map();
129
135
  const usedResultKeys = new Set();
130
136
  const claimResultKey = (key, what) => {
131
137
  if (key === '_count' || usedResultKeys.has(key)) {
@@ -148,6 +154,7 @@ function buildGroupBy(qi, args) {
148
154
  selectExprs.push(qi.q(col));
149
155
  byReaders.push({ resultKey: entry, rowKey: col, raw: false });
150
156
  byOrderExprs.set(entry, qi.q(col));
157
+ havingGroupKeys.set(entry, { kind: 'column', field: entry });
151
158
  }
152
159
  else {
153
160
  const col = resolveJsonPathTarget(qi, 'group key', entry.field, entry.path);
@@ -163,6 +170,9 @@ function buildGroupBy(qi, args) {
163
170
  // ORDER BY by this JSON alias re-emits the extract expression (with its
164
171
  // already-bound $n): the same reuse HAVING does for JSON aggregates.
165
172
  byOrderExprs.set(alias, extract);
173
+ // Parenthesized: a scalar having predicate appends comparison / IS NULL
174
+ // operators to this expression, and the extract is emitted bare here.
175
+ havingGroupKeys.set(alias, { kind: 'expr', expr: `(${extract})`, label: `JSON group key "${alias}"` });
166
176
  }
167
177
  }
168
178
  // _count
@@ -258,7 +268,7 @@ function buildGroupBy(qi, args) {
258
268
  // Appends to the same `params` array, so placeholders continue from the
259
269
  // WHERE clause's parameter positions (qi.p(params.length) below).
260
270
  if (args.having) {
261
- const havingClauses = buildHavingClauses(qi, args.having, params, jsonAggExprs);
271
+ const havingClauses = buildHavingClauses(qi, args.having, params, jsonAggExprs, havingGroupKeys);
262
272
  if (havingClauses.length > 0) {
263
273
  sql += ` HAVING ${havingClauses.join(' AND ')}`;
264
274
  }
@@ -502,35 +512,46 @@ function buildDistinctOnSource(qi, distinctOn, whereSql, params) {
502
512
  return (`(SELECT DISTINCT ON (${distinctCols.join(', ')}) * FROM ${qi.q(qi.table)}${whereSql} ` +
503
513
  `ORDER BY ${orderParts.join(', ')}) AS ${qi.q(qi.table)}`);
504
514
  }
515
+ /**
516
+ * Maps a per-field aggregate key to its SQL function name. The set of allowed
517
+ * keys is fixed here: any OTHER underscore-prefixed key on a field's filter
518
+ * object is rejected by {@link ValidationError} (never interpolated), and every
519
+ * non-underscore key is a scalar operator on the grouped value itself.
520
+ */
521
+ const HAVING_AGGREGATE_FNS = {
522
+ _sum: 'SUM',
523
+ _avg: 'AVG',
524
+ _min: 'MIN',
525
+ _max: 'MAX',
526
+ _count: 'COUNT',
527
+ };
505
528
  /**
506
529
  * Build the SQL fragments for a {@link HavingClause}.
507
530
  *
531
+ * A field entry carries an AGGREGATE filter (`{ _sum: { gt: 100 } }`), a
532
+ * SCALAR filter on the grouped value itself (`{ not: null }`, `{ in: [...] }`,
533
+ * or a bare value as equality shorthand), or both in one object (ANDed,
534
+ * scalar first). `AND` / `OR` / `NOT` combine predicates at any depth.
535
+ *
508
536
  * Each aggregate expression (`COUNT(*)`, `SUM("col")`, etc.) is constructed
509
537
  * from a **schema-validated, quoted** column identifier: `qi.toColumn()`
510
538
  * throws {@link ValidationError} for unknown fields and `qi.q()` quotes via
511
539
  * the dialect, so no unvalidated identifier ever reaches the SQL string. Every
512
540
  * comparison value is pushed onto the shared `params` array and referenced by
513
- * a `$N` placeholder via {@link buildHavingNumericClauses}, there is no string
514
- * interpolation of user values.
541
+ * a `$N` placeholder via {@link buildHavingNumericClauses} (aggregates) or the
542
+ * shared WHERE compiler (scalars), there is no string interpolation of user
543
+ * values.
515
544
  *
516
545
  * `jsonAggExprs` (from {@link buildGroupBy}) maps `alias:aggKey` to the
517
546
  * exact aggregate expression a JSON-path aggregate emitted in SELECT
518
547
  * (including its already-bound path placeholder), so HAVING on a JSON-path
519
548
  * aggregate alias reuses the same expression instead of resolving the alias
520
- * as a column.
549
+ * as a column. `groupKeys` is the resolved `by` key set (see
550
+ * {@link HavingGroupKey}): a scalar filter is legal ONLY on a group key,
551
+ * because a non-grouped column cannot be referenced in HAVING at all.
521
552
  */
522
- function buildHavingClauses(qi, having, params, jsonAggExprs) {
553
+ function buildHavingClauses(qi, having, params, jsonAggExprs, groupKeys) {
523
554
  const clauses = [];
524
- // Maps the per-field aggregate key to its SQL function name. The set of
525
- // allowed keys is fixed here, any other key on a field's filter object is
526
- // rejected by ValidationError below (never interpolated).
527
- const aggFnByKey = {
528
- _sum: 'SUM',
529
- _avg: 'AVG',
530
- _min: 'MIN',
531
- _max: 'MAX',
532
- _count: 'COUNT',
533
- };
534
555
  for (const [key, value] of Object.entries(having)) {
535
556
  if (value === undefined)
536
557
  continue;
@@ -539,11 +560,20 @@ function buildHavingClauses(qi, having, params, jsonAggExprs) {
539
560
  clauses.push(...buildHavingNumericClauses(qi, 'COUNT(*)', value, params));
540
561
  continue;
541
562
  }
542
- // Otherwise `key` is a field name mapping to a per-aggregate filter object.
543
- if (typeof value !== 'object' || value === null) {
544
- throw new errors_js_1.ValidationError(`[turbine] Invalid having filter for field "${key}" on table "${qi.table}": ` +
545
- `expected an aggregate object like { _sum: { gt: 100 } }.`);
563
+ // AND / OR / NOT, mixing scalar and aggregate predicates at any depth.
564
+ if (key === 'AND' || key === 'OR' || key === 'NOT') {
565
+ clauses.push(...buildHavingCombinator(qi, key, value, params, jsonAggExprs, groupKeys));
566
+ continue;
567
+ }
568
+ // Otherwise `key` is a field name. Split its aggregate keys from its
569
+ // scalar operator keys: everything the fixed aggregate map does not name
570
+ // filters the grouped value itself.
571
+ const { aggEntries, scalarFilter } = splitHavingField(qi, key, value);
572
+ if (scalarFilter !== undefined) {
573
+ clauses.push(...buildHavingScalarClauses(qi, key, scalarFilter, params, groupKeys));
546
574
  }
575
+ if (aggEntries.length === 0)
576
+ continue;
547
577
  // toColumn validates the field against schema metadata (throws
548
578
  // ValidationError on unknown columns) and q() quotes the identifier, no
549
579
  // unvalidated identifier ever reaches the SQL string. Resolution is lazy:
@@ -554,37 +584,147 @@ function buildHavingClauses(qi, having, params, jsonAggExprs) {
554
584
  quotedCol ??= qi.q(qi.toColumn(key));
555
585
  return quotedCol;
556
586
  };
557
- for (const [aggKey, filter] of Object.entries(value)) {
558
- if (filter === undefined)
559
- continue;
560
- // ownLookup, not a bare index: an inherited Object.prototype member
561
- // ("constructor", "toString", …) would otherwise resolve to a truthy
562
- // builtin and be spliced into the HAVING clause as its source text.
563
- const fn = (0, utils_js_1.ownLookup)(aggFnByKey, aggKey);
564
- if (!fn) {
565
- throw new errors_js_1.ValidationError(`[turbine] Unknown aggregate "${aggKey}" in having for field "${key}" on table "${qi.table}". ` +
566
- `Supported: ${Object.keys(aggFnByKey).join(', ')}.`);
567
- }
568
- const expr = jsonAggExprs?.get(`${key}:${aggKey}`) ?? `${fn}(${columnExpr()})`;
569
- clauses.push(...buildHavingNumericClauses(qi, expr, filter, params));
587
+ for (const agg of aggEntries) {
588
+ const expr = jsonAggExprs?.get(`${key}:${agg.key}`) ?? `${agg.fn}(${columnExpr()})`;
589
+ clauses.push(...buildHavingNumericClauses(qi, expr, agg.filter, params));
590
+ }
591
+ }
592
+ return clauses;
593
+ }
594
+ /**
595
+ * Partition one `having` field entry into its aggregate filters and its scalar
596
+ * filter. A non-object value (or an object naming no aggregate key) is scalar
597
+ * in full, so the whole value keeps its original shape (operator object, JSON
598
+ * filter, bare value, `null`). An unknown UNDERSCORE-prefixed key is a
599
+ * misspelled aggregate, not a scalar operator, and throws E003 naming it.
600
+ */
601
+ function splitHavingField(qi, field, value) {
602
+ if (!(0, filters_js_1.isUnmatchedPlainObject)(value))
603
+ return { aggEntries: [], scalarFilter: value };
604
+ const aggEntries = [];
605
+ const scalarKeys = {};
606
+ for (const [k, v] of Object.entries(value)) {
607
+ if (v === undefined)
608
+ continue;
609
+ // ownLookup, not a bare index: an inherited Object.prototype member
610
+ // ("constructor", "toString", …) would otherwise resolve to a truthy
611
+ // builtin and be spliced into the HAVING clause as its source text.
612
+ const fn = (0, utils_js_1.ownLookup)(HAVING_AGGREGATE_FNS, k);
613
+ if (fn) {
614
+ aggEntries.push({ key: k, fn, filter: v });
615
+ }
616
+ else if (k.startsWith('_')) {
617
+ throw new errors_js_1.ValidationError(`[turbine] Unknown aggregate "${k}" in having for field "${field}" on table "${qi.table}". ` +
618
+ `Supported: ${Object.keys(HAVING_AGGREGATE_FNS).join(', ')}.`);
619
+ }
620
+ else {
621
+ scalarKeys[k] = v;
622
+ }
623
+ }
624
+ if (aggEntries.length === 0)
625
+ return { aggEntries, scalarFilter: value };
626
+ return { aggEntries, scalarFilter: Object.keys(scalarKeys).length > 0 ? scalarKeys : undefined };
627
+ }
628
+ /**
629
+ * Compile a `having` AND / OR / NOT branch. Each condition is a nested
630
+ * {@link HavingClause}; its own clauses are ANDed (parenthesized when there is
631
+ * more than one) before being combined. `AND` contributes its parts directly
632
+ * (the caller ANDs them), mirroring {@link buildWhereClause}'s combinator
633
+ * shapes so HAVING and WHERE read the same way.
634
+ */
635
+ function buildHavingCombinator(qi, key, value, params, jsonAggExprs, groupKeys) {
636
+ const conditions = Array.isArray(value) ? value : [value];
637
+ const parts = [];
638
+ for (const condition of conditions) {
639
+ if (!(0, filters_js_1.isUnmatchedPlainObject)(condition)) {
640
+ throw new errors_js_1.ValidationError(`[turbine] Invalid having "${key}" on table "${qi.table}": expected ` +
641
+ `${key === 'OR' ? 'an array of having objects' : 'a having object (or an array of them)'}.`);
570
642
  }
643
+ const sub = buildHavingClauses(qi, condition, params, jsonAggExprs, groupKeys);
644
+ if (sub.length === 0)
645
+ continue;
646
+ parts.push(sub.length === 1 ? sub[0] : `(${sub.join(' AND ')})`);
647
+ }
648
+ if (parts.length === 0)
649
+ return [];
650
+ if (key === 'AND')
651
+ return parts;
652
+ if (key === 'OR')
653
+ return [`(${parts.join(' OR ')})`];
654
+ return [`NOT (${parts.join(' AND ')})`];
655
+ }
656
+ /**
657
+ * Compile a SCALAR `having` filter: a predicate on the GROUPED value itself,
658
+ * as Prisma's groupBy allows (`having: { typeId: { not: null } }` →
659
+ * `HAVING "type_id" IS NOT NULL`).
660
+ *
661
+ * Placement is always HAVING, never WHERE. For a group key the two are
662
+ * result-equivalent (the value is constant within the group), but a scalar
663
+ * predicate ORed with an aggregate one is only expressible in HAVING, so one
664
+ * placement covers every shape and matches Prisma's emitted SQL.
665
+ *
666
+ * The field MUST be one of the `by` group keys: a predicate on any other
667
+ * column cannot appear in HAVING (Postgres answers "column must appear in the
668
+ * GROUP BY clause"), so it throws {@link ValidationError} E003 pointing at
669
+ * `where` / `by` / an aggregate filter instead of emitting invalid SQL.
670
+ *
671
+ * A plain by-column routes through the shared WHERE compiler
672
+ * ({@link whereMod.buildScalarClause}), so the operator set, enum casts, LIKE
673
+ * escaping, `mode: 'insensitive'`, and the dialect IN-clause form are
674
+ * inherited rather than reimplemented. A JSON-path group key compiles against
675
+ * its re-emitted extract expression.
676
+ */
677
+ function buildHavingScalarClauses(qi, field, value, params, groupKeys) {
678
+ const ref = groupKeys?.get(field);
679
+ if (!ref) {
680
+ const known = groupKeys ? [...groupKeys.keys()] : [];
681
+ throw new errors_js_1.ValidationError(`[turbine] having on "${field}" (table "${qi.table}") filters the grouped value itself, but ` +
682
+ `"${field}" is not one of the \`by\` group keys [${known.join(', ') || 'none'}]. A predicate on a ` +
683
+ 'non-grouped column cannot go in HAVING: move it to `where` (it filters rows, not groups), add ' +
684
+ `"${field}" to \`by\`, or filter an aggregate of it instead (e.g. { ${field}: { _count: { gt: 0 } } }).`);
685
+ }
686
+ const clauses = [];
687
+ if (ref.kind === 'column') {
688
+ whereMod.buildScalarClause(qi, ref.field, value, params, clauses);
689
+ return clauses;
690
+ }
691
+ // JSON-path group key: the extract expression IS the group key, so it can
692
+ // carry a predicate in HAVING. It is not a column, so the column-typed
693
+ // surface (enum casts, temporal rewrites, column references) does not apply.
694
+ if (value === null) {
695
+ clauses.push(`${ref.expr} IS NULL`);
696
+ }
697
+ else if ((0, filters_js_1.isWhereOperator)(value)) {
698
+ clauses.push(...whereMod.buildOperatorClauses(qi, ref.expr, value, params));
699
+ }
700
+ else if ((0, filters_js_1.isUnmatchedPlainObject)(value)) {
701
+ throw new errors_js_1.ValidationError(`[turbine] Unknown operator${Object.keys(value).length > 1 ? 's' : ''} ` +
702
+ `${Object.keys(value)
703
+ .map((k) => `"${k}"`)
704
+ .join(', ')} on ${ref.label} in having for table "${qi.table}".`);
705
+ }
706
+ else {
707
+ params.push(value);
708
+ clauses.push(`${ref.expr} = ${qi.p(params.length)}`);
571
709
  }
572
710
  return clauses;
573
711
  }
574
712
  /**
575
- * Convert a single having filter into one or more parameterized SQL
576
- * comparisons against the given aggregate expression. A bare number is
577
- * shorthand for equality. Unknown operator keys throw {@link ValidationError}.
713
+ * Convert a single having aggregate filter into one or more parameterized SQL
714
+ * comparisons against the given aggregate expression. A bare value is
715
+ * shorthand for equality. Operands are not numeric-only: `_min` / `_max`
716
+ * return a stored cell, so `MIN("title") > 'm'` is as valid as
717
+ * `SUM("views") > 10`. Unknown operator keys throw {@link ValidationError}.
578
718
  */
579
719
  function buildHavingNumericClauses(qi, expr, filter, params) {
580
- // Bare number equality.
581
- if (typeof filter === 'number') {
720
+ if (filter === null) {
721
+ throw new errors_js_1.ValidationError(`[turbine] Invalid having filter on "${expr}" for table "${qi.table}": expected a value or operator object.`);
722
+ }
723
+ // Bare value (number, string, boolean, Date, …) → equality.
724
+ if (typeof filter !== 'object' || filter instanceof Date) {
582
725
  params.push(filter);
583
726
  return [`${expr} = ${qi.p(params.length)}`];
584
727
  }
585
- if (typeof filter !== 'object' || filter === null) {
586
- throw new errors_js_1.ValidationError(`[turbine] Invalid having filter on "${expr}" for table "${qi.table}": expected a number or operator object.`);
587
- }
588
728
  const op = filter;
589
729
  const allowedKeys = new Set(['equals', 'not', 'gt', 'gte', 'lt', 'lte', 'in', 'notIn']);
590
730
  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;
@@ -708,12 +708,12 @@ class QueryInterface {
708
708
  const rest = untyped.length > MAX_NAMED ? ` (+${untyped.length - MAX_NAMED} more)` : '';
709
709
  console.warn(`[turbine] table "${this.table}": no database type in metadata for column(s) ${named}${rest} (neither the ` +
710
710
  "column entry's `dialectType`/`pgType` nor the table-level `dialectTypes`/`pgTypes` map). Turbine cannot " +
711
- 'tell which of them are zone-less, so a `Date` written to one is bound by the driver as-is: right for ' +
712
- "`timestamptz`, but a zone-less `date`/`timestamp` column then stores the PROCESS's local calendar fields " +
713
- 'rather than UTC (silently, since the read path shifts back by the same offset), and a `time`/`timetz` ' +
714
- 'column rejects the value outright (`22007 invalid input syntax for type time`). Columns whose type IS ' +
715
- 'resolved, every `timestamptz` among them, are unaffected. Regenerate the metadata with ' +
716
- '`npx turbine generate`, or set the column types in your `defineSchema` definition.');
711
+ 'tell which of them are zone-less, so it skips the UTC bind rewrite and a `Date` goes to the driver ' +
712
+ "as-is: right for `timestamptz`, but a zone-less `date`/`timestamp` then stores the PROCESS's local " +
713
+ 'calendar fields rather than UTC (silently, since the read path shifts back by the same offset), and a ' +
714
+ '`time`/`timetz` column rejects the value outright (`22007 invalid input syntax for type time`). ' +
715
+ 'Columns whose type IS resolved, every `timestamptz` among them, are unaffected. Fix: regenerate with ' +
716
+ '`npx turbine generate`, or set the column types in `defineSchema`.');
717
717
  }
718
718
  /** Quote an identifier through the active SQL dialect. */
719
719
  q(name) {
@@ -1288,7 +1288,15 @@ class QueryInterface {
1288
1288
  return null;
1289
1289
  return split;
1290
1290
  }
1291
- /** Dev-only once-per-relation note that `'auto'` engaged the batched fallback. */
1291
+ /**
1292
+ * Dev-only once-per-relation note that `'auto'` engaged the batched fallback.
1293
+ *
1294
+ * Both lines follow the same four parts: the CONDITION that tripped the rule,
1295
+ * the MECHANISM (which plan shape was replaced by which, and what that costs),
1296
+ * the fix, and the escape hatch. Naming only the condition is what lets a
1297
+ * reader build a wrong model of the mechanism and read a correct optimization
1298
+ * as a bug, so the mechanism sentence is not optional.
1299
+ */
1292
1300
  emitAutoNotes(engaged) {
1293
1301
  if (process.env.NODE_ENV === 'production')
1294
1302
  return;
@@ -1297,22 +1305,30 @@ class QueryInterface {
1297
1305
  continue;
1298
1306
  if (e.reason === 'to-one-cardinality') {
1299
1307
  console.warn(`[turbine] auto strategy: to-one relation "${e.relation}" on "${this.table}" loads batched ` +
1300
- `(the query is unbounded or its limit exceeds ${this.autoToOneThreshold()} rows, and a correlated ` +
1301
- 'to-one subquery is re-evaluated per parent row). Bound the query with a smaller `limit`, tune ' +
1302
- "`autoToOneJoinMaxRows`, or set `relationLoadStrategy: 'join'` to force the single-statement plan.");
1308
+ `(the query is unbounded or its limit exceeds ${this.autoToOneThreshold()} rows). On the join plan ` +
1309
+ 'a to-one relation is a correlated subquery the engine re-evaluates once per parent row, a per-row ' +
1310
+ 'cost that stands even on a unique index; batched replaces it with ONE follow-up statement ' +
1311
+ '(`key = ANY(...)` for the whole page), so it trades that per-row cost for a single extra round ' +
1312
+ 'trip. Bound the query with a smaller `limit`, tune `autoToOneJoinMaxRows` (the break-even is ' +
1313
+ "round-trip time / per-row cost), or set `relationLoadStrategy: 'join'` to force the " +
1314
+ 'single-statement plan.');
1303
1315
  continue;
1304
1316
  }
1305
1317
  const probe = e.miss
1306
1318
  ? `probe "${e.miss.table}"(${e.miss.columns.join(', ')}) has no covering index`
1307
1319
  : 'a probe in its subtree has no covering index';
1308
- // The `_count` case is the one people read as a needless demotion, because
1309
- // the follow-up statement is a grouped COUNT and the inline form looks
1310
- // like it would be one too. It is not: state the shape it replaced.
1320
+ const child = e.miss?.table ?? 'the child table';
1321
+ // Say which shape was replaced, not just the condition. The `_count` case
1322
+ // is the one people read as a needless demotion, because the follow-up
1323
+ // statement is a grouped COUNT and the inline form looks like it would be
1324
+ // one too. It is not.
1311
1325
  const why = e.relation === '_count'
1312
1326
  ? ' The inline form is one correlated COUNT(*) re-evaluated per parent row, so on an unindexed ' +
1313
- `probe it is one full scan of "${e.miss?.table ?? 'the child table'}" per parent row; the ` +
1314
- 'follow-up is one grouped scan for the whole page.'
1315
- : '';
1327
+ `probe it is one full scan of "${child}" per parent row; the follow-up is one grouped scan for ` +
1328
+ 'the whole page.'
1329
+ : ' On the join plan that relation is a correlated subquery re-evaluated once per parent row, so an ' +
1330
+ `unindexed probe is one full scan of "${child}" per parent row; the batched follow-up scans it ` +
1331
+ 'once for the whole page.';
1316
1332
  console.warn(`[turbine] auto strategy: relation "${e.relation}" on "${this.table}" loads batched (${probe}).${why} ` +
1317
1333
  "Create the covering index (or set `relationLoadStrategy: 'join'` to force the single-statement " +
1318
1334
  'plan); run `npx turbine doctor` for the exact CREATE INDEX SQL.');
@@ -1813,12 +1829,21 @@ class QueryInterface {
1813
1829
  const v = whereObj[k];
1814
1830
  return v !== null && !(0, filters_js_1.isWhereOperator)(v) && !(0, utils_js_1.ownLookup)(this.tableMeta.relations, k);
1815
1831
  });
1816
- // Simple path: plain equality, no operators/null/OR
1832
+ // Simple path: plain equality, no operators/null/OR.
1833
+ //
1834
+ // This path pushes its own params instead of going through
1835
+ // `buildWhereClause`, so every VALUE transform the general walker applies
1836
+ // has to be mirrored here or the two paths disagree on the same predicate.
1837
+ // `coerceWhereOperand` is the one that matters: without it a `Date` keyed on
1838
+ // a zone-less `date`/`timestamp`/`time` column binds raw, and a row that
1839
+ // `findFirst` matches, `findUnique` silently misses. It is a value-only
1840
+ // transform, so the emitted SQL and the cache key are untouched.
1817
1841
  if (!args.with && isSimpleWhere) {
1842
+ const coerce = (k, v) => whereMod.coerceWhereOperand(this.ctx, this.tableMeta, this.toColumn(k), v);
1818
1843
  const buildSql = (freshParams) => {
1819
1844
  const qt = this.q(this.table);
1820
1845
  const whereClauses = whereKeys.map((k, i) => {
1821
- freshParams.push(whereObj[k]);
1846
+ freshParams.push(coerce(k, whereObj[k]));
1822
1847
  return `${this.toSqlColumn(k)} = ${this.p(i + 1)}`;
1823
1848
  });
1824
1849
  const whereSql = whereClauses.length > 0 ? ` WHERE ${whereClauses.join(' AND ')}` : '';
@@ -1826,9 +1851,9 @@ class QueryInterface {
1826
1851
  return `SELECT ${selectExpr} FROM ${qt}${whereSql}${this.limitOneClause()}`;
1827
1852
  };
1828
1853
  const entry = this.acquireSql(ck, buildSql);
1829
- // Collect params (same order as build)
1854
+ // Collect params (same order and same coercion as build)
1830
1855
  for (const k of whereKeys) {
1831
- params.push(whereObj[k]);
1856
+ params.push(coerce(k, whereObj[k]));
1832
1857
  }
1833
1858
  this.crossCheckCache('findUnique', ck, entry, buildSql, params);
1834
1859
  return {
@@ -1905,8 +1930,11 @@ class QueryInterface {
1905
1930
  if (args?.with) {
1906
1931
  const depth = this.measureWithDepth(args.with);
1907
1932
  if (depth > 5 && (0, warn_registry_js_1.shouldWarnOnce)(warn_registry_js_1.WARN_NS.deepWith, this.table)) {
1908
- console.warn(`[turbine] Deep with clause (depth ${depth}) on "${this.tableMeta.name}", ` +
1909
- 'consider splitting into separate queries for better performance.');
1933
+ console.warn(`[turbine] Deep with clause (depth ${depth}) on "${this.tableMeta.name}": every level is a ` +
1934
+ 'correlated subquery the engine re-evaluates once per row of the level above, so the work ' +
1935
+ 'multiplies down the tree and the whole subtree is built as JSON inside each parent row. Split ' +
1936
+ "into separate queries, or use `relationLoadStrategy: 'batched'` (one flat statement per " +
1937
+ 'relation, no per-parent re-evaluation). Dev-only: silent under `NODE_ENV=production`.');
1910
1938
  }
1911
1939
  }
1912
1940
  }
@@ -2010,8 +2038,11 @@ class QueryInterface {
2010
2038
  if (this.warnedTables.has(this.table))
2011
2039
  return;
2012
2040
  this.warnedTables.add(this.table);
2013
- console.warn(`[turbine] warning: findMany on "${this.table}" has no limit: this will fetch every row. ` +
2014
- 'Pass `limit`, or silence with `warnOnUnlimited: false` (per call, per table, or in config).');
2041
+ console.warn(`[turbine] warning: findMany on "${this.table}" has no limit: the statement is emitted with no row limit, ` +
2042
+ 'so the engine returns every matching row and the driver materializes all of them as objects before ' +
2043
+ 'this call resolves (the cost grows with the table, not with the rows you use). Pass `limit`/`take`, ' +
2044
+ 'or set `defaultLimit` in the client config; silence with `warnOnUnlimited: false` (per call, per ' +
2045
+ 'table, or in config).');
2015
2046
  }
2016
2047
  /**
2017
2048
  * Whether `where` can match at most one row, because it pins every column of
@@ -2855,28 +2886,24 @@ class QueryInterface {
2855
2886
  console.warn(`[turbine] relationLoadStrategy: 'flatten' did not engage on "${this.table}": ${reason}. ` +
2856
2887
  'Every relation loads via the correlated subquery instead (same rows, same values, different plan).');
2857
2888
  }
2858
- /** Convert camelCase field name to snake_case column name (unquoted, for non-SQL uses) */
2889
+ /**
2890
+ * Convert a field name to its snake_case column name (unquoted, for non-SQL
2891
+ * uses), throwing E003 when the key names no column.
2892
+ *
2893
+ * The resolution rule itself lives in {@link resolveColumnName} (query/utils.ts)
2894
+ * so the value-side passes that must NOT throw, write coercion above all, can
2895
+ * share it instead of re-deriving it. Accepting `camelToSnake(field)` only
2896
+ * when it is a real column preserves the convenience of writing `userId` when
2897
+ * the schema exposes `user_id` under an unusual field name (and of writing the
2898
+ * column name outright), while rejecting arbitrary strings, closing the
2899
+ * defense-in-depth gap for SQL injection and catching typos like
2900
+ * `where: { emial: 'x' }` with a clear error instead of a cryptic Postgres
2901
+ * "column does not exist".
2902
+ */
2859
2903
  toColumn(field) {
2860
- // Prototype-safe lookup: a plain-object `columnMap` would otherwise return
2861
- // an inherited member (e.g. Object.prototype.constructor) for a field named
2862
- // "constructor" / "toString" / "__proto__", bypassing the unknown-field
2863
- // check below and returning a non-string as the column name.
2864
- const mapped = (0, utils_js_1.ownLookup)(this.tableMeta.columnMap, field);
2865
- if (mapped)
2866
- return mapped;
2867
- // Fall back to camelToSnake ONLY if that snake_cased name also exists as a
2868
- // real column on the table. This preserves the convenience of writing
2869
- // `userId` when the schema exposes `user_id` under an unusual field name,
2870
- // but rejects arbitrary strings, closing the defense-in-depth gap for
2871
- // SQL injection and catching typos like `where: { emial: 'x' }` with a
2872
- // clear error instead of a cryptic Postgres "column does not exist".
2873
- const snake = (0, schema_js_1.camelToSnake)(field);
2874
- if (this.tableMeta.reverseColumnMap && (0, utils_js_1.ownLookup)(this.tableMeta.reverseColumnMap, snake)) {
2875
- return snake;
2876
- }
2877
- if (this.tableMeta.allColumns?.includes(snake)) {
2878
- return snake;
2879
- }
2904
+ const column = (0, utils_js_1.resolveColumnName)(this.tableMeta, field);
2905
+ if (column)
2906
+ return column;
2880
2907
  throw new errors_js_1.ValidationError((0, utils_js_1.unknownFieldMessage)(this.table, field, this.tableMeta));
2881
2908
  }
2882
2909
  /** Convert camelCase field name to a double-quoted SQL identifier */
@@ -718,23 +718,29 @@ export interface CountArgs<T, R extends object = {}> {
718
718
  skipGlobalFilters?: SkipGlobalFilters;
719
719
  }
720
720
  /**
721
- * Numeric comparison operators usable inside a `having` filter. A bare number
721
+ * Comparison operators usable inside a `having` aggregate filter. A bare value
722
722
  * is shorthand for equality (`COUNT(*) = $n`); the operator object supports
723
- * range and inequality comparisons. Mirrors the numeric subset of
723
+ * range and inequality comparisons. Mirrors the comparison subset of
724
724
  * {@link WhereOperator} so the same SQL machinery can be reused.
725
+ *
726
+ * `V` is the operand type: `number` for `_sum` / `_avg` / `_count`, the
727
+ * column's own type for `_min` / `_max` (those return a stored cell, so
728
+ * `MIN("title") > 'm'` is as valid as `MIN("views") > 10`).
725
729
  */
726
- export interface HavingNumericOperator {
727
- equals?: number;
728
- not?: number;
729
- gt?: number;
730
- gte?: number;
731
- lt?: number;
732
- lte?: number;
733
- in?: number[];
734
- notIn?: number[];
730
+ export interface HavingComparisonOperator<V = number> {
731
+ equals?: V;
732
+ not?: V;
733
+ gt?: V;
734
+ gte?: V;
735
+ lt?: V;
736
+ lte?: V;
737
+ in?: V[];
738
+ notIn?: V[];
735
739
  }
736
- /** A single having predicate value: a bare number (equality) or an operator object. */
737
- export type HavingFilter = number | HavingNumericOperator;
740
+ /** The number-operand spelling, kept as the historical public name. */
741
+ export type HavingNumericOperator = HavingComparisonOperator<number>;
742
+ /** A single having predicate value: a bare value (equality) or an operator object. */
743
+ export type HavingFilter<V = number> = V | HavingComparisonOperator<V>;
738
744
  /**
739
745
  * Per-field aggregate filters inside a {@link HavingClause}. Each aggregate
740
746
  * function maps to a {@link HavingFilter} comparison on that field.
@@ -742,33 +748,52 @@ export type HavingFilter = number | HavingNumericOperator;
742
748
  * @example
743
749
  * viewCount: { _sum: { gt: 100 }, _avg: { lte: 50 } }
744
750
  */
745
- export interface HavingAggregateFilter {
751
+ export interface HavingAggregateFilter<V = unknown> {
746
752
  _sum?: HavingFilter;
747
753
  _avg?: HavingFilter;
748
- _min?: HavingFilter;
749
- _max?: HavingFilter;
754
+ _min?: HavingFilter<V>;
755
+ _max?: HavingFilter<V>;
750
756
  _count?: HavingFilter;
751
757
  }
758
+ /**
759
+ * One field entry of a {@link HavingClause}. Following Prisma, a field accepts
760
+ * BOTH an aggregate filter (`{ _sum: { gt: 100 } }`) and a scalar filter on the
761
+ * grouped value itself (`{ not: null }`, `{ in: ['a', 'b'] }`, or a bare value
762
+ * as equality shorthand), including both in the same object, which are ANDed.
763
+ *
764
+ * A scalar filter is only legal on a column listed in `by`: the group's value
765
+ * is constant within the group, so it compiles into HAVING against the bare
766
+ * group key. On any other column it throws {@link ValidationError} E003, since
767
+ * a non-grouped column cannot be referenced in HAVING at all.
768
+ */
769
+ export type HavingFieldFilter<V = unknown> = (HavingAggregateFilter<V> & WhereOperator<V>) | WhereValue<V>;
752
770
  /**
753
771
  * HAVING clause for `groupBy`, filters whole groups by their aggregate values
754
- * (the SQL `HAVING` clause). Follows Prisma's shape: each aggregable field maps
755
- * to a {@link HavingAggregateFilter} (`field → aggregate → operator → value`),
756
- * and the special top-level `_count` key (no field) filters on `COUNT(*)`.
772
+ * and by the grouped values themselves (the SQL `HAVING` clause). Follows
773
+ * Prisma's shape: each field maps to a {@link HavingFieldFilter}, the special
774
+ * top-level `_count` key (no field) filters on `COUNT(*)`, and `AND` / `OR` /
775
+ * `NOT` combine predicates at any depth.
757
776
  *
758
- * Implemented as a mapped type so the special `_count` key can carry a
759
- * {@link HavingFilter} while every entity field carries a
760
- * {@link HavingAggregateFilter}, without the index-signature conflict an
761
- * intersection type would produce when `T` is a broad `Record<string, unknown>`.
777
+ * Implemented as a mapped type so the special keys can carry their own value
778
+ * types while every entity field carries a {@link HavingFieldFilter}, without
779
+ * the index-signature conflict an intersection type would produce when `T` is
780
+ * a broad `Record<string, unknown>`.
762
781
  *
763
782
  * @example
764
783
  * // groups with more than 5 rows whose summed viewCount is at least 100
765
784
  * having: { _count: { gt: 5 }, viewCount: { _sum: { gte: 100 } } }
785
+ * @example
786
+ * // by: ['typeId'] , drop the NULL group, keep busy groups
787
+ * having: { typeId: { not: null }, _count: { gt: 1 } }
766
788
  */
767
789
  export type HavingClause<T> = {
768
790
  /** Filter on `COUNT(*)` for the whole group. */
769
791
  _count?: HavingFilter;
792
+ AND?: HavingClause<T> | HavingClause<T>[];
793
+ OR?: HavingClause<T>[];
794
+ NOT?: HavingClause<T> | HavingClause<T>[];
770
795
  } & {
771
- [K in keyof T & string]?: HavingAggregateFilter;
796
+ [K in keyof T & string]?: HavingFieldFilter<T[K]>;
772
797
  };
773
798
  /**
774
799
  * A JSON-path group key in {@link GroupByArgs.by}: groups by the value