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