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

(grails-core) 15/29: feat: describe the inverse side of a one-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 8013c1215ed1bd03ced25f16566a5fd4b368c0fd
Author: Walter Duque de Estrada <wb...@mac.com>
AuthorDate: Mon Oct 5 02:27:49 2026 -0500

    feat: describe the inverse side of a one-to-one in GrailsDomainGenerator
    
    The binder binds a valid one-to-one as a Hibernate OneToOne with no column that
    only names the property of the other side holding the foreign key, which is
    bound as a many-to-one. The generator states the inverse side as
    @OneToOne(mappedBy) and keeps the foreign key side a @ManyToOne, because
    @OneToOne with a join column would add a unique constraint the binder does not.
    
    The differential compares 22 inverse sides with the bound OneToOne values and
    reads them back through Hibernate's annotation binder with no mismatches.
    
    Co-Authored-By: Claude Sonnet 5.5 <no...@anthropic.com>
---
 .../domainbinding/jpa/GrailsDomainGenerator.groovy | 64 ++++++++++++---
 .../cfg/domainbinding/jpa/ToOneFacets.groovy       | 12 ++-
 .../GrailsDomainGeneratorDifferentialSpec.groovy   | 76 +++++++++++++++--
 .../jpa/GrailsDomainGeneratorSpec.groovy           | 95 ++++++++++++++++++++--
 4 files changed, 221 insertions(+), 26 deletions(-)

diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGenerator.groovy b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGenerator.groovy
index 0da83e75fb..27a63f649f 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
@@ -44,6 +44,7 @@ import jakarta.persistence.InheritanceType
 import jakarta.persistence.JoinColumn
 import jakarta.persistence.ManyToOne
 import jakarta.persistence.MapKeyColumn
+import jakarta.persistence.OneToOne
 import jakarta.persistence.OrderColumn
 import jakarta.persistence.PrimaryKeyJoinColumn
 import jakarta.persistence.Table
@@ -907,8 +908,9 @@ class GrailsDomainGenerator {
      * @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), ' +
+        boolean inverseOneToOne = boundAsOneToOne(property)
+        if (inverseOneToOne && ((HibernateOneToOneProperty) property).needsSimpleValueBinding()) {
+            return 'the binder binds this one-to-one as a Hibernate OneToOne with a column of its own (constrained), ' +
                     'which the generator does not support yet'
         }
         GrailsHibernatePersistentEntity target = property.hibernateAssociatedEntity
@@ -921,6 +923,9 @@ class GrailsDomainGenerator {
                     'identifier property, named and ordered from the identifier'
         }
         PropertyConfig mapped = property.hibernateMappedForm
+        if (inverseOneToOne) {
+            return null
+        }
         if (mapped.columns != null && mapped.columns.size() > 1) {
             return 'the mapping states several columns, which the binder binds as a composite foreign key'
         }
@@ -946,6 +951,15 @@ class GrailsDomainGenerator {
                 (property instanceof HibernateOneToOneProperty && !((HibernateOneToOneProperty) property).isValidHibernateOneToOne())
     }
 
