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/05 12:55:37 UTC

(grails-core) 20/29: feat: state column indexes and unique groups in GrailsDomainGenerator

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 6408bfeb74169828e711e1b961aa601732038442
Author: Walter Duque de Estrada <wb...@mac.com>
AuthorDate: Mon Oct 5 04:13:00 2026 -0500

    feat: state column indexes and unique groups in GrailsDomainGenerator
    
    The generator emitted a plain unique column and nothing else, so an index: or
    a unique: group in a mapping vanished from the generated class without a word.
    It now runs the binder's own IndexBinder and CreateKeyForProps on a scratch
    table, in the order the binder binds the columns (identifier, version, each
    property, each embedded leaf, to-one foreign keys), and writes the result into
    @Table(indexes, uniqueConstraints) with the binder's names and column order.
    The single-table subclasses of a hierarchy contribute to the root's table.
    
    What the binder does that annotations should not copy is rejected by name: an
    index or a unique group on a collection property (the binder indexes the key
    column of the collection table, and the group names columns the collection
    table does not have) and a unique group on an enum property (EnumTypeBinder
    never creates the key).
    
    The differential now compares the indexes and unique keys of every table an
    entity owns, and reads them back through Hibernate's annotation binder. Four
    binder defects found on the way are pinned as @PendingFeature in
    GrailsDomainBinderOptionDefectSpec: insertable/updatable false is ignored, a
    unique group on an enum makes no key, and one on a collection makes a key over a
    column the collection table lacks.
    
    Co-Authored-By: Claude Sonnet 5.5 <no...@anthropic.com>
---
 .../cfg/domainbinding/jpa/ConstraintFacets.groovy  |  37 +++
 .../domainbinding/jpa/GrailsDomainGenerator.groovy | 166 ++++++++++++++
 .../cfg/domainbinding/jpa/IndexFacets.groovy       |  33 +++
 .../cfg/domainbinding/jpa/UniqueKeyFacets.groovy   |  33 +++
 .../jpa/GrailsDomainBinderOptionDefectSpec.groovy  | 145 ++++++++++++
 .../GrailsDomainGeneratorDifferentialSpec.groovy   | 110 ++++++++-
 .../jpa/GrailsDomainGeneratorIndexSpec.groovy      | 247 +++++++++++++++++++++
 .../jpa/GrailsDomainGeneratorSupport.groovy        |  81 +++++++
 .../GrailsDomainGeneratorUniqueGroupSpec.groovy    | 246 ++++++++++++++++++++
 9 files changed, 1093 insertions(+), 5 deletions(-)

diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/ConstraintFacets.groovy b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/ConstraintFacets.groovy
new file mode 100644
index 0000000000..91103139a5
--- /dev/null
+++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/ConstraintFacets.groovy
@@ -0,0 +1,37 @@
+/*
+ *  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 org.grails.orm.hibernate.cfg.domainbinding.jpa
+
+import groovy.transform.CompileStatic
+
+/**
+ * The indexes and multi-column unique keys the domain binder puts on one table, in the order it creates them, with the
+ * names and column order it gives them. {@link GrailsDomainGenerator} writes them into
+ * {@code @Table(indexes, uniqueConstraints)}.
+ *
+ * <p>The indexes come from {@code index:} on a column ({@code IndexBinder}), the unique keys from {@code unique:} with
+ * a group of properties ({@code CreateKeyForProps}). A plain {@code unique: true} is a unique column, not a unique key.</p>
+ *
+ * @since 9.0
+ */
+@CompileStatic
+record ConstraintFacets(
+    List<IndexFacets> indexes,
+    List<UniqueKeyFacets> uniqueKeys) {
+}
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 bfb77a3139..0fdabb9de5 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
@@ -41,6 +41,7 @@ import jakarta.persistence.EnumType
 import jakarta.persistence.Enumerated
 import jakarta.persistence.FetchType
 import jakarta.persistence.Id
+import jakarta.persistence.Index
 import jakarta.persistence.Inheritance
 import jakarta.persistence.InheritanceType
 import jakarta.persistence.JoinColumn
@@ -54,6 +55,7 @@ import jakarta.persistence.OrderBy
 import jakarta.persistence.OrderColumn
 import jakarta.persistence.PrimaryKeyJoinColumn
 import jakarta.persistence.Table
+import jakarta.persistence.UniqueConstraint
 import jakarta.persistence.Version
 import net.bytebuddy.ByteBuddy
 import net.bytebuddy.description.annotation.AnnotationDescription
@@ -110,6 +112,7 @@ import org.grails.orm.hibernate.cfg.PersistentEntityNamingStrategy
 import org.grails.orm.hibernate.cfg.PropertyConfig
 import org.grails.orm.hibernate.cfg.domainbinding.binder.ColumnConfigToColumnBinder
 import org.grails.orm.hibernate.cfg.domainbinding.binder.GrailsDomainBinder
+import org.grails.orm.hibernate.cfg.domainbinding.binder.IndexBinder
 import org.grails.orm.hibernate.cfg.domainbinding.binder.NumericColumnConstraintsBinder
 import org.grails.orm.hibernate.cfg.domainbinding.binder.StringColumnConstraintsBinder
 import org.grails.orm.hibernate.cfg.domainbinding.generator.GrailsIdentityGenerator
@@ -138,6 +141,7 @@ import org.grails.orm.hibernate.cfg.domainbinding.hibernate.HibernateToOneProper
 import org.grails.orm.hibernate.cfg.domainbinding.util.CascadeBehavior
 import org.grails.orm.hibernate.cfg.domainbinding.util.CascadeBehaviorFetcher
 import org.grails.orm.hibernate.cfg.domainbinding.util.ColumnNameForPropertyAndPathFetcher
+import org.grails.orm.hibernate.cfg.domainbinding.util.CreateKeyForProps
 import org.grails.orm.hibernate.cfg.domainbinding.util.DefaultColumnNameFetcher
 import org.grails.orm.hibernate.cfg.domainbinding.util.GrailsEnumType
 import org.grails.orm.hibernate.cfg.domainbinding.util.TableForManyCalculator
