HHH-17164 - Proper, first-class soft-delete support

HHH-17311 - Reversed soft delete support

https://hibernate.atlassian.net/browse/HHH-17164
https://hibernate.atlassian.net/browse/HHH-17311
This commit is contained in:
Steve Ebersole 2023-10-12 21:12:11 -05:00
parent 2cb4652688
commit 51f2f4f75d
22 changed files with 774 additions and 168 deletions

View File

@ -33,6 +33,7 @@ include::associations.adoc[]
include::collections.adoc[]
include::natural_id.adoc[]
include::partitioning.adoc[]
include::soft_delete.adoc[]
include::dynamic_model.adoc[]
include::inheritance.adoc[]
include::immutability.adoc[]

View File

@ -0,0 +1,166 @@
[[soft-delete]]
=== Soft Delete
:root-project-dir: ../../../../../../..
:core-project-dir: {root-project-dir}/hibernate-core
:testing-dir: {core-project-dir}/src/test/java/org/hibernate/orm/test/softdelete
An occasional requirement seen in the wild is to never physically remove rows from the database, but to
instead perform a "soft delete" where a column is updated to indicate that the row is no longer active.
Hibernate offers first-class support for this behavior through its `@SoftDelete` annotation.
[NOTE]
====
The `@SoftDelete` annotation is new in 6.4.
It was possible to hack together support for soft deletes in previous versions using a combination of filters,
`@Where` and custom delete event handling. However, that approach was tedious and did not work in
all cases. `@SoftDelete` should be highly preferred.
====
Hibernate supports soft delete for both <<soft-delete-entity,entities>> and <<soft-delete-collection,collections>>.
Soft delete support is defined by 3 main parts -
1. The <<soft-delete-column,column>> which contains the indicator.
2. A conversion from `Boolean` indicator value to the proper database type
3. Whether to <<soft-delete-reverse,reverse>> the indicator values
[[soft-delete-column]]
==== Indicator column
The column where the indicator value is stored is defined using `@SoftDelete#columnName` attribute. This
defaults to the name `deleted`.
See <<soft-delete-basic-example>> for an example of customizing the column name.
Depending on the conversion type, an appropriate check constraint may be applied to the column.
[[soft-delete-conversion]]
==== Indicator conversion
The conversion is defined using a JPA <<basic-jpa-convert,AttributeConverter>>. The "domain type" is always
boolean. The "relational type" can be any type, as defined by the converter; though generally speaking,
numerics and characters work best.
An explicit conversion can be specified using `@SoftDelete#converter`. See <<soft-delete-basic-example>>
for an example of specifying an explicit conversion. Explicit conversions can leverage the 3
Hibernate-provided converters for the 3 most common cases -
`NumericBooleanConverter`:: Defines conversion using `0` for `false` and `1` for `true`
`YesNoConverter`:: Defines conversion using `'N'` for `false` and `'Y'` for `true`
`TrueFalseConverter`:: Defines conversion using `'F'` for `false` and `'T'` for `true`
If an explicit converter is not specified, Hibernate will follow the same resolution steps defined in
<<basic-boolean>> to determine the proper database type. This breaks down into 3 categories -
boolean (and bit):: the underlying type is boolean / bit and no conversion is applied
numeric:: the underlying type is integer and values are converted according to `NumericBooleanConverter`
character:: the underlying type is char and values are converted according to `TrueFalseConverter`
[[soft-delete-entity]]
==== Entity soft delete
Hibernate supports the soft delete of entities, with the indicator column defined on the primary table.
[[soft-delete-basic-example]]
.Basic entity soft-delete
====
[source,java]
----
include::{testing-dir}/SimpleEntity.java[tag=example-soft-delete-basic, indent=0]
----
====
For entity hierarchies, the soft delete applies to all inheritance types.
[[soft-delete-secondary-example]]
.Inherited entity soft-delete
====
[source,java]
----
include::{testing-dir}/secondary/JoinedRoot.java[tag=example-soft-delete-secondary, indent=0]
include::{testing-dir}/secondary/JoinedSub.java[tag=example-soft-delete-secondary, indent=0]
----
====
See also <<soft-delete-package>>.
[[soft-delete-collection]]
==== Collection soft delete
Soft delete may be applied to collection mapped with a "collection table", aka `@ElementCollection`
and `@ManyToMany`. The soft delete applies to the collection table row.
Annotating a `@OneToMany` association with `@SoftDelete` will throw an exception.
In the case of `@OneToMany` and `@ManyToMany`, the mapped entity may itself be soft deletable which is
handled transparently.
[[soft-delete-element-collection-example]]
.Soft delete for @ElementCollection
====
[source,java]
----
include::{testing-dir}/collections/CollectionOwner.java[tag=example-soft-delete-element-collection, indent=0]
----
====
Given this `@ElementCollection` mapping, rows in the `elements` table will be soft deleted using an indicator column named `deleted`.
[[soft-delete-many2many-example]]
.Soft delete for @ManyToMany
====
[source,java]
----
include::{testing-dir}/collections/CollectionOwner.java[tag=example-soft-delete-many-to-many, indent=0]
----
====
Given this `@ManyToMany` mapping, rows in the `m2m` table will be soft deleted using an indicator column named `gone`.
See also <<soft-delete-package>>.
[[soft-delete-package]]
==== Package-level soft delete
The `@SoftDelete` annotation may also be placed at the package level, in which case it applies to all
entities and collections defined within the package.
[[soft-delete-reverse]]
==== Reversed soft delete
A common requirement in applications using soft delete is to track rows which are active as opposed to removed,
reversing the boolean value. For example:
[[soft-delete-reverse-example]]
.Reversed soft-delete
====
[source,java]
----
include::{testing-dir}/converter/reversed/TheEntity.java[tag=example-soft-delete-reverse, indent=0]
----
====
When an instance of `TheEntity` is persisted, the value `'Y'` will be inserted into the
`active` column. When an instance of `TheEntity` is removed, the column's value is updated to `'N'`.
This example explicitly specifies the built-in `YesNoConverter`, but reversal works with any conversion
even implicit conversions -
[[soft-delete-reverse-example-2]]
.Reversed soft-delete with implicit conversion
====
[source,java]
----
include::{testing-dir}/converter/reversed/TheEntity2.java[tag=example-soft-delete-reverse, indent=0]
----
====
The important thing to remember is that the stored values are reversed from the "normal" soft delete state.
`active == true` is the same as `deleted == false` - both describe the same state.

