grafeo-ogm 2.5.4 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README.md +123 -0
  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 +39 -0
  8. package/dist/cjs/compilers/mutation.compiler.d.ts.map +1 -1
  9. package/dist/cjs/compilers/mutation.compiler.js +161 -11
  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 +7 -0
  23. package/dist/cjs/model.d.ts.map +1 -1
  24. package/dist/cjs/model.js +20 -4
  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 +39 -0
  39. package/dist/esm/compilers/mutation.compiler.d.ts.map +1 -1
  40. package/dist/esm/compilers/mutation.compiler.js +161 -11
  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 +7 -0
  54. package/dist/esm/model.d.ts.map +1 -1
  55. package/dist/esm/model.js +20 -4
  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 +1 -1
@@ -4,6 +4,7 @@ import { NO_ROOT_POLICY } from '../policy/types';
4
4
  import { buildRelPattern, getTargetLabelString, resolveTargetDef, } from '../schema/utils';
5
5
  import { assertSafeIdentifier, assertSafeLabel, assertSafePropertyName, escapeIdentifier, isPlainObject, mergeParams, } from '../utils/validation';
6
6
  import { coerceWriteValue, resolveDefaultWriteValue, wrapListItemExpr, wrapWhereParam, wrapWriteExpr, } from '../utils/write-coercion';
7
+ import { lenientStrictMode } from '../utils/strict-mode';
7
8
  import { WhereCompiler } from './where.compiler';
8
9
  /** The root node `n` of an update — the pre-2.4.8 names, unprefixed. */
