Skip to content

Commit 399cba4

Browse files
feat: Allow cascading updates to be disabled. (#2897)
This adds a boolean attribute `cascadeUpdates` to `@Relationship`, selectively preventing the cascade of updates. This attribute will be `false` by default. It does not have an effect when storing new entities. It does not affect the deletion of relationships. It does not affect the storing of relationships with or without properties. Be aware that with a non-cascading update, you can bring your aggregate root in a state in which it is no longer in sync with the actual state of it in the graph. Thanks to @shanon84 for valuable input. Closes #2604
1 parent 2861e77 commit 399cba4

23 files changed

+1245
-10
lines changed

pom.xml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@
114114
<skipUnitTests>${skipTests}</skipUnitTests>
115115

116116
<springdata.commons>3.3.0-SNAPSHOT</springdata.commons>
117+
<junit-pioneer.version>2.2.0</junit-pioneer.version>
117118
</properties>
118119

119120
<dependencyManagement>
@@ -141,6 +142,11 @@
141142
<version>${junit-cc-testcontainer}</version>
142143
<scope>test</scope>
143144
</dependency>
145+
<dependency>
146+
<groupId>org.junit-pioneer</groupId>
147+
<artifactId>junit-pioneer</artifactId>
148+
<version>${junit-pioneer.version}</version>
149+
</dependency>
144150
<dependency>
145151
<groupId>io.github.classgraph</groupId>
146152
<artifactId>classgraph</artifactId>
@@ -441,6 +447,11 @@
441447
<artifactId>blockhound</artifactId>
442448
<scope>test</scope>
443449
</dependency>
450+
<dependency>
451+
<groupId>org.junit-pioneer</groupId>
452+
<artifactId>junit-pioneer</artifactId>
453+
<scope>test</scope>
454+
</dependency>
444455
</dependencies>
445456

446457
<repositories>

src/main/java/org/springframework/data/neo4j/core/Neo4jTemplate.java

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -890,8 +890,7 @@ private <T> T processNestedRelations(
890890
// here a map entry is not always anymore a dynamic association
891891
Object relatedObjectBeforeCallbacksApplied = relationshipContext.identifyAndExtractRelationshipTargetNode(relatedValueToStore);
892892
Neo4jPersistentEntity<?> targetEntity = neo4jMappingContext.getRequiredPersistentEntity(relatedObjectBeforeCallbacksApplied.getClass());
893-
894-
boolean isEntityNew = targetEntity.isNew(relatedObjectBeforeCallbacksApplied);
893+
boolean isNewEntity = targetEntity.isNew(relatedObjectBeforeCallbacksApplied);
895894

896895
Object newRelatedObject = stateMachine.hasProcessedValue(relatedObjectBeforeCallbacksApplied)
897896
? stateMachine.getProcessedAs(relatedObjectBeforeCallbacksApplied)
@@ -903,7 +902,13 @@ private <T> T processNestedRelations(
903902
if (stateMachine.hasProcessedValue(relatedValueToStore)) {
904903
relatedInternalId = stateMachine.getObjectId(relatedValueToStore);
905904
} else {
906-
savedEntity = saveRelatedNode(newRelatedObject, targetEntity, includeProperty, currentPropertyPath);
905+
if (isNewEntity || relationshipDescription.cascadeUpdates()) {
906+
savedEntity = saveRelatedNode(newRelatedObject, targetEntity, includeProperty, currentPropertyPath);
907+
} else {
908+
var targetPropertyAccessor = targetEntity.getPropertyAccessor(newRelatedObject);
909+
var requiredIdProperty = targetEntity.getRequiredIdProperty();
910+
savedEntity = loadRelatedNode(targetEntity, targetPropertyAccessor.getProperty(requiredIdProperty));
911+
}
907912
relatedInternalId = TemplateSupport.rendererCanUseElementIdIfPresent(renderer, targetEntity) ? savedEntity.elementId() : savedEntity.id();
908913
stateMachine.markEntityAsProcessed(relatedValueToStore, relatedInternalId);
909914
if (relatedValueToStore instanceof MappingSupport.RelationshipPropertiesWithEntityHolder) {
@@ -987,7 +992,7 @@ private <T> T processNestedRelations(
987992
}
988993

989994
if (processState != ProcessState.PROCESSED_ALL_VALUES) {
990-
processNestedRelations(targetEntity, targetPropertyAccessor, isEntityNew, stateMachine, includeProperty, currentPropertyPath);
995+
processNestedRelations(targetEntity, targetPropertyAccessor, isNewEntity, stateMachine, includeProperty, currentPropertyPath);
991996
}
992997

993998
Object potentiallyRecreatedNewRelatedObject = MappingSupport.getRelationshipOrRelationshipPropertiesObject(neo4jMappingContext,
@@ -1039,6 +1044,23 @@ private <T> T processNestedRelations(
10391044
return finalSubgraphRoot;
10401045
}
10411046

1047+
// The pendant to {@link #saveRelatedNode(Object, NodeDescription, PropertyFilter, PropertyFilter.RelaxedPropertyPath)}
1048+
// We can't do without a query, as we need to refresh the internal id
1049+
private Entity loadRelatedNode(NodeDescription<?> targetNodeDescription, Object relatedInternalId) {
1050+
1051+
var targetPersistentEntity = (Neo4jPersistentEntity<?>) targetNodeDescription;
1052+
var queryFragmentsAndParameters = QueryFragmentsAndParameters.forFindById(targetPersistentEntity, convertIdValues(targetPersistentEntity.getRequiredIdProperty(), relatedInternalId));
1053+
var nodeName = Constants.NAME_OF_TYPED_ROOT_NODE.apply(targetNodeDescription).getValue();
1054+
1055+
return neo4jClient
1056+
.query(() -> renderer.render(
1057+
cypherGenerator.prepareFindOf(targetNodeDescription, queryFragmentsAndParameters.getQueryFragments().getMatchOn(),
1058+
queryFragmentsAndParameters.getQueryFragments().getCondition()).returning(nodeName).build()))
1059+
.bindAll(queryFragmentsAndParameters.getParameters())
1060+
.fetchAs(Entity.class).mappedBy((t, r) -> r.get(nodeName).asNode())
1061+
.one().orElseThrow();
1062+
}
1063+
10421064
private void assignIdToRelationshipProperties(
10431065
NestedRelationshipContext relationshipContext,
10441066
Object relatedValueToStore,

src/main/java/org/springframework/data/neo4j/core/ReactiveNeo4jTemplate.java

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -969,14 +969,16 @@ private <T> Mono<T> processNestedRelations(Neo4jPersistentEntity<?> sourceEntity
969969
Flux<RelationshipHandler> relationshipCreation = Flux.fromIterable(relatedValuesToStore).concatMap(relatedValueToStore -> {
970970

971971
Object relatedObjectBeforeCallbacksApplied = relationshipContext.identifyAndExtractRelationshipTargetNode(relatedValueToStore);
972+
Neo4jPersistentEntity<?> targetEntity = neo4jMappingContext.getRequiredPersistentEntity(relatedObjectBeforeCallbacksApplied.getClass());
973+
boolean isNewEntity = targetEntity.isNew(relatedObjectBeforeCallbacksApplied);
974+
972975
return Mono.deferContextual(ctx ->
973976

974977
(stateMachine.hasProcessedValue(relatedObjectBeforeCallbacksApplied)
975978
? Mono.just(stateMachine.getProcessedAs(relatedObjectBeforeCallbacksApplied))
976979
: eventSupport.maybeCallBeforeBind(relatedObjectBeforeCallbacksApplied))
977980

978981
.flatMap(newRelatedObject -> {
979-
Neo4jPersistentEntity<?> targetEntity = neo4jMappingContext.getRequiredPersistentEntity(relatedObjectBeforeCallbacksApplied.getClass());
980982

981983
Mono<Tuple2<AtomicReference<Object>, AtomicReference<Entity>>> queryOrSave;
982984
if (stateMachine.hasProcessedValue(relatedValueToStore)) {
@@ -987,7 +989,16 @@ private <T> Mono<T> processNestedRelations(Neo4jPersistentEntity<?> sourceEntity
987989
}
988990
queryOrSave = Mono.just(Tuples.of(relatedInternalId, new AtomicReference<>()));
989991
} else {
990-
queryOrSave = saveRelatedNode(newRelatedObject, targetEntity, includeProperty, currentPropertyPath)
992+
Mono<Entity> savedEntity;
993+
if (isNewEntity || relationshipDescription.cascadeUpdates()) {
994+
savedEntity = saveRelatedNode(newRelatedObject, targetEntity, includeProperty, currentPropertyPath);
995+
} else {
996+
var targetPropertyAccessor = targetEntity.getPropertyAccessor(newRelatedObject);
997+
var requiredIdProperty = targetEntity.getRequiredIdProperty();
998+
savedEntity = loadRelatedNode(targetEntity, targetPropertyAccessor.getProperty(requiredIdProperty));
999+
}
1000+
1001+
queryOrSave = savedEntity
9911002
.map(entity -> Tuples.of(new AtomicReference<>((Object) (TemplateSupport.rendererCanUseElementIdIfPresent(renderer, targetEntity) ? entity.elementId() : entity.id())), new AtomicReference<>(entity)))
9921003
.doOnNext(t -> {
9931004
var relatedInternalId = t.getT1().get();
@@ -998,6 +1009,7 @@ private <T> Mono<T> processNestedRelations(Neo4jPersistentEntity<?> sourceEntity
9981009
}
9991010
});
10001011
}
1012+
10011013
return queryOrSave.flatMap(idAndEntity -> {
10021014
Object relatedInternalId = idAndEntity.getT1().get();
10031015
Entity savedEntity = idAndEntity.getT2().get();
@@ -1088,6 +1100,23 @@ private <T> Mono<T> processNestedRelations(Neo4jPersistentEntity<?> sourceEntity
10881100

10891101
}
10901102

1103+
// The pendant to {@link #saveRelatedNode(Object, Neo4jPersistentEntity, PropertyFilter, PropertyFilter.RelaxedPropertyPath)}
1104+
// We can't do without a query, as we need to refresh the internal id
1105+
private Mono<Entity> loadRelatedNode(NodeDescription<?> targetNodeDescription, Object relatedInternalId) {
1106+
1107+
var targetPersistentEntity = (Neo4jPersistentEntity<?>) targetNodeDescription;
1108+
var queryFragmentsAndParameters = QueryFragmentsAndParameters.forFindById(targetPersistentEntity, convertIdValues(targetPersistentEntity.getRequiredIdProperty(), relatedInternalId));
1109+
var nodeName = Constants.NAME_OF_TYPED_ROOT_NODE.apply(targetNodeDescription).getValue();
1110+
1111+
return neo4jClient
1112+
.query(() -> renderer.render(
1113+
cypherGenerator.prepareFindOf(targetNodeDescription, queryFragmentsAndParameters.getQueryFragments().getMatchOn(),
1114+
queryFragmentsAndParameters.getQueryFragments().getCondition()).returning(nodeName).build()))
1115+
.bindAll(queryFragmentsAndParameters.getParameters())
1116+
.fetchAs(Entity.class).mappedBy((t, r) -> r.get(nodeName).asNode())
1117+
.one();
1118+
}
1119+
10911120
private Mono<Entity> saveRelatedNode(Object relatedNode, Neo4jPersistentEntity<?> targetNodeDescription, PropertyFilter includeProperty, PropertyFilter.RelaxedPropertyPath currentPropertyPath) {
10921121

10931122
return determineDynamicLabels(relatedNode, targetNodeDescription)

src/main/java/org/springframework/data/neo4j/core/mapping/CypherGenerator.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -756,6 +756,12 @@ public Collection<Expression> createGenericReturnStatement() {
756756
return returnExpressions;
757757
}
758758

759+
760+
public StatementBuilder.OngoingReading prepareFindOf(NodeDescription<?> nodeDescription, @Nullable List<PatternElement> initialMatchOn, @Nullable Condition condition) {
761+
var rootNode = createRootNode(nodeDescription);
762+
return prepareMatchOfRootNode(rootNode, initialMatchOn).where(conditionOrNoCondition(condition));
763+
}
764+
759765
private MapProjection projectPropertiesAndRelationships(PropertyFilter.RelaxedPropertyPath parentPath, Neo4jPersistentEntity<?> nodeDescription, SymbolicName nodeName,
760766
Predicate<PropertyFilter.RelaxedPropertyPath> includedProperties, @Nullable RelationshipDescription relationshipDescription, List<RelationshipDescription> processedRelationships) {
761767

src/main/java/org/springframework/data/neo4j/core/mapping/DefaultNeo4jPersistentProperty.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,7 @@ protected Association<Neo4jPersistentProperty> createAssociation() {
169169

170170
DefaultRelationshipDescription relationshipDescription = new DefaultRelationshipDescription(this,
171171
obverseRelationshipDescription.orElse(null), type, dynamicAssociation, (NodeDescription<?>) getOwner(),
172-
this.getName(), obverseOwner, direction, relationshipPropertiesClass);
172+
this.getName(), obverseOwner, direction, relationshipPropertiesClass, relationship == null || relationship.cascadeUpdates());
173173

174174
// Update the previous found, if any, relationship with the newly created one as its counterpart.
175175
obverseRelationshipDescription

src/main/java/org/springframework/data/neo4j/core/mapping/DefaultRelationshipDescription.java

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,12 @@ final class DefaultRelationshipDescription extends Association<Neo4jPersistentPr
4444

4545
private RelationshipDescription relationshipObverse;
4646

47+
private final boolean cascadeUpdates;
48+
4749
DefaultRelationshipDescription(Neo4jPersistentProperty inverse, @Nullable RelationshipDescription relationshipObverse,
4850
String type, boolean dynamic, NodeDescription<?> source, String fieldName, NodeDescription<?> target,
49-
Relationship.Direction direction, @Nullable NodeDescription<?> relationshipProperties) {
51+
Relationship.Direction direction, @Nullable NodeDescription<?> relationshipProperties,
52+
boolean cascadeUpdates) {
5053

5154
// the immutable obverse association-wise is always null because we cannot determine them on both sides
5255
// if we consider to support bidirectional relationships.
@@ -60,6 +63,7 @@ final class DefaultRelationshipDescription extends Association<Neo4jPersistentPr
6063
this.target = target;
6164
this.direction = direction;
6265
this.relationshipPropertiesClass = relationshipProperties;
66+
this.cascadeUpdates = cascadeUpdates;
6367
}
6468

6569
@Override
@@ -117,6 +121,11 @@ public boolean hasRelationshipObverse() {
117121
return this.relationshipObverse != null;
118122
}
119123

124+
@Override
125+
public boolean cascadeUpdates() {
126+
return cascadeUpdates;
127+
}
128+
120129
@Override
121130
public String toString() {
122131
return "DefaultRelationshipDescription{" + "type='" + type + '\'' + ", source='" + source + '\'' + ", direction='"

src/main/java/org/springframework/data/neo4j/core/mapping/RelationshipDescription.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,4 +138,9 @@ default String generateRelatedNodesCollectionName(NodeDescription<?> mostAbstrac
138138
* @return true if a logically same relationship in the target entity exists, otherwise false.
139139
*/
140140
boolean hasRelationshipObverse();
141+
142+
/**
143+
* {@return true if updates should be cascaded along this relationship}
144+
*/
145+
boolean cascadeUpdates();
141146
}

src/main/java/org/springframework/data/neo4j/core/schema/Relationship.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,4 +82,13 @@ public Direction opposite() {
8282
* @return The direction of the relationship.
8383
*/
8484
Direction direction() default Direction.OUTGOING;
85+
86+
/**
87+
* Set this attribute to {@literal false} if you don't want updates on an aggregate root to be cascaded to related objects.
88+
* Be aware that in this case you are responsible to manually save the related objects and that you might end up with a local
89+
* object graph that is not in sync with the actual graph.
90+
*
91+
* @return whether updates to the owning instance should be cascaded to the related objects
92+
*/
93+
boolean cascadeUpdates() default true;
8594
}
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
/*
2+
* Copyright 2011-2024 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.springframework.data.neo4j.integration.cascading;
17+
18+
import static org.assertj.core.api.Assertions.assertThat;
19+
20+
import java.util.HashMap;
21+
import java.util.List;
22+
import java.util.Map;
23+
24+
import org.junit.jupiter.api.BeforeAll;
25+
import org.neo4j.driver.Driver;
26+
import org.neo4j.driver.types.TypeSystem;
27+
import org.springframework.beans.factory.annotation.Autowired;
28+
29+
abstract class AbstractCascadingTestBase {
30+
31+
@Autowired
32+
Driver driver;
33+
34+
static Map<Class<? extends Parent>, String> EXISTING_IDS = new HashMap<>();
35+
36+
@BeforeAll
37+
static void clean(@Autowired Driver driver) {
38+
39+
EXISTING_IDS.clear();
40+
driver.executableQuery("MATCH (n) DETACH DELETE n").execute();
41+
for (Class<? extends Parent> type : List.of(PUI.class, PUE.class, PVI.class, PVE.class)) {
42+
var label = type.getSimpleName();
43+
var id = "";
44+
var idReturn = "elementId(p) AS id";
45+
var version = "";
46+
if (ExternalId.class.isAssignableFrom(type)) {
47+
id = "SET p.id = randomUUID()";
48+
idReturn = "p.id AS id";
49+
}
50+
if (Versioned.class.isAssignableFrom(type)) {
51+
version = "SET p.version = 1";
52+
53+
}
54+
var newId = driver.executableQuery("""
55+
WITH 'ParentDB' AS name
56+
CREATE (p:%s {id: randomUUID(), name: name})
57+
%s
58+
%s
59+
CREATE (p) -[:HAS_SINGLE_CUI]-> (sCUI:CUI {name: name + '.singleCUI'})
60+
CREATE (p) -[:HAS_SINGLE_CUE]-> (sCUE:CUE {name: name + '.singleCUE', id: randomUUID()})
61+
CREATE (p) -[:HAS_MANY_CUI]-> (mCUI1:CUI {name: name + '.cUI1'})
62+
CREATE (p) -[:HAS_MANY_CUI]-> (mCUI2:CUI {name: name + '.cUI2'})
63+
CREATE (p) -[:HAS_SINGLE_CVI]-> (sCVI:CVI {name: name + '.singleCVI', version: 0})
64+
CREATE (p) -[:HAS_SINGLE_CVE]-> (sCVE:CVE {name: name + '.singleCVE', version: 0, id: randomUUID()})
65+
CREATE (p) -[:HAS_MANY_CVI]-> (mCVI1:CVI {name: name + '.cVI1', version: 0})
66+
CREATE (p) -[:HAS_MANY_CVI]-> (mCVI2:CVI {name: name + '.cVI2', version: 0})
67+
CREATE (sCUI) -[:HAS_NESTED_CHILDREN]-> (:CUI {name: name + '.singleCUI.c1'})
68+
CREATE (sCUI) -[:HAS_NESTED_CHILDREN]-> (:CUI {name: name + '.singleCUI.c2'})
69+
CREATE (mCUI1) -[:HAS_NESTED_CHILDREN]-> (:CUI {name: name + '.cUI1.cc1'})
70+
CREATE (mCUI1) -[:HAS_NESTED_CHILDREN]-> (:CUI {name: name + '.cUI1.cc2'})
71+
CREATE (mCUI2) -[:HAS_NESTED_CHILDREN]-> (:CUI {name: name + '.cUI2.cc1'})
72+
CREATE (mCUI2) -[:HAS_NESTED_CHILDREN]-> (:CUI {name: name + '.cUI2.cc2'})
73+
RETURN %s
74+
""".formatted(label, id, version, idReturn)).execute().records().get(0).get("id").asString();
75+
EXISTING_IDS.put(type, newId);
76+
}
77+
}
78+
79+
80+
<T extends Parent> void assertAllRelationshipsHaveBeenCreated(T instance) {
81+
82+
var type = instance.getClass();
83+
try (var session = driver.session()) {
84+
var result = session.run("""
85+
MATCH (p:%s WHERE %s)
86+
MATCH (p) -[:HAS_SINGLE_CUI]-> (sCUI)
87+
MATCH (p) -[:HAS_SINGLE_CUE]-> (sCUE)
88+
MATCH (p) -[:HAS_MANY_CUI]-> (mCUI)
89+
MATCH (p) -[:HAS_SINGLE_CVI]-> (sCVI {version: 0})
90+
MATCH (p) -[:HAS_SINGLE_CVE]-> (sCVE {version: 0})
91+
MATCH (p) -[:HAS_MANY_CVI]-> (mCVI {version: 0})
92+
MATCH (sCUI) -[:HAS_NESTED_CHILDREN]-> (nc1)
93+
MATCH (mCUI) -[:HAS_NESTED_CHILDREN]-> (nc2)
94+
RETURN p, sCUI, sCUE, collect(DISTINCT mCUI) AS mCUI, collect(DISTINCT nc1) AS nc1, collect(DISTINCT nc2) AS nc2,
95+
sCVI, sCVE, collect(DISTINCT mCVI) AS mCVI
96+
""".formatted(type.getSimpleName(), instance instanceof ExternalId ? "p.id = $id" : "elementId(p) = $id"), Map.of("id", instance.getId()))
97+
.list();
98+
99+
assertThat(result).hasSize(1).element(0)
100+
.satisfies(r -> {
101+
if (instance instanceof Versioned) {
102+
assertThat(r.get("p").asNode().get("version").asLong()).isZero();
103+
}
104+
if (instance instanceof ExternalId) {
105+
assertThat(r.get("p").asNode().get("id").asString()).isEqualTo(instance.getId());
106+
} else {
107+
assertThat(r.get("p").asNode().elementId()).isEqualTo(instance.getId());
108+
}
109+
assertThat(r.get("sCUI").hasType(TypeSystem.getDefault().NODE())).isTrue();
110+
assertThat(r.get("sCUE").hasType(TypeSystem.getDefault().NODE())).isTrue();
111+
assertThat(r.get("mCUI").asList(v -> v.asNode().get("name").asString()))
112+
.containsExactlyInAnyOrder("Parent.cUI1", "Parent.cUI2");
113+
assertThat(r.get("nc1").asList(v -> v.asNode().get("name").asString()))
114+
.containsExactlyInAnyOrder("Parent.singleCUI.cc1", "Parent.singleCUI.cc2");
115+
assertThat(r.get("nc2").asList(v -> v.asNode().get("name").asString()))
116+
.containsExactlyInAnyOrder("Parent.cUI1.cc1", "Parent.cUI1.cc2", "Parent.cUI2.cc1", "Parent.cUI2.cc2");
117+
assertThat(r.get("sCVI").asNode().get("version").asLong()).isZero();
118+
assertThat(r.get("sCVE").asNode().get("version").asLong()).isZero();
119+
assertThat(r.get("mCVI").asList(v -> {
120+
var node = v.asNode();
121+
return node.get("name").asString() + "." + node.get("version").asLong();
122+
}))
123+
.containsExactlyInAnyOrder("Parent.cVI1.0", "Parent.cVI2.0");
124+
});
125+
}
126+
}
127+
}

0 commit comments

Comments
 (0)