grafeo-ogm 2.4.7 → 2.5.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 (203) hide show
  1. package/CHANGELOG.md +126 -0
  2. package/README.md +95 -35
  3. package/dist/cjs/cli/commands/db-push.d.ts.map +1 -1
  4. package/dist/cjs/cli/commands/db-push.js +74 -8
  5. package/dist/cjs/cli/commands/db-push.js.map +1 -1
  6. package/dist/cjs/cli/commands/db-seed.d.ts.map +1 -1
  7. package/dist/cjs/cli/commands/db-seed.js +10 -1
  8. package/dist/cjs/cli/commands/db-seed.js.map +1 -1
  9. package/dist/cjs/cli/introspect.d.ts +2 -2
  10. package/dist/cjs/cli/introspect.d.ts.map +1 -1
  11. package/dist/cjs/cli/introspect.js +44 -2
  12. package/dist/cjs/cli/introspect.js.map +1 -1
  13. package/dist/cjs/cli/push-planner.d.ts +57 -2
  14. package/dist/cjs/cli/push-planner.d.ts.map +1 -1
  15. package/dist/cjs/cli/push-planner.js +161 -3
  16. package/dist/cjs/cli/push-planner.js.map +1 -1
  17. package/dist/cjs/compilers/fulltext.compiler.d.ts.map +1 -1
  18. package/dist/cjs/compilers/fulltext.compiler.js +5 -1
  19. package/dist/cjs/compilers/fulltext.compiler.js.map +1 -1
  20. package/dist/cjs/compilers/mutation.compiler.d.ts +107 -1
  21. package/dist/cjs/compilers/mutation.compiler.d.ts.map +1 -1
  22. package/dist/cjs/compilers/mutation.compiler.js +695 -422
  23. package/dist/cjs/compilers/mutation.compiler.js.map +1 -1
  24. package/dist/cjs/compilers/select-normalizer.d.ts.map +1 -1
  25. package/dist/cjs/compilers/select-normalizer.js +12 -1
  26. package/dist/cjs/compilers/select-normalizer.js.map +1 -1
  27. package/dist/cjs/compilers/selection.compiler.d.ts +56 -0
  28. package/dist/cjs/compilers/selection.compiler.d.ts.map +1 -1
  29. package/dist/cjs/compilers/selection.compiler.js +271 -29
  30. package/dist/cjs/compilers/selection.compiler.js.map +1 -1
  31. package/dist/cjs/compilers/where.compiler.d.ts.map +1 -1
  32. package/dist/cjs/compilers/where.compiler.js +36 -15
  33. package/dist/cjs/compilers/where.compiler.js.map +1 -1
  34. package/dist/cjs/execution/executor.d.ts +34 -9
  35. package/dist/cjs/execution/executor.d.ts.map +1 -1
  36. package/dist/cjs/execution/executor.js +42 -5
  37. package/dist/cjs/execution/executor.js.map +1 -1
  38. package/dist/cjs/execution/result-mapper.d.ts.map +1 -1
  39. package/dist/cjs/execution/result-mapper.js +4 -2
  40. package/dist/cjs/execution/result-mapper.js.map +1 -1
  41. package/dist/cjs/generator/generate-types.d.ts.map +1 -1
  42. package/dist/cjs/generator/generate-types.js +76 -0
  43. package/dist/cjs/generator/generate-types.js.map +1 -1
  44. package/dist/cjs/generator/type-emitters/aggregation-emitter.d.ts.map +1 -1
  45. package/dist/cjs/generator/type-emitters/aggregation-emitter.js +19 -7
  46. package/dist/cjs/generator/type-emitters/aggregation-emitter.js.map +1 -1
  47. package/dist/cjs/generator/type-emitters/input-emitter.js +13 -4
  48. package/dist/cjs/generator/type-emitters/input-emitter.js.map +1 -1
  49. package/dist/cjs/generator/type-emitters/model-map-emitter.d.ts +1 -0
  50. package/dist/cjs/generator/type-emitters/model-map-emitter.d.ts.map +1 -1
  51. package/dist/cjs/generator/type-emitters/model-map-emitter.js +5 -20
  52. package/dist/cjs/generator/type-emitters/model-map-emitter.js.map +1 -1
  53. package/dist/cjs/generator/type-emitters/node-type-emitter.d.ts +3 -0
  54. package/dist/cjs/generator/type-emitters/node-type-emitter.d.ts.map +1 -1
  55. package/dist/cjs/generator/type-emitters/node-type-emitter.js +6 -11
  56. package/dist/cjs/generator/type-emitters/node-type-emitter.js.map +1 -1
  57. package/dist/cjs/generator/type-emitters/select-fields-emitter.js +21 -2
  58. package/dist/cjs/generator/type-emitters/select-fields-emitter.js.map +1 -1
  59. package/dist/cjs/generator/type-emitters/where-emitter.js +19 -3
  60. package/dist/cjs/generator/type-emitters/where-emitter.js.map +1 -1
  61. package/dist/cjs/index.d.ts +1 -1
  62. package/dist/cjs/index.d.ts.map +1 -1
  63. package/dist/cjs/interface-model.d.ts +12 -0
  64. package/dist/cjs/interface-model.d.ts.map +1 -1
  65. package/dist/cjs/interface-model.js +27 -13
  66. package/dist/cjs/interface-model.js.map +1 -1
  67. package/dist/cjs/model.d.ts.map +1 -1
  68. package/dist/cjs/model.js +12 -0
  69. package/dist/cjs/model.js.map +1 -1
  70. package/dist/cjs/ogm.d.ts +22 -2
  71. package/dist/cjs/ogm.d.ts.map +1 -1
  72. package/dist/cjs/ogm.js +42 -5
  73. package/dist/cjs/ogm.js.map +1 -1
  74. package/dist/cjs/policy/nested-writes.d.ts.map +1 -1
  75. package/dist/cjs/policy/nested-writes.js +40 -20
  76. package/dist/cjs/policy/nested-writes.js.map +1 -1
  77. package/dist/cjs/schema/parser.d.ts.map +1 -1
  78. package/dist/cjs/schema/parser.js +94 -16
  79. package/dist/cjs/schema/parser.js.map +1 -1
  80. package/dist/cjs/schema/types.d.ts +1 -1
  81. package/dist/cjs/schema/types.d.ts.map +1 -1
  82. package/dist/cjs/subgraph/subgraph-operations.d.ts.map +1 -1
  83. package/dist/cjs/subgraph/subgraph-operations.js +71 -6
  84. package/dist/cjs/subgraph/subgraph-operations.js.map +1 -1
  85. package/dist/cjs/subgraph/types.d.ts +14 -0
  86. package/dist/cjs/subgraph/types.d.ts.map +1 -1
  87. package/dist/cjs/testing/cypher-assert.d.ts +19 -1
  88. package/dist/cjs/testing/cypher-assert.d.ts.map +1 -1
  89. package/dist/cjs/testing/cypher-assert.js +217 -13
  90. package/dist/cjs/testing/cypher-assert.js.map +1 -1
  91. package/dist/cjs/testing/neo4j-record-factory.d.ts +29 -2
  92. package/dist/cjs/testing/neo4j-record-factory.d.ts.map +1 -1
  93. package/dist/cjs/testing/neo4j-record-factory.js +93 -3
  94. package/dist/cjs/testing/neo4j-record-factory.js.map +1 -1
  95. package/dist/cjs/utils/cypher-field-projection.d.ts +31 -3
  96. package/dist/cjs/utils/cypher-field-projection.d.ts.map +1 -1
  97. package/dist/cjs/utils/cypher-field-projection.js +59 -7
  98. package/dist/cjs/utils/cypher-field-projection.js.map +1 -1
  99. package/dist/cjs/utils/write-coercion.d.ts +18 -0
  100. package/dist/cjs/utils/write-coercion.d.ts.map +1 -1
  101. package/dist/cjs/utils/write-coercion.js +74 -1
  102. package/dist/cjs/utils/write-coercion.js.map +1 -1
  103. package/dist/esm/cli/commands/db-push.d.ts.map +1 -1
  104. package/dist/esm/cli/commands/db-push.js +74 -8
  105. package/dist/esm/cli/commands/db-push.js.map +1 -1
  106. package/dist/esm/cli/commands/db-seed.d.ts.map +1 -1
  107. package/dist/esm/cli/commands/db-seed.js +10 -1
  108. package/dist/esm/cli/commands/db-seed.js.map +1 -1
  109. package/dist/esm/cli/introspect.d.ts +2 -2
  110. package/dist/esm/cli/introspect.d.ts.map +1 -1
  111. package/dist/esm/cli/introspect.js +44 -2
  112. package/dist/esm/cli/introspect.js.map +1 -1
  113. package/dist/esm/cli/push-planner.d.ts +57 -2
  114. package/dist/esm/cli/push-planner.d.ts.map +1 -1
  115. package/dist/esm/cli/push-planner.js +162 -4
  116. package/dist/esm/cli/push-planner.js.map +1 -1
  117. package/dist/esm/compilers/fulltext.compiler.d.ts.map +1 -1
  118. package/dist/esm/compilers/fulltext.compiler.js +5 -1
  119. package/dist/esm/compilers/fulltext.compiler.js.map +1 -1
  120. package/dist/esm/compilers/mutation.compiler.d.ts +107 -1
  121. package/dist/esm/compilers/mutation.compiler.d.ts.map +1 -1
  122. package/dist/esm/compilers/mutation.compiler.js +692 -422
  123. package/dist/esm/compilers/mutation.compiler.js.map +1 -1
  124. package/dist/esm/compilers/select-normalizer.d.ts.map +1 -1
  125. package/dist/esm/compilers/select-normalizer.js +12 -1
  126. package/dist/esm/compilers/select-normalizer.js.map +1 -1
  127. package/dist/esm/compilers/selection.compiler.d.ts +56 -0
  128. package/dist/esm/compilers/selection.compiler.d.ts.map +1 -1
  129. package/dist/esm/compilers/selection.compiler.js +273 -31
  130. package/dist/esm/compilers/selection.compiler.js.map +1 -1
  131. package/dist/esm/compilers/where.compiler.d.ts.map +1 -1
  132. package/dist/esm/compilers/where.compiler.js +37 -16
  133. package/dist/esm/compilers/where.compiler.js.map +1 -1
  134. package/dist/esm/execution/executor.d.ts +34 -9
  135. package/dist/esm/execution/executor.d.ts.map +1 -1
  136. package/dist/esm/execution/executor.js +41 -5
  137. package/dist/esm/execution/executor.js.map +1 -1
  138. package/dist/esm/execution/result-mapper.d.ts.map +1 -1
  139. package/dist/esm/execution/result-mapper.js +4 -2
  140. package/dist/esm/execution/result-mapper.js.map +1 -1
  141. package/dist/esm/generator/generate-types.d.ts.map +1 -1
  142. package/dist/esm/generator/generate-types.js +76 -0
  143. package/dist/esm/generator/generate-types.js.map +1 -1
  144. package/dist/esm/generator/type-emitters/aggregation-emitter.d.ts.map +1 -1
  145. package/dist/esm/generator/type-emitters/aggregation-emitter.js +19 -7
  146. package/dist/esm/generator/type-emitters/aggregation-emitter.js.map +1 -1
  147. package/dist/esm/generator/type-emitters/input-emitter.js +13 -4
  148. package/dist/esm/generator/type-emitters/input-emitter.js.map +1 -1
  149. package/dist/esm/generator/type-emitters/model-map-emitter.d.ts +1 -0
  150. package/dist/esm/generator/type-emitters/model-map-emitter.d.ts.map +1 -1
  151. package/dist/esm/generator/type-emitters/model-map-emitter.js +5 -20
  152. package/dist/esm/generator/type-emitters/model-map-emitter.js.map +1 -1
  153. package/dist/esm/generator/type-emitters/node-type-emitter.d.ts +3 -0
  154. package/dist/esm/generator/type-emitters/node-type-emitter.d.ts.map +1 -1
  155. package/dist/esm/generator/type-emitters/node-type-emitter.js +6 -11
  156. package/dist/esm/generator/type-emitters/node-type-emitter.js.map +1 -1
  157. package/dist/esm/generator/type-emitters/select-fields-emitter.js +21 -2
  158. package/dist/esm/generator/type-emitters/select-fields-emitter.js.map +1 -1
  159. package/dist/esm/generator/type-emitters/where-emitter.js +19 -3
  160. package/dist/esm/generator/type-emitters/where-emitter.js.map +1 -1
  161. package/dist/esm/index.d.ts +1 -1
  162. package/dist/esm/index.d.ts.map +1 -1
  163. package/dist/esm/interface-model.d.ts +12 -0
  164. package/dist/esm/interface-model.d.ts.map +1 -1
  165. package/dist/esm/interface-model.js +27 -13
  166. package/dist/esm/interface-model.js.map +1 -1
  167. package/dist/esm/model.d.ts.map +1 -1
  168. package/dist/esm/model.js +12 -0
  169. package/dist/esm/model.js.map +1 -1
  170. package/dist/esm/ogm.d.ts +22 -2
  171. package/dist/esm/ogm.d.ts.map +1 -1
  172. package/dist/esm/ogm.js +43 -6
  173. package/dist/esm/ogm.js.map +1 -1
  174. package/dist/esm/policy/nested-writes.d.ts.map +1 -1
  175. package/dist/esm/policy/nested-writes.js +40 -20
  176. package/dist/esm/policy/nested-writes.js.map +1 -1
  177. package/dist/esm/schema/parser.d.ts.map +1 -1
  178. package/dist/esm/schema/parser.js +94 -16
  179. package/dist/esm/schema/parser.js.map +1 -1
  180. package/dist/esm/schema/types.d.ts +1 -1
  181. package/dist/esm/schema/types.d.ts.map +1 -1
  182. package/dist/esm/subgraph/subgraph-operations.d.ts.map +1 -1
  183. package/dist/esm/subgraph/subgraph-operations.js +71 -6
  184. package/dist/esm/subgraph/subgraph-operations.js.map +1 -1
  185. package/dist/esm/subgraph/types.d.ts +14 -0
  186. package/dist/esm/subgraph/types.d.ts.map +1 -1
  187. package/dist/esm/testing/cypher-assert.d.ts +19 -1
  188. package/dist/esm/testing/cypher-assert.d.ts.map +1 -1
  189. package/dist/esm/testing/cypher-assert.js +217 -13
  190. package/dist/esm/testing/cypher-assert.js.map +1 -1
  191. package/dist/esm/testing/neo4j-record-factory.d.ts +29 -2
  192. package/dist/esm/testing/neo4j-record-factory.d.ts.map +1 -1
  193. package/dist/esm/testing/neo4j-record-factory.js +93 -3
  194. package/dist/esm/testing/neo4j-record-factory.js.map +1 -1
  195. package/dist/esm/utils/cypher-field-projection.d.ts +31 -3
  196. package/dist/esm/utils/cypher-field-projection.d.ts.map +1 -1
  197. package/dist/esm/utils/cypher-field-projection.js +58 -7
  198. package/dist/esm/utils/cypher-field-projection.js.map +1 -1
  199. package/dist/esm/utils/write-coercion.d.ts +18 -0
  200. package/dist/esm/utils/write-coercion.d.ts.map +1 -1
  201. package/dist/esm/utils/write-coercion.js +73 -1
  202. package/dist/esm/utils/write-coercion.js.map +1 -1
  203. package/package.json +1 -1
