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