diff --git a/hibernate-reactive-core/src/main/java/org/hibernate/reactive/engine/impl/CollectionTypes.java b/hibernate-reactive-core/src/main/java/org/hibernate/reactive/engine/impl/CollectionTypes.java new file mode 100644 index 000000000..4bc488bb6 --- /dev/null +++ b/hibernate-reactive-core/src/main/java/org/hibernate/reactive/engine/impl/CollectionTypes.java @@ -0,0 +1,481 @@ +/* Hibernate, Relational Persistence for Idiomatic Java + * + * SPDX-License-Identifier: Apache-2.0 + * Copyright: Red Hat Inc. and Hibernate Authors + */ +package org.hibernate.reactive.engine.impl; + +import java.io.Serializable; +import java.lang.invoke.MethodHandles; +import java.lang.reflect.Array; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.SortedMap; +import java.util.TreeMap; +import java.util.concurrent.CompletionStage; + +import org.hibernate.Hibernate; +import org.hibernate.HibernateException; +import org.hibernate.collection.spi.AbstractPersistentCollection; +import org.hibernate.collection.spi.PersistentArrayHolder; +import org.hibernate.collection.spi.PersistentCollection; +import org.hibernate.engine.spi.CollectionEntry; +import org.hibernate.engine.spi.PersistenceContext; +import org.hibernate.engine.spi.SessionImplementor; +import org.hibernate.engine.spi.SharedSessionContractImplementor; +import org.hibernate.metamodel.mapping.PluralAttributeMapping; +import org.hibernate.persister.collection.CollectionPersister; +import org.hibernate.reactive.logging.impl.Log; +import org.hibernate.reactive.logging.impl.LoggerFactory; +import org.hibernate.type.ArrayType; +import org.hibernate.type.CollectionType; +import org.hibernate.type.CustomCollectionType; +import org.hibernate.type.EntityType; +import org.hibernate.type.ForeignKeyDirection; +import org.hibernate.type.MapType; +import org.hibernate.type.Type; + +import static org.hibernate.bytecode.enhance.spi.LazyPropertyInitializer.UNFETCHED_PROPERTY; +import static org.hibernate.internal.util.collections.CollectionHelper.mapOfSize; +import static org.hibernate.pretty.MessageHelper.collectionInfoString; +import static org.hibernate.reactive.util.impl.CompletionStages.completedFuture; +import static org.hibernate.reactive.util.impl.CompletionStages.loop; +import static org.hibernate.reactive.util.impl.CompletionStages.voidFuture; + +/** + * Reactive operations that really belong to {@link CollectionType} + * + */ +public class CollectionTypes { + private static final Log LOG = LoggerFactory.make( Log.class, MethodHandles.lookup() ); + + /** + * @see org.hibernate.type.AbstractType#replace(Object, Object, SharedSessionContractImplementor, Object, Map, ForeignKeyDirection) + */ + public static CompletionStage replace( + CollectionType type, + Object original, + Object target, + SessionImplementor session, + Object owner, + Map copyCache, + ForeignKeyDirection foreignKeyDirection) + throws HibernateException { + // Collection and OneToOne are the only associations that could be TO_PARENT + return type.getForeignKeyDirection() == foreignKeyDirection + ? replace( type, original, target, session, owner, copyCache ) + : completedFuture( target ); + } + + /** + * @see CollectionType#replace(Object, Object, SharedSessionContractImplementor, Object, Map) + */ + public static CompletionStage replace( + CollectionType type, + Object original, + Object target, + SessionImplementor session, + Object owner, + Map copyCache) throws HibernateException { + if ( original == null ) { + return completedFuture( replaceNullOriginal( target, session ) ); + } + else if ( !Hibernate.isInitialized( original ) ) { + return completedFuture( replaceUninitializedOriginal( type, original, target, session, copyCache ) ); + } + else { + return replaceOriginal( type, original, target, session, owner, copyCache ); + } + } + + // todo: make org.hibernate.type.CollectionType#replaceNullOriginal public ? + /** + * @see CollectionType#replaceNullOriginal(Object, SharedSessionContractImplementor) + */ + private static Object replaceNullOriginal( + Object target, + SessionImplementor session) { + if ( target == null ) { + return null; + } + else if ( target instanceof Collection collection ) { + collection.clear(); + return collection; + } + else if ( target instanceof Map map ) { + map.clear(); + return map; + } + else { + final PersistenceContext persistenceContext = session.getPersistenceContext(); + final PersistentCollection collectionHolder = persistenceContext.getCollectionHolder( target ); + if ( collectionHolder != null ) { + if ( collectionHolder instanceof PersistentArrayHolder arrayHolder ) { + persistenceContext.removeCollectionHolder( target ); + arrayHolder.beginRead(); + final PluralAttributeMapping attributeMapping = + persistenceContext.getCollectionEntry( collectionHolder ) + .getLoadedPersister().getAttributeMapping(); + arrayHolder.injectLoadedState( attributeMapping, null ); + arrayHolder.endRead(); + arrayHolder.dirty(); + persistenceContext.addCollectionHolder( collectionHolder ); + return arrayHolder.getArray(); + } + } + } + return null; + } + + // todo: make org.hibernate.type.CollectionType#replaceUninitializedOriginal public + private static Object replaceUninitializedOriginal( + CollectionType type, + Object original, + Object target, + SessionImplementor session, + Map copyCache) { + final PersistentCollection persistentCollection = (PersistentCollection) original; + if ( persistentCollection.hasQueuedOperations() ) { + 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( + session.getFactory() + .getMappingMetamodel() + .getCollectionDescriptor( type.getRole() ), copyCache + ); + } + else { + // original is a detached copy of the collection; + // it contains queued operations, which will be ignored + LOG.ignoreQueuedOperationsOnMerge( + collectionInfoString( type.getRole(), persistentCollection.getKey() ) ); + } + } + return target; + } + + /** + * @see CollectionType#replaceOriginal(Object, Object, SharedSessionContractImplementor, Object, Map) + */ + private static CompletionStage replaceOriginal( + CollectionType type, + Object original, + Object target, + SessionImplementor session, + Object owner, + Map copyCache) { + + //for arrays, replaceElements() may return a different reference, since + //the array length might not match + return replaceElements( + type, + original, + instantiateResultIfNecessary( type, original, target ), + owner, + copyCache, + session + ).thenCompose( result -> { + if ( original == target ) { + // get the elements back into the target making sure to handle dirty flag + final boolean wasClean = + target instanceof PersistentCollection collection + && !collection.isDirty(); + //TODO: this is a little inefficient, don't need to do a whole + // deep replaceElements() call + return replaceElements( type, result, target, owner, copyCache, session ) + .thenApply( unused -> { + if ( wasClean ) { + ( (PersistentCollection) target ).clearDirty(); + } + return target; + } ); + } + else { + return completedFuture( result ); + } + } ); + } + + /** + * @see CollectionType#replaceElements(Object, Object, Object, Map, SharedSessionContractImplementor) + */ + private static CompletionStage replaceElements( + CollectionType type, + Object original, + Object target, + Object owner, + Map copyCache, + SessionImplementor session) { + if ( type instanceof ArrayType ) { + return replaceArrayTypeElements( type, original, target, owner, copyCache, session ); + } + else if ( type instanceof CustomCollectionType ) { + return completedFuture( type.replaceElements( original, target, owner, copyCache, session ) ); + } + else if ( type instanceof MapType ) { + return replaceMapTypeElements( + type, + (Map) original, + (Map) target, + owner, + copyCache, + session + ); + } + else { + return replaceCollectionTypeElements( + type, + original, + (Collection) target, + owner, + copyCache, + session + ); + } + } + + private static CompletionStage replaceCollectionTypeElements( + CollectionType type, + Object original, + final Collection result, + Object owner, + Map copyCache, + SessionImplementor session) { + result.clear(); + + // copy elements into newly empty target collection + final Type elemType = type.getElementType( session.getFactory() ); + return loop( + (Collection) original, o -> getReplace( elemType, o, owner, session, copyCache ) + .thenAccept( result::add ) + ).thenCompose( unused -> { + // if the original is a PersistentCollection, and that original + // was not flagged as dirty, then reset the target's dirty flag + // here after the copy operation. + //

+ // One thing to be careful of here is a "bare" original collection + // in which case we should never ever ever reset the dirty flag + // on the target because we simply do not know... + if ( original instanceof PersistentCollection originalPersistentCollection + && result instanceof PersistentCollection resultPersistentCollection ) { + return preserveSnapshot( + originalPersistentCollection, resultPersistentCollection, + elemType, owner, copyCache, session + ).thenApply( v -> { + if ( !originalPersistentCollection.isDirty() ) { + resultPersistentCollection.clearDirty(); + } + return result; + } ); + } + else { + return completedFuture( result ); + } + } ); + } + + private static CompletionStage replaceMapTypeElements( + CollectionType type, + Map original, + Map target, + Object owner, + Map copyCache, + SessionImplementor session) { + final CollectionPersister persister = session.getFactory().getRuntimeMetamodels() + .getMappingMetamodel().getCollectionDescriptor( type.getRole() ); + final Map result = target; + result.clear(); + + return loop( + original.entrySet(), entry -> { + final Map.Entry me = entry; + return getReplace( persister.getIndexType(), me.getKey(), owner, session, copyCache ) + .thenCompose( key -> getReplace( + persister.getElementType(), + me.getValue(), + owner, + session, + copyCache + ).thenAccept( value -> result.put( key, value ) ) + ); + } + ).thenApply( unused -> result ); + } + + private static CompletionStage replaceArrayTypeElements( + CollectionType type, + Object original, + Object target, + Object owner, + Map copyCache, + SessionImplementor session) { + final Object result; + final int length = Array.getLength( original ); + if ( length != Array.getLength( target ) ) { + //note: this affects the return value! + result = ( (ArrayType) type ).instantiateResult( original ); + } + else { + result = target; + } + + final Type elemType = type.getElementType( session.getFactory() ); + return loop( + 0, length, i -> getReplace( elemType, Array.get( original, i ), owner, session, copyCache ) + .thenApply( o -> { + Array.set( result, i, o ); + return result; + } ) + ).thenApply( unused -> result ); + } + + private static CompletionStage getReplace( + Type elemType, + Object o, + Object owner, + SessionImplementor session, + Map copyCache) { + return getReplace( elemType, o, null, owner, session, copyCache ); + } + + private static CompletionStage getReplace( + Type elemType, + Object o, + Object target, + Object owner, + SessionImplementor session, + Map copyCache) { + if ( elemType instanceof EntityType ) { + return EntityTypes.replace( (EntityType) elemType, o, target, session, owner, copyCache ); + } + else { + final Object replace = elemType.replace( o, target, session, owner, copyCache ); + return completedFuture( replace ); + } + } + + /** + * @see CollectionType#preserveSnapshot(PersistentCollection, PersistentCollection, Type, Object, Map, SharedSessionContractImplementor) + */ + private static CompletionStage preserveSnapshot( + PersistentCollection original, + PersistentCollection result, + Type elemType, + Object owner, + Map copyCache, + SessionImplementor session) { + final CollectionEntry ce = session.getPersistenceContextInternal().getCollectionEntry( result ); + if ( ce != null ) { + return createSnapshot( original, result, elemType, owner, copyCache, session ) + .thenAccept( serializable -> ce.resetStoredSnapshot( result, serializable ) ); + } + return voidFuture(); + } + + /** + * @see CollectionType#createSnapshot(PersistentCollection, PersistentCollection, Type, Object, Map, SharedSessionContractImplementor) + */ + private static CompletionStage createSnapshot( + PersistentCollection original, + PersistentCollection result, + Type elemType, + Object owner, + Map copyCache, + SessionImplementor session) { + final Serializable originalSnapshot = original.getStoredSnapshot(); + if ( originalSnapshot instanceof List list ) { + return createListSnapshot( list, elemType, owner, copyCache, session ); + } + else if ( originalSnapshot instanceof Map map ) { + return createMapSnapshot( map, result, elemType, owner, copyCache, session ); + } + else if ( originalSnapshot instanceof Object[] array ) { + return createArraySnapshot( array, elemType, owner, copyCache, session ); + } + else { + // retain the same snapshot + return completedFuture( result.getStoredSnapshot() ); + } + } + + /** + * @see CollectionType#createArraySnapshot(Object[], Type, Object, Map, SharedSessionContractImplementor) + */ + private static CompletionStage createArraySnapshot( + Object[] array, + Type elemType, + Object owner, + Map copyCache, + SessionImplementor session) { + return loop( + 0, array.length, i -> getReplace( elemType, array[i], owner, session, copyCache ) + .thenAccept( o -> array[i] = o ) + ).thenApply( unused -> array ); + } + + /** + * @see CollectionType#createMapSnapshot(Map, PersistentCollection, Type, Object, Map, SharedSessionContractImplementor) + */ + private static CompletionStage createMapSnapshot( + Map map, + PersistentCollection result, + Type elemType, + Object owner, + Map copyCache, + SessionImplementor session) { + final Map resultSnapshot = (Map) result.getStoredSnapshot(); + final Map targetMap; + if ( map instanceof SortedMap sortedMap ) { + //noinspection unchecked, rawtypes + targetMap = new TreeMap( sortedMap.comparator() ); + } + else { + targetMap = mapOfSize( map.size() ); + } + return loop( + map.entrySet(), entry -> + getReplace( elemType, entry.getValue(), resultSnapshot, owner, session, copyCache ) + .thenAccept( newValue -> { + final Object key = entry.getKey(); + targetMap.put( key == entry.getValue() ? newValue : key, newValue ); + } ) + ).thenApply( v -> (Serializable) targetMap ); + } + + /** + * @see CollectionType#createListSnapshot(List, Type, Object, Map, SharedSessionContractImplementor) + */ + private static CompletionStage createListSnapshot( + List list, + Type elemType, + Object owner, + Map copyCache, + SessionImplementor session) { + final ArrayList targetList = new ArrayList<>( list.size() ); + return loop( + list, obj -> getReplace( elemType, obj, owner, session, copyCache ) + .thenAccept( targetList::add ) + ).thenApply( unused -> targetList ); + } + + /** + * @see CollectionType#instantiateResultIfNecessary(Object, Object) + */ + private static Object instantiateResultIfNecessary(CollectionType type, Object original, Object target) { + // for a null target, or a target which is the same as the original, + // we need to put the merged elements in a new collection + // by default just use an unanticipated capacity since we don't + // know how to extract the capacity to use from original here... + return target == null + || target == original + || target == UNFETCHED_PROPERTY + || target instanceof PersistentCollection collection && collection.isWrapper( original ) + ? type.instantiate( -1 ) + : target; + } +} diff --git a/hibernate-reactive-core/src/main/java/org/hibernate/reactive/engine/impl/EntityTypes.java b/hibernate-reactive-core/src/main/java/org/hibernate/reactive/engine/impl/EntityTypes.java index 879f7ddc8..fb1c29fe4 100644 --- a/hibernate-reactive-core/src/main/java/org/hibernate/reactive/engine/impl/EntityTypes.java +++ b/hibernate-reactive-core/src/main/java/org/hibernate/reactive/engine/impl/EntityTypes.java @@ -25,6 +25,7 @@ import org.hibernate.reactive.persister.entity.impl.ReactiveEntityPersister; import org.hibernate.reactive.session.impl.ReactiveQueryExecutorLookup; import org.hibernate.reactive.session.impl.ReactiveSessionImpl; +import org.hibernate.type.CollectionType; import org.hibernate.type.EntityType; import org.hibernate.type.ForeignKeyDirection; import org.hibernate.type.OneToOneType; @@ -137,7 +138,7 @@ static CompletionStage loadByUniqueKey( else { return persister .reactiveLoadByUniqueKey( uniqueKeyPropertyName, key, session ) - .thenApply( ukResult -> loadHibernateProxyEntity( ukResult, session ) + .thenCompose( ukResult -> loadHibernateProxyEntity( ukResult, session ) .thenApply( targetUK -> { persistenceContext.addEntity( euk, targetUK ); return targetUK; @@ -158,42 +159,8 @@ public static CompletionStage replace( final Object owner, final Map copyCache) { Object[] copied = new Object[original.length]; - for ( int i = 0; i < types.length; i++ ) { - if ( original[i] == UNFETCHED_PROPERTY || original[i] == UNKNOWN ) { - copied[i] = target[i]; - } - else { - if ( !( types[i] instanceof EntityType ) ) { - copied[i] = types[i].replace( - original[i], - target[i] == UNFETCHED_PROPERTY ? null : target[i], - session, - owner, - copyCache - ); - } - } - } - return loop( 0, types.length, - i -> original[i] != UNFETCHED_PROPERTY && original[i] != UNKNOWN - && types[i] instanceof EntityType, - i -> replace( - (EntityType) types[i], - original[i], - target[i] == UNFETCHED_PROPERTY ? null : target[i], - session, - owner, - copyCache - ).thenCompose( copy -> { - if ( copy instanceof CompletionStage ) { - return ( (CompletionStage) copy ) - .thenAccept( nonStageCopy -> copied[i] = nonStageCopy ); - } - else { - copied[i] = copy; - return voidFuture(); - } - } ) + return loop( + 0, types.length, i -> replace( original, target, types, session, owner, copyCache, i, copied ) ).thenApply( v -> copied ); } @@ -209,43 +176,9 @@ public static CompletionStage replace( final Map copyCache, final ForeignKeyDirection foreignKeyDirection) { Object[] copied = new Object[original.length]; - for ( int i = 0; i < types.length; i++ ) { - if ( original[i] == UNFETCHED_PROPERTY || original[i] == UNKNOWN ) { - copied[i] = target[i]; - } - else { - if ( !( types[i] instanceof EntityType ) ) { - copied[i] = types[i].replace( - original[i], - target[i] == UNFETCHED_PROPERTY ? null : target[i], - session, - owner, - copyCache, - foreignKeyDirection - ); - } - } - } - return loop( 0, types.length, - i -> original[i] != UNFETCHED_PROPERTY && original[i] != UNKNOWN - && types[i] instanceof EntityType, - i -> replace( - (EntityType) types[i], - original[i], - target[i] == UNFETCHED_PROPERTY ? null : target[i], - session, - owner, - copyCache, - foreignKeyDirection - ).thenCompose( copy -> { - if ( copy instanceof CompletionStage ) { - return ( (CompletionStage) copy ).thenAccept( nonStageCopy -> copied[i] = nonStageCopy ); - } - else { - copied[i] = copy; - return voidFuture(); - } - } ) + return loop( + 0, types.length, + i -> replace( original, target, types, session, owner, copyCache, foreignKeyDirection, i, copied ) ).thenApply( v -> copied ); } @@ -272,7 +205,7 @@ private static CompletionStage replace( /** * @see EntityType#replace(Object, Object, SharedSessionContractImplementor, Object, Map) */ - private static CompletionStage replace( + protected static CompletionStage replace( EntityType entityType, Object original, Object target, @@ -336,15 +269,16 @@ private static CompletionStage resolveIdOrUniqueKey( // as a ComponentType. In the case that the entity is unfetched, we need to // explicitly fetch it here before calling replace(). (Note that in Hibernate // ORM this is unnecessary due to transparent lazy fetching.) - return ( (ReactiveSessionImpl) session ).reactiveFetch( id, true ) + return ( (ReactiveSessionImpl) session ) + .reactiveFetch( id, true ) .thenCompose( fetched -> { - Object idOrUniqueKey = entityType.getIdentifierOrUniqueKeyType( session.getFactory() ) + Object idOrUniqueKey = entityType + .getIdentifierOrUniqueKeyType( session.getFactory() ) .replace( fetched, null, session, owner, copyCache ); if ( idOrUniqueKey instanceof CompletionStage ) { return ( (CompletionStage) idOrUniqueKey ) .thenCompose( key -> resolve( entityType, key, owner, session ) ); } - return resolve( entityType, idOrUniqueKey, owner, session ); } ); } ); @@ -426,9 +360,9 @@ private static CompletionStage getIdentifierFromHibernateProxy( if ( type.isEntityIdentifierMapping() ) { propertyValue = getIdentifier( (EntityType) type, propertyValue, (SessionImplementor) session ); } - return completedFuture( propertyValue ); + return propertyValue; } - return nullFuture(); + return null; } ); } @@ -450,4 +384,100 @@ private static CompletionStage loadHibernateProxyEntity( } } + private static CompletionStage replace( + Object[] original, + Object[] target, + Type[] types, + SessionImplementor session, + Object owner, + Map copyCache, + int i, + Object[] copied) { + if ( original[i] == UNFETCHED_PROPERTY || original[i] == UNKNOWN ) { + copied[i] = target[i]; + return voidFuture(); + } + else if ( types[i] instanceof CollectionType ) { + return CollectionTypes.replace( + (CollectionType) types[i], + original[i], + target[i] == UNFETCHED_PROPERTY ? null : target[i], + session, + owner, + copyCache + ).thenAccept( copy -> copied[i] = copy ); + } + else if ( types[i] instanceof EntityType ) { + return replace( + (EntityType) types[i], + original[i], + target[i] == UNFETCHED_PROPERTY ? null : target[i], + session, + owner, + copyCache + ).thenAccept( copy -> copied[i] = copy ); + } + else { + final Type type = types[i]; + copied[i] = type.replace( + original[i], + target[i] == UNFETCHED_PROPERTY ? null : target[i], + session, + owner, + copyCache + ); + return voidFuture(); + } + } + + private static CompletionStage replace( + Object[] original, + Object[] target, + Type[] types, + SessionImplementor session, + Object owner, + Map copyCache, + ForeignKeyDirection foreignKeyDirection, + int i, + Object[] copied) { + if ( original[i] == UNFETCHED_PROPERTY || original[i] == UNKNOWN ) { + copied[i] = target[i]; + return voidFuture(); + } + else if ( types[i] instanceof CollectionType ) { + return CollectionTypes.replace( + (CollectionType) types[i], + original[i], + target[i] == UNFETCHED_PROPERTY ? null : target[i], + session, + owner, + copyCache, + foreignKeyDirection + ).thenAccept( copy -> copied[i] = copy ); + } + else if ( types[i] instanceof EntityType ) { + return replace( + (EntityType) types[i], + original[i], + target[i] == UNFETCHED_PROPERTY ? null : target[i], + session, + owner, + copyCache, + foreignKeyDirection + ).thenAccept( copy -> copied[i] = copy ); + } + else { + copied[i] = types[i].replace( + original[i], + target[i] == UNFETCHED_PROPERTY ? null : target[i], + session, + owner, + copyCache, + foreignKeyDirection + ); + return voidFuture(); + } + } + + } diff --git a/hibernate-reactive-core/src/main/java/org/hibernate/reactive/logging/impl/Log.java b/hibernate-reactive-core/src/main/java/org/hibernate/reactive/logging/impl/Log.java index 9c6c126cc..75287e5be 100644 --- a/hibernate-reactive-core/src/main/java/org/hibernate/reactive/logging/impl/Log.java +++ b/hibernate-reactive-core/src/main/java/org/hibernate/reactive/logging/impl/Log.java @@ -311,4 +311,8 @@ public interface Log extends BasicLogger { @LogMessage(level = WARN) @Message(id = 448, value = "Warnings creating temp table : %s") void warningsCreatingTempTable(SQLWarning warning); + + @LogMessage(level = WARN) + @Message( id= 494, value = "Attempt to merge an uninitialized collection with queued operations; queued operations will be ignored: %s") + void ignoreQueuedOperationsOnMerge(String collectionInfoString); } diff --git a/hibernate-reactive-core/src/test/java/org/hibernate/reactive/OneToManyArrayMergeTest.java b/hibernate-reactive-core/src/test/java/org/hibernate/reactive/OneToManyArrayMergeTest.java new file mode 100644 index 000000000..e2eb826d9 --- /dev/null +++ b/hibernate-reactive-core/src/test/java/org/hibernate/reactive/OneToManyArrayMergeTest.java @@ -0,0 +1,190 @@ +/* Hibernate, Relational Persistence for Idiomatic Java + * + * SPDX-License-Identifier: Apache-2.0 + * Copyright: Red Hat Inc. and Hibernate Authors + */ +package org.hibernate.reactive; + +import java.util.Collection; +import java.util.List; +import java.util.Objects; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import io.vertx.junit5.Timeout; +import io.vertx.junit5.VertxTestContext; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; + +import static java.util.concurrent.TimeUnit.MINUTES; +import static org.assertj.core.api.Assertions.assertThat; + +@Timeout(value = 2, timeUnit = MINUTES) +public class OneToManyArrayMergeTest extends BaseReactiveTest { + + private final static Long USER_ID = 1L; + private final static Long ADMIN_ROLE_ID = 2L; + private final static Long USER_ROLE_ID = 3L; + private final static String UPDATED_FIRSTNAME = "UPDATED FIRSTNAME"; + private final static String UPDATED_LASTNAME = "UPDATED LASTNAME"; + + @Override + protected Collection> annotatedEntities() { + return List.of( User.class, Role.class ); + } + + @BeforeEach + public void populateDb(VertxTestContext context) { + Role adminRole = new Role( ADMIN_ROLE_ID, "admin" ); + Role userRole = new Role( USER_ROLE_ID, "user" ); + User user = new User( USER_ID, "first", "last", adminRole ); + test( + context, getMutinySessionFactory() + .withTransaction( s -> s.persistAll( user, adminRole, userRole ) ) + ); + } + + @Test + public void testMerge(VertxTestContext context) { + test( + context, getMutinySessionFactory() + .withTransaction( s -> s.find( User.class, USER_ID ) ) + .chain( user -> getMutinySessionFactory() + .withTransaction( s -> s + .createQuery( "FROM Role", Role.class ) + .getResultList() ) + .map( roles -> { + user.addAll( roles ); + user.setFirstname( UPDATED_FIRSTNAME ); + user.setLastname( UPDATED_LASTNAME ); + return user; + } ) + ) + .chain( user -> { + assertThat( user.getFirstname() ).isEqualTo( UPDATED_FIRSTNAME ); + assertThat( user.getLastname() ).isEqualTo( UPDATED_LASTNAME ); + assertThat( user.getRoles() ).hasSize( 2 ); + return getMutinySessionFactory() + .withTransaction( s -> s.merge( user ) ); + } + ) + .chain( v -> getMutinySessionFactory() + .withTransaction( s -> s.find( User.class, USER_ID ) ) + ) + .invoke( user -> { + Role adminRole = new Role( ADMIN_ROLE_ID, "admin" ); + Role userRole = new Role( USER_ROLE_ID, "user" ); + assertThat( user.getFirstname() ).isEqualTo( UPDATED_FIRSTNAME ); + assertThat( user.getLastname() ).isEqualTo( UPDATED_LASTNAME ); + assertThat( user.getRoles() ).containsExactlyInAnyOrder( + adminRole, + userRole + ); + } + ) + ); + } + + @Entity(name = "User") + @Table(name = "USER_TABLE") + public static class User { + + @Id + private Long id; + + private String firstname; + + private String lastname; + + @OneToMany(fetch = FetchType.EAGER) + private Role[] roles; + + public User() { + } + + public User(Long id, String firstname, String lastname, Role... roles) { + this.id = id; + this.firstname = firstname; + this.lastname = lastname; + this.roles = new Role[roles.length]; + for ( int i = 0; i < roles.length; i++ ) { + this.roles[i] = roles[i]; + } + } + + public Long getId() { + return id; + } + + public String getFirstname() { + return firstname; + } + + public void setFirstname(String firstname) { + this.firstname = firstname; + } + + public String getLastname() { + return lastname; + } + + public void setLastname(String lastname) { + this.lastname = lastname; + } + + public Role[] getRoles() { + return roles; + } + + public void addAll(List roles) { + this.roles = new Role[roles.size()]; + for ( int i = 0; i < roles.size(); i++ ) { + this.roles[i] = roles.get( i ); + } + } + } + + @Entity(name = "Role") + @Table(name = "ROLE_TABLE") + public static class Role { + + @Id + private Long id; + private String code; + + public Role() { + } + + public Role(Long id, String code) { + this.id = id; + this.code = code; + } + + public Object getId() { + return id; + } + + @Override + public boolean equals(Object o) { + if ( o == null || getClass() != o.getClass() ) { + return false; + } + Role role = (Role) o; + return Objects.equals( id, role.id ) && Objects.equals( code, role.code ); + } + + @Override + public int hashCode() { + return Objects.hash( id, code ); + } + + @Override + public String toString() { + return "Role{" + code + '}'; + } + } +} diff --git a/hibernate-reactive-core/src/test/java/org/hibernate/reactive/OneToManyMapMergeTest.java b/hibernate-reactive-core/src/test/java/org/hibernate/reactive/OneToManyMapMergeTest.java new file mode 100644 index 000000000..7a1096d3d --- /dev/null +++ b/hibernate-reactive-core/src/test/java/org/hibernate/reactive/OneToManyMapMergeTest.java @@ -0,0 +1,199 @@ +/* Hibernate, Relational Persistence for Idiomatic Java + * + * SPDX-License-Identifier: Apache-2.0 + * Copyright: Red Hat Inc. and Hibernate Authors + */ +package org.hibernate.reactive; + +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import io.vertx.junit5.Timeout; +import io.vertx.junit5.VertxTestContext; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; + +import static java.util.concurrent.TimeUnit.MINUTES; +import static org.assertj.core.api.Assertions.assertThat; + +@Timeout(value = 2, timeUnit = MINUTES) +public class OneToManyMapMergeTest extends BaseReactiveTest { + + private final static Long USER_ID = 1L; + private final static Long ADMIN_ROLE_ID = 2L; + private final static Long USER_ROLE_ID = 3L; + private final static String UPDATED_FIRSTNAME = "UPDATED FIRSTNAME"; + private final static String UPDATED_LASTNAME = "UPDATED LASTNAME"; + + @Override + protected Collection> annotatedEntities() { + return List.of( User.class, Role.class ); + } + + @BeforeEach + public void populateDb(VertxTestContext context) { + Role adminRole = new Role( ADMIN_ROLE_ID, "admin" ); + Role userRole = new Role( USER_ROLE_ID, "user" ); + User user = new User( USER_ID, "first", "last", adminRole ); + test( + context, getMutinySessionFactory() + .withTransaction( s -> s.persistAll( user, adminRole, userRole ) ) + ); + } + + @Test + public void testMerge(VertxTestContext context) { + test( + context, getMutinySessionFactory() + .withTransaction( s -> s.find( User.class, USER_ID ) ) + .chain( user -> getMutinySessionFactory() + .withTransaction( s -> s + .createQuery( "FROM Role", Role.class ) + .getResultList() ) + .map( roles -> { + user.addAll( roles ); + user.setFirstname( UPDATED_FIRSTNAME ); + user.setLastname( UPDATED_LASTNAME ); + return user; + } ) + ) + .chain( user -> { + assertThat( user.getFirstname() ).isEqualTo( UPDATED_FIRSTNAME ); + assertThat( user.getLastname() ).isEqualTo( UPDATED_LASTNAME ); + assertThat( user.getRoles() ).hasSize( 2 ); + return getMutinySessionFactory() + .withTransaction( s -> s.merge( user ) ); + } + ) + .chain( v -> getMutinySessionFactory() + .withTransaction( s -> s.find( User.class, USER_ID ) ) + ) + .invoke( user -> { + Role adminRole = new Role( ADMIN_ROLE_ID, "admin" ); + Role userRole = new Role( USER_ROLE_ID, "user" ); + assertThat( user.getFirstname() ).isEqualTo( UPDATED_FIRSTNAME ); + assertThat( user.getLastname() ).isEqualTo( UPDATED_LASTNAME ); + assertThat( user.getRoles() ).containsEntry( + adminRole.getCode(), + adminRole + ); + assertThat( user.getRoles() ).containsEntry( + userRole.getCode(), + userRole + ); + } + ) + ); + } + + @Entity(name = "User") + @Table(name = "USER_TABLE") + public static class User { + + @Id + private Long id; + + private String firstname; + + private String lastname; + + @OneToMany(fetch = FetchType.EAGER) + private Map roles = new HashMap(); + + public User() { + } + + public User(Long id, String firstname, String lastname, Role... roles) { + this.id = id; + this.firstname = firstname; + this.lastname = lastname; + for ( Role role : roles ) { + this.roles.put( role.getCode(), role ); + } + } + + public Long getId() { + return id; + } + + public String getFirstname() { + return firstname; + } + + public void setFirstname(String firstname) { + this.firstname = firstname; + } + + public String getLastname() { + return lastname; + } + + public void setLastname(String lastname) { + this.lastname = lastname; + } + + public Map getRoles() { + return roles; + } + + public void addAll(List roles) { + this.roles.clear(); + for ( Role role : roles ) { + this.roles.put( role.getCode(), role ); + } + } + } + + @Entity(name = "Role") + @Table(name = "ROLE_TABLE") + public static class Role { + + @Id + private Long id; + private String code; + + public Role() { + } + + public Role(Long id, String code) { + this.id = id; + this.code = code; + } + + public Object getId() { + return id; + } + + public String getCode() { + return code; + } + + @Override + public boolean equals(Object o) { + if ( o == null || getClass() != o.getClass() ) { + return false; + } + Role role = (Role) o; + return Objects.equals( id, role.id ) && Objects.equals( code, role.code ); + } + + @Override + public int hashCode() { + return Objects.hash( id, code ); + } + + @Override + public String toString() { + return "Role{" + code + '}'; + } + } +} diff --git a/hibernate-reactive-core/src/test/java/org/hibernate/reactive/OneToManyMergeTest.java b/hibernate-reactive-core/src/test/java/org/hibernate/reactive/OneToManyMergeTest.java new file mode 100644 index 000000000..76d7f7732 --- /dev/null +++ b/hibernate-reactive-core/src/test/java/org/hibernate/reactive/OneToManyMergeTest.java @@ -0,0 +1,227 @@ +/* Hibernate, Relational Persistence for Idiomatic Java + * + * SPDX-License-Identifier: Apache-2.0 + * Copyright: Red Hat Inc. and Hibernate Authors + */ +package org.hibernate.reactive; + +import java.util.Collection; +import java.util.List; +import java.util.Objects; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import io.vertx.junit5.Timeout; +import io.vertx.junit5.VertxTestContext; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; + +import static java.util.concurrent.TimeUnit.MINUTES; +import static org.assertj.core.api.Assertions.assertThat; + +@Timeout(value = 2, timeUnit = MINUTES) +public class OneToManyMergeTest extends BaseReactiveTest { + + private final static Long USER_ID = 1L; + private final static Long ADMIN_ROLE_ID = 2L; + private final static Long USER_ROLE_ID = 3L; + private final static String UPDATED_FIRSTNAME = "UPDATED FIRSTNAME"; + private final static String UPDATED_LASTNAME = "UPDATED LASTNAME"; + + @Override + protected Collection> annotatedEntities() { + return List.of( User.class, Role.class ); + } + + @BeforeEach + public void populateDb(VertxTestContext context) { + Role adminRole = new Role( ADMIN_ROLE_ID, "admin" ); + Role userRole = new Role( USER_ROLE_ID, "user" ); + User user = new User( USER_ID, "first", "last", adminRole ); + test( + context, getMutinySessionFactory() + .withTransaction( s -> s.persistAll( user, adminRole, userRole ) ) + ); + } + + @Test + public void testMerge(VertxTestContext context) { + test( + context, getMutinySessionFactory() + .withTransaction( s -> s.find( User.class, USER_ID ) ) + .chain( user -> getMutinySessionFactory() + .withTransaction( s -> s + .createQuery( "FROM Role", Role.class ) + .getResultList() ) + .map( roles -> { + user.getRoles().clear(); + user.getRoles().addAll( roles ); + user.setFirstname( UPDATED_FIRSTNAME ); + user.setLastname( UPDATED_LASTNAME ); + return user; + } ) + ) + .chain( user -> { + assertThat( user.getFirstname() ).isEqualTo( UPDATED_FIRSTNAME ); + assertThat( user.getLastname() ).isEqualTo( UPDATED_LASTNAME ); + assertThat( user.getRoles() ).hasSize( 2 ); + return getMutinySessionFactory() + .withTransaction( s -> s.merge( user ) ); + } + ) + .chain( v -> getMutinySessionFactory() + .withTransaction( s -> s.find( User.class, USER_ID ) ) + ) + .invoke( user -> { + Role adminRole = new Role( ADMIN_ROLE_ID, "admin" ); + Role userRole = new Role( USER_ROLE_ID, "user" ); + assertThat( user.getFirstname() ).isEqualTo( UPDATED_FIRSTNAME ); + assertThat( user.getLastname() ).isEqualTo( UPDATED_LASTNAME ); + assertThat( user.getRoles() ).containsExactlyInAnyOrder( + adminRole, + userRole + ); + } + ) + ); + } + + @Test + public void testMergeRemovingCollectionElements(VertxTestContext context) { + test( + context, getMutinySessionFactory() + .withTransaction( s -> s.find( User.class, USER_ID ) ) + .chain( user -> getMutinySessionFactory() + .withTransaction( s -> s + .createQuery( "FROM Role", Role.class ) + .getResultList() ) + .map( roles -> { + user.clearRoles(); + user.setFirstname( UPDATED_FIRSTNAME ); + user.setLastname( UPDATED_LASTNAME ); + return user; + } ) + ) + .chain( user -> { + assertThat( user.getFirstname() ).isEqualTo( UPDATED_FIRSTNAME ); + assertThat( user.getLastname() ).isEqualTo( UPDATED_LASTNAME ); + assertThat( user.getRoles() ).isNull(); + return getMutinySessionFactory() + .withTransaction( s -> s.merge( user ) ); + } + ) + .chain( v -> getMutinySessionFactory() + .withTransaction( s -> s.find( User.class, USER_ID ) ) + ) + .invoke( user -> { + assertThat( user.getFirstname() ).isEqualTo( UPDATED_FIRSTNAME ); + assertThat( user.getLastname() ).isEqualTo( UPDATED_LASTNAME ); + assertThat( user.getRoles() ).isNullOrEmpty(); + } + ) + ); + } + + @Entity(name = "User") + @Table(name = "USER_TABLE") + public static class User { + + @Id + private Long id; + + private String firstname; + + private String lastname; + + @OneToMany(fetch = FetchType.EAGER) + private List roles; + + public User() { + } + + public User(Long id, String firstname, String lastname, Role... roles) { + this.id = id; + this.firstname = firstname; + this.lastname = lastname; + this.roles = List.of( roles ); + } + + public User(Long id, String firstname, String lastname) { + this.id = id; + this.firstname = firstname; + this.lastname = lastname; + } + + public Long getId() { + return id; + } + + public String getFirstname() { + return firstname; + } + + public void setFirstname(String firstname) { + this.firstname = firstname; + } + + public String getLastname() { + return lastname; + } + + public void setLastname(String lastname) { + this.lastname = lastname; + } + + public List getRoles() { + return roles; + } + + public void clearRoles() { + this.roles = null; + } + } + + @Entity(name = "Role") + @Table(name = "ROLE_TABLE") + public static class Role { + + @Id + private Long id; + private String code; + + public Role() { + } + + public Role(Long id, String code) { + this.id = id; + this.code = code; + } + + public Object getId() { + return id; + } + + @Override + public boolean equals(Object o) { + if ( o == null || getClass() != o.getClass() ) { + return false; + } + Role role = (Role) o; + return Objects.equals( id, role.id ) && Objects.equals( code, role.code ); + } + + @Override + public int hashCode() { + return Objects.hash( id, code ); + } + + @Override + public String toString() { + return "Role{" + code + '}'; + } + } +}