You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@grails.apache.org by bo...@apache.org on 2026/10/05 19:52:20 UTC

(grails-core) branch fix/hibernate7-unique-group-enum-and-collection-8.0.x created (now b8db16ea0f)

This is an automated email from the ASF dual-hosted git repository.

borinquenkid pushed a change to branch fix/hibernate7-unique-group-enum-and-collection-8.0.x
in repository https://gitbox.apache.org/repos/asf/grails-core.git


      at b8db16ea0f fix: unique groups on enum and collection properties

This branch includes the following new commits:

     new b8db16ea0f fix: unique groups on enum and collection properties

The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.



(grails-core) 01/01: fix: unique groups on enum and collection properties

Posted by bo...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

borinquenkid pushed a commit to branch fix/hibernate7-unique-group-enum-and-collection-8.0.x
in repository https://gitbox.apache.org/repos/asf/grails-core.git

commit b8db16ea0fd9133b240510f85f196fda9a2d7dd6
Author: Walter Duque de Estrada <wb...@mac.com>
AuthorDate: Mon Oct 5 10:33:14 2026 -0500

    fix: unique groups on enum and collection properties
    
    A unique group (unique: 'other' or unique: ['a', 'b']) creates a unique key over
    the property's column and the columns of the listed properties. Two shapes were
    broken:
    
    - An enum property made no key at all: EnumTypeBinder never ran the unique key
      creation that ColumnBinder.bindColumn performs through CreateKeyForProps, so
      duplicates of the group were accepted by the database. EnumTypeBinder now calls
      CreateKeyForProps for the enum column, exactly as ColumnBinder does.
    - A collection property made a key over a column its table lacks: the collection
      key is bound through ColumnBinder with the collection property, so the group
      became a unique key of the collection table that named the owner's column
      (x on ug_collection_group_tags). The collection table then failed to create
      and the first insert failed with "Table not found". The same path serves a
      basic collection of enums, via EnumTypeBinder. CreateKeyForProps now creates
      no key for a to-many property: the group's other columns are not in its table,
      so there is nothing meaningful to make. Hibernate 5 reaches the same code
      (bindColumn -> handleUniqueConstraint on the collection key) and, for enums,
      never creates the key either, so it has both defects; ignoring the group on a
      collection is the only consistent reading.
    
    Plain unique: true on an enum already worked and is covered as a regression row.
    
    Co-Authored-By: Claude Sonnet 5.5 <no...@anthropic.com>
---
 .../cfg/domainbinding/binder/EnumTypeBinder.java   |  20 +++
 .../cfg/domainbinding/util/CreateKeyForProps.java  |   6 +
 .../tests/UniqueGroupEnumAndCollectionSpec.groovy  | 195 +++++++++++++++++++++
 .../cfg/domainbinding/CreateKeyForPropsSpec.groovy |  17 ++
 .../cfg/domainbinding/EnumTypeBinderSpec.groovy    |  20 +++
 5 files changed, 258 insertions(+)

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 5a0c102280..20e8e25c3e 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
@@ -34,6 +34,7 @@ import org.grails.orm.hibernate.cfg.PersistentEntityNamingStrategy;
 import org.grails.orm.hibernate.cfg.PropertyConfig;
 import org.grails.orm.hibernate.cfg.domainbinding.hibernate.HibernateEnumProperty;
 import org.grails.orm.hibernate.cfg.domainbinding.util.ColumnNameForPropertyAndPathFetcher;
+import org.grails.orm.hibernate.cfg.domainbinding.util.CreateKeyForProps;
 import org.grails.orm.hibernate.cfg.domainbinding.util.GrailsEnumType;
 
 import static org.grails.orm.hibernate.cfg.domainbinding.binder.GrailsDomainBinder.ENUM_CLASS_PROP;
