Skip to content

Commit 5ad977c

Browse files
authored
Add aggregate boundaries for simpified projection (#3023)
Introduce a parameter to the `@Node` annotation (`aggregateBoundary`) which allows users to express that an entity should only report its `@Id` if it gets loaded from a certain different class/entity. In all other cases it will behave as a usual entity and SDN will hydrate all properties and follow all relationships. This works for reading and writing entities. Signed-off-by: Gerrit Meier <[email protected]>
1 parent a53a27b commit 5ad977c

File tree

14 files changed

+1354
-29
lines changed

14 files changed

+1354
-29
lines changed

src/main/antora/modules/ROOT/pages/projections/sdn-projections.adoc

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ additional properties - via accessors or fields - Spring Data Neo4j looks in the
2828
Properties must match exactly by name and can be of simple types (as defined in `org.springframework.data.neo4j.core.convert.Neo4jSimpleTypes`)
2929
or of known persistent entities. Collections of those are supported, but maps are not.
3030

31+
There is also an additional mechanism built into Spring Data Neo4j which allows defining loading and persisting boundaries on the entity definition level.
32+
Read more about this in the <<projections.sdn.aggregate-boundaries>> section.
33+
3134
[[projections.sdn.multi-level]]
3235
== Multi-level projections
3336

@@ -212,3 +215,13 @@ The key to a dynamic projection is to specify the desired projection type as the
212215
in a repository like this: `<T> Collection<T> findByName(String name, Class<T> type)`. This is a declaration that
213216
could be added to the `TestRepository` above and allow for different projections retrieved by the same method, without
214217
to repeat a possible `@Query` annotation on several methods.
218+
219+
[[projections.sdn.aggregate-boundaries]]
220+
== Aggregate boundaries
221+
222+
Reflecting multiple levels of relationships by introducing multiple projections can be cumbersome.
223+
To simplify this already on the entity level, it's possible to add an additional parameter `aggregateBoundary` and supply 1..n classes.
224+
With this the parameterized entity will only report its `@Id` field back and SDN won't follow its relationships or fetch other properties.
225+
226+
It's still possible to use interface-based projections for those entities.
227+
Those projections can be even broader as the declared aggregate boundaries and e.g. include properties or relationships.

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

Lines changed: 31 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -254,8 +254,8 @@ public <T> List<T> findAll(Class<T> domainType) {
254254
private <T> List<T> doFindAll(Class<T> domainType, @Nullable Class<?> resultType) {
255255
return executeReadOnly(tx -> {
256256
Neo4jPersistentEntity<?> entityMetaData = this.neo4jMappingContext.getRequiredPersistentEntity(domainType);
257-
return createExecutableQuery(domainType, resultType, QueryFragmentsAndParameters.forFindAll(entityMetaData),
258-
true)
257+
return createExecutableQuery(domainType, resultType,
258+
QueryFragmentsAndParameters.forFindAll(entityMetaData, this.neo4jMappingContext), true)
259259
.getResults();
260260
});
261261
}
@@ -367,8 +367,10 @@ public <T> Optional<T> findById(Object id, Class<T> domainType) {
367367
Neo4jPersistentEntity<?> entityMetaData = this.neo4jMappingContext.getRequiredPersistentEntity(domainType);
368368

369369
return createExecutableQuery(domainType, null,
370-
QueryFragmentsAndParameters.forFindById(entityMetaData, TemplateSupport
371-
.convertIdValues(this.neo4jMappingContext, entityMetaData.getRequiredIdProperty(), id)),
370+
QueryFragmentsAndParameters.forFindById(entityMetaData,
371+
TemplateSupport.convertIdValues(this.neo4jMappingContext,
372+
entityMetaData.getRequiredIdProperty(), id),
373+
this.neo4jMappingContext),
372374
true)
373375
.getSingleResult();
374376
});
@@ -380,17 +382,20 @@ public <T> List<T> findAllById(Iterable<?> ids, Class<T> domainType) {
380382
Neo4jPersistentEntity<?> entityMetaData = this.neo4jMappingContext.getRequiredPersistentEntity(domainType);
381383

382384
return createExecutableQuery(domainType, null,
383-
QueryFragmentsAndParameters.forFindByAllId(entityMetaData, TemplateSupport
384-
.convertIdValues(this.neo4jMappingContext, entityMetaData.getRequiredIdProperty(), ids)),
385+
QueryFragmentsAndParameters.forFindByAllId(entityMetaData,
386+
TemplateSupport.convertIdValues(this.neo4jMappingContext,
387+
entityMetaData.getRequiredIdProperty(), ids),
388+
this.neo4jMappingContext),
385389
true)
386390
.getResults();
387391
});
388392
}
389393

