From 972f55746145485ce3ee3573ef72baf81a9fad61 Mon Sep 17 00:00:00 2001 From: Rob Green Date: Sun, 2 Nov 2025 09:44:42 -0500 Subject: [PATCH] HHH-19192 prevent physically deleting collections when soft delete is set --- .../internal/SqmMutationStrategyHelper.java | 54 ++++++++++++------- .../collections/BulkDeleteOwnerTest.java | 54 +++++++++++++++++++ 2 files changed, 89 insertions(+), 19 deletions(-) create mode 100644 hibernate-core/src/test/java/org/hibernate/orm/test/softdelete/collections/BulkDeleteOwnerTest.java diff --git a/hibernate-core/src/main/java/org/hibernate/query/sqm/mutation/internal/SqmMutationStrategyHelper.java b/hibernate-core/src/main/java/org/hibernate/query/sqm/mutation/internal/SqmMutationStrategyHelper.java index 0352bfb705b0..85e53e005095 100644 --- a/hibernate-core/src/main/java/org/hibernate/query/sqm/mutation/internal/SqmMutationStrategyHelper.java +++ b/hibernate-core/src/main/java/org/hibernate/query/sqm/mutation/internal/SqmMutationStrategyHelper.java @@ -43,13 +43,18 @@ import org.hibernate.metamodel.mapping.internal.ToOneAttributeMapping; import org.hibernate.persister.entity.EntityPersister; import org.hibernate.query.spi.QueryOptions; +import org.hibernate.sql.ast.tree.AbstractUpdateOrDeleteStatement; import org.hibernate.sql.ast.tree.delete.DeleteStatement; import org.hibernate.sql.ast.tree.from.NamedTableReference; import org.hibernate.sql.ast.tree.from.TableReference; import org.hibernate.sql.ast.tree.predicate.Predicate; +import org.hibernate.sql.ast.tree.update.Assignment; +import org.hibernate.sql.ast.tree.update.UpdateStatement; import org.hibernate.sql.exec.spi.JdbcOperationQueryMutation; import org.hibernate.sql.exec.spi.JdbcParameterBindings; +import static java.util.Collections.singletonList; + /** * @author Steve Ebersole */ @@ -171,31 +176,42 @@ private static void visitCollectionTableDeletes( QueryOptions queryOptions, Consumer jdbcOperationConsumer) { final String separateCollectionTable = attributeMapping.getSeparateCollectionTable(); + // Skip deleting rows in collection tables if cascade delete is enabled + if ( separateCollectionTable == null || attributeMapping.getCollectionDescriptor().isCascadeDeleteEnabled() ) { + return; + } final SessionFactoryImplementor sessionFactory = attributeMapping.getCollectionDescriptor().getFactory(); final JdbcServices jdbcServices = sessionFactory.getJdbcServices(); + // element-collection or many-to-many - delete the collection-table row + final NamedTableReference tableReference = new NamedTableReference( + separateCollectionTable, + DeleteStatement.DEFAULT_ALIAS, + true + ); - // Skip deleting rows in collection tables if cascade delete is enabled - if ( separateCollectionTable != null && !attributeMapping.getCollectionDescriptor().isCascadeDeleteEnabled() ) { - // element-collection or many-to-many - delete the collection-table row - - final NamedTableReference tableReference = new NamedTableReference( - separateCollectionTable, - DeleteStatement.DEFAULT_ALIAS, - true - ); - - final DeleteStatement sqlAstDelete = new DeleteStatement( - tableReference, - restrictionProducer.apply( tableReference, attributeMapping ) + final AbstractUpdateOrDeleteStatement sqlAst; + if ( attributeMapping.getSoftDeleteMapping() != null ) { + final Assignment softDeleteAssignment = attributeMapping + .getSoftDeleteMapping() + .createSoftDeleteAssignment( tableReference ); + sqlAst = new UpdateStatement( + tableReference, + singletonList( softDeleteAssignment ), + restrictionProducer.apply( tableReference, attributeMapping ) ); - - jdbcOperationConsumer.accept( - jdbcServices.getJdbcEnvironment() - .getSqlAstTranslatorFactory() - .buildMutationTranslator( sessionFactory, sqlAstDelete ) - .translate( jdbcParameterBindings, queryOptions ) + } + else { + sqlAst = new DeleteStatement( + tableReference, + restrictionProducer.apply( tableReference, attributeMapping ) ); } + jdbcOperationConsumer.accept( + jdbcServices.getJdbcEnvironment() + .getSqlAstTranslatorFactory() + .buildMutationTranslator( sessionFactory, sqlAst ) + .translate( jdbcParameterBindings, queryOptions ) + ); } public static boolean isId(JdbcMappingContainer type) { diff --git a/hibernate-core/src/test/java/org/hibernate/orm/test/softdelete/collections/BulkDeleteOwnerTest.java b/hibernate-core/src/test/java/org/hibernate/orm/test/softdelete/collections/BulkDeleteOwnerTest.java new file mode 100644 index 000000000000..e5d0d17bfc2b --- /dev/null +++ b/hibernate-core/src/test/java/org/hibernate/orm/test/softdelete/collections/BulkDeleteOwnerTest.java @@ -0,0 +1,54 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright Red Hat Inc. and Hibernate Authors + */ +package org.hibernate.orm.test.softdelete.collections; + +import jakarta.persistence.CollectionTable; +import jakarta.persistence.ElementCollection; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.Table; +import org.hibernate.annotations.SoftDelete; +import org.hibernate.annotations.SoftDeleteType; +import org.hibernate.testing.jdbc.SQLStatementInspector; +import org.hibernate.testing.orm.junit.DomainModel; +import org.hibernate.testing.orm.junit.Jira; +import org.hibernate.testing.orm.junit.SessionFactory; +import org.hibernate.testing.orm.junit.SessionFactoryScope; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@SessionFactory +@DomainModel( annotatedClasses = { BulkDeleteOwnerTest.Employee.class } ) +@Jira( "https://hibernate.atlassian.net/browse/HHH-19192" ) +public class BulkDeleteOwnerTest { + @Test void test(SessionFactoryScope scope) { + final SQLStatementInspector sqlInspector = scope.getCollectingStatementInspector(); + sqlInspector.clear(); + scope.inTransaction( session -> { + session.createMutationQuery( "delete Employee where id = 1" ).executeUpdate(); + assertThat( sqlInspector.getSqlQueries() ).hasSize( 2 ); + assertThat( sqlInspector.getSqlQueries().get( 0 ) ).doesNotContain( "delete from" ); + assertThat( sqlInspector.getSqlQueries().get( 0 ) ).contains( "update employee_accolades" ); + assertThat( sqlInspector.getSqlQueries().get( 0 ) ).contains( "set deleted_on=localtimestamp" ); + assertThat( sqlInspector.getSqlQueries().get( 0 ) ).contains( ".employee_fk in (select" ); + } ); + } + + @Entity(name = "Employee") + @Table(name = "employees") + @SoftDelete( strategy = SoftDeleteType.TIMESTAMP, columnName = "deleted_at" ) + public static class Employee { + @Id + long id; + @ElementCollection + @CollectionTable( name = "employee_accolades", joinColumns = @JoinColumn( name = "employee_fk" ) ) + @SoftDelete( strategy = SoftDeleteType.TIMESTAMP, columnName = "deleted_on" ) + private List accolades; + } +}