turbine-orm 0.31.0 → 0.32.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -19
- package/dist/cjs/cli/config.js +76 -16
- package/dist/cjs/cli/index.js +174 -19
- package/dist/cjs/client.js +16 -0
- package/dist/cjs/errors.js +62 -4
- package/dist/cjs/generate.js +2 -1
- package/dist/cjs/powql.js +39 -2
- package/dist/cjs/query/batched-loader.js +34 -0
- package/dist/cjs/query/builder.js +686 -121
- package/dist/cjs/query/filters.js +28 -0
- package/dist/cli/config.d.ts +53 -1
- package/dist/cli/config.js +73 -16
- package/dist/cli/index.d.ts +68 -0
- package/dist/cli/index.js +173 -21
- package/dist/client.js +16 -0
- package/dist/errors.d.ts +22 -2
- package/dist/errors.js +62 -4
- package/dist/generate.js +2 -1
- package/dist/index.d.ts +1 -1
- package/dist/powql.js +39 -2
- package/dist/query/batched-loader.d.ts +12 -0
- package/dist/query/batched-loader.js +33 -0
- package/dist/query/builder.d.ts +129 -0
- package/dist/query/builder.js +688 -123
- package/dist/query/deferred.d.ts +10 -1
- package/dist/query/filters.d.ts +13 -1
- package/dist/query/filters.js +27 -0
- package/dist/query/index.d.ts +1 -1
- package/dist/query/types.d.ts +148 -12
- package/package.json +3 -2
package/dist/query/builder.js
CHANGED
|
@@ -15,11 +15,110 @@ import { CircularRelationError, NotFoundError, OptimisticLockError, RelationErro
|
|
|
15
15
|
import { missingIndexForRelation } from '../index-advisor.js';
|
|
16
16
|
import { executeNestedCreate, executeNestedUpdate, hasRelationFields, } from '../nested-write.js';
|
|
17
17
|
import { camelToSnake, normalizeKeyColumns, snakeToCamel } from '../schema.js';
|
|
18
|
-
import { includeKeysForBatching, loadRelationsBatched, neededParentKeyFields, resolveCountRelations, stripFields, } from './batched-loader.js';
|
|
19
|
-
import { assertBindableEqualsOperand, findArrayUniqueKey, findJsonUniqueKey, fingerprintJsonFilterShape, fingerprintOperatorShape, isArrayFilter, isColumnRef, isJsonFilter, isJsonPathOrderBy, isOrderBySpec, isTextSearchFilter, isUnmatchedPlainObject, isVectorFilter, isVectorOrderBy, isWhereOperator, JSON_RANGE_OPERATORS, normalizeOrderBy, sortedEntries, sortedKeys, UPDATE_OPERATOR_KEYS, VECTOR_DISTANCE_COMPARATORS, VECTOR_METRIC_OPERATORS, validateTextSearchConfig, } from './filters.js';
|
|
18
|
+
import { includeKeysForBatching, loadRelationsBatched, neededParentKeyFields, rejectNestedPickOrder, resolveCountRelations, stripFields, } from './batched-loader.js';
|
|
19
|
+
import { assertBindableEqualsOperand, findArrayUniqueKey, findJsonUniqueKey, fingerprintJsonFilterShape, fingerprintOperatorShape, isArrayFilter, isColumnRef, isJsonFilter, isJsonPathOrderBy, isOrderBySpec, isRelationPickOrderBy, isTextSearchFilter, isUnmatchedPlainObject, isVectorFilter, isVectorOrderBy, isWhereOperator, JSON_RANGE_OPERATORS, normalizeOrderBy, sortedEntries, sortedKeys, UPDATE_OPERATOR_KEYS, VECTOR_DISTANCE_COMPARATORS, VECTOR_METRIC_OPERATORS, validateTextSearchConfig, } from './filters.js';
|
|
20
20
|
import { escapeLike, LRUCache, OPERATOR_KEYS, parseDbDate, sqlToPreparedName } from './utils.js';
|
|
21
21
|
/** Relations already warned about missing FK indexes (once per process, dev only). */
|
|
22
22
|
const unindexedRelationWarned = new Set();
|
|
23
|
+
/**
|
|
24
|
+
* Dev-mode SQL-cache lockstep cross-check gate.
|
|
25
|
+
*
|
|
26
|
+
* The SQL template cache requires three code paths to enumerate where-clause
|
|
27
|
+
* keys identically: `fingerprintWhere` (builds the cache key),
|
|
28
|
+
* `buildWhereClause` (builds SQL + `$N` params on a MISS), and
|
|
29
|
+
* `collectWhereParams` (re-collects params on a HIT without rebuilding). They
|
|
30
|
+
* are synchronized only by convention, and drift has shipped silent
|
|
31
|
+
* wrong-results bugs before (permuted where-key order; an orderBy fingerprint
|
|
32
|
+
* collision). This check catches such drift loudly the moment a cache HIT
|
|
33
|
+
* happens by rebuilding the SQL + params fresh and comparing them against what
|
|
34
|
+
* the cache-hit path produced.
|
|
35
|
+
*
|
|
36
|
+
* Enabled only when `NODE_ENV !== 'production'` (same convention as the other
|
|
37
|
+
* dev-only guards in this file) AND `TURBINE_DISABLE_CACHE_CHECK !== '1'`. The
|
|
38
|
+
* env vars are read inline (not captured once) so tests and perf-sensitive dev
|
|
39
|
+
* traffic can toggle them per process. In production the check never runs, so
|
|
40
|
+
* the hot path is unchanged.
|
|
41
|
+
*/
|
|
42
|
+
function cacheCrossCheckEnabled() {
|
|
43
|
+
return process.env.NODE_ENV !== 'production' && process.env.TURBINE_DISABLE_CACHE_CHECK !== '1';
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Strict structural equality for a single SQL parameter value. Handles the
|
|
47
|
+
* value shapes Turbine binds: primitives (incl. `NaN` and `bigint`), `null`/
|
|
48
|
+
* `undefined`, `Date` (by time), `Buffer`/typed arrays (by bytes), arrays
|
|
49
|
+
* (`in` lists, pgvector arrays), and plain objects (JSON filter payloads).
|
|
50
|
+
*/
|
|
51
|
+
function cacheParamValueEqual(a, b) {
|
|
52
|
+
if (a === b)
|
|
53
|
+
return true; // identical ref or equal primitive (covers matching null/undefined)
|
|
54
|
+
if (a === null || b === null || a === undefined || b === undefined)
|
|
55
|
+
return false;
|
|
56
|
+
const ta = typeof a;
|
|
57
|
+
if (ta !== typeof b)
|
|
58
|
+
return false;
|
|
59
|
+
if (ta !== 'object') {
|
|
60
|
+
// Primitives that failed `===`: only NaN is legitimately "equal" to itself.
|
|
61
|
+
return typeof a === 'number' && Number.isNaN(a) && Number.isNaN(b);
|
|
62
|
+
}
|
|
63
|
+
if (a instanceof Date || b instanceof Date) {
|
|
64
|
+
return a instanceof Date && b instanceof Date && a.getTime() === b.getTime();
|
|
65
|
+
}
|
|
66
|
+
const aView = ArrayBuffer.isView(a);
|
|
67
|
+
const bView = ArrayBuffer.isView(b);
|
|
68
|
+
if (aView || bView) {
|
|
69
|
+
if (!aView || !bView)
|
|
70
|
+
return false;
|
|
71
|
+
const ua = a;
|
|
72
|
+
const ub = b;
|
|
73
|
+
if (ua.byteLength !== ub.byteLength)
|
|
74
|
+
return false;
|
|
75
|
+
const va = new Uint8Array(ua.buffer, ua.byteOffset, ua.byteLength);
|
|
76
|
+
const vb = new Uint8Array(ub.buffer, ub.byteOffset, ub.byteLength);
|
|
77
|
+
for (let i = 0; i < va.length; i++) {
|
|
78
|
+
if (va[i] !== vb[i])
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
const aArr = Array.isArray(a);
|
|
84
|
+
const bArr = Array.isArray(b);
|
|
85
|
+
if (aArr || bArr) {
|
|
86
|
+
if (!aArr || !bArr)
|
|
87
|
+
return false;
|
|
88
|
+
const arrA = a;
|
|
89
|
+
const arrB = b;
|
|
90
|
+
if (arrA.length !== arrB.length)
|
|
91
|
+
return false;
|
|
92
|
+
for (let i = 0; i < arrA.length; i++) {
|
|
93
|
+
if (!cacheParamValueEqual(arrA[i], arrB[i]))
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
const objA = a;
|
|
99
|
+
const objB = b;
|
|
100
|
+
const keysA = Object.keys(objA);
|
|
101
|
+
const keysB = Object.keys(objB);
|
|
102
|
+
if (keysA.length !== keysB.length)
|
|
103
|
+
return false;
|
|
104
|
+
for (const k of keysA) {
|
|
105
|
+
if (!Object.hasOwn(objB, k))
|
|
106
|
+
return false;
|
|
107
|
+
if (!cacheParamValueEqual(objA[k], objB[k]))
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
/** Element-wise strict equality of two SQL parameter arrays. */
|
|
113
|
+
function cacheParamsEqual(a, b) {
|
|
114
|
+
if (a.length !== b.length)
|
|
115
|
+
return false;
|
|
116
|
+
for (let i = 0; i < a.length; i++) {
|
|
117
|
+
if (!cacheParamValueEqual(a[i], b[i]))
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
23
122
|
// biome-ignore lint/complexity/noBannedTypes: {} means "no relations known" — intentional for untyped table access
|
|
24
123
|
export class QueryInterface {
|
|
25
124
|
pool;
|
|
@@ -28,6 +127,15 @@ export class QueryInterface {
|
|
|
28
127
|
tableMeta;
|
|
29
128
|
/** SQL template cache: cacheKey → SqlCacheEntry (sql + prepared statement name) */
|
|
30
129
|
sqlTemplateCache = new LRUCache(1000);
|
|
130
|
+
/**
|
|
131
|
+
* Whether the most recent {@link acquireSql} call was a cache HIT. Read by
|
|
132
|
+
* {@link crossCheckCache} to decide whether to run the dev-mode lockstep
|
|
133
|
+
* cross-check. Safe as a single mutable flag: each `build*()` method calls
|
|
134
|
+
* `acquireSql` then `crossCheckCache` synchronously with no intervening
|
|
135
|
+
* `await` and no re-entrant `acquireSql` (relation subqueries are built
|
|
136
|
+
* inline, not through the top-level cache).
|
|
137
|
+
*/
|
|
138
|
+
lastCacheHit = false;
|
|
31
139
|
middlewares;
|
|
32
140
|
defaultLimit;
|
|
33
141
|
warnOnUnlimited;
|
|
@@ -291,6 +399,10 @@ export class QueryInterface {
|
|
|
291
399
|
*/
|
|
292
400
|
async runFindManyBatched(args) {
|
|
293
401
|
const withClause = args.with;
|
|
402
|
+
// Scope-rule parity with the join strategy (which throws at SQL build):
|
|
403
|
+
// reject nested pick-row ordering BEFORE the base query so acceptance
|
|
404
|
+
// never depends on how many rows come back.
|
|
405
|
+
rejectNestedPickOrder(withClause);
|
|
294
406
|
// Capture the opt-out from the ARGS before any await: this.currentSkip is
|
|
295
407
|
// instance state on a cached accessor, so a concurrent build during the
|
|
296
408
|
// base-query await would overwrite it (tenant query loading relations with
|
|
@@ -342,24 +454,84 @@ export class QueryInterface {
|
|
|
342
454
|
* On hit, increments counters and returns the cached entry.
|
|
343
455
|
*
|
|
344
456
|
* When `sqlCache` is disabled, always calls `build()` without caching.
|
|
457
|
+
*
|
|
458
|
+
* `build` receives a fresh `$N` param scratch array. On a miss those params
|
|
459
|
+
* are discarded (the returned params come from each call site's dedicated
|
|
460
|
+
* collect path); the array exists so the build path can number placeholders
|
|
461
|
+
* via `params.length` exactly as it does today. On a HIT, `build` is skipped
|
|
462
|
+
* here but re-run by {@link crossCheckCache} (dev only) with a fresh array to
|
|
463
|
+
* verify the collect path stayed in lockstep with the build path.
|
|
464
|
+
*
|
|
465
|
+
* Sets {@link lastCacheHit} so the caller's `crossCheckCache` knows whether a
|
|
466
|
+
* cross-check is warranted.
|
|
345
467
|
*/
|
|
346
468
|
acquireSql(cacheKey, build) {
|
|
347
469
|
if (!this.sqlCacheEnabled) {
|
|
348
|
-
|
|
470
|
+
this.lastCacheHit = false;
|
|
471
|
+
const sql = build([]);
|
|
349
472
|
this.cacheMisses++;
|
|
350
473
|
return { sql, name: sqlToPreparedName(sql) };
|
|
351
474
|
}
|
|
352
475
|
const cached = this.sqlTemplateCache.get(cacheKey);
|
|
353
476
|
if (cached) {
|
|
354
477
|
this.cacheHits++;
|
|
478
|
+
this.lastCacheHit = true;
|
|
355
479
|
return cached;
|
|
356
480
|
}
|
|
357
|
-
|
|
481
|
+
this.lastCacheHit = false;
|
|
482
|
+
const sql = build([]);
|
|
358
483
|
const entry = { sql, name: sqlToPreparedName(sql) };
|
|
359
484
|
this.sqlTemplateCache.set(cacheKey, entry);
|
|
360
485
|
this.cacheMisses++;
|
|
361
486
|
return entry;
|
|
362
487
|
}
|
|
488
|
+
/**
|
|
489
|
+
* Dev-mode SQL-cache lockstep cross-check (see {@link cacheCrossCheckEnabled}).
|
|
490
|
+
*
|
|
491
|
+
* Runs only when the most recent {@link acquireSql} was a cache HIT and the
|
|
492
|
+
* check is enabled. Rebuilds the SQL + `$N` params fresh via the same `build`
|
|
493
|
+
* closure the caller passed to `acquireSql`, then compares:
|
|
494
|
+
* (a) the cached SQL string byte-for-byte against the fresh SQL, and
|
|
495
|
+
* (b) the params the cache-hit collect path produced against the fresh
|
|
496
|
+
* build-path params (length and element-wise strict deep-equal).
|
|
497
|
+
*
|
|
498
|
+
* A mismatch means the fingerprint / build / collect paths have drifted out
|
|
499
|
+
* of lockstep (the exact class of bug that has silently corrupted results
|
|
500
|
+
* before), so it throws a {@link ValidationError} (E003) naming the
|
|
501
|
+
* fingerprint, the operation, and both SQL strings (truncated). Failing loud
|
|
502
|
+
* in dev/test is the point. Production never reaches the comparison.
|
|
503
|
+
*
|
|
504
|
+
* @param op human label of the calling build method (for the error message).
|
|
505
|
+
* @param cacheKey the cache fingerprint that HIT.
|
|
506
|
+
* @param entry the cached SQL entry that will be executed.
|
|
507
|
+
* @param build the same closure passed to `acquireSql`; re-run here to
|
|
508
|
+
* capture the fresh build-path SQL + params.
|
|
509
|
+
* @param collectedParams the params the caller's collect path produced.
|
|
510
|
+
*/
|
|
511
|
+
crossCheckCache(op, cacheKey, entry, build, collectedParams) {
|
|
512
|
+
if (!this.lastCacheHit)
|
|
513
|
+
return;
|
|
514
|
+
if (!cacheCrossCheckEnabled())
|
|
515
|
+
return;
|
|
516
|
+
const freshParams = [];
|
|
517
|
+
const freshSql = build(freshParams);
|
|
518
|
+
const sqlOk = freshSql === entry.sql;
|
|
519
|
+
const paramsOk = cacheParamsEqual(collectedParams, freshParams);
|
|
520
|
+
if (sqlOk && paramsOk)
|
|
521
|
+
return;
|
|
522
|
+
const truncate = (s) => (s.length > 300 ? `${s.slice(0, 300)}… (${s.length} chars total)` : s);
|
|
523
|
+
const details = [];
|
|
524
|
+
if (!sqlOk) {
|
|
525
|
+
details.push(`cached SQL and freshly-built SQL diverge:\n cached = <${truncate(entry.sql)}>\n fresh = <${truncate(freshSql)}>`);
|
|
526
|
+
}
|
|
527
|
+
if (!paramsOk) {
|
|
528
|
+
details.push(`cache-hit params and freshly-built params diverge (collected ${collectedParams.length}, built ${freshParams.length})`);
|
|
529
|
+
}
|
|
530
|
+
throw new ValidationError(`[turbine] SQL cache lockstep violation on ${op} (fingerprint "${cacheKey}"). ` +
|
|
531
|
+
`This is a Turbine internal invariant violation, please report it at ` +
|
|
532
|
+
`https://github.com/zvndev/turbine-orm/issues. The fingerprint, SQL-build, and ` +
|
|
533
|
+
`param-collect paths must enumerate where-clause keys identically.\n${details.join('\n')}`);
|
|
534
|
+
}
|
|
363
535
|
/**
|
|
364
536
|
* Reset the per-instance unlimited-query warning dedupe set.
|
|
365
537
|
* Exposed for tests so a single test process can verify the warning fires
|
|
@@ -516,6 +688,8 @@ export class QueryInterface {
|
|
|
516
688
|
*/
|
|
517
689
|
async runFindUniqueBatched(args) {
|
|
518
690
|
const withClause = args.with;
|
|
691
|
+
// Same scope-rule parity as runFindManyBatched: reject before querying.
|
|
692
|
+
rejectNestedPickOrder(withClause);
|
|
519
693
|
const needed = neededParentKeyFields(this.tableMeta, withClause);
|
|
520
694
|
const proj = includeKeysForBatching(args.select, args.omit, needed);
|
|
521
695
|
const baseArgs = { ...args, with: undefined, select: proj.select, omit: proj.omit };
|
|
@@ -556,19 +730,22 @@ export class QueryInterface {
|
|
|
556
730
|
});
|
|
557
731
|
// Simple path: plain equality, no operators/null/OR
|
|
558
732
|
if (!args.with && isSimpleWhere) {
|
|
559
|
-
const
|
|
733
|
+
const buildSql = (freshParams) => {
|
|
560
734
|
const qt = this.q(this.table);
|
|
561
|
-
const
|
|
562
|
-
|
|
735
|
+
const whereClauses = whereKeys.map((k, i) => {
|
|
736
|
+
freshParams.push(whereObj[k]);
|
|
737
|
+
return `${this.toSqlColumn(k)} = ${this.p(i + 1)}`;
|
|
738
|
+
});
|
|
563
739
|
const whereSql = whereClauses.length > 0 ? ` WHERE ${whereClauses.join(' AND ')}` : '';
|
|
564
740
|
const selectExpr = columnsList ? columnsList.map((c) => `${qt}.${this.q(c)}`).join(', ') : `${qt}.*`;
|
|
565
|
-
void tempParams; // params are positional, SQL is value-invariant
|
|
566
741
|
return `SELECT ${selectExpr} FROM ${qt}${whereSql}${this.limitOneClause()}`;
|
|
567
|
-
}
|
|
742
|
+
};
|
|
743
|
+
const entry = this.acquireSql(ck, buildSql);
|
|
568
744
|
// Collect params (same order as build)
|
|
569
745
|
for (const k of whereKeys) {
|
|
570
746
|
params.push(whereObj[k]);
|
|
571
747
|
}
|
|
748
|
+
this.crossCheckCache('findUnique', ck, entry, buildSql, params);
|
|
572
749
|
return {
|
|
573
750
|
sql: entry.sql,
|
|
574
751
|
params,
|
|
@@ -582,16 +759,17 @@ export class QueryInterface {
|
|
|
582
759
|
}
|
|
583
760
|
// General path (with operators, null, OR, with clause)
|
|
584
761
|
if (!args.with) {
|
|
585
|
-
const
|
|
586
|
-
const freshParams = [];
|
|
762
|
+
const buildSql = (freshParams) => {
|
|
587
763
|
const clause = this.buildWhereClause(whereObj, freshParams);
|
|
588
764
|
const whereSql = clause ? ` WHERE ${clause}` : '';
|
|
589
765
|
const qt = this.q(this.table);
|
|
590
766
|
const selectExpr = columnsList ? columnsList.map((c) => `${qt}.${this.q(c)}`).join(', ') : `${qt}.*`;
|
|
591
767
|
return `SELECT ${selectExpr} FROM ${qt}${whereSql}${this.limitOneClause()}`;
|
|
592
|
-
}
|
|
768
|
+
};
|
|
769
|
+
const entry = this.acquireSql(ck, buildSql);
|
|
593
770
|
// Collect params
|
|
594
771
|
this.collectWhereParams(whereObj, params);
|
|
772
|
+
this.crossCheckCache('findUnique', ck, entry, buildSql, params);
|
|
595
773
|
return {
|
|
596
774
|
sql: entry.sql,
|
|
597
775
|
params,
|
|
@@ -608,16 +786,17 @@ export class QueryInterface {
|
|
|
608
786
|
// 1. buildWhere pushes where params
|
|
609
787
|
// 2. buildSelectWithRelations pushes relation params to same array
|
|
610
788
|
// We must preserve this exact order.
|
|
611
|
-
const
|
|
612
|
-
const freshParams = [];
|
|
789
|
+
const buildSql = (freshParams) => {
|
|
613
790
|
const clause = this.buildWhereClause(whereObj, freshParams);
|
|
614
791
|
const whereSql = clause ? ` WHERE ${clause}` : '';
|
|
615
792
|
const selectClause = this.buildSelectWithRelations(this.table, args.with, freshParams, columnsList);
|
|
616
793
|
return `SELECT ${selectClause} FROM ${this.q(this.table)}${whereSql}${this.limitOneClause()}`;
|
|
617
|
-
}
|
|
794
|
+
};
|
|
795
|
+
const entry = this.acquireSql(ck, buildSql);
|
|
618
796
|
// Collect params in exact build order: where first, then with-clause relations
|
|
619
797
|
this.collectWhereParams(whereObj, params);
|
|
620
798
|
this.collectWithParams(args.with, params);
|
|
799
|
+
this.crossCheckCache('findUnique', ck, entry, buildSql, params);
|
|
621
800
|
const parseWith = this.makeNestedParser(args.with);
|
|
622
801
|
return {
|
|
623
802
|
sql: entry.sql,
|
|
@@ -682,7 +861,7 @@ export class QueryInterface {
|
|
|
682
861
|
if (this.warnedTables.has(this.table))
|
|
683
862
|
return;
|
|
684
863
|
this.warnedTables.add(this.table);
|
|
685
|
-
console.warn(`[turbine] warning: findMany on "${this.table}" has no limit
|
|
864
|
+
console.warn(`[turbine] warning: findMany on "${this.table}" has no limit: this will fetch every row. ` +
|
|
686
865
|
'Pass `limit`, or silence with `warnOnUnlimited: false` (per call, per table, or in config).');
|
|
687
866
|
}
|
|
688
867
|
/**
|
|
@@ -702,6 +881,21 @@ export class QueryInterface {
|
|
|
702
881
|
// biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
|
|
703
882
|
buildFindMany(args) {
|
|
704
883
|
this.currentSkip = args?.skipGlobalFilters;
|
|
884
|
+
// `distinct` + relation orderBy is refused up front (E003): the distinct
|
|
885
|
+
// path re-orders in an outer wrapper (`... AS "<table>_distinct" ORDER BY
|
|
886
|
+
// <userOrder>`) where a correlated relation subquery (pick-row, `_count`,
|
|
887
|
+
// to-one relation ordering) would reference the parent table name out of
|
|
888
|
+
// scope — a guaranteed "missing FROM-clause entry" crash on Postgres.
|
|
889
|
+
// Checked BEFORE the SQL cache so build and warm-cache paths throw
|
|
890
|
+
// identically (same rule as the vector guard inside the distinct branch).
|
|
891
|
+
if (args?.distinct && args.distinct.length > 0 && args.orderBy) {
|
|
892
|
+
for (const d of Object.values(args.orderBy)) {
|
|
893
|
+
if (this.isRelationOrderByValue(d)) {
|
|
894
|
+
throw new ValidationError('[turbine] `distinct` cannot be combined with relation orderBy (pick-row, `_count`, or ' +
|
|
895
|
+
'to-one relation ordering): the outer re-order cannot reference the parent table.');
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
}
|
|
705
899
|
const columnsList = this.resolveColumns(args?.select, args?.omit);
|
|
706
900
|
const colKey = columnsList ? columnsList.join(',') : '*';
|
|
707
901
|
// AND-merge this table's global filter into the user where; `hasWhere` gates
|
|
@@ -715,7 +909,7 @@ export class QueryInterface {
|
|
|
715
909
|
const withFp = args?.with ? this.withFingerprint(args.with) : '';
|
|
716
910
|
const orderFp = args?.orderBy
|
|
717
911
|
? Object.entries(args.orderBy)
|
|
718
|
-
.map(([k, d]) => `${k}:${this.orderByEntryFingerprint(d)}`)
|
|
912
|
+
.map(([k, d]) => `${k}:${this.orderByEntryFingerprint(d, this.tableMeta.relations[k]?.to)}`)
|
|
719
913
|
.join(',')
|
|
720
914
|
: '';
|
|
721
915
|
const cursorFp = args?.cursor
|
|
@@ -724,15 +918,24 @@ export class QueryInterface {
|
|
|
724
918
|
.sort()
|
|
725
919
|
.join(',')
|
|
726
920
|
: '';
|
|
727
|
-
|
|
921
|
+
// distinct must fingerprint in USER order: the SQL emits `DISTINCT ON` in
|
|
922
|
+
// the caller's column order, so a permuted array rebuilds different SQL and
|
|
923
|
+
// must not collapse onto the same cache entry (would trip the cross-check).
|
|
924
|
+
const distinctFp = args?.distinct ? args.distinct.join(',') : '';
|
|
728
925
|
const effectiveLimit = args?.take ?? args?.limit ?? this.defaultLimit;
|
|
729
|
-
|
|
730
|
-
|
|
926
|
+
// On engines that inline the literal LIMIT/OFFSET into the SQL text
|
|
927
|
+
// (dialect.inlineLimitOffset, MySQL), the value is part of the SQL, not the
|
|
928
|
+
// params, so it MUST be part of the fingerprint or two different limits share
|
|
929
|
+
// one cached statement (silent wrong row counts). Parameterized engines
|
|
930
|
+
// (PG/SQLite/SQL Server, whose buildLimitOffset uses placeholders) keep the
|
|
931
|
+
// presence-only fingerprint so the cache is not needlessly fragmented.
|
|
932
|
+
const inlinePagination = this.dialect.inlineLimitOffset === true;
|
|
933
|
+
const limitFp = effectiveLimit !== undefined ? (inlinePagination ? `v${effectiveLimit}` : '1') : '0';
|
|
934
|
+
const offsetFp = args?.offset !== undefined ? (inlinePagination ? `v${args.offset}` : '1') : '0';
|
|
731
935
|
const ck = `fm:${whereFp}|c=${colKey}|o=${orderFp}|l=${limitFp}|off=${offsetFp}|cur=${cursorFp}|d=${distinctFp}|w=${withFp}${this.globalFilterCacheSegment()}`;
|
|
732
936
|
const params = [];
|
|
733
|
-
const
|
|
734
|
-
// Fresh build
|
|
735
|
-
const freshParams = [];
|
|
937
|
+
const buildSql = (freshParams) => {
|
|
938
|
+
// Fresh build: generates SQL and populates freshParams
|
|
736
939
|
const { sql: freshWhereSql } = hasWhere
|
|
737
940
|
? (() => {
|
|
738
941
|
const clause = this.buildWhereClause(whereObj, freshParams);
|
|
@@ -812,7 +1015,8 @@ export class QueryInterface {
|
|
|
812
1015
|
}
|
|
813
1016
|
sql += this.buildPagination(limitPh, offsetPh, !!args?.orderBy);
|
|
814
1017
|
return sql;
|
|
815
|
-
}
|
|
1018
|
+
};
|
|
1019
|
+
const entry = this.acquireSql(ck, buildSql);
|
|
816
1020
|
// Collect params in exact build order:
|
|
817
1021
|
// 1. WHERE params (includes the AND-merged global filter, if any)
|
|
818
1022
|
if (hasWhere) {
|
|
@@ -843,6 +1047,7 @@ export class QueryInterface {
|
|
|
843
1047
|
if (args?.offset !== undefined && !this.dialect.inlineLimitOffset) {
|
|
844
1048
|
params.push(Number(args.offset));
|
|
845
1049
|
}
|
|
1050
|
+
this.crossCheckCache('findMany', ck, entry, buildSql, params);
|
|
846
1051
|
// Build the row parser once (positional shapes are computed here, not per row).
|
|
847
1052
|
const parseWith = args?.with ? this.makeNestedParser(args.with) : null;
|
|
848
1053
|
return {
|
|
@@ -1168,8 +1373,7 @@ export class QueryInterface {
|
|
|
1168
1373
|
const whereFp = this.fingerprintWhere(whereObj);
|
|
1169
1374
|
const ck = lock ? null : `u:${setFp}|${whereFp}${this.globalFilterCacheSegment()}`;
|
|
1170
1375
|
const params = [];
|
|
1171
|
-
const buildSql = () => {
|
|
1172
|
-
const freshParams = [];
|
|
1376
|
+
const buildSql = (freshParams) => {
|
|
1173
1377
|
const setEntries = Object.entries(dataObj).filter(([, v]) => v !== undefined);
|
|
1174
1378
|
const setClauses = setEntries.map(([k, v]) => this.buildSetClause(k, v, freshParams));
|
|
1175
1379
|
if (lock) {
|
|
@@ -1193,13 +1397,15 @@ export class QueryInterface {
|
|
|
1193
1397
|
};
|
|
1194
1398
|
let sql;
|
|
1195
1399
|
let preparedName;
|
|
1400
|
+
let cacheEntry;
|
|
1196
1401
|
if (ck) {
|
|
1197
|
-
|
|
1198
|
-
sql =
|
|
1199
|
-
preparedName =
|
|
1402
|
+
cacheEntry = this.acquireSql(ck, buildSql);
|
|
1403
|
+
sql = cacheEntry.sql;
|
|
1404
|
+
preparedName = cacheEntry.name;
|
|
1200
1405
|
}
|
|
1201
1406
|
else {
|
|
1202
|
-
|
|
1407
|
+
// optimisticLock path: value-variant version check → uncacheable, no cross-check.
|
|
1408
|
+
sql = buildSql([]);
|
|
1203
1409
|
}
|
|
1204
1410
|
// Collect params: SET first, then WHERE, then version check (same order as fresh build)
|
|
1205
1411
|
this.collectSetParams(dataObj, params);
|
|
@@ -1207,6 +1413,9 @@ export class QueryInterface {
|
|
|
1207
1413
|
if (lock) {
|
|
1208
1414
|
params.push(lock.expected);
|
|
1209
1415
|
}
|
|
1416
|
+
if (ck && cacheEntry) {
|
|
1417
|
+
this.crossCheckCache('update', ck, cacheEntry, buildSql, params);
|
|
1418
|
+
}
|
|
1210
1419
|
return {
|
|
1211
1420
|
sql,
|
|
1212
1421
|
params,
|
|
@@ -1338,8 +1547,7 @@ export class QueryInterface {
|
|
|
1338
1547
|
const whereFp = this.fingerprintWhere(whereObj);
|
|
1339
1548
|
const ck = `d:${whereFp}${this.globalFilterCacheSegment()}`;
|
|
1340
1549
|
const params = [];
|
|
1341
|
-
const
|
|
1342
|
-
const freshParams = [];
|
|
1550
|
+
const buildSql = (freshParams) => {
|
|
1343
1551
|
const clause = this.buildWhereClause(whereObj, freshParams);
|
|
1344
1552
|
const whereSql = clause ? ` WHERE ${clause}` : '';
|
|
1345
1553
|
// SQL Server injects `OUTPUT DELETED.*` between `DELETE FROM <t>` and WHERE;
|
|
@@ -1347,8 +1555,10 @@ export class QueryInterface {
|
|
|
1347
1555
|
return this.dialect.buildDeleteStatement
|
|
1348
1556
|
? this.dialect.buildDeleteStatement({ table: this.q(this.table), whereSql, returning: '*' })
|
|
1349
1557
|
: `DELETE FROM ${this.q(this.table)}${whereSql}${this.dialect.buildReturningClause('*')}`;
|
|
1350
|
-
}
|
|
1558
|
+
};
|
|
1559
|
+
const entry = this.acquireSql(ck, buildSql);
|
|
1351
1560
|
this.collectWhereParams(whereObj, params);
|
|
1561
|
+
this.crossCheckCache('delete', ck, entry, buildSql, params);
|
|
1352
1562
|
return {
|
|
1353
1563
|
sql: entry.sql,
|
|
1354
1564
|
params,
|
|
@@ -1476,16 +1686,17 @@ export class QueryInterface {
|
|
|
1476
1686
|
const whereFp = this.fingerprintWhere(whereObj);
|
|
1477
1687
|
const ck = `um:${setFp}|${whereFp}${this.globalFilterCacheSegment()}`;
|
|
1478
1688
|
const params = [];
|
|
1479
|
-
const
|
|
1480
|
-
const freshParams = [];
|
|
1689
|
+
const buildSql = (freshParams) => {
|
|
1481
1690
|
const setEntries = Object.entries(dataObj).filter(([, v]) => v !== undefined);
|
|
1482
1691
|
const setClauses = setEntries.map(([k, v]) => this.buildSetClause(k, v, freshParams));
|
|
1483
1692
|
const whereClause = this.buildWhereClause(whereObj, freshParams);
|
|
1484
1693
|
const whereSql = whereClause ? ` WHERE ${whereClause}` : '';
|
|
1485
1694
|
return `UPDATE ${this.q(this.table)} SET ${setClauses.join(', ')}${whereSql}`;
|
|
1486
|
-
}
|
|
1695
|
+
};
|
|
1696
|
+
const entry = this.acquireSql(ck, buildSql);
|
|
1487
1697
|
this.collectSetParams(dataObj, params);
|
|
1488
1698
|
this.collectWhereParams(whereObj, params);
|
|
1699
|
+
this.crossCheckCache('updateMany', ck, entry, buildSql, params);
|
|
1489
1700
|
return {
|
|
1490
1701
|
sql: entry.sql,
|
|
1491
1702
|
params,
|
|
@@ -1512,13 +1723,14 @@ export class QueryInterface {
|
|
|
1512
1723
|
const whereFp = this.fingerprintWhere(whereObj);
|
|
1513
1724
|
const ck = `dm:${whereFp}${this.globalFilterCacheSegment()}`;
|
|
1514
1725
|
const params = [];
|
|
1515
|
-
const
|
|
1516
|
-
const freshParams = [];
|
|
1726
|
+
const buildSql = (freshParams) => {
|
|
1517
1727
|
const clause = this.buildWhereClause(whereObj, freshParams);
|
|
1518
1728
|
const whereSql = clause ? ` WHERE ${clause}` : '';
|
|
1519
1729
|
return `DELETE FROM ${this.q(this.table)}${whereSql}`;
|
|
1520
|
-
}
|
|
1730
|
+
};
|
|
1731
|
+
const entry = this.acquireSql(ck, buildSql);
|
|
1521
1732
|
this.collectWhereParams(whereObj, params);
|
|
1733
|
+
this.crossCheckCache('deleteMany', ck, entry, buildSql, params);
|
|
1522
1734
|
return {
|
|
1523
1735
|
sql: entry.sql,
|
|
1524
1736
|
params,
|
|
@@ -1545,15 +1757,16 @@ export class QueryInterface {
|
|
|
1545
1757
|
const whereFp = hasWhere ? this.fingerprintWhere(whereObj) : '';
|
|
1546
1758
|
const ck = `cnt:${whereFp}${this.globalFilterCacheSegment()}`;
|
|
1547
1759
|
const params = [];
|
|
1548
|
-
const
|
|
1549
|
-
const freshParams = [];
|
|
1760
|
+
const buildSql = (freshParams) => {
|
|
1550
1761
|
const clause = hasWhere ? this.buildWhereClause(whereObj, freshParams) : null;
|
|
1551
1762
|
const whereSql = clause ? ` WHERE ${clause}` : '';
|
|
1552
1763
|
return `SELECT ${this.castAgg('COUNT(*)', 'int')} AS count FROM ${this.q(this.table)}${whereSql}`;
|
|
1553
|
-
}
|
|
1764
|
+
};
|
|
1765
|
+
const entry = this.acquireSql(ck, buildSql);
|
|
1554
1766
|
if (hasWhere) {
|
|
1555
1767
|
this.collectWhereParams(whereObj, params);
|
|
1556
1768
|
}
|
|
1769
|
+
this.crossCheckCache('count', ck, entry, buildSql, params);
|
|
1557
1770
|
return {
|
|
1558
1771
|
sql: entry.sql,
|
|
1559
1772
|
params,
|
|
@@ -1576,67 +1789,124 @@ export class QueryInterface {
|
|
|
1576
1789
|
const meta = this.schema.tables[this.table];
|
|
1577
1790
|
if (meta) {
|
|
1578
1791
|
for (const key of args.by) {
|
|
1579
|
-
if (!(key in meta.columnMap)) {
|
|
1792
|
+
if (typeof key === 'string' && !(key in meta.columnMap)) {
|
|
1580
1793
|
throw new ValidationError(`Unknown column "${key}" in groupBy for table "${this.table}"`);
|
|
1581
1794
|
}
|
|
1582
1795
|
}
|
|
1583
1796
|
}
|
|
1584
1797
|
this.currentSkip = args.skipGlobalFilters;
|
|
1585
|
-
const groupColsRaw = args.by.map((k) => this.toColumn(k));
|
|
1586
|
-
const groupCols = groupColsRaw.map((c) => this.q(c));
|
|
1587
1798
|
const gbWhere = this.mergeGlobalFilter(args.where);
|
|
1588
1799
|
const { sql: whereSql, params } = gbWhere
|
|
1589
1800
|
? this.buildWhere(gbWhere)
|
|
1590
1801
|
: { sql: '', params: [] };
|
|
1591
|
-
//
|
|
1592
|
-
|
|
1802
|
+
// Row source. Plain: `"table"<WHERE>`. With `distinctOn` (PostgreSQL
|
|
1803
|
+
// only), the groupBy runs over one representative row per column
|
|
1804
|
+
// combination: the wrapper carries args.where INSIDE it (filter before
|
|
1805
|
+
// picking) and is aliased as the table name so every outer expression is
|
|
1806
|
+
// byte-identical either way.
|
|
1807
|
+
const fromSql = args.distinctOn
|
|
1808
|
+
? this.buildDistinctOnSource(args.distinctOn, whereSql, params)
|
|
1809
|
+
: `${this.q(this.table)}${whereSql}`;
|
|
1810
|
+
// Group keys: plain columns and/or JSON-path keys. Output-name collisions
|
|
1811
|
+
// are rejected up front — and the check runs over the EMITTED SQL output
|
|
1812
|
+
// column names (snake_case column / JSON alias / `_agg_key` aggregate
|
|
1813
|
+
// alias), not just the given arg keys: the driver keeps only the LAST
|
|
1814
|
+
// duplicate field per row object, so a JSON alias equal to another key's
|
|
1815
|
+
// snake_case column (or an aggregate output alias) would silently clobber
|
|
1816
|
+
// that value in the results.
|
|
1817
|
+
const groupExprs = [];
|
|
1818
|
+
const selectExprs = [];
|
|
1819
|
+
/** by entries in order: how to read each group key off the result row. */
|
|
1820
|
+
const byReaders = [];
|
|
1821
|
+
const usedResultKeys = new Set();
|
|
1822
|
+
const claimResultKey = (key, what) => {
|
|
1823
|
+
if (key === '_count' || usedResultKeys.has(key)) {
|
|
1824
|
+
throw new ValidationError(`[turbine] groupBy output name "${key}" (${what}) collides with another output column on table ` +
|
|
1825
|
+
`"${this.table}": set an explicit \`alias\` (or rename the aggregate key) to disambiguate.`);
|
|
1826
|
+
}
|
|
1827
|
+
usedResultKeys.add(key);
|
|
1828
|
+
};
|
|
1829
|
+
for (const entry of args.by) {
|
|
1830
|
+
if (typeof entry === 'string') {
|
|
1831
|
+
const col = this.toColumn(entry);
|
|
1832
|
+
claimResultKey(entry, `column "${col}"`);
|
|
1833
|
+
// The emitted output column is the snake_case name; claim it too (when
|
|
1834
|
+
// it differs from the result key) so a JSON alias like 'created_at'
|
|
1835
|
+
// cannot silently shadow the 'createdAt' group key on the wire.
|
|
1836
|
+
if (col !== entry)
|
|
1837
|
+
claimResultKey(col, `column "${col}"`);
|
|
1838
|
+
groupExprs.push(this.q(col));
|
|
1839
|
+
selectExprs.push(this.q(col));
|
|
1840
|
+
byReaders.push({ resultKey: entry, rowKey: col, raw: false });
|
|
1841
|
+
}
|
|
1842
|
+
else {
|
|
1843
|
+
const col = this.resolveJsonPathTarget('group key', entry.field, entry.path);
|
|
1844
|
+
params.push(this.jsonPathParam(entry.path));
|
|
1845
|
+
const extract = this.dialect.buildJsonPathExtract(this.q(col), this.p(params.length));
|
|
1846
|
+
const alias = entry.alias ?? String(entry.path[entry.path.length - 1]);
|
|
1847
|
+
claimResultKey(alias, `JSON path on "${entry.field}"`);
|
|
1848
|
+
// Same expression (and the same $n placeholder) in SELECT and GROUP BY.
|
|
1849
|
+
selectExprs.push(`(${extract}) AS ${this.q(alias)}`);
|
|
1850
|
+
groupExprs.push(extract);
|
|
1851
|
+
byReaders.push({ resultKey: alias, rowKey: alias, raw: true });
|
|
1852
|
+
}
|
|
1853
|
+
}
|
|
1593
1854
|
// _count
|
|
1594
1855
|
if (args._count === true || args._count === undefined) {
|
|
1595
1856
|
// default: always include count
|
|
1596
1857
|
selectExprs.push(`${this.castAgg('COUNT(*)', 'int')} AS _count`);
|
|
1597
1858
|
}
|
|
1598
|
-
// _sum
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
selectExprs.push(`MIN(${this.q(col)}) AS ${this.q(`_min_${col}`)}`);
|
|
1859
|
+
// _sum / _avg / _min / _max: `true` keeps the plain-column behavior; a
|
|
1860
|
+
// {@link JsonPathAggregateTarget} aggregates a JSON path under the arg key
|
|
1861
|
+
// as alias. `jsonAggFields` routes each JSON-aggregate row key back to its
|
|
1862
|
+
// alias (and coercion kind) in the transform; `jsonAggExprs` lets HAVING
|
|
1863
|
+
// reuse the exact aggregate expression (same placeholders) by alias.
|
|
1864
|
+
const jsonAggFields = new Map();
|
|
1865
|
+
const jsonAggExprs = new Map();
|
|
1866
|
+
const buildAggregates = (aggKey, sqlFn, spec) => {
|
|
1867
|
+
if (!spec)
|
|
1868
|
+
return;
|
|
1869
|
+
for (const [key, target] of Object.entries(spec)) {
|
|
1870
|
+
if (!target)
|
|
1871
|
+
continue;
|
|
1872
|
+
if (target === true) {
|
|
1873
|
+
const col = this.toColumn(key);
|
|
1874
|
+
// Aggregate output aliases share the same output-name namespace as
|
|
1875
|
+
// the group keys: `_sum: { totalPrice: true, total_price: {json} }`
|
|
1876
|
+
// would emit two "_sum_total_price" columns and silently drop one.
|
|
1877
|
+
claimResultKey(`${aggKey}_${col}`, `${aggKey} of column "${col}"`);
|
|
1878
|
+
const inner = `${sqlFn}(${this.q(col)})`;
|
|
1879
|
+
const expr = aggKey === '_avg' ? this.castAgg(inner, 'float') : inner;
|
|
1880
|
+
selectExprs.push(`${expr} AS ${this.q(`${aggKey}_${col}`)}`);
|
|
1881
|
+
continue;
|
|
1622
1882
|
}
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
if (enabled) {
|
|
1629
|
-
const col = this.toColumn(field);
|
|
1630
|
-
selectExprs.push(`MAX(${this.q(col)}) AS ${this.q(`_max_${col}`)}`);
|
|
1883
|
+
const col = this.resolveJsonPathTarget(`${aggKey} target "${key}"`, target.field, target.path);
|
|
1884
|
+
const alwaysNumeric = aggKey === '_sum' || aggKey === '_avg';
|
|
1885
|
+
if (alwaysNumeric && target.type === 'text') {
|
|
1886
|
+
throw new ValidationError(`[turbine] groupBy ${aggKey} target "${key}" on table "${this.table}": ` +
|
|
1887
|
+
`${aggKey} over a JSON path is always numeric: remove \`type: 'text'\`.`);
|
|
1631
1888
|
}
|
|
1889
|
+
const numeric = alwaysNumeric || target.type === 'numeric';
|
|
1890
|
+
claimResultKey(`${aggKey}_${key}`, `${aggKey} JSON target "${key}"`);
|
|
1891
|
+
params.push(this.jsonPathParam(target.path));
|
|
1892
|
+
const extract = this.dialect.buildJsonPathExtract(this.q(col), this.p(params.length));
|
|
1893
|
+
const inner = `${sqlFn}(${numeric ? this.castJsonNumeric(extract) : extract})`;
|
|
1894
|
+
const expr = aggKey === '_avg' ? this.castAgg(inner, 'float') : inner;
|
|
1895
|
+
selectExprs.push(`${expr} AS ${this.q(`${aggKey}_${key}`)}`);
|
|
1896
|
+
jsonAggFields.set(`${aggKey}_${key}`, { field: key, numeric });
|
|
1897
|
+
jsonAggExprs.set(`${key}:${aggKey}`, expr);
|
|
1632
1898
|
}
|
|
1633
|
-
}
|
|
1634
|
-
|
|
1899
|
+
};
|
|
1900
|
+
buildAggregates('_sum', 'SUM', args._sum);
|
|
1901
|
+
buildAggregates('_avg', 'AVG', args._avg);
|
|
1902
|
+
buildAggregates('_min', 'MIN', args._min);
|
|
1903
|
+
buildAggregates('_max', 'MAX', args._max);
|
|
1904
|
+
let sql = `SELECT ${selectExprs.join(', ')} FROM ${fromSql} GROUP BY ${groupExprs.join(', ')}`;
|
|
1635
1905
|
// HAVING — filter whole groups by their aggregate values.
|
|
1636
1906
|
// Appends to the same `params` array, so placeholders continue from the
|
|
1637
1907
|
// WHERE clause's parameter positions (this.p(params.length) below).
|
|
1638
1908
|
if (args.having) {
|
|
1639
|
-
const havingClauses = this.buildHavingClauses(args.having, params);
|
|
1909
|
+
const havingClauses = this.buildHavingClauses(args.having, params, jsonAggExprs);
|
|
1640
1910
|
if (havingClauses.length > 0) {
|
|
1641
1911
|
sql += ` HAVING ${havingClauses.join(' AND ')}`;
|
|
1642
1912
|
}
|
|
@@ -1652,9 +1922,11 @@ export class QueryInterface {
|
|
|
1652
1922
|
const parsed = this.parseRow(row, this.table);
|
|
1653
1923
|
// Restructure aggregate results into nested objects (Prisma-style)
|
|
1654
1924
|
const restructured = {};
|
|
1655
|
-
// Copy group-by fields
|
|
1656
|
-
|
|
1657
|
-
|
|
1925
|
+
// Copy group-by fields. JSON-path keys read their alias off the raw
|
|
1926
|
+
// row (the alias is not a table column, so parseRow's snake→camel
|
|
1927
|
+
// mapping must not touch it).
|
|
1928
|
+
for (const reader of byReaders) {
|
|
1929
|
+
restructured[reader.resultKey] = reader.raw ? row[reader.rowKey] : parsed[reader.resultKey];
|
|
1658
1930
|
}
|
|
1659
1931
|
// _count
|
|
1660
1932
|
if ('_count' in row) {
|
|
@@ -1669,29 +1941,27 @@ export class QueryInterface {
|
|
|
1669
1941
|
const minObj = {};
|
|
1670
1942
|
const maxObj = {};
|
|
1671
1943
|
let hasSums = false, hasAvgs = false, hasMins = false, hasMaxs = false;
|
|
1944
|
+
// JSON-path aggregates keep their arg key verbatim; plain-column
|
|
1945
|
+
// aggregates keep the snake→camel field mapping.
|
|
1946
|
+
const jsonAgg = (rawKey) => jsonAggFields.get(rawKey);
|
|
1947
|
+
const fieldFor = (rawKey, col) => jsonAgg(rawKey)?.field ?? this.tableMeta.reverseColumnMap[col] ?? snakeToCamel(col);
|
|
1672
1948
|
for (const [rawKey, rawValue] of Object.entries(row)) {
|
|
1673
1949
|
if (rawKey.startsWith('_sum_')) {
|
|
1674
|
-
|
|
1675
|
-
const field = this.tableMeta.reverseColumnMap[col] ?? snakeToCamel(col);
|
|
1676
|
-
sumObj[field] = rawValue !== null ? Number(rawValue) : null;
|
|
1950
|
+
sumObj[fieldFor(rawKey, rawKey.slice(5))] = rawValue !== null ? Number(rawValue) : null;
|
|
1677
1951
|
hasSums = true;
|
|
1678
1952
|
}
|
|
1679
1953
|
else if (rawKey.startsWith('_avg_')) {
|
|
1680
|
-
|
|
1681
|
-
const field = this.tableMeta.reverseColumnMap[col] ?? snakeToCamel(col);
|
|
1682
|
-
avgObj[field] = rawValue !== null ? Number(rawValue) : null;
|
|
1954
|
+
avgObj[fieldFor(rawKey, rawKey.slice(5))] = rawValue !== null ? Number(rawValue) : null;
|
|
1683
1955
|
hasAvgs = true;
|
|
1684
1956
|
}
|
|
1685
1957
|
else if (rawKey.startsWith('_min_')) {
|
|
1686
|
-
const
|
|
1687
|
-
|
|
1688
|
-
minObj[field] = rawValue;
|
|
1958
|
+
const j = jsonAgg(rawKey);
|
|
1959
|
+
minObj[fieldFor(rawKey, rawKey.slice(5))] = j?.numeric && rawValue !== null ? Number(rawValue) : rawValue;
|
|
1689
1960
|
hasMins = true;
|
|
1690
1961
|
}
|
|
1691
1962
|
else if (rawKey.startsWith('_max_')) {
|
|
1692
|
-
const
|
|
1693
|
-
|
|
1694
|
-
maxObj[field] = rawValue;
|
|
1963
|
+
const j = jsonAgg(rawKey);
|
|
1964
|
+
maxObj[fieldFor(rawKey, rawKey.slice(5))] = j?.numeric && rawValue !== null ? Number(rawValue) : rawValue;
|
|
1695
1965
|
hasMaxs = true;
|
|
1696
1966
|
}
|
|
1697
1967
|
}
|
|
@@ -1708,6 +1978,75 @@ export class QueryInterface {
|
|
|
1708
1978
|
tag: `${this.table}.groupBy`,
|
|
1709
1979
|
};
|
|
1710
1980
|
}
|
|
1981
|
+
/**
|
|
1982
|
+
* Validate a JSON-path target (group key or aggregate target) in groupBy:
|
|
1983
|
+
* the field must resolve to a real json/jsonb column and the path must be a
|
|
1984
|
+
* non-empty array of keys/indexes. Returns the resolved snake_case column.
|
|
1985
|
+
*/
|
|
1986
|
+
resolveJsonPathTarget(context, field, path) {
|
|
1987
|
+
if (typeof field !== 'string') {
|
|
1988
|
+
throw new ValidationError(`[turbine] groupBy ${context} on table "${this.table}" requires a string \`field\`.`);
|
|
1989
|
+
}
|
|
1990
|
+
const col = this.toColumn(field);
|
|
1991
|
+
if (!Array.isArray(path) ||
|
|
1992
|
+
path.length === 0 ||
|
|
1993
|
+
path.some((el) => typeof el !== 'string' && !(typeof el === 'number' && Number.isFinite(el)))) {
|
|
1994
|
+
throw new ValidationError(`[turbine] groupBy ${context} on "${field}" (table "${this.table}") requires a non-empty \`path\` ` +
|
|
1995
|
+
`array of keys/indexes (e.g. { field: '${field}', path: ['category'] }).`);
|
|
1996
|
+
}
|
|
1997
|
+
const colType = this.pgTypeForColumn(this.tableMeta, col);
|
|
1998
|
+
if (!this.isJsonColumnType(colType)) {
|
|
1999
|
+
throw new ValidationError(`[turbine] groupBy ${context} on "${field}": column "${col}" on table "${this.table}" is not a JSON ` +
|
|
2000
|
+
`column (actual type: ${colType}).`);
|
|
2001
|
+
}
|
|
2002
|
+
return col;
|
|
2003
|
+
}
|
|
2004
|
+
/**
|
|
2005
|
+
* Build the `distinctOn` row source for groupBy (PostgreSQL only: other
|
|
2006
|
+
* engines throw {@link UnsupportedFeatureError} E017):
|
|
2007
|
+
*
|
|
2008
|
+
* ```sql
|
|
2009
|
+
* (SELECT DISTINCT ON ("c1") * FROM "table"<WHERE> ORDER BY "c1", <orderBy>) AS "table"
|
|
2010
|
+
* ```
|
|
2011
|
+
*
|
|
2012
|
+
* The wrapper is aliased as the table name so every outer expression (group
|
|
2013
|
+
* keys, aggregates, HAVING, ORDER BY) is byte-identical to the plain path.
|
|
2014
|
+
* `distinctOn.orderBy` is required (it decides which row survives) and
|
|
2015
|
+
* supports plain columns, {@link OrderBySpec} nulls, and JSON-path specs;
|
|
2016
|
+
* JSON paths push their text[] param here, after the WHERE params.
|
|
2017
|
+
*/
|
|
2018
|
+
buildDistinctOnSource(distinctOn, whereSql, params) {
|
|
2019
|
+
if (this.dialect.name !== 'postgresql') {
|
|
2020
|
+
throw new UnsupportedFeatureError('DISTINCT ON row source (groupBy distinctOn)', this.dialect.name, 'groupBy({ distinctOn }) requires PostgreSQL: SELECT DISTINCT ON is not portable.');
|
|
2021
|
+
}
|
|
2022
|
+
if (!Array.isArray(distinctOn.columns) || distinctOn.columns.length === 0) {
|
|
2023
|
+
throw new ValidationError(`[turbine] groupBy distinctOn on table "${this.table}" requires a non-empty \`columns\` array.`);
|
|
2024
|
+
}
|
|
2025
|
+
const orderEntries = Object.entries(distinctOn.orderBy ?? {});
|
|
2026
|
+
if (orderEntries.length === 0) {
|
|
2027
|
+
throw new ValidationError(`[turbine] groupBy distinctOn on table "${this.table}" requires \`orderBy\` to pick ONE row per ` +
|
|
2028
|
+
"column combination deterministically (e.g. orderBy: { createdAt: 'desc' }).");
|
|
2029
|
+
}
|
|
2030
|
+
const distinctCols = distinctOn.columns.map((c) => this.q(this.toColumn(c)));
|
|
2031
|
+
// DISTINCT ON expressions must lead the ORDER BY; the user's orderBy then
|
|
2032
|
+
// decides which row survives per combination.
|
|
2033
|
+
const orderParts = [...distinctCols];
|
|
2034
|
+
for (const [key, value] of orderEntries) {
|
|
2035
|
+
if (isJsonPathOrderBy(value)) {
|
|
2036
|
+
orderParts.push(this.buildJsonPathOrderEntry(this.table, this.tableMeta, key, value, '', params));
|
|
2037
|
+
continue;
|
|
2038
|
+
}
|
|
2039
|
+
if (isVectorOrderBy(value) || this.isRelationOrderByValue(value)) {
|
|
2040
|
+
throw new ValidationError(`[turbine] groupBy distinctOn.orderBy on "${key}" (table "${this.table}") supports plain columns, ` +
|
|
2041
|
+
'sort specs, and JSON-path orderings only.');
|
|
2042
|
+
}
|
|
2043
|
+
const col = this.resolveOrderByColumn(this.table, this.tableMeta, key);
|
|
2044
|
+
const { dir, nulls } = normalizeOrderBy(value);
|
|
2045
|
+
orderParts.push(`${this.q(col)} ${dir}${this.nullsSuffix(nulls)}`);
|
|
2046
|
+
}
|
|
2047
|
+
return (`(SELECT DISTINCT ON (${distinctCols.join(', ')}) * FROM ${this.q(this.table)}${whereSql} ` +
|
|
2048
|
+
`ORDER BY ${orderParts.join(', ')}) AS ${this.q(this.table)}`);
|
|
2049
|
+
}
|
|
1711
2050
|
/**
|
|
1712
2051
|
* Build the SQL fragments for a {@link HavingClause}.
|
|
1713
2052
|
*
|
|
@@ -1718,8 +2057,14 @@ export class QueryInterface {
|
|
|
1718
2057
|
* comparison value is pushed onto the shared `params` array and referenced by
|
|
1719
2058
|
* a `$N` placeholder via {@link buildHavingNumericClauses} — there is no string
|
|
1720
2059
|
* interpolation of user values.
|
|
2060
|
+
*
|
|
2061
|
+
* `jsonAggExprs` (from {@link buildGroupBy}) maps `alias:aggKey` to the
|
|
2062
|
+
* exact aggregate expression a JSON-path aggregate emitted in SELECT
|
|
2063
|
+
* (including its already-bound path placeholder), so HAVING on a JSON-path
|
|
2064
|
+
* aggregate alias reuses the same expression instead of resolving the alias
|
|
2065
|
+
* as a column.
|
|
1721
2066
|
*/
|
|
1722
|
-
buildHavingClauses(having, params) {
|
|
2067
|
+
buildHavingClauses(having, params, jsonAggExprs) {
|
|
1723
2068
|
const clauses = [];
|
|
1724
2069
|
// Maps the per-field aggregate key to its SQL function name. The set of
|
|
1725
2070
|
// allowed keys is fixed here — any other key on a field's filter object is
|
|
@@ -1746,8 +2091,14 @@ export class QueryInterface {
|
|
|
1746
2091
|
}
|
|
1747
2092
|
// toColumn validates the field against schema metadata (throws
|
|
1748
2093
|
// ValidationError on unknown columns) and q() quotes the identifier — no
|
|
1749
|
-
// unvalidated identifier ever reaches the SQL string.
|
|
1750
|
-
|
|
2094
|
+
// unvalidated identifier ever reaches the SQL string. Resolution is lazy:
|
|
2095
|
+
// a JSON-path aggregate alias is not a column, so it must not hit
|
|
2096
|
+
// toColumn when every aggregate under it resolves via `jsonAggExprs`.
|
|
2097
|
+
let quotedCol = null;
|
|
2098
|
+
const columnExpr = () => {
|
|
2099
|
+
quotedCol ??= this.q(this.toColumn(key));
|
|
2100
|
+
return quotedCol;
|
|
2101
|
+
};
|
|
1751
2102
|
for (const [aggKey, filter] of Object.entries(value)) {
|
|
1752
2103
|
if (filter === undefined)
|
|
1753
2104
|
continue;
|
|
@@ -1756,7 +2107,7 @@ export class QueryInterface {
|
|
|
1756
2107
|
throw new ValidationError(`[turbine] Unknown aggregate "${aggKey}" in having for field "${key}" on table "${this.table}". ` +
|
|
1757
2108
|
`Supported: ${Object.keys(aggFnByKey).join(', ')}.`);
|
|
1758
2109
|
}
|
|
1759
|
-
const expr = `${fn}(${
|
|
2110
|
+
const expr = jsonAggExprs?.get(`${key}:${aggKey}`) ?? `${fn}(${columnExpr()})`;
|
|
1760
2111
|
clauses.push(...this.buildHavingNumericClauses(expr, filter, params));
|
|
1761
2112
|
}
|
|
1762
2113
|
}
|
|
@@ -2403,7 +2754,7 @@ export class QueryInterface {
|
|
|
2403
2754
|
// JSONB filter
|
|
2404
2755
|
if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
|
|
2405
2756
|
const colType = this.getColumnPgType(rawColumn);
|
|
2406
|
-
if (colType
|
|
2757
|
+
if (this.isJsonColumnType(colType)) {
|
|
2407
2758
|
this.collectJsonFilterParams(value, params, this.q(rawColumn));
|
|
2408
2759
|
continue;
|
|
2409
2760
|
}
|
|
@@ -2514,7 +2865,7 @@ export class QueryInterface {
|
|
|
2514
2865
|
// the target column is json/jsonb.
|
|
2515
2866
|
if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
|
|
2516
2867
|
const colType = this.pgTypeForColumn(meta, col);
|
|
2517
|
-
if (colType
|
|
2868
|
+
if (this.isJsonColumnType(colType)) {
|
|
2518
2869
|
this.collectJsonFilterParams(value, params, `${this.q(targetTable)}.${this.q(col)}`);
|
|
2519
2870
|
continue;
|
|
2520
2871
|
}
|
|
@@ -2584,7 +2935,8 @@ export class QueryInterface {
|
|
|
2584
2935
|
let pathPushed = false;
|
|
2585
2936
|
const pushPathOnce = () => {
|
|
2586
2937
|
if (!pathPushed) {
|
|
2587
|
-
|
|
2938
|
+
// Only reached when a path-requiring clause validated filter.path.
|
|
2939
|
+
params.push(this.jsonPathParam(filter.path, filter.path));
|
|
2588
2940
|
pathPushed = true;
|
|
2589
2941
|
}
|
|
2590
2942
|
};
|
|
@@ -2636,15 +2988,20 @@ export class QueryInterface {
|
|
|
2636
2988
|
// then the path bound as one text[] param.
|
|
2637
2989
|
if (isJsonPathOrderBy(dir)) {
|
|
2638
2990
|
this.validateJsonPathOrderBy(this.table, this.tableMeta, key, dir);
|
|
2639
|
-
params.push(dir.path
|
|
2991
|
+
params.push(this.jsonPathParam(dir.path));
|
|
2640
2992
|
continue;
|
|
2641
2993
|
}
|
|
2642
2994
|
// To-many relation orderBy (`{ posts: { _count } }`) uses the same count
|
|
2643
|
-
// subquery as `_count
|
|
2644
|
-
//
|
|
2995
|
+
// subquery as `_count`: mirror its global-filter params. Pick-row
|
|
2996
|
+
// ordering mirrors its full param chain (by-path / global filter /
|
|
2997
|
+
// pick.where / pick.orderBy paths). To-one relation orderBy carries the
|
|
2998
|
+
// target's global filter once per ordered column.
|
|
2645
2999
|
if (this.isRelationOrderByValue(dir)) {
|
|
2646
3000
|
const relDef = this.tableMeta.relations[key];
|
|
2647
|
-
if (relDef && (
|
|
3001
|
+
if (relDef && isRelationPickOrderBy(dir)) {
|
|
3002
|
+
this.collectRelationPickOrderParams(key, relDef, dir, params);
|
|
3003
|
+
}
|
|
3004
|
+
else if (relDef && (relDef.type === 'hasMany' || relDef.type === 'manyToMany')) {
|
|
2648
3005
|
this.collectRelationCountParams(relDef, params);
|
|
2649
3006
|
}
|
|
2650
3007
|
else if (relDef) {
|
|
@@ -2734,12 +3091,15 @@ export class QueryInterface {
|
|
|
2734
3091
|
}
|
|
2735
3092
|
// orderBy shape (OrderBySpec nulls placement changes the SQL, so fingerprint it)
|
|
2736
3093
|
if (opts.orderBy) {
|
|
2737
|
-
const
|
|
3094
|
+
const targetRels = this.schema.tables[relDef.to]?.relations;
|
|
3095
|
+
const oEntries = Object.entries(opts.orderBy).map(([k, d]) => `${k}:${this.orderByEntryFingerprint(d, targetRels?.[k]?.to)}`);
|
|
2738
3096
|
subParts.push(`o=${oEntries.join(',')}`);
|
|
2739
3097
|
}
|
|
2740
|
-
// limit presence
|
|
3098
|
+
// limit presence, but on inline-pagination engines (MySQL) the literal
|
|
3099
|
+
// value is baked into the subquery SQL, so fingerprint the value there or
|
|
3100
|
+
// `{limit:3}` and `{limit:5}` would share one cached statement.
|
|
2741
3101
|
if (opts.limit !== undefined) {
|
|
2742
|
-
subParts.push('l=1');
|
|
3102
|
+
subParts.push(this.dialect.inlineLimitOffset ? `l=${opts.limit}` : 'l=1');
|
|
2743
3103
|
}
|
|
2744
3104
|
// nested with (recurse)
|
|
2745
3105
|
if (opts.with) {
|
|
@@ -3144,7 +3504,7 @@ export class QueryInterface {
|
|
|
3144
3504
|
// Handle JSONB filter operators (for json/jsonb columns)
|
|
3145
3505
|
if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
|
|
3146
3506
|
const colType = this.getColumnPgType(rawColumn);
|
|
3147
|
-
if (colType
|
|
3507
|
+
if (this.isJsonColumnType(colType)) {
|
|
3148
3508
|
const jsonClauses = this.buildJsonFilterClauses(column, value, params);
|
|
3149
3509
|
andClauses.push(...jsonClauses);
|
|
3150
3510
|
continue;
|
|
@@ -3359,7 +3719,7 @@ export class QueryInterface {
|
|
|
3359
3719
|
// jsonb value, silently matching nothing.
|
|
3360
3720
|
if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
|
|
3361
3721
|
const colType = this.pgTypeForColumn(meta, col);
|
|
3362
|
-
if (colType
|
|
3722
|
+
if (this.isJsonColumnType(colType)) {
|
|
3363
3723
|
conditions.push(...this.buildJsonFilterClauses(qCol, value, params));
|
|
3364
3724
|
continue;
|
|
3365
3725
|
}
|
|
@@ -3459,7 +3819,7 @@ export class QueryInterface {
|
|
|
3459
3819
|
assertBindableEqualityValue(rawColumn, value, columnPgType, table) {
|
|
3460
3820
|
if (!isUnmatchedPlainObject(value))
|
|
3461
3821
|
return;
|
|
3462
|
-
if (columnPgType
|
|
3822
|
+
if (this.isJsonColumnType(columnPgType))
|
|
3463
3823
|
return;
|
|
3464
3824
|
const badKeys = Object.keys(value);
|
|
3465
3825
|
throw new ValidationError(badKeys.length === 0
|
|
@@ -3530,7 +3890,7 @@ export class QueryInterface {
|
|
|
3530
3890
|
// bound as a plain equality value.
|
|
3531
3891
|
if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
|
|
3532
3892
|
const colType = this.pgTypeForColumn(targetMeta, col);
|
|
3533
|
-
if (colType
|
|
3893
|
+
if (this.isJsonColumnType(colType)) {
|
|
3534
3894
|
clauses.push(...this.buildJsonFilterClauses(qCol, value, params));
|
|
3535
3895
|
continue;
|
|
3536
3896
|
}
|
|
@@ -3603,7 +3963,7 @@ export class QueryInterface {
|
|
|
3603
3963
|
// JSONB filter — mirrors buildAliasWhere.
|
|
3604
3964
|
if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
|
|
3605
3965
|
const colType = this.pgTypeForColumn(targetMeta, col);
|
|
3606
|
-
if (colType
|
|
3966
|
+
if (this.isJsonColumnType(colType)) {
|
|
3607
3967
|
this.collectJsonFilterParams(value, params, this.q(col));
|
|
3608
3968
|
continue;
|
|
3609
3969
|
}
|
|
@@ -3836,7 +4196,7 @@ export class QueryInterface {
|
|
|
3836
4196
|
* vs relation-column never collide on one cached SQL string. Captures the
|
|
3837
4197
|
* SQL-shaping bits (direction, nulls, metric, relation keys) — never values.
|
|
3838
4198
|
*/
|
|
3839
|
-
orderByEntryFingerprint(d) {
|
|
4199
|
+
orderByEntryFingerprint(d, targetTable) {
|
|
3840
4200
|
// Vector KNN ordering changes the emitted operator by metric and adds a
|
|
3841
4201
|
// `::vector` param, so metric + direction must be part of the cache key.
|
|
3842
4202
|
if (isVectorOrderBy(d)) {
|
|
@@ -3847,13 +4207,36 @@ export class QueryInterface {
|
|
|
3847
4207
|
if (isJsonPathOrderBy(d)) {
|
|
3848
4208
|
return `jp(${d.direction ?? 'asc'},${d.type === 'numeric' ? 'num' : 'text'},${d.nulls ?? ''})`;
|
|
3849
4209
|
}
|
|
4210
|
+
// Pick-row relation ordering: the by-shape (column vs JSON path vs cast),
|
|
4211
|
+
// direction, nulls, pick.orderBy shape, and pick.where SHAPE are all SQL
|
|
4212
|
+
// text; the JSON paths and pick.where values are bound params and stay OUT
|
|
4213
|
+
// of the key. `targetTable` (the relation's target, resolved by the
|
|
4214
|
+
// caller) lets the pick.where fingerprint distinguish relation-filter
|
|
4215
|
+
// shapes inside it: two pick.wheres that differ only in shape must never
|
|
4216
|
+
// share one cached SQL string.
|
|
4217
|
+
if (isRelationPickOrderBy(d)) {
|
|
4218
|
+
const by = typeof d.by === 'string'
|
|
4219
|
+
? `col=${JSON.stringify(d.by)}`
|
|
4220
|
+
: `jp(${JSON.stringify(d.by?.field)},${d.by?.type === 'numeric' ? 'num' : 'text'})`;
|
|
4221
|
+
const pickOrder = Object.entries(d.pick?.orderBy ?? {})
|
|
4222
|
+
.map(([k, v]) => `${k}:${this.orderByEntryFingerprint(v)}`)
|
|
4223
|
+
.join(',');
|
|
4224
|
+
const pickWhere = d.pick?.where
|
|
4225
|
+
? `;pw=${this.fingerprintAliasWhere(d.pick.where, targetTable)}`
|
|
4226
|
+
: '';
|
|
4227
|
+
return `pick(${by},${d.direction ?? 'asc'},${d.nulls ?? ''};po=${pickOrder}${pickWhere})`;
|
|
4228
|
+
}
|
|
3850
4229
|
if (isOrderBySpec(d))
|
|
3851
4230
|
return `spec(${d.sort},${d.nulls ?? ''})`;
|
|
3852
4231
|
if (d && typeof d === 'object') {
|
|
3853
4232
|
// Relation ordering (`{ _count: 'desc' }` or `{ name: 'asc' }`).
|
|
4233
|
+
// INSERTION order, never sorted: the compile side (buildRelationOrderBy)
|
|
4234
|
+
// emits one ORDER BY term per entry in Object.entries order, so entry
|
|
4235
|
+
// order is SQL-shaping precedence. A sorted fingerprint made
|
|
4236
|
+
// `{ name: 'asc', email: 'desc' }` and the swapped literal share one
|
|
4237
|
+
// cached SQL string — silently mis-ordered results on a warm cache.
|
|
3854
4238
|
return `rel(${Object.entries(d)
|
|
3855
4239
|
.map(([k, v]) => `${k}=${this.orderByEntryFingerprint(v)}`)
|
|
3856
|
-
.sort()
|
|
3857
4240
|
.join(',')})`;
|
|
3858
4241
|
}
|
|
3859
4242
|
return String(d);
|
|
@@ -3971,7 +4354,7 @@ export class QueryInterface {
|
|
|
3971
4354
|
`of keys/indexes (e.g. { path: ['weight'], direction: 'asc' }).`);
|
|
3972
4355
|
}
|
|
3973
4356
|
const colType = this.pgTypeForColumn(meta, col);
|
|
3974
|
-
if (colType
|
|
4357
|
+
if (!this.isJsonColumnType(colType)) {
|
|
3975
4358
|
throw new ValidationError(`[turbine] JSON-path orderBy on "${field}": column "${col}" on table "${table}" is not a JSON column ` +
|
|
3976
4359
|
`(actual type: ${colType}).`);
|
|
3977
4360
|
}
|
|
@@ -3991,7 +4374,7 @@ export class QueryInterface {
|
|
|
3991
4374
|
if (!params) {
|
|
3992
4375
|
throw new ValidationError(`[turbine] JSON-path ordering on "${field}" is not supported in this orderBy context.`);
|
|
3993
4376
|
}
|
|
3994
|
-
params.push(spec.path
|
|
4377
|
+
params.push(this.jsonPathParam(spec.path));
|
|
3995
4378
|
const extract = this.dialect.buildJsonPathExtract(`${prefix}${this.q(col)}`, this.p(params.length));
|
|
3996
4379
|
const lhs = spec.type === 'numeric' ? this.castJsonNumeric(extract) : extract;
|
|
3997
4380
|
const dir = spec.direction?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
|
@@ -4019,12 +4402,21 @@ export class QueryInterface {
|
|
|
4019
4402
|
throw new RelationError(`[turbine] Unknown relation "${relName}" in orderBy on table "${ownerTable}". ` +
|
|
4020
4403
|
`Available: ${Object.keys(ownerMeta.relations).join(', ')}`);
|
|
4021
4404
|
}
|
|
4405
|
+
// Pick-row ordering (`{ pick, by }`): order by a value from ONE related
|
|
4406
|
+
// row: a correlated scalar subquery with its own ORDER BY … LIMIT 1.
|
|
4407
|
+
// Top-level findMany only (`ctx` present means we are inside a relation
|
|
4408
|
+
// subquery's orderBy) and hasMany only: validatePickOrderBy throws the
|
|
4409
|
+
// scope errors, shared with the cache-hit collect mirror.
|
|
4410
|
+
if (isRelationPickOrderBy(value)) {
|
|
4411
|
+
this.validatePickOrderBy(relName, relDef, value, ctx !== undefined);
|
|
4412
|
+
return this.buildRelationPickOrderBy(relName, relDef, value, alias, parentRef, params);
|
|
4413
|
+
}
|
|
4022
4414
|
// To-many: only `_count` is meaningful → correlated COUNT(*) subquery.
|
|
4023
4415
|
if (relDef.type === 'hasMany' || relDef.type === 'manyToMany') {
|
|
4024
4416
|
const keys = Object.keys(value);
|
|
4025
4417
|
if (keys.length !== 1 || keys[0] !== '_count') {
|
|
4026
4418
|
throw new ValidationError(`[turbine] orderBy on to-many relation "${relName}" only supports "_count" ` +
|
|
4027
|
-
`(got: ${keys.join(', ') || '(empty)'}).`);
|
|
4419
|
+
`or a pick-row ordering ({ pick, by }) (got: ${keys.join(', ') || '(empty)'}).`);
|
|
4028
4420
|
}
|
|
4029
4421
|
const { dir } = normalizeOrderBy(value._count);
|
|
4030
4422
|
return `${this.buildRelationCountExpr(relDef, parentRef, alias, params)} ${dir}`;
|
|
@@ -4065,6 +4457,145 @@ export class QueryInterface {
|
|
|
4065
4457
|
})
|
|
4066
4458
|
.join(', ');
|
|
4067
4459
|
}
|
|
4460
|
+
/**
|
|
4461
|
+
* Validate a {@link RelationPickOrderBy} entry's scope and shape. Shared by
|
|
4462
|
+
* the SQL-build path ({@link buildRelationPickOrderBy}) and the cache-hit
|
|
4463
|
+
* param-collect mirror ({@link collectRelationPickOrderParams}) so both
|
|
4464
|
+
* always throw identically:
|
|
4465
|
+
*
|
|
4466
|
+
* - `nested` (inside a relation subquery's orderBy or a pick.orderBy):
|
|
4467
|
+
* top-level findMany only in this release (E003),
|
|
4468
|
+
* - manyToMany: not supported (E003 naming the limitation),
|
|
4469
|
+
* - to-one: order by the target column directly instead (E003),
|
|
4470
|
+
* - `pick.orderBy` is REQUIRED (deterministic row choice),
|
|
4471
|
+
* - `by` must be a target column name or a `{ field, path }` JSON-path spec.
|
|
4472
|
+
*/
|
|
4473
|
+
pickOrderNestedError(relName) {
|
|
4474
|
+
return new ValidationError(`[turbine] Pick-row ordering on relation "${relName}" is only supported in a top-level ` +
|
|
4475
|
+
'findMany orderBy: nested `with` orderBy does not support it.');
|
|
4476
|
+
}
|
|
4477
|
+
validatePickOrderBy(relName, relDef, spec, nested) {
|
|
4478
|
+
if (nested) {
|
|
4479
|
+
throw this.pickOrderNestedError(relName);
|
|
4480
|
+
}
|
|
4481
|
+
if (relDef.type === 'manyToMany') {
|
|
4482
|
+
throw new ValidationError(`[turbine] Pick-row ordering is not supported on manyToMany relation "${relName}": ` +
|
|
4483
|
+
'hasMany relations only.');
|
|
4484
|
+
}
|
|
4485
|
+
if (relDef.type !== 'hasMany') {
|
|
4486
|
+
throw new ValidationError(`[turbine] Pick-row ordering is only for to-many (hasMany) relations; "${relName}" is ${relDef.type}. ` +
|
|
4487
|
+
`Order by the target column directly instead ({ ${relName}: { <column>: 'asc' } }).`);
|
|
4488
|
+
}
|
|
4489
|
+
const pickOrder = spec.pick?.orderBy;
|
|
4490
|
+
if (typeof spec.pick !== 'object' ||
|
|
4491
|
+
spec.pick === null ||
|
|
4492
|
+
typeof pickOrder !== 'object' ||
|
|
4493
|
+
pickOrder === null ||
|
|
4494
|
+
Object.keys(pickOrder).length === 0) {
|
|
4495
|
+
throw new ValidationError(`[turbine] Pick-row ordering on relation "${relName}" requires \`pick.orderBy\` to choose ONE ` +
|
|
4496
|
+
"related row deterministically (e.g. pick: { orderBy: { createdAt: 'desc' } }).");
|
|
4497
|
+
}
|
|
4498
|
+
const by = spec.by;
|
|
4499
|
+
const validJsonBy = typeof by === 'object' && by !== null && typeof by.field === 'string' && Array.isArray(by.path);
|
|
4500
|
+
if (typeof by !== 'string' && !validJsonBy) {
|
|
4501
|
+
throw new ValidationError(`[turbine] Pick-row ordering on relation "${relName}" requires \`by\`: a target column name ` +
|
|
4502
|
+
"or a JSON-path spec ({ field: 'data', path: ['title'] }).");
|
|
4503
|
+
}
|
|
4504
|
+
}
|
|
4505
|
+
/**
|
|
4506
|
+
* Compile a {@link RelationPickOrderBy} term: a correlated scalar subquery
|
|
4507
|
+
* that picks ONE related row (`ORDER BY <pick.orderBy> LIMIT 1`, optionally
|
|
4508
|
+
* filtered by `pick.where` and the target's global filter) and surfaces one
|
|
4509
|
+
* value from it (a plain target column or a JSON-path extraction) as the
|
|
4510
|
+
* parent ORDER BY key:
|
|
4511
|
+
*
|
|
4512
|
+
* ```sql
|
|
4513
|
+
* (SELECT ord0."data" #>> $1::text[] FROM "versions" ord0
|
|
4514
|
+
* WHERE ord0."instance_id" = "instances"."id" AND ord0."is_current" = $2
|
|
4515
|
+
* ORDER BY ord0."created_at" DESC LIMIT 1) ASC NULLS LAST
|
|
4516
|
+
* ```
|
|
4517
|
+
*
|
|
4518
|
+
* Param-push order (mirrored EXACTLY by
|
|
4519
|
+
* {@link collectRelationPickOrderParams}): `by` JSON path (if any) →
|
|
4520
|
+
* target global filter → `pick.where` → `pick.orderBy` JSON paths.
|
|
4521
|
+
*/
|
|
4522
|
+
buildRelationPickOrderBy(relName, relDef, spec, alias, parentRef, params) {
|
|
4523
|
+
if (!params) {
|
|
4524
|
+
throw new ValidationError(`[turbine] Pick-row ordering on relation "${relName}" is only supported in a top-level findMany orderBy.`);
|
|
4525
|
+
}
|
|
4526
|
+
const targetMeta = this.schema.tables[relDef.to];
|
|
4527
|
+
if (!targetMeta)
|
|
4528
|
+
throw new RelationError(`[turbine] Unknown relation target "${relDef.to}"`);
|
|
4529
|
+
// The value surfaced from the picked row (SELECT list: its param binds first).
|
|
4530
|
+
let byExpr;
|
|
4531
|
+
if (typeof spec.by === 'string') {
|
|
4532
|
+
const col = this.resolveOrderByColumn(relDef.to, targetMeta, spec.by);
|
|
4533
|
+
byExpr = `${alias}.${this.q(col)}`;
|
|
4534
|
+
}
|
|
4535
|
+
else {
|
|
4536
|
+
const col = this.validateJsonPathOrderBy(relDef.to, targetMeta, spec.by.field, {
|
|
4537
|
+
path: spec.by.path,
|
|
4538
|
+
});
|
|
4539
|
+
params.push(this.jsonPathParam(spec.by.path));
|
|
4540
|
+
const extract = this.dialect.buildJsonPathExtract(`${alias}.${this.q(col)}`, this.p(params.length));
|
|
4541
|
+
byExpr = spec.by.type === 'numeric' ? this.castJsonNumeric(extract) : extract;
|
|
4542
|
+
}
|
|
4543
|
+
// Correlation to the parent row, then the target's global filter (a
|
|
4544
|
+
// soft-deleted / other-tenant row must never be picked: matches the
|
|
4545
|
+
// `with` subquery and to-one relation-orderBy semantics), then pick.where.
|
|
4546
|
+
let where = this.dialect.buildCorrelation(alias, relDef.foreignKey, this.q(parentRef), relDef.referenceKey);
|
|
4547
|
+
const gf = this.targetGlobalFilterAlias(relDef.to, alias, params);
|
|
4548
|
+
if (gf)
|
|
4549
|
+
where += ` AND ${gf}`;
|
|
4550
|
+
if (spec.pick.where) {
|
|
4551
|
+
const pickWhere = this.buildAliasWhere(relDef.to, targetMeta, alias, spec.pick.where, params);
|
|
4552
|
+
if (pickWhere)
|
|
4553
|
+
where += ` AND ${pickWhere}`;
|
|
4554
|
+
}
|
|
4555
|
+
// pick.orderBy: same surface as a relation `with` orderBy on the target
|
|
4556
|
+
// (plain columns, OrderBySpec nulls, JSON-path specs); a nested pick in
|
|
4557
|
+
// here routes back through buildRelationOrderBy with ctx set and throws
|
|
4558
|
+
// the top-level-only E003.
|
|
4559
|
+
const orderClause = this.buildRelationOrderClause(relDef.to, targetMeta, alias, Object.entries(spec.pick.orderBy), params);
|
|
4560
|
+
const dir = spec.direction?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
|
4561
|
+
const limitOne = this.buildPagination('1', undefined, true);
|
|
4562
|
+
// Parents with ZERO surviving related rows make the correlated subquery
|
|
4563
|
+
// yield NULL. Without a nulls clause, Postgres DESC defaults to NULLS
|
|
4564
|
+
// FIRST — every childless parent would top a "highest first" sort. Default
|
|
4565
|
+
// to NULLS LAST in BOTH directions (deterministic across engines: SQLite's
|
|
4566
|
+
// NULL-is-smallest default diverges from Postgres) unless the caller set
|
|
4567
|
+
// `nulls` explicitly; the grammar gate matches nullsSuffix (PG + SQLite).
|
|
4568
|
+
const nullsSql = spec.nulls
|
|
4569
|
+
? this.nullsSuffix(spec.nulls)
|
|
4570
|
+
: this.dialect.name === 'postgresql' || this.dialect.name === 'sqlite'
|
|
4571
|
+
? ' NULLS LAST'
|
|
4572
|
+
: '';
|
|
4573
|
+
return `(SELECT ${byExpr} FROM ${this.q(relDef.to)} ${alias} WHERE ${where}${orderClause}${limitOne}) ${dir}${nullsSql}`;
|
|
4574
|
+
}
|
|
4575
|
+
/**
|
|
4576
|
+
* Param-collect mirror of {@link buildRelationPickOrderBy}: re-runs the same
|
|
4577
|
+
* validation (a warmed cache can never skip it), then pushes in the same
|
|
4578
|
+
* order: `by` JSON path → target global filter → `pick.where` →
|
|
4579
|
+
* `pick.orderBy` JSON paths.
|
|
4580
|
+
*/
|
|
4581
|
+
collectRelationPickOrderParams(relName, relDef, spec, params) {
|
|
4582
|
+
this.validatePickOrderBy(relName, relDef, spec, false);
|
|
4583
|
+
const targetMeta = this.schema.tables[relDef.to];
|
|
4584
|
+
if (!targetMeta)
|
|
4585
|
+
throw new RelationError(`[turbine] Unknown relation target "${relDef.to}"`);
|
|
4586
|
+
if (typeof spec.by === 'string') {
|
|
4587
|
+
this.resolveOrderByColumn(relDef.to, targetMeta, spec.by);
|
|
4588
|
+
}
|
|
4589
|
+
else {
|
|
4590
|
+
this.validateJsonPathOrderBy(relDef.to, targetMeta, spec.by.field, { path: spec.by.path });
|
|
4591
|
+
params.push(this.jsonPathParam(spec.by.path));
|
|
4592
|
+
}
|
|
4593
|
+
this.collectTargetGlobalFilterAlias(relDef.to, params);
|
|
4594
|
+
if (spec.pick.where) {
|
|
4595
|
+
this.collectAliasWhereParams(relDef.to, targetMeta, spec.pick.where, params);
|
|
4596
|
+
}
|
|
4597
|
+
this.collectRelationOrderParams(relDef.to, targetMeta, Object.entries(spec.pick.orderBy), params);
|
|
4598
|
+
}
|
|
4068
4599
|
/**
|
|
4069
4600
|
* Compile the ORDER BY terms of a relation `with` clause against the
|
|
4070
4601
|
* relation's table alias. One unified path for every relation shape
|
|
@@ -4115,10 +4646,15 @@ export class QueryInterface {
|
|
|
4115
4646
|
}
|
|
4116
4647
|
if (isJsonPathOrderBy(dirValue)) {
|
|
4117
4648
|
this.validateJsonPathOrderBy(targetTable, targetMeta, key, dirValue);
|
|
4118
|
-
params.push(dirValue.path
|
|
4649
|
+
params.push(this.jsonPathParam(dirValue.path));
|
|
4119
4650
|
continue;
|
|
4120
4651
|
}
|
|
4121
4652
|
if (this.isRelationOrderByValue(dirValue)) {
|
|
4653
|
+
// Pick-row ordering is top-level-only: the build path throws the same
|
|
4654
|
+
// E003 (buildRelationOrderBy with ctx set), so the mirror must too.
|
|
4655
|
+
if (isRelationPickOrderBy(dirValue)) {
|
|
4656
|
+
throw this.pickOrderNestedError(key);
|
|
4657
|
+
}
|
|
4122
4658
|
const relDef = targetMeta.relations[key];
|
|
4123
4659
|
if (relDef && (relDef.type === 'hasMany' || relDef.type === 'manyToMany')) {
|
|
4124
4660
|
this.collectRelationCountParams(relDef, params);
|
|
@@ -5077,6 +5613,16 @@ export class QueryInterface {
|
|
|
5077
5613
|
* Used to detect JSONB/array columns for specialized operators.
|
|
5078
5614
|
* Uses pre-computed Map for O(1) lookup instead of linear scan.
|
|
5079
5615
|
*/
|
|
5616
|
+
/**
|
|
5617
|
+
* Case-insensitive json/jsonb column-type check. Postgres reports lowercase
|
|
5618
|
+
* udt_names, but SQLite/MySQL introspection surfaces the DECLARED type
|
|
5619
|
+
* (e.g. `JSON`), so every JSON-feature gate compares through this predicate
|
|
5620
|
+
* — build and collect sides alike, keeping the SQL-cache lockstep.
|
|
5621
|
+
*/
|
|
5622
|
+
isJsonColumnType(colType) {
|
|
5623
|
+
const t = colType.toLowerCase();
|
|
5624
|
+
return t === 'json' || t === 'jsonb';
|
|
5625
|
+
}
|
|
5080
5626
|
getColumnPgType(column) {
|
|
5081
5627
|
return this.columnPgTypeMap.get(column) ?? 'text';
|
|
5082
5628
|
}
|
|
@@ -5146,7 +5692,8 @@ export class QueryInterface {
|
|
|
5146
5692
|
let pathParamIdx = null;
|
|
5147
5693
|
const pathExtract = () => {
|
|
5148
5694
|
if (pathParamIdx === null) {
|
|
5149
|
-
|
|
5695
|
+
// Only reached when a path-requiring clause validated filter.path.
|
|
5696
|
+
params.push(this.jsonPathParam(filter.path, filter.path));
|
|
5150
5697
|
pathParamIdx = params.length;
|
|
5151
5698
|
}
|
|
5152
5699
|
return this.dialect.buildJsonPathExtract(column, this.p(pathParamIdx));
|
|
@@ -5182,6 +5729,24 @@ export class QueryInterface {
|
|
|
5182
5729
|
}
|
|
5183
5730
|
return clauses;
|
|
5184
5731
|
}
|
|
5732
|
+
/**
|
|
5733
|
+
* Bind value for a JSON path parameter, encoded per dialect. PostgreSQL's
|
|
5734
|
+
* `#>>` takes a `text[]` (the segments as strings — or `nativeForm` when the
|
|
5735
|
+
* caller has a specific native binding, e.g. JsonFilter's raw path array).
|
|
5736
|
+
* Every other engine's JSON function (`json_extract` / `JSON_EXTRACT` /
|
|
5737
|
+
* `JSON_VALUE`) takes a `'$'`-rooted JSONPath STRING: binding the raw array
|
|
5738
|
+
* would arrive as `'["a"]'` (the driver shims JSON.stringify non-primitive
|
|
5739
|
+
* params) and fail at runtime with the engine's bad-JSON-path error. The
|
|
5740
|
+
* encoded path stays a bound parameter — never spliced into SQL text — so
|
|
5741
|
+
* the build/collect param mirrors stay in lockstep and injection-safe.
|
|
5742
|
+
*/
|
|
5743
|
+
jsonPathParam(path, nativeForm) {
|
|
5744
|
+
if (this.dialect.jsonPathSupport === 'native')
|
|
5745
|
+
return nativeForm ?? path.map(String);
|
|
5746
|
+
return `$${path
|
|
5747
|
+
.map((seg) => typeof seg === 'number' || /^\d+$/.test(String(seg)) ? `[${seg}]` : `."${String(seg).replace(/"/g, '\\"')}"`)
|
|
5748
|
+
.join('')}`;
|
|
5749
|
+
}
|
|
5185
5750
|
/**
|
|
5186
5751
|
* Cast an extracted JSON path text value to a numeric type for range
|
|
5187
5752
|
* comparison. PostgreSQL uses `(expr)::numeric` (exact — the right way to
|