turbine-orm 0.65.0 → 0.66.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 +34 -32
- package/dist/adapters/cockroachdb.js +21 -3
- package/dist/adapters/index.d.ts +15 -0
- package/dist/adapters/yugabytedb.js +20 -3
- package/dist/cjs/adapters/cockroachdb.js +21 -3
- package/dist/cjs/adapters/index.d.ts +15 -0
- package/dist/cjs/adapters/yugabytedb.js +20 -3
- package/dist/cjs/cli/destructive.d.ts +18 -4
- package/dist/cjs/cli/destructive.js +230 -122
- package/dist/cjs/cli/index.d.ts +21 -4
- package/dist/cjs/cli/index.js +119 -22
- package/dist/cjs/cli/mcp.d.ts +28 -8
- package/dist/cjs/cli/mcp.js +170 -127
- package/dist/cjs/cli/migrate.d.ts +134 -13
- package/dist/cjs/cli/migrate.js +349 -241
- package/dist/cjs/cli/pii-predicate-guard.d.ts +112 -0
- package/dist/cjs/cli/pii-predicate-guard.js +390 -0
- package/dist/cjs/cli/prisma-resolve.js +75 -4
- package/dist/cjs/cli/prisma-schema.d.ts +17 -1
- package/dist/cjs/cli/prisma-schema.js +83 -17
- package/dist/cjs/cli/sql-statements.d.ts +125 -0
- package/dist/cjs/cli/sql-statements.js +378 -0
- package/dist/cjs/cli/studio.js +49 -118
- package/dist/cjs/cli/ui.d.ts +1 -1
- package/dist/cjs/client.d.ts +43 -0
- package/dist/cjs/client.js +125 -6
- package/dist/cjs/dialect.d.ts +123 -0
- package/dist/cjs/dialect.js +33 -0
- package/dist/cjs/errors.d.ts +74 -1
- package/dist/cjs/errors.js +239 -25
- package/dist/cjs/index-advisor.d.ts +33 -1
- package/dist/cjs/index-advisor.js +32 -1
- package/dist/cjs/introspect.d.ts +48 -0
- package/dist/cjs/introspect.js +222 -91
- package/dist/cjs/mssql.js +43 -1
- package/dist/cjs/mysql.d.ts +5 -2
- package/dist/cjs/mysql.js +202 -17
- package/dist/cjs/nested-write.js +6 -1
- package/dist/cjs/pipeline-submittable.js +17 -3
- package/dist/cjs/pipeline.js +75 -9
- package/dist/cjs/powdb.d.ts +23 -0
- package/dist/cjs/powdb.js +33 -1
- package/dist/cjs/powql.d.ts +61 -9
- package/dist/cjs/powql.js +186 -49
- package/dist/cjs/prisma-compat.js +160 -41
- package/dist/cjs/query/aggregates.d.ts +1 -1
- package/dist/cjs/query/aggregates.js +80 -18
- package/dist/cjs/query/batched-loader.d.ts +10 -0
- package/dist/cjs/query/batched-loader.js +268 -7
- package/dist/cjs/query/builder.d.ts +73 -0
- package/dist/cjs/query/builder.js +225 -28
- package/dist/cjs/query/filters.d.ts +162 -0
- package/dist/cjs/query/filters.js +250 -1
- package/dist/cjs/query/relations.d.ts +10 -10
- package/dist/cjs/query/relations.js +93 -12
- package/dist/cjs/query/types.d.ts +14 -1
- package/dist/cjs/query/utils.d.ts +146 -2
- package/dist/cjs/query/utils.js +210 -4
- package/dist/cjs/query/warn-registry.d.ts +10 -0
- package/dist/cjs/query/warn-registry.js +10 -0
- package/dist/cjs/query/where-compile.d.ts +30 -0
- package/dist/cjs/query/where-compile.js +41 -0
- package/dist/cjs/query/where.d.ts +128 -13
- package/dist/cjs/query/where.js +215 -77
- package/dist/cjs/query/writes.d.ts +1 -1
- package/dist/cjs/query/writes.js +39 -15
- package/dist/cjs/schema-builder.d.ts +2 -1
- package/dist/cjs/schema-sql.d.ts +94 -4
- package/dist/cjs/schema-sql.js +506 -30
- package/dist/cjs/schema.d.ts +3 -1
- package/dist/cjs/sqlite.d.ts +6 -0
- package/dist/cjs/sqlite.js +151 -10
- package/dist/cjs/typed-sql.d.ts +29 -1
- package/dist/cjs/typed-sql.js +30 -12
- package/dist/cli/destructive.d.ts +18 -4
- package/dist/cli/destructive.js +229 -121
- package/dist/cli/index.d.ts +21 -4
- package/dist/cli/index.js +120 -24
- package/dist/cli/mcp.d.ts +28 -8
- package/dist/cli/mcp.js +172 -129
- package/dist/cli/migrate.d.ts +134 -13
- package/dist/cli/migrate.js +347 -238
- package/dist/cli/pii-predicate-guard.d.ts +112 -0
- package/dist/cli/pii-predicate-guard.js +386 -0
- package/dist/cli/prisma-resolve.js +75 -4
- package/dist/cli/prisma-schema.d.ts +17 -1
- package/dist/cli/prisma-schema.js +83 -17
- package/dist/cli/sql-statements.d.ts +125 -0
- package/dist/cli/sql-statements.js +373 -0
- package/dist/cli/studio.js +49 -118
- package/dist/cli/ui.d.ts +1 -1
- package/dist/client.d.ts +43 -0
- package/dist/client.js +126 -7
- package/dist/dialect.d.ts +123 -0
- package/dist/dialect.js +33 -0
- package/dist/errors.d.ts +74 -1
- package/dist/errors.js +228 -19
- package/dist/index-advisor.d.ts +33 -1
- package/dist/index-advisor.js +31 -1
- package/dist/introspect.d.ts +48 -0
- package/dist/introspect.js +221 -91
- package/dist/mssql.js +44 -2
- package/dist/mysql.d.ts +5 -2
- package/dist/mysql.js +203 -18
- package/dist/nested-write.js +7 -2
- package/dist/pipeline-submittable.js +18 -4
- package/dist/pipeline.js +76 -10
- package/dist/powdb.d.ts +23 -0
- package/dist/powdb.js +33 -2
- package/dist/powql.d.ts +61 -9
- package/dist/powql.js +187 -50
- package/dist/prisma-compat.js +160 -41
- package/dist/query/aggregates.d.ts +1 -1
- package/dist/query/aggregates.js +82 -20
- package/dist/query/batched-loader.d.ts +10 -0
- package/dist/query/batched-loader.js +270 -9
- package/dist/query/builder.d.ts +73 -0
- package/dist/query/builder.js +226 -30
- package/dist/query/filters.d.ts +162 -0
- package/dist/query/filters.js +246 -1
- package/dist/query/relations.d.ts +10 -10
- package/dist/query/relations.js +94 -14
- package/dist/query/types.d.ts +14 -1
- package/dist/query/utils.d.ts +146 -2
- package/dist/query/utils.js +204 -3
- package/dist/query/warn-registry.d.ts +10 -0
- package/dist/query/warn-registry.js +10 -0
- package/dist/query/where-compile.d.ts +30 -0
- package/dist/query/where-compile.js +40 -1
- package/dist/query/where.d.ts +128 -13
- package/dist/query/where.js +216 -80
- package/dist/query/writes.d.ts +1 -1
- package/dist/query/writes.js +40 -16
- package/dist/schema-builder.d.ts +2 -1
- package/dist/schema-sql.d.ts +94 -4
- package/dist/schema-sql.js +505 -30
- package/dist/schema.d.ts +3 -1
- package/dist/sqlite.d.ts +6 -0
- package/dist/sqlite.js +151 -10
- package/dist/typed-sql.d.ts +29 -1
- package/dist/typed-sql.js +30 -12
- package/package.json +6 -4
|
@@ -55,8 +55,8 @@
|
|
|
55
55
|
*/
|
|
56
56
|
import { CircularRelationError, RelationError, UnsupportedFeatureError, ValidationError } from '../errors.js';
|
|
57
57
|
import { normalizeKeyColumns } from '../schema.js';
|
|
58
|
-
import { isRelationPickOrderBy, sortedEntries } from './filters.js';
|
|
59
|
-
import { ownLookup, selectNamesNothingMessage, selectOmitExclusiveMessage } from './utils.js';
|
|
58
|
+
import { dedupeOrderEntries, isOrderBySpec, isRelationPickOrderBy, orderByEntries, sortedEntries } from './filters.js';
|
|
59
|
+
import { markInternalCombinator, ownLookup, selectNamesNothingMessage, selectOmitExclusiveMessage, sqlToPreparedName, } from './utils.js';
|
|
60
60
|
/**
|
|
61
61
|
* Max parent keys per follow-up query. On Postgres the whole key set travels as
|
|
62
62
|
* ONE array parameter (`= ANY($1)`), so this is not a bind-parameter limit, it
|
|
@@ -68,6 +68,147 @@ import { ownLookup, selectNamesNothingMessage, selectOmitExclusiveMessage } from
|
|
|
68
68
|
const MAX_RELATION_KEYS = 32_000;
|
|
69
69
|
/** Nesting cap, parity with the join strategy's depth-10 guard. */
|
|
70
70
|
const MAX_DEPTH = 10;
|
|
71
|
+
/**
|
|
72
|
+
* Column the {@link Dialect.buildPartitionLimit} wrapper adds to carry the
|
|
73
|
+
* per-key row number. It is part of that statement's projection, so the loader
|
|
74
|
+
* removes it from every raw row before the child's own transform parses them,
|
|
75
|
+
* or it would surface as an extra field on every entity and break output
|
|
76
|
+
* equality with the join strategy.
|
|
77
|
+
*/
|
|
78
|
+
const PARTITION_RANK_COLUMN = '__turbine_rn';
|
|
79
|
+
/**
|
|
80
|
+
* The relation `orderBy` expressed as plain column/direction/nulls triples for
|
|
81
|
+
* the window, or `null` when this pushdown cannot PROVE it would pick the same
|
|
82
|
+
* rows the join plan picks. `null` sends the whole relation back to the
|
|
83
|
+
* client-side slice, which is what every engine without the dialect hook does.
|
|
84
|
+
*
|
|
85
|
+
* ## Only a TOTAL order is eligible, and that is not a conservatism
|
|
86
|
+
*
|
|
87
|
+
* The join plan runs one correlated `… WHERE fk = parent ORDER BY … LIMIT n`
|
|
88
|
+
* per parent; the pushdown runs one flat statement over every parent and ranks
|
|
89
|
+
* with `ROW_NUMBER()`. When the ordering leaves TIES, "the first n" is not a
|
|
90
|
+
* defined set, so the two plans are each free to return different tied rows,
|
|
91
|
+
* and measured on PostgreSQL 16 (5 parents, 32 children, ties and NULLs on the
|
|
92
|
+
* sort column, `limit: 2`) they do:
|
|
93
|
+
*
|
|
94
|
+
* relation shape old (client slice) == join window == join
|
|
95
|
+
* `orderBy: { sortKey: 'asc' }` w/ ties N N
|
|
96
|
+
* no `orderBy` Y N
|
|
97
|
+
* `{ sortKey: { sort:'asc', nulls:'first' } }` N N
|
|
98
|
+
* `orderBy: [{ sortKey }, { id }]` (total) Y Y
|
|
99
|
+
*
|
|
100
|
+
* So the rule is not "the window is wrong", it is that WITHOUT A TOTAL ORDER
|
|
101
|
+
* neither implementation can be right, and the only shape where the pushdown
|
|
102
|
+
* demonstrably regressed something that used to hold is the unordered one. The
|
|
103
|
+
* bound is only taken where the answer is forced: the resolved column list must
|
|
104
|
+
* cover a NOT NULL unique key of the target (see {@link totallyOrdered}), which
|
|
105
|
+
* makes "the n smallest keys per parent" a single set in a single order that
|
|
106
|
+
* both plans must return. Everything else keeps the pre-existing behaviour
|
|
107
|
+
* rather than a silently different row set, because a wrong answer is worse
|
|
108
|
+
* than a slower query.
|
|
109
|
+
*
|
|
110
|
+
* A caller who wants the bound on an unordered relation has an existing,
|
|
111
|
+
* plan-symmetric way to ask for it: `stableRelationOrder` fills a primary-key
|
|
112
|
+
* ascending order into every to-many relation that declares none, on the join
|
|
113
|
+
* plan and the batched plan alike, which makes the shape totally ordered and
|
|
114
|
+
* therefore eligible here.
|
|
115
|
+
*
|
|
116
|
+
* ## What else disqualifies a shape
|
|
117
|
+
*
|
|
118
|
+
* Only the plain direction and {@link OrderBySpec} forms are accepted. A
|
|
119
|
+
* JSON-path, vector-distance, relation `_count` or pick-row ordering compiles
|
|
120
|
+
* to an expression (sometimes with its own bound params) that the loader cannot
|
|
121
|
+
* re-emit here. Same for a key that names no column: the child query build is
|
|
122
|
+
* what reports that, with the proper E003, so this must not throw its own error
|
|
123
|
+
* first.
|
|
124
|
+
*
|
|
125
|
+
* The entry list is DEDUPED first, with the same rule and the same metadata the
|
|
126
|
+
* inner statement's own `orderBy` goes through (the follow-up is compiled as a
|
|
127
|
+
* top-level `findMany` on the child table, which dedupes). Reading the RAW args
|
|
128
|
+
* here made the wrapper's text vary while the statement it wraps did not, which
|
|
129
|
+
* is a correctness divergence (`[{a:'asc'},{a:'desc'}]` sorts by `a ASC` inside
|
|
130
|
+
* and would have ranked by `a ASC, a DESC` outside) and, because the wrapper is
|
|
131
|
+
* named after its own text, an unbounded set of server-side prepared
|
|
132
|
+
* statements: measured, 25 requests that were semantically one sort key left 26
|
|
133
|
+
* named statements on one connection.
|
|
134
|
+
*/
|
|
135
|
+
function partitionOrderBy(meta, orderBy) {
|
|
136
|
+
// A child column spelled like the wrapper's rank alias would make the outer
|
|
137
|
+
// `WHERE … <= $n` reference ambiguous (Postgres 42702) and the strip below
|
|
138
|
+
// would delete a real value. It fails closed, but with a raw driver error
|
|
139
|
+
// carrying no TURBINE_ code, on a query the join plan serves fine, so the
|
|
140
|
+
// relation declines the pushdown instead.
|
|
141
|
+
if (meta.allColumns.includes(PARTITION_RANK_COLUMN))
|
|
142
|
+
return null;
|
|
143
|
+
const raw = orderByEntries(orderBy);
|
|
144
|
+
// `meta.name` is the table the child statement is compiled against, so a
|
|
145
|
+
// direction this refuses on a dropped term reads exactly as it does when the
|
|
146
|
+
// child's own compile path refuses it a few lines later.
|
|
147
|
+
const entries = dedupeOrderEntries(meta, raw, meta.name)?.entries ?? raw;
|
|
148
|
+
// No ordering at all is the extreme case of the tie rule above: an unordered
|
|
149
|
+
// window numbers each partition arbitrarily and the join plan's ORDER-BY-less
|
|
150
|
+
// `LIMIT` takes arbitrary rows, and those arbitrary choices are made by
|
|
151
|
+
// different plans over different row sets.
|
|
152
|
+
if (entries.length === 0)
|
|
153
|
+
return null;
|
|
154
|
+
const out = [];
|
|
155
|
+
for (const [key, value] of entries) {
|
|
156
|
+
const column = ownLookup(meta.columnMap, key);
|
|
157
|
+
if (!column || !meta.allColumns.includes(column))
|
|
158
|
+
return null;
|
|
159
|
+
let sort;
|
|
160
|
+
let nulls;
|
|
161
|
+
if (isOrderBySpec(value)) {
|
|
162
|
+
// EXACTLY `{ sort, nulls? }` and nothing else. `isOrderBySpec` only tests
|
|
163
|
+
// for a `sort` key, and a JSON-path ordering can carry one too
|
|
164
|
+
// (`{ path: ['a'], sort: 'asc' }`), which would be read here as a plain
|
|
165
|
+
// column ordering and rank by the wrong expression. Anything with a key
|
|
166
|
+
// outside the pair falls back to the client-side slice.
|
|
167
|
+
for (const k of Object.keys(value))
|
|
168
|
+
if (k !== 'sort' && k !== 'nulls')
|
|
169
|
+
return null;
|
|
170
|
+
sort = value.sort;
|
|
171
|
+
if (value.nulls === 'first')
|
|
172
|
+
nulls = 'FIRST';
|
|
173
|
+
else if (value.nulls === 'last')
|
|
174
|
+
nulls = 'LAST';
|
|
175
|
+
else if (value.nulls !== undefined)
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
else if (typeof value === 'object' && value !== null) {
|
|
179
|
+
// Relation `_count` / pick-row / vector orderings are objects too.
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
else {
|
|
183
|
+
sort = value;
|
|
184
|
+
}
|
|
185
|
+
if (sort !== 'asc' && sort !== 'desc')
|
|
186
|
+
return null;
|
|
187
|
+
out.push({ column, direction: sort === 'asc' ? 'ASC' : 'DESC', nulls });
|
|
188
|
+
}
|
|
189
|
+
return totallyOrdered(meta, out.map((o) => o.column))
|
|
190
|
+
? out
|
|
191
|
+
: null;
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* True when sorting by `columns` can leave no two rows of `meta` tied: the list
|
|
195
|
+
* covers every column of the primary key, or of some unique constraint, and
|
|
196
|
+
* every column of that key is NOT NULL.
|
|
197
|
+
*
|
|
198
|
+
* The NOT NULL half is not decoration. A UNIQUE constraint over a nullable
|
|
199
|
+
* column admits any number of NULL rows in PostgreSQL (they are all distinct to
|
|
200
|
+
* the constraint and all equal to a sort), so such a key orders the non-null
|
|
201
|
+
* rows and leaves the NULL ones tied with each other, which is exactly the
|
|
202
|
+
* shape this rule exists to refuse. Primary-key columns are NOT NULL by
|
|
203
|
+
* definition, and are checked anyway: metadata that says otherwise is metadata
|
|
204
|
+
* this proof cannot rest on, and declining costs a bound rather than an answer.
|
|
205
|
+
*/
|
|
206
|
+
function totallyOrdered(meta, columns) {
|
|
207
|
+
const sorted = new Set(columns);
|
|
208
|
+
const nonNullable = (column) => meta.columns.some((c) => c.name === column && c.nullable === false);
|
|
209
|
+
const covered = (key) => key !== undefined && key.length > 0 && key.every((c) => sorted.has(c) && nonNullable(c));
|
|
210
|
+
return covered(meta.primaryKey) || (meta.uniqueColumns ?? []).some(covered);
|
|
211
|
+
}
|
|
71
212
|
/**
|
|
72
213
|
* The default projection of `meta` expressed in FIELD names: which fields the
|
|
73
214
|
* default (no `select`/`omit`) projection hides, and which it returns. Today the
|
|
@@ -456,7 +597,33 @@ async function loadToOneOrMany(ctx, parents, rel, relName, options, timeout, dep
|
|
|
456
597
|
// to-many with a to-one inside it, which is why `include` and `join` were both
|
|
457
598
|
// clean and seventeen rounds of parity capture missed it.
|
|
458
599
|
assertProjectionShape(targetMeta.name, options.select, options.omit);
|
|
459
|
-
|
|
600
|
+
// Decide the per-parent pushdown BEFORE resolving the projection: when it is
|
|
601
|
+
// on, the window's ORDER BY reads the order columns out of the derived table,
|
|
602
|
+
// so they have to be projected (and, like the correlation keys, stripped
|
|
603
|
+
// again afterwards). `null` means this relation keeps the client-side slice,
|
|
604
|
+
// which is the case for every shape whose ordering does not force WHICH rows
|
|
605
|
+
// the limit keeps. See {@link partitionOrderBy} and {@link boundedChildQuery}.
|
|
606
|
+
const windowOrder = single || options.limit === undefined ? null : partitionOrderBy(targetMeta, options.orderBy);
|
|
607
|
+
const pushDownLimit = windowOrder !== null && ctx.buildPartitionLimit !== undefined;
|
|
608
|
+
// THE ONE PII EXCEPTION IN THE READ PATH, recorded here because the contract
|
|
609
|
+
// in schema.ts says a PII column is excluded from every default projection
|
|
610
|
+
// "at the SQL level". Ordering a LIMITED relation by a PII column force-adds
|
|
611
|
+
// that column to this follow-up's SELECT list (the window reads it out of the
|
|
612
|
+
// derived table), and it is removed from the entities by `proj.strip` before
|
|
613
|
+
// anything returns. The join plan does the same thing in the same case: its
|
|
614
|
+
// wrapped subquery projects `targetMeta.allColumns` into the derived table
|
|
615
|
+
// whenever a relation carries a `limit` or an `orderBy`, and its
|
|
616
|
+
// `json_build_object` then emits only the resolved, PII-free column list. So
|
|
617
|
+
// the SQL-level statement is parity, and no PII value reaches a caller on
|
|
618
|
+
// either plan. What is NOT identical, and is the honest cost of the pushdown:
|
|
619
|
+
// on the join plan the value never leaves the server, while here it crosses
|
|
620
|
+
// the wire and is dropped client-side. A caller who cannot accept that should
|
|
621
|
+
// not order a limited relation by a PII column, which is a shape that already
|
|
622
|
+
// reveals the column's ordering.
|
|
623
|
+
const orderFields = pushDownLimit
|
|
624
|
+
? (windowOrder ?? []).map((o) => targetMeta.reverseColumnMap[o.column] ?? o.column)
|
|
625
|
+
: [];
|
|
626
|
+
const proj = includeKeysForBatching(options.select, options.omit, [childKeyField, ...orderFields, ...neededParentKeyFields(targetMeta, (options.with ?? {}))], defaultProjectionFields(targetMeta, ctx.includePii));
|
|
460
627
|
const child = ctx.makeChild(rel.to);
|
|
461
628
|
const buildChunk = (chunk) => child.buildFindMany({
|
|
462
629
|
where: mergeChildWhere(options.where, childKeyField, chunk),
|
|
@@ -478,13 +645,20 @@ async function loadToOneOrMany(ctx, parents, rel, relName, options, timeout, dep
|
|
|
478
645
|
const chunks = [];
|
|
479
646
|
for (let i = 0; i < keys.length; i += MAX_RELATION_KEYS)
|
|
480
647
|
chunks.push(keys.slice(i, i + MAX_RELATION_KEYS));
|
|
481
|
-
// Chunks run concurrently, results concatenated in chunk order.
|
|
482
|
-
// `limit` is
|
|
483
|
-
//
|
|
484
|
-
//
|
|
648
|
+
// Chunks run concurrently, results concatenated in chunk order. A per-relation
|
|
649
|
+
// `limit` is bounded IN THE DATABASE when the dialect can express "at most N
|
|
650
|
+
// rows per correlation key" AND the relation's ordering forces which N those
|
|
651
|
+
// are (see partitionOrderBy / boundedChildQuery); a plain trailing `LIMIT`
|
|
652
|
+
// never can, because this one statement covers every parent. The client-side
|
|
653
|
+
// slice below still runs either way.
|
|
485
654
|
const chunkResults = await Promise.all(chunks.map(async (chunk) => {
|
|
486
655
|
const deferred = buildChunk(chunk);
|
|
487
|
-
const
|
|
656
|
+
const bounded = pushDownLimit
|
|
657
|
+
? boundedChildQuery(ctx, deferred, childKeyCol, windowOrder ?? [], options.limit)
|
|
658
|
+
: deferred;
|
|
659
|
+
const result = await ctx.exec(bounded.sql, bounded.params, bounded.preparedName);
|
|
660
|
+
if (bounded !== deferred)
|
|
661
|
+
stripRankColumn(result);
|
|
488
662
|
return deferred.transform(result);
|
|
489
663
|
}));
|
|
490
664
|
const allChildren = chunkResults.flat();
|
|
@@ -514,6 +688,77 @@ async function loadToOneOrMany(ctx, parents, rel, relName, options, timeout, dep
|
|
|
514
688
|
}
|
|
515
689
|
stripFields(allChildren, proj.strip);
|
|
516
690
|
}
|
|
691
|
+
/**
|
|
692
|
+
* Rewrite a compiled child follow-up so the ENGINE returns at most `limit` rows
|
|
693
|
+
* per correlation key, instead of returning every matching child and letting the
|
|
694
|
+
* loader throw most of them away.
|
|
695
|
+
*
|
|
696
|
+
* WHY IT IS WORTH THE WRAPPER. The follow-up is one flat statement covering
|
|
697
|
+
* every parent, so a trailing `LIMIT n` would cap the TOTAL, not the per-parent
|
|
698
|
+
* count, and would starve most parents; that is exactly why the limit was
|
|
699
|
+
* applied client-side and why the comment above used to say it could not be
|
|
700
|
+
* pushed down. What it could not do was push down a `LIMIT`. A window function
|
|
701
|
+
* expresses the actual requirement. Measured (200 posts, ~505 comments each,
|
|
702
|
+
* `with: { comments: { limit: 3 } }`, 600 rows kept):
|
|
703
|
+
*
|
|
704
|
+
* strategy rows over the wire peak heap
|
|
705
|
+
* join 200 +0.5 MB
|
|
706
|
+
* batched (before) 101,000 +52.9 MB
|
|
707
|
+
*
|
|
708
|
+
* and this is not an opt-in-only path: `'auto'`, the default since 0.41, routes
|
|
709
|
+
* a relation to the batched loader whenever its probe column is provably
|
|
710
|
+
* unindexed, so on a "posts with 10K comments each" shape the old behaviour is
|
|
711
|
+
* an OOM rather than a slowdown.
|
|
712
|
+
*
|
|
713
|
+
* WHAT IS DELIBERATELY NOT CHANGED. The mirror case is why this is a bound and
|
|
714
|
+
* not a strategy switch: with NO per-relation limit the batched plan beat the
|
|
715
|
+
* join plan 83 ms to 631 ms on the same data, so nothing here touches the
|
|
716
|
+
* unlimited path. The client-side slice also stays: it is a no-op once the
|
|
717
|
+
* engine has bounded each partition, and it is still the whole mechanism on an
|
|
718
|
+
* engine with no {@link Dialect.buildPartitionLimit}, and on every relation
|
|
719
|
+
* whose ordering leaves the choice of rows open ({@link partitionOrderBy}).
|
|
720
|
+
*
|
|
721
|
+
* WHAT THE CALLER GUARANTEES. `orderBy` is non-empty and totally orders the
|
|
722
|
+
* target, so the window's rank and the outer sort agree by construction and the
|
|
723
|
+
* n ranked rows per key are the n rows the join plan's per-parent
|
|
724
|
+
* `ORDER BY … LIMIT n` returns, in the same order. That is the whole reason
|
|
725
|
+
* this rewrite is allowed to change which rows come back over the wire.
|
|
726
|
+
*
|
|
727
|
+
* The prepared-statement name is REDERIVED from the wrapped text, never reused:
|
|
728
|
+
* the child's name is the hash of the INNER statement, and sending different
|
|
729
|
+
* text under a name the connection has already parsed would execute the old
|
|
730
|
+
* statement with these params. An unnamed child (a variable-arity where) stays
|
|
731
|
+
* unnamed.
|
|
732
|
+
*/
|
|
733
|
+
function boundedChildQuery(ctx, deferred, partitionColumn, orderBy, limit) {
|
|
734
|
+
const wrap = ctx.buildPartitionLimit;
|
|
735
|
+
if (!wrap)
|
|
736
|
+
return deferred;
|
|
737
|
+
const sql = wrap({
|
|
738
|
+
innerSql: deferred.sql,
|
|
739
|
+
partitionColumn,
|
|
740
|
+
orderBy,
|
|
741
|
+
limitPlaceholder: ctx.paramPlaceholder(deferred.params.length + 1),
|
|
742
|
+
rankColumn: PARTITION_RANK_COLUMN,
|
|
743
|
+
});
|
|
744
|
+
return {
|
|
745
|
+
sql,
|
|
746
|
+
params: [...deferred.params, limit],
|
|
747
|
+
preparedName: deferred.preparedName ? sqlToPreparedName(sql) : deferred.preparedName,
|
|
748
|
+
transform: deferred.transform,
|
|
749
|
+
};
|
|
750
|
+
}
|
|
751
|
+
/**
|
|
752
|
+
* Remove the window wrapper's rank column from every raw row, in place, before
|
|
753
|
+
* the child's own transform parses them. It is the LAST column of the wrapper's
|
|
754
|
+
* projection, so deleting it leaves the remaining key order untouched, which
|
|
755
|
+
* matters: object key order is observable output here (callers stringify
|
|
756
|
+
* results into HTTP bodies, ETags and cache keys).
|
|
757
|
+
*/
|
|
758
|
+
function stripRankColumn(result) {
|
|
759
|
+
for (const row of result.rows)
|
|
760
|
+
delete row[PARTITION_RANK_COLUMN];
|
|
761
|
+
}
|
|
517
762
|
/**
|
|
518
763
|
* manyToMany: a three-hop batched loader (no join pushdown):
|
|
519
764
|
* (1) read junction rows for all parents (`sourceKey = ANY($1)` chunks),
|
|
@@ -618,6 +863,18 @@ async function loadManyToMany(ctx, parents, rel, relName, options, timeout, dept
|
|
|
618
863
|
// (3) Stitch. Iterate `targetsInOrder` (already ordered by the relation's
|
|
619
864
|
// orderBy) and pick the ones each parent links to, so per-parent order honours
|
|
620
865
|
// orderBy; then apply the per-relation `limit` client-side.
|
|
866
|
+
//
|
|
867
|
+
// CLIENT-SIDE ON PURPOSE, and not an oversight of the per-parent pushdown the
|
|
868
|
+
// to-one/to-many loader uses. That pushdown ranks the follow-up rows with
|
|
869
|
+
// `ROW_NUMBER() OVER (PARTITION BY <correlation column>)`, and this query has
|
|
870
|
+
// no such column: step (2) reads TARGET rows by their own primary key, the
|
|
871
|
+
// parent correlation lives one hop back in the junction, and a single target
|
|
872
|
+
// row is legitimately linked to many parents, so it must be fetched once and
|
|
873
|
+
// attached several times. Partitioning by parent would mean joining the
|
|
874
|
+
// junction into the follow-up and returning one copy of the target row per
|
|
875
|
+
// link, which spends exactly the bytes the pushdown exists to save; and the
|
|
876
|
+
// bound would still have to respect a `limit` measured per parent, not per
|
|
877
|
+
// fetched row. So the m2m loader keeps the slice below, on every engine.
|
|
621
878
|
const limit = options.limit;
|
|
622
879
|
for (const parent of parents) {
|
|
623
880
|
const linked = new Set((targetsBySource.get(keyOf(parent[parentRefField])) ?? []).map(keyOf));
|
|
@@ -766,8 +1023,12 @@ function mergeChildWhere(where, keyField, chunk) {
|
|
|
766
1023
|
const correlation = { [keyField]: { in: chunk } };
|
|
767
1024
|
if (!where)
|
|
768
1025
|
return correlation;
|
|
1026
|
+
// Branded: this `AND` is TURBINE's, always exactly two branches and decided
|
|
1027
|
+
// here rather than by the caller, so it must not put the follow-up on the
|
|
1028
|
+
// variable-arity (unnamed) prepared-statement path. Same rule and same
|
|
1029
|
+
// reason as the global-filter merge in where.ts.
|
|
769
1030
|
if (Object.hasOwn(where, keyField))
|
|
770
|
-
return { AND: [where, correlation] };
|
|
1031
|
+
return markInternalCombinator({ AND: [where, correlation] });
|
|
771
1032
|
return { ...where, ...correlation };
|
|
772
1033
|
}
|
|
773
1034
|
/**
|
package/dist/query/builder.d.ts
CHANGED
|
@@ -13,6 +13,15 @@
|
|
|
13
13
|
import type pg from 'pg';
|
|
14
14
|
import type { SchemaMetadata } from '../schema.js';
|
|
15
15
|
import type { AggregateArgs, AggregateResult, CountArgs, CreateArgs, CreateManyArgs, DeleteArgs, DeleteManyArgs, FindManyArgs, FindManyStreamArgs, FindUniqueArgs, GroupByArgs, GroupByResult, QueryResult, TypedWithClause, UpdateArgs, UpdateManyArgs, UpsertArgs, WithClause } from './types.js';
|
|
16
|
+
/**
|
|
17
|
+
* Discard the memoized cross-check environment so the next cache hit re-reads
|
|
18
|
+
* `process.env`.
|
|
19
|
+
*
|
|
20
|
+
* @internal Exposed for the tests that toggle these variables in-process (see
|
|
21
|
+
* {@link crossCheckEnv}). Production code sets them before the first query and
|
|
22
|
+
* never again.
|
|
23
|
+
*/
|
|
24
|
+
export declare function resetCacheCrossCheckEnv(): void;
|
|
16
25
|
/**
|
|
17
26
|
* Marginal cost of keeping a to-one relation on the JOIN plan, per parent row.
|
|
18
27
|
*
|
|
@@ -199,6 +208,16 @@ export declare class QueryInterface<T extends object, R extends object = {}> {
|
|
|
199
208
|
* inline, not through the top-level cache).
|
|
200
209
|
*/
|
|
201
210
|
private lastCacheHit;
|
|
211
|
+
/**
|
|
212
|
+
* Set while a build closure runs when the statement's WHERE (or HAVING)
|
|
213
|
+
* carries a caller-written `AND`/`OR` combinator array, i.e. when its SQL text
|
|
214
|
+
* is a function of an arity the caller chose. Read (and reset) by
|
|
215
|
+
* {@link acquireSql}, which brackets the single `build()` call, so the flag
|
|
216
|
+
* has no lifetime outside that synchronous window and cannot leak between
|
|
217
|
+
* queries. See {@link BuilderCtx.markVariableArity} for the unbounded
|
|
218
|
+
* server-side prepared-statement growth this exists to stop.
|
|
219
|
+
*/
|
|
220
|
+
private variableArityShape;
|
|
202
221
|
private readonly middlewares;
|
|
203
222
|
private readonly defaultLimit?;
|
|
204
223
|
private readonly warnOnUnlimited;
|
|
@@ -285,6 +304,17 @@ export declare class QueryInterface<T extends object, R extends object = {}> {
|
|
|
285
304
|
* to Date as well (otherwise nested dates leak through as strings).
|
|
286
305
|
*/
|
|
287
306
|
private readonly camelDateFieldCache;
|
|
307
|
+
/**
|
|
308
|
+
* Per-table memo of `Object.entries(meta.relations)`, consumed by the nested
|
|
309
|
+
* row parser (see `getRelationEntries` in relations.ts). Same rationale and
|
|
310
|
+
* same lifetime as {@link camelDateFieldCache}: the metadata is immutable, and
|
|
311
|
+
* the parser reads it once per row.
|
|
312
|
+
*/
|
|
313
|
+
private readonly relationEntryCache;
|
|
314
|
+
/** Per-table memo of batched-loader child readers (see {@link batchedChild}). */
|
|
315
|
+
private readonly batchedChildCache;
|
|
316
|
+
/** The (option-invariant) options every batched child is built with. */
|
|
317
|
+
private batchedChildOptions?;
|
|
288
318
|
/** True when this QI runs inside an active transaction (set via _txScoped option). */
|
|
289
319
|
private readonly txScoped;
|
|
290
320
|
/** Original options reference, forwarded to child QIs in nested writes. */
|
|
@@ -668,6 +698,28 @@ export declare class QueryInterface<T extends object, R extends object = {}> {
|
|
|
668
698
|
* child, and the per-relation `limit` is applied client-side by the loader.
|
|
669
699
|
*/
|
|
670
700
|
private batchedContext;
|
|
701
|
+
/**
|
|
702
|
+
* The child {@link QueryInterface} the batched loader uses for `table`,
|
|
703
|
+
* memoized per table for the lifetime of this accessor.
|
|
704
|
+
*
|
|
705
|
+
* It used to be constructed fresh for every relation of every query, which
|
|
706
|
+
* threw away that child's SQL template cache each time: a relation follow-up
|
|
707
|
+
* therefore MISSED the cache on every single request and rebuilt its SQL,
|
|
708
|
+
* which is the one thing the template cache exists to avoid, and it allocated
|
|
709
|
+
* a whole QueryInterface (column type maps, camel-date memo, the ctx literal)
|
|
710
|
+
* per relation per query. `childOptions` was likewise a fresh spread per
|
|
711
|
+
* query although it is a pure function of this instance's options.
|
|
712
|
+
*
|
|
713
|
+
* Safe to share across queries: a QueryInterface holds no per-query state
|
|
714
|
+
* beyond the transient build fields, which live and die inside one
|
|
715
|
+
* synchronous `build*` call, and every child is bound to THIS instance's pool
|
|
716
|
+
* (so it still joins an active transaction) with `defaultLimit` cleared and
|
|
717
|
+
* unlimited-warnings silenced, because a relation load must fetch every
|
|
718
|
+
* matching child. Per-query values (`skipGlobalFilters`, `includePii`,
|
|
719
|
+
* `timeout`, `forceCustomPlan`) are passed as ARGUMENTS by the loader, never
|
|
720
|
+
* baked into the child, which is what makes the memo sound.
|
|
721
|
+
*/
|
|
722
|
+
private batchedChild;
|
|
671
723
|
/**
|
|
672
724
|
* Run a findMany with the batched strategy: execute the base query WITHOUT
|
|
673
725
|
* relation subqueries (all other clauses intact), then load each relation via
|
|
@@ -710,6 +762,27 @@ export declare class QueryInterface<T extends object, R extends object = {}> {
|
|
|
710
762
|
* cross-check is warranted.
|
|
711
763
|
*/
|
|
712
764
|
private acquireSql;
|
|
765
|
+
/**
|
|
766
|
+
* Run one build closure and pair its SQL with the prepared-statement name it
|
|
767
|
+
* should execute under.
|
|
768
|
+
*
|
|
769
|
+
* The `variableArityShape` flag is reset immediately BEFORE the build and read
|
|
770
|
+
* immediately AFTER it, so this method is the flag's entire lifetime: the
|
|
771
|
+
* build walk (`buildWhereClause` / `buildScopedWhere` / the HAVING combinator)
|
|
772
|
+
* is the only thing that can set it, nothing can observe a stale value, and a
|
|
773
|
+
* throw out of `build()` leaves nothing behind to affect the next query.
|
|
774
|
+
*
|
|
775
|
+
* An EMPTY name is how "send this unnamed" travels: `queryWithTimeout` tests
|
|
776
|
+
* the name for truthiness, so `''` takes the plain `(text, values)` form the
|
|
777
|
+
* driver never registers a named statement for. Doing it HERE rather than at
|
|
778
|
+
* the execute seam is what makes it survive the cache: the entry is created
|
|
779
|
+
* once, on the miss, and a later HIT reuses the same (empty) name, so a
|
|
780
|
+
* variable-arity shape can never acquire a name from a warmed template.
|
|
781
|
+
*
|
|
782
|
+
* See {@link BuilderCtx.markVariableArity} for what makes a shape
|
|
783
|
+
* variable-arity and the measured reason it must not be named.
|
|
784
|
+
*/
|
|
785
|
+
private buildCacheEntry;
|
|
713
786
|
/**
|
|
714
787
|
* Dev-mode SQL-cache lockstep cross-check (see {@link cacheCrossCheckEnabled}).
|
|
715
788
|
*
|