Skip to content

Hibernate 7: bind each collection of an embedded entity with its own second pass - #16617

Merged
jdaugherty merged 3 commits into
8.0.xfrom
fix/hibernate7-embedded-hasmany-collection-8.0.x
Oct 14, 2026
Merged

jdaugherty merged 3 commits into
8.0.xfrom
fix/hibernate7-embedded-hasmany-collection-8.0.x

Conversation

@borinquenkid

@borinquenkid borinquenkid commented Oct 11, 2026 •

Copy link
Copy Markdown
Member

Problem

An entity that is also embedded in another can declare a hasMany. With

@Entity class EmbeddedHolder { EmbeddedWithMany embed; static embedded = ['embed'] }
@Entity class EmbeddedWithMany { static hasMany = [members: EmbeddedMember] }
@Entity class EmbeddedMember { String name }

the session factory fails to build with Collection key not bound (an NPE on Collection.getElement()).

Cause

Such an entity is bound twice, once as a component of its holder and once as an entity, and its hasMany property is bound with a different Hibernate collection each time. The property only remembers the last collection. The second passes bound property.getCollection(), so the collection of the first binding never got its element and key.

Fix

ListSecondPass, MapSecondPass and SetSecondPass now keep the collection they were created for and restore it on the property before binding. CollectionBinder passes it in. The existing two-argument constructors are kept.

Tests

EmbeddedEntityCollectionKindsSpec saves and reads back the set, list and map of an embedded entity. With the production files reverted all three cases fail with NullPointerException: Cannot invoke "org.hibernate.mapping.Value.getType()" because the return value of "org.hibernate.mapping.Collection.getElement()" is null. With the fix all three pass.

Known limitation

An embedded entity that is also an @Entity is always bound standalone as well, and both bindings share one join table, so the standalone collection's key ends up with the holder's foreign key. This is out of scope here and tracked in #16634.

Why this is separate

