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
package/dist/query/builder.js
CHANGED
|
@@ -12,13 +12,16 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import { postgresDialect } from '../dialect.js';
|
|
14
14
|
import { NotFoundError, TimeoutError, UnsupportedFeatureError, ValidationError, wrapPgError } from '../errors.js';
|
|
15
|
+
import { missingIndexForRelation, schemaHasIndexInfo } from '../index-advisor.js';
|
|
15
16
|
import { executeNestedCreate, executeNestedUpdate, hasRelationFields, } from '../nested-write.js';
|
|
16
|
-
import { camelToSnake, snakeToCamel } from '../schema.js';
|
|
17
|
+
import { camelToSnake, normalizeKeyColumns, snakeToCamel } from '../schema.js';
|
|
17
18
|
import * as aggMod from './aggregates.js';
|
|
18
|
-
import { includeKeysForBatching, loadRelationsBatched, neededParentKeyFields, rejectNestedPickOrder, stripFields, } from './batched-loader.js';
|
|
19
|
+
import { includeKeysForBatching, loadRelationsBatched, neededParentKeyFields, rejectNestedPickOrder, resolveCountRelations, stripFields, } from './batched-loader.js';
|
|
20
|
+
import { expandCompoundUniqueWhere } from './compound-unique.js';
|
|
19
21
|
import { isJsonPathOrderBy, isOrderBySpec, isRelationPickOrderBy, isVectorOrderBy, isWhereOperator, orderByEntries, sortedEntries, } from './filters.js';
|
|
20
22
|
import * as relationsMod from './relations.js';
|
|
21
23
|
import { LRUCache, ownLookup, parseDbDate, sqlToPreparedName } from './utils.js';
|
|
24
|
+
import { shouldWarnOnce, WARN_NS } from './warn-registry.js';
|
|
22
25
|
import * as whereMod from './where.js';
|
|
23
26
|
import * as writesMod from './writes.js';
|
|
24
27
|
/**
|
|
@@ -143,6 +146,34 @@ function cacheParamsEqual(a, b) {
|
|
|
143
146
|
}
|
|
144
147
|
return true;
|
|
145
148
|
}
|
|
149
|
+
/**
|
|
150
|
+
* Return `args` with any Prisma compound-unique selector in `args.where`
|
|
151
|
+
* expanded to its column conjunction (see {@link expandCompoundUniqueWhere}).
|
|
152
|
+
* Returns the same `args` reference when nothing expands, so untouched queries
|
|
153
|
+
* are byte-identical. Generic so it serves every unique-`where` arg shape.
|
|
154
|
+
*/
|
|
155
|
+
function maybeExpandCompoundUnique(meta, args) {
|
|
156
|
+
const where = args.where;
|
|
157
|
+
if (!where)
|
|
158
|
+
return args;
|
|
159
|
+
const expanded = expandCompoundUniqueWhere(meta, where);
|
|
160
|
+
return expanded === where ? args : { ...args, where: expanded };
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Whether a relation `with`-clause `orderBy` carries no actual ordering: an
|
|
164
|
+
* empty array, or an object with no non-`undefined` own keys. Used by
|
|
165
|
+
* {@link QueryInterface.applyStableRelationOrder} so an explicit (non-empty)
|
|
166
|
+
* orderBy is never overwritten while an empty `{}` / `[]` still gets the
|
|
167
|
+
* synthesized PK order.
|
|
168
|
+
*/
|
|
169
|
+
function isEmptyOrderBy(orderBy) {
|
|
170
|
+
if (Array.isArray(orderBy))
|
|
171
|
+
return orderBy.length === 0;
|
|
172
|
+
if (orderBy && typeof orderBy === 'object') {
|
|
173
|
+
return Object.values(orderBy).every((v) => v === undefined);
|
|
174
|
+
}
|
|
175
|
+
return orderBy === undefined || orderBy === null;
|
|
176
|
+
}
|
|
146
177
|
// biome-ignore lint/complexity/noBannedTypes: {} means "no relations known" — intentional for untyped table access
|
|
147
178
|
export class QueryInterface {
|
|
148
179
|
pool;
|
|
@@ -176,8 +207,16 @@ export class QueryInterface {
|
|
|
176
207
|
*/
|
|
177
208
|
sqlCacheEnabled;
|
|
178
209
|
dialect;
|
|
179
|
-
/**
|
|
210
|
+
/**
|
|
211
|
+
* Client-level default relation-loading strategy. When nothing is configured
|
|
212
|
+
* this is `'auto'` (the implicit default): per-relation, keep the single-
|
|
213
|
+
* statement join unless the introspected metadata proves a probe is unindexed,
|
|
214
|
+
* in which case that relation falls back to the batched loader. An explicit
|
|
215
|
+
* `'join'`/`'batched'` (client or query level) always wins.
|
|
216
|
+
*/
|
|
180
217
|
relationLoadStrategy;
|
|
218
|
+
/** Client-level default for {@link applyStableRelationOrder} (off unless configured). */
|
|
219
|
+
stableRelationOrder;
|
|
181
220
|
/** Nested-relation JSON encoding: 'object' (default) or 'positional'. */
|
|
182
221
|
jsonEncoding;
|
|
183
222
|
/**
|
|
@@ -208,8 +247,6 @@ export class QueryInterface {
|
|
|
208
247
|
* must never receive this schema's `::"enum"` cast (see enumTypeForColumn).
|
|
209
248
|
*/
|
|
210
249
|
crossSchemaTypeColumns;
|
|
211
|
-
/** Tracks tables that have already triggered a deep-with warning (one-time) */
|
|
212
|
-
deepWithWarned = new Set();
|
|
213
250
|
/**
|
|
214
251
|
* Per-table memo of date columns keyed by their camelCase FIELD name.
|
|
215
252
|
* `meta.dateColumns` is keyed by raw snake_case column name, which matches
|
|
@@ -224,6 +261,15 @@ export class QueryInterface {
|
|
|
224
261
|
options;
|
|
225
262
|
/** Set by executeWithMiddleware so queryWithTimeout can include it in events. */
|
|
226
263
|
currentAction = 'raw';
|
|
264
|
+
/**
|
|
265
|
+
* Tags the query events of an in-flight `relationLoadStrategy: 'auto'` query
|
|
266
|
+
* that engaged the batched fallback (`'auto-batched'`), so observability sees
|
|
267
|
+
* which queries the auto default re-planned. Same transient-instance-state
|
|
268
|
+
* caveat as {@link currentAction}: set for the whole auto-split operation and
|
|
269
|
+
* cleared afterward; a concurrent unrelated query on the same accessor during
|
|
270
|
+
* that window could read it (a best-effort diagnostic tag, not load-bearing).
|
|
271
|
+
*/
|
|
272
|
+
currentStrategyTag;
|
|
227
273
|
/**
|
|
228
274
|
* The active query's `skipGlobalFilters` opt-out, set at the top of each
|
|
229
275
|
* `build*` method and read deep in the (synchronous) SQL-build + param-collect
|
|
@@ -290,7 +336,8 @@ export class QueryInterface {
|
|
|
290
336
|
this.sqlCacheEnabled = options?.sqlCache !== false && sqlCacheSize !== 0;
|
|
291
337
|
this.sqlTemplateCache = new LRUCache(sqlCacheSize !== undefined && sqlCacheSize > 0 ? Math.floor(sqlCacheSize) : 1000);
|
|
292
338
|
this.dialect = options?.dialect ?? postgresDialect;
|
|
293
|
-
this.relationLoadStrategy = options?.relationLoadStrategy ?? '
|
|
339
|
+
this.relationLoadStrategy = options?.relationLoadStrategy ?? 'auto';
|
|
340
|
+
this.stableRelationOrder = options?.stableRelationOrder === true;
|
|
294
341
|
this.jsonEncoding = options?.jsonEncoding ?? 'object';
|
|
295
342
|
// Only retain the map when it has at least one entry, so `globalFilters`
|
|
296
343
|
// stays `undefined` (and every merge path a no-op) for the common case.
|
|
@@ -456,12 +503,277 @@ export class QueryInterface {
|
|
|
456
503
|
// -------------------------------------------------------------------------
|
|
457
504
|
/**
|
|
458
505
|
* Resolve the effective relation-loading strategy for a query: the per-query
|
|
459
|
-
* arg wins, then the client-level default, then `'
|
|
506
|
+
* arg wins, then the client-level default, then `'auto'`. Only meaningful when
|
|
460
507
|
* a `with` clause is present; the callers gate on that.
|
|
461
508
|
*/
|
|
462
509
|
resolveLoadStrategy(argStrategy) {
|
|
463
510
|
return argStrategy ?? this.relationLoadStrategy;
|
|
464
511
|
}
|
|
512
|
+
/**
|
|
513
|
+
* The effective {@link QueryInterfaceOptions.stableRelationOrder} for a query:
|
|
514
|
+
* the per-query arg wins, then the client-level default (off).
|
|
515
|
+
*/
|
|
516
|
+
resolveStableOrder(argFlag) {
|
|
517
|
+
return argFlag ?? this.stableRelationOrder;
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* Fill a PK-ascending `orderBy` into every to-many `with` relation that has no
|
|
521
|
+
* explicit one, recursing into nested `with`. Returns a CLONED clause (user
|
|
522
|
+
* args are never mutated); when nothing needs filling it returns the input
|
|
523
|
+
* object unchanged, so the byte-identical fast path stays free. Only called
|
|
524
|
+
* when {@link resolveStableOrder} is true; runs BEFORE `withFingerprint`, so
|
|
525
|
+
* the two orderings get distinct SQL-cache entries automatically. To-one
|
|
526
|
+
* relations are single rows (no array to order) and PK-less targets have
|
|
527
|
+
* nothing stable to order by, so both are left untouched.
|
|
528
|
+
*/
|
|
529
|
+
applyStableRelationOrder(withClause, table, depth = 0) {
|
|
530
|
+
if (depth >= 10)
|
|
531
|
+
return withClause; // parity with the build depth cap
|
|
532
|
+
const meta = this.schema.tables[table];
|
|
533
|
+
if (!meta)
|
|
534
|
+
return withClause;
|
|
535
|
+
let out;
|
|
536
|
+
for (const [relName, spec] of Object.entries(withClause)) {
|
|
537
|
+
if (relName === '_count' || !spec)
|
|
538
|
+
continue; // `_count` is a count, not a row load
|
|
539
|
+
const rel = ownLookup(meta.relations, relName);
|
|
540
|
+
if (!rel)
|
|
541
|
+
continue; // unknown relation, let the build path surface E005
|
|
542
|
+
const options = spec === true ? {} : spec;
|
|
543
|
+
// Recurse first so a nested change alone still clones this level.
|
|
544
|
+
const nestedWith = options.with;
|
|
545
|
+
const newNested = nestedWith ? this.applyStableRelationOrder(nestedWith, rel.to, depth + 1) : undefined;
|
|
546
|
+
const nestedChanged = newNested !== undefined && newNested !== nestedWith;
|
|
547
|
+
const isToMany = rel.type === 'hasMany' || rel.type === 'manyToMany';
|
|
548
|
+
const hasOrder = options.orderBy !== undefined && !isEmptyOrderBy(options.orderBy);
|
|
549
|
+
let synthOrder;
|
|
550
|
+
if (isToMany && !hasOrder) {
|
|
551
|
+
const targetMeta = this.schema.tables[rel.to];
|
|
552
|
+
const pk = targetMeta?.primaryKey ?? [];
|
|
553
|
+
if (targetMeta && pk.length > 0) {
|
|
554
|
+
const pkFields = pk.map((c) => targetMeta.reverseColumnMap[c] ?? c);
|
|
555
|
+
synthOrder =
|
|
556
|
+
pkFields.length === 1 ? { [pkFields[0]]: 'asc' } : pkFields.map((f) => ({ [f]: 'asc' }));
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
if (!synthOrder && !nestedChanged)
|
|
560
|
+
continue; // nothing to change, keep the ref
|
|
561
|
+
out ??= { ...withClause };
|
|
562
|
+
const clonedSpec = { ...options };
|
|
563
|
+
if (synthOrder)
|
|
564
|
+
clonedSpec.orderBy = synthOrder;
|
|
565
|
+
if (nestedChanged)
|
|
566
|
+
clonedSpec.with = newNested;
|
|
567
|
+
out[relName] = clonedSpec;
|
|
568
|
+
}
|
|
569
|
+
return out ?? withClause;
|
|
570
|
+
}
|
|
571
|
+
// -------------------------------------------------------------------------
|
|
572
|
+
// relationLoadStrategy: 'auto', per-relation batched fallback when the
|
|
573
|
+
// introspected metadata proves a probe is unindexed (finding 13).
|
|
574
|
+
// -------------------------------------------------------------------------
|
|
575
|
+
/**
|
|
576
|
+
* Whether a relation can be served by the batched loader, i.e. all its
|
|
577
|
+
* correlation keys are single-column (the loader throws E017 on composite
|
|
578
|
+
* keys). Composite-key relations therefore always stay on the join plan under
|
|
579
|
+
* `'auto'` (and keep the existing unindexed-probe dev warning).
|
|
580
|
+
*/
|
|
581
|
+
relationBatchEligible(rel) {
|
|
582
|
+
if (rel.type === 'manyToMany') {
|
|
583
|
+
const through = rel.through;
|
|
584
|
+
if (!through)
|
|
585
|
+
return false;
|
|
586
|
+
const pkLen = this.schema.tables[rel.to]?.primaryKey.length ?? 0;
|
|
587
|
+
return (normalizeKeyColumns(through.sourceKey).length === 1 &&
|
|
588
|
+
normalizeKeyColumns(through.targetKey).length === 1 &&
|
|
589
|
+
normalizeKeyColumns(rel.referenceKey).length === 1 &&
|
|
590
|
+
pkLen === 1);
|
|
591
|
+
}
|
|
592
|
+
return normalizeKeyColumns(rel.foreignKey).length === 1 && normalizeKeyColumns(rel.referenceKey).length === 1;
|
|
593
|
+
}
|
|
594
|
+
/**
|
|
595
|
+
* The verdict for one relation SUBTREE under `'auto'`: is any probe in the
|
|
596
|
+
* subtree unindexed, is EVERY relation in the subtree batched-eligible, and
|
|
597
|
+
* the first unindexed probe found (for the dev note). Subtree-atomic: a whole
|
|
598
|
+
* top-level relation falls back only when its entire subtree is eligible,
|
|
599
|
+
* mirroring the batched loader recursing the same tree.
|
|
600
|
+
*/
|
|
601
|
+
autoSubtreeVerdict(rel, spec, depth) {
|
|
602
|
+
let eligible = this.relationBatchEligible(rel);
|
|
603
|
+
const ownMiss = missingIndexForRelation(this.schema, rel);
|
|
604
|
+
let unindexed = ownMiss !== null;
|
|
605
|
+
let miss = ownMiss ?? undefined;
|
|
606
|
+
const options = spec === true ? {} : spec;
|
|
607
|
+
const nested = options.with;
|
|
608
|
+
if (nested && depth < 10) {
|
|
609
|
+
const targetMeta = this.schema.tables[rel.to];
|
|
610
|
+
for (const [childName, childSpec] of Object.entries(nested)) {
|
|
611
|
+
if (!childSpec)
|
|
612
|
+
continue;
|
|
613
|
+
if (childName === '_count') {
|
|
614
|
+
const cv = this.autoCountVerdict(childSpec, targetMeta);
|
|
615
|
+
eligible = eligible && cv.eligible;
|
|
616
|
+
unindexed = unindexed || cv.unindexed;
|
|
617
|
+
if (!miss && cv.miss)
|
|
618
|
+
miss = cv.miss;
|
|
619
|
+
continue;
|
|
620
|
+
}
|
|
621
|
+
const childRel = ownLookup(targetMeta?.relations ?? {}, childName);
|
|
622
|
+
if (!childRel)
|
|
623
|
+
continue; // unknown nested relation, let the build path surface it
|
|
624
|
+
const v = this.autoSubtreeVerdict(childRel, childSpec, depth + 1);
|
|
625
|
+
eligible = eligible && v.eligible;
|
|
626
|
+
unindexed = unindexed || v.unindexed;
|
|
627
|
+
if (!miss && v.miss)
|
|
628
|
+
miss = v.miss;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
return { unindexed, eligible, miss };
|
|
632
|
+
}
|
|
633
|
+
/** The `_count` verdict under `'auto'`: any counted probe unindexed + all single-key. */
|
|
634
|
+
autoCountVerdict(countSpec, parentMeta) {
|
|
635
|
+
if (!parentMeta)
|
|
636
|
+
return { unindexed: false, eligible: true };
|
|
637
|
+
let rels;
|
|
638
|
+
try {
|
|
639
|
+
rels = resolveCountRelations(parentMeta, countSpec);
|
|
640
|
+
}
|
|
641
|
+
catch {
|
|
642
|
+
return { unindexed: false, eligible: true }; // let the join/loader path surface the error
|
|
643
|
+
}
|
|
644
|
+
let unindexed = false;
|
|
645
|
+
let eligible = true;
|
|
646
|
+
let miss;
|
|
647
|
+
for (const rel of rels) {
|
|
648
|
+
if (!this.relationBatchEligible(rel))
|
|
649
|
+
eligible = false;
|
|
650
|
+
const m = missingIndexForRelation(this.schema, rel);
|
|
651
|
+
if (m) {
|
|
652
|
+
unindexed = true;
|
|
653
|
+
miss ??= m;
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
return { unindexed, eligible, miss };
|
|
657
|
+
}
|
|
658
|
+
/**
|
|
659
|
+
* Partition a top-level `with` clause under `'auto'`: each relation whose
|
|
660
|
+
* subtree has a PROVEN unindexed probe AND is fully batched-eligible routes to
|
|
661
|
+
* `batchedWith`; everything else (indexed, composite-key, unknown) stays in
|
|
662
|
+
* `joinWith` (byte-identical join). The reserved `_count` key partitions the
|
|
663
|
+
* same way. Also returns the engaged relations for the dev note.
|
|
664
|
+
*/
|
|
665
|
+
partitionWithForAuto(withClause) {
|
|
666
|
+
const joinWith = {};
|
|
667
|
+
const batchedWith = {};
|
|
668
|
+
const engaged = [];
|
|
669
|
+
for (const [key, spec] of Object.entries(withClause)) {
|
|
670
|
+
if (!spec)
|
|
671
|
+
continue;
|
|
672
|
+
if (key === '_count') {
|
|
673
|
+
const cv = this.autoCountVerdict(spec, this.tableMeta);
|
|
674
|
+
if (cv.unindexed && cv.eligible) {
|
|
675
|
+
batchedWith[key] = spec;
|
|
676
|
+
engaged.push({ relation: '_count', miss: cv.miss });
|
|
677
|
+
}
|
|
678
|
+
else {
|
|
679
|
+
joinWith[key] = spec;
|
|
680
|
+
}
|
|
681
|
+
continue;
|
|
682
|
+
}
|
|
683
|
+
const rel = ownLookup(this.tableMeta.relations, key);
|
|
684
|
+
if (!rel) {
|
|
685
|
+
joinWith[key] = spec; // unknown relation, let the join path surface E005
|
|
686
|
+
continue;
|
|
687
|
+
}
|
|
688
|
+
const v = this.autoSubtreeVerdict(rel, spec, 0);
|
|
689
|
+
if (v.unindexed && v.eligible) {
|
|
690
|
+
batchedWith[key] = spec;
|
|
691
|
+
engaged.push({ relation: key, miss: v.miss });
|
|
692
|
+
}
|
|
693
|
+
else {
|
|
694
|
+
joinWith[key] = spec;
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
return { joinWith, batchedWith, engaged };
|
|
698
|
+
}
|
|
699
|
+
/**
|
|
700
|
+
* Plan the `'auto'` split for a query's `with` clause: normalize stable order,
|
|
701
|
+
* partition, and return the split ONLY when at least one relation falls back
|
|
702
|
+
* to batched. Returns `null` (→ run the plain join path, byte-identical, same
|
|
703
|
+
* cache keys) when there is no DB-backed index metadata or nothing qualifies.
|
|
704
|
+
*/
|
|
705
|
+
planAuto(withArg, stableFlag) {
|
|
706
|
+
// No DB-backed index info (code-first / defineSchema-only) → cannot PROVE any
|
|
707
|
+
// probe is unindexed, so 'auto' behaves exactly like 'join'.
|
|
708
|
+
if (!schemaHasIndexInfo(this.schema))
|
|
709
|
+
return null;
|
|
710
|
+
const withClause = this.resolveStableOrder(stableFlag)
|
|
711
|
+
? this.applyStableRelationOrder(withArg, this.table)
|
|
712
|
+
: withArg;
|
|
713
|
+
const split = this.partitionWithForAuto(withClause);
|
|
714
|
+
if (Object.keys(split.batchedWith).length === 0)
|
|
715
|
+
return null;
|
|
716
|
+
return split;
|
|
717
|
+
}
|
|
718
|
+
/** Dev-only once-per-relation note that `'auto'` engaged the batched fallback. */
|
|
719
|
+
emitAutoNotes(engaged) {
|
|
720
|
+
if (process.env.NODE_ENV === 'production')
|
|
721
|
+
return;
|
|
722
|
+
for (const e of engaged) {
|
|
723
|
+
if (!shouldWarnOnce(WARN_NS.autoStrategy, `${this.table}.${e.relation}`))
|
|
724
|
+
continue;
|
|
725
|
+
const probe = e.miss
|
|
726
|
+
? `probe "${e.miss.table}"(${e.miss.columns.join(', ')}) has no covering index`
|
|
727
|
+
: 'a probe in its subtree has no covering index';
|
|
728
|
+
console.warn(`[turbine] auto strategy: relation "${e.relation}" on "${this.table}" loads batched (${probe}). ` +
|
|
729
|
+
"Create the covering index (or set `relationLoadStrategy: 'join'` to force the single-statement " +
|
|
730
|
+
'plan); run `npx turbine doctor` for the exact CREATE INDEX SQL.');
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
/**
|
|
734
|
+
* Execute a findMany/findUnique `'auto'` split: run the base query with the
|
|
735
|
+
* residual `joinWith` (plus any parent stitch keys the batched subset needs),
|
|
736
|
+
* then load `batchedWith` via the batched loader and stitch. `single` returns
|
|
737
|
+
* the first entity (findUnique) instead of the array. Output is identical in
|
|
738
|
+
* shape to the pure join plan.
|
|
739
|
+
*/
|
|
740
|
+
async runAutoSplit(args, split, single) {
|
|
741
|
+
this.emitAutoNotes(split.engaged);
|
|
742
|
+
const { joinWith, batchedWith } = split;
|
|
743
|
+
// Scope-rule parity with the batched strategy: reject nested pick ordering
|
|
744
|
+
// on the batched subset up front.
|
|
745
|
+
rejectNestedPickOrder(batchedWith);
|
|
746
|
+
const skip = args.skipGlobalFilters;
|
|
747
|
+
const needed = neededParentKeyFields(this.tableMeta, batchedWith);
|
|
748
|
+
const proj = includeKeysForBatching(args.select, args.omit, needed);
|
|
749
|
+
const hasJoin = Object.keys(joinWith).length > 0;
|
|
750
|
+
// Force the residual `with` onto the join plan so the base query never
|
|
751
|
+
// re-enters this auto planning.
|
|
752
|
+
const baseArgs = {
|
|
753
|
+
...args,
|
|
754
|
+
with: hasJoin ? joinWith : undefined,
|
|
755
|
+
select: proj.select,
|
|
756
|
+
omit: proj.omit,
|
|
757
|
+
relationLoadStrategy: 'join',
|
|
758
|
+
};
|
|
759
|
+
this.currentStrategyTag = 'auto-batched';
|
|
760
|
+
try {
|
|
761
|
+
const deferred = single
|
|
762
|
+
? this.buildFindUnique(baseArgs)
|
|
763
|
+
: this.buildFindMany(baseArgs);
|
|
764
|
+
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, deferred.preparedName);
|
|
765
|
+
const rows = deferred.transform(result);
|
|
766
|
+
const entities = single ? (rows ? [rows] : []) : rows;
|
|
767
|
+
if (entities.length > 0) {
|
|
768
|
+
await loadRelationsBatched(this.batchedContext(args.timeout, skip, args.includePii === true), entities, batchedWith, args.timeout);
|
|
769
|
+
}
|
|
770
|
+
stripFields(entities, proj.strip);
|
|
771
|
+
return single ? (entities[0] ?? null) : entities;
|
|
772
|
+
}
|
|
773
|
+
finally {
|
|
774
|
+
this.currentStrategyTag = undefined;
|
|
775
|
+
}
|
|
776
|
+
}
|
|
465
777
|
/**
|
|
466
778
|
* Build the {@link RelationLoadContext} the batched loader needs, closing over
|
|
467
779
|
* this interface's pool/dialect/executor. Child readers are constructed on the
|
|
@@ -513,7 +825,12 @@ export class QueryInterface {
|
|
|
513
825
|
* from the returned rows, so the shape matches the join strategy exactly.
|
|
514
826
|
*/
|
|
515
827
|
async runFindManyBatched(args) {
|
|
516
|
-
|
|
828
|
+
// Stable relation order (opt-in): the batched loader forwards each relation's
|
|
829
|
+
// orderBy into its follow-up query, so filling the synthesized PK order here
|
|
830
|
+
// makes the batched output deterministic exactly like the join path.
|
|
831
|
+
const withClause = this.resolveStableOrder(args.stableRelationOrder)
|
|
832
|
+
? this.applyStableRelationOrder(args.with, this.table)
|
|
833
|
+
: args.with;
|
|
517
834
|
// Scope-rule parity with the join strategy (which throws at SQL build):
|
|
518
835
|
// reject nested pick-row ordering BEFORE the base query so acceptance
|
|
519
836
|
// never depends on how many rows come back.
|
|
@@ -669,7 +986,17 @@ export class QueryInterface {
|
|
|
669
986
|
if (!onQuery)
|
|
670
987
|
return;
|
|
671
988
|
try {
|
|
672
|
-
onQuery({
|
|
989
|
+
onQuery({
|
|
990
|
+
sql,
|
|
991
|
+
params,
|
|
992
|
+
duration,
|
|
993
|
+
model: this.table,
|
|
994
|
+
action,
|
|
995
|
+
rows,
|
|
996
|
+
timestamp: new Date(),
|
|
997
|
+
error,
|
|
998
|
+
strategy: this.currentStrategyTag,
|
|
999
|
+
});
|
|
673
1000
|
}
|
|
674
1001
|
catch {
|
|
675
1002
|
// Listener errors must never crash a query
|
|
@@ -808,8 +1135,15 @@ export class QueryInterface {
|
|
|
808
1135
|
// -------------------------------------------------------------------------
|
|
809
1136
|
async findUnique(args) {
|
|
810
1137
|
return this.executeWithMiddleware('findUnique', args, async () => {
|
|
811
|
-
if (args.with
|
|
812
|
-
|
|
1138
|
+
if (args.with) {
|
|
1139
|
+
const strategy = this.resolveLoadStrategy(args.relationLoadStrategy);
|
|
1140
|
+
if (strategy === 'batched')
|
|
1141
|
+
return this.runFindUniqueBatched(args);
|
|
1142
|
+
if (strategy === 'auto') {
|
|
1143
|
+
const split = this.planAuto(args.with, args.stableRelationOrder);
|
|
1144
|
+
if (split)
|
|
1145
|
+
return this.runAutoSplit(args, split, true);
|
|
1146
|
+
}
|
|
813
1147
|
}
|
|
814
1148
|
const deferred = this.buildFindUnique(args);
|
|
815
1149
|
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, deferred.preparedName);
|
|
@@ -823,7 +1157,10 @@ export class QueryInterface {
|
|
|
823
1157
|
* strategy's shape for the one row.
|
|
824
1158
|
*/
|
|
825
1159
|
async runFindUniqueBatched(args) {
|
|
826
|
-
|
|
1160
|
+
// Stable relation order (opt-in), see runFindManyBatched.
|
|
1161
|
+
const withClause = this.resolveStableOrder(args.stableRelationOrder)
|
|
1162
|
+
? this.applyStableRelationOrder(args.with, this.table)
|
|
1163
|
+
: args.with;
|
|
827
1164
|
// Same scope-rule parity as runFindManyBatched: reject before querying.
|
|
828
1165
|
rejectNestedPickOrder(withClause);
|
|
829
1166
|
const needed = neededParentKeyFields(this.tableMeta, withClause);
|
|
@@ -841,6 +1178,16 @@ export class QueryInterface {
|
|
|
841
1178
|
// biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
|
|
842
1179
|
buildFindUnique(args) {
|
|
843
1180
|
this.currentSkip = args.skipGlobalFilters;
|
|
1181
|
+
// Prisma compound-unique selector expansion (before global-filter merge and
|
|
1182
|
+
// fingerprinting, so the cache only ever sees the canonical expanded where).
|
|
1183
|
+
args = maybeExpandCompoundUnique(this.tableMeta, args);
|
|
1184
|
+
// Stable relation order (opt-in): fill PK-asc orderBy into unordered to-many
|
|
1185
|
+
// relations before fingerprinting (see buildFindMany).
|
|
1186
|
+
if (args.with && this.resolveStableOrder(args.stableRelationOrder)) {
|
|
1187
|
+
const normalized = this.applyStableRelationOrder(args.with, this.table);
|
|
1188
|
+
if (normalized !== args.with)
|
|
1189
|
+
args = { ...args, with: normalized };
|
|
1190
|
+
}
|
|
844
1191
|
const includePii = args.includePii === true;
|
|
845
1192
|
const columnsList = this.resolveColumns(args.select, args.omit, includePii);
|
|
846
1193
|
// A global filter turns the where into `{ AND: [...] }`, which the
|
|
@@ -958,16 +1305,22 @@ export class QueryInterface {
|
|
|
958
1305
|
if (process.env.NODE_ENV !== 'production') {
|
|
959
1306
|
if (args?.with) {
|
|
960
1307
|
const depth = this.measureWithDepth(args.with);
|
|
961
|
-
if (depth > 5 &&
|
|
962
|
-
this.deepWithWarned.add(this.table);
|
|
1308
|
+
if (depth > 5 && shouldWarnOnce(WARN_NS.deepWith, this.table)) {
|
|
963
1309
|
console.warn(`[turbine] Deep with clause (depth ${depth}) on "${this.tableMeta.name}" — ` +
|
|
964
1310
|
'consider splitting into separate queries for better performance.');
|
|
965
1311
|
}
|
|
966
1312
|
}
|
|
967
1313
|
}
|
|
968
1314
|
return this.executeWithMiddleware('findMany', (args ?? {}), async () => {
|
|
969
|
-
if (args?.with
|
|
970
|
-
|
|
1315
|
+
if (args?.with) {
|
|
1316
|
+
const strategy = this.resolveLoadStrategy(args.relationLoadStrategy);
|
|
1317
|
+
if (strategy === 'batched')
|
|
1318
|
+
return this.runFindManyBatched(args);
|
|
1319
|
+
if (strategy === 'auto') {
|
|
1320
|
+
const split = this.planAuto(args.with, args.stableRelationOrder);
|
|
1321
|
+
if (split)
|
|
1322
|
+
return this.runAutoSplit(args, split, false);
|
|
1323
|
+
}
|
|
971
1324
|
}
|
|
972
1325
|
const deferred = this.buildFindMany(args);
|
|
973
1326
|
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args?.timeout, deferred.preparedName);
|
|
@@ -1076,6 +1429,14 @@ export class QueryInterface {
|
|
|
1076
1429
|
// biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
|
|
1077
1430
|
buildFindMany(args) {
|
|
1078
1431
|
this.currentSkip = args?.skipGlobalFilters;
|
|
1432
|
+
// Stable relation order (opt-in): fill PK-asc orderBy into unordered to-many
|
|
1433
|
+
// relations BEFORE fingerprinting, so the two orderings get distinct cache
|
|
1434
|
+
// entries and every downstream path (SQL build, collect, parser) inherits it.
|
|
1435
|
+
if (args?.with && this.resolveStableOrder(args.stableRelationOrder)) {
|
|
1436
|
+
const normalized = this.applyStableRelationOrder(args.with, this.table);
|
|
1437
|
+
if (normalized !== args.with)
|
|
1438
|
+
args = { ...args, with: normalized };
|
|
1439
|
+
}
|
|
1079
1440
|
// `distinct` + relation orderBy is refused up front (E003): the distinct
|
|
1080
1441
|
// path re-orders in an outer wrapper (`... AS "<table>_distinct" ORDER BY
|
|
1081
1442
|
// <userOrder>`) where a correlated relation subquery (pick-row, `_count`,
|
|
@@ -1357,10 +1718,20 @@ export class QueryInterface {
|
|
|
1357
1718
|
// -------------------------------------------------------------------------
|
|
1358
1719
|
async findFirst(args) {
|
|
1359
1720
|
return this.executeWithMiddleware('findFirst', (args ?? {}), async () => {
|
|
1360
|
-
if (args?.with
|
|
1721
|
+
if (args?.with) {
|
|
1722
|
+
const strategy = this.resolveLoadStrategy(args.relationLoadStrategy);
|
|
1361
1723
|
// findFirst is findMany + LIMIT 1: batch the single base row, then load.
|
|
1362
|
-
|
|
1363
|
-
|
|
1724
|
+
if (strategy === 'batched') {
|
|
1725
|
+
const rows = await this.runFindManyBatched({ ...args, limit: 1 });
|
|
1726
|
+
return (rows[0] ?? null);
|
|
1727
|
+
}
|
|
1728
|
+
if (strategy === 'auto') {
|
|
1729
|
+
const split = this.planAuto(args.with, args.stableRelationOrder);
|
|
1730
|
+
if (split) {
|
|
1731
|
+
const rows = (await this.runAutoSplit({ ...args, limit: 1 }, split, false));
|
|
1732
|
+
return (rows[0] ?? null);
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1364
1735
|
}
|
|
1365
1736
|
const deferred = this.buildFindFirst(args);
|
|
1366
1737
|
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args?.timeout, deferred.preparedName);
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* turbine-orm — Prisma-style compound-unique `where` selectors.
|
|
3
|
+
*
|
|
4
|
+
* Prisma lets a `findUnique`-family `where` address a multi-column unique
|
|
5
|
+
* constraint through a single synthetic key holding the member columns:
|
|
6
|
+
*
|
|
7
|
+
* ```ts
|
|
8
|
+
* db.members.findUnique({ where: { orgId_userId: { orgId: 1, userId: 7 } } })
|
|
9
|
+
* // ≡ where: { orgId: 1, userId: 7 } → WHERE "org_id" = $1 AND "user_id" = $2
|
|
10
|
+
* ```
|
|
11
|
+
*
|
|
12
|
+
* {@link expandCompoundUniqueWhere} rewrites such a selector into the equivalent
|
|
13
|
+
* column conjunction BEFORE the where is fingerprinted / SQL-compiled, so the
|
|
14
|
+
* template cache only ever sees the canonical expanded form (no new cache-key
|
|
15
|
+
* segment, and the expanded shape shares its entry with the spelled-out form).
|
|
16
|
+
*
|
|
17
|
+
* Selector NAMES come from the table metadata, in priority order:
|
|
18
|
+
* 1. a composite primary key (length ≥ 2);
|
|
19
|
+
* 2. each composite `uniqueColumns` entry (introspected composite UNIQUE
|
|
20
|
+
* constraints);
|
|
21
|
+
* 3. each composite UNIQUE index in `indexes` (`unique && !docPath`, the ONLY
|
|
22
|
+
* composite-unique source a `defineSchema` code-first client has, since
|
|
23
|
+
* `defineSchema` records single-column uniques in `uniqueColumns` and
|
|
24
|
+
* composite uniques as declared unique indexes).
|
|
25
|
+
*
|
|
26
|
+
* For every column set two lookup names are registered (both mapping to the same
|
|
27
|
+
* ordered FIELD list): the underscore join of the camelCase FIELD names
|
|
28
|
+
* (`orgId_userId`, Prisma's default) and, when different, the underscore join of
|
|
29
|
+
* the raw snake_case column names (`org_id_user_id`).
|
|
30
|
+
*
|
|
31
|
+
* Collision rules (deterministic, documented):
|
|
32
|
+
* - a synthetic name equal to a real field / column / relation name is never
|
|
33
|
+
* registered (real members always win);
|
|
34
|
+
* - two DIFFERENT column sets producing the same name drop that name entirely.
|
|
35
|
+
*
|
|
36
|
+
* The map is pure metadata, so it is computed lazily and memoized per
|
|
37
|
+
* {@link TableMetadata} in a module-level `WeakMap`.
|
|
38
|
+
*/
|
|
39
|
+
import type { TableMetadata } from '../schema.js';
|
|
40
|
+
/**
|
|
41
|
+
* Expand any Prisma compound-unique selector keys in `where` into their column
|
|
42
|
+
* conjunction. Returns the SAME object reference when nothing expands (the
|
|
43
|
+
* byte-identical fast path), else a shallow clone with the selector keys
|
|
44
|
+
* replaced. A selector key whose members do not exactly match the constraint's
|
|
45
|
+
* fields throws a {@link ValidationError} (E003) naming the required members; a
|
|
46
|
+
* non-selector unknown key is left untouched to fall through to the existing
|
|
47
|
+
* unknown-column error at SQL-build time.
|
|
48
|
+
*/
|
|
49
|
+
export declare function expandCompoundUniqueWhere(meta: TableMetadata, where: Record<string, unknown>): Record<string, unknown>;
|
|
Binary file
|
package/dist/query/deferred.d.ts
CHANGED
|
@@ -55,6 +55,14 @@ export interface QueryEvent {
|
|
|
55
55
|
rows: number;
|
|
56
56
|
timestamp: Date;
|
|
57
57
|
error?: Error;
|
|
58
|
+
/**
|
|
59
|
+
* Set to `'auto-batched'` on the statements of a query whose
|
|
60
|
+
* `relationLoadStrategy: 'auto'` engaged the per-relation batched fallback
|
|
61
|
+
* (the base statement and every batched follow-up). Absent for the plain join
|
|
62
|
+
* path, explicit `'join'`/`'batched'`, and non-auto queries, so production
|
|
63
|
+
* observability can see exactly which queries the auto default re-planned.
|
|
64
|
+
*/
|
|
65
|
+
strategy?: 'auto-batched';
|
|
58
66
|
}
|
|
59
67
|
export type QueryEventListener = (event: QueryEvent) => void;
|
|
60
68
|
/** Options passed from TurbineClient to QueryInterface */
|
|
@@ -137,6 +145,16 @@ export interface QueryInterfaceOptions {
|
|
|
137
145
|
* silently; see the PowDB docs).
|
|
138
146
|
*/
|
|
139
147
|
relationLoadStrategy?: RelationLoadStrategy;
|
|
148
|
+
/**
|
|
149
|
+
* When `true`, every to-many `with` relation that carries no explicit
|
|
150
|
+
* `orderBy` is loaded ordered by the target table's primary key ascending, so
|
|
151
|
+
* unordered child arrays come back in a deterministic order (json_agg / batched
|
|
152
|
+
* loaders otherwise leave child-array order engine-dependent). An explicit
|
|
153
|
+
* per-relation `orderBy` always wins, and a per-query `stableRelationOrder`
|
|
154
|
+
* overrides this default. Default `false`; when off the emitted SQL is
|
|
155
|
+
* byte-identical to before. SQL engines only (PowDB keeps its own defaults).
|
|
156
|
+
*/
|
|
157
|
+
stableRelationOrder?: boolean;
|
|
140
158
|
/**
|
|
141
159
|
* How nested-relation subqueries encode each row's JSON: `'object'` (default,
|
|
142
160
|
* `json_build_object`) or `'positional'` (`json_build_array`, key-less — see
|
package/dist/query/relations.js
CHANGED
|
@@ -18,10 +18,9 @@ import { camelToSnake, normalizeKeyColumns, snakeToCamel } from '../schema.js';
|
|
|
18
18
|
import { resolveCountRelations } from './batched-loader.js';
|
|
19
19
|
import { isJsonPathOrderBy, isOrderBySpec, isRelationPickOrderBy, isVectorOrderBy, normalizeOrderBy, orderByEntries, sortedEntries, } from './filters.js';
|
|
20
20
|
import { ownLookup } from './utils.js';
|
|
21
|
+
import { hasWarnedOnce, shouldWarnOnce, WARN_NS } from './warn-registry.js';
|
|
21
22
|
import * as whereMod from './where.js';
|
|
22
23
|
import * as writesMod from './writes.js';
|
|
23
|
-
/** Relations already warned about missing FK indexes (once per process, dev only). */
|
|
24
|
-
const unindexedRelationWarned = new Set();
|
|
25
24
|
/**
|
|
26
25
|
* Resolve select/omit options into a list of snake_case column names.
|
|
27
26
|
* Returns null if neither is provided (meaning all columns).
|
|
@@ -1326,10 +1325,13 @@ export function buildRelationSubquery(qi, relDef, spec, params, parentRef, alias
|
|
|
1326
1325
|
// instead of letting the slowness look like an ORM problem.
|
|
1327
1326
|
if (process.env.NODE_ENV !== 'production') {
|
|
1328
1327
|
const warnKey = `${relDef.from}.${relDef.name}`;
|
|
1329
|
-
|
|
1328
|
+
// Compute the (potentially costly) probe only for a key not yet warned, and
|
|
1329
|
+
// claim the key through the process-wide registry ONLY when we actually warn,
|
|
1330
|
+
// so a dual-package / HMR-reevaluated second module copy cannot re-warn the
|
|
1331
|
+
// same relation, and indexed relations never consume the dedupe cap.
|
|
1332
|
+
if (!hasWarnedOnce(WARN_NS.unindexedRelation, warnKey)) {
|
|
1330
1333
|
const miss = missingIndexForRelation(qi.schema, relDef);
|
|
1331
|
-
if (miss) {
|
|
1332
|
-
unindexedRelationWarned.add(warnKey);
|
|
1334
|
+
if (miss && shouldWarnOnce(WARN_NS.unindexedRelation, warnKey)) {
|
|
1333
1335
|
console.warn(`[turbine] Relation "${relDef.name}" on "${relDef.from}" probes ` +
|
|
1334
1336
|
`"${miss.table}"(${miss.columns.join(', ')}) which has no covering index — ` +
|
|
1335
1337
|
`each parent row scans the full table. Fix: ${miss.createSql}; ` +
|