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/10/08 19:20:09 UTC

(grails-core) branch 9.0.x-h7-improvement-phase3 updated (76b58f0f09 -> be9a58b4aa)

This is an automated email from the ASF dual-hosted git repository.

borinquenkid pushed a change to branch 9.0.x-h7-improvement-phase3
in repository https://gitbox.apache.org/repos/asf/grails-core.git


    from 76b58f0f09 style: name intentionally empty catch parameter ignored in grails-data-hibernate7
     new 056538c8eb docs: document native domain binding in the Hibernate 7 manual
     new 4118bb1568 docs: add the Grails 8 to 9 upgrade notes for Hibernate 7 domain binding
     new 536dfadd30 docs: add native binding and mapping fixes to the Hibernate 7 migration checklist
     new 6895d3af6c feat: type the index of a list and the key of a map as the mapping says in native domain binding
     new 24f114b337 feat: accept an index or a unique group on a collection property in native domain binding
     new ce5c58e2e6 feat: accept a type mapped on a collection property in native domain binding
     new d39f374d6f docs: refresh the native binding schema comparison after closing three refused mappings
     new 8f77322ec3 feat: accept a collection inside an embedded type in native domain binding
     new 849b34f8a1 feat: accept a map on a many-to-many in native domain binding
     new f4580a8cb3 feat: accept serializable on a property that is not a collection in native domain binding
     new 25227f0adf feat: accept type parameters on a registered type name in native domain binding
     new 24a0fcb346 feat: accept an attribute converter class as the type in native domain binding
     new 17927c0faf feat: apply a custom collection type on a collection of entities in native domain binding
     new b1fbda05f6 feat: accept a natural id on a subclass, an embedded property and an unknown name in native domain binding
     new ff9292e942 feat: accept a registered type name for a different Java type in native domain binding
     new be9a58b4aa docs: document that a custom collection type takes effect with native binding

The 16 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.


Summary of changes:
 .../cfg/domainbinding/jpa/CollectionFacets.groovy  |  16 +-
 .../jpa/GeneratedDomainClassBinder.groovy          | 123 ++++-
 .../domainbinding/jpa/GrailsDomainGenerator.groovy | 495 ++++++++++++++++-----
 .../cfg/domainbinding/jpa/ToManyFacets.groovy      |  20 +-
 .../cfg/domainbinding/jpa/TypeFacets.groovy        |  11 +-
 .../HibernateConnectionSourceSettings.groovy       |   6 +-
 ...edDomainClassesCollectionConstraintsSpec.groovy | 357 +++++++++++++++
 ...atedDomainClassesCollectionIndexTypeSpec.groovy | 194 ++++++++
 ...GeneratedDomainClassesCollectionTypeSpec.groovy | 337 ++++++++++++++
 ...GeneratedDomainClassesConverterClassSpec.groovy | 175 ++++++++
 ...tedDomainClassesCustomCollectionTypeSpec.groovy | 188 ++++++++
 ...ratedDomainClassesEmbeddedCollectionSpec.groovy | 445 ++++++++++++++++++
 .../GeneratedDomainClassesMapManyToManySpec.groovy | 273 ++++++++++++
 .../GeneratedDomainClassesNaturalIdSpec.groovy     | 365 +++++++++++++++
 ...neratedDomainClassesSerializableTypeSpec.groovy | 180 ++++++++
 .../GeneratedDomainClassesTypeMismatchSpec.groovy  | 185 ++++++++
 ...GeneratedDomainClassesTypeParametersSpec.groovy | 198 +++++++++
 .../unbootable/EmbeddedCollectionDomains.groovy    |  32 ++
 .../binder/ManyToManyWithoutOwnerSpec.groovy       |  41 +-
 ...eneratedDomainClassesDdlDifferentialSpec.groovy |  28 +-
 .../GrailsDomainGeneratorDifferentialSpec.groovy   | 232 ++++++++--
 .../jpa/GrailsDomainGeneratorIndexSpec.groovy      |  22 +-
 .../jpa/GrailsDomainGeneratorNaturalIdSpec.groovy  |  45 +-
 .../GrailsDomainGeneratorOptionCoverageSpec.groovy |  18 +-
 .../jpa/GrailsDomainGeneratorSpec.groovy           | 305 ++++++++++++-
 .../GrailsDomainGeneratorUniqueGroupSpec.groovy    |  16 +-
 .../advancedGORMFeatures/nativeDomainBinding.adoc  | 233 ++++++++++
 .../configuration/configurationReference.adoc      |   1 +
 .../asciidoc/gettingStarted/hibernateVersions.adoc |   8 +
 .../docs/src/docs/asciidoc/index.adoc              |   5 +
 grails-doc/src/en/guide/toc.yml                    |   1 +
 .../src/en/guide/upgrading/upgrading90x.adoc       | 138 ++++++
 32 files changed, 4423 insertions(+), 270 deletions(-)
 create mode 100644 grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/generated/GeneratedDomainClassesCollectionConstraintsSpec.groovy
 create mode 100644 grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/generated/GeneratedDomainClassesCollectionIndexTypeSpec.groovy
 create mode 100644 grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/generated/GeneratedDomainClassesCollectionTypeSpec.groovy
 create mode 100644 grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/generated/GeneratedDomainClassesConverterClassSpec.groovy
 create mode 100644 grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/generated/GeneratedDomainClassesCustomCollectionTypeSpec.groovy
 create mode 100644 grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/generated/GeneratedDomainClassesEmbeddedCollectionSpec.groovy
 create mode 100644 grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/generated/GeneratedDomainClassesMapManyToManySpec.groovy
 create mode 100644 grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/generated/GeneratedDomainClassesNaturalIdSpec.groovy
 create mode 100644 grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/generated/GeneratedDomainClassesSerializableTypeSpec.groovy
 create mode 100644 grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/generated/GeneratedDomainClassesTypeMismatchSpec.groovy
 create mode 100644 grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/generated/GeneratedDomainClassesTypeParametersSpec.groovy
 create mode 100644 grails-data-hibernate7/docs/src/docs/asciidoc/advancedGORMFeatures/nativeDomainBinding.adoc
 create mode 100644 grails-doc/src/en/guide/upgrading/upgrading90x.adoc


(grails-core) 05/16: feat: accept an index or a unique group on a collection property in native domain binding

Posted by bo...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

borinquenkid pushed a commit to branch 9.0.x-h7-improvement-phase3
in repository https://gitbox.apache.org/repos/asf/grails-core.git

commit 24f114b337532bb172691e0cfd8872d2155c482e
Author: Walter Duque de Estrada <wb...@mac.com>
AuthorDate: Wed Oct 7 20:34:17 2026 -0500

    feat: accept an index or a unique group on a collection property in native domain binding
    
    The generator refused any mapping with index: or a unique group on a hasMany property. The domain
    binder binds the key column of a collection like any other column, so index: creates an index of the
    collection table over the key column (and over the element column of a collection of enums), with the
    name the mapping gives it. A unique group names columns of the owner's table, which the collection
    table does not have, so the key the binder creates cannot be created (fixed on the 8.0.x line).
    
    The generator now decides the indexes with the binder's own IndexBinder on a scratch table, and the
    aligner creates them on the table Hibernate bound for the collection: the inverse side of a
    many-to-many has no table annotation to state them on, and the binder indexes the key of both sides
    in the one join table. A unique group on a collection property states no key. An index given as a
    closure configures the index column and names no index, since the binder would name it after the
    closure instance, a name that differs on every boot. The DDL differential knows both as differences
    of the binder, and the 8 fixtures that were refused compile into both differentials.
    
    Co-Authored-By: Claude Sonnet 5.5 <no...@anthropic.com>
---
 .../cfg/domainbinding/jpa/CollectionFacets.groovy  |  10 +-
 .../jpa/GeneratedDomainClassBinder.groovy          |  32 ++
 .../domainbinding/jpa/GrailsDomainGenerator.groovy |  68 ++--
 .../cfg/domainbinding/jpa/ToManyFacets.groovy      |  10 +-
 ...edDomainClassesCollectionConstraintsSpec.groovy | 357 +++++++++++++++++++++
 ...eneratedDomainClassesDdlDifferentialSpec.groovy |  13 +-
 .../GrailsDomainGeneratorDifferentialSpec.groovy   |  32 +-
 .../jpa/GrailsDomainGeneratorIndexSpec.groovy      |  22 +-
 .../GrailsDomainGeneratorOptionCoverageSpec.groovy |  18 +-
 .../GrailsDomainGeneratorUniqueGroupSpec.groovy    |  16 +-
 .../advancedGORMFeatures/nativeDomainBinding.adoc  |  10 +-
 11 files changed, 518 insertions(+), 70 deletions(-)

diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/CollectionFacets.groovy b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/CollectionFacets.groovy
index ebde8f77e4..b9bcbb9bb2 100644
--- a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/CollectionFacets.groovy
+++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/CollectionFacets.groovy
@@ -41,6 +41,13 @@ import org.hibernate.FetchMode
  * type of that name. It is {@code null} when the mapping states none, or the type is the default (an integer for a list, a string for
  * a map). A list's index stays an integer in Java whatever its JDBC type; the key of a map is typed with the facets' Java type.</p>
  *
+ * <p>{@code indexes} are the indexes the binder puts on the table of the collection when the mapping gives the collection property an
+ * {@code index:}, with the names and the column order it gives them: over the key column and, for a collection of enums, the element
+ * column too. It is empty when there are none, or when the binder binds no key column for the property (it is mapped by the foreign key of
+ * the other side, or the join table maps its key columns). A {@code unique} group on the property creates no key: it names columns of the
+ * owner's table, which the table of the collection does not have. {@link GeneratedDomainClassBinder} creates the indexes on the bound
+ * table, since the inverse side of a many-to-many has no table annotation of its own to state them on.</p>
+ *
  * <p>{@code extraLazy} is an explicit {@code lazy: true}: the binder makes the collection extra-lazy, which Hibernate's annotation
  * binder cannot state, so {@code GeneratedDomainClassBinder} sets it on the collection after binding.</p>
  *
@@ -62,5 +69,6 @@ record CollectionFacets(
     String cacheUsage,
     List<ColumnFacets> keys,
     List<String> referencedKeys,
-    TypeFacets indexType) {
+    TypeFacets indexType,
+    List<IndexFacets> indexes) {
 }
diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GeneratedDomainClassBinder.groovy b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GeneratedDomainClassBinder.groovy
index 8a63ef0851..0ccac33665 100644
--- a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GeneratedDomainClassBinder.groovy
+++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GeneratedDomainClassBinder.groovy
@@ -556,6 +556,7 @@ class GeneratedDomainClassBinder implements SessionFactoryBuilderFactory {
             alignExtraLazy((Collection) property.value, (HibernatePersistentProperty) persistentProperty)
             alignCollectionTable((Collection) property.value, (HibernatePersistentProperty) persistentProperty)
             alignListIndexLength((Collection) property.value, (HibernatePersistentProperty) persistentProperty)
+            alignCollectionIndexes((Collection) property.value, (HibernatePersistentProperty) persistentProperty)
         }
         if (property.value instanceof Component && persistentProperty instanceof Embedded) {
             // PropertyBinder marks the property lazy when the mapping says lazy: true; @Basic(fetch = LAZY) on an @Embedded is ignored
@@ -592,6 +593,37 @@ class GeneratedDomainClassBinder implements SessionFactoryBuilderFactory {
         }
     }
 
+    /**
+     * The domain binder indexes the key column of a collection (and the element column of a collection of enums) when the mapping puts
+     * {@code index:} on the collection property. {@code @CollectionTable} and {@code @JoinTable} could state the index of the owning side
+     * only: the inverse side of a many-to-many has no table annotation of its own, and the binder indexes the key of both sides in the one
+     * join table. The indexes the generator decided are created here on the table Hibernate bound for the collection, with the names the
+     * mapping gives them.
+     */
+    private void alignCollectionIndexes(Collection collection, HibernatePersistentProperty property) {
+        List<IndexFacets> indexes
+        if (property instanceof HibernateToManyEntityProperty) {
+            indexes = generator.toManyFacets((HibernateToManyEntityProperty) property).indexes()
+        } else if (property instanceof HibernateBasicProperty) {
+            indexes = generator.collectionFacets((HibernateBasicProperty) property).indexes()
+        } else {
+            return
+        }
+        Table table = collection.collectionTable
+        if (table == null) {
+            return
+        }
+        for (IndexFacets facets : indexes) {
+            org.hibernate.mapping.Index index = table.getOrCreateIndex(facets.name())
+            for (String name : facets.columns()) {
+                Column column = table.columns.find { Column candidate -> candidate.name.equalsIgnoreCase(name.replace('`', '')) }
+                if (column != null) {
+                    index.addColumn(column)
+                }
+            }
+        }
+    }
+
     /**
      * Hibernate's annotation binder gives the index column of a list no length, which it sizes as a long string when the mapping types
      * the index as a string (a CLOB, which cannot be a key). The domain binder's column has the default length of every column it creates.
diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGenerator.groovy b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGenerator.groovy
index 7fa9930039..f095766b4c 100644
--- a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGenerator.groovy
+++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGenerator.groovy
@@ -1194,10 +1194,6 @@ class GrailsDomainGenerator {
         if (elementType == null || elementType == Object) {
             return 'the element type is not known'
         }
-        String constraintProblem = collectionConstraintProblem(property)
-        if (constraintProblem != null) {
-            return constraintProblem
-        }
         if (mapped.joinTable.keys != null && mapped.joinTable.keys.size() > 1) {
             return 'the join table has a composite key'
         }
@@ -1289,29 +1285,43 @@ class GrailsDomainGenerator {
     }
 
     /**
-     * The binder binds the key column of a collection like any other column ({@code DependentKeyValueBinder} runs
-     * {@code ColumnBinder} on the property), so an {@code index:} or a {@code unique:} group on the collection property
-     * becomes an index or a unique key of the collection table over the key column, and for a collection of enums the
-     * element column is indexed too. A unique group names columns of the owner's table, which the collection table does not
-     * have. That is not what the mapping means, and not something the generator states, so it rejects the property.
+     * The binder binds the key column of a collection like any other column ({@code DependentKeyValueBinder} runs {@code ColumnBinder}
+     * on the collection property), so an {@code index:} on the property becomes an index of the collection table over the key column,
+     * named as the mapping says ({@code IndexBinder}), and for a collection of enums the element column is indexed too, before the key.
+     * A {@code unique} group on the property would name columns of the owner's table, which the collection table does not have: the
+     * binder creates a key over columns that do not exist (a defect fixed on the 8.0.x line), so the generator states none.
+     *
+     * <p>The {@code index:} of a closure is not an index: it configures the index column of a list or a map, and the name the binder
+     * gives the index is the closure's own ({@code toString()}, which holds the identity of the closure instance and differs on every
+     * boot), so none is stated for it.</p>
      *
-     * @return why the generator rejects the constraint on the collection property, or {@code null} when there is none the binder binds
+     * @return the indexes of the collection table in the order the binder creates them
      */
-    private static String collectionConstraintProblem(HibernateToManyProperty property) {
-        PropertyConfig mapped = property.hibernateMappedForm
-        ColumnConfig columnConfig = firstColumnConfig(mapped)
-        boolean indexed = columnConfig?.index != null && !Boolean.FALSE.equals(columnConfig.index) &&
-                !'false'.equalsIgnoreCase(columnConfig.index.toString())
-        boolean bindsKey = property.isBidirectional() ?
-                (property.hibernateInverseSide instanceof HibernateManyToManyProperty || Map.isAssignableFrom(property.type)) :
-                !mapped.hasJoinKeyMapping()
-        boolean bindsEnumElement = property instanceof HibernateEnumProperty
-        if ((bindsKey || bindsEnumElement) && indexed || bindsKey && mapped.isUniqueWithinGroup()) {
-            return 'an index or a unique group is mapped on the collection property: the binder creates it over the key column of the ' +
-                    'collection table (a unique group over columns of the owner\'s table, which the collection table does not have), ' +
-                    'which the generator does not state'
+    private List<IndexFacets> collectionTableIndexes(
+            HibernateToManyProperty property, String tableName, List<ColumnFacets> keys, ColumnFacets element) {
+        ColumnConfig columnConfig = firstColumnConfig(property.hibernateMappedForm)
+        if (tableName == null || columnConfig?.index == null || columnConfig.index instanceof Closure) {
+            return []
         }
-        return null
+        org.hibernate.mapping.Table table = new org.hibernate.mapping.Table('grails', tableName.replace('`', ''))
+        if (property instanceof HibernateEnumProperty) {
+            indexBinder.bindIndex(element.name(), new Column(element.name()), columnConfig, table)
+        }
+        if (bindsCollectionKey(property)) {
+            GrailsHibernatePersistentEntity owner = property.hibernateOwner
+            List<ColumnConfig> configs = compositeIdentifier(owner) ? compositeForeignKeyConfigs(property, owner) : [columnConfig]
+            for (int i = 0; i < keys.size() && i < configs.size(); i++) {
+                indexBinder.bindIndex(keys[i].name(), new Column(keys[i].name()), configs[i], table)
+            }
+        }
+        return table.indexes.values().collect { org.hibernate.mapping.Index index -> new IndexFacets(index.name, index.columns*.name) }
+    }
+
+    /** Whether the binder binds the key column of the collection like a column of the mapping (and so indexes it). */
+    private static boolean bindsCollectionKey(HibernateToManyProperty property) {
+        return property.isBidirectional() ?
+                (property.hibernateInverseSide instanceof HibernateManyToManyProperty || Map.isAssignableFrom(property.type)) :
+                !property.hibernateMappedForm.hasJoinKeyMapping()
     }
 
     /**
@@ -1345,7 +1355,8 @@ class GrailsDomainGenerator {
                 property.cacheUsage,
                 collectionKeyColumns(property),
                 collectionKeyReferencedColumns(property),
-                collectionIndexType(property, kind))
+                collectionIndexType(property, kind),
+                collectionTableIndexes(property, tableForMany.getTableName(property), collectionKeyColumns(property), collectionElementFacets(property, kind)))
     }
 
     /**
@@ -1572,10 +1583,6 @@ class GrailsDomainGenerator {
         if (mapped.type != null) {
             return 'a type is mapped on the collection property itself, which the binder applies to the collection and its element alike'
         }
-        String constraintProblem = collectionConstraintProblem(property)
-        if (constraintProblem != null) {
-            return constraintProblem
-        }
         if (property instanceof HibernateManyToManyProperty) {
             HibernateAssociation other = property.hibernateInverseSide
             if (!(other instanceof HibernateManyToManyProperty) || Map.isAssignableFrom(other.type)) {
@@ -1732,7 +1739,8 @@ class GrailsDomainGenerator {
                 condition,
                 keys != null ? keys : [key],
                 keys != null ? collectionKeyReferencedColumns(property) : [],
-                collectionIndexType(property, kind))
+                collectionIndexType(property, kind),
+                collectionTableIndexes(property, table, keys != null ? keys : [key], element))
     }
 
     /**
diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/ToManyFacets.groovy b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/ToManyFacets.groovy
index 0b5bd9502a..a4a8b5fc62 100644
--- a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/ToManyFacets.groovy
+++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/ToManyFacets.groovy
@@ -49,6 +49,13 @@ import org.hibernate.FetchMode
  * type of that name. It is {@code null} when the mapping states none, or the type is the default (an integer for a list, a string for
  * a map). A list's index stays an integer in Java whatever its JDBC type; the key of a map is typed with the facets' Java type.</p>
  *
+ * <p>{@code indexes} are the indexes the binder puts on the table of the collection when the mapping gives the collection property an
+ * {@code index:}, with the names and the column order it gives them: over the key column and, for a collection of enums, the element
+ * column too. It is empty when there are none, or when the binder binds no key column for the property (it is mapped by the foreign key of
+ * the other side, or the join table maps its key columns). A {@code unique} group on the property creates no key: it names columns of the
+ * owner's table, which the table of the collection does not have. {@link GeneratedDomainClassBinder} creates the indexes on the bound
+ * table, since the inverse side of a many-to-many has no table annotation of its own to state them on.</p>
+ *
  * <p>{@code extraLazy} is an explicit {@code lazy: true}: the binder makes the collection extra-lazy, which Hibernate's annotation
  * binder cannot state, so {@code GeneratedDomainClassBinder} sets it on the collection after binding.</p>
  *
@@ -77,5 +84,6 @@ record ToManyFacets(
     String tenantCondition,
     List<ColumnFacets> keys,
     List<String> referencedKeys,
-    TypeFacets indexType) {
+    TypeFacets indexType,
+    List<IndexFacets> indexes) {
 }
diff --git a/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/generated/GeneratedDomainClassesCollectionConstraintsSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/generated/GeneratedDomainClassesCollectionConstraintsSpec.groovy
new file mode 100644
index 0000000000..49021a5ba5
--- /dev/null
+++ b/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/generated/GeneratedDomainClassesCollectionConstraintsSpec.groovy
@@ -0,0 +1,357 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package grails.gorm.tests.generated
+
+import java.util.concurrent.atomic.AtomicInteger
+
+import grails.gorm.annotation.Entity
+import org.hibernate.mapping.Table
+import spock.lang.AutoCleanup
+import spock.lang.Specification
+import spock.lang.Unroll
+
+import org.grails.orm.hibernate.HibernateDatastore
+
+/**
+ * An {@code index:} or a {@code unique:} group mapped on a collection property, booted through the generated classes and through
+ * the domain binder. The binder binds the key column of a collection like any other column, so {@code index:} creates an index
+ * over the key column of the collection table (and, for a collection of enums, over the element column too), named as the mapping
+ * says; a unique group names columns of the owner's table, which the collection table does not have, so it creates no key in the
+ * generated mode (the binder's impossible key is a defect fixed on the 8.0.x line).
+ */
+class GeneratedDomainClassesCollectionConstraintsSpec extends Specification {
+
+    static final AtomicInteger BOOTS = new AtomicInteger()
+
+    @AutoCleanup
+    HibernateDatastore datastore
+
+    private HibernateDatastore boot(List<Class> group, boolean generated) {
+        datastore?.close()
+        datastore = new HibernateDatastore([
+                'dataSource.url'                  : "jdbc:h2:mem:ccs${BOOTS.incrementAndGet()};LOCK_TIMEOUT=10000".toString(),
+                'dataSource.dbCreate'             : 'create-drop',
+                'hibernate.generatedDomainClasses': generated,
+        ], group as Class[])
+        return datastore
+    }
+
+    /** The tables of the boot model as plain data: the columns with their type, the indexes and the unique keys. */
+    private Map<String, Map> schema(List<Class> group, boolean generated) {
+        HibernateDatastore booted = boot(group, generated)
+        Map<String, Map> result = new TreeMap<String, Map>()
+        for (Table table : booted.metadata.collectTableMappings()) {
+            if (table.physicalTable) {
+                result[table.name] = [
+                        columns   : table.columns.collectEntries { org.hibernate.mapping.Column column ->
+                            // a primary key column is not null in the schema whatever the column says
+                            boolean nullable = column.nullable && !(table.primaryKey != null && table.primaryKey.columns*.name.contains(column.name))
+                            [(column.name): "${column.getSqlType(booted.metadata)}${nullable ? '' : ' not null'}".toString()]
+                        },
+                        primaryKey: table.primaryKey?.columns*.name?.sort(),
+                        indexes   : table.indexes.values().collectEntries { [(it.name): it.columns*.name] },
+                        uniqueKeys: table.uniqueKeys.values().collect { it.columns*.name.sort() }.sort { it.toString() },
+                ]
+            }
+        }
+        return result
+    }
+
+    /** The names of the indexes H2 itself holds on a table, whatever it generated for keys. */
+    private List<String> h2Indexes(String table) {
+        List<String> found = []
+        datastore.connectionSources.defaultConnectionSource.dataSource.connection.withCloseable { java.sql.Connection connection ->
+            connection.createStatement().withCloseable { java.sql.Statement statement ->
+                statement.executeQuery(
+                        "select INDEX_NAME from INFORMATION_SCHEMA.INDEXES where upper(TABLE_NAME) = '${table.toUpperCase()}' " +
+                                "and INDEX_TYPE_NAME = 'INDEX' order by INDEX_NAME".toString()).withCloseable { java.sql.ResultSet rows ->
+                    while (rows.next()) {
+                        found << rows.getString(1).toLowerCase()
+                    }
+                }
+            }
+        }
+        return found.findAll { String name -> !name.startsWith('fk') }
+    }
+
+    @Unroll
+    void "an index mapped on #label creates the indexes the domain binder creates"() {
+        when:
+        Map<String, Map> binder = schema(group, false)
+        Map<String, Map> generated = schema(group, true)
+
+        then: "the indexes and keys of every table are the binder's (the binder leaves an unused column in the table of a map of values)"
+        generated.keySet() == binder.keySet()
+        generated.collectEntries { String name, Map description -> [(name): [description.indexes, description.uniqueKeys]] } ==
+                binder.collectEntries { String name, Map description -> [(name): [description.indexes, description.uniqueKeys]] }
+        generated[table].indexes == indexes
+
+        where:
+        label                                    | group                | table                  | indexes
+        'a set of strings'                       | [CcsSetIdx]          | 'ccs_set_idx_tags'     | [ccs_tags_idx: ['ccs_set_idx_id']]
+        'a set, with true'                       | [CcsIndexTrue]       | 'ccs_index_true_tags'  | [ccs_index_true_tags_ccs_index_true_id_idx: ['ccs_index_true_id']]
+        'a set, with two names'                  | [CcsIndexMulti]      | 'ccs_index_multi_tags' | [ccs_a: ['ccs_index_multi_id'], ccs_b: ['ccs_index_multi_id']]
+        'a set, with false'                      | [CcsIndexFalse]      | 'ccs_index_false_tags' | [:]
+        'a set of enums, over the element too'   | [CcsEnumIdx]         | 'ccs_enum_idx_colors'  | [ccs_color_idx: ['ccs_color', 'ccs_enum_idx_id']]
+        'a list, which names its index column'   | [CcsListIdx]         | 'ccs_list_idx_items'   | [ccs_items_idx: ['ccs_list_idx_id']]
+        'a map'                                  | [CcsMapIdx]          | 'ccs_map_idx_attrs'    | [ccs_attrs_idx: ['ccs_map_idx_id']]
+        'a map, with the settings of the index column' | [CcsMapSettings] | 'ccs_map_settings_attrs' | ['[column:ccs_k': ['ccs_map_settings_id'], 'type:string]': ['ccs_map_settings_id']]
+        'a list of long, with the settings of the index column' | [CcsListSettings] | 'ccs_list_settings_items' | ['[column:ccs_ix': ['ccs_list_settings_id'], 'type:long]': ['ccs_list_settings_id']]
+        'a one-to-many through a join table'     | [CcsKidOwner, CcsKid] | 'ccs_kid_owner_ccs_kid' | [ccs_kids_idx: ['ccs_kid_owner_kids_id']]
+        'a many-to-many, on both sides'          | [CcsLeft, CcsRight]  | 'ccs_left_rights'      | [ccs_right_idx: ['ccs_right_id'], ccs_left_idx: ['ccs_left_id']]
+        'a collection with a mapped join key'    | [CcsJoinKey]         | 'ccs_tags_table'       | [:]
+        'a one-to-many mapped by a foreign key'  | [CcsParent, CcsChild] | 'ccs_child'           | [:]
+    }
+
+    void "the indexes are created in the database, over the key column of the collection table"() {
+        when:
+        boot([CcsSetIdx, CcsEnumIdx], true)
+
+        then:
+        h2Indexes('ccs_set_idx_tags') == ['ccs_tags_idx']
+        h2Indexes('ccs_enum_idx_colors') == ['ccs_color_idx']
+    }
+
+    void "a closure mapped as the index names no index, as its name would be the closure's own, which differs on every boot"() {
+        when:
+        Map<String, Map> binder = schema([CcsClosureIdx], false)
+        Map<String, Map> generated = schema([CcsClosureIdx], true)
+
+        then: "both name the index column after the closure, and only the binder creates an index, named after the closure instance"
+        generated['ccs_closure_idx_items'].columns == binder['ccs_closure_idx_items'].columns
+        generated['ccs_closure_idx_items'].columns.keySet().contains('ccs_cx')
+        generated['ccs_closure_idx_items'].indexes.isEmpty()
+        binder['ccs_closure_idx_items'].indexes.size() == 1
+        binder['ccs_closure_idx_items'].indexes.keySet().first().contains('closure')
+    }
+
+    @Unroll
+    void "a unique group mapped on #label creates no key, since the collection table does not hold the columns of the group"() {
+        when:
+        Map<String, Map> generated = schema(group, true)
+
+        then: "the owner's table has no key either, and the table of the collection holds the keys of the set only"
+        generated[owner].uniqueKeys.isEmpty()
+        generated[table].uniqueKeys.every { List<String> columns -> columns.every { String column -> column in generated[table].columns.keySet() } }
+        !generated[table].uniqueKeys.any { List<String> columns -> 'x' in columns }
+
+        where:
+        label                    | group           | owner           | table
+        'a set of strings'       | [CcsUqGroup]    | 'ccs_uq_group'  | 'ccs_uq_group_tags'
+        'a list'                 | [CcsUqList]     | 'ccs_uq_list'   | 'ccs_uq_list_items'
+        'a set of enums'         | [CcsUqEnum]     | 'ccs_uq_enum'   | 'ccs_uq_enum_colors'
+    }
+
+    void "a collection with a unique group and an index saves and loads, and the group does not reject a repeated value"() {
+        given:
+        boot([CcsUqGroup, CcsSetIdx], true)
+
+        when:
+        Long id = CcsUqGroup.withTransaction {
+            new CcsUqGroup(x: 'a', tags: ['one', 'two'] as Set).save(failOnError: true, flush: true).id
+        }
+        CcsUqGroup.withTransaction {
+            new CcsUqGroup(x: 'a', tags: ['one'] as Set).save(failOnError: true, flush: true)
+        }
+        Long indexed = CcsSetIdx.withTransaction {
+            new CcsSetIdx(tags: ['x', 'y', 'z'] as Set).save(failOnError: true, flush: true).id
+        }
+
+        then:
+        CcsUqGroup.withNewSession { CcsUqGroup.get(id).tags } == ['one', 'two'] as Set
+        CcsUqGroup.withNewSession { CcsUqGroup.count() } == 2
+        CcsSetIdx.withNewSession { CcsSetIdx.get(indexed).tags } == ['x', 'y', 'z'] as Set
+    }
+
+    void "a list, a map and a one-to-many with an index save, reload and are found through queries"() {
+        given:
+        boot([CcsListIdx, CcsMapIdx, CcsKidOwner, CcsKid, CcsLeft, CcsRight], true)
+
+        when:
+        Long listId = CcsListIdx.withTransaction { new CcsListIdx(items: ['a', 'b', 'c']).save(failOnError: true, flush: true).id }
+        Long mapId = CcsMapIdx.withTransaction { new CcsMapIdx(attrs: [k1: 'v1', k2: 'v2']).save(failOnError: true, flush: true).id }
+        Long ownerId = CcsKidOwner.withTransaction {
+            CcsKidOwner owner = new CcsKidOwner()
+            owner.addToKids(new CcsKid(name: 'kid'))
+            owner.save(failOnError: true, flush: true).id
+        }
+        Long leftId = CcsLeft.withTransaction {
+            CcsLeft left = new CcsLeft()
+            left.addToRights(new CcsRight())
+            left.save(failOnError: true, flush: true).id
+        }
+
+        then:
+        CcsListIdx.withNewSession { CcsListIdx.get(listId).items } == ['a', 'b', 'c']
+        CcsMapIdx.withNewSession { CcsMapIdx.get(mapId).attrs } == [k1: 'v1', k2: 'v2']
+        CcsKidOwner.withNewSession { CcsKidOwner.get(ownerId).kids*.name } == ['kid']
+        CcsLeft.withNewSession { CcsLeft.get(leftId).rights.size() } == 1
+        CcsRight.withNewSession { CcsRight.list().first().lefts.size() } == 1
+        CcsListIdx.withNewSession { CcsListIdx.where { items.size() == 3 }.count() } == 1
+    }
+}
+
+enum CcsColor { RED, GREEN }
+
+@Entity
+class CcsSetIdx {
+    Set<String> tags
+    static hasMany = [tags: String]
+    static mapping = { tags index: 'ccs_tags_idx' }
+}
+
+@Entity
+class CcsIndexTrue {
+    Set<String> tags
+    static hasMany = [tags: String]
+    static mapping = { tags index: true }
+}
+
+@Entity
+class CcsIndexMulti {
+    Set<String> tags
+    static hasMany = [tags: String]
+    static mapping = { tags index: 'ccs_a, ccs_b' }
+}
+
+@Entity
+class CcsIndexFalse {
+    Set<String> tags
+    static hasMany = [tags: String]
+    static mapping = { tags index: false }
+}
+
+@Entity
+class CcsEnumIdx {
+    Set<CcsColor> colors
+    static hasMany = [colors: CcsColor]
+    static mapping = { colors index: 'ccs_color_idx' }
+}
+
+@Entity
+class CcsListIdx {
+    List<String> items
+    static hasMany = [items: String]
+    static mapping = { items index: 'ccs_items_idx' }
+}
+
+@Entity
+class CcsMapIdx {
+    Map<String, String> attrs
+    static hasMany = [attrs: String]
+    static mapping = { attrs index: 'ccs_attrs_idx' }
+}
+
+@Entity
+class CcsMapSettings {
+    Map<String, String> attrs
+    static hasMany = [attrs: String]
+    static mapping = { attrs index: [column: 'ccs_k', type: 'string'] }
+}
+
+@Entity
+class CcsListSettings {
+    List<String> items
+    static hasMany = [items: String]
+    static mapping = { items index: [column: 'ccs_ix', type: 'long'] }
+}
+
+@Entity
+class CcsClosureIdx {
+    List<String> items
+    static hasMany = [items: String]
+    static mapping = {
+        items index: {
+            column name: 'ccs_cx'
+        }
+    }
+}
+
+@Entity
+class CcsKid {
+    String name
+}
+
+@Entity
+class CcsKidOwner {
+    Set<CcsKid> kids
+    static hasMany = [kids: CcsKid]
+    static mapping = { kids index: 'ccs_kids_idx' }
+}
+
+@Entity
+class CcsLeft {
+    Set<CcsRight> rights
+    static hasMany = [rights: CcsRight]
+    static mapping = { rights index: 'ccs_left_idx' }
+}
+
+@Entity
+class CcsRight {
+    Set<CcsLeft> lefts
+    static hasMany = [lefts: CcsLeft]
+    static belongsTo = [CcsLeft]
+    static mapping = { lefts index: 'ccs_right_idx' }
+}
+
+@Entity
+class CcsJoinKey {
+    Set<String> tags
+    static hasMany = [tags: String]
+    static mapping = {
+        tags joinTable: [name: 'ccs_tags_table', key: 'owner_fk', column: 'tag_value'], index: 'ccs_jk_idx'
+    }
+}
+
+@Entity
+class CcsParent {
+    Set<CcsChild> children
+    static hasMany = [children: CcsChild]
+    static mapping = { children index: 'ccs_children_idx' }
+}
+
+@Entity
+class CcsChild {
+    CcsParent parent
+    static belongsTo = [parent: CcsParent]
+}
+
+@Entity
+class CcsUqGroup {
+    String x
+    Set<String> tags
+    static hasMany = [tags: String]
+    static mapping = { tags unique: 'x' }
+}
+
+@Entity
+class CcsUqList {
+    String x
+    List<String> items
+    static hasMany = [items: String]
+    static mapping = { items unique: 'x' }
+}
+
+@Entity
+class CcsUqEnum {
+    String x
+    Set<CcsColor> colors
+    static hasMany = [colors: CcsColor]
+    static mapping = { colors unique: 'x' }
+}
diff --git a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GeneratedDomainClassesDdlDifferentialSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GeneratedDomainClassesDdlDifferentialSpec.groovy
index c91853f0cc..efc61e1de2 100644
--- a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GeneratedDomainClassesDdlDifferentialSpec.groovy
+++ b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GeneratedDomainClassesDdlDifferentialSpec.groovy
@@ -64,12 +64,14 @@ class GeneratedDomainClassesDdlDifferentialSpec extends Specification {
      */
     private static final List<Map> KNOWN = [
             [id: 'BINDER_DUPLICATE_FOREIGN_KEY', reason: 'A binder defect: a bidirectional association to an entity with a composite identifier gets two foreign keys of the same name over the same columns, one from the to-one side (referenced columns stated) and one from the collection key (referenced columns left to the primary key), listed in two column orders. Hibernate creates one constraint of that name; which of the two it is depends on the order the domain classes are bound in. Th [...]
+            [id: 'CLOSURE_INDEX_NAME', reason: 'A closure mapped as the index of a collection property (index: { column name: ... }, which configures the index column of a list or a map): the binder also reads it as the name of an index of the collection table and names it with the closure\'s toString(), which holds the identity of the closure instance (...$_closure2@3ff5aef4) and differs on every boot. A name that cannot be reproduced is not stated: the generated mode creates the index  [...]
             [id: 'COMPOSITE_KEY_TYPE_SWAP', reason: 'A binder defect: for a foreign key to a composite identifier whose part is itself a to-one to a composite identifier, the binder names the foreign key columns after the parts in the mapped order but gives them the types of the sorted referenced key, so a part of another type than its neighbour gets the NAME of the other part (child.parent_grand_parent_name is INTEGER, referencing the integer luckyNumber). The generated mode names them  [...]
             [id: 'IGNORE_NOT_FOUND_FOREIGN_KEY', reason: 'ignoreNotFound: true: Hibernate\'s @NotFound(IGNORE) disables the foreign key (SimpleValue.disableForeignKey, there is no enabling counterpart), the binder keeps it, which makes the option contradict itself (a dangling reference cannot exist). Re-creating the key would need Table.createForeignKey with a name computed through the implicit naming strategy\'s internal ForeignKeyNameSource. Hibernate\'s own behaviour is arguably the r [...]
             [id: 'INVERSE_JOIN_TABLE_NAME', reason: 'A mapping that names the join table on both sides of a many-to-many with different names (the binder adopts the owning side\'s name only when the inverse side names none, since the 8.0.x fix for the inverse join table): the inverse side keeps its own name and the binder creates a second, unused table for it. The generated mode creates the table the owning side names (Hibernate derives the inverse side from mappedBy), so the unused tabl [...]
             [id: 'LIST_INDEX_CHECK', reason: 'Hibernate adds check (<index column> >= 0) to the index column of every list (IndexColumn.addIndexCheckConstraint, always, for @OrderColumn) and offers no annotation to avoid it; Column.getCheckConstraints() is unmodifiable and Column has no removal method (Column.copy shares the list), so it cannot be removed through public API, only by reflection on the private list, which is not done. The check can never reject a value GORM writes (indexes [...]
             [id: 'MAP_ELEMENT_NULLABLE', reason: 'The mapping of a map of values states nullable: false on the element column and the binder leaves the column nullable (it ignores the option, like the enum column extras); the generated mode honours the mapping, so a database created by the binder has a nullable column where the generated mode creates NOT NULL. Matching the binder would drop a constraint the mapping states.'],
             [id: 'MAP_UNUSED_COLUMN', reason: 'The binder leaves an unused nullable column in the table of a map of values (the element it bound before the map replaced it, attributes_java_lang_string); the generated mode creates no such column. Nothing reads the extra column; an existing database keeps it (update does not drop columns).'],
+            [id: 'UNIQUE_GROUP_ON_COLLECTION', reason: 'A binder defect (fixed on the 8.0.x line by PR 16533, so the class disappears with the next up-merge): a unique group mapped on a collection property makes the binder create a unique key on the collection table over the key column and the other properties of the group, which are columns of the owner\'s table and not of the collection table, so the key cannot be created (Hibernate logs the failed statement and boots; the key is not i [...]
             [id: 'UNIQUE_GROUP_ON_ENUM', reason: 'A binder defect (pinned in GrailsDomainBinderOptionDefectSpec): a unique group that includes an enum property is dropped by the binder. The generated mode creates the constraint the mapping states, so a database created by the binder lacks it and `update` would add it. 2 groups in the scanned domains.']
     ]
 
@@ -82,7 +84,6 @@ class GeneratedDomainClassesDdlDifferentialSpec extends Specification {
             'is registered for the Java type',
             'names a class that is not a UserType',
             'a type is mapped on the collection property itself',
-            'an index or a unique group is mapped on the collection property',
             'is a collection inside an embedded type',
             'declares a natural id but is a subclass',
             'which is HibernateEmbeddedProperty',
@@ -173,6 +174,12 @@ class GeneratedDomainClassesDdlDifferentialSpec extends Specification {
         if (collection && kind == 'column nullable' && d.collections*.startsWith('Map of BasicValue').any()) {
             return 'MAP_ELEMENT_NULLABLE'
         }
+        if (collection && kind == 'index only in binder mode' && (detail =~ /_closure\d+@[0-9a-f]+/).find()) {
+            return 'CLOSURE_INDEX_NAME'
+        }
+        if (kind == 'unique key only in binder mode' && d.impossibleKey) {
+            return 'UNIQUE_GROUP_ON_COLLECTION'
+        }
         if (collection && kind in ['column nullable', 'primary key only in generated mode', 'unique key only in binder mode']) {
             return 'COLLECTION_TABLE_KEY'
         }
@@ -371,7 +378,9 @@ class GeneratedDomainClassesDdlDifferentialSpec extends Specification {
             compareColumns(name, b, g, add, tags)
             comparePrimaryKeys(name, b, g, add, tags)
             compareNamed('foreign key', name, b.foreignKeys, g.foreignKeys, ['columns', 'refTable'], add, tags)
-            compareNamed('unique key', name, b.uniqueKeys, g.uniqueKeys, ['columns'], add, tags)
+            // a key over a column the table does not have cannot be created: the group names columns of the owner's table
+            Map keyTags = tags + [impossibleKey: tags.joinTable && b.uniqueKeys.values().any { Map key -> key.columns.any { String column -> !b.columns.containsKey(column) } }]
+            compareNamed('unique key', name, b.uniqueKeys, g.uniqueKeys, ['columns'], add, keyTags)
             compareNamed('index', name, b.indexes, g.indexes, ['columns'], add, tags)
             if (b.checks != g.checks) {
                 add('check constraint', name, "binder=${b.checks} generated=${g.checks}", tags)
diff --git a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorDifferentialSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorDifferentialSpec.groovy
index e036390f7f..5960b29c46 100644
--- a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorDifferentialSpec.groovy
+++ b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorDifferentialSpec.groovy
@@ -839,20 +839,33 @@ class GrailsDomainGeneratorDifferentialSpec extends HibernateGormDatastoreSpec {
                 found << "${where} tableColumns: generator=${described} binder=${collection.collectionTable.columns*.name}".toString()
             }
         }
-        found.addAll(compareCollectionTableIndexes(where, collection))
+        found.addAll(compareCollectionTableIndexes(where, facets.indexes(), collection, false, known))
         return found
     }
 
     /**
-     * The generator states no index on the table of a collection: the binder only creates one there when the mapping puts
-     * {@code index:} on the collection property, which the generator rejects, so any index it finds is a mapping the generator
-     * accepted and drops.
+     * The indexes the binder put on the table of a collection against the ones the generator decided: the same names over the same
+     * columns in the same order. The two sides of a bidirectional many-to-many share the one join table, so each side's indexes are
+     * part of the binder's, which holds both sides'. An index the binder named after a closure (the closure's own {@code toString()}, which
+     * holds the identity of the closure instance) is the binder's alone: the generator states none for a closure.
      */
-    private static List<String> compareCollectionTableIndexes(String where, HibernateCollection collection) {
-        List<String> indexes = collection.collectionTable.indexes.values().collect { org.hibernate.mapping.Index index ->
-            "${index.name}:${index.columns*.name}".toString()
+    private static List<String> compareCollectionTableIndexes(
+            String where, List<IndexFacets> facets, HibernateCollection collection, boolean sharedTable, Map<String, Integer> known) {
+        Map<String, List<String>> bound = [:]
+        for (org.hibernate.mapping.Index index : collection.collectionTable.indexes.values()) {
+            if ((index.name =~ /_closure\d+@[0-9a-f]+/).find()) {
+                known['the binder names an index of the collection table after the closure mapped as its index, which differs on every boot']++
+            } else {
+                bound[index.name] = index.columns*.name
+            }
+        }
+        Map<String, List<String>> expected = facets.collectEntries { IndexFacets index -> [(index.name()): index.columns().collect { it.replace('`', '') }] }
+        if (sharedTable) {
+            return expected.findAll { String name, List<String> columns -> !bound.containsKey(name) || !bound[name].containsAll(columns) }.collect {
+                String name, List<String> columns -> "${where} collectionTableIndexes: generator=${name}:${columns} binder=${bound}".toString()
+            }
         }
-        return indexes.isEmpty() ? [] : ["${where} collectionTableIndexes: generator=[] binder=${indexes}".toString()]
+        return expected == bound ? [] : ["${where} collectionTableIndexes: generator=${expected} binder=${bound}".toString()]
     }
 
     /** The key of a collection of basic values must be updatable: Hibernate writes no rows for a collection whose key is not. */
@@ -1659,7 +1672,8 @@ class GrailsDomainGeneratorDifferentialSpec extends HibernateGormDatastoreSpec {
             })
             // Hibernate copies the size of the referenced identifier onto the element column after binding
             found.addAll(compareValueColumn("${where} element".toString(), facets.element(), collection.element, collection.collectionTable, true))
-            found.addAll(compareCollectionTableIndexes(where, collection))
+            found.addAll(compareCollectionTableIndexes(
+                    where, facets.indexes(), collection, property instanceof HibernateManyToManyProperty && property.bidirectional, known))
         }
         found.addAll(circularKeyOrFound(property, compareValueColumns("${where} key".toString(), facets.keys(), collection.key, collection.collectionTable, true), known))
         if (!((DependantValue) collection.key).updateable) {
diff --git a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorIndexSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorIndexSpec.groovy
index 1002af30a9..23bb5a2e43 100644
--- a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorIndexSpec.groovy
+++ b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorIndexSpec.groovy
@@ -24,6 +24,7 @@ import org.hibernate.boot.Metadata
 import org.hibernate.mapping.PersistentClass
 
 import org.grails.orm.hibernate.cfg.domainbinding.hibernate.GrailsHibernatePersistentEntity
+import org.grails.orm.hibernate.cfg.domainbinding.hibernate.HibernateBasicProperty
 
 /**
  * Describes how {@link GrailsDomainGenerator} states the indexes a mapping asks for with {@code index:} on a column. The
@@ -115,20 +116,21 @@ class GrailsDomainGeneratorIndexSpec extends GrailsDomainGeneratorSupport {
         readIndexes(metadata.getEntityBinding(classes[entity(GenIdxJoinedChild)].name)) == boundIndexes(GenIdxJoinedChild)
     }
 
-    void "an index on a collection property is rejected by name, because the binder indexes the key column of the collection table"() {
+    void "an index on a collection property is stated over the key column of the collection table, with the name the binder gives it"() {
         when:
-        generateGroup(GenIdxCollections)
+        CollectionFacets facets = newGenerator().collectionFacets((HibernateBasicProperty) entity(GenIdxCollections).getPropertyByName(property))
+        Map<GrailsHibernatePersistentEntity, Class<?>> classes = generateGroup(GenIdxCollections)
 
-        then:
-        UnsupportedOperationException e = thrown()
-        e.message.contains('GenIdxCollections')
-        e.message.contains(property)
-        e.message.contains('index or a unique group')
-        entity(GenIdxCollections).persistentClass.getProperty(property).value.collectionTable.indexes.values()*.name == [indexName]
+        then: "it is created on the bound table after Hibernate binds the collection, not by an annotation of the carrier"
+        facets.indexes() == expected.collect { String name, List<String> columns -> new IndexFacets(name, columns) }
+        entity(GenIdxCollections).persistentClass.getProperty(property).value.collectionTable.indexes.values().collect {
+            [it.name, it.columns*.name]
+        } == expected.collect { String name, List<String> columns -> [name, columns] }
+        classes[entity(GenIdxCollections)].getAnnotation(Table).indexes().length == 0
 
         where:
-        property | indexName
-        'tags'   | 'gen_idx_tags'
+        property | expected
+        'tags'   | ['gen_idx_tags': ['gen_idx_collections_id']]
     }
 
     private Map<String, List<String>> boundIndexes(Class<?> domain) {
diff --git a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorOptionCoverageSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorOptionCoverageSpec.groovy
index c1e5f42ab6..41f088f26f 100644
--- a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorOptionCoverageSpec.groovy
+++ b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorOptionCoverageSpec.groovy
@@ -79,7 +79,9 @@ class GrailsDomainGeneratorOptionCoverageSpec extends GrailsDomainGeneratorSuppo
             'property.min'                 : ['number'],
             'property.scale'               : ['number'],
             'property.unique'              : ['basic', 'number', 'target', 'tags', 'modes', 'targets', 'ordered'],
-            'property.uniqueGroup'         : ['basic', 'number', 'mode', 'target', 'tags', 'modes', 'targets', 'ordered'],
+            // a unique group on a collection property names columns of the owner's table, which the collection table does not have: the binder's
+            // key over them cannot be created (a defect fixed on the 8.0.x line), so the generator deliberately states none
+            'property.uniqueGroup'         : ['basic', 'number', 'mode', 'target'],
             'property.insertable'          : KINDS,
             'property.updatable'           : KINDS,
             'property.type'                : ['basic'],
@@ -405,8 +407,8 @@ class GrailsDomainGeneratorOptionCoverageSpec extends GrailsDomainGeneratorSuppo
 
     /**
      * What the generator decides for the group: the generated classes (annotations and fields) and the facets that no annotation
-     * carries but the binding of the generated classes applies to the bound properties: an extra-lazy collection and a lazy
-     * embedded property.
+     * carries but the binding of the generated classes applies to the bound properties: an extra-lazy collection, the indexes of the
+     * table of a collection and a lazy embedded property.
      */
     private String signature() {
         Map<GrailsHibernatePersistentEntity, Class<?>> classes = generateGroup(GenCovOwner, GenCovTarget)
@@ -422,13 +424,17 @@ class GrailsDomainGeneratorOptionCoverageSpec extends GrailsDomainGeneratorSuppo
     private static String appliedAfterBinding(GrailsDomainGenerator generator, GrailsHibernatePersistentEntity entity) {
         return entity.hibernatePersistentProperties.findAll { HibernatePersistentProperty property ->
             if (property instanceof HibernateBasicProperty && generator.supports(property)) {
-                return generator.collectionFacets((HibernateBasicProperty) property).extraLazy()
+                CollectionFacets facets = generator.collectionFacets((HibernateBasicProperty) property)
+                return facets.extraLazy() || !facets.indexes().isEmpty()
             }
             if (property instanceof HibernateEmbeddedProperty) {
                 return generator.supports(property) && property.isLazy()
             }
-            return property instanceof HibernateToManyEntityProperty && generator.supports(property) &&
-                    generator.toManyFacets((HibernateToManyEntityProperty) property).extraLazy()
+            if (property instanceof HibernateToManyEntityProperty && generator.supports(property)) {
+                ToManyFacets facets = generator.toManyFacets((HibernateToManyEntityProperty) property)
+                return facets.extraLazy() || !facets.indexes().isEmpty()
+            }
+            return false
         }*.name.sort().join(',')
     }
 }
diff --git a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorUniqueGroupSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorUniqueGroupSpec.groovy
index 2231230110..bd5c144561 100644
--- a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorUniqueGroupSpec.groovy
+++ b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorUniqueGroupSpec.groovy
@@ -19,12 +19,14 @@
 package org.grails.orm.hibernate.cfg.domainbinding.jpa
 
 import grails.gorm.annotation.Entity
+import jakarta.persistence.CollectionTable
 import jakarta.persistence.Table
 import jakarta.persistence.UniqueConstraint
 import org.hibernate.boot.Metadata
 import org.hibernate.mapping.PersistentClass
 
 import org.grails.orm.hibernate.cfg.domainbinding.hibernate.GrailsHibernatePersistentEntity
+import org.grails.orm.hibernate.cfg.domainbinding.hibernate.HibernateBasicProperty
 
 /**
  * Describes how {@link GrailsDomainGenerator} states the unique constraints a mapping asks for with {@code unique: 'group'}
@@ -116,18 +118,18 @@ class GrailsDomainGeneratorUniqueGroupSpec extends GrailsDomainGeneratorSupport
         newGenerator().constraintFacets(entity(GenUqTarget)).primaryKeyOrder() == null
     }
 
-    void "a unique group on a collection property is rejected by name, because the binder makes it a key of the collection table"() {
+    void "a unique group on a collection property states no key, since the key the binder makes names columns the collection table does not have"() {
         when:
-        generateGroup(GenUqCollection)
+        Map<GrailsHibernatePersistentEntity, Class<?>> classes = generateGroup(GenUqCollection)
+        CollectionTable table = classes[entity(GenUqCollection)].getDeclaredField('tags').getAnnotation(CollectionTable)
 
-        then: "the key names a column of the owner's table, which the collection table does not have"
-        UnsupportedOperationException e = thrown()
-        e.message.contains('GenUqCollection')
-        e.message.contains('tags')
-        e.message.contains('index or a unique group')
+        then: "the binder's key names a column of the owner's table, so it cannot be created; the generated collection has none"
         entity(GenUqCollection).persistentClass.getProperty('tags').value.collectionTable.uniqueKeys.values().any {
             it.columns*.name.contains('x')
         }
+        table.uniqueConstraints().length == 0
+        newGenerator().collectionFacets((HibernateBasicProperty) entity(GenUqCollection).getPropertyByName('tags')).indexes().isEmpty()
+        newGenerator().constraintFacets(entity(GenUqCollection)).uniqueKeys().isEmpty()
     }
 
     private Map<GrailsHibernatePersistentEntity, Class<?>> generateAll() {
diff --git a/grails-data-hibernate7/docs/src/docs/asciidoc/advancedGORMFeatures/nativeDomainBinding.adoc b/grails-data-hibernate7/docs/src/docs/asciidoc/advancedGORMFeatures/nativeDomainBinding.adoc
index 981de8025c..a2af6f4c77 100644
--- a/grails-data-hibernate7/docs/src/docs/asciidoc/advancedGORMFeatures/nativeDomainBinding.adoc
+++ b/grails-data-hibernate7/docs/src/docs/asciidoc/advancedGORMFeatures/nativeDomainBinding.adoc
@@ -89,10 +89,6 @@ Nothing is ignored silently. A mapping that boots is bound the way it is written
 | `The natural id of [...] names [...], which is not a persistent property of the entity ...`
 | Correct the name. Classic binding skips an unknown name without a message, so the entity has no natural id.
 
-| An `index` or a `unique` group on the property of a collection (`hasMany`).
-| `Collection property [...]: an index or a unique group is mapped on the collection property ...`
-| Remove the option from the collection property and create the index in a database migration if you need it. Classic binding creates the index over the key column of the collection table, and a `unique` group that names columns of the owner's table makes the schema fail to create.
-
 | A `type` on the property of a collection (`hasMany`).
 | `Collection property [...]: a type is mapped on the collection property itself ...`
 | Remove the `type`. Classic binding applies it to the collection and to its elements alike.
@@ -158,6 +154,12 @@ Some bugs of classic binding are not copied by native binding. Native binding do
 
 | The `nullable: false` constraint on the element column of a map of values.
 | Classic binding leaves the column nullable. Native binding makes it `not null`.
+
+| A `unique` group on the property of a collection (`hasMany`).
+| The group names columns of the owner's table, which the table of the collection does not have. Classic binding tries to create a unique key over them and the statement fails when the schema is created, so the database has no such key. Native binding creates no key. The `index` option on the same property works in both bindings: the index is created over the key column of the collection table (and over the element column of a collection of enums), with the name the mapping gives it.
+
+| An `index` given as a closure on the property of a collection, for example `index: { column name: 'position' }`.
+| The closure configures the index column of a list or a map in both bindings. Classic binding also creates an index of the collection table that is named after the closure, a name that changes on every start. Native binding creates the index column and no such index.
 |===
 
 [[nativeDomainBinding-schema]]


(grails-core) 09/16: feat: accept a map on a many-to-many in native domain binding

Posted by bo...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

borinquenkid pushed a commit to branch 9.0.x-h7-improvement-phase3
in repository https://gitbox.apache.org/repos/asf/grails-core.git

commit 849b34f8a1567f6e00adac82aa137504b366f0ab
Author: Walter Duque de Estrada <wb...@mac.com>
AuthorDate: Wed Oct 7 23:39:55 2026 -0500

    feat: accept a map on a many-to-many in native domain binding
    
    The generated mode refused a Map valued hasMany on a bidirectional many-to-many. The domain binder makes a map side not inverse whether or not the mapping says it owns the relationship, so the map side writes the join table (key column, element column and the column of the key of the map, which is part of the primary key), a Set on the other side is inverse over the same table, and two maps are two independent tables. The generated classes now state exactly that: the map side is the o [...]
    
    Two maps read back as the binder does, a map stores its rows through its own side only, which is documented rather than improved. The element column of a map beside a set that belongs to the map's class is nullable or not null in the binder depending on the order the classes are bound in, which the generated mode does not copy: it states not null, and the difference is a known class of the DDL differential with the pinning spec. The refused group count of the DDL differential drops fr [...]
    
    Co-Authored-By: Claude Sonnet 5.5 <no...@anthropic.com>
---
 .../domainbinding/jpa/GrailsDomainGenerator.groovy |  20 +-
 .../GeneratedDomainClassesMapManyToManySpec.groovy | 273 +++++++++++++++++++++
 .../binder/ManyToManyWithoutOwnerSpec.groovy       |  41 +---
 ...eneratedDomainClassesDdlDifferentialSpec.groovy |   6 +-
 .../jpa/GrailsDomainGeneratorSpec.groovy           |  40 ++-
 .../advancedGORMFeatures/nativeDomainBinding.adoc  |  11 +-
 6 files changed, 343 insertions(+), 48 deletions(-)

diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGenerator.groovy b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGenerator.groovy
index 6511ef7da6..7ca78ef1da 100644
--- a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGenerator.groovy
+++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGenerator.groovy
@@ -1670,6 +1670,15 @@ class GrailsDomainGenerator {
         return order.indexOf(property.name) <= order.indexOf(inverse.name)
     }
 
+    /**
+     * Whether the binder binds the collection of this many-to-many side as the one that writes the join table: the owning side
+     * ({@code belongsTo}), and a map, which {@code MapSecondPassBinder} always makes not inverse (a map side stores its rows with
+     * the key of the map whether or not the mapping says it owns the relationship).
+     */
+    private static boolean ownsManyToMany(HibernateManyToManyProperty property) {
+        return property.owningSide || Map.isAssignableFrom(property.type)
+    }
+
     /**
      * @return why the generator cannot describe the collection of entities, or {@code null} when it can
      */
@@ -1679,9 +1688,6 @@ class GrailsDomainGenerator {
         if (writeProblem != null) {
             return writeProblem
         }
-        if (Map.isAssignableFrom(property.type) && property instanceof HibernateManyToManyProperty) {
-            return 'a map on a many-to-many, which the generator does not support yet'
-        }
         CollectionKind kind = CollectionKind.of(property.type)
         if (kind == null) {
             return "the declared type [${property.type?.name}] is not one of Set, SortedSet, List or Collection, " +
@@ -1707,10 +1713,10 @@ class GrailsDomainGenerator {
         }
         if (property instanceof HibernateManyToManyProperty) {
             HibernateAssociation other = property.hibernateInverseSide
-            if (!(other instanceof HibernateManyToManyProperty) || Map.isAssignableFrom(other.type)) {
+            if (!(other instanceof HibernateManyToManyProperty)) {
                 return "the other side [${other?.name}] is not a many-to-many collection"
             }
-            if (!property.owningSide && !other.owningSide) {
+            if (!ownsManyToMany(property) && !ownsManyToMany((HibernateManyToManyProperty) other)) {
                 return 'neither side of the many-to-many owns it (no belongsTo): the binder binds both collections inverse, so ' +
                         'no row is ever written, and annotations cannot say it (Hibernate\'s annotation binder fails with a ' +
                         'NullPointerException when both sides are mappedBy)'
@@ -1802,7 +1808,7 @@ class GrailsDomainGenerator {
             // ManyToOneBinder binds the element like the other side's own column: its name rules and its (never) nullable column
             element = circularKeyName(other, toOneColumnFacets(other))
             String joinColumnName = joinTable?.column?.name
-            if (joinColumnName != null && property.owningSide && !property.isCircular()) {
+            if (joinColumnName != null && ownsManyToMany(property) && !property.isCircular()) {
                 // the owning side names the element column of the join table it writes (the inverse side adopts it); a circular
                 // side keeps naming it after the property of the other side, as the binder does
                 element = new ColumnFacets(
@@ -1810,7 +1816,7 @@ class GrailsDomainGenerator {
                         element.length(), element.precision(), element.scale(), element.sqlType(), element.defaultValue(),
                         element.read(), element.write(), element.comment())
             }
-            mappedBy = property.owningSide ? null : other.name
+            mappedBy = ownsManyToMany(property) ? null : other.name
         } else if (property.shouldBindWithForeignKey()) {
             HibernateToOneProperty inverse = (HibernateToOneProperty) property.hibernateInverseSide
             // CollectionKeyBinder copies the other side's foreign key column into the key; the key updater makes it nullable
diff --git a/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/generated/GeneratedDomainClassesMapManyToManySpec.groovy b/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/generated/GeneratedDomainClassesMapManyToManySpec.groovy
new file mode 100644
index 0000000000..2e5fc4cd45
--- /dev/null
+++ b/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/generated/GeneratedDomainClassesMapManyToManySpec.groovy
@@ -0,0 +1,273 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package grails.gorm.tests.generated
+
+import java.util.concurrent.atomic.AtomicInteger
+
+import grails.gorm.annotation.Entity
+import org.hibernate.mapping.Table
+import spock.lang.AutoCleanup
+import spock.lang.Specification
+import spock.lang.Unroll
+
+import org.grails.orm.hibernate.HibernateDatastore
+import org.grails.orm.hibernate.cfg.domainbinding.binder.MapMapLeft
+import org.grails.orm.hibernate.cfg.domainbinding.binder.MapMapRight
+import org.grails.orm.hibernate.cfg.domainbinding.binder.MapSetLeft
+import org.grails.orm.hibernate.cfg.domainbinding.binder.MapSetRight
+
+/**
+ * A {@code Map} on a many-to-many, bound by the domain binder and by the generated classes. The domain binder makes a map side not
+ * inverse whether or not the mapping says it owns the relationship, so the map side writes the join table (key column, element
+ * column and the column of the key of the map, which is part of the primary key), a {@code Set} on the other side is inverse and reads
+ * the same table, and two maps are two independent tables (a map on a many-to-many stores through its own side only). The generated
+ * classes state exactly that.
+ */
+class GeneratedDomainClassesMapManyToManySpec extends Specification {
+
+    static final AtomicInteger BOOTS = new AtomicInteger()
+
+    @AutoCleanup
+    HibernateDatastore datastore
+
+    private HibernateDatastore boot(List<Class> group, boolean generated) {
+        datastore?.close()
+        datastore = new HibernateDatastore([
+                'dataSource.url'                  : "jdbc:h2:mem:mmm${BOOTS.incrementAndGet()};LOCK_TIMEOUT=10000".toString(),
+                'dataSource.dbCreate'             : 'create-drop',
+                'hibernate.generatedDomainClasses': generated,
+        ], group as Class[])
+        return datastore
+    }
+
+    /** The tables of the boot model as plain data, and which collection of which role is inverse. */
+    private Map<String, Map> schema(List<Class> group, boolean generated) {
+        HibernateDatastore booted = boot(group, generated)
+        Map<String, Map> result = new TreeMap<String, Map>()
+        for (Table table : booted.metadata.collectTableMappings()) {
+            if (table.physicalTable) {
+                result[table.name] = [
+                        columns    : table.columns.collectEntries { org.hibernate.mapping.Column column ->
+                            boolean nullable = column.nullable && !(table.primaryKey != null && table.primaryKey.columns*.name.contains(column.name))
+                            [(column.name): "${column.getSqlType(booted.metadata)}${nullable ? '' : ' not null'}${column.unique ? ' unique' : ''}".toString()]
+                        },
+                        primaryKey : table.primaryKey?.columns*.name?.sort(),
+                        foreignKeys: table.foreignKeys.values().collect { "${it.columns*.name} -> ${it.referencedTable.name}".toString() }.sort(),
+                        indexes    : table.indexes.values().collectEntries { [(it.name): it.columns*.name] },
+                        uniqueKeys : table.uniqueKeys.values().collect { it.columns*.name.sort() }.sort { it.toString() },
+                ]
+            }
+        }
+        result['collections'] = booted.metadata.collectionBindings.collectEntries { org.hibernate.mapping.Collection collection ->
+            [(collection.role): "${collection.class.simpleName} inverse=${collection.inverse} table=${collection.collectionTable.name} key=${collection.key.selectables*.text}".toString()]
+        }.sort()
+        return result
+    }
+
+    @Unroll
+    void "#label: the generated mode creates the schema of the domain binder"() {
+        when:
+        Map<String, Map> binder = schema(group, false)
+        Map<String, Map> generated = schema(group, true)
+
+        then:
+        generated == binder
+        generated.keySet() - 'collections' == tables as Set
+        generated[joinTable].columns.keySet() == columns as Set
+        generated[joinTable].primaryKey == primaryKey.sort(false)
+
+        where:
+        label                                          | group                       | tables                                                                        | joinTable                | columns                                                         | primaryKey
+        'a map beside a set'                           | [MapSetLeft, MapSetRight]   | ['map_set_left', 'map_set_right', 'map_set_left_rights']                      | 'map_set_left_rights'    | ['map_set_left_id', 'map_set_right_id', 'rights_idx']           | ['map_set_left_id', 'rights_idx']
+        'a map beside a map'                           | [MapMapLeft, MapMapRight]   | ['map_map_left', 'map_map_right', 'map_map_left_rights', 'map_map_right_lefts'] | 'map_map_right_lefts'  | ['map_map_left_id', 'map_map_right_id', 'lefts_idx']            | ['map_map_right_id', 'lefts_idx']
+        'a map beside a set that belongs to the map'   | [MmmOwnerLeft, MmmOwnedRight] | ['mmm_owner_left', 'mmm_owned_right', 'mmm_owner_left_rights']              | 'mmm_owner_left_rights'  | ['mmm_owner_left_id', 'mmm_owned_right_id', 'rights_idx']       | ['mmm_owner_left_id', 'rights_idx']
+        'a map beside a set that owns the relationship' | [MmmOwnedLeft, MmmOwnerRight] | ['mmm_owned_left', 'mmm_owner_right', 'mmm_owned_left_rights', 'mmm_owner_right_lefts'] | 'mmm_owned_left_rights' | ['mmm_owned_left_id', 'mmm_owner_right_id', 'rights_idx'] | ['mmm_owned_left_id', 'rights_idx']
+        'a map with a join table, key, column and index names' | [MmmNamedLeft, MmmNamedRight] | ['mmm_named_left', 'mmm_named_right', 'mmm_named_join']              | 'mmm_named_join'         | ['l_fk', 'r_fk', 'k_col']                                      | ['l_fk', 'k_col']
+    }
+
+    void "the map side writes the join table and the set side is inverse"() {
+        when:
+        Map<String, Map> generated = schema([MapSetLeft, MapSetRight], true)
+
+        then:
+        generated.collections['org.grails.orm.hibernate.cfg.domainbinding.binder.MapSetLeft.rights'].contains('inverse=false')
+        generated.collections['org.grails.orm.hibernate.cfg.domainbinding.binder.MapSetRight.lefts'].contains('inverse=true')
+        generated.collections['org.grails.orm.hibernate.cfg.domainbinding.binder.MapSetRight.lefts'].contains('table=map_set_left_rights')
+    }
+
+    @Unroll
+    void "#label: the rows are stored through the map, read back, changed and deleted, the same in both modes"() {
+        when:
+        Map<Boolean, Map> results = [false, true].collectEntries { boolean generated ->
+            boot(group, generated)
+            [(generated): cycle(left, right, joinTable)]
+        }
+
+        then:
+        results[true] == results[false]
+        results[true].stored == [a: 'r1', b: 'r2']
+        results[true].changed == [b: 'r2', c: 'r1']
+        results[true].rowsStored == 2
+        results[true].rowsChanged == 2
+        results[true].rowsDeleted == 0
+        results[true].inverse == inverse
+
+        where:
+        label                           | group                         | left          | right          | joinTable               | inverse
+        'a map beside a set'            | [MapSetLeft, MapSetRight]     | MapSetLeft    | MapSetRight    | 'map_set_left_rights'   | ['left']
+        'a map beside a map'            | [MapMapLeft, MapMapRight]     | MapMapLeft    | MapMapRight    | 'map_map_left_rights'   | []
+        'a map beside a set it owns'    | [MmmOwnerLeft, MmmOwnedRight] | MmmOwnerLeft  | MmmOwnedRight  | 'mmm_owner_left_rights' | ['left']
+        'a map beside an owning set'    | [MmmOwnedLeft, MmmOwnerRight] | MmmOwnedLeft  | MmmOwnerRight  | 'mmm_owned_left_rights' | []
+        'a map with a join table named' | [MmmNamedLeft, MmmNamedRight] | MmmNamedLeft  | MmmNamedRight  | 'mmm_named_join'        | ['left']
+    }
+
+    void "the domain binder leaves the element column of a map nullable or not null depending on the order the classes are bound in, the generated mode always states not null"() {
+        when: "the column is shared by the element of the map and the key of the set that reads it, and the binder sets its nullability twice"
+        Map<String, String> binder = [[MmmOwnerLeft, MmmOwnedRight], [MmmOwnedRight, MmmOwnerLeft]].collectEntries { List<Class> group ->
+            [(group*.simpleName.join(',')): schema(group, false)['mmm_owner_left_rights'].columns['mmm_owned_right_id']]
+        }
+        Map<String, String> generated = [[MmmOwnerLeft, MmmOwnedRight], [MmmOwnedRight, MmmOwnerLeft]].collectEntries { List<Class> group ->
+            [(group*.simpleName.join(',')): schema(group, true)['mmm_owner_left_rights'].columns['mmm_owned_right_id']]
+        }
+
+        then:
+        binder.values().toSet() == ['bigint', 'bigint not null'].toSet()
+        generated.values().toSet() == ['bigint not null'].toSet()
+    }
+
+    private Map cycle(Class left, Class right, String joinTable) {
+        Long leftId
+        Long firstId
+        left.withTransaction {
+            def first = right.newInstance(name: 'r1').save(failOnError: true)
+            def second = right.newInstance(name: 'r2').save(failOnError: true)
+            firstId = first.id
+            leftId = left.newInstance(name: 'left', rights: [a: first, b: second]).save(failOnError: true, flush: true).id
+        }
+        Map stored = left.withNewSession { new TreeMap(left.get(leftId).rights.collectEntries { String key, value -> [key, value.name] }) }
+        List inverse = right.withNewSession {
+            def side = right.get(firstId).lefts
+            side instanceof Map ? side.values()*.name.sort() : side*.name.sort()
+        }
+        int rowsStored = countRows(joinTable)
+        left.withTransaction {
+            def row = left.get(leftId)
+            def first = right.get(firstId)
+            row.rights.remove('a')
+            row.rights.put('c', first)
+            row.save(failOnError: true, flush: true)
+        }
+        Map changed = left.withNewSession { new TreeMap(left.get(leftId).rights.collectEntries { String key, value -> [key, value.name] }) }
+        int rowsChanged = countRows(joinTable)
+        left.withTransaction { left.get(leftId).delete(flush: true) }
+        return [stored: stored, inverse: inverse, changed: changed, rowsStored: rowsStored, rowsChanged: rowsChanged, rowsDeleted: countRows(joinTable)]
+    }
+
+    private int countRows(String table) {
+        datastore.connectionSources.defaultConnectionSource.dataSource.connection.withCloseable { java.sql.Connection connection ->
+            connection.createStatement().executeQuery("select count(*) from ${table}".toString()).with { java.sql.ResultSet rows -> rows.next(); rows.getInt(1) }
+        }
+    }
+
+    void "a query joins through the map and through the set that reads it, the same in both modes"() {
+        when:
+        List<List> results = [false, true].collect { boolean generated ->
+            boot([MapSetLeft, MapSetRight], generated)
+            MapSetLeft.withTransaction {
+                MapSetRight red = new MapSetRight(name: 'red').save(failOnError: true)
+                MapSetRight blue = new MapSetRight(name: 'blue').save(failOnError: true)
+                new MapSetLeft(name: 'one', rights: [x: red, y: blue]).save(failOnError: true)
+                new MapSetLeft(name: 'two', rights: [x: red]).save(failOnError: true, flush: true)
+            }
+            MapSetLeft.withNewSession {
+                [MapSetLeft.executeQuery('select l.name from MapSetLeft l join l.rights r where r.name = :n order by l.name', [n: 'red']),
+                 MapSetLeft.executeQuery('select l.name from MapSetLeft l join l.rights r where r.name = :n', [n: 'blue']),
+                 MapSetRight.executeQuery('select l.name from MapSetRight r join r.lefts l where r.name = :n order by l.name', [n: 'red']),
+                 MapSetLeft.executeQuery('select count(l) from MapSetLeft l where size(l.rights) = 2')[0]]
+            }
+        }
+
+        then:
+        results[1] == results[0]
+        results[1] == [['one', 'two'], ['one'], ['one', 'two'], 1]
+    }
+
+    void "the second map of two maps does not see the rows stored through the first, in both modes"() {
+        when:
+        List<List> results = [false, true].collect { boolean generated ->
+            boot([MapMapLeft, MapMapRight], generated)
+            MapMapLeft.withTransaction {
+                MapMapRight right = new MapMapRight(name: 'right').save(failOnError: true)
+                new MapMapLeft(name: 'left', rights: [first: right]).save(failOnError: true, flush: true)
+            }
+            MapMapLeft.withNewSession { [MapMapLeft.findByName('left').rights.keySet() as List, MapMapRight.findByName('right').lefts.keySet() as List] }
+        }
+
+        then:
+        results[1] == results[0]
+        results[1] == [['first'], []]
+    }
+}
+
+@Entity
+class MmmOwnerLeft {
+    String name
+    Map<String, MmmOwnedRight> rights
+    static hasMany = [rights: MmmOwnedRight]
+}
+
+@Entity
+class MmmOwnedRight {
+    String name
+    Set<MmmOwnerLeft> lefts
+    static hasMany = [lefts: MmmOwnerLeft]
+    static belongsTo = MmmOwnerLeft
+}
+
+@Entity
+class MmmOwnedLeft {
+    String name
+    Map<String, MmmOwnerRight> rights
+    static hasMany = [rights: MmmOwnerRight]
+    static belongsTo = MmmOwnerRight
+}
+
+@Entity
+class MmmOwnerRight {
+    String name
+    Set<MmmOwnedLeft> lefts
+    static hasMany = [lefts: MmmOwnedLeft]
+}
+
+@Entity
+class MmmNamedLeft {
+    String name
+    Map<String, MmmNamedRight> rights
+    static hasMany = [rights: MmmNamedRight]
+    static mapping = {
+        rights joinTable: [name: 'mmm_named_join', key: 'l_fk', column: 'r_fk'], indexColumn: [name: 'k_col']
+    }
+}
+
+@Entity
+class MmmNamedRight {
+    String name
+    Set<MmmNamedLeft> lefts
+    static hasMany = [lefts: MmmNamedLeft]
+}
diff --git a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/binder/ManyToManyWithoutOwnerSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/binder/ManyToManyWithoutOwnerSpec.groovy
index 901ec72f7c..1301ce7c9c 100644
--- a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/binder/ManyToManyWithoutOwnerSpec.groovy
+++ b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/binder/ManyToManyWithoutOwnerSpec.groovy
@@ -67,20 +67,6 @@ class ManyToManyWithoutOwnerSpec extends Specification {
         return null
     }
 
-    private static UnsupportedOperationException refusalFailure(Closure<?> boot) {
-        try {
-            boot()
-        } catch (Throwable e) {
-            for (Throwable cause = e; cause != null; cause = cause.cause == cause ? null : cause.cause) {
-                if (cause instanceof UnsupportedOperationException) {
-                    return (UnsupportedOperationException) cause
-                }
-            }
-            throw e
-        }
-        return null
-    }
-
     @Unroll
     def "the application does not start when neither side of a many-to-many declares belongsTo (generated mapping: #generated)"() {
         when:
@@ -158,9 +144,9 @@ class ManyToManyWithoutOwnerSpec extends Specification {
     }
 
     @Unroll
-    def "a many-to-many with a Map on #shape starts without belongsTo and stores its rows through the Map side"() {
+    def "a many-to-many with a Map on #shape starts without belongsTo and stores its rows through the Map side (generated mapping: #generated)"() {
         when:
-        boot(false, left, right)
+        boot(generated, left, right)
         left.withTransaction {
             def rightRow = right.newInstance(name: 'right').save(failOnError: true)
             left.newInstance(name: 'left', (leftProperty): [first: rightRow]).save(flush: true, failOnError: true)
@@ -171,24 +157,11 @@ class ManyToManyWithoutOwnerSpec extends Specification {
         !readsBack || right.withNewSession { right.findByName('right')."$rightProperty"*.name } == ['left']
 
         where:
-        shape        | left       | right       | leftProperty | rightProperty | readsBack
-        'one side'   | MapSetLeft | MapSetRight | 'rights'     | 'lefts'       | true
-        'both sides' | MapMapLeft | MapMapRight | 'rights'     | 'lefts'       | false
-    }
-
-    @Unroll
-    def "the generated mapping refuses a many-to-many with a Map on #shape as a map on a many-to-many, not as a missing belongsTo"() {
-        when:
-        UnsupportedOperationException refusal = refusalFailure { boot(true, left, right) }
-
-        then:
-        refusal != null
-        refusal.message.contains('a map on a many-to-many, which the generator does not support yet')
-
-        where:
-        shape        | left       | right
-        'one side'   | MapSetLeft | MapSetRight
-        'both sides' | MapMapLeft | MapMapRight
+        shape        | left       | right       | leftProperty | rightProperty | readsBack | generated
+        'one side'   | MapSetLeft | MapSetRight | 'rights'     | 'lefts'       | true      | false
+        'both sides' | MapMapLeft | MapMapRight | 'rights'     | 'lefts'       | false     | false
+        'one side'   | MapSetLeft | MapSetRight | 'rights'     | 'lefts'       | true      | true
+        'both sides' | MapMapLeft | MapMapRight | 'rights'     | 'lefts'       | false     | true
     }
 
     def "a Set on both sides without belongsTo still does not start"() {
diff --git a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GeneratedDomainClassesDdlDifferentialSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GeneratedDomainClassesDdlDifferentialSpec.groovy
index edc0b72504..f0cc67926e 100644
--- a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GeneratedDomainClassesDdlDifferentialSpec.groovy
+++ b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GeneratedDomainClassesDdlDifferentialSpec.groovy
@@ -69,6 +69,7 @@ class GeneratedDomainClassesDdlDifferentialSpec extends Specification {
             [id: 'IGNORE_NOT_FOUND_FOREIGN_KEY', reason: 'ignoreNotFound: true: Hibernate\'s @NotFound(IGNORE) disables the foreign key (SimpleValue.disableForeignKey, there is no enabling counterpart), the binder keeps it, which makes the option contradict itself (a dangling reference cannot exist). Re-creating the key would need Table.createForeignKey with a name computed through the implicit naming strategy\'s internal ForeignKeyNameSource. Hibernate\'s own behaviour is arguably the r [...]
             [id: 'INVERSE_JOIN_TABLE_NAME', reason: 'A mapping that names the join table on both sides of a many-to-many with different names (the binder adopts the owning side\'s name only when the inverse side names none, since the 8.0.x fix for the inverse join table): the inverse side keeps its own name and the binder creates a second, unused table for it. The generated mode creates the table the owning side names (Hibernate derives the inverse side from mappedBy), so the unused tabl [...]
             [id: 'LIST_INDEX_CHECK', reason: 'Hibernate adds check (<index column> >= 0) to the index column of every list (IndexColumn.addIndexCheckConstraint, always, for @OrderColumn) and offers no annotation to avoid it; Column.getCheckConstraints() is unmodifiable and Column has no removal method (Column.copy shares the list), so it cannot be removed through public API, only by reflection on the private list, which is not done. The check can never reject a value GORM writes (indexes [...]
+            [id: 'MAP_MANY_TO_MANY_ELEMENT_ORDER', reason: 'A binder defect that depends on the order the classes are bound in: the element column of a map on a many-to-many is also the key column of the set that reads the map, one column of the join table, and the binder sets its nullability twice (the element binder: not null; the key updater of the inverse set: nullable), so a database created by the binder has a nullable column or a not null one depending on which of the two collecti [...]
             [id: 'MAP_ELEMENT_NULLABLE', reason: 'The mapping of a map of values states nullable: false on the element column and the binder leaves the column nullable (it ignores the option, like the enum column extras); the generated mode honours the mapping, so a database created by the binder has a nullable column where the generated mode creates NOT NULL. Matching the binder would drop a constraint the mapping states.'],
             [id: 'MAP_UNUSED_COLUMN', reason: 'The binder leaves an unused nullable column in the table of a map of values (the element it bound before the map replaced it, attributes_java_lang_string); the generated mode creates no such column. Nothing reads the extra column; an existing database keeps it (update does not drop columns).'],
             [id: 'UNIQUE_GROUP_ON_COLLECTION', reason: 'A binder defect (fixed on the 8.0.x line by PR 16533, so the class disappears with the next up-merge): a unique group mapped on a collection property makes the binder create a unique key on the collection table over the key column and the other properties of the group, which are columns of the owner\'s table and not of the collection table, so the key cannot be created (Hibernate logs the failed statement and boots; the key is not i [...]
@@ -87,7 +88,6 @@ class GeneratedDomainClassesDdlDifferentialSpec extends Specification {
             'which is HibernateEmbeddedProperty',
             'which is not a persistent property of the entity',
             'is a registered type with type parameters',
-            'a map on a many-to-many',
     ]
 
     private static final AtomicInteger BOOTS = new AtomicInteger()
@@ -178,6 +178,10 @@ class GeneratedDomainClassesDdlDifferentialSpec extends Specification {
         if (kind == 'unique key only in binder mode' && d.impossibleKey) {
             return 'UNIQUE_GROUP_ON_COLLECTION'
         }
+        if (collection && kind == 'column nullable' && detail.contains('binder=true generated=false') &&
+                d.collections*.startsWith('Map of ManyToOne').any() && d.collections*.endsWith('(inverse)').any()) {
+            return 'MAP_MANY_TO_MANY_ELEMENT_ORDER'
+        }
         if (collection && kind in ['column nullable', 'primary key only in generated mode', 'unique key only in binder mode']) {
             return 'COLLECTION_TABLE_KEY'
         }
diff --git a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorSpec.groovy
index 5f107fb16e..6834603cca 100644
--- a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorSpec.groovy
+++ b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorSpec.groovy
@@ -167,7 +167,7 @@ class GrailsDomainGeneratorSpec extends HibernateGormDatastoreSpec {
                 GenFkTarget, GenFkOwned, GenFkOwner, GenFkCascades, GenFkNodeA, GenFkNodeB, GenFkHasOneOwner, GenFkHasOneDetail, GenOneFace, GenOneNose, GenFkManyOne, GenFkOneSide, GenFkSub, GenFkSubRoot,
                 GenOmParent, GenOmChild, GenOmListed, GenOmOrdered, GenOmOrphan, GenOmFetched, GenOmTag, GenOmStep, GenOmKept, GenOmSortedOwner, GenOmMapOwner, GenMapBidiOwner, GenMapBidiChild, GenEmbAssocOwner,
                 GenMmStudent, GenMmCourse, GenMmPerson, GenParamsOnly, GenDecimal, GenUnversioned, GenUnversionedRoot, GenUnversionedChild, GenConverted, GenValueTyped,
-                GenCollEmbeddedHolder, GenCollEmbeddedSibling, GenCollEmbeddedItem, GenCollEmbeddedEntityHolder)
+                GenCollEmbeddedHolder, GenCollEmbeddedSibling, GenCollEmbeddedItem, GenCollEmbeddedEntityHolder, GenMapM2mLeft, GenMapM2mRight)
     }
 
     List<StandardServiceRegistry> registries = []
@@ -1902,6 +1902,26 @@ class GrailsDomainGeneratorSpec extends HibernateGormDatastoreSpec {
         GenMapBidiOwner | 'kids'   | GenMapBidiChild | 'gen_map_bidi_owner_kids' | 'kids_id' | 'owner_id'              | 'kids_idx'
     }
 
+    void "a map on a many-to-many is the side that writes the join table, with a map key column, and the set that reads it is mappedBy"() {
+        given:
+        Map<GrailsHibernatePersistentEntity, Class<?>> classes = generateGroup(GenMapM2mLeft, GenMapM2mRight)
+        Field rights = classes[entity(GenMapM2mLeft)].getDeclaredField('rights')
+        Field lefts = classes[entity(GenMapM2mRight)].getDeclaredField('lefts')
+
+        expect:
+        rights.genericType.typeName == 'java.util.Map<java.lang.String, ' + GenMapM2mRight.name + '>'
+        rights.getAnnotation(ManyToMany).mappedBy() == ''
+        rights.getAnnotation(JoinTable).name() == 'gen_map_m2m_left_rights'
+        rights.getAnnotation(JoinTable).joinColumns()*.name() == ['gen_map_m2m_left_id']
+        rights.getAnnotation(JoinTable).inverseJoinColumns()*.name() == ['gen_map_m2m_right_id']
+        rights.getAnnotation(MapKeyColumn).name() == 'rights_idx'
+        lefts.genericType.typeName == 'java.util.Set<' + GenMapM2mLeft.name + '>'
+        lefts.getAnnotation(ManyToMany).mappedBy() == 'rights'
+        !lefts.isAnnotationPresent(JoinTable)
+        newGenerator().supports(entity(GenMapM2mLeft).getHibernatePropertyByName('rights'))
+        newGenerator().supports(entity(GenMapM2mRight).getHibernatePropertyByName('lefts'))
+    }
+
     void "an association inside an embedded type is a field of the embeddable, and each owner states its join column with @AssociationOverride"() {
         given:
         Map<GrailsHibernatePersistentEntity, Class<?>> classes = generateGroup(GenEmbAssocOwner, GenFkTarget)
@@ -3347,3 +3367,21 @@ class GenUnversionedChild extends GenUnversionedRoot {
 
     String extra
 }
+
+@Entity
+class GenMapM2mLeft {
+
+    String name
+    Map<String, GenMapM2mRight> rights
+
+    static hasMany = [rights: GenMapM2mRight]
+}
+
+@Entity
+class GenMapM2mRight {
+
+    String name
+    Set<GenMapM2mLeft> lefts
+
+    static hasMany = [lefts: GenMapM2mLeft]
+}
diff --git a/grails-data-hibernate7/docs/src/docs/asciidoc/advancedGORMFeatures/nativeDomainBinding.adoc b/grails-data-hibernate7/docs/src/docs/asciidoc/advancedGORMFeatures/nativeDomainBinding.adoc
index 97c796475c..42b70ef4d9 100644
--- a/grails-data-hibernate7/docs/src/docs/asciidoc/advancedGORMFeatures/nativeDomainBinding.adoc
+++ b/grails-data-hibernate7/docs/src/docs/asciidoc/advancedGORMFeatures/nativeDomainBinding.adoc
@@ -61,6 +61,7 @@ For the domain classes in GORM's own test suites, both bindings produce the same
 * A `hasMany` of basic values whose `type` is `'serializable'` or a `UserType` class is stored in one column of the owner's table, as before, with the `index`, `unique` group and column settings of the property. A `type` on a `hasMany` of domain classes changes nothing in both bindings.
 * An `index` on the property of a `hasMany` is created over the key column of the collection table (and over the element column of a collection of enums), and `indexColumn` may give the index of a list or the key of a map a `type` other than integer or string.
 * A `hasMany` inside a class listed in `static embedded` (basic values, enums or domain classes, also in a nested embedded class) is stored in a collection table named after the embedded class, not after the owner, with a key column named after the embedded class too, as with classic binding.
+* A `Map` on a many-to-many (`Map<String, Book> books` with a `Set` or another `Map` on the other side) writes its own join table, with the key column of the map as part of the primary key, as with classic binding. A `Set` on the other side reads that table and does not write it, and two maps are two separate tables: rows stored through one map are not visible through the other, in both bindings.
 * Identifier generators, inheritance strategies (single table, joined and table-per-concrete-class), discriminators, natural ids, optimistic locking, lazy and eager fetching, cascades, sorting, batch size and `cache` usage are mapped as with classic binding.
 
 [[nativeDomainBinding-refused]]
@@ -96,10 +97,6 @@ Nothing is ignored silently. A mapping that boots is bound the way it is written
 | `Collection property [...]: the type [...] mapped on the collection property names neither a class nor serializable ...` or `... a custom collection type (a UserCollectionType) is mapped on the collection property ...`
 | Remove the `type`, or use `'serializable'` or a `UserType` class to store the collection in one column. Classic binding does not boot with a registered type name (it fails with `Could not load requested class`); a custom collection type is not described yet.
 
-| A `Map` on a many-to-many.
-| `Association property [...]: a map on a many-to-many ...`
-| Use a `Set` or `List` many-to-many, or model the join with a domain class of its own.
-
 | A `type` that is a registered Hibernate type name for a different Java type than the property, for example `type: 'serializable'` on a `String`.
 | `Type [serializable] of property [...] is registered for the Java type [java.io.Serializable], not the property's [java.lang.String] ...`
 | Use a `UserType` class, or a type name registered for the property's own type.
@@ -168,7 +165,7 @@ Some bugs of classic binding are not copied by native binding. Native binding do
 [[nativeDomainBinding-schema]]
 === Differences in the Schema
 
-The schema of 679 groups of related domain classes was created with both bindings on H2 and compared: tables, columns with type, nullability, default, length, precision and scale, primary keys with their column order, foreign keys, unique keys, indexes, check constraints and sequences. Apart from the 10 groups that native binding refuses, these are the differences. Other databases are not covered by this comparison, so verify against your own database as described below.
+The schema of 685 groups of related domain classes was created with both bindings on H2 and compared: tables, columns with type, nullability, default, length, precision and scale, primary keys with their column order, foreign keys, unique keys, indexes, check constraints and sequences. Apart from the 8 groups that native binding refuses, these are the differences. Other databases are not covered by this comparison, so verify against your own database as described below.
 
 [cols="2,3,3", options="header"]
 |===
@@ -196,6 +193,10 @@ The schema of 679 groups of related domain classes was created with both binding
 | Classic binding creates a second table for the inverse side that nothing reads or writes. Native binding uses the table that the owning side names.
 | The second table stays in an existing database. Remove it yourself.
 
+| `not null` on the column of a `Map` on a many-to-many that holds the entity the map refers to, when the other side is a `Set`.
+| That column is also the key column of the `Set` that reads the map, and classic binding sets its nullability twice, so it is nullable or not null depending on the order in which the domain classes are bound. Native binding always creates it `not null`, like the element column of any many-to-many.
+| `update` does not change an existing nullable column. A diff reports the difference.
+
 | `not null` on the element column of a map of values mapped with `nullable: false`.
 | See <<nativeDomainBinding-behavior,Differences in Behavior>>.
 | `update` does not change an existing nullable column. A diff reports the difference.


(grails-core) 07/16: docs: refresh the native binding schema comparison after closing three refused mappings

Posted by bo...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

borinquenkid pushed a commit to branch 9.0.x-h7-improvement-phase3
in repository https://gitbox.apache.org/repos/asf/grails-core.git

commit d39f374d6f4e1a2d42b6d67821edc1d429233328
Author: Walter Duque de Estrada <wb...@mac.com>
AuthorDate: Wed Oct 7 21:36:27 2026 -0500

    docs: refresh the native binding schema comparison after closing three refused mappings
    
    The comparison now covers 663 groups and native binding refuses 12; the two differences of the
    binder that the closed mappings add (no key for a unique group on a collection, no index named
    after a closure) are listed in the schema table.
    
    Co-Authored-By: Claude Sonnet 5.5 <no...@anthropic.com>
---
 .../asciidoc/advancedGORMFeatures/nativeDomainBinding.adoc     | 10 +++++++++-
 1 file changed, 9 insertions(+), 1 deletion(-)

diff --git a/grails-data-hibernate7/docs/src/docs/asciidoc/advancedGORMFeatures/nativeDomainBinding.adoc b/grails-data-hibernate7/docs/src/docs/asciidoc/advancedGORMFeatures/nativeDomainBinding.adoc
index 41a8ca0019..d674f4465c 100644
--- a/grails-data-hibernate7/docs/src/docs/asciidoc/advancedGORMFeatures/nativeDomainBinding.adoc
+++ b/grails-data-hibernate7/docs/src/docs/asciidoc/advancedGORMFeatures/nativeDomainBinding.adoc
@@ -167,7 +167,7 @@ Some bugs of classic binding are not copied by native binding. Native binding do
 [[nativeDomainBinding-schema]]
 === Differences in the Schema
 
-The schema of 620 groups of related domain classes was created with both bindings on H2 and compared: tables, columns with type, nullability, default, length, precision and scale, primary keys with their column order, foreign keys, unique keys, indexes, check constraints and sequences. Apart from the 22 groups that native binding refuses, these are the differences. Other databases are not covered by this comparison, so verify against your own database as described below.
+The schema of 663 groups of related domain classes was created with both bindings on H2 and compared: tables, columns with type, nullability, default, length, precision and scale, primary keys with their column order, foreign keys, unique keys, indexes, check constraints and sequences. Apart from the 12 groups that native binding refuses, these are the differences. Other databases are not covered by this comparison, so verify against your own database as described below.
 
 [cols="2,3,3", options="header"]
 |===
@@ -206,6 +206,14 @@ The schema of 620 groups of related domain classes was created with both binding
 | A unique constraint over the columns of a `unique` group that includes an enum.
 | See <<nativeDomainBinding-behavior,Differences in Behavior>>.
 | `update` adds the constraint.
+
+| No unique key on the table of a collection for a `unique` group mapped on the collection property.
+| See <<nativeDomainBinding-behavior,Differences in Behavior>>.
+| The key never existed in a database created by classic binding: the statement that creates it fails.
+
+| No index named after a closure on the table of a collection whose `index` is a closure.
+| See <<nativeDomainBinding-behavior,Differences in Behavior>>.
+| A database created by classic binding has an index with a name that depends on the start it was created in. Drop it if you want to be rid of it.
 |===
 
 The comparison finds no difference in the unique keys, indexes and identifier columns that natural ids, `unique` groups and `index` options create: they have the same columns in the same order as with classic binding.


(grails-core) 12/16: feat: accept an attribute converter class as the type in native domain binding

Posted by bo...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

borinquenkid pushed a commit to branch 9.0.x-h7-improvement-phase3
in repository https://gitbox.apache.org/repos/asf/grails-core.git

commit 24a0fcb346e650895bfbe85b591c98a698c6ccbc
Author: Walter Duque de Estrada <wb...@mac.com>
AuthorDate: Thu Oct 8 01:03:25 2026 -0500

    feat: accept an attribute converter class as the type in native domain binding
    
    The generator refused type: 'org.hibernate.type.YesNoConverter' because @Type cannot name a class
    that is not a UserType. The domain binder accepts it: it resolves the name to the converter and
    binds the property with it, on a simple property (char(1), a Boolean stored as Y or N and read back)
    and on a hasMany stored in one column (the collection becomes one char(1) column of the owner),
    without checking that the converter accepts the property's class. A String with that converter
    boots and fails only when it is used.
    
    The generator now states it with @Convert and no JDBC type, which the converter resolves itself to
    the same column type. When the property's class is not one the converter converts, the generated
    field has the type the converter converts, so Hibernate boots as the domain binder does. Both
    bindings are compared on the schema and on a Boolean saved, queried and read back.
    
    Co-Authored-By: Claude Sonnet 5.5 <no...@anthropic.com>
---
 .../domainbinding/jpa/GrailsDomainGenerator.groovy |  33 +++-
 .../cfg/domainbinding/jpa/TypeFacets.groovy        |  11 +-
 ...GeneratedDomainClassesConverterClassSpec.groovy | 175 +++++++++++++++++++++
 ...eneratedDomainClassesDdlDifferentialSpec.groovy |   1 -
 .../GrailsDomainGeneratorDifferentialSpec.groovy   |   5 +-
 .../jpa/GrailsDomainGeneratorSpec.groovy           |  27 +++-
 .../advancedGORMFeatures/nativeDomainBinding.adoc  |   7 +-
 7 files changed, 240 insertions(+), 19 deletions(-)

diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGenerator.groovy b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGenerator.groovy
index 4d51fe6691..bdc2e5568b 100644
--- a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGenerator.groovy
+++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGenerator.groovy
@@ -118,6 +118,7 @@ import org.hibernate.type.spi.TypeConfiguration
 import org.hibernate.usertype.UserCollectionType
 import org.hibernate.usertype.UserType
 
+import org.springframework.core.GenericTypeResolver
 import org.springframework.util.ClassUtils
 
 import org.grails.datastore.mapping.model.config.GormProperties
@@ -1100,10 +1101,14 @@ class GrailsDomainGenerator {
         }
         Class<?> named = loadClass(name, property)
         if (named != null) {
-            return UserType.isAssignableFrom(named) ?
-                    new TypeDecision(true, name, new TypeFacets(named, null, parameters)) :
-                    new TypeDecision(false, name, null, 'names a class that is not a UserType (an AttributeConverter, for example), which ' +
-                            '@Type cannot name')
+            if (UserType.isAssignableFrom(named)) {
+                return new TypeDecision(true, name, new TypeFacets(named, null, parameters))
+            }
+            if (AttributeConverter.isAssignableFrom(named) && !isEnum) {
+                return convertedType(name, named, type)
+            }
+            return new TypeDecision(false, name, null, 'names a class that is neither a UserType nor an AttributeConverter, which ' +
+                    '@Type and @Convert cannot name')
         }
         BasicType<?> registered = typeConfiguration.basicTypeRegistry.getRegisteredType(name)
         boolean identity = property instanceof HibernateSimpleIdentityProperty
@@ -1154,6 +1159,18 @@ class GrailsDomainGenerator {
         return registeredJava.interface && type != null && registeredJava.isAssignableFrom(boxed(type))
     }
 
+    /**
+     * A {@code type} naming an {@code AttributeConverter}: the binder resolves the name to the converter and binds the property with it,
+     * whatever the property's class is. {@code @Convert} states it, on a field of the Java type the converter converts when the
+     * property's class is not one the converter accepts (the binder does not check, Hibernate does).
+     */
+    private static TypeDecision convertedType(String name, Class<?> converter, Class<?> type) {
+        Class<?>[] arguments = GenericTypeResolver.resolveTypeArguments(converter, AttributeConverter)
+        Class<?> domain = arguments != null ? arguments[0] : null
+        Class<?> javaType = domain != null && (type == null || !domain.isAssignableFrom(boxed(type))) ? domain : null
+        return new TypeDecision(true, name, new TypeFacets(null, null, [:], javaType, converter))
+    }
+
     private static Class<?> loadClass(String name, HibernatePersistentProperty property) {
         for (ClassLoader loader : [property.hibernateOwner.javaClass.classLoader, Thread.currentThread().contextClassLoader]) {
             try {
@@ -3088,8 +3105,12 @@ class GrailsDomainGenerator {
 
     private static List<AnnotationDescription> typeAnnotations(TypeFacets facets) {
         if (facets.converter() != null) {
-            return [AnnotationDescription.Builder.ofType(Convert).define('converter', TypeDescription.ForLoadedType.of(facets.converter())).build(),
-                    AnnotationDescription.Builder.ofType(JdbcTypeCode).define('value', facets.jdbcTypeCode().intValue()).build()]
+            List<AnnotationDescription> converted = [AnnotationDescription.Builder.ofType(Convert)
+                    .define('converter', TypeDescription.ForLoadedType.of(facets.converter())).build()]
+            if (facets.jdbcTypeCode() != null) {
+                converted << AnnotationDescription.Builder.ofType(JdbcTypeCode).define('value', facets.jdbcTypeCode().intValue()).build()
+            }
+            return converted
         }
         if (facets.jdbcTypeCode() != null) {
             return [AnnotationDescription.Builder.ofType(JdbcTypeCode).define('value', facets.jdbcTypeCode().intValue()).build()]
diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/TypeFacets.groovy b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/TypeFacets.groovy
index 976e77ff24..e84c05b2fb 100644
--- a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/TypeFacets.groovy
+++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/TypeFacets.groovy
@@ -27,10 +27,13 @@ import groovy.transform.CompileStatic
  * value, such as {@code yes_no} ({@code @Convert} together with the {@code @JdbcTypeCode} of the registered type, which can differ
  * from the one the converter alone resolves to: {@code numeric_boolean} is a TINYINT, the converter alone an INTEGER).
  *
- * <p>Exactly one of {@code userType} and {@code jdbcTypeCode} is set, and {@code converter} only next to {@code jdbcTypeCode}. {@code parameters} is only meaningful for a
- * user type and is never {@code null}. {@code javaType} is the Java type the registered type maps when it is not the
- * property's own, which only an identifier can have (its value comes from a generator, so the mapping may name a type for a
- * value the property's class does not hold); the generated field then has that type.</p>
+ * <p>A converter named by its class ({@code type: 'org.hibernate.type.YesNoConverter'}) is {@code converter} alone: no {@code userType} and no
+ * {@code jdbcTypeCode}, which the converter resolves itself. Otherwise exactly one of {@code userType} and {@code jdbcTypeCode} is set, and
+ * {@code converter} only next to {@code jdbcTypeCode}. {@code parameters} is only meaningful for a user type and is never {@code null}.
+ * {@code javaType} is the Java type the generated field has when it is not the property's own: the type a registered type maps for an
+ * identifier (its value comes from a generator, so the mapping may name a type for a value the property's class does not hold), the
+ * interface of a registered type the property's class implements ({@code serializable}), or the type a converter converts when the property's
+ * class is not one it accepts.</p>
  *
  * @since 9.0
  */
diff --git a/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/generated/GeneratedDomainClassesConverterClassSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/generated/GeneratedDomainClassesConverterClassSpec.groovy
new file mode 100644
index 0000000000..b60528dae5
--- /dev/null
+++ b/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/generated/GeneratedDomainClassesConverterClassSpec.groovy
@@ -0,0 +1,175 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package grails.gorm.tests.generated
+
+import java.sql.Connection
+import java.sql.ResultSet
+import java.sql.ResultSetMetaData
+import java.util.concurrent.atomic.AtomicInteger
+
+import grails.gorm.annotation.Entity
+import org.hibernate.mapping.Table
+import spock.lang.AutoCleanup
+import spock.lang.Specification
+import spock.lang.Unroll
+
+import org.grails.orm.hibernate.HibernateDatastore
+
+/**
+ * A {@code type} that names an {@code AttributeConverter} class, such as {@code org.hibernate.type.YesNoConverter}. The domain binder
+ * converts the property with it, whatever the class of the property is, and also when the property is a {@code hasMany} stored in one
+ * column. The generated mode boots with the same schema and converts the same way.
+ */
+class GeneratedDomainClassesConverterClassSpec extends Specification {
+
+    static final AtomicInteger BOOTS = new AtomicInteger()
+
+    @AutoCleanup
+    HibernateDatastore datastore
+
+    private HibernateDatastore boot(List<Class> group, boolean generated) {
+        datastore?.close()
+        datastore = new HibernateDatastore([
+                'dataSource.url'                  : "jdbc:h2:mem:tns${BOOTS.incrementAndGet()};LOCK_TIMEOUT=10000".toString(),
+                'dataSource.dbCreate'             : 'create-drop',
+                'hibernate.generatedDomainClasses': generated,
+        ], group as Class[])
+        return datastore
+    }
+
+    /** The tables of the boot model as plain data: columns with SQL type and nullability, indexes, unique keys. */
+    private Map<String, Map> schema(HibernateDatastore booted) {
+        Map<String, Map> result = new TreeMap<String, Map>()
+        for (Table table : booted.metadata.collectTableMappings()) {
+            if (table.physicalTable) {
+                result[table.name] = [
+                        columns   : table.columns.collectEntries { org.hibernate.mapping.Column column ->
+                            boolean nullable = column.nullable && !(table.primaryKey != null && table.primaryKey.columns*.name.contains(column.name))
+                            [(column.name): "${column.getSqlType(booted.metadata)}${nullable ? '' : ' not null'}${column.unique ? ' unique' : ''}".toString()]
+                        },
+                        primaryKey: table.primaryKey?.columns*.name?.sort(),
+                        indexes   : table.indexes.values().collectEntries { [(it.name): it.columns*.name] },
+                        uniqueKeys: table.uniqueKeys.values().collect { it.columns*.name.sort() }.sort { it.toString() },
+                ]
+            }
+        }
+        return result
+    }
+
+    /** The rows of a table as stored, binary values as lists of bytes. */
+    private List<List> rows(String sql) {
+        return datastore.connectionSources.defaultConnectionSource.dataSource.connection.withCloseable { Connection connection ->
+            ResultSet result = connection.createStatement().executeQuery(sql)
+            ResultSetMetaData meta = result.metaData
+            List<List> found = []
+            while (result.next()) {
+                found << (1..meta.columnCount).collect { int i ->
+                    Object value = result.getObject(i)
+                    value instanceof byte[] ? ((byte[]) value).toList() : value
+                }
+            }
+            found
+        }
+    }
+
+    /** What a property does when it is saved, read back by a new session and stored: the value, or why it could not be read. */
+    private Map roundTrip(HibernateDatastore booted, Class type, Map<String, Object> values, String property, String select) {
+        Long id = type.withTransaction { type.newInstance(values).save(failOnError: true, flush: true).id }
+        Map observed = [stored: rows(select)]
+        try {
+            Object value = type.withNewSession { type.get(id)."${property}" }
+            observed.reloaded = value instanceof byte[] ? ((byte[]) value).toList() : value
+        } catch (Throwable e) {
+            Throwable root = e
+            while (root.cause != null && root.cause != root) {
+                root = root.cause
+            }
+            observed.reloaded = "cannot be read: ${root.class.simpleName}".toString()
+        }
+        return observed
+    }
+
+    @Unroll
+    void "#label has the column type the domain binder gives it"() {
+        when:
+        Map<String, Map> binder = schema(boot(group, false))
+        Map<String, Map> generated = schema(boot(group, true))
+
+        then:
+        generated == binder
+        generated[table].columns[column] == type
+
+        where:
+        label                                                | group                  | table                 | column          | type
+        'a boolean converted by an attribute converter'      | [TnConvertedBoolean]   | 'tn_converted_boolean' | 'flag'         | 'char(1)'
+        'a string typed with an attribute converter'         | [TnConvertedString]    | 'tn_converted_string' | 'data'          | 'char(1)'
+        'a set typed with an attribute converter'            | [TnConvertedSet]       | 'tn_converted_set'    | 'categories'    | 'char(1)'
+    }
+
+    void "a boolean converted by an attribute converter is stored as the converter says and read back"() {
+        when:
+        Map binder = roundTrip(boot([TnConvertedBoolean], false), TnConvertedBoolean, [flag: true], 'flag', 'select flag from tn_converted_boolean')
+        Map generated = roundTrip(boot([TnConvertedBoolean], true), TnConvertedBoolean, [flag: true], 'flag', 'select flag from tn_converted_boolean')
+
+        then:
+        generated == binder
+        generated == [stored: [['Y']], reloaded: true]
+
+        and: "a query on the property converts its parameter"
+        TnConvertedBoolean.withNewSession { TnConvertedBoolean.findAllByFlag(true)*.flag } == [true]
+        TnConvertedBoolean.withNewSession { TnConvertedBoolean.findAllByFlag(false) } == []
+    }
+
+    void "an attribute converter on a property of a class it does not convert is accepted by both bindings and fails the same way when used"() {
+        when:
+        Map<Boolean, String> outcome = [false, true].collectEntries { boolean generated ->
+            boot([TnConvertedString], generated)
+            String result
+            try {
+                TnConvertedString.withTransaction { new TnConvertedString(data: 'Y').save(failOnError: true, flush: true) }
+                result = 'saved'
+            } catch (Throwable e) {
+                result = 'failed'
+            }
+            [(generated): result]
+        }
+
+        then:
+        outcome[true] == outcome[false]
+    }
+}
+
+@Entity
+class TnConvertedBoolean {
+    Boolean flag
+    static mapping = { flag type: 'org.hibernate.type.YesNoConverter' }
+}
+
+@Entity
+class TnConvertedString {
+    String data
+    static mapping = { data type: 'org.hibernate.type.YesNoConverter' }
+}
+
+@Entity
+class TnConvertedSet {
+    Set<String> categories
+    static hasMany = [categories: String]
+    static mapping = { categories type: 'org.hibernate.type.YesNoConverter' }
+}
diff --git a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GeneratedDomainClassesDdlDifferentialSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GeneratedDomainClassesDdlDifferentialSpec.groovy
index 4b49a3f8fe..cd44f3bb23 100644
--- a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GeneratedDomainClassesDdlDifferentialSpec.groovy
+++ b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GeneratedDomainClassesDdlDifferentialSpec.groovy
@@ -83,7 +83,6 @@ class GeneratedDomainClassesDdlDifferentialSpec extends Specification {
     private static final List<String> REFUSALS = [
             'neither side of the many-to-many owns it',
             'is registered for the Java type',
-            'names a class that is not a UserType',
             'declares a natural id but is a subclass',
             'which is HibernateEmbeddedProperty',
             'which is not a persistent property of the entity',
diff --git a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorDifferentialSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorDifferentialSpec.groovy
index 8e680c0873..dcf4159d8e 100644
--- a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorDifferentialSpec.groovy
+++ b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorDifferentialSpec.groovy
@@ -1098,7 +1098,7 @@ class GrailsDomainGeneratorDifferentialSpec extends HibernateGormDatastoreSpec {
     /**
      * The explicit type the generator states must be the one the binder put on the bound value: the same
      * {@code UserType} class and parameters, or the same JDBC type for a registered type name; with no explicit type the
-     * binder's type name is the property's own class and it has no parameters.
+     * binder's type name is the property's own class and it has no parameters. A converter named by its class carries no JDBC type.
      */
     private List<String> compareType(
             String where, HibernatePersistentProperty property, GrailsDomainGenerator generator,
@@ -1130,7 +1130,8 @@ class GrailsDomainGeneratorDifferentialSpec extends HibernateGormDatastoreSpec {
             explicitTypes["registered ${value.typeName} (converter)".toString()]++
             BasicValue.Resolution<?> resolution = value.resolve()
             if (value.typeName != property.getTypeName(type) || resolution.valueConverter?.getClass() != facets.converter() ||
-                    resolution.jdbcType.defaultSqlTypeCode != facets.jdbcTypeCode()) {
+                    (facets.jdbcTypeCode() != null && resolution.jdbcType.defaultSqlTypeCode != facets.jdbcTypeCode())) {
+                // a converter named by its class states no JDBC type: the one the converter alone resolves to is the binder's too
                 found << "${where} converter: generator=${facets.converter()}/${facets.jdbcTypeCode()} binder=${resolution.valueConverter?.getClass()}/${resolution.jdbcType.defaultSqlTypeCode} (type ${value.typeName})".toString()
             }
         } else {
diff --git a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorSpec.groovy
index 4003c38787..251d0801ab 100644
--- a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorSpec.groovy
+++ b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorSpec.groovy
@@ -168,7 +168,7 @@ class GrailsDomainGeneratorSpec extends HibernateGormDatastoreSpec {
                 GenOmParent, GenOmChild, GenOmListed, GenOmOrdered, GenOmOrphan, GenOmFetched, GenOmTag, GenOmStep, GenOmKept, GenOmSortedOwner, GenOmMapOwner, GenMapBidiOwner, GenMapBidiChild, GenEmbAssocOwner,
                 GenMmStudent, GenMmCourse, GenMmPerson, GenParamsOnly, GenDecimal, GenUnversioned, GenUnversionedRoot, GenUnversionedChild, GenConverted, GenValueTyped,
                 GenCollEmbeddedHolder, GenCollEmbeddedSibling, GenCollEmbeddedItem, GenCollEmbeddedEntityHolder, GenMapM2mLeft, GenMapM2mRight,
-                GenSerialized, GenTypeParams)
+                GenSerialized, GenTypeParams, GenConverterClass)
     }
 
     List<StandardServiceRegistry> registries = []
@@ -408,6 +408,19 @@ class GrailsDomainGeneratorSpec extends HibernateGormDatastoreSpec {
         params.getDeclaredField('amount').getAnnotation(JdbcTypeCode).value() == java.sql.Types.INTEGER
     }
 
+    void "a type that names an attribute converter class becomes @Convert, on a field of the type the converter converts"() {
+        when:
+        Class<?> converted = generate(GenConverterClass)
+
+        then:
+        converted.getDeclaredField('flag').getAnnotation(Convert).converter() == YesNoConverter
+        converted.getDeclaredField('flag').type == Boolean
+        !converted.getDeclaredField('flag').isAnnotationPresent(Type)
+        !converted.getDeclaredField('flag').isAnnotationPresent(JdbcTypeCode)
+        converted.getDeclaredField('data').getAnnotation(Convert).converter() == YesNoConverter
+        converted.getDeclaredField('data').type == Boolean
+    }
+
     void "Hibernate's own annotation binder resolves a converted type like the domain binder"() {
         given:
         Map<GrailsHibernatePersistentEntity, Class<?>> classes = generateGroup(GenConverted)
@@ -2846,6 +2859,18 @@ class GenTypeParams {
     }
 }
 
+@Entity
+class GenConverterClass {
+
+    Boolean flag
+    String data
+
+    static mapping = {
+        flag type: 'org.hibernate.type.YesNoConverter'
+        data type: 'org.hibernate.type.YesNoConverter'
+    }
+}
+
 class GenUpperType implements UserType<String>, ParameterizedType {
 
     @Override
diff --git a/grails-data-hibernate7/docs/src/docs/asciidoc/advancedGORMFeatures/nativeDomainBinding.adoc b/grails-data-hibernate7/docs/src/docs/asciidoc/advancedGORMFeatures/nativeDomainBinding.adoc
index 14b6b71b23..7c1478fdae 100644
--- a/grails-data-hibernate7/docs/src/docs/asciidoc/advancedGORMFeatures/nativeDomainBinding.adoc
+++ b/grails-data-hibernate7/docs/src/docs/asciidoc/advancedGORMFeatures/nativeDomainBinding.adoc
@@ -64,6 +64,7 @@ For the domain classes in GORM's own test suites, both bindings produce the same
 * A `Map` on a many-to-many (`Map<String, Book> books` with a `Set` or another `Map` on the other side) writes its own join table, with the key column of the map as part of the primary key, as with classic binding. A `Set` on the other side reads that table and does not write it, and two maps are two separate tables: rows stored through one map are not visible through the other, in both bindings.
 * `type: 'serializable'` on a property that is not a collection (a `String`, a `byte[]` or any `Serializable`) is a binary column holding the Java serialization of the value, as with classic binding.
 * `params` on a registered type name such as `type: 'string'` are ignored, as with classic binding. They reach the type when it is a `UserType` that implements `ParameterizedType`.
+* A `type` that names an `AttributeConverter` class, for example `type: 'org.hibernate.type.YesNoConverter'`, converts the property with it, on a single property as well as on a `hasMany` stored in one column, as with classic binding.
 * Identifier generators, inheritance strategies (single table, joined and table-per-concrete-class), discriminators, natural ids, optimistic locking, lazy and eager fetching, cascades, sorting, batch size and `cache` usage are mapped as with classic binding.
 
 [[nativeDomainBinding-refused]]
@@ -103,10 +104,6 @@ Nothing is ignored silently. A mapping that boots is bound the way it is written
 | `Type [string] of property [...] is registered for the Java type [java.lang.String], not the property's [java.lang.Integer] ...`
 | Use a `UserType` class, or a type name registered for the property's own type.
 
-| An `AttributeConverter` class named as the `type`, for example `type: 'org.hibernate.type.YesNoConverter'`.
-| `Type [...] of property [...] names a class that is not a UserType (an AttributeConverter, for example) ...`
-| Use the registered names `yes_no`, `true_false` or `numeric_boolean`, or write a `UserType`.
-
 | `insertable: false` or `updatable: false` on a property that has no column of its own: a collection, an embedded object, or the inverse side of a one-to-one.
 | `... insertable: false or updatable: false is mapped on a property that has no column of its own ...`
 | Remove the option. Classic binding ignores it on such a property. On a property with a column, both bindings honor it.
@@ -163,7 +160,7 @@ Some bugs of classic binding are not copied by native binding. Native binding do
 [[nativeDomainBinding-schema]]
 === Differences in the Schema
 
-The schema of 694 groups of related domain classes was created with both bindings on H2 and compared: tables, columns with type, nullability, default, length, precision and scale, primary keys with their column order, foreign keys, unique keys, indexes, check constraints and sequences. Apart from the 6 groups that native binding refuses, these are the differences. Other databases are not covered by this comparison, so verify against your own database as described below.
+The schema of 700 groups of related domain classes was created with both bindings on H2 and compared: tables, columns with type, nullability, default, length, precision and scale, primary keys with their column order, foreign keys, unique keys, indexes, check constraints and sequences. Apart from the 4 groups that native binding refuses, these are the differences. Other databases are not covered by this comparison, so verify against your own database as described below.
 
 [cols="2,3,3", options="header"]
 |===


(grails-core) 02/16: docs: add the Grails 8 to 9 upgrade notes for Hibernate 7 domain binding

Posted by bo...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

borinquenkid pushed a commit to branch 9.0.x-h7-improvement-phase3
in repository https://gitbox.apache.org/repos/asf/grails-core.git

commit 4118bb1568f6bf17d876aae32aed11829d42d6fd
Author: Walter Duque de Estrada <wb...@mac.com>
AuthorDate: Wed Oct 7 19:11:36 2026 -0500

    docs: add the Grails 8 to 9 upgrade notes for Hibernate 7 domain binding
    
    A many-to-many without belongsTo now fails at startup, several binder
    defects are fixed, and native binding is opt-in. These change behaviour or
    the generated schema, so the guide needs an upgrading90x page registered in
    the table of contents.
    
    Co-Authored-By: Claude Sonnet 5.5 <no...@anthropic.com>
---
 grails-doc/src/en/guide/toc.yml                    |   1 +
 .../src/en/guide/upgrading/upgrading90x.adoc       | 109 +++++++++++++++++++++
 2 files changed, 110 insertions(+)

diff --git a/grails-doc/src/en/guide/toc.yml b/grails-doc/src/en/guide/toc.yml
index 6691419311..ab2ab61769 100644
--- a/grails-doc/src/en/guide/toc.yml
+++ b/grails-doc/src/en/guide/toc.yml
@@ -37,6 +37,7 @@ gettingStarted:
   developmentReloading: Development Reloading
 upgrading:
   title: Upgrading from the previous versions
+  upgrading90x: Upgrading from Grails 8 to Grails 9
   upgrading80x: Upgrading from Grails 7 to Grails 8
   upgrading72x: Upgrading from Grails 7.1 to Grails 7.2
   upgrading71x: Upgrading from Grails 7.0 to Grails 7.1
diff --git a/grails-doc/src/en/guide/upgrading/upgrading90x.adoc b/grails-doc/src/en/guide/upgrading/upgrading90x.adoc
new file mode 100644
index 0000000000..c71f707736
--- /dev/null
+++ b/grails-doc/src/en/guide/upgrading/upgrading90x.adoc
@@ -0,0 +1,109 @@
+////
+Licensed to the Apache Software Foundation (ASF) under one
+or more contributor license agreements.  See the NOTICE file
+distributed with this work for additional information
+regarding copyright ownership.  The ASF licenses this file
+to you under the Apache License, Version 2.0 (the
+"License"); you may not use this file except in compliance
+with the License.  You may obtain a copy of the License at
+
+https://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing,
+software distributed under the License is distributed on an
+"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+KIND, either express or implied.  See the License for the
+specific language governing permissions and limitations
+under the License.
+////
+
+=== Upgrade Instructions for Grails 8 to Grails 9
+
+This guide outlines the changes introduced in Grails {version} and the steps required to upgrade your application from Grails 8.
+
+==== 1. Hibernate 7 Domain Binding
+
+These notes apply to applications that use GORM for Hibernate 7 (`grails-hibernate7`).
+
+===== 1.1 A Many-to-Many Without `belongsTo` Fails at Startup
+
+A bidirectional many-to-many is written by its owning side, which `belongsTo` designates.
+When neither side declares `belongsTo`, both collections used to be bound as inverse, so no join table row was ever written and the relationship was silently lost.
+The application now refuses to start instead:
+
+[source,text]
+----
+Neither side of the many-to-many between [com.example.Author.books] and [com.example.Book.authors] declares belongsTo, so the relationship would never be stored. Declare belongsTo on the owned side, for example in Book: static belongsTo = Author
+----
+
+The message is a Hibernate `MappingException`.
+It names both sides of each affected relationship, and the sides in alphabetical order.
+
+Declare `belongsTo` on the side that should be owned:
+
+[source,groovy]
+----
+class Book {
+    String title
+    static hasMany = [authors: Author]
+    static belongsTo = Author
+}
+
+class Author {
+    String name
+    static hasMany = [books: Book]
+}
+----
+
+The same applies to a many-to-many from a class to itself: `belongsTo` names the class itself.
+A unidirectional many-to-many, a many-to-many that has `belongsTo` on either side, and one where either side is a `Map` need no change.
+The rule is the same whether or not you enable native domain binding, see below.
+
+See link:{hibernate7Guide}index.html#gormAssociation-manyToMany[Many-to-Many Associations] in the GORM for Hibernate 7 manual.
+
+===== 1.2 Fixes That Change Mappings and Generated Schemas
+
+The following defects of the Hibernate 7 mapping are fixed.
+They are also fixed in the 8.0.x line, so an application that is already on a recent Grails 8 release has seen them.
+
+* *`insertable: false` and `updatable: false`* were ignored on every property.
+  They are honored now: a column mapped `insertable: false` is left out of the `INSERT`, and one mapped `updatable: false` is left out of the `UPDATE`.
+  Review the domain classes that use them, because values that used to be written are now not written.
+* *Enum columns* did not receive the `comment`, `defaultValue`, `read` and `write` of their column mapping.
+  They do now, so the generated column definition of an enum property changes.
+  Compare the generated schema with your database before you use `dbCreate: update`.
+* *Collections of basic values* of an owner with more than one unidirectional `hasMany` were not written to their table.
+  The elements are now stored.
+  No change is needed.
+* *The inverse side of a many-to-many* read a join table of its own default name when only the owning side named the join table.
+  The inverse side now reads the table that the owner writes.
+  An empty table with the default name may remain in an existing database.
+
+===== 1.3 Native Domain Binding
+
+GORM for Hibernate 7 can bind your domain classes with Hibernate's own annotation binder instead of building Hibernate's mapping itself.
+This is native domain binding.
+It is the direction GORM for Hibernate 7 is taking and is intended to become the default.
+In Grails {version} it is opt-in and the default stays classic binding.
+
+Enable it with the `hibernate.generatedDomainClasses` setting:
+
+[source,yaml]
+.grails-app/conf/application.yml
+----
+hibernate:
+    generatedDomainClasses: true
+----
+
+Your domain classes, your queries and the GORM API do not change.
+The mapping can, though:
+
+* A few mapping options cannot be expressed with annotations.
+  Native binding refuses such a mapping with an `UnsupportedOperationException` at startup instead of ignoring the option.
+  Examples are an `index` or a `unique` group on a collection property, a natural id on a subclass, a collection inside an embedded class, and a `Map` on a many-to-many.
+* The generated schema differs from the one of classic binding in a few documented cases, for example a `check (idx >= 0)` constraint on the index column of every list, and the column types of foreign keys to nested composite identifiers.
+* A few defects of classic binding are not copied, such as a `unique` group that includes an enum property being dropped.
+
+Before you switch an existing database, run `dbm-gorm-diff` against a copy of it with the setting on.
+
+The link:{hibernate7Guide}index.html#advancedGORMFeatures-nativeDomainBinding[Native Domain Binding] chapter of the GORM for Hibernate 7 manual lists every refused mapping, every schema difference and what each means for an existing database.


(grails-core) 03/16: docs: add native binding and mapping fixes to the Hibernate 7 migration checklist

Posted by bo...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

borinquenkid pushed a commit to branch 9.0.x-h7-improvement-phase3
in repository https://gitbox.apache.org/repos/asf/grails-core.git

commit 536dfadd30ae332f2f8713d0ce387813d348827d
Author: Walter Duque de Estrada <wb...@mac.com>
AuthorDate: Wed Oct 7 19:11:36 2026 -0500

    docs: add native binding and mapping fixes to the Hibernate 7 migration checklist
    
    Users migrating to Grails 9 on Hibernate 7 find their action items in the
    checklist, so the mapping fixes and the opt-in native binding point to the
    new manual page.
    
    Co-Authored-By: Claude Sonnet 5.5 <no...@anthropic.com>
---
 .../docs/src/docs/asciidoc/gettingStarted/hibernateVersions.adoc  | 8 ++++++++
 1 file changed, 8 insertions(+)

diff --git a/grails-data-hibernate7/docs/src/docs/asciidoc/gettingStarted/hibernateVersions.adoc b/grails-data-hibernate7/docs/src/docs/asciidoc/gettingStarted/hibernateVersions.adoc
index 15bd28e7c7..101f5eac78 100644
--- a/grails-data-hibernate7/docs/src/docs/asciidoc/gettingStarted/hibernateVersions.adoc
+++ b/grails-data-hibernate7/docs/src/docs/asciidoc/gettingStarted/hibernateVersions.adoc
@@ -109,6 +109,14 @@ If you are migrating an application from Hibernate 5 or the Grails Hibernate 5 p
 | Grails 9 refuses to start when a bidirectional many-to-many has no `belongsTo` on either side. Earlier versions bound both sides as inverse, so no join table row was ever written and the relationship was silently lost.
 | Declare `belongsTo` on the side that should be owned, for example `static belongsTo = Author` in `Book`. For a many-to-many from a class to itself, `belongsTo` names the class itself. The startup error names both sides of each affected relationship. See <<gormAssociation-manyToMany,Many-to-Many Associations>>.
 
+| Mapping fixes on classic binding
+| `insertable: false` and `updatable: false` are honored, enum columns receive the `comment`, `defaultValue`, `read` and `write` of their column mapping, the elements of basic-value collections of an owner with several unidirectional collections are stored, and the inverse side of a many-to-many reads the join table that its owner writes.
+| Review domain classes that use `insertable: false` or `updatable: false`, because values that used to be written are no longer written. Compare the generated schema of enum properties with your database before using `dbCreate=update`. See <<nativeDomainBinding,Native Domain Binding>> for the related schema differences.
+
+| Native domain binding (opt-in)
+| `hibernate.generatedDomainClasses: true` makes Hibernate's own annotation binder bind the domain classes. It is off by default and is intended to become the default. A few mapping options are refused at startup and the generated schema differs from classic binding in documented cases.
+| Before enabling it, fix the mappings listed in <<nativeDomainBinding-refused,Mappings Native Binding Refuses>> and run `dbm-gorm-diff` against a copy of the existing database. See <<nativeDomainBinding-schema,Differences in the Schema>>.
+
 | Query locks and query cache
 | Locked queries are not query-cacheable.
 | Do not expect `lock: true` queries to use the query cache. Treat pessimistically locked queries as database reads that require fresh row state.


(grails-core) 13/16: feat: apply a custom collection type on a collection of entities in native domain binding

Posted by bo...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

borinquenkid pushed a commit to branch 9.0.x-h7-improvement-phase3
in repository https://gitbox.apache.org/repos/asf/grails-core.git

commit 17927c0faf3d4753b652d5c88dfbacfaea75476e
Author: Walter Duque de Estrada <wb...@mac.com>
AuthorDate: Thu Oct 8 01:03:30 2026 -0500

    feat: apply a custom collection type on a collection of entities in native domain binding
    
    The generator refused a UserCollectionType as the type of a hasMany of domain classes. Probed in
    the domain binder: it boots, with the same schema (a join table), and saves and reads the entities.
    It names the class to Hibernate in a way Hibernate never uses: the class is never instantiated and
    the collection stays Hibernate's own set. On a hasMany of basic values the same type does not boot
    (the binder gives the class to the element as its type), and stays refused by name with that reason.
    
    The generator now states the class with @CollectionType, so the collection is held by the type the
    mapping names. The schema is unchanged and the new spec compares it in both bindings and shows
    which mode uses the class. Dropping the annotation would reproduce the domain binder's behavior
    exactly, if applying a type the mapping names is not wanted.
    
    Co-Authored-By: Claude Sonnet 5.5 <no...@anthropic.com>
---
 .../domainbinding/jpa/GrailsDomainGenerator.groovy |  20 ++-
 .../cfg/domainbinding/jpa/ToManyFacets.groovy      |   6 +-
 ...tedDomainClassesCustomCollectionTypeSpec.groovy | 188 +++++++++++++++++++++
 .../jpa/GrailsDomainGeneratorSpec.groovy           |  59 ++++++-
 .../advancedGORMFeatures/nativeDomainBinding.adoc  |   7 +-
 5 files changed, 266 insertions(+), 14 deletions(-)

diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGenerator.groovy b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGenerator.groovy
index bdc2e5568b..dec8bee278 100644
--- a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGenerator.groovy
+++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGenerator.groovy
@@ -81,6 +81,7 @@ import org.hibernate.annotations.BatchSize
 import org.hibernate.annotations.Cache
 import org.hibernate.annotations.CacheConcurrencyStrategy
 import org.hibernate.annotations.Cascade
+import org.hibernate.annotations.CollectionType
 import org.hibernate.annotations.ColumnDefault
 import org.hibernate.annotations.ColumnTransformer
 import org.hibernate.annotations.Comment
@@ -1451,14 +1452,14 @@ class GrailsDomainGenerator {
     /**
      * A {@code type} on a collection property that is neither a class nor {@code serializable}: the binder gives the name to the
      * element of the collection as a class name and fails to boot ({@code Could not load requested class: text}, probed for the
-     * registered names {@code string}, {@code text}, {@code materialized_clob} and {@code yes_no}), and a custom collection type is a
-     * class the generator does not describe yet.
+     * registered names {@code string}, {@code text}, {@code materialized_clob} and {@code yes_no}), and so does a custom collection
+     * type, which it gives to the element as its type.
      */
     private static String mappedCollectionTypeProblem(HibernateBasicProperty property) {
         Class<?> userType = property.userType
         if (userType != null && UserCollectionType.isAssignableFrom(userType)) {
-            return 'a custom collection type (a UserCollectionType) is mapped on the collection property, which the generator does not ' +
-                    'support yet'
+            return 'a custom collection type (a UserCollectionType) is mapped on a collection of basic values: the binder gives the class to ' +
+                    'the element as its type and fails to boot (Named type did not implement BasicType nor UserType)'
         }
         return "the type [${property.hibernateMappedForm.typeName}] mapped on the collection property names neither a class nor " +
                 "serializable: the binder gives the name to the element as a class name and fails to boot ('Could not load requested class')"
@@ -1730,10 +1731,6 @@ class GrailsDomainGenerator {
             return 'a class is mapped as the type of the collection property, which the binder binds as one column of the owner\'s table ' +
                     'that holds the collection, and Hibernate fails to boot for a collection of entities'
         }
-        if (mapped.type != null && property.userType != null) {
-            return 'a custom collection type (a UserCollectionType) is mapped on the collection property, which the generator does not ' +
-                    'support yet'
-        }
         if (property instanceof HibernateManyToManyProperty) {
             HibernateAssociation other = property.hibernateInverseSide
             if (!(other instanceof HibernateManyToManyProperty)) {
@@ -1891,7 +1888,8 @@ class GrailsDomainGenerator {
                 keys != null ? keys : [key],
                 keys != null ? collectionKeyReferencedColumns(property) : [],
                 collectionIndexType(property, kind),
-                collectionTableIndexes(property, table, keys != null ? keys : [key], element))
+                collectionTableIndexes(property, table, keys != null ? keys : [key], element),
+                mapped.type != null && property.userType != null && UserCollectionType.isAssignableFrom(property.userType) ? property.userType : null)
     }
 
     /**
@@ -2777,6 +2775,10 @@ class GrailsDomainGenerator {
             // the binder marks the collection sorted and names no comparator: the elements' natural order
             annotations << AnnotationDescription.Builder.ofType(SortNatural).build()
         }
+        if (facets.collectionType() != null) {
+            annotations << AnnotationDescription.Builder.ofType(CollectionType)
+                    .define('type', TypeDescription.ForLoadedType.of(facets.collectionType())).build()
+        }
         if (facets.orderProperty() != null) {
             annotations << AnnotationDescription.Builder.ofType(OrderBy).define('value', "${facets.orderProperty()} ${facets.orderDirection()}".toString()).build()
         }
diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/ToManyFacets.groovy b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/ToManyFacets.groovy
index a4a8b5fc62..29f2be2f79 100644
--- a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/ToManyFacets.groovy
+++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/ToManyFacets.groovy
@@ -56,6 +56,9 @@ import org.hibernate.FetchMode
  * owner's table, which the table of the collection does not have. {@link GeneratedDomainClassBinder} creates the indexes on the bound
  * table, since the inverse side of a many-to-many has no table annotation of its own to state them on.</p>
  *
+ * <p>{@code collectionType} is the custom collection type ({@code UserCollectionType}) the mapping's {@code type} names for the collection, and
+ * {@code null} when it names none ({@code @CollectionType}).</p>
+ *
  * <p>{@code extraLazy} is an explicit {@code lazy: true}: the binder makes the collection extra-lazy, which Hibernate's annotation
  * binder cannot state, so {@code GeneratedDomainClassBinder} sets it on the collection after binding.</p>
  *
@@ -85,5 +88,6 @@ record ToManyFacets(
     List<ColumnFacets> keys,
     List<String> referencedKeys,
     TypeFacets indexType,
-    List<IndexFacets> indexes) {
+    List<IndexFacets> indexes,
+    Class<?> collectionType = null) {
 }
diff --git a/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/generated/GeneratedDomainClassesCustomCollectionTypeSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/generated/GeneratedDomainClassesCustomCollectionTypeSpec.groovy
new file mode 100644
index 0000000000..b30afd70e3
--- /dev/null
+++ b/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/generated/GeneratedDomainClassesCustomCollectionTypeSpec.groovy
@@ -0,0 +1,188 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package grails.gorm.tests.generated
+
+import java.util.concurrent.atomic.AtomicInteger
+
+import grails.gorm.annotation.Entity
+import org.hibernate.collection.spi.PersistentCollection
+import org.hibernate.collection.spi.PersistentSet
+import org.hibernate.engine.spi.SharedSessionContractImplementor
+import org.hibernate.mapping.Table
+import org.hibernate.metamodel.CollectionClassification
+import org.hibernate.persister.collection.CollectionPersister
+import org.hibernate.usertype.UserCollectionType
+import spock.lang.AutoCleanup
+import spock.lang.Specification
+
+import org.grails.orm.hibernate.HibernateDatastore
+
+/**
+ * A custom collection type ({@code UserCollectionType}) as the {@code type} of a {@code hasMany}. On a collection of domain classes both
+ * bindings boot with the same schema; the domain binder never uses the class, the generated mode applies it. On a collection of basic
+ * values the domain binder does not boot.
+ */
+class GeneratedDomainClassesCustomCollectionTypeSpec extends Specification {
+
+    static final AtomicInteger BOOTS = new AtomicInteger()
+
+    @AutoCleanup
+    HibernateDatastore datastore
+
+    private HibernateDatastore boot(List<Class> group, boolean generated) {
+        datastore?.close()
+        datastore = new HibernateDatastore([
+                'dataSource.url'                  : "jdbc:h2:mem:tns${BOOTS.incrementAndGet()};LOCK_TIMEOUT=10000".toString(),
+                'dataSource.dbCreate'             : 'create-drop',
+                'hibernate.generatedDomainClasses': generated,
+        ], group as Class[])
+        return datastore
+    }
+
+    /** The tables of the boot model as plain data: columns with SQL type and nullability, indexes, unique keys. */
+    private Map<String, Map> schema(HibernateDatastore booted) {
+        Map<String, Map> result = new TreeMap<String, Map>()
+        for (Table table : booted.metadata.collectTableMappings()) {
+            if (table.physicalTable) {
+                result[table.name] = [
+                        columns   : table.columns.collectEntries { org.hibernate.mapping.Column column ->
+                            boolean nullable = column.nullable && !(table.primaryKey != null && table.primaryKey.columns*.name.contains(column.name))
+                            [(column.name): "${column.getSqlType(booted.metadata)}${nullable ? '' : ' not null'}${column.unique ? ' unique' : ''}".toString()]
+                        },
+                        primaryKey: table.primaryKey?.columns*.name?.sort(),
+                        indexes   : table.indexes.values().collectEntries { [(it.name): it.columns*.name] },
+                        uniqueKeys: table.uniqueKeys.values().collect { it.columns*.name.sort() }.sort { it.toString() },
+                ]
+            }
+        }
+        return result
+    }
+
+    void "a custom collection type on a collection of entities is a table in both bindings, and only the generated mode puts the custom type to use"() {
+        when:
+        Map<Boolean, Map> observed = [false, true].collectEntries { boolean generated ->
+            TnSetCollectionType.CREATED.set(0)
+            TnSetCollectionType.WRAPS.set(0)
+            HibernateDatastore booted = boot([TnCollectionOwner, TnCollectionKid], generated)
+            Long id = TnCollectionOwner.withTransaction {
+                TnCollectionOwner owner = new TnCollectionOwner(name: 'o')
+                owner.addToKids(new TnCollectionKid(name: 'a'))
+                owner.addToKids(new TnCollectionKid(name: 'b'))
+                owner.save(failOnError: true, flush: true).id
+            }
+            Map result = TnCollectionOwner.withNewSession {
+                [kids: TnCollectionOwner.get(id).kids*.name.sort()]
+            }
+            result.schema = schema(booted)
+            result.customTypeUsed = TnSetCollectionType.CREATED.get() > 0 && TnSetCollectionType.WRAPS.get() > 0
+            [(generated): result]
+        }
+
+        then: "the schema and the data are the same"
+        observed[true].schema == observed[false].schema
+        observed[true].kids == ['a', 'b']
+        observed[false].kids == ['a', 'b']
+        observed[true].schema.keySet().containsAll(['tn_collection_owner', 'tn_collection_kid', 'tn_collection_owner_tn_collection_kid'])
+
+        and: "the domain binder names the custom type to Hibernate in a way Hibernate never uses, so it silently keeps its own set; the generated mode applies the type the mapping names"
+        !observed[false].customTypeUsed
+        observed[true].customTypeUsed
+    }
+
+    void "a custom collection type on a collection of basic values is not accepted by the domain binder, and the generated mode says why"() {
+        when:
+        boot([TnCollectionBasic], false)
+
+        then:
+        thrown(Exception)
+
+        when:
+        boot([TnCollectionBasic], true)
+
+        then:
+        Exception e = thrown()
+        Throwable root = e
+        while (root.cause != null && root.cause != root) {
+            root = root.cause
+        }
+        root instanceof UnsupportedOperationException
+        root.message.contains('a custom collection type')
+    }
+}
+
+class TnSetCollectionType implements UserCollectionType {
+
+    static final AtomicInteger WRAPS = new AtomicInteger()
+    static final AtomicInteger CREATED = new AtomicInteger()
+    static final AtomicInteger INSTANTIATED = new AtomicInteger()
+
+    TnSetCollectionType() {
+        CREATED.incrementAndGet()
+    }
+
+    CollectionClassification getClassification() { CollectionClassification.SET }
+
+    Class<?> getCollectionClass() { Set }
+
+    PersistentCollection<?> instantiate(SharedSessionContractImplementor session, CollectionPersister persister) {
+        INSTANTIATED.incrementAndGet()
+        new PersistentSet(session)
+    }
+
+    PersistentCollection<?> wrap(SharedSessionContractImplementor session, Object collection) {
+        WRAPS.incrementAndGet()
+        new PersistentSet(session, (Set) collection)
+    }
+
+    Iterator<?> getElementsIterator(Object collection) { ((Set) collection).iterator() }
+
+    boolean contains(Object collection, Object entity) { ((Set) collection).contains(entity) }
+
+    Object indexOf(Object collection, Object entity) { null }
+
+    Object replaceElements(Object original, Object target, CollectionPersister persister, Object owner, Map copyCache,
+                           SharedSessionContractImplementor session) {
+        Set targetSet = (Set) target
+        targetSet.clear()
+        targetSet.addAll((Set) original)
+        targetSet
+    }
+
+    Object instantiate(int anticipatedSize) { new HashSet() }
+}
+
+@Entity
+class TnCollectionKid {
+    String name
+}
+
+@Entity
+class TnCollectionOwner {
+    String name
+    Set<TnCollectionKid> kids
+    static hasMany = [kids: TnCollectionKid]
+    static mapping = { kids type: TnSetCollectionType }
+}
+
+@Entity
+class TnCollectionBasic {
+    Set<String> tags
+    static hasMany = [tags: String]
+    static mapping = { tags type: TnSetCollectionType }
+}
diff --git a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorSpec.groovy
index 251d0801ab..f34e01b2e9 100644
--- a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorSpec.groovy
+++ b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorSpec.groovy
@@ -67,6 +67,7 @@ import org.hibernate.annotations.BatchSize
 import org.hibernate.annotations.Cache
 import org.hibernate.annotations.CacheConcurrencyStrategy
 import org.hibernate.annotations.Cascade
+import org.hibernate.annotations.CollectionType
 import org.hibernate.annotations.ColumnDefault
 import org.hibernate.annotations.Comment
 import org.hibernate.annotations.DiscriminatorFormula
@@ -114,6 +115,7 @@ import org.hibernate.mapping.Property
 import org.hibernate.type.CustomType
 import org.hibernate.type.descriptor.WrapperOptions
 import org.hibernate.usertype.ParameterizedType
+import org.hibernate.usertype.UserCollectionType
 import org.hibernate.usertype.UserType
 import java.sql.PreparedStatement
 import java.sql.ResultSet
@@ -168,7 +170,7 @@ class GrailsDomainGeneratorSpec extends HibernateGormDatastoreSpec {
                 GenOmParent, GenOmChild, GenOmListed, GenOmOrdered, GenOmOrphan, GenOmFetched, GenOmTag, GenOmStep, GenOmKept, GenOmSortedOwner, GenOmMapOwner, GenMapBidiOwner, GenMapBidiChild, GenEmbAssocOwner,
                 GenMmStudent, GenMmCourse, GenMmPerson, GenParamsOnly, GenDecimal, GenUnversioned, GenUnversionedRoot, GenUnversionedChild, GenConverted, GenValueTyped,
                 GenCollEmbeddedHolder, GenCollEmbeddedSibling, GenCollEmbeddedItem, GenCollEmbeddedEntityHolder, GenMapM2mLeft, GenMapM2mRight,
-                GenSerialized, GenTypeParams, GenConverterClass)
+                GenSerialized, GenTypeParams, GenConverterClass, GenCustomCollectionOwner, GenCustomCollectionKid)
     }
 
     List<StandardServiceRegistry> registries = []
@@ -421,6 +423,14 @@ class GrailsDomainGeneratorSpec extends HibernateGormDatastoreSpec {
         converted.getDeclaredField('data').type == Boolean
     }
 
+    void "a custom collection type on a collection of entities becomes @CollectionType"() {
+        given:
+        Map<GrailsHibernatePersistentEntity, Class<?>> classes = generateGroup(GenCustomCollectionOwner, GenCustomCollectionKid)
+
+        expect:
+        classes[entity(GenCustomCollectionOwner)].getDeclaredField('kids').getAnnotation(CollectionType).type() == GenSetCollectionType
+    }
+
     void "Hibernate's own annotation binder resolves a converted type like the domain binder"() {
         given:
         Map<GrailsHibernatePersistentEntity, Class<?>> classes = generateGroup(GenConverted)
@@ -2871,6 +2881,53 @@ class GenConverterClass {
     }
 }
 
+class GenSetCollectionType implements UserCollectionType {
+
+    org.hibernate.metamodel.CollectionClassification getClassification() { org.hibernate.metamodel.CollectionClassification.SET }
+
+    Class<?> getCollectionClass() { Set }
+
+    org.hibernate.collection.spi.PersistentCollection<?> instantiate(
+            org.hibernate.engine.spi.SharedSessionContractImplementor session, org.hibernate.persister.collection.CollectionPersister persister) {
+        new org.hibernate.collection.spi.PersistentSet(session)
+    }
+
+    org.hibernate.collection.spi.PersistentCollection<?> wrap(org.hibernate.engine.spi.SharedSessionContractImplementor session, Object collection) {
+        new org.hibernate.collection.spi.PersistentSet(session, (Set) collection)
+    }
+
+    Iterator<?> getElementsIterator(Object collection) { ((Set) collection).iterator() }
+
+    boolean contains(Object collection, Object entity) { ((Set) collection).contains(entity) }
+
+    Object indexOf(Object collection, Object entity) { null }
+
+    Object replaceElements(Object original, Object target, org.hibernate.persister.collection.CollectionPersister persister, Object owner,
+                           Map copyCache, org.hibernate.engine.spi.SharedSessionContractImplementor session) {
+        target
+    }
+
+    Object instantiate(int anticipatedSize) { new HashSet() }
+}
+
+@Entity
+class GenCustomCollectionKid {
+
+    String name
+}
+
+@Entity
+class GenCustomCollectionOwner {
+
+    Set<GenCustomCollectionKid> kids
+
+    static hasMany = [kids: GenCustomCollectionKid]
+
+    static mapping = {
+        kids type: GenSetCollectionType
+    }
+}
+
 class GenUpperType implements UserType<String>, ParameterizedType {
 
     @Override
diff --git a/grails-data-hibernate7/docs/src/docs/asciidoc/advancedGORMFeatures/nativeDomainBinding.adoc b/grails-data-hibernate7/docs/src/docs/asciidoc/advancedGORMFeatures/nativeDomainBinding.adoc
index 7c1478fdae..bfcc235495 100644
--- a/grails-data-hibernate7/docs/src/docs/asciidoc/advancedGORMFeatures/nativeDomainBinding.adoc
+++ b/grails-data-hibernate7/docs/src/docs/asciidoc/advancedGORMFeatures/nativeDomainBinding.adoc
@@ -65,6 +65,7 @@ For the domain classes in GORM's own test suites, both bindings produce the same
 * `type: 'serializable'` on a property that is not a collection (a `String`, a `byte[]` or any `Serializable`) is a binary column holding the Java serialization of the value, as with classic binding.
 * `params` on a registered type name such as `type: 'string'` are ignored, as with classic binding. They reach the type when it is a `UserType` that implements `ParameterizedType`.
 * A `type` that names an `AttributeConverter` class, for example `type: 'org.hibernate.type.YesNoConverter'`, converts the property with it, on a single property as well as on a `hasMany` stored in one column, as with classic binding.
+* A custom collection type (a `UserCollectionType` class) as the `type` of a `hasMany` of domain classes is applied to the collection. Classic binding boots with the same schema but never uses the class: it keeps Hibernate's own collection.
 * Identifier generators, inheritance strategies (single table, joined and table-per-concrete-class), discriminators, natural ids, optimistic locking, lazy and eager fetching, cascades, sorting, batch size and `cache` usage are mapped as with classic binding.
 
 [[nativeDomainBinding-refused]]
@@ -97,8 +98,8 @@ Nothing is ignored silently. A mapping that boots is bound the way it is written
 | Correct the name. Classic binding skips an unknown name without a message, so the entity has no natural id.
 
 | A `type` on a `hasMany` of basic values that is a registered Hibernate type name, for example `type: 'text'`, or a custom collection type.
-| `Collection property [...]: the type [...] mapped on the collection property names neither a class nor serializable ...` or `... a custom collection type (a UserCollectionType) is mapped on the collection property ...`
-| Remove the `type`, or use `'serializable'` or a `UserType` class to store the collection in one column. Classic binding does not boot with a registered type name (it fails with `Could not load requested class`); a custom collection type is not described yet.
+| `Collection property [...]: the type [...] mapped on the collection property names neither a class nor serializable ...` or `... a custom collection type (a UserCollectionType) is mapped on a collection of basic values ...`
+| Remove the `type`, or use `'serializable'` or a `UserType` class to store the collection in one column. Classic binding does not boot with a registered type name (it fails with `Could not load requested class`), and neither with a custom collection type on a collection of basic values.
 
 | A `type` that is a registered Hibernate type name for a different Java type than the property, where the type is not an interface the property implements, for example `type: 'string'` on an `Integer`.
 | `Type [string] of property [...] is registered for the Java type [java.lang.String], not the property's [java.lang.Integer] ...`
@@ -160,7 +161,7 @@ Some bugs of classic binding are not copied by native binding. Native binding do
 [[nativeDomainBinding-schema]]
 === Differences in the Schema
 
-The schema of 700 groups of related domain classes was created with both bindings on H2 and compared: tables, columns with type, nullability, default, length, precision and scale, primary keys with their column order, foreign keys, unique keys, indexes, check constraints and sequences. Apart from the 4 groups that native binding refuses, these are the differences. Other databases are not covered by this comparison, so verify against your own database as described below.
+The schema of 702 groups of related domain classes was created with both bindings on H2 and compared: tables, columns with type, nullability, default, length, precision and scale, primary keys with their column order, foreign keys, unique keys, indexes, check constraints and sequences. Apart from the 4 groups that native binding refuses, these are the differences. Other databases are not covered by this comparison, so verify against your own database as described below.
 
 [cols="2,3,3", options="header"]
 |===


(grails-core) 01/16: docs: document native domain binding in the Hibernate 7 manual

Posted by bo...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

borinquenkid pushed a commit to branch 9.0.x-h7-improvement-phase3
in repository https://gitbox.apache.org/repos/asf/grails-core.git

commit 056538c8eb4658b379a05fd8d65f54371df02494
Author: Walter Duque de Estrada <wb...@mac.com>
AuthorDate: Wed Oct 7 19:11:35 2026 -0500

    docs: document native domain binding in the Hibernate 7 manual
    
    Native binding (hibernate.generatedDomainClasses) refuses some mappings and
    creates a few schema differences from classic binding. Users need the
    complete list of refused mappings, remaining schema differences and the
    advice to run dbm-gorm-diff before switching an existing database. The
    settings javadoc no longer calls the feature experimental, since it is
    intended to become the default.
    
    Co-Authored-By: Claude Sonnet 5.5 <no...@anthropic.com>
---
 .../HibernateConnectionSourceSettings.groovy       |   6 +-
 .../advancedGORMFeatures/nativeDomainBinding.adoc  | 234 +++++++++++++++++++++
 .../configuration/configurationReference.adoc      |   1 +
 .../docs/src/docs/asciidoc/index.adoc              |   5 +
 4 files changed, 243 insertions(+), 3 deletions(-)

diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/connections/HibernateConnectionSourceSettings.groovy b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/connections/HibernateConnectionSourceSettings.groovy
index fc9ca1e881..bc88ce91ae 100644
--- a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/connections/HibernateConnectionSourceSettings.groovy
+++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/connections/HibernateConnectionSourceSettings.groovy
@@ -89,12 +89,12 @@ class HibernateConnectionSourceSettings extends ConnectionSourceSettings {
          * Whether to use Hibernate's dirty checking instead of Grails'
          */
         boolean hibernateDirtyChecking = false
+
+        // the JVM-wide system property switches the default on to run an existing test suite through the generated path; a configured value wins
         /**
          * Whether Hibernate's annotation binder binds classes generated from the GORM mapping, instead of the domain
          * binder building Hibernate's boot model by hand. The application's real domain classes are still what is
-         * persisted and loaded. Experimental; off by default. The default can be switched on for a whole JVM, for example to
-         * run an existing test suite through the generated path, with the system property
-         * {@code grails.hibernate.generatedDomainClasses=true}; a value set in the configuration always wins.
+         * persisted and loaded. Off by default, and intended to become the default.
          */
         boolean generatedDomainClasses = Boolean.getBoolean('grails.hibernate.generatedDomainClasses')
 
diff --git a/grails-data-hibernate7/docs/src/docs/asciidoc/advancedGORMFeatures/nativeDomainBinding.adoc b/grails-data-hibernate7/docs/src/docs/asciidoc/advancedGORMFeatures/nativeDomainBinding.adoc
new file mode 100644
index 0000000000..887b6ec058
--- /dev/null
+++ b/grails-data-hibernate7/docs/src/docs/asciidoc/advancedGORMFeatures/nativeDomainBinding.adoc
@@ -0,0 +1,234 @@
+////
+Licensed to the Apache Software Foundation (ASF) under one
+or more contributor license agreements.  See the NOTICE file
+distributed with this work for additional information
+regarding copyright ownership.  The ASF licenses this file
+to you under the Apache License, Version 2.0 (the
+"License"); you may not use this file except in compliance
+with the License.  You may obtain a copy of the License at
+
+https://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing,
+software distributed under the License is distributed on an
+"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+KIND, either express or implied.  See the License for the
+specific language governing permissions and limitations
+under the License.
+////
+
+[[advancedGORMFeatures-nativeDomainBinding]]
+== Native Domain Binding
+
+GORM for Hibernate 7 can bind your domain classes to Hibernate in two ways.
+
+Classic binding::
+GORM reads your `static mapping` blocks and constraints and builds Hibernate's mapping model itself. This is the default.
+
+Native binding::
+GORM describes each domain class with the JPA and Hibernate annotations that express the same mapping, and Hibernate's own annotation binder binds them. Your domain classes are not changed. Sessions, queries, proxies, dirty checking and the instances you work with are still your real domain classes.
+
+Native binding is where GORM for Hibernate 7 is heading: the mapping is bound by the same code that binds hand-annotated JPA entities, so it follows Hibernate releases without GORM keeping its own copy of Hibernate's mapping rules. It is opt-in today and is intended to become the default. Classic binding will then be retired.
+
+This page lists everything that differs from classic binding, so you can decide when to switch.
+
+=== Enabling Native Binding
+
+Set `hibernate.generatedDomainClasses` to `true`. It is a boolean and defaults to `false`.
+
+[source,yaml]
+.grails-app/conf/application.yml
+----
+hibernate:
+    generatedDomainClasses: true
+----
+
+The same key works wherever the configuration is read: `application.yml` in Spring Boot, or the `Map` or `PropertyResolver` passed to `HibernateDatastore` when GORM is used standalone. The setting applies to every data source of the application.
+
+NOTE: Native binding changes how the mapping is built, not the API. `save()`, `get()`, finders, criteria, HQL, `where` queries, events, validation and transactions are used exactly as before.
+
+=== What Stays the Same
+
+For the domain classes in GORM's own test suites, both bindings produce the same schema apart from the differences listed below. The following are the same on purpose:
+
+* Entity names are the names of your domain classes, so statistics, entity graphs, cache regions and log messages use the names they used with classic binding.
+* Second-level cache regions are named as with classic binding.
+* Hibernate Envers audits your entities, with the two differences described in <<nativeDomainBinding-envers,Envers>>.
+* The Database Migration plugin snapshots and `dbm-gorm-diff` see the same model, apart from the differences below.
+* All multi-tenancy modes (`DISCRIMINATOR`, `SCHEMA`, `DATABASE`) and multiple data sources work. An association still cannot refer to an entity of another data source.
+* Composite identifiers use the same primary key column order.
+* Properties mapped with a `UserType` class, and with the registered converter types `yes_no`, `true_false` and `numeric_boolean`, are stored as before.
+* Identifier generators, inheritance strategies (single table, joined and table-per-concrete-class), discriminators, natural ids, optimistic locking, lazy and eager fetching, cascades, sorting, batch size and `cache` usage are mapped as with classic binding.
+
+[[nativeDomainBinding-refused]]
+=== Mappings Native Binding Refuses
+
+Where an annotation cannot express a mapping option faithfully, native binding refuses the mapping instead of dropping the option. The application fails at startup. The exception has an `UnsupportedOperationException` as its root cause, and the message names the domain class and the property.
+
+Nothing is ignored silently. A mapping that boots is bound the way it is written.
+
+[cols="2,3,3", options="header"]
+|===
+| Mapping
+| Message of the root cause
+| What to do
+
+| A collection inside an embedded class: a `hasMany` of basic values in a class listed in `static embedded`.
+| `... is a collection inside an embedded type ...`
+| Move the collection to the owning domain class, or make the embedded class a domain class and map an association. Classic binding boots this only in some cases, and fails when two classes embed the type.
+
+| A natural id declared on a subclass.
+| `Entity [...] declares a natural id but is a subclass ...`
+| Declare the natural id on the root class of the hierarchy. For a subclass, use a `unique` constraint on its property. Classic binding adds a unique key to the table of the hierarchy.
+
+| A natural id that names an embedded property, a collection or a formula.
+| `The natural id of [...] names [...], which is ... only a simple property, an enum or a foreign key can be part of a natural id ...`
+| Name only simple properties, enums and many-to-one associations in `natural`.
+
+| A natural id that names a property the class does not have.
+| `The natural id of [...] names [...], which is not a persistent property of the entity ...`
+| Correct the name. Classic binding skips an unknown name without a message, so the entity has no natural id.
+
+| An `index` or a `unique` group on the property of a collection (`hasMany`).
+| `Collection property [...]: an index or a unique group is mapped on the collection property ...`
+| Remove the option from the collection property and create the index in a database migration if you need it. Classic binding creates the index over the key column of the collection table, and a `unique` group that names columns of the owner's table makes the schema fail to create.
+
+| A `type` on the property of a collection (`hasMany`).
+| `Collection property [...]: a type is mapped on the collection property itself ...`
+| Remove the `type`. Classic binding applies it to the collection and to its elements alike.
+
+| A `Map` on a many-to-many.
+| `Association property [...]: a map on a many-to-many ...`
+| Use a `Set` or `List` many-to-many, or model the join with a domain class of its own.
+
+| A map whose `indexColumn` has a `type` other than string, or a list whose `indexColumn` has a `type` other than integer.
+| `... the index column of the map is mapped with the type [long] ...` or `... the index column type [long] is not integer`
+| Remove the `type` from `indexColumn`. Use `String` map keys and the default list index.
+
+| A `type` that is a registered Hibernate type name for a different Java type than the property, for example `type: 'serializable'` on a `String`.
+| `Type [serializable] of property [...] is registered for the Java type [java.io.Serializable], not the property's [java.lang.String] ...`
+| Use a `UserType` class, or a type name registered for the property's own type.
+
+| A registered type name together with `params`.
+| `Type [string] of property [...] is a registered type with type parameters, which no annotation states`
+| Use a `UserType` class that implements `ParameterizedType`, or drop the parameters.
+
+| An `AttributeConverter` class named as the `type`, for example `type: 'org.hibernate.type.YesNoConverter'`.
+| `Type [...] of property [...] names a class that is not a UserType (an AttributeConverter, for example) ...`
+| Use the registered names `yes_no`, `true_false` or `numeric_boolean`, or write a `UserType`.
+
+| `insertable: false` or `updatable: false` on a property that has no column of its own: a collection, an embedded object, or the inverse side of a one-to-one.
+| `... insertable: false or updatable: false is mapped on a property that has no column of its own ...`
+| Remove the option. Classic binding ignores it on such a property. On a property with a column, both bindings honor it.
+
+| A `discriminator` column with `precision` or `scale`.
+| `The discriminator column of [...] sets a precision or a scale, which @DiscriminatorColumn cannot state`
+| Remove them. Use `length` to size the column.
+
+| A composite identifier that is combined with something the mapping does not state faithfully: a many-to-many, a map or a join table that involves an entity with a composite identifier, a composite identifier part that is the inverse side of a one-to-one, a foreign key mapped with a different number of columns than the identifier has properties, or a composite identifier referring to a deeply nested composite identifier.
+| `... composite identifier ...`, naming the entity and the part.
+| Give the entity a single-column identifier, or map the relationship with a domain class of its own.
+
+| A foreign key, join column or collection key whose column mapping sets `length`, `precision`, `scale`, `defaultValue`, `read`, `write` or `comment`.
+| `... the column config ... sets a length, a precision, a scale, a default, a read or write expression or a comment, which a join column cannot state`
+| Remove the option. A join column takes its type from the column it references.
+
+| An association mapped with a `formula`, or with several columns where the target does not have a composite identifier.
+| `... the association is mapped with a formula` or `... the mapping states several columns ...`
+| Map the association with a single `column`.
+
+| A multi-tenant id (`tenantId`) that has a mapped `type`.
+| `The tenant id [...] of [...] has a mapped type ...`
+| Remove the `type` from the tenant id.
+|===
+
+A few mappings fail to boot with classic binding too, so they are not a difference between the two: a `SortedSet` of basic values, a `Map` of enums, a hierarchy that mixes inheritance strategies, a `discriminator` type other than string, integer or character, and a joined subclass of a root with a composite identifier.
+
+[[nativeDomainBinding-behavior]]
+=== Differences in Behavior
+
+Some bugs of classic binding are not copied by native binding. Native binding does what the mapping says.
+
+[cols="2,3", options="header"]
+|===
+| Mapping
+| Difference
+
+| A `unique` group that includes an enum property.
+| Classic binding drops the group. Native binding creates the unique constraint. With `dbCreate: update` the constraint is added to an existing table, and fails if the table holds duplicates.
+
+| An owner with two or more unidirectional `hasMany` collections that use a join table.
+| Classic binding can fail to write the join table rows of such a collection. Native binding writes them.
+
+| The `nullable: false` constraint on the element column of a map of values.
+| Classic binding leaves the column nullable. Native binding makes it `not null`.
+|===
+
+[[nativeDomainBinding-schema]]
+=== Differences in the Schema
+
+The schema of 620 groups of related domain classes was created with both bindings on H2 and compared: tables, columns with type, nullability, default, length, precision and scale, primary keys with their column order, foreign keys, unique keys, indexes, check constraints and sequences. Apart from the 22 groups that native binding refuses, these are the differences. Other databases are not covered by this comparison, so verify against your own database as described below.
+
+[cols="2,3,3", options="header"]
+|===
+| Difference
+| Why
+| Impact on an existing database
+
+| `check (<index column> >= 0)` on the index column of every `List` mapping.
+| Hibernate adds it to every list index column and offers no way to leave it out. GORM never writes a negative index.
+| Tables created by classic binding have no such constraint. It cannot reject a value that GORM writes.
+
+| Column types, column order and primary key order of a table that has a foreign key to an entity whose composite identifier contains another composite identifier.
+| Classic binding gives such foreign key columns the type of a different part of the key than their name says, for example a column `parent_grand_parent_name` of type `integer`. Native binding names and types them correctly. The fix for classic binding is in the 8.0.x line; once your version includes it, this difference disappears for the column types.
+| The column types differ from those of a database created by classic binding. Migrate or recreate such tables before you switch. The order of the foreign key columns may remain different.
+
+| The order of the columns of the foreign key from a collection to an entity with a composite identifier.
+| Classic binding creates two foreign keys of the same name in two column orders and keeps one of them depending on the order the classes are bound in.
+| Same constraint name and same columns, in another order.
+
+| No foreign key for an association mapped with `ignoreNotFound: true`.
+| Hibernate does not create the foreign key for `@NotFound(IGNORE)`. A dangling reference is the point of the option.
+| A database created by classic binding has the foreign key. A diff reports a foreign key that the model no longer has.
+
+| One join table instead of two when the two sides of a many-to-many name the join table differently.
+| Classic binding creates a second table for the inverse side that nothing reads or writes. Native binding uses the table that the owning side names.
+| The second table stays in an existing database. Remove it yourself.
+
+| `not null` on the element column of a map of values mapped with `nullable: false`.
+| See <<nativeDomainBinding-behavior,Differences in Behavior>>.
+| `update` does not change an existing nullable column. A diff reports the difference.
+
+| No unused extra nullable column on the table of a map of values.
+| Classic binding creates a column such as `attributes_java_lang_string` that nothing uses.
+| The column stays in an existing database. It does no harm.
+
+| A unique constraint over the columns of a `unique` group that includes an enum.
+| See <<nativeDomainBinding-behavior,Differences in Behavior>>.
+| `update` adds the constraint.
+|===
+
+The comparison finds no difference in the unique keys, indexes and identifier columns that natural ids, `unique` groups and `index` options create: they have the same columns in the same order as with classic binding.
+
+=== Switching an Existing Database
+
+Do not switch a production database over blindly. Whether `dbCreate: update` or a Liquibase changelog would change anything depends on the differences above.
+
+. Start the application with `hibernate.generatedDomainClasses: true` against a copy of your schema. Fix every refused mapping from the table above.
+. Run <<ref-diff-scripts-dbm-gorm-diff,dbm-gorm-diff>> against the copy. An empty diff means the switch does not change the schema. Otherwise, compare each reported change with the table above, and apply the changes you want through a migration.
+. Run your test suite once with the setting on.
+. Set the property in the environment where you want it first, for example in `development` only.
+
+Prefer migrations over `dbCreate: update` for a production schema.
+
+[[nativeDomainBinding-envers]]
+=== Envers
+
+Envers audits the same entities and writes the same revisions with both bindings. There are two differences:
+
+* A `@NotAudited` annotation on a Groovy property is honored by native binding. Classic binding ignores it: Groovy puts the annotation on the field, and Envers reads the getter, so the property is audited. Classic binding audits a property that you excluded.
+* The `version` column of optimistic locking is audited by both bindings, as it is with classic binding.
+
+=== Reporting an Unsupported Mapping
+
+If a mapping is refused that you think should work, or if the schema differs in a way that this page does not list, open an issue at https://github.com/apache/grails-core/issues with the domain classes, the `static mapping` blocks and the exception message or the DDL of each binding. The refusals above are not permanent; each one is a mapping that native binding does not yet express, and the list is expected to shrink.
diff --git a/grails-data-hibernate7/docs/src/docs/asciidoc/configuration/configurationReference.adoc b/grails-data-hibernate7/docs/src/docs/asciidoc/configuration/configurationReference.adoc
index b6083621f4..2a0aa39d51 100644
--- a/grails-data-hibernate7/docs/src/docs/asciidoc/configuration/configurationReference.adoc
+++ b/grails-data-hibernate7/docs/src/docs/asciidoc/configuration/configurationReference.adoc
@@ -61,6 +61,7 @@ name,description,default value
 `hibernate.configLocations`, Location of additional Hibernate XML configuration files
 `hibernate.packagesToScan`, Specify packages to search for autodetection of your entity classes in the classpath
 `hibernate.grails.proxy.lazy_to_string`, Whether `toString()` on an uninitialized proxy avoids initializing it (see Proxies and toString()), `false`
+`hibernate.generatedDomainClasses`, Whether Hibernate's own annotation binder binds the domain classes (see Native Domain Binding), `false`
 |===
 
 In addition, any additional settings that start with `hibernate.` are passed through to Hibernate, so if there is any specific feature of Hibernate you wish to configure that is possible.
diff --git a/grails-data-hibernate7/docs/src/docs/asciidoc/index.adoc b/grails-data-hibernate7/docs/src/docs/asciidoc/index.adoc
index d3b845fe34..791dcc79a5 100644
--- a/grails-data-hibernate7/docs/src/docs/asciidoc/index.adoc
+++ b/grails-data-hibernate7/docs/src/docs/asciidoc/index.adoc
@@ -269,6 +269,11 @@ include::advancedGORMFeatures/ormdsl/customNamingStrategy.adoc[]
 
 include::advancedGORMFeatures/defaultSortOrder.adoc[]
 
+[[nativeDomainBinding]]
+=== Native Domain Binding
+
+include::advancedGORMFeatures/nativeDomainBinding.adoc[]
+
 [[programmaticTransactions]]
 == Programmatic Transactions
 


(grails-core) 11/16: feat: accept type parameters on a registered type name in native domain binding

Posted by bo...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

borinquenkid pushed a commit to branch 9.0.x-h7-improvement-phase3
in repository https://gitbox.apache.org/repos/asf/grails-core.git

commit 25227f0adf30e077f3f90618ddc63efa2c43bc51
Author: Walter Duque de Estrada <wb...@mac.com>
AuthorDate: Thu Oct 8 01:03:24 2026 -0500

    feat: accept type parameters on a registered type name in native domain binding
    
    The generator refused params next to a registered type name (type: 'string', params: [...]).
    The domain binder hands the parameters to the type it resolves: a UserType that implements
    ParameterizedType receives them, and a registered built-in type ignores them (probed: the column is
    varchar(255), the value is stored as it is and read back).
    
    The generator now accepts the parameters on a registered type, a converting registered type and a
    serialized collection and states none of them, which is the observable behavior of the domain binder.
    The parameters of a UserType are still stated with @Type and reach the type, which the new spec
    compares in both bindings.
    
    Co-Authored-By: Claude Sonnet 5.5 <no...@anthropic.com>
---
 .../domainbinding/jpa/GrailsDomainGenerator.groovy |  20 +--
 ...GeneratedDomainClassesTypeParametersSpec.groovy | 198 +++++++++++++++++++++
 ...eneratedDomainClassesDdlDifferentialSpec.groovy |   1 -
 .../jpa/GrailsDomainGeneratorSpec.groovy           |  26 ++-
 .../advancedGORMFeatures/nativeDomainBinding.adoc  |   7 +-
 5 files changed, 233 insertions(+), 19 deletions(-)

diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGenerator.groovy b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGenerator.groovy
index c0fc95658b..4d51fe6691 100644
--- a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGenerator.groovy
+++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGenerator.groovy
@@ -1096,7 +1096,7 @@ class GrailsDomainGenerator {
             return new TypeDecision(true, name, null)
         }
         if (column && property.isSerializableType()) {
-            return serializedCollectionType(parameters)
+            return serializedCollectionType()
         }
         Class<?> named = loadClass(name, property)
         if (named != null) {
@@ -1107,17 +1107,18 @@ class GrailsDomainGenerator {
         }
         BasicType<?> registered = typeConfiguration.basicTypeRegistry.getRegisteredType(name)
         boolean identity = property instanceof HibernateSimpleIdentityProperty
-        if (registered != null && registered.valueConverter instanceof AttributeConverter && parameters.isEmpty() && !isEnum && !identity &&
+        if (registered != null && registered.valueConverter instanceof AttributeConverter && !isEnum && !identity &&
                 !element && registered.javaTypeDescriptor.javaTypeClass == boxed(type)) {
             // a registered type that converts its value (yes_no, true_false, numeric_boolean) is the JPA converter it wraps
+            // the binder hands the type parameters to a registered type, which ignores them
             return new TypeDecision(true, name, new TypeFacets(
-                    null, registered.jdbcType.defaultSqlTypeCode, parameters, null, registered.valueConverter.getClass()))
+                    null, registered.jdbcType.defaultSqlTypeCode, [:], null, registered.valueConverter.getClass()))
         }
-        if (registered != null && registered.valueConverter == null && parameters.isEmpty() && !isEnum &&
+        if (registered != null && registered.valueConverter == null && !isEnum &&
                 (identity || registered.javaTypeDescriptor.javaTypeClass == boxed(type) || serializesValueOf(registered, type))) {
             Class<?> registeredJava = registered.javaTypeDescriptor.javaTypeClass
             return new TypeDecision(true, name, new TypeFacets(
-                    null, registered.jdbcType.defaultSqlTypeCode, parameters, registeredJava == boxed(type) ? null : registeredJava))
+                    null, registered.jdbcType.defaultSqlTypeCode, [:], registeredJava == boxed(type) ? null : registeredJava))
         }
         String problem
         if (registered == null) {
@@ -1127,8 +1128,6 @@ class GrailsDomainGenerator {
         } else if (registered.valueConverter != null) {
             problem = 'is a registered type that converts its value in a way @Convert does not state (not a JPA attribute converter, ' +
                     'or the type of a collection element, an identifier or an enum)'
-        } else if (!parameters.isEmpty()) {
-            problem = 'is a registered type with type parameters, which no annotation states'
         } else {
             problem = "is registered for the Java type [${registered.javaTypeDescriptor.javaTypeClass.name}], not the property's [${type?.name}]: " +
                     'stating it would need a converter, which annotations do not say for a type name'
@@ -1141,11 +1140,8 @@ class GrailsDomainGenerator {
      * under that name for {@code Serializable}. A field declared {@code Serializable} (the property of the real class is read and
      * written by its own accessors, whatever the field says) has that Java type, and the JDBC type is the binary one.
      */
-    private static TypeDecision serializedCollectionType(Map<String, String> parameters) {
-        if (!parameters.isEmpty()) {
-            return new TypeDecision(false, 'serializable', null, 'is a registered type with type parameters, which no annotation states')
-        }
-        return new TypeDecision(true, 'serializable', new TypeFacets(null, Types.VARBINARY, parameters, Serializable))
+    private static TypeDecision serializedCollectionType() {
+        return new TypeDecision(true, 'serializable', new TypeFacets(null, Types.VARBINARY, [:], Serializable))
     }
 
     /**
diff --git a/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/generated/GeneratedDomainClassesTypeParametersSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/generated/GeneratedDomainClassesTypeParametersSpec.groovy
new file mode 100644
index 0000000000..e6139bac87
--- /dev/null
+++ b/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/generated/GeneratedDomainClassesTypeParametersSpec.groovy
@@ -0,0 +1,198 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package grails.gorm.tests.generated
+
+import java.sql.Connection
+import java.sql.PreparedStatement
+import java.sql.ResultSet
+import java.sql.ResultSetMetaData
+import java.sql.Types
+import java.util.concurrent.atomic.AtomicInteger
+
+import grails.gorm.annotation.Entity
+import org.hibernate.engine.spi.SharedSessionContractImplementor
+import org.hibernate.mapping.Table
+import org.hibernate.usertype.ParameterizedType
+import org.hibernate.usertype.UserType
+import spock.lang.AutoCleanup
+import spock.lang.Specification
+import spock.lang.Unroll
+
+import org.grails.orm.hibernate.HibernateDatastore
+
+/**
+ * {@code params} on a {@code type}. The domain binder hands them to the type: a registered type name ignores them, a
+ * {@code UserType} that implements {@code ParameterizedType} receives them. The generated mode does the same.
+ */
+class GeneratedDomainClassesTypeParametersSpec extends Specification {
+
+    static final AtomicInteger BOOTS = new AtomicInteger()
+
+    @AutoCleanup
+    HibernateDatastore datastore
+
+    private HibernateDatastore boot(List<Class> group, boolean generated) {
+        datastore?.close()
+        datastore = new HibernateDatastore([
+                'dataSource.url'                  : "jdbc:h2:mem:tns${BOOTS.incrementAndGet()};LOCK_TIMEOUT=10000".toString(),
+                'dataSource.dbCreate'             : 'create-drop',
+                'hibernate.generatedDomainClasses': generated,
+        ], group as Class[])
+        return datastore
+    }
+
+    /** The tables of the boot model as plain data: columns with SQL type and nullability, indexes, unique keys. */
+    private Map<String, Map> schema(HibernateDatastore booted) {
+        Map<String, Map> result = new TreeMap<String, Map>()
+        for (Table table : booted.metadata.collectTableMappings()) {
+            if (table.physicalTable) {
+                result[table.name] = [
+                        columns   : table.columns.collectEntries { org.hibernate.mapping.Column column ->
+                            boolean nullable = column.nullable && !(table.primaryKey != null && table.primaryKey.columns*.name.contains(column.name))
+                            [(column.name): "${column.getSqlType(booted.metadata)}${nullable ? '' : ' not null'}${column.unique ? ' unique' : ''}".toString()]
+                        },
+                        primaryKey: table.primaryKey?.columns*.name?.sort(),
+                        indexes   : table.indexes.values().collectEntries { [(it.name): it.columns*.name] },
+                        uniqueKeys: table.uniqueKeys.values().collect { it.columns*.name.sort() }.sort { it.toString() },
+                ]
+            }
+        }
+        return result
+    }
+
+    /** The rows of a table as stored, binary values as lists of bytes. */
+    private List<List> rows(String sql) {
+        return datastore.connectionSources.defaultConnectionSource.dataSource.connection.withCloseable { Connection connection ->
+            ResultSet result = connection.createStatement().executeQuery(sql)
+            ResultSetMetaData meta = result.metaData
+            List<List> found = []
+            while (result.next()) {
+                found << (1..meta.columnCount).collect { int i ->
+                    Object value = result.getObject(i)
+                    value instanceof byte[] ? ((byte[]) value).toList() : value
+                }
+            }
+            found
+        }
+    }
+
+    /** What a property does when it is saved, read back by a new session and stored: the value, or why it could not be read. */
+    private Map roundTrip(HibernateDatastore booted, Class type, Map<String, Object> values, String property, String select) {
+        Long id = type.withTransaction { type.newInstance(values).save(failOnError: true, flush: true).id }
+        Map observed = [stored: rows(select)]
+        try {
+            Object value = type.withNewSession { type.get(id)."${property}" }
+            observed.reloaded = value instanceof byte[] ? ((byte[]) value).toList() : value
+        } catch (Throwable e) {
+            Throwable root = e
+            while (root.cause != null && root.cause != root) {
+                root = root.cause
+            }
+            observed.reloaded = "cannot be read: ${root.class.simpleName}".toString()
+        }
+        return observed
+    }
+
+    @Unroll
+    void "#label has the column type the domain binder gives it"() {
+        when:
+        Map<String, Map> binder = schema(boot(group, false))
+        Map<String, Map> generated = schema(boot(group, true))
+
+        then:
+        generated == binder
+        generated[table].columns[column] == type
+
+        where:
+        label                                                | group                  | table                 | column          | type
+        'a string typed string with type parameters'         | [TnStringParams]       | 'tn_string_params'    | 'my_property'   | 'varchar(255)'
+        'an integer typed integer with type parameters'      | [TnIntegerParams]      | 'tn_integer_params'   | 'amount'        | 'integer'
+        'a string with a user type that takes parameters'    | [TnPrefixed]           | 'tn_prefixed'         | 'label'         | 'varchar(255)'
+    }
+
+    void "a type with type parameters is saved and read back as the domain binder does, which hands the parameters to a registered type that ignores them"() {
+        when:
+        Map binder = roundTrip(boot([TnStringParams], false), TnStringParams, [myProperty: 'hello'], 'myProperty', 'select my_property from tn_string_params')
+        Map generated = roundTrip(boot([TnStringParams], true), TnStringParams, [myProperty: 'hello'], 'myProperty', 'select my_property from tn_string_params')
+
+        then:
+        generated == binder
+        generated == [stored: [['hello']], reloaded: 'hello']
+    }
+
+    void "the parameters of a user type reach the user type"() {
+        when:
+        Map binder = roundTrip(boot([TnPrefixed], false), TnPrefixed, [label: 'hello'], 'label', 'select label from tn_prefixed')
+        Map generated = roundTrip(boot([TnPrefixed], true), TnPrefixed, [label: 'hello'], 'label', 'select label from tn_prefixed')
+
+        then:
+        generated == binder
+        generated == [stored: [['p:hello']], reloaded: 'hello']
+    }
+}
+
+@Entity
+class TnStringParams {
+    String myProperty
+    static mapping = { myProperty type: 'string', params: [param1: 'value1'] }
+}
+
+@Entity
+class TnIntegerParams {
+    Integer amount
+    static mapping = { amount type: 'integer', params: [param1: 'value1'] }
+}
+
+class TnPrefixType implements UserType<String>, ParameterizedType {
+
+    String prefix = ''
+
+    void setParameterValues(Properties parameters) {
+        prefix = parameters.getProperty('prefix')
+    }
+
+    int getSqlType() { Types.VARCHAR }
+    Class<String> returnedClass() { String }
+    boolean equals(String x, String y) { x == y }
+    int hashCode(String x) { x.hashCode() }
+
+    String nullSafeGet(ResultSet rs, int position, SharedSessionContractImplementor session, Object owner) {
+        String text = rs.getString(position)
+        text == null ? null : text.substring(prefix.length())
+    }
+
+    void nullSafeSet(PreparedStatement st, String value, int index, SharedSessionContractImplementor session) {
+        if (value == null) {
+            st.setNull(index, Types.VARCHAR)
+        } else {
+            st.setString(index, prefix + value)
+        }
+    }
+
+    String deepCopy(String value) { value }
+    boolean isMutable() { false }
+    Serializable disassemble(String value) { value }
+    String assemble(Serializable cached, Object owner) { (String) cached }
+}
+
+@Entity
+class TnPrefixed {
+    String label
+    static mapping = { label type: TnPrefixType, params: [prefix: 'p:'] }
+}
diff --git a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GeneratedDomainClassesDdlDifferentialSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GeneratedDomainClassesDdlDifferentialSpec.groovy
index f0cc67926e..4b49a3f8fe 100644
--- a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GeneratedDomainClassesDdlDifferentialSpec.groovy
+++ b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GeneratedDomainClassesDdlDifferentialSpec.groovy
@@ -87,7 +87,6 @@ class GeneratedDomainClassesDdlDifferentialSpec extends Specification {
             'declares a natural id but is a subclass',
             'which is HibernateEmbeddedProperty',
             'which is not a persistent property of the entity',
-            'is a registered type with type parameters',
     ]
 
     private static final AtomicInteger BOOTS = new AtomicInteger()
diff --git a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorSpec.groovy
index f25fbbe2fa..4003c38787 100644
--- a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorSpec.groovy
+++ b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorSpec.groovy
@@ -168,7 +168,7 @@ class GrailsDomainGeneratorSpec extends HibernateGormDatastoreSpec {
                 GenOmParent, GenOmChild, GenOmListed, GenOmOrdered, GenOmOrphan, GenOmFetched, GenOmTag, GenOmStep, GenOmKept, GenOmSortedOwner, GenOmMapOwner, GenMapBidiOwner, GenMapBidiChild, GenEmbAssocOwner,
                 GenMmStudent, GenMmCourse, GenMmPerson, GenParamsOnly, GenDecimal, GenUnversioned, GenUnversionedRoot, GenUnversionedChild, GenConverted, GenValueTyped,
                 GenCollEmbeddedHolder, GenCollEmbeddedSibling, GenCollEmbeddedItem, GenCollEmbeddedEntityHolder, GenMapM2mLeft, GenMapM2mRight,
-                GenSerialized)
+                GenSerialized, GenTypeParams)
     }
 
     List<StandardServiceRegistry> registries = []
@@ -396,6 +396,18 @@ class GrailsDomainGeneratorSpec extends HibernateGormDatastoreSpec {
         !serialized.getDeclaredField('plain').isAnnotationPresent(JdbcTypeCode)
     }
 
+    void "type parameters on a registered type name are ignored, as the domain binder hands them to a type that ignores them"() {
+        when:
+        Class<?> params = generate(GenTypeParams)
+
+        then:
+        params.getDeclaredField('name').type == String
+        !params.getDeclaredField('name').isAnnotationPresent(Type)
+        params.getDeclaredField('name').getAnnotation(JdbcTypeCode).value() == java.sql.Types.VARCHAR
+        params.getDeclaredField('amount').type == Integer
+        params.getDeclaredField('amount').getAnnotation(JdbcTypeCode).value() == java.sql.Types.INTEGER
+    }
+
     void "Hibernate's own annotation binder resolves a converted type like the domain binder"() {
         given:
         Map<GrailsHibernatePersistentEntity, Class<?>> classes = generateGroup(GenConverted)
@@ -2822,6 +2834,18 @@ class GenSerialized {
     }
 }
 
+@Entity
+class GenTypeParams {
+
+    String name
+    Integer amount
+
+    static mapping = {
+        name type: 'string', params: [param1: 'value1']
+        amount type: 'integer', params: [param1: 'value1']
+    }
+}
+
 class GenUpperType implements UserType<String>, ParameterizedType {
 
     @Override
diff --git a/grails-data-hibernate7/docs/src/docs/asciidoc/advancedGORMFeatures/nativeDomainBinding.adoc b/grails-data-hibernate7/docs/src/docs/asciidoc/advancedGORMFeatures/nativeDomainBinding.adoc
index d29d522bb2..14b6b71b23 100644
--- a/grails-data-hibernate7/docs/src/docs/asciidoc/advancedGORMFeatures/nativeDomainBinding.adoc
+++ b/grails-data-hibernate7/docs/src/docs/asciidoc/advancedGORMFeatures/nativeDomainBinding.adoc
@@ -63,6 +63,7 @@ For the domain classes in GORM's own test suites, both bindings produce the same
 * A `hasMany` inside a class listed in `static embedded` (basic values, enums or domain classes, also in a nested embedded class) is stored in a collection table named after the embedded class, not after the owner, with a key column named after the embedded class too, as with classic binding.
 * A `Map` on a many-to-many (`Map<String, Book> books` with a `Set` or another `Map` on the other side) writes its own join table, with the key column of the map as part of the primary key, as with classic binding. A `Set` on the other side reads that table and does not write it, and two maps are two separate tables: rows stored through one map are not visible through the other, in both bindings.
 * `type: 'serializable'` on a property that is not a collection (a `String`, a `byte[]` or any `Serializable`) is a binary column holding the Java serialization of the value, as with classic binding.
+* `params` on a registered type name such as `type: 'string'` are ignored, as with classic binding. They reach the type when it is a `UserType` that implements `ParameterizedType`.
 * Identifier generators, inheritance strategies (single table, joined and table-per-concrete-class), discriminators, natural ids, optimistic locking, lazy and eager fetching, cascades, sorting, batch size and `cache` usage are mapped as with classic binding.
 
 [[nativeDomainBinding-refused]]
@@ -102,10 +103,6 @@ Nothing is ignored silently. A mapping that boots is bound the way it is written
 | `Type [string] of property [...] is registered for the Java type [java.lang.String], not the property's [java.lang.Integer] ...`
 | Use a `UserType` class, or a type name registered for the property's own type.
 
-| A registered type name together with `params`.
-| `Type [string] of property [...] is a registered type with type parameters, which no annotation states`
-| Use a `UserType` class that implements `ParameterizedType`, or drop the parameters.
-
 | An `AttributeConverter` class named as the `type`, for example `type: 'org.hibernate.type.YesNoConverter'`.
 | `Type [...] of property [...] names a class that is not a UserType (an AttributeConverter, for example) ...`
 | Use the registered names `yes_no`, `true_false` or `numeric_boolean`, or write a `UserType`.
@@ -166,7 +163,7 @@ Some bugs of classic binding are not copied by native binding. Native binding do
 [[nativeDomainBinding-schema]]
 === Differences in the Schema
 
-The schema of 689 groups of related domain classes was created with both bindings on H2 and compared: tables, columns with type, nullability, default, length, precision and scale, primary keys with their column order, foreign keys, unique keys, indexes, check constraints and sequences. Apart from the 7 groups that native binding refuses, these are the differences. Other databases are not covered by this comparison, so verify against your own database as described below.
+The schema of 694 groups of related domain classes was created with both bindings on H2 and compared: tables, columns with type, nullability, default, length, precision and scale, primary keys with their column order, foreign keys, unique keys, indexes, check constraints and sequences. Apart from the 6 groups that native binding refuses, these are the differences. Other databases are not covered by this comparison, so verify against your own database as described below.
 
 [cols="2,3,3", options="header"]
 |===


(grails-core) 08/16: feat: accept a collection inside an embedded type in native domain binding

Posted by bo...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

borinquenkid pushed a commit to branch 9.0.x-h7-improvement-phase3
in repository https://gitbox.apache.org/repos/asf/grails-core.git

commit 8f77322ec3f46a743521651c1695e23632c32a1f
Author: Walter Duque de Estrada <wb...@mac.com>
AuthorDate: Wed Oct 7 23:14:39 2026 -0500

    feat: accept a collection inside an embedded type in native domain binding
    
    The generated mode refused every hasMany inside a class listed in static embedded. The domain binder names the collection table after the embedded class and not after the owner (and the key column too, pointing at the owner's table), which the property's own facets already state, so the collections of basic values, enums and entities (also in a nested embedded class) are now bound as element collections and to-many associations of the generated embeddable.
    
    Two defects of the first attempt are fixed: a collection was listed as an attribute override leaf of the owner and renamed its element column, and a collection of entities inside an embeddable made Hibernate retry a failed element type check forever, so the embeddable that holds one takes the name of the embedded class like an entity.
    
    The one case still refused, by a precise message, is a collection of an embedded type reachable through more than one embedded property: the binder binds a single table whose key points at only one owner, and fails to boot when the properties are named alike. The refused group count of the DDL differential drops from 12 to 10, and the differential now compares the collections inside components.
    
    Co-Authored-By: Claude Sonnet 5.5 <no...@anthropic.com>
---
 .../domainbinding/jpa/GrailsDomainGenerator.groovy |  73 +++-
 ...ratedDomainClassesEmbeddedCollectionSpec.groovy | 445 +++++++++++++++++++++
 .../unbootable/EmbeddedCollectionDomains.groovy    |  32 ++
 ...eneratedDomainClassesDdlDifferentialSpec.groovy |   1 -
 .../GrailsDomainGeneratorDifferentialSpec.groovy   |  41 +-
 .../jpa/GrailsDomainGeneratorSpec.groovy           | 108 ++++-
 .../advancedGORMFeatures/nativeDomainBinding.adoc  |   9 +-
 7 files changed, 693 insertions(+), 16 deletions(-)

diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGenerator.groovy b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGenerator.groovy
index c9442c0e33..6511ef7da6 100644
--- a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGenerator.groovy
+++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGenerator.groovy
@@ -279,6 +279,10 @@ class GrailsDomainGenerator {
         for (GrailsHibernatePersistentEntity entity : given) {
             requireReferencedEntities(entity, given)
         }
+        String collision = embeddedCollectionCollision(given)
+        if (collision != null) {
+            throw new UnsupportedOperationException(collision)
+        }
         List<GrailsHibernatePersistentEntity> ordered = new ArrayList<GrailsHibernatePersistentEntity>(given)
         ordered.sort { GrailsHibernatePersistentEntity a, GrailsHibernatePersistentEntity b -> depth(a) <=> depth(b) }
 
@@ -305,6 +309,53 @@ class GrailsDomainGenerator {
         return result
     }
 
+    /**
+     * The binder names the table and the key column of a collection inside an embedded type after the embedded type and not after
+     * the owner, and gives it no owner-specific identity, so two embedded properties that share a type with a collection share one
+     * table whose key is a foreign key to the owner that was bound first (probed: the rows of the other owner violate it unless the
+     * identifiers happen to coincide), and Hibernate refuses the duplicate collection at boot when the two properties have the same name
+     * (pinned in {@code GrailsDomainBinderEmbeddedCollectionDefectSpec}). One embedded property per type is described exactly as the
+     * binder binds it; a second one is rejected by name rather than described with a table the generator would have to invent.
+     *
+     * @return why the embedded properties of the entities cannot be described together, or {@code null} when no collection of an
+     *     embedded type is reachable through more than one embedded property
+     */
+    private static String embeddedCollectionCollision(Collection<GrailsHibernatePersistentEntity> entities) {
+        Map<String, List<String>> uses = new LinkedHashMap<String, List<String>>()
+        for (GrailsHibernatePersistentEntity entity : entities) {
+            for (HibernatePersistentProperty property : entity.persistentPropertiesToBind) {
+                if (property instanceof HibernateEmbeddedProperty) {
+                    collectCollectionUses((HibernateEmbeddedProperty) property, "${entity.name}.${property.name}".toString(), uses, [])
+                }
+            }
+        }
+        Map.Entry<String, List<String>> shared = uses.entrySet().find { Map.Entry<String, List<String>> use -> use.value.size() > 1 }
+        if (shared == null) {
+            return null
+        }
+        String[] parts = shared.key.split('#')
+        return "The collection [${parts[1]}] of the embedded type [${parts[0]}] is reachable through ${shared.value.size()} embedded " +
+                "properties (${shared.value.join(', ')}): the binder names its table and its key column after the embedded type and not " +
+                'after the owner, so the owners share one table whose key is a foreign key to only one of them, and Hibernate refuses ' +
+                'the duplicate collection at boot when the properties have the same name (pinned in ' +
+                'GrailsDomainBinderEmbeddedCollectionDefectSpec), which the generator does not copy and annotations cannot say'
+    }
+
+    private static void collectCollectionUses(
+            HibernateEmbeddedProperty property, String path, Map<String, List<String>> uses, List<Class<?>> visiting) {
+        GrailsHibernatePersistentEntity type = (GrailsHibernatePersistentEntity) property.associatedEntity
+        if (type == null || visiting.contains(type.javaClass)) {
+            return
+        }
+        for (HibernatePersistentProperty peer : embeddedPeers(property)) {
+            if (peer instanceof HibernateEmbeddedProperty) {
+                collectCollectionUses((HibernateEmbeddedProperty) peer, "${path}.${peer.name}".toString(), uses, visiting + [type.javaClass])
+            } else if (peer instanceof HibernateToManyProperty) {
+                uses.computeIfAbsent("${type.javaClass.name}#${peer.name}".toString()) { String key -> new ArrayList<String>() }.add(path)
+            }
+        }
+    }
+
     private static void requireWholeHierarchy(GrailsHibernatePersistentEntity entity, Set<GrailsHibernatePersistentEntity> given) {
         if (!entity.isRoot() && superEntity(entity, given) == null) {
             throw new IllegalArgumentException(
@@ -2152,6 +2203,9 @@ class GrailsDomainGenerator {
             String path = relative.isEmpty() ? peer.name : "${relative}.${peer.name}".toString()
             if (peer instanceof HibernateEmbeddedProperty) {
                 collectLeaves((HibernateEmbeddedProperty) peer, currentPath, path, enclosing, leaves)
+            } else if (peer instanceof HibernateToManyProperty) {
+                // a collection has a table of its own and no column in the owner's table: the embeddable states its table and columns itself
+                continue
             } else if (isDerived(peer)) {
                 leaves << new EmbeddedLeaf(path, peer, null, null)
             } else if (peer instanceof HibernateToOneProperty && compositeIdentifier(((HibernateToOneProperty) peer).hibernateAssociatedEntity)) {
@@ -2237,10 +2291,9 @@ class GrailsDomainGenerator {
                     return problem
                 }
             } else if (peer instanceof HibernateToManyProperty) {
-                return "the property [${peer.name}] of [${type.name}] is a collection inside an embedded type: the binder names its table and " +
-                        'its key column after the embedded type and not after the owner, so two owners of the type collide ' +
-                        'and Hibernate refuses the duplicate collection at boot (pinned in GrailsDomainBinderEmbeddedCollectionDefectSpec), ' +
-                        'which the generator does not copy and annotations cannot say'
+                if (!supports(peer)) {
+                    return unsupportedReason(type, peer)
+                }
             } else if (peer instanceof HibernateToOneProperty) {
                 if (!boundAsManyToOne((HibernateToOneProperty) peer)) {
                     return "the property [${peer.name}] of [${type.name}] is the inverse side of a one-to-one inside an embedded type, " +
@@ -2969,9 +3022,19 @@ class GrailsDomainGenerator {
         if (existing != null) {
             return existing.typeDescription
         }
-        String base = generatedEmbeddableName(type)
+        // Hibernate checks the declared element type of a collection of entities against the class it finds for the declaring type by
+        // name, and retries a failed check without end. It finds the class generated for an entity under the entity's name, which has
+        // no getters to check, but would find the real class of an embedded type with its getters and the generated class of the
+        // entity as the element type, so the embeddable that holds such a collection takes the name of the embedded type like an entity.
+        boolean holdsEntities = peers.any { HibernatePersistentProperty peer -> peer instanceof HibernateToManyEntityProperty }
+        String base = holdsEntities ? type.javaClass.name : generatedEmbeddableName(type)
         String name = base
         int variant = 1
+        if (holdsEntities && embeddables.values().any { DynamicType.Unloaded<?> other -> other.typeDescription.name == name }) {
+            throw new UnsupportedOperationException(
+                    "The embedded type [${type.name}] holds a collection of entities and is embedded with two different sets of " +
+                            'properties, which the generator cannot describe: Hibernate needs the class generated for it to carry the name of the type')
+        }
         while (embeddables.values().any { DynamicType.Unloaded<?> other -> other.typeDescription.name == name }) {
             name = base + '_' + (++variant)
         }
diff --git a/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/generated/GeneratedDomainClassesEmbeddedCollectionSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/generated/GeneratedDomainClassesEmbeddedCollectionSpec.groovy
new file mode 100644
index 0000000000..f28322dd1a
--- /dev/null
+++ b/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/generated/GeneratedDomainClassesEmbeddedCollectionSpec.groovy
@@ -0,0 +1,445 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package grails.gorm.tests.generated
+
+import java.util.concurrent.atomic.AtomicInteger
+
+import grails.gorm.annotation.Entity
+import grails.unbootable.EmbeddedCollectionOwnerA
+import grails.unbootable.EmbeddedCollectionOwnerB
+import grails.unbootable.EmbeddedCollectionOwnerC
+import grails.unbootable.EmbeddedCollectionOwnerD
+import org.hibernate.mapping.Table
+import spock.lang.AutoCleanup
+import spock.lang.Specification
+import spock.lang.Unroll
+
+import org.grails.orm.hibernate.HibernateDatastore
+
+/**
+ * A collection inside an embedded type, bound by the domain binder and by the generated classes. The domain binder names the table
+ * of such a collection after the embedded type and not after the owner ({@code ecs_words_words}), and its key column after the
+ * embedded type too ({@code ecs_words_id}), whatever the embedded property is called; the key points at the table of the owner. The
+ * generated classes state the same names. Two embedded properties that share a type with a collection would share that one table,
+ * which the binder cannot make sense of, so the generated mode rejects them by name.
+ */
+class GeneratedDomainClassesEmbeddedCollectionSpec extends Specification {
+
+    static final AtomicInteger BOOTS = new AtomicInteger()
+
+    @AutoCleanup
+    HibernateDatastore datastore
+
+    private HibernateDatastore boot(List<Class> group, boolean generated) {
+        datastore?.close()
+        datastore = new HibernateDatastore([
+                'dataSource.url'                  : "jdbc:h2:mem:ecs${BOOTS.incrementAndGet()};LOCK_TIMEOUT=10000".toString(),
+                'dataSource.dbCreate'             : 'create-drop',
+                'hibernate.generatedDomainClasses': generated,
+        ], group as Class[])
+        return datastore
+    }
+
+    /** The tables of the boot model as plain data: columns with SQL type and nullability, keys, foreign keys, indexes, unique keys. */
+    private Map<String, Map> schema(List<Class> group, boolean generated) {
+        HibernateDatastore booted = boot(group, generated)
+        Map<String, Map> result = new TreeMap<String, Map>()
+        for (Table table : booted.metadata.collectTableMappings()) {
+            if (table.physicalTable) {
+                result[table.name] = [
+                        columns    : table.columns.collectEntries { org.hibernate.mapping.Column column ->
+                            boolean nullable = column.nullable && !(table.primaryKey != null && table.primaryKey.columns*.name.contains(column.name))
+                            [(column.name): "${column.getSqlType(booted.metadata)}${nullable ? '' : ' not null'}${column.unique ? ' unique' : ''}".toString()]
+                        },
+                        primaryKey : table.primaryKey?.columns*.name?.sort(),
+                        foreignKeys: table.foreignKeys.values().collect { "${it.columns*.name} -> ${it.referencedTable.name}".toString() }.sort(),
+                        indexes    : table.indexes.values().collectEntries { [(it.name): it.columns*.name] },
+                        uniqueKeys : table.uniqueKeys.values().collect { it.columns*.name.sort() }.sort { it.toString() },
+                ]
+            }
+        }
+        return result
+    }
+
+    @Unroll
+    void "#label: the generated mode creates the schema of the domain binder"() {
+        when:
+        Map<String, Map> binder = schema(group, false)
+        Map<String, Map> generated = schema(group, true)
+
+        then:
+        generated == binder
+        generated.keySet() == tables as Set
+        generated[table].columns.keySet() == columns as Set
+
+        where:
+        label                                  | group                      | tables                                                      | table                | columns
+        'a set of strings'                     | [EcsSetOwner]              | ['ecs_set_owner', 'ecs_words_words']                        | 'ecs_words_words'    | ['ecs_words_id', 'words_java_lang_string']
+        'a set beside simple properties'       | [EcsMixedOwner]            | ['ecs_mixed_owner', 'ecs_mixed_words_words']                | 'ecs_mixed_words_words' | ['ecs_mixed_words_id', 'words_java_lang_string']
+        'a set of enums'                       | [EcsEnumOwner]             | ['ecs_enum_owner', 'ecs_enums_colors']                      | 'ecs_enums_colors'   | ['ecs_enums_id', 'ecs_color']
+        'a set mapped with a join table'       | [EcsNamedOwner]            | ['ecs_named_owner', 'ecs_named_words']                      | 'ecs_named_words'    | ['owner_fk', 'word_col']
+        'a set in a nested embedded type'      | [EcsNestedOwner]           | ['ecs_nested_owner', 'ecs_deep_tags']                       | 'ecs_deep_tags'      | ['ecs_deep_id', 'tags_java_lang_string']
+        'a set of entities'                    | [EcsEntityOwner, EcsItem]  | ['ecs_entity_owner', 'ecs_item', 'ecs_entities_ecs_item']   | 'ecs_entities_ecs_item' | ['ecs_entities_items_id', 'ecs_item_id']
+        'a set of entities in a nested type'   | [EcsDeepEntityOwner, EcsItem] | ['ecs_deep_entity_owner', 'ecs_item', 'ecs_deep_entities_ecs_item'] | 'ecs_deep_entities_ecs_item' | ['ecs_deep_entities_items_id', 'ecs_item_id']
+    }
+
+    @Unroll
+    void "#label: the schema differs from the domain binder's only as the schema of a collection of the owner itself does"() {
+        when:
+        Map<String, Map> binder = schema(group, false)
+        Map<String, Map> generated = schema(group, true)
+        Map<String, Map> plainBinder = schema([plain], false)
+        Map<String, Map> plainGenerated = schema([plain], true)
+
+        then: "the same tables, the same columns apart from the one the binder leaves unused, the same keys"
+        generated.keySet() == binder.keySet()
+        generated[table].columns.keySet() == binder[table].columns.keySet() - unused
+        generated[table].primaryKey == binder[table].primaryKey
+        generated[table].foreignKeys == binder[table].foreignKeys
+        generated[table].indexes == binder[table].indexes
+        generated[table].uniqueKeys == binder[table].uniqueKeys
+
+        and: "the types differ exactly where they differ for a collection that is a property of the owner"
+        shape(binder[table].columns, generated[table].columns) == shape(plainBinder[plainTable].columns, plainGenerated[plainTable].columns)
+
+        where:
+        label               | group          | table              | unused                       | plain        | plainTable
+        'a list of strings' | [EcsListOwner] | 'ecs_lists_items'  | []                           | EcsPlainList | 'ecs_plain_list_items'
+        'a map of strings'  | [EcsMapOwner]  | 'ecs_maps_by_name' | ['by_name_java_lang_string'] | EcsPlainMap  | 'ecs_plain_map_by_name'
+    }
+
+    private static List shape(Map<String, String> binder, Map<String, String> generated) {
+        return (binder.keySet() + generated.keySet()).collect { String name -> [binder[name], generated[name]] }
+                .findAll { List pair -> pair[0] != pair[1] }.sort { it.toString() }
+    }
+
+    @Unroll
+    void "#label are stored, read back, changed and deleted with the owner, the same in both modes"() {
+        when:
+        Map<Boolean, List> results = [false, true].collectEntries { boolean generated ->
+            boot(group, generated)
+            [(generated): this."${cycle}"()]
+        }
+
+        then:
+        results[true] == results[false]
+        results[true] == expected
+
+        where:
+        label                                | group                         | cycle              | expected
+        'the words of an embedded set'       | [EcsSetOwner]                 | 'setCycle'         | [['a', 'b'], ['a', 'c'], 0]
+        'the elements of an embedded list'   | [EcsListOwner]                | 'listCycle'        | [['x', 'y', 'x'], ['y', 'z'], 0]
+        'the entries of an embedded map'     | [EcsMapOwner]                 | 'mapCycle'         | [[one: 'a', two: 'b'], [two: 'b', three: 'c'], 0]
+        'the colors of an embedded set'      | [EcsEnumOwner]                | 'enumCycle'        | [['GREEN', 'RED'], ['GREEN'], 0]
+        'the items of an embedded set'       | [EcsEntityOwner, EcsItem]     | 'entityCycle'      | [['one', 'two'], ['three', 'two'], 0]
+        'the items of a nested embedded set' | [EcsDeepEntityOwner, EcsItem] | 'deepEntityCycle'  | [['one', 'two'], ['one', 'three', 'two'], 0]
+        'the tags of a nested embedded set'  | [EcsNestedOwner]              | 'nestedCycle'      | [['t1', 't2'], ['t2', 't3'], 0]
+    }
+
+    private List setCycle() {
+        Long id = EcsSetOwner.withTransaction { new EcsSetOwner(inner: new EcsWords(words: ['a', 'b'] as Set)).save(failOnError: true, flush: true).id }
+        List first = EcsSetOwner.withNewSession { EcsSetOwner.get(id).inner.words.sort() }
+        EcsSetOwner.withTransaction { EcsSetOwner owner = EcsSetOwner.get(id); owner.inner.words = ['a', 'c'] as Set; owner.save(failOnError: true, flush: true) }
+        List second = EcsSetOwner.withNewSession { EcsSetOwner.get(id).inner.words.sort() }
+        EcsSetOwner.withTransaction { EcsSetOwner.get(id).delete(flush: true) }
+        return [first, second, countRows('ecs_words_words')]
+    }
+
+    private List listCycle() {
+        Long id = EcsListOwner.withTransaction { new EcsListOwner(inner: new EcsLists(items: ['x', 'y', 'x'])).save(failOnError: true, flush: true).id }
+        List first = EcsListOwner.withNewSession { new ArrayList(EcsListOwner.get(id).inner.items) }
+        EcsListOwner.withTransaction { EcsListOwner owner = EcsListOwner.get(id); owner.inner.items = ['y', 'z']; owner.save(failOnError: true, flush: true) }
+        List second = EcsListOwner.withNewSession { new ArrayList(EcsListOwner.get(id).inner.items) }
+        EcsListOwner.withTransaction { EcsListOwner.get(id).delete(flush: true) }
+        return [first, second, countRows('ecs_lists_items')]
+    }
+
+    private List mapCycle() {
+        Long id = EcsMapOwner.withTransaction { new EcsMapOwner(inner: new EcsMaps(byName: [one: 'a', two: 'b'])).save(failOnError: true, flush: true).id }
+        Map first = EcsMapOwner.withNewSession { new TreeMap(EcsMapOwner.get(id).inner.byName) }
+        EcsMapOwner.withTransaction { EcsMapOwner owner = EcsMapOwner.get(id); owner.inner.byName = [two: 'b', three: 'c']; owner.save(failOnError: true, flush: true) }
+        Map second = EcsMapOwner.withNewSession { new TreeMap(EcsMapOwner.get(id).inner.byName) }
+        EcsMapOwner.withTransaction { EcsMapOwner.get(id).delete(flush: true) }
+        return [first, second, countRows('ecs_maps_by_name')]
+    }
+
+    private List enumCycle() {
+        Long id = EcsEnumOwner.withTransaction { new EcsEnumOwner(inner: new EcsEnums(colors: [EcsColor.RED, EcsColor.GREEN] as Set)).save(failOnError: true, flush: true).id }
+        List first = EcsEnumOwner.withNewSession { EcsEnumOwner.get(id).inner.colors*.name().sort() }
+        EcsEnumOwner.withTransaction { EcsEnumOwner owner = EcsEnumOwner.get(id); owner.inner.colors = [EcsColor.GREEN] as Set; owner.save(failOnError: true, flush: true) }
+        List second = EcsEnumOwner.withNewSession { EcsEnumOwner.get(id).inner.colors*.name().sort() }
+        EcsEnumOwner.withTransaction { EcsEnumOwner.get(id).delete(flush: true) }
+        return [first, second, countRows('ecs_enums_colors')]
+    }
+
+    private List entityCycle() {
+        Long id = EcsEntityOwner.withTransaction {
+            EcsItem one = new EcsItem(name: 'one').save(failOnError: true)
+            EcsItem two = new EcsItem(name: 'two').save(failOnError: true)
+            new EcsEntityOwner(inner: new EcsEntities(items: [one, two] as Set)).save(failOnError: true, flush: true).id
+        }
+        List first = EcsEntityOwner.withNewSession { EcsEntityOwner.get(id).inner.items*.name.sort() }
+        EcsEntityOwner.withTransaction {
+            EcsItem three = new EcsItem(name: 'three').save(failOnError: true)
+            EcsEntityOwner owner = EcsEntityOwner.get(id)
+            owner.inner.items = [EcsItem.findByName('two'), three] as Set
+            owner.save(failOnError: true, flush: true)
+        }
+        List second = EcsEntityOwner.withNewSession { EcsEntityOwner.get(id).inner.items*.name.sort() }
+        EcsEntityOwner.withTransaction { EcsEntityOwner.get(id).delete(flush: true) }
+        return [first, second, countRows('ecs_entities_ecs_item')]
+    }
+
+    private List deepEntityCycle() {
+        Long id = EcsDeepEntityOwner.withTransaction {
+            EcsItem one = new EcsItem(name: 'one').save(failOnError: true)
+            EcsItem two = new EcsItem(name: 'two').save(failOnError: true)
+            new EcsDeepEntityOwner(mid: new EcsMidEntities(label: 'm', deep: new EcsDeepEntities(items: [one, two] as Set))).save(failOnError: true, flush: true).id
+        }
+        List first = EcsDeepEntityOwner.withNewSession { EcsDeepEntityOwner.get(id).mid.deep.items*.name.sort() }
+        EcsDeepEntityOwner.withTransaction {
+            EcsDeepEntityOwner owner = EcsDeepEntityOwner.get(id)
+            owner.mid.deep.items.add(new EcsItem(name: 'three').save(failOnError: true))
+            owner.save(failOnError: true, flush: true)
+        }
+        List second = EcsDeepEntityOwner.withNewSession { EcsDeepEntityOwner.get(id).mid.deep.items*.name.sort() }
+        EcsDeepEntityOwner.withTransaction { EcsDeepEntityOwner.get(id).delete(flush: true) }
+        return [first, second, countRows('ecs_deep_entities_ecs_item')]
+    }
+
+    private List nestedCycle() {
+        Long id = EcsNestedOwner.withTransaction { new EcsNestedOwner(mid: new EcsMid(x: 'm', deep: new EcsDeep(tags: ['t1', 't2'] as Set))).save(failOnError: true, flush: true).id }
+        List first = EcsNestedOwner.withNewSession { EcsNestedOwner.get(id).mid.deep.tags.sort() }
+        EcsNestedOwner.withTransaction { EcsNestedOwner owner = EcsNestedOwner.get(id); owner.mid.deep.tags.remove('t1'); owner.mid.deep.tags.add('t3'); owner.save(failOnError: true, flush: true) }
+        List second = EcsNestedOwner.withNewSession { EcsNestedOwner.get(id).mid.deep.tags.sort() }
+        EcsNestedOwner.withTransaction { EcsNestedOwner.get(id).delete(flush: true) }
+        return [first, second, countRows('ecs_deep_tags')]
+    }
+
+    private int countRows(String table) {
+        datastore.connectionSources.defaultConnectionSource.dataSource.connection.withCloseable { java.sql.Connection connection ->
+            connection.createStatement().executeQuery("select count(*) from ${table}".toString()).with { java.sql.ResultSet rows -> rows.next(); rows.getInt(1) }
+        }
+    }
+
+    void "the words of an embedded set are found by a query on the owner in both modes"() {
+        when:
+        List<List> results = [false, true].collect { boolean generated ->
+            boot([EcsSetOwner], generated)
+            EcsSetOwner.withTransaction {
+                new EcsSetOwner(inner: new EcsWords(words: ['red', 'blue'] as Set)).save(failOnError: true)
+                new EcsSetOwner(inner: new EcsWords(words: ['green'] as Set)).save(failOnError: true, flush: true)
+            }
+            EcsSetOwner.withNewSession {
+                [EcsSetOwner.count(), EcsSetOwner.list()*.inner.words.flatten().sort(), EcsSetOwner.executeQuery('select count(o) from EcsSetOwner o where o.id > 0')[0]]
+            }
+        }
+
+        then:
+        results[1] == results[0]
+        results[1] == [2, ['blue', 'green', 'red'], 2]
+    }
+
+    void "a type with a collection that two embedded properties share is rejected by the generated mode and says why"() {
+        when: "the domain binder cannot boot owners that embed the type under the same name"
+        boot([EmbeddedCollectionOwnerA, EmbeddedCollectionOwnerB], false)
+
+        then:
+        thrown(Exception)
+
+        when:
+        boot([EmbeddedCollectionOwnerA, EmbeddedCollectionOwnerB], true)
+
+        then:
+        Exception e = thrown()
+        rootOf(e) instanceof UnsupportedOperationException
+        rootOf(e).message.contains('is reachable through 2 embedded properties')
+        rootOf(e).message.contains('EmbeddedCollectionOwnerA.inner')
+        rootOf(e).message.contains('EmbeddedCollectionOwnerB.inner')
+    }
+
+    void "under different property names the domain binder boots but keeps one table with a key to the first owner, which the generated mode does not copy"() {
+        when:
+        Map<String, Map> binder = schema([EmbeddedCollectionOwnerC, EmbeddedCollectionOwnerD], false)
+
+        then: "one collection table, a foreign key to one of the owners"
+        binder.keySet().count { it.contains('words') } == 1
+        binder.values().find { it.columns.containsKey('embedded_collection_holder_id') }.foreignKeys.size() == 1
+
+        when: "the second owner stores a collection"
+        EmbeddedCollectionOwnerD.withTransaction {
+            new EmbeddedCollectionOwnerD(second: new grails.unbootable.EmbeddedCollectionHolder(words: ['w'] as Set)).save(failOnError: true, flush: true)
+        }
+
+        then: "its row violates the foreign key to the other owner"
+        thrown(Exception)
+
+        when:
+        boot([EmbeddedCollectionOwnerC, EmbeddedCollectionOwnerD], true)
+
+        then:
+        Exception e = thrown()
+        rootOf(e) instanceof UnsupportedOperationException
+        rootOf(e).message.contains('EmbeddedCollectionOwnerC.first')
+        rootOf(e).message.contains('EmbeddedCollectionOwnerD.second')
+    }
+
+    private static Throwable rootOf(Throwable e) {
+        Throwable root = e
+        while (root.cause != null && root.cause != root) {
+            root = root.cause
+        }
+        return root
+    }
+}
+
+enum EcsColor { RED, GREEN }
+
+class EcsWords {
+    Set<String> words
+    static hasMany = [words: String]
+}
+
+@Entity
+class EcsSetOwner {
+    EcsWords inner
+    static embedded = ['inner']
+}
+
+class EcsMixedWords {
+    String label
+    Set<String> words
+    static hasMany = [words: String]
+}
+
+@Entity
+class EcsMixedOwner {
+    String name
+    EcsMixedWords inner
+    static embedded = ['inner']
+}
+
+class EcsEnums {
+    Set<EcsColor> colors
+    static hasMany = [colors: EcsColor]
+}
+
+@Entity
+class EcsEnumOwner {
+    EcsEnums inner
+    static embedded = ['inner']
+}
+
+class EcsNamed {
+    Set<String> words
+    static hasMany = [words: String]
+    static mapping = {
+        words joinTable: [name: 'ecs_named_words', key: 'owner_fk', column: 'word_col']
+    }
+}
+
+@Entity
+class EcsNamedOwner {
+    EcsNamed inner
+    static embedded = ['inner']
+}
+
+class EcsDeep {
+    Set<String> tags
+    static hasMany = [tags: String]
+}
+
+class EcsMid {
+    String x
+    EcsDeep deep
+    static embedded = ['deep']
+}
+
+@Entity
+class EcsNestedOwner {
+    EcsMid mid
+    static embedded = ['mid']
+}
+
+class EcsLists {
+    List<String> items
+    static hasMany = [items: String]
+}
+
+@Entity
+class EcsListOwner {
+    EcsLists inner
+    static embedded = ['inner']
+}
+
+class EcsMaps {
+    Map<String, String> byName
+    static hasMany = [byName: String]
+}
+
+@Entity
+class EcsMapOwner {
+    EcsMaps inner
+    static embedded = ['inner']
+}
+
+@Entity
+class EcsPlainList {
+    List<String> items
+    static hasMany = [items: String]
+}
+
+@Entity
+class EcsPlainMap {
+    Map<String, String> byName
+    static hasMany = [byName: String]
+}
+
+@Entity
+class EcsItem {
+    String name
+}
+
+class EcsEntities {
+    Set<EcsItem> items
+    static hasMany = [items: EcsItem]
+}
+
+@Entity
+class EcsEntityOwner {
+    EcsEntities inner
+    static embedded = ['inner']
+}
+
+class EcsDeepEntities {
+    Set<EcsItem> items
+    static hasMany = [items: EcsItem]
+}
+
+class EcsMidEntities {
+    String label
+    EcsDeepEntities deep
+    static embedded = ['deep']
+}
+
+@Entity
+class EcsDeepEntityOwner {
+    EcsMidEntities mid
+    static embedded = ['mid']
+}
diff --git a/grails-data-hibernate7/core/src/test/groovy/grails/unbootable/EmbeddedCollectionDomains.groovy b/grails-data-hibernate7/core/src/test/groovy/grails/unbootable/EmbeddedCollectionDomains.groovy
index c32d620865..5ef6dc4cb7 100644
--- a/grails-data-hibernate7/core/src/test/groovy/grails/unbootable/EmbeddedCollectionDomains.groovy
+++ b/grails-data-hibernate7/core/src/test/groovy/grails/unbootable/EmbeddedCollectionDomains.groovy
@@ -46,3 +46,35 @@ class EmbeddedCollectionHolder {
 
     static hasMany = [words: String]
 }
+
+/**
+ * Two owners that embed the same type under different property names: the binder boots (the roles differ) but binds one collection
+ * table, whose key is a foreign key to the owner that was bound first.
+ */
+@Entity
+class EmbeddedCollectionOwnerC {
+
+    EmbeddedCollectionHolder first
+
+    static embedded = ['first']
+}
+
+@Entity
+class EmbeddedCollectionOwnerD {
+
+    EmbeddedCollectionHolder second
+
+    static embedded = ['second']
+}
+
+/**
+ * One owner that embeds the same type under two property names: the same single table again.
+ */
+@Entity
+class EmbeddedCollectionOwnerTwice {
+
+    EmbeddedCollectionHolder home
+    EmbeddedCollectionHolder work
+
+    static embedded = ['home', 'work']
+}
diff --git a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GeneratedDomainClassesDdlDifferentialSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GeneratedDomainClassesDdlDifferentialSpec.groovy
index 2c5e1cc0d7..edc0b72504 100644
--- a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GeneratedDomainClassesDdlDifferentialSpec.groovy
+++ b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GeneratedDomainClassesDdlDifferentialSpec.groovy
@@ -83,7 +83,6 @@ class GeneratedDomainClassesDdlDifferentialSpec extends Specification {
             'neither side of the many-to-many owns it',
             'is registered for the Java type',
             'names a class that is not a UserType',
-            'is a collection inside an embedded type',
             'declares a natural id but is a subclass',
             'which is HibernateEmbeddedProperty',
             'which is not a persistent property of the entity',
diff --git a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorDifferentialSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorDifferentialSpec.groovy
index 621b97914c..8e680c0873 100644
--- a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorDifferentialSpec.groovy
+++ b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorDifferentialSpec.groovy
@@ -209,7 +209,7 @@ class GrailsDomainGeneratorDifferentialSpec extends HibernateGormDatastoreSpec {
                             } else {
                                 embeddedProperties++
                                 embeddedLeaves += leaves.size()
-                                mismatches.addAll(compareEmbedded(entity, embedded, leaves, generator, bound, explicitTypes, known))
+                                mismatches.addAll(compareEmbedded(entity, embedded, leaves, generator, bound, explicitTypes, known, collections, associations))
                             }
                             continue
                         }
@@ -951,7 +951,8 @@ class GrailsDomainGeneratorDifferentialSpec extends HibernateGormDatastoreSpec {
      */
     private List<String> compareEmbedded(
             GrailsHibernatePersistentEntity entity, HibernateEmbeddedProperty property, List<EmbeddedLeaf> leaves,
-            GrailsDomainGenerator generator, Property bound, Map<String, Integer> explicitTypes, Map<String, Integer> known) {
+            GrailsDomainGenerator generator, Property bound, Map<String, Integer> explicitTypes, Map<String, Integer> known,
+            Map<String, Integer> collections, Map<String, Integer> associations) {
         List<String> found = []
         String where = "${entity.name}.${property.name}"
         Map<String, Property> boundLeaves = terminalProperties(bound).collectEntries { String path, Property leaf ->
@@ -984,6 +985,40 @@ class GrailsDomainGeneratorDifferentialSpec extends HibernateGormDatastoreSpec {
             }
             found.addAll(compareType(leafWhere, leaf.property, generator, boundLeaf, explicitTypes))
         }
+        found.addAll(compareEmbeddedCollections(where, property, (Component) bound.value, generator, explicitTypes, known, collections, associations))
+        return found
+    }
+
+    /**
+     * A collection inside an embedded type has a table of its own and no leaf in the owner's table: the facets the generator decides
+     * for it are compared with the collection the binder bound inside the component, nested components included.
+     */
+    private List<String> compareEmbeddedCollections(
+            String where, HibernateEmbeddedProperty property, Component component, GrailsDomainGenerator generator,
+            Map<String, Integer> explicitTypes, Map<String, Integer> known, Map<String, Integer> collections,
+            Map<String, Integer> associations) {
+        List<String> found = []
+        GrailsHibernatePersistentEntity type = (GrailsHibernatePersistentEntity) property.associatedEntity
+        for (HibernatePersistentProperty peer : type.getHibernatePersistentProperties(property.owner.javaClass)) {
+            Property inner = component.properties.find { Property candidate -> candidate.name == peer.name }
+            if (inner == null) {
+                continue
+            }
+            String peerWhere = "${where}.${peer.name}".toString()
+            if (peer instanceof HibernateEmbeddedProperty && inner.value instanceof Component) {
+                found.addAll(compareEmbeddedCollections(
+                        peerWhere, (HibernateEmbeddedProperty) peer, (Component) inner.value, generator, explicitTypes, known, collections, associations))
+            } else if (peer instanceof HibernateToManyEntityProperty && inner.value instanceof HibernateCollection) {
+                ToManyFacets facets = generator.toManyFacets((HibernateToManyEntityProperty) peer)
+                associations["embedded ${facets.manyToMany() ? 'many-to-many' : 'one-to-many'}, ${facets.kind()}".toString()]++
+                found.addAll(compareToMany(peerWhere, (HibernateToManyEntityProperty) peer, facets, inner, known))
+            } else if (peer instanceof HibernateBasicProperty && inner.value instanceof HibernateCollection) {
+                CollectionFacets facets = generator.collectionFacets((HibernateBasicProperty) peer)
+                collections["embedded ${facets.kind()}${peer instanceof HibernateEnumProperty ? ' of enums' : ''}".toString()]++
+                found.addAll(compareCollection(
+                        peerWhere, (HibernateBasicProperty) peer, facets, generator, (HibernateCollection) inner.value, explicitTypes, known))
+            }
+        }
         return found
     }
 
@@ -994,7 +1029,7 @@ class GrailsDomainGeneratorDifferentialSpec extends HibernateGormDatastoreSpec {
             for (Property inner : ((Component) property.value).properties) {
                 if (inner.value instanceof Component) {
                     terminalProperties(inner).each { String path, Property leaf -> result["${property.name}.${path}".toString()] = leaf }
-                } else {
+                } else if (!(inner.value instanceof HibernateCollection)) {
                     result["${property.name}.${inner.name}".toString()] = inner
                 }
             }
diff --git a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorSpec.groovy
index 59ca4f2e65..5f107fb16e 100644
--- a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorSpec.groovy
+++ b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorSpec.groovy
@@ -26,6 +26,10 @@ import java.lang.reflect.Field
 
 import grails.gorm.annotation.Entity
 import grails.gorm.tests.HibernateGormDatastoreSpec
+import grails.unbootable.EmbeddedCollectionHolder
+import grails.unbootable.EmbeddedCollectionOwnerA
+import grails.unbootable.EmbeddedCollectionOwnerB
+import grails.unbootable.EmbeddedCollectionOwnerTwice
 import grails.unbootable.NoOwnerLeft
 import grails.unbootable.NoOwnerRight
 import jakarta.persistence.AssociationOverride
@@ -162,7 +166,8 @@ class GrailsDomainGeneratorSpec extends HibernateGormDatastoreSpec {
                 GenEmbedOwner, GenEmbedOther, GenEmbedBase, GenEmbedChild, GenEmbedFormulaOwner, GenCollSingle, GenCollKinds, GenCollLazy,
                 GenFkTarget, GenFkOwned, GenFkOwner, GenFkCascades, GenFkNodeA, GenFkNodeB, GenFkHasOneOwner, GenFkHasOneDetail, GenOneFace, GenOneNose, GenFkManyOne, GenFkOneSide, GenFkSub, GenFkSubRoot,
                 GenOmParent, GenOmChild, GenOmListed, GenOmOrdered, GenOmOrphan, GenOmFetched, GenOmTag, GenOmStep, GenOmKept, GenOmSortedOwner, GenOmMapOwner, GenMapBidiOwner, GenMapBidiChild, GenEmbAssocOwner,
-                GenMmStudent, GenMmCourse, GenMmPerson, GenParamsOnly, GenDecimal, GenUnversioned, GenUnversionedRoot, GenUnversionedChild, GenConverted, GenValueTyped)
+                GenMmStudent, GenMmCourse, GenMmPerson, GenParamsOnly, GenDecimal, GenUnversioned, GenUnversionedRoot, GenUnversionedChild, GenConverted, GenValueTyped,
+                GenCollEmbeddedHolder, GenCollEmbeddedSibling, GenCollEmbeddedItem, GenCollEmbeddedEntityHolder)
     }
 
     List<StandardServiceRegistry> registries = []
@@ -1325,12 +1330,70 @@ class GrailsDomainGeneratorSpec extends HibernateGormDatastoreSpec {
         e.message.contains('collectionFacets')
     }
 
-    void "a collection of basic values inside an embedded type is rejected by name"() {
+    void "a collection of basic values inside an embedded type is an element collection of the embeddable, named after the embedded type"() {
         given:
         GrailsHibernatePersistentEntity holder = unbound(GenCollEmbeddedHolder)
+        Class<?> owner = generate(GenCollEmbeddedHolder)
+        Class<?> embeddable = owner.getDeclaredField('inner').type
+        Field words = embeddable.getDeclaredField('words')
 
         expect:
-        newGenerator().unsupportedReason(holder, holder.getHibernatePropertyByName('inner')).contains('collection inside an embedded type')
+        newGenerator().supports(holder.getHibernatePropertyByName('inner'))
+        words.isAnnotationPresent(ElementCollection)
+        words.getAnnotation(CollectionTable).name() == 'gen_coll_embedded_words'
+        words.getAnnotation(CollectionTable).joinColumns()*.name() == ['gen_coll_embedded_id']
+        words.getAnnotation(Column).name() == 'words_java_lang_string'
+        embeddable.name == 'org.grails.orm.hibernate.generated.org_grails_orm_hibernate_cfg_domainbinding_jpa_GenCollEmbedded_Embeddable'
+
+        and: "the collection has no column of the owner's table, so the owner overrides none for it"
+        newGenerator().embeddedLeaves(holder.getHibernatePropertyByName('inner') as HibernateEmbeddedProperty).isEmpty()
+        !owner.getDeclaredField('inner').isAnnotationPresent(AttributeOverrides)
+    }
+
+    void "a collection of entities inside an embedded type is a to-many of the embeddable, which takes the name of the embedded type"() {
+        given:
+        Map<GrailsHibernatePersistentEntity, Class<?>> classes = generateGroup(GenCollEmbeddedEntityHolder, GenCollEmbeddedItem)
+        Class<?> owner = classes[entity(GenCollEmbeddedEntityHolder)]
+        Class<?> embeddable = owner.getDeclaredField('inner').type
+        Field items = embeddable.getDeclaredField('items')
+
+        expect: "Hibernate retries without end the element check of a collection whose declaring class it resolves to the real embedded class"
+        embeddable.name == GenCollEmbeddedEntities.name
+        embeddable.isAnnotationPresent(Embeddable)
+        items.genericType.typeName == 'java.util.Set<' + GenCollEmbeddedItem.name + '>'
+        items.getAnnotation(ManyToMany).fetch() == FetchType.LAZY
+        items.getAnnotation(JoinTable).name() == 'gen_coll_embedded_entities_gen_coll_embedded_item'
+        items.getAnnotation(JoinTable).joinColumns()*.name() == ['gen_coll_embedded_entities_items_id']
+    }
+
+    void "an embedded type that has a collection is rejected when a second embedded property shares it"() {
+        when:
+        newGenerator().generateAll([unbound(EmbeddedCollectionOwnerA), unbound(EmbeddedCollectionOwnerB)], getClass().classLoader)
+
+        then:
+        UnsupportedOperationException e = thrown()
+        e.message.contains('The collection [words] of the embedded type [' + EmbeddedCollectionHolder.name + ']')
+        e.message.contains('is reachable through 2 embedded properties')
+        e.message.contains(EmbeddedCollectionOwnerA.name + '.inner')
+        e.message.contains(EmbeddedCollectionOwnerB.name + '.inner')
+    }
+
+    void "an owner that embeds the same type with a collection twice is rejected"() {
+        when:
+        newGenerator().generateAll([unbound(EmbeddedCollectionOwnerTwice)], getClass().classLoader)
+
+        then:
+        UnsupportedOperationException e = thrown()
+        e.message.contains('is reachable through 2 embedded properties')
+        e.message.contains(EmbeddedCollectionOwnerTwice.name + '.home')
+        e.message.contains(EmbeddedCollectionOwnerTwice.name + '.work')
+    }
+
+    void "owners that embed different types with collections are generated together"() {
+        expect:
+        generateGroup(GenCollEmbeddedHolder).size() == 1
+        newGenerator().generateAll(
+                [unbound(GenCollEmbeddedHolder), unbound(GenCollEmbeddedSibling)], getClass().classLoader).size() == 2
     }
 
     void "a to-one association is a field typed with the class generated for its target, in the same class loader"() {
@@ -2058,6 +2121,9 @@ class GenEmbedBad {
     Set<String> ref
 
     static hasMany = [ref: String]
+    static mapping = {
+        ref type: 'text'
+    }
 }
 
 @Entity
@@ -2179,6 +2245,42 @@ class GenCollEmbedded {
     static hasMany = [words: String]
 }
 
+@Entity
+class GenCollEmbeddedSibling {
+
+    GenCollOther inner
+
+    static embedded = ['inner']
+}
+
+class GenCollOther {
+
+    Set<String> tags
+
+    static hasMany = [tags: String]
+}
+
+@Entity
+class GenCollEmbeddedItem {
+
+    String name
+}
+
+@Entity
+class GenCollEmbeddedEntityHolder {
+
+    GenCollEmbeddedEntities inner
+
+    static embedded = ['inner']
+}
+
+class GenCollEmbeddedEntities {
+
+    Set<GenCollEmbeddedItem> items
+
+    static hasMany = [items: GenCollEmbeddedItem]
+}
+
 @Entity
 class GenBasic {
 
diff --git a/grails-data-hibernate7/docs/src/docs/asciidoc/advancedGORMFeatures/nativeDomainBinding.adoc b/grails-data-hibernate7/docs/src/docs/asciidoc/advancedGORMFeatures/nativeDomainBinding.adoc
index d674f4465c..97c796475c 100644
--- a/grails-data-hibernate7/docs/src/docs/asciidoc/advancedGORMFeatures/nativeDomainBinding.adoc
+++ b/grails-data-hibernate7/docs/src/docs/asciidoc/advancedGORMFeatures/nativeDomainBinding.adoc
@@ -60,6 +60,7 @@ For the domain classes in GORM's own test suites, both bindings produce the same
 * Properties mapped with a `UserType` class, and with the registered converter types `yes_no`, `true_false` and `numeric_boolean`, are stored as before.
 * A `hasMany` of basic values whose `type` is `'serializable'` or a `UserType` class is stored in one column of the owner's table, as before, with the `index`, `unique` group and column settings of the property. A `type` on a `hasMany` of domain classes changes nothing in both bindings.
 * An `index` on the property of a `hasMany` is created over the key column of the collection table (and over the element column of a collection of enums), and `indexColumn` may give the index of a list or the key of a map a `type` other than integer or string.
+* A `hasMany` inside a class listed in `static embedded` (basic values, enums or domain classes, also in a nested embedded class) is stored in a collection table named after the embedded class, not after the owner, with a key column named after the embedded class too, as with classic binding.
 * Identifier generators, inheritance strategies (single table, joined and table-per-concrete-class), discriminators, natural ids, optimistic locking, lazy and eager fetching, cascades, sorting, batch size and `cache` usage are mapped as with classic binding.
 
 [[nativeDomainBinding-refused]]
@@ -75,9 +76,9 @@ Nothing is ignored silently. A mapping that boots is bound the way it is written
 | Message of the root cause
 | What to do
 
-| A collection inside an embedded class: a `hasMany` of basic values in a class listed in `static embedded`.
-| `... is a collection inside an embedded type ...`
-| Move the collection to the owning domain class, or make the embedded class a domain class and map an association. Classic binding boots this only in some cases, and fails when two classes embed the type.
+| A collection inside an embedded class that two embedded properties share: a `hasMany` in a class listed in `static embedded`, where more than one property of the application (of one domain class or of several) embeds that class.
+| `The collection [...] of the embedded type [...] is reachable through N embedded properties (...) ...`
+| Embed the class from one property only, or make it a domain class and map an association. Classic binding names the table and the key column of the collection after the embedded class, so all the owners share one table whose foreign key points at only one of them, and it fails to boot when the owners name the property alike.
 
 | A natural id declared on a subclass.
 | `Entity [...] declares a natural id but is a subclass ...`
@@ -167,7 +168,7 @@ Some bugs of classic binding are not copied by native binding. Native binding do
 [[nativeDomainBinding-schema]]
 === Differences in the Schema
 
-The schema of 663 groups of related domain classes was created with both bindings on H2 and compared: tables, columns with type, nullability, default, length, precision and scale, primary keys with their column order, foreign keys, unique keys, indexes, check constraints and sequences. Apart from the 12 groups that native binding refuses, these are the differences. Other databases are not covered by this comparison, so verify against your own database as described below.
+The schema of 679 groups of related domain classes was created with both bindings on H2 and compared: tables, columns with type, nullability, default, length, precision and scale, primary keys with their column order, foreign keys, unique keys, indexes, check constraints and sequences. Apart from the 10 groups that native binding refuses, these are the differences. Other databases are not covered by this comparison, so verify against your own database as described below.
 
 [cols="2,3,3", options="header"]
 |===


(grails-core) 16/16: docs: document that a custom collection type takes effect with native binding

Posted by bo...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

borinquenkid pushed a commit to branch 9.0.x-h7-improvement-phase3
in repository https://gitbox.apache.org/repos/asf/grails-core.git

commit be9a58b4aa576a878705d83a4db378f834fae9d5
Author: Walter Duque de Estrada <wb...@mac.com>
AuthorDate: Thu Oct 8 14:14:03 2026 -0500

    docs: document that a custom collection type takes effect with native binding
    
    Classic binding accepts a custom collection type on a hasMany of domain
    classes but never uses the class. Native binding applies it, so the upgrade
    notes now say so in a section of their own (1.4), the manual lists it under the
    differences in behavior instead of under what stays the same, and the stale
    examples of refused mappings in section 1.3 (an index on a collection property,
    a natural id on a subclass, a collection inside an embedded class, a Map on a
    many-to-many) are removed because native binding accepts all of them now.
    
    Co-Authored-By: Claude Sonnet 5.5 <no...@anthropic.com>
---
 .../advancedGORMFeatures/nativeDomainBinding.adoc  |  4 ++-
 .../src/en/guide/upgrading/upgrading90x.adoc       | 31 +++++++++++++++++++++-
 2 files changed, 33 insertions(+), 2 deletions(-)

diff --git a/grails-data-hibernate7/docs/src/docs/asciidoc/advancedGORMFeatures/nativeDomainBinding.adoc b/grails-data-hibernate7/docs/src/docs/asciidoc/advancedGORMFeatures/nativeDomainBinding.adoc
index 657c068079..edec11f96a 100644
--- a/grails-data-hibernate7/docs/src/docs/asciidoc/advancedGORMFeatures/nativeDomainBinding.adoc
+++ b/grails-data-hibernate7/docs/src/docs/asciidoc/advancedGORMFeatures/nativeDomainBinding.adoc
@@ -66,7 +66,6 @@ For the domain classes in GORM's own test suites, both bindings produce the same
 * `params` on a registered type name such as `type: 'string'` are ignored, as with classic binding. They reach the type when it is a `UserType` that implements `ParameterizedType`.
 * A `type` that names an `AttributeConverter` class, for example `type: 'org.hibernate.type.YesNoConverter'`, converts the property with it, on a single property as well as on a `hasMany` stored in one column, as with classic binding.
 * A registered type name for a different Java type than the property's, for example `type: 'string'` on an `Integer`, gives the column of the registered type (`varchar(255)`), as with classic binding. Neither binding converts the value, so saving a value that is not null fails with a `ClassCastException` in both; a null value and the other properties of the class are stored.
-* A custom collection type (a `UserCollectionType` class) as the `type` of a `hasMany` of domain classes is applied to the collection. Classic binding boots with the same schema but never uses the class: it keeps Hibernate's own collection.
 * A natural id (`id natural: [...]`) may name an embedded object (the unique key spans its columns, in the order of the embedded class), and a name that is no property of the class is skipped without a message, as with classic binding.
 * A natural id declared on a subclass adds a unique key to the table of the hierarchy (the table of the subclass in a joined hierarchy), over the columns of the named properties in the order the mapping names them, and makes those properties updatable only when the natural id is `mutable`. Classic binding also marks the properties as natural identifiers, and with Hibernate 7 that makes every load of the subclass entity fail with a `NullPointerException`; native binding does not mark them [...]
 * Identifier generators, inheritance strategies (single table, joined and table-per-concrete-class), discriminators, natural ids, optimistic locking, lazy and eager fetching, cascades, sorting, batch size and `cache` usage are mapped as with classic binding.
@@ -133,6 +132,9 @@ Some bugs of classic binding are not copied by native binding. Native binding do
 | Mapping
 | Difference
 
+| A custom collection type (a `UserCollectionType` class) as the `type` of a `hasMany` of domain classes, for example `books type: MyCollectionType`.
+| Classic binding boots with the same schema but never uses the class: it keeps Hibernate's own collection and does not even create an instance of your type. Native binding applies the type, so Hibernate creates, wraps and loads the collection with your class. If you map a custom collection type that has never been exercised, test it before you switch. On a `hasMany` of basic values neither binding supports it.
+
 | A `unique` group that includes an enum property.
 | Classic binding drops the group. Native binding creates the unique constraint. With `dbCreate: update` the constraint is added to an existing table, and fails if the table holds duplicates.
 
diff --git a/grails-doc/src/en/guide/upgrading/upgrading90x.adoc b/grails-doc/src/en/guide/upgrading/upgrading90x.adoc
index c71f707736..9f3d83ff6d 100644
--- a/grails-doc/src/en/guide/upgrading/upgrading90x.adoc
+++ b/grails-doc/src/en/guide/upgrading/upgrading90x.adoc
@@ -100,10 +100,39 @@ The mapping can, though:
 
 * A few mapping options cannot be expressed with annotations.
   Native binding refuses such a mapping with an `UnsupportedOperationException` at startup instead of ignoring the option.
-  Examples are an `index` or a `unique` group on a collection property, a natural id on a subclass, a collection inside an embedded class, and a `Map` on a many-to-many.
+  The manual chapter lists them.
 * The generated schema differs from the one of classic binding in a few documented cases, for example a `check (idx >= 0)` constraint on the index column of every list, and the column types of foreign keys to nested composite identifiers.
 * A few defects of classic binding are not copied, such as a `unique` group that includes an enum property being dropped.
+* A custom collection type on a `hasMany` now takes effect, see <<native-binding-custom-collection-type,1.4>>.
 
 Before you switch an existing database, run `dbm-gorm-diff` against a copy of it with the setting on.
 
 The link:{hibernate7Guide}index.html#advancedGORMFeatures-nativeDomainBinding[Native Domain Binding] chapter of the GORM for Hibernate 7 manual lists every refused mapping, every schema difference and what each means for an existing database.
+
+[[native-binding-custom-collection-type]]
+===== 1.4 Custom Collection Types Take Effect with Native Binding
+
+A `hasMany` of domain classes can name a Hibernate `UserCollectionType` as its `type`:
+
+[source,groovy]
+----
+class Author {
+    static hasMany = [books: Book]
+    static mapping = {
+        books type: MyCollectionType
+    }
+}
+----
+
+Classic binding accepts this mapping and creates the usual schema, but it never uses the class.
+It does not even create an instance of `MyCollectionType`, and `author.books` is an ordinary Hibernate collection.
+
+Native binding applies the type.
+Hibernate creates, wraps and loads the collection with your class, so the code of your collection type runs where it never ran before.
+The schema is the same.
+
+If your application maps a custom collection type, test it with `hibernate.generatedDomainClasses` set to `true` before you switch.
+A type that was never exercised can fail or behave differently once it is used.
+With classic binding, which stays the default in Grails {version}, nothing changes.
+
+A custom collection type on a `hasMany` of basic values is not supported by either binding.


(grails-core) 04/16: feat: type the index of a list and the key of a map as the mapping says in native domain binding

Posted by bo...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

borinquenkid pushed a commit to branch 9.0.x-h7-improvement-phase3
in repository https://gitbox.apache.org/repos/asf/grails-core.git

commit 6895d3af6cc8bae90552e06aec69862dd45861b8
Author: Walter Duque de Estrada <wb...@mac.com>
AuthorDate: Wed Oct 7 20:05:06 2026 -0500

    feat: type the index of a list and the key of a map as the mapping says in native domain binding
    
    A map whose indexColumn (or index settings) carries a type other than string, or a list whose index
    carries a type other than integer, was refused. The domain binder types the key column of a map and the
    index column of a list with the registered type the mapping names, whatever the declared key class is.
    
    The generator now states the Java type of the key in the generics of the generated field and with
    @MapKeyJdbcTypeCode for a map, and the Java and JDBC type of the index with @ListIndexJavaType and
    @ListIndexJdbcTypeCode for a list. Hibernate sizes the index column of a list typed as a string as a
    long string, so the aligner gives it the default length of every column the domain binder creates.
    The differentials compare the Java and JDBC type of the index as well.
    
    Co-Authored-By: Claude Sonnet 5.5 <no...@anthropic.com>
---
 .../cfg/domainbinding/jpa/CollectionFacets.groovy  |   8 +-
 .../jpa/GeneratedDomainClassBinder.groovy          |  27 +++
 .../domainbinding/jpa/GrailsDomainGenerator.groovy | 114 ++++++++++--
 .../cfg/domainbinding/jpa/ToManyFacets.groovy      |   8 +-
 ...atedDomainClassesCollectionIndexTypeSpec.groovy | 194 +++++++++++++++++++++
 ...eneratedDomainClassesDdlDifferentialSpec.groovy |   1 -
 .../GrailsDomainGeneratorDifferentialSpec.groovy   |  27 +++
 .../advancedGORMFeatures/nativeDomainBinding.adoc  |   4 -
 8 files changed, 358 insertions(+), 25 deletions(-)

diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/CollectionFacets.groovy b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/CollectionFacets.groovy
index f1a34957ad..ebde8f77e4 100644
--- a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/CollectionFacets.groovy
+++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/CollectionFacets.groovy
@@ -36,6 +36,11 @@ import org.hibernate.FetchMode
  * a composite identifier; {@code referencedKeys} names the column of the owner's key each one points at, and is empty unless the
  * owner has a composite identifier. {@code key} is the first of the key columns.</p>
  *
+ * <p>{@code indexType} is the type the binder gives the index column of a list or the key column of a map when the mapping types it
+ * ({@code indexColumn: [type: 'long']}), independently of the declared key class: the Java type and the JDBC type of the registered
+ * type of that name. It is {@code null} when the mapping states none, or the type is the default (an integer for a list, a string for
+ * a map). A list's index stays an integer in Java whatever its JDBC type; the key of a map is typed with the facets' Java type.</p>
+ *
  * <p>{@code extraLazy} is an explicit {@code lazy: true}: the binder makes the collection extra-lazy, which Hibernate's annotation
  * binder cannot state, so {@code GeneratedDomainClassBinder} sets it on the collection after binding.</p>
  *
@@ -56,5 +61,6 @@ record CollectionFacets(
     int batchSize,
     String cacheUsage,
     List<ColumnFacets> keys,
-    List<String> referencedKeys) {
+    List<String> referencedKeys,
+    TypeFacets indexType) {
 }
diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GeneratedDomainClassBinder.groovy b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GeneratedDomainClassBinder.groovy
index 59f6c5ebfb..8a63ef0851 100644
--- a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GeneratedDomainClassBinder.groovy
+++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GeneratedDomainClassBinder.groovy
@@ -21,6 +21,7 @@ package org.grails.orm.hibernate.cfg.domainbinding.jpa
 import java.lang.reflect.Field
 
 import groovy.transform.CompileStatic
+import org.hibernate.Length
 import org.hibernate.MappingException
 import org.hibernate.boot.SessionFactoryBuilder
 import org.hibernate.boot.internal.InFlightMetadataCollectorImpl
@@ -46,11 +47,14 @@ import org.hibernate.mapping.Column
 import org.hibernate.mapping.Component
 import org.hibernate.mapping.ForeignKey
 import org.hibernate.mapping.GeneratorSettings
+import org.hibernate.mapping.IndexedCollection
 import org.hibernate.mapping.ManyToOne
 import org.hibernate.mapping.PersistentClass
 import org.hibernate.mapping.PrimaryKey
 import org.hibernate.mapping.Property
 import org.hibernate.mapping.RootClass
+import org.hibernate.mapping.List as HibernateList
+import org.hibernate.mapping.Selectable
 import org.hibernate.mapping.Set as HibernateSet
 import org.hibernate.mapping.Table
 import org.hibernate.mapping.UniqueKey
@@ -551,6 +555,7 @@ class GeneratedDomainClassBinder implements SessionFactoryBuilderFactory {
         if (property.value instanceof Collection && persistentProperty instanceof HibernatePersistentProperty) {
             alignExtraLazy((Collection) property.value, (HibernatePersistentProperty) persistentProperty)
             alignCollectionTable((Collection) property.value, (HibernatePersistentProperty) persistentProperty)
+            alignListIndexLength((Collection) property.value, (HibernatePersistentProperty) persistentProperty)
         }
         if (property.value instanceof Component && persistentProperty instanceof Embedded) {
             // PropertyBinder marks the property lazy when the mapping says lazy: true; @Basic(fetch = LAZY) on an @Embedded is ignored
@@ -587,6 +592,28 @@ class GeneratedDomainClassBinder implements SessionFactoryBuilderFactory {
         }
     }
 
+    /**
+     * Hibernate's annotation binder gives the index column of a list no length, which it sizes as a long string when the mapping types
+     * the index as a string (a CLOB, which cannot be a key). The domain binder's column has the default length of every column it creates.
+     */
+    private void alignListIndexLength(Collection collection, HibernatePersistentProperty property) {
+        TypeFacets indexType
+        if (property instanceof HibernateToManyEntityProperty) {
+            indexType = generator.toManyFacets((HibernateToManyEntityProperty) property).indexType()
+        } else if (property instanceof HibernateBasicProperty) {
+            indexType = generator.collectionFacets((HibernateBasicProperty) property).indexType()
+        } else {
+            return
+        }
+        if (indexType != null && collection instanceof HibernateList) {
+            for (Selectable selectable : ((IndexedCollection) collection).index.selectables) {
+                if (selectable instanceof Column) {
+                    ((Column) selectable).length = (long) Length.DEFAULT
+                }
+            }
+        }
+    }
+
     /**
      * Hibernate's annotation binder forces the key and element columns of a collection table not null ("I break the spec, but it's
      * for good"), where the domain binder leaves them nullable unless they are part of the primary key, and a set whose element
diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGenerator.groovy b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGenerator.groovy
index 16456786a0..7fa9930039 100644
--- a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGenerator.groovy
+++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGenerator.groovy
@@ -93,6 +93,9 @@ import org.hibernate.annotations.FetchMode as AnnotationFetchMode
 import org.hibernate.annotations.Formula
 import org.hibernate.annotations.Immutable
 import org.hibernate.annotations.JdbcTypeCode
+import org.hibernate.annotations.ListIndexJavaType
+import org.hibernate.annotations.ListIndexJdbcTypeCode
+import org.hibernate.annotations.MapKeyJdbcTypeCode
 import org.hibernate.annotations.NaturalId as HibernateNaturalId
 import org.hibernate.annotations.NotFound
 import org.hibernate.annotations.NotFoundAction
@@ -1215,13 +1218,9 @@ class GrailsDomainGenerator {
         if (kind == CollectionKind.MAP && type.facets != null) {
             return 'a mapped element type on a map'
         }
-        if (kind == CollectionKind.LIST && property.getIndexColumnType('integer') != 'integer') {
-            return "the index column type [${property.getIndexColumnType('integer')}] is not integer"
-        }
-        if (kind == CollectionKind.MAP && property.getIndexColumnType('string') != 'string') {
-            return "the index column of the map is mapped with the type [${property.getIndexColumnType('string')}], which the binder gives the key " +
-                    'independently of the declared key class; the generator types the key as the declared String and would need ' +
-                    '@MapKeyJdbcTypeCode to say another type, which it does not write yet'
+        String indexTypeProblem = collectionIndexTypeProblem(property, kind)
+        if (indexTypeProblem != null) {
+            return indexTypeProblem
         }
         ColumnFacets key = collectionKeyFacets(property)
         if (key.length() != null || key.precision() != null || key.scale() != null || key.defaultValue() != null ||
@@ -1345,7 +1344,8 @@ class GrailsDomainGenerator {
                 Math.max(property.batchSize, 0),
                 property.cacheUsage,
                 collectionKeyColumns(property),
-                collectionKeyReferencedColumns(property))
+                collectionKeyReferencedColumns(property),
+                collectionIndexType(property, kind))
     }
 
     /**
@@ -1442,6 +1442,54 @@ class GrailsDomainGenerator {
                 column.precision?.intValue(), column.scale?.intValue(), column.sqlType, null, null, null, null)
     }
 
+    /**
+     * The binder types the index column of a list and the key column of a map with the type the mapping names
+     * ({@code indexColumn: [type: 'long']}, or the {@code type} of the {@code index:} settings), whatever the declared key class is.
+     *
+     * @return why the generator cannot state that type, or {@code null} when it can (or the mapping states none)
+     */
+    private String collectionIndexTypeProblem(HibernateToManyProperty property, CollectionKind kind) {
+        if (!kind.indexed) {
+            return null
+        }
+        String name = property.getIndexColumnType(kind == CollectionKind.LIST ? 'integer' : 'string')
+        BasicType<?> registered = typeConfiguration.basicTypeRegistry.getRegisteredType(name)
+        if (registered == null) {
+            return "the index column type [${name}] is not a type registered with Hibernate, which is the only kind of type the generator " +
+                    'states for the index of a list or the key of a map'
+        }
+        if (registered.valueConverter != null) {
+            return "the index column type [${name}] is a registered type that converts its value, which no annotation states for the " +
+                    'index of a list or the key of a map'
+        }
+        if (kind == CollectionKind.LIST) {
+            Class<?> descriptor = javaTypeDescriptorClass(registered.javaTypeDescriptor.javaTypeClass)
+            if (!Modifier.isPublic(descriptor.modifiers) || descriptor.constructors.every { it.parameterCount != 0 }) {
+                return "the index column type [${name}] has a Java type descriptor [${descriptor.simpleName}] that Hibernate cannot instantiate " +
+                        'from an annotation, which is how the generator types the index of a list'
+            }
+        }
+        return null
+    }
+
+    /**
+     * @return the type of the index column of a list or the key column of a map when the mapping types it with other than the
+     *     default (an integer for a list, a string for a map), as the Java and the JDBC type of the registered type of that name;
+     *     {@code null} otherwise
+     */
+    private TypeFacets collectionIndexType(HibernateToManyProperty property, CollectionKind kind) {
+        if (!kind.indexed) {
+            return null
+        }
+        String defaultName = kind == CollectionKind.LIST ? 'integer' : 'string'
+        String name = property.getIndexColumnType(defaultName)
+        if (name == defaultName) {
+            return null
+        }
+        BasicType<?> registered = typeConfiguration.basicTypeRegistry.getRegisteredType(name)
+        return new TypeFacets(null, registered.jdbcType.defaultSqlTypeCode, [:], registered.javaTypeDescriptor.javaTypeClass)
+    }
+
     /**
      * Mirrors {@code ListSecondPassBinder} (a nullable index column named by {@code getIndexColumnName}) and
      * {@code MapSecondPassBinder} (the same name, plus the index column config's facets); {@code null} for a set or a bag.
@@ -1566,13 +1614,9 @@ class GrailsDomainGenerator {
                         'write expression or a comment, which a join column cannot state'
             }
         }
-        if (kind == CollectionKind.LIST && property.getIndexColumnType('integer') != 'integer') {
-            return "the index column type [${property.getIndexColumnType('integer')}] is not integer"
-        }
-        if (kind == CollectionKind.MAP && property.getIndexColumnType('string') != 'string') {
-            return "the index column of the map is mapped with the type [${property.getIndexColumnType('string')}], which the binder gives the key " +
-                    'independently of the declared key class; the generator types the key as the declared String and would need ' +
-                    '@MapKeyJdbcTypeCode to say another type, which it does not write yet'
+        String indexTypeProblem = collectionIndexTypeProblem(property, kind)
+        if (indexTypeProblem != null) {
+            return indexTypeProblem
         }
         if (property.hasSort()) {
             if (kind.indexed) {
@@ -1687,7 +1731,8 @@ class GrailsDomainGenerator {
                 property.hasSort() ? (property.order != null ? property.order : 'asc') : null,
                 condition,
                 keys != null ? keys : [key],
-                keys != null ? collectionKeyReferencedColumns(property) : [])
+                keys != null ? collectionKeyReferencedColumns(property) : [],
+                collectionIndexType(property, kind))
     }
 
     /**
@@ -2538,8 +2583,10 @@ class GrailsDomainGenerator {
                     .define('name', facets.index().name())
                     .define('nullable', facets.index().nullable())
                     .build()
+            annotations.addAll(listIndexTypeAnnotations(facets.indexType()))
         } else if (facets.kind() == CollectionKind.MAP) {
             annotations << mapKeyColumnAnnotation(facets.index())
+            annotations.addAll(mapKeyTypeAnnotations(facets.indexType()))
         }
         if (facets.kind() == CollectionKind.SORTED_SET) {
             // the binder marks the collection sorted and names no comparator: the elements' natural order
@@ -2573,7 +2620,7 @@ class GrailsDomainGenerator {
             annotations << AnnotationDescription.ForLoadedAnnotation.of(constraint)
         }
         List<TypeDescription> arguments = facets.kind() == CollectionKind.MAP ?
-                [TypeDescription.ForLoadedType.of(String), generatedType(property.hibernateAssociatedEntity)] :
+                [TypeDescription.ForLoadedType.of(mapKeyClass(facets.indexType())), generatedType(property.hibernateAssociatedEntity)] :
                 [generatedType(property.hibernateAssociatedEntity)]
         TypeDescription.Generic fieldType = TypeDescription.Generic.Builder.parameterizedType(
                 TypeDescription.ForLoadedType.of(facets.kind().javaType), arguments).build()
@@ -2646,8 +2693,10 @@ class GrailsDomainGenerator {
                     .define('name', facets.index().name())
                     .define('nullable', facets.index().nullable())
                     .build()
+            annotations.addAll(listIndexTypeAnnotations(facets.indexType()))
         } else if (facets.kind() == CollectionKind.MAP) {
             annotations << mapKeyColumnAnnotation(facets.index())
+            annotations.addAll(mapKeyTypeAnnotations(facets.indexType()))
         }
         TypeFacets type = typeFacets(property)
         if (type != null) {
@@ -2670,7 +2719,7 @@ class GrailsDomainGenerator {
         Class<?> elementClass = property instanceof HibernateEnumProperty ?
                 ((HibernateEnumProperty) property).enumType : ((HibernateBasicProperty) property).componentType
         TypeDescription.Generic fieldType = facets.kind() == CollectionKind.MAP ?
-                TypeDescription.Generic.Builder.parameterizedType(Map, String, elementClass).build() :
+                TypeDescription.Generic.Builder.parameterizedType(Map, mapKeyClass(facets.indexType()), elementClass).build() :
                 TypeDescription.Generic.Builder.parameterizedType(facets.kind().javaType, elementClass).build()
         return builder.defineField(property.name, fieldType, Visibility.PRIVATE)
                 .annotateField(annotations as AnnotationDescription[])
@@ -2701,6 +2750,35 @@ class GrailsDomainGenerator {
         return annotation.build()
     }
 
+    /** The Java type of the key of a map: the declared one is a string, the mapping can type the key column with another registered type. */
+    private static Class<?> mapKeyClass(TypeFacets indexType) {
+        return indexType == null ? String : indexType.javaType()
+    }
+
+    /** The Java and the JDBC type of the index column of a list, which the binder types as the mapping says, as it types the key of a map. */
+    private List<AnnotationDescription> listIndexTypeAnnotations(TypeFacets indexType) {
+        if (indexType == null) {
+            return []
+        }
+        return [
+                AnnotationDescription.Builder.ofType(ListIndexJavaType).define('value', TypeDescription.ForLoadedType.of(javaTypeDescriptorClass(indexType.javaType()))).build(),
+                AnnotationDescription.Builder.ofType(ListIndexJdbcTypeCode).define('value', indexType.jdbcTypeCode()).build(),
+        ]
+    }
+
+    /** The class of Hibernate's Java type descriptor of a Java type, which an annotation names and Hibernate instantiates. */
+    private Class<?> javaTypeDescriptorClass(Class<?> javaType) {
+        return typeConfiguration.javaTypeRegistry.getDescriptor(javaType).getClass()
+    }
+
+    /** The JDBC type of the key column of a map; its Java type is the key of the generated field. */
+    private static List<AnnotationDescription> mapKeyTypeAnnotations(TypeFacets indexType) {
+        if (indexType == null) {
+            return []
+        }
+        return [AnnotationDescription.Builder.ofType(MapKeyJdbcTypeCode).define('value', indexType.jdbcTypeCode()).build()]
+    }
+
     private static AnnotationDescription mapKeyColumnAnnotation(ColumnFacets facets) {
         AnnotationDescription.Builder annotation = AnnotationDescription.Builder.ofType(MapKeyColumn)
                 .define('name', facets.name())
diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/ToManyFacets.groovy b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/ToManyFacets.groovy
index 2acc0b2fc6..0b5bd9502a 100644
--- a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/ToManyFacets.groovy
+++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/ToManyFacets.groovy
@@ -44,6 +44,11 @@ import org.hibernate.FetchMode
  * a composite identifier; {@code referencedKeys} names the column of the owner's key each one points at, and is empty unless the
  * owner has a composite identifier. {@code key} is the first of the key columns.</p>
  *
+ * <p>{@code indexType} is the type the binder gives the index column of a list or the key column of a map when the mapping types it
+ * ({@code indexColumn: [type: 'long']}), independently of the declared key class: the Java type and the JDBC type of the registered
+ * type of that name. It is {@code null} when the mapping states none, or the type is the default (an integer for a list, a string for
+ * a map). A list's index stays an integer in Java whatever its JDBC type; the key of a map is typed with the facets' Java type.</p>
+ *
  * <p>{@code extraLazy} is an explicit {@code lazy: true}: the binder makes the collection extra-lazy, which Hibernate's annotation
  * binder cannot state, so {@code GeneratedDomainClassBinder} sets it on the collection after binding.</p>
  *
@@ -71,5 +76,6 @@ record ToManyFacets(
     String orderDirection,
     String tenantCondition,
     List<ColumnFacets> keys,
-    List<String> referencedKeys) {
+    List<String> referencedKeys,
+    TypeFacets indexType) {
 }
diff --git a/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/generated/GeneratedDomainClassesCollectionIndexTypeSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/generated/GeneratedDomainClassesCollectionIndexTypeSpec.groovy
new file mode 100644
index 0000000000..d30cb11fad
--- /dev/null
+++ b/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/generated/GeneratedDomainClassesCollectionIndexTypeSpec.groovy
@@ -0,0 +1,194 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package grails.gorm.tests.generated
+
+import java.util.concurrent.atomic.AtomicInteger
+
+import grails.gorm.annotation.Entity
+import org.hibernate.mapping.Table
+import spock.lang.AutoCleanup
+import spock.lang.Specification
+import spock.lang.Unroll
+
+import org.grails.orm.hibernate.HibernateDatastore
+
+/**
+ * The index column of a list and the key column of a map are typed by the mapping ({@code indexColumn: [type: 'long']}, or the
+ * {@code type} of the {@code index:} settings), independently of the declared key class, and the domain binder gives them that
+ * type. The generated classes state it with Hibernate's own annotations, so a database created by the binder keeps its schema and
+ * the keys of a map are read back as the type the column holds.
+ */
+class GeneratedDomainClassesCollectionIndexTypeSpec extends Specification {
+
+    static final AtomicInteger BOOTS = new AtomicInteger()
+
+    @AutoCleanup
+    HibernateDatastore datastore
+
+    private HibernateDatastore boot(List<Class> group, boolean generated) {
+        datastore?.close()
+        datastore = new HibernateDatastore([
+                'dataSource.url'                  : "jdbc:h2:mem:cit${BOOTS.incrementAndGet()};LOCK_TIMEOUT=10000".toString(),
+                'dataSource.dbCreate'             : 'create-drop',
+                'hibernate.generatedDomainClasses': generated,
+        ], group as Class[])
+        return datastore
+    }
+
+    /** The collection tables of the boot model as plain data: every column with its SQL type, nullability and length. */
+    private Map<String, Map> schema(List<Class> group, boolean generated) {
+        HibernateDatastore booted = boot(group, generated)
+        Map<String, Map> result = new TreeMap<String, Map>()
+        for (Table table : booted.metadata.collectTableMappings()) {
+            if (table.physicalTable) {
+                result[table.name] = [
+                        columns   : table.columns.collectEntries { org.hibernate.mapping.Column column ->
+                            // a primary key column is not null in the schema whatever the column says
+                            boolean nullable = column.nullable && !(table.primaryKey != null && table.primaryKey.columns*.name.contains(column.name))
+                            [(column.name): "${column.getSqlType(booted.metadata)}${nullable ? '' : ' not null'}".toString()]
+                        },
+                        primaryKey: table.primaryKey?.columns*.name?.sort(),
+                ]
+            }
+        }
+        return result
+    }
+
+    @Unroll
+    void "#label is typed in the generated mode as the domain binder types it"() {
+        when:
+        Map<String, Map> binder = schema(group, false)
+        Map<String, Map> generated = schema(group, true)
+
+        then: "the column and the key it is part of are the binder's (the binder leaves an unused column in the table of a map of values)"
+        binder[table].columns[column] == type
+        generated[table].columns[column] == type
+        generated[table].primaryKey == binder[table].primaryKey
+        generated.keySet() == binder.keySet()
+
+        where:
+        label                                              | group                  | table                  | column  | type
+        'the key of a map of entities, mapped as long'     | [CitLongMap, CitValue] | 'cit_long_map_tags'    | 'tags_idx' | 'long not null'
+        'the key of a map of values, mapped as long'       | [CitValueMap]          | 'cit_value_map_attrs'  | 'k'     | 'long not null'
+        'the key of a map, mapped as integer and named'    | [CitIntegerMap]        | 'cit_integer_map_attrs' | 'k'    | 'integer not null'
+        'the key of a map, with a length'                  | [CitLengthMap]         | 'cit_length_map_attrs' | 'k'     | 'varchar(40) not null'
+        'the index of a list, mapped as long'              | [CitLongList]          | 'cit_long_list_items'  | 'ix'    | 'bigint not null'
+        'the index of a list, mapped as string'            | [CitStringList]        | 'cit_string_list_items' | 'ix'   | 'varchar(255) not null'
+        'the index of a list of entities, mapped as long'  | [CitEntityList, CitValue] | 'cit_entity_list_cit_value' | 'ix' | 'bigint not null'
+    }
+
+    void "a map keyed by long saves, reloads and is queried with the keys the column holds"() {
+        given:
+        boot([CitLongMap, CitValue], true)
+
+        when:
+        Long id = CitLongMap.withTransaction {
+            CitLongMap owner = new CitLongMap()
+            owner.tags = [(1L): new CitValue(name: 'one').save(failOnError: true), (22L): new CitValue(name: 'two').save(failOnError: true)]
+            owner.save(failOnError: true, flush: true).id
+        }
+
+        then:
+        CitLongMap.withNewSession {
+            Map<Long, CitValue> loaded = CitLongMap.get(id).tags
+            loaded.keySet() == [1L, 22L] as Set && loaded[22L].name == 'two'
+        }
+    }
+
+    void "the index of a list mapped as long keeps the order of the elements"() {
+        given:
+        boot([CitLongList, CitStringList], true)
+
+        when:
+        Long longId = CitLongList.withTransaction { new CitLongList(items: ['c', 'a', 'b']).save(failOnError: true, flush: true).id }
+        Long stringId = CitStringList.withTransaction { new CitStringList(items: ['z', 'y']).save(failOnError: true, flush: true).id }
+
+        then:
+        CitLongList.withNewSession { CitLongList.get(longId).items } == ['c', 'a', 'b']
+        CitStringList.withNewSession { CitStringList.get(stringId).items } == ['z', 'y']
+    }
+}
+
+@Entity
+class CitValue {
+    String name
+}
+
+@Entity
+class CitLongMap {
+    Map<Long, CitValue> tags
+    static hasMany = [tags: CitValue]
+    static mapping = {
+        tags indexColumn: [type: 'long']
+    }
+}
+
+@Entity
+class CitValueMap {
+    Map<String, String> attrs
+    static hasMany = [attrs: String]
+    static mapping = {
+        attrs indexColumn: [type: 'long', name: 'k']
+    }
+}
+
+@Entity
+class CitIntegerMap {
+    Map<Integer, String> attrs
+    static hasMany = [attrs: String]
+    static mapping = {
+        attrs indexColumn: [type: 'integer', name: 'k']
+    }
+}
+
+@Entity
+class CitLengthMap {
+    Map<String, String> attrs
+    static hasMany = [attrs: String]
+    static mapping = {
+        attrs indexColumn: [name: 'k', length: 40]
+    }
+}
+
+@Entity
+class CitLongList {
+    List<String> items
+    static hasMany = [items: String]
+    static mapping = {
+        items indexColumn: [type: 'long', name: 'ix']
+    }
+}
+
+@Entity
+class CitStringList {
+    List<String> items
+    static hasMany = [items: String]
+    static mapping = {
+        items indexColumn: [type: 'string', name: 'ix']
+    }
+}
+
+@Entity
+class CitEntityList {
+    List<CitValue> values
+    static hasMany = [values: CitValue]
+    static mapping = {
+        values indexColumn: [type: 'long', name: 'ix']
+    }
+}
diff --git a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GeneratedDomainClassesDdlDifferentialSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GeneratedDomainClassesDdlDifferentialSpec.groovy
index 5db18f52d3..c91853f0cc 100644
--- a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GeneratedDomainClassesDdlDifferentialSpec.groovy
+++ b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GeneratedDomainClassesDdlDifferentialSpec.groovy
@@ -88,7 +88,6 @@ class GeneratedDomainClassesDdlDifferentialSpec extends Specification {
             'which is HibernateEmbeddedProperty',
             'which is not a persistent property of the entity',
             'is a registered type with type parameters',
-            'the index column of the map is mapped with the type',
             'a map on a many-to-many',
     ]
 
diff --git a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorDifferentialSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorDifferentialSpec.groovy
index 620e26ab98..e036390f7f 100644
--- a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorDifferentialSpec.groovy
+++ b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorDifferentialSpec.groovy
@@ -18,6 +18,8 @@
  */
 package org.grails.orm.hibernate.cfg.domainbinding.jpa
 
+import java.sql.Types
+
 import jakarta.persistence.InheritanceType
 import grails.gorm.tests.HibernateGormDatastoreSpec
 import org.hibernate.FetchMode
@@ -822,6 +824,7 @@ class GrailsDomainGeneratorDifferentialSpec extends HibernateGormDatastoreSpec {
             if (collection instanceof IndexedCollection) {
                 found.addAll(compareValueColumn(
                         "${where} index".toString(), facets.index(), ((IndexedCollection) collection).index, collection.collectionTable, false))
+                found.addAll(compareIndexType("${where} index".toString(), facets.kind(), facets.indexType(), (IndexedCollection) collection))
             } else {
                 found << "${where} index: generator=${facets.index().name()} binder=none".toString()
             }
@@ -1666,6 +1669,7 @@ class GrailsDomainGeneratorDifferentialSpec extends HibernateGormDatastoreSpec {
             if (collection instanceof IndexedCollection) {
                 found.addAll(compareValueColumn(
                         "${where} index".toString(), facets.index(), ((IndexedCollection) collection).index, collection.collectionTable, false))
+                found.addAll(compareIndexType("${where} index".toString(), facets.kind(), facets.indexType(), (IndexedCollection) collection))
             } else {
                 found << "${where} index: generator=${facets.index().name()} binder=none".toString()
             }
@@ -1675,6 +1679,29 @@ class GrailsDomainGeneratorDifferentialSpec extends HibernateGormDatastoreSpec {
         return found
     }
 
+    /**
+     * The type the binder gave the index column of a list or the key column of a map against the one the generator decided: the JDBC type
+     * and the Java type of both.
+     */
+    private static List<String> compareIndexType(String where, CollectionKind kind, TypeFacets indexType, IndexedCollection collection) {
+        if (!(collection.index instanceof BasicValue)) {
+            return []
+        }
+        BasicValue index = (BasicValue) collection.index
+        List<String> found = []
+        int expectedJdbc = indexType != null ? indexType.jdbcTypeCode() : (kind == CollectionKind.LIST ? Types.INTEGER : Types.VARCHAR)
+        int boundJdbc = index.resolve().jdbcType.defaultSqlTypeCode
+        if (expectedJdbc != boundJdbc) {
+            found << "${where} jdbcTypeCode: generator=${expectedJdbc} binder=${boundJdbc}".toString()
+        }
+        Class<?> expectedJava = indexType != null ? indexType.javaType() : (kind == CollectionKind.LIST ? Integer : String)
+        Class<?> boundJava = index.resolve().domainJavaType.javaTypeClass
+        if (expectedJava != boundJava) {
+            found << "${where} javaType: generator=${expectedJava.name} binder=${boundJava.name}".toString()
+        }
+        return found
+    }
+
     /**
      * A collection of entities against the one Hibernate's annotation binder built from the generated field: the same kind,
      * ownership, join table, key, element and index columns, fetching, cascade, ordering and filters.
diff --git a/grails-data-hibernate7/docs/src/docs/asciidoc/advancedGORMFeatures/nativeDomainBinding.adoc b/grails-data-hibernate7/docs/src/docs/asciidoc/advancedGORMFeatures/nativeDomainBinding.adoc
index 887b6ec058..981de8025c 100644
--- a/grails-data-hibernate7/docs/src/docs/asciidoc/advancedGORMFeatures/nativeDomainBinding.adoc
+++ b/grails-data-hibernate7/docs/src/docs/asciidoc/advancedGORMFeatures/nativeDomainBinding.adoc
@@ -101,10 +101,6 @@ Nothing is ignored silently. A mapping that boots is bound the way it is written
 | `Association property [...]: a map on a many-to-many ...`
 | Use a `Set` or `List` many-to-many, or model the join with a domain class of its own.
 
-| A map whose `indexColumn` has a `type` other than string, or a list whose `indexColumn` has a `type` other than integer.
-| `... the index column of the map is mapped with the type [long] ...` or `... the index column type [long] is not integer`
-| Remove the `type` from `indexColumn`. Use `String` map keys and the default list index.
-
 | A `type` that is a registered Hibernate type name for a different Java type than the property, for example `type: 'serializable'` on a `String`.
 | `Type [serializable] of property [...] is registered for the Java type [java.io.Serializable], not the property's [java.lang.String] ...`
 | Use a `UserType` class, or a type name registered for the property's own type.


(grails-core) 15/16: feat: accept a registered type name for a different Java type in native domain binding

Posted by bo...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

borinquenkid pushed a commit to branch 9.0.x-h7-improvement-phase3
in repository https://gitbox.apache.org/repos/asf/grails-core.git

commit ff9292e942f0a420fc2345ca3e4ba8fe5f255060
Author: Walter Duque de Estrada <wb...@mac.com>
AuthorDate: Thu Oct 8 02:35:21 2026 -0500

    feat: accept a registered type name for a different Java type in native domain binding
    
    The generator refused type: 'string' on an Integer (and the like) because the type Hibernate
    registers under that name is for another Java class than the property's. The domain binder boots
    it: it resolves the name and not the property's class, so the column is the registered type's
    (varchar(255) for string, integer for integer, clob for materialized_clob), and saving a value that
    is not null fails with a ClassCastException because the type cannot unwrap it, while null and the
    other properties are stored.
    
    The generator now types the field with the registered Java type and its JDBC type whenever the
    registered type does not convert its value, which is what it already did for serializable. The
    real class is still read and written through its own accessors, so the failure on save is the
    binder's too, and the spec compares both modes on the column and on that outcome. The DDL
    differential now boots every group the domain binder boots.
    
    Co-Authored-By: Claude Sonnet 5.5 <no...@anthropic.com>
---
 .../domainbinding/jpa/GrailsDomainGenerator.groovy |  20 +--
 .../GeneratedDomainClassesTypeMismatchSpec.groovy  | 185 +++++++++++++++++++++
 ...eneratedDomainClassesDdlDifferentialSpec.groovy |   1 -
 .../jpa/GrailsDomainGeneratorSpec.groovy           |  22 +--
 .../advancedGORMFeatures/nativeDomainBinding.adoc  |   7 +-
 5 files changed, 198 insertions(+), 37 deletions(-)

diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGenerator.groovy b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGenerator.groovy
index 9212bc05d5..c038eb92e9 100644
--- a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGenerator.groovy
+++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGenerator.groovy
@@ -1138,8 +1138,9 @@ class GrailsDomainGenerator {
             return new TypeDecision(true, name, new TypeFacets(
                     null, registered.jdbcType.defaultSqlTypeCode, [:], null, registered.valueConverter.getClass()))
         }
-        if (registered != null && registered.valueConverter == null && !isEnum &&
-                (identity || registered.javaTypeDescriptor.javaTypeClass == boxed(type) || serializesValueOf(registered, type))) {
+        if (registered != null && registered.valueConverter == null && !isEnum) {
+            // the binder resolves the name and not the property's class: the field has the registered Java type, whatever the
+            // property's class is (a String or a byte[] for serializable, an Integer for string), with the registered JDBC type
             Class<?> registeredJava = registered.javaTypeDescriptor.javaTypeClass
             return new TypeDecision(true, name, new TypeFacets(
                     null, registered.jdbcType.defaultSqlTypeCode, [:], registeredJava == boxed(type) ? null : registeredJava))
@@ -1149,12 +1150,9 @@ class GrailsDomainGenerator {
             problem = 'is neither a UserType class nor a type registered with Hibernate'
         } else if (isEnum) {
             problem = 'is a registered type on an enum, which the binder binds with its own type parameters'
-        } else if (registered.valueConverter != null) {
+        } else {
             problem = 'is a registered type that converts its value in a way @Convert does not state (not a JPA attribute converter, ' +
                     'or the type of a collection element, an identifier or an enum)'
-        } else {
-            problem = "is registered for the Java type [${registered.javaTypeDescriptor.javaTypeClass.name}], not the property's [${type?.name}]: " +
-                    'stating it would need a converter, which annotations do not say for a type name'
         }
         return new TypeDecision(false, name, null, problem)
     }
@@ -1168,16 +1166,6 @@ class GrailsDomainGenerator {
         return new TypeDecision(true, 'serializable', new TypeFacets(null, Types.VARBINARY, [:], Serializable))
     }
 
-    /**
-     * {@code serializable} (and any registered type of a Java interface) on a property of a class that implements it: the property is
-     * typed with the registered Java type, as the binder resolves the type name and not the property's class, so a {@code String} or a
-     * {@code byte[]} is read and written through that type.
-     */
-    private static boolean serializesValueOf(BasicType<?> registered, Class<?> type) {
-        Class<?> registeredJava = registered.javaTypeDescriptor.javaTypeClass
-        return registeredJava.interface && type != null && registeredJava.isAssignableFrom(boxed(type))
-    }
-
     /**
      * A {@code type} naming an {@code AttributeConverter}: the binder resolves the name to the converter and binds the property with it,
      * whatever the property's class is. {@code @Convert} states it, on a field of the Java type the converter converts when the
diff --git a/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/generated/GeneratedDomainClassesTypeMismatchSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/generated/GeneratedDomainClassesTypeMismatchSpec.groovy
new file mode 100644
index 0000000000..e871fe3c0c
--- /dev/null
+++ b/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/generated/GeneratedDomainClassesTypeMismatchSpec.groovy
@@ -0,0 +1,185 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package grails.gorm.tests.generated
+
+import java.util.concurrent.atomic.AtomicInteger
+
+import grails.gorm.annotation.Entity
+import org.hibernate.mapping.Table
+import spock.lang.AutoCleanup
+import spock.lang.Specification
+import spock.lang.Unroll
+
+import org.grails.orm.hibernate.HibernateDatastore
+
+/**
+ * A registered type name for a different Java type than the property's, for example {@code type: 'string'} on an {@code Integer}. The
+ * domain binder resolves the name and not the property's class: the column has the registered type's SQL type, and the type cannot
+ * convert the value, so a value that is not null fails when it is saved with a {@code ClassCastException}. The generated mode boots with the
+ * same column and fails the same way.
+ */
+class GeneratedDomainClassesTypeMismatchSpec extends Specification {
+
+    static final AtomicInteger BOOTS = new AtomicInteger()
+
+    @AutoCleanup
+    HibernateDatastore datastore
+
+    private HibernateDatastore boot(List<Class> group, boolean generated) {
+        datastore?.close()
+        datastore = new HibernateDatastore([
+                'dataSource.url'                  : "jdbc:h2:mem:tm${BOOTS.incrementAndGet()};LOCK_TIMEOUT=10000".toString(),
+                'dataSource.dbCreate'             : 'create-drop',
+                'hibernate.generatedDomainClasses': generated,
+        ], group as Class[])
+        return datastore
+    }
+
+    /** The tables of the boot model as plain data: columns with SQL type and nullability. */
+    private Map<String, Map> schema(HibernateDatastore booted) {
+        Map<String, Map> result = new TreeMap<String, Map>()
+        for (Table table : booted.metadata.collectTableMappings()) {
+            if (table.physicalTable) {
+                result[table.name] = [
+                        columns   : table.columns.collectEntries { org.hibernate.mapping.Column column ->
+                            boolean nullable = column.nullable && !(table.primaryKey != null && table.primaryKey.columns*.name.contains(column.name))
+                            [(column.name): "${column.getSqlType(booted.metadata)}${nullable ? '' : ' not null'}".toString()]
+                        },
+                        primaryKey: table.primaryKey?.columns*.name?.sort(),
+                ]
+            }
+        }
+        return result
+    }
+
+    private static String attempt(Closure<?> step) {
+        try {
+            return "ok: ${step.call()}".toString()
+        } catch (Throwable e) {
+            Throwable root = e
+            while (root.cause != null && root.cause != root) {
+                root = root.cause
+            }
+            return "failed: ${root.class.simpleName}".toString()
+        }
+    }
+
+    @Unroll
+    void "#label has the column type the domain binder gives it"() {
+        when:
+        Map<String, Map> binder = schema(boot([type], false))
+        Map<String, Map> generated = schema(boot([type], true))
+
+        then:
+        generated == binder
+        generated[table].columns.tag.startsWith(sqlType)
+
+        where:
+        label                                      | type          | table          | sqlType
+        'a string type on an Integer'              | TmStringOnInt | 'tm_string_on_int' | 'varchar(255)'
+        'an integer type on a String'              | TmIntOnString | 'tm_int_on_string' | 'integer'
+        'a long type on an Integer'                | TmLongOnInt   | 'tm_long_on_int'   | 'bigint'
+        'a boolean type on a String'               | TmBoolOnString | 'tm_bool_on_string' | 'boolean'
+        'a text type on an Integer'                | TmTextOnInt   | 'tm_text_on_int'   | 'varchar(32600)'
+        'a timestamp type on a Long'               | TmDateOnLong  | 'tm_date_on_long'  | 'timestamp'
+        'a materialized_clob type on an Integer'   | TmClobOnInt   | 'tm_clob_on_int'   | 'clob'
+    }
+
+    @Unroll
+    void "#label fails to save a value as the domain binder does, and stores null and the other properties"() {
+        when:
+        Map<Boolean, Map> outcome = [false, true].collectEntries { boolean generated ->
+            boot([type], generated)
+            Map steps = [:]
+            steps.saveValue = attempt { type.withTransaction { type.newInstance(tag: value, name: 'a').save(failOnError: true, flush: true).id } }
+            Long id = null
+            steps.saveNull = attempt { id = type.withTransaction { type.newInstance(name: 'b').save(failOnError: true, flush: true).id } }
+            steps.reload = attempt { type.withNewSession { Object found = type.get(id); [found.name, found.tag] } }
+            [(generated): steps]
+        }
+
+        then:
+        outcome[true] == outcome[false]
+        outcome[true].saveValue == 'failed: ClassCastException'
+        outcome[true].reload.startsWith('ok: [b, null]')
+
+        where:
+        label                       | type           | value
+        'a string type on an Integer' | TmStringOnInt | 42
+        'an integer type on a String' | TmIntOnString | '42'
+        'a long type on an Integer'   | TmLongOnInt   | 42
+        'a boolean type on a String'  | TmBoolOnString | 'x'
+    }
+}
+
+@Entity
+class TmStringOnInt {
+    String name
+    Integer tag
+    static constraints = { name nullable: true; tag nullable: true }
+    static mapping = { tag type: 'string' }
+}
+
+@Entity
+class TmIntOnString {
+    String name
+    String tag
+    static constraints = { name nullable: true; tag nullable: true }
+    static mapping = { tag type: 'integer' }
+}
+
+@Entity
+class TmLongOnInt {
+    String name
+    Integer tag
+    static constraints = { name nullable: true; tag nullable: true }
+    static mapping = { tag type: 'long' }
+}
+
+@Entity
+class TmBoolOnString {
+    String name
+    String tag
+    static constraints = { name nullable: true; tag nullable: true }
+    static mapping = { tag type: 'boolean' }
+}
+
+@Entity
+class TmTextOnInt {
+    String name
+    Integer tag
+    static constraints = { name nullable: true; tag nullable: true }
+    static mapping = { tag type: 'text' }
+}
+
+@Entity
+class TmDateOnLong {
+    String name
+    Long tag
+    static constraints = { name nullable: true; tag nullable: true }
+    static mapping = { tag type: 'timestamp' }
+}
+
+@Entity
+class TmClobOnInt {
+    String name
+    Integer tag
+    static constraints = { name nullable: true; tag nullable: true }
+    static mapping = { tag type: 'materialized_clob' }
+}
diff --git a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GeneratedDomainClassesDdlDifferentialSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GeneratedDomainClassesDdlDifferentialSpec.groovy
index b8470381de..b29aa1a7ec 100644
--- a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GeneratedDomainClassesDdlDifferentialSpec.groovy
+++ b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GeneratedDomainClassesDdlDifferentialSpec.groovy
@@ -82,7 +82,6 @@ class GeneratedDomainClassesDdlDifferentialSpec extends Specification {
      */
     private static final List<String> REFUSALS = [
             'neither side of the many-to-many owns it',
-            'is registered for the Java type',
     ]
 
     private static final AtomicInteger BOOTS = new AtomicInteger()
diff --git a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorSpec.groovy
index f34e01b2e9..faa5b295c8 100644
--- a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorSpec.groovy
+++ b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorSpec.groovy
@@ -283,15 +283,6 @@ class GrailsDomainGeneratorSpec extends HibernateGormDatastoreSpec {
         getPersistentEntity(GenParamsOnly).persistentClass.getProperty('quantity').value.typeParameters.getProperty('sequence_name') == 'seq'
     }
 
-    void "a property the generator does not support is rejected by name"() {
-        when:
-        newGenerator().generateAll([unbound(GenUnsupportedType)], getClass().classLoader)
-
-        then:
-        UnsupportedOperationException e = thrown()
-        e.message.contains('Type [string] of property [tag] of [' + GenUnsupportedType.name + ']')
-    }
-
     void "the version is marked as the optimistic lock"() {
         expect:
         field('version').isAnnotationPresent(Version)
@@ -491,14 +482,15 @@ class GrailsDomainGeneratorSpec extends HibernateGormDatastoreSpec {
         !typed.getDeclaredField('kind').isAnnotationPresent(Enumerated)
     }
 
-    void "a type name that is neither a user type nor a registered type for the class is rejected by name"() {
+    void "a registered type name for a different Java type types the field with the registered Java type and its JDBC type"() {
         when:
-        generate(GenUnsupportedType)
+        Class<?> generated = generate(GenUnsupportedType)
 
-        then:
-        UnsupportedOperationException e = thrown()
-        e.message.contains('tag')
-        e.message.contains('string')
+        then: "the binder resolves the name and not the property's class, so the column is the registered type's"
+        generated.getDeclaredField('tag').type == String
+        generated.getDeclaredField('tag').getAnnotation(JdbcTypeCode).value() == Types.VARCHAR
+        newGenerator().typeFacets((HibernatePersistentProperty) entity(GenUnsupportedType).getPropertyByName('tag')) ==
+                new TypeFacets(null, Types.VARCHAR, [:], String)
     }
 
     void "Hibernate's own annotation binder reads the generated types and formulas"() {
diff --git a/grails-data-hibernate7/docs/src/docs/asciidoc/advancedGORMFeatures/nativeDomainBinding.adoc b/grails-data-hibernate7/docs/src/docs/asciidoc/advancedGORMFeatures/nativeDomainBinding.adoc
index de66048bd7..657c068079 100644
--- a/grails-data-hibernate7/docs/src/docs/asciidoc/advancedGORMFeatures/nativeDomainBinding.adoc
+++ b/grails-data-hibernate7/docs/src/docs/asciidoc/advancedGORMFeatures/nativeDomainBinding.adoc
@@ -65,6 +65,7 @@ For the domain classes in GORM's own test suites, both bindings produce the same
 * `type: 'serializable'` on a property that is not a collection (a `String`, a `byte[]` or any `Serializable`) is a binary column holding the Java serialization of the value, as with classic binding.
 * `params` on a registered type name such as `type: 'string'` are ignored, as with classic binding. They reach the type when it is a `UserType` that implements `ParameterizedType`.
 * A `type` that names an `AttributeConverter` class, for example `type: 'org.hibernate.type.YesNoConverter'`, converts the property with it, on a single property as well as on a `hasMany` stored in one column, as with classic binding.
+* A registered type name for a different Java type than the property's, for example `type: 'string'` on an `Integer`, gives the column of the registered type (`varchar(255)`), as with classic binding. Neither binding converts the value, so saving a value that is not null fails with a `ClassCastException` in both; a null value and the other properties of the class are stored.
 * A custom collection type (a `UserCollectionType` class) as the `type` of a `hasMany` of domain classes is applied to the collection. Classic binding boots with the same schema but never uses the class: it keeps Hibernate's own collection.
 * A natural id (`id natural: [...]`) may name an embedded object (the unique key spans its columns, in the order of the embedded class), and a name that is no property of the class is skipped without a message, as with classic binding.
 * A natural id declared on a subclass adds a unique key to the table of the hierarchy (the table of the subclass in a joined hierarchy), over the columns of the named properties in the order the mapping names them, and makes those properties updatable only when the natural id is `mutable`. Classic binding also marks the properties as natural identifiers, and with Hibernate 7 that makes every load of the subclass entity fail with a `NullPointerException`; native binding does not mark them [...]
@@ -95,10 +96,6 @@ Nothing is ignored silently. A mapping that boots is bound the way it is written
 | `Collection property [...]: the type [...] mapped on the collection property names neither a class nor serializable ...` or `... a custom collection type (a UserCollectionType) is mapped on a collection of basic values ...`
 | Remove the `type`, or use `'serializable'` or a `UserType` class to store the collection in one column. Classic binding does not boot with a registered type name (it fails with `Could not load requested class`), and neither with a custom collection type on a collection of basic values.
 
-| A `type` that is a registered Hibernate type name for a different Java type than the property, where the type is not an interface the property implements, for example `type: 'string'` on an `Integer`.
-| `Type [string] of property [...] is registered for the Java type [java.lang.String], not the property's [java.lang.Integer] ...`
-| Use a `UserType` class, or a type name registered for the property's own type.
-
 | `insertable: false` or `updatable: false` on a property that has no column of its own: a collection, an embedded object, or the inverse side of a one-to-one.
 | `... insertable: false or updatable: false is mapped on a property that has no column of its own ...`
 | Remove the option. Classic binding ignores it on such a property. On a property with a column, both bindings honor it.
@@ -155,7 +152,7 @@ Some bugs of classic binding are not copied by native binding. Native binding do
 [[nativeDomainBinding-schema]]
 === Differences in the Schema
 
-The schema of 702 groups of related domain classes was created with both bindings on H2 and compared: tables, columns with type, nullability, default, length, precision and scale, primary keys with their column order, foreign keys, unique keys, indexes, check constraints and sequences. Apart from the 4 groups that native binding refuses, these are the differences. Other databases are not covered by this comparison, so verify against your own database as described below.
+The schema of 734 groups of related domain classes was created with both bindings on H2 and compared: tables, columns with type, nullability, default, length, precision and scale, primary keys with their column order, foreign keys, unique keys, indexes, check constraints and sequences. Native binding booted every group that classic binding boots, and these are the differences. Other databases are not covered by this comparison, so verify against your own database as described below.
 
 [cols="2,3,3", options="header"]
 |===


(grails-core) 14/16: feat: accept a natural id on a subclass, an embedded property and an unknown name in native domain binding

Posted by bo...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

borinquenkid pushed a commit to branch 9.0.x-h7-improvement-phase3
in repository https://gitbox.apache.org/repos/asf/grails-core.git

commit b1fbda05f65ee3df9071448df7926afd7a5f718f
Author: Walter Duque de Estrada <wb...@mac.com>
AuthorDate: Thu Oct 8 02:22:05 2026 -0500

    feat: accept a natural id on a subclass, an embedded property and an unknown name in native domain binding
    
    The generator refused three natural id mappings that the domain binder boots: a natural id on a
    subclass (Hibernate refuses @NaturalId there), a natural id that names an embedded property, and
    one that names a property the class does not have (the binder skips the name without a word).
    
    A subclass now gets what NaturalId.createUniqueKey gives it: one unique key over the columns of
    the named properties in the order of the mapping, on the table of the class (the table of the
    hierarchy for single-table), with the name the binder generates, and the properties are updatable
    only when the natural id is mutable. The properties are not marked as natural identifiers: with
    Hibernate 7 the binder's marking makes every load and update of the subclass fail with a
    NullPointerException, because Hibernate builds the natural id mapping of a root only. An embedded
    property is stated with @NaturalId and the key takes the columns of the embedded type in the order
    it declares them, as the binder's does. An unknown name is dropped from the natural id; a mapping
    whose names are all unknown has none. A natural id that names a collection or a formula stays
    refused: the domain binder does not boot it either.
    
    Co-Authored-By: Claude Sonnet 5.5 <no...@anthropic.com>
---
 .../jpa/GeneratedDomainClassBinder.groovy          |  64 +++-
 .../domainbinding/jpa/GrailsDomainGenerator.groovy |  64 ++--
 .../GeneratedDomainClassesNaturalIdSpec.groovy     | 365 +++++++++++++++++++++
 ...eneratedDomainClassesDdlDifferentialSpec.groovy |   3 -
 .../GrailsDomainGeneratorDifferentialSpec.groovy   | 116 +++++--
 .../jpa/GrailsDomainGeneratorNaturalIdSpec.groovy  |  45 ++-
 .../advancedGORMFeatures/nativeDomainBinding.adoc  |  16 +-
 7 files changed, 588 insertions(+), 85 deletions(-)

diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GeneratedDomainClassBinder.groovy b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GeneratedDomainClassBinder.groovy
index 0ccac33665..50b8ba38d4 100644
--- a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GeneratedDomainClassBinder.groovy
+++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GeneratedDomainClassBinder.groovy
@@ -73,6 +73,7 @@ import org.grails.orm.hibernate.cfg.domainbinding.binder.PropertyBinder
 import org.grails.orm.hibernate.cfg.domainbinding.binder.StringColumnConstraintsBinder
 import org.grails.orm.hibernate.cfg.domainbinding.generator.GrailsSequenceGeneratorEnum
 import org.grails.orm.hibernate.cfg.domainbinding.hibernate.GrailsHibernatePersistentEntity
+import org.grails.orm.hibernate.cfg.domainbinding.hibernate.HibernateEmbeddedProperty
 import org.grails.orm.hibernate.cfg.domainbinding.hibernate.HibernatePersistentEntity
 import org.grails.orm.hibernate.cfg.domainbinding.hibernate.HibernatePersistentProperty
 import org.grails.orm.hibernate.cfg.domainbinding.hibernate.HibernateBasicProperty
@@ -83,6 +84,7 @@ import org.grails.orm.hibernate.cfg.domainbinding.util.ColumnNameForPropertyAndP
 import org.grails.orm.hibernate.cfg.domainbinding.util.DefaultColumnNameFetcher
 import org.grails.orm.hibernate.cfg.domainbinding.util.GeneratorCreationContextWrapper
 import org.grails.orm.hibernate.cfg.domainbinding.util.MultiTenantFilterDefinitionBinder
+import org.grails.orm.hibernate.cfg.domainbinding.util.UniqueNameGenerator
 
 /**
  * Lets Hibernate's own annotation binder bind the GORM domain classes, while the application's real instances stay what
@@ -116,6 +118,7 @@ class GeneratedDomainClassBinder implements SessionFactoryBuilderFactory {
     private final PropertyBinder propertyBinder = new PropertyBinder()
     private final Map<String, Generated> generatedByName = new HashMap<String, Generated>()
     private final MultiTenantFilterDefinitionBinder filterDefinitionBinder = new MultiTenantFilterDefinitionBinder()
+    private final UniqueNameGenerator uniqueNameGenerator = new UniqueNameGenerator()
     private PersistentEntityNamingStrategy namingStrategy
     private JdbcEnvironment jdbcEnvironment
     private GrailsDomainGenerator generator
@@ -365,18 +368,65 @@ class GeneratedDomainClassBinder implements SessionFactoryBuilderFactory {
             key.explicit = false
         }
         alignPrimaryKeyOrder(persistentClass, entity)
-        NaturalIdFacets naturalId = persistentClass instanceof RootClass ? generator.naturalIdFacets(entity) : null
-        if (naturalId != null) {
-            List<String> columns = naturalId.propertyNames().collectMany { String name ->
-                persistentClass.getProperty(name).columns*.name
-            }
+        NaturalIdFacets naturalId = generator.naturalIdFacets(entity)
+        if (naturalId == null) {
+            return
+        }
+        List<Column> columns = naturalId.propertyNames().collectMany { String name ->
+            naturalColumns(persistentClass, entity, name)
+        }
+        if (persistentClass instanceof RootClass) {
             for (UniqueKey key : persistentClass.table.uniqueKeys.values()) {
-                if (key.columns*.name.toSet() == columns.toSet()) {
-                    List<Column> ordered = key.columns.sort(false) { Column column -> columns.indexOf(column.name) }
+                if (key.columns*.name.toSet() == columns*.name.toSet()) {
+                    List<Column> ordered = key.columns.sort(false) { Column column -> columns*.name.indexOf(column.name) }
                     key.columns.clear()
                     key.columns.addAll(ordered)
                 }
             }
+        } else {
+            alignSubclassNaturalId(persistentClass, naturalId, columns)
+        }
+    }
+
+    /**
+     * The columns of a property of a natural id, in the order the domain binder gives them: the order of the properties of an
+     * embedded type as the mapping declares them, where Hibernate's annotation binder sorts the properties of a component by name.
+     */
+    private List<Column> naturalColumns(PersistentClass persistentClass, GrailsHibernatePersistentEntity entity, String name) {
+        Property property = persistentClass.getProperty(name)
+        List<Column> columns = property.columns
+        PersistentProperty<?> mapped = entity.getPropertyByName(name)
+        if (property.value instanceof Component && mapped instanceof HibernateEmbeddedProperty) {
+            List<String> declared = generator.embeddedLeaves((HibernateEmbeddedProperty) mapped).collectMany { EmbeddedLeaf leaf ->
+                leaf.toOne() != null ? leaf.toOne().joinColumns()*.name() : (leaf.column() != null ? [leaf.column().name()] : [])
+            }
+            List<String> bound = columns*.name
+            if (declared.toSet() == bound.toSet()) {
+                return columns.sort(false) { Column column -> declared.indexOf(column.name) }
+            }
+        }
+        return columns
+    }
+
+    /**
+     * Hibernate refuses {@code @NaturalId} on a subclass. The domain binder binds the natural id of a subclass as it does a root's
+     * ({@code NaturalId.createUniqueKey}): each property it names, found in the hierarchy, is made updatable exactly when the natural
+     * id is mutable, and one unique key over their columns in the order of the mapping is added to the table of the class, which is
+     * the table of the hierarchy for a single-table subclass, with the name {@link UniqueNameGenerator} gives it.
+     *
+     * <p>The properties are not marked as natural identifiers: with Hibernate 7 the domain binder's marking makes every load and
+     * update of the subclass fail with a {@code NullPointerException}, because Hibernate builds the natural id mapping of a root
+     * only.</p>
+     */
+    private void alignSubclassNaturalId(PersistentClass persistentClass, NaturalIdFacets naturalId, List<Column> columns) {
+        for (String name : naturalId.propertyNames()) {
+            persistentClass.getProperty(name).updateable = naturalId.mutable()
+        }
+        if (!columns.isEmpty()) {
+            UniqueKey key = new UniqueKey(persistentClass.table)
+            columns.each { Column column -> key.addColumn(column) }
+            uniqueNameGenerator.setGeneratedUniqueName(key)
+            persistentClass.table.addUniqueKey(key)
         }
     }
 
diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGenerator.groovy b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGenerator.groovy
index dec8bee278..9212bc05d5 100644
--- a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGenerator.groovy
+++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGenerator.groovy
@@ -122,6 +122,7 @@ import org.hibernate.usertype.UserType
 import org.springframework.core.GenericTypeResolver
 import org.springframework.util.ClassUtils
 
+import org.grails.datastore.mapping.model.PersistentProperty
 import org.grails.datastore.mapping.model.config.GormProperties
 import org.grails.datastore.mapping.model.types.Association
 import org.grails.orm.hibernate.cfg.CacheConfig
@@ -722,9 +723,12 @@ class GrailsDomainGenerator {
 
     /**
      * Decides the natural identifier the domain binder binds for the entity, as {@code NaturalIdentifierBinder} reads it from the
-     * mapped identity: the properties it names and whether it is mutable. The binder also makes each of those properties updatable
-     * exactly when the natural id is mutable, and adds one unique key over their columns; Hibernate's annotation binder does both
-     * for {@code @NaturalId}, with a unique key of its own name.
+     * mapped identity: the properties it names and whether it is mutable. The binder makes each of those properties updatable
+     * exactly when the natural id is mutable, and adds one unique key over their columns to the table of the entity (the table of the
+     * hierarchy for a subclass of a single-table hierarchy). A name that is no property of the entity or of its superclasses is
+     * skipped without a word, so it is not part of the facets either; a mapping whose names are all skipped has no natural id.
+     * Hibernate's annotation binder does the rest for {@code @NaturalId} on a root, with a unique key of its own name; a subclass
+     * cannot state it, and the aligner binds the key and the flags of a subclass.
      *
      * @return the facets, or {@code null} when the mapping names no natural identifier
      */
@@ -733,43 +737,57 @@ class GrailsDomainGenerator {
         if (natural == null || natural.propertyNames == null || natural.propertyNames.isEmpty()) {
             return null
         }
-        return new NaturalIdFacets(new ArrayList<String>(natural.propertyNames), natural.mutable)
+        List<String> known = natural.propertyNames.findAll { String name ->
+            PersistentProperty<?> found = entity.getPropertyByName(name)
+            found instanceof HibernatePersistentProperty && entity.identity?.name != name
+        }
+        return known.isEmpty() ? null : new NaturalIdFacets(new ArrayList<String>(known), natural.mutable)
     }
 
     /**
-     * @return why the generator cannot describe the natural identifier of the entity, or {@code null} when it can: it must be
-     *     the entity's own (Hibernate refuses {@code @NaturalId} on a subclass, where the binder puts a key on the table of the
-     *     hierarchy), and every property it names must be a plain column, an enum or a foreign key of the entity (the binder skips a
-     *     name that is no property of the class without a word, which the generator rejects instead)
+     * @return why the generator cannot describe the natural identifier of the entity, or {@code null} when it can: every property it
+     *     names must be a plain column, an enum, a foreign key or an embedded type of the entity
      */
     private String naturalIdProblem(GrailsHibernatePersistentEntity entity) {
         NaturalIdFacets natural = naturalIdFacets(entity)
         if (natural == null) {
             return null
         }
-        if (!entity.isRoot()) {
-            return "Entity [${entity.name}] declares a natural id but is a subclass: Hibernate refuses @NaturalId on a subclass, " +
-                    'where the binder adds a unique key to the table of the hierarchy'
-        }
         for (String name : natural.propertyNames()) {
-            HibernatePersistentProperty property = entity.persistentPropertiesToBind.find { HibernatePersistentProperty p -> p.name == name }
-            if (property == null) {
-                return "The natural id of [${entity.name}] names [${name}], which is not a persistent property of the entity: " +
-                        'the binder skips it without a word'
-            }
-            if (property instanceof HibernateEmbeddedProperty || property instanceof HibernateBasicProperty ||
+            HibernatePersistentProperty property = (HibernatePersistentProperty) entity.getPropertyByName(name)
+            if (property instanceof HibernateBasicProperty ||
                     property instanceof HibernateToManyEntityProperty || isDerived(property) ||
                     (property instanceof HibernateToOneProperty && boundAsOneToOne((HibernateToOneProperty) property))) {
                 return "The natural id of [${entity.name}] names [${name}], which is ${property.getClass().simpleName}: " +
-                        'only a simple property, an enum or a foreign key can be part of a natural id the generator states'
+                        'only a simple property, an enum, a foreign key or an embedded type can be part of a natural id the generator states'
+            }
+        }
+        return null
+    }
+
+    /**
+     * @return whether the natural identifier the binder gives the property makes it updatable: the natural id of its entity or of a
+     *     subclass of it, which may name a property it inherits; {@code null} when no natural id names it
+     */
+    private Boolean naturalIdUpdatable(HibernatePersistentProperty property) {
+        Deque<GrailsHibernatePersistentEntity> pending = new ArrayDeque<GrailsHibernatePersistentEntity>()
+        pending.add(property.hibernateOwner)
+        while (!pending.isEmpty()) {
+            GrailsHibernatePersistentEntity entity = pending.poll()
+            NaturalIdFacets natural = naturalIdFacets(entity)
+            if (natural != null && natural.propertyNames().contains(property.name)) {
+                return natural.mutable()
             }
+            pending.addAll(entity.childEntities)
         }
         return null
     }
 
     /** @return whether the property is part of the natural identifier of its entity, and so whether that is mutable; {@code null} when it is not part of it */
     private Boolean naturalIdMutable(HibernatePersistentProperty property) {
-        NaturalIdFacets natural = naturalIdFacets(property.hibernateOwner)
+        GrailsHibernatePersistentEntity owner = property.hibernateOwner
+        // a subclass cannot state @NaturalId: the aligner binds the natural id of a subclass
+        NaturalIdFacets natural = owner.isRoot() ? naturalIdFacets(owner) : null
         return natural != null && natural.propertyNames().contains(property.name) ? natural.mutable() : null
     }
 
@@ -2402,7 +2420,7 @@ class GrailsDomainGenerator {
             HibernatePersistentProperty property, Column column, String name, boolean nullable, boolean unique) {
         PropertyConfig mappedForm = property.hibernateMappedForm
         // NaturalId.createUniqueKey sets the updatability of each of its properties to the mutability of the natural id
-        Boolean naturalMutable = naturalIdMutable(property)
+        Boolean naturalMutable = naturalIdUpdatable(property)
         return new ColumnFacets(
                 name,
                 nullable,
@@ -3033,6 +3051,10 @@ class GrailsDomainGenerator {
                         .build()
             }
         }
+        Boolean naturalMutable = naturalIdMutable(property)
+        if (naturalMutable != null) {
+            annotations << AnnotationDescription.Builder.ofType(HibernateNaturalId).define('mutable', naturalMutable).build()
+        }
         for (Annotation audit : auditAnnotations(property)) {
             annotations << AnnotationDescription.ForLoadedAnnotation.of(audit)
         }
diff --git a/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/generated/GeneratedDomainClassesNaturalIdSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/generated/GeneratedDomainClassesNaturalIdSpec.groovy
new file mode 100644
index 0000000000..7ee86971d2
--- /dev/null
+++ b/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/generated/GeneratedDomainClassesNaturalIdSpec.groovy
@@ -0,0 +1,365 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package grails.gorm.tests.generated
+
+import java.util.concurrent.atomic.AtomicInteger
+
+import grails.gorm.annotation.Entity
+import org.hibernate.mapping.PersistentClass
+import org.hibernate.mapping.Property
+import org.hibernate.mapping.RootClass
+import org.hibernate.mapping.Table
+import spock.lang.AutoCleanup
+import spock.lang.Specification
+import spock.lang.Unroll
+
+import org.grails.orm.hibernate.HibernateDatastore
+
+/**
+ * A natural id the mapping states with {@code id natural: ...} on an entity the generated mode cannot hand to Hibernate's
+ * {@code @NaturalId} as it is: a subclass (the domain binder adds the unique key to the table of the hierarchy), an embedded
+ * property (the key spans the columns of the embedded type) and a name that is no property (the domain binder skips it). The
+ * generated mode boots with the unique keys the domain binder creates (name and column order), flags the same properties as natural,
+ * and rejects the same duplicates.
+ */
+class GeneratedDomainClassesNaturalIdSpec extends Specification {
+
+    static final AtomicInteger BOOTS = new AtomicInteger()
+
+    @AutoCleanup
+    HibernateDatastore datastore
+
+    private HibernateDatastore boot(List<Class> group, boolean generated) {
+        datastore?.close()
+        datastore = new HibernateDatastore([
+                'dataSource.url'                  : "jdbc:h2:mem:ni${BOOTS.incrementAndGet()};LOCK_TIMEOUT=10000".toString(),
+                'dataSource.dbCreate'             : 'create-drop',
+                'hibernate.generatedDomainClasses': generated,
+        ], group as Class[])
+        return datastore
+    }
+
+    /**
+     * The unique keys of every table (columns in order, and names when asked for: Hibernate names the key of a root's {@code @NaturalId}
+     * itself) and the updatability of the properties, with the properties of a root flagged natural.
+     */
+    private Map<String, Object> schema(HibernateDatastore booted, boolean names = false, boolean natural = true) {
+        Map<String, Object> result = new TreeMap<String, Object>()
+        for (Table table : booted.metadata.collectTableMappings()) {
+            if (table.physicalTable) {
+                result["table ${table.name}".toString()] = [
+                        columns   : table.columns*.name.sort(),
+                        uniqueKeys: table.uniqueKeys.values().collect { names ? [name: it.name, columns: it.columns*.name] : [columns: it.columns*.name] }.sort { it.toString() },
+                ]
+            }
+        }
+        for (PersistentClass persistentClass : booted.metadata.entityBindings) {
+            result["updatable ${persistentClass.entityName}".toString()] = persistentClass.declaredProperties.collectEntries { Property p -> [(p.name): p.updateable] }
+            if (natural && persistentClass instanceof RootClass) {
+                result["natural ${persistentClass.entityName}".toString()] = persistentClass.declaredProperties.findAll { Property p -> p.naturalIdentifier }*.name
+            }
+        }
+        return result
+    }
+
+    @Unroll
+    void "#label has the unique key and the natural properties the domain binder gives it"() {
+        given: "the domain binder also marks a property of a parent as natural when its subclass names it, which the generated mode does not"
+        boolean natural = group != [NiMixRoot, NiMixChild]
+
+        when:
+        Map<String, Object> binder = schema(boot(group, false), false, natural)
+        Map<String, Object> generated = schema(boot(group, true), false, natural)
+
+        then:
+        generated == binder
+        generated["table ${table}".toString()].uniqueKeys*.columns == keys
+
+        where:
+        label                                              | group                                  | table             | keys
+        'a subclass of a single-table hierarchy'           | [NiRoot, NiChild]                      | 'ni_root'         | [['code']]
+        'a subclass naming a property of its parent'       | [NiMixRoot, NiMixChild]                | 'ni_mix_root'     | [['code', 'name']]
+        'a mutable natural id on a subclass'               | [NiMutRoot, NiMutChild]                | 'ni_mut_root'     | [['code']]
+        'a root and a subclass that both state one'        | [NiBothRoot, NiBothChild]              | 'ni_both_root'    | [['code'], ['name']]
+        'a subclass three levels down'                     | [NiGrand, NiGrandChild, NiGrandGrand]  | 'ni_grand'        | [['extra', 'code']]
+        'a subclass naming a foreign key'                  | [NiFkTarget, NiFkRoot, NiFkChild]      | 'ni_fk_root'      | [['target_id', 'code']]
+        'a subclass of a joined hierarchy'                 | [NiJRoot, NiJChild]                    | 'nijchild'       | [['region', 'code']]
+        'an embedded property after a simple one'          | [NiEmb]                                | 'ni_emb'          | [['code', 'home_street', 'home_city']]
+        'an embedded property before a simple one'         | [NiEmb2]                               | 'ni_emb2'         | [['home_street', 'home_city', 'code']]
+        'an embedded property of a subclass'               | [NiEmbRoot, NiEmbChild]                | 'ni_emb_root'     | [['home_street', 'home_city', 'code']]
+        'an embedded property alone'                       | [NiEmbOnly]                            | 'ni_emb_only'     | [['home_street', 'home_city']]
+        'a name that is no property after a real one'      | [NiTypo]                               | 'ni_typo'         | [['code']]
+        'a name that is no property and nothing else'      | [NiOnlyTypo]                           | 'ni_only_typo'    | []
+    }
+
+    @Unroll
+    void "the unique key of #label has the name the domain binder gives it"() {
+        when:
+        Map<String, Object> binder = schema(boot(group, false), true, false)
+        Map<String, Object> generated = schema(boot(group, true), true, false)
+
+        then:
+        generated["table ${table}".toString()].uniqueKeys == binder["table ${table}".toString()].uniqueKeys
+        generated["table ${table}".toString()].uniqueKeys.size() == count
+
+        where:
+        label                             | group                                 | table          | count
+        'a subclass'                      | [NiRoot, NiChild]                     | 'ni_root'      | 1
+        'a subclass naming a parent column' | [NiMixRoot, NiMixChild]             | 'ni_mix_root'  | 1
+        'a subclass of a joined hierarchy' | [NiJRoot, NiJChild]                  | 'nijchild'     | 1
+        'a subclass three levels down'    | [NiGrand, NiGrandChild, NiGrandGrand] | 'ni_grand'     | 1
+    }
+
+    @Unroll
+    void "#label rejects a duplicate natural id and accepts a distinct one, as the domain binder does"() {
+        when:
+        Map<Boolean, List> outcome = [false, true].collectEntries { boolean generated ->
+            boot(group, generated)
+            [(generated): attempts.collect { Map values -> save(type, values) }]
+        }
+
+        then:
+        outcome[true] == outcome[false]
+        outcome[true] == expected
+
+        where:
+        label                       | group                      | type        | attempts                                                                                          | expected
+        'a subclass'                | [NiRoot, NiChild]          | NiChild     | [[code: 'a'], [code: 'b'], [code: 'a']]                                                           | ['saved', 'saved', 'rejected: JdbcSQLIntegrityConstraintViolationException']
+        'a subclass with a parent property' | [NiMixRoot, NiMixChild] | NiMixChild | [[code: 'a', name: 'x'], [code: 'a', name: 'y'], [code: 'a', name: 'x']]                       | ['saved', 'saved', 'rejected: JdbcSQLIntegrityConstraintViolationException']
+        'an embedded property'      | [NiEmb]                    | NiEmb       | [[code: 'a', home: new NiHome(street: 's', city: 'c')], [code: 'a', home: new NiHome(street: 's', city: 'd')], [code: 'a', home: new NiHome(street: 's', city: 'c')]] | ['saved', 'saved', 'rejected: JdbcSQLIntegrityConstraintViolationException']
+        'a name that is no property' | [NiTypo]                  | NiTypo      | [[code: 'a'], [code: 'b'], [code: 'a']]                                                           | ['saved', 'saved', 'rejected: JdbcSQLIntegrityConstraintViolationException']
+        'nothing but a missing name' | [NiOnlyTypo]              | NiOnlyTypo  | [[code: 'a'], [code: 'a']]                                                                        | ['saved', 'saved']
+    }
+
+    void "a subclass with a natural id is stored, found by query and updated as the domain binder does, and is also read by id"() {
+        when:
+        Map<Boolean, Map> outcome = [false, true].collectEntries { boolean generated ->
+            boot([NiRoot, NiChild, NiMutRoot, NiMutChild], generated)
+            Map steps = [:]
+            Long immutable = null
+            Long mutable = null
+            steps.saveImmutable = attempt { immutable = NiChild.withTransaction { NiChild.newInstance(code: 'a', name: 'n').save(failOnError: true, flush: true).id } }
+            steps.saveMutable = attempt { mutable = NiMutChild.withTransaction { NiMutChild.newInstance(code: 'a').save(failOnError: true, flush: true).id } }
+            steps.listImmutable = attempt { NiChild.withNewSession { NiChild.list()*.code } }
+            steps.findImmutable = attempt { NiChild.withNewSession { NiChild.findByCode('a')?.name } }
+            steps.getImmutable = attempt { NiChild.withNewSession { NiChild.get(immutable).code } }
+            steps.updateImmutable = attempt { NiChild.withTransaction { NiChild.get(immutable).code = 'changed' } }
+            steps.updateMutable = attempt { NiMutChild.withTransaction { NiMutChild.get(mutable).code = 'changed' } }
+            steps.reloadImmutable = attempt { NiChild.withNewSession { NiChild.get(immutable).code } }
+            steps.reloadMutable = attempt { NiMutChild.withNewSession { NiMutChild.get(mutable).code } }
+            [(generated): steps]
+        }
+        List<String> shared = ['saveImmutable', 'saveMutable', 'listImmutable', 'findImmutable']
+
+        then: "what the domain binder can do is done the same way"
+        outcome[true].subMap(shared) == outcome[false].subMap(shared)
+        outcome[true].subMap(shared) == [saveImmutable: 'ok: 1', saveMutable: 'ok: 1', listImmutable: 'ok: [a]', findImmutable: 'ok: n']
+
+        and: "the domain binder marks the properties as natural identifiers, which makes Hibernate 7 fail on every load of the subclass"
+        ['getImmutable', 'updateImmutable', 'updateMutable', 'reloadImmutable', 'reloadMutable'].every {
+            outcome[false][it] == 'failed: NullPointerException'
+        }
+
+        and: "the generated mode keeps the unique key and the updatability and loads the entity"
+        outcome[true].subMap(['getImmutable', 'reloadImmutable', 'reloadMutable']) ==
+                [getImmutable: 'ok: a', reloadImmutable: 'ok: a', reloadMutable: 'ok: changed']
+    }
+
+    void "a natural id on an embedded property reloads the embedded value"() {
+        when:
+        Map<Boolean, Object> outcome = [false, true].collectEntries { boolean generated ->
+            boot([NiEmb], generated)
+            Long id = NiEmb.withTransaction { NiEmb.newInstance(code: 'a', home: new NiHome(street: 's', city: 'c')).save(failOnError: true, flush: true).id }
+            [(generated): NiEmb.withNewSession { NiEmb e = NiEmb.get(id); [e.code, e.home.street, e.home.city] }]
+        }
+
+        then:
+        outcome[true] == outcome[false]
+        outcome[true] == ['a', 's', 'c']
+    }
+
+    private static String attempt(Closure<?> step) {
+        try {
+            return "ok: ${step.call()}".toString()
+        } catch (Throwable e) {
+            Throwable root = e
+            while (root.cause != null && root.cause != root) {
+                root = root.cause
+            }
+            return "failed: ${root.class.simpleName}".toString()
+        }
+    }
+
+    private static String save(Class type, Map values) {
+        try {
+            type.withTransaction { type.newInstance(values).save(failOnError: true, flush: true) }
+            return 'saved'
+        } catch (Throwable e) {
+            Throwable root = e
+            while (root.cause != null && root.cause != root) {
+                root = root.cause
+            }
+            return "rejected: ${root.class.simpleName}".toString()
+        }
+    }
+}
+
+@Entity
+class NiRoot {
+    String name
+}
+
+@Entity
+class NiChild extends NiRoot {
+    String code
+    static mapping = { id natural: 'code' }
+}
+
+@Entity
+class NiMixRoot {
+    String name
+}
+
+@Entity
+class NiMixChild extends NiMixRoot {
+    String code
+    static mapping = { id natural: ['code', 'name'] }
+}
+
+@Entity
+class NiMutRoot {
+    String name
+}
+
+@Entity
+class NiMutChild extends NiMutRoot {
+    String code
+    static mapping = { id natural: [properties: ['code'], mutable: true] }
+}
+
+@Entity
+class NiBothRoot {
+    String name
+    static mapping = { id natural: 'name' }
+}
+
+@Entity
+class NiBothChild extends NiBothRoot {
+    String code
+    static mapping = { id natural: 'code' }
+}
+
+@Entity
+class NiGrand {
+    String name
+}
+
+@Entity
+class NiGrandChild extends NiGrand {
+    String code
+}
+
+@Entity
+class NiGrandGrand extends NiGrandChild {
+    String extra
+    static mapping = { id natural: ['extra', 'code'] }
+}
+
+@Entity
+class NiFkTarget {
+    String label
+}
+
+@Entity
+class NiFkRoot {
+    String name
+}
+
+@Entity
+class NiFkChild extends NiFkRoot {
+    NiFkTarget target
+    String code
+    static mapping = { id natural: ['target', 'code'] }
+}
+
+@Entity
+class NiJRoot {
+    String name
+    static mapping = { tablePerHierarchy false }
+}
+
+@Entity
+class NiJChild extends NiJRoot {
+    String code
+    String region
+    static mapping = { id natural: ['region', 'code'] }
+}
+
+class NiHome {
+    String street
+    String city
+}
+
+@Entity
+class NiEmb {
+    String code
+    NiHome home
+    static embedded = ['home']
+    static mapping = { id natural: ['code', 'home'] }
+}
+
+@Entity
+class NiEmb2 {
+    String code
+    NiHome home
+    static embedded = ['home']
+    static mapping = { id natural: ['home', 'code'] }
+}
+
+@Entity
+class NiEmbRoot {
+    String label
+}
+
+@Entity
+class NiEmbChild extends NiEmbRoot {
+    String code
+    NiHome home
+    static embedded = ['home']
+    static mapping = { id natural: ['home', 'code'] }
+}
+
+@Entity
+class NiEmbOnly {
+    NiHome home
+    static embedded = ['home']
+    static mapping = { id natural: ['home'] }
+}
+
+@Entity
+class NiTypo {
+    String code
+    static mapping = { id natural: ['code', 'typo'] }
+}
+
+@Entity
+class NiOnlyTypo {
+    String code
+    static mapping = { id natural: ['typo'] }
+}
diff --git a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GeneratedDomainClassesDdlDifferentialSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GeneratedDomainClassesDdlDifferentialSpec.groovy
index cd44f3bb23..b8470381de 100644
--- a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GeneratedDomainClassesDdlDifferentialSpec.groovy
+++ b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GeneratedDomainClassesDdlDifferentialSpec.groovy
@@ -83,9 +83,6 @@ class GeneratedDomainClassesDdlDifferentialSpec extends Specification {
     private static final List<String> REFUSALS = [
             'neither side of the many-to-many owns it',
             'is registered for the Java type',
-            'declares a natural id but is a subclass',
-            'which is HibernateEmbeddedProperty',
-            'which is not a persistent property of the entity',
     ]
 
     private static final AtomicInteger BOOTS = new AtomicInteger()
diff --git a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorDifferentialSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorDifferentialSpec.groovy
index dcf4159d8e..5229b4c7bf 100644
--- a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorDifferentialSpec.groovy
+++ b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorDifferentialSpec.groovy
@@ -157,10 +157,10 @@ class GrailsDomainGeneratorDifferentialSpec extends HibernateGormDatastoreSpec {
                         mismatches.addAll(compareEntity(entity, generator.entityFacets(entity), hierarchy))
                         mismatches.addAll(compareHierarchy(entity, hierarchy))
                         if (hierarchy.ownsTable() && generator.generationProblem(entity) == null) {
-                            mismatches.addAll(compareConstraints(entity, generator.constraintFacets(entity), constraints))
+                            mismatches.addAll(compareConstraints(generator, entity, generator.constraintFacets(entity), constraints))
                         }
                         if (generator.generationProblem(entity) == null) {
-                            mismatches.addAll(compareNaturalId(entity, generator.naturalIdFacets(entity), naturals))
+                            mismatches.addAll(compareNaturalId(generator, entity, generator.naturalIdFacets(entity), naturals))
                             mismatches.addAll(compareCache(entity, generator.cacheFacets(entity), caches))
                         }
                     }
@@ -399,13 +399,14 @@ class GrailsDomainGeneratorDifferentialSpec extends HibernateGormDatastoreSpec {
      * The indexes and multi-column unique keys on the table the entity owns: the same names, the same columns in the same
      * order. The unique key the binder adds for a natural id is not part of them (it is compared with the natural id).
      */
-    private List<String> compareConstraints(GrailsHibernatePersistentEntity entity, ConstraintFacets facets, Map<String, Integer> constraints) {
+    private List<String> compareConstraints(
+            GrailsDomainGenerator generator, GrailsHibernatePersistentEntity entity, ConstraintFacets facets, Map<String, Integer> constraints) {
         org.hibernate.mapping.Table table = entity.persistentClass.table
         String where = "${entity.name} table ${table.name}"
         Map<String, List<String>> boundIndexes = table.indexes.values().collectEntries { org.hibernate.mapping.Index index ->
             [(index.name): index.columns*.name]
         } as Map<String, List<String>>
-        Map<String, List<String>> boundKeys = tableKeysWithoutNaturalId(entity.persistentClass)
+        Map<String, List<String>> boundKeys = tableKeysWithoutNaturalId(entity.persistentClass, naturalKeySets(generator, entity))
         Map<String, List<String>> generatedIndexes = facets.indexes().collectEntries { IndexFacets index -> [(index.name()): index.columns()] } as Map<String, List<String>>
         Map<String, List<String>> generatedKeys = facets.uniqueKeys().findAll { UniqueKeyFacets key -> key.bound() }
                 .collectEntries { UniqueKeyFacets key -> [(key.name()): key.columns()] } as Map<String, List<String>>
@@ -600,43 +601,107 @@ class GrailsDomainGeneratorDifferentialSpec extends HibernateGormDatastoreSpec {
     }
 
     /**
-     * The natural identifier: the properties the binder marked natural are the ones the generator names, each is updatable exactly
-     * when the natural id is mutable, and one unique key spans their columns in the order the mapping names them.
+     * The natural identifier: the properties the binder marked natural are the ones a natural id of the class or of a subclass
+     * names (a subclass may name a property it inherits), each property the entity's own natural id names is updatable exactly when
+     * the natural id is mutable, and one unique key on the table of the class spans their columns in the order the mapping names them.
      */
-    private List<String> compareNaturalId(GrailsHibernatePersistentEntity entity, NaturalIdFacets facets, Map<String, Integer> naturals) {
+    private List<String> compareNaturalId(
+            GrailsDomainGenerator generator, GrailsHibernatePersistentEntity entity, NaturalIdFacets facets, Map<String, Integer> naturals) {
         PersistentClass persistentClass = entity.persistentClass
-        List<Property> natural = persistentClass.properties.findAll { Property p -> p.naturalIdentifier }
+        List<Property> natural = persistentClass.declaredProperties.findAll { Property p -> p.naturalIdentifier }
         String where = "${entity.name} natural id"
+        Set<String> named = hierarchyNaturalNames(generator, entity)
+        if (!named.containsAll(natural*.name)) {
+            return ["${where} properties: generator=${named} binder=${natural*.name}".toString()]
+        }
         if (facets == null) {
-            return natural.isEmpty() ? [] : ["${where} properties: generator=none binder=${natural*.name}".toString()]
+            return []
         }
         naturals['entities']++
         naturals['properties'] += facets.propertyNames().size()
         List<String> found = []
-        if (facets.propertyNames().toSet() != natural*.name.toSet()) {
-            found << "${where} properties: generator=${facets.propertyNames()} binder=${natural*.name}".toString()
-            return found
-        }
-        natural.each { Property p ->
-            if (p.updateable != facets.mutable()) {
+        facets.propertyNames().each { String name ->
+            Property p = persistentClass.hasProperty(name) ? persistentClass.getProperty(name) : null
+            if (p == null || !p.naturalIdentifier) {
+                found << "${where} properties: generator=${facets.propertyNames()} binder=not natural: ${name}".toString()
+            } else if (p.updateable != facets.mutable()) {
                 found << "${where} updatable of ${p.name}: generator=${facets.mutable()} binder=${p.updateable}".toString()
             }
         }
-        List<String> columns = facets.propertyNames().collectMany { String name -> persistentClass.getProperty(name).columns*.name }
+        List<String> columns = naturalKeyColumns(generator, entity, persistentClass, facets)
         if (!persistentClass.table.uniqueKeys.values().any { org.hibernate.mapping.UniqueKey key -> key.columns*.name == columns }) {
             found << "${where} uniqueKey: generator=${columns} binder=${persistentClass.table.uniqueKeys.values()*.columns*.name}".toString()
         }
         return found
     }
 
+    /**
+     * The columns of the natural id the binder puts in its unique key: those of each property's value, in the order of the mapping.
+     * The columns of an embedded property are in the order of the properties of the embedded type as it declares them, which is the
+     * order of the generator's leaves: the binder adds them to the key before Hibernate sorts the properties of the component by
+     * name, so the bound component no longer tells.
+     */
+    private static List<String> naturalKeyColumns(
+            GrailsDomainGenerator generator, GrailsHibernatePersistentEntity entity, PersistentClass persistentClass, NaturalIdFacets facets) {
+        return facets.propertyNames().collectMany { String name ->
+            Object mapped = entity.getPropertyByName(name)
+            List<String> bound = persistentClass.getProperty(name).value.selectables.findAll { it instanceof org.hibernate.mapping.Column }
+                    .collect { ((org.hibernate.mapping.Column) it).name }
+            if (mapped instanceof HibernateEmbeddedProperty) {
+                List<String> declared = generator.embeddedLeaves((HibernateEmbeddedProperty) mapped).collectMany { EmbeddedLeaf leaf ->
+                    leaf.toOne() != null ? leaf.toOne().joinColumns()*.name() : (leaf.column() != null ? [leaf.column().name()] : [])
+                }
+                return declared.toSet() == bound.toSet() ? declared : bound
+            }
+            return bound
+        } as List<String>
+    }
+
+    /** The properties the natural id of the entity or of one of its subclasses names. */
+    private static Set<String> hierarchyNaturalNames(GrailsDomainGenerator generator, GrailsHibernatePersistentEntity entity) {
+        Set<String> names = new HashSet<String>()
+        Deque<GrailsHibernatePersistentEntity> pending = new ArrayDeque<GrailsHibernatePersistentEntity>([entity])
+        while (!pending.isEmpty()) {
+            GrailsHibernatePersistentEntity next = pending.poll()
+            NaturalIdFacets facets = generator.naturalIdFacets(next)
+            if (facets != null) {
+                names.addAll(facets.propertyNames())
+            }
+            pending.addAll(next.childEntities)
+        }
+        return names
+    }
+
+    /**
+     * The column sets of the natural ids of the entity and its subclasses whose unique key lies on the table of the entity: the
+     * binder puts the key of a subclass of a single-table hierarchy on the table of the hierarchy. They are compared with the natural
+     * id, not with the unique keys the mapping states.
+     */
+    private static Set<Set<String>> naturalKeySets(GrailsDomainGenerator generator, GrailsHibernatePersistentEntity entity) {
+        Set<Set<String>> sets = new HashSet<Set<String>>()
+        Deque<GrailsHibernatePersistentEntity> pending = new ArrayDeque<GrailsHibernatePersistentEntity>([entity])
+        while (!pending.isEmpty()) {
+            GrailsHibernatePersistentEntity next = pending.poll()
+            NaturalIdFacets facets = generator.naturalIdFacets(next)
+            if (facets != null && next.persistentClass.table.is(entity.persistentClass.table)) {
+                sets << naturalKeyColumns(generator, next, next.persistentClass, facets).toSet()
+            }
+            pending.addAll(next.childEntities)
+        }
+        return sets
+    }
+
     /**
      * The natural identifier Hibernate's annotation binder reads from {@code @NaturalId}: the same properties, with the same
      * updatability, and a unique key over the same columns. The key has the name Hibernate's implicit naming gives it and its
      * columns follow the order of the fields, not the order the mapping names them: listed, not reported.
      */
     private static List<String> compareAnnotatedNaturalId(
-            String where, PersistentClass bound, PersistentClass annotated, Map<String, Integer> known) {
-        List<Property> boundNatural = bound.properties.findAll { Property p -> p.naturalIdentifier }
+            String where, PersistentClass bound, PersistentClass annotated, NaturalIdFacets facets, Map<String, Integer> known) {
+        // the properties a subclass names are marked by the binder, not by the annotation (Hibernate refuses @NaturalId on a subclass)
+        List<Property> boundNatural = bound.properties.findAll { Property p ->
+            p.naturalIdentifier && facets != null && facets.propertyNames().contains(p.name)
+        }
         List<Property> annotatedNatural = annotated.properties.findAll { Property p -> p.naturalIdentifier }
         if (boundNatural.isEmpty() && annotatedNatural.isEmpty()) {
             return []
@@ -666,10 +731,7 @@ class GrailsDomainGeneratorDifferentialSpec extends HibernateGormDatastoreSpec {
      * from a unique column once the metadata is complete (their names start with {@code UK_}), and the unique key over the columns
      * of a natural id, which is compared with the natural id.
      */
-    private static Map<String, List<String>> tableKeysWithoutNaturalId(PersistentClass persistentClass) {
-        List<Set<String>> naturalColumns = persistentClass.subclassClosure.collect { PersistentClass member ->
-            member.declaredProperties.findAll { Property p -> p.naturalIdentifier }*.columns.flatten()*.name.toSet()
-        }.findAll { Set<String> columns -> !columns.isEmpty() }
+    private static Map<String, List<String>> tableKeysWithoutNaturalId(PersistentClass persistentClass, Set<Set<String>> naturalColumns) {
         return persistentClass.table.uniqueKeys.values().findAll { org.hibernate.mapping.UniqueKey key ->
             !(key.columns.size() == 1 && key.name.startsWith('UK_')) && !naturalColumns.contains(key.columns*.name.toSet())
         }.collectEntries { org.hibernate.mapping.UniqueKey key ->
@@ -1374,7 +1436,7 @@ class GrailsDomainGeneratorDifferentialSpec extends HibernateGormDatastoreSpec {
         }
         if (bound instanceof RootClass) {
             found.addAll(compareAnnotatedDiscriminator(where, (RootClass) bound, (RootClass) annotated))
-            found.addAll(compareAnnotatedNaturalId(where, bound, annotated, known))
+            found.addAll(compareAnnotatedNaturalId(where, bound, annotated, generator.naturalIdFacets(entity), known))
             found.addAll(compareAnnotatedCache(where, (RootClass) bound, (RootClass) annotated))
             if (entity.identity == null && generator.generationProblem(entity) == null) {
                 annotationRead['composite identifiers']++
@@ -1382,7 +1444,7 @@ class GrailsDomainGeneratorDifferentialSpec extends HibernateGormDatastoreSpec {
             }
         }
         if (pairs.ownsTable[0]) {
-            found.addAll(compareAnnotatedConstraints(where, bound, annotated, generator.constraintFacets(entity)))
+            found.addAll(compareAnnotatedConstraints(where, bound, annotated, generator.constraintFacets(entity), naturalKeySets(generator, entity)))
         }
         Map<String, Map<String, Object>> expectations = leafExpectations(generator, entity)
         for (Property property : bound.declaredProperties) {
@@ -1454,7 +1516,7 @@ class GrailsDomainGeneratorDifferentialSpec extends HibernateGormDatastoreSpec {
      * is compared by columns in the natural id comparison and not here.
      */
     private static List<String> compareAnnotatedConstraints(
-            String where, PersistentClass bound, PersistentClass annotated, ConstraintFacets facets) {
+            String where, PersistentClass bound, PersistentClass annotated, ConstraintFacets facets, Set<Set<String>> naturalKeys) {
         Map<String, List<String>> boundIndexes = bound.table.indexes.values().collectEntries { org.hibernate.mapping.Index index ->
             [(index.name): index.columns*.name]
         } as Map<String, List<String>>
@@ -1465,10 +1527,10 @@ class GrailsDomainGeneratorDifferentialSpec extends HibernateGormDatastoreSpec {
         if (boundIndexes != annotatedIndexes) {
             found << "${where} indexes: generator=${annotatedIndexes} binder=${boundIndexes}".toString()
         }
-        Map<String, List<String>> boundKeys = tableKeysWithoutNaturalId(bound)
+        Map<String, List<String>> boundKeys = tableKeysWithoutNaturalId(bound, naturalKeys)
         // a key the mapping asks for and the binder does not create is stated by the generator, so Hibernate reads it too
         Set<String> unbound = facets.uniqueKeys().findAll { UniqueKeyFacets key -> !key.bound() }*.name().toSet()
-        Map<String, List<String>> annotatedKeys = tableKeysWithoutNaturalId(annotated).findAll { String name, List<String> columns ->
+        Map<String, List<String>> annotatedKeys = tableKeysWithoutNaturalId(annotated, naturalKeys).findAll { String name, List<String> columns ->
             !unbound.contains(name)
         } as Map<String, List<String>>
         if (boundKeys != annotatedKeys) {
diff --git a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorNaturalIdSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorNaturalIdSpec.groovy
index 54f103a5bf..9733d23b20 100644
--- a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorNaturalIdSpec.groovy
+++ b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorNaturalIdSpec.groovy
@@ -35,7 +35,7 @@ import org.grails.orm.hibernate.cfg.domainbinding.hibernate.GrailsHibernatePersi
 class GrailsDomainGeneratorNaturalIdSpec extends GrailsDomainGeneratorSupport {
 
     void setupSpec() {
-        manager.registerDomainClasses(GenNatTarget, GenNatMutable, GenNatImmutable, GenNatRef, GenNatRoot, GenNatChild, GenNatTypo, GenNatEmbedded, GenNatNone)
+        manager.registerDomainClasses(GenNatTarget, GenNatMutable, GenNatImmutable, GenNatRef, GenNatRoot, GenNatChild, GenNatTypo, GenNatOnlyTypo, GenNatEmbedded, GenNatNone)
     }
 
     void "every property of a natural id is marked, and it is mutable only when the mapping says so"() {
@@ -86,29 +86,32 @@ class GrailsDomainGeneratorNaturalIdSpec extends GrailsDomainGeneratorSupport {
         !generateGroup(GenNatNone).values().first().declaredFields.any { it.isAnnotationPresent(NaturalId) }
     }
 
-    void "a natural id that names something the generator cannot state is rejected by name"() {
+    void "a name that is no property of the entity is skipped, as the domain binder skips it"() {
+        expect:
+        newGenerator().naturalIdFacets(entity(GenNatTypo)) == new NaturalIdFacets(['code'], false)
+        newGenerator().naturalIdFacets(entity(GenNatOnlyTypo)) == null
+        generateGroup(GenNatTypo).values().first().getDeclaredField('code').isAnnotationPresent(NaturalId)
+        !generateGroup(GenNatOnlyTypo).values().first().declaredFields.any { it.isAnnotationPresent(NaturalId) }
+    }
+
+    void "an embedded property can be part of the natural id, and the root states it with the other properties"() {
         when:
-        generateGroup(domain)
+        Class<?> generated = generateGroup(GenNatEmbedded).values().first()
 
         then:
-        UnsupportedOperationException e = thrown()
-        e.message.contains(domain.simpleName)
-        e.message.contains(reason)
-
-        where:
-        domain        | reason
-        GenNatTypo    | 'typo'
-        GenNatEmbedded | 'home'
+        newGenerator().naturalIdFacets(entity(GenNatEmbedded)) == new NaturalIdFacets(['code', 'home'], false)
+        generated.getDeclaredField('code').isAnnotationPresent(NaturalId)
+        !generated.getDeclaredField('home').getAnnotation(NaturalId).mutable()
     }
 
-    void "a natural id on a subclass is rejected by name"() {
+    void "the natural id of a subclass is described by the facets but not stated: Hibernate refuses @NaturalId on a subclass"() {
         when:
-        generateGroup(GenNatRoot, GenNatChild)
+        Map<GrailsHibernatePersistentEntity, Class<?>> classes = generateGroup(GenNatRoot, GenNatChild)
 
         then:
-        UnsupportedOperationException e = thrown()
-        e.message.contains('GenNatChild')
-        e.message.contains('natural')
+        newGenerator().naturalIdFacets(entity(GenNatChild)) == new NaturalIdFacets(['code'], false)
+        !classes[entity(GenNatChild)].getDeclaredField('code').isAnnotationPresent(NaturalId)
+        !classes[entity(GenNatChild)].getDeclaredField('code').getAnnotation(Column).updatable()
     }
 
     private static Map<String, List<Object>> naturalProperties(PersistentClass persistentClass) {
@@ -190,6 +193,16 @@ class GenNatTypo {
     }
 }
 
+@Entity
+class GenNatOnlyTypo {
+
+    String code
+
+    static mapping = {
+        id natural: ['typo']
+    }
+}
+
 @Entity
 class GenNatEmbedded {
 
diff --git a/grails-data-hibernate7/docs/src/docs/asciidoc/advancedGORMFeatures/nativeDomainBinding.adoc b/grails-data-hibernate7/docs/src/docs/asciidoc/advancedGORMFeatures/nativeDomainBinding.adoc
index bfcc235495..de66048bd7 100644
--- a/grails-data-hibernate7/docs/src/docs/asciidoc/advancedGORMFeatures/nativeDomainBinding.adoc
+++ b/grails-data-hibernate7/docs/src/docs/asciidoc/advancedGORMFeatures/nativeDomainBinding.adoc
@@ -66,6 +66,8 @@ For the domain classes in GORM's own test suites, both bindings produce the same
 * `params` on a registered type name such as `type: 'string'` are ignored, as with classic binding. They reach the type when it is a `UserType` that implements `ParameterizedType`.
 * A `type` that names an `AttributeConverter` class, for example `type: 'org.hibernate.type.YesNoConverter'`, converts the property with it, on a single property as well as on a `hasMany` stored in one column, as with classic binding.
 * A custom collection type (a `UserCollectionType` class) as the `type` of a `hasMany` of domain classes is applied to the collection. Classic binding boots with the same schema but never uses the class: it keeps Hibernate's own collection.
+* A natural id (`id natural: [...]`) may name an embedded object (the unique key spans its columns, in the order of the embedded class), and a name that is no property of the class is skipped without a message, as with classic binding.
+* A natural id declared on a subclass adds a unique key to the table of the hierarchy (the table of the subclass in a joined hierarchy), over the columns of the named properties in the order the mapping names them, and makes those properties updatable only when the natural id is `mutable`. Classic binding also marks the properties as natural identifiers, and with Hibernate 7 that makes every load of the subclass entity fail with a `NullPointerException`; native binding does not mark them [...]
 * Identifier generators, inheritance strategies (single table, joined and table-per-concrete-class), discriminators, natural ids, optimistic locking, lazy and eager fetching, cascades, sorting, batch size and `cache` usage are mapped as with classic binding.
 
 [[nativeDomainBinding-refused]]
@@ -85,17 +87,9 @@ Nothing is ignored silently. A mapping that boots is bound the way it is written
 | `The collection [...] of the embedded type [...] is reachable through N embedded properties (...) ...`
 | Embed the class from one property only, or make it a domain class and map an association. Classic binding names the table and the key column of the collection after the embedded class, so all the owners share one table whose foreign key points at only one of them, and it fails to boot when the owners name the property alike.
 
-| A natural id declared on a subclass.
-| `Entity [...] declares a natural id but is a subclass ...`
-| Declare the natural id on the root class of the hierarchy. For a subclass, use a `unique` constraint on its property. Classic binding adds a unique key to the table of the hierarchy.
-
-| A natural id that names an embedded property, a collection or a formula.
-| `The natural id of [...] names [...], which is ... only a simple property, an enum or a foreign key can be part of a natural id ...`
-| Name only simple properties, enums and many-to-one associations in `natural`.
-
-| A natural id that names a property the class does not have.
-| `The natural id of [...] names [...], which is not a persistent property of the entity ...`
-| Correct the name. Classic binding skips an unknown name without a message, so the entity has no natural id.
+| A natural id that names a collection or a formula.
+| `The natural id of [...] names [...], which is ... only a simple property, an enum, a foreign key or an embedded type can be part of a natural id ...`
+| Name only simple properties, enums, many-to-one associations and embedded objects in `natural`. Classic binding does not boot either: it fails with a `ClassCastException` for a collection and with `constraint involves a formula` for a formula.
 
 | A `type` on a `hasMany` of basic values that is a registered Hibernate type name, for example `type: 'text'`, or a custom collection type.
 | `Collection property [...]: the type [...] mapped on the collection property names neither a class nor serializable ...` or `... a custom collection type (a UserCollectionType) is mapped on a collection of basic values ...`


(grails-core) 06/16: feat: accept a type mapped on a collection property in native domain binding

Posted by bo...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

borinquenkid pushed a commit to branch 9.0.x-h7-improvement-phase3
in repository https://gitbox.apache.org/repos/asf/grails-core.git

commit ce5c58e2e6797fae94f05abb387a7524329c50f8
Author: Walter Duque de Estrada <wb...@mac.com>
AuthorDate: Wed Oct 7 21:03:22 2026 -0500

    feat: accept a type mapped on a collection property in native domain binding
    
    The generator refused every hasMany property with a type. The domain binder binds such a property
    as a collection only for a type that is neither a class nor serializable. A UserType class (or any
    class that is not a collection type) and serializable make the property one column of the owner's
    table, typed with the user type or serialized, with the index, the unique group and the column
    settings of any other column. A registered type name (text, string, yes_no) makes the binder fail
    to boot, probed for four names, and on a collection of entities the type changes nothing.
    
    The generator describes the column-bound collection like a simple property, declares the generated
    field with the element type Hibernate needs to take it for a basic value (a field with a collection
    type and no element type fails to bind), or as Serializable for the serialized one, and states the
    index and the unique group on the owner's table. It refuses, with the reason, a registered type
    name, a class on a collection of entities and a custom collection type, none of which the binder
    accepts or the generator describes. The unique group of such a column keeps its key, as the 8.0.x
    fix does for a serializable or user-typed collection.
    
    Co-Authored-By: Claude Sonnet 5.5 <no...@anthropic.com>
---
 .../domainbinding/jpa/GrailsDomainGenerator.groovy | 113 ++++++-
 ...GeneratedDomainClassesCollectionTypeSpec.groovy | 337 +++++++++++++++++++++
 ...eneratedDomainClassesDdlDifferentialSpec.groovy |   1 -
 .../GrailsDomainGeneratorDifferentialSpec.groovy   |  11 +-
 .../jpa/GrailsDomainGeneratorSpec.groovy           |   4 +-
 .../advancedGORMFeatures/nativeDomainBinding.adoc  |  10 +-
 6 files changed, 455 insertions(+), 21 deletions(-)

diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGenerator.groovy b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGenerator.groovy
index f095766b4c..c9442c0e33 100644
--- a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGenerator.groovy
+++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGenerator.groovy
@@ -21,6 +21,7 @@ package org.grails.orm.hibernate.cfg.domainbinding.jpa
 import java.lang.annotation.Annotation
 import java.lang.reflect.Method
 import java.lang.reflect.Modifier
+import java.sql.Types
 
 import groovy.transform.CompileStatic
 import jakarta.persistence.AssociationOverride
@@ -68,6 +69,7 @@ import net.bytebuddy.description.annotation.AnnotationDescription
 import net.bytebuddy.description.method.MethodDescription
 import net.bytebuddy.description.modifier.TypeManifestation
 import net.bytebuddy.description.modifier.Visibility
+import net.bytebuddy.description.type.TypeDefinition
 import net.bytebuddy.description.type.TypeDescription
 import net.bytebuddy.description.type.TypeList
 import net.bytebuddy.dynamic.DynamicType
@@ -113,6 +115,7 @@ import org.hibernate.generator.Assigned
 import org.hibernate.generator.Generator
 import org.hibernate.type.BasicType
 import org.hibernate.type.spi.TypeConfiguration
+import org.hibernate.usertype.UserCollectionType
 import org.hibernate.usertype.UserType
 
 import org.springframework.util.ClassUtils
@@ -807,12 +810,12 @@ class GrailsDomainGenerator {
             for (HibernatePersistentProperty peer : embeddedPeers(embedded)) {
                 collectConstraintSites(peer, current, sites)
             }
-        } else if (property instanceof HibernateBasicProperty || property instanceof HibernateToManyEntityProperty ||
+        } else if ((property instanceof HibernateBasicProperty && !boundAsColumn(property)) || property instanceof HibernateToManyEntityProperty ||
                 isDerived(property) || (property instanceof HibernateToOneProperty && boundAsOneToOne((HibernateToOneProperty) property))) {
             return
         } else {
             ColumnConfig columnConfig = firstColumnConfig(property.hibernateMappedForm)
-            boolean enumeration = property instanceof HibernateEnumProperty
+            boolean enumeration = property instanceof HibernateEnumProperty && !boundAsColumn(property)
             String name = enumeration ?
                     ((HibernateEnumProperty) property).resolveEnumColumnName(namingStrategy, columnNames, path) :
                     columnNames.getColumnNameForPropertyAndPath(property, path, columnConfig)
@@ -1014,9 +1017,11 @@ class GrailsDomainGenerator {
     }
 
     private TypeDecision decideType(HibernatePersistentProperty property) {
-        boolean isEnum = property instanceof HibernateEnumProperty
+        // a collection stored in one column is typed as a column of its own Java type, which is the collection
+        boolean column = boundAsColumn(property)
+        boolean isEnum = property instanceof HibernateEnumProperty && !column
         // the type of a collection property is the collection's; the binder types the element with the component type
-        boolean element = property instanceof HibernateBasicProperty
+        boolean element = property instanceof HibernateBasicProperty && !column
         Class<?> type = isEnum ? ((HibernateEnumProperty) property).enumType :
                 (element ? ((HibernateBasicProperty) property).componentType : property.type)
         String name = property.getTypeName(type)
@@ -1039,6 +1044,9 @@ class GrailsDomainGenerator {
             // type parameters with no type to give them to: the binder hands them to a built-in type, which ignores them
             return new TypeDecision(true, name, null)
         }
+        if (column && property.isSerializableType()) {
+            return serializedCollectionType(parameters)
+        }
         Class<?> named = loadClass(name, property)
         if (named != null) {
             return UserType.isAssignableFrom(named) ?
@@ -1077,6 +1085,18 @@ class GrailsDomainGenerator {
         return new TypeDecision(false, name, null, problem)
     }
 
+    /**
+     * {@code serializable} on a collection stored in one column: the collection is serialized to bytes, the type Hibernate registers
+     * under that name for {@code Serializable}. A field declared {@code Serializable} (the property of the real class is read and
+     * written by its own accessors, whatever the field says) has that Java type, and the JDBC type is the binary one.
+     */
+    private static TypeDecision serializedCollectionType(Map<String, String> parameters) {
+        if (!parameters.isEmpty()) {
+            return new TypeDecision(false, 'serializable', null, 'is a registered type with type parameters, which no annotation states')
+        }
+        return new TypeDecision(true, 'serializable', new TypeFacets(null, Types.VARBINARY, parameters, Serializable))
+    }
+
     private static Class<?> loadClass(String name, HibernatePersistentProperty property) {
         for (ClassLoader loader : [property.hibernateOwner.javaClass.classLoader, Thread.currentThread().contextClassLoader]) {
             try {
@@ -1175,6 +1195,9 @@ class GrailsDomainGenerator {
      */
     private String collectionProblem(HibernateBasicProperty property) {
         PropertyConfig mapped = property.hibernateMappedForm
+        if (boundAsColumn(property)) {
+            return columnCollectionProblem(property)
+        }
         String writeProblem = writeRestrictionProblem(mapped)
         if (writeProblem != null) {
             return writeProblem
@@ -1188,7 +1211,7 @@ class GrailsDomainGenerator {
                     'the only ones the binder creates a collection for'
         }
         if (mapped.type != null) {
-            return 'a type is mapped on the collection property itself, which the binder applies to the collection and its element alike'
+            return mappedCollectionTypeProblem(property)
         }
         Class<?> elementType = property.componentType
         if (elementType == null || elementType == Object) {
@@ -1324,6 +1347,49 @@ class GrailsDomainGenerator {
                 !property.hibernateMappedForm.hasJoinKeyMapping()
     }
 
+    /**
+     * The binder binds a collection property as one column of the owner's table, typed with the mapping's {@code type}, when the type
+     * is a class that is not a collection type (a {@code UserType}, for example) or {@code serializable}
+     * ({@code GrailsPropertyBinder}): it is no collection then, and has the index, the unique group and the column settings of any
+     * other column. Any other {@code type} stays a type of the collection, which no collection of values accepts (see
+     * {@link #mappedCollectionTypeProblem}).
+     */
+    private static boolean boundAsColumn(HibernatePersistentProperty property) {
+        return property instanceof HibernateBasicProperty && (property.isUserButNotCollectionType() || property.isSerializableType())
+    }
+
+    /**
+     * @return why the generator cannot describe the collection of values stored in one column ({@link #boundAsColumn}), or {@code null}
+     *     when it can: it is described like the column of a simple property, typed with the mapping's type
+     */
+    private String columnCollectionProblem(HibernateBasicProperty property) {
+        PropertyConfig mapped = property.hibernateMappedForm
+        if (mapped.columns != null && mapped.columns.size() > 1) {
+            return 'the mapping states several columns for a collection stored in one column'
+        }
+        TypeDecision type = decideType(property)
+        if (!type.supported) {
+            return "the type [${type.name}] ${type.problem}"
+        }
+        return null
+    }
+
+    /**
+     * A {@code type} on a collection property that is neither a class nor {@code serializable}: the binder gives the name to the
+     * element of the collection as a class name and fails to boot ({@code Could not load requested class: text}, probed for the
+     * registered names {@code string}, {@code text}, {@code materialized_clob} and {@code yes_no}), and a custom collection type is a
+     * class the generator does not describe yet.
+     */
+    private static String mappedCollectionTypeProblem(HibernateBasicProperty property) {
+        Class<?> userType = property.userType
+        if (userType != null && UserCollectionType.isAssignableFrom(userType)) {
+            return 'a custom collection type (a UserCollectionType) is mapped on the collection property, which the generator does not ' +
+                    'support yet'
+        }
+        return "the type [${property.hibernateMappedForm.typeName}] mapped on the collection property names neither a class nor " +
+                "serializable: the binder gives the name to the element as a class name and fails to boot ('Could not load requested class')"
+    }
+
     /**
      * Decides how the binder binds a collection of basic values or enums: its table, the key column pointing at the
      * owner, the element column, the index (list) or key (map) column, and the fetching.
@@ -1580,8 +1646,13 @@ class GrailsDomainGenerator {
                 return compositeProblem
             }
         }
-        if (mapped.type != null) {
-            return 'a type is mapped on the collection property itself, which the binder applies to the collection and its element alike'
+        if (property.isUserButNotCollectionType()) {
+            return 'a class is mapped as the type of the collection property, which the binder binds as one column of the owner\'s table ' +
+                    'that holds the collection, and Hibernate fails to boot for a collection of entities'
+        }
+        if (mapped.type != null && property.userType != null) {
+            return 'a custom collection type (a UserCollectionType) is mapped on the collection property, which the generator does not ' +
+                    'support yet'
         }
         if (property instanceof HibernateManyToManyProperty) {
             HibernateAssociation other = property.hibernateInverseSide
@@ -2033,6 +2104,9 @@ class GrailsDomainGenerator {
      * {@link Column}, in the order the binder applies them.
      */
     ColumnFacets columnFacets(HibernatePersistentProperty property) {
+        if (boundAsColumn(property)) {
+            return basicColumnFacets(property, null, null)
+        }
         if (property instanceof HibernateBasicProperty) {
             throw new IllegalArgumentException(
                     "Property [${property.name}] is a collection: it has a key, an element and perhaps an index column, see collectionFacets")
@@ -2412,7 +2486,7 @@ class GrailsDomainGenerator {
         if (property instanceof HibernateEmbeddedProperty) {
             return defineEmbeddedField(builder, (HibernateEmbeddedProperty) property, embeddables, true)
         }
-        if (property instanceof HibernateBasicProperty) {
+        if (property instanceof HibernateBasicProperty && !boundAsColumn(property)) {
             return defineCollectionField(builder, (HibernateBasicProperty) property)
         }
         if (property instanceof HibernateToOneProperty) {
@@ -2445,10 +2519,31 @@ class GrailsDomainGenerator {
         for (Annotation constraint : carriedAnnotations(property)) {
             annotations << AnnotationDescription.ForLoadedAnnotation.of(constraint)
         }
-        return builder.defineField(property.name, type?.javaType() ?: columnFieldType(property), Visibility.PRIVATE)
+        return builder.defineField(property.name, fieldType(property, type), Visibility.PRIVATE)
                 .annotateField(annotations as AnnotationDescription[])
     }
 
+    /**
+     * The type of the field of a column: the Java type the type facets name, else the property's. Hibernate's annotation binder takes a
+     * field of a collection type with no element type for a basic collection and fails to bind it, so a collection stored in one column
+     * is declared with its element type (and the {@code String} key of a map), and a serialized one with {@code Serializable}.
+     */
+    private TypeDefinition fieldType(HibernatePersistentProperty property, TypeFacets type) {
+        if (type?.javaType() != null) {
+            return TypeDescription.ForLoadedType.of(type.javaType())
+        }
+        if (boundAsColumn(property)) {
+            HibernateBasicProperty collection = (HibernateBasicProperty) property
+            Class<?> element = property instanceof HibernateEnumProperty ? ((HibernateEnumProperty) property).enumType : collection.componentType
+            if (element != null && element != Object) {
+                return CollectionKind.MAP.javaType.isAssignableFrom(property.type) ?
+                        TypeDescription.Generic.Builder.parameterizedType(property.type, String, element).build() :
+                        TypeDescription.Generic.Builder.parameterizedType(property.type, element).build()
+            }
+        }
+        return TypeDescription.ForLoadedType.of(columnFieldType(property))
+    }
+
     /**
      * The type of the field of a basic property. Hibernate treats a primitive field as not optional and, for every property of a
      * single-table hierarchy with subclasses (the properties the root declares as well as those of the subclasses), adds a table
diff --git a/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/generated/GeneratedDomainClassesCollectionTypeSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/generated/GeneratedDomainClassesCollectionTypeSpec.groovy
new file mode 100644
index 0000000000..3e1114f7d1
--- /dev/null
+++ b/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/generated/GeneratedDomainClassesCollectionTypeSpec.groovy
@@ -0,0 +1,337 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package grails.gorm.tests.generated
+
+import java.sql.PreparedStatement
+import java.sql.ResultSet
+import java.sql.Types
+import java.util.concurrent.atomic.AtomicInteger
+
+import grails.gorm.annotation.Entity
+import org.hibernate.engine.spi.SharedSessionContractImplementor
+import org.hibernate.mapping.Table
+import org.hibernate.usertype.UserType
+import spock.lang.AutoCleanup
+import spock.lang.Specification
+import spock.lang.Unroll
+
+import org.grails.orm.hibernate.HibernateDatastore
+
+/**
+ * A {@code type} mapped on a collection property. The domain binder keeps the collection a collection only when the type is
+ * neither a class that is not a collection type nor {@code serializable}: those two make the property one column of the owner's
+ * table, typed with the user type or serialized, with the index, the unique group and the column settings of any other column. A
+ * type name Hibernate knows ({@code text}) makes the binder fail to boot, and on a collection of entities it changes nothing.
+ */
+class GeneratedDomainClassesCollectionTypeSpec extends Specification {
+
+    static final AtomicInteger BOOTS = new AtomicInteger()
+
+    @AutoCleanup
+    HibernateDatastore datastore
+
+    private HibernateDatastore boot(List<Class> group, boolean generated) {
+        datastore?.close()
+        datastore = new HibernateDatastore([
+                'dataSource.url'                  : "jdbc:h2:mem:cts${BOOTS.incrementAndGet()};LOCK_TIMEOUT=10000".toString(),
+                'dataSource.dbCreate'             : 'create-drop',
+                'hibernate.generatedDomainClasses': generated,
+        ], group as Class[])
+        return datastore
+    }
+
+    /** The tables of the boot model as plain data: columns with SQL type and nullability, indexes, unique keys. */
+    private Map<String, Map> schema(List<Class> group, boolean generated) {
+        HibernateDatastore booted = boot(group, generated)
+        Map<String, Map> result = new TreeMap<String, Map>()
+        for (Table table : booted.metadata.collectTableMappings()) {
+            if (table.physicalTable) {
+                result[table.name] = [
+                        columns   : table.columns.collectEntries { org.hibernate.mapping.Column column ->
+                            boolean nullable = column.nullable && !(table.primaryKey != null && table.primaryKey.columns*.name.contains(column.name))
+                            [(column.name): "${column.getSqlType(booted.metadata)}${nullable ? '' : ' not null'}${column.unique ? ' unique' : ''}".toString()]
+                        },
+                        primaryKey: table.primaryKey?.columns*.name?.sort(),
+                        indexes   : table.indexes.values().collectEntries { [(it.name): it.columns*.name] },
+                        uniqueKeys: table.uniqueKeys.values().collect { it.columns*.name.sort() }.sort { it.toString() },
+                ]
+            }
+        }
+        return result
+    }
+
+    @Unroll
+    void "#label is one column of the owner's table, as the domain binder binds it"() {
+        when:
+        Map<String, Map> binder = schema(group, false)
+        Map<String, Map> generated = schema(group, true)
+
+        then:
+        generated == binder
+        generated.keySet() == tables as Set
+        generated[table].columns[column] == type
+
+        where:
+        label                                    | group                | tables               | table                | column  | type
+        'a set with a user type'                 | [CtsUserTypeSet]     | ['cts_user_type_set'] | 'cts_user_type_set' | 'tags'  | 'varchar(255)'
+        'a list with a user type'                | [CtsUserTypeList]    | ['cts_user_type_list'] | 'cts_user_type_list' | 'items' | 'varchar(255)'
+        'a serializable set'                     | [CtsSerializableSet] | ['cts_serializable_set'] | 'cts_serializable_set' | 'tags' | 'varbinary(255)'
+        'a serializable list'                    | [CtsSerializableList] | ['cts_serializable_list'] | 'cts_serializable_list' | 'items' | 'varbinary(255)'
+        'a serializable map'                     | [CtsSerializableMap] | ['cts_serializable_map'] | 'cts_serializable_map' | 'items' | 'varbinary(255)'
+        'a serializable set of enums'            | [CtsSerializableEnums] | ['cts_serializable_enums'] | 'cts_serializable_enums' | 'colors' | 'varbinary(255)'
+        'a serializable set with a column config' | [CtsSerializableColumn] | ['cts_serializable_column'] | 'cts_serializable_column' | 'ser_col' | 'varbinary(100)'
+        'a serializable set that is not null'    | [CtsSerializableNotNull] | ['cts_serializable_not_null'] | 'cts_serializable_not_null' | 'tags' | 'varbinary(255) not null'
+        'a serializable set with an index and a unique group' | [CtsSerializableKeys] | ['cts_serializable_keys'] | 'cts_serializable_keys' | 'tags' | 'varbinary(255)'
+        'a set with a user type, an index and a unique group' | [CtsUserTypeKeys] | ['cts_user_type_keys'] | 'cts_user_type_keys' | 'tags' | 'varchar(255)'
+    }
+
+    void "the index and the unique group of a collection stored in one column are on the owner's table, where the binder creates them"() {
+        when:
+        Map<String, Map> generated = schema([CtsSerializableKeys, CtsUserTypeKeys], true)
+
+        then:
+        generated['cts_serializable_keys'].indexes == [cts_ser_idx: ['tags']]
+        generated['cts_serializable_keys'].uniqueKeys == [['tags', 'x']]
+        generated['cts_user_type_keys'].indexes == [cts_ut_idx: ['tags']]
+        generated['cts_user_type_keys'].uniqueKeys == [['tags', 'x']]
+    }
+
+    void "a collection with a user type is stored with the user type and read back"() {
+        given:
+        boot([CtsUserTypeSet, CtsUserTypeList], true)
+
+        when:
+        Long setId = CtsUserTypeSet.withTransaction { new CtsUserTypeSet(tags: ['a', 'b'] as Set).save(failOnError: true, flush: true).id }
+        Long listId = CtsUserTypeList.withTransaction { new CtsUserTypeList(items: ['x', 'y', 'x']).save(failOnError: true, flush: true).id }
+
+        then:
+        CtsUserTypeSet.withNewSession { CtsUserTypeSet.get(setId).tags } == ['a', 'b'] as Set
+        CtsUserTypeList.withNewSession { CtsUserTypeList.get(listId).items } == ['x', 'y', 'x']
+        datastore.connectionSources.defaultConnectionSource.dataSource.connection.withCloseable { java.sql.Connection connection ->
+            connection.createStatement().executeQuery('select tags from cts_user_type_set').with { ResultSet rows -> rows.next(); rows.getString(1) }
+        } in ['a,b', 'b,a']
+    }
+
+    @Unroll
+    void "a serializable #label is stored in one column, read back and updated"() {
+        given:
+        boot(group, true)
+
+        when:
+        Long id = type.withTransaction { type.newInstance("${property}": initial).save(failOnError: true, flush: true).id }
+        type.withTransaction { type.get(id)."${property}" = changed; type.get(id).save(failOnError: true, flush: true) }
+
+        then:
+        type.withNewSession { type.get(id)."${property}" } == changed
+
+        where:
+        label     | group                  | type                  | property | initial               | changed
+        'set'     | [CtsSerializableSet]   | CtsSerializableSet    | 'tags'   | ['a', 'b'] as Set     | ['a', 'c'] as Set
+        'list'    | [CtsSerializableList]  | CtsSerializableList   | 'items'  | ['a', 'b']            | ['b', 'a', 'z']
+        'map'     | [CtsSerializableMap]   | CtsSerializableMap    | 'items'  | [one: 'a']            | [two: 'b']
+        'enums'   | [CtsSerializableEnums] | CtsSerializableEnums  | 'colors' | [CtsTypeColor.RED] as Set | [CtsTypeColor.GREEN] as Set
+    }
+
+    void "a type mapped on a collection of entities changes nothing in both bindings"() {
+        when:
+        Map<String, Map> binder = schema([CtsEntityTyped, CtsKid], false)
+        Map<String, Map> generated = schema([CtsEntityTyped, CtsKid], true)
+        Long id = CtsEntityTyped.withTransaction {
+            CtsEntityTyped owner = new CtsEntityTyped()
+            owner.addToKids(new CtsKid(name: 'k'))
+            owner.save(failOnError: true, flush: true).id
+        }
+
+        then: "the collection stays a join table"
+        generated == binder
+        generated.keySet().contains('cts_entity_typed_cts_kid')
+        CtsEntityTyped.withNewSession { CtsEntityTyped.get(id).kids*.name } == ['k']
+    }
+
+    @Unroll
+    void "a #label cannot be bound by the domain binder, and the generated mode says why instead of binding something else"() {
+        when:
+        boot(group, false)
+
+        then: "the binder fails to boot"
+        thrown(Exception)
+
+        when:
+        boot(group, true)
+
+        then:
+        Exception e = thrown()
+        Throwable root = e
+        while (root.cause != null && root.cause != root) {
+            root = root.cause
+        }
+        root instanceof UnsupportedOperationException
+        root.message.contains(message)
+
+        where:
+        label                                                    | group                 | message
+        'registered type name on the property of a collection'   | [CtsNamedType]        | 'names neither a class nor serializable'
+        'user type class on the property of a collection of entities' | [CtsEntityUserType, CtsKid] | 'binds as one column of the owner'
+    }
+}
+
+enum CtsTypeColor { RED, GREEN }
+
+class CtsSetType implements UserType<Set> {
+    int getSqlType() { Types.VARCHAR }
+    Class<Set> returnedClass() { Set }
+    boolean equals(Set x, Set y) { x == y }
+    int hashCode(Set x) { x.hashCode() }
+    Set nullSafeGet(ResultSet rs, int position, SharedSessionContractImplementor session, Object owner) {
+        String text = rs.getString(position)
+        text == null ? null : new HashSet(text.split(',').toList())
+    }
+    void nullSafeSet(PreparedStatement st, Set value, int index, SharedSessionContractImplementor session) {
+        if (value == null) {
+            st.setNull(index, Types.VARCHAR)
+        } else {
+            st.setString(index, value.join(','))
+        }
+    }
+    Set deepCopy(Set value) { value == null ? null : new HashSet(value) }
+    boolean isMutable() { true }
+    Serializable disassemble(Set value) { value == null ? null : new HashSet(value) }
+    Set assemble(Serializable cached, Object owner) { cached as Set }
+}
+
+class CtsListType implements UserType<List> {
+    int getSqlType() { Types.VARCHAR }
+    Class<List> returnedClass() { List }
+    boolean equals(List x, List y) { x == y }
+    int hashCode(List x) { x.hashCode() }
+    List nullSafeGet(ResultSet rs, int position, SharedSessionContractImplementor session, Object owner) {
+        String text = rs.getString(position)
+        text == null ? null : new ArrayList(text.split(',').toList())
+    }
+    void nullSafeSet(PreparedStatement st, List value, int index, SharedSessionContractImplementor session) {
+        if (value == null) {
+            st.setNull(index, Types.VARCHAR)
+        } else {
+            st.setString(index, value.join(','))
+        }
+    }
+    List deepCopy(List value) { value == null ? null : new ArrayList(value) }
+    boolean isMutable() { true }
+    Serializable disassemble(List value) { value == null ? null : new ArrayList(value) }
+    List assemble(Serializable cached, Object owner) { cached as List }
+}
+
+@Entity
+class CtsUserTypeSet {
+    Set<String> tags
+    static hasMany = [tags: String]
+    static mapping = { tags type: CtsSetType }
+}
+
+@Entity
+class CtsUserTypeList {
+    List<String> items
+    static hasMany = [items: String]
+    static mapping = { items type: CtsListType }
+}
+
+@Entity
+class CtsSerializableSet {
+    Set<String> tags
+    static hasMany = [tags: String]
+    static mapping = { tags type: 'serializable' }
+}
+
+@Entity
+class CtsSerializableList {
+    List<String> items
+    static hasMany = [items: String]
+    static mapping = { items type: 'serializable' }
+}
+
+@Entity
+class CtsSerializableMap {
+    Map<String, String> items
+    static hasMany = [items: String]
+    static mapping = { items type: 'serializable' }
+}
+
+@Entity
+class CtsSerializableEnums {
+    Set<CtsTypeColor> colors
+    static hasMany = [colors: CtsTypeColor]
+    static mapping = { colors type: 'serializable' }
+}
+
+@Entity
+class CtsSerializableColumn {
+    Set<String> tags
+    static hasMany = [tags: String]
+    static mapping = { tags type: 'serializable', column: 'ser_col', length: 100 }
+}
+
+@Entity
+class CtsSerializableNotNull {
+    Set<String> tags
+    static hasMany = [tags: String]
+    static mapping = { tags type: 'serializable' }
+    static constraints = { tags nullable: false }
+}
+
+@Entity
+class CtsSerializableKeys {
+    String x
+    Set<String> tags
+    static hasMany = [tags: String]
+    static mapping = { tags type: 'serializable', index: 'cts_ser_idx', unique: 'x' }
+}
+
+@Entity
+class CtsUserTypeKeys {
+    String x
+    Set<String> tags
+    static hasMany = [tags: String]
+    static mapping = { tags type: CtsSetType, index: 'cts_ut_idx', unique: 'x' }
+}
+
+@Entity
+class CtsKid {
+    String name
+}
+
+@Entity
+class CtsEntityTyped {
+    Set<CtsKid> kids
+    static hasMany = [kids: CtsKid]
+    static mapping = { kids type: 'serializable' }
+}
+
+@Entity
+class CtsNamedType {
+    Set<String> tags
+    static hasMany = [tags: String]
+    static mapping = { tags type: 'text' }
+}
+
+@Entity
+class CtsEntityUserType {
+    Set<CtsKid> kids
+    static hasMany = [kids: CtsKid]
+    static mapping = { kids type: CtsSetType }
+}
diff --git a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GeneratedDomainClassesDdlDifferentialSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GeneratedDomainClassesDdlDifferentialSpec.groovy
index efc61e1de2..2c5e1cc0d7 100644
--- a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GeneratedDomainClassesDdlDifferentialSpec.groovy
+++ b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GeneratedDomainClassesDdlDifferentialSpec.groovy
@@ -83,7 +83,6 @@ class GeneratedDomainClassesDdlDifferentialSpec extends Specification {
             'neither side of the many-to-many owns it',
             'is registered for the Java type',
             'names a class that is not a UserType',
-            'a type is mapped on the collection property itself',
             'is a collection inside an embedded type',
             'declares a natural id but is a subclass',
             'which is HibernateEmbeddedProperty',
diff --git a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorDifferentialSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorDifferentialSpec.groovy
index 5960b29c46..621b97914c 100644
--- a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorDifferentialSpec.groovy
+++ b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorDifferentialSpec.groovy
@@ -228,7 +228,8 @@ class GrailsDomainGeneratorDifferentialSpec extends HibernateGormDatastoreSpec {
                             }
                             continue
                         }
-                        if (property instanceof HibernateBasicProperty) {
+                        // a collection the mapping types with a class or serializable is one column of the owner's table, compared like any column
+                        if (property instanceof HibernateBasicProperty && !(bound?.value instanceof BasicValue)) {
                             if (!generator.validationAnnotations(property).isEmpty()) {
                                 skipped['bean validation constraints (applied by Hibernate after binding)']++
                             } else if (bound == null || !(bound.value instanceof HibernateCollection)) {
@@ -286,7 +287,7 @@ class GrailsDomainGeneratorDifferentialSpec extends HibernateGormDatastoreSpec {
                             mismatches.addAll(compareEnum(where, (HibernateEnumProperty) property, generator, bound))
                         }
                         if (!(property instanceof HibernateSimpleIdentityProperty)) {
-                            mismatches.addAll(compareType(where, property, generator, bound, explicitTypes))
+                            mismatches.addAll(compareType(where, property, generator, bound, explicitTypes, property instanceof HibernateBasicProperty))
                         }
                     }
                 }
@@ -1066,11 +1067,11 @@ class GrailsDomainGeneratorDifferentialSpec extends HibernateGormDatastoreSpec {
      */
     private List<String> compareType(
             String where, HibernatePersistentProperty property, GrailsDomainGenerator generator,
-            Property bound, Map<String, Integer> explicitTypes) {
+            Property bound, Map<String, Integer> explicitTypes, boolean wholeCollection = false) {
         BasicValue value = (BasicValue) bound.value
         TypeFacets facets = generator.typeFacets(property)
-        // a collection property is typed with its element's class
-        Class<?> type = property instanceof HibernateBasicProperty ? ((HibernateBasicProperty) property).componentType : property.type
+        // a collection property is typed with its element's class, unless the whole collection is one column
+        Class<?> type = property instanceof HibernateBasicProperty && !wholeCollection ? ((HibernateBasicProperty) property).componentType : property.type
         List<String> found = []
         Map<String, String> actualParameters = [:]
         value.typeParameters?.stringPropertyNames()?.each { String key -> actualParameters[key] = value.typeParameters.getProperty(key) }
diff --git a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorSpec.groovy
index 2d609c3c36..59ca4f2e65 100644
--- a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorSpec.groovy
+++ b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorSpec.groovy
@@ -1296,14 +1296,14 @@ class GrailsDomainGeneratorSpec extends HibernateGormDatastoreSpec {
         newGenerator().unsupportedReason(entity, property).contains('SortedSet')
     }
 
-    void "a type mapped on the collection property itself is rejected by name"() {
+    void "a type name mapped on the collection property is rejected by name, because the binder gives it to the element as a class name and cannot boot"() {
         given:
         GrailsHibernatePersistentEntity entity = unbound(GenCollTyped)
         HibernateBasicProperty property = (HibernateBasicProperty) entity.getHibernatePropertyByName('notes')
 
         expect:
         !newGenerator().supports(property)
-        newGenerator().unsupportedReason(entity, property).contains('a type is mapped on the collection property itself')
+        newGenerator().unsupportedReason(entity, property).contains('names neither a class nor serializable')
     }
 
     void "a collection of embedded objects is rejected by name, because the binder cannot bind one"() {
diff --git a/grails-data-hibernate7/docs/src/docs/asciidoc/advancedGORMFeatures/nativeDomainBinding.adoc b/grails-data-hibernate7/docs/src/docs/asciidoc/advancedGORMFeatures/nativeDomainBinding.adoc
index a2af6f4c77..41a8ca0019 100644
--- a/grails-data-hibernate7/docs/src/docs/asciidoc/advancedGORMFeatures/nativeDomainBinding.adoc
+++ b/grails-data-hibernate7/docs/src/docs/asciidoc/advancedGORMFeatures/nativeDomainBinding.adoc
@@ -58,6 +58,8 @@ For the domain classes in GORM's own test suites, both bindings produce the same
 * All multi-tenancy modes (`DISCRIMINATOR`, `SCHEMA`, `DATABASE`) and multiple data sources work. An association still cannot refer to an entity of another data source.
 * Composite identifiers use the same primary key column order.
 * Properties mapped with a `UserType` class, and with the registered converter types `yes_no`, `true_false` and `numeric_boolean`, are stored as before.
+* A `hasMany` of basic values whose `type` is `'serializable'` or a `UserType` class is stored in one column of the owner's table, as before, with the `index`, `unique` group and column settings of the property. A `type` on a `hasMany` of domain classes changes nothing in both bindings.
+* An `index` on the property of a `hasMany` is created over the key column of the collection table (and over the element column of a collection of enums), and `indexColumn` may give the index of a list or the key of a map a `type` other than integer or string.
 * Identifier generators, inheritance strategies (single table, joined and table-per-concrete-class), discriminators, natural ids, optimistic locking, lazy and eager fetching, cascades, sorting, batch size and `cache` usage are mapped as with classic binding.
 
 [[nativeDomainBinding-refused]]
@@ -89,9 +91,9 @@ Nothing is ignored silently. A mapping that boots is bound the way it is written
 | `The natural id of [...] names [...], which is not a persistent property of the entity ...`
 | Correct the name. Classic binding skips an unknown name without a message, so the entity has no natural id.
 
-| A `type` on the property of a collection (`hasMany`).
-| `Collection property [...]: a type is mapped on the collection property itself ...`
-| Remove the `type`. Classic binding applies it to the collection and to its elements alike.
+| A `type` on a `hasMany` of basic values that is a registered Hibernate type name, for example `type: 'text'`, or a custom collection type.
+| `Collection property [...]: the type [...] mapped on the collection property names neither a class nor serializable ...` or `... a custom collection type (a UserCollectionType) is mapped on the collection property ...`
+| Remove the `type`, or use `'serializable'` or a `UserType` class to store the collection in one column. Classic binding does not boot with a registered type name (it fails with `Could not load requested class`); a custom collection type is not described yet.
 
 | A `Map` on a many-to-many.
 | `Association property [...]: a map on a many-to-many ...`
@@ -134,7 +136,7 @@ Nothing is ignored silently. A mapping that boots is bound the way it is written
 | Remove the `type` from the tenant id.
 |===
 
-A few mappings fail to boot with classic binding too, so they are not a difference between the two: a `SortedSet` of basic values, a `Map` of enums, a hierarchy that mixes inheritance strategies, a `discriminator` type other than string, integer or character, and a joined subclass of a root with a composite identifier.
+A few mappings fail to boot with classic binding too, so they are not a difference between the two: a `SortedSet` of basic values, a `UserType` class as the `type` of a `hasMany` of domain classes, a `Map` of enums, a hierarchy that mixes inheritance strategies, a `discriminator` type other than string, integer or character, and a joined subclass of a root with a composite identifier.
 
 [[nativeDomainBinding-behavior]]
 === Differences in Behavior


(grails-core) 10/16: feat: accept serializable on a property that is not a collection in native domain binding

Posted by bo...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

borinquenkid pushed a commit to branch 9.0.x-h7-improvement-phase3
in repository https://gitbox.apache.org/repos/asf/grails-core.git

commit f4580a8cb3a25b5860cfb2bcccba17c1f1041eef
Author: Walter Duque de Estrada <wb...@mac.com>
AuthorDate: Thu Oct 8 01:03:11 2026 -0500

    feat: accept serializable on a property that is not a collection in native domain binding
    
    The generator refused type: 'serializable' on a String or a byte[] because the type Hibernate
    registers under that name is for java.io.Serializable, not for the property's class. The domain
    binder accepts it: the column is varbinary(255) and the value is stored as its Java serialization
    (a String 'hello' is stored as ac ed 00 05 74 00 05 68 65 6c 6c 6f and read back).
    
    The generator now declares the field with the registered Java type when the property's class
    implements that interface, with the binary JDBC type, which is how it already stated a serialized
    collection. Hibernate then resolves the same type as the domain binder, so a byte[] behaves as it
    does there too: Hibernate writes an instance of the target type as it is, so the raw bytes are
    stored and cannot be read back as a serialization. That is the domain binder's behavior and both
    bindings are compared on it. A serialization written by the JDK is read by both. A registered type
    for an unrelated Java class (string on an Integer) stays refused by name.
    
    Co-Authored-By: Claude Sonnet 5.5 <no...@anthropic.com>
---
 .../domainbinding/jpa/GrailsDomainGenerator.groovy |  12 +-
 ...neratedDomainClassesSerializableTypeSpec.groovy | 180 +++++++++++++++++++++
 .../jpa/GrailsDomainGeneratorSpec.groovy           |  33 +++-
 .../advancedGORMFeatures/nativeDomainBinding.adoc  |   7 +-
 4 files changed, 225 insertions(+), 7 deletions(-)

diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGenerator.groovy b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGenerator.groovy
index 7ca78ef1da..c0fc95658b 100644
--- a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGenerator.groovy
+++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGenerator.groovy
@@ -1114,7 +1114,7 @@ class GrailsDomainGenerator {
                     null, registered.jdbcType.defaultSqlTypeCode, parameters, null, registered.valueConverter.getClass()))
         }
         if (registered != null && registered.valueConverter == null && parameters.isEmpty() && !isEnum &&
-                (identity || registered.javaTypeDescriptor.javaTypeClass == boxed(type))) {
+                (identity || registered.javaTypeDescriptor.javaTypeClass == boxed(type) || serializesValueOf(registered, type))) {
             Class<?> registeredJava = registered.javaTypeDescriptor.javaTypeClass
             return new TypeDecision(true, name, new TypeFacets(
                     null, registered.jdbcType.defaultSqlTypeCode, parameters, registeredJava == boxed(type) ? null : registeredJava))
@@ -1148,6 +1148,16 @@ class GrailsDomainGenerator {
         return new TypeDecision(true, 'serializable', new TypeFacets(null, Types.VARBINARY, parameters, Serializable))
     }
 
+    /**
+     * {@code serializable} (and any registered type of a Java interface) on a property of a class that implements it: the property is
+     * typed with the registered Java type, as the binder resolves the type name and not the property's class, so a {@code String} or a
+     * {@code byte[]} is read and written through that type.
+     */
+    private static boolean serializesValueOf(BasicType<?> registered, Class<?> type) {
+        Class<?> registeredJava = registered.javaTypeDescriptor.javaTypeClass
+        return registeredJava.interface && type != null && registeredJava.isAssignableFrom(boxed(type))
+    }
+
     private static Class<?> loadClass(String name, HibernatePersistentProperty property) {
         for (ClassLoader loader : [property.hibernateOwner.javaClass.classLoader, Thread.currentThread().contextClassLoader]) {
             try {
diff --git a/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/generated/GeneratedDomainClassesSerializableTypeSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/generated/GeneratedDomainClassesSerializableTypeSpec.groovy
new file mode 100644
index 0000000000..d2db542ef1
--- /dev/null
+++ b/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/generated/GeneratedDomainClassesSerializableTypeSpec.groovy
@@ -0,0 +1,180 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package grails.gorm.tests.generated
+
+import java.sql.Connection
+import java.sql.PreparedStatement
+import java.sql.ResultSet
+import java.sql.ResultSetMetaData
+import java.util.concurrent.atomic.AtomicInteger
+
+import grails.gorm.annotation.Entity
+import org.hibernate.mapping.Table
+import spock.lang.AutoCleanup
+import spock.lang.Specification
+import spock.lang.Unroll
+
+import org.grails.orm.hibernate.HibernateDatastore
+
+/**
+ * {@code type: 'serializable'} on a property that is not a collection. The registered type is for {@code java.io.Serializable}, so the
+ * domain binder stores the Java serialization of a {@code String} or any other value in a binary column. The generated mode boots with
+ * the same schema and stores the same bytes.
+ */
+class GeneratedDomainClassesSerializableTypeSpec extends Specification {
+
+    static final AtomicInteger BOOTS = new AtomicInteger()
+
+    @AutoCleanup
+    HibernateDatastore datastore
+
+    private HibernateDatastore boot(List<Class> group, boolean generated) {
+        datastore?.close()
+        datastore = new HibernateDatastore([
+                'dataSource.url'                  : "jdbc:h2:mem:tns${BOOTS.incrementAndGet()};LOCK_TIMEOUT=10000".toString(),
+                'dataSource.dbCreate'             : 'create-drop',
+                'hibernate.generatedDomainClasses': generated,
+        ], group as Class[])
+        return datastore
+    }
+
+    /** The tables of the boot model as plain data: columns with SQL type and nullability, indexes, unique keys. */
+    private Map<String, Map> schema(HibernateDatastore booted) {
+        Map<String, Map> result = new TreeMap<String, Map>()
+        for (Table table : booted.metadata.collectTableMappings()) {
+            if (table.physicalTable) {
+                result[table.name] = [
+                        columns   : table.columns.collectEntries { org.hibernate.mapping.Column column ->
+                            boolean nullable = column.nullable && !(table.primaryKey != null && table.primaryKey.columns*.name.contains(column.name))
+                            [(column.name): "${column.getSqlType(booted.metadata)}${nullable ? '' : ' not null'}${column.unique ? ' unique' : ''}".toString()]
+                        },
+                        primaryKey: table.primaryKey?.columns*.name?.sort(),
+                        indexes   : table.indexes.values().collectEntries { [(it.name): it.columns*.name] },
+                        uniqueKeys: table.uniqueKeys.values().collect { it.columns*.name.sort() }.sort { it.toString() },
+                ]
+            }
+        }
+        return result
+    }
+
+    /** The rows of a table as stored, binary values as lists of bytes. */
+    private List<List> rows(String sql) {
+        return datastore.connectionSources.defaultConnectionSource.dataSource.connection.withCloseable { Connection connection ->
+            ResultSet result = connection.createStatement().executeQuery(sql)
+            ResultSetMetaData meta = result.metaData
+            List<List> found = []
+            while (result.next()) {
+                found << (1..meta.columnCount).collect { int i ->
+                    Object value = result.getObject(i)
+                    value instanceof byte[] ? ((byte[]) value).toList() : value
+                }
+            }
+            found
+        }
+    }
+
+    /** What a property does when it is saved, read back by a new session and stored: the value, or why it could not be read. */
+    private Map roundTrip(HibernateDatastore booted, Class type, Map<String, Object> values, String property, String select) {
+        Long id = type.withTransaction { type.newInstance(values).save(failOnError: true, flush: true).id }
+        Map observed = [stored: rows(select)]
+        try {
+            Object value = type.withNewSession { type.get(id)."${property}" }
+            observed.reloaded = value instanceof byte[] ? ((byte[]) value).toList() : value
+        } catch (Throwable e) {
+            Throwable root = e
+            while (root.cause != null && root.cause != root) {
+                root = root.cause
+            }
+            observed.reloaded = "cannot be read: ${root.class.simpleName}".toString()
+        }
+        return observed
+    }
+
+    @Unroll
+    void "#label has the column type the domain binder gives it"() {
+        when:
+        Map<String, Map> binder = schema(boot(group, false))
+        Map<String, Map> generated = schema(boot(group, true))
+
+        then:
+        generated == binder
+        generated[table].columns[column] == type
+
+        where:
+        label                                                | group                  | table                 | column          | type
+        'a byte array typed serializable'                    | [TnSerializableBytes]  | 'tn_serializable_bytes' | 'payload'     | 'varbinary(255)'
+        'a string typed serializable'                        | [TnSerializableString] | 'tn_serializable_string' | 'tag'        | 'varbinary(255)'
+    }
+
+    void "a string typed serializable is stored as the Java serialization of the string, as the domain binder stores it"() {
+        when:
+        Map binder = roundTrip(boot([TnSerializableString], false), TnSerializableString, [tag: 'hello'], 'tag', 'select tag from tn_serializable_string')
+        Map generated = roundTrip(boot([TnSerializableString], true), TnSerializableString, [tag: 'hello'], 'tag', 'select tag from tn_serializable_string')
+
+        then:
+        generated == binder
+        generated.reloaded == 'hello'
+        generated.stored == [[[-84, -19, 0, 5, 116, 0, 5, 104, 101, 108, 108, 111]]]
+    }
+
+    void "a byte array typed serializable behaves as it does in the domain binder"() {
+        when:
+        Map binder = roundTrip(boot([TnSerializableBytes], false), TnSerializableBytes, [payload: 'hello'.bytes], 'payload', 'select payload from tn_serializable_bytes')
+        Map generated = roundTrip(boot([TnSerializableBytes], true), TnSerializableBytes, [payload: 'hello'.bytes], 'payload', 'select payload from tn_serializable_bytes')
+
+        then:
+        generated == binder
+    }
+
+    void "a serialized byte array written by the Java serialization is read back by both bindings"() {
+        given:
+        byte[] serialized = new ByteArrayOutputStream().with { bytes ->
+            new ObjectOutputStream(bytes).with { it.writeObject('hello'.bytes); it.flush() }
+            bytes.toByteArray()
+        }
+
+        when:
+        Map<Boolean, Object> read = [false, true].collectEntries { boolean generated ->
+            boot([TnSerializableBytes], generated)
+            datastore.connectionSources.defaultConnectionSource.dataSource.connection.withCloseable { Connection connection ->
+                PreparedStatement insert = connection.prepareStatement('insert into tn_serializable_bytes (id, version, payload) values (1, 0, ?)')
+                insert.setBytes(1, serialized)
+                insert.executeUpdate()
+            }
+            Object value = TnSerializableBytes.withNewSession { TnSerializableBytes.get(1L).payload }
+            [(generated): ((byte[]) value).toList()]
+        }
+
+        then:
+        read[true] == read[false]
+        read[true] == 'hello'.bytes.toList()
+    }
+}
+
+@Entity
+class TnSerializableBytes {
+    byte[] payload
+    static mapping = { payload type: 'serializable' }
+}
+
+@Entity
+class TnSerializableString {
+    String tag
+    static mapping = { tag type: 'serializable' }
+}
diff --git a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorSpec.groovy
index 6834603cca..f25fbbe2fa 100644
--- a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorSpec.groovy
+++ b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorSpec.groovy
@@ -167,7 +167,8 @@ class GrailsDomainGeneratorSpec extends HibernateGormDatastoreSpec {
                 GenFkTarget, GenFkOwned, GenFkOwner, GenFkCascades, GenFkNodeA, GenFkNodeB, GenFkHasOneOwner, GenFkHasOneDetail, GenOneFace, GenOneNose, GenFkManyOne, GenFkOneSide, GenFkSub, GenFkSubRoot,
                 GenOmParent, GenOmChild, GenOmListed, GenOmOrdered, GenOmOrphan, GenOmFetched, GenOmTag, GenOmStep, GenOmKept, GenOmSortedOwner, GenOmMapOwner, GenMapBidiOwner, GenMapBidiChild, GenEmbAssocOwner,
                 GenMmStudent, GenMmCourse, GenMmPerson, GenParamsOnly, GenDecimal, GenUnversioned, GenUnversionedRoot, GenUnversionedChild, GenConverted, GenValueTyped,
-                GenCollEmbeddedHolder, GenCollEmbeddedSibling, GenCollEmbeddedItem, GenCollEmbeddedEntityHolder, GenMapM2mLeft, GenMapM2mRight)
+                GenCollEmbeddedHolder, GenCollEmbeddedSibling, GenCollEmbeddedItem, GenCollEmbeddedEntityHolder, GenMapM2mLeft, GenMapM2mRight,
+                GenSerialized)
     }
 
     List<StandardServiceRegistry> registries = []
@@ -286,7 +287,7 @@ class GrailsDomainGeneratorSpec extends HibernateGormDatastoreSpec {
 
         then:
         UnsupportedOperationException e = thrown()
-        e.message.contains('Type [serializable] of property [tag] of [' + GenUnsupportedType.name + ']')
+        e.message.contains('Type [string] of property [tag] of [' + GenUnsupportedType.name + ']')
     }
 
     void "the version is marked as the optimistic lock"() {
@@ -382,6 +383,19 @@ class GrailsDomainGeneratorSpec extends HibernateGormDatastoreSpec {
         !converted.getDeclaredField('plain').isAnnotationPresent(Convert)
     }
 
+    void "serializable on a property that is not a collection is a binary column of the Serializable Java type"() {
+        when:
+        Class<?> serialized = generate(GenSerialized)
+
+        then:
+        serialized.getDeclaredField('tag').type == Serializable
+        serialized.getDeclaredField('tag').getAnnotation(JdbcTypeCode).value() == java.sql.Types.VARBINARY
+        serialized.getDeclaredField('payload').type == Serializable
+        serialized.getDeclaredField('payload').getAnnotation(JdbcTypeCode).value() == java.sql.Types.VARBINARY
+        serialized.getDeclaredField('plain').type == String
+        !serialized.getDeclaredField('plain').isAnnotationPresent(JdbcTypeCode)
+    }
+
     void "Hibernate's own annotation binder resolves a converted type like the domain binder"() {
         given:
         Map<GrailsHibernatePersistentEntity, Class<?>> classes = generateGroup(GenConverted)
@@ -449,7 +463,7 @@ class GrailsDomainGeneratorSpec extends HibernateGormDatastoreSpec {
         then:
         UnsupportedOperationException e = thrown()
         e.message.contains('tag')
-        e.message.contains('serializable')
+        e.message.contains('string')
     }
 
     void "Hibernate's own annotation binder reads the generated types and formulas"() {
@@ -2788,10 +2802,23 @@ class GenParamsOnly {
 @Entity
 class GenUnsupportedType {
 
+    Integer tag
+
+    static mapping = {
+        tag type: 'string'
+    }
+}
+
+@Entity
+class GenSerialized {
+
     String tag
+    byte[] payload
+    String plain
 
     static mapping = {
         tag type: 'serializable'
+        payload type: 'serializable'
     }
 }
 
diff --git a/grails-data-hibernate7/docs/src/docs/asciidoc/advancedGORMFeatures/nativeDomainBinding.adoc b/grails-data-hibernate7/docs/src/docs/asciidoc/advancedGORMFeatures/nativeDomainBinding.adoc
index 42b70ef4d9..d29d522bb2 100644
--- a/grails-data-hibernate7/docs/src/docs/asciidoc/advancedGORMFeatures/nativeDomainBinding.adoc
+++ b/grails-data-hibernate7/docs/src/docs/asciidoc/advancedGORMFeatures/nativeDomainBinding.adoc
@@ -62,6 +62,7 @@ For the domain classes in GORM's own test suites, both bindings produce the same
 * An `index` on the property of a `hasMany` is created over the key column of the collection table (and over the element column of a collection of enums), and `indexColumn` may give the index of a list or the key of a map a `type` other than integer or string.
 * A `hasMany` inside a class listed in `static embedded` (basic values, enums or domain classes, also in a nested embedded class) is stored in a collection table named after the embedded class, not after the owner, with a key column named after the embedded class too, as with classic binding.
 * A `Map` on a many-to-many (`Map<String, Book> books` with a `Set` or another `Map` on the other side) writes its own join table, with the key column of the map as part of the primary key, as with classic binding. A `Set` on the other side reads that table and does not write it, and two maps are two separate tables: rows stored through one map are not visible through the other, in both bindings.
+* `type: 'serializable'` on a property that is not a collection (a `String`, a `byte[]` or any `Serializable`) is a binary column holding the Java serialization of the value, as with classic binding.
 * Identifier generators, inheritance strategies (single table, joined and table-per-concrete-class), discriminators, natural ids, optimistic locking, lazy and eager fetching, cascades, sorting, batch size and `cache` usage are mapped as with classic binding.
 
 [[nativeDomainBinding-refused]]
@@ -97,8 +98,8 @@ Nothing is ignored silently. A mapping that boots is bound the way it is written
 | `Collection property [...]: the type [...] mapped on the collection property names neither a class nor serializable ...` or `... a custom collection type (a UserCollectionType) is mapped on the collection property ...`
 | Remove the `type`, or use `'serializable'` or a `UserType` class to store the collection in one column. Classic binding does not boot with a registered type name (it fails with `Could not load requested class`); a custom collection type is not described yet.
 
-| A `type` that is a registered Hibernate type name for a different Java type than the property, for example `type: 'serializable'` on a `String`.
-| `Type [serializable] of property [...] is registered for the Java type [java.io.Serializable], not the property's [java.lang.String] ...`
+| A `type` that is a registered Hibernate type name for a different Java type than the property, where the type is not an interface the property implements, for example `type: 'string'` on an `Integer`.
+| `Type [string] of property [...] is registered for the Java type [java.lang.String], not the property's [java.lang.Integer] ...`
 | Use a `UserType` class, or a type name registered for the property's own type.
 
 | A registered type name together with `params`.
@@ -165,7 +166,7 @@ Some bugs of classic binding are not copied by native binding. Native binding do
 [[nativeDomainBinding-schema]]
 === Differences in the Schema
 
-The schema of 685 groups of related domain classes was created with both bindings on H2 and compared: tables, columns with type, nullability, default, length, precision and scale, primary keys with their column order, foreign keys, unique keys, indexes, check constraints and sequences. Apart from the 8 groups that native binding refuses, these are the differences. Other databases are not covered by this comparison, so verify against your own database as described below.
+The schema of 689 groups of related domain classes was created with both bindings on H2 and compared: tables, columns with type, nullability, default, length, precision and scale, primary keys with their column order, foreign keys, unique keys, indexes, check constraints and sequences. Apart from the 7 groups that native binding refuses, these are the differences. Other databases are not covered by this comparison, so verify against your own database as described below.
 
 [cols="2,3,3", options="header"]
 |===