This is an automated email from the ASF dual-hosted git repository. jamesfredley pushed a commit to branch fix/where-query-bugs-2 in repository https://gitbox.apache.org/repos/asf/grails-core.git
commit f8738317c8fda9fadab8c46bba59f498bfaa237e Author: James Fredley <[email protected]> AuthorDate: Wed Feb 25 22:34:51 2026 -0500 fix: propagate LEFT JOIN from DetachedCriteria into subqueries (#14485) populateHibernateDetachedCriteria only copied criteria and projections from a DetachedCriteria when converting to a Hibernate DetachedCriteria for subqueries, ignoring fetchStrategies and joinTypes. This caused LEFT JOIN to be silently downgraded to INNER JOIN in subqueries like: Author.where { 'in'('id', Author.where { join('books', JoinType.LEFT) books { isNull('name') } }.id()) } Now applies fetchStrategies/joinTypes to the HibernateQuery before processing criteria, matching the pattern in DynamicFinder.applyDetachedCriteria. Assisted-by: Claude Code <[email protected]> Fixes #14485 --- .../grails/orm/HibernateCriteriaBuilder.java | 26 +++++ .../grails/gorm/tests/WhereQueryBugFixSpec.groovy | 122 +++++++++++++++++++++ 2 files changed, 148 insertions(+) diff --git a/grails-data-hibernate5/core/src/main/groovy/grails/orm/HibernateCriteriaBuilder.java b/grails-data-hibernate5/core/src/main/groovy/grails/orm/HibernateCriteriaBuilder.java index 06b9c83134..c7c102a8c2 100644 --- a/grails-data-hibernate5/core/src/main/groovy/grails/orm/HibernateCriteriaBuilder.java +++ b/grails-data-hibernate5/core/src/main/groovy/grails/orm/HibernateCriteriaBuilder.java @@ -20,11 +20,13 @@ package grails.orm; import java.util.List; import java.util.Map; +import java.util.Map.Entry; import groovy.lang.GroovySystem; import jakarta.persistence.metamodel.Attribute; import jakarta.persistence.metamodel.PluralAttribute; +import jakarta.persistence.FetchType; import org.hibernate.Criteria; import org.hibernate.SessionFactory; @@ -41,6 +43,7 @@ import org.springframework.transaction.support.TransactionSynchronizationManager import org.grails.datastore.mapping.model.PersistentEntity; import org.grails.datastore.mapping.query.api.QueryableCriteria; +import org.grails.datastore.gorm.query.criteria.AbstractDetachedCriteria; import org.grails.orm.hibernate.GrailsHibernateTemplate; import org.grails.orm.hibernate.HibernateDatastore; import org.grails.orm.hibernate.cfg.GrailsHibernateUtil; @@ -234,6 +237,28 @@ public class HibernateCriteriaBuilder extends AbstractHibernateCriteriaBuilder { } private static void populateHibernateDetachedCriteria(AbstractHibernateQuery query, org.hibernate.criterion.DetachedCriteria detachedCriteria, QueryableCriteria<?> queryableCriteria) { + if (queryableCriteria instanceof AbstractDetachedCriteria) { + AbstractDetachedCriteria<?> abstractDetachedCriteria = (AbstractDetachedCriteria<?>) queryableCriteria; + Map<String, FetchType> fetchStrategies = abstractDetachedCriteria.getFetchStrategies(); + for (Entry<String, FetchType> entry : fetchStrategies.entrySet()) { + String property = entry.getKey(); + switch (entry.getValue()) { + case EAGER: + jakarta.persistence.criteria.JoinType gormJoinType = abstractDetachedCriteria.getJoinTypes().get(property); + if (gormJoinType != null) { + query.join(property, gormJoinType); + } + else { + query.join(property); + } + break; + case LAZY: + query.select(property); + break; + } + } + } + List<org.grails.datastore.mapping.query.Query.Criterion> criteriaList = queryableCriteria.getCriteria(); for (org.grails.datastore.mapping.query.Query.Criterion criterion : criteriaList) { Criterion hibernateCriterion = HibernateQuery.HIBERNATE_CRITERION_ADAPTER.toHibernateCriterion(query, criterion, null); @@ -253,6 +278,7 @@ public class HibernateCriteriaBuilder extends AbstractHibernateCriteriaBuilder { detachedCriteria.setProjection(projectionList); } + /** * Closes the session if it is copen */ diff --git a/grails-data-hibernate5/core/src/test/groovy/grails/gorm/tests/WhereQueryBugFixSpec.groovy b/grails-data-hibernate5/core/src/test/groovy/grails/gorm/tests/WhereQueryBugFixSpec.groovy new file mode 100644 index 0000000000..5e6baadcad --- /dev/null +++ b/grails-data-hibernate5/core/src/test/groovy/grails/gorm/tests/WhereQueryBugFixSpec.groovy @@ -0,0 +1,122 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package grails.gorm.tests + +import grails.gorm.DetachedCriteria +import grails.gorm.annotation.Entity +import grails.gorm.hibernate.HibernateEntity +import org.grails.orm.hibernate.HibernateDatastore +import org.springframework.transaction.PlatformTransactionManager +import spock.lang.AutoCleanup +import spock.lang.Issue +import spock.lang.Shared +import spock.lang.Specification + +import grails.gorm.transactions.Rollback + +import jakarta.persistence.criteria.JoinType + +/** + * Tests for where-query bug fixes in PR 2. + */ +class WhereQueryBugFixSpec extends Specification { + + @Shared @AutoCleanup HibernateDatastore datastore = new HibernateDatastore( + WqAuthor, WqBookItem, WqCase, WqEvent, WqPerson + ) + @Shared PlatformTransactionManager transactionManager = datastore.transactionManager + + @Rollback + @Issue("https://github.com/apache/grails-core/issues/14485") + def "#14485 - LEFT JOIN in DetachedCriteria subquery should not be downgraded to INNER JOIN"() { + given: "authors with and without books" + def authorWithBooks = new WqAuthor(name: 'Author A').save(flush: true, failOnError: true) + def authorNoBooks = new WqAuthor(name: 'Author B').save(flush: true, failOnError: true) + def authorWithBio = new WqAuthor(name: 'Author C').save(flush: true, failOnError: true) + new WqBookItem(title: 'Novel', wqAuthor: authorWithBooks).save(flush: true, failOnError: true) + new WqBookItem(title: 'Biography', wqAuthor: authorWithBio).save(flush: true, failOnError: true) + + when: "querying authors using a subquery with LEFT JOIN on books" + def subquery = WqAuthor.where { + join('wqBookItems', JoinType.LEFT) + wqBookItems { + or { + isNull('title') + ilike('title', '%biography%') + } + } + }.id() + + def results = WqAuthor.where { + 'in'('id', subquery) + }.list() + + then: "both authors without books (NULL from LEFT JOIN) and with biography are found" + results.size() == 2 + results*.name.sort() == ['Author B', 'Author C'] + } + + @Rollback + @Issue("https://github.com/apache/grails-core/issues/14485") + def "#14485 - direct LEFT JOIN where query returns authors without books"() { + given: "authors with and without books" + def authorWithBooks = new WqAuthor(name: 'Writer A').save(flush: true, failOnError: true) + def authorNoBooks = new WqAuthor(name: 'Writer B').save(flush: true, failOnError: true) + new WqBookItem(title: 'A Novel', wqAuthor: authorWithBooks).save(flush: true, failOnError: true) + + when: "querying with LEFT JOIN directly (not as subquery)" + def results = WqAuthor.where { + join('wqBookItems', JoinType.LEFT) + wqBookItems { + isNull('title') + } + }.list() + + then: "author without books is found via LEFT JOIN null match" + results.size() == 1 + results[0].name == 'Writer B' + } +} + +@Entity +class WqAuthor implements HibernateEntity<WqAuthor> { + String name + static hasMany = [wqBookItems: WqBookItem] +} + +@Entity +class WqBookItem implements HibernateEntity<WqBookItem> { + String title + static belongsTo = [wqAuthor: WqAuthor] +} + +@Entity +class WqCase implements HibernateEntity<WqCase> { + WqPerson person +} + +@Entity +class WqEvent implements HibernateEntity<WqEvent> { + WqCase generalCase +} + +@Entity +class WqPerson implements HibernateEntity<WqPerson> { + String fullname +}