390394
@Override
391395
public <T> T save(T instance) {
392-
393-
return execute(tx -> saveImpl(instance, Collections.emptySet(), null));
396+
Collection<PropertyFilter.ProjectedPath> pps = PropertyFilterSupport
397+
.getInputPropertiesForAggregateBoundary(instance.getClass(), this.neo4jMappingContext);
398+
return execute(tx -> saveImpl(instance, pps, null));
394399

395400
}
396401

@@ -550,9 +555,12 @@ private <T> List<T> saveAllImpl(Iterable<T> instances,
550555

551556
Set<Class<?>> types = new HashSet<>();
552557
List<T> entities = new ArrayList<>();
558+
Map<Class<?>, Collection<PropertyFilter.ProjectedPath>> includedPropertiesByClass = new HashMap<>();
553559
instances.forEach(instance -> {
554560
entities.add(instance);
555561
types.add(instance.getClass());
562+
includedPropertiesByClass.put(instance.getClass(), PropertyFilterSupport
563+
.getInputPropertiesForAggregateBoundary(instance.getClass(), this.neo4jMappingContext));
556564
});
557565

558566
if (entities.isEmpty()) {
@@ -573,7 +581,12 @@ private <T> List<T> saveAllImpl(Iterable<T> instances,
573581

574582
NestedRelationshipProcessingStateMachine stateMachine = new NestedRelationshipProcessingStateMachine(
575583
this.neo4jMappingContext);
576-
return entities.stream().map(e -> saveImpl(e, pps, stateMachine)).collect(Collectors.toList());
584+
return entities.stream()
585+
.map(e -> saveImpl(e,
586+
((includedProperties != null && !includedProperties.isEmpty()) || includeProperty != null) ? pps
587+
: includedPropertiesByClass.get(e.getClass()),
588+
stateMachine))
589+
.collect(Collectors.toList());
577590
}
578591

579592
class Tuple3<T> {
@@ -628,7 +641,10 @@ class Tuple3<T> {
628641
String internalId = Objects.requireNonNull(idToInternalIdMapping.get(id));
629642
stateMachine.registerInitialObject(t.originalInstance, internalId);
630643
return this.<T>processRelations(entityMetaData, propertyAccessor, t.wasNew, stateMachine,
631-
TemplateSupport.computeIncludePropertyPredicate(pps, entityMetaData));
644+
TemplateSupport.computeIncludePropertyPredicate(
645+
((includedProperties != null && !includedProperties.isEmpty()) || includeProperty != null)
646+
? pps : includedPropertiesByClass.get(t.modifiedInstance.getClass()),
647+
entityMetaData));
632648
}).collect(Collectors.toList());
633649
}
634650

