Skip to content

Commit 74764c0

Browse files
committed
GH-2340 - Clear exception if mapping recursive immutable objects.
Also improving the documentation around this topic. Closes #2340
1 parent cc0db8d commit 74764c0

File tree

4 files changed

+74
-12
lines changed

4 files changed

+74
-12
lines changed

src/main/asciidoc/object-mapping/sdc-object-mapping.adoc

+10
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,16 @@ It's an established pattern to rather use static factory methods to expose these
222222
* _Use Lombok to avoid boilerplate code_ --
223223
As persistence operations usually require a constructor taking all arguments, their declaration becomes a tedious repetition of boilerplate parameter to field assignments that can best be avoided by using Lombok's `@AllArgsConstructor`.
224224

225+
[[mapping.fundamentals.recommendations.note-immutable]]
226+
=== A note on immutable mapping
227+
228+
Although we recommend to use immutable mapping and constructs wherever possible, there are some limitations when it comes to mapping.
229+
Given a bidirectional relationship where `A` has a constructor reference to `B` and `B` has a reference to `A`, or a more complex scenario.
230+
This hen/egg situation is not solvable for Spring Data Neo4j.
231+
During the instantiation of `A` it eagerly needs to have a fully instantiated `B`, which on the other hand requires an instance (to be precise, the _same_ instance) of `A`.
232+
SDN allows such models in general, but will throw a `MappingException` at runtime if the data that gets returned from the database contains such constellation as described above.
233+
In such cases or scenarios, where you cannot foresee what the data that gets returned looks like, you are better suited with a mutable field for the relationships.
234+
225235
[[mapping.fundamentals.kotlin]]
226236
== Kotlin support
227237

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

