From 6f5b1e554386662e02d4ef3b91c23863258ff481 Mon Sep 17 00:00:00 2001 From: Gail Badner Date: Thu, 26 Jul 2018 16:00:22 -0700 Subject: [PATCH] HHH-11209 : Test cases HHH-11209 : NullPointerException in EntityType.replace() with a PersistentBag HHH-11209 : Add test for merging a detached collection with queued operations HHH-11209 : Throw UnsupportedOperationException if a detached collection with queued operations is merged HHH-11209 : Ignore queued operations when merging a detached collection with queued operations; add warnings HHH-11209 : Fix typo in comment --- .../internal/CollectionUpdateAction.java | 7 +- .../QueuedOperationCollectionAction.java | 17 + .../AbstractPersistentCollection.java | 40 +- .../hibernate/internal/CoreMessageLogger.java | 12 + .../org/hibernate/type/CollectionType.java | 21 +- .../BagDelayedOperationNoCascadeTest.java | 288 +++++++++++++ .../BagDelayedOperationTest.java | 49 ++- .../DetachedBagDelayedOperationTest.java | 387 ++++++++++++++++++ 8 files changed, 797 insertions(+), 24 deletions(-) create mode 100644 hibernate-core/src/test/java/org/hibernate/test/collection/delayedOperation/BagDelayedOperationNoCascadeTest.java create mode 100644 hibernate-core/src/test/java/org/hibernate/test/collection/delayedOperation/DetachedBagDelayedOperationTest.java diff --git a/hibernate-core/src/main/java/org/hibernate/action/internal/CollectionUpdateAction.java b/hibernate-core/src/main/java/org/hibernate/action/internal/CollectionUpdateAction.java index b8cb3c18cc..39264ee6b8 100644 --- a/hibernate-core/src/main/java/org/hibernate/action/internal/CollectionUpdateAction.java +++ b/hibernate-core/src/main/java/org/hibernate/action/internal/CollectionUpdateAction.java @@ -57,8 +57,11 @@ public final class CollectionUpdateAction extends CollectionAction { preUpdate(); if ( !collection.wasInitialized() ) { - if ( !collection.hasQueuedOperations() ) { - throw new AssertionFailure( "no queued adds" ); + // If there were queued operations, they would have been processed + // and cleared by now. + // The collection should still be dirty. + if ( !collection.isDirty() ) { + throw new AssertionFailure( "collection is not dirty" ); } //do nothing - we only need to notify the cache... } diff --git a/hibernate-core/src/main/java/org/hibernate/action/internal/QueuedOperationCollectionAction.java b/hibernate-core/src/main/java/org/hibernate/action/internal/QueuedOperationCollectionAction.java index 4abf3efb24..532b12e377 100644 --- a/hibernate-core/src/main/java/org/hibernate/action/internal/QueuedOperationCollectionAction.java +++ b/hibernate-core/src/main/java/org/hibernate/action/internal/QueuedOperationCollectionAction.java @@ -9,7 +9,9 @@ package org.hibernate.action.internal; import java.io.Serializable; import org.hibernate.HibernateException; +import org.hibernate.collection.internal.AbstractPersistentCollection; import org.hibernate.collection.spi.PersistentCollection; +import org.hibernate.engine.spi.CollectionEntry; import org.hibernate.engine.spi.SharedSessionContractImplementor; import org.hibernate.persister.collection.CollectionPersister; @@ -40,6 +42,21 @@ public final class QueuedOperationCollectionAction extends CollectionAction { @Override public void execute() throws HibernateException { + // this QueuedOperationCollectionAction has to be executed before any other + // CollectionAction involving the same collection. + getPersister().processQueuedOps( getCollection(), getKey(), getSession() ); + + // TODO: It would be nice if this could be done safely by CollectionPersister#processQueuedOps; + // Can't change the SPI to do this though. + ((AbstractPersistentCollection) getCollection() ).clearOperationQueue(); + + // The other CollectionAction types call CollectionEntry#afterAction, which + // clears the dirty flag. We don't want to call CollectionEntry#afterAction unless + // there is no other CollectionAction that will be executed on the same collection. + final CollectionEntry ce = getSession().getPersistenceContext().getCollectionEntry( getCollection() ); + if ( !ce.isDoremove() && !ce.isDoupdate() && !ce.isDorecreate() ) { + ce.afterAction( getCollection() ); + } } } diff --git a/hibernate-core/src/main/java/org/hibernate/collection/internal/AbstractPersistentCollection.java b/hibernate-core/src/main/java/org/hibernate/collection/internal/AbstractPersistentCollection.java index 44d0a506c2..54f714770f 100644 --- a/hibernate-core/src/main/java/org/hibernate/collection/internal/AbstractPersistentCollection.java +++ b/hibernate-core/src/main/java/org/hibernate/collection/internal/AbstractPersistentCollection.java @@ -512,6 +512,7 @@ public abstract class AbstractPersistentCollection implements Serializable, Pers for ( DelayedOperation operation : operationQueue ) { operation.operate(); } + clearOperationQueue(); } @Override @@ -523,11 +524,15 @@ public abstract class AbstractPersistentCollection implements Serializable, Pers @Override public void postAction() { - operationQueue = null; + clearOperationQueue(); cachedSize = -1; clearDirty(); } + public final void clearOperationQueue() { + operationQueue = null; + } + @Override public Object getValue() { return this; @@ -549,9 +554,8 @@ public abstract class AbstractPersistentCollection implements Serializable, Pers public boolean afterInitialize() { setInitialized(); //do this bit after setting initialized to true or it will recurse - if ( operationQueue != null ) { + if ( hasQueuedOperations() ) { performQueuedOperations(); - operationQueue = null; cachedSize = -1; return false; } @@ -620,6 +624,9 @@ public abstract class AbstractPersistentCollection implements Serializable, Pers prepareForPossibleLoadingOutsideTransaction(); if ( currentSession == this.session ) { if ( !isTempSession ) { + if ( hasQueuedOperations() ) { + LOG.queuedOperationWhenDetachFromSession( MessageHelper.collectionInfoString( getRole(), getKey() ) ); + } this.session = null; } return true; @@ -647,25 +654,22 @@ public abstract class AbstractPersistentCollection implements Serializable, Pers if ( session == this.session ) { return false; } - else { - if ( this.session != null ) { - final String msg = generateUnexpectedSessionStateMessage( session ); - if ( isConnectedToSession() ) { - throw new HibernateException( - "Illegal attempt to associate a collection with two open sessions. " + msg - ); - } - else { - LOG.logUnexpectedSessionInCollectionNotConnected( msg ); - this.session = session; - return true; - } + else if ( this.session != null ) { + final String msg = generateUnexpectedSessionStateMessage( session ); + if ( isConnectedToSession() ) { + throw new HibernateException( + "Illegal attempt to associate a collection with two open sessions. " + msg + ); } else { - this.session = session; - return true; + LOG.logUnexpectedSessionInCollectionNotConnected( msg ); } } + if ( hasQueuedOperations() ) { + LOG.queuedOperationWhenAttachToSession( MessageHelper.collectionInfoString( getRole(), getKey() ) ); + } + this.session = session; + return true; } private String generateUnexpectedSessionStateMessage(SharedSessionContractImplementor session) { diff --git a/hibernate-core/src/main/java/org/hibernate/internal/CoreMessageLogger.java b/hibernate-core/src/main/java/org/hibernate/internal/CoreMessageLogger.java index cd5c7deeb4..fdcbf98c53 100644 --- a/hibernate-core/src/main/java/org/hibernate/internal/CoreMessageLogger.java +++ b/hibernate-core/src/main/java/org/hibernate/internal/CoreMessageLogger.java @@ -1831,4 +1831,16 @@ public interface CoreMessageLogger extends BasicLogger { @LogMessage(level = INFO) @Message(value = "Query plan cache misses: %s", id = 493) void queryPlanCacheMisses(long queryPlanCacheMissCount); + + @LogMessage(level = WARN) + @Message(value = "Attempt to merge an uninitialized collection with queued operations; queued operations will be ignored: %s", id = 494) + void ignoreQueuedOperationsOnMerge(String collectionInfoString); + + @LogMessage(level = WARN) + @Message(value = "Attaching an uninitialized collection with queued operations to a session: %s", id = 495) + void queuedOperationWhenAttachToSession(String collectionInfoString); + + @LogMessage(level = WARN) + @Message(value = "Detaching an uninitialized collection with queued operations from a session: %s", id = 496) + void queuedOperationWhenDetachFromSession(String collectionInfoString); } diff --git a/hibernate-core/src/main/java/org/hibernate/type/CollectionType.java b/hibernate-core/src/main/java/org/hibernate/type/CollectionType.java index 428c7bbb08..5d4febce59 100644 --- a/hibernate-core/src/main/java/org/hibernate/type/CollectionType.java +++ b/hibernate-core/src/main/java/org/hibernate/type/CollectionType.java @@ -454,7 +454,7 @@ public abstract class CollectionType extends AbstractType implements Association public Object resolve(Object value, SharedSessionContractImplementor session, Object owner) throws HibernateException { - return resolve(value, session, owner, null); + return resolve( value, session, owner, null ); } @Override @@ -681,8 +681,23 @@ public abstract class CollectionType extends AbstractType implements Association } if ( !Hibernate.isInitialized( original ) ) { if ( ( (PersistentCollection) original ).hasQueuedOperations() ) { - final AbstractPersistentCollection pc = (AbstractPersistentCollection) original; - pc.replaceQueuedOperationValues( getPersister( session ), copyCache ); + if ( original == target ) { + // A managed entity with an uninitialized collection is being merged, + // We need to replace any detached entities in the queued operations + // with managed copies. + final AbstractPersistentCollection pc = (AbstractPersistentCollection) original; + pc.replaceQueuedOperationValues( getPersister( session ), copyCache ); + } + else { + // original is a detached copy of the collection; + // it contains queued operations, which will be ignored + LOG.ignoreQueuedOperationsOnMerge( + MessageHelper.collectionInfoString( + getRole(), + ( (PersistentCollection) original ).getKey() + ) + ); + } } return target; } diff --git a/hibernate-core/src/test/java/org/hibernate/test/collection/delayedOperation/BagDelayedOperationNoCascadeTest.java b/hibernate-core/src/test/java/org/hibernate/test/collection/delayedOperation/BagDelayedOperationNoCascadeTest.java new file mode 100644 index 0000000000..bd63bbeac7 --- /dev/null +++ b/hibernate-core/src/test/java/org/hibernate/test/collection/delayedOperation/BagDelayedOperationNoCascadeTest.java @@ -0,0 +1,288 @@ +/* + * 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 . + */ + +package org.hibernate.test.collection.delayedOperation; + +import java.util.ArrayList; +import java.util.List; +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.ManyToOne; +import javax.persistence.OneToMany; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import org.hibernate.Hibernate; +import org.hibernate.Session; +import org.hibernate.collection.internal.AbstractPersistentCollection; +import org.hibernate.testing.TestForIssue; +import org.hibernate.testing.junit4.BaseCoreFunctionalTestCase; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * Tests delayed operations that are queued for a PersistentBag. The Bag does not have + * to be extra-lazy to queue the operations. + * @author Gail Badner + */ +public class BagDelayedOperationNoCascadeTest extends BaseCoreFunctionalTestCase { + private Long parentId; + + @Override + protected Class[] getAnnotatedClasses() { + return new Class[] { + Parent.class, + Child.class + }; + } + + @Before + public void setup() { + // start by cleaning up in case a test fails + if ( parentId != null ) { + cleanup(); + } + + Parent parent = new Parent(); + Child child1 = new Child( "Sherman" ); + Child child2 = new Child( "Yogi" ); + parent.addChild( child1 ); + parent.addChild( child2 ); + + Session s = openSession(); + s.getTransaction().begin(); + s.persist( child1 ); + s.persist( child2 ); + s.persist( parent ); + s.getTransaction().commit(); + s.close(); + + parentId = parent.getId(); + } + + @After + public void cleanup() { + Session s = openSession(); + s.getTransaction().begin(); + s.createQuery( "delete from Child" ).executeUpdate(); + s.createQuery( "delete from Parent" ).executeUpdate(); + s.getTransaction().commit(); + s.close(); + + parentId = null; + } + + @Test + @TestForIssue( jiraKey = "HHH-5855") + public void testSimpleAddManaged() { + // Add 2 Child entities + Session s = openSession(); + s.getTransaction().begin(); + Child c1 = new Child( "Darwin" ); + s.persist( c1 ); + Child c2 = new Child( "Comet" ); + s.persist( c2 ); + s.getTransaction().commit(); + s.close(); + + // Add a managed Child and commit + s = openSession(); + s.getTransaction().begin(); + Parent p = s.get( Parent.class, parentId ); + assertFalse( Hibernate.isInitialized( p.getChildren() ) ); + // get the first Child so it is managed; add to collection + p.addChild( s.get( Child.class, c1.getId() ) ); + // collection should still be uninitialized + assertFalse( Hibernate.isInitialized( p.getChildren() ) ); + s.getTransaction().commit(); + s.close(); + + s = openSession(); + s.getTransaction().begin(); + p = s.get( Parent.class, parentId ); + assertFalse( Hibernate.isInitialized( p.getChildren() ) ); + assertEquals( 3, p.getChildren().size() ); + s.getTransaction().commit(); + s.close(); + + // Add the other managed Child, merge and commit. + s = openSession(); + s.getTransaction().begin(); + p = s.get( Parent.class, parentId ); + assertFalse( Hibernate.isInitialized( p.getChildren() ) ); + // get the second Child so it is managed; add to collection + p.addChild( s.get( Child.class, c2.getId() ) ); + // collection should still be uninitialized + assertFalse( Hibernate.isInitialized( p.getChildren() ) ); + s.merge( p ); + s.getTransaction().commit(); + s.close(); + + s = openSession(); + s.getTransaction().begin(); + p = s.get( Parent.class, parentId ); + assertFalse( Hibernate.isInitialized( p.getChildren() ) ); + assertEquals( 4, p.getChildren().size() ); + s.getTransaction().commit(); + s.close(); + } + + @Test + @TestForIssue( jiraKey = "HHH-11209") + public void testMergeInitializedBagAndRemerge() { + Session s = openSession(); + s.getTransaction().begin(); + Parent p = s.get( Parent.class, parentId ); + assertFalse( Hibernate.isInitialized( p.getChildren() ) ); + // initialize + Hibernate.initialize( p.getChildren() ); + assertTrue( Hibernate.isInitialized( p.getChildren() ) ); + s.getTransaction().commit(); + s.close(); + + s = openSession(); + s.getTransaction().begin(); + p = (Parent) s.merge( p ); + Child c = new Child( "Zeke" ); + c.setParent( p ); + s.persist( c ); + assertFalse( Hibernate.isInitialized( p.getChildren() ) ); + p.getChildren().size(); + p.getChildren().add( c ); + s.getTransaction().commit(); + s.close(); + + // Merge detached Parent with initialized children + s = openSession(); + s.getTransaction().begin(); + p = (Parent) s.merge( p ); + // after merging, p#children will be uninitialized + assertFalse( Hibernate.isInitialized( p.getChildren() ) ); + assertTrue( ( (AbstractPersistentCollection) p.getChildren() ).hasQueuedOperations() ); + s.getTransaction().commit(); + assertFalse( ( (AbstractPersistentCollection) p.getChildren() ).hasQueuedOperations() ); + s.close(); + + // Merge detached Parent, now with uninitialized children no queued operations + s = openSession(); + s.getTransaction().begin(); + p = (Parent) s.merge( p ); + assertFalse( Hibernate.isInitialized( p.getChildren() ) ); + assertFalse( ( (AbstractPersistentCollection) p.getChildren() ).hasQueuedOperations() ); + s.getTransaction().commit(); + s.close(); + } + + @Entity(name = "Parent") + public static class Parent { + + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + private Long id; + + // Don't need extra-lazy to delay add operations to a bag. + @OneToMany(mappedBy = "parent") + private List children = new ArrayList(); + + public Parent() { + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public List getChildren() { + return children; + } + + public void addChild(Child child) { + children.add(child); + child.setParent(this); + } + } + + @Entity(name = "Child") + public static class Child { + + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + private Long id; + + @Column(nullable = false) + private String name; + + @ManyToOne + private Parent parent; + + public Child() { + } + + public Child(String name) { + this.name = name; + } + + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public Parent getParent() { + return parent; + } + + public void setParent(Parent parent) { + this.parent = parent; + } + + @Override + public String toString() { + return "Child{" + + "id=" + id + + ", name='" + name + '\'' + + '}'; + } + + @Override + public boolean equals(Object o) { + if ( this == o ) { + return true; + } + if ( o == null || getClass() != o.getClass() ) { + return false; + } + + Child child = (Child) o; + + return name.equals( child.name ); + + } + + @Override + public int hashCode() { + return name.hashCode(); + } + } + +} diff --git a/hibernate-core/src/test/java/org/hibernate/test/collection/delayedOperation/BagDelayedOperationTest.java b/hibernate-core/src/test/java/org/hibernate/test/collection/delayedOperation/BagDelayedOperationTest.java index 0a18eac435..b45a252d73 100644 --- a/hibernate-core/src/test/java/org/hibernate/test/collection/delayedOperation/BagDelayedOperationTest.java +++ b/hibernate-core/src/test/java/org/hibernate/test/collection/delayedOperation/BagDelayedOperationTest.java @@ -24,11 +24,13 @@ import org.junit.Test; import org.hibernate.Hibernate; import org.hibernate.Session; +import org.hibernate.collection.internal.AbstractPersistentCollection; import org.hibernate.testing.TestForIssue; import org.hibernate.testing.junit4.BaseCoreFunctionalTestCase; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; /** * Tests delayed operations that are queued for a PersistentBag. The Bag does not have @@ -123,7 +125,7 @@ public class BagDelayedOperationTest extends BaseCoreFunctionalTestCase { assertFalse( Hibernate.isInitialized( p.getChildren() ) ); p.addChild( c2 ); assertFalse( Hibernate.isInitialized( p.getChildren() ) ); - s.merge( p ); + p = (Parent) s.merge( p ); s.getTransaction().commit(); s.close(); @@ -236,6 +238,51 @@ public class BagDelayedOperationTest extends BaseCoreFunctionalTestCase { s.close(); } + @Test + @TestForIssue( jiraKey = "HHH-11209") + public void testMergeInitializedBagAndRemerge() { + Session s = openSession(); + s.getTransaction().begin(); + Parent p = s.get( Parent.class, parentId ); + assertFalse( Hibernate.isInitialized( p.getChildren() ) ); + // initialize + Hibernate.initialize( p.getChildren() ); + assertTrue( Hibernate.isInitialized( p.getChildren() ) ); + s.getTransaction().commit(); + s.close(); + + s = openSession(); + s.getTransaction().begin(); + p = (Parent) s.merge( p ); + assertTrue( Hibernate.isInitialized( p.getChildren() ) ); + Child c = new Child( "Zeke" ); + c.setParent( p ); + s.persist( c ); + p.getChildren().size(); + p.getChildren().add( c ); + s.getTransaction().commit(); + s.close(); + + // Merge detached Parent with initialized children + s = openSession(); + s.getTransaction().begin(); + p = (Parent) s.merge( p ); + // after merging, p#children will be initialized + assertTrue( Hibernate.isInitialized( p.getChildren() ) ); + assertFalse( ( (AbstractPersistentCollection) p.getChildren() ).hasQueuedOperations() ); + s.getTransaction().commit(); + s.close(); + + // Merge detached Parent + s = openSession(); + s.getTransaction().begin(); + p = (Parent) s.merge( p ); + assertTrue( Hibernate.isInitialized( p.getChildren() ) ); + assertFalse( ( (AbstractPersistentCollection) p.getChildren() ).hasQueuedOperations() ); + s.getTransaction().commit(); + s.close(); + } + @Entity(name = "Parent") public static class Parent { diff --git a/hibernate-core/src/test/java/org/hibernate/test/collection/delayedOperation/DetachedBagDelayedOperationTest.java b/hibernate-core/src/test/java/org/hibernate/test/collection/delayedOperation/DetachedBagDelayedOperationTest.java new file mode 100644 index 0000000000..2e5cea84d5 --- /dev/null +++ b/hibernate-core/src/test/java/org/hibernate/test/collection/delayedOperation/DetachedBagDelayedOperationTest.java @@ -0,0 +1,387 @@ +/* + * 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 . + */ + +package org.hibernate.test.collection.delayedOperation; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import javax.persistence.CascadeType; +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.ManyToOne; +import javax.persistence.OneToMany; + +import org.hibernate.Hibernate; +import org.hibernate.collection.internal.AbstractPersistentCollection; +import org.hibernate.internal.CoreMessageLogger; +import org.hibernate.type.CollectionType; + +import org.hibernate.testing.TestForIssue; +import org.hibernate.testing.junit4.BaseCoreFunctionalTestCase; +import org.hibernate.testing.logger.LoggerInspectionRule; +import org.hibernate.testing.logger.Triggerable; +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; + +import org.jboss.logging.Logger; + +import static org.hibernate.testing.transaction.TransactionUtil.doInHibernate; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * Tests merge of detached PersistentBag + * + * @author Gail Badner + */ +public class DetachedBagDelayedOperationTest extends BaseCoreFunctionalTestCase { + + @Override + protected Class[] getAnnotatedClasses() { + return new Class[] { + Parent.class, + Child.class + }; + } + + @Rule + public LoggerInspectionRule logInspectionCollectionType = new LoggerInspectionRule( + Logger.getMessageLogger( CoreMessageLogger.class, CollectionType.class.getName() ) + ); + + @Rule + public LoggerInspectionRule logInspectionAbstractPersistentCollection = new LoggerInspectionRule( + Logger.getMessageLogger( CoreMessageLogger.class, AbstractPersistentCollection.class.getName() ) + ); + + private Triggerable triggerableIgnoreQueuedOperationsOnMerge; + private Triggerable triggerableQueuedOperationWhenAttachToSession; + private Triggerable triggerableQueuedOperationWhenDetachFromSession; + + @Before + public void setup() { + Parent parent = new Parent(); + parent.id = 1L; + Child child1 = new Child( "Sherman" ); + Child child2 = new Child( "Yogi" ); + parent.addChild( child1 ); + parent.addChild( child2 ); + + doInHibernate( + this::sessionFactory, session -> { + + session.persist( child1 ); + session.persist( child2 ); + session.persist( parent ); + } + ); + + triggerableIgnoreQueuedOperationsOnMerge = logInspectionCollectionType.watchForLogMessages( "HHH000494" ); + triggerableQueuedOperationWhenAttachToSession = logInspectionAbstractPersistentCollection.watchForLogMessages( "HHH000495" ); + triggerableQueuedOperationWhenDetachFromSession = logInspectionAbstractPersistentCollection.watchForLogMessages( "HHH000496" ); + + resetTriggerables(); + } + + @After + public void cleanup() { + doInHibernate( + this::sessionFactory, session -> { + session.createQuery( "delete from Child" ).executeUpdate(); + session.createQuery( "delete from Parent" ).executeUpdate(); + } + ); + } + + @Test + @TestForIssue( jiraKey = "HHH-11209" ) + public void testMergeDetachedCollectionWithQueuedOperations() { + final Parent pOriginal = doInHibernate( + this::sessionFactory, session -> { + Parent p = session.get( Parent.class, 1L ); + assertFalse( Hibernate.isInitialized( p.getChildren() ) ); + // initialize + Hibernate.initialize( p.getChildren() ); + assertTrue( Hibernate.isInitialized( p.getChildren() ) ); + return p; + } + ); + final Parent pWithQueuedOperations = doInHibernate( + this::sessionFactory, session -> { + Parent p = (Parent) session.merge( pOriginal ); + Child c = new Child( "Zeke" ); + c.setParent( p ); + session.persist( c ); + assertFalse( Hibernate.isInitialized( p.getChildren() ) ); + p.getChildren().add( c ); + assertFalse( Hibernate.isInitialized( p.getChildren() ) ); + assertTrue( ( (AbstractPersistentCollection) p.getChildren() ).hasQueuedOperations() ); + + checkTriggerablesNotTriggered(); + session.detach( p ); + assertTrue( triggerableQueuedOperationWhenDetachFromSession.wasTriggered() ); + assertEquals( + "HHH000496: Detaching an uninitialized collection with queued operations from a session: [org.hibernate.test.collection.delayedOperation.DetachedBagDelayedOperationTest$Parent.children#1]", + triggerableQueuedOperationWhenDetachFromSession.triggerMessage() + ); + triggerableQueuedOperationWhenDetachFromSession.reset(); + + // Make sure nothing else got triggered + checkTriggerablesNotTriggered(); + + return p; + } + ); + + checkTriggerablesNotTriggered(); + + assertTrue( ( (AbstractPersistentCollection) pWithQueuedOperations.getChildren() ).hasQueuedOperations() ); + + // Merge detached Parent with uninitialized collection with queued operations + doInHibernate( + this::sessionFactory, session -> { + + checkTriggerablesNotTriggered(); + + assertFalse( triggerableIgnoreQueuedOperationsOnMerge.wasTriggered() ); + Parent p = (Parent) session.merge( pWithQueuedOperations ); + assertTrue( triggerableIgnoreQueuedOperationsOnMerge.wasTriggered() ); + assertEquals( + "HHH000494: Attempt to merge an uninitialized collection with queued operations; queued operations will be ignored: [org.hibernate.test.collection.delayedOperation.DetachedBagDelayedOperationTest$Parent.children#1]", + triggerableIgnoreQueuedOperationsOnMerge.triggerMessage() + ); + triggerableIgnoreQueuedOperationsOnMerge.reset(); + + assertFalse( Hibernate.isInitialized( p.getChildren() ) ); + assertFalse( ( (AbstractPersistentCollection) p.getChildren() ).hasQueuedOperations() ); + + // When initialized, p.children will not include the new Child ("Zeke"), + // because that Child was flushed without a parent before being detached + // along with its parent. + Hibernate.initialize( p.getChildren() ); + final Set childNames = new HashSet( + Arrays.asList( new String[] { "Yogi", "Sherman" } ) + ); + assertEquals( childNames.size(), p.getChildren().size() ); + for ( Child child : p.getChildren() ) { + childNames.remove( child.getName() ); + } + assertEquals( 0, childNames.size() ); + } + ); + + checkTriggerablesNotTriggered(); + } + + @Test + @TestForIssue( jiraKey = "HHH-11209" ) + public void testSaveOrUpdateDetachedCollectionWithQueuedOperations() { + final Parent pOriginal = doInHibernate( + this::sessionFactory, session -> { + Parent p = session.get( Parent.class, 1L ); + assertFalse( Hibernate.isInitialized( p.getChildren() ) ); + // initialize + Hibernate.initialize( p.getChildren() ); + assertTrue( Hibernate.isInitialized( p.getChildren() ) ); + return p; + } + ); + final Parent pAfterDetachWithQueuedOperations = doInHibernate( + this::sessionFactory, session -> { + Parent p = (Parent) session.merge( pOriginal ); + Child c = new Child( "Zeke" ); + c.setParent( p ); + session.persist( c ); + assertFalse( Hibernate.isInitialized( p.getChildren() ) ); + p.getChildren().add( c ); + assertFalse( Hibernate.isInitialized( p.getChildren() ) ); + assertTrue( ( (AbstractPersistentCollection) p.getChildren() ).hasQueuedOperations() ); + + checkTriggerablesNotTriggered(); + session.detach( p ); + assertTrue( triggerableQueuedOperationWhenDetachFromSession.wasTriggered() ); + assertEquals( + "HHH000496: Detaching an uninitialized collection with queued operations from a session: [org.hibernate.test.collection.delayedOperation.DetachedBagDelayedOperationTest$Parent.children#1]", + triggerableQueuedOperationWhenDetachFromSession.triggerMessage() + ); + triggerableQueuedOperationWhenDetachFromSession.reset(); + + // Make sure nothing else got triggered + checkTriggerablesNotTriggered(); + + return p; + } + ); + + checkTriggerablesNotTriggered(); + + assertTrue( ( (AbstractPersistentCollection) pAfterDetachWithQueuedOperations.getChildren() ).hasQueuedOperations() ); + + // Save detached Parent with uninitialized collection with queued operations + doInHibernate( + this::sessionFactory, session -> { + + checkTriggerablesNotTriggered(); + + assertFalse( triggerableQueuedOperationWhenAttachToSession.wasTriggered() ); + session.saveOrUpdate( pAfterDetachWithQueuedOperations ); + assertTrue( triggerableQueuedOperationWhenAttachToSession.wasTriggered() ); + assertEquals( + "HHH000495: Attaching an uninitialized collection with queued operations to a session: [org.hibernate.test.collection.delayedOperation.DetachedBagDelayedOperationTest$Parent.children#1]", + triggerableQueuedOperationWhenAttachToSession.triggerMessage() + ); + triggerableQueuedOperationWhenAttachToSession.reset(); + + // Make sure nothing else got triggered + checkTriggerablesNotTriggered(); + + assertFalse( Hibernate.isInitialized( pAfterDetachWithQueuedOperations.getChildren() ) ); + assertTrue( ( (AbstractPersistentCollection) pAfterDetachWithQueuedOperations.getChildren() ).hasQueuedOperations() ); + + // Queued operations will be executed when the collection is initialized, + // After initialization, the collection will contain the Child that was added as a + // queued operation before being detached above. + Hibernate.initialize( pAfterDetachWithQueuedOperations.getChildren() ); + final Set childNames = new HashSet( + Arrays.asList( new String[] { "Yogi", "Sherman", "Zeke" } ) + ); + assertEquals( childNames.size(), pAfterDetachWithQueuedOperations.getChildren().size() ); + for ( Child child : pAfterDetachWithQueuedOperations.getChildren() ) { + childNames.remove( child.getName() ); + } + assertEquals( 0, childNames.size() ); + } + ); + + checkTriggerablesNotTriggered(); + } + + private void resetTriggerables() { + triggerableIgnoreQueuedOperationsOnMerge.reset(); + triggerableQueuedOperationWhenAttachToSession.reset(); + triggerableQueuedOperationWhenDetachFromSession.reset(); + } + + private void checkTriggerablesNotTriggered() { + assertFalse( triggerableIgnoreQueuedOperationsOnMerge.wasTriggered() ); + assertFalse( triggerableQueuedOperationWhenAttachToSession.wasTriggered() ); + assertFalse( triggerableQueuedOperationWhenDetachFromSession.wasTriggered() ); + } + + @Entity(name = "Parent") + public static class Parent { + + @Id + private Long id; + + // Don't need extra-lazy to delay add operations to a bag. + @OneToMany(mappedBy = "parent", cascade = CascadeType.DETACH) + private List children ; + + public Parent() { + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public List getChildren() { + return children; + } + + public void addChild(Child child) { + if ( children == null ) { + children = new ArrayList<>(); + } + children.add(child); + child.setParent(this); + } + } + + @Entity(name = "Child") + public static class Child { + + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + private Long id; + + @Column(nullable = false) + private String name; + + @ManyToOne + private Parent parent; + + public Child() { + } + + public Child(String name) { + this.name = name; + } + + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public Parent getParent() { + return parent; + } + + public void setParent(Parent parent) { + this.parent = parent; + } + + @Override + public String toString() { + return "Child{" + + "id=" + id + + ", name='" + name + '\'' + + '}'; + } + + @Override + public boolean equals(Object o) { + if ( this == o ) { + return true; + } + if ( o == null || getClass() != o.getClass() ) { + return false; + } + + Child child = (Child) o; + + return name.equals( child.name ); + + } + + @Override + public int hashCode() { + return name.hashCode(); + } + } + +}