turbine-orm 0.49.0 → 0.50.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +122 -39
- package/dist/cjs/adapters/cockroachdb.d.ts +39 -0
- package/dist/cjs/adapters/index.d.ts +110 -0
- package/dist/cjs/adapters/yugabytedb.d.ts +51 -0
- package/dist/cjs/cli/config.d.ts +181 -0
- package/dist/cjs/cli/config.js +32 -6
- package/dist/cjs/cli/destructive.d.ts +38 -0
- package/dist/cjs/cli/index.d.ts +359 -0
- package/dist/cjs/cli/index.js +228 -56
- package/dist/cjs/cli/loader.d.ts +61 -0
- package/dist/cjs/cli/mcp.d.ts +42 -0
- package/dist/cjs/cli/migrate.d.ts +356 -0
- package/dist/cjs/cli/migrate.js +131 -40
- package/dist/cjs/cli/observe-ui.d.ts +1 -0
- package/dist/cjs/cli/observe-ui.js +14 -5
- package/dist/cjs/cli/observe.d.ts +25 -0
- package/dist/cjs/cli/observe.js +49 -12
- package/dist/cjs/cli/pii-tags.d.ts +53 -0
- package/dist/cjs/cli/prisma-report.d.ts +33 -0
- package/dist/cjs/cli/prisma-report.js +73 -0
- package/dist/cjs/cli/prisma-resolve.d.ts +106 -0
- package/dist/cjs/cli/prisma-resolve.js +1 -0
- package/dist/cjs/cli/prisma-schema.d.ts +176 -0
- package/dist/cjs/cli/prisma-schema.js +82 -4
- package/dist/cjs/cli/rate-limit.d.ts +32 -0
- package/dist/cjs/cli/rate-limit.js +45 -0
- package/dist/cjs/cli/studio-demo.d.ts +43 -0
- package/dist/cjs/cli/studio-ui.generated.d.ts +1 -0
- package/dist/cjs/cli/studio.d.ts +207 -0
- package/dist/cjs/cli/studio.js +136 -71
- package/dist/cjs/cli/ui.d.ts +73 -0
- package/dist/cjs/cli/ui.js +51 -9
- package/dist/cjs/client.d.ts +837 -0
- package/dist/cjs/client.js +3 -0
- package/dist/cjs/dialect.d.ts +516 -0
- package/dist/cjs/dialect.js +37 -12
- package/dist/cjs/errors.d.ts +370 -0
- package/dist/cjs/generate.d.ts +137 -0
- package/dist/cjs/generate.js +39 -6
- package/dist/cjs/index-advisor.d.ts +153 -0
- package/dist/cjs/index-stats.d.ts +384 -0
- package/dist/cjs/index.d.ts +55 -0
- package/dist/cjs/index.js +7 -2
- package/dist/cjs/introspect.d.ts +269 -0
- package/dist/cjs/mssql.d.ts +232 -0
- package/dist/cjs/mssql.js +6 -0
- package/dist/cjs/mysql.d.ts +173 -0
- package/dist/cjs/mysql.js +16 -0
- package/dist/cjs/nested-write.d.ts +96 -0
- package/dist/cjs/nested-write.js +414 -24
- package/dist/cjs/observe.d.ts +115 -0
- package/dist/cjs/optional-peer-import.d.cts +72 -0
- package/dist/cjs/pipeline-submittable.d.ts +93 -0
- package/dist/cjs/pipeline.d.ts +71 -0
- package/dist/cjs/powdb-introspect.d.ts +84 -0
- package/dist/cjs/powdb.d.ts +931 -0
- package/dist/cjs/powdb.js +106 -21
- package/dist/cjs/powql.d.ts +592 -0
- package/dist/cjs/powql.js +42 -6
- package/dist/cjs/prisma-compat.d.ts +283 -0
- package/dist/cjs/prisma-compat.js +167 -9
- package/dist/cjs/query/aggregates.d.ts +92 -0
- package/dist/cjs/query/aggregates.js +7 -3
- package/dist/cjs/query/batched-loader.d.ts +193 -0
- package/dist/cjs/query/builder.d.ts +849 -0
- package/dist/cjs/query/builder.js +571 -65
- package/dist/cjs/query/compound-unique.d.ts +51 -0
- package/dist/cjs/query/deferred.d.ts +223 -0
- package/dist/cjs/query/filters.d.ts +201 -0
- package/dist/cjs/query/index.d.ts +14 -0
- package/dist/cjs/query/index.js +6 -1
- package/dist/cjs/query/relations.d.ts +609 -0
- package/dist/cjs/query/relations.js +693 -46
- package/dist/cjs/query/types.d.ts +1300 -0
- package/dist/cjs/query/utils.d.ts +209 -0
- package/dist/cjs/query/utils.js +208 -1
- package/dist/cjs/query/warn-registry.d.ts +68 -0
- package/dist/cjs/query/warn-registry.js +9 -0
- package/dist/cjs/query/where-compile.d.ts +139 -0
- package/dist/cjs/query/where.d.ts +548 -0
- package/dist/cjs/query/where.js +58 -22
- package/dist/cjs/query/writes.d.ts +172 -0
- package/dist/cjs/query/writes.js +105 -12
- package/dist/cjs/realtime.d.ts +70 -0
- package/dist/cjs/schema-builder.d.ts +354 -0
- package/dist/cjs/schema-metadata.d.ts +83 -0
- package/dist/cjs/schema-sql.d.ts +217 -0
- package/dist/cjs/schema-sql.js +23 -5
- package/dist/cjs/schema.d.ts +356 -0
- package/dist/cjs/schema.js +125 -0
- package/dist/cjs/seed.d.ts +15 -0
- package/dist/cjs/serverless.d.ts +142 -0
- package/dist/cjs/sqlite.d.ts +143 -0
- package/dist/cjs/sqlite.js +4 -0
- package/dist/cjs/typed-sql.d.ts +102 -0
- package/dist/cli/config.d.ts +18 -4
- package/dist/cli/config.js +31 -6
- package/dist/cli/index.d.ts +123 -0
- package/dist/cli/index.js +223 -58
- package/dist/cli/migrate.d.ts +59 -10
- package/dist/cli/migrate.js +128 -41
- package/dist/cli/observe-ui.d.ts +1 -1
- package/dist/cli/observe-ui.js +14 -5
- package/dist/cli/observe.d.ts +7 -1
- package/dist/cli/observe.js +48 -12
- package/dist/cli/prisma-report.d.ts +14 -0
- package/dist/cli/prisma-report.js +72 -0
- package/dist/cli/prisma-resolve.d.ts +6 -0
- package/dist/cli/prisma-resolve.js +1 -0
- package/dist/cli/prisma-schema.d.ts +62 -2
- package/dist/cli/prisma-schema.js +81 -4
- package/dist/cli/rate-limit.d.ts +32 -0
- package/dist/cli/rate-limit.js +40 -0
- package/dist/cli/studio.d.ts +5 -5
- package/dist/cli/studio.js +135 -70
- package/dist/cli/ui.d.ts +1 -1
- package/dist/cli/ui.js +51 -9
- package/dist/client.d.ts +40 -0
- package/dist/client.js +3 -0
- package/dist/dialect.d.ts +17 -1
- package/dist/dialect.js +37 -12
- package/dist/generate.js +40 -7
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/mssql.js +6 -0
- package/dist/mysql.js +16 -0
- package/dist/nested-write.d.ts +2 -0
- package/dist/nested-write.js +415 -25
- package/dist/powdb.d.ts +4 -2
- package/dist/powdb.js +106 -21
- package/dist/powql.d.ts +5 -0
- package/dist/powql.js +42 -6
- package/dist/prisma-compat.d.ts +2 -0
- package/dist/prisma-compat.js +166 -8
- package/dist/query/aggregates.js +7 -3
- package/dist/query/builder.d.ts +292 -21
- package/dist/query/builder.js +570 -64
- package/dist/query/deferred.d.ts +39 -0
- package/dist/query/index.d.ts +1 -1
- package/dist/query/index.js +1 -1
- package/dist/query/relations.d.ts +173 -5
- package/dist/query/relations.js +688 -47
- package/dist/query/types.d.ts +123 -39
- package/dist/query/utils.d.ts +116 -0
- package/dist/query/utils.js +198 -0
- package/dist/query/warn-registry.d.ts +9 -0
- package/dist/query/warn-registry.js +9 -0
- package/dist/query/where.d.ts +38 -1
- package/dist/query/where.js +58 -23
- package/dist/query/writes.d.ts +42 -1
- package/dist/query/writes.js +104 -13
- package/dist/schema-sql.d.ts +14 -0
- package/dist/schema-sql.js +23 -5
- package/dist/schema.d.ts +38 -0
- package/dist/schema.js +123 -0
- package/dist/sqlite.js +4 -0
- package/package.json +77 -28
package/dist/nested-write.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* `client.ts` directly — the transaction handle is passed in via
|
|
12
12
|
* `NestedWriteContext`.
|
|
13
13
|
*/
|
|
14
|
-
import { CircularRelationError, describeTargetForMessage, NotFoundError, RelationError, ValidationError, } from './errors.js';
|
|
14
|
+
import { CircularRelationError, describeTargetForMessage, NotFoundError, RelationError, UnsupportedFeatureError, ValidationError, } from './errors.js';
|
|
15
15
|
import { normalizeKeyColumns } from './schema.js';
|
|
16
16
|
const MAX_DEPTH = 10;
|
|
17
17
|
const CREATE_ONLY_OPS = new Set(['create', 'connect', 'connectOrCreate']);
|
|
@@ -85,6 +85,21 @@ export function injectForeignKey(childData, relation, parentRow, schema) {
|
|
|
85
85
|
function toArray(value) {
|
|
86
86
|
return Array.isArray(value) ? value : [value];
|
|
87
87
|
}
|
|
88
|
+
/**
|
|
89
|
+
* Stringified comparison key for a key value, matching `keyOf` in
|
|
90
|
+
* query/batched-loader.ts (the loader stitches parent and child rows across two
|
|
91
|
+
* tables the same way).
|
|
92
|
+
*
|
|
93
|
+
* Two rows read from DIFFERENT tables go through different column parsers, so
|
|
94
|
+
* the same logical key can arrive as `1` from one and `'1'` from the other (a
|
|
95
|
+
* bigint junction column read as text next to a numeric target primary key).
|
|
96
|
+
* Strict identity also fails outright for object-valued keys: two `Date`s or two
|
|
97
|
+
* `Buffer`s holding the same value are never `===`, so a `Set` of them
|
|
98
|
+
* de-duplicates nothing. Comparing on the string form makes both work.
|
|
99
|
+
*/
|
|
100
|
+
function keyOf(value) {
|
|
101
|
+
return String(value);
|
|
102
|
+
}
|
|
88
103
|
/**
|
|
89
104
|
* Validate that all operation keys in a nested write are recognized and
|
|
90
105
|
* allowed for the current context (create vs update).
|
|
@@ -212,16 +227,375 @@ function assertTargetSelectsSomething(target, op, relName, rel) {
|
|
|
212
227
|
`An empty or all-undefined "where" would ${op} every related "${rel.to}" row of this parent.`);
|
|
213
228
|
}
|
|
214
229
|
/**
|
|
215
|
-
* many-to-many
|
|
216
|
-
*
|
|
217
|
-
*
|
|
218
|
-
*
|
|
219
|
-
*
|
|
230
|
+
* The many-to-many nested-write operations that DO write junction rows:
|
|
231
|
+
* `connect`, `disconnect` and `set`. Everything else (create, connectOrCreate,
|
|
232
|
+
* delete, update, upsert) would have to write the TARGET row as well, and there
|
|
233
|
+
* is no safe default for the junction's own extra columns, so those still
|
|
234
|
+
* refuse loudly. Falling off the end of the dispatch instead would drop the
|
|
235
|
+
* write silently and report success.
|
|
236
|
+
*/
|
|
237
|
+
const M2M_SUPPORTED_OPS = new Set(['connect', 'disconnect', 'set']);
|
|
238
|
+
/**
|
|
239
|
+
* How to reach a junction table's write accessor, spelled per client because the
|
|
240
|
+
* two clients expose it under DIFFERENT keys.
|
|
241
|
+
*
|
|
242
|
+
* On the core client the auto-created property accessors are CAMELCASED
|
|
243
|
+
* (`db.userTags` for `user_tags`, see the `Object.defineProperty` loops in
|
|
244
|
+
* client.ts), so `db["user_tags"]` is `undefined` for every snake_case junction
|
|
245
|
+
* — which is every junction `turbine pull` produces over a hand-written join
|
|
246
|
+
* table. `db.table("user_tags")` takes the raw table name and exists on both
|
|
247
|
+
* TurbineClient and the TransactionClient handed to `$transaction`, so that is
|
|
248
|
+
* the form to recommend.
|
|
249
|
+
*
|
|
250
|
+
* The prisma-compat client has no `table()` method at all; it exposes each
|
|
251
|
+
* junction as a model delegate keyed by the RAW table name (`junctionModels` in
|
|
252
|
+
* prisma-compat.ts), inside `$transaction` too.
|
|
253
|
+
*/
|
|
254
|
+
function junctionAccessorHint(junction) {
|
|
255
|
+
return (`db.table("${junction}").create / createMany on the core client (inside $transaction too), ` +
|
|
256
|
+
`or db["${junction}"].create / createMany on the prisma-compat client`);
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* The refusal for a many-to-many nested operation that is still unsupported.
|
|
260
|
+
* Names the operation, the ones that ARE supported, and a remedy that is
|
|
261
|
+
* actually reachable on the reader's client (see {@link junctionAccessorHint}).
|
|
262
|
+
*/
|
|
263
|
+
function manyToManyOpUnsupported(op, relName, rel) {
|
|
264
|
+
const junction = rel.through?.table ?? 'junction';
|
|
265
|
+
return new ValidationError(`[turbine] Nested "${op}" is not supported on the many-to-many relation "${relName}" ` +
|
|
266
|
+
`(via the "${junction}" junction table). The supported many-to-many nested operations are ` +
|
|
267
|
+
`connect, disconnect and set. To ${op} a "${rel.to}" row itself, write it directly on the ` +
|
|
268
|
+
`"${rel.to}" table inside the same $transaction and link it with a nested connect; to write ` +
|
|
269
|
+
`link rows by hand, use the junction accessor: ${junctionAccessorHint(junction)}.`);
|
|
270
|
+
}
|
|
271
|
+
/** Refuse every op on an m2m relation that the junction-write path does not handle. */
|
|
272
|
+
function assertManyToManyOpsSupported(relName, rel, ops) {
|
|
273
|
+
for (const op of Object.keys(ops)) {
|
|
274
|
+
if (!M2M_SUPPORTED_OPS.has(op))
|
|
275
|
+
throw manyToManyOpUnsupported(op, relName, rel);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Does the junction constrain the (source, target) pair to at most one row?
|
|
280
|
+
* Only a key over EXACTLY the two columns qualifies: a unique over a superset
|
|
281
|
+
* permits duplicate pairs, and a PARTIAL unique index only constrains the rows
|
|
282
|
+
* matching its predicate.
|
|
283
|
+
*/
|
|
284
|
+
function junctionPairIsUnique(junctionMeta, sourceCol, targetCol) {
|
|
285
|
+
const covers = (cols) => cols.length === 2 && cols.includes(sourceCol) && cols.includes(targetCol);
|
|
286
|
+
if (covers(junctionMeta.primaryKey))
|
|
287
|
+
return true;
|
|
288
|
+
if ((junctionMeta.uniqueColumns ?? []).some(covers))
|
|
289
|
+
return true;
|
|
290
|
+
return (junctionMeta.indexes ?? []).some((idx) => idx.unique && !idx.partial && !idx.docPath && covers(idx.columns));
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Resolve the junction descriptor for a many-to-many nested write.
|
|
294
|
+
*
|
|
295
|
+
* Composite source/target keys are refused rather than guessed: a multi-column
|
|
296
|
+
* link row cannot be addressed with the single-column `in (...)` predicates the
|
|
297
|
+
* batched junction writes below use, and emitting a partially-keyed delete would
|
|
298
|
+
* unlink rows the caller never named.
|
|
299
|
+
*/
|
|
300
|
+
function junctionPlan(ctx, rel, relName, parentRow) {
|
|
301
|
+
const through = rel.through;
|
|
302
|
+
if (!through) {
|
|
303
|
+
throw new ValidationError(`[turbine] manyToMany relation "${relName}" is missing a \`through\` junction descriptor.`);
|
|
304
|
+
}
|
|
305
|
+
const junctionMeta = ctx.schema.tables[through.table];
|
|
306
|
+
if (!junctionMeta) {
|
|
307
|
+
throw new ValidationError(`[turbine] Nested write on the many-to-many relation "${relName}": junction table ` +
|
|
308
|
+
`"${through.table}" is not present in the schema metadata, so its rows cannot be written. ` +
|
|
309
|
+
`Regenerate the schema (turbine generate) so the junction table is included.`);
|
|
310
|
+
}
|
|
311
|
+
const targetMeta = ctx.schema.tables[rel.to];
|
|
312
|
+
if (!targetMeta) {
|
|
313
|
+
throw new ValidationError(`[turbine] Nested write on the many-to-many relation "${relName}": unknown target table "${rel.to}".`);
|
|
314
|
+
}
|
|
315
|
+
const sourceKeys = normalizeKeyColumns(through.sourceKey);
|
|
316
|
+
const targetKeys = normalizeKeyColumns(through.targetKey);
|
|
317
|
+
const refKeys = normalizeKeyColumns(rel.referenceKey);
|
|
318
|
+
const targetPk = targetMeta.primaryKey;
|
|
319
|
+
if (sourceKeys.length !== 1 || targetKeys.length !== 1 || refKeys.length !== 1 || targetPk.length !== 1) {
|
|
320
|
+
throw new ValidationError(`[turbine] Nested writes on the many-to-many relation "${relName}" (via "${through.table}") support ` +
|
|
321
|
+
`single-column junction keys only; this relation links on composite keys ` +
|
|
322
|
+
`(source ${sourceKeys.length}, target ${targetKeys.length}, target primary key ${targetPk.length} column(s)). ` +
|
|
323
|
+
`Write the junction rows directly inside the same $transaction: ${junctionAccessorHint(through.table)}.`);
|
|
324
|
+
}
|
|
325
|
+
// The two junction columns must DIFFER. When they collide (a `through` typo
|
|
326
|
+
// that names one column twice) every predicate and every inserted row this
|
|
327
|
+
// module builds is keyed by ONE object property, so the source key silently
|
|
328
|
+
// overwrites the target key: the disconnect predicate loses its parent scope
|
|
329
|
+
// and deletes other parents' link rows, and the inserted row carries no parent
|
|
330
|
+
// key at all. Introspection can never produce this (it requires two distinct
|
|
331
|
+
// FK columns), but a hand-declared `defineSchema` manyToMany can.
|
|
332
|
+
if (sourceKeys[0] === targetKeys[0]) {
|
|
333
|
+
throw new ValidationError(`[turbine] Nested write on the many-to-many relation "${relName}" cannot run: the junction ` +
|
|
334
|
+
`"${through.table}" names the same column "${sourceKeys[0]}" as BOTH its sourceKey and its ` +
|
|
335
|
+
`targetKey, so a link row cannot hold the parent key and the target key at once. Fix the ` +
|
|
336
|
+
`relation's \`through\` descriptor to name the two distinct junction columns.`);
|
|
337
|
+
}
|
|
338
|
+
const parentField = ctx.schema.tables[rel.from]?.reverseColumnMap[refKeys[0]] ?? refKeys[0];
|
|
339
|
+
const parentValue = parentRow[parentField];
|
|
340
|
+
if (parentValue === null || parentValue === undefined) {
|
|
341
|
+
throw new ValidationError(`[turbine] Nested write on the many-to-many relation "${relName}" cannot run: the parent's reference ` +
|
|
342
|
+
`key "${parentField}" is ${parentValue === null ? 'null' : 'missing from the loaded row'}, so no ` +
|
|
343
|
+
`junction row can be correlated to this parent.`);
|
|
344
|
+
}
|
|
345
|
+
return {
|
|
346
|
+
table: through.table,
|
|
347
|
+
sourceField: junctionMeta.reverseColumnMap[sourceKeys[0]] ?? sourceKeys[0],
|
|
348
|
+
targetField: junctionMeta.reverseColumnMap[targetKeys[0]] ?? targetKeys[0],
|
|
349
|
+
parentValue,
|
|
350
|
+
targetKeyField: targetMeta.reverseColumnMap[targetPk[0]] ?? targetPk[0],
|
|
351
|
+
pairIsUnique: junctionPairIsUnique(junctionMeta, sourceKeys[0], targetKeys[0]),
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
/** The E003 for a target selector that matched no row. */
|
|
355
|
+
function noTargetRow(op, relName, rel, target) {
|
|
356
|
+
return new ValidationError(`[turbine] Nested ${op} on the many-to-many relation "${relName}": no "${rel.to}" row found ` +
|
|
357
|
+
`matching ${describeTargetForMessage(target)}.`);
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* The plain primary-key values of `items` when EVERY selector is simple
|
|
361
|
+
* equality on the target's primary key (`{ id: 7 }`), else `null`.
|
|
362
|
+
*
|
|
363
|
+
* This is the overwhelmingly common connect payload, and it is the only shape
|
|
364
|
+
* that can be resolved by one `IN (...)` read: anything else (a secondary
|
|
365
|
+
* unique, a compound-unique selector, an operator object like
|
|
366
|
+
* `{ id: { in: [...] } }`, a multi-key selector) still needs its own
|
|
367
|
+
* `findUnique` to know WHICH row it names.
|
|
220
368
|
*/
|
|
221
|
-
function
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
369
|
+
function simplePkSelectors(items, targetKeyField) {
|
|
370
|
+
const values = [];
|
|
371
|
+
for (const item of items) {
|
|
372
|
+
if (!item || typeof item !== 'object' || Array.isArray(item))
|
|
373
|
+
return null;
|
|
374
|
+
const keys = Object.keys(item);
|
|
375
|
+
if (keys.length !== 1 || keys[0] !== targetKeyField)
|
|
376
|
+
return null;
|
|
377
|
+
const value = item[targetKeyField];
|
|
378
|
+
if (value === null || value === undefined)
|
|
379
|
+
return null;
|
|
380
|
+
// An operator object (`{ gt: 1 }`, `{ in: [...] }`) is not an equality
|
|
381
|
+
// selector. Date and Buffer keys are objects too; they ARE plain values, but
|
|
382
|
+
// they are rare enough that routing them down the per-selector path costs
|
|
383
|
+
// nothing and keeps this test to one cheap rule.
|
|
384
|
+
if (typeof value === 'object')
|
|
385
|
+
return null;
|
|
386
|
+
values.push(value);
|
|
387
|
+
}
|
|
388
|
+
return values;
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* Resolve each caller-supplied target selector to the target row's key value,
|
|
392
|
+
* de-duplicated in first-seen order. A selector that matches no row is refused:
|
|
393
|
+
* silently skipping it is exactly the failure mode this path exists to remove.
|
|
394
|
+
*
|
|
395
|
+
* A payload of plain primary-key selectors (see {@link simplePkSelectors}) is
|
|
396
|
+
* resolved with ONE `IN (...)` read instead of a `findUnique` per target, so a
|
|
397
|
+
* 20-target connect costs one round trip rather than twenty. The per-selector
|
|
398
|
+
* path stays for arbitrary unique selectors, and BOTH refuse a target that does
|
|
399
|
+
* not exist, naming it.
|
|
400
|
+
*/
|
|
401
|
+
async function resolveJunctionTargets(ctx, rel, relName, plan, op, items) {
|
|
402
|
+
for (const target of items)
|
|
403
|
+
assertTargetSelectsSomething(target, op, relName, rel);
|
|
404
|
+
const pkValues = simplePkSelectors(items, plan.targetKeyField);
|
|
405
|
+
if (pkValues)
|
|
406
|
+
return resolveJunctionTargetsByPk(ctx, rel, relName, plan, op, pkValues);
|
|
407
|
+
const values = [];
|
|
408
|
+
const seen = new Set();
|
|
409
|
+
for (const target of items) {
|
|
410
|
+
const row = (await ctx.tx.table(rel.to).findUnique({ where: target }));
|
|
411
|
+
if (!row)
|
|
412
|
+
throw noTargetRow(op, relName, rel, target);
|
|
413
|
+
const value = row[plan.targetKeyField];
|
|
414
|
+
if (value === null || value === undefined) {
|
|
415
|
+
throw new ValidationError(`[turbine] Nested ${op} on the many-to-many relation "${relName}": the "${rel.to}" row matching ` +
|
|
416
|
+
`${describeTargetForMessage(target)} has no "${plan.targetKeyField}" value to link.`);
|
|
417
|
+
}
|
|
418
|
+
const key = keyOf(value);
|
|
419
|
+
if (seen.has(key))
|
|
420
|
+
continue;
|
|
421
|
+
seen.add(key);
|
|
422
|
+
values.push(value);
|
|
423
|
+
}
|
|
424
|
+
return values;
|
|
425
|
+
}
|
|
426
|
+
/** One-read resolution for a payload of plain primary-key selectors. */
|
|
427
|
+
async function resolveJunctionTargetsByPk(ctx, rel, relName, plan, op, pkValues) {
|
|
428
|
+
const wanted = [];
|
|
429
|
+
const seen = new Set();
|
|
430
|
+
for (const value of pkValues) {
|
|
431
|
+
const key = keyOf(value);
|
|
432
|
+
if (seen.has(key))
|
|
433
|
+
continue;
|
|
434
|
+
seen.add(key);
|
|
435
|
+
wanted.push(value);
|
|
436
|
+
}
|
|
437
|
+
if (wanted.length === 0)
|
|
438
|
+
return [];
|
|
439
|
+
const rows = (await ctx.tx.table(rel.to).findMany({
|
|
440
|
+
where: { [plan.targetKeyField]: { in: wanted } },
|
|
441
|
+
warnOnUnlimited: false,
|
|
442
|
+
}));
|
|
443
|
+
// Keyed by string, so a target primary key read back in a different shape than
|
|
444
|
+
// the caller wrote it (a bigint `1` for a supplied `'1'`) still matches.
|
|
445
|
+
const found = new Map();
|
|
446
|
+
for (const row of rows) {
|
|
447
|
+
const value = row[plan.targetKeyField];
|
|
448
|
+
if (value === null || value === undefined)
|
|
449
|
+
continue;
|
|
450
|
+
found.set(keyOf(value), value);
|
|
451
|
+
}
|
|
452
|
+
// The DB-side value is returned, not the caller's, so the junction insert
|
|
453
|
+
// writes the same shape the per-selector path would.
|
|
454
|
+
return wanted.map((value) => {
|
|
455
|
+
const hit = found.get(keyOf(value));
|
|
456
|
+
if (hit === undefined)
|
|
457
|
+
throw noTargetRow(op, relName, rel, { [plan.targetKeyField]: value });
|
|
458
|
+
return hit;
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
/** The junction rows linking this parent to `values`. */
|
|
462
|
+
function junctionRows(plan, values) {
|
|
463
|
+
return values.map((v) => ({ [plan.sourceField]: plan.parentValue, [plan.targetField]: v }));
|
|
464
|
+
}
|
|
465
|
+
/** The `feature` string both refusing engines tag their skipDuplicates E017 with. */
|
|
466
|
+
const SKIP_DUPLICATES_FEATURE = 'createMany({ skipDuplicates: true })';
|
|
467
|
+
/**
|
|
468
|
+
* Per-schema memo of whether the bound engine accepts
|
|
469
|
+
* `createMany({ skipDuplicates })`, so the engines that refuse it pay for the
|
|
470
|
+
* refusal once instead of on every connect.
|
|
471
|
+
*
|
|
472
|
+
* The capability is not reachable from here as a flag — the `Dialect` is private
|
|
473
|
+
* to QueryInterface / TurbineClient and `NestedWriteContext` carries only the
|
|
474
|
+
* schema and the transaction — so it is read from the engine's OWN structured
|
|
475
|
+
* refusal (`UnsupportedFeatureError.feature`) rather than from a hardcoded
|
|
476
|
+
* engine list that would drift. Both refusing engines throw while BUILDING the
|
|
477
|
+
* statement, before anything is written, so the attempt is side-effect-free.
|
|
478
|
+
*
|
|
479
|
+
* Getting the memo wrong is harmless in both directions: a stale `false` only
|
|
480
|
+
* costs the read-then-insert fallback, and a stale `true` is re-detected and
|
|
481
|
+
* corrected by the same catch.
|
|
482
|
+
*/
|
|
483
|
+
const skipDuplicatesSupport = new WeakMap();
|
|
484
|
+
/**
|
|
485
|
+
* Insert the link rows with `ON CONFLICT DO NOTHING` semantics. Returns false
|
|
486
|
+
* (having written nothing) when the engine refuses the option, so the caller can
|
|
487
|
+
* fall back.
|
|
488
|
+
*/
|
|
489
|
+
async function insertJunctionRowsSkippingDuplicates(ctx, plan, values) {
|
|
490
|
+
if (skipDuplicatesSupport.get(ctx.schema) === false)
|
|
491
|
+
return false;
|
|
492
|
+
try {
|
|
493
|
+
await ctx.tx.table(plan.table).createMany({ data: junctionRows(plan, values), skipDuplicates: true });
|
|
494
|
+
skipDuplicatesSupport.set(ctx.schema, true);
|
|
495
|
+
return true;
|
|
496
|
+
}
|
|
497
|
+
catch (err) {
|
|
498
|
+
if (err instanceof UnsupportedFeatureError && err.feature === SKIP_DUPLICATES_FEATURE) {
|
|
499
|
+
skipDuplicatesSupport.set(ctx.schema, false);
|
|
500
|
+
return false;
|
|
501
|
+
}
|
|
502
|
+
throw err;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* `connect`: link this parent to each target, idempotently.
|
|
507
|
+
*
|
|
508
|
+
* Two strategies, because idempotence has to survive CONCURRENCY:
|
|
509
|
+
*
|
|
510
|
+
* 1. Junction constrains the pair AND the engine supports `skipDuplicates`
|
|
511
|
+
* (PostgreSQL, SQLite and MySQL: `ON CONFLICT DO NOTHING` / a no-op
|
|
512
|
+
* `ON DUPLICATE KEY UPDATE`) — one INSERT, no read. The engine resolves the
|
|
513
|
+
* conflict, so two transactions connecting the same pair at the same time
|
|
514
|
+
* both succeed and one link row exists. This is the introspected path:
|
|
515
|
+
* every junction introspection can detect declares the pair unique.
|
|
516
|
+
* 2. Otherwise — read this parent's existing link rows for exactly these targets
|
|
517
|
+
* and insert only the missing ones. Used when the engine refuses the option
|
|
518
|
+
* (SQL Server and PowDB have no single-statement skip-duplicates form and
|
|
519
|
+
* throw E017), and when the junction does NOT constrain the pair, where
|
|
520
|
+
* `ON CONFLICT DO NOTHING` has no constraint to fire on and would let a
|
|
521
|
+
* repeated connect insert a second link row. Read-then-insert is not
|
|
522
|
+
* concurrency-safe (both transactions can read "missing"); on an unconstrained
|
|
523
|
+
* junction nothing available here is, and this at least keeps a serially
|
|
524
|
+
* repeated connect idempotent.
|
|
525
|
+
*/
|
|
526
|
+
async function processManyToManyConnect(ctx, rel, relName, plan, items) {
|
|
527
|
+
if (items.length === 0)
|
|
528
|
+
return;
|
|
529
|
+
const values = await resolveJunctionTargets(ctx, rel, relName, plan, 'connect', items);
|
|
530
|
+
if (values.length === 0)
|
|
531
|
+
return;
|
|
532
|
+
if (plan.pairIsUnique && (await insertJunctionRowsSkippingDuplicates(ctx, plan, values)))
|
|
533
|
+
return;
|
|
534
|
+
const existing = (await ctx.tx.table(plan.table).findMany({
|
|
535
|
+
where: { [plan.sourceField]: plan.parentValue, [plan.targetField]: { in: values } },
|
|
536
|
+
// An internal engine read: never lecture the caller about a missing `limit`
|
|
537
|
+
// on a statement they did not write.
|
|
538
|
+
warnOnUnlimited: false,
|
|
539
|
+
}));
|
|
540
|
+
// Compared as strings: the junction column and the target primary key are read
|
|
541
|
+
// through DIFFERENT tables' parsers, so an already-linked pair can arrive as
|
|
542
|
+
// `'1'` here and `1` there, and strict identity would re-insert it.
|
|
543
|
+
const linked = new Set(existing.map((r) => keyOf(r[plan.targetField])));
|
|
544
|
+
const missing = values.filter((v) => !linked.has(keyOf(v)));
|
|
545
|
+
if (missing.length === 0)
|
|
546
|
+
return;
|
|
547
|
+
await ctx.tx.table(plan.table).createMany({ data: junctionRows(plan, missing) });
|
|
548
|
+
}
|
|
549
|
+
/**
|
|
550
|
+
* `disconnect`: remove the link rows for exactly the named targets.
|
|
551
|
+
*
|
|
552
|
+
* Scoped by BOTH keys in one statement. A delete scoped by the source key alone
|
|
553
|
+
* would unlink every target of this parent, and one scoped by the target key
|
|
554
|
+
* alone would unlink OTHER parents' rows.
|
|
555
|
+
*/
|
|
556
|
+
async function processManyToManyDisconnect(ctx, rel, relName, plan, items) {
|
|
557
|
+
if (items.length === 0)
|
|
558
|
+
return;
|
|
559
|
+
const values = await resolveJunctionTargets(ctx, rel, relName, plan, 'disconnect', items);
|
|
560
|
+
if (values.length === 0)
|
|
561
|
+
return;
|
|
562
|
+
await ctx.tx.table(plan.table).deleteMany({
|
|
563
|
+
where: { [plan.sourceField]: plan.parentValue, [plan.targetField]: { in: values } },
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
/**
|
|
567
|
+
* `set`: replace this parent's whole link set, in one transaction.
|
|
568
|
+
*
|
|
569
|
+
* `set: []` clears every link of this parent (Prisma's semantics, and the same
|
|
570
|
+
* choice the hasMany/hasOne `set` path already makes). The clearing delete is
|
|
571
|
+
* always scoped by the source key, so it can never touch another parent's rows.
|
|
572
|
+
*/
|
|
573
|
+
async function processManyToManySet(ctx, rel, relName, plan, items) {
|
|
574
|
+
const values = await resolveJunctionTargets(ctx, rel, relName, plan, 'set', items);
|
|
575
|
+
await ctx.tx.table(plan.table).deleteMany({ where: { [plan.sourceField]: plan.parentValue } });
|
|
576
|
+
if (values.length === 0)
|
|
577
|
+
return;
|
|
578
|
+
await ctx.tx.table(plan.table).createMany({ data: junctionRows(plan, values) });
|
|
579
|
+
}
|
|
580
|
+
/**
|
|
581
|
+
* Run every supported junction operation for one many-to-many relation, in the
|
|
582
|
+
* order the caller's payload implies: `set` (a full replacement) first, then
|
|
583
|
+
* `disconnect`, then `connect`.
|
|
584
|
+
*/
|
|
585
|
+
async function processManyToMany(ctx, rel, relName, ops, parentRow) {
|
|
586
|
+
assertManyToManyOpsSupported(relName, rel, ops);
|
|
587
|
+
if (ops.set === undefined && ops.disconnect === undefined && ops.connect === undefined)
|
|
588
|
+
return;
|
|
589
|
+
const plan = junctionPlan(ctx, rel, relName, parentRow);
|
|
590
|
+
if (ops.set !== undefined) {
|
|
591
|
+
await processManyToManySet(ctx, rel, relName, plan, toArray(ops.set));
|
|
592
|
+
}
|
|
593
|
+
if (ops.disconnect !== undefined) {
|
|
594
|
+
await processManyToManyDisconnect(ctx, rel, relName, plan, toArray(ops.disconnect));
|
|
595
|
+
}
|
|
596
|
+
if (ops.connect !== undefined) {
|
|
597
|
+
await processManyToManyConnect(ctx, rel, relName, plan, toArray(ops.connect));
|
|
598
|
+
}
|
|
225
599
|
}
|
|
226
600
|
/**
|
|
227
601
|
* The E001 raised when a nested delete/update/disconnect target is not a child
|
|
@@ -295,7 +669,10 @@ export async function executeNestedCreate(ctx, tableName, data, depth = 0, path
|
|
|
295
669
|
Object.assign(belongsToFks, await resolveBelongsToForCreate(ctx, rel, ops, tableName, depth, path, relName));
|
|
296
670
|
}
|
|
297
671
|
else if (rel.type === 'manyToMany') {
|
|
298
|
-
|
|
672
|
+
// Validated BEFORE the parent insert so an unsupported m2m op refuses
|
|
673
|
+
// without having written anything; the junction rows themselves need the
|
|
674
|
+
// parent's key, so they are written after the insert (below).
|
|
675
|
+
assertManyToManyOpsSupported(relName, rel, ops);
|
|
299
676
|
}
|
|
300
677
|
}
|
|
301
678
|
// Insert the parent row (scalars + resolved belongsTo foreign keys)
|
|
@@ -309,6 +686,12 @@ export async function executeNestedCreate(ctx, tableName, data, depth = 0, path
|
|
|
309
686
|
if (rel.type === 'hasMany' || rel.type === 'hasOne') {
|
|
310
687
|
await processHasManyCreate(ctx, rel, ops, parentRow, depth, path, relName);
|
|
311
688
|
}
|
|
689
|
+
else if (rel.type === 'manyToMany') {
|
|
690
|
+
// The junction row carries the parent's key, so it can only be written
|
|
691
|
+
// once the parent row exists. Same transaction, same ordering rule the
|
|
692
|
+
// hasMany path follows.
|
|
693
|
+
await processManyToMany(ctx, rel, relName, ops, parentRow);
|
|
694
|
+
}
|
|
312
695
|
}
|
|
313
696
|
// Build the `with` clause for the final read to return the full tree
|
|
314
697
|
const withClause = {};
|
|
@@ -418,7 +801,7 @@ export async function executeNestedUpdate(ctx, tableName, where, data, depth = 0
|
|
|
418
801
|
}
|
|
419
802
|
}
|
|
420
803
|
else {
|
|
421
|
-
|
|
804
|
+
await processManyToMany(ctx, rel, relName, ops, parentRow);
|
|
422
805
|
}
|
|
423
806
|
}
|
|
424
807
|
// Final read with all touched relations
|
|
@@ -766,18 +1149,22 @@ async function processBelongsToUpdate(ctx, rel, updateArg, parentRow, parentTabl
|
|
|
766
1149
|
if (!item.data) {
|
|
767
1150
|
throw new ValidationError(`[turbine] Nested update on belongsTo "${rel.name}" requires a "data" field.`);
|
|
768
1151
|
}
|
|
769
|
-
//
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
const
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
const
|
|
777
|
-
|
|
778
|
-
|
|
1152
|
+
// The related row is the one this parent's FK points at. Route through the
|
|
1153
|
+
// shared correlation helper (like every sibling operation) so a NULL parent
|
|
1154
|
+
// FK reports not-found instead of compiling to `refField IS NULL` and
|
|
1155
|
+
// updating EVERY row of the related table with a null reference key.
|
|
1156
|
+
const where = belongsToCorrelationWhere(ctx, rel, parentRow, parentTable);
|
|
1157
|
+
if (!where) {
|
|
1158
|
+
// Parent FK is NULL: it points at nothing, so nothing is in scope to update.
|
|
1159
|
+
const nullFk = Object.fromEntries(normalizeKeyColumns(rel.foreignKey).map((c) => [c, null]));
|
|
1160
|
+
throw notRelatedToParent('update', rel.name, rel, nullFk);
|
|
1161
|
+
}
|
|
1162
|
+
try {
|
|
1163
|
+
await ctx.tx.table(rel.to).update({ where, data: item.data });
|
|
1164
|
+
}
|
|
1165
|
+
catch (err) {
|
|
1166
|
+
rethrowAsNotRelated(err, 'update', rel.name, rel, where);
|
|
779
1167
|
}
|
|
780
|
-
await ctx.tx.table(rel.to).update({ where, data: item.data });
|
|
781
1168
|
}
|
|
782
1169
|
async function processBelongsToUpsert(ctx, rel, upsertArg, parentRow, parentTable) {
|
|
783
1170
|
const item = upsertArg;
|
|
@@ -797,8 +1184,11 @@ async function processBelongsToUpsert(ctx, rel, upsertArg, parentRow, parentTabl
|
|
|
797
1184
|
// relation's reference key (unique or the PK by construction), so at most one
|
|
798
1185
|
// row can come back.
|
|
799
1186
|
const existing = correlation
|
|
800
|
-
? ((await ctx.tx.table(rel.to).findMany({
|
|
801
|
-
|
|
1187
|
+
? ((await ctx.tx.table(rel.to).findMany({
|
|
1188
|
+
where: scopeWhereToParent(item.where, correlation),
|
|
1189
|
+
// Internal engine read (see the connect path): no unlimited-findMany warning.
|
|
1190
|
+
warnOnUnlimited: false,
|
|
1191
|
+
}))[0] ?? null)
|
|
802
1192
|
: null;
|
|
803
1193
|
if (existing) {
|
|
804
1194
|
const relatedMeta = ctx.schema.tables[rel.to];
|
package/dist/powdb.d.ts
CHANGED
|
@@ -104,7 +104,9 @@ export declare class PowdbFloatParam {
|
|
|
104
104
|
*/
|
|
105
105
|
export declare class PowdbJsonParam {
|
|
106
106
|
readonly value: unknown;
|
|
107
|
-
|
|
107
|
+
readonly column?: string | undefined;
|
|
108
|
+
/** `column` is diagnostic only: it names the target column when serialization fails. */
|
|
109
|
+
constructor(value: unknown, column?: string | undefined);
|
|
108
110
|
}
|
|
109
111
|
/** The four shapes a PowQL result takes over the legacy string wire. */
|
|
110
112
|
type PowdbResult = {
|
|
@@ -695,7 +697,7 @@ interface EmbeddedModule {
|
|
|
695
697
|
* quotes, backslashes, `$N`, `"); drop … --`, raw CR, and emoji all round-trip
|
|
696
698
|
* as data and cannot break out of the literal or inject a second statement.
|
|
697
699
|
*/
|
|
698
|
-
export declare function encodePowqlLiteral(value: unknown): string;
|
|
700
|
+
export declare function encodePowqlLiteral(value: unknown, position?: string): string;
|
|
699
701
|
/**
|
|
700
702
|
* The newest PowDB engine LINE (major.minor) whose lexer escape handling
|
|
701
703
|
* {@link encodePowqlString} is VERIFIED against by reading
|