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:23 UTC

(grails-core) 06/29: feat: describe single-table inheritance hierarchies 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 0e01aace4e8fbb0a8a45329634d1dd976c2ad85c
Author: Walter Duque de Estrada <wb...@mac.com>
AuthorDate: Sun Oct 4 23:55:27 2026 -0500

    feat: describe single-table inheritance hierarchies in GrailsDomainGenerator
    
    Hibernate's annotation binder derives a hierarchy from extends, so the generator
    now builds the classes of a hierarchy together (generateAll): each subclass is
    generated against its superclass and all of them are loaded into one class loader,
    which a Hibernate registry can be given once. The root states @Inheritance,
    @DiscriminatorColumn or @DiscriminatorFormula, @DiscriminatorOptions for a
    non-inserted discriminator and @DiscriminatorValue; a subclass states only its
    value and its own class-level facets, never a table.
    
    Every decision is the binder's own accessor or constraint class; the differential
    spec now compares root and subclass entities (kind, superclass, table, abstractness,
    discriminator) with the bound PersistentClass and stays at zero mismatches.
    
    Co-Authored-By: Claude Sonnet 5.5 <no...@anthropic.com>
---
 .../domainbinding/jpa/DiscriminatorFacets.groovy   |  44 +++
 .../domainbinding/jpa/GrailsDomainGenerator.groovy | 309 +++++++++++++++--
 .../cfg/domainbinding/jpa/HierarchyFacets.groovy   |  45 +++
 .../GrailsDomainGeneratorDifferentialSpec.groovy   |  91 ++++-
 .../jpa/GrailsDomainGeneratorSpec.groovy           | 385 ++++++++++++++++++++-
 5 files changed, 829 insertions(+), 45 deletions(-)

diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/DiscriminatorFacets.groovy b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/DiscriminatorFacets.groovy
new file mode 100644
index 0000000000..5ef69a3064
--- /dev/null
+++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/DiscriminatorFacets.groovy
@@ -0,0 +1,44 @@
+/*
+ *  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
+import jakarta.persistence.DiscriminatorType
+
+/**
+ * The discriminator the domain binder gives the root of a table-per-hierarchy tree: a column or a formula, the type
+ * it is read as, and whether the discriminator is written on insert.
+ *
+ * <p>Exactly one of {@code column} and {@code formula} is set. {@code length} and {@code sqlType} are {@code null}
+ * when the mapping does not state them. {@code typeName} is the Hibernate type name the binder puts on the
+ * discriminator value ({@code string} unless the mapping says otherwise) and {@code type} is the JPA discriminator
+ * type that stands for it.</p>
+ *
+ * @since 9.0
+ */
+@CompileStatic
+record DiscriminatorFacets(
+    String column,
+    String formula,
+    String typeName,
+    DiscriminatorType type,
+    Integer length,
+    String sqlType,
+    boolean insertable) {
+}
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 bc6a33e80f..94006762a3 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
@@ -23,22 +23,32 @@ import java.lang.reflect.Method
 
 import groovy.transform.CompileStatic
 import jakarta.persistence.Column as JpaColumn
+import jakarta.persistence.DiscriminatorColumn
+import jakarta.persistence.DiscriminatorType
+import jakarta.persistence.DiscriminatorValue
 import jakarta.persistence.Entity
 import jakarta.persistence.EnumType
 import jakarta.persistence.Enumerated
 import jakarta.persistence.Id
+import jakarta.persistence.Inheritance
+import jakarta.persistence.InheritanceType
+import jakarta.persistence.PrimaryKeyJoinColumn
 import jakarta.persistence.Table
 import jakarta.persistence.Version
 import net.bytebuddy.ByteBuddy
 import net.bytebuddy.description.annotation.AnnotationDescription
+import net.bytebuddy.description.modifier.TypeManifestation
 import net.bytebuddy.description.modifier.Visibility
 import net.bytebuddy.description.type.TypeDescription
 import net.bytebuddy.dynamic.DynamicType
 import net.bytebuddy.dynamic.loading.ClassLoadingStrategy
+import org.hibernate.Length
 import org.hibernate.annotations.BatchSize
 import org.hibernate.annotations.ColumnDefault
 import org.hibernate.annotations.ColumnTransformer
 import org.hibernate.annotations.Comment
+import org.hibernate.annotations.DiscriminatorFormula
+import org.hibernate.annotations.DiscriminatorOptions
 import org.hibernate.annotations.DynamicInsert
 import org.hibernate.annotations.DynamicUpdate
 import org.hibernate.annotations.Formula
@@ -55,6 +65,7 @@ import org.hibernate.type.spi.TypeConfiguration
 import org.hibernate.usertype.UserType
 
 import org.grails.orm.hibernate.cfg.ColumnConfig
+import org.grails.orm.hibernate.cfg.DiscriminatorConfig
 import org.grails.orm.hibernate.cfg.HibernateSimpleIdentity
 import org.grails.orm.hibernate.cfg.IdentityEnumType
 import org.grails.orm.hibernate.cfg.Mapping
