Skip to content

Commit 43a3047

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 f7b1c6f commit 43a3047

File tree

14 files changed

+834
-107
lines changed

14 files changed

+834
-107
lines changed

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

Lines changed: 45 additions & 0 deletions
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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -678,8 +678,7 @@ private <T> T processNestedRelations(Neo4jPersistentEntity<?> sourceEntity, Pers
678678
if (stateMachine.hasProcessedValue(relatedValueToStore)) {
679679
relatedInternalId = queryRelatedNode(newRelatedObject, targetEntity);
680680
} else {
681-
relatedInternalId = saveRelatedNode(newRelatedObject, relationshipContext.getAssociationTargetType(),
682-
targetEntity);
681+
relatedInternalId = saveRelatedNode(newRelatedObject, targetEntity);
683682
}
684683
stateMachine.markValueAsProcessed(relatedValueToStore);
685684

@@ -750,9 +749,10 @@ private <Y> Long queryRelatedNode(Object entity, Neo4jPersistentEntity<?> target
750749
.fetchAs(Long.class).one().get();
751750
}
752751

753-
private <Y> Long saveRelatedNode(Object entity, Class<Y> entityType, NodeDescription targetNodeDescription) {
752+
private <Y> Long saveRelatedNode(Object entity, NodeDescription targetNodeDescription) {
754753

755754
DynamicLabels dynamicLabels = determineDynamicLabels(entity, (Neo4jPersistentEntity) targetNodeDescription);
755+
Class<Y> entityType = (Class<Y>) ((Neo4jPersistentEntity<?>) targetNodeDescription).getType();
756756
Optional<Long> optionalSavedNodeId = neo4jClient
757757
.query(() -> renderer.render(cypherGenerator.prepareSaveOf(targetNodeDescription, dynamicLabels)))
758758
.bind((Y) entity).with(neo4jMappingContext.getRequiredBinderFunctionFor(entityType))

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -778,7 +778,7 @@ private <T> Mono<T> processNestedRelations(Neo4jPersistentEntity<?> sourceEntity
778778
if (stateMachine.hasProcessedValue(relatedValueToStore)) {
779779
queryOrSave = queryRelatedNode(newRelatedObject, targetEntity);
780780
} else {
781-
queryOrSave = saveRelatedNode(newRelatedObject, relationshipContext.getAssociationTargetType(), targetEntity);
781+
queryOrSave = saveRelatedNode(newRelatedObject, targetEntity);
782782
}
783783
stateMachine.markValueAsProcessed(relatedValueToStore);
784784
return queryOrSave.flatMap(relatedInternalId -> {
@@ -869,12 +869,12 @@ private <Y> Mono<Long> queryRelatedNode(Object entity, Neo4jPersistentEntity<?>
869869
.fetchAs(Long.class).one();
870870
}
871871

872-
private <Y> Mono<Long> saveRelatedNode(Object relatedNode, Class<Y> entityType,
873-
Neo4jPersistentEntity<?> targetNodeDescription) {
872+
private <Y> Mono<Long> saveRelatedNode(Object relatedNode, Neo4jPersistentEntity<?> targetNodeDescription) {
874873

875874
return determineDynamicLabels((Y) relatedNode, targetNodeDescription)
876875
.flatMap(t -> {
877876
Y entity = t.getT1();
877+
Class<Y> entityType = (Class<Y>) ((Neo4jPersistentEntity<?>) targetNodeDescription).getType();
878878
DynamicLabels dynamicLabels = t.getT2();
879879

880880
return neo4jClient

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

Lines changed: 2 additions & 2 deletions
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

Lines changed: 59 additions & 15 deletions
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

@@ -298,13 +303,15 @@ private void verifyDynamicLabels() {
298303
* 3. If only {@link Node#labels()} property is set, use the first one as the primary label 4. If the
299304
* {@link Node#primaryLabel()} property is set, use this as the primary label
300305
*
306+
* @param type the type of the underlying class
301307
* @return computed primary label
302308
*/
303-
private String computePrimaryLabel() {
309+
@Nullable
310+
static String computePrimaryLabel(Class<?> type) {
304311

305-
Node nodeAnnotation = this.findAnnotation(Node.class);
306-
if (nodeAnnotation == null || hasEmptyLabelInformation(nodeAnnotation)) {
307-
return this.getType().getSimpleName();
312+
Node nodeAnnotation = AnnotatedElementUtils.findMergedAnnotation(type, Node.class);
313+
if ((nodeAnnotation == null || hasEmptyLabelInformation(nodeAnnotation))) {
314+
return type.getSimpleName();
308315
} else if (StringUtils.hasText(nodeAnnotation.primaryLabel())) {
309316
return nodeAnnotation.primaryLabel();
310317
} else {
@@ -320,28 +327,52 @@ private String computePrimaryLabel() {
320327
private List<String> computeAdditionalLabels() {
321328

322329
return Stream.concat(computeOwnAdditionalLabels().stream(), computeParentLabels().stream())
330+
.distinct() // In case the interfaces added a duplicate of the primary label.
331+
.filter(v -> !getPrimaryLabel().equals(v))
323332
.collect(Collectors.toList());
324333
}
325334

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

347378
@NonNull
@@ -350,8 +381,7 @@ private List<String> computeParentLabels() {
350381
Neo4jPersistentEntity<?> parentNodeDescriptionCalculated = (Neo4jPersistentEntity<?>) parentNodeDescription;
351382

352383
while (parentNodeDescriptionCalculated != null) {
353-
if (parentNodeDescriptionCalculated.isAnnotationPresent(Node.class)
354-
|| parentNodeDescriptionCalculated.isAnnotationPresent(Persistent.class)) {
384+
if (isExplicitlyAnnotatedAsEntity(parentNodeDescriptionCalculated)) {
355385

356386
parentLabels.add(parentNodeDescriptionCalculated.getPrimaryLabel());
357387
parentLabels.addAll(parentNodeDescriptionCalculated.getAdditionalLabels());
@@ -361,6 +391,20 @@ private List<String> computeParentLabels() {
361391
return parentLabels;
362392
}
363393

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

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

Lines changed: 2 additions & 1 deletion
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
@@ -145,7 +146,7 @@ protected Association<Neo4jPersistentProperty> createAssociation() {
145146
Relationship outgoingRelationship = this.findAnnotation(Relationship.class);
146147

147148
String type;
148-
if (outgoingRelationship != null && outgoingRelationship.type() != null) {
149+
if (outgoingRelationship != null && StringUtils.hasText(outgoingRelationship.type())) {
149150
type = outgoingRelationship.type();
150151
} else {
151152
type = deriveRelationshipType(this.getName());

0 commit comments

Comments
 (0)