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..d2e36b1f9
--- /dev/null
+++ b/hibernate-reactive-core/src/main/java/org/hibernate/reactive/engine/impl/CollectionTypes.java
@@ -0,0 +1,516 @@
+/* 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.reactive.util.impl.CompletionStages;
+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.nullFuture;
+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
+ // 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
+ )
+ .thenCompose( v -> {
+ if ( !originalPersistentCollection.isDirty() ) {
+ resultPersistentCollection.clearDirty();
+ }
+ return voidFuture();
+ }
+ ).thenApply( v -> result );
+ }
+ else {
+ return 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 -> {
+ return 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 )
+ .thenCompose( o -> {
+ array[i] = o;
+ return voidFuture();
+ }
+ )
+ ).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 )
+ .thenCompose( newValue -> {
+ final Object key = entry.getKey();
+ targetMap.put( key == entry.getValue() ? newValue : key, newValue );
+ return voidFuture();
+ } )
+ ).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 )
+ .thenCompose( o -> {
+ targetList.add( o );
+ return voidFuture();
+ } )
+ ).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..722ba4a78 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;
@@ -158,42 +159,11 @@ 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 +179,11 @@ 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 +210,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,
@@ -450,4 +388,133 @@ 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
+ ).thenCompose( copy -> {
+ if ( copy instanceof CompletionStage ) {
+ return ( (CompletionStage>) copy ).thenAccept( nonStageCopy -> copied[i] = nonStageCopy );
+ }
+ else {
+ copied[i] = copy;
+ return voidFuture();
+ }
+ } );
+ }
+ else if ( types[i] instanceof EntityType ) {
+ return 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();
+ }
+ } );
+ }
+ 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
+ ).thenCompose( copy -> {
+ if ( copy instanceof CompletionStage ) {
+ return ( (CompletionStage>) copy ).thenAccept( nonStageCopy -> copied[i] = nonStageCopy );
+ }
+ else {
+ copied[i] = copy;
+ return voidFuture();
+ }
+ } );
+ }
+ else if ( types[i] instanceof EntityType ) {
+ return 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();
+ }
+ } );
+ }
+ 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 + '}';
+ }
+ }
+}