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

(grails-core) 14/29: feat: describe to-one associations bound as a many-to-one 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 b58c854bc7fdcc1278fc3e0986cfda0d976391fd
Author: Walter Duque de Estrada <wb...@mac.com>
AuthorDate: Mon Oct 5 02:20:22 2026 -0500

    feat: describe to-one associations bound as a many-to-one in GrailsDomainGenerator
    
    A generated entity's association field is typed with the class generated for its
    target, because Hibernate resolves the target from the field type. generateAll
    therefore requires every referenced entity to be part of the call and builds the
    target types by name only, so cycles and self references need no ordering.
    
    The foreign key, fetching, cascade and missing-row handling are the binder's own
    decisions (ColumnBinder rules on a scratch column, CascadeBehaviorFetcher). The
    differential now generates whole connected groups and compares 158 to-one
    properties with the bound ManyToOne values, 52 of them also read back through
    Hibernate's annotation binder, with no mismatches.
    
    Co-Authored-By: Claude Sonnet 5.5 <no...@anthropic.com>
---
 .../cfg/domainbinding/jpa/CascadeFacets.groovy     |  38 +++
 .../domainbinding/jpa/GrailsDomainGenerator.groovy | 313 ++++++++++++++++++-
 .../cfg/domainbinding/jpa/ToOneFacets.groovy       |  46 +++
 .../GrailsDomainGeneratorDifferentialSpec.groovy   | 257 ++++++++++++---
 .../jpa/GrailsDomainGeneratorSpec.groovy           | 346 ++++++++++++++++++++-
 5 files changed, 949 insertions(+), 51 deletions(-)

diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/CascadeFacets.groovy b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/CascadeFacets.groovy
new file mode 100644
index 0000000000..9c8c95edad
--- /dev/null
+++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/CascadeFacets.groovy
@@ -0,0 +1,38 @@
+/*
+ *  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.CascadeType
+
+/**
+ * The cascade the domain binder gives an association, as the annotations state it. The binder's own
+ * {@code CascadeBehaviorFetcher} decides the behavior; this record only splits it into what JPA's
+ * {@code cascade} attribute can say ({@code jpa}), what only Hibernate's {@code @Cascade} can say
+ * ({@code hibernate}: lock, replicate and the orphan delete of a to-one association) and whether
+ * {@code orphanRemoval} is stated on a one-to-one or one-to-many association.
+ *
+ * @since 9.0
+ */
+@CompileStatic
+record CascadeFacets(
+    List<CascadeType> jpa,
+    List<org.hibernate.annotations.CascadeType> hibernate,
+    boolean orphanRemoval) {
+}
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 2063a49e07..0da83e75fb 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
@@ -20,10 +20,12 @@ package org.grails.orm.hibernate.cfg.domainbinding.jpa
 
 import java.lang.annotation.Annotation
 import java.lang.reflect.Method
+import java.lang.reflect.Modifier
 
 import groovy.transform.CompileStatic
 import jakarta.persistence.AttributeOverride
 import jakarta.persistence.AttributeOverrides
+import jakarta.persistence.CascadeType
 import jakarta.persistence.CollectionTable
 import jakarta.persistence.Column as JpaColumn
 import jakarta.persistence.DiscriminatorColumn
@@ -40,6 +42,7 @@ import jakarta.persistence.Id
 import jakarta.persistence.Inheritance
 import jakarta.persistence.InheritanceType
 import jakarta.persistence.JoinColumn
+import jakarta.persistence.ManyToOne
 import jakarta.persistence.MapKeyColumn
 import jakarta.persistence.OrderColumn
 import jakarta.persistence.PrimaryKeyJoinColumn
@@ -47,9 +50,11 @@ import jakarta.persistence.Table
 import jakarta.persistence.Version
 import net.bytebuddy.ByteBuddy
 import net.bytebuddy.description.annotation.AnnotationDescription
+import net.bytebuddy.description.method.MethodDescription
 import net.bytebuddy.description.modifier.TypeManifestation
 import net.bytebuddy.description.modifier.Visibility
 import net.bytebuddy.description.type.TypeDescription
+import net.bytebuddy.description.type.TypeList
 import net.bytebuddy.dynamic.DynamicType
 import net.bytebuddy.dynamic.loading.ClassLoadingStrategy
 import org.hibernate.FetchMode
@@ -57,6 +62,7 @@ import org.hibernate.Length
 import org.hibernate.annotations.BatchSize
 import org.hibernate.annotations.Cache
 import org.hibernate.annotations.CacheConcurrencyStrategy
+import org.hibernate.annotations.Cascade
 import org.hibernate.annotations.ColumnDefault
 import org.hibernate.annotations.ColumnTransformer
 import org.hibernate.annotations.Comment
@@ -70,6 +76,8 @@ import org.hibernate.annotations.FilterDef
 import org.hibernate.annotations.FetchMode as AnnotationFetchMode
 import org.hibernate.annotations.Formula
 import org.hibernate.annotations.JdbcTypeCode
+import org.hibernate.annotations.NotFound
+import org.hibernate.annotations.NotFoundAction
 import org.hibernate.annotations.ParamDef
 import org.hibernate.annotations.Parameter
 import org.hibernate.annotations.Type
@@ -83,6 +91,7 @@ import org.hibernate.type.spi.TypeConfiguration
 import org.hibernate.usertype.UserType
 
 import org.grails.datastore.mapping.model.config.GormProperties
+import org.grails.datastore.mapping.model.types.Association
 import org.grails.orm.hibernate.cfg.ColumnConfig
 import org.grails.orm.hibernate.cfg.DiscriminatorConfig
 import org.grails.orm.hibernate.cfg.HibernateSimpleIdentity
@@ -102,15 +111,21 @@ import org.grails.orm.hibernate.cfg.domainbinding.generator.GrailsSequenceGenera
 import org.grails.orm.hibernate.cfg.domainbinding.generator.GrailsSequenceStyleGenerator
 import org.grails.orm.hibernate.cfg.domainbinding.generator.GrailsTableGenerator
 import org.grails.orm.hibernate.cfg.domainbinding.hibernate.GrailsHibernatePersistentEntity
+import org.grails.orm.hibernate.cfg.domainbinding.hibernate.HibernateAssociation
 import org.grails.orm.hibernate.cfg.domainbinding.hibernate.HibernateBasicProperty
 import org.grails.orm.hibernate.cfg.domainbinding.hibernate.HibernateEmbeddedCollectionProperty
 import org.grails.orm.hibernate.cfg.domainbinding.hibernate.HibernateEmbeddedProperty
 import org.grails.orm.hibernate.cfg.domainbinding.hibernate.HibernateEnumProperty
+import org.grails.orm.hibernate.cfg.domainbinding.hibernate.HibernateManyToOneProperty
+import org.grails.orm.hibernate.cfg.domainbinding.hibernate.HibernateOneToOneProperty
 import org.grails.orm.hibernate.cfg.domainbinding.hibernate.HibernatePersistentProperty
 import org.grails.orm.hibernate.cfg.domainbinding.hibernate.HibernateSimpleIdentityProperty
 import org.grails.orm.hibernate.cfg.domainbinding.hibernate.HibernateSimpleProperty
 import org.grails.orm.hibernate.cfg.domainbinding.hibernate.HibernateTenantIdProperty
 import org.grails.orm.hibernate.cfg.domainbinding.hibernate.HibernateToManyProperty
+import org.grails.orm.hibernate.cfg.domainbinding.hibernate.HibernateToOneProperty
+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.DefaultColumnNameFetcher
 import org.grails.orm.hibernate.cfg.domainbinding.util.GrailsEnumType
