You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@grails.apache.org by bo...@apache.org on 2026/08/28 03:08:44 UTC
(grails-core) branch 8.0.x updated: Fix join-table foreign keys to use resolved entity table names (#15736) (#16028)
This is an automated email from the ASF dual-hosted git repository.
borinquenkid pushed a commit to branch 8.0.x
in repository https://gitbox.apache.org/repos/asf/grails-core.git
The following commit(s) were added to refs/heads/8.0.x by this push:
new 057ffcd812 Fix join-table foreign keys to use resolved entity table names (#15736) (#16028)
057ffcd812 is described below
commit 057ffcd8129ab7d8852ab662071fe33985cbd551
Author: Gianluca Sartori <gs...@users.noreply.github.com>
AuthorDate: Fri Aug 28 05:08:38 2026 +0200
Fix join-table foreign keys to use resolved entity table names (#15736) (#16028)
* PR for: https://github.com/apache/grails-core/issues/15644
* Merge fixes & cleanups
* Fix proposal for https://github.com/apache/grails-core/issues/15736
* Consolidate join-table table-name resolution and fix property-prefix 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 <no...@anthropic.com>
* Revert "Merge fixes & cleanups"
This reverts commit d1ca11fc663192167367efae9e14874684d03a70.
* Revert "PR for: https://github.com/apache/grails-core/issues/15644"
This reverts commit bfdbbe88fa63c33b92e3a0ed7e0509ef99b500ac.
* Upgrade guide
* Strip backticks from resolved associated-entity table names
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 <no...@anthropic.com>
* Narrow join-table naming fix's scope in code and docs to unidirectional 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 <no...@anthropic.com>
---------
Co-authored-by: Walter Duque de Estrada <wb...@mac.com>
Co-authored-by: Claude Sonnet 5 <no...@anthropic.com>
Co-authored-by: James Fredley <ja...@users.noreply.github.com>
---
.../hibernate/HibernateToManyProperty.java | 27 ++++--
.../hibernate/HibernateToManyPropertySpec.groovy | 100 +++++++++++++++++++++
.../ormdsl/customNamingStrategy.adoc | 4 +
.../src/en/guide/upgrading/upgrading80x.adoc | 54 +++++++++++
4 files changed, 180 insertions(+), 5 deletions(-)
diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/hibernate/HibernateToManyProperty.java b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/hibernate/HibernateToManyProperty.java
index d58c287a3c..c836359d60 100644
--- a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/hibernate/HibernateToManyProperty.java
+++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/hibernate/HibernateToManyProperty.java
@@ -245,17 +245,31 @@ public interface HibernateToManyProperty extends PropertyWithMapping<PropertyCon
IndexedCollection.DEFAULT_ELEMENT_COLUMN_NAME);
}
+ /**
+ * Only reached for a unidirectional {@code hasMany} join table (via {@code CollectionWithJoinTableBinder}).
+ * A bidirectional many-to-many join table's foreign-key columns instead go through
+ * {@code DefaultColumnNameFetcher#resolveForeignKeyForPropertyDomainClass}, unaffected by this method.
+ */
default String resolveJoinTableForeignKeyColumnName(PersistentEntityNamingStrategy namingStrategy) {
return ofNullable(getHibernateMappedForm())
.map(PropertyConfig::getJoinTableColumnConfig)
.map(ColumnConfig::getName)
- .orElseGet(() -> namingStrategy.resolveColumnName(getHibernateAssociatedEntity()
- .getHibernateRootEntity()
- .getJavaClass()
- .getSimpleName()) +
+ .orElseGet(() -> resolveAssociatedEntityTableName(namingStrategy) +
GrailsDomainBinder.FOREIGN_KEY_SUFFIX);
}
+ /**
+ * Resolves the associated root entity's table name for use as a join-table foreign-key column
+ * prefix. The result is a column-identifier fragment, never a literal, quotable SQL identifier -
+ * so the Groovy backtick-quoting convention is always invalid there and must be stripped once
+ * here, rather than trusted to the caller (a prior bug left the foreign key malformed as
+ * {@code `quoted_table`_id}).
+ */
+ default String resolveAssociatedEntityTableName(PersistentEntityNamingStrategy namingStrategy) {
+ return new BackticksRemover().apply(
+ getHibernateAssociatedEntity().getHibernateRootEntity().getTableName(namingStrategy));
+ }
+
default String joinTableColumName(PersistentEntityNamingStrategy namingStrategy) {
final Class<?> referencedType = getComponentType();
var joinColumnMappingOptional = getColumnConfigOptional();
@@ -268,8 +282,11 @@ public interface HibernateToManyProperty extends PropertyWithMapping<PropertyCon
// isn't named after the enum's package.
columnName = namingStrategy.resolveColumnName(referencedType.getSimpleName());
} else {
+ // Both callers of joinTableColumName (BasicCollectionElementBinder, EnumTypeBinder) operate on
+ // a HibernateBasicProperty, so referencedType is always the collection's basic element type here,
+ // never an associated entity - resolveAssociatedEntityTableName does not apply to this path.
var clazz = namingStrategy.resolveColumnName(referencedType.getName());
- var prop = namingStrategy.resolveTableName(getName());
+ var prop = namingStrategy.resolveColumnName(getName());
columnName = new BackticksRemover().apply(prop) + UNDERSCORE + new BackticksRemover().apply(clazz);
}
return columnName;
diff --git a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/hibernate/HibernateToManyPropertySpec.groovy b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/hibernate/HibernateToManyPropertySpec.groovy
index 8d323a765d..3153572075 100644
--- a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/hibernate/HibernateToManyPropertySpec.groovy
+++ b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/hibernate/HibernateToManyPropertySpec.groovy
@@ -21,6 +21,9 @@ package org.grails.orm.hibernate.cfg.domainbinding.hibernate
import grails.gorm.annotation.Entity
import grails.gorm.tests.HibernateGormDatastoreSpec
import org.hibernate.MappingException
+import org.hibernate.boot.model.naming.Identifier
+import org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl
+import org.hibernate.engine.jdbc.env.spi.JdbcEnvironment
import org.grails.datastore.mapping.model.PersistentProperty
import org.grails.datastore.mapping.model.PersistentEntity
import org.grails.datastore.mapping.model.PropertyMapping
@@ -28,6 +31,7 @@ import org.grails.datastore.mapping.model.ClassMapping
import org.grails.datastore.mapping.reflect.EntityReflector
import org.grails.orm.hibernate.cfg.PropertyConfig
import org.grails.orm.hibernate.cfg.Mapping
+import org.grails.orm.hibernate.cfg.domainbinding.util.NamingStrategyWrapper
class HibernateToManyPropertySpec extends HibernateGormDatastoreSpec {
@@ -63,6 +67,37 @@ class HibernateToManyPropertySpec extends HibernateGormDatastoreSpec {
columnName == "custom_book_fk"
}
+ void "resolveJoinTableForeignKeyColumnName removes a domain prefix through a physical naming strategy"() {
+ given:
+ def property = createTestHibernateToManyProperty(HTMPAuthor, "books")
+ def namingStrategy = new NamingStrategyWrapper(
+ new HTMPPrefixRemovingPhysicalNamingStrategy(), getGrailsDomainBinder().jdbcEnvironment)
+ hibernateFirstPass()
+
+ expect:
+ property.resolveJoinTableForeignKeyColumnName(namingStrategy) == "book_id"
+ }
+
+ void "resolveJoinTableForeignKeyColumnName uses the associated entity explicit table mapping"() {
+ given:
+ def property = createTestHibernateToManyProperty(HTMPMappedTableAuthor, "books")
+ def namingStrategy = getGrailsDomainBinder().namingStrategy
+ hibernateFirstPass()
+
+ expect:
+ property.resolveJoinTableForeignKeyColumnName(namingStrategy) == "htmp_book_id"
+ }
+
+ void "resolveJoinTableForeignKeyColumnName strips backticks from a backtick-quoted associated entity table name"() {
+ given:
+ def property = createTestHibernateToManyProperty(HTMPQuotedTableAuthor, "books")
+ def namingStrategy = getGrailsDomainBinder().namingStrategy
+ hibernateFirstPass()
+
+ expect:
+ property.resolveJoinTableForeignKeyColumnName(namingStrategy) == "htmp_quoted_book_id"
+ }
+
void "isAssociationColumnNullable returns false for ManyToMany"() {
given: "Register only entities for this specific test"
createPersistentEntity(HTMPCourse) // Course is needed because Student refers to it
@@ -351,6 +386,17 @@ class HibernateToManyPropertySpec extends HibernateGormDatastoreSpec {
property.joinTableColumName(namingStrategy) != null
}
+ void "joinTableColumName resolves the property prefix through column naming rather than table naming"() {
+ given: "a physical naming strategy where column and table transformation rules diverge for 'tags'"
+ def property = createTestHibernateToManyProperty(HTMPOwnerString, "tags")
+ def namingStrategy = new NamingStrategyWrapper(
+ new HTMPColumnMarkingPhysicalNamingStrategy(), getGrailsDomainBinder().jdbcEnvironment)
+ hibernateFirstPass()
+
+ expect: "the property prefix carries the column-naming marker; the unmarked form would mean the old resolveTableName() path ran instead"
+ property.joinTableColumName(namingStrategy).startsWith("tags_as_column_")
+ }
+
void "joinTableColumName returns derived column name for enum collection"() {
given:
def property = createTestHibernateToManyProperty(HTMPEntityWithEnum, "statuses")
@@ -581,6 +627,60 @@ class HTMPBook {
String title
}
+@Entity
+class HTMPMappedTableBook {
+ Long id
+ String title
+
+ static mapping = {
+ table 'htmp_book'
+ }
+}
+
+@Entity
+class HTMPMappedTableAuthor {
+ Long id
+ String name
+ static hasMany = [books: HTMPMappedTableBook]
+}
+
+@Entity
+class HTMPQuotedTableBook {
+ Long id
+ String title
+
+ static mapping = {
+ table '`htmp_quoted_book`'
+ }
+}
+
+@Entity
+class HTMPQuotedTableAuthor {
+ Long id
+ String name
+ static hasMany = [books: HTMPQuotedTableBook]
+}
+
+class HTMPPrefixRemovingPhysicalNamingStrategy extends PhysicalNamingStrategyStandardImpl {
+
+ @Override
+ Identifier toPhysicalTableName(Identifier logicalName, JdbcEnvironment jdbcEnvironment) {
+ logicalName.text == HTMPBook.simpleName ?
+ Identifier.toIdentifier('book') :
+ super.toPhysicalTableName(logicalName, jdbcEnvironment)
+ }
+}
+
+class HTMPColumnMarkingPhysicalNamingStrategy extends PhysicalNamingStrategyStandardImpl {
+
+ @Override
+ Identifier toPhysicalColumnName(Identifier logicalName, JdbcEnvironment jdbcEnvironment) {
+ logicalName.text == 'tags' ?
+ Identifier.toIdentifier('tags_as_column') :
+ super.toPhysicalColumnName(logicalName, jdbcEnvironment)
+ }
+}
+
@Entity
class HTMPAuthor {
Long id
diff --git a/grails-data-hibernate7/docs/src/docs/asciidoc/advancedGORMFeatures/ormdsl/customNamingStrategy.adoc b/grails-data-hibernate7/docs/src/docs/asciidoc/advancedGORMFeatures/ormdsl/customNamingStrategy.adoc
index d7162af30f..8672757aff 100644
--- a/grails-data-hibernate7/docs/src/docs/asciidoc/advancedGORMFeatures/ormdsl/customNamingStrategy.adoc
+++ b/grails-data-hibernate7/docs/src/docs/asciidoc/advancedGORMFeatures/ormdsl/customNamingStrategy.adoc
@@ -68,3 +68,7 @@ class UpperCaseNamingStrategy implements PhysicalNamingStrategy {
----
TIP: Individual column or table names set explicitly in the `mapping` block always take precedence over what the naming strategy would produce.
+
+For a *unidirectional* `hasMany` (a collection with no `belongsTo` or reciprocal `hasMany` on the other side), the default foreign-key column that references the associated entity is derived from that entity's physical table name. Consequently, a custom strategy that changes a domain table name also changes that column. For example, given `static hasMany = [books: TBook]`, if the strategy maps `TBook` to the table `book`, the default foreign-key column is `book_id`, not `tbook_id`.
+
+This does not apply to a *bidirectional* many-to-many association: both of its join-table foreign-key columns are still derived from the class names, regardless of any `table` mapping or naming strategy. Applications upgrading from an earlier GORM version should account for the unidirectional case's schema change, or configure the join-table columns explicitly in the `mapping` block. See https://grails.apache.org/docs/latest/guide/single.html#_join_table_foreign_key_column_names[Join-Tab [...]
diff --git a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc
index 4e9c8b8bc2..6021ae918e 100644
--- a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc
+++ b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc
@@ -1343,6 +1343,60 @@ GORM's `createCriteria()` and `withCriteria()` DSL are implemented on top of the
*`javax.persistence` → `jakarta.persistence`*: This migration was already required for Grails 7; Grails 8 continues to require `jakarta.*`.
+[[_join_table_foreign_key_column_names]]
+===== 26.9 Join-Table Foreign-Key Column Names
+
+This change affects only a *unidirectional* `hasMany` — a collection with no `belongsTo` and no reciprocal `hasMany` on the other side. A *bidirectional* many-to-many association (both sides declare `hasMany`, or one side uses `belongsTo`) is **not** affected: both of its join-table foreign-key columns are still derived from the class names, identical to Grails 7.
+
+In Grails 7 (Hibernate 5), the default foreign-key column that referenced the associated entity in a unidirectional `hasMany` join table was derived from the simple name of that entity's domain class, after applying the column naming strategy.
+For example, the domain class `Book` produced the foreign-key column `book_id`, even when its physical table was mapped to a different name.
+
+In Grails 8 (Hibernate 7), that foreign-key column is instead derived from the physical table name of the associated domain class.
+The physical name includes an explicit `table` mapping and any transformation made by a custom physical naming strategy.
+For example, given the following mapping:
+
+[source,groovy]
+----
+class Book {
+ static mapping = {
+ table 'catalog_book'
+ }
+}
+
+class Shelf {
+ String label
+
+ static hasMany = [books: Book] // unidirectional: no belongsTo, no reciprocal hasMany
+}
+----
+
+Grails 7 used `book_id` by default in the `shelf_books` join table, whereas Grails 8 uses `catalog_book_id`. The other column in that same join table (`shelf_id`, derived from the owning `Shelf` class) is unchanged.
+This is a breaking schema change for an existing database if its join table still uses the Grails 7 column name.
+
+To keep the existing schema unchanged, configure the join table and its foreign-key column explicitly in the `static mapping` block:
+
+[source,groovy]
+----
+class Shelf {
+ String label
+
+ static hasMany = [books: Book]
+
+ static mapping = {
+ books joinTable: [
+ name: 'shelf_books',
+ key: 'shelf_id',
+ column: 'book_id'
+ ]
+ }
+}
+----
+
+Replace `shelf_books`, `shelf_id`, and `book_id` with the table and column names already used by your database.
+Declaring all three names prevents the naming strategy from changing the mapping during the upgrade.
+
+Separately, the element column of a *basic (non-enum) collection* (e.g. `static hasMany = [items: String]`) is now resolved through the naming strategy's *column* naming rules instead of its *table* naming rules for the property-name prefix, producing a `<property>_<element-type identifier>` column name. An enum collection is unaffected: its element column has no property-name prefix and already used the column naming rules before this change. Most naming strategies apply the same transf [...]
+
==== 27. GORM Properties Are Nullable by Default
Grails 8 changes the validation default so that an *unconstrained persistent (domain) property is nullable by default* rather than required.