+48
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,15 @@ private <ET> ET map(MapAccessor queryResult, Neo4jPersistentEntity<ET> nodeDescr
254254
Long internalId = getInternalId(queryResult);
255255

256256
Supplier<Object> mappedObjectSupplier = () -> {
257+
if (knownObjects.isInCreation(internalId)) {
258+
throw new MappingException(
259+
String.format(
260+
"The node with id %s has a logical cyclic mapping dependency. " +
261+
"Its creation caused the creation of another node that has a reference to this.",
262+
internalId)
263+
);
264+
}
265+
knownObjects.setInCreation(internalId);
257266

258267
List<String> allLabels = getLabels(queryResult, nodeDescription);
259268
NodeDescriptionAndLabels nodeDescriptionAndLabels = nodeDescriptionStore
@@ -264,6 +273,7 @@ private <ET> ET map(MapAccessor queryResult, Neo4jPersistentEntity<ET> nodeDescr
264273
ET instance = instantiate(concreteNodeDescription, queryResult,
265274
nodeDescriptionAndLabels.getDynamicLabels(), lastMappedEntity, relationshipsFromResult, nodesFromResult);
266275

276+
knownObjects.removeFromInCreation(internalId);
267277
PersistentPropertyAccessor<ET> propertyAccessor = concreteNodeDescription.getPropertyAccessor(instance);
268278

269279
if (concreteNodeDescription.requiresPropertyPopulation()) {
@@ -634,19 +644,45 @@ static class KnownObjects {
634644
private final Lock write = lock.writeLock();
635645

636646
private final Map<Long, Object> internalIdStore = new HashMap<>();
647+
private final Set<Long> idsInCreation = new HashSet<>();
637648

638649
private void storeObject(@Nullable Long internalId, Object object) {
639650
if (internalId == null) {
640651
return;
641652
}
642653
try {
643654
write.lock();
655+
idsInCreation.remove(internalId);
644656
internalIdStore.put(internalId, object);
645657
} finally {
646658
write.unlock();
647659
}
648660
}
649661

662+
private void setInCreation(@Nullable Long internalId) {
663+
if (internalId == null) {
664+
return;
665+
}
666+
try {
667+
write.lock();
668+
idsInCreation.add(internalId);
669+
} finally {
670+
write.unlock();
671+
}
672+
}
673+
674+
private boolean isInCreation(@Nullable Long internalId) {
675+
if (internalId == null) {
676+
return false;
677+
}
678+
try {
679+
read.lock();
680+
return idsInCreation.contains(internalId);
681+
} finally {
682+
read.unlock();
683+
}
684+
}
685+
650686
@Nullable
651687
private Object getObject(@Nullable Long internalId) {
652688
if (internalId == null) {
@@ -667,5 +703,17 @@ private Object getObject(@Nullable Long internalId) {
667703
}
668704
return null;
669705
}
706+
707+
private void removeFromInCreation(@Nullable Long internalId) {
708+
if (internalId == null) {
709+
return;
710+
}
711+
try {
712+
write.lock();
713+
idsInCreation.remove(internalId);
714+
} finally {
715+
write.unlock();
716+
}
717+
}
670718
}
671719
}

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

+7-9
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import static org.assertj.core.api.Assertions.assertThat;
1919
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
2020
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
21+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
2122
import static org.assertj.core.api.Assertions.tuple;
2223

2324
import java.time.Instant;
@@ -78,6 +79,7 @@
7879
import org.springframework.data.geo.Distance;
7980
import org.springframework.data.geo.Metrics;
8081
import org.springframework.data.geo.Polygon;
82+
import org.springframework.data.mapping.MappingException;
8183
import org.springframework.data.neo4j.config.AbstractNeo4jConfig;
8284
import org.springframework.data.neo4j.core.DatabaseSelection;
8385
import org.springframework.data.neo4j.core.DatabaseSelectionProvider;
@@ -977,21 +979,17 @@ void findEntityWithRelationshipToTheSameNode(@Autowired RelationshipRepository r
977979
}
978980

979981
@Test
980-
void findEntityWithBidirectionalRelationship(@Autowired BidirectionalStartRepository repository) {
982+
void findEntityWithBidirectionalRelationshipInConstructorThrowsException(@Autowired BidirectionalStartRepository repository) {
981983

982984
long startId = doWithSession(session -> session
983985
.run("CREATE (n:BidirectionalStart{name:'Ernie'})-[:CONNECTED]->(e:BidirectionalEnd{name:'Bert'}), "
984986
+ "(e)<-[:ANOTHER_CONNECTION]-(anotherStart:BidirectionalStart{name:'Elmo'})" + "RETURN n")
985987
.single().get("n").asNode().id());
986988

987-
Optional<BidirectionalStart> entityOptional = repository.findById(startId);
988-
assertThat(entityOptional).isPresent();
989-
BidirectionalStart entity = entityOptional.get();
990-
assertThat(entity.getEnds()).hasSize(1);
991-
992-
BidirectionalEnd end = entity.getEnds().iterator().next();
993-
assertThat(end.getAnotherStart()).isNotNull();
994-
assertThat(end.getAnotherStart().getName()).isEqualTo("Elmo");
989+
assertThatThrownBy(() -> repository.findById(startId))
990+
.hasRootCauseMessage("The node with id " + startId + " has a logical cyclic mapping dependency. " +
991+
"Its creation caused the creation of another node that has a reference to this.")
992+
.hasRootCauseInstanceOf(MappingException.class);
995993

996994
}
997995

src/test/java/org/springframework/data/neo4j/integration/reactive/ReactiveRepositoryIT.java

+9-3
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
2020
import static org.assertj.core.api.Assertions.tuple;
2121

22+
import org.springframework.data.mapping.MappingException;
2223
import reactor.core.publisher.Flux;
2324
import reactor.core.publisher.Mono;
2425
import reactor.test.StepVerifier;
@@ -829,9 +830,14 @@ void loadEntityWithBidirectionalRelationship(@Autowired BidirectionalStartReposi
829830
return startNode.id();
830831
});
831832

832-
StepVerifier.create(repository.findById(startId)).assertNext(entity -> {
833-
assertThat(entity.getEnds()).hasSize(1);
834-
}).verifyComplete();
833+
StepVerifier.create(repository.findById(startId))
834+
.verifyErrorMatches(error -> {
835+
Throwable cause = error.getCause();
836+
return cause instanceof MappingException && cause.getMessage().equals(
837+
"The node with id " + startId + " has a logical cyclic mapping dependency. " +
838+
"Its creation caused the creation of another node that has a reference to this.");
839+
});
840+
835841
}
836842

837843
@Test

0 commit comments

Comments
 (0)