@@ -47,6 +48,7 @@ public class EnumTypeBinder {
     private final IndexBinder indexBinder;
     private final ColumnConfigToColumnBinder columnConfigToColumnBinder;
     private final PersistentEntityNamingStrategy namingStrategy;
+    private final CreateKeyForProps createKeyForProps;
 
     public EnumTypeBinder(
             MetadataBuildingContext metadataBuildingContext,
@@ -66,11 +68,28 @@ public class EnumTypeBinder {
             IndexBinder indexBinder,
             ColumnConfigToColumnBinder columnConfigToColumnBinder,
             PersistentEntityNamingStrategy namingStrategy) {
+        this(
+                metadataBuildingContext,
+                columnNameForPropertyAndPathFetcher,
+                indexBinder,
+                columnConfigToColumnBinder,
+                namingStrategy,
+                new CreateKeyForProps(columnNameForPropertyAndPathFetcher));
+    }
+
+    protected EnumTypeBinder(
+            MetadataBuildingContext metadataBuildingContext,
+            ColumnNameForPropertyAndPathFetcher columnNameForPropertyAndPathFetcher,
+            IndexBinder indexBinder,
+            ColumnConfigToColumnBinder columnConfigToColumnBinder,
+            PersistentEntityNamingStrategy namingStrategy,
+            CreateKeyForProps createKeyForProps) {
         this.metadataBuildingContext = metadataBuildingContext;
         this.columnNameForPropertyAndPathFetcher = columnNameForPropertyAndPathFetcher;
         this.indexBinder = indexBinder;
         this.columnConfigToColumnBinder = columnConfigToColumnBinder;
         this.namingStrategy = namingStrategy;
+        this.createKeyForProps = createKeyForProps;
     }
 
     public BasicValue bindEnumType(@Nonnull HibernateEnumProperty property, String path) {
@@ -99,6 +118,7 @@ public class EnumTypeBinder {
         Table t = simpleValue.getTable();
         t.addColumn(column);
         simpleValue.addColumn(column);
+        createKeyForProps.createKeyForProps(property, path, t, columnName);
 
         if (!pc.getColumns().isEmpty()) {
             ColumnConfig columnConfig = pc.getColumns().get(0);
diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/util/CreateKeyForProps.java b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/util/CreateKeyForProps.java
index feaf52f04c..5fca69d3f8 100644
--- a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/util/CreateKeyForProps.java
+++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/util/CreateKeyForProps.java
@@ -28,6 +28,7 @@ import org.hibernate.mapping.Table;
 import org.grails.orm.hibernate.cfg.PropertyConfig;
 import org.grails.orm.hibernate.cfg.domainbinding.hibernate.GrailsHibernatePersistentEntity;
 import org.grails.orm.hibernate.cfg.domainbinding.hibernate.HibernatePersistentProperty;
+import org.grails.orm.hibernate.cfg.domainbinding.hibernate.HibernateToManyProperty;
 
 @SuppressWarnings("PMD.DataflowAnomalyAnalysis")
 public class CreateKeyForProps {
@@ -48,6 +49,11 @@ public class CreateKeyForProps {
     }
 
     public void createKeyForProps(HibernatePersistentProperty grailsProp, String path, Table table, String columnName) {
+        if (grailsProp instanceof HibernateToManyProperty) {
+            // The column of a collection property lives in the collection table, which does not hold the columns
+            // of the other properties of the group, so a unique group has no key to create there.
+            return;
+        }
         PropertyConfig mappedForm = grailsProp.getMappedForm();
 
         if (mappedForm.isUnique() && mappedForm.isUniqueWithinGroup()) {
diff --git a/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/UniqueGroupEnumAndCollectionSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/UniqueGroupEnumAndCollectionSpec.groovy
new file mode 100644
index 0000000000..993211aeba
--- /dev/null
+++ b/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/UniqueGroupEnumAndCollectionSpec.groovy
@@ -0,0 +1,195 @@
+/*
+ *  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.mapping.Collection
+import org.hibernate.mapping.Table
+import spock.lang.AutoCleanup
+import spock.lang.Shared
+import spock.lang.Specification
+
+/**
+ * A {@code unique} group makes a unique key over the property's column and the columns of the listed
+ * properties. It has to do so for enum properties as it does for every other column, and it has no meaning on
+ * a collection property, whose table does not hold the listed columns.
+ */
+@Rollback
+class UniqueGroupEnumAndCollectionSpec extends Specification {
+
+    @Shared @AutoCleanup HibernateDatastore datastore = new HibernateDatastore(
+            UgEnumGroup, UgEnumConstraintGroup, UgStringGroup, UgEnumUnique, UgCollectionGroup, UgEnumCollectionGroup)
+
+    void "a unique group on an enum property makes a unique key over the enum and the listed columns"() {
+        expect:
+        uniqueKeyColumns(UgEnumGroup) == [['state', 'other'] as Set]
+    }
+
+    void "a unique group declared in the constraints block on an enum property makes the same key"() {
+        expect:
+        uniqueKeyColumns(UgEnumConstraintGroup) == [['state', 'other'] as Set]
+    }
+
+    void "a unique group on a string property still makes a unique key, as before"() {
+        expect:
+        uniqueKeyColumns(UgStringGroup) == [['name', 'other'] as Set]
+    }
+
+    void "a duplicate of an enum unique group is rejected by the database"() {
+        given:
+        new UgEnumGroup(state: UgState.ON, other: 'a').save(flush: true, failOnError: true)
+        UgEnumGroup.withSession { it.clear() }
+
+        when:
+        new UgEnumGroup(state: UgState.ON, other: 'a').save(flush: true, validate: false)
+
+        then:
+        thrown(Exception)
+    }
+
+    void "the same enum with another value of the grouped column is accepted"() {
+        when:
+        new UgEnumGroup(state: UgState.ON, other: 'b').save(flush: true, failOnError: true)
+        new UgEnumGroup(state: UgState.OFF, other: 'b').save(flush: true, failOnError: true)
+        new UgEnumGroup(state: UgState.ON, other: 'c').save(flush: true, failOnError: true)
+
+        then:
+        UgEnumGroup.countByOther('b') == 2
+    }
+
+    void "a plain unique enum property is unique in the database"() {
+        given:
+        new UgEnumUnique(state: UgState.ON).save(flush: true, failOnError: true)
+        UgEnumUnique.withSession { it.clear() }
+
+        when:
+        new UgEnumUnique(state: UgState.ON).save(flush: true, validate: false)
+
+        then:
+        thrown(Exception)
+    }
+
+    void "the unique keys of a collection table name only columns of that table"() {
+        given:
+        Collection collection = datastore.metadata.getCollectionBinding(UgCollectionGroup.name + '.tags')
+        Table table = collection.collectionTable
+        Set<String> columns = table.columns*.name.toSet()
+
+        expect:
+        table.uniqueKeys.values().every { it.columns*.name.every { String name -> columns.contains(name) } }
+    }
+
+    void "a collection property with a unique group can be saved and reloaded"() {
+        given:
+        UgCollectionGroup owner = new UgCollectionGroup(x: 'x')
+        owner.addToTags('one')
+        owner.addToTags('two')
+        owner.save(flush: true, failOnError: true)
+        UgCollectionGroup.withSession { it.clear() }
+
+        expect:
+        UgCollectionGroup.get(owner.id).tags == ['one', 'two'] as Set
+    }
+
+    void "a collection of enums with a unique group can be saved and reloaded"() {
+        given:
+        UgEnumCollectionGroup owner = new UgEnumCollectionGroup(x: 'x')
+        owner.addToStates(UgState.ON)
+        owner.addToStates(UgState.OFF)
+        owner.save(flush: true, failOnError: true)
+        UgEnumCollectionGroup.withSession { it.clear() }
+
+        expect:
+        UgEnumCollectionGroup.get(owner.id).states == [UgState.ON, UgState.OFF] as Set
+    }
+
+    private List<Set<String>> uniqueKeyColumns(Class<?> domain) {
+        Table table = datastore.metadata.getEntityBinding(domain.name).table
+        table.uniqueKeys.values().collect { it.columns*.name*.toLowerCase().toSet() }
+    }
+}
+
+enum UgState {
+    ON, OFF
+}
+
+@Entity
+class UgEnumGroup {
+    UgState state
+    String other
+
+    static mapping = {
+        state unique: 'other'
+    }
+}
+
+@Entity
+class UgEnumConstraintGroup {
+    UgState state
+    String other
+
+    static constraints = {
+        state unique: 'other'
+    }
+}
+
+@Entity
+class UgStringGroup {
+    String name
+    String other
+
+    static mapping = {
+        name unique: 'other'
+    }
+}
+
+@Entity
+class UgEnumUnique {
+    UgState state
+
+    static mapping = {
+        state unique: true
+    }
+}
+
+@Entity
+class UgCollectionGroup {
+    String x
+    Set<String> tags
+
+    static hasMany = [tags: String]
+
+    static mapping = {
+        tags unique: 'x'
+    }
+}
+
+@Entity
+class UgEnumCollectionGroup {
+    String x
+    Set<UgState> states
+
+    static hasMany = [states: UgState]
+
+    static mapping = {
+        states unique: 'x'
+    }
+}
diff --git a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/CreateKeyForPropsSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/CreateKeyForPropsSpec.groovy
index 061225d18e..c9d3d91138 100644
--- a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/CreateKeyForPropsSpec.groovy
+++ b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/CreateKeyForPropsSpec.groovy
@@ -21,6 +21,7 @@ package org.grails.orm.hibernate.cfg.domainbinding
 
 import org.grails.orm.hibernate.cfg.domainbinding.hibernate.GrailsHibernatePersistentEntity
 import org.grails.orm.hibernate.cfg.domainbinding.hibernate.HibernatePersistentProperty
+import org.grails.orm.hibernate.cfg.domainbinding.hibernate.HibernateToManyProperty
 import org.hibernate.MappingException
 import org.hibernate.mapping.Table
 import spock.lang.Specification
@@ -138,4 +139,20 @@ class CreateKeyForPropsSpec extends Specification {
         0 * uniqueKeyCreator._
         0 * columnNameFetcher._
     }
+
+    def "creates no key for a collection property, whose table does not hold the other columns of the group"() {
+        given:
+        def columnNameFetcher = Mock(ColumnNameForPropertyAndPathFetcher)
+        def uniqueKeyCreator = Mock(UniqueKeyForColumnsCreator)
+        def subject = new CreateKeyForProps(columnNameFetcher, uniqueKeyCreator)
+        def collectionProp = Mock(HibernateToManyProperty)
+
+        when:
+        subject.createKeyForProps(collectionProp, "", new Table("t_collection"), "owner_id")
+
+        then:
+        0 * collectionProp._
+        0 * uniqueKeyCreator._
+        0 * columnNameFetcher._
+    }
 }
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 d01e000a12..9d2bd4bc8c 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
@@ -44,6 +44,7 @@ import org.grails.orm.hibernate.cfg.domainbinding.hibernate.HibernateBasicProper
 import org.grails.orm.hibernate.cfg.domainbinding.hibernate.HibernateEnumProperty
 import org.grails.orm.hibernate.cfg.domainbinding.util.BackticksRemover
 import org.grails.orm.hibernate.cfg.domainbinding.util.ColumnNameForPropertyAndPathFetcher
+import org.grails.orm.hibernate.cfg.domainbinding.util.CreateKeyForProps
 import org.grails.orm.hibernate.cfg.domainbinding.util.DefaultColumnNameFetcher
 
 class EnumTypeBinderSpec extends HibernateGormDatastoreSpec {
@@ -159,6 +160,25 @@ class EnumTypeBinderSpec extends HibernateGormDatastoreSpec {
         1 * columnBinder.bindColumnConfigToColumn(_, _, _)
         1 * indexBinder.bindIndex("status_col", _, _, _)
     }
+
+    def "should create the unique group key for the enum column on the bound table"() {
+        given:
+        def grailsDomainBinder = getGrailsDomainBinder()
+        def namingStrategy = grailsDomainBinder.getNamingStrategy()
+        def defaultColumnNameFetcher = new DefaultColumnNameFetcher(namingStrategy, new BackticksRemover())
+        def columnNameFetcher = new ColumnNameForPropertyAndPathFetcher(namingStrategy, defaultColumnNameFetcher, new BackticksRemover())
+        def createKeyForProps = Mock(CreateKeyForProps)
+        def keyBinder = new EnumTypeBinder(grailsDomainBinder.getMetadataBuildingContext(), columnNameFetcher,
+                indexBinder, columnBinder, namingStrategy, createKeyForProps)
+        def table = new Table("person")
+        def property = setupProperty(Person01, "status", table)
+
+        when:
+        keyBinder.bindEnumType(property as HibernateEnumProperty, "myPath")
+
+        then:
+        1 * createKeyForProps.createKeyForProps(property, "myPath", table, "my_path_status")
+    }
 }
 
 // --- Supporting Classes ---