@@ -5,6 +5,33 @@ import { buildRelPattern, getTargetLabelString, resolveTargetDef, } from '../sch
5
5
  import { assertSafeIdentifier, assertSafeLabel, assertSafePropertyName, escapeIdentifier, isPlainObject, mergeParams, } from '../utils/validation';
6
6
  import { coerceWriteValue, resolveDefaultWriteValue, wrapListItemExpr, wrapWhereParam, wrapWriteExpr, } from '../utils/write-coercion';
7
7
  import { WhereCompiler } from './where.compiler';
8
+ /** The root node `n` of an update — the pre-2.4.8 names, unprefixed. */
9
+ const TOP_SCOPE = {
10
+ sourceVar: 'n',
11
+ carryVars: ['n'],
12
+ withClause: 'WITH n',
13
+ varPrefix: '',
14
+ depth: 0,
15
+ };
16
+ /** Max depth of connects nested in connects (disconnects likewise). */
17
+ const MAX_NESTED_LINK_DEPTH = 5;
18
+ /**
19
+ * The scope of a connect / disconnect nested in an item whose target is
20
+ * bound as `targetVar`: that node becomes the source, everything in scope
21
+ * stays carried, and its own names are prefixed by it.
22
+ */
23
+ function nestedScope(carryVars, targetVar, depth) {
24
+ if (depth >= MAX_NESTED_LINK_DEPTH)
25
+ throw new OGMError(`Nested connect / disconnect deeper than ${MAX_NESTED_LINK_DEPTH} levels is not supported.`);
26
+ const vars = [...new Set([...carryVars, targetVar])];
27
+ return {
28
+ sourceVar: targetVar,
29
+ carryVars: vars,
30
+ withClause: `WITH ${vars.join(', ')}`,
31
+ varPrefix: `${targetVar}_`,
32
+ depth: depth + 1,
33
+ };
34
+ }
8
35
  /**
9
36
  * Compiles create, update, delete, and label mutations into Cypher + params.
10
37
  */
@@ -16,6 +43,13 @@ export class MutationCompiler {
16
43
  * Bounded by the number of node types in the schema (~50-100 entries).
17
44
  */
18
45
  this.labelCache = new Map();
46
+ /**
47
+ * Relationships narrowed from an abstract target to one member by the
48
+ * per-member dispatch. A BLANKET disconnect on one adds the member's
49
+ * label: the relationship type alone also reaches the other members, so
50
+ * pre-2.4.8 `{ Ebook: [{ disconnect: [{}] }] }` unlinked every member.
51
+ */
52
+ this.narrowedRelDefs = new WeakSet();
19
53
  this.logger = options?.logger;
20
54
  this.policyWhereCompiler = options?.whereCompiler;
21
55
  }
