Fix join-table foreign keys to use resolved entity table names (#15736) - #16028
Fix join-table foreign keys to use resolved entity table names (#15736)#16028gsartori wants to merge 9 commits into
Conversation
|
@borinquenkid this code has been made with the support of AI, please consider it as a starting point to address the issue |
…naming getHibernateAssociatedEntity().getHibernateRootEntity().getTableName(namingStrategy) was duplicated between resolveJoinTableForeignKeyColumnName() and joinTableColumName(). Extract it to HibernateAssociation#resolveAssociatedEntityTableName so both to-one and to-many association properties share one implementation. joinTableColumName() also resolved the collection property-name prefix via resolveTableName(getName()) even though the result is used as a column, not a table, on the join table. Under the default snake-case naming strategy this is indistinguishable from resolveColumnName(), which is why it went unnoticed, but it produces the wrong prefix under a PhysicalNamingStrategy that treats column and table naming differently. Switch it to resolveColumnName(getName()). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Thanks for tracking this down, @gsartori — the root cause you identified is correct: deriving the join-table FK/column prefix from the raw simple class name instead of the entity's actually-resolved table name (mapping override or While reviewing it, I noticed the same I pushed a follow-up commit (
Ran the full One thing needs to happen before this can merge, though: this PR also bundles in a removal of
Once those are out, this is in good shape to merge on the join-table naming fix alone. |
borinquenkid
left a comment
There was a problem hiding this comment.
Do the changes as described
|
@borinquenkid I've messed up two different branches when updating the codebase to Grails 8 without noticing. I've reverted the unwanted commits, it should be clean now |
|
The change from grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/hibernate/HibernateToManyProperty.java |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## 8.0.x #16028 +/- ##
==================================================
+ Coverage 51.4351% 51.4910% +0.0559%
- Complexity 17723 17762 +39
==================================================
Files 2039 2039
Lines 95497 95537 +40
Branches 16564 16571 +7
==================================================
+ Hits 49119 49193 +74
+ Misses 39078 39040 -38
- Partials 7300 7304 +4 🚀 New features to boost your workflow:
|
|
This change warrants an entry in the upgrade guide as it could be breaking for existing applications. |
|
@materi I've added a paragraph to the upgrade guide, feel free to change it if you feel it's not to the point |
jdaugherty
left a comment
There was a problem hiding this comment.
Prior to reviewing this, I asked AI to review this PR and have included the comments here. Please take a look and then I'll take another pass myself:
Thanks for digging into this — the direction is right, and resolving the prefix through GrailsHibernatePersistentEntity#getTableName(...) is consistent with what TableForManyCalculator already does for the join-table name.
Two things I think need to be resolved before this goes in:
- The change only reaches unidirectional
hasManyjoin tables. A bidirectional many-to-many still names both join columns from the class names, so the upgrade note and the naming-strategy doc currently describe a change that does not happen for the shape most readers will have. - Appending
_idto the raw table name regresses backtick-quotedtablemappings (table 'user') into invalid DDL.TableForManyCalculatorstrips backticks for exactly this reason.
Repro details and before/after schemas are in the line comments.
The red functional-test job looks unrelated: it failed pulling images from registry-1.docker.io and connecting to develocity.apache.org, so a rerun should clear it.
| .getHibernateRootEntity() | ||
| .getJavaClass() | ||
| .getSimpleName()) + | ||
| .orElseGet(() -> resolveAssociatedEntityTableName(namingStrategy) + |
There was a problem hiding this comment.
resolveJoinTableForeignKeyColumnName only runs for unidirectional hasMany join tables, so a bidirectional many-to-many is not affected by this change.
CollectionSecondPassBinder sends a bidirectional many-to-many element to ManyToOneElementBinder → ManyToOneBinder → SimpleValueBinder → DefaultColumnNameFetcher, which for a HibernateManyToManyProperty returns resolveForeignKeyForPropertyDomainClass(...) — still the decapitalized class simple name run through resolveColumnName. The only production call site of the method changed here is CollectionWithJoinTableBinder, reached from UnidirectionalOneToManyBinder.
I compared the generated H2 schema on this branch against the merge base with these domain classes:
@Entity class ProbeAuthor { String name
static hasMany = [books: ProbeBook]
static mapping = { table 'writer' } }
@Entity class ProbeBook { String title
static belongsTo = ProbeAuthor
static hasMany = [authors: ProbeAuthor]
static mapping = { table 'catalog_book' } }
@Entity class ProbeShelf { String label
static hasMany = [shelved: ProbeBook] } // unidirectional| join table | 8.0.x | this branch |
|---|---|---|
writer_books (bidirectional many-to-many) |
probe_author_id, probe_book_id |
unchanged |
probe_shelf_catalog_book (unidirectional) |
probe_book_id, probe_shelf_shelved_id |
catalog_book_id, probe_shelf_shelved_id |
Two consequences worth deciding on explicitly:
- the many-to-many shape described in both doc changes (and in Grails 8 GORM ignores NamingStrategy for hasMany join table column names (prefixed with entity class name) #15736's example app, if its associations are bidirectional) gets no new behavior;
- even in the path that did change, the owner-side key column (
probe_shelf_shelved_id) is still derived from the owner class name viaDefaultColumnNameFetcher, so one column of the join table now follows the resolved table name and the other does not.
Could we either extend the resolution to DefaultColumnNameFetcher#getDefaultColumnName / resolveForeignKeyForPropertyDomainClass so both sides of a join table agree, or keep the code change as-is and narrow the documentation to the unidirectional case it actually covers?
There was a problem hiding this comment.
Thanks for the detailed repro — went with your second option: kept the code narrowly scoped to what it actually fixes (the associated-entity FK column of a unidirectional hasMany, via CollectionWithJoinTableBinder) rather than extending it into DefaultColumnNameFetcher/many-to-many, which would be a materially larger change than #15736 asked for. Instead, re-scoped the docs to describe exactly that in 4da31cc: upgrading80x.adoc §26.9 now leads with the unidirectional-only scope, replaces the many-to-many example with a genuinely unidirectional one (Shelf hasMany books, no belongsTo), and notes the owner-side column (shelf_id) is unchanged. Also added a doc comment on resolveJoinTableForeignKeyColumnName itself recording the scope and pointing at DefaultColumnNameFetcher#resolveForeignKeyForPropertyDomainClass as the unaffected many-to-many path, and a closing paragraph noting the resolveTableName→resolveColumnName property-prefix change you flagged for basic/enum collections.
| return getHibernateAssociatedEntity().getName(); | ||
| } | ||
|
|
||
| default String resolveAssociatedEntityTableName(PersistentEntityNamingStrategy namingStrategy) { |
There was a problem hiding this comment.
This returns the table name verbatim, and the two call sites then treat it differently: joinTableColumName passes it through BackticksRemover, while resolveJoinTableForeignKeyColumnName concatenates _id onto it directly. TableForManyCalculator.calculateTableForMany also strips backticks from getTableName(...), because backtick-quoting a reserved word in table is supported and used (e.g. grails/gorm/tests/multitenancy/User maps table 'user').
With a quoted table on the far side of a unidirectional hasMany, the FK column name is now malformed:
@Entity class ProbeQuoted { String label
static mapping = { table '`user`' } }
@Entity class ProbeShelf { String label
static hasMany = [quoted: ProbeQuoted] }Error executing DDL "create table probe_shelf_user (`user`_id bigint, probe_shelf_quoted_id bigint, unique (probe_shelf_quoted_id, `user`_id))"
via JDBC [Unknown data type: "_ID"]
On 8.0.x the same mapping produces probe_shelf_user(probe_quoted_id, probe_shelf_quoted_id). Without hibernate.hbm2ddl.halt_on_error the statement fails silently and the join table is simply missing from the generated schema, which makes it an easy one to ship unnoticed.
Stripping backticks here (or at the resolveJoinTableForeignKeyColumnName call site, matching joinTableColumName) fixes it. A test with a backtick-quoted table mapping would be worth adding alongside the two new cases.
There was a problem hiding this comment.
Fixed in 546fea4 (pushed before your review, sorry for the noise) — stripped backticks once at the source in HibernateAssociation#resolveAssociatedEntityTableName, which both joinTableColumName and resolveJoinTableForeignKeyColumnName go through, so it's no longer left to each caller. Added "resolveJoinTableForeignKeyColumnName strips backticks from a backtick-quoted associated entity table name" reproducing your table '\user`'-shaped repro (HTMPQuotedTableAuthor/HTMPQuotedTableBook` in the spec).
| } else { | ||
| var clazz = namingStrategy.resolveColumnName(referencedType.getName()); | ||
| var prop = namingStrategy.resolveTableName(getName()); | ||
| var clazz = isBasic() ? |
There was a problem hiding this comment.
Both callers of joinTableColumName take a HibernateBasicProperty (BasicCollectionElementBinder#bind and EnumTypeBinder#bindEnumTypeForColumn), and HibernateBasicProperty extends BasicWithMapping which extends Basic — so isBasic() is always true here and the association branch never executes during binding. The only thing reaching it is the mocked naming strategy in the new spec.
If it is intended as future-proofing, I'd rather drop the ternary (or move joinTableColumName onto the basic-collection interface, where its two callers already are) so the code doesn't suggest an association path that doesn't exist. If there is a mapping that does reach it, a test that goes through the binder rather than a mock would make that clear.
There was a problem hiding this comment.
Dropped the ternary in 4da31cc — confirmed both real callers (BasicCollectionElementBinder#bind, EnumTypeBinder#bindEnumTypeForColumn) type their parameter as HibernateBasicProperty, so isBasic() is always true here and the association branch was dead. joinTableColumName now always resolves clazz via resolveColumnName(referencedType.getName()), with a comment explaining why resolveAssociatedEntityTableName doesn't apply on this path. Went with removing it over relocating the method onto HibernateToManyCollectionProperty to keep the diff small, since dropping the branch already removes the misleading suggestion of an association path.
|
|
||
| *`javax.persistence` → `jakarta.persistence`*: This migration was already required for Grails 7; Grails 8 continues to require `jakarta.*`. | ||
|
|
||
| ===== 26.9 Many-to-Many Join-Table Column Names |
There was a problem hiding this comment.
As noted in the comment on HibernateToManyProperty#resolveJoinTableForeignKeyColumnName, a bidirectional many-to-many join table is not affected by this change: I checked the exact mapping used in this section's example (Book with table 'catalog_book', Author/Book many-to-many) and both join columns are still derived from the class names on this branch, identical to 8.0.x.
As written, this section asks many-to-many users to migrate or pin a schema that isn't changing — which is worse than no note at all, since the suggested joinTable mapping would itself be the schema change. It needs to be re-scoped to what actually changes (the element FK column of a unidirectional hasMany join table), or the code change extended to cover many-to-many.
One more change worth listing here if it stays in: switching the property prefix from resolveTableName to resolveColumnName alters the element column of basic and enum collections under any strategy whose table and column rules differ.
There was a problem hiding this comment.
Re-scoped in 4da31cc — renamed the section, opened with an explicit callout that only a unidirectional hasMany is affected and a bidirectional many-to-many is not, and swapped the example for a genuinely unidirectional one (Shelf hasMany books) instead of the Author/Book many-to-many that wasn't actually changing. Also called out that the join table's other column (owner-side) is unchanged, and added your point about the basic/enum collection element-column prefix as a separate paragraph at the end.
|
|
||
| TIP: Individual column or table names set explicitly in the `mapping` block always take precedence over what the naming strategy would produce. | ||
|
|
||
| The default foreign-key column names in a `hasMany` join table are derived from the physical table names of the associated domain classes. Consequently, a custom strategy that changes a domain table name also changes the corresponding join-table foreign-key column prefix. For example, if the strategy maps `TBook` to the table `book`, the default foreign-key column is `book_id`, not `tbook_id`. Applications upgrading from an earlier GORM version should account for this schema change or configure the join-table columns explicitly in the `mapping` block. |
There was a problem hiding this comment.
Same issue as the upgrade note: this states that the default hasMany join-table foreign keys are derived from the physical table names, but that only holds for a unidirectional hasMany. Add belongsTo (or a many-to-many) and both columns still come from the class names, so a reader with the far more common bidirectional mapping will not see book_id.
Since the paragraph doesn't say which association shape it applies to, I'd make that explicit, and add a cross-reference to the corresponding upgrade-guide section so the schema-migration advice is in one place.
There was a problem hiding this comment.
Re-scoped this paragraph in 4da31cc to make the unidirectional-only condition explicit up front, and added a cross-reference to the upgrade guide's §26.9 (now titled "Join-Table Foreign-Key Column Names") so the migration guidance lives in one place.
| property.joinTableColumName(namingStrategy) != null | ||
| } | ||
|
|
||
| void "joinTableColumName applies table naming to the associated entity and column naming to the property prefix"() { |
There was a problem hiding this comment.
This one asserts the interactions with a mocked naming strategy rather than an outcome, which is how it passes for a branch that binding never takes (see the comment on joinTableColumName), and it also pins resolveColumnName/resolveTableName call counts that are implementation detail rather than behavior.
For a regression guard on #15736, could we add at least one test that completes binding and asserts the resulting join-table columns — e.g. boot a HibernateDatastore over an Author/Book pair with explicit table mappings and assert the collection table's column names? A test at that level is what would have surfaced the two behavioral gaps noted in the other comments.
There was a problem hiding this comment.
Replaced it in 4da31cc — the new test boots a real PhysicalNamingStrategy (HTMPColumnMarkingPhysicalNamingStrategy) whose toPhysicalColumnName diverges from its (default) toPhysicalTableName for the property name, then asserts the resulting joinTableColumName prefix carries the column-naming marker — i.e. an outcome that only holds if the property prefix actually goes through resolveColumnName, not resolveTableName. No more mock interaction/call-count assertions on the (now-removed) unreachable branch.
| } | ||
|
|
||
| @Entity | ||
| class Book { |
There was a problem hiding this comment.
Every other domain class in this spec is HTMP-prefixed to keep the file's entities namespaced, and there are already several unrelated Book domain classes elsewhere in this test source set. HTMPMappedTableBook with, say, table 'catalog_book' would keep the convention and still demonstrate that the explicit table mapping wins over the class name.
There was a problem hiding this comment.
Renamed to HTMPMappedTableBook in 4da31cc to keep the file's HTMP-prefix convention.
resolveAssociatedEntityTableName's result is only ever used as a column-identifier fragment (a join-table foreign-key or element column name) by its callers, never as a literal SQL identifier - so a backtick-quoted table mapping (e.g. table '`user`') produced a malformed column like `user`_id via resolveJoinTableForeignKeyColumnName, generating invalid DDL that fails silently without hbm2ddl.halt_on_error. Strips backticks once at the source instead of trusting each caller to do it themselves, since every caller wants the clean form. Adds a domain-class-only regression test (no mocks) reproducing the bug via a real unidirectional hasMany join-table binding. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…al hasMany jdaugherty's review confirmed resolveJoinTableForeignKeyColumnName only runs for a unidirectional hasMany join table - a bidirectional many-to-many still derives both columns from class names, unaffected by this change. Documents that scope explicitly (with a corrected, genuinely-unidirectional example) in both the upgrade guide and the naming-strategy guide, and cross-references the two, instead of describing a many-to-many schema change that doesn't happen. joinTableColumName's isBasic() ternary was dead: both of its callers (BasicCollectionElementBinder, EnumTypeBinder) always pass a HibernateBasicProperty, so the association branch never ran and was only reachable through a mocked naming strategy in the existing test. Dropped the ternary and replaced that test with one that boots a real PhysicalNamingStrategy distinguishing column from table naming and asserts the resulting join column, rather than asserting mock interaction counts on unreachable code. Renamed the test's Book domain class to HTMPMappedTableBook to match the rest of the file's HTMP-prefix convention. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
✅ All tests passed ✅🏷️ Commit: 4da31cc Learn more about TestLens at testlens.app. |
Description
Fixes #15736.
GORM previously generated default
hasManyjoin-table foreign-key columns from the associated domain class name. This produced inconsistent names when the entity’s effective table name differed because of a custom HibernatePhysicalNamingStrategyor an explicit table mapping.Join-table foreign-key prefixes are now derived from
GrailsHibernatePersistentEntity#getTableName(...), which resolves explicit mappings first and otherwise applies the configured naming strategy.Examples covered by regression tests:
HTMPBooktransformed by a naming strategy to tablebookproducesbook_id.Bookexplicitly mapped to tablehtmp_bookproduceshtmp_book_id.The same table-name resolution is used by association join-table naming while preserving the existing behavior for basic and enum collections. Explicit join-table column mappings continue to take precedence.
The custom naming strategy documentation now explains its effect on join-table foreign-key columns and warns that existing applications may require a schema migration or explicit column configuration.
Contributor Checklist
Issue and Scope
8.0.xmajor release branch.Code Quality
I have added regression tests covering both custom physical naming and explicit entity table mappings.
I have not run the complete
./gradlew build --rerun-tasks.I have not run the complete
./gradlew codeStyle.The targeted test was run successfully:
Result: 47 tests passed, 0 failures, 0 skipped.
This PR does not contain mass reformatting, style-only changes, or large-scale refactoring.
Generative AI tooling was used with a quality model, and the resulting changes were reviewed and tested.
Licensing and Attribution
Documentation