Skip to content

Aggregate projection #3023

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 12 commits into from
Jul 17, 2025
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ additional properties - via accessors or fields - Spring Data Neo4j looks in the
Properties must match exactly by name and can be of simple types (as defined in `org.springframework.data.neo4j.core.convert.Neo4jSimpleTypes`)
or of known persistent entities. Collections of those are supported, but maps are not.

There is also an additional mechanism built into Spring Data Neo4j which allows defining loading and persisting boundaries on the entity definition level.
Read more about this in the <<projections.sdn.aggregate-boundaries>> section.

[[projections.sdn.multi-level]]
== Multi-level projections

Expand Down Expand Up @@ -212,3 +215,13 @@ The key to a dynamic projection is to specify the desired projection type as the
in a repository like this: `<T> Collection<T> findByName(String name, Class<T> type)`. This is a declaration that
could be added to the `TestRepository` above and allow for different projections retrieved by the same method, without
to repeat a possible `@Query` annotation on several methods.

[[projections.sdn.aggregate-boundaries]]
== Aggregate boundaries

Reflecting multiple levels of relationships by introducing multiple projections can be cumbersome.
To simplify this already on the entity level, it's possible to add an additional parameter `aggregateBoundary` and supply 1..n classes.
With this the parameterized entity will only report its `@Id` field back and SDN won't follow its relationships or fetch other properties.

It's still possible to use interface-based projections for those entities.
Those projections can be even broader as the declared aggregate boundaries and e.g. include properties or relationships.
Original file line number Diff line number Diff line change
Expand Up @@ -254,8 +254,8 @@ public <T> List<T> findAll(Class<T> domainType) {
private <T> List<T> doFindAll(Class<T> domainType, @Nullable Class<?> resultType) {
return executeReadOnly(tx -> {
Neo4jPersistentEntity<?> entityMetaData = this.neo4jMappingContext.getRequiredPersistentEntity(domainType);
return createExecutableQuery(domainType, resultType, QueryFragmentsAndParameters.forFindAll(entityMetaData),
true)
return createExecutableQuery(domainType, resultType,
QueryFragmentsAndParameters.forFindAll(entityMetaData, this.neo4jMappingContext), true)
.getResults();
});
}
Expand Down Expand Up @@ -367,8 +367,10 @@ public <T> Optional<T> findById(Object id, Class<T> domainType) {
Neo4jPersistentEntity<?> entityMetaData = this.neo4jMappingContext.getRequiredPersistentEntity(domainType);

return createExecutableQuery(domainType, null,
QueryFragmentsAndParameters.forFindById(entityMetaData, TemplateSupport
.convertIdValues(this.neo4jMappingContext, entityMetaData.getRequiredIdProperty(), id)),
QueryFragmentsAndParameters.forFindById(entityMetaData,
TemplateSupport.convertIdValues(this.neo4jMappingContext,
entityMetaData.getRequiredIdProperty(), id),
this.neo4jMappingContext),
true)
.getSingleResult();
});
Expand All @@ -380,17 +382,20 @@ public <T> List<T> findAllById(Iterable<?> ids, Class<T> domainType) {
Neo4jPersistentEntity<?> entityMetaData = this.neo4jMappingContext.getRequiredPersistentEntity(domainType);

return createExecutableQuery(domainType, null,
QueryFragmentsAndParameters.forFindByAllId(entityMetaData, TemplateSupport
.convertIdValues(this.neo4jMappingContext, entityMetaData.getRequiredIdProperty(), ids)),
QueryFragmentsAndParameters.forFindByAllId(entityMetaData,
TemplateSupport.convertIdValues(this.neo4jMappingContext,
entityMetaData.getRequiredIdProperty(), ids),
this.neo4jMappingContext),
true)
.getResults();
});
}

@Override
public <T> T save(T instance) {

return execute(tx -> saveImpl(instance, Collections.emptySet(), null));
Collection<PropertyFilter.ProjectedPath> pps = PropertyFilterSupport
.getInputPropertiesForAggregateBoundary(instance.getClass(), this.neo4jMappingContext);
return execute(tx -> saveImpl(instance, pps, null));

}

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

Set<Class<?>> types = new HashSet<>();
List<T> entities = new ArrayList<>();
Map<Class<?>, Collection<PropertyFilter.ProjectedPath>> includedPropertiesByClass = new HashMap<>();
instances.forEach(instance -> {
entities.add(instance);
types.add(instance.getClass());
includedPropertiesByClass.put(instance.getClass(), PropertyFilterSupport
.getInputPropertiesForAggregateBoundary(instance.getClass(), this.neo4jMappingContext));
});

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

