jamesfredley commented on PR #15530:
URL: https://github.com/apache/grails-core/pull/15530#issuecomment-4180021203
Hibernate 7 (built on the Hibernate 6.x lineage) is the most significant
architectural shift
in Hibernate's history. The legacy Criteria API was removed entirely, the
Type system was
rewritten, Session API methods were renamed to align with JPA, and Spring
Framework 7 dropped
its `org.springframework.orm.hibernate5` package. This PR introduces a
parallel GORM
implementation (`grails-data-hibernate7`) alongside the existing
`grails-data-hibernate5`,
sharing the same core datamapping abstractions.
**Key facts about this PR:**
- ~1,790 files changed across ~913 commits
- ~256 new test files in the h7 module (entire module is new)
- Shared modules (`datamapping-core`, `datastore-core`, `datamapping-tck`)
have ~124 files
changed with +4,035/-1,229 lines
- The GORM DSL API is preserved - app-level query code (`where`, `criteria`,
dynamic finders)
remains unchanged
- Internal implementation is entirely rebuilt for JPA Criteria, ByteBuddy
proxies, and
Jakarta EE 10
---
## 2. Why Hibernate 7
Grails uses **Hibernate 5.6.15.Final** via the `hibernate-core-jakarta`
artifact (the
Jakarta EE repackaging of Hibernate 5.6) and **Hibernate 7.2.5.Final**.
| Factor | Hibernate 5.6 Jakarta | Hibernate 7.2 |
|--------|----------------------|---------------|
| Artifact | `org.hibernate:hibernate-core-jakarta:5.6.15.Final` |
`org.hibernate.orm:hibernate-core:7.2.5.Final` |
| JPA version | JPA 3.0 (`jakarta.persistence`) | JPA 3.2
(`jakarta.persistence`) |
| Criteria API | Legacy `org.hibernate.Criteria` + JPA Criteria | JPA
Criteria only (legacy removed) |
| Type system | `org.hibernate.type.Type` (read-by-name) | `JavaType` /
`JdbcType` (read-by-position) |
| Session methods | `save()`, `update()`, `delete()`, `load()`,
`saveOrUpdate()` | JPA-aligned: `persist()`, `merge()`, `remove()`,
`getReference()` (legacy methods still exist but `saveOrUpdate()` was removed) |
| Proxy generation | ByteBuddy (default since 5.3; Javassist still
available) | ByteBuddy only (Javassist removed) |
| Spring ORM | `spring-orm` `hibernate5` package | Package removed in Spring
7 (Grails forks it) |
| JDBC result reading | By column name | By position (performance
improvement) |
| ID type constraint | `Serializable` required | `Object` (relaxed) |
| Sequence naming | Global `hibernate_sequence` | Per-entity `<entity>_seq` |
| Group ID | `org.hibernate` | `org.hibernate.orm` |
**Note on Jakarta**: Because Grails uses the `hibernate-core-jakarta`
artifact, both H5 and
H7 already use `jakarta.persistence` - the `javax` to `jakarta` migration is
NOT a factor in
this upgrade. The breaking changes are purely Hibernate API-level.
Hibernate 5.6 is end-of-life. Spring Boot 4 / Spring Framework 7 only
supports Hibernate 6.6+.
Grails 8 must support Hibernate 7 to remain on supported infrastructure.
---
## 3. Architecture Overview
### Module Layout
```
grails-core/
grails-datastore-core/ # Abstract datastore model (shared by ALL)
grails-datamapping-core/ # GORM API, DetachedCriteria, finders
(shared by ALL)
grails-datamapping-tck/ # Test Compatibility Kit (shared by ALL)
grails-data-hibernate5/ # Hibernate 5 GORM implementation
core/
grails-data-hibernate7/ # Hibernate 7 GORM implementation (NEW)
core/
grails-data-mongodb/ # MongoDB GORM implementation
grails-bom/ # Base BOM
grails-hibernate5-bom/ # H5-specific version overrides
grails-hibernate7-bom/ # H7-specific version overrides (NEW)
```
### Dependency Flow
```
App build.gradle
|
+-- platform('grails-hibernate7-bom') // or grails-hibernate5-bom
+-- implementation('grails-data-hibernate7') // or grails-data-hibernate5
|
+-- grails-datamapping-core (shared GORM API)
| +-- grails-datastore-core (shared model)
+-- hibernate-core 7.x
+-- byte-buddy
```
Both h5 and h7 modules depend on the same `grails-datamapping-core` and
`grails-datastore-core`.
Changes to these shared modules affect **all** datastores (h5, h7, MongoDB).
---
## 4. Query Engine - Complete Rebuild
### The Problem
Hibernate 7.2 completely removed the legacy Criteria API
(`org.hibernate.Criteria`,
`Restrictions`, `Projections`). GORM's entire query infrastructure was built
on this API.
### H5.6 Architecture
```
GORM DSL (where/criteria/dynamic finders)
|
v
AbstractHibernateQuery (extends Query)
|
v
Hibernate Criteria API (org.hibernate.Criteria)
|-- Restrictions.eq(), .like(), .between()
|-- Projections.count(), .sum()
|-- Criteria.createAlias() for joins
v
SQL
```
Key classes:
- `AbstractHibernateQuery` - base query class using `org.hibernate.Criteria`
directly
- `AbstractHibernateCriterionAdapter` - translates GORM criteria to
Hibernate `Criterion`
- `HibernateCriteriaBuilder` - DSL builder using
`AbstractHibernateCriteriaBuilder`
### H7.2 Architecture
```
GORM DSL (where/criteria/dynamic finders)
|
v
HibernateQuery (extends Query)
|
v
DetachedCriteria (holds query state)
|
v
JpaCriteriaQueryCreator (translates to JPA)
|-- PredicateGenerator (GORM criteria -> JPA Predicate)
|-- JpaFromProvider (manages FROM/JOIN clauses)
|-- HibernateCriteriaBuilder (JPA criteria builder)
v
JPA CriteriaQuery -> SQL
```
Key new classes:
- `HibernateQuery` - bridges GORM to JPA Criteria via `DetachedCriteria`
- `JpaCriteriaQueryCreator` - translates `DetachedCriteria` to
`CriteriaQuery` with
projections, joins, and predicates
- `PredicateGenerator` - converts GORM criteria to JPA `Predicate` objects
using
`HibernateCriteriaBuilder`
- `JpaFromProvider` - manages FROM clauses, aliases, and joins;
automatically applies LEFT
joins for projected associations to preserve null rows
- `HibernateHqlQuery` - handles HQL queries with the split `SelectionQuery`
/ `MutationQuery`
types (Hibernate 7 splits query execution by type)
### Feature Comparison
| Feature | H5.6 | H7.2 |
|---------|------|------|
| Basic criteria (eq, like, between) | `Restrictions.*` |
`PredicateGenerator` -> JPA `Predicate` |
| Projections (count, sum, avg) | `Projections.*` |
`JpaCriteriaQueryCreator.projectionToJpaExpression()` |
| Joins / Associations | `Criteria.createAlias()` with Hibernate `JoinType`
| `JpaFromProvider` with JPA `JoinType` |
| Subqueries | `DetachedCriteria` + `Restrictions` | `JpaSubQuery` in
`PredicateGenerator.handleSubqueryCriterion()` |
| RLIKE / Regex | Abstract `createRlikeExpression()` per dialect | Custom
JPA function via `GrailsRLikeFunctionContributor` |
| Count with projections | Workaround: loads all rows into memory (logged
warning) | `Query.countResults()` - same fallback, but cleaner path |
| HQL mutations (delete/update) | `Query.executeUpdate()` |
`MutationQuery.executeUpdate()` (separate type) |
### User-Facing Impact
**None for typical GORM usage.** The GORM DSL (`where`, `criteria`, dynamic
finders) is
preserved. The translation layer is entirely internal.
**Edge cases:**
- Custom `HibernateCriteriaBuilder` subclasses will break (different base
class hierarchy)
- Direct Hibernate `Session` usage via `withSession { session ->
session.createCriteria(...) }`
will fail - `createCriteria()` no longer exists
- HQL queries that relied on "pass-through" tokens (unknown tokens silently
passed to SQL)
must now use `sql(...)` wrapper
- Implicit join behavior changed: `from Person p join p.address` now returns
`List<Person>`
instead of `List<Object[]>`
---
## 5. Domain Binding - Monolith to Modules
### The Problem
H5.6's `GrailsDomainBinder` was a ~4,000+ line monolithic class responsible
for all domain-to-
Hibernate mapping translation. This was extremely difficult to test,
maintain, or extend.
### H5.6 Architecture
```
GrailsDomainBinder (monolithic, ~4,000 lines)
|-- handles all property types
|-- handles all association types
|-- handles all inheritance strategies
|-- handles all ID generation strategies
|-- handles second-pass binding
v
Hibernate Mapping Model
```
### H7.2 Architecture
The monolithic binder was decomposed into ~100+ specialized classes across
four categories:
**Binders (37 classes)** - one per mapping concern:
| Category | Classes |
|----------|---------|
| Identity | `SimpleIdBinder`, `CompositeIdBinder`, `IdentityBinder`,
`NaturalIdentifierBinder` |
| Properties | `PropertyBinder`, `GrailsPropertyBinder`,
`SimpleValueBinder`, `ColumnBinder` |
| Associations | `ManyToOneBinder`, `OneToOneBinder`,
`ForeignKeyOneToOneBinder`, `CollectionBinder` |
| Inheritance | `SubclassMappingBinder`, `SingleTableSubclassBinder`,
`JoinedSubClassBinder`, `UnionSubclassBinder` |
| Discriminators | `DefaultDiscriminatorBinder`,
`ConfiguredDiscriminatorBinder`, `DiscriminatorPropertyBinder` |
| Constraints | `StringColumnConstraintsBinder`,
`NumericColumnConstraintsBinder` |
| Other | `RootBinder`, `ClassBinder`, `ClassPropertiesBinder`,
`VersionBinder`, `EnumTypeBinder`, `IndexBinder` |
**Second-Pass Binders (20 classes)** - deferred binding logic:
- `GrailsSecondPass`, `SetSecondPass`, `ListSecondPass`, `MapSecondPass`
- `CollectionSecondPassBinder`, `CollectionKeyBinder`,
`CollectionOrderByBinder`
- `UnidirectionalOneToManyBinder`, `BidirectionalOneToManyLinker`
- `ManyToManyElementBinder`, `BasicCollectionElementBinder`
- `CollectionWithJoinTableBinder`, `CollectionMultiTenantFilterBinder`
**ID Generators (8 classes):**
- `GrailsIdentityGenerator`, `GrailsNativeGenerator`,
`GrailsSequenceStyleGenerator`
- `GrailsIncrementGenerator`, `GrailsTableGenerator`, `GrailsSequenceWrapper`
**Hibernate Model Classes (30+ classes):**
- `HibernatePersistentEntity`, `HibernatePersistentProperty`,
`HibernateClassMapping`
- `HibernateSimpleProperty`, `HibernateEnumProperty`,
`HibernateEmbeddedProperty`
- `HibernateToOneProperty`, `HibernateManyToOneProperty`,
`HibernateOneToManyProperty`
- `HibernateMappingBuilder` (DSL builder), `HibernateMappingFactory`
### Mapping DSL Compatibility
**All standard H5.6 mapping DSL options are supported in H7.2.** No known
removals for
typical mapping configurations.
```groovy
// All of these continue to work in H7.2:
static mapping = {
table 'my_table'
version false
cache usage: 'read-write'
id generator: 'sequence', params: [sequence_name: 'my_seq']
name column: 'full_name', length: 255
books sort: 'title', order: 'asc', lazy: false
}
```
The identity/generator strategy differs internally: H5.6 used a single
`GrailsIdentifierGeneratorFactory` for all strategies, while H7.2 provides
dedicated generator
classes for each strategy. This is transparent to app developers.
### User-Facing Impact
**None.** The mapping DSL is fully preserved. The decomposition is purely
internal.
The benefit is dramatically improved testability - H7.2 has dedicated unit
tests for each
binder class, compared to H5.6's integration-test-only approach.
---
## 6. Proxy and Lazy Loading
### H5.6 Approach
- ByteBuddy proxies (default since Hibernate 5.3; Javassist still available
but not used)
- `HibernateProxy` interface for detection
- `PersistentCollection.wasInitialized()` for collection initialization
checks
- Groovy proxy detection via direct `ProxyInstanceMetaClass` checks in
`HibernateProxyHandler`
### H7.2 Approach
- **ByteBuddy-only** proxies via custom `ByteBuddyGroovyProxyFactory` and
`GrailsBytecodeProvider`
- `LazyInitializable.wasInitialized()` replaces
`PersistentCollection.wasInitialized()`
- Groovy proxy logic delegated to `GroovyProxyInterceptorLogic` (cleaner
separation)
- `Hibernate.createDetachedProxy()` for proxy creation
### Key Differences
| Aspect | H5.6 Jakarta | H7.2 |
|--------|-------------|------|
| Proxy library | ByteBuddy (Javassist still available) | ByteBuddy only
(Javassist removed) |
| Collection init check | `PersistentCollection.wasInitialized()` |
`LazyInitializable.wasInitialized()` |
| Groovy proxy logic | Inline in `HibernateProxyHandler` | Delegated to
`GroovyProxyInterceptorLogic` |
| Proxy factory | Hibernate's default | Custom `ByteBuddyGroovyProxyFactory`
|
| Bytecode provider | Hibernate's default | Custom `GrailsBytecodeProvider` |
### Bug Found During Review
H7.2's `HibernateProxyHandler.isInitialized()` was missing Groovy proxy
support. The
`ProxyInstanceMetaClass` check that exists in H5.6 was not carried over.
Since
`Hibernate.isInitialized()` returns `true` for any non-Hibernate object,
uninitialized Groovy
proxies were incorrectly reported as initialized.
**Fix**: Added `GroovyProxyInterceptorLogic.isInitialized()` helper and
integrated it into
H7.2's `HibernateProxyHandler`. Submitted as [PR
#15548](https://github.com/apache/grails-core/pull/15548).
### User-Facing Impact
**Minimal for typical usage.** Proxy behavior should be functionally
identical.
**Edge cases:**
- `instanceof HibernateProxy` checks continue to work (ByteBuddy proxies
still implement it).
The risk is code relying on Javassist-specific proxy implementation
classes or behavior -
Javassist support was removed entirely in H7
- Custom `ProxyHandler` implementations may need updating for the new
interfaces
- Lazy loading timing could differ slightly due to ByteBuddy implementation
---
## 7. Session and Transaction Management
### Session
| Aspect | H5.6 Jakarta | H7.2 |
|--------|-------------|------|
| Base class | `AbstractHibernateSession` |
`AbstractAttributeStoringSession` |
| Template | `GrailsHibernateTemplate` (Spring's) | Forked
`GrailsHibernateTemplate` (Spring ORM fork, still heavily used) |
| Bulk operations | `Query.executeUpdate()` |
`MutationQuery.executeUpdate()` |
| Query creation | Via Criteria API | Direct `HibernateQuery` construction |
| Flush mode | Default: `COMMIT`; `AUTO` = Hibernate `AUTO` | Default:
`COMMIT`; `AUTO` = Hibernate `AUTO` |
| Interfaces | `Session` | `Session` + `QueryAliasAwareSession` |
**Flush mode behavior**: Both H5 and H7 default to `COMMIT` mode, with
`AUTO` available when
`autoFlush: true` is configured. Hibernate 7's native `AUTO` flush mode is
less aggressive
than Hibernate 5's (H7 uses smart dirty checking, flushing only when queries
might return
stale data). However, **GORM mitigates this difference**: the shared
`Query.flushBeforeQuery()`
method explicitly flushes on `FlushModeType.AUTO` before every query, and
both H5 and H7 HQL
query wrappers call it. The behavioral risk applies primarily to apps using
raw Hibernate
`Session` queries outside of GORM's query layer.
### Transaction Management
**H5.6**: Uses Spring's
`org.springframework.orm.hibernate5.HibernateTransactionManager`.
**H7.2**: Uses a **forked**
`org.grails.orm.hibernate.support.hibernate7.HibernateTransactionManager`.
**Why forked?** Spring Framework 7 removed its `spring-orm`
Hibernate-specific transaction
manager entirely. Spring 7 treats Hibernate purely as a JPA provider,
expecting apps to use
`JpaTransactionManager`. However, GORM needs Hibernate-specific transaction
semantics
(session binding, flush mode control), so the Spring 6 implementation was
forked, migrated
to Jakarta, and maintained within Grails.
Forked Spring ORM classes in `support/hibernate7/` (22 classes total):
- `HibernateTransactionManager` - core transaction management
- `SessionFactoryUtils` - session factory utilities
- `HibernateTemplate` / `HibernateCallback` / `HibernateOperations` -
template API
- `SpringSessionContext` / `SpringJtaSessionContext` - session context
- `SessionHolder` - session holder for thread-local binding
- `LocalSessionFactoryBean` / `LocalSessionFactoryBuilder` - factory
configuration
- `HibernateExceptionTranslator` - exception translation to Spring
DataAccessException
- `HibernateJdbcException`, `HibernateObjectRetrievalFailureException`,
`HibernateOptimisticLockingFailureException`, `HibernateQueryException`,
`HibernateSystemException` - exception hierarchy
- `ConfigurableJtaPlatform` - JTA integration
- `SpringBeanContainer` - bean container for Hibernate
- `SpringFlushSynchronization` / `SpringSessionSynchronization` -
synchronization
- `support/AsyncRequestInterceptor` - async request handling
- `support/OpenSessionInViewInterceptor` - OSIV support
### Datastore Initialization
**H5.6**: Uses anonymous inner classes for child datastores in
multi-datasource setups.
**H7.2**: Refactored multi-datasource initialization pattern, which:
- Prevents infinite recursion during multi-datasource initialization
- Uses `Action.interpretHbm2ddlSetting()` instead of
`SchemaAutoTooling.interpret()`
- Injects `GrailsBytecodeProvider` for proxy support
### User-Facing Impact
- **Flush mode**: Apps using raw Hibernate `Session` queries outside of GORM
with
`autoFlush: true` may see different flush timing. GORM's own query layer
(`Query.flushBeforeQuery()`) mitigates this by explicitly flushing before
queries. Explicit
`flush: true` or `flushMode: ALWAYS` can be used for raw Session queries
where needed.
(Note: Default GORM mode is `COMMIT` in both H5 and H7, so most apps are
unaffected.)
- **Transaction manager**: Same public API, transparent to app code.
- **Multi-datasource**: Should be more stable in H7.2 due to the refactored
initialization
pattern.
---
## 8. Shared Module Changes (Impact on ALL Datastores)
These changes affect `grails-datamapping-core`, `grails-datastore-core`, and
`grails-datamapping-tck` - modules shared by Hibernate 5, Hibernate 7, AND
MongoDB.
### 8.1 Query.java - Core Query Model
**Changes:**
| Change | Before | After | Impact |
|--------|--------|-------|--------|
| `max` / `offset` fields | `int max = -1; int offset = 0` | `Integer max;
Integer offset` | Nullable allows distinguishing "not set" from "set to 0" |
| New `getMax()` / `getOffset()` getters | None (field access) | Public
`Integer` getters | Better encapsulation |
| New `countResults()` method | N/A | Default implementation with projection
fallback | Shared count logic, overridable by datastores |
| New `QueryElement` interface | N/A | Parent of both `Criterion` and
`Projection` | Unifies query elements for h7's JPA translation |
| `Projection` | Plain class | Implements `QueryElement` | Enables uniform
handling in h7 query creator |
| `distinct()` | No-op | Adds `Projections.distinct()` | **Behavioral
change** - distinct now actually adds a projection |
**Risk**: The `distinct()` fix is a behavioral change. Previously
`distinct()` on a
`ProjectionList` was a no-op. Now it adds a distinct projection. This could
affect query
results for all datastores if any code path called `distinct()` expecting it
to do nothing.
### 8.2 MappingFactory.java - Property Creation
**Changes:**
All property creation methods (`createIdentity`, `createSimple`,
`createOneToOne`,
`createManyToOne`, `createOneToMany`, `createManyToMany`, `createEmbedded`,
`createEmbeddedCollection`, `createBasicCollection`, `createCustom`,
`createTenantId`) were
refactored from **anonymous inner classes** to **named concrete classes**.
Before (H5 pattern):
```java
public Simple<T> createSimple(...) {
return new Simple<>(owner, context, pd) {
PropertyMapping<T> propertyMapping = createPropertyMapping(this,
owner);
public PropertyMapping<T> getMapping() {
return propertyMapping;
}
};
}
```
After (H7 pattern):
```java
public Simple<T> createSimple(...) {
SimpleWithMapping<T> simple = new SimpleWithMapping<>(owner, context,
pd);
simple.setMapping(createPropertyMapping(simple, owner));
return simple;
}
```
12 new `*WithMapping` classes added to `grails-datastore-core`:
- `IdentityWithMapping`, `TenantIdWithMapping`, `SimpleWithMapping`,
`CustomWithMapping`
- `OneToOneWithMapping`, `ManyToOneWithMapping`, `OneToManyWithMapping`,
`ManyToManyWithMapping`
- `EmbeddedWithMapping`, `EmbeddedCollectionWithMapping`, `BasicWithMapping`
- `PropertyWithMapping` (base interface with `setMapping()`)
Also: `PropertyMapping` anonymous inner classes replaced with
`DefaultPropertyMapping`,
and `IdentityMapping` anonymous inner classes replaced with
`DefaultIdentityMapping`.
Additionally, `createDerivedPropertyMapping` was changed from `private` to
`protected`,
allowing H7's `HibernateMappingFactory` to override it.
**Risk**: Low. The returned types are the same base types (`Simple`,
`OneToMany`, etc.).
Subclasses of `MappingFactory` that override these methods will still work.
The concrete
`*WithMapping` types add a `setMapping()` capability that anonymous inner
classes lacked,
which is needed by H7's two-phase binding but is backward compatible.
### 8.3 PersistentProperty.java - New Default Methods
**Added default methods:**
| Method | Purpose |
|--------|---------|
| `getMappedForm()` | Convenience - `getMapping().getMappedForm()` with null
safety |
| `isUnidirectionalOneToMany()` | Type check + bidirectional check |
| `isLazyAble()` | Whether property supports lazy loading |
| `isBidirectionalManyToOne()` | Type check + bidirectional check |
| `supportsJoinColumnMapping()` | `ManyToMany` or unidirectional `OneToMany`
or `Basic` |
| `isSorted()` | Whether collection type is `SortedSet` |
| `isCompositeIdProperty()` | Whether property is part of composite identity
|
| `isIdentityProperty()` | Whether property is the identity |
| `getOwnerClassName()` | Owner's class name with null safety |
**Risk**: Low. These are all `default` interface methods. They add
convenience without
breaking existing implementations. However, if any implementation has a
method with the same
signature but different semantics, it could shadow the default. MongoDB's
property
implementations should be checked.
### 8.4 GormStaticApi.groovy - Behavioral Changes
**`merge()` method - BEHAVIORAL CHANGE:**
Before:
```groovy
D merge(D d) {
execute({ Session session ->
session.persist(d)
return d // returns the ORIGINAL object
} as SessionCallback)
}
```
After:
```groovy
D merge(D d) {
execute({ Session session ->
Object merged = session.merge(d)
return (D) merged // returns the MERGED object (may be different
instance)
} as SessionCallback)
}
```
This is significant: `merge()` previously called `persist()` and returned
the original
object. Now it calls `session.merge()` and returns the result. In
Hibernate/JPA, `merge()`
returns a **new managed instance** while the original remains detached. Code
that keeps a
reference to the original object after calling `merge()` may now be working
with a stale
detached instance.
**Scope of `merge()` change**: This affects all three entry points:
- **Instance method**: `domainObj.merge()` (most common in app code)
- **Static method**: `DomainClass.merge(domainObj)`
- **Raw Session**: `session.merge(entity)` (already had this semantic in
both H5 and H7)
The behavioral change is in GORM's `GormStaticApi.merge()`, which both the
instance and
static methods delegate to. Raw `Session.merge()` always returned a new
instance in both
Hibernate versions - the change is that GORM now correctly propagates this
semantic.
**Other changes (style/formatting only, no behavioral impact):**
- Import reordering
- Removed redundant `else` blocks
- Parentheses removal from method calls (`session.persist(x)` ->
`session.persist x`)
- `public` modifier added to several generic methods
- Removed trailing spaces after casts
### 8.5 GormEntity.groovy - Named Query Accessor Removal
**Removed:**
- `getNamedQuery(String queryName)` - deprecated since Grails 3.2
- `getNamedQuery(String queryName, Object... args)` - deprecated since
Grails 3.2
- Import of `GormQueryOperations`
**Note**: Named query *support* itself is not removed -
`GormStaticApi.methodMissing()` still
dispatches named queries at runtime. Only the explicit `getNamedQuery()`
accessor methods
(which were marked `forRemoval = true`) have been removed.
**Risk**: Low for most apps. The deprecated accessors have had `forRemoval =
true` markers
for years. Apps calling `getNamedQuery()` directly will get a compile error;
apps using named
queries via method-missing dispatch (the standard usage pattern) are
unaffected. The
recommended replacement for explicit accessor calls is `where` queries.
### 8.6 DetachedCriteria.groovy - Query Behavior Changes
**Changes:**
1. **`list()` method - BEHAVIORAL CHANGE:**
Before: Only created a `PagedResultList` when `args?.max` was set.
After: Creates a `PagedResultList` when `args` is non-null and non-empty,
AND calls
`DynamicFinder.populateArgumentsForCriteria()` to apply
sorting/pagination.
This means passing `[sort: 'name']` without `max` will now return a
`PagedResultList`
instead of a plain `List`. Previously, sort-only args would return a
plain list.
2. **`count()` method - SIMPLIFIED:**
Before: Had a special code path that loaded all rows when user-defined
projections
existed (with a warning log). Used `@Slf4j` for logging.
After: Delegates to `query.countResults()` (the new `Query` method),
which has the same
fallback behavior but without the logging. Removed `@Slf4j` annotation.
3. **`clone()` - visibility change:** `protected` -> default (public in
Groovy)
### 8.7 DynamicFinder.java - Query Junction Refactoring
**New methods:**
- `getJunction(DynamicFinderInvocation)` - builds the query junction from
expressions,
handling the AND/OR operator. Extracted from `AbstractFindByFinder` for
reuse.
- `buildQuery(DynamicFinderInvocation, Session)` - builds a complete query
from an
invocation. Previously this logic was spread across `FindAllByFinder` and
`AbstractFindByFinder`.
- `firstExpressionIsRequiredBoolean()` - hook for subclasses (default:
`false`). Used by
`CountByFinder` to handle boolean expressions in counting queries.
**Finder class simplification:** `AbstractFindByFinder` and
`FindAllByFinder` were
significantly simplified (-89 lines) by extracting common logic up to
`DynamicFinder`.
### 8.8 PagedResultList / PagedList - Interface Extraction
**New interface**: `PagedList<E>` - extracted from `PagedResultList`.
```java
public interface PagedList<E> extends List<E>, Serializable {
int getTotalCount();
List<E> getResultList();
// ... default method implementations delegating to getResultList()
}
```
`PagedResultList` now implements `PagedList` instead of directly
implementing `List` +
`Serializable`.
**New behavior in `PagedResultList.initTotalCount()`**: When cloning the
query for counting,
it now resets `offset(0)` and `max(-1)` to ensure the count query counts ALL
rows, not just
the current page.
**Risk**: Code that does `instanceof PagedResultList` will still work. Code
that does
`instanceof List` will still work. The `PagedList` interface enables H7 to
provide its own
`PagedResultList` implementation (extending Hibernate's `PagedResultList`
wrapper) while
sharing the interface with the core module. H7.2 provides
`HibernatePagedResultList` which
extends Hibernate 7's own `PagedResultList` wrapper while implementing the
shared `PagedList`
interface.
### 8.9 TCK (Test Compatibility Kit) Changes
The TCK (`grails-datamapping-tck`) had extensive changes:
- **`GrailsDataTckManager`**: Refactored - `domainClasses` field changed
from public to
private; must use `addAllDomainClasses(Collection<Class>)` instead of
field access.
- **New domain classes**: `ChildPersister`, `Child_BT_Default_P`,
`EagerOwner`,
`Owner_Default_Bi_P`, `Owner_Default_Uni_P`, `SimpleCountry` - for testing
association/persistence patterns
- **New test spec**: `PagedResultSpecHibernate` - Hibernate-specific paged
result tests
(separate from the shared `PagedResultSpec`)
- **New test spec**: `RLikeSpec` - regex query tests
- **Expanded specs**: `EnumSpec` (+135 lines), `FindByMethodSpec` (+206
lines),
`OptimisticLockingSpec` (+144 lines), `SizeQuerySpec` (+274 lines)
- **Numerous spec refinements**: Added `setupSpec`/`cleanupSpec` blocks,
improved assertion
specificity, added edge case coverage
---
## 9. BOM Strategy
### Current Structure
```
grails-bom (base)
|-- defaults to h5-compatible liquibase (4.27.0)
|-- includes all framework module versions
|
+-- grails-hibernate5-bom
| |-- extends grails-bom (no overrides - pure alias)
|
+-- grails-hibernate7-bom
|-- extends grails-bom
|-- overrides liquibase to h7-compatible versions
|-- adds liquibase-hibernate7 (replaces liquibase-hibernate5)
```
### What Differs Between BOMs
The primary difference is the **Liquibase extension artifact**:
| Dependency | Base/H5 BOM | H7 BOM |
|------------|-------------|--------|
| `org.liquibase:liquibase-core` | 4.27.0 (strictly) | 4.27.0 (strictly) |
| `org.liquibase:liquibase-cdi` | 4.27.0 (strictly) | 4.27.0 (strictly) |
| `org.liquibase.ext:liquibase-hibernate5` | 4.27.0 (strictly) | N/A |
| `org.liquibase.ext:liquibase-hibernate7` | N/A | 4.27.0 (strictly) |
Note: Currently both BOMs use 4.27.0 for all Liquibase dependencies. The
versions are
managed via `gradle.properties` (`liquibaseHibernate5Version`,
`liquibaseHibernate7CoreVersion`,
etc.) and may diverge in the future as Hibernate 7 requires newer Liquibase
features.
The Hibernate version itself is NOT in any BOM - it comes transitively from
`grails-data-hibernate5` or `grails-data-hibernate7`.
### Assessment
The split BOM approach is the industry-standard pattern for this problem.
Spring Boot does
the same (their BOM picks one version, and overrides are required for
alternatives).
The base `grails-bom` defaults to H5-compatible versions, and
`grails-hibernate5-bom`
inherits it as-is (convenience alias for symmetry), while
`grails-hibernate7-bom` inherits
and overrides only what differs. This keeps the upgrade path simple:
existing apps importing
`grails-bom` continue to work unchanged with H5, and switching to H7 is a
single BOM swap.
---
## 10. Breaking Changes for Grails App Developers
**Impact varies by usage pattern:**
1. **Standard GORM DSL users** (where queries, criteria DSL, dynamic
finders) - minimal impact. The GORM API is preserved.
2. **Raw Hibernate Session/HQL users** (`withSession`, `withNewSession`,
direct HQL) - moderate impact. Session API method names changed, flush behavior
differs for raw queries.
3. **Hibernate SPI users** (custom `UserType`, proxy handlers, criteria
extensions) - significant impact. Type system, proxy internals, and criteria
API all changed.
### High Impact
| Change | Who's Affected | Migration |
|--------|---------------|-----------|
| `merge()` returns new instance | Apps calling `.merge()` and keeping
reference to original | Use the returned object: `obj = obj.merge()` |
| Named query accessors removed | Apps calling `getNamedQuery()` directly
(deprecated since 3.2) | Convert to `where` queries or rely on method-missing
dispatch |
| Flush mode `AUTO` behavior | Apps using raw Hibernate `Session` queries
with `autoFlush: true` | GORM queries are mitigated; add explicit `flush: true`
for raw Session queries |
| Hibernate Criteria API gone | Apps with `withSession {
session.createCriteria(...) }` | Use GORM criteria DSL or HQL |
### Medium Impact
| Change | Who's Affected | Migration |
|--------|---------------|-----------|
| Sequence naming default | Apps with existing schemas using
`hibernate_sequence` | Set `hibernate.id.db_structure_naming_strategy=legacy` |
| `saveOrUpdate()` removed | Apps using raw Hibernate
`session.saveOrUpdate()` | Use `session.persist()` / `session.merge()` |
| `session.load()` / `session.delete()` deprecated | Apps using raw
Hibernate `session.load()` or `session.delete()` | Prefer
`session.getReference()` / `session.remove()` (legacy methods still work but
JPA-aligned methods are recommended) |
| Serializable ID constraint relaxed | Custom ID types not implementing
Serializable | Generally a non-issue (relaxation, not restriction) |
| Boolean type mappings | Custom `yes_no`, `true_false`, `numeric_boolean`
types | Use JPA `AttributeConverter` (e.g., `YesNoConverter`) |
| `DetachedCriteria.list()` with non-max args | Code passing sort-only args
expecting plain List | Handle `PagedList` return type |
### Low Impact (Internal Only)
| Change | Who's Affected | Notes |
|--------|---------------|-------|
| `distinct()` now adds projection | Internal query builders | Behavioral
fix, previously was a no-op |
| `countResults()` fallback | Datastores with custom count | Can override
for optimization |
| `PersistentProperty` new defaults | Custom datastore implementations |
Additive - won't break existing code |
| `*WithMapping` classes | Custom `MappingFactory` subclasses | Backward
compatible - same return types |
| TCK `GrailsDataTckManager` | Custom TCK test suites | Use
`addAllDomainClasses()` instead of field access |
### Not Breaking (Preserved)
- GORM `where` queries
- GORM criteria DSL
- Dynamic finders (`findBy*`, `findAllBy*`, `countBy*`)
- Domain class mapping DSL
- Validation and constraints
- Multi-tenancy API
- `withTransaction`, `withNewSession`, etc.
- `save()`, `delete()`, `get()`, `list()`, `count()` on domain classes
- GORM events and listeners
---
## 11. Migration Tooling - Liquibase
Liquibase support is version-specific:
| Hibernate | Liquibase Core | Liquibase Extension |
|-----------|---------------|---------------------|
| 5.x | 4.27.0 | `liquibase-hibernate5` 4.27.0 |
| 7.x | 4.27.0 | `liquibase-hibernate7` 4.27.0 |
Note: Both currently use 4.27.0. The versions are managed separately in
`gradle.properties`
and may diverge in the future.
The `liquibase-hibernate5` and `liquibase-hibernate7` extensions are
different artifacts
because they use Hibernate-version-specific APIs for schema introspection.
This is the
primary reason the BOMs must differ.
**Repo-level detail**: The `grails-data-hibernate7` module includes
Grails-maintained
Liquibase integration code for H7, not just an external artifact swap. The
Liquibase H7
extension uses Hibernate 7's metadata APIs for schema diff/generation, which
changed
significantly from H5.
---
## 12. Test Coverage Status
### 12.1 Non-Hibernate-7 Modules (Phase 1 - COMPLETE)
All test coverage was preserved for:
- `grails-datamapping-core-test` (in-memory datastore)
- `grails-data-hibernate5` (Hibernate 5)
- `grails-data-mongodb` (MongoDB)
### 12.2 Hibernate 7 vs Hibernate 5 Parity (Phase 2 - COMPLETE)
H7 has **dramatically more** test files than H5 (~256 vs ~107) due to the
domain binder
decomposition creating many focused unit tests.
Only real coverage gap found: `HibernateProxyHandler7Spec` had 5 tests vs
H5.6's 20.
**Fixed** - expanded to 21 tests (20 matching H5.6 + 1 new Groovy proxy
test).
### 12.3 H7 Test Exclusions (Tests Skipped or Failing on H7)
A systematic comparison of `@Ignore`, `@PendingFeature`, and `@Requires`
annotations across
both H5 and H7 test suites.
#### True H7 Regressions (work on H5, fail on H7)
~~Only **one** test was a genuine H7-specific regression:~~
**RESOLVED** - [PR #15549](https://github.com/apache/grails-core/pull/15549)
(branch `fix/h7-multi-datasource-executequery`)
#### Shared Failures (broken on both H5 and H7)
These tests are excluded on H7, but are **also** excluded on H5 - they are
pre-existing issues,
not H7 regressions:
| Test | H7 Annotation | H5 Annotation | Issue |
|------|---------------|---------------|-------|
| `TwoUnidirectionalHasManySpec."test two JPA unidirectional one to many
references"` | `@PendingFeature` ("JPA @OneToMany unidirectional mapping
generates non-nullable join column in Hibernate 7") | `@Ignore` (2 tests) |
Unidirectional `@OneToMany` mapping issue exists in both versions |
| `SubclassMultipleListCollectionSpec` (entire class) | `@Ignore` |
`@Ignore` | Same issue on both versions |
#### H7 Tests Requiring Docker (Not Failures)
These H7 tests are gated by `@Requires` for Docker/Testcontainers
availability. They are
H7-only tests with no H5 equivalent - they represent **new** coverage, not
gaps:
| Test | Annotation | Purpose |
|------|------------|---------|
| `GrailsSequenceGeneratorEnumSpec` | `@Requires({
DockerHelper.isAvailable() })` | Enum sequence generation with PostgreSQL |
| `HibernateDatastoreIntegrationSpec` | `@Requires({
DockerHelper.isAvailable() })` | Full datastore integration with PostgreSQL |
| `RLikeHibernate7Spec` | `@Requires({ DockerHelper.isAvailable() })` |
Regex query support with PostgreSQL |
#### Previously Broken, Now Fixed in H7
These H7 tests have **commented-out** `@Ignore` annotations, showing issues
that were resolved
during development:
| Test | Original Annotation | Status |
|------|---------------------|--------|
| `GlobalConstraintWithCompositeIdSpec."idEq()"` | `@Ignore("DDL not working
for composite id")` (commented out) | Now passes |
| `HibernateQuerySpec."idEq()"` | `@Ignore("Need better implementation of
Predicate")` (commented out) | Now passes |
#### H5 Test Files With No Direct H7 Counterpart
Three H5 test files have no single matching H7 file, but all have
**equivalent or better**
coverage spread across multiple H7 specs:
| H5 Test File | H5 Tests | H7 Coverage | H7 Tests |
|-------------|----------|-------------|----------|
| `ByteBuddyProxySpec` | 5 | Split across `Hibernate7GroovyProxySpec`,
`ByteBuddyGroovyProxyFactorySpec`, `GroovyProxyInterceptorLogicSpec`,
`ToOneProxySpec`, `HibernateProxyHandler7Spec` | More total coverage |
| `Hibernate5OptimisticLockingSpec` | 2 | `Hibernate7OptimisticLockingSpec`
| 3 (more coverage) |
| `HibernateProxyHandler5Spec` | 20 | `HibernateProxyHandler7Spec` | 21
(more coverage) |
#### H5 Exclusions (for reference)
The H5 test suite has its own set of exclusions - several of which are
**not** present in H7:
| H5 Test | Annotation | Also in H7? |
|---------|------------|-------------|
| `UniqueConstraintHibernateSpec` | 1 `@Ignore` | No (H7 clean) |
| `ByteBuddyProxySpec` | 3 `@PendingFeatureIf` (fail without yakworks
library) | No (different proxy architecture) |
| `HasManyWithInQuerySpec` | 1 `@Ignore` | No (H7 clean) |
| `TwoUnidirectionalHasManySpec` | 2 `@Ignore` | Yes (1 `@PendingFeature`) |
| `SaveWithInvalidEntitySpec` | 1 `@Ignore` | No (H7 clean) |
| `SubclassMultipleListCollectionSpec` | Class `@Ignore` + 1 method
`@Ignore` | Yes (class `@Ignore`) |
| `UniqueWithMultipleDataSourcesSpec` | 1 `@Ignore` | No (H7 clean) |
| `WhereQueryOldIssueVerificationSpec` | 0 exclusions | 0 exclusions (clean
on both) |
#### Summary
- **0 true H7 regressions**: The one regression
(`MultipleDataSourceConnectionsSpec` GString
coercion) has been **fixed** in [PR
#15549](https://github.com/apache/grails-core/pull/15549)
- **2 shared failures**: Pre-existing issues in both H5 and H7, not
regressions
- **3 Docker-gated tests**: New H7-only coverage requiring Testcontainers
(not failures)
- **2 tests fixed**: Previously broken in H7 development, now passing
- **0 coverage gaps**: All H5 test files have equivalent or better H7
coverage
- **H5 has more exclusions than H7**: H5 has 8 files with exclusions vs H7's
2 (excluding Docker-gated)
---
## 13. Risks and Resolved Questions
### Risks
1. **Flush mode behavioral change (raw Session only)**: Hibernate 7's native
`AUTO` flush
mode is less aggressive than Hibernate 5's (smart dirty checking vs
flush-before-every-query).
However, GORM's shared `Query.flushBeforeQuery()` explicitly flushes on
`FlushModeType.AUTO`,
mitigating this for all GORM queries. The risk applies only to apps using
raw Hibernate
`Session` queries outside GORM's query layer with `autoFlush: true`.
(Default GORM mode is
`COMMIT` in both, so apps without `autoFlush: true` are unaffected.)
2. **`merge()` return value**: The change from returning the original object
to returning the
merged instance is a semantic change. Apps that call `merge()` but
continue using the
original reference may silently work with stale data.
3. **`DetachedCriteria.list()` with args**: Returning `PagedResultList` for
any non-empty
args (not just when `max` is set) could change behavior for code that
passes sort-only
arguments.
4. **Spring ORM fork maintenance**: The forked `HibernateTransactionManager`
and related
classes will need ongoing maintenance as they diverge from Spring's
JPA-based approach.
Security patches in Spring's transaction management won't automatically
flow to the fork.
5. **`distinct()` behavioral fix**: Previously a no-op, now actually adds a
distinct
projection. If any code path relied on `distinct()` being a no-op, query
results could
change.
### Resolved Questions
1. **MongoDB impact of shared changes** - **SAFE, no issues found.**
MongoDB GORM does not implement custom `PersistentProperty` classes. It
uses the standard
property types from `grails-datastore-core` (`Identity`, `Basic`,
`ToOne`, `ManyToOne`,
`OneToMany`, `ManyToMany`, `Embedded`, etc.) created by the shared
`MappingFactory`. All
new default methods use `instanceof` checks against these same core
types, so they apply
correctly to MongoDB's property instances.
- `isLazyAble()` - checks `ToOne`, `Embedded`, `Association` - correct
for MongoDB
- `isBidirectionalManyToOne()` - checks `ManyToOne` + bidirectionality -
correct
- `isUnidirectionalOneToMany()` - checks `OneToMany` + bidirectionality -
correct
- `supportsJoinColumnMapping()` - returns true for
`ManyToMany`/unidirectional `OneToMany`/`Basic` -
join columns don't exist in MongoDB documents, but this method isn't
used in MongoDB code
- `isCompositeIdProperty()`, `isIdentityProperty()`, `isSorted()`,
`getMappedForm()`,
`getOwnerClassName()` - all generic, datastore-agnostic
MongoDB has 100+ test specs covering associations, embedded, identity,
etc. with no reported
failures from these defaults.
2. **PagedList interface adoption** - **MongoDB has no paged result
implementation to update.**
The `PagedList<E>` interface (in `grails-datamapping-core`) defines
`getTotalCount()` and
`getResultList()`, extending `List<E>`. The shared `PagedResultList`
implements it. Hibernate
5 has two `PagedResultList` classes extending the shared implementation.
MongoDB has **no**
`PagedResultList` implementation in `grails-data-mongodb/` - it uses the
shared one directly.
No action needed.
3. **HQL implicit join behavior** - **No affected queries found in the
codebase.**
The Hibernate 7 change (`from Person p join p.address` returning
`List<Person>` instead of
`List<Object[]>`) does not affect existing GORM queries. GORM's HQL
queries either select
single entities or use explicit joins for filtering rather than expecting
array results from
implicit joins. No code in the codebase processes HQL join results as
`Object[]` arrays.
This remains a risk for **end-user applications** that write raw HQL with
implicit joins
expecting array results, but it is not a framework-level issue.
4. **Custom UserType migration** - **Compatibility layer exists, but method
signatures changed.**
`org.hibernate.usertype.UserType` still exists in Hibernate 7 as a
generic interface
(`UserType<J>`). It has NOT been removed. However:
- `nullSafeGet(ResultSet, int, SharedSessionContractImplementor, Object)`
is **deprecated
for removal** - replace with `nullSafeGet(ResultSet, int,
WrapperOptions)`
- `nullSafeSet(PreparedStatement, J, int,
SharedSessionContractImplementor)` is **deprecated
for removal** - replace with `nullSafeSet(PreparedStatement, J, int,
WrapperOptions)`
- `getSqlType()` now returns `int` from `org.hibernate.type.SqlTypes`
- Hibernate 7 includes `UserTypeLegacyBridge` as an internal adapter
**Recommended migration path**: For simple types, use JPA
`AttributeConverter` instead
(limitation: cannot be used for `@Id`, `@Version`, or association
attributes). For complex
types, update `UserType` method signatures to use `WrapperOptions`
instead of
`SharedSessionContractImplementor`.
GORM's internal custom types (like `IdentityEnumType`) have already been
migrated to the
new signatures in the H7 module. The `@Type` annotation now takes the
class directly:
`@Type(MyCustomType.class)`.
| Feature | Hibernate 5.6 | Hibernate 7.2 |
|---------|---------------|---------------|
| `UserType` | Primary custom type API | Supported (deprecated SPI
methods) |
| `JavaType` / `JdbcType` | Internal | Primary underlying system |
| `AttributeConverter` | Supported | Recommended for simple types |
| `nullSafeGet` (Session param) | Active | Deprecated for removal |
| `nullSafeGet` (WrapperOptions param) | N/A | Mandatory replacement |
---
## 14. Recommendations
### For the PR
**Verified finding**: MongoDB compatibility is confirmed safe - shared
module changes
(`PersistentProperty` defaults, `DetachedCriteria.list()` behavior,
`PagedList` interface)
do not affect MongoDB. No action needed.
1. **Document the `merge()` behavioral change** in migration notes. This is
the most likely
source of subtle bugs for upgrading apps.
2. **Document the flush mode nuance** (Hibernate 7's native `AUTO` is less
aggressive than
Hibernate 5's, but GORM's `Query.flushBeforeQuery()` mitigates this for
GORM queries).
Note that the risk applies only to raw Hibernate `Session` usage with
`autoFlush: true`.
The default GORM mode (`COMMIT`) is unchanged.
3. **For apps with raw Session queries and `autoFlush: true`**, suggest
using explicit
`flush: true` on those specific operations, or `flushMode: ALWAYS` as a
broader workaround:
```yaml
grails:
gorm:
flushMode: ALWAYS # only if raw Session flush timing is critical
```
### For Grails 8 Migration Guide
1. Provide a "Hibernate 7 Migration Checklist" covering:
- `merge()` return value
- Flush mode
- Named query accessor removal
- Sequence naming strategy
- Custom `UserType` migration (update `nullSafeGet`/`nullSafeSet`
signatures from
`SharedSessionContractImplementor` to `WrapperOptions`, or migrate to
`AttributeConverter`)
- Direct Hibernate Session API changes (`saveOrUpdate()` removed; prefer
`persist()`/`merge()`/`remove()`/`getReference()` over legacy equivalents)
- HQL implicit join return type change (array to entity) for apps with
raw HQL queries
2. Provide Liquibase migration instructions (different extension artifact).
3. Clearly document that the GORM DSL is preserved - most app code needs
zero changes.
### For Long-Term Maintenance
1. **Plan for Hibernate 5 deprecation** timeline - when does H5 support end
in Grails?
2. **Monitor the Spring ORM fork** for security implications.
3. **Consider contributing** the forked transaction manager back to Spring
as a Hibernate 7
module, or explore whether `JpaTransactionManager` can be adapted.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]