+    /**
+     * The binder binds a valid one-to-one ({@code OneToOneBinder}) as a Hibernate {@code OneToOne}. Its foreign key lives on
+     * the other side (which is bound as a many-to-one above), so this side has no column and only names the property that
+     * holds the key.
+     */
+    private static boolean boundAsOneToOne(HibernateToOneProperty property) {
+        return 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
@@ -964,6 +978,20 @@ class GrailsDomainGenerator {
             throw new UnsupportedOperationException(unsupportedReason(property.hibernateOwner, property))
         }
         PropertyConfig mapped = property.hibernateMappedForm
+        if (boundAsOneToOne(property)) {
+            HibernateOneToOneProperty oneToOne = (HibernateOneToOneProperty) property
+            // OneToOneBinder never sets the value's lazy flag (it stays true) nor an ignore-not-found, and constrained is false
+            return new ToOneFacets(
+                    property.hibernateAssociatedEntity.name,
+                    true,
+                    FetchMode.JOIN == oneToOne.hibernateFetchMode ? FetchMode.JOIN : FetchMode.SELECT,
+                    true,
+                    false,
+                    cascadeFacets(property),
+                    null,
+                    oneToOne.hibernateReferencedPropertyName,
+                    oneToOne.hibernateReferencedEntityName)
+        }
         ColumnFacets column = toOneColumnFacets(property)
         return new ToOneFacets(
                 property.hibernateAssociatedEntity.name,
@@ -972,7 +1000,9 @@ class GrailsDomainGenerator {
                 column.nullable(),
                 mapped.ignoreNotFound,
                 cascadeFacets(property),
-                column)
+                column,
+                null,
+                null)
     }
 
     private ColumnFacets toOneColumnFacets(HibernateToOneProperty property) {
@@ -1043,6 +1073,10 @@ class GrailsDomainGenerator {
                     "Property [${property.name}] is a collection: it has a key, an element and perhaps an index column, see collectionFacets")
         }
         if (property instanceof HibernateToOneProperty) {
+            if (boundAsOneToOne((HibernateToOneProperty) property)) {
+                throw new IllegalArgumentException(
+                        "Property [${property.name}] is the inverse side of a one-to-one: it has no column, see toOneFacets")
+            }
             return toOneColumnFacets((HibernateToOneProperty) property)
         }
         if (property instanceof HibernateEnumProperty) {
@@ -1349,19 +1383,29 @@ class GrailsDomainGenerator {
     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())
+        if (facets.mappedBy() != null) {
+            annotations << AnnotationDescription.Builder.ofType(OneToOne)
+                    .define('mappedBy', facets.mappedBy())
+                    .define('fetch', facets.lazy() ? FetchType.LAZY : FetchType.EAGER)
+                    .define('optional', facets.optional())
+                    .define('orphanRemoval', facets.cascade().orphanRemoval())
+                    .defineEnumerationArray('cascade', CascadeType, facets.cascade().jpa() as CascadeType[])
+                    .build()
+        } else {
+            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()) {
+        if (facets.cascade().orphanRemoval() && facets.mappedBy() == null) {
             // @ManyToOne has no orphanRemoval attribute
             hibernateCascade << org.hibernate.annotations.CascadeType.DELETE_ORPHAN
         }
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
index 022220ca2a..9a41a8d2d3 100644
--- 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
@@ -27,10 +27,16 @@ import org.hibernate.FetchMode
  * foreign key column. {@link GrailsDomainGenerator} writes them into {@code @ManyToOne}, {@code @JoinColumn},
  * {@code @Fetch}, {@code @NotFound} and {@code @Cascade}.
  *
+ * <p>The same record describes the inverse side of a bidirectional one-to-one, which the binder binds as a Hibernate
+ * {@code OneToOne} with no column of its own: {@code mappedBy} names the property of the other side that holds the foreign
+ * key, {@code joinColumn} is {@code null} and {@code referencedEntity} is the entity name the binder gives the value (the
+ * entity that declares the other side, which is the target or one of its superclasses). For a foreign key association both
+ * are {@code null}.</p>
+ *
  * <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>
+ * association, so it is {@code true} exactly when the join column is nullable (and for the inverse side of a one-to-one, which has no column).</p>
  *
  * @since 9.0
  */
@@ -42,5 +48,7 @@ record ToOneFacets(
     boolean optional,
     boolean ignoreNotFound,
     CascadeFacets cascade,
-    ColumnFacets joinColumn) {
+    ColumnFacets joinColumn,
+    String mappedBy,
+    String referencedEntity) {
 }
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 1a484d0d60..06d971a795 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
@@ -48,6 +48,7 @@ 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.OneToOne
 import org.hibernate.mapping.ToOne
 import org.hibernate.mapping.Backref
 import org.hibernate.mapping.IndexBackref
@@ -218,13 +219,18 @@ class GrailsDomainGeneratorDifferentialSpec extends HibernateGormDatastoreSpec {
                             continue
                         }
                         if (property instanceof HibernateToOneProperty) {
-                            if (bound == null || !(bound.value instanceof ToOne) || bound.columns.size() != 1) {
+                            if (bound == null || !(bound.value instanceof ToOne) ||
+                                    (!(bound.value instanceof OneToOne) && bound.columns.size() != 1)) {
                                 skipped['association with no single bound column']++
                                 continue
                             }
                             compared++
                             String where = "${entity.name}.${property.name}".toString()
-                            associations[toOneKind(property)]++
+                            associations[toOneKind(property, bound)]++
+                            if (bound.value instanceof OneToOne) {
+                                mismatches.addAll(compareOneToOne(where, generator.toOneFacets((HibernateToOneProperty) property), bound, known))
+                                continue
+                            }
                             // Hibernate copies the length, precision and scale of the referenced identifier onto a foreign key column after binding
                             mismatches.addAll(compare(where, generator.columnFacets(property), bound, ['length', 'precision', 'scale']))
                             mismatches.addAll(compareToOne(where, generator.toOneFacets((HibernateToOneProperty) property), bound, known))
@@ -951,6 +957,10 @@ class GrailsDomainGeneratorDifferentialSpec extends HibernateGormDatastoreSpec {
                             property, other, byName, known))
                 }
             }
+            if (property.value instanceof OneToOne) {
+                // the inverse side of a one-to-one has no column of its own
+                continue
+            }
             Map<String, Property> boundLeaves = terminalProperties(property)
             Map<String, Property> annotatedLeaves = terminalProperties(other)
             if (boundLeaves.keySet() != annotatedLeaves.keySet()) {
@@ -976,8 +986,36 @@ class GrailsDomainGeneratorDifferentialSpec extends HibernateGormDatastoreSpec {
         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 toOneKind(HibernatePersistentProperty property, Property bound) {
+        if (bound.value instanceof OneToOne) {
+            return 'one-to-one (inverse side)'
+        }
+        return property instanceof HibernateManyToOneProperty ? 'many-to-one' : 'one-to-one (foreign key)'
+    }
+
+    /**
+     * The inverse side of a one-to-one as {@code OneToOneBinder} bound it: no column, the property of the other side that
+     * holds the foreign key, the entity that declares it, the foreign key direction and the fetching.
+     */
+    private static List<String> compareOneToOne(String where, ToOneFacets facets, Property bound, Map<String, Integer> known) {
+        OneToOne value = (OneToOne) bound.value
+        Map<String, List> pairs = [
+                referencedEntity    : [facets.referencedEntity(), value.referencedEntityName],
+                referencedProperty  : [facets.mappedBy(), value.referencedPropertyName],
+                constrained         : [false, value.constrained],
+                foreignKeyDirection : ['TO_PARENT', value.foreignKeyType?.name()],
+                lazy                : [facets.lazy(), value.lazy],
+                fetchMode           : [facets.fetchMode() == FetchMode.JOIN ? 'JOIN' : 'SELECT', fetchModeOf(value)],
+                cascade             : [cascadeActions(facets.cascade()), cascadeActions(bound.cascade)],
+                hasNoColumns        : [true, value.selectables.every { !(it instanceof Column) } || value.columns.isEmpty()],
+        ]
+        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) {
+            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']++
+        }
+        return found
     }
 
     private static String fetchModeOf(ToOne value) {
@@ -1054,8 +1092,13 @@ class GrailsDomainGeneratorDifferentialSpec extends HibernateGormDatastoreSpec {
         }
         ToOne bound = (ToOne) boundProperty.value
         ToOne annotated = (ToOne) annotatedProperty.value
+        if ((bound instanceof OneToOne) != (annotated instanceof OneToOne)) {
+            return ["${where} kind: generator=${annotated.getClass().simpleName} binder=${bound.getClass().simpleName}".toString()]
+        }
+        // the binder names the entity that declares the other side of a one-to-one, which can be a superclass of the field's type
+        String targetName = GrailsDomainGenerator.generatedClassName(byName[facets.target()])
         Map<String, List> pairs = [
-                target        : [GrailsDomainGenerator.generatedClassName(byName[bound.referencedEntityName]), annotated.referencedEntityName],
+                target        : [targetName, annotated.referencedEntityName],
                 fetchMode     : [fetchModeOf(bound), fetchModeOf(annotated)],
                 ignoreNotFound: [bound instanceof ManyToOne && ((ManyToOne) bound).ignoreNotFound,
                                  annotated instanceof ManyToOne && ((ManyToOne) annotated).ignoreNotFound],
@@ -1063,18 +1106,30 @@ class GrailsDomainGeneratorDifferentialSpec extends HibernateGormDatastoreSpec {
                 insertable    : [boundProperty.insertable, annotatedProperty.insertable],
                 updatable     : [boundProperty.updateable, annotatedProperty.updateable],
         ]
+        if (bound instanceof OneToOne) {
+            pairs.referencedProperty = [bound.referencedPropertyName, annotated.referencedPropertyName]
+            pairs.constrained = [((OneToOne) bound).constrained, ((OneToOne) annotated).constrained]
+            pairs.foreignKeyDirection = [((OneToOne) bound).foreignKeyType, ((OneToOne) annotated).foreignKeyType]
+        }
         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.referencedEntityName != facets.target()) {
+            if (bound instanceof OneToOne && bound.referencedEntityName == facets.referencedEntity()) {
+                known['the binder names the declaring superclass of the other side as the referenced entity of a one-to-one; the generated field is typed with the target itself']++
+            } else {
+                found << "${where} referencedEntity: generator=${facets.target()} binder=${bound.referencedEntityName}".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']++
+            if (bound.lazy && !annotated.lazy && (facets.ignoreNotFound() || fetchModeOf(bound) == 'JOIN')) {
+                known['Hibernate binds a @NotFound or fetch-join association as eager (lazy false), the binder keeps the value lazy']++
             } else {
                 found << "${where} lazy: generator=${annotated.lazy} binder=${bound.lazy}".toString()
             }
         }
         if (boundProperty.optional != annotatedProperty.optional) {
-            if (annotatedProperty.optional == facets.joinColumn().nullable()) {
+            if (annotatedProperty.optional == facets.optional()) {
                 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()
@@ -1216,6 +1271,11 @@ class GrailsDomainGeneratorDifferentialSpec extends HibernateGormDatastoreSpec {
                 }
             } else if (property instanceof HibernateBasicProperty) {
                 found[property.name] = [sqlType: null, validated: !generator.validationAnnotations(property).isEmpty()]
+            } else if (property instanceof HibernateToOneProperty) {
+                found[property.name] = [
+                        sqlType  : generator.toOneFacets((HibernateToOneProperty) property).joinColumn()?.sqlType(),
+                        validated: !generator.validationAnnotations(property).isEmpty(),
+                ]
             } else {
                 found[property.name] = [
                         sqlType  : generator.isDerived(property) ? null : generator.columnFacets(property).sqlType(),
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 2c5862a1d9..ea876f24af 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
@@ -144,7 +144,7 @@ class GrailsDomainGeneratorSpec extends HibernateGormDatastoreSpec {
                 GenJoinedVehicle, GenJoinedCar, GenJoinedSportsCar, GenJoinedSedan, GenJoinedKeyed, GenJoinedKeyedChild,
                 GenFleetVehicle, GenFleetCar, GenFleetSportsCar, GenFleetSedan, GenUnionBase, GenUnionLeaf, GenUnionMiddle, GenUnionBottom,
                 GenEmbedOwner, GenEmbedOther, GenEmbedBase, GenEmbedChild, GenEmbedBadHolder, GenEmbedFormulaOwner, GenCollSingle, GenCollKinds, GenCollLazy,
-                GenFkTarget, GenFkOwned, GenFkOwner, GenFkCascades, GenFkNodeA, GenFkNodeB, GenFkHasOneOwner, GenFkHasOneDetail, GenFkManyOne, GenFkOneSide, GenFkSub, GenFkSubRoot)
+                GenFkTarget, GenFkOwned, GenFkOwner, GenFkCascades, GenFkNodeA, GenFkNodeB, GenFkHasOneOwner, GenFkHasOneDetail, GenOneFace, GenOneNose, GenFkManyOne, GenFkOneSide, GenFkSub, GenFkSubRoot)
     }
 
     List<StandardServiceRegistry> registries = []
@@ -1322,14 +1322,85 @@ class GrailsDomainGeneratorSpec extends HibernateGormDatastoreSpec {
         facets.optional()
     }
 
-    void "a one-to-one the binder binds as a Hibernate OneToOne is rejected by name until it is supported"() {
+    void "the inverse side of a hasOne is a @OneToOne(mappedBy) with no column, and the other side keeps the foreign key"() {
         given:
-        GrailsHibernatePersistentEntity entity = entity(GenFkHasOneOwner)
-        HibernatePersistentProperty property = entity.getHibernatePropertyByName('detail')
+        Map<GrailsHibernatePersistentEntity, Class<?>> classes = generateGroup(GenFkHasOneOwner, GenFkHasOneDetail)
+        Field detail = classes[entity(GenFkHasOneOwner)].getDeclaredField('detail')
+        Field owner = classes[entity(GenFkHasOneDetail)].getDeclaredField('owner')
 
         expect:
-        !newGenerator().supports(property)
-        newGenerator().unsupportedReason(entity, property).contains('Hibernate OneToOne')
+        detail.type == classes[entity(GenFkHasOneDetail)]
+        detail.getAnnotation(jakarta.persistence.OneToOne).mappedBy() == 'owner'
+        detail.getAnnotation(jakarta.persistence.OneToOne).optional()
+        detail.getAnnotation(jakarta.persistence.OneToOne).cascade().toList() == [jakarta.persistence.CascadeType.ALL]
+        !detail.getAnnotation(jakarta.persistence.OneToOne).orphanRemoval()
+        detail.getAnnotation(Fetch).value() == FetchMode.SELECT
+        !detail.isAnnotationPresent(JoinColumn)
+        !detail.isAnnotationPresent(ManyToOne)
+        owner.type == classes[entity(GenFkHasOneOwner)]
+        owner.isAnnotationPresent(ManyToOne)
+        owner.getAnnotation(JoinColumn).name() == 'owner_id'
+        !owner.getAnnotation(JoinColumn).nullable()
+        !owner.getAnnotation(ManyToOne).optional()
+    }
+
+    void "a one-to-one owned through belongsTo is a foreign key on the owner and a mappedBy @OneToOne on the owned side"() {
+        given:
+        Map<GrailsHibernatePersistentEntity, Class<?>> classes = generateGroup(GenOneFace, GenOneNose)
+        Field nose = classes[entity(GenOneFace)].getDeclaredField('nose')
+        Field face = classes[entity(GenOneNose)].getDeclaredField('face')
+
+        expect:
+        nose.isAnnotationPresent(ManyToOne)
+        nose.getAnnotation(JoinColumn).name() == 'nose_id'
+        nose.getAnnotation(ManyToOne).cascade().toList() == [jakarta.persistence.CascadeType.ALL]
+        face.getAnnotation(jakarta.persistence.OneToOne).mappedBy() == 'nose'
+        face.getAnnotation(jakarta.persistence.OneToOne).cascade().toList() ==
+                [jakarta.persistence.CascadeType.PERSIST, jakarta.persistence.CascadeType.MERGE]
+        !face.isAnnotationPresent(JoinColumn)
+    }
+
+    void "an inverse one-to-one has no column, so it has no column facets"() {
+        when:
+        newGenerator().columnFacets(entity(GenFkHasOneOwner).getHibernatePropertyByName('detail'))
+
+        then:
+        IllegalArgumentException e = thrown()
+        e.message.contains('toOneFacets')
+    }
+
+    void "the facets of an inverse one-to-one name the property that holds the key and the entity the binder references"() {
+        given:
+        ToOneFacets facets = newGenerator().toOneFacets(
+                (HibernateToOneProperty) entity(GenFkHasOneOwner).getHibernatePropertyByName('detail'))
+
+        expect:
+        facets.mappedBy() == 'owner'
+        facets.target() == GenFkHasOneDetail.name
+        facets.referencedEntity() == GenFkHasOneDetail.name
+        facets.joinColumn() == null
+        facets.optional()
+        !facets.ignoreNotFound()
+    }
+
+    void "Hibernate's own annotation binder reads an inverse one-to-one as a OneToOne that the other side's foreign key owns"() {
+        given:
+        Map<GrailsHibernatePersistentEntity, Class<?>> classes = generateGroup(GenFkHasOneOwner, GenFkHasOneDetail)
+        Metadata metadata = annotationMetadata(classes.values())
+        PersistentClass ownerClass = metadata.getEntityBinding(classes[entity(GenFkHasOneOwner)].name)
+        PersistentClass detailClass = metadata.getEntityBinding(classes[entity(GenFkHasOneDetail)].name)
+        Property inverse = ownerClass.getProperty('detail')
+        org.hibernate.mapping.OneToOne value = (org.hibernate.mapping.OneToOne) inverse.value
+        org.hibernate.mapping.ManyToOne key = (org.hibernate.mapping.ManyToOne) detailClass.getProperty('owner').value
+
+        expect:
+        value.referencedEntityName == classes[entity(GenFkHasOneDetail)].name
+        value.referencedPropertyName == 'owner'
+        !value.constrained
+        value.foreignKeyType == org.hibernate.type.ForeignKeyDirection.TO_PARENT
+        inverse.cascade == 'all'
+        key.selectables*.text == ['owner_id']
+        !key.columns[0].nullable
     }
 
     void "an association to an entity with a composite identifier is rejected by name"() {
@@ -2321,6 +2392,18 @@ class GenFkHasOneDetail {
     GenFkHasOneOwner owner
 }
 
+@Entity
+class GenOneFace {
+
+    GenOneNose nose
+}
+
+@Entity
+class GenOneNose {
+
+    static belongsTo = [face: GenOneFace]
+}
+
 @Entity
 class GenFkManyOne {