NestedRelationshipProcessingStateMachine stateMachine = new NestedRelationshipProcessingStateMachine(
this.neo4jMappingContext);
return entities.stream().map(e -> saveImpl(e, pps, stateMachine)).collect(Collectors.toList());
return entities.stream()
.map(e -> saveImpl(e,
((includedProperties != null && !includedProperties.isEmpty()) || includeProperty != null) ? pps
: includedPropertiesByClass.get(e.getClass()),
stateMachine))
.collect(Collectors.toList());
}

class Tuple3<T> {
Expand Down Expand Up @@ -628,7 +641,10 @@ class Tuple3<T> {
String internalId = Objects.requireNonNull(idToInternalIdMapping.get(id));
stateMachine.registerInitialObject(t.originalInstance, internalId);
return this.<T>processRelations(entityMetaData, propertyAccessor, t.wasNew, stateMachine,
TemplateSupport.computeIncludePropertyPredicate(pps, entityMetaData));
TemplateSupport.computeIncludePropertyPredicate(
((includedProperties != null && !includedProperties.isEmpty()) || includeProperty != null)
? pps : includedPropertiesByClass.get(t.modifiedInstance.getClass()),
entityMetaData));
}).collect(Collectors.toList());
}

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

var targetPersistentEntity = (Neo4jPersistentEntity<?>) targetNodeDescription;
var queryFragmentsAndParameters = QueryFragmentsAndParameters.forFindById(targetPersistentEntity,
TemplateSupport.convertIdValues(this.neo4jMappingContext,
targetPersistentEntity.getRequiredIdProperty(), relatedInternalId));
var queryFragmentsAndParameters = QueryFragmentsAndParameters
.forFindById(targetPersistentEntity,
TemplateSupport.convertIdValues(this.neo4jMappingContext,
targetPersistentEntity.getRequiredIdProperty(), relatedInternalId),
this.neo4jMappingContext);
var nodeName = Constants.NAME_OF_TYPED_ROOT_NODE.apply(targetNodeDescription).getValue();

return this.neo4jClient
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,15 @@
package org.springframework.data.neo4j.core;

import java.beans.PropertyDescriptor;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.locks.ReentrantLock;
import java.util.function.Predicate;

import org.apiguardian.api.API;

