diff --git a/src/main/java/org/springframework/data/neo4j/core/mapping/Constants.java b/src/main/java/org/springframework/data/neo4j/core/mapping/Constants.java index b15a30c14..e87bd0684 100644 --- a/src/main/java/org/springframework/data/neo4j/core/mapping/Constants.java +++ b/src/main/java/org/springframework/data/neo4j/core/mapping/Constants.java @@ -69,6 +69,11 @@ public final class Constants { public static final String NAME_OF_KNOWN_RELATIONSHIPS_PARAM = "__knownRelationShipIds__"; public static final String NAME_OF_ALL_PROPERTIES = "__allProperties__"; + /** + * Optional property for relationship properties' simple class name to keep type info + */ + public static final String NAME_OF_RELATIONSHIP_TYPE = "__relationshipType__"; + public static final String NAME_OF_SYNTHESIZED_ROOT_NODE = "__sn__"; public static final String NAME_OF_SYNTHESIZED_RELATED_NODES = "__srn__"; public static final String NAME_OF_SYNTHESIZED_RELATIONS = "__sr__"; diff --git a/src/main/java/org/springframework/data/neo4j/core/mapping/DefaultNeo4jEntityConverter.java b/src/main/java/org/springframework/data/neo4j/core/mapping/DefaultNeo4jEntityConverter.java index c4600d700..85ba9f1a6 100644 --- a/src/main/java/org/springframework/data/neo4j/core/mapping/DefaultNeo4jEntityConverter.java +++ b/src/main/java/org/springframework/data/neo4j/core/mapping/DefaultNeo4jEntityConverter.java @@ -233,6 +233,10 @@ final class DefaultNeo4jEntityConverter implements Neo4jEntityConverter { Neo4jPersistentEntity nodeDescription = (Neo4jPersistentEntity) nodeDescriptionStore .getNodeDescription(source.getClass()); + if (nodeDescription.hasRelationshipPropertyPersistTypeInfoFlag()) { + // add type info when write to the database + properties.put(Constants.NAME_OF_RELATIONSHIP_TYPE, nodeDescription.getPrimaryLabel()); + } PersistentPropertyAccessor propertyAccessor = nodeDescription.getPropertyAccessor(source); PropertyHandlerSupport.of(nodeDescription).doWithProperties((Neo4jPersistentProperty p) -> { @@ -427,6 +431,8 @@ final class DefaultNeo4jEntityConverter implements Neo4jEntityConverter { /** * Returns the list of labels for the entity to be created from the "main" node returned. + * In case of a relationship that maps to a relationship properties definition, + * return the optional persisted type. * * @param queryResult The complete query result * @return The list of labels defined by the query variable {@link Constants#NAME_OF_LABELS}. @@ -440,6 +446,13 @@ final class DefaultNeo4jEntityConverter implements Neo4jEntityConverter { } else if (queryResult instanceof Node) { Node nodeRepresentation = (Node) queryResult; nodeRepresentation.labels().forEach(labels::add); + } else if (queryResult instanceof Relationship) { + Value value = queryResult.get(Constants.NAME_OF_RELATIONSHIP_TYPE); + if (value.isNull()) { + labels.addAll(nodeDescription.getStaticLabels()); + } else { + labels.add(value.asString()); + } } else if (containsOnePlainNode(queryResult)) { for (Value value : queryResult.values()) { if (value.hasType(nodeType)) { diff --git a/src/main/java/org/springframework/data/neo4j/core/mapping/DefaultNeo4jPersistentEntity.java b/src/main/java/org/springframework/data/neo4j/core/mapping/DefaultNeo4jPersistentEntity.java index c9ac515f1..d5df9f90a 100644 --- a/src/main/java/org/springframework/data/neo4j/core/mapping/DefaultNeo4jPersistentEntity.java +++ b/src/main/java/org/springframework/data/neo4j/core/mapping/DefaultNeo4jPersistentEntity.java @@ -185,6 +185,14 @@ final class DefaultNeo4jPersistentEntity extends BasicPersistentEntity */ boolean isRelationshipPropertiesEntity(); + /** + * Determines if the entity is annotated with {@link org.springframework.data.neo4j.core.schema.RelationshipProperties} + * and has the flag {@link org.springframework.data.neo4j.core.schema.RelationshipProperties#persistTypeInfo()} set to true. + * @return true if this is a relationship properties class and the type info should be persisted, otherwise false. + */ + boolean hasRelationshipPropertyPersistTypeInfoFlag(); + /** * @return True if the underlying domain classes uses {@code id()} to compute internally generated ids. */ diff --git a/src/main/java/org/springframework/data/neo4j/core/schema/RelationshipProperties.java b/src/main/java/org/springframework/data/neo4j/core/schema/RelationshipProperties.java index d575ecfc2..a2b9bd641 100644 --- a/src/main/java/org/springframework/data/neo4j/core/schema/RelationshipProperties.java +++ b/src/main/java/org/springframework/data/neo4j/core/schema/RelationshipProperties.java @@ -52,4 +52,12 @@ import org.apiguardian.api.API; @Inherited @API(status = API.Status.STABLE, since = "6.0") public @interface RelationshipProperties { + /** + * Set to true will persist {@link org.springframework.data.neo4j.core.mapping.Constants#NAME_OF_RELATIONSHIP_TYPE} to {@link Class#getSimpleName()} + * as a property in relationships. This property will be used to determine the type of the relationship + * when mapping back to the domain model. + * + * @return whether to persist type information for the annotated class. + */ + boolean persistTypeInfo() default false; } diff --git a/src/test/java/org/springframework/data/neo4j/integration/issues/IssuesIT.java b/src/test/java/org/springframework/data/neo4j/integration/issues/IssuesIT.java index 860998747..5dd9aebbc 100644 --- a/src/test/java/org/springframework/data/neo4j/integration/issues/IssuesIT.java +++ b/src/test/java/org/springframework/data/neo4j/integration/issues/IssuesIT.java @@ -189,6 +189,13 @@ import org.springframework.data.neo4j.integration.issues.gh2918.ConditionNode; import org.springframework.data.neo4j.integration.issues.gh2918.ConditionRepository; import org.springframework.data.neo4j.integration.issues.gh2963.MyModel; import org.springframework.data.neo4j.integration.issues.gh2963.MyRepository; +import org.springframework.data.neo4j.integration.issues.gh2973.BaseNode; +import org.springframework.data.neo4j.integration.issues.gh2973.BaseRelationship; +import org.springframework.data.neo4j.integration.issues.gh2973.Gh2973Repository; +import org.springframework.data.neo4j.integration.issues.gh2973.RelationshipA; +import org.springframework.data.neo4j.integration.issues.gh2973.RelationshipB; +import org.springframework.data.neo4j.integration.issues.gh2973.RelationshipC; +import org.springframework.data.neo4j.integration.issues.gh2973.RelationshipD; import org.springframework.data.neo4j.integration.issues.qbe.A; import org.springframework.data.neo4j.integration.issues.qbe.ARepository; import org.springframework.data.neo4j.integration.issues.qbe.B; @@ -1698,6 +1705,72 @@ class IssuesIT extends TestBase { assertThat(rootModelFromDbCustom).map(MyModel::getMyNestedModel).isPresent(); } + @Tag("GH-2973") + @Test + void abstractedRelationshipTypesShouldBeMappedCorrectly(@Autowired Gh2973Repository gh2973Repository) { + var node = new BaseNode(); + var nodeFail = new BaseNode(); + RelationshipA a1 = new RelationshipA(); + RelationshipA a2 = new RelationshipA(); + RelationshipA a3 = new RelationshipA(); + RelationshipB b1 = new RelationshipB(); + RelationshipB b2 = new RelationshipB(); + RelationshipC c1 = new RelationshipC(); + RelationshipD d1 = new RelationshipD(); + + a1.setTargetNode(new BaseNode()); + a1.setA("a1"); + a2.setTargetNode(new BaseNode()); + a2.setA("a2"); + a3.setTargetNode(new BaseNode()); + a3.setA("a3"); + + b1.setTargetNode(new BaseNode()); + b1.setB("b1"); + b2.setTargetNode(new BaseNode()); + b2.setB("b2"); + + c1.setTargetNode(new BaseNode()); + c1.setC("c1"); + + d1.setTargetNode(new BaseNode()); + d1.setD("d1"); + + node.setRelationships(Map.of( + "a", List.of( + a1, a2, b2 + ), + "b", List.of( + b1, a3 + ) + )); + nodeFail.setRelationships(Map.of( + "c", List.of( + c1, d1 + ) + )); + var persistedNode = gh2973Repository.save(node); + var persistedNodeFail = gh2973Repository.save(nodeFail); + + // with type info, the relationships are of the correct type + var loadedNode = gh2973Repository.findById(persistedNode.getId()).get(); + List relationshipsA = loadedNode.getRelationships().get("a"); + List relationshipsB = loadedNode.getRelationships().get("b"); + assertThat(relationshipsA).satisfiesExactlyInAnyOrder( + r1 -> assertThat(r1).isOfAnyClassIn(RelationshipA.class), + r2 -> assertThat(r2).isOfAnyClassIn(RelationshipA.class), + r3 -> assertThat(r3).isOfAnyClassIn(RelationshipB.class) + ); + assertThat(relationshipsB).satisfiesExactlyInAnyOrder( + r1 -> assertThat(r1).isOfAnyClassIn(RelationshipA.class), + r2 -> assertThat(r2).isOfAnyClassIn(RelationshipB.class) + ); + // without type info, the relationships are all same type and not the base class BaseRelationship + var loadedNodeFail = gh2973Repository.findById(persistedNodeFail.getId()).get(); + List relationshipsCFail = loadedNodeFail.getRelationships().get("c"); + assertThat(relationshipsCFail.get(0)).isNotExactlyInstanceOf(BaseRelationship.class); + } + @Configuration @EnableTransactionManagement @EnableNeo4jRepositories(namedQueriesLocation = "more-custom-queries.properties") @@ -1855,4 +1928,6 @@ class IssuesIT extends TestBase { return repository.save(n1); } + + } diff --git a/src/test/java/org/springframework/data/neo4j/integration/issues/gh2973/BaseNode.java b/src/test/java/org/springframework/data/neo4j/integration/issues/gh2973/BaseNode.java new file mode 100644 index 000000000..91ac2899e --- /dev/null +++ b/src/test/java/org/springframework/data/neo4j/integration/issues/gh2973/BaseNode.java @@ -0,0 +1,55 @@ +/* + * Copyright 2011-2025 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.issues.gh2973; + +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 java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + + +/** + * @author yangyaofei + */ +@Node +public class BaseNode { + @Id + @GeneratedValue + private UUID id; + @Relationship(direction = Relationship.Direction.OUTGOING) + private Map> relationships = new HashMap<>(); + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public Map> getRelationships() { + return relationships; + } + + public void setRelationships(Map> relationships) { + this.relationships = relationships; + } +} diff --git a/src/test/java/org/springframework/data/neo4j/integration/issues/gh2973/BaseRelationship.java b/src/test/java/org/springframework/data/neo4j/integration/issues/gh2973/BaseRelationship.java new file mode 100644 index 000000000..b3d3bb153 --- /dev/null +++ b/src/test/java/org/springframework/data/neo4j/integration/issues/gh2973/BaseRelationship.java @@ -0,0 +1,49 @@ +/* + * Copyright 2011-2025 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.issues.gh2973; + +import org.springframework.data.neo4j.core.schema.GeneratedValue; +import org.springframework.data.neo4j.core.schema.RelationshipId; +import org.springframework.data.neo4j.core.schema.RelationshipProperties; +import org.springframework.data.neo4j.core.schema.TargetNode; + +/** + * @author yangyaofei + */ +@RelationshipProperties +public abstract class BaseRelationship { + @RelationshipId + @GeneratedValue + private Long id; + @TargetNode + private BaseNode targetNode; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public BaseNode getTargetNode() { + return targetNode; + } + + public void setTargetNode(BaseNode targetNode) { + this.targetNode = targetNode; + } +} diff --git a/src/test/java/org/springframework/data/neo4j/integration/issues/gh2973/Gh2973Repository.java b/src/test/java/org/springframework/data/neo4j/integration/issues/gh2973/Gh2973Repository.java new file mode 100644 index 000000000..42a03cfee --- /dev/null +++ b/src/test/java/org/springframework/data/neo4j/integration/issues/gh2973/Gh2973Repository.java @@ -0,0 +1,28 @@ +/* + * Copyright 2011-2025 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.issues.gh2973; + +import org.springframework.data.neo4j.repository.Neo4jRepository; + +import java.util.UUID; + +/** + * Test repository for GH-2973 + * + * @author yangyaofei + */ +public interface Gh2973Repository extends Neo4jRepository { +} diff --git a/src/test/java/org/springframework/data/neo4j/integration/issues/gh2973/RelationshipA.java b/src/test/java/org/springframework/data/neo4j/integration/issues/gh2973/RelationshipA.java new file mode 100644 index 000000000..22b026e9c --- /dev/null +++ b/src/test/java/org/springframework/data/neo4j/integration/issues/gh2973/RelationshipA.java @@ -0,0 +1,34 @@ +/* + * Copyright 2011-2025 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.issues.gh2973; + +import org.springframework.data.neo4j.core.schema.RelationshipProperties; + +/** + * @author yangyaofei + */ +@RelationshipProperties(persistTypeInfo = true) +public class RelationshipA extends BaseRelationship { + String a; + + public String getA() { + return a; + } + + public void setA(String a) { + this.a = a; + } +} diff --git a/src/test/java/org/springframework/data/neo4j/integration/issues/gh2973/RelationshipB.java b/src/test/java/org/springframework/data/neo4j/integration/issues/gh2973/RelationshipB.java new file mode 100644 index 000000000..1243a159a --- /dev/null +++ b/src/test/java/org/springframework/data/neo4j/integration/issues/gh2973/RelationshipB.java @@ -0,0 +1,34 @@ +/* + * Copyright 2011-2025 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.issues.gh2973; + +import org.springframework.data.neo4j.core.schema.RelationshipProperties; + +/** + * @author yangyaofei + */ +@RelationshipProperties(persistTypeInfo = true) +public class RelationshipB extends BaseRelationship { + String b; + + public String getB() { + return b; + } + + public void setB(String b) { + this.b = b; + } +} diff --git a/src/test/java/org/springframework/data/neo4j/integration/issues/gh2973/RelationshipC.java b/src/test/java/org/springframework/data/neo4j/integration/issues/gh2973/RelationshipC.java new file mode 100644 index 000000000..9172778f3 --- /dev/null +++ b/src/test/java/org/springframework/data/neo4j/integration/issues/gh2973/RelationshipC.java @@ -0,0 +1,34 @@ +/* + * Copyright 2011-2025 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.issues.gh2973; + +import org.springframework.data.neo4j.core.schema.RelationshipProperties; + +/** + * @author yangyaofei + */ +@RelationshipProperties +public class RelationshipC extends BaseRelationship { + String c; + + public String getC() { + return c; + } + + public void setC(String c) { + this.c = c; + } +} diff --git a/src/test/java/org/springframework/data/neo4j/integration/issues/gh2973/RelationshipD.java b/src/test/java/org/springframework/data/neo4j/integration/issues/gh2973/RelationshipD.java new file mode 100644 index 000000000..480ad6114 --- /dev/null +++ b/src/test/java/org/springframework/data/neo4j/integration/issues/gh2973/RelationshipD.java @@ -0,0 +1,34 @@ +/* + * Copyright 2011-2025 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.issues.gh2973; + +import org.springframework.data.neo4j.core.schema.RelationshipProperties; + +/** + * @author yangyaofei + */ +@RelationshipProperties +public class RelationshipD extends BaseRelationship { + String d; + + public String getD() { + return d; + } + + public void setD(String d) { + this.d = d; + } +}