grafeo-ogm 2.5.4 → 2.6.1

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 (65) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/README.md +126 -1
  3. package/dist/cjs/compilers/fulltext.compiler.d.ts +17 -1
  4. package/dist/cjs/compilers/fulltext.compiler.d.ts.map +1 -1
  5. package/dist/cjs/compilers/fulltext.compiler.js +35 -3
  6. package/dist/cjs/compilers/fulltext.compiler.js.map +1 -1
  7. package/dist/cjs/compilers/mutation.compiler.d.ts +65 -2
  8. package/dist/cjs/compilers/mutation.compiler.d.ts.map +1 -1
  9. package/dist/cjs/compilers/mutation.compiler.js +249 -15
  10. package/dist/cjs/compilers/mutation.compiler.js.map +1 -1
  11. package/dist/cjs/compilers/selection.compiler.d.ts +65 -1
  12. package/dist/cjs/compilers/selection.compiler.d.ts.map +1 -1
  13. package/dist/cjs/compilers/selection.compiler.js +521 -25
  14. package/dist/cjs/compilers/selection.compiler.js.map +1 -1
  15. package/dist/cjs/compilers/where.compiler.d.ts +4 -0
  16. package/dist/cjs/compilers/where.compiler.d.ts.map +1 -1
  17. package/dist/cjs/compilers/where.compiler.js +23 -0
  18. package/dist/cjs/compilers/where.compiler.js.map +1 -1
  19. package/dist/cjs/interface-model.d.ts.map +1 -1
  20. package/dist/cjs/interface-model.js +8 -1
  21. package/dist/cjs/interface-model.js.map +1 -1
  22. package/dist/cjs/model.d.ts +33 -0
  23. package/dist/cjs/model.d.ts.map +1 -1
  24. package/dist/cjs/model.js +90 -11
  25. package/dist/cjs/model.js.map +1 -1
  26. package/dist/cjs/ogm.d.ts +14 -0
  27. package/dist/cjs/ogm.d.ts.map +1 -1
  28. package/dist/cjs/ogm.js +36 -2
  29. package/dist/cjs/ogm.js.map +1 -1
  30. package/dist/cjs/utils/strict-mode.d.ts +39 -0
  31. package/dist/cjs/utils/strict-mode.d.ts.map +1 -0
  32. package/dist/cjs/utils/strict-mode.js +59 -0
  33. package/dist/cjs/utils/strict-mode.js.map +1 -0
  34. package/dist/esm/compilers/fulltext.compiler.d.ts +17 -1
  35. package/dist/esm/compilers/fulltext.compiler.d.ts.map +1 -1
  36. package/dist/esm/compilers/fulltext.compiler.js +35 -3
  37. package/dist/esm/compilers/fulltext.compiler.js.map +1 -1
  38. package/dist/esm/compilers/mutation.compiler.d.ts +65 -2
  39. package/dist/esm/compilers/mutation.compiler.d.ts.map +1 -1
  40. package/dist/esm/compilers/mutation.compiler.js +249 -15
  41. package/dist/esm/compilers/mutation.compiler.js.map +1 -1
  42. package/dist/esm/compilers/selection.compiler.d.ts +65 -1
  43. package/dist/esm/compilers/selection.compiler.d.ts.map +1 -1
  44. package/dist/esm/compilers/selection.compiler.js +521 -25
  45. package/dist/esm/compilers/selection.compiler.js.map +1 -1
  46. package/dist/esm/compilers/where.compiler.d.ts +4 -0
  47. package/dist/esm/compilers/where.compiler.d.ts.map +1 -1
  48. package/dist/esm/compilers/where.compiler.js +23 -0
  49. package/dist/esm/compilers/where.compiler.js.map +1 -1
  50. package/dist/esm/interface-model.d.ts.map +1 -1
  51. package/dist/esm/interface-model.js +8 -1
  52. package/dist/esm/interface-model.js.map +1 -1
  53. package/dist/esm/model.d.ts +33 -0
  54. package/dist/esm/model.d.ts.map +1 -1
  55. package/dist/esm/model.js +91 -12
  56. package/dist/esm/model.js.map +1 -1
  57. package/dist/esm/ogm.d.ts +14 -0
  58. package/dist/esm/ogm.d.ts.map +1 -1
  59. package/dist/esm/ogm.js +36 -2
  60. package/dist/esm/ogm.js.map +1 -1
  61. package/dist/esm/utils/strict-mode.d.ts +39 -0
  62. package/dist/esm/utils/strict-mode.d.ts.map +1 -0
  63. package/dist/esm/utils/strict-mode.js +54 -0
  64. package/dist/esm/utils/strict-mode.js.map +1 -0
  65. package/package.json +2 -1
