grafeo-ogm 2.2.0 → 2.3.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.
Files changed (59) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/README.md +49 -1
  3. package/dist/cjs/compilers/mutation.compiler.d.ts +94 -31
  4. package/dist/cjs/compilers/mutation.compiler.d.ts.map +1 -1
  5. package/dist/cjs/compilers/mutation.compiler.js +381 -343
  6. package/dist/cjs/compilers/mutation.compiler.js.map +1 -1
  7. package/dist/cjs/compilers/selection.compiler.d.ts +1 -1
  8. package/dist/cjs/compilers/selection.compiler.d.ts.map +1 -1
  9. package/dist/cjs/compilers/selection.compiler.js +33 -39
  10. package/dist/cjs/compilers/selection.compiler.js.map +1 -1
  11. package/dist/cjs/compilers/where.compiler.d.ts +124 -1
  12. package/dist/cjs/compilers/where.compiler.d.ts.map +1 -1
  13. package/dist/cjs/compilers/where.compiler.js +371 -102
  14. package/dist/cjs/compilers/where.compiler.js.map +1 -1
  15. package/dist/cjs/interface-model.d.ts +20 -10
  16. package/dist/cjs/interface-model.d.ts.map +1 -1
  17. package/dist/cjs/interface-model.js +47 -104
  18. package/dist/cjs/interface-model.js.map +1 -1
  19. package/dist/cjs/model.d.ts +4 -29
  20. package/dist/cjs/model.d.ts.map +1 -1
  21. package/dist/cjs/model.js +46 -129
  22. package/dist/cjs/model.js.map +1 -1
  23. package/dist/cjs/policy/nested-writes.d.ts +35 -0
  24. package/dist/cjs/policy/nested-writes.d.ts.map +1 -0
  25. package/dist/cjs/policy/nested-writes.js +199 -0
  26. package/dist/cjs/policy/nested-writes.js.map +1 -0
  27. package/dist/cjs/policy/types.d.ts +18 -0
  28. package/dist/cjs/policy/types.d.ts.map +1 -1
  29. package/dist/cjs/policy/types.js +24 -0
  30. package/dist/cjs/policy/types.js.map +1 -1
  31. package/dist/esm/compilers/mutation.compiler.d.ts +94 -31
  32. package/dist/esm/compilers/mutation.compiler.d.ts.map +1 -1
  33. package/dist/esm/compilers/mutation.compiler.js +381 -344
  34. package/dist/esm/compilers/mutation.compiler.js.map +1 -1
  35. package/dist/esm/compilers/selection.compiler.d.ts +1 -1
  36. package/dist/esm/compilers/selection.compiler.d.ts.map +1 -1
  37. package/dist/esm/compilers/selection.compiler.js +33 -39
  38. package/dist/esm/compilers/selection.compiler.js.map +1 -1
  39. package/dist/esm/compilers/where.compiler.d.ts +124 -1
  40. package/dist/esm/compilers/where.compiler.d.ts.map +1 -1
  41. package/dist/esm/compilers/where.compiler.js +371 -104
  42. package/dist/esm/compilers/where.compiler.js.map +1 -1
  43. package/dist/esm/interface-model.d.ts +20 -10
  44. package/dist/esm/interface-model.d.ts.map +1 -1
  45. package/dist/esm/interface-model.js +47 -104
  46. package/dist/esm/interface-model.js.map +1 -1
  47. package/dist/esm/model.d.ts +4 -29
  48. package/dist/esm/model.d.ts.map +1 -1
  49. package/dist/esm/model.js +47 -130
  50. package/dist/esm/model.js.map +1 -1
  51. package/dist/esm/policy/nested-writes.d.ts +35 -0
  52. package/dist/esm/policy/nested-writes.d.ts.map +1 -0
  53. package/dist/esm/policy/nested-writes.js +193 -0
  54. package/dist/esm/policy/nested-writes.js.map +1 -0
  55. package/dist/esm/policy/types.d.ts +18 -0
  56. package/dist/esm/policy/types.d.ts.map +1 -1
  57. package/dist/esm/policy/types.js +23 -0
  58. package/dist/esm/policy/types.js.map +1 -1
  59. package/package.json +1 -1
@@ -1,13 +1,10 @@
1
1
  import { OGMError } from '../errors';
2
+ import { PolicyDeniedError } from '../policy/errors';
3
+ import { NO_ROOT_POLICY } from '../policy/types';
2
4
  import { buildRelPattern, getTargetLabelString, resolveTargetDef, } from '../schema/utils';
3
- import { assertSafeIdentifier, assertSafeLabel, assertSafePropertyName, escapeIdentifier, mergeParams, } from '../utils/validation';
5
+ import { assertSafeIdentifier, assertSafeLabel, assertSafePropertyName, escapeIdentifier, isPlainObject, mergeParams, } from '../utils/validation';
4
6
  import { coerceWriteValue, resolveDefaultWriteValue, wrapListItemExpr, wrapWhereParam, wrapWriteExpr, } from '../utils/write-coercion';
5
7
  import { WhereCompiler } from './where.compiler';
6
- /**
7
- * Relationship filter suffixes that require EXISTS subquery patterns.
8
- * These match related nodes via relationship traversal rather than scalar property comparison.
9
- */
10
- const RELATIONSHIP_SUFFIXES = ['_SOME', '_NONE', '_ALL', '_SINGLE'];
11
8
  /**
12
9
  * Compiles create, update, delete, and label mutations into Cypher + params.
13
10
  */
@@ -19,8 +16,6 @@ export class MutationCompiler {
19
16
  * Bounded by the number of node types in the schema (~50-100 entries).
20
17
  */
21
18
  this.labelCache = new Map();
22
- /** Counter for unique relationship filter variables within a single WHERE clause. */
23
- this.relFilterCounter = 0;
24
19
  this.logger = options?.logger;
25
20
  this.policyWhereCompiler = options?.whereCompiler;
26
21
  }
@@ -48,34 +43,126 @@ export class MutationCompiler {
48
43
  * `connect_<field>_<prop>`) or the selection's params (allocated later
49
44
  * from the same counter).
50
45
  */