@@ -144,6 +159,7 @@ class GrailsDomainGenerator {
     private final TypeConfiguration typeConfiguration
     private final TableForManyCalculator tableForMany
     private final DefaultColumnNameFetcher defaultColumnNames
+    private final CascadeBehaviorFetcher cascadeFetcher = new CascadeBehaviorFetcher()
 
     GrailsDomainGenerator(
             PersistentEntityNamingStrategy namingStrategy,
@@ -208,6 +224,15 @@ class GrailsDomainGenerator {
         for (GrailsHibernatePersistentEntity entity : given) {
             requireWholeHierarchy(entity, given)
         }
+        for (GrailsHibernatePersistentEntity entity : given) {
+            String problem = generationProblem(entity)
+            if (problem != null) {
+                throw new UnsupportedOperationException(problem)
+            }
+        }
+        for (GrailsHibernatePersistentEntity entity : given) {
+            requireReferencedEntities(entity, given)
+        }
         List<GrailsHibernatePersistentEntity> ordered = new ArrayList<GrailsHibernatePersistentEntity>(given)
         ordered.sort { GrailsHibernatePersistentEntity a, GrailsHibernatePersistentEntity b -> depth(a) <=> depth(b) }
 
@@ -249,6 +274,58 @@ class GrailsDomainGenerator {
         }
     }
 
+    private static void requireReferencedEntities(GrailsHibernatePersistentEntity entity, Set<GrailsHibernatePersistentEntity> given) {
+        for (GrailsHibernatePersistentEntity target : referencedEntities(entity)) {
+            if (!given.any { GrailsHibernatePersistentEntity other -> other.javaClass == target.javaClass }) {
+                throw new IllegalArgumentException(
+                        "Entity [${entity.name}] refers to [${target.name}], which is not part of the call: the generated field " +
+                                'is typed with the class generated for its target, so every entity of a connected group must be ' +
+                                'generated together (generateAll)')
+            }
+        }
+    }
+
+    /**
+     * @return the entities the generated class of the entity refers to through its associations, in property order and
+     *     without duplicates: the classes generated for them must be part of the same call
+     */
+    static List<GrailsHibernatePersistentEntity> referencedEntities(GrailsHibernatePersistentEntity entity) {
+        List<GrailsHibernatePersistentEntity> found = []
+        for (HibernatePersistentProperty property : entity.persistentPropertiesToBind) {
+            if (property instanceof HibernateToOneProperty) {
+                GrailsHibernatePersistentEntity target = ((HibernateToOneProperty) property).hibernateAssociatedEntity
+                if (target != null && !found.any { GrailsHibernatePersistentEntity other -> other.javaClass == target.javaClass }) {
+                    found << target
+                }
+            }
+        }
+        return found
+    }
+
+    /**
+     * @return why {@link #generateAll} cannot describe the entity, or {@code null} when it can: a hierarchy that mixes
+     *     strategies, a tenant id with a mapped type, an entity with no simple identifier, or a property the generator
+     *     does not support. It does not look at the other entities of the call.
+     */
+    String generationProblem(GrailsHibernatePersistentEntity entity) {
+        try {
+            hierarchyFacets(entity)
+            tenantFacets(entity)
+        } catch (UnsupportedOperationException e) {
+            return e.message
+        }
+        if (entity.isRoot() && !(entity.identity instanceof HibernateSimpleIdentityProperty)) {
+            return "Entity [${entity.name}] has no simple identifier (a composite identifier, for example), " +
+                    'which the generator does not support yet'
+        }
+        for (HibernatePersistentProperty property : entity.persistentPropertiesToBind) {
+            if (!supports(property)) {
+                return unsupportedReason(entity, property)
+            }
+        }
+        return null
+    }
+
     private static GrailsHibernatePersistentEntity superEntity(
             GrailsHibernatePersistentEntity entity, Set<GrailsHibernatePersistentEntity> given) {
         return given.find { GrailsHibernatePersistentEntity other -> other.javaClass == entity.javaClass.superclass }
@@ -277,9 +354,6 @@ class GrailsDomainGenerator {
             builder = defineIdentityAndVersion(builder, entity, embeddables)
         }
         for (HibernatePersistentProperty property : entity.persistentPropertiesToBind) {
-            if (!supports(property)) {
-                throw new UnsupportedOperationException(unsupportedReason(entity, property))
-            }
             builder = defineField(builder, property, [], embeddables)
         }
         return builder.make()
@@ -289,11 +363,6 @@ class GrailsDomainGenerator {
             DynamicType.Builder<Object> builder, GrailsHibernatePersistentEntity entity,
             Map<String, DynamicType.Unloaded<?>> embeddables) {
         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')
-        }
         List<AnnotationDescription> idAnnotations = [AnnotationDescription.Builder.ofType(Id).build()]
         idAnnotations.addAll(idGeneratorAnnotations(idFacets(entity)))
         DynamicType.Builder<Object> result = defineField(builder, identity, idAnnotations, embeddables)
@@ -326,6 +395,12 @@ class GrailsDomainGenerator {
                 return "Collection property [${property.name}] of [${entity.name}]: ${problem}"
             }
         }
+        if (property instanceof HibernateToOneProperty) {
+            String problem = toOneProblem((HibernateToOneProperty) property)
+            if (problem != null) {
+                return "Association property [${property.name}] of [${entity.name}]: ${problem}"
+            }
+        }
         if ((property instanceof HibernateSimpleProperty || property instanceof HibernateTenantIdProperty) && !decideType(property).supported) {
             return typeNotSupported(property, decideType(property).name)
         }
@@ -467,8 +542,8 @@ class GrailsDomainGenerator {
     /**
      * @return whether the generator can describe the property today: a plain single-column basic property, a
      *     derived (formula) property, the tenant id (an ordinary column), the version, an enum, an embedded object whose own properties are all
-     *     supported, a collection of basic values or enums, or the simple identifier. Custom types, multi-column properties and every association are not
-     *     supported yet.
+     *     supported, a collection of basic values or enums, a many-to-one association, or the simple identifier. Custom types,
+     *     multi-column properties and the other associations are not supported yet.
      */
     boolean supports(HibernatePersistentProperty property) {
         if (property instanceof HibernateSimpleIdentityProperty) {
@@ -480,6 +555,9 @@ class GrailsDomainGenerator {
         if (property instanceof HibernateBasicProperty) {
             return collectionProblem((HibernateBasicProperty) property) == null
         }
+        if (property instanceof HibernateToOneProperty) {
+            return toOneProblem((HibernateToOneProperty) property) == null
+        }
         if (!(property instanceof HibernateSimpleProperty) && !(property instanceof HibernateTenantIdProperty)) {
             return false
         }
@@ -825,6 +903,136 @@ class GrailsDomainGenerator {
                 column.scale?.intValue(), column.sqlType, null, null, null, null)
     }
 
+    /**
+     * @return why the generator cannot describe the to-one association, or {@code null} when it can
+     */
+    private String toOneProblem(HibernateToOneProperty property) {
+        if (!boundAsManyToOne(property)) {
+            return 'the binder binds this one-to-one as a Hibernate OneToOne (an inverse side, a hasOne or a shared primary key), ' +
+                    'which the generator does not support yet'
+        }
+        GrailsHibernatePersistentEntity target = property.hibernateAssociatedEntity
+        if (target == null) {
+            return 'the associated entity is unknown'
+        }
+        GrailsHibernatePersistentEntity root = target.hibernateRootEntity
+        if (root.hibernateCompositeIdentity.isPresent() || (root.compositeIdentity?.length ?: 0) > 1) {
+            return "the associated entity [${target.name}] has a composite identifier: the foreign key has one column for each " +
+                    'identifier property, named and ordered from the identifier'
+        }
+        PropertyConfig mapped = property.hibernateMappedForm
+        if (mapped.columns != null && mapped.columns.size() > 1) {
+            return 'the mapping states several columns, which the binder binds as a composite foreign key'
+        }
+        if (mapped.derived) {
+            return 'the association is mapped with a formula'
+        }
+        ColumnFacets key = toOneColumnFacets(property)
+        if (key.length() != null || key.precision() != null || key.scale() != null || key.defaultValue() != null ||
+                key.read() != null || key.write() != null || key.comment() != null) {
+            return 'the column config sets a length, a precision, a scale, a default, a read or write expression or a comment, ' +
+                    'which a join column cannot state'
+        }
+        return null
+    }
+
+    /**
+     * The binder binds a many-to-one as a {@code ManyToOne} value, and so it does a one-to-one that is not a valid Hibernate
+     * one-to-one ({@code ForeignKeyOneToOneBinder}): the foreign key is a column of the owner's table and only the
+     * uniqueness differs.
+     */
+    private static boolean boundAsManyToOne(HibernateToOneProperty property) {
+        return property instanceof HibernateManyToOneProperty ||
+                (property instanceof HibernateOneToOneProperty && !((HibernateOneToOneProperty) property).isValidHibernateOneToOne())
+    }
+
+    /**
+     * Decides how the binder binds a to-one association whose foreign key is a column of the owner's table: the associated
+     * entity, whether it is lazy, how it is fetched, whether the foreign key may be null, what happens when the row it
+     * points at is missing, the cascade and the join column.
+     *
+     * <p>{@code ColumnBinder} makes the foreign key column nullable as {@code isAssociationColumnNullable} says (always, for
+     * the associations handled here) and only then lets a subclass override it: a table-per-hierarchy subclass is always
+     * nullable, any other subclass follows the property's {@code nullable}. A root entity's association with
+     * {@code nullable: false} therefore keeps a nullable column, and only its {@code Property.optional} is false, which
+     * annotations cannot say (a non-optional association is a NOT NULL column), so the generated field is optional.</p>
+     *
+     * @throws UnsupportedOperationException when something about the association cannot be stated yet
+     */
+    ToOneFacets toOneFacets(HibernateToOneProperty property) {
+        if (toOneProblem(property) != null) {
+            throw new UnsupportedOperationException(unsupportedReason(property.hibernateOwner, property))
+        }
+        PropertyConfig mapped = property.hibernateMappedForm
+        ColumnFacets column = toOneColumnFacets(property)
+        return new ToOneFacets(
+                property.hibernateAssociatedEntity.name,
+                property.isLazy(),
+                FetchMode.JOIN == mapped.fetchMode ? FetchMode.JOIN : FetchMode.SELECT,
+                column.nullable(),
+                mapped.ignoreNotFound,
+                cascadeFacets(property),
+                column)
+    }
+
+    private ColumnFacets toOneColumnFacets(HibernateToOneProperty property) {
+        PropertyConfig mapped = property.hibernateMappedForm
+        ColumnConfig columnConfig = firstColumnConfig(mapped)
+        Column column = new Column()
+        columnConfigBinder.bindColumnConfigToColumn(column, columnConfig, mapped)
+        if (columnConfig != null) {
+            column.comment = columnConfig.comment
+            column.defaultValue = columnConfig.defaultValue
+            column.customRead = columnConfig.read
+            column.customWrite = columnConfig.write
+        }
+        String name = columnNames.getColumnNameForPropertyAndPath(property, '', columnConfig)
+        boolean nullable = property.isAssociationColumnNullable()
+        if (!property.hibernateOwner.isRoot()) {
+            Mapping mapping = property.hibernateOwner.hibernateMappedForm
+            nullable = mapping != null && mapping.tablePerHierarchy ? true : property.nullable
+        }
+        boolean unique = mapped.isUnique() && !mapped.isUniqueWithinGroup()
+        if (property instanceof HibernateOneToOneProperty && mapped.isUniqueWithinGroup()) {
+            // ForeignKeyOneToOneBinder: a column in a unique group is unique on its own only when the other side is a valid one-to-one
+            HibernateOneToOneProperty inverse = ((HibernateOneToOneProperty) property).hibernateInverseSide
+            unique = property.isBidirectional() && inverse != null && inverse.isValidHibernateOneToOne()
+        }
+        return facets(property, column, name, nullable, unique, true)
+    }
+
+    /**
+     * Decides the cascade of an association with the binder's own {@code CascadeBehaviorFetcher} (an explicit
+     * {@code cascade} mapping, else the behavior implied by the kind of association and who owns it) and splits it into what
+     * the annotations can say: JPA's {@code cascade}, Hibernate's {@code @Cascade} for what JPA cannot name, and
+     * {@code orphanRemoval}.
+     */
+    CascadeFacets cascadeFacets(HibernateAssociation property) {
+        CascadeBehavior behavior = CascadeBehavior.fromString(cascadeFetcher.getCascadeBehaviour((Association<?>) property))
+        switch (behavior) {
+            case CascadeBehavior.ALL:
+                return new CascadeFacets([CascadeType.ALL], [], false)
+            case CascadeBehavior.ALL_DELETE_ORPHAN:
+                return new CascadeFacets([CascadeType.ALL], [], true)
+            case CascadeBehavior.SAVE_UPDATE:
+                return new CascadeFacets([CascadeType.PERSIST, CascadeType.MERGE], [], false)
+            case CascadeBehavior.MERGE:
+                return new CascadeFacets([CascadeType.MERGE], [], false)
+            case CascadeBehavior.PERSIST:
+                return new CascadeFacets([CascadeType.PERSIST], [], false)
+            case CascadeBehavior.DELETE:
+                return new CascadeFacets([CascadeType.REMOVE], [], false)
+            case CascadeBehavior.EVICT:
+                return new CascadeFacets([CascadeType.DETACH], [], false)
+            case CascadeBehavior.LOCK:
+                return new CascadeFacets([], [org.hibernate.annotations.CascadeType.LOCK], false)
+            case CascadeBehavior.REPLICATE:
+                return new CascadeFacets([], [org.hibernate.annotations.CascadeType.REPLICATE], false)
+            default:
+                return new CascadeFacets([], [], false)
+        }
+    }
+
     /**
      * Decides the column facets for a supported property by running the domain binder's own rules on a scratch
      * {@link Column}, in the order the binder applies them.
@@ -834,6 +1042,9 @@ class GrailsDomainGenerator {
             throw new IllegalArgumentException(
                     "Property [${property.name}] is a collection: it has a key, an element and perhaps an index column, see collectionFacets")
         }
+        if (property instanceof HibernateToOneProperty) {
+            return toOneColumnFacets((HibernateToOneProperty) property)
+        }
         if (property instanceof HibernateEnumProperty) {
             return enumColumnFacets((HibernateEnumProperty) property, null)
         }
@@ -923,6 +1134,8 @@ class GrailsDomainGenerator {
                 }
             } else if (peer instanceof HibernateToManyProperty) {
                 return "the property [${peer.name}] of [${type.name}] is a collection inside an embedded type, which the generator does not support yet"
+            } else if (peer instanceof HibernateAssociation) {
+                return "the property [${peer.name}] of [${type.name}] is an association inside an embedded type, which the generator does not support yet"
             } else if (!supports(peer)) {
                 return unsupportedReason(type, peer)
             }
@@ -1103,6 +1316,9 @@ class GrailsDomainGenerator {
         if (property instanceof HibernateBasicProperty) {
             return defineCollectionField(builder, (HibernateBasicProperty) property)
         }
+        if (property instanceof HibernateToOneProperty) {
+            return defineToOneField(builder, (HibernateToOneProperty) property)
+        }
         List<AnnotationDescription> annotations = new ArrayList<>(extra)
         if (isDerived(property)) {
             annotations << AnnotationDescription.Builder.ofType(Formula).define('value', property.hibernateMappedForm.formula).build()
@@ -1124,6 +1340,83 @@ class GrailsDomainGenerator {
                 .annotateField(annotations as AnnotationDescription[])
     }
 
+    /**
+     * A many-to-one association is a field typed with the class generated for its target (which is why the target must be
+     * part of the same call), with {@code @ManyToOne} stating the fetch type, the optionality and the cascade, a
+     * {@code @JoinColumn}, an explicit {@code @Fetch} (without it an eager association would be a join fetch, which the
+     * binder's default is not), and {@code @NotFound} when the mapping says to ignore a missing row.
+     */
+    private DynamicType.Builder<Object> defineToOneField(DynamicType.Builder<Object> builder, HibernateToOneProperty property) {
+        ToOneFacets facets = toOneFacets(property)
+        List<AnnotationDescription> annotations = []
+        annotations << AnnotationDescription.Builder.ofType(ManyToOne)
+                .define('fetch', facets.lazy() ? FetchType.LAZY : FetchType.EAGER)
+                .define('optional', facets.optional())
+                .defineEnumerationArray('cascade', CascadeType, facets.cascade().jpa() as CascadeType[])
+                .build()
+        annotations << joinColumnAnnotation(facets.joinColumn())
+        annotations << AnnotationDescription.Builder.ofType(Fetch)
+                .define('value', facets.fetchMode() == FetchMode.JOIN ? AnnotationFetchMode.JOIN : AnnotationFetchMode.SELECT).build()
+        if (facets.ignoreNotFound()) {
+            annotations << AnnotationDescription.Builder.ofType(NotFound).define('action', NotFoundAction.IGNORE).build()
+        }
+        List<org.hibernate.annotations.CascadeType> hibernateCascade = new ArrayList<org.hibernate.annotations.CascadeType>(facets.cascade().hibernate())
+        if (facets.cascade().orphanRemoval()) {
+            // @ManyToOne has no orphanRemoval attribute
+            hibernateCascade << org.hibernate.annotations.CascadeType.DELETE_ORPHAN
+        }
+        if (!hibernateCascade.isEmpty()) {
+            annotations << AnnotationDescription.Builder.ofType(Cascade)
+                    .defineEnumerationArray('value', org.hibernate.annotations.CascadeType,
+                            hibernateCascade as org.hibernate.annotations.CascadeType[])
+                    .build()
+        }
+        for (Annotation constraint : validationAnnotations(property)) {
+            annotations << AnnotationDescription.ForLoadedAnnotation.of(constraint)
+        }
+        return builder.defineField(property.name, generatedType(property.hibernateAssociatedEntity), Visibility.PRIVATE)
+                .annotateField(annotations as AnnotationDescription[])
+    }
+
+    /**
+     * The type of a field that refers to another generated entity. It is only a name: the class itself is built in the same
+     * call and loaded together with its referrers, so entities may refer to each other in any shape, cycles included.
+     */
+    private static TypeDescription generatedType(GrailsHibernatePersistentEntity entity) {
+        return new GeneratedType(generatedClassName(entity))
+    }
+
+    /**
+     * A top-level class that is only named. ByteBuddy's own latent description refuses to say that it has no declaring or
+     * enclosing type, which it asks for when it writes a field of that type.
+     */
+    private static final class GeneratedType extends TypeDescription.Latent {
+
+        GeneratedType(String name) {
+            super(name, Modifier.PUBLIC, TypeDescription.Generic.OBJECT)
+        }
+
+        @Override
+        TypeDescription getDeclaringType() {
+            return null
+        }
+
+        @Override
+        TypeDescription getEnclosingType() {
+            return null
+        }
+
+        @Override
+        MethodDescription.InDefinedShape getEnclosingMethod() {
+            return null
+        }
+
+        @Override
+        TypeList getDeclaredTypes() {
+            return new TypeList.Empty()
+        }
+    }
+
     /**
      * A collection of basic values or enums is an {@code @ElementCollection} field whose generic signature names the
      * element (and the {@code String} key of a map), with its table, key, element, index and fetching stated by the
diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/ToOneFacets.groovy b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/ToOneFacets.groovy
new file mode 100644
index 0000000000..022220ca2a
--- /dev/null
+++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/ToOneFacets.groovy
@@ -0,0 +1,46 @@
+/*
+ *  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 org.hibernate.FetchMode
+
+/**
+ * The decisions the domain binder makes for an association whose foreign key is a column of the owner's table (a
+ * many-to-one, or a one-to-one that the binder binds as one): the associated entity, the fetching, the cascade and the
+ * foreign key column. {@link GrailsDomainGenerator} writes them into {@code @ManyToOne}, {@code @JoinColumn},
+ * {@code @Fetch}, {@code @NotFound} and {@code @Cascade}.
+ *
+ * <p>{@code target} is the GORM entity name of the associated entity; the generated field is typed with the class
+ * generated for it. {@code fetchMode} is {@code JOIN} or {@code SELECT}: the binder's {@code DEFAULT} is a select.
+ * {@code optional} is what the annotation states: Hibernate makes the join column NOT NULL for a non-optional
+ * association, so it is {@code true} exactly when the join column is nullable.</p>
+ *
+ * @since 9.0
+ */
+@CompileStatic
+record ToOneFacets(
+    String target,
+    boolean lazy,
+    FetchMode fetchMode,
+    boolean optional,
+    boolean ignoreNotFound,
+    CascadeFacets cascade,
+    ColumnFacets joinColumn) {
+}
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 666b4ac060..1a484d0d60 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
@@ -47,6 +47,10 @@ import org.hibernate.mapping.Formula
 import org.hibernate.mapping.JoinedSubclass
 import org.hibernate.mapping.List as HibernateList
 import org.hibernate.mapping.Map as HibernateMap
+import org.hibernate.mapping.ManyToOne
+import org.hibernate.mapping.ToOne
+import org.hibernate.mapping.Backref
+import org.hibernate.mapping.IndexBackref
 import org.hibernate.mapping.PersistentClass
 import org.hibernate.mapping.Property
 import org.hibernate.mapping.RootClass
@@ -77,7 +81,9 @@ import org.grails.orm.hibernate.cfg.domainbinding.hibernate.HibernateEmbeddedPro
 import org.grails.orm.hibernate.cfg.domainbinding.hibernate.HibernateEnumProperty
 import org.grails.orm.hibernate.cfg.domainbinding.hibernate.HibernatePersistentProperty
 import org.grails.orm.hibernate.cfg.domainbinding.hibernate.HibernateSimpleIdentityProperty
+import org.grails.orm.hibernate.cfg.domainbinding.hibernate.HibernateManyToOneProperty
 import org.grails.orm.hibernate.cfg.domainbinding.hibernate.HibernateSimpleProperty
+import org.grails.orm.hibernate.cfg.domainbinding.hibernate.HibernateToOneProperty
 import org.grails.orm.hibernate.cfg.domainbinding.hibernate.HibernateToManyProperty
 import org.grails.orm.hibernate.cfg.domainbinding.util.BackticksRemover
 import org.grails.orm.hibernate.cfg.domainbinding.util.ColumnNameForPropertyAndPathFetcher
@@ -123,6 +129,7 @@ class GrailsDomainGeneratorDifferentialSpec extends HibernateGormDatastoreSpec {
         Map<String, Integer> strategies = [:].withDefault { 0 }
         Map<String, Integer> hierarchies = [:].withDefault { 0 }
         Map<String, Integer> annotationRead = [:].withDefault { 0 }
+        Map<String, Integer> associations = [:].withDefault { 0 }
 
         when:
         for (List<Class<?>> group : groups) {
@@ -172,7 +179,7 @@ class GrailsDomainGeneratorDifferentialSpec extends HibernateGormDatastoreSpec {
                                     "${property.getClass().simpleName} (type ${property.getTypeName()})".toString() :
                                     property instanceof HibernateEmbeddedProperty ?
                                             "embedded: ${generator.unsupportedReason(entity, property).replaceAll(/\[[^\]]*\]/, '[..]')}".toString() :
-                                            property instanceof HibernateBasicProperty ?
+                                            property instanceof HibernateBasicProperty || property instanceof HibernateToOneProperty ?
                                                     "${property.getClass().simpleName}: ${generator.unsupportedReason(entity, property).replaceAll(/\[[^\]]*\]/, '[..]')}".toString() :
                                                     property.getClass().simpleName]++
                             continue
@@ -210,6 +217,19 @@ class GrailsDomainGeneratorDifferentialSpec extends HibernateGormDatastoreSpec {
                             }
                             continue
                         }
+                        if (property instanceof HibernateToOneProperty) {
+                            if (bound == null || !(bound.value instanceof ToOne) || bound.columns.size() != 1) {
+                                skipped['association with no single bound column']++
+                                continue
+                            }
+                            compared++
+                            String where = "${entity.name}.${property.name}".toString()
+                            associations[toOneKind(property)]++
+                            // 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(compareToOne(where, generator.toOneFacets((HibernateToOneProperty) property), bound, known))
+                            continue
+                        }
                         if (bound != null && generator.isDerived(property)) {
                             compared++
                             derived++
@@ -243,6 +263,7 @@ class GrailsDomainGeneratorDifferentialSpec extends HibernateGormDatastoreSpec {
                 "(${derived} derived); ${embeddedProperties} embedded properties compared, ${embeddedLeaves} embedded columns\n"
         report << "explicit types compared: ${explicitTypes}\n"
         report << "collections of basic values compared by kind: ${collections}\n"
+        report << "associations compared by kind: ${associations}\n"
         report << "tenant filters compared: ${tenants}\n"
         report << "id generators compared by strategy: ${strategies}\n"
         report << "entities compared by hierarchy role: ${hierarchies}\n"
@@ -278,7 +299,7 @@ class GrailsDomainGeneratorDifferentialSpec extends HibernateGormDatastoreSpec {
         return new HibernateDatastore(group as Class[])
     }
 
-    private List<String> compare(String where, ColumnFacets facets, Property bound) {
+    private List<String> compare(String where, ColumnFacets facets, Property bound, Collection<String> ignore = []) {
         Column column = (Column) bound.columns[0]
         Map<String, List> pairs = [
                 name      : [facets.name().replace('`', ''), column.name],
@@ -300,6 +321,7 @@ class GrailsDomainGeneratorDifferentialSpec extends HibernateGormDatastoreSpec {
             // Hibernate derives an sqlType for every column after binding; only an explicit one is comparable
             pairs.remove('sqlType')
         }
+        ignore.each { pairs.remove(it) }
         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()
         }
@@ -783,53 +805,93 @@ class GrailsDomainGeneratorDifferentialSpec extends HibernateGormDatastoreSpec {
     }
 
     /**
-     * For every hierarchy the generator can describe whole, binds the generated classes with Hibernate's own annotation
-     * binder and compares the resulting {@code PersistentClass} of each entity with the one the domain binder built:
-     * the link between what the generator decides and what Hibernate makes of the annotations.
+     * Binds the classes generated for a group with Hibernate's own annotation binder and compares the resulting
+     * {@code PersistentClass} of each entity with the one the domain binder built: the link between what the generator
+     * decides and what Hibernate makes of the annotations. A group is generated whole, because an association's field is
+     * typed with the class generated for its target; entities the generator cannot describe are left out together with
+     * everything that refers to them.
      */
     private List<String> compareAnnotationBoundHierarchies(
             GrailsDomainGenerator generator, List<GrailsHibernatePersistentEntity> entities, Map<String, Integer> skipped,
             Map<String, Integer> annotationRead, Map<String, Integer> known, Map<String, Integer> tenants) {
         List<String> found = []
-        Map<GrailsHibernatePersistentEntity, List<GrailsHibernatePersistentEntity>> hierarchies = [:]
-        for (GrailsHibernatePersistentEntity entity : entities) {
-            hierarchies.get(entity.hibernateRootEntity, []) << entity
+        List<GrailsHibernatePersistentEntity> generatable = generatableClosure(generator, entities, skipped)
+        if (generatable.isEmpty()) {
+            return found
         }
-        for (Map.Entry<GrailsHibernatePersistentEntity, List<GrailsHibernatePersistentEntity>> hierarchy : hierarchies.entrySet()) {
-            Map<GrailsHibernatePersistentEntity, Class<?>> classes
+        Map<GrailsHibernatePersistentEntity, Class<?>> classes
+        try {
+            classes = generator.generateAll(generatable, getClass().classLoader)
+        } catch (UnsupportedOperationException | IllegalArgumentException e) {
+            found << "${generatable*.name} generateAll: generator=rejected binder=accepted (${e.message?.readLines()?.first()})".toString()
+            return found
+        }
+        StandardServiceRegistry registry = new StandardServiceRegistryBuilder(
+                new BootstrapServiceRegistryBuilder().applyClassLoader(classes.values().first().classLoader).build())
+                .applySetting('hibernate.dialect', H2Dialect.name)
+                .applySetting('hibernate.connection.url', 'jdbc:h2:mem:generator-differential;DB_CLOSE_DELAY=-1')
+                .build()
+        try {
+            MetadataSources sources = new MetadataSources(registry)
+            classes.values().each { sources.addAnnotatedClass(it) }
+            Metadata metadata
             try {
-                classes = generator.generateAll(hierarchy.value, getClass().classLoader)
-            } catch (UnsupportedOperationException | IllegalArgumentException e) {
-                skipped["${hierarchy.value.size() > 1 ? 'hierarchy' : 'entity'} not generated whole: ${e.getClass().simpleName}".toString()]++
-                continue
+                metadata = sources.buildMetadata()
+            } catch (Exception e) {
+                found << "${generatable*.name} annotationBinder: generator=accepted binder=${e.message?.readLines()?.first()}".toString()
+                return found
             }
-            StandardServiceRegistry registry = new StandardServiceRegistryBuilder(
-                    new BootstrapServiceRegistryBuilder().applyClassLoader(classes.values().first().classLoader).build())
-                    .applySetting('hibernate.dialect', H2Dialect.name)
-                    .applySetting('hibernate.connection.url', 'jdbc:h2:mem:generator-differential;DB_CLOSE_DELAY=-1')
-                    .build()
-            try {
-                MetadataSources sources = new MetadataSources(registry)
-                classes.values().each { sources.addAnnotatedClass(it) }
-                Metadata metadata
-                try {
-                    metadata = sources.buildMetadata()
-                } catch (Exception e) {
-                    found << "${hierarchy.key.name} annotationBinder: generator=accepted binder=${e.message?.readLines()?.first()}".toString()
+            annotationRead['groups']++
+            Map<Class<?>, List<GrailsHibernatePersistentEntity>> hierarchies = generatable.groupBy { it.hibernateRootEntity.javaClass }
+            hierarchies.each { Class<?> root, List<GrailsHibernatePersistentEntity> members ->
+                annotationRead["${generator.hierarchyFacets(members.first().hibernateRootEntity).strategy() ?: 'single class'} (${members.size()} classes)".toString()]++
+            }
+            Map<String, GrailsHibernatePersistentEntity> byName = generatable.collectEntries { [(it.name): it] }
+            found.addAll(compareAnnotatedFilterDefinition(generatable, generator, metadata, tenants))
+            classes.each { GrailsHibernatePersistentEntity entity, Class<?> generated ->
+                found.addAll(compareAnnotatedTenantFilter(entity, generator, metadata.getEntityBinding(generated.name), tenants))
+                found.addAll(compareAnnotationBound(generator, entity, metadata.getEntityBinding(generated.name), byName, annotationRead, known))
+            }
+        } finally {
+            StandardServiceRegistryBuilder.destroy(registry)
+        }
+        return found
+    }
+
+    /**
+     * The entities of a group that can be generated together: those the generator describes, whose whole hierarchy it
+     * describes, and whose association targets it describes too. An entity that fails any of that is dropped with its whole
+     * hierarchy, which can in turn drop the entities that refer to it, until nothing changes.
+     */
+    private static List<GrailsHibernatePersistentEntity> generatableClosure(
+            GrailsDomainGenerator generator, List<GrailsHibernatePersistentEntity> entities, Map<String, Integer> skipped) {
+        List<GrailsHibernatePersistentEntity> candidates = new ArrayList<GrailsHibernatePersistentEntity>(entities)
+        boolean changed = true
+        while (changed) {
+            changed = false
+            for (GrailsHibernatePersistentEntity entity : new ArrayList<GrailsHibernatePersistentEntity>(candidates)) {
+                if (!candidates.contains(entity)) {
                     continue
                 }
-                annotationRead["${generator.hierarchyFacets(hierarchy.key).strategy() ?: 'single class'} (${classes.size()} classes)".toString()]++
-                Map<String, GrailsHibernatePersistentEntity> byName = hierarchy.value.collectEntries { [(it.name): it] }
-                found.addAll(compareAnnotatedFilterDefinition(hierarchy.value, generator, metadata, tenants))
-                classes.each { GrailsHibernatePersistentEntity entity, Class<?> generated ->
-                    found.addAll(compareAnnotatedTenantFilter(entity, generator, metadata.getEntityBinding(generated.name), tenants))
-                    found.addAll(compareAnnotationBound(generator, entity, metadata.getEntityBinding(generated.name), byName, annotationRead, known))
+                String reason = generator.generationProblem(entity)
+                if (reason == null) {
+                    GrailsHibernatePersistentEntity missing = GrailsDomainGenerator.referencedEntities(entity).find {
+                        GrailsHibernatePersistentEntity target -> !candidates.any { it.javaClass == target.javaClass }
+                    }
+                    if (missing != null) {
+                        reason = "it refers to [${missing.name}], which is not generated"
+                    }
+                }
+                if (reason != null) {
+                    Class<?> root = entity.hibernateRootEntity.javaClass
+                    List<GrailsHibernatePersistentEntity> removed = candidates.findAll { it.hibernateRootEntity.javaClass == root }
+                    candidates.removeAll(removed)
+                    skipped["not read back (${removed.size() > 1 ? 'hierarchy' : 'entity'}): ${reason.replaceAll(/\[[^\]]*\]/, '[..]')}".toString()]++
+                    changed = true
                 }
-            } finally {
-                StandardServiceRegistryBuilder.destroy(registry)
             }
         }
-        return found
+        return candidates
     }
 
     private static List<String> compareAnnotationBound(
@@ -849,7 +911,7 @@ class GrailsDomainGeneratorDifferentialSpec extends HibernateGormDatastoreSpec {
                                      annotated.superclass == null || !annotated.table.is(annotated.superclass.table)],
                 abstractClass     : [Boolean.TRUE == bound.isAbstract(), Boolean.TRUE == annotated.isAbstract()],
                 abstractUnionTable: [bound.table.isAbstractUnionTable(), annotated.table.isAbstractUnionTable()],
-                properties        : [bound.declaredProperties*.name.toSet(), annotated.declaredProperties*.name.toSet()],
+                properties        : [declaredNames(bound), declaredNames(annotated)],
         ]
         if (bound instanceof SingleTableSubclass || bound instanceof RootClass && bound.discriminator != null) {
             pairs.discriminatorValue = [bound.discriminatorValue, annotated.discriminatorValue]
@@ -880,6 +942,15 @@ class GrailsDomainGeneratorDifferentialSpec extends HibernateGormDatastoreSpec {
                         generator.collectionFacets(collectionProperty), known))
                 continue
             }
+            if (property.value instanceof ToOne) {
+                HibernatePersistentProperty source = entity.persistentPropertiesToBind.find { it.name == property.name }
+                if (source instanceof HibernateToOneProperty && generator.validationAnnotations(source).isEmpty()) {
+                    annotationRead['associations']++
+                    found.addAll(compareAnnotatedToOne(
+                            "${where} property ${property.name}".toString(), generator.toOneFacets((HibernateToOneProperty) source),
+                            property, other, byName, known))
+                }
+            }
             Map<String, Property> boundLeaves = terminalProperties(property)
             Map<String, Property> annotatedLeaves = terminalProperties(other)
             if (boundLeaves.keySet() != annotatedLeaves.keySet()) {
@@ -900,6 +971,118 @@ class GrailsDomainGeneratorDifferentialSpec extends HibernateGormDatastoreSpec {
         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()
+    }
+
+    private static String toOneKind(HibernatePersistentProperty property) {
+        return property instanceof HibernateManyToOneProperty ? 'many-to-one' : 'one-to-one'
+    }
+
+    private static String fetchModeOf(ToOne value) {
+        return value.fetchMode == FetchMode.JOIN ? 'JOIN' : 'SELECT'
+    }
+
+    /**
+     * A to-one association against the binder's {@code ToOne} value and property: the referenced entity, whether it is lazy,
+     * how it is fetched, what a missing row does, the cascade and whether the property is optional. The foreign key column
+     * itself goes through the ordinary column comparison.
+     */
+    private static List<String> compareToOne(String where, ToOneFacets facets, Property bound, Map<String, Integer> known) {
+        ToOne value = (ToOne) bound.value
+        Map<String, List> pairs = [
+                target        : [facets.target(), value.referencedEntityName],
+                lazy          : [facets.lazy(), value.lazy],
+                fetchMode     : [facets.fetchMode() == FetchMode.JOIN ? 'JOIN' : 'SELECT', fetchModeOf(value)],
+                ignoreNotFound: [facets.ignoreNotFound(), value instanceof ManyToOne && ((ManyToOne) value).ignoreNotFound],
+                cascade       : [cascadeActions(facets.cascade()), cascadeActions(bound.cascade)],
+        ]
+        List<String> found = pairs.findAll { String facet, List values -> values[0] != values[1] }.collect { String facet, List values ->
+            "${where} ${facet}: generator=${values[0]} binder=${values[1]}".toString()
+        }
+        if (facets.optional() != bound.optional) {
+            if (facets.optional() == facets.joinColumn().nullable()) {
+                known['the binder makes a to-one association optional or not independently of its foreign key column being nullable; annotations state both together, so the generated field follows the column']++
+            } else {
+                found << "${where} optional: generator=${facets.optional()} binder=${bound.optional}".toString()
+            }
+        }
+        return found
+    }
+
+    private static Set<String> cascadeActions(CascadeFacets facets) {
+        Map<String, String> jpa = [ALL: 'all', PERSIST: 'persist', MERGE: 'merge', REMOVE: 'delete', REFRESH: 'refresh', DETACH: 'evict']
+        List<String> tokens = facets.jpa().collect { jpa[it.name()] }
+        tokens.addAll(facets.hibernate().collect { it.name().toLowerCase().replace('_', '-') })
+        if (facets.orphanRemoval()) {
+            tokens << 'delete-orphan'
+        }
+        return normalizedCascade(tokens)
+    }
+
+    private static Set<String> cascadeActions(String cascade) {
+        return normalizedCascade(cascade == null ? [] : cascade.split(',').collect { it.trim() })
+    }
+
+    /** The actions a cascade string stands for: {@code all} subsumes the others but orphan removal, and {@code none} is no action. */
+    private static Set<String> normalizedCascade(Collection<String> tokens) {
+        Set<String> actions = new TreeSet<String>()
+        tokens.each { String token ->
+            if (token == 'all-delete-orphan') {
+                actions << 'all' << 'delete-orphan'
+            } else if (token != 'none' && !token.isEmpty()) {
+                actions << token
+            }
+        }
+        if (actions.contains('all')) {
+            actions.retainAll(['all', 'delete-orphan'])
+        }
+        return actions
+    }
+
+    /**
+     * A to-one association of the binder's entity against the one Hibernate's annotation binder built: the same referenced
+     * entity (by the name of the class generated for it), fetching, cascade and write behavior. Where Hibernate's
+     * annotation path cannot say what the binder says, the difference is listed as a known divergence.
+     */
+    private static List<String> compareAnnotatedToOne(
+            String where, ToOneFacets facets, Property boundProperty, Property annotatedProperty,
+            Map<String, GrailsHibernatePersistentEntity> byName, Map<String, Integer> known) {
+        if (!(annotatedProperty.value instanceof ToOne)) {
+            return ["${where} kind: generator=${annotatedProperty.value.getClass().simpleName} binder=${boundProperty.value.getClass().simpleName}".toString()]
+        }
+        ToOne bound = (ToOne) boundProperty.value
+        ToOne annotated = (ToOne) annotatedProperty.value
+        Map<String, List> pairs = [
+                target        : [GrailsDomainGenerator.generatedClassName(byName[bound.referencedEntityName]), annotated.referencedEntityName],
+                fetchMode     : [fetchModeOf(bound), fetchModeOf(annotated)],
+                ignoreNotFound: [bound instanceof ManyToOne && ((ManyToOne) bound).ignoreNotFound,
+                                 annotated instanceof ManyToOne && ((ManyToOne) annotated).ignoreNotFound],
+                cascade       : [cascadeActions(boundProperty.cascade), cascadeActions(annotatedProperty.cascade)],
+                insertable    : [boundProperty.insertable, annotatedProperty.insertable],
+                updatable     : [boundProperty.updateable, annotatedProperty.updateable],
+        ]
+        List<String> found = pairs.findAll { String facet, List values -> values[0] != values[1] }.collect { String facet, List values ->
+            "${where} ${facet}: generator=${values[1]} binder=${values[0]}".toString()
+        }
+        if (bound.lazy != annotated.lazy) {
+            if (bound.lazy && !annotated.lazy && facets.ignoreNotFound()) {
+                known['Hibernate binds a @NotFound association as eager (lazy false), the binder keeps it lazy']++
+            } else {
+                found << "${where} lazy: generator=${annotated.lazy} binder=${bound.lazy}".toString()
+            }
+        }
+        if (boundProperty.optional != annotatedProperty.optional) {
+            if (annotatedProperty.optional == facets.joinColumn().nullable()) {
+                known['the binder makes a to-one association optional or not independently of its foreign key column being nullable; annotations state both together, so the generated field follows the column']++
+            } else {
+                found << "${where} optional: generator=${annotatedProperty.optional} binder=${boundProperty.optional}".toString()
+            }
+        }
+        return found
+    }
+
     /**
      * A collection of the binder's entity against the one Hibernate's annotation binder built: the same kind, table, key,
      * element and index columns, and the same fetching and caching.
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 1c465bb5a6..2c5862a1d9 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
@@ -43,6 +43,7 @@ import jakarta.persistence.Id
 import jakarta.persistence.Inheritance
 import jakarta.persistence.InheritanceType
 import jakarta.persistence.JoinColumn
+import jakarta.persistence.ManyToOne
 import jakarta.persistence.MapKeyColumn
 import jakarta.persistence.OrderColumn
 import jakarta.persistence.PrimaryKeyJoinColumn
@@ -52,6 +53,7 @@ import jakarta.validation.constraints.Size
 import org.hibernate.annotations.BatchSize
 import org.hibernate.annotations.Cache
 import org.hibernate.annotations.CacheConcurrencyStrategy
+import org.hibernate.annotations.Cascade
 import org.hibernate.annotations.ColumnDefault
 import org.hibernate.annotations.Comment
 import org.hibernate.annotations.DiscriminatorFormula
@@ -62,6 +64,8 @@ import org.hibernate.annotations.FetchMode
 import org.hibernate.annotations.Formula
 import org.hibernate.annotations.IdGeneratorType
 import org.hibernate.annotations.JdbcTypeCode
+import org.hibernate.annotations.NotFound
+import org.hibernate.annotations.NotFoundAction
 import org.hibernate.annotations.Type
 import org.hibernate.annotations.UuidGenerator
 import org.hibernate.boot.Metadata
@@ -105,6 +109,10 @@ import org.grails.orm.hibernate.cfg.HibernateMappingContext
 import org.grails.orm.hibernate.connections.HibernateConnectionSourceSettings
 import org.grails.orm.hibernate.cfg.domainbinding.hibernate.HibernateBasicProperty
 import org.grails.orm.hibernate.cfg.domainbinding.hibernate.HibernateEmbeddedProperty
+import org.grails.orm.hibernate.cfg.domainbinding.hibernate.HibernateManyToOneProperty
+import org.grails.orm.hibernate.cfg.domainbinding.hibernate.HibernateOneToOneProperty
+import org.grails.orm.hibernate.cfg.domainbinding.hibernate.HibernatePersistentProperty
+import org.grails.orm.hibernate.cfg.domainbinding.hibernate.HibernateToOneProperty
 import org.grails.orm.hibernate.cfg.domainbinding.generator.GrailsIdentityGenerator
 import org.grails.orm.hibernate.cfg.domainbinding.generator.GrailsIncrementGenerator
 import org.grails.orm.hibernate.cfg.domainbinding.generator.GrailsNativeGenerator
@@ -135,7 +143,8 @@ class GrailsDomainGeneratorSpec extends HibernateGormDatastoreSpec {
                 GenGizmo, GenCoded, GenCodedChild, GenFormulaRoot, GenFormulaChild, GenAbstractBase, GenConcreteChild, GenNoted, GenNotedChild,
                 GenJoinedVehicle, GenJoinedCar, GenJoinedSportsCar, GenJoinedSedan, GenJoinedKeyed, GenJoinedKeyedChild,
                 GenFleetVehicle, GenFleetCar, GenFleetSportsCar, GenFleetSedan, GenUnionBase, GenUnionLeaf, GenUnionMiddle, GenUnionBottom,
-                GenEmbedOwner, GenEmbedOther, GenEmbedBase, GenEmbedChild, GenEmbedBadHolder, GenEmbedFormulaOwner, GenCollSingle, GenCollKinds, GenCollLazy)
+                GenEmbedOwner, GenEmbedOther, GenEmbedBase, GenEmbedChild, GenEmbedBadHolder, GenEmbedFormulaOwner, GenCollSingle, GenCollKinds, GenCollLazy,
+                GenFkTarget, GenFkOwned, GenFkOwner, GenFkCascades, GenFkNodeA, GenFkNodeB, GenFkHasOneOwner, GenFkHasOneDetail, GenFkManyOne, GenFkOneSide, GenFkSub, GenFkSubRoot)
     }
 
     List<StandardServiceRegistry> registries = []
@@ -221,13 +230,16 @@ class GrailsDomainGeneratorSpec extends HibernateGormDatastoreSpec {
     }
 
     void "a property the generator does not support is rejected by name"() {
+        given:
+        GrailsHibernatePersistentEntity entity = unbound(GenFkCompositeRef)
+
         when:
-        generate(GenWithOwner)
+        newGenerator().generateAll([entity], getClass().classLoader)
 
         then:
         UnsupportedOperationException e = thrown()
-        e.message.contains('basic')
-        e.message.contains('does not support yet')
+        e.message.contains('Association property [target] of [' + GenFkCompositeRef.name + ']')
+        e.message.contains('composite identifier')
     }
 
     void "the version is marked as the optimistic lock"() {
@@ -1176,6 +1188,200 @@ class GrailsDomainGeneratorSpec extends HibernateGormDatastoreSpec {
         newGenerator().unsupportedReason(holder, holder.getHibernatePropertyByName('inner')).contains('collection inside an embedded type')
     }
 
+    void "a to-one association is a field typed with the class generated for its target, in the same class loader"() {
+        given:
+        Map<GrailsHibernatePersistentEntity, Class<?>> classes = generateGroup(GenFkOwner, GenFkTarget, GenFkOwned)
+        Class<?> owner = classes[entity(GenFkOwner)]
+        Class<?> target = classes[entity(GenFkTarget)]
+
+        expect:
+        owner.getDeclaredField('plain').type == target
+        owner.getDeclaredField('owned').type == classes[entity(GenFkOwned)]
+        owner.classLoader == target.classLoader
+        owner.getDeclaredField('plain').isAnnotationPresent(ManyToOne)
+        !owner.getDeclaredField('plain').isAnnotationPresent(jakarta.persistence.OneToOne)
+    }
+
+    void "an entity that refers to another is rejected by name when the target is not part of the call"() {
+        when:
+        generate(GenFkOwner)
+
+        then:
+        IllegalArgumentException e = thrown()
+        e.message.contains('[' + GenFkOwner.name + ']')
+        e.message.contains('[' + GenFkTarget.name + ']')
+        e.message.contains('generateAll')
+    }
+
+    void "the fetching, optionality, foreign key column and missing-row handling of a to-one association are the ones the binder decides"() {
+        given:
+        Class<?> owner = generateGroup(GenFkOwner, GenFkTarget, GenFkOwned)[entity(GenFkOwner)]
+        Field field = owner.getDeclaredField(property)
+
+        expect:
+        field.getAnnotation(ManyToOne).fetch() == fetch
+        field.getAnnotation(ManyToOne).optional() == optional
+        field.getAnnotation(JoinColumn).name() == column
+        field.getAnnotation(JoinColumn).nullable() == nullable
+        field.getAnnotation(JoinColumn).unique() == unique
+        field.getAnnotation(Fetch).value() == fetchMode
+        field.isAnnotationPresent(NotFound) == ignoreNotFound
+        !ignoreNotFound || field.getAnnotation(NotFound).action() == NotFoundAction.IGNORE
+
+        where:
+        property   | fetch           | optional | column       | nullable | unique | fetchMode         | ignoreNotFound
+        'plain'    | FetchType.LAZY  | true     | 'plain_id'   | true     | false  | FetchMode.SELECT  | false
+        'required' | FetchType.LAZY  | true     | 'required_id'| true     | false  | FetchMode.SELECT  | false
+        'joined'   | FetchType.EAGER | true     | 'joined_id'  | true     | false  | FetchMode.JOIN    | false
+        'eager'    | FetchType.EAGER | true     | 'eager_id'   | true     | false  | FetchMode.SELECT  | false
+        'missing'  | FetchType.LAZY  | true     | 'missing_id' | true     | false  | FetchMode.SELECT  | true
+        'named'    | FetchType.LAZY  | true     | 'target_ref' | true     | true   | FetchMode.SELECT  | false
+    }
+
+    void "a self reference is a field of the generated class itself"() {
+        given:
+        Class<?> owner = generateGroup(GenFkOwner, GenFkTarget, GenFkOwned)[entity(GenFkOwner)]
+
+        expect:
+        owner.getDeclaredField('parent').type == owner
+    }
+
+    void "the cascade of a to-one association is the binder's, split into what JPA and Hibernate can state"() {
+        given:
+        Class<?> owner = generateGroup(GenFkCascades, GenFkTarget)[entity(GenFkCascades)]
+        Field field = owner.getDeclaredField(property)
+
+        expect:
+        field.getAnnotation(ManyToOne).cascade().toList() == jpa
+        (field.isAnnotationPresent(Cascade) ? field.getAnnotation(Cascade).value().toList() : []) == hibernate
+
+        where:
+        property    | jpa                                                                  | hibernate
+        'cascadeAll' | [jakarta.persistence.CascadeType.ALL]                                | []
+        'cascadeOrphan' | [jakarta.persistence.CascadeType.ALL]                                | [org.hibernate.annotations.CascadeType.DELETE_ORPHAN]
+        'cascadeSaveUpdate' | [jakarta.persistence.CascadeType.PERSIST, jakarta.persistence.CascadeType.MERGE] | []
+        'cascadeMerged' | [jakarta.persistence.CascadeType.MERGE]                              | []
+        'cascadePersisted' | [jakarta.persistence.CascadeType.PERSIST]                            | []
+        'cascadeDeleted' | [jakarta.persistence.CascadeType.REMOVE]                             | []
+        'cascadeEvicted' | [jakarta.persistence.CascadeType.DETACH]                             | []
+        'cascadeLocked' | []                                                                   | [org.hibernate.annotations.CascadeType.LOCK]
+        'cascadeReplicated' | []                                                                   | [org.hibernate.annotations.CascadeType.REPLICATE]
+        'cascadeNone' | []                                                                   | []
+    }
+
+    void "two entities that refer to each other are generated in one call, whatever the order"() {
+        when:
+        Map<GrailsHibernatePersistentEntity, Class<?>> classes = generateGroup(*order)
+        Class<?> a = classes[entity(GenFkNodeA)]
+        Class<?> b = classes[entity(GenFkNodeB)]
+
+        then:
+        a.getDeclaredField('other').type == b
+        b.getDeclaredField('back').type == a
+        a.getDeclaredField('other').getAnnotation(JoinColumn).name() == 'other_id'
+        b.getDeclaredField('back').getAnnotation(JoinColumn).name() == 'back_id'
+
+        where:
+        order << [[GenFkNodeA, GenFkNodeB], [GenFkNodeB, GenFkNodeA]]
+    }
+
+    void "a one-to-one with no inverse is bound as a many-to-one, so it is a @ManyToOne with a plain join column"() {
+        given:
+        GrailsHibernatePersistentEntity entity = entity(GenFkOwner)
+
+        expect:
+        entity.getHibernatePropertyByName('plain') instanceof HibernateOneToOneProperty
+        !((HibernateOneToOneProperty) entity.getHibernatePropertyByName('plain')).isValidHibernateOneToOne()
+        newGenerator().supports(entity.getHibernatePropertyByName('plain'))
+    }
+
+    void "a many-to-one with an inverse hasMany is a to-one association with the same facets"() {
+        given:
+        HibernatePersistentProperty property = entity(GenFkManyOne).getHibernatePropertyByName('one')
+        ToOneFacets facets = newGenerator().toOneFacets((HibernateToOneProperty) property)
+
+        expect:
+        property instanceof HibernateManyToOneProperty
+        facets.target() == GenFkOneSide.name
+        facets.lazy()
+        facets.fetchMode() == org.hibernate.FetchMode.SELECT
+        facets.optional()
+        !facets.ignoreNotFound()
+        facets.joinColumn().name() == 'one_id'
+        facets.joinColumn().nullable()
+    }
+
+    void "a to-one association of a subclass in a table-per-hierarchy is always nullable and named like its property"() {
+        given:
+        ToOneFacets facets = newGenerator().toOneFacets(
+                (HibernateToOneProperty) entity(GenFkSub).getHibernatePropertyByName('target'))
+
+        expect:
+        facets.joinColumn().name() == 'target_id'
+        facets.joinColumn().nullable()
+        facets.optional()
+    }
+
+    void "a one-to-one the binder binds as a Hibernate OneToOne is rejected by name until it is supported"() {
+        given:
+        GrailsHibernatePersistentEntity entity = entity(GenFkHasOneOwner)
+        HibernatePersistentProperty property = entity.getHibernatePropertyByName('detail')
+
+        expect:
+        !newGenerator().supports(property)
+        newGenerator().unsupportedReason(entity, property).contains('Hibernate OneToOne')
+    }
+
+    void "an association to an entity with a composite identifier is rejected by name"() {
+        given:
+        GrailsHibernatePersistentEntity entity = unbound(GenFkCompositeRef)
+        HibernatePersistentProperty property = entity.getHibernatePropertyByName('target')
+
+        expect:
+        !newGenerator().supports(property)
+        newGenerator().unsupportedReason(entity, property).contains('composite identifier')
+    }
+
+    void "Hibernate's own annotation binder reads a generated to-one association as a ManyToOne to the generated target"() {
+        given:
+        Map<GrailsHibernatePersistentEntity, Class<?>> classes = generateGroup(GenFkOwner, GenFkTarget, GenFkOwned)
+        Metadata metadata = annotationMetadata(classes.values())
+        PersistentClass owner = metadata.getEntityBinding(classes[entity(GenFkOwner)].name)
+        Property read = owner.getProperty(property)
+        org.hibernate.mapping.ManyToOne value = (org.hibernate.mapping.ManyToOne) read.value
+
+        expect:
+        value.referencedEntityName == classes[entity(target)].name
+        value.selectables*.text == [column]
+        value.lazy == lazy
+        value.fetchMode == fetchMode
+        value.ignoreNotFound == ignoreNotFound
+        read.cascade == cascade
+
+        where:
+        property   | target       | column       | lazy  | fetchMode                   | ignoreNotFound | cascade
+        'plain'    | GenFkTarget  | 'plain_id'   | true  | org.hibernate.FetchMode.SELECT | false       | 'persist,merge'
+        'joined'   | GenFkTarget  | 'joined_id'  | false | org.hibernate.FetchMode.JOIN   | false       | 'persist,merge'
+        'eager'    | GenFkTarget  | 'eager_id'   | false | org.hibernate.FetchMode.SELECT | false       | 'persist,merge'
+        'missing'  | GenFkTarget  | 'missing_id' | false | org.hibernate.FetchMode.SELECT | true        | 'persist,merge'
+        'named'    | GenFkTarget  | 'target_ref' | true  | org.hibernate.FetchMode.SELECT | false       | 'persist,merge'
+        'parent'   | GenFkOwner   | 'parent_id'  | true  | org.hibernate.FetchMode.SELECT | false       | 'persist,merge'
+    }
+
+    void "a to-one association keeps the unique foreign key of its mapping in the annotation-built column"() {
+        given:
+        Map<GrailsHibernatePersistentEntity, Class<?>> classes = generateGroup(GenFkOwner, GenFkTarget, GenFkOwned)
+        PersistentClass owner = annotationMetadata(classes.values()).getEntityBinding(classes[entity(GenFkOwner)].name)
+
+        expect:
+        owner.getProperty('named').columns[0].unique
+        !owner.getProperty('plain').columns[0].unique
+    }
+
+    private Map<GrailsHibernatePersistentEntity, Class<?>> generateGroup(Class<?>... domainClasses) {
+        return newGenerator().generateAll(domainClasses.collect { entity(it) }, getClass().classLoader)
+    }
+
     private GrailsHibernatePersistentEntity unbound(Class<?> domainClass) {
         // the mapping model alone, so a domain the binder cannot boot can still be described
         return (GrailsHibernatePersistentEntity) new HibernateMappingContext(
@@ -2023,3 +2229,135 @@ class GenNativeProbeEntity {
     @GenNativeProbe
     Long id
 }
+
+@Entity
+class GenFkTarget {
+
+    String name
+}
+
+@Entity
+class GenFkOwned {
+
+    String name
+
+    static belongsTo = [GenFkOwner]
+}
+
+@Entity
+class GenFkOwner {
+
+    String title
+    GenFkTarget plain
+    GenFkTarget required
+    GenFkTarget joined
+    GenFkTarget eager
+    GenFkTarget missing
+    GenFkTarget named
+    GenFkOwned owned
+    GenFkOwner parent
+
+    static constraints = {
+        required nullable: false
+    }
+
+    static mapping = {
+        joined fetch: 'join'
+        eager lazy: false
+        missing ignoreNotFound: true
+        named column: 'target_ref', unique: true
+    }
+}
+
+@Entity
+class GenFkCascades {
+
+    GenFkTarget cascadeAll
+    GenFkTarget cascadeOrphan
+    GenFkTarget cascadeSaveUpdate
+    GenFkTarget cascadeMerged
+    GenFkTarget cascadePersisted
+    GenFkTarget cascadeDeleted
+    GenFkTarget cascadeEvicted
+    GenFkTarget cascadeLocked
+    GenFkTarget cascadeReplicated
+    GenFkTarget cascadeNone
+
+    static mapping = {
+        cascadeAll cascade: 'all'
+        cascadeOrphan cascade: 'all-delete-orphan'
+        cascadeSaveUpdate cascade: 'save-update'
+        cascadeMerged cascade: 'merge'
+        cascadePersisted cascade: 'persist'
+        cascadeDeleted cascade: 'delete'
+        cascadeEvicted cascade: 'evict'
+        cascadeLocked cascade: 'lock'
+        cascadeReplicated cascade: 'replicate'
+        cascadeNone cascade: 'none'
+    }
+}
+
+@Entity
+class GenFkNodeA {
+
+    GenFkNodeB other
+}
+
+@Entity
+class GenFkNodeB {
+
+    GenFkNodeA back
+}
+
+@Entity
+class GenFkHasOneOwner {
+
+    static hasOne = [detail: GenFkHasOneDetail]
+}
+
+@Entity
+class GenFkHasOneDetail {
+
+    GenFkHasOneOwner owner
+}
+
+@Entity
+class GenFkManyOne {
+
+    GenFkOneSide one
+}
+
+@Entity
+class GenFkOneSide {
+
+    static hasMany = [many: GenFkManyOne]
+}
+
+@Entity
+class GenFkSubRoot {
+
+    String name
+}
+
+@Entity
+class GenFkSub extends GenFkSubRoot {
+
+    GenFkTarget target
+}
+
+@Entity
+class GenFkComposite implements Serializable {
+
+    String first
+    String last
+
+    static mapping = {
+        id composite: ['first', 'last']
+    }
+}
+
+@Entity
+class GenFkCompositeRef {
+
+    GenFkComposite target
+}