Skip to content

Commit dd174f7

Browse files
committed
GH-2345 - Add missing relationships during mapping.
If it is possible (the structure is mutable), SDN should try to add relationships to objects if those information come in subsequent records. closes #2345
1 parent 7798362 commit dd174f7

File tree

3 files changed

+212
-24
lines changed

3 files changed

+212
-24
lines changed

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

Lines changed: 123 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,8 @@ final class DefaultNeo4jEntityConverter implements Neo4jEntityConverter {
102102
public <R> R read(Class<R> targetType, MapAccessor mapAccessor) {
103103

104104
Neo4jPersistentEntity<R> rootNodeDescription = (Neo4jPersistentEntity) nodeDescriptionStore.getNodeDescription(targetType);
105+
knownObjects.nextRecord();
106+
105107
MapAccessor queryRoot = determineQueryRoot(mapAccessor, rootNodeDescription);
106108
if (queryRoot == null) {
107109
throw new NoRootNodeMappingException(String.format("Could not find mappable nodes or relationships inside %s for %s", mapAccessor, rootNodeDescription));
@@ -272,45 +274,77 @@ private <ET> ET map(MapAccessor queryResult, Neo4jPersistentEntity<ET> nodeDescr
272274
Neo4jPersistentEntity<ET> concreteNodeDescription = (Neo4jPersistentEntity<ET>) nodeDescriptionAndLabels
273275
.getNodeDescription();
274276

275-
boolean isKotlinType = KotlinDetector.isKotlinType(concreteNodeDescription.getType());
276277
ET instance = instantiate(concreteNodeDescription, queryResult,
277278
nodeDescriptionAndLabels.getDynamicLabels(), lastMappedEntity, relationshipsFromResult, nodesFromResult);
278279

279280
knownObjects.removeFromInCreation(internalId);
280-
PersistentPropertyAccessor<ET> propertyAccessor = concreteNodeDescription.getPropertyAccessor(instance);
281281

282-
if (concreteNodeDescription.requiresPropertyPopulation()) {
283-
284-
// Fill simple properties
285-
Predicate<Neo4jPersistentProperty> isConstructorParameter = concreteNodeDescription
286-
.getPersistenceConstructor()::isConstructorParameter;
287-
PropertyHandler<Neo4jPersistentProperty> handler = populateFrom(queryResult, propertyAccessor,
288-
isConstructorParameter, nodeDescriptionAndLabels.getDynamicLabels(), lastMappedEntity, isKotlinType);
289-
concreteNodeDescription.doWithProperties(handler);
290-
291-
// in a cyclic graph / with bidirectional relationships, we could end up in a state in which we
292-
// reference the start again. Because it is getting still constructed, it won't be in the knownObjects
293-
// store unless we temporarily put it there.
294-
knownObjects.storeObject(internalId, instance);
295-
// Fill associations
296-
concreteNodeDescription.doWithAssociations(
297-
populateFrom(queryResult, propertyAccessor, isConstructorParameter, relationshipsFromResult, nodesFromResult));
298-
}
282+
populateProperties(queryResult, nodeDescription, internalId, instance, lastMappedEntity, relationshipsFromResult, nodesFromResult, false);
283+
284+
PersistentPropertyAccessor<ET> propertyAccessor = concreteNodeDescription.getPropertyAccessor(instance);
299285
ET bean = propertyAccessor.getBean();
300286

301287
// save final state of the bean
302288
knownObjects.storeObject(internalId, bean);
303289
return bean;
304290
};
305291

306-
Object mappedObject = knownObjects.getObject(internalId);
292+
ET mappedObject = (ET) knownObjects.getObject(internalId);
307293
if (mappedObject == null) {
308-
mappedObject = mappedObjectSupplier.get();
294+
mappedObject = (ET) mappedObjectSupplier.get();
309295
knownObjects.storeObject(internalId, mappedObject);
296+
} else if (knownObjects.alreadyMappedInPreviousRecord(internalId)) {
297+
// If the object were created in a run before, it _could_ have missing relationships
298+
// (e.g. due to incomplete fetching by a custom query)
299+
// in such cases we will add the additional data from the next record.
300+
// This can and should only work for
301+
// 1. mutable owning types
302+
// AND (!!!)
303+
// 2. mutable target types
304+
// because we cannot just create new instances
305+
populateProperties(queryResult, nodeDescription, internalId, mappedObject, lastMappedEntity, relationshipsFromResult, nodesFromResult, true);
310306
}
311307
return (ET) mappedObject;
312308
}
313309

310+
311+
private <ET> void populateProperties(MapAccessor queryResult, Neo4jPersistentEntity<ET> nodeDescription, Long internalId,
312+
ET mappedObject, @Nullable Object lastMappedEntity,
313+
Collection<Relationship> relationshipsFromResult, Collection<Node> nodesFromResult, boolean objectAlreadyMapped) {
314+
315+
List<String> allLabels = getLabels(queryResult, nodeDescription);
316+
NodeDescriptionAndLabels nodeDescriptionAndLabels = nodeDescriptionStore
317+
.deriveConcreteNodeDescription(nodeDescription, allLabels);
318+
319+
@SuppressWarnings("unchecked")
320+
Neo4jPersistentEntity<ET> concreteNodeDescription = (Neo4jPersistentEntity<ET>) nodeDescriptionAndLabels
321+
.getNodeDescription();
322+
323+
if (!concreteNodeDescription.requiresPropertyPopulation()) {
324+
return;
325+
}
326+
327+
PersistentPropertyAccessor<ET> propertyAccessor = concreteNodeDescription.getPropertyAccessor(mappedObject);
328+
Predicate<Neo4jPersistentProperty> isConstructorParameter = concreteNodeDescription
329+
.getPersistenceConstructor()::isConstructorParameter;
330+
331+
// if the object were mapped before, we assume that at least all properties are populated
332+
if (!objectAlreadyMapped) {
333+
boolean isKotlinType = KotlinDetector.isKotlinType(concreteNodeDescription.getType());
334+
// Fill simple properties
335+
PropertyHandler<Neo4jPersistentProperty> handler = populateFrom(queryResult, propertyAccessor,
336+
isConstructorParameter, nodeDescriptionAndLabels.getDynamicLabels(), lastMappedEntity, isKotlinType);
337+
concreteNodeDescription.doWithProperties(handler);
338+
}
339+
// in a cyclic graph / with bidirectional relationships, we could end up in a state in which we
340+
// reference the start again. Because it is getting still constructed, it won't be in the knownObjects
341+
// store unless we temporarily put it there.
342+
knownObjects.storeObject(internalId, mappedObject);
343+
// Fill associations
344+
concreteNodeDescription.doWithAssociations(
345+
populateFrom(queryResult, propertyAccessor, isConstructorParameter, objectAlreadyMapped, relationshipsFromResult, nodesFromResult));
346+
}
347+
314348
@Nullable
315349
private Long getInternalId(@NonNull MapAccessor queryResult) {
316350
return queryResult instanceof Node
@@ -398,8 +432,9 @@ public Object getParameterValue(PreferredConstructor.Parameter parameter) {
398432
}
399433

400434
private PropertyHandler<Neo4jPersistentProperty> populateFrom(MapAccessor queryResult,
401-
PersistentPropertyAccessor<?> propertyAccessor, Predicate<Neo4jPersistentProperty> isConstructorParameter,
402-
Collection<String> surplusLabels, Object targetNode, boolean ownerIsKotlinType) {
435+
PersistentPropertyAccessor<?> propertyAccessor, Predicate<Neo4jPersistentProperty> isConstructorParameter,
436+
Collection<String> surplusLabels, @Nullable Object targetNode, boolean ownerIsKotlinType) {
437+
403438
return property -> {
404439
if (isConstructorParameter.test(property)) {
405440
return;
@@ -421,20 +456,55 @@ private PropertyHandler<Neo4jPersistentProperty> populateFrom(MapAccessor queryR
421456
};
422457
}
423458

459+
@Nullable
424460
private static Object getValueOrDefault(boolean ownerIsKotlinType, Class<?> rawType, @Nullable Object value) {
425461

426462
return value == null && !ownerIsKotlinType && rawType.isPrimitive() ? ReflectionUtils.getPrimitiveDefault(rawType) : value;
427463
}
428464

429465
private AssociationHandler<Neo4jPersistentProperty> populateFrom(MapAccessor queryResult,
430-
PersistentPropertyAccessor<?> propertyAccessor, Predicate<Neo4jPersistentProperty> isConstructorParameter, Collection<Relationship> relationshipsFromResult, Collection<Node> nodesFromResult) {
466+
PersistentPropertyAccessor<?> propertyAccessor, Predicate<Neo4jPersistentProperty> isConstructorParameter,
467+
boolean objectAlreadyMapped, Collection<Relationship> relationshipsFromResult, Collection<Node> nodesFromResult) {
468+
431469
return association -> {
432470

433471
Neo4jPersistentProperty persistentProperty = association.getInverse();
472+
434473
if (isConstructorParameter.test(persistentProperty)) {
435474
return;
436475
}
437476

477+
if (objectAlreadyMapped) {
478+
479+
// avoid multiple instances of the "same" object
480+
boolean willCreateNewInstance = persistentProperty.getWither() != null;
481+
if (willCreateNewInstance) {
482+
throw new MappingException("Cannot create a new instance of an already existing object.");
483+
}
484+
485+
Object propertyValue = propertyAccessor.getProperty(persistentProperty);
486+
487+
boolean propertyValueNotNull = propertyValue != null;
488+
489+
boolean populatedCollection = persistentProperty.isCollectionLike()
490+
&& propertyValueNotNull
491+
&& !((Collection<?>) propertyValue).isEmpty();
492+
493+
boolean populatedMap = persistentProperty.isMap()
494+
&& propertyValueNotNull
495+
&& !((Map<?, ?>) propertyValue).isEmpty();
496+
497+
boolean populatedScalarValue = !persistentProperty.isCollectionLike()
498+
&& propertyValueNotNull;
499+
500+
boolean propertyAlreadyPopulated = populatedCollection || populatedMap || populatedScalarValue;
501+
502+
// avoid unnecessary re-assignment of values
503+
if (propertyAlreadyPopulated) {
504+
return;
505+
}
506+
}
507+
438508
createInstanceOfRelationships(persistentProperty, queryResult, (RelationshipDescription) association, relationshipsFromResult, nodesFromResult)
439509
.ifPresent(value -> propertyAccessor.setProperty(persistentProperty, value));
440510
};
@@ -654,6 +724,7 @@ static class KnownObjects {
654724
private final Lock write = lock.writeLock();
655725

656726
private final Map<Long, Object> internalIdStore = new HashMap<>();
727+
private final Map<Long, Boolean> internalNextRecord = new HashMap<>();
657728
private final Set<Long> idsInCreation = new HashSet<>();
658729

659730
private void storeObject(@Nullable Long internalId, Object object) {
@@ -664,6 +735,7 @@ private void storeObject(@Nullable Long internalId, Object object) {
664735
write.lock();
665736
idsInCreation.remove(internalId);
666737
internalIdStore.put(internalId, object);
738+
internalNextRecord.put(internalId, false);
667739
} finally {
668740
write.unlock();
669741
}
@@ -725,5 +797,32 @@ private void removeFromInCreation(@Nullable Long internalId) {
725797
write.unlock();
726798
}
727799
}
800+
801+
private boolean alreadyMappedInPreviousRecord(@Nullable Long internalId) {
802+
if (internalId == null) {
803+
return false;
804+
}
805+
try {
806+
807+
read.lock();
808+
809+
Boolean nextRecord = internalNextRecord.get(internalId);
810+
811+
if (nextRecord != null) {
812+
return nextRecord;
813+
}
814+
815+
} finally {
816+
read.unlock();
817+
}
818+
return false;
819+
}
820+
821+
/**
822+
* Mark all currently existing objects as mapped.
823+
*/
824+
private void nextRecord() {
825+
internalNextRecord.replaceAll((x, y) -> true);
826+
}
728827
}
729828
}

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

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@
116116
import org.springframework.data.neo4j.integration.shared.common.FriendshipRelationship;
117117
import org.springframework.data.neo4j.integration.shared.common.Hobby;
118118
import org.springframework.data.neo4j.integration.shared.common.ImmutablePerson;
119+
import org.springframework.data.neo4j.integration.shared.common.ImmutablePet;
119120
import org.springframework.data.neo4j.integration.shared.common.Inheritance;
120121
import org.springframework.data.neo4j.integration.shared.common.KotlinPerson;
121122
import org.springframework.data.neo4j.integration.shared.common.LikesHobbyRelationship;
@@ -709,6 +710,27 @@ void customFindMapsDeepRelationships(@Autowired PetRepository repository) {
709710
assertThat(pet2.getFriends()).containsExactly(comparisonPet3);
710711
}
711712

713+
@Test // GH-2345
714+
void customFindHydratesIncompleteCustomQueryObjectsCorrect(@Autowired PetRepository repository) {
715+
doWithSession(session ->
716+
session.run("CREATE (:Pet{name: 'Luna'})-[:Has]->(:Pet{name:'Luna'})-[:Has]->(:Pet{name:'Daphne'})").consume()
717+
);
718+
719+
List<Pet> pets = repository.findLunas();
720+
assertThat(pets).hasSize(2);
721+
722+
assertThat(pets).allMatch(pet -> !pet.getFriends().isEmpty());
723+
}
724+
725+
@Test // GH-2345
726+
void customFindFailsOnHydrationOfCustomQueryObjectsIfImmutable(@Autowired ImmutablePetRepository repository) {
727+
doWithSession(session ->
728+
session.run("CREATE (:ImmutablePet{name: 'Luna'})-[:Has]->(:ImmutablePet{name:'Luna'})-[:Has]->(:ImmutablePet{name:'Daphne'})").consume()
729+
);
730+
731+
assertThatExceptionOfType(MappingException.class).isThrownBy(repository::findLunas);
732+
}
733+
712734
@Test // DATAGRAPH-1409
713735
void findPageWithCustomQuery(@Autowired PetRepository repository) {
714736

@@ -4128,6 +4150,16 @@ interface PetRepository extends Neo4jRepository<Pet, Long> {
41284150
long countByFriendsNameAndFriendsFriendsName(String friendName, String friendFriendName);
41294151

41304152
boolean existsByName(String name);
4153+
4154+
@Query("MATCH (n:Pet) where n.name='Luna' OPTIONAL MATCH (n)-[r:Has]->(m:Pet) return n, collect(r), collect(m)")
4155+
List<Pet> findLunas();
4156+
}
4157+
4158+
interface ImmutablePetRepository extends Neo4jRepository<ImmutablePet, Long> {
4159+
4160+
@Query("MATCH (n:ImmutablePet) where n.name='Luna' OPTIONAL MATCH (n)-[r:Has]->(m:ImmutablePet) return n, collect(r), collect(m)")
4161+
List<ImmutablePet> findLunas();
4162+
41314163
}
41324164

41334165
interface OneToOneRepository extends Neo4jRepository<OneToOneSource, String> {
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
/*
2+
* Copyright 2011-2021 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.shared.common;
17+
18+
import org.springframework.data.annotation.PersistenceConstructor;
19+
import org.springframework.data.neo4j.core.schema.GeneratedValue;
20+
import org.springframework.data.neo4j.core.schema.Id;
21+
import org.springframework.data.neo4j.core.schema.Node;
22+
import org.springframework.data.neo4j.core.schema.Relationship;
23+
24+
import java.util.Set;
25+
26+
/**
27+
* @author Gerrit Meier
28+
*/
29+
@Node
30+
public class ImmutablePet {
31+
32+
@Id
33+
@GeneratedValue
34+
public final Long id;
35+
36+
public final String name;
37+
38+
@Relationship("Has")
39+
public final Set<ImmutablePet> friends;
40+
41+
@PersistenceConstructor
42+
public ImmutablePet(Long id, String name) {
43+
this.id = id;
44+
this.name = name;
45+
this.friends = null;
46+
}
47+
48+
public ImmutablePet(Long id, String name, Set<ImmutablePet> friends) {
49+
this.id = id;
50+
this.name = name;
51+
this.friends = friends;
52+
}
53+
54+
public ImmutablePet withFriends(Set<ImmutablePet> newFriends) {
55+
return new ImmutablePet(id, name, newFriends);
56+
}
57+
}

0 commit comments

Comments
 (0)