grafeo-ogm 2.4.6 → 2.4.8

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 (189) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/README.md +77 -31
  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 +70 -3
  12. package/dist/cjs/cli/introspect.js.map +1 -1
  13. package/dist/cjs/cli/push-planner.d.ts +61 -2
  14. package/dist/cjs/cli/push-planner.d.ts.map +1 -1
  15. package/dist/cjs/cli/push-planner.js +182 -17
  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 +647 -380
  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 +40 -0
  28. package/dist/cjs/compilers/selection.compiler.d.ts.map +1 -1
  29. package/dist/cjs/compilers/selection.compiler.js +123 -21
  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 +3 -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.map +1 -1
  64. package/dist/cjs/interface-model.js +6 -0
  65. package/dist/cjs/interface-model.js.map +1 -1
  66. package/dist/cjs/model.d.ts.map +1 -1
  67. package/dist/cjs/model.js +6 -0
  68. package/dist/cjs/model.js.map +1 -1
  69. package/dist/cjs/ogm.d.ts +22 -2
  70. package/dist/cjs/ogm.d.ts.map +1 -1
  71. package/dist/cjs/ogm.js +42 -5
  72. package/dist/cjs/ogm.js.map +1 -1
  73. package/dist/cjs/policy/nested-writes.d.ts.map +1 -1
  74. package/dist/cjs/policy/nested-writes.js +40 -20
  75. package/dist/cjs/policy/nested-writes.js.map +1 -1
  76. package/dist/cjs/schema/parser.d.ts.map +1 -1
  77. package/dist/cjs/schema/parser.js +94 -16
  78. package/dist/cjs/schema/parser.js.map +1 -1
  79. package/dist/cjs/schema/types.d.ts +1 -1
  80. package/dist/cjs/schema/types.d.ts.map +1 -1
  81. package/dist/cjs/testing/cypher-assert.d.ts.map +1 -1
  82. package/dist/cjs/testing/cypher-assert.js +25 -2
  83. package/dist/cjs/testing/cypher-assert.js.map +1 -1
  84. package/dist/cjs/testing/neo4j-record-factory.d.ts +25 -2
  85. package/dist/cjs/testing/neo4j-record-factory.d.ts.map +1 -1
  86. package/dist/cjs/testing/neo4j-record-factory.js +82 -3
  87. package/dist/cjs/testing/neo4j-record-factory.js.map +1 -1
  88. package/dist/cjs/utils/cypher-field-projection.d.ts +31 -3
  89. package/dist/cjs/utils/cypher-field-projection.d.ts.map +1 -1
  90. package/dist/cjs/utils/cypher-field-projection.js +59 -7
  91. package/dist/cjs/utils/cypher-field-projection.js.map +1 -1
  92. package/dist/cjs/utils/write-coercion.d.ts +18 -0
  93. package/dist/cjs/utils/write-coercion.d.ts.map +1 -1
  94. package/dist/cjs/utils/write-coercion.js +74 -1
  95. package/dist/cjs/utils/write-coercion.js.map +1 -1
  96. package/dist/esm/cli/commands/db-push.d.ts.map +1 -1
  97. package/dist/esm/cli/commands/db-push.js +74 -8
  98. package/dist/esm/cli/commands/db-push.js.map +1 -1
  99. package/dist/esm/cli/commands/db-seed.d.ts.map +1 -1
  100. package/dist/esm/cli/commands/db-seed.js +10 -1
  101. package/dist/esm/cli/commands/db-seed.js.map +1 -1
  102. package/dist/esm/cli/introspect.d.ts +2 -2
  103. package/dist/esm/cli/introspect.d.ts.map +1 -1
  104. package/dist/esm/cli/introspect.js +70 -3
  105. package/dist/esm/cli/introspect.js.map +1 -1
  106. package/dist/esm/cli/push-planner.d.ts +61 -2
  107. package/dist/esm/cli/push-planner.d.ts.map +1 -1
  108. package/dist/esm/cli/push-planner.js +183 -18
  109. package/dist/esm/cli/push-planner.js.map +1 -1
  110. package/dist/esm/compilers/fulltext.compiler.d.ts.map +1 -1
  111. package/dist/esm/compilers/fulltext.compiler.js +5 -1
  112. package/dist/esm/compilers/fulltext.compiler.js.map +1 -1
  113. package/dist/esm/compilers/mutation.compiler.d.ts +107 -1
  114. package/dist/esm/compilers/mutation.compiler.d.ts.map +1 -1
  115. package/dist/esm/compilers/mutation.compiler.js +644 -380
  116. package/dist/esm/compilers/mutation.compiler.js.map +1 -1
  117. package/dist/esm/compilers/select-normalizer.d.ts.map +1 -1
  118. package/dist/esm/compilers/select-normalizer.js +12 -1
  119. package/dist/esm/compilers/select-normalizer.js.map +1 -1
  120. package/dist/esm/compilers/selection.compiler.d.ts +40 -0
  121. package/dist/esm/compilers/selection.compiler.d.ts.map +1 -1
  122. package/dist/esm/compilers/selection.compiler.js +123 -21
  123. package/dist/esm/compilers/selection.compiler.js.map +1 -1
  124. package/dist/esm/compilers/where.compiler.d.ts.map +1 -1
  125. package/dist/esm/compilers/where.compiler.js +37 -16
  126. package/dist/esm/compilers/where.compiler.js.map +1 -1
  127. package/dist/esm/execution/executor.d.ts +34 -9
  128. package/dist/esm/execution/executor.d.ts.map +1 -1
  129. package/dist/esm/execution/executor.js +41 -5
  130. package/dist/esm/execution/executor.js.map +1 -1
  131. package/dist/esm/execution/result-mapper.d.ts.map +1 -1
  132. package/dist/esm/execution/result-mapper.js +4 -2
  133. package/dist/esm/execution/result-mapper.js.map +1 -1
  134. package/dist/esm/generator/generate-types.d.ts.map +1 -1
  135. package/dist/esm/generator/generate-types.js +76 -0
  136. package/dist/esm/generator/generate-types.js.map +1 -1
  137. package/dist/esm/generator/type-emitters/aggregation-emitter.d.ts.map +1 -1
  138. package/dist/esm/generator/type-emitters/aggregation-emitter.js +19 -7
  139. package/dist/esm/generator/type-emitters/aggregation-emitter.js.map +1 -1
  140. package/dist/esm/generator/type-emitters/input-emitter.js +13 -4
  141. package/dist/esm/generator/type-emitters/input-emitter.js.map +1 -1
  142. package/dist/esm/generator/type-emitters/model-map-emitter.d.ts +1 -0
  143. package/dist/esm/generator/type-emitters/model-map-emitter.d.ts.map +1 -1
  144. package/dist/esm/generator/type-emitters/model-map-emitter.js +5 -20
  145. package/dist/esm/generator/type-emitters/model-map-emitter.js.map +1 -1
  146. package/dist/esm/generator/type-emitters/node-type-emitter.d.ts +3 -0
  147. package/dist/esm/generator/type-emitters/node-type-emitter.d.ts.map +1 -1
  148. package/dist/esm/generator/type-emitters/node-type-emitter.js +6 -11
  149. package/dist/esm/generator/type-emitters/node-type-emitter.js.map +1 -1
  150. package/dist/esm/generator/type-emitters/select-fields-emitter.js +21 -2
  151. package/dist/esm/generator/type-emitters/select-fields-emitter.js.map +1 -1
  152. package/dist/esm/generator/type-emitters/where-emitter.js +3 -3
  153. package/dist/esm/generator/type-emitters/where-emitter.js.map +1 -1
  154. package/dist/esm/index.d.ts +1 -1
  155. package/dist/esm/index.d.ts.map +1 -1
  156. package/dist/esm/interface-model.d.ts.map +1 -1
  157. package/dist/esm/interface-model.js +6 -0
  158. package/dist/esm/interface-model.js.map +1 -1
  159. package/dist/esm/model.d.ts.map +1 -1
  160. package/dist/esm/model.js +6 -0
  161. package/dist/esm/model.js.map +1 -1
  162. package/dist/esm/ogm.d.ts +22 -2
  163. package/dist/esm/ogm.d.ts.map +1 -1
  164. package/dist/esm/ogm.js +43 -6
  165. package/dist/esm/ogm.js.map +1 -1
  166. package/dist/esm/policy/nested-writes.d.ts.map +1 -1
  167. package/dist/esm/policy/nested-writes.js +40 -20
  168. package/dist/esm/policy/nested-writes.js.map +1 -1
  169. package/dist/esm/schema/parser.d.ts.map +1 -1
  170. package/dist/esm/schema/parser.js +94 -16
  171. package/dist/esm/schema/parser.js.map +1 -1
  172. package/dist/esm/schema/types.d.ts +1 -1
  173. package/dist/esm/schema/types.d.ts.map +1 -1
  174. package/dist/esm/testing/cypher-assert.d.ts.map +1 -1
  175. package/dist/esm/testing/cypher-assert.js +25 -2
  176. package/dist/esm/testing/cypher-assert.js.map +1 -1
  177. package/dist/esm/testing/neo4j-record-factory.d.ts +25 -2
  178. package/dist/esm/testing/neo4j-record-factory.d.ts.map +1 -1
  179. package/dist/esm/testing/neo4j-record-factory.js +82 -3
  180. package/dist/esm/testing/neo4j-record-factory.js.map +1 -1
  181. package/dist/esm/utils/cypher-field-projection.d.ts +31 -3
  182. package/dist/esm/utils/cypher-field-projection.d.ts.map +1 -1
  183. package/dist/esm/utils/cypher-field-projection.js +58 -7
  184. package/dist/esm/utils/cypher-field-projection.js.map +1 -1
  185. package/dist/esm/utils/write-coercion.d.ts +18 -0
  186. package/dist/esm/utils/write-coercion.d.ts.map +1 -1
  187. package/dist/esm/utils/write-coercion.js +73 -1
  188. package/dist/esm/utils/write-coercion.js.map +1 -1
  189. package/package.json +1 -1
@@ -2,6 +2,9 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.MutationCompiler = void 0;
4
4
  exports.connectionWhereToNodeWhere = connectionWhereToNodeWhere;
5
+ exports.abstractTarget = abstractTarget;
6
+ exports.perMemberEntries = perMemberEntries;
7
+ exports.unwrapAbstractCreateNode = unwrapAbstractCreateNode;
5
8
  const errors_1 = require("../errors");
6
9
  const errors_2 = require("../policy/errors");
7
10
  const types_1 = require("../policy/types");
