Skip to content

Commit b85a5be

Browse files
DATAGRAPH-1400 - Use the correct domain type before creating DTO projections.
This change makes sure we use the correct domain type before we create DTO based projections. Without taking the underlying domain type into consideration, annotated attributes, explicit relationship types etc. won't be taken into consideration when generating the Cypher query. The DTO is than generated in two steps: First the domain type is instantiated and populated with the set of properties defined by the DTO and than the DTO is created from it. The creation is not part of the entity converter but a later step. In a future version of SDN this might change. In addition to this changes, we allow additional attributes in a DTO projection. For them to be hydrated, custom queries must be supplied, containing the corresponding attributes. Included with the commit is documentation of the defined behavior as well as a couple of tests to make sure the behavior of Spring Data Commons with regard to what is considered to be a projection or not keeps the way it is now. Co-authored-by: Gerrit Meier <[email protected]>
1 parent a9a59ff commit b85a5be

24 files changed

+873
-79
lines changed

pom.xml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,8 @@
124124
<skipUnitTests>${skipTests}</skipUnitTests>
125125
<springdata.commons>2.4.0-SNAPSHOT</springdata.commons>
126126
<testcontainers.version>1.13.0</testcontainers.version>
127+
128+
<spring-data-commons-docs.dir>../../../../../spring-data-commons/src/main/asciidoc</spring-data-commons-docs.dir>
127129
</properties>
128130

129131
<dependencyManagement>
@@ -716,6 +718,7 @@
716718
<setanchors/>
717719
<idprefix/>
718720
<idseparator/>
721+
<spring-data-commons-docs>${spring-data-commons-docs.dir}</spring-data-commons-docs>
719722
</attributes>
720723
<requires>
721724
<require>asciidoctor-diagram</require>

src/main/asciidoc/index.adoc

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,12 @@ endif::[]
1919

2020
include::{manualIncludeDir}/README.adoc[tags=properties]
2121

22-
:gh-base: https://github.com/neo4j/SDN
22+
:gh-base: https://github.com/spring-projects/spring-data-neo4j
2323
:java-driver-starter-href: https://github.com/neo4j/neo4j-java-driver-spring-boot-starter
24-
:springVersion: 5.2.0.RELEASE
24+
:springVersion: 5.3.0-RC1
2525
:spring-framework-docs: https://docs.spring.io/spring/docs/{springVersion}/spring-framework-reference
2626
:spring-framework-javadoc: https://docs.spring.io/spring/docs/{springVersion}/javadoc-api
27-
:spring-data-commons-docs: ../../../../../other-spring-data/spring-data-commons/src/main/asciidoc
27+
:spring-data-commons-docs: ../../../../../spring-data-commons/src/main/asciidoc
2828

2929
(C) 2008-2020 The original authors.
3030

@@ -53,6 +53,8 @@ include::object-mapping/index.adoc[]
5353

5454
include::{spring-data-commons-docs}/repository-projections.adoc[leveloffset=+1]
5555

56+
include::object-mapping/projections.adoc[leveloffset=+1,lines=5..]
57+
5658
include::testing/index.adoc[]
5759

