diff --git a/src/main/asciidoc/appendix/custom-queries.adoc b/src/main/asciidoc/appendix/custom-queries.adoc index 9bd0fc797..069172bc5 100644 --- a/src/main/asciidoc/appendix/custom-queries.adoc +++ b/src/main/asciidoc/appendix/custom-queries.adoc @@ -125,6 +125,52 @@ NOTE: Deciding if you want to go with client-side or database-side reduction dep All the paths needs to get created in the database's memory first when the `reduce` function is used. On the other hand a large amount of data that needs to get merged on the client-side results in a higher memory usage there. +[[custom-query.paths]] +== Using paths to populate and return a list of entities + +Given are a graph that looks like this: + +[[custom-query.paths.g]] +.graph with outgoing relationships +image::custom-query.paths.png[] + +and a domain model as shown in the <> (Constructors and accessors have been omitted for brevity): + +[[custom-query.paths.dm]] +[source,java,indent=0,tabsize=4] +.Domain model for a <>. +---- +include::../../../../src/test/java/org/springframework/data/neo4j/integration/issues/gh2210/GH2210IT.java[tag=custom-query.paths.dm] +---- + +As you see, the relationships are only outgoing. Generated finder methods (including `findById`) will always try to match +a root node to be mapped. From there on onwards, all related objects will be mapped. In queries that should return only one object, +that root object is returned. In queries that return many objects, all matching objects are returned. Out- and incoming relationships +from those objects returned are of course populated. + +Assume the following Cypher query: + +[source,cypher] +---- +MATCH p = (leaf:SomeEntity {number: $a})-[:SOME_RELATION_TO*]-(:SomeEntity) +RETURN leaf, collect(nodes(p)), collect(relationships(p)) +---- + +It follows the recommendation from <> and it works great for the leaf node +you want to match here. However: That is only the case in all scenarios that return 0 or 1 mapped objects. +While that query will populate all relationships like before, it won't return all 4 objects. + +This can be changed by returning the whole path: + +[source,cypher] +---- +MATCH p = (leaf:SomeEntity {number: $a})-[:SOME_RELATION_TO*]-(:SomeEntity) +RETURN p +---- + +Here we do want to use the fact that the path `p` actually returns 3 rows with paths to all 4 nodes. All 4 nodes will be +populated, linked together and returned. + [[custom-queries.parameters]] == Parameters in custom queries diff --git a/src/main/asciidoc/faq/faq.adoc b/src/main/asciidoc/faq/faq.adoc index 9db43508f..61f8c33d4 100644 --- a/src/main/asciidoc/faq/faq.adoc +++ b/src/main/asciidoc/faq/faq.adoc @@ -550,6 +550,8 @@ The query returns the path plus all relationships and related nodes collected so The path mapping works for single paths as well for multiple records of paths (which are returned by the `allShortestPath` function.) +TIP: Named paths can be used efficiently to populate and return more than just a root node, see <>. + [[faq.spring-boot.sdn]] == Do I need Spring Boot to use Spring Data Neo4j? diff --git a/src/main/asciidoc/img/custom-query.paths.png b/src/main/asciidoc/img/custom-query.paths.png new file mode 100644 index 000000000..ff7f0b554 Binary files /dev/null and b/src/main/asciidoc/img/custom-query.paths.png differ diff --git a/src/test/java/org/springframework/data/neo4j/integration/issues/gh2210/GH2210IT.java b/src/test/java/org/springframework/data/neo4j/integration/issues/gh2210/GH2210IT.java new file mode 100644 index 000000000..577286f18 --- /dev/null +++ b/src/test/java/org/springframework/data/neo4j/integration/issues/gh2210/GH2210IT.java @@ -0,0 +1,255 @@ +/* + * Copyright 2011-2021 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.gh2210; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.neo4j.driver.Driver; +import org.neo4j.driver.Record; +import org.neo4j.driver.Transaction; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.neo4j.config.AbstractNeo4jConfig; +import org.springframework.data.neo4j.core.Neo4jTemplate; +import org.springframework.data.neo4j.core.convert.Neo4jConversions; +import org.springframework.data.neo4j.core.mapping.Neo4jMappingContext; +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.schema.RelationshipProperties; +import org.springframework.data.neo4j.core.schema.TargetNode; +import org.springframework.data.neo4j.test.Neo4jExtension; +import org.springframework.data.neo4j.test.Neo4jIntegrationTest; +import org.springframework.transaction.annotation.EnableTransactionManagement; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Michael J. Simons + */ +@Neo4jIntegrationTest +class GH2210IT { + + protected static Neo4jExtension.Neo4jConnectionSupport neo4jConnectionSupport; + + static final Long numberA = 1L; + static final Long numberB = 2L; + static final Long numberC = 3L; + static final Long numberD = 4L; + + @BeforeAll + protected static void setupData() { + try (Transaction transaction = neo4jConnectionSupport.getDriver().session().beginTransaction()) { + transaction.run("MATCH (n) detach delete n"); + Map params = new HashMap<>(); + params.put("numberA", numberA); + params.put("numberB", numberB); + params.put("numberC", numberC); + params.put("numberD", numberD); + Record r = transaction.run("create (a:SomeEntity {number: $numberA, name: \"A\"})\n" + + "create (b:SomeEntity {number: $numberB, name: \"B\"})\n" + + "create (c:SomeEntity {number: $numberC, name: \"C\"})\n" + + "create (d:SomeEntity {number: $numberD, name: \"D\"})\n" + + "create (a) -[:SOME_RELATION_TO {someData: \"d1\"}] -> (b)\n" + + "create (b) <-[:SOME_RELATION_TO {someData: \"d2\"}] - (c)\n" + + "create (c) <-[:SOME_RELATION_TO {someData: \"d3\"}] - (d)\n" + + "return * ", params).single(); + transaction.commit(); + } + } + + @Test // GH-2210 + void standardFinderShouldWork(@Autowired Neo4jTemplate template) { + + assertA(template.findById(numberA, SomeEntity.class)); + + assertB(template.findById(numberB, SomeEntity.class)); + + assertD(template.findById(numberD, SomeEntity.class)); + } + + @Test // GH-2210 + void pathsBasedQueryShouldWork(@Autowired Neo4jTemplate template) { + + String query = "MATCH p = (leaf:SomeEntity {number: $a})-[:SOME_RELATION_TO*]-(:SomeEntity) RETURN leaf, collect(nodes(p)), collect(relationships(p))"; + assertA(template.findOne(query, Collections.singletonMap("a", numberA), SomeEntity.class)); + + assertB(template.findOne(query, Collections.singletonMap("a", numberB), SomeEntity.class)); + + assertD(template.findOne(query, Collections.singletonMap("a", numberD), SomeEntity.class)); + } + + @Test // GH-2210 + void aPathReturnedShouldPopulateAllNodes(@Autowired Neo4jTemplate template) { + + String query = "MATCH p = (leaf:SomeEntity {number: $a})-[:SOME_RELATION_TO*]-(:SomeEntity) RETURN p"; + assertAll(template.findAll(query, Collections.singletonMap("a", numberA), SomeEntity.class)); + } + + @Test // GH-2210 + void standardFindAllShouldWork(@Autowired Neo4jTemplate template) { + + assertAll(template.findAll(SomeEntity.class)); + } + + void assertAll(List entities) { + + assertThat(entities).hasSize(4); + assertThat(entities).allSatisfy(v -> { + switch (v.getName()) { + case "A": + assertA(Optional.of(v)); + break; + case "B": + assertB(Optional.of(v)); + break; + case "D": + assertD(Optional.of(v)); + break; + } + }); + } + + void assertA(Optional a) { + + assertThat(a).hasValueSatisfying(s -> { + assertThat(s.getName()).isEqualTo("A"); + assertThat(s.getSomeRelationsOut()) + .hasSize(1) + .first().satisfies(b -> { + assertThat(b.getSomeData()).isEqualTo("d1"); + assertThat(b.getTargetPerson().getName()).isEqualTo("B"); + assertThat(b.getTargetPerson().getSomeRelationsOut()).isEmpty(); + }); + }); + } + + void assertD(Optional d) { + + assertThat(d).hasValueSatisfying(s -> { + assertThat(s.getName()).isEqualTo("D"); + assertThat(s.getSomeRelationsOut()) + .hasSize(1) + .first().satisfies(c -> { + assertThat(c.getSomeData()).isEqualTo("d3"); + assertThat(c.getTargetPerson().getName()).isEqualTo("C"); + assertThat(c.getTargetPerson().getSomeRelationsOut()) + .hasSize(1) + .first().satisfies(b -> { + assertThat(b.getSomeData()).isEqualTo("d2"); + assertThat(b.getTargetPerson().getName()).isEqualTo("B"); + assertThat(b.getTargetPerson().getSomeRelationsOut()).isEmpty(); + }); + }); + }); + } + + void assertB(Optional b) { + + assertThat(b).hasValueSatisfying(s -> { + assertThat(s.getName()).isEqualTo("B"); + assertThat(s.getSomeRelationsOut()).isEmpty(); + }); + } + + // tag::custom-query.paths.dm[] + @Node + static class SomeEntity { + + @Id + private final Long number; + + private String name; + + @Relationship(type = "SOME_RELATION_TO", direction = Relationship.Direction.OUTGOING) + private Set someRelationsOut = new HashSet<>(); + // end::custom-query.paths.dm[] + + public Long getNumber() { + return number; + } + + public String getName() { + return name; + } + + public Set getSomeRelationsOut() { + return someRelationsOut; + } + + SomeEntity(Long number) { + this.number = number; + } + // tag::custom-query.paths.dm[] + } + + @RelationshipProperties + static class SomeRelation { + + @Id @GeneratedValue + private Long id; + + private String someData; + + @TargetNode + private SomeEntity targetPerson; + // end::custom-query.paths.dm[] + + public Long getId() { + return id; + } + + public String getSomeData() { + return someData; + } + + public SomeEntity getTargetPerson() { + return targetPerson; + } + // tag::custom-query.paths.dm[] + } + // end::custom-query.paths.dm[] + + @Configuration + @EnableTransactionManagement + static class Config extends AbstractNeo4jConfig { + + @Bean + public Driver driver() { + + return neo4jConnectionSupport.getDriver(); + } + + @Override + public Neo4jMappingContext neo4jMappingContext(Neo4jConversions neo4JConversions) throws ClassNotFoundException { + + Neo4jMappingContext ctx = new Neo4jMappingContext(neo4JConversions); + ctx.setInitialEntitySet(new HashSet<>(Arrays.asList(SomeEntity.class, SomeRelation.class))); + return ctx; + } + } +}