@@ -171,6 +175,8 @@ class GrailsDomainGenerator {
     private final TableForManyCalculator tableForMany
     private final DefaultColumnNameFetcher defaultColumnNames
     private final CascadeBehaviorFetcher cascadeFetcher = new CascadeBehaviorFetcher()
+    private final IndexBinder indexBinder = new IndexBinder()
+    private final CreateKeyForProps keyForProps
 
     GrailsDomainGenerator(
             PersistentEntityNamingStrategy namingStrategy,
@@ -188,6 +194,7 @@ class GrailsDomainGenerator {
         // the table name rules never touch the metadata collector; only the schema and catalog defaults do, and those are mirrored here
         this.tableForMany = new TableForManyCalculator(namingStrategy, null)
         this.defaultColumnNames = new DefaultColumnNameFetcher(namingStrategy)
+        this.keyForProps = new CreateKeyForProps(columnNames)
     }
 
     /**
@@ -429,6 +436,11 @@ class GrailsDomainGenerator {
                 return "Association property [${property.name}] of [${entity.name}]: ${problem}"
             }
         }
+        if (property instanceof HibernateEnumProperty && property.hibernateMappedForm.isUniqueWithinGroup()) {
+            return "Enum property [${property.name}] of [${entity.name}] is in a unique group, which the binder ignores for an enum " +
+                    '(it creates no unique key and leaves the column not unique), so the constraint the mapping asks for would exist ' +
+                    'only in the generated model'
+        }
         if ((property instanceof HibernateSimpleProperty || property instanceof HibernateTenantIdProperty) && !decideType(property).supported) {
             return typeNotSupported(property, decideType(property).name)
         }
@@ -474,6 +486,104 @@ class GrailsDomainGenerator {
                 sharesTable ? null : entity.comment)
     }
 
+    /**
+     * Decides the indexes and multi-column unique keys the domain binder puts on the table the entity owns. The binder's own
+     * {@code IndexBinder} and {@code CreateKeyForProps} run, on a scratch table with the entity's table name, for every column
+     * the binder passes through {@code ColumnBinder} (a simple property, the identifier, the version, a to-one foreign key, each
+     * leaf of an embedded type) or {@code EnumTypeBinder} (an enum, which only gets an index), in the order the binder binds
+     * them, so the names ({@code <table>_<column>_idx}, {@code UK} and a hash) and the column order are the binder's. The
+     * single-table subclasses of a hierarchy share the table of the root, so their columns are part of the root's constraints.
+     *
+     * @param entity an entity that owns its table: a root, or a joined or table-per-class subclass
+     */
+    ConstraintFacets constraintFacets(GrailsHibernatePersistentEntity entity) {
+        org.hibernate.mapping.Table table = new org.hibernate.mapping.Table('grails', entityFacets(entity).tableName().replace('`', ''))
+        List<GrailsHibernatePersistentEntity> contributors = [entity]
+        collectSharingSubclasses(entity, contributors)
+        for (GrailsHibernatePersistentEntity contributor : contributors) {
+            List<ConstraintSite> sites = []
+            collectConstraintSites(contributor, sites)
+            for (ConstraintSite site : sites) {
+                Column column = new Column(site.columnName)
+                indexBinder.bindIndex(site.columnName, column, site.columnConfig, table)
+                if (!site.enumeration) {
+                    keyForProps.createKeyForProps(site.property, site.path, table, site.columnName)
+                }
+            }
+        }
+        return new ConstraintFacets(
+                table.indexes.values().collect { org.hibernate.mapping.Index index ->
+                    new IndexFacets(index.name, index.columns*.name)
+                },
+                table.uniqueKeys.values().collect { org.hibernate.mapping.UniqueKey key ->
+                    new UniqueKeyFacets(key.name, key.columns*.name)
+                })
+    }
+
+    private static void collectSharingSubclasses(GrailsHibernatePersistentEntity entity, List<GrailsHibernatePersistentEntity> into) {
+        for (GrailsHibernatePersistentEntity child : entity.childEntities) {
+            if (child.isTablePerHierarchySubclass()) {
+                into << child
+                collectSharingSubclasses(child, into)
+            }
+        }
+    }
+
+    private void collectConstraintSites(GrailsHibernatePersistentEntity contributor, List<ConstraintSite> sites) {
+        List<HibernatePersistentProperty> properties = []
+        if (contributor.isRoot()) {
+            if (contributor.identity instanceof HibernateSimpleIdentityProperty) {
+                properties << (HibernatePersistentProperty) contributor.identity
+            }
+            if (contributor.version != null) {
+                properties << contributor.version
+            }
+        }
+        properties.addAll(contributor.persistentPropertiesToBind)
+        for (HibernatePersistentProperty property : properties) {
+            collectConstraintSites(property, '', sites)
+        }
+    }
+
+    private void collectConstraintSites(HibernatePersistentProperty property, String path, List<ConstraintSite> sites) {
+        if (property instanceof HibernateEmbeddedProperty) {
+            HibernateEmbeddedProperty embedded = (HibernateEmbeddedProperty) property
+            String current = path.isEmpty() ? embedded.name : "${path}.${embedded.name}".toString()
+            for (HibernatePersistentProperty peer : embeddedPeers(embedded)) {
+                collectConstraintSites(peer, current, sites)
+            }
+        } else if (property instanceof HibernateBasicProperty || property instanceof HibernateToManyEntityProperty ||
+                isDerived(property) || (property instanceof HibernateToOneProperty && boundAsOneToOne((HibernateToOneProperty) property))) {
+            return
+        } else {
+            ColumnConfig columnConfig = firstColumnConfig(property.hibernateMappedForm)
+            boolean enumeration = property instanceof HibernateEnumProperty
+            String name = enumeration ?
+                    ((HibernateEnumProperty) property).resolveEnumColumnName(namingStrategy, columnNames, path) :
+                    columnNames.getColumnNameForPropertyAndPath(property, path, columnConfig)
+            sites << new ConstraintSite(property, path, name, columnConfig, enumeration)
+        }
+    }
+
+    /** One column the binder passes through {@code ColumnBinder} or {@code EnumTypeBinder}: where its index and unique group come from. */
+    private static final class ConstraintSite {
+
+        final HibernatePersistentProperty property
+        final String path
+        final String columnName
+        final ColumnConfig columnConfig
+        final boolean enumeration
+
+        ConstraintSite(
+                HibernatePersistentProperty property, String path, String columnName, ColumnConfig columnConfig, boolean enumeration) {
+            this.property = property
+            this.path = path
+            this.columnName = columnName
+            this.columnConfig = columnConfig
+            this.enumeration = enumeration
+        }
+    }
+
     /**
      * Decides where the entity sits in its inheritance hierarchy the way the domain binder does: the strategy is the
      * one the entity's own accessors report ({@code isUnionSubclass}, {@code isJoinedSubclass}, else single table),
@@ -600,6 +710,9 @@ class GrailsDomainGenerator {
             // the enum binder never reads the formula, it always binds a column
             return false
         }
+        if (property instanceof HibernateEnumProperty && mappedForm.isUniqueWithinGroup()) {
+            return false
+        }
         return decideType(property).supported
     }
 
@@ -786,6 +899,10 @@ 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.lazy == Boolean.TRUE) {
             return 'an explicit lazy: true makes the binder bind an extra-lazy collection, which Hibernate 7 annotations cannot state'
         }
@@ -818,6 +935,32 @@ class GrailsDomainGenerator {
         return null
     }
 
+    /**
+     * 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.
+     *
+     * @return why the generator rejects the constraint on the collection property, or {@code null} when there is none the binder binds
+     */
+    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'
+        }
+        return null
+    }
+
     /**
      * 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.
@@ -976,6 +1119,10 @@ class GrailsDomainGenerator {
         if (mapped.lazy == Boolean.TRUE) {
             return 'an explicit lazy: true makes the binder bind an extra-lazy collection, which Hibernate 7 annotations cannot state'
         }
+        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)) {
@@ -1497,6 +1644,25 @@ class GrailsDomainGenerator {
             if (facets.catalog()) {
                 table = table.define('catalog', facets.catalog())
             }
+            ConstraintFacets constraints = constraintFacets(entity)
+            if (!constraints.indexes().isEmpty()) {
+                table = table.defineAnnotationArray('indexes', TypeDescription.ForLoadedType.of(Index),
+                        constraints.indexes().collect { IndexFacets index ->
+                            AnnotationDescription.Builder.ofType(Index)
+                                    .define('name', index.name())
+                                    .define('columnList', index.columns().join(', '))
+                                    .build()
+                        } as AnnotationDescription[])
+            }
+            if (!constraints.uniqueKeys().isEmpty()) {
+                table = table.defineAnnotationArray('uniqueConstraints', TypeDescription.ForLoadedType.of(UniqueConstraint),
+                        constraints.uniqueKeys().collect { UniqueKeyFacets key ->
+                            AnnotationDescription.Builder.ofType(UniqueConstraint)
+                                    .define('name', key.name())
+                                    .defineArray('columnNames', key.columns() as String[])
+                                    .build()
+                        } as AnnotationDescription[])
+            }
             annotations << table.build()
         }
         annotations.addAll(hierarchyAnnotations(entity, hierarchy))
diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/IndexFacets.groovy b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/IndexFacets.groovy
new file mode 100644
index 0000000000..4c41706909
--- /dev/null
+++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/IndexFacets.groovy
@@ -0,0 +1,33 @@
+/*
+ *  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 org.grails.orm.hibernate.cfg.domainbinding.jpa
+
+import groovy.transform.CompileStatic
+
+/**
+ * An index the domain binder puts on a table: its name and its columns in the order it added them.
+ * {@link GrailsDomainGenerator} writes it into {@code @Table(indexes)}.
+ *
+ * @since 9.0
+ */
+@CompileStatic
+record IndexFacets(
+    String name,
+    List<String> columns) {
+}
diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/UniqueKeyFacets.groovy b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/UniqueKeyFacets.groovy
new file mode 100644
index 0000000000..5cf459079a
--- /dev/null
+++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/UniqueKeyFacets.groovy
@@ -0,0 +1,33 @@
+/*
+ *  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 org.grails.orm.hibernate.cfg.domainbinding.jpa
+
+import groovy.transform.CompileStatic
+
+/**
+ * A multi-column unique key the domain binder puts on a table: its name and its columns in the order it added them.
+ * {@link GrailsDomainGenerator} writes it into {@code @Table(uniqueConstraints)}.
+ *
+ * @since 9.0
+ */
+@CompileStatic
+record UniqueKeyFacets(
+    String name,
+    List<String> columns) {
+}
diff --git a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainBinderOptionDefectSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainBinderOptionDefectSpec.groovy
new file mode 100644
index 0000000000..3fc14c0c19
--- /dev/null
+++ b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainBinderOptionDefectSpec.groovy
@@ -0,0 +1,145 @@
+/*
+ *  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 org.grails.orm.hibernate.cfg.domainbinding.jpa
+
+import grails.gorm.annotation.Entity
+import grails.gorm.tests.HibernateGormDatastoreSpec
+import org.hibernate.mapping.Collection as HibernateCollection
+import org.hibernate.mapping.Table
+import spock.lang.PendingFeature
+
+/**
+ * Pins defects of the domain binder found while auditing which mapping options {@link GrailsDomainGenerator} drops. The
+ * binder silently ignores the options below; the generator either states what the mapping asks for (and the differential spec
+ * lists the difference) or rejects the mapping by name. Each feature reports as fixed when the binder is.
+ */
+class GrailsDomainBinderOptionDefectSpec extends HibernateGormDatastoreSpec {
+
+    void setupSpec() {
+        manager.registerDomainClasses(DefectWritable, DefectReadOnlyColumns, DefectEnumGroup, DefectCollectionGroup)
+    }
+
+    void "a column with no write restriction is inserted and updated"() {
+        given:
+        DefectWritable row = new DefectWritable(inserted: 'a', updated: 'b').save(flush: true, failOnError: true)
+        session.clear()
+        DefectWritable loaded = DefectWritable.get(row.id)
+        loaded.updated = 'c'
+        loaded.save(flush: true, failOnError: true)
+        session.clear()
+
+        expect:
+        DefectWritable.get(row.id).inserted == 'a'
+        DefectWritable.get(row.id).updated == 'c'
+    }
+
+    @PendingFeature(reason = 'PropertyBinder overwrites the insertable flag of the property with the one of its columns, which are always insertable, so insertable: false is ignored')
+    void "insertable false keeps the value out of the insert"() {
+        given:
+        DefectReadOnlyColumns row = new DefectReadOnlyColumns(inserted: 'a', updated: 'b').save(flush: true, failOnError: true)
+        session.clear()
+
+        expect:
+        DefectReadOnlyColumns.get(row.id).inserted == null
+    }
+
+    @PendingFeature(reason = 'PropertyBinder overwrites the updatable flag of the property with the one of its columns, which are always updatable, so updatable: false is ignored')
+    void "updatable false keeps the value out of the update"() {
+        given:
+        DefectReadOnlyColumns row = new DefectReadOnlyColumns(updated: 'b').save(flush: true, failOnError: true)
+        session.clear()
+        DefectReadOnlyColumns loaded = DefectReadOnlyColumns.get(row.id)
+        loaded.updated = 'c'
+        loaded.save(flush: true, failOnError: true)
+        session.clear()
+
+        expect:
+        DefectReadOnlyColumns.get(row.id).updated == 'b'
+    }
+
+    @PendingFeature(reason = 'EnumTypeBinder never calls CreateKeyForProps, so a unique group on an enum property makes no unique key')
+    void "a unique group on an enum property is unique in the database"() {
+        given:
+        new DefectEnumGroup(state: DefectState.ON, other: 'a').save(flush: true, failOnError: true)
+        session.clear()
+
+        when:
+        new DefectEnumGroup(state: DefectState.ON, other: 'a').save(flush: true, validate: false)
+
+        then:
+        thrown(Exception)
+    }
+
+    @PendingFeature(reason = 'CollectionKeyBinder runs ColumnBinder on the collection property, so a unique group becomes a unique key of the collection table that names a column of the owner\'s table')
+    void "the unique key of a collection table names only columns of that table"() {
+        given:
+        Table table = ((HibernateCollection) getPersistentEntity(DefectCollectionGroup).persistentClass.getProperty('tags').value).collectionTable
+        Set<String> columns = table.columns*.name.toSet()
+
+        expect:
+        table.uniqueKeys.values().every { it.columns*.name.every { String name -> columns.contains(name) } }
+    }
+}
+
+@Entity
+class DefectWritable {
+
+    String inserted
+    String updated
+}
+
+@Entity
+class DefectReadOnlyColumns {
+
+    String inserted
+    String updated
+
+    static mapping = {
+        inserted insertable: false
+        updated updatable: false
+    }
+}
+
+@Entity
+class DefectEnumGroup {
+
+    DefectState state
+    String other
+
+    static mapping = {
+        state unique: 'other'
+    }
+}
+
+@Entity
+class DefectCollectionGroup {
+
+    String x
+    Set<String> tags
+
+    static hasMany = [tags: String]
+
+    static mapping = {
+        tags unique: 'x'
+    }
+}
+
+enum DefectState {
+    ON, OFF
+}
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 5f6c8179f9..135c8ea8fa 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
@@ -135,6 +135,7 @@ class GrailsDomainGeneratorDifferentialSpec extends HibernateGormDatastoreSpec {
         Map<String, Integer> hierarchies = [:].withDefault { 0 }
         Map<String, Integer> annotationRead = [:].withDefault { 0 }
         Map<String, Integer> associations = [:].withDefault { 0 }
+        Map<String, Integer> constraints = [:].withDefault { 0 }
 
         when:
         for (List<Class<?>> group : groups) {
@@ -161,6 +162,9 @@ class GrailsDomainGeneratorDifferentialSpec extends HibernateGormDatastoreSpec {
                         hierarchies["${hierarchy.strategy() ?: 'none'}${entity.isRoot() ? ' root' : ' subclass'}".toString()]++
                         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(compareTenantFilter(entity, generator, (SessionFactoryImplementor) datastore.sessionFactory, skipped, tenants))
                     if (entity.isRoot()) {
@@ -251,7 +255,7 @@ class GrailsDomainGeneratorDifferentialSpec extends HibernateGormDatastoreSpec {
                                 continue
                             }
                             // Hibernate copies the length, precision and scale of the referenced identifier onto a foreign key column after binding
-                            mismatches.addAll(compare(where, generator.columnFacets(property), bound, ['length', 'precision', 'scale']))
+                            mismatches.addAll(compare(where, generator.columnFacets(property), bound, known, ['length', 'precision', 'scale']))
                             mismatches.addAll(compareToOne(where, generator.toOneFacets((HibernateToOneProperty) property), bound, known))
                             continue
                         }
@@ -268,7 +272,7 @@ class GrailsDomainGeneratorDifferentialSpec extends HibernateGormDatastoreSpec {
                         }
                         compared++
                         String where = "${entity.name}.${property.name}".toString()
-                        mismatches.addAll(compare(where, generator.columnFacets(property), bound))
+                        mismatches.addAll(compare(where, generator.columnFacets(property), bound, known))
                         if (property instanceof HibernateEnumProperty && generator.typeFacets(property) == null) {
                             mismatches.addAll(compareEnum(where, (HibernateEnumProperty) property, generator, bound))
                         }
@@ -290,6 +294,7 @@ class GrailsDomainGeneratorDifferentialSpec extends HibernateGormDatastoreSpec {
         report << "collections of basic values compared by kind: ${collections}\n"
         report << "associations compared by kind: ${associations}\n"
         report << "tenant filters compared: ${tenants}\n"
+        report << "table constraints compared: ${constraints}\n"
         report << "id generators compared by strategy: ${strategies}\n"
         report << "entities compared by hierarchy role: ${hierarchies}\n"
         report << "hierarchies read back through Hibernate's annotation binder: ${annotationRead}\n"
@@ -324,7 +329,14 @@ class GrailsDomainGeneratorDifferentialSpec extends HibernateGormDatastoreSpec {
         return new HibernateDatastore(group as Class[])
     }
 
-    private List<String> compare(String where, ColumnFacets facets, Property bound, Collection<String> ignore = []) {
+    /**
+     * The column facets the generator decided against the bound column and property. A mapping that says {@code insertable: false}
+     * or {@code updatable: false} is a known divergence, not a mismatch: {@code PropertyBinder} overwrites those flags with the
+     * ones of the columns, which are always set, so the binder ignores the option (pinned in
+     * {@link GrailsDomainBinderOptionDefectSpec}) and the generator states what the mapping asks for.
+     */
+    private List<String> compare(
+            String where, ColumnFacets facets, Property bound, Map<String, Integer> known, Collection<String> ignore = []) {
         Column column = (Column) bound.columns[0]
         Map<String, List> pairs = [
                 name      : [facets.name().replace('`', ''), column.name],
@@ -347,11 +359,58 @@ class GrailsDomainGeneratorDifferentialSpec extends HibernateGormDatastoreSpec {
             pairs.remove('sqlType')
         }
         ignore.each { pairs.remove(it) }
+        ['insertable', 'updatable'].each { String facet ->
+            if (pairs[facet] != null && pairs[facet][0] == false && pairs[facet][1] == true) {
+                known["the binder ignores insertable: false and updatable: false in a mapping (PropertyBinder overwrites them); the generator states them".toString()]++
+                pairs.remove(facet)
+            }
+        }
         return pairs.findAll { String facet, List values -> values[0] != values[1] }.collect { String facet, List values ->
             "${where} ${facet}: generator=${values[0]} binder=${values[1]}".toString()
         }
     }
 
+    /**
+     * 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) {
+        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>> generatedIndexes = facets.indexes().collectEntries { IndexFacets index -> [(index.name()): index.columns()] } as Map<String, List<String>>
+        Map<String, List<String>> generatedKeys = facets.uniqueKeys().collectEntries { UniqueKeyFacets key -> [(key.name()): key.columns()] } as Map<String, List<String>>
+        constraints['indexes'] += boundIndexes.size()
+        constraints['unique keys'] += boundKeys.size()
+        List<String> found = []
+        if (generatedIndexes != boundIndexes) {
+            found << "${where} indexes: generator=${generatedIndexes} binder=${boundIndexes}".toString()
+        }
+        if (generatedKeys != boundKeys) {
+            found << "${where} uniqueKeys: generator=${generatedKeys} binder=${boundKeys}".toString()
+        }
+        return found
+    }
+
+    /**
+     * The unique keys of the table that a mapping states with a group, by name. Left out: the single-column keys Hibernate derives
+     * 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() }
+        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 ->
+            [(key.name): key.columns*.name]
+        } as Map<String, List<String>>
+    }
+
     /**
      * The tenant filter {@code MultiTenantFilterBinder} puts on the entity's class and the one global definition it
      * registers: the same name, condition and parameter, or no filter at all when the generator says there is none.
@@ -514,9 +573,22 @@ class GrailsDomainGeneratorDifferentialSpec extends HibernateGormDatastoreSpec {
                 found << "${where} tableColumns: generator=${described} binder=${collection.collectionTable.columns*.name}".toString()
             }
         }
+        found.addAll(compareCollectionTableIndexes(where, collection))
         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.
+     */
+    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()
+        }
+        return indexes.isEmpty() ? [] : ["${where} collectionTableIndexes: generator=[] binder=${indexes}".toString()]
+    }
+
     /** The key of a collection of basic values must be updatable: Hibernate writes no rows for a collection whose key is not. */
     private static List<String> keyUpdatable(
             String where, HibernateBasicProperty property, CollectionFacets facets, DependantValue key, Map<String, Integer> known) {
@@ -601,11 +673,11 @@ class GrailsDomainGeneratorDifferentialSpec extends HibernateGormDatastoreSpec {
                 continue
             } else if (leaf.toOne() != null) {
                 // Hibernate copies the size of the referenced identifier onto a foreign key column after binding
-                found.addAll(compare(leafWhere, leaf.column(), boundLeaf, ['length', 'precision', 'scale']))
+                found.addAll(compare(leafWhere, leaf.column(), boundLeaf, known, ['length', 'precision', 'scale']))
                 found.addAll(compareToOne(leafWhere, leaf.toOne(), boundLeaf, known))
                 continue
             } else {
-                found.addAll(compare(leafWhere, leaf.column(), boundLeaf))
+                found.addAll(compare(leafWhere, leaf.column(), boundLeaf, known))
                 if (leaf.property instanceof HibernateEnumProperty && generator.typeFacets(leaf.property) == null) {
                     found.addAll(compareEnum(leafWhere, (HibernateEnumProperty) leaf.property, generator, boundLeaf))
                 }
@@ -956,6 +1028,9 @@ class GrailsDomainGeneratorDifferentialSpec extends HibernateGormDatastoreSpec {
         if (bound instanceof RootClass) {
             found.addAll(compareAnnotatedDiscriminator(where, (RootClass) bound, (RootClass) annotated))
         }
+        if (pairs.ownsTable[0]) {
+            found.addAll(compareAnnotatedConstraints(where, bound, annotated))
+        }
         Map<String, Map<String, Object>> expectations = leafExpectations(generator, entity)
         for (Property property : bound.declaredProperties) {
             Property other = annotated.hasProperty(property.name) ? annotated.getProperty(property.name) : null
@@ -1020,6 +1095,30 @@ class GrailsDomainGeneratorDifferentialSpec extends HibernateGormDatastoreSpec {
         return found
     }
 
+    /**
+     * The indexes and unique keys Hibernate's annotation binder puts on the table of the generated class: the same as the
+     * binder's, by name and column order. Hibernate adds a unique key of its own for a natural id, with its own name, so that one
+     * is compared by columns in the natural id comparison and not here.
+     */
+    private static List<String> compareAnnotatedConstraints(String where, PersistentClass bound, PersistentClass annotated) {
+        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>>
+        Map<String, List<String>> annotatedIndexes = annotated.table.indexes.values().collectEntries { org.hibernate.mapping.Index index ->
+            [(index.name): index.columns*.name]
+        } as Map<String, List<String>>
+        List<String> found = []
+        if (boundIndexes != annotatedIndexes) {
+            found << "${where} indexes: generator=${annotatedIndexes} binder=${boundIndexes}".toString()
+        }
+        Map<String, List<String>> boundKeys = tableKeysWithoutNaturalId(bound)
+        Map<String, List<String>> annotatedKeys = tableKeysWithoutNaturalId(annotated)
+        if (boundKeys != annotatedKeys) {
+            found << "${where} uniqueKeys: generator=${annotatedKeys} binder=${boundKeys}".toString()
+        }
+        return found
+    }
+
     /** The names of the properties a class declares itself: the binder's and Hibernate's back references are not part of the field set. */
     private static Set<String> declaredNames(PersistentClass persistentClass) {
         return persistentClass.declaredProperties.findAll { !(it instanceof Backref) && !(it instanceof IndexBackref) }*.name.toSet()
@@ -1244,6 +1343,7 @@ class GrailsDomainGeneratorDifferentialSpec extends HibernateGormDatastoreSpec {
             })
             // Hibernate copies the size of the referenced identifier onto the element column after binding
             found.addAll(circularOrFound(property, compareValueColumn("${where} element".toString(), facets.element(), collection.element, collection.collectionTable, true), known))
+            found.addAll(compareCollectionTableIndexes(where, collection))
         }
         found.addAll(circularOrFound(property, compareValueColumn("${where} key".toString(), facets.key(), 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
new file mode 100644
index 0000000000..1002af30a9
--- /dev/null
+++ b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorIndexSpec.groovy
@@ -0,0 +1,247 @@
+/*
+ *  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 org.grails.orm.hibernate.cfg.domainbinding.jpa
+
+import grails.gorm.annotation.Entity
+import jakarta.persistence.Table
+import org.hibernate.boot.Metadata
+import org.hibernate.mapping.PersistentClass
+
+import org.grails.orm.hibernate.cfg.domainbinding.hibernate.GrailsHibernatePersistentEntity
+
+/**
+ * Describes how {@link GrailsDomainGenerator} states the indexes a mapping asks for with {@code index:} on a column. The
+ * names and the column order are the domain binder's ({@code IndexBinder}: {@code <table>_<column>_idx} for
+ * {@code true}, the given names for a string, several names separated by commas, and {@code false} is none); the
+ * differential spec compares them on every domain class.
+ */
+class GrailsDomainGeneratorIndexSpec extends GrailsDomainGeneratorSupport {
+
+    void setupSpec() {
+        manager.registerDomainClasses(
+                GenIdxTarget, GenIdxOwner, GenIdxChild, GenIdxJoinedRoot, GenIdxJoinedChild, GenIdxTypeless, GenIdxCollections)
+    }
+
+    void "a column index is stated on the table with the name the binder gives it"() {
+        when:
+        Map<GrailsHibernatePersistentEntity, Class<?>> classes = generateGroup(
+                GenIdxTarget, GenIdxOwner, GenIdxChild, GenIdxJoinedRoot, GenIdxJoinedChild)
+        Map<String, List<String>> indexes = generatedIndexes(classes[entity(GenIdxOwner)])
+
+        then:
+        indexes['gen_idx_owner_plain_idx'] == ['plain']
+        indexes['gen_idx_named'] == ['named']
+        indexes['gen_idx_target'] == ['target_id']
+        indexes['gen_idx_status'] == ['status']
+        indexes['gen_idx_zip'] == ['home_zip']
+    }
+
+    void "several names on one column and one name on several columns make composite and overlapping indexes"() {
+        when:
+        Map<GrailsHibernatePersistentEntity, Class<?>> classes = generateGroup(
+                GenIdxTarget, GenIdxOwner, GenIdxChild, GenIdxJoinedRoot, GenIdxJoinedChild)
+        Map<String, List<String>> indexes = generatedIndexes(classes[entity(GenIdxOwner)])
+
+        then:
+        indexes['gen_idx_shared'] == ['shared1', 'shared2']
+        indexes['gen_idx_other'] == ['shared2']
+    }
+
+    void "false is not an index"() {
+        when:
+        Map<GrailsHibernatePersistentEntity, Class<?>> classes = generateGroup(
+                GenIdxTarget, GenIdxOwner, GenIdxChild, GenIdxJoinedRoot, GenIdxJoinedChild)
+
+        then:
+        !generatedIndexes(classes[entity(GenIdxOwner)]).values().any { it.contains('off') }
+        generatedIndexes(newGenerator().generate(entity(GenIdxTypeless), getClass().classLoader)).isEmpty()
+    }
+
+    void "an index on a single-table subclass is stated on the table of the hierarchy, which is the root's"() {
+        when:
+        Map<GrailsHibernatePersistentEntity, Class<?>> classes = generateGroup(
+                GenIdxTarget, GenIdxOwner, GenIdxChild, GenIdxJoinedRoot, GenIdxJoinedChild)
+
+        then:
+        generatedIndexes(classes[entity(GenIdxOwner)])['gen_idx_owner_child_col_idx'] == ['child_col']
+        !classes[entity(GenIdxChild)].isAnnotationPresent(Table)
+    }
+
+    void "an index on a joined subclass is stated on the subclass's own table"() {
+        when:
+        Map<GrailsHibernatePersistentEntity, Class<?>> classes = generateGroup(
+                GenIdxTarget, GenIdxOwner, GenIdxChild, GenIdxJoinedRoot, GenIdxJoinedChild)
+
+        then:
+        generatedIndexes(classes[entity(GenIdxJoinedChild)]) == ['gen_idx_joined_child_extra_idx': ['extra']]
+        generatedIndexes(classes[entity(GenIdxJoinedRoot)]).isEmpty()
+    }
+
+    void "the generated indexes are the ones the binder bound, with the same names and column order"() {
+        when:
+        Map<GrailsHibernatePersistentEntity, Class<?>> classes = generateGroup(
+                GenIdxTarget, GenIdxOwner, GenIdxChild, GenIdxJoinedRoot, GenIdxJoinedChild)
+
+        then:
+        [GenIdxOwner, GenIdxJoinedChild].every { Class<?> domain ->
+            generatedIndexes(classes[entity(domain)]) == boundIndexes(domain)
+        }
+    }
+
+    void "Hibernate's annotation binder reads the generated indexes as the binder bound them"() {
+        given:
+        Map<GrailsHibernatePersistentEntity, Class<?>> classes = generateGroup(
+                GenIdxTarget, GenIdxOwner, GenIdxChild, GenIdxJoinedRoot, GenIdxJoinedChild)
+        Metadata metadata = annotationMetadata(classes.values())
+
+        expect:
+        readIndexes(metadata.getEntityBinding(classes[entity(GenIdxOwner)].name)) == boundIndexes(GenIdxOwner)
+        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"() {
+        when:
+        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]
+
+        where:
+        property | indexName
+        'tags'   | 'gen_idx_tags'
+    }
+
+    private Map<String, List<String>> boundIndexes(Class<?> domain) {
+        return entity(domain).persistentClass.table.indexes.values().collectEntries { org.hibernate.mapping.Index index ->
+            [(index.name): index.columns*.name]
+        } as Map<String, List<String>>
+    }
+
+    private static Map<String, List<String>> readIndexes(PersistentClass persistentClass) {
+        return persistentClass.table.indexes.values().collectEntries { org.hibernate.mapping.Index index ->
+            [(index.name): index.columns*.name]
+        } as Map<String, List<String>>
+    }
+
+    private static Map<String, List<String>> generatedIndexes(Class<?> generated) {
+        Table table = generated.getAnnotation(Table)
+        if (table == null) {
+            return [:]
+        }
+        return table.indexes().collectEntries { jakarta.persistence.Index index ->
+            [(index.name()): index.columnList().split(',').collect { it.trim() }]
+        } as Map<String, List<String>>
+    }
+}
+
+@Entity
+class GenIdxTarget {
+
+    String label
+}
+
+@Entity
+class GenIdxOwner {
+
+    String plain
+    String named
+    String shared1
+    String shared2
+    String off
+    GenIdxTarget target
+    GenIdxStatus status
+    GenIdxAddress home
+
+    static embedded = ['home']
+
+    static mapping = {
+        plain index: true
+        named index: 'gen_idx_named'
+        shared1 index: 'gen_idx_shared'
+        shared2 index: 'gen_idx_shared, gen_idx_other'
+        off index: false
+        target index: 'gen_idx_target'
+        status index: 'gen_idx_status'
+    }
+}
+
+@Entity
+class GenIdxChild extends GenIdxOwner {
+
+    String childCol
+
+    static mapping = {
+        childCol index: true
+    }
+}
+
+@Entity
+class GenIdxJoinedRoot {
+
+    String name
+
+    static mapping = {
+        tablePerHierarchy false
+    }
+}
+
+@Entity
+class GenIdxJoinedChild extends GenIdxJoinedRoot {
+
+    String extra
+
+    static mapping = {
+        extra index: true
+    }
+}
+
+@Entity
+class GenIdxTypeless {
+
+    String name
+}
+
+class GenIdxAddress {
+
+    String zip
+
+    static mapping = {
+        zip index: 'gen_idx_zip'
+    }
+}
+
+enum GenIdxStatus {
+    OPEN, CLOSED
+}
+
+@Entity
+class GenIdxCollections {
+
+    Set<String> tags
+
+    static hasMany = [tags: String]
+
+    static mapping = {
+        tags index: 'gen_idx_tags'
+    }
+}
diff --git a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorSupport.groovy b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorSupport.groovy
new file mode 100644
index 0000000000..ac6bc873b6
--- /dev/null
+++ b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorSupport.groovy
@@ -0,0 +1,81 @@
+/*
+ *  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 org.grails.orm.hibernate.cfg.domainbinding.jpa
+
+import grails.gorm.tests.HibernateGormDatastoreSpec
+import org.hibernate.boot.Metadata
+import org.hibernate.boot.MetadataSources
+import org.hibernate.boot.registry.BootstrapServiceRegistryBuilder
+import org.hibernate.boot.registry.StandardServiceRegistry
+import org.hibernate.boot.registry.StandardServiceRegistryBuilder
+import org.hibernate.dialect.H2Dialect
+
+import org.grails.orm.hibernate.cfg.domainbinding.binder.ColumnConfigToColumnBinder
+import org.grails.orm.hibernate.cfg.domainbinding.binder.NumericColumnConstraintsBinder
+import org.grails.orm.hibernate.cfg.domainbinding.binder.StringColumnConstraintsBinder
+import org.grails.orm.hibernate.cfg.domainbinding.hibernate.GrailsHibernatePersistentEntity
+import org.grails.orm.hibernate.cfg.domainbinding.util.BackticksRemover
+import org.grails.orm.hibernate.cfg.domainbinding.util.ColumnNameForPropertyAndPathFetcher
+import org.grails.orm.hibernate.cfg.domainbinding.util.DefaultColumnNameFetcher
+
+/**
+ * What the specs of {@link GrailsDomainGenerator} share: a generator wired like the domain binder, generation of a group
+ * of entities, and Hibernate's own annotation binder over the generated classes, so a spec can compare the generated
+ * class with what the domain binder bound.
+ */
+abstract class GrailsDomainGeneratorSupport extends HibernateGormDatastoreSpec {
+
+    private final List<StandardServiceRegistry> registries = []
+
+    void cleanup() {
+        registries.each { StandardServiceRegistryBuilder.destroy(it) }
+        registries.clear()
+    }
+
+    protected GrailsDomainGenerator newGenerator() {
+        def naming = getGrailsDomainBinder().getNamingStrategy()
+        return new GrailsDomainGenerator(
+                naming,
+                new ColumnNameForPropertyAndPathFetcher(naming, new DefaultColumnNameFetcher(naming), new BackticksRemover()),
+                new ColumnConfigToColumnBinder(),
+                new StringColumnConstraintsBinder(),
+                new NumericColumnConstraintsBinder(new H2Dialect()),
+                getSessionFactory().typeConfiguration)
+    }
+
+    protected GrailsHibernatePersistentEntity entity(Class<?> domainClass) {
+        return getPersistentEntity(domainClass)
+    }
+
+    protected Map<GrailsHibernatePersistentEntity, Class<?>> generateGroup(Class<?>... domainClasses) {
+        return newGenerator().generateAll(domainClasses.collect { entity(it) }, getClass().classLoader)
+    }
+
+    protected Metadata annotationMetadata(Collection<Class<?>> classes) {
+        StandardServiceRegistry registry = new StandardServiceRegistryBuilder(
+                new BootstrapServiceRegistryBuilder().applyClassLoader(classes.first().classLoader).build())
+                .applySetting('hibernate.dialect', H2Dialect.name)
+                .applySetting('hibernate.connection.url', 'jdbc:h2:mem:generator-support;DB_CLOSE_DELAY=-1')
+                .build()
+        registries << registry
+        MetadataSources sources = new MetadataSources(registry)
+        classes.each { sources.addAnnotatedClass(it) }
+        return sources.buildMetadata()
+    }
+}
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
new file mode 100644
index 0000000000..c1df8ddae1
--- /dev/null
+++ b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorUniqueGroupSpec.groovy
@@ -0,0 +1,246 @@
+/*
+ *  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 org.grails.orm.hibernate.cfg.domainbinding.jpa
+
+import grails.gorm.annotation.Entity
+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
+
+/**
+ * Describes how {@link GrailsDomainGenerator} states the unique constraints a mapping asks for with {@code unique: 'group'}
+ * or {@code unique: ['a', 'b']} on a column: one multi-column unique key per property that names a group, with the
+ * binder's column order (the property's own column last) and the binder's name ({@code UK} and a hash of the table and
+ * the columns). The differential spec compares them on every domain class.
+ */
+class GrailsDomainGeneratorUniqueGroupSpec extends GrailsDomainGeneratorSupport {
+
+    void setupSpec() {
+        manager.registerDomainClasses(GenUqTarget, GenUqOwner, GenUqChild, GenUqJoinedRoot, GenUqJoinedChild, GenUqEnum, GenUqCollection)
+    }
+
+    void "a unique group is a unique constraint on the table over the columns of the group"() {
+        when:
+        Map<GrailsHibernatePersistentEntity, Class<?>> classes = generateAll()
+        List<List<String>> constraints = generatedConstraints(classes[entity(GenUqOwner)]).values().toList()
+
+        then: "the column of the property that names the group comes last, as the binder orders it"
+        constraints.contains(['b', 'a'])
+        constraints.contains(['d', 'c'])
+        constraints.contains(['other_id', 'target_id'])
+        constraints.contains(['home_city', 'home_street'])
+    }
+
+    void "a plain unique is not a group: it stays a unique column"() {
+        when:
+        Map<GrailsHibernatePersistentEntity, Class<?>> classes = generateAll()
+
+        then:
+        !generatedConstraints(classes[entity(GenUqOwner)]).values().any { it == ['solo'] }
+        classes[entity(GenUqOwner)].getDeclaredField('solo').getAnnotation(jakarta.persistence.Column).unique()
+    }
+
+    void "a group of a single-table subclass is stated on the table of the hierarchy and one of a joined subclass on its own table"() {
+        when:
+        Map<GrailsHibernatePersistentEntity, Class<?>> classes = generateAll()
+
+        then:
+        generatedConstraints(classes[entity(GenUqOwner)]).values().toList().contains(['child_two', 'child_one'])
+        !classes[entity(GenUqChild)].isAnnotationPresent(Table)
+        generatedConstraints(classes[entity(GenUqJoinedChild)]).values().toList() == [['extra_two', 'extra_one']]
+        generatedConstraints(classes[entity(GenUqJoinedRoot)]).isEmpty()
+    }
+
+    void "the generated unique constraints are the ones the binder bound, with the same names and column order"() {
+        when:
+        Map<GrailsHibernatePersistentEntity, Class<?>> classes = generateAll()
+
+        then:
+        [GenUqOwner, GenUqJoinedChild].every { Class<?> domain ->
+            generatedConstraints(classes[entity(domain)]) == boundConstraints(domain)
+        }
+    }
+
+    void "Hibernate's annotation binder reads the generated unique constraints as the binder bound them"() {
+        given:
+        Map<GrailsHibernatePersistentEntity, Class<?>> classes = generateAll()
+        Metadata metadata = annotationMetadata(classes.values())
+
+        expect:
+        readConstraints(metadata.getEntityBinding(classes[entity(GenUqOwner)].name)) == boundConstraints(GenUqOwner)
+        readConstraints(metadata.getEntityBinding(classes[entity(GenUqJoinedChild)].name)) == boundConstraints(GenUqJoinedChild)
+    }
+
+    void "a unique group on an enum property is rejected by name, because the binder ignores it"() {
+        when:
+        generateGroup(GenUqEnum)
+
+        then: "the binder makes no unique key for it, so the constraint would exist only in the generated model"
+        UnsupportedOperationException e = thrown()
+        e.message.contains('GenUqEnum')
+        e.message.contains('state')
+        e.message.contains('unique group')
+        entity(GenUqEnum).persistentClass.table.uniqueKeys.values().every { it.columns.size() < 2 }
+    }
+
+    void "a unique group on a collection property is rejected by name, because the binder makes it a key of the collection table"() {
+        when:
+        generateGroup(GenUqCollection)
+
+        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')
+        entity(GenUqCollection).persistentClass.getProperty('tags').value.collectionTable.uniqueKeys.values().any {
+            it.columns*.name.contains('x')
+        }
+    }
+
+    private Map<GrailsHibernatePersistentEntity, Class<?>> generateAll() {
+        return generateGroup(GenUqTarget, GenUqOwner, GenUqChild, GenUqJoinedRoot, GenUqJoinedChild)
+    }
+
+    private Map<String, List<String>> boundConstraints(Class<?> domain) {
+        return readConstraints(entity(domain).persistentClass)
+    }
+
+    private static Map<String, List<String>> readConstraints(PersistentClass persistentClass) {
+        // Hibernate derives a single-column unique key, named UK_ and a hash, from every unique column once the metadata is complete
+        return persistentClass.table.uniqueKeys.values().findAll { org.hibernate.mapping.UniqueKey key ->
+            key.columns.size() > 1
+        }.collectEntries { org.hibernate.mapping.UniqueKey key ->
+            [(key.name): key.columns*.name]
+        } as Map<String, List<String>>
+    }
+
+    private static Map<String, List<String>> generatedConstraints(Class<?> generated) {
+        Table table = generated.getAnnotation(Table)
+        if (table == null) {
+            return [:]
+        }
+        return table.uniqueConstraints().collectEntries { UniqueConstraint constraint ->
+            [(constraint.name()): constraint.columnNames().toList()]
+        } as Map<String, List<String>>
+    }
+}
+
+@Entity
+class GenUqTarget {
+
+    String label
+}
+
+@Entity
+class GenUqOwner {
+
+    String a
+    String b
+    String c
+    String d
+    String solo
+    GenUqTarget target
+    GenUqTarget other
+    GenUqAddress home
+
+    static embedded = ['home']
+
+    static constraints = {
+        a unique: ['b']
+        solo unique: true
+    }
+
+    static mapping = {
+        c unique: 'd'
+        target unique: 'other'
+    }
+}
+
+@Entity
+class GenUqChild extends GenUqOwner {
+
+    String childOne
+    String childTwo
+
+    static mapping = {
+        childOne unique: 'childTwo'
+    }
+}
+
+@Entity
+class GenUqJoinedRoot {
+
+    String name
+
+    static mapping = {
+        tablePerHierarchy false
+    }
+}
+
+@Entity
+class GenUqJoinedChild extends GenUqJoinedRoot {
+
+    String extraOne
+    String extraTwo
+
+    static mapping = {
+        extraOne unique: 'extraTwo'
+    }
+}
+
+class GenUqAddress {
+
+    String street
+    String city
+
+    static mapping = {
+        street unique: 'city'
+    }
+}
+
+@Entity
+class GenUqEnum {
+
+    GenUqState state
+    String other
+
+    static mapping = {
+        state unique: 'other'
+    }
+}
+
+enum GenUqState {
+    ON, OFF
+}
+
+@Entity
+class GenUqCollection {
+
+    String x
+    Set<String> tags
+
+    static hasMany = [tags: String]
+
+    static mapping = {
+        tags unique: 'x'
+    }
+}