From 17eba45d24616081e4e57660f02af52e2bd04d2e Mon Sep 17 00:00:00 2001 From: Marco Belladelli Date: Mon, 3 Nov 2025 16:59:29 +0100 Subject: [PATCH 1/2] HHH-19905 Add test for issue --- .../query/hql/ImplicitNestedJoinTest.java | 141 ++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 hibernate-core/src/test/java/org/hibernate/orm/test/query/hql/ImplicitNestedJoinTest.java diff --git a/hibernate-core/src/test/java/org/hibernate/orm/test/query/hql/ImplicitNestedJoinTest.java b/hibernate-core/src/test/java/org/hibernate/orm/test/query/hql/ImplicitNestedJoinTest.java new file mode 100644 index 000000000000..9ffcf25827dd --- /dev/null +++ b/hibernate-core/src/test/java/org/hibernate/orm/test/query/hql/ImplicitNestedJoinTest.java @@ -0,0 +1,141 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright Red Hat Inc. and Hibernate Authors + */ +package org.hibernate.orm.test.query.hql; + +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.ManyToOne; +import org.hibernate.testing.orm.junit.DomainModel; +import org.hibernate.testing.orm.junit.Jira; +import org.hibernate.testing.orm.junit.SessionFactory; +import org.hibernate.testing.orm.junit.SessionFactoryScope; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Christian Beikov + */ +@DomainModel(annotatedClasses = { + ImplicitNestedJoinTest.RootEntity.class, + ImplicitNestedJoinTest.FirstLevelReferencedEntity.class, + ImplicitNestedJoinTest.SecondLevelReferencedEntityA.class, + ImplicitNestedJoinTest.SecondLevelReferencedEntityB.class +}) +@SessionFactory +@Jira("https://hibernate.atlassian.net/browse/HHH-19905") +public class ImplicitNestedJoinTest { + + @Test + public void testInnerAndLeftJoin(SessionFactoryScope scope) { + scope.inSession( session -> { + final var resultList = session.createQuery( + "select r.id from RootEntity r" + + " join r.firstLevelReference.secondLevelReferenceA sa " + + " left join r.firstLevelReference.secondLevelReferenceB sb", + Long.class + ).getResultList(); + assertThat( resultList ).hasSize( 2 ).containsExactlyInAnyOrder( 1L, 2L ); + } ); + } + + @Test + public void testLeftAndInnerJoin(SessionFactoryScope scope) { + scope.inSession( session -> { + final var resultList = session.createQuery( + "select r.id from RootEntity r" + + " left join r.firstLevelReference.secondLevelReferenceA sa " + + " join r.firstLevelReference.secondLevelReferenceB sb", + Long.class + ).getResultList(); + assertThat( resultList ).hasSize( 1 ).containsExactly( 1L ); + } ); + } + + @Test + public void testBothInnerJoins(SessionFactoryScope scope) { + scope.inSession( session -> { + final var resultList = session.createQuery( + "select r.id from RootEntity r" + + " join r.firstLevelReference.secondLevelReferenceA sa " + + " join r.firstLevelReference.secondLevelReferenceB sb", + Long.class + ).getResultList(); + assertThat( resultList ).hasSize( 1 ).containsExactly( 1L ); + } ); + } + + @BeforeAll + public void setUp(SessionFactoryScope scope) { + scope.inTransaction( session -> { + // create some test data : one first level reference with both second level references, and + // another first level reference with only one second level reference + SecondLevelReferencedEntityA secondLevelA = new SecondLevelReferencedEntityA(); + secondLevelA.id = 1L; + secondLevelA.name = "Second Level A"; + session.persist( secondLevelA ); + SecondLevelReferencedEntityB secondLevelB = new SecondLevelReferencedEntityB(); + secondLevelB.id = 1L; + session.persist( secondLevelB ); + FirstLevelReferencedEntity firstLevel1 = new FirstLevelReferencedEntity(); + firstLevel1.id = 1L; + firstLevel1.secondLevelReferenceA = secondLevelA; + firstLevel1.secondLevelReferenceB = secondLevelB; + session.persist( firstLevel1 ); + RootEntity root1 = new RootEntity(); + root1.id = 1L; + root1.firstLevelReference = firstLevel1; + session.persist( root1 ); + FirstLevelReferencedEntity firstLevel2 = new FirstLevelReferencedEntity(); + firstLevel2.id = 2L; + firstLevel2.secondLevelReferenceA = secondLevelA; + session.persist( firstLevel2 ); + RootEntity root2 = new RootEntity(); + root2.id = 2L; + root2.firstLevelReference = firstLevel2; + session.persist( root2 ); + } ); + } + + @AfterAll + public void tearDown(SessionFactoryScope scope) { + scope.getSessionFactory().getSchemaManager().truncateMappedObjects(); + } + + @Entity(name = "RootEntity") + public static class RootEntity { + @Id + private Long id; + + @ManyToOne + private FirstLevelReferencedEntity firstLevelReference; + } + + @Entity(name = "FirstLevelReferencedEntity") + public static class FirstLevelReferencedEntity { + @Id + private Long id; + @ManyToOne + private SecondLevelReferencedEntityA secondLevelReferenceA; + @ManyToOne + private SecondLevelReferencedEntityB secondLevelReferenceB; + + } + + @Entity(name = "SecondLevelReferencedEntityA") + public static class SecondLevelReferencedEntityA { + @Id + private Long id; + private String name; + } + + @Entity(name = "SecondLevelReferencedEntityB") + public static class SecondLevelReferencedEntityB { + @Id + private Long id; + } +} From 27d66f862378ab8f126a9deaaa440e55565b8671 Mon Sep 17 00:00:00 2001 From: Marco Belladelli Date: Mon, 3 Nov 2025 17:02:55 +0100 Subject: [PATCH 2/2] HHH-19905 Force implicit joins to inner type on creation --- .../query/hql/internal/QualifiedJoinPathConsumer.java | 11 ++++++----- .../basic/JoinFetchElementCollectionTest.java | 2 +- .../orm/test/hql/FunctionNameAsColumnTest.java | 4 ++-- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/hibernate-core/src/main/java/org/hibernate/query/hql/internal/QualifiedJoinPathConsumer.java b/hibernate-core/src/main/java/org/hibernate/query/hql/internal/QualifiedJoinPathConsumer.java index e2e57d4c943c..f86034704e30 100644 --- a/hibernate-core/src/main/java/org/hibernate/query/hql/internal/QualifiedJoinPathConsumer.java +++ b/hibernate-core/src/main/java/org/hibernate/query/hql/internal/QualifiedJoinPathConsumer.java @@ -109,6 +109,7 @@ public void consumeIdentifier(String identifier, boolean isBase, boolean isTermi assert delegate != null; delegate.consumeIdentifier( identifier, + isTerminal, !nested && isTerminal, // Non-nested joins shall allow reuse, but nested ones (i.e. in treat) // only allow join reuse for non-terminal parts @@ -254,7 +255,7 @@ else if ( fetch ) { } private interface ConsumerDelegate { - void consumeIdentifier(String identifier, boolean isTerminal, boolean allowReuse); + void consumeIdentifier(String identifier, boolean originallyTerminal, boolean isTerminal, boolean allowReuse); void consumeTreat(String typeName, boolean isTerminal); SemanticPathPart getConsumedPart(); } @@ -282,11 +283,11 @@ private AttributeJoinDelegate( } @Override - public void consumeIdentifier(String identifier, boolean isTerminal, boolean allowReuse) { + public void consumeIdentifier(String identifier, boolean originallyTerminal, boolean isTerminal, boolean allowReuse) { currentPath = createJoin( currentPath, identifier, - joinType, + originallyTerminal ? joinType : SqmJoinType.INNER, alias, fetch, isTerminal, @@ -347,11 +348,11 @@ public ExpectingEntityJoinDelegate( this.fetch = fetch; this.alias = alias; - consumeIdentifier( identifier, isTerminal, true ); + consumeIdentifier( identifier, isTerminal, isTerminal, true ); } @Override - public void consumeIdentifier(String identifier, boolean isTerminal, boolean allowReuse) { + public void consumeIdentifier(String identifier, boolean originallyTerminal, boolean isTerminal, boolean allowReuse) { if ( !path.isEmpty() ) { path.append( '.' ); } diff --git a/hibernate-core/src/test/java/org/hibernate/orm/test/collection/basic/JoinFetchElementCollectionTest.java b/hibernate-core/src/test/java/org/hibernate/orm/test/collection/basic/JoinFetchElementCollectionTest.java index a946601a8d94..1b07c6abe58a 100644 --- a/hibernate-core/src/test/java/org/hibernate/orm/test/collection/basic/JoinFetchElementCollectionTest.java +++ b/hibernate-core/src/test/java/org/hibernate/orm/test/collection/basic/JoinFetchElementCollectionTest.java @@ -58,7 +58,7 @@ public void testJoinFetchesByPath(SessionFactoryScope scope) { session -> { final String qry = "SELECT user " + "FROM User user " - + "LEFT OUTER JOIN FETCH user.contact " + + "JOIN FETCH user.contact " + "LEFT OUTER JOIN FETCH user.contact.emailAddresses2 " + "LEFT OUTER JOIN FETCH user.contact.emailAddresses"; User user = (User) session.createQuery( qry ).uniqueResult(); diff --git a/hibernate-core/src/test/java/org/hibernate/orm/test/hql/FunctionNameAsColumnTest.java b/hibernate-core/src/test/java/org/hibernate/orm/test/hql/FunctionNameAsColumnTest.java index 83ca1a2cdc4e..5b93b3c889cb 100644 --- a/hibernate-core/src/test/java/org/hibernate/orm/test/hql/FunctionNameAsColumnTest.java +++ b/hibernate-core/src/test/java/org/hibernate/orm/test/hql/FunctionNameAsColumnTest.java @@ -106,7 +106,7 @@ public void testGetMultiColumnSameNameAsArgFunctionHQL(SessionFactoryScope sessi sessionFactory.inTransaction(s -> { EntityWithFunctionAsColumnHolder holder1 = (EntityWithFunctionAsColumnHolder) s.createQuery( "from EntityWithFunctionAsColumnHolder h left join fetch h.entityWithArgFunctionAsColumns " + - "left join fetch h.nextHolder left join fetch h.nextHolder.entityWithArgFunctionAsColumns " + + "join fetch h.nextHolder left join fetch h.nextHolder.entityWithArgFunctionAsColumns " + "where h.nextHolder is not null" ) .uniqueResult(); assertTrue( Hibernate.isInitialized( holder1.getEntityWithArgFunctionAsColumns() ) ); @@ -193,7 +193,7 @@ public void testGetMultiColumnSameNameAsNoArgFunctionHQL(SessionFactoryScope fac var hql = """ from EntityWithFunctionAsColumnHolder h left join fetch h.entityWithNoArgFunctionAsColumns - left join fetch h.nextHolder + join fetch h.nextHolder left join fetch h.nextHolder.entityWithNoArgFunctionAsColumns where h.nextHolder is not null """;