Skip to content

Commit 868ca7c

Browse files
committed
GH-2203 - Support list of list of relationships/nodes mapping.
1 parent 01fa719 commit 868ca7c

File tree

6 files changed

+178
-10
lines changed

6 files changed

+178
-10
lines changed

src/main/asciidoc/appendix/custom-queries.adoc

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,82 @@ To get the right object(s) back, it is required to _collect_ the relationships a
4949

5050
With this result as a single record it is possible for Spring Data Neo4j to add all related nodes correctly to the root node.
5151

52+
[[custom-queries.for-relationships.long-paths]]
53+
=== Reaching deeper into the graph
54+
55+
The example above assumes that you are only trying to fetch the first level of related nodes.
56+
This is sometimes not enough and there are maybe nodes deeper in the graph that should also be part of the mapped instance.
57+
There are two ways to achieve this: Database-side or client-side reduction.
58+
59+
For this the example from above should also contain `Movies` on the `Persons` that get returned with the initial `Movie`.
60+
61+
.Example for 'The Matrix' and 'Keanu Reeves'
62+
image::movie-graph-deep.png[]
63+
64+
[[custom-queries.for-relationships.long-paths.database]]
65+
==== Database-side reduction
66+
67+
Keeping in mind that Spring Data Neo4j can only properly process record based, the result for one entity instance needs to be in one record.
68+
Using https://neo4j.com/docs/cypher-manual/current/syntax/patterns/#cypher-pattern-path-variables[Cypher's path] capabilities is a valid option to fetch all branches in the graph.
69+
70+
[source,cypher]
71+
.Naive path-based approach
72+
----
73+
MATCH p=(m:Movie{title: 'The Matrix'})<-[:ACTED_IN]-(:Person)-[:ACTED_IN*..0]->(:Movie)
74+
RETURN p;
75+
----
76+
77+
This will result in multiple paths that are not merged within one record.
78+
It is possible to call `collect(p)` but Spring Data Neo4j does not understand the concept of paths in the mapping process.
79+
Thus, nodes and relationships needs to get extracted for the result.
80+
81+
[source,cypher]
82+
.Extracting nodes and relationships
83+
----
84+
MATCH p=(m:Movie{title: 'The Matrix'})<-[:ACTED_IN]-(:Person)-[:ACTED_IN*..0]->(:Movie)
85+
RETURN m, nodes(p), relationships(p);
86+
----
87+
88+
Because there are multiple paths that lead from 'The Matrix' to another movie, the result still won't be a single record.
89+
This is where https://neo4j.com/docs/cypher-manual/current/functions/list/#functions-reduce[Cypher's reduce function] comes into play.
90+
91+
[source,cypher]
92+
.Reducing nodes and relationships
93+
----
94+
MATCH p=(m:Movie{title: 'The Matrix'})<-[:ACTED_IN]-(:Person)-[:ACTED_IN*..0]->(:Movie)
95+
WITH collect(p) as paths, m
96+
WITH m,
97+
reduce(a=[], node in reduce(b=[], c in [aa in paths | nodes(aa)] | b + c) | case when node in a then a else a + node end) as nodes,
98+
reduce(d=[], relationship in reduce(e=[], f in [dd in paths | relationships(dd)] | e + f) | case when relationship in d then d else d + relationship end) as relationships
99+
RETURN m, relationships, nodes;
100+
----
101+
102+
The `reduce` function allows us to flatten the nodes and relationships from various paths.
103+
As a result we will get a tuple similar to <<custom-queries.for-relationships.one.record>> but with a mixture of relationship types or nodes in the collections.
104+
105+
[[custom-queries.for-relationships.long-paths.client]]
106+
==== Client-side reduction
107+
108+
If the reduction should happen on the client-side, Spring Data Neo4j enables you to map also lists of lists of relationships or nodes.
109+
Still, the requirement applies that the returned record should contain all information to hydrate the resulting entity instance correctly.
110+
111+
[source,cypher]
112+
.Collect nodes and relationships from path
113+
----
114+
MATCH p=(m:Movie{title: 'The Matrix'})<-[:ACTED_IN]-(:Person)-[:ACTED_IN*..0]->(:Movie)
115+
RETURN m, collect(nodes(p)), collect(relationships(p));
116+
----
117+
118+
The additional `collect` statement creates lists in the format:
119+
----
120+
[[rel1, rel2], [rel3, rel4]]
121+
----
122+
Those lists will now get converted during the mapping process into a flat list.
123+
124+
NOTE: Deciding if you want to go with client-side or database-side reduction depends on the amount of data that will get generated.
125+
All the paths needs to get created in the database's memory first when the `reduce` function is used.
126+
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.
127+
52128
[[custom-queries.parameters]]
53129
== Parameters in custom queries
54130

