turbine-orm 0.40.1 → 0.41.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -4
- package/dist/cjs/cli/config.js +3 -0
- package/dist/cjs/cli/index.js +179 -0
- package/dist/cjs/cli/prisma-report.js +216 -0
- package/dist/cjs/cli/prisma-resolve.js +335 -0
- package/dist/cjs/cli/prisma-schema.js +484 -0
- package/dist/cjs/client.js +1 -0
- package/dist/cjs/generate.js +279 -22
- package/dist/cjs/index.js +3 -2
- package/dist/cjs/introspect.js +203 -26
- package/dist/cjs/mssql.js +9 -10
- package/dist/cjs/mysql.js +3 -9
- package/dist/cjs/powdb-introspect.js +5 -10
- package/dist/cjs/powql.js +13 -0
- package/dist/cjs/prisma-compat.js +1147 -0
- package/dist/cjs/query/aggregates.js +67 -7
- package/dist/cjs/query/builder.js +388 -17
- package/dist/cjs/query/compound-unique.js +0 -0
- package/dist/cjs/query/relations.js +7 -5
- package/dist/cjs/query/warn-registry.js +98 -0
- package/dist/cjs/query/writes.js +13 -5
- package/dist/cjs/schema.js +47 -0
- package/dist/cjs/sqlite.js +4 -9
- package/dist/cli/config.d.ts +26 -0
- package/dist/cli/config.js +3 -0
- package/dist/cli/index.d.ts +11 -0
- package/dist/cli/index.js +180 -1
- package/dist/cli/prisma-report.d.ts +19 -0
- package/dist/cli/prisma-report.js +211 -0
- package/dist/cli/prisma-resolve.d.ts +87 -0
- package/dist/cli/prisma-resolve.js +330 -0
- package/dist/cli/prisma-schema.d.ts +116 -0
- package/dist/cli/prisma-schema.js +479 -0
- package/dist/cli/ui.d.ts +1 -1
- package/dist/client.d.ts +18 -2
- package/dist/client.js +1 -0
- package/dist/generate.d.ts +80 -1
- package/dist/generate.js +277 -25
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/introspect.d.ts +92 -2
- package/dist/introspect.js +198 -26
- package/dist/mssql.js +10 -11
- package/dist/mysql.js +4 -10
- package/dist/powdb-introspect.js +5 -10
- package/dist/powql.js +13 -0
- package/dist/prisma-compat.d.ts +281 -0
- package/dist/prisma-compat.js +1143 -0
- package/dist/query/aggregates.js +67 -7
- package/dist/query/builder.d.ts +77 -4
- package/dist/query/builder.js +390 -19
- package/dist/query/compound-unique.d.ts +49 -0
- package/dist/query/compound-unique.js +0 -0
- package/dist/query/deferred.d.ts +18 -0
- package/dist/query/relations.js +7 -5
- package/dist/query/types.d.ts +70 -9
- package/dist/query/warn-registry.d.ts +57 -0
- package/dist/query/warn-registry.js +92 -0
- package/dist/query/writes.js +13 -5
- package/dist/schema.d.ts +75 -0
- package/dist/schema.js +46 -0
- package/dist/sqlite.js +5 -10
- package/package.json +6 -1
|
@@ -134,15 +134,37 @@ function buildGroupBy(qi, args) {
|
|
|
134
134
|
}
|
|
135
135
|
}
|
|
136
136
|
// _count
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
137
|
+
// - `true` / omitted → scalar `_count` column (COUNT(*)), result `_count: number`.
|
|
138
|
+
// - record form → one column per selection: `_all` → COUNT(*) AS "_count__all"
|
|
139
|
+
// (double underscore, collision-proof against a real column named `all`),
|
|
140
|
+
// each field → COUNT(col) AS "_count_<col>", result `_count: { _all, field }`.
|
|
141
|
+
const countArg = args._count;
|
|
142
|
+
const countIsRecord = countArg !== true && countArg !== undefined && typeof countArg === 'object';
|
|
143
|
+
const scalarCount = countArg === true || countArg === undefined;
|
|
144
|
+
if (scalarCount) {
|
|
145
|
+
// default: always include the scalar count
|
|
140
146
|
selectExprs.push(`${qi.castAgg('COUNT(*)', 'int')} AS _count`);
|
|
141
147
|
}
|
|
148
|
+
else if (countIsRecord) {
|
|
149
|
+
for (const [field, enabled] of Object.entries(countArg)) {
|
|
150
|
+
if (!enabled)
|
|
151
|
+
continue;
|
|
152
|
+
if (field === '_all') {
|
|
153
|
+
selectExprs.push(`${qi.castAgg('COUNT(*)', 'int')} AS ${qi.q('_count__all')}`);
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
const col = qi.toColumn(field);
|
|
157
|
+
selectExprs.push(`${qi.castAgg(`COUNT(${qi.q(col)})`, 'int')} AS ${qi.q(`_count_${col}`)}`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
142
161
|
// ORDER BY aggregate expressions, keyed `${aggKey}:${field}` (plus a bare
|
|
143
162
|
// `_count`). Populated alongside the SELECT list below so `orderBy` can only
|
|
144
163
|
// reference an aggregate that is actually requested. `COUNT(*)` (uncast) is
|
|
145
164
|
// the ordering expression (the SELECT cast is only for the returned value).
|
|
165
|
+
// COUNT(*) is orderable whenever it is selected: scalar `_count`, OR the
|
|
166
|
+
// record form containing `_all`.
|
|
167
|
+
const countSelected = scalarCount || (countIsRecord && countArg._all === true);
|
|
146
168
|
const aggOrderExprs = new Map();
|
|
147
169
|
if (countSelected)
|
|
148
170
|
aggOrderExprs.set('_count', 'COUNT(*)');
|
|
@@ -234,12 +256,34 @@ function buildGroupBy(qi, args) {
|
|
|
234
256
|
restructured[reader.resultKey] = reader.raw ? row[reader.rowKey] : parsed[reader.resultKey];
|
|
235
257
|
}
|
|
236
258
|
// _count
|
|
259
|
+
// scalar form → the plain `_count` (or driver-lowercased `count`) column.
|
|
260
|
+
// record form → assemble `{ _all, field, ... }` from the `_count__all`
|
|
261
|
+
// and `_count_<col>` columns. `_count__all` MUST be matched before the
|
|
262
|
+
// generic `_count_` prefix (its slice(7) would map through snakeToCamel).
|
|
237
263
|
if ('_count' in row) {
|
|
238
264
|
restructured._count = row._count;
|
|
239
265
|
}
|
|
240
266
|
else if ('count' in row) {
|
|
241
267
|
restructured._count = row.count;
|
|
242
268
|
}
|
|
269
|
+
else {
|
|
270
|
+
const countObj = {};
|
|
271
|
+
let hasCount = false;
|
|
272
|
+
for (const [rawKey, rawValue] of Object.entries(row)) {
|
|
273
|
+
if (rawKey === '_count__all') {
|
|
274
|
+
countObj._all = rawValue;
|
|
275
|
+
hasCount = true;
|
|
276
|
+
}
|
|
277
|
+
else if (rawKey.startsWith('_count_')) {
|
|
278
|
+
const col = rawKey.slice(7);
|
|
279
|
+
const field = qi.tableMeta.reverseColumnMap[col] ?? (0, schema_js_1.snakeToCamel)(col);
|
|
280
|
+
countObj[field] = rawValue;
|
|
281
|
+
hasCount = true;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
if (hasCount)
|
|
285
|
+
restructured._count = countObj;
|
|
286
|
+
}
|
|
243
287
|
// Collect aggregates into nested objects
|
|
244
288
|
const sumObj = {};
|
|
245
289
|
const avgObj = {};
|
|
@@ -562,6 +606,9 @@ function buildAggregate(qi, args) {
|
|
|
562
606
|
}
|
|
563
607
|
if (args._count && typeof args._count === 'object') {
|
|
564
608
|
for (const key of Object.keys(args._count)) {
|
|
609
|
+
// `_all` is the reserved COUNT(*) selector, not a column.
|
|
610
|
+
if (key === '_all')
|
|
611
|
+
continue;
|
|
565
612
|
if (!(key in meta.columnMap)) {
|
|
566
613
|
throw new errors_js_1.ValidationError(`Unknown column "${key}" in aggregate for table "${qi.table}"`);
|
|
567
614
|
}
|
|
@@ -569,13 +616,20 @@ function buildAggregate(qi, args) {
|
|
|
569
616
|
}
|
|
570
617
|
}
|
|
571
618
|
const selectExprs = [];
|
|
572
|
-
// _count
|
|
619
|
+
// _count. `true` → scalar COUNT(*). Record form: reserved `_all` → COUNT(*) AS
|
|
620
|
+
// "_count__all" (double underscore, collision-proof against a real column named
|
|
621
|
+
// `all`); each field → COUNT(col) AS "_count_<col>".
|
|
573
622
|
if (args._count === true) {
|
|
574
623
|
selectExprs.push(`${qi.castAgg('COUNT(*)', 'int')} AS _count`);
|
|
575
624
|
}
|
|
576
625
|
else if (args._count && typeof args._count === 'object') {
|
|
577
626
|
for (const [field, enabled] of Object.entries(args._count)) {
|
|
578
|
-
if (enabled)
|
|
627
|
+
if (!enabled)
|
|
628
|
+
continue;
|
|
629
|
+
if (field === '_all') {
|
|
630
|
+
selectExprs.push(`${qi.castAgg('COUNT(*)', 'int')} AS ${qi.q('_count__all')}`);
|
|
631
|
+
}
|
|
632
|
+
else {
|
|
579
633
|
const col = qi.toColumn(field);
|
|
580
634
|
selectExprs.push(`${qi.castAgg(`COUNT(${qi.q(col)})`, 'int')} AS ${qi.q(`_count_${col}`)}`);
|
|
581
635
|
}
|
|
@@ -632,11 +686,17 @@ function buildAggregate(qi, args) {
|
|
|
632
686
|
aggResult._count = row._count;
|
|
633
687
|
}
|
|
634
688
|
else {
|
|
635
|
-
// Check for per-column counts
|
|
689
|
+
// Check for per-column counts. `_count__all` MUST be matched before the
|
|
690
|
+
// generic `_count_` prefix (its slice(7) is `_all`, which snakeToCamel
|
|
691
|
+
// would mangle to `All`).
|
|
636
692
|
const countObj = {};
|
|
637
693
|
let hasCountFields = false;
|
|
638
694
|
for (const [key, val] of Object.entries(row)) {
|
|
639
|
-
if (key
|
|
695
|
+
if (key === '_count__all') {
|
|
696
|
+
countObj._all = val;
|
|
697
|
+
hasCountFields = true;
|
|
698
|
+
}
|
|
699
|
+
else if (key.startsWith('_count_')) {
|
|
640
700
|
const col = key.slice(7);
|
|
641
701
|
const field = qi.tableMeta.reverseColumnMap[col] ?? (0, schema_js_1.snakeToCamel)(col);
|
|
642
702
|
countObj[field] = val;
|
|
@@ -48,13 +48,16 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
48
48
|
exports.QueryInterface = void 0;
|
|
49
49
|
const dialect_js_1 = require("../dialect.js");
|
|
50
50
|
const errors_js_1 = require("../errors.js");
|
|
51
|
+
const index_advisor_js_1 = require("../index-advisor.js");
|
|
51
52
|
const nested_write_js_1 = require("../nested-write.js");
|
|
52
53
|
const schema_js_1 = require("../schema.js");
|
|
53
54
|
const aggMod = __importStar(require("./aggregates.js"));
|
|
54
55
|
const batched_loader_js_1 = require("./batched-loader.js");
|
|
56
|
+
const compound_unique_js_1 = require("./compound-unique.js");
|
|
55
57
|
const filters_js_1 = require("./filters.js");
|
|
56
58
|
const relationsMod = __importStar(require("./relations.js"));
|
|
57
59
|
const utils_js_1 = require("./utils.js");
|
|
60
|
+
const warn_registry_js_1 = require("./warn-registry.js");
|
|
58
61
|
const whereMod = __importStar(require("./where.js"));
|
|
59
62
|
const writesMod = __importStar(require("./writes.js"));
|
|
60
63
|
/**
|
|
@@ -179,6 +182,34 @@ function cacheParamsEqual(a, b) {
|
|
|
179
182
|
}
|
|
180
183
|
return true;
|
|
181
184
|
}
|
|
185
|
+
/**
|
|
186
|
+
* Return `args` with any Prisma compound-unique selector in `args.where`
|
|
187
|
+
* expanded to its column conjunction (see {@link expandCompoundUniqueWhere}).
|
|
188
|
+
* Returns the same `args` reference when nothing expands, so untouched queries
|
|
189
|
+
* are byte-identical. Generic so it serves every unique-`where` arg shape.
|
|
190
|
+
*/
|
|
191
|
+
function maybeExpandCompoundUnique(meta, args) {
|
|
192
|
+
const where = args.where;
|
|
193
|
+
if (!where)
|
|
194
|
+
return args;
|
|
195
|
+
const expanded = (0, compound_unique_js_1.expandCompoundUniqueWhere)(meta, where);
|
|
196
|
+
return expanded === where ? args : { ...args, where: expanded };
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Whether a relation `with`-clause `orderBy` carries no actual ordering: an
|
|
200
|
+
* empty array, or an object with no non-`undefined` own keys. Used by
|
|
201
|
+
* {@link QueryInterface.applyStableRelationOrder} so an explicit (non-empty)
|
|
202
|
+
* orderBy is never overwritten while an empty `{}` / `[]` still gets the
|
|
203
|
+
* synthesized PK order.
|
|
204
|
+
*/
|
|
205
|
+
function isEmptyOrderBy(orderBy) {
|
|
206
|
+
if (Array.isArray(orderBy))
|
|
207
|
+
return orderBy.length === 0;
|
|
208
|
+
if (orderBy && typeof orderBy === 'object') {
|
|
209
|
+
return Object.values(orderBy).every((v) => v === undefined);
|
|
210
|
+
}
|
|
211
|
+
return orderBy === undefined || orderBy === null;
|
|
212
|
+
}
|
|
182
213
|
// biome-ignore lint/complexity/noBannedTypes: {} means "no relations known" — intentional for untyped table access
|
|
183
214
|
class QueryInterface {
|
|
184
215
|
pool;
|
|
@@ -212,8 +243,16 @@ class QueryInterface {
|
|
|
212
243
|
*/
|
|
213
244
|
sqlCacheEnabled;
|
|
214
245
|
dialect;
|
|
215
|
-
/**
|
|
246
|
+
/**
|
|
247
|
+
* Client-level default relation-loading strategy. When nothing is configured
|
|
248
|
+
* this is `'auto'` (the implicit default): per-relation, keep the single-
|
|
249
|
+
* statement join unless the introspected metadata proves a probe is unindexed,
|
|
250
|
+
* in which case that relation falls back to the batched loader. An explicit
|
|
251
|
+
* `'join'`/`'batched'` (client or query level) always wins.
|
|
252
|
+
*/
|
|
216
253
|
relationLoadStrategy;
|
|
254
|
+
/** Client-level default for {@link applyStableRelationOrder} (off unless configured). */
|
|
255
|
+
stableRelationOrder;
|
|
217
256
|
/** Nested-relation JSON encoding: 'object' (default) or 'positional'. */
|
|
218
257
|
jsonEncoding;
|
|
219
258
|
/**
|
|
@@ -244,8 +283,6 @@ class QueryInterface {
|
|
|
244
283
|
* must never receive this schema's `::"enum"` cast (see enumTypeForColumn).
|
|
245
284
|
*/
|
|
246
285
|
crossSchemaTypeColumns;
|
|
247
|
-
/** Tracks tables that have already triggered a deep-with warning (one-time) */
|
|
248
|
-
deepWithWarned = new Set();
|
|
249
286
|
/**
|
|
250
287
|
* Per-table memo of date columns keyed by their camelCase FIELD name.
|
|
251
288
|
* `meta.dateColumns` is keyed by raw snake_case column name, which matches
|
|
@@ -260,6 +297,15 @@ class QueryInterface {
|
|
|
260
297
|
options;
|
|
261
298
|
/** Set by executeWithMiddleware so queryWithTimeout can include it in events. */
|
|
262
299
|
currentAction = 'raw';
|
|
300
|
+
/**
|
|
301
|
+
* Tags the query events of an in-flight `relationLoadStrategy: 'auto'` query
|
|
302
|
+
* that engaged the batched fallback (`'auto-batched'`), so observability sees
|
|
303
|
+
* which queries the auto default re-planned. Same transient-instance-state
|
|
304
|
+
* caveat as {@link currentAction}: set for the whole auto-split operation and
|
|
305
|
+
* cleared afterward; a concurrent unrelated query on the same accessor during
|
|
306
|
+
* that window could read it (a best-effort diagnostic tag, not load-bearing).
|
|
307
|
+
*/
|
|
308
|
+
currentStrategyTag;
|
|
263
309
|
/**
|
|
264
310
|
* The active query's `skipGlobalFilters` opt-out, set at the top of each
|
|
265
311
|
* `build*` method and read deep in the (synchronous) SQL-build + param-collect
|
|
@@ -326,7 +372,8 @@ class QueryInterface {
|
|
|
326
372
|
this.sqlCacheEnabled = options?.sqlCache !== false && sqlCacheSize !== 0;
|
|
327
373
|
this.sqlTemplateCache = new utils_js_1.LRUCache(sqlCacheSize !== undefined && sqlCacheSize > 0 ? Math.floor(sqlCacheSize) : 1000);
|
|
328
374
|
this.dialect = options?.dialect ?? dialect_js_1.postgresDialect;
|
|
329
|
-
this.relationLoadStrategy = options?.relationLoadStrategy ?? '
|
|
375
|
+
this.relationLoadStrategy = options?.relationLoadStrategy ?? 'auto';
|
|
376
|
+
this.stableRelationOrder = options?.stableRelationOrder === true;
|
|
330
377
|
this.jsonEncoding = options?.jsonEncoding ?? 'object';
|
|
331
378
|
// Only retain the map when it has at least one entry, so `globalFilters`
|
|
332
379
|
// stays `undefined` (and every merge path a no-op) for the common case.
|
|
@@ -492,12 +539,277 @@ class QueryInterface {
|
|
|
492
539
|
// -------------------------------------------------------------------------
|
|
493
540
|
/**
|
|
494
541
|
* Resolve the effective relation-loading strategy for a query: the per-query
|
|
495
|
-
* arg wins, then the client-level default, then `'
|
|
542
|
+
* arg wins, then the client-level default, then `'auto'`. Only meaningful when
|
|
496
543
|
* a `with` clause is present; the callers gate on that.
|
|
497
544
|
*/
|
|
498
545
|
resolveLoadStrategy(argStrategy) {
|
|
499
546
|
return argStrategy ?? this.relationLoadStrategy;
|
|
500
547
|
}
|
|
548
|
+
/**
|
|
549
|
+
* The effective {@link QueryInterfaceOptions.stableRelationOrder} for a query:
|
|
550
|
+
* the per-query arg wins, then the client-level default (off).
|
|
551
|
+
*/
|
|
552
|
+
resolveStableOrder(argFlag) {
|
|
553
|
+
return argFlag ?? this.stableRelationOrder;
|
|
554
|
+
}
|
|
555
|
+
/**
|
|
556
|
+
* Fill a PK-ascending `orderBy` into every to-many `with` relation that has no
|
|
557
|
+
* explicit one, recursing into nested `with`. Returns a CLONED clause (user
|
|
558
|
+
* args are never mutated); when nothing needs filling it returns the input
|
|
559
|
+
* object unchanged, so the byte-identical fast path stays free. Only called
|
|
560
|
+
* when {@link resolveStableOrder} is true; runs BEFORE `withFingerprint`, so
|
|
561
|
+
* the two orderings get distinct SQL-cache entries automatically. To-one
|
|
562
|
+
* relations are single rows (no array to order) and PK-less targets have
|
|
563
|
+
* nothing stable to order by, so both are left untouched.
|
|
564
|
+
*/
|
|
565
|
+
applyStableRelationOrder(withClause, table, depth = 0) {
|
|
566
|
+
if (depth >= 10)
|
|
567
|
+
return withClause; // parity with the build depth cap
|
|
568
|
+
const meta = this.schema.tables[table];
|
|
569
|
+
if (!meta)
|
|
570
|
+
return withClause;
|
|
571
|
+
let out;
|
|
572
|
+
for (const [relName, spec] of Object.entries(withClause)) {
|
|
573
|
+
if (relName === '_count' || !spec)
|
|
574
|
+
continue; // `_count` is a count, not a row load
|
|
575
|
+
const rel = (0, utils_js_1.ownLookup)(meta.relations, relName);
|
|
576
|
+
if (!rel)
|
|
577
|
+
continue; // unknown relation, let the build path surface E005
|
|
578
|
+
const options = spec === true ? {} : spec;
|
|
579
|
+
// Recurse first so a nested change alone still clones this level.
|
|
580
|
+
const nestedWith = options.with;
|
|
581
|
+
const newNested = nestedWith ? this.applyStableRelationOrder(nestedWith, rel.to, depth + 1) : undefined;
|
|
582
|
+
const nestedChanged = newNested !== undefined && newNested !== nestedWith;
|
|
583
|
+
const isToMany = rel.type === 'hasMany' || rel.type === 'manyToMany';
|
|
584
|
+
const hasOrder = options.orderBy !== undefined && !isEmptyOrderBy(options.orderBy);
|
|
585
|
+
let synthOrder;
|
|
586
|
+
if (isToMany && !hasOrder) {
|
|
587
|
+
const targetMeta = this.schema.tables[rel.to];
|
|
588
|
+
const pk = targetMeta?.primaryKey ?? [];
|
|
589
|
+
if (targetMeta && pk.length > 0) {
|
|
590
|
+
const pkFields = pk.map((c) => targetMeta.reverseColumnMap[c] ?? c);
|
|
591
|
+
synthOrder =
|
|
592
|
+
pkFields.length === 1 ? { [pkFields[0]]: 'asc' } : pkFields.map((f) => ({ [f]: 'asc' }));
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
if (!synthOrder && !nestedChanged)
|
|
596
|
+
continue; // nothing to change, keep the ref
|
|
597
|
+
out ??= { ...withClause };
|
|
598
|
+
const clonedSpec = { ...options };
|
|
599
|
+
if (synthOrder)
|
|
600
|
+
clonedSpec.orderBy = synthOrder;
|
|
601
|
+
if (nestedChanged)
|
|
602
|
+
clonedSpec.with = newNested;
|
|
603
|
+
out[relName] = clonedSpec;
|
|
604
|
+
}
|
|
605
|
+
return out ?? withClause;
|
|
606
|
+
}
|
|
607
|
+
// -------------------------------------------------------------------------
|
|
608
|
+
// relationLoadStrategy: 'auto', per-relation batched fallback when the
|
|
609
|
+
// introspected metadata proves a probe is unindexed (finding 13).
|
|
610
|
+
// -------------------------------------------------------------------------
|
|
611
|
+
/**
|
|
612
|
+
* Whether a relation can be served by the batched loader, i.e. all its
|
|
613
|
+
* correlation keys are single-column (the loader throws E017 on composite
|
|
614
|
+
* keys). Composite-key relations therefore always stay on the join plan under
|
|
615
|
+
* `'auto'` (and keep the existing unindexed-probe dev warning).
|
|
616
|
+
*/
|
|
617
|
+
relationBatchEligible(rel) {
|
|
618
|
+
if (rel.type === 'manyToMany') {
|
|
619
|
+
const through = rel.through;
|
|
620
|
+
if (!through)
|
|
621
|
+
return false;
|
|
622
|
+
const pkLen = this.schema.tables[rel.to]?.primaryKey.length ?? 0;
|
|
623
|
+
return ((0, schema_js_1.normalizeKeyColumns)(through.sourceKey).length === 1 &&
|
|
624
|
+
(0, schema_js_1.normalizeKeyColumns)(through.targetKey).length === 1 &&
|
|
625
|
+
(0, schema_js_1.normalizeKeyColumns)(rel.referenceKey).length === 1 &&
|
|
626
|
+
pkLen === 1);
|
|
627
|
+
}
|
|
628
|
+
return (0, schema_js_1.normalizeKeyColumns)(rel.foreignKey).length === 1 && (0, schema_js_1.normalizeKeyColumns)(rel.referenceKey).length === 1;
|
|
629
|
+
}
|
|
630
|
+
/**
|
|
631
|
+
* The verdict for one relation SUBTREE under `'auto'`: is any probe in the
|
|
632
|
+
* subtree unindexed, is EVERY relation in the subtree batched-eligible, and
|
|
633
|
+
* the first unindexed probe found (for the dev note). Subtree-atomic: a whole
|
|
634
|
+
* top-level relation falls back only when its entire subtree is eligible,
|
|
635
|
+
* mirroring the batched loader recursing the same tree.
|
|
636
|
+
*/
|
|
637
|
+
autoSubtreeVerdict(rel, spec, depth) {
|
|
638
|
+
let eligible = this.relationBatchEligible(rel);
|
|
639
|
+
const ownMiss = (0, index_advisor_js_1.missingIndexForRelation)(this.schema, rel);
|
|
640
|
+
let unindexed = ownMiss !== null;
|
|
641
|
+
let miss = ownMiss ?? undefined;
|
|
642
|
+
const options = spec === true ? {} : spec;
|
|
643
|
+
const nested = options.with;
|
|
644
|
+
if (nested && depth < 10) {
|
|
645
|
+
const targetMeta = this.schema.tables[rel.to];
|
|
646
|
+
for (const [childName, childSpec] of Object.entries(nested)) {
|
|
647
|
+
if (!childSpec)
|
|
648
|
+
continue;
|
|
649
|
+
if (childName === '_count') {
|
|
650
|
+
const cv = this.autoCountVerdict(childSpec, targetMeta);
|
|
651
|
+
eligible = eligible && cv.eligible;
|
|
652
|
+
unindexed = unindexed || cv.unindexed;
|
|
653
|
+
if (!miss && cv.miss)
|
|
654
|
+
miss = cv.miss;
|
|
655
|
+
continue;
|
|
656
|
+
}
|
|
657
|
+
const childRel = (0, utils_js_1.ownLookup)(targetMeta?.relations ?? {}, childName);
|
|
658
|
+
if (!childRel)
|
|
659
|
+
continue; // unknown nested relation, let the build path surface it
|
|
660
|
+
const v = this.autoSubtreeVerdict(childRel, childSpec, depth + 1);
|
|
661
|
+
eligible = eligible && v.eligible;
|
|
662
|
+
unindexed = unindexed || v.unindexed;
|
|
663
|
+
if (!miss && v.miss)
|
|
664
|
+
miss = v.miss;
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
return { unindexed, eligible, miss };
|
|
668
|
+
}
|
|
669
|
+
/** The `_count` verdict under `'auto'`: any counted probe unindexed + all single-key. */
|
|
670
|
+
autoCountVerdict(countSpec, parentMeta) {
|
|
671
|
+
if (!parentMeta)
|
|
672
|
+
return { unindexed: false, eligible: true };
|
|
673
|
+
let rels;
|
|
674
|
+
try {
|
|
675
|
+
rels = (0, batched_loader_js_1.resolveCountRelations)(parentMeta, countSpec);
|
|
676
|
+
}
|
|
677
|
+
catch {
|
|
678
|
+
return { unindexed: false, eligible: true }; // let the join/loader path surface the error
|
|
679
|
+
}
|
|
680
|
+
let unindexed = false;
|
|
681
|
+
let eligible = true;
|
|
682
|
+
let miss;
|
|
683
|
+
for (const rel of rels) {
|
|
684
|
+
if (!this.relationBatchEligible(rel))
|
|
685
|
+
eligible = false;
|
|
686
|
+
const m = (0, index_advisor_js_1.missingIndexForRelation)(this.schema, rel);
|
|
687
|
+
if (m) {
|
|
688
|
+
unindexed = true;
|
|
689
|
+
miss ??= m;
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
return { unindexed, eligible, miss };
|
|
693
|
+
}
|
|
694
|
+
/**
|
|
695
|
+
* Partition a top-level `with` clause under `'auto'`: each relation whose
|
|
696
|
+
* subtree has a PROVEN unindexed probe AND is fully batched-eligible routes to
|
|
697
|
+
* `batchedWith`; everything else (indexed, composite-key, unknown) stays in
|
|
698
|
+
* `joinWith` (byte-identical join). The reserved `_count` key partitions the
|
|
699
|
+
* same way. Also returns the engaged relations for the dev note.
|
|
700
|
+
*/
|
|
701
|
+
partitionWithForAuto(withClause) {
|
|
702
|
+
const joinWith = {};
|
|
703
|
+
const batchedWith = {};
|
|
704
|
+
const engaged = [];
|
|
705
|
+
for (const [key, spec] of Object.entries(withClause)) {
|
|
706
|
+
if (!spec)
|
|
707
|
+
continue;
|
|
708
|
+
if (key === '_count') {
|
|
709
|
+
const cv = this.autoCountVerdict(spec, this.tableMeta);
|
|
710
|
+
if (cv.unindexed && cv.eligible) {
|
|
711
|
+
batchedWith[key] = spec;
|
|
712
|
+
engaged.push({ relation: '_count', miss: cv.miss });
|
|
713
|
+
}
|
|
714
|
+
else {
|
|
715
|
+
joinWith[key] = spec;
|
|
716
|
+
}
|
|
717
|
+
continue;
|
|
718
|
+
}
|
|
719
|
+
const rel = (0, utils_js_1.ownLookup)(this.tableMeta.relations, key);
|
|
720
|
+
if (!rel) {
|
|
721
|
+
joinWith[key] = spec; // unknown relation, let the join path surface E005
|
|
722
|
+
continue;
|
|
723
|
+
}
|
|
724
|
+
const v = this.autoSubtreeVerdict(rel, spec, 0);
|
|
725
|
+
if (v.unindexed && v.eligible) {
|
|
726
|
+
batchedWith[key] = spec;
|
|
727
|
+
engaged.push({ relation: key, miss: v.miss });
|
|
728
|
+
}
|
|
729
|
+
else {
|
|
730
|
+
joinWith[key] = spec;
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
return { joinWith, batchedWith, engaged };
|
|
734
|
+
}
|
|
735
|
+
/**
|
|
736
|
+
* Plan the `'auto'` split for a query's `with` clause: normalize stable order,
|
|
737
|
+
* partition, and return the split ONLY when at least one relation falls back
|
|
738
|
+
* to batched. Returns `null` (→ run the plain join path, byte-identical, same
|
|
739
|
+
* cache keys) when there is no DB-backed index metadata or nothing qualifies.
|
|
740
|
+
*/
|
|
741
|
+
planAuto(withArg, stableFlag) {
|
|
742
|
+
// No DB-backed index info (code-first / defineSchema-only) → cannot PROVE any
|
|
743
|
+
// probe is unindexed, so 'auto' behaves exactly like 'join'.
|
|
744
|
+
if (!(0, index_advisor_js_1.schemaHasIndexInfo)(this.schema))
|
|
745
|
+
return null;
|
|
746
|
+
const withClause = this.resolveStableOrder(stableFlag)
|
|
747
|
+
? this.applyStableRelationOrder(withArg, this.table)
|
|
748
|
+
: withArg;
|
|
749
|
+
const split = this.partitionWithForAuto(withClause);
|
|
750
|
+
if (Object.keys(split.batchedWith).length === 0)
|
|
751
|
+
return null;
|
|
752
|
+
return split;
|
|
753
|
+
}
|
|
754
|
+
/** Dev-only once-per-relation note that `'auto'` engaged the batched fallback. */
|
|
755
|
+
emitAutoNotes(engaged) {
|
|
756
|
+
if (process.env.NODE_ENV === 'production')
|
|
757
|
+
return;
|
|
758
|
+
for (const e of engaged) {
|
|
759
|
+
if (!(0, warn_registry_js_1.shouldWarnOnce)(warn_registry_js_1.WARN_NS.autoStrategy, `${this.table}.${e.relation}`))
|
|
760
|
+
continue;
|
|
761
|
+
const probe = e.miss
|
|
762
|
+
? `probe "${e.miss.table}"(${e.miss.columns.join(', ')}) has no covering index`
|
|
763
|
+
: 'a probe in its subtree has no covering index';
|
|
764
|
+
console.warn(`[turbine] auto strategy: relation "${e.relation}" on "${this.table}" loads batched (${probe}). ` +
|
|
765
|
+
"Create the covering index (or set `relationLoadStrategy: 'join'` to force the single-statement " +
|
|
766
|
+
'plan); run `npx turbine doctor` for the exact CREATE INDEX SQL.');
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
/**
|
|
770
|
+
* Execute a findMany/findUnique `'auto'` split: run the base query with the
|
|
771
|
+
* residual `joinWith` (plus any parent stitch keys the batched subset needs),
|
|
772
|
+
* then load `batchedWith` via the batched loader and stitch. `single` returns
|
|
773
|
+
* the first entity (findUnique) instead of the array. Output is identical in
|
|
774
|
+
* shape to the pure join plan.
|
|
775
|
+
*/
|
|
776
|
+
async runAutoSplit(args, split, single) {
|
|
777
|
+
this.emitAutoNotes(split.engaged);
|
|
778
|
+
const { joinWith, batchedWith } = split;
|
|
779
|
+
// Scope-rule parity with the batched strategy: reject nested pick ordering
|
|
780
|
+
// on the batched subset up front.
|
|
781
|
+
(0, batched_loader_js_1.rejectNestedPickOrder)(batchedWith);
|
|
782
|
+
const skip = args.skipGlobalFilters;
|
|
783
|
+
const needed = (0, batched_loader_js_1.neededParentKeyFields)(this.tableMeta, batchedWith);
|
|
784
|
+
const proj = (0, batched_loader_js_1.includeKeysForBatching)(args.select, args.omit, needed);
|
|
785
|
+
const hasJoin = Object.keys(joinWith).length > 0;
|
|
786
|
+
// Force the residual `with` onto the join plan so the base query never
|
|
787
|
+
// re-enters this auto planning.
|
|
788
|
+
const baseArgs = {
|
|
789
|
+
...args,
|
|
790
|
+
with: hasJoin ? joinWith : undefined,
|
|
791
|
+
select: proj.select,
|
|
792
|
+
omit: proj.omit,
|
|
793
|
+
relationLoadStrategy: 'join',
|
|
794
|
+
};
|
|
795
|
+
this.currentStrategyTag = 'auto-batched';
|
|
796
|
+
try {
|
|
797
|
+
const deferred = single
|
|
798
|
+
? this.buildFindUnique(baseArgs)
|
|
799
|
+
: this.buildFindMany(baseArgs);
|
|
800
|
+
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, deferred.preparedName);
|
|
801
|
+
const rows = deferred.transform(result);
|
|
802
|
+
const entities = single ? (rows ? [rows] : []) : rows;
|
|
803
|
+
if (entities.length > 0) {
|
|
804
|
+
await (0, batched_loader_js_1.loadRelationsBatched)(this.batchedContext(args.timeout, skip, args.includePii === true), entities, batchedWith, args.timeout);
|
|
805
|
+
}
|
|
806
|
+
(0, batched_loader_js_1.stripFields)(entities, proj.strip);
|
|
807
|
+
return single ? (entities[0] ?? null) : entities;
|
|
808
|
+
}
|
|
809
|
+
finally {
|
|
810
|
+
this.currentStrategyTag = undefined;
|
|
811
|
+
}
|
|
812
|
+
}
|
|
501
813
|
/**
|
|
502
814
|
* Build the {@link RelationLoadContext} the batched loader needs, closing over
|
|
503
815
|
* this interface's pool/dialect/executor. Child readers are constructed on the
|
|
@@ -549,7 +861,12 @@ class QueryInterface {
|
|
|
549
861
|
* from the returned rows, so the shape matches the join strategy exactly.
|
|
550
862
|
*/
|
|
551
863
|
async runFindManyBatched(args) {
|
|
552
|
-
|
|
864
|
+
// Stable relation order (opt-in): the batched loader forwards each relation's
|
|
865
|
+
// orderBy into its follow-up query, so filling the synthesized PK order here
|
|
866
|
+
// makes the batched output deterministic exactly like the join path.
|
|
867
|
+
const withClause = this.resolveStableOrder(args.stableRelationOrder)
|
|
868
|
+
? this.applyStableRelationOrder(args.with, this.table)
|
|
869
|
+
: args.with;
|
|
553
870
|
// Scope-rule parity with the join strategy (which throws at SQL build):
|
|
554
871
|
// reject nested pick-row ordering BEFORE the base query so acceptance
|
|
555
872
|
// never depends on how many rows come back.
|
|
@@ -705,7 +1022,17 @@ class QueryInterface {
|
|
|
705
1022
|
if (!onQuery)
|
|
706
1023
|
return;
|
|
707
1024
|
try {
|
|
708
|
-
onQuery({
|
|
1025
|
+
onQuery({
|
|
1026
|
+
sql,
|
|
1027
|
+
params,
|
|
1028
|
+
duration,
|
|
1029
|
+
model: this.table,
|
|
1030
|
+
action,
|
|
1031
|
+
rows,
|
|
1032
|
+
timestamp: new Date(),
|
|
1033
|
+
error,
|
|
1034
|
+
strategy: this.currentStrategyTag,
|
|
1035
|
+
});
|
|
709
1036
|
}
|
|
710
1037
|
catch {
|
|
711
1038
|
// Listener errors must never crash a query
|
|
@@ -844,8 +1171,15 @@ class QueryInterface {
|
|
|
844
1171
|
// -------------------------------------------------------------------------
|
|
845
1172
|
async findUnique(args) {
|
|
846
1173
|
return this.executeWithMiddleware('findUnique', args, async () => {
|
|
847
|
-
if (args.with
|
|
848
|
-
|
|
1174
|
+
if (args.with) {
|
|
1175
|
+
const strategy = this.resolveLoadStrategy(args.relationLoadStrategy);
|
|
1176
|
+
if (strategy === 'batched')
|
|
1177
|
+
return this.runFindUniqueBatched(args);
|
|
1178
|
+
if (strategy === 'auto') {
|
|
1179
|
+
const split = this.planAuto(args.with, args.stableRelationOrder);
|
|
1180
|
+
if (split)
|
|
1181
|
+
return this.runAutoSplit(args, split, true);
|
|
1182
|
+
}
|
|
849
1183
|
}
|
|
850
1184
|
const deferred = this.buildFindUnique(args);
|
|
851
1185
|
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, deferred.preparedName);
|
|
@@ -859,7 +1193,10 @@ class QueryInterface {
|
|
|
859
1193
|
* strategy's shape for the one row.
|
|
860
1194
|
*/
|
|
861
1195
|
async runFindUniqueBatched(args) {
|
|
862
|
-
|
|
1196
|
+
// Stable relation order (opt-in), see runFindManyBatched.
|
|
1197
|
+
const withClause = this.resolveStableOrder(args.stableRelationOrder)
|
|
1198
|
+
? this.applyStableRelationOrder(args.with, this.table)
|
|
1199
|
+
: args.with;
|
|
863
1200
|
// Same scope-rule parity as runFindManyBatched: reject before querying.
|
|
864
1201
|
(0, batched_loader_js_1.rejectNestedPickOrder)(withClause);
|
|
865
1202
|
const needed = (0, batched_loader_js_1.neededParentKeyFields)(this.tableMeta, withClause);
|
|
@@ -877,6 +1214,16 @@ class QueryInterface {
|
|
|
877
1214
|
// biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
|
|
878
1215
|
buildFindUnique(args) {
|
|
879
1216
|
this.currentSkip = args.skipGlobalFilters;
|
|
1217
|
+
// Prisma compound-unique selector expansion (before global-filter merge and
|
|
1218
|
+
// fingerprinting, so the cache only ever sees the canonical expanded where).
|
|
1219
|
+
args = maybeExpandCompoundUnique(this.tableMeta, args);
|
|
1220
|
+
// Stable relation order (opt-in): fill PK-asc orderBy into unordered to-many
|
|
1221
|
+
// relations before fingerprinting (see buildFindMany).
|
|
1222
|
+
if (args.with && this.resolveStableOrder(args.stableRelationOrder)) {
|
|
1223
|
+
const normalized = this.applyStableRelationOrder(args.with, this.table);
|
|
1224
|
+
if (normalized !== args.with)
|
|
1225
|
+
args = { ...args, with: normalized };
|
|
1226
|
+
}
|
|
880
1227
|
const includePii = args.includePii === true;
|
|
881
1228
|
const columnsList = this.resolveColumns(args.select, args.omit, includePii);
|
|
882
1229
|
// A global filter turns the where into `{ AND: [...] }`, which the
|
|
@@ -994,16 +1341,22 @@ class QueryInterface {
|
|
|
994
1341
|
if (process.env.NODE_ENV !== 'production') {
|
|
995
1342
|
if (args?.with) {
|
|
996
1343
|
const depth = this.measureWithDepth(args.with);
|
|
997
|
-
if (depth > 5 &&
|
|
998
|
-
this.deepWithWarned.add(this.table);
|
|
1344
|
+
if (depth > 5 && (0, warn_registry_js_1.shouldWarnOnce)(warn_registry_js_1.WARN_NS.deepWith, this.table)) {
|
|
999
1345
|
console.warn(`[turbine] Deep with clause (depth ${depth}) on "${this.tableMeta.name}" — ` +
|
|
1000
1346
|
'consider splitting into separate queries for better performance.');
|
|
1001
1347
|
}
|
|
1002
1348
|
}
|
|
1003
1349
|
}
|
|
1004
1350
|
return this.executeWithMiddleware('findMany', (args ?? {}), async () => {
|
|
1005
|
-
if (args?.with
|
|
1006
|
-
|
|
1351
|
+
if (args?.with) {
|
|
1352
|
+
const strategy = this.resolveLoadStrategy(args.relationLoadStrategy);
|
|
1353
|
+
if (strategy === 'batched')
|
|
1354
|
+
return this.runFindManyBatched(args);
|
|
1355
|
+
if (strategy === 'auto') {
|
|
1356
|
+
const split = this.planAuto(args.with, args.stableRelationOrder);
|
|
1357
|
+
if (split)
|
|
1358
|
+
return this.runAutoSplit(args, split, false);
|
|
1359
|
+
}
|
|
1007
1360
|
}
|
|
1008
1361
|
const deferred = this.buildFindMany(args);
|
|
1009
1362
|
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args?.timeout, deferred.preparedName);
|
|
@@ -1112,6 +1465,14 @@ class QueryInterface {
|
|
|
1112
1465
|
// biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
|
|
1113
1466
|
buildFindMany(args) {
|
|
1114
1467
|
this.currentSkip = args?.skipGlobalFilters;
|
|
1468
|
+
// Stable relation order (opt-in): fill PK-asc orderBy into unordered to-many
|
|
1469
|
+
// relations BEFORE fingerprinting, so the two orderings get distinct cache
|
|
1470
|
+
// entries and every downstream path (SQL build, collect, parser) inherits it.
|
|
1471
|
+
if (args?.with && this.resolveStableOrder(args.stableRelationOrder)) {
|
|
1472
|
+
const normalized = this.applyStableRelationOrder(args.with, this.table);
|
|
1473
|
+
if (normalized !== args.with)
|
|
1474
|
+
args = { ...args, with: normalized };
|
|
1475
|
+
}
|
|
1115
1476
|
// `distinct` + relation orderBy is refused up front (E003): the distinct
|
|
1116
1477
|
// path re-orders in an outer wrapper (`... AS "<table>_distinct" ORDER BY
|
|
1117
1478
|
// <userOrder>`) where a correlated relation subquery (pick-row, `_count`,
|
|
@@ -1393,10 +1754,20 @@ class QueryInterface {
|
|
|
1393
1754
|
// -------------------------------------------------------------------------
|
|
1394
1755
|
async findFirst(args) {
|
|
1395
1756
|
return this.executeWithMiddleware('findFirst', (args ?? {}), async () => {
|
|
1396
|
-
if (args?.with
|
|
1757
|
+
if (args?.with) {
|
|
1758
|
+
const strategy = this.resolveLoadStrategy(args.relationLoadStrategy);
|
|
1397
1759
|
// findFirst is findMany + LIMIT 1: batch the single base row, then load.
|
|
1398
|
-
|
|
1399
|
-
|
|
1760
|
+
if (strategy === 'batched') {
|
|
1761
|
+
const rows = await this.runFindManyBatched({ ...args, limit: 1 });
|
|
1762
|
+
return (rows[0] ?? null);
|
|
1763
|
+
}
|
|
1764
|
+
if (strategy === 'auto') {
|
|
1765
|
+
const split = this.planAuto(args.with, args.stableRelationOrder);
|
|
1766
|
+
if (split) {
|
|
1767
|
+
const rows = (await this.runAutoSplit({ ...args, limit: 1 }, split, false));
|
|
1768
|
+
return (rows[0] ?? null);
|
|
1769
|
+
}
|
|
1770
|
+
}
|
|
1400
1771
|
}
|
|
1401
1772
|
const deferred = this.buildFindFirst(args);
|
|
1402
1773
|
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args?.timeout, deferred.preparedName);
|
|
Binary file
|