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:42 UTC
(grails-core) 25/29: feat: describe composite identifiers and foreign keys to them 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 c0c2baa8d29397302d6665773d1158100c3597aa
Author: Walter Duque de Estrada <wb...@mac.com>
AuthorDate: Mon Oct 5 05:36:21 2026 -0500
feat: describe composite identifiers and foreign keys to them in GrailsDomainGenerator
An entity with id composite: [...] was rejected outright (17 of the scanned domain
classes), and so was every association that pointed at one.
The binder builds one embedded identifier component from the parts, whose columns
are the primary key. Both annotation shapes reproduce its columns, names and
nullability; @EmbeddedId was chosen because its Hibernate model has the same
properties as the binder's (an @IdClass adds an _identifierMapper property and a
second copy of every part). The generated class gets an @EmbeddedId of a generated
@Embeddable that holds the parts: a simple part is an ordinary @Column that is never
null, a many-to-one part a @ManyToOne with its @JoinColumn. Indexes and unique
groups on the parts reach the table like any other column, and a unique key that
repeats the primary key is dropped the way Hibernate does it (the scratch table gets
a primary key at the point the binder creates it).
A foreign key to an entity with a composite identifier has one column for each
identifier property. The generator names them as CompositeIdentifierToManyToOneBinder
does (the table of the target and the default name of the identifier property, or the
columns the mapping states) without changing the mapping, and states them as
@JoinColumns that name the key column each one points at.
Still rejected by name: a part that refers to an entity with a composite identifier,
a composite identifier with subclasses, a foreign key to a composite identifier whose
columns the mapping states only in part, and a foreign key to one inside an embedded
type. The differential compares the identifier component, its primary key and the
foreign keys with the binder and reads them back through Hibernate's annotation
binder; Hibernate orders the columns of a foreign key to a composite key like the
referenced key, the binder like the mapping, which is listed as a known divergence.
Co-Authored-By: Claude Sonnet 5.5 <no...@anthropic.com>
---
.../cfg/domainbinding/jpa/CompositeIdFacets.groovy | 39 +++
.../domainbinding/jpa/GrailsDomainGenerator.groovy | 313 ++++++++++++++++++---
.../cfg/domainbinding/jpa/ToOneFacets.groovy | 10 +-
.../GrailsDomainGeneratorCompositeIdSpec.groovy | 260 +++++++++++++++++
.../GrailsDomainGeneratorDifferentialSpec.groovy | 143 +++++++++-
.../jpa/GrailsDomainGeneratorSpec.groovy | 18 +-
6 files changed, 712 insertions(+), 71 deletions(-)
diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/CompositeIdFacets.groovy b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/CompositeIdFacets.groovy
new file mode 100644
index 0000000000..c9e1183b91
--- /dev/null
+++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/CompositeIdFacets.groovy
@@ -0,0 +1,39 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.grails.orm.hibernate.cfg.domainbinding.jpa
+
+import groovy.transform.CompileStatic
+
+/**
+ * The composite identifier the domain binder binds for a root entity ({@code id composite: [...]}): the parts in the order the
+ * mapping names them, each with its column facets (a simple property) or its foreign key column facets and association facets
+ * (a many-to-one part). The binder builds one embedded identifier component from them, whose columns form the primary key and so
+ * are never null. {@link GrailsDomainGenerator} writes them into a generated {@code @Embeddable} that the entity uses as its
+ * {@code @EmbeddedId}.
+ *
+ * <p>{@code fieldName} is the name of the {@code @EmbeddedId} field of the generated class: the binder's identifier has no
+ * property name, so any name that no property of the entity uses will do.</p>
+ *
+ * @since 9.0
+ */
+@CompileStatic
+record CompositeIdFacets(
+ String fieldName,
+ List<EmbeddedLeaf> parts) {
+}
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 edaaceca72..6e6585bb72 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
@@ -38,6 +38,7 @@ import jakarta.persistence.DiscriminatorValue
import jakarta.persistence.ElementCollection
import jakarta.persistence.Embeddable
import jakarta.persistence.Embedded
+import jakarta.persistence.EmbeddedId
import jakarta.persistence.Entity
import jakarta.persistence.EnumType
import jakarta.persistence.Enumerated
@@ -47,6 +48,7 @@ import jakarta.persistence.Index
import jakarta.persistence.Inheritance
import jakarta.persistence.InheritanceType
import jakarta.persistence.JoinColumn
+import jakarta.persistence.JoinColumns
import jakarta.persistence.JoinTable as JpaJoinTable
import jakarta.persistence.ManyToMany
import jakarta.persistence.ManyToOne
@@ -98,6 +100,7 @@ import org.hibernate.annotations.Type
import org.hibernate.annotations.UuidGenerator
import org.hibernate.id.uuid.UuidGenerator as HibernateUuidGenerator
import org.hibernate.mapping.Column
+import org.hibernate.mapping.PrimaryKey
import org.hibernate.generator.Assigned
import org.hibernate.generator.Generator
import org.hibernate.type.BasicType
@@ -316,6 +319,9 @@ class GrailsDomainGenerator {
static List<GrailsHibernatePersistentEntity> referencedEntities(GrailsHibernatePersistentEntity entity) {
List<GrailsHibernatePersistentEntity> found = []
collectReferencedEntities(entity.persistentPropertiesToBind, found, [])
+ if (entity.isRoot() && compositeIdentifier(entity)) {
+ collectReferencedEntities(entity.compositeIdentity.toList(), found, [])
+ }
return found
}
@@ -350,8 +356,14 @@ class GrailsDomainGenerator {
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'
+ String compositeProblem = compositeIdProblem(entity)
+ if (compositeProblem != null) {
+ return compositeProblem
+ }
+ }
+ if (!entity.isRoot() && compositeIdentifier(entity.hibernateRootEntity)) {
+ return "Entity [${entity.name}] is a subclass of [${entity.hibernateRootEntity.name}], which has a composite identifier: " +
+ 'the key of a subclass table would copy the columns of the composite key, which the generator does not support yet'
}
String naturalIdProblem = naturalIdProblem(entity)
if (naturalIdProblem != null) {
@@ -406,10 +418,15 @@ class GrailsDomainGenerator {
private DynamicType.Builder<Object> defineIdentityAndVersion(
DynamicType.Builder<Object> builder, GrailsHibernatePersistentEntity entity,
Map<String, DynamicType.Unloaded<?>> embeddables) {
- HibernatePersistentProperty identity = (HibernatePersistentProperty) entity.identity
- List<AnnotationDescription> idAnnotations = [AnnotationDescription.Builder.ofType(Id).build()]
- idAnnotations.addAll(idGeneratorAnnotations(idFacets(entity)))
- DynamicType.Builder<Object> result = defineField(builder, identity, idAnnotations, embeddables)
+ DynamicType.Builder<Object> result
+ if (entity.identity instanceof HibernateSimpleIdentityProperty) {
+ HibernatePersistentProperty identity = (HibernatePersistentProperty) entity.identity
+ List<AnnotationDescription> idAnnotations = [AnnotationDescription.Builder.ofType(Id).build()]
+ idAnnotations.addAll(idGeneratorAnnotations(idFacets(entity)))
+ result = defineField(builder, identity, idAnnotations, embeddables)
+ } else {
+ result = defineCompositeIdentity(builder, entity, embeddables)
+ }
HibernatePersistentProperty version = entity.version
if (version != null) {
result = defineField(result, version, [AnnotationDescription.Builder.ofType(Version).build()], embeddables)
@@ -417,6 +434,93 @@ class GrailsDomainGenerator {
return result
}
+ /**
+ * Describes the composite identifier of a root entity as {@code CompositeIdBinder} binds it: one identifier component with a
+ * property for every part, in the order the mapping names them. The columns of a part are the ones the part would have as an
+ * ordinary property, except that they are never null: the binder's component is the primary key, and Hibernate makes every
+ * column of a primary key not null.
+ */
+ CompositeIdFacets compositeIdFacets(GrailsHibernatePersistentEntity entity) {
+ List<EmbeddedLeaf> parts = entity.compositeIdentity.collect { HibernatePersistentProperty part ->
+ part instanceof HibernateToOneProperty ?
+ new EmbeddedLeaf(part.name, part, toOneColumnFacets((HibernateToOneProperty) part), toOneFacets((HibernateToOneProperty) part)) :
+ new EmbeddedLeaf(part.name, part, columnFacets(part), null)
+ }
+ String fieldName = 'id'
+ while (entity.persistentPropertiesToBind.any { HibernatePersistentProperty p -> p.name == fieldName }) {
+ fieldName += '_'
+ }
+ return new CompositeIdFacets(fieldName, parts)
+ }
+
+ /**
+ * @return whether the property is one of the parts of the composite identifier of its entity, which the binder binds into the
+ * identifier component instead of binding it as a property of the entity
+ */
+ private static boolean isCompositeIdPart(HibernatePersistentProperty property) {
+ GrailsHibernatePersistentEntity owner = property.hibernateOwner
+ return owner != null && owner.isRoot() && compositeIdentifier(owner) &&
+ owner.compositeIdentity.any { HibernatePersistentProperty part -> part.name == property.name }
+ }
+
+ /**
+ * @return why the generator cannot describe the composite identifier of the root entity, or {@code null} when it can: every
+ * part must be a simple property or an enum that the generator supports, or a many-to-one to an entity with a simple
+ * identifier (a part that refers to an entity with a composite identifier makes the foreign key columns depend on the
+ * order {@code Component.sortProperties} gave the referenced key), and the entity must have no subclasses
+ */
+ private String compositeIdProblem(GrailsHibernatePersistentEntity entity) {
+ if (!compositeIdentifier(entity)) {
+ return "Entity [${entity.name}] has no identifier the generator knows"
+ }
+ if (!entity.childEntities.isEmpty()) {
+ return "Entity [${entity.name}] has a composite identifier and subclasses: the key of a subclass table would copy the " +
+ 'columns of the composite key, which the generator does not support yet'
+ }
+ for (HibernatePersistentProperty part : entity.compositeIdentity) {
+ if (part instanceof HibernateToOneProperty) {
+ HibernateToOneProperty toOne = (HibernateToOneProperty) part
+ if (!boundAsManyToOne(toOne)) {
+ return "Composite identifier part [${part.name}] of [${entity.name}] is a one-to-one the binder binds as a Hibernate " +
+ 'OneToOne, which the generator does not support yet'
+ }
+ if (toOne.hibernateAssociatedEntity != null && compositeIdentifier(toOne.hibernateAssociatedEntity.hibernateRootEntity)) {
+ return "Composite identifier part [${part.name}] of [${entity.name}] refers to [${toOne.hibernateAssociatedEntity.name}], " +
+ 'which has a composite identifier too: the foreign key columns follow the order Hibernate gives the sorted ' +
+ 'properties of the referenced key, which the generator does not reproduce yet'
+ }
+ String problem = toOneProblem(toOne)
+ if (problem != null) {
+ return "Composite identifier part [${part.name}] of [${entity.name}]: ${problem}"
+ }
+ } else if (!(part instanceof HibernateSimpleProperty) || isDerived(part)) {
+ return "Composite identifier part [${part.name}] of [${entity.name}] is a ${part.getClass().simpleName}, which the " +
+ 'generator does not support yet'
+ } else if (!supports(part)) {
+ return "Composite identifier part [${part.name}] of [${entity.name}]: ${unsupportedReason(entity, part)}"
+ }
+ }
+ return null
+ }
+
+ private DynamicType.Builder<Object> defineCompositeIdentity(
+ DynamicType.Builder<Object> builder, GrailsHibernatePersistentEntity entity,
+ Map<String, DynamicType.Unloaded<?>> embeddables) {
+ CompositeIdFacets id = compositeIdFacets(entity)
+ DynamicType.Builder<Object> key = (DynamicType.Builder<Object>) new ByteBuddy()
+ .subclass(Object)
+ .implement(Serializable)
+ .name(generatedClassName(entity) + '_Id')
+ .annotateType(AnnotationDescription.Builder.ofType(Embeddable).build())
+ for (EmbeddedLeaf part : id.parts()) {
+ key = defineField(key, part.property(), [], embeddables)
+ }
+ DynamicType.Unloaded<?> unloaded = key.make()
+ embeddables.put('id|' + entity.name, unloaded)
+ return builder.defineField(id.fieldName(), unloaded.typeDescription, Visibility.PRIVATE)
+ .annotateField(AnnotationDescription.Builder.ofType(EmbeddedId).build())
+ }
+
/**
* @return why {@link #generate} rejects the property, as the {@link UnsupportedOperationException} message names it
*/
@@ -582,18 +686,19 @@ class GrailsDomainGenerator {
collectSharingSubclasses(entity, contributors)
Set<String> unbound = new HashSet<String>()
for (GrailsHibernatePersistentEntity contributor : contributors) {
- List<ConstraintSite> sites = []
- collectConstraintSites(contributor, sites)
- for (ConstraintSite site : sites) {
- Column column = new Column(site.columnName)
- indexBinder.bindIndex(site.columnName, column, site.columnConfig, table)
- Set<String> before = new HashSet<String>(table.uniqueKeys.keySet())
- keyForProps.createKeyForProps(site.property, site.path, table, site.columnName)
- if (site.enumeration) {
- // EnumTypeBinder never calls CreateKeyForProps: the key is the mapping's, not the binder's
- unbound.addAll(table.uniqueKeys.keySet() - before)
- }
+ List<ConstraintSite> identity = []
+ List<ConstraintSite> version = []
+ List<ConstraintSite> properties = []
+ collectConstraintSites(contributor, identity, version, properties)
+ applyConstraintSites(table, identity + version, unbound)
+ if (contributor.isRoot()) {
+ // RootPersistentClassCommonValuesBinder creates the primary key after the identifier and the version, and
+ // Hibernate then drops a unique key that repeats exactly the columns of the primary key
+ PrimaryKey primaryKey = new PrimaryKey(table)
+ identity.each { ConstraintSite site -> primaryKey.addColumn(new Column(site.columnName)) }
+ table.primaryKey = primaryKey
}
+ applyConstraintSites(table, properties, unbound)
}
return new ConstraintFacets(
table.indexes.values().collect { org.hibernate.mapping.Index index ->
@@ -604,6 +709,19 @@ class GrailsDomainGenerator {
})
}
+ private void applyConstraintSites(org.hibernate.mapping.Table table, List<ConstraintSite> sites, Set<String> unbound) {
+ for (ConstraintSite site : sites) {
+ Column column = new Column(site.columnName)
+ indexBinder.bindIndex(site.columnName, column, site.columnConfig, table)
+ Set<String> before = new HashSet<String>(table.uniqueKeys.keySet())
+ keyForProps.createKeyForProps(site.property, site.path, table, site.columnName)
+ if (site.enumeration) {
+ // EnumTypeBinder never calls CreateKeyForProps: the key is the mapping's, not the binder's
+ unbound.addAll(table.uniqueKeys.keySet() - before)
+ }
+ }
+ }
+
private static void collectSharingSubclasses(GrailsHibernatePersistentEntity entity, List<GrailsHibernatePersistentEntity> into) {
for (GrailsHibernatePersistentEntity child : entity.childEntities) {
if (child.isTablePerHierarchySubclass()) {
@@ -613,19 +731,24 @@ class GrailsDomainGenerator {
}
}
- private void collectConstraintSites(GrailsHibernatePersistentEntity contributor, List<ConstraintSite> sites) {
- List<HibernatePersistentProperty> properties = []
+ private void collectConstraintSites(
+ GrailsHibernatePersistentEntity contributor, List<ConstraintSite> identity, List<ConstraintSite> version,
+ List<ConstraintSite> properties) {
if (contributor.isRoot()) {
if (contributor.identity instanceof HibernateSimpleIdentityProperty) {
- properties << (HibernatePersistentProperty) contributor.identity
+ collectConstraintSites((HibernatePersistentProperty) contributor.identity, '', identity)
+ } else if (compositeIdentifier(contributor)) {
+ // CompositeIdBinder binds the parts of the identifier one after the other, like any property
+ for (HibernatePersistentProperty part : contributor.compositeIdentity) {
+ collectConstraintSites(part, '', identity)
+ }
}
if (contributor.version != null) {
- properties << contributor.version
+ collectConstraintSites(contributor.version, '', version)
}
}
- properties.addAll(contributor.persistentPropertiesToBind)
- for (HibernatePersistentProperty property : properties) {
- collectConstraintSites(property, '', sites)
+ for (HibernatePersistentProperty property : contributor.persistentPropertiesToBind) {
+ collectConstraintSites(property, '', properties)
}
}
@@ -1383,30 +1506,88 @@ class GrailsDomainGenerator {
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 (inverseOneToOne) {
return writeRestrictionProblem(mapped)
}
- if (mapped.columns != null && mapped.columns.size() > 1) {
+ boolean compositeTarget = compositeIdentifier(target)
+ if (compositeTarget) {
+ String problem = compositeForeignKeyProblem(property, target)
+ if (problem != null) {
+ return problem
+ }
+ } else 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'
+ for (ColumnFacets key : toOneColumnsFacets(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 foreign key to an entity with a composite identifier has one column for each identifier property
+ * ({@code CompositeIdentifierToManyToOneBinder}). The generator reproduces that when the identifier properties are simple
+ * columns or foreign keys to entities with a simple identifier, and the mapping states either no columns or exactly one for
+ * each identifier property, all named.
+ *
+ * @return why the generator cannot describe the foreign key, or {@code null} when it can
+ */
+ private String compositeForeignKeyProblem(HibernateAssociation property, GrailsHibernatePersistentEntity target) {
+ GrailsHibernatePersistentEntity root = target.hibernateRootEntity
+ if (!root.hibernateCompositeIdentity.isPresent()) {
+ return "the associated entity [${target.name}] has a composite identifier that the mapping does not state, which the " +
+ 'binder binds as a simple one'
+ }
+ if (compositeIdProblem(root) != null) {
+ return "the associated entity [${target.name}] has a composite identifier the generator cannot describe: ${compositeIdProblem(root)}"
+ }
+ List<ColumnConfig> columns = property.hibernateMappedForm.columns
+ int expected = root.hibernateCompositeIdentity.get().propertyNames.length
+ if (!columns.isEmpty() && (columns.size() != expected || columns.any { ColumnConfig cc -> cc.name == null })) {
+ return "the mapping states ${columns.size()} columns (or leaves one unnamed) for a foreign key to a composite identifier " +
+ "with ${expected} properties: the binder fills the missing ones and the generator does not reproduce that yet"
}
return null
}
+ /**
+ * The column configs the binder binds the foreign key to an entity with a composite identifier with. A mapping that states
+ * them names them; otherwise {@code CompositeIdentifierToManyToOneBinder} (which adds them to the mapping, as it binds)
+ * names one for each identifier property after the table of the associated entity and the default column name of the
+ * identifier property.
+ */
+ private List<ColumnConfig> compositeForeignKeyConfigs(HibernateAssociation property, GrailsHibernatePersistentEntity target) {
+ List<ColumnConfig> columns = property.hibernateMappedForm.columns
+ if (!columns.isEmpty()) {
+ return columns
+ }
+ GrailsHibernatePersistentEntity root = target.hibernateRootEntity
+ String prefix = root.getTableName(namingStrategy)
+ return root.hibernateCompositeIdentity.get().propertyNames.collect { String propertyName ->
+ HibernatePersistentProperty referenced = root.getHibernatePropertyByName(propertyName)
+ String suffix = referenced != null ? defaultColumnNames.getDefaultColumnName(referenced) : propertyName
+ new ColumnConfig(name: [prefix, suffix].collect { String part -> part.replace('`', '') }.join('_'))
+ }
+ }
+
+ /** The column of the key of the entity that each foreign key column points at, in the order of the identifier properties. */
+ private List<String> compositeReferencedColumns(GrailsHibernatePersistentEntity target) {
+ GrailsHibernatePersistentEntity root = target.hibernateRootEntity
+ return root.hibernateCompositeIdentity.get().propertyNames.collect { String propertyName ->
+ HibernatePersistentProperty part = root.getHibernatePropertyByName(propertyName)
+ part instanceof HibernateToOneProperty ?
+ toOneColumnFacets((HibernateToOneProperty) part).name() : columnFacets(part).name()
+ }
+ }
+
/**
* 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
@@ -1456,24 +1637,44 @@ class GrailsDomainGenerator {
cascadeFacets(property),
null,
oneToOne.hibernateReferencedPropertyName,
- oneToOne.hibernateReferencedEntityName)
+ oneToOne.hibernateReferencedEntityName,
+ [],
+ [])
}
- ColumnFacets column = toOneColumnFacets(property)
+ List<ColumnFacets> columns = toOneColumnsFacets(property)
+ ColumnFacets column = columns[0]
return new ToOneFacets(
property.hibernateAssociatedEntity.name,
property.isLazy(),
FetchMode.JOIN == mapped.fetchMode ? FetchMode.JOIN : FetchMode.SELECT,
- column.nullable(),
+ columns.every { ColumnFacets c -> c.nullable() },
mapped.ignoreNotFound,
cascadeFacets(property),
column,
null,
- null)
+ null,
+ columns,
+ compositeIdentifier(property.hibernateAssociatedEntity) ? compositeReferencedColumns(property.hibernateAssociatedEntity) : [])
}
private ColumnFacets toOneColumnFacets(HibernateAssociation property, String path = '') {
+ return toOneColumnsFacets(property, path)[0]
+ }
+
+ /** The foreign key columns of the association, one for an ordinary association, one for each identifier property of a composite identifier. */
+ private List<ColumnFacets> toOneColumnsFacets(HibernateAssociation property, String path = '') {
+ GrailsHibernatePersistentEntity target = property.hibernateAssociatedEntity
+ if (target != null && compositeIdentifier(target) && property.hibernateMappedForm.derived == false &&
+ target.hibernateRootEntity.hibernateCompositeIdentity.isPresent() && compositeForeignKeyProblem(property, target) == null) {
+ return compositeForeignKeyConfigs(property, target).collect { ColumnConfig columnConfig ->
+ toOneColumnFacets(property, path, columnConfig)
+ }
+ }
+ return [toOneColumnFacets(property, path, firstColumnConfig(property.hibernateMappedForm))]
+ }
+
+ private ColumnFacets toOneColumnFacets(HibernateAssociation property, String path, ColumnConfig columnConfig) {
PropertyConfig mapped = property.hibernateMappedForm
- ColumnConfig columnConfig = firstColumnConfig(mapped)
Column column = new Column()
columnConfigBinder.bindColumnConfigToColumn(column, columnConfig, mapped)
if (columnConfig != null) {
@@ -1494,7 +1695,8 @@ class GrailsDomainGenerator {
HibernateOneToOneProperty inverse = ((HibernateOneToOneProperty) property).hibernateInverseSide
unique = property.isBidirectional() && inverse != null && inverse.isValidHibernateOneToOne()
}
- return facets(property, column, name, nullable, unique)
+ // a part of the composite identifier is a primary key column, and Hibernate makes those not null
+ return facets(property, column, name, nullable && !isCompositeIdPart((HibernatePersistentProperty) property), unique)
}
/**
@@ -1504,6 +1706,10 @@ class GrailsDomainGenerator {
* {@code orphanRemoval}.
*/
CascadeFacets cascadeFacets(HibernateAssociation property) {
+ if (isCompositeIdPart((HibernatePersistentProperty) property)) {
+ // a part of the identifier is a key: nothing cascades through it
+ return new CascadeFacets([], [], false)
+ }
CascadeBehavior behavior = CascadeBehavior.fromString(cascadeFetcher.getCascadeBehaviour((Association<?>) property))
switch (behavior) {
case CascadeBehavior.ALL:
@@ -1662,6 +1868,11 @@ class GrailsDomainGenerator {
if (problem != null) {
return "the association [${peer.name}] of [${type.name}]: ${problem}"
}
+ if (((HibernateToOneProperty) peer).hibernateAssociatedEntity != null &&
+ compositeIdentifier(((HibernateToOneProperty) peer).hibernateAssociatedEntity)) {
+ return "the association [${peer.name}] of [${type.name}] refers to an entity with a composite identifier, whose foreign " +
+ 'key has one column for each identifier property, which the generator does not state inside an embedded type 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)) {
@@ -1705,7 +1916,7 @@ class GrailsDomainGenerator {
} else if (type != null && Number.isAssignableFrom(type)) {
numericConstraints.bindNumericColumnConstraints(column, columnConfig, mappedForm, type)
}
- return facets(property, column, name, isNullable(property, parent),
+ return facets(property, column, name, isNullable(property, parent) && !isCompositeIdPart(property),
mappedForm.isUnique() && !mappedForm.isUniqueWithinGroup())
}
@@ -1941,7 +2152,18 @@ class GrailsDomainGenerator {
.define('optional', facets.optional())
.defineEnumerationArray('cascade', CascadeType, facets.cascade().jpa() as CascadeType[])
.build()
- annotations << joinColumnAnnotation(facets.joinColumn())
+ if (facets.joinColumns().size() > 1) {
+ // a foreign key to a composite identifier: each column states the column of the key it points at
+ List<AnnotationDescription> columns = []
+ for (int i = 0; i < facets.joinColumns().size(); i++) {
+ columns << joinColumnAnnotation(facets.joinColumns()[i], facets.referencedColumns()[i])
+ }
+ annotations << AnnotationDescription.Builder.ofType(JoinColumns)
+ .defineAnnotationArray('value', TypeDescription.ForLoadedType.of(JoinColumn), columns as AnnotationDescription[])
+ .build()
+ } else {
+ annotations << joinColumnAnnotation(facets.joinColumn())
+ }
}
Boolean naturalMutable = naturalIdMutable(property)
if (naturalMutable != null) {
@@ -2161,7 +2383,7 @@ class GrailsDomainGenerator {
.annotateField(annotations as AnnotationDescription[])
}
- private static AnnotationDescription joinColumnAnnotation(ColumnFacets facets) {
+ private static AnnotationDescription joinColumnAnnotation(ColumnFacets facets, String referencedColumn = null) {
AnnotationDescription.Builder annotation = AnnotationDescription.Builder.ofType(JoinColumn)
.define('name', facets.name())
.define('nullable', facets.nullable())
@@ -2171,6 +2393,9 @@ class GrailsDomainGenerator {
if (facets.sqlType()) {
annotation = annotation.define('columnDefinition', facets.sqlType())
}
+ if (referencedColumn != null) {
+ annotation = annotation.define('referencedColumnName', referencedColumn)
+ }
return annotation.build()
}
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 9a41a8d2d3..449d620ae4 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
@@ -38,6 +38,12 @@ import org.hibernate.FetchMode
* {@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 (and for the inverse side of a one-to-one, which has no column).</p>
*
+ * <p>{@code joinColumns} are the foreign key columns in the order the binder binds them: one for an ordinary association, one
+ * for each identifier property when the associated entity has a composite identifier, none for the inverse side of a
+ * one-to-one. {@code referencedColumns} names, for a composite identifier, the column of the associated entity's key that each
+ * foreign key column points at (the generated {@code @JoinColumn} states it); it is empty for an ordinary association, which
+ * points at the single key column by default. {@code joinColumn} is the first of the foreign key columns.</p>
+ *
* @since 9.0
*/
@CompileStatic
@@ -50,5 +56,7 @@ record ToOneFacets(
CascadeFacets cascade,
ColumnFacets joinColumn,
String mappedBy,
- String referencedEntity) {
+ String referencedEntity,
+ List<ColumnFacets> joinColumns,
+ List<String> referencedColumns) {
}
diff --git a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorCompositeIdSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorCompositeIdSpec.groovy
new file mode 100644
index 0000000000..d594a9d65f
--- /dev/null
+++ b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorCompositeIdSpec.groovy
@@ -0,0 +1,260 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.grails.orm.hibernate.cfg.domainbinding.jpa
+
+import grails.gorm.annotation.Entity
+import jakarta.persistence.Column
+import jakarta.persistence.Embeddable
+import jakarta.persistence.EmbeddedId
+import jakarta.persistence.JoinColumn
+import jakarta.persistence.JoinColumns
+import jakarta.persistence.ManyToOne
+import jakarta.persistence.Table
+import jakarta.persistence.Version
+import org.hibernate.boot.Metadata
+import org.hibernate.mapping.Component
+import org.hibernate.mapping.PersistentClass
+import org.hibernate.mapping.Property
+import org.hibernate.mapping.ToOne
+
+import org.grails.orm.hibernate.cfg.domainbinding.hibernate.GrailsHibernatePersistentEntity
+
+/**
+ * Describes how {@link GrailsDomainGenerator} states a composite identifier ({@code id composite: [...]}) and the foreign keys
+ * that point at one. The binder builds one identifier component from the parts, whose columns are the primary key, and a
+ * foreign key to such an entity has one column for each identifier property. The generated class gets an {@code @EmbeddedId}
+ * of a generated {@code @Embeddable} that holds the parts, and a foreign key states {@code @JoinColumns} that name the column of
+ * the key each of them points at. The differential spec compares the identifier and the foreign keys on every domain class.
+ */
+class GrailsDomainGeneratorCompositeIdSpec extends GrailsDomainGeneratorSupport {
+
+ void setupSpec() {
+ manager.registerDomainClasses(
+ GenCidSimple, GenCidTarget, GenCidParts, GenCidRef, GenCidIndexed, GenCidNested, GenCidParent, GenCidChild)
+ }
+
+ void "a composite identifier is an @EmbeddedId of a generated embeddable that holds the parts, and the entity keeps the other properties"() {
+ when:
+ Class<?> generated = generateGroup(GenCidSimple).values().first()
+ Class<?> key = generated.getDeclaredField('id').type
+
+ then:
+ generated.getDeclaredField('id').isAnnotationPresent(EmbeddedId)
+ key.isAnnotationPresent(Embeddable)
+ Serializable.isAssignableFrom(key)
+ key.name == generated.name + '_Id'
+ key.declaredFields*.name.toSet() == ['last', 'age'].toSet()
+ generated.declaredFields*.name.toSet() == ['id', 'version', 'note'].toSet()
+ generated.getDeclaredField('version').isAnnotationPresent(Version)
+ }
+
+ void "the parts are never null, whatever the mapping says, and are named as the binder names them"() {
+ when:
+ Class<?> key = generateGroup(GenCidSimple).values().first().getDeclaredField('id').type
+
+ then:
+ key.getDeclaredField('last').getAnnotation(Column).name() == 'last'
+ !key.getDeclaredField('last').getAnnotation(Column).nullable()
+ key.getDeclaredField('age').getAnnotation(Column).name() == 'age'
+ !key.getDeclaredField('age').getAnnotation(Column).nullable()
+ }
+
+ void "a many-to-one part is an association of the embeddable with its foreign key column"() {
+ when:
+ Map<GrailsHibernatePersistentEntity, Class<?>> classes = generateGroup(GenCidParts, GenCidTarget)
+ Class<?> key = classes[entity(GenCidParts)].getDeclaredField('id').type
+
+ then:
+ key.getDeclaredField('owner').isAnnotationPresent(ManyToOne)
+ !key.getDeclaredField('owner').getAnnotation(ManyToOne).optional()
+ key.getDeclaredField('owner').getAnnotation(JoinColumn).name() == 'owner_id'
+ !key.getDeclaredField('owner').getAnnotation(JoinColumn).nullable()
+ key.getDeclaredField('owner').type == classes[entity(GenCidTarget)]
+ }
+
+ void "Hibernate's annotation binder reads the composite identifier as the binder bound it"() {
+ given:
+ Map<GrailsHibernatePersistentEntity, Class<?>> classes = generateGroup(GenCidSimple, GenCidParts, GenCidTarget)
+ Metadata metadata = annotationMetadata(classes.values())
+ PersistentClass bound = entity(domain).persistentClass
+ PersistentClass read = metadata.getEntityBinding(classes[entity(domain)].name)
+
+ expect:
+ parts(read) == parts(bound)
+ read.table.primaryKey.columns*.name.toSet() == bound.table.primaryKey.columns*.name.toSet()
+ read.table.foreignKeys.values().collect { it.columns*.name.toSet() }.toSet() ==
+ bound.table.foreignKeys.values().collect { it.columns*.name.toSet() }.toSet()
+
+ where:
+ domain << [GenCidSimple, GenCidParts]
+ }
+
+ void "a foreign key to a composite identifier has a join column for each identifier property, naming the key column it points at"() {
+ when:
+ Map<GrailsHibernatePersistentEntity, Class<?>> classes = generateGroup(GenCidRef, GenCidSimple)
+ JoinColumns plain = classes[entity(GenCidRef)].getDeclaredField('plain').getAnnotation(JoinColumns)
+ JoinColumns named = classes[entity(GenCidRef)].getDeclaredField('named').getAnnotation(JoinColumns)
+
+ then: "the default names are the table of the target and the name of the identifier property, in the order of the mapping"
+ plain.value()*.name() == ['gen_cid_simple_last', 'gen_cid_simple_age']
+ plain.value()*.referencedColumnName() == ['last', 'age']
+ plain.value().every { it.nullable() }
+ named.value()*.name() == ['n_last', 'n_age']
+ named.value()*.referencedColumnName() == ['last', 'age']
+ !classes[entity(GenCidRef)].getDeclaredField('plain').isAnnotationPresent(JoinColumn)
+ }
+
+ void "Hibernate's annotation binder reads the foreign key to a composite identifier as the binder bound it"() {
+ given:
+ Map<GrailsHibernatePersistentEntity, Class<?>> classes = generateGroup(GenCidRef, GenCidSimple)
+ Metadata metadata = annotationMetadata(classes.values())
+ PersistentClass bound = entity(GenCidRef).persistentClass
+ PersistentClass read = metadata.getEntityBinding(classes[entity(GenCidRef)].name)
+
+ expect: "the same columns for each association, in whatever order Hibernate arranges them, and the same constraints"
+ ['plain', 'named'].every { String name ->
+ ((ToOne) bound.getProperty(name).value).selectables*.text.toSet() == ((ToOne) read.getProperty(name).value).selectables*.text.toSet() &&
+ bound.getProperty(name).columns*.nullable.toSet() == read.getProperty(name).columns*.nullable.toSet()
+ }
+ read.table.foreignKeys.values().collect { it.columns*.name.toSet() }.toSet() ==
+ bound.table.foreignKeys.values().collect { it.columns*.name.toSet() }.toSet()
+ }
+
+ void "an index on a part of the identifier is an index of the table, as the binder binds it"() {
+ when:
+ Class<?> generated = generateGroup(GenCidIndexed).values().first()
+
+ then:
+ generated.getAnnotation(Table).indexes()*.name() == ['gen_cid_a_idx']
+ generated.getAnnotation(Table).indexes()*.columnList() == ['a']
+ entity(GenCidIndexed).persistentClass.table.indexes.keySet() == ['gen_cid_a_idx'].toSet()
+ }
+
+ void "an identifier part that refers to an entity with a composite identifier is rejected by name"() {
+ when:
+ generateGroup(GenCidNested, GenCidSimple)
+
+ then:
+ UnsupportedOperationException e = thrown()
+ e.message.contains('GenCidNested')
+ e.message.contains('parent')
+ e.message.contains('composite identifier')
+ }
+
+ void "a composite identifier with subclasses is rejected by name"() {
+ when:
+ generateGroup(GenCidParent, GenCidChild)
+
+ then:
+ UnsupportedOperationException e = thrown()
+ e.message.contains('GenCidParent')
+ e.message.contains('subclasses')
+ }
+
+ private static Map<String, Map<String, Object>> parts(PersistentClass persistentClass) {
+ Component id = (Component) persistentClass.identifier
+ return id.properties.collectEntries { Property part ->
+ [(part.name): [columns: part.columns*.name, nullable: part.columns*.nullable, toOne: part.value instanceof ToOne]]
+ } as Map<String, Map<String, Object>>
+ }
+}
+
+@Entity
+class GenCidSimple implements Serializable {
+
+ String last
+ Long age
+ String note
+
+ static mapping = {
+ id composite: ['last', 'age']
+ }
+}
+
+@Entity
+class GenCidTarget {
+
+ String label
+}
+
+@Entity
+class GenCidParts implements Serializable {
+
+ GenCidTarget owner
+ String name
+ String extra
+
+ static mapping = {
+ id composite: ['name', 'owner']
+ }
+}
+
+@Entity
+class GenCidRef {
+
+ GenCidSimple plain
+ GenCidSimple named
+
+ static mapping = {
+ named {
+ column name: 'n_last'
+ column name: 'n_age'
+ }
+ }
+}
+
+@Entity
+class GenCidIndexed implements Serializable {
+
+ String a
+ String b
+
+ static mapping = {
+ id composite: ['a', 'b']
+ a index: 'gen_cid_a_idx'
+ }
+}
+
+@Entity
+class GenCidNested implements Serializable {
+
+ GenCidSimple parent
+ String name
+
+ static mapping = {
+ id composite: ['parent', 'name']
+ }
+}
+
+@Entity
+class GenCidParent implements Serializable {
+
+ String a
+ String b
+
+ static mapping = {
+ id composite: ['a', 'b']
+ }
+}
+
+@Entity
+class GenCidChild extends GenCidParent {
+
+ String c
+}
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 eef69feadd..9c0780227e 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
@@ -138,6 +138,8 @@ class GrailsDomainGeneratorDifferentialSpec extends HibernateGormDatastoreSpec {
Map<String, Integer> constraints = [:].withDefault { 0 }
Map<String, Integer> naturals = [:].withDefault { 0 }
Map<String, Integer> caches = [:].withDefault { 0 }
+ Map<String, Integer> composites = [:].withDefault { 0 }
+ List<String> rejectedComposites = []
when:
for (List<Class<?>> group : groups) {
@@ -177,8 +179,11 @@ class GrailsDomainGeneratorDifferentialSpec extends HibernateGormDatastoreSpec {
if (entity.identity instanceof HibernateSimpleIdentityProperty) {
mismatches.addAll(compareIdentifierGenerator(
entity, generator.idFacets(entity), (SessionFactoryImplementor) datastore.sessionFactory, strategies))
+ } else if (generator.generationProblem(entity) == null) {
+ mismatches.addAll(compareCompositeId(entity, generator.compositeIdFacets(entity), composites, known))
} else {
- skipped['entity without a simple identifier']++
+ rejectedComposites << "${entity.name}: ${generator.generationProblem(entity)}".toString()
+ skipped["entity without a simple identifier: ${generator.generationProblem(entity).replaceAll(/\[[^\]]*\]/, '[..]')}".toString()]++
}
if (entity.version != null) {
properties << entity.version
@@ -249,8 +254,8 @@ class GrailsDomainGeneratorDifferentialSpec extends HibernateGormDatastoreSpec {
}
if (property instanceof HibernateToOneProperty) {
if (bound == null || !(bound.value instanceof ToOne) ||
- (!(bound.value instanceof OneToOne) && bound.columns.size() != 1)) {
- skipped['association with no single bound column']++
+ (!(bound.value instanceof OneToOne) && bound.columns.isEmpty())) {
+ skipped['association with no bound column']++
continue
}
compared++
@@ -261,8 +266,18 @@ class GrailsDomainGeneratorDifferentialSpec extends HibernateGormDatastoreSpec {
continue
}
// Hibernate copies the length, precision and scale of the referenced identifier onto a foreign key column after binding
- mismatches.addAll(compare(where, generator.columnFacets(property), bound, known, ['length', 'precision', 'scale']))
- mismatches.addAll(compareToOne(where, generator.toOneFacets((HibernateToOneProperty) property), bound, known))
+ ToOneFacets toOneFacets = generator.toOneFacets((HibernateToOneProperty) property)
+ if (toOneFacets.joinColumns().size() != bound.columns.size()) {
+ mismatches << "${where} columns: generator=${toOneFacets.joinColumns()*.name()} binder=${bound.columns*.name}".toString()
+ continue
+ }
+ if (bound.columns.size() > 1) {
+ associations['to-one to a composite identifier']++
+ }
+ toOneFacets.joinColumns().eachWithIndex { ColumnFacets column, int index ->
+ mismatches.addAll(compare(where, column, bound, known, ['length', 'precision', 'scale'], false, index))
+ }
+ mismatches.addAll(compareToOne(where, toOneFacets, bound, known))
continue
}
if (bound != null && generator.isDerived(property)) {
@@ -303,6 +318,8 @@ class GrailsDomainGeneratorDifferentialSpec extends HibernateGormDatastoreSpec {
report << "table constraints compared: ${constraints}\n"
report << "natural ids compared: ${naturals}\n"
report << "entity caches compared: ${caches}\n"
+ report << "composite identifiers compared: ${composites}\n"
+ rejectedComposites.each { report << "composite identifier rejected: ${it}\n" }
report << "id generators compared by strategy: ${strategies}\n"
report << "entities compared by hierarchy role: ${hierarchies}\n"
report << "hierarchies read back through Hibernate's annotation binder: ${annotationRead}\n"
@@ -345,8 +362,8 @@ class GrailsDomainGeneratorDifferentialSpec extends HibernateGormDatastoreSpec {
*/
private List<String> compare(
String where, ColumnFacets facets, Property bound, Map<String, Integer> known, Collection<String> ignore = [],
- boolean enumeration = false) {
- Column column = (Column) bound.columns[0]
+ boolean enumeration = false, int columnIndex = 0) {
+ Column column = (Column) bound.columns[columnIndex]
Map<String, List> pairs = [
name : [facets.name().replace('`', ''), column.name],
quoted : [facets.name().startsWith('`'), column.quoted],
@@ -416,6 +433,99 @@ class GrailsDomainGeneratorDifferentialSpec extends HibernateGormDatastoreSpec {
return found
}
+ /**
+ * The composite identifier as {@code CompositeIdBinder} bound it: one component with a property for every part, whose columns
+ * are the primary key and so not null. A simple part must have the column facets of an ordinary property; a many-to-one part
+ * the foreign key column and the associated entity (nothing cascades through an identifier, so the cascade the binder states on
+ * the part is not compared).
+ */
+ private List<String> compareCompositeId(
+ GrailsHibernatePersistentEntity entity, CompositeIdFacets facets, Map<String, Integer> composites, Map<String, Integer> known) {
+ PersistentClass persistentClass = entity.persistentClass
+ String where = "${entity.name} composite id"
+ if (!(persistentClass.identifier instanceof Component)) {
+ return ["${where} kind: generator=component binder=${persistentClass.identifier.getClass().simpleName}".toString()]
+ }
+ Component id = (Component) persistentClass.identifier
+ composites['entities']++
+ List<String> found = []
+ if (facets.parts()*.path().toSet() != id.properties*.name.toSet()) {
+ return ["${where} parts: generator=${facets.parts()*.path()} binder=${id.properties*.name}".toString()]
+ }
+ for (EmbeddedLeaf part : facets.parts()) {
+ Property bound = id.getProperty(part.path())
+ String partWhere = "${where} part ${part.path()}".toString()
+ if (bound.columns.size() != 1) {
+ found << "${partWhere} columns: generator=1 binder=${bound.columns.size()}".toString()
+ continue
+ }
+ if (part.toOne() != null) {
+ composites['to-one parts']++
+ found.addAll(compare(partWhere, part.column(), bound, known, ['length', 'precision', 'scale']))
+ found.addAll(compareToOne(partWhere, part.toOne(), bound, known).findAll { String line ->
+ !line.contains(' cascade: ') && !line.contains(' optional: ')
+ })
+ } else {
+ composites['simple parts']++
+ found.addAll(compare(partWhere, part.column(), bound, known, [], part.property instanceof HibernateEnumProperty))
+ }
+ }
+ Set<String> expectedKey = facets.parts().collect { EmbeddedLeaf part -> part.column().name().replace('`', '') }.toSet()
+ if (persistentClass.table.primaryKey?.columns*.name?.toSet() != expectedKey) {
+ found << "${where} primaryKey: generator=${expectedKey} binder=${persistentClass.table.primaryKey?.columns*.name}".toString()
+ }
+ return found
+ }
+
+ /**
+ * The composite identifier Hibernate's annotation binder reads from an {@code @EmbeddedId}: a component with the same parts,
+ * the same columns (not null), the same primary key and the same foreign key columns. The binder's identifier has no property
+ * and its unsaved value is {@code undefined}; Hibernate's has a property of its own and no unsaved value: listed.
+ */
+ private static List<String> compareAnnotatedCompositeId(
+ String where, PersistentClass bound, PersistentClass annotated, CompositeIdFacets facets, Map<String, Integer> known) {
+ if (!(annotated.identifier instanceof Component)) {
+ return ["${where} compositeId: generator=${annotated.identifier.getClass().simpleName} binder=Component".toString()]
+ }
+ Component boundId = (Component) bound.identifier
+ Component annotatedId = (Component) annotated.identifier
+ List<String> found = []
+ if (boundId.properties*.name.toSet() != annotatedId.properties*.name.toSet()) {
+ return ["${where} compositeId parts: generator=${annotatedId.properties*.name} binder=${boundId.properties*.name}".toString()]
+ }
+ boundId.properties.each { Property boundPart ->
+ Property annotatedPart = annotatedId.getProperty(boundPart.name)
+ Map<String, List> pairs = [
+ columns : [boundPart.columns*.name, annotatedPart.columns*.name],
+ nullable: [boundPart.columns*.nullable, annotatedPart.columns*.nullable],
+ kind : [boundPart.value.getClass().simpleName, annotatedPart.value.getClass().simpleName],
+ ]
+ found.addAll(pairs.findAll { String facet, List values -> values[0] != values[1] }.collect { String facet, List values ->
+ "${where} compositeId part ${boundPart.name} ${facet}: generator=${values[1]} binder=${values[0]}".toString()
+ })
+ if (boundPart.value instanceof ToOne && annotatedPart.value instanceof ToOne) {
+ String target = ((ToOne) annotatedPart.value).referencedEntityName
+ if (!target.endsWith(((ToOne) boundPart.value).referencedEntityName.replace('.', '_'))) {
+ found << "${where} compositeId part ${boundPart.name} target: generator=${target} binder=${((ToOne) boundPart.value).referencedEntityName}".toString()
+ }
+ }
+ }
+ Set<String> boundKey = bound.table.primaryKey?.columns*.name?.toSet()
+ Set<String> annotatedKey = annotated.table.primaryKey?.columns*.name?.toSet()
+ if (boundKey != annotatedKey) {
+ found << "${where} compositeId primaryKey: generator=${annotatedKey} binder=${boundKey}".toString()
+ }
+ Set<Set<String>> boundKeys = bound.table.foreignKeys.values().collect { it.columns*.name.toSet() }.toSet()
+ Set<Set<String>> annotatedKeys = annotated.table.foreignKeys.values().collect { it.columns*.name.toSet() }.toSet()
+ if (boundKeys != annotatedKeys) {
+ found << "${where} compositeId foreignKeys: generator=${annotatedKeys} binder=${boundKeys}".toString()
+ }
+ if (boundId.nullValue != annotatedId.nullValue) {
+ known['the binder gives a composite identifier the unsaved value undefined; Hibernate gives an @EmbeddedId none']++
+ }
+ return found
+ }
+
/**
* The second-level cache of the root: the concurrency strategy, whether the class is cached and mutable, and whether lazy
* properties are cached, or none of it when the generator states no cache.
@@ -1157,6 +1267,10 @@ class GrailsDomainGeneratorDifferentialSpec extends HibernateGormDatastoreSpec {
found.addAll(compareAnnotatedDiscriminator(where, (RootClass) bound, (RootClass) annotated))
found.addAll(compareAnnotatedNaturalId(where, bound, annotated, known))
found.addAll(compareAnnotatedCache(where, (RootClass) bound, (RootClass) annotated))
+ if (entity.identity == null && generator.generationProblem(entity) == null) {
+ annotationRead['composite identifiers']++
+ found.addAll(compareAnnotatedCompositeId(where, bound, annotated, generator.compositeIdFacets(entity), known))
+ }
}
if (pairs.ownsTable[0]) {
found.addAll(compareAnnotatedConstraints(where, bound, annotated, generator.constraintFacets(entity)))
@@ -1218,7 +1332,7 @@ class GrailsDomainGeneratorDifferentialSpec extends HibernateGormDatastoreSpec {
}
if (!expected.validated) {
found.addAll(compareAnnotatedLeaf(
- "${where} property ${path}".toString(), leaf, annotatedLeaves[path], (String) expected.sqlType, false))
+ "${where} property ${path}".toString(), leaf, annotatedLeaves[path], (String) expected.sqlType, false, known))
}
}
}
@@ -1648,11 +1762,18 @@ class GrailsDomainGeneratorDifferentialSpec extends HibernateGormDatastoreSpec {
* or formulas, the same nullability, and every column facet the binder states (length, precision, scale, unique,
* explicit SQL type, default, read and write expressions, comment) must have reached the annotation-built column.
*/
- private static List<String> compareAnnotatedLeaf(String where, Property bound, Property annotated, String explicitSqlType, boolean key) {
+ private static List<String> compareAnnotatedLeaf(
+ String where, Property bound, Property annotated, String explicitSqlType, boolean key, Map<String, Integer> known = [:]) {
List<String> boundSelectables = bound.selectables.collect { it instanceof Column ? ((Column) it).name : "formula:${((Formula) it).getFormula()}" }
List<String> annotatedSelectables = annotated.selectables.collect { it instanceof Column ? ((Column) it).name : "formula:${((Formula) it).getFormula()}" }
List<Column> boundColumns = bound.selectables.findAll { it instanceof Column }.collect { (Column) it }
List<Column> annotatedColumns = annotated.selectables.findAll { it instanceof Column }.collect { (Column) it }
+ if (boundColumns.size() > 1 && boundSelectables != annotatedSelectables && boundSelectables.toSet() == annotatedSelectables.toSet()) {
+ // the columns of a foreign key to a composite identifier: Hibernate orders them like the columns of the referenced key
+ known['Hibernate orders the columns of a foreign key to a composite identifier like the referenced key, the binder like the mapping']++
+ annotatedColumns = boundColumns.collect { Column column -> annotatedColumns.find { Column other -> other.name == column.name } }
+ annotatedSelectables = boundSelectables
+ }
List<Boolean> boundNullable = boundColumns*.nullable
List<Boolean> annotatedNullable = annotatedColumns*.nullable
// a collection's key columns are NOT NULL in the annotation path whatever the binder did (see compareAnnotatedCollection), and their size comes from the referenced identifier
@@ -1701,7 +1822,9 @@ class GrailsDomainGeneratorDifferentialSpec extends HibernateGormDatastoreSpec {
Map<String, Map<String, Object>> found = [:]
List<HibernatePersistentProperty> properties = []
if (entity.isRoot()) {
- properties << (HibernatePersistentProperty) entity.identity
+ if (entity.identity != null) {
+ properties << (HibernatePersistentProperty) entity.identity
+ }
if (entity.version != null) {
properties << entity.version
}
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 1dd958cb58..bd1a539b69 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
@@ -238,16 +238,12 @@ class GrailsDomainGeneratorSpec extends HibernateGormDatastoreSpec {
}
void "a property the generator does not support is rejected by name"() {
- given:
- GrailsHibernatePersistentEntity entity = unbound(GenFkCompositeRef)
-
when:
- newGenerator().generateAll([entity], getClass().classLoader)
+ newGenerator().generateAll([unbound(GenUnsupportedType)], getClass().classLoader)
then:
UnsupportedOperationException e = thrown()
- e.message.contains('Association property [target] of [' + GenFkCompositeRef.name + ']')
- e.message.contains('composite identifier')
+ e.message.contains('Type [serializable] of property [tag] of [' + GenUnsupportedType.name + ']')
}
void "the version is marked as the optimistic lock"() {
@@ -1411,16 +1407,6 @@ class GrailsDomainGeneratorSpec extends HibernateGormDatastoreSpec {
!key.columns[0].nullable
}
- 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)