9
10
  const TOP_SCOPE = {
@@ -60,6 +61,7 @@ export class MutationCompiler {
60
61
  this.narrowedRelDefs = new WeakSet();
61
62
  this.logger = options?.logger;
62
63
  this.policyWhereCompiler = options?.whereCompiler;
64
+ this.strictMode = options?.strictMode ?? lenientStrictMode();
63
65
  }
64
66
  /**
65
67
  * Resolve and compile the TARGET type's `read` policy predicate for a
@@ -278,6 +280,68 @@ export class MutationCompiler {
278
280
  throw new OGMError(`Cannot resolve the target type "${relDef.target}" of relationship "${relDef.fieldName}".`);
279
281
  return targetNodeDef;
280
282
  }
283
+ /**
284
+ * v2.6.0 — keys of a connect / disconnect item that the compiler does not
285
+ * read. Pre-2.6.0 (and still by default) they are ignored, so a typo such
286
+ * as `{ wehre: … }` compiles to an item with NO filter: a connect links
287
+ * every target node, a disconnect unlinks every related one. Default: one
288
+ * warning per OGM; strict: `OGMError`, like an unknown write field.
289
+ */
290
+ checkLinkItemKeys(item, kind, relDef) {
291
+ const allowed = kind === 'connect' ? CONNECT_ITEM_KEYS : DISCONNECT_ITEM_KEYS;
292
+ const unknown = Object.keys(item).filter((k) => item[k] !== undefined && !allowed.has(k));
293
+ if (unknown.length === 0)
294
+ return;
295
+ const list = unknown.map((k) => `"${k}"`).join(', ');
296
+ const allowedList = [...allowed].map((k) => `"${k}"`).join(', ');
297
+ if (this.strictMode.strict)
298
+ throw new OGMError(`Unknown key(s) ${list} in a ${kind} item on "${relDef.fieldName}". Allowed: ${allowedList}.`);
299
+ const unfiltered = item.where == null;
300
+ this.strictMode.warn(`${kind}-unknown-key`, `${relDef.fieldName}\u0000${unknown.join(',')}`, `Unknown key(s) ${list} in a ${kind} item on "${relDef.fieldName}" ` +
301
+ `${unknown.length > 1 ? 'are' : 'is'} ignored` +
302
+ (unfiltered
303
+ ? `; with no "where", the item ${kind === 'connect'
304
+ ? `connects EVERY "${relDef.target}" node`
305
+ : `unlinks EVERY related "${relDef.target}" node`}.`
306
+ : '.') +
307
+ ` Allowed: ${allowedList}.`, `throw an OGMError for unknown ${kind} item keys`);
308
+ }
309
+ /**
310
+ * v2.6.0 — `labels` on a MERGE path (upsert, createMany with
311
+ * skipDuplicates) are SET after the MERGE, so they are also added to an
312
+ * EXISTING node it matched. Default: unchanged, one warning; strict: the
313
+ * labels go in `ON CREATE SET`, onto newly created nodes only.
314
+ */
315
+ labelsOnMatchedNodes(nodeDef, op) {
316
+ const call = op === 'upsert' ? 'upsert' : 'createMany with skipDuplicates';
317
+ 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 ` +
318
+ `it matched is relabelled too.`, 'apply "labels" only to the nodes it creates');
319
+ }
320
+ /**
321
+ * v2.6.0 — `edge` keys that are not properties of the relationship's
322
+ * `@relationshipProperties` type. Pre-2.6.0 (and still by default) each
323
+ * is written to the relationship as a stray property. Default: one
324
+ * warning per OGM; strict: `OGMError` (`Unknown field …`).
325
+ */
326
+ checkEdgeKeys(relDef, edge, input) {
327
+ if (!isPlainObject(edge))
328
+ return;
329
+ const declared = relDef.properties
330
+ ? this.schema.relationshipProperties.get(relDef.properties)?.properties
331
+ : undefined;
332
+ for (const key of Object.keys(edge)) {
333
+ if (edge[key] === undefined || declared?.has(key))
334
+ continue;
335
+ const owner = relDef.properties
336
+ ? `the relationship properties type "${relDef.properties}"`
337
+ : `the relationship, which declares no @relationshipProperties`;
338
+ if (this.strictMode.strict)
339
+ throw new OGMError(`Unknown field "${key}" in the edge input (${input}) of "${relDef.fieldName}": it is not a property of ` +
340
+ `${owner}. Declare it in the schema, or fix the key.`);
341
+ 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}; ` +
342
+ `it is written to the relationship as a stray property.`, `throw an OGMError for unknown edge fields`);
343
+ }
344
+ }
281
345
  /**
282
346
  * Generate CREATE Cypher for one or more nodes.
283
347
  * Handles scalar properties, nested relationship creates, and connects.
@@ -571,6 +635,19 @@ export class MutationCompiler {
571
635
  if (mergeProps.length === 0)
572
636
  throw new OGMError(`upsert requires at least one scalar property in "where" for MERGE key: ` +
573
637
  `an @id or @unique field of ${nodeDef.typeName}.`);
638
+ // v2.6.0 — MERGE matches ALL its keys together, so `{ id, email }`
639
+ // misses a node that has the email under another id and creates a
640
+ // duplicate beside it (or fails on the unique constraint). Default:
641
+ // compiled as before, with one warning; strict: exactly one key.
642
+ if (mergeKeyNames.length > 1) {
643
+ const keys = mergeKeyNames.map((k) => `"${k}"`).join(', ');
644
+ if (this.strictMode.strict)
645
+ throw new OGMError(`upsert "where" must name exactly one @id / @unique field of ${nodeDef.typeName}; got ${keys}. ` +
646
+ `MERGE matches all of them together, so a node that has one but not the others is not found ` +
647
+ `and a duplicate is created. Pass a single unique key.`);
648
+ 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 ` +
649
+ `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"');
650
+ }
574
651
  lines.push(`MERGE (n:${labelStr} { ${mergeProps.join(', ')} })`);
575
652
  // ON CREATE SET — all properties from create input (scalar only)
576
653
  const createSets = [];
@@ -621,6 +698,11 @@ export class MutationCompiler {
621
698
  continue;
622
699
  createSets.push(`n.${escapeIdentifier(propDef.name)} = randomUUID()`);
623
700
  }
701
+ // v2.6.0 — strict: runtime labels go on the node only when MERGE creates
702
+ // it (see `labelsOnMatchedNodes`).
703
+ const labelsOnCreate = this.strictMode.strict && labels && labels.length > 0;
704
+ if (labelsOnCreate)
705
+ createSets.push(`n:${labels.map((l) => assertSafeLabel(l)).join(':')}`);
624
706
  if (createSets.length > 0)
625
707
  lines.push(`ON CREATE SET ${createSets.join(', ')}`);
626
708
  // ON MATCH SET — all properties from update input (scalar only)
@@ -644,9 +726,10 @@ export class MutationCompiler {
644
726
  if (updateSets.length > 0)
645
727
  lines.push(`ON MATCH SET ${updateSets.join(', ')}`);
646
728
  // Extra labels
647
- if (labels && labels.length > 0) {
729
+ if (labels && labels.length > 0 && !labelsOnCreate) {
648
730
  const validatedLabels = labels.map((l) => assertSafeLabel(l)).join(':');
649
731
  lines.push(`SET n:${validatedLabels}`);
732
+ this.labelsOnMatchedNodes(nodeDef, 'upsert');
650
733
  }
651
734
  lines.push('RETURN n');
652
735
  return { cypher: lines.join('\n'), params };
@@ -796,6 +879,9 @@ export class MutationCompiler {
796
879
  onCreateSets.push(`n.${escapeIdentifier(propDef.name)} = ${wrapWriteExpr(`$${paramName}`, bound, propDef)}`);
797
880
  params[paramName] = bound;
798
881
  }
882
+ // v2.6.0 — strict: labels only on the nodes MERGE creates.
883
+ if (this.strictMode.strict && labels && labels.length > 0)
884
+ onCreateSets.push(`n:${labels.map((l) => assertSafeLabel(l)).join(':')}`);
799
885
  if (onCreateSets.length > 0)
800
886
  lines.push(`ON CREATE SET ${onCreateSets.join(', ')}`);
801
887
  }
@@ -815,9 +901,13 @@ export class MutationCompiler {
815
901
  lines.push(`CREATE (n:${labelStr} { ${propParts.join(', ')} })`);
816
902
  }
817
903
  // Extra labels
818
- if (labels && labels.length > 0) {
904
+ if (labels &&
905
+ labels.length > 0 &&
906
+ !(skipDuplicates && this.strictMode.strict)) {
819
907
  const validatedLabels = labels.map((l) => assertSafeLabel(l)).join(':');
820
908
  lines.push(`SET n:${validatedLabels}`);
909
+ if (skipDuplicates)
910
+ this.labelsOnMatchedNodes(nodeDef, 'createMany');
821
911
  }
822
912
  lines.push('RETURN count(n) AS count');
823
913
  return { cypher: lines.join('\n'), params };
@@ -967,6 +1057,7 @@ export class MutationCompiler {
967
1057
  mergeParams(params, propParams);
968
1058
  lines.push(`CREATE ${relPattern}`);
969
1059
  // Set edge properties if present
1060
+ this.checkEdgeKeys(relDef, edgeInput, 'nested create');
970
1061
  if (edgeInput && Object.keys(edgeInput).length > 0) {
971
1062
  const relVar = `r_edge_${startCounter + counter - 1}`;
972
1063
  // Re-match the relationship to set edge props
@@ -1105,6 +1196,11 @@ export class MutationCompiler {
1105
1196
  scope,
1106
1197
  }));
1107
1198
  else {
1199
+ // Every item is a plain object here (fast-path eligibility).
1200
+ for (const item of spec) {
1201
+ this.checkLinkItemKeys(item, 'connect', relDef);
1202
+ this.checkEdgeKeys(relDef, item.edge, 'connect');
1203
+ }
1108
1204
  const targetVar = `${varPrefix}target`;
1109
1205
  const relVar = `${varPrefix}r`;
1110
1206
  const targetLabelStr = this.getCachedLabelString(targetNodeDef);
@@ -1231,6 +1327,8 @@ export class MutationCompiler {
1231
1327
  // compiling it would MATCH, and link, every target node.
1232
1328
  if (!isPlainObject(connectSpec))
1233
1329
  throw new OGMError(`A connect item on "${relDef.fieldName}" must be an object such as { where: { node: { … } } }; got ${connectSpec === null ? 'null' : typeof connectSpec}.`);
1330
+ this.checkLinkItemKeys(connectSpec, 'connect', relDef);
1331
+ this.checkEdgeKeys(relDef, connectSpec.edge, 'connect');
1234
1332
  const lines = [];
1235
1333
  const whereSpec = connectSpec.where;
1236
1334
  const edgeInput = connectSpec.edge;
@@ -1372,21 +1470,25 @@ export class MutationCompiler {
1372
1470
  const predicate = targetNodeDef
1373
1471
  ? this.buildTargetPolicyPredicate(targetNodeDef, blanketTarget, params, policyContext, paramCounter)
1374
1472
  : undefined;
1473
+ const { labelRaw, labelPredicate } = this.blanketRestriction(relDef, targetNodeDef, blanketTarget);
1375
1474
  const pattern = buildRelPattern({
1376
1475
  sourceVar,
1377
1476
  relDef,
1378
- targetVar: predicate ? blanketTarget : '',
1477
+ targetVar: predicate || labelPredicate ? blanketTarget : '',
1379
1478
  edgeVar: relVar,
1380
- targetLabelRaw: this.blanketTargetLabel(relDef),
1479
+ targetLabelRaw: labelRaw,
1381
1480
  });
1382
1481
  lines.push(`OPTIONAL MATCH ${pattern}`);
1383
- if (predicate)
1384
- lines.push(`WHERE ${predicate}`);
1482
+ const blanketConditions = [labelPredicate, predicate].filter(Boolean);
1483
+ if (blanketConditions.length > 0)
1484
+ lines.push(`WHERE ${blanketConditions.join(' AND ')}`);
1385
1485
  lines.push(`DELETE ${relVar}`);
1386
1486
  }
1387
1487
  else {
1388
1488
  // Specific disconnect with conditions
1389
1489
  const disconnectSpec = item;
1490
+ if (isPlainObject(disconnectSpec))
1491
+ this.checkLinkItemKeys(disconnectSpec, 'disconnect', relDef);
1390
1492
  const whereSpec = disconnectSpec.where;
1391
1493
  const targetVar = `${varPrefix}target_${tag}_${si}`;
1392
1494
  const pattern = buildRelPattern({
@@ -1425,6 +1527,33 @@ export class MutationCompiler {
1425
1527
  this.narrowedRelDefs.add(narrowed);
1426
1528
  return narrowed;
1427
1529
  }
1530
+ /**
1531
+ * v2.6.0 — how a blanket disconnect (`null` / `{}`) restricts its target.
1532
+ *
1533
+ * Default (v2.5.3): only a relationship narrowed to a union / interface
1534
+ * member keeps that member's label; otherwise the target is anonymous,
1535
+ * so with two fields on one relationship type and direction
1536
+ * (`pets: [Pet]`, `toys: [Toy]`, both `HAS`) a blanket disconnect of one
1537
+ * also unlinks the other's nodes (warned once at OGM construction).
1538
+ *
1539
+ * Strict: the field's own target type — its label, or for an abstract
1540
+ * target a predicate over its members' labels on `targetVar`.
1541
+ */
1542
+ blanketRestriction(relDef, targetNodeDef, targetVar) {
1543
+ const narrowed = this.blanketTargetLabel(relDef);
1544
+ if (narrowed || !this.strictMode.strict || !targetNodeDef)
1545
+ return { labelRaw: narrowed, labelPredicate: null };
1546
+ if (abstractTarget(targetNodeDef.typeName, this.schema))
1547
+ return {
1548
+ labelRaw: undefined,
1549
+ labelPredicate: this.targetMatch(targetNodeDef, targetVar)
1550
+ .labelPredicate,
1551
+ };
1552
+ return {
1553
+ labelRaw: this.getCachedLabelString(targetNodeDef),
1554
+ labelPredicate: null,
1555
+ };
1556
+ }
1428
1557
  /** The label a blanket disconnect over `relDef` must keep (see above). */
