turbine-orm 0.32.0 → 0.32.2
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 +12 -1
- package/dist/cjs/query/builder.js +332 -44
- 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/powql.js +12 -1
- package/dist/query/builder.d.ts +57 -0
- package/dist/query/builder.js +332 -44
- package/dist/query/deferred.d.ts +10 -1
- package/dist/query/types.d.ts +40 -2
- package/package.json +3 -2
package/dist/query/builder.js
CHANGED
|
@@ -20,6 +20,105 @@ import { assertBindableEqualsOperand, findArrayUniqueKey, findJsonUniqueKey, fin
|
|
|
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;
|
|
@@ -346,24 +454,84 @@ export class QueryInterface {
|
|
|
346
454
|
* On hit, increments counters and returns the cached entry.
|
|
347
455
|
*
|
|
348
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.
|
|
349
467
|
*/
|
|
350
468
|
acquireSql(cacheKey, build) {
|
|
351
469
|
if (!this.sqlCacheEnabled) {
|
|
352
|
-
|
|
470
|
+
this.lastCacheHit = false;
|
|
471
|
+
const sql = build([]);
|
|
353
472
|
this.cacheMisses++;
|
|
354
473
|
return { sql, name: sqlToPreparedName(sql) };
|
|
355
474
|
}
|
|
356
475
|
const cached = this.sqlTemplateCache.get(cacheKey);
|
|
357
476
|
if (cached) {
|
|
358
477
|
this.cacheHits++;
|
|
478
|
+
this.lastCacheHit = true;
|
|
359
479
|
return cached;
|
|
360
480
|
}
|
|
361
|
-
|
|
481
|
+
this.lastCacheHit = false;
|
|
482
|
+
const sql = build([]);
|
|
362
483
|
const entry = { sql, name: sqlToPreparedName(sql) };
|
|
363
484
|
this.sqlTemplateCache.set(cacheKey, entry);
|
|
364
485
|
this.cacheMisses++;
|
|
365
486
|
return entry;
|
|
366
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
|
+
}
|
|
367
535
|
/**
|
|
368
536
|
* Reset the per-instance unlimited-query warning dedupe set.
|
|
369
537
|
* Exposed for tests so a single test process can verify the warning fires
|
|
@@ -562,19 +730,22 @@ export class QueryInterface {
|
|
|
562
730
|
});
|
|
563
731
|
// Simple path: plain equality, no operators/null/OR
|
|
564
732
|
if (!args.with && isSimpleWhere) {
|
|
565
|
-
const
|
|
733
|
+
const buildSql = (freshParams) => {
|
|
566
734
|
const qt = this.q(this.table);
|
|
567
|
-
const
|
|
568
|
-
|
|
735
|
+
const whereClauses = whereKeys.map((k, i) => {
|
|
736
|
+
freshParams.push(whereObj[k]);
|
|
737
|
+
return `${this.toSqlColumn(k)} = ${this.p(i + 1)}`;
|
|
738
|
+
});
|
|
569
739
|
const whereSql = whereClauses.length > 0 ? ` WHERE ${whereClauses.join(' AND ')}` : '';
|
|
570
740
|
const selectExpr = columnsList ? columnsList.map((c) => `${qt}.${this.q(c)}`).join(', ') : `${qt}.*`;
|
|
571
|
-
void tempParams; // params are positional, SQL is value-invariant
|
|
572
741
|
return `SELECT ${selectExpr} FROM ${qt}${whereSql}${this.limitOneClause()}`;
|
|
573
|
-
}
|
|
742
|
+
};
|
|
743
|
+
const entry = this.acquireSql(ck, buildSql);
|
|
574
744
|
// Collect params (same order as build)
|
|
575
745
|
for (const k of whereKeys) {
|
|
576
746
|
params.push(whereObj[k]);
|
|
577
747
|
}
|
|
748
|
+
this.crossCheckCache('findUnique', ck, entry, buildSql, params);
|
|
578
749
|
return {
|
|
579
750
|
sql: entry.sql,
|
|
580
751
|
params,
|
|
@@ -588,16 +759,17 @@ export class QueryInterface {
|
|
|
588
759
|
}
|
|
589
760
|
// General path (with operators, null, OR, with clause)
|
|
590
761
|
if (!args.with) {
|
|
591
|
-
const
|
|
592
|
-
const freshParams = [];
|
|
762
|
+
const buildSql = (freshParams) => {
|
|
593
763
|
const clause = this.buildWhereClause(whereObj, freshParams);
|
|
594
764
|
const whereSql = clause ? ` WHERE ${clause}` : '';
|
|
595
765
|
const qt = this.q(this.table);
|
|
596
766
|
const selectExpr = columnsList ? columnsList.map((c) => `${qt}.${this.q(c)}`).join(', ') : `${qt}.*`;
|
|
597
767
|
return `SELECT ${selectExpr} FROM ${qt}${whereSql}${this.limitOneClause()}`;
|
|
598
|
-
}
|
|
768
|
+
};
|
|
769
|
+
const entry = this.acquireSql(ck, buildSql);
|
|
599
770
|
// Collect params
|
|
600
771
|
this.collectWhereParams(whereObj, params);
|
|
772
|
+
this.crossCheckCache('findUnique', ck, entry, buildSql, params);
|
|
601
773
|
return {
|
|
602
774
|
sql: entry.sql,
|
|
603
775
|
params,
|
|
@@ -614,16 +786,17 @@ export class QueryInterface {
|
|
|
614
786
|
// 1. buildWhere pushes where params
|
|
615
787
|
// 2. buildSelectWithRelations pushes relation params to same array
|
|
616
788
|
// We must preserve this exact order.
|
|
617
|
-
const
|
|
618
|
-
const freshParams = [];
|
|
789
|
+
const buildSql = (freshParams) => {
|
|
619
790
|
const clause = this.buildWhereClause(whereObj, freshParams);
|
|
620
791
|
const whereSql = clause ? ` WHERE ${clause}` : '';
|
|
621
792
|
const selectClause = this.buildSelectWithRelations(this.table, args.with, freshParams, columnsList);
|
|
622
793
|
return `SELECT ${selectClause} FROM ${this.q(this.table)}${whereSql}${this.limitOneClause()}`;
|
|
623
|
-
}
|
|
794
|
+
};
|
|
795
|
+
const entry = this.acquireSql(ck, buildSql);
|
|
624
796
|
// Collect params in exact build order: where first, then with-clause relations
|
|
625
797
|
this.collectWhereParams(whereObj, params);
|
|
626
798
|
this.collectWithParams(args.with, params);
|
|
799
|
+
this.crossCheckCache('findUnique', ck, entry, buildSql, params);
|
|
627
800
|
const parseWith = this.makeNestedParser(args.with);
|
|
628
801
|
return {
|
|
629
802
|
sql: entry.sql,
|
|
@@ -688,7 +861,7 @@ export class QueryInterface {
|
|
|
688
861
|
if (this.warnedTables.has(this.table))
|
|
689
862
|
return;
|
|
690
863
|
this.warnedTables.add(this.table);
|
|
691
|
-
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. ` +
|
|
692
865
|
'Pass `limit`, or silence with `warnOnUnlimited: false` (per call, per table, or in config).');
|
|
693
866
|
}
|
|
694
867
|
/**
|
|
@@ -745,15 +918,24 @@ export class QueryInterface {
|
|
|
745
918
|
.sort()
|
|
746
919
|
.join(',')
|
|
747
920
|
: '';
|
|
748
|
-
|
|
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(',') : '';
|
|
749
925
|
const effectiveLimit = args?.take ?? args?.limit ?? this.defaultLimit;
|
|
750
|
-
|
|
751
|
-
|
|
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';
|
|
752
935
|
const ck = `fm:${whereFp}|c=${colKey}|o=${orderFp}|l=${limitFp}|off=${offsetFp}|cur=${cursorFp}|d=${distinctFp}|w=${withFp}${this.globalFilterCacheSegment()}`;
|
|
753
936
|
const params = [];
|
|
754
|
-
const
|
|
755
|
-
// Fresh build
|
|
756
|
-
const freshParams = [];
|
|
937
|
+
const buildSql = (freshParams) => {
|
|
938
|
+
// Fresh build: generates SQL and populates freshParams
|
|
757
939
|
const { sql: freshWhereSql } = hasWhere
|
|
758
940
|
? (() => {
|
|
759
941
|
const clause = this.buildWhereClause(whereObj, freshParams);
|
|
@@ -833,7 +1015,8 @@ export class QueryInterface {
|
|
|
833
1015
|
}
|
|
834
1016
|
sql += this.buildPagination(limitPh, offsetPh, !!args?.orderBy);
|
|
835
1017
|
return sql;
|
|
836
|
-
}
|
|
1018
|
+
};
|
|
1019
|
+
const entry = this.acquireSql(ck, buildSql);
|
|
837
1020
|
// Collect params in exact build order:
|
|
838
1021
|
// 1. WHERE params (includes the AND-merged global filter, if any)
|
|
839
1022
|
if (hasWhere) {
|
|
@@ -864,6 +1047,7 @@ export class QueryInterface {
|
|
|
864
1047
|
if (args?.offset !== undefined && !this.dialect.inlineLimitOffset) {
|
|
865
1048
|
params.push(Number(args.offset));
|
|
866
1049
|
}
|
|
1050
|
+
this.crossCheckCache('findMany', ck, entry, buildSql, params);
|
|
867
1051
|
// Build the row parser once (positional shapes are computed here, not per row).
|
|
868
1052
|
const parseWith = args?.with ? this.makeNestedParser(args.with) : null;
|
|
869
1053
|
return {
|
|
@@ -1189,8 +1373,7 @@ export class QueryInterface {
|
|
|
1189
1373
|
const whereFp = this.fingerprintWhere(whereObj);
|
|
1190
1374
|
const ck = lock ? null : `u:${setFp}|${whereFp}${this.globalFilterCacheSegment()}`;
|
|
1191
1375
|
const params = [];
|
|
1192
|
-
const buildSql = () => {
|
|
1193
|
-
const freshParams = [];
|
|
1376
|
+
const buildSql = (freshParams) => {
|
|
1194
1377
|
const setEntries = Object.entries(dataObj).filter(([, v]) => v !== undefined);
|
|
1195
1378
|
const setClauses = setEntries.map(([k, v]) => this.buildSetClause(k, v, freshParams));
|
|
1196
1379
|
if (lock) {
|
|
@@ -1214,13 +1397,15 @@ export class QueryInterface {
|
|
|
1214
1397
|
};
|
|
1215
1398
|
let sql;
|
|
1216
1399
|
let preparedName;
|
|
1400
|
+
let cacheEntry;
|
|
1217
1401
|
if (ck) {
|
|
1218
|
-
|
|
1219
|
-
sql =
|
|
1220
|
-
preparedName =
|
|
1402
|
+
cacheEntry = this.acquireSql(ck, buildSql);
|
|
1403
|
+
sql = cacheEntry.sql;
|
|
1404
|
+
preparedName = cacheEntry.name;
|
|
1221
1405
|
}
|
|
1222
1406
|
else {
|
|
1223
|
-
|
|
1407
|
+
// optimisticLock path: value-variant version check → uncacheable, no cross-check.
|
|
1408
|
+
sql = buildSql([]);
|
|
1224
1409
|
}
|
|
1225
1410
|
// Collect params: SET first, then WHERE, then version check (same order as fresh build)
|
|
1226
1411
|
this.collectSetParams(dataObj, params);
|
|
@@ -1228,6 +1413,9 @@ export class QueryInterface {
|
|
|
1228
1413
|
if (lock) {
|
|
1229
1414
|
params.push(lock.expected);
|
|
1230
1415
|
}
|
|
1416
|
+
if (ck && cacheEntry) {
|
|
1417
|
+
this.crossCheckCache('update', ck, cacheEntry, buildSql, params);
|
|
1418
|
+
}
|
|
1231
1419
|
return {
|
|
1232
1420
|
sql,
|
|
1233
1421
|
params,
|
|
@@ -1359,8 +1547,7 @@ export class QueryInterface {
|
|
|
1359
1547
|
const whereFp = this.fingerprintWhere(whereObj);
|
|
1360
1548
|
const ck = `d:${whereFp}${this.globalFilterCacheSegment()}`;
|
|
1361
1549
|
const params = [];
|
|
1362
|
-
const
|
|
1363
|
-
const freshParams = [];
|
|
1550
|
+
const buildSql = (freshParams) => {
|
|
1364
1551
|
const clause = this.buildWhereClause(whereObj, freshParams);
|
|
1365
1552
|
const whereSql = clause ? ` WHERE ${clause}` : '';
|
|
1366
1553
|
// SQL Server injects `OUTPUT DELETED.*` between `DELETE FROM <t>` and WHERE;
|
|
@@ -1368,8 +1555,10 @@ export class QueryInterface {
|
|
|
1368
1555
|
return this.dialect.buildDeleteStatement
|
|
1369
1556
|
? this.dialect.buildDeleteStatement({ table: this.q(this.table), whereSql, returning: '*' })
|
|
1370
1557
|
: `DELETE FROM ${this.q(this.table)}${whereSql}${this.dialect.buildReturningClause('*')}`;
|
|
1371
|
-
}
|
|
1558
|
+
};
|
|
1559
|
+
const entry = this.acquireSql(ck, buildSql);
|
|
1372
1560
|
this.collectWhereParams(whereObj, params);
|
|
1561
|
+
this.crossCheckCache('delete', ck, entry, buildSql, params);
|
|
1373
1562
|
return {
|
|
1374
1563
|
sql: entry.sql,
|
|
1375
1564
|
params,
|
|
@@ -1497,16 +1686,17 @@ export class QueryInterface {
|
|
|
1497
1686
|
const whereFp = this.fingerprintWhere(whereObj);
|
|
1498
1687
|
const ck = `um:${setFp}|${whereFp}${this.globalFilterCacheSegment()}`;
|
|
1499
1688
|
const params = [];
|
|
1500
|
-
const
|
|
1501
|
-
const freshParams = [];
|
|
1689
|
+
const buildSql = (freshParams) => {
|
|
1502
1690
|
const setEntries = Object.entries(dataObj).filter(([, v]) => v !== undefined);
|
|
1503
1691
|
const setClauses = setEntries.map(([k, v]) => this.buildSetClause(k, v, freshParams));
|
|
1504
1692
|
const whereClause = this.buildWhereClause(whereObj, freshParams);
|
|
1505
1693
|
const whereSql = whereClause ? ` WHERE ${whereClause}` : '';
|
|
1506
1694
|
return `UPDATE ${this.q(this.table)} SET ${setClauses.join(', ')}${whereSql}`;
|
|
1507
|
-
}
|
|
1695
|
+
};
|
|
1696
|
+
const entry = this.acquireSql(ck, buildSql);
|
|
1508
1697
|
this.collectSetParams(dataObj, params);
|
|
1509
1698
|
this.collectWhereParams(whereObj, params);
|
|
1699
|
+
this.crossCheckCache('updateMany', ck, entry, buildSql, params);
|
|
1510
1700
|
return {
|
|
1511
1701
|
sql: entry.sql,
|
|
1512
1702
|
params,
|
|
@@ -1533,13 +1723,14 @@ export class QueryInterface {
|
|
|
1533
1723
|
const whereFp = this.fingerprintWhere(whereObj);
|
|
1534
1724
|
const ck = `dm:${whereFp}${this.globalFilterCacheSegment()}`;
|
|
1535
1725
|
const params = [];
|
|
1536
|
-
const
|
|
1537
|
-
const freshParams = [];
|
|
1726
|
+
const buildSql = (freshParams) => {
|
|
1538
1727
|
const clause = this.buildWhereClause(whereObj, freshParams);
|
|
1539
1728
|
const whereSql = clause ? ` WHERE ${clause}` : '';
|
|
1540
1729
|
return `DELETE FROM ${this.q(this.table)}${whereSql}`;
|
|
1541
|
-
}
|
|
1730
|
+
};
|
|
1731
|
+
const entry = this.acquireSql(ck, buildSql);
|
|
1542
1732
|
this.collectWhereParams(whereObj, params);
|
|
1733
|
+
this.crossCheckCache('deleteMany', ck, entry, buildSql, params);
|
|
1543
1734
|
return {
|
|
1544
1735
|
sql: entry.sql,
|
|
1545
1736
|
params,
|
|
@@ -1566,15 +1757,16 @@ export class QueryInterface {
|
|
|
1566
1757
|
const whereFp = hasWhere ? this.fingerprintWhere(whereObj) : '';
|
|
1567
1758
|
const ck = `cnt:${whereFp}${this.globalFilterCacheSegment()}`;
|
|
1568
1759
|
const params = [];
|
|
1569
|
-
const
|
|
1570
|
-
const freshParams = [];
|
|
1760
|
+
const buildSql = (freshParams) => {
|
|
1571
1761
|
const clause = hasWhere ? this.buildWhereClause(whereObj, freshParams) : null;
|
|
1572
1762
|
const whereSql = clause ? ` WHERE ${clause}` : '';
|
|
1573
1763
|
return `SELECT ${this.castAgg('COUNT(*)', 'int')} AS count FROM ${this.q(this.table)}${whereSql}`;
|
|
1574
|
-
}
|
|
1764
|
+
};
|
|
1765
|
+
const entry = this.acquireSql(ck, buildSql);
|
|
1575
1766
|
if (hasWhere) {
|
|
1576
1767
|
this.collectWhereParams(whereObj, params);
|
|
1577
1768
|
}
|
|
1769
|
+
this.crossCheckCache('count', ck, entry, buildSql, params);
|
|
1578
1770
|
return {
|
|
1579
1771
|
sql: entry.sql,
|
|
1580
1772
|
params,
|
|
@@ -1626,6 +1818,15 @@ export class QueryInterface {
|
|
|
1626
1818
|
const selectExprs = [];
|
|
1627
1819
|
/** by entries in order: how to read each group key off the result row. */
|
|
1628
1820
|
const byReaders = [];
|
|
1821
|
+
// ORDER BY registries: map each key the groupBy RESULT actually contains to
|
|
1822
|
+
// the exact SELECT expression that produced it, so `orderBy` re-emits that
|
|
1823
|
+
// expression (never a SELECT alias, since not every dialect accepts alias
|
|
1824
|
+
// references in ORDER BY, and re-emitting mirrors HAVING's `jsonAggExprs`).
|
|
1825
|
+
// `byOrderExprs`: plain by-field name / JSON group-key alias → column or
|
|
1826
|
+
// extract expression. `aggOrderExprs`: `${aggKey}:${field}` → aggregate
|
|
1827
|
+
// expression (including any already-bound JSON-path placeholder, reused
|
|
1828
|
+
// exactly like HAVING since ORDER BY is appended after all other params).
|
|
1829
|
+
const byOrderExprs = new Map();
|
|
1629
1830
|
const usedResultKeys = new Set();
|
|
1630
1831
|
const claimResultKey = (key, what) => {
|
|
1631
1832
|
if (key === '_count' || usedResultKeys.has(key)) {
|
|
@@ -1646,6 +1847,7 @@ export class QueryInterface {
|
|
|
1646
1847
|
groupExprs.push(this.q(col));
|
|
1647
1848
|
selectExprs.push(this.q(col));
|
|
1648
1849
|
byReaders.push({ resultKey: entry, rowKey: col, raw: false });
|
|
1850
|
+
byOrderExprs.set(entry, this.q(col));
|
|
1649
1851
|
}
|
|
1650
1852
|
else {
|
|
1651
1853
|
const col = this.resolveJsonPathTarget('group key', entry.field, entry.path);
|
|
@@ -1657,13 +1859,24 @@ export class QueryInterface {
|
|
|
1657
1859
|
selectExprs.push(`(${extract}) AS ${this.q(alias)}`);
|
|
1658
1860
|
groupExprs.push(extract);
|
|
1659
1861
|
byReaders.push({ resultKey: alias, rowKey: alias, raw: true });
|
|
1862
|
+
// ORDER BY by this JSON alias re-emits the extract expression (with its
|
|
1863
|
+
// already-bound $n): the same reuse HAVING does for JSON aggregates.
|
|
1864
|
+
byOrderExprs.set(alias, extract);
|
|
1660
1865
|
}
|
|
1661
1866
|
}
|
|
1662
1867
|
// _count
|
|
1663
|
-
|
|
1868
|
+
const countSelected = args._count === true || args._count === undefined;
|
|
1869
|
+
if (countSelected) {
|
|
1664
1870
|
// default: always include count
|
|
1665
1871
|
selectExprs.push(`${this.castAgg('COUNT(*)', 'int')} AS _count`);
|
|
1666
1872
|
}
|
|
1873
|
+
// ORDER BY aggregate expressions, keyed `${aggKey}:${field}` (plus a bare
|
|
1874
|
+
// `_count`). Populated alongside the SELECT list below so `orderBy` can only
|
|
1875
|
+
// reference an aggregate that is actually requested. `COUNT(*)` (uncast) is
|
|
1876
|
+
// the ordering expression (the SELECT cast is only for the returned value).
|
|
1877
|
+
const aggOrderExprs = new Map();
|
|
1878
|
+
if (countSelected)
|
|
1879
|
+
aggOrderExprs.set('_count', 'COUNT(*)');
|
|
1667
1880
|
// _sum / _avg / _min / _max: `true` keeps the plain-column behavior; a
|
|
1668
1881
|
// {@link JsonPathAggregateTarget} aggregates a JSON path under the arg key
|
|
1669
1882
|
// as alias. `jsonAggFields` routes each JSON-aggregate row key back to its
|
|
@@ -1686,6 +1899,7 @@ export class QueryInterface {
|
|
|
1686
1899
|
const inner = `${sqlFn}(${this.q(col)})`;
|
|
1687
1900
|
const expr = aggKey === '_avg' ? this.castAgg(inner, 'float') : inner;
|
|
1688
1901
|
selectExprs.push(`${expr} AS ${this.q(`${aggKey}_${col}`)}`);
|
|
1902
|
+
aggOrderExprs.set(`${aggKey}:${key}`, expr);
|
|
1689
1903
|
continue;
|
|
1690
1904
|
}
|
|
1691
1905
|
const col = this.resolveJsonPathTarget(`${aggKey} target "${key}"`, target.field, target.path);
|
|
@@ -1703,6 +1917,7 @@ export class QueryInterface {
|
|
|
1703
1917
|
selectExprs.push(`${expr} AS ${this.q(`${aggKey}_${key}`)}`);
|
|
1704
1918
|
jsonAggFields.set(`${aggKey}_${key}`, { field: key, numeric });
|
|
1705
1919
|
jsonAggExprs.set(`${key}:${aggKey}`, expr);
|
|
1920
|
+
aggOrderExprs.set(`${aggKey}:${key}`, expr);
|
|
1706
1921
|
}
|
|
1707
1922
|
};
|
|
1708
1923
|
buildAggregates('_sum', 'SUM', args._sum);
|
|
@@ -1719,9 +1934,12 @@ export class QueryInterface {
|
|
|
1719
1934
|
sql += ` HAVING ${havingClauses.join(' AND ')}`;
|
|
1720
1935
|
}
|
|
1721
1936
|
}
|
|
1722
|
-
// ORDER BY
|
|
1937
|
+
// ORDER BY, over the groupBy RESULT columns (by-fields, JSON aliases, and
|
|
1938
|
+
// requested aggregates), not the table's physical columns.
|
|
1723
1939
|
if (args.orderBy) {
|
|
1724
|
-
|
|
1940
|
+
const orderSql = this.buildGroupByOrderBy(args.orderBy, byOrderExprs, aggOrderExprs);
|
|
1941
|
+
if (orderSql)
|
|
1942
|
+
sql += ` ORDER BY ${orderSql}`;
|
|
1725
1943
|
}
|
|
1726
1944
|
return {
|
|
1727
1945
|
sql,
|
|
@@ -1786,6 +2004,74 @@ export class QueryInterface {
|
|
|
1786
2004
|
tag: `${this.table}.groupBy`,
|
|
1787
2005
|
};
|
|
1788
2006
|
}
|
|
2007
|
+
/**
|
|
2008
|
+
* Compile a groupBy `orderBy` into an ORDER BY body. Unlike findMany ORDER BY
|
|
2009
|
+
* ({@link buildOrderBy}, which validates keys against the table's physical
|
|
2010
|
+
* columns), groupBy ordering targets the columns the RESULT actually
|
|
2011
|
+
* contains: plain by-fields, JSON group-key aliases, and requested aggregates
|
|
2012
|
+
* (`_count` / `_sum` / `_avg` / `_min` / `_max`). Each key re-emits the exact
|
|
2013
|
+
* SELECT expression that produced it (`byOrderExprs` / `aggOrderExprs`),
|
|
2014
|
+
* mirroring how HAVING re-emits aggregate expressions, so no dialect ever has
|
|
2015
|
+
* to accept a SELECT-alias reference in ORDER BY, and any already-bound
|
|
2016
|
+
* JSON-path placeholder is reused verbatim (ORDER BY is the last clause, so
|
|
2017
|
+
* no `$n` renumbering). An aggregate key that was not requested, or an unknown
|
|
2018
|
+
* by-key, throws {@link ValidationError} E003 listing the valid keys.
|
|
2019
|
+
*/
|
|
2020
|
+
buildGroupByOrderBy(orderBy, byOrderExprs, aggOrderExprs) {
|
|
2021
|
+
const aggBlocks = new Set(['_count', '_sum', '_avg', '_min', '_max']);
|
|
2022
|
+
/** Human-readable list of every key this call can order by (for E003). */
|
|
2023
|
+
const validKeys = () => {
|
|
2024
|
+
const keys = [...byOrderExprs.keys()];
|
|
2025
|
+
for (const k of aggOrderExprs.keys()) {
|
|
2026
|
+
keys.push(k.includes(':') ? k.replace(':', '.') : k);
|
|
2027
|
+
}
|
|
2028
|
+
return keys.join(', ') || '(none)';
|
|
2029
|
+
};
|
|
2030
|
+
const parts = [];
|
|
2031
|
+
for (const [key, value] of Object.entries(orderBy)) {
|
|
2032
|
+
if (value === undefined)
|
|
2033
|
+
continue;
|
|
2034
|
+
// Aggregate ordering blocks.
|
|
2035
|
+
if (aggBlocks.has(key)) {
|
|
2036
|
+
if (key === '_count') {
|
|
2037
|
+
const expr = aggOrderExprs.get('_count');
|
|
2038
|
+
if (!expr) {
|
|
2039
|
+
throw new ValidationError(`[turbine] Cannot order groupBy by "_count" on table "${this.table}": _count is not selected. ` +
|
|
2040
|
+
`Orderable keys: ${validKeys()}.`);
|
|
2041
|
+
}
|
|
2042
|
+
const { dir, nulls } = normalizeOrderBy(value);
|
|
2043
|
+
parts.push(`${expr} ${dir}${this.nullsSuffix(nulls)}`);
|
|
2044
|
+
continue;
|
|
2045
|
+
}
|
|
2046
|
+
// `_sum` / `_avg` / `_min` / `_max`: an object of field → direction/spec.
|
|
2047
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
2048
|
+
throw new ValidationError(`[turbine] Invalid groupBy orderBy for "${key}" on table "${this.table}": ` +
|
|
2049
|
+
`expected a field map like { ${key}: { amount: 'desc' } }.`);
|
|
2050
|
+
}
|
|
2051
|
+
for (const [field, dirSpec] of Object.entries(value)) {
|
|
2052
|
+
if (dirSpec === undefined)
|
|
2053
|
+
continue;
|
|
2054
|
+
const expr = aggOrderExprs.get(`${key}:${field}`);
|
|
2055
|
+
if (!expr) {
|
|
2056
|
+
throw new ValidationError(`[turbine] Cannot order groupBy by "${key}.${field}" on table "${this.table}": ` +
|
|
2057
|
+
`that aggregate is not requested in this call. Orderable keys: ${validKeys()}.`);
|
|
2058
|
+
}
|
|
2059
|
+
const { dir, nulls } = normalizeOrderBy(dirSpec);
|
|
2060
|
+
parts.push(`${expr} ${dir}${this.nullsSuffix(nulls)}`);
|
|
2061
|
+
}
|
|
2062
|
+
continue;
|
|
2063
|
+
}
|
|
2064
|
+
// Plain by-field name or JSON group-key alias.
|
|
2065
|
+
const expr = byOrderExprs.get(key);
|
|
2066
|
+
if (!expr) {
|
|
2067
|
+
throw new ValidationError(`[turbine] Unknown field "${key}" in groupBy orderBy on table "${this.table}". ` +
|
|
2068
|
+
`Orderable keys: ${validKeys()}.`);
|
|
2069
|
+
}
|
|
2070
|
+
const { dir, nulls } = normalizeOrderBy(value);
|
|
2071
|
+
parts.push(`${expr} ${dir}${this.nullsSuffix(nulls)}`);
|
|
2072
|
+
}
|
|
2073
|
+
return parts.join(', ');
|
|
2074
|
+
}
|
|
1789
2075
|
/**
|
|
1790
2076
|
* Validate a JSON-path target (group key or aggregate target) in groupBy:
|
|
1791
2077
|
* the field must resolve to a real json/jsonb column and the path must be a
|
|
@@ -2903,9 +3189,11 @@ export class QueryInterface {
|
|
|
2903
3189
|
const oEntries = Object.entries(opts.orderBy).map(([k, d]) => `${k}:${this.orderByEntryFingerprint(d, targetRels?.[k]?.to)}`);
|
|
2904
3190
|
subParts.push(`o=${oEntries.join(',')}`);
|
|
2905
3191
|
}
|
|
2906
|
-
// limit presence
|
|
3192
|
+
// limit presence, but on inline-pagination engines (MySQL) the literal
|
|
3193
|
+
// value is baked into the subquery SQL, so fingerprint the value there or
|
|
3194
|
+
// `{limit:3}` and `{limit:5}` would share one cached statement.
|
|
2907
3195
|
if (opts.limit !== undefined) {
|
|
2908
|
-
subParts.push('l=1');
|
|
3196
|
+
subParts.push(this.dialect.inlineLimitOffset ? `l=${opts.limit}` : 'l=1');
|
|
2909
3197
|
}
|
|
2910
3198
|
// nested with (recurse)
|
|
2911
3199
|
if (opts.with) {
|
package/dist/query/deferred.d.ts
CHANGED
|
@@ -85,10 +85,19 @@ export interface QueryInterfaceOptions {
|
|
|
85
85
|
preparedStatements?: boolean;
|
|
86
86
|
/**
|
|
87
87
|
* Enable the SQL template cache. When true, repeated queries with the
|
|
88
|
-
* same shape (same keys, operators, relations
|
|
88
|
+
* same shape (same keys, operators, relations, different values) reuse
|
|
89
89
|
* cached SQL text instead of rebuilding from scratch.
|
|
90
90
|
*
|
|
91
91
|
* Default: `true`. Set to `false` as a nuclear kill switch.
|
|
92
|
+
*
|
|
93
|
+
* Dev-mode safety net: when `NODE_ENV !== 'production'`, every cache HIT is
|
|
94
|
+
* cross-checked by rebuilding the SQL + params fresh and comparing them
|
|
95
|
+
* against the cache-hit result, catching any drift between the fingerprint,
|
|
96
|
+
* SQL-build, and param-collect paths (which has silently corrupted results
|
|
97
|
+
* before). A mismatch throws a `ValidationError` (E003). This runs only
|
|
98
|
+
* outside production, so it never touches the production hot path. Set the
|
|
99
|
+
* env var `TURBINE_DISABLE_CACHE_CHECK=1` to opt out when dev traffic is
|
|
100
|
+
* perf-sensitive.
|
|
92
101
|
*/
|
|
93
102
|
sqlCache?: boolean;
|
|
94
103
|
/** SQL dialect implementation. Defaults to PostgreSQL. */
|