turbine-orm 0.49.0 → 0.50.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 +122 -39
- package/dist/cjs/adapters/cockroachdb.d.ts +39 -0
- package/dist/cjs/adapters/index.d.ts +110 -0
- package/dist/cjs/adapters/yugabytedb.d.ts +51 -0
- package/dist/cjs/cli/config.d.ts +181 -0
- package/dist/cjs/cli/config.js +32 -6
- package/dist/cjs/cli/destructive.d.ts +38 -0
- package/dist/cjs/cli/index.d.ts +359 -0
- package/dist/cjs/cli/index.js +228 -56
- package/dist/cjs/cli/loader.d.ts +61 -0
- package/dist/cjs/cli/mcp.d.ts +42 -0
- package/dist/cjs/cli/migrate.d.ts +356 -0
- package/dist/cjs/cli/migrate.js +131 -40
- package/dist/cjs/cli/observe-ui.d.ts +1 -0
- package/dist/cjs/cli/observe-ui.js +14 -5
- package/dist/cjs/cli/observe.d.ts +25 -0
- package/dist/cjs/cli/observe.js +49 -12
- package/dist/cjs/cli/pii-tags.d.ts +53 -0
- package/dist/cjs/cli/prisma-report.d.ts +33 -0
- package/dist/cjs/cli/prisma-report.js +73 -0
- package/dist/cjs/cli/prisma-resolve.d.ts +106 -0
- package/dist/cjs/cli/prisma-resolve.js +1 -0
- package/dist/cjs/cli/prisma-schema.d.ts +176 -0
- package/dist/cjs/cli/prisma-schema.js +82 -4
- package/dist/cjs/cli/rate-limit.d.ts +32 -0
- package/dist/cjs/cli/rate-limit.js +45 -0
- package/dist/cjs/cli/studio-demo.d.ts +43 -0
- package/dist/cjs/cli/studio-ui.generated.d.ts +1 -0
- package/dist/cjs/cli/studio.d.ts +207 -0
- package/dist/cjs/cli/studio.js +136 -71
- package/dist/cjs/cli/ui.d.ts +73 -0
- package/dist/cjs/cli/ui.js +51 -9
- package/dist/cjs/client.d.ts +837 -0
- package/dist/cjs/client.js +3 -0
- package/dist/cjs/dialect.d.ts +516 -0
- package/dist/cjs/dialect.js +37 -12
- package/dist/cjs/errors.d.ts +370 -0
- package/dist/cjs/generate.d.ts +137 -0
- package/dist/cjs/generate.js +39 -6
- package/dist/cjs/index-advisor.d.ts +153 -0
- package/dist/cjs/index-stats.d.ts +384 -0
- package/dist/cjs/index.d.ts +55 -0
- package/dist/cjs/index.js +7 -2
- package/dist/cjs/introspect.d.ts +269 -0
- package/dist/cjs/mssql.d.ts +232 -0
- package/dist/cjs/mssql.js +6 -0
- package/dist/cjs/mysql.d.ts +173 -0
- package/dist/cjs/mysql.js +16 -0
- package/dist/cjs/nested-write.d.ts +96 -0
- package/dist/cjs/nested-write.js +414 -24
- package/dist/cjs/observe.d.ts +115 -0
- package/dist/cjs/optional-peer-import.d.cts +72 -0
- package/dist/cjs/pipeline-submittable.d.ts +93 -0
- package/dist/cjs/pipeline.d.ts +71 -0
- package/dist/cjs/powdb-introspect.d.ts +84 -0
- package/dist/cjs/powdb.d.ts +931 -0
- package/dist/cjs/powdb.js +106 -21
- package/dist/cjs/powql.d.ts +592 -0
- package/dist/cjs/powql.js +42 -6
- package/dist/cjs/prisma-compat.d.ts +283 -0
- package/dist/cjs/prisma-compat.js +167 -9
- package/dist/cjs/query/aggregates.d.ts +92 -0
- package/dist/cjs/query/aggregates.js +7 -3
- package/dist/cjs/query/batched-loader.d.ts +193 -0
- package/dist/cjs/query/builder.d.ts +849 -0
- package/dist/cjs/query/builder.js +571 -65
- package/dist/cjs/query/compound-unique.d.ts +51 -0
- package/dist/cjs/query/deferred.d.ts +223 -0
- package/dist/cjs/query/filters.d.ts +201 -0
- package/dist/cjs/query/index.d.ts +14 -0
- package/dist/cjs/query/index.js +6 -1
- package/dist/cjs/query/relations.d.ts +609 -0
- package/dist/cjs/query/relations.js +693 -46
- package/dist/cjs/query/types.d.ts +1300 -0
- package/dist/cjs/query/utils.d.ts +209 -0
- package/dist/cjs/query/utils.js +208 -1
- package/dist/cjs/query/warn-registry.d.ts +68 -0
- package/dist/cjs/query/warn-registry.js +9 -0
- package/dist/cjs/query/where-compile.d.ts +139 -0
- package/dist/cjs/query/where.d.ts +548 -0
- package/dist/cjs/query/where.js +58 -22
- package/dist/cjs/query/writes.d.ts +172 -0
- package/dist/cjs/query/writes.js +105 -12
- package/dist/cjs/realtime.d.ts +70 -0
- package/dist/cjs/schema-builder.d.ts +354 -0
- package/dist/cjs/schema-metadata.d.ts +83 -0
- package/dist/cjs/schema-sql.d.ts +217 -0
- package/dist/cjs/schema-sql.js +23 -5
- package/dist/cjs/schema.d.ts +356 -0
- package/dist/cjs/schema.js +125 -0
- package/dist/cjs/seed.d.ts +15 -0
- package/dist/cjs/serverless.d.ts +142 -0
- package/dist/cjs/sqlite.d.ts +143 -0
- package/dist/cjs/sqlite.js +4 -0
- package/dist/cjs/typed-sql.d.ts +102 -0
- package/dist/cli/config.d.ts +18 -4
- package/dist/cli/config.js +31 -6
- package/dist/cli/index.d.ts +123 -0
- package/dist/cli/index.js +223 -58
- package/dist/cli/migrate.d.ts +59 -10
- package/dist/cli/migrate.js +128 -41
- package/dist/cli/observe-ui.d.ts +1 -1
- package/dist/cli/observe-ui.js +14 -5
- package/dist/cli/observe.d.ts +7 -1
- package/dist/cli/observe.js +48 -12
- package/dist/cli/prisma-report.d.ts +14 -0
- package/dist/cli/prisma-report.js +72 -0
- package/dist/cli/prisma-resolve.d.ts +6 -0
- package/dist/cli/prisma-resolve.js +1 -0
- package/dist/cli/prisma-schema.d.ts +62 -2
- package/dist/cli/prisma-schema.js +81 -4
- package/dist/cli/rate-limit.d.ts +32 -0
- package/dist/cli/rate-limit.js +40 -0
- package/dist/cli/studio.d.ts +5 -5
- package/dist/cli/studio.js +135 -70
- package/dist/cli/ui.d.ts +1 -1
- package/dist/cli/ui.js +51 -9
- package/dist/client.d.ts +40 -0
- package/dist/client.js +3 -0
- package/dist/dialect.d.ts +17 -1
- package/dist/dialect.js +37 -12
- package/dist/generate.js +40 -7
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/mssql.js +6 -0
- package/dist/mysql.js +16 -0
- package/dist/nested-write.d.ts +2 -0
- package/dist/nested-write.js +415 -25
- package/dist/powdb.d.ts +4 -2
- package/dist/powdb.js +106 -21
- package/dist/powql.d.ts +5 -0
- package/dist/powql.js +42 -6
- package/dist/prisma-compat.d.ts +2 -0
- package/dist/prisma-compat.js +166 -8
- package/dist/query/aggregates.js +7 -3
- package/dist/query/builder.d.ts +292 -21
- package/dist/query/builder.js +570 -64
- package/dist/query/deferred.d.ts +39 -0
- package/dist/query/index.d.ts +1 -1
- package/dist/query/index.js +1 -1
- package/dist/query/relations.d.ts +173 -5
- package/dist/query/relations.js +688 -47
- package/dist/query/types.d.ts +123 -39
- package/dist/query/utils.d.ts +116 -0
- package/dist/query/utils.js +198 -0
- package/dist/query/warn-registry.d.ts +9 -0
- package/dist/query/warn-registry.js +9 -0
- package/dist/query/where.d.ts +38 -1
- package/dist/query/where.js +58 -23
- package/dist/query/writes.d.ts +42 -1
- package/dist/query/writes.js +104 -13
- package/dist/schema-sql.d.ts +14 -0
- package/dist/schema-sql.js +23 -5
- package/dist/schema.d.ts +38 -0
- package/dist/schema.js +123 -0
- package/dist/sqlite.js +4 -0
- package/package.json +77 -28
package/dist/query/builder.js
CHANGED
|
@@ -69,6 +69,71 @@ function cacheCrossCheckMode() {
|
|
|
69
69
|
* so this set is only ever touched on the `'sampled'` path.
|
|
70
70
|
*/
|
|
71
71
|
const loggedCacheMismatchFingerprints = new Set();
|
|
72
|
+
/**
|
|
73
|
+
* Marginal cost of keeping a to-one relation on the JOIN plan, per parent row.
|
|
74
|
+
*
|
|
75
|
+
* A to-one relation compiled into the join plan is a CORRELATED subquery: the
|
|
76
|
+
* engine re-evaluates it once per parent row, so the join plan costs roughly
|
|
77
|
+
* `AUTO_JOIN_PENALTY_MS_PER_ROW * parentRows` more CPU than one flat follow-up
|
|
78
|
+
* query, no matter how well indexed the correlation column is. The batched plan
|
|
79
|
+
* pays that back as a second statement, i.e. one extra round trip.
|
|
80
|
+
*
|
|
81
|
+
* Break-even is therefore, to a first approximation:
|
|
82
|
+
*
|
|
83
|
+
* parentRows = roundTripMs / AUTO_JOIN_PENALTY_MS_PER_ROW
|
|
84
|
+
*
|
|
85
|
+
* Measured (PostgreSQL 17, hasOne over a UNIQUE FK, 10K-row parent table,
|
|
86
|
+
* median of 15 reps per point) at two very different link speeds:
|
|
87
|
+
*
|
|
88
|
+
* link RTT penalty/row break-even observed crossover
|
|
89
|
+
* ─────────────────── ──────── ───────────── ──────────── ──────────────────
|
|
90
|
+
* loopback TCP 0.118ms 0.000711ms ~166-236 between 200 and 400
|
|
91
|
+
* +1ms/direction 2.683ms 0.000717ms ~3744-3993 between 3000 and 5000
|
|
92
|
+
*
|
|
93
|
+
* The two things that matters most in that table: the per-row penalty is
|
|
94
|
+
* essentially IDENTICAL across the two links (it is a property of the plan, not
|
|
95
|
+
* the wire), while the break-even moved by 17x. So the break-even is a function
|
|
96
|
+
* of the deployment's round-trip time and NOTHING ELSE that is knowable at plan
|
|
97
|
+
* time. That is why this is expressed as a per-row cost and a round-trip time
|
|
98
|
+
* rather than as a hard-coded row count: a row count tuned on a Unix socket is
|
|
99
|
+
* off by ~20x for a cross-region deployment, and vice versa. Concretely, the
|
|
100
|
+
* previously shipped flat `1000` was simultaneously too HIGH on loopback
|
|
101
|
+
* (up to 1.44x slower than the better plan just under the cliff) and too LOW
|
|
102
|
+
* over a 2.7ms link (1.26x slower just above it).
|
|
103
|
+
*/
|
|
104
|
+
export const AUTO_JOIN_PENALTY_MS_PER_ROW = 0.0007;
|
|
105
|
+
/**
|
|
106
|
+
* Round-trip time assumed before this process has observed a real one, chosen
|
|
107
|
+
* as a typical same-region managed-Postgres latency (app and database in one
|
|
108
|
+
* region over TCP). It is stated as a LATENCY rather than a row count so the
|
|
109
|
+
* assumption is visible and re-derivable: at
|
|
110
|
+
* {@link AUTO_JOIN_PENALTY_MS_PER_ROW} it yields exactly the 1000-row default
|
|
111
|
+
* this heuristic has always shipped, so an unmeasured process behaves exactly
|
|
112
|
+
* as before.
|
|
113
|
+
*/
|
|
114
|
+
export const AUTO_ASSUMED_ROUND_TRIP_MS = 0.7;
|
|
115
|
+
/**
|
|
116
|
+
* Default parent-row ceiling under which `'auto'` keeps a to-one relation on
|
|
117
|
+
* the single-statement join plan: {@link AUTO_ASSUMED_ROUND_TRIP_MS} divided by
|
|
118
|
+
* {@link AUTO_JOIN_PENALTY_MS_PER_ROW}. Used until the process has measured its
|
|
119
|
+
* own round-trip time, and whenever measurement is unavailable.
|
|
120
|
+
*/
|
|
121
|
+
export const AUTO_TO_ONE_JOIN_MAX_ROWS = Math.round(AUTO_ASSUMED_ROUND_TRIP_MS / AUTO_JOIN_PENALTY_MS_PER_ROW);
|
|
122
|
+
/**
|
|
123
|
+
* Clamps on the MEASURED threshold (an explicit `autoToOneJoinMaxRows` is an
|
|
124
|
+
* instruction, not an estimate, and bypasses both).
|
|
125
|
+
*
|
|
126
|
+
* The lower clamp matters: on a very fast link the formula can drop the
|
|
127
|
+
* threshold to a few dozen rows, and the sweep shows the join plan winning by
|
|
128
|
+
* up to 1.83x on a handful of parent rows, where the second statement's fixed
|
|
129
|
+
* cost dwarfs everything. Holding the floor at 100 rows keeps those small
|
|
130
|
+
* queries on the join plan; the cost of doing so, in the band where batched has
|
|
131
|
+
* just started to win, is under 1.2x. The upper clamp is a sanity bound for a
|
|
132
|
+
* pathological latency reading (a 70ms measurement would otherwise ask for
|
|
133
|
+
* 100K rows).
|
|
134
|
+
*/
|
|
135
|
+
export const AUTO_TO_ONE_JOIN_ROWS_MIN = 100;
|
|
136
|
+
export const AUTO_TO_ONE_JOIN_ROWS_MAX = 100_000;
|
|
72
137
|
/**
|
|
73
138
|
* Strict structural equality for a single SQL parameter value. Handles the
|
|
74
139
|
* value shapes Turbine binds: primitives (incl. `NaN` and `bigint`), `null`/
|
|
@@ -217,6 +282,23 @@ export class QueryInterface {
|
|
|
217
282
|
relationLoadStrategy;
|
|
218
283
|
/** Client-level default for {@link applyStableRelationOrder} (off unless configured). */
|
|
219
284
|
stableRelationOrder;
|
|
285
|
+
/**
|
|
286
|
+
* Client-level opt-in: apply an implicit primary-key ascending `ORDER BY` to a
|
|
287
|
+
* `findMany` that paginates (`limit`/`take`/`offset`) but declares no
|
|
288
|
+
* `orderBy`. OFF by default in core, see {@link applyImplicitPkOrdering}.
|
|
289
|
+
*/
|
|
290
|
+
implicitPkOrdering;
|
|
291
|
+
/**
|
|
292
|
+
* Explicitly configured parent-row ceiling for the `'auto'` to-one rule, or
|
|
293
|
+
* `undefined` to derive it from the observed round-trip time. See
|
|
294
|
+
* {@link autoToOneThreshold}.
|
|
295
|
+
*/
|
|
296
|
+
autoToOneJoinMaxRowsOption;
|
|
297
|
+
/**
|
|
298
|
+
* Deployment round-trip time in milliseconds, from which the to-one threshold
|
|
299
|
+
* is derived. See {@link autoToOneThreshold}.
|
|
300
|
+
*/
|
|
301
|
+
autoRoundTripMs;
|
|
220
302
|
/** Nested-relation JSON encoding: 'object' (default) or 'positional'. */
|
|
221
303
|
jsonEncoding;
|
|
222
304
|
/**
|
|
@@ -338,6 +420,12 @@ export class QueryInterface {
|
|
|
338
420
|
this.dialect = options?.dialect ?? postgresDialect;
|
|
339
421
|
this.relationLoadStrategy = options?.relationLoadStrategy ?? 'auto';
|
|
340
422
|
this.stableRelationOrder = options?.stableRelationOrder === true;
|
|
423
|
+
this.implicitPkOrdering = options?.implicitPkOrdering === true;
|
|
424
|
+
const autoToOne = options?.autoToOneJoinMaxRows;
|
|
425
|
+
this.autoToOneJoinMaxRowsOption =
|
|
426
|
+
autoToOne !== undefined && Number.isFinite(autoToOne) && autoToOne >= 0 ? Math.floor(autoToOne) : undefined;
|
|
427
|
+
const rtt = options?.autoRoundTripMs;
|
|
428
|
+
this.autoRoundTripMs = rtt !== undefined && Number.isFinite(rtt) && rtt > 0 ? rtt : undefined;
|
|
341
429
|
this.jsonEncoding = options?.jsonEncoding ?? 'object';
|
|
342
430
|
// Only retain the map when it has at least one entry, so `globalFilters`
|
|
343
431
|
// stays `undefined` (and every merge path a no-op) for the common case.
|
|
@@ -404,7 +492,8 @@ export class QueryInterface {
|
|
|
404
492
|
camelDateFieldCache: this.camelDateFieldCache,
|
|
405
493
|
limitOneClause: () => this.limitOneClause(),
|
|
406
494
|
buildPagination: (limitPh, offsetPh, hasOrderBy) => this.buildPagination(limitPh, offsetPh, hasOrderBy),
|
|
407
|
-
paginationRef: (value, params) => this.paginationRef(value, params),
|
|
495
|
+
paginationRef: (value, params, arg) => this.paginationRef(value, params, arg),
|
|
496
|
+
paginationValue: (value, arg) => this.paginationValue(value, arg),
|
|
408
497
|
};
|
|
409
498
|
}
|
|
410
499
|
/** Quote an identifier through the active SQL dialect. */
|
|
@@ -455,19 +544,33 @@ export class QueryInterface {
|
|
|
455
544
|
}
|
|
456
545
|
return ' LIMIT 1';
|
|
457
546
|
}
|
|
547
|
+
/**
|
|
548
|
+
* Coerce a LIMIT/OFFSET argument and validate it as a non-negative safe
|
|
549
|
+
* integer. Numeric strings (`'5'`) coerce; everything else (`NaN`, a
|
|
550
|
+
* non-numeric string, a negative, a fractional or out-of-safe-range number)
|
|
551
|
+
* throws {@link ValidationError} (E003) naming the argument and the table.
|
|
552
|
+
*
|
|
553
|
+
* This runs on EVERY pagination path, parameterized as well as inlined: a
|
|
554
|
+
* bound `NaN` serializes as SQL NULL, and Postgres reads `LIMIT NULL` as
|
|
555
|
+
* "no limit", so an unvalidated value silently turns a paginated query into
|
|
556
|
+
* a full-table read (and a bad OFFSET silently disappears).
|
|
557
|
+
*/
|
|
558
|
+
paginationValue(value, arg) {
|
|
559
|
+
const n = Number(value);
|
|
560
|
+
if (!Number.isSafeInteger(n) || n < 0) {
|
|
561
|
+
throw new ValidationError(`[turbine] ${arg} on "${this.table}" must be a non-negative integer, received: ${String(value)}`);
|
|
562
|
+
}
|
|
563
|
+
return n;
|
|
564
|
+
}
|
|
458
565
|
/**
|
|
459
566
|
* Validate a LIMIT/OFFSET value as a non-negative integer and return it as an
|
|
460
567
|
* inline SQL literal. Used only on `dialect.inlineLimitOffset` engines (MySQL).
|
|
461
568
|
* The input is always a Turbine-controlled pagination value, never a raw user
|
|
462
|
-
* string
|
|
569
|
+
* string, and this guard guarantees the output is `String` of a validated
|
|
463
570
|
* integer, so inlining cannot inject SQL.
|
|
464
571
|
*/
|
|
465
|
-
limitOffsetLiteral(value) {
|
|
466
|
-
|
|
467
|
-
if (!Number.isInteger(n) || n < 0) {
|
|
468
|
-
throw new ValidationError(`LIMIT/OFFSET must be a non-negative integer, received: ${String(value)}`);
|
|
469
|
-
}
|
|
470
|
-
return String(n);
|
|
572
|
+
limitOffsetLiteral(value, arg) {
|
|
573
|
+
return String(this.paginationValue(value, arg));
|
|
471
574
|
}
|
|
472
575
|
/**
|
|
473
576
|
* Resolve a LIMIT/OFFSET value to either an inline literal (no param pushed, on
|
|
@@ -476,11 +579,11 @@ export class QueryInterface {
|
|
|
476
579
|
* the param order stays mirrored; PG/SQLite/SQL Server keep parameterizing and
|
|
477
580
|
* stay byte-identical.
|
|
478
581
|
*/
|
|
479
|
-
paginationRef(value, params) {
|
|
582
|
+
paginationRef(value, params, arg) {
|
|
480
583
|
if (this.dialect.inlineLimitOffset) {
|
|
481
|
-
return this.limitOffsetLiteral(value);
|
|
584
|
+
return this.limitOffsetLiteral(value, arg);
|
|
482
585
|
}
|
|
483
|
-
params.push(
|
|
586
|
+
params.push(this.paginationValue(value, arg));
|
|
484
587
|
return this.p(params.length);
|
|
485
588
|
}
|
|
486
589
|
/**
|
|
@@ -569,6 +672,180 @@ export class QueryInterface {
|
|
|
569
672
|
return out ?? withClause;
|
|
570
673
|
}
|
|
571
674
|
// -------------------------------------------------------------------------
|
|
675
|
+
// Deterministic pagination (unordered LIMIT/OFFSET)
|
|
676
|
+
// -------------------------------------------------------------------------
|
|
677
|
+
/**
|
|
678
|
+
* The primary key of this table as an ascending `orderBy`, in DECLARATION
|
|
679
|
+
* order (a composite PK orders on every column), or `undefined` for a PK-less
|
|
680
|
+
* table. Field names are the camelCase accessor names, so the emitted SQL goes
|
|
681
|
+
* through the normal column mapping.
|
|
682
|
+
*/
|
|
683
|
+
pkOrderBy() {
|
|
684
|
+
const pk = this.tableMeta.primaryKey ?? [];
|
|
685
|
+
if (pk.length === 0)
|
|
686
|
+
return undefined;
|
|
687
|
+
const fields = pk.map((c) => this.tableMeta.reverseColumnMap[c] ?? c);
|
|
688
|
+
return fields.length === 1 ? { [fields[0]]: 'asc' } : fields.map((f) => ({ [f]: 'asc' }));
|
|
689
|
+
}
|
|
690
|
+
/**
|
|
691
|
+
* The field names a `cursor` actually seeks on (its own keys with a defined
|
|
692
|
+
* value), in the canonical sorted order the cursor conditions are built in.
|
|
693
|
+
* Empty for a missing cursor or one whose every value is `undefined` (which
|
|
694
|
+
* emits no seek condition at all, so it does not paginate).
|
|
695
|
+
*/
|
|
696
|
+
cursorFields(cursor) {
|
|
697
|
+
if (!cursor || typeof cursor !== 'object')
|
|
698
|
+
return [];
|
|
699
|
+
return Object.entries(cursor)
|
|
700
|
+
.filter(([, v]) => v !== undefined)
|
|
701
|
+
.map(([k]) => k)
|
|
702
|
+
.sort();
|
|
703
|
+
}
|
|
704
|
+
/**
|
|
705
|
+
* The ascending ordering implied by a `cursor`, or `undefined` when the shape
|
|
706
|
+
* is too ambiguous to order safely.
|
|
707
|
+
*
|
|
708
|
+
* A cursor seek emits `col > $n` per field (`<` when the orderBy says desc),
|
|
709
|
+
* so the ONLY ordering coherent with it is on the cursor's own field: ordering
|
|
710
|
+
* a seek on column X by column Y walks the table in an order the seek does not
|
|
711
|
+
* follow, which skips and repeats rows just as badly as no order at all. That
|
|
712
|
+
* is why this orders on the cursor field rather than blindly on the primary
|
|
713
|
+
* key when the two differ.
|
|
714
|
+
*
|
|
715
|
+
* Returns `undefined` (warn, inject nothing) for two shapes:
|
|
716
|
+
* - a MULTI-field cursor. `a > $1 AND b > $2` is a conjunction, not a proper
|
|
717
|
+
* composite keyset seek (`(a, b) > ($1, $2)`), so no single ORDER BY makes
|
|
718
|
+
* it correct. Injecting `(a asc, b asc)` would dress a broken seek up as a
|
|
719
|
+
* sound one.
|
|
720
|
+
* - a field that does not resolve to a real column. Column validation belongs
|
|
721
|
+
* to the normal build path, which raises a precise error; synthesizing an
|
|
722
|
+
* ORDER BY on it here would only change which error the caller sees.
|
|
723
|
+
*/
|
|
724
|
+
cursorOrderBy(cursor) {
|
|
725
|
+
const fields = this.cursorFields(cursor);
|
|
726
|
+
if (fields.length !== 1)
|
|
727
|
+
return undefined;
|
|
728
|
+
const field = fields[0];
|
|
729
|
+
try {
|
|
730
|
+
this.toColumn(field);
|
|
731
|
+
}
|
|
732
|
+
catch {
|
|
733
|
+
return undefined;
|
|
734
|
+
}
|
|
735
|
+
return { [field]: 'asc' };
|
|
736
|
+
}
|
|
737
|
+
/**
|
|
738
|
+
* Whether a findMany paginates (`limit` / `take` / `offset` / `cursor`) but
|
|
739
|
+
* declares no ordering, which makes the returned page NON-DETERMINISTIC:
|
|
740
|
+
* Postgres is free to return different rows for the same unordered `LIMIT`
|
|
741
|
+
* once the heap changes underneath it, so a row can appear on two pages or on
|
|
742
|
+
* none.
|
|
743
|
+
*
|
|
744
|
+
* `cursor` counts, and is the worst case rather than an exception: a keyset
|
|
745
|
+
* seek with no ORDER BY is exactly this bug (`WHERE id > $1 LIMIT $2` walks
|
|
746
|
+
* the heap in whatever order the plan happens to produce). An empty orderBy
|
|
747
|
+
* (`[]`, or an object whose every value is `undefined`) counts as absent,
|
|
748
|
+
* because it emits no ordering.
|
|
749
|
+
*
|
|
750
|
+
* `distinct` is still excluded: that path re-orders in an outer wrapper around
|
|
751
|
+
* a `DISTINCT ON` whose ordering picks the representative row, so an implicit
|
|
752
|
+
* key would change which rows come back, not just their order.
|
|
753
|
+
*/
|
|
754
|
+
isUnorderedPage(args) {
|
|
755
|
+
if (!args)
|
|
756
|
+
return false;
|
|
757
|
+
if (args.distinct !== undefined)
|
|
758
|
+
return false;
|
|
759
|
+
if (!isEmptyOrderBy(args.orderBy))
|
|
760
|
+
return false;
|
|
761
|
+
if (args.limit !== undefined || args.take !== undefined || args.offset !== undefined)
|
|
762
|
+
return true;
|
|
763
|
+
return this.cursorFields(args.cursor).length > 0;
|
|
764
|
+
}
|
|
765
|
+
/**
|
|
766
|
+
* Opt-in (`implicitPkOrdering`) primary-key ascending ordering for a paginating
|
|
767
|
+
* findMany that declares no `orderBy`, making its pages deterministic.
|
|
768
|
+
*
|
|
769
|
+
* OFF by default in CORE, deliberately: turning it on would add an `ORDER BY`
|
|
770
|
+
* to SQL that existing applications already emit, changing both the rows a
|
|
771
|
+
* given page returns and the plan the engine picks. That is a breaking change
|
|
772
|
+
* in everything but the type signature, so it waits for a major. The
|
|
773
|
+
* `turbine-orm/prisma-compat` layer defaults it ON instead, because reproducing
|
|
774
|
+
* Prisma's semantics is that layer's whole contract.
|
|
775
|
+
*
|
|
776
|
+
* An explicit `orderBy` always wins, a PK-less table is left alone (nothing
|
|
777
|
+
* stable to order by), and a composite PK orders on every column in
|
|
778
|
+
* declaration order. A `cursor` query orders on the CURSOR's field instead
|
|
779
|
+
* (see {@link cursorOrderBy}), and is left alone when that shape is ambiguous.
|
|
780
|
+
* `distinct` shapes are skipped (see {@link isUnorderedPage}). With the flag
|
|
781
|
+
* off this returns `undefined` before touching anything, so the emitted SQL is
|
|
782
|
+
* byte-identical to before.
|
|
783
|
+
*/
|
|
784
|
+
implicitPkOrderBy(args) {
|
|
785
|
+
if (!this.implicitPkOrdering)
|
|
786
|
+
return undefined;
|
|
787
|
+
if (!this.isUnorderedPage(args))
|
|
788
|
+
return undefined;
|
|
789
|
+
if (this.cursorFields(args?.cursor).length > 0)
|
|
790
|
+
return this.cursorOrderBy(args?.cursor);
|
|
791
|
+
return this.pkOrderBy();
|
|
792
|
+
}
|
|
793
|
+
/**
|
|
794
|
+
* Dev-only, once per query shape: an unordered paginating findMany returns a
|
|
795
|
+
* non-deterministic page (see {@link isUnorderedPage}), and on real data it is
|
|
796
|
+
* also usually the slower plan (an unordered `LIMIT` can discard tens of
|
|
797
|
+
* thousands of heap rows that an index scan on the key would have skipped).
|
|
798
|
+
*
|
|
799
|
+
* Gated exactly like the other dev diagnostics (silent under
|
|
800
|
+
* `NODE_ENV=production`) and consistent with `warnOnUnlimited`: a per-call
|
|
801
|
+
* `warnOnUnlimited: false` silences it, `true` forces it past a config-level
|
|
802
|
+
* opt-out, and a config/per-table `warnOnUnlimited: false` silences it. Deduped
|
|
803
|
+
* process-wide through the shared warn registry, so it can never spam.
|
|
804
|
+
*
|
|
805
|
+
* Suppressed only when `implicitPkOrdering` will ACTUALLY order this query.
|
|
806
|
+
* The flag being on is not enough: a PK-less table and an ambiguous
|
|
807
|
+
* multi-field cursor both get no injected ordering, and those are precisely
|
|
808
|
+
* the shapes that still need saying out loud.
|
|
809
|
+
*/
|
|
810
|
+
maybeWarnUnorderedPage(args) {
|
|
811
|
+
if (process.env.NODE_ENV === 'production')
|
|
812
|
+
return;
|
|
813
|
+
if (this.implicitPkOrdering && this.implicitPkOrderBy(args) !== undefined)
|
|
814
|
+
return;
|
|
815
|
+
const perCall = args?.warnOnUnlimited;
|
|
816
|
+
if (perCall === false)
|
|
817
|
+
return;
|
|
818
|
+
if (perCall === undefined && !this.warnOnUnlimited)
|
|
819
|
+
return;
|
|
820
|
+
if (!this.isUnorderedPage(args))
|
|
821
|
+
return;
|
|
822
|
+
const cursorFields = this.cursorFields(args?.cursor);
|
|
823
|
+
const shape = [
|
|
824
|
+
cursorFields.length > 0 ? 'cursor' : '',
|
|
825
|
+
args?.limit !== undefined ? 'limit' : '',
|
|
826
|
+
args?.take !== undefined ? 'take' : '',
|
|
827
|
+
args?.offset !== undefined ? 'offset' : '',
|
|
828
|
+
]
|
|
829
|
+
.filter(Boolean)
|
|
830
|
+
.join('+');
|
|
831
|
+
if (!shouldWarnOnce(WARN_NS.unorderedPage, `${this.table}|${shape}`))
|
|
832
|
+
return;
|
|
833
|
+
const asOrderBy = (fields) => fields.length === 1 ? `{ ${fields[0]}: 'asc' }` : `[${fields.map((f) => `{ ${f}: 'asc' }`).join(', ')}]`;
|
|
834
|
+
const pk = (this.tableMeta.primaryKey ?? []).map((c) => this.tableMeta.reverseColumnMap[c] ?? c);
|
|
835
|
+
// A cursor seeks on its own field, so that is the ordering to recommend:
|
|
836
|
+
// the primary key would be the wrong advice whenever the two differ.
|
|
837
|
+
const orderFields = cursorFields.length > 0 ? cursorFields : pk;
|
|
838
|
+
const suggestion = orderFields.length > 0 ? `Add \`orderBy: ${asOrderBy(orderFields)}\`` : 'Add an `orderBy` on a unique column';
|
|
839
|
+
const seek = cursorFields.length > 0
|
|
840
|
+
? 'a `cursor` seek with no orderBy compares against rows the engine is free to return in any order, so ' +
|
|
841
|
+
'paging with it skips and repeats rows: '
|
|
842
|
+
: '';
|
|
843
|
+
console.warn(`[turbine] findMany on "${this.table}" paginates (${shape}) with no orderBy: ${seek}the page is NOT ` +
|
|
844
|
+
'deterministic (the same query can return different rows as the table changes, so a row may appear ' +
|
|
845
|
+
`on two pages or on none). ${suggestion}, or set \`implicitPkOrdering: true\` in the client config ` +
|
|
846
|
+
'to order paginated queries automatically (by the cursor field, or by the primary key).');
|
|
847
|
+
}
|
|
848
|
+
// -------------------------------------------------------------------------
|
|
572
849
|
// relationLoadStrategy: 'auto', per-relation batched fallback when the
|
|
573
850
|
// introspected metadata proves a probe is unindexed (finding 13).
|
|
574
851
|
// -------------------------------------------------------------------------
|
|
@@ -656,13 +933,69 @@ export class QueryInterface {
|
|
|
656
933
|
return { unindexed, eligible, miss };
|
|
657
934
|
}
|
|
658
935
|
/**
|
|
659
|
-
*
|
|
660
|
-
*
|
|
661
|
-
*
|
|
662
|
-
*
|
|
663
|
-
*
|
|
936
|
+
* Whether a query's parent set is potentially large at plan time, which is the
|
|
937
|
+
* only cardinality signal available before the base query runs. A `findMany`
|
|
938
|
+
* with no `limit`/`take` (or one above {@link autoToOneJoinMaxRows}) can return
|
|
939
|
+
* an arbitrary number of parent rows; a small `limit` bounds it. `findUnique` /
|
|
940
|
+
* `findFirst` pass `false` explicitly (their parent set is one row).
|
|
941
|
+
*/
|
|
942
|
+
autoParentSetLarge(args) {
|
|
943
|
+
const limit = args?.take ?? args?.limit ?? this.defaultLimit;
|
|
944
|
+
return limit === undefined || limit > this.autoToOneThreshold();
|
|
945
|
+
}
|
|
946
|
+
/**
|
|
947
|
+
* The parent-row count at which `'auto'` stops preferring the single-statement
|
|
948
|
+
* join for a to-one relation.
|
|
949
|
+
*
|
|
950
|
+
* Resolution order:
|
|
951
|
+
* 1. an explicit `autoToOneJoinMaxRows` — an instruction, used verbatim
|
|
952
|
+
* (no clamping: the caller has measured their own workload);
|
|
953
|
+
* 2. the configured `autoRoundTripMs` divided by
|
|
954
|
+
* {@link AUTO_JOIN_PENALTY_MS_PER_ROW}, clamped to
|
|
955
|
+
* [{@link AUTO_TO_ONE_JOIN_ROWS_MIN}, {@link AUTO_TO_ONE_JOIN_ROWS_MAX}];
|
|
956
|
+
* 3. {@link AUTO_TO_ONE_JOIN_MAX_ROWS}, which is that same division applied
|
|
957
|
+
* to {@link AUTO_ASSUMED_ROUND_TRIP_MS}.
|
|
958
|
+
*
|
|
959
|
+
* Deriving it rather than hard-coding a row count is the whole point: the
|
|
960
|
+
* sweep in {@link AUTO_JOIN_PENALTY_MS_PER_ROW} shows the break-even moving
|
|
961
|
+
* 17x between a loopback link and a 2.7ms one while the per-row penalty stays
|
|
962
|
+
* put, so any single constant is wrong for someone by more than the margin it
|
|
963
|
+
* is trying to save. Placing the switch AT the break-even is also what removes
|
|
964
|
+
* the old cliff: two plans that cost the same at the boundary make the regret
|
|
965
|
+
* there ~1.0x, rising only as the true row count moves away from it — where
|
|
966
|
+
* the previous fixed 1000 put its WORST case (1.44x measured) immediately
|
|
967
|
+
* below its own switch point.
|
|
968
|
+
*
|
|
969
|
+
* Cheap enough to recompute per call (a division and two comparisons over
|
|
970
|
+
* readonly fields), so there is no cached copy to invalidate.
|
|
971
|
+
*/
|
|
972
|
+
autoToOneThreshold() {
|
|
973
|
+
if (this.autoToOneJoinMaxRowsOption !== undefined)
|
|
974
|
+
return this.autoToOneJoinMaxRowsOption;
|
|
975
|
+
if (this.autoRoundTripMs === undefined)
|
|
976
|
+
return AUTO_TO_ONE_JOIN_MAX_ROWS;
|
|
977
|
+
const rows = Math.round(this.autoRoundTripMs / AUTO_JOIN_PENALTY_MS_PER_ROW);
|
|
978
|
+
return Math.min(AUTO_TO_ONE_JOIN_ROWS_MAX, Math.max(AUTO_TO_ONE_JOIN_ROWS_MIN, rows));
|
|
979
|
+
}
|
|
980
|
+
/**
|
|
981
|
+
* Partition a top-level `with` clause under `'auto'`. A relation routes to
|
|
982
|
+
* `batchedWith` when it is fully batched-eligible AND either
|
|
983
|
+
*
|
|
984
|
+
* 1. its subtree has a PROVEN unindexed probe (index metadata only), or
|
|
985
|
+
* 2. it is TO-ONE and the parent set is potentially large
|
|
986
|
+
* ({@link AUTO_TO_ONE_JOIN_MAX_ROWS}), since a correlated to-one subquery
|
|
987
|
+
* is re-evaluated per parent row no matter how well indexed it is.
|
|
988
|
+
*
|
|
989
|
+
* Everything else (indexed to-many, composite-key, unknown) stays in `joinWith`
|
|
990
|
+
* (byte-identical join). The reserved `_count` key falls back on rule 1 only,
|
|
991
|
+
* and only for a large parent set: an inline `_count` is one correlated
|
|
992
|
+
* `COUNT(*)` per parent row, so the grouped follow-up wins exactly when there
|
|
993
|
+
* are many parents, while for a handful of parents the extra round-trip costs
|
|
994
|
+
* more than the repeated (small) scans. Also returns the engaged relations for
|
|
995
|
+
* the dev note.
|
|
664
996
|
*/
|
|
665
|
-
partitionWithForAuto(withClause) {
|
|
997
|
+
partitionWithForAuto(withClause, parentSetLarge) {
|
|
998
|
+
const hasIndexInfo = schemaHasIndexInfo(this.schema);
|
|
666
999
|
const joinWith = {};
|
|
667
1000
|
const batchedWith = {};
|
|
668
1001
|
const engaged = [];
|
|
@@ -671,9 +1004,9 @@ export class QueryInterface {
|
|
|
671
1004
|
continue;
|
|
672
1005
|
if (key === '_count') {
|
|
673
1006
|
const cv = this.autoCountVerdict(spec, this.tableMeta);
|
|
674
|
-
if (cv.unindexed && cv.eligible) {
|
|
1007
|
+
if (hasIndexInfo && parentSetLarge && cv.unindexed && cv.eligible) {
|
|
675
1008
|
batchedWith[key] = spec;
|
|
676
|
-
engaged.push({ relation: '_count', miss: cv.miss });
|
|
1009
|
+
engaged.push({ relation: '_count', reason: 'unindexed', miss: cv.miss });
|
|
677
1010
|
}
|
|
678
1011
|
else {
|
|
679
1012
|
joinWith[key] = spec;
|
|
@@ -686,9 +1019,16 @@ export class QueryInterface {
|
|
|
686
1019
|
continue;
|
|
687
1020
|
}
|
|
688
1021
|
const v = this.autoSubtreeVerdict(rel, spec, 0);
|
|
689
|
-
|
|
1022
|
+
const unindexedFallback = hasIndexInfo && v.unindexed;
|
|
1023
|
+
const toOne = rel.type === 'belongsTo' || rel.type === 'hasOne';
|
|
1024
|
+
const cardinalityFallback = toOne && parentSetLarge;
|
|
1025
|
+
if (v.eligible && (unindexedFallback || cardinalityFallback)) {
|
|
690
1026
|
batchedWith[key] = spec;
|
|
691
|
-
engaged.push({
|
|
1027
|
+
engaged.push({
|
|
1028
|
+
relation: key,
|
|
1029
|
+
reason: unindexedFallback ? 'unindexed' : 'to-one-cardinality',
|
|
1030
|
+
miss: unindexedFallback ? v.miss : undefined,
|
|
1031
|
+
});
|
|
692
1032
|
}
|
|
693
1033
|
else {
|
|
694
1034
|
joinWith[key] = spec;
|
|
@@ -700,17 +1040,18 @@ export class QueryInterface {
|
|
|
700
1040
|
* Plan the `'auto'` split for a query's `with` clause: normalize stable order,
|
|
701
1041
|
* partition, and return the split ONLY when at least one relation falls back
|
|
702
1042
|
* to batched. Returns `null` (→ run the plain join path, byte-identical, same
|
|
703
|
-
* cache keys) when
|
|
1043
|
+
* cache keys) when nothing qualifies.
|
|
704
1044
|
*/
|
|
705
|
-
planAuto(withArg, stableFlag) {
|
|
706
|
-
//
|
|
707
|
-
//
|
|
708
|
-
|
|
1045
|
+
planAuto(withArg, stableFlag, parentSetLarge) {
|
|
1046
|
+
// Without DB-backed index info (code-first / defineSchema-only) no probe can
|
|
1047
|
+
// be PROVEN unindexed; the to-one cardinality rule does not depend on index
|
|
1048
|
+
// metadata, so it still applies.
|
|
1049
|
+
if (!schemaHasIndexInfo(this.schema) && !parentSetLarge)
|
|
709
1050
|
return null;
|
|
710
1051
|
const withClause = this.resolveStableOrder(stableFlag)
|
|
711
1052
|
? this.applyStableRelationOrder(withArg, this.table)
|
|
712
1053
|
: withArg;
|
|
713
|
-
const split = this.partitionWithForAuto(withClause);
|
|
1054
|
+
const split = this.partitionWithForAuto(withClause, parentSetLarge);
|
|
714
1055
|
if (Object.keys(split.batchedWith).length === 0)
|
|
715
1056
|
return null;
|
|
716
1057
|
return split;
|
|
@@ -722,6 +1063,13 @@ export class QueryInterface {
|
|
|
722
1063
|
for (const e of engaged) {
|
|
723
1064
|
if (!shouldWarnOnce(WARN_NS.autoStrategy, `${this.table}.${e.relation}`))
|
|
724
1065
|
continue;
|
|
1066
|
+
if (e.reason === 'to-one-cardinality') {
|
|
1067
|
+
console.warn(`[turbine] auto strategy: to-one relation "${e.relation}" on "${this.table}" loads batched ` +
|
|
1068
|
+
`(the query is unbounded or its limit exceeds ${this.autoToOneThreshold()} rows, and a correlated ` +
|
|
1069
|
+
'to-one subquery is re-evaluated per parent row). Bound the query with a smaller `limit`, tune ' +
|
|
1070
|
+
"`autoToOneJoinMaxRows`, or set `relationLoadStrategy: 'join'` to force the single-statement plan.");
|
|
1071
|
+
continue;
|
|
1072
|
+
}
|
|
725
1073
|
const probe = e.miss
|
|
726
1074
|
? `probe "${e.miss.table}"(${e.miss.columns.join(', ')}) has no covering index`
|
|
727
1075
|
: 'a probe in its subtree has no covering index';
|
|
@@ -1140,7 +1488,9 @@ export class QueryInterface {
|
|
|
1140
1488
|
if (strategy === 'batched')
|
|
1141
1489
|
return this.runFindUniqueBatched(args);
|
|
1142
1490
|
if (strategy === 'auto') {
|
|
1143
|
-
|
|
1491
|
+
// findUnique's parent set is a single row: the join plan's correlated
|
|
1492
|
+
// subqueries run once, so the cardinality rule never applies here.
|
|
1493
|
+
const split = this.planAuto(args.with, args.stableRelationOrder, false);
|
|
1144
1494
|
if (split)
|
|
1145
1495
|
return this.runAutoSplit(args, split, true);
|
|
1146
1496
|
}
|
|
@@ -1189,6 +1539,12 @@ export class QueryInterface {
|
|
|
1189
1539
|
args = { ...args, with: normalized };
|
|
1190
1540
|
}
|
|
1191
1541
|
const includePii = args.includePii === true;
|
|
1542
|
+
// findUnique is never flatten-planned: it reads ONE parent row, so the
|
|
1543
|
+
// correlated subquery already runs exactly once and a join buys nothing.
|
|
1544
|
+
// Say so, because the caller did ask for a strategy that is not running.
|
|
1545
|
+
if (args.with && this.resolveLoadStrategy(args.relationLoadStrategy) === 'flatten') {
|
|
1546
|
+
this.warnFlattenBlocked('findUnique reads a single parent row, where the correlated subquery already runs once');
|
|
1547
|
+
}
|
|
1192
1548
|
const columnsList = this.resolveColumns(args.select, args.omit, includePii);
|
|
1193
1549
|
// A global filter turns the where into `{ AND: [...] }`, which the
|
|
1194
1550
|
// `isSimpleWhere` test below rejects → the general (buildWhereClause) path
|
|
@@ -1301,6 +1657,7 @@ export class QueryInterface {
|
|
|
1301
1657
|
// -------------------------------------------------------------------------
|
|
1302
1658
|
async findMany(args) {
|
|
1303
1659
|
this.maybeWarnUnlimited(args);
|
|
1660
|
+
this.maybeWarnUnorderedPage(args);
|
|
1304
1661
|
// Dev-only: warn on deeply nested with clauses
|
|
1305
1662
|
if (process.env.NODE_ENV !== 'production') {
|
|
1306
1663
|
if (args?.with) {
|
|
@@ -1317,7 +1674,7 @@ export class QueryInterface {
|
|
|
1317
1674
|
if (strategy === 'batched')
|
|
1318
1675
|
return this.runFindManyBatched(args);
|
|
1319
1676
|
if (strategy === 'auto') {
|
|
1320
|
-
const split = this.planAuto(args.with, args.stableRelationOrder);
|
|
1677
|
+
const split = this.planAuto(args.with, args.stableRelationOrder, this.autoParentSetLarge(args));
|
|
1321
1678
|
if (split)
|
|
1322
1679
|
return this.runAutoSplit(args, split, false);
|
|
1323
1680
|
}
|
|
@@ -1437,6 +1794,23 @@ export class QueryInterface {
|
|
|
1437
1794
|
if (normalized !== args.with)
|
|
1438
1795
|
args = { ...args, with: normalized };
|
|
1439
1796
|
}
|
|
1797
|
+
// An empty orderBy carries no ordering, so treat it as ABSENT rather than
|
|
1798
|
+
// emitting a bare `ORDER BY` with nothing after it (a syntax error at the
|
|
1799
|
+
// following LIMIT). This matters most for the documented escape hatch from
|
|
1800
|
+
// implicit ordering, "pass an explicit orderBy": callers who assemble that
|
|
1801
|
+
// array conditionally end up passing `[]`. Normalized here, before the
|
|
1802
|
+
// implicit-ordering and fingerprinting steps, so every downstream path sees
|
|
1803
|
+
// one shape.
|
|
1804
|
+
if (args?.orderBy !== undefined && isEmptyOrderBy(args.orderBy)) {
|
|
1805
|
+
args = { ...args, orderBy: undefined };
|
|
1806
|
+
}
|
|
1807
|
+
// Deterministic pagination (opt-in): fill a PK-asc orderBy into a paginating
|
|
1808
|
+
// query that declares none, BEFORE fingerprinting so the ordered and
|
|
1809
|
+
// unordered shapes get distinct cache entries. No-op unless
|
|
1810
|
+
// `implicitPkOrdering` is enabled (see implicitPkOrderBy).
|
|
1811
|
+
const implicitOrder = this.implicitPkOrderBy(args);
|
|
1812
|
+
if (implicitOrder)
|
|
1813
|
+
args = { ...args, orderBy: implicitOrder };
|
|
1440
1814
|
// `distinct` + relation orderBy is refused up front (E003): the distinct
|
|
1441
1815
|
// path re-orders in an outer wrapper (`... AS "<table>_distinct" ORDER BY
|
|
1442
1816
|
// <userOrder>`) where a correlated relation subquery (pick-row, `_count`,
|
|
@@ -1499,7 +1873,16 @@ export class QueryInterface {
|
|
|
1499
1873
|
// `withFp` does NOT capture; it is projection-invariant). So it MUST be its
|
|
1500
1874
|
// own cache-key segment: a cached no-PII statement must never serve an
|
|
1501
1875
|
// `includePii` call, nor vice versa.
|
|
1502
|
-
|
|
1876
|
+
// `relationLoadStrategy: 'flatten'` compiles eligible to-one relations to
|
|
1877
|
+
// LEFT JOINs instead of correlated subqueries — a completely different
|
|
1878
|
+
// statement for the same `with` shape, which `withFp` (strategy-blind)
|
|
1879
|
+
// does not distinguish. So the plan gets its own cache-key segment, exactly
|
|
1880
|
+
// like `pii=`: a join-planned template must never serve a flatten-planned
|
|
1881
|
+
// call. Absent (the default), the segment is empty and every existing cache
|
|
1882
|
+
// key is byte-identical to before.
|
|
1883
|
+
const flattenPlan = this.planFlatten(args, includePii);
|
|
1884
|
+
const flattenFp = flattenPlan ? `|fl=${flattenPlan.signature}` : '';
|
|
1885
|
+
const ck = `fm:${whereFp}|c=${colKey}|o=${orderFp}|l=${limitFp}|off=${offsetFp}|cur=${cursorFp}|d=${distinctFp}|w=${withFp}|pii=${includePii ? 1 : 0}${flattenFp}${this.globalFilterCacheSegment()}`;
|
|
1503
1886
|
const params = [];
|
|
1504
1887
|
const buildSql = (freshParams) => {
|
|
1505
1888
|
// Fresh build: generates SQL and populates freshParams
|
|
@@ -1516,9 +1899,15 @@ export class QueryInterface {
|
|
|
1516
1899
|
distinctCols = args.distinct.map((k) => this.toSqlColumn(k));
|
|
1517
1900
|
distinctPrefix = `DISTINCT ON (${distinctCols.join(', ')}) `;
|
|
1518
1901
|
}
|
|
1902
|
+
// Join-sink for `relationLoadStrategy: 'flatten'`. Filled while the SELECT
|
|
1903
|
+
// list is built (so the flattened relations' ON-clause params interleave
|
|
1904
|
+
// with the `with` params in one traversal, which the collect path
|
|
1905
|
+
// mirrors) and spliced into the FROM clause at assembly. Stays empty for
|
|
1906
|
+
// every other plan → byte-identical SQL.
|
|
1907
|
+
const relationJoins = [];
|
|
1519
1908
|
let selectClause;
|
|
1520
1909
|
if (args?.with) {
|
|
1521
|
-
selectClause = this.buildSelectWithRelations(this.table, args.with, freshParams, columnsList, undefined, undefined, includePii);
|
|
1910
|
+
selectClause = this.buildSelectWithRelations(this.table, args.with, freshParams, columnsList, undefined, undefined, includePii, flattenPlan ? { plan: flattenPlan, joinSink: relationJoins } : undefined);
|
|
1522
1911
|
}
|
|
1523
1912
|
else if (columnsList) {
|
|
1524
1913
|
selectClause = columnsList.map((c) => `${qt}.${this.q(c)}`).join(', ');
|
|
@@ -1584,7 +1973,7 @@ export class QueryInterface {
|
|
|
1584
1973
|
const orderBySql = args?.orderBy
|
|
1585
1974
|
? ` ORDER BY ${this.buildOrderBy(args.orderBy, freshParams, lateralJoins)}`
|
|
1586
1975
|
: '';
|
|
1587
|
-
sql = `SELECT ${distinctPrefix}${selectClause} FROM ${qt}${lateralJoins.join('')}${tail}${orderBySql}`;
|
|
1976
|
+
sql = `SELECT ${distinctPrefix}${selectClause} FROM ${qt}${relationJoins.join('')}${lateralJoins.join('')}${tail}${orderBySql}`;
|
|
1588
1977
|
}
|
|
1589
1978
|
// Pagination — push params in the same order the collect path mirrors
|
|
1590
1979
|
// (limit before offset); the SQL TEXT shape is dialect-owned via
|
|
@@ -1592,10 +1981,10 @@ export class QueryInterface {
|
|
|
1592
1981
|
let limitPh;
|
|
1593
1982
|
let offsetPh;
|
|
1594
1983
|
if (effectiveLimit !== undefined) {
|
|
1595
|
-
limitPh = this.paginationRef(effectiveLimit, freshParams);
|
|
1984
|
+
limitPh = this.paginationRef(effectiveLimit, freshParams, 'limit');
|
|
1596
1985
|
}
|
|
1597
1986
|
if (args?.offset !== undefined) {
|
|
1598
|
-
offsetPh = this.paginationRef(args.offset, freshParams);
|
|
1987
|
+
offsetPh = this.paginationRef(args.offset, freshParams, 'skip/offset');
|
|
1599
1988
|
}
|
|
1600
1989
|
sql += this.buildPagination(limitPh, offsetPh, !!args?.orderBy);
|
|
1601
1990
|
return sql;
|
|
@@ -1608,7 +1997,7 @@ export class QueryInterface {
|
|
|
1608
1997
|
}
|
|
1609
1998
|
// 2. WITH relation params
|
|
1610
1999
|
if (args?.with) {
|
|
1611
|
-
this.collectWithParams(args.with, params);
|
|
2000
|
+
this.collectWithParams(args.with, params, undefined, flattenPlan);
|
|
1612
2001
|
}
|
|
1613
2002
|
// 3. Cursor params — sorted (canonical) order, matching cursorFp and the build path.
|
|
1614
2003
|
if (args?.cursor) {
|
|
@@ -1625,15 +2014,18 @@ export class QueryInterface {
|
|
|
1625
2014
|
// 5. LIMIT param — skipped when the dialect inlines pagination (build path
|
|
1626
2015
|
// mirrors via paginationRef → no placeholder, no param).
|
|
1627
2016
|
if (effectiveLimit !== undefined && !this.dialect.inlineLimitOffset) {
|
|
1628
|
-
|
|
2017
|
+
// Validate here too: on a cache HIT the build path never runs, and a
|
|
2018
|
+
// warmed template would otherwise bind an unvalidated NaN (= SQL NULL,
|
|
2019
|
+
// i.e. no limit at all).
|
|
2020
|
+
params.push(this.paginationValue(effectiveLimit, 'limit'));
|
|
1629
2021
|
}
|
|
1630
2022
|
// 6. OFFSET param — same inline gate as LIMIT above.
|
|
1631
2023
|
if (args?.offset !== undefined && !this.dialect.inlineLimitOffset) {
|
|
1632
|
-
params.push(
|
|
2024
|
+
params.push(this.paginationValue(args.offset, 'skip/offset'));
|
|
1633
2025
|
}
|
|
1634
2026
|
this.crossCheckCache('findMany', ck, entry, buildSql, params);
|
|
1635
2027
|
// Build the row parser once (positional shapes are computed here, not per row).
|
|
1636
|
-
const parseWith = args?.with ? this.makeNestedParser(args.with, includePii) : null;
|
|
2028
|
+
const parseWith = args?.with ? this.makeNestedParser(args.with, includePii, flattenPlan) : null;
|
|
1637
2029
|
return {
|
|
1638
2030
|
sql: entry.sql,
|
|
1639
2031
|
params,
|
|
@@ -1656,14 +2048,19 @@ export class QueryInterface {
|
|
|
1656
2048
|
* method fall back to the full cursor path.
|
|
1657
2049
|
*
|
|
1658
2050
|
* **Cursor path:** Uses DECLARE CURSOR within a dedicated transaction on a
|
|
1659
|
-
* single pooled connection. The cursor is
|
|
1660
|
-
* connection released when iteration completes
|
|
1661
|
-
* (
|
|
2051
|
+
* single pooled connection. The cursor is CLOSEd (in the dialect's `finally`)
|
|
2052
|
+
* and the connection released both when iteration completes normally and when
|
|
2053
|
+
* it ends early (`break` from `for await`). An error mid-stream skips the
|
|
2054
|
+
* CLOSE and rolls back instead, which drops the cursor with the transaction.
|
|
1662
2055
|
*
|
|
1663
|
-
* **Snapshot semantics note:**
|
|
1664
|
-
*
|
|
1665
|
-
*
|
|
1666
|
-
*
|
|
2056
|
+
* **Snapshot semantics note:** Outside a transaction the speculative
|
|
2057
|
+
* fast-path runs unwrapped, and an overflow opens the cursor in its own
|
|
2058
|
+
* transaction, so the two fetches span two separate snapshots. Wrapping the
|
|
2059
|
+
* call in `$transaction` gives strict single-snapshot semantics: both the
|
|
2060
|
+
* speculative fetch and the cursor then run on the caller's connection
|
|
2061
|
+
* inside the caller's transaction (the cursor path issues no BEGIN/COMMIT of
|
|
2062
|
+
* its own and releases nothing, so the caller's transaction is intact when
|
|
2063
|
+
* iteration finishes).
|
|
1667
2064
|
*
|
|
1668
2065
|
* @example
|
|
1669
2066
|
* ```ts
|
|
@@ -1676,7 +2073,14 @@ export class QueryInterface {
|
|
|
1676
2073
|
const batchSize = Math.max(1, Math.floor(Number(args?.batchSize ?? 1000)));
|
|
1677
2074
|
const hasRelations = !!args?.with;
|
|
1678
2075
|
// Build the positional-aware relation parser once for the whole stream.
|
|
1679
|
-
|
|
2076
|
+
// Same flatten plan buildFindMany compiles below. The plan is a pure
|
|
2077
|
+
// function of the schema, the `with` shape and `includePii` — never of
|
|
2078
|
+
// `limit` — so the batch-size override the speculative fetch applies cannot
|
|
2079
|
+
// change it, and the stream's parser matches the emitted SQL.
|
|
2080
|
+
const streamFlattenPlan = hasRelations ? this.planFlatten(args, args?.includePii === true) : null;
|
|
2081
|
+
const parseWith = hasRelations
|
|
2082
|
+
? this.makeNestedParser(args.with, args?.includePii === true, streamFlattenPlan)
|
|
2083
|
+
: null;
|
|
1680
2084
|
// --- Speculative first fetch: try to satisfy the entire drain in one RTT ---
|
|
1681
2085
|
const speculativeDeferred = this.buildFindMany({
|
|
1682
2086
|
...args,
|
|
@@ -1693,13 +2097,24 @@ export class QueryInterface {
|
|
|
1693
2097
|
}
|
|
1694
2098
|
// --- Overflow: fall back to cursor path from scratch ---
|
|
1695
2099
|
const deferred = this.buildFindMany(args);
|
|
1696
|
-
// Acquire a dedicated connection
|
|
2100
|
+
// Acquire a dedicated connection: cursors require a single connection in a
|
|
1697
2101
|
// transaction. The dialect owns the streaming SQL (Postgres: BEGIN → DECLARE
|
|
1698
2102
|
// … NO SCROLL CURSOR FOR → FETCH n → CLOSE → COMMIT, ROLLBACK on error); we
|
|
1699
2103
|
// just parse + yield the row batches it produces.
|
|
1700
|
-
|
|
2104
|
+
//
|
|
2105
|
+
// Inside a caller-owned transaction there is nothing to check out: the
|
|
2106
|
+
// transaction-scoped pool pins every query to the transaction's own
|
|
2107
|
+
// connection, so `pool.query` already IS that connection. The stream rides
|
|
2108
|
+
// on it, is never released here, and the dialect is told to emit no
|
|
2109
|
+
// transaction control of its own (`ambientTransaction`).
|
|
2110
|
+
const client = this.txScoped ? null : await this.pool.connect();
|
|
2111
|
+
const conn = client ?? {
|
|
2112
|
+
query: async (text, values) => (await this.pool.query(text, values)),
|
|
2113
|
+
};
|
|
1701
2114
|
try {
|
|
1702
|
-
for await (const batch of this.dialect.openStream(
|
|
2115
|
+
for await (const batch of this.dialect.openStream(conn, deferred.sql, deferred.params, batchSize, {
|
|
2116
|
+
ambientTransaction: this.txScoped,
|
|
2117
|
+
})) {
|
|
1703
2118
|
for (const row of batch) {
|
|
1704
2119
|
yield (parseWith ? parseWith(row) : this.parseRow(row, this.table));
|
|
1705
2120
|
}
|
|
@@ -1710,7 +2125,7 @@ export class QueryInterface {
|
|
|
1710
2125
|
throw wrapPgError(err);
|
|
1711
2126
|
}
|
|
1712
2127
|
finally {
|
|
1713
|
-
client
|
|
2128
|
+
client?.release();
|
|
1714
2129
|
}
|
|
1715
2130
|
}
|
|
1716
2131
|
// -------------------------------------------------------------------------
|
|
@@ -1726,7 +2141,8 @@ export class QueryInterface {
|
|
|
1726
2141
|
return (rows[0] ?? null);
|
|
1727
2142
|
}
|
|
1728
2143
|
if (strategy === 'auto') {
|
|
1729
|
-
|
|
2144
|
+
// findFirst is findMany + LIMIT 1: a one-row parent set.
|
|
2145
|
+
const split = this.planAuto(args.with, args.stableRelationOrder, false);
|
|
1730
2146
|
if (split) {
|
|
1731
2147
|
const rows = (await this.runAutoSplit({ ...args, limit: 1 }, split, false));
|
|
1732
2148
|
return (rows[0] ?? null);
|
|
@@ -1875,11 +2291,22 @@ export class QueryInterface {
|
|
|
1875
2291
|
}
|
|
1876
2292
|
async runInImplicitTx(fn) {
|
|
1877
2293
|
const client = await this.pool.connect();
|
|
2294
|
+
let began = false;
|
|
1878
2295
|
try {
|
|
1879
2296
|
await client.query(this.dialect.beginStatement());
|
|
2297
|
+
began = true;
|
|
1880
2298
|
const { TransactionClient } = await import('../client.js');
|
|
2299
|
+
const tx = new TransactionClient(
|
|
1881
2300
|
// biome-ignore lint/suspicious/noExplicitAny: MiddlewareFn and Middleware are structurally identical
|
|
1882
|
-
|
|
2301
|
+
client, this.schema,
|
|
2302
|
+
// biome-ignore lint/suspicious/noExplicitAny: MiddlewareFn and Middleware are structurally identical
|
|
2303
|
+
this.middlewares, this.options,
|
|
2304
|
+
// Pass the source pool so its read-only guard + capabilities carry into
|
|
2305
|
+
// the transaction-scoped proxy pool (see createTxPool). Without it a
|
|
2306
|
+
// read-only client's nested writes bypass the E018 guard and an
|
|
2307
|
+
// older-engine client falls back to the full capability set inside the
|
|
2308
|
+
// implicit transaction.
|
|
2309
|
+
this.pool);
|
|
1883
2310
|
// biome-ignore lint/suspicious/noExplicitAny: TransactionClient satisfies NestedWriteContext['tx'] at runtime
|
|
1884
2311
|
const ctx = { schema: this.schema, tx: tx };
|
|
1885
2312
|
const result = await fn(ctx);
|
|
@@ -1887,11 +2314,15 @@ export class QueryInterface {
|
|
|
1887
2314
|
return result;
|
|
1888
2315
|
}
|
|
1889
2316
|
catch (err) {
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
2317
|
+
// Only roll back a transaction we actually opened: a failed BEGIN must
|
|
2318
|
+
// not emit a stray ROLLBACK on a connection that never began one.
|
|
2319
|
+
if (began) {
|
|
2320
|
+
try {
|
|
2321
|
+
await client.query(this.dialect.rollbackStatement());
|
|
2322
|
+
}
|
|
2323
|
+
catch {
|
|
2324
|
+
// Best-effort rollback: connection may have died.
|
|
2325
|
+
}
|
|
1895
2326
|
}
|
|
1896
2327
|
throw err;
|
|
1897
2328
|
}
|
|
@@ -2038,8 +2469,8 @@ export class QueryInterface {
|
|
|
2038
2469
|
withFingerprint(withClause, table, depth = 0) {
|
|
2039
2470
|
return relationsMod.withFingerprint(this.ctx, withClause, table, depth);
|
|
2040
2471
|
}
|
|
2041
|
-
collectWithParams(withClause, params, table) {
|
|
2042
|
-
relationsMod.collectWithParams(this.ctx, withClause, params, table);
|
|
2472
|
+
collectWithParams(withClause, params, table, flattenPlan) {
|
|
2473
|
+
relationsMod.collectWithParams(this.ctx, withClause, params, table, flattenPlan);
|
|
2043
2474
|
}
|
|
2044
2475
|
orderByEntryFingerprint(d, targetTable) {
|
|
2045
2476
|
return relationsMod.orderByEntryFingerprint(this.ctx, d, targetTable);
|
|
@@ -2071,11 +2502,75 @@ export class QueryInterface {
|
|
|
2071
2502
|
getCamelDateFields(table, meta) {
|
|
2072
2503
|
return relationsMod.getCamelDateFields(this.ctx, table, meta);
|
|
2073
2504
|
}
|
|
2074
|
-
makeNestedParser(withClause, includePii) {
|
|
2075
|
-
return relationsMod.makeNestedParser(this.ctx, withClause, includePii);
|
|
2505
|
+
makeNestedParser(withClause, includePii, flattenPlan) {
|
|
2506
|
+
return relationsMod.makeNestedParser(this.ctx, withClause, includePii, flattenPlan);
|
|
2507
|
+
}
|
|
2508
|
+
buildSelectWithRelations(table, withClause, params, columnsList, depth, path, includePii, flatten) {
|
|
2509
|
+
return relationsMod.buildSelectWithRelations(this.ctx, table, withClause, params, columnsList, depth, path, includePii, flatten);
|
|
2076
2510
|
}
|
|
2077
|
-
|
|
2078
|
-
|
|
2511
|
+
/**
|
|
2512
|
+
* Compile the `relationLoadStrategy: 'flatten'` plan for a findMany-shaped
|
|
2513
|
+
* query, or `null` to emit exactly the SQL (and cache key) the default
|
|
2514
|
+
* strategy emits.
|
|
2515
|
+
*
|
|
2516
|
+
* `'flatten'` compiles an eligible to-one relation to a `LEFT JOIN` with a
|
|
2517
|
+
* prefixed scalar projection instead of a correlated `json_build_object`
|
|
2518
|
+
* subquery. The correlated form is re-evaluated once per parent row, so its
|
|
2519
|
+
* cost scales with the parent set no matter how well the FK is indexed; the
|
|
2520
|
+
* join does not, and unlike `'batched'` it stays a single round trip.
|
|
2521
|
+
*
|
|
2522
|
+
* It is an EXPLICIT opt-in: `'auto'` is unchanged and never selects it.
|
|
2523
|
+
*
|
|
2524
|
+
* Query-shape gates (any of these routes the WHOLE query back to the default
|
|
2525
|
+
* strategy, silently and byte-identically):
|
|
2526
|
+
* - the resolved strategy is not `'flatten'`;
|
|
2527
|
+
* - `jsonEncoding: 'positional'` (a flattened relation emits no JSON at all,
|
|
2528
|
+
* so the two encodings are not composed in this version);
|
|
2529
|
+
* - the dialect owns relation-subquery generation
|
|
2530
|
+
* (`dialect.buildRelationSubquery`, i.e. SQL Server's `FOR JSON PATH`);
|
|
2531
|
+
* - `distinct` (the `DISTINCT ON` rewrite re-orders in an outer wrapper, and
|
|
2532
|
+
* the extra projected columns have not been proven safe there).
|
|
2533
|
+
*
|
|
2534
|
+
* `limit` / `offset` / `cursor` / `orderBy` need no gate: every flattened join
|
|
2535
|
+
* is over a PROVABLY UNIQUE target key, so it matches at most one row per
|
|
2536
|
+
* parent and cannot change the parent row count that pagination applies to.
|
|
2537
|
+
*
|
|
2538
|
+
* Per-relation eligibility lives in `planFlattenWith` / `planFlattenNode`.
|
|
2539
|
+
* Only the findMany family is planned (`findMany`, `findFirst`,
|
|
2540
|
+
* `findManyStream`, and pipelined `buildFindMany`); `findUnique` reads a single
|
|
2541
|
+
* parent row, where the correlated subquery runs exactly once, so it stays on
|
|
2542
|
+
* the default path.
|
|
2543
|
+
*/
|
|
2544
|
+
planFlatten(args, includePii) {
|
|
2545
|
+
const withClause = args?.with;
|
|
2546
|
+
if (!withClause)
|
|
2547
|
+
return null;
|
|
2548
|
+
if (this.resolveLoadStrategy(args?.relationLoadStrategy) !== 'flatten')
|
|
2549
|
+
return null;
|
|
2550
|
+
// Query-level refusals: the whole plan is off, so name the reason once for
|
|
2551
|
+
// the query rather than once per relation (relations.ts warns per relation
|
|
2552
|
+
// for the eligibility rules it owns).
|
|
2553
|
+
const queryLevelBlock = this.jsonEncoding === 'positional'
|
|
2554
|
+
? "`jsonEncoding: 'positional'` is active, and a flattened relation emits no JSON to encode"
|
|
2555
|
+
: this.dialect.buildRelationSubquery
|
|
2556
|
+
? `the ${this.dialect.name} dialect generates relation subqueries itself`
|
|
2557
|
+
: args?.distinct && args.distinct.length > 0
|
|
2558
|
+
? 'the query uses `distinct`'
|
|
2559
|
+
: undefined;
|
|
2560
|
+
if (queryLevelBlock) {
|
|
2561
|
+
this.warnFlattenBlocked(queryLevelBlock);
|
|
2562
|
+
return null;
|
|
2563
|
+
}
|
|
2564
|
+
return relationsMod.planFlattenWith(this.ctx, this.table, withClause, includePii);
|
|
2565
|
+
}
|
|
2566
|
+
/** Dev-only once-only note that `'flatten'` was refused for the whole query. */
|
|
2567
|
+
warnFlattenBlocked(reason) {
|
|
2568
|
+
if (process.env.NODE_ENV === 'production')
|
|
2569
|
+
return;
|
|
2570
|
+
if (!shouldWarnOnce(WARN_NS.flattenFallback, `${this.table}|query|${reason}`))
|
|
2571
|
+
return;
|
|
2572
|
+
console.warn(`[turbine] relationLoadStrategy: 'flatten' did not engage on "${this.table}": ${reason}. ` +
|
|
2573
|
+
'Every relation loads via the correlated subquery instead (same rows, same values, different plan).');
|
|
2079
2574
|
}
|
|
2080
2575
|
/** Convert camelCase field name to snake_case column name (unquoted, for non-SQL uses) */
|
|
2081
2576
|
toColumn(field) {
|
|
@@ -2266,7 +2761,18 @@ export class QueryInterface {
|
|
|
2266
2761
|
const value = row[col];
|
|
2267
2762
|
const field = reverseMap[col] ?? col; // fall back to raw col name, not regex
|
|
2268
2763
|
// Top-level rows are snake_case (dateCols); nested rows are camelCase (camelDateFields).
|
|
2269
|
-
|
|
2764
|
+
//
|
|
2765
|
+
// An ARRAY value is excluded: `dateColumns` includes array-of-date
|
|
2766
|
+
// columns (`date[]`, `timestamp[]`, `timestamptz[]`), for which the
|
|
2767
|
+
// driver already hands back a `Date[]`. Coercing it ran
|
|
2768
|
+
// `new Date(String(theArray))` and replaced the whole array with a
|
|
2769
|
+
// single Invalid Date — the column was unreadable on every strategy.
|
|
2770
|
+
// The join strategy's string arrays are handled upstream instead, by
|
|
2771
|
+
// the JSON-wire decode in relations.ts.
|
|
2772
|
+
if ((dateCols.has(col) || camelDateFields.has(field)) &&
|
|
2773
|
+
value !== null &&
|
|
2774
|
+
!(value instanceof Date) &&
|
|
2775
|
+
!Array.isArray(value)) {
|
|
2270
2776
|
// Offset-less strings (Postgres `timestamp`, json_agg output) are
|
|
2271
2777
|
// pinned to UTC so results don't depend on the server's time zone.
|
|
2272
2778
|
parsed[field] = this.utcTimestamps ? parseDbDate(String(value)) : new Date(value);
|