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.
- package/dist/cjs/client.js +152 -0
- package/dist/cjs/nested-write.js +51 -5
- package/dist/cjs/prisma-compat.d.ts +80 -2
- package/dist/cjs/prisma-compat.js +249 -74
- package/dist/cjs/query/aggregates.d.ts +33 -9
- package/dist/cjs/query/aggregates.js +180 -40
- package/dist/cjs/query/builder.d.ts +23 -2
- package/dist/cjs/query/builder.js +72 -45
- package/dist/cjs/query/types.d.ts +49 -24
- package/dist/cjs/query/utils.d.ts +30 -0
- package/dist/cjs/query/utils.js +35 -0
- package/dist/cjs/query/warn-registry.d.ts +7 -0
- package/dist/cjs/query/warn-registry.js +7 -0
- package/dist/cjs/query/writes.d.ts +5 -1
- package/dist/cjs/query/writes.js +31 -6
- package/dist/client.js +153 -1
- package/dist/nested-write.js +51 -5
- package/dist/prisma-compat.d.ts +80 -2
- package/dist/prisma-compat.js +249 -74
- package/dist/query/aggregates.d.ts +33 -9
- package/dist/query/aggregates.js +181 -41
- package/dist/query/builder.d.ts +23 -2
- package/dist/query/builder.js +74 -47
- package/dist/query/types.d.ts +49 -24
- package/dist/query/utils.d.ts +30 -0
- package/dist/query/utils.js +35 -1
- package/dist/query/warn-registry.d.ts +7 -0
- package/dist/query/warn-registry.js +7 -0
- package/dist/query/writes.d.ts +5 -1
- package/dist/query/writes.js +32 -7
- package/package.json +1 -1
package/dist/query/aggregates.js
CHANGED
|
@@ -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}
|
|
471
|
-
* interpolation of user
|
|
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
|
-
//
|
|
500
|
-
if (
|
|
501
|
-
|
|
502
|
-
|
|
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
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
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
|
|
534
|
-
* shorthand for equality.
|
|
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
|
-
|
|
538
|
-
|
|
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)) {
|
package/dist/query/builder.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
/**
|
|
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;
|
package/dist/query/builder.js
CHANGED
|
@@ -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 {
|
|
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`
|
|
676
|
-
"`timestamptz`, but a zone-less `date`/`timestamp`
|
|
677
|
-
'rather than UTC (silently, since the read path shifts back by the same offset), and a
|
|
678
|
-
'column rejects the value outright (`22007 invalid input syntax for type time`).
|
|
679
|
-
'resolved, every `timestamptz` among them, are unaffected.
|
|
680
|
-
'`npx turbine generate`, or set the column types in
|
|
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
|
-
/**
|
|
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
|
|
1265
|
-
'to-one
|
|
1266
|
-
|
|
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
|
-
|
|
1273
|
-
//
|
|
1274
|
-
//
|
|
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 "${
|
|
1278
|
-
'
|
|
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
|
-
'
|
|
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:
|
|
1978
|
-
'
|
|
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
|
-
/**
|
|
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
|
-
|
|
2825
|
-
|
|
2826
|
-
|
|
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 */
|