This is an automated email from the ASF dual-hosted git repository.

jdaugherty pushed a commit to branch bug-detached-criteria-distinct
in repository https://gitbox.apache.org/repos/asf/grails-core.git

commit 3b02911e65d2b76e0381e52323fc0770fd906e57
Author: James Daugherty <[email protected]>
AuthorDate: Wed Mar 11 08:26:29 2026 -0400

    fix #15491 - Bug: DetachedCriteria.distinct().property() projections 
exclude rows where the projected property value is null
---
 .../hibernate/query/AbstractHibernateQuery.java    |   8 +-
 ...hedCriteriaProjectionNullAssociationSpec.groovy | 136 +++++++++++++++++++++
 2 files changed, 143 insertions(+), 1 deletion(-)

diff --git 
a/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/query/AbstractHibernateQuery.java
 
b/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/query/AbstractHibernateQuery.java
index 8bb56963c5..80e69b959f 100644
--- 
a/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/query/AbstractHibernateQuery.java
+++ 
b/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/query/AbstractHibernateQuery.java
@@ -708,7 +708,13 @@ public abstract class AbstractHibernateQuery extends Query 
{
             }
             associationPath.append(token);
 
-            CriteriaAndAlias criteriaAndAlias = 
getOrCreateAlias(associationPath.toString(), generateAlias(token));
+            // Use LEFT JOIN for auto-created projection aliases so that rows
+            // with null associations are preserved in the result set.
+            String path = associationPath.toString();
+            if (!joinTypes.containsKey(path)) {
+                joinTypes.put(path, JoinType.LEFT);
+            }
+            CriteriaAndAlias criteriaAndAlias = getOrCreateAlias(path, 
generateAlias(token));
             if (criteriaAndAlias == null) {
                 return calculatePropertyName(propertyName);
             }
diff --git 
a/grails-data-hibernate5/core/src/test/groovy/grails/gorm/tests/DetachedCriteriaProjectionNullAssociationSpec.groovy
 
b/grails-data-hibernate5/core/src/test/groovy/grails/gorm/tests/DetachedCriteriaProjectionNullAssociationSpec.groovy
new file mode 100644
index 0000000000..68df6902e9
--- /dev/null
+++ 
b/grails-data-hibernate5/core/src/test/groovy/grails/gorm/tests/DetachedCriteriaProjectionNullAssociationSpec.groovy
@@ -0,0 +1,136 @@
+/*
+ *  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.transactions.Rollback
+import grails.gorm.transactions.Transactional
+import org.grails.orm.hibernate.HibernateDatastore
+import org.springframework.transaction.PlatformTransactionManager
+import spock.lang.AutoCleanup
+import spock.lang.Shared
+import spock.lang.Specification
+
+/**
+ * Tests that DetachedCriteria projections on nullable association properties
+ * correctly include rows where the association is null.
+ */
+class DetachedCriteriaProjectionNullAssociationSpec extends Specification {
+
+    @Shared @AutoCleanup HibernateDatastore datastore = new 
HibernateDatastore(Shipment, Warehouse)
+    @Shared PlatformTransactionManager transactionManager = 
datastore.getTransactionManager()
+
+    @Transactional
+    def setup() {
+        Shipment.findAll().each { it.delete() }
+        Warehouse.findAll().each { it.delete(flush: true) }
+    }
+
+    @Rollback
+    def 'distinct projection on nullable association property includes rows 
with null association'() {
+        given: 'shipments with and without a warehouse'
+        def warehouse = new Warehouse(name: 'Main').save(flush: true)
+        new Shipment(description: 'With warehouse', warehouse: 
warehouse).save(flush: true)
+        new Shipment(description: 'No warehouse', warehouse: null).save(flush: 
true)
+
+        when: 'projecting distinct warehouse IDs'
+        def results = new DetachedCriteria(Shipment).build {
+            projections {
+                distinct('warehouse.id')
+            }
+        }.list()
+
+        then: 'both the warehouse ID and null should be returned'
+        results.size() == 2
+        results.contains(warehouse.id)
+        results.contains(null)
+    }
+
+    @Rollback
+    def 'property projection on nullable association includes rows with null 
association'() {
+        given: 'shipments with and without a warehouse'
+        def warehouse = new Warehouse(name: 'Central').save(flush: true)
+        new Shipment(description: 'Has warehouse', warehouse: 
warehouse).save(flush: true)
+        new Shipment(description: 'Missing warehouse', warehouse: 
null).save(flush: true)
+
+        when: 'projecting warehouse IDs without distinct'
+        def results = new DetachedCriteria(Shipment).build {
+            projections {
+                property('warehouse.id')
+            }
+        }.list()
+
+        then: 'both values should be returned including null'
+        results.size() == 2
+        results.contains(warehouse.id)
+        results.contains(null)
+    }
+
+    @Rollback
+    def 'distinct id with property projection on nullable association includes 
null rows'() {
+        given: 'shipments with and without a warehouse'
+        def warehouse = new Warehouse(name: 'North').save(flush: true)
+        new Shipment(description: 'Assigned', warehouse: 
warehouse).save(flush: true)
+        new Shipment(description: 'Unassigned', warehouse: null).save(flush: 
true)
+
+        when: 'using distinct on id and property on nullable association'
+        def results = Shipment.where 
{}.distinct('id').property('warehouse.id').list()
+
+        then: 'all rows should be returned'
+        results.size() == 2
+    }
+
+    @Rollback
+    def 'multiple projections with nullable association property preserve null 
rows'() {
+        given: 'shipments with and without a warehouse'
+        def warehouse = new Warehouse(name: 'South').save(flush: true)
+        new Shipment(description: 'Stored', warehouse: warehouse).save(flush: 
true)
+        new Shipment(description: 'In transit', warehouse: null).save(flush: 
true)
+
+        when: 'projecting both id and nullable warehouse.id'
+        def results = new DetachedCriteria(Shipment).build {
+            projections {
+                property('id')
+                property('warehouse.id')
+            }
+        }.list()
+
+        then: 'both rows should be returned'
+        results.size() == 2
+        def warehouseIds = results.collect { it[1] }
+        warehouseIds.contains(warehouse.id)
+        warehouseIds.contains(null)
+    }
+}
+
+@Entity
+class Shipment implements Serializable {
+    String description
+    Warehouse warehouse
+
+    static constraints = {
+        warehouse nullable: true
+    }
+}
+
+@Entity
+class Warehouse implements Serializable {
+    String name
+}

Reply via email to