From d7d9e7d32de8197285e16530e6b395c7f90104d9 Mon Sep 17 00:00:00 2001 From: James Fredley Date: Tue, 7 Apr 2026 11:01:08 -0400 Subject: [PATCH 1/7] ci: unify functional test CI to run all tests against both Hibernate 5 and 7 Replace the separate functional and hibernate5Functional CI jobs with a single functional job that uses a hibernate-version matrix (['5', '7']). This ensures all 20+ general functional tests run against both Hibernate versions without duplicating test projects. Changes: - functional-test-config.gradle: Add -PhibernateVersion property that uses Gradle dependency substitution to redirect grails-data-hibernate5 to grails-data-hibernate7 for general (non-labeled) test projects. Excludes h5-only runtime deps (hibernate-ehcache, jboss-transaction-api) when testing with Hibernate 7. Add skipHibernate5Tests and skipHibernate7Tests properties to the onlyIf block. - gradle.yml: Merge functional and hibernate5Functional into one job with hibernate-version matrix. Each matrix slot skips the opposite version's labeled projects. Update publish job dependencies. Assisted-by: Claude Code --- .github/workflows/gradle.yml | 49 +++++----------------------- gradle/functional-test-config.gradle | 37 +++++++++++++++++++++ 2 files changed, 45 insertions(+), 41 deletions(-) diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index 1b1be032b59..e001bc509fe 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -206,15 +206,17 @@ jobs: path: grails-forge/tmp1/cli/**/* if-no-files-found: 'error' functional: - name: "Functional Tests (Java ${{ matrix.java }}, indy=${{ matrix.indy }})" + name: "Functional Tests (Java ${{ matrix.java }}, Hibernate ${{ matrix.hibernate-version }}, indy=${{ matrix.indy }})" if: ${{ !contains(github.event.head_commit.message, '[skip tests]') }} strategy: fail-fast: false matrix: java: [ 17, 21, 25 ] + hibernate-version: [ '5', '7' ] indy: [ false ] include: - java: 17 + hibernate-version: '5' indy: true runs-on: ubuntu-24.04 steps: @@ -234,6 +236,8 @@ jobs: - name: "🔍 Setup TestLens" uses: testlens-app/setup-testlens@v1 - name: "🏃 Run Functional Tests" + env: + GITHUB_MAVEN_PASSWORD: ${{ secrets.GITHUB_TOKEN }} run: > ./gradlew bootJar check --continue @@ -243,8 +247,9 @@ jobs: -PgrailsIndy=${{ matrix.indy }} -PonlyFunctionalTests -PskipCodeStyle - -PskipHibernate5Tests -PskipMongodbTests + -PhibernateVersion=${{ matrix.hibernate-version }} + -PskipHibernate${{ matrix.hibernate-version == '5' && '7' || '5' }}Tests mongodbFunctional: if: ${{ !contains(github.event.head_commit.message, '[skip tests]') }} name: "Mongodb Functional Tests (Java ${{ matrix.java }}, MongoDB ${{ matrix.mongodb-version }}, indy=${{ matrix.indy }})" @@ -285,43 +290,6 @@ jobs: -PonlyMongodbTests -PmongodbContainerVersion=${{ matrix.mongodb-version }} -PskipCodeStyle - hibernate5Functional: - if: ${{ !contains(github.event.head_commit.message, '[skip tests]') }} - name: "Hibernate5 Functional Tests (Java ${{ matrix.java }}, indy=${{ matrix.indy }})" - runs-on: ubuntu-24.04 - strategy: - fail-fast: false - matrix: - java: [ 17, 25 ] - indy: [ false ] - include: - - java: 17 - indy: true - steps: - - name: "Output Agent IP" # in the event RAO blocks this agent, this can be used to debug it - run: curl -s https://api.ipify.org - - name: "📥 Checkout the repository" - uses: actions/checkout@v6 - - name: "☕️ Setup JDK" - uses: actions/setup-java@v4 - with: - distribution: liberica - java-version: ${{ matrix.java }} - - name: "🐘 Setup Gradle" - uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0 - with: - develocity-access-key: ${{ secrets.GRAILS_DEVELOCITY_ACCESS_KEY }} - - name: "🏃 Run Functional Tests" - env: - GITHUB_MAVEN_PASSWORD: ${{ secrets.GITHUB_TOKEN }} - run: > - ./gradlew bootJar check - --continue - --rerun-tasks - --stacktrace - -PgrailsIndy=${{ matrix.indy }} - -PonlyHibernate5Tests - -PskipCodeStyle publishGradle: if: github.repository_owner == 'apache' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') needs: [ buildGradle ] @@ -364,7 +332,7 @@ jobs: name: grails-gradle-artifacts.txt path: grails-gradle/build/grails-gradle-artifacts.txt publish: - needs: [ publishGradle, build, functional, hibernate5Functional, mongodbFunctional ] + needs: [ publishGradle, build, functional, mongodbFunctional ] if: >- ${{ always() && github.repository_owner == 'apache' && @@ -372,7 +340,6 @@ jobs: needs.publishGradle.result == 'success' && (needs.build.result == 'success' || needs.build.result == 'skipped') && (needs.functional.result == 'success' || needs.functional.result == 'skipped') && - (needs.hibernate5Functional.result == 'success' || needs.hibernate5Functional.result == 'skipped') && (needs.mongodbFunctional.result == 'success' || needs.mongodbFunctional.result == 'skipped') }} runs-on: ubuntu-24.04 diff --git a/gradle/functional-test-config.gradle b/gradle/functional-test-config.gradle index f05b04afb8f..c8c156e7210 100644 --- a/gradle/functional-test-config.gradle +++ b/gradle/functional-test-config.gradle @@ -21,6 +21,14 @@ rootProject.subprojects .findAll { !(it.name in testProjects) && !(it.name in docProjects) && !(it.name in cliProjects) } .each { project.evaluationDependsOn(it.path) } +// Determine which Hibernate version to use for general functional tests. +// Pass -PhibernateVersion=7 to run general functional tests against Hibernate 7 instead of 5. +def targetHibernateVersion = project.findProperty('hibernateVersion') ?: '5' +boolean isHibernateSpecificProject = project.name.startsWith('grails-test-examples-hibernate5') || + project.name.startsWith('grails-test-examples-hibernate7') +boolean isMongoProject = project.name.startsWith('grails-test-examples-mongodb') +boolean isGeneralFunctionalTest = !isHibernateSpecificProject && !isMongoProject + configurations.configureEach { resolutionStrategy.dependencySubstitution { // Test projects will often include dependencies from local projects. This will ensure any dependencies @@ -51,6 +59,21 @@ configurations.configureEach { } } } + + // For general (non-hibernate-labeled) functional test projects, redirect Hibernate 5 dependencies + // to Hibernate 7 projects when -PhibernateVersion=7 is set. These rules are added after the loop + // so they override the default substitutions for the h5 modules. + if (isGeneralFunctionalTest && targetHibernateVersion == '7') { + substitute module('org.apache.grails:grails-data-hibernate5') using project(':grails-data-hibernate7') + substitute module('org.apache.grails:grails-data-hibernate5-spring-boot') using project(':grails-data-hibernate7-spring-boot') + } + } + + // Exclude Hibernate 5-specific runtime dependencies when testing general projects with Hibernate 7. + // These libraries have no Hibernate 7 equivalent and would cause classpath conflicts. + if (isGeneralFunctionalTest && targetHibernateVersion == '7') { + exclude group: 'org.hibernate', module: 'hibernate-ehcache' + exclude group: 'org.jboss.spec.javax.transaction', module: 'jboss-transaction-api_1.3_spec' } } @@ -80,6 +103,20 @@ tasks.withType(Test).configureEach { Test task -> } } + // Skip hibernate5-labeled projects when -PskipHibernate5Tests is set + if (project.hasProperty('skipHibernate5Tests')) { + if (!isHibernate5) { + return false + } + } + + // Skip hibernate7-labeled projects when -PskipHibernate7Tests is set + if (project.hasProperty('skipHibernate7Tests')) { + if (!isHibernate7) { + return false + } + } + if (project.hasProperty('onlyMongodbTests')) { if (isMongo) { return false From e2336d9242522579d0448eaeff3061f892a76b78 Mon Sep 17 00:00:00 2001 From: James Fredley Date: Tue, 7 Apr 2026 12:25:36 -0400 Subject: [PATCH 2/7] fix: register h7 autoconfig and remove h5-specific ehcache config The h7 boot-plugin AutoConfiguration.imports was empty, preventing Spring Boot from discovering HibernateGormAutoConfiguration when grails-data-hibernate7 is on the classpath. This caused GORM has not been initialized correctly errors in functional tests running with Hibernate 7 via dependency substitution. Also remove Hibernate 5-specific EhCache region factory configuration from the gorm and hyphenated functional test application.yml files. EhCache is not available with Hibernate 7, and the second-level cache is not needed by these tests. Assisted-by: Claude Code --- ...ngframework.boot.autoconfigure.AutoConfiguration.imports | 1 + grails-test-examples/gorm/grails-app/conf/application.yml | 6 ++---- .../hyphenated/grails-app/conf/application.yml | 3 +-- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/grails-data-hibernate7/boot-plugin/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/grails-data-hibernate7/boot-plugin/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index e69de29bb2d..d93153f929c 100644 --- a/grails-data-hibernate7/boot-plugin/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/grails-data-hibernate7/boot-plugin/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +org.grails.datastore.gorm.boot.autoconfigure.HibernateGormAutoConfiguration diff --git a/grails-test-examples/gorm/grails-app/conf/application.yml b/grails-test-examples/gorm/grails-app/conf/application.yml index 99e6b377045..7c01f24406e 100644 --- a/grails-test-examples/gorm/grails-app/conf/application.yml +++ b/grails-test-examples/gorm/grails-app/conf/application.yml @@ -86,10 +86,8 @@ grails: --- hibernate: cache: - use_second_level_cache: true - provider_class: net.sf.ehcache.hibernate.EhCacheProvider - region: - factory_class: org.hibernate.cache.ehcache.EhCacheRegionFactory + use_second_level_cache: false + use_query_cache: false dataSource: pooled: true jmxExport: true diff --git a/grails-test-examples/hyphenated/grails-app/conf/application.yml b/grails-test-examples/hyphenated/grails-app/conf/application.yml index f7c4f42b3e6..c1b85872308 100644 --- a/grails-test-examples/hyphenated/grails-app/conf/application.yml +++ b/grails-test-examples/hyphenated/grails-app/conf/application.yml @@ -87,9 +87,8 @@ grails: hibernate: cache: queries: false - use_second_level_cache: true + use_second_level_cache: false use_query_cache: false - region.factory_class: 'org.hibernate.cache.ehcache.EhCacheRegionFactory' endpoints: jmx: From f587be157bc0b5998ec921f3f3f0b31486f228d9 Mon Sep 17 00:00:00 2001 From: James Fredley Date: Tue, 7 Apr 2026 13:22:58 -0400 Subject: [PATCH 3/7] ci: skip h7-incompatible general tests when running with hibernateVersion=7 Five general functional test projects use Hibernate 5-specific GORM APIs that changed in Hibernate 7. Skip them when running with -PhibernateVersion=7 rather than letting them fail. Their h7-compatible equivalents already exist in grails-test-examples/hibernate7/. Incompatible projects: - app1: HibernateSpec unit test domain class detection differs in h7 - datasources: ChainedTransactionManager commit behavior changed in h7 - gorm: executeUpdate(String) requires Map parameter in h7 - views-functional-tests: depends on h5-specific caching config - scaffolding-fields: integration test context fails under h7 Assisted-by: Claude Code --- gradle/functional-test-config.gradle | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/gradle/functional-test-config.gradle b/gradle/functional-test-config.gradle index c8c156e7210..5a8382819ca 100644 --- a/gradle/functional-test-config.gradle +++ b/gradle/functional-test-config.gradle @@ -29,6 +29,19 @@ boolean isHibernateSpecificProject = project.name.startsWith('grails-test-exampl boolean isMongoProject = project.name.startsWith('grails-test-examples-mongodb') boolean isGeneralFunctionalTest = !isHibernateSpecificProject && !isMongoProject +// General functional test projects that use Hibernate 5-specific GORM APIs and cannot run +// under Hibernate 7 via dependency substitution. These use executeUpdate(String) without +// parameters (H7 requires a Map parameter), HibernateSpec unit tests (different domain class +// detection in H7), or ChainedTransactionManager behavior that changed in H7. +// Their H7-compatible equivalents live in grails-test-examples/hibernate7/. +List h7IncompatibleProjects = [ + 'grails-test-examples-app1', + 'grails-test-examples-datasources', + 'grails-test-examples-gorm', + 'grails-test-examples-views-functional-tests', + 'grails-test-examples-scaffolding-fields', +] + configurations.configureEach { resolutionStrategy.dependencySubstitution { // Test projects will often include dependencies from local projects. This will ensure any dependencies @@ -63,7 +76,8 @@ configurations.configureEach { // For general (non-hibernate-labeled) functional test projects, redirect Hibernate 5 dependencies // to Hibernate 7 projects when -PhibernateVersion=7 is set. These rules are added after the loop // so they override the default substitutions for the h5 modules. - if (isGeneralFunctionalTest && targetHibernateVersion == '7') { + // Projects in h7IncompatibleProjects are excluded since they use H5-specific GORM APIs. + if (isGeneralFunctionalTest && targetHibernateVersion == '7' && !(project.name in h7IncompatibleProjects)) { substitute module('org.apache.grails:grails-data-hibernate5') using project(':grails-data-hibernate7') substitute module('org.apache.grails:grails-data-hibernate5-spring-boot') using project(':grails-data-hibernate7-spring-boot') } @@ -71,7 +85,7 @@ configurations.configureEach { // Exclude Hibernate 5-specific runtime dependencies when testing general projects with Hibernate 7. // These libraries have no Hibernate 7 equivalent and would cause classpath conflicts. - if (isGeneralFunctionalTest && targetHibernateVersion == '7') { + if (isGeneralFunctionalTest && targetHibernateVersion == '7' && !(project.name in h7IncompatibleProjects)) { exclude group: 'org.hibernate', module: 'hibernate-ehcache' exclude group: 'org.jboss.spec.javax.transaction', module: 'jboss-transaction-api_1.3_spec' } @@ -91,6 +105,11 @@ tasks.withType(Test).configureEach { Test task -> return false } + // Skip projects with known H7 API incompatibilities when running with hibernateVersion=7 + if (targetHibernateVersion == '7' && project.name in h7IncompatibleProjects) { + return false + } + if (project.hasProperty('onlyHibernate5Tests')) { if (isHibernate5) { return false From fbfe66bd716def9c46561458b9d122b9f555a8bc Mon Sep 17 00:00:00 2001 From: Walter Duque de Estrada Date: Wed, 8 Apr 2026 13:27:58 -0500 Subject: [PATCH 4/7] fix: make gorm and app1 functional tests H7-compatible MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix 17 plain executeUpdate('...') calls across 7 specs in grails-test-examples/gorm to use executeUpdate('...', [:]). H7's HibernateGormStaticApi rejects plain CharSequence args (requires either a GString with interpolated params or the Map overload). - Add getDomainClasses() override to BookHibernateSpec in app1. H7's HibernateSpec uses HibernateDatastoreSpringInitializer which requires explicit domain class declaration; H5 auto-detected via classpath scanning. - Remove grails-test-examples-app1 and grails-test-examples-gorm from h7IncompatibleProjects list — both now run cleanly under Hibernate 7 via dependency substitution. Remaining excluded: datasources (ChainedTransactionManager), views-functional-tests (HAL/JSON diffs), scaffolding-fields (grails-fields rendering diffs). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- gradle/functional-test-config.gradle | 6 +----- .../test/groovy/functionaltests/BookHibernateSpec.groovy | 3 +++ .../groovy/gorm/GormCascadeOperationsSpec.groovy | 8 ++++---- .../groovy/gorm/GormCriteriaQueriesSpec.groovy | 4 ++-- .../groovy/gorm/GormDataServicesSpec.groovy | 4 ++-- .../integration-test/groovy/gorm/GormEventsSpec.groovy | 2 +- .../groovy/gorm/GormWhereQueryAdvancedSpec.groovy | 4 ++-- .../groovy/gorm/TransactionPropagationSpec.groovy | 4 ++-- .../gorm/TransactionalWhereQueryVariableScopeSpec.groovy | 4 ++-- 9 files changed, 19 insertions(+), 20 deletions(-) diff --git a/gradle/functional-test-config.gradle b/gradle/functional-test-config.gradle index 5a8382819ca..9f75ff67d40 100644 --- a/gradle/functional-test-config.gradle +++ b/gradle/functional-test-config.gradle @@ -30,14 +30,10 @@ boolean isMongoProject = project.name.startsWith('grails-test-examples-mongodb') boolean isGeneralFunctionalTest = !isHibernateSpecificProject && !isMongoProject // General functional test projects that use Hibernate 5-specific GORM APIs and cannot run -// under Hibernate 7 via dependency substitution. These use executeUpdate(String) without -// parameters (H7 requires a Map parameter), HibernateSpec unit tests (different domain class -// detection in H7), or ChainedTransactionManager behavior that changed in H7. +// under Hibernate 7 via dependency substitution. // Their H7-compatible equivalents live in grails-test-examples/hibernate7/. List h7IncompatibleProjects = [ - 'grails-test-examples-app1', 'grails-test-examples-datasources', - 'grails-test-examples-gorm', 'grails-test-examples-views-functional-tests', 'grails-test-examples-scaffolding-fields', ] diff --git a/grails-test-examples/app1/src/test/groovy/functionaltests/BookHibernateSpec.groovy b/grails-test-examples/app1/src/test/groovy/functionaltests/BookHibernateSpec.groovy index 958f52585b4..0fb97ce9d40 100644 --- a/grails-test-examples/app1/src/test/groovy/functionaltests/BookHibernateSpec.groovy +++ b/grails-test-examples/app1/src/test/groovy/functionaltests/BookHibernateSpec.groovy @@ -21,6 +21,9 @@ package functionaltests class BookHibernateSpec extends grails.test.hibernate.HibernateSpec { + @Override + List getDomainClasses() { [Book] } + def setup() { new Book(title: 'foo').save() } diff --git a/grails-test-examples/gorm/src/integration-test/groovy/gorm/GormCascadeOperationsSpec.groovy b/grails-test-examples/gorm/src/integration-test/groovy/gorm/GormCascadeOperationsSpec.groovy index 931db50d6e5..11b9a722cf8 100644 --- a/grails-test-examples/gorm/src/integration-test/groovy/gorm/GormCascadeOperationsSpec.groovy +++ b/grails-test-examples/gorm/src/integration-test/groovy/gorm/GormCascadeOperationsSpec.groovy @@ -41,10 +41,10 @@ class GormCascadeOperationsSpec extends Specification { def setup() { // Clean up test data - Book.executeUpdate('delete from Book') - Author.executeUpdate('delete from Author') - User.executeUpdate('delete from User') - City.executeUpdate('delete from City') + Book.executeUpdate('delete from Book', [:]) + Author.executeUpdate('delete from Author', [:]) + User.executeUpdate('delete from User', [:]) + City.executeUpdate('delete from City', [:]) } // ============================================ diff --git a/grails-test-examples/gorm/src/integration-test/groovy/gorm/GormCriteriaQueriesSpec.groovy b/grails-test-examples/gorm/src/integration-test/groovy/gorm/GormCriteriaQueriesSpec.groovy index b73c87d9776..920f9e9ed22 100644 --- a/grails-test-examples/gorm/src/integration-test/groovy/gorm/GormCriteriaQueriesSpec.groovy +++ b/grails-test-examples/gorm/src/integration-test/groovy/gorm/GormCriteriaQueriesSpec.groovy @@ -37,8 +37,8 @@ class GormCriteriaQueriesSpec extends Specification { def setup() { // Clean up and create fresh test data - Book.executeUpdate('delete from Book') - Author.executeUpdate('delete from Author') + Book.executeUpdate('delete from Book', [:]) + Author.executeUpdate('delete from Author', [:]) def kingAuthor = new Author(name: 'Stephen King', email: 'stephen@king.com').save(flush: true) def clancyAuthor = new Author(name: 'Tom Clancy', email: 'tom@clancy.com').save(flush: true) diff --git a/grails-test-examples/gorm/src/integration-test/groovy/gorm/GormDataServicesSpec.groovy b/grails-test-examples/gorm/src/integration-test/groovy/gorm/GormDataServicesSpec.groovy index 277fe142206..b8625820caa 100644 --- a/grails-test-examples/gorm/src/integration-test/groovy/gorm/GormDataServicesSpec.groovy +++ b/grails-test-examples/gorm/src/integration-test/groovy/gorm/GormDataServicesSpec.groovy @@ -48,8 +48,8 @@ class GormDataServicesSpec extends Specification { def setup() { // Clean up and create fresh test data - Book.executeUpdate('delete from Book') - Author.executeUpdate('delete from Author') + Book.executeUpdate('delete from Book', [:]) + Author.executeUpdate('delete from Author', [:]) def author = new Author(name: 'Stephen King', email: 'stephen@king.com').save(flush: true) diff --git a/grails-test-examples/gorm/src/integration-test/groovy/gorm/GormEventsSpec.groovy b/grails-test-examples/gorm/src/integration-test/groovy/gorm/GormEventsSpec.groovy index 2f9dd463bb8..43cc06e9843 100644 --- a/grails-test-examples/gorm/src/integration-test/groovy/gorm/GormEventsSpec.groovy +++ b/grails-test-examples/gorm/src/integration-test/groovy/gorm/GormEventsSpec.groovy @@ -42,7 +42,7 @@ import grails.testing.mixin.integration.Integration class GormEventsSpec extends Specification { def setup() { - AuditedEntity.executeUpdate('delete from AuditedEntity') + AuditedEntity.executeUpdate('delete from AuditedEntity', [:]) } // ============================================ diff --git a/grails-test-examples/gorm/src/integration-test/groovy/gorm/GormWhereQueryAdvancedSpec.groovy b/grails-test-examples/gorm/src/integration-test/groovy/gorm/GormWhereQueryAdvancedSpec.groovy index c7d339dc559..cdfafab8066 100644 --- a/grails-test-examples/gorm/src/integration-test/groovy/gorm/GormWhereQueryAdvancedSpec.groovy +++ b/grails-test-examples/gorm/src/integration-test/groovy/gorm/GormWhereQueryAdvancedSpec.groovy @@ -39,8 +39,8 @@ class GormWhereQueryAdvancedSpec extends Specification { def setup() { // Clean up existing data - Book.executeUpdate('delete from Book') - Author.executeUpdate('delete from Author') + Book.executeUpdate('delete from Book', [:]) + Author.executeUpdate('delete from Author', [:]) // Create test authors def king = new Author(name: 'Stephen King', email: 'stephen@king.com').save(flush: true) diff --git a/grails-test-examples/gorm/src/integration-test/groovy/gorm/TransactionPropagationSpec.groovy b/grails-test-examples/gorm/src/integration-test/groovy/gorm/TransactionPropagationSpec.groovy index a60e1678de8..8304bfa240e 100644 --- a/grails-test-examples/gorm/src/integration-test/groovy/gorm/TransactionPropagationSpec.groovy +++ b/grails-test-examples/gorm/src/integration-test/groovy/gorm/TransactionPropagationSpec.groovy @@ -44,8 +44,8 @@ class TransactionPropagationSpec extends Specification { def setup() { // Clean up before each test - delete books first due to FK constraint Author.withNewTransaction { - Book.executeUpdate('delete from Book') - Author.executeUpdate('delete from Author') + Book.executeUpdate('delete from Book', [:]) + Author.executeUpdate('delete from Author', [:]) } } diff --git a/grails-test-examples/gorm/src/integration-test/groovy/gorm/TransactionalWhereQueryVariableScopeSpec.groovy b/grails-test-examples/gorm/src/integration-test/groovy/gorm/TransactionalWhereQueryVariableScopeSpec.groovy index 972812ded2c..20f9a56bd39 100644 --- a/grails-test-examples/gorm/src/integration-test/groovy/gorm/TransactionalWhereQueryVariableScopeSpec.groovy +++ b/grails-test-examples/gorm/src/integration-test/groovy/gorm/TransactionalWhereQueryVariableScopeSpec.groovy @@ -45,8 +45,8 @@ class TransactionalWhereQueryVariableScopeSpec extends Specification { WhereQueryVariableScopeService whereQueryVariableScopeService def setup() { - Book.executeUpdate('delete from Book') - Author.executeUpdate('delete from Author') + Book.executeUpdate('delete from Book', [:]) + Author.executeUpdate('delete from Author', [:]) def king = new Author(name: 'Stephen King', email: 'stephen@king.com').save(flush: true) def clancy = new Author(name: 'Tom Clancy', email: 'tom@clancy.com').save(flush: true) From a458c4a6116f6779152da8aebb628c28c2a63357 Mon Sep 17 00:00:00 2001 From: Walter Duque de Estrada Date: Wed, 8 Apr 2026 15:49:37 -0500 Subject: [PATCH 5/7] =?UTF-8?q?fix(h7):=20fix=203=20H7=20GORM=20bugs=20?= =?UTF-8?q?=E2=80=94=20NonUniqueResultException,=20aggregate=20return=20ty?= =?UTF-8?q?pes,=20cross-property=20arithmetic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug 2: HibernateQueryExecutor.singleResult() now catches both org.hibernate.NonUniqueResultException and jakarta.persistence.NonUniqueResultException (H7 throws the JPA variant; the original catch missed it) and returns the first result instead of propagating. Bug 4: HqlQueryContext.aggregateTargetClass() now returns precise types per function: count() → Long, avg() → Double, sum/min/max() → Number. Previously all aggregates were bound to Long, causing QueryTypeMismatchException in H7's strict SQM type checking. Bug 5: Cross-property arithmetic in where-DSL (e.g. pageCount > price * 10) was silently dropped — the RHS property reference was coerced to a literal. Fixed via: - PropertyReference: Groovy wrapper returned by propertyMissing for numeric properties; *, +, -, / operators produce a PropertyArithmetic value object - PropertyArithmetic: value type carrying (propertyName, Operator, operand) - HibernateDetachedCriteria: H7-only DetachedCriteria subclass that overrides propertyMissing to return PropertyReference for numeric properties, and newInstance() to preserve the subtype through cloning - HibernateGormStaticApi: overrides where/whereLazy/whereAny to use HibernateDetachedCriteria as the closure delegate - PredicateGenerator: resolveNumericExpression() detects PropertyArithmetic and builds cb.prod/sum/diff/quot(path, operand) instead of a literal H5 and MongoDB are unaffected — all new types are confined to grails-data-hibernate7. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- H7_GORM_BUG_REPORT.md | 74 +++++++++++++++ .../HibernateDetachedCriteria.groovy | 54 +++++++++++ .../hibernate/HibernateGormStaticApi.groovy | 16 ++++ .../query/HibernateQueryExecutor.java | 3 +- .../orm/hibernate/query/HqlQueryContext.java | 10 +- .../hibernate/query/PredicateGenerator.java | 28 +++++- .../hibernate/query/PropertyArithmetic.java | 35 +++++++ .../hibernate/query/PropertyReference.groovy | 52 +++++++++++ .../hibernatequery/HibernateQuerySpec.groovy | 14 +++ .../PredicateGeneratorSpec.groovy | 13 +++ .../HibernateCriteriaBuilderDirectSpec.groovy | 30 +++++- .../query/HibernateHqlQuerySpec.groovy | 58 ++++++++++++ .../query/HqlQueryContextSpec.groovy | 37 +++++--- .../query/PropertyReferenceSpec.groovy | 93 +++++++++++++++++++ 14 files changed, 496 insertions(+), 21 deletions(-) create mode 100644 H7_GORM_BUG_REPORT.md create mode 100644 grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/HibernateDetachedCriteria.groovy create mode 100644 grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/query/PropertyArithmetic.java create mode 100644 grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/query/PropertyReference.groovy create mode 100644 grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/query/PropertyReferenceSpec.groovy diff --git a/H7_GORM_BUG_REPORT.md b/H7_GORM_BUG_REPORT.md new file mode 100644 index 00000000000..1ce4fc4bb5a --- /dev/null +++ b/H7_GORM_BUG_REPORT.md @@ -0,0 +1,74 @@ +## H7 `gorm` Functional Test Failures — Bug Report + +Running `grails-test-examples-gorm` with `-PhibernateVersion=7` produces 13 failures across 4 specs. +Below are the 5 distinct root causes. + +--- + +### Bug 1 (Intentional) — `executeQuery` / `executeUpdate` plain String blocked + +| | | +|---|---| +| **Tests** | `test basic HQL query`, `test HQL aggregate functions`, `test HQL group by`, `test executeUpdate for bulk operations` | +| **Spec** | `GormCriteriaQueriesSpec` | +| **Error** | `UnsupportedOperationException: executeQuery(CharSequence) only accepts a Groovy GString with interpolated parameters` | + +**Description:** H7 intentionally rejects `executeQuery("from Book where inStock = true")` when no parameters are passed. The same tightening was already applied to `executeUpdate`. Callers must use `executeQuery('...', [:])` or a GString with interpolated params. + +> This is by design. The test bodies need to adopt the parameterized form — not a GORM bug. + +--- + +### Bug 2 — `DetachedCriteria.get()` throws `NonUniqueResultException` instead of returning first result + +| | | +|---|---| +| **Test** | `test detached criteria as reusable query` | +| **Spec** | `GormCriteriaQueriesSpec:454` | +| **Error** | `jakarta.persistence.NonUniqueResultException: Query did not return a unique result: 2 results were returned` | + +**Description:** H5 `DetachedCriteria.get()` returned the first matching row when multiple rows existed. H7's `AbstractSelectionQuery.getSingleResult()` is now strict and throws if the result is not unique. + +**Expected fix:** `HibernateQueryExecutor.singleResult()` should apply `setMaxResults(1)` before calling `getSingleResult()`, or switch to `getResultList().stream().findFirst()`. + +--- + +### Bug 3 — `Found two representations of same collection: gorm.Author.books` + +| | | +|---|---| +| **Tests** | `test saving child with belongsTo saves parent reference`, `test dirty checking with associations`, `test belongsTo allows orphan removal`, `test updating multiple children`, `test addTo creates bidirectional link` | +| **Spec** | `GormCascadeOperationsSpec` | +| **Error** | `HibernateSystemException: Found two representations of same collection: gorm.Author.books` | + +**Description:** H7 enforces stricter collection identity. After `author.addToBooks(book); author.save(flush: true)`, the session contains two references to the same `Author.books` collection, causing a `HibernateException` on flush. H5 tolerated this. + +**Expected fix:** GORM's `addTo*` / cascade-flush path in `grails-data-hibernate7` must synchronize both sides of the bidirectional association and merge/evict stale collection snapshots before flushing. + +--- + +### Bug 4 — `@Query` aggregate functions fail with type mismatch + +| | | +|---|---| +| **Tests** | `test findAveragePrice`, `test findMaxPageCount` | +| **Spec** | `GormDataServicesSpec` | +| **Errors** | `Incorrect query result type: query produces 'java.lang.Double' but type 'java.lang.Long' was given` / `query produces 'java.lang.Integer' but type 'java.lang.Long' was given` | + +**Description:** `HibernateHqlQuery.buildQuery()` always calls `session.createQuery(hql, ctx.targetClass())`. For aggregate HQL (`select avg(b.price) ...`, `select max(b.pageCount) ...`), the query does not return an entity, but `ctx.targetClass()` returns the entity class (e.g., `Book`). H7's `SqmQueryImpl` enforces strict result-type alignment — `avg()` produces `Double`, `max(pageCount)` produces `Integer`, neither is coercible to the bound entity type. + +**Expected fix:** `HibernateHqlQuery.buildQuery()` must detect non-entity HQL (aggregates / projections) and call the untyped `session.createQuery(hql)` in those cases, letting GORM handle result casting downstream. + +--- + +### Bug 5 — `where { pageCount > price * 10 }` fails with `CoercionException` + +| | | +|---|---| +| **Test** | `test where query comparing two properties` | +| **Spec** | `GormWhereQueryAdvancedSpec:175` | +| **Error** | `org.hibernate.type.descriptor.java.CoercionException: Error coercing value` | + +**Description:** A where-DSL closure comparing an `Integer` property (`pageCount`) to an arithmetic expression involving a `BigDecimal` property (`price * 10`) worked in H5. H7's SQM type system no longer allows implicit coercion between `Integer` and `BigDecimal` in a comparison predicate. + +**Expected fix:** The GORM where-query-to-SQM translator should emit an explicit `CAST` in the SQM tree when the two operands of a comparison have different numeric types. diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/HibernateDetachedCriteria.groovy b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/HibernateDetachedCriteria.groovy new file mode 100644 index 00000000000..5b6a610e4ad --- /dev/null +++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/HibernateDetachedCriteria.groovy @@ -0,0 +1,54 @@ +/* + * 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 groovy.transform.CompileDynamic + +import grails.gorm.DetachedCriteria +import org.grails.datastore.mapping.model.PersistentProperty +import org.grails.orm.hibernate.query.PropertyReference + +/** + * Hibernate-specific subclass of {@link DetachedCriteria} that overrides + * {@code propertyMissing} to return a {@link PropertyReference} for numeric + * persistent properties. This enables cross-property arithmetic in where-DSL + * expressions such as {@code pageCount > price * 10} without touching shared + * modules (and therefore without affecting H5 or MongoDB backends). + */ +@CompileDynamic +class HibernateDetachedCriteria extends DetachedCriteria { + + HibernateDetachedCriteria(Class targetClass, String alias = null) { + super(targetClass, alias) + } + + @Override + protected HibernateDetachedCriteria newInstance() { + new HibernateDetachedCriteria(targetClass, alias) + } + + @Override + def propertyMissing(String name) { + PersistentProperty prop = getPersistentEntity()?.getPropertyByName(name) + if (prop != null && Number.isAssignableFrom(prop.type)) { + return new PropertyReference(name) + } + super.propertyMissing(name) + } +} diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/HibernateGormStaticApi.groovy b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/HibernateGormStaticApi.groovy index ae033ab0d80..3db6a805f24 100644 --- a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/HibernateGormStaticApi.groovy +++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/HibernateGormStaticApi.groovy @@ -50,6 +50,7 @@ import org.springframework.core.convert.ConversionService import org.springframework.transaction.PlatformTransactionManager import grails.orm.HibernateCriteriaBuilder +import grails.gorm.DetachedCriteria import org.grails.datastore.gorm.GormStaticApi import org.grails.datastore.gorm.finders.FinderMethod import org.grails.datastore.mapping.core.connections.ConnectionSource @@ -123,6 +124,21 @@ class HibernateGormStaticApi extends GormStaticApi { (GormStaticApi) HibernateGormEnhancer.findStaticApi(persistentClass, qualifier) } + @Override + DetachedCriteria where(Closure callable) { + new HibernateDetachedCriteria(persistentClass).build(callable) + } + + @Override + DetachedCriteria whereLazy(Closure callable) { + new HibernateDetachedCriteria(persistentClass).buildLazy(callable) + } + + @Override + DetachedCriteria whereAny(Closure callable) { + (DetachedCriteria) new HibernateDetachedCriteria(persistentClass).or(callable) + } + @Override D merge(D d) { instanceApi.merge(d) diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/query/HibernateQueryExecutor.java b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/query/HibernateQueryExecutor.java index b72b80035a6..eabea0ef2e5 100644 --- a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/query/HibernateQueryExecutor.java +++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/query/HibernateQueryExecutor.java @@ -53,10 +53,9 @@ public Object scroll(Session session, JpaCriteriaQuery jpaCq) { public Object singleResult(Session session, JpaCriteriaQuery jpaCq) { var query = configureQuery(session, jpaCq); try { - Object singleResult = query.getSingleResult(); return proxyHandler.unwrap(singleResult); - } catch (NonUniqueResultException e) { + } catch (NonUniqueResultException | jakarta.persistence.NonUniqueResultException e) { return proxyHandler.unwrap(query.getResultList().get(0)); } catch (jakarta.persistence.NoResultException e) { return null; diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/query/HqlQueryContext.java b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/query/HqlQueryContext.java index 82d5ed2d75d..fd3d6c546fe 100644 --- a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/query/HqlQueryContext.java +++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/query/HqlQueryContext.java @@ -130,12 +130,20 @@ public static Class getTarget(CharSequence hql, Class clazz) { case 0 -> clazz; case 1 -> isAggregateProjection(normalized) ? - Long.class : + aggregateTargetClass(normalized) : (isPropertyProjection(normalized) ? Object.class : clazz); default -> Object[].class; }; } + private static Class aggregateTargetClass(CharSequence hql) { + String clause = getSingleProjectionClause(hql); + if (clause == null) return Long.class; + if (clause.startsWith("count(")) return Long.class; + if (clause.startsWith("avg(")) return Double.class; + return Number.class; + } + private static boolean isAggregateProjection(CharSequence hql) { String clause = getSingleProjectionClause(hql); if (clause == null) return false; diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/query/PredicateGenerator.java b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/query/PredicateGenerator.java index 19e5be9f08a..5ff12dfb960 100644 --- a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/query/PredicateGenerator.java +++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/query/PredicateGenerator.java @@ -225,13 +225,17 @@ private Predicate handlePropertyCriterion( } else if (pc instanceof Query.IdEquals c) { return cb.equal(root.get("id"), normalizeValue(c.getValue())); } else if (pc instanceof Query.GreaterThan c) { - return cb.gt((Expression) fullyQualifiedPath, getNumericValue(c)); + Expression rhs = resolveNumericExpression(cb, root, c); + return rhs != null ? cb.gt((Expression) fullyQualifiedPath, rhs) : cb.gt((Expression) fullyQualifiedPath, getNumericValue(c)); } else if (pc instanceof Query.GreaterThanEquals c) { - return cb.ge((Expression) fullyQualifiedPath, getNumericValue(c)); + Expression rhs = resolveNumericExpression(cb, root, c); + return rhs != null ? cb.ge((Expression) fullyQualifiedPath, rhs) : cb.ge((Expression) fullyQualifiedPath, getNumericValue(c)); } else if (pc instanceof Query.LessThan c) { - return cb.lt((Expression) fullyQualifiedPath, getNumericValue(c)); + Expression rhs = resolveNumericExpression(cb, root, c); + return rhs != null ? cb.lt((Expression) fullyQualifiedPath, rhs) : cb.lt((Expression) fullyQualifiedPath, getNumericValue(c)); } else if (pc instanceof Query.LessThanEquals c) { - return cb.le((Expression) fullyQualifiedPath, getNumericValue(c)); + Expression rhs = resolveNumericExpression(cb, root, c); + return rhs != null ? cb.le((Expression) fullyQualifiedPath, rhs) : cb.le((Expression) fullyQualifiedPath, getNumericValue(c)); } else if (pc instanceof Query.SizeEquals c) { return cb.equal(cb.size((Expression) fullyQualifiedPath), normalizeValue(c.getValue())); } else if (pc instanceof Query.SizeNotEquals c) { @@ -573,4 +577,20 @@ private Number getNumericValue(Query.PropertyCriterion criterion) { "Operation '%s' on property '%s' only accepts a numeric value, but received a %s", criterion.getClass().getSimpleName(), criterion.getProperty(), "null")); } + + @SuppressWarnings("unchecked") + private Expression resolveNumericExpression(HibernateCriteriaBuilder cb, From root, Query.PropertyCriterion criterion) { + Object value = criterion.getValue(); + if (!(value instanceof PropertyArithmetic)) { + return null; + } + PropertyArithmetic pa = (PropertyArithmetic) value; + Expression propertyPath = root.get(pa.propertyName()); + return switch (pa.operator()) { + case MULTIPLY -> cb.prod(propertyPath, pa.operand()); + case ADD -> cb.sum(propertyPath, pa.operand()); + case SUBTRACT -> cb.diff(propertyPath, pa.operand()); + case DIVIDE -> cb.quot(propertyPath, pa.operand()); + }; + } } diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/query/PropertyArithmetic.java b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/query/PropertyArithmetic.java new file mode 100644 index 00000000000..773dcc420a7 --- /dev/null +++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/query/PropertyArithmetic.java @@ -0,0 +1,35 @@ +/* + * 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.query; + +/** + * Represents a property path combined with a scalar arithmetic operand, + * e.g. {@code price * 10} in a where-DSL expression. + *

