Skip to content

Commit 62496fc

Browse files
GH-2201 - Allow interfaces in domain model hierachies.
While technically possible, Spring Data Neo4j prevented a proper use of interfaces in domain hierachies due to the fact that it checks whether a primary label has been used already in a domain model. That check can be triggered at different point in times: Either when initializing the persistence context with a predefined set of entities or when an interface based entity is refered from an implementation of itself. This commit changes the following approaches: * Interfaces won't contribute to the index of entities by primary label * The explicit or implicit names of interfaces implemented by an entity will contribute to the list of additional labels. However, we don't traverse the graph of interfaces to the top * When hydrating instances we must check whether the target is an interface. If so, we go through the list of all known persistent entities and look for one that can be assigned to the interface and which spots all the mapped labels In addition we fixed some bugs when selecting the target entity type when saving relationships. Also, `@Relationship` without a given relationship type name will work now without messing up the type name. This allows for the following scenarios: 1. Creating an API module being free of Spring or Neo4j based annotations. That API module can than be implemented by different Spring Data stores that support interfaces in their hierachies like we do now then. 2. Putting an explicit `@Node` annotation onto an interface so that the implementation can be arbitrary named (Which is probably a rarer use case). 3. Using polymorpmic relationships: If an interface has multiple implementations in the same project, the interface and it's implementations can both be annotated. The annotation on the interface is going to be the primary label and the labels on the annotated implementations are the selectors for picking out the class to hydrate during loading. This closes #2201.
1 parent 38a269e commit 62496fc

File tree

14 files changed

+834
-108
lines changed

14 files changed

+834
-108
lines changed

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

+45
Original file line numberDiff line numberDiff line change
@@ -74,12 +74,57 @@ The primary label should always be the most concrete label that reflects your do
7474
For each instance of an annotated class that is written through a repository or through the Neo4j template, one node in the graph with at least the primary label will be written.
7575
Vice versa, all nodes with the primary label will be mapped to the instances of the annotated class.
7676

77+
==== A note on class hierarchies
78+
7779
The `@Node` annotation is not inherited from super-types and interfaces.
7880
You can however annotate your domain classes individually at every inheritance level.
7981
This allows polymorphic queries: You can pass in base or intermediate classes and retrieve the correct, concrete instance for your nodes.
8082
This is only supported for abstract bases annotated with `@Node`.
8183
The labels defined on such a class will be used as additional labels together with the labels of the concrete implementations.
8284

85+
We also support interfaces in domain-class-hierarchies for some scenarios:
86+
87+
.Domain model in a separate module, same primary label like the interface name
88+
[source,java,indent=0,tabsize=4]
89+
----
90+
include::../../../../src/test/java/org/springframework/data/neo4j/integration/shared/common/Inheritance.java[tag=interface1]
91+
----
92+
<.> Just the plain interface name, as you would name your domain
93+
<.> As we need to synchronize the primary labels, we put `@Node` on the implementing class, which
94+
is probably in another module. Note that the value is exactly the same as the name of the interface
95+
implemented. Renaming is not possible.
96+
97+
Using a different primary label instead of the interface name is possible, too:
98+
99+
.Different primary label
100+
[source,java,indent=0,tabsize=4]
101+
----
102+
include::../../../../src/test/java/org/springframework/data/neo4j/integration/shared/common/Inheritance.java[tag=interface2]
103+
----
104+
<.> Put the `@Node` annotation on the interface
105+
106+
It's also possible to use different implementations of an interface and have a polymorph domain model.
107+
When doing so, at least two labels are required: A label determining the interface and one determining the concrete class:
108+
109+
.Multiple implementations
110+
[source,java,indent=0,tabsize=4]
111+
----
112+
include::../../../../src/test/java/org/springframework/data/neo4j/integration/shared/common/Inheritance.java[tag=interface3]
113+
----
114+
<.> Explicitly specifying the label that identifies the interface is required in this scenario
115+
<.> Which applies for the first…
116+
<.> and second implementation as well
117+
<.> This is a client or parent model, using `SomeInterface3` transparently for two relationships
118+
<.> No concrete type is specified
119+
120+
The data structure needed is shown in the following test. The same would be written by the OGM:
121+
122+
.Data structure needed for using multiple, different interface implementations
123+
[source,java,indent=0,tabsize=4]
124+
----
125+
include::../../../../src/test/java/org/springframework/data/neo4j/integration/imperative/InheritanceMappingIT.java[tag=interface3]
126+
----
127+
83128
[[mapping.annotations.node.dynamic.labels]]
84129
==== Dynamic or "runtime" managed labels
85130

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

