Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -21,30 +21,23 @@
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;

import static org.grails.orm.hibernate.cfg.domainbinding.binder.GrailsDomainBinder.ENUM_CLASS_PROP;

public class EnumTypeBinder {

private static final Logger LOG = LoggerFactory.getLogger(EnumTypeBinder.class);
private final MetadataBuildingContext metadataBuildingContext;
private final ColumnNameForPropertyAndPathFetcher columnNameForPropertyAndPathFetcher;
private final IndexBinder indexBinder;
Expand Down Expand Up @@ -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();
Expand All @@ -142,5 +97,7 @@ protected void bindEnumType(
indexBinder.bindIndex(columnName, column, columnConfig, t);
columnConfigToColumnBinder.bindColumnConfigToColumn(column, columnConfig, pc);
}
return simpleValue;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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));
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
Comment thread
jdaugherty marked this conversation as resolved.

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() {
Comment thread
jdaugherty marked this conversation as resolved.
return true;
}

@Override
public boolean isCollectionElement() {
return true;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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();
}
Comment on lines +49 to +60

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two things about placing this override on HibernateBasicProperty rather than on HibernateBasicEnumProperty.

Only the enum path needs it. BasicCollectionElementBinder's non-enum branch reads collection.getCollectionTable() directly and never calls property.getTable(), so scalar basic collections gain nothing here and only inherit the risk. Scoping it to the enum subclass would match where resolveEnumColumnName and isEnumColumnNullable were placed.

It changes what TableForManyCalculator.getJoinTableSchema() reads.

String owningTableSchema = property.getTable().getSchema();

For a basic collection that expression no longer means what the variable is named. It still returns the right value, but only because of an ordering coincidence in CollectionBinder.bindCollection():

  1. collectionHolder.create(property) -> CollectionType.create() does coll.setCollectionTable(owner.getTable())
  2. property.setCollection(collection, path) -> the field here becomes non-null, so getTable() starts returning the collection table
  3. bindCollectionTable() calls getJoinTableSchema(), which now reads the collection table — still the owner's table from step 1
  4. collection.setCollectionTable(<real join table>)

The correct schema survives only because of the seed in step 1. The javadoc says the fallback covers "before the collection table has been assigned", but by the time getJoinTableSchema() runs the collection table is assigned — to the owner's table. Anything that reorders steps 1 and 3 silently changes the schema of every basic join table.

Either scope the override to the enum subclass, or have getJoinTableSchema() ask the owner directly (property.getPersistentClass().getTable().getSchema()) so it stops depending on this ordering.

EnumHasManyDdlSpec covers the override end-to-end, so there is no coverage hole — but a feature in HibernateBasicPropertySpec pinning both arms of the ternary would be worth adding alongside whichever fix you pick.

}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
* <p>Two concrete subtypes exist, corresponding to the two creation paths in {@link
* <p>Three concrete subtypes exist, corresponding to the three creation paths in {@link
* HibernateMappingFactory}:
*
* <ul>
* <li>{@link HibernateSimpleEnumProperty} — plain enum with no custom type marshaller
* <li>{@link HibernateCustomEnumProperty} — enum backed by a custom type marshaller
* <li>{@link HibernateBasicEnumProperty} — enum element of a {@code hasMany} basic collection
* </ul>
*
* <p>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();
}

Comment on lines +57 to +64

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The debug log that used to accompany the table-per-hierarchy case in EnumTypeBinder is gone:

LOG.debug("[GrailsDomainBinder] Sub class property [{}] for column name [{}] forced to nullable", ...)

That message is the only signal a user gets that their nullable: false on a table-per-hierarchy subclass was deliberately overridden. isEnumColumnNullable() returns a boolean so it has nowhere natural to log; keeping the message at the EnumTypeBinder call site would preserve it.

