From 399cba4faed7e95af06fd9d544c5f9c4b03635c8 Mon Sep 17 00:00:00 2001 From: Michael Simons Date: Wed, 8 May 2024 16:46:37 +0200 Subject: [PATCH] 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 --- pom.xml | 11 ++ .../data/neo4j/core/Neo4jTemplate.java | 30 ++++- .../neo4j/core/ReactiveNeo4jTemplate.java | 33 ++++- .../neo4j/core/mapping/CypherGenerator.java | 6 + .../DefaultNeo4jPersistentProperty.java | 2 +- .../DefaultRelationshipDescription.java | 11 +- .../core/mapping/RelationshipDescription.java | 5 + .../data/neo4j/core/schema/Relationship.java | 9 ++ .../cascading/AbstractCascadingTestBase.java | 127 ++++++++++++++++++ .../data/neo4j/integration/cascading/CUE.java | 65 +++++++++ .../data/neo4j/integration/cascading/CUI.java | 68 ++++++++++ .../data/neo4j/integration/cascading/CVE.java | 52 +++++++ .../data/neo4j/integration/cascading/CVI.java | 51 +++++++ .../integration/cascading/CascadingIT.java | 108 +++++++++++++++ .../integration/cascading/ExternalId.java | 22 +++ .../data/neo4j/integration/cascading/PUE.java | 114 ++++++++++++++++ .../data/neo4j/integration/cascading/PUI.java | 114 ++++++++++++++++ .../data/neo4j/integration/cascading/PVE.java | 124 +++++++++++++++++ .../data/neo4j/integration/cascading/PVI.java | 123 +++++++++++++++++ .../neo4j/integration/cascading/Parent.java | 42 ++++++ .../cascading/ReactiveCascadingIT.java | 111 +++++++++++++++ .../integration/cascading/Versioned.java | 24 ++++ .../compose_as_ids/CompositeIdsIT.java | 3 +- 23 files changed, 1245 insertions(+), 10 deletions(-) create mode 100644 src/test/java/org/springframework/data/neo4j/integration/cascading/AbstractCascadingTestBase.java create mode 100644 src/test/java/org/springframework/data/neo4j/integration/cascading/CUE.java create mode 100644 src/test/java/org/springframework/data/neo4j/integration/cascading/CUI.java create mode 100644 src/test/java/org/springframework/data/neo4j/integration/cascading/CVE.java create mode 100644 src/test/java/org/springframework/data/neo4j/integration/cascading/CVI.java create mode 100644 src/test/java/org/springframework/data/neo4j/integration/cascading/CascadingIT.java create mode 100644 src/test/java/org/springframework/data/neo4j/integration/cascading/ExternalId.java create mode 100644 src/test/java/org/springframework/data/neo4j/integration/cascading/PUE.java create mode 100644 src/test/java/org/springframework/data/neo4j/integration/cascading/PUI.java create mode 100644 src/test/java/org/springframework/data/neo4j/integration/cascading/PVE.java create mode 100644 src/test/java/org/springframework/data/neo4j/integration/cascading/PVI.java create mode 100644 src/test/java/org/springframework/data/neo4j/integration/cascading/Parent.java create mode 100644 src/test/java/org/springframework/data/neo4j/integration/cascading/ReactiveCascadingIT.java create mode 100644 src/test/java/org/springframework/data/neo4j/integration/cascading/Versioned.java diff --git a/pom.xml b/pom.xml index e26bbc9db..e737fe177 100644 --- a/pom.xml +++ b/pom.xml @@ -114,6 +114,7 @@ ${skipTests} 3.3.0-SNAPSHOT + 2.2.0 @@ -141,6 +142,11 @@ ${junit-cc-testcontainer} test + + org.junit-pioneer + junit-pioneer + ${junit-pioneer.version} + io.github.classgraph classgraph @@ -441,6 +447,11 @@ blockhound test + + org.junit-pioneer + junit-pioneer + test + diff --git a/src/main/java/org/springframework/data/neo4j/core/Neo4jTemplate.java b/src/main/java/org/springframework/data/neo4j/core/Neo4jTemplate.java index 0caea2c1d..9b54d6e50 100644 --- a/src/main/java/org/springframework/data/neo4j/core/Neo4jTemplate.java +++ b/src/main/java/org/springframework/data/neo4j/core/Neo4jTemplate.java @@ -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, diff --git a/src/main/java/org/springframework/data/neo4j/core/ReactiveNeo4jTemplate.java b/src/main/java/org/springframework/data/neo4j/core/ReactiveNeo4jTemplate.java index 813cf61d3..f9b661924 100644 --- a/src/main/java/org/springframework/data/neo4j/core/ReactiveNeo4jTemplate.java +++ b/src/main/java/org/springframework/data/neo4j/core/ReactiveNeo4jTemplate.java @@ -969,6 +969,9 @@ public final class ReactiveNeo4jTemplate implements Flux 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, AtomicReference>> 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 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 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 saveRelatedNode(Object relatedNode, Neo4jPersistentEntity targetNodeDescription, PropertyFilter includeProperty, PropertyFilter.RelaxedPropertyPath currentPropertyPath) { return determineDynamicLabels(relatedNode, targetNodeDescription) diff --git a/src/main/java/org/springframework/data/neo4j/core/mapping/CypherGenerator.java b/src/main/java/org/springframework/data/neo4j/core/mapping/CypherGenerator.java index 0c0b2c7c7..d7cc246a4 100644 --- a/src/main/java/org/springframework/data/neo4j/core/mapping/CypherGenerator.java +++ b/src/main/java/org/springframework/data/neo4j/core/mapping/CypherGenerator.java @@ -756,6 +756,12 @@ public enum CypherGenerator { return returnExpressions; } + + public StatementBuilder.OngoingReading prepareFindOf(NodeDescription nodeDescription, @Nullable List 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 includedProperties, @Nullable RelationshipDescription relationshipDescription, List processedRelationships) { diff --git a/src/main/java/org/springframework/data/neo4j/core/mapping/DefaultNeo4jPersistentProperty.java b/src/main/java/org/springframework/data/neo4j/core/mapping/DefaultNeo4jPersistentProperty.java index 47f573e4b..505966a67 100644 --- a/src/main/java/org/springframework/data/neo4j/core/mapping/DefaultNeo4jPersistentProperty.java +++ b/src/main/java/org/springframework/data/neo4j/core/mapping/DefaultNeo4jPersistentProperty.java @@ -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 diff --git a/src/main/java/org/springframework/data/neo4j/core/mapping/DefaultRelationshipDescription.java b/src/main/java/org/springframework/data/neo4j/core/mapping/DefaultRelationshipDescription.java index ef053c578..ad08a68d9 100644 --- a/src/main/java/org/springframework/data/neo4j/core/mapping/DefaultRelationshipDescription.java +++ b/src/main/java/org/springframework/data/neo4j/core/mapping/DefaultRelationshipDescription.java @@ -44,9 +44,12 @@ final class DefaultRelationshipDescription extends Association 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, 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 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); + } + } + + + 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"); + }); + } + } +} diff --git a/src/test/java/org/springframework/data/neo4j/integration/cascading/CUE.java b/src/test/java/org/springframework/data/neo4j/integration/cascading/CUE.java new file mode 100644 index 000000000..d76c8c395 --- /dev/null +++ b/src/test/java/org/springframework/data/neo4j/integration/cascading/CUE.java @@ -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 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 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; + } +} diff --git a/src/test/java/org/springframework/data/neo4j/integration/cascading/CUI.java b/src/test/java/org/springframework/data/neo4j/integration/cascading/CUI.java new file mode 100644 index 000000000..20ef3a1d0 --- /dev/null +++ b/src/test/java/org/springframework/data/neo4j/integration/cascading/CUI.java @@ -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 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 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 getNested() { + return nested; + } +} diff --git a/src/test/java/org/springframework/data/neo4j/integration/cascading/CVE.java b/src/test/java/org/springframework/data/neo4j/integration/cascading/CVE.java new file mode 100644 index 000000000..fce8b9201 --- /dev/null +++ b/src/test/java/org/springframework/data/neo4j/integration/cascading/CVE.java @@ -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; + } +} diff --git a/src/test/java/org/springframework/data/neo4j/integration/cascading/CVI.java b/src/test/java/org/springframework/data/neo4j/integration/cascading/CVI.java new file mode 100644 index 000000000..152a882fb --- /dev/null +++ b/src/test/java/org/springframework/data/neo4j/integration/cascading/CVI.java @@ -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; + } +} diff --git a/src/test/java/org/springframework/data/neo4j/integration/cascading/CascadingIT.java b/src/test/java/org/springframework/data/neo4j/integration/cascading/CascadingIT.java new file mode 100644 index 000000000..2bbcc8550 --- /dev/null +++ b/src/test/java/org/springframework/data/neo4j/integration/cascading/CascadingIT.java @@ -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 + void updatesMustNotCascade( + @Values(classes = {PUI.class, PUE.class, PVI.class, PVE.class}) Class 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 + void newItemsMustBePersistedRegardlessOfCascadeSingleSave( + @Values(classes = {PUI.class, PUE.class, PVI.class, PVE.class}) Class 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); + } +} diff --git a/src/test/java/org/springframework/data/neo4j/integration/cascading/ExternalId.java b/src/test/java/org/springframework/data/neo4j/integration/cascading/ExternalId.java new file mode 100644 index 000000000..785e8aa1f --- /dev/null +++ b/src/test/java/org/springframework/data/neo4j/integration/cascading/ExternalId.java @@ -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 { +} diff --git a/src/test/java/org/springframework/data/neo4j/integration/cascading/PUE.java b/src/test/java/org/springframework/data/neo4j/integration/cascading/PUE.java new file mode 100644 index 000000000..d9279c16a --- /dev/null +++ b/src/test/java/org/springframework/data/neo4j/integration/cascading/PUE.java @@ -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 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 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 getManyCUI() { + return manyCUI; + } + + @Override + public List 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; + } +} diff --git a/src/test/java/org/springframework/data/neo4j/integration/cascading/PUI.java b/src/test/java/org/springframework/data/neo4j/integration/cascading/PUI.java new file mode 100644 index 000000000..e8640a0da --- /dev/null +++ b/src/test/java/org/springframework/data/neo4j/integration/cascading/PUI.java @@ -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 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 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 getManyCUI() { + return manyCUI; + } + + @Override + public List 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; + } +} diff --git a/src/test/java/org/springframework/data/neo4j/integration/cascading/PVE.java b/src/test/java/org/springframework/data/neo4j/integration/cascading/PVE.java new file mode 100644 index 000000000..7b19bdfb4 --- /dev/null +++ b/src/test/java/org/springframework/data/neo4j/integration/cascading/PVE.java @@ -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 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 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 getManyCUI() { + return manyCUI; + } + + @Override + public List 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; + } +} diff --git a/src/test/java/org/springframework/data/neo4j/integration/cascading/PVI.java b/src/test/java/org/springframework/data/neo4j/integration/cascading/PVI.java new file mode 100644 index 000000000..18660725d --- /dev/null +++ b/src/test/java/org/springframework/data/neo4j/integration/cascading/PVI.java @@ -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 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 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 getManyCUI() { + return manyCUI; + } + + @Override + public List 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; + } +} diff --git a/src/test/java/org/springframework/data/neo4j/integration/cascading/Parent.java b/src/test/java/org/springframework/data/neo4j/integration/cascading/Parent.java new file mode 100644 index 000000000..dda7a723a --- /dev/null +++ b/src/test/java/org/springframework/data/neo4j/integration/cascading/Parent.java @@ -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 getManyCUI(); + + List getManyCVI(); + + CUE getSingleCUE(); + + CUI getSingleCUI(); + + CVE getSingleCVE(); + + CVI getSingleCVI(); +} diff --git a/src/test/java/org/springframework/data/neo4j/integration/cascading/ReactiveCascadingIT.java b/src/test/java/org/springframework/data/neo4j/integration/cascading/ReactiveCascadingIT.java new file mode 100644 index 000000000..3c23f940a --- /dev/null +++ b/src/test/java/org/springframework/data/neo4j/integration/cascading/ReactiveCascadingIT.java @@ -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 + void updatesMustNotCascade( + @Values(classes = {PUI.class, PUE.class, PVI.class, PVE.class}) Class 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 + void newItemsMustBePersistedRegardlessOfCascadeSingleSave( + @Values(classes = {PUI.class, PUE.class, PVI.class, PVE.class}) Class 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); + } +} diff --git a/src/test/java/org/springframework/data/neo4j/integration/cascading/Versioned.java b/src/test/java/org/springframework/data/neo4j/integration/cascading/Versioned.java new file mode 100644 index 000000000..9005fe9cb --- /dev/null +++ b/src/test/java/org/springframework/data/neo4j/integration/cascading/Versioned.java @@ -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(); +} diff --git a/src/test/java/org/springframework/data/neo4j/integration/conversion_imperative/compose_as_ids/CompositeIdsIT.java b/src/test/java/org/springframework/data/neo4j/integration/conversion_imperative/compose_as_ids/CompositeIdsIT.java index 1060be879..cbea2b87d 100644 --- a/src/test/java/org/springframework/data/neo4j/integration/conversion_imperative/compose_as_ids/CompositeIdsIT.java +++ b/src/test/java/org/springframework/data/neo4j/integration/conversion_imperative/compose_as_ids/CompositeIdsIT.java @@ -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");