Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -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:
Expand All @@ -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
Expand All @@ -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 }})"
Expand Down Expand Up @@ -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 ]
Expand Down Expand Up @@ -364,15 +332,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.
52 changes: 52 additions & 0 deletions gradle/functional-test-config.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,23 @@ 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

Comment on lines +24 to +31
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.
// General functional test projects that use Hibernate 5-specific GORM APIs and cannot run
// under Hibernate 7 via dependency substitution.
// Their H7-compatible equivalents live in grails-test-examples/hibernate7/.
List<String> h7IncompatibleProjects = [
'grails-test-examples-datasources',
'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
Expand Down Expand Up @@ -51,6 +68,22 @@ 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.
// 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')
}
}

// 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' && !(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'
}
}

Expand All @@ -68,6 +101,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
Expand All @@ -80,6 +118,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
}
}
Comment on lines +121 to +133
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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
org.grails.datastore.gorm.boot.autoconfigure.HibernateGormAutoConfiguration
Original file line number Diff line number Diff line change
Expand Up @@ -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

/**
Expand Down Expand Up @@ -127,4 +133,37 @@ trait HibernateEntity<D> extends GormEntity<D> {
HibernateGormStaticApi<D> api = (HibernateGormStaticApi<D>) 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)
}
}
Original file line number Diff line number Diff line change
@@ -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<T> extends DetachedCriteria<T> {

HibernateDetachedCriteria(Class<T> targetClass, String alias = null) {
super(targetClass, alias)
}

@Override
protected HibernateDetachedCriteria<T> newInstance() {
new HibernateDetachedCriteria<T>(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)
}
}
Loading
Loading