jdaugherty commented on code in PR #16052:
URL: https://github.com/apache/grails-core/pull/16052#discussion_r3670148120


##########
grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/hibernate/HibernateMappingFactory.groovy:
##########
@@ -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)

Review Comment:
   `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.



##########
grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/hibernate/HibernateBasicProperty.java:
##########
@@ -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();
+    }

Review Comment:
   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.**
   
   ```java
   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.



##########
grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/hibernate/HibernateToManyProperty.java:
##########
@@ -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());

Review Comment:
   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.
   
   ```groovy
   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.



##########
grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/GrailsPropertyBinderSpec.groovy:
##########
@@ -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<PropertyConfig>() {
+            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)
+

Review Comment:
   The new branch in `GrailsPropertyBinder` — `&& 
!hibernateEnumProperty.isCollectionElement()` — has no test in this spec. The 
only thing currently standing behind it is `EnumHasManyDdlSpec` booting 
successfully, which will not tell you which side of the condition broke.
   
   Two features in the same style as the ones already here would pin it at the 
level it was written:
   
   - a scalar enum property yields the `BasicValue` from `enumTypeBinder`
   - a `hasMany`-of-enum property falls through to `collectionBinder` and 
yields a `Collection`
   
   The second is the one that regresses silently if `isCollectionElement()` 
ever returns the wrong thing.



##########
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.
  *
- * <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();
+    }
+

Review Comment:
   The debug log that used to accompany the table-per-hierarchy case in 
`EnumTypeBinder` is gone:
   
   ```java
   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.



##########
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

Review Comment:
   This feature does not test what its name claims. I ran it against the PR 
with the only change being `enumType: 'ordinal'` -> `enumType: 'string'` on 
`OrdinalSurveyResponse`, and it still passes:
   
   ```
   EnumHasManyDdlSpec > a hasMany of enum with enumType ordinal stores the 
ordinal, not the name PASSED
   Results: SUCCESS (6 tests, 6 successes, 0 failures, 0 skipped)
   ```
   
   That is expected from the two assertions:
   
   - `columnNamesFor(...)` compares column *names*, which are identical for 
`STRING` and `ORDINAL`. The `expect:` label even says "the element column is a 
numeric ordinal column, not a string one", but nothing in the expression looks 
at the type.
   - The round trip reads through the same mapping it wrote through, so it 
succeeds under either style.
   
   This is the same shape as the `contains('answer')` issue from the last round 
— the feature would go green against a build where 
`GrailsEnumType.ORDINAL.configure()` was never reached, which is exactly the 
branch this PR rewrote.
   
   `IdentityEnumTypeSpec` in this same package is the established pattern for 
pinning storage representation — it selects the raw column and asserts the 
stored value:
   
   ```groovy
   ResultSet resultSet = ds.getConnection().prepareStatement('select status 
from enum_entity_domain').executeQuery()
   ```
   
   Something equivalent here would actually discriminate, e.g. asserting the 
raw `survey_answer` value is `0` for `FOR_SURE`, or reading `data_type` from 
`information_schema.columns` and asserting it is numeric rather than character. 
As written the `ORDINAL` and `IDENTITY` arms of the new 
`GrailsEnumType.configure()` have no coverage on the collection path at all.
   
   Minor, same block: `expect:` followed by `when:`/`then:` puts two unrelated 
assertions in one feature. Splitting the DDL check from the round trip would 
let each fail with its own message.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to