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.
- package/CHANGELOG.md +68 -0
- package/README.md +126 -1
- package/dist/cjs/compilers/fulltext.compiler.d.ts +17 -1
- package/dist/cjs/compilers/fulltext.compiler.d.ts.map +1 -1
- package/dist/cjs/compilers/fulltext.compiler.js +35 -3
- package/dist/cjs/compilers/fulltext.compiler.js.map +1 -1
- package/dist/cjs/compilers/mutation.compiler.d.ts +65 -2
- package/dist/cjs/compilers/mutation.compiler.d.ts.map +1 -1
- package/dist/cjs/compilers/mutation.compiler.js +249 -15
- package/dist/cjs/compilers/mutation.compiler.js.map +1 -1
- package/dist/cjs/compilers/selection.compiler.d.ts +65 -1
- package/dist/cjs/compilers/selection.compiler.d.ts.map +1 -1
- package/dist/cjs/compilers/selection.compiler.js +521 -25
- package/dist/cjs/compilers/selection.compiler.js.map +1 -1
- package/dist/cjs/compilers/where.compiler.d.ts +4 -0
- package/dist/cjs/compilers/where.compiler.d.ts.map +1 -1
- package/dist/cjs/compilers/where.compiler.js +23 -0
- package/dist/cjs/compilers/where.compiler.js.map +1 -1
- package/dist/cjs/interface-model.d.ts.map +1 -1
- package/dist/cjs/interface-model.js +8 -1
- package/dist/cjs/interface-model.js.map +1 -1
- package/dist/cjs/model.d.ts +33 -0
- package/dist/cjs/model.d.ts.map +1 -1
- package/dist/cjs/model.js +90 -11
- package/dist/cjs/model.js.map +1 -1
- package/dist/cjs/ogm.d.ts +14 -0
- package/dist/cjs/ogm.d.ts.map +1 -1
- package/dist/cjs/ogm.js +36 -2
- package/dist/cjs/ogm.js.map +1 -1
- package/dist/cjs/utils/strict-mode.d.ts +39 -0
- package/dist/cjs/utils/strict-mode.d.ts.map +1 -0
- package/dist/cjs/utils/strict-mode.js +59 -0
- package/dist/cjs/utils/strict-mode.js.map +1 -0
- package/dist/esm/compilers/fulltext.compiler.d.ts +17 -1
- package/dist/esm/compilers/fulltext.compiler.d.ts.map +1 -1
- package/dist/esm/compilers/fulltext.compiler.js +35 -3
- package/dist/esm/compilers/fulltext.compiler.js.map +1 -1
- package/dist/esm/compilers/mutation.compiler.d.ts +65 -2
- package/dist/esm/compilers/mutation.compiler.d.ts.map +1 -1
- package/dist/esm/compilers/mutation.compiler.js +249 -15
- package/dist/esm/compilers/mutation.compiler.js.map +1 -1
- package/dist/esm/compilers/selection.compiler.d.ts +65 -1
- package/dist/esm/compilers/selection.compiler.d.ts.map +1 -1
- package/dist/esm/compilers/selection.compiler.js +521 -25
- package/dist/esm/compilers/selection.compiler.js.map +1 -1
- package/dist/esm/compilers/where.compiler.d.ts +4 -0
- package/dist/esm/compilers/where.compiler.d.ts.map +1 -1
- package/dist/esm/compilers/where.compiler.js +23 -0
- package/dist/esm/compilers/where.compiler.js.map +1 -1
- package/dist/esm/interface-model.d.ts.map +1 -1
- package/dist/esm/interface-model.js +8 -1
- package/dist/esm/interface-model.js.map +1 -1
- package/dist/esm/model.d.ts +33 -0
- package/dist/esm/model.d.ts.map +1 -1
- package/dist/esm/model.js +91 -12
- package/dist/esm/model.js.map +1 -1
- package/dist/esm/ogm.d.ts +14 -0
- package/dist/esm/ogm.d.ts.map +1 -1
- package/dist/esm/ogm.js +36 -2
- package/dist/esm/ogm.js.map +1 -1
- package/dist/esm/utils/strict-mode.d.ts +39 -0
- package/dist/esm/utils/strict-mode.d.ts.map +1 -0
- package/dist/esm/utils/strict-mode.js +54 -0
- package/dist/esm/utils/strict-mode.js.map +1 -0
- package/package.json +2 -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.
|
|
@@ -532,8 +596,14 @@ export class MutationCompiler {
|
|
|
532
596
|
/**
|
|
533
597
|
* Generate MERGE (upsert) Cypher with ON CREATE SET / ON MATCH SET.
|
|
534
598
|
* Scalar properties only — nested relationship ops are not supported.
|
|
599
|
+
*
|
|
600
|
+
* v2.6.1 — `updateGuard` is the root type's `update` row filter on `n`
|
|
601
|
+
* (compiled by the caller with `preserveVars: ['__matched']`). When it
|
|
602
|
+
* is non-empty, an EXISTING node is updated only when it passes the
|
|
603
|
+
* guard, exactly like a direct `update`; one that fails it is left
|
|
604
|
+
* untouched and no row is returned. See `buildGuardedMerge`.
|
|
535
605
|
*/
|
|
536
|
-
compileMerge(where, create, update, nodeDef, labels) {
|
|
606
|
+
compileMerge(where, create, update, nodeDef, labels, updateGuard) {
|
|
537
607
|
const labelStr = this.getCachedLabelString(nodeDef);
|
|
538
608
|
const lines = [];
|
|
539
609
|
const params = {};
|
|
@@ -571,6 +641,19 @@ export class MutationCompiler {
|
|
|
571
641
|
if (mergeProps.length === 0)
|
|
572
642
|
throw new OGMError(`upsert requires at least one scalar property in "where" for MERGE key: ` +
|
|
573
643
|
`an @id or @unique field of ${nodeDef.typeName}.`);
|
|
644
|
+
// v2.6.0 — MERGE matches ALL its keys together, so `{ id, email }`
|
|
645
|
+
// misses a node that has the email under another id and creates a
|
|
646
|
+
// duplicate beside it (or fails on the unique constraint). Default:
|
|
647
|
+
// compiled as before, with one warning; strict: exactly one key.
|
|
648
|
+
if (mergeKeyNames.length > 1) {
|
|
649
|
+
const keys = mergeKeyNames.map((k) => `"${k}"`).join(', ');
|
|
650
|
+
if (this.strictMode.strict)
|
|
651
|
+
throw new OGMError(`upsert "where" must name exactly one @id / @unique field of ${nodeDef.typeName}; got ${keys}. ` +
|
|
652
|
+
`MERGE matches all of them together, so a node that has one but not the others is not found ` +
|
|
653
|
+
`and a duplicate is created. Pass a single unique key.`);
|
|
654
|
+
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 ` +
|
|
655
|
+
`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"');
|
|
656
|
+
}
|
|
574
657
|
lines.push(`MERGE (n:${labelStr} { ${mergeProps.join(', ')} })`);
|
|
575
658
|
// ON CREATE SET — all properties from create input (scalar only)
|
|
576
659
|
const createSets = [];
|
|
@@ -621,6 +704,11 @@ export class MutationCompiler {
|
|
|
621
704
|
continue;
|
|
622
705
|
createSets.push(`n.${escapeIdentifier(propDef.name)} = randomUUID()`);
|
|
623
706
|
}
|
|
707
|
+
// v2.6.0 — strict: runtime labels go on the node only when MERGE creates
|
|
708
|
+
// it (see `labelsOnMatchedNodes`).
|
|
709
|
+
const labelsOnCreate = this.strictMode.strict && labels && labels.length > 0;
|
|
710
|
+
if (labelsOnCreate)
|
|
711
|
+
createSets.push(`n:${labels.map((l) => assertSafeLabel(l)).join(':')}`);
|
|
624
712
|
if (createSets.length > 0)
|
|
625
713
|
lines.push(`ON CREATE SET ${createSets.join(', ')}`);
|
|
626
714
|
// ON MATCH SET — all properties from update input (scalar only)
|
|
@@ -641,12 +729,19 @@ export class MutationCompiler {
|
|
|
641
729
|
updateSets.push(`n.${escapeIdentifier(key)} = ${valueExpr}`);
|
|
642
730
|
params[paramName] = coerceWriteValue(value, propDef);
|
|
643
731
|
}
|
|
644
|
-
if (
|
|
732
|
+
if (updateGuard !== undefined && updateGuard.cypher !== '') {
|
|
733
|
+
const guarded = buildGuardedMerge(labelStr, mergeProps, updateGuard, updateSets, params);
|
|
734
|
+
lines.unshift(...guarded.before);
|
|
735
|
+
lines.push(...guarded.after);
|
|
736
|
+
}
|
|
737
|
+
else if (updateSets.length > 0)
|
|
645
738
|
lines.push(`ON MATCH SET ${updateSets.join(', ')}`);
|
|
646
|
-
// Extra labels
|
|
647
|
-
|
|
739
|
+
// Extra labels. v2.6.1 — with an update guard, the rows that reach this
|
|
740
|
+
// SET are the created node and an existing node that passed the guard.
|
|
741
|
+
if (labels && labels.length > 0 && !labelsOnCreate) {
|
|
648
742
|
const validatedLabels = labels.map((l) => assertSafeLabel(l)).join(':');
|
|
649
743
|
lines.push(`SET n:${validatedLabels}`);
|
|
744
|
+
this.labelsOnMatchedNodes(nodeDef, 'upsert');
|
|
650
745
|
}
|
|
651
746
|
lines.push('RETURN n');
|
|
652
747
|
return { cypher: lines.join('\n'), params };
|
|
@@ -692,7 +787,15 @@ export class MutationCompiler {
|
|
|
692
787
|
* Scalar properties only — no nested relationship operations.
|
|
693
788
|
* Returns count of created nodes.
|
|
694
789
|
*/
|
|
695
|
-
compileCreateMany(data, nodeDef, skipDuplicates, labels
|
|
790
|
+
compileCreateMany(data, nodeDef, skipDuplicates, labels,
|
|
791
|
+
/**
|
|
792
|
+
* v2.6.1 — the root type's `update` row filter on `n`. With
|
|
793
|
+
* `skipDuplicates` and default-mode `labels`, an EXISTING node the
|
|
794
|
+
* MERGE matched is relabelled only when it passes this guard (a label
|
|
795
|
+
* change is an update, as `setLabels` enforces); created nodes get the
|
|
796
|
+
* labels in `ON CREATE SET`.
|
|
797
|
+
*/
|
|
798
|
+
updateGuard) {
|
|
696
799
|
if (data.length === 0)
|
|
697
800
|
return { cypher: 'RETURN 0 AS count', params: {} };
|
|
698
801
|
const labelStr = this.getCachedLabelString(nodeDef);
|
|
@@ -765,6 +868,12 @@ export class MutationCompiler {
|
|
|
765
868
|
});
|
|
766
869
|
params.items = sanitizedItems;
|
|
767
870
|
lines.push('UNWIND $items AS item');
|
|
871
|
+
const guardedLabels = Boolean(skipDuplicates) &&
|
|
872
|
+
!this.strictMode.strict &&
|
|
873
|
+
labels !== undefined &&
|
|
874
|
+
labels.length > 0 &&
|
|
875
|
+
updateGuard !== undefined &&
|
|
876
|
+
updateGuard.cypher !== '';
|
|
768
877
|
if (skipDuplicates) {
|
|
769
878
|
// MERGE key: the unique/id fields EVERY item supplies — an item
|
|
770
879
|
// missing one would MERGE on null, which Neo4j rejects.
|
|
@@ -796,6 +905,13 @@ export class MutationCompiler {
|
|
|
796
905
|
onCreateSets.push(`n.${escapeIdentifier(propDef.name)} = ${wrapWriteExpr(`$${paramName}`, bound, propDef)}`);
|
|
797
906
|
params[paramName] = bound;
|
|
798
907
|
}
|
|
908
|
+
// v2.6.0 — strict: labels only on the nodes MERGE creates.
|
|
909
|
+
// v2.6.1 — also with an update guard: the matched nodes are
|
|
910
|
+
// relabelled below, only where the guard passes.
|
|
911
|
+
if ((this.strictMode.strict || guardedLabels) &&
|
|
912
|
+
labels &&
|
|
913
|
+
labels.length > 0)
|
|
914
|
+
onCreateSets.push(`n:${labels.map((l) => assertSafeLabel(l)).join(':')}`);
|
|
799
915
|
if (onCreateSets.length > 0)
|
|
800
916
|
lines.push(`ON CREATE SET ${onCreateSets.join(', ')}`);
|
|
801
917
|
}
|
|
@@ -815,9 +931,26 @@ export class MutationCompiler {
|
|
|
815
931
|
lines.push(`CREATE (n:${labelStr} { ${propParts.join(', ')} })`);
|
|
816
932
|
}
|
|
817
933
|
// Extra labels
|
|
818
|
-
if (
|
|
934
|
+
if (guardedLabels) {
|
|
935
|
+
// v2.6.1 — matched nodes are relabelled only where the update guard
|
|
936
|
+
// passes. Pre-2.6.1 `SET n:<labels>` ran on every node the MERGE
|
|
937
|
+
// returned, so an existing node the context could not update was
|
|
938
|
+
// relabelled. FOREACH keeps one row per item (invariant 2).
|
|
939
|
+
const validatedLabels = labels.map((l) => assertSafeLabel(l)).join(':');
|
|
940
|
+
mergeParams(params, updateGuard.params);
|
|
941
|
+
lines.push('WITH n');
|
|
942
|
+
if (updateGuard.preludes && updateGuard.preludes.length > 0)
|
|
943
|
+
lines.push(...updateGuard.preludes);
|
|
944
|
+
lines.push(`FOREACH (__l IN CASE WHEN ${updateGuard.cypher} THEN [1] ELSE [] END | SET n:${validatedLabels})`);
|
|
945
|
+
this.labelsOnMatchedNodes(nodeDef, 'createMany');
|
|
946
|
+
}
|
|
947
|
+
else if (labels &&
|
|
948
|
+
labels.length > 0 &&
|
|
949
|
+
!(skipDuplicates && this.strictMode.strict)) {
|
|
819
950
|
const validatedLabels = labels.map((l) => assertSafeLabel(l)).join(':');
|
|
820
951
|
lines.push(`SET n:${validatedLabels}`);
|
|
952
|
+
if (skipDuplicates)
|
|
953
|
+
this.labelsOnMatchedNodes(nodeDef, 'createMany');
|
|
821
954
|
}
|
|
822
955
|
lines.push('RETURN count(n) AS count');
|
|
823
956
|
return { cypher: lines.join('\n'), params };
|
|
@@ -967,6 +1100,7 @@ export class MutationCompiler {
|
|
|
967
1100
|
mergeParams(params, propParams);
|
|
968
1101
|
lines.push(`CREATE ${relPattern}`);
|
|
969
1102
|
// Set edge properties if present
|
|
1103
|
+
this.checkEdgeKeys(relDef, edgeInput, 'nested create');
|
|
970
1104
|
if (edgeInput && Object.keys(edgeInput).length > 0) {
|
|
971
1105
|
const relVar = `r_edge_${startCounter + counter - 1}`;
|
|
972
1106
|
// Re-match the relationship to set edge props
|
|
@@ -1105,6 +1239,11 @@ export class MutationCompiler {
|
|
|
1105
1239
|
scope,
|
|
1106
1240
|
}));
|
|
1107
1241
|
else {
|
|
1242
|
+
// Every item is a plain object here (fast-path eligibility).
|
|
1243
|
+
for (const item of spec) {
|
|
1244
|
+
this.checkLinkItemKeys(item, 'connect', relDef);
|
|
1245
|
+
this.checkEdgeKeys(relDef, item.edge, 'connect');
|
|
1246
|
+
}
|
|
1108
1247
|
const targetVar = `${varPrefix}target`;
|
|
1109
1248
|
const relVar = `${varPrefix}r`;
|
|
1110
1249
|
const targetLabelStr = this.getCachedLabelString(targetNodeDef);
|
|
@@ -1231,6 +1370,8 @@ export class MutationCompiler {
|
|
|
1231
1370
|
// compiling it would MATCH, and link, every target node.
|
|
1232
1371
|
if (!isPlainObject(connectSpec))
|
|
1233
1372
|
throw new OGMError(`A connect item on "${relDef.fieldName}" must be an object such as { where: { node: { … } } }; got ${connectSpec === null ? 'null' : typeof connectSpec}.`);
|
|
1373
|
+
this.checkLinkItemKeys(connectSpec, 'connect', relDef);
|
|
1374
|
+
this.checkEdgeKeys(relDef, connectSpec.edge, 'connect');
|
|
1234
1375
|
const lines = [];
|
|
1235
1376
|
const whereSpec = connectSpec.where;
|
|
1236
1377
|
const edgeInput = connectSpec.edge;
|
|
@@ -1372,21 +1513,25 @@ export class MutationCompiler {
|
|
|
1372
1513
|
const predicate = targetNodeDef
|
|
1373
1514
|
? this.buildTargetPolicyPredicate(targetNodeDef, blanketTarget, params, policyContext, paramCounter)
|
|
1374
1515
|
: undefined;
|
|
1516
|
+
const { labelRaw, labelPredicate } = this.blanketRestriction(relDef, targetNodeDef, blanketTarget);
|
|
1375
1517
|
const pattern = buildRelPattern({
|
|
1376
1518
|
sourceVar,
|
|
1377
1519
|
relDef,
|
|
1378
|
-
targetVar: predicate ? blanketTarget : '',
|
|
1520
|
+
targetVar: predicate || labelPredicate ? blanketTarget : '',
|
|
1379
1521
|
edgeVar: relVar,
|
|
1380
|
-
targetLabelRaw:
|
|
1522
|
+
targetLabelRaw: labelRaw,
|
|
1381
1523
|
});
|
|
1382
1524
|
lines.push(`OPTIONAL MATCH ${pattern}`);
|
|
1383
|
-
|
|
1384
|
-
|
|
1525
|
+
const blanketConditions = [labelPredicate, predicate].filter(Boolean);
|
|
1526
|
+
if (blanketConditions.length > 0)
|
|
1527
|
+
lines.push(`WHERE ${blanketConditions.join(' AND ')}`);
|
|
1385
1528
|
lines.push(`DELETE ${relVar}`);
|
|
1386
1529
|
}
|
|
1387
1530
|
else {
|
|
1388
1531
|
// Specific disconnect with conditions
|
|
1389
1532
|
const disconnectSpec = item;
|
|
1533
|
+
if (isPlainObject(disconnectSpec))
|
|
1534
|
+
this.checkLinkItemKeys(disconnectSpec, 'disconnect', relDef);
|
|
1390
1535
|
const whereSpec = disconnectSpec.where;
|
|
1391
1536
|
const targetVar = `${varPrefix}target_${tag}_${si}`;
|
|
1392
1537
|
const pattern = buildRelPattern({
|
|
@@ -1425,6 +1570,33 @@ export class MutationCompiler {
|
|
|
1425
1570
|
this.narrowedRelDefs.add(narrowed);
|
|
1426
1571
|
return narrowed;
|
|
1427
1572
|
}
|
|
1573
|
+
/**
|
|
1574
|
+
* v2.6.0 — how a blanket disconnect (`null` / `{}`) restricts its target.
|
|
1575
|
+
*
|
|
1576
|
+
* Default (v2.5.3): only a relationship narrowed to a union / interface
|
|
1577
|
+
* member keeps that member's label; otherwise the target is anonymous,
|
|
1578
|
+
* so with two fields on one relationship type and direction
|
|
1579
|
+
* (`pets: [Pet]`, `toys: [Toy]`, both `HAS`) a blanket disconnect of one
|
|
1580
|
+
* also unlinks the other's nodes (warned once at OGM construction).
|
|
1581
|
+
*
|
|
1582
|
+
* Strict: the field's own target type — its label, or for an abstract
|
|
1583
|
+
* target a predicate over its members' labels on `targetVar`.
|
|
1584
|
+
*/
|
|
1585
|
+
blanketRestriction(relDef, targetNodeDef, targetVar) {
|
|
1586
|
+
const narrowed = this.blanketTargetLabel(relDef);
|
|
1587
|
+
if (narrowed || !this.strictMode.strict || !targetNodeDef)
|
|
1588
|
+
return { labelRaw: narrowed, labelPredicate: null };
|
|
1589
|
+
if (abstractTarget(targetNodeDef.typeName, this.schema))
|
|
1590
|
+
return {
|
|
1591
|
+
labelRaw: undefined,
|
|
1592
|
+
labelPredicate: this.targetMatch(targetNodeDef, targetVar)
|
|
1593
|
+
.labelPredicate,
|
|
1594
|
+
};
|
|
1595
|
+
return {
|
|
1596
|
+
labelRaw: this.getCachedLabelString(targetNodeDef),
|
|
1597
|
+
labelPredicate: null,
|
|
1598
|
+
};
|
|
1599
|
+
}
|
|
1428
1600
|
/** The label a blanket disconnect over `relDef` must keep (see above). */
|
|
1429
1601
|
blanketTargetLabel(relDef) {
|
|
1430
1602
|
if (!this.narrowedRelDefs.has(relDef))
|
|
@@ -1515,6 +1687,8 @@ export class MutationCompiler {
|
|
|
1515
1687
|
: [item.disconnect];
|
|
1516
1688
|
for (let di = 0; di < disconnectItems.length; di++) {
|
|
1517
1689
|
const disconnectSpec = disconnectItems[di];
|
|
1690
|
+
if (isPlainObject(disconnectSpec))
|
|
1691
|
+
this.checkLinkItemKeys(disconnectSpec, 'disconnect', relDef);
|
|
1518
1692
|
const whereSpec = disconnectSpec.where;
|
|
1519
1693
|
// A nested `disconnect` needs the target bound, so an item that
|
|
1520
1694
|
// carries one is never the anonymous blanket form.
|
|
@@ -1531,18 +1705,20 @@ export class MutationCompiler {
|
|
|
1531
1705
|
// disconnect: only unlink targets the caller can read.
|
|
1532
1706
|
const blanketTarget = `${sourceVar}_disc${varCounter}`;
|
|
1533
1707
|
const predicate = this.buildTargetPolicyPredicate(targetNodeDef, blanketTarget, params, policyContext, paramCounter);
|
|
1534
|
-
|
|
1708
|
+
const { labelRaw, labelPredicate } = this.blanketRestriction(relDef, targetNodeDef, blanketTarget);
|
|
1709
|
+
if (predicate || labelPredicate)
|
|
1535
1710
|
varCounter++;
|
|
1536
1711
|
const pattern = buildRelPattern({
|
|
1537
1712
|
sourceVar,
|
|
1538
1713
|
relDef,
|
|
1539
|
-
targetVar: predicate ? blanketTarget : '',
|
|
1714
|
+
targetVar: predicate || labelPredicate ? blanketTarget : '',
|
|
1540
1715
|
edgeVar: relVar,
|
|
1541
|
-
targetLabelRaw:
|
|
1716
|
+
targetLabelRaw: labelRaw,
|
|
1542
1717
|
});
|
|
1543
1718
|
lines.push(`OPTIONAL MATCH ${pattern}`);
|
|
1544
|
-
|
|
1545
|
-
|
|
1719
|
+
const blanketConditions = [labelPredicate, predicate].filter(Boolean);
|
|
1720
|
+
if (blanketConditions.length > 0)
|
|
1721
|
+
lines.push(`WHERE ${blanketConditions.join(' AND ')}`);
|
|
1546
1722
|
lines.push(`DELETE ${relVar}`);
|
|
1547
1723
|
}
|
|
1548
1724
|
else {
|
|
@@ -1650,6 +1826,7 @@ export class MutationCompiler {
|
|
|
1650
1826
|
// create() accepts. Pre-2.4.5 this path never read
|
|
1651
1827
|
// `createSpec.edge`, so they were silently dropped.
|
|
1652
1828
|
const edgeInput = createSpec.edge;
|
|
1829
|
+
this.checkEdgeKeys(relDef, edgeInput, 'nested create');
|
|
1653
1830
|
const edgeEntries = Object.entries(edgeInput ?? {}).filter(([, v]) => v !== undefined);
|
|
1654
1831
|
const edgeVar = edgeEntries.length ? `${createVar}_e` : undefined;
|
|
1655
1832
|
const relPattern = buildRelPattern({
|
|
@@ -1720,6 +1897,7 @@ export class MutationCompiler {
|
|
|
1720
1897
|
// Pre-compute SET clauses for edge (relationship) properties
|
|
1721
1898
|
const edgeClauses = [];
|
|
1722
1899
|
const edgeParams = {};
|
|
1900
|
+
this.checkEdgeKeys(relDef, edgeUpdate, 'nested update');
|
|
1723
1901
|
if (edgeUpdate)
|
|
1724
1902
|
for (const [prop, val] of Object.entries(edgeUpdate)) {
|
|
1725
1903
|
if (val === undefined)
|
|
@@ -1867,6 +2045,21 @@ MutationCompiler.OPERATOR_SUFFIXES = [
|
|
|
1867
2045
|
* path to validate that every item shares the same shape; mismatched
|
|
1868
2046
|
* shapes silently drop keys from the compiled WHERE / SET.
|
|
1869
2047
|
*/
|
|
2048
|
+
/**
|
|
2049
|
+
* v2.6.0 — the keys a connect / disconnect item is compiled from. `overwrite`
|
|
2050
|
+
* is offered by the generated `ConnectFieldInput` and accepted as before
|
|
2051
|
+
* (MERGE + SET always overwrites the edge properties it is given).
|
|
2052
|
+
*/
|
|
2053
|
+
const CONNECT_ITEM_KEYS = new Set([
|
|
2054
|
+
'where',
|
|
2055
|
+
'edge',
|
|
2056
|
+
'overwrite',
|
|
2057
|
+
'connect',
|
|
2058
|
+
]);
|
|
2059
|
+
const DISCONNECT_ITEM_KEYS = new Set([
|
|
2060
|
+
'where',
|
|
2061
|
+
'disconnect',
|
|
2062
|
+
]);
|
|
1870
2063
|
/** Keys that make a nested-mutation `where` connection-shaped. */
|
|
1871
2064
|
const CONNECTION_WHERE_KEYS = new Set([
|
|
1872
2065
|
'node',
|
|
@@ -2039,6 +2232,47 @@ function upsertRelationshipError(nodeDef, key, branch) {
|
|
|
2039
2232
|
return new OGMError(`upsert does not support relationship fields: "${key}" in "${branch}" (${nodeDef.typeName}). ` +
|
|
2040
2233
|
`Use create() / update() for nested relationship operations.`);
|
|
2041
2234
|
}
|
|
2235
|
+
/**
|
|
2236
|
+
* v2.6.1 — the lines around an upsert MERGE that apply the root type's
|
|
2237
|
+
* `update` row filter to the node it MATCHES, as a direct `update` does:
|
|
2238
|
+
*
|
|
2239
|
+
* OPTIONAL MATCH (__existing:L { key }) ┐ before
|
|
2240
|
+
* WITH count(__existing) > 0 AS __matched ┘
|
|
2241
|
+
* MERGE (n:L { key })
|
|
2242
|
+
* ON CREATE SET …
|
|
2243
|
+
* WITH n, __matched ┐ after
|
|
2244
|
+
* [@cypher preludes of the guard] │
|
|
2245
|
+
* WHERE NOT __matched OR (<guard>) │
|
|
2246
|
+
* FOREACH (__u IN CASE WHEN __matched THEN [1] ELSE [] END | SET …)
|
|
2247
|
+
*
|
|
2248
|
+
* A created node passes untouched by the update sets; an existing node is
|
|
2249
|
+
* updated only when it passes the guard; one that fails it is neither
|
|
2250
|
+
* updated nor returned (and the MERGE created nothing). Pre-2.6.1 the
|
|
2251
|
+
* update went in `ON MATCH SET` with no row filter, so any context the
|
|
2252
|
+
* WITH CHECK admitted rewrote any existing node — even its owner field.
|
|
2253
|
+
*
|
|
2254
|
+
* `count()` keeps one row before the MERGE whether 0, 1 or several nodes
|
|
2255
|
+
* match (invariant 2); FOREACH needs no subquery after the write
|
|
2256
|
+
* (invariant 1). Under a race, a node created between the OPTIONAL MATCH
|
|
2257
|
+
* and the MERGE is matched with `__matched = false`: its properties are
|
|
2258
|
+
* not updated (default-mode runtime `labels`, SET after this, still are).
|
|
2259
|
+
*/
|
|
2260
|
+
function buildGuardedMerge(labelStr, mergeProps, guard, updateSets, params) {
|
|
2261
|
+
mergeParams(params, guard.params);
|
|
2262
|
+
const after = ['WITH n, __matched'];
|
|
2263
|
+
if (guard.preludes && guard.preludes.length > 0)
|
|
2264
|
+
after.push(...guard.preludes);
|
|
2265
|
+
after.push(`WHERE NOT __matched OR (${guard.cypher})`);
|
|
2266
|
+
if (updateSets.length > 0)
|
|
2267
|
+
after.push(`FOREACH (__u IN CASE WHEN __matched THEN [1] ELSE [] END | SET ${updateSets.join(', ')})`);
|
|
2268
|
+
return {
|
|
2269
|
+
before: [
|
|
2270
|
+
`OPTIONAL MATCH (__existing:${labelStr} { ${mergeProps.join(', ')} })`,
|
|
2271
|
+
'WITH count(__existing) > 0 AS __matched',
|
|
2272
|
+
],
|
|
2273
|
+
after,
|
|
2274
|
+
};
|
|
2275
|
+
}
|
|
2042
2276
|
/** `null` / `{}`: the whole-relationship (blanket) form, never per member. */
|
|
2043
2277
|
function isBlanketSpec(spec) {
|
|
2044
2278
|
if (spec == null)
|