diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/JdbcAggregateOperations.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/JdbcAggregateOperations.java index 279502a175..d79b8d9de1 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/JdbcAggregateOperations.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/JdbcAggregateOperations.java @@ -80,6 +80,15 @@ public interface JdbcAggregateOperations { */ void deleteById(Object id, Class domainType); + /** + * Deletes all aggregates identified by their aggregate root ids. + * + * @param ids the ids of the aggregate roots of the aggregates to be deleted. Must not be {@code null}. + * @param domainType the type of the aggregate root. + * @param the type of the aggregate root. + */ + void deleteAllById(Iterable ids, Class domainType); + /** * Delete an aggregate identified by it's aggregate root. * @@ -96,6 +105,15 @@ public interface JdbcAggregateOperations { */ void deleteAll(Class domainType); + /** + * Delete all aggregates identified by their aggregate roots. + * + * @param aggregateRoots to delete. Must not be {@code null}. + * @param domainType type of the aggregate roots to be deleted. Must not be {@code null}. + * @param the type of the aggregate roots. + */ + void deleteAll(Iterable aggregateRoots, Class domainType); + /** * Counts the number of aggregates of a given type. * diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/JdbcAggregateTemplate.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/JdbcAggregateTemplate.java index 88a61e65c1..9ec9748b90 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/JdbcAggregateTemplate.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/JdbcAggregateTemplate.java @@ -17,7 +17,9 @@ import java.util.ArrayList; import java.util.Iterator; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.function.Function; import java.util.stream.Collectors; import java.util.stream.StreamSupport; @@ -32,13 +34,14 @@ import org.springframework.data.mapping.IdentifierAccessor; import org.springframework.data.mapping.callback.EntityCallbacks; import org.springframework.data.relational.core.conversion.AggregateChange; -import org.springframework.data.relational.core.conversion.RootAggregateChange; import org.springframework.data.relational.core.conversion.BatchingAggregateChange; +import org.springframework.data.relational.core.conversion.DeleteAggregateChange; import org.springframework.data.relational.core.conversion.MutableAggregateChange; import org.springframework.data.relational.core.conversion.RelationalEntityDeleteWriter; import org.springframework.data.relational.core.conversion.RelationalEntityInsertWriter; import org.springframework.data.relational.core.conversion.RelationalEntityUpdateWriter; import org.springframework.data.relational.core.conversion.RelationalEntityVersionUtils; +import org.springframework.data.relational.core.conversion.RootAggregateChange; import org.springframework.data.relational.core.mapping.RelationalMappingContext; import org.springframework.data.relational.core.mapping.RelationalPersistentEntity; import org.springframework.data.relational.core.mapping.RelationalPersistentProperty; @@ -273,6 +276,25 @@ public void deleteById(Object id, Class domainType) { deleteTree(id, null, domainType); } + @Override + public void deleteAllById(Iterable ids, Class domainType) { + + Assert.isTrue(ids.iterator().hasNext(), "Ids must not be empty!"); + + BatchingAggregateChange> batchingAggregateChange = BatchingAggregateChange + .forDelete(domainType); + + ids.forEach(id -> { + DeleteAggregateChange change = createDeletingChange(id, null, domainType); + triggerBeforeDelete(null, id, change); + batchingAggregateChange.add(change); + }); + + executor.executeDelete(batchingAggregateChange); + + ids.forEach(id -> triggerAfterDelete(null, id, batchingAggregateChange)); + } + @Override public void deleteAll(Class domainType) { @@ -282,6 +304,28 @@ public void deleteAll(Class domainType) { executor.executeDelete(change); } + @Override + public void deleteAll(Iterable instances, Class domainType) { + + Assert.isTrue(instances.iterator().hasNext(), "Aggregate instances must not be empty!"); + + BatchingAggregateChange> batchingAggregateChange = BatchingAggregateChange + .forDelete(domainType); + Map instancesBeforeExecute = new LinkedHashMap<>(); + + instances.forEach(instance -> { + Object id = context.getRequiredPersistentEntity(domainType).getIdentifierAccessor(instance) + .getRequiredIdentifier(); + DeleteAggregateChange change = createDeletingChange(id, instance, domainType); + instancesBeforeExecute.put(id, triggerBeforeDelete(instance, id, change)); + batchingAggregateChange.add(change); + }); + + executor.executeDelete(batchingAggregateChange); + + instancesBeforeExecute.forEach((id, instance) -> triggerAfterDelete(instance, id, batchingAggregateChange)); + } + private T afterExecute(AggregateChange change, T entityAfterExecution) { Object identifier = context.getRequiredPersistentEntity(change.getEntityType()) @@ -292,8 +336,7 @@ private T afterExecute(AggregateChange change, T entityAfterExecution) { return triggerAfterSave(entityAfterExecution, change); } - private RootAggregateChange beforeExecute(T aggregateRoot, - Function> changeCreator) { + private RootAggregateChange beforeExecute(T aggregateRoot, Function> changeCreator) { Assert.notNull(aggregateRoot, "Aggregate instance must not be null!"); @@ -376,8 +419,7 @@ private RootAggregateChange createUpdateChange(EntityAndPreviousVersion aggregateChange = MutableAggregateChange.forSave(entityAndVersion.entity, entityAndVersion.version); - new RelationalEntityUpdateWriter(context).write(entityAndVersion.entity, - aggregateChange); + new RelationalEntityUpdateWriter(context).write(entityAndVersion.entity, aggregateChange); return aggregateChange; } @@ -420,7 +462,7 @@ private RelationalPersistentEntity getRequiredPersistentEntity(T instance return (RelationalPersistentEntity) context.getRequiredPersistentEntity(instance.getClass()); } - private MutableAggregateChange createDeletingChange(Object id, @Nullable T entity, Class domainType) { + private DeleteAggregateChange createDeletingChange(Object id, @Nullable T entity, Class domainType) { Number previousVersion = null; if (entity != null) { @@ -429,7 +471,7 @@ private MutableAggregateChange createDeletingChange(Object id, @Nullable previousVersion = RelationalEntityVersionUtils.getVersionNumberFromEntity(entity, persistentEntity, converter); } } - MutableAggregateChange aggregateChange = MutableAggregateChange.forDelete(domainType, previousVersion); + DeleteAggregateChange aggregateChange = MutableAggregateChange.forDelete(domainType, previousVersion); jdbcEntityDeleteWriter.write(id, aggregateChange); return aggregateChange; } @@ -482,7 +524,7 @@ private T triggerAfterSave(T aggregateRoot, AggregateChange change) { return entityCallbacks.callback(AfterSaveCallback.class, aggregateRoot); } - private void triggerAfterDelete(@Nullable T aggregateRoot, Object id, MutableAggregateChange change) { + private void triggerAfterDelete(@Nullable T aggregateRoot, Object id, AggregateChange change) { publisher.publishEvent(new AfterDeleteEvent<>(Identifier.of(id), aggregateRoot, change)); diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/support/SimpleJdbcRepository.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/support/SimpleJdbcRepository.java index 42ad0b9f6e..4809be9238 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/support/SimpleJdbcRepository.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/support/SimpleJdbcRepository.java @@ -101,14 +101,13 @@ public void delete(T instance) { @Override public void deleteAllById(Iterable ids) { - ids.forEach(it -> entityOperations.deleteById(it, entity.getType())); + entityOperations.deleteAllById(ids, entity.getType()); } @Transactional @Override - @SuppressWarnings("unchecked") public void deleteAll(Iterable entities) { - entities.forEach(it -> entityOperations.delete(it, (Class) it.getClass())); + entityOperations.deleteAll(entities, entity.getType()); } @Transactional diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/JdbcAggregateTemplateIntegrationTests.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/JdbcAggregateTemplateIntegrationTests.java index 206820bfe3..e5f918aa8b 100644 --- a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/JdbcAggregateTemplateIntegrationTests.java +++ b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/JdbcAggregateTemplateIntegrationTests.java @@ -329,6 +329,38 @@ void saveAndDeleteAllWithReferencedEntity() { softly.assertAll(); } + @Test // GH-537 + @EnabledOnFeature(SUPPORTS_QUOTED_IDS) + void saveAndDeleteAllByAggregateRootsWithReferencedEntity() { + LegoSet legoSet1 = template.save(legoSet); + LegoSet legoSet2 = template.save(createLegoSet("Some Name")); + + template.deleteAll(List.of(legoSet1, legoSet2), LegoSet.class); + + SoftAssertions softly = new SoftAssertions(); + + assertThat(template.findAll(LegoSet.class)).isEmpty(); + assertThat(template.findAll(Manual.class)).isEmpty(); + + softly.assertAll(); + } + + @Test // GH-537 + @EnabledOnFeature(SUPPORTS_QUOTED_IDS) + void saveAndDeleteAllByIdsWithReferencedEntity() { + LegoSet legoSet1 = template.save(legoSet); + LegoSet legoSet2 = template.save(createLegoSet("Some Name")); + + template.deleteAllById(List.of(legoSet1.id, legoSet2.id), LegoSet.class); + + SoftAssertions softly = new SoftAssertions(); + + assertThat(template.findAll(LegoSet.class)).isEmpty(); + assertThat(template.findAll(Manual.class)).isEmpty(); + + softly.assertAll(); + } + @Test // DATAJDBC-112 @EnabledOnFeature({ SUPPORTS_QUOTED_IDS, SUPPORTS_GENERATED_IDS_IN_REFERENCED_ENTITIES }) void updateReferencedEntityFromNull() { diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/BatchingAggregateChange.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/BatchingAggregateChange.java index 8f53bf1015..ea83c12810 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/BatchingAggregateChange.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/BatchingAggregateChange.java @@ -47,4 +47,19 @@ static BatchingAggregateChange> forSave(Class e return new SaveBatchingAggregateChange<>(entityClass); } + + /** + * Factory method to create a {@link BatchingAggregateChange} for deleting entities. + * + * @param entityClass aggregate root type. + * @param entity type. + * @return the {@link BatchingAggregateChange} for deleting root entities. + * @since 3.0 + */ + static BatchingAggregateChange> forDelete(Class entityClass) { + + Assert.notNull(entityClass, "Entity class must not be null"); + + return new DeleteBatchingAggregateChange<>(entityClass); + } } diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/DefaultAggregateChange.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/DeleteAggregateChange.java similarity index 89% rename from spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/DefaultAggregateChange.java rename to spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/DeleteAggregateChange.java index 990d8849ca..ff7c3da3da 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/DefaultAggregateChange.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/DeleteAggregateChange.java @@ -30,9 +30,7 @@ * @author Chirag Tailor * @since 2.0 */ -class DefaultAggregateChange implements MutableAggregateChange { - - private final Kind kind; +public class DeleteAggregateChange implements MutableAggregateChange { /** Type of the aggregate root to be changed */ private final Class entityType; @@ -42,9 +40,7 @@ class DefaultAggregateChange implements MutableAggregateChange { /** The previous version assigned to the instance being changed, if available */ @Nullable private final Number previousVersion; - public DefaultAggregateChange(Kind kind, Class entityType, @Nullable Number previousVersion) { - - this.kind = kind; + public DeleteAggregateChange(Class entityType, @Nullable Number previousVersion) { this.entityType = entityType; this.previousVersion = previousVersion; } @@ -64,7 +60,7 @@ public void addAction(DbAction action) { @Override public Kind getKind() { - return this.kind; + return Kind.DELETE; } @Override diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/DeleteBatchingAggregateChange.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/DeleteBatchingAggregateChange.java new file mode 100644 index 0000000000..49e0ea5128 --- /dev/null +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/DeleteBatchingAggregateChange.java @@ -0,0 +1,89 @@ +package org.springframework.data.relational.core.conversion; + +import org.springframework.data.mapping.PersistentPropertyPath; +import org.springframework.data.relational.core.mapping.RelationalPersistentProperty; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; + +import static java.util.Collections.*; + +/** + * A {@link BatchingAggregateChange} implementation for delete changes that can contain actions for one or more delete + * operations. When consumed, actions are yielded in the appropriate entity tree order with deletes carried out from + * leaves to root. All operations that can be batched are grouped and combined to offer the ability for an optimized + * batch operation to be used. + * + * @author Chirag Tailor + * @since 3.0 + */ +public class DeleteBatchingAggregateChange implements BatchingAggregateChange> { + + private static final Comparator> pathLengthComparator = // + Comparator.comparing(PersistentPropertyPath::getLength); + + private final Class entityType; + private final List> rootActions = new ArrayList<>(); + private final List> lockActions = new ArrayList<>(); + private final Map, List>> deleteActions = // + new HashMap<>(); + + public DeleteBatchingAggregateChange(Class entityType) { + this.entityType = entityType; + } + + @Override + public Kind getKind() { + return Kind.DELETE; + } + + @Override + public Class getEntityType() { + return entityType; + } + + @Override + public void forEachAction(Consumer> consumer) { + + lockActions.forEach(consumer); + deleteActions.entrySet().stream().sorted(Map.Entry.comparingByKey(pathLengthComparator.reversed())) + .forEach((entry) -> { + List> deletes = entry.getValue(); + if (deletes.size() > 1) { + consumer.accept(new DbAction.BatchDelete<>(deletes)); + } else { + deletes.forEach(consumer); + } + }); + rootActions.forEach(consumer); + } + + @Override + public void add(DeleteAggregateChange aggregateChange) { + + aggregateChange.forEachAction(action -> { + if (action instanceof DbAction.DeleteRoot deleteRootAction) { + //noinspection unchecked + rootActions.add((DbAction.DeleteRoot) deleteRootAction); + } else if (action instanceof DbAction.Delete deleteAction) { + // noinspection unchecked + addDelete((DbAction.Delete) deleteAction); + } else if (action instanceof DbAction.AcquireLockRoot lockRootAction) { + lockActions.add(lockRootAction); + } + }); + } + + private void addDelete(DbAction.Delete action) { + + PersistentPropertyPath propertyPath = action.getPropertyPath(); + deleteActions.merge(propertyPath, new ArrayList<>(singletonList(action)), (actions, defaultValue) -> { + actions.add(action); + return actions; + }); + } +} diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/MutableAggregateChange.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/MutableAggregateChange.java index f0984be4cd..9701bfcaca 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/MutableAggregateChange.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/MutableAggregateChange.java @@ -59,15 +59,15 @@ static RootAggregateChange forSave(T entity, @Nullable Number previousVer } /** - * Factory method to create an {@link MutableAggregateChange} for deleting entities. + * Factory method to create a {@link DeleteAggregateChange} for deleting entities. * * @param entity aggregate root to delete. * @param entity type. - * @return the {@link MutableAggregateChange} for deleting the root {@code entity}. + * @return the {@link DeleteAggregateChange} for deleting the root {@code entity}. * @since 1.2 */ @SuppressWarnings("unchecked") - static MutableAggregateChange forDelete(T entity) { + static DeleteAggregateChange forDelete(T entity) { Assert.notNull(entity, "Entity must not be null"); @@ -75,31 +75,31 @@ static MutableAggregateChange forDelete(T entity) { } /** - * Factory method to create an {@link MutableAggregateChange} for deleting entities. + * Factory method to create a {@link DeleteAggregateChange} for deleting entities. * * @param entityClass aggregate root type. * @param entity type. - * @return the {@link MutableAggregateChange} for deleting the root {@code entity}. + * @return the {@link DeleteAggregateChange} for deleting the root {@code entity}. * @since 1.2 */ - static MutableAggregateChange forDelete(Class entityClass) { + static DeleteAggregateChange forDelete(Class entityClass) { return forDelete(entityClass, null); } /** - * Factory method to create an {@link MutableAggregateChange} for deleting entities. + * Factory method to create a {@link DeleteAggregateChange} for deleting entities. * * @param entityClass aggregate root type. * @param previousVersion the previous version assigned to the instance being saved. May be {@literal null}. * @param entity type. - * @return the {@link MutableAggregateChange} for deleting the root {@code entity}. + * @return the {@link DeleteAggregateChange} for deleting the root {@code entity}. * @since 2.4 */ - static MutableAggregateChange forDelete(Class entityClass, @Nullable Number previousVersion) { + static DeleteAggregateChange forDelete(Class entityClass, @Nullable Number previousVersion) { Assert.notNull(entityClass, "Entity class must not be null"); - return new DefaultAggregateChange<>(Kind.DELETE, entityClass, previousVersion); + return new DeleteAggregateChange<>(entityClass, previousVersion); } /** diff --git a/spring-data-relational/src/test/java/org/springframework/data/relational/core/conversion/DeleteBatchingAggregateChangeTest.java b/spring-data-relational/src/test/java/org/springframework/data/relational/core/conversion/DeleteBatchingAggregateChangeTest.java new file mode 100644 index 0000000000..c91edd7bb8 --- /dev/null +++ b/spring-data-relational/src/test/java/org/springframework/data/relational/core/conversion/DeleteBatchingAggregateChangeTest.java @@ -0,0 +1,189 @@ +package org.springframework.data.relational.core.conversion; + +import static org.assertj.core.api.Assertions.*; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +import org.assertj.core.groups.Tuple; +import org.junit.jupiter.api.Test; +import org.springframework.data.annotation.Id; +import org.springframework.data.relational.core.mapping.RelationalMappingContext; + +import lombok.Value; + +/** + * Unit tests for {@link DeleteBatchingAggregateChange}. + * + * @author Chirag Tailor + */ +class DeleteBatchingAggregateChangeTest { + + RelationalMappingContext context = new RelationalMappingContext(); + + @Test + void yieldsDeleteActions() { + + Root root = new Root(1L, null); + DeleteAggregateChange aggregateChange = MutableAggregateChange.forDelete(root); + DbAction.Delete intermediateDelete = new DbAction.Delete<>(1L, + context.getPersistentPropertyPath("intermediate", Root.class)); + aggregateChange.addAction(intermediateDelete); + + BatchingAggregateChange> change = BatchingAggregateChange.forDelete(Root.class); + change.add(aggregateChange); + + assertThat(extractActions(change)).containsExactly(intermediateDelete); + } + + @Test + void yieldsNestedDeleteActionsInTreeOrderFromLeavesToRoot() { + + Root root = new Root(2L, null); + DeleteAggregateChange aggregateChange = MutableAggregateChange.forDelete(root); + DbAction.Delete intermediateDelete = new DbAction.Delete<>(1L, + context.getPersistentPropertyPath("intermediate", Root.class)); + aggregateChange.addAction(intermediateDelete); + DbAction.Delete leafDelete = new DbAction.Delete<>(1L, + context.getPersistentPropertyPath("intermediate.leaf", Root.class)); + aggregateChange.addAction(leafDelete); + + BatchingAggregateChange> change = BatchingAggregateChange.forDelete(Root.class); + change.add(aggregateChange); + + List> actions = extractActions(change); + assertThat(actions).containsExactly(leafDelete, intermediateDelete); + } + + @Test + void yieldsDeleteActionsAsBatchDeletes_groupedByPath_whenGroupContainsMultipleDeletes() { + + Root root = new Root(1L, null); + DeleteAggregateChange aggregateChange = MutableAggregateChange.forDelete(root); + DbAction.Delete intermediateDelete1 = new DbAction.Delete<>(1L, + context.getPersistentPropertyPath("intermediate", Root.class)); + DbAction.Delete intermediateDelete2 = new DbAction.Delete<>(2L, + context.getPersistentPropertyPath("intermediate", Root.class)); + aggregateChange.addAction(intermediateDelete1); + aggregateChange.addAction(intermediateDelete2); + + BatchingAggregateChange> change = BatchingAggregateChange.forDelete(Root.class); + change.add(aggregateChange); + + List> actions = extractActions(change); + assertThat(actions).extracting(DbAction::getClass, DbAction::getEntityType) // + .containsExactly(Tuple.tuple(DbAction.BatchDelete.class, Intermediate.class)); + assertThat(getBatchWithValueAction(actions, Intermediate.class, DbAction.BatchDelete.class).getActions()) + .containsExactly(intermediateDelete1, intermediateDelete2); + } + + @Test + void yieldsDeleteRootActions() { + + DeleteAggregateChange aggregateChange = MutableAggregateChange.forDelete(new Root(null, null)); + DbAction.DeleteRoot deleteRoot = new DbAction.DeleteRoot<>(1L, Root.class, null); + aggregateChange.addAction(deleteRoot); + + BatchingAggregateChange> change = BatchingAggregateChange.forDelete(Root.class); + change.add(aggregateChange); + + assertThat(extractActions(change)).containsExactly(deleteRoot); + } + + @Test + void yieldsDeleteRootActionsAfterDeleteActions() { + + DeleteAggregateChange aggregateChange = MutableAggregateChange.forDelete(new Root(null, null)); + DbAction.DeleteRoot deleteRoot = new DbAction.DeleteRoot<>(1L, Root.class, null); + aggregateChange.addAction(deleteRoot); + DbAction.Delete intermediateDelete = new DbAction.Delete<>(1L, + context.getPersistentPropertyPath("intermediate", Root.class)); + aggregateChange.addAction(intermediateDelete); + + BatchingAggregateChange> change = BatchingAggregateChange.forDelete(Root.class); + change.add(aggregateChange); + + assertThat(extractActions(change)).containsExactly(intermediateDelete, deleteRoot); + } + + @Test + void yieldsLockRootActions() { + + DeleteAggregateChange aggregateChange = MutableAggregateChange.forDelete(new Root(null, null)); + DbAction.AcquireLockRoot lockRootAction = new DbAction.AcquireLockRoot<>(1L, Root.class); + aggregateChange.addAction(lockRootAction); + + BatchingAggregateChange> change = BatchingAggregateChange.forDelete(Root.class); + change.add(aggregateChange); + + assertThat(extractActions(change)).containsExactly(lockRootAction); + } + + @Test + void yieldsLockRootActionsBeforeDeleteActions() { + + DeleteAggregateChange aggregateChange = MutableAggregateChange.forDelete(new Root(null, null)); + DbAction.Delete intermediateDelete = new DbAction.Delete<>(1L, + context.getPersistentPropertyPath("intermediate", Root.class)); + aggregateChange.addAction(intermediateDelete); + DbAction.AcquireLockRoot lockRootAction = new DbAction.AcquireLockRoot<>(1L, Root.class); + aggregateChange.addAction(lockRootAction); + + BatchingAggregateChange> change = BatchingAggregateChange.forDelete(Root.class); + change.add(aggregateChange); + + assertThat(extractActions(change)).containsExactly(lockRootAction, intermediateDelete); + } + + private List> extractActions(BatchingAggregateChange> change) { + + List> actions = new ArrayList<>(); + change.forEachAction(actions::add); + return actions; + } + + private DbAction.BatchWithValue, Object> getBatchWithValueAction(List> actions, + Class entityType, Class batchActionType) { + + return getBatchWithValueActions(actions, entityType, batchActionType).stream().findFirst() + .orElseThrow(() -> new RuntimeException("No BatchWithValue action found!")); + } + + private DbAction.BatchWithValue, Object> getBatchWithValueAction(List> actions, + Class entityType, Class batchActionType, Object batchValue) { + + return getBatchWithValueActions(actions, entityType, batchActionType).stream() + .filter(batchWithValue -> batchWithValue.getBatchValue() == batchValue).findFirst().orElseThrow( + () -> new RuntimeException(String.format("No BatchWithValue with batch value '%s' found!", batchValue))); + } + + @SuppressWarnings("unchecked") + private List, Object>> getBatchWithValueActions( + List> actions, Class entityType, Class batchActionType) { + + return actions.stream() // + .filter(dbAction -> dbAction.getClass().equals(batchActionType)) // + .filter(dbAction -> dbAction.getEntityType().equals(entityType)) // + .map(dbAction -> (DbAction.BatchWithValue, Object>) dbAction).collect(Collectors.toList()); + } + + @Value + static class Root { + @Id Long id; + Intermediate intermediate; + } + + @Value + static class Intermediate { + @Id Long id; + String name; + Leaf leaf; + } + + @Value + static class Leaf { + @Id Long id; + String name; + } +}