Skip to content

Hibernate 7: native domain binding through Hibernate's annotation binder - #16568

Open
borinquenkid wants to merge 174 commits into
refactor/java-to-groovy-grails-data-hibernate7from
9.0.x-h7-native-binding
Open

borinquenkid wants to merge 174 commits into
refactor/java-to-groovy-grails-data-hibernate7from
9.0.x-h7-native-binding

Conversation

@borinquenkid

@borinquenkid borinquenkid commented Oct 10, 2026 •

Copy link
Copy Markdown
Member

Depends on #16572 (the Hibernate 7 modules converted to Groovy): this branch is based on refactor/java-to-groovy-grails-data-hibernate7, so until #16572 merges the diff shown here includes it. Once it merges, the diff shrinks to the 113 commits of this PR. The two follow-ups are stacked on it: native binding as the default, then the deletion of the classic binder.

What

GORM for Hibernate 7 gains native domain binding: a GrailsDomainGenerator turns an already-built GORM PersistentEntity into a JPA/Hibernate-annotated carrier class, and Hibernate's own annotation binder binds it. Sessions, queries and proxies keep using the real domain classes (the carrier's entity name, cache regions and class-loader lookups all resolve to the real class). In this PR it is opt-in (hibernate.generatedDomainClasses: true); the next PR in the stack makes it the default, and the last one deletes the hand-built GrailsDomainBinder.

Where to start reading

Native domain binding replaces GrailsDomainBinder building Hibernate's boot model by hand (about 5,800 lines that re-implement what Hibernate's annotation binder already does): GORM generates a JPA-annotated class for each domain class from its evaluated mapping and lets Hibernate's own annotation binder bind it. The application's real domain classes remain what Hibernate persists, loads, queries and proxies; the generated classes exist only while the mappings are bound.