View File

@ -12,7 +12,6 @@ import java.lang.annotation.Retention;
import java.lang.annotation.Target;
import org.hibernate.dialect.Dialect;
import org.hibernate.type.BooleanAsBooleanConverter;
import jakarta.persistence.AttributeConverter;
@ -65,19 +64,6 @@ public @interface SoftDelete {
*/
String columnName() default "deleted";
/**
* (Optional) The Java type used for values when dealing with JDBC.
* This type should match the "relational type" of the specified
* {@linkplain #converter() converter}.
* <p/>
* By default, Hibernate will inspect the {@linkplain #converter() converter}
* and determine the proper type from its signature.
*
* @apiNote Sometimes useful since {@linkplain #converter() converter}
* signatures are not required to be parameterized.
*/
Class<?> jdbcType() default void.class;
/**
* (Optional) Conversion to apply to determine the appropriate value to
* store in the database. The "domain representation" can be: <dl>
@ -94,5 +80,19 @@ public @interface SoftDelete {
*
* @apiNote The converter should never return {@code null}
*/
Class<? extends AttributeConverter<Boolean,?>> converter() default BooleanAsBooleanConverter.class;
Class<? extends AttributeConverter<Boolean,?>> converter() default UnspecifiedConversion.class;
/**
* Whether the stored values should be reversed. This is used when the application tracks
* rows that are active as opposed to rows that are deleted.
*/
boolean reversed() default false;
/**
* Used as the default for {@linkplain SoftDelete#converter()}, indicating that
* {@linkplain Dialect#getPreferredSqlTypeCodeForBoolean() dialect} and
* {@linkplain org.hibernate.cfg.MappingSettings#PREFERRED_BOOLEAN_JDBC_TYPE settings}
* resolution should be used.
*/
interface UnspecifiedConversion extends AttributeConverter<Boolean,Object> {}
}

View File