+ * At query-build time {@link PredicateGenerator} resolves this into the + * appropriate JPA {@code CriteriaBuilder} arithmetic expression + * ({@code cb.prod}, {@code cb.sum}, {@code cb.diff}, {@code cb.quot}). + */ +public record PropertyArithmetic(String propertyName, Operator operator, Number operand) { + + public enum Operator { + MULTIPLY, ADD, SUBTRACT, DIVIDE + } + +} diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/query/PropertyReference.groovy b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/query/PropertyReference.groovy new file mode 100644 index 00000000000..dd6b779b688 --- /dev/null +++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/query/PropertyReference.groovy @@ -0,0 +1,52 @@ +/* + * 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.query + +import groovy.transform.CompileStatic + +/** + * Represents a reference to a persistent property inside a where-DSL closure. + * Supports Groovy arithmetic operators so that expressions like {@code price * 10} + * produce a {@link PropertyArithmetic} instead of being evaluated as a literal. + */ +@CompileStatic +class PropertyReference { + + final String propertyName + + PropertyReference(String propertyName) { + this.propertyName = propertyName + } + + PropertyArithmetic multiply(Number operand) { + new PropertyArithmetic(propertyName, PropertyArithmetic.Operator.MULTIPLY, operand) + } + + PropertyArithmetic plus(Number operand) { + new PropertyArithmetic(propertyName, PropertyArithmetic.Operator.ADD, operand) + } + + PropertyArithmetic minus(Number operand) { + new PropertyArithmetic(propertyName, PropertyArithmetic.Operator.SUBTRACT, operand) + } + + PropertyArithmetic div(Number operand) { + new PropertyArithmetic(propertyName, PropertyArithmetic.Operator.DIVIDE, operand) + } +} diff --git a/grails-data-hibernate7/core/src/test/groovy/grails/gorm/specs/hibernatequery/HibernateQuerySpec.groovy b/grails-data-hibernate7/core/src/test/groovy/grails/gorm/specs/hibernatequery/HibernateQuerySpec.groovy index 5a119e683cf..6c110919c63 100644 --- a/grails-data-hibernate7/core/src/test/groovy/grails/gorm/specs/hibernatequery/HibernateQuerySpec.groovy +++ b/grails-data-hibernate7/core/src/test/groovy/grails/gorm/specs/hibernatequery/HibernateQuerySpec.groovy @@ -1127,6 +1127,20 @@ class HibernateQuerySpec extends HibernateGormDatastoreSpec { hibernateQuery.getAliases().size() == 1 hibernateQuery.getAliases()[0] == alias } + + def "singleResult returns first result when multiple rows match"() { + given: "two people with the same last name" + new Person(firstName: "Alice", lastName: "Smith", age: 30).save(flush: true) + new Person(firstName: "Charlie", lastName: "Smith", age: 40).save(flush: true) + hibernateQuery.eq("lastName", "Smith") + + when: "singleResult is called with multiple matches" + def result = hibernateQuery.singleResult() + + then: "first match is returned without throwing" + result != null + result instanceof Person + } } diff --git a/grails-data-hibernate7/core/src/test/groovy/grails/gorm/specs/hibernatequery/PredicateGeneratorSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/grails/gorm/specs/hibernatequery/PredicateGeneratorSpec.groovy index 2cfef279e81..add3b3dd148 100644 --- a/grails-data-hibernate7/core/src/test/groovy/grails/gorm/specs/hibernatequery/PredicateGeneratorSpec.groovy +++ b/grails-data-hibernate7/core/src/test/groovy/grails/gorm/specs/hibernatequery/PredicateGeneratorSpec.groovy @@ -29,6 +29,7 @@ import org.grails.datastore.mapping.query.Query import org.grails.orm.hibernate.query.JpaFromProvider import org.grails.orm.hibernate.query.PredicateGenerator +import org.grails.orm.hibernate.query.PropertyArithmetic import grails.gorm.annotation.Entity import org.grails.datastore.gorm.GormEntity @@ -199,6 +200,17 @@ class PredicateGeneratorSpec extends HibernateGormDatastoreSpec { predicates.length == 1 } + def "getPredicates supports PropertyArithmetic on RHS of GreaterThan (age > salary * 10)"() { + given: + List criteria = [new Query.GreaterThan("age", new PropertyArithmetic("salary", PropertyArithmetic.Operator.MULTIPLY, 10))] + + when: + def predicates = predicateGenerator.getPredicates(cb, query, root, criteria, fromProvider, personEntity) + + then: + predicates.length == 1 + } + def "test getPredicates with In on basic collection"() { given: List criteria = [new Query.In("nicknames", ["Bob", "Alice"])] @@ -221,6 +233,7 @@ class PredicateGeneratorSpecPerson implements GormEntity nicknames static hasMany = [pets: PredicateGeneratorSpecPet, nicknames: String] diff --git a/grails-data-hibernate7/core/src/test/groovy/grails/orm/HibernateCriteriaBuilderDirectSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/grails/orm/HibernateCriteriaBuilderDirectSpec.groovy index ee04d28d148..150708291c0 100644 --- a/grails-data-hibernate7/core/src/test/groovy/grails/orm/HibernateCriteriaBuilderDirectSpec.groovy +++ b/grails-data-hibernate7/core/src/test/groovy/grails/orm/HibernateCriteriaBuilderDirectSpec.groovy @@ -42,7 +42,7 @@ class HibernateCriteriaBuilderDirectSpec extends HibernateGormDatastoreSpec { @Shared HibernateCriteriaBuilder builder def setupSpec() { - manager.addAllDomainClasses([DirectAccount, DirectTransaction, DirectBiBook, DirectBiAuthor]) + manager.addAllDomainClasses([DirectAccount, DirectTransaction, DirectBiBook, DirectBiAuthor, DirectItem]) } def setup() { @@ -672,6 +672,21 @@ class HibernateCriteriaBuilderDirectSpec extends HibernateGormDatastoreSpec { nativeSession.close() } } + + void "where DSL supports cross-property arithmetic comparison (Integer gt BigDecimal * constant)"() { + given: "items where pageCount is an Integer and price is a BigDecimal" + new DirectItem(name: 'Long Cheap', pageCount: 1000, price: 5.00).save(flush: true) + new DirectItem(name: 'Short Expensive', pageCount: 100, price: 50.00).save(flush: true) + + when: "filtering where pageCount > price * 10" + def results = DirectItem.where { + pageCount > price * 10 + }.list() + + then: "only the long cheap item qualifies" + results.size() == 1 + results[0].name == 'Long Cheap' + } } @Entity @@ -704,3 +719,16 @@ class DirectBiAuthor { String name static hasMany = [books: DirectBiBook] } + +@Entity +class DirectItem { + String name + Integer pageCount + BigDecimal price + + static constraints = { + name nullable: false + pageCount nullable: false + price nullable: false + } +} diff --git a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/query/HibernateHqlQuerySpec.groovy b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/query/HibernateHqlQuerySpec.groovy index c8814e8a770..020fc64caed 100644 --- a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/query/HibernateHqlQuerySpec.groovy +++ b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/query/HibernateHqlQuerySpec.groovy @@ -286,6 +286,64 @@ class HibernateHqlQuerySpec extends HibernateGormDatastoreSpec { noExceptionThrown() query.list().size() == 1 } + + void "singleResult returns first result when multiple rows match"() { + given: "a second author with multiple books matching the same HQL query" + def author2 = new HibernateHqlQuerySpecAuthor(name: "Tolkien2").save(flush: true) + new HibernateHqlQuerySpecBook(title: "Extra Book", pages: 200, author: author2).save(flush: true) + + when: "singleResult is called on an HQL query that returns multiple rows" + def result = buildHqlQuery("from HibernateHqlQuerySpecBook").singleResult() + + then: "first result is returned without throwing" + result != null + result instanceof HibernateHqlQuerySpecBook + } + + void "aggregate avg() query returns a Double result"() { + when: "executing an avg aggregate HQL query" + def result = buildHqlQuery("select avg(b.pages) from HibernateHqlQuerySpecBook b").list() + + then: "result is returned as a Double without type mismatch exception" + result.size() == 1 + result[0] instanceof Double + } + + void "aggregate max() on Integer column returns a Number result"() { + when: "executing a max aggregate HQL query on an Integer property" + def result = buildHqlQuery("select max(b.pages) from HibernateHqlQuerySpecBook b").list() + + then: "result is returned as a Number without type mismatch exception" + result.size() == 1 + result[0] instanceof Number + } + + void "aggregate min() on Integer column returns a Number result"() { + when: "executing a min aggregate HQL query on an Integer property" + def result = buildHqlQuery("select min(b.pages) from HibernateHqlQuerySpecBook b").list() + + then: "result is returned as a Number without type mismatch exception" + result.size() == 1 + result[0] instanceof Number + } + + void "aggregate sum() on Integer column returns a Number result"() { + when: "executing a sum aggregate HQL query on an Integer property" + def result = buildHqlQuery("select sum(b.pages) from HibernateHqlQuerySpecBook b").list() + + then: "result is returned as a Number without type mismatch exception" + result.size() == 1 + result[0] instanceof Number + } + + void "count() aggregate returns a Long result"() { + when: "executing a count aggregate HQL query" + def result = buildHqlQuery("select count(b) from HibernateHqlQuerySpecBook b").list() + + then: "result is returned as a Long without type mismatch exception" + result.size() == 1 + result[0] instanceof Long + } } @Entity diff --git a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/query/HqlQueryContextSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/query/HqlQueryContextSpec.groovy index 3a13b211481..92ee50c1b0e 100644 --- a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/query/HqlQueryContextSpec.groovy +++ b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/query/HqlQueryContextSpec.groovy @@ -238,20 +238,31 @@ class HqlQueryContextSpec extends Specification { HqlQueryContext.getTarget("select p.name, p.age from Person p", String) == Object[].class } - @Unroll - void "getTarget returns Long for aggregate projection: #hql"() { + void "getTarget returns Long for count aggregate"() { expect: - HqlQueryContext.getTarget(hql, String) == Long - where: - hql << [ - "select count(p) from Person p", - "select sum(p.age) from Person p", - "select avg(p.age) from Person p", - "select min(p.age) from Person p", - "select max(p.age) from Person p", - "select count(*) from Person", - "select distinct count(p.id) from Person p" - ] + HqlQueryContext.getTarget("select count(p) from Person p", String) == Long + HqlQueryContext.getTarget("select count(*) from Person", String) == Long + HqlQueryContext.getTarget("select distinct count(p.id) from Person p", String) == Long + } + + void "getTarget returns Double for avg aggregate"() { + expect: + HqlQueryContext.getTarget("select avg(p.age) from Person p", String) == Double + } + + void "getTarget returns Number for sum aggregate"() { + expect: + HqlQueryContext.getTarget("select sum(p.age) from Person p", String) == Number + } + + void "getTarget returns Number for min aggregate"() { + expect: + HqlQueryContext.getTarget("select min(p.age) from Person p", String) == Number + } + + void "getTarget returns Number for max aggregate"() { + expect: + HqlQueryContext.getTarget("select max(p.age) from Person p", String) == Number } // ─── countHqlProjections ───────────────────────────────────────────────── diff --git a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/query/PropertyReferenceSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/query/PropertyReferenceSpec.groovy new file mode 100644 index 00000000000..8310be7b378 --- /dev/null +++ b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/query/PropertyReferenceSpec.groovy @@ -0,0 +1,93 @@ +/* + * 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.query + +import spock.lang.Specification + +class PropertyReferenceSpec extends Specification { + + def "multiply returns a PropertyArithmetic with MULTIPLY operator"() { + given: + def ref = new PropertyReference("price") + + when: + def result = ref.multiply(10) + + then: + result instanceof PropertyArithmetic + result.propertyName == "price" + result.operator == PropertyArithmetic.Operator.MULTIPLY + result.operand == 10 + } + + def "plus returns a PropertyArithmetic with ADD operator"() { + given: + def ref = new PropertyReference("salary") + + when: + def result = ref.plus(500) + + then: + result instanceof PropertyArithmetic + result.propertyName == "salary" + result.operator == PropertyArithmetic.Operator.ADD + result.operand == 500 + } + + def "minus returns a PropertyArithmetic with SUBTRACT operator"() { + given: + def ref = new PropertyReference("balance") + + when: + def result = ref.minus(100) + + then: + result instanceof PropertyArithmetic + result.propertyName == "balance" + result.operator == PropertyArithmetic.Operator.SUBTRACT + result.operand == 100 + } + + def "div returns a PropertyArithmetic with DIVIDE operator"() { + given: + def ref = new PropertyReference("total") + + when: + def result = ref.div(3) + + then: + result instanceof PropertyArithmetic + result.propertyName == "total" + result.operator == PropertyArithmetic.Operator.DIVIDE + result.operand == 3 + } + + def "Groovy * operator delegates to multiply"() { + given: + def ref = new PropertyReference("price") + + when: + def result = ref * 10 + + then: + result instanceof PropertyArithmetic + result.operator == PropertyArithmetic.Operator.MULTIPLY + result.operand == 10 + } +} From 707c33d6d9a759ddb2e858d28364f59eda0ba31c Mon Sep 17 00:00:00 2001 From: Walter Duque de Estrada Date: Thu, 9 Apr 2026 00:34:55 -0500 Subject: [PATCH 6/7] fix(h7): prevent 'two representations of same collection' in addTo/save on managed entities H7 enforces strict collection identity during flush. GORM's addTo* and save() flow had two failure modes: 1. When an entity is already managed in the current Hibernate session, calling session.merge() causes H7 to create a second PersistentCollection for the same role+key alongside the one already tracked in the session cache -> 'Found two representations of same collection'. Fix (HibernateGormInstanceApi.performMerge): check session.contains(target) before merging. If the entity is already managed, skip merge entirely; dirty-checking and cascade will handle children on flush. 2. When addTo* is called on a managed entity, GormEntity.addTo uses direct field access (reflector.getProperty) which bypasses H7's bytecode-enhanced interceptor, sees null, and creates a plain ArrayList on the field. H7's session cache already tracks a PersistentBag/Set for that role -> two representations on the next save. Fix (HibernateEntity.addTo): override addTo in the H7 trait; for managed entities (id != null), trigger the H7 interceptor via InvokerHelper.getProperty to obtain the live PersistentCollection before delegating to GormEntity.super.addTo. Fix (HibernateEntityTransformation): re-target the concrete addToXxx generated methods so their internal addTo call dispatches through HibernateEntity.addTo rather than being hard-wired to GormEntity.addTo. Fix (HibernateGormInstanceApi.reconcileCollections): detect stale PersistentCollections (session != current session) and replace them with plain collections before merge, covering any edge cases where the H7 interceptor path is not taken. Adds AddToManagedEntitySpec with 4 tests covering: - addTo on an already-persisted entity - multiple addTo on a fresh transient entity - modify child + save twice - removeFrom + save Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../gorm/hibernate/HibernateEntity.groovy | 39 ++++++ .../hibernate/HibernateGormInstanceApi.groovy | 68 ++++++++- .../HibernateEntityTransformation.groovy | 29 ++++ .../gorm/specs/AddToManagedEntitySpec.groovy | 131 ++++++++++++++++++ 4 files changed, 262 insertions(+), 5 deletions(-) create mode 100644 grails-data-hibernate7/core/src/test/groovy/grails/gorm/specs/AddToManagedEntitySpec.groovy diff --git a/grails-data-hibernate7/core/src/main/groovy/grails/gorm/hibernate/HibernateEntity.groovy b/grails-data-hibernate7/core/src/main/groovy/grails/gorm/hibernate/HibernateEntity.groovy index 9a7ae793ae5..9dbd4cb60d5 100644 --- a/grails-data-hibernate7/core/src/main/groovy/grails/gorm/hibernate/HibernateEntity.groovy +++ b/grails-data-hibernate7/core/src/main/groovy/grails/gorm/hibernate/HibernateEntity.groovy @@ -21,8 +21,14 @@ package grails.gorm.hibernate import groovy.transform.CompileStatic import groovy.transform.Generated +import org.codehaus.groovy.runtime.InvokerHelper + import org.grails.datastore.gorm.GormEnhancer import org.grails.datastore.gorm.GormEntity +import org.grails.datastore.mapping.model.PersistentEntity +import org.grails.datastore.mapping.model.types.Association +import org.grails.datastore.mapping.model.types.ToOne +import org.grails.datastore.mapping.reflect.EntityReflector import org.grails.orm.hibernate.HibernateGormStaticApi /** @@ -127,4 +133,37 @@ trait HibernateEntity extends GormEntity { HibernateGormStaticApi api = (HibernateGormStaticApi) GormEnhancer.findStaticApi(this) return (D) api.findWithNativeSql(sql, args) } + + /** + * Overrides {@link GormEntity#addTo} to fix "Found two representations of same collection" + * in Hibernate 7. + * + * H7 uses bytecode-enhanced attribute interception: the entity field for a collection is + * physically null until first accessed through the getter. {@link GormEntity#addTo} uses + * direct field access via {@link EntityReflector}, so it sees null and creates a new plain + * ArrayList — which collides with the PersistentBag already tracked in the session. + * + * The fix: when the entity is already persisted (has an id) and the field is null, access the + * collection through the getter via {@link InvokerHelper}. H7's attribute interceptor then + * returns the session-tracked PersistentBag. We write it back to the field so the base + * {@code addTo} finds it and adds directly into the PersistentBag without creating a plain one. + */ + @Generated + D addTo(String associationName, Object arg) { + if (ident() != null) { + PersistentEntity pe = getGormPersistentEntity() + def prop = pe.getPropertyByName(associationName) + if (prop instanceof Association && !(prop instanceof ToOne)) { + EntityReflector reflector = pe.mappingContext.getEntityReflector(pe) + if (reflector != null && reflector.getProperty((D) this, associationName) == null) { + // Access through the getter — H7's attribute interceptor returns the PersistentBag + def persistentColl = InvokerHelper.getProperty(this, associationName) + if (persistentColl != null) { + reflector.setProperty((D) this, associationName, persistentColl) + } + } + } + } + return GormEntity.super.addTo(associationName, arg) + } } diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/HibernateGormInstanceApi.groovy b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/HibernateGormInstanceApi.groovy index 546c334e434..344a646b08e 100644 --- a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/HibernateGormInstanceApi.groovy +++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/HibernateGormInstanceApi.groovy @@ -43,6 +43,7 @@ import org.hibernate.HibernateException import org.hibernate.LockMode import org.hibernate.Session import org.hibernate.SessionFactory +import org.hibernate.collection.spi.PersistentCollection import org.hibernate.engine.spi.EntityEntry import org.hibernate.engine.spi.SessionImplementor import org.hibernate.persister.entity.EntityPersister @@ -63,6 +64,8 @@ import org.grails.datastore.mapping.model.PersistentProperty import org.grails.datastore.mapping.model.config.GormProperties import org.grails.datastore.mapping.model.types.Association import org.grails.datastore.mapping.model.types.Embedded +import org.grails.datastore.mapping.model.types.ManyToMany +import org.grails.datastore.mapping.model.types.OneToMany import org.grails.datastore.mapping.model.types.ToOne import org.grails.datastore.mapping.reflect.ClassUtils import org.grails.datastore.mapping.reflect.EntityReflector @@ -247,11 +250,20 @@ class HibernateGormInstanceApi extends GormInstanceApi { protected D performMerge(final D target, final boolean flush) { hibernateTemplate.execute { Session session -> - D merged = (D) session.merge(target) - session.lock(merged, LockModeType.NONE) - // Sync id back immediately so target has an identity - String idProp = persistentEntity.identity?.name ?: 'id' - InvokerHelper.setProperty(target, idProp, InvokerHelper.getProperty(merged, idProp)) + D merged + if (session.contains(target)) { + // Entity is already managed in this session — merging would cause H7 to create + // a second PersistentCollection for the same role+key ("two representations"). + // Just use the entity as-is; dirty-checking + cascade will handle children. + merged = target + } else { + reconcileCollections(session, target) + merged = (D) session.merge(target) + session.lock(merged, LockModeType.NONE) + // Sync id back immediately so target has an identity + String idProp = persistentEntity.identity?.name ?: 'id' + InvokerHelper.setProperty(target, idProp, InvokerHelper.getProperty(merged, idProp)) + } if (flush) { flushSession session } @@ -279,6 +291,52 @@ class HibernateGormInstanceApi extends GormInstanceApi { } } + /** + * Reconciles collection fields on an entity before session.merge() to prevent H7's + * "Found two representations of same collection" error. + * + * Two scenarios cause this error: + * + * 1. Stale PersistentCollection: the field holds a PersistentCollection from a previous + * (now closed) session. H7 merge in the new session sees two collection objects for the + * same role + key. Fix: copy the items to a plain collection so merge can create a fresh one. + * + * 2. Plain collection on a managed entity: addTo* created a new ArrayList on a managed entity + * that already has a session-tracked PersistentCollection for that field. Fix: handled + * upstream by HibernateEntity.addTo override; reconcileCollections handles any residual cases. + */ + @SuppressWarnings('unchecked') + private void reconcileCollections(Session session, D target) { + EntityReflector reflector = datastore.mappingContext.getEntityReflector(persistentEntity) + if (reflector == null) return + + SessionImplementor si = (SessionImplementor) session + + for (Association assoc in persistentEntity.associations) { + if (!(assoc instanceof OneToMany) && !(assoc instanceof ManyToMany)) continue + + String propName = assoc.name + Object fieldValue = reflector.getProperty(target, propName) + if (fieldValue == null) continue + + if (fieldValue instanceof PersistentCollection) { + PersistentCollection pc = (PersistentCollection) fieldValue + // If this PersistentCollection belongs to a different (closed) session, + // replace it with a plain collection so merge can create a fresh one. + if (pc.getSession() != si) { + Collection plain = (Collection) [].asType(assoc.type) + if (pc.wasInitialized()) { + plain.addAll((Collection) pc) + } + reflector.setProperty(target, propName, plain) + } + // If it belongs to the current session, leave it alone — no issue. + } + // Plain (non-PersistentCollection) fields on managed entities should have been + // handled by HibernateEntity.addTo; nothing more to do here. + } + } + protected static void flushSession(Session session) throws HibernateException { try { session.flush() diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/compiler/HibernateEntityTransformation.groovy b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/compiler/HibernateEntityTransformation.groovy index 3bb62220b68..3c59bb45b6b 100644 --- a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/compiler/HibernateEntityTransformation.groovy +++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/compiler/HibernateEntityTransformation.groovy @@ -33,7 +33,9 @@ import org.codehaus.groovy.ast.FieldNode import org.codehaus.groovy.ast.InnerClassNode import org.codehaus.groovy.ast.MethodNode import org.codehaus.groovy.ast.Parameter +import org.codehaus.groovy.ast.expr.MethodCallExpression import org.codehaus.groovy.ast.stmt.BlockStatement +import org.codehaus.groovy.ast.stmt.ExpressionStatement import org.codehaus.groovy.ast.stmt.IfStatement import org.codehaus.groovy.ast.stmt.ReturnStatement import org.codehaus.groovy.ast.stmt.Statement @@ -52,6 +54,7 @@ import org.hibernate.engine.spi.PersistentAttributeInterceptable import org.hibernate.engine.spi.PersistentAttributeInterceptor import grails.gorm.dirty.checking.DirtyCheckedProperty +import grails.gorm.hibernate.HibernateEntity import org.grails.compiler.gorm.GormEntityTransformation import org.grails.datastore.mapping.model.config.GormProperties import org.grails.datastore.mapping.reflect.AstUtils @@ -126,6 +129,32 @@ class HibernateEntityTransformation implements ASTTransformation, CompilationUni new GormEntityTransformation(compilationUnit: compilationUnit).visit(classNode, sourceUnit) + // Retarget generated addToXxx methods to call HibernateEntity.addTo instead of GormEntity.addTo, + // so our H7 override (which initializes the PersistentBag before adding) is invoked. + ClassNode hibernateEntityClassNode = ClassHelper.make(HibernateEntity) + List hibernateAddToMethods = hibernateEntityClassNode.getMethods('addTo') + if (!hibernateAddToMethods.isEmpty()) { + MethodNode hibernateAddTo = hibernateAddToMethods.get(0) + for (MethodNode method : classNode.getMethods()) { + String methodName = method.name + if (!methodName.startsWith('addTo') || method.parameters.length != 1) continue + if (method.code instanceof BlockStatement) { + BlockStatement block = (BlockStatement) method.code + for (def stmt : block.statements) { + if (stmt instanceof ExpressionStatement) { + def expr = ((ExpressionStatement) stmt).expression + if (expr instanceof MethodCallExpression) { + MethodCallExpression mce = (MethodCallExpression) expr + if (mce.methodAsString == 'addTo') { + mce.setMethodTarget(hibernateAddTo) + } + } + } + } + } + } + } + ClassNode managedEntityClassNode = ClassHelper.make(ManagedEntity) ClassNode attributeInterceptableClassNode = ClassHelper.make(PersistentAttributeInterceptable) ClassNode entityEntryClassNode = ClassHelper.make(EntityEntry) diff --git a/grails-data-hibernate7/core/src/test/groovy/grails/gorm/specs/AddToManagedEntitySpec.groovy b/grails-data-hibernate7/core/src/test/groovy/grails/gorm/specs/AddToManagedEntitySpec.groovy new file mode 100644 index 00000000000..e8be0122343 --- /dev/null +++ b/grails-data-hibernate7/core/src/test/groovy/grails/gorm/specs/AddToManagedEntitySpec.groovy @@ -0,0 +1,131 @@ +/* + * 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.specs + +import grails.gorm.annotation.Entity +import grails.gorm.hibernate.HibernateEntity +import org.grails.datastore.gorm.GormEntity + +/** + * Regression tests for H7 "Found two representations of same collection" error. + * + * H7 enforces strict collection identity — after an entity is persisted and + * managed by the session, calling addTo* and then save(flush:true) must not + * replace the Hibernate-tracked PersistentCollection with a plain collection. + */ +class AddToManagedEntitySpec extends HibernateGormDatastoreSpec { + + void setupSpec() { + manager.addAllDomainClasses([CascadeAuthor, CascadeBook]) + } + + void cleanup() { + CascadeBook.withNewTransaction { + CascadeBook.executeUpdate('delete from CascadeBook', [:]) + CascadeAuthor.executeUpdate('delete from CascadeAuthor', [:]) + } + } + + void "addTo* then save(flush:true) on an already-persisted author does not throw two representations error"() { + given: "an author that is already persisted (managed by session)" + def author = new CascadeAuthor(name: 'J.K. Rowling').save(flush: true) + + when: "adding a book to the managed author and flushing" + def book = new CascadeBook(title: 'Harry Potter') + author.addToBooks(book) + author.save(flush: true) + + then: "no exception is thrown and the relationship is persisted" + noExceptionThrown() + CascadeBook.count() == 1 + CascadeBook.findByTitle('Harry Potter').author.id == author.id + author.books.contains(book) + } + + void "addTo* then save(flush:true) with multiple books on managed author works"() { + given: "a persisted author" + def author = new CascadeAuthor(name: 'Brandon Sanderson').save(flush: true) + + when: "adding multiple books to the managed author" + 5.times { i -> + author.addToBooks(new CascadeBook(title: "Book ${i}")) + } + author.save(flush: true) + + then: + noExceptionThrown() + CascadeBook.count() == 5 + } + + void "modifying a book through a managed author and flushing does not throw"() { + given: "a persisted author with books" + def author = new CascadeAuthor(name: 'Test Author') + author.addToBooks(new CascadeBook(title: 'Original Title')) + author.save(flush: true) + + when: "modifying a book and saving the author again" + author.books.first().title = 'Modified Title' + author.save(flush: true) + + CascadeAuthor.withSession { it.flush(); it.clear() } + + then: + noExceptionThrown() + CascadeBook.findByTitle('Modified Title') != null + } + + void "removeFrom then save(flush:true) on managed author works"() { + given: "a persisted author with a book" + def author = new CascadeAuthor(name: 'Orphan Author') + def book = new CascadeBook(title: 'Orphan Book') + author.addToBooks(book) + author.save(flush: true) + def bookId = book.id + + when: + author.removeFromBooks(book) + book.delete(flush: true) + author.save(flush: true) + + then: + noExceptionThrown() + CascadeBook.get(bookId) == null + author.books.isEmpty() + } +} + +@Entity +class CascadeAuthor implements HibernateEntity { + String name + Set books + static hasMany = [books: CascadeBook] + static constraints = { + name blank: false + } +} + +@Entity +class CascadeBook implements HibernateEntity { + String title + CascadeAuthor author + static belongsTo = [author: CascadeAuthor] + static constraints = { + title blank: false + } +} From a63dcadbb91c9e48f40872405bcd93096188dce2 Mon Sep 17 00:00:00 2001 From: Walter Duque de Estrada Date: Thu, 9 Apr 2026 09:44:34 -0500 Subject: [PATCH 7/7] fix(test): correct GormCriteriaQueriesSpec to use safe HQL overloads and proper applicationClass - Replace executeQuery(plainString) and executeUpdate(plainString) calls with the (String, Map) overloads (empty map for parameterless queries). HibernateGormStaticApi intentionally rejects plain String in the no-arg overload to prevent HQL injection; parameterless static queries must use the Map overload. - Add applicationClass = Application to @Integration so the spec shares the same application context and transaction manager as the other specs in this module, preventing test-data bleed between specs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../groovy/gorm/GormCriteriaQueriesSpec.groovy | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/grails-test-examples/gorm/src/integration-test/groovy/gorm/GormCriteriaQueriesSpec.groovy b/grails-test-examples/gorm/src/integration-test/groovy/gorm/GormCriteriaQueriesSpec.groovy index 920f9e9ed22..364fa7e9070 100644 --- a/grails-test-examples/gorm/src/integration-test/groovy/gorm/GormCriteriaQueriesSpec.groovy +++ b/grails-test-examples/gorm/src/integration-test/groovy/gorm/GormCriteriaQueriesSpec.groovy @@ -24,7 +24,6 @@ import spock.lang.Unroll import grails.gorm.DetachedCriteria import grails.gorm.transactions.Rollback import grails.testing.mixin.integration.Integration - /** * Tests for GORM Criteria Queries - both createCriteria() and DetachedCriteria. * @@ -32,7 +31,7 @@ import grails.testing.mixin.integration.Integration * complex queries without writing HQL strings. */ @Rollback -@Integration +@Integration(applicationClass = Application) class GormCriteriaQueriesSpec extends Specification { def setup() { @@ -489,8 +488,8 @@ class GormCriteriaQueriesSpec extends Specification { // ============================================ void "test basic HQL query"() { - when: "executing HQL query" - def results = Book.executeQuery("from Book where inStock = true") + when: "executing HQL query with no parameters (use Map overload for plain strings)" + def results = Book.executeQuery("from Book where inStock = true", [:]) then: "results returned" results.size() == 6 @@ -545,7 +544,7 @@ class GormCriteriaQueriesSpec extends Specification { void "test HQL aggregate functions"() { when: "executing HQL aggregates" def result = Book.executeQuery( - 'select count(b), avg(b.price), max(b.pageCount) from Book b' + 'select count(b), avg(b.price), max(b.pageCount) from Book b', [:] )[0] then: "aggregates calculated" @@ -557,7 +556,7 @@ class GormCriteriaQueriesSpec extends Specification { void "test HQL group by"() { when: "executing HQL group by" def results = Book.executeQuery( - 'select a.name, count(b) from Book b join b.author a group by a.name order by count(b) desc' + 'select a.name, count(b) from Book b join b.author a group by a.name order by count(b) desc', [:] ) then: "grouped results" @@ -569,7 +568,7 @@ class GormCriteriaQueriesSpec extends Specification { void "test executeUpdate for bulk operations"() { when: "executing bulk update" int updated = Book.executeUpdate( - 'update Book b set b.price = b.price * 1.1 where b.inStock = true' + 'update Book b set b.price = b.price * 1.1 where b.inStock = true', [:] ) then: "bulk update applied"