@@ -9,6 +12,33 @@ const utils_1 = require("../schema/utils");
9
12
  const validation_1 = require("../utils/validation");
10
13
  const write_coercion_1 = require("../utils/write-coercion");
11
14
  const where_compiler_1 = require("./where.compiler");
15
+ /** The root node `n` of an update — the pre-2.4.8 names, unprefixed. */
16
+ const TOP_SCOPE = {
17
+ sourceVar: 'n',
18
+ carryVars: ['n'],
19
+ withClause: 'WITH n',
20
+ varPrefix: '',
21
+ depth: 0,
22
+ };
23
+ /** Max depth of connects nested in connects (disconnects likewise). */
24
+ const MAX_NESTED_LINK_DEPTH = 5;
25
+ /**
26
+ * The scope of a connect / disconnect nested in an item whose target is
27
+ * bound as `targetVar`: that node becomes the source, everything in scope
28
+ * stays carried, and its own names are prefixed by it.
29
+ */
30
+ function nestedScope(carryVars, targetVar, depth) {
31
+ if (depth >= MAX_NESTED_LINK_DEPTH)
32
+ throw new errors_1.OGMError(`Nested connect / disconnect deeper than ${MAX_NESTED_LINK_DEPTH} levels is not supported.`);
33
+ const vars = [...new Set([...carryVars, targetVar])];
34
+ return {
35
+ sourceVar: targetVar,
36
+ carryVars: vars,
37
+ withClause: `WITH ${vars.join(', ')}`,
38
+ varPrefix: `${targetVar}_`,
39
+ depth: depth + 1,
40
+ };
41
+ }
12
42
  /**
13
43
  * Compiles create, update, delete, and label mutations into Cypher + params.
14
44
  */
@@ -20,6 +50,13 @@ class MutationCompiler {
20
50
  * Bounded by the number of node types in the schema (~50-100 entries).
21
51
  */
22
52
  this.labelCache = new Map();
53
+ /**
54
+ * Relationships narrowed from an abstract target to one member by the
55
+ * per-member dispatch. A BLANKET disconnect on one adds the member's
56
+ * label: the relationship type alone also reaches the other members, so
57
+ * pre-2.4.8 `{ Ebook: [{ disconnect: [{}] }] }` unlinked every member.
58
+ */
59
+ this.narrowedRelDefs = new WeakSet();
23
60
  this.logger = options?.logger;
24
61
  this.policyWhereCompiler = options?.whereCompiler;
25
62
  }
