diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/binder/EnumTypeBinder.java b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/binder/EnumTypeBinder.java index 30fe4d34ed5..63f5e6b6303 100644 --- a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/binder/EnumTypeBinder.java +++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/binder/EnumTypeBinder.java @@ -21,22 +21,16 @@ import java.util.Properties; import jakarta.annotation.Nonnull; -import jakarta.persistence.EnumType; import org.hibernate.boot.spi.MetadataBuildingContext; import org.hibernate.mapping.BasicValue; import org.hibernate.mapping.Column; import org.hibernate.mapping.Table; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import org.grails.orm.hibernate.cfg.ColumnConfig; -import org.grails.orm.hibernate.cfg.IdentityEnumType; import org.grails.orm.hibernate.cfg.PersistentEntityNamingStrategy; import org.grails.orm.hibernate.cfg.PropertyConfig; -import org.grails.orm.hibernate.cfg.domainbinding.hibernate.HibernateBasicProperty; import org.grails.orm.hibernate.cfg.domainbinding.hibernate.HibernateEnumProperty; -import org.grails.orm.hibernate.cfg.domainbinding.hibernate.HibernatePersistentProperty; import org.grails.orm.hibernate.cfg.domainbinding.util.ColumnNameForPropertyAndPathFetcher; import org.grails.orm.hibernate.cfg.domainbinding.util.GrailsEnumType; @@ -44,7 +38,6 @@ public class EnumTypeBinder { - private static final Logger LOG = LoggerFactory.getLogger(EnumTypeBinder.class); private final MetadataBuildingContext metadataBuildingContext; private final ColumnNameForPropertyAndPathFetcher columnNameForPropertyAndPathFetcher; private final IndexBinder indexBinder; @@ -77,60 +70,22 @@ protected EnumTypeBinder( } public BasicValue bindEnumType(@Nonnull HibernateEnumProperty property, String path) { - String columnName = columnNameForPropertyAndPathFetcher.getColumnNameForPropertyAndPath(property, path, null); + String columnName = property.resolveEnumColumnName(namingStrategy, columnNameForPropertyAndPathFetcher, path); BasicValue simpleValue = new BasicValue(metadataBuildingContext, property.getTable()); - bindEnumType(property, property.getType(), simpleValue, columnName); - return simpleValue; - } - - public BasicValue bindEnumTypeForColumn(@Nonnull HibernateBasicProperty property) { - String columnName = property.joinTableColumName(namingStrategy); - BasicValue simpleValue = new BasicValue(metadataBuildingContext, property.getTable()); - bindEnumType(property, property.getComponentType(), simpleValue, columnName); - return simpleValue; - } - - protected void bindEnumType( - HibernatePersistentProperty property, Class propertyType, BasicValue simpleValue, String columnName) { + Class propertyType = property.getEnumType(); PropertyConfig pc = property.getHibernateMappedForm(); - Properties enumProperties = new Properties(); - enumProperties.put(ENUM_CLASS_PROP, propertyType.getName()); String typeName = property.getTypeName(propertyType); if (typeName != null) { simpleValue.setTypeName(typeName); } else { - switch (GrailsEnumType.fromString(pc.getEnumType())) { - case DEFAULT, STRING -> { - // Hibernate 7 native string enum mapping: store by Enum.name() as VARCHAR. - simpleValue.setImplicitJavaTypeAccess(tc -> propertyType); - simpleValue.setEnumerationStyle(EnumType.STRING); - } - case ORDINAL -> { - // Hibernate 7 native ordinal enum mapping: store by Enum.ordinal() as INTEGER. - simpleValue.setImplicitJavaTypeAccess(tc -> propertyType); - simpleValue.setEnumerationStyle(EnumType.ORDINAL); - } - case IDENTITY -> simpleValue.setTypeName(IdentityEnumType.class.getName()); - default -> throw new IllegalArgumentException("Unknown enum type: " + pc.getEnumType()); - } + GrailsEnumType.fromString(pc.getEnumType()).configure(simpleValue, propertyType); } + Properties enumProperties = new Properties(); + enumProperties.put(ENUM_CLASS_PROP, propertyType.getName()); simpleValue.setTypeParameters(enumProperties); Column column = new Column(); - boolean isTablePerHierarchySubclass = property.getHibernateOwner().isTablePerHierarchySubclass(); - if (isTablePerHierarchySubclass) { - // Properties on subclasses in a table-per-hierarchy strategy must be nullable. - if (LOG.isDebugEnabled()) { - LOG.debug( - "[GrailsDomainBinder] Sub class property [{}] for column name [{}] forced to nullable", - property.getName(), - columnName); - } - column.setNullable(true); - } else { - column.setNullable(property.isNullable()); - } - + column.setNullable(property.isEnumColumnNullable()); column.setValue(simpleValue); column.setName(columnName); Table t = simpleValue.getTable(); @@ -142,5 +97,7 @@ protected void bindEnumType( indexBinder.bindIndex(columnName, column, columnConfig, t); columnConfigToColumnBinder.bindColumnConfigToColumn(column, columnConfig, pc); } + return simpleValue; } + } diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/binder/GrailsPropertyBinder.java b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/binder/GrailsPropertyBinder.java index 9bc98e23162..f54cd925840 100644 --- a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/binder/GrailsPropertyBinder.java +++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/binder/GrailsPropertyBinder.java @@ -75,7 +75,11 @@ public Value bindProperty( Value value; - if (currentGrailsProp instanceof HibernateEnumProperty hibernateEnumProperty) { + if (currentGrailsProp instanceof HibernateEnumProperty hibernateEnumProperty && + !hibernateEnumProperty.isCollectionElement()) { + // A hasMany-of-enum property is also a HibernateEnumProperty, but it must still go + // through collectionBinder.bindCollection() below so its join table gets created; + // EnumTypeBinder only binds its element column, from BasicCollectionElementBinder. value = enumTypeBinder.bindEnumType(hibernateEnumProperty, path); } else if (currentGrailsProp.isUserButNotCollectionType()) { value = simpleValueBinder.bindBasicValue(currentGrailsProp, parentProperty, path); diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/binder/PropertyBinder.java b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/binder/PropertyBinder.java index f6c931a578c..60ce0e39d6b 100644 --- a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/binder/PropertyBinder.java +++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/binder/PropertyBinder.java @@ -31,7 +31,6 @@ import org.grails.orm.hibernate.access.TraitPropertyAccessStrategy; import org.grails.orm.hibernate.cfg.PropertyConfig; import org.grails.orm.hibernate.cfg.domainbinding.hibernate.HibernateAssociation; -import org.grails.orm.hibernate.cfg.domainbinding.hibernate.HibernateEnumProperty; import org.grails.orm.hibernate.cfg.domainbinding.hibernate.HibernatePersistentProperty; import org.grails.orm.hibernate.cfg.domainbinding.util.CascadeBehaviorFetcher; @@ -86,8 +85,11 @@ public Property bindProperty(HibernatePersistentProperty persistentProperty, Val prop.setPropertyAccessorName(accessorName); prop.setOptional(persistentProperty.isNullable()); - if (persistentProperty instanceof Association association && - !(persistentProperty instanceof HibernateEnumProperty)) { + // No enum type is excluded here on its own account: a plain scalar enum property is never an + // Association, so instanceof Association already excludes it. A hasMany-of-enum collection IS + // an Association (Basic), and CascadeBehaviorFetcher already dispatches Basic -> ALL correctly, + // so it must go through the same path as every other collection type. + if (persistentProperty instanceof Association association) { prop.setCascade(cascadeBehaviorFetcher.getCascadeBehaviour(association)); } diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/hibernate/HibernateBasicEnumProperty.java b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/hibernate/HibernateBasicEnumProperty.java new file mode 100644 index 00000000000..0f0648c98ea --- /dev/null +++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/hibernate/HibernateBasicEnumProperty.java @@ -0,0 +1,61 @@ +/* + * 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.hibernate; + +import java.beans.PropertyDescriptor; + +import org.grails.datastore.mapping.model.MappingContext; +import org.grails.orm.hibernate.cfg.PersistentEntityNamingStrategy; +import org.grails.orm.hibernate.cfg.domainbinding.util.ColumnNameForPropertyAndPathFetcher; + +/** + * Hibernate basic collection element property whose element type is an enum. Created by {@link + * HibernateMappingFactory#createBasicCollection} when the collection's element type is an enum. + */ +public class HibernateBasicEnumProperty extends HibernateBasicProperty implements HibernateEnumProperty { + + public HibernateBasicEnumProperty( + GrailsHibernatePersistentEntity entity, MappingContext context, PropertyDescriptor property) { + super(entity, context, property); + } + + @Override + public Class getEnumType() { + return getComponentType(); + } + + @Override + public String resolveEnumColumnName( + PersistentEntityNamingStrategy namingStrategy, + ColumnNameForPropertyAndPathFetcher columnNameForPropertyAndPathFetcher, + String path) { + return joinTableColumName(namingStrategy); + } + + /** A hasMany element column is always nullable, matching the non-enum sibling binding path. */ + @Override + public boolean isEnumColumnNullable() { + return true; + } + + @Override + public boolean isCollectionElement() { + return true; + } +} diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/hibernate/HibernateBasicProperty.java b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/hibernate/HibernateBasicProperty.java index 7aa1f98a419..5b8e2e280f0 100644 --- a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/hibernate/HibernateBasicProperty.java +++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/hibernate/HibernateBasicProperty.java @@ -21,6 +21,7 @@ import java.beans.PropertyDescriptor; import org.hibernate.mapping.Collection; +import org.hibernate.mapping.Table; import org.grails.datastore.mapping.model.MappingContext; import org.grails.datastore.mapping.model.types.mapping.BasicWithMapping; @@ -45,4 +46,16 @@ public Collection getHibernateCollection() { public void setHibernateCollection(Collection collection) { this.collection = collection; } + + /** + * For a basic (scalar or enum) collection element, the property's table is the + * collection's join table rather than the owning entity's table. Before the collection + * table has been assigned (e.g. while it is itself being computed), falls back to the + * owning entity's table, matching the pre-collection-binding default. + */ + @Override + public Table getTable() { + Table collectionTable = collection != null ? collection.getCollectionTable() : null; + return collectionTable != null ? collectionTable : getPersistentClass().getTable(); + } } diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/hibernate/HibernateEnumProperty.java b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/hibernate/HibernateEnumProperty.java index a4716225451..d9c8610c8a3 100644 --- a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/hibernate/HibernateEnumProperty.java +++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/hibernate/HibernateEnumProperty.java @@ -18,18 +18,57 @@ */ package org.grails.orm.hibernate.cfg.domainbinding.hibernate; +import org.grails.orm.hibernate.cfg.PersistentEntityNamingStrategy; +import org.grails.orm.hibernate.cfg.domainbinding.util.ColumnNameForPropertyAndPathFetcher; + /** - * Marker interface for Hibernate persistent properties whose Java type is an enum. + * Contract for Hibernate persistent properties that bind an enum value — either the property's + * own type or a basic collection's element type. * - *

Two concrete subtypes exist, corresponding to the two creation paths in {@link + *

Three concrete subtypes exist, corresponding to the three creation paths in {@link * HibernateMappingFactory}: * *

* *

Use {@code instanceof HibernateEnumProperty} instead of {@code isEnumType()} to branch on - * enum properties at binding time. + * enum properties at binding time. Each implementation resolves its own enum class and column + * name so {@link org.grails.orm.hibernate.cfg.domainbinding.binder.EnumTypeBinder} can bind any + * of them through a single code path. */ -public interface HibernateEnumProperty extends HibernatePersistentProperty {} +public interface HibernateEnumProperty extends HibernatePersistentProperty { + + /** The enum class to bind: the property's own type, or a basic collection's element type. */ + default Class getEnumType() { + return getType(); + } + + /** Resolves the column name to bind the enum value under. */ + default String resolveEnumColumnName( + PersistentEntityNamingStrategy namingStrategy, + ColumnNameForPropertyAndPathFetcher columnNameForPropertyAndPathFetcher, + String path) { + return columnNameForPropertyAndPathFetcher.getColumnNameForPropertyAndPath(this, path, null); + } + + /** + * Whether the enum column should allow NULL. Subclass properties in a table-per-hierarchy + * strategy must be nullable; otherwise this follows the property's own nullable constraint. + */ + default boolean isEnumColumnNullable() { + return getHibernateOwner().isTablePerHierarchySubclass() || isNullable(); + } + + /** + * Whether this property is a {@code hasMany} basic-collection element rather than a scalar + * enum-typed property. {@link org.grails.orm.hibernate.cfg.domainbinding.binder.GrailsPropertyBinder} + * uses this to decide whether to bind it directly here, or let it fall through to the normal + * to-many collection path (whose element is bound later, from within the collection binder). + */ + default boolean isCollectionElement() { + return false; + } +} diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/hibernate/HibernateMappingFactory.groovy b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/hibernate/HibernateMappingFactory.groovy index 4bc8430812f..a69a6630a99 100644 --- a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/hibernate/HibernateMappingFactory.groovy +++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/hibernate/HibernateMappingFactory.groovy @@ -167,10 +167,13 @@ class HibernateMappingFactory extends AbstractGormMappingFactory, HibernateAssociation { @@ -92,6 +97,38 @@ default boolean isOneToMany() { return this instanceof HibernateOneToManyProperty; } + /** + * The cascade behavior implied by this to-many property's shape, absent an explicit {@code + * cascade} mapping. Self-contained: every fact this needs (basic-ness, Map-typedness, embedded + * collection-ness, ownership, circularity) is already exposed by this interface or inherited + * from the GORM {@code Association} hierarchy, so no external dispatch is required. + */ + default CascadeBehavior getImpliedCascadeBehavior() { + if (!(this instanceof Association association)) { + throw new MappingException("Unrecognized to-many association type " + getType()); + } + if (isBasic()) { + return ALL; + } + if (Map.class.isAssignableFrom(getType())) { + return association.isCorrectlyOwned() ? ALL : SAVE_UPDATE; + } + if (this instanceof EmbeddedCollection) { + return ALL; + } + // Fail-fast only for entity relationships that are truly missing an association + if (getAssociatedEntity() == null) { + throw new MappingException("Relationship " + this + " has no associated entity"); + } + if (isOneToMany()) { + return association.isCorrectlyOwned() ? ALL : SAVE_UPDATE; + } + if (isManyToMany()) { + return association.isCorrectlyOwned() || isCircular() ? SAVE_UPDATE : NONE; + } + throw new MappingException("Unrecognized to-many association type " + getType()); + } + /** * Returns the component type for this to-many collection, or {@code null} if it cannot be * determined. @@ -226,12 +263,14 @@ default String joinTableColumName(PersistentEntityNamingStrategy namingStrategy) String columnName; if (present) { columnName = joinColumnMappingOptional.get().getName(); + } else if (referencedType.isEnum()) { + // Use the enum's simple name, not its fully-qualified name, so the column + // isn't named after the enum's package. + columnName = namingStrategy.resolveColumnName(referencedType.getSimpleName()); } else { var clazz = namingStrategy.resolveColumnName(referencedType.getName()); var prop = namingStrategy.resolveTableName(getName()); - columnName = referencedType.isEnum() ? - clazz : - new BackticksRemover().apply(prop) + UNDERSCORE + new BackticksRemover().apply(clazz); + columnName = new BackticksRemover().apply(prop) + UNDERSCORE + new BackticksRemover().apply(clazz); } return columnName; } diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/secondpass/BasicCollectionElementBinder.java b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/secondpass/BasicCollectionElementBinder.java index 576253a1e94..359eb34f96f 100644 --- a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/secondpass/BasicCollectionElementBinder.java +++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/secondpass/BasicCollectionElementBinder.java @@ -31,8 +31,11 @@ import org.grails.orm.hibernate.cfg.domainbinding.binder.EnumTypeBinder; import org.grails.orm.hibernate.cfg.domainbinding.binder.SimpleValueColumnBinder; import org.grails.orm.hibernate.cfg.domainbinding.hibernate.HibernateBasicProperty; +import org.grails.orm.hibernate.cfg.domainbinding.hibernate.HibernateEnumProperty; import org.grails.orm.hibernate.cfg.domainbinding.util.SimpleValueColumnFetcher; +import static org.grails.orm.hibernate.cfg.domainbinding.binder.GrailsDomainBinder.EMPTY_PATH; + /** Binds the element value for a basic (scalar or enum) collection. */ public class BasicCollectionElementBinder { @@ -61,10 +64,10 @@ public BasicCollectionElementBinder( /** Creates and binds a {@link BasicValue} element for the given basic collection property. */ public BasicValue bind(@Nonnull HibernateBasicProperty property) { - String columnName = property.joinTableColumName(namingStrategy); - if (property.isEnum()) { - return enumTypeBinder.bindEnumTypeForColumn(property); + if (property instanceof HibernateEnumProperty hibernateEnumProperty) { + return enumTypeBinder.bindEnumType(hibernateEnumProperty, EMPTY_PATH); } else { + String columnName = property.joinTableColumName(namingStrategy); final Class referencedType = property.getComponentType(); String typeName = property.getTypeName(referencedType); Collection collection = property.getCollection(); diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/util/CascadeBehaviorFetcher.java b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/util/CascadeBehaviorFetcher.java index 799a0c448e5..3b5a95c685c 100644 --- a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/util/CascadeBehaviorFetcher.java +++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/util/CascadeBehaviorFetcher.java @@ -18,7 +18,6 @@ */ package org.grails.orm.hibernate.cfg.domainbinding.util; -import java.util.Map; import java.util.Optional; import org.hibernate.MappingException; @@ -26,15 +25,12 @@ import org.slf4j.LoggerFactory; import org.grails.datastore.mapping.model.types.Association; -import org.grails.datastore.mapping.model.types.Basic; import org.grails.datastore.mapping.model.types.Embedded; -import org.grails.datastore.mapping.model.types.EmbeddedCollection; import org.grails.orm.hibernate.cfg.PropertyConfig; -import org.grails.orm.hibernate.cfg.domainbinding.hibernate.HibernateManyToManyProperty; import org.grails.orm.hibernate.cfg.domainbinding.hibernate.HibernateManyToOneProperty; -import org.grails.orm.hibernate.cfg.domainbinding.hibernate.HibernateOneToManyProperty; import org.grails.orm.hibernate.cfg.domainbinding.hibernate.HibernateOneToOneProperty; import org.grails.orm.hibernate.cfg.domainbinding.hibernate.HibernatePersistentProperty; +import org.grails.orm.hibernate.cfg.domainbinding.hibernate.HibernateToManyProperty; import static org.grails.orm.hibernate.cfg.domainbinding.util.CascadeBehavior.ALL; import static org.grails.orm.hibernate.cfg.domainbinding.util.CascadeBehavior.NONE; @@ -82,23 +78,16 @@ private Optional getDefinedBehavior(HibernatePersistentProperty } private CascadeBehavior getImpliedBehavior(Association association) { - // Handle types that do not require an associated entity first - if (association instanceof Basic) { - return ALL; - } - - if (Map.class.isAssignableFrom(association.getType())) { - return association.isCorrectlyOwned() ? ALL : SAVE_UPDATE; + // Every to-many shape (Basic, Map-typed, EmbeddedCollection, OneToMany, ManyToMany) knows its + // own implied cascade behavior; only the to-one/embedded/hasOne shapes remain here. + if (association instanceof HibernateToManyProperty toMany) { + return toMany.getImpliedCascadeBehavior(); } if (association instanceof Embedded) { return ALL; } - if (association instanceof EmbeddedCollection) { - return ALL; - } - // Fail-fast only for entity relationships that are truly missing an association if (association.getAssociatedEntity() == null) { throw new MappingException("Relationship " + association + " has no associated entity"); @@ -108,10 +97,6 @@ private CascadeBehavior getImpliedBehavior(Association association) { return ALL; } else if (association instanceof HibernateOneToOneProperty) { return association.isOwningSide() ? ALL : SAVE_UPDATE; - } else if (association instanceof HibernateOneToManyProperty) { - return association.isCorrectlyOwned() ? ALL : SAVE_UPDATE; - } else if (association instanceof HibernateManyToManyProperty) { - return association.isCorrectlyOwned() || association.isCircular() ? SAVE_UPDATE : NONE; } else if (association instanceof HibernateManyToOneProperty) { if (association.isCorrectlyOwned() && !association.isCircular()) { return ALL; diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/util/GrailsEnumType.java b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/util/GrailsEnumType.java index 26842b02aad..1a2f0f04806 100644 --- a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/util/GrailsEnumType.java +++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/util/GrailsEnumType.java @@ -18,13 +18,42 @@ */ package org.grails.orm.hibernate.cfg.domainbinding.util; +import jakarta.persistence.EnumType; + import org.hibernate.MappingException; +import org.hibernate.mapping.BasicValue; + +import org.grails.orm.hibernate.cfg.IdentityEnumType; public enum GrailsEnumType { - DEFAULT("default"), - STRING("string"), - ORDINAL("ordinal"), - IDENTITY("identity"); + DEFAULT("default") { + @Override + public void configure(BasicValue simpleValue, Class propertyType) { + STRING.configure(simpleValue, propertyType); + } + }, + // Hibernate 7 native string enum mapping: store by Enum.name() as VARCHAR. + STRING("string") { + @Override + public void configure(BasicValue simpleValue, Class propertyType) { + simpleValue.setImplicitJavaTypeAccess(tc -> propertyType); + simpleValue.setEnumerationStyle(EnumType.STRING); + } + }, + // Hibernate 7 native ordinal enum mapping: store by Enum.ordinal() as INTEGER. + ORDINAL("ordinal") { + @Override + public void configure(BasicValue simpleValue, Class propertyType) { + simpleValue.setImplicitJavaTypeAccess(tc -> propertyType); + simpleValue.setEnumerationStyle(EnumType.ORDINAL); + } + }, + IDENTITY("identity") { + @Override + public void configure(BasicValue simpleValue, Class propertyType) { + simpleValue.setTypeName(IdentityEnumType.class.getName()); + } + }; private final String type; @@ -48,4 +77,7 @@ public static GrailsEnumType fromString(String value) { public String getType() { return type; } + + /** Configures the given {@link BasicValue} to store {@code propertyType} per this enum type. */ + public abstract void configure(BasicValue simpleValue, Class propertyType); } diff --git a/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/EnumHasManyDdlSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/EnumHasManyDdlSpec.groovy new file mode 100644 index 00000000000..fed83a001fa --- /dev/null +++ b/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/EnumHasManyDdlSpec.groovy @@ -0,0 +1,207 @@ +/* + * 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 grails.gorm.tests + +import grails.gorm.annotation.Entity +import grails.gorm.transactions.Rollback +import org.grails.orm.hibernate.HibernateDatastore +import org.hibernate.engine.spi.SessionImplementor +import spock.lang.AutoCleanup +import spock.lang.Issue +import spock.lang.Shared +import spock.lang.Specification + +import java.sql.ResultSet + +/** + * Reproduces https://github.com/apache/grails-core/issues/16051 + * + * A domain with a `hasMany` collection whose related type is an enum (a + * Set of a basic/enum type, not an entity) produces broken join table DDL: + * the element column is bound against the owning entity's table instead of + * the join table, and its name is derived from the enum's fully-qualified + * class name instead of its simple name. + */ +@Rollback +class EnumHasManyDdlSpec extends Specification { + + @Shared @AutoCleanup HibernateDatastore datastore = + new HibernateDatastore(SurveyResponse, OrdinalSurveyResponse, NamedColumnSurveyResponse, NonNullableSurveyResponse) + + @Issue("https://github.com/apache/grails-core/issues/16051") + void "join table for a hasMany of enum is created with the element column"() { + expect: "the join table has exactly the owner FK column and the element column, named from the enum's simple name" + columnNamesFor('SURVEY_RESPONSE_ANSWERS') == ['survey_response_id', 'survey_answer'] as Set + } + + @Issue("https://github.com/apache/grails-core/issues/16051") + void "the owner table does not get a spurious column for the hasMany enum element"() { + expect: "no answer-related column leaked onto survey_response itself" + !columnNamesFor('SURVEY_RESPONSE').any { it.contains('answer') } + } + + @Issue("https://github.com/apache/grails-core/issues/16051") + void "a hasMany of enum can actually be saved and reloaded"() { + given: + def response = new SurveyResponse(respondent: "Alice") + response.addToAnswers(SurveyAnswer.MAYBE) + response.addToAnswers(SurveyAnswer.DONT_KNOW) + + when: + response.save(flush: true) + response.discard() + def reloaded = SurveyResponse.get(response.id) + + then: + reloaded.answers.sort() == [SurveyAnswer.MAYBE, SurveyAnswer.DONT_KNOW].sort() + } + + @Issue("https://github.com/apache/grails-core/issues/16051") + void "a hasMany of enum with enumType ordinal stores the ordinal, not the name"() { + expect: "the element column is a numeric ordinal column, not a string one" + columnNamesFor('ORDINAL_SURVEY_RESPONSE_ANSWERS') == ['ordinal_survey_response_id', 'survey_answer'] as Set + + when: + def response = new OrdinalSurveyResponse(respondent: "Bob") + response.addToAnswers(SurveyAnswer.FOR_SURE) + response.save(flush: true) + response.discard() + def reloaded = OrdinalSurveyResponse.get(response.id) + + then: + reloaded.answers == [SurveyAnswer.FOR_SURE] as Set + } + + @Issue("https://github.com/apache/grails-core/issues/16051") + void "a hasMany of enum with an explicit joinTable column name uses it verbatim"() { + expect: "the element column uses the explicitly configured name instead of the derived one" + columnNamesFor('NAMED_COLUMN_SURVEY_RESPONSE_ANSWERS') == + ['named_column_survey_response_id', 'chosen_answer'] as Set + + when: + def response = new NamedColumnSurveyResponse(respondent: "Carol") + response.addToAnswers(SurveyAnswer.MAYBE_NOT) + response.save(flush: true) + response.discard() + def reloaded = NamedColumnSurveyResponse.get(response.id) + + then: + reloaded.answers == [SurveyAnswer.MAYBE_NOT] as Set + } + + @Issue("https://github.com/apache/grails-core/issues/16051") + void "the hasMany enum element column stays nullable even when nullable false is declared, matching the non-enum sibling path"() { + expect: "nullable: false on a hasMany-of-enum does not reach the element column, same as a hasMany-of-String would" + isNullableColumn('NON_NULLABLE_SURVEY_RESPONSE_ANSWERS', 'survey_answer') + } + + private Set columnNamesFor(String tableName) { + SessionImplementor sessionImplementor = (SessionImplementor) datastore.sessionFactory.currentSession + sessionImplementor.doReturningWork { connection -> + Set columnNames = [] + try (def statement = connection.prepareStatement( + "select column_name from information_schema.columns where table_name = ?")) { + statement.setString(1, tableName) + try (ResultSet resultSet = statement.executeQuery()) { + while (resultSet.next()) { + columnNames << resultSet.getString('column_name').toLowerCase() + } + } + } + columnNames + } + } + + private boolean isNullableColumn(String tableName, String columnName) { + SessionImplementor sessionImplementor = (SessionImplementor) datastore.sessionFactory.currentSession + sessionImplementor.doReturningWork { connection -> + try (def statement = connection.prepareStatement( + "select is_nullable from information_schema.columns " + + "where table_name = ? and column_name = ?")) { + statement.setString(1, tableName) + statement.setString(2, columnName.toUpperCase()) + try (ResultSet resultSet = statement.executeQuery()) { + resultSet.next() + 'YES'.equalsIgnoreCase(resultSet.getString('is_nullable')) + } + } + } + } +} + +@Entity +class SurveyResponse { + String respondent + Set answers + + static hasMany = [answers: SurveyAnswer] + + static constraints = { + respondent blank: false + } +} + +@Entity +class OrdinalSurveyResponse { + String respondent + Set answers + + static hasMany = [answers: SurveyAnswer] + + static mapping = { + answers enumType: 'ordinal' + } + + static constraints = { + respondent blank: false + } +} + +@Entity +class NamedColumnSurveyResponse { + String respondent + Set answers + + static hasMany = [answers: SurveyAnswer] + + static mapping = { + answers joinTable: [column: 'chosen_answer'] + } + + static constraints = { + respondent blank: false + } +} + +@Entity +class NonNullableSurveyResponse { + String respondent + Set answers + + static hasMany = [answers: SurveyAnswer] + + static constraints = { + respondent blank: false + answers nullable: false + } +} + +enum SurveyAnswer { + FOR_SURE, MAYBE, MAYBE_NOT, DONT_KNOW +} diff --git a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/EnumTypeBinderSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/EnumTypeBinderSpec.groovy index 990260ba00e..25c5f9ea1c8 100644 --- a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/EnumTypeBinderSpec.groovy +++ b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/EnumTypeBinderSpec.groovy @@ -81,12 +81,13 @@ class EnumTypeBinderSpec extends HibernateGormDatastoreSpec { def table = new Table("person_statuses") def property = setupProperty(PersonWithCollection, "statuses", table) - expect: "The property is a ToMany property" + expect: "The property is both a ToMany and an enum property" property instanceof HibernateBasicProperty == true + property instanceof HibernateEnumProperty == true when: "the enum is bound for the collection column" // This will now successfully call property.getComponentType() internally - def result = binder.bindEnumTypeForColumn(property as HibernateBasicProperty) + def result = binder.bindEnumType(property as HibernateEnumProperty, GrailsDomainBinder.EMPTY_PATH) then: "The BasicValue is configured correctly" result.getEnumerationStyle() == EnumType.STRING diff --git a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/GrailsPropertyBinderSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/GrailsPropertyBinderSpec.groovy index 3c553f70cbe..8d9446bbf1a 100644 --- a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/GrailsPropertyBinderSpec.groovy +++ b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/GrailsPropertyBinderSpec.groovy @@ -44,6 +44,12 @@ import org.grails.orm.hibernate.cfg.domainbinding.util.ColumnNameForPropertyAndP import org.grails.orm.hibernate.cfg.domainbinding.util.DefaultColumnNameFetcher import org.grails.orm.hibernate.cfg.domainbinding.util.BackticksRemover import org.grails.orm.hibernate.cfg.domainbinding.util.TableForManyCalculator +import org.grails.datastore.mapping.engine.types.CustomTypeMarshaller +import org.grails.datastore.mapping.model.ClassMapping +import org.grails.datastore.mapping.model.PropertyMapping +import org.grails.orm.hibernate.cfg.PropertyConfig + +import java.beans.PropertyDescriptor import static org.grails.orm.hibernate.cfg.domainbinding.binder.GrailsDomainBinder.EMPTY_PATH @@ -250,6 +256,37 @@ class GrailsPropertyBinderSpec extends HibernateGormDatastoreSpec { def dataProp = persistentEntity.getPropertyByName("data") as HibernatePersistentProperty Value value = propertyBinder.bindProperty(dataProp, null, EMPTY_PATH) + then: "the type: mapping DSL routes this through isUserButNotCollectionType(), as a plain HibernateSimpleProperty" + !(dataProp instanceof HibernateCustomProperty) + dataProp.isUserButNotCollectionType() + value instanceof BasicValue + } + + void "Test bind a genuine HibernateCustomProperty (GORM-detected custom type marshaller, no type: mapping)"() { + given: "a HibernateCustomProperty built the way HibernateMappingFactory#createCustom does: no type: " + + "mapping is set, so isUserButNotCollectionType() is false and the instanceof HibernateCustomProperty " + + "branch is the only one that can match" + def binder = getGrailsDomainBinder() + def propertyBinder = getBinders(binder).propertyBinder + def persistentEntity = getPersistentEntity(PropertyBinderSpecSimpleBook) as GrailsHibernatePersistentEntity + def rootClass = new RootClass(binder.getMetadataBuildingContext()) + rootClass.setTable(new Table("SIMPLE_BOOK")) + persistentEntity.setPersistentClass(rootClass) + + def propertyDescriptor = new PropertyDescriptor("title", PropertyBinderSpecSimpleBook) + def marshaller = Mock(CustomTypeMarshaller) + def customProp = new HibernateCustomProperty(persistentEntity, getMappingContext(), propertyDescriptor, marshaller) + customProp.setMapping(new PropertyMapping() { + ClassMapping getClassMapping() { null } + PropertyConfig getMappedForm() { new PropertyConfig() } + }) + + expect: "no type: mapping means the isUserButNotCollectionType() branch cannot intercept it" + !customProp.isUserButNotCollectionType() + + when: + Value value = propertyBinder.bindProperty(customProp, null, EMPTY_PATH) + then: value instanceof BasicValue } diff --git a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/PropertyBinderSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/PropertyBinderSpec.groovy index 2ecc69c120a..cac12ef2399 100644 --- a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/PropertyBinderSpec.groovy +++ b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/PropertyBinderSpec.groovy @@ -37,7 +37,7 @@ class PropertyBinderSpec extends HibernateGormDatastoreSpec { @Shared PropertyBinder binder = new PropertyBinder(new CascadeBehaviorFetcher()) void setupSpec() { - manager.registerDomainClasses(PBEntity, PBAuthor) + manager.registerDomainClasses(PBEntity, PBAuthor, PBCascadeEntity) } void "test property binding with real objects"() { @@ -98,6 +98,24 @@ class PropertyBinderSpec extends HibernateGormDatastoreSpec { new PropertyBinder() != null } + void "test cascade behavior for hasMany of enum matches hasMany of String"() { + given: + def entity = (HibernatePersistentEntity) getMappingContext().getPersistentEntity(PBCascadeEntity.name) + def stringProperty = (HibernatePersistentProperty) entity.getPropertyByName("tags") + def enumProperty = (HibernatePersistentProperty) entity.getPropertyByName("statuses") + def table = new Table("PB_CASCADE_ENTITY") + def stringValue = new BasicValue(getGrailsDomainBinder().getMetadataBuildingContext(), table) + def enumValue = new BasicValue(getGrailsDomainBinder().getMetadataBuildingContext(), table) + + when: + def stringBound = binder.bindProperty(stringProperty, stringValue) + def enumBound = binder.bindProperty(enumProperty, enumValue) + + then: "a hasMany-of-enum collection cascades the same way as a hasMany-of-String collection" + stringBound.getCascade() == "all" + enumBound.getCascade() == stringBound.getCascade() + } + void "test accessorName for field access"() { given: def entity = (HibernatePersistentEntity) getMappingContext().getPersistentEntity(PBEntity.name) @@ -137,3 +155,11 @@ class PBAuthor { Long id String name } + +enum PBStatus { ACTIVE, INACTIVE } + +@Entity +class PBCascadeEntity { + Long id + static hasMany = [tags: String, statuses: PBStatus] +} diff --git a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/secondpass/BasicCollectionElementBinderSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/secondpass/BasicCollectionElementBinderSpec.groovy index 1715f03e765..d152df625c6 100644 --- a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/secondpass/BasicCollectionElementBinderSpec.groovy +++ b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/secondpass/BasicCollectionElementBinderSpec.groovy @@ -76,7 +76,7 @@ class BasicCollectionElementBinderSpec extends HibernateGormDatastoreSpec { element != null element.getColumnSpan() > 0 // Ensure the enum binder is NOT called for a String collection - 0 * enumTypeBinder.bindEnumTypeForColumn(_, _, _) + 0 * enumTypeBinder.bindEnumType(_, _) } void "bind delegates to enumTypeBinder for enum collection"() { @@ -95,8 +95,7 @@ class BasicCollectionElementBinderSpec extends HibernateGormDatastoreSpec { then: element != null - // Corrected: Match the 3-argument signature (Property, Class, String) - 1 * enumTypeBinder.bindEnumTypeForColumn(property) >> mockValue + 1 * enumTypeBinder.bindEnumType(property, _) >> mockValue } void "test bind with custom column mapping and backticks"() { @@ -182,10 +181,8 @@ class BasicCollectionElementBinderSpec extends HibernateGormDatastoreSpec { when: BasicValue element = binder.bind(property) - then: "columnName is the resolved fully qualified Enum class name" - // The namingStrategy resolves 'org.grails.orm.hibernate.cfg.domainbinding.secondpass.BCEBStatus' - // to 'org_grails_orm_hibernate_cfg_domainbinding_secondpass_bcebstatus' - 1 * enumTypeBinder.bindEnumTypeForColumn(property) >> mockValue + then: "the enum binder is delegated to for the enum element" + 1 * enumTypeBinder.bindEnumType(property, _) >> mockValue element == mockValue }