/**
* 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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -167,10 +167,13 @@ class HibernateMappingFactory extends AbstractGormMappingFactory<Mapping, Proper
PersistentEntity entity, MappingContext context, PropertyDescriptor property, Class collectionType) {
if (entity instanceof GrailsHibernatePersistentEntity) {
GrailsHibernatePersistentEntity ghpEntity = (GrailsHibernatePersistentEntity) entity
HibernateBasicProperty basic = new HibernateBasicProperty(ghpEntity, context, property)
boolean isEnumCollection = collectionType != null && collectionType.isEnum()
HibernateBasicProperty basic = isEnumCollection
? new HibernateBasicEnumProperty(ghpEntity, context, property)
: new HibernateBasicProperty(ghpEntity, context, property)
Comment on lines +170 to +173

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HibernateMappingFactorySpec has three features covering exactly this method, and none of them can tell whether this branch is present. Reducing it back to new HibernateBasicProperty(ghpEntity, context, property) leaves the spec fully green:

Results: SUCCESS (29 tests, 29 successes, 0 failures, 0 skipped)

The three features are:

  • "createBasicCollection produces HibernateBasicProperty for a basic element collection"
  • "createBasicCollection sets custom marshaller for enum hasMany"
  • "createBasicCollection uses Enum base marshaller when no specific marshaller for enum collection type"

The latter two build entities whose collections are enums, and all three assert instanceof HibernateBasicProperty — the supertype, which stays true either way.

The latter two should assert HibernateBasicEnumProperty; the first should assert the negative (!(sectionsProp instanceof HibernateBasicEnumProperty)) so the split is pinned from both sides. EnumHasManyDdlSpec does catch the regression at boot, so this is a weak-assertion problem rather than uncovered behaviour — but it is a one-word fix in each case.

basic.setMapping(createPropertyMapping(basic, entity))
CustomTypeMarshaller customTypeMarshaller = findCustomType(context, property.propertyType)
if (collectionType != null && collectionType.isEnum()) {
if (isEnumCollection) {
customTypeMarshaller = findCustomType(context, collectionType)
if (customTypeMarshaller == null) {
customTypeMarshaller = findCustomType(context, Enum)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@

import org.grails.datastore.mapping.model.types.Association;
import org.grails.datastore.mapping.model.types.Basic;
import org.grails.datastore.mapping.model.types.EmbeddedCollection;
import org.grails.datastore.mapping.model.types.mapping.PropertyWithMapping;
import org.grails.orm.hibernate.cfg.CacheConfig;
import org.grails.orm.hibernate.cfg.ColumnConfig;
Expand All @@ -39,11 +40,15 @@
import org.grails.orm.hibernate.cfg.PropertyConfig;
import org.grails.orm.hibernate.cfg.domainbinding.binder.GrailsDomainBinder;
import org.grails.orm.hibernate.cfg.domainbinding.util.BackticksRemover;
import org.grails.orm.hibernate.cfg.domainbinding.util.CascadeBehavior;

import static java.util.Optional.ofNullable;
import static org.grails.orm.hibernate.cfg.GrailsHibernateUtil.qualify;
import static org.grails.orm.hibernate.cfg.domainbinding.binder.GrailsDomainBinder.UNDERSCORE;
import static org.grails.orm.hibernate.cfg.domainbinding.util.CascadeBehavior.ALL;
import static org.grails.orm.hibernate.cfg.domainbinding.util.CascadeBehavior.ALL_DELETE_ORPHAN;
import static org.grails.orm.hibernate.cfg.domainbinding.util.CascadeBehavior.NONE;
import static org.grails.orm.hibernate.cfg.domainbinding.util.CascadeBehavior.SAVE_UPDATE;

/** Marker interface for Hibernate to-many associations */
public interface HibernateToManyProperty extends PropertyWithMapping<PropertyConfig>, HibernateAssociation {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HibernateToManyPropertySpec is the direct unit test for this method and it was not touched. Its enum case currently asserts nothing useful:

void "joinTableColumName returns derived column name for enum collection"() {
    given:
    def property = createTestHibernateToManyProperty(HTMPEntityWithEnum, "statuses")
    def namingStrategy = getGrailsDomainBinder().namingStrategy

    expect:
    property.joinTableColumName(namingStrategy) != null
}

That passed before this change and passes after it, so the behaviour you are fixing here has no unit-level guard. Since the sibling case two features down ("joinTableColumName uses explicit join table column name when present") already asserts an exact string, please pin this one the same way — == "htmp_status" or whatever the strategy resolves for that enum's simple name. That also documents the Grails 7 parity this restores.

Comment on lines +266 to +269

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Repeating this from the last round because it is unchanged: HibernateToManyPropertySpec is the direct unit test for this method and it still asserts nothing about the value.

void "joinTableColumName returns derived column name for enum collection"() {
    ...
    expect:
    property.joinTableColumName(namingStrategy) != null
}

I confirmed the gap is real. Reverting just this line to referencedType.getName() and running both specs:

EnumHasManyDdlSpec > join table for a hasMany of enum is created with the element column FAILED
EnumHasManyDdlSpec > a hasMany of enum with enumType ordinal ... FAILED
EnumHasManyDdlSpec > the hasMany enum element column stays nullable ... FAILED
HibernateToManyPropertySpec > joinTableColumName returns derived column name for enum collection PASSED

So the integration spec now guards the fix (good — that is an improvement over last round), but the unit spec for the changed method still passes against the bug. The sibling feature two down, "joinTableColumName uses explicit join table column name when present", already asserts == "tag_val"; please pin this one the same way.

} 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;
}
Expand Down
Loading
Loading