5860
include::{spring-data-commons-docs}/auditing.adoc[leveloffset=+1]
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
[[projections]]
2+
= Projections
3+
4+
[[projections.general-remarks]]
5+
== General remarks
6+
7+
As stated above, projections come in two flavors: Interface and DTO based projections.
8+
In Spring Data Neo4j both types of projections have a direct influence which properties and relationships are transferred
9+
over the wire.
10+
Therefore, both approaches can reduce the load on your database in case you are dealing with nodes and entities containing
11+
lots of properties which might not be needed in all usage scenarios in your application.
12+
13+
For both interface and DTO based projections, Spring Data Neo4j will use the repository's domain type for building the
14+
query. All annotations on all attributes that might change the query will be taken in consideration.
15+
The domain type is the type that has been defined through the repository declaration
16+
(Given a declaration like `interface TestRepository extends CrudRepository<TestEntity, Long>` the domain type would be
17+
`TestEntity`).
18+
19+
Interface based projections will always be dynamic proxies to the underlying domain type. The names of the accessors defined
20+
on such interfaces (like `getName`) must resolve to properties (here: `name`) that are present on the projected entity.
21+
Whether those properties have accessors or not on the domain type is not relevant, as long as they can be accessed through
22+
the common Spring Data infrastructure. The later is ensure already, as the domain type wouldn't be a persistent entity in
23+
the first place.
24+
25+
DTO based projections are somewhat more flexible when used with custom queries. While the standard query is derived from
26+
the original domain type and therefore only the properties and relationship beeing defined there can be used, custom queries
27+
can add additional properties.
28+
29+
The rules are as follows: First, the properties of the domain type are used to populate the DTO. In case the DTO declare
30+
additional properties - via accessors or fields - Spring Data Neo4j looks in the resulting record for matching properties.
31+
Properties must match exactly by name and can be of simple types (as defined in `org.springframework.data.neo4j.core.convert.Neo4jSimpleTypes`)
32+
or of known persistent entites. Collections of those are supported, but no maps.
33+
34+
=== A full example
35+
36+
Given the following entities, projections and the corresponding repository:
37+
38+
[[projections.simple-entity]]
39+
[source,java]
40+
.A simple entity
41+
----
42+
@Node
43+
class TestEntity {
44+
@Id @GeneratedValue private Long id;
45+
46+
private String name;
47+
48+
@Property("a_property") // <.>
49+
private String aProperty;
50+
}
51+
----
52+
<.> This property has a different name in the Graph
53+
54+
[[projections.simple-entity-extended]]
55+
[source,java]
56+
.A derived entity, inheriting from `TestEntity`
57+
----
58+
@Node
59+
class ExtendedTestEntity extends TestEntity {
60+
61+
private String otherAttribute;
62+
}
63+
----
64+
65+
[[projections.simple-entity-interface-projected]]
66+
[source,java]
67+
.Interface projection of `TestEntity`
68+
----
69+
interface TestEntityInterfaceProjection {
70+
71+
String getName();
72+
}
73+
----
74+
75+
[[projections.simple-entity-dto-projected]]
76+
[source,java]
77+
.DTO projection of `TestEntity`, including one additional attribute
78+
----
79+
class TestEntityDTOProjection {
80+
81+
private String name;
82+
83+
private Long numberOfRelations; // <.>
84+
85+
public String getName() {
86+
return name;
87+
}
88+
89+
public void setName(String name) {
90+
this.name = name;
91+
}
92+
93+
public Long getNumberOfRelations() {
94+
return numberOfRelations;
95+
}
96+
97+
public void setNumberOfRelations(Long numberOfRelations) {
98+
this.numberOfRelations = numberOfRelations;
99+
}
100+
}
101+
----
102+
<.> This attribute doesn't exist on the projected entity
103+
104+
A repository for `TestEntity` is shown below and it will behave as explained with the listing.
105+
106+
[[projections.simple-entity-repository]]
107+
[source,java]
108+
.A repository for the `TestEntity`
109+
----
110+
interface TestRepository extends CrudRepository<TestEntity, Long> { // <.>
111+
112+
List<TestEntity> findAll(); // <.>
113+
114+
List<ExtendedTestEntity> findAllExtendedEntites(); // <.>
115+
116+
List<TestEntityInterfaceProjection> findAllInterfaceProjections(); // <.>
117+
118+
List<TestEntityDTOProjection> findAllDTOProjections(); // <.>
119+
120+
@Query("MATCH (t:TestEntity) - [r:RELATED_TO] -> () RETURN t, COUNT(r) AS numberOfRelations") // <.>
121+
List<TestEntityDTOProjection> findAllDTOProjectionsWithCustomQuery();
122+
}
123+
----
124+
<.> The domain type of the repository is `TestEntity`
125+
<.> Methods returning one or more `TestEntity` will just return instances of it, as it matches the domain type
126+
<.> Methods returning one or more instances of class that extend the domain type will just return instances
127+
of the extending class. The domain type of the method in question will the extended class, which
128+
still satifies the domain type of the repository itself
129+
<.> This method returns an interface projection, the return type of the method is therefore different
130+
from the repository's domain type. The interface can only access properties defined in the domain type
131+
<.> This method returns a DTO projection. Executing it will cause SDN to issue a warning, as the DTO defines
132+
`numberOfRelations` as additional attribute, which is not in the contract of the domain type.
133+
The annotated attribute `aProperty` in `TestEntity` will be correctly translated to `a_property` in the query.
134+
As above, the return type is different from the repositories domain type.
135+
<.> This method also returns a DTO projection. However, no warning will be issued, as the query contains a fitting
136+
value for the additional attributes defined in the projection.

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

