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 {