src/main/asciidoc/appendix/query-creation.adoc

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -88,9 +88,8 @@ The above return part will then look like:
8888
The map projection and pattern comprehension used by SDN ensures that only the properties and relationships you have defined are getting queried.
8989

9090
In cases where you have self-referencing nodes or creating schemas that potentially lead to cycles in the data that gets returned,
91-
SDN falls back to a path-based query creation that does a wildcard mapping on all available relationship types reachable from
92-
the initial domain entity.
93-
94-
The return pattern looks similar to this:
95-
96-
`RETURN n{..., __paths__: [p = (n)-[:HAS|KNOWS*]-() | p]}`
91+
SDN falls back to a cascading / data-driven query creation.
92+
Starting with an initial query that looks for the specific node and considering the conditions,
93+
it steps through the resulting nodes and, if their relationships are also mapped, would create further queries on the fly.
94+
This query creation and execution loop will continue until no query finds new relationships or nodes.
95+
The way of the creation can be seen analogue to the save/update process.
74.3 KB
Loading

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -445,16 +445,17 @@ private Optional<Object> createInstanceOfRelationships(Neo4jPersistentProperty p
445445
Collection<Relationship> allMatchingTypeRelationshipsInResult = new ArrayList<>();
446446
Collection<Node> allNodesWithMatchingLabelInResult = new ArrayList<>();
447447

448-
// Grab everything else
448+
// Retrieve all relationships from the result's list(s)
449449
StreamSupport.stream(allValues.values().spliterator(), false)
450450
.filter(MappingSupport.isListContainingOnly(listType, this.relationshipType))
451-
.flatMap(entry -> entry.asList(Value::asRelationship).stream())
451+
.flatMap(entry -> MappingSupport.extractRelationships(listType, entry).stream())
452452
.filter(r -> r.type().equals(typeOfRelationship) || relationshipDescription.isDynamic())
453453
.forEach(allMatchingTypeRelationshipsInResult::add);
454454

455+
// Retrieve all nodes from the result's list(s)
455456
StreamSupport.stream(allValues.values().spliterator(), false)
456457
.filter(MappingSupport.isListContainingOnly(listType, this.nodeType))
457-
.flatMap(entry -> entry.asList(Value::asNode).stream())
458+
.flatMap(entry -> MappingSupport.extractNodes(listType, entry).stream())
458459
.filter(n -> n.hasLabel(targetLabel)).collect(Collectors.toList())
459460
.forEach(allNodesWithMatchingLabelInResult::add);
460461

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

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,20 @@
1616
package org.springframework.data.neo4j.core.mapping;
1717

1818
import java.util.AbstractMap.SimpleEntry;
19+
import java.util.ArrayList;
1920
import java.util.Collection;
2021
import java.util.Collections;
22+
import java.util.HashSet;
2123
import java.util.Map;
2224
import java.util.Objects;
25+
import java.util.function.Function;
2326
import java.util.function.Predicate;
2427
import java.util.stream.Collectors;
2528

2629
import org.apiguardian.api.API;
2730
import org.neo4j.driver.Value;
31+
import org.neo4j.driver.types.Node;
32+
import org.neo4j.driver.types.Relationship;
2833
import org.neo4j.driver.types.Type;
2934
import org.springframework.data.mapping.PersistentPropertyAccessor;
3035
import org.springframework.lang.Nullable;
@@ -80,8 +85,17 @@ public static Collection<?> unifyRelationshipValue(Neo4jPersistentProperty prope
8085
public static Predicate<Value> isListContainingOnly(Type collectionType, Type requiredType) {
8186

8287
Predicate<Value> containsOnlyRequiredType = entry -> {
88+
// either this is a list containing other list of possible the same required type
89+
// or the type exists directly in the list
8390
for (Value listEntry : entry.values()) {
84-
if (!listEntry.hasType(requiredType)) {
91+
if (listEntry.hasType(collectionType)) {
92+
boolean listInListCorrectType = true;
93+
for (Value listInListEntry : entry.asList(Function.identity())) {
94+
listInListCorrectType = listInListCorrectType && isListContainingOnly(collectionType, requiredType)
95+
.test(listInListEntry);
96+
}
97+
return listInListCorrectType;
98+
} else if (!listEntry.hasType(requiredType)) {
8599
return false;
86100
}
87101
}
@@ -92,6 +106,40 @@ public static Predicate<Value> isListContainingOnly(Type collectionType, Type re
92106
return isList.and(containsOnlyRequiredType);
93107
}
94108

109+
static Collection<Relationship> extractRelationships(Type collectionType, Value entry) {
110+
111+
Collection<Relationship> relationships = new HashSet<>();
112+
113+
for (Value listEntry : entry.values()) {
114+
if (listEntry.hasType(collectionType)) {
115+
for (Value listInListEntry : entry.asList(Function.identity())) {
116+
relationships.addAll(extractRelationships(collectionType, listInListEntry));
117+
}
118+
} else {
119+
relationships.add(listEntry.asRelationship());
120+
}
121+
}
122+
return relationships;
123+
}
124+
125+
static Collection<Node> extractNodes(Type collectionType, Value entry) {
126+
127+
// There can be multiple relationships leading to the same node.
128+
// Thus we need a collection implementation that supports duplicates.
129+
Collection<Node> nodes = new ArrayList<>();
130+
131+
for (Value listEntry : entry.values()) {
132+
if (listEntry.hasType(collectionType)) {
133+
for (Value listInListEntry : entry.asList(Function.identity())) {
134+
nodes.addAll(extractNodes(collectionType, listInListEntry));
135+
}
136+
} else {
137+
nodes.add(listEntry.asNode());
138+
}
139+
}
140+
return nodes;
141+
}
142+
95143
private MappingSupport() {}
96144

97145
/**

src/test/java/org/springframework/data/neo4j/integration/imperative/RepositoryIT.java

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1107,6 +1107,46 @@ void findEntityWithRelationshipViaQuery(@Autowired RelationshipRepository reposi
11071107

11081108
}
11091109

1110+
@Test
1111+
void findEntityWithRelationshipViaPathQuery(@Autowired RelationshipRepository repository) {
1112+
1113+
long personId;
1114+
long hobbyNodeId;
1115+
long petNode1Id;
1116+
long petNode2Id;
1117+
1118+
try (Session session = createSession()) {
1119+
Record record = session
1120+
.run("CREATE (n:PersonWithRelationship{name:'Freddie'})-[:Has]->(h1:Hobby{name:'Music'}), "
1121+
+ "(n)-[:Has]->(p1:Pet{name: 'Jerry'}), (n)-[:Has]->(p2:Pet{name: 'Tom'}) " + "RETURN n, h1, p1, p2")
1122+
.single();
1123+
1124+
Node personNode = record.get("n").asNode();
1125+
Node hobbyNode1 = record.get("h1").asNode();
1126+
Node petNode1 = record.get("p1").asNode();
1127+
Node petNode2 = record.get("p2").asNode();
1128+
1129+
personId = personNode.id();
1130+
hobbyNodeId = hobbyNode1.id();
1131+
petNode1Id = petNode1.id();
1132+
petNode2Id = petNode2.id();
1133+
}
1134+
1135+
PersonWithRelationship loadedPerson = repository.getPersonWithRelationshipsViaPathQuery();
1136+
assertThat(loadedPerson.getName()).isEqualTo("Freddie");
1137+
assertThat(loadedPerson.getId()).isEqualTo(personId);
1138+
Hobby hobby = loadedPerson.getHobbies();
1139+
assertThat(hobby).isNotNull();
1140+
assertThat(hobby.getId()).isEqualTo(hobbyNodeId);
1141+
assertThat(hobby.getName()).isEqualTo("Music");
1142+
1143+
List<Pet> pets = loadedPerson.getPets();
1144+
Pet comparisonPet1 = new Pet(petNode1Id, "Jerry");
1145+
Pet comparisonPet2 = new Pet(petNode2Id, "Tom");
1146+
assertThat(pets).containsExactlyInAnyOrder(comparisonPet1, comparisonPet2);
1147+
1148+
}
1149+
11101150
@Test
11111151
void findEntityWithRelationshipWithAssignedId(@Autowired PetRepository repository) {
11121152

@@ -3985,6 +4025,10 @@ interface RelationshipRepository extends Neo4jRepository<PersonWithRelationship,
39854025
+ "return n, petRels, pets, collect(r2) as hobbyRels, collect(h) as hobbies")
39864026
PersonWithRelationship getPersonWithRelationshipsViaQuery();
39874027

4028+
@Query("MATCH p=(n:PersonWithRelationship{name:'Freddie'})-[:Has*]->(something) "
4029+
+ "return n, collect(relationships(p)), collect(nodes(p))")
4030+
PersonWithRelationship getPersonWithRelationshipsViaPathQuery();
4031+
39884032
PersonWithRelationship findByPetsName(String petName);
39894033

39904034
PersonWithRelationship findByName(String name);

0 commit comments

Comments
 (0)