From 96f1734544dfbf0b45cd7a7503c3cc237496d469 Mon Sep 17 00:00:00 2001 From: Gavin King Date: Thu, 12 Jun 2025 12:31:41 +0200 Subject: [PATCH 1/6] allow an EntityCopyObserverFactory in MERGE_ENTITY_COPY_OBSERVER setting --- .../EntityCopyNotAllowedObserver.java | 9 ++-- .../EntityCopyObserverFactoryInitiator.java | 44 +++++++++---------- .../event/spi/EntityCopyObserver.java | 4 +- .../event/spi/EntityCopyObserverFactory.java | 3 ++ 4 files changed, 29 insertions(+), 31 deletions(-) diff --git a/hibernate-core/src/main/java/org/hibernate/event/internal/EntityCopyNotAllowedObserver.java b/hibernate-core/src/main/java/org/hibernate/event/internal/EntityCopyNotAllowedObserver.java index c63278fec08b..bd6678e66ecf 100644 --- a/hibernate-core/src/main/java/org/hibernate/event/internal/EntityCopyNotAllowedObserver.java +++ b/hibernate-core/src/main/java/org/hibernate/event/internal/EntityCopyNotAllowedObserver.java @@ -45,12 +45,9 @@ public void entityCopyDetected( } private String getManagedOrDetachedEntityString(Object managedEntity, Object entity ) { - if ( entity == managedEntity) { - return "Managed: [" + entity + "]"; - } - else { - return "Detached: [" + entity + "]"; - } + return entity == managedEntity + ? "Managed: [" + entity + "]" + : "Detached: [" + entity + "]"; } public void clear() { diff --git a/hibernate-core/src/main/java/org/hibernate/event/internal/EntityCopyObserverFactoryInitiator.java b/hibernate-core/src/main/java/org/hibernate/event/internal/EntityCopyObserverFactoryInitiator.java index 06bc29912a11..3525918455c9 100644 --- a/hibernate-core/src/main/java/org/hibernate/event/internal/EntityCopyObserverFactoryInitiator.java +++ b/hibernate-core/src/main/java/org/hibernate/event/internal/EntityCopyObserverFactoryInitiator.java @@ -18,7 +18,7 @@ /** * Looks for the configuration property {@value AvailableSettings#MERGE_ENTITY_COPY_OBSERVER} and registers - * the matching {@link EntityCopyObserverFactory} based on the configuration value. + * the matching {@link EntityCopyObserverFactory} based on the configuration observerClass. *

* For known implementations some optimisations are possible, such as reusing a singleton for the stateless * implementations. When a user plugs in a custom {@link EntityCopyObserver} we take a defensive approach. @@ -32,7 +32,10 @@ public class EntityCopyObserverFactoryInitiator implements StandardServiceInitia @Override public EntityCopyObserverFactory initiateService(final Map configurationValues, final ServiceRegistryImplementor registry) { final Object value = getConfigurationValue( configurationValues ); - if ( value.equals( EntityCopyNotAllowedObserver.SHORT_NAME ) + if ( value instanceof EntityCopyObserverFactory factory ) { + return factory; + } + else if ( value.equals( EntityCopyNotAllowedObserver.SHORT_NAME ) || value.equals( EntityCopyNotAllowedObserver.class.getName() ) ) { LOG.debugf( "Configured EntityCopyObserver strategy: %s", EntityCopyNotAllowedObserver.SHORT_NAME ); return EntityCopyNotAllowedObserver.FACTORY_OF_SELF; @@ -48,15 +51,16 @@ else if ( value.equals( EntityCopyAllowedLoggedObserver.SHORT_NAME ) return EntityCopyAllowedLoggedObserver.FACTORY_OF_SELF; } else { - //We load an "example instance" just to get its Class; - //this might look excessive, but it also happens to test eagerly (at boot) that we can actually construct these - //and that they are indeed of the right type. + // We load an "example instance" just to get its Class; + // this might look excessive, but it also happens to test eagerly + // (at boot) that we can actually construct these and that they + // are indeed of the right type. final EntityCopyObserver exampleInstance = registry.requireService( StrategySelector.class ) .resolveStrategy( EntityCopyObserver.class, value ); - final Class observerType = exampleInstance.getClass(); - LOG.debugf( "Configured EntityCopyObserver is a custom implementation of type %s", observerType.getName() ); - return new EntityObserversFactoryFromClass( observerType ); + final Class observerType = exampleInstance.getClass(); + LOG.debugf( "Configured EntityCopyObserver is a custom implementation of type '%s'", observerType.getName() ); + return new EntityCopyObserverFactoryFromClass( observerType ); } } @@ -78,23 +82,17 @@ public Class getServiceInitiated() { return EntityCopyObserverFactory.class; } - private static class EntityObserversFactoryFromClass implements EntityCopyObserverFactory { - - private final Class value; - - public EntityObserversFactoryFromClass(Class value) { - this.value = value; - } + private record EntityCopyObserverFactoryFromClass(Class observerClass) + implements EntityCopyObserverFactory { @Override - public EntityCopyObserver createEntityCopyObserver() { - try { - return (EntityCopyObserver) value.newInstance(); - } - catch (Exception e) { - throw new HibernateException( "Could not instantiate class of type " + value.getName() ); + public EntityCopyObserver createEntityCopyObserver() { + try { + return observerClass.newInstance(); + } + catch (Exception e) { + throw new HibernateException( "Could not instantiate class of type " + observerClass.getName() ); + } } } - } - } diff --git a/hibernate-core/src/main/java/org/hibernate/event/spi/EntityCopyObserver.java b/hibernate-core/src/main/java/org/hibernate/event/spi/EntityCopyObserver.java index d19083c661d1..119af5afacdc 100644 --- a/hibernate-core/src/main/java/org/hibernate/event/spi/EntityCopyObserver.java +++ b/hibernate-core/src/main/java/org/hibernate/event/spi/EntityCopyObserver.java @@ -24,14 +24,14 @@ public interface EntityCopyObserver { void entityCopyDetected(Object managedEntity, Object mergeEntity1, Object mergeEntity2, EventSource session); /** - * Called when the top-level merge operation is complete. + * Called when the toplevel merge operation is complete. * * @param session The session */ void topLevelMergeComplete(EventSource session); /** - * Called to clear any data stored in this EntityCopyObserver. + * Called to clear any data stored in this {@code EntityCopyObserver}. */ void clear(); } diff --git a/hibernate-core/src/main/java/org/hibernate/event/spi/EntityCopyObserverFactory.java b/hibernate-core/src/main/java/org/hibernate/event/spi/EntityCopyObserverFactory.java index f61ee1c8fd52..cb9d238133cb 100644 --- a/hibernate-core/src/main/java/org/hibernate/event/spi/EntityCopyObserverFactory.java +++ b/hibernate-core/src/main/java/org/hibernate/event/spi/EntityCopyObserverFactory.java @@ -6,6 +6,9 @@ import org.hibernate.service.Service; +/** + * A {@linkplain Service service} which creates new instances of {@link EntityCopyObserver}. + */ @FunctionalInterface public interface EntityCopyObserverFactory extends Service { EntityCopyObserver createEntityCopyObserver(); From bf422d6238f65df7c8118136c0710261e35ad513 Mon Sep 17 00:00:00 2001 From: Gavin King Date: Thu, 12 Jun 2025 12:32:25 +0200 Subject: [PATCH 2/6] misc very minor changes --- .../selector/internal/StrategySelectorImpl.java | 11 ++++------- .../event/internal/DefaultMergeEventListener.java | 3 +-- .../java/org/hibernate/event/spi/MergeContext.java | 2 +- .../main/java/org/hibernate/proxy/HibernateProxy.java | 3 +++ .../src/main/java/org/hibernate/type/TypeHelper.java | 4 ++-- 5 files changed, 11 insertions(+), 12 deletions(-) diff --git a/hibernate-core/src/main/java/org/hibernate/boot/registry/selector/internal/StrategySelectorImpl.java b/hibernate-core/src/main/java/org/hibernate/boot/registry/selector/internal/StrategySelectorImpl.java index 6a91d8c0c227..3a8183d52013 100644 --- a/hibernate-core/src/main/java/org/hibernate/boot/registry/selector/internal/StrategySelectorImpl.java +++ b/hibernate-core/src/main/java/org/hibernate/boot/registry/selector/internal/StrategySelectorImpl.java @@ -157,13 +157,10 @@ public T resolveStrategy( return strategy.cast( strategyReference ); } - final Class implementationClass; - if ( strategyReference instanceof Class ) { - implementationClass = (Class) strategyReference; - } - else { - implementationClass = selectStrategyImplementor( strategy, strategyReference.toString() ); - } + final Class implementationClass = + strategyReference instanceof Class + ? (Class) strategyReference + : selectStrategyImplementor( strategy, strategyReference.toString() ); try { return creator.create( implementationClass ); diff --git a/hibernate-core/src/main/java/org/hibernate/event/internal/DefaultMergeEventListener.java b/hibernate-core/src/main/java/org/hibernate/event/internal/DefaultMergeEventListener.java index 170479eb7641..f612346c72c8 100644 --- a/hibernate-core/src/main/java/org/hibernate/event/internal/DefaultMergeEventListener.java +++ b/hibernate-core/src/main/java/org/hibernate/event/internal/DefaultMergeEventListener.java @@ -220,7 +220,7 @@ private void merge(MergeEvent event, MergeContext copiedAlready, Object entity) ) ); source.getActionQueue().unScheduleUnloadedDeletion( entity ); - entityIsDetached(event, copiedId, originalId, copiedAlready); + entityIsDetached( event, copiedId, originalId, copiedAlready ); break; } throw new ObjectDeletedException( "deleted instance passed to merge", @@ -299,7 +299,6 @@ protected void entityIsTransient(MergeEvent event, Object id, MergeContext copyC // copy created before we actually copy super.cascadeAfterSave( session, persister, entity, copyCache ); - copyValues( persister, entity, copy, session, copyCache, ForeignKeyDirection.TO_PARENT ); // saveTransientEntity has been called using a copy that contains empty collections diff --git a/hibernate-core/src/main/java/org/hibernate/event/spi/MergeContext.java b/hibernate-core/src/main/java/org/hibernate/event/spi/MergeContext.java index 09a67d12d8b5..85b4bd0f00b2 100644 --- a/hibernate-core/src/main/java/org/hibernate/event/spi/MergeContext.java +++ b/hibernate-core/src/main/java/org/hibernate/event/spi/MergeContext.java @@ -275,7 +275,7 @@ public Object put(Object mergeEntity, Object managedEntity, boolean isOperatedOn * @throws IllegalStateException if internal cross-references are out of sync, */ public void putAll(Map map) { - for ( Entry entry : map.entrySet() ) { + for ( var entry : map.entrySet() ) { put( entry.getKey(), entry.getValue() ); } } diff --git a/hibernate-core/src/main/java/org/hibernate/proxy/HibernateProxy.java b/hibernate-core/src/main/java/org/hibernate/proxy/HibernateProxy.java index dc9301314cb7..d122bd569a89 100644 --- a/hibernate-core/src/main/java/org/hibernate/proxy/HibernateProxy.java +++ b/hibernate-core/src/main/java/org/hibernate/proxy/HibernateProxy.java @@ -3,6 +3,8 @@ * Copyright Red Hat Inc. and Hibernate Authors */ package org.hibernate.proxy; + +import java.io.Serial; import java.io.Serializable; import org.hibernate.Internal; @@ -42,6 +44,7 @@ public interface HibernateProxy extends Serializable, PrimeAmongSecondarySuperty * * @return The serializable proxy replacement. */ + @Serial Object writeReplace(); /** diff --git a/hibernate-core/src/main/java/org/hibernate/type/TypeHelper.java b/hibernate-core/src/main/java/org/hibernate/type/TypeHelper.java index 14bfb7fbdd6b..b1f4f07fd8f3 100644 --- a/hibernate-core/src/main/java/org/hibernate/type/TypeHelper.java +++ b/hibernate-core/src/main/java/org/hibernate/type/TypeHelper.java @@ -72,7 +72,7 @@ public static Object[] replace( final SharedSessionContractImplementor session, final Object owner, final Map copyCache) { - Object[] copied = new Object[original.length]; + final Object[] copied = new Object[original.length]; for ( int i = 0; i < types.length; i++ ) { if ( original[i] == LazyPropertyInitializer.UNFETCHED_PROPERTY || original[i] == PropertyAccessStrategyBackRefImpl.UNKNOWN ) { @@ -138,7 +138,7 @@ public static Object[] replace( final Object owner, final Map copyCache, final ForeignKeyDirection foreignKeyDirection) { - Object[] copied = new Object[original.length]; + final Object[] copied = new Object[original.length]; for ( int i = 0; i < types.length; i++ ) { if ( original[i] == LazyPropertyInitializer.UNFETCHED_PROPERTY || original[i] == PropertyAccessStrategyBackRefImpl.UNKNOWN ) { From e4a7eb7b97bc536b181c16c915bc170b26a39c0d Mon Sep 17 00:00:00 2001 From: Gavin King Date: Thu, 12 Jun 2025 13:34:12 +0200 Subject: [PATCH 3/6] HHH-19535 introduce preMerge() and postMerge() methods of Interceptor --- .../main/java/org/hibernate/Interceptor.java | 44 +++++- .../internal/DefaultMergeEventListener.java | 127 ++++++++---------- 2 files changed, 97 insertions(+), 74 deletions(-) diff --git a/hibernate-core/src/main/java/org/hibernate/Interceptor.java b/hibernate-core/src/main/java/org/hibernate/Interceptor.java index 7f266d9d7053..2cc6af0f927d 100644 --- a/hibernate-core/src/main/java/org/hibernate/Interceptor.java +++ b/hibernate-core/src/main/java/org/hibernate/Interceptor.java @@ -378,7 +378,7 @@ default void onInsert(Object entity, Object id, Object[] state, String[] propert * @param entity The entity instance being deleted * @param id The identifier of the entity * @param state The entity state - * @param propertyNames The names of the entity properties. + * @param propertyNames The names of the entity properties * @param propertyTypes The types of the entity properties * * @see StatelessSession#update(Object) @@ -391,7 +391,7 @@ default void onUpdate(Object entity, Object id, Object[] state, String[] propert * @param entity The entity instance being deleted * @param id The identifier of the entity * @param state The entity state - * @param propertyNames The names of the entity properties. + * @param propertyNames The names of the entity properties * @param propertyTypes The types of the entity properties * * @see StatelessSession#upsert(String, Object) @@ -403,10 +403,48 @@ default void onUpsert(Object entity, Object id, Object[] state, String[] propert * * @param entity The entity instance being deleted * @param id The identifier of the entity - * @param propertyNames The names of the entity properties. + * @param propertyNames The names of the entity properties * @param propertyTypes The types of the entity properties * * @see StatelessSession#delete(Object) */ default void onDelete(Object entity, Object id, String[] propertyNames, Type[] propertyTypes) {} + + /** + * Called before copying the state of a merged entity to a managed entity + * belonging to the persistence context of a stateful {@link Session}. + *

+ * The interceptor may modify the {@code state}. + * + * @param entity The entity passed to {@code merge()} + * @param state The state of the entity passed to {@code merge()} + * @param propertyNames The names of the entity properties + * @param propertyTypes The types of the entity properties + * + * @since 7.1 + */ + @Incubating + default void preMerge(Object entity, Object[] state, String[] propertyNames, Type[] propertyTypes) {} + + /** + * Called after copying the state of a merged entity to a managed entity + * belonging to the persistence context of a stateful {@link Session}. + *

+ * Modification of the {@code sourceState} or {@code targetState} has no effect. + * + * @param source The entity passed to {@code merge()} + * @param target The target managed entity + * @param targetState The copied state already assigned to the target managed entity + * @param originalState The original state of the target managed entity before assignment of the copied state, + * or {@code null} if the target entity is a new instance + * @param propertyNames The names of the entity properties + * @param propertyTypes The types of the entity properties + * + * @since 7.1 + */ + @Incubating + default void postMerge( + Object source, Object target, + Object[] targetState, Object[] originalState, + String[] propertyNames, Type[] propertyTypes) {} } diff --git a/hibernate-core/src/main/java/org/hibernate/event/internal/DefaultMergeEventListener.java b/hibernate-core/src/main/java/org/hibernate/event/internal/DefaultMergeEventListener.java index f612346c72c8..37b37b2ed6ce 100644 --- a/hibernate-core/src/main/java/org/hibernate/event/internal/DefaultMergeEventListener.java +++ b/hibernate-core/src/main/java/org/hibernate/event/internal/DefaultMergeEventListener.java @@ -25,7 +25,6 @@ import org.hibernate.engine.spi.PersistentAttributeInterceptor; import org.hibernate.engine.spi.SelfDirtinessTracker; import org.hibernate.engine.spi.SessionFactoryImplementor; -import org.hibernate.engine.spi.SessionImplementor; import org.hibernate.event.spi.EntityCopyObserver; import org.hibernate.event.spi.EventSource; import org.hibernate.event.spi.MergeContext; @@ -274,7 +273,7 @@ protected void entityIsPersistent(MergeEvent event, MergeContext copyCache) { final EntityPersister persister = source.getEntityPersister( event.getEntityName(), entity ); copyCache.put( entity, entity, true ); //before cascade! cascadeOnMerge( source, persister, entity, copyCache ); - copyValues( persister, entity, entity, source, copyCache ); + TypeHelper.replace( persister, entity, source, entity, copyCache ); event.setResult( entity ); } @@ -285,13 +284,27 @@ protected void entityIsTransient(MergeEvent event, Object id, MergeContext copyC final EventSource session = event.getSession(); final String entityName = event.getEntityName(); final EntityPersister persister = session.getEntityPersister( entityName, entity ); + final String[] propertyNames = persister.getPropertyNames(); + final Type[] propertyTypes = persister.getPropertyTypes(); final Object copy = copyEntity( copyCache, entity, session, persister, id ); // cascade first, so that all unsaved objects get their // copy created before we actually copy //cascadeOnMerge(event, persister, entity, copyCache, Cascades.CASCADE_BEFORE_MERGE); super.cascadeBeforeSave( session, persister, entity, copyCache ); - copyValues( persister, entity, copy, session, copyCache, ForeignKeyDirection.FROM_PARENT ); + + final Object[] sourceValues = persister.getValues( entity ); + session.getInterceptor().preMerge( entity, sourceValues, propertyNames, propertyTypes ); + final Object[] copiedValues = TypeHelper.replace( + sourceValues, + persister.getValues( copy ), + propertyTypes, + session, + copy, + copyCache, + ForeignKeyDirection.FROM_PARENT + ); + persister.setValues( copy, copiedValues ); saveTransientEntity( copy, entityName, event.getRequestedId(), session, copyCache ); @@ -299,10 +312,24 @@ protected void entityIsTransient(MergeEvent event, Object id, MergeContext copyC // copy created before we actually copy super.cascadeAfterSave( session, persister, entity, copyCache ); - copyValues( persister, entity, copy, session, copyCache, ForeignKeyDirection.TO_PARENT ); + // this is the second pass through on a merge op, so here we limit the + // replacement to association types (value types were already replaced + // during the first pass) +// final Object[] newSourceValues = persister.getValues( entity ); + final Object[] targetValues = TypeHelper.replaceAssociations( + sourceValues, // newSourceValues, + persister.getValues( copy ), + propertyTypes, + session, + copy, + copyCache, + ForeignKeyDirection.TO_PARENT + ); + persister.setValues( copy, targetValues ); + session.getInterceptor().postMerge( entity, copy, targetValues, null, propertyNames, propertyTypes ); // saveTransientEntity has been called using a copy that contains empty collections - // (copyValues uses `ForeignKeyDirection.FROM_PARENT`) then the PC may contain a wrong + // (copyValues uses ForeignKeyDirection.FROM_PARENT) then the PC may contain a wrong // collection snapshot, the CollectionVisitor realigns the collection snapshot values // with the final copy new CollectionVisitor( copy, id, session ) @@ -382,11 +409,11 @@ protected void entityIsDetached(MergeEvent event, Object copiedId, Object origin LOG.trace( "Merging detached instance" ); final Object entity = event.getEntity(); - final EventSource source = event.getSession(); - final EntityPersister persister = source.getEntityPersister( event.getEntityName(), entity ); + final EventSource session = event.getSession(); + final EntityPersister persister = session.getEntityPersister( event.getEntityName(), entity ); final String entityName = persister.getEntityName(); if ( originalId == null ) { - originalId = persister.getIdentifier( entity, source ); + originalId = persister.getIdentifier( entity, session ); } final Object clonedIdentifier = copiedId == null ? persister.getIdentifierType().deepCopy( originalId, event.getFactory() ) @@ -394,9 +421,9 @@ protected void entityIsDetached(MergeEvent event, Object copiedId, Object origin final Object id = getDetachedEntityId( event, originalId, persister ); // we must clone embedded composite identifiers, or we will get back the same instance that we pass in // apply the special MERGE fetch profile and perform the resolution (Session#get) - final Object result = source.getLoadQueryInfluencers().fromInternalFetchProfile( + final Object result = session.getLoadQueryInfluencers().fromInternalFetchProfile( CascadingFetchProfile.MERGE, - () -> source.get( entityName, clonedIdentifier ) + () -> session.get( entityName, clonedIdentifier ) ); if ( result == null ) { @@ -404,7 +431,7 @@ protected void entityIsDetached(MergeEvent event, Object copiedId, Object origin // we got here because we assumed that an instance // with an assigned id and no version was detached, // when it was really transient (or deleted) - final Boolean knownTransient = persister.isTransient( entity, source ); + final Boolean knownTransient = persister.isTransient( entity, session ); if ( knownTransient == Boolean.FALSE ) { // we know for sure it's detached (generated id // or a version property), and so the instance @@ -424,8 +451,24 @@ protected void entityIsDetached(MergeEvent event, Object copiedId, Object origin final Object target = targetEntity( event, entity, persister, id, result ); // cascade first, so that all unsaved objects get their // copy created before we actually copy - cascadeOnMerge( source, persister, entity, copyCache ); - copyValues( persister, entity, target, source, copyCache ); + cascadeOnMerge( session, persister, entity, copyCache ); + + final String[] propertyNames = persister.getPropertyNames(); + final Type[] propertyTypes = persister.getPropertyTypes(); + + final Object[] sourceValues = persister.getValues( entity ); + final Object[] originalValues = persister.getValues( target ); + session.getInterceptor().preMerge( entity, sourceValues, propertyNames, propertyTypes ); + final Object[] targetValues = TypeHelper.replace( + sourceValues, + originalValues, + propertyTypes, + session, + target, + copyCache + ); + persister.setValues( target, targetValues ); + session.getInterceptor().postMerge( entity, target, targetValues, originalValues, propertyNames, propertyTypes ); //copyValues works by reflection, so explicitly mark the entity instance dirty markInterceptorDirty( entity, target ); event.setResult( result ); @@ -570,64 +613,6 @@ private static boolean existsInDatabase(Object entity, EventSource source, Entit return entry != null && entry.isExistsInDatabase(); } - protected void copyValues( - final EntityPersister persister, - final Object entity, - final Object target, - final SessionImplementor source, - final MergeContext copyCache) { - if ( entity == target ) { - TypeHelper.replace( persister, entity, source, entity, copyCache ); - } - else { - final Object[] copiedValues = TypeHelper.replace( - persister.getValues( entity ), - persister.getValues( target ), - persister.getPropertyTypes(), - source, - target, - copyCache - ); - persister.setValues( target, copiedValues ); - } - } - - protected void copyValues( - final EntityPersister persister, - final Object entity, - final Object target, - final SessionImplementor source, - final MergeContext copyCache, - final ForeignKeyDirection foreignKeyDirection) { - final Object[] copiedValues; - if ( foreignKeyDirection == ForeignKeyDirection.TO_PARENT ) { - // this is the second pass through on a merge op, so here we limit the - // replacement to associations types (value types were already replaced - // during the first pass) - copiedValues = TypeHelper.replaceAssociations( - persister.getValues( entity ), - persister.getValues( target ), - persister.getPropertyTypes(), - source, - target, - copyCache, - foreignKeyDirection - ); - } - else { - copiedValues = TypeHelper.replace( - persister.getValues( entity ), - persister.getValues( target ), - persister.getPropertyTypes(), - source, - target, - copyCache, - foreignKeyDirection - ); - } - persister.setValues( target, copiedValues ); - } - /** * Perform any cascades needed as part of this copy event. * From 605c0c10c3e26add12c26add8c3cc852156c0612 Mon Sep 17 00:00:00 2001 From: Gavin King Date: Fri, 13 Jun 2025 14:46:36 +0200 Subject: [PATCH 4/6] HHH-19535 test to demo relevant capabilities of new preMerge() interceptor --- .../merge/MergeInterceptionTest.java | 62 +++++++++++++++++++ .../interceptor/merge/MergeInterceptor.java | 38 ++++++++++++ 2 files changed, 100 insertions(+) create mode 100644 hibernate-core/src/test/java/org/hibernate/orm/test/interceptor/merge/MergeInterceptionTest.java create mode 100644 hibernate-core/src/test/java/org/hibernate/orm/test/interceptor/merge/MergeInterceptor.java diff --git a/hibernate-core/src/test/java/org/hibernate/orm/test/interceptor/merge/MergeInterceptionTest.java b/hibernate-core/src/test/java/org/hibernate/orm/test/interceptor/merge/MergeInterceptionTest.java new file mode 100644 index 000000000000..181fffa59a9b --- /dev/null +++ b/hibernate-core/src/test/java/org/hibernate/orm/test/interceptor/merge/MergeInterceptionTest.java @@ -0,0 +1,62 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright Red Hat Inc. and Hibernate Authors + */ +package org.hibernate.orm.test.interceptor.merge; + +import jakarta.persistence.ElementCollection; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import org.hibernate.Hibernate; +import org.hibernate.cfg.SessionEventSettings; +import org.hibernate.testing.orm.junit.EntityManagerFactoryScope; +import org.hibernate.testing.orm.junit.Jpa; +import org.hibernate.testing.orm.junit.Setting; +import org.junit.jupiter.api.Test; + +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@Jpa(annotatedClasses = MergeInterceptionTest.Thing.class, + integrationSettings = @Setting(name = SessionEventSettings.INTERCEPTOR, + value = "org.hibernate.orm.test.interceptor.merge.MergeInterceptor")) +class MergeInterceptionTest { + @Test + void test(EntityManagerFactoryScope scope) { + Thing t = scope.fromTransaction( em -> { + Thing thing = new Thing(); + thing.name = "Hibernate"; + assertNull( thing.names ); + em.persist( thing ); + assertNotNull( thing.names ); + assertTrue( Hibernate.isInitialized( thing.names ) ); + assertEquals( 0, thing.names.size() ); + thing.names.add( "CDI" ); + thing.names.add( "Ceylon" ); + return thing; + } ); + scope.inTransaction( em -> { + t.name = "Hibernate ORM"; + t.names = null; + Thing thing = em.merge( t ); + assertNull( t.names ); + assertNotNull( thing.names ); + assertFalse( Hibernate.isInitialized( thing.names ) ); + assertEquals( 2, thing.names.size() ); + } ); + } + @Entity + static class Thing { + @Id @GeneratedValue + private Long id; + private String name; + @ElementCollection + Set names; + } +} diff --git a/hibernate-core/src/test/java/org/hibernate/orm/test/interceptor/merge/MergeInterceptor.java b/hibernate-core/src/test/java/org/hibernate/orm/test/interceptor/merge/MergeInterceptor.java new file mode 100644 index 000000000000..b661318a5881 --- /dev/null +++ b/hibernate-core/src/test/java/org/hibernate/orm/test/interceptor/merge/MergeInterceptor.java @@ -0,0 +1,38 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright Red Hat Inc. and Hibernate Authors + */ +package org.hibernate.orm.test.interceptor.merge; + +import org.hibernate.Hibernate; +import org.hibernate.Interceptor; +import org.hibernate.type.Type; + +/** + * An interceptor that initializes null collection references + * when an entity is passed to persist() or merge(). + * + * @author Gavin King + */ +public class MergeInterceptor implements Interceptor { + @Override + public boolean onPersist(Object entity, Object id, Object[] state, String[] propertyNames, Type[] types) { + boolean result = false; + for ( int i = 0; i < types.length; i++ ) { + if ( types[i].isCollectionType() && state[i] == null ) { + state[i] = Hibernate.set().createNewInstance(); + result = true; + } + } + return result; + } + + @Override + public void preMerge(Object entity, Object[] state, String[] propertyNames, Type[] types) { + for ( int i = 0; i < types.length; i++ ) { + if ( types[i].isCollectionType() && state[i] == null ) { + state[i] = Hibernate.set().createDetachedInstance(); + } + } + } +} From e2b44b71dce3b2ff851932cd7e3dcdbfa7811feb Mon Sep 17 00:00:00 2001 From: Gavin King Date: Fri, 13 Jun 2025 19:02:25 +0200 Subject: [PATCH 5/6] HHH-19535 Interceptor.postMerge() should accept an id --- .../src/main/java/org/hibernate/Interceptor.java | 5 +++-- .../event/internal/DefaultMergeEventListener.java | 15 +++++++++------ 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/hibernate-core/src/main/java/org/hibernate/Interceptor.java b/hibernate-core/src/main/java/org/hibernate/Interceptor.java index 2cc6af0f927d..098f0b830e38 100644 --- a/hibernate-core/src/main/java/org/hibernate/Interceptor.java +++ b/hibernate-core/src/main/java/org/hibernate/Interceptor.java @@ -434,9 +434,10 @@ default void preMerge(Object entity, Object[] state, String[] propertyNames, Typ * * @param source The entity passed to {@code merge()} * @param target The target managed entity + * @param id The identifier of the managed entity * @param targetState The copied state already assigned to the target managed entity * @param originalState The original state of the target managed entity before assignment of the copied state, - * or {@code null} if the target entity is a new instance + * or {@code null} if the target entity is a new instance * @param propertyNames The names of the entity properties * @param propertyTypes The types of the entity properties * @@ -444,7 +445,7 @@ default void preMerge(Object entity, Object[] state, String[] propertyNames, Typ */ @Incubating default void postMerge( - Object source, Object target, + Object source, Object target, Object id, Object[] targetState, Object[] originalState, String[] propertyNames, Type[] propertyTypes) {} } diff --git a/hibernate-core/src/main/java/org/hibernate/event/internal/DefaultMergeEventListener.java b/hibernate-core/src/main/java/org/hibernate/event/internal/DefaultMergeEventListener.java index 37b37b2ed6ce..c4e8d2170bb7 100644 --- a/hibernate-core/src/main/java/org/hibernate/event/internal/DefaultMergeEventListener.java +++ b/hibernate-core/src/main/java/org/hibernate/event/internal/DefaultMergeEventListener.java @@ -8,6 +8,7 @@ import org.hibernate.AssertionFailure; import org.hibernate.HibernateException; +import org.hibernate.Interceptor; import org.hibernate.ObjectDeletedException; import org.hibernate.StaleObjectStateException; import org.hibernate.WrongClassException; @@ -282,6 +283,7 @@ protected void entityIsTransient(MergeEvent event, Object id, MergeContext copyC final Object entity = event.getEntity(); final EventSource session = event.getSession(); + final Interceptor interceptor = session.getInterceptor(); final String entityName = event.getEntityName(); final EntityPersister persister = session.getEntityPersister( entityName, entity ); final String[] propertyNames = persister.getPropertyNames(); @@ -294,7 +296,7 @@ protected void entityIsTransient(MergeEvent event, Object id, MergeContext copyC super.cascadeBeforeSave( session, persister, entity, copyCache ); final Object[] sourceValues = persister.getValues( entity ); - session.getInterceptor().preMerge( entity, sourceValues, propertyNames, propertyTypes ); + interceptor.preMerge( entity, sourceValues, propertyNames, propertyTypes ); final Object[] copiedValues = TypeHelper.replace( sourceValues, persister.getValues( copy ), @@ -326,7 +328,7 @@ protected void entityIsTransient(MergeEvent event, Object id, MergeContext copyC ForeignKeyDirection.TO_PARENT ); persister.setValues( copy, targetValues ); - session.getInterceptor().postMerge( entity, copy, targetValues, null, propertyNames, propertyTypes ); + interceptor.postMerge( entity, copy, id, targetValues, null, propertyNames, propertyTypes ); // saveTransientEntity has been called using a copy that contains empty collections // (copyValues uses ForeignKeyDirection.FROM_PARENT) then the PC may contain a wrong @@ -341,9 +343,9 @@ protected void entityIsTransient(MergeEvent event, Object id, MergeContext copyC event.setResult( copy ); if ( isPersistentAttributeInterceptable( copy ) ) { - final PersistentAttributeInterceptor interceptor = + final PersistentAttributeInterceptor attributeInterceptor = asPersistentAttributeInterceptable( copy ).$$_hibernate_getInterceptor(); - if ( interceptor == null ) { + if ( attributeInterceptor == null ) { persister.getBytecodeEnhancementMetadata().injectInterceptor( copy, id, session ); } } @@ -453,12 +455,13 @@ protected void entityIsDetached(MergeEvent event, Object copiedId, Object origin // copy created before we actually copy cascadeOnMerge( session, persister, entity, copyCache ); + final Interceptor interceptor = session.getInterceptor(); final String[] propertyNames = persister.getPropertyNames(); final Type[] propertyTypes = persister.getPropertyTypes(); final Object[] sourceValues = persister.getValues( entity ); final Object[] originalValues = persister.getValues( target ); - session.getInterceptor().preMerge( entity, sourceValues, propertyNames, propertyTypes ); + interceptor.preMerge( entity, sourceValues, propertyNames, propertyTypes ); final Object[] targetValues = TypeHelper.replace( sourceValues, originalValues, @@ -468,7 +471,7 @@ protected void entityIsDetached(MergeEvent event, Object copiedId, Object origin copyCache ); persister.setValues( target, targetValues ); - session.getInterceptor().postMerge( entity, target, targetValues, originalValues, propertyNames, propertyTypes ); + interceptor.postMerge( entity, target, id, targetValues, originalValues, propertyNames, propertyTypes ); //copyValues works by reflection, so explicitly mark the entity instance dirty markInterceptorDirty( entity, target ); event.setResult( result ); From 6cdd21642130a52ba1e691376e6c134944db4cff Mon Sep 17 00:00:00 2001 From: Gavin King Date: Fri, 13 Jun 2025 19:02:56 +0200 Subject: [PATCH 6/6] HHH-19535 add test for Interceptor.postMerge() --- .../merge/MergeAuditingInterceptor.java | 25 ++++++++++ .../merge/MergeAuditingInterceptorTest.java | 46 +++++++++++++++++++ 2 files changed, 71 insertions(+) create mode 100644 hibernate-core/src/test/java/org/hibernate/orm/test/interceptor/merge/MergeAuditingInterceptor.java create mode 100644 hibernate-core/src/test/java/org/hibernate/orm/test/interceptor/merge/MergeAuditingInterceptorTest.java diff --git a/hibernate-core/src/test/java/org/hibernate/orm/test/interceptor/merge/MergeAuditingInterceptor.java b/hibernate-core/src/test/java/org/hibernate/orm/test/interceptor/merge/MergeAuditingInterceptor.java new file mode 100644 index 000000000000..fbd60b076bf7 --- /dev/null +++ b/hibernate-core/src/test/java/org/hibernate/orm/test/interceptor/merge/MergeAuditingInterceptor.java @@ -0,0 +1,25 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright Red Hat Inc. and Hibernate Authors + */ +package org.hibernate.orm.test.interceptor.merge; + +import org.hibernate.Interceptor; +import org.hibernate.type.Type; + +import java.util.ArrayList; +import java.util.List; + +public class MergeAuditingInterceptor implements Interceptor { + + static final List auditTrail = new ArrayList<>(); + + @Override + public void postMerge(Object source, Object target, Object id, Object[] targetState, Object[] originalState, String[] propertyNames, Type[] propertyTypes) { + for ( int i = 0; i < propertyNames.length; i++ ) { + if ( !propertyTypes[i].isEqual( originalState[i], targetState[i] ) ) { + auditTrail.add( propertyNames[i] + " changed from " + originalState[i] + " to " + targetState[i] + " for " + id ); + } + } + } +} diff --git a/hibernate-core/src/test/java/org/hibernate/orm/test/interceptor/merge/MergeAuditingInterceptorTest.java b/hibernate-core/src/test/java/org/hibernate/orm/test/interceptor/merge/MergeAuditingInterceptorTest.java new file mode 100644 index 000000000000..30a571500e0a --- /dev/null +++ b/hibernate-core/src/test/java/org/hibernate/orm/test/interceptor/merge/MergeAuditingInterceptorTest.java @@ -0,0 +1,46 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright Red Hat Inc. and Hibernate Authors + */ +package org.hibernate.orm.test.interceptor.merge; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import org.hibernate.cfg.SessionEventSettings; +import org.hibernate.testing.orm.junit.EntityManagerFactoryScope; +import org.hibernate.testing.orm.junit.Jpa; +import org.hibernate.testing.orm.junit.Setting; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +@Jpa(annotatedClasses = MergeAuditingInterceptorTest.Thing.class, + integrationSettings = @Setting(name = SessionEventSettings.INTERCEPTOR, + value = "org.hibernate.orm.test.interceptor.merge.MergeAuditingInterceptor")) +class MergeAuditingInterceptorTest { + @Test + void test(EntityManagerFactoryScope scope) { + Thing t = scope.fromTransaction( em -> { + Thing thing = new Thing(); + thing.name = "Hibernate"; + em.persist( thing ); + return thing; + } ); + scope.inTransaction( em -> { + t.name = "Hibernate ORM"; + Thing thing = em.merge( t ); + assertEquals( 1, MergeAuditingInterceptor.auditTrail.size() ); + assertEquals( "name changed from Hibernate to Hibernate ORM for " + t.id, + MergeAuditingInterceptor.auditTrail.get( 0 ) ); + } ); + } + + @Entity + static class Thing { + @Id + @GeneratedValue + private Long id; + private String name; + } +}