@ -77,6 +77,7 @@ public class SoftDeleteHelper {
);
final BasicValue softDeleteIndicatorValue = new BasicValue( context, table );
softDeleteIndicatorValue.makeSoftDelete( softDelete.reversed() );
softDeleteIndicatorValue.setJpaAttributeConverterDescriptor( converterDescriptor );
softDeleteIndicatorValue.setImplicitJavaTypeAccess( (typeConfiguration) -> {
return converterDescriptor.getRelationalValueResolvedType().getErasedType();
@ -149,8 +150,17 @@ public class SoftDeleteHelper {
//noinspection unchecked
final JdbcLiteralFormatter<Object> literalFormatter = resolution.getJdbcMapping().getJdbcLiteralFormatter();
final Object deletedLiteralValue = converter.toRelationalValue( true );
final Object nonDeletedLiteralValue = converter.toRelationalValue( false );
final Object deletedLiteralValue;
final Object nonDeletedLiteralValue;
if ( converter == null ) {
// the database column is BIT or BOOLEAN : pass-thru
deletedLiteralValue = true;
nonDeletedLiteralValue = false;
}
else {
deletedLiteralValue = converter.toRelationalValue( true );
nonDeletedLiteralValue = converter.toRelationalValue( false );
}
return new SoftDeleteMappingImpl(
softDeletableModelPart,

View File

@ -16,9 +16,13 @@ import org.hibernate.Incubating;
import org.hibernate.Internal;
import org.hibernate.MappingException;
import org.hibernate.TimeZoneStorageStrategy;
import org.hibernate.annotations.SoftDelete;
import org.hibernate.annotations.TimeZoneStorageType;
import org.hibernate.boot.model.TypeDefinition;
import org.hibernate.boot.model.convert.internal.AutoApplicableConverterDescriptorBypassedImpl;
import org.hibernate.boot.model.convert.internal.ClassBasedConverterDescriptor;
import org.hibernate.boot.model.convert.internal.InstanceBasedConverterDescriptor;
import org.hibernate.boot.model.convert.spi.AutoApplicableConverterDescriptor;
import org.hibernate.boot.model.convert.spi.ConverterDescriptor;
import org.hibernate.boot.model.convert.spi.JpaAttributeConverterCreationContext;
import org.hibernate.boot.model.process.internal.InferredBasicValueResolution;
@ -47,13 +51,17 @@ import org.hibernate.resource.beans.spi.ManagedBeanRegistry;
import org.hibernate.tool.schema.extract.spi.ColumnTypeInformation;
import org.hibernate.type.BasicType;
import org.hibernate.type.CustomType;
import org.hibernate.type.NumericBooleanConverter;
import org.hibernate.type.TrueFalseConverter;
import org.hibernate.type.Type;
import org.hibernate.type.WrapperArrayHandling;
import org.hibernate.type.descriptor.converter.spi.BasicValueConverter;
import org.hibernate.type.descriptor.converter.spi.JpaAttributeConverter;
import org.hibernate.type.descriptor.java.BasicJavaType;
import org.hibernate.type.descriptor.java.BasicPluralJavaType;
import org.hibernate.type.descriptor.java.JavaType;
import org.hibernate.type.descriptor.java.MutabilityPlan;
import org.hibernate.type.descriptor.jdbc.BooleanJdbcType;
import org.hibernate.type.descriptor.jdbc.JdbcType;
import org.hibernate.type.descriptor.jdbc.JdbcTypeIndicators;
import org.hibernate.type.internal.BasicTypeImpl;
@ -62,6 +70,7 @@ import org.hibernate.type.spi.TypeConfigurationAware;
import org.hibernate.usertype.DynamicParameterizedType;
import org.hibernate.usertype.UserType;
import com.fasterxml.classmate.ResolvedType;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.EnumType;
import jakarta.persistence.TemporalType;
@ -73,7 +82,7 @@ import static org.hibernate.mapping.MappingHelper.injectParameters;
/**
* @author Steve Ebersole
*/
public class BasicValue extends SimpleValue implements JdbcTypeIndicators, Resolvable {
public class BasicValue extends SimpleValue implements JdbcTypeIndicators, Resolvable, JpaAttributeConverterCreationContext {
private static final CoreMessageLogger log = CoreLogging.messageLogger( BasicValue.class );
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@ -90,6 +99,8 @@ public class BasicValue extends SimpleValue implements JdbcTypeIndicators, Resol
private EnumType enumerationStyle;
private TemporalType temporalPrecision;
private TimeZoneStorageType timeZoneStorageType;
private boolean isSoftDelete;
private boolean isSoftDeleteReversed;
private java.lang.reflect.Type resolvedJavaType;
@ -135,6 +146,19 @@ public class BasicValue extends SimpleValue implements JdbcTypeIndicators, Resol
return new BasicValue( this );
}
public boolean isSoftDelete() {
return isSoftDelete;
}
public boolean isSoftDeleteReversed() {
return isSoftDeleteReversed;
}
public void makeSoftDelete(boolean reversed) {
isSoftDelete = true;
isSoftDeleteReversed = reversed;
}
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Setters - in preparation of resolution
@ -417,16 +441,168 @@ public class BasicValue extends SimpleValue implements JdbcTypeIndicators, Resol
}
// determine JavaType if we can
final BasicJavaType<?> explicitJavaType =
explicitJavaTypeAccess == null ? null : explicitJavaTypeAccess.apply( getTypeConfiguration() );
final JavaType<?> javaType = determineJavaType( explicitJavaType );
final BasicJavaType<?> explicitJavaType = explicitJavaTypeAccess == null
? null
: explicitJavaTypeAccess.apply( getTypeConfiguration() );
JavaType<?> javaType = determineJavaType( explicitJavaType );
ConverterDescriptor attributeConverterDescriptor = getAttributeConverterDescriptor();
if ( isSoftDelete() ) {
assert attributeConverterDescriptor != null;
final boolean conversionWasUnspecified = SoftDelete.UnspecifiedConversion.class.equals( attributeConverterDescriptor.getAttributeConverterClass() );
if ( conversionWasUnspecified ) {
final JdbcType jdbcType = BooleanJdbcType.INSTANCE.resolveIndicatedType( this, javaType );
if ( jdbcType.isNumber() ) {
attributeConverterDescriptor = new InstanceBasedConverterDescriptor(
NumericBooleanConverter.INSTANCE,
getBuildingContext().getBootstrapContext().getClassmateContext()
);
}
else if ( jdbcType.isString() ) {
// here we pick 'T' / 'F' storage, though 'Y' / 'N' is equally valid - its 50/50
attributeConverterDescriptor = new InstanceBasedConverterDescriptor(
TrueFalseConverter.INSTANCE,
getBuildingContext().getBootstrapContext().getClassmateContext()
);
}
else {
// should indicate BIT or BOOLEAN == no conversion needed
// - we still create the converter to properly set up JDBC type, etc
attributeConverterDescriptor = new InstanceBasedConverterDescriptor(
PassThruSoftDeleteConverter.INSTANCE,
getBuildingContext().getBootstrapContext().getClassmateContext()
);
}
}
if ( isSoftDeleteReversed() ) {
attributeConverterDescriptor = new ReversedConverterDescriptor<>( attributeConverterDescriptor );
}
}
final ConverterDescriptor attributeConverterDescriptor = getAttributeConverterDescriptor();
return attributeConverterDescriptor != null
? converterResolution( javaType, attributeConverterDescriptor )
: resolution( explicitJavaType, javaType );
}
private static class ReversedConverterDescriptor<R> implements ConverterDescriptor {
private final ConverterDescriptor underlyingDescriptor;
public ReversedConverterDescriptor(ConverterDescriptor underlyingDescriptor) {
this.underlyingDescriptor = underlyingDescriptor;
}
@Override
public Class<? extends AttributeConverter<Boolean,R>> getAttributeConverterClass() {
//noinspection unchecked
return (Class<? extends AttributeConverter<Boolean, R>>) getClass();
}
@Override
public ResolvedType getDomainValueResolvedType() {
return underlyingDescriptor.getDomainValueResolvedType();
}
@Override
public ResolvedType getRelationalValueResolvedType() {
return underlyingDescriptor.getRelationalValueResolvedType();
}
@Override
public AutoApplicableConverterDescriptor getAutoApplyDescriptor() {
return AutoApplicableConverterDescriptorBypassedImpl.INSTANCE;
}
@Override
public JpaAttributeConverter<Boolean,R> createJpaAttributeConverter(JpaAttributeConverterCreationContext context) {
//noinspection unchecked
return new ReversedJpaAttributeConverter<>(
(JpaAttributeConverter<Boolean, R>) underlyingDescriptor.createJpaAttributeConverter( context ),
context.getJavaTypeRegistry().getDescriptor( ReversedJpaAttributeConverter.class )
);
}
}
private static class ReversedJpaAttributeConverter<R, B extends AttributeConverter<Boolean, R>>
implements JpaAttributeConverter<Boolean,R>, AttributeConverter<Boolean,R>, ManagedBean<B> {
private final JpaAttributeConverter<Boolean,R> underlyingJpaConverter;
private final JavaType<ReversedJpaAttributeConverter<R,B>> converterJavaType;
public ReversedJpaAttributeConverter(
JpaAttributeConverter<Boolean, R> underlyingJpaConverter,
JavaType<ReversedJpaAttributeConverter<R,B>> converterJavaType) {
this.underlyingJpaConverter = underlyingJpaConverter;
this.converterJavaType = converterJavaType;
}
@Override
public Boolean toDomainValue(R relationalValue) {
return !underlyingJpaConverter.toDomainValue( relationalValue );
}
@Override
public R toRelationalValue(Boolean domainValue) {
return underlyingJpaConverter.toRelationalValue( !domainValue );
}
@Override
public Boolean convertToEntityAttribute(R relationalValue) {
return toDomainValue( relationalValue );
}
@Override
public R convertToDatabaseColumn(Boolean domainValue) {
return toRelationalValue( domainValue );
}
@Override
public JavaType<Boolean> getDomainJavaType() {
return underlyingJpaConverter.getDomainJavaType();
}
@Override
public JavaType<R> getRelationalJavaType() {
return underlyingJpaConverter.getRelationalJavaType();
}
@Override
public JavaType<? extends AttributeConverter<Boolean, R>> getConverterJavaType() {
return converterJavaType;
}
@Override
public ManagedBean<? extends AttributeConverter<Boolean, R>> getConverterBean() {
return this;
}
@Override
public Class<B> getBeanClass() {
//noinspection unchecked
return (Class<B>) getClass();
}
@Override
public B getBeanInstance() {
//noinspection unchecked
return (B) this;
}
}
private static class PassThruSoftDeleteConverter implements AttributeConverter<Boolean,Boolean> {
private static final PassThruSoftDeleteConverter INSTANCE = new PassThruSoftDeleteConverter();
@Override
public Boolean convertToDatabaseColumn(Boolean domainValue) {
return domainValue;
}
@Override
public Boolean convertToEntityAttribute(Boolean relationalValue) {
return relationalValue;
}
}
private Resolution<?> resolution(BasicJavaType explicitJavaType, JavaType<?> javaType) {
final JavaType<?> basicJavaType;
final JdbcType jdbcType;
@ -471,24 +647,19 @@ public class BasicValue extends SimpleValue implements JdbcTypeIndicators, Resol
);
}
@Override
public ManagedBeanRegistry getManagedBeanRegistry() {
return getServiceRegistry().getService( ManagedBeanRegistry.class );
}
private Resolution<?> converterResolution(JavaType<?> javaType, ConverterDescriptor attributeConverterDescriptor) {
final ManagedBeanRegistry managedBeanRegistry = getServiceRegistry().getService( ManagedBeanRegistry.class );
final NamedConverterResolution<?> converterResolution = NamedConverterResolution.from(
attributeConverterDescriptor,
explicitJavaTypeAccess,
explicitJdbcTypeAccess,
explicitMutabilityPlanAccess,
this,
new JpaAttributeConverterCreationContext() {
@Override
public ManagedBeanRegistry getManagedBeanRegistry() {
return managedBeanRegistry;
}
@Override
public TypeConfiguration getTypeConfiguration() {
return BasicValue.this.getTypeConfiguration();
}
},
this,
getBuildingContext()
);

View File

@ -1,51 +0,0 @@
/*
* Hibernate, Relational Persistence for Idiomatic Java
*
* License: GNU Lesser General Public License (LGPL), version 2.1 or later.
* See the lgpl.txt file in the root directory or http://www.gnu.org/licenses/lgpl-2.1.html.
*/
package org.hibernate.type;
import org.hibernate.annotations.SoftDelete;
import org.hibernate.type.descriptor.java.BooleanJavaType;
import org.hibernate.type.descriptor.java.JavaType;
/**
* Simple pass-through boolean value converter.
* Useful in {@linkplain SoftDelete#converter() certain scenarios}.
*
* @author Steve Ebersole
*/
public class BooleanAsBooleanConverter implements StandardBooleanConverter<Boolean> {
public static final BooleanAsBooleanConverter INSTANCE = new BooleanAsBooleanConverter();
@Override
public Boolean convertToDatabaseColumn(Boolean attribute) {
return toRelationalValue( attribute );
}
@Override
public Boolean convertToEntityAttribute(Boolean dbData) {
return toDomainValue( dbData );
}
@Override
public Boolean toDomainValue(Boolean relationalForm) {
return relationalForm;
}
@Override
public Boolean toRelationalValue(Boolean domainForm) {
return domainForm;
}
@Override
public JavaType<Boolean> getDomainJavaType() {
return BooleanJavaType.INSTANCE;
}
@Override
public JavaType<Boolean> getRelationalJavaType() {
return BooleanJavaType.INSTANCE;
}
}

View File

@ -0,0 +1,211 @@
/*
* Hibernate, Relational Persistence for Idiomatic Java
*
* License: GNU Lesser General Public License (LGPL), version 2.1 or later.
* See the lgpl.txt file in the root directory or http://www.gnu.org/licenses/lgpl-2.1.html.
*/
package org.hibernate.orm.test.softdelete;
import java.sql.ResultSet;
import java.sql.Statement;
import java.util.List;
import org.hibernate.ObjectNotFoundException;
import org.hibernate.annotations.NaturalId;
import org.hibernate.annotations.SoftDelete;
import org.hibernate.testing.orm.junit.DomainModel;
import org.hibernate.testing.orm.junit.SessionFactory;
import org.hibernate.testing.orm.junit.SessionFactoryScope;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.fail;
/**
* @author Steve Ebersole
*/
@DomainModel(annotatedClasses = ImplicitSoftDeleteTests.ImplicitEntity.class)
@SessionFactory
public class ImplicitSoftDeleteTests {
@BeforeEach
void createTestData(SessionFactoryScope scope) {
scope.inTransaction( (session) -> {
session.persist( new ImplicitEntity( 1, "first" ) );
session.persist( new ImplicitEntity( 2, "second" ) );
session.persist( new ImplicitEntity( 3, "third" ) );
} );
scope.inTransaction( (session) -> {
final ImplicitEntity first = session.getReference( ImplicitEntity.class, 1 );
session.remove( first );
session.flush();
// make sure all 3 are still physically there
session.doWork( (connection) -> {
final Statement statement = connection.createStatement();
final ResultSet resultSet = statement.executeQuery( "select count(1) from implicit_entities" );
resultSet.next();
final int count = resultSet.getInt( 1 );
assertThat( count ).isEqualTo( 3 );
} );
} );
}
@AfterEach
void tearDown(SessionFactoryScope scope) {
scope.inTransaction( (session) -> session.doWork( (connection) -> {
final Statement statement = connection.createStatement();
statement.execute( "delete from implicit_entities" );
} ) );
}
@Test
void testSelectionQuery(SessionFactoryScope scope) {
scope.inTransaction( (session) -> {
// should not return #1
assertThat( session.createQuery( "from ImplicitEntity" ).list() ).hasSize( 2 );
} );
}
@Test
void testLoading(SessionFactoryScope scope) {
// Load
scope.inTransaction( (session) -> {
assertThat( session.get( ImplicitEntity.class, 1 ) ).isNull();
assertThat( session.get( ImplicitEntity.class, 2 ) ).isNotNull();
assertThat( session.get( ImplicitEntity.class, 3 ) ).isNotNull();
} );
// Proxy
scope.inTransaction( (session) -> {
final ImplicitEntity reference = session.getReference( ImplicitEntity.class, 1 );
try {
reference.getName();
fail( "Expecting to fail" );
}
catch (ObjectNotFoundException expected) {
}
final ImplicitEntity reference2 = session.getReference( ImplicitEntity.class, 2 );
reference2.getName();
final ImplicitEntity reference3 = session.getReference( ImplicitEntity.class, 3 );
reference3.getName();
} );
}
@Test
void testMultiLoading(SessionFactoryScope scope) {
scope.inTransaction( (session) -> {
final List<ImplicitEntity> results = session
.byMultipleIds( ImplicitEntity.class )
// otherwise the first position would contain a null for #1
.enableOrderedReturn( false )
.multiLoad( 1, 2, 3 );
assertThat( results ).hasSize( 2 );
} );
}
@Test
void testNaturalIdLoading(SessionFactoryScope scope) {
scope.inTransaction( (session) -> {
final ImplicitEntity first = session.bySimpleNaturalId( ImplicitEntity.class ).load( "first" );
assertThat( first ).isNull();
final ImplicitEntity second = session.bySimpleNaturalId( ImplicitEntity.class ).load( "second" );
assertThat( second ).isNotNull();
} );
}
@Test
void testDeletion(SessionFactoryScope scope) {
scope.inTransaction( (session) -> {
final ImplicitEntity reference = session.getReference( ImplicitEntity.class, 2 );
session.remove( reference );
session.flush();
final List<ImplicitEntity> active = session
.createSelectionQuery( "from ImplicitEntity", ImplicitEntity.class )
.list();
// #1 was "deleted" up front and we just "deleted" #2... only #3 should be active
assertThat( active ).hasSize( 1 );
assertThat( active.get(0).getId() ).isEqualTo( 3 );
} );
}
@Test
void testFullUpdateMutationQuery(SessionFactoryScope scope) {
scope.inTransaction( (session) -> {
final int affected = session.createMutationQuery( "update ImplicitEntity set name = null" ).executeUpdate();
assertThat( affected ).isEqualTo( 2 );
} );
}
@Test
void testRestrictedUpdateMutationQuery(SessionFactoryScope scope) {
scope.inTransaction( (session) -> {
final int affected = session
.createMutationQuery( "update ImplicitEntity set name = null where name = 'second'" )
.executeUpdate();
assertThat( affected ).isEqualTo( 1 );
} );
}
@Test
void testFullDeleteMutationQuery(SessionFactoryScope scope) {
scope.inTransaction( (session) -> {
final int affected = session.createMutationQuery( "delete ImplicitEntity" ).executeUpdate();
// only #2 and #3
assertThat( affected ).isEqualTo( 2 );
} );
}
@Test
void testRestrictedDeleteMutationQuery(SessionFactoryScope scope) {
scope.inTransaction( (session) -> {
final int affected = session
.createMutationQuery( "delete ImplicitEntity where name = 'second'" )
.executeUpdate();
// only #2
assertThat( affected ).isEqualTo( 1 );
} );
}
@Entity(name="ImplicitEntity")
@Table(name="implicit_entities")
@SoftDelete
public static class ImplicitEntity {
@Id
private Integer id;
@NaturalId
private String name;
public ImplicitEntity() {
}
public ImplicitEntity(Integer id, String name) {
this.id = id;
this.name = name;
}
public Integer getId() {
return id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
}

View File

@ -8,7 +8,6 @@ package org.hibernate.orm.test.softdelete;
import org.hibernate.annotations.SoftDelete;
import org.hibernate.metamodel.spi.MappingMetamodelImplementor;
import org.hibernate.type.BooleanAsBooleanConverter;
import org.hibernate.type.NumericBooleanConverter;
import org.hibernate.type.TrueFalseConverter;
import org.hibernate.type.YesNoConverter;
@ -80,7 +79,7 @@ public class MappingTests {
@Entity(name="BooleanEntity")
@Table(name="boolean_entity")
@SoftDelete(converter = BooleanAsBooleanConverter.class)
@SoftDelete()
public static class BooleanEntity {
@Id
private Integer id;
@ -116,7 +115,7 @@ public class MappingTests {
@Entity(name="ReversedYesNoEntity")
@Table(name="reversed_yes_no_entity")
@SoftDelete(columnName = "active", converter = ReverseYesNoConverter.class)
@SoftDelete(columnName = "active", converter = YesNoConverter.class, reversed = true)
public static class ReversedYesNoEntity {
@Id
private Integer id;

View File

@ -1,32 +0,0 @@
/*
* Hibernate, Relational Persistence for Idiomatic Java
*
* License: GNU Lesser General Public License (LGPL), version 2.1 or later.
* See the lgpl.txt file in the root directory or http://www.gnu.org/licenses/lgpl-2.1.html.
*/
package org.hibernate.orm.test.softdelete;
import jakarta.persistence.AttributeConverter;
/**
* @author Steve Ebersole
*/
public class ReverseYesNoConverter implements AttributeConverter<Boolean,Character> {
@Override
public Character convertToDatabaseColumn(Boolean attribute) {
return attribute ? 'N' : 'Y';
}
@Override
public Boolean convertToEntityAttribute(Character dbData) {
if ( dbData == 'Y' ) {
return false;
}
if ( dbData == 'N' ) {
return true;
}
throw new IllegalArgumentException( "Illegal value [" + dbData + "]; expecting 'Y' or 'N'" );
}
}

View File

@ -0,0 +1,46 @@
/*
* Hibernate, Relational Persistence for Idiomatic Java
*
* License: GNU Lesser General Public License (LGPL), version 2.1 or later.
* See the lgpl.txt file in the root directory or http://www.gnu.org/licenses/lgpl-2.1.html.
*/
package org.hibernate.orm.test.softdelete;
import org.hibernate.annotations.NaturalId;
import org.hibernate.annotations.SoftDelete;
import org.hibernate.type.YesNoConverter;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
@Table(name = "simple")
//tag::example-soft-delete-basic[]
@Entity(name = "SimpleEntity")
@SoftDelete(columnName = "removed", converter = YesNoConverter.class)
public class SimpleEntity {
// ...
//end::example-soft-delete-basic[]
@Id
private Integer id;
@NaturalId
private String name;
public SimpleEntity() {
}
public SimpleEntity(Integer id, String name) {
this.id = id;
this.name = name;
}
public Integer getId() {
return id;
}
public String getName() {
return name;
}
//tag::example-soft-delete-basic[]
}
//end::example-soft-delete-basic[]

View File

@ -12,7 +12,6 @@ import java.util.List;
import org.hibernate.Hibernate;
import org.hibernate.ObjectNotFoundException;
import org.hibernate.annotations.BatchSize;
import org.hibernate.annotations.NaturalId;
import org.hibernate.annotations.SoftDelete;
import org.hibernate.type.YesNoConverter;
@ -34,7 +33,7 @@ import static org.assertj.core.api.Assertions.fail;
/**
* @author Steve Ebersole
*/
@DomainModel(annotatedClasses = { SimpleSoftDeleteTests.SimpleEntity.class, SimpleSoftDeleteTests.BatchLoadable.class })
@DomainModel(annotatedClasses = { SimpleEntity.class, SimpleSoftDeleteTests.BatchLoadable.class })
@SessionFactory(useCollectingStatementInspector = true)
public class SimpleSoftDeleteTests {
@BeforeEach
@ -212,40 +211,10 @@ public class SimpleSoftDeleteTests {
} );
}
/**
* @implNote Uses YesNoConverter to work across all databases, even those
* not supporting an actual BOOLEAN datatype
*/
@Entity(name="SimpleEntity")
@Table(name="simple")
@SoftDelete(columnName = "removed", converter = YesNoConverter.class)
public static class SimpleEntity {
@Id
private Integer id;
@NaturalId
private String name;
public SimpleEntity() {
}
public SimpleEntity(Integer id, String name) {
this.id = id;
this.name = name;
}
public Integer getId() {
return id;
}
public String getName() {
return name;
}
}
@Entity(name="BatchLoadable")
@Table(name="batch_loadable")
@BatchSize(size = 5)
@SoftDelete(columnName = "active", converter = ReverseYesNoConverter.class)
@SoftDelete(columnName = "active", converter = YesNoConverter.class, reversed = true)
public static class BatchLoadable {
@Id
private Integer id;

View File

@ -11,18 +11,17 @@ import java.sql.Statement;
import org.hibernate.annotations.Fetch;
import org.hibernate.annotations.FetchMode;
import org.hibernate.annotations.SoftDelete;
import org.hibernate.type.YesNoConverter;
import org.hibernate.testing.jdbc.SQLStatementInspector;
import org.hibernate.testing.orm.junit.DomainModel;
import org.hibernate.testing.orm.junit.SessionFactory;
import org.hibernate.testing.orm.junit.SessionFactoryScope;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
@ -145,7 +144,7 @@ public class ToOneTests {
@Entity(name="User")
@Table(name="users")
@SoftDelete(columnName = "active", converter = ReverseYesNoConverter.class)
@SoftDelete(columnName = "active", converter = YesNoConverter.class, reversed = true)
public static class User {
@Id
private Integer id;

View File

@ -12,6 +12,7 @@ import org.hibernate.annotations.SoftDelete;
import org.hibernate.boot.Metadata;
import org.hibernate.boot.MetadataSources;
import org.hibernate.metamodel.UnsupportedMappingException;
import org.hibernate.type.YesNoConverter;
import org.junit.jupiter.api.Test;
@ -64,7 +65,7 @@ public class ValidationTests {
@Entity(name="Address")
@Table(name="addresses")
@SoftDelete(columnName = "active", converter = ReverseYesNoConverter.class)
@SoftDelete(columnName = "active", converter = YesNoConverter.class, reversed = true)
public static class Address {
@Id
private Integer id;
@ -73,7 +74,7 @@ public class ValidationTests {
@Entity(name="NoNo")
@Table(name="nonos")
@SoftDelete(columnName = "active", converter = ReverseYesNoConverter.class)
@SoftDelete(columnName = "active", converter = YesNoConverter.class, reversed = true)
@SQLDelete( sql = "delete from nonos" )
public static class NoNo {
@Id

View File

@ -35,12 +35,15 @@ public class CollectionOwner {
@Basic
private String name;
//tag::example-soft-delete-element-collection[]
@ElementCollection
@CollectionTable(name = "elements", joinColumns = @JoinColumn(name = "owner_fk"))
@Column(name = "txt")
@SoftDelete(converter = YesNoConverter.class)
private Collection<String> elements;
//end::example-soft-delete-element-collection[]
//tag::example-soft-delete-many-to-many[]
@ManyToMany
@JoinTable(
name = "m2m",
@ -49,6 +52,7 @@ public class CollectionOwner {
)
@SoftDelete(columnName = "gone", converter = NumericBooleanConverter.class)
private Collection<CollectionOwned> manyToMany;
//end::example-soft-delete-many-to-many[]
protected CollectionOwner() {
// for Hibernate use

View File

@ -12,7 +12,8 @@ import org.hibernate.annotations.BatchSize;
import org.hibernate.annotations.Fetch;
import org.hibernate.annotations.FetchMode;
import org.hibernate.annotations.SoftDelete;
import org.hibernate.orm.test.softdelete.ReverseYesNoConverter;
import org.hibernate.type.NumericBooleanConverter;
import org.hibernate.type.YesNoConverter;
import jakarta.persistence.CollectionTable;
import jakarta.persistence.ElementCollection;
@ -34,13 +35,13 @@ public class CollectionOwner2 {
@ElementCollection
@CollectionTable(name="batch_loadables", joinColumns = @JoinColumn(name="owner_fk"))
@BatchSize(size = 5)
@SoftDelete(columnName = "active", converter = ReverseYesNoConverter.class)
@SoftDelete(columnName = "active", converter = YesNoConverter.class, reversed = true)
private Set<String> batchLoadable;
@ElementCollection
@CollectionTable(name="subselect_loadables", joinColumns = @JoinColumn(name="owner_fk"))
@Fetch(FetchMode.SUBSELECT)
@SoftDelete(columnName = "active", converter = ReverseYesNoConverter.class)
@SoftDelete(columnName = "active", converter = NumericBooleanConverter.class, reversed = true)
private Set<String> subSelectLoadable;
public CollectionOwner2() {

View File

@ -121,7 +121,7 @@ public class FetchLoadableTests {
// trigger loading one of the subselect-loadable collections
first.getSubSelectLoadable().size();
assertThat( statementInspector.getSqlQueries() ).hasSize( 1 );
assertThat( statementInspector.getSqlQueries().get( 0 ) ).containsAnyOf( "active='Y'", "active=N'Y'" );
assertThat( statementInspector.getSqlQueries().get( 0 ) ).containsAnyOf( "active=1", "active=N'Y'" );
assertThat( Hibernate.isInitialized( first.getSubSelectLoadable() ) ).isTrue();
assertThat( Hibernate.isInitialized( second.getSubSelectLoadable() ) ).isTrue();
} );

View File

@ -6,22 +6,27 @@
*/
package org.hibernate.orm.test.softdelete.converter.reversed;
import org.hibernate.cfg.AvailableSettings;
import org.hibernate.metamodel.spi.MappingMetamodelImplementor;
import org.hibernate.orm.test.softdelete.MappingVerifier;
import org.hibernate.orm.test.softdelete.ReverseYesNoConverter;
import org.hibernate.testing.jdbc.SQLStatementInspector;
import org.hibernate.testing.orm.junit.DomainModel;
import org.hibernate.testing.orm.junit.ServiceRegistry;
import org.hibernate.testing.orm.junit.SessionFactory;
import org.hibernate.testing.orm.junit.SessionFactoryScope;
import org.hibernate.testing.orm.junit.Setting;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/**
* @implNote {@code preferred_boolean_jdbc_type=CHAR} will use T/F as the default (Entity2)
*
* @author Steve Ebersole
*/
@DomainModel(annotatedClasses = { ReverseYesNoConverter.class, TheEntity.class })
@ServiceRegistry(settings = @Setting(name= AvailableSettings.PREFERRED_BOOLEAN_JDBC_TYPE, value = "CHAR"))
@DomainModel(annotatedClasses = {TheEntity.class, TheEntity2.class})
@SessionFactory( useCollectingStatementInspector = true)
public class ReversedSoftDeleteTests {
@Test
@ -33,6 +38,12 @@ public class ReversedSoftDeleteTests {
"the_entity",
'N'
);
MappingVerifier.verifyMapping(
metamodel.getEntityDescriptor( TheEntity2.class ).getSoftDeleteMapping(),
"active",
"the_entity2",
'F'
);
}
@Test
@ -60,4 +71,30 @@ public class ReversedSoftDeleteTests {
assertThat( sqlInspector.getSqlQueries().get( 0 ) ).containsIgnoringCase( "update " );
assertThat( sqlInspector.getSqlQueries().get( 0 ) ).containsAnyOf( "active='N'", "active=N'N'" );
}
@Test
void testUsage2(SessionFactoryScope scope) {
final SQLStatementInspector sqlInspector = scope.getCollectingStatementInspector();
sqlInspector.clear();
scope.inTransaction( (session) -> {
session.persist( new TheEntity2( 1, "it" ) );
} );
assertThat( sqlInspector.getSqlQueries() ).hasSize( 1 );
assertThat( sqlInspector.getSqlQueries().get( 0 ) ).contains( "'T'" );
assertThat( sqlInspector.getSqlQueries().get( 0 ) ).doesNotContain( "'F'" );
sqlInspector.clear();
scope.inTransaction( (session) -> {
final TheEntity2 reference = session.getReference( TheEntity2.class, 1 );
session.remove( reference );
} );
assertThat( sqlInspector.getSqlQueries() ).hasSize( 1 );
assertThat( sqlInspector.getSqlQueries().get( 0 ) ).doesNotContainIgnoringCase( "delete " );
assertThat( sqlInspector.getSqlQueries().get( 0 ) ).containsIgnoringCase( "update " );
assertThat( sqlInspector.getSqlQueries().get( 0 ) ).contains( "active='F'" );
}
}

View File

@ -7,20 +7,23 @@
package org.hibernate.orm.test.softdelete.converter.reversed;
import org.hibernate.annotations.SoftDelete;
import org.hibernate.orm.test.softdelete.ReverseYesNoConverter;
import org.hibernate.type.YesNoConverter;
import jakarta.persistence.Basic;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Basic;
import jakarta.persistence.Table;
/**
* @author Steve Ebersole
*/
@Entity
@Table(name = "the_entity")
@SoftDelete(columnName = "active", converter = ReverseYesNoConverter.class)
//tag::example-soft-delete-reverse[]
@Entity
@SoftDelete(columnName = "active", converter = YesNoConverter.class, reversed = true)
public class TheEntity {
// ...
//end::example-soft-delete-reverse[]
@Id
private Integer id;
@Basic
@ -46,4 +49,7 @@ public class TheEntity {
public void setName(String name) {
this.name = name;
}
//tag::example-soft-delete-reverse[]
}
//end::example-soft-delete-reverse[]

View File

@ -0,0 +1,55 @@
/*
* Hibernate, Relational Persistence for Idiomatic Java
*
* License: GNU Lesser General Public License (LGPL), version 2.1 or later.
* See the lgpl.txt file in the root directory or http://www.gnu.org/licenses/lgpl-2.1.html.
*/
package org.hibernate.orm.test.softdelete.converter.reversed;
import org.hibernate.annotations.SoftDelete;
import org.hibernate.type.YesNoConverter;
import jakarta.persistence.Basic;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
/**
* @author Steve Ebersole
*/
@Table(name = "the_entity2")
//tag::example-soft-delete-reverse[]
@Entity
@SoftDelete(columnName = "active", reversed = true)
public class TheEntity2 {
// ...
//end::example-soft-delete-reverse[]
@Id
private Integer id;
@Basic
private String name;
protected TheEntity2() {
// for Hibernate use
}
public TheEntity2(Integer id, String name) {
this.id = id;
this.name = name;
}
public Integer getId() {
return id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
//tag::example-soft-delete-reverse[]
}
//end::example-soft-delete-reverse[]

View File

@ -22,11 +22,14 @@ import jakarta.persistence.Table;
*
* @author Steve Ebersole
*/
@Table(name = "joined_root")
//tag::example-soft-delete-secondary[]
@Entity
@Inheritance(strategy = InheritanceType.JOINED)
@Table(name = "joined_root")
@SoftDelete(columnName = "removed", converter = YesNoConverter.class)
public abstract class JoinedRoot {
// ...
//end::example-soft-delete-secondary[]
@Id
private Integer id;
@Basic
@ -52,4 +55,6 @@ public abstract class JoinedRoot {
public void setName(String name) {
this.name = name;
}
//tag::example-soft-delete-secondary[]
}
//end::example-soft-delete-secondary[]

View File

@ -14,10 +14,13 @@ import jakarta.persistence.Table;
/**
* @author Steve Ebersole
*/
//tag::example-soft-delete-secondary[]
@Entity
@Table(name = "joined_sub")
@PrimaryKeyJoinColumn(name = "joined_fk")
public class JoinedSub extends JoinedRoot {
// ...
//end::example-soft-delete-secondary[]
@Basic
String subDetails;
@ -28,4 +31,6 @@ public class JoinedSub extends JoinedRoot {
super( id, name );
this.subDetails = subDetails;
}
//tag::example-soft-delete-secondary[]
}
//end::example-soft-delete-secondary[]

View File

@ -16,3 +16,6 @@ earlier versions, see any other pertinent migration guides as well.
* link:{docsBase}/6.0/migration-guide/migration-guide.html[6.0 Migration guide]
[[soft-delete]]
== Soft Delete