diff --git a/hibernate-core/src/main/java/org/hibernate/query/sqm/sql/BaseSqmToSqlAstConverter.java b/hibernate-core/src/main/java/org/hibernate/query/sqm/sql/BaseSqmToSqlAstConverter.java index 46e050d7e66e..d03270260705 100644 --- a/hibernate-core/src/main/java/org/hibernate/query/sqm/sql/BaseSqmToSqlAstConverter.java +++ b/hibernate-core/src/main/java/org/hibernate/query/sqm/sql/BaseSqmToSqlAstConverter.java @@ -8366,6 +8366,15 @@ else if ( getLoadQueryInfluencers().hasEnabledFetchProfiles() ) { } else { tableGroup = compatibleTableGroup; + + if ( joinProducer instanceof PluralAttributeMapping attributeMapping ) { + if ( attributeMapping.getOrderByFragment() != null ) { + applyOrdering( tableGroup, attributeMapping.getOrderByFragment() ); + } + if ( attributeMapping.getManyToManyOrderByFragment() != null ) { + applyOrdering( tableGroup, attributeMapping.getManyToManyOrderByFragment() ); + } + } } // and return the joined group diff --git a/hibernate-core/src/test/java/org/hibernate/orm/test/entitygraph/FetchGraphCollectionOrderByAndCriteriaJoinTest.java b/hibernate-core/src/test/java/org/hibernate/orm/test/entitygraph/FetchGraphCollectionOrderByAndCriteriaJoinTest.java new file mode 100644 index 000000000000..6975a030612f --- /dev/null +++ b/hibernate-core/src/test/java/org/hibernate/orm/test/entitygraph/FetchGraphCollectionOrderByAndCriteriaJoinTest.java @@ -0,0 +1,327 @@ +package org.hibernate.orm.test.entitygraph; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.NamedAttributeNode; +import jakarta.persistence.NamedEntityGraph; +import jakarta.persistence.NamedEntityGraphs; +import jakarta.persistence.NamedSubgraph; +import jakarta.persistence.OneToMany; +import jakarta.persistence.OrderBy; +import jakarta.persistence.criteria.JoinType; +import jakarta.persistence.criteria.Predicate; +import org.hibernate.Hibernate; +import org.hibernate.graph.spi.RootGraphImplementor; +import org.hibernate.query.Query; +import org.hibernate.query.criteria.HibernateCriteriaBuilder; +import org.hibernate.query.criteria.JpaCriteriaQuery; +import org.hibernate.query.criteria.JpaJoin; +import org.hibernate.query.criteria.JpaRoot; +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 java.util.ArrayList; +import java.util.Iterator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Random; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author baranyit@gmail.com + */ +@DomainModel(annotatedClasses = { + FetchGraphCollectionOrderByAndCriteriaJoinTest.Level1.class, + FetchGraphCollectionOrderByAndCriteriaJoinTest.Level2.class, + FetchGraphCollectionOrderByAndCriteriaJoinTest.Level3.class, +}) +@SessionFactory +@Jira( "https://hibernate.atlassian.net/browse/HHH-19207" ) +public class FetchGraphCollectionOrderByAndCriteriaJoinTest { + + @Test + public void testJoinAndFilter(SessionFactoryScope scope) { + executeTest( scope, true, true ); + } + + @Test + public void testNotJoinAndNotFilter(SessionFactoryScope scope) { + executeTest( scope, false, false ); + } + + /** + * This case describes the problem of using a fetch graph with a collection that has an @OrderBy clause + * and a criteria join without any usage. + *

+ * This test case is expected to fail because the @OrderBy is not applied to the collection in the + * generated SQL query. + *

+ * The issue can also be solved by optimizing the criteria definition like in the test case + * testJoinAndFilter or testNotJoinAndNotFilter, but there are some program code + * structures where it is not possible to do it, or makes the source code more complex and less readable. + *