51
- buildTargetPolicyPredicate(targetNodeDef, targetVar, params, policyContext, paramCounter) {
52
- if (!policyContext || !paramCounter)
53
- return null;
54
- const targetPolicy = policyContext.resolveForType(targetNodeDef.typeName, 'read');
55
- if (!targetPolicy)
46
+ buildTargetPolicyPredicate(targetNodeDef, targetVar, params, policyContext, paramCounter,
47
+ /**
48
+ * Operation whose policy gates the target (v2.3.0): `read` for
49
+ * connect/disconnect (you must see a node to link it), `update` for a
50
+ * nested update, `delete` for nested/cascade deletes — exactly what the
51
+ * equivalent DIRECT write on the target type enforces.
52
+ */
53
+ op = 'read') {
54
+ if (!policyContext)
56
55
  return null;
57
- const targetBundle = {
58
- ctx: policyContext.ctx,
59
- operation: 'read',
60
- resolved: targetPolicy,
61
- resolveForType: policyContext.resolveForType,
62
- defaults: policyContext.defaults,
63
- };
64
- const compiled = this.getPolicyWhereCompiler().compile(undefined, targetVar, targetNodeDef, paramCounter, { policyContext: targetBundle });
65
- if (compiled.preludes && compiled.preludes.length > 0)
56
+ // v2.3.0 — a missing counter must never mean a missing policy.
57
+ const counter = paramCounter ?? counterAfter(params);
58
+ // Mirror the direct write's compile-time default-deny: a direct
59
+ // update/delete with `onDeny: 'throw'` and no applicable permissive
60
+ // throws before running, so the nested equivalent must too. (Connect/
61
+ // disconnect keep their pre-2.3.0 `read` semantics: compile to false.)
62
+ if (op !== 'read' && policyContext.defaults.onDeny === 'throw') {
63
+ const resolved = policyContext.resolveForType(targetNodeDef.typeName, op);
64
+ if (resolved && !resolved.overridden && resolved.permissives.length === 0)
65
+ throw new PolicyDeniedError({
66
+ typeName: targetNodeDef.typeName,
67
+ operation: op,
68
+ reason: 'no-permissive-matched',
69
+ });
70
+ }
71
+ // Abstract (interface / union) targets get a CASE over their concrete
72
+ // members' own clauses — pre-2.3.0 only the abstract type's own
73
+ // policies (usually none) were applied.
74
+ const clause = this.getPolicyWhereCompiler().compileTargetPolicyClause(targetNodeDef.typeName, targetVar, op, policyContext, counter);
75
+ if (clause.preludes.length > 0)
66
76
  throw new OGMError(`Policy on "${targetNodeDef.typeName}" requires @cypher field ` +
67
- `projection, which is not supported inside connect/disconnect ` +
77
+ `projection, which is not supported inside nested mutation ` +
68
78
  `target filters. Refactor the policy to use stored properties.`);
69
- if (!compiled.cypher)
79
+ if (!clause.cypher)
70
80
  return null;
71
- mergeParams(params, compiled.params);
72
- return compiled.cypher;
81
+ mergeParams(params, clause.params);
82
+ return clause.cypher;
73
83
  }
74
84
  getPolicyWhereCompiler() {
85
+ // Inherit the logger: since v2.3.0 this compiler also compiles every
86
+ // nested-write `where`, and its empty-AND/OR warnings must surface.
75
87
  if (!this.policyWhereCompiler)
76
- this.policyWhereCompiler = new WhereCompiler(this.schema);
88
+ this.policyWhereCompiler = new WhereCompiler(this.schema, {
89
+ logger: this.logger,
90
+ });
77
91
  return this.policyWhereCompiler;
78
92
  }
93
+ /**
94
+ * Compile a nested-write `where` (connect, disconnect, nested update,
95
+ * nested/cascade delete) against `targetVar` through `WhereCompiler` —
96
+ * the single where implementation (v2.3.0: the mutation compiler's
97
+ * parallel builder was removed after repeatedly drifting from it).
98
+ * Operators, null semantics, abstract relationship targets and —
99
+ * crucially — traversal-policy enforcement are identical to a direct
100
+ * `where`: a relationship filter inside it AND-stitches the traversed
101
+ * type's `read` policy. The target's OWN operation policy is added
102
+ * separately by `buildTargetPolicyPredicate`, so the root here is bound
103
+ * as `NO_ROOT_POLICY`.
104
+ *
105
+ * Returns `''` for an absent or empty where.
106
+ */
107
+ compileTargetWhere(whereSpec, targetVar, targetNodeDef, params, policyContext, paramCounter) {
108
+ if (!whereSpec || Object.keys(whereSpec).length === 0)
109
+ return '';
110
+ const counter = paramCounter ?? counterAfter(params);
111
+ const nodeWhere = connectionWhereToNodeWhere(whereSpec);
112
+ const traversalBundle = policyContext
113
+ ? { ...policyContext, operation: 'read', resolved: NO_ROOT_POLICY }
114
+ : undefined;
115
+ const compiled = this.getPolicyWhereCompiler().compile(nodeWhere, targetVar, targetNodeDef, counter, traversalBundle ? { policyContext: traversalBundle } : undefined);
116
+ if (compiled.preludes && compiled.preludes.length > 0)
117
+ throw new OGMError(`Filtering a nested mutation target of type "${targetNodeDef.typeName}" ` +
118
+ `by an @cypher field is not supported. Use stored properties in ` +
119
+ `nested connect/disconnect/update/delete filters.`);
120
+ mergeParams(params, compiled.params);
121
+ return compiled.cypher;
122
+ }
123
+ /**
124
+ * The bulk-connect UNWIND fast path compiles each where key against the
125
+ * ROW's own value (`connItem.where.node.<key>`), which `WhereCompiler`
126
+ * cannot express. It is therefore restricted to the one shape where
127
+ * that is exact: every item's where is `{ node: { … } }` or bare
128
+ * properties, holding only declared, stored scalar properties of the
129
+ * target (registered operators allowed) with NON-NULL values — no
130
+ * `AND`/`OR`/`NOT`/`node_NOT`, no relationship traversal, no `@cypher`
131
+ * field. Every predicate it emits is then a scalar comparison against a
132
+ * concrete row value, so it can only narrow matches, never fail open.
133
+ * Anything else takes the per-item path through `WhereCompiler`.
134
+ */
135
+ isConnectFastPathEligible(items, targetNodeDef) {
136
+ for (const item of items) {
137
+ const where = item.where;
138
+ if (where === undefined)
139
+ continue;
140
+ if (!isPlainObject(where))
141
+ return false;
142
+ const keys = Object.keys(where).filter((k) => where[k] !== undefined);
143
+ let nodeWhere;
144
+ if (keys.some((k) => CONNECTION_WHERE_KEYS.has(k))) {
145
+ if (keys.length !== 1 || keys[0] !== 'node')
146
+ return false;
147
+ if (!isPlainObject(where.node))
148
+ return false;
149
+ nodeWhere = where.node;
150
+ }
151
+ else
152
+ nodeWhere = where;
153
+ for (const [key, val] of Object.entries(nodeWhere)) {
154
+ if (val === undefined || val === null)
155
+ return false;
156
+ if (isPlainObject(val))
157
+ return false;
158
+ const { baseProp } = this.parseOperatorSuffix(key);
159
+ const propDef = targetNodeDef.properties.get(baseProp);
160
+ if (!propDef || propDef.isCypher)
161
+ return false;
162
+ }
163
+ }
164
+ return true;
165
+ }
79
166
  /**
80
167
  * Surface a logical operator that contributed zero effective conditions.
81
168
  * Mirrors WhereCompiler.warnEmptyLogical — fires only on the anomalous
@@ -102,8 +189,19 @@ export class MutationCompiler {
102
189
  * Generate CREATE Cypher for one or more nodes.
103
190
  * Handles scalar properties, nested relationship creates, and connects.
104
191
  */
105
- compileCreate(inputs, nodeDef, labels) {
106
- this.relFilterCounter = 0;
192
+ compileCreate(inputs, nodeDef, labels,
193
+ /**
194
+ * The caller's `create` bundle (v2.3.0). Its `resolveForType` gates
195
+ * every connect target in the nested input by the TARGET type's `read`
196
+ * policy — pre-2.3.0 the create path threaded no policy at all.
197
+ */
198
+ policyContext,
199
+ /**
200
+ * Shared `param<N>` counter; continue it into the RETURN selection so
201
+ * nested-filter params never collide with the selection's.
202
+ */
203
+ paramCounter) {
204
+ const counter = paramCounter ?? { count: 0 };
107
205
  const lines = [];
108
206
  const params = {};
109
207
  const createdVars = [];
@@ -120,10 +218,7 @@ export class MutationCompiler {
120
218
  // Pass all previously created node vars as ancestors so WITH clauses
121
219
  // don't drop them from scope (needed for RETURN n at the end)
122
220
  const relLines = this.buildCreateRelationships(input, nodeDef, nodeVar, prefix, params, createdVars.slice(0, -1), // ancestors = all vars before current
123
- // No policy context is threaded on the create path: `create`'s
124
- // nested connect enforcement is out of scope for this fix, so
125
- // the emitted create Cypher stays byte-identical.
126
- undefined, undefined);
221
+ policyContext, counter);
127
222
  lines.push(...relLines);
128
223
  }
129
224
  // Add extra labels to all created nodes
@@ -153,10 +248,12 @@ export class MutationCompiler {
153
248
  * disconnect `where` params or the selection's params.
154
249
  */
155
250
  paramCounter) {
156
- this.relFilterCounter = 0;
157
251
  const labelStr = this.getCachedLabelString(nodeDef);
158
252
  const lines = [];
159
253
  const params = { ...whereResult.params };
254
+ // Continue after the root WHERE's params when the caller threads no
255
+ // counter, so nested filters can never collide with them.
256
+ const counter = paramCounter ?? counterAfter(whereResult.params);
160
257
  // Apply runtime labels to MATCH pattern (same as find())
161
258
  if (labels && labels.length > 0) {
162
259
  const extraLabels = labels.map((l) => assertSafeLabel(l)).join(':');
@@ -189,18 +286,18 @@ export class MutationCompiler {
189
286
  if (setClauses.length > 0)
190
287
  lines.push(`SET ${setClauses.join(', ')}`);
191
288
  // Process nested relationship operations within update body
192
- const relLines = this.buildUpdateRelationships(update, nodeDef, 'n', 'update', params, 0, [], policyContext, paramCounter);
289
+ const relLines = this.buildUpdateRelationships(update, nodeDef, 'n', 'update', params, 0, [], policyContext, counter);
193
290
  lines.push(...relLines);
194
291
  }
195
292
  // Disconnect relationships (top-level) — must run before connects
196
293
  // so that blanket disconnects don't remove newly-connected relationships.
197
294
  if (disconnect) {
198
- const disconnectLines = this.buildDisconnects(disconnect, nodeDef, params, policyContext, paramCounter);
295
+ const disconnectLines = this.buildDisconnects(disconnect, nodeDef, params, policyContext, counter);
199
296
  lines.push(...disconnectLines);
200
297
  }
201
298
  // Connect relationships (top-level)
202
299
  if (connect) {
203
- const connectLines = this.buildConnects(connect, nodeDef, params, policyContext, paramCounter);
300
+ const connectLines = this.buildConnects(connect, nodeDef, params, policyContext, counter);
204
301
  lines.push(...connectLines);
205
302
  }
206
303
  lines.push(returnMode === 'count' ? 'RETURN count(n) AS count' : 'RETURN n');
@@ -208,40 +305,121 @@ export class MutationCompiler {
208
305
  }
209
306
  /**
210
307
  * Generate DELETE Cypher with optional cascade.
308
+ *
309
+ * v2.3.0 — each cascaded relationship honours its per-item `where` and
310
+ * is gated by the TARGET type's `delete` policy (`buildNestedDelete`).
311
+ * Pre-2.3.0 the cascade iterated only the relationship KEYS: the
312
+ * documented `{ where }` was ignored, so every related node — shared
313
+ * ones included — was deleted, with no policy check.
211
314
  */
212
- compileDelete(nodeDef, whereResult, deleteInput) {
315
+ compileDelete(nodeDef, whereResult, deleteInput,
316
+ /** The caller's `delete` bundle; gates each cascade target. */
317
+ policyContext,
318
+ /** Shared `param<N>` counter, continued from the root WHERE. */
319
+ paramCounter) {
213
320
  const labelStr = this.getCachedLabelString(nodeDef);
214
321
  const lines = [];
215
322
  const params = { ...whereResult.params };
323
+ const counter = paramCounter ?? counterAfter(whereResult.params);
216
324
  lines.push(`MATCH (n:${labelStr})`);
217
325
  if (whereResult.preludes && whereResult.preludes.length > 0)
218
326
  lines.push(...whereResult.preludes);
219
327
  if (whereResult.cypher)
220
328
  lines.push(`WHERE ${whereResult.cypher}`);
221
329
  if (deleteInput && Object.keys(deleteInput).length > 0) {
222
- const deleteVars = [];
223
330
  let varCounter = 0;
224
- for (const [fieldName] of Object.entries(deleteInput)) {
331
+ for (const [fieldName, spec] of Object.entries(deleteInput)) {
332
+ if (spec === undefined)
333
+ continue;
225
334
  const relDef = nodeDef.relationships.get(fieldName);
226
335
  if (!relDef)
227
- continue;
228
- const cascadeVar = `cascade_${varCounter}`;
229
- varCounter++;
230
- const pattern = buildRelPattern({
231
- sourceVar: 'n',
336
+ throw new OGMError(`Unknown relationship "${fieldName}" in the delete input of ${nodeDef.typeName}.`);
337
+ const targetNodeDef = resolveTargetDef(relDef.target, this.schema);
338
+ if (!targetNodeDef)
339
+ throw new OGMError(`Cannot resolve the target type "${relDef.target}" of relationship "${fieldName}".`);
340
+ lines.push(...this.buildNestedDelete({
341
+ spec,
232
342
  relDef,
233
- targetVar: cascadeVar,
234
- targetLabel: relDef.target,
235
- });
236
- lines.push(`OPTIONAL MATCH ${pattern}`);
237
- deleteVars.push(cascadeVar);
343
+ sourceVar: 'n',
344
+ withClause: 'WITH n',
345
+ targetNodeDef,
346
+ nextVar: () => `cascade_${varCounter++}`,
347
+ tag: fieldName,
348
+ params,
349
+ policyContext,
350
+ paramCounter: counter,
351
+ }));
238
352
  }
239
- lines.push(`DETACH DELETE ${[...deleteVars, 'n'].join(', ')}`);
240
353
  }
241
- else
242
- lines.push('DETACH DELETE n');
354
+ lines.push('DETACH DELETE n');
243
355
  return { cypher: lines.join('\n'), params };
244
356
  }
357
+ /**
358
+ * Nested / cascade delete of ONE relationship's targets (v2.3.0), shared
359
+ * by `compileDelete` (`Model.delete`'s `delete` input) and delete-inside-
360
+ * `update`. One subquery per spec item:
361
+ *
362
+ * <withClause>
363
+ * CALL {
364
+ * <withClause>
365
+ * MATCH (src)-[:REL]->(t)
366
+ * WHERE <item.where via WhereCompiler> AND <target `delete` policy>
367
+ * DETACH DELETE t
368
+ * RETURN count(*) AS _del_<tag>_<i>
369
+ * }
370
+ *
371
+ * `spec` is one item or an array (singular vs list relationships);
372
+ * `null` means "no cascade" (GraphQL InputMaybe). `{}` / no `where`
373
+ * deletes every related node the target's `delete` policy permits.
374
+ * A multi-level cascade (`delete` inside an item) and unknown item keys
375
+ * are rejected instead of silently ignored.
376
+ */
377
+ buildNestedDelete(args) {
378
+ const { spec, relDef, sourceVar, withClause, targetNodeDef } = args;
379
+ if (spec === null || spec === undefined)
380
+ return [];
381
+ // Legacy shorthand: `{ rel: true }` = delete every related node (the
382
+ // pre-2.3.0 cascade ignored values, so callers relied on it). Any other
383
+ // non-object value — `false` included, which used to delete everything
384
+ // too — is rejected below rather than silently reinterpreted.
385
+ const items = spec === true ? [{}] : Array.isArray(spec) ? spec : [spec];
386
+ const lines = [];
387
+ items.forEach((item, idx) => {
388
+ if (!isPlainObject(item))
389
+ throw new OGMError(`Each nested delete of "${relDef.fieldName}" must be an object ({ where?, delete? }).`);
390
+ for (const key of Object.keys(item)) {
391
+ if (item[key] === undefined)
392
+ continue;
393
+ if (key !== 'where' && key !== 'delete')
394
+ throw new OGMError(`Unknown key "${key}" in a nested delete of "${relDef.fieldName}". Allowed: "where", "delete".`);
395
+ }
396
+ if (hasNestedCascade(item.delete))
397
+ throw new OGMError(`Multi-level cascade delete is not supported (a "delete" inside the nested delete of "${relDef.fieldName}"). ` +
398
+ `Delete the deeper nodes in a separate call.`);
399
+ const targetVar = args.nextVar();
400
+ const pattern = buildRelPattern({
401
+ sourceVar,
402
+ relDef,
403
+ targetVar,
404
+ targetLabel: 'auto',
405
+ schema: this.schema,
406
+ });
407
+ const conditions = [
408
+ this.compileTargetWhere(item.where, targetVar, targetNodeDef, args.params, args.policyContext, args.paramCounter),
409
+ this.buildTargetPolicyPredicate(targetNodeDef, targetVar, args.params, args.policyContext, args.paramCounter, 'delete'),
410
+ ].filter((c) => Boolean(c));
411
+ lines.push(withClause);
412
+ lines.push('CALL {');
413
+ lines.push(withClause);
414
+ lines.push(`MATCH ${pattern}`);
415
+ if (conditions.length > 0)
416
+ lines.push(`WHERE ${conditions.join(' AND ')}`);
417
+ lines.push(`DETACH DELETE ${targetVar}`);
418
+ lines.push(`RETURN count(*) AS _del_${args.tag}_${idx}`);
419
+ lines.push('}');
420
+ });
421
+ return lines;
422
+ }
245
423
  /**
246
424
  * Generate MERGE (upsert) Cypher with ON CREATE SET / ON MATCH SET.
247
425
  * Scalar properties only — nested relationship ops are not supported.
@@ -674,11 +852,10 @@ export class MutationCompiler {
674
852
  const connectVar = `${nodeVar}_cn${startCounter + counter}`;
675
853
  counter++;
676
854
  lines.push(`MATCH (${connectVar}:${targetLabelStr})`);
677
- const conditions = this.buildConnectionWhereConditions(whereSpec, connectVar, `${prefix}_conn${ci}`, params, targetNodeDef);
678
- const policyPredicate = this.buildTargetPolicyPredicate(targetNodeDef, connectVar, params, policyContext, paramCounter);
679
- const connectConditions = policyPredicate
680
- ? [...conditions, policyPredicate]
681
- : conditions;
855
+ const connectConditions = [
856
+ this.compileTargetWhere(whereSpec, connectVar, targetNodeDef, params, policyContext, paramCounter),
857
+ this.buildTargetPolicyPredicate(targetNodeDef, connectVar, params, policyContext, paramCounter),
858
+ ].filter((c) => Boolean(c));
682
859
  if (connectConditions.length > 0)
683
860
  lines.push(`WHERE ${connectConditions.join(' AND ')}`);
684
861
  if (edgeInput && Object.keys(edgeInput).length > 0) {
@@ -732,13 +909,13 @@ export class MutationCompiler {
732
909
  if (Array.isArray(spec)) {
733
910
  if (spec.length === 0)
734
911
  continue; // Empty array — nothing to connect
735
- // Check if WHERE contains relationship filters (nested objects referencing
736
- // relationships on the target node). UNWIND can't handle EXISTS subqueries
737
- // with dynamic params, so we fall back to individual CALL subqueries.
738
- const hasRelFilters = this.arrayConnectHasRelationshipFilters(spec, targetNodeDef);
739
- if (hasRelFilters)
740
- // Fall back to individual CALL subqueries per item — these use
741
- // buildConnectionWhereConditions which handles relationship filters.
912
+ // The UNWIND fast path compiles filters against per-row values and
913
+ // is only exact for plain scalar, non-null filters (see
914
+ // isConnectFastPathEligible). Everything else — null values,
915
+ // logical keys, traversals — takes one CALL subquery per item,
916
+ // compiled through WhereCompiler.
917
+ const useFastPath = this.isConnectFastPathEligible(spec, targetNodeDef);
918
+ if (!useFastPath)
742
919
  for (let ci = 0; ci < spec.length; ci++) {
743
920
  const connectSpec = spec[ci];
744
921
  const whereSpec = connectSpec.where;
@@ -748,11 +925,10 @@ export class MutationCompiler {
748
925
  lines.push('WITH n');
749
926
  const connectVar = `target_${fieldName}_${ci}`;
750
927
  lines.push(`MATCH (${connectVar}:${targetLabelStr})`);
751
- const conditions = this.buildConnectionWhereConditions(whereSpec, connectVar, `connect_${fieldName}_${ci}`, params, targetNodeDef);
752
- const policyPredicate = this.buildTargetPolicyPredicate(targetNodeDef, connectVar, params, policyContext, paramCounter);
753
- const connectConditions = policyPredicate
754
- ? [...conditions, policyPredicate]
755
- : conditions;
928
+ const connectConditions = [
929
+ this.compileTargetWhere(whereSpec, connectVar, targetNodeDef, params, policyContext, paramCounter),
930
+ this.buildTargetPolicyPredicate(targetNodeDef, connectVar, params, policyContext, paramCounter),
931
+ ].filter((c) => Boolean(c));
756
932
  if (connectConditions.length > 0)
757
933
  lines.push(`WHERE ${connectConditions.join(' AND ')}`);
758
934
  if (edgeInput && Object.keys(edgeInput).length > 0) {
@@ -860,11 +1036,10 @@ export class MutationCompiler {
860
1036
  const edgeInput = connectSpec.edge;
861
1037
  lines.push('WITH n');
862
1038
  lines.push(`MATCH (target:${targetLabelStr})`);
863
- const matchConditions = this.buildConnectionWhereConditions(whereSpec, 'target', `connect_${fieldName}`, params, targetNodeDef);
864
- const policyPredicate = this.buildTargetPolicyPredicate(targetNodeDef, 'target', params, policyContext, paramCounter);
865
- const singleConnectConditions = policyPredicate
866
- ? [...matchConditions, policyPredicate]
867
- : matchConditions;
1039
+ const singleConnectConditions = [
1040
+ this.compileTargetWhere(whereSpec, 'target', targetNodeDef, params, policyContext, paramCounter),
1041
+ this.buildTargetPolicyPredicate(targetNodeDef, 'target', params, policyContext, paramCounter),
1042
+ ].filter((c) => Boolean(c));
868
1043
  if (singleConnectConditions.length > 0)
869
1044
  lines.push(`WHERE ${singleConnectConditions.join(' AND ')}`);
870
1045
  if (edgeInput && Object.keys(edgeInput).length > 0) {
@@ -937,13 +1112,12 @@ export class MutationCompiler {
937
1112
  targetLabel: 'auto',
938
1113
  });
939
1114
  lines.push(`OPTIONAL MATCH ${pattern}`);
940
- const conditions = this.buildConnectionWhereConditions(whereSpec, targetVar, `disconnect_${fieldName}_${si}`, params, targetNodeDef);
941
- const policyPredicate = targetNodeDef
942
- ? this.buildTargetPolicyPredicate(targetNodeDef, targetVar, params, policyContext, paramCounter)
943
- : null;
944
- const disconnectConditions = policyPredicate
945
- ? [...conditions, policyPredicate]
946
- : conditions;
1115
+ if (!targetNodeDef)
1116
+ throw new OGMError(`Cannot resolve the target type "${relDef.target}" of relationship "${fieldName}".`);
1117
+ const disconnectConditions = [
1118
+ this.compileTargetWhere(whereSpec, targetVar, targetNodeDef, params, policyContext, paramCounter),
1119
+ this.buildTargetPolicyPredicate(targetNodeDef, targetVar, params, policyContext, paramCounter),
1120
+ ].filter((c) => Boolean(c));
947
1121
  if (disconnectConditions.length > 0)
948
1122
  lines.push(`WHERE ${disconnectConditions.join(' AND ')}`);
949
1123
  lines.push(`DELETE ${relVar}`);
@@ -1019,47 +1193,21 @@ export class MutationCompiler {
1019
1193
  const item = items[i];
1020
1194
  const itemPrefix = `${relPrefix}_${i}`;
1021
1195
  // --- delete (detach delete target nodes) ---
1022
- // Wrapped in CALL subquery to prevent OPTIONAL MATCH from multiplying
1023
- // pipeline rows (N existing nodes → N rows → N subsequent CREATEs).
1024
- if (item.delete) {
1025
- const deleteItems = Array.isArray(item.delete)
1026
- ? item.delete
1027
- : [item.delete];
1028
- for (let di = 0; di < deleteItems.length; di++) {
1029
- const deleteSpec = deleteItems[di];
1030
- const whereSpec = deleteSpec.where;
1031
- const delTarget = `${sourceVar}_del${varCounter}`;
1032
- const delRel = `r_del_${key}_${i}_${di}`;
1033
- varCounter++;
1034
- lines.push(withClause);
1035
- lines.push(`CALL {`);
1036
- lines.push(withClause);
1037
- const pattern = buildRelPattern({
1038
- sourceVar,
1039
- relDef,
1040
- targetVar: delTarget,
1041
- edgeVar: delRel,
1042
- targetLabel: 'auto',
1043
- });
1044
- lines.push(`OPTIONAL MATCH ${pattern}`);
1045
- if (whereSpec && Object.keys(whereSpec).length > 0) {
1046
- const nodeWhere = (whereSpec.node ?? whereSpec);
1047
- // Use the same WHERE-condition builder as the disconnect /
1048
- // connect paths so operator suffixes (_GT/_CONTAINS/_IN/etc.),
1049
- // `NOT`, and relationship sub-filters work consistently. The
1050
- // pre-1.7.2 inline implementation only emitted `prop = $param`
1051
- // for every key, silently turning operator-suffixed keys
1052
- // (`name_CONTAINS`) into property lookups against
1053
- // non-existent fields → zero rows deleted.
1054
- const conditions = this.buildNodeWhereConditions(nodeWhere, delTarget, `${itemPrefix}_del${di}`, params, targetNodeDef);
1055
- if (conditions.length > 0)
1056
- lines.push(`WHERE ${conditions.join(' AND ')}`);
1057
- }
1058
- lines.push(`DETACH DELETE ${delTarget}`);
1059
- lines.push(`RETURN count(*) AS _del_${key}_${i}_${di}`);
1060
- lines.push(`}`);
1061
- }
1062
- }
1196
+ // Shared with `compileDelete`'s cascade: where + target `delete`
1197
+ // policy, one CALL subquery per spec item (v2.3.0).
1198
+ if (item.delete)
1199
+ lines.push(...this.buildNestedDelete({
1200
+ spec: item.delete,
1201
+ relDef,
1202
+ sourceVar,
1203
+ withClause,
1204
+ targetNodeDef,
1205
+ nextVar: () => `${sourceVar}_del${varCounter++}`,
1206
+ tag: `${key}_${i}`,
1207
+ params,
1208
+ policyContext,
1209
+ paramCounter,
1210
+ }));
1063
1211
  // --- create ---
1064
1212
  if (item.create) {
1065
1213
  const createItems = Array.isArray(item.create)
@@ -1140,11 +1288,10 @@ export class MutationCompiler {
1140
1288
  // keys are handled identically to the top-level disconnect
1141
1289
  // path (`buildDisconnects`). Restores backwards-compat with
1142
1290
  // @neo4j/graphql-ogm's nested update.disconnect[].where shape.
1143
- const conditions = this.buildConnectionWhereConditions(whereSpec, discTarget, `${itemPrefix}_disc_${di}`, params, targetNodeDef);
1144
- const policyPredicate = this.buildTargetPolicyPredicate(targetNodeDef, discTarget, params, policyContext, paramCounter);
1145
- const nestedDiscConditions = policyPredicate
1146
- ? [...conditions, policyPredicate]
1147
- : conditions;
1291
+ const nestedDiscConditions = [
1292
+ this.compileTargetWhere(whereSpec, discTarget, targetNodeDef, params, policyContext, paramCounter),
1293
+ this.buildTargetPolicyPredicate(targetNodeDef, discTarget, params, policyContext, paramCounter),
1294
+ ].filter((c) => Boolean(c));
1148
1295
  if (nestedDiscConditions.length > 0)
1149
1296
  lines.push(`WHERE ${nestedDiscConditions.join(' AND ')}`);
1150
1297
  lines.push(`DELETE ${relVar}`);
@@ -1174,11 +1321,10 @@ export class MutationCompiler {
1174
1321
  // connect handle the same shapes as nested disconnect and
1175
1322
  // top-level paths. Empty/undefined `whereSpec` short-circuits
1176
1323
  // to `[]` inside the helper, so the no-where case still works.
1177
- const conditions = this.buildConnectionWhereConditions(whereSpec, connectVar, `${itemPrefix}_conn${ci}`, params, targetNodeDef);
1178
- const policyPredicate = this.buildTargetPolicyPredicate(targetNodeDef, connectVar, params, policyContext, paramCounter);
1179
- const nestedConnConditions = policyPredicate
1180
- ? [...conditions, policyPredicate]
1181
- : conditions;
1324
+ const nestedConnConditions = [
1325
+ this.compileTargetWhere(whereSpec, connectVar, targetNodeDef, params, policyContext, paramCounter),
1326
+ this.buildTargetPolicyPredicate(targetNodeDef, connectVar, params, policyContext, paramCounter),
1327
+ ].filter((c) => Boolean(c));
1182
1328
  if (nestedConnConditions.length > 0)
1183
1329
  lines.push(`WHERE ${nestedConnConditions.join(' AND ')}`);
1184
1330
  if (edgeInput && Object.keys(edgeInput).length > 0) {
@@ -1285,11 +1431,15 @@ export class MutationCompiler {
1285
1431
  // compiler; this was the missed third sibling. The param
1286
1432
  // prefix is chosen so the legacy plain-equality case keeps
1287
1433
  // byte-identical param names (`${itemPrefix}_where_${prop}`).
1288
- if (updateWhere) {
1289
- const conditions = this.buildConnectionWhereConditions(updateWhere, updateVar, `${itemPrefix}_where`, params, targetNodeDef);
1290
- if (conditions.length > 0)
1291
- lines.push(`WHERE ${conditions.join(' AND ')}`);
1292
- }
1434
+ // v2.3.0 — the nested update is row-filtered by the TARGET's
1435
+ // `update` policy, exactly like a direct update on that type
1436
+ // (pre-2.3.0 it enforced no target policy at all).
1437
+ const updateConditions = [
1438
+ this.compileTargetWhere(updateWhere, updateVar, targetNodeDef, params, policyContext, paramCounter),
1439
+ this.buildTargetPolicyPredicate(targetNodeDef, updateVar, params, policyContext, paramCounter, 'update'),
1440
+ ].filter((c) => Boolean(c));
1441
+ if (updateConditions.length > 0)
1442
+ lines.push(`WHERE ${updateConditions.join(' AND ')}`);
1293
1443
  if (setClauses.length > 0)
1294
1444
  lines.push(`SET ${setClauses.join(', ')}`);
1295
1445
  mergeParams(params, setParams);
@@ -1317,180 +1467,6 @@ export class MutationCompiler {
1317
1467
  // No suffix — simple equality
1318
1468
  return { baseProp: key, template: '%v = $%p' };
1319
1469
  }
1320
- /**
1321
- * Build WHERE conditions for connect/disconnect operations.
1322
- * Handles operator suffixes (_IN, _NOT, _NOT_IN, _CONTAINS, etc.),
1323
- * NOT blocks, and relationship filters (_SOME, _NONE, _ALL, _SINGLE).
1324
- *
1325
- * @param targetNodeDef - Optional node definition for the target node.
1326
- * Required to resolve relationship filters like `configurations_SOME`.
1327
- * When omitted, relationship filters are silently skipped.
1328
- */
1329
- buildNodeWhereConditions(nodeWhere, targetVar, prefix, params, targetNodeDef) {
1330
- const conditions = [];
1331
- for (const [prop, val] of Object.entries(nodeWhere))
1332
- if (prop === 'NOT') {
1333
- const notSpec = val;
1334
- for (const [notProp, notVal] of Object.entries(notSpec)) {
1335
- const { baseProp, template } = this.parseOperatorSuffix(notProp);
1336
- assertSafePropertyName(baseProp, 'where property');
1337
- const paramName = `${prefix}_NOT_${notProp}`;
1338
- const escapedProp = escapeIdentifier(baseProp);
1339
- const paramRef = wrapWhereParam(`$${paramName}`, notVal, targetNodeDef?.properties.get(baseProp), notProp.slice(baseProp.length));
1340
- let expr;
1341
- if (template === '%v = $%p')
1342
- // Simple equality → negate to <>
1343
- expr = `${targetVar}.${escapedProp} <> ${paramRef}`;
1344
- else {
1345
- // Operator suffix — wrap with NOT if not already negated
1346
- const resolved = template
1347
- .replace('%v', `${targetVar}.${escapedProp}`)
1348
- .replace('$%p', paramRef);
1349
- expr = resolved.startsWith('NOT ') ? resolved : `NOT ${resolved}`;
1350
- }
1351
- conditions.push(expr);
1352
- params[paramName] = notVal;
1353
- }
1354
- }
1355
- else {
1356
- // Check for relationship filter suffixes (_SOME, _NONE, _ALL, _SINGLE)
1357
- const relFilter = this.tryBuildRelationshipFilter(prop, val, targetVar, prefix, params, targetNodeDef);
1358
- if (relFilter) {
1359
- conditions.push(relFilter);
1360
- continue;
1361
- }
1362
- const { baseProp, template } = this.parseOperatorSuffix(prop);
1363
- assertSafePropertyName(baseProp, 'where property');
1364
- const paramName = `${prefix}_${prop}`;
1365
- const paramRef = wrapWhereParam(`$${paramName}`, val, targetNodeDef?.properties.get(baseProp), prop.slice(baseProp.length));
1366
- conditions.push(template
1367
- .replace('%v', `${targetVar}.${escapeIdentifier(baseProp)}`)
1368
- .replace('$%p', paramRef));
1369
- params[paramName] = val;
1370
- }
1371
- return conditions;
1372
- }
1373
- /**
1374
- * Build WHERE conditions from a connection-level WHERE spec.
1375
- * Handles `node`, `NOT`, `AND`, `OR` at the connection level.
1376
- *
1377
- * Connection WHERE format: `{ node: {...}, NOT: { node: {...} }, AND: [...], OR: [...] }`
1378
- */
1379
- buildConnectionWhereConditions(whereSpec, targetVar, prefix, params, targetNodeDef) {
1380
- if (!whereSpec || Object.keys(whereSpec).length === 0)
1381
- return [];
1382
- const conditions = [];
1383
- // Direct node conditions
1384
- if (whereSpec.node) {
1385
- const nodeConditions = this.buildNodeWhereConditions(whereSpec.node, targetVar, prefix, params, targetNodeDef);
1386
- conditions.push(...nodeConditions);
1387
- }
1388
- // NOT wrapper — negate the inner connection conditions
1389
- if (whereSpec.NOT) {
1390
- const notSpec = whereSpec.NOT;
1391
- const innerConditions = this.buildConnectionWhereConditions(notSpec, targetVar, `${prefix}_NOT`, params, targetNodeDef);
1392
- if (innerConditions.length > 0)
1393
- conditions.push(`NOT (${innerConditions.join(' AND ')})`);
1394
- }
1395
- // AND — all sub-conditions must hold
1396
- if (Array.isArray(whereSpec.AND)) {
1397
- const before = conditions.length;
1398
- for (let i = 0; i < whereSpec.AND.length; i++) {
1399
- const sub = this.buildConnectionWhereConditions(whereSpec.AND[i], targetVar, `${prefix}_AND${i}`, params, targetNodeDef);
1400
- conditions.push(...sub);
1401
- }
1402
- if (conditions.length === before)
1403
- this.warnEmptyLogical('AND', 'mutation connection where');
1404
- }
1405
- // OR — any sub-condition must hold
1406
- if (Array.isArray(whereSpec.OR)) {
1407
- const orClauses = [];
1408
- for (let i = 0; i < whereSpec.OR.length; i++) {
1409
- const sub = this.buildConnectionWhereConditions(whereSpec.OR[i], targetVar, `${prefix}_OR${i}`, params, targetNodeDef);
1410
- if (sub.length > 0)
1411
- orClauses.push(`(${sub.join(' AND ')})`);
1412
- }
1413
- if (orClauses.length > 0)
1414
- conditions.push(`(${orClauses.join(' OR ')})`);
1415
- // Prisma semantics: OR with zero effective disjuncts matches NOTHING.
1416
- // Previously the operator vanished, so `disconnect: [{ where:
1417
- // { OR: [] } }]` detached EVERY related node instead of none.
1418
- else {
1419
- this.warnEmptyLogical('OR', 'mutation connection where');
1420
- conditions.push('false');
1421
- }
1422
- }
1423
- // Fallback: if no recognized keys, treat the whole spec as node conditions
1424
- // (backward compatibility for simple `{ id: '...' }` style)
1425
- if (!whereSpec.node && !whereSpec.NOT && !whereSpec.AND && !whereSpec.OR) {
1426
- // Reject unsupported connection WHERE keys (e.g., "edge")
1427
- if (whereSpec.edge !== undefined)
1428
- throw new OGMError(`Connection WHERE with "edge" filters is not supported in mutations. ` +
1429
- `Only "node", "NOT", "AND", "OR" keys or direct property conditions are allowed.`);
1430
- const fallbackConditions = this.buildNodeWhereConditions(whereSpec, targetVar, prefix, params, targetNodeDef);
1431
- conditions.push(...fallbackConditions);
1432
- }
1433
- return conditions;
1434
- }
1435
- /**
1436
- * Try to compile a relationship filter condition (_SOME, _NONE, _ALL, _SINGLE).
1437
- * Returns the Cypher expression string or null if the key is not a relationship filter.
1438
- */
1439
- tryBuildRelationshipFilter(key, value, nodeVar, prefix, params, targetNodeDef) {
1440
- if (!targetNodeDef || typeof value !== 'object' || value === null)
1441
- return null;
1442
- // Detect suffix and extract field name
1443
- let suffix = null;
1444
- let fieldName = '';
1445
- for (const s of RELATIONSHIP_SUFFIXES)
1446
- if (key.endsWith(s)) {
1447
- suffix = s;
1448
- fieldName = key.slice(0, -s.length);
1449
- break;
1450
- }
1451
- // If no suffix, check if bare key is a relationship (defaults to _SOME)
1452
- if (!suffix) {
1453
- const relDef = targetNodeDef.relationships.get(key);
1454
- if (!relDef)
1455
- return null;
1456
- suffix = '_SOME';
1457
- fieldName = key;
1458
- }
1459
- const relDef = targetNodeDef.relationships.get(fieldName);
1460
- if (!relDef)
1461
- return null;
1462
- const relTargetDef = resolveTargetDef(relDef.target, this.schema);
1463
- if (!relTargetDef)
1464
- return null;
1465
- const relVar = `rf${this.relFilterCounter++}`;
1466
- const innerWhere = value;
1467
- // Build the MATCH pattern
1468
- const edgePart = `[:${escapeIdentifier(relDef.type)}]`;
1469
- const escapedTarget = escapeIdentifier(relDef.target);
1470
- const pattern = relDef.direction === 'IN'
1471
- ? `(${nodeVar})<-${edgePart}-(${relVar}:${escapedTarget})`
1472
- : `(${nodeVar})-${edgePart}->(${relVar}:${escapedTarget})`;
1473
- // Recursively compile inner conditions
1474
- const innerConditions = this.buildNodeWhereConditions(innerWhere, relVar, `${prefix}_${fieldName}`, params, relTargetDef);
1475
- const innerClause = innerConditions.length > 0
1476
- ? ` WHERE ${innerConditions.join(' AND ')}`
1477
- : '';
1478
- switch (suffix) {
1479
- case '_SOME':
1480
- return `EXISTS { MATCH ${pattern}${innerClause} }`;
1481
- case '_NONE':
1482
- return `NOT EXISTS { MATCH ${pattern}${innerClause} }`;
1483
- case '_ALL':
1484
- // Double negation: all match ≡ none fail to match
1485
- if (!innerClause)
1486
- return '';
1487
- return `NOT EXISTS { MATCH ${pattern} WHERE NOT (${innerConditions.join(' AND ')}) }`;
1488
- case '_SINGLE':
1489
- return `size([${relVar} IN [(${pattern}${innerClause} | ${relVar})] | ${relVar}]) = 1`;
1490
- default:
1491
- return null;
1492
- }
1493
- }
1494
1470
  buildGeneratedIdClause(nodeDef) {
1495
1471
  const parts = [];
1496
1472
  for (const [, propDef] of nodeDef.properties)
@@ -1498,38 +1474,6 @@ export class MutationCompiler {
1498
1474
  parts.push(`${escapeIdentifier(propDef.name)}: randomUUID()`);
1499
1475
  return parts.join(', ');
1500
1476
  }
1501
- /**
1502
- * Check if any item in an array connect spec has relationship filters in its WHERE clause.
1503
- * Relationship filters require EXISTS subqueries which can't use UNWIND param references.
1504
- */
1505
- arrayConnectHasRelationshipFilters(items, targetNodeDef) {
1506
- for (const item of items) {
1507
- const whereSpec = item.where;
1508
- if (!whereSpec)
1509
- continue;
1510
- const nodeWhere = (whereSpec.node ?? whereSpec);
1511
- for (const key of Object.keys(nodeWhere)) {
1512
- // Strip operator suffixes to get the base field name
1513
- let fieldName = key;
1514
- for (const s of RELATIONSHIP_SUFFIXES)
1515
- if (key.endsWith(s)) {
1516
- fieldName = key.slice(0, -s.length);
1517
- break;
1518
- }
1519
- // If the base field name is a relationship on the target, it's a rel filter
1520
- if (targetNodeDef.relationships.has(fieldName))
1521
- return true;
1522
- // Also check if the value is an object (nested filter on a relationship)
1523
- const val = nodeWhere[key];
1524
- if (typeof val === 'object' &&
1525
- val !== null &&
1526
- !Array.isArray(val) &&
1527
- targetNodeDef.relationships.has(key))
1528
- return true;
1529
- }
1530
- }
1531
- return false;
1532
- }
1533
1477
  extractConnectWhereConditions(item, targetVar, itemVar, targetNodeDef) {
1534
1478
  const conditions = [];
1535
1479
  const whereSpec = item.where;
@@ -1586,6 +1530,99 @@ MutationCompiler.OPERATOR_SUFFIXES = [
1586
1530
  * path to validate that every item shares the same shape; mismatched
1587
1531
  * shapes silently drop keys from the compiled WHERE / SET.
1588
1532
  */
1533
+ /** Keys that make a nested-mutation `where` connection-shaped. */
1534
+ const CONNECTION_WHERE_KEYS = new Set([
1535
+ 'node',
1536
+ 'node_NOT',
1537
+ 'NOT',
1538
+ 'AND',
1539
+ 'OR',
1540
+ 'edge',
1541
+ 'edge_NOT',
1542
+ ]);
1543
+ /**
1544
+ * Map a nested-mutation connection where onto an equivalent NODE where on
1545
+ * the target, for `WhereCompiler` (v2.3.0). Mutation `where` never supports
1546
+ * edge filters, so the two shapes are interchangeable:
1547
+ *
1548
+ * { node: A } → A
1549
+ * { node_NOT: A } → { NOT: A }
1550
+ * { NOT: C } → { NOT: map(C) }
1551
+ * { AND: [C…] } → { AND: [map(C)…] } (likewise OR; `OR: []`
1552
+ * still matches nothing)
1553
+ * bare properties → themselves (legacy `{ id: 'x' }` shape)
1554
+ *
1555
+ * `edge`/`edge_NOT` throw (unsupported in mutations). In a connection-
1556
+ * shaped where, any other key throws too — the removed parallel builder
1557
+ * silently IGNORED such keys (`{ id: 'x', NOT: {…} }` dropped `id`),
1558
+ * widening the match.
1559
+ */
1560
+ export function connectionWhereToNodeWhere(spec) {
1561
+ if (!isPlainObject(spec))
1562
+ throw new OGMError('A nested mutation where must be an object.');
1563
+ const keys = Object.keys(spec).filter((k) => spec[k] !== undefined);
1564
+ const edgeKey = keys.find((k) => k === 'edge' || k === 'edge_NOT');
1565
+ if (edgeKey)
1566
+ throw new OGMError(`Connection WHERE with "${edgeKey}" filters is not supported in mutations. ` +
1567
+ `Only "node", "node_NOT", "NOT", "AND", "OR" keys or direct property conditions are allowed.`);
1568
+ if (!keys.some((k) => CONNECTION_WHERE_KEYS.has(k)))
1569
+ return spec;
1570
+ const parts = [];
1571
+ for (const key of keys) {
1572
+ const value = spec[key];
1573
+ if (value === null)
1574
+ continue;
1575
+ switch (key) {
1576
+ case 'node':
1577
+ parts.push(value);
1578
+ break;
1579
+ case 'node_NOT':
1580
+ parts.push({ NOT: value });
1581
+ break;
1582
+ case 'NOT':
1583
+ parts.push({
1584
+ NOT: connectionWhereToNodeWhere(value),
1585
+ });
1586
+ break;
1587
+ case 'AND':
1588
+ case 'OR':
1589
+ if (!Array.isArray(value))
1590
+ throw new OGMError(`"${key}" in a nested mutation where must be an array.`);
1591
+ parts.push({
1592
+ [key]: value.map((item) => connectionWhereToNodeWhere(item)),
1593
+ });
1594
+ break;
1595
+ default:
1596
+ throw new OGMError(`Unknown key "${key}" in a connection-shaped nested mutation where. ` +
1597
+ `Allowed: "node", "node_NOT", "NOT", "AND", "OR" — put property ` +
1598
+ `conditions inside "node".`);
1599
+ }
1600
+ }
1601
+ return parts.length === 1 ? parts[0] : { AND: parts };
1602
+ }
1603
+ /** A nested delete item's own `delete` asks for a deeper cascade. */
1604
+ function hasNestedCascade(value) {
1605
+ if (value === undefined || value === null)
1606
+ return false;
1607
+ if (Array.isArray(value))
1608
+ return value.length > 0;
1609
+ return isPlainObject(value) ? Object.keys(value).length > 0 : true;
1610
+ }
1611
+ /**
1612
+ * A `param<N>` counter that continues after every `param<N>` already in
1613
+ * `params`, for callers that did not thread their own counter (direct
1614
+ * `MutationCompiler` use): nested-write filters compiled with it can never
1615
+ * collide with the root WHERE's params.
1616
+ */
1617
+ function counterAfter(params) {
1618
+ let max = -1;
1619
+ for (const key of Object.keys(params)) {
1620
+ const match = /^param(\d+)$/.exec(key);
1621
+ if (match)
1622
+ max = Math.max(max, Number(match[1]));
1623
+ }
1624
+ return { count: max + 1 };
1625
+ }
1589
1626
  function computeConnectItemSignature(item) {
1590
1627
  const where = item.where;
1591
1628
  const nodeWhere = (where?.node ?? where ?? {});