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 12:55:41 UTC
(grails-core) 24/29: feat: reject or state every option GrailsDomainGenerator used to drop
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 913628deca272d67fa3dcfee8744df7c4fcb1f75
Author: Walter Duque de Estrada <wb...@mac.com>
AuthorDate: Mon Oct 5 04:59:38 2026 -0500
feat: reject or state every option GrailsDomainGenerator used to drop
Auditing the mapping options against the binder found three more silent drops:
- an embedded property mapped with a user type (the binder binds it as one simple
value, the generator wrote an @Embedded and said nothing) is rejected by name;
- insertable: false / updatable: false on a property with no column of its own (an
embedded object, a collection, the inverse side of a one-to-one) is rejected by
name, because annotations have nothing to state them on;
- the comment, default and read and write expressions of an enum column, which
EnumTypeBinder forgets, and a unique group on an enum property, for which it makes
no key, are now stated by the generator instead of copied, as is the rule for
binder defects. The differential lists both as known divergences; the enum column
case is pinned in GrailsDomainBinderOptionDefectSpec.
GrailsDomainGeneratorOptionCoverageSpec keeps this from regressing: it switches every
PropertyConfig, ColumnConfig, cache, join table, index column and Mapping option on,
on a property of each kind, and requires the generated classes to change or the
entity to be rejected wherever the binder reads the option. The options that change
nothing anywhere are listed with the reason (no binder class reads them, run time
only, or consumed by the metamodel first).
Co-Authored-By: Claude Sonnet 5.5 <no...@anthropic.com>
---
.../domainbinding/jpa/GrailsDomainGenerator.groovy | 82 ++--
.../cfg/domainbinding/jpa/UniqueKeyFacets.groovy | 7 +-
.../jpa/GrailsDomainBinderOptionDefectSpec.groovy | 25 +-
.../GrailsDomainGeneratorDifferentialSpec.groovy | 32 +-
.../GrailsDomainGeneratorOptionCoverageSpec.groovy | 453 +++++++++++++++++++++
.../GrailsDomainGeneratorUniqueGroupSpec.groovy | 13 +-
6 files changed, 572 insertions(+), 40 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 b3748c0b96..edaaceca72 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
@@ -451,11 +451,6 @@ class GrailsDomainGenerator {
return "Association property [${property.name}] of [${entity.name}]: ${problem}"
}
}
- if (property instanceof HibernateEnumProperty && property.hibernateMappedForm.isUniqueWithinGroup()) {
- return "Enum property [${property.name}] of [${entity.name}] is in a unique group, which the binder ignores for an enum " +
- '(it creates no unique key and leaves the column not unique), so the constraint the mapping asks for would exist ' +
- 'only in the generated model'
- }
if ((property instanceof HibernateSimpleProperty || property instanceof HibernateTenantIdProperty) && !decideType(property).supported) {
return typeNotSupported(property, decideType(property).name)
}
@@ -575,8 +570,8 @@ class GrailsDomainGenerator {
* Decides the indexes and multi-column unique keys the domain binder puts on the table the entity owns. The binder's own
* {@code IndexBinder} and {@code CreateKeyForProps} run, on a scratch table with the entity's table name, for every column
* the binder passes through {@code ColumnBinder} (a simple property, the identifier, the version, a to-one foreign key, each
- * leaf of an embedded type) or {@code EnumTypeBinder} (an enum, which only gets an index), in the order the binder binds
- * them, so the names ({@code <table>_<column>_idx}, {@code UK} and a hash) and the column order are the binder's. The
+ * leaf of an embedded type) or {@code EnumTypeBinder} (an enum, which only gets an index from the binder and a unique group
+ * from the generator), in the order the binder binds them, so the names ({@code <table>_<column>_idx}, {@code UK} and a hash) and the column order are the binder's. The
* single-table subclasses of a hierarchy share the table of the root, so their columns are part of the root's constraints.
*
* @param entity an entity that owns its table: a root, or a joined or table-per-class subclass
@@ -585,14 +580,18 @@ class GrailsDomainGenerator {
org.hibernate.mapping.Table table = new org.hibernate.mapping.Table('grails', entityFacets(entity).tableName().replace('`', ''))
List<GrailsHibernatePersistentEntity> contributors = [entity]
collectSharingSubclasses(entity, contributors)
+ Set<String> unbound = new HashSet<String>()
for (GrailsHibernatePersistentEntity contributor : contributors) {
List<ConstraintSite> sites = []
collectConstraintSites(contributor, sites)
for (ConstraintSite site : sites) {
Column column = new Column(site.columnName)
indexBinder.bindIndex(site.columnName, column, site.columnConfig, table)
- if (!site.enumeration) {
- keyForProps.createKeyForProps(site.property, site.path, table, site.columnName)
+ Set<String> before = new HashSet<String>(table.uniqueKeys.keySet())
+ keyForProps.createKeyForProps(site.property, site.path, table, site.columnName)
+ if (site.enumeration) {
+ // EnumTypeBinder never calls CreateKeyForProps: the key is the mapping's, not the binder's
+ unbound.addAll(table.uniqueKeys.keySet() - before)
}
}
}
@@ -601,7 +600,7 @@ class GrailsDomainGenerator {
new IndexFacets(index.name, index.columns*.name)
},
table.uniqueKeys.values().collect { org.hibernate.mapping.UniqueKey key ->
- new UniqueKeyFacets(key.name, key.columns*.name)
+ new UniqueKeyFacets(key.name, key.columns*.name, !unbound.contains(key.name))
})
}
@@ -795,9 +794,6 @@ class GrailsDomainGenerator {
// the enum binder never reads the formula, it always binds a column
return false
}
- if (property instanceof HibernateEnumProperty && mappedForm.isUniqueWithinGroup()) {
- return false
- }
return decideType(property).supported
}
@@ -969,6 +965,10 @@ class GrailsDomainGenerator {
*/
private String collectionProblem(HibernateBasicProperty property) {
PropertyConfig mapped = property.hibernateMappedForm
+ String writeProblem = writeRestrictionProblem(mapped)
+ if (writeProblem != null) {
+ return writeProblem
+ }
CollectionKind kind = CollectionKind.of(property.type)
if (property.type == SortedSet) {
return 'a SortedSet: the binder names java.util.SortedSet as the collection\'s custom type, which Hibernate rejects when it boots'
@@ -1020,6 +1020,21 @@ class GrailsDomainGenerator {
return null
}
+ /**
+ * {@code PropertyBinder} overwrites the insertable and updatable flags of every property with the ones of its columns, which
+ * are always set, so the binder ignores {@code insertable: false} and {@code updatable: false} (pinned in
+ * {@code GrailsDomainBinderOptionDefectSpec}). The generator states them where there is a column to state them on (a simple
+ * property, an enum, a foreign key). A property with no column of its own, an embedded object, a collection or the inverse
+ * side of a one-to-one, has nothing to state them on, so the generator rejects it rather than drop the option.
+ */
+ private static String writeRestrictionProblem(PropertyConfig mapped) {
+ if (mapped != null && (!mapped.insertable || !mapped.updatable)) {
+ return 'insertable: false or updatable: false is mapped on a property that has no column of its own (the binder ignores ' +
+ 'both for every property), and annotations cannot state them on it'
+ }
+ return null
+ }
+
/**
* The binder binds the key column of a collection like any other column ({@code DependentKeyValueBinder} runs
* {@code ColumnBinder} on the property), so an {@code index:} or a {@code unique:} group on the collection property
@@ -1182,6 +1197,10 @@ class GrailsDomainGenerator {
*/
private String toManyProblem(HibernateToManyEntityProperty property) {
PropertyConfig mapped = property.hibernateMappedForm
+ String writeProblem = writeRestrictionProblem(mapped)
+ if (writeProblem != null) {
+ return writeProblem
+ }
if (Map.isAssignableFrom(property.type) && property instanceof HibernateManyToManyProperty) {
return 'a map on a many-to-many, which the generator does not support yet'
}
@@ -1371,7 +1390,7 @@ class GrailsDomainGenerator {
}
PropertyConfig mapped = property.hibernateMappedForm
if (inverseOneToOne) {
- return null
+ return writeRestrictionProblem(mapped)
}
if (mapped.columns != null && mapped.columns.size() > 1) {
return 'the mapping states several columns, which the binder binds as a composite foreign key'
@@ -1475,7 +1494,7 @@ class GrailsDomainGenerator {
HibernateOneToOneProperty inverse = ((HibernateOneToOneProperty) property).hibernateInverseSide
unique = property.isBidirectional() && inverse != null && inverse.isValidHibernateOneToOne()
}
- return facets(property, column, name, nullable, unique, true)
+ return facets(property, column, name, nullable, unique)
}
/**
@@ -1613,6 +1632,14 @@ class GrailsDomainGenerator {
return 'the property is mapped lazy: true, which the binder marks on the component as a lazy attribute and which ' +
'annotations cannot state on an @Embedded'
}
+ if (property.isUserButNotCollectionType()) {
+ return "the property is mapped with the type [${property.userType.name}]: the binder binds it as one simple value of that " +
+ 'type and not as an embedded object, which the generator does not state'
+ }
+ String writeProblem = writeRestrictionProblem(property.hibernateMappedForm)
+ if (writeProblem != null) {
+ return writeProblem
+ }
if (visiting.contains(type.javaClass)) {
return "the embedded type [${type.name}] contains itself"
}
@@ -1679,10 +1706,10 @@ class GrailsDomainGenerator {
numericConstraints.bindNumericColumnConstraints(column, columnConfig, mappedForm, type)
}
return facets(property, column, name, isNullable(property, parent),
- mappedForm.isUnique() && !mappedForm.isUniqueWithinGroup(), true)
+ mappedForm.isUnique() && !mappedForm.isUniqueWithinGroup())
}
- /** Mirrors {@code EnumTypeBinder}: only the column config rules apply, and the column config's own uniqueness. */
+ /** Mirrors {@code EnumTypeBinder}: the column config's length, precision, scale, SQL type and uniqueness, plus the comment, default and read and write expressions it forgets. */
private ColumnFacets enumColumnFacets(HibernateEnumProperty property, String path) {
PropertyConfig mappedForm = property.hibernateMappedForm
Column column = new Column()
@@ -1690,13 +1717,20 @@ class GrailsDomainGenerator {
if (columnConfig != null) {
columnConfigBinder.bindColumnConfigToColumn(column, columnConfig, mappedForm)
}
+ if (columnConfig != null) {
+ // EnumTypeBinder ignores the comment, the default and the read and write expressions of the column config (a binder
+ // defect, pinned in GrailsDomainBinderOptionDefectSpec); the generator states what the mapping asks for
+ column.comment = columnConfig.comment
+ column.defaultValue = columnConfig.defaultValue
+ column.customRead = columnConfig.read
+ column.customWrite = columnConfig.write
+ }
String name = property.resolveEnumColumnName(namingStrategy, columnNames, path)
- return facets(property, column, name, property.isEnumColumnNullable(), column.unique, false)
+ return facets(property, column, name, property.isEnumColumnNullable(), column.unique)
}
private ColumnFacets facets(
- HibernatePersistentProperty property, Column column, String name, boolean nullable, boolean unique,
- boolean withExtras) {
+ HibernatePersistentProperty property, Column column, String name, boolean nullable, boolean unique) {
PropertyConfig mappedForm = property.hibernateMappedForm
// NaturalId.createUniqueKey sets the updatability of each of its properties to the mutability of the natural id
Boolean naturalMutable = naturalIdMutable(property)
@@ -1710,10 +1744,10 @@ class GrailsDomainGenerator {
column.precision?.intValue(),
column.scale?.intValue(),
column.sqlType,
- withExtras ? column.defaultValue : null,
- withExtras ? column.customRead : null,
- withExtras ? column.customWrite : null,
- withExtras ? column.comment : null)
+ column.defaultValue,
+ column.customRead,
+ column.customWrite,
+ column.comment)
}
private static ColumnConfig firstColumnConfig(PropertyConfig mappedForm) {
diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/UniqueKeyFacets.groovy b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/UniqueKeyFacets.groovy
index 5cf459079a..45ed98047e 100644
--- a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/UniqueKeyFacets.groovy
+++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/UniqueKeyFacets.groovy
@@ -24,10 +24,15 @@ import groovy.transform.CompileStatic
* A multi-column unique key the domain binder puts on a table: its name and its columns in the order it added them.
* {@link GrailsDomainGenerator} writes it into {@code @Table(uniqueConstraints)}.
*
+ * <p>{@code bound} is {@code false} for a key the mapping asks for and the binder does not create: a unique group on an enum
+ * property, which {@code EnumTypeBinder} never gives a key. The generator states it all the same, because the option is the
+ * mapping's and the binder's omission is a defect, not a rule to copy.</p>
+ *
* @since 9.0
*/
@CompileStatic
record UniqueKeyFacets(
String name,
- List<String> columns) {
+ List<String> columns,
+ boolean bound) {
}
diff --git a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainBinderOptionDefectSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainBinderOptionDefectSpec.groovy
index 3fc14c0c19..4d2dcd1cce 100644
--- a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainBinderOptionDefectSpec.groovy
+++ b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainBinderOptionDefectSpec.groovy
@@ -21,6 +21,7 @@ package org.grails.orm.hibernate.cfg.domainbinding.jpa
import grails.gorm.annotation.Entity
import grails.gorm.tests.HibernateGormDatastoreSpec
import org.hibernate.mapping.Collection as HibernateCollection
+import org.hibernate.mapping.Column
import org.hibernate.mapping.Table
import spock.lang.PendingFeature
@@ -32,7 +33,7 @@ import spock.lang.PendingFeature
class GrailsDomainBinderOptionDefectSpec extends HibernateGormDatastoreSpec {
void setupSpec() {
- manager.registerDomainClasses(DefectWritable, DefectReadOnlyColumns, DefectEnumGroup, DefectCollectionGroup)
+ manager.registerDomainClasses(DefectWritable, DefectReadOnlyColumns, DefectEnumGroup, DefectCollectionGroup, DefectEnumColumn)
}
void "a column with no write restriction is inserted and updated"() {
@@ -86,6 +87,18 @@ class GrailsDomainBinderOptionDefectSpec extends HibernateGormDatastoreSpec {
thrown(Exception)
}
+ @PendingFeature(reason = 'EnumTypeBinder only applies the length, precision, scale, SQL type and uniqueness of the column config, so a comment, a default and read and write expressions on an enum column are ignored')
+ void "the comment, default and read and write expressions of an enum column reach the column"() {
+ given:
+ Column column = getPersistentEntity(DefectEnumColumn).persistentClass.getProperty('state').columns[0] as Column
+
+ expect:
+ column.comment == 'the state'
+ column.defaultValue == "'ON'"
+ column.customRead == 'lower(state)'
+ column.customWrite == 'upper(?)'
+ }
+
@PendingFeature(reason = 'CollectionKeyBinder runs ColumnBinder on the collection property, so a unique group becomes a unique key of the collection table that names a column of the owner\'s table')
void "the unique key of a collection table names only columns of that table"() {
given:
@@ -140,6 +153,16 @@ class DefectCollectionGroup {
}
}
+@Entity
+class DefectEnumColumn {
+
+ DefectState state
+
+ static mapping = {
+ state comment: 'the state', defaultValue: "'ON'", read: 'lower(state)', write: 'upper(?)'
+ }
+}
+
enum DefectState {
ON, OFF
}
diff --git a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorDifferentialSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorDifferentialSpec.groovy
index 436dba0edc..eef69feadd 100644
--- a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorDifferentialSpec.groovy
+++ b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorDifferentialSpec.groovy
@@ -278,7 +278,7 @@ class GrailsDomainGeneratorDifferentialSpec extends HibernateGormDatastoreSpec {
}
compared++
String where = "${entity.name}.${property.name}".toString()
- mismatches.addAll(compare(where, generator.columnFacets(property), bound, known))
+ mismatches.addAll(compare(where, generator.columnFacets(property), bound, known, [], property instanceof HibernateEnumProperty))
if (property instanceof HibernateEnumProperty && generator.typeFacets(property) == null) {
mismatches.addAll(compareEnum(where, (HibernateEnumProperty) property, generator, bound))
}
@@ -344,7 +344,8 @@ class GrailsDomainGeneratorDifferentialSpec extends HibernateGormDatastoreSpec {
* {@link GrailsDomainBinderOptionDefectSpec}) and the generator states what the mapping asks for.
*/
private List<String> compare(
- String where, ColumnFacets facets, Property bound, Map<String, Integer> known, Collection<String> ignore = []) {
+ String where, ColumnFacets facets, Property bound, Map<String, Integer> known, Collection<String> ignore = [],
+ boolean enumeration = false) {
Column column = (Column) bound.columns[0]
Map<String, List> pairs = [
name : [facets.name().replace('`', ''), column.name],
@@ -367,6 +368,16 @@ class GrailsDomainGeneratorDifferentialSpec extends HibernateGormDatastoreSpec {
pairs.remove('sqlType')
}
ignore.each { pairs.remove(it) }
+ if (enumeration) {
+ // EnumTypeBinder ignores the comment, default and read and write expressions of the column config (a binder defect,
+ // pinned in GrailsDomainBinderOptionDefectSpec); the generator states them
+ ['default', 'read', 'write', 'comment'].each { String facet ->
+ if (pairs[facet][0] != null && pairs[facet][1] == null) {
+ known["the binder ignores the comment, default and read and write expressions of an enum column; the generator states them".toString()]++
+ pairs.remove(facet)
+ }
+ }
+ }
['insertable', 'updatable'].each { String facet ->
if (pairs[facet] != null && pairs[facet][0] == false && pairs[facet][1] == true) {
known["the binder ignores insertable: false and updatable: false in a mapping (PropertyBinder overwrites them); the generator states them".toString()]++
@@ -390,7 +401,9 @@ class GrailsDomainGeneratorDifferentialSpec extends HibernateGormDatastoreSpec {
} as Map<String, List<String>>
Map<String, List<String>> boundKeys = tableKeysWithoutNaturalId(entity.persistentClass)
Map<String, List<String>> generatedIndexes = facets.indexes().collectEntries { IndexFacets index -> [(index.name()): index.columns()] } as Map<String, List<String>>
- Map<String, List<String>> generatedKeys = facets.uniqueKeys().collectEntries { UniqueKeyFacets key -> [(key.name()): key.columns()] } as Map<String, List<String>>
+ Map<String, List<String>> generatedKeys = facets.uniqueKeys().findAll { UniqueKeyFacets key -> key.bound() }
+ .collectEntries { UniqueKeyFacets key -> [(key.name()): key.columns()] } as Map<String, List<String>>
+ constraints['unique keys the binder does not create'] += facets.uniqueKeys().count { UniqueKeyFacets key -> !key.bound() }
constraints['indexes'] += boundIndexes.size()
constraints['unique keys'] += boundKeys.size()
List<String> found = []
@@ -792,7 +805,7 @@ class GrailsDomainGeneratorDifferentialSpec extends HibernateGormDatastoreSpec {
found.addAll(compareToOne(leafWhere, leaf.toOne(), boundLeaf, known))
continue
} else {
- found.addAll(compare(leafWhere, leaf.column(), boundLeaf, known))
+ found.addAll(compare(leafWhere, leaf.column(), boundLeaf, known, [], leaf.property instanceof HibernateEnumProperty))
if (leaf.property instanceof HibernateEnumProperty && generator.typeFacets(leaf.property) == null) {
found.addAll(compareEnum(leafWhere, (HibernateEnumProperty) leaf.property, generator, boundLeaf))
}
@@ -1146,7 +1159,7 @@ class GrailsDomainGeneratorDifferentialSpec extends HibernateGormDatastoreSpec {
found.addAll(compareAnnotatedCache(where, (RootClass) bound, (RootClass) annotated))
}
if (pairs.ownsTable[0]) {
- found.addAll(compareAnnotatedConstraints(where, bound, annotated))
+ found.addAll(compareAnnotatedConstraints(where, bound, annotated, generator.constraintFacets(entity)))
}
Map<String, Map<String, Object>> expectations = leafExpectations(generator, entity)
for (Property property : bound.declaredProperties) {
@@ -1217,7 +1230,8 @@ class GrailsDomainGeneratorDifferentialSpec extends HibernateGormDatastoreSpec {
* binder's, by name and column order. Hibernate adds a unique key of its own for a natural id, with its own name, so that one
* is compared by columns in the natural id comparison and not here.
*/
- private static List<String> compareAnnotatedConstraints(String where, PersistentClass bound, PersistentClass annotated) {
+ private static List<String> compareAnnotatedConstraints(
+ String where, PersistentClass bound, PersistentClass annotated, ConstraintFacets facets) {
Map<String, List<String>> boundIndexes = bound.table.indexes.values().collectEntries { org.hibernate.mapping.Index index ->
[(index.name): index.columns*.name]
} as Map<String, List<String>>
@@ -1229,7 +1243,11 @@ class GrailsDomainGeneratorDifferentialSpec extends HibernateGormDatastoreSpec {
found << "${where} indexes: generator=${annotatedIndexes} binder=${boundIndexes}".toString()
}
Map<String, List<String>> boundKeys = tableKeysWithoutNaturalId(bound)
- Map<String, List<String>> annotatedKeys = tableKeysWithoutNaturalId(annotated)
+ // a key the mapping asks for and the binder does not create is stated by the generator, so Hibernate reads it too
+ Set<String> unbound = facets.uniqueKeys().findAll { UniqueKeyFacets key -> !key.bound() }*.name().toSet()
+ Map<String, List<String>> annotatedKeys = tableKeysWithoutNaturalId(annotated).findAll { String name, List<String> columns ->
+ !unbound.contains(name)
+ } as Map<String, List<String>>
if (boundKeys != annotatedKeys) {
found << "${where} uniqueKeys: generator=${annotatedKeys} binder=${boundKeys}".toString()
}
diff --git a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorOptionCoverageSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorOptionCoverageSpec.groovy
new file mode 100644
index 0000000000..0de9109c02
--- /dev/null
+++ b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorOptionCoverageSpec.groovy
@@ -0,0 +1,453 @@
+/*
+ * 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.jpa
+
+import grails.gorm.annotation.Entity
+import jakarta.persistence.AccessType
+import jakarta.persistence.CascadeType
+import jakarta.persistence.EnumType
+import jakarta.persistence.FetchType
+import org.hibernate.MappingException
+import org.hibernate.annotations.ColumnDefault
+import org.hibernate.annotations.ColumnTransformer
+import org.hibernate.annotations.Comment
+import org.hibernate.mapping.Column
+
+import org.grails.datastore.mapping.config.AuditMetadataType
+import org.grails.orm.hibernate.cfg.CacheConfig
+import org.grails.orm.hibernate.cfg.ColumnConfig
+import org.grails.orm.hibernate.cfg.Mapping
+import org.grails.orm.hibernate.cfg.NaturalId
+import org.grails.orm.hibernate.cfg.PropertyConfig
+import org.grails.orm.hibernate.cfg.domainbinding.hibernate.GrailsHibernatePersistentEntity
+
+/**
+ * Guards the rule that {@link GrailsDomainGenerator} never drops a mapping option without a word. Every option of
+ * {@code PropertyConfig}, its {@code ColumnConfig}, its cache, join table and index column, and of {@code Mapping} is switched
+ * on, on a property of each kind (a simple property, a number, an enum, a to-one association, an embedded object, and four kinds
+ * of collection), and the classes the generator writes are compared with the ones it wrote before: the option must change them,
+ * or the generator must reject the entity.
+ *
+ * <p>{@link #MUST_HAVE_EFFECT} lists, for each option, the kinds of property where the domain binder reads it, so the generator must
+ * react. {@link #NEVER_HAS_EFFECT} lists the options that change nothing on any kind, with the reason: nothing in the domain binder
+ * reads them, they only matter at run time, or the metamodel consumes them before the generator runs. An option that appears in
+ * neither list is not meaningful for the kind and may be ignored there. Anything the binder reads and the generator ignores
+ * is a silent drop and fails here.</p>
+ */
+class GrailsDomainGeneratorOptionCoverageSpec extends GrailsDomainGeneratorSupport {
+
+ private static final List<String> KINDS = ['basic', 'number', 'mode', 'target', 'home', 'tags', 'modes', 'targets', 'ordered']
+
+ private static final List<String> SIMPLE = ['basic', 'number', 'mode']
+
+ private static final List<String> COLLECTIONS = ['tags', 'modes', 'targets', 'ordered']
+
+ private static final List<String> VALUE_COLLECTIONS = ['tags', 'modes', 'ordered']
+
+ private static final List<String> ENTITY_ASSOCIATIONS = ['target', 'targets']
+
+ /** Option to the kinds where the binder reads it: the generator must change its output or reject the entity there. */
+ private static final Map<String, List<String>> MUST_HAVE_EFFECT = [
+ 'property.nullable' : ['basic', 'number', 'mode'],
+ 'property.lazy' : ['basic', 'number', 'mode', 'target', 'home', 'tags', 'modes', 'targets'],
+ 'property.fetchStrategy' : ['target', 'tags', 'modes', 'targets', 'ordered'],
+ 'property.cascade' : ENTITY_ASSOCIATIONS,
+ 'property.ignoreNotFound' : ['target'],
+ 'property.maxSize' : ['basic'],
+ 'property.inList' : ['basic'],
+ 'property.max' : ['number'],
+ 'property.min' : ['number'],
+ 'property.scale' : ['number'],
+ 'property.unique' : ['basic', 'number', 'target', 'tags', 'modes', 'targets', 'ordered'],
+ 'property.uniqueGroup' : ['basic', 'number', 'mode', 'target', 'tags', 'modes', 'targets', 'ordered'],
+ 'property.insertable' : KINDS,
+ 'property.updatable' : KINDS,
+ 'property.type' : ['basic'],
+ 'property.typeClass' : ['basic', 'number', 'mode', 'home', 'tags', 'modes', 'targets', 'ordered'],
+ 'property.derived' : ['basic', 'number', 'mode', 'target'],
+ 'property.cache' : COLLECTIONS,
+ 'property.batchSize' : COLLECTIONS,
+ 'property.sort' : ['targets'],
+ 'property.indexColumn' : ['ordered'],
+ 'property.joinTable.name' : COLLECTIONS,
+ 'property.joinTable.schema' : COLLECTIONS,
+ 'property.joinTable.catalog' : COLLECTIONS,
+ 'property.joinTable.keys' : COLLECTIONS,
+ 'property.joinTable.column' : COLLECTIONS,
+ 'column.name' : KINDS - ['home'],
+ 'column.sqlType' : KINDS - ['home'],
+ 'column.enumType' : ['mode', 'modes'],
+ 'column.index' : KINDS - ['home'],
+ 'column.unique' : KINDS - ['home'],
+ 'column.length' : KINDS - ['home'],
+ 'column.precision' : KINDS - ['home'],
+ 'column.scale' : KINDS - ['home'],
+ 'column.defaultValue' : KINDS - ['home'],
+ 'column.comment' : KINDS - ['home'],
+ 'column.read' : KINDS - ['home'],
+ 'column.write' : KINDS - ['home'],
+ 'mapping.autoImport' : ['mapping'],
+ 'mapping.batchSize' : ['mapping'],
+ 'mapping.cache' : ['mapping'],
+ 'mapping.comment' : ['mapping'],
+ 'mapping.dynamicInsert' : ['mapping'],
+ 'mapping.dynamicUpdate' : ['mapping'],
+ 'mapping.table.name' : ['mapping'],
+ 'mapping.table.schema' : ['mapping'],
+ 'mapping.table.catalog' : ['mapping'],
+ 'mapping.identity.natural' : ['mapping'],
+ 'mapping.userTypes' : ['mapping'],
+ ]
+
+ /** Options that change nothing anywhere, and why that is no silent drop. */
+ private static final Map<String, String> NEVER_HAS_EFFECT = [
+ 'property.accessType' : 'run time: how the entity instance is read and written, which a generated metadata class never does',
+ 'property.auditMetadataType' : 'no binder class reads it',
+ 'property.cascadeValidate' : 'validation only: no binder class reads it',
+ 'property.cascades' : 'no binder class reads it: the cascade is the cascade string',
+ 'property.enumType' : 'the property-level field is dead: the enum style is read from the column config',
+ 'property.generator' : 'only the identifier reads it, through the identity mapping',
+ 'property.index' : 'the boolean is dead: only the index of the column config is read',
+ 'property.minSize' : 'validation only: the binder reads maxSize',
+ 'property.name' : 'no binder class reads it: the property name is the one of the class',
+ 'property.order' : 'only meaningful together with sort, which is checked with property.sort',
+ 'property.orphanRemoval' : 'no binder class reads it: orphan removal is the all-delete-orphan cascade',
+ 'property.reference' : 'no binder class reads it',
+ 'property.storedAs' : 'no binder class reads it',
+ 'property.targetName' : 'no binder class reads it',
+ 'property.typeParams' : 'only meaningful together with a type, which is checked with property.type',
+ 'property.formula' : 'the metamodel turns a formula into the derived flag before the generator runs',
+ 'mapping.autowire' : 'run time: Spring autowiring of the instance',
+ 'mapping.stateless' : 'run time: the session kind',
+ 'mapping.autoTimestamp' : 'run time: the timestamp listener',
+ 'mapping.datasources' : 'run time: which datastore holds the entity',
+ 'mapping.defaultSort' : 'run time: the default order of queries',
+ 'mapping.sort.name' : 'run time: the default order of queries',
+ ]
+
+ void setupSpec() {
+ manager.registerDomainClasses(GenCovTarget, GenCovOwner, GenCovEnumColumn)
+ }
+
+ void "every option the binder reads changes what the generator writes, or the generator rejects the entity"() {
+ given:
+ Map<String, Map<String, String>> verdicts = verdicts()
+
+ expect: "an option the binder reads on a kind of property is never dropped"
+ MUST_HAVE_EFFECT.collectMany { String option, List<String> kinds ->
+ kinds.findAll { String kind -> verdicts[option][kind] == 'ignored' }.collect { String kind -> "${option} on ${kind}".toString() }
+ } == []
+ }
+
+ void "no mutation breaks the generator with something other than a rejection"() {
+ given:
+ Map<String, Map<String, String>> verdicts = verdicts()
+
+ expect:
+ verdicts.collectMany { String option, Map<String, String> byKind ->
+ byKind.findAll { String kind, String verdict -> verdict.startsWith('error') }.collect { String kind, String verdict ->
+ "${option} on ${kind}: ${verdict}".toString()
+ }
+ } == []
+ }
+
+ void "the options listed as having no effect have none, on any kind of property"() {
+ given:
+ Map<String, Map<String, String>> verdicts = verdicts()
+
+ expect:
+ NEVER_HAS_EFFECT.keySet().collectMany { String option ->
+ verdicts[option].findAll { String kind, String verdict -> verdict != 'ignored' }.collect { String kind, String verdict ->
+ "${option} on ${kind}: ${verdict}".toString()
+ }
+ } == []
+ }
+
+ void "every option that was tried is accounted for"() {
+ given:
+ Set<String> tried = verdicts().keySet()
+
+ expect:
+ (tried - MUST_HAVE_EFFECT.keySet() - NEVER_HAS_EFFECT.keySet()) == [] as Set
+ (MUST_HAVE_EFFECT.keySet() + NEVER_HAS_EFFECT.keySet() - tried) == [] as Set
+ }
+
+ void "an enum column states the comment, default and read and write expressions that the binder forgets"() {
+ when:
+ Class<?> generated = generateGroup(GenCovEnumColumn).values().first()
+ Column read = annotationMetadata([generated]).getEntityBinding(generated.name).getProperty('mode').columns[0] as Column
+
+ then:
+ generated.getDeclaredField('mode').getAnnotation(Comment).value() == 'the mode'
+ generated.getDeclaredField('mode').getAnnotation(ColumnDefault).value() == "'A'"
+ generated.getDeclaredField('mode').getAnnotation(ColumnTransformer).read() == 'lower(mode)'
+ generated.getDeclaredField('mode').getAnnotation(ColumnTransformer).write() == 'upper(?)'
+ read.comment == 'the mode'
+ read.defaultValue == "'A'"
+ read.customRead == 'lower(mode)'
+ read.customWrite == 'upper(?)'
+ }
+
+ void "an insertable or updatable flag on a property with no column is rejected by name"() {
+ when:
+ generateGroup(GenCovOwner, GenCovTarget)
+ entity(GenCovOwner).getHibernatePropertyByName(property).hibernateMappedForm.insertable = false
+ generateGroup(GenCovOwner, GenCovTarget)
+
+ then:
+ UnsupportedOperationException e = thrown()
+ e.message.contains('GenCovOwner')
+ e.message.contains(property)
+ e.message.contains('insertable: false or updatable: false')
+
+ cleanup:
+ entity(GenCovOwner).getHibernatePropertyByName(property).hibernateMappedForm.insertable = true
+
+ where:
+ property << ['home', 'tags', 'targets']
+ }
+
+ void "a user type mapped on an embedded property is rejected by name, because the binder binds it as a simple value"() {
+ given:
+ PropertyConfig config = entity(GenCovOwner).getHibernatePropertyByName('home').hibernateMappedForm
+ config.type = GenUpperType
+
+ when:
+ generateGroup(GenCovOwner, GenCovTarget)
+
+ then:
+ UnsupportedOperationException e = thrown()
+ e.message.contains('GenCovOwner')
+ e.message.contains('home')
+ e.message.contains('GenUpperType')
+
+ cleanup:
+ config.type = null
+ }
+
+ private Map<String, Map<String, String>> verdicts
+
+ private Map<String, Map<String, String>> verdicts() {
+ if (verdicts == null) {
+ verdicts = computeVerdicts()
+ }
+ return verdicts
+ }
+
+ private Map<String, Map<String, String>> computeVerdicts() {
+ Map<String, Map<String, String>> result = [:].withDefault { [:] }
+ String base = signature()
+ for (String kind : KINDS) {
+ PropertyConfig config = entity(GenCovOwner).getHibernatePropertyByName(kind).hibernateMappedForm
+ propertyMutations().each { String option, List<Closure> variants ->
+ result["property.${option}".toString()][kind] = verdict(base, config, variants)
+ }
+ columnMutations().each { String option, List<Closure> variants ->
+ boolean added = config.columns.isEmpty()
+ if (added) {
+ config.columns << new ColumnConfig()
+ }
+ result["column.${option}".toString()][kind] = verdict(base, config.columns[0], variants)
+ if (added) {
+ config.columns.clear()
+ }
+ }
+ }
+ Mapping mapping = entity(GenCovOwner).hibernateMappedForm
+ mappingMutations().each { String option, List<Closure> variants ->
+ result["mapping.${option}".toString()]['mapping'] = verdict(base, mapping, variants)
+ }
+ return result
+ }
+
+ private String verdict(String base, Object target, List<Closure> variants) {
+ Set<String> found = []
+ for (Closure variant : variants) {
+ Closure restore = variant.call(target)
+ try {
+ found << (signature() == base ? 'ignored' : 'effect')
+ } catch (UnsupportedOperationException | MappingException ignored) {
+ // the generator's own rejection, or the binder's own validation that the generator runs: loud either way
+ found << 'effect'
+ } catch (Exception e) {
+ found << "error ${e.getClass().simpleName}: ${e.message}".toString()
+ } finally {
+ restore.call()
+ }
+ }
+ return found.find { String verdict -> verdict.startsWith('error') } ?: (found.contains('effect') ? 'effect' : 'ignored')
+ }
+
+ private static Closure set(String name, Object value) {
+ return { Object target ->
+ Object old = target.getProperty(name)
+ target.setProperty(name, value)
+ return { target.setProperty(name, old) }
+ }
+ }
+
+ private static Closure change(Closure apply, Closure undo) {
+ return { Object target ->
+ apply.call(target)
+ return { undo.call(target) }
+ }
+ }
+
+ private static Map<String, List<Closure>> propertyMutations() {
+ return [
+ 'nullable' : [set('nullable', false), set('nullable', true)],
+ 'lazy' : [set('lazy', Boolean.TRUE), set('lazy', Boolean.FALSE)],
+ 'fetchStrategy' : [set('fetchStrategy', FetchType.EAGER), set('fetchStrategy', FetchType.LAZY)],
+ 'cascade' : [set('cascade', 'all'), set('cascade', 'none'), set('cascade', 'all-delete-orphan')],
+ 'ignoreNotFound' : [set('ignoreNotFound', true)],
+ 'maxSize' : [set('maxSize', 7)],
+ 'minSize' : [set('minSize', 3)],
+ 'inList' : [set('inList', ['10', '200'])],
+ 'max' : [set('max', new BigInteger('1' + '0' * 49))],
+ 'min' : [set('min', new BigInteger('-1' + '0' * 49))],
+ 'scale' : [set('scale', 3)],
+ 'unique' : [set('unique', true)],
+ 'uniqueGroup' : [change({ PropertyConfig c -> c.setUnique(['basic']) },
+ { PropertyConfig c -> c.uniquenessGroup.clear(); c.setUnique(false) })],
+ 'insertable' : [set('insertable', false)],
+ 'updatable' : [set('updatable', false)],
+ 'type' : [set('type', 'text')],
+ 'typeClass' : [set('type', GenUpperType)],
+ 'typeParams' : [set('typeParams', new Properties())],
+ 'derived' : [change({ PropertyConfig c -> c.formula = 'x'; c.derived = true },
+ { PropertyConfig c -> c.formula = null; c.derived = false })],
+ 'formula' : [set('formula', 'x')],
+ 'cache' : [change({ PropertyConfig c -> c.cache = new CacheConfig(enabled: true) }, { PropertyConfig c -> c.cache = null })],
+ 'batchSize' : [set('batchSize', 5)],
+ 'sort' : [set('sort', 'label')],
+ 'order' : [set('order', 'desc')],
+ 'indexColumn' : [change({ PropertyConfig c -> c.indexColumn = new PropertyConfig(columns: [new ColumnConfig(name: 'ic')]) },
+ { PropertyConfig c -> c.indexColumn = null })],
+ 'joinTable.name' : [change({ PropertyConfig c -> c.joinTable.name = 'jt' }, { PropertyConfig c -> c.joinTable.name = null })],
+ 'joinTable.schema' : [change({ PropertyConfig c -> c.joinTable.schema = 'sc' }, { PropertyConfig c -> c.joinTable.schema = null })],
+ 'joinTable.catalog': [change({ PropertyConfig c -> c.joinTable.catalog = 'ca' }, { PropertyConfig c -> c.joinTable.catalog = null })],
+ 'joinTable.keys' : [change({ PropertyConfig c -> c.joinTable.keys = [new ColumnConfig(name: 'k')] }, { PropertyConfig c -> c.joinTable.keys = [] })],
+ 'joinTable.column' : [change({ PropertyConfig c -> c.joinTable.column = new ColumnConfig(name: 'e') }, { PropertyConfig c -> c.joinTable.column = null })],
+ 'accessType' : [set('accessType', AccessType.FIELD)],
+ 'auditMetadataType': [set('auditMetadataType', AuditMetadataType.values()[0])],
+ 'cascadeValidate' : [set('cascadeValidate', 'x')],
+ 'cascades' : [set('cascades', [CascadeType.ALL])],
+ 'enumType' : [change({ PropertyConfig c -> c.setEnumType('ordinal') }, { PropertyConfig c -> c.setEnumType((EnumType) null) })],
+ 'generator' : [set('generator', 'x')],
+ 'index' : [set('index', true)],
+ 'name' : [set('name', 'x')],
+ 'orphanRemoval' : [set('orphanRemoval', true)],
+ 'reference' : [set('reference', true)],
+ 'storedAs' : [set('storedAs', String)],
+ 'targetName' : [set('targetName', 'x')],
+ ]
+ }
+
+ private static Map<String, List<Closure>> columnMutations() {
+ return [
+ 'name' : [set('name', 'x_col')],
+ 'sqlType' : [set('sqlType', 'varchar(10)')],
+ 'enumType' : [set('enumType', 'ordinal')],
+ 'index' : [set('index', 'x_idx')],
+ 'unique' : [set('unique', true)],
+ 'length' : [set('length', 7)],
+ 'precision' : [set('precision', 9)],
+ 'scale' : [set('scale', 2)],
+ 'defaultValue': [set('defaultValue', '1')],
+ 'comment' : [set('comment', 'c')],
+ 'read' : [set('read', 'r')],
+ 'write' : [set('write', 'w')],
+ ]
+ }
+
+ private static Map<String, List<Closure>> mappingMutations() {
+ return [
+ 'autoImport' : [set('autoImport', false)],
+ 'batchSize' : [set('batchSize', 4)],
+ 'cache' : [change({ Mapping m -> m.cache = new CacheConfig(enabled: true) }, { Mapping m -> m.cache = null })],
+ 'comment' : [set('comment', 'c')],
+ 'dynamicInsert' : [set('dynamicInsert', true)],
+ 'dynamicUpdate' : [set('dynamicUpdate', true)],
+ 'table.name' : [change({ Mapping m -> m.table.name = 't' }, { Mapping m -> m.table.name = null })],
+ 'table.schema' : [change({ Mapping m -> m.table.schema = 's' }, { Mapping m -> m.table.schema = null })],
+ 'table.catalog' : [change({ Mapping m -> m.table.catalog = 'c' }, { Mapping m -> m.table.catalog = null })],
+ 'identity.natural': [change({ Mapping m -> m.identity.natural = new NaturalId(propertyNames: ['basic']) },
+ { Mapping m -> m.identity.natural = null })],
+ 'userTypes' : [change({ Mapping m -> m.userTypes[String] = 'text' }, { Mapping m -> m.userTypes.clear() })],
+ 'autowire' : [set('autowire', true)],
+ 'stateless' : [set('stateless', true)],
+ 'autoTimestamp' : [set('autoTimestamp', false)],
+ 'datasources' : [set('datasources', ['other'])],
+ 'defaultSort' : [set('defaultSort', 'x')],
+ 'sort.name' : [change({ Mapping m -> m.sort.name = 'x' }, { Mapping m -> m.sort.name = null })],
+ ]
+ }
+
+ private String signature() {
+ Map<GrailsHibernatePersistentEntity, Class<?>> classes = generateGroup(GenCovOwner, GenCovTarget)
+ return classes.values().collect { Class<?> generated ->
+ generated.declaredAnnotations.collect { it.toString() }.join('|') + '#' +
+ generated.declaredFields.sort { it.name }.collect { java.lang.reflect.Field field ->
+ "${field.name}:${field.genericType}:${field.declaredAnnotations.collect { it.toString() }.join(',')}".toString()
+ }.join(';')
+ }.join('\n')
+ }
+}
+
+enum GenCovMode {
+ A, B
+}
+
+@Entity
+class GenCovTarget {
+
+ String label
+}
+
+@Entity
+class GenCovOwner {
+
+ String basic
+ Integer number
+ GenCovMode mode
+ GenCovTarget target
+ GenCovHome home
+ Set<String> tags
+ Set<GenCovMode> modes
+ Set<GenCovTarget> targets
+ List<String> ordered
+
+ static embedded = ['home']
+ static hasMany = [tags: String, modes: GenCovMode, targets: GenCovTarget, ordered: String]
+}
+
+@Entity
+class GenCovEnumColumn {
+
+ GenCovMode mode
+
+ static mapping = {
+ mode comment: 'the mode', defaultValue: "'A'", read: 'lower(mode)', write: 'upper(?)'
+ }
+}
+
+class GenCovHome {
+
+ String street
+}
diff --git a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorUniqueGroupSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorUniqueGroupSpec.groovy
index c1df8ddae1..1ffa1ee94b 100644
--- a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorUniqueGroupSpec.groovy
+++ b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorUniqueGroupSpec.groovy
@@ -90,15 +90,14 @@ class GrailsDomainGeneratorUniqueGroupSpec extends GrailsDomainGeneratorSupport
readConstraints(metadata.getEntityBinding(classes[entity(GenUqJoinedChild)].name)) == boundConstraints(GenUqJoinedChild)
}
- void "a unique group on an enum property is rejected by name, because the binder ignores it"() {
+ void "a unique group on an enum property is stated, although the binder never creates the key"() {
when:
- generateGroup(GenUqEnum)
+ Map<GrailsHibernatePersistentEntity, Class<?>> classes = generateGroup(GenUqEnum)
+ ConstraintFacets facets = newGenerator().constraintFacets(entity(GenUqEnum))
- then: "the binder makes no unique key for it, so the constraint would exist only in the generated model"
- UnsupportedOperationException e = thrown()
- e.message.contains('GenUqEnum')
- e.message.contains('state')
- e.message.contains('unique group')
+ then: "EnumTypeBinder never calls CreateKeyForProps, which the mapping does not mean: the generator states the key and says the binder does not"
+ generatedConstraints(classes[entity(GenUqEnum)]).values().toList() == [['other', 'state']]
+ facets.uniqueKeys()*.bound() == [false]
entity(GenUqEnum).persistentClass.table.uniqueKeys.values().every { it.columns.size() < 2 }
}