+ * The required and the logical behaviour should be that the @OrderBy clause is applied to the + * collection as in the other test cases. If this problem occurs very difficult to find out the reason because + * this behaviour is not documented and the source code looks correct. + */ + @Test + public void testJoinAndNotFilter(SessionFactoryScope scope) { + executeTest( scope, true, false ); + } + + + private void executeTest(SessionFactoryScope scope, boolean directJoin, boolean filterOnJoin) { + scope.inTransaction( session -> { + HibernateCriteriaBuilder builder = session.getCriteriaBuilder(); + JpaCriteriaQuery criteriaQuery = builder.createQuery( Level1.class ); + JpaRoot root = criteriaQuery.from( Level1.class ); + + List predicates = new ArrayList<>(); + predicates.add( + builder.equal( root.get( "id" ), 1L ) + ); + + if ( directJoin || filterOnJoin ) { + // Directly add the join to the level2 and level3 entities + JpaJoin join = root.join( "children", JoinType.INNER ) + .join( "children", JoinType.LEFT ); + + if ( filterOnJoin ) { + predicates.add( + builder.gt( join.get( "id" ), 1L ) + ); + } + } + + // Add all defined predicates to the criteria query + criteriaQuery.where( builder.and( predicates ) ); + + // Set some default root ordering (not required for the test case) + criteriaQuery.orderBy( builder.asc( root.get( "id" ) ) ); + + // Create the TypedQuery with entity graph + RootGraphImplementor graph = session.getEntityGraph( "level1_loadAll" ); + Query query = session + .createQuery( criteriaQuery ) + .setHint( org.hibernate.jpa.SpecHints.HINT_SPEC_FETCH_GRAPH, graph ); + + // Parse the result as stream, but the problem occurs also with getResultList() + query.getResultStream().forEach( level1 -> { + + // Check ordering of Level2 entities + Long ordinalLevel2 = 0L; + assertThat( level1.getChildren() ).matches( Hibernate::isInitialized ); + for ( Level2 level2 : level1.getChildren() ) { + System.out.println( "Level2: " + level2.getOrdinal() ); + assertThat( level2.getOrdinal() ).isGreaterThan( ordinalLevel2 ); + ordinalLevel2 = level2.getOrdinal(); + + // Check ordering of Level3 entities + Long ordinalLevel3 = 0L; + assertThat( level2.getChildren() ).matches( Hibernate::isInitialized ); + for ( Level3 level3 : level2.getChildren() ) { + System.out.println( "Level3: " + level3.getOrdinal() ); + assertThat( level3.getOrdinal() ).isGreaterThan( ordinalLevel3 ); + ordinalLevel3 = level3.getOrdinal(); + } + } + } ); + } ); + } + + @BeforeAll + public void setUp(SessionFactoryScope scope) { + scope.inTransaction( session -> { + + final Iterator randomOrdinals = new Random().longs( 100, 999 ) + .distinct().limit( 200 ).boxed().iterator(); + + for ( long l1 = 1; l1 <= 5; l1++ ) { + final Level1 root = new Level1( l1 ); + + for ( long l2 = 1; l2 <= 5; l2++ ) { + final long l2Id = (l1 * 10) + l2; + final Level2 child2 = new Level2( root, l2Id, randomOrdinals.next() ); + + for ( long l3 = 1; l3 <= 5; l3++ ) { + final long l3Id = (l2Id * 10) + l3; + new Level3( child2, l3Id, randomOrdinals.next() ); + } + } + session.persist( root ); + } + } ); + } + + @AfterAll + public void tearDown(SessionFactoryScope scope) { + scope.getSessionFactory().getSchemaManager().truncateMappedObjects(); + } + + @Entity(name = "Level1") + @NamedEntityGraphs({ + @NamedEntityGraph( + name = "level1_loadAll", + attributeNodes = { + @NamedAttributeNode(value = "children", subgraph = "subgraph.children") + }, + subgraphs = { + @NamedSubgraph( + name = "subgraph.children", + attributeNodes = { + @NamedAttributeNode(value = "children") + } + ) + } + ) + }) + static class Level1 { + @Id + private Long id; + + @OneToMany(fetch = FetchType.LAZY, + mappedBy = "parent", + cascade = CascadeType.PERSIST) + @OrderBy("ordinal") + private Set children = new LinkedHashSet<>(); + + public Level1() { + } + + public Level1(Long id) { + this.id = id; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Set getChildren() { + return children; + } + + @Override + public String toString() { + return "Level1 #" + id; + } + } + + @Entity(name = "Level2") + static class Level2 { + @Id + Long id; + + Long ordinal; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "parent_id") + private Level1 parent; + + @OneToMany(fetch = FetchType.LAZY, + mappedBy = "parent", + cascade = CascadeType.PERSIST) + @OrderBy("ordinal") + private Set children = new LinkedHashSet<>(); + + public Level2() { + } + + public Level2(Level1 parent, Long id, Long ordinal) { + this.parent = parent; + this.id = id; + this.ordinal = ordinal; + parent.getChildren().add( this ); + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Level1 getParent() { + return parent; + } + + public void setParent(Level1 parent) { + this.parent = parent; + } + + public Set getChildren() { + return children; + } + + public Long getOrdinal() { + return ordinal; + } + + @Override + public String toString() { + return "Level1 #" + id + " $" + ordinal; + } + } + + @Entity(name = "Level3") + static class Level3 { + @Id + Long id; + + Long ordinal; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "parent_id") + private Level2 parent; + + public Level3() { + } + + public Level3(Level2 parent, Long id, Long ordinal) { + this.parent = parent; + this.id = id; + this.ordinal = ordinal; + parent.getChildren().add( this ); + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Level2 getParent() { + return parent; + } + + public void setParent(Level2 parent) { + this.parent = parent; + } + + public Long getOrdinal() { + return ordinal; + } + + @Override + public String toString() { + return "Level3 #" + id + " $" + ordinal; + } + } +}