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
This commit is contained in:
Michael Simons
2024-05-08 16:46:37 +02:00
committed by GitHub
parent 2861e77133
commit 399cba4fae
23 changed files with 1245 additions and 10 deletions

11
pom.xml
View File

@@ -114,6 +114,7 @@
<skipUnitTests>${skipTests}</skipUnitTests>
<springdata.commons>3.3.0-SNAPSHOT</springdata.commons>
<junit-pioneer.version>2.2.0</junit-pioneer.version>
</properties>
<dependencyManagement>
@@ -141,6 +142,11 @@
<version>${junit-cc-testcontainer}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit-pioneer</groupId>
<artifactId>junit-pioneer</artifactId>
<version>${junit-pioneer.version}</version>
</dependency>
<dependency>
<groupId>io.github.classgraph</groupId>
<artifactId>classgraph</artifactId>
@@ -441,6 +447,11 @@
<artifactId>blockhound</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit-pioneer</groupId>
<artifactId>junit-pioneer</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<repositories>

View File

@@ -890,8 +890,7 @@ public final class Neo4jTemplate implements
// here a map entry is not always anymore a dynamic association
Object relatedObjectBeforeCallbacksApplied = relationshipContext.identifyAndExtractRelationshipTargetNode(relatedValueToStore);
Neo4jPersistentEntity<?> targetEntity = neo4jMappingContext.getRequiredPersistentEntity(relatedObjectBeforeCallbacksApplied.getClass());
boolean isEntityNew = targetEntity.isNew(relatedObjectBeforeCallbacksApplied);
boolean isNewEntity = targetEntity.isNew(relatedObjectBeforeCallbacksApplied);
Object newRelatedObject = stateMachine.hasProcessedValue(relatedObjectBeforeCallbacksApplied)
? stateMachine.getProcessedAs(relatedObjectBeforeCallbacksApplied)
@@ -903,7 +902,13 @@ public final class Neo4jTemplate implements
if (stateMachine.hasProcessedValue(relatedValueToStore)) {
relatedInternalId = stateMachine.getObjectId(relatedValueToStore);
} else {
savedEntity = saveRelatedNode(newRelatedObject, targetEntity, includeProperty, currentPropertyPath);
if (isNewEntity || relationshipDescription.cascadeUpdates()) {
savedEntity = saveRelatedNode(newRelatedObject, targetEntity, includeProperty, currentPropertyPath);
} else {
var targetPropertyAccessor = targetEntity.getPropertyAccessor(newRelatedObject);
var requiredIdProperty = targetEntity.getRequiredIdProperty();
savedEntity = loadRelatedNode(targetEntity, targetPropertyAccessor.getProperty(requiredIdProperty));
}
relatedInternalId = TemplateSupport.rendererCanUseElementIdIfPresent(renderer, targetEntity) ? savedEntity.elementId() : savedEntity.id();
stateMachine.markEntityAsProcessed(relatedValueToStore, relatedInternalId);
if (relatedValueToStore instanceof MappingSupport.RelationshipPropertiesWithEntityHolder) {
@@ -987,7 +992,7 @@ public final class Neo4jTemplate implements
}
if (processState != ProcessState.PROCESSED_ALL_VALUES) {
processNestedRelations(targetEntity, targetPropertyAccessor, isEntityNew, stateMachine, includeProperty, currentPropertyPath);
processNestedRelations(targetEntity, targetPropertyAccessor, isNewEntity, stateMachine, includeProperty, currentPropertyPath);
}
Object potentiallyRecreatedNewRelatedObject = MappingSupport.getRelationshipOrRelationshipPropertiesObject(neo4jMappingContext,
@@ -1039,6 +1044,23 @@ public final class Neo4jTemplate implements
return finalSubgraphRoot;
}
// The pendant to {@link #saveRelatedNode(Object, NodeDescription, PropertyFilter, PropertyFilter.RelaxedPropertyPath)}
// We can't do without a query, as we need to refresh the internal id
private Entity loadRelatedNode(NodeDescription<?> targetNodeDescription, Object relatedInternalId) {
var targetPersistentEntity = (Neo4jPersistentEntity<?>) targetNodeDescription;
var queryFragmentsAndParameters = QueryFragmentsAndParameters.forFindById(targetPersistentEntity, convertIdValues(targetPersistentEntity.getRequiredIdProperty(), relatedInternalId));
var nodeName = Constants.NAME_OF_TYPED_ROOT_NODE.apply(targetNodeDescription).getValue();
return neo4jClient
.query(() -> renderer.render(
cypherGenerator.prepareFindOf(targetNodeDescription, queryFragmentsAndParameters.getQueryFragments().getMatchOn(),
queryFragmentsAndParameters.getQueryFragments().getCondition()).returning(nodeName).build()))
.bindAll(queryFragmentsAndParameters.getParameters())
.fetchAs(Entity.class).mappedBy((t, r) -> r.get(nodeName).asNode())
.one().orElseThrow();
}
private void assignIdToRelationshipProperties(
NestedRelationshipContext relationshipContext,
Object relatedValueToStore,

View File

@@ -969,6 +969,9 @@ public final class ReactiveNeo4jTemplate implements
Flux<RelationshipHandler> relationshipCreation = Flux.fromIterable(relatedValuesToStore).concatMap(relatedValueToStore -> {
Object relatedObjectBeforeCallbacksApplied = relationshipContext.identifyAndExtractRelationshipTargetNode(relatedValueToStore);
Neo4jPersistentEntity<?> targetEntity = neo4jMappingContext.getRequiredPersistentEntity(relatedObjectBeforeCallbacksApplied.getClass());
boolean isNewEntity = targetEntity.isNew(relatedObjectBeforeCallbacksApplied);
return Mono.deferContextual(ctx ->
(stateMachine.hasProcessedValue(relatedObjectBeforeCallbacksApplied)
@@ -976,7 +979,6 @@ public final class ReactiveNeo4jTemplate implements
: eventSupport.maybeCallBeforeBind(relatedObjectBeforeCallbacksApplied))
.flatMap(newRelatedObject -> {
Neo4jPersistentEntity<?> targetEntity = neo4jMappingContext.getRequiredPersistentEntity(relatedObjectBeforeCallbacksApplied.getClass());
Mono<Tuple2<AtomicReference<Object>, AtomicReference<Entity>>> queryOrSave;
if (stateMachine.hasProcessedValue(relatedValueToStore)) {
@@ -987,7 +989,16 @@ public final class ReactiveNeo4jTemplate implements
}
queryOrSave = Mono.just(Tuples.of(relatedInternalId, new AtomicReference<>()));
} else {
queryOrSave = saveRelatedNode(newRelatedObject, targetEntity, includeProperty, currentPropertyPath)
Mono<Entity> savedEntity;
if (isNewEntity || relationshipDescription.cascadeUpdates()) {
savedEntity = saveRelatedNode(newRelatedObject, targetEntity, includeProperty, currentPropertyPath);
} else {
var targetPropertyAccessor = targetEntity.getPropertyAccessor(newRelatedObject);
var requiredIdProperty = targetEntity.getRequiredIdProperty();
savedEntity = loadRelatedNode(targetEntity, targetPropertyAccessor.getProperty(requiredIdProperty));
}
queryOrSave = savedEntity
.map(entity -> Tuples.of(new AtomicReference<>((Object) (TemplateSupport.rendererCanUseElementIdIfPresent(renderer, targetEntity) ? entity.elementId() : entity.id())), new AtomicReference<>(entity)))
.doOnNext(t -> {
var relatedInternalId = t.getT1().get();
@@ -998,6 +1009,7 @@ public final class ReactiveNeo4jTemplate implements
}
});
}
return queryOrSave.flatMap(idAndEntity -> {
Object relatedInternalId = idAndEntity.getT1().get();
Entity savedEntity = idAndEntity.getT2().get();
@@ -1088,6 +1100,23 @@ public final class ReactiveNeo4jTemplate implements
}
// The pendant to {@link #saveRelatedNode(Object, Neo4jPersistentEntity, PropertyFilter, PropertyFilter.RelaxedPropertyPath)}
// We can't do without a query, as we need to refresh the internal id
private Mono<Entity> loadRelatedNode(NodeDescription<?> targetNodeDescription, Object relatedInternalId) {
var targetPersistentEntity = (Neo4jPersistentEntity<?>) targetNodeDescription;
var queryFragmentsAndParameters = QueryFragmentsAndParameters.forFindById(targetPersistentEntity, convertIdValues(targetPersistentEntity.getRequiredIdProperty(), relatedInternalId));
var nodeName = Constants.NAME_OF_TYPED_ROOT_NODE.apply(targetNodeDescription).getValue();
return neo4jClient
.query(() -> renderer.render(
cypherGenerator.prepareFindOf(targetNodeDescription, queryFragmentsAndParameters.getQueryFragments().getMatchOn(),
queryFragmentsAndParameters.getQueryFragments().getCondition()).returning(nodeName).build()))
.bindAll(queryFragmentsAndParameters.getParameters())
.fetchAs(Entity.class).mappedBy((t, r) -> r.get(nodeName).asNode())
.one();
}
private Mono<Entity> saveRelatedNode(Object relatedNode, Neo4jPersistentEntity<?> targetNodeDescription, PropertyFilter includeProperty, PropertyFilter.RelaxedPropertyPath currentPropertyPath) {
return determineDynamicLabels(relatedNode, targetNodeDescription)

View File

@@ -756,6 +756,12 @@ public enum CypherGenerator {
return returnExpressions;
}
public StatementBuilder.OngoingReading prepareFindOf(NodeDescription<?> nodeDescription, @Nullable List<PatternElement> initialMatchOn, @Nullable Condition condition) {
var rootNode = createRootNode(nodeDescription);
return prepareMatchOfRootNode(rootNode, initialMatchOn).where(conditionOrNoCondition(condition));
}
private MapProjection projectPropertiesAndRelationships(PropertyFilter.RelaxedPropertyPath parentPath, Neo4jPersistentEntity<?> nodeDescription, SymbolicName nodeName,
Predicate<PropertyFilter.RelaxedPropertyPath> includedProperties, @Nullable RelationshipDescription relationshipDescription, List<RelationshipDescription> processedRelationships) {

View File

@@ -169,7 +169,7 @@ final class DefaultNeo4jPersistentProperty extends AnnotationBasedPersistentProp
DefaultRelationshipDescription relationshipDescription = new DefaultRelationshipDescription(this,
obverseRelationshipDescription.orElse(null), type, dynamicAssociation, (NodeDescription<?>) getOwner(),
this.getName(), obverseOwner, direction, relationshipPropertiesClass);
this.getName(), obverseOwner, direction, relationshipPropertiesClass, relationship == null || relationship.cascadeUpdates());
// Update the previous found, if any, relationship with the newly created one as its counterpart.
obverseRelationshipDescription

View File

@@ -44,9 +44,12 @@ final class DefaultRelationshipDescription extends Association<Neo4jPersistentPr
private RelationshipDescription relationshipObverse;
private final boolean cascadeUpdates;
DefaultRelationshipDescription(Neo4jPersistentProperty inverse, @Nullable RelationshipDescription relationshipObverse,
String type, boolean dynamic, NodeDescription<?> source, String fieldName, NodeDescription<?> target,
Relationship.Direction direction, @Nullable NodeDescription<?> relationshipProperties) {
Relationship.Direction direction, @Nullable NodeDescription<?> relationshipProperties,
boolean cascadeUpdates) {
// the immutable obverse association-wise is always null because we cannot determine them on both sides
// if we consider to support bidirectional relationships.
@@ -60,6 +63,7 @@ final class DefaultRelationshipDescription extends Association<Neo4jPersistentPr
this.target = target;
this.direction = direction;
this.relationshipPropertiesClass = relationshipProperties;
this.cascadeUpdates = cascadeUpdates;
}
@Override
@@ -117,6 +121,11 @@ final class DefaultRelationshipDescription extends Association<Neo4jPersistentPr
return this.relationshipObverse != null;
}
@Override
public boolean cascadeUpdates() {
return cascadeUpdates;
}
@Override
public String toString() {
return "DefaultRelationshipDescription{" + "type='" + type + '\'' + ", source='" + source + '\'' + ", direction='"

View File

@@ -138,4 +138,9 @@ public interface RelationshipDescription {
* @return true if a logically same relationship in the target entity exists, otherwise false.
*/
boolean hasRelationshipObverse();
/**
* {@return true if updates should be cascaded along this relationship}
*/
boolean cascadeUpdates();
}

View File

@@ -82,4 +82,13 @@ public @interface Relationship {
* @return The direction of the relationship.
*/
Direction direction() default Direction.OUTGOING;
/**
* Set this attribute to {@literal false} if you don't want updates on an aggregate root to be cascaded to related objects.
* Be aware that in this case you are responsible to manually save the related objects and that you might end up with a local
* object graph that is not in sync with the actual graph.
*
* @return whether updates to the owning instance should be cascaded to the related objects
*/
boolean cascadeUpdates() default true;
}

View File

@@ -0,0 +1,127 @@
/*
* Copyright 2011-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.data.neo4j.integration.cascading;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.BeforeAll;
import org.neo4j.driver.Driver;
import org.neo4j.driver.types.TypeSystem;
import org.springframework.beans.factory.annotation.Autowired;
abstract class AbstractCascadingTestBase {
@Autowired
Driver driver;
static Map<Class<? extends Parent>, String> EXISTING_IDS = new HashMap<>();
@BeforeAll
static void clean(@Autowired Driver driver) {
EXISTING_IDS.clear();
driver.executableQuery("MATCH (n) DETACH DELETE n").execute();
for (Class<? extends Parent> type : List.of(PUI.class, PUE.class, PVI.class, PVE.class)) {
var label = type.getSimpleName();
var id = "";
var idReturn = "elementId(p) AS id";
var version = "";
if (ExternalId.class.isAssignableFrom(type)) {
id = "SET p.id = randomUUID()";
idReturn = "p.id AS id";
}
if (Versioned.class.isAssignableFrom(type)) {
version = "SET p.version = 1";
}
var newId = driver.executableQuery("""
WITH 'ParentDB' AS name
CREATE (p:%s {id: randomUUID(), name: name})
%s
%s
CREATE (p) -[:HAS_SINGLE_CUI]-> (sCUI:CUI {name: name + '.singleCUI'})
CREATE (p) -[:HAS_SINGLE_CUE]-> (sCUE:CUE {name: name + '.singleCUE', id: randomUUID()})
CREATE (p) -[:HAS_MANY_CUI]-> (mCUI1:CUI {name: name + '.cUI1'})
CREATE (p) -[:HAS_MANY_CUI]-> (mCUI2:CUI {name: name + '.cUI2'})
CREATE (p) -[:HAS_SINGLE_CVI]-> (sCVI:CVI {name: name + '.singleCVI', version: 0})
CREATE (p) -[:HAS_SINGLE_CVE]-> (sCVE:CVE {name: name + '.singleCVE', version: 0, id: randomUUID()})
CREATE (p) -[:HAS_MANY_CVI]-> (mCVI1:CVI {name: name + '.cVI1', version: 0})
CREATE (p) -[:HAS_MANY_CVI]-> (mCVI2:CVI {name: name + '.cVI2', version: 0})
CREATE (sCUI) -[:HAS_NESTED_CHILDREN]-> (:CUI {name: name + '.singleCUI.c1'})
CREATE (sCUI) -[:HAS_NESTED_CHILDREN]-> (:CUI {name: name + '.singleCUI.c2'})
CREATE (mCUI1) -[:HAS_NESTED_CHILDREN]-> (:CUI {name: name + '.cUI1.cc1'})
CREATE (mCUI1) -[:HAS_NESTED_CHILDREN]-> (:CUI {name: name + '.cUI1.cc2'})
CREATE (mCUI2) -[:HAS_NESTED_CHILDREN]-> (:CUI {name: name + '.cUI2.cc1'})
CREATE (mCUI2) -[:HAS_NESTED_CHILDREN]-> (:CUI {name: name + '.cUI2.cc2'})
RETURN %s
""".formatted(label, id, version, idReturn)).execute().records().get(0).get("id").asString();
EXISTING_IDS.put(type, newId);
}
}
<T extends Parent> void assertAllRelationshipsHaveBeenCreated(T instance) {
var type = instance.getClass();
try (var session = driver.session()) {
var result = session.run("""
MATCH (p:%s WHERE %s)
MATCH (p) -[:HAS_SINGLE_CUI]-> (sCUI)
MATCH (p) -[:HAS_SINGLE_CUE]-> (sCUE)
MATCH (p) -[:HAS_MANY_CUI]-> (mCUI)
MATCH (p) -[:HAS_SINGLE_CVI]-> (sCVI {version: 0})
MATCH (p) -[:HAS_SINGLE_CVE]-> (sCVE {version: 0})
MATCH (p) -[:HAS_MANY_CVI]-> (mCVI {version: 0})
MATCH (sCUI) -[:HAS_NESTED_CHILDREN]-> (nc1)
MATCH (mCUI) -[:HAS_NESTED_CHILDREN]-> (nc2)
RETURN p, sCUI, sCUE, collect(DISTINCT mCUI) AS mCUI, collect(DISTINCT nc1) AS nc1, collect(DISTINCT nc2) AS nc2,
sCVI, sCVE, collect(DISTINCT mCVI) AS mCVI
""".formatted(type.getSimpleName(), instance instanceof ExternalId ? "p.id = $id" : "elementId(p) = $id"), Map.of("id", instance.getId()))
.list();
assertThat(result).hasSize(1).element(0)
.satisfies(r -> {
if (instance instanceof Versioned) {
assertThat(r.get("p").asNode().get("version").asLong()).isZero();
}
if (instance instanceof ExternalId) {
assertThat(r.get("p").asNode().get("id").asString()).isEqualTo(instance.getId());
} else {
assertThat(r.get("p").asNode().elementId()).isEqualTo(instance.getId());
}
assertThat(r.get("sCUI").hasType(TypeSystem.getDefault().NODE())).isTrue();
assertThat(r.get("sCUE").hasType(TypeSystem.getDefault().NODE())).isTrue();
assertThat(r.get("mCUI").asList(v -> v.asNode().get("name").asString()))
.containsExactlyInAnyOrder("Parent.cUI1", "Parent.cUI2");
assertThat(r.get("nc1").asList(v -> v.asNode().get("name").asString()))
.containsExactlyInAnyOrder("Parent.singleCUI.cc1", "Parent.singleCUI.cc2");
assertThat(r.get("nc2").asList(v -> v.asNode().get("name").asString()))
.containsExactlyInAnyOrder("Parent.cUI1.cc1", "Parent.cUI1.cc2", "Parent.cUI2.cc1", "Parent.cUI2.cc2");
assertThat(r.get("sCVI").asNode().get("version").asLong()).isZero();
assertThat(r.get("sCVE").asNode().get("version").asLong()).isZero();
assertThat(r.get("mCVI").asList(v -> {
var node = v.asNode();
return node.get("name").asString() + "." + node.get("version").asLong();
}))
.containsExactlyInAnyOrder("Parent.cVI1.0", "Parent.cVI2.0");
});
}
}
}

View File

@@ -0,0 +1,65 @@
/*
* Copyright 2011-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.data.neo4j.integration.cascading;
import java.util.List;
import org.springframework.data.annotation.PersistenceCreator;
import org.springframework.data.neo4j.core.schema.GeneratedValue;
import org.springframework.data.neo4j.core.schema.Id;
import org.springframework.data.neo4j.core.schema.Relationship;
import org.springframework.data.neo4j.core.support.UUIDStringGenerator;
/**
* Children / Unversioned / Externally generated id
*/
public class CUE implements ExternalId {
@Id
@GeneratedValue(UUIDStringGenerator.class)
private String id;
private String name;
@Relationship("HAS_NESTED_CHILDREN")
private List<CUE> nested;
public CUE(String name) {
this.name = name;
this.nested = List.of(
new CUE(name + ".cc1", List.of()),
new CUE(name + ".cc2", List.of())
);
}
@PersistenceCreator
public CUE(String name, List<CUE> nested) {
this.name = name;
this.nested = nested;
}
public String getId() {
return id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}

View File

@@ -0,0 +1,68 @@
/*
* Copyright 2011-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.data.neo4j.integration.cascading;
import java.util.List;
import org.springframework.data.annotation.PersistenceCreator;
import org.springframework.data.neo4j.core.schema.GeneratedValue;
import org.springframework.data.neo4j.core.schema.Id;
import org.springframework.data.neo4j.core.schema.Relationship;
/**
* Children / Unversioned / Internally generated id
*/
public class CUI {
@Id
@GeneratedValue
private String id;
private String name;
@Relationship(value = "HAS_NESTED_CHILDREN", cascadeUpdates = false)
private List<CUI> nested;
public CUI(String name) {
this.name = name;
this.nested = List.of(
new CUI(name + ".cc1", List.of()),
new CUI(name + ".cc2", List.of())
);
}
@PersistenceCreator
public CUI(String name, List<CUI> nested) {
this.name = name;
this.nested = nested;
}
public String getId() {
return id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public List<CUI> getNested() {
return nested;
}
}

View File

@@ -0,0 +1,52 @@
/*
* Copyright 2011-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.data.neo4j.integration.cascading;
import org.springframework.data.annotation.Version;
import org.springframework.data.neo4j.core.schema.GeneratedValue;
import org.springframework.data.neo4j.core.schema.Id;
import org.springframework.data.neo4j.core.support.UUIDStringGenerator;
/**
* Children / Unversioned / Externally generated id
*/
public class CVE implements Versioned, ExternalId {
@Id
@GeneratedValue(UUIDStringGenerator.class)
private String id;
@Version
private Long version;
private String name;
public CVE(String name) {
this.name = name;
}
public String getId() {
return id;
}
public String getName() {
return name;
}
public Long getVersion() {
return version;
}
}

View File

@@ -0,0 +1,51 @@
/*
* Copyright 2011-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.data.neo4j.integration.cascading;
import org.springframework.data.annotation.Version;
import org.springframework.data.neo4j.core.schema.GeneratedValue;
import org.springframework.data.neo4j.core.schema.Id;
/**
* Children / Unversioned / Internally generated id
*/
public class CVI implements Versioned {
@Id
@GeneratedValue
private String id;
@Version
private Long version;
private String name;
public CVI(String name) {
this.name = name;
}
public String getId() {
return id;
}
public String getName() {
return name;
}
public Long getVersion() {
return version;
}
}

View File

@@ -0,0 +1,108 @@
/*
* Copyright 2011-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.data.neo4j.integration.cascading;
import static org.assertj.core.api.Assertions.assertThat;
import java.lang.reflect.InvocationTargetException;
import java.util.List;
import org.junitpioneer.jupiter.cartesian.CartesianTest;
import org.junitpioneer.jupiter.cartesian.CartesianTest.Values;
import org.neo4j.driver.Driver;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Import;
import org.springframework.data.neo4j.core.Neo4jTemplate;
import org.springframework.data.neo4j.test.Neo4jExtension;
import org.springframework.data.neo4j.test.Neo4jImperativeTestConfiguration;
import org.springframework.data.neo4j.test.Neo4jIntegrationTest;
import org.springframework.transaction.annotation.EnableTransactionManagement;
@Neo4jIntegrationTest
@Import(CascadingIT.Config.class)
class CascadingIT extends AbstractCascadingTestBase {
protected static Neo4jExtension.Neo4jConnectionSupport neo4jConnectionSupport;
@EnableTransactionManagement
@ComponentScan
static class Config extends Neo4jImperativeTestConfiguration {
@Bean
public Driver driver() {
return neo4jConnectionSupport.getDriver();
}
@Override
public boolean isCypher5Compatible() {
return neo4jConnectionSupport.isCypher5SyntaxCompatible();
}
}
@Autowired
Neo4jTemplate template;
@CartesianTest
<T extends Parent> void updatesMustNotCascade(
@Values(classes = {PUI.class, PUE.class, PVI.class, PVE.class}) Class<T> type,
@Values(booleans = {true, false}) boolean single) throws NoSuchMethodException, InvocationTargetException, InstantiationException, IllegalAccessException {
var id = EXISTING_IDS.get(type);
var instance = this.template.findById(id, type).orElseThrow();
instance.setName("Updated parent");
instance.getSingleCUE().setName("Updated single CUE");
instance.getSingleCUI().setName("Updated single CUI");
instance.getManyCUI().forEach(cui -> {
cui.setName(cui.getName() + ".updatedNested1");
cui.getNested().forEach(nested -> nested.setName(nested + ".updatedNested2"));
});
if (single) {
this.template.save(instance);
} else {
this.template.saveAll(List.of(instance, type.getDeclaredConstructor(String.class).newInstance("Parent2")));
}
// Can't assert on the instance above, as that would ofc be the purposefully modified state
var reloadedInstance = this.template.findById(id, type).orElseThrow();
assertThat(reloadedInstance.getName()).isEqualTo("Updated parent");
assertThat(reloadedInstance.getSingleCUE().getName()).isEqualTo("ParentDB.singleCUE");
assertThat(reloadedInstance.getSingleCUI().getName()).isEqualTo("ParentDB.singleCUI");
assertThat(reloadedInstance.getSingleCVE().getVersion()).isZero();
assertThat(reloadedInstance.getSingleCVI().getVersion()).isZero();
assertThat(reloadedInstance.getManyCUI()).allMatch(cui -> cui.getName().endsWith(".updatedNested1") && cui.getNested().stream().noneMatch(nested -> nested.getName().endsWith(".updatedNested2")));
assertThat(reloadedInstance.getManyCVI()).allMatch(cvi -> cvi.getVersion() == 0L);
}
@CartesianTest
<T extends Parent> void newItemsMustBePersistedRegardlessOfCascadeSingleSave(
@Values(classes = {PUI.class, PUE.class, PVI.class, PVE.class}) Class<T> type,
@Values(booleans = {true, false}) boolean single) throws Exception {
T instance;
if (single) {
instance = template.save(type.getDeclaredConstructor(String.class).newInstance("Parent"));
} else {
instance = template.saveAll(List.of(type.getDeclaredConstructor(String.class).newInstance("Parent"), type.getDeclaredConstructor(String.class).newInstance("Parent2"))).get(0);
}
assertAllRelationshipsHaveBeenCreated(instance);
}
}

View File

@@ -0,0 +1,22 @@
/*
* Copyright 2011-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.data.neo4j.integration.cascading;
/**
* Marker for external id.
*/
public interface ExternalId {
}

View File

@@ -0,0 +1,114 @@
/*
* Copyright 2011-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.data.neo4j.integration.cascading;
import java.util.List;
import org.springframework.data.neo4j.core.schema.GeneratedValue;
import org.springframework.data.neo4j.core.schema.Id;
import org.springframework.data.neo4j.core.schema.Node;
import org.springframework.data.neo4j.core.schema.Relationship;
import org.springframework.data.neo4j.core.support.UUIDStringGenerator;
/**
* Parent / Unversioned / Externally generated id
*/
@Node
public class PUE implements Parent, ExternalId {
@Id
@GeneratedValue(UUIDStringGenerator.class)
private String id;
private String name;
@Relationship(value = "HAS_SINGLE_CUI", cascadeUpdates = false)
private CUI singleCUI;
@Relationship(value = "HAS_SINGLE_CUE", cascadeUpdates = false)
private CUE singleCUE;
@Relationship("HAS_MANY_CUI")
private List<CUI> manyCUI;
@Relationship(value = "HAS_SINGLE_CVI", cascadeUpdates = false)
private CVI singleCVI;
@Relationship(value = "HAS_SINGLE_CVE", cascadeUpdates = false)
private CVE singleCVE;
@Relationship(value = "HAS_MANY_CVI", cascadeUpdates = false)
private List<CVI> manyCVI;
public PUE(String name) {
this.name = name;
this.singleCUI = new CUI(name + ".singleCUI");
this.singleCUE = new CUE(name + ".singleCUE");
this.manyCUI = List.of(
new CUI(name + ".cUI1"),
new CUI(name + ".cUI2")
);
this.singleCVI = new CVI(name + ".singleCVI");
this.singleCVE = new CVE(name + ".singleCVE");
this.manyCVI = List.of(
new CVI(name + ".cVI1"),
new CVI(name + ".cVI2")
);
}
public String getId() {
return id;
}
public String getName() {
return name;
}
@Override
public void setName(String name) {
this.name = name;
}
@Override
public List<CUI> getManyCUI() {
return manyCUI;
}
@Override
public List<CVI> getManyCVI() {
return manyCVI;
}
@Override
public CUE getSingleCUE() {
return singleCUE;
}
@Override
public CUI getSingleCUI() {
return singleCUI;
}
@Override
public CVE getSingleCVE() {
return singleCVE;
}
@Override
public CVI getSingleCVI() {
return singleCVI;
}
}

View File

@@ -0,0 +1,114 @@
/*
* Copyright 2011-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.data.neo4j.integration.cascading;
import java.util.List;
import org.springframework.data.neo4j.core.schema.GeneratedValue;
import org.springframework.data.neo4j.core.schema.Id;
import org.springframework.data.neo4j.core.schema.Node;
import org.springframework.data.neo4j.core.schema.Relationship;
/**
* Parent / Unversioned / Internally generated id
*/
@Node
public class PUI implements Parent {
@Id
@GeneratedValue
private String id;
private String name;
@Relationship(value = "HAS_SINGLE_CUI", cascadeUpdates = false)
private CUI singleCUI;
@Relationship(value = "HAS_SINGLE_CUE", cascadeUpdates = false)
private CUE singleCUE;
@Relationship("HAS_MANY_CUI")
private List<CUI> manyCUI;
@Relationship(value = "HAS_SINGLE_CVI", cascadeUpdates = false)
private CVI singleCVI;
@Relationship(value = "HAS_SINGLE_CVE", cascadeUpdates = false)
private CVE singleCVE;
@Relationship(value = "HAS_MANY_CVI", cascadeUpdates = false)
private List<CVI> manyCVI;
public PUI(String name) {
this.name = name;
this.singleCUI = new CUI(name + ".singleCUI");
this.singleCUE = new CUE(name + ".singleCUE");
this.manyCUI = List.of(
new CUI(name + ".cUI1"),
new CUI(name + ".cUI2")
);
this.singleCVI = new CVI(name + ".singleCVI");
this.singleCVE = new CVE(name + ".singleCVE");
this.manyCVI = List.of(
new CVI(name + ".cVI1"),
new CVI(name + ".cVI2")
);
}
public String getId() {
return id;
}
@Override
public String getName() {
return name;
}
@Override
public void setName(String name) {
this.name = name;
}
@Override
public List<CUI> getManyCUI() {
return manyCUI;
}
@Override
public List<CVI> getManyCVI() {
return manyCVI;
}
@Override
public CUE getSingleCUE() {
return singleCUE;
}
@Override
public CUI getSingleCUI() {
return singleCUI;
}
@Override
public CVE getSingleCVE() {
return singleCVE;
}
@Override
public CVI getSingleCVI() {
return singleCVI;
}
}

View File

@@ -0,0 +1,124 @@
/*
* Copyright 2011-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.data.neo4j.integration.cascading;
import java.util.List;
import org.springframework.data.annotation.Version;
import org.springframework.data.neo4j.core.schema.GeneratedValue;
import org.springframework.data.neo4j.core.schema.Id;
import org.springframework.data.neo4j.core.schema.Node;
import org.springframework.data.neo4j.core.schema.Relationship;
import org.springframework.data.neo4j.core.support.UUIDStringGenerator;
/**
* Parent / Versioned / Externally generated id
*/
@Node
public class PVE implements Parent, Versioned, ExternalId {
@Id
@GeneratedValue(UUIDStringGenerator.class)
private String id;
@Version
private Long version;
private String name;
@Relationship(value = "HAS_SINGLE_CUI", cascadeUpdates = false)
private CUI singleCUI;
@Relationship(value = "HAS_SINGLE_CUE", cascadeUpdates = false)
private CUE singleCUE;
@Relationship("HAS_MANY_CUI")
private List<CUI> manyCUI;
@Relationship(value = "HAS_SINGLE_CVI", cascadeUpdates = false)
private CVI singleCVI;
@Relationship(value = "HAS_SINGLE_CVE", cascadeUpdates = false)
private CVE singleCVE;
@Relationship(value = "HAS_MANY_CVI", cascadeUpdates = false)
private List<CVI> manyCVI;
public PVE(String name) {
this.name = name;
this.singleCUI = new CUI(name + ".singleCUI");
this.singleCUE = new CUE(name + ".singleCUE");
this.manyCUI = List.of(
new CUI(name + ".cUI1"),
new CUI(name + ".cUI2")
);
this.singleCVI = new CVI(name + ".singleCVI");
this.singleCVE = new CVE(name + ".singleCVE");
this.manyCVI = List.of(
new CVI(name + ".cVI1"),
new CVI(name + ".cVI2")
);
}
public String getId() {
return id;
}
@Override
public String getName() {
return name;
}
@Override
public void setName(String name) {
this.name = name;
}
@Override
public Long getVersion() {
return version;
}
@Override
public List<CUI> getManyCUI() {
return manyCUI;
}
@Override
public List<CVI> getManyCVI() {
return manyCVI;
}
@Override
public CUE getSingleCUE() {
return singleCUE;
}
@Override
public CUI getSingleCUI() {
return singleCUI;
}
@Override
public CVE getSingleCVE() {
return singleCVE;
}
@Override
public CVI getSingleCVI() {
return singleCVI;
}
}

View File

@@ -0,0 +1,123 @@
/*
* Copyright 2011-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.data.neo4j.integration.cascading;
import java.util.List;
import org.springframework.data.annotation.Version;
import org.springframework.data.neo4j.core.schema.GeneratedValue;
import org.springframework.data.neo4j.core.schema.Id;
import org.springframework.data.neo4j.core.schema.Node;
import org.springframework.data.neo4j.core.schema.Relationship;
/**
* Parent / Versioned / Internally generated id
*/
@Node
public class PVI implements Parent, Versioned {
@Id
@GeneratedValue
private String id;
@Version
private Long version;
private String name;
@Relationship(value = "HAS_SINGLE_CUI", cascadeUpdates = false)
private CUI singleCUI;
@Relationship(value = "HAS_SINGLE_CUE", cascadeUpdates = false)
private CUE singleCUE;
@Relationship("HAS_MANY_CUI")
private List<CUI> manyCUI;
@Relationship(value = "HAS_SINGLE_CVI", cascadeUpdates = false)
private CVI singleCVI;
@Relationship(value = "HAS_SINGLE_CVE", cascadeUpdates = false)
private CVE singleCVE;
@Relationship(value = "HAS_MANY_CVI", cascadeUpdates = false)
private List<CVI> manyCVI;
public PVI(String name) {
this.name = name;
this.singleCUI = new CUI(name + ".singleCUI");
this.singleCUE = new CUE(name + ".singleCUE");
this.manyCUI = List.of(
new CUI(name + ".cUI1"),
new CUI(name + ".cUI2")
);
this.singleCVI = new CVI(name + ".singleCVI");
this.singleCVE = new CVE(name + ".singleCVE");
this.manyCVI = List.of(
new CVI(name + ".cVI1"),
new CVI(name + ".cVI2")
);
}
public String getId() {
return id;
}
@Override
public String getName() {
return name;
}
@Override
public void setName(String name) {
this.name = name;
}
@Override
public Long getVersion() {
return version;
}
@Override
public List<CUI> getManyCUI() {
return manyCUI;
}
@Override
public List<CVI> getManyCVI() {
return manyCVI;
}
@Override
public CUE getSingleCUE() {
return singleCUE;
}
@Override
public CUI getSingleCUI() {
return singleCUI;
}
@Override
public CVE getSingleCVE() {
return singleCVE;
}
@Override
public CVI getSingleCVI() {
return singleCVI;
}
}

View File

@@ -0,0 +1,42 @@
/*
* Copyright 2011-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.data.neo4j.integration.cascading;
import java.util.List;
/**
* Marker for parent
*/
public interface Parent {
String getId();
String getName();
void setName(String name);
List<CUI> getManyCUI();
List<CVI> getManyCVI();
CUE getSingleCUE();
CUI getSingleCUI();
CVE getSingleCVE();
CVI getSingleCVI();
}

View File

@@ -0,0 +1,111 @@
/*
* Copyright 2011-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.data.neo4j.integration.cascading;
import static org.assertj.core.api.Assertions.assertThat;
import java.lang.reflect.InvocationTargetException;
import java.util.List;
import org.junitpioneer.jupiter.cartesian.CartesianTest;
import org.junitpioneer.jupiter.cartesian.CartesianTest.Values;
import org.neo4j.driver.Driver;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Import;
import org.springframework.data.neo4j.core.ReactiveNeo4jTemplate;
import org.springframework.data.neo4j.test.Neo4jExtension;
import org.springframework.data.neo4j.test.Neo4jIntegrationTest;
import org.springframework.data.neo4j.test.Neo4jReactiveTestConfiguration;
import org.springframework.transaction.annotation.EnableTransactionManagement;
@Neo4jIntegrationTest
@Import(ReactiveCascadingIT.Config.class)
class ReactiveCascadingIT extends AbstractCascadingTestBase {
protected static Neo4jExtension.Neo4jConnectionSupport neo4jConnectionSupport;
@EnableTransactionManagement
@ComponentScan
static class Config extends Neo4jReactiveTestConfiguration {
@Bean
public Driver driver() {
return neo4jConnectionSupport.getDriver();
}
@Override
public boolean isCypher5Compatible() {
return neo4jConnectionSupport.isCypher5SyntaxCompatible();
}
}
@Autowired
ReactiveNeo4jTemplate template;
@CartesianTest
<T extends Parent> void updatesMustNotCascade(
@Values(classes = {PUI.class, PUE.class, PVI.class, PVE.class}) Class<T> type,
@Values(booleans = {true, false}) boolean single) throws NoSuchMethodException, InvocationTargetException, InstantiationException, IllegalAccessException {
var id = EXISTING_IDS.get(type);
var instance = this.template.findById(id, type).single().block();
instance.setName("Updated parent");
instance.getSingleCUE().setName("Updated single CUE");
instance.getSingleCUI().setName("Updated single CUI");
instance.getManyCUI().forEach(cui -> {
cui.setName(cui.getName() + ".updatedNested1");
cui.getNested().forEach(nested -> nested.setName(nested + ".updatedNested2"));
});
if (single) {
this.template.save(instance).block();
} else {
this.template.saveAll(List.of(instance, type.getDeclaredConstructor(String.class).newInstance("Parent2"))).collectList().block();
}
// Can't assert on the instance above, as that would ofc be the purposefully modified state
var reloadedInstance = this.template.findById(id, type).singleOptional().block().orElseThrow();
assertThat(reloadedInstance.getName()).isEqualTo("Updated parent");
assertThat(reloadedInstance.getSingleCUE().getName()).isEqualTo("ParentDB.singleCUE");
assertThat(reloadedInstance.getSingleCUI().getName()).isEqualTo("ParentDB.singleCUI");
assertThat(reloadedInstance.getSingleCVE().getVersion()).isZero();
assertThat(reloadedInstance.getSingleCVI().getVersion()).isZero();
assertThat(reloadedInstance.getManyCUI()).allMatch(cui -> cui.getName().endsWith(".updatedNested1") && cui.getNested().stream().noneMatch(nested -> nested.getName().endsWith(".updatedNested2")));
assertThat(reloadedInstance.getManyCVI()).allMatch(cvi -> cvi.getVersion() == 0L);
}
@CartesianTest
<T extends Parent> void newItemsMustBePersistedRegardlessOfCascadeSingleSave(
@Values(classes = {PUI.class, PUE.class, PVI.class, PVE.class}) Class<T> type,
@Values(booleans = {true, false}) boolean single) throws Exception {
T instance;
if (single) {
instance = template.save(type.getDeclaredConstructor(String.class).newInstance("Parent")).block();
} else {
instance = template.saveAll(List.of(type.getDeclaredConstructor(String.class).newInstance("Parent"), type.getDeclaredConstructor(String.class).newInstance("Parent2")))
.collectList()
.block()
.get(0);
}
assertAllRelationshipsHaveBeenCreated(instance);
}
}

View File

@@ -0,0 +1,24 @@
/*
* Copyright 2011-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.data.neo4j.integration.cascading;
/**
* Marker for versioned
*/
public interface Versioned {
Long getVersion();
}

View File

@@ -97,8 +97,7 @@ class CompositeIdsIT {
@Test
void compositeIdsShouldWork(@Autowired ThingWithCompositeIdRepository repository) {
ThingWithCompositeId thing = new ThingWithCompositeId(new CompositeValue("a,", 1), "first entity");
ThingWithCompositeId saved = repository.save(thing);
ThingWithCompositeId saved = repository.save(new ThingWithCompositeId(new CompositeValue("a,", 1), "first entity"));
assertThat(saved.getVersion()).isGreaterThanOrEqualTo(0);
saved.setName("foobar");