From ae238d303271f1b613e3c59c9fa1f7209c5b192f Mon Sep 17 00:00:00 2001 From: Steve Ebersole Date: Thu, 2 Feb 2023 12:23:40 -0600 Subject: [PATCH] HHH-16081 - Converted collection-as-basic values are considered immutable HHH-16132 - Dirty checking broken for collection-as-basic mappings (test) --- .../internal/NamedConverterResolution.java | 58 ++++-- .../util/collections/CollectionHelper.java | 10 + .../java/spi/CollectionJavaType.java | 40 +++- .../descriptor/java/spi/JavaTypeRegistry.java | 2 +- .../mutabiity/ConvertedMapImmutableTests.java | 165 +++++++++++++++ .../mutabiity/ConvertedMapMutableTests.java | 179 +++++++++++++++++ .../mutabiity/ConvertedMutabilityTests.java | 188 ++++++++++++++++++ 7 files changed, 613 insertions(+), 29 deletions(-) create mode 100644 hibernate-core/src/test/java/org/hibernate/orm/test/mapping/converted/converter/mutabiity/ConvertedMapImmutableTests.java create mode 100644 hibernate-core/src/test/java/org/hibernate/orm/test/mapping/converted/converter/mutabiity/ConvertedMapMutableTests.java create mode 100644 hibernate-core/src/test/java/org/hibernate/orm/test/mapping/converted/converter/mutabiity/ConvertedMutabilityTests.java diff --git a/hibernate-core/src/main/java/org/hibernate/boot/model/process/internal/NamedConverterResolution.java b/hibernate-core/src/main/java/org/hibernate/boot/model/process/internal/NamedConverterResolution.java index e423fe78fc..66d8b6ab60 100644 --- a/hibernate-core/src/main/java/org/hibernate/boot/model/process/internal/NamedConverterResolution.java +++ b/hibernate-core/src/main/java/org/hibernate/boot/model/process/internal/NamedConverterResolution.java @@ -6,18 +6,21 @@ */ package org.hibernate.boot.model.process.internal; +import java.util.Map; import java.util.function.Function; +import org.hibernate.annotations.Immutable; import org.hibernate.boot.model.convert.internal.ClassBasedConverterDescriptor; import org.hibernate.boot.model.convert.spi.ConverterDescriptor; import org.hibernate.boot.model.convert.spi.JpaAttributeConverterCreationContext; import org.hibernate.boot.registry.classloading.spi.ClassLoaderService; import org.hibernate.boot.spi.MetadataBuildingContext; import org.hibernate.mapping.BasicValue; +import org.hibernate.mapping.Collection; import org.hibernate.metamodel.mapping.JdbcMapping; -import org.hibernate.type.descriptor.converter.spi.JpaAttributeConverter; import org.hibernate.type.BasicType; import org.hibernate.type.descriptor.converter.internal.AttributeConverterMutabilityPlanImpl; +import org.hibernate.type.descriptor.converter.spi.JpaAttributeConverter; import org.hibernate.type.descriptor.java.BasicJavaType; import org.hibernate.type.descriptor.java.ImmutableMutabilityPlan; import org.hibernate.type.descriptor.java.JavaType; @@ -112,23 +115,14 @@ public class NamedConverterResolution implements BasicValue.Resolution { ? explicitJdbcType : relationalJtd.getRecommendedJdbcType( sqlTypeIndicators ); - final MutabilityPlan explicitMutabilityPlan = explicitMutabilityPlanAccess != null - ? explicitMutabilityPlanAccess.apply( typeConfiguration ) - : null; + final MutabilityPlan mutabilityPlan = determineMutabilityPlan( + explicitMutabilityPlanAccess, + typeConfiguration, + converter, + domainJtd + ); - - final MutabilityPlan mutabilityPlan; - if ( explicitMutabilityPlan != null ) { - mutabilityPlan = explicitMutabilityPlan; - } - else if ( ! domainJtd.getMutabilityPlan().isMutable() ) { - mutabilityPlan = ImmutableMutabilityPlan.instance(); - } - else { - mutabilityPlan = new AttributeConverterMutabilityPlanImpl<>( converter, true ); - } - - return new NamedConverterResolution( + return new NamedConverterResolution<>( domainJtd, relationalJtd, jdbcType, @@ -138,6 +132,36 @@ public class NamedConverterResolution implements BasicValue.Resolution { ); } + private static MutabilityPlan determineMutabilityPlan( + Function explicitMutabilityPlanAccess, + TypeConfiguration typeConfiguration, + JpaAttributeConverter converter, + JavaType domainJtd) { + //noinspection unchecked + final MutabilityPlan explicitMutabilityPlan = explicitMutabilityPlanAccess != null + ? explicitMutabilityPlanAccess.apply( typeConfiguration ) + : null; + if ( explicitMutabilityPlan != null ) { + return explicitMutabilityPlan; + } + + if ( converter.getConverterJavaType().getJavaTypeClass().isAnnotationPresent( Immutable.class ) ) { + return ImmutableMutabilityPlan.instance(); + } + + if ( !domainJtd.getMutabilityPlan().isMutable() + && !isCollection( domainJtd.getJavaTypeClass() ) ) { + return ImmutableMutabilityPlan.instance(); + } + + return new AttributeConverterMutabilityPlanImpl<>( converter, true ); + } + + private static boolean isCollection(Class javaType) { + return Collection.class.isAssignableFrom( javaType ) + || Map.class.isAssignableFrom( javaType ); + } + private final JavaType domainJtd; private final JavaType relationalJtd; diff --git a/hibernate-core/src/main/java/org/hibernate/internal/util/collections/CollectionHelper.java b/hibernate-core/src/main/java/org/hibernate/internal/util/collections/CollectionHelper.java index 927784930d..00a13f9dbd 100644 --- a/hibernate-core/src/main/java/org/hibernate/internal/util/collections/CollectionHelper.java +++ b/hibernate-core/src/main/java/org/hibernate/internal/util/collections/CollectionHelper.java @@ -426,6 +426,16 @@ public final class CollectionHelper { } } + public static String[] asPairs(Map map) { + final String[] pairs = new String[ map.size() * 2 ]; + int i = 0; + for ( Map.Entry entry : map.entrySet() ) { + pairs[i++] = entry.getKey(); + pairs[i++] = entry.getValue(); + } + return pairs; + } + public static Properties toProperties(Object... pairs) { final Properties properties = new Properties(); if ( pairs.length > 0 ) { diff --git a/hibernate-core/src/main/java/org/hibernate/type/descriptor/java/spi/CollectionJavaType.java b/hibernate-core/src/main/java/org/hibernate/type/descriptor/java/spi/CollectionJavaType.java index fd8dbebe7c..c4a8d31dbe 100644 --- a/hibernate-core/src/main/java/org/hibernate/type/descriptor/java/spi/CollectionJavaType.java +++ b/hibernate-core/src/main/java/org/hibernate/type/descriptor/java/spi/CollectionJavaType.java @@ -7,6 +7,7 @@ package org.hibernate.type.descriptor.java.spi; import java.lang.reflect.ParameterizedType; +import java.util.Objects; import org.hibernate.collection.spi.CollectionSemantics; import org.hibernate.collection.spi.PersistentCollection; @@ -89,17 +90,34 @@ public class CollectionJavaType extends AbstractClassJavaType { @Override public boolean areEqual(C one, C another) { - return one == another || - ( - one instanceof PersistentCollection && - ( (PersistentCollection) one ).wasInitialized() && - ( (PersistentCollection) one ).isWrapper( another ) - ) || - ( - another instanceof PersistentCollection && - ( (PersistentCollection) another ).wasInitialized() && - ( (PersistentCollection) another ).isWrapper( one ) - ); +// return one == another || +// ( +// one instanceof PersistentCollection && +// ( (PersistentCollection) one ).wasInitialized() && +// ( (PersistentCollection) one ).isWrapper( another ) +// ) || +// ( +// another instanceof PersistentCollection && +// ( (PersistentCollection) another ).wasInitialized() && +// ( (PersistentCollection) another ).isWrapper( one ) +// ); + + + if ( one == another ) { + return true; + } + + if ( one instanceof PersistentCollection ) { + final PersistentCollection pc = (PersistentCollection) one; + return pc.wasInitialized() && ( pc.isWrapper( another ) || pc.isDirectlyProvidedCollection( another ) ); + } + + if ( another instanceof PersistentCollection ) { + final PersistentCollection pc = (PersistentCollection) another; + return pc.wasInitialized() && ( pc.isWrapper( one ) || pc.isDirectlyProvidedCollection( one ) ); + } + + return Objects.equals( one, another ); } @Override diff --git a/hibernate-core/src/main/java/org/hibernate/type/descriptor/java/spi/JavaTypeRegistry.java b/hibernate-core/src/main/java/org/hibernate/type/descriptor/java/spi/JavaTypeRegistry.java index 8be9a45e14..122207dae3 100644 --- a/hibernate-core/src/main/java/org/hibernate/type/descriptor/java/spi/JavaTypeRegistry.java +++ b/hibernate-core/src/main/java/org/hibernate/type/descriptor/java/spi/JavaTypeRegistry.java @@ -136,7 +136,7 @@ public class JavaTypeRegistry implements JavaTypeBaseline.BaselineTarget, Serial () -> { if ( javaType instanceof ParameterizedType ) { final ParameterizedType parameterizedType = (ParameterizedType) javaType; - final JavaType rawType = findDescriptor( ( parameterizedType ).getRawType() ); + final JavaType rawType = findDescriptor( parameterizedType.getRawType() ); if ( rawType != null ) { return rawType.createJavaType( parameterizedType, typeConfiguration ); } diff --git a/hibernate-core/src/test/java/org/hibernate/orm/test/mapping/converted/converter/mutabiity/ConvertedMapImmutableTests.java b/hibernate-core/src/test/java/org/hibernate/orm/test/mapping/converted/converter/mutabiity/ConvertedMapImmutableTests.java new file mode 100644 index 0000000000..152487fe3a --- /dev/null +++ b/hibernate-core/src/test/java/org/hibernate/orm/test/mapping/converted/converter/mutabiity/ConvertedMapImmutableTests.java @@ -0,0 +1,165 @@ +/* + * 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.mapping.converted.converter.mutabiity; + +import java.util.Map; + +import org.hibernate.annotations.Immutable; +import org.hibernate.internal.util.collections.CollectionHelper; + +import org.hibernate.testing.jdbc.SQLStatementInspector; +import org.hibernate.testing.orm.junit.DomainModel; +import org.hibernate.testing.orm.junit.FailureExpected; +import org.hibernate.testing.orm.junit.JiraKey; +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.Column; +import jakarta.persistence.Convert; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Steve Ebersole + */ +@DomainModel( annotatedClasses = ConvertedMapImmutableTests.TestEntity.class ) +@SessionFactory( useCollectingStatementInspector = true ) +public class ConvertedMapImmutableTests { + + @Test + @JiraKey( "HHH-16081" ) + void testManagedUpdate(SessionFactoryScope scope) { + final SQLStatementInspector statementInspector = scope.getCollectingStatementInspector(); + + scope.inTransaction( (session) -> { + final TestEntity loaded = session.get( TestEntity.class, 1 ); + loaded.values.put( "ghi", "789" ); + statementInspector.clear(); + } ); + + final TestEntity after = scope.fromTransaction( (session) -> session.get( TestEntity.class, 1 ) ); + assertThat( after.values ).hasSize( 2 ); + } + + @Test + @JiraKey( "HHH-16081" ) + @FailureExpected( reason = "Fails due to HHH-16132 - Hibernate believes the attribute is dirty, even though it is immutable." ) + void testMerge(SessionFactoryScope scope) { + final SQLStatementInspector statementInspector = scope.getCollectingStatementInspector(); + + final TestEntity loaded = scope.fromTransaction( (session) -> session.get( TestEntity.class, 1 ) ); + assertThat( loaded.values ).hasSize( 2 ); + + loaded.values.put( "ghi", "789" ); + statementInspector.clear(); + scope.inTransaction( (session) -> session.merge( loaded ) ); + + final TestEntity merged = scope.fromTransaction( (session) -> session.get( TestEntity.class, 1 ) ); + assertThat( merged.values ).hasSize( 2 ); + } + + @Test + @JiraKey( "HHH-16132" ) + @FailureExpected( reason = "Fails due to HHH-16132 - Hibernate believes the attribute is dirty, even though it is immutable." ) + void testDirtyChecking(SessionFactoryScope scope) { + final SQLStatementInspector statementInspector = scope.getCollectingStatementInspector(); + + // make changes to a managed entity - should not trigger update since it is immutable + scope.inTransaction( (session) -> { + final TestEntity managed = session.get( TestEntity.class, 1 ); + statementInspector.clear(); + assertThat( managed.values ).hasSize( 2 ); + // make the change + managed.values.put( "ghi", "789" ); + } ); + assertThat( statementInspector.getSqlQueries() ).isEmpty(); + + // make no changes to a detached entity and merge it - should not trigger update + final TestEntity loaded = scope.fromTransaction( (session) -> session.get( TestEntity.class, 1 ) ); + assertThat( loaded.values ).hasSize( 2 ); + // make the change + loaded.values.put( "ghi", "789" ); + statementInspector.clear(); + scope.inTransaction( (session) -> session.merge( loaded ) ); + // the SELECT + assertThat( statementInspector.getSqlQueries() ).hasSize( 1 ); + } + + @Test + @JiraKey( "HHH-16132" ) + void testNotDirtyChecking(SessionFactoryScope scope) { + final SQLStatementInspector statementInspector = scope.getCollectingStatementInspector(); + + // make changes to a managed entity - should not trigger update + scope.inTransaction( (session) -> { + final TestEntity managed = session.get( TestEntity.class, 1 ); + statementInspector.clear(); + assertThat( managed.values ).hasSize( 2 ); + } ); + assertThat( statementInspector.getSqlQueries() ).isEmpty(); + + // make no changes to a detached entity and merge it - should not trigger update + final TestEntity loaded = scope.fromTransaction( (session) -> session.get( TestEntity.class, 1 ) ); + assertThat( loaded.values ).hasSize( 2 ); + statementInspector.clear(); + scope.inTransaction( (session) -> session.merge( loaded ) ); + // the SELECT + assertThat( statementInspector.getSqlQueries() ).hasSize( 1 ); + } + + @BeforeEach + void createTestData(SessionFactoryScope scope) { + scope.inTransaction( (session) -> { + session.persist( new TestEntity( + 1, + CollectionHelper.toMap( + "abc", "123", + "def", "456" + ) + ) ); + } ); + } + + @AfterEach + void dropTestData(SessionFactoryScope scope) { + scope.inTransaction( (session) -> { + session.createMutationQuery( "delete TestEntity" ).executeUpdate(); + } ); + } + + @Immutable + public static class ImmutableMapConverter extends ConvertedMapMutableTests.MapConverter { + } + + @Entity( name = "TestEntity" ) + @Table( name = "entity_immutable_map" ) + public static class TestEntity { + @Id + private Integer id; + + @Convert( converter = ImmutableMapConverter.class ) + @Column( name="vals" ) + private Map values; + + private TestEntity() { + // for use by Hibernate + } + + public TestEntity( + Integer id, + Map values) { + this.id = id; + this.values = values; + } + } +} diff --git a/hibernate-core/src/test/java/org/hibernate/orm/test/mapping/converted/converter/mutabiity/ConvertedMapMutableTests.java b/hibernate-core/src/test/java/org/hibernate/orm/test/mapping/converted/converter/mutabiity/ConvertedMapMutableTests.java new file mode 100644 index 0000000000..22cc1f6e93 --- /dev/null +++ b/hibernate-core/src/test/java/org/hibernate/orm/test/mapping/converted/converter/mutabiity/ConvertedMapMutableTests.java @@ -0,0 +1,179 @@ +/* + * 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.mapping.converted.converter.mutabiity; + +import java.util.Map; + +import org.hibernate.internal.util.StringHelper; +import org.hibernate.internal.util.collections.CollectionHelper; + +import org.hibernate.testing.jdbc.SQLStatementInspector; +import org.hibernate.testing.orm.junit.DomainModel; +import org.hibernate.testing.orm.junit.JiraKey; +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.AttributeConverter; +import jakarta.persistence.Column; +import jakarta.persistence.Convert; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Steve Ebersole + */ +@JiraKey( "HHH-16081" ) +@DomainModel( annotatedClasses = ConvertedMapMutableTests.TestEntity.class ) +@SessionFactory( useCollectingStatementInspector = true ) +public class ConvertedMapMutableTests { + + @Test + void testMutableMap(SessionFactoryScope scope) { + final SQLStatementInspector statementInspector = scope.getCollectingStatementInspector(); + + scope.inTransaction( (session) -> { + final TestEntity loaded = session.get( TestEntity.class, 1 ); + assertThat( loaded.values ).hasSize( 2 ); + loaded.values.put( "ghi", "789" ); + statementInspector.clear(); + } ); + assertThat( statementInspector.getSqlQueries() ).isNotEmpty(); + + scope.inTransaction( (session) -> { + final TestEntity loaded = session.get( TestEntity.class, 1 ); + assertThat( loaded.values ).hasSize( 3 ); + statementInspector.clear(); + } ); + assertThat( statementInspector.getSqlQueries() ).isEmpty(); + } + + @Test + void testMutableMapWithMerge(SessionFactoryScope scope) { + final TestEntity loaded = scope.fromTransaction( (session) -> session.get( TestEntity.class, 1 ) ); + assertThat( loaded.values ).hasSize( 2 ); + + loaded.values.put( "ghi", "789" ); + scope.inTransaction( (session) -> session.merge( loaded ) ); + + final TestEntity changed = scope.fromTransaction( (session) -> session.get( TestEntity.class, 1 ) ); + assertThat( changed.values ).hasSize( 3 ); + } + + @Test + @JiraKey( "HHH-16132" ) + void testDirtyChecking(SessionFactoryScope scope) { + final SQLStatementInspector statementInspector = scope.getCollectingStatementInspector(); + + // make changes to a managed entity - should trigger update + scope.inTransaction( (session) -> { + final TestEntity managed = session.get( TestEntity.class, 1 ); + statementInspector.clear(); + assertThat( managed.values ).hasSize( 2 ); + // make the change + managed.values.put( "ghi", "789" ); + } ); + assertThat( statementInspector.getSqlQueries() ).hasSize( 1 ); + + // make changes to a detached entity and merge it - should trigger update + final TestEntity loaded = scope.fromTransaction( (session) -> session.get( TestEntity.class, 1 ) ); + assertThat( loaded.values ).hasSize( 3 ); + // make the change + loaded.values.put( "jkl", "007" ); + statementInspector.clear(); + scope.inTransaction( (session) -> session.merge( loaded ) ); + // the SELECT + UPDATE + assertThat( statementInspector.getSqlQueries() ).hasSize( 2 ); + } + + @Test + @JiraKey( "HHH-16132" ) + void testNotDirtyChecking(SessionFactoryScope scope) { + final SQLStatementInspector statementInspector = scope.getCollectingStatementInspector(); + + // make no changes to a managed entity - should not trigger update + scope.inTransaction( (session) -> { + final TestEntity managed = session.get( TestEntity.class, 1 ); + statementInspector.clear(); + assertThat( managed.values ).hasSize( 2 ); + } ); + assertThat( statementInspector.getSqlQueries() ).isEmpty(); + + // make no changes to a detached entity and merge it - should not trigger update + final TestEntity loaded = scope.fromTransaction( (session) -> session.get( TestEntity.class, 1 ) ); + assertThat( loaded.values ).hasSize( 2 ); + statementInspector.clear(); + scope.inTransaction( (session) -> session.merge( loaded ) ); + // the SELECT + assertThat( statementInspector.getSqlQueries() ).hasSize( 1 ); + } + + @BeforeEach + void createTestData(SessionFactoryScope scope) { + scope.inTransaction( (session) -> { + session.persist( new TestEntity( + 1, + CollectionHelper.toMap( + "abc", "123", + "def", "456" + ) + ) ); + } ); + } + + @AfterEach + void dropTestData(SessionFactoryScope scope) { + scope.inTransaction( (session) -> { + session.createMutationQuery( "delete TestEntity" ).executeUpdate(); + } ); + } + + public static class MapConverter implements AttributeConverter,String> { + @Override + public String convertToDatabaseColumn(Map map) { + if ( CollectionHelper.isEmpty( map ) ) { + return null; + } + return StringHelper.join( ", ", CollectionHelper.asPairs( map ) ); + } + + @Override + public Map convertToEntityAttribute(String pairs) { + if ( StringHelper.isEmpty( pairs ) ) { + return null; + } + return CollectionHelper.toMap( StringHelper.split( ", ", pairs ) ); + } + } + + @Entity( name = "TestEntity" ) + @Table( name = "entity_mutable_map" ) + public static class TestEntity { + @Id + private Integer id; + + @Convert( converter = MapConverter.class ) + @Column( name = "vals" ) + private Map values; + + private TestEntity() { + // for use by Hibernate + } + + public TestEntity( + Integer id, + Map values) { + this.id = id; + this.values = values; + } + } +} diff --git a/hibernate-core/src/test/java/org/hibernate/orm/test/mapping/converted/converter/mutabiity/ConvertedMutabilityTests.java b/hibernate-core/src/test/java/org/hibernate/orm/test/mapping/converted/converter/mutabiity/ConvertedMutabilityTests.java new file mode 100644 index 0000000000..70b70706c3 --- /dev/null +++ b/hibernate-core/src/test/java/org/hibernate/orm/test/mapping/converted/converter/mutabiity/ConvertedMutabilityTests.java @@ -0,0 +1,188 @@ +/* + * 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.mapping.converted.converter.mutabiity; + +import java.time.Instant; +import java.time.format.DateTimeFormatter; +import java.util.Date; + +import org.hibernate.annotations.Immutable; +import org.hibernate.internal.util.StringHelper; + +import org.hibernate.testing.jdbc.SQLStatementInspector; +import org.hibernate.testing.orm.junit.DomainModel; +import org.hibernate.testing.orm.junit.JiraKey; +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.AttributeConverter; +import jakarta.persistence.Convert; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Steve Ebersole + */ +@JiraKey( "HHH-16081" ) +@DomainModel( annotatedClasses = ConvertedMutabilityTests.TestEntityWithDates.class ) +@SessionFactory( useCollectingStatementInspector = true ) +public class ConvertedMutabilityTests { + private static final Instant START = Instant.now(); + + @Test + void testImmutableDate(SessionFactoryScope scope) { + final SQLStatementInspector statementInspector = scope.getCollectingStatementInspector(); + + scope.inTransaction( (session) -> { + final TestEntityWithDates loaded = session.get( TestEntityWithDates.class, 1 ); + + statementInspector.clear(); + + // change `d2` - because it is immutable, this should not trigger an update + loaded.d2.setTime( Instant.EPOCH.toEpochMilli() ); + } ); + + assertThat( statementInspector.getSqlQueries() ).isEmpty(); + + scope.inTransaction( (session) -> { + final TestEntityWithDates loaded = session.get( TestEntityWithDates.class, 1 ); + assertThat( loaded.d1.getTime() ).isEqualTo( START.toEpochMilli() ); + } ); + } + + @Test + void testMutableDate(SessionFactoryScope scope) { + final SQLStatementInspector statementInspector = scope.getCollectingStatementInspector(); + + scope.inTransaction( (session) -> { + final TestEntityWithDates loaded = session.get( TestEntityWithDates.class, 1 ); + + statementInspector.clear(); + + // change `d1` - because it is mutable, this should trigger an update + loaded.d1.setTime( Instant.EPOCH.toEpochMilli() ); + } ); + + assertThat( statementInspector.getSqlQueries() ).isNotEmpty(); + + scope.inTransaction( (session) -> { + final TestEntityWithDates loaded = session.get( TestEntityWithDates.class, 1 ); + assertThat( loaded.d1.getTime() ).isEqualTo( Instant.EPOCH.toEpochMilli() ); + } ); + } + + @Test + void testDatesWithMerge(SessionFactoryScope scope) { + final SQLStatementInspector statementInspector = scope.getCollectingStatementInspector(); + final TestEntityWithDates loaded = scope.fromTransaction( (session) -> session.get( TestEntityWithDates.class, 1 ) ); + + loaded.d1.setTime( Instant.EPOCH.toEpochMilli() ); + + statementInspector.clear(); + scope.inTransaction( (session) -> session.merge( loaded ) ); + assertThat( statementInspector.getSqlQueries() ).isNotEmpty(); + + final TestEntityWithDates loaded2 = scope.fromTransaction( (session) -> session.get( TestEntityWithDates.class, 1 ) ); + assertThat( loaded2.d1.getTime() ).isEqualTo( Instant.EPOCH.toEpochMilli() ); + + loaded2.d2.setTime( Instant.EPOCH.toEpochMilli() ); + statementInspector.clear(); + scope.inTransaction( (session) -> session.merge( loaded ) ); + assertThat( statementInspector.getSqlQueries() ).isNotEmpty(); + + final TestEntityWithDates loaded3 = scope.fromTransaction( (session) -> session.get( TestEntityWithDates.class, 1 ) ); + assertThat( loaded3.d2.getTime() ).isEqualTo( START.toEpochMilli() ); + } + + @BeforeEach + void createTestData(SessionFactoryScope scope) { + scope.inTransaction( (session) -> { + session.persist( new TestEntityWithDates( + 1, + Date.from( START ), + Date.from( START ) + ) ); + } ); + } + + @AfterEach + void dropTestData(SessionFactoryScope scope) { + scope.inTransaction( (session) -> { + session.createMutationQuery( "delete TestEntityWithDates" ).executeUpdate(); + } ); + } + + public static class DateConverter implements AttributeConverter { + @Override + public String convertToDatabaseColumn(Date date) { + if ( date == null ) { + return null; + } + return DateTimeFormatter.ISO_INSTANT.format( date.toInstant() ); + } + + @Override + public Date convertToEntityAttribute(String date) { + if ( StringHelper.isEmpty( date ) ) { + return null; + } + return Date.from( Instant.from( DateTimeFormatter.ISO_INSTANT.parse( date ) ) ); + } + } + + @Immutable + public static class ImmutableDateConverter implements AttributeConverter { + @Override + public String convertToDatabaseColumn(Date date) { + if ( date == null ) { + return null; + } + return DateTimeFormatter.ISO_INSTANT.format( date.toInstant() ); + } + + @Override + public Date convertToEntityAttribute(String date) { + if ( StringHelper.isEmpty( date ) ) { + return null; + } + return Date.from( Instant.from( DateTimeFormatter.ISO_INSTANT.parse( date ) ) ); + } + } + + + @Entity( name = "TestEntityWithDates" ) + @Table( name = "entity_dates" ) + public static class TestEntityWithDates { + @Id + private Integer id; + + @Convert( converter = DateConverter.class ) + private Date d1; + @Convert( converter = ImmutableDateConverter.class ) + private Date d2; + + private TestEntityWithDates() { + // for use by Hibernate + } + + public TestEntityWithDates( + Integer id, + Date d1, + Date d2) { + this.id = id; + this.d1 = d1; + this.d2 = d2; + } + } + +}