The code lives in one package, org.grails.orm.hibernate.cfg.domainbinding.jpa, with the value records in the subpackage jpa.facets. Reviewing it is easiest in the order the binding runs:

  1. The switch. HibernateConnectionSourceSettings reads hibernate.generatedDomainClasses. When it is on, HibernateMappingContextConfiguration creates a GeneratedDomainClassBinder and gives it to GrailsDomainBinder, which hands the entities to it instead of binding them itself. (From Hibernate 7: delete the classic domain binder #16570 on there is no switch: GormMappingContributor always hands the entities to the generated-class binder.)
  2. The entry point. GeneratedDomainClassBinder.contribute is called by Hibernate while it collects mappings. It asks GrailsDomainGenerator.generateAll for an annotated class per entity and gives those classes to Hibernate's annotation binder. Its second half, getSessionFactoryBuilder, runs once the metadata is complete and points every bound entity at the real domain class, its property accessors and GORM's identifier generator.
  3. The decisions. GrailsDomainGenerator is where every mapping rule lives. It reads a GrailsHibernatePersistentEntity (properties plus the evaluated mapping and constraints blocks) into the records of jpa.facets, which state what to bind (columns, types, ids, associations, collections, inheritance, caches, tenancy, natural ids, indexes, unique keys), and then writes those facets as JPA and Hibernate annotations with ByteBuddy. A mapping it cannot describe is refused up front, by entity and property name, through supports and unsupportedReason, before Hibernate boots. This is the large class; it will be split into per-concern collaborators once the classic binder is gone, so that its diff stays reviewable against the binder it replaces.
  4. Class loading. GeneratedDomainClassLoader resolves a domain class name to the generated class while Hibernate binds the mappings. Because a class loader answers a name the same way forever once Class.forName has been asked, the switch to the real classes is made by GeneratedDomainClassLoaderService, which swaps to a second service whose loader resolves the same names to the application's classes.
  5. GrailsIdGenerator is a marker annotation carrying the GORM id generator strategy and its parameters, since GORM's generators cannot be named through Hibernate's @IdGeneratorType.

How it is verified. The specs under grails.gorm.tests.generated run the same behaviour through both bindings. Two differential specs compare, for every scanned test domain class (about 1,170 entities and 3,400 properties), what the generator states against what the classic binder bound, both at the Hibernate boot-model level and at the DDL level. The classic side is frozen as recorded data under src/test/resources/classic-oracle, so the comparison survives the removal of the classic binder in #16570. The whole core suite also runs through the generated classes.

The same reading order is in the package-info of the jpa package and in the hibernate-developer skill.

Why

The classic binder re-implements Hibernate's boot model by hand (~5,800 lines) and has been the source of a steady stream of mapping defects (seven of them fixed on 8.0.x during this work: #16520, #16522, #16523, #16524, #16531, #16532, #16533, #16539). Letting Hibernate bind the entities removes that layer; the GORM mapping DSL stays the public surface.

Scope

  • grails-data-hibernate7/core, package org.grails.orm.hibernate.cfg.domainbinding.jpa: GrailsDomainGenerator (ByteBuddy; every mapping option the classic binder honours, incl. composite identifiers via @IdClass, embedded types, all three inheritance strategies, multi-tenancy, natural ids, caches, custom and converting types, custom collection types), GeneratedDomainClassBinder (AdditionalMappingContributor plus an aligner that reproduces the classic binder's naming and DDL decisions where Hibernate's defaults differ), GeneratedDomainClassLoader[Service].
  • ManyToManyOwnerValidator: a bidirectional many-to-many with belongsTo on neither side now fails at startup (MappingException) instead of silently never being written; on 8.0.x the same case logs a warning (Hibernate 7: warn when a many-to-many has no belongsTo on either side #16532).
  • Envers: a generated entity carries a JPA @Version, which Envers would skip; the optimistic-lock column is audited as before unless the application sets org.hibernate.envers.do_not_audit_optimistic_locking_field itself.
  • Tests: 26 both-modes specs under grails.gorm.tests.generated, the whole core suite runnable through the generated classes (-Pgrails.test.generatedDomainClasses=true), and two differentials that compare native binding with the classic binder over every scanned test domain class (1,169 entities, 3,381 properties: 0 mismatches; DDL: 85 known differences, 0 unknown, 0 refused). The classic side of both differentials is frozen as recorded data (src/test/resources/classic-oracle/), so they survive the deletion of the classic binder.
  • Docs: new chapter Native Domain Binding in the GORM for Hibernate 7 manual (what stays the same, what changes in behaviour and schema, switching an existing database, Envers, the one unsupported mapping and its workaround), configuration reference, Hibernate 7 migration checklist, and the Grails 8 → 9 upgrade notes (upgrading90x.adoc §1).

Also in this PR, because the branch has to pass the repository's CodeNarc rules: the grails-startup-progress sources that arrived from upstream in Java are converted to Groovy, and intentionally empty catch parameters are named ignored in seven modules.

Behaviour changes for applications (all documented in the upgrade notes)

  • A many-to-many without an owning side fails at startup.
  • With native binding on: a custom UserCollectionType on a hasMany of entities takes effect (the classic binder ignored it); a shared embedded collection gets a table per owner; an embedded object as a part of a composite identifier is refused with a message pointing at the documented flattening workaround; a handful of schema differences listed in the manual (for example check (idx >= 0) on list index columns).

Verification

Every module of grails-data-hibernate7 in both modes at the tip of this PR: core 4,813 tests (classic default) and 4,523 (generated), dbmigration-core 40, dbmigration 110 + 93 CLI, plugin 39 + 2, spring-boot 2, all green; both differentials as above; codeStyle clean; docs built.

Merge of the Java-to-Groovy review follow-up (#16572)

This branch now contains the reviewed #16572 (merged up the stack), including 9.0.x with the upstream fixes #16533 (unique groups on enum and collection properties) and #16539 (nested composite foreign key column order).

  • GrailsDomainGenerator follows the classic binder for both: it states the unique group of an enum property (UniqueKeyFacets is now record UniqueKeyFacets(name, columns); the bound flag is gone) and orders a nested composite foreign key's columns, and the columns they reference, by identifier property name.
  • The frozen classic oracle (ddl-differential.txt, generator-differential.txt) is refrozen from the classic binder for the fixtures that came with 9.0.x; 48 existing records changed, consistent with Hibernate 7: unique groups on enum and collection properties #16533/Hibernate 7: name nested composite foreign key columns in the referenced key's order #16539.
  • Removed the three "known difference" rows in the native binding guide and the upgrade-guide bullet that described differences which no longer exist. The Hibernate 7 section of the upgrade guide is now 2.x (9.0.x added a GPars section as 1).
  • Verified at the tip: core 4,967 tests, plugin 39, dbmigration-core 41, 0 failures; codeStyle clean.

borinquenkid and others added 4 commits October 10, 2026 10:05
Converts the 24 Java sources of the spring-orm subproject (the
Hibernate 7 copies of Spring's HibernateTemplate, HibernateTransactionManager,
LocalSessionFactoryBean/Builder, SessionFactoryUtils, SpringBeanContainer,
the exception translators, OpenSessionInViewInterceptor and the
TransactionResources pair) to statically compiled Groovy under
src/main/groovy, and adds the Groovy plugin, the Groovy dependency and the
code-style plugin to its build so the new sources are compiled and
Checkstyle/CodeNarc-checked like every other framework module.

Maintainers asked in #16351 for the Java-to-Groovy migration to land one
Gradle project at a time. This is the first slice: it transplants the end
state of the 9.0.x-java-to-groovy branch for this project (original commits
2416bdd..0c2e2c2) onto 9.0.x unchanged, so that the native domain
binding work can be rebuilt on top of Groovy sources.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Converts the 207 Java main sources of grails-data-hibernate7-core
(HibernateDatastore, GrailsHibernateTemplate, the criteria builder, the
query package, the cfg/domainbinding subtree with its binders, generators
and second passes, the connections, proxy, access, support and exceptions
packages) to statically compiled Groovy, and adds two Spock specs written
while reviewing the conversion. The behaviour is unchanged: the module's
own 3657-test suite passes on the converted sources, and the review of the
original branch caught and fixed the Groovy semantics that differ from Java
(reference identity in equals(), Optional.map null-collapse, closure spread,
isX/getX accessor ambiguity).

Maintainers asked in #16351 for the Java-to-Groovy migration to land one
Gradle project at a time. This transplants the end state of the
9.0.x-java-to-groovy branch for this project (original commits
98c59ed..25dde13) onto 9.0.x unchanged. It depends on the Groovy
spring-orm subproject committed just before it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Converts the 35 Java main sources, 19 Java tests and 29 Java test fixtures
of the dbmigration-core subproject (the liquibase-hibernate fork: the
HibernateDatabase family, the snapshot and diff generators, the custom
metadata factory and the harness) to statically compiled Groovy. The
HibernateDatabase classes are annotated with @pojo as well as
@CompileStatic, because their public getProperty(String) API would
otherwise become the GroovyObject property hook and hijack dynamic
property access in the dbmigration plugin's Spock specs. Lombok is dropped
from the build, since the fixtures no longer use it, and jboss-logging is
added as compileOnly because Groovydoc resolves the documented Hibernate
types with Class.forName and hibernate-core exposes it at runtime only.

The RAT exclusions in gradle/rat-root-config.gradle that name the forked
third-party files are repointed from the old .java paths to the new
.groovy paths, so the files stay excluded and the rat task stays green.

Maintainers asked in #16351 for the Java-to-Groovy migration to land one
Gradle project at a time. This transplants the end state of the
9.0.x-java-to-groovy branch for this project (original commits
44f4279..e2ba1e6) onto 9.0.x unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…o Groovy

Converts the 5 Java sources of the Hibernate 7 Grails plugin subproject
(HibernatePersistenceContextInterceptor, GrailsOpenSessionInViewInterceptor,
AggregatePersistenceContextInterceptor and its abstract multi-data-source
base, SessionFactoryAwarePersistenceContextInterceptor) to statically
compiled Groovy. With this commit no Java source is left under
grails-data-hibernate7; the dbmigration, boot-plugin and docs subprojects
were already pure Groovy and are unchanged.

Maintainers asked in #16351 for the Java-to-Groovy migration to land one
Gradle project at a time. This transplants the end state of the
9.0.x-java-to-groovy branch for this project (original commits
ee295ea..06fb6a1) onto 9.0.x unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
borinquenkid and others added 23 commits October 10, 2026 11:14
IdentityEnumTypeSpec gains the IdentityEnumType UserType contract features
(adapted to the position-based nullSafeGet/nullSafeSet and getSqlType API,
plus an integer-id case), GroovyProxyInterceptorLogicSpec gains the missing
isInitialized feature, and DatabaseMultiTenantServiceRoutingSpec and
MultiTenantFinderDispatchSpec are added.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The Hibernate 7 copy was @ignore'd and weakened to assert only A.count() == 1
because of a NullPointerException tracked in issue 10604. That failure no
longer occurs, and the original assertions (an exception is thrown, the
entity has errors, and field1 reports a field error) pass on Hibernate 7.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…nyToManySpec

The Hibernate 7 copy built its entities with plain constructors instead of
saving the department through the @CurrentTenant data service as the Hibernate 5
original does. Restore the service path; it passes on Hibernate 7.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…he default

The mapWith guard joined its two comparisons with OR, which is true for any
non-null value, so a class declaring mapWith = 'hibernate' (or the default
strategy) skipped the transform along with classes mapped by other datastores.
Use AND so only a different strategy skips it, and cover both accepted values.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…rnate 7

NullValueEqualSpec skipped its 'not equal' feature on Hibernate 7 although it
passes, and PagedResultSpec was skipped on Hibernate 7 only because it asserted
the exact class name PagedResultList while Hibernate subtypes it. Lift the first
skip and make the second assertion match any *PagedResultList; the exact
HibernatePagedResultList type stays covered by HibernateGormStaticApiSpec.

The two Hibernate 7 forks that existed to work around those gates
(NullValueEqualSpec and PagedResultSpecHibernate) were verbatim copies and are
removed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Behaviour specs cannot see a renamed column or table because the same binder
writes and reads it. Record the H2 tables, columns, keys and indexes for a
small domain (hasMany, belongsTo, enum, single-table inheritance, element
collection, mapping DSL), plus each entity's Hibernate contributor and bound
data source, so a binder refactor that moves any of them fails a test.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ty list

GrailsDomainBinder ignored the MetadataBuildingContext Hibernate hands to an
AdditionalMappingContributor and built its own MetadataBuildingContextRootImpl
from internal classes. Use the supplied context instead.

The binder also pulled its entities from the mapping context itself, twice, and
HibernateMappingContext.getHibernatePersistentEntities(dataSourceName) set the
data source on every entity as a side effect of a getter. The getter is now a
pure filter; HibernateMappingContextConfiguration computes the entity list once,
after the additional classes have been added, sets the data source on each, and
passes the list to the binder. The unused three-argument constructor is removed
and the test-only re-run takes an explicit entity list.

Entity tagging and the physical schema are unchanged (MappingSnapshotSpec).
AdditionalClassBindingSpec covers entities registered through annotatedClasses,
which only exist once the session factory is being built.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The merged default discriminator column is named class instead of DTYPE
(#16471). Regenerate the snapshot; nothing else in it changes.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ated JPA class

Generate, with ByteBuddy, a class whose fields carry the annotations Hibernate's
annotation binder understands, from an already-built GrailsHibernatePersistentEntity:
@entity and @table on the class, @id on the identifier, and @column on each basic
property. The field set is exactly the entity's persistent properties.

Column facets are not re-derived: the generator runs the constraint binders the
domain binder uses on a scratch Column and reads the result back. Properties
that map several columns are rejected with a clear message until they are supported.

The generator is not wired into the domain binder yet.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…est domain class

Add a differential spec that scans the TCK and Hibernate 7 test domain classes,
groups them by association so each group boots as its own datastore, and checks
that the column facets the generator decides equal what the binder bound on the
Hibernate Column and Property. 1237 properties on 696 entities match; groups
that cannot boot alone (deliberately invalid mappings, multi-datasource entities)
and properties the generator does not support yet are reported, not hidden.

The comparison found four things the generator now handles:
- a table-per-hierarchy subclass has no identifier column of its own, so a
  non-root entity is rejected until inheritance is supported;
- an identifier column is never nullable;
- Bean Validation constraints (@notblank, @digits, @SiZe) become DDL when
  Hibernate applies them after binding, so they are copied onto the generated
  field instead of being mirrored;
- a backtick-quoted column name stays quoted.

The generator now exposes supports() and columnFacets(), and rejects a property
kind it does not support by name instead of describing it wrongly.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ts in GrailsDomainGenerator

The generator now emits @Version for the version property, @Enumerated or
@type(IdentityEnumType) for enums following the enum binder's own rules,
@ColumnDefault, @ColumnTransformer and @comment for the column extras, and
@DynamicInsert, @DynamicUpdate, @batchsize and a table @comment at class level.
Class-level decisions are exposed as an EntityFacets record next to the column
facets.

The differential spec compares all of it with the binder on 696 entities and
1896 properties, and found no mismatch. It now also compares the enum style and
the entity facets with the bound PersistentClass and table, and skips entities
whose persistent class belongs to another entity (an embedded class).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…enerator

A derived property is bound as a Formula with no column, so the generator
emits @formula and no @column. An explicit type (PropertyConfig.type,
Mapping.userTypes, typeParams, or an enum with a type) becomes @type for a
UserType class with its parameters, or @JdbcTypeCode for a registered legacy
name such as text, resolved with Hibernate's own type registry instead of a
hand-kept table. Anything else (serializable, converter names, custom
marshaller types) stays unsupported and is named in the exception.

The differential spec now compares formulas and the bound BasicValue's type
name, parameters and JDBC type, and a generated class is bound by Hibernate's
own annotation binder to confirm the annotations mean what the binder bound.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The binder installs one of GORM's own generator classes on the identifier.
A Hibernate IdGeneratorType annotation cannot name them (the GORM generators
configure themselves, and GrailsNativeGenerator inherits an annotation-based
contract that rejects any annotation but Hibernate's own NativeGenerator), so
the generated id carries a GrailsIdGenerator marker with the strategy and the
identity params, except assigned (plain @id) and uuid/uuid2 (@uuidgenerator,
the same class). The code that binds the generated class installs the
generator with the same GrailsSequenceWrapper call the binder uses, so the
classes and parameters are identical by construction.

The differential spec compares the class and key parameters of the generator
the binder installed on every root entity, and a spec binds generated classes
with Hibernate's own annotation binder and compares them with the binder's.
An entity without a simple identifier is now rejected instead of generating
a class with no @id.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…nerator

Hibernate's annotation binder derives a hierarchy from extends, so the generator
now builds the classes of a hierarchy together (generateAll): each subclass is
generated against its superclass and all of them are loaded into one class loader,
which a Hibernate registry can be given once. The root states @Inheritance,
@DiscriminatorColumn or @DiscriminatorFormula, @DiscriminatorOptions for a
non-inserted discriminator and @DiscriminatorValue; a subclass states only its
value and its own class-level facets, never a table.

Every decision is the binder's own accessor or constraint class; the differential
spec now compares root and subclass entities (kind, superclass, table, abstractness,
discriminator) with the bound PersistentClass and stays at zero mismatches.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A joined root states @Inheritance(JOINED) and its table; each subclass states its own
table and a @PrimaryKeyJoinColumn named like the identifier column, as
JoinedSubClassBinder names the key. The differential spec now compares the key
column with the bound JoinedSubclass and the Hibernate class kind per strategy.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…nerator

A table-per-class root states @Inheritance(TABLE_PER_CLASS) and each subclass its own
table. The generated class is abstract exactly when the entity is, which is how
Hibernate's annotation binder learns that an abstract root's table is abstract; the
facets report the binder's own isTableAbstract for roots. Specs read abstract and
three-level hierarchies back through Hibernate's annotation binder.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…binder in the differential

The facet comparison shows what the generator decides; this also binds the generated
classes of every describable hierarchy with Hibernate's own annotation binder and
compares each PersistentClass (kind, superclass, table, abstractness, properties,
discriminator, key column) with the one the domain binder built.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
An embedded property becomes an @Embedded field of a generated @embeddable
shared by every owner that embeds the same type. The owner states each column
with an @AttributeOverride because the column name (path prefix) and the
nullability (parent property, table-per-hierarchy owner, enclosing components)
depend on the owner; the rules are ComponentBinder's and ColumnBinder's own,
run on a scratch Column.

The differential now compares every embedded component leaf with the binder and
reads every describable entity (not only hierarchies) back through Hibernate's
annotation binder, including the column facets the binder states, so an
override that drops a length or a precision is caught.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…enerator

A hasMany of simple values or enums becomes an @ElementCollection field whose
generic signature names the element, with @CollectionTable and its key
@joincolumn, the element @column, @OrderColumn for a list, @MapKeyColumn for a
map, and the fetch, batch size and cache the mapping states. The table name,
column names and facets come from the binder's own classes (TableForManyCalculator,
the join table accessors, the column config binder); only the few decisions that
live inside a second-pass method are mirrored.

The differential compares each collection with the bound Hibernate Collection and
reads it back through Hibernate's annotation binder. The comparison found binder
defects the generator deliberately does not copy: an owner with more than one
unidirectional collection gets a non-updatable key, which makes Hibernate write no
rows (pinned by a pending feature in BasicCollectionKeyDefectSpec); a SortedSet
and a map of enums cannot be bound; a map's table keeps an unused column.
Collections the binder or Hibernate's annotations cannot describe (extra lazy,
SortedSet, map of enums, embedded collections) are rejected by name.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ainGenerator

The tenant id is an ordinary column, so it is described like a simple property.
The tenant filter the binder adds to a multi-tenant entity is written as @filter
on the entity and one @FilterDef carrying the parameter, not as Hibernate's
@TenantID: that one also sets the tenant on insert and filters every query
through a resolver, while GORM enables the filter itself per session and treats
the tenant id as a normal writable property.

Hibernate refuses a second @FilterDef of the same name, so generateAll puts the
single definition on the first multi-tenant entity of the call. The differential
boots multi-tenant groups in discriminator mode (GORM only gives an entity a tenant
id there), compares each class's own filter and the global definition with the
binder's and reads both back through Hibernate's annotation binder.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
CollectionKeyColumnUpdater made the key of every collection not updatable once
its owner had more than one unidirectional to-many property. For an entity
collection the key is a foreign key in the target table and that rule keeps
several collections from all updating it, but a collection of basic values or
enums lives in a table of its own, and Hibernate writes no rows at all for a
collection whose key is not updatable. An owner with two such collections saved
and reloaded with both of them empty, silently.

Keep the existing rule for entity collections and always leave the key of a
collection of basic values updatable. The regression is covered at three levels:
a unit row on the updater, a persist-and-reload spec for an owner with two
collections (formerly a pending feature that pinned the defect), and the
differential, which no longer needs to record the defect as a known divergence.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…mainGenerator

A generated entity's association field is typed with the class generated for its
target, because Hibernate resolves the target from the field type. generateAll
therefore requires every referenced entity to be part of the call and builds the
target types by name only, so cycles and self references need no ordering.

The foreign key, fetching, cascade and missing-row handling are the binder's own
decisions (ColumnBinder rules on a scratch column, CascadeBehaviorFetcher). The
differential now generates whole connected groups and compares 158 to-one
properties with the bound ManyToOne values, 52 of them also read back through
Hibernate's annotation binder, with no mismatches.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The binder binds a valid one-to-one as a Hibernate OneToOne with no column that
only names the property of the other side holding the foreign key, which is
bound as a many-to-one. The generator states the inverse side as
@OnetoOne(mappedBy) and keeps the foreign key side a @manytoone, because
@OnetoOne with a join column would add a unique constraint the binder does not.

The differential compares 22 inverse sides with the bound OneToOne values and
reads them back through Hibernate's annotation binder with no mismatches.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@borinquenkid
borinquenkid marked this pull request as ready for review October 20, 2026 02:09
@borinquenkid

Copy link
Copy Markdown
Member Author

BLOCKER before merge: this branch carries a temporary CI commit.

This PR is stacked, and to avoid starting a full CI matrix on every push or rebase of the stack it carries one temporary commit, CHANGEME FOR ACCEPTANCE: disable PR workflow triggers. It changes the pull_request: trigger of 13 workflows to branches: ['CHANGEME-FOR-ACCEPTANCE'], a branch that does not exist, so pushing, rebasing or force-pushing this branch starts no jobs.

Condition for merging this PR: that commit must be reverted on this branch first, and CI must then run and be green. The absence of CI checks on this PR is deliberate and does not mean the code was tested. The full build runs on #16576 and #16572, the PRs that target 10.0.x.

borinquenkid and others added 18 commits October 19, 2026 22:05
…nless it shares a join table

Ports the 8.0.x fix for #16665 to the Groovy conversion of CollectionKeyColumnUpdater by hand:
the key is non-updatable only when another unidirectional to-many property of the owner uses the
same collection table (issue 10811), so addTo on an owner with several hasMany to different
classes is written to its join table instead of being discarded.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…/16568-merge-h7

# Conflicts:
#	grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/secondpass/CollectionKeyColumnUpdater.groovy
…the Groovy conversion

The three binder files the fix touches are Groovy here, so the changes are ported by hand into
GrailsDomainBinder.groovy, GrailsPropertyBinder.groovy and HibernatePersistentProperty.groovy, and the
new CompositeUserTypeBinder is added as Groovy. The specs and the docs merge as they are.
… since the last freeze

The composite user type fixtures of #16695, the MultipleUnidirectionalHasManySpec entities of #16665, and the
proxy, trait-initializer, CKCU and property-binder fixtures that arrived with earlier merges were not recorded, so
ClassicOracleSpec failed on every native branch. Recorded with -Pgrails.test.refreezeClassicOracle=true; the two
composite groups the classic binder cannot boot without a default mapping are recorded as unbootable.
… fixed collection-key feature

Hibernate now derives a per-entity proxy base class (Entity$HibernateProxy), so a proxy's direct superclass is no
longer the entity; the serialization spec asserts assignability instead, in both modes. The key of an entity
collection stays updatable since 06d5ae4, so the pending feature of EntityCollectionKeyDefectSpec passes and
is promoted to a regular feature.
The native binding now states what CompositeUserTypeBinder binds: a property mapped with a Hibernate CompositeUserType
becomes an @Embedded field of the value class with @CompositeType and one @AttributeOverride per attribute of the user
type's embeddable class, the attributes taken from its record components or its fields and readable properties in
alphabetical order, the configured columns assigned by position and an attribute without one named
<property>_<attribute>. Each column carries the property's nullability, uniqueness and constraints and its own column
settings, so both bindings create the same schema; more columns than attributes is refused as the binder refuses it.
A composite user type inside an embedded type is reported as unsupported.

The differential harness compares the attribute columns of such a property with the recorded component, and the oracle
records the two fixtures #16695 added. GeneratedDomainClassesCompositeUserTypeSpec boots the fixtures of
CompositeUserTypeMappingSpec in both modes and compares the schemas, round-trips a value and queries by attribute.
…hether a key is updatable

Port of 7138500 (#16686) to the Groovy conversion. CollectionKeyColumnUpdater asked every
unidirectional to-many of the owner for its Hibernate collection; a to-many mapped with
type: 'serializable' or with a user type is bound as a column and has none, and getCollection()
throws a MappingException rather than returning null, so such an owner failed the session factory
build. Filter the siblings with HibernateToManyProperty.isBoundAsCollection.

CollectionKeyColumnUpdaterSpec and MultipleUnidirectionalHasManySpec carry the cases of #16686.
MuhAccount, MuhNote and CKCUColumnBoundOwner join the oracle; the former MuhItem group is regrouped under MuhAccount.
…acets subpackage

The nineteen *Facets records and the two value types they are built from, CollectionKind and EmbeddedLeaf, move
from domainbinding.jpa to domainbinding.jpa.facets, leaving the generator, the generated-class binder and the loaders in
jpa as the classes that do something.
A package overview of domainbinding.jpa and a section of the hibernate-developer skill give the reading order of the
binding: the switch, GeneratedDomainClassBinder.contribute, GrailsDomainGenerator and its facets, the two class loaders
and GrailsIdGenerator, then the specs.
…oovy-grails-data-hibernate7

# Conflicts:
#	grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/HibernateDatastore.groovy
#	grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/binder/CollectionBinder.groovy
#	grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/binder/ManyToOneValuesBinder.groovy
#	grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/secondpass/ToManyEntityMultiTenantFilterBinder.groovy
#	grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/multitenancy/MultiTenantEventListener.groovy
…/16568-merge-h7

# Conflicts:
#	.github/workflows/benchmark.yml
#	.github/workflows/build-health.yml
#	.github/workflows/codeanalysis.yml
#	.github/workflows/codeql.yml
#	.github/workflows/codestyle.yml
#	.github/workflows/coverage.yml
#	.github/workflows/end-to-end.yml
#	.github/workflows/gradle.yml
#	.github/workflows/groovy-snapshot-canary.yml
#	.github/workflows/rat.yml
#	.github/workflows/sitemesh2.yml
#	.github/workflows/validate-actions.yml
#	.github/workflows/vulnerability-scan.yml
@testlens-app

testlens-app Bot commented Oct 22, 2026 •

Copy link
Copy Markdown

🔎 No tests executed 🔎

🏷️ Commit: 686239a
▶️ Tests: 0 executed
⚪️ Checks: 46/46 completed


Learn more about TestLens at testlens.app/docs.

…y paths

A move only, with no change to any file's content, so that the history of
every class survives the conversion that follows: Git and GitHub connect a
rename when the similarity is at least half, and a pure move is a whole
match. The next commit converts the moved files.
The content of #16572 at 194cd43, on top of the move
of the Java sources to their Groovy paths: the grails-data-hibernate7 core
and dbmigration modules and their test fixtures converted to Groovy, with
the semicolons kept under a suppression header as the stage-2 commits
describe, plus the docs and build changes of that pull request. Idiomatic
cleanup stays in the groovyism pull request that follows.
…commit): same tree, so this records the new history only

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

2 participants