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
+}

Reply via email to