turbine-orm 0.25.0 → 0.26.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 +26 -1
- package/dist/cjs/cli/index.js +83 -0
- package/dist/cjs/client.js +14 -0
- package/dist/cjs/dialect.js +24 -0
- package/dist/cjs/index-advisor.js +0 -0
- package/dist/cjs/mssql.js +3 -1
- package/dist/cjs/query/batched-loader.js +392 -0
- package/dist/cjs/query/builder.js +548 -60
- package/dist/cjs/query/utils.js +36 -0
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.js +83 -0
- package/dist/client.d.ts +40 -1
- package/dist/client.js +14 -0
- package/dist/dialect.d.ts +11 -0
- package/dist/dialect.js +24 -0
- package/dist/index-advisor.d.ts +83 -0
- package/dist/index-advisor.js +0 -0
- package/dist/mssql.js +3 -1
- package/dist/query/batched-loader.d.ts +120 -0
- package/dist/query/batched-loader.js +386 -0
- package/dist/query/builder.d.ts +118 -1
- package/dist/query/builder.js +549 -61
- package/dist/query/index.d.ts +1 -1
- package/dist/query/types.d.ts +37 -2
- package/dist/query/utils.d.ts +16 -0
- package/dist/query/utils.js +35 -0
- package/package.json +1 -1
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* turbine-orm — Batched relation loader (the `relationLoadStrategy: 'batched'` path)
|
|
3
|
+
*
|
|
4
|
+
* ## Why this exists
|
|
5
|
+
*
|
|
6
|
+
* Turbine's default `with`-clause strategy resolves nested relations in ONE SQL
|
|
7
|
+
* statement using correlated `json_agg(json_build_object(...))` subqueries — one
|
|
8
|
+
* probe per parent row (see `buildRelationSubquery` in builder.ts). That is the
|
|
9
|
+
* right default: a single round-trip, and when the child FK columns are indexed
|
|
10
|
+
* each probe is an index seek. But it degrades in two situations:
|
|
11
|
+
*
|
|
12
|
+
* 1. **Missing FK index** — a correlated probe per parent row becomes
|
|
13
|
+
* N-parents × full-table-scan. A batched-loader ORM pays that missing index
|
|
14
|
+
* only ONCE (a single `WHERE fk = ANY($1)` seq-scan), which is why schemas
|
|
15
|
+
* migrated from those ORMs often lack the index the json_agg path needs.
|
|
16
|
+
* 2. **Huge unpaginated result sets** — the JSON wire format
|
|
17
|
+
* (`json_build_object` per row, re-serialized inside `json_agg`) is heavy to
|
|
18
|
+
* encode/decode compared with flat rows.
|
|
19
|
+
*
|
|
20
|
+
* This module implements the alternative, opt-in strategy: run the base query
|
|
21
|
+
* WITHOUT relation subqueries, collect the parent keys, then issue ONE flat
|
|
22
|
+
* follow-up query per relation (`SELECT ... FROM child WHERE fk = ANY($1)`),
|
|
23
|
+
* and stitch the children onto the parents in memory. D relation levels cost D
|
|
24
|
+
* extra round-trips instead of one, but each is a single indexed lookup over a
|
|
25
|
+
* key set, and rows come back flat.
|
|
26
|
+
*
|
|
27
|
+
* ## Design constraints (see CLAUDE.md)
|
|
28
|
+
*
|
|
29
|
+
* - **Same executor / connection path.** Every follow-up query runs through the
|
|
30
|
+
* caller's own executor ({@link RelationLoadContext.exec}) and child query
|
|
31
|
+
* interfaces built on the caller's pool. Inside a `$transaction` that pool is
|
|
32
|
+
* the pinned-connection `txPool`, so batched loads join the transaction — no
|
|
33
|
+
* separate pool checkout per query.
|
|
34
|
+
* - **Identical output shape.** The stitched result is byte-for-byte the same
|
|
35
|
+
* shape the join strategy produces: relation arrays for hasMany/manyToMany
|
|
36
|
+
* (`[]` when empty), single-or-null for hasOne/belongsTo, with the same
|
|
37
|
+
* camelCase keys and Date coercion — because the child rows are parsed by the
|
|
38
|
+
* very same `parseRow`/`buildFindMany` machinery via a child QueryInterface.
|
|
39
|
+
* - **Stitch keys never leak.** To stitch, the follow-up query must select the
|
|
40
|
+
* FK/PK it joins on even when the caller's `select`/`omit` excluded it; the
|
|
41
|
+
* loader adds those columns for the query and strips them from the returned
|
|
42
|
+
* entities afterwards ({@link includeKeysForBatching}).
|
|
43
|
+
*
|
|
44
|
+
* PowDB (powql.ts) has its own batched loaders for the same reasons — this is the
|
|
45
|
+
* clean Postgres/SQL implementation, deliberately NOT shared with PowQL.
|
|
46
|
+
*
|
|
47
|
+
* @module
|
|
48
|
+
*/
|
|
49
|
+
import { CircularRelationError, UnsupportedFeatureError, ValidationError } from '../errors.js';
|
|
50
|
+
import { normalizeKeyColumns } from '../schema.js';
|
|
51
|
+
/**
|
|
52
|
+
* Max parent keys per follow-up query. On Postgres the whole key set travels as
|
|
53
|
+
* ONE array parameter (`= ANY($1)`), so this is not a bind-parameter limit — it
|
|
54
|
+
* only bounds planner/memory cost per statement. Keep it large: every extra
|
|
55
|
+
* chunk is an extra network round-trip, and round-trips are exactly what the
|
|
56
|
+
* batched strategy exists to minimize (a 9-chunk load was measured 2× slower
|
|
57
|
+
* than a single-statement one over a WAN link).
|
|
58
|
+
*/
|
|
59
|
+
const MAX_RELATION_KEYS = 32_000;
|
|
60
|
+
/** Nesting cap — parity with the join strategy's depth-10 guard. */
|
|
61
|
+
const MAX_DEPTH = 10;
|
|
62
|
+
/**
|
|
63
|
+
* Adjust a `select`/`omit` pair so that `fields` are guaranteed present in the
|
|
64
|
+
* query result, returning the adjusted projection plus the list of fields that
|
|
65
|
+
* were added ONLY for stitching and must be stripped from the final entities.
|
|
66
|
+
*
|
|
67
|
+
* Used both for the base query (parent keys) and each follow-up query (child
|
|
68
|
+
* keys) so a caller's `select: { title: true }` on a relation still stitches even
|
|
69
|
+
* though the FK was not requested — and the FK never appears in the output.
|
|
70
|
+
*/
|
|
71
|
+
export function includeKeysForBatching(select, omit, fields) {
|
|
72
|
+
const unique = [...new Set(fields)];
|
|
73
|
+
if (select) {
|
|
74
|
+
const next = { ...select };
|
|
75
|
+
const strip = [];
|
|
76
|
+
for (const f of unique) {
|
|
77
|
+
if (!next[f]) {
|
|
78
|
+
next[f] = true;
|
|
79
|
+
strip.push(f); // not requested by the caller — added only to stitch
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return { select: next, omit, strip };
|
|
83
|
+
}
|
|
84
|
+
if (omit) {
|
|
85
|
+
const next = { ...omit };
|
|
86
|
+
const strip = [];
|
|
87
|
+
for (const f of unique) {
|
|
88
|
+
if (next[f]) {
|
|
89
|
+
delete next[f]; // un-omit so the key is present; the caller wanted it gone
|
|
90
|
+
strip.push(f);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return { select, omit: next, strip };
|
|
94
|
+
}
|
|
95
|
+
// Neither select nor omit — every column is already present; nothing to strip.
|
|
96
|
+
return { select, omit, strip: [] };
|
|
97
|
+
}
|
|
98
|
+
/** Delete stitch-only key fields from each row (no-op when `fields` is empty). */
|
|
99
|
+
export function stripFields(rows, fields) {
|
|
100
|
+
if (fields.length === 0)
|
|
101
|
+
return;
|
|
102
|
+
for (const row of rows) {
|
|
103
|
+
for (const f of fields)
|
|
104
|
+
delete row[f];
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* The set of parent FIELD names a batched load of `withClause` needs present on
|
|
109
|
+
* each parent row in order to stitch (the local key of every requested relation).
|
|
110
|
+
* The caller adds these to the base query and strips the added ones afterwards.
|
|
111
|
+
*/
|
|
112
|
+
export function neededParentKeyFields(parentMeta, withClause) {
|
|
113
|
+
const fields = new Set();
|
|
114
|
+
for (const [relName, spec] of Object.entries(withClause)) {
|
|
115
|
+
if (!spec)
|
|
116
|
+
continue;
|
|
117
|
+
const rel = parentMeta.relations[relName];
|
|
118
|
+
if (!rel)
|
|
119
|
+
continue; // unknown relation — the join path throws; let the loader surface it
|
|
120
|
+
for (const col of localKeyColumns(rel)) {
|
|
121
|
+
fields.add(parentMeta.reverseColumnMap[col] ?? col);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return [...fields];
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* The parent-side key column(s) used to correlate a relation:
|
|
128
|
+
* - hasMany / hasOne: the parent's `referenceKey` (child's FK points at it)
|
|
129
|
+
* - belongsTo: the parent's `foreignKey` (points at the child's PK)
|
|
130
|
+
* - manyToMany: the parent's `referenceKey` (junction's sourceKey → it)
|
|
131
|
+
*/
|
|
132
|
+
function localKeyColumns(rel) {
|
|
133
|
+
if (rel.type === 'belongsTo')
|
|
134
|
+
return normalizeKeyColumns(rel.foreignKey);
|
|
135
|
+
return normalizeKeyColumns(rel.referenceKey);
|
|
136
|
+
}
|
|
137
|
+
/** Stringified stitch key — robust to number/uuid/bigint type drift across a join. */
|
|
138
|
+
function keyOf(value) {
|
|
139
|
+
return String(value);
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Load every relation in `withClause` for `parents` and attach it onto each row
|
|
143
|
+
* in place. Mirrors the join strategy's output shape exactly. Recurses for nested
|
|
144
|
+
* `with` by re-running itself against the freshly-loaded child rows.
|
|
145
|
+
*/
|
|
146
|
+
export async function loadRelationsBatched(ctx, parents, withClause, timeout, depth = 0, path = [ctx.parentMeta.name]) {
|
|
147
|
+
if (depth >= MAX_DEPTH)
|
|
148
|
+
throw new CircularRelationError([...path, '…']);
|
|
149
|
+
if (parents.length === 0)
|
|
150
|
+
return;
|
|
151
|
+
// Sibling relations are independent (each writes only its own parent[relName]
|
|
152
|
+
// and reads only parent keys), so load them concurrently — on a pool that's
|
|
153
|
+
// real parallelism, inside a transaction pg queues them on the one connection.
|
|
154
|
+
const loads = [];
|
|
155
|
+
for (const [relName, spec] of Object.entries(withClause)) {
|
|
156
|
+
if (!spec)
|
|
157
|
+
continue;
|
|
158
|
+
const rel = ctx.parentMeta.relations[relName];
|
|
159
|
+
if (!rel) {
|
|
160
|
+
throw new ValidationError(`[turbine] Unknown relation "${relName}" on table "${ctx.parentMeta.name}". ` +
|
|
161
|
+
`Available: ${Object.keys(ctx.parentMeta.relations).join(', ')}`);
|
|
162
|
+
}
|
|
163
|
+
const options = spec === true ? {} : spec;
|
|
164
|
+
loads.push(rel.type === 'manyToMany'
|
|
165
|
+
? loadManyToMany(ctx, parents, rel, relName, options, timeout, depth, path)
|
|
166
|
+
: loadToOneOrMany(ctx, parents, rel, relName, options, timeout, depth, path));
|
|
167
|
+
}
|
|
168
|
+
await Promise.all(loads);
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* hasMany / hasOne / belongsTo: one follow-up `SELECT ... WHERE childKey = ANY($1)`
|
|
172
|
+
* (chunked), grouped by the correlation key and attached (array vs single-or-null).
|
|
173
|
+
*/
|
|
174
|
+
async function loadToOneOrMany(ctx, parents, rel, relName, options, timeout, depth, path) {
|
|
175
|
+
const fk = normalizeKeyColumns(rel.foreignKey);
|
|
176
|
+
const rk = normalizeKeyColumns(rel.referenceKey);
|
|
177
|
+
if (fk.length > 1 || rk.length > 1) {
|
|
178
|
+
throw new UnsupportedFeatureError('composite-key batched relation loading', 'relationLoadStrategy: "batched"', `relation "${relName}" — use the default 'join' strategy for composite-key relations`);
|
|
179
|
+
}
|
|
180
|
+
const targetMeta = requireTable(ctx.schema, rel.to, relName);
|
|
181
|
+
// Local key lives on the parent; the correlating key lives on the child.
|
|
182
|
+
// hasMany/hasOne: parent.referenceKey ← child.foreignKey
|
|
183
|
+
// belongsTo: parent.foreignKey → child.referenceKey
|
|
184
|
+
const parentKeyCol = rel.type === 'belongsTo' ? fk[0] : rk[0];
|
|
185
|
+
const childKeyCol = rel.type === 'belongsTo' ? rk[0] : fk[0];
|
|
186
|
+
const parentKeyField = ctx.parentMeta.reverseColumnMap[parentKeyCol] ?? parentKeyCol;
|
|
187
|
+
const childKeyField = targetMeta.reverseColumnMap[childKeyCol] ?? childKeyCol;
|
|
188
|
+
const keys = uniqueKeys(parents, parentKeyField);
|
|
189
|
+
const single = rel.type === 'belongsTo' || rel.type === 'hasOne';
|
|
190
|
+
if (keys.length === 0) {
|
|
191
|
+
for (const parent of parents)
|
|
192
|
+
parent[relName] = single ? null : [];
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
// The follow-up must project the child correlation key even if the caller's
|
|
196
|
+
// select/omit excluded it; strip it back off afterwards so the shape matches join.
|
|
197
|
+
const proj = includeKeysForBatching(options.select, options.omit, [childKeyField]);
|
|
198
|
+
const child = ctx.makeChild(rel.to);
|
|
199
|
+
const chunks = [];
|
|
200
|
+
for (let i = 0; i < keys.length; i += MAX_RELATION_KEYS)
|
|
201
|
+
chunks.push(keys.slice(i, i + MAX_RELATION_KEYS));
|
|
202
|
+
// Chunks run concurrently, results concatenated in chunk order. Per-relation
|
|
203
|
+
// `limit` is NOT pushed down here: `LIMIT` on a `fk = ANY($1)` query over the
|
|
204
|
+
// whole batch would cap TOTAL children, not children-per-parent. It is applied
|
|
205
|
+
// client-side per group after stitching (below).
|
|
206
|
+
const chunkResults = await Promise.all(chunks.map(async (chunk) => {
|
|
207
|
+
const deferred = child.buildFindMany({
|
|
208
|
+
where: mergeChildWhere(options.where, childKeyField, chunk),
|
|
209
|
+
select: proj.select,
|
|
210
|
+
omit: proj.omit,
|
|
211
|
+
orderBy: options.orderBy,
|
|
212
|
+
});
|
|
213
|
+
const result = await ctx.exec(deferred.sql, deferred.params, deferred.preparedName);
|
|
214
|
+
return deferred.transform(result);
|
|
215
|
+
}));
|
|
216
|
+
const allChildren = chunkResults.flat();
|
|
217
|
+
// Recurse for nested `with` BEFORE stripping keys (children carry their own keys).
|
|
218
|
+
if (options.with && allChildren.length > 0) {
|
|
219
|
+
await loadRelationsBatched({ ...ctx, parentMeta: targetMeta }, allChildren, options.with, timeout, depth + 1, [...path, relName]);
|
|
220
|
+
}
|
|
221
|
+
const byKey = groupBy(allChildren, childKeyField);
|
|
222
|
+
const limit = options.limit;
|
|
223
|
+
for (const parent of parents) {
|
|
224
|
+
const bucket = byKey.get(keyOf(parent[parentKeyField])) ?? [];
|
|
225
|
+
if (single) {
|
|
226
|
+
parent[relName] = bucket[0] ?? null;
|
|
227
|
+
}
|
|
228
|
+
else {
|
|
229
|
+
parent[relName] = limit !== undefined ? bucket.slice(0, limit) : bucket;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
stripFields(allChildren, proj.strip);
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* manyToMany: a three-hop batched loader (no join pushdown):
|
|
236
|
+
* (1) read junction rows for all parents (`sourceKey = ANY($1)` chunks),
|
|
237
|
+
* (2) read the target rows for the collected targetKeys,
|
|
238
|
+
* (3) stitch parent → junction targetKeys → target rows in memory.
|
|
239
|
+
* Composite junction/target keys fall back to the join strategy (throw E017).
|
|
240
|
+
*/
|
|
241
|
+
async function loadManyToMany(ctx, parents, rel, relName, options, timeout, depth, path) {
|
|
242
|
+
const through = rel.through;
|
|
243
|
+
if (!through) {
|
|
244
|
+
throw new ValidationError(`[turbine] manyToMany relation "${relName}" is missing its junction (\`through\`).`);
|
|
245
|
+
}
|
|
246
|
+
const sourceJ = normalizeKeyColumns(through.sourceKey);
|
|
247
|
+
const targetJ = normalizeKeyColumns(through.targetKey);
|
|
248
|
+
const sourceRef = normalizeKeyColumns(rel.referenceKey);
|
|
249
|
+
const targetMeta = requireTable(ctx.schema, rel.to, relName);
|
|
250
|
+
if (sourceJ.length > 1 || targetJ.length > 1 || sourceRef.length > 1 || targetMeta.primaryKey.length !== 1) {
|
|
251
|
+
throw new UnsupportedFeatureError('composite-key batched manyToMany loading', 'relationLoadStrategy: "batched"', `relation "${relName}" — use the default 'join' strategy for composite-key m2m relations`);
|
|
252
|
+
}
|
|
253
|
+
const sourceJCol = sourceJ[0];
|
|
254
|
+
const targetJCol = targetJ[0];
|
|
255
|
+
const sourceRefCol = sourceRef[0];
|
|
256
|
+
const targetPkCol = targetMeta.primaryKey[0];
|
|
257
|
+
const parentRefField = ctx.parentMeta.reverseColumnMap[sourceRefCol] ?? sourceRefCol;
|
|
258
|
+
const targetPkField = targetMeta.reverseColumnMap[targetPkCol] ?? targetPkCol;
|
|
259
|
+
const parentKeys = uniqueKeys(parents, parentRefField);
|
|
260
|
+
if (parentKeys.length === 0) {
|
|
261
|
+
for (const parent of parents)
|
|
262
|
+
parent[relName] = [];
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
// (1) Junction rows: sourceKeyVal → [targetKeyVal]. Raw SQL through the caller's
|
|
266
|
+
// executor (the junction table has no relations we need, so no child reader).
|
|
267
|
+
const targetsBySource = new Map();
|
|
268
|
+
const targetValSet = new Set();
|
|
269
|
+
const jTable = ctx.quote(through.table);
|
|
270
|
+
const jSource = ctx.quote(sourceJCol);
|
|
271
|
+
const jTarget = ctx.quote(targetJCol);
|
|
272
|
+
const jChunks = [];
|
|
273
|
+
for (let i = 0; i < parentKeys.length; i += MAX_RELATION_KEYS) {
|
|
274
|
+
jChunks.push(parentKeys.slice(i, i + MAX_RELATION_KEYS));
|
|
275
|
+
}
|
|
276
|
+
const jResults = await Promise.all(jChunks.map((chunk) => {
|
|
277
|
+
const params = [ctx.inClauseParam(chunk)];
|
|
278
|
+
const predicate = ctx.buildInClause(`${jTable}.${jSource}`, ctx.paramPlaceholder(1), false);
|
|
279
|
+
const sql = `SELECT ${jTable}.${jSource} AS "s", ${jTable}.${jTarget} AS "t" FROM ${jTable} WHERE ${predicate}`;
|
|
280
|
+
return ctx.exec(sql, params);
|
|
281
|
+
}));
|
|
282
|
+
for (const { rows } of jResults) {
|
|
283
|
+
for (const row of rows) {
|
|
284
|
+
const sv = keyOf(row.s);
|
|
285
|
+
const tv = row.t;
|
|
286
|
+
if (tv == null)
|
|
287
|
+
continue;
|
|
288
|
+
const bucket = targetsBySource.get(sv);
|
|
289
|
+
if (bucket)
|
|
290
|
+
bucket.push(tv);
|
|
291
|
+
else
|
|
292
|
+
targetsBySource.set(sv, [tv]);
|
|
293
|
+
targetValSet.add(tv);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
// (2) Target rows by PK, honouring the relation's own where/select/omit/orderBy.
|
|
297
|
+
const proj = includeKeysForBatching(options.select, options.omit, [targetPkField]);
|
|
298
|
+
const child = ctx.makeChild(rel.to);
|
|
299
|
+
const targetVals = [...targetValSet];
|
|
300
|
+
const tChunks = [];
|
|
301
|
+
for (let i = 0; i < targetVals.length; i += MAX_RELATION_KEYS) {
|
|
302
|
+
tChunks.push(targetVals.slice(i, i + MAX_RELATION_KEYS));
|
|
303
|
+
}
|
|
304
|
+
const tResults = await Promise.all(tChunks.map(async (chunk) => {
|
|
305
|
+
const deferred = child.buildFindMany({
|
|
306
|
+
where: mergeChildWhere(options.where, targetPkField, chunk),
|
|
307
|
+
select: proj.select,
|
|
308
|
+
omit: proj.omit,
|
|
309
|
+
orderBy: options.orderBy,
|
|
310
|
+
});
|
|
311
|
+
const result = await ctx.exec(deferred.sql, deferred.params, deferred.preparedName);
|
|
312
|
+
return deferred.transform(result);
|
|
313
|
+
}));
|
|
314
|
+
const targetsInOrder = tResults.flat();
|
|
315
|
+
// Nested `with` on the target rows (before stripping their PK).
|
|
316
|
+
if (options.with && targetsInOrder.length > 0) {
|
|
317
|
+
await loadRelationsBatched({ ...ctx, parentMeta: targetMeta }, targetsInOrder, options.with, timeout, depth + 1, [...path, relName]);
|
|
318
|
+
}
|
|
319
|
+
const targetByPk = new Map();
|
|
320
|
+
for (const t of targetsInOrder)
|
|
321
|
+
targetByPk.set(keyOf(t[targetPkField]), t);
|
|
322
|
+
// (3) Stitch. Iterate `targetsInOrder` (already ordered by the relation's
|
|
323
|
+
// orderBy) and pick the ones each parent links to, so per-parent order honours
|
|
324
|
+
// orderBy; then apply the per-relation `limit` client-side.
|
|
325
|
+
const limit = options.limit;
|
|
326
|
+
for (const parent of parents) {
|
|
327
|
+
const linked = new Set((targetsBySource.get(keyOf(parent[parentRefField])) ?? []).map(keyOf));
|
|
328
|
+
if (linked.size === 0) {
|
|
329
|
+
parent[relName] = [];
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
const out = [];
|
|
333
|
+
for (const t of targetsInOrder) {
|
|
334
|
+
if (linked.has(keyOf(t[targetPkField]))) {
|
|
335
|
+
out.push(t);
|
|
336
|
+
if (limit !== undefined && out.length >= limit)
|
|
337
|
+
break;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
parent[relName] = out;
|
|
341
|
+
}
|
|
342
|
+
stripFields(targetsInOrder, proj.strip);
|
|
343
|
+
}
|
|
344
|
+
// ---------------------------------------------------------------------------
|
|
345
|
+
// Small helpers
|
|
346
|
+
// ---------------------------------------------------------------------------
|
|
347
|
+
/** Merge the batched correlation predicate (`key IN chunk`) into the relation's own where. */
|
|
348
|
+
function mergeChildWhere(where, keyField, chunk) {
|
|
349
|
+
return { ...(where ?? {}), [keyField]: { in: chunk } };
|
|
350
|
+
}
|
|
351
|
+
/** Distinct, non-null values of `field` across `rows`. */
|
|
352
|
+
function uniqueKeys(rows, field) {
|
|
353
|
+
const seen = new Set();
|
|
354
|
+
const out = [];
|
|
355
|
+
for (const row of rows) {
|
|
356
|
+
const v = row[field];
|
|
357
|
+
if (v == null)
|
|
358
|
+
continue;
|
|
359
|
+
const k = keyOf(v);
|
|
360
|
+
if (seen.has(k))
|
|
361
|
+
continue;
|
|
362
|
+
seen.add(k);
|
|
363
|
+
out.push(v);
|
|
364
|
+
}
|
|
365
|
+
return out;
|
|
366
|
+
}
|
|
367
|
+
/** Group rows by the stringified value of `field`, preserving input order. */
|
|
368
|
+
function groupBy(rows, field) {
|
|
369
|
+
const map = new Map();
|
|
370
|
+
for (const row of rows) {
|
|
371
|
+
const k = keyOf(row[field]);
|
|
372
|
+
const bucket = map.get(k);
|
|
373
|
+
if (bucket)
|
|
374
|
+
bucket.push(row);
|
|
375
|
+
else
|
|
376
|
+
map.set(k, [row]);
|
|
377
|
+
}
|
|
378
|
+
return map;
|
|
379
|
+
}
|
|
380
|
+
/** Resolve a table's metadata or throw a clear relation error. */
|
|
381
|
+
function requireTable(schema, table, relName) {
|
|
382
|
+
const meta = schema.tables[table];
|
|
383
|
+
if (!meta)
|
|
384
|
+
throw new ValidationError(`[turbine] Relation "${relName}" targets unknown table "${table}".`);
|
|
385
|
+
return meta;
|
|
386
|
+
}
|
package/dist/query/builder.d.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
import type pg from 'pg';
|
|
14
14
|
import type { Dialect } from '../dialect.js';
|
|
15
15
|
import type { SchemaMetadata } from '../schema.js';
|
|
16
|
-
import type { AggregateArgs, AggregateResult, CountArgs, CreateArgs, CreateManyArgs, DeleteArgs, DeleteManyArgs, FindManyArgs, FindManyStreamArgs, FindUniqueArgs, GroupByArgs, QueryResult, TypedWithClause, UpdateArgs, UpdateManyArgs, UpsertArgs, WithClause } from './types.js';
|
|
16
|
+
import type { AggregateArgs, AggregateResult, CountArgs, CreateArgs, CreateManyArgs, DeleteArgs, DeleteManyArgs, FindManyArgs, FindManyStreamArgs, FindUniqueArgs, GroupByArgs, QueryResult, RelationLoadStrategy, TypedWithClause, UpdateArgs, UpdateManyArgs, UpsertArgs, WithClause } from './types.js';
|
|
17
17
|
/**
|
|
18
18
|
* Runs a SQL statement and resolves its raw result. Passed to a
|
|
19
19
|
* {@link DeferredQuery.reselect} plan so it can run the write and the follow-up
|
|
@@ -94,6 +94,26 @@ export interface QueryInterfaceOptions {
|
|
|
94
94
|
sqlCache?: boolean;
|
|
95
95
|
/** SQL dialect implementation. Defaults to PostgreSQL. */
|
|
96
96
|
dialect?: Dialect;
|
|
97
|
+
/**
|
|
98
|
+
* Interpret offset-less timestamp strings (Postgres `timestamp` without
|
|
99
|
+
* time zone, and the JSON emitted by nested-relation subqueries) as UTC.
|
|
100
|
+
* This is the Prisma/Rails/Django convention and makes results independent
|
|
101
|
+
* of the server's local time zone. Default: `true`. Set `false` to restore
|
|
102
|
+
* the pre-0.26 behavior (JS local-time interpretation).
|
|
103
|
+
*/
|
|
104
|
+
utcTimestamps?: boolean;
|
|
105
|
+
/**
|
|
106
|
+
* Client-level default relation-loading strategy for `with` clauses. Per-query
|
|
107
|
+
* `relationLoadStrategy` args override this; both default to `'join'`.
|
|
108
|
+
*/
|
|
109
|
+
relationLoadStrategy?: RelationLoadStrategy;
|
|
110
|
+
/**
|
|
111
|
+
* How nested-relation subqueries encode each row's JSON: `'object'` (default,
|
|
112
|
+
* `json_build_object`) or `'positional'` (`json_build_array`, key-less — see
|
|
113
|
+
* {@link Dialect.buildJsonArray}). Positional is Postgres-only in v1; a
|
|
114
|
+
* `with` clause on any other dialect throws `UnsupportedFeatureError` (E017).
|
|
115
|
+
*/
|
|
116
|
+
jsonEncoding?: 'object' | 'positional';
|
|
97
117
|
/** @internal Set by TransactionClient — signals that this QI runs inside an active transaction. */
|
|
98
118
|
_txScoped?: boolean;
|
|
99
119
|
/** @internal Callback from TurbineClient for query event emission. */
|
|
@@ -117,9 +137,14 @@ export declare class QueryInterface<T extends object, R extends object = {}> {
|
|
|
117
137
|
private readonly middlewares;
|
|
118
138
|
private readonly defaultLimit?;
|
|
119
139
|
private readonly warnOnUnlimited;
|
|
140
|
+
private readonly utcTimestamps;
|
|
120
141
|
private readonly preparedStatementsEnabled;
|
|
121
142
|
private readonly sqlCacheEnabled;
|
|
122
143
|
private readonly dialect;
|
|
144
|
+
/** Client-level default relation-loading strategy ('join' unless configured). */
|
|
145
|
+
private readonly relationLoadStrategy;
|
|
146
|
+
/** Nested-relation JSON encoding: 'object' (default) or 'positional'. */
|
|
147
|
+
private readonly jsonEncoding;
|
|
123
148
|
/**
|
|
124
149
|
* Tracks tables that have already triggered an unlimited-query warning so
|
|
125
150
|
* the user is not spammed once per row. Per-instance state — each
|
|
@@ -204,6 +229,34 @@ export declare class QueryInterface<T extends object, R extends object = {}> {
|
|
|
204
229
|
private inClause;
|
|
205
230
|
/** The single bound parameter for an `IN` list (PG: the array; SQLite: a JSON string). */
|
|
206
231
|
private inParam;
|
|
232
|
+
/**
|
|
233
|
+
* Resolve the effective relation-loading strategy for a query: the per-query
|
|
234
|
+
* arg wins, then the client-level default, then `'join'`. Only meaningful when
|
|
235
|
+
* a `with` clause is present; the callers gate on that.
|
|
236
|
+
*/
|
|
237
|
+
private resolveLoadStrategy;
|
|
238
|
+
/**
|
|
239
|
+
* Build the {@link RelationLoadContext} the batched loader needs, closing over
|
|
240
|
+
* this interface's pool/dialect/executor. Child readers are constructed on the
|
|
241
|
+
* SAME pool (so they join an active transaction) with `defaultLimit` cleared
|
|
242
|
+
* and unlimited-warnings silenced — a relation load must fetch every matching
|
|
243
|
+
* child, and the per-relation `limit` is applied client-side by the loader.
|
|
244
|
+
*/
|
|
245
|
+
private batchedContext;
|
|
246
|
+
/**
|
|
247
|
+
* Run a findMany with the batched strategy: execute the base query WITHOUT
|
|
248
|
+
* relation subqueries (all other clauses intact), then load each relation via
|
|
249
|
+
* one flat follow-up query and stitch client-side. Parent stitch keys the
|
|
250
|
+
* caller's `select`/`omit` excluded are added for the base query and stripped
|
|
251
|
+
* from the returned rows, so the shape matches the join strategy exactly.
|
|
252
|
+
*/
|
|
253
|
+
private runFindManyBatched;
|
|
254
|
+
/**
|
|
255
|
+
* Build the base findMany args for a batched run: drop `with`, and ensure every
|
|
256
|
+
* parent correlation key needed for stitching is projected (returning the list
|
|
257
|
+
* of keys that must be stripped from the output afterwards).
|
|
258
|
+
*/
|
|
259
|
+
private prepareBatchedBase;
|
|
207
260
|
/**
|
|
208
261
|
* Return cache hit/miss statistics for this QueryInterface instance.
|
|
209
262
|
* Useful for monitoring and benchmarking.
|
|
@@ -275,6 +328,13 @@ export declare class QueryInterface<T extends object, R extends object = {}> {
|
|
|
275
328
|
*/
|
|
276
329
|
private executeWithMiddleware;
|
|
277
330
|
findUnique<W extends TypedWithClause<R> = {}, S extends Record<string, boolean> | undefined = undefined, O extends Record<string, boolean> | undefined = undefined>(args: FindUniqueArgs<T, R, W, S, O>): Promise<QueryResult<T, R, W, S, O> | null>;
|
|
331
|
+
/**
|
|
332
|
+
* Batched-strategy findUnique: fetch the single base row without relation
|
|
333
|
+
* subqueries (adding any parent stitch keys the projection excluded), then load
|
|
334
|
+
* its relations via one follow-up query each and stitch. Mirrors the join
|
|
335
|
+
* strategy's shape for the one row.
|
|
336
|
+
*/
|
|
337
|
+
private runFindUniqueBatched;
|
|
278
338
|
buildFindUnique<W extends TypedWithClause<R> = {}>(args: FindUniqueArgs<T, R, W, Record<string, boolean> | undefined, Record<string, boolean> | undefined>): DeferredQuery<T | null>;
|
|
279
339
|
findMany<W extends TypedWithClause<R> = {}, S extends Record<string, boolean> | undefined = undefined, O extends Record<string, boolean> | undefined = undefined>(args?: FindManyArgs<T, R, W, S, O>): Promise<QueryResult<T, R, W, S, O>[]>;
|
|
280
340
|
/**
|
|
@@ -582,10 +642,67 @@ export declare class QueryInterface<T extends object, R extends object = {}> {
|
|
|
582
642
|
* memoized per table. Used so nested relation rows (camelCase keys) coerce
|
|
583
643
|
* dates the same way top-level rows do.
|
|
584
644
|
*/
|
|
645
|
+
/**
|
|
646
|
+
* Prisma-compat: a plain object on a to-one relation key —
|
|
647
|
+
* `where: { vendor: { name: { contains: 'x' } } }` — is an implicit `is`
|
|
648
|
+
* filter. Normalize it to `{ is: obj }` so all downstream handling (SQL,
|
|
649
|
+
* params, fingerprint) sees one canonical shape. To-many relations still
|
|
650
|
+
* require an explicit `some`/`every`/`none` (a bare object there is
|
|
651
|
+
* ambiguous and was never valid in Prisma either).
|
|
652
|
+
*/
|
|
653
|
+
private normalizeRelationFilter;
|
|
585
654
|
private getCamelDateFields;
|
|
586
655
|
private parseRow;
|
|
587
656
|
/** Parse a row that may contain JSON nested relation columns */
|
|
588
657
|
private parseNestedRow;
|
|
658
|
+
/**
|
|
659
|
+
* Resolve the emitted column list for a relation, honoring `select` / `omit`.
|
|
660
|
+
* Shared by {@link buildRelationSubquery} (json order) and
|
|
661
|
+
* {@link buildRelationShape} (decode key order) so they can never diverge.
|
|
662
|
+
*/
|
|
663
|
+
private resolveTargetColumns;
|
|
664
|
+
/**
|
|
665
|
+
* Render a single relation row's JSON: a keyed object (`'object'`) or a
|
|
666
|
+
* positional array (`'positional'`). The array drops the keys but keeps the
|
|
667
|
+
* exact expression order, so {@link RelationShape.keys} maps positions back.
|
|
668
|
+
*/
|
|
669
|
+
private buildJsonRow;
|
|
670
|
+
/**
|
|
671
|
+
* Build the top-level relation shapes for a `with` clause, mirroring
|
|
672
|
+
* {@link buildSelectWithRelations}: same relation iteration order, same
|
|
673
|
+
* per-relation column resolution, same nested recursion.
|
|
674
|
+
*/
|
|
675
|
+
private buildRelationShapes;
|
|
676
|
+
/**
|
|
677
|
+
* Recursively describe one relation's positional layout: the camelCase key
|
|
678
|
+
* order (scalar columns first, then nested relation slots in the same order
|
|
679
|
+
* {@link buildRelationSubquery} appends them), the nested sub-shapes, and the
|
|
680
|
+
* cardinality (single object for belongsTo/hasOne, array for the rest).
|
|
681
|
+
*/
|
|
682
|
+
private buildRelationShape;
|
|
683
|
+
/**
|
|
684
|
+
* Build the row parser for a `with` clause. In object mode this is just
|
|
685
|
+
* {@link parseNestedRow}. In positional mode it decodes each relation's
|
|
686
|
+
* positional arrays into the object form first (shapes built once, not per
|
|
687
|
+
* row), then delegates to parseNestedRow for date/snake-camel coercion.
|
|
688
|
+
*/
|
|
689
|
+
private makeNestedParser;
|
|
690
|
+
/**
|
|
691
|
+
* Return a shallow copy of a top-level row with each relation column decoded
|
|
692
|
+
* from its positional array(s) into the object representation. Only relation
|
|
693
|
+
* columns are positional — base scalar columns stay object-keyed — so the
|
|
694
|
+
* result is exactly what the object encoding would have handed parseNestedRow.
|
|
695
|
+
*/
|
|
696
|
+
private decodePositionalRelations;
|
|
697
|
+
/**
|
|
698
|
+
* Decode one relation's positional JSON value. `json_agg` returns the value as
|
|
699
|
+
* a JSON string at the top level (JSON.parse once); nested relation slots are
|
|
700
|
+
* already-parsed arrays. A `'many'` value is an array of positional arrays; a
|
|
701
|
+
* `'one'` value is a single positional array or null.
|
|
702
|
+
*/
|
|
703
|
+
private decodePositionalValue;
|
|
704
|
+
/** Map one positional array back to a keyed object using the shape's key order. */
|
|
705
|
+
private decodePositionalObject;
|
|
589
706
|
/**
|
|
590
707
|
* Build a SELECT clause that includes both base columns and nested relation subqueries.
|
|
591
708
|
*
|