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

jamesfredley pushed a commit to branch fix/exists-cross-join
in repository https://gitbox.apache.org/repos/asf/grails-core.git

commit 799f2f95b180c8dd2dab78a4f86a792d947c507a
Author: James Fredley <[email protected]>
AuthorDate: Thu Feb 19 19:20:49 2026 -0500

    Fix exists() cross-join caused by duplicate CriteriaQuery root
    
    AbstractHibernateGormStaticApi.exists() called criteriaQuery.from() twice,
    creating a second query root that produced a cartesian product. The 
generated
    SQL selected count(alias0) from Table alias1, Table alias0 where 
alias1.id=?,
    scanning the entire table for every matching row instead of a simple count.
    
    Reuse the existing queryRoot variable for the count select expression.
    
    Fixes #14334
    
    Assisted-by: Claude Code <[email protected]>
---
 .../AbstractHibernateGormStaticApi.groovy          |   2 +-
 .../orm/hibernate/ExistsCrossJoinSpec.groovy       | 122 +++++++++++++++++++++
 2 files changed, 123 insertions(+), 1 deletion(-)

diff --git 
a/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/AbstractHibernateGormStaticApi.groovy
 
b/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/AbstractHibernateGormStaticApi.groovy
index a94ae96587..8b478961a1 100644
--- 
a/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/AbstractHibernateGormStaticApi.groovy
+++ 
b/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/AbstractHibernateGormStaticApi.groovy
@@ -245,7 +245,7 @@ abstract class AbstractHibernateGormStaticApi<D> extends 
GormStaticApi<D> {
                     //TODO: Remove explicit type cast once GROOVY-9460
                     criteriaBuilder.equal((Expression<?>) idProp, id)
             )
-            
criteriaQuery.select(criteriaBuilder.count(criteriaQuery.from(persistentEntity.javaClass)))
+            criteriaQuery.select(criteriaBuilder.count(queryRoot))
             Query criteria = session.createQuery(criteriaQuery)
             HibernateHqlQuery hibernateHqlQuery = new HibernateHqlQuery(
                     hibernateSession, persistentEntity, criteria)
diff --git 
a/grails-data-hibernate5/core/src/test/groovy/org/grails/orm/hibernate/ExistsCrossJoinSpec.groovy
 
b/grails-data-hibernate5/core/src/test/groovy/org/grails/orm/hibernate/ExistsCrossJoinSpec.groovy
new file mode 100644
index 0000000000..a0365a13e8
--- /dev/null
+++ 
b/grails-data-hibernate5/core/src/test/groovy/org/grails/orm/hibernate/ExistsCrossJoinSpec.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 org.grails.orm.hibernate
+
+import grails.gorm.annotation.Entity
+import grails.gorm.transactions.Rollback
+import org.grails.datastore.mapping.core.DatastoreUtils
+import org.grails.orm.hibernate.cfg.Settings
+import org.hibernate.resource.jdbc.spi.StatementInspector
+import org.springframework.transaction.PlatformTransactionManager
+import spock.lang.AutoCleanup
+import spock.lang.Issue
+import spock.lang.Shared
+import spock.lang.Specification
+
+@Issue('https://github.com/apache/grails-core/issues/14334')
+class ExistsCrossJoinSpec extends Specification {
+
+    @Shared SqlCapture sqlCapture = new SqlCapture()
+
+    @Shared @AutoCleanup HibernateDatastore hibernateDatastore = new 
HibernateDatastore(
+            DatastoreUtils.createPropertyResolver(
+                    (Settings.SETTING_DB_CREATE): 'create-drop',
+                    'hibernate.session_factory.statement_inspector': sqlCapture
+            ),
+            ExistsItem
+    )
+    @Shared PlatformTransactionManager transactionManager = 
hibernateDatastore.getTransactionManager()
+
+    @Rollback
+    void "exists returns true for existing entity"() {
+        given:
+        ExistsItem item = new ExistsItem(name: 'alpha').save(flush: true)
+
+        expect:
+        ExistsItem.exists(item.id)
+    }
+
+    @Rollback
+    void "exists returns false for non-existent id"() {
+        expect:
+        !ExistsItem.exists(99999)
+    }
+
+    @Rollback
+    void "exists does not produce a cross-join"() {
+        given:
+        new ExistsItem(name: 'one').save(flush: true)
+        new ExistsItem(name: 'two').save(flush: true)
+        new ExistsItem(name: 'three').save(flush: true)
+
+        when:
+        sqlCapture.clear()
+        ExistsItem item = new ExistsItem(name: 'target').save(flush: true)
+        sqlCapture.clear()
+        ExistsItem.exists(item.id)
+
+        then: "the SQL should contain only a single FROM clause (no 
cross-join)"
+        sqlCapture.statements.any { it.toLowerCase().contains('select count') }
+
+        and: "there should be exactly one table reference in the FROM clause"
+        String countSql = sqlCapture.statements.find { 
it.toLowerCase().contains('select count') }
+        countSql != null
+        // A cross-join would have the table name appearing twice after 'from'
+        // e.g. "from exists_item x0_, exists_item x1_" vs correct "from 
exists_item x0_"
+        countSql.toLowerCase().split('cross join').length == 1
+        // Verify no comma-join pattern (two table aliases after FROM)
+        !countSql.toLowerCase().matches(/.*from\s+\S+\s+\S+\s*,\s*\S+\s+\S+.*/)
+    }
+
+    @Rollback
+    void "exists with multiple rows returns correct result"() {
+        given: "multiple entities in the table"
+        ExistsItem target = new ExistsItem(name: 'target').save(flush: true)
+        new ExistsItem(name: 'other1').save(flush: true)
+        new ExistsItem(name: 'other2').save(flush: true)
+        new ExistsItem(name: 'other3').save(flush: true)
+        new ExistsItem(name: 'other4').save(flush: true)
+
+        expect: "exists returns correct results"
+        ExistsItem.exists(target.id)
+        !ExistsItem.exists(99999)
+    }
+
+    /**
+     * Captures SQL statements executed by Hibernate for inspection in tests.
+     */
+    static class SqlCapture implements StatementInspector {
+        final List<String> statements = Collections.synchronizedList(new 
ArrayList<String>())
+
+        @Override
+        String inspect(String sql) {
+            statements.add(sql)
+            return sql
+        }
+
+        void clear() {
+            statements.clear()
+        }
+    }
+}
+
+@Entity
+class ExistsItem {
+    String name
+}

Reply via email to