turbine-orm 0.13.3 → 0.15.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/dist/adapters/cockroachdb.js +1 -1
- package/dist/adapters/index.d.ts +7 -4
- package/dist/adapters/index.js +1 -1
- package/dist/adapters/yugabytedb.js +1 -1
- package/dist/cjs/adapters/cockroachdb.js +1 -1
- package/dist/cjs/adapters/index.js +1 -1
- package/dist/cjs/adapters/yugabytedb.js +1 -1
- package/dist/cjs/cli/studio.js +45 -7
- package/dist/cjs/client.js +48 -1
- package/dist/cjs/dialect.js +6 -4
- package/dist/cjs/errors.js +44 -1
- package/dist/cjs/generate.js +95 -1
- package/dist/cjs/index.js +10 -1
- package/dist/cjs/introspect.js +14 -4
- package/dist/cjs/nested-write.js +467 -0
- package/dist/cjs/query/builder.js +212 -16
- package/dist/cli/studio.d.ts +10 -2
- package/dist/cli/studio.js +45 -7
- package/dist/client.d.ts +23 -0
- package/dist/client.js +47 -1
- package/dist/dialect.d.ts +3 -3
- package/dist/dialect.js +6 -4
- package/dist/errors.d.ts +23 -0
- package/dist/errors.js +41 -0
- package/dist/generate.js +95 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.js +4 -2
- package/dist/introspect.js +15 -5
- package/dist/nested-write.d.ts +95 -0
- package/dist/nested-write.js +461 -0
- package/dist/query/builder.d.ts +28 -12
- package/dist/query/builder.js +180 -17
- package/dist/query/index.d.ts +1 -1
- package/dist/query/types.d.ts +76 -8
- package/dist/schema.d.ts +9 -3
- package/package.json +2 -2
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* turbine-orm — Nested write engine
|
|
3
|
+
*
|
|
4
|
+
* Tree-walking create/update that resolves relation fields in `data` into
|
|
5
|
+
* batched SQL operations within a transaction. Supports create, connect,
|
|
6
|
+
* connectOrCreate, disconnect, set, and delete on related records at
|
|
7
|
+
* arbitrary depth (capped at 10).
|
|
8
|
+
*
|
|
9
|
+
* This module is imported by `query/builder.ts` when the `data` argument
|
|
10
|
+
* of `create()` or `update()` contains relation fields. It never imports
|
|
11
|
+
* `client.ts` directly — the transaction handle is passed in via
|
|
12
|
+
* `NestedWriteContext`.
|
|
13
|
+
*/
|
|
14
|
+
import { CircularRelationError, RelationError, ValidationError } from './errors.js';
|
|
15
|
+
import { normalizeKeyColumns } from './schema.js';
|
|
16
|
+
const MAX_DEPTH = 10;
|
|
17
|
+
const CREATE_ONLY_OPS = new Set(['create', 'connect', 'connectOrCreate']);
|
|
18
|
+
const UPDATE_ONLY_OPS = new Set(['disconnect', 'set', 'delete']);
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Pure helpers (exported for testing)
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
/**
|
|
23
|
+
* Separates scalar data fields from relation operation fields.
|
|
24
|
+
*
|
|
25
|
+
* A key is treated as a relation field only when:
|
|
26
|
+
* 1. It matches a relation name in `tableMeta.relations`
|
|
27
|
+
* 2. Its value is a non-null, non-array, non-Date plain object
|
|
28
|
+
*
|
|
29
|
+
* Everything else goes into `scalars`.
|
|
30
|
+
*/
|
|
31
|
+
export function extractRelationFields(data, tableMeta) {
|
|
32
|
+
const scalars = {};
|
|
33
|
+
const relations = {};
|
|
34
|
+
for (const [key, value] of Object.entries(data)) {
|
|
35
|
+
if (key in tableMeta.relations &&
|
|
36
|
+
value !== null &&
|
|
37
|
+
typeof value === 'object' &&
|
|
38
|
+
!Array.isArray(value) &&
|
|
39
|
+
!(value instanceof Date)) {
|
|
40
|
+
relations[key] = value;
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
scalars[key] = value;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return { scalars, relations };
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Quick check: does `data` contain any relation fields that would trigger
|
|
50
|
+
* the nested write path? Used by QueryInterface to decide whether to
|
|
51
|
+
* delegate to the nested write engine or take the fast scalar-only path.
|
|
52
|
+
*/
|
|
53
|
+
export function hasRelationFields(data, tableMeta) {
|
|
54
|
+
for (const key of Object.keys(data)) {
|
|
55
|
+
if (key in tableMeta.relations) {
|
|
56
|
+
const val = data[key];
|
|
57
|
+
if (val !== null && typeof val === 'object' && !Array.isArray(val) && !(val instanceof Date)) {
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Inject the parent row's PK value(s) as FK field(s) into child data.
|
|
66
|
+
* Handles composite keys. Returns a new object (does not mutate input).
|
|
67
|
+
*/
|
|
68
|
+
export function injectForeignKey(childData, relation, parentRow, schema) {
|
|
69
|
+
const fks = normalizeKeyColumns(relation.foreignKey);
|
|
70
|
+
const refs = normalizeKeyColumns(relation.referenceKey);
|
|
71
|
+
const childTable = schema.tables[relation.to];
|
|
72
|
+
const result = { ...childData };
|
|
73
|
+
for (let i = 0; i < fks.length; i++) {
|
|
74
|
+
const fkCol = fks[i];
|
|
75
|
+
const refCol = refs[i];
|
|
76
|
+
const refField = schema.tables[relation.from]?.reverseColumnMap[refCol] ?? refCol;
|
|
77
|
+
const fkField = childTable?.reverseColumnMap[fkCol] ?? fkCol;
|
|
78
|
+
result[fkField] = parentRow[refField];
|
|
79
|
+
}
|
|
80
|
+
return result;
|
|
81
|
+
}
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
// Internal helpers
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
function toArray(value) {
|
|
86
|
+
return Array.isArray(value) ? value : [value];
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Validate that all operation keys in a nested write are recognized and
|
|
90
|
+
* allowed for the current context (create vs update).
|
|
91
|
+
*/
|
|
92
|
+
function validateOps(relationName, ops, isUpdate) {
|
|
93
|
+
for (const opName of Object.keys(ops)) {
|
|
94
|
+
if (!CREATE_ONLY_OPS.has(opName) && !UPDATE_ONLY_OPS.has(opName)) {
|
|
95
|
+
throw new ValidationError(`[turbine] Unknown nested write operation "${opName}" on relation "${relationName}". ` +
|
|
96
|
+
`Valid operations: create, connect, connectOrCreate${isUpdate ? ', disconnect, set, delete' : ''}.`);
|
|
97
|
+
}
|
|
98
|
+
if (!isUpdate && UPDATE_ONLY_OPS.has(opName)) {
|
|
99
|
+
throw new ValidationError(`[turbine] Operation "${opName}" on relation "${relationName}" is only valid inside update(), not create().`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Build a PK-based where clause from a parent row and its table metadata.
|
|
105
|
+
*/
|
|
106
|
+
function pkWhere(tableMeta, row) {
|
|
107
|
+
const where = {};
|
|
108
|
+
for (const col of tableMeta.primaryKey) {
|
|
109
|
+
const field = tableMeta.reverseColumnMap[col] ?? col;
|
|
110
|
+
where[field] = row[field];
|
|
111
|
+
}
|
|
112
|
+
return where;
|
|
113
|
+
}
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
// executeNestedCreate
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
/**
|
|
118
|
+
* Tree-walking create: inserts the parent row, then processes each relation
|
|
119
|
+
* operation (create, connect, connectOrCreate), and finally reads back the
|
|
120
|
+
* full tree using `findUnique` with an auto-built `with` clause.
|
|
121
|
+
*/
|
|
122
|
+
export async function executeNestedCreate(ctx, tableName, data, depth = 0, path = []) {
|
|
123
|
+
if (depth > MAX_DEPTH) {
|
|
124
|
+
throw new CircularRelationError(path);
|
|
125
|
+
}
|
|
126
|
+
const tableMeta = ctx.schema.tables[tableName];
|
|
127
|
+
if (!tableMeta) {
|
|
128
|
+
throw new ValidationError(`[turbine] Unknown table "${tableName}".`);
|
|
129
|
+
}
|
|
130
|
+
const { scalars, relations } = extractRelationFields(data, tableMeta);
|
|
131
|
+
// Validate all relation operations
|
|
132
|
+
for (const [relName, ops] of Object.entries(relations)) {
|
|
133
|
+
const rel = tableMeta.relations[relName];
|
|
134
|
+
if (!rel) {
|
|
135
|
+
throw new RelationError(`[turbine] Unknown relation "${relName}" on table "${tableName}". ` +
|
|
136
|
+
`Available relations: ${Object.keys(tableMeta.relations).join(', ') || '(none)'}.`);
|
|
137
|
+
}
|
|
138
|
+
validateOps(relName, ops, false);
|
|
139
|
+
}
|
|
140
|
+
// Insert the parent row
|
|
141
|
+
const parentRow = (await ctx.tx.table(tableName).create({ data: scalars }));
|
|
142
|
+
// Process each relation
|
|
143
|
+
for (const [relName, ops] of Object.entries(relations)) {
|
|
144
|
+
const rel = tableMeta.relations[relName];
|
|
145
|
+
if (rel.type === 'hasMany' || rel.type === 'hasOne') {
|
|
146
|
+
await processHasManyCreate(ctx, rel, ops, parentRow, depth, path, relName);
|
|
147
|
+
}
|
|
148
|
+
else if (rel.type === 'belongsTo') {
|
|
149
|
+
await processBelongsToCreate(ctx, rel, ops, parentRow, tableName, depth, path, relName);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
// Build the `with` clause for the final read to return the full tree
|
|
153
|
+
const withClause = {};
|
|
154
|
+
for (const relName of Object.keys(relations)) {
|
|
155
|
+
withClause[relName] = true;
|
|
156
|
+
}
|
|
157
|
+
// Final read using existing json_agg machinery
|
|
158
|
+
const fullRow = await ctx.tx.table(tableName).findUnique({
|
|
159
|
+
where: pkWhere(tableMeta, parentRow),
|
|
160
|
+
with: Object.keys(withClause).length > 0 ? withClause : undefined,
|
|
161
|
+
});
|
|
162
|
+
return (fullRow ?? parentRow);
|
|
163
|
+
}
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
// executeNestedUpdate
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
/**
|
|
168
|
+
* Tree-walking update: updates the parent row with scalar data, then
|
|
169
|
+
* processes each relation operation (create, connect, connectOrCreate,
|
|
170
|
+
* disconnect, set, delete), and reads back the full tree.
|
|
171
|
+
*/
|
|
172
|
+
export async function executeNestedUpdate(ctx, tableName, where, data, depth = 0, path = []) {
|
|
173
|
+
if (depth > MAX_DEPTH) {
|
|
174
|
+
throw new CircularRelationError(path);
|
|
175
|
+
}
|
|
176
|
+
const tableMeta = ctx.schema.tables[tableName];
|
|
177
|
+
if (!tableMeta) {
|
|
178
|
+
throw new ValidationError(`[turbine] Unknown table "${tableName}".`);
|
|
179
|
+
}
|
|
180
|
+
const { scalars, relations } = extractRelationFields(data, tableMeta);
|
|
181
|
+
// Validate all relation operations
|
|
182
|
+
for (const [relName, ops] of Object.entries(relations)) {
|
|
183
|
+
const rel = tableMeta.relations[relName];
|
|
184
|
+
if (!rel) {
|
|
185
|
+
throw new RelationError(`[turbine] Unknown relation "${relName}" on table "${tableName}". ` +
|
|
186
|
+
`Available relations: ${Object.keys(tableMeta.relations).join(', ') || '(none)'}.`);
|
|
187
|
+
}
|
|
188
|
+
validateOps(relName, ops, true);
|
|
189
|
+
}
|
|
190
|
+
// Update parent row with scalar data (may be empty if only relation ops)
|
|
191
|
+
let parentRow;
|
|
192
|
+
if (Object.keys(scalars).length > 0) {
|
|
193
|
+
parentRow = (await ctx.tx.table(tableName).update({ where, data: scalars }));
|
|
194
|
+
}
|
|
195
|
+
else {
|
|
196
|
+
parentRow = (await ctx.tx.table(tableName).findUnique({ where }));
|
|
197
|
+
if (!parentRow) {
|
|
198
|
+
throw new ValidationError(`[turbine] update: no ${tableName} row found matching ${JSON.stringify(where)}.`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
// Process each relation
|
|
202
|
+
for (const [relName, ops] of Object.entries(relations)) {
|
|
203
|
+
const rel = tableMeta.relations[relName];
|
|
204
|
+
if (rel.type === 'hasMany' || rel.type === 'hasOne') {
|
|
205
|
+
// create, connect, connectOrCreate — same as nested create
|
|
206
|
+
await processHasManyCreate(ctx, rel, ops, parentRow, depth, path, relName);
|
|
207
|
+
// disconnect
|
|
208
|
+
if (ops.disconnect !== undefined) {
|
|
209
|
+
await processDisconnect(ctx, rel, ops.disconnect, relName);
|
|
210
|
+
}
|
|
211
|
+
// set
|
|
212
|
+
if (ops.set !== undefined) {
|
|
213
|
+
await processSet(ctx, rel, ops.set, parentRow);
|
|
214
|
+
}
|
|
215
|
+
// delete
|
|
216
|
+
if (ops.delete !== undefined) {
|
|
217
|
+
await processDelete(ctx, rel, ops.delete);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
else if (rel.type === 'belongsTo') {
|
|
221
|
+
await processBelongsToCreate(ctx, rel, ops, parentRow, tableName, depth, path, relName);
|
|
222
|
+
if (ops.disconnect !== undefined) {
|
|
223
|
+
// For belongsTo disconnect, null out the FK on the parent
|
|
224
|
+
const fks = normalizeKeyColumns(rel.foreignKey);
|
|
225
|
+
const nullable = fks.every((fk) => {
|
|
226
|
+
const col = tableMeta.columns.find((c) => c.name === fk);
|
|
227
|
+
return col?.nullable ?? false;
|
|
228
|
+
});
|
|
229
|
+
if (!nullable) {
|
|
230
|
+
throw new ValidationError(`[turbine] Cannot disconnect "${relName}": foreign key column(s) ${fks.join(', ')} are NOT NULL. Use delete instead.`);
|
|
231
|
+
}
|
|
232
|
+
const updateData = {};
|
|
233
|
+
for (const fk of fks) {
|
|
234
|
+
const field = tableMeta.reverseColumnMap[fk] ?? fk;
|
|
235
|
+
updateData[field] = null;
|
|
236
|
+
}
|
|
237
|
+
await ctx.tx.table(tableName).update({
|
|
238
|
+
where: pkWhere(tableMeta, parentRow),
|
|
239
|
+
data: updateData,
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
// Final read with all touched relations
|
|
245
|
+
const withClause = {};
|
|
246
|
+
for (const relName of Object.keys(relations)) {
|
|
247
|
+
withClause[relName] = true;
|
|
248
|
+
}
|
|
249
|
+
const fullRow = await ctx.tx.table(tableName).findUnique({
|
|
250
|
+
where: pkWhere(tableMeta, parentRow),
|
|
251
|
+
with: Object.keys(withClause).length > 0 ? withClause : undefined,
|
|
252
|
+
});
|
|
253
|
+
return (fullRow ?? parentRow);
|
|
254
|
+
}
|
|
255
|
+
// ---------------------------------------------------------------------------
|
|
256
|
+
// hasMany/hasOne create operations
|
|
257
|
+
// ---------------------------------------------------------------------------
|
|
258
|
+
async function processHasManyCreate(ctx, rel, ops, parentRow, depth, path, relName) {
|
|
259
|
+
// create
|
|
260
|
+
if (ops.create !== undefined) {
|
|
261
|
+
const items = toArray(ops.create);
|
|
262
|
+
if (items.length > 0) {
|
|
263
|
+
// Check if any items have nested relations (need per-row recursion)
|
|
264
|
+
const childTable = ctx.schema.tables[rel.to];
|
|
265
|
+
const hasNested = childTable && items.some((item) => Object.keys(item).some((k) => k in (childTable.relations ?? {})));
|
|
266
|
+
if (hasNested) {
|
|
267
|
+
// Per-row recursive create for items with nested relations
|
|
268
|
+
for (const item of items) {
|
|
269
|
+
const injected = injectForeignKey(item, rel, parentRow, ctx.schema);
|
|
270
|
+
await executeNestedCreate(ctx, rel.to, injected, depth + 1, [...path, relName]);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
else {
|
|
274
|
+
// Batch via createMany (UNNEST) — fast path
|
|
275
|
+
const injected = items.map((item) => injectForeignKey(item, rel, parentRow, ctx.schema));
|
|
276
|
+
await ctx.tx.table(rel.to).createMany({ data: injected });
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
// connect
|
|
281
|
+
if (ops.connect !== undefined) {
|
|
282
|
+
const items = toArray(ops.connect);
|
|
283
|
+
if (items.length > 0) {
|
|
284
|
+
await batchConnect(ctx, rel, items, parentRow);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
// connectOrCreate
|
|
288
|
+
if (ops.connectOrCreate !== undefined) {
|
|
289
|
+
const items = toArray(ops.connectOrCreate);
|
|
290
|
+
for (const item of items) {
|
|
291
|
+
const op = item;
|
|
292
|
+
await connectOrCreate(ctx, rel, op, parentRow);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
// ---------------------------------------------------------------------------
|
|
297
|
+
// belongsTo create operations
|
|
298
|
+
// ---------------------------------------------------------------------------
|
|
299
|
+
async function processBelongsToCreate(ctx, rel, ops, parentRow, parentTable, depth, path, relName) {
|
|
300
|
+
const fks = normalizeKeyColumns(rel.foreignKey);
|
|
301
|
+
const refs = normalizeKeyColumns(rel.referenceKey);
|
|
302
|
+
// create — insert the related row, then update parent's FK
|
|
303
|
+
if (ops.create !== undefined) {
|
|
304
|
+
const items = toArray(ops.create);
|
|
305
|
+
if (items.length > 0) {
|
|
306
|
+
const relatedRow = (await executeNestedCreate(ctx, rel.to, items[0], depth + 1, [...path, relName]));
|
|
307
|
+
const updateData = {};
|
|
308
|
+
const relatedTable = ctx.schema.tables[rel.to];
|
|
309
|
+
for (let i = 0; i < fks.length; i++) {
|
|
310
|
+
const fkField = ctx.schema.tables[parentTable]?.reverseColumnMap[fks[i]] ?? fks[i];
|
|
311
|
+
const refField = relatedTable?.reverseColumnMap[refs[i]] ?? refs[i];
|
|
312
|
+
updateData[fkField] = relatedRow[refField];
|
|
313
|
+
}
|
|
314
|
+
const parentMeta = ctx.schema.tables[parentTable];
|
|
315
|
+
await ctx.tx.table(parentTable).update({
|
|
316
|
+
where: pkWhere(parentMeta, parentRow),
|
|
317
|
+
data: updateData,
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
// connect — validate existence, update parent's FK
|
|
322
|
+
if (ops.connect !== undefined) {
|
|
323
|
+
const items = toArray(ops.connect);
|
|
324
|
+
if (items.length > 0) {
|
|
325
|
+
const target = items[0];
|
|
326
|
+
const existing = await ctx.tx.table(rel.to).findUnique({ where: target });
|
|
327
|
+
if (!existing) {
|
|
328
|
+
throw new ValidationError(`[turbine] connect on "${relName}": no ${rel.to} row found matching ${JSON.stringify(target)}.`);
|
|
329
|
+
}
|
|
330
|
+
const updateData = {};
|
|
331
|
+
const relatedTable = ctx.schema.tables[rel.to];
|
|
332
|
+
for (let i = 0; i < fks.length; i++) {
|
|
333
|
+
const fkField = ctx.schema.tables[parentTable]?.reverseColumnMap[fks[i]] ?? fks[i];
|
|
334
|
+
const refField = relatedTable?.reverseColumnMap[refs[i]] ?? refs[i];
|
|
335
|
+
updateData[fkField] = existing[refField];
|
|
336
|
+
}
|
|
337
|
+
const parentMeta = ctx.schema.tables[parentTable];
|
|
338
|
+
await ctx.tx.table(parentTable).update({
|
|
339
|
+
where: pkWhere(parentMeta, parentRow),
|
|
340
|
+
data: updateData,
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
// ---------------------------------------------------------------------------
|
|
346
|
+
// connect, connectOrCreate, disconnect, set, delete helpers
|
|
347
|
+
// ---------------------------------------------------------------------------
|
|
348
|
+
async function batchConnect(ctx, rel, items, parentRow) {
|
|
349
|
+
const fks = normalizeKeyColumns(rel.foreignKey);
|
|
350
|
+
const refs = normalizeKeyColumns(rel.referenceKey);
|
|
351
|
+
const childTable = ctx.schema.tables[rel.to];
|
|
352
|
+
if (!childTable)
|
|
353
|
+
return;
|
|
354
|
+
// Validate all targets exist
|
|
355
|
+
for (const target of items) {
|
|
356
|
+
const existing = await ctx.tx.table(rel.to).findUnique({ where: target });
|
|
357
|
+
if (!existing) {
|
|
358
|
+
throw new ValidationError(`[turbine] connect: no ${rel.to} row found matching ${JSON.stringify(target)}.`);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
// Build FK update data to point children at parent
|
|
362
|
+
const updateData = {};
|
|
363
|
+
for (let i = 0; i < fks.length; i++) {
|
|
364
|
+
const fkField = childTable.reverseColumnMap[fks[i]] ?? fks[i];
|
|
365
|
+
const refField = ctx.schema.tables[rel.from]?.reverseColumnMap[refs[i]] ?? refs[i];
|
|
366
|
+
updateData[fkField] = parentRow[refField];
|
|
367
|
+
}
|
|
368
|
+
// Update each matching child
|
|
369
|
+
for (const target of items) {
|
|
370
|
+
await ctx.tx.table(rel.to).update({ where: target, data: updateData });
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
async function connectOrCreate(ctx, rel, op, parentRow) {
|
|
374
|
+
const fks = normalizeKeyColumns(rel.foreignKey);
|
|
375
|
+
const refs = normalizeKeyColumns(rel.referenceKey);
|
|
376
|
+
const childTable = ctx.schema.tables[rel.to];
|
|
377
|
+
if (!childTable)
|
|
378
|
+
return;
|
|
379
|
+
// Try to find existing
|
|
380
|
+
let row = (await ctx.tx.table(rel.to).findUnique({ where: op.where }));
|
|
381
|
+
if (!row) {
|
|
382
|
+
// Create with FK injected
|
|
383
|
+
const injected = injectForeignKey(op.create, rel, parentRow, ctx.schema);
|
|
384
|
+
row = (await ctx.tx.table(rel.to).create({ data: injected }));
|
|
385
|
+
}
|
|
386
|
+
else {
|
|
387
|
+
// Update FK to point to parent
|
|
388
|
+
const updateData = {};
|
|
389
|
+
for (let i = 0; i < fks.length; i++) {
|
|
390
|
+
const fkField = childTable.reverseColumnMap[fks[i]] ?? fks[i];
|
|
391
|
+
const refField = ctx.schema.tables[rel.from]?.reverseColumnMap[refs[i]] ?? refs[i];
|
|
392
|
+
updateData[fkField] = parentRow[refField];
|
|
393
|
+
}
|
|
394
|
+
await ctx.tx.table(rel.to).update({ where: op.where, data: updateData });
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
async function processDisconnect(ctx, rel, disconnectArg, relName) {
|
|
398
|
+
const fks = normalizeKeyColumns(rel.foreignKey);
|
|
399
|
+
const childTable = ctx.schema.tables[rel.to];
|
|
400
|
+
if (!childTable)
|
|
401
|
+
return;
|
|
402
|
+
// Check FK nullability
|
|
403
|
+
const nullable = fks.every((fk) => {
|
|
404
|
+
const col = childTable.columns.find((c) => c.name === fk);
|
|
405
|
+
return col?.nullable ?? false;
|
|
406
|
+
});
|
|
407
|
+
if (!nullable) {
|
|
408
|
+
throw new ValidationError(`[turbine] Cannot disconnect "${relName}": foreign key column(s) ${fks.join(', ')} on "${rel.to}" are NOT NULL. Use delete instead.`);
|
|
409
|
+
}
|
|
410
|
+
const items = toArray(disconnectArg);
|
|
411
|
+
const nullData = {};
|
|
412
|
+
for (const fk of fks) {
|
|
413
|
+
const field = childTable.reverseColumnMap[fk] ?? fk;
|
|
414
|
+
nullData[field] = null;
|
|
415
|
+
}
|
|
416
|
+
for (const target of items) {
|
|
417
|
+
await ctx.tx.table(rel.to).update({ where: target, data: nullData });
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
async function processSet(ctx, rel, setItems, parentRow) {
|
|
421
|
+
const fks = normalizeKeyColumns(rel.foreignKey);
|
|
422
|
+
const refs = normalizeKeyColumns(rel.referenceKey);
|
|
423
|
+
const childTable = ctx.schema.tables[rel.to];
|
|
424
|
+
if (!childTable)
|
|
425
|
+
return;
|
|
426
|
+
// Build parent FK match for finding current children
|
|
427
|
+
const parentWhere = {};
|
|
428
|
+
for (let i = 0; i < fks.length; i++) {
|
|
429
|
+
const fkField = childTable.reverseColumnMap[fks[i]] ?? fks[i];
|
|
430
|
+
const refField = ctx.schema.tables[rel.from]?.reverseColumnMap[refs[i]] ?? refs[i];
|
|
431
|
+
parentWhere[fkField] = parentRow[refField];
|
|
432
|
+
}
|
|
433
|
+
// Disconnect all current children
|
|
434
|
+
const nullData = {};
|
|
435
|
+
for (const fk of fks) {
|
|
436
|
+
const field = childTable.reverseColumnMap[fk] ?? fk;
|
|
437
|
+
nullData[field] = null;
|
|
438
|
+
}
|
|
439
|
+
await ctx.tx.table(rel.to).updateMany({
|
|
440
|
+
where: parentWhere,
|
|
441
|
+
data: nullData,
|
|
442
|
+
allowFullTableScan: true,
|
|
443
|
+
});
|
|
444
|
+
// Connect the specified items
|
|
445
|
+
const updateData = {};
|
|
446
|
+
for (let i = 0; i < fks.length; i++) {
|
|
447
|
+
const fkField = childTable.reverseColumnMap[fks[i]] ?? fks[i];
|
|
448
|
+
const refField = ctx.schema.tables[rel.from]?.reverseColumnMap[refs[i]] ?? refs[i];
|
|
449
|
+
updateData[fkField] = parentRow[refField];
|
|
450
|
+
}
|
|
451
|
+
for (const target of setItems) {
|
|
452
|
+
await ctx.tx.table(rel.to).update({ where: target, data: updateData });
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
async function processDelete(ctx, rel, deleteArg) {
|
|
456
|
+
const items = toArray(deleteArg);
|
|
457
|
+
for (const target of items) {
|
|
458
|
+
await ctx.tx.table(rel.to).delete({ where: target });
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
//# sourceMappingURL=nested-write.js.map
|
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, TypedWithClause, UpdateArgs, UpdateManyArgs, UpsertArgs, WithClause
|
|
16
|
+
import type { AggregateArgs, AggregateResult, CountArgs, CreateArgs, CreateManyArgs, DeleteArgs, DeleteManyArgs, FindManyArgs, FindManyStreamArgs, FindUniqueArgs, GroupByArgs, QueryResult, TypedWithClause, UpdateArgs, UpdateManyArgs, UpsertArgs, WithClause } from './types.js';
|
|
17
17
|
export interface DeferredQuery<T> {
|
|
18
18
|
/** SQL text with $1, $2 placeholders */
|
|
19
19
|
sql: string;
|
|
@@ -67,6 +67,8 @@ export interface QueryInterfaceOptions {
|
|
|
67
67
|
sqlCache?: boolean;
|
|
68
68
|
/** SQL dialect implementation. Defaults to PostgreSQL. */
|
|
69
69
|
dialect?: Dialect;
|
|
70
|
+
/** @internal Set by TransactionClient — signals that this QI runs inside an active transaction. */
|
|
71
|
+
_txScoped?: boolean;
|
|
70
72
|
}
|
|
71
73
|
export declare class QueryInterface<T extends object, R extends object = {}> {
|
|
72
74
|
private readonly pool;
|
|
@@ -98,6 +100,10 @@ export declare class QueryInterface<T extends object, R extends object = {}> {
|
|
|
98
100
|
private readonly columnArrayTypeMap;
|
|
99
101
|
/** Tracks tables that have already triggered a deep-with warning (one-time) */
|
|
100
102
|
private readonly deepWithWarned;
|
|
103
|
+
/** True when this QI runs inside an active transaction (set via _txScoped option). */
|
|
104
|
+
private readonly txScoped;
|
|
105
|
+
/** Original options reference — forwarded to child QIs in nested writes. */
|
|
106
|
+
private readonly options?;
|
|
101
107
|
constructor(pool: pg.Pool, table: string, schema: SchemaMetadata, middlewares?: MiddlewareFn[], options?: QueryInterfaceOptions);
|
|
102
108
|
/** Quote an identifier through the active SQL dialect. */
|
|
103
109
|
private q;
|
|
@@ -143,9 +149,9 @@ export declare class QueryInterface<T extends object, R extends object = {}> {
|
|
|
143
149
|
* To intercept queries before SQL generation, use the raw() method instead.
|
|
144
150
|
*/
|
|
145
151
|
private executeWithMiddleware;
|
|
146
|
-
findUnique<W extends TypedWithClause<R> = {}>(args: FindUniqueArgs<T, R, W>): Promise<
|
|
147
|
-
buildFindUnique<W extends TypedWithClause<R> = {}>(args: FindUniqueArgs<T, R, W>): DeferredQuery<T | null>;
|
|
148
|
-
findMany<W extends TypedWithClause<R> = {}>(args?: FindManyArgs<T, R, W>): Promise<
|
|
152
|
+
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>;
|
|
153
|
+
buildFindUnique<W extends TypedWithClause<R> = {}>(args: FindUniqueArgs<T, R, W, Record<string, boolean> | undefined, Record<string, boolean> | undefined>): DeferredQuery<T | null>;
|
|
154
|
+
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>[]>;
|
|
149
155
|
/**
|
|
150
156
|
* Emit a one-time `console.warn` when {@link findMany} is called without an
|
|
151
157
|
* explicit `limit`/`take` and `warnOnUnlimited` has not been disabled.
|
|
@@ -162,7 +168,7 @@ export declare class QueryInterface<T extends object, R extends object = {}> {
|
|
|
162
168
|
* Used by the dev-only deep-with warning guard.
|
|
163
169
|
*/
|
|
164
170
|
private measureWithDepth;
|
|
165
|
-
buildFindMany<W extends TypedWithClause<R> = {}>(args?: FindManyArgs<T, R, W>): DeferredQuery<T[]>;
|
|
171
|
+
buildFindMany<W extends TypedWithClause<R> = {}>(args?: FindManyArgs<T, R, W, Record<string, boolean> | undefined, Record<string, boolean> | undefined>): DeferredQuery<T[]>;
|
|
166
172
|
/**
|
|
167
173
|
* Stream rows from a findMany query using PostgreSQL cursors.
|
|
168
174
|
* Returns an AsyncIterable that yields individual rows, fetching in batches internally.
|
|
@@ -190,19 +196,24 @@ export declare class QueryInterface<T extends object, R extends object = {}> {
|
|
|
190
196
|
* }
|
|
191
197
|
* ```
|
|
192
198
|
*/
|
|
193
|
-
findManyStream<W extends TypedWithClause<R> = {}>(args?: FindManyStreamArgs<T, R, W>): AsyncGenerator<
|
|
194
|
-
findFirst<W extends TypedWithClause<R> = {}>(args?: FindManyArgs<T, R, W>): Promise<
|
|
195
|
-
buildFindFirst<W extends TypedWithClause<R> = {}>(args?: FindManyArgs<T, R, W>): DeferredQuery<T | null>;
|
|
196
|
-
findFirstOrThrow<W extends TypedWithClause<R> = {}>(args?: FindManyArgs<T, R, W>): Promise<
|
|
197
|
-
buildFindFirstOrThrow<W extends TypedWithClause<R> = {}>(args?: FindManyArgs<T, R, W>): DeferredQuery<T>;
|
|
198
|
-
findUniqueOrThrow<W extends TypedWithClause<R> = {}>(args: FindUniqueArgs<T, R, W>): Promise<
|
|
199
|
-
buildFindUniqueOrThrow<W extends TypedWithClause<R> = {}>(args: FindUniqueArgs<T, R, W>): DeferredQuery<T>;
|
|
199
|
+
findManyStream<W extends TypedWithClause<R> = {}, S extends Record<string, boolean> | undefined = undefined, O extends Record<string, boolean> | undefined = undefined>(args?: FindManyStreamArgs<T, R, W, S, O>): AsyncGenerator<QueryResult<T, R, W, S, O>, void, undefined>;
|
|
200
|
+
findFirst<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> | null>;
|
|
201
|
+
buildFindFirst<W extends TypedWithClause<R> = {}>(args?: FindManyArgs<T, R, W, Record<string, boolean> | undefined, Record<string, boolean> | undefined>): DeferredQuery<T | null>;
|
|
202
|
+
findFirstOrThrow<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>>;
|
|
203
|
+
buildFindFirstOrThrow<W extends TypedWithClause<R> = {}>(args?: FindManyArgs<T, R, W, Record<string, boolean> | undefined, Record<string, boolean> | undefined>): DeferredQuery<T>;
|
|
204
|
+
findUniqueOrThrow<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>>;
|
|
205
|
+
buildFindUniqueOrThrow<W extends TypedWithClause<R> = {}>(args: FindUniqueArgs<T, R, W, Record<string, boolean> | undefined, Record<string, boolean> | undefined>): DeferredQuery<T>;
|
|
200
206
|
create(args: CreateArgs<T>): Promise<T>;
|
|
201
207
|
buildCreate(args: CreateArgs<T>): DeferredQuery<T>;
|
|
202
208
|
createMany(args: CreateManyArgs<T>): Promise<T[]>;
|
|
203
209
|
buildCreateMany(args: CreateManyArgs<T>): DeferredQuery<T[]>;
|
|
204
210
|
update(args: UpdateArgs<T>): Promise<T>;
|
|
205
211
|
buildUpdate(args: UpdateArgs<T>): DeferredQuery<T>;
|
|
212
|
+
private nestedCreate;
|
|
213
|
+
private nestedUpdate;
|
|
214
|
+
private runInImplicitTx;
|
|
215
|
+
private buildNestedCtx;
|
|
216
|
+
private makeTxProxy;
|
|
206
217
|
delete(args: DeleteArgs<T>): Promise<T>;
|
|
207
218
|
buildDelete(args: DeleteArgs<T>): DeferredQuery<T>;
|
|
208
219
|
upsert(args: UpsertArgs<T>): Promise<T>;
|
|
@@ -502,6 +513,11 @@ export declare class QueryInterface<T extends object, R extends object = {}> {
|
|
|
502
513
|
* Supports: has, hasEvery, hasSome, isEmpty.
|
|
503
514
|
*/
|
|
504
515
|
private buildArrayFilterClauses;
|
|
516
|
+
/**
|
|
517
|
+
* Build SQL clause for full-text search using to_tsvector @@ to_tsquery.
|
|
518
|
+
* The config name is validated to prevent injection (only alphanumeric + underscore).
|
|
519
|
+
*/
|
|
520
|
+
private buildTextSearchClause;
|
|
505
521
|
/**
|
|
506
522
|
* Get the Postgres array type for a column (used by UNNEST in createMany).
|
|
507
523
|
* Uses pre-computed Map for O(1) lookup instead of linear scan.
|