It was found while moving the graphql examples to Hibernate 7 and was sitting inside the Hibernate 5 removal branch (#16350). A removal should only delete, so the fix lands here on 8.0.x and reaches 9.0.x through the merge-up.

🤖 Generated with Claude Code

…own second pass

An entity that is also embedded in another is bound twice, once as a component and once
as an entity, and its hasMany property is bound with a different Hibernate collection
each time. The property only remembers the last collection, and the second passes bound
property.getCollection(), so the collection of the first binding never got its element
and key and the session factory failed with 'Collection key not bound'. Each second pass
now keeps the collection it was created for and restores it on the property before
binding.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
(cherry picked from commit 7c65405)
@borinquenkid
borinquenkid requested review from jamesfredley and jdaugherty and a balanced review from Copilot October 11, 2026 20:20

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@borinquenkid borinquenkid added this to the grails:8.0.1 milestone Oct 11, 2026
@borinquenkid

Copy link
Copy Markdown
Member Author

Full :grails-data-hibernate7-core:test on this branch: 421 suites, 3723 tests, 0 failures, 0 errors, 22 skipped. codenarcMain and checkstyleMain pass.

🤖 Generated with Claude Code

…ntity

CollectionBinderSpec checks that bindCollection registers the second pass that matches
the kind of collection, and that binding one property twice registers a pass and a
collection for each binding.

EmbeddedEntityCollectionKindsSpec saves and reads back an embedded entity whose hasMany is
a set, a list and a map, through a real session, so the set, list and map second passes
all keep their own collection.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
@borinquenkid

Copy link
Copy Markdown
Member Author

Added tests for the other two collection kinds and for CollectionBinderSpec:

  • CollectionBinderSpec: bindCollection registers the second pass that matches the collection (set, list, map), and binding one property twice registers a pass and a collection for each binding.
  • EmbeddedEntityCollectionKindsSpec (integration, HibernateGormDatastoreSpec): an embedded entity whose hasMany is a set, a list and a map is saved and read back through a real session. With the four production files reverted all three fail with the same Collection.getElement() NPE; with the fix they pass.

Full :grails-data-hibernate7-core:test on the branch: 422 suites, 3730 tests, 0 failures, 0 errors, 22 skipped. codenarcTest and checkstyleTest pass.

Not covered here: an entity that is both embedded and used on its own. Its standalone hasMany join table has a foreign key to the holder's table, because the two bindings share one collection table. That limitation exists independently of this fix, since the session factory did not build at all before it.

🤖 Generated with Claude Code

@codecov

codecov Bot commented Oct 11, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 59.5715%. Comparing base (c424313) to head (fcee01d).

Additional details and impacted files

Impacted file tree graph

@@                Coverage Diff                 @@
##                8.0.x     #16617        +/-   ##
==================================================
- Coverage     59.5761%   59.5715%   -0.0046%     
+ Complexity      25033      25030         -3     
==================================================
  Files            2175       2175                
  Lines          108651     108651                
  Branches        19821      19821                
==================================================
- Hits            64730      64725         -5     
- Misses          34975      34984         +9     
+ Partials         8946       8942         -4     

see 2 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@jdaugherty jdaugherty left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The fix is right: CollectionBinder.bindCollection creates a fresh collection per binding but the property only holds the last one, so a second pass that reads property.getCollection() binds the wrong collection. Carrying the collection in the second pass and restoring it before binding is also how the Hibernate 5 binder worked (GrailsCollectionSecondPass/ListSecondPass/MapSecondPass all took the collection), so this restores parity rather than inventing a new model. The keys still come from collection.getOwner(), so each binding gets the right owner even though setPersistentClass on the shared entity is overwritten by whichever binding ran last. Merges cleanly into 8.1.x and 9.0.x; Checkstyle, CodeNarc and the repository-conventions check are clean on the module.

Two non-blocking notes inline.

@@ -216,11 +216,11 @@ private void bindCollectionTable(HibernateToManyProperty property, Collection co

private void registerSecondPass(HibernateToManyProperty property, Collection collection) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

On the limitation in the description: GormMappingConfigurationStrategy.getOrCreateEmbeddedEntity returns the same PersistentEntity when the embedded type is itself an @Entity, so an embedded entity with a hasMany is always also bound standalone. Both bindings then resolve the same join table name (TableForManyCalculator names it from the property's owner) and mappings.addTable hands back the same Table, which is why the standalone collection's key ends up with the holder's foreign key. Agreed that is out of scope here, but could you open an issue for it and link it from the PR description so it is tracked now that the session factory builds?

/**
* An entity that is also embedded in another can declare a hasMany. The collection is bound once for the
* embedded component and once for the entity itself, and each needs its own element and key.
*/

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This covers the same set case as EmbeddedEntityCollectionKindsSpec, on a different base class. Consider folding it into that spec so the embedded hasMany scenario lives in one place.

…ollectionKindsSpec

EmbeddedEntityWithHasManySpec covered the set case that
EmbeddedEntityCollectionKindsSpec already covers, with the list and map cases
besides, so the embedded hasMany scenario now lives in one place.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
@testlens-app

testlens-app Bot commented Oct 14, 2026

Copy link
Copy Markdown

✅ All tests passed ✅

🏷️ Commit: c2bc931
▶️ Tests: 84038 executed
⚪️ Checks: 89/89 completed


Learn more about TestLens at testlens.app/docs.

@jdaugherty jdaugherty left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Both follow-ups from the last round are in. The standalone-binding limitation is tracked in #16634 and linked from the description, and EmbeddedEntityWithHasManySpec is folded into EmbeddedEntityCollectionKindsSpec, which already asserted the same set case alongside the list and map cases, so no coverage was lost. The production change is unchanged since fcee01d. EmbeddedEntityCollectionKindsSpec and CollectionBinderSpec pass locally at c2bc931 on top of the current 8.0.x, and the branch still merges cleanly.

@jdaugherty
jdaugherty merged commit 8b7b316 into 8.0.x Oct 14, 2026
90 checks passed
@jdaugherty
jdaugherty deleted the fix/hibernate7-embedded-hasmany-collection-8.0.x branch October 14, 2026 15:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

3 participants