@@ -138,6 +175,9 @@ class MutationCompiler {
138
175
  */
139
176
  isConnectFastPathEligible(items, targetNodeDef) {
140
177
  for (const item of items) {
178
+ // A nested `connect` is per item (v2.4.8): the UNWIND cannot run it.
179
+ if (item.connect != null)
180
+ return false;
141
181
  const where = item.where;
142
182
  if (where === undefined)
143
183
  continue;
@@ -189,6 +229,36 @@ class MutationCompiler {
189
229
  this.labelCache.set(nodeDef.typeName, labelStr);
190
230
  return labelStr;
191
231
  }
232
+ /**
233
+ * The MATCH that binds a connect TARGET by type. A node type keeps its
234
+ * label pattern; an interface / union target matches its MEMBERS' labels
235
+ * through a WHERE predicate (v2.4.8). Pre-2.4.8 it matched the abstract
236
+ * type's own label, which implementer and member nodes do not carry, so
237
+ * the connect linked nothing.
238
+ */
239
+ targetMatch(targetNodeDef, targetVar) {
240
+ const abstract = abstractTarget(targetNodeDef.typeName, this.schema);
241
+ if (!abstract)
242
+ return {
243
+ match: `MATCH (${targetVar}:${this.getCachedLabelString(targetNodeDef)})`,
244
+ labelPredicate: null,
245
+ };
246
+ const alternatives = abstract.members.map((m) => `${targetVar}:${this.getCachedLabelString(this.schema.nodes.get(m))}`);
247
+ return {
248
+ match: `MATCH (${targetVar})`,
249
+ labelPredicate: alternatives.length > 0 ? `(${alternatives.join(' OR ')})` : 'false',
250
+ };
251
+ }
252
+ /**
253
+ * Resolve a relationship's target for a nested write. Throws when the
254
+ * target is unknown — pre-2.4.8 the nested op was silently skipped.
255
+ */
256
+ requireTargetDef(relDef) {
257
+ const targetNodeDef = (0, utils_1.resolveTargetDef)(relDef.target, this.schema);
258
+ if (!targetNodeDef)
259
+ throw new errors_1.OGMError(`Cannot resolve the target type "${relDef.target}" of relationship "${relDef.fieldName}".`);
260
+ return targetNodeDef;
261
+ }
192
262
  /**
193
263
  * Generate CREATE Cypher for one or more nodes.
194
264
  * Handles scalar properties, nested relationship creates, and connects.
@@ -490,8 +560,11 @@ class MutationCompiler {
490
560
  for (const [key, value] of Object.entries(create)) {
491
561
  if (value === undefined)
492
562
  continue;
493
- if (nodeDef.relationships.has(key))
494
- continue;
563
+ if (nodeDef.relationships.has(key)) {
564
+ if (value === null)
565
+ continue;
566
+ throw upsertRelationshipError(nodeDef, key, 'create');
567
+ }
495
568
  (0, validation_1.assertSafePropertyName)(key, 'create property');
496
569
  const propDef = nodeDef.properties.get(key);
497
570
  const paramName = `onCreate_${key}`;
@@ -537,8 +610,11 @@ class MutationCompiler {
537
610
  for (const [key, value] of Object.entries(update)) {
538
611
  if (value === undefined)
539
612
  continue;
540
- if (nodeDef.relationships.has(key))
541
- continue;
613
+ if (nodeDef.relationships.has(key)) {
614
+ if (value === null)
615
+ continue;
616
+ throw upsertRelationshipError(nodeDef, key, 'update');
617
+ }
542
618
  (0, validation_1.assertSafePropertyName)(key, 'update property');
543
619
  const propDef = nodeDef.properties.get(key);
544
620
  const paramName = `onMatch_${key}`;
@@ -789,19 +865,20 @@ class MutationCompiler {
789
865
  const relDef = nodeDef.relationships.get(key);
790
866
  if (!relDef)
791
867
  continue;
868
+ if (value == null)
869
+ continue;
792
870
  const relInput = value;
793
- // Check if target is a union type — if so, relInput uses per-member keys
794
- // e.g., items: { Ebook: { create: [...] } }
795
- const isUnionTarget = !this.schema.nodes.has(relDef.target) &&
796
- this.schema.unions?.has(relDef.target);
797
- if (isUnionTarget) {
798
- const unionMembers = this.schema.unions.get(relDef.target);
799
- for (const [memberKey, memberValue] of Object.entries(relInput)) {
800
- if (!unionMembers.includes(memberKey))
801
- continue;
871
+ // Union / interface targets: per-member keys (e.g. items: { Ebook:
872
+ // { create: [...] } }) dispatch to the concrete member; an interface
873
+ // also takes the flat `{ connect, create }` shape (v2.4.8 — pre-2.4.8
874
+ // interface targets were skipped entirely, so nothing was written).
875
+ const abstract = abstractTarget(relDef.target, this.schema);
876
+ const memberEntries = abstract
877
+ ? perMemberEntries(relInput, abstract, key)
878
+ : null;
879
+ if (memberEntries) {
880
+ for (const [memberKey, memberValue] of memberEntries) {
802
881
  const memberNodeDef = this.schema.nodes.get(memberKey);
803
- if (!memberNodeDef)
804
- continue;
805
882
  const memberInput = memberValue;
806
883
  // Process creates/connects for this union member
807
884
  const memberLines = this.buildCreateRelationshipsForTarget(memberInput, memberNodeDef, relDef, nodeVar, `${prefix}_${key}_${memberKey}`, params, allVars, withClause, nestedCounter, policyContext, paramCounter);
@@ -810,9 +887,7 @@ class MutationCompiler {
810
887
  }
811
888
  continue;
812
889
  }
813
- const targetNodeDef = this.schema.nodes.get(relDef.target);
814
- if (!targetNodeDef)
815
- continue;
890
+ const targetNodeDef = this.requireTargetDef(relDef);
816
891
  const resultLines = this.buildCreateRelationshipsForTarget(relInput, targetNodeDef, relDef, nodeVar, `${prefix}_${key}`, params, allVars, withClause, nestedCounter, policyContext, paramCounter);
817
892
  nestedCounter += resultLines.counterUsed;
818
893
  lines.push(...resultLines.lines);
@@ -826,7 +901,8 @@ class MutationCompiler {
826
901
  buildCreateRelationshipsForTarget(relInput, targetNodeDef, relDef, nodeVar, prefix, params, allVars, withClause, startCounter, policyContext, paramCounter) {
827
902
  const lines = [];
828
903
  let counter = 0;
829
- const targetLabelStr = this.getCachedLabelString(targetNodeDef);
904
+ // A flat interface input (v2.4.8): each create names its implementer.
905
+ const abstract = abstractTarget(targetNodeDef.typeName, this.schema);
830
906
  // --- create ---
831
907
  if (relInput.create) {
832
908
  const createItems = Array.isArray(relInput.create)
@@ -834,15 +910,22 @@ class MutationCompiler {
834
910
  : [relInput.create];
835
911
  for (let ci = 0; ci < createItems.length; ci++) {
836
912
  const createSpec = createItems[ci];
837
- const nodeSpec = (createSpec.node ?? createSpec);
913
+ let nodeSpec = (createSpec.node ?? createSpec);
914
+ let createDef = targetNodeDef;
915
+ if (abstract) {
916
+ const [member, memberSpec] = unwrapAbstractCreateNode(nodeSpec, abstract, relDef.fieldName);
917
+ createDef = this.schema.nodes.get(member);
918
+ nodeSpec = memberSpec;
919
+ }
920
+ const targetLabelStr = this.getCachedLabelString(createDef);
838
921
  const createVar = `${nodeVar}_c${startCounter + counter}`;
839
922
  counter++;
840
- const { propString, propParams } = this.buildCreateProperties(nodeSpec, targetNodeDef, `${prefix}_create${ci}`);
923
+ const { propString, propParams } = this.buildCreateProperties(nodeSpec, createDef, `${prefix}_create${ci}`);
841
924
  // Handle nested relationship creates within the created node
842
- const nestedRelLines = this.buildCreateRelationships(nodeSpec, targetNodeDef, createVar, `${prefix}_create${ci}`, params, allVars, policyContext, paramCounter);
925
+ const nestedRelLines = this.buildCreateRelationships(nodeSpec, createDef, createVar, `${prefix}_create${ci}`, params, allVars, policyContext, paramCounter);
843
926
  const propsClause = propString.length > 0
844
927
  ? ` { ${propString} }`
845
- : ` { ${this.buildGeneratedIdClause(targetNodeDef)} }`;
928
+ : ` { ${this.buildGeneratedIdClause(createDef)} }`;
846
929
  const relPattern = (0, utils_1.buildRelPattern)({
847
930
  sourceVar: nodeVar,
848
931
  relDef,
@@ -872,6 +955,9 @@ class MutationCompiler {
872
955
  targetVar: createVar,
873
956
  edgeVar: relVar,
874
957
  targetLabel: 'auto',
958
+ // Abstract targets: no label — the created node never carries
959
+ // the union / interface label (v2.4.8).
960
+ schema: this.schema,
875
961
  });
876
962
  lines.push(`WITH ${[...allVars, createVar].join(', ')}`);
877
963
  lines.push(`MATCH ${relArrow}`);
@@ -901,339 +987,426 @@ class MutationCompiler {
901
987
  ? relInput.connect
902
988
  : [relInput.connect];
903
989
  for (let ci = 0; ci < connectItems.length; ci++) {
904
- const connectSpec = connectItems[ci];
905
- const whereSpec = connectSpec.where;
906
- const edgeInput = connectSpec.edge;
907
990
  // Wrap in CALL subquery so a failed MATCH doesn't kill the outer pipeline
908
- lines.push(withClause);
909
- lines.push(`CALL {`);
910
- lines.push(withClause);
911
- const connectVar = `${nodeVar}_cn${startCounter + counter}`;
991
+ const index = startCounter + counter;
912
992
  counter++;
913
- lines.push(`MATCH (${connectVar}:${targetLabelStr})`);
914
- const connectConditions = [
915
- this.compileTargetWhere(whereSpec, connectVar, targetNodeDef, params, policyContext, paramCounter),
916
- this.buildTargetPolicyPredicate(targetNodeDef, connectVar, params, policyContext, paramCounter),
917
- ].filter((c) => Boolean(c));
918
- if (connectConditions.length > 0)
919
- lines.push(`WHERE ${connectConditions.join(' AND ')}`);
920
- if (edgeInput && Object.keys(edgeInput).length > 0) {
921
- // Use bare target var (no label) since connectVar is already bound by MATCH.
922
- // Including labels on a bound variable in MERGE causes Neo4j to reject it.
923
- const relVar = `r_conn_${startCounter + counter - 1}`;
924
- (0, validation_1.assertSafeIdentifier)(relDef.type, 'relationship type');
925
- const escapedRelType = (0, validation_1.escapeIdentifier)(relDef.type);
926
- const mergePattern = relDef.direction === 'OUT'
927
- ? `(${nodeVar})-[${relVar}:${escapedRelType}]->(${connectVar})`
928
- : `(${nodeVar})<-[${relVar}:${escapedRelType}]-(${connectVar})`;
929
- lines.push(`MERGE ${mergePattern}`);
930
- const setItems = [];
931
- for (const [prop, val] of Object.entries(edgeInput)) {
932
- if (val === undefined)
933
- continue;
934
- (0, validation_1.assertSafePropertyName)(prop, 'edge property');
935
- const edgePropDef = this.getEdgePropDef(relDef, prop);
936
- const paramName = `${prefix}_conn${ci}_edge_${prop}`;
937
- setItems.push(`r_conn_${startCounter + counter - 1}.${(0, validation_1.escapeIdentifier)(prop)} = ${(0, write_coercion_1.wrapWriteExpr)(`$${paramName}`, val, edgePropDef)}`);
938
- params[paramName] = (0, write_coercion_1.coerceWriteValue)(val, edgePropDef);
939
- }
940
- if (setItems.length > 0)
941
- lines.push(`SET ${setItems.join(', ')}`);
942
- }
943
- else {
944
- const relPattern = (0, utils_1.buildRelPattern)({
993
+ lines.push(...this.buildConnectItem({
994
+ connectSpec: connectItems[ci],
995
+ relDef,
996
+ targetNodeDef,
997
+ sourceVar: nodeVar,
998
+ withClause,
999
+ connectVar: `${nodeVar}_cn${index}`,
1000
+ relVar: `r_conn_${index}`,
1001
+ edgeParamPrefix: `${prefix}_conn${ci}`,
1002
+ returnAlias: `_ccn_${prefix}_${ci}`,
1003
+ params,
1004
+ policyContext,
1005
+ paramCounter,
1006
+ scope: {
945
1007
  sourceVar: nodeVar,
946
- relDef,
947
- targetVar: connectVar,
948
- });
949
- lines.push(`MERGE ${relPattern}`);
950
- }
951
- lines.push(`RETURN count(*) AS _ccn_${prefix}_${ci}`);
952
- lines.push(`}`);
1008
+ carryVars: allVars,
1009
+ withClause,
1010
+ varPrefix: '',
1011
+ depth: 0,
1012
+ },
1013
+ }));
953
1014
  }
954
1015
  }
955
1016
  return { lines, counterUsed: counter };
956
1017
  }
957
- buildConnects(connect, nodeDef, params, policyContext, paramCounter) {
1018
+ /**
1019
+ * Top-level `connect`. A union / interface relationship keyed by member
1020
+ * (`{ media: { Book: [...] } }`, the shape codegen emits) connects each
1021
+ * member separately; a flat interface input matches the implementers by
1022
+ * their own labels (v2.4.8 — pre-2.4.8 both matched the abstract type's
1023
+ * label, which no node carries, and the per-member shape was never
1024
+ * unwrapped, so the MATCH carried NO filter at all).
1025
+ */
1026
+ buildConnects(connect, nodeDef, params, policyContext, paramCounter, scope = TOP_SCOPE) {
958
1027
  const lines = [];
959
1028
  for (const [fieldName, spec] of Object.entries(connect)) {
960
1029
  const relDef = nodeDef.relationships.get(fieldName);
961
1030
  if (!relDef)
962
1031
  continue;
963
- const targetNodeDef = (0, utils_1.resolveTargetDef)(relDef.target, this.schema);
964
- if (!targetNodeDef)
1032
+ const abstract = abstractTarget(relDef.target, this.schema);
1033
+ const memberEntries = abstract && !isBlanketSpec(spec)
1034
+ ? perMemberEntries(spec, abstract, fieldName)
1035
+ : null;
1036
+ if (memberEntries) {
1037
+ for (const [member, memberSpec] of memberEntries)
1038
+ lines.push(...this.buildRelConnects(memberSpec, this.narrowToMember(relDef, member), this.schema.nodes.get(member), `${fieldName}_${member}`, params, policyContext, paramCounter, scope));
965
1039
  continue;
966
- const targetLabelStr = this.getCachedLabelString(targetNodeDef);
967
- // Array connect
968
- if (Array.isArray(spec)) {
969
- if (spec.length === 0)
970
- continue; // Empty array — nothing to connect
971
- // The UNWIND fast path compiles filters against per-row values and
972
- // is only exact for plain scalar, non-null filters (see
973
- // isConnectFastPathEligible). Everything else — null values,
974
- // logical keys, traversals — takes one CALL subquery per item,
975
- // compiled through WhereCompiler.
976
- const useFastPath = this.isConnectFastPathEligible(spec, targetNodeDef);
977
- if (!useFastPath)
978
- for (let ci = 0; ci < spec.length; ci++) {
979
- const connectSpec = spec[ci];
980
- const whereSpec = connectSpec.where;
981
- const edgeInput = connectSpec.edge;
982
- lines.push('WITH n');
983
- lines.push(`CALL {`);
984
- lines.push('WITH n');
985
- const connectVar = `target_${fieldName}_${ci}`;
986
- lines.push(`MATCH (${connectVar}:${targetLabelStr})`);
987
- const connectConditions = [
988
- this.compileTargetWhere(whereSpec, connectVar, targetNodeDef, params, policyContext, paramCounter),
989
- this.buildTargetPolicyPredicate(targetNodeDef, connectVar, params, policyContext, paramCounter),
990
- ].filter((c) => Boolean(c));
991
- if (connectConditions.length > 0)
992
- lines.push(`WHERE ${connectConditions.join(' AND ')}`);
993
- if (edgeInput && Object.keys(edgeInput).length > 0) {
994
- (0, validation_1.assertSafeIdentifier)(relDef.type, 'relationship type');
995
- const escapedRelType = (0, validation_1.escapeIdentifier)(relDef.type);
996
- const relVar = `r_conn_${fieldName}_${ci}`;
997
- const mergePattern = relDef.direction === 'OUT'
998
- ? `(n)-[${relVar}:${escapedRelType}]->(${connectVar})`
999
- : `(n)<-[${relVar}:${escapedRelType}]-(${connectVar})`;
1000
- lines.push(`MERGE ${mergePattern}`);
1001
- const setItems = [];
1002
- for (const [prop, val] of Object.entries(edgeInput)) {
1003
- if (val === undefined)
1004
- continue;
1005
- (0, validation_1.assertSafePropertyName)(prop, 'edge property');
1006
- const edgePropDef = this.getEdgePropDef(relDef, prop);
1007
- const paramName = `connect_${fieldName}_${ci}_edge_${prop}`;
1008
- setItems.push(`${relVar}.${(0, validation_1.escapeIdentifier)(prop)} = ${(0, write_coercion_1.wrapWriteExpr)(`$${paramName}`, val, edgePropDef)}`);
1009
- params[paramName] = (0, write_coercion_1.coerceWriteValue)(val, edgePropDef);
1010
- }
1011
- if (setItems.length > 0)
1012
- lines.push(`SET ${setItems.join(', ')}`);
1013
- }
1014
- else {
1015
- const mergePattern = (0, utils_1.buildRelPattern)({
1016
- sourceVar: 'n',
1017
- relDef,
1018
- targetVar: connectVar,
1019
- });
1020
- lines.push(`MERGE ${mergePattern}`);
1021
- }
1022
- lines.push(`RETURN count(*) AS _ccn_${fieldName}_${ci}`);
1023
- lines.push(`}`);
1024
- }
1025
- else {
1026
- // Scalar-only WHERE — use efficient UNWIND approach
1027
- const paramName = `connect_${fieldName}`;
1028
- // Strip undefined values from edge objects — Neo4j driver drops undefined,
1029
- // causing param mismatches when the Cypher references nested edge properties.
1030
- params[paramName] = spec.map((item) => {
1031
- const edge = item.edge;
1032
- if (!edge)
1033
- return item;
1034
- const cleaned = {};
1035
- for (const [k, v] of Object.entries(edge))
1036
- if (v !== undefined)
1037
- cleaned[k] = (0, write_coercion_1.coerceWriteValue)(v, this.getEdgePropDef(relDef, k));
1038
- return { ...item, edge: cleaned };
1039
- });
1040
- // Wrap in a CALL subquery: UNWIND + MATCH yields one row per
1041
- // matched target (zero when nothing matches), which would
1042
- // multiply — or drop — the updated node for every later clause.
1043
- // Connecting to no target is a no-op, as in @neo4j/graphql.
1044
- lines.push('WITH n');
1045
- lines.push('CALL {');
1046
- lines.push('WITH n');
1047
- lines.push(`UNWIND $${paramName} AS connItem`);
1048
- lines.push(`MATCH (target:${targetLabelStr})`);
1049
- // Build WHERE from the first item's structure to determine the path.
1050
- // Pre-1.7.4 we silently used `firstItem`'s keys for ALL items —
1051
- // if `spec[1]` had different filter keys (e.g. an extra
1052
- // `tenantId`), those keys were silently dropped from the WHERE.
1053
- // Validate homogeneity now and throw if items diverge so the
1054
- // caller knows to split into separate calls or use the per-item
1055
- // CALL fallback.
1056
- const firstItem = spec[0];
1057
- const firstItemSig = computeConnectItemSignature(firstItem);
1058
- for (let idx = 1; idx < spec.length; idx++) {
1059
- const sig = computeConnectItemSignature(spec[idx]);
1060
- if (sig !== firstItemSig)
1061
- throw new errors_1.OGMError(`connect array items have divergent shapes — item[0] has keys "${firstItemSig}" ` +
1062
- `but item[${idx}] has keys "${sig}". The UNWIND fast path requires every item ` +
1063
- `to share the same WHERE / edge key set; otherwise the additional keys are ` +
1064
- `silently dropped. Split into separate connect calls, or normalise the items ` +
1065
- `to share the same shape.`);
1066
- }
1067
- const whereConditions = this.extractConnectWhereConditions(firstItem, 'target', 'connItem', targetNodeDef);
1068
- const policyPredicate = this.buildTargetPolicyPredicate(targetNodeDef, 'target', params, policyContext, paramCounter);
1069
- const unwindConditions = policyPredicate
1070
- ? [...whereConditions, policyPredicate]
1071
- : whereConditions;
1072
- if (unwindConditions.length > 0)
1073
- lines.push(`WHERE ${unwindConditions.join(' AND ')}`);
1074
- const mergePattern = (0, utils_1.buildRelPattern)({
1075
- sourceVar: 'n',
1076
- relDef,
1077
- targetVar: 'target',
1078
- });
1079
- lines.push(`MERGE ${mergePattern}`);
1080
- // Handle edge properties from first item structure
1081
- const edgeProps = this.extractEdgeProperties(firstItem);
1082
- if (edgeProps.length > 0) {
1083
- // Use bare target var (no label) since target is already bound by MATCH
1084
- (0, validation_1.assertSafeIdentifier)(relDef.type, 'relationship type');
1085
- const escapedRelType = (0, validation_1.escapeIdentifier)(relDef.type);
1086
- const mergeWithVar = relDef.direction === 'OUT'
1087
- ? `(n)-[r:${escapedRelType}]->(target)`
1088
- : `(n)<-[r:${escapedRelType}]-(target)`;
1089
- // Replace MERGE line with one that has a variable
1090
- lines[lines.length - 1] = `MERGE ${mergeWithVar}`;
1091
- // r.prop is relationship property (escape), connItem.edge.prop is parameter map (no escape)
1092
- const setItems = edgeProps.map((p) => `r.${(0, validation_1.escapeIdentifier)(p)} = ${(0, write_coercion_1.wrapListItemExpr)(`connItem.edge.${p}`, spec.map((it) => it.edge?.[p]), this.getEdgePropDef(relDef, p))}`);
1093
- lines.push(`SET ${setItems.join(', ')}`);
1094
- }
1095
- lines.push(`RETURN count(*) AS _conn_${fieldName}`);
1096
- lines.push('}');
1097
- }
1098
1040
  }
1041
+ lines.push(...this.buildRelConnects(spec, relDef, this.requireTargetDef(relDef), fieldName, params, policyContext, paramCounter, scope));
1042
+ }
1043
+ return lines;
1044
+ }
1045
+ /**
1046
+ * One relationship's top-level connect. `tag` names its variables,
1047
+ * params and aliases: the field name, or `<field>_<Member>` for an
1048
+ * abstract relationship given per member, so two members never collide.
1049
+ */
1050
+ buildRelConnects(spec, relDef, targetNodeDef, tag, params, policyContext, paramCounter, scope) {
1051
+ const lines = [];
1052
+ const { sourceVar, withClause, varPrefix } = scope;
1053
+ // Array connect
1054
+ if (Array.isArray(spec)) {
1055
+ if (spec.length === 0)
1056
+ return lines; // Empty array — nothing to connect
1057
+ // The UNWIND fast path compiles filters against per-row values and
1058
+ // is only exact for plain scalar, non-null filters (see
1059
+ // isConnectFastPathEligible). Everything else — null values,
1060
+ // logical keys, traversals, abstract targets — takes one CALL
1061
+ // subquery per item, compiled through WhereCompiler.
1062
+ const useFastPath = !abstractTarget(targetNodeDef.typeName, this.schema) &&
1063
+ this.isConnectFastPathEligible(spec, targetNodeDef);
1064
+ if (!useFastPath)
1065
+ for (let ci = 0; ci < spec.length; ci++)
1066
+ lines.push(...this.buildConnectItem({
1067
+ connectSpec: spec[ci],
1068
+ relDef,
1069
+ targetNodeDef,
1070
+ sourceVar,
1071
+ withClause,
1072
+ connectVar: `${varPrefix}target_${tag}_${ci}`,
1073
+ relVar: `${varPrefix}r_conn_${tag}_${ci}`,
1074
+ edgeParamPrefix: `${varPrefix}connect_${tag}_${ci}`,
1075
+ returnAlias: `_ccn_${varPrefix}${tag}_${ci}`,
1076
+ params,
1077
+ policyContext,
1078
+ paramCounter,
1079
+ scope,
1080
+ }));
1099
1081
  else {
1100
- // Single connect
1101
- const connectSpec = spec;
1102
- const whereSpec = connectSpec.where;
1103
- const edgeInput = connectSpec.edge;
1104
- // Same row-preserving isolation as the UNWIND path above.
1105
- lines.push('WITH n');
1082
+ const targetVar = `${varPrefix}target`;
1083
+ const relVar = `${varPrefix}r`;
1084
+ const targetLabelStr = this.getCachedLabelString(targetNodeDef);
1085
+ // Scalar-only WHERE — use efficient UNWIND approach
1086
+ const paramName = `${varPrefix}connect_${tag}`;
1087
+ // Strip undefined values from edge objects — Neo4j driver drops undefined,
1088
+ // causing param mismatches when the Cypher references nested edge properties.
1089
+ params[paramName] = spec.map((raw) => {
1090
+ // The WHERE reads `connItem.where.node.<key>`, so a bare-property
1091
+ // where (`{ where: { title } }`, which the fast path accepts) is
1092
+ // rebound under `node`. Pre-2.4.8 it was sent as-is: every lookup
1093
+ // was null, so the array connect silently linked nothing.
1094
+ const where = raw.where;
1095
+ const item = (0, validation_1.isPlainObject)(where) &&
1096
+ !Object.keys(where).some((k) => CONNECTION_WHERE_KEYS.has(k))
1097
+ ? { ...raw, where: { node: where } }
1098
+ : raw;
1099
+ const edge = item.edge;
1100
+ if (!edge)
1101
+ return item;
1102
+ const cleaned = {};
1103
+ for (const [k, v] of Object.entries(edge))
1104
+ if (v !== undefined)
1105
+ cleaned[k] = (0, write_coercion_1.coerceWriteValue)(v, this.getEdgePropDef(relDef, k));
1106
+ return { ...item, edge: cleaned };
1107
+ });
1108
+ // Wrap in a CALL subquery: UNWIND + MATCH yields one row per
1109
+ // matched target (zero when nothing matches), which would
1110
+ // multiply — or drop — the updated node for every later clause.
1111
+ // Connecting to no target is a no-op, as in @neo4j/graphql.
1112
+ lines.push(withClause);
1106
1113
  lines.push('CALL {');
1107
- lines.push('WITH n');
1108
- lines.push(`MATCH (target:${targetLabelStr})`);
1109
- const singleConnectConditions = [
1110
- this.compileTargetWhere(whereSpec, 'target', targetNodeDef, params, policyContext, paramCounter),
1111
- this.buildTargetPolicyPredicate(targetNodeDef, 'target', params, policyContext, paramCounter),
1112
- ].filter((c) => Boolean(c));
1113
- if (singleConnectConditions.length > 0)
1114
- lines.push(`WHERE ${singleConnectConditions.join(' AND ')}`);
1115
- if (edgeInput && Object.keys(edgeInput).length > 0) {
1114
+ lines.push(withClause);
1115
+ lines.push(`UNWIND $${paramName} AS connItem`);
1116
+ lines.push(`MATCH (${targetVar}:${targetLabelStr})`);
1117
+ // Build WHERE from the first item's structure to determine the path.
1118
+ // Pre-1.7.4 we silently used `firstItem`'s keys for ALL items —
1119
+ // if `spec[1]` had different filter keys (e.g. an extra
1120
+ // `tenantId`), those keys were silently dropped from the WHERE.
1121
+ // Validate homogeneity now and throw if items diverge so the
1122
+ // caller knows to split into separate calls or use the per-item
1123
+ // CALL fallback.
1124
+ const firstItem = spec[0];
1125
+ const firstItemSig = computeConnectItemSignature(firstItem);
1126
+ for (let idx = 1; idx < spec.length; idx++) {
1127
+ const sig = computeConnectItemSignature(spec[idx]);
1128
+ if (sig !== firstItemSig)
1129
+ throw new errors_1.OGMError(`connect array items have divergent shapes — item[0] has keys "${firstItemSig}" ` +
1130
+ `but item[${idx}] has keys "${sig}". The UNWIND fast path requires every item ` +
1131
+ `to share the same WHERE / edge key set; otherwise the additional keys are ` +
1132
+ `silently dropped. Split into separate connect calls, or normalise the items ` +
1133
+ `to share the same shape.`);
1134
+ }
1135
+ const whereConditions = this.extractConnectWhereConditions(firstItem, targetVar, 'connItem', targetNodeDef);
1136
+ const policyPredicate = this.buildTargetPolicyPredicate(targetNodeDef, targetVar, params, policyContext, paramCounter);
1137
+ const unwindConditions = policyPredicate
1138
+ ? [...whereConditions, policyPredicate]
1139
+ : whereConditions;
1140
+ if (unwindConditions.length > 0)
1141
+ lines.push(`WHERE ${unwindConditions.join(' AND ')}`);
1142
+ const mergePattern = (0, utils_1.buildRelPattern)({
1143
+ sourceVar,
1144
+ relDef,
1145
+ targetVar,
1146
+ });
1147
+ lines.push(`MERGE ${mergePattern}`);
1148
+ // Handle edge properties from first item structure
1149
+ const edgeProps = this.extractEdgeProperties(firstItem);
1150
+ if (edgeProps.length > 0) {
1116
1151
  // Use bare target var (no label) since target is already bound by MATCH
1117
1152
  (0, validation_1.assertSafeIdentifier)(relDef.type, 'relationship type');
1118
1153
  const escapedRelType = (0, validation_1.escapeIdentifier)(relDef.type);
1119
1154
  const mergeWithVar = relDef.direction === 'OUT'
1120
- ? `(n)-[r:${escapedRelType}]->(target)`
1121
- : `(n)<-[r:${escapedRelType}]-(target)`;
1122
- lines.push(`MERGE ${mergeWithVar}`);
1123
- const setItems = [];
1124
- for (const [prop, val] of Object.entries(edgeInput)) {
1125
- if (val === undefined)
1126
- continue;
1127
- const edgePropDef = this.getEdgePropDef(relDef, prop);
1128
- const paramName = `connect_${fieldName}_edge_${prop}`;
1129
- setItems.push(`r.${(0, validation_1.escapeIdentifier)(prop)} = ${(0, write_coercion_1.wrapWriteExpr)(`$${paramName}`, val, edgePropDef)}`);
1130
- params[paramName] = (0, write_coercion_1.coerceWriteValue)(val, edgePropDef);
1131
- }
1132
- if (setItems.length > 0)
1133
- lines.push(`SET ${setItems.join(', ')}`);
1155
+ ? `(${sourceVar})-[${relVar}:${escapedRelType}]->(${targetVar})`
1156
+ : `(${sourceVar})<-[${relVar}:${escapedRelType}]-(${targetVar})`;
1157
+ // Replace MERGE line with one that has a variable
1158
+ lines[lines.length - 1] = `MERGE ${mergeWithVar}`;
1159
+ // r.prop is relationship property (escape), connItem.edge.prop is parameter map (no escape)
1160
+ const setItems = edgeProps.map((p) => `${relVar}.${(0, validation_1.escapeIdentifier)(p)} = ${(0, write_coercion_1.wrapListItemExpr)(`connItem.edge.${p}`, spec.map((it) => it.edge?.[p]), this.getEdgePropDef(relDef, p))}`);
1161
+ lines.push(`SET ${setItems.join(', ')}`);
1134
1162
  }
1135
- else {
1136
- const mergePattern = (0, utils_1.buildRelPattern)({
1137
- sourceVar: 'n',
1138
- relDef,
1139
- targetVar: 'target',
1140
- });
1141
- lines.push(`MERGE ${mergePattern}`);
1142
- }
1143
- lines.push(`RETURN count(*) AS _conn_${fieldName}`);
1163
+ lines.push(`RETURN count(*) AS _conn_${varPrefix}${tag}`);
1144
1164
  lines.push('}');
1145
1165
  }
1166
+ return lines;
1146
1167
  }
1168
+ // Single connect — same row-preserving isolation as the UNWIND path.
1169
+ return this.buildConnectItem({
1170
+ connectSpec: spec,
1171
+ relDef,
1172
+ targetNodeDef,
1173
+ sourceVar,
1174
+ withClause,
1175
+ connectVar: `${varPrefix}target`,
1176
+ relVar: `${varPrefix}r`,
1177
+ edgeParamPrefix: `${varPrefix}connect_${tag}`,
1178
+ returnAlias: `_conn_${varPrefix}${tag}`,
1179
+ params,
1180
+ policyContext,
1181
+ paramCounter,
1182
+ scope,
1183
+ });
1184
+ }
1185
+ /**
1186
+ * One connect item, isolated in a CALL subquery so a MATCH that finds
1187
+ * nothing (or many) never changes the outer row count:
1188
+ *
1189
+ * <withClause>
1190
+ * CALL {
1191
+ * <withClause>
1192
+ * MATCH (cv[:Label])
1193
+ * WHERE <member labels> AND <where> AND <target `read` policy>
1194
+ * MERGE (src)-[rv:REL]->(cv) [SET rv.<edge> = …]
1195
+ * RETURN count(*) AS <alias>
1196
+ * }
1197
+ *
1198
+ * Shared by every connect site (top-level, nested in create, nested in
1199
+ * update), which differ only in the names they pass.
1200
+ */
1201
+ buildConnectItem(a) {
1202
+ const { connectSpec, relDef, targetNodeDef, sourceVar, connectVar } = a;
1203
+ const lines = [];
1204
+ const whereSpec = connectSpec?.where;
1205
+ const edgeInput = connectSpec?.edge;
1206
+ lines.push(a.withClause);
1207
+ lines.push('CALL {');
1208
+ lines.push(a.withClause);
1209
+ const { match, labelPredicate } = this.targetMatch(targetNodeDef, connectVar);
1210
+ lines.push(match);
1211
+ const conditions = [
1212
+ labelPredicate,
1213
+ this.compileTargetWhere(whereSpec, connectVar, targetNodeDef, a.params, a.policyContext, a.paramCounter),
1214
+ this.buildTargetPolicyPredicate(targetNodeDef, connectVar, a.params, a.policyContext, a.paramCounter),
1215
+ ].filter((c) => Boolean(c));
1216
+ if (conditions.length > 0)
1217
+ lines.push(`WHERE ${conditions.join(' AND ')}`);
1218
+ if (edgeInput && Object.keys(edgeInput).length > 0) {
1219
+ // Use bare target var (no label) since connectVar is already bound by MATCH.
1220
+ // Including labels on a bound variable in MERGE causes Neo4j to reject it.
1221
+ (0, validation_1.assertSafeIdentifier)(relDef.type, 'relationship type');
1222
+ const escapedRelType = (0, validation_1.escapeIdentifier)(relDef.type);
1223
+ const mergePattern = relDef.direction === 'OUT'
1224
+ ? `(${sourceVar})-[${a.relVar}:${escapedRelType}]->(${connectVar})`
1225
+ : `(${sourceVar})<-[${a.relVar}:${escapedRelType}]-(${connectVar})`;
1226
+ lines.push(`MERGE ${mergePattern}`);
1227
+ const setItems = [];
1228
+ for (const [prop, val] of Object.entries(edgeInput)) {
1229
+ if (val === undefined)
1230
+ continue;
1231
+ (0, validation_1.assertSafePropertyName)(prop, 'edge property');
1232
+ const edgePropDef = this.getEdgePropDef(relDef, prop);
1233
+ const paramName = `${a.edgeParamPrefix}_edge_${prop}`;
1234
+ setItems.push(`${a.relVar}.${(0, validation_1.escapeIdentifier)(prop)} = ${(0, write_coercion_1.wrapWriteExpr)(`$${paramName}`, val, edgePropDef)}`);
1235
+ a.params[paramName] = (0, write_coercion_1.coerceWriteValue)(val, edgePropDef);
1236
+ }
1237
+ if (setItems.length > 0)
1238
+ lines.push(`SET ${setItems.join(', ')}`);
1239
+ }
1240
+ else {
1241
+ const relPattern = (0, utils_1.buildRelPattern)({
1242
+ sourceVar,
1243
+ relDef,
1244
+ targetVar: connectVar,
1245
+ });
1246
+ lines.push(`MERGE ${relPattern}`);
1247
+ }
1248
+ // A `connect` nested in the item links the CONNECTED node onward
1249
+ // (`connect: { posts: [{ where, connect: { tags: [...] } }] }`), with
1250
+ // the same policy predicates at its level. Pre-2.4.8 it was ignored.
1251
+ const nested = connectSpec?.connect;
1252
+ if (nested != null) {
1253
+ if (abstractTarget(targetNodeDef.typeName, this.schema))
1254
+ throw new errors_1.OGMError(`A nested "connect" inside a connect on "${relDef.fieldName}" (abstract type "${targetNodeDef.typeName}") ` +
1255
+ `must be keyed by the implementing type, e.g. { ${relDef.fieldName}: { Member: [{ where, connect }] } }.`);
1256
+ const inner = nestedScope(a.scope.carryVars, connectVar, a.scope.depth);
1257
+ for (const nestedConnect of Array.isArray(nested) ? nested : [nested]) {
1258
+ if (!(0, validation_1.isPlainObject)(nestedConnect))
1259
+ throw new errors_1.OGMError(`A nested "connect" inside a connect on "${relDef.fieldName}" must be an object keyed by relationship.`);
1260
+ lines.push(...this.buildConnects(nestedConnect, targetNodeDef, a.params, a.policyContext, a.paramCounter, inner));
1261
+ }
1262
+ }
1263
+ lines.push(`RETURN count(*) AS ${a.returnAlias}`);
1264
+ lines.push('}');
1147
1265
  return lines;
1148
1266
  }
1149
- buildDisconnects(disconnect, nodeDef, params, policyContext, paramCounter) {
1267
+ /**
1268
+ * Top-level `disconnect`. Abstract relationships follow the connect
1269
+ * rules (see buildConnects): per member, or flat on an interface,
1270
+ * matching implementers by their own labels (v2.4.8 — pre-2.4.8 the
1271
+ * target pattern used the interface / union label, so nothing matched).
1272
+ */
1273
+ buildDisconnects(disconnect, nodeDef, params, policyContext, paramCounter, scope = TOP_SCOPE) {
1150
1274
  const lines = [];
1151
1275
  for (const [fieldName, spec] of Object.entries(disconnect)) {
1152
1276
  const relDef = nodeDef.relationships.get(fieldName);
1153
1277
  if (!relDef)
1154
1278
  continue;
1155
- const targetNodeDef = (0, utils_1.resolveTargetDef)(relDef.target, this.schema) ?? undefined;
1156
- // Normalize spec to an array of disconnect items
1157
- const specItems = Array.isArray(spec) ? spec : [spec];
1158
- for (let si = 0; si < specItems.length; si++) {
1159
- const item = specItems[si];
1160
- const relVar = `r_${fieldName}_${si}`;
1161
- // Wrap in a CALL subquery: the OPTIONAL MATCH yields one row per
1162
- // matched relationship, which would multiply every later clause.
1163
- // `count(*)` collapses it back to exactly one row per `n`.
1164
- lines.push('WITH n');
1165
- lines.push('CALL {');
1166
- lines.push('WITH n');
1167
- if (item == null ||
1168
- (typeof item === 'object' && Object.keys(item).length === 0)) {
1169
- // Blanket disconnect — remove every relationship of this type to
1170
- // a target the caller can READ. Like the `where` branch below, a
1171
- // policy-bound context only unlinks nodes it can see; pre-2.5.0
1172
- // the blanket form bound an anonymous `()` and also severed links
1173
- // to policy-hidden nodes. Without policies there is no predicate
1174
- // and the Cypher is unchanged.
1175
- const blanketTarget = `target_${fieldName}_${si}`;
1176
- const predicate = targetNodeDef
1177
- ? this.buildTargetPolicyPredicate(targetNodeDef, blanketTarget, params, policyContext, paramCounter)
1178
- : undefined;
1179
- const pattern = (0, utils_1.buildRelPattern)({
1180
- sourceVar: 'n',
1181
- relDef,
1182
- targetVar: predicate ? blanketTarget : '',
1183
- edgeVar: relVar,
1184
- });
1185
- lines.push(`OPTIONAL MATCH ${pattern}`);
1186
- if (predicate)
1187
- lines.push(`WHERE ${predicate}`);
1188
- lines.push(`DELETE ${relVar}`);
1189
- }
1190
- else {
1191
- // Specific disconnect with conditions
1192
- const disconnectSpec = item;
1193
- const whereSpec = disconnectSpec.where;
1194
- const targetVar = `target_${fieldName}_${si}`;
1195
- const pattern = (0, utils_1.buildRelPattern)({
1196
- sourceVar: 'n',
1197
- relDef,
1198
- targetVar,
1199
- edgeVar: relVar,
1200
- targetLabel: 'auto',
1201
- });
1202
- lines.push(`OPTIONAL MATCH ${pattern}`);
1203
- if (!targetNodeDef)
1204
- throw new errors_1.OGMError(`Cannot resolve the target type "${relDef.target}" of relationship "${fieldName}".`);
1205
- const disconnectConditions = [
1206
- this.compileTargetWhere(whereSpec, targetVar, targetNodeDef, params, policyContext, paramCounter),
1207
- this.buildTargetPolicyPredicate(targetNodeDef, targetVar, params, policyContext, paramCounter),
1208
- ].filter((c) => Boolean(c));
1209
- if (disconnectConditions.length > 0)
1210
- lines.push(`WHERE ${disconnectConditions.join(' AND ')}`);
1211
- lines.push(`DELETE ${relVar}`);
1212
- }
1213
- lines.push(`RETURN count(*) AS _${relVar}`);
1214
- lines.push('}');
1279
+ const abstract = abstractTarget(relDef.target, this.schema);
1280
+ // `null` / `{}` stays a blanket disconnect of the whole relationship.
1281
+ const memberEntries = abstract && !isBlanketSpec(spec)
1282
+ ? perMemberEntries(spec, abstract, fieldName)
1283
+ : null;
1284
+ if (memberEntries) {
1285
+ for (const [member, memberSpec] of memberEntries)
1286
+ lines.push(...this.buildRelDisconnects(memberSpec, this.narrowToMember(relDef, member), this.schema.nodes.get(member), `${fieldName}_${member}`, params, policyContext, paramCounter, scope));
1287
+ continue;
1215
1288
  }
1289
+ lines.push(...this.buildRelDisconnects(spec, relDef, (0, utils_1.resolveTargetDef)(relDef.target, this.schema) ?? undefined, fieldName, params, policyContext, paramCounter, scope));
1216
1290
  }
1217
1291
  return lines;
1218
1292
  }
1219
1293
  /**
1220
- * Dispatch update operations for a union-typed relationship.
1294
+ * A `disconnect` nested in a disconnect item unlinks the disconnected
1295
+ * node's OWN relationships (`disconnect: { posts: [{ where, disconnect:
1296
+ * { tags: [...] } }] }`), with the same policy predicates at its level.
1297
+ * Pre-2.4.8 it was ignored. `''`-free: no input, no lines.
1298
+ */
1299
+ buildNestedDisconnect(nested, relDef, targetNodeDef, targetVar, carryVars, params, policyContext, paramCounter, depth = 0) {
1300
+ if (nested == null)
1301
+ return [];
1302
+ if (abstractTarget(targetNodeDef.typeName, this.schema))
1303
+ throw new errors_1.OGMError(`A nested "disconnect" inside a disconnect on "${relDef.fieldName}" (abstract type "${targetNodeDef.typeName}") ` +
1304
+ `must be keyed by the implementing type, e.g. { ${relDef.fieldName}: { Member: [{ where, disconnect }] } }.`);
1305
+ const inner = nestedScope(carryVars, targetVar, depth);
1306
+ const lines = [];
1307
+ for (const nestedDisconnect of Array.isArray(nested) ? nested : [nested]) {
1308
+ if (!(0, validation_1.isPlainObject)(nestedDisconnect))
1309
+ throw new errors_1.OGMError(`A nested "disconnect" inside a disconnect on "${relDef.fieldName}" must be an object keyed by relationship.`);
1310
+ lines.push(...this.buildDisconnects(nestedDisconnect, targetNodeDef, params, policyContext, paramCounter, inner));
1311
+ }
1312
+ return lines;
1313
+ }
1314
+ /** One relationship's disconnect items; `tag` as in buildRelConnects. */
1315
+ buildRelDisconnects(spec, relDef, targetNodeDef, tag, params, policyContext, paramCounter, scope) {
1316
+ const lines = [];
1317
+ const { sourceVar, withClause, varPrefix } = scope;
1318
+ // Normalize spec to an array of disconnect items
1319
+ const specItems = Array.isArray(spec) ? spec : [spec];
1320
+ for (let si = 0; si < specItems.length; si++) {
1321
+ const item = specItems[si];
1322
+ const relVar = `${varPrefix}r_${tag}_${si}`;
1323
+ // Wrap in a CALL subquery: the OPTIONAL MATCH yields one row per
1324
+ // matched relationship, which would multiply every later clause.
1325
+ // `count(*)` collapses it back to exactly one row per `n`.
1326
+ lines.push(withClause);
1327
+ lines.push('CALL {');
1328
+ lines.push(withClause);
1329
+ if (item == null ||
1330
+ (typeof item === 'object' && Object.keys(item).length === 0)) {
1331
+ // Blanket disconnect — remove every relationship of this type to
1332
+ // a target the caller can READ. Like the `where` branch below, a
1333
+ // policy-bound context only unlinks nodes it can see; pre-2.4.4
1334
+ // the blanket form bound an anonymous `()` and also severed links
1335
+ // to policy-hidden nodes. Without policies there is no predicate
1336
+ // and the Cypher is unchanged.
1337
+ const blanketTarget = `${varPrefix}target_${tag}_${si}`;
1338
+ const predicate = targetNodeDef
1339
+ ? this.buildTargetPolicyPredicate(targetNodeDef, blanketTarget, params, policyContext, paramCounter)
1340
+ : undefined;
1341
+ const pattern = (0, utils_1.buildRelPattern)({
1342
+ sourceVar,
1343
+ relDef,
1344
+ targetVar: predicate ? blanketTarget : '',
1345
+ edgeVar: relVar,
1346
+ targetLabelRaw: this.blanketTargetLabel(relDef),
1347
+ });
1348
+ lines.push(`OPTIONAL MATCH ${pattern}`);
1349
+ if (predicate)
1350
+ lines.push(`WHERE ${predicate}`);
1351
+ lines.push(`DELETE ${relVar}`);
1352
+ }
1353
+ else {
1354
+ // Specific disconnect with conditions
1355
+ const disconnectSpec = item;
1356
+ const whereSpec = disconnectSpec.where;
1357
+ const targetVar = `${varPrefix}target_${tag}_${si}`;
1358
+ const pattern = (0, utils_1.buildRelPattern)({
1359
+ sourceVar,
1360
+ relDef,
1361
+ targetVar,
1362
+ edgeVar: relVar,
1363
+ targetLabel: 'auto',
1364
+ // An abstract target binds no label (its nodes carry their
1365
+ // members' labels); the member predicate below narrows it.
1366
+ schema: this.schema,
1367
+ });
1368
+ lines.push(`OPTIONAL MATCH ${pattern}`);
1369
+ if (!targetNodeDef)
1370
+ throw new errors_1.OGMError(`Cannot resolve the target type "${relDef.target}" of relationship "${relDef.fieldName}".`);
1371
+ const labelPredicate = abstractTarget(targetNodeDef.typeName, this.schema)
1372
+ ? this.targetMatch(targetNodeDef, targetVar).labelPredicate
1373
+ : null;
1374
+ const disconnectConditions = [
1375
+ labelPredicate,
1376
+ this.compileTargetWhere(whereSpec, targetVar, targetNodeDef, params, policyContext, paramCounter),
1377
+ this.buildTargetPolicyPredicate(targetNodeDef, targetVar, params, policyContext, paramCounter),
1378
+ ].filter((c) => Boolean(c));
1379
+ if (disconnectConditions.length > 0)
1380
+ lines.push(`WHERE ${disconnectConditions.join(' AND ')}`);
1381
+ lines.push(`DELETE ${relVar}`);
1382
+ lines.push(...this.buildNestedDisconnect(disconnectSpec.disconnect, relDef, targetNodeDef, targetVar, scope.carryVars, params, policyContext, paramCounter, scope.depth));
1383
+ }
1384
+ lines.push(`RETURN count(*) AS _${relVar}`);
1385
+ lines.push('}');
1386
+ }
1387
+ return lines;
1388
+ }
1389
+ narrowToMember(relDef, member) {
1390
+ const narrowed = { ...relDef, target: member };
1391
+ this.narrowedRelDefs.add(narrowed);
1392
+ return narrowed;
1393
+ }
1394
+ /** The label a blanket disconnect over `relDef` must keep (see above). */
1395
+ blanketTargetLabel(relDef) {
1396
+ if (!this.narrowedRelDefs.has(relDef))
1397
+ return undefined;
1398
+ return this.getCachedLabelString(this.schema.nodes.get(relDef.target));
1399
+ }
1400
+ /**
1401
+ * Dispatch update operations for a union- or interface-typed
1402
+ * relationship given per member (interfaces since v2.4.8).
1221
1403
  * Each member key in the input is recursed into buildUpdateRelationships
1222
1404
  * with a narrowed relDef pointing at the concrete member type.
1223
1405
  */
1224
1406
  dispatchUnionUpdateOps(key, memberEntries, relDef, nodeDef, sourceVar, prefix, params, depth, ancestorVars, policyContext, paramCounter) {
1225
- const unionMembers = this.schema.unions.get(relDef.target);
1226
1407
  const lines = [];
1227
- for (const [memberKey, memberValue] of Object.entries(memberEntries)) {
1228
- if (!unionMembers.includes(memberKey))
1229
- continue;
1230
- const memberNodeDef = this.schema.nodes.get(memberKey);
1231
- if (!memberNodeDef || memberValue == null)
1232
- continue;
1233
- const memberRelDef = {
1234
- ...relDef,
1235
- target: memberKey,
1236
- };
1408
+ for (const [memberKey, memberValue] of memberEntries) {
1409
+ const memberRelDef = this.narrowToMember(relDef, memberKey);
1237
1410
  const memberItems = Array.isArray(memberValue)
1238
1411
  ? memberValue
1239
1412
  : [memberValue];
@@ -1262,18 +1435,21 @@ class MutationCompiler {
1262
1435
  const relDef = nodeDef.relationships.get(key);
1263
1436
  if (!relDef || value == null)
1264
1437
  continue;
1265
- // Union targets use per-member keys — dispatch each member recursively
1266
- const isUnionTarget = !this.schema.nodes.has(relDef.target) &&
1267
- this.schema.unions?.has(relDef.target);
1268
- if (isUnionTarget) {
1269
- lines.push(...this.dispatchUnionUpdateOps(key, value, relDef, nodeDef, sourceVar, prefix, params, depth, ancestorVars, policyContext, paramCounter));
1438
+ // Union / interface targets given per member — dispatch each member
1439
+ // recursively. A flat interface input is handled below against the
1440
+ // interface (v2.4.8 — pre-2.4.8 it was skipped, writing nothing).
1441
+ const abstract = abstractTarget(relDef.target, this.schema);
1442
+ const memberEntries = abstract
1443
+ ? perMemberEntries(value, abstract, key)
1444
+ : null;
1445
+ if (memberEntries) {
1446
+ lines.push(...this.dispatchUnionUpdateOps(key, memberEntries, relDef, nodeDef, sourceVar, prefix, params, depth, ancestorVars, policyContext, paramCounter));
1270
1447
  continue;
1271
1448
  }
1272
- const targetNodeDef = this.schema.nodes.get(relDef.target);
1273
- if (!targetNodeDef)
1274
- continue;
1275
- const targetLabelStr = this.getCachedLabelString(targetNodeDef);
1449
+ const targetNodeDef = this.requireTargetDef(relDef);
1276
1450
  const relPrefix = `${prefix}_${key}`;
1451
+ // Past the dispatch, `abstract` is set only for a flat INTERFACE
1452
+ // input (a flat union input threw in perMemberEntries).
1277
1453
  // Value can be an array (e.g., chapters: [{ create: ... }, { update: ... }])
1278
1454
  // or an object (e.g., author: { connect: ... })
1279
1455
  const items = Array.isArray(value)
@@ -1306,13 +1482,17 @@ class MutationCompiler {
1306
1482
  for (let di = 0; di < disconnectItems.length; di++) {
1307
1483
  const disconnectSpec = disconnectItems[di];
1308
1484
  const whereSpec = disconnectSpec.where;
1485
+ // A nested `disconnect` needs the target bound, so an item that
1486
+ // carries one is never the anonymous blanket form.
1487
+ const nestedDisconnect = disconnectSpec.disconnect;
1309
1488
  const relVar = `r_disc_${key}_${i}_${di}`;
1310
1489
  // Same isolation as the top-level disconnect: keep the
1311
1490
  // OPTIONAL MATCH fan-out from multiplying later nested ops.
1312
1491
  lines.push(withClause);
1313
1492
  lines.push('CALL {');
1314
1493
  lines.push(withClause);
1315
- if (!whereSpec || Object.keys(whereSpec).length === 0) {
1494
+ if ((!whereSpec || Object.keys(whereSpec).length === 0) &&
1495
+ nestedDisconnect == null) {
1316
1496
  // Blanket — same read-policy gate as the top-level blanket
1317
1497
  // disconnect: only unlink targets the caller can read.
1318
1498
  const blanketTarget = `${sourceVar}_disc${varCounter}`;
@@ -1324,6 +1504,7 @@ class MutationCompiler {
1324
1504
  relDef,
1325
1505
  targetVar: predicate ? blanketTarget : '',
1326
1506
  edgeVar: relVar,
1507
+ targetLabelRaw: this.blanketTargetLabel(relDef),
1327
1508
  });
1328
1509
  lines.push(`OPTIONAL MATCH ${pattern}`);
1329
1510
  if (predicate)
@@ -1339,6 +1520,9 @@ class MutationCompiler {
1339
1520
  targetVar: discTarget,
1340
1521
  edgeVar: relVar,
1341
1522
  targetLabel: 'auto',
1523
+ // An interface target matches its implementers, which do
1524
+ // not carry the interface label (v2.4.8).
1525
+ schema: this.schema,
1342
1526
  });
1343
1527
  lines.push(`OPTIONAL MATCH ${pattern}`);
1344
1528
  // Pre-1.8.1 this called `buildNodeWhereConditions` with
@@ -1351,12 +1535,16 @@ class MutationCompiler {
1351
1535
  // path (`buildDisconnects`). Restores backwards-compat with
1352
1536
  // @neo4j/graphql-ogm's nested update.disconnect[].where shape.
1353
1537
  const nestedDiscConditions = [
1538
+ abstract
1539
+ ? this.targetMatch(targetNodeDef, discTarget).labelPredicate
1540
+ : null,
1354
1541
  this.compileTargetWhere(whereSpec, discTarget, targetNodeDef, params, policyContext, paramCounter),
1355
1542
  this.buildTargetPolicyPredicate(targetNodeDef, discTarget, params, policyContext, paramCounter),
1356
1543
  ].filter((c) => Boolean(c));
1357
1544
  if (nestedDiscConditions.length > 0)
1358
1545
  lines.push(`WHERE ${nestedDiscConditions.join(' AND ')}`);
1359
1546
  lines.push(`DELETE ${relVar}`);
1547
+ lines.push(...this.buildNestedDisconnect(nestedDisconnect, relDef, targetNodeDef, discTarget, allVars, params, policyContext, paramCounter));
1360
1548
  }
1361
1549
  lines.push(`RETURN count(*) AS _${relVar}`);
1362
1550
  lines.push('}');
@@ -1368,61 +1556,30 @@ class MutationCompiler {
1368
1556
  ? item.connect
1369
1557
  : [item.connect];
1370
1558
  for (let ci = 0; ci < connectItems.length; ci++) {
1371
- const connectSpec = connectItems[ci];
1372
- const whereSpec = connectSpec.where;
1373
- const edgeInput = connectSpec.edge;
1374
1559
  // Wrap in CALL subquery so a failed MATCH doesn't kill the outer pipeline
1375
- lines.push(withClause);
1376
- lines.push(`CALL {`);
1377
- lines.push(withClause);
1378
1560
  const connectVar = `${sourceVar}_conn${varCounter}`;
1379
1561
  varCounter++;
1380
- lines.push(`MATCH (${connectVar}:${targetLabelStr})`);
1381
- // Pre-1.8.1 this used `buildNodeWhereConditions` with
1382
- // `(whereSpec?.node ?? whereSpec ?? {})`, which broke on
1383
- // connection-shape inputs (NOT/AND/OR wrappers). Routing
1384
- // through `buildConnectionWhereConditions` makes nested
1385
- // connect handle the same shapes as nested disconnect and
1386
- // top-level paths. Empty/undefined `whereSpec` short-circuits
1387
- // to `[]` inside the helper, so the no-where case still works.
1388
- const nestedConnConditions = [
1389
- this.compileTargetWhere(whereSpec, connectVar, targetNodeDef, params, policyContext, paramCounter),
1390
- this.buildTargetPolicyPredicate(targetNodeDef, connectVar, params, policyContext, paramCounter),
1391
- ].filter((c) => Boolean(c));
1392
- if (nestedConnConditions.length > 0)
1393
- lines.push(`WHERE ${nestedConnConditions.join(' AND ')}`);
1394
- if (edgeInput && Object.keys(edgeInput).length > 0) {
1395
- // Use bare target var (no label) since connectVar is already bound by MATCH
1396
- const relVar = `r_conn_${key}_${ci}`;
1397
- (0, validation_1.assertSafeIdentifier)(relDef.type, 'relationship type');
1398
- const escapedRelType = (0, validation_1.escapeIdentifier)(relDef.type);
1399
- const mergePattern = relDef.direction === 'OUT'
1400
- ? `(${sourceVar})-[${relVar}:${escapedRelType}]->(${connectVar})`
1401
- : `(${sourceVar})<-[${relVar}:${escapedRelType}]-(${connectVar})`;
1402
- lines.push(`MERGE ${mergePattern}`);
1403
- const setItems = [];
1404
- for (const [prop, val] of Object.entries(edgeInput)) {
1405
- if (val === undefined)
1406
- continue;
1407
- (0, validation_1.assertSafePropertyName)(prop, 'edge property');
1408
- const edgePropDef = this.getEdgePropDef(relDef, prop);
1409
- const paramName = `${itemPrefix}_conn${ci}_edge_${prop}`;
1410
- setItems.push(`r_conn_${key}_${ci}.${(0, validation_1.escapeIdentifier)(prop)} = ${(0, write_coercion_1.wrapWriteExpr)(`$${paramName}`, val, edgePropDef)}`);
1411
- params[paramName] = (0, write_coercion_1.coerceWriteValue)(val, edgePropDef);
1412
- }
1413
- if (setItems.length > 0)
1414
- lines.push(`SET ${setItems.join(', ')}`);
1415
- }
1416
- else {
1417
- const relPattern = (0, utils_1.buildRelPattern)({
1562
+ lines.push(...this.buildConnectItem({
1563
+ connectSpec: connectItems[ci],
1564
+ relDef,
1565
+ targetNodeDef,
1566
+ sourceVar,
1567
+ withClause,
1568
+ connectVar,
1569
+ relVar: `r_conn_${key}_${ci}`,
1570
+ edgeParamPrefix: `${itemPrefix}_conn${ci}`,
1571
+ returnAlias: `_cn_${key}_${ci}`,
1572
+ params,
1573
+ policyContext,
1574
+ paramCounter,
1575
+ scope: {
1418
1576
  sourceVar,
1419
- relDef,
1420
- targetVar: connectVar,
1421
- });
1422
- lines.push(`MERGE ${relPattern}`);
1423
- }
1424
- lines.push(`RETURN count(*) AS _cn_${key}_${ci}`);
1425
- lines.push(`}`);
1577
+ carryVars: allVars,
1578
+ withClause,
1579
+ varPrefix: '',
1580
+ depth: 0,
1581
+ },
1582
+ }));
1426
1583
  }
1427
1584
  }
1428
1585
  // --- create ---
@@ -1435,15 +1592,23 @@ class MutationCompiler {
1435
1592
  : [item.create];
1436
1593
  for (let ci = 0; ci < createItems.length; ci++) {
1437
1594
  const createSpec = createItems[ci];
1438
- const nodeSpec = (createSpec.node ?? createSpec);
1595
+ let nodeSpec = (createSpec.node ?? createSpec);
1596
+ // Flat interface input: the create names its implementer.
1597
+ let createDef = targetNodeDef;
1598
+ if (abstract) {
1599
+ const [member, memberSpec] = unwrapAbstractCreateNode(nodeSpec, abstract, key);
1600
+ createDef = this.schema.nodes.get(member);
1601
+ nodeSpec = memberSpec;
1602
+ }
1603
+ const targetLabelStr = this.getCachedLabelString(createDef);
1439
1604
  const createVar = `${sourceVar}_cr${varCounter}`;
1440
1605
  varCounter++;
1441
- const { propString, propParams } = this.buildCreateProperties(nodeSpec, targetNodeDef, `${itemPrefix}_create${ci}`);
1606
+ const { propString, propParams } = this.buildCreateProperties(nodeSpec, createDef, `${itemPrefix}_create${ci}`);
1442
1607
  // Handle nested relationship creates within the created node
1443
- const nestedRelLines = this.buildCreateRelationships(nodeSpec, targetNodeDef, createVar, `${itemPrefix}_create${ci}`, params, allVars, policyContext, paramCounter);
1608
+ const nestedRelLines = this.buildCreateRelationships(nodeSpec, createDef, createVar, `${itemPrefix}_create${ci}`, params, allVars, policyContext, paramCounter);
1444
1609
  const propsClause = propString.length > 0
1445
1610
  ? ` { ${propString} }`
1446
- : ` { ${this.buildGeneratedIdClause(targetNodeDef)} }`;
1611
+ : ` { ${this.buildGeneratedIdClause(createDef)} }`;
1447
1612
  // `edge` properties on the new relationship — the same input
1448
1613
  // create() accepts. Pre-2.4.5 this path never read
1449
1614
  // `createSpec.edge`, so they were silently dropped.
@@ -1492,6 +1657,11 @@ class MutationCompiler {
1492
1657
  const nodeUpdate = (updateSpec.node ??
1493
1658
  (edgeUpdate ? {} : updateSpec));
1494
1659
  const updateWhere = item.where;
1660
+ // A flat interface update writes the interface's own fields; the
1661
+ // per-implementer `_on` form is not supported (v2.4.8).
1662
+ if (abstract && (0, validation_1.isPlainObject)(nodeUpdate) && '_on' in nodeUpdate)
1663
+ throw new errors_1.OGMError(`"_on" in a nested update of "${key}" is not supported. Key the update by implementing type instead ` +
1664
+ `(e.g. { ${key}: { ${abstract.members[0] ?? 'Member'}: [{ where, update }] } }).`);
1495
1665
  const updateVar = `${sourceVar}_u${varCounter}`;
1496
1666
  const relVar = `r_${key}_${i}`;
1497
1667
  varCounter++;
@@ -1542,6 +1712,7 @@ class MutationCompiler {
1542
1712
  targetVar: updateVar,
1543
1713
  edgeVar: relVar,
1544
1714
  targetLabel: 'auto',
1715
+ schema: this.schema,
1545
1716
  });
1546
1717
  lines.push(`MATCH ${relArrow}`);
1547
1718
  // WHERE on the target.
@@ -1725,6 +1896,102 @@ function connectionWhereToNodeWhere(spec) {
1725
1896
  }
1726
1897
  return parts.length === 1 ? parts[0] : { AND: parts };
1727
1898
  }
1899
+ /**
1900
+ * The concrete members of an interface / union relationship target, or
1901
+ * `null` for a node type. Implementer nodes carry their OWN labels, not
1902
+ * the interface's, so every write that has to find one matches by these
1903
+ * members' labels (v2.4.8).
1904
+ */
1905
+ function abstractTarget(target, schema) {
1906
+ if (schema.nodes.has(target))
1907
+ return null;
1908
+ const iface = schema.interfaces?.get(target);
1909
+ if (iface)
1910
+ return {
1911
+ kind: 'interface',
1912
+ name: target,
1913
+ members: iface.implementedBy.filter((m) => schema.nodes.has(m)),
1914
+ };
1915
+ const union = schema.unions?.get(target);
1916
+ if (union)
1917
+ return {
1918
+ kind: 'union',
1919
+ name: target,
1920
+ members: union.filter((m) => schema.nodes.has(m)),
1921
+ };
1922
+ return null;
1923
+ }
1924
+ /**
1925
+ * Classify the nested-write value of an abstract relationship (v2.4.8):
1926
+ *
1927
+ * - PER-MEMBER — keyed by member type (`{ Dog: { … } }`), the shape
1928
+ * codegen emits for union AND interface targets. Returns the defined
1929
+ * `[member, value]` entries.
1930
+ * - FLAT — the operation keys themselves (`{ connect, create, … }`, or an
1931
+ * array of them), the @neo4j/graphql interface shape. Returns `null`.
1932
+ * Only interfaces accept it: a union has no common fields to filter or
1933
+ * write, so a flat union input throws.
1934
+ *
1935
+ * Pre-2.4.8 a key that was not a member was skipped, so a flat or typo'd
1936
+ * input on an abstract relationship compiled to no write at all. Mixing
1937
+ * member keys with other keys now throws instead.
1938
+ */
1939
+ function perMemberEntries(value, target, fieldName) {
1940
+ if (Array.isArray(value) || !(0, validation_1.isPlainObject)(value)) {
1941
+ if (target.kind === 'union')
1942
+ throw new errors_1.OGMError(`Relationship "${fieldName}" targets the union "${target.name}": key its nested input by member type ` +
1943
+ `(${target.members.map((m) => `"${m}"`).join(', ')}).`);
1944
+ return null;
1945
+ }
1946
+ const keys = Object.keys(value).filter((k) => value[k] !== undefined);
1947
+ const memberKeys = keys.filter((k) => target.members.includes(k));
1948
+ if (memberKeys.length === 0 && target.kind === 'interface')
1949
+ return null;
1950
+ if (memberKeys.length !== keys.length) {
1951
+ const other = keys.filter((k) => !target.members.includes(k));
1952
+ throw new errors_1.OGMError(`Unknown key(s) ${other.map((k) => `"${k}"`).join(', ')} in the nested input of "${fieldName}" ` +
1953
+ `(${target.kind} "${target.name}"). ` +
1954
+ (target.kind === 'union'
1955
+ ? `Key it by member type: ${target.members.map((m) => `"${m}"`).join(', ')}.`
1956
+ : `Use either member keys (${target.members.map((m) => `"${m}"`).join(', ')}) or the operation keys, not both.`));
1957
+ }
1958
+ return memberKeys
1959
+ .filter((k) => value[k] !== null)
1960
+ .map((k) => [k, value[k]]);
1961
+ }
1962
+ /**
1963
+ * Unwrap a FLAT interface nested-create node spec — `{ Dog: { … } }`,
1964
+ * exactly one implementer key — into the implementer and its own input
1965
+ * (v2.4.8). Anything else throws: without a member key the compiler cannot
1966
+ * know which labels and properties the new node gets.
1967
+ */
1968
+ function unwrapAbstractCreateNode(nodeSpec, target, fieldName) {
1969
+ const keys = (0, validation_1.isPlainObject)(nodeSpec)
1970
+ ? Object.keys(nodeSpec).filter((k) => nodeSpec[k] !== undefined)
1971
+ : [];
1972
+ if (!(0, validation_1.isPlainObject)(nodeSpec) ||
1973
+ keys.length !== 1 ||
1974
+ !target.members.includes(keys[0]) ||
1975
+ !(0, validation_1.isPlainObject)(nodeSpec[keys[0]]))
1976
+ throw new errors_1.OGMError(`A nested create on "${fieldName}" (${target.kind} "${target.name}") must name exactly one implementing type, ` +
1977
+ `e.g. { node: { ${target.members[0] ?? 'Member'}: { … } } }. Allowed: ${target.members.map((m) => `"${m}"`).join(', ')}.`);
1978
+ return [keys[0], nodeSpec[keys[0]]];
1979
+ }
1980
+ /**
1981
+ * MERGE writes scalars only. Pre-2.4.8 a relationship field in an upsert's
1982
+ * `create` / `update` was skipped, so its nested connect / create never
1983
+ * ran and nothing said so.
1984
+ */
1985
+ function upsertRelationshipError(nodeDef, key, branch) {
1986
+ return new errors_1.OGMError(`upsert does not support relationship fields: "${key}" in "${branch}" (${nodeDef.typeName}). ` +
1987
+ `Use create() / update() for nested relationship operations.`);
1988
+ }
1989
+ /** `null` / `{}`: the whole-relationship (blanket) form, never per member. */
1990
+ function isBlanketSpec(spec) {
1991
+ if (spec == null)
1992
+ return true;
1993
+ return ((0, validation_1.isPlainObject)(spec) && Object.keys(spec).every((k) => spec[k] === undefined));
1994
+ }
1728
1995
  /** A nested delete item's own `delete` asks for a deeper cascade. */
1729
1996
  function hasNestedCascade(value) {
1730
1997
  if (value === undefined || value === null)