Skip to content

Commit f5d93fa

Browse files
committed
GH-2858 - Add re-population of properties and relationships during mapping.
Prior to this, an incomplete loaded entity, due to projection, was never touched again to add missing properties loaded via a different relationship and projection definition. Closes #2858
1 parent e50fd2e commit f5d93fa

File tree

5 files changed

+197
-28
lines changed

5 files changed

+197
-28
lines changed

src/main/java/org/springframework/data/neo4j/core/Neo4jTemplate.java

-1
Original file line numberDiff line numberDiff line change
@@ -1202,7 +1202,6 @@ private NodesAndRelationshipsByIdStatementProvider createNodesAndRelationshipsBy
12021202
return NodesAndRelationshipsByIdStatementProvider.EMPTY;
12031203
}
12041204
// load first level relationships
1205-
// final Set<String> relationshipIds = new HashSet<>();
12061205
final Map<String, Set<String>> relationshipsToRelatedNodeIds = new HashMap<>();
12071206

12081207
for (RelationshipDescription relationshipDescription : entityMetaData.getRelationshipsInHierarchy(queryFragments::includeField)) {

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

+66-27
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
import java.util.Map;
2626
import java.util.Optional;
2727
import java.util.Set;
28+
import java.util.concurrent.ConcurrentHashMap;
2829
import java.util.concurrent.locks.Lock;
2930
import java.util.concurrent.locks.ReentrantReadWriteLock;
3031
import java.util.function.BiConsumer;
@@ -334,6 +335,7 @@ private <ET> ET map(MapAccessor queryResult, Neo4jPersistentEntity<ET> nodeDescr
334335

335336
// save final state of the bean
336337
knownObjects.storeObject(internalId, bean);
338+
knownObjects.mappedWithQueryResult(internalId, queryResult);
337339
return bean;
338340
};
339341

@@ -342,21 +344,36 @@ private <ET> ET map(MapAccessor queryResult, Neo4jPersistentEntity<ET> nodeDescr
342344
if (mappedObject == null) {
343345
mappedObject = mappedObjectSupplier.get();
344346
knownObjects.storeObject(internalId, mappedObject);
345-
} else if (knownObjects.alreadyMappedInPreviousRecord(internalId)) {
346-
// If the object were created in a run before, it _could_ have missing relationships
347-
// (e.g. due to incomplete fetching by a custom query)
348-
// in such cases we will add the additional data from the next record.
347+
knownObjects.mappedWithQueryResult(internalId, queryResult);
348+
} else if (knownObjects.alreadyMappedInPreviousRecord(internalId) || hasMoreFields(queryResult.asMap(), knownObjects.getQueryResultsFor(internalId))) {
349+
// If the object were created in a run before or from a different path that represents another projection,
350+
// it _could_ have missing relationships and properties.
351+
// In such cases, we will add the additional data from the next record.
349352
// This can and should only work for
350-
// 1. mutable owning types
353+
// 1. Mutable owning types
351354
// AND (!!!)
352-
// 2. mutable target types
355+
// 2. Mutable target types
353356
// because we cannot just create new instances
354357
populateProperties(queryResult, (Neo4jPersistentEntity<ET>) genericTargetNodeDescription, nodeDescription, internalId, mappedObject, lastMappedEntity, relationshipsFromResult, nodesFromResult, true);
355358
}
356359
// due to a needed side effect in `populateProperties`, the entity might have been changed
357360
return getMostCurrentInstance(internalId, mappedObject);
358361
}
359362

363+
private boolean hasMoreFields(Map<String, Object> currentQueryResult, Set<Map<String, Object>> savedQueryResults) {
364+
if (savedQueryResults.isEmpty()) {
365+
return true;
366+
}
367+
Set<String> currentFields = new HashSet<>(currentQueryResult.keySet());
368+
Set<String> alreadyProcessedFields = new HashSet<>();
369+
370+
for (Map<String, Object> savedQueryResult : savedQueryResults) {
371+
alreadyProcessedFields.addAll(savedQueryResult.keySet());
372+
}
373+
currentFields.removeAll(alreadyProcessedFields);
374+
return !currentFields.isEmpty();
375+
}
376+
360377
@Nullable
361378
private <ET> ET getMostCurrentInstance(String internalId, ET fallbackInstance) {
362379
return (ET) (knownObjects.getObject(internalId) != null ? knownObjects.getObject(internalId) : fallbackInstance);
@@ -383,21 +400,19 @@ private <ET> void populateProperties(MapAccessor queryResult, Neo4jPersistentEnt
383400
Predicate<Neo4jPersistentProperty> isConstructorParameter = concreteNodeDescription
384401
.getInstanceCreatorMetadata()::isCreatorParameter;
385402

386-
// if the object were mapped before, we assume that at least all properties are populated
387-
if (!objectAlreadyMapped) {
388-
boolean isKotlinType = KotlinDetector.isKotlinType(concreteNodeDescription.getType());
389-
// Fill simple properties
390-
PropertyHandler<Neo4jPersistentProperty> handler = populateFrom(queryResult, propertyAccessor,
391-
isConstructorParameter, nodeDescriptionAndLabels.getDynamicLabels(), lastMappedEntity, isKotlinType);
392-
PropertyHandlerSupport.of(concreteNodeDescription).doWithProperties(handler);
393-
}
403+
boolean isKotlinType = KotlinDetector.isKotlinType(concreteNodeDescription.getType());
404+
// Fill simple properties
405+
PropertyHandler<Neo4jPersistentProperty> handler = populateFrom(queryResult, propertyAccessor,
406+
isConstructorParameter, nodeDescriptionAndLabels.getDynamicLabels(), lastMappedEntity, isKotlinType, objectAlreadyMapped);
407+
PropertyHandlerSupport.of(concreteNodeDescription).doWithProperties(handler);
394408
// in a cyclic graph / with bidirectional relationships, we could end up in a state in which we
395409
// reference the start again. Because it is getting still constructed, it won't be in the knownObjects
396410
// store unless we temporarily put it there.
397411
knownObjects.storeObject(internalId, propertyAccessor.getBean());
412+
knownObjects.mappedWithQueryResult(internalId, queryResult);
398413

399414
AssociationHandlerSupport.of(concreteNodeDescription).doWithAssociations(
400-
populateFrom(queryResult, baseNodeDescription, propertyAccessor, isConstructorParameter, objectAlreadyMapped, relationshipsFromResult, nodesFromResult));
415+
populateFrom(queryResult, baseNodeDescription, propertyAccessor, isConstructorParameter, objectAlreadyMapped, relationshipsFromResult, nodesFromResult, internalId));
401416
}
402417

403418
@NonNull
@@ -499,22 +514,25 @@ public <T> T getParameterValue(Parameter<T, Neo4jPersistentProperty> parameter)
499514

500515
private PropertyHandler<Neo4jPersistentProperty> populateFrom(MapAccessor queryResult,
501516
PersistentPropertyAccessor<?> propertyAccessor, Predicate<Neo4jPersistentProperty> isConstructorParameter,
502-
Collection<String> surplusLabels, @Nullable Object targetNode, boolean ownerIsKotlinType) {
517+
Collection<String> surplusLabels, @Nullable Object targetNode, boolean ownerIsKotlinType, boolean objectAlreadyMapped) {
503518

504519
return property -> {
505520
if (isConstructorParameter.test(property)) {
506521
return;
507522
}
508523

509524
TypeInformation<?> typeInformation = property.getTypeInformation();
510-
if (property.isDynamicLabels()) {
511-
propertyAccessor.setProperty(property,
512-
createDynamicLabelsProperty(typeInformation, surplusLabels));
513-
} else if (property.isAnnotationPresent(TargetNode.class)) {
514-
if (queryResult instanceof Relationship) {
515-
propertyAccessor.setProperty(property, targetNode);
525+
if (!objectAlreadyMapped) {
526+
if (property.isDynamicLabels()) {
527+
propertyAccessor.setProperty(property,
528+
createDynamicLabelsProperty(typeInformation, surplusLabels));
529+
} else if (property.isAnnotationPresent(TargetNode.class)) {
530+
if (queryResult instanceof Relationship) {
531+
propertyAccessor.setProperty(property, targetNode);
532+
}
516533
}
517-
} else {
534+
}
535+
if (!property.isDynamicLabels() && !property.isAnnotationPresent(TargetNode.class)) {
518536
Object value = conversionService.readValue(extractValueOf(property, queryResult), typeInformation, property.getOptionalConverter());
519537
if (value != null) {
520538
Class<?> rawType = typeInformation.getType();
@@ -532,7 +550,7 @@ private static Object getValueOrDefault(boolean ownerIsKotlinType, Class<?> rawT
532550

533551
private AssociationHandler<Neo4jPersistentProperty> populateFrom(MapAccessor queryResult, NodeDescription<?> baseDescription,
534552
PersistentPropertyAccessor<?> propertyAccessor, Predicate<Neo4jPersistentProperty> isConstructorParameter,
535-
boolean objectAlreadyMapped, Collection<Relationship> relationshipsFromResult, Collection<Node> nodesFromResult) {
553+
boolean objectAlreadyMapped, Collection<Relationship> relationshipsFromResult, Collection<Node> nodesFromResult, String internalId) {
536554

537555
return association -> {
538556

@@ -555,15 +573,15 @@ private AssociationHandler<Neo4jPersistentProperty> populateFrom(MapAccessor que
555573

556574
boolean propertyValueNotNull = propertyValue != null;
557575

558-
boolean populatedCollection = persistentProperty.isCollectionLike()
576+
boolean populatedCollection = objectAlreadyMapped && persistentProperty.isCollectionLike()
559577
&& propertyValueNotNull
560578
&& !((Collection<?>) propertyValue).isEmpty();
561579

562-
boolean populatedMap = persistentProperty.isMap()
580+
boolean populatedMap = objectAlreadyMapped && persistentProperty.isMap()
563581
&& propertyValueNotNull
564582
&& !((Map<?, ?>) propertyValue).isEmpty();
565583

566-
boolean populatedScalarValue = !persistentProperty.isCollectionLike() && !persistentProperty.isMap()
584+
boolean populatedScalarValue = objectAlreadyMapped && !persistentProperty.isCollectionLike() && !persistentProperty.isMap()
567585
&& propertyValueNotNull;
568586

569587
if (populatedCollection) {
@@ -592,6 +610,7 @@ private AssociationHandler<Neo4jPersistentProperty> populateFrom(MapAccessor que
592610

593611
createInstanceOfRelationships(persistentProperty, queryResult, (RelationshipDescription) association, baseDescription, relationshipsFromResult, nodesFromResult)
594612
.ifPresent(value -> propertyAccessor.setProperty(persistentProperty, value));
613+
595614
};
596615
}
597616

@@ -903,6 +922,7 @@ static class KnownObjects {
903922
private final Set<String> idsInCreation = new HashSet<>();
904923

905924
private final Map<String, Integer> processedRelationships = new HashMap<>();
925+
private final Map<String, Set<Map<String, Object>>> mappedQueryResults = new HashMap<>();
906926

907927
private void storeObject(@Nullable String internalId, Object object) {
908928
if (internalId == null) {
@@ -1029,5 +1049,24 @@ private void nextRecord() {
10291049
previousRecords.addAll(internalCurrentRecord.keySet());
10301050
internalCurrentRecord.clear();
10311051
}
1052+
1053+
private void mappedWithQueryResult(String internalId, MapAccessor queryResult) {
1054+
try {
1055+
write.lock();
1056+
mappedQueryResults.computeIfAbsent(internalId, id -> ConcurrentHashMap.newKeySet())
1057+
.add(queryResult.asMap());
1058+
} finally {
1059+
write.unlock();
1060+
}
1061+
}
1062+
1063+
private Set<Map<String, Object>> getQueryResultsFor(String internalId) {
1064+
try {
1065+
read.lock();
1066+
return mappedQueryResults.get(internalId);
1067+
} finally {
1068+
read.unlock();
1069+
}
1070+
}
10321071
}
10331072
}

src/test/java/org/springframework/data/neo4j/integration/issues/IssuesIT.java

+38
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,8 @@
145145
import org.springframework.data.neo4j.integration.issues.gh2639.Sales;
146146
import org.springframework.data.neo4j.integration.issues.gh2819.GH2819Model;
147147
import org.springframework.data.neo4j.integration.issues.gh2819.GH2819Repository;
148+
import org.springframework.data.neo4j.integration.issues.gh2858.GH2858;
149+
import org.springframework.data.neo4j.integration.issues.gh2858.GH2858Repository;
148150
import org.springframework.data.neo4j.integration.issues.qbe.A;
149151
import org.springframework.data.neo4j.integration.issues.qbe.ARepository;
150152
import org.springframework.data.neo4j.integration.issues.qbe.B;
@@ -1124,6 +1126,42 @@ void inheritanceAndProjectionShouldMapRelatedNodesCorrectly(@Autowired GH2819Rep
11241126

11251127
}
11261128

1129+
@Test
1130+
@Tag("GH-2858")
1131+
void hydrateProjectionReachableViaMultiplePaths(@Autowired GH2858Repository repository) {
1132+
GH2858 entity = new GH2858();
1133+
entity.name = "rootEntity";
1134+
1135+
GH2858 friend1 = new GH2858();
1136+
friend1.name = "friend1";
1137+
1138+
GH2858 friendAndRelative = new GH2858();
1139+
friendAndRelative.name = "friendAndRelative";
1140+
1141+
// root -> friend1 -> friendAndRelative
1142+
// \ /|
1143+
// -------------------
1144+
friend1.friends = List.of(friendAndRelative);
1145+
entity.relatives = List.of(friendAndRelative);
1146+
entity.friends = List.of(friend1);
1147+
1148+
GH2858 savedEntity = repository.save(entity);
1149+
1150+
GH2858.GH2858Projection projection = repository.findOneByName(savedEntity.name);
1151+
1152+
assertThat(projection.getFriends()).hasSize(1);
1153+
assertThat(projection.getRelatives()).hasSize(1);
1154+
1155+
GH2858.GH2858Projection.Friend loadedFriend = projection.getFriends().get(0);
1156+
assertThat(loadedFriend.getName()).isEqualTo("friend1");
1157+
assertThat(loadedFriend.getFriends()).hasSize(1);
1158+
GH2858.GH2858Projection.KnownPerson friendOfFriend = loadedFriend.getFriends().get(0);
1159+
assertThat(friendOfFriend.getName()).isEqualTo("friendAndRelative");
1160+
1161+
assertThat(projection.getRelatives().get(0).getName()).isEqualTo(friendOfFriend.getName());
1162+
1163+
}
1164+
11271165
@Configuration
11281166
@EnableTransactionManagement
11291167
@EnableNeo4jRepositories(namedQueriesLocation = "more-custom-queries.properties")
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/*
2+
* Copyright 2011-2024 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.springframework.data.neo4j.integration.issues.gh2858;
17+
18+
import org.springframework.data.neo4j.core.schema.GeneratedValue;
19+
import org.springframework.data.neo4j.core.schema.Id;
20+
import org.springframework.data.neo4j.core.schema.Node;
21+
import org.springframework.data.neo4j.core.schema.Relationship;
22+
23+
import java.util.List;
24+
25+
/**
26+
* @author Gerrit Meier
27+
*/
28+
@Node
29+
public class GH2858 {
30+
31+
@Id
32+
@GeneratedValue
33+
public String id;
34+
35+
public String name;
36+
37+
@Relationship("FRIEND_WITH")
38+
public List<GH2858> friends;
39+
40+
@Relationship("RELATED_TO")
41+
public List<GH2858> relatives;
42+
43+
/**
44+
* Projection of GH2858 entity
45+
*/
46+
public interface GH2858Projection {
47+
String getName();
48+
List<Friend> getFriends();
49+
List<KnownPerson> getRelatives();
50+
51+
/**
52+
* Additional projection with just the name field.
53+
*/
54+
interface KnownPerson {
55+
String getName();
56+
}
57+
58+
/**
59+
* Additional projection with name field and friends relationship.
60+
*/
61+
interface Friend {
62+
String getName();
63+
List<KnownPerson> getFriends();
64+
}
65+
}
66+
67+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
/*
2+
* Copyright 2011-2024 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.springframework.data.neo4j.integration.issues.gh2858;
17+
18+
import org.springframework.data.neo4j.repository.Neo4jRepository;
19+
20+
/**
21+
* @author Gerrit Meier
22+
*/
23+
public interface GH2858Repository extends Neo4jRepository<GH2858, String> {
24+
25+
GH2858.GH2858Projection findOneByName(String name);
26+
}

0 commit comments

Comments
 (0)