Expand All @@ -29,6 +33,7 @@
import org.springframework.data.neo4j.core.mapping.GraphPropertyDescription;
import org.springframework.data.neo4j.core.mapping.Neo4jMappingContext;
import org.springframework.data.neo4j.core.mapping.Neo4jPersistentEntity;
import org.springframework.data.neo4j.core.mapping.NodeDescription;
import org.springframework.data.neo4j.core.mapping.PropertyFilter;
import org.springframework.data.neo4j.core.mapping.RelationshipDescription;
import org.springframework.data.projection.ProjectionFactory;
Expand All @@ -47,6 +52,9 @@
@API(status = API.Status.INTERNAL, since = "6.1.3")
public final class PropertyFilterSupport {

// A cache to look up if there are aggregate boundaries between two entities.
private static final AggregateBoundaries AGGREGATE_BOUNDARIES = new AggregateBoundaries();

private PropertyFilterSupport() {
}

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

boolean isProjecting = returnedType.isProjecting();
boolean isClosedProjection = factory.getProjectionInformation(potentiallyProjectedType).isClosed();
if (!isProjecting && containsAggregateBoundary(domainType, mappingContext)) {
Collection<PropertyFilter.ProjectedPath> listForAggregate = createListForAggregate(domainType,
mappingContext);
return listForAggregate;
}

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

public static Collection<PropertyFilter.ProjectedPath> getInputPropertiesForAggregateBoundary(Class<?> domainType,
Neo4jMappingContext mappingContext) {
if (!containsAggregateBoundary(domainType, mappingContext)) {
return Collections.emptySet();
}
Collection<PropertyFilter.ProjectedPath> listForAggregate = createListForAggregate(domainType, mappingContext);
return listForAggregate;
}

public static Predicate<PropertyFilter.RelaxedPropertyPath> createRelaxedPropertyPathFilter(Class<?> domainType,
Neo4jMappingContext mappingContext) {
if (!containsAggregateBoundary(domainType, mappingContext)) {
return PropertyFilter.NO_FILTER;
}
Collection<PropertyFilter.RelaxedPropertyPath> relaxedPropertyPathFilter = createRelaxedPropertyPathFilter(
domainType, mappingContext, new HashSet<RelationshipDescription>());
return (rpp) -> {
return relaxedPropertyPathFilter.contains(rpp);
};
}

private static Collection<PropertyFilter.RelaxedPropertyPath> createRelaxedPropertyPathFilter(Class<?> domainType,
Neo4jMappingContext neo4jMappingContext, Set<RelationshipDescription> processedRelationships) {
var relaxedPropertyPath = PropertyFilter.RelaxedPropertyPath.withRootType(domainType);
var relaxedPropertyPaths = new ArrayList<PropertyFilter.RelaxedPropertyPath>();
relaxedPropertyPaths.add(relaxedPropertyPath);
Neo4jPersistentEntity<?> domainEntity = neo4jMappingContext.getRequiredPersistentEntity(domainType);
domainEntity.getGraphProperties().stream().forEach(property -> {
relaxedPropertyPaths.add(relaxedPropertyPath.append(property.getFieldName()));
});
for (RelationshipDescription relationshipDescription : domainEntity.getRelationshipsInHierarchy(any -> true)) {
var target = relationshipDescription.getTarget();
PropertyFilter.RelaxedPropertyPath relationshipPath = relaxedPropertyPath
.append(relationshipDescription.getFieldName());
relaxedPropertyPaths.add(relationshipPath);
processedRelationships.add(relationshipDescription);
createRelaxedPropertyPathFilter(domainType, target, relationshipPath, relaxedPropertyPaths,
processedRelationships);
}
return relaxedPropertyPaths;
}

private static Collection<PropertyFilter.RelaxedPropertyPath> createRelaxedPropertyPathFilter(Class<?> domainType,
NodeDescription<?> nodeDescription, PropertyFilter.RelaxedPropertyPath relaxedPropertyPath,
Collection<PropertyFilter.RelaxedPropertyPath> relaxedPropertyPaths,
Set<RelationshipDescription> processedRelationships) {
// always add the related entity itself
relaxedPropertyPaths.add(relaxedPropertyPath);
if (nodeDescription.hasAggregateBoundaries(domainType)) {
relaxedPropertyPaths.add(relaxedPropertyPath
.append(((Neo4jPersistentEntity<?>) nodeDescription).getRequiredIdProperty().getFieldName()));

return relaxedPropertyPaths;
}
nodeDescription.getGraphProperties().stream().forEach(property -> {
relaxedPropertyPaths.add(relaxedPropertyPath.append(property.getFieldName()));
});
for (RelationshipDescription relationshipDescription : nodeDescription
.getRelationshipsInHierarchy(any -> true)) {
if (processedRelationships.contains(relationshipDescription)) {
continue;
}
var target = relationshipDescription.getTarget();
PropertyFilter.RelaxedPropertyPath relationshipPath = relaxedPropertyPath
.append(relationshipDescription.getFieldName());
relaxedPropertyPaths.add(relationshipPath);
processedRelationships.add(relationshipDescription);
createRelaxedPropertyPathFilter(domainType, target, relationshipPath, relaxedPropertyPaths,
processedRelationships);
}
return relaxedPropertyPaths;
}

private static Collection<PropertyFilter.ProjectedPath> createListForAggregate(Class<?> domainType,
Neo4jMappingContext neo4jMappingContext) {
var relaxedPropertyPath = PropertyFilter.RelaxedPropertyPath.withRootType(domainType);
var filteredProperties = new ArrayList<PropertyFilter.ProjectedPath>();
Neo4jPersistentEntity<?> domainEntity = neo4jMappingContext.getRequiredPersistentEntity(domainType);
domainEntity.getGraphProperties().stream().forEach(property -> {
filteredProperties
.add(new PropertyFilter.ProjectedPath(relaxedPropertyPath.append(property.getFieldName()), false));
});
for (RelationshipDescription relationshipDescription : domainEntity.getRelationshipsInHierarchy(any -> true)) {
var target = relationshipDescription.getTarget();
filteredProperties.addAll(createListForAggregate(domainType, target,
relaxedPropertyPath.append(relationshipDescription.getFieldName())));
}
return filteredProperties;
}

private static Collection<PropertyFilter.ProjectedPath> createListForAggregate(Class<?> domainType,
NodeDescription<?> nodeDescription, PropertyFilter.RelaxedPropertyPath relaxedPropertyPath) {
var filteredProperties = new ArrayList<PropertyFilter.ProjectedPath>();
// always add the related entity itself
filteredProperties.add(new PropertyFilter.ProjectedPath(relaxedPropertyPath, false));
if (nodeDescription.hasAggregateBoundaries(domainType)) {
filteredProperties.add(new PropertyFilter.ProjectedPath(
relaxedPropertyPath
.append(((Neo4jPersistentEntity<?>) nodeDescription).getRequiredIdProperty().getFieldName()),
false));
return filteredProperties;
}
nodeDescription.getGraphProperties().stream().forEach(property -> {
filteredProperties
.add(new PropertyFilter.ProjectedPath(relaxedPropertyPath.append(property.getFieldName()), false));
});
for (RelationshipDescription relationshipDescription : nodeDescription
.getRelationshipsInHierarchy(any -> true)) {
var target = relationshipDescription.getTarget();
filteredProperties.addAll(createListForAggregate(domainType, target,
relaxedPropertyPath.append(relationshipDescription.getFieldName())));
}
return filteredProperties;
}

private static boolean containsAggregateBoundary(Class<?> domainType, Neo4jMappingContext neo4jMappingContext) {
var processedRelationships = new HashSet<RelationshipDescription>();
Neo4jPersistentEntity<?> domainEntity = neo4jMappingContext.getRequiredPersistentEntity(domainType);
if (AGGREGATE_BOUNDARIES.hasEntry(domainEntity, domainType)) {
return AGGREGATE_BOUNDARIES.getCachedStatus(domainEntity, domainType);
}
for (RelationshipDescription relationshipDescription : domainEntity.getRelationshipsInHierarchy(any -> true)) {
var target = relationshipDescription.getTarget();
if (target.hasAggregateBoundaries(domainType)) {
AGGREGATE_BOUNDARIES.add(domainEntity, domainType, true);
return true;
}
processedRelationships.add(relationshipDescription);
boolean containsAggregateBoundary = containsAggregateBoundary(domainType, target, processedRelationships);
AGGREGATE_BOUNDARIES.add(domainEntity, domainType, containsAggregateBoundary);
return containsAggregateBoundary;
}
AGGREGATE_BOUNDARIES.add(domainEntity, domainType, false);
return false;
}

private static boolean containsAggregateBoundary(Class<?> domainType, NodeDescription<?> nodeDescription,
Set<RelationshipDescription> processedRelationships) {
for (RelationshipDescription relationshipDescription : nodeDescription
.getRelationshipsInHierarchy(any -> true)) {
var target = relationshipDescription.getTarget();
Class<?> underlyingClass = nodeDescription.getUnderlyingClass();
if (processedRelationships.contains(relationshipDescription)) {
continue;
}
if (target.hasAggregateBoundaries(domainType)) {
return true;
}
processedRelationships.add(relationshipDescription);
return containsAggregateBoundary(domainType, target, processedRelationships);
}
return false;
}

static Collection<PropertyFilter.ProjectedPath> addPropertiesFrom(Class<?> domainType, Class<?> returnType,
ProjectionFactory projectionFactory, Neo4jMappingContext neo4jMappingContext) {

Expand Down Expand Up @@ -258,4 +425,65 @@ boolean isChildLevel() {

}

record AggregateBoundary(Neo4jPersistentEntity<?> entity, Class<?> domainType, boolean status) {

}

private static final class AggregateBoundaries {

private final Set<AggregateBoundary> aggregateBoundaries = new HashSet<>();

private final ReentrantLock lock = new ReentrantLock();

void add(Neo4jPersistentEntity<?> entity, Class<?> domainType, boolean status) {
try {
this.lock.lock();
for (AggregateBoundary aggregateBoundary : this.aggregateBoundaries) {
if (aggregateBoundary.domainType().equals(domainType) && aggregateBoundary.entity().equals(entity)
&& aggregateBoundary.status() != status) {
throw new IllegalStateException("%s cannot have a different status to %s. Was %s now %s"
.formatted(entity.getUnderlyingClass(), domainType, aggregateBoundary.status(), status));
}
}
this.aggregateBoundaries.add(new AggregateBoundary(entity, domainType, status));
}
finally {
this.lock.unlock();
}
}

boolean hasEntry(Neo4jPersistentEntity<?> entity, Class<?> domainType) {
try {
this.lock.lock();
for (AggregateBoundary aggregateBoundary : this.aggregateBoundaries) {
if (aggregateBoundary.domainType().equals(domainType)
&& aggregateBoundary.entity().equals(entity)) {
return true;
}
}
return false;
}
finally {
this.lock.unlock();
}
}

boolean getCachedStatus(Neo4jPersistentEntity<?> entity, Class<?> domainType) {
try {
this.lock.lock();
for (AggregateBoundary aggregateBoundary : this.aggregateBoundaries) {
if (aggregateBoundary.domainType().equals(domainType)
&& aggregateBoundary.entity().equals(entity)) {
return aggregateBoundary.status();
}
}
return false;
}
finally {
this.lock.unlock();
}
}

}

}
Loading