diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index 5f4a9c75ae2..c0b1457a5c5 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -230,15 +230,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: [ 21, 25 ] + hibernate-version: [ '5', '7' ] indy: [ false ] include: - java: 21 + hibernate-version: '5' indy: true runs-on: ubuntu-24.04 steps: @@ -258,6 +260,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 @@ -267,8 +271,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 }})" @@ -309,43 +314,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: [ 21, 25 ] - indy: [ false ] - include: - - java: 21 - 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 ] @@ -388,7 +356,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' && @@ -396,7 +364,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/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/dependencies.gradle b/dependencies.gradle index 393d5b17317..12cf059d351 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -214,7 +214,10 @@ ext { customBomVersions = [ 'cache-ri-impl.version' : '1.1.1', 'hibernate-models.version' : '1.0.1', - 'hibernate.version' : '7.2.5.Final', + // Aligned with Spring Boot 4.0.5's managed Hibernate version to avoid a + // version-constraint conflict when consumers import both grails-hibernate7-bom + // (via grails-data-hibernate7) and spring-boot-dependencies (via grails-base-bom). + 'hibernate.version' : '7.2.7.Final', 'jandex.version' : '3.2.3', 'liquibase-hibernate.version' : '4.27.0', 'liquibase-test-harness.version': '1.0.11', diff --git a/gradle/functional-test-config.gradle b/gradle/functional-test-config.gradle index 15869319600..450962ccd8e 100644 --- a/gradle/functional-test-config.gradle +++ b/gradle/functional-test-config.gradle @@ -23,9 +23,15 @@ rootProject.subprojects // 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') +// Only '5' and '7' are supported; any other value fails the build fast to catch typos. +def targetHibernateVersion = (project.findProperty('hibernateVersion') ?: '5').toString() +if (!(targetHibernateVersion in ['5', '7'])) { + throw new GradleException( + "Unsupported hibernateVersion '${targetHibernateVersion}'. Expected '5' or '7'.") +} +boolean isHibernate5LabeledProject = project.name.startsWith('grails-test-examples-hibernate5') +boolean isHibernate7LabeledProject = project.name.startsWith('grails-test-examples-hibernate7') +boolean isHibernateSpecificProject = isHibernate5LabeledProject || isHibernate7LabeledProject boolean isMongoProject = project.name.startsWith('grails-test-examples-mongodb') boolean isGeneralFunctionalTest = !isHibernateSpecificProject && !isMongoProject @@ -38,6 +44,12 @@ List h7IncompatibleProjects = [ 'grails-test-examples-scaffolding-fields', ] +// Redirect the default grails-bom to grails-hibernate7-bom for general functional tests when +// running with -PhibernateVersion=7, so consumers get the Hibernate 7 version constraints +// (hibernate.version=7.2.5.Final and related). The default grails-bom and grails-hibernate5-bom +// ship the Hibernate 5 constraints. +def redirectBomToH7 = isGeneralFunctionalTest && targetHibernateVersion == '7' && !(project.name in h7IncompatibleProjects) + configurations.configureEach { resolutionStrategy.dependencySubstitution { // Test projects will often include dependencies from local projects. This will ensure any dependencies @@ -49,7 +61,8 @@ configurations.configureEach { //TODO: This does not handle libraries that are both test fixtures & a libraries like grails-data-mongodb, // see grails-test-examples-mongodb-base, & grails-test-examples-mongodb-hibernate5 for project() workaround if (possibleProject.name == 'grails-bom') { - substitute module(substitutedArtifact) using platform(project(':grails-bom')) + def targetBom = redirectBomToH7 ? ':grails-hibernate7-bom' : ':grails-bom' + substitute module(substitutedArtifact) using platform(project(targetBom)) } else if(possibleProject.name == 'grails-geb') { def selector = it.variant(module(substitutedArtifact)) { VariantSelectionDetails details -> @@ -73,7 +86,7 @@ configurations.configureEach { // 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. // Projects in h7IncompatibleProjects are excluded since they use H5-specific GORM APIs. - if (isGeneralFunctionalTest && targetHibernateVersion == '7' && !(project.name in h7IncompatibleProjects)) { + if (redirectBomToH7) { 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') } @@ -87,14 +100,37 @@ configurations.configureEach { } } +// For general functional test projects running against Hibernate 7, attach the Hibernate 7 BOM as a +// platform on the dependency buckets the test projects use. The test project's own build.gradle +// imports platform(project(':grails-bom')) which lacks Hibernate 7 version constraints +// (hibernate-models, jandex, hibernate-tools-orm, etc.). Attaching the H7 BOM here means we don't +// have to edit each test project's build.gradle, and the H7 constraints take precedence because the +// dependency-substitution above already swaps grails-data-hibernate5 -> grails-data-hibernate7 in +// the same path. +if (isGeneralFunctionalTest && targetHibernateVersion == '7' && !(project.name in h7IncompatibleProjects)) { + def addH7Platform = { String confName -> + def conf = configurations.findByName(confName) + if (conf != null) { + conf.dependencies.add(dependencies.platform(dependencies.project(path: ':grails-hibernate7-bom'))) + } + } + plugins.withId('java') { + ['implementation', 'compileOnly', 'runtimeOnly', + 'testImplementation', 'testCompileOnly', 'testRuntimeOnly', + 'integrationTestImplementation', 'integrationTestRuntimeOnly', + 'testAndDevelopmentOnly'].each(addH7Platform) + } +} + List debugArguments = [ '-Xmx2g', '-Xdebug', '-Xnoagent', '-Djava.compiler=NONE', '-Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=5005' ] tasks.withType(Test).configureEach { Test task -> - boolean isHibernate5 = !project.name.startsWith('grails-test-examples-hibernate5') - boolean isHibernate7 = !project.name.startsWith('grails-test-examples-hibernate7') - boolean isMongo = !project.name.startsWith('grails-test-examples-mongodb') + // Each boolean name describes what the project IS. Positive names, positive semantics. + boolean isHibernate5Project = project.name.startsWith('grails-test-examples-hibernate5') + boolean isHibernate7Project = project.name.startsWith('grails-test-examples-hibernate7') + boolean isMongoTaskProject = project.name.startsWith('grails-test-examples-mongodb') onlyIf { if (project.hasProperty('skipFunctionalTests')) { @@ -106,50 +142,43 @@ tasks.withType(Test).configureEach { Test task -> return false } - if (project.hasProperty('onlyHibernate5Tests')) { - if (isHibernate5) { - return false - } + // Only run hibernate5-labeled projects when -PonlyHibernate5Tests is set + if (project.hasProperty('onlyHibernate5Tests') && !isHibernate5Project) { + return false } - if (project.hasProperty('onlyHibernate7Tests')) { - if (isHibernate7) { - return false - } + // Only run hibernate7-labeled projects when -PonlyHibernate7Tests is set + if (project.hasProperty('onlyHibernate7Tests') && !isHibernate7Project) { + return false } // Skip hibernate5-labeled projects when -PskipHibernate5Tests is set - if (project.hasProperty('skipHibernate5Tests')) { - if (!isHibernate5) { - return false - } + if (project.hasProperty('skipHibernate5Tests') && isHibernate5Project) { + return false } // Skip hibernate7-labeled projects when -PskipHibernate7Tests is set - if (project.hasProperty('skipHibernate7Tests')) { - if (!isHibernate7) { - return false - } + if (project.hasProperty('skipHibernate7Tests') && isHibernate7Project) { + return false } - if (project.hasProperty('onlyMongodbTests')) { - if (isMongo) { - return false - } + // Only run mongodb-labeled projects when -PonlyMongodbTests is set + if (project.hasProperty('onlyMongodbTests') && !isMongoTaskProject) { + return false } if (project.hasProperty('onlyCoreTests')) { return false } - if(project.hasProperty('skipTests')) { + if (project.hasProperty('skipTests')) { return false } return true } - if (isMongo && project.hasProperty('serializeMongoTests')) { + if (isMongoTaskProject && project.hasProperty('serializeMongoTests')) { // if the developer decides to run a local mongo instance, the tests must be serialized instead of launching containers as needed task.outputs.dir rootProject.layout.buildDirectory.dir('mongo-test-serialize') } diff --git a/grails-forge/gradle.properties b/grails-forge/gradle.properties index e0adeeda0be..0008fcb9a82 100644 --- a/grails-forge/gradle.properties +++ b/grails-forge/gradle.properties @@ -57,6 +57,7 @@ slf4jVersion=2.0.17 snakeyamlVersion=2.4 spockVersion=2.1-groovy-3.0 spotlessVersion=6.25.0 +testcontainersVersion=1.20.4 testRetryVersion=1.6.2 typesafeConfigVersion=1.4.3 diff --git a/grails-forge/grails-forge-analytics-postgres/build.gradle b/grails-forge/grails-forge-analytics-postgres/build.gradle index c9afe81f4b7..d89640336a3 100644 --- a/grails-forge/grails-forge-analytics-postgres/build.gradle +++ b/grails-forge/grails-forge-analytics-postgres/build.gradle @@ -28,9 +28,11 @@ group = 'org.apache.grails.forge' dependencies { + annotationProcessor platform("io.micronaut:micronaut-bom:$micronautVersion") annotationProcessor 'io.micronaut:micronaut-graal' annotationProcessor 'io.micronaut.data:micronaut-data-processor' + implementation platform("io.micronaut:micronaut-bom:$micronautVersion") implementation project(':grails-forge-core') implementation "com.google.cloud.sql:postgres-socket-factory:$postgresSocketFactoryVersion" implementation 'io.micronaut.data:micronaut-data-jdbc' @@ -40,11 +42,16 @@ dependencies { runtimeOnly "ch.qos.logback:logback-classic:$logbackClassicVersion" + testAnnotationProcessor platform("io.micronaut:micronaut-bom:$micronautVersion") testCompileOnly "io.micronaut:micronaut-inject-groovy:$micronautVersion" + testImplementation platform("io.micronaut:micronaut-bom:$micronautVersion") + // The testcontainers BOM is not imported by micronaut-bom 3.10.4, so pin the version explicitly. + testImplementation platform("org.testcontainers:testcontainers-bom:$testcontainersVersion") testImplementation "ch.qos.logback:logback-classic:$logbackClassicVersion" testImplementation 'io.micronaut:micronaut-http-client' - testImplementation 'org.testcontainers:testcontainers-postgresql' + // The artifact is org.testcontainers:postgresql, not testcontainers-postgresql. + testImplementation 'org.testcontainers:postgresql' } application { mainClass = 'org.grails.forge.analytics.postgres.Main' diff --git a/grails-forge/grails-forge-cli/build.gradle b/grails-forge/grails-forge-cli/build.gradle index 2fe5810e0f9..fad79b69dd1 100644 --- a/grails-forge/grails-forge-cli/build.gradle +++ b/grails-forge/grails-forge-cli/build.gradle @@ -97,7 +97,10 @@ dependencies { testImplementation 'io.micronaut.picocli:micronaut-picocli' testImplementation "org.reflections:reflections:$reflectionsVersion" - testImplementation 'org.testcontainers:testcontainers-spock' + // The testcontainers BOM is not imported by micronaut-bom 3.10.4, so pin the version explicitly. + // Also: the published artifact id is org.testcontainers:spock, not testcontainers-spock. + testImplementation platform("org.testcontainers:testcontainers-bom:$testcontainersVersion") + testImplementation 'org.testcontainers:spock' if (project.hasProperty('micronautVersion')) { testCompileOnly "io.micronaut:micronaut-inject-groovy:$micronautVersion" } diff --git a/grails-forge/grails-forge-core/build.gradle b/grails-forge/grails-forge-core/build.gradle index 84a50c8cca2..6f4d79b8e20 100644 --- a/grails-forge/grails-forge-core/build.gradle +++ b/grails-forge/grails-forge-core/build.gradle @@ -84,7 +84,7 @@ def grailsVersionInfoTask = tasks.register('grailsVersionInfo', WriteGrailsVersi grailsVersionInfoTask.configure { WriteGrailsVersionInfoTask it -> def bomPublicationTask = gradle.includedBuild('grails-core').task(':grails-bom:generatePomFileForMavenPublication') it.dependsOn(bomPublicationTask) - it.bomPublicationFile = rootProject.layout.projectDirectory.file('../grails-bom/build/publications/maven/pom-default.xml') + it.bomPublicationFile = rootProject.layout.projectDirectory.file('../grails-bom/default/build/publications/maven/pom-default.xml') it.versionsDirectory = grailsVersionsPath } sourceSets.main.resources.srcDir(grailsVersionsPath) 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: