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/08 19:20:20 UTC

(grails-core) 11/16: feat: accept type parameters on a registered type name in native domain binding

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

borinquenkid pushed a commit to branch 9.0.x-h7-improvement-phase3
in repository https://gitbox.apache.org/repos/asf/grails-core.git

commit 25227f0adf30e077f3f90618ddc63efa2c43bc51
Author: Walter Duque de Estrada <wb...@mac.com>
AuthorDate: Thu Oct 8 01:03:24 2026 -0500

    feat: accept type parameters on a registered type name in native domain binding
    
    The generator refused params next to a registered type name (type: 'string', params: [...]).
    The domain binder hands the parameters to the type it resolves: a UserType that implements
    ParameterizedType receives them, and a registered built-in type ignores them (probed: the column is
    varchar(255), the value is stored as it is and read back).
    
    The generator now accepts the parameters on a registered type, a converting registered type and a
    serialized collection and states none of them, which is the observable behavior of the domain binder.
    The parameters of a UserType are still stated with @Type and reach the type, which the new spec
    compares in both bindings.
    
    Co-Authored-By: Claude Sonnet 5.5 <no...@anthropic.com>
---
 .../domainbinding/jpa/GrailsDomainGenerator.groovy |  20 +--
 ...GeneratedDomainClassesTypeParametersSpec.groovy | 198 +++++++++++++++++++++
 ...eneratedDomainClassesDdlDifferentialSpec.groovy |   1 -
 .../jpa/GrailsDomainGeneratorSpec.groovy           |  26 ++-
 .../advancedGORMFeatures/nativeDomainBinding.adoc  |   7 +-
 5 files changed, 233 insertions(+), 19 deletions(-)

diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGenerator.groovy b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGenerator.groovy
index c0fc95658b..4d51fe6691 100644
--- a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGenerator.groovy
+++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGenerator.groovy
@@ -1096,7 +1096,7 @@ class GrailsDomainGenerator {
             return new TypeDecision(true, name, null)
         }
         if (column && property.isSerializableType()) {
-            return serializedCollectionType(parameters)
+            return serializedCollectionType()
         }
         Class<?> named = loadClass(name, property)
         if (named != null) {
@@ -1107,17 +1107,18 @@ class GrailsDomainGenerator {
         }
         BasicType<?> registered = typeConfiguration.basicTypeRegistry.getRegisteredType(name)
         boolean identity = property instanceof HibernateSimpleIdentityProperty
-        if (registered != null && registered.valueConverter instanceof AttributeConverter && parameters.isEmpty() && !isEnum && !identity &&
+        if (registered != null && registered.valueConverter instanceof AttributeConverter && !isEnum && !identity &&
                 !element && registered.javaTypeDescriptor.javaTypeClass == boxed(type)) {
             // a registered type that converts its value (yes_no, true_false, numeric_boolean) is the JPA converter it wraps
+            // the binder hands the type parameters to a registered type, which ignores them
             return new TypeDecision(true, name, new TypeFacets(
-                    null, registered.jdbcType.defaultSqlTypeCode, parameters, null, registered.valueConverter.getClass()))
+                    null, registered.jdbcType.defaultSqlTypeCode, [:], null, registered.valueConverter.getClass()))
         }
-        if (registered != null && registered.valueConverter == null && parameters.isEmpty() && !isEnum &&
+        if (registered != null && registered.valueConverter == null && !isEnum &&
                 (identity || registered.javaTypeDescriptor.javaTypeClass == boxed(type) || serializesValueOf(registered, type))) {
             Class<?> registeredJava = registered.javaTypeDescriptor.javaTypeClass
             return new TypeDecision(true, name, new TypeFacets(
-                    null, registered.jdbcType.defaultSqlTypeCode, parameters, registeredJava == boxed(type) ? null : registeredJava))
+                    null, registered.jdbcType.defaultSqlTypeCode, [:], registeredJava == boxed(type) ? null : registeredJava))
         }
         String problem
         if (registered == null) {
@@ -1127,8 +1128,6 @@ class GrailsDomainGenerator {
         } else if (registered.valueConverter != null) {
             problem = 'is a registered type that converts its value in a way @Convert does not state (not a JPA attribute converter, ' +
                     'or the type of a collection element, an identifier or an enum)'
-        } else if (!parameters.isEmpty()) {
-            problem = 'is a registered type with type parameters, which no annotation states'
         } else {
             problem = "is registered for the Java type [${registered.javaTypeDescriptor.javaTypeClass.name}], not the property's [${type?.name}]: " +
                     'stating it would need a converter, which annotations do not say for a type name'
@@ -1141,11 +1140,8 @@ class GrailsDomainGenerator {
      * under that name for {@code Serializable}. A field declared {@code Serializable} (the property of the real class is read and
      * written by its own accessors, whatever the field says) has that Java type, and the JDBC type is the binary one.
      */
-    private static TypeDecision serializedCollectionType(Map<String, String> parameters) {
-        if (!parameters.isEmpty()) {
-            return new TypeDecision(false, 'serializable', null, 'is a registered type with type parameters, which no annotation states')
-        }
-        return new TypeDecision(true, 'serializable', new TypeFacets(null, Types.VARBINARY, parameters, Serializable))
+    private static TypeDecision serializedCollectionType() {
+        return new TypeDecision(true, 'serializable', new TypeFacets(null, Types.VARBINARY, [:], Serializable))
     }
 
     /**
diff --git a/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/generated/GeneratedDomainClassesTypeParametersSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/generated/GeneratedDomainClassesTypeParametersSpec.groovy
new file mode 100644
index 0000000000..e6139bac87
--- /dev/null
+++ b/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/generated/GeneratedDomainClassesTypeParametersSpec.groovy
@@ -0,0 +1,198 @@
+/*
+ *  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.generated
+
+import java.sql.Connection
+import java.sql.PreparedStatement
+import java.sql.ResultSet
+import java.sql.ResultSetMetaData
+import java.sql.Types
+import java.util.concurrent.atomic.AtomicInteger
+
+import grails.gorm.annotation.Entity
+import org.hibernate.engine.spi.SharedSessionContractImplementor
+import org.hibernate.mapping.Table
+import org.hibernate.usertype.ParameterizedType
+import org.hibernate.usertype.UserType
+import spock.lang.AutoCleanup
+import spock.lang.Specification
+import spock.lang.Unroll
+
+import org.grails.orm.hibernate.HibernateDatastore
+
+/**
+ * {@code params} on a {@code type}. The domain binder hands them to the type: a registered type name ignores them, a
+ * {@code UserType} that implements {@code ParameterizedType} receives them. The generated mode does the same.
+ */
+class GeneratedDomainClassesTypeParametersSpec extends Specification {
+
+    static final AtomicInteger BOOTS = new AtomicInteger()
+
+    @AutoCleanup
+    HibernateDatastore datastore
+
+    private HibernateDatastore boot(List<Class> group, boolean generated) {
+        datastore?.close()
+        datastore = new HibernateDatastore([
+                'dataSource.url'                  : "jdbc:h2:mem:tns${BOOTS.incrementAndGet()};LOCK_TIMEOUT=10000".toString(),
+                'dataSource.dbCreate'             : 'create-drop',
+                'hibernate.generatedDomainClasses': generated,
+        ], group as Class[])
+        return datastore
+    }
+
+    /** The tables of the boot model as plain data: columns with SQL type and nullability, indexes, unique keys. */
+    private Map<String, Map> schema(HibernateDatastore booted) {
+        Map<String, Map> result = new TreeMap<String, Map>()
+        for (Table table : booted.metadata.collectTableMappings()) {
+            if (table.physicalTable) {
+                result[table.name] = [
+                        columns   : table.columns.collectEntries { org.hibernate.mapping.Column column ->
+                            boolean nullable = column.nullable && !(table.primaryKey != null && table.primaryKey.columns*.name.contains(column.name))
+                            [(column.name): "${column.getSqlType(booted.metadata)}${nullable ? '' : ' not null'}${column.unique ? ' unique' : ''}".toString()]
+                        },
+                        primaryKey: table.primaryKey?.columns*.name?.sort(),
+                        indexes   : table.indexes.values().collectEntries { [(it.name): it.columns*.name] },
+                        uniqueKeys: table.uniqueKeys.values().collect { it.columns*.name.sort() }.sort { it.toString() },
+                ]
+            }
+        }
+        return result
+    }
+
+    /** The rows of a table as stored, binary values as lists of bytes. */
+    private List<List> rows(String sql) {
+        return datastore.connectionSources.defaultConnectionSource.dataSource.connection.withCloseable { Connection connection ->
+            ResultSet result = connection.createStatement().executeQuery(sql)
+            ResultSetMetaData meta = result.metaData
+            List<List> found = []
+            while (result.next()) {
+                found << (1..meta.columnCount).collect { int i ->
+                    Object value = result.getObject(i)
+                    value instanceof byte[] ? ((byte[]) value).toList() : value
+                }
+            }
+            found
+        }
+    }
+
+    /** What a property does when it is saved, read back by a new session and stored: the value, or why it could not be read. */
+    private Map roundTrip(HibernateDatastore booted, Class type, Map<String, Object> values, String property, String select) {
+        Long id = type.withTransaction { type.newInstance(values).save(failOnError: true, flush: true).id }
+        Map observed = [stored: rows(select)]
+        try {
+            Object value = type.withNewSession { type.get(id)."${property}" }
+            observed.reloaded = value instanceof byte[] ? ((byte[]) value).toList() : value
+        } catch (Throwable e) {
+            Throwable root = e
+            while (root.cause != null && root.cause != root) {
+                root = root.cause
+            }
+            observed.reloaded = "cannot be read: ${root.class.simpleName}".toString()
+        }
+        return observed
+    }
+
+    @Unroll
+    void "#label has the column type the domain binder gives it"() {
+        when:
+        Map<String, Map> binder = schema(boot(group, false))
+        Map<String, Map> generated = schema(boot(group, true))
+
+        then:
+        generated == binder
+        generated[table].columns[column] == type
+
+        where:
+        label                                                | group                  | table                 | column          | type
+        'a string typed string with type parameters'         | [TnStringParams]       | 'tn_string_params'    | 'my_property'   | 'varchar(255)'
+        'an integer typed integer with type parameters'      | [TnIntegerParams]      | 'tn_integer_params'   | 'amount'        | 'integer'
+        'a string with a user type that takes parameters'    | [TnPrefixed]           | 'tn_prefixed'         | 'label'         | 'varchar(255)'
+    }
+
+    void "a type with type parameters is saved and read back as the domain binder does, which hands the parameters to a registered type that ignores them"() {
+        when:
+        Map binder = roundTrip(boot([TnStringParams], false), TnStringParams, [myProperty: 'hello'], 'myProperty', 'select my_property from tn_string_params')
+        Map generated = roundTrip(boot([TnStringParams], true), TnStringParams, [myProperty: 'hello'], 'myProperty', 'select my_property from tn_string_params')
+
+        then:
+        generated == binder
+        generated == [stored: [['hello']], reloaded: 'hello']
+    }
+
+    void "the parameters of a user type reach the user type"() {
+        when:
+        Map binder = roundTrip(boot([TnPrefixed], false), TnPrefixed, [label: 'hello'], 'label', 'select label from tn_prefixed')
+        Map generated = roundTrip(boot([TnPrefixed], true), TnPrefixed, [label: 'hello'], 'label', 'select label from tn_prefixed')
+
+        then:
+        generated == binder
+        generated == [stored: [['p:hello']], reloaded: 'hello']
+    }
+}
+
+@Entity
+class TnStringParams {
+    String myProperty
+    static mapping = { myProperty type: 'string', params: [param1: 'value1'] }
+}
+
+@Entity
+class TnIntegerParams {
+    Integer amount
+    static mapping = { amount type: 'integer', params: [param1: 'value1'] }
+}
+
+class TnPrefixType implements UserType<String>, ParameterizedType {
+
+    String prefix = ''
+
+    void setParameterValues(Properties parameters) {
+        prefix = parameters.getProperty('prefix')
+    }
+
+    int getSqlType() { Types.VARCHAR }
+    Class<String> returnedClass() { String }
+    boolean equals(String x, String y) { x == y }
+    int hashCode(String x) { x.hashCode() }
+
+    String nullSafeGet(ResultSet rs, int position, SharedSessionContractImplementor session, Object owner) {
+        String text = rs.getString(position)
+        text == null ? null : text.substring(prefix.length())
+    }
+
+    void nullSafeSet(PreparedStatement st, String value, int index, SharedSessionContractImplementor session) {
+        if (value == null) {
+            st.setNull(index, Types.VARCHAR)
+        } else {
+            st.setString(index, prefix + value)
+        }
+    }
+
+    String deepCopy(String value) { value }
+    boolean isMutable() { false }
+    Serializable disassemble(String value) { value }
+    String assemble(Serializable cached, Object owner) { (String) cached }
+}
+
+@Entity
+class TnPrefixed {
+    String label
+    static mapping = { label type: TnPrefixType, params: [prefix: 'p:'] }
+}
diff --git a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GeneratedDomainClassesDdlDifferentialSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GeneratedDomainClassesDdlDifferentialSpec.groovy
index f0cc67926e..4b49a3f8fe 100644
--- a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GeneratedDomainClassesDdlDifferentialSpec.groovy
+++ b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GeneratedDomainClassesDdlDifferentialSpec.groovy
@@ -87,7 +87,6 @@ class GeneratedDomainClassesDdlDifferentialSpec extends Specification {
             'declares a natural id but is a subclass',
             'which is HibernateEmbeddedProperty',
             'which is not a persistent property of the entity',
-            'is a registered type with type parameters',
     ]
 
     private static final AtomicInteger BOOTS = new AtomicInteger()
diff --git a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorSpec.groovy
index f25fbbe2fa..4003c38787 100644
--- a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorSpec.groovy
+++ b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorSpec.groovy
@@ -168,7 +168,7 @@ class GrailsDomainGeneratorSpec extends HibernateGormDatastoreSpec {
                 GenOmParent, GenOmChild, GenOmListed, GenOmOrdered, GenOmOrphan, GenOmFetched, GenOmTag, GenOmStep, GenOmKept, GenOmSortedOwner, GenOmMapOwner, GenMapBidiOwner, GenMapBidiChild, GenEmbAssocOwner,
                 GenMmStudent, GenMmCourse, GenMmPerson, GenParamsOnly, GenDecimal, GenUnversioned, GenUnversionedRoot, GenUnversionedChild, GenConverted, GenValueTyped,
                 GenCollEmbeddedHolder, GenCollEmbeddedSibling, GenCollEmbeddedItem, GenCollEmbeddedEntityHolder, GenMapM2mLeft, GenMapM2mRight,
-                GenSerialized)
+                GenSerialized, GenTypeParams)
     }
 
     List<StandardServiceRegistry> registries = []
@@ -396,6 +396,18 @@ class GrailsDomainGeneratorSpec extends HibernateGormDatastoreSpec {
         !serialized.getDeclaredField('plain').isAnnotationPresent(JdbcTypeCode)
     }
 
+    void "type parameters on a registered type name are ignored, as the domain binder hands them to a type that ignores them"() {
+        when:
+        Class<?> params = generate(GenTypeParams)
+
+        then:
+        params.getDeclaredField('name').type == String
+        !params.getDeclaredField('name').isAnnotationPresent(Type)
+        params.getDeclaredField('name').getAnnotation(JdbcTypeCode).value() == java.sql.Types.VARCHAR
+        params.getDeclaredField('amount').type == Integer
+        params.getDeclaredField('amount').getAnnotation(JdbcTypeCode).value() == java.sql.Types.INTEGER
+    }
+
     void "Hibernate's own annotation binder resolves a converted type like the domain binder"() {
         given:
         Map<GrailsHibernatePersistentEntity, Class<?>> classes = generateGroup(GenConverted)
@@ -2822,6 +2834,18 @@ class GenSerialized {
     }
 }
 
+@Entity
+class GenTypeParams {
+
+    String name
+    Integer amount
+
+    static mapping = {
+        name type: 'string', params: [param1: 'value1']
+        amount type: 'integer', params: [param1: 'value1']
+    }
+}
+
 class GenUpperType implements UserType<String>, ParameterizedType {
 
     @Override
diff --git a/grails-data-hibernate7/docs/src/docs/asciidoc/advancedGORMFeatures/nativeDomainBinding.adoc b/grails-data-hibernate7/docs/src/docs/asciidoc/advancedGORMFeatures/nativeDomainBinding.adoc
index d29d522bb2..14b6b71b23 100644
--- a/grails-data-hibernate7/docs/src/docs/asciidoc/advancedGORMFeatures/nativeDomainBinding.adoc
+++ b/grails-data-hibernate7/docs/src/docs/asciidoc/advancedGORMFeatures/nativeDomainBinding.adoc
@@ -63,6 +63,7 @@ For the domain classes in GORM's own test suites, both bindings produce the same
 * A `hasMany` inside a class listed in `static embedded` (basic values, enums or domain classes, also in a nested embedded class) is stored in a collection table named after the embedded class, not after the owner, with a key column named after the embedded class too, as with classic binding.
 * A `Map` on a many-to-many (`Map<String, Book> books` with a `Set` or another `Map` on the other side) writes its own join table, with the key column of the map as part of the primary key, as with classic binding. A `Set` on the other side reads that table and does not write it, and two maps are two separate tables: rows stored through one map are not visible through the other, in both bindings.
 * `type: 'serializable'` on a property that is not a collection (a `String`, a `byte[]` or any `Serializable`) is a binary column holding the Java serialization of the value, as with classic binding.
+* `params` on a registered type name such as `type: 'string'` are ignored, as with classic binding. They reach the type when it is a `UserType` that implements `ParameterizedType`.
 * Identifier generators, inheritance strategies (single table, joined and table-per-concrete-class), discriminators, natural ids, optimistic locking, lazy and eager fetching, cascades, sorting, batch size and `cache` usage are mapped as with classic binding.
 
 [[nativeDomainBinding-refused]]
@@ -102,10 +103,6 @@ Nothing is ignored silently. A mapping that boots is bound the way it is written
 | `Type [string] of property [...] is registered for the Java type [java.lang.String], not the property's [java.lang.Integer] ...`
 | Use a `UserType` class, or a type name registered for the property's own type.
 
-| A registered type name together with `params`.
-| `Type [string] of property [...] is a registered type with type parameters, which no annotation states`
-| Use a `UserType` class that implements `ParameterizedType`, or drop the parameters.
-
 | An `AttributeConverter` class named as the `type`, for example `type: 'org.hibernate.type.YesNoConverter'`.
 | `Type [...] of property [...] names a class that is not a UserType (an AttributeConverter, for example) ...`
 | Use the registered names `yes_no`, `true_false` or `numeric_boolean`, or write a `UserType`.
@@ -166,7 +163,7 @@ Some bugs of classic binding are not copied by native binding. Native binding do
 [[nativeDomainBinding-schema]]
 === Differences in the Schema
 
-The schema of 689 groups of related domain classes was created with both bindings on H2 and compared: tables, columns with type, nullability, default, length, precision and scale, primary keys with their column order, foreign keys, unique keys, indexes, check constraints and sequences. Apart from the 7 groups that native binding refuses, these are the differences. Other databases are not covered by this comparison, so verify against your own database as described below.
+The schema of 694 groups of related domain classes was created with both bindings on H2 and compared: tables, columns with type, nullability, default, length, precision and scale, primary keys with their column order, foreign keys, unique keys, indexes, check constraints and sequences. Apart from the 6 groups that native binding refuses, these are the differences. Other databases are not covered by this comparison, so verify against your own database as described below.
 
 [cols="2,3,3", options="header"]
 |===