@@ -1182,9 +1198,11 @@ private Optional<Object> getRelationshipId(Statement statement, @Nullable Neo4jP
11821198
private Entity loadRelatedNode(NodeDescription<?> targetNodeDescription, @Nullable Object relatedInternalId) {
11831199

11841200
var targetPersistentEntity = (Neo4jPersistentEntity<?>) targetNodeDescription;
1185-
var queryFragmentsAndParameters = QueryFragmentsAndParameters.forFindById(targetPersistentEntity,
1186-
TemplateSupport.convertIdValues(this.neo4jMappingContext,
1187-
targetPersistentEntity.getRequiredIdProperty(), relatedInternalId));
1201+
var queryFragmentsAndParameters = QueryFragmentsAndParameters
1202+
.forFindById(targetPersistentEntity,
1203+
TemplateSupport.convertIdValues(this.neo4jMappingContext,
1204+
targetPersistentEntity.getRequiredIdProperty(), relatedInternalId),
1205+
this.neo4jMappingContext);
11881206
var nodeName = Constants.NAME_OF_TYPED_ROOT_NODE.apply(targetNodeDescription).getValue();
11891207

11901208
return this.neo4jClient

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

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,15 @@
1616
package org.springframework.data.neo4j.core;
1717

1818
import java.beans.PropertyDescriptor;
19+
import java.util.ArrayList;
1920
import java.util.Collection;
2021
import java.util.Collections;
2122
import java.util.HashSet;
2223
import java.util.Objects;
2324
import java.util.Optional;
25+
import java.util.Set;
26+
import java.util.concurrent.locks.ReentrantLock;
27+
import java.util.function.Predicate;
2428

2529
import org.apiguardian.api.API;
2630

@@ -29,6 +33,7 @@
2933
import org.springframework.data.neo4j.core.mapping.GraphPropertyDescription;
3034
import org.springframework.data.neo4j.core.mapping.Neo4jMappingContext;
3135
import org.springframework.data.neo4j.core.mapping.Neo4jPersistentEntity;
36+
import org.springframework.data.neo4j.core.mapping.NodeDescription;
3237
import org.springframework.data.neo4j.core.mapping.PropertyFilter;
3338
import org.springframework.data.neo4j.core.mapping.RelationshipDescription;
3439
import org.springframework.data.projection.ProjectionFactory;
@@ -47,6 +52,9 @@
4752
@API(status = API.Status.INTERNAL, since = "6.1.3")
4853
public final class PropertyFilterSupport {
4954

55+
// A cache to look up if there are aggregate boundaries between two entities.
56+
private static final AggregateBoundaries AGGREGATE_BOUNDARIES = new AggregateBoundaries();
57+
5058
private PropertyFilterSupport() {
5159
}
5260

@@ -61,6 +69,11 @@ public static Collection<PropertyFilter.ProjectedPath> getInputProperties(Result
6169

6270
boolean isProjecting = returnedType.isProjecting();
6371
boolean isClosedProjection = factory.getProjectionInformation(potentiallyProjectedType).isClosed();
72+
if (!isProjecting && containsAggregateBoundary(domainType, mappingContext)) {
73+
Collection<PropertyFilter.ProjectedPath> listForAggregate = createListForAggregate(domainType,
74+
mappingContext);
75+
return listForAggregate;
76+
}
6477

6578
if (!isProjecting || !isClosedProjection) {
6679
return Collections.emptySet();
@@ -84,6 +97,160 @@ public static Collection<PropertyFilter.ProjectedPath> getInputProperties(Result
8497
return filteredProperties;
8598
}
8699

100+
public static Collection<PropertyFilter.ProjectedPath> getInputPropertiesForAggregateBoundary(Class<?> domainType,
101+
Neo4jMappingContext mappingContext) {
102+
if (!containsAggregateBoundary(domainType, mappingContext)) {
103+
return Collections.emptySet();
104+
}
105+
Collection<PropertyFilter.ProjectedPath> listForAggregate = createListForAggregate(domainType, mappingContext);
106+
return listForAggregate;
107+
}
108+
109+
public static Predicate<PropertyFilter.RelaxedPropertyPath> createRelaxedPropertyPathFilter(Class<?> domainType,
110+
Neo4jMappingContext mappingContext) {
111+
if (!containsAggregateBoundary(domainType, mappingContext)) {
112+
return PropertyFilter.NO_FILTER;
113+
}
114+
Collection<PropertyFilter.RelaxedPropertyPath> relaxedPropertyPathFilter = createRelaxedPropertyPathFilter(
115+
domainType, mappingContext, new HashSet<RelationshipDescription>());
116+
return (rpp) -> {
117+
return relaxedPropertyPathFilter.contains(rpp);
118+
};
119+
}
120+
121+
private static Collection<PropertyFilter.RelaxedPropertyPath> createRelaxedPropertyPathFilter(Class<?> domainType,
122+
Neo4jMappingContext neo4jMappingContext, Set<RelationshipDescription> processedRelationships) {
123+
var relaxedPropertyPath = PropertyFilter.RelaxedPropertyPath.withRootType(domainType);
124+
var relaxedPropertyPaths = new ArrayList<PropertyFilter.RelaxedPropertyPath>();
125+
relaxedPropertyPaths.add(relaxedPropertyPath);
126+
Neo4jPersistentEntity<?> domainEntity = neo4jMappingContext.getRequiredPersistentEntity(domainType);
127+
domainEntity.getGraphProperties().stream().forEach(property -> {
128+
relaxedPropertyPaths.add(relaxedPropertyPath.append(property.getFieldName()));
129+
});
130+
for (RelationshipDescription relationshipDescription : domainEntity.getRelationshipsInHierarchy(any -> true)) {
131+
var target = relationshipDescription.getTarget();
132+
PropertyFilter.RelaxedPropertyPath relationshipPath = relaxedPropertyPath
133+
.append(relationshipDescription.getFieldName());
134+
relaxedPropertyPaths.add(relationshipPath);
135+
processedRelationships.add(relationshipDescription);
136+
createRelaxedPropertyPathFilter(domainType, target, relationshipPath, relaxedPropertyPaths,
137+
processedRelationships);
138+
}
139+
return relaxedPropertyPaths;
140+
}
141+
142+
private static Collection<PropertyFilter.RelaxedPropertyPath> createRelaxedPropertyPathFilter(Class<?> domainType,
143+
NodeDescription<?> nodeDescription, PropertyFilter.RelaxedPropertyPath relaxedPropertyPath,
144+
Collection<PropertyFilter.RelaxedPropertyPath> relaxedPropertyPaths,
145+
Set<RelationshipDescription> processedRelationships) {
146+
// always add the related entity itself
147+
relaxedPropertyPaths.add(relaxedPropertyPath);
148+
if (nodeDescription.hasAggregateBoundaries(domainType)) {
149+
relaxedPropertyPaths.add(relaxedPropertyPath
150+
.append(((Neo4jPersistentEntity<?>) nodeDescription).getRequiredIdProperty().getFieldName()));
151+
152+
return relaxedPropertyPaths;
153+
}
154+
nodeDescription.getGraphProperties().stream().forEach(property -> {
155+
relaxedPropertyPaths.add(relaxedPropertyPath.append(property.getFieldName()));
156+
});
157+
for (RelationshipDescription relationshipDescription : nodeDescription
158+
.getRelationshipsInHierarchy(any -> true)) {
159+
if (processedRelationships.contains(relationshipDescription)) {
160+
continue;
161+
}
162+
var target = relationshipDescription.getTarget();
163+
PropertyFilter.RelaxedPropertyPath relationshipPath = relaxedPropertyPath
164+
.append(relationshipDescription.getFieldName());
165+
relaxedPropertyPaths.add(relationshipPath);
166+
processedRelationships.add(relationshipDescription);
167+
createRelaxedPropertyPathFilter(domainType, target, relationshipPath, relaxedPropertyPaths,
168+
processedRelationships);
169+
}
170+
return relaxedPropertyPaths;
171+
}
172+
173+
private static Collection<PropertyFilter.ProjectedPath> createListForAggregate(Class<?> domainType,
174+
Neo4jMappingContext neo4jMappingContext) {
175+
var relaxedPropertyPath = PropertyFilter.RelaxedPropertyPath.withRootType(domainType);
176+
var filteredProperties = new ArrayList<PropertyFilter.ProjectedPath>();
177+
Neo4jPersistentEntity<?> domainEntity = neo4jMappingContext.getRequiredPersistentEntity(domainType);
178+
domainEntity.getGraphProperties().stream().forEach(property -> {
179+
filteredProperties
180+
.add(new PropertyFilter.ProjectedPath(relaxedPropertyPath.append(property.getFieldName()), false));
181+
});
182+
for (RelationshipDescription relationshipDescription : domainEntity.getRelationshipsInHierarchy(any -> true)) {
183+
var target = relationshipDescription.getTarget();
184+
filteredProperties.addAll(createListForAggregate(domainType, target,
185+
relaxedPropertyPath.append(relationshipDescription.getFieldName())));
186+
}
187+
return filteredProperties;
188+
}
189+
190+
private static Collection<PropertyFilter.ProjectedPath> createListForAggregate(Class<?> domainType,
191+
NodeDescription<?> nodeDescription, PropertyFilter.RelaxedPropertyPath relaxedPropertyPath) {
192+
var filteredProperties = new ArrayList<PropertyFilter.ProjectedPath>();
193+
// always add the related entity itself
194+
filteredProperties.add(new PropertyFilter.ProjectedPath(relaxedPropertyPath, false));
195+
if (nodeDescription.hasAggregateBoundaries(domainType)) {
196+
filteredProperties.add(new PropertyFilter.ProjectedPath(
197+
relaxedPropertyPath
198+
.append(((Neo4jPersistentEntity<?>) nodeDescription).getRequiredIdProperty().getFieldName()),
199+
false));
200+
return filteredProperties;
201+
}
202+
nodeDescription.getGraphProperties().stream().forEach(property -> {
203+
filteredProperties
204+
.add(new PropertyFilter.ProjectedPath(relaxedPropertyPath.append(property.getFieldName()), false));
205+
});
206+
for (RelationshipDescription relationshipDescription : nodeDescription
207+
.getRelationshipsInHierarchy(any -> true)) {
208+
var target = relationshipDescription.getTarget();
209+
filteredProperties.addAll(createListForAggregate(domainType, target,
210+
relaxedPropertyPath.append(relationshipDescription.getFieldName())));
211+
}
212+
return filteredProperties;
213+
}
214+
215+
private static boolean containsAggregateBoundary(Class<?> domainType, Neo4jMappingContext neo4jMappingContext) {
216+
var processedRelationships = new HashSet<RelationshipDescription>();
217+
Neo4jPersistentEntity<?> domainEntity = neo4jMappingContext.getRequiredPersistentEntity(domainType);
218+
if (AGGREGATE_BOUNDARIES.hasEntry(domainEntity, domainType)) {
219+
return AGGREGATE_BOUNDARIES.getCachedStatus(domainEntity, domainType);
220+
}
221+
for (RelationshipDescription relationshipDescription : domainEntity.getRelationshipsInHierarchy(any -> true)) {
222+
var target = relationshipDescription.getTarget();
223+
if (target.hasAggregateBoundaries(domainType)) {
224+
AGGREGATE_BOUNDARIES.add(domainEntity, domainType, true);
225+
return true;
226+
}
227+
processedRelationships.add(relationshipDescription);
228+
boolean containsAggregateBoundary = containsAggregateBoundary(domainType, target, processedRelationships);
229+
AGGREGATE_BOUNDARIES.add(domainEntity, domainType, containsAggregateBoundary);
230+
return containsAggregateBoundary;
231+
}
232+
AGGREGATE_BOUNDARIES.add(domainEntity, domainType, false);
233+
return false;
234+
}
235+
236+
private static boolean containsAggregateBoundary(Class<?> domainType, NodeDescription<?> nodeDescription,
237+
Set<RelationshipDescription> processedRelationships) {
238+
for (RelationshipDescription relationshipDescription : nodeDescription
239+
.getRelationshipsInHierarchy(any -> true)) {
240+
var target = relationshipDescription.getTarget();
241+
Class<?> underlyingClass = nodeDescription.getUnderlyingClass();
242+
if (processedRelationships.contains(relationshipDescription)) {
243+
continue;
244+
}
245+
if (target.hasAggregateBoundaries(domainType)) {
246+
return true;
247+
}
248+
processedRelationships.add(relationshipDescription);
249+
return containsAggregateBoundary(domainType, target, processedRelationships);
250+
}
251+
return false;
252+
}
253+
87254
static Collection<PropertyFilter.ProjectedPath> addPropertiesFrom(Class<?> domainType, Class<?> returnType,
88255
ProjectionFactory projectionFactory, Neo4jMappingContext neo4jMappingContext) {
89256

@@ -258,4 +425,65 @@ boolean isChildLevel() {
258425

259426
}
260427

428+
record AggregateBoundary(Neo4jPersistentEntity<?> entity, Class<?> domainType, boolean status) {
429+
430+
}
431+
432+
private static final class AggregateBoundaries {
433+
434+
private final Set<AggregateBoundary> aggregateBoundaries = new HashSet<>();
435+
436+
private final ReentrantLock lock = new ReentrantLock();
437+
438+
void add(Neo4jPersistentEntity<?> entity, Class<?> domainType, boolean status) {
439+
try {
440+
this.lock.lock();
441+
for (AggregateBoundary aggregateBoundary : this.aggregateBoundaries) {
442+
if (aggregateBoundary.domainType().equals(domainType) && aggregateBoundary.entity().equals(entity)
443+
&& aggregateBoundary.status() != status) {
444+
throw new IllegalStateException("%s cannot have a different status to %s. Was %s now %s"
445+
.formatted(entity.getUnderlyingClass(), domainType, aggregateBoundary.status(), status));
446+
}
447+
}
448+
this.aggregateBoundaries.add(new AggregateBoundary(entity, domainType, status));
449+
}
450+
finally {
451+
this.lock.unlock();
452+
}
453+
}
454+
455+
boolean hasEntry(Neo4jPersistentEntity<?> entity, Class<?> domainType) {
456+
try {
457+
this.lock.lock();
458+
for (AggregateBoundary aggregateBoundary : this.aggregateBoundaries) {
459+
if (aggregateBoundary.domainType().equals(domainType)
460+
&& aggregateBoundary.entity().equals(entity)) {
461+
return true;
462+
}
463+
}
464+
return false;
465+
}
466+
finally {
467+
this.lock.unlock();
468+
}
469+
}
470+
471+
boolean getCachedStatus(Neo4jPersistentEntity<?> entity, Class<?> domainType) {
472+
try {
473+
this.lock.lock();
474+
for (AggregateBoundary aggregateBoundary : this.aggregateBoundaries) {
475+
if (aggregateBoundary.domainType().equals(domainType)
476+
&& aggregateBoundary.entity().equals(entity)) {
477+
return aggregateBoundary.status();
478+
}
479+
}
480+
return false;
481+
}
482+
finally {
483+
this.lock.unlock();
484+
}
485+
}
486+
487+
}
488+
261489
}

0 commit comments

Comments
 (0)