From fc6f1952a7ac0295e36538f02d13edbbd3dfe0ba Mon Sep 17 00:00:00 2001 From: Christian Beikov Date: Fri, 28 Nov 2025 17:24:39 +0100 Subject: [PATCH] HHH-19963 Only consider a ToOne be bidirectional for OneToMany if FKs are equal --- .../internal/PluralAttributeMappingImpl.java | 31 ++++- .../BidirectionalOneToManyTest.java | 122 ++++++++++++++++++ 2 files changed, 148 insertions(+), 5 deletions(-) create mode 100644 hibernate-core/src/test/java/org/hibernate/orm/test/mapping/collections/BidirectionalOneToManyTest.java diff --git a/hibernate-core/src/main/java/org/hibernate/metamodel/mapping/internal/PluralAttributeMappingImpl.java b/hibernate-core/src/main/java/org/hibernate/metamodel/mapping/internal/PluralAttributeMappingImpl.java index 6e6525df9f14..20d4f2191cb2 100644 --- a/hibernate-core/src/main/java/org/hibernate/metamodel/mapping/internal/PluralAttributeMappingImpl.java +++ b/hibernate-core/src/main/java/org/hibernate/metamodel/mapping/internal/PluralAttributeMappingImpl.java @@ -32,6 +32,7 @@ import org.hibernate.metamodel.mapping.SelectableMapping; import org.hibernate.metamodel.mapping.SoftDeleteMapping; import org.hibernate.metamodel.mapping.TableDetails; +import org.hibernate.metamodel.mapping.ValuedModelPart; import org.hibernate.metamodel.mapping.ordering.OrderByFragment; import org.hibernate.metamodel.mapping.ordering.OrderByFragmentTranslator; import org.hibernate.metamodel.mapping.ordering.TranslationContext; @@ -226,12 +227,32 @@ private static void injectAttributeMapping( @Override public boolean isBidirectionalAttributeName(NavigablePath fetchablePath, ToOneAttributeMapping modelPart) { - if ( bidirectionalAttributeName == null ) { - // If the FK-target of the to-one mapping is the same as the FK-target of this plural mapping, - // then we say this is bidirectional, given that this is only invoked for model parts of the collection elements - return fkDescriptor.getTargetPart() == modelPart.getForeignKeyDescriptor().getTargetPart(); + return bidirectionalAttributeName == null + // If the FK-target of the to-one mapping is the same as the FK-target of this one-to-many mapping, + // and the FK-key refer to the same column then we say this is bidirectional, + // given that this is only invoked for model parts of the collection elements + ? modelPart.getSideNature() == ForeignKeyDescriptor.Nature.KEY + && collectionDescriptor.isOneToMany() + && fkDescriptor.getTargetPart() == modelPart.getForeignKeyDescriptor().getTargetPart() + && areEqual( fkDescriptor.getKeyPart(), modelPart.getForeignKeyDescriptor().getKeyPart() ) + : fetchablePath.getLocalName().equals( bidirectionalAttributeName ); + } + + private boolean areEqual(ValuedModelPart part1, ValuedModelPart part2) { + final int typeCount = part1.getJdbcTypeCount(); + if ( part2.getJdbcTypeCount() != typeCount ) { + return false; } - return fetchablePath.getLocalName().endsWith( bidirectionalAttributeName ); + for ( int i = 0; i < typeCount; i++ ) { + final SelectableMapping selectable1 = part1.getSelectable( i ); + final SelectableMapping selectable2 = part2.getSelectable( i ); + if ( selectable1.getJdbcMapping() != selectable2.getJdbcMapping() + || !selectable1.getContainingTableExpression().equals( selectable2.getContainingTableExpression() ) + || !selectable1.getSelectionExpression().equals( selectable2.getSelectionExpression() ) ) { + return false; + } + } + return true; } public void finishInitialization( diff --git a/hibernate-core/src/test/java/org/hibernate/orm/test/mapping/collections/BidirectionalOneToManyTest.java b/hibernate-core/src/test/java/org/hibernate/orm/test/mapping/collections/BidirectionalOneToManyTest.java new file mode 100644 index 000000000000..b02d92f30aee --- /dev/null +++ b/hibernate-core/src/test/java/org/hibernate/orm/test/mapping/collections/BidirectionalOneToManyTest.java @@ -0,0 +1,122 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright Red Hat Inc. and Hibernate Authors + */ +package org.hibernate.orm.test.mapping.collections; + +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.JoinTable; +import jakarta.persistence.ManyToMany; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import org.hibernate.testing.orm.junit.EntityManagerFactoryScope; +import org.hibernate.testing.orm.junit.Jira; +import org.hibernate.testing.orm.junit.Jpa; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +@Jpa( annotatedClasses = {BidirectionalOneToManyTest.Organization.class, BidirectionalOneToManyTest.User.class} ) +@Jira("https://hibernate.atlassian.net/browse/HHH-19963") +public class BidirectionalOneToManyTest { + + @Test + public void testParentNotTreatedAsBidirectional(EntityManagerFactoryScope scope) { + scope.inTransaction( entityManager -> { + Organization o3 = new Organization( 3L, "o3", null, new ArrayList<>() ); + Organization o1 = new Organization( 1L, "o1", null, new ArrayList<>( Arrays.asList( o3 )) ); + Organization o2 = new Organization( 2L, "o2", o1, new ArrayList<>() ); + entityManager.persist(o3); + entityManager.persist(o1); + entityManager.persist(o2); + + User u1 = new User( 1L, o2 ); + User u2 = new User( 2L, o2 ); + entityManager.persist(u1); + entityManager.persist(u2); + }); + + scope.inTransaction( entityManager -> { + User user1 = entityManager.find(User.class, 1L); + Organization ou3 = entityManager.find(Organization.class, 3L); + assertNull( ou3.getParentOrganization(), "Parent of o3 is null"); + assertEquals(0, ou3.getPredecessorOrganizations().size(), "Predecessors of o3 is empty"); + }); + } + + @Entity(name = "Organization") + public static class Organization { + + @Id + private Long id; + private String name; + + @ManyToOne(fetch = FetchType.EAGER) + @JoinColumn(name = "parentorganization_objectId") + private Organization parentOrganization; + + @ManyToMany(fetch = FetchType.EAGER) + @JoinTable(name = "organization_predecessor") + private List predecessorOrganizations = new ArrayList<>(); + + public Organization() { + } + + public Organization(Long id, String name, Organization parentOrganization, List predecessorOrganizations) { + this.id = id; + this.name = name; + this.parentOrganization = parentOrganization; + this.predecessorOrganizations = predecessorOrganizations; + } + + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + public Organization getParentOrganization() { + return parentOrganization; + } + + public List getPredecessorOrganizations() { + return predecessorOrganizations; + } + } + + @Entity(name = "User") + @Table(name = "usr_tbl") + public static class User { + + @Id + private Long id; + @ManyToOne(fetch = FetchType.EAGER) + private Organization organization; + + public User() { + } + + public User(Long id, Organization organization) { + this.id = id; + this.organization = organization; + } + + public Long getId() { + return id; + } + + public Organization getOrganization() { + return organization; + } + } +}