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
|
@@ -56,6 +56,105 @@ const filters_js_1 = require("./filters.js");
|
|
|
56
56
|
const utils_js_1 = require("./utils.js");
|
|
57
57
|
/** Relations already warned about missing FK indexes (once per process, dev only). */
|
|
58
58
|
const unindexedRelationWarned = new Set();
|
|
59
|
+
/**
|
|
60
|
+
* Dev-mode SQL-cache lockstep cross-check gate.
|
|
61
|
+
*
|
|
62
|
+
* The SQL template cache requires three code paths to enumerate where-clause
|
|
63
|
+
* keys identically: `fingerprintWhere` (builds the cache key),
|
|
64
|
+
* `buildWhereClause` (builds SQL + `$N` params on a MISS), and
|
|
65
|
+
* `collectWhereParams` (re-collects params on a HIT without rebuilding). They
|
|
66
|
+
* are synchronized only by convention, and drift has shipped silent
|
|
67
|
+
* wrong-results bugs before (permuted where-key order; an orderBy fingerprint
|
|
68
|
+
* collision). This check catches such drift loudly the moment a cache HIT
|
|
69
|
+
* happens by rebuilding the SQL + params fresh and comparing them against what
|
|
70
|
+
* the cache-hit path produced.
|
|
71
|
+
*
|
|
72
|
+
* Enabled only when `NODE_ENV !== 'production'` (same convention as the other
|
|
73
|
+
* dev-only guards in this file) AND `TURBINE_DISABLE_CACHE_CHECK !== '1'`. The
|
|
74
|
+
* env vars are read inline (not captured once) so tests and perf-sensitive dev
|
|
75
|
+
* traffic can toggle them per process. In production the check never runs, so
|
|
76
|
+
* the hot path is unchanged.
|
|
77
|
+
*/
|
|
78
|
+
function cacheCrossCheckEnabled() {
|
|
79
|
+
return process.env.NODE_ENV !== 'production' && process.env.TURBINE_DISABLE_CACHE_CHECK !== '1';
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Strict structural equality for a single SQL parameter value. Handles the
|
|
83
|
+
* value shapes Turbine binds: primitives (incl. `NaN` and `bigint`), `null`/
|
|
84
|
+
* `undefined`, `Date` (by time), `Buffer`/typed arrays (by bytes), arrays
|
|
85
|
+
* (`in` lists, pgvector arrays), and plain objects (JSON filter payloads).
|
|
86
|
+
*/
|
|
87
|
+
function cacheParamValueEqual(a, b) {
|
|
88
|
+
if (a === b)
|
|
89
|
+
return true; // identical ref or equal primitive (covers matching null/undefined)
|
|
90
|
+
if (a === null || b === null || a === undefined || b === undefined)
|
|
91
|
+
return false;
|
|
92
|
+
const ta = typeof a;
|
|
93
|
+
if (ta !== typeof b)
|
|
94
|
+
return false;
|
|
95
|
+
if (ta !== 'object') {
|
|
96
|
+
// Primitives that failed `===`: only NaN is legitimately "equal" to itself.
|
|
97
|
+
return typeof a === 'number' && Number.isNaN(a) && Number.isNaN(b);
|
|
98
|
+
}
|
|
99
|
+
if (a instanceof Date || b instanceof Date) {
|
|
100
|
+
return a instanceof Date && b instanceof Date && a.getTime() === b.getTime();
|
|
101
|
+
}
|
|
102
|
+
const aView = ArrayBuffer.isView(a);
|
|
103
|
+
const bView = ArrayBuffer.isView(b);
|
|
104
|
+
if (aView || bView) {
|
|
105
|
+
if (!aView || !bView)
|
|
106
|
+
return false;
|
|
107
|
+
const ua = a;
|
|
108
|
+
const ub = b;
|
|
109
|
+
if (ua.byteLength !== ub.byteLength)
|
|
110
|
+
return false;
|
|
111
|
+
const va = new Uint8Array(ua.buffer, ua.byteOffset, ua.byteLength);
|
|
112
|
+
const vb = new Uint8Array(ub.buffer, ub.byteOffset, ub.byteLength);
|
|
113
|
+
for (let i = 0; i < va.length; i++) {
|
|
114
|
+
if (va[i] !== vb[i])
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
const aArr = Array.isArray(a);
|
|
120
|
+
const bArr = Array.isArray(b);
|
|
121
|
+
if (aArr || bArr) {
|
|
122
|
+
if (!aArr || !bArr)
|
|
123
|
+
return false;
|
|
124
|
+
const arrA = a;
|
|
125
|
+
const arrB = b;
|
|
126
|
+
if (arrA.length !== arrB.length)
|
|
127
|
+
return false;
|
|
128
|
+
for (let i = 0; i < arrA.length; i++) {
|
|
129
|
+
if (!cacheParamValueEqual(arrA[i], arrB[i]))
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
const objA = a;
|
|
135
|
+
const objB = b;
|
|
136
|
+
const keysA = Object.keys(objA);
|
|
137
|
+
const keysB = Object.keys(objB);
|
|
138
|
+
if (keysA.length !== keysB.length)
|
|
139
|
+
return false;
|
|
140
|
+
for (const k of keysA) {
|
|
141
|
+
if (!Object.hasOwn(objB, k))
|
|
142
|
+
return false;
|
|
143
|
+
if (!cacheParamValueEqual(objA[k], objB[k]))
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
/** Element-wise strict equality of two SQL parameter arrays. */
|
|
149
|
+
function cacheParamsEqual(a, b) {
|
|
150
|
+
if (a.length !== b.length)
|
|
151
|
+
return false;
|
|
152
|
+
for (let i = 0; i < a.length; i++) {
|
|
153
|
+
if (!cacheParamValueEqual(a[i], b[i]))
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
59
158
|
// biome-ignore lint/complexity/noBannedTypes: {} means "no relations known" — intentional for untyped table access
|
|
60
159
|
class QueryInterface {
|
|
61
160
|
pool;
|
|
@@ -64,6 +163,15 @@ class QueryInterface {
|
|
|
64
163
|
tableMeta;
|
|
65
164
|
/** SQL template cache: cacheKey → SqlCacheEntry (sql + prepared statement name) */
|
|
66
165
|
sqlTemplateCache = new utils_js_1.LRUCache(1000);
|
|
166
|
+
/**
|
|
167
|
+
* Whether the most recent {@link acquireSql} call was a cache HIT. Read by
|
|
168
|
+
* {@link crossCheckCache} to decide whether to run the dev-mode lockstep
|
|
169
|
+
* cross-check. Safe as a single mutable flag: each `build*()` method calls
|
|
170
|
+
* `acquireSql` then `crossCheckCache` synchronously with no intervening
|
|
171
|
+
* `await` and no re-entrant `acquireSql` (relation subqueries are built
|
|
172
|
+
* inline, not through the top-level cache).
|
|
173
|
+
*/
|
|
174
|
+
lastCacheHit = false;
|
|
67
175
|
middlewares;
|
|
68
176
|
defaultLimit;
|
|
69
177
|
warnOnUnlimited;
|
|
@@ -327,6 +435,10 @@ class QueryInterface {
|
|
|
327
435
|
*/
|
|
328
436
|
async runFindManyBatched(args) {
|
|
329
437
|
const withClause = args.with;
|
|
438
|
+
// Scope-rule parity with the join strategy (which throws at SQL build):
|
|
439
|
+
// reject nested pick-row ordering BEFORE the base query so acceptance
|
|
440
|
+
// never depends on how many rows come back.
|
|
441
|
+
(0, batched_loader_js_1.rejectNestedPickOrder)(withClause);
|
|
330
442
|
// Capture the opt-out from the ARGS before any await: this.currentSkip is
|
|
331
443
|
// instance state on a cached accessor, so a concurrent build during the
|
|
332
444
|
// base-query await would overwrite it (tenant query loading relations with
|
|
@@ -378,24 +490,84 @@ class QueryInterface {
|
|
|
378
490
|
* On hit, increments counters and returns the cached entry.
|
|
379
491
|
*
|
|
380
492
|
* When `sqlCache` is disabled, always calls `build()` without caching.
|
|
493
|
+
*
|
|
494
|
+
* `build` receives a fresh `$N` param scratch array. On a miss those params
|
|
495
|
+
* are discarded (the returned params come from each call site's dedicated
|
|
496
|
+
* collect path); the array exists so the build path can number placeholders
|
|
497
|
+
* via `params.length` exactly as it does today. On a HIT, `build` is skipped
|
|
498
|
+
* here but re-run by {@link crossCheckCache} (dev only) with a fresh array to
|
|
499
|
+
* verify the collect path stayed in lockstep with the build path.
|
|
500
|
+
*
|
|
501
|
+
* Sets {@link lastCacheHit} so the caller's `crossCheckCache` knows whether a
|
|
502
|
+
* cross-check is warranted.
|
|
381
503
|
*/
|
|
382
504
|
acquireSql(cacheKey, build) {
|
|
383
505
|
if (!this.sqlCacheEnabled) {
|
|
384
|
-
|
|
506
|
+
this.lastCacheHit = false;
|
|
507
|
+
const sql = build([]);
|
|
385
508
|
this.cacheMisses++;
|
|
386
509
|
return { sql, name: (0, utils_js_1.sqlToPreparedName)(sql) };
|
|
387
510
|
}
|
|
388
511
|
const cached = this.sqlTemplateCache.get(cacheKey);
|
|
389
512
|
if (cached) {
|
|
390
513
|
this.cacheHits++;
|
|
514
|
+
this.lastCacheHit = true;
|
|
391
515
|
return cached;
|
|
392
516
|
}
|
|
393
|
-
|
|
517
|
+
this.lastCacheHit = false;
|
|
518
|
+
const sql = build([]);
|
|
394
519
|
const entry = { sql, name: (0, utils_js_1.sqlToPreparedName)(sql) };
|
|
395
520
|
this.sqlTemplateCache.set(cacheKey, entry);
|
|
396
521
|
this.cacheMisses++;
|
|
397
522
|
return entry;
|
|
398
523
|
}
|
|
524
|
+
/**
|
|
525
|
+
* Dev-mode SQL-cache lockstep cross-check (see {@link cacheCrossCheckEnabled}).
|
|
526
|
+
*
|
|
527
|
+
* Runs only when the most recent {@link acquireSql} was a cache HIT and the
|
|
528
|
+
* check is enabled. Rebuilds the SQL + `$N` params fresh via the same `build`
|
|
529
|
+
* closure the caller passed to `acquireSql`, then compares:
|
|
530
|
+
* (a) the cached SQL string byte-for-byte against the fresh SQL, and
|
|
531
|
+
* (b) the params the cache-hit collect path produced against the fresh
|
|
532
|
+
* build-path params (length and element-wise strict deep-equal).
|
|
533
|
+
*
|
|
534
|
+
* A mismatch means the fingerprint / build / collect paths have drifted out
|
|
535
|
+
* of lockstep (the exact class of bug that has silently corrupted results
|
|
536
|
+
* before), so it throws a {@link ValidationError} (E003) naming the
|
|
537
|
+
* fingerprint, the operation, and both SQL strings (truncated). Failing loud
|
|
538
|
+
* in dev/test is the point. Production never reaches the comparison.
|
|
539
|
+
*
|
|
540
|
+
* @param op human label of the calling build method (for the error message).
|
|
541
|
+
* @param cacheKey the cache fingerprint that HIT.
|
|
542
|
+
* @param entry the cached SQL entry that will be executed.
|
|
543
|
+
* @param build the same closure passed to `acquireSql`; re-run here to
|
|
544
|
+
* capture the fresh build-path SQL + params.
|
|
545
|
+
* @param collectedParams the params the caller's collect path produced.
|
|
546
|
+
*/
|
|
547
|
+
crossCheckCache(op, cacheKey, entry, build, collectedParams) {
|
|
548
|
+
if (!this.lastCacheHit)
|
|
549
|
+
return;
|
|
550
|
+
if (!cacheCrossCheckEnabled())
|
|
551
|
+
return;
|
|
552
|
+
const freshParams = [];
|
|
553
|
+
const freshSql = build(freshParams);
|
|
554
|
+
const sqlOk = freshSql === entry.sql;
|
|
555
|
+
const paramsOk = cacheParamsEqual(collectedParams, freshParams);
|
|
556
|
+
if (sqlOk && paramsOk)
|
|
557
|
+
return;
|
|
558
|
+
const truncate = (s) => (s.length > 300 ? `${s.slice(0, 300)}… (${s.length} chars total)` : s);
|
|
559
|
+
const details = [];
|
|
560
|
+
if (!sqlOk) {
|
|
561
|
+
details.push(`cached SQL and freshly-built SQL diverge:\n cached = <${truncate(entry.sql)}>\n fresh = <${truncate(freshSql)}>`);
|
|
562
|
+
}
|
|
563
|
+
if (!paramsOk) {
|
|
564
|
+
details.push(`cache-hit params and freshly-built params diverge (collected ${collectedParams.length}, built ${freshParams.length})`);
|
|
565
|
+
}
|
|
566
|
+
throw new errors_js_1.ValidationError(`[turbine] SQL cache lockstep violation on ${op} (fingerprint "${cacheKey}"). ` +
|
|
567
|
+
`This is a Turbine internal invariant violation, please report it at ` +
|
|
568
|
+
`https://github.com/zvndev/turbine-orm/issues. The fingerprint, SQL-build, and ` +
|
|
569
|
+
`param-collect paths must enumerate where-clause keys identically.\n${details.join('\n')}`);
|
|
570
|
+
}
|
|
399
571
|
/**
|
|
400
572
|
* Reset the per-instance unlimited-query warning dedupe set.
|
|
401
573
|
* Exposed for tests so a single test process can verify the warning fires
|
|
@@ -552,6 +724,8 @@ class QueryInterface {
|
|
|
552
724
|
*/
|
|
553
725
|
async runFindUniqueBatched(args) {
|
|
554
726
|
const withClause = args.with;
|
|
727
|
+
// Same scope-rule parity as runFindManyBatched: reject before querying.
|
|
728
|
+
(0, batched_loader_js_1.rejectNestedPickOrder)(withClause);
|
|
555
729
|
const needed = (0, batched_loader_js_1.neededParentKeyFields)(this.tableMeta, withClause);
|
|
556
730
|
const proj = (0, batched_loader_js_1.includeKeysForBatching)(args.select, args.omit, needed);
|
|
557
731
|
const baseArgs = { ...args, with: undefined, select: proj.select, omit: proj.omit };
|
|
@@ -592,19 +766,22 @@ class QueryInterface {
|
|
|
592
766
|
});
|
|
593
767
|
// Simple path: plain equality, no operators/null/OR
|
|
594
768
|
if (!args.with && isSimpleWhere) {
|
|
595
|
-
const
|
|
769
|
+
const buildSql = (freshParams) => {
|
|
596
770
|
const qt = this.q(this.table);
|
|
597
|
-
const
|
|
598
|
-
|
|
771
|
+
const whereClauses = whereKeys.map((k, i) => {
|
|
772
|
+
freshParams.push(whereObj[k]);
|
|
773
|
+
return `${this.toSqlColumn(k)} = ${this.p(i + 1)}`;
|
|
774
|
+
});
|
|
599
775
|
const whereSql = whereClauses.length > 0 ? ` WHERE ${whereClauses.join(' AND ')}` : '';
|
|
600
776
|
const selectExpr = columnsList ? columnsList.map((c) => `${qt}.${this.q(c)}`).join(', ') : `${qt}.*`;
|
|
601
|
-
void tempParams; // params are positional, SQL is value-invariant
|
|
602
777
|
return `SELECT ${selectExpr} FROM ${qt}${whereSql}${this.limitOneClause()}`;
|
|
603
|
-
}
|
|
778
|
+
};
|
|
779
|
+
const entry = this.acquireSql(ck, buildSql);
|
|
604
780
|
// Collect params (same order as build)
|
|
605
781
|
for (const k of whereKeys) {
|
|
606
782
|
params.push(whereObj[k]);
|
|
607
783
|
}
|
|
784
|
+
this.crossCheckCache('findUnique', ck, entry, buildSql, params);
|
|
608
785
|
return {
|
|
609
786
|
sql: entry.sql,
|
|
610
787
|
params,
|
|
@@ -618,16 +795,17 @@ class QueryInterface {
|
|
|
618
795
|
}
|
|
619
796
|
// General path (with operators, null, OR, with clause)
|
|
620
797
|
if (!args.with) {
|
|
621
|
-
const
|
|
622
|
-
const freshParams = [];
|
|
798
|
+
const buildSql = (freshParams) => {
|
|
623
799
|
const clause = this.buildWhereClause(whereObj, freshParams);
|
|
624
800
|
const whereSql = clause ? ` WHERE ${clause}` : '';
|
|
625
801
|
const qt = this.q(this.table);
|
|
626
802
|
const selectExpr = columnsList ? columnsList.map((c) => `${qt}.${this.q(c)}`).join(', ') : `${qt}.*`;
|
|
627
803
|
return `SELECT ${selectExpr} FROM ${qt}${whereSql}${this.limitOneClause()}`;
|
|
628
|
-
}
|
|
804
|
+
};
|
|
805
|
+
const entry = this.acquireSql(ck, buildSql);
|
|
629
806
|
// Collect params
|
|
630
807
|
this.collectWhereParams(whereObj, params);
|
|
808
|
+
this.crossCheckCache('findUnique', ck, entry, buildSql, params);
|
|
631
809
|
return {
|
|
632
810
|
sql: entry.sql,
|
|
633
811
|
params,
|
|
@@ -644,16 +822,17 @@ class QueryInterface {
|
|
|
644
822
|
// 1. buildWhere pushes where params
|
|
645
823
|
// 2. buildSelectWithRelations pushes relation params to same array
|
|
646
824
|
// We must preserve this exact order.
|
|
647
|
-
const
|
|
648
|
-
const freshParams = [];
|
|
825
|
+
const buildSql = (freshParams) => {
|
|
649
826
|
const clause = this.buildWhereClause(whereObj, freshParams);
|
|
650
827
|
const whereSql = clause ? ` WHERE ${clause}` : '';
|
|
651
828
|
const selectClause = this.buildSelectWithRelations(this.table, args.with, freshParams, columnsList);
|
|
652
829
|
return `SELECT ${selectClause} FROM ${this.q(this.table)}${whereSql}${this.limitOneClause()}`;
|
|
653
|
-
}
|
|
830
|
+
};
|
|
831
|
+
const entry = this.acquireSql(ck, buildSql);
|
|
654
832
|
// Collect params in exact build order: where first, then with-clause relations
|
|
655
833
|
this.collectWhereParams(whereObj, params);
|
|
656
834
|
this.collectWithParams(args.with, params);
|
|
835
|
+
this.crossCheckCache('findUnique', ck, entry, buildSql, params);
|
|
657
836
|
const parseWith = this.makeNestedParser(args.with);
|
|
658
837
|
return {
|
|
659
838
|
sql: entry.sql,
|
|
@@ -718,7 +897,7 @@ class QueryInterface {
|
|
|
718
897
|
if (this.warnedTables.has(this.table))
|
|
719
898
|
return;
|
|
720
899
|
this.warnedTables.add(this.table);
|
|
721
|
-
console.warn(`[turbine] warning: findMany on "${this.table}" has no limit
|
|
900
|
+
console.warn(`[turbine] warning: findMany on "${this.table}" has no limit: this will fetch every row. ` +
|
|
722
901
|
'Pass `limit`, or silence with `warnOnUnlimited: false` (per call, per table, or in config).');
|
|
723
902
|
}
|
|
724
903
|
/**
|
|
@@ -738,6 +917,21 @@ class QueryInterface {
|
|
|
738
917
|
// biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
|
|
739
918
|
buildFindMany(args) {
|
|
740
919
|
this.currentSkip = args?.skipGlobalFilters;
|
|
920
|
+
// `distinct` + relation orderBy is refused up front (E003): the distinct
|
|
921
|
+
// path re-orders in an outer wrapper (`... AS "<table>_distinct" ORDER BY
|
|
922
|
+
// <userOrder>`) where a correlated relation subquery (pick-row, `_count`,
|
|
923
|
+
// to-one relation ordering) would reference the parent table name out of
|
|
924
|
+
// scope — a guaranteed "missing FROM-clause entry" crash on Postgres.
|
|
925
|
+
// Checked BEFORE the SQL cache so build and warm-cache paths throw
|
|
926
|
+
// identically (same rule as the vector guard inside the distinct branch).
|
|
927
|
+
if (args?.distinct && args.distinct.length > 0 && args.orderBy) {
|
|
928
|
+
for (const d of Object.values(args.orderBy)) {
|
|
929
|
+
if (this.isRelationOrderByValue(d)) {
|
|
930
|
+
throw new errors_js_1.ValidationError('[turbine] `distinct` cannot be combined with relation orderBy (pick-row, `_count`, or ' +
|
|
931
|
+
'to-one relation ordering): the outer re-order cannot reference the parent table.');
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
}
|
|
741
935
|
const columnsList = this.resolveColumns(args?.select, args?.omit);
|
|
742
936
|
const colKey = columnsList ? columnsList.join(',') : '*';
|
|
743
937
|
// AND-merge this table's global filter into the user where; `hasWhere` gates
|
|
@@ -751,7 +945,7 @@ class QueryInterface {
|
|
|
751
945
|
const withFp = args?.with ? this.withFingerprint(args.with) : '';
|
|
752
946
|
const orderFp = args?.orderBy
|
|
753
947
|
? Object.entries(args.orderBy)
|
|
754
|
-
.map(([k, d]) => `${k}:${this.orderByEntryFingerprint(d)}`)
|
|
948
|
+
.map(([k, d]) => `${k}:${this.orderByEntryFingerprint(d, this.tableMeta.relations[k]?.to)}`)
|
|
755
949
|
.join(',')
|
|
756
950
|
: '';
|
|
757
951
|
const cursorFp = args?.cursor
|
|
@@ -760,15 +954,24 @@ class QueryInterface {
|
|
|
760
954
|
.sort()
|
|
761
955
|
.join(',')
|
|
762
956
|
: '';
|
|
763
|
-
|
|
957
|
+
// distinct must fingerprint in USER order: the SQL emits `DISTINCT ON` in
|
|
958
|
+
// the caller's column order, so a permuted array rebuilds different SQL and
|
|
959
|
+
// must not collapse onto the same cache entry (would trip the cross-check).
|
|
960
|
+
const distinctFp = args?.distinct ? args.distinct.join(',') : '';
|
|
764
961
|
const effectiveLimit = args?.take ?? args?.limit ?? this.defaultLimit;
|
|
765
|
-
|
|
766
|
-
|
|
962
|
+
// On engines that inline the literal LIMIT/OFFSET into the SQL text
|
|
963
|
+
// (dialect.inlineLimitOffset, MySQL), the value is part of the SQL, not the
|
|
964
|
+
// params, so it MUST be part of the fingerprint or two different limits share
|
|
965
|
+
// one cached statement (silent wrong row counts). Parameterized engines
|
|
966
|
+
// (PG/SQLite/SQL Server, whose buildLimitOffset uses placeholders) keep the
|
|
967
|
+
// presence-only fingerprint so the cache is not needlessly fragmented.
|
|
968
|
+
const inlinePagination = this.dialect.inlineLimitOffset === true;
|
|
969
|
+
const limitFp = effectiveLimit !== undefined ? (inlinePagination ? `v${effectiveLimit}` : '1') : '0';
|
|
970
|
+
const offsetFp = args?.offset !== undefined ? (inlinePagination ? `v${args.offset}` : '1') : '0';
|
|
767
971
|
const ck = `fm:${whereFp}|c=${colKey}|o=${orderFp}|l=${limitFp}|off=${offsetFp}|cur=${cursorFp}|d=${distinctFp}|w=${withFp}${this.globalFilterCacheSegment()}`;
|
|
768
972
|
const params = [];
|
|
769
|
-
const
|
|
770
|
-
// Fresh build
|
|
771
|
-
const freshParams = [];
|
|
973
|
+
const buildSql = (freshParams) => {
|
|
974
|
+
// Fresh build: generates SQL and populates freshParams
|
|
772
975
|
const { sql: freshWhereSql } = hasWhere
|
|
773
976
|
? (() => {
|
|
774
977
|
const clause = this.buildWhereClause(whereObj, freshParams);
|
|
@@ -848,7 +1051,8 @@ class QueryInterface {
|
|
|
848
1051
|
}
|
|
849
1052
|
sql += this.buildPagination(limitPh, offsetPh, !!args?.orderBy);
|
|
850
1053
|
return sql;
|
|
851
|
-
}
|
|
1054
|
+
};
|
|
1055
|
+
const entry = this.acquireSql(ck, buildSql);
|
|
852
1056
|
// Collect params in exact build order:
|
|
853
1057
|
// 1. WHERE params (includes the AND-merged global filter, if any)
|
|
854
1058
|
if (hasWhere) {
|
|
@@ -879,6 +1083,7 @@ class QueryInterface {
|
|
|
879
1083
|
if (args?.offset !== undefined && !this.dialect.inlineLimitOffset) {
|
|
880
1084
|
params.push(Number(args.offset));
|
|
881
1085
|
}
|
|
1086
|
+
this.crossCheckCache('findMany', ck, entry, buildSql, params);
|
|
882
1087
|
// Build the row parser once (positional shapes are computed here, not per row).
|
|
883
1088
|
const parseWith = args?.with ? this.makeNestedParser(args.with) : null;
|
|
884
1089
|
return {
|
|
@@ -1204,8 +1409,7 @@ class QueryInterface {
|
|
|
1204
1409
|
const whereFp = this.fingerprintWhere(whereObj);
|
|
1205
1410
|
const ck = lock ? null : `u:${setFp}|${whereFp}${this.globalFilterCacheSegment()}`;
|
|
1206
1411
|
const params = [];
|
|
1207
|
-
const buildSql = () => {
|
|
1208
|
-
const freshParams = [];
|
|
1412
|
+
const buildSql = (freshParams) => {
|
|
1209
1413
|
const setEntries = Object.entries(dataObj).filter(([, v]) => v !== undefined);
|
|
1210
1414
|
const setClauses = setEntries.map(([k, v]) => this.buildSetClause(k, v, freshParams));
|
|
1211
1415
|
if (lock) {
|
|
@@ -1229,13 +1433,15 @@ class QueryInterface {
|
|
|
1229
1433
|
};
|
|
1230
1434
|
let sql;
|
|
1231
1435
|
let preparedName;
|
|
1436
|
+
let cacheEntry;
|
|
1232
1437
|
if (ck) {
|
|
1233
|
-
|
|
1234
|
-
sql =
|
|
1235
|
-
preparedName =
|
|
1438
|
+
cacheEntry = this.acquireSql(ck, buildSql);
|
|
1439
|
+
sql = cacheEntry.sql;
|
|
1440
|
+
preparedName = cacheEntry.name;
|
|
1236
1441
|
}
|
|
1237
1442
|
else {
|
|
1238
|
-
|
|
1443
|
+
// optimisticLock path: value-variant version check → uncacheable, no cross-check.
|
|
1444
|
+
sql = buildSql([]);
|
|
1239
1445
|
}
|
|
1240
1446
|
// Collect params: SET first, then WHERE, then version check (same order as fresh build)
|
|
1241
1447
|
this.collectSetParams(dataObj, params);
|
|
@@ -1243,6 +1449,9 @@ class QueryInterface {
|
|
|
1243
1449
|
if (lock) {
|
|
1244
1450
|
params.push(lock.expected);
|
|
1245
1451
|
}
|
|
1452
|
+
if (ck && cacheEntry) {
|
|
1453
|
+
this.crossCheckCache('update', ck, cacheEntry, buildSql, params);
|
|
1454
|
+
}
|
|
1246
1455
|
return {
|
|
1247
1456
|
sql,
|
|
1248
1457
|
params,
|
|
@@ -1374,8 +1583,7 @@ class QueryInterface {
|
|
|
1374
1583
|
const whereFp = this.fingerprintWhere(whereObj);
|
|
1375
1584
|
const ck = `d:${whereFp}${this.globalFilterCacheSegment()}`;
|
|
1376
1585
|
const params = [];
|
|
1377
|
-
const
|
|
1378
|
-
const freshParams = [];
|
|
1586
|
+
const buildSql = (freshParams) => {
|
|
1379
1587
|
const clause = this.buildWhereClause(whereObj, freshParams);
|
|
1380
1588
|
const whereSql = clause ? ` WHERE ${clause}` : '';
|
|
1381
1589
|
// SQL Server injects `OUTPUT DELETED.*` between `DELETE FROM <t>` and WHERE;
|
|
@@ -1383,8 +1591,10 @@ class QueryInterface {
|
|
|
1383
1591
|
return this.dialect.buildDeleteStatement
|
|
1384
1592
|
? this.dialect.buildDeleteStatement({ table: this.q(this.table), whereSql, returning: '*' })
|
|
1385
1593
|
: `DELETE FROM ${this.q(this.table)}${whereSql}${this.dialect.buildReturningClause('*')}`;
|
|
1386
|
-
}
|
|
1594
|
+
};
|
|
1595
|
+
const entry = this.acquireSql(ck, buildSql);
|
|
1387
1596
|
this.collectWhereParams(whereObj, params);
|
|
1597
|
+
this.crossCheckCache('delete', ck, entry, buildSql, params);
|
|
1388
1598
|
return {
|
|
1389
1599
|
sql: entry.sql,
|
|
1390
1600
|
params,
|
|
@@ -1512,16 +1722,17 @@ class QueryInterface {
|
|
|
1512
1722
|
const whereFp = this.fingerprintWhere(whereObj);
|
|
1513
1723
|
const ck = `um:${setFp}|${whereFp}${this.globalFilterCacheSegment()}`;
|
|
1514
1724
|
const params = [];
|
|
1515
|
-
const
|
|
1516
|
-
const freshParams = [];
|
|
1725
|
+
const buildSql = (freshParams) => {
|
|
1517
1726
|
const setEntries = Object.entries(dataObj).filter(([, v]) => v !== undefined);
|
|
1518
1727
|
const setClauses = setEntries.map(([k, v]) => this.buildSetClause(k, v, freshParams));
|
|
1519
1728
|
const whereClause = this.buildWhereClause(whereObj, freshParams);
|
|
1520
1729
|
const whereSql = whereClause ? ` WHERE ${whereClause}` : '';
|
|
1521
1730
|
return `UPDATE ${this.q(this.table)} SET ${setClauses.join(', ')}${whereSql}`;
|
|
1522
|
-
}
|
|
1731
|
+
};
|
|
1732
|
+
const entry = this.acquireSql(ck, buildSql);
|
|
1523
1733
|
this.collectSetParams(dataObj, params);
|
|
1524
1734
|
this.collectWhereParams(whereObj, params);
|
|
1735
|
+
this.crossCheckCache('updateMany', ck, entry, buildSql, params);
|
|
1525
1736
|
return {
|
|
1526
1737
|
sql: entry.sql,
|
|
1527
1738
|
params,
|
|
@@ -1548,13 +1759,14 @@ class QueryInterface {
|
|
|
1548
1759
|
const whereFp = this.fingerprintWhere(whereObj);
|
|
1549
1760
|
const ck = `dm:${whereFp}${this.globalFilterCacheSegment()}`;
|
|
1550
1761
|
const params = [];
|
|
1551
|
-
const
|
|
1552
|
-
const freshParams = [];
|
|
1762
|
+
const buildSql = (freshParams) => {
|
|
1553
1763
|
const clause = this.buildWhereClause(whereObj, freshParams);
|
|
1554
1764
|
const whereSql = clause ? ` WHERE ${clause}` : '';
|
|
1555
1765
|
return `DELETE FROM ${this.q(this.table)}${whereSql}`;
|
|
1556
|
-
}
|
|
1766
|
+
};
|
|
1767
|
+
const entry = this.acquireSql(ck, buildSql);
|
|
1557
1768
|
this.collectWhereParams(whereObj, params);
|
|
1769
|
+
this.crossCheckCache('deleteMany', ck, entry, buildSql, params);
|
|
1558
1770
|
return {
|
|
1559
1771
|
sql: entry.sql,
|
|
1560
1772
|
params,
|
|
@@ -1581,15 +1793,16 @@ class QueryInterface {
|
|
|
1581
1793
|
const whereFp = hasWhere ? this.fingerprintWhere(whereObj) : '';
|
|
1582
1794
|
const ck = `cnt:${whereFp}${this.globalFilterCacheSegment()}`;
|
|
1583
1795
|
const params = [];
|
|
1584
|
-
const
|
|
1585
|
-
const freshParams = [];
|
|
1796
|
+
const buildSql = (freshParams) => {
|
|
1586
1797
|
const clause = hasWhere ? this.buildWhereClause(whereObj, freshParams) : null;
|
|
1587
1798
|
const whereSql = clause ? ` WHERE ${clause}` : '';
|
|
1588
1799
|
return `SELECT ${this.castAgg('COUNT(*)', 'int')} AS count FROM ${this.q(this.table)}${whereSql}`;
|
|
1589
|
-
}
|
|
1800
|
+
};
|
|
1801
|
+
const entry = this.acquireSql(ck, buildSql);
|
|
1590
1802
|
if (hasWhere) {
|
|
1591
1803
|
this.collectWhereParams(whereObj, params);
|
|
1592
1804
|
}
|
|
1805
|
+
this.crossCheckCache('count', ck, entry, buildSql, params);
|
|
1593
1806
|
return {
|
|
1594
1807
|
sql: entry.sql,
|
|
1595
1808
|
params,
|
|
@@ -1612,67 +1825,124 @@ class QueryInterface {
|
|
|
1612
1825
|
const meta = this.schema.tables[this.table];
|
|
1613
1826
|
if (meta) {
|
|
1614
1827
|
for (const key of args.by) {
|
|
1615
|
-
if (!(key in meta.columnMap)) {
|
|
1828
|
+
if (typeof key === 'string' && !(key in meta.columnMap)) {
|
|
1616
1829
|
throw new errors_js_1.ValidationError(`Unknown column "${key}" in groupBy for table "${this.table}"`);
|
|
1617
1830
|
}
|
|
1618
1831
|
}
|
|
1619
1832
|
}
|
|
1620
1833
|
this.currentSkip = args.skipGlobalFilters;
|
|
1621
|
-
const groupColsRaw = args.by.map((k) => this.toColumn(k));
|
|
1622
|
-
const groupCols = groupColsRaw.map((c) => this.q(c));
|
|
1623
1834
|
const gbWhere = this.mergeGlobalFilter(args.where);
|
|
1624
1835
|
const { sql: whereSql, params } = gbWhere
|
|
1625
1836
|
? this.buildWhere(gbWhere)
|
|
1626
1837
|
: { sql: '', params: [] };
|
|
1627
|
-
//
|
|
1628
|
-
|
|
1838
|
+
// Row source. Plain: `"table"<WHERE>`. With `distinctOn` (PostgreSQL
|
|
1839
|
+
// only), the groupBy runs over one representative row per column
|
|
1840
|
+
// combination: the wrapper carries args.where INSIDE it (filter before
|
|
1841
|
+
// picking) and is aliased as the table name so every outer expression is
|
|
1842
|
+
// byte-identical either way.
|
|
1843
|
+
const fromSql = args.distinctOn
|
|
1844
|
+
? this.buildDistinctOnSource(args.distinctOn, whereSql, params)
|
|
1845
|
+
: `${this.q(this.table)}${whereSql}`;
|
|
1846
|
+
// Group keys: plain columns and/or JSON-path keys. Output-name collisions
|
|
1847
|
+
// are rejected up front — and the check runs over the EMITTED SQL output
|
|
1848
|
+
// column names (snake_case column / JSON alias / `_agg_key` aggregate
|
|
1849
|
+
// alias), not just the given arg keys: the driver keeps only the LAST
|
|
1850
|
+
// duplicate field per row object, so a JSON alias equal to another key's
|
|
1851
|
+
// snake_case column (or an aggregate output alias) would silently clobber
|
|
1852
|
+
// that value in the results.
|
|
1853
|
+
const groupExprs = [];
|
|
1854
|
+
const selectExprs = [];
|
|
1855
|
+
/** by entries in order: how to read each group key off the result row. */
|
|
1856
|
+
const byReaders = [];
|
|
1857
|
+
const usedResultKeys = new Set();
|
|
1858
|
+
const claimResultKey = (key, what) => {
|
|
1859
|
+
if (key === '_count' || usedResultKeys.has(key)) {
|
|
1860
|
+
throw new errors_js_1.ValidationError(`[turbine] groupBy output name "${key}" (${what}) collides with another output column on table ` +
|
|
1861
|
+
`"${this.table}": set an explicit \`alias\` (or rename the aggregate key) to disambiguate.`);
|
|
1862
|
+
}
|
|
1863
|
+
usedResultKeys.add(key);
|
|
1864
|
+
};
|
|
1865
|
+
for (const entry of args.by) {
|
|
1866
|
+
if (typeof entry === 'string') {
|
|
1867
|
+
const col = this.toColumn(entry);
|
|
1868
|
+
claimResultKey(entry, `column "${col}"`);
|
|
1869
|
+
// The emitted output column is the snake_case name; claim it too (when
|
|
1870
|
+
// it differs from the result key) so a JSON alias like 'created_at'
|
|
1871
|
+
// cannot silently shadow the 'createdAt' group key on the wire.
|
|
1872
|
+
if (col !== entry)
|
|
1873
|
+
claimResultKey(col, `column "${col}"`);
|
|
1874
|
+
groupExprs.push(this.q(col));
|
|
1875
|
+
selectExprs.push(this.q(col));
|
|
1876
|
+
byReaders.push({ resultKey: entry, rowKey: col, raw: false });
|
|
1877
|
+
}
|
|
1878
|
+
else {
|
|
1879
|
+
const col = this.resolveJsonPathTarget('group key', entry.field, entry.path);
|
|
1880
|
+
params.push(this.jsonPathParam(entry.path));
|
|
1881
|
+
const extract = this.dialect.buildJsonPathExtract(this.q(col), this.p(params.length));
|
|
1882
|
+
const alias = entry.alias ?? String(entry.path[entry.path.length - 1]);
|
|
1883
|
+
claimResultKey(alias, `JSON path on "${entry.field}"`);
|
|
1884
|
+
// Same expression (and the same $n placeholder) in SELECT and GROUP BY.
|
|
1885
|
+
selectExprs.push(`(${extract}) AS ${this.q(alias)}`);
|
|
1886
|
+
groupExprs.push(extract);
|
|
1887
|
+
byReaders.push({ resultKey: alias, rowKey: alias, raw: true });
|
|
1888
|
+
}
|
|
1889
|
+
}
|
|
1629
1890
|
// _count
|
|
1630
1891
|
if (args._count === true || args._count === undefined) {
|
|
1631
1892
|
// default: always include count
|
|
1632
1893
|
selectExprs.push(`${this.castAgg('COUNT(*)', 'int')} AS _count`);
|
|
1633
1894
|
}
|
|
1634
|
-
// _sum
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
selectExprs.push(`MIN(${this.q(col)}) AS ${this.q(`_min_${col}`)}`);
|
|
1895
|
+
// _sum / _avg / _min / _max: `true` keeps the plain-column behavior; a
|
|
1896
|
+
// {@link JsonPathAggregateTarget} aggregates a JSON path under the arg key
|
|
1897
|
+
// as alias. `jsonAggFields` routes each JSON-aggregate row key back to its
|
|
1898
|
+
// alias (and coercion kind) in the transform; `jsonAggExprs` lets HAVING
|
|
1899
|
+
// reuse the exact aggregate expression (same placeholders) by alias.
|
|
1900
|
+
const jsonAggFields = new Map();
|
|
1901
|
+
const jsonAggExprs = new Map();
|
|
1902
|
+
const buildAggregates = (aggKey, sqlFn, spec) => {
|
|
1903
|
+
if (!spec)
|
|
1904
|
+
return;
|
|
1905
|
+
for (const [key, target] of Object.entries(spec)) {
|
|
1906
|
+
if (!target)
|
|
1907
|
+
continue;
|
|
1908
|
+
if (target === true) {
|
|
1909
|
+
const col = this.toColumn(key);
|
|
1910
|
+
// Aggregate output aliases share the same output-name namespace as
|
|
1911
|
+
// the group keys: `_sum: { totalPrice: true, total_price: {json} }`
|
|
1912
|
+
// would emit two "_sum_total_price" columns and silently drop one.
|
|
1913
|
+
claimResultKey(`${aggKey}_${col}`, `${aggKey} of column "${col}"`);
|
|
1914
|
+
const inner = `${sqlFn}(${this.q(col)})`;
|
|
1915
|
+
const expr = aggKey === '_avg' ? this.castAgg(inner, 'float') : inner;
|
|
1916
|
+
selectExprs.push(`${expr} AS ${this.q(`${aggKey}_${col}`)}`);
|
|
1917
|
+
continue;
|
|
1658
1918
|
}
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
if (enabled) {
|
|
1665
|
-
const col = this.toColumn(field);
|
|
1666
|
-
selectExprs.push(`MAX(${this.q(col)}) AS ${this.q(`_max_${col}`)}`);
|
|
1919
|
+
const col = this.resolveJsonPathTarget(`${aggKey} target "${key}"`, target.field, target.path);
|
|
1920
|
+
const alwaysNumeric = aggKey === '_sum' || aggKey === '_avg';
|
|
1921
|
+
if (alwaysNumeric && target.type === 'text') {
|
|
1922
|
+
throw new errors_js_1.ValidationError(`[turbine] groupBy ${aggKey} target "${key}" on table "${this.table}": ` +
|
|
1923
|
+
`${aggKey} over a JSON path is always numeric: remove \`type: 'text'\`.`);
|
|
1667
1924
|
}
|
|
1925
|
+
const numeric = alwaysNumeric || target.type === 'numeric';
|
|
1926
|
+
claimResultKey(`${aggKey}_${key}`, `${aggKey} JSON target "${key}"`);
|
|
1927
|
+
params.push(this.jsonPathParam(target.path));
|
|
1928
|
+
const extract = this.dialect.buildJsonPathExtract(this.q(col), this.p(params.length));
|
|
1929
|
+
const inner = `${sqlFn}(${numeric ? this.castJsonNumeric(extract) : extract})`;
|
|
1930
|
+
const expr = aggKey === '_avg' ? this.castAgg(inner, 'float') : inner;
|
|
1931
|
+
selectExprs.push(`${expr} AS ${this.q(`${aggKey}_${key}`)}`);
|
|
1932
|
+
jsonAggFields.set(`${aggKey}_${key}`, { field: key, numeric });
|
|
1933
|
+
jsonAggExprs.set(`${key}:${aggKey}`, expr);
|
|
1668
1934
|
}
|
|
1669
|
-
}
|
|
1670
|
-
|
|
1935
|
+
};
|
|
1936
|
+
buildAggregates('_sum', 'SUM', args._sum);
|
|
1937
|
+
buildAggregates('_avg', 'AVG', args._avg);
|
|
1938
|
+
buildAggregates('_min', 'MIN', args._min);
|
|
1939
|
+
buildAggregates('_max', 'MAX', args._max);
|
|
1940
|
+
let sql = `SELECT ${selectExprs.join(', ')} FROM ${fromSql} GROUP BY ${groupExprs.join(', ')}`;
|
|
1671
1941
|
// HAVING — filter whole groups by their aggregate values.
|
|
1672
1942
|
// Appends to the same `params` array, so placeholders continue from the
|
|
1673
1943
|
// WHERE clause's parameter positions (this.p(params.length) below).
|
|
1674
1944
|
if (args.having) {
|
|
1675
|
-
const havingClauses = this.buildHavingClauses(args.having, params);
|
|
1945
|
+
const havingClauses = this.buildHavingClauses(args.having, params, jsonAggExprs);
|
|
1676
1946
|
if (havingClauses.length > 0) {
|
|
1677
1947
|
sql += ` HAVING ${havingClauses.join(' AND ')}`;
|
|
1678
1948
|
}
|
|
@@ -1688,9 +1958,11 @@ class QueryInterface {
|
|
|
1688
1958
|
const parsed = this.parseRow(row, this.table);
|
|
1689
1959
|
// Restructure aggregate results into nested objects (Prisma-style)
|
|
1690
1960
|
const restructured = {};
|
|
1691
|
-
// Copy group-by fields
|
|
1692
|
-
|
|
1693
|
-
|
|
1961
|
+
// Copy group-by fields. JSON-path keys read their alias off the raw
|
|
1962
|
+
// row (the alias is not a table column, so parseRow's snake→camel
|
|
1963
|
+
// mapping must not touch it).
|
|
1964
|
+
for (const reader of byReaders) {
|
|
1965
|
+
restructured[reader.resultKey] = reader.raw ? row[reader.rowKey] : parsed[reader.resultKey];
|
|
1694
1966
|
}
|
|
1695
1967
|
// _count
|
|
1696
1968
|
if ('_count' in row) {
|
|
@@ -1705,29 +1977,27 @@ class QueryInterface {
|
|
|
1705
1977
|
const minObj = {};
|
|
1706
1978
|
const maxObj = {};
|
|
1707
1979
|
let hasSums = false, hasAvgs = false, hasMins = false, hasMaxs = false;
|
|
1980
|
+
// JSON-path aggregates keep their arg key verbatim; plain-column
|
|
1981
|
+
// aggregates keep the snake→camel field mapping.
|
|
1982
|
+
const jsonAgg = (rawKey) => jsonAggFields.get(rawKey);
|
|
1983
|
+
const fieldFor = (rawKey, col) => jsonAgg(rawKey)?.field ?? this.tableMeta.reverseColumnMap[col] ?? (0, schema_js_1.snakeToCamel)(col);
|
|
1708
1984
|
for (const [rawKey, rawValue] of Object.entries(row)) {
|
|
1709
1985
|
if (rawKey.startsWith('_sum_')) {
|
|
1710
|
-
|
|
1711
|
-
const field = this.tableMeta.reverseColumnMap[col] ?? (0, schema_js_1.snakeToCamel)(col);
|
|
1712
|
-
sumObj[field] = rawValue !== null ? Number(rawValue) : null;
|
|
1986
|
+
sumObj[fieldFor(rawKey, rawKey.slice(5))] = rawValue !== null ? Number(rawValue) : null;
|
|
1713
1987
|
hasSums = true;
|
|
1714
1988
|
}
|
|
1715
1989
|
else if (rawKey.startsWith('_avg_')) {
|
|
1716
|
-
|
|
1717
|
-
const field = this.tableMeta.reverseColumnMap[col] ?? (0, schema_js_1.snakeToCamel)(col);
|
|
1718
|
-
avgObj[field] = rawValue !== null ? Number(rawValue) : null;
|
|
1990
|
+
avgObj[fieldFor(rawKey, rawKey.slice(5))] = rawValue !== null ? Number(rawValue) : null;
|
|
1719
1991
|
hasAvgs = true;
|
|
1720
1992
|
}
|
|
1721
1993
|
else if (rawKey.startsWith('_min_')) {
|
|
1722
|
-
const
|
|
1723
|
-
|
|
1724
|
-
minObj[field] = rawValue;
|
|
1994
|
+
const j = jsonAgg(rawKey);
|
|
1995
|
+
minObj[fieldFor(rawKey, rawKey.slice(5))] = j?.numeric && rawValue !== null ? Number(rawValue) : rawValue;
|
|
1725
1996
|
hasMins = true;
|
|
1726
1997
|
}
|
|
1727
1998
|
else if (rawKey.startsWith('_max_')) {
|
|
1728
|
-
const
|
|
1729
|
-
|
|
1730
|
-
maxObj[field] = rawValue;
|
|
1999
|
+
const j = jsonAgg(rawKey);
|
|
2000
|
+
maxObj[fieldFor(rawKey, rawKey.slice(5))] = j?.numeric && rawValue !== null ? Number(rawValue) : rawValue;
|
|
1731
2001
|
hasMaxs = true;
|
|
1732
2002
|
}
|
|
1733
2003
|
}
|
|
@@ -1744,6 +2014,75 @@ class QueryInterface {
|
|
|
1744
2014
|
tag: `${this.table}.groupBy`,
|
|
1745
2015
|
};
|
|
1746
2016
|
}
|
|
2017
|
+
/**
|
|
2018
|
+
* Validate a JSON-path target (group key or aggregate target) in groupBy:
|
|
2019
|
+
* the field must resolve to a real json/jsonb column and the path must be a
|
|
2020
|
+
* non-empty array of keys/indexes. Returns the resolved snake_case column.
|
|
2021
|
+
*/
|
|
2022
|
+
resolveJsonPathTarget(context, field, path) {
|
|
2023
|
+
if (typeof field !== 'string') {
|
|
2024
|
+
throw new errors_js_1.ValidationError(`[turbine] groupBy ${context} on table "${this.table}" requires a string \`field\`.`);
|
|
2025
|
+
}
|
|
2026
|
+
const col = this.toColumn(field);
|
|
2027
|
+
if (!Array.isArray(path) ||
|
|
2028
|
+
path.length === 0 ||
|
|
2029
|
+
path.some((el) => typeof el !== 'string' && !(typeof el === 'number' && Number.isFinite(el)))) {
|
|
2030
|
+
throw new errors_js_1.ValidationError(`[turbine] groupBy ${context} on "${field}" (table "${this.table}") requires a non-empty \`path\` ` +
|
|
2031
|
+
`array of keys/indexes (e.g. { field: '${field}', path: ['category'] }).`);
|
|
2032
|
+
}
|
|
2033
|
+
const colType = this.pgTypeForColumn(this.tableMeta, col);
|
|
2034
|
+
if (!this.isJsonColumnType(colType)) {
|
|
2035
|
+
throw new errors_js_1.ValidationError(`[turbine] groupBy ${context} on "${field}": column "${col}" on table "${this.table}" is not a JSON ` +
|
|
2036
|
+
`column (actual type: ${colType}).`);
|
|
2037
|
+
}
|
|
2038
|
+
return col;
|
|
2039
|
+
}
|
|
2040
|
+
/**
|
|
2041
|
+
* Build the `distinctOn` row source for groupBy (PostgreSQL only: other
|
|
2042
|
+
* engines throw {@link UnsupportedFeatureError} E017):
|
|
2043
|
+
*
|
|
2044
|
+
* ```sql
|
|
2045
|
+
* (SELECT DISTINCT ON ("c1") * FROM "table"<WHERE> ORDER BY "c1", <orderBy>) AS "table"
|
|
2046
|
+
* ```
|
|
2047
|
+
*
|
|
2048
|
+
* The wrapper is aliased as the table name so every outer expression (group
|
|
2049
|
+
* keys, aggregates, HAVING, ORDER BY) is byte-identical to the plain path.
|
|
2050
|
+
* `distinctOn.orderBy` is required (it decides which row survives) and
|
|
2051
|
+
* supports plain columns, {@link OrderBySpec} nulls, and JSON-path specs;
|
|
2052
|
+
* JSON paths push their text[] param here, after the WHERE params.
|
|
2053
|
+
*/
|
|
2054
|
+
buildDistinctOnSource(distinctOn, whereSql, params) {
|
|
2055
|
+
if (this.dialect.name !== 'postgresql') {
|
|
2056
|
+
throw new errors_js_1.UnsupportedFeatureError('DISTINCT ON row source (groupBy distinctOn)', this.dialect.name, 'groupBy({ distinctOn }) requires PostgreSQL: SELECT DISTINCT ON is not portable.');
|
|
2057
|
+
}
|
|
2058
|
+
if (!Array.isArray(distinctOn.columns) || distinctOn.columns.length === 0) {
|
|
2059
|
+
throw new errors_js_1.ValidationError(`[turbine] groupBy distinctOn on table "${this.table}" requires a non-empty \`columns\` array.`);
|
|
2060
|
+
}
|
|
2061
|
+
const orderEntries = Object.entries(distinctOn.orderBy ?? {});
|
|
2062
|
+
if (orderEntries.length === 0) {
|
|
2063
|
+
throw new errors_js_1.ValidationError(`[turbine] groupBy distinctOn on table "${this.table}" requires \`orderBy\` to pick ONE row per ` +
|
|
2064
|
+
"column combination deterministically (e.g. orderBy: { createdAt: 'desc' }).");
|
|
2065
|
+
}
|
|
2066
|
+
const distinctCols = distinctOn.columns.map((c) => this.q(this.toColumn(c)));
|
|
2067
|
+
// DISTINCT ON expressions must lead the ORDER BY; the user's orderBy then
|
|
2068
|
+
// decides which row survives per combination.
|
|
2069
|
+
const orderParts = [...distinctCols];
|
|
2070
|
+
for (const [key, value] of orderEntries) {
|
|
2071
|
+
if ((0, filters_js_1.isJsonPathOrderBy)(value)) {
|
|
2072
|
+
orderParts.push(this.buildJsonPathOrderEntry(this.table, this.tableMeta, key, value, '', params));
|
|
2073
|
+
continue;
|
|
2074
|
+
}
|
|
2075
|
+
if ((0, filters_js_1.isVectorOrderBy)(value) || this.isRelationOrderByValue(value)) {
|
|
2076
|
+
throw new errors_js_1.ValidationError(`[turbine] groupBy distinctOn.orderBy on "${key}" (table "${this.table}") supports plain columns, ` +
|
|
2077
|
+
'sort specs, and JSON-path orderings only.');
|
|
2078
|
+
}
|
|
2079
|
+
const col = this.resolveOrderByColumn(this.table, this.tableMeta, key);
|
|
2080
|
+
const { dir, nulls } = (0, filters_js_1.normalizeOrderBy)(value);
|
|
2081
|
+
orderParts.push(`${this.q(col)} ${dir}${this.nullsSuffix(nulls)}`);
|
|
2082
|
+
}
|
|
2083
|
+
return (`(SELECT DISTINCT ON (${distinctCols.join(', ')}) * FROM ${this.q(this.table)}${whereSql} ` +
|
|
2084
|
+
`ORDER BY ${orderParts.join(', ')}) AS ${this.q(this.table)}`);
|
|
2085
|
+
}
|
|
1747
2086
|
/**
|
|
1748
2087
|
* Build the SQL fragments for a {@link HavingClause}.
|
|
1749
2088
|
*
|
|
@@ -1754,8 +2093,14 @@ class QueryInterface {
|
|
|
1754
2093
|
* comparison value is pushed onto the shared `params` array and referenced by
|
|
1755
2094
|
* a `$N` placeholder via {@link buildHavingNumericClauses} — there is no string
|
|
1756
2095
|
* interpolation of user values.
|
|
2096
|
+
*
|
|
2097
|
+
* `jsonAggExprs` (from {@link buildGroupBy}) maps `alias:aggKey` to the
|
|
2098
|
+
* exact aggregate expression a JSON-path aggregate emitted in SELECT
|
|
2099
|
+
* (including its already-bound path placeholder), so HAVING on a JSON-path
|
|
2100
|
+
* aggregate alias reuses the same expression instead of resolving the alias
|
|
2101
|
+
* as a column.
|
|
1757
2102
|
*/
|
|
1758
|
-
buildHavingClauses(having, params) {
|
|
2103
|
+
buildHavingClauses(having, params, jsonAggExprs) {
|
|
1759
2104
|
const clauses = [];
|
|
1760
2105
|
// Maps the per-field aggregate key to its SQL function name. The set of
|
|
1761
2106
|
// allowed keys is fixed here — any other key on a field's filter object is
|
|
@@ -1782,8 +2127,14 @@ class QueryInterface {
|
|
|
1782
2127
|
}
|
|
1783
2128
|
// toColumn validates the field against schema metadata (throws
|
|
1784
2129
|
// ValidationError on unknown columns) and q() quotes the identifier — no
|
|
1785
|
-
// unvalidated identifier ever reaches the SQL string.
|
|
1786
|
-
|
|
2130
|
+
// unvalidated identifier ever reaches the SQL string. Resolution is lazy:
|
|
2131
|
+
// a JSON-path aggregate alias is not a column, so it must not hit
|
|
2132
|
+
// toColumn when every aggregate under it resolves via `jsonAggExprs`.
|
|
2133
|
+
let quotedCol = null;
|
|
2134
|
+
const columnExpr = () => {
|
|
2135
|
+
quotedCol ??= this.q(this.toColumn(key));
|
|
2136
|
+
return quotedCol;
|
|
2137
|
+
};
|
|
1787
2138
|
for (const [aggKey, filter] of Object.entries(value)) {
|
|
1788
2139
|
if (filter === undefined)
|
|
1789
2140
|
continue;
|
|
@@ -1792,7 +2143,7 @@ class QueryInterface {
|
|
|
1792
2143
|
throw new errors_js_1.ValidationError(`[turbine] Unknown aggregate "${aggKey}" in having for field "${key}" on table "${this.table}". ` +
|
|
1793
2144
|
`Supported: ${Object.keys(aggFnByKey).join(', ')}.`);
|
|
1794
2145
|
}
|
|
1795
|
-
const expr = `${fn}(${
|
|
2146
|
+
const expr = jsonAggExprs?.get(`${key}:${aggKey}`) ?? `${fn}(${columnExpr()})`;
|
|
1796
2147
|
clauses.push(...this.buildHavingNumericClauses(expr, filter, params));
|
|
1797
2148
|
}
|
|
1798
2149
|
}
|
|
@@ -2439,7 +2790,7 @@ class QueryInterface {
|
|
|
2439
2790
|
// JSONB filter
|
|
2440
2791
|
if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isJsonFilter)(value)) {
|
|
2441
2792
|
const colType = this.getColumnPgType(rawColumn);
|
|
2442
|
-
if (colType
|
|
2793
|
+
if (this.isJsonColumnType(colType)) {
|
|
2443
2794
|
this.collectJsonFilterParams(value, params, this.q(rawColumn));
|
|
2444
2795
|
continue;
|
|
2445
2796
|
}
|
|
@@ -2550,7 +2901,7 @@ class QueryInterface {
|
|
|
2550
2901
|
// the target column is json/jsonb.
|
|
2551
2902
|
if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isJsonFilter)(value)) {
|
|
2552
2903
|
const colType = this.pgTypeForColumn(meta, col);
|
|
2553
|
-
if (colType
|
|
2904
|
+
if (this.isJsonColumnType(colType)) {
|
|
2554
2905
|
this.collectJsonFilterParams(value, params, `${this.q(targetTable)}.${this.q(col)}`);
|
|
2555
2906
|
continue;
|
|
2556
2907
|
}
|
|
@@ -2620,7 +2971,8 @@ class QueryInterface {
|
|
|
2620
2971
|
let pathPushed = false;
|
|
2621
2972
|
const pushPathOnce = () => {
|
|
2622
2973
|
if (!pathPushed) {
|
|
2623
|
-
|
|
2974
|
+
// Only reached when a path-requiring clause validated filter.path.
|
|
2975
|
+
params.push(this.jsonPathParam(filter.path, filter.path));
|
|
2624
2976
|
pathPushed = true;
|
|
2625
2977
|
}
|
|
2626
2978
|
};
|
|
@@ -2672,15 +3024,20 @@ class QueryInterface {
|
|
|
2672
3024
|
// then the path bound as one text[] param.
|
|
2673
3025
|
if ((0, filters_js_1.isJsonPathOrderBy)(dir)) {
|
|
2674
3026
|
this.validateJsonPathOrderBy(this.table, this.tableMeta, key, dir);
|
|
2675
|
-
params.push(dir.path
|
|
3027
|
+
params.push(this.jsonPathParam(dir.path));
|
|
2676
3028
|
continue;
|
|
2677
3029
|
}
|
|
2678
3030
|
// To-many relation orderBy (`{ posts: { _count } }`) uses the same count
|
|
2679
|
-
// subquery as `_count
|
|
2680
|
-
//
|
|
3031
|
+
// subquery as `_count`: mirror its global-filter params. Pick-row
|
|
3032
|
+
// ordering mirrors its full param chain (by-path / global filter /
|
|
3033
|
+
// pick.where / pick.orderBy paths). To-one relation orderBy carries the
|
|
3034
|
+
// target's global filter once per ordered column.
|
|
2681
3035
|
if (this.isRelationOrderByValue(dir)) {
|
|
2682
3036
|
const relDef = this.tableMeta.relations[key];
|
|
2683
|
-
if (relDef && (
|
|
3037
|
+
if (relDef && (0, filters_js_1.isRelationPickOrderBy)(dir)) {
|
|
3038
|
+
this.collectRelationPickOrderParams(key, relDef, dir, params);
|
|
3039
|
+
}
|
|
3040
|
+
else if (relDef && (relDef.type === 'hasMany' || relDef.type === 'manyToMany')) {
|
|
2684
3041
|
this.collectRelationCountParams(relDef, params);
|
|
2685
3042
|
}
|
|
2686
3043
|
else if (relDef) {
|
|
@@ -2770,12 +3127,15 @@ class QueryInterface {
|
|
|
2770
3127
|
}
|
|
2771
3128
|
// orderBy shape (OrderBySpec nulls placement changes the SQL, so fingerprint it)
|
|
2772
3129
|
if (opts.orderBy) {
|
|
2773
|
-
const
|
|
3130
|
+
const targetRels = this.schema.tables[relDef.to]?.relations;
|
|
3131
|
+
const oEntries = Object.entries(opts.orderBy).map(([k, d]) => `${k}:${this.orderByEntryFingerprint(d, targetRels?.[k]?.to)}`);
|
|
2774
3132
|
subParts.push(`o=${oEntries.join(',')}`);
|
|
2775
3133
|
}
|
|
2776
|
-
// limit presence
|
|
3134
|
+
// limit presence, but on inline-pagination engines (MySQL) the literal
|
|
3135
|
+
// value is baked into the subquery SQL, so fingerprint the value there or
|
|
3136
|
+
// `{limit:3}` and `{limit:5}` would share one cached statement.
|
|
2777
3137
|
if (opts.limit !== undefined) {
|
|
2778
|
-
subParts.push('l=1');
|
|
3138
|
+
subParts.push(this.dialect.inlineLimitOffset ? `l=${opts.limit}` : 'l=1');
|
|
2779
3139
|
}
|
|
2780
3140
|
// nested with (recurse)
|
|
2781
3141
|
if (opts.with) {
|
|
@@ -3180,7 +3540,7 @@ class QueryInterface {
|
|
|
3180
3540
|
// Handle JSONB filter operators (for json/jsonb columns)
|
|
3181
3541
|
if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isJsonFilter)(value)) {
|
|
3182
3542
|
const colType = this.getColumnPgType(rawColumn);
|
|
3183
|
-
if (colType
|
|
3543
|
+
if (this.isJsonColumnType(colType)) {
|
|
3184
3544
|
const jsonClauses = this.buildJsonFilterClauses(column, value, params);
|
|
3185
3545
|
andClauses.push(...jsonClauses);
|
|
3186
3546
|
continue;
|
|
@@ -3395,7 +3755,7 @@ class QueryInterface {
|
|
|
3395
3755
|
// jsonb value, silently matching nothing.
|
|
3396
3756
|
if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isJsonFilter)(value)) {
|
|
3397
3757
|
const colType = this.pgTypeForColumn(meta, col);
|
|
3398
|
-
if (colType
|
|
3758
|
+
if (this.isJsonColumnType(colType)) {
|
|
3399
3759
|
conditions.push(...this.buildJsonFilterClauses(qCol, value, params));
|
|
3400
3760
|
continue;
|
|
3401
3761
|
}
|
|
@@ -3495,7 +3855,7 @@ class QueryInterface {
|
|
|
3495
3855
|
assertBindableEqualityValue(rawColumn, value, columnPgType, table) {
|
|
3496
3856
|
if (!(0, filters_js_1.isUnmatchedPlainObject)(value))
|
|
3497
3857
|
return;
|
|
3498
|
-
if (columnPgType
|
|
3858
|
+
if (this.isJsonColumnType(columnPgType))
|
|
3499
3859
|
return;
|
|
3500
3860
|
const badKeys = Object.keys(value);
|
|
3501
3861
|
throw new errors_js_1.ValidationError(badKeys.length === 0
|
|
@@ -3566,7 +3926,7 @@ class QueryInterface {
|
|
|
3566
3926
|
// bound as a plain equality value.
|
|
3567
3927
|
if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isJsonFilter)(value)) {
|
|
3568
3928
|
const colType = this.pgTypeForColumn(targetMeta, col);
|
|
3569
|
-
if (colType
|
|
3929
|
+
if (this.isJsonColumnType(colType)) {
|
|
3570
3930
|
clauses.push(...this.buildJsonFilterClauses(qCol, value, params));
|
|
3571
3931
|
continue;
|
|
3572
3932
|
}
|
|
@@ -3639,7 +3999,7 @@ class QueryInterface {
|
|
|
3639
3999
|
// JSONB filter — mirrors buildAliasWhere.
|
|
3640
4000
|
if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isJsonFilter)(value)) {
|
|
3641
4001
|
const colType = this.pgTypeForColumn(targetMeta, col);
|
|
3642
|
-
if (colType
|
|
4002
|
+
if (this.isJsonColumnType(colType)) {
|
|
3643
4003
|
this.collectJsonFilterParams(value, params, this.q(col));
|
|
3644
4004
|
continue;
|
|
3645
4005
|
}
|
|
@@ -3872,7 +4232,7 @@ class QueryInterface {
|
|
|
3872
4232
|
* vs relation-column never collide on one cached SQL string. Captures the
|
|
3873
4233
|
* SQL-shaping bits (direction, nulls, metric, relation keys) — never values.
|
|
3874
4234
|
*/
|
|
3875
|
-
orderByEntryFingerprint(d) {
|
|
4235
|
+
orderByEntryFingerprint(d, targetTable) {
|
|
3876
4236
|
// Vector KNN ordering changes the emitted operator by metric and adds a
|
|
3877
4237
|
// `::vector` param, so metric + direction must be part of the cache key.
|
|
3878
4238
|
if ((0, filters_js_1.isVectorOrderBy)(d)) {
|
|
@@ -3883,13 +4243,36 @@ class QueryInterface {
|
|
|
3883
4243
|
if ((0, filters_js_1.isJsonPathOrderBy)(d)) {
|
|
3884
4244
|
return `jp(${d.direction ?? 'asc'},${d.type === 'numeric' ? 'num' : 'text'},${d.nulls ?? ''})`;
|
|
3885
4245
|
}
|
|
4246
|
+
// Pick-row relation ordering: the by-shape (column vs JSON path vs cast),
|
|
4247
|
+
// direction, nulls, pick.orderBy shape, and pick.where SHAPE are all SQL
|
|
4248
|
+
// text; the JSON paths and pick.where values are bound params and stay OUT
|
|
4249
|
+
// of the key. `targetTable` (the relation's target, resolved by the
|
|
4250
|
+
// caller) lets the pick.where fingerprint distinguish relation-filter
|
|
4251
|
+
// shapes inside it: two pick.wheres that differ only in shape must never
|
|
4252
|
+
// share one cached SQL string.
|
|
4253
|
+
if ((0, filters_js_1.isRelationPickOrderBy)(d)) {
|
|
4254
|
+
const by = typeof d.by === 'string'
|
|
4255
|
+
? `col=${JSON.stringify(d.by)}`
|
|
4256
|
+
: `jp(${JSON.stringify(d.by?.field)},${d.by?.type === 'numeric' ? 'num' : 'text'})`;
|
|
4257
|
+
const pickOrder = Object.entries(d.pick?.orderBy ?? {})
|
|
4258
|
+
.map(([k, v]) => `${k}:${this.orderByEntryFingerprint(v)}`)
|
|
4259
|
+
.join(',');
|
|
4260
|
+
const pickWhere = d.pick?.where
|
|
4261
|
+
? `;pw=${this.fingerprintAliasWhere(d.pick.where, targetTable)}`
|
|
4262
|
+
: '';
|
|
4263
|
+
return `pick(${by},${d.direction ?? 'asc'},${d.nulls ?? ''};po=${pickOrder}${pickWhere})`;
|
|
4264
|
+
}
|
|
3886
4265
|
if ((0, filters_js_1.isOrderBySpec)(d))
|
|
3887
4266
|
return `spec(${d.sort},${d.nulls ?? ''})`;
|
|
3888
4267
|
if (d && typeof d === 'object') {
|
|
3889
4268
|
// Relation ordering (`{ _count: 'desc' }` or `{ name: 'asc' }`).
|
|
4269
|
+
// INSERTION order, never sorted: the compile side (buildRelationOrderBy)
|
|
4270
|
+
// emits one ORDER BY term per entry in Object.entries order, so entry
|
|
4271
|
+
// order is SQL-shaping precedence. A sorted fingerprint made
|
|
4272
|
+
// `{ name: 'asc', email: 'desc' }` and the swapped literal share one
|
|
4273
|
+
// cached SQL string — silently mis-ordered results on a warm cache.
|
|
3890
4274
|
return `rel(${Object.entries(d)
|
|
3891
4275
|
.map(([k, v]) => `${k}=${this.orderByEntryFingerprint(v)}`)
|
|
3892
|
-
.sort()
|
|
3893
4276
|
.join(',')})`;
|
|
3894
4277
|
}
|
|
3895
4278
|
return String(d);
|
|
@@ -4007,7 +4390,7 @@ class QueryInterface {
|
|
|
4007
4390
|
`of keys/indexes (e.g. { path: ['weight'], direction: 'asc' }).`);
|
|
4008
4391
|
}
|
|
4009
4392
|
const colType = this.pgTypeForColumn(meta, col);
|
|
4010
|
-
if (colType
|
|
4393
|
+
if (!this.isJsonColumnType(colType)) {
|
|
4011
4394
|
throw new errors_js_1.ValidationError(`[turbine] JSON-path orderBy on "${field}": column "${col}" on table "${table}" is not a JSON column ` +
|
|
4012
4395
|
`(actual type: ${colType}).`);
|
|
4013
4396
|
}
|
|
@@ -4027,7 +4410,7 @@ class QueryInterface {
|
|
|
4027
4410
|
if (!params) {
|
|
4028
4411
|
throw new errors_js_1.ValidationError(`[turbine] JSON-path ordering on "${field}" is not supported in this orderBy context.`);
|
|
4029
4412
|
}
|
|
4030
|
-
params.push(spec.path
|
|
4413
|
+
params.push(this.jsonPathParam(spec.path));
|
|
4031
4414
|
const extract = this.dialect.buildJsonPathExtract(`${prefix}${this.q(col)}`, this.p(params.length));
|
|
4032
4415
|
const lhs = spec.type === 'numeric' ? this.castJsonNumeric(extract) : extract;
|
|
4033
4416
|
const dir = spec.direction?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
|
@@ -4055,12 +4438,21 @@ class QueryInterface {
|
|
|
4055
4438
|
throw new errors_js_1.RelationError(`[turbine] Unknown relation "${relName}" in orderBy on table "${ownerTable}". ` +
|
|
4056
4439
|
`Available: ${Object.keys(ownerMeta.relations).join(', ')}`);
|
|
4057
4440
|
}
|
|
4441
|
+
// Pick-row ordering (`{ pick, by }`): order by a value from ONE related
|
|
4442
|
+
// row: a correlated scalar subquery with its own ORDER BY … LIMIT 1.
|
|
4443
|
+
// Top-level findMany only (`ctx` present means we are inside a relation
|
|
4444
|
+
// subquery's orderBy) and hasMany only: validatePickOrderBy throws the
|
|
4445
|
+
// scope errors, shared with the cache-hit collect mirror.
|
|
4446
|
+
if ((0, filters_js_1.isRelationPickOrderBy)(value)) {
|
|
4447
|
+
this.validatePickOrderBy(relName, relDef, value, ctx !== undefined);
|
|
4448
|
+
return this.buildRelationPickOrderBy(relName, relDef, value, alias, parentRef, params);
|
|
4449
|
+
}
|
|
4058
4450
|
// To-many: only `_count` is meaningful → correlated COUNT(*) subquery.
|
|
4059
4451
|
if (relDef.type === 'hasMany' || relDef.type === 'manyToMany') {
|
|
4060
4452
|
const keys = Object.keys(value);
|
|
4061
4453
|
if (keys.length !== 1 || keys[0] !== '_count') {
|
|
4062
4454
|
throw new errors_js_1.ValidationError(`[turbine] orderBy on to-many relation "${relName}" only supports "_count" ` +
|
|
4063
|
-
`(got: ${keys.join(', ') || '(empty)'}).`);
|
|
4455
|
+
`or a pick-row ordering ({ pick, by }) (got: ${keys.join(', ') || '(empty)'}).`);
|
|
4064
4456
|
}
|
|
4065
4457
|
const { dir } = (0, filters_js_1.normalizeOrderBy)(value._count);
|
|
4066
4458
|
return `${this.buildRelationCountExpr(relDef, parentRef, alias, params)} ${dir}`;
|
|
@@ -4101,6 +4493,145 @@ class QueryInterface {
|
|
|
4101
4493
|
})
|
|
4102
4494
|
.join(', ');
|
|
4103
4495
|
}
|
|
4496
|
+
/**
|
|
4497
|
+
* Validate a {@link RelationPickOrderBy} entry's scope and shape. Shared by
|
|
4498
|
+
* the SQL-build path ({@link buildRelationPickOrderBy}) and the cache-hit
|
|
4499
|
+
* param-collect mirror ({@link collectRelationPickOrderParams}) so both
|
|
4500
|
+
* always throw identically:
|
|
4501
|
+
*
|
|
4502
|
+
* - `nested` (inside a relation subquery's orderBy or a pick.orderBy):
|
|
4503
|
+
* top-level findMany only in this release (E003),
|
|
4504
|
+
* - manyToMany: not supported (E003 naming the limitation),
|
|
4505
|
+
* - to-one: order by the target column directly instead (E003),
|
|
4506
|
+
* - `pick.orderBy` is REQUIRED (deterministic row choice),
|
|
4507
|
+
* - `by` must be a target column name or a `{ field, path }` JSON-path spec.
|
|
4508
|
+
*/
|
|
4509
|
+
pickOrderNestedError(relName) {
|
|
4510
|
+
return new errors_js_1.ValidationError(`[turbine] Pick-row ordering on relation "${relName}" is only supported in a top-level ` +
|
|
4511
|
+
'findMany orderBy: nested `with` orderBy does not support it.');
|
|
4512
|
+
}
|
|
4513
|
+
validatePickOrderBy(relName, relDef, spec, nested) {
|
|
4514
|
+
if (nested) {
|
|
4515
|
+
throw this.pickOrderNestedError(relName);
|
|
4516
|
+
}
|
|
4517
|
+
if (relDef.type === 'manyToMany') {
|
|
4518
|
+
throw new errors_js_1.ValidationError(`[turbine] Pick-row ordering is not supported on manyToMany relation "${relName}": ` +
|
|
4519
|
+
'hasMany relations only.');
|
|
4520
|
+
}
|
|
4521
|
+
if (relDef.type !== 'hasMany') {
|
|
4522
|
+
throw new errors_js_1.ValidationError(`[turbine] Pick-row ordering is only for to-many (hasMany) relations; "${relName}" is ${relDef.type}. ` +
|
|
4523
|
+
`Order by the target column directly instead ({ ${relName}: { <column>: 'asc' } }).`);
|
|
4524
|
+
}
|
|
4525
|
+
const pickOrder = spec.pick?.orderBy;
|
|
4526
|
+
if (typeof spec.pick !== 'object' ||
|
|
4527
|
+
spec.pick === null ||
|
|
4528
|
+
typeof pickOrder !== 'object' ||
|
|
4529
|
+
pickOrder === null ||
|
|
4530
|
+
Object.keys(pickOrder).length === 0) {
|
|
4531
|
+
throw new errors_js_1.ValidationError(`[turbine] Pick-row ordering on relation "${relName}" requires \`pick.orderBy\` to choose ONE ` +
|
|
4532
|
+
"related row deterministically (e.g. pick: { orderBy: { createdAt: 'desc' } }).");
|
|
4533
|
+
}
|
|
4534
|
+
const by = spec.by;
|
|
4535
|
+
const validJsonBy = typeof by === 'object' && by !== null && typeof by.field === 'string' && Array.isArray(by.path);
|
|
4536
|
+
if (typeof by !== 'string' && !validJsonBy) {
|
|
4537
|
+
throw new errors_js_1.ValidationError(`[turbine] Pick-row ordering on relation "${relName}" requires \`by\`: a target column name ` +
|
|
4538
|
+
"or a JSON-path spec ({ field: 'data', path: ['title'] }).");
|
|
4539
|
+
}
|
|
4540
|
+
}
|
|
4541
|
+
/**
|
|
4542
|
+
* Compile a {@link RelationPickOrderBy} term: a correlated scalar subquery
|
|
4543
|
+
* that picks ONE related row (`ORDER BY <pick.orderBy> LIMIT 1`, optionally
|
|
4544
|
+
* filtered by `pick.where` and the target's global filter) and surfaces one
|
|
4545
|
+
* value from it (a plain target column or a JSON-path extraction) as the
|
|
4546
|
+
* parent ORDER BY key:
|
|
4547
|
+
*
|
|
4548
|
+
* ```sql
|
|
4549
|
+
* (SELECT ord0."data" #>> $1::text[] FROM "versions" ord0
|
|
4550
|
+
* WHERE ord0."instance_id" = "instances"."id" AND ord0."is_current" = $2
|
|
4551
|
+
* ORDER BY ord0."created_at" DESC LIMIT 1) ASC NULLS LAST
|
|
4552
|
+
* ```
|
|
4553
|
+
*
|
|
4554
|
+
* Param-push order (mirrored EXACTLY by
|
|
4555
|
+
* {@link collectRelationPickOrderParams}): `by` JSON path (if any) →
|
|
4556
|
+
* target global filter → `pick.where` → `pick.orderBy` JSON paths.
|
|
4557
|
+
*/
|
|
4558
|
+
buildRelationPickOrderBy(relName, relDef, spec, alias, parentRef, params) {
|
|
4559
|
+
if (!params) {
|
|
4560
|
+
throw new errors_js_1.ValidationError(`[turbine] Pick-row ordering on relation "${relName}" is only supported in a top-level findMany orderBy.`);
|
|
4561
|
+
}
|
|
4562
|
+
const targetMeta = this.schema.tables[relDef.to];
|
|
4563
|
+
if (!targetMeta)
|
|
4564
|
+
throw new errors_js_1.RelationError(`[turbine] Unknown relation target "${relDef.to}"`);
|
|
4565
|
+
// The value surfaced from the picked row (SELECT list: its param binds first).
|
|
4566
|
+
let byExpr;
|
|
4567
|
+
if (typeof spec.by === 'string') {
|
|
4568
|
+
const col = this.resolveOrderByColumn(relDef.to, targetMeta, spec.by);
|
|
4569
|
+
byExpr = `${alias}.${this.q(col)}`;
|
|
4570
|
+
}
|
|
4571
|
+
else {
|
|
4572
|
+
const col = this.validateJsonPathOrderBy(relDef.to, targetMeta, spec.by.field, {
|
|
4573
|
+
path: spec.by.path,
|
|
4574
|
+
});
|
|
4575
|
+
params.push(this.jsonPathParam(spec.by.path));
|
|
4576
|
+
const extract = this.dialect.buildJsonPathExtract(`${alias}.${this.q(col)}`, this.p(params.length));
|
|
4577
|
+
byExpr = spec.by.type === 'numeric' ? this.castJsonNumeric(extract) : extract;
|
|
4578
|
+
}
|
|
4579
|
+
// Correlation to the parent row, then the target's global filter (a
|
|
4580
|
+
// soft-deleted / other-tenant row must never be picked: matches the
|
|
4581
|
+
// `with` subquery and to-one relation-orderBy semantics), then pick.where.
|
|
4582
|
+
let where = this.dialect.buildCorrelation(alias, relDef.foreignKey, this.q(parentRef), relDef.referenceKey);
|
|
4583
|
+
const gf = this.targetGlobalFilterAlias(relDef.to, alias, params);
|
|
4584
|
+
if (gf)
|
|
4585
|
+
where += ` AND ${gf}`;
|
|
4586
|
+
if (spec.pick.where) {
|
|
4587
|
+
const pickWhere = this.buildAliasWhere(relDef.to, targetMeta, alias, spec.pick.where, params);
|
|
4588
|
+
if (pickWhere)
|
|
4589
|
+
where += ` AND ${pickWhere}`;
|
|
4590
|
+
}
|
|
4591
|
+
// pick.orderBy: same surface as a relation `with` orderBy on the target
|
|
4592
|
+
// (plain columns, OrderBySpec nulls, JSON-path specs); a nested pick in
|
|
4593
|
+
// here routes back through buildRelationOrderBy with ctx set and throws
|
|
4594
|
+
// the top-level-only E003.
|
|
4595
|
+
const orderClause = this.buildRelationOrderClause(relDef.to, targetMeta, alias, Object.entries(spec.pick.orderBy), params);
|
|
4596
|
+
const dir = spec.direction?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
|
4597
|
+
const limitOne = this.buildPagination('1', undefined, true);
|
|
4598
|
+
// Parents with ZERO surviving related rows make the correlated subquery
|
|
4599
|
+
// yield NULL. Without a nulls clause, Postgres DESC defaults to NULLS
|
|
4600
|
+
// FIRST — every childless parent would top a "highest first" sort. Default
|
|
4601
|
+
// to NULLS LAST in BOTH directions (deterministic across engines: SQLite's
|
|
4602
|
+
// NULL-is-smallest default diverges from Postgres) unless the caller set
|
|
4603
|
+
// `nulls` explicitly; the grammar gate matches nullsSuffix (PG + SQLite).
|
|
4604
|
+
const nullsSql = spec.nulls
|
|
4605
|
+
? this.nullsSuffix(spec.nulls)
|
|
4606
|
+
: this.dialect.name === 'postgresql' || this.dialect.name === 'sqlite'
|
|
4607
|
+
? ' NULLS LAST'
|
|
4608
|
+
: '';
|
|
4609
|
+
return `(SELECT ${byExpr} FROM ${this.q(relDef.to)} ${alias} WHERE ${where}${orderClause}${limitOne}) ${dir}${nullsSql}`;
|
|
4610
|
+
}
|
|
4611
|
+
/**
|
|
4612
|
+
* Param-collect mirror of {@link buildRelationPickOrderBy}: re-runs the same
|
|
4613
|
+
* validation (a warmed cache can never skip it), then pushes in the same
|
|
4614
|
+
* order: `by` JSON path → target global filter → `pick.where` →
|
|
4615
|
+
* `pick.orderBy` JSON paths.
|
|
4616
|
+
*/
|
|
4617
|
+
collectRelationPickOrderParams(relName, relDef, spec, params) {
|
|
4618
|
+
this.validatePickOrderBy(relName, relDef, spec, false);
|
|
4619
|
+
const targetMeta = this.schema.tables[relDef.to];
|
|
4620
|
+
if (!targetMeta)
|
|
4621
|
+
throw new errors_js_1.RelationError(`[turbine] Unknown relation target "${relDef.to}"`);
|
|
4622
|
+
if (typeof spec.by === 'string') {
|
|
4623
|
+
this.resolveOrderByColumn(relDef.to, targetMeta, spec.by);
|
|
4624
|
+
}
|
|
4625
|
+
else {
|
|
4626
|
+
this.validateJsonPathOrderBy(relDef.to, targetMeta, spec.by.field, { path: spec.by.path });
|
|
4627
|
+
params.push(this.jsonPathParam(spec.by.path));
|
|
4628
|
+
}
|
|
4629
|
+
this.collectTargetGlobalFilterAlias(relDef.to, params);
|
|
4630
|
+
if (spec.pick.where) {
|
|
4631
|
+
this.collectAliasWhereParams(relDef.to, targetMeta, spec.pick.where, params);
|
|
4632
|
+
}
|
|
4633
|
+
this.collectRelationOrderParams(relDef.to, targetMeta, Object.entries(spec.pick.orderBy), params);
|
|
4634
|
+
}
|
|
4104
4635
|
/**
|
|
4105
4636
|
* Compile the ORDER BY terms of a relation `with` clause against the
|
|
4106
4637
|
* relation's table alias. One unified path for every relation shape
|
|
@@ -4151,10 +4682,15 @@ class QueryInterface {
|
|
|
4151
4682
|
}
|
|
4152
4683
|
if ((0, filters_js_1.isJsonPathOrderBy)(dirValue)) {
|
|
4153
4684
|
this.validateJsonPathOrderBy(targetTable, targetMeta, key, dirValue);
|
|
4154
|
-
params.push(dirValue.path
|
|
4685
|
+
params.push(this.jsonPathParam(dirValue.path));
|
|
4155
4686
|
continue;
|
|
4156
4687
|
}
|
|
4157
4688
|
if (this.isRelationOrderByValue(dirValue)) {
|
|
4689
|
+
// Pick-row ordering is top-level-only: the build path throws the same
|
|
4690
|
+
// E003 (buildRelationOrderBy with ctx set), so the mirror must too.
|
|
4691
|
+
if ((0, filters_js_1.isRelationPickOrderBy)(dirValue)) {
|
|
4692
|
+
throw this.pickOrderNestedError(key);
|
|
4693
|
+
}
|
|
4158
4694
|
const relDef = targetMeta.relations[key];
|
|
4159
4695
|
if (relDef && (relDef.type === 'hasMany' || relDef.type === 'manyToMany')) {
|
|
4160
4696
|
this.collectRelationCountParams(relDef, params);
|
|
@@ -5113,6 +5649,16 @@ class QueryInterface {
|
|
|
5113
5649
|
* Used to detect JSONB/array columns for specialized operators.
|
|
5114
5650
|
* Uses pre-computed Map for O(1) lookup instead of linear scan.
|
|
5115
5651
|
*/
|
|
5652
|
+
/**
|
|
5653
|
+
* Case-insensitive json/jsonb column-type check. Postgres reports lowercase
|
|
5654
|
+
* udt_names, but SQLite/MySQL introspection surfaces the DECLARED type
|
|
5655
|
+
* (e.g. `JSON`), so every JSON-feature gate compares through this predicate
|
|
5656
|
+
* — build and collect sides alike, keeping the SQL-cache lockstep.
|
|
5657
|
+
*/
|
|
5658
|
+
isJsonColumnType(colType) {
|
|
5659
|
+
const t = colType.toLowerCase();
|
|
5660
|
+
return t === 'json' || t === 'jsonb';
|
|
5661
|
+
}
|
|
5116
5662
|
getColumnPgType(column) {
|
|
5117
5663
|
return this.columnPgTypeMap.get(column) ?? 'text';
|
|
5118
5664
|
}
|
|
@@ -5182,7 +5728,8 @@ class QueryInterface {
|
|
|
5182
5728
|
let pathParamIdx = null;
|
|
5183
5729
|
const pathExtract = () => {
|
|
5184
5730
|
if (pathParamIdx === null) {
|
|
5185
|
-
|
|
5731
|
+
// Only reached when a path-requiring clause validated filter.path.
|
|
5732
|
+
params.push(this.jsonPathParam(filter.path, filter.path));
|
|
5186
5733
|
pathParamIdx = params.length;
|
|
5187
5734
|
}
|
|
5188
5735
|
return this.dialect.buildJsonPathExtract(column, this.p(pathParamIdx));
|
|
@@ -5218,6 +5765,24 @@ class QueryInterface {
|
|
|
5218
5765
|
}
|
|
5219
5766
|
return clauses;
|
|
5220
5767
|
}
|
|
5768
|
+
/**
|
|
5769
|
+
* Bind value for a JSON path parameter, encoded per dialect. PostgreSQL's
|
|
5770
|
+
* `#>>` takes a `text[]` (the segments as strings — or `nativeForm` when the
|
|
5771
|
+
* caller has a specific native binding, e.g. JsonFilter's raw path array).
|
|
5772
|
+
* Every other engine's JSON function (`json_extract` / `JSON_EXTRACT` /
|
|
5773
|
+
* `JSON_VALUE`) takes a `'$'`-rooted JSONPath STRING: binding the raw array
|
|
5774
|
+
* would arrive as `'["a"]'` (the driver shims JSON.stringify non-primitive
|
|
5775
|
+
* params) and fail at runtime with the engine's bad-JSON-path error. The
|
|
5776
|
+
* encoded path stays a bound parameter — never spliced into SQL text — so
|
|
5777
|
+
* the build/collect param mirrors stay in lockstep and injection-safe.
|
|
5778
|
+
*/
|
|
5779
|
+
jsonPathParam(path, nativeForm) {
|
|
5780
|
+
if (this.dialect.jsonPathSupport === 'native')
|
|
5781
|
+
return nativeForm ?? path.map(String);
|
|
5782
|
+
return `$${path
|
|
5783
|
+
.map((seg) => typeof seg === 'number' || /^\d+$/.test(String(seg)) ? `[${seg}]` : `."${String(seg).replace(/"/g, '\\"')}"`)
|
|
5784
|
+
.join('')}`;
|
|
5785
|
+
}
|
|
5221
5786
|
/**
|
|
5222
5787
|
* Cast an extracted JSON path text value to a numeric type for range
|
|
5223
5788
|
* comparison. PostgreSQL uses `(expr)::numeric` (exact — the right way to
|