1429
1558
  blanketTargetLabel(relDef) {
1430
1559
  if (!this.narrowedRelDefs.has(relDef))
@@ -1515,6 +1644,8 @@ export class MutationCompiler {
1515
1644
  : [item.disconnect];
1516
1645
  for (let di = 0; di < disconnectItems.length; di++) {
1517
1646
  const disconnectSpec = disconnectItems[di];
1647
+ if (isPlainObject(disconnectSpec))
1648
+ this.checkLinkItemKeys(disconnectSpec, 'disconnect', relDef);
1518
1649
  const whereSpec = disconnectSpec.where;
1519
1650
  // A nested `disconnect` needs the target bound, so an item that
1520
1651
  // carries one is never the anonymous blanket form.
@@ -1531,18 +1662,20 @@ export class MutationCompiler {
1531
1662
  // disconnect: only unlink targets the caller can read.
1532
1663
  const blanketTarget = `${sourceVar}_disc${varCounter}`;
1533
1664
  const predicate = this.buildTargetPolicyPredicate(targetNodeDef, blanketTarget, params, policyContext, paramCounter);
1534
- if (predicate)
1665
+ const { labelRaw, labelPredicate } = this.blanketRestriction(relDef, targetNodeDef, blanketTarget);
1666
+ if (predicate || labelPredicate)
1535
1667
  varCounter++;
1536
1668
  const pattern = buildRelPattern({
1537
1669
  sourceVar,
1538
1670
  relDef,
1539
- targetVar: predicate ? blanketTarget : '',
1671
+ targetVar: predicate || labelPredicate ? blanketTarget : '',
1540
1672
  edgeVar: relVar,
1541
- targetLabelRaw: this.blanketTargetLabel(relDef),
1673
+ targetLabelRaw: labelRaw,
1542
1674
  });
1543
1675
  lines.push(`OPTIONAL MATCH ${pattern}`);
1544
- if (predicate)
1545
- lines.push(`WHERE ${predicate}`);
1676
+ const blanketConditions = [labelPredicate, predicate].filter(Boolean);
1677
+ if (blanketConditions.length > 0)
1678
+ lines.push(`WHERE ${blanketConditions.join(' AND ')}`);
1546
1679
  lines.push(`DELETE ${relVar}`);
1547
1680
  }
1548
1681
  else {
@@ -1650,6 +1783,7 @@ export class MutationCompiler {
1650
1783
  // create() accepts. Pre-2.4.5 this path never read
1651
1784
  // `createSpec.edge`, so they were silently dropped.
1652
1785
  const edgeInput = createSpec.edge;
1786
+ this.checkEdgeKeys(relDef, edgeInput, 'nested create');
1653
1787
  const edgeEntries = Object.entries(edgeInput ?? {}).filter(([, v]) => v !== undefined);
1654
1788
  const edgeVar = edgeEntries.length ? `${createVar}_e` : undefined;
1655
1789
  const relPattern = buildRelPattern({
@@ -1720,6 +1854,7 @@ export class MutationCompiler {
1720
1854
  // Pre-compute SET clauses for edge (relationship) properties
1721
1855
  const edgeClauses = [];
1722
1856
  const edgeParams = {};
1857
+ this.checkEdgeKeys(relDef, edgeUpdate, 'nested update');
1723
1858
  if (edgeUpdate)
1724
1859
  for (const [prop, val] of Object.entries(edgeUpdate)) {
1725
1860
  if (val === undefined)
@@ -1867,6 +2002,21 @@ MutationCompiler.OPERATOR_SUFFIXES = [
1867
2002
  * path to validate that every item shares the same shape; mismatched
1868
2003
  * shapes silently drop keys from the compiled WHERE / SET.
1869
2004
  */
2005
+ /**
2006
+ * v2.6.0 — the keys a connect / disconnect item is compiled from. `overwrite`
2007
+ * is offered by the generated `ConnectFieldInput` and accepted as before
2008
+ * (MERGE + SET always overwrites the edge properties it is given).
2009
+ */
2010
+ const CONNECT_ITEM_KEYS = new Set([
2011
+ 'where',
2012
+ 'edge',
2013
+ 'overwrite',
2014
+ 'connect',
2015
+ ]);
2016
+ const DISCONNECT_ITEM_KEYS = new Set([
2017
+ 'where',
2018
+ 'disconnect',
2019
+ ]);
1870
2020
  /** Keys that make a nested-mutation `where` connection-shaped. */
1871
2021
  const CONNECTION_WHERE_KEYS = new Set([
1872
2022
  'node',