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 a94ae96587a..8b478961a10 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 extends GormStaticApi { //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 00000000000..a0365a13e88 --- /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 statements = Collections.synchronizedList(new ArrayList()) + + @Override + String inspect(String sql) { + statements.add(sql) + return sql + } + + void clear() { + statements.clear() + } + } +} + +@Entity +class ExistsItem { + String name +} diff --git a/grails-test-examples/gorm/src/integration-test/groovy/gorm/ExistsSpec.groovy b/grails-test-examples/gorm/src/integration-test/groovy/gorm/ExistsSpec.groovy new file mode 100644 index 00000000000..cff30560daa --- /dev/null +++ b/grails-test-examples/gorm/src/integration-test/groovy/gorm/ExistsSpec.groovy @@ -0,0 +1,57 @@ +/* + * 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 gorm + +import grails.gorm.transactions.Rollback +import grails.testing.mixin.integration.Integration +import spock.lang.Issue +import spock.lang.Specification + +@Integration(applicationClass = Application) +@Rollback +@Issue('https://github.com/apache/grails-core/issues/14334') +class ExistsSpec extends Specification { + + void "exists returns true for persisted entity"() { + given: + Product p = new Product(isbn: '9780451524935').save(flush: true) + + expect: + Product.exists(p.id) + } + + void "exists returns false for non-existent id"() { + expect: + !Product.exists(99999) + } + + void "exists returns correct result with multiple rows in table"() { + given: + new Product(isbn: '1000000000001').save(flush: true) + new Product(isbn: '1000000000002').save(flush: true) + new Product(isbn: '1000000000003').save(flush: true) + Product target = new Product(isbn: '1000000000004').save(flush: true) + new Product(isbn: '1000000000005').save(flush: true) + + expect: + Product.exists(target.id) + !Product.exists(99999) + } +}