@@ -121,42 +132,127 @@ class GrailsDomainGenerator {
     }
 
     /**
-     * @param entity the entity to describe
+     * Generates the class for an entity that is not part of an inheritance hierarchy.
+     *
+     * @param entity the entity to describe; it must be a root with no subclasses
      * @param parent the class loader the generated class is loaded under; the class lives as long as it does
      * @return a new class whose annotations describe the entity
+     * @throws IllegalArgumentException when the entity is part of a hierarchy, which {@link #generateAll} describes
      */
     Class<?> generate(GrailsHibernatePersistentEntity entity, ClassLoader parent) {
-        if (!entity.isRoot()) {
-            throw new UnsupportedOperationException(
-                    "Entity [${entity.name}] is part of an inheritance hierarchy, which the generator does not support yet")
+        return generateAll([entity], parent).get(entity)
+    }
+
+    /**
+     * Generates one class for each entity, so that a hierarchy is described whole. Hibernate's annotation binder
+     * derives the hierarchy from {@code extends}, so the class generated for a subclass extends the class
+     * generated for its direct superclass.
+     *
+     * <p>All the classes of one call are loaded together into a single new class loader whose parent is
+     * {@code parent}: a subclass resolves its superclass by plain delegation inside that loader, and a Hibernate
+     * registry that must load the classes by name needs only that one loader
+     * ({@code BootstrapServiceRegistryBuilder.applyClassLoader(generated.classLoader)}). Nothing is injected into an
+     * existing loader, so no reflective access to {@code ClassLoader.defineClass} is needed, and the classes are
+     * collected together with the loader.</p>
+     *
+     * @param entities the entities to describe; every direct superclass and every direct subclass of an entity must
+     *     be in the collection
+     * @param parent the class loader the generated classes are loaded under
+     * @return the generated class of each entity, in the order of {@code entities}
+     * @throws IllegalArgumentException when a hierarchy is not complete
+     * @throws UnsupportedOperationException when an entity uses something the generator cannot describe yet
+     */
+    Map<GrailsHibernatePersistentEntity, Class<?>> generateAll(
+            Collection<? extends GrailsHibernatePersistentEntity> entities, ClassLoader parent) {
+        Set<GrailsHibernatePersistentEntity> given = new LinkedHashSet<GrailsHibernatePersistentEntity>(entities)
+        for (GrailsHibernatePersistentEntity entity : given) {
+            requireWholeHierarchy(entity, given)
+        }
+        List<GrailsHibernatePersistentEntity> ordered = new ArrayList<GrailsHibernatePersistentEntity>(given)
+        ordered.sort { GrailsHibernatePersistentEntity a, GrailsHibernatePersistentEntity b -> depth(a) <=> depth(b) }
+
+        Map<GrailsHibernatePersistentEntity, DynamicType.Unloaded<?>> made = [:]
+        Map<TypeDescription, byte[]> types = [:]
+        for (GrailsHibernatePersistentEntity entity : ordered) {
+            TypeDescription superType = entity.isRoot() ?
+                    TypeDescription.ForLoadedType.of(Object) : made.get(superEntity(entity, given)).typeDescription
+            DynamicType.Unloaded<?> unloaded = make(entity, superType)
+            made.put(entity, unloaded)
+            types.putAll(unloaded.allTypes)
+        }
+        Map<TypeDescription, Class<?>> loaded = ClassLoadingStrategy.Default.WRAPPER.load(parent, types)
+        Map<GrailsHibernatePersistentEntity, Class<?>> result = [:]
+        for (GrailsHibernatePersistentEntity entity : given) {
+            result.put(entity, loaded.get(made.get(entity).typeDescription))
+        }
+        return result
+    }
+
+    private static void requireWholeHierarchy(GrailsHibernatePersistentEntity entity, Set<GrailsHibernatePersistentEntity> given) {
+        if (!entity.isRoot() && superEntity(entity, given) == null) {
+            throw new IllegalArgumentException(
+                    "Entity [${entity.name}] is part of an inheritance hierarchy: generate it together with its " +
+                            'superclass and subclasses (generateAll)')
+        }
+        for (GrailsHibernatePersistentEntity child : entity.childEntities) {
+            if (!given.any { GrailsHibernatePersistentEntity other -> other.javaClass == child.javaClass }) {
+                throw new IllegalArgumentException(
+                        "Entity [${entity.name}] is part of an inheritance hierarchy: generate it together with its " +
+                                "subclass [${child.name}] (generateAll)")
+            }
+        }
+    }
+
+    private static GrailsHibernatePersistentEntity superEntity(
+            GrailsHibernatePersistentEntity entity, Set<GrailsHibernatePersistentEntity> given) {
+        return given.find { GrailsHibernatePersistentEntity other -> other.javaClass == entity.javaClass.superclass }
+    }
+
+    private static int depth(GrailsHibernatePersistentEntity entity) {
+        int depth = 0
+        for (Class<?> type = entity.javaClass.superclass; type != null && type != Object; type = type.superclass) {
+            depth++
         }
-        DynamicType.Builder<Object> builder = new ByteBuddy()
-                .subclass(Object)
+        return depth
+    }
+
+    private DynamicType.Unloaded<?> make(GrailsHibernatePersistentEntity entity, TypeDescription superType) {
+        HierarchyFacets hierarchy = hierarchyFacets(entity)
+        DynamicType.Builder<Object> builder = (DynamicType.Builder<Object>) new ByteBuddy()
+                .subclass(superType)
                 .name(generatedClassName(entity))
-                .annotateType(classAnnotations(entity) as AnnotationDescription[])
+                .annotateType(classAnnotations(entity, hierarchy) as AnnotationDescription[])
+        if (hierarchy.abstractClass()) {
+            builder = builder.modifiers(Visibility.PUBLIC, TypeManifestation.ABSTRACT)
+        }
+        if (entity.isRoot()) {
+            builder = defineIdentityAndVersion(builder, entity)
+        }
+        for (HibernatePersistentProperty property : entity.persistentPropertiesToBind) {
+            if (!supports(property)) {
+                throw new UnsupportedOperationException(unsupportedReason(entity, property))
+            }
+            builder = defineField(builder, property, [])
+        }
+        return builder.make()
+    }
 
+    private DynamicType.Builder<Object> defineIdentityAndVersion(
+            DynamicType.Builder<Object> builder, GrailsHibernatePersistentEntity entity) {
         HibernatePersistentProperty identity = (HibernatePersistentProperty) entity.identity
         if (!(identity instanceof HibernateSimpleIdentityProperty)) {
             throw new UnsupportedOperationException(
                     "Entity [${entity.name}] has no simple identifier (a composite identifier, for example), " +
                             'which the generator does not support yet')
         }
-        if (identity != null) {
-            List<AnnotationDescription> idAnnotations = [AnnotationDescription.Builder.ofType(Id).build()]
-            idAnnotations.addAll(idGeneratorAnnotations(idFacets(entity)))
-            builder = defineField(builder, identity, idAnnotations)
-        }
+        List<AnnotationDescription> idAnnotations = [AnnotationDescription.Builder.ofType(Id).build()]
+        idAnnotations.addAll(idGeneratorAnnotations(idFacets(entity)))
+        DynamicType.Builder<Object> result = defineField(builder, identity, idAnnotations)
         HibernatePersistentProperty version = entity.version
         if (version != null) {
-            builder = defineField(builder, version, [AnnotationDescription.Builder.ofType(Version).build()])
-        }
-        for (HibernatePersistentProperty property : entity.persistentPropertiesToBind) {
-            if (!supports(property)) {
-                throw new UnsupportedOperationException(unsupportedReason(entity, property))
-            }
-            builder = defineField(builder, property, [])
+            result = defineField(result, version, [AnnotationDescription.Builder.ofType(Version).build()])
         }
-        return builder.make().load(parent, ClassLoadingStrategy.Default.WRAPPER).loaded
+        return result
     }
 
     private String unsupportedReason(GrailsHibernatePersistentEntity entity, HibernatePersistentProperty property) {
@@ -177,20 +273,123 @@ class GrailsDomainGenerator {
     }
 
     /**
-     * Decides the class-level facets of a root entity the way the domain binder does.
+     * Decides the class-level facets of an entity the way the domain binder does. A single-table subclass has no
+     * table of its own: it reports the table of its hierarchy and no comment, which belongs to the table and so to
+     * the root. A subclass's JPA
+     * name is always its simple name, whatever {@code autoImport} says: that is what the binder's subclass mapping
+     * does.
      */
     EntityFacets entityFacets(GrailsHibernatePersistentEntity entity) {
         Mapping mapping = entity.mappedForm
+        boolean sharesTable = entity.isTablePerHierarchySubclass()
+        GrailsHibernatePersistentEntity tableOwner = sharesTable ? entity.hibernateRootEntity : entity
+        Mapping tableMapping = tableOwner.mappedForm
         boolean autoImport = mapping == null || mapping.autoImport
         return new EntityFacets(
-                autoImport ? entity.javaClass.simpleName : entity.javaClass.name,
-                entity.getTableName(namingStrategy),
-                mapping?.table?.schema ?: null,
-                mapping?.table?.catalog ?: null,
+                autoImport || !entity.isRoot() ? entity.javaClass.simpleName : entity.javaClass.name,
+                tableOwner.getTableName(namingStrategy),
+                tableMapping?.table?.schema ?: null,
+                tableMapping?.table?.catalog ?: null,
                 mapping != null && mapping.dynamicInsert,
                 mapping != null && mapping.dynamicUpdate,
                 mapping?.batchSize != null ? mapping.batchSize : 0,
-                entity.comment)
+                sharesTable ? null : entity.comment)
+    }
+
+    /**
+     * 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),
+     * the discriminator is the one {@code DiscriminatorPropertyBinder} binds on the root of a single-table
+     * hierarchy that has subclasses, a subclass's discriminator value is the entity's own
+     * {@code getDiscriminatorValue}, and a joined subclass's key column is named like its identifier column.
+     *
+     * @throws UnsupportedOperationException for a hierarchy that mixes strategies (annotations state the strategy
+     *     once, on the root) or whose discriminator has no annotation equivalent
+     */
+    HierarchyFacets hierarchyFacets(GrailsHibernatePersistentEntity entity) {
+        boolean root = entity.isRoot()
+        if (root && entity.childEntities.isEmpty()) {
+            return new HierarchyFacets(null, null, entity.isAbstract(), entity.isTableAbstract(), true, null, null, null)
+        }
+        InheritanceType strategy = inheritanceType(entity)
+        InheritanceType hierarchyStrategy = inheritanceType(entity.hibernateRootEntity)
+        if (strategy != hierarchyStrategy) {
+            throw new UnsupportedOperationException(
+                    "Entity [${entity.name}] uses ${strategy} but the root of its hierarchy uses ${hierarchyStrategy}: " +
+                            'a hierarchy that mixes inheritance strategies is not supported')
+        }
+        if (strategy != InheritanceType.SINGLE_TABLE) {
+            throw new UnsupportedOperationException("Inheritance strategy ${strategy} of [${entity.name}] is not supported yet")
+        }
+        boolean singleTable = strategy == InheritanceType.SINGLE_TABLE
+        String discriminatorValue = null
+        if (singleTable) {
+            DiscriminatorConfig config = entity.hibernateMappedForm?.discriminator
+            discriminatorValue = root ? (config?.value != null ? config.value : entity.name) : entity.discriminatorValue
+        }
+        return new HierarchyFacets(
+                strategy,
+                root ? null : entity.parentEntity.name,
+                entity.isAbstract(),
+                root ? entity.isTableAbstract() : strategy == InheritanceType.TABLE_PER_CLASS && entity.isAbstract(),
+                root || !singleTable,
+                discriminatorValue,
+                root && singleTable ? discriminatorFacets(entity) : null,
+                !root && strategy == InheritanceType.JOINED ?
+                        columnNames.getColumnNameForPropertyAndPath((HibernatePersistentProperty) entity.identity, '', null) : null)
+    }
+
+    private static InheritanceType inheritanceType(GrailsHibernatePersistentEntity entity) {
+        if (entity.isUnionSubclass()) {
+            return InheritanceType.TABLE_PER_CLASS
+        }
+        return entity.isJoinedSubclass() ? InheritanceType.JOINED : InheritanceType.SINGLE_TABLE
+    }
+
+    /**
+     * Mirrors {@code ConfiguredDiscriminatorBinder} and {@code DefaultDiscriminatorBinder}: a formula, else a column
+     * that is named {@code class} unless configured and takes its length and SQL type from the column config.
+     */
+    private DiscriminatorFacets discriminatorFacets(GrailsHibernatePersistentEntity entity) {
+        DiscriminatorConfig config = entity.hibernateMappedForm?.discriminator
+        String typeName = config?.type == null ? 'string' :
+                (config.type instanceof Class ? ((Class<?>) config.type).name : config.type.toString())
+        DiscriminatorType type = discriminatorType(entity, typeName)
+        boolean insertable = config?.insertable == null || config.insertable
+        if (config?.formula != null) {
+            return new DiscriminatorFacets(null, config.formula, typeName, type, null, null, insertable)
+        }
+        ColumnConfig columnConfig = config?.column
+        Column column = new Column()
+        columnConfigBinder.bindColumnConfigToColumn(column, columnConfig, null)
+        if (column.precision != null || column.scale != null) {
+            throw new UnsupportedOperationException(
+                    "The discriminator column of [${entity.name}] sets a precision or a scale, " +
+                            'which @DiscriminatorColumn cannot state')
+        }
+        return new DiscriminatorFacets(
+                columnConfig?.name != null ? columnConfig.name : GrailsDomainBinder.DEFAULT_DISCRIMINATOR_COLUMN_NAME,
+                null, typeName, type, column.length?.intValue(), column.sqlType, insertable)
+    }
+
+    private static DiscriminatorType discriminatorType(GrailsHibernatePersistentEntity entity, String typeName) {
+        switch (typeName) {
+            case 'string':
+            case 'java.lang.String':
+                return DiscriminatorType.STRING
+            case 'integer':
+            case 'int':
+            case 'java.lang.Integer':
+                return DiscriminatorType.INTEGER
+            case 'character':
+            case 'char':
+            case 'java.lang.Character':
+                return DiscriminatorType.CHAR
+            default:
+                throw new UnsupportedOperationException(
+                        "The discriminator type [${typeName}] of [${entity.name}] is not one of string, integer " +
+                                'or character, which the generator does not support yet')
+        }
     }
 
     /**
@@ -433,19 +632,22 @@ class GrailsDomainGenerator {
         return columns == null || columns.isEmpty() ? null : columns[0]
     }
 
-    private List<AnnotationDescription> classAnnotations(GrailsHibernatePersistentEntity entity) {
+    private List<AnnotationDescription> classAnnotations(GrailsHibernatePersistentEntity entity, HierarchyFacets hierarchy) {
         EntityFacets facets = entityFacets(entity)
         List<AnnotationDescription> annotations = []
         annotations << AnnotationDescription.Builder.ofType(Entity).define('name', facets.jpaName()).build()
 
-        AnnotationDescription.Builder table = AnnotationDescription.Builder.ofType(Table).define('name', facets.tableName())
-        if (facets.schema()) {
-            table = table.define('schema', facets.schema())
-        }
-        if (facets.catalog()) {
-            table = table.define('catalog', facets.catalog())
+        if (hierarchy.ownsTable()) {
+            AnnotationDescription.Builder table = AnnotationDescription.Builder.ofType(Table).define('name', facets.tableName())
+            if (facets.schema()) {
+                table = table.define('schema', facets.schema())
+            }
+            if (facets.catalog()) {
+                table = table.define('catalog', facets.catalog())
+            }
+            annotations << table.build()
         }
-        annotations << table.build()
+        annotations.addAll(hierarchyAnnotations(entity, hierarchy))
 
         if (facets.dynamicInsert()) {
             annotations << AnnotationDescription.Builder.ofType(DynamicInsert).build()
@@ -462,6 +664,45 @@ class GrailsDomainGenerator {
         return annotations
     }
 
+    private static List<AnnotationDescription> hierarchyAnnotations(GrailsHibernatePersistentEntity entity, HierarchyFacets hierarchy) {
+        List<AnnotationDescription> annotations = []
+        if (hierarchy.strategy() == null) {
+            return annotations
+        }
+        if (entity.isRoot()) {
+            annotations << AnnotationDescription.Builder.ofType(Inheritance).define('strategy', hierarchy.strategy()).build()
+        }
+        DiscriminatorFacets discriminator = hierarchy.discriminator()
+        if (discriminator != null) {
+            if (discriminator.formula() != null) {
+                annotations << AnnotationDescription.Builder.ofType(DiscriminatorFormula)
+                        .define('value', discriminator.formula())
+                        .define('discriminatorType', discriminator.type())
+                        .build()
+            } else {
+                AnnotationDescription.Builder column = AnnotationDescription.Builder.ofType(DiscriminatorColumn)
+                        .define('name', discriminator.column())
+                        .define('discriminatorType', discriminator.type())
+                        // the binder's discriminator column has Hibernate's default length unless the mapping says otherwise
+                        .define('length', discriminator.length() != null ? discriminator.length() : (int) Length.DEFAULT)
+                if (discriminator.sqlType()) {
+                    column = column.define('columnDefinition', discriminator.sqlType())
+                }
+                annotations << column.build()
+            }
+            if (!discriminator.insertable()) {
+                annotations << AnnotationDescription.Builder.ofType(DiscriminatorOptions).define('insert', false).build()
+            }
+        }
+        if (hierarchy.discriminatorValue() != null) {
+            annotations << AnnotationDescription.Builder.ofType(DiscriminatorValue).define('value', hierarchy.discriminatorValue()).build()
+        }
+        if (hierarchy.keyColumn() != null) {
+            annotations << AnnotationDescription.Builder.ofType(PrimaryKeyJoinColumn).define('name', hierarchy.keyColumn()).build()
+        }
+        return annotations
+    }
+
     private DynamicType.Builder<Object> defineField(
             DynamicType.Builder<Object> builder, HibernatePersistentProperty property, List<AnnotationDescription> extra) {
         List<AnnotationDescription> annotations = new ArrayList<>(extra)
diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/HierarchyFacets.groovy b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/HierarchyFacets.groovy
new file mode 100644
index 0000000000..22d7e73eb5
--- /dev/null
+++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/HierarchyFacets.groovy
@@ -0,0 +1,45 @@
+/*
+ *  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
+import jakarta.persistence.InheritanceType
+
+/**
+ * The inheritance decisions the domain binder makes for one entity: where it sits in its hierarchy, whether it has
+ * a table of its own, and what tells its rows apart.
+ *
+ * <p>{@code strategy} is {@code null} for an entity that is not part of a hierarchy (a root with no subclasses).
+ * {@code superclass} is the entity name of the direct superclass, {@code null} for a root. {@code discriminator}
+ * is only set for the root of a single-table hierarchy that has subclasses, {@code discriminatorValue} only for the
+ * entities of a single-table hierarchy, and {@code keyColumn} only for a joined subclass.</p>
+ *
+ * @since 9.0
+ */
+@CompileStatic
+record HierarchyFacets(
+    InheritanceType strategy,
+    String superclass,
+    boolean abstractClass,
+    boolean abstractTable,
+    boolean ownsTable,
+    String discriminatorValue,
+    DiscriminatorFacets discriminator,
+    String keyColumn) {
+}
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 880cb4940b..478253ef63 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
@@ -31,6 +31,8 @@ import org.hibernate.mapping.Column
 import org.hibernate.mapping.Formula
 import org.hibernate.mapping.PersistentClass
 import org.hibernate.mapping.Property
+import org.hibernate.mapping.RootClass
+import org.hibernate.mapping.SingleTableSubclass
 import java.lang.reflect.Field
 import java.lang.reflect.Modifier
 import java.lang.reflect.ParameterizedType
@@ -87,6 +89,7 @@ class GrailsDomainGeneratorDifferentialSpec extends HibernateGormDatastoreSpec {
         int derived = 0
         Map<String, Integer> explicitTypes = [:].withDefault { 0 }
         Map<String, Integer> strategies = [:].withDefault { 0 }
+        Map<String, Integer> hierarchies = [:].withDefault { 0 }
 
         when:
         for (List<Class<?>> group : groups) {
@@ -102,8 +105,18 @@ class GrailsDomainGeneratorDifferentialSpec extends HibernateGormDatastoreSpec {
                         .findAll { group.contains(it.javaClass) && it.persistentClass != null && it.persistentClass.entityName == it.name }) {
                     entities++
                     List<HibernatePersistentProperty> properties = []
+                    HierarchyFacets hierarchy = null
+                    try {
+                        hierarchy = generator.hierarchyFacets(entity)
+                    } catch (UnsupportedOperationException e) {
+                        skipped["hierarchy: ${e.message.replaceAll(/\[[^\]]*\]/, '[..]')}".toString()]++
+                    }
+                    if (hierarchy != null) {
+                        hierarchies["${hierarchy.strategy() ?: 'none'}${entity.isRoot() ? ' root' : ' subclass'}".toString()]++
+                        mismatches.addAll(compareEntity(entity, generator.entityFacets(entity), hierarchy))
+                        mismatches.addAll(compareHierarchy(entity, hierarchy))
+                    }
                     if (entity.isRoot()) {
-                        mismatches.addAll(compareEntity(entity, generator.entityFacets(entity)))
                         if (entity.identity instanceof HibernateSimpleIdentityProperty) {
                             mismatches.addAll(compareIdentifierGenerator(
                                     entity, generator.idFacets(entity), (SessionFactoryImplementor) datastore.sessionFactory, strategies))
@@ -113,9 +126,9 @@ class GrailsDomainGeneratorDifferentialSpec extends HibernateGormDatastoreSpec {
                         if (entity.version != null) {
                             properties << entity.version
                         }
-                    }
-                    if (entity.identity != null) {
-                        properties << (HibernatePersistentProperty) entity.identity
+                        if (entity.identity != null) {
+                            properties << (HibernatePersistentProperty) entity.identity
+                        }
                     }
                     properties.addAll(entity.persistentPropertiesToBind)
                     for (HibernatePersistentProperty property : properties) {
@@ -161,6 +174,7 @@ class GrailsDomainGeneratorDifferentialSpec extends HibernateGormDatastoreSpec {
                 "(${derived} derived)\n"
         report << "explicit types compared: ${explicitTypes}\n"
         report << "id generators compared by strategy: ${strategies}\n"
+        report << "entities compared by hierarchy role: ${hierarchies}\n"
         report << "unsupported by kind: ${skipped}\n"
         report << "mismatches by facet: ${mismatches.groupBy { (it =~ /\s(\w+): generator=/)[0][1] }.collectEntries { k, v -> [k, v.size()] }}\n"
         unbootable.each { report << "unbootable: ${it.key.take(120)} -> ${it.value.take(200)}\n" }
@@ -303,16 +317,19 @@ class GrailsDomainGeneratorDifferentialSpec extends HibernateGormDatastoreSpec {
         return expected == actual ? [] : ["${entity.name}.${property.name} enumStyle: generator=${expected} binder=${actual}".toString()]
     }
 
-    private List<String> compareEntity(GrailsHibernatePersistentEntity entity, EntityFacets facets) {
+    private List<String> compareEntity(GrailsHibernatePersistentEntity entity, EntityFacets facets, HierarchyFacets hierarchy) {
         PersistentClass persistentClass = entity.persistentClass
         Map<String, List> pairs = [
                 jpaName      : [facets.jpaName(), persistentClass.jpaEntityName],
                 tableName    : [facets.tableName().replace('`', ''), persistentClass.table.name],
                 dynamicInsert: [facets.dynamicInsert(), persistentClass.useDynamicInsert()],
                 dynamicUpdate: [facets.dynamicUpdate(), persistentClass.useDynamicUpdate()],
-                batchSize    : [facets.batchSize(), persistentClass.batchSize],
-                comment      : [facets.comment(), persistentClass.table.comment],
+                // the binder leaves a subclass's unset batch size at -1 and a root's at 0: both mean "not stated"
+                batchSize    : [facets.batchSize(), Math.max(persistentClass.batchSize, 0)],
         ]
+        if (hierarchy.ownsTable()) {
+            pairs.comment = [facets.comment(), persistentClass.table.comment]
+        }
         if (facets.schema() != null) {
             pairs.schema = [facets.schema(), persistentClass.table.schema]
         }
@@ -324,6 +341,66 @@ class GrailsDomainGeneratorDifferentialSpec extends HibernateGormDatastoreSpec {
         }
     }
 
+    /**
+     * Where the entity sits in its hierarchy: the Hibernate class the binder created for it, its direct superclass,
+     * its table, abstractness and discriminator.
+     */
+    private List<String> compareHierarchy(GrailsHibernatePersistentEntity entity, HierarchyFacets facets) {
+        PersistentClass persistentClass = entity.persistentClass
+        Map<String, List> pairs = [
+                kind              : [expectedKind(entity, facets), persistentClass.getClass().simpleName],
+                superclass        : [facets.superclass(), persistentClass.superclass?.entityName],
+                abstractClass     : [facets.abstractClass(), Boolean.TRUE == persistentClass.isAbstract()],
+                abstractTable     : [facets.abstractTable(), persistentClass.table.isAbstract()],
+                ownsTable         : [facets.ownsTable(),
+                                     persistentClass.superclass == null || !persistentClass.table.is(persistentClass.superclass.table)],
+                discriminatorValue: [facets.discriminatorValue(), persistentClass.discriminatorValue],
+        ]
+        List<String> found = pairs.findAll { String facet, List values -> values[0] != values[1] }.collect { String facet, List values ->
+            "${entity.name} ${facet}: generator=${values[0]} binder=${values[1]}".toString()
+        }
+        if (persistentClass instanceof RootClass) {
+            found.addAll(compareDiscriminator(entity, facets.discriminator(), (RootClass) persistentClass))
+        }
+        return found
+    }
+
+    private static String expectedKind(GrailsHibernatePersistentEntity entity, HierarchyFacets facets) {
+        if (entity.isRoot()) {
+            return RootClass.simpleName
+        }
+        return SingleTableSubclass.simpleName
+    }
+
+    /** The discriminator the binder put on the root: a column or a formula, its type, length and whether it is inserted. */
+    private List<String> compareDiscriminator(GrailsHibernatePersistentEntity entity, DiscriminatorFacets facets, RootClass root) {
+        String where = "${entity.name} discriminator"
+        if ((facets != null) != (root.discriminator != null)) {
+            return ["${where} present: generator=${facets != null} binder=${root.discriminator != null}".toString()]
+        }
+        if (facets == null) {
+            return []
+        }
+        BasicValue value = (BasicValue) root.discriminator
+        List<String> formulas = value.selectables.findAll { it instanceof Formula }.collect { ((Formula) it).getFormula() }
+        List<Column> columns = value.selectables.findAll { it instanceof Column }.collect { (Column) it }
+        Map<String, List> pairs = [
+                typeName  : [facets.typeName(), value.typeName],
+                insertable: [facets.insertable(), root.isDiscriminatorInsertable()],
+                formula   : [facets.formula() == null ? [] : [facets.formula()], formulas],
+                column    : [facets.column() == null ? [] : [facets.column()], columns*.name],
+        ]
+        if (!columns.isEmpty()) {
+            pairs.length = [facets.length(), columns[0].length?.intValue()]
+            if (facets.sqlType() != null) {
+                pairs.sqlType = [facets.sqlType(), columns[0].sqlType]
+            }
+        }
+        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()
+        }
+    }
+
     private static Property boundProperty(PersistentClass persistentClass, HibernatePersistentProperty property) {
         if (persistentClass.identifierProperty != null && persistentClass.identifierProperty.name == property.name) {
             return persistentClass.identifierProperty
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 51cf26854e..d7c097021b 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
@@ -27,15 +27,22 @@ import java.lang.reflect.Field
 import grails.gorm.annotation.Entity
 import grails.gorm.tests.HibernateGormDatastoreSpec
 import jakarta.persistence.Column
+import jakarta.persistence.DiscriminatorColumn
+import jakarta.persistence.DiscriminatorType
+import jakarta.persistence.DiscriminatorValue
 import jakarta.persistence.EnumType
 import jakarta.persistence.Enumerated
 import jakarta.persistence.Id
+import jakarta.persistence.Inheritance
+import jakarta.persistence.InheritanceType
 import jakarta.persistence.Table
 import jakarta.persistence.Version
 import jakarta.validation.constraints.Size
 import org.hibernate.annotations.BatchSize
 import org.hibernate.annotations.ColumnDefault
 import org.hibernate.annotations.Comment
+import org.hibernate.annotations.DiscriminatorFormula
+import org.hibernate.annotations.DiscriminatorOptions
 import org.hibernate.annotations.DynamicUpdate
 import org.hibernate.annotations.Formula
 import org.hibernate.annotations.IdGeneratorType
@@ -53,6 +60,8 @@ import org.hibernate.id.enhanced.SequenceStyleGenerator
 import org.hibernate.id.enhanced.TableGenerator
 import org.hibernate.id.uuid.UuidGenerator as HibernateUuidGenerator
 import org.hibernate.mapping.PersistentClass
+import org.hibernate.mapping.RootClass
+import org.hibernate.mapping.SingleTableSubclass
 import org.hibernate.mapping.GeneratorCreator
 import org.hibernate.boot.registry.BootstrapServiceRegistry
 import org.hibernate.boot.registry.BootstrapServiceRegistryBuilder
@@ -97,7 +106,15 @@ class GrailsDomainGeneratorSpec extends HibernateGormDatastoreSpec {
 
     void setupSpec() {
         manager.registerDomainClasses(GenBasic, GenVehicle, GenCar, GenWithEnum, GenWithOwner, GenDerived, GenTyped, GenUnsupportedType, GenIdSequence, GenIdUuid, GenIdAssigned, GenIdTable,
-                GenIdIncrement, GenIdIdentity, GenIdNative)
+                GenIdIncrement, GenIdIdentity, GenIdNative, GenAnimal, GenDog, GenPuppy, GenCat, GenToy, GenPlushToy, GenGadget,
+                GenGizmo, GenCoded, GenCodedChild, GenFormulaRoot, GenFormulaChild, GenAbstractBase, GenConcreteChild, GenNoted, GenNotedChild)
+    }
+
+    List<StandardServiceRegistry> registries = []
+
+    void cleanup() {
+        registries.each { StandardServiceRegistryBuilder.destroy(it) }
+        registries.clear()
     }
 
     void setup() {
@@ -162,13 +179,17 @@ class GrailsDomainGeneratorSpec extends HibernateGormDatastoreSpec {
         field('tag').getAnnotation(Size).max() == 20
     }
 
-    void "an entity in an inheritance hierarchy is rejected until inheritance is supported"() {
+    void "an entity of an inheritance hierarchy cannot be generated on its own"() {
         when:
-        generate(GenCar)
+        generate(entityClass)
 
         then:
-        UnsupportedOperationException e = thrown()
+        IllegalArgumentException e = thrown()
         e.message.contains('inheritance hierarchy')
+        e.message.contains('generateAll')
+
+        where:
+        entityClass << [GenCar, GenVehicle]
     }
 
     void "a property the generator does not support is rejected by name"() {
@@ -404,6 +425,235 @@ class GrailsDomainGeneratorSpec extends HibernateGormDatastoreSpec {
         StandardServiceRegistryBuilder.destroy(registry)
     }
 
+    void "a single-table hierarchy is generated whole, each subclass extending the class generated for its superclass"() {
+        when:
+        Map<GrailsHibernatePersistentEntity, Class<?>> classes = generateHierarchy(GenAnimal, GenDog, GenPuppy, GenCat)
+
+        then:
+        classes[entity(GenAnimal)].superclass == Object
+        classes[entity(GenDog)].superclass == classes[entity(GenAnimal)]
+        classes[entity(GenPuppy)].superclass == classes[entity(GenDog)]
+        classes[entity(GenCat)].superclass == classes[entity(GenAnimal)]
+        classes.keySet()*.javaClass == [GenAnimal, GenDog, GenPuppy, GenCat]
+    }
+
+    void "the classes of one hierarchy share one class loader that can load each of them by name and see the parent"() {
+        when:
+        Map<GrailsHibernatePersistentEntity, Class<?>> classes = generateHierarchy(GenAnimal, GenDog, GenPuppy, GenCat)
+        ClassLoader loader = classes.values().first().classLoader
+
+        then:
+        classes.values().every { it.classLoader.is(loader) }
+        !loader.is(getClass().classLoader)
+        classes.values().every { Class.forName(it.name, false, loader).is(it) }
+        loader.parent.is(getClass().classLoader)
+
+        and: "the loader is not the loader of an unrelated generation"
+        !generate(GenBasic).classLoader.is(loader)
+    }
+
+    void "the root of a single-table hierarchy states the strategy and the default discriminator"() {
+        when:
+        Class<?> root = generateHierarchy(GenAnimal, GenDog, GenPuppy, GenCat)[entity(GenAnimal)]
+        DiscriminatorColumn column = root.getAnnotation(DiscriminatorColumn)
+
+        then:
+        root.getAnnotation(Inheritance).strategy() == InheritanceType.SINGLE_TABLE
+        root.getAnnotation(Table).name() == 'gen_animal'
+        column.name() == 'class'
+        column.discriminatorType() == DiscriminatorType.STRING
+        column.length() == 255
+        root.getAnnotation(DiscriminatorValue).value() == GenAnimal.name
+        !root.isAnnotationPresent(DiscriminatorOptions)
+        !root.isAnnotationPresent(DiscriminatorFormula)
+    }
+
+    void "a single-table subclass has no table, no strategy and no discriminator column, only its value"() {
+        given:
+        Map<GrailsHibernatePersistentEntity, Class<?>> classes = generateHierarchy(GenAnimal, GenDog, GenPuppy, GenCat)
+
+        expect:
+        Class<?> subclass = classes[entity(entityClass)]
+        subclass.getAnnotation(DiscriminatorValue).value() == entityClass.name
+        subclass.getAnnotation(jakarta.persistence.Entity).name() == entityClass.simpleName
+        !subclass.isAnnotationPresent(Table)
+        !subclass.isAnnotationPresent(Inheritance)
+        !subclass.isAnnotationPresent(DiscriminatorColumn)
+
+        where:
+        entityClass << [GenDog, GenPuppy, GenCat]
+    }
+
+    void "a subclass declares only its own properties, with no identifier and no version, and its columns are nullable"() {
+        when:
+        Class<?> dog = generateHierarchy(GenAnimal, GenDog, GenPuppy, GenCat)[entity(GenDog)]
+
+        then:
+        dog.declaredFields*.name == ['breed']
+        !dog.getDeclaredField('breed').isAnnotationPresent(Id)
+        dog.getDeclaredField('breed').getAnnotation(Column).nullable()
+        dog.getDeclaredField('breed').getAnnotation(Column).name() == 'breed'
+    }
+
+    void "a configured discriminator states its value, column, length and the value of each subclass"() {
+        when:
+        Map<GrailsHibernatePersistentEntity, Class<?>> classes = generateHierarchy(GenToy, GenPlushToy)
+        Class<?> root = classes[entity(GenToy)]
+        DiscriminatorColumn column = root.getAnnotation(DiscriminatorColumn)
+
+        then:
+        root.getAnnotation(DiscriminatorValue).value() == 'TOY'
+        column.name() == 'toy_kind'
+        column.length() == 12
+        column.discriminatorType() == DiscriminatorType.STRING
+        classes[entity(GenPlushToy)].getAnnotation(DiscriminatorValue).value() == 'PLUSH'
+    }
+
+    void "a root mapping that sets only the discriminator column keeps the class name as the root value"() {
+        when:
+        Map<GrailsHibernatePersistentEntity, Class<?>> classes = generateHierarchy(GenGadget, GenGizmo)
+
+        then:
+        classes[entity(GenGadget)].getAnnotation(DiscriminatorColumn).name() == 'gadget_kind'
+        classes[entity(GenGadget)].getAnnotation(DiscriminatorValue).value() == GenGadget.name
+        classes[entity(GenGizmo)].getAnnotation(DiscriminatorValue).value() == GenGizmo.name
+    }
+
+    void "a discriminator type, an insert flag and an explicit value are carried over"() {
+        when:
+        Map<GrailsHibernatePersistentEntity, Class<?>> classes = generateHierarchy(GenCoded, GenCodedChild)
+        Class<?> root = classes[entity(GenCoded)]
+
+        then:
+        root.getAnnotation(DiscriminatorColumn).discriminatorType() == DiscriminatorType.INTEGER
+        root.getAnnotation(DiscriminatorColumn).name() == 'class'
+        root.getAnnotation(DiscriminatorOptions).insert() == false
+        root.getAnnotation(DiscriminatorValue).value() == '1'
+        classes[entity(GenCodedChild)].getAnnotation(DiscriminatorValue).value() == '2'
+    }
+
+    void "a discriminator formula replaces the discriminator column"() {
+        when:
+        Map<GrailsHibernatePersistentEntity, Class<?>> classes = generateHierarchy(GenFormulaRoot, GenFormulaChild)
+        Class<?> root = classes[entity(GenFormulaRoot)]
+
+        then:
+        root.getAnnotation(DiscriminatorFormula).value() == "case when kind_code = 1 then 'A' else 'B' end"
+        root.getAnnotation(DiscriminatorFormula).discriminatorType() == DiscriminatorType.STRING
+        !root.isAnnotationPresent(DiscriminatorColumn)
+        root.getAnnotation(DiscriminatorValue).value() == GenFormulaRoot.name
+        classes[entity(GenFormulaChild)].getAnnotation(DiscriminatorValue).value() == 'A'
+    }
+
+    void "an abstract class in a hierarchy is generated abstract"() {
+        when:
+        Map<GrailsHibernatePersistentEntity, Class<?>> classes = generateHierarchy(GenAbstractBase, GenConcreteChild, GenNoted, GenNotedChild)
+
+        then:
+        java.lang.reflect.Modifier.isAbstract(classes[entity(GenAbstractBase)].modifiers)
+        !java.lang.reflect.Modifier.isAbstract(classes[entity(GenConcreteChild)].modifiers)
+        classes[entity(GenConcreteChild)].superclass == classes[entity(GenAbstractBase)]
+    }
+
+    void "a single-table subclass states its own class-level facets but never a table or a comment"() {
+        when:
+        Map<GrailsHibernatePersistentEntity, Class<?>> classes = generateHierarchy(GenNoted, GenNotedChild)
+        Class<?> root = classes[entity(GenNoted)]
+        Class<?> child = classes[entity(GenNotedChild)]
+
+        then:
+        root.getAnnotation(Comment).value() == 'noted things'
+        root.getAnnotation(Table).name() == 'gen_noted'
+        child.isAnnotationPresent(org.hibernate.annotations.DynamicInsert)
+        child.getAnnotation(BatchSize).size() == 3
+        !child.isAnnotationPresent(Comment)
+        !child.isAnnotationPresent(Table)
+        !root.isAnnotationPresent(org.hibernate.annotations.DynamicInsert)
+    }
+
+    void "a hierarchy must be generated whole"() {
+        when:
+        newGenerator().generateAll(entityClasses.collect { entity(it) }, getClass().classLoader)
+
+        then:
+        IllegalArgumentException e = thrown()
+        e.message.contains(missing)
+
+        where:
+        entityClasses          | missing
+        [GenDog]               | 'superclass'
+        [GenAnimal, GenDog]    | 'subclass [org.grails.orm.hibernate.cfg.domainbinding.jpa.GenCat]'
+    }
+
+    void "the facets of a hierarchy are the ones the binder reports"() {
+        when:
+        HierarchyFacets facets = newGenerator().hierarchyFacets(entity(entityClass))
+
+        then:
+        facets.strategy() == strategy
+        facets.superclass() == superclass
+        facets.ownsTable() == ownsTable
+        facets.discriminatorValue() == value
+        (facets.discriminator() != null) == hasDiscriminator
+
+        where:
+        entityClass | strategy                     | superclass     | ownsTable | value         | hasDiscriminator
+        GenAnimal   | InheritanceType.SINGLE_TABLE | null           | true      | GenAnimal.name | true
+        GenDog      | InheritanceType.SINGLE_TABLE | GenAnimal.name | false     | GenDog.name   | false
+        GenPuppy    | InheritanceType.SINGLE_TABLE | GenDog.name    | false     | GenPuppy.name | false
+        GenBasic    | null                         | null           | true      | null          | false
+    }
+
+    void "Hibernate's annotation binder reads the generated single-table hierarchy as the binder builds it"() {
+        given:
+        Map<GrailsHibernatePersistentEntity, Class<?>> classes = generateHierarchy(GenAnimal, GenDog, GenPuppy, GenCat)
+
+        when:
+        Metadata metadata = annotationMetadata(classes.values())
+        PersistentClass animal = metadata.getEntityBinding(classes[entity(GenAnimal)].name)
+        PersistentClass dog = metadata.getEntityBinding(classes[entity(GenDog)].name)
+        PersistentClass puppy = metadata.getEntityBinding(classes[entity(GenPuppy)].name)
+
+        then:
+        animal instanceof RootClass
+        dog instanceof SingleTableSubclass
+        puppy instanceof SingleTableSubclass
+        puppy.superclass.entityName == dog.entityName
+        dog.superclass.entityName == animal.entityName
+        puppy.table.is(animal.table)
+        animal.table.name == 'gen_animal'
+        animal.discriminator.selectables*.text == ['class']
+        animal.discriminatorValue == GenAnimal.name
+        puppy.discriminatorValue == GenPuppy.name
+        puppy.getProperty('weeks').columns*.nullable == [true]
+
+        and: "the binder built the same shape"
+        getPersistentEntity(GenPuppy).persistentClass.superclass.entityName == GenDog.name
+        getPersistentEntity(GenAnimal).persistentClass.discriminator.selectables*.text == ['class']
+    }
+
+    private Metadata annotationMetadata(Collection<Class<?>> classes) {
+        BootstrapServiceRegistry bootstrap = new BootstrapServiceRegistryBuilder()
+                .applyClassLoader(classes.first().classLoader)
+                .build()
+        StandardServiceRegistry registry = new StandardServiceRegistryBuilder(bootstrap)
+                .applySetting('hibernate.dialect', H2Dialect.name)
+                .applySetting('hibernate.connection.url', 'jdbc:h2:mem:generator-hierarchy;DB_CLOSE_DELAY=-1')
+                .build()
+        registries << registry
+        MetadataSources sources = new MetadataSources(registry)
+        classes.each { sources.addAnnotatedClass(it) }
+        return sources.buildMetadata()
+    }
+
+    private Map<GrailsHibernatePersistentEntity, Class<?>> generateHierarchy(Class<?>... domainClasses) {
+        return newGenerator().generateAll(domainClasses.collect { entity(it) }, getClass().classLoader)
+    }
+
+    private GrailsHibernatePersistentEntity entity(Class<?> domainClass) {
+        return getPersistentEntity(domainClass)
+    }
+
     private IdFacets idFacets(Class<?> domainClass) {
         return newGenerator().idFacets(getPersistentEntity(domainClass))
     }
@@ -503,6 +753,133 @@ class GenCar extends GenVehicle {
     Integer doors
 }
 
+@Entity
+class GenAnimal {
+
+    String name
+}
+
+@Entity
+class GenDog extends GenAnimal {
+
+    String breed
+
+    static constraints = {
+        breed nullable: false
+    }
+}
+
+@Entity
+class GenPuppy extends GenDog {
+
+    Integer weeks
+}
+
+@Entity
+class GenCat extends GenAnimal {
+
+    Boolean indoor
+}
+
+@Entity
+class GenToy {
+
+    String name
+
+    static mapping = {
+        discriminator value: 'TOY', column: [name: 'toy_kind', length: 12]
+    }
+}
+
+@Entity
+class GenPlushToy extends GenToy {
+
+    static mapping = {
+        discriminator 'PLUSH'
+    }
+}
+
+@Entity
+class GenGadget {
+
+    String name
+
+    static mapping = {
+        discriminator column: 'gadget_kind'
+    }
+}
+
+@Entity
+class GenGizmo extends GenGadget {
+}
+
+@Entity
+class GenCoded {
+
+    String name
+
+    static mapping = {
+        discriminator value: '1', type: 'integer', insert: false
+    }
+}
+
+@Entity
+class GenCodedChild extends GenCoded {
+
+    static mapping = {
+        discriminator '2'
+    }
+}
+
+@Entity
+class GenFormulaRoot {
+
+    Integer kindCode
+
+    static mapping = {
+        discriminator formula: "case when kind_code = 1 then 'A' else 'B' end"
+    }
+}
+
+@Entity
+class GenFormulaChild extends GenFormulaRoot {
+
+    static mapping = {
+        discriminator 'A'
+    }
+}
+
+@Entity
+class GenNoted {
+
+    String name
+
+    static mapping = {
+        comment 'noted things'
+    }
+}
+
+@Entity
+class GenNotedChild extends GenNoted {
+
+    static mapping = {
+        dynamicInsert true
+        batchSize 3
+    }
+}
+
+@Entity
+abstract class GenAbstractBase {
+
+    String title
+}
+
+@Entity
+class GenConcreteChild extends GenAbstractBase {
+
+    String extra
+}
+
 enum GenKind {
     SMALL, LARGE
 }