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
|
@@ -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}
|
|
514
|
-
* interpolation of user
|
|
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
|
-
//
|
|
543
|
-
if (
|
|
544
|
-
|
|
545
|
-
|
|
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
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
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
|
|
577
|
-
* shorthand for equality.
|
|
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
|
-
|
|
581
|
-
|
|
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
|
-
/**
|
|
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;
|
|
@@ -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`
|
|
712
|
-
"`timestamptz`, but a zone-less `date`/`timestamp`
|
|
713
|
-
'rather than UTC (silently, since the read path shifts back by the same offset), and a
|
|
714
|
-
'column rejects the value outright (`22007 invalid input syntax for type time`).
|
|
715
|
-
'resolved, every `timestamptz` among them, are unaffected.
|
|
716
|
-
'`npx turbine generate`, or set the column types in
|
|
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
|
-
/**
|
|
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
|
|
1301
|
-
'to-one
|
|
1302
|
-
|
|
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
|
-
|
|
1309
|
-
//
|
|
1310
|
-
//
|
|
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 "${
|
|
1314
|
-
'
|
|
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
|
-
'
|
|
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:
|
|
2014
|
-
'
|
|
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
|
-
/**
|
|
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
|
-
|
|
2861
|
-
|
|
2862
|
-
|
|
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
|
-
*
|
|
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
|
|
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
|
|
727
|
-
equals?:
|
|
728
|
-
not?:
|
|
729
|
-
gt?:
|
|
730
|
-
gte?:
|
|
731
|
-
lt?:
|
|
732
|
-
lte?:
|
|
733
|
-
in?:
|
|
734
|
-
notIn?:
|
|
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
|
-
/**
|
|
737
|
-
export type
|
|
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
|
|
755
|
-
* to a {@link
|
|
756
|
-
*
|
|
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
|
|
759
|
-
*
|
|
760
|
-
*
|
|
761
|
-
*
|
|
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]?:
|
|
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
|