Lines changed: 15 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
import java.util.ArrayList;
1919
import java.util.Collection;
20+
import java.util.Collections;
2021
import java.util.HashMap;
2122
import java.util.HashSet;
2223
import java.util.List;
@@ -33,7 +34,6 @@
3334
import java.util.stream.StreamSupport;
3435

3536
import org.apache.commons.logging.LogFactory;
36-
import org.neo4j.driver.Record;
3737
import org.neo4j.driver.Value;
3838
import org.neo4j.driver.Values;
3939
import org.neo4j.driver.types.MapAccessor;
@@ -68,40 +68,38 @@ final class DefaultNeo4jEntityConverter implements Neo4jEntityConverter {
6868

6969
private static final LogAccessor log = new LogAccessor(LogFactory.getLog(DefaultNeo4jEntityConverter.class));
7070

71-
/**
72-
* The shared entity instantiators of this context. Those should not be recreated for each entity or even not for each
73-
* query, as otherwise the cache of Spring's org.springframework.data.convert.ClassGeneratingEntityInstantiator won't
74-
* apply
75-
*/
76-
private static final EntityInstantiators INSTANTIATORS = new EntityInstantiators();
77-
71+
private final EntityInstantiators entityInstantiators;
7872
private final NodeDescriptionStore nodeDescriptionStore;
7973
private final Neo4jConversionService conversionService;
8074

8175
private TypeSystem typeSystem;
8276

83-
DefaultNeo4jEntityConverter(Neo4jConversionService conversionService, NodeDescriptionStore nodeDescriptionStore) {
77+
DefaultNeo4jEntityConverter(EntityInstantiators entityInstantiators, Neo4jConversionService conversionService, NodeDescriptionStore nodeDescriptionStore) {
8478

79+
Assert.notNull(entityInstantiators, "EntityInstantiators must not be null!");
8580
Assert.notNull(conversionService, "Neo4jConversionService must not be null!");
81+
Assert.notNull(nodeDescriptionStore, "NodeDescriptionStore must not be null!");
8682

83+
this.entityInstantiators = entityInstantiators;
8784
this.conversionService = conversionService;
8885
this.nodeDescriptionStore = nodeDescriptionStore;
8986
}
9087

9188
@Override
92-
public <R> R read(Class<R> targetType, Record record) {
89+
public <R> R read(Class<R> targetType, MapAccessor mapAccessor) {
9390

9491
Neo4jPersistentEntity<R> rootNodeDescription = (Neo4jPersistentEntity) nodeDescriptionStore
9592
.getNodeDescription(targetType);
9693

9794
try {
98-
List<Value> recordValues = record.values();
95+
Iterable<Value> recordValues = mapAccessor instanceof Value && ((Value) mapAccessor).hasType(typeSystem.NODE()) ?
96+
Collections.singletonList((Value) mapAccessor) : mapAccessor.values();
9997
String nodeLabel = rootNodeDescription.getPrimaryLabel();
10098
MapAccessor queryRoot = null;
10199
for (Value value : recordValues) {
102100
if (value.hasType(typeSystem.NODE()) && value.asNode().hasLabel(nodeLabel)) {
103-
if (recordValues.size() > 1) {
104-
queryRoot = mergeRootNodeWithRecord(value.asNode(), record);
101+
if (mapAccessor.size() > 1) {
102+
queryRoot = mergeRootNodeWithRecord(value.asNode(), mapAccessor);
105103
} else {
106104
queryRoot = value.asNode();
107105
}
@@ -118,14 +116,12 @@ public <R> R read(Class<R> targetType, Record record) {
118116
}
119117

120118
if (queryRoot == null) {
121-
log.warn(() -> String.format("Could not find mappable nodes or relationships inside %s for %s", record,
122-
rootNodeDescription));
123-
return null; // todo should not be null because of the @nonnullapi annotation in the EntityReader. Fail?
119+
throw new MappingException(String.format("Could not find mappable nodes or relationships inside %s for %s", mapAccessor, rootNodeDescription));
124120
} else {
125121
return map(queryRoot, rootNodeDescription, new KnownObjects(), new HashSet<>());
126122
}
127123
} catch (Exception e) {
128-
throw new MappingException("Error mapping " + record.toString(), e);
124+
throw new MappingException("Error mapping " + mapAccessor.toString(), e);
129125
}
130126
}
131127

@@ -188,7 +184,7 @@ void setTypeSystem(TypeSystem typeSystem) {
188184
* @param record Record that should be merged
189185
* @return
190186
*/
191-
private static MapAccessor mergeRootNodeWithRecord(Node node, Record record) {
187+
private static MapAccessor mergeRootNodeWithRecord(Node node, MapAccessor record) {
192188
Map<String, Object> mergedAttributes = new HashMap<>(node.size() + record.size() + 1);
193189

194190
mergedAttributes.put(Constants.NAME_OF_INTERNAL_ID, node.id());
@@ -314,7 +310,7 @@ public Object getParameterValue(PreferredConstructor.Parameter parameter) {
314310
}
315311
};
316312

317-
return INSTANTIATORS.getInstantiatorFor(nodeDescription).createInstance(nodeDescription, parameterValueProvider);
313+
return entityInstantiators.getInstantiatorFor(nodeDescription).createInstance(nodeDescription, parameterValueProvider);
318314
}
319315

320316
private PropertyHandler<Neo4jPersistentProperty> populateFrom(MapAccessor queryResult,

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
import java.util.Map;
1919

2020
import org.apiguardian.api.API;
21-
import org.neo4j.driver.Record;
21+
import org.neo4j.driver.types.MapAccessor;
2222
import org.springframework.data.convert.EntityReader;
2323
import org.springframework.data.convert.EntityWriter;
2424

@@ -30,5 +30,5 @@
3030
* @since 6.0
3131
*/
3232
@API(status = API.Status.INTERNAL, since = "6.0")
33-
public interface Neo4jEntityConverter extends EntityReader<Object, Record>, EntityWriter<Object, Map<String, Object>> {
33+
public interface Neo4jEntityConverter extends EntityReader<Object, MapAccessor>, EntityWriter<Object, Map<String, Object>> {
3434
}

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

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,10 @@
3535
import org.springframework.beans.factory.config.AutowireCapableBeanFactory;
3636
import org.springframework.context.ApplicationContext;
3737
import org.springframework.data.mapping.MappingException;
38+
import org.springframework.data.mapping.PersistentEntity;
3839
import org.springframework.data.mapping.context.AbstractMappingContext;
40+
import org.springframework.data.mapping.model.EntityInstantiator;
41+
import org.springframework.data.mapping.model.EntityInstantiators;
3942
import org.springframework.data.mapping.model.Property;
4043
import org.springframework.data.mapping.model.SimpleTypeHolder;
4144
import org.springframework.data.neo4j.core.convert.ConvertWith;
@@ -62,6 +65,13 @@
6265
public final class Neo4jMappingContext extends AbstractMappingContext<Neo4jPersistentEntity<?>, Neo4jPersistentProperty>
6366
implements Schema {
6467

68+
/**
69+
* The shared entity instantiators of this context. Those should not be recreated for each entity or even not for each
70+
* query, as otherwise the cache of Spring's org.springframework.data.convert.ClassGeneratingEntityInstantiator won't
71+
* apply
72+
*/
73+
private static final EntityInstantiators INSTANTIATORS = new EntityInstantiators();
74+
6575
/**
6676
* A map of fallback id generators, that have not been added to the application context
6777
*/
@@ -107,7 +117,7 @@ public Neo4jMappingContext(Neo4jConversions neo4jConversions, TypeSystem typeSys
107117
super.setSimpleTypeHolder(Neo4jSimpleTypes.HOLDER);
108118
this.conversionService = new DefaultNeo4jConversionService(neo4jConversions);
109119

110-
DefaultNeo4jEntityConverter defaultNeo4jConverter = new DefaultNeo4jEntityConverter(conversionService, nodeDescriptionStore);
120+
DefaultNeo4jEntityConverter defaultNeo4jConverter = new DefaultNeo4jEntityConverter(INSTANTIATORS, conversionService, nodeDescriptionStore);
111121
if (typeSystem != null) {
112122
defaultNeo4jConverter.setTypeSystem(typeSystem);
113123
}
@@ -122,6 +132,10 @@ public Neo4jConversionService getConversionService() {
122132
return conversionService;
123133
}
124134

135+
public EntityInstantiator getInstantiatorFor(PersistentEntity<?, ?> entity) {
136+
return INSTANTIATORS.getInstantiatorFor(entity);
137+
}
138+
125139
boolean hasCustomWriteTarget(Class<?> targetType) {
126140
return conversionService.hasCustomWriteTarget(targetType);
127141
}

0 commit comments

Comments
 (0)