+3-4
Original file line numberDiff line numberDiff line change
@@ -526,8 +526,7 @@ private <T> T processNestedRelations(Neo4jPersistentEntity<?> sourceEntity, Obje
526526
if (stateMachine.hasProcessedValue(relatedValueToStore)) {
527527
relatedInternalId = queryRelatedNode(relatedNode, targetEntity, inDatabase);
528528
} else {
529-
relatedInternalId = saveRelatedNode(relatedNode, relationshipContext.getAssociationTargetType(),
530-
targetEntity, inDatabase);
529+
relatedInternalId = saveRelatedNode(relatedNode, targetEntity, inDatabase);
531530
}
532531
stateMachine.markValueAsProcessed(relatedValueToStore);
533532

@@ -589,11 +588,11 @@ private <Y> Long queryRelatedNode(Object entity, Neo4jPersistentEntity<?> target
589588
.fetchAs(Long.class).one().get();
590589
}
591590

592-
private <Y> Long saveRelatedNode(Object entity, Class<Y> entityType, NodeDescription targetNodeDescription,
593-
@Nullable String inDatabase) {
591+
private <Y> Long saveRelatedNode(Object entity, NodeDescription targetNodeDescription, @Nullable String inDatabase) {
594592

595593
DynamicLabels dynamicLabels = determineDynamicLabels(entity, (Neo4jPersistentEntity) targetNodeDescription,
596594
inDatabase);
595+
Class<Y> entityType = (Class<Y>) ((Neo4jPersistentEntity<?>) targetNodeDescription).getType();
597596
Optional<Long> optionalSavedNodeId = neo4jClient
598597
.query(() -> renderer.render(cypherGenerator.prepareSaveOf(targetNodeDescription, dynamicLabels)))
599598
.in(inDatabase).bind((Y) entity).with(neo4jMappingContext.getRequiredBinderFunctionFor(entityType))

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

+3-3
Original file line numberDiff line numberDiff line change
@@ -659,8 +659,7 @@ private Mono<Void> processNestedRelations(Neo4jPersistentEntity<?> sourceEntity,
659659
if (stateMachine.hasProcessedValue(relatedValueToStore)) {
660660
relatedIdMono = queryRelatedNode(relatedNode, targetEntity, inDatabase);
661661
} else {
662-
relatedIdMono = saveRelatedNode(relatedNode, relationshipContext.getAssociationTargetType(),
663-
targetEntity, inDatabase);
662+
relatedIdMono = saveRelatedNode(relatedNode, targetEntity, inDatabase);
664663
}
665664
stateMachine.markValueAsProcessed(relatedValueToStore);
666665
return relatedIdMono.flatMap(relatedInternalId -> {
@@ -736,12 +735,13 @@ private <Y> Mono<Long> queryRelatedNode(Object entity, Neo4jPersistentEntity<?>
736735
.fetchAs(Long.class).one();
737736
}
738737

739-
private <Y> Mono<Long> saveRelatedNode(Object relatedNode, Class<Y> entityType, NodeDescription targetNodeDescription,
738+
private <Y> Mono<Long> saveRelatedNode(Object relatedNode, NodeDescription targetNodeDescription,
740739
@Nullable String inDatabase) {
741740

742741
return determineDynamicLabels((Y) relatedNode, (Neo4jPersistentEntity<?>) targetNodeDescription, inDatabase)
743742
.flatMap(t -> {
744743
Y entity = t.getT1();
744+
Class<Y> entityType = (Class<Y>) ((Neo4jPersistentEntity<?>) targetNodeDescription).getType();
745745
DynamicLabels dynamicLabels = t.getT2();
746746

747747
return neo4jClient

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

+2-2
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,7 @@ private <ET> ET map(MapAccessor queryResult, MapAccessor allValues, Neo4jPersist
245245
Supplier<Object> mappedObjectSupplier = () -> {
246246

247247
List<String> allLabels = getLabels(queryResult, nodeDescription);
248-
NodeDescriptionAndLabels nodeDescriptionAndLabels = NodeDescriptionStore
248+
NodeDescriptionAndLabels nodeDescriptionAndLabels = nodeDescriptionStore
249249
.deriveConcreteNodeDescription(nodeDescription, allLabels);
250250
Neo4jPersistentEntity<ET> concreteNodeDescription = (Neo4jPersistentEntity<ET>) nodeDescriptionAndLabels
251251
.getNodeDescription();
@@ -306,7 +306,7 @@ private Neo4jPersistentEntity<?> getMostConcreteTargetNodeDescription(
306306
Neo4jPersistentEntity<?> genericTargetNodeDescription, MapAccessor possibleValueNode) {
307307

308308
List<String> allLabels = getLabels(possibleValueNode, null);
309-
NodeDescriptionAndLabels nodeDescriptionAndLabels = NodeDescriptionStore
309+
NodeDescriptionAndLabels nodeDescriptionAndLabels = nodeDescriptionStore
310310
.deriveConcreteNodeDescription(genericTargetNodeDescription, allLabels);
311311
return (Neo4jPersistentEntity<?>) nodeDescriptionAndLabels
312312
.getNodeDescription();

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

+59-15
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
import java.util.stream.Stream;
3333

3434
import org.apache.commons.logging.LogFactory;
35+
import org.springframework.core.annotation.AnnotatedElementUtils;
3536
import org.springframework.core.log.LogAccessor;
3637
import org.springframework.data.annotation.Persistent;
3738
import org.springframework.data.mapping.Association;
@@ -90,7 +91,7 @@ final class DefaultNeo4jPersistentEntity<T> extends BasicPersistentEntity<T, Neo
9091
DefaultNeo4jPersistentEntity(TypeInformation<T> information) {
9192
super(information);
9293

93-
this.primaryLabel = computePrimaryLabel();
94+
this.primaryLabel = computePrimaryLabel(this.getType());
9495
this.additionalLabels = Lazy.of(this::computeAdditionalLabels);
9596
this.graphProperties = Lazy.of(this::computeGraphProperties);
9697
this.dynamicLabelsProperty = Lazy.of(() -> getGraphProperties().stream().map(Neo4jPersistentProperty.class::cast)
@@ -206,6 +207,10 @@ public void verify() {
206207

207208
private void verifyIdDescription() {
208209

210+
if (this.describesInterface()) {
211+
return;
212+
}
213+
209214
if (this.getIdDescription() == null
210215
&& (this.isAnnotationPresent(Node.class) || this.isAnnotationPresent(Persistent.class))) {
211216

@@ -300,13 +305,15 @@ private void verifyDynamicLabels() {
300305
* 3. If only {@link Node#labels()} property is set, use the first one as the primary label 4. If the
301306
* {@link Node#primaryLabel()} property is set, use this as the primary label
302307
*
308+
* @param type the type of the underlying class
303309
* @return computed primary label
304310
*/
305-
private String computePrimaryLabel() {
311+
@Nullable
312+
static String computePrimaryLabel(Class<?> type) {
306313

307-
Node nodeAnnotation = this.findAnnotation(Node.class);
308-
if (nodeAnnotation == null || hasEmptyLabelInformation(nodeAnnotation)) {
309-
return this.getType().getSimpleName();
314+
Node nodeAnnotation = AnnotatedElementUtils.findMergedAnnotation(type, Node.class);
315+
if ((nodeAnnotation == null || hasEmptyLabelInformation(nodeAnnotation))) {
316+
return type.getSimpleName();
310317
} else if (StringUtils.hasText(nodeAnnotation.primaryLabel())) {
311318
return nodeAnnotation.primaryLabel();
312319
} else {
@@ -322,28 +329,52 @@ private String computePrimaryLabel() {
322329
private List<String> computeAdditionalLabels() {
323330

324331
return Stream.concat(computeOwnAdditionalLabels().stream(), computeParentLabels().stream())
332+
.distinct() // In case the interfaces added a duplicate of the primary label.
333+
.filter(v -> !getPrimaryLabel().equals(v))
325334
.collect(Collectors.toList());
326335
}
327336

328337
/**
329338
* The additional labels will get computed and returned by following rules:<br>
330339
* 1. If there is no {@link Node} annotation, empty {@code String} array.<br>
331340
* 2. If there is an annotation but it has no properties set, empty {@code String} array.<br>
332-
* 3. If only {@link Node#labels()} property is set, use the all but the first one as the additional labels.<br>
333-
* 3. If the {@link Node#primaryLabel()} property is set, use the all but the first one as the additional labels.<br>
341+
* 3a. If only {@link Node#labels()} property is set, use the all but the first one as the additional labels.<br>
342+
* 3b. If the {@link Node#primaryLabel()} property is set, use the all but the first one as the additional labels.<br>
343+
* 4. If the class has any interfaces that are explicitly annotated with {@link Node}, we take all values from them.
334344
*
335345
* @return computed additional labels of the concrete class
336346
*/
337347
@NonNull
338348
private List<String> computeOwnAdditionalLabels() {
349+
List<String> result = new ArrayList<>();
350+
339351
Node nodeAnnotation = this.findAnnotation(Node.class);
340-
if (nodeAnnotation == null || hasEmptyLabelInformation(nodeAnnotation)) {
341-
return Collections.emptyList();
342-
} else if (StringUtils.hasText(nodeAnnotation.primaryLabel())) {
343-
return Arrays.asList(nodeAnnotation.labels());
344-
} else {
345-
return Arrays.asList(Arrays.copyOfRange(nodeAnnotation.labels(), 1, nodeAnnotation.labels().length));
352+
if (!(nodeAnnotation == null || hasEmptyLabelInformation(nodeAnnotation))) {
353+
if (StringUtils.hasText(nodeAnnotation.primaryLabel())) {
354+
result.addAll(Arrays.asList(nodeAnnotation.labels()));
355+
} else {
356+
result.addAll(Arrays.asList(Arrays.copyOfRange(nodeAnnotation.labels(), 1, nodeAnnotation.labels().length)));
357+
}
358+
}
359+
360+
// Add everything we find on _direct_ interfaces
361+
// We don't traverse interfaces of interfaces
362+
for (Class<?> anInterface : this.getType().getInterfaces()) {
363+
nodeAnnotation = AnnotatedElementUtils.findMergedAnnotation(anInterface, Node.class);
364+
if (nodeAnnotation == null) {
365+
continue;
366+
}
367+
if (hasEmptyLabelInformation(nodeAnnotation)) {
368+
result.add(anInterface.getSimpleName());
369+
} else {
370+
if (StringUtils.hasText(nodeAnnotation.primaryLabel())) {
371+
result.add(nodeAnnotation.primaryLabel());
372+
}
373+
result.addAll(Arrays.asList(nodeAnnotation.labels()));
374+
}
346375
}
376+
377+
return Collections.unmodifiableList(result);
347378
}
348379

349380
@NonNull
@@ -352,8 +383,7 @@ private List<String> computeParentLabels() {
352383
Neo4jPersistentEntity<?> parentNodeDescriptionCalculated = (Neo4jPersistentEntity<?>) parentNodeDescription;
353384

354385
while (parentNodeDescriptionCalculated != null) {
355-
if (parentNodeDescriptionCalculated.isAnnotationPresent(Node.class)
356-
|| parentNodeDescriptionCalculated.isAnnotationPresent(Persistent.class)) {
386+
if (isExplicitlyAnnotatedAsEntity(parentNodeDescriptionCalculated)) {
357387

358388
parentLabels.add(parentNodeDescriptionCalculated.getPrimaryLabel());
359389
parentLabels.addAll(parentNodeDescriptionCalculated.getAdditionalLabels());
@@ -363,6 +393,20 @@ private List<String> computeParentLabels() {
363393
return parentLabels;
364394
}
365395

396+
/**
397+
* @param entity The entity to check for annotation
398+
* @return True if the type is explicitly annotated as entity and as such eligible to contribute to the list of labels
399+
* and required to be part of the label lookup.
400+
*/
401+
private static boolean isExplicitlyAnnotatedAsEntity(Neo4jPersistentEntity<?> entity) {
402+
return entity.isAnnotationPresent(Node.class) || entity.isAnnotationPresent(Persistent.class);
403+
}
404+
405+
@Override
406+
public boolean describesInterface() {
407+
return this.getTypeInformation().getRawTypeInformation().getType().isInterface();
408+
}
409+
366410
private static boolean hasEmptyLabelInformation(Node nodeAnnotation) {
367411
return nodeAnnotation.labels().length < 1 && !StringUtils.hasText(nodeAnnotation.primaryLabel());
368412
}

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

+2-1
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
import org.springframework.lang.NonNull;
4040
import org.springframework.lang.Nullable;
4141
import org.springframework.util.Assert;
42+
import org.springframework.util.StringUtils;
4243

4344
/**
4445
* @author Michael J. Simons
@@ -127,7 +128,7 @@ protected Association<Neo4jPersistentProperty> createAssociation() {
127128
Relationship outgoingRelationship = this.findAnnotation(Relationship.class);
128129

129130
String type;
130-
if (outgoingRelationship != null && outgoingRelationship.type() != null) {
131+
if (outgoingRelationship != null && StringUtils.hasText(outgoingRelationship.type())) {
131132
type = outgoingRelationship.type();
132133
} else {
133134
type = deriveRelationshipType(this.getName());

0 commit comments

Comments
 (0)