Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
d7d9e7d
ci: unify functional test CI to run all tests against both Hibernate …
jamesfredley Apr 7, 2026
e2336d9
fix: register h7 autoconfig and remove h5-specific ehcache config
jamesfredley Apr 7, 2026
f587be1
ci: skip h7-incompatible general tests when running with hibernateVer…
jamesfredley Apr 7, 2026
fbfe66b
fix: make gorm and app1 functional tests H7-compatible
borinquenkid Apr 8, 2026
a458c4a
fix(h7): fix 3 H7 GORM bugs — NonUniqueResultException, aggregate ret…
borinquenkid Apr 8, 2026
707c33d
fix(h7): prevent 'two representations of same collection' in addTo/sa…
borinquenkid Apr 9, 2026
a63dcad
fix(test): correct GormCriteriaQueriesSpec to use safe HQL overloads …
borinquenkid Apr 9, 2026
dc7c4a0
address PR feedback: validate hibernateVersion, rename booleans, fix …
jamesfredley Apr 16, 2026
b92f3d5
Merge remote-tracking branch 'origin/8.0.x-hibernate7' into ci/hibern…
jamesfredley Apr 16, 2026
5a97cbe
fix(ci): substitute grails-bom with grails-hibernate7-bom for H7 func…
jamesfredley Apr 16, 2026
5fa3c96
fix(ci): align hibernate to 7.2.7 and fix grails-bom publication path
jamesfredley Apr 16, 2026
3f8eb79
fix(ci): swap project(':grails-bom') for h7, add micronaut-bom to for…
jamesfredley Apr 16, 2026
b59c548
fix(ci): attach grails-hibernate7-bom platform to test project depend…
jamesfredley Apr 16, 2026
904fd76
fix(ci): pin testcontainers BOM in grails-forge-analytics-postgres
jamesfredley Apr 16, 2026
5db69b2
fix(ci): correct testcontainers artifact name to org.testcontainers:p…
jamesfredley Apr 16, 2026
52ba29e
fix(ci): correct testcontainers spock artifact in grails-forge-cli
jamesfredley Apr 16, 2026
cf6d9ce
chore(forge): move testcontainers version to gradle.properties
jamesfredley Apr 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 8 additions & 41 deletions .github/workflows/gradle.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand All @@ -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 }})"
Expand Down Expand Up @@ -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 ]
Expand Down Expand Up @@ -388,15 +356,14 @@ 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' &&
(github.event_name == 'push' || github.event_name == 'workflow_dispatch') &&
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
Expand Down
74 changes: 74 additions & 0 deletions H7_GORM_BUG_REPORT.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 4 additions & 1 deletion dependencies.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
89 changes: 59 additions & 30 deletions gradle/functional-test-config.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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

Comment on lines +24 to +37
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hibernateVersion is accepted as an arbitrary string and silently falls back to “Hibernate 5 behavior” for any value other than '7'. That can hide typos/misconfiguration (especially for local runs). Consider normalizing + validating the value (e.g., allow only '5'/'7' and throw a GradleException otherwise) so the build fails fast when an unsupported value is provided.

Copilot uses AI. Check for mistakes.
Expand All @@ -38,6 +44,12 @@ List<String> 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
Expand All @@ -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 ->
Expand All @@ -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')
}
Expand All @@ -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<String> 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')) {
Expand All @@ -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
}
Comment on lines +155 to +163
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The skipHibernate5Tests / skipHibernate7Tests logic relies on isHibernate5 / isHibernate7 being defined as negated startsWith(...) checks (so isHibernate5 == false actually means “this is an hibernate5-labeled project”). This inverted naming makes the skip conditions hard to reason about and easy to break with future edits. Consider redefining these booleans to match their names (or renaming them to isNotHibernate5Project/etc.) and then update the onlyIf conditions accordingly.

Copilot uses AI. Check for mistakes.

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')
}
Expand Down
1 change: 1 addition & 0 deletions grails-forge/gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
9 changes: 8 additions & 1 deletion grails-forge/grails-forge-analytics-postgres/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'
Expand Down
5 changes: 4 additions & 1 deletion grails-forge/grails-forge-cli/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
Loading
Loading