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"]
|===