@@ -134,6 +168,9 @@ export class MutationCompiler {
134
168
  */
135
169
  isConnectFastPathEligible(items, targetNodeDef) {
136
170
  for (const item of items) {
171
+ // A nested `connect` is per item (v2.4.8): the UNWIND cannot run it.
172
+ if (item.connect != null)
173
+ return false;
137
174
  const where = item.where;
138
175
  if (where === undefined)
139
176
  continue;
@@ -185,6 +222,36 @@ export class MutationCompiler {
185
222
  this.labelCache.set(nodeDef.typeName, labelStr);
186
223
  return labelStr;
187
224
  }
225
+ /**
226
+ * The MATCH that binds a connect TARGET by type. A node type keeps its
227
+ * label pattern; an interface / union target matches its MEMBERS' labels
228
+ * through a WHERE predicate (v2.4.8). Pre-2.4.8 it matched the abstract
229
+ * type's own label, which implementer and member nodes do not carry, so
230
+ * the connect linked nothing.
231
+ */
232
+ targetMatch(targetNodeDef, targetVar) {
233
+ const abstract = abstractTarget(targetNodeDef.typeName, this.schema);
234
+ if (!abstract)
235
+ return {
236
+ match: `MATCH (${targetVar}:${this.getCachedLabelString(targetNodeDef)})`,
237
+ labelPredicate: null,
238
+ };
239
+ const alternatives = abstract.members.map((m) => `${targetVar}:${this.getCachedLabelString(this.schema.nodes.get(m))}`);
240
+ return {
241
+ match: `MATCH (${targetVar})`,
242
+ labelPredicate: alternatives.length > 0 ? `(${alternatives.join(' OR ')})` : 'false',
243
+ };
244
+ }
245
+ /**
246
+ * Resolve a relationship's target for a nested write. Throws when the
247
+ * target is unknown — pre-2.4.8 the nested op was silently skipped.
248
+ */
249
+ requireTargetDef(relDef) {
250
+ const targetNodeDef = resolveTargetDef(relDef.target, this.schema);
251
+ if (!targetNodeDef)
252
+ throw new OGMError(`Cannot resolve the target type "${relDef.target}" of relationship "${relDef.fieldName}".`);
253
+ return targetNodeDef;
254
+ }
188
255
  /**
189
256
  * Generate CREATE Cypher for one or more nodes.
190
257
  * Handles scalar properties, nested relationship creates, and connects.
@@ -209,8 +276,10 @@ export class MutationCompiler {
209
276
  const input = inputs[i];
210
277
  const prefix = `create${i}`;
211
278
  const nodeVar = i === 0 ? 'n' : `n_${i}`;
212
- const { propString, propParams } = this.buildCreateProperties(input, nodeDef, prefix);
279
+ // Labels first: an unsafe label must fail as such, before any
280
+ // input-key check (v2.5.0 rejects unknown keys).
213
281
  const labelStr = this.getCachedLabelString(nodeDef);
282
+ const { propString, propParams } = this.buildCreateProperties(input, nodeDef, prefix);
214
283
  lines.push(`CREATE (${nodeVar}:${labelStr} { ${propString} })`);
215
284
  mergeParams(params, propParams);
216
285
  createdVars.push(nodeVar);
@@ -285,6 +354,7 @@ export class MutationCompiler {
285
354
  if (value === undefined)
286
355
  continue;
287
356
  assertSafePropertyName(key, 'update property');
357
+ assertKnownField(nodeDef, key, 'update');
288
358
  const propDef = nodeDef.properties.get(key);
289
359
  const paramName = `update_${key}`;
290
360
  const valueExpr = wrapWriteExpr(`$${paramName}`, value, propDef);
@@ -436,19 +506,31 @@ export class MutationCompiler {
436
506
  const labelStr = this.getCachedLabelString(nodeDef);
437
507
  const lines = [];
438
508
  const params = {};
439
- // Build MERGE key properties from where clause (scalar only)
509
+ // MERGE key: the `@id` / `@unique` fields of `where`, and nothing else
510
+ // (v2.5.0). Any other key throws:
511
+ // - a plain field (`{ email, name }`) became part of the MERGE
512
+ // pattern, so an existing node with that email but another name did
513
+ // not match — MERGE created a DUPLICATE beside it;
514
+ // - an operator key (`name_CONTAINS`) was dropped, silently widening
515
+ // the match to the remaining keys;
516
+ // - a typo (`usernme`) became a phantom property on the new node.
517
+ // Pre-1.7.4 any key set was accepted; 1.7.4 required at least one
518
+ // unique key but still let the others through.
440
519
  const mergeProps = [];
441
520
  const mergeKeyNames = [];
442
521
  for (const [key, value] of Object.entries(where)) {
443
- if (value === undefined || value === null)
444
- continue;
445
- // Skip relationship suffixes and operators
446
- if (key === 'AND' || key === 'OR' || key === 'NOT')
447
- continue;
448
- if (key.includes('_') && !nodeDef.properties.has(key))
522
+ if (value === undefined)
449
523
  continue;
450
- assertSafePropertyName(key, 'merge key');
451
524
  const propDef = nodeDef.properties.get(key);
525
+ if (!propDef ||
526
+ propDef.isCypher ||
527
+ !(propDef.isUnique || propDef.isGenerated))
528
+ throw new OGMError(`upsert "where" accepts only @id / @unique fields of ${nodeDef.typeName}; "${key}" is not one. ` +
529
+ `MERGE matches on exactly these keys — put other fields in "create" / "update", ` +
530
+ `or use findFirst() + create() / update() for a non-unique match.`);
531
+ if (value === null)
532
+ throw new OGMError(`upsert "where.${key}" is null; a MERGE key must have a value.`);
533
+ assertSafePropertyName(key, 'merge key');
452
534
  const paramName = `merge_${key}`;
453
535
  const valueExpr = wrapWriteExpr(`$${paramName}`, value, propDef);
454
536
  mergeProps.push(`${escapeIdentifier(key)}: ${valueExpr}`);
@@ -456,39 +538,21 @@ export class MutationCompiler {
456
538
  params[paramName] = coerceWriteValue(value, propDef);
457
539
  }
458
540
  if (mergeProps.length === 0)
459
- throw new OGMError('upsert requires at least one scalar property in "where" for MERGE key');
460
- // Validate that the MERGE key set contains at least one `@unique` or
461
- // `@id` property. Pre-1.7.4 we accepted any key set, so:
462
- // - A typo in `where` (e.g. `usernme`) became a phantom MERGE
463
- // property — Neo4j happily created a node with that mis-named
464
- // attribute on first use.
465
- // - A non-unique `where` (e.g. `where: { country: 'AR' }`) matched
466
- // multiple existing nodes; MERGE then fans out across all of
467
- // them, applying ON MATCH SET to every match. Almost always a
468
- // bug, never the developer's intent.
469
- // Requiring at least one unique/id key forces MERGE to target at
470
- // most one row.
471
- const hasUniqueOrId = mergeKeyNames.some((name) => {
472
- const prop = nodeDef.properties.get(name);
473
- // `isGenerated` is the parser's flag for the `@id` directive;
474
- // `isUnique` is the flag for `@unique`. Either is sufficient
475
- // to guarantee the MERGE pattern targets at most one row.
476
- return prop !== undefined && (prop.isUnique || prop.isGenerated);
477
- });
478
- if (!hasUniqueOrId)
479
- throw new OGMError(`upsert "where" must contain at least one property marked with @id or @unique. ` +
480
- `Got [${mergeKeyNames.map((n) => `"${n}"`).join(', ')}] — none of these are unique on ${nodeDef.typeName}. ` +
481
- `Without a unique key, MERGE can fan out across multiple existing nodes (applying ON MATCH SET to each) ` +
482
- `or create phantom properties on typo. If you really want a non-unique merge, use create() + update().`);
541
+ throw new OGMError(`upsert requires at least one scalar property in "where" for MERGE key: ` +
542
+ `an @id or @unique field of ${nodeDef.typeName}.`);
483
543
  lines.push(`MERGE (n:${labelStr} { ${mergeProps.join(', ')} })`);
484
544
  // ON CREATE SET — all properties from create input (scalar only)
485
545
  const createSets = [];
486
546
  for (const [key, value] of Object.entries(create)) {
487
547
  if (value === undefined)
488
548
  continue;
489
- if (nodeDef.relationships.has(key))
490
- continue;
549
+ if (nodeDef.relationships.has(key)) {
550
+ if (value === null)
551
+ continue;
552
+ throw upsertRelationshipError(nodeDef, key, 'create');
553
+ }
491
554
  assertSafePropertyName(key, 'create property');
555
+ assertKnownField(nodeDef, key, 'create');
492
556
  const propDef = nodeDef.properties.get(key);
493
557
  const paramName = `onCreate_${key}`;
494
558
  const valueExpr = wrapWriteExpr(`$${paramName}`, value, propDef);
@@ -533,9 +597,13 @@ export class MutationCompiler {
533
597
  for (const [key, value] of Object.entries(update)) {
534
598
  if (value === undefined)
535
599
  continue;
536
- if (nodeDef.relationships.has(key))
537
- continue;
600
+ if (nodeDef.relationships.has(key)) {
601
+ if (value === null)
602
+ continue;
603
+ throw upsertRelationshipError(nodeDef, key, 'update');
604
+ }
538
605
  assertSafePropertyName(key, 'update property');
606
+ assertKnownField(nodeDef, key, 'update');
539
607
  const propDef = nodeDef.properties.get(key);
540
608
  const paramName = `onMatch_${key}`;
541
609
  const valueExpr = wrapWriteExpr(`$${paramName}`, value, propDef);
@@ -609,16 +677,13 @@ export class MutationCompiler {
609
677
  for (const key of Object.keys(item)) {
610
678
  if (item[key] === undefined || scalarKeys.includes(key))
611
679
  continue;
612
- if (nodeDef.relationships.has(key)) {
613
- // Only the first item's relationship fields have ever been
614
- // rejected; in later items they were (and still are) skipped
615
- // unwritten. Rejecting them everywhere would make calls that
616
- // succeed today throw — tracked as a separate, breaking change.
617
- if (index > 0)
618
- continue;
619
- throw new OGMError(`createMany does not support relationship fields. Found: "${key}". Use create() for nested operations.`);
620
- }
680
+ // v2.5.0 — rejected in EVERY item. Pre-2.5.0 only the first item's
681
+ // relationship fields were; in later items the nested input was
682
+ // skipped unwritten.
683
+ if (nodeDef.relationships.has(key))
684
+ throw new OGMError(`createMany does not support relationship fields. Found: "${key}" in data[${index}]. Use create() for nested operations.`);
621
685
  assertSafePropertyName(key, 'createMany property');
686
+ assertKnownField(nodeDef, key, 'createMany');
622
687
  scalarKeys.push(key);
623
688
  }
624
689
  // Identify @id (generated) fields that are NOT provided in data
@@ -750,6 +815,7 @@ export class MutationCompiler {
750
815
  if (value === undefined)
751
816
  continue;
752
817
  assertSafePropertyName(key, 'create property');
818
+ assertKnownField(nodeDef, key, 'create');
753
819
  const propDef = nodeDef.properties.get(key);
754
820
  const paramName = `${prefix}_${key}`;
755
821
  const valueExpr = wrapWriteExpr(`$${paramName}`, value, propDef);
@@ -785,19 +851,20 @@ export class MutationCompiler {
785
851
  const relDef = nodeDef.relationships.get(key);
786
852
  if (!relDef)
787
853
  continue;
854
+ if (value == null)
855
+ continue;
788
856
  const relInput = value;
789
- // Check if target is a union type — if so, relInput uses per-member keys
790
- // e.g., items: { Ebook: { create: [...] } }
791
- const isUnionTarget = !this.schema.nodes.has(relDef.target) &&
792
- this.schema.unions?.has(relDef.target);
793
- if (isUnionTarget) {
794
- const unionMembers = this.schema.unions.get(relDef.target);
795
- for (const [memberKey, memberValue] of Object.entries(relInput)) {
796
- if (!unionMembers.includes(memberKey))
797
- continue;
857
+ // Union / interface targets: per-member keys (e.g. items: { Ebook:
858
+ // { create: [...] } }) dispatch to the concrete member; an interface
859
+ // also takes the flat `{ connect, create }` shape (v2.4.8 — pre-2.4.8
860
+ // interface targets were skipped entirely, so nothing was written).
861
+ const abstract = abstractTarget(relDef.target, this.schema);
862
+ const memberEntries = abstract
863
+ ? perMemberEntries(relInput, abstract, key)
864
+ : null;
865
+ if (memberEntries) {
866
+ for (const [memberKey, memberValue] of memberEntries) {
798
867
  const memberNodeDef = this.schema.nodes.get(memberKey);
799
- if (!memberNodeDef)
800
- continue;
801
868
  const memberInput = memberValue;
802
869
  // Process creates/connects for this union member
803
870
  const memberLines = this.buildCreateRelationshipsForTarget(memberInput, memberNodeDef, relDef, nodeVar, `${prefix}_${key}_${memberKey}`, params, allVars, withClause, nestedCounter, policyContext, paramCounter);
@@ -806,9 +873,7 @@ export class MutationCompiler {
806
873
  }
807
874
  continue;
808
875
  }
809
- const targetNodeDef = this.schema.nodes.get(relDef.target);
810
- if (!targetNodeDef)
811
- continue;
876
+ const targetNodeDef = this.requireTargetDef(relDef);
812
877
  const resultLines = this.buildCreateRelationshipsForTarget(relInput, targetNodeDef, relDef, nodeVar, `${prefix}_${key}`, params, allVars, withClause, nestedCounter, policyContext, paramCounter);
813
878
  nestedCounter += resultLines.counterUsed;
814
879
  lines.push(...resultLines.lines);
@@ -822,7 +887,8 @@ export class MutationCompiler {
822
887
  buildCreateRelationshipsForTarget(relInput, targetNodeDef, relDef, nodeVar, prefix, params, allVars, withClause, startCounter, policyContext, paramCounter) {
823
888
  const lines = [];
824
889
  let counter = 0;
825
- const targetLabelStr = this.getCachedLabelString(targetNodeDef);
890
+ // A flat interface input (v2.4.8): each create names its implementer.
891
+ const abstract = abstractTarget(targetNodeDef.typeName, this.schema);
826
892
  // --- create ---
827
893
  if (relInput.create) {
828
894
  const createItems = Array.isArray(relInput.create)
@@ -830,15 +896,22 @@ export class MutationCompiler {
830
896
  : [relInput.create];
831
897
  for (let ci = 0; ci < createItems.length; ci++) {
832
898
  const createSpec = createItems[ci];
833
- const nodeSpec = (createSpec.node ?? createSpec);
899
+ let nodeSpec = (createSpec.node ?? createSpec);
900
+ let createDef = targetNodeDef;
901
+ if (abstract) {
902
+ const [member, memberSpec] = unwrapAbstractCreateNode(nodeSpec, abstract, relDef.fieldName);
903
+ createDef = this.schema.nodes.get(member);
904
+ nodeSpec = memberSpec;
905
+ }
906
+ const targetLabelStr = this.getCachedLabelString(createDef);
834
907
  const createVar = `${nodeVar}_c${startCounter + counter}`;
835
908
  counter++;
836
- const { propString, propParams } = this.buildCreateProperties(nodeSpec, targetNodeDef, `${prefix}_create${ci}`);
909
+ const { propString, propParams } = this.buildCreateProperties(nodeSpec, createDef, `${prefix}_create${ci}`);
837
910
  // Handle nested relationship creates within the created node
838
- const nestedRelLines = this.buildCreateRelationships(nodeSpec, targetNodeDef, createVar, `${prefix}_create${ci}`, params, allVars, policyContext, paramCounter);
911
+ const nestedRelLines = this.buildCreateRelationships(nodeSpec, createDef, createVar, `${prefix}_create${ci}`, params, allVars, policyContext, paramCounter);
839
912
  const propsClause = propString.length > 0
840
913
  ? ` { ${propString} }`
841
- : ` { ${this.buildGeneratedIdClause(targetNodeDef)} }`;
914
+ : ` { ${this.buildGeneratedIdClause(createDef)} }`;
842
915
  const relPattern = buildRelPattern({
843
916
  sourceVar: nodeVar,
844
917
  relDef,
@@ -868,6 +941,9 @@ export class MutationCompiler {
868
941
  targetVar: createVar,
869
942
  edgeVar: relVar,
870
943
  targetLabel: 'auto',
944
+ // Abstract targets: no label — the created node never carries
945
+ // the union / interface label (v2.4.8).
946
+ schema: this.schema,
871
947
  });
872
948
  lines.push(`WITH ${[...allVars, createVar].join(', ')}`);
873
949
  lines.push(`MATCH ${relArrow}`);
@@ -897,339 +973,426 @@ export class MutationCompiler {
897
973
  ? relInput.connect
898
974
  : [relInput.connect];
899
975
  for (let ci = 0; ci < connectItems.length; ci++) {
900
- const connectSpec = connectItems[ci];
901
- const whereSpec = connectSpec.where;
902
- const edgeInput = connectSpec.edge;
903
976
  // Wrap in CALL subquery so a failed MATCH doesn't kill the outer pipeline
904
- lines.push(withClause);
905
- lines.push(`CALL {`);
906
- lines.push(withClause);
907
- const connectVar = `${nodeVar}_cn${startCounter + counter}`;
977
+ const index = startCounter + counter;
908
978
  counter++;
909
- lines.push(`MATCH (${connectVar}:${targetLabelStr})`);
910
- const connectConditions = [
911
- this.compileTargetWhere(whereSpec, connectVar, targetNodeDef, params, policyContext, paramCounter),
912
- this.buildTargetPolicyPredicate(targetNodeDef, connectVar, params, policyContext, paramCounter),
913
- ].filter((c) => Boolean(c));
914
- if (connectConditions.length > 0)
915
- lines.push(`WHERE ${connectConditions.join(' AND ')}`);
916
- if (edgeInput && Object.keys(edgeInput).length > 0) {
917
- // Use bare target var (no label) since connectVar is already bound by MATCH.
918
- // Including labels on a bound variable in MERGE causes Neo4j to reject it.
919
- const relVar = `r_conn_${startCounter + counter - 1}`;
920
- assertSafeIdentifier(relDef.type, 'relationship type');
921
- const escapedRelType = escapeIdentifier(relDef.type);
922
- const mergePattern = relDef.direction === 'OUT'
923
- ? `(${nodeVar})-[${relVar}:${escapedRelType}]->(${connectVar})`
924
- : `(${nodeVar})<-[${relVar}:${escapedRelType}]-(${connectVar})`;
925
- lines.push(`MERGE ${mergePattern}`);
926
- const setItems = [];
927
- for (const [prop, val] of Object.entries(edgeInput)) {
928
- if (val === undefined)
929
- continue;
930
- assertSafePropertyName(prop, 'edge property');
931
- const edgePropDef = this.getEdgePropDef(relDef, prop);
932
- const paramName = `${prefix}_conn${ci}_edge_${prop}`;
933
- setItems.push(`r_conn_${startCounter + counter - 1}.${escapeIdentifier(prop)} = ${wrapWriteExpr(`$${paramName}`, val, edgePropDef)}`);
934
- params[paramName] = coerceWriteValue(val, edgePropDef);
935
- }
936
- if (setItems.length > 0)
937
- lines.push(`SET ${setItems.join(', ')}`);
938
- }
939
- else {
940
- const relPattern = buildRelPattern({
979
+ lines.push(...this.buildConnectItem({
980
+ connectSpec: connectItems[ci],
981
+ relDef,
982
+ targetNodeDef,
983
+ sourceVar: nodeVar,
984
+ withClause,
985
+ connectVar: `${nodeVar}_cn${index}`,
986
+ relVar: `r_conn_${index}`,
987
+ edgeParamPrefix: `${prefix}_conn${ci}`,
988
+ returnAlias: `_ccn_${prefix}_${ci}`,
989
+ params,
990
+ policyContext,
991
+ paramCounter,
992
+ scope: {
941
993
  sourceVar: nodeVar,
942
- relDef,
943
- targetVar: connectVar,
944
- });
945
- lines.push(`MERGE ${relPattern}`);
946
- }
947
- lines.push(`RETURN count(*) AS _ccn_${prefix}_${ci}`);
948
- lines.push(`}`);
994
+ carryVars: allVars,
995
+ withClause,
996
+ varPrefix: '',
997
+ depth: 0,
998
+ },
999
+ }));
949
1000
  }
950
1001
  }
951
1002
  return { lines, counterUsed: counter };
952
1003
  }
953
- buildConnects(connect, nodeDef, params, policyContext, paramCounter) {
1004
+ /**
1005
+ * Top-level `connect`. A union / interface relationship keyed by member
1006
+ * (`{ media: { Book: [...] } }`, the shape codegen emits) connects each
1007
+ * member separately; a flat interface input matches the implementers by
1008
+ * their own labels (v2.4.8 — pre-2.4.8 both matched the abstract type's
1009
+ * label, which no node carries, and the per-member shape was never
1010
+ * unwrapped, so the MATCH carried NO filter at all).
1011
+ */
1012
+ buildConnects(connect, nodeDef, params, policyContext, paramCounter, scope = TOP_SCOPE) {
954
1013
  const lines = [];
955
1014
  for (const [fieldName, spec] of Object.entries(connect)) {
956
1015
  const relDef = nodeDef.relationships.get(fieldName);
957
1016
  if (!relDef)
958
1017
  continue;
959
- const targetNodeDef = resolveTargetDef(relDef.target, this.schema);
960
- if (!targetNodeDef)
1018
+ const abstract = abstractTarget(relDef.target, this.schema);
1019
+ const memberEntries = abstract && !isBlanketSpec(spec)
1020
+ ? perMemberEntries(spec, abstract, fieldName)
1021
+ : null;
1022
+ if (memberEntries) {
1023
+ for (const [member, memberSpec] of memberEntries)
1024
+ lines.push(...this.buildRelConnects(memberSpec, this.narrowToMember(relDef, member), this.schema.nodes.get(member), `${fieldName}_${member}`, params, policyContext, paramCounter, scope));
961
1025
  continue;
962
- const targetLabelStr = this.getCachedLabelString(targetNodeDef);
963
- // Array connect
964
- if (Array.isArray(spec)) {
965
- if (spec.length === 0)
966
- continue; // Empty array — nothing to connect
967
- // The UNWIND fast path compiles filters against per-row values and
968
- // is only exact for plain scalar, non-null filters (see
969
- // isConnectFastPathEligible). Everything else — null values,
970
- // logical keys, traversals — takes one CALL subquery per item,
971
- // compiled through WhereCompiler.
972
- const useFastPath = this.isConnectFastPathEligible(spec, targetNodeDef);
973
- if (!useFastPath)
974
- for (let ci = 0; ci < spec.length; ci++) {
975
- const connectSpec = spec[ci];
976
- const whereSpec = connectSpec.where;
977
- const edgeInput = connectSpec.edge;
978
- lines.push('WITH n');
979
- lines.push(`CALL {`);
980
- lines.push('WITH n');
981
- const connectVar = `target_${fieldName}_${ci}`;
982
- lines.push(`MATCH (${connectVar}:${targetLabelStr})`);
983
- const connectConditions = [
984
- this.compileTargetWhere(whereSpec, connectVar, targetNodeDef, params, policyContext, paramCounter),
985
- this.buildTargetPolicyPredicate(targetNodeDef, connectVar, params, policyContext, paramCounter),
986
- ].filter((c) => Boolean(c));
987
- if (connectConditions.length > 0)
988
- lines.push(`WHERE ${connectConditions.join(' AND ')}`);
989
- if (edgeInput && Object.keys(edgeInput).length > 0) {
990
- assertSafeIdentifier(relDef.type, 'relationship type');
991
- const escapedRelType = escapeIdentifier(relDef.type);
992
- const relVar = `r_conn_${fieldName}_${ci}`;
993
- const mergePattern = relDef.direction === 'OUT'
994
- ? `(n)-[${relVar}:${escapedRelType}]->(${connectVar})`
995
- : `(n)<-[${relVar}:${escapedRelType}]-(${connectVar})`;
996
- lines.push(`MERGE ${mergePattern}`);
997
- const setItems = [];
998
- for (const [prop, val] of Object.entries(edgeInput)) {
999
- if (val === undefined)
1000
- continue;
1001
- assertSafePropertyName(prop, 'edge property');
1002
- const edgePropDef = this.getEdgePropDef(relDef, prop);
1003
- const paramName = `connect_${fieldName}_${ci}_edge_${prop}`;
1004
- setItems.push(`${relVar}.${escapeIdentifier(prop)} = ${wrapWriteExpr(`$${paramName}`, val, edgePropDef)}`);
1005
- params[paramName] = coerceWriteValue(val, edgePropDef);
1006
- }
1007
- if (setItems.length > 0)
1008
- lines.push(`SET ${setItems.join(', ')}`);
1009
- }
1010
- else {
1011
- const mergePattern = buildRelPattern({
1012
- sourceVar: 'n',
1013
- relDef,
1014
- targetVar: connectVar,
1015
- });
1016
- lines.push(`MERGE ${mergePattern}`);
1017
- }
1018
- lines.push(`RETURN count(*) AS _ccn_${fieldName}_${ci}`);
1019
- lines.push(`}`);
1020
- }
1021
- else {
1022
- // Scalar-only WHERE — use efficient UNWIND approach
1023
- const paramName = `connect_${fieldName}`;
1024
- // Strip undefined values from edge objects — Neo4j driver drops undefined,
1025
- // causing param mismatches when the Cypher references nested edge properties.
1026
- params[paramName] = spec.map((item) => {
1027
- const edge = item.edge;
1028
- if (!edge)
1029
- return item;
1030
- const cleaned = {};
1031
- for (const [k, v] of Object.entries(edge))
1032
- if (v !== undefined)
1033
- cleaned[k] = coerceWriteValue(v, this.getEdgePropDef(relDef, k));
1034
- return { ...item, edge: cleaned };
1035
- });
1036
- // Wrap in a CALL subquery: UNWIND + MATCH yields one row per
1037
- // matched target (zero when nothing matches), which would
1038
- // multiply — or drop — the updated node for every later clause.
1039
- // Connecting to no target is a no-op, as in @neo4j/graphql.
1040
- lines.push('WITH n');
1041
- lines.push('CALL {');
1042
- lines.push('WITH n');
1043
- lines.push(`UNWIND $${paramName} AS connItem`);
1044
- lines.push(`MATCH (target:${targetLabelStr})`);
1045
- // Build WHERE from the first item's structure to determine the path.
1046
- // Pre-1.7.4 we silently used `firstItem`'s keys for ALL items —
1047
- // if `spec[1]` had different filter keys (e.g. an extra
1048
- // `tenantId`), those keys were silently dropped from the WHERE.
1049
- // Validate homogeneity now and throw if items diverge so the
1050
- // caller knows to split into separate calls or use the per-item
1051
- // CALL fallback.
1052
- const firstItem = spec[0];
1053
- const firstItemSig = computeConnectItemSignature(firstItem);
1054
- for (let idx = 1; idx < spec.length; idx++) {
1055
- const sig = computeConnectItemSignature(spec[idx]);
1056
- if (sig !== firstItemSig)
1057
- throw new OGMError(`connect array items have divergent shapes — item[0] has keys "${firstItemSig}" ` +
1058
- `but item[${idx}] has keys "${sig}". The UNWIND fast path requires every item ` +
1059
- `to share the same WHERE / edge key set; otherwise the additional keys are ` +
1060
- `silently dropped. Split into separate connect calls, or normalise the items ` +
1061
- `to share the same shape.`);
1062
- }
1063
- const whereConditions = this.extractConnectWhereConditions(firstItem, 'target', 'connItem', targetNodeDef);
1064
- const policyPredicate = this.buildTargetPolicyPredicate(targetNodeDef, 'target', params, policyContext, paramCounter);
1065
- const unwindConditions = policyPredicate
1066
- ? [...whereConditions, policyPredicate]
1067
- : whereConditions;
1068
- if (unwindConditions.length > 0)
1069
- lines.push(`WHERE ${unwindConditions.join(' AND ')}`);
1070
- const mergePattern = buildRelPattern({
1071
- sourceVar: 'n',
1072
- relDef,
1073
- targetVar: 'target',
1074
- });
1075
- lines.push(`MERGE ${mergePattern}`);
1076
- // Handle edge properties from first item structure
1077
- const edgeProps = this.extractEdgeProperties(firstItem);
1078
- if (edgeProps.length > 0) {
1079
- // Use bare target var (no label) since target is already bound by MATCH
1080
- assertSafeIdentifier(relDef.type, 'relationship type');
1081
- const escapedRelType = escapeIdentifier(relDef.type);
1082
- const mergeWithVar = relDef.direction === 'OUT'
1083
- ? `(n)-[r:${escapedRelType}]->(target)`
1084
- : `(n)<-[r:${escapedRelType}]-(target)`;
1085
- // Replace MERGE line with one that has a variable
1086
- lines[lines.length - 1] = `MERGE ${mergeWithVar}`;
1087
- // r.prop is relationship property (escape), connItem.edge.prop is parameter map (no escape)
1088
- const setItems = edgeProps.map((p) => `r.${escapeIdentifier(p)} = ${wrapListItemExpr(`connItem.edge.${p}`, spec.map((it) => it.edge?.[p]), this.getEdgePropDef(relDef, p))}`);
1089
- lines.push(`SET ${setItems.join(', ')}`);
1090
- }
1091
- lines.push(`RETURN count(*) AS _conn_${fieldName}`);
1092
- lines.push('}');
1093
- }
1094
1026
  }
1027
+ lines.push(...this.buildRelConnects(spec, relDef, this.requireTargetDef(relDef), fieldName, params, policyContext, paramCounter, scope));
1028
+ }
1029
+ return lines;
1030
+ }
1031
+ /**
1032
+ * One relationship's top-level connect. `tag` names its variables,
1033
+ * params and aliases: the field name, or `<field>_<Member>` for an
1034
+ * abstract relationship given per member, so two members never collide.
1035
+ */
1036
+ buildRelConnects(spec, relDef, targetNodeDef, tag, params, policyContext, paramCounter, scope) {
1037
+ const lines = [];
1038
+ const { sourceVar, withClause, varPrefix } = scope;
1039
+ // Array connect
1040
+ if (Array.isArray(spec)) {
1041
+ if (spec.length === 0)
1042
+ return lines; // Empty array — nothing to connect
1043
+ // The UNWIND fast path compiles filters against per-row values and
1044
+ // is only exact for plain scalar, non-null filters (see
1045
+ // isConnectFastPathEligible). Everything else — null values,
1046
+ // logical keys, traversals, abstract targets — takes one CALL
1047
+ // subquery per item, compiled through WhereCompiler.
1048
+ const useFastPath = !abstractTarget(targetNodeDef.typeName, this.schema) &&
1049
+ this.isConnectFastPathEligible(spec, targetNodeDef);
1050
+ if (!useFastPath)
1051
+ for (let ci = 0; ci < spec.length; ci++)
1052
+ lines.push(...this.buildConnectItem({
1053
+ connectSpec: spec[ci],
1054
+ relDef,
1055
+ targetNodeDef,
1056
+ sourceVar,
1057
+ withClause,
1058
+ connectVar: `${varPrefix}target_${tag}_${ci}`,
1059
+ relVar: `${varPrefix}r_conn_${tag}_${ci}`,
1060
+ edgeParamPrefix: `${varPrefix}connect_${tag}_${ci}`,
1061
+ returnAlias: `_ccn_${varPrefix}${tag}_${ci}`,
1062
+ params,
1063
+ policyContext,
1064
+ paramCounter,
1065
+ scope,
1066
+ }));
1095
1067
  else {
1096
- // Single connect
1097
- const connectSpec = spec;
1098
- const whereSpec = connectSpec.where;
1099
- const edgeInput = connectSpec.edge;
1100
- // Same row-preserving isolation as the UNWIND path above.
1101
- lines.push('WITH n');
1068
+ const targetVar = `${varPrefix}target`;
1069
+ const relVar = `${varPrefix}r`;
1070
+ const targetLabelStr = this.getCachedLabelString(targetNodeDef);
1071
+ // Scalar-only WHERE — use efficient UNWIND approach
1072
+ const paramName = `${varPrefix}connect_${tag}`;
1073
+ // Strip undefined values from edge objects — Neo4j driver drops undefined,
1074
+ // causing param mismatches when the Cypher references nested edge properties.
1075
+ params[paramName] = spec.map((raw) => {
1076
+ // The WHERE reads `connItem.where.node.<key>`, so a bare-property
1077
+ // where (`{ where: { title } }`, which the fast path accepts) is
1078
+ // rebound under `node`. Pre-2.4.8 it was sent as-is: every lookup
1079
+ // was null, so the array connect silently linked nothing.
1080
+ const where = raw.where;
1081
+ const item = isPlainObject(where) &&
1082
+ !Object.keys(where).some((k) => CONNECTION_WHERE_KEYS.has(k))
1083
+ ? { ...raw, where: { node: where } }
1084
+ : raw;
1085
+ const edge = item.edge;
1086
+ if (!edge)
1087
+ return item;
1088
+ const cleaned = {};
1089
+ for (const [k, v] of Object.entries(edge))
1090
+ if (v !== undefined)
1091
+ cleaned[k] = coerceWriteValue(v, this.getEdgePropDef(relDef, k));
1092
+ return { ...item, edge: cleaned };
1093
+ });
1094
+ // Wrap in a CALL subquery: UNWIND + MATCH yields one row per
1095
+ // matched target (zero when nothing matches), which would
1096
+ // multiply — or drop — the updated node for every later clause.
1097
+ // Connecting to no target is a no-op, as in @neo4j/graphql.
1098
+ lines.push(withClause);
1102
1099
  lines.push('CALL {');
1103
- lines.push('WITH n');
1104
- lines.push(`MATCH (target:${targetLabelStr})`);
1105
- const singleConnectConditions = [
1106
- this.compileTargetWhere(whereSpec, 'target', targetNodeDef, params, policyContext, paramCounter),
1107
- this.buildTargetPolicyPredicate(targetNodeDef, 'target', params, policyContext, paramCounter),
1108
- ].filter((c) => Boolean(c));
1109
- if (singleConnectConditions.length > 0)
1110
- lines.push(`WHERE ${singleConnectConditions.join(' AND ')}`);
1111
- if (edgeInput && Object.keys(edgeInput).length > 0) {
1100
+ lines.push(withClause);
1101
+ lines.push(`UNWIND $${paramName} AS connItem`);
1102
+ lines.push(`MATCH (${targetVar}:${targetLabelStr})`);
1103
+ // Build WHERE from the first item's structure to determine the path.
1104
+ // Pre-1.7.4 we silently used `firstItem`'s keys for ALL items —
1105
+ // if `spec[1]` had different filter keys (e.g. an extra
1106
+ // `tenantId`), those keys were silently dropped from the WHERE.
1107
+ // Validate homogeneity now and throw if items diverge so the
1108
+ // caller knows to split into separate calls or use the per-item
1109
+ // CALL fallback.
1110
+ const firstItem = spec[0];
1111
+ const firstItemSig = computeConnectItemSignature(firstItem);
1112
+ for (let idx = 1; idx < spec.length; idx++) {
1113
+ const sig = computeConnectItemSignature(spec[idx]);
1114
+ if (sig !== firstItemSig)
1115
+ throw new OGMError(`connect array items have divergent shapes — item[0] has keys "${firstItemSig}" ` +
1116
+ `but item[${idx}] has keys "${sig}". The UNWIND fast path requires every item ` +
1117
+ `to share the same WHERE / edge key set; otherwise the additional keys are ` +
1118
+ `silently dropped. Split into separate connect calls, or normalise the items ` +
1119
+ `to share the same shape.`);
1120
+ }
1121
+ const whereConditions = this.extractConnectWhereConditions(firstItem, targetVar, 'connItem', targetNodeDef);
1122
+ const policyPredicate = this.buildTargetPolicyPredicate(targetNodeDef, targetVar, params, policyContext, paramCounter);
1123
+ const unwindConditions = policyPredicate
1124
+ ? [...whereConditions, policyPredicate]
1125
+ : whereConditions;
1126
+ if (unwindConditions.length > 0)
1127
+ lines.push(`WHERE ${unwindConditions.join(' AND ')}`);
1128
+ const mergePattern = buildRelPattern({
1129
+ sourceVar,
1130
+ relDef,
1131
+ targetVar,
1132
+ });
1133
+ lines.push(`MERGE ${mergePattern}`);
1134
+ // Handle edge properties from first item structure
1135
+ const edgeProps = this.extractEdgeProperties(firstItem);
1136
+ if (edgeProps.length > 0) {
1112
1137
  // Use bare target var (no label) since target is already bound by MATCH
1113
1138
  assertSafeIdentifier(relDef.type, 'relationship type');
1114
1139
  const escapedRelType = escapeIdentifier(relDef.type);
1115
1140
  const mergeWithVar = relDef.direction === 'OUT'
1116
- ? `(n)-[r:${escapedRelType}]->(target)`
1117
- : `(n)<-[r:${escapedRelType}]-(target)`;
1118
- lines.push(`MERGE ${mergeWithVar}`);
1119
- const setItems = [];
1120
- for (const [prop, val] of Object.entries(edgeInput)) {
1121
- if (val === undefined)
1122
- continue;
1123
- const edgePropDef = this.getEdgePropDef(relDef, prop);
1124
- const paramName = `connect_${fieldName}_edge_${prop}`;
1125
- setItems.push(`r.${escapeIdentifier(prop)} = ${wrapWriteExpr(`$${paramName}`, val, edgePropDef)}`);
1126
- params[paramName] = coerceWriteValue(val, edgePropDef);
1127
- }
1128
- if (setItems.length > 0)
1129
- lines.push(`SET ${setItems.join(', ')}`);
1141
+ ? `(${sourceVar})-[${relVar}:${escapedRelType}]->(${targetVar})`
1142
+ : `(${sourceVar})<-[${relVar}:${escapedRelType}]-(${targetVar})`;
1143
+ // Replace MERGE line with one that has a variable
1144
+ lines[lines.length - 1] = `MERGE ${mergeWithVar}`;
1145
+ // r.prop is relationship property (escape), connItem.edge.prop is parameter map (no escape)
1146
+ const setItems = edgeProps.map((p) => `${relVar}.${escapeIdentifier(p)} = ${wrapListItemExpr(`connItem.edge.${p}`, spec.map((it) => it.edge?.[p]), this.getEdgePropDef(relDef, p))}`);
1147
+ lines.push(`SET ${setItems.join(', ')}`);
1130
1148
  }
1131
- else {
1132
- const mergePattern = buildRelPattern({
1133
- sourceVar: 'n',
1134
- relDef,
1135
- targetVar: 'target',
1136
- });
1137
- lines.push(`MERGE ${mergePattern}`);
1138
- }
1139
- lines.push(`RETURN count(*) AS _conn_${fieldName}`);
1149
+ lines.push(`RETURN count(*) AS _conn_${varPrefix}${tag}`);
1140
1150
  lines.push('}');
1141
1151
  }
1152
+ return lines;
1153
+ }
1154
+ // Single connect — same row-preserving isolation as the UNWIND path.
1155
+ return this.buildConnectItem({
1156
+ connectSpec: spec,
1157
+ relDef,
1158
+ targetNodeDef,
1159
+ sourceVar,
1160
+ withClause,
1161
+ connectVar: `${varPrefix}target`,
1162
+ relVar: `${varPrefix}r`,
1163
+ edgeParamPrefix: `${varPrefix}connect_${tag}`,
1164
+ returnAlias: `_conn_${varPrefix}${tag}`,
1165
+ params,
1166
+ policyContext,
1167
+ paramCounter,
1168
+ scope,
1169
+ });
1170
+ }
1171
+ /**
1172
+ * One connect item, isolated in a CALL subquery so a MATCH that finds
1173
+ * nothing (or many) never changes the outer row count:
1174
+ *
1175
+ * <withClause>
1176
+ * CALL {
1177
+ * <withClause>
1178
+ * MATCH (cv[:Label])
1179
+ * WHERE <member labels> AND <where> AND <target `read` policy>
1180
+ * MERGE (src)-[rv:REL]->(cv) [SET rv.<edge> = …]
1181
+ * RETURN count(*) AS <alias>
1182
+ * }
1183
+ *
1184
+ * Shared by every connect site (top-level, nested in create, nested in
1185
+ * update), which differ only in the names they pass.
1186
+ */
1187
+ buildConnectItem(a) {
1188
+ const { connectSpec, relDef, targetNodeDef, sourceVar, connectVar } = a;
1189
+ const lines = [];
1190
+ const whereSpec = connectSpec?.where;
1191
+ const edgeInput = connectSpec?.edge;
1192
+ lines.push(a.withClause);
1193
+ lines.push('CALL {');
1194
+ lines.push(a.withClause);
1195
+ const { match, labelPredicate } = this.targetMatch(targetNodeDef, connectVar);
1196
+ lines.push(match);
1197
+ const conditions = [
1198
+ labelPredicate,
1199
+ this.compileTargetWhere(whereSpec, connectVar, targetNodeDef, a.params, a.policyContext, a.paramCounter),
1200
+ this.buildTargetPolicyPredicate(targetNodeDef, connectVar, a.params, a.policyContext, a.paramCounter),
1201
+ ].filter((c) => Boolean(c));
1202
+ if (conditions.length > 0)
1203
+ lines.push(`WHERE ${conditions.join(' AND ')}`);
1204
+ if (edgeInput && Object.keys(edgeInput).length > 0) {
1205
+ // Use bare target var (no label) since connectVar is already bound by MATCH.
1206
+ // Including labels on a bound variable in MERGE causes Neo4j to reject it.
1207
+ assertSafeIdentifier(relDef.type, 'relationship type');
1208
+ const escapedRelType = escapeIdentifier(relDef.type);
1209
+ const mergePattern = relDef.direction === 'OUT'
1210
+ ? `(${sourceVar})-[${a.relVar}:${escapedRelType}]->(${connectVar})`
1211
+ : `(${sourceVar})<-[${a.relVar}:${escapedRelType}]-(${connectVar})`;
1212
+ lines.push(`MERGE ${mergePattern}`);
1213
+ const setItems = [];
1214
+ for (const [prop, val] of Object.entries(edgeInput)) {
1215
+ if (val === undefined)
1216
+ continue;
1217
+ assertSafePropertyName(prop, 'edge property');
1218
+ const edgePropDef = this.getEdgePropDef(relDef, prop);
1219
+ const paramName = `${a.edgeParamPrefix}_edge_${prop}`;
1220
+ setItems.push(`${a.relVar}.${escapeIdentifier(prop)} = ${wrapWriteExpr(`$${paramName}`, val, edgePropDef)}`);
1221
+ a.params[paramName] = coerceWriteValue(val, edgePropDef);
1222
+ }
1223
+ if (setItems.length > 0)
1224
+ lines.push(`SET ${setItems.join(', ')}`);
1225
+ }
1226
+ else {
1227
+ const relPattern = buildRelPattern({
1228
+ sourceVar,
1229
+ relDef,
1230
+ targetVar: connectVar,
1231
+ });
1232
+ lines.push(`MERGE ${relPattern}`);
1233
+ }
1234
+ // A `connect` nested in the item links the CONNECTED node onward
1235
+ // (`connect: { posts: [{ where, connect: { tags: [...] } }] }`), with
1236
+ // the same policy predicates at its level. Pre-2.4.8 it was ignored.
1237
+ const nested = connectSpec?.connect;
1238
+ if (nested != null) {
1239
+ if (abstractTarget(targetNodeDef.typeName, this.schema))
1240
+ throw new OGMError(`A nested "connect" inside a connect on "${relDef.fieldName}" (abstract type "${targetNodeDef.typeName}") ` +
1241
+ `must be keyed by the implementing type, e.g. { ${relDef.fieldName}: { Member: [{ where, connect }] } }.`);
1242
+ const inner = nestedScope(a.scope.carryVars, connectVar, a.scope.depth);
1243
+ for (const nestedConnect of Array.isArray(nested) ? nested : [nested]) {
1244
+ if (!isPlainObject(nestedConnect))
1245
+ throw new OGMError(`A nested "connect" inside a connect on "${relDef.fieldName}" must be an object keyed by relationship.`);
1246
+ lines.push(...this.buildConnects(nestedConnect, targetNodeDef, a.params, a.policyContext, a.paramCounter, inner));
1247
+ }
1142
1248
  }
1249
+ lines.push(`RETURN count(*) AS ${a.returnAlias}`);
1250
+ lines.push('}');
1143
1251
  return lines;
1144
1252
  }
1145
- buildDisconnects(disconnect, nodeDef, params, policyContext, paramCounter) {
1253
+ /**
1254
+ * Top-level `disconnect`. Abstract relationships follow the connect
1255
+ * rules (see buildConnects): per member, or flat on an interface,
1256
+ * matching implementers by their own labels (v2.4.8 — pre-2.4.8 the
1257
+ * target pattern used the interface / union label, so nothing matched).
1258
+ */
1259
+ buildDisconnects(disconnect, nodeDef, params, policyContext, paramCounter, scope = TOP_SCOPE) {
1146
1260
  const lines = [];
1147
1261
  for (const [fieldName, spec] of Object.entries(disconnect)) {
1148
1262
  const relDef = nodeDef.relationships.get(fieldName);
1149
1263
  if (!relDef)
1150
1264
  continue;
1151
- const targetNodeDef = resolveTargetDef(relDef.target, this.schema) ?? undefined;
1152
- // Normalize spec to an array of disconnect items
1153
- const specItems = Array.isArray(spec) ? spec : [spec];
1154
- for (let si = 0; si < specItems.length; si++) {
1155
- const item = specItems[si];
1156
- const relVar = `r_${fieldName}_${si}`;
1157
- // Wrap in a CALL subquery: the OPTIONAL MATCH yields one row per
1158
- // matched relationship, which would multiply every later clause.
1159
- // `count(*)` collapses it back to exactly one row per `n`.
1160
- lines.push('WITH n');
1161
- lines.push('CALL {');
1162
- lines.push('WITH n');
1163
- if (item == null ||
1164
- (typeof item === 'object' && Object.keys(item).length === 0)) {
1165
- // Blanket disconnect — remove every relationship of this type to
1166
- // a target the caller can READ. Like the `where` branch below, a
1167
- // policy-bound context only unlinks nodes it can see; pre-2.5.0
1168
- // the blanket form bound an anonymous `()` and also severed links
1169
- // to policy-hidden nodes. Without policies there is no predicate
1170
- // and the Cypher is unchanged.
1171
- const blanketTarget = `target_${fieldName}_${si}`;
1172
- const predicate = targetNodeDef
1173
- ? this.buildTargetPolicyPredicate(targetNodeDef, blanketTarget, params, policyContext, paramCounter)
1174
- : undefined;
1175
- const pattern = buildRelPattern({
1176
- sourceVar: 'n',
1177
- relDef,
1178
- targetVar: predicate ? blanketTarget : '',
1179
- edgeVar: relVar,
1180
- });
1181
- lines.push(`OPTIONAL MATCH ${pattern}`);
1182
- if (predicate)
1183
- lines.push(`WHERE ${predicate}`);
1184
- lines.push(`DELETE ${relVar}`);
1185
- }
1186
- else {
1187
- // Specific disconnect with conditions
1188
- const disconnectSpec = item;
1189
- const whereSpec = disconnectSpec.where;
1190
- const targetVar = `target_${fieldName}_${si}`;
1191
- const pattern = buildRelPattern({
1192
- sourceVar: 'n',
1193
- relDef,
1194
- targetVar,
1195
- edgeVar: relVar,
1196
- targetLabel: 'auto',
1197
- });
1198
- lines.push(`OPTIONAL MATCH ${pattern}`);
1199
- if (!targetNodeDef)
1200
- throw new OGMError(`Cannot resolve the target type "${relDef.target}" of relationship "${fieldName}".`);
1201
- const disconnectConditions = [
1202
- this.compileTargetWhere(whereSpec, targetVar, targetNodeDef, params, policyContext, paramCounter),
1203
- this.buildTargetPolicyPredicate(targetNodeDef, targetVar, params, policyContext, paramCounter),
1204
- ].filter((c) => Boolean(c));
1205
- if (disconnectConditions.length > 0)
1206
- lines.push(`WHERE ${disconnectConditions.join(' AND ')}`);
1207
- lines.push(`DELETE ${relVar}`);
1208
- }
1209
- lines.push(`RETURN count(*) AS _${relVar}`);
1210
- lines.push('}');
1265
+ const abstract = abstractTarget(relDef.target, this.schema);
1266
+ // `null` / `{}` stays a blanket disconnect of the whole relationship.
1267
+ const memberEntries = abstract && !isBlanketSpec(spec)
1268
+ ? perMemberEntries(spec, abstract, fieldName)
1269
+ : null;
1270
+ if (memberEntries) {
1271
+ for (const [member, memberSpec] of memberEntries)
1272
+ lines.push(...this.buildRelDisconnects(memberSpec, this.narrowToMember(relDef, member), this.schema.nodes.get(member), `${fieldName}_${member}`, params, policyContext, paramCounter, scope));
1273
+ continue;
1211
1274
  }
1275
+ lines.push(...this.buildRelDisconnects(spec, relDef, resolveTargetDef(relDef.target, this.schema) ?? undefined, fieldName, params, policyContext, paramCounter, scope));
1212
1276
  }
1213
1277
  return lines;
1214
1278
  }
1215
1279
  /**
1216
- * Dispatch update operations for a union-typed relationship.
1280
+ * A `disconnect` nested in a disconnect item unlinks the disconnected
1281
+ * node's OWN relationships (`disconnect: { posts: [{ where, disconnect:
1282
+ * { tags: [...] } }] }`), with the same policy predicates at its level.
1283
+ * Pre-2.4.8 it was ignored. `''`-free: no input, no lines.
1284
+ */
1285
+ buildNestedDisconnect(nested, relDef, targetNodeDef, targetVar, carryVars, params, policyContext, paramCounter, depth = 0) {
1286
+ if (nested == null)
1287
+ return [];
1288
+ if (abstractTarget(targetNodeDef.typeName, this.schema))
1289
+ throw new OGMError(`A nested "disconnect" inside a disconnect on "${relDef.fieldName}" (abstract type "${targetNodeDef.typeName}") ` +
1290
+ `must be keyed by the implementing type, e.g. { ${relDef.fieldName}: { Member: [{ where, disconnect }] } }.`);
1291
+ const inner = nestedScope(carryVars, targetVar, depth);
1292
+ const lines = [];
1293
+ for (const nestedDisconnect of Array.isArray(nested) ? nested : [nested]) {
1294
+ if (!isPlainObject(nestedDisconnect))
1295
+ throw new OGMError(`A nested "disconnect" inside a disconnect on "${relDef.fieldName}" must be an object keyed by relationship.`);
1296
+ lines.push(...this.buildDisconnects(nestedDisconnect, targetNodeDef, params, policyContext, paramCounter, inner));
1297
+ }
1298
+ return lines;
1299
+ }
1300
+ /** One relationship's disconnect items; `tag` as in buildRelConnects. */
1301
+ buildRelDisconnects(spec, relDef, targetNodeDef, tag, params, policyContext, paramCounter, scope) {
1302
+ const lines = [];
1303
+ const { sourceVar, withClause, varPrefix } = scope;
1304
+ // Normalize spec to an array of disconnect items
1305
+ const specItems = Array.isArray(spec) ? spec : [spec];
1306
+ for (let si = 0; si < specItems.length; si++) {
1307
+ const item = specItems[si];
1308
+ const relVar = `${varPrefix}r_${tag}_${si}`;
1309
+ // Wrap in a CALL subquery: the OPTIONAL MATCH yields one row per
1310
+ // matched relationship, which would multiply every later clause.
1311
+ // `count(*)` collapses it back to exactly one row per `n`.
1312
+ lines.push(withClause);
1313
+ lines.push('CALL {');
1314
+ lines.push(withClause);
1315
+ if (item == null ||
1316
+ (typeof item === 'object' && Object.keys(item).length === 0)) {
1317
+ // Blanket disconnect — remove every relationship of this type to
1318
+ // a target the caller can READ. Like the `where` branch below, a
1319
+ // policy-bound context only unlinks nodes it can see; pre-2.4.4
1320
+ // the blanket form bound an anonymous `()` and also severed links
1321
+ // to policy-hidden nodes. Without policies there is no predicate
1322
+ // and the Cypher is unchanged.
1323
+ const blanketTarget = `${varPrefix}target_${tag}_${si}`;
1324
+ const predicate = targetNodeDef
1325
+ ? this.buildTargetPolicyPredicate(targetNodeDef, blanketTarget, params, policyContext, paramCounter)
1326
+ : undefined;
1327
+ const pattern = buildRelPattern({
1328
+ sourceVar,
1329
+ relDef,
1330
+ targetVar: predicate ? blanketTarget : '',
1331
+ edgeVar: relVar,
1332
+ targetLabelRaw: this.blanketTargetLabel(relDef),
1333
+ });
1334
+ lines.push(`OPTIONAL MATCH ${pattern}`);
1335
+ if (predicate)
1336
+ lines.push(`WHERE ${predicate}`);
1337
+ lines.push(`DELETE ${relVar}`);
1338
+ }
1339
+ else {
1340
+ // Specific disconnect with conditions
1341
+ const disconnectSpec = item;
1342
+ const whereSpec = disconnectSpec.where;
1343
+ const targetVar = `${varPrefix}target_${tag}_${si}`;
1344
+ const pattern = buildRelPattern({
1345
+ sourceVar,
1346
+ relDef,
1347
+ targetVar,
1348
+ edgeVar: relVar,
1349
+ targetLabel: 'auto',
1350
+ // An abstract target binds no label (its nodes carry their
1351
+ // members' labels); the member predicate below narrows it.
1352
+ schema: this.schema,
1353
+ });
1354
+ lines.push(`OPTIONAL MATCH ${pattern}`);
1355
+ if (!targetNodeDef)
1356
+ throw new OGMError(`Cannot resolve the target type "${relDef.target}" of relationship "${relDef.fieldName}".`);
1357
+ const labelPredicate = abstractTarget(targetNodeDef.typeName, this.schema)
1358
+ ? this.targetMatch(targetNodeDef, targetVar).labelPredicate
1359
+ : null;
1360
+ const disconnectConditions = [
1361
+ labelPredicate,
1362
+ this.compileTargetWhere(whereSpec, targetVar, targetNodeDef, params, policyContext, paramCounter),
1363
+ this.buildTargetPolicyPredicate(targetNodeDef, targetVar, params, policyContext, paramCounter),
1364
+ ].filter((c) => Boolean(c));
1365
+ if (disconnectConditions.length > 0)
1366
+ lines.push(`WHERE ${disconnectConditions.join(' AND ')}`);
1367
+ lines.push(`DELETE ${relVar}`);
1368
+ lines.push(...this.buildNestedDisconnect(disconnectSpec.disconnect, relDef, targetNodeDef, targetVar, scope.carryVars, params, policyContext, paramCounter, scope.depth));
1369
+ }
1370
+ lines.push(`RETURN count(*) AS _${relVar}`);
1371
+ lines.push('}');
1372
+ }
1373
+ return lines;
1374
+ }
1375
+ narrowToMember(relDef, member) {
1376
+ const narrowed = { ...relDef, target: member };
1377
+ this.narrowedRelDefs.add(narrowed);
1378
+ return narrowed;
1379
+ }
1380
+ /** The label a blanket disconnect over `relDef` must keep (see above). */
1381
+ blanketTargetLabel(relDef) {
1382
+ if (!this.narrowedRelDefs.has(relDef))
1383
+ return undefined;
1384
+ return this.getCachedLabelString(this.schema.nodes.get(relDef.target));
1385
+ }
1386
+ /**
1387
+ * Dispatch update operations for a union- or interface-typed
1388
+ * relationship given per member (interfaces since v2.4.8).
1217
1389
  * Each member key in the input is recursed into buildUpdateRelationships
1218
1390
  * with a narrowed relDef pointing at the concrete member type.
1219
1391
  */
1220
1392
  dispatchUnionUpdateOps(key, memberEntries, relDef, nodeDef, sourceVar, prefix, params, depth, ancestorVars, policyContext, paramCounter) {
1221
- const unionMembers = this.schema.unions.get(relDef.target);
1222
1393
  const lines = [];
1223
- for (const [memberKey, memberValue] of Object.entries(memberEntries)) {
1224
- if (!unionMembers.includes(memberKey))
1225
- continue;
1226
- const memberNodeDef = this.schema.nodes.get(memberKey);
1227
- if (!memberNodeDef || memberValue == null)
1228
- continue;
1229
- const memberRelDef = {
1230
- ...relDef,
1231
- target: memberKey,
1232
- };
1394
+ for (const [memberKey, memberValue] of memberEntries) {
1395
+ const memberRelDef = this.narrowToMember(relDef, memberKey);
1233
1396
  const memberItems = Array.isArray(memberValue)
1234
1397
  ? memberValue
1235
1398
  : [memberValue];
@@ -1258,18 +1421,21 @@ export class MutationCompiler {
1258
1421
  const relDef = nodeDef.relationships.get(key);
1259
1422
  if (!relDef || value == null)
1260
1423
  continue;
1261
- // Union targets use per-member keys — dispatch each member recursively
1262
- const isUnionTarget = !this.schema.nodes.has(relDef.target) &&
1263
- this.schema.unions?.has(relDef.target);
1264
- if (isUnionTarget) {
1265
- lines.push(...this.dispatchUnionUpdateOps(key, value, relDef, nodeDef, sourceVar, prefix, params, depth, ancestorVars, policyContext, paramCounter));
1424
+ // Union / interface targets given per member — dispatch each member
1425
+ // recursively. A flat interface input is handled below against the
1426
+ // interface (v2.4.8 — pre-2.4.8 it was skipped, writing nothing).
1427
+ const abstract = abstractTarget(relDef.target, this.schema);
1428
+ const memberEntries = abstract
1429
+ ? perMemberEntries(value, abstract, key)
1430
+ : null;
1431
+ if (memberEntries) {
1432
+ lines.push(...this.dispatchUnionUpdateOps(key, memberEntries, relDef, nodeDef, sourceVar, prefix, params, depth, ancestorVars, policyContext, paramCounter));
1266
1433
  continue;
1267
1434
  }
1268
- const targetNodeDef = this.schema.nodes.get(relDef.target);
1269
- if (!targetNodeDef)
1270
- continue;
1271
- const targetLabelStr = this.getCachedLabelString(targetNodeDef);
1435
+ const targetNodeDef = this.requireTargetDef(relDef);
1272
1436
  const relPrefix = `${prefix}_${key}`;
1437
+ // Past the dispatch, `abstract` is set only for a flat INTERFACE
1438
+ // input (a flat union input threw in perMemberEntries).
1273
1439
  // Value can be an array (e.g., chapters: [{ create: ... }, { update: ... }])
1274
1440
  // or an object (e.g., author: { connect: ... })
1275
1441
  const items = Array.isArray(value)
@@ -1302,13 +1468,17 @@ export class MutationCompiler {
1302
1468
  for (let di = 0; di < disconnectItems.length; di++) {
1303
1469
  const disconnectSpec = disconnectItems[di];
1304
1470
  const whereSpec = disconnectSpec.where;
1471
+ // A nested `disconnect` needs the target bound, so an item that
1472
+ // carries one is never the anonymous blanket form.
1473
+ const nestedDisconnect = disconnectSpec.disconnect;
1305
1474
  const relVar = `r_disc_${key}_${i}_${di}`;
1306
1475
  // Same isolation as the top-level disconnect: keep the
1307
1476
  // OPTIONAL MATCH fan-out from multiplying later nested ops.
1308
1477
  lines.push(withClause);
1309
1478
  lines.push('CALL {');
1310
1479
  lines.push(withClause);
1311
- if (!whereSpec || Object.keys(whereSpec).length === 0) {
1480
+ if ((!whereSpec || Object.keys(whereSpec).length === 0) &&
1481
+ nestedDisconnect == null) {
1312
1482
  // Blanket — same read-policy gate as the top-level blanket
1313
1483
  // disconnect: only unlink targets the caller can read.
1314
1484
  const blanketTarget = `${sourceVar}_disc${varCounter}`;
@@ -1320,6 +1490,7 @@ export class MutationCompiler {
1320
1490
  relDef,
1321
1491
  targetVar: predicate ? blanketTarget : '',
1322
1492
  edgeVar: relVar,
1493
+ targetLabelRaw: this.blanketTargetLabel(relDef),
1323
1494
  });
1324
1495
  lines.push(`OPTIONAL MATCH ${pattern}`);
1325
1496
  if (predicate)
@@ -1335,6 +1506,9 @@ export class MutationCompiler {
1335
1506
  targetVar: discTarget,
1336
1507
  edgeVar: relVar,
1337
1508
  targetLabel: 'auto',
1509
+ // An interface target matches its implementers, which do
1510
+ // not carry the interface label (v2.4.8).
1511
+ schema: this.schema,
1338
1512
  });
1339
1513
  lines.push(`OPTIONAL MATCH ${pattern}`);
1340
1514
  // Pre-1.8.1 this called `buildNodeWhereConditions` with
@@ -1347,12 +1521,16 @@ export class MutationCompiler {
1347
1521
  // path (`buildDisconnects`). Restores backwards-compat with
1348
1522
  // @neo4j/graphql-ogm's nested update.disconnect[].where shape.
1349
1523
  const nestedDiscConditions = [
1524
+ abstract
1525
+ ? this.targetMatch(targetNodeDef, discTarget).labelPredicate
1526
+ : null,
1350
1527
  this.compileTargetWhere(whereSpec, discTarget, targetNodeDef, params, policyContext, paramCounter),
1351
1528
  this.buildTargetPolicyPredicate(targetNodeDef, discTarget, params, policyContext, paramCounter),
1352
1529
  ].filter((c) => Boolean(c));
1353
1530
  if (nestedDiscConditions.length > 0)
1354
1531
  lines.push(`WHERE ${nestedDiscConditions.join(' AND ')}`);
1355
1532
  lines.push(`DELETE ${relVar}`);
1533
+ lines.push(...this.buildNestedDisconnect(nestedDisconnect, relDef, targetNodeDef, discTarget, allVars, params, policyContext, paramCounter));
1356
1534
  }
1357
1535
  lines.push(`RETURN count(*) AS _${relVar}`);
1358
1536
  lines.push('}');
@@ -1364,61 +1542,30 @@ export class MutationCompiler {
1364
1542
  ? item.connect
1365
1543
  : [item.connect];
1366
1544
  for (let ci = 0; ci < connectItems.length; ci++) {
1367
- const connectSpec = connectItems[ci];
1368
- const whereSpec = connectSpec.where;
1369
- const edgeInput = connectSpec.edge;
1370
1545
  // Wrap in CALL subquery so a failed MATCH doesn't kill the outer pipeline
1371
- lines.push(withClause);
1372
- lines.push(`CALL {`);
1373
- lines.push(withClause);
1374
1546
  const connectVar = `${sourceVar}_conn${varCounter}`;
1375
1547
  varCounter++;
1376
- lines.push(`MATCH (${connectVar}:${targetLabelStr})`);
1377
- // Pre-1.8.1 this used `buildNodeWhereConditions` with
1378
- // `(whereSpec?.node ?? whereSpec ?? {})`, which broke on
1379
- // connection-shape inputs (NOT/AND/OR wrappers). Routing
1380
- // through `buildConnectionWhereConditions` makes nested
1381
- // connect handle the same shapes as nested disconnect and
1382
- // top-level paths. Empty/undefined `whereSpec` short-circuits
1383
- // to `[]` inside the helper, so the no-where case still works.
1384
- const nestedConnConditions = [
1385
- this.compileTargetWhere(whereSpec, connectVar, targetNodeDef, params, policyContext, paramCounter),
1386
- this.buildTargetPolicyPredicate(targetNodeDef, connectVar, params, policyContext, paramCounter),
1387
- ].filter((c) => Boolean(c));
1388
- if (nestedConnConditions.length > 0)
1389
- lines.push(`WHERE ${nestedConnConditions.join(' AND ')}`);
1390
- if (edgeInput && Object.keys(edgeInput).length > 0) {
1391
- // Use bare target var (no label) since connectVar is already bound by MATCH
1392
- const relVar = `r_conn_${key}_${ci}`;
1393
- assertSafeIdentifier(relDef.type, 'relationship type');
1394
- const escapedRelType = escapeIdentifier(relDef.type);
1395
- const mergePattern = relDef.direction === 'OUT'
1396
- ? `(${sourceVar})-[${relVar}:${escapedRelType}]->(${connectVar})`
1397
- : `(${sourceVar})<-[${relVar}:${escapedRelType}]-(${connectVar})`;
1398
- lines.push(`MERGE ${mergePattern}`);
1399
- const setItems = [];
1400
- for (const [prop, val] of Object.entries(edgeInput)) {
1401
- if (val === undefined)
1402
- continue;
1403
- assertSafePropertyName(prop, 'edge property');
1404
- const edgePropDef = this.getEdgePropDef(relDef, prop);
1405
- const paramName = `${itemPrefix}_conn${ci}_edge_${prop}`;
1406
- setItems.push(`r_conn_${key}_${ci}.${escapeIdentifier(prop)} = ${wrapWriteExpr(`$${paramName}`, val, edgePropDef)}`);
1407
- params[paramName] = coerceWriteValue(val, edgePropDef);
1408
- }
1409
- if (setItems.length > 0)
1410
- lines.push(`SET ${setItems.join(', ')}`);
1411
- }
1412
- else {
1413
- const relPattern = buildRelPattern({
1548
+ lines.push(...this.buildConnectItem({
1549
+ connectSpec: connectItems[ci],
1550
+ relDef,
1551
+ targetNodeDef,
1552
+ sourceVar,
1553
+ withClause,
1554
+ connectVar,
1555
+ relVar: `r_conn_${key}_${ci}`,
1556
+ edgeParamPrefix: `${itemPrefix}_conn${ci}`,
1557
+ returnAlias: `_cn_${key}_${ci}`,
1558
+ params,
1559
+ policyContext,
1560
+ paramCounter,
1561
+ scope: {
1414
1562
  sourceVar,
1415
- relDef,
1416
- targetVar: connectVar,
1417
- });
1418
- lines.push(`MERGE ${relPattern}`);
1419
- }
1420
- lines.push(`RETURN count(*) AS _cn_${key}_${ci}`);
1421
- lines.push(`}`);
1563
+ carryVars: allVars,
1564
+ withClause,
1565
+ varPrefix: '',
1566
+ depth: 0,
1567
+ },
1568
+ }));
1422
1569
  }
1423
1570
  }
1424
1571
  // --- create ---
@@ -1431,15 +1578,23 @@ export class MutationCompiler {
1431
1578
  : [item.create];
1432
1579
  for (let ci = 0; ci < createItems.length; ci++) {
1433
1580
  const createSpec = createItems[ci];
1434
- const nodeSpec = (createSpec.node ?? createSpec);
1581
+ let nodeSpec = (createSpec.node ?? createSpec);
1582
+ // Flat interface input: the create names its implementer.
1583
+ let createDef = targetNodeDef;
1584
+ if (abstract) {
1585
+ const [member, memberSpec] = unwrapAbstractCreateNode(nodeSpec, abstract, key);
1586
+ createDef = this.schema.nodes.get(member);
1587
+ nodeSpec = memberSpec;
1588
+ }
1589
+ const targetLabelStr = this.getCachedLabelString(createDef);
1435
1590
  const createVar = `${sourceVar}_cr${varCounter}`;
1436
1591
  varCounter++;
1437
- const { propString, propParams } = this.buildCreateProperties(nodeSpec, targetNodeDef, `${itemPrefix}_create${ci}`);
1592
+ const { propString, propParams } = this.buildCreateProperties(nodeSpec, createDef, `${itemPrefix}_create${ci}`);
1438
1593
  // Handle nested relationship creates within the created node
1439
- const nestedRelLines = this.buildCreateRelationships(nodeSpec, targetNodeDef, createVar, `${itemPrefix}_create${ci}`, params, allVars, policyContext, paramCounter);
1594
+ const nestedRelLines = this.buildCreateRelationships(nodeSpec, createDef, createVar, `${itemPrefix}_create${ci}`, params, allVars, policyContext, paramCounter);
1440
1595
  const propsClause = propString.length > 0
1441
1596
  ? ` { ${propString} }`
1442
- : ` { ${this.buildGeneratedIdClause(targetNodeDef)} }`;
1597
+ : ` { ${this.buildGeneratedIdClause(createDef)} }`;
1443
1598
  // `edge` properties on the new relationship — the same input
1444
1599
  // create() accepts. Pre-2.4.5 this path never read
1445
1600
  // `createSpec.edge`, so they were silently dropped.
@@ -1488,6 +1643,11 @@ export class MutationCompiler {
1488
1643
  const nodeUpdate = (updateSpec.node ??
1489
1644
  (edgeUpdate ? {} : updateSpec));
1490
1645
  const updateWhere = item.where;
1646
+ // A flat interface update writes the interface's own fields; the
1647
+ // per-implementer `_on` form is not supported (v2.4.8).
1648
+ if (abstract && isPlainObject(nodeUpdate) && '_on' in nodeUpdate)
1649
+ throw new OGMError(`"_on" in a nested update of "${key}" is not supported. Key the update by implementing type instead ` +
1650
+ `(e.g. { ${key}: { ${abstract.members[0] ?? 'Member'}: [{ where, update }] } }).`);
1491
1651
  const updateVar = `${sourceVar}_u${varCounter}`;
1492
1652
  const relVar = `r_${key}_${i}`;
1493
1653
  varCounter++;
@@ -1500,6 +1660,7 @@ export class MutationCompiler {
1500
1660
  if (val === undefined)
1501
1661
  continue;
1502
1662
  assertSafePropertyName(prop, 'nested update property');
1663
+ assertKnownField(targetNodeDef, prop, 'nested update');
1503
1664
  const nodePropDef = targetNodeDef.properties.get(prop);
1504
1665
  const paramName = `${itemPrefix}_set_${prop}`;
1505
1666
  setClauses.push(`${updateVar}.${escapeIdentifier(prop)} = ${wrapWriteExpr(`$${paramName}`, val, nodePropDef)}`);
@@ -1538,6 +1699,7 @@ export class MutationCompiler {
1538
1699
  targetVar: updateVar,
1539
1700
  edgeVar: relVar,
1540
1701
  targetLabel: 'auto',
1702
+ schema: this.schema,
1541
1703
  });
1542
1704
  lines.push(`MATCH ${relArrow}`);
1543
1705
  // WHERE on the target.
@@ -1720,6 +1882,114 @@ export function connectionWhereToNodeWhere(spec) {
1720
1882
  }
1721
1883
  return parts.length === 1 ? parts[0] : { AND: parts };
1722
1884
  }
1885
+ /**
1886
+ * The concrete members of an interface / union relationship target, or
1887
+ * `null` for a node type. Implementer nodes carry their OWN labels, not
1888
+ * the interface's, so every write that has to find one matches by these
1889
+ * members' labels (v2.4.8).
1890
+ */
1891
+ export function abstractTarget(target, schema) {
1892
+ if (schema.nodes.has(target))
1893
+ return null;
1894
+ const iface = schema.interfaces?.get(target);
1895
+ if (iface)
1896
+ return {
1897
+ kind: 'interface',
1898
+ name: target,
1899
+ members: iface.implementedBy.filter((m) => schema.nodes.has(m)),
1900
+ };
1901
+ const union = schema.unions?.get(target);
1902
+ if (union)
1903
+ return {
1904
+ kind: 'union',
1905
+ name: target,
1906
+ members: union.filter((m) => schema.nodes.has(m)),
1907
+ };
1908
+ return null;
1909
+ }
1910
+ /**
1911
+ * Classify the nested-write value of an abstract relationship (v2.4.8):
1912
+ *
1913
+ * - PER-MEMBER — keyed by member type (`{ Dog: { … } }`), the shape
1914
+ * codegen emits for union AND interface targets. Returns the defined
1915
+ * `[member, value]` entries.
1916
+ * - FLAT — the operation keys themselves (`{ connect, create, … }`, or an
1917
+ * array of them), the @neo4j/graphql interface shape. Returns `null`.
1918
+ * Only interfaces accept it: a union has no common fields to filter or
1919
+ * write, so a flat union input throws.
1920
+ *
1921
+ * Pre-2.4.8 a key that was not a member was skipped, so a flat or typo'd
1922
+ * input on an abstract relationship compiled to no write at all. Mixing
1923
+ * member keys with other keys now throws instead.
1924
+ */
1925
+ export function perMemberEntries(value, target, fieldName) {
1926
+ if (Array.isArray(value) || !isPlainObject(value)) {
1927
+ if (target.kind === 'union')
1928
+ throw new OGMError(`Relationship "${fieldName}" targets the union "${target.name}": key its nested input by member type ` +
1929
+ `(${target.members.map((m) => `"${m}"`).join(', ')}).`);
1930
+ return null;
1931
+ }
1932
+ const keys = Object.keys(value).filter((k) => value[k] !== undefined);
1933
+ const memberKeys = keys.filter((k) => target.members.includes(k));
1934
+ if (memberKeys.length === 0 && target.kind === 'interface')
1935
+ return null;
1936
+ if (memberKeys.length !== keys.length) {
1937
+ const other = keys.filter((k) => !target.members.includes(k));
1938
+ throw new OGMError(`Unknown key(s) ${other.map((k) => `"${k}"`).join(', ')} in the nested input of "${fieldName}" ` +
1939
+ `(${target.kind} "${target.name}"). ` +
1940
+ (target.kind === 'union'
1941
+ ? `Key it by member type: ${target.members.map((m) => `"${m}"`).join(', ')}.`
1942
+ : `Use either member keys (${target.members.map((m) => `"${m}"`).join(', ')}) or the operation keys, not both.`));
1943
+ }
1944
+ return memberKeys
1945
+ .filter((k) => value[k] !== null)
1946
+ .map((k) => [k, value[k]]);
1947
+ }
1948
+ /**
1949
+ * Unwrap a FLAT interface nested-create node spec — `{ Dog: { … } }`,
1950
+ * exactly one implementer key — into the implementer and its own input
1951
+ * (v2.4.8). Anything else throws: without a member key the compiler cannot
1952
+ * know which labels and properties the new node gets.
1953
+ */
1954
+ export function unwrapAbstractCreateNode(nodeSpec, target, fieldName) {
1955
+ const keys = isPlainObject(nodeSpec)
1956
+ ? Object.keys(nodeSpec).filter((k) => nodeSpec[k] !== undefined)
1957
+ : [];
1958
+ if (!isPlainObject(nodeSpec) ||
1959
+ keys.length !== 1 ||
1960
+ !target.members.includes(keys[0]) ||
1961
+ !isPlainObject(nodeSpec[keys[0]]))
1962
+ throw new OGMError(`A nested create on "${fieldName}" (${target.kind} "${target.name}") must name exactly one implementing type, ` +
1963
+ `e.g. { node: { ${target.members[0] ?? 'Member'}: { … } } }. Allowed: ${target.members.map((m) => `"${m}"`).join(', ')}.`);
1964
+ return [keys[0], nodeSpec[keys[0]]];
1965
+ }
1966
+ /**
1967
+ * Reject a write key that is neither a property nor a relationship of the
1968
+ * type (v2.5.0). Pre-2.5.0 only its spelling was checked, so a typo
1969
+ * (`nmae`) was stored as a stray property and the intended field was left
1970
+ * untouched — at every depth (nested creates and updates included).
1971
+ */
1972
+ function assertKnownField(nodeDef, key, input) {
1973
+ if (nodeDef.properties.has(key) || nodeDef.relationships.has(key))
1974
+ return;
1975
+ throw new OGMError(`Unknown field "${key}" in the ${input} input of ${nodeDef.typeName}: it is neither a property nor a ` +
1976
+ `relationship of the type. Declare it in the schema, or fix the key.`);
1977
+ }
1978
+ /**
1979
+ * MERGE writes scalars only. Pre-2.4.8 a relationship field in an upsert's
1980
+ * `create` / `update` was skipped, so its nested connect / create never
1981
+ * ran and nothing said so.
1982
+ */
1983
+ function upsertRelationshipError(nodeDef, key, branch) {
1984
+ return new OGMError(`upsert does not support relationship fields: "${key}" in "${branch}" (${nodeDef.typeName}). ` +
1985
+ `Use create() / update() for nested relationship operations.`);
1986
+ }
1987
+ /** `null` / `{}`: the whole-relationship (blanket) form, never per member. */
1988
+ function isBlanketSpec(spec) {
1989
+ if (spec == null)
1990
+ return true;
1991
+ return (isPlainObject(spec) && Object.keys(spec).every((k) => spec[k] === undefined));
1992
+ }
1723
1993
  /** A nested delete item's own `delete` asks for a deeper cascade. */
1724
1994
  function hasNestedCascade(value) {
1725
1995
  if (value === undefined || value === null)