HHH-15602 Fix bidirectional association management code

This commit is contained in:
Christian Beikov 2023-04-17 15:33:49 +02:00
parent a5ae8737a6
commit 982b132213
4 changed files with 268 additions and 62 deletions

View File

@ -35,6 +35,7 @@ import net.bytebuddy.dynamic.scaffold.FieldLocator;
import net.bytebuddy.dynamic.scaffold.InstrumentedType;
import net.bytebuddy.implementation.Implementation;
import net.bytebuddy.implementation.bytecode.ByteCodeAppender;
import net.bytebuddy.implementation.bytecode.assign.Assigner;
import net.bytebuddy.jar.asm.MethodVisitor;
import net.bytebuddy.jar.asm.Opcodes;
import net.bytebuddy.jar.asm.Type;
@ -57,7 +58,14 @@ final class BiDirectionalAssociationHandler implements Implementation {
return implementation;
}
String mappedBy = getMappedBy( persistentField, targetEntity, enhancementContext );
if ( mappedBy == null || mappedBy.isEmpty() ) {
String bidirectionalAttributeName;
if ( mappedBy == null ) {
bidirectionalAttributeName = getMappedByManyToMany( persistentField, targetEntity, enhancementContext );
}
else {
bidirectionalAttributeName = mappedBy;
}
if ( bidirectionalAttributeName == null || bidirectionalAttributeName.isEmpty() ) {
if ( log.isInfoEnabled() ) {
log.infof(
"Bi-directional association not managed for field [%s#%s]: Could not find target field in [%s]",
@ -70,15 +78,24 @@ final class BiDirectionalAssociationHandler implements Implementation {
}
TypeDescription targetType = FieldLocator.ForClassHierarchy.Factory.INSTANCE.make( targetEntity )
.locate( mappedBy )
.locate( bidirectionalAttributeName )
.getField()
.getType()
.asErasure();
if ( persistentField.hasAnnotation( OneToOne.class ) ) {
implementation = Advice.withCustomMapping()
.bind( CodeTemplates.FieldValue.class, persistentField.getFieldDescription() )
.bind( CodeTemplates.MappedBy.class, mappedBy )
.bind(
// We need to make the fieldValue writable for one-to-one to avoid stack overflows
// when unsetting the inverse field
new Advice.OffsetMapping.ForField.Resolved.Factory<>(
CodeTemplates.FieldValue.class,
persistentField.getFieldDescription(),
false,
Assigner.Typing.DYNAMIC
)
)
.bind( CodeTemplates.InverseSide.class, mappedBy != null )
.to( CodeTemplates.OneToOneHandler.class )
.wrap( implementation );
}
@ -86,7 +103,7 @@ final class BiDirectionalAssociationHandler implements Implementation {
if ( persistentField.hasAnnotation( OneToMany.class ) ) {
implementation = Advice.withCustomMapping()
.bind( CodeTemplates.FieldValue.class, persistentField.getFieldDescription() )
.bind( CodeTemplates.MappedBy.class, mappedBy )
.bind( CodeTemplates.InverseSide.class, mappedBy != null )
.to( persistentField.getType().asErasure().isAssignableTo( Map.class )
? CodeTemplates.OneToManyOnMapHandler.class
: CodeTemplates.OneToManyOnCollectionHandler.class )
@ -96,7 +113,7 @@ final class BiDirectionalAssociationHandler implements Implementation {
if ( persistentField.hasAnnotation( ManyToOne.class ) ) {
implementation = Advice.withCustomMapping()
.bind( CodeTemplates.FieldValue.class, persistentField.getFieldDescription() )
.bind( CodeTemplates.MappedBy.class, mappedBy )
.bind( CodeTemplates.BidirectionalAttribute.class, bidirectionalAttributeName )
.to( CodeTemplates.ManyToOneHandler.class )
.wrap( implementation );
}
@ -116,12 +133,13 @@ final class BiDirectionalAssociationHandler implements Implementation {
implementation = Advice.withCustomMapping()
.bind( CodeTemplates.FieldValue.class, persistentField.getFieldDescription() )
.bind( CodeTemplates.MappedBy.class, mappedBy )
.bind( CodeTemplates.InverseSide.class, mappedBy != null )
.bind( CodeTemplates.BidirectionalAttribute.class, bidirectionalAttributeName )
.to( CodeTemplates.ManyToManyHandler.class )
.wrap( implementation );
}
return new BiDirectionalAssociationHandler( implementation, targetEntity, targetType, mappedBy );
return new BiDirectionalAssociationHandler( implementation, managedCtClass, persistentField, targetEntity, targetType, bidirectionalAttributeName );
}
public static TypeDescription getTargetEntityClass(TypeDescription managedCtClass, AnnotatedFieldDescription persistentField) {
@ -186,16 +204,16 @@ final class BiDirectionalAssociationHandler implements Implementation {
}
private static String getMappedBy(AnnotatedFieldDescription target, TypeDescription targetEntity, ByteBuddyEnhancementContext context) {
String mappedBy = getMappedByNotManyToMany( target );
final String mappedBy = getMappedByFromAnnotation( target );
if ( mappedBy == null || mappedBy.isEmpty() ) {
return getMappedByManyToMany( target, targetEntity, context );
return null;
}
else {
// HHH-13446 - mappedBy from annotation may not be a valid bi-directional association, verify by calling isValidMappedBy()
return isValidMappedBy( target, targetEntity, mappedBy, context ) ? mappedBy : "";
return isValidMappedBy( target, targetEntity, mappedBy, context ) ? mappedBy : null;
}
}
private static boolean isValidMappedBy(AnnotatedFieldDescription persistentField, TypeDescription targetEntity, String mappedBy, ByteBuddyEnhancementContext context) {
try {
FieldDescription f = FieldLocator.ForClassHierarchy.Factory.INSTANCE.make( targetEntity ).locate( mappedBy ).getField();
@ -207,8 +225,7 @@ final class BiDirectionalAssociationHandler implements Implementation {
return false;
}
}
private static String getMappedByNotManyToMany(AnnotatedFieldDescription target) {
private static String getMappedByFromAnnotation(AnnotatedFieldDescription target) {
try {
AnnotationDescription.Loadable<OneToOne> oto = target.getAnnotation( OneToOne.class );
if ( oto != null ) {
@ -235,7 +252,7 @@ final class BiDirectionalAssociationHandler implements Implementation {
for ( FieldDescription f : targetEntity.getDeclaredFields() ) {
AnnotatedFieldDescription annotatedF = new AnnotatedFieldDescription( context, f );
if ( context.isPersistentField( annotatedF )
&& target.getName().equals( getMappedByNotManyToMany( annotatedF ) )
&& target.getName().equals( getMappedBy( annotatedF, entityType( annotatedF.getType() ), context ) )
&& target.getDeclaringType().asErasure().isAssignableTo( entityType( annotatedF.getType() ) ) ) {
if ( log.isDebugEnabled() ) {
log.debugf(
@ -267,21 +284,28 @@ final class BiDirectionalAssociationHandler implements Implementation {
private final Implementation delegate;
private final TypeDescription entity;
private final AnnotatedFieldDescription field;
private final TypeDescription targetEntity;
private final TypeDescription targetType;
private final String mappedBy;
private final String bidirectionalAttributeName;
private BiDirectionalAssociationHandler(
Implementation delegate,
TypeDescription entity,
AnnotatedFieldDescription field,
TypeDescription targetEntity,
TypeDescription targetType,
String mappedBy) {
String bidirectionalAttributeName) {
this.delegate = delegate;
this.entity = entity;
this.field = field;
this.targetEntity = targetEntity;
this.targetType = targetType;
this.mappedBy = mappedBy;
this.bidirectionalAttributeName = bidirectionalAttributeName;
}
@Override
@ -315,11 +339,21 @@ final class BiDirectionalAssociationHandler implements Implementation {
super.visitMethodInsn(
Opcodes.INVOKEVIRTUAL,
targetEntity.getInternalName(),
EnhancerConstants.PERSISTENT_FIELD_READER_PREFIX + mappedBy,
EnhancerConstants.PERSISTENT_FIELD_READER_PREFIX + bidirectionalAttributeName,
Type.getMethodDescriptor( Type.getType( targetType.getDescriptor() ) ),
false
);
}
else if ( name.equals( "getterSelf" ) ) {
super.visitVarInsn( Opcodes.ALOAD, 0 );
super.visitMethodInsn(
Opcodes.INVOKEVIRTUAL,
entity.getInternalName(),
EnhancerConstants.PERSISTENT_FIELD_READER_PREFIX + field.getName(),
Type.getMethodDescriptor( Type.getType( field.getDescriptor() ) ),
false
);
}
else if ( name.equals( "setterSelf" ) ) {
super.visitInsn( Opcodes.POP );
super.visitTypeInsn( Opcodes.CHECKCAST, targetEntity.getInternalName() );
@ -327,7 +361,7 @@ final class BiDirectionalAssociationHandler implements Implementation {
super.visitMethodInsn(
Opcodes.INVOKEVIRTUAL,
targetEntity.getInternalName(),
EnhancerConstants.PERSISTENT_FIELD_WRITER_PREFIX + mappedBy,
EnhancerConstants.PERSISTENT_FIELD_WRITER_PREFIX + bidirectionalAttributeName,
Type.getMethodDescriptor( Type.getType( void.class ), Type.getType( targetType.getDescriptor() ) ),
false
);
@ -339,7 +373,7 @@ final class BiDirectionalAssociationHandler implements Implementation {
super.visitMethodInsn(
Opcodes.INVOKEVIRTUAL,
targetEntity.getInternalName(),
EnhancerConstants.PERSISTENT_FIELD_WRITER_PREFIX + mappedBy,
EnhancerConstants.PERSISTENT_FIELD_WRITER_PREFIX + bidirectionalAttributeName,
Type.getMethodDescriptor( Type.getType( void.class ), Type.getType( targetType.getDescriptor() ) ),
false
);
@ -368,11 +402,11 @@ final class BiDirectionalAssociationHandler implements Implementation {
return Objects.equals( delegate, that.delegate ) &&
Objects.equals( targetEntity, that.targetEntity ) &&
Objects.equals( targetType, that.targetType ) &&
Objects.equals( mappedBy, that.mappedBy );
Objects.equals( bidirectionalAttributeName, that.bidirectionalAttributeName );
}
@Override
public int hashCode() {
return Objects.hash( delegate, targetEntity, targetType, mappedBy );
return Objects.hash( delegate, targetEntity, targetType, bidirectionalAttributeName );
}
}

View File

@ -356,15 +356,24 @@ class CodeTemplates {
static class OneToOneHandler {
@Advice.OnMethodEnter
static void enter(@FieldValue Object field, @Advice.Argument(0) Object argument, @MappedBy String mappedBy) {
if ( field != null && Hibernate.isPropertyInitialized( field, mappedBy ) && argument != null ) {
setterNull( field, null );
static void enter(@FieldValue Object field, @Advice.Argument(0) Object argument, @InverseSide boolean inverseSide) {
// Unset the inverse attribute, which possibly initializes the old value,
// only if this is the inverse side, or the old value is already initialized
if ( ( inverseSide || Hibernate.isInitialized( field ) ) && getterSelf() != null ) {
// We copy the old value, then set the field to null which we must do before
// unsetting the inverse attribute, as we'd otherwise run into a stack overflow situation
// The field is writable, so setting it to null here is actually a field write.
Object fieldCopy = field;
field = null;
setterNull( fieldCopy, null );
}
}
@Advice.OnMethodExit
static void exit(@Advice.This Object self, @Advice.Argument(0) Object argument, @MappedBy String mappedBy) {
if ( argument != null && Hibernate.isPropertyInitialized( argument, mappedBy ) && getter( argument ) != self ) {
static void exit(@Advice.This Object self, @Advice.Argument(0) Object argument, @InverseSide boolean inverseSide) {
// Update the inverse attribute, which possibly initializes the argument value,
// only if this is the inverse side, or the argument value is already initialized
if ( argument != null && ( inverseSide || Hibernate.isInitialized( argument ) ) && getter( argument ) != self ) {
setterSelf( argument, self );
}
}
@ -374,6 +383,11 @@ class CodeTemplates {
throw new AssertionError();
}
static Object getterSelf() {
// is replaced by the actual method call
throw new AssertionError();
}
static void setterNull(Object target, Object argument) {
// is replaced by the actual method call
throw new AssertionError();
@ -387,24 +401,30 @@ class CodeTemplates {
static class OneToManyOnCollectionHandler {
@Advice.OnMethodEnter
static void enter(@FieldValue Collection<?> field, @Advice.Argument(0) Collection<?> argument, @MappedBy String mappedBy) {
if ( field != null && Hibernate.isPropertyInitialized( field, mappedBy ) ) {
static void enter(@FieldValue Collection<?> field, @Advice.Argument(0) Collection<?> argument, @InverseSide boolean inverseSide) {
// If this is the inverse side or the old collection is already initialized,
// we must unset the respective ManyToOne of the old collection elements,
// because only the owning side is responsible for persisting the state.
if ( ( inverseSide || Hibernate.isInitialized( field ) ) && getterSelf() != null ) {
Object[] array = field.toArray();
for ( Object array1 : array ) {
if ( argument == null || !argument.contains( array1 ) ) {
setterNull( array1, null );
for ( int i = 0; i < array.length; i++ ) {
if ( ( inverseSide || Hibernate.isInitialized( array[i] ) ) && ( argument == null || !argument.contains( array[i] ) ) ) {
setterNull( array[i], null );
}
}
}
}
@Advice.OnMethodExit
static void exit(@Advice.This Object self, @Advice.Argument(0) Collection<?> argument, @MappedBy String mappedBy) {
if ( argument != null && Hibernate.isPropertyInitialized( argument, mappedBy ) ) {
static void exit(@Advice.This Object self, @Advice.Argument(0) Collection<?> argument, @InverseSide boolean inverseSide) {
// If this is the inverse side or the new collection is already initialized,
// we must set the respective ManyToOne on the new collection elements,
// because only the owning side is responsible for persisting the state.
if ( argument != null && ( inverseSide || Hibernate.isInitialized( argument ) ) ) {
Object[] array = argument.toArray();
for ( Object array1 : array ) {
if ( Hibernate.isPropertyInitialized( array1, mappedBy ) && getter( array1 ) != self ) {
setterSelf( array1, self );
for ( int i = 0; i < array.length; i++ ) {
if ( ( inverseSide || Hibernate.isInitialized( array[i] ) ) && getter( array[i] ) != self ) {
setterSelf( array[i], self );
}
}
}
@ -415,6 +435,11 @@ class CodeTemplates {
throw new AssertionError();
}
static Object getterSelf() {
// is replaced by the actual method call
throw new AssertionError();
}
static void setterNull(Object target, Object argument) {
// is replaced by the actual method call
throw new AssertionError();
@ -428,24 +453,31 @@ class CodeTemplates {
static class OneToManyOnMapHandler {
@Advice.OnMethodEnter
static void enter(@FieldValue Map<?, ?> field, @Advice.Argument(0) Map<?, ?> argument, @MappedBy String mappedBy) {
if ( field != null && Hibernate.isPropertyInitialized( field, mappedBy ) ) {
static void enter(@FieldValue Map<?, ?> field, @Advice.Argument(0) Map<?, ?> argument, @InverseSide boolean inverseSide) {
// If this is the inverse side or the old collection is already initialized,
// we must unset the respective ManyToOne of the old collection elements,
// because only the owning side is responsible for persisting the state.
if ( ( inverseSide || Hibernate.isInitialized( field ) ) && getterSelf() != null ) {
Object[] array = field.values().toArray();
for ( Object array1 : array ) {
if ( argument == null || !argument.values().contains( array1 ) ) {
setterNull( array1, null );
for ( int i = 0; i < array.length; i++ ) {
if ( ( inverseSide || Hibernate.isInitialized( array[i] ) )
&& ( argument == null || !argument.containsValue( array[i] ) ) ) {
setterNull( array[i], null );
}
}
}
}
@Advice.OnMethodExit
static void exit(@Advice.This Object self, @Advice.Argument(0) Map<?, ?> argument, @MappedBy String mappedBy) {
if ( argument != null && Hibernate.isPropertyInitialized( argument, mappedBy ) ) {
static void exit(@Advice.This Object self, @Advice.Argument(0) Map<?, ?> argument, @InverseSide boolean inverseSide) {
// If this is the inverse side or the new collection is already initialized,
// we must set the respective ManyToOne on the new collection elements,
// because only the owning side is responsible for persisting the state.
if ( argument != null && ( inverseSide || Hibernate.isInitialized( argument ) ) ) {
Object[] array = argument.values().toArray();
for ( Object array1 : array ) {
if ( Hibernate.isPropertyInitialized( array1, mappedBy ) && getter( array1 ) != self ) {
setterSelf( array1, self );
for ( int i = 0; i < array.length; i++ ) {
if ( ( inverseSide || Hibernate.isInitialized( array[i] ) ) && getter( array[i] ) != self ) {
setterSelf( array[i], self );
}
}
}
@ -456,6 +488,11 @@ class CodeTemplates {
throw new AssertionError();
}
static Object getterSelf() {
// is replaced by the actual method call
throw new AssertionError();
}
static void setterNull(Object target, Object argument) {
// is replaced with the actual setter call during instrumentation.
throw new AssertionError();
@ -469,8 +506,9 @@ class CodeTemplates {
static class ManyToOneHandler {
@Advice.OnMethodEnter
static void enter(@Advice.This Object self, @FieldValue Object field, @MappedBy String mappedBy) {
if ( field != null && Hibernate.isPropertyInitialized( field, mappedBy ) ) {
static void enter(@Advice.This Object self, @FieldValue Object field, @BidirectionalAttribute String inverseAttribute) {
// This is always the owning side, so we only need to update the inverse side if the collection is initialized
if ( getterSelf() != null && Hibernate.isPropertyInitialized( field, inverseAttribute ) ) {
Collection<?> c = getter( field );
if ( c != null ) {
c.remove( self );
@ -479,8 +517,9 @@ class CodeTemplates {
}
@Advice.OnMethodExit
static void exit(@Advice.This Object self, @Advice.Argument(0) Object argument, @MappedBy String mappedBy) {
if ( argument != null && Hibernate.isPropertyInitialized( argument, mappedBy ) ) {
static void exit(@Advice.This Object self, @Advice.Argument(0) Object argument, @BidirectionalAttribute String inverseAttribute) {
// This is always the owning side, so we only need to update the inverse side if the collection is initialized
if ( argument != null && Hibernate.isPropertyInitialized( argument, inverseAttribute ) ) {
Collection<Object> c = getter( argument );
if ( c != null && !c.contains( self ) ) {
c.add( self );
@ -492,29 +531,41 @@ class CodeTemplates {
// is replaced by the actual method call
throw new AssertionError();
}
static Object getterSelf() {
// is replaced by the actual method call
throw new AssertionError();
}
}
static class ManyToManyHandler {
@Advice.OnMethodEnter
static void enter(@Advice.This Object self, @FieldValue Collection<?> field, @Advice.Argument(0) Collection<?> argument, @MappedBy String mappedBy) {
if ( field != null && Hibernate.isPropertyInitialized( field, mappedBy ) ) {
static void enter(@Advice.This Object self, @FieldValue Collection<?> field, @Advice.Argument(0) Collection<?> argument, @InverseSide boolean inverseSide, @BidirectionalAttribute String bidirectionalAttribute) {
// If this is the inverse side or the old collection is already initialized,
// we must remove self from the respective old collection elements inverse collections,
// because only the owning side is responsible for persisting the state.
if ( ( inverseSide || Hibernate.isInitialized( argument ) ) && getterSelf() != null ) {
Object[] array = field.toArray();
for ( Object array1 : array ) {
if ( argument == null || !argument.contains( array1 ) ) {
getter( array1 ).remove( self );
for ( int i = 0; i < array.length; i++ ) {
if ( ( inverseSide || Hibernate.isPropertyInitialized( array[i], bidirectionalAttribute ) )
&& ( argument == null || !argument.contains( array[i] ) ) ) {
getter( array[i] ).remove( self );
}
}
}
}
@Advice.OnMethodExit
static void exit(@Advice.This Object self, @Advice.Argument(0) Collection<?> argument, @MappedBy String mappedBy) {
if ( argument != null && Hibernate.isPropertyInitialized( argument, mappedBy ) ) {
static void exit(@Advice.This Object self, @Advice.Argument(0) Collection<?> argument, @InverseSide boolean inverseSide, @BidirectionalAttribute String bidirectionalAttribute) {
// If this is the inverse side or the new collection is already initialized,
// we must add self to the respective new collection elements inverse collections,
// because only the owning side is responsible for persisting the state.
if ( argument != null && ( inverseSide || Hibernate.isInitialized( argument ) ) ) {
Object[] array = argument.toArray();
for ( Object array1 : array ) {
if ( Hibernate.isPropertyInitialized( array1, mappedBy ) ) {
if ( inverseSide || Hibernate.isPropertyInitialized( array1, bidirectionalAttribute ) ) {
Collection<Object> c = getter( array1 );
if ( c != self && c != null ) {
if ( c != null && !c.contains( self ) ) {
c.add( self );
}
}
@ -526,6 +577,11 @@ class CodeTemplates {
// is replaced by the actual method call
throw new AssertionError();
}
static Object getterSelf() {
// is replaced by the actual method call
throw new AssertionError();
}
}
@Retention(RetentionPolicy.RUNTIME)
@ -539,7 +595,12 @@ class CodeTemplates {
}
@Retention(RetentionPolicy.RUNTIME)
@interface MappedBy {
@interface InverseSide {
}
@Retention(RetentionPolicy.RUNTIME)
@interface BidirectionalAttribute {
}

View File

@ -0,0 +1,92 @@
/*
* Hibernate, Relational Persistence for Idiomatic Java
*
* License: GNU Lesser General Public License (LGPL), version 2.1 or later.
* See the lgpl.txt file in the root directory or <http://www.gnu.org/licenses/lgpl-2.1.html>.
*/
package org.hibernate.orm.test.bytecode.enhancement.association;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.ManyToMany;
import org.hibernate.testing.bytecode.enhancement.BytecodeEnhancerRunner;
import org.junit.Assert;
import org.junit.Test;
import org.junit.runner.RunWith;
/**
* @author Luis Barreiro
*/
@RunWith( BytecodeEnhancerRunner.class )
public class ManyToManyAssociationListTest {
@Test
public void testBidirectionalExisting() {
Group group = new Group();
Group anotherGroup = new Group();
User user = new User();
anotherGroup.users.add( user );
user.setGroups( new ArrayList<>( Collections.singleton( group ) ) );
user.setGroups( new ArrayList<>( Arrays.asList( group, anotherGroup ) ) );
Assert.assertEquals( 1, group.getUsers().size() );
Assert.assertEquals( 1, anotherGroup.getUsers().size() );
}
// -- //
@Entity
private static class Group {
@Id
Long id;
@Column
String name;
@ManyToMany( mappedBy = "groups" )
List<User> users = new ArrayList<>();
List<User> getUsers() {
return Collections.unmodifiableList( users );
}
void resetUsers() {
// this wouldn't trigger association management: users.clear();
users = new ArrayList<>();
}
}
@Entity
private static class User {
@Id
Long id;
String password;
@ManyToMany
List<Group> groups;
void addGroup(Group group) {
List<Group> groups = this.groups == null ? new ArrayList<>() : this.groups;
groups.add( group );
this.groups = groups;
}
List<Group> getGroups() {
return Collections.unmodifiableList( groups );
}
void setGroups(List<Group> groups) {
this.groups = groups;
}
}
}

View File

@ -49,6 +49,25 @@ public class OneToOneAssociationTest {
Assert.assertEquals( user, user.getCustomer().getUser() );
}
@Test
public void testSetNull() {
User user = new User();
user.setLogin( UUID.randomUUID().toString() );
Customer customer = new Customer();
customer.setUser( user );
Assert.assertEquals( customer, user.getCustomer() );
// check dirty tracking is set automatically with bi-directional association management
EnhancerTestUtils.checkDirtyTracking( user, "login", "customer" );
user.setCustomer( null );
Assert.assertNull( user.getCustomer() );
Assert.assertNull( customer.getUser() );
}
// --- //
@Entity