@@ -11,6 +11,7 @@ const types_1 = require("../policy/types");
11
11
  const utils_1 = require("../schema/utils");
12
12
  const validation_1 = require("../utils/validation");
13
13
  const write_coercion_1 = require("../utils/write-coercion");
14
+ const strict_mode_1 = require("../utils/strict-mode");
14
15
  const where_compiler_1 = require("./where.compiler");
15
16
  /** The root node `n` of an update — the pre-2.4.8 names, unprefixed. */
16
17
  const TOP_SCOPE = {
@@ -67,6 +68,7 @@ class MutationCompiler {
67
68
  this.narrowedRelDefs = new WeakSet();
68
69
  this.logger = options?.logger;
69
70
  this.policyWhereCompiler = options?.whereCompiler;
71
+ this.strictMode = options?.strictMode ?? (0, strict_mode_1.lenientStrictMode)();
70
72
  }
71
73
  /**
72
74
  * Resolve and compile the TARGET type's `read` policy predicate for a
@@ -285,6 +287,68 @@ class MutationCompiler {
285
287
  throw new errors_1.OGMError(`Cannot resolve the target type "${relDef.target}" of relationship "${relDef.fieldName}".`);
286
288
  return targetNodeDef;
287
289
  }
290
+ /**
291
+ * v2.6.0 — keys of a connect / disconnect item that the compiler does not
292
+ * read. Pre-2.6.0 (and still by default) they are ignored, so a typo such
293
+ * as `{ wehre: … }` compiles to an item with NO filter: a connect links
294
+ * every target node, a disconnect unlinks every related one. Default: one
295
+ * warning per OGM; strict: `OGMError`, like an unknown write field.
296
+ */
297
+ checkLinkItemKeys(item, kind, relDef) {
298
+ const allowed = kind === 'connect' ? CONNECT_ITEM_KEYS : DISCONNECT_ITEM_KEYS;
299
+ const unknown = Object.keys(item).filter((k) => item[k] !== undefined && !allowed.has(k));
300
+ if (unknown.length === 0)
301
+ return;
302
+ const list = unknown.map((k) => `"${k}"`).join(', ');
303
+ const allowedList = [...allowed].map((k) => `"${k}"`).join(', ');
304
+ if (this.strictMode.strict)
305
+ throw new errors_1.OGMError(`Unknown key(s) ${list} in a ${kind} item on "${relDef.fieldName}". Allowed: ${allowedList}.`);
306
+ const unfiltered = item.where == null;
307
+ this.strictMode.warn(`${kind}-unknown-key`, `${relDef.fieldName}\u0000${unknown.join(',')}`, `Unknown key(s) ${list} in a ${kind} item on "${relDef.fieldName}" ` +
308
+ `${unknown.length > 1 ? 'are' : 'is'} ignored` +
309
+ (unfiltered
310
+ ? `; with no "where", the item ${kind === 'connect'
311
+ ? `connects EVERY "${relDef.target}" node`
312
+ : `unlinks EVERY related "${relDef.target}" node`}.`
313
+ : '.') +
314
+ ` Allowed: ${allowedList}.`, `throw an OGMError for unknown ${kind} item keys`);
315
+ }
316
+ /**
317
+ * v2.6.0 — `labels` on a MERGE path (upsert, createMany with
318
+ * skipDuplicates) are SET after the MERGE, so they are also added to an
319
+ * EXISTING node it matched. Default: unchanged, one warning; strict: the
320
+ * labels go in `ON CREATE SET`, onto newly created nodes only.
321
+ */
322
+ labelsOnMatchedNodes(nodeDef, op) {
323
+ const call = op === 'upsert' ? 'upsert' : 'createMany with skipDuplicates';
324
+ this.strictMode.warn('labels-on-matched-nodes', `${nodeDef.typeName}\u0000${op}`, `"labels" on ${call} (${nodeDef.typeName}) are added to every node the MERGE returns, so an existing node ` +
325
+ `it matched is relabelled too.`, 'apply "labels" only to the nodes it creates');
326
+ }
327
+ /**
328
+ * v2.6.0 — `edge` keys that are not properties of the relationship's
329
+ * `@relationshipProperties` type. Pre-2.6.0 (and still by default) each
330
+ * is written to the relationship as a stray property. Default: one
331
+ * warning per OGM; strict: `OGMError` (`Unknown field …`).
332
+ */
333
+ checkEdgeKeys(relDef, edge, input) {
334
+ if (!(0, validation_1.isPlainObject)(edge))
335
+ return;
336
+ const declared = relDef.properties
337
+ ? this.schema.relationshipProperties.get(relDef.properties)?.properties
338
+ : undefined;
339
+ for (const key of Object.keys(edge)) {
340
+ if (edge[key] === undefined || declared?.has(key))
341
+ continue;
342
+ const owner = relDef.properties
343
+ ? `the relationship properties type "${relDef.properties}"`
344
+ : `the relationship, which declares no @relationshipProperties`;
345
+ if (this.strictMode.strict)
346
+ throw new errors_1.OGMError(`Unknown field "${key}" in the edge input (${input}) of "${relDef.fieldName}": it is not a property of ` +
347
+ `${owner}. Declare it in the schema, or fix the key.`);
348
+ this.strictMode.warn('edge-unknown-key', `${relDef.fieldName}\u0000${key}`, `Unknown field "${key}" in the edge input of "${relDef.fieldName}" is not a property of ${owner}; ` +
349
+ `it is written to the relationship as a stray property.`, `throw an OGMError for unknown edge fields`);
350
+ }
351
+ }
288
352
  /**
289
353
  * Generate CREATE Cypher for one or more nodes.
290
354
  * Handles scalar properties, nested relationship creates, and connects.
@@ -539,8 +603,14 @@ class MutationCompiler {
539
603
  /**
540
604
  * Generate MERGE (upsert) Cypher with ON CREATE SET / ON MATCH SET.
541
605
  * Scalar properties only — nested relationship ops are not supported.
606
+ *
607
+ * v2.6.1 — `updateGuard` is the root type's `update` row filter on `n`
608
+ * (compiled by the caller with `preserveVars: ['__matched']`). When it
609
+ * is non-empty, an EXISTING node is updated only when it passes the
610
+ * guard, exactly like a direct `update`; one that fails it is left
611
+ * untouched and no row is returned. See `buildGuardedMerge`.
542
612
  */
543
- compileMerge(where, create, update, nodeDef, labels) {
613
+ compileMerge(where, create, update, nodeDef, labels, updateGuard) {
544
614
  const labelStr = this.getCachedLabelString(nodeDef);
545
615
  const lines = [];
546
616
  const params = {};
@@ -578,6 +648,19 @@ class MutationCompiler {
578
648
  if (mergeProps.length === 0)
579
649
  throw new errors_1.OGMError(`upsert requires at least one scalar property in "where" for MERGE key: ` +
580
650
  `an @id or @unique field of ${nodeDef.typeName}.`);
651
+ // v2.6.0 — MERGE matches ALL its keys together, so `{ id, email }`
652
+ // misses a node that has the email under another id and creates a
653
+ // duplicate beside it (or fails on the unique constraint). Default:
654
+ // compiled as before, with one warning; strict: exactly one key.
655
+ if (mergeKeyNames.length > 1) {
656
+ const keys = mergeKeyNames.map((k) => `"${k}"`).join(', ');
657
+ if (this.strictMode.strict)
658
+ throw new errors_1.OGMError(`upsert "where" must name exactly one @id / @unique field of ${nodeDef.typeName}; got ${keys}. ` +
659
+ `MERGE matches all of them together, so a node that has one but not the others is not found ` +
660
+ `and a duplicate is created. Pass a single unique key.`);
661
+ this.strictMode.warn('upsert-multiple-unique-keys', `${nodeDef.typeName}\u0000${mergeKeyNames.join(',')}`, `upsert on ${nodeDef.typeName} with several unique keys in "where" (${keys}) MERGEs on all of them ` +
662
+ `together: a node that has one but not the others is not matched and a duplicate is created.`, 'require exactly one unique key in an upsert "where"');
663
+ }
581
664
  lines.push(`MERGE (n:${labelStr} { ${mergeProps.join(', ')} })`);
582
665
  // ON CREATE SET — all properties from create input (scalar only)
583
666
  const createSets = [];
@@ -628,6 +711,11 @@ class MutationCompiler {
628
711
  continue;
629
712
  createSets.push(`n.${(0, validation_1.escapeIdentifier)(propDef.name)} = randomUUID()`);
630
713
  }
714
+ // v2.6.0 — strict: runtime labels go on the node only when MERGE creates
715
+ // it (see `labelsOnMatchedNodes`).
716
+ const labelsOnCreate = this.strictMode.strict && labels && labels.length > 0;
717
+ if (labelsOnCreate)
718
+ createSets.push(`n:${labels.map((l) => (0, validation_1.assertSafeLabel)(l)).join(':')}`);
631
719
  if (createSets.length > 0)
632
720
  lines.push(`ON CREATE SET ${createSets.join(', ')}`);
633
721
  // ON MATCH SET — all properties from update input (scalar only)
@@ -648,12 +736,19 @@ class MutationCompiler {
648
736
  updateSets.push(`n.${(0, validation_1.escapeIdentifier)(key)} = ${valueExpr}`);
649
737
  params[paramName] = (0, write_coercion_1.coerceWriteValue)(value, propDef);
650
738
  }
651
- if (updateSets.length > 0)
739
+ if (updateGuard !== undefined && updateGuard.cypher !== '') {
740
+ const guarded = buildGuardedMerge(labelStr, mergeProps, updateGuard, updateSets, params);
741
+ lines.unshift(...guarded.before);
742
+ lines.push(...guarded.after);
743
+ }
744
+ else if (updateSets.length > 0)
652
745
  lines.push(`ON MATCH SET ${updateSets.join(', ')}`);
653
- // Extra labels
654
- if (labels && labels.length > 0) {
746
+ // Extra labels. v2.6.1 — with an update guard, the rows that reach this
747
+ // SET are the created node and an existing node that passed the guard.
748
+ if (labels && labels.length > 0 && !labelsOnCreate) {
655
749
  const validatedLabels = labels.map((l) => (0, validation_1.assertSafeLabel)(l)).join(':');
656
750
  lines.push(`SET n:${validatedLabels}`);
751
+ this.labelsOnMatchedNodes(nodeDef, 'upsert');
657
752
  }
658
753
  lines.push('RETURN n');
659
754
  return { cypher: lines.join('\n'), params };
@@ -699,7 +794,15 @@ class MutationCompiler {
699
794
  * Scalar properties only — no nested relationship operations.
700
795
  * Returns count of created nodes.
701
796
  */
702
- compileCreateMany(data, nodeDef, skipDuplicates, labels) {
797
+ compileCreateMany(data, nodeDef, skipDuplicates, labels,
798
+ /**
799
+ * v2.6.1 — the root type's `update` row filter on `n`. With
800
+ * `skipDuplicates` and default-mode `labels`, an EXISTING node the
801
+ * MERGE matched is relabelled only when it passes this guard (a label
802
+ * change is an update, as `setLabels` enforces); created nodes get the
803
+ * labels in `ON CREATE SET`.
804
+ */
805
+ updateGuard) {
703
806
  if (data.length === 0)
704
807
  return { cypher: 'RETURN 0 AS count', params: {} };
705
808
  const labelStr = this.getCachedLabelString(nodeDef);
@@ -772,6 +875,12 @@ class MutationCompiler {
772
875
  });
773
876
  params.items = sanitizedItems;
774
877
  lines.push('UNWIND $items AS item');
878
+ const guardedLabels = Boolean(skipDuplicates) &&
879
+ !this.strictMode.strict &&
880
+ labels !== undefined &&
881
+ labels.length > 0 &&
882
+ updateGuard !== undefined &&
883
+ updateGuard.cypher !== '';
775
884
  if (skipDuplicates) {
776
885
  // MERGE key: the unique/id fields EVERY item supplies — an item
777
886
  // missing one would MERGE on null, which Neo4j rejects.
@@ -803,6 +912,13 @@ class MutationCompiler {
803
912
  onCreateSets.push(`n.${(0, validation_1.escapeIdentifier)(propDef.name)} = ${(0, write_coercion_1.wrapWriteExpr)(`$${paramName}`, bound, propDef)}`);
804
913
  params[paramName] = bound;
805
914
  }
915
+ // v2.6.0 — strict: labels only on the nodes MERGE creates.
916
+ // v2.6.1 — also with an update guard: the matched nodes are
917
+ // relabelled below, only where the guard passes.
918
+ if ((this.strictMode.strict || guardedLabels) &&
919
+ labels &&
920
+ labels.length > 0)
921
+ onCreateSets.push(`n:${labels.map((l) => (0, validation_1.assertSafeLabel)(l)).join(':')}`);
806
922
  if (onCreateSets.length > 0)
807
923
  lines.push(`ON CREATE SET ${onCreateSets.join(', ')}`);
808
924
  }
@@ -822,9 +938,26 @@ class MutationCompiler {
822
938
  lines.push(`CREATE (n:${labelStr} { ${propParts.join(', ')} })`);
823
939
  }
824
940
  // Extra labels
825
- if (labels && labels.length > 0) {
941
+ if (guardedLabels) {
942
+ // v2.6.1 — matched nodes are relabelled only where the update guard
943
+ // passes. Pre-2.6.1 `SET n:<labels>` ran on every node the MERGE
944
+ // returned, so an existing node the context could not update was
945
+ // relabelled. FOREACH keeps one row per item (invariant 2).
946
+ const validatedLabels = labels.map((l) => (0, validation_1.assertSafeLabel)(l)).join(':');
947
+ (0, validation_1.mergeParams)(params, updateGuard.params);
948
+ lines.push('WITH n');
949
+ if (updateGuard.preludes && updateGuard.preludes.length > 0)
950
+ lines.push(...updateGuard.preludes);
951
+ lines.push(`FOREACH (__l IN CASE WHEN ${updateGuard.cypher} THEN [1] ELSE [] END | SET n:${validatedLabels})`);
952
+ this.labelsOnMatchedNodes(nodeDef, 'createMany');
953
+ }
954
+ else if (labels &&
955
+ labels.length > 0 &&
956
+ !(skipDuplicates && this.strictMode.strict)) {
826
957
  const validatedLabels = labels.map((l) => (0, validation_1.assertSafeLabel)(l)).join(':');
827
958
  lines.push(`SET n:${validatedLabels}`);
959
+ if (skipDuplicates)
960
+ this.labelsOnMatchedNodes(nodeDef, 'createMany');
828
961
  }
829
962
  lines.push('RETURN count(n) AS count');
830
963
  return { cypher: lines.join('\n'), params };
@@ -974,6 +1107,7 @@ class MutationCompiler {
974
1107
  (0, validation_1.mergeParams)(params, propParams);
975
1108
  lines.push(`CREATE ${relPattern}`);
976
1109
  // Set edge properties if present
1110
+ this.checkEdgeKeys(relDef, edgeInput, 'nested create');
977
1111
  if (edgeInput && Object.keys(edgeInput).length > 0) {
978
1112
  const relVar = `r_edge_${startCounter + counter - 1}`;
979
1113
  // Re-match the relationship to set edge props
@@ -1112,6 +1246,11 @@ class MutationCompiler {
1112
1246
  scope,
1113
1247
  }));
1114
1248
  else {
1249
+ // Every item is a plain object here (fast-path eligibility).
1250
+ for (const item of spec) {
1251
+ this.checkLinkItemKeys(item, 'connect', relDef);
1252
+ this.checkEdgeKeys(relDef, item.edge, 'connect');
1253
+ }
1115
1254
  const targetVar = `${varPrefix}target`;
1116
1255
  const relVar = `${varPrefix}r`;
1117
1256
  const targetLabelStr = this.getCachedLabelString(targetNodeDef);
@@ -1238,6 +1377,8 @@ class MutationCompiler {
1238
1377
  // compiling it would MATCH, and link, every target node.
1239
1378
  if (!(0, validation_1.isPlainObject)(connectSpec))
1240
1379
  throw new errors_1.OGMError(`A connect item on "${relDef.fieldName}" must be an object such as { where: { node: { … } } }; got ${connectSpec === null ? 'null' : typeof connectSpec}.`);
1380
+ this.checkLinkItemKeys(connectSpec, 'connect', relDef);
1381
+ this.checkEdgeKeys(relDef, connectSpec.edge, 'connect');
1241
1382
  const lines = [];
1242
1383
  const whereSpec = connectSpec.where;
1243
1384
  const edgeInput = connectSpec.edge;
@@ -1379,21 +1520,25 @@ class MutationCompiler {
1379
1520
  const predicate = targetNodeDef
1380
1521
  ? this.buildTargetPolicyPredicate(targetNodeDef, blanketTarget, params, policyContext, paramCounter)
1381
1522
  : undefined;
1523
+ const { labelRaw, labelPredicate } = this.blanketRestriction(relDef, targetNodeDef, blanketTarget);
1382
1524
  const pattern = (0, utils_1.buildRelPattern)({
1383
1525
  sourceVar,
1384
1526
  relDef,
1385
- targetVar: predicate ? blanketTarget : '',
1527
+ targetVar: predicate || labelPredicate ? blanketTarget : '',
1386
1528
  edgeVar: relVar,
1387
- targetLabelRaw: this.blanketTargetLabel(relDef),
1529
+ targetLabelRaw: labelRaw,
1388
1530
  });
1389
1531
  lines.push(`OPTIONAL MATCH ${pattern}`);
1390
- if (predicate)
1391
- lines.push(`WHERE ${predicate}`);
1532
+ const blanketConditions = [labelPredicate, predicate].filter(Boolean);
1533
+ if (blanketConditions.length > 0)
1534
+ lines.push(`WHERE ${blanketConditions.join(' AND ')}`);
1392
1535
  lines.push(`DELETE ${relVar}`);
1393
1536
  }
1394
1537
  else {
1395
1538
  // Specific disconnect with conditions
1396
1539
  const disconnectSpec = item;
1540
+ if ((0, validation_1.isPlainObject)(disconnectSpec))
1541
+ this.checkLinkItemKeys(disconnectSpec, 'disconnect', relDef);
1397
1542
  const whereSpec = disconnectSpec.where;
1398
1543
  const targetVar = `${varPrefix}target_${tag}_${si}`;
1399
1544
  const pattern = (0, utils_1.buildRelPattern)({
@@ -1432,6 +1577,33 @@ class MutationCompiler {
1432
1577
  this.narrowedRelDefs.add(narrowed);
1433
1578
  return narrowed;
1434
1579
  }
1580
+ /**
1581
+ * v2.6.0 — how a blanket disconnect (`null` / `{}`) restricts its target.
1582
+ *
1583
+ * Default (v2.5.3): only a relationship narrowed to a union / interface
1584
+ * member keeps that member's label; otherwise the target is anonymous,
1585
+ * so with two fields on one relationship type and direction
1586
+ * (`pets: [Pet]`, `toys: [Toy]`, both `HAS`) a blanket disconnect of one
1587
+ * also unlinks the other's nodes (warned once at OGM construction).
1588
+ *
1589
+ * Strict: the field's own target type — its label, or for an abstract
1590
+ * target a predicate over its members' labels on `targetVar`.
1591
+ */
1592
+ blanketRestriction(relDef, targetNodeDef, targetVar) {
1593
+ const narrowed = this.blanketTargetLabel(relDef);
1594
+ if (narrowed || !this.strictMode.strict || !targetNodeDef)
1595
+ return { labelRaw: narrowed, labelPredicate: null };
1596
+ if (abstractTarget(targetNodeDef.typeName, this.schema))
1597
+ return {
1598
+ labelRaw: undefined,
1599
+ labelPredicate: this.targetMatch(targetNodeDef, targetVar)
1600
+ .labelPredicate,
1601
+ };
1602
+ return {
1603
+ labelRaw: this.getCachedLabelString(targetNodeDef),
1604
+ labelPredicate: null,
1605
+ };
1606
+ }
1435
1607
  /** The label a blanket disconnect over `relDef` must keep (see above). */
1436
1608
  blanketTargetLabel(relDef) {
1437
1609
  if (!this.narrowedRelDefs.has(relDef))
@@ -1522,6 +1694,8 @@ class MutationCompiler {
1522
1694
  : [item.disconnect];
1523
1695
  for (let di = 0; di < disconnectItems.length; di++) {
1524
1696
  const disconnectSpec = disconnectItems[di];
1697
+ if ((0, validation_1.isPlainObject)(disconnectSpec))
1698
+ this.checkLinkItemKeys(disconnectSpec, 'disconnect', relDef);
1525
1699
  const whereSpec = disconnectSpec.where;
1526
1700
  // A nested `disconnect` needs the target bound, so an item that
1527
1701
  // carries one is never the anonymous blanket form.
@@ -1538,18 +1712,20 @@ class MutationCompiler {
1538
1712
  // disconnect: only unlink targets the caller can read.
1539
1713
  const blanketTarget = `${sourceVar}_disc${varCounter}`;
1540
1714
  const predicate = this.buildTargetPolicyPredicate(targetNodeDef, blanketTarget, params, policyContext, paramCounter);
1541
- if (predicate)
1715
+ const { labelRaw, labelPredicate } = this.blanketRestriction(relDef, targetNodeDef, blanketTarget);
1716
+ if (predicate || labelPredicate)
1542
1717
  varCounter++;
1543
1718
  const pattern = (0, utils_1.buildRelPattern)({
1544
1719
  sourceVar,
1545
1720
  relDef,
1546
- targetVar: predicate ? blanketTarget : '',
1721
+ targetVar: predicate || labelPredicate ? blanketTarget : '',
1547
1722
  edgeVar: relVar,
1548
- targetLabelRaw: this.blanketTargetLabel(relDef),
1723
+ targetLabelRaw: labelRaw,
1549
1724
  });
1550
1725
  lines.push(`OPTIONAL MATCH ${pattern}`);
1551
- if (predicate)
1552
- lines.push(`WHERE ${predicate}`);
1726
+ const blanketConditions = [labelPredicate, predicate].filter(Boolean);
1727
+ if (blanketConditions.length > 0)
1728
+ lines.push(`WHERE ${blanketConditions.join(' AND ')}`);
1553
1729
  lines.push(`DELETE ${relVar}`);
1554
1730
  }
1555
1731
  else {
@@ -1657,6 +1833,7 @@ class MutationCompiler {
1657
1833
  // create() accepts. Pre-2.4.5 this path never read
1658
1834
  // `createSpec.edge`, so they were silently dropped.
1659
1835
  const edgeInput = createSpec.edge;
1836
+ this.checkEdgeKeys(relDef, edgeInput, 'nested create');
1660
1837
  const edgeEntries = Object.entries(edgeInput ?? {}).filter(([, v]) => v !== undefined);
1661
1838
  const edgeVar = edgeEntries.length ? `${createVar}_e` : undefined;
1662
1839
  const relPattern = (0, utils_1.buildRelPattern)({
@@ -1727,6 +1904,7 @@ class MutationCompiler {
1727
1904
  // Pre-compute SET clauses for edge (relationship) properties
1728
1905
  const edgeClauses = [];
1729
1906
  const edgeParams = {};
1907
+ this.checkEdgeKeys(relDef, edgeUpdate, 'nested update');
1730
1908
  if (edgeUpdate)
1731
1909
  for (const [prop, val] of Object.entries(edgeUpdate)) {
1732
1910
  if (val === undefined)
@@ -1875,6 +2053,21 @@ MutationCompiler.OPERATOR_SUFFIXES = [
1875
2053
  * path to validate that every item shares the same shape; mismatched
1876
2054
  * shapes silently drop keys from the compiled WHERE / SET.
1877
2055
  */
2056
+ /**
2057
+ * v2.6.0 — the keys a connect / disconnect item is compiled from. `overwrite`
2058
+ * is offered by the generated `ConnectFieldInput` and accepted as before
2059
+ * (MERGE + SET always overwrites the edge properties it is given).
2060
+ */
2061
+ const CONNECT_ITEM_KEYS = new Set([
2062
+ 'where',
2063
+ 'edge',
2064
+ 'overwrite',
2065
+ 'connect',
2066
+ ]);
2067
+ const DISCONNECT_ITEM_KEYS = new Set([
2068
+ 'where',
2069
+ 'disconnect',
2070
+ ]);
1878
2071
  /** Keys that make a nested-mutation `where` connection-shaped. */
1879
2072
  const CONNECTION_WHERE_KEYS = new Set([
1880
2073
  'node',
@@ -2047,6 +2240,47 @@ function upsertRelationshipError(nodeDef, key, branch) {
2047
2240
  return new errors_1.OGMError(`upsert does not support relationship fields: "${key}" in "${branch}" (${nodeDef.typeName}). ` +
2048
2241
  `Use create() / update() for nested relationship operations.`);
2049
2242
  }
2243
+ /**
2244
+ * v2.6.1 — the lines around an upsert MERGE that apply the root type's
2245
+ * `update` row filter to the node it MATCHES, as a direct `update` does:
2246
+ *
2247
+ * OPTIONAL MATCH (__existing:L { key }) ┐ before
2248
+ * WITH count(__existing) > 0 AS __matched ┘
2249
+ * MERGE (n:L { key })
2250
+ * ON CREATE SET …
2251
+ * WITH n, __matched ┐ after
2252
+ * [@cypher preludes of the guard] │
2253
+ * WHERE NOT __matched OR (<guard>) │
2254
+ * FOREACH (__u IN CASE WHEN __matched THEN [1] ELSE [] END | SET …)
2255
+ *
2256
+ * A created node passes untouched by the update sets; an existing node is
2257
+ * updated only when it passes the guard; one that fails it is neither
2258
+ * updated nor returned (and the MERGE created nothing). Pre-2.6.1 the
2259
+ * update went in `ON MATCH SET` with no row filter, so any context the
2260
+ * WITH CHECK admitted rewrote any existing node — even its owner field.
2261
+ *
2262
+ * `count()` keeps one row before the MERGE whether 0, 1 or several nodes
2263
+ * match (invariant 2); FOREACH needs no subquery after the write
2264
+ * (invariant 1). Under a race, a node created between the OPTIONAL MATCH
2265
+ * and the MERGE is matched with `__matched = false`: its properties are
2266
+ * not updated (default-mode runtime `labels`, SET after this, still are).
2267
+ */
2268
+ function buildGuardedMerge(labelStr, mergeProps, guard, updateSets, params) {
2269
+ (0, validation_1.mergeParams)(params, guard.params);
2270
+ const after = ['WITH n, __matched'];
2271
+ if (guard.preludes && guard.preludes.length > 0)
2272
+ after.push(...guard.preludes);
2273
+ after.push(`WHERE NOT __matched OR (${guard.cypher})`);
2274
+ if (updateSets.length > 0)
2275
+ after.push(`FOREACH (__u IN CASE WHEN __matched THEN [1] ELSE [] END | SET ${updateSets.join(', ')})`);
2276
+ return {
2277
+ before: [
2278
+ `OPTIONAL MATCH (__existing:${labelStr} { ${mergeProps.join(', ')} })`,
2279
+ 'WITH count(__existing) > 0 AS __matched',
2280
+ ],
2281
+ after,
2282
+ };
2283
+ }
2050
2284
  /** `null` / `{}`: the whole-relationship (blanket) form, never per member. */
2051
2285
  function isBlanketSpec(spec) {
2052
2286
  if (spec == null)