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}:
*
*
* - {@link HibernateSimpleEnumProperty} — plain enum with no custom type marshaller
*
- {@link HibernateCustomEnumProperty} — enum backed by a custom type marshaller
+ *
- {@link HibernateBasicEnumProperty} — enum element of a {@code hasMany} basic collection
*
*
* 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
}