diff --git a/src/main/antora/modules/ROOT/pages/projections/sdn-projections.adoc b/src/main/antora/modules/ROOT/pages/projections/sdn-projections.adoc index 0807c2d5d7..4346b2de53 100644 --- a/src/main/antora/modules/ROOT/pages/projections/sdn-projections.adoc +++ b/src/main/antora/modules/ROOT/pages/projections/sdn-projections.adoc @@ -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 <> section. + [[projections.sdn.multi-level]] == Multi-level projections @@ -212,3 +215,13 @@ The key to a dynamic projection is to specify the desired projection type as the in a repository like this: ` Collection findByName(String name, Class 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. diff --git a/src/main/java/org/springframework/data/neo4j/core/Neo4jTemplate.java b/src/main/java/org/springframework/data/neo4j/core/Neo4jTemplate.java index 42d6f5d3c2..e069dc85b2 100644 --- a/src/main/java/org/springframework/data/neo4j/core/Neo4jTemplate.java +++ b/src/main/java/org/springframework/data/neo4j/core/Neo4jTemplate.java @@ -254,8 +254,8 @@ public List findAll(Class domainType) { private List doFindAll(Class 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(); }); } @@ -367,8 +367,10 @@ public Optional findById(Object id, Class 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(); }); @@ -380,8 +382,10 @@ public List findAllById(Iterable ids, Class 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(); }); @@ -389,8 +393,9 @@ public List findAllById(Iterable ids, Class domainType) { @Override public T save(T instance) { - - return execute(tx -> saveImpl(instance, Collections.emptySet(), null)); + Collection pps = PropertyFilterSupport + .getInputPropertiesForAggregateBoundary(instance.getClass(), this.neo4jMappingContext); + return execute(tx -> saveImpl(instance, pps, null)); } @@ -550,9 +555,12 @@ private List saveAllImpl(Iterable instances, Set> types = new HashSet<>(); List entities = new ArrayList<>(); + Map, Collection> 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()) { @@ -573,7 +581,12 @@ private List saveAllImpl(Iterable 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 { @@ -628,7 +641,10 @@ class Tuple3 { String internalId = Objects.requireNonNull(idToInternalIdMapping.get(id)); stateMachine.registerInitialObject(t.originalInstance, internalId); return this.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()); } @@ -1182,9 +1198,11 @@ private Optional 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 diff --git a/src/main/java/org/springframework/data/neo4j/core/PropertyFilterSupport.java b/src/main/java/org/springframework/data/neo4j/core/PropertyFilterSupport.java index ffe4add2a6..f53cd58172 100644 --- a/src/main/java/org/springframework/data/neo4j/core/PropertyFilterSupport.java +++ b/src/main/java/org/springframework/data/neo4j/core/PropertyFilterSupport.java @@ -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; @@ -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; @@ -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() { } @@ -61,6 +69,11 @@ public static Collection getInputProperties(Result boolean isProjecting = returnedType.isProjecting(); boolean isClosedProjection = factory.getProjectionInformation(potentiallyProjectedType).isClosed(); + if (!isProjecting && containsAggregateBoundary(domainType, mappingContext)) { + Collection listForAggregate = createListForAggregate(domainType, + mappingContext); + return listForAggregate; + } if (!isProjecting || !isClosedProjection) { return Collections.emptySet(); @@ -84,6 +97,160 @@ public static Collection getInputProperties(Result return filteredProperties; } + public static Collection getInputPropertiesForAggregateBoundary(Class domainType, + Neo4jMappingContext mappingContext) { + if (!containsAggregateBoundary(domainType, mappingContext)) { + return Collections.emptySet(); + } + Collection listForAggregate = createListForAggregate(domainType, mappingContext); + return listForAggregate; + } + + public static Predicate createRelaxedPropertyPathFilter(Class domainType, + Neo4jMappingContext mappingContext) { + if (!containsAggregateBoundary(domainType, mappingContext)) { + return PropertyFilter.NO_FILTER; + } + Collection relaxedPropertyPathFilter = createRelaxedPropertyPathFilter( + domainType, mappingContext, new HashSet()); + return (rpp) -> { + return relaxedPropertyPathFilter.contains(rpp); + }; + } + + private static Collection createRelaxedPropertyPathFilter(Class domainType, + Neo4jMappingContext neo4jMappingContext, Set processedRelationships) { + var relaxedPropertyPath = PropertyFilter.RelaxedPropertyPath.withRootType(domainType); + var relaxedPropertyPaths = new ArrayList(); + 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 createRelaxedPropertyPathFilter(Class domainType, + NodeDescription nodeDescription, PropertyFilter.RelaxedPropertyPath relaxedPropertyPath, + Collection relaxedPropertyPaths, + Set 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 createListForAggregate(Class domainType, + Neo4jMappingContext neo4jMappingContext) { + var relaxedPropertyPath = PropertyFilter.RelaxedPropertyPath.withRootType(domainType); + var filteredProperties = new ArrayList(); + 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 createListForAggregate(Class domainType, + NodeDescription nodeDescription, PropertyFilter.RelaxedPropertyPath relaxedPropertyPath) { + var filteredProperties = new ArrayList(); + // 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(); + 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 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 addPropertiesFrom(Class domainType, Class returnType, ProjectionFactory projectionFactory, Neo4jMappingContext neo4jMappingContext) { @@ -258,4 +425,65 @@ boolean isChildLevel() { } + record AggregateBoundary(Neo4jPersistentEntity entity, Class domainType, boolean status) { + + } + + private static final class AggregateBoundaries { + + private final Set 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(); + } + } + + } + } diff --git a/src/main/java/org/springframework/data/neo4j/core/ReactiveNeo4jTemplate.java b/src/main/java/org/springframework/data/neo4j/core/ReactiveNeo4jTemplate.java index 79942cf110..099e8c340e 100644 --- a/src/main/java/org/springframework/data/neo4j/core/ReactiveNeo4jTemplate.java +++ b/src/main/java/org/springframework/data/neo4j/core/ReactiveNeo4jTemplate.java @@ -243,7 +243,8 @@ public Flux findAll(Class domainType) { private Flux doFindAll(Class domainType, @Nullable Class resultType) { Neo4jPersistentEntity entityMetaData = this.neo4jMappingContext.getRequiredPersistentEntity(domainType); - return createExecutableQuery(domainType, resultType, QueryFragmentsAndParameters.forFindAll(entityMetaData)) + return createExecutableQuery(domainType, resultType, + QueryFragmentsAndParameters.forFindAll(entityMetaData, this.neo4jMappingContext)) .flatMapMany(ExecutableQuery::getResults); } @@ -348,7 +349,8 @@ public Mono findById(Object id, Class domainType) { return executeReadOnly(createExecutableQuery(domainType, null, QueryFragmentsAndParameters.forFindById(entityMetaData, TemplateSupport.convertIdValues(this.neo4jMappingContext, - entityMetaData.getRequiredIdProperty(), id))) + entityMetaData.getRequiredIdProperty(), id), + this.neo4jMappingContext)) .flatMap(ExecutableQuery::getSingleResult)); } @@ -360,7 +362,8 @@ public Flux findAllById(Iterable ids, Class domainType) { return executeReadOnly(createExecutableQuery(domainType, null, QueryFragmentsAndParameters.forFindByAllId(entityMetaData, TemplateSupport.convertIdValues(this.neo4jMappingContext, - entityMetaData.getRequiredIdProperty(), ids))) + entityMetaData.getRequiredIdProperty(), ids), + this.neo4jMappingContext)) .flatMapMany(ExecutableQuery::getResults)); } @@ -373,8 +376,9 @@ public Mono> toExecutableQuery(Class domainType, @Override public Mono save(T instance) { - - return execute(saveImpl(instance, Collections.emptySet(), null)); + Collection pps = PropertyFilterSupport + .getInputPropertiesForAggregateBoundary(instance.getClass(), this.neo4jMappingContext); + return execute(saveImpl(instance, pps, null)); } @Override @@ -625,9 +629,12 @@ private Flux saveAllImpl(Iterable instances, Set> types = new HashSet<>(); List entities = new ArrayList<>(); + Map, Collection> 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()) { @@ -648,7 +655,12 @@ private Flux saveAllImpl(Iterable instances, NestedRelationshipProcessingStateMachine stateMachine = new NestedRelationshipProcessingStateMachine( this.neo4jMappingContext); - return Flux.fromIterable(entities).concatMap(e -> this.saveImpl(e, pps, stateMachine)); + + return Flux.fromIterable(entities) + .concatMap(e -> this.saveImpl(e, + ((includedProperties != null && !includedProperties.isEmpty()) || includeProperty != null) ? pps + : includedPropertiesByClass.get(e.getClass()), + stateMachine)); } @SuppressWarnings("unchecked") // We can safely assume here that we have a @@ -685,7 +697,13 @@ private Flux saveAllImpl(Iterable instances, PersistentPropertyAccessor propertyAccessor = entityMetaData.getPropertyAccessor(t.getT3()); Neo4jPersistentProperty idProperty = entityMetaData.getRequiredIdProperty(); return processRelations(entityMetaData, propertyAccessor, t.getT2(), ctx.get("stateMachine"), - ctx.get("knownRelIds"), TemplateSupport.computeIncludePropertyPredicate(pps, entityMetaData)); + ctx.get("knownRelIds"), + TemplateSupport + .computeIncludePropertyPredicate( + ((includedProperties != null && !includedProperties.isEmpty()) + || includeProperty != null) ? pps + : includedPropertiesByClass.get(t.getT3().getClass()), + entityMetaData)); })))) .contextWrite(ctx -> ctx .put("stateMachine", new NestedRelationshipProcessingStateMachine(this.neo4jMappingContext, null, null)) @@ -1291,9 +1309,11 @@ private Mono getRelationshipId(Statement statement, @Nullable Neo4jPersi private Mono 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 diff --git a/src/main/java/org/springframework/data/neo4j/core/mapping/CypherGenerator.java b/src/main/java/org/springframework/data/neo4j/core/mapping/CypherGenerator.java index dbf0b04072..95c60b5c6c 100644 --- a/src/main/java/org/springframework/data/neo4j/core/mapping/CypherGenerator.java +++ b/src/main/java/org/springframework/data/neo4j/core/mapping/CypherGenerator.java @@ -743,6 +743,12 @@ public Collection createReturnStatementForExists(Neo4jPersistentEnti return Collections.singleton(Cypher.count(Constants.NAME_OF_TYPED_ROOT_NODE.apply(nodeDescription))); } + /** + * Used for create statements from the (Reactive)Neo4jTemplate. This shouldn't be used + * for any find operations. + * @param nodeDescription persistentEntity + * @return return expression for entity + */ public Collection createReturnStatementForMatch(Neo4jPersistentEntity nodeDescription) { return createReturnStatementForMatch(nodeDescription, PropertyFilter.NO_FILTER); } diff --git a/src/main/java/org/springframework/data/neo4j/core/mapping/DefaultNeo4jPersistentEntity.java b/src/main/java/org/springframework/data/neo4j/core/mapping/DefaultNeo4jPersistentEntity.java index bf071d61bc..3b953a2887 100644 --- a/src/main/java/org/springframework/data/neo4j/core/mapping/DefaultNeo4jPersistentEntity.java +++ b/src/main/java/org/springframework/data/neo4j/core/mapping/DefaultNeo4jPersistentEntity.java @@ -95,6 +95,8 @@ final class DefaultNeo4jPersistentEntity extends BasicPersistentEntity vectorProperty; + private final Lazy>> aggregateBoundaries; + @Nullable private NodeDescription parentNodeDescription; @@ -119,6 +121,16 @@ final class DefaultNeo4jPersistentEntity extends BasicPersistentEntity> computeAggregateBoundaries() { + Node nodeAnnotation = AnnotatedElementUtils.findMergedAnnotation(this.getType(), Node.class); + if (nodeAnnotation == null || nodeAnnotation.aggregateBoundary().length == 0) { + return List.of(); + } + return Arrays.stream(nodeAnnotation.aggregateBoundary()).toList(); } /** @@ -629,6 +641,11 @@ public boolean containsPossibleCircles(Predicate> getAggregateBoundaries() { + return this.aggregateBoundaries.get(); + } + private boolean calculatePossibleCircles(Predicate includeField) { Collection allRelationships = new HashSet<>(getRelationshipsInHierarchy(includeField)); diff --git a/src/main/java/org/springframework/data/neo4j/core/mapping/NodeDescription.java b/src/main/java/org/springframework/data/neo4j/core/mapping/NodeDescription.java index 80e0650b45..94468bfd34 100644 --- a/src/main/java/org/springframework/data/neo4j/core/mapping/NodeDescription.java +++ b/src/main/java/org/springframework/data/neo4j/core/mapping/NodeDescription.java @@ -181,4 +181,10 @@ default Expression getIdExpression() { */ boolean describesInterface(); + default boolean hasAggregateBoundaries(Class domainType) { + return getAggregateBoundaries().contains(domainType); + } + + List> getAggregateBoundaries(); + } diff --git a/src/main/java/org/springframework/data/neo4j/core/mapping/PropertyFilter.java b/src/main/java/org/springframework/data/neo4j/core/mapping/PropertyFilter.java index 8197e8647e..de2a1f539e 100644 --- a/src/main/java/org/springframework/data/neo4j/core/mapping/PropertyFilter.java +++ b/src/main/java/org/springframework/data/neo4j/core/mapping/PropertyFilter.java @@ -17,6 +17,7 @@ import java.util.Collection; import java.util.HashSet; +import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.function.Predicate; @@ -245,6 +246,20 @@ public RelaxedPropertyPath replaceLastSegment(@Nullable String lastSegment) { getSegment().equals(this.dotPath) ? lastSegment : getSegment() + "." + lastSegment, this.type); } + @Override + public boolean equals(Object o) { + if (o == null || this.getClass() != o.getClass()) { + return false; + } + RelaxedPropertyPath that = (RelaxedPropertyPath) o; + return Objects.equals(this.dotPath, that.dotPath) && Objects.equals(this.type, that.type); + } + + @Override + public int hashCode() { + return Objects.hash(this.dotPath, this.type); + } + } /** diff --git a/src/main/java/org/springframework/data/neo4j/core/schema/Node.java b/src/main/java/org/springframework/data/neo4j/core/schema/Node.java index 036605b743..cb8c95d7aa 100644 --- a/src/main/java/org/springframework/data/neo4j/core/schema/Node.java +++ b/src/main/java/org/springframework/data/neo4j/core/schema/Node.java @@ -62,4 +62,6 @@ */ String primaryLabel() default ""; + Class[] aggregateBoundary() default {}; + } diff --git a/src/main/java/org/springframework/data/neo4j/repository/query/QueryFragmentsAndParameters.java b/src/main/java/org/springframework/data/neo4j/repository/query/QueryFragmentsAndParameters.java index 4c95f6bc04..dc28dd6087 100644 --- a/src/main/java/org/springframework/data/neo4j/repository/query/QueryFragmentsAndParameters.java +++ b/src/main/java/org/springframework/data/neo4j/repository/query/QueryFragmentsAndParameters.java @@ -40,6 +40,7 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.domain.ScrollPosition; import org.springframework.data.domain.Sort; +import org.springframework.data.neo4j.core.PropertyFilterSupport; import org.springframework.data.neo4j.core.mapping.Constants; import org.springframework.data.neo4j.core.mapping.CypherGenerator; import org.springframework.data.neo4j.core.mapping.Neo4jMappingContext; @@ -95,11 +96,14 @@ public QueryFragmentsAndParameters(@NonNull String cypherQuery, Map entityMetaData, Object idValues) { + public static QueryFragmentsAndParameters forFindById(Neo4jPersistentEntity entityMetaData, Object idValues, + Neo4jMappingContext mappingContext) { Map parameters = Collections.singletonMap(Constants.NAME_OF_ID, idValues); QueryFragments queryFragments = forFindOrExistsById(entityMetaData); - queryFragments.setReturnExpressions(cypherGenerator.createReturnStatementForMatch(entityMetaData)); + queryFragments + .setReturnExpressions(cypherGenerator.createReturnStatementForMatch(entityMetaData, PropertyFilterSupport + .createRelaxedPropertyPathFilter(entityMetaData.getUnderlyingClass(), mappingContext))); return new QueryFragmentsAndParameters(entityMetaData, queryFragments, parameters, null); } @@ -121,7 +125,8 @@ private static QueryFragments forFindOrExistsById(Neo4jPersistentEntity entit return queryFragments; } - public static QueryFragmentsAndParameters forFindByAllId(Neo4jPersistentEntity entityMetaData, Object idValues) { + public static QueryFragmentsAndParameters forFindByAllId(Neo4jPersistentEntity entityMetaData, Object idValues, + Neo4jMappingContext mappingContext) { Map parameters = Collections.singletonMap(Constants.NAME_OF_IDS, idValues); Node container = cypherGenerator.createRootNode(entityMetaData); @@ -142,15 +147,20 @@ public static QueryFragmentsAndParameters forFindByAllId(Neo4jPersistentEntity entityMetaData) { + public static QueryFragmentsAndParameters forFindAll(Neo4jPersistentEntity entityMetaData, + Neo4jMappingContext mappingContext) { QueryFragments queryFragments = new QueryFragments(); queryFragments.addMatchOn(cypherGenerator.createRootNode(entityMetaData)); queryFragments.setCondition(Cypher.noCondition()); - queryFragments.setReturnExpressions(cypherGenerator.createReturnStatementForMatch(entityMetaData)); + queryFragments + .setReturnExpressions(cypherGenerator.createReturnStatementForMatch(entityMetaData, PropertyFilterSupport + .createRelaxedPropertyPathFilter(entityMetaData.getUnderlyingClass(), mappingContext))); return new QueryFragmentsAndParameters(entityMetaData, queryFragments, Map.of(), null); } diff --git a/src/test/java/org/springframework/data/neo4j/integration/imperative/AggregateBoundaryIT.java b/src/test/java/org/springframework/data/neo4j/integration/imperative/AggregateBoundaryIT.java new file mode 100644 index 0000000000..25bff87309 --- /dev/null +++ b/src/test/java/org/springframework/data/neo4j/integration/imperative/AggregateBoundaryIT.java @@ -0,0 +1,382 @@ +/* + * Copyright 2011-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.neo4j.integration.imperative; + +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.neo4j.driver.Driver; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.neo4j.core.DatabaseSelectionProvider; +import org.springframework.data.neo4j.core.transaction.Neo4jBookmarkManager; +import org.springframework.data.neo4j.core.transaction.Neo4jTransactionManager; +import org.springframework.data.neo4j.integration.shared.common.AggregateEntitiesWithGeneratedIds.IntermediateEntity; +import org.springframework.data.neo4j.integration.shared.common.AggregateEntitiesWithGeneratedIds.StartEntity; +import org.springframework.data.neo4j.integration.shared.common.AggregateEntitiesWithInternalIds.StartEntityInternalId; +import org.springframework.data.neo4j.repository.Neo4jRepository; +import org.springframework.data.neo4j.repository.config.EnableNeo4jRepositories; +import org.springframework.data.neo4j.test.BookmarkCapture; +import org.springframework.data.neo4j.test.Neo4jExtension; +import org.springframework.data.neo4j.test.Neo4jImperativeTestConfiguration; +import org.springframework.data.neo4j.test.Neo4jIntegrationTest; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.annotation.EnableTransactionManagement; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Gerrit Meier + */ +@Neo4jIntegrationTest +class AggregateBoundaryIT { + + protected static Neo4jExtension.Neo4jConnectionSupport neo4jConnectionSupport; + + private final String startEntityUuid = "1476db91-10e2-4202-a63f-524be2dcb7fe"; + + private String startEntityInternalId; + + @BeforeEach + void setup(@Autowired Driver driver, @Autowired BookmarkCapture bookmarkCapture) { + try (var session = driver.session(bookmarkCapture.createSessionConfig())) { + session.run("MATCH (n) detach delete n").consume(); + this.startEntityInternalId = session + .run(""" + CREATE (se:StartEntityInternalId{name:'start'})-[:CONNECTED]->(ie:IntermediateEntityInternalId)-[:CONNECTED]->(dae:DifferentAggregateEntityInternalId{name:'some_name'}) + RETURN elementId(se) as id; + """) + .single() + .get("id") + .asString(); + session + .run(""" + CREATE (se:StartEntity{name:'start'})-[:CONNECTED]->(ie:IntermediateEntity)-[:CONNECTED]->(dae:DifferentAggregateEntity{name:'some_name'}) + SET se.id = $uuid1, ie.id = $uuid2, dae.id = $uuid3; + """, + Map.of("uuid1", this.startEntityInternalId, "uuid2", UUID.randomUUID().toString(), "uuid3", + UUID.randomUUID().toString())) + .consume(); + bookmarkCapture.seedWith(session.lastBookmarks()); + } + } + + @AfterEach + void tearDown(@Autowired Driver driver, @Autowired BookmarkCapture bookmarkCapture) { + try (var session = driver.session(bookmarkCapture.createSessionConfig())) { + session.run("MATCH (n) detach delete n").consume(); + bookmarkCapture.seedWith(session.lastBookmarks()); + } + } + + @Test + void shouldOnlyReportIdForDifferentAggregateEntityWithGenericFindAll( + @Autowired AggregateRepositoryWithInternalId repository) { + var startEntity = repository.findAll().get(0); + assertThatLimitingWorks(startEntity); + } + + @Test + void shouldOnlyReportIdForDifferentAggregateEntityWithGenericFindAllById( + @Autowired AggregateRepositoryWithInternalId repository) { + var startEntity = repository.findAllById(List.of(this.startEntityInternalId)).get(0); + assertThatLimitingWorks(startEntity); + } + + @Test + void shouldOnlyReportIdForDifferentAggregateEntityWithGenericFindById( + @Autowired AggregateRepositoryWithInternalId repository) { + var startEntity = repository.findById(this.startEntityInternalId).get(); + assertThatLimitingWorks(startEntity); + } + + @Test + void shouldOnlyReportIdForDifferentAggregateEntityWithPartTreeFindAll( + @Autowired AggregateRepositoryWithInternalId repository) { + var startEntity = repository.findAllByName("start").get(0); + assertThatLimitingWorks(startEntity); + } + + @Test + void shouldOnlyReportIdForDifferentAggregateEntityWithEmptyParameterPartTreeFindAll( + @Autowired AggregateRepositoryWithInternalId repository) { + var startEntity = repository.findAllBy().get(0); + assertThatLimitingWorks(startEntity); + } + + @Test + void shouldOnlyReportIdForDifferentAggregateEntityWithPartTreeFindOne( + @Autowired AggregateRepositoryWithInternalId repository) { + var startEntity = repository.findByName("start"); + assertThatLimitingWorks(startEntity); + } + + @Test + void shouldOnlyReportIdForDifferentAggregateEntityWithEmptyParameterPartTreeFindOne( + @Autowired AggregateRepositoryWithInternalId repository) { + var startEntity = repository.findBy(); + assertThatLimitingWorks(startEntity); + } + + @Test + void shouldOnlyPersistUntilLimitWithSave(@Autowired AggregateRepositoryWithInternalId repository, + @Autowired Driver driver, @Autowired BookmarkCapture bookmarkCapture) { + var startEntity = repository.findAllBy().get(0); + startEntity.getIntermediateEntity().getDifferentAggregateEntity().setName("different"); + repository.save(startEntity); + + try (var session = driver.session(bookmarkCapture.createSessionConfig())) { + var name = session.executeRead(tx -> tx.run( + "MATCH (:StartEntityInternalId)-[:CONNECTED]->(:IntermediateEntityInternalId)-[:CONNECTED]->(dae:DifferentAggregateEntityInternalId) return dae.name as name") + .single() + .get("name") + .asString()); + bookmarkCapture.seedWith(session.lastBookmarks()); + assertThat(name).isEqualTo("some_name"); + } + } + + @Test + void shouldOnlyPersistUntilLimitWithSaveAll(@Autowired AggregateRepositoryWithInternalId repository, + @Autowired Driver driver, @Autowired BookmarkCapture bookmarkCapture) { + var startEntity = repository.findAllBy().get(0); + startEntity.getIntermediateEntity().getDifferentAggregateEntity().setName("different"); + repository.saveAll(List.of(startEntity)); + + try (var session = driver.session(bookmarkCapture.createSessionConfig())) { + var name = session.executeRead(tx -> tx.run( + "MATCH (:StartEntityInternalId)-[:CONNECTED]->(:IntermediateEntityInternalId)-[:CONNECTED]->(dae:DifferentAggregateEntityInternalId) return dae.name as name") + .single() + .get("name") + .asString()); + bookmarkCapture.seedWith(session.lastBookmarks()); + assertThat(name).isEqualTo("some_name"); + } + } + + @Test + void shouldOnlyReportIdForDifferentAggregateEntityWithGenericFindAllGeneratedId( + @Autowired AggregateRepositoryWithGeneratedIdId repository) { + var startEntity = repository.findAll().get(0); + assertThatLimitingWorks(startEntity); + } + + @Test + void shouldOnlyReportIdForDifferentAggregateEntityWithGenericFindAllByIdGeneratedId( + @Autowired AggregateRepositoryWithGeneratedIdId repository) { + var startEntity = repository.findAllById(List.of(this.startEntityInternalId)).get(0); + assertThatLimitingWorks(startEntity); + } + + @Test + void shouldOnlyReportIdForDifferentAggregateEntityWithGenericFindByIdGeneratedId( + @Autowired AggregateRepositoryWithGeneratedIdId repository) { + var startEntity = repository.findById(this.startEntityInternalId).get(); + assertThatLimitingWorks(startEntity); + } + + @Test + void shouldOnlyReportIdForDifferentAggregateEntityWithPartTreeFindAllGeneratedId( + @Autowired AggregateRepositoryWithGeneratedIdId repository) { + var startEntity = repository.findAllByName("start").get(0); + assertThatLimitingWorks(startEntity); + } + + @Test + void shouldOnlyReportIdForDifferentAggregateEntityWithEmptyParameterPartTreeFindAllGeneratedId( + @Autowired AggregateRepositoryWithGeneratedIdId repository) { + var startEntity = repository.findAllBy().get(0); + assertThatLimitingWorks(startEntity); + } + + @Test + void shouldOnlyReportIdForDifferentAggregateEntityWithPartTreeFindOneGeneratedId( + @Autowired AggregateRepositoryWithGeneratedIdId repository) { + var startEntity = repository.findByName("start"); + assertThatLimitingWorks(startEntity); + } + + @Test + void shouldOnlyReportIdForDifferentAggregateEntityWithEmptyParameterPartTreeFindOneGeneratedId( + @Autowired AggregateRepositoryWithGeneratedIdId repository) { + var startEntity = repository.findBy(); + assertThatLimitingWorks(startEntity); + } + + @Test + void shouldOnlyPersistUntilLimitWithSaveGeneratedId(@Autowired AggregateRepositoryWithGeneratedIdId repository, + @Autowired Driver driver, @Autowired BookmarkCapture bookmarkCapture) { + var startEntity = repository.findAllBy().get(0); + startEntity.getIntermediateEntity().getDifferentAggregateEntity().setName("different"); + repository.save(startEntity); + + try (var session = driver.session(bookmarkCapture.createSessionConfig())) { + var name = session.executeRead(tx -> tx.run( + "MATCH (:StartEntity)-[:CONNECTED]->(:IntermediateEntity)-[:CONNECTED]->(dae:DifferentAggregateEntity) return dae.name as name") + .single() + .get("name") + .asString()); + bookmarkCapture.seedWith(session.lastBookmarks()); + assertThat(name).isEqualTo("some_name"); + } + } + + @Test + void shouldOnlyPersistUntilLimitWithSaveAllGeneratedId(@Autowired AggregateRepositoryWithGeneratedIdId repository, + @Autowired Driver driver, @Autowired BookmarkCapture bookmarkCapture) { + var startEntity = repository.findAllBy().get(0); + startEntity.getIntermediateEntity().getDifferentAggregateEntity().setName("different"); + repository.saveAll(List.of(startEntity)); + + try (var session = driver.session(bookmarkCapture.createSessionConfig())) { + var name = session.executeRead(tx -> tx.run( + "MATCH (:StartEntity)-[:CONNECTED]->(:IntermediateEntity)-[:CONNECTED]->(dae:DifferentAggregateEntity) return dae.name as name") + .single() + .get("name") + .asString()); + bookmarkCapture.seedWith(session.lastBookmarks()); + assertThat(name).isEqualTo("some_name"); + } + } + + @Test + void shouldAllowWiderProjectionThanDomain(@Autowired AggregateRepositoryWithGeneratedIdId repository) { + var startEntity = repository.findProjectionBy(); + assertThat(startEntity.getIntermediateEntity().getDifferentAggregateEntity().getName()).isEqualTo("some_name"); + } + + @Test + void shouldLoadCompleteEntityWhenQueriedFromDifferentEntity(@Autowired IntermediateEntityRepository repository) { + var intermediateEntity = repository.findAll().get(0); + assertThat(intermediateEntity.getDifferentAggregateEntity().getName()).isEqualTo("some_name"); + } + + private void assertThatLimitingWorks(StartEntity startEntity) { + assertThat(startEntity).isNotNull(); + assertThat(startEntity.getId()).isNotNull(); + assertThat(startEntity.getIntermediateEntity()).isNotNull(); + assertThat(startEntity.getIntermediateEntity().getId()).isNotNull(); + assertThat(startEntity.getIntermediateEntity().getDifferentAggregateEntity()).isNotNull(); + assertThat(startEntity.getIntermediateEntity().getDifferentAggregateEntity().getId()).isNotNull(); + assertThat(startEntity.getIntermediateEntity().getDifferentAggregateEntity().getName()).isNull(); + } + + private void assertThatLimitingWorks(StartEntityInternalId startEntity) { + assertThat(startEntity).isNotNull(); + assertThat(startEntity.getId()).isNotNull(); + assertThat(startEntity.getIntermediateEntity()).isNotNull(); + assertThat(startEntity.getIntermediateEntity().getId()).isNotNull(); + assertThat(startEntity.getIntermediateEntity().getDifferentAggregateEntity()).isNotNull(); + assertThat(startEntity.getIntermediateEntity().getDifferentAggregateEntity().getId()).isNotNull(); + assertThat(startEntity.getIntermediateEntity().getDifferentAggregateEntity().getName()).isNull(); + } + + interface AggregateRepositoryWithInternalId extends Neo4jRepository { + + List findAllBy(); + + List findAllByName(String name); + + StartEntityInternalId findBy(); + + StartEntityInternalId findByName(String name); + + } + + interface AggregateRepositoryWithGeneratedIdId extends Neo4jRepository { + + List findAllBy(); + + List findAllByName(String name); + + StartEntity findBy(); + + StartEntity findByName(String name); + + StartEntityProjection findProjectionBy(); + + } + + interface IntermediateEntityRepository extends Neo4jRepository { + + } + + interface StartEntityProjection { + + String getName(); + + IntermediateEntityProjection getIntermediateEntity(); + + } + + interface IntermediateEntityProjection { + + DifferentAggregateEntityProjection getDifferentAggregateEntity(); + + } + + interface DifferentAggregateEntityProjection { + + String getName(); + + } + + @Configuration + @EnableTransactionManagement + @EnableNeo4jRepositories(considerNestedRepositories = true) + static class Config extends Neo4jImperativeTestConfiguration { + + @Bean + @Override + public Driver driver() { + return neo4jConnectionSupport.getDriver(); + } + + @Override + protected Collection getMappingBasePackages() { + return Collections.singleton(AggregateBoundaryIT.class.getPackage().getName()); + } + + @Bean + BookmarkCapture bookmarkCapture() { + return new BookmarkCapture(); + } + + @Override + public PlatformTransactionManager transactionManager(Driver driver, + DatabaseSelectionProvider databaseNameProvider) { + + BookmarkCapture bookmarkCapture = bookmarkCapture(); + return new Neo4jTransactionManager(driver, databaseNameProvider, + Neo4jBookmarkManager.create(bookmarkCapture)); + } + + @Override + public boolean isCypher5Compatible() { + return neo4jConnectionSupport.isCypher5SyntaxCompatible(); + } + + } + +} diff --git a/src/test/java/org/springframework/data/neo4j/integration/reactive/ReactiveAggregateBoundaryIT.java b/src/test/java/org/springframework/data/neo4j/integration/reactive/ReactiveAggregateBoundaryIT.java new file mode 100644 index 0000000000..0efaa4dbd9 --- /dev/null +++ b/src/test/java/org/springframework/data/neo4j/integration/reactive/ReactiveAggregateBoundaryIT.java @@ -0,0 +1,403 @@ +/* + * Copyright 2011-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.neo4j.integration.reactive; + +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.neo4j.driver.Driver; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.neo4j.core.ReactiveDatabaseSelectionProvider; +import org.springframework.data.neo4j.core.transaction.Neo4jBookmarkManager; +import org.springframework.data.neo4j.core.transaction.ReactiveNeo4jTransactionManager; +import org.springframework.data.neo4j.integration.shared.common.AggregateEntitiesWithGeneratedIds.IntermediateEntity; +import org.springframework.data.neo4j.integration.shared.common.AggregateEntitiesWithGeneratedIds.StartEntity; +import org.springframework.data.neo4j.integration.shared.common.AggregateEntitiesWithInternalIds.StartEntityInternalId; +import org.springframework.data.neo4j.repository.ReactiveNeo4jRepository; +import org.springframework.data.neo4j.repository.config.EnableReactiveNeo4jRepositories; +import org.springframework.data.neo4j.test.BookmarkCapture; +import org.springframework.data.neo4j.test.Neo4jExtension; +import org.springframework.data.neo4j.test.Neo4jIntegrationTest; +import org.springframework.data.neo4j.test.Neo4jReactiveTestConfiguration; +import org.springframework.transaction.ReactiveTransactionManager; +import org.springframework.transaction.annotation.EnableTransactionManagement; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Gerrit Meier + */ +@Neo4jIntegrationTest +@Tag(Neo4jExtension.NEEDS_REACTIVE_SUPPORT) +class ReactiveAggregateBoundaryIT { + + protected static Neo4jExtension.Neo4jConnectionSupport neo4jConnectionSupport; + + private final String startEntityUuid = "1476db91-10e2-4202-a63f-524be2dcb7fe"; + + private String startEntityInternalId; + + @BeforeEach + void setup(@Autowired Driver driver, @Autowired BookmarkCapture bookmarkCapture) { + try (var session = driver.session(bookmarkCapture.createSessionConfig())) { + session.run("MATCH (n) detach delete n").consume(); + this.startEntityInternalId = session + .run(""" + CREATE (se:StartEntityInternalId{name:'start'})-[:CONNECTED]->(ie:IntermediateEntityInternalId)-[:CONNECTED]->(dae:DifferentAggregateEntityInternalId{name:'some_name'}) + RETURN elementId(se) as id; + """) + .single() + .get("id") + .asString(); + session + .run(""" + CREATE (se:StartEntity{name:'start'})-[:CONNECTED]->(ie:IntermediateEntity)-[:CONNECTED]->(dae:DifferentAggregateEntity{name:'some_name'}) + SET se.id = $uuid1, ie.id = $uuid2, dae.id = $uuid3; + """, + Map.of("uuid1", this.startEntityInternalId, "uuid2", UUID.randomUUID().toString(), "uuid3", + UUID.randomUUID().toString())) + .consume(); + bookmarkCapture.seedWith(session.lastBookmarks()); + } + } + + @AfterEach + void tearDown(@Autowired Driver driver, @Autowired BookmarkCapture bookmarkCapture) { + try (var session = driver.session(bookmarkCapture.createSessionConfig())) { + session.run("MATCH (n) detach delete n").consume(); + bookmarkCapture.seedWith(session.lastBookmarks()); + } + } + + @Test + void shouldOnlyReportIdForDifferentAggregateEntityWithGenericFindAll( + @Autowired ReactiveAggregateRepositoryWithInternalId repository) { + StepVerifier.create(repository.findAll()).assertNext(startEntity -> { + assertThatLimitingWorks(startEntity); + }).verifyComplete(); + } + + @Test + void shouldOnlyReportIdForDifferentAggregateEntityWithGenericFindAllById( + @Autowired ReactiveAggregateRepositoryWithInternalId repository) { + StepVerifier.create(repository.findAllById(List.of(this.startEntityInternalId))).assertNext(startEntity -> { + assertThatLimitingWorks(startEntity); + }).verifyComplete(); + } + + @Test + void shouldOnlyReportIdForDifferentAggregateEntityWithGenericFindById( + @Autowired ReactiveAggregateRepositoryWithInternalId repository) { + StepVerifier.create(repository.findById(this.startEntityInternalId)).assertNext(startEntity -> { + assertThatLimitingWorks(startEntity); + }).verifyComplete(); + } + + @Test + void shouldOnlyReportIdForDifferentAggregateEntityWithPartTreeFindAll( + @Autowired ReactiveAggregateRepositoryWithInternalId repository) { + StepVerifier.create(repository.findAllByName("start")).assertNext(startEntity -> { + assertThatLimitingWorks(startEntity); + }).verifyComplete(); + } + + @Test + void shouldOnlyReportIdForDifferentAggregateEntityWithEmptyParameterPartTreeFindAll( + @Autowired ReactiveAggregateRepositoryWithInternalId repository) { + StepVerifier.create(repository.findAllBy()).assertNext(startEntity -> { + assertThatLimitingWorks(startEntity); + }).verifyComplete(); + } + + @Test + void shouldOnlyReportIdForDifferentAggregateEntityWithPartTreeFindOne( + @Autowired ReactiveAggregateRepositoryWithInternalId repository) { + StepVerifier.create(repository.findByName("start")).assertNext(startEntity -> { + assertThatLimitingWorks(startEntity); + }).verifyComplete(); + } + + @Test + void shouldOnlyReportIdForDifferentAggregateEntityWithEmptyParameterPartTreeFindOne( + @Autowired ReactiveAggregateRepositoryWithInternalId repository) { + StepVerifier.create(repository.findBy()).assertNext(startEntity -> { + assertThatLimitingWorks(startEntity); + }).verifyComplete(); + } + + @Test + void shouldOnlyPersistUntilLimitWithSave(@Autowired ReactiveAggregateRepositoryWithInternalId repository, + @Autowired Driver driver, @Autowired BookmarkCapture bookmarkCapture) { + var startEntity = repository.findAllBy().blockLast(); + startEntity.getIntermediateEntity().getDifferentAggregateEntity().setName("different"); + repository.save(startEntity).block(); + + try (var session = driver.session(bookmarkCapture.createSessionConfig())) { + var name = session.executeRead(tx -> tx.run( + "MATCH (:StartEntityInternalId)-[:CONNECTED]->(:IntermediateEntityInternalId)-[:CONNECTED]->(dae:DifferentAggregateEntityInternalId) return dae.name as name") + .single() + .get("name") + .asString()); + bookmarkCapture.seedWith(session.lastBookmarks()); + assertThat(name).isEqualTo("some_name"); + } + } + + @Test + void shouldOnlyPersistUntilLimitWithSaveAll(@Autowired ReactiveAggregateRepositoryWithInternalId repository, + @Autowired Driver driver, @Autowired BookmarkCapture bookmarkCapture) { + var startEntity = repository.findAllBy().blockLast(); + startEntity.getIntermediateEntity().getDifferentAggregateEntity().setName("different"); + repository.saveAll(List.of(startEntity)).blockLast(); + + try (var session = driver.session(bookmarkCapture.createSessionConfig())) { + var name = session.executeRead(tx -> tx.run( + "MATCH (:StartEntityInternalId)-[:CONNECTED]->(:IntermediateEntityInternalId)-[:CONNECTED]->(dae:DifferentAggregateEntityInternalId) return dae.name as name") + .single() + .get("name") + .asString()); + bookmarkCapture.seedWith(session.lastBookmarks()); + assertThat(name).isEqualTo("some_name"); + } + } + + @Test + void shouldOnlyReportIdForDifferentAggregateEntityWithGenericFindAllGeneratedId( + @Autowired ReactiveAggregateRepositoryWithGeneratedIdId repository) { + StepVerifier.create(repository.findAll()).assertNext(startEntity -> { + assertThatLimitingWorks(startEntity); + }).verifyComplete(); + } + + @Test + void shouldOnlyReportIdForDifferentAggregateEntityWithGenericFindAllByIdGeneratedId( + @Autowired ReactiveAggregateRepositoryWithGeneratedIdId repository) { + StepVerifier.create(repository.findAllById(List.of(this.startEntityInternalId))).assertNext(startEntity -> { + assertThatLimitingWorks(startEntity); + }).verifyComplete(); + } + + @Test + void shouldOnlyReportIdForDifferentAggregateEntityWithGenericFindByIdGeneratedId( + @Autowired ReactiveAggregateRepositoryWithGeneratedIdId repository) { + StepVerifier.create(repository.findById(this.startEntityInternalId)).assertNext(startEntity -> { + assertThatLimitingWorks(startEntity); + }).verifyComplete(); + } + + @Test + void shouldOnlyReportIdForDifferentAggregateEntityWithPartTreeFindAllGeneratedId( + @Autowired ReactiveAggregateRepositoryWithGeneratedIdId repository) { + StepVerifier.create(repository.findAllByName("start")).assertNext(startEntity -> { + assertThatLimitingWorks(startEntity); + }).verifyComplete(); + } + + @Test + void shouldOnlyReportIdForDifferentAggregateEntityWithEmptyParameterPartTreeFindAllGeneratedId( + @Autowired ReactiveAggregateRepositoryWithGeneratedIdId repository) { + StepVerifier.create(repository.findAllBy()).assertNext(startEntity -> { + assertThatLimitingWorks(startEntity); + }).verifyComplete(); + } + + @Test + void shouldOnlyReportIdForDifferentAggregateEntityWithPartTreeFindOneGeneratedId( + @Autowired ReactiveAggregateRepositoryWithGeneratedIdId repository) { + StepVerifier.create(repository.findByName("start")).assertNext(startEntity -> { + assertThatLimitingWorks(startEntity); + }).verifyComplete(); + } + + @Test + void shouldOnlyReportIdForDifferentAggregateEntityWithEmptyParameterPartTreeFindOneGeneratedId( + @Autowired ReactiveAggregateRepositoryWithGeneratedIdId repository) { + StepVerifier.create(repository.findBy()).assertNext(startEntity -> { + assertThatLimitingWorks(startEntity); + }).verifyComplete(); + } + + @Test + void shouldOnlyPersistUntilLimitWithSaveGeneratedId( + @Autowired ReactiveAggregateRepositoryWithGeneratedIdId repository, @Autowired Driver driver, + @Autowired BookmarkCapture bookmarkCapture) { + var startEntity = repository.findAllBy().blockFirst(); + startEntity.getIntermediateEntity().getDifferentAggregateEntity().setName("different"); + repository.save(startEntity).block(); + + try (var session = driver.session(bookmarkCapture.createSessionConfig())) { + var name = session.executeRead(tx -> tx.run( + "MATCH (:StartEntity)-[:CONNECTED]->(:IntermediateEntity)-[:CONNECTED]->(dae:DifferentAggregateEntity) return dae.name as name") + .single() + .get("name") + .asString()); + bookmarkCapture.seedWith(session.lastBookmarks()); + assertThat(name).isEqualTo("some_name"); + } + } + + @Test + void shouldOnlyPersistUntilLimitWithSaveAllGeneratedId( + @Autowired ReactiveAggregateRepositoryWithGeneratedIdId repository, @Autowired Driver driver, + @Autowired BookmarkCapture bookmarkCapture) { + var startEntity = repository.findAllBy().blockFirst(); + startEntity.getIntermediateEntity().getDifferentAggregateEntity().setName("different"); + repository.saveAll(List.of(startEntity)).blockLast(); + + try (var session = driver.session(bookmarkCapture.createSessionConfig())) { + var name = session.executeRead(tx -> tx.run( + "MATCH (:StartEntity)-[:CONNECTED]->(:IntermediateEntity)-[:CONNECTED]->(dae:DifferentAggregateEntity) return dae.name as name") + .single() + .get("name") + .asString()); + bookmarkCapture.seedWith(session.lastBookmarks()); + assertThat(name).isEqualTo("some_name"); + } + } + + @Test + void shouldAllowWiderProjectionThanDomain(@Autowired ReactiveAggregateRepositoryWithGeneratedIdId repository) { + StepVerifier.create(repository.findProjectionBy()) + .assertNext(startEntity -> assertThat( + startEntity.getIntermediateEntity().getDifferentAggregateEntity().getName()) + .isEqualTo("some_name")) + .verifyComplete(); + } + + @Test + void shouldLoadCompleteEntityWhenQueriedFromDifferentEntity( + @Autowired ReactiveIntermediateEntityRepository repository) { + StepVerifier.create(repository.findAll()).assertNext(intermediateEntity -> { + assertThat(intermediateEntity).isNotNull(); + assertThat(intermediateEntity.getId()).isNotNull(); + assertThat(intermediateEntity.getDifferentAggregateEntity()).isNotNull(); + assertThat(intermediateEntity.getDifferentAggregateEntity().getId()).isNotNull(); + assertThat(intermediateEntity.getDifferentAggregateEntity().getName()).isEqualTo("some_name"); + }).verifyComplete(); + } + + private void assertThatLimitingWorks(StartEntity startEntity) { + assertThat(startEntity).isNotNull(); + assertThat(startEntity.getId()).isNotNull(); + assertThat(startEntity.getIntermediateEntity()).isNotNull(); + assertThat(startEntity.getIntermediateEntity().getId()).isNotNull(); + assertThat(startEntity.getIntermediateEntity().getDifferentAggregateEntity()).isNotNull(); + assertThat(startEntity.getIntermediateEntity().getDifferentAggregateEntity().getId()).isNotNull(); + assertThat(startEntity.getIntermediateEntity().getDifferentAggregateEntity().getName()).isNull(); + } + + private void assertThatLimitingWorks(StartEntityInternalId startEntity) { + assertThat(startEntity).isNotNull(); + assertThat(startEntity.getId()).isNotNull(); + assertThat(startEntity.getIntermediateEntity()).isNotNull(); + assertThat(startEntity.getIntermediateEntity().getId()).isNotNull(); + assertThat(startEntity.getIntermediateEntity().getDifferentAggregateEntity()).isNotNull(); + assertThat(startEntity.getIntermediateEntity().getDifferentAggregateEntity().getId()).isNotNull(); + assertThat(startEntity.getIntermediateEntity().getDifferentAggregateEntity().getName()).isNull(); + } + + interface ReactiveAggregateRepositoryWithInternalId extends ReactiveNeo4jRepository { + + Flux findAllBy(); + + Flux findAllByName(String name); + + Mono findBy(); + + Mono findByName(String name); + + } + + interface ReactiveAggregateRepositoryWithGeneratedIdId extends ReactiveNeo4jRepository { + + Flux findAllBy(); + + Flux findAllByName(String name); + + Mono findBy(); + + Mono findByName(String name); + + Mono findProjectionBy(); + + } + + interface ReactiveIntermediateEntityRepository extends ReactiveNeo4jRepository { + + } + + interface StartEntityProjection { + + String getName(); + + IntermediateEntityProjection getIntermediateEntity(); + + } + + interface IntermediateEntityProjection { + + DifferentAggregateEntityProjection getDifferentAggregateEntity(); + + } + + interface DifferentAggregateEntityProjection { + + String getName(); + + } + + @Configuration + @EnableTransactionManagement + @EnableReactiveNeo4jRepositories(considerNestedRepositories = true) + static class Config extends Neo4jReactiveTestConfiguration { + + @Bean + @Override + public Driver driver() { + return neo4jConnectionSupport.getDriver(); + } + + @Bean + BookmarkCapture bookmarkCapture() { + return new BookmarkCapture(); + } + + @Override + public ReactiveTransactionManager reactiveTransactionManager(Driver driver, + ReactiveDatabaseSelectionProvider databaseSelectionProvider) { + return new ReactiveNeo4jTransactionManager(driver, databaseSelectionProvider, + Neo4jBookmarkManager.create(bookmarkCapture())); + } + + @Override + public boolean isCypher5Compatible() { + return neo4jConnectionSupport.isCypher5SyntaxCompatible(); + } + + } + +} diff --git a/src/test/java/org/springframework/data/neo4j/integration/shared/common/AggregateEntitiesWithGeneratedIds.java b/src/test/java/org/springframework/data/neo4j/integration/shared/common/AggregateEntitiesWithGeneratedIds.java new file mode 100644 index 0000000000..4c4789b440 --- /dev/null +++ b/src/test/java/org/springframework/data/neo4j/integration/shared/common/AggregateEntitiesWithGeneratedIds.java @@ -0,0 +1,103 @@ +/* + * Copyright 2011-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.neo4j.integration.shared.common; + +import org.springframework.data.neo4j.core.schema.GeneratedValue; +import org.springframework.data.neo4j.core.schema.Id; +import org.springframework.data.neo4j.core.schema.Node; +import org.springframework.data.neo4j.core.schema.Relationship; +import org.springframework.data.neo4j.core.support.UUIDStringGenerator; + +public final class AggregateEntitiesWithGeneratedIds { + + @Node + public static class StartEntity { + + @Id + @GeneratedValue(UUIDStringGenerator.class) + public String id; + + private String name; + + @Relationship("CONNECTED") + IntermediateEntity intermediateEntity; + + public IntermediateEntity getIntermediateEntity() { + return this.intermediateEntity; + } + + public String getId() { + return this.id; + } + + public String getName() { + return this.name; + } + + public void setName(String name) { + this.name = name; + } + + } + + @Node + public static class IntermediateEntity { + + @Id + @GeneratedValue(UUIDStringGenerator.class) + public String id; + + @Relationship("CONNECTED") + DifferentAggregateEntity differentAggregateEntity; + + public DifferentAggregateEntity getDifferentAggregateEntity() { + return this.differentAggregateEntity; + } + + public String getId() { + return this.id; + } + + } + + @Node(aggregateBoundary = StartEntity.class) + public static class DifferentAggregateEntity { + + @Id + @GeneratedValue(UUIDStringGenerator.class) + public String id; + + public String name; + + public DifferentAggregateEntity(String name) { + this.name = name; + } + + public String getId() { + return this.id; + } + + public String getName() { + return this.name; + } + + public void setName(String name) { + this.name = name; + } + + } + +} diff --git a/src/test/java/org/springframework/data/neo4j/integration/shared/common/AggregateEntitiesWithInternalIds.java b/src/test/java/org/springframework/data/neo4j/integration/shared/common/AggregateEntitiesWithInternalIds.java new file mode 100644 index 0000000000..372f2af85b --- /dev/null +++ b/src/test/java/org/springframework/data/neo4j/integration/shared/common/AggregateEntitiesWithInternalIds.java @@ -0,0 +1,102 @@ +/* + * Copyright 2011-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.neo4j.integration.shared.common; + +import org.springframework.data.neo4j.core.schema.GeneratedValue; +import org.springframework.data.neo4j.core.schema.Id; +import org.springframework.data.neo4j.core.schema.Node; +import org.springframework.data.neo4j.core.schema.Relationship; + +public final class AggregateEntitiesWithInternalIds { + + @Node + public static class StartEntityInternalId { + + @Id + @GeneratedValue + public String id; + + private String name; + + @Relationship("CONNECTED") + IntermediateEntityInternalId intermediateEntity; + + public IntermediateEntityInternalId getIntermediateEntity() { + return this.intermediateEntity; + } + + public String getId() { + return this.id; + } + + public String getName() { + return this.name; + } + + public void setName(String name) { + this.name = name; + } + + } + + @Node + public static class IntermediateEntityInternalId { + + @Id + @GeneratedValue + public String id; + + @Relationship("CONNECTED") + DifferentAggregateEntityInternalId differentAggregateEntity; + + public DifferentAggregateEntityInternalId getDifferentAggregateEntity() { + return this.differentAggregateEntity; + } + + public String getId() { + return this.id; + } + + } + + @Node(aggregateBoundary = StartEntityInternalId.class) + public static class DifferentAggregateEntityInternalId { + + @Id + @GeneratedValue + public String id; + + public String name; + + public DifferentAggregateEntityInternalId(String name) { + this.name = name; + } + + public String getId() { + return this.id; + } + + public String getName() { + return this.name; + } + + public void setName(String name) { + this.name = name; + } + + } + +}