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/06 18:38:08 UTC

(grails-core) branch 9.0.x-h7-improvement-phase3 updated (a83dbb1e7c -> 06b118ea3c)

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

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


    from a83dbb1e7c test: state why each remaining schema difference of generated mode is kept
     new 12d2b635d4 test: run the whole Hibernate 7 suite through the generated domain classes with a switch
     new 84c2007a17 fix: bind a GORM entity from annotatedClasses through the generated domain classes
     new e7612a94f1 fix: state the NONE optimistic lock style for an unversioned root in generated classes
     new ee0568f308 fix: generate a part of a composite identifier that maps a generator in generated classes
     new a5a4328421 fix: bind an explicit lazy true collection as extra-lazy in generated classes
     new af47a25b0f fix: bind a lazy true embedded property as lazy in generated classes
     new 58f6323b28 feat: state a registered type that converts its value as a JPA converter in generated classes
     new 2d9efbcbb1 test: drop the generated-mode exclusion that the lazy embedded fix made unnecessary
     new 0a72fbb09c fix: let Envers audit the entities bound from generated classes
     new 1269efa13e fix: take the identifier of a table per hierarchy from its root in the GORM column snapshot
     new e1871f83bf test: compare the Liquibase snapshot and changelog of generated mode with binder mode
     new 0358446919 test: run schema and database per tenant over the generated classes and compare with binder mode
     new ee7f1815fb fix: name the data source when a generated entity refers to an entity of another one
     new 06b118ea3c test: assert that generated carrier classes do not leak through serialization and by-name lookups

The 14 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.


Summary of changes:
 gradle/hibernate7-test-config.gradle               |  14 +
 .../core/generated-mode-exclusions.txt             |  42 +++
 .../HibernateMappingContextConfiguration.groovy    |  13 +
 .../cfg/domainbinding/jpa/CollectionFacets.groovy  |   4 +
 .../cfg/domainbinding/jpa/EntityFacets.groovy      |   6 +-
 .../jpa/GeneratedDomainClassBinder.groovy          |  99 +++++-
 .../domainbinding/jpa/GrailsDomainGenerator.groovy | 109 +++++--
 .../cfg/domainbinding/jpa/ToManyFacets.groovy      |   4 +
 .../cfg/domainbinding/jpa/TypeFacets.groovy        |  11 +-
 .../HibernateConnectionSourceSettings.groovy       |   6 +-
 ...neratedDomainClassesAnnotatedClassesSpec.groovy |  77 +++++
 .../GeneratedDomainClassesCompositeIdSpec.groovy   |  29 +-
 .../GeneratedDomainClassesContributorSpec.groovy   | 124 ++++++++
 .../GeneratedDomainClassesConverterSpec.groovy     |  65 ++++
 .../GeneratedDomainClassesExtraLazySpec.groovy     | 124 ++++++++
 .../GeneratedDomainClassesLazyEmbeddedSpec.groovy  |  86 ++++++
 ...eneratedDomainClassesMultiDataSourceSpec.groovy | 203 ++++++++++++
 .../GeneratedDomainClassesSerializationSpec.groovy | 343 +++++++++++++++++++++
 .../GeneratedDomainClassesTenancyModesSpec.groovy  | 274 ++++++++++++++++
 .../unbootable/CrossDataSourceDomains.groovy       |  14 +-
 .../core/GrailsDataHibernate7TckManager.groovy     |  15 +-
 ...eneratedDomainClassesDdlDifferentialSpec.groovy |   6 -
 .../GrailsDomainGeneratorDifferentialSpec.groovy   |  29 +-
 .../GrailsDomainGeneratorLazyPropertySpec.groovy   |  22 +-
 .../GrailsDomainGeneratorOptionCoverageSpec.groovy |  27 +-
 .../jpa/GrailsDomainGeneratorSpec.groovy           | 128 +++++++-
 .../HibernateConnectionSourceSettingsSpec.groovy   |  37 ++-
 ...hibernate.boot.spi.AdditionalMappingContributor |   1 +
 .../liquibase/GormColumnSnapshotGenerator.groovy   |   8 +-
 .../generated/GeneratedModeEnversSpec.groovy       | 248 +++++++++++++++
 .../generated/GeneratedModeSnapshotSpec.groovy     | 313 +++++++++++++++++++
 .../GormColumnSnapshotGeneratorSpec.groovy         |  57 ++++
 32 files changed, 2452 insertions(+), 86 deletions(-)
 create mode 100644 grails-data-hibernate7/core/generated-mode-exclusions.txt
 create mode 100644 grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/generated/GeneratedDomainClassesAnnotatedClassesSpec.groovy
 create mode 100644 grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/generated/GeneratedDomainClassesContributorSpec.groovy
 create mode 100644 grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/generated/GeneratedDomainClassesConverterSpec.groovy
 create mode 100644 grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/generated/GeneratedDomainClassesExtraLazySpec.groovy
 create mode 100644 grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/generated/GeneratedDomainClassesLazyEmbeddedSpec.groovy
 create mode 100644 grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/generated/GeneratedDomainClassesMultiDataSourceSpec.groovy
 create mode 100644 grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/generated/GeneratedDomainClassesSerializationSpec.groovy
 create mode 100644 grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/generated/GeneratedDomainClassesTenancyModesSpec.groovy
 copy grails-data-mongodb/bson/src/test/groovy/org/grails/datastore/bson/codecs/domain/Profile.groovy => grails-data-hibernate7/core/src/test/groovy/grails/unbootable/CrossDataSourceDomains.groovy (71%)
 create mode 100644 grails-data-hibernate7/core/src/test/resources/META-INF/services/org.hibernate.boot.spi.AdditionalMappingContributor
 create mode 100644 grails-data-hibernate7/dbmigration/src/test/groovy/org/grails/plugins/databasemigration/generated/GeneratedModeEnversSpec.groovy
 create mode 100644 grails-data-hibernate7/dbmigration/src/test/groovy/org/grails/plugins/databasemigration/generated/GeneratedModeSnapshotSpec.groovy


(grails-core) 04/14: fix: generate a part of a composite identifier that maps a generator in generated classes

Posted by bo...@apache.org.
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 ee0568f30861f17359e1db49c4c2170efcce25c1
Author: Walter Duque de Estrada <wb...@mac.com>
AuthorDate: Tue Oct 6 10:06:02 2026 -0500

    fix: generate a part of a composite identifier that maps a generator in generated classes
    
    The domain binder gives the value of every property that maps a generator a generator
    creator, and Hibernate uses the creators of the parts of a non-aggregated identifier when it
    builds the identifier generator, so a part mapped with generator: 'sequence' is filled from
    the sequence. The class Hibernate bound from the generated @IdClass had no creator on its
    parts, so the part was never generated (NULL not allowed for column). The binder now sets
    the same creator on the identifier's parts, and the sequence difference the DDL
    differential listed as kept is gone. Found by running
    CompositeIdWithManyToOneAndSequenceSpec in generated mode.
    
    Co-Authored-By: Claude Sonnet 5.5 <no...@anthropic.com>
---
 .../jpa/GeneratedDomainClassBinder.groovy          | 31 +++++++++++++++++++++-
 .../GeneratedDomainClassesCompositeIdSpec.groovy   | 29 +++++++++++++++++++-
 ...eneratedDomainClassesDdlDifferentialSpec.groovy |  4 ---
 3 files changed, 58 insertions(+), 6 deletions(-)

diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GeneratedDomainClassBinder.groovy b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GeneratedDomainClassBinder.groovy
index 9ead27dd0a..a98888c328 100644
--- a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GeneratedDomainClassBinder.groovy
+++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GeneratedDomainClassBinder.groovy
@@ -291,12 +291,41 @@ class GeneratedDomainClassBinder implements SessionFactoryBuilderFactory {
     private void alignCompositeIdentifier(RootClass root, GrailsHibernatePersistentEntity entity, MetadataImplementor metadata) {
         Class<?> real = entity.javaClass
         metadata.typeConfiguration.javaTypeRegistry.resolveEntityTypeDescriptor(real)
-        alignIdentifierComponent((Component) root.identifier, entity, real)
+        Component identifier = (Component) root.identifier
+        alignIdentifierComponent(identifier, entity, real)
+        installPartGenerators(identifier, entity)
         if (root.identifierMapper != null) {
             alignIdentifierComponent(root.identifierMapper, entity, real)
         }
     }
 
+    /**
+     * The domain binder gives the value of every property that maps a {@code generator} a generator creator, and Hibernate
+     * consults the creators of the parts of a non-aggregated identifier when it builds the identifier generator (it generates the
+     * parts that are not assigned). The same creator is set on the identifier's part, with the generator and the parameters the
+     * mapping names; the {@code @IdClass} Hibernate bound has none, as it has no annotation for one.
+     */
+    private void installPartGenerators(Component identifier, GrailsHibernatePersistentEntity entity) {
+        for (Property property : identifier.getProperties()) {
+            HibernatePersistentProperty part = entity.compositeIdentity.find { HibernatePersistentProperty candidate -> candidate.name == property.name }
+            String strategy = part?.generatorName
+            if (strategy != null && property.value instanceof BasicValue) {
+                BasicValue value = (BasicValue) property.value
+                value.setCustomIdGeneratorCreator({ GeneratorCreationContext context ->
+                    HibernateSimpleIdentity mappedId = entity.hibernateIdentity instanceof HibernateSimpleIdentity ?
+                            (HibernateSimpleIdentity) entity.hibernateIdentity : part.buildPropertyIdentity().orElse(null)
+                    return GrailsSequenceGeneratorEnum.getGenerator(
+                            GrailsSequenceGeneratorEnum.fromName(strategy).orElse(GrailsSequenceGeneratorEnum.NATIVE),
+                            new GeneratorCreationContextWrapper(context, value),
+                            mappedId,
+                            entity,
+                            jdbcEnvironment,
+                            namingStrategy)
+                })
+            }
+        }
+    }
+
     private void alignIdentifierComponent(Component component, GrailsHibernatePersistentEntity entity, Class<?> real) {
         component.componentClassName = real.name
         for (Property property : component.getProperties()) {
diff --git a/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/generated/GeneratedDomainClassesCompositeIdSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/generated/GeneratedDomainClassesCompositeIdSpec.groovy
index 7cc2d99339..f01bff4b86 100644
--- a/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/generated/GeneratedDomainClassesCompositeIdSpec.groovy
+++ b/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/generated/GeneratedDomainClassesCompositeIdSpec.groovy
@@ -36,13 +36,29 @@ class GeneratedDomainClassesCompositeIdSpec extends HibernateGormDatastoreSpec {
 
     void setupSpec() {
         registerGeneratedDomainClasses(
-                GdcCidItem, GdcCidSpecial, GdcCidOwner, GdcCidPart, GdcCidRef, GdcCidBox, GdcCidThing)
+                GdcCidItem, GdcCidSpecial, GdcCidOwner, GdcCidPart, GdcCidRef, GdcCidBox, GdcCidThing, GdcCidSequenced)
     }
 
     private GdcCidItem savedItem(String region = 'eu', String code = 'a', String label = 'first') {
         return new GdcCidItem(region: region, code: code, label: label).save(flush: true)
     }
 
+    def "a part of the identifier that maps a generator is generated, the others are assigned"() {
+        when:
+        GdcCidSequenced first = new GdcCidSequenced(region: 'eu', label: 'one').save(flush: true)
+        GdcCidSequenced second = new GdcCidSequenced(region: 'eu', label: 'two').save(flush: true)
+        sessionFactory.currentSession.clear()
+        List sequences = sessionFactory.currentSession.createNativeQuery(
+                "select sequence_name from information_schema.sequences where sequence_name = 'GDC_CID_SEQ'", String).list()
+
+        then:
+        first.partNumber != null
+        second.partNumber == first.partNumber + 1
+        GdcCidSequenced.count() == 2
+        GdcCidSequenced.get(new GdcCidSequenced(partNumber: first.partNumber, region: 'eu')).label == 'one'
+        sequences == ['GDC_CID_SEQ']
+    }
+
     def "the entity is its own identifier and has no identifier property"() {
         when:
         EntityPersister persister = sessionFactory.mappingMetamodel.getEntityDescriptor(GdcCidItem)
@@ -295,3 +311,14 @@ class GdcCidThing {
     String name
     static belongsTo = [box: GdcCidBox]
 }
+
+@Entity
+class GdcCidSequenced implements Serializable {
+    Integer partNumber
+    String region
+    String label
+    static mapping = {
+        partNumber generator: 'sequence', params: [sequence_name: 'GDC_CID_SEQ']
+        id composite: ['partNumber', 'region']
+    }
+}
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 fd3dd2b501..6d7bfbbac5 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
@@ -72,7 +72,6 @@ class GeneratedDomainClassesDdlDifferentialSpec extends Specification {
             [id: 'LIST_INDEX_CHECK', reason: 'Hibernate adds check (<index column> >= 0) to the index column of every list (IndexColumn.addIndexCheckConstraint, always, for @OrderColumn) and offers no annotation to avoid it; Column.getCheckConstraints() is unmodifiable and Column has no removal method (Column.copy shares the list), so it cannot be removed through public API, only by reflection on the private list, which is not done. The check can never reject a value GORM writes (indexes [...]
             [id: 'MAP_ELEMENT_NULLABLE', reason: 'The mapping of a map of values states nullable: false on the element column and the binder leaves the column nullable (it ignores the option, like the enum column extras); the generated mode honours the mapping, so a database created by the binder has a nullable column where the generated mode creates NOT NULL. Matching the binder would drop a constraint the mapping states.'],
             [id: 'MAP_UNUSED_COLUMN', reason: 'The binder leaves an unused nullable column in the table of a map of values (the element it bound before the map replaced it, attributes_java_lang_string); the generated mode creates no such column. Nothing reads the extra column; an existing database keeps it (update does not drop columns).'],
-            [id: 'SEQUENCE', reason: 'A generator mapped on one part of a composite identifier (idColumn generator: \'sequence\'): the binder creates the sequence, Hibernate\'s annotation binder has no generator for a part of a non-aggregated identifier (an @IdClass key is assigned), so there is nothing to create it for. 1 mapping in the scanned domains (Tooth/ToothDisease), whose composite key parts are assigned in both modes.'],
             [id: 'UNIQUE_GROUP_ON_ENUM', reason: 'A binder defect (pinned in GrailsDomainBinderOptionDefectSpec): a unique group that includes an enum property is dropped by the binder. The generated mode creates the constraint the mapping states, so a database created by the binder lacks it and `update` would add it. 2 groups in the scanned domains.']
     ]
 
@@ -195,9 +194,6 @@ class GeneratedDomainClassesDdlDifferentialSpec extends Specification {
         if (kind == 'foreign key only in binder mode') {
             return 'IGNORE_NOT_FOUND_FOREIGN_KEY'
         }
-        if (kind == 'sequence') {
-            return 'SEQUENCE'
-        }
         if (kind == 'table only in binder mode') {
             return 'INVERSE_JOIN_TABLE_NAME'
         }


(grails-core) 01/14: test: run the whole Hibernate 7 suite through the generated domain classes with a switch

Posted by bo...@apache.org.
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 12d2b635d4a1a45e241dcbd150054a2da5df26df
Author: Walter Duque de Estrada <wb...@mac.com>
AuthorDate: Tue Oct 6 09:47:12 2026 -0500

    test: run the whole Hibernate 7 suite through the generated domain classes with a switch
    
    The module's own suite is the Hibernate contract, so how close generated mode is to
    replacing the domain binder is measured by running all of it that way. The Gradle
    property -Pgrails.test.generatedDomainClasses=true sets the JVM property
    grails.hibernate.generatedDomainClasses, which the TCK manager and the default of
    hibernate.generatedDomainClasses read, so specs that build their own HibernateDatastore
    switch too. Binder mode stays the default. Specs that exercise the binder or generator
    internals and fail only for that reason are listed in generated-mode-exclusions.txt.
    
    Co-Authored-By: Claude Sonnet 5.5 <no...@anthropic.com>
---
 gradle/hibernate7-test-config.gradle               | 14 +++++++
 .../core/generated-mode-exclusions.txt             | 43 ++++++++++++++++++++++
 .../HibernateConnectionSourceSettings.groovy       |  6 ++-
 .../core/GrailsDataHibernate7TckManager.groovy     | 15 +++++++-
 .../HibernateConnectionSourceSettingsSpec.groovy   | 37 ++++++++++++++++++-
 5 files changed, 111 insertions(+), 4 deletions(-)

diff --git a/gradle/hibernate7-test-config.gradle b/gradle/hibernate7-test-config.gradle
index 37bc27a0da..548ca7ee10 100644
--- a/gradle/hibernate7-test-config.gradle
+++ b/gradle/hibernate7-test-config.gradle
@@ -45,6 +45,20 @@ tasks.withType(Test).configureEach {
     }
 
     useJUnitPlatform()
+
+    // Opt-in: -Pgrails.test.generatedDomainClasses=true runs the suite with every manager-built datastore booted through the
+    // generated-domain-class path (hibernate.generatedDomainClasses). Binder mode stays the default. Specs that exercise the
+    // domain binder's internals do not apply to that path and are listed, one class name per line, in the module's
+    // generated-mode-exclusions.txt.
+    if (providers.gradleProperty('grails.test.generatedDomainClasses').map { it.toBoolean() }.getOrElse(false)) {
+        systemProperty('grails.hibernate.generatedDomainClasses', 'true')
+        File exclusions = project.file('generated-mode-exclusions.txt')
+        if (exclusions.exists()) {
+            exclusions.readLines().collect { it.trim() }.findAll { it && !it.startsWith('#') }.each { String pattern ->
+                filter.excludeTestsMatching(pattern)
+            }
+        }
+    }
     // Lets TCK specs gate features on the running suite. Spock evaluates @IgnoreIf/@Requires
     // preconditions before setupSpec() runs, so this must be set at the task level - a
     // TCK-manager System.setProperty would come too late.
diff --git a/grails-data-hibernate7/core/generated-mode-exclusions.txt b/grails-data-hibernate7/core/generated-mode-exclusions.txt
new file mode 100644
index 0000000000..366b1595f8
--- /dev/null
+++ b/grails-data-hibernate7/core/generated-mode-exclusions.txt
@@ -0,0 +1,43 @@
+#
+# 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.
+#
+# Specs that do not apply when the whole suite runs through the generated domain classes:
+#   ./gradlew -Pgrails.test.generatedDomainClasses=true :grails-data-hibernate7-core:test
+#
+# They exercise the domain binder or the generator themselves (they build binder objects by hand, bind through
+# createPersistentEntity / hibernateFirstPass, or compare the binder with the generator), so their subject is not the path
+# the switch selects. A spec is a "binder internals" spec when its source lives under cfg/domainbinding or mentions
+# GrailsDomainBinder, GrailsDomainGenerator, GeneratedDomainClassBinder, hibernateFirstPass, createPersistentEntity,
+# getGrailsDomainBinder, getCollector(, InFlightMetadataCollector or MetadataBuildingContext (145 source files today).
+# Only those that FAIL in generated mode are listed, one fully qualified class name per line; the others run and pass.
+# Binder mode (the default) runs every spec and does not read this file.
+#
+org.grails.orm.hibernate.cfg.domainbinding.GrailsPropertyBinderSpec
+org.grails.orm.hibernate.cfg.domainbinding.hibernate.HibernateBasicPropertySpec
+org.grails.orm.hibernate.cfg.domainbinding.jpa.EntityCollectionKeyDefectSpec
+org.grails.orm.hibernate.cfg.domainbinding.jpa.GrailsDomainBinderOptionDefectSpec
+org.grails.orm.hibernate.cfg.domainbinding.jpa.GrailsDomainGeneratorDifferentialSpec
+org.grails.orm.hibernate.cfg.domainbinding.jpa.GrailsDomainGeneratorIndexSpec
+org.grails.orm.hibernate.cfg.domainbinding.jpa.GrailsDomainGeneratorLazyPropertySpec
+org.grails.orm.hibernate.cfg.domainbinding.jpa.GrailsDomainGeneratorNaturalIdSpec
+org.grails.orm.hibernate.cfg.domainbinding.jpa.GrailsDomainGeneratorSpec
+org.grails.orm.hibernate.cfg.domainbinding.jpa.GrailsDomainGeneratorTenantSpec
+org.grails.orm.hibernate.cfg.domainbinding.jpa.GrailsDomainGeneratorUniqueGroupSpec
+org.grails.orm.hibernate.cfg.domainbinding.jpa.ManyToManyOwnershipDefectSpec
+org.grails.orm.hibernate.cfg.domainbinding.secondpass.CollectionKeyBinderSpec
+org.grails.orm.hibernate.cfg.domainbinding.secondpass.ManyToOneElementBinderSpec
+org.grails.orm.hibernate.cfg.domainbinding.secondpass.MapSecondPassBinderSpec
+org.grails.orm.hibernate.cfg.domainbinding.secondpass.ToManyEntityMultiTenantFilterBinderSpec
diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/connections/HibernateConnectionSourceSettings.groovy b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/connections/HibernateConnectionSourceSettings.groovy
index 639da126ae..fc9ca1e881 100644
--- a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/connections/HibernateConnectionSourceSettings.groovy
+++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/connections/HibernateConnectionSourceSettings.groovy
@@ -92,9 +92,11 @@ class HibernateConnectionSourceSettings extends ConnectionSourceSettings {
         /**
          * Whether Hibernate's annotation binder binds classes generated from the GORM mapping, instead of the domain
          * binder building Hibernate's boot model by hand. The application's real domain classes are still what is
-         * persisted and loaded. Experimental; off by default.
+         * persisted and loaded. Experimental; off by default. The default can be switched on for a whole JVM, for example to
+         * run an existing test suite through the generated path, with the system property
+         * {@code grails.hibernate.generatedDomainClasses=true}; a value set in the configuration always wins.
          */
-        boolean generatedDomainClasses = false
+        boolean generatedDomainClasses = Boolean.getBoolean('grails.hibernate.generatedDomainClasses')
 
         /**
          * Cache settings
diff --git a/grails-data-hibernate7/core/src/test/groovy/org/apache/grails/data/hibernate7/core/GrailsDataHibernate7TckManager.groovy b/grails-data-hibernate7/core/src/test/groovy/org/apache/grails/data/hibernate7/core/GrailsDataHibernate7TckManager.groovy
index 201169a72b..5537c9fe95 100644
--- a/grails-data-hibernate7/core/src/test/groovy/org/apache/grails/data/hibernate7/core/GrailsDataHibernate7TckManager.groovy
+++ b/grails-data-hibernate7/core/src/test/groovy/org/apache/grails/data/hibernate7/core/GrailsDataHibernate7TckManager.groovy
@@ -55,7 +55,18 @@ class GrailsDataHibernate7TckManager extends GrailsDataTckManager {
     HibernateDatastore multiTenantMultiDataSourceDatastore
     ConfigObject grailsConfig = new ConfigObject()
     boolean isTransactional = true
-    boolean generatedDomainClasses = false
+
+    /**
+     * System property that boots every datastore through the generated-domain-class path
+     * ({@code hibernate.generatedDomainClasses}). It is off by default; the Gradle property
+     * {@code -Pgrails.test.generatedDomainClasses=true} sets it on the test JVM, which runs the whole module suite in
+     * generated mode. The datastores this manager builds read it, and so does the default of
+     * {@code HibernateConnectionSourceSettings}, which covers the specs that build their own {@code HibernateDatastore}
+     * without setting {@code hibernate.generatedDomainClasses} themselves.
+     */
+    static final String GENERATED_MODE_PROPERTY = 'grails.hibernate.generatedDomainClasses'
+
+    boolean generatedDomainClasses = Boolean.getBoolean(GENERATED_MODE_PROPERTY)
 
     /**
      * Registers the classes like {@link #registerDomainClasses} and boots the datastore through the generated-domain-class
@@ -153,6 +164,7 @@ class GrailsDataHibernate7TckManager extends GrailsDataTckManager {
                 'hibernate.cache.queries'  : 'true',
                 'hibernate.hbm2ddl.auto'   : 'create-drop',
                 'hibernate.proxy_factory_class' : 'org.grails.orm.hibernate.proxy.ByteBuddyGroovyProxyFactory',
+                'hibernate.generatedDomainClasses' : generatedDomainClasses,
                 'grails.gorm.default.mapping' : {
                     id generator: 'identity'
                 },
@@ -198,6 +210,7 @@ class GrailsDataHibernate7TckManager extends GrailsDataTckManager {
                 'hibernate.cache.queries'                   : 'true',
                 'hibernate.hbm2ddl.auto'                    : 'create-drop',
                 'hibernate.proxy_factory_class'             : 'org.grails.orm.hibernate.proxy.ByteBuddyGroovyProxyFactory',
+                'hibernate.generatedDomainClasses'          : generatedDomainClasses,
                 'grails.gorm.default.mapping'               : {
                     id generator: 'identity'
                 },
diff --git a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/connections/HibernateConnectionSourceSettingsSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/connections/HibernateConnectionSourceSettingsSpec.groovy
index ac3b0be3b2..eabd4d8b9d 100644
--- a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/connections/HibernateConnectionSourceSettingsSpec.groovy
+++ b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/connections/HibernateConnectionSourceSettingsSpec.groovy
@@ -120,9 +120,13 @@ class HibernateConnectionSourceSettingsSpec extends Specification {
 
     void "the generated domain classes switch is off by default and can be turned on"() {
         given:
+        String property = 'grails.hibernate.generatedDomainClasses'
+        String previous = System.clearProperty(property)
+
+        when:
         def settings = new HibernateConnectionSourceSettings()
 
-        expect:
+        then:
         !settings.hibernate.generatedDomainClasses
 
         when:
@@ -130,6 +134,37 @@ class HibernateConnectionSourceSettingsSpec extends Specification {
 
         then:
         settings.hibernate.generatedDomainClasses
+
+        cleanup:
+        if (previous != null) {
+            System.setProperty(property, previous)
+        }
+    }
+
+    void "the JVM-wide system property switches the generated domain classes default on, and a configured value wins"() {
+        given:
+        String property = 'grails.hibernate.generatedDomainClasses'
+        String previous = System.getProperty(property)
+        System.setProperty(property, 'true')
+
+        when:
+        def settings = new HibernateConnectionSourceSettings()
+
+        then:
+        settings.hibernate.generatedDomainClasses
+
+        when:
+        settings.hibernate.generatedDomainClasses = false
+
+        then:
+        !settings.hibernate.generatedDomainClasses
+
+        cleanup:
+        if (previous == null) {
+            System.clearProperty(property)
+        } else {
+            System.setProperty(property, previous)
+        }
     }
 
     void "test toProperties with dirty checking and custom config"() {


(grails-core) 13/14: fix: name the data source when a generated entity refers to an entity of another one

Posted by bo...@apache.org.
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 ee7f1815fb432da67693c2c7b2ee90b70f25e72c
Author: Walter Duque de Estrada <wb...@mac.com>
AuthorDate: Tue Oct 6 12:01:16 2026 -0500

    fix: name the data source when a generated entity refers to an entity of another one
    
    An association cannot cross data sources: the session factory of a data source
    maps its own entities. Generated mode reported it through the generator's
    IllegalArgumentException about the generateAll call; the domain binder fails with
    a MappingException. The binder now throws a MappingException that names both
    entities and the data source before the generator runs.
    
    Multi data source setups with one data source per entity, entities mapped to all
    data sources and a data source added at run time are compared with binder mode.
    
    Co-Authored-By: Claude Sonnet 5.5 <no...@anthropic.com>
---
 .../jpa/GeneratedDomainClassBinder.groovy          |  20 ++
 ...eneratedDomainClassesMultiDataSourceSpec.groovy | 203 +++++++++++++++++++++
 .../unbootable/CrossDataSourceDomains.groovy       |  34 ++++
 3 files changed, 257 insertions(+)

diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GeneratedDomainClassBinder.groovy b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GeneratedDomainClassBinder.groovy
index d643a931ed..59427ba450 100644
--- a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GeneratedDomainClassBinder.groovy
+++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GeneratedDomainClassBinder.groovy
@@ -21,6 +21,7 @@ package org.grails.orm.hibernate.cfg.domainbinding.jpa
 import java.lang.reflect.Field
 
 import groovy.transform.CompileStatic
+import org.hibernate.MappingException
 import org.hibernate.boot.SessionFactoryBuilder
 import org.hibernate.boot.internal.InFlightMetadataCollectorImpl
 import org.hibernate.boot.internal.MetadataBuildingContextRootImpl
@@ -146,6 +147,7 @@ class GeneratedDomainClassBinder implements SessionFactoryBuilderFactory {
         if (toGenerate.isEmpty()) {
             return
         }
+        requireTargetsOnTheDataSource(toGenerate)
         // GORM's dirty checking reads the 'derived' flag the domain binder sets on the mapping of a formula property
         for (GrailsHibernatePersistentEntity entity : toGenerate) {
             entity.configureDerivedProperties()
@@ -221,6 +223,24 @@ class GeneratedDomainClassBinder implements SessionFactoryBuilderFactory {
         }
     }
 
+    /**
+     * An association can only refer to an entity of the same data source: the session factory of a data source maps its own
+     * entities. The domain binder fails with a mapping exception that names the unmapped class; this one names the data
+     * source too, and stops before the generator reports a target that it was not given.
+     */
+    private void requireTargetsOnTheDataSource(List<GrailsHibernatePersistentEntity> entities) {
+        Set<Class<?>> mapped = entities*.javaClass.toSet()
+        for (GrailsHibernatePersistentEntity entity : entities) {
+            for (GrailsHibernatePersistentEntity target : GrailsDomainGenerator.referencedEntities(entity)) {
+                if (!mapped.contains(target.javaClass)) {
+                    throw new MappingException(
+                            "An association from entity [${entity.name}] refers to [${target.name}], which is not mapped to " +
+                                    "data source [${dataSourceName}]: an association cannot cross data sources")
+                }
+            }
+        }
+    }
+
     private List<GrailsHibernatePersistentEntity> hierarchies(List<HibernatePersistentEntity> entities) {
         List<GrailsHibernatePersistentEntity> result = []
         Deque<GrailsHibernatePersistentEntity> pending = new ArrayDeque<GrailsHibernatePersistentEntity>()
diff --git a/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/generated/GeneratedDomainClassesMultiDataSourceSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/generated/GeneratedDomainClassesMultiDataSourceSpec.groovy
new file mode 100644
index 0000000000..f6d42820b0
--- /dev/null
+++ b/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/generated/GeneratedDomainClassesMultiDataSourceSpec.groovy
@@ -0,0 +1,203 @@
+/*
+ *  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 grails.gorm.annotation.Entity
+import grails.unbootable.CrossDataSourceRef
+import org.hibernate.MappingException
+import org.hibernate.Session
+import org.hibernate.dialect.H2Dialect
+import spock.lang.AutoCleanup
+import spock.lang.Specification
+
+import org.grails.datastore.mapping.core.DatastoreUtils
+import org.grails.orm.hibernate.HibernateDatastore
+
+/**
+ * Several data sources over the generated mapping, each entity mapped to one of them (or to all): every data source has
+ * its own session factory and its own generated classes, and the tables of each database are those the domain binder
+ * creates.
+ */
+class GeneratedDomainClassesMultiDataSourceSpec extends Specification {
+
+    @AutoCleanup
+    HibernateDatastore datastore
+
+    private HibernateDatastore boot(boolean generated, List<Class> classes) {
+        datastore?.close()
+        long stamp = System.nanoTime()
+        datastore = new HibernateDatastore(DatastoreUtils.createPropertyResolver([
+                'dataSource.url'                  : "jdbc:h2:mem:gdcMdDefault${generated}${stamp};LOCK_TIMEOUT=10000".toString(),
+                'dataSource.dbCreate'             : 'create-drop',
+                'dataSource.dialect'              : H2Dialect.name,
+                'dataSources.second.url'          : "jdbc:h2:mem:gdcMdSecond${generated}${stamp};LOCK_TIMEOUT=10000".toString(),
+                'dataSources.second.dbCreate'     : 'create-drop',
+                'hibernate.hbm2ddl.auto'          : 'create-drop',
+                'hibernate.cache.queries'         : 'false',
+                'hibernate.cache.use_query_cache' : 'false',
+                'hibernate.generatedDomainClasses': generated,
+        ]), classes as Class[])
+        return datastore
+    }
+
+    private static Map<String, List<String>> tables(javax.sql.DataSource dataSource) {
+        Map<String, List<String>> result = new TreeMap<String, List<String>>()
+        dataSource.connection.withCloseable { java.sql.Connection connection ->
+            connection.createStatement().withCloseable { java.sql.Statement statement ->
+                statement.executeQuery('''select c.table_name, c.column_name, c.data_type, c.is_nullable,
+                        (select count(*) from information_schema.table_constraints t
+                          where t.table_name = c.table_name and t.constraint_type = 'FOREIGN KEY')
+                        from information_schema.columns c where c.table_schema = 'PUBLIC'
+                        order by c.table_name, c.column_name''').with { java.sql.ResultSet rs ->
+                    while (rs.next()) {
+                        result.computeIfAbsent(rs.getString(1)) { new ArrayList<String>() }
+                                .add("${rs.getString(2)} ${rs.getString(3)} ${rs.getString(4)} fks=${rs.getInt(5)}".toString())
+                    }
+                }
+            }
+        }
+        return result
+    }
+
+    private Map scenario(boolean generated) {
+        HibernateDatastore booted = boot(generated, [GdcMdPrimary, GdcMdSecond, GdcMdSecondChild, GdcMdAll])
+        Map result = [:]
+        GdcMdPrimary.withTransaction { new GdcMdPrimary(name: 'primary').save(flush: true, failOnError: true) }
+        GdcMdSecond.withTransaction {
+            GdcMdSecond second = new GdcMdSecond(name: 'second')
+            second.addToChildren(new GdcMdSecondChild(label: 'child'))
+            second.save(flush: true, failOnError: true)
+        }
+        GdcMdAll.withTransaction { new GdcMdAll(name: 'in the default').save(flush: true, failOnError: true) }
+        GdcMdAll.second.withTransaction { new GdcMdAll(name: 'in the second').save(flush: true, failOnError: true) }
+        result.counts = [
+                primary    : GdcMdPrimary.withNewSession { GdcMdPrimary.count() },
+                second     : GdcMdSecond.withNewSession { GdcMdSecond.count() },
+                child      : GdcMdSecond.withNewSession { GdcMdSecond.first().children*.label },
+                allDefault : GdcMdAll.withNewSession { GdcMdAll.list()*.name },
+                allSecond  : GdcMdAll.second.withNewSession { GdcMdAll.second.list()*.name },
+        ]
+        result.urls = [
+                primary: GdcMdPrimary.withNewSession { Session s -> s.doReturningWork { it.metaData.getURL() } }.replaceAll(/(true|false)\d+$/, ''),
+                second : GdcMdSecond.withNewSession { Session s -> s.doReturningWork { it.metaData.getURL() } }.replaceAll(/(true|false)\d+$/, ''),
+        ]
+        result.defaultTables = tables(booted.connectionSources.defaultConnectionSource.dataSource)
+        result.secondTables = tables(booted.connectionSources.getConnectionSource('second').dataSource)
+        result.mapped = [
+                default: booted.getDatastoreForConnection('dataSource').sessionFactory.metamodel.entities*.javaType*.simpleName.sort(),
+                second : booted.getDatastoreForConnection('second').sessionFactory.metamodel.entities*.javaType*.simpleName.sort(),
+        ]
+        return result
+    }
+
+    def "each entity lives in the data source it is mapped to, and the databases are built like binder mode builds them"() {
+        when:
+        Map binder = scenario(false)
+        Map generated = scenario(true)
+
+        then: 'the rows are in the right database, through the named data source api and through a lazy collection'
+        generated.counts == [
+                primary   : 1, second: 1, child: ['child'],
+                allDefault: ['in the default'], allSecond: ['in the second'],
+        ]
+
+        and: 'each session factory maps only its entities, as real classes'
+        generated.mapped == [
+                default: ['GdcMdAll', 'GdcMdPrimary'],
+                second : ['GdcMdAll', 'GdcMdSecond', 'GdcMdSecondChild'],
+        ]
+        generated.mapped == binder.mapped
+
+        and: 'the tables, columns and foreign keys of both databases equal binder mode'
+        generated.defaultTables.keySet() == ['GDC_MD_ALL', 'GDC_MD_PRIMARY'] as Set
+        generated.secondTables.keySet() == ['GDC_MD_ALL', 'GDC_MD_SECOND', 'GDC_MD_SECOND_CHILD'] as Set
+        generated.defaultTables == binder.defaultTables
+        generated.secondTables == binder.secondTables
+        generated.counts == binder.counts
+        generated.urls == binder.urls
+    }
+
+    def "a data source added at run time gets the generated classes of the entities mapped to all data sources"() {
+        given:
+        boot(true, [GdcMdPrimary, GdcMdAll])
+
+        when:
+        datastore.connectionSources.addConnectionSource('late', [
+                dbCreate: 'create-drop',
+                url     : "jdbc:h2:mem:gdcMdLate${System.nanoTime()};LOCK_TIMEOUT=10000".toString(),
+        ])
+        GdcMdAll.late.withTransaction { new GdcMdAll(name: 'late').save(flush: true, failOnError: true) }
+
+        then:
+        GdcMdAll.late.withNewSession { GdcMdAll.late.list()*.name } == ['late']
+        GdcMdAll.withNewSession { GdcMdAll.count() } == 0
+        datastore.getDatastoreForConnection('late').sessionFactory.metamodel.entity(GdcMdAll).javaType == GdcMdAll
+    }
+
+    def "an association across data sources is refused by name, as a mapping exception, in #mode mode"() {
+        when:
+        boot(generated, [CrossDataSourceRef, GdcMdSecond, GdcMdSecondChild])
+
+        then:
+        Exception e = thrown()
+        Throwable root = e
+        while (root.cause != null) {
+            root = root.cause
+        }
+        root instanceof MappingException
+        root.message.contains(GdcMdSecond.name)
+        generated ? (root.message.contains(CrossDataSourceRef.name) && root.message.contains('data source')) : root.message.contains('cross_data_source_ref')
+
+        where:
+        generated | mode
+        false     | 'binder'
+        true      | 'generated'
+    }
+}
+
+@Entity
+class GdcMdPrimary {
+    String name
+}
+
+@Entity
+class GdcMdSecond {
+    String name
+    static hasMany = [children: GdcMdSecondChild]
+    static mapping = {
+        datasource 'second'
+    }
+}
+
+@Entity
+class GdcMdSecondChild {
+    String label
+    static belongsTo = [parent: GdcMdSecond]
+    static mapping = {
+        datasource 'second'
+    }
+}
+
+@Entity
+class GdcMdAll {
+    String name
+    static mapping = {
+        datasource 'ALL'
+    }
+}
diff --git a/grails-data-hibernate7/core/src/test/groovy/grails/unbootable/CrossDataSourceDomains.groovy b/grails-data-hibernate7/core/src/test/groovy/grails/unbootable/CrossDataSourceDomains.groovy
new file mode 100644
index 0000000000..9ed4da54eb
--- /dev/null
+++ b/grails-data-hibernate7/core/src/test/groovy/grails/unbootable/CrossDataSourceDomains.groovy
@@ -0,0 +1,34 @@
+/*
+ *  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.unbootable
+
+import grails.gorm.annotation.Entity
+import grails.gorm.tests.generated.GdcMdSecond
+
+/**
+ * A domain class on the default data source that refers to one mapped to another data source, kept outside the packages the
+ * differential specs scan: the session factory of a data source maps its own entities only, so no mode can boot it.
+ */
+@Entity
+class CrossDataSourceRef {
+
+    String name
+
+    GdcMdSecond other
+}


(grails-core) 06/14: fix: bind a lazy true embedded property as lazy in generated classes

Posted by bo...@apache.org.
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 af47a25b0f3b58ff63f3f7aee2022f42ba897cfc
Author: Walter Duque de Estrada <wb...@mac.com>
AuthorDate: Tue Oct 6 10:25:49 2026 -0500

    fix: bind a lazy true embedded property as lazy in generated classes
    
    The domain binder marks the property of an embedded object lazy when the mapping says
    lazy: true. Hibernate's annotation binder ignores @Basic(fetch = LAZY) on an @Embedded
    (checked by reading the generated classes back), so the generator used to reject the entity.
    It is accepted now and the binder of the generated classes sets the flag on the bound
    property; a real-boot spec reads it from the session factory's metadata. The REFUSALS entry
    of the DDL differential is gone.
    
    Co-Authored-By: Claude Sonnet 5.5 <no...@anthropic.com>
---
 .../jpa/GeneratedDomainClassBinder.groovy          |  5 ++
 .../domainbinding/jpa/GrailsDomainGenerator.groovy |  4 -
 .../GeneratedDomainClassesLazyEmbeddedSpec.groovy  | 86 ++++++++++++++++++++++
 ...eneratedDomainClassesDdlDifferentialSpec.groovy |  1 -
 .../GrailsDomainGeneratorLazyPropertySpec.groovy   | 22 +++---
 .../GrailsDomainGeneratorOptionCoverageSpec.groovy | 11 ++-
 6 files changed, 111 insertions(+), 18 deletions(-)

diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GeneratedDomainClassBinder.groovy b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GeneratedDomainClassBinder.groovy
index 9e57d5c928..e426541feb 100644
--- a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GeneratedDomainClassBinder.groovy
+++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GeneratedDomainClassBinder.groovy
@@ -362,6 +362,11 @@ class GeneratedDomainClassBinder implements SessionFactoryBuilderFactory {
             alignCollectionTable((Collection) property.value, (HibernatePersistentProperty) persistentProperty)
         }
         if (property.value instanceof Component && persistentProperty instanceof Embedded) {
+            // PropertyBinder marks the property lazy when the mapping says lazy: true; @Basic(fetch = LAZY) on an @Embedded is ignored
+            // by Hibernate's annotation binder, so the mapping's flag is set on the bound property
+            if (persistentProperty instanceof HibernatePersistentProperty && ((HibernatePersistentProperty) persistentProperty).isLazy()) {
+                property.lazy = true
+            }
             PersistentEntity embedded = ((Embedded<?>) persistentProperty).associatedEntity
             if (embedded instanceof GrailsHibernatePersistentEntity) {
                 alignComponent(
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 cd33c05917..93174b4979 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
@@ -2029,10 +2029,6 @@ class GrailsDomainGenerator {
         if (!type.isRoot()) {
             return "the embedded type [${type.name}] extends another persistent class"
         }
-        if (property.isLazy()) {
-            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'
diff --git a/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/generated/GeneratedDomainClassesLazyEmbeddedSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/generated/GeneratedDomainClassesLazyEmbeddedSpec.groovy
new file mode 100644
index 0000000000..e723f88e29
--- /dev/null
+++ b/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/generated/GeneratedDomainClassesLazyEmbeddedSpec.groovy
@@ -0,0 +1,86 @@
+/*
+ * 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 grails.gorm.tests.HibernateGormDatastoreSpec
+import grails.persistence.Entity
+import org.hibernate.mapping.PersistentClass
+
+/**
+ * An embedded property mapped {@code lazy: true} is bound as a lazy attribute, as the domain binder binds it. Hibernate's
+ * annotation binder ignores {@code @Basic(fetch = LAZY)} on an {@code @Embedded}, so the generated-class binding sets the flag
+ * on the bound property; the spec reads it from the metadata of a real boot and checks the data still round-trips.
+ */
+class GeneratedDomainClassesLazyEmbeddedSpec extends HibernateGormDatastoreSpec {
+
+    void setupSpec() {
+        registerGeneratedDomainClasses(GdcLazyHome, GdcPlainHome)
+    }
+
+    def "an embedded property mapped lazy is a lazy property of the bound entity, one mapped by default is not"() {
+        when:
+        PersistentClass lazyHome = datastore.metadata.getEntityBinding(GdcLazyHome.name)
+        PersistentClass plainHome = datastore.metadata.getEntityBinding(GdcPlainHome.name)
+
+        then:
+        lazyHome.getProperty('address').lazy
+        !plainHome.getProperty('address').lazy
+    }
+
+    def "the embedded values of a lazy embedded property round-trip"() {
+        given:
+        new GdcLazyHome(name: 'h', address: new GdcAddress(street: 's', city: 'c')).save(flush: true)
+        sessionFactory.currentSession.clear()
+
+        when:
+        GdcLazyHome loaded = GdcLazyHome.findByName('h')
+
+        then:
+        loaded.address.street == 's'
+        loaded.address.city == 'c'
+    }
+}
+
+@Entity
+class GdcLazyHome {
+
+    String name
+    GdcAddress address
+
+    static embedded = ['address']
+
+    static mapping = {
+        address lazy: true
+    }
+}
+
+@Entity
+class GdcPlainHome {
+
+    String name
+    GdcAddress address
+
+    static embedded = ['address']
+}
+
+class GdcAddress {
+
+    String street
+    String city
+}
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 0ea4d5e793..64978fe5d2 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
@@ -86,7 +86,6 @@ class GeneratedDomainClassesDdlDifferentialSpec extends Specification {
             'a type is mapped on the collection property itself',
             'an index or a unique group is mapped on the collection property',
             'is a collection inside an embedded type',
-            'the property is mapped lazy: true',
             'declares a natural id but is a subclass',
             'which is HibernateEmbeddedProperty',
             'which is not a persistent property of the entity',
diff --git a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorLazyPropertySpec.groovy b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorLazyPropertySpec.groovy
index 260c17e0fd..0b600fcfff 100644
--- a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorLazyPropertySpec.groovy
+++ b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorLazyPropertySpec.groovy
@@ -29,8 +29,8 @@ import org.grails.orm.hibernate.cfg.domainbinding.hibernate.GrailsHibernatePersi
 /**
  * Describes how {@link GrailsDomainGenerator} states {@code lazy: true} on a simple or enum property: the binder marks
  * the Hibernate property lazy (a lazy attribute, which Hibernate loads on first access when the class is enhanced), and
- * the annotation for that is {@code @Basic(fetch = LAZY)}. Where the binder sets it and annotations cannot say it, the
- * generator rejects the entity by name.
+ * the annotation for that is {@code @Basic(fetch = LAZY)}. Hibernate's annotation binder ignores that annotation on an
+ * {@code @Embedded}, so a lazy embedded property is accepted and marked lazy when the generated classes are bound.
  */
 class GrailsDomainGeneratorLazyPropertySpec extends GrailsDomainGeneratorSupport {
 
@@ -60,15 +60,17 @@ class GrailsDomainGeneratorLazyPropertySpec extends GrailsDomainGeneratorSupport
         bound.getProperty('big').lazy
     }
 
-    void "lazy on an embedded property is rejected by name"() {
-        when:
-        generateGroup(GenLazyEmbedded)
+    void "a lazy embedded property is accepted: annotations cannot state it, so the binder of the generated classes sets it"() {
+        given:
+        Map<GrailsHibernatePersistentEntity, Class<?>> classes = generateGroup(GenLazyEmbedded)
+        Metadata metadata = annotationMetadata(classes.values())
+        PersistentClass bound = entity(GenLazyEmbedded).persistentClass
+        PersistentClass read = metadata.getEntityBinding(classes[entity(GenLazyEmbedded)].name)
 
-        then:
-        UnsupportedOperationException e = thrown()
-        e.message.contains('GenLazyEmbedded')
-        e.message.contains('home')
-        e.message.contains('lazy')
+        expect:
+        bound.getProperty('home').lazy
+        !read.getProperty('home').lazy
+        !classes.values().first().getDeclaredField('home').isAnnotationPresent(Basic)
     }
 }
 
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
index 32803eccf2..c1e5f42ab6 100644
--- 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
@@ -37,6 +37,7 @@ import org.grails.orm.hibernate.cfg.NaturalId
 import org.grails.orm.hibernate.cfg.PropertyConfig
 import org.grails.orm.hibernate.cfg.domainbinding.hibernate.GrailsHibernatePersistentEntity
 import org.grails.orm.hibernate.cfg.domainbinding.hibernate.HibernateBasicProperty
+import org.grails.orm.hibernate.cfg.domainbinding.hibernate.HibernateEmbeddedProperty
 import org.grails.orm.hibernate.cfg.domainbinding.hibernate.HibernatePersistentProperty
 import org.grails.orm.hibernate.cfg.domainbinding.hibernate.HibernateToManyEntityProperty
 
@@ -404,7 +405,8 @@ class GrailsDomainGeneratorOptionCoverageSpec extends GrailsDomainGeneratorSuppo
 
     /**
      * What the generator decides for the group: the generated classes (annotations and fields) and the facets that no annotation
-     * carries but the binding of the generated classes applies to the bound collections, such as an extra-lazy collection.
+     * carries but the binding of the generated classes applies to the bound properties: an extra-lazy collection and a lazy
+     * embedded property.
      */
     private String signature() {
         Map<GrailsHibernatePersistentEntity, Class<?>> classes = generateGroup(GenCovOwner, GenCovTarget)
@@ -413,15 +415,18 @@ class GrailsDomainGeneratorOptionCoverageSpec extends GrailsDomainGeneratorSuppo
             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(';') + '#' + extraLazyCollections(generator, entity)
+                    }.join(';') + '#' + appliedAfterBinding(generator, entity)
         }.join('\n')
     }
 
-    private static String extraLazyCollections(GrailsDomainGenerator generator, GrailsHibernatePersistentEntity entity) {
+    private static String appliedAfterBinding(GrailsDomainGenerator generator, GrailsHibernatePersistentEntity entity) {
         return entity.hibernatePersistentProperties.findAll { HibernatePersistentProperty property ->
             if (property instanceof HibernateBasicProperty && generator.supports(property)) {
                 return generator.collectionFacets((HibernateBasicProperty) property).extraLazy()
             }
+            if (property instanceof HibernateEmbeddedProperty) {
+                return generator.supports(property) && property.isLazy()
+            }
             return property instanceof HibernateToManyEntityProperty && generator.supports(property) &&
                     generator.toManyFacets((HibernateToManyEntityProperty) property).extraLazy()
         }*.name.sort().join(',')


(grails-core) 12/14: test: run schema and database per tenant over the generated classes and compare with binder mode

Posted by bo...@apache.org.
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 035844691924fafabd2f0d720479ebd7c6ce23f0
Author: Walter Duque de Estrada <wb...@mac.com>
AuthorDate: Tue Oct 6 11:52:31 2026 -0500

    test: run schema and database per tenant over the generated classes and compare with binder mode
    
    Co-Authored-By: Claude Sonnet 5.5 <no...@anthropic.com>
---
 .../GeneratedDomainClassesTenancyModesSpec.groovy  | 274 +++++++++++++++++++++
 1 file changed, 274 insertions(+)

diff --git a/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/generated/GeneratedDomainClassesTenancyModesSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/generated/GeneratedDomainClassesTenancyModesSpec.groovy
new file mode 100644
index 0000000000..02a5d7285c
--- /dev/null
+++ b/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/generated/GeneratedDomainClassesTenancyModesSpec.groovy
@@ -0,0 +1,274 @@
+/*
+ *  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 grails.gorm.MultiTenant
+import grails.gorm.annotation.Entity
+import grails.gorm.multitenancy.Tenants
+import org.hibernate.dialect.H2Dialect
+import spock.lang.AutoCleanup
+import spock.lang.Specification
+import spock.util.environment.RestoreSystemProperties
+
+import org.grails.datastore.mapping.core.DatastoreUtils
+import org.grails.datastore.mapping.multitenancy.AllTenantsResolver
+import org.grails.datastore.mapping.multitenancy.MultiTenancySettings
+import org.grails.datastore.mapping.multitenancy.resolvers.SystemPropertyTenantResolver
+import org.grails.orm.hibernate.HibernateDatastore
+
+/**
+ * Schema and database per tenant over the generated mapping: every tenant has its own session factory, built from the same
+ * generated classes, and its tables are those the domain binder creates. Discriminator tenancy has its own spec.
+ */
+@RestoreSystemProperties
+class GeneratedDomainClassesTenancyModesSpec extends Specification {
+
+    @AutoCleanup
+    HibernateDatastore datastore
+
+    static List<String> tenants = ['north', 'south']
+
+    def setup() {
+        tenants = ['north', 'south']
+    }
+
+    private HibernateDatastore boot(boolean generated, MultiTenancySettings.MultiTenancyMode mode, Map extra = [:]) {
+        datastore?.close()
+        System.setProperty(SystemPropertyTenantResolver.PROPERTY_NAME, 'north')
+        datastore = new HibernateDatastore(DatastoreUtils.createPropertyResolver([
+                'grails.gorm.multiTenancy.mode'               : mode,
+                'grails.gorm.multiTenancy.tenantResolverClass': GdcTenantsResolver,
+                'dataSource.url'                              : "jdbc:h2:mem:gdcTenancy${generated}${System.nanoTime()};LOCK_TIMEOUT=10000".toString(),
+                'dataSource.dbCreate'                         : 'create',
+                'dataSource.dialect'                          : H2Dialect.name,
+                'hibernate.hbm2ddl.auto'                      : 'create',
+                'hibernate.cache.queries'                     : 'false',
+                'hibernate.cache.use_query_cache'             : 'false',
+                'hibernate.generatedDomainClasses'            : generated,
+        ] + extra), GdcTenantAuthor, GdcTenantBook)
+        return datastore
+    }
+
+    private static Map<String, List<String>> tablesAndColumns(javax.sql.DataSource dataSource) {
+        Map<String, List<String>> result = new TreeMap<String, List<String>>()
+        dataSource.connection.withCloseable { java.sql.Connection connection ->
+            connection.createStatement().withCloseable { java.sql.Statement statement ->
+                statement.executeQuery('''select table_schema, table_name, column_name, data_type, is_nullable
+                        from information_schema.columns where table_schema not in ('INFORMATION_SCHEMA')
+                        order by table_schema, table_name, column_name''').with { java.sql.ResultSet rs ->
+                    while (rs.next()) {
+                        String key = "${rs.getString(1)}.${rs.getString(2)}".toString()
+                        result.computeIfAbsent(key) { new ArrayList<String>() }
+                                .add("${rs.getString(3)} ${rs.getString(4)} ${rs.getString(5)}".toString())
+                    }
+                }
+            }
+        }
+        return result
+    }
+
+    private static Map<String, Integer> constraints(javax.sql.DataSource dataSource) {
+        Map<String, Integer> result = new TreeMap<String, Integer>()
+        dataSource.connection.withCloseable { java.sql.Connection connection ->
+            connection.createStatement().withCloseable { java.sql.Statement statement ->
+                statement.executeQuery('''select table_schema, table_name, constraint_type, count(*)
+                        from information_schema.table_constraints where table_schema not in ('INFORMATION_SCHEMA')
+                        group by table_schema, table_name, constraint_type''').with { java.sql.ResultSet rs ->
+                    while (rs.next()) {
+                        result["${rs.getString(1)}.${rs.getString(2)} ${rs.getString(3)}".toString()] = rs.getInt(4)
+                    }
+                }
+            }
+        }
+        return result
+    }
+
+    private Map scenario(boolean generated) {
+        HibernateDatastore booted = boot(generated, MultiTenancySettings.MultiTenancyMode.SCHEMA)
+        Map result = [:]
+        ['north', 'south'].each { String tenant ->
+            Tenants.withId(tenant) {
+                GdcTenantAuthor.withTransaction {
+                    GdcTenantAuthor author = new GdcTenantAuthor(name: "author of ${tenant}".toString())
+                    author.addToBooks(new GdcTenantBook(title: "${tenant} one".toString()))
+                    author.addToBooks(new GdcTenantBook(title: "${tenant} two".toString()))
+                    author.save(flush: true, failOnError: true)
+                }
+            }
+        }
+        Tenants.withId('south') {
+            GdcTenantAuthor.withTransaction {
+                GdcTenantAuthor.first().addToBooks(new GdcTenantBook(title: 'south three')).save(flush: true, failOnError: true)
+            }
+        }
+        result.counts = ['north', 'south'].collectEntries { String tenant ->
+            [tenant, Tenants.withId(tenant) {
+                GdcTenantAuthor.withNewSession {
+                    [GdcTenantAuthor.count(), GdcTenantBook.count(), GdcTenantAuthor.first().books*.title.sort()]
+                }
+            }]
+        }
+        tenants = ['north', 'south', 'east']
+        booted.addTenantForSchema('east')
+        result.east = Tenants.withId('east') { GdcTenantAuthor.withNewSession { GdcTenantAuthor.count() } }
+        Map each = [:]
+        GdcTenantAuthor.eachTenant { String tenantId -> each[tenantId] = GdcTenantAuthor.count() }
+        result.each = each
+        result.tables = tablesAndColumns(booted.connectionSources.defaultConnectionSource.dataSource)
+        result.constraints = constraints(booted.connectionSources.defaultConnectionSource.dataSource)
+        return result
+    }
+
+    def "schema per tenant isolates the rows of each tenant and builds each tenant's tables like binder mode"() {
+        when:
+        Map binder = scenario(false)
+        Map generated = scenario(true)
+
+        then: 'rows stay in their tenant, through a lazy collection too'
+        generated.counts == [north: [1, 2, ['north one', 'north two']], south: [1, 3, ['south one', 'south three', 'south two']]]
+        generated.east == 0
+        generated.each == [north: 1, south: 1, east: 0]
+
+        and: 'every tenant schema has the tables, columns and constraints binder mode creates'
+        generated.tables.keySet().any { it.startsWith('north.') }
+        generated.tables.keySet().any { it.startsWith('south.') }
+        generated.tables.keySet().any { it.startsWith('east.') }
+        generated.tables == binder.tables
+        generated.constraints == binder.constraints
+        generated.counts == binder.counts
+        generated.each == binder.each
+    }
+
+    def "a session factory of a tenant maps the real domain classes under their own names"() {
+        when:
+        HibernateDatastore booted = boot(true, MultiTenancySettings.MultiTenancyMode.SCHEMA)
+        HibernateDatastore north = booted.getDatastoreForConnection('north')
+
+        then:
+        [booted, north].every { HibernateDatastore each ->
+            each.sessionFactory.mappingMetamodel.getEntityDescriptor(GdcTenantAuthor).entityName == GdcTenantAuthor.name &&
+                    each.sessionFactory.metamodel.entity(GdcTenantAuthor).javaType == GdcTenantAuthor &&
+                    each.sessionFactory.mappingMetamodel.getEntityDescriptor(GdcTenantBook).mappedClass == GdcTenantBook
+        }
+    }
+
+    def "tables with and without an explicit schema land in the schema binder mode puts them in, under a default schema"() {
+        when:
+        Map results = [false, true].collectEntries { boolean generated ->
+            datastore?.close()
+            datastore = new HibernateDatastore(DatastoreUtils.createPropertyResolver([
+                    'dataSource.url'                  : "jdbc:h2:mem:gdcSchemas${generated}${System.nanoTime()};LOCK_TIMEOUT=10000;INIT=CREATE SCHEMA IF NOT EXISTS gdc_default\\;CREATE SCHEMA IF NOT EXISTS gdc_shared".toString(),
+                    'dataSource.dbCreate'             : 'create',
+                    'dataSource.dialect'              : H2Dialect.name,
+                    'hibernate.hbm2ddl.auto'          : 'create',
+                    'hibernate.default_schema'        : 'gdc_default',
+                    'hibernate.generatedDomainClasses': generated,
+            ]), GdcSchemaPlain, GdcSchemaOwner, GdcSchemaChild)
+            GdcSchemaOwner.withTransaction {
+                GdcSchemaOwner owner = new GdcSchemaOwner(name: 'owner')
+                owner.tags = ['a', 'b'] as Set
+                owner.addToChildren(new GdcSchemaChild(label: 'child'))
+                owner.save(flush: true, failOnError: true)
+                new GdcSchemaPlain(name: 'plain').save(flush: true, failOnError: true)
+            }
+            Map found = GdcSchemaOwner.withNewSession {
+                GdcSchemaOwner owner = GdcSchemaOwner.first()
+                [tags: owner.tags.sort(), children: owner.children*.label, plain: GdcSchemaPlain.count()]
+            }
+            [generated, [found: found, tables: tablesAndColumns(datastore.connectionSources.defaultConnectionSource.dataSource)]]
+        }
+
+        then:
+        results[true].found == [tags: ['a', 'b'], children: ['child'], plain: 1]
+        results[true].tables.keySet() == ['GDC_DEFAULT.GDC_SCHEMA_CHILD', 'GDC_DEFAULT.GDC_SCHEMA_PLAIN', 'GDC_SHARED.GDC_SCHEMA_OWNER', 'GDC_SHARED.GDC_SCHEMA_OWNER_TAGS'] as Set
+        results[true].tables == results[false].tables
+    }
+
+    def "database per tenant isolates the rows of each tenant and builds each database like binder mode"() {
+        when:
+        Map results = [false, true].collectEntries { boolean generated ->
+            HibernateDatastore booted = boot(generated, MultiTenancySettings.MultiTenancyMode.DATABASE, [
+                    'dataSources.north.url': "jdbc:h2:mem:gdcTenancyNorth${generated}${System.nanoTime()};LOCK_TIMEOUT=10000".toString(),
+                    'dataSources.south.url': "jdbc:h2:mem:gdcTenancySouth${generated}${System.nanoTime()};LOCK_TIMEOUT=10000".toString(),
+            ])
+            ['north', 'south'].each { String tenant ->
+                Tenants.withId(tenant) {
+                    GdcTenantAuthor.withTransaction {
+                        GdcTenantAuthor author = new GdcTenantAuthor(name: "author of ${tenant}".toString())
+                        author.addToBooks(new GdcTenantBook(title: "${tenant} one".toString()))
+                        author.save(flush: true, failOnError: true)
+                    }
+                }
+            }
+            Map counts = ['north', 'south'].collectEntries { String tenant ->
+                [tenant, Tenants.withId(tenant) {
+                    GdcTenantAuthor.withNewSession { [GdcTenantAuthor.count(), GdcTenantAuthor.first().books*.title] }
+                }]
+            }
+            [generated, [counts: counts,
+                         north: tablesAndColumns(booted.connectionSources.getConnectionSource('north').dataSource),
+                         south: constraints(booted.connectionSources.getConnectionSource('south').dataSource)]]
+        }
+
+        then:
+        results[true].counts == [north: [1, ['north one']], south: [1, ['south one']]]
+        results[true].north.keySet().any { it.endsWith('.GDC_TENANT_AUTHOR') }
+        results[true] == results[false]
+    }
+}
+
+@Entity
+class GdcSchemaPlain {
+    String name
+}
+
+@Entity
+class GdcSchemaOwner {
+    String name
+    static hasMany = [tags: String, children: GdcSchemaChild]
+    static mapping = {
+        table schema: 'gdc_shared'
+    }
+}
+
+@Entity
+class GdcSchemaChild {
+    String label
+    static belongsTo = [owner: GdcSchemaOwner]
+}
+
+class GdcTenantsResolver extends SystemPropertyTenantResolver implements AllTenantsResolver {
+
+    @Override
+    Iterable<Serializable> resolveTenantIds() {
+        return GeneratedDomainClassesTenancyModesSpec.tenants as List<Serializable>
+    }
+}
+
+@Entity
+class GdcTenantAuthor implements MultiTenant<GdcTenantAuthor> {
+    String name
+    static hasMany = [books: GdcTenantBook]
+}
+
+@Entity
+class GdcTenantBook implements MultiTenant<GdcTenantBook> {
+    String title
+    static belongsTo = [author: GdcTenantAuthor]
+}


(grails-core) 11/14: test: compare the Liquibase snapshot and changelog of generated mode with binder mode

Posted by bo...@apache.org.
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 e1871f83bf6ed264b299aa5037d3a20f9c339aab
Author: Walter Duque de Estrada <wb...@mac.com>
AuthorDate: Tue Oct 6 11:46:14 2026 -0500

    test: compare the Liquibase snapshot and changelog of generated mode with binder mode
    
    Co-Authored-By: Claude Sonnet 5.5 <no...@anthropic.com>
---
 .../generated/GeneratedModeSnapshotSpec.groovy     | 313 +++++++++++++++++++++
 1 file changed, 313 insertions(+)

diff --git a/grails-data-hibernate7/dbmigration/src/test/groovy/org/grails/plugins/databasemigration/generated/GeneratedModeSnapshotSpec.groovy b/grails-data-hibernate7/dbmigration/src/test/groovy/org/grails/plugins/databasemigration/generated/GeneratedModeSnapshotSpec.groovy
new file mode 100644
index 0000000000..ec32c4f3d9
--- /dev/null
+++ b/grails-data-hibernate7/dbmigration/src/test/groovy/org/grails/plugins/databasemigration/generated/GeneratedModeSnapshotSpec.groovy
@@ -0,0 +1,313 @@
+/*
+ *  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.plugins.databasemigration.generated
+
+import grails.gorm.annotation.Entity
+import liquibase.CatalogAndSchema
+import liquibase.Liquibase
+import liquibase.database.Database
+import liquibase.database.core.H2Database
+import liquibase.database.jvm.JdbcConnection
+import liquibase.diff.DiffResult
+import liquibase.diff.compare.CompareControl
+import liquibase.diff.output.DiffOutputControl
+import liquibase.diff.output.changelog.DiffToChangeLog
+import liquibase.resource.ClassLoaderResourceAccessor
+import liquibase.serializer.core.xml.XMLChangeLogSerializer
+import liquibase.snapshot.DatabaseSnapshot
+import liquibase.snapshot.SnapshotControl
+import liquibase.snapshot.SnapshotGeneratorFactory
+import liquibase.structure.DatabaseObject
+import liquibase.structure.core.Column
+import liquibase.structure.core.ForeignKey
+import liquibase.structure.core.Index
+import liquibase.structure.core.PrimaryKey
+import liquibase.structure.core.Sequence
+import liquibase.structure.core.Table
+import liquibase.structure.core.UniqueConstraint
+import org.hibernate.dialect.H2Dialect
+import spock.lang.AutoCleanup
+import spock.lang.Specification
+
+import org.grails.orm.hibernate.HibernateDatastore
+import org.grails.plugins.databasemigration.liquibase.GormDatabase
+
+/**
+ * The Liquibase snapshot and the changelog that dbm-gorm-diff and dbm-generate-gorm-changelog build from the Hibernate
+ * mapping model are the same whether the domain binder or the generated domain classes bound it: tables, columns and
+ * their types, primary keys, foreign keys, unique constraints, indexes and sequences. The differences that remain are
+ * the known ones of the generated mode, each pinned by its own feature.
+ */
+class GeneratedModeSnapshotSpec extends Specification {
+
+    private static final List<Class> BASE = [
+            SnapAuthor, SnapBook, SnapVehicle, SnapCar, SnapAccount, SnapSavings, SnapShape, SnapCircle, SnapSequenced, SnapPlain]
+
+    private static final List<Class> ALL = BASE + [SnapStudent, SnapCourse, SnapEnrollment, SnapNatural]
+
+    @AutoCleanup
+    HibernateDatastore datastore
+
+    private GormDatabase boot(boolean generated, List<Class> classes) {
+        datastore?.close()
+        datastore = new HibernateDatastore([
+                'dataSource.url'                  : "jdbc:h2:mem:snapSpec${generated}${System.nanoTime()};LOCK_TIMEOUT=10000;DB_CLOSE_DELAY=-1".toString(),
+                'dataSource.dialect'              : H2Dialect.name,
+                'dataSource.dbCreate'             : 'none',
+                'hibernate.generatedDomainClasses': generated,
+        ], classes as Class[])
+        return new GormDatabase(new H2Dialect(), datastore)
+    }
+
+    private static List<String> snapshotLines(GormDatabase database) {
+        DatabaseSnapshot snapshot = SnapshotGeneratorFactory.instance.createSnapshot(
+                new CatalogAndSchema(null, null), database,
+                new SnapshotControl(database, Table, Column, PrimaryKey, ForeignKey, UniqueConstraint, Index, Sequence))
+        List<String> lines = []
+        [Table, Column, PrimaryKey, ForeignKey, UniqueConstraint, Index, Sequence].each { Class<? extends DatabaseObject> type ->
+            for (DatabaseObject object : snapshot.get(type)) {
+                Map<String, String> attributes = new TreeMap<String, String>()
+                for (String name : object.attributes) {
+                    if (name != 'snapshotId' && name != 'schema' && name != 'name') {
+                        Object value = object.getAttribute(name, Object)
+                        attributes[name] = value instanceof Collection ?
+                                value.collect { String.valueOf(it) }.sort().toString() : String.valueOf(value)
+                    }
+                }
+                // Liquibase names the index behind a constraint with four random letters
+                lines << "${type.simpleName} ${object.name} ${attributes}".toString().replaceAll(/_[A-Z]{4}_IX/, '_XXXX_IX')
+            }
+        }
+        return lines.sort()
+    }
+
+    /**
+     * The known differences that the features below pin are hidden: the order of the columns of a primary key and the
+     * name Hibernate gives the unique key of a natural id.
+     */
+    private static List<String> withoutKnownDifferences(List<String> lines) {
+        return lines.collect { String line ->
+            line.replaceAll(/(?i)(on [\w.]+)\(([^)]*)\)/) { List<String> match ->
+                match[1] + '(' + match[2].split(', ').toList().sort().join(', ') + ')'
+            }.replaceAll(/UK[0-9a-z]{20,}/, 'UK<hash>')
+        }.sort()
+    }
+
+    private static String changeLog(GormDatabase reference) {
+        Class.forName('org.h2.Driver')
+        java.sql.Connection connection = java.sql.DriverManager.getConnection("jdbc:h2:mem:snapTarget${System.nanoTime()}", 'SA', '')
+        Database target = new H2Database()
+        target.setConnection(new JdbcConnection(connection))
+        try {
+            Set<Class<? extends DatabaseObject>> types = [Table, Column, PrimaryKey, ForeignKey, UniqueConstraint, Index, Sequence] as Set
+            DiffResult diff = new Liquibase((String) null, new ClassLoaderResourceAccessor(), target)
+                    .diff(reference, target, new CompareControl(types))
+            ByteArrayOutputStream out = new ByteArrayOutputStream()
+            new DiffToChangeLog(diff, new DiffOutputControl(false, false, false, null))
+                    .print(new PrintStream(out), new XMLChangeLogSerializer())
+            return out.toString('UTF-8')
+        } finally {
+            target.close()
+        }
+    }
+
+    /**
+     * Each change set without its generated id, its lines in sorted order (the order of the columns of a table is a known
+     * difference), and the change sets sorted.
+     */
+    private static List<String> canonicalChangeLog(String xml) {
+        String stripped = xml.replaceAll(/ id="[^"]*"/, '').replaceAll(/_[A-Z]{4}_IX/, '_XXXX_IX')
+        return stripped.split(/<changeSet/).drop(1).collect { String block ->
+            block.readLines()*.trim().findAll { it }.sort().join(' | ')
+        }.sort()
+    }
+
+    def "the Liquibase snapshot of the generated mapping equals the snapshot of the binder mapping"() {
+        when:
+        List<String> binder = snapshotLines(boot(false, ALL))
+        List<String> generated = snapshotLines(boot(true, ALL))
+
+        then: 'the snapshot has every kind of object'
+        ['Table', 'Column', 'PrimaryKey', 'ForeignKey', 'UniqueConstraint', 'Index', 'Sequence'].every { String type ->
+            binder.any { it.startsWith(type + ' ') }
+        }
+        binder.size() > 100
+
+        and: 'nothing differs besides the known differences'
+        withoutKnownDifferences(binder) - withoutKnownDifferences(generated) == []
+        withoutKnownDifferences(generated) - withoutKnownDifferences(binder) == []
+    }
+
+    def "the primary key of a composite identifier lists its columns in the order Hibernate sorts them, and the natural id key has Hibernate's own name"() {
+        when:
+        List<String> binder = snapshotLines(boot(false, ALL))
+        List<String> generated = snapshotLines(boot(true, ALL))
+        Closure<String> primaryKey = { List<String> lines -> lines.find { it.startsWith('PrimaryKey snap_enrollmentPK') } }
+        Closure<String> naturalKey = { List<String> lines ->
+            (lines.find { it.startsWith('UniqueConstraint UK') && it.contains('snap_natural') } =~ /UniqueConstraint (UK\w+)/)[0][1]
+        }
+
+        then: 'the binder keeps the order of the mapping, Hibernate sorts the parts of the identifier'
+        primaryKey(binder).contains('ON HIBERNATE.snap_enrollment(student, course)')
+        primaryKey(generated).contains('ON HIBERNATE.snap_enrollment(course, student)')
+
+        and: 'both name the natural id key from its table and columns'
+        naturalKey(binder) != naturalKey(generated)
+    }
+
+    def "the changelog generated from the generated mapping equals the one generated from the binder mapping"() {
+        when:
+        List<String> binder = canonicalChangeLog(changeLog(boot(false, BASE)))
+        List<String> generated = canonicalChangeLog(changeLog(boot(true, BASE)))
+
+        then:
+        binder.size() > 15
+        binder.any { it.contains('<createSequence') }
+        binder.any { it.contains('autoIncrement="true"') }
+        binder.any { it.contains('<addForeignKeyConstraint') }
+        binder - generated == []
+        generated - binder == []
+    }
+}
+
+enum SnapStatus { OPEN, CLOSED }
+
+class SnapAddress {
+    String street
+    String city
+}
+
+@Entity
+class SnapAuthor {
+    String name
+    SnapStatus status
+    BigDecimal balance
+    SnapAddress home
+    static embedded = ['home']
+    static hasMany = [books: SnapBook]
+    static constraints = {
+        name maxSize: 50, nullable: false
+        balance scale: 2, max: 9999999999.99G
+    }
+    static mapping = {
+        name index: 'snap_author_name_idx'
+        status enumType: 'string'
+    }
+}
+
+@Entity
+class SnapBook {
+    String title
+    String isbn
+    static belongsTo = [author: SnapAuthor]
+    static hasMany = [tags: String]
+    static constraints = {
+        isbn unique: true, nullable: true
+    }
+}
+
+@Entity
+class SnapStudent {
+    String name
+    static hasMany = [courses: SnapCourse]
+}
+
+@Entity
+class SnapCourse {
+    String title
+    static belongsTo = SnapStudent
+    static hasMany = [students: SnapStudent]
+}
+
+@Entity
+class SnapVehicle {
+    String plate
+}
+
+@Entity
+class SnapCar extends SnapVehicle {
+    Integer doors
+}
+
+@Entity
+class SnapAccount {
+    String owner
+    static mapping = {
+        tablePerHierarchy false
+    }
+}
+
+@Entity
+class SnapSavings extends SnapAccount {
+    BigDecimal rate
+}
+
+@Entity
+class SnapShape {
+    String color
+    static mapping = {
+        tablePerHierarchy false
+        tablePerConcreteClass true
+        id generator: 'sequence', params: [sequence_name: 'snap_shape_seq']
+    }
+}
+
+@Entity
+class SnapCircle extends SnapShape {
+    Integer radius
+}
+
+@Entity
+class SnapEnrollment implements Serializable {
+    String student
+    String course
+    Integer grade
+    static mapping = {
+        id composite: ['student', 'course']
+    }
+}
+
+@Entity
+class SnapSequenced {
+    String label
+    static mapping = {
+        id generator: 'sequence', params: [sequence_name: 'snap_seq']
+    }
+}
+
+@Entity
+class SnapNatural {
+    String code
+    String description
+    static mapping = {
+        id natural: 'code'
+        version false
+    }
+}
+
+@Entity
+class SnapPlain {
+    String first
+    String last
+    String full
+    static mapping = {
+        full formula: "first || ' ' || last"
+        version false
+    }
+}


(grails-core) 03/14: fix: state the NONE optimistic lock style for an unversioned root in generated classes

Posted by bo...@apache.org.
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 e7612a94f1c743ff4384dcafd7136a87d9a2306b
Author: Walter Duque de Estrada <wb...@mac.com>
AuthorDate: Tue Oct 6 10:00:04 2026 -0500

    fix: state the NONE optimistic lock style for an unversioned root in generated classes
    
    The domain binder sets OptimisticLockStyle.NONE on a root without a version property
    (version false), while Hibernate's annotation binder defaults to VERSION. The generator now
    writes @OptimisticLocking(type = NONE) on such a root, and the differential compares the
    lock style with the binder's and with what Hibernate's annotation binder reads back. Found
    by running HibernateOptimisticLockingStyleMappingSpec in generated mode.
    
    Co-Authored-By: Claude Sonnet 5.5 <no...@anthropic.com>
---
 .../cfg/domainbinding/jpa/EntityFacets.groovy      |  6 ++-
 .../domainbinding/jpa/GrailsDomainGenerator.groovy |  9 ++++-
 .../GrailsDomainGeneratorDifferentialSpec.groovy   |  4 ++
 .../jpa/GrailsDomainGeneratorSpec.groovy           | 46 +++++++++++++++++++++-
 4 files changed, 61 insertions(+), 4 deletions(-)

diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/EntityFacets.groovy b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/EntityFacets.groovy
index 938c958f00..38682a1a3b 100644
--- a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/EntityFacets.groovy
+++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/EntityFacets.groovy
@@ -25,7 +25,8 @@ import groovy.transform.CompileStatic
  * {@code PersistentClass} and its table, and that {@link GrailsDomainGenerator} writes into the class annotations.
  *
  * <p>{@code schema}, {@code catalog} and {@code comment} are {@code null} when unset; {@code batchSize} is
- * {@code 0} when unset.</p>
+ * {@code 0} when unset. {@code versioned} is whether the hierarchy has a version property: when it has none the binder
+ * sets the optimistic lock style of the root to {@code NONE}, where Hibernate's own default is {@code VERSION}.</p>
  *
  * @since 9.0
  */
@@ -38,5 +39,6 @@ record EntityFacets(
     boolean dynamicInsert,
     boolean dynamicUpdate,
     int batchSize,
-    String comment) {
+    String comment,
+    boolean versioned) {
 }
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 017495add6..0024c5e359 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
@@ -94,6 +94,8 @@ import org.hibernate.annotations.JdbcTypeCode
 import org.hibernate.annotations.NaturalId as HibernateNaturalId
 import org.hibernate.annotations.NotFound
 import org.hibernate.annotations.NotFoundAction
+import org.hibernate.annotations.OptimisticLockType
+import org.hibernate.annotations.OptimisticLocking
 import org.hibernate.annotations.ParamDef
 import org.hibernate.annotations.Parameter
 import org.hibernate.annotations.SortNatural
@@ -627,7 +629,8 @@ class GrailsDomainGenerator {
                 mapping != null && mapping.dynamicInsert,
                 mapping != null && mapping.dynamicUpdate,
                 mapping?.batchSize != null ? mapping.batchSize : 0,
-                sharesTable ? null : entity.comment)
+                sharesTable ? null : entity.comment,
+                entity.hibernateRootEntity.version != null)
     }
 
     /**
@@ -2204,6 +2207,10 @@ class GrailsDomainGenerator {
         if (facets.dynamicUpdate()) {
             annotations << AnnotationDescription.Builder.ofType(DynamicUpdate).build()
         }
+        if (entity.isRoot() && !facets.versioned()) {
+            // VersionBinder sets the lock style NONE for a root without a version; Hibernate's default is VERSION
+            annotations << AnnotationDescription.Builder.ofType(OptimisticLocking).define('type', OptimisticLockType.NONE).build()
+        }
         if (facets.batchSize() > 0) {
             annotations << AnnotationDescription.Builder.ofType(BatchSize).define('size', facets.batchSize()).build()
         }
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 83a900e08d..530447ed0d 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
@@ -28,6 +28,7 @@ import org.hibernate.boot.registry.BootstrapServiceRegistryBuilder
 import org.hibernate.boot.registry.StandardServiceRegistry
 import org.hibernate.boot.registry.StandardServiceRegistryBuilder
 import org.hibernate.dialect.H2Dialect
+import org.hibernate.engine.OptimisticLockStyle
 import org.hibernate.engine.jdbc.Size
 import org.hibernate.engine.spi.FilterDefinition
 import org.hibernate.engine.spi.SessionFactoryImplementor
@@ -1100,6 +1101,8 @@ class GrailsDomainGeneratorDifferentialSpec extends HibernateGormDatastoreSpec {
                 dynamicUpdate: [facets.dynamicUpdate(), persistentClass.useDynamicUpdate()],
                 // the binder leaves a subclass's unset batch size at -1 and a root's at 0: both mean "not stated"
                 batchSize    : [facets.batchSize(), Math.max(persistentClass.batchSize, 0)],
+                // VersionBinder: NONE for a root without a version, VERSION otherwise; a subclass reads its root's
+                versioned    : [facets.versioned(), persistentClass.optimisticLockStyle == OptimisticLockStyle.VERSION],
         ]
         if (hierarchy.ownsTable()) {
             pairs.comment = [facets.comment(), persistentClass.table.comment]
@@ -1298,6 +1301,7 @@ class GrailsDomainGeneratorDifferentialSpec extends HibernateGormDatastoreSpec {
                 abstractClass     : [Boolean.TRUE == bound.isAbstract(), Boolean.TRUE == annotated.isAbstract()],
                 abstractUnionTable: [bound.table.isAbstractUnionTable(), annotated.table.isAbstractUnionTable()],
                 properties        : [declaredNames(bound), declaredNames(annotated)],
+                optimisticLock    : [bound.optimisticLockStyle, annotated.optimisticLockStyle],
         ]
         if (bound instanceof SingleTableSubclass || bound instanceof RootClass && bound.discriminator != null) {
             pairs.discriminatorValue = [bound.discriminatorValue, annotated.discriminatorValue]
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 126d327f01..c1c5762f25 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
@@ -65,6 +65,8 @@ import org.hibernate.annotations.Comment
 import org.hibernate.annotations.DiscriminatorFormula
 import org.hibernate.annotations.DiscriminatorOptions
 import org.hibernate.annotations.DynamicUpdate
+import org.hibernate.annotations.OptimisticLockType
+import org.hibernate.annotations.OptimisticLocking
 import org.hibernate.annotations.Fetch
 import org.hibernate.annotations.FetchMode
 import org.hibernate.annotations.Formula
@@ -152,7 +154,7 @@ class GrailsDomainGeneratorSpec extends HibernateGormDatastoreSpec {
                 GenEmbedOwner, GenEmbedOther, GenEmbedBase, GenEmbedChild, GenEmbedFormulaOwner, GenCollSingle, GenCollKinds, GenCollLazy,
                 GenFkTarget, GenFkOwned, GenFkOwner, GenFkCascades, GenFkNodeA, GenFkNodeB, GenFkHasOneOwner, GenFkHasOneDetail, GenOneFace, GenOneNose, GenFkManyOne, GenFkOneSide, GenFkSub, GenFkSubRoot,
                 GenOmParent, GenOmChild, GenOmListed, GenOmOrdered, GenOmOrphan, GenOmFetched, GenOmTag, GenOmStep, GenOmKept, GenOmSortedOwner, GenOmMapOwner, GenMapBidiOwner, GenMapBidiChild, GenEmbAssocOwner,
-                GenMmStudent, GenMmCourse, GenMmPerson, GenMmNoOwnerA, GenMmNoOwnerB, GenParamsOnly, GenDecimal)
+                GenMmStudent, GenMmCourse, GenMmPerson, GenMmNoOwnerA, GenMmNoOwnerB, GenParamsOnly, GenDecimal, GenUnversioned, GenUnversionedRoot, GenUnversionedChild)
     }
 
     List<StandardServiceRegistry> registries = []
@@ -288,6 +290,22 @@ class GrailsDomainGeneratorSpec extends HibernateGormDatastoreSpec {
         generated.getAnnotation(Comment).value() == 'basic things'
     }
 
+    void "a root without a version states the NONE optimistic lock style, a versioned root states none"() {
+        expect:
+        !generated.isAnnotationPresent(OptimisticLocking)
+        generate(GenUnversioned).getAnnotation(OptimisticLocking).type() == OptimisticLockType.NONE
+        !generate(GenUnversioned).declaredFields*.name.contains('version')
+    }
+
+    void "a subclass of an unversioned root leaves the lock style to the root"() {
+        when:
+        Map<GrailsHibernatePersistentEntity, Class<?>> classes = newGenerator().generateAll(
+                [GenUnversionedRoot, GenUnversionedChild].collect { getPersistentEntity(it) }, getClass().classLoader)
+
+        then:
+        classes.values().findAll { it.isAnnotationPresent(OptimisticLocking) }*.name == [GenUnversionedRoot.name]
+    }
+
     void "the column extras stated in the mapping become Hibernate annotations"() {
         expect:
         field('code').getAnnotation(ColumnDefault).value() == "'none'"
@@ -3064,3 +3082,29 @@ class GenEmbAssocPlace {
     String street
     GenFkTarget city
 }
+
+@Entity
+class GenUnversioned {
+
+    String name
+
+    static mapping = {
+        version false
+    }
+}
+
+@Entity
+class GenUnversionedRoot {
+
+    String name
+
+    static mapping = {
+        version false
+    }
+}
+
+@Entity
+class GenUnversionedChild extends GenUnversionedRoot {
+
+    String extra
+}


(grails-core) 08/14: test: drop the generated-mode exclusion that the lazy embedded fix made unnecessary

Posted by bo...@apache.org.
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 2d9efbcbb109ed39492f65f213fbdf40840f7a84
Author: Walter Duque de Estrada <wb...@mac.com>
AuthorDate: Tue Oct 6 10:44:28 2026 -0500

    test: drop the generated-mode exclusion that the lazy embedded fix made unnecessary
    
    GrailsDomainGeneratorLazyPropertySpec passes in generated mode now that a lazy embedded
    property is accepted. The whole module suite in generated mode is 3991 tests, 0 failures;
    the 15 listed specs exercise the binder or generator internals with fixture mappings the
    generator refuses by name.
    
    Co-Authored-By: Claude Sonnet 5.5 <no...@anthropic.com>
---
 grails-data-hibernate7/core/generated-mode-exclusions.txt | 1 -
 1 file changed, 1 deletion(-)

diff --git a/grails-data-hibernate7/core/generated-mode-exclusions.txt b/grails-data-hibernate7/core/generated-mode-exclusions.txt
index 366b1595f8..2aa5d4b516 100644
--- a/grails-data-hibernate7/core/generated-mode-exclusions.txt
+++ b/grails-data-hibernate7/core/generated-mode-exclusions.txt
@@ -31,7 +31,6 @@ org.grails.orm.hibernate.cfg.domainbinding.jpa.EntityCollectionKeyDefectSpec
 org.grails.orm.hibernate.cfg.domainbinding.jpa.GrailsDomainBinderOptionDefectSpec
 org.grails.orm.hibernate.cfg.domainbinding.jpa.GrailsDomainGeneratorDifferentialSpec
 org.grails.orm.hibernate.cfg.domainbinding.jpa.GrailsDomainGeneratorIndexSpec
-org.grails.orm.hibernate.cfg.domainbinding.jpa.GrailsDomainGeneratorLazyPropertySpec
 org.grails.orm.hibernate.cfg.domainbinding.jpa.GrailsDomainGeneratorNaturalIdSpec
 org.grails.orm.hibernate.cfg.domainbinding.jpa.GrailsDomainGeneratorSpec
 org.grails.orm.hibernate.cfg.domainbinding.jpa.GrailsDomainGeneratorTenantSpec


(grails-core) 09/14: fix: let Envers audit the entities bound from generated classes

Posted by bo...@apache.org.
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 0a72fbb09c41a72e4fede6adf05c532e4ecfa234
Author: Walter Duque de Estrada <wb...@mac.com>
AuthorDate: Tue Oct 6 11:35:43 2026 -0500

    fix: let Envers audit the entities bound from generated classes
    
    Envers builds its audit mappings in an AdditionalMappingContributor from the
    entities bound when it runs. Hibernate binds the classes handed to
    contributeEntity only after every contributor has run, so in generated mode
    Envers saw no entity, and the generated classes carried no @Audited anyway:
    AuditReader queries threw NotAuditedException.
    
    The generated classes are now bound inside the generated binder's contribution,
    with the second passes Hibernate runs for its main mapping sources, and the
    generator copies the org.hibernate.envers annotations of the domain class and
    of its properties onto them. Audit tables, revisions and the instances audit
    queries return are checked against binder mode in the dbmigration module.
    
    Co-Authored-By: Claude Sonnet 5.5 <no...@anthropic.com>
---
 .../jpa/GeneratedDomainClassBinder.groovy          |  23 +-
 .../domainbinding/jpa/GrailsDomainGenerator.groovy |  61 ++++-
 .../GeneratedDomainClassesContributorSpec.groovy   | 124 +++++++++++
 ...hibernate.boot.spi.AdditionalMappingContributor |   1 +
 .../generated/GeneratedModeEnversSpec.groovy       | 248 +++++++++++++++++++++
 5 files changed, 448 insertions(+), 9 deletions(-)

diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GeneratedDomainClassBinder.groovy b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GeneratedDomainClassBinder.groovy
index e426541feb..d643a931ed 100644
--- a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GeneratedDomainClassBinder.groovy
+++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GeneratedDomainClassBinder.groovy
@@ -22,6 +22,9 @@ import java.lang.reflect.Field
 
 import groovy.transform.CompileStatic
 import org.hibernate.boot.SessionFactoryBuilder
+import org.hibernate.boot.internal.InFlightMetadataCollectorImpl
+import org.hibernate.boot.internal.MetadataBuildingContextRootImpl
+import org.hibernate.boot.model.source.internal.annotations.AnnotationMetadataSourceProcessorImpl
 import org.hibernate.boot.spi.AdditionalMappingContributions
 import org.hibernate.boot.spi.MetadataBuildingContext
 import org.hibernate.boot.spi.MetadataImplementor
@@ -160,7 +163,25 @@ class GeneratedDomainClassBinder implements SessionFactoryBuilderFactory {
         classLoader.register(generated.values().first().classLoader)
         for (Map.Entry<GrailsHibernatePersistentEntity, Class<?>> entry : generated.entrySet()) {
             generatedByName.put(entry.value.name, new Generated(entry.key, entry.value))
-            contributions.contributeEntity(entry.value)
+        }
+        bindNow(contributions, buildingContext, new ArrayList<Class<?>>(generated.values()))
+    }
+
+    /**
+     * Hibernate binds the classes handed to {@code contributeEntity} only after every {@code AdditionalMappingContributor}
+     * has run, and runs the second passes of those entities later still. A contributor that reads the bound entities, such as
+     * Envers building the audit mappings of the audited ones, would then see none, or collections with no element. The
+     * generated classes are therefore bound here, with the entity hierarchy processing Hibernate applies to such classes and
+     * the second passes it applies to the entities of its main mapping sources, so that a contributor running after this
+     * binder finds the same bound model as it finds for annotated entities.
+     */
+    private static void bindNow(AdditionalMappingContributions contributions, MetadataBuildingContext buildingContext, List<Class<?>> classes) {
+        if (buildingContext instanceof MetadataBuildingContextRootImpl && buildingContext.metadataCollector instanceof InFlightMetadataCollectorImpl) {
+            AnnotationMetadataSourceProcessorImpl.processAdditionalMappings(
+                    classes, null, null, (MetadataBuildingContextRootImpl) buildingContext, buildingContext.buildingOptions)
+            ((InFlightMetadataCollectorImpl) buildingContext.metadataCollector).processSecondPasses(buildingContext)
+        } else {
+            classes.each { Class<?> generatedClass -> contributions.contributeEntity(generatedClass) }
         }
     }
 
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 253c5d88d8..73811c6b23 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
@@ -183,6 +183,9 @@ class GrailsDomainGenerator {
 
     static final String GENERATED_PACKAGE = 'org.grails.orm.hibernate.generated'
 
+    private static final List<String> VALIDATION_PACKAGES = ['jakarta.validation.constraints.', 'org.hibernate.validator.constraints.']
+    private static final List<String> AUDIT_PACKAGES = ['org.hibernate.envers.']
+
     private final PersistentEntityNamingStrategy namingStrategy
     private final ColumnNameForPropertyAndPathFetcher columnNames
     private final ColumnConfigToColumnBinder columnConfigBinder
@@ -2170,6 +2173,9 @@ class GrailsDomainGenerator {
         EntityFacets facets = entityFacets(entity)
         List<AnnotationDescription> annotations = []
         annotations << AnnotationDescription.Builder.ofType(Entity).define('name', facets.jpaName()).build()
+        for (Annotation audit : auditAnnotations(entity)) {
+            annotations << AnnotationDescription.ForLoadedAnnotation.of(audit)
+        }
 
         if (hierarchy.ownsTable()) {
             AnnotationDescription.Builder table = AnnotationDescription.Builder.ofType(Table).define('name', facets.tableName())
@@ -2334,7 +2340,7 @@ class GrailsDomainGenerator {
         } else if (property instanceof HibernateEnumProperty) {
             annotations << enumAnnotation((HibernateEnumProperty) property)
         }
-        for (Annotation constraint : validationAnnotations(property)) {
+        for (Annotation constraint : carriedAnnotations(property)) {
             annotations << AnnotationDescription.ForLoadedAnnotation.of(constraint)
         }
         return builder.defineField(property.name, type?.javaType() ?: columnFieldType(property), Visibility.PRIVATE)
@@ -2416,7 +2422,7 @@ class GrailsDomainGenerator {
                             hibernateCascade as org.hibernate.annotations.CascadeType[])
                     .build()
         }
-        for (Annotation constraint : validationAnnotations(property)) {
+        for (Annotation constraint : carriedAnnotations(property)) {
             annotations << AnnotationDescription.ForLoadedAnnotation.of(constraint)
         }
         return builder.defineField(property.name, generatedType(property.hibernateAssociatedEntity), Visibility.PRIVATE)
@@ -2514,7 +2520,7 @@ class GrailsDomainGenerator {
                     .define('condition', facets.tenantCondition())
                     .build()
         }
-        for (Annotation constraint : validationAnnotations(property)) {
+        for (Annotation constraint : carriedAnnotations(property)) {
             annotations << AnnotationDescription.ForLoadedAnnotation.of(constraint)
         }
         List<TypeDescription> arguments = facets.kind() == CollectionKind.MAP ?
@@ -2609,7 +2615,7 @@ class GrailsDomainGenerator {
             annotations << AnnotationDescription.Builder.ofType(Cache)
                     .define('usage', CacheConcurrencyStrategy.parse(facets.cacheUsage())).build()
         }
-        for (Annotation constraint : validationAnnotations(property)) {
+        for (Annotation constraint : carriedAnnotations(property)) {
             annotations << AnnotationDescription.ForLoadedAnnotation.of(constraint)
         }
         Class<?> elementClass = property instanceof HibernateEnumProperty ?
@@ -2713,6 +2719,9 @@ class GrailsDomainGenerator {
                         .build()
             }
         }
+        for (Annotation audit : auditAnnotations(property)) {
+            annotations << AnnotationDescription.ForLoadedAnnotation.of(audit)
+        }
         return builder.defineField(property.name, embeddable, Visibility.PRIVATE)
                 .annotateField(annotations as AnnotationDescription[])
     }
@@ -2866,11 +2875,43 @@ class GrailsDomainGenerator {
         return extras
     }
 
+    /**
+     * The annotations of other libraries that Hibernate or its integrations read from the entity class and that the
+     * generated field must therefore carry: the Bean Validation constraints and the Envers annotations.
+     */
+    List<Annotation> carriedAnnotations(HibernatePersistentProperty property) {
+        List<Annotation> carried = validationAnnotations(property)
+        carried.addAll(auditAnnotations(property))
+        return carried
+    }
+
     /**
      * The Bean Validation constraints declared on the property. Hibernate turns them into DDL (not null, precision,
      * scale, length) after binding, so they are copied onto the generated field for Hibernate to apply itself.
      */
     List<Annotation> validationAnnotations(HibernatePersistentProperty property) {
+        return memberAnnotations(property, VALIDATION_PACKAGES)
+    }
+
+    /**
+     * The Hibernate Envers annotations ({@code @Audited}, {@code @NotAudited}, {@code @AuditJoinTable}, ...) declared on the
+     * property. Envers reads them from the bound entity's class, which is the generated class while the mappings are bound,
+     * so they are copied onto the generated field. Envers is not a dependency of this module: the annotations are recognised by
+     * package.
+     */
+    List<Annotation> auditAnnotations(HibernatePersistentProperty property) {
+        return memberAnnotations(property, AUDIT_PACKAGES)
+    }
+
+    /**
+     * The Hibernate Envers annotations declared on the domain class itself ({@code @Audited}, {@code @AuditTable},
+     * {@code @AuditOverride}, ...), which the generated class carries so that Envers audits the entity.
+     */
+    List<Annotation> auditAnnotations(GrailsHibernatePersistentEntity entity) {
+        return entity.javaClass.declaredAnnotations.findAll { Annotation a -> inPackages(a, AUDIT_PACKAGES) } as List<Annotation>
+    }
+
+    private static List<Annotation> memberAnnotations(HibernatePersistentProperty property, List<String> packages) {
         Class<?> owner = property.hibernateOwner.javaClass
         List<Annotation> found = []
         try {
@@ -2884,10 +2925,14 @@ class GrailsDomainGenerator {
                 found.addAll(method.declaredAnnotations as List<Annotation>)
             }
         }
-        return found.findAll { Annotation a ->
-            String type = a.annotationType().name
-            type.startsWith('jakarta.validation.constraints.') || type.startsWith('org.hibernate.validator.constraints.')
-        }
+        // a field and its getter may both carry the annotation: a class can state it once
+        Set<Class<? extends Annotation>> seen = new HashSet<Class<? extends Annotation>>()
+        return found.findAll { Annotation a -> inPackages(a, packages) && seen.add(a.annotationType()) }
+    }
+
+    private static boolean inPackages(Annotation annotation, List<String> packages) {
+        String type = annotation.annotationType().name
+        return packages.any { String prefix -> type.startsWith(prefix) }
     }
 
     /**
diff --git a/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/generated/GeneratedDomainClassesContributorSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/generated/GeneratedDomainClassesContributorSpec.groovy
new file mode 100644
index 0000000000..339869a0a3
--- /dev/null
+++ b/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/generated/GeneratedDomainClassesContributorSpec.groovy
@@ -0,0 +1,124 @@
+/*
+ *  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 grails.persistence.Entity
+import org.hibernate.boot.ResourceStreamLocator
+import org.hibernate.boot.spi.AdditionalMappingContributions
+import org.hibernate.boot.spi.AdditionalMappingContributor
+import org.hibernate.boot.spi.InFlightMetadataCollector
+import org.hibernate.boot.spi.MetadataBuildingContext
+import org.hibernate.mapping.Collection
+import org.hibernate.mapping.PersistentClass
+import spock.lang.Specification
+
+import org.grails.datastore.mapping.core.DatastoreUtils
+import org.grails.orm.hibernate.HibernateDatastore
+
+/**
+ * A contributor that runs after the domain binder, such as Hibernate Envers building the audit mappings, reads the entities
+ * bound so far. The generated classes are bound when the domain binder runs, with their second passes, so that such a
+ * contributor finds the same bound model whether the domain binder or the generated classes bound the entities.
+ */
+class GeneratedDomainClassesContributorSpec extends Specification {
+
+    HibernateDatastore datastore
+
+    def setup() {
+        RecordingMappingContributor.recording = true
+        RecordingMappingContributor.seen.clear()
+    }
+
+    def cleanup() {
+        RecordingMappingContributor.recording = false
+        datastore?.close()
+    }
+
+    private HibernateDatastore boot(boolean generated) {
+        datastore = new HibernateDatastore(
+                DatastoreUtils.createPropertyResolver([
+                        'dataSource.url'                  : "jdbc:h2:mem:gdcContributor${generated};LOCK_TIMEOUT=10000".toString(),
+                        'dataSource.dbCreate'             : 'create-drop',
+                        'hibernate.generatedDomainClasses': generated,
+                ]), GdcContributorShelf, GdcContributorItem)
+    }
+
+    def "a contributor sees the bound entities, with their collections complete, in #mode mode"() {
+        when:
+        boot(generated)
+
+        then: 'the entities are bound under the domain class names'
+        RecordingMappingContributor.seen.keySet() == [GdcContributorShelf.name, GdcContributorItem.name].toSet()
+
+        and: 'the collection of the owner has its element and key'
+        RecordingMappingContributor.seen[GdcContributorShelf.name] == ['items:true']
+
+        where:
+        generated | mode
+        true      | 'generated'
+        false     | 'binder'
+    }
+}
+
+/**
+ * Records, while {@link #recording} is set, the entities bound and the completeness of their collections when it runs.
+ */
+class RecordingMappingContributor implements AdditionalMappingContributor {
+
+    static volatile boolean recording
+    static final Map<String, List<String>> seen = Collections.synchronizedMap(new LinkedHashMap<String, List<String>>())
+
+    @Override
+    String getContributorName() {
+        return 'recording'
+    }
+
+    @Override
+    void contribute(
+            AdditionalMappingContributions contributions,
+            InFlightMetadataCollector metadata,
+            ResourceStreamLocator resourceStreamLocator,
+            MetadataBuildingContext buildingContext) {
+        if (!recording) {
+            return
+        }
+        for (PersistentClass entity : metadata.entityBindings) {
+            List<String> collections = []
+            for (Object property : entity.declaredProperties) {
+                Object value = ((org.hibernate.mapping.Property) property).value
+                if (value instanceof Collection) {
+                    collections << "${((org.hibernate.mapping.Property) property).name}:${((Collection) value).element != null}".toString()
+                }
+            }
+            seen.put(entity.entityName, collections)
+        }
+    }
+}
+
+@Entity
+class GdcContributorShelf {
+    String name
+    static hasMany = [items: GdcContributorItem]
+}
+
+@Entity
+class GdcContributorItem {
+    String label
+    static belongsTo = [shelf: GdcContributorShelf]
+}
diff --git a/grails-data-hibernate7/core/src/test/resources/META-INF/services/org.hibernate.boot.spi.AdditionalMappingContributor b/grails-data-hibernate7/core/src/test/resources/META-INF/services/org.hibernate.boot.spi.AdditionalMappingContributor
new file mode 100644
index 0000000000..fe4ed223e9
--- /dev/null
+++ b/grails-data-hibernate7/core/src/test/resources/META-INF/services/org.hibernate.boot.spi.AdditionalMappingContributor
@@ -0,0 +1 @@
+grails.gorm.tests.generated.RecordingMappingContributor
diff --git a/grails-data-hibernate7/dbmigration/src/test/groovy/org/grails/plugins/databasemigration/generated/GeneratedModeEnversSpec.groovy b/grails-data-hibernate7/dbmigration/src/test/groovy/org/grails/plugins/databasemigration/generated/GeneratedModeEnversSpec.groovy
new file mode 100644
index 0000000000..855a350c05
--- /dev/null
+++ b/grails-data-hibernate7/dbmigration/src/test/groovy/org/grails/plugins/databasemigration/generated/GeneratedModeEnversSpec.groovy
@@ -0,0 +1,248 @@
+/*
+ *  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.plugins.databasemigration.generated
+
+import grails.gorm.annotation.Entity
+import org.hibernate.Session
+import org.hibernate.boot.spi.MetadataImplementor
+import org.hibernate.dialect.H2Dialect
+import org.hibernate.envers.AuditReader
+import org.hibernate.envers.AuditReaderFactory
+import org.hibernate.envers.Audited
+import org.hibernate.envers.NotAudited
+import org.hibernate.envers.RevisionType
+import org.hibernate.mapping.Table
+import spock.lang.AutoCleanup
+import spock.lang.PendingFeature
+import spock.lang.Specification
+
+import org.grails.orm.hibernate.HibernateDatastore
+
+/**
+ * Hibernate Envers over the generated domain classes. Envers builds its audit mappings from the entities bound when its
+ * contributor runs, reading the annotations of the bound classes, so the generated classes are bound before it and carry
+ * the Envers annotations of the domain classes. The audit tables, the revisions and the instances the audit queries return
+ * are those of binder mode, with the differences that are named in the features below.
+ */
+class GeneratedModeEnversSpec extends Specification {
+
+    @AutoCleanup
+    HibernateDatastore datastore
+
+    private HibernateDatastore boot(boolean generated, List<Class> classes, Map extra = [:]) {
+        datastore?.close()
+        datastore = new HibernateDatastore([
+                'dataSource.url'                  : "jdbc:h2:mem:enversSpec${generated}${System.nanoTime()};LOCK_TIMEOUT=10000;DB_CLOSE_DELAY=-1".toString(),
+                'dataSource.dialect'              : H2Dialect.name,
+                'dataSource.dbCreate'             : 'create-drop',
+                'hibernate.hbm2ddl.auto'          : 'create',
+                'hibernate.cache.queries'         : 'false',
+                'hibernate.cache.use_query_cache' : 'false',
+                'hibernate.generatedDomainClasses': generated,
+        ] + extra, classes as Class[])
+        return datastore
+    }
+
+    private static Map<String, List<String>> columnsByTable(HibernateDatastore datastore) {
+        Map<String, List<String>> tables = [:]
+        for (Table table : ((MetadataImplementor) datastore.metadata).collectTableMappings()) {
+            tables[table.name.toLowerCase()] = table.columns*.name*.toLowerCase().sort()
+        }
+        return tables
+    }
+
+    private static Map bookHistory() {
+        Long bookId
+        Long novelId
+        EnversBook.withTransaction {
+            bookId = new EnversBook(title: 'one', pages: 10, secret: 's').save(flush: true, failOnError: true).id
+            novelId = new EnversNovel(title: 'tale', pages: 300, secret: 't', genre: 'drama').save(flush: true, failOnError: true).id
+        }
+        EnversBook.withTransaction {
+            EnversBook book = EnversBook.get(bookId)
+            book.title = 'two'
+            book.save(flush: true, failOnError: true)
+        }
+        EnversBook.withTransaction {
+            EnversBook.get(bookId).delete(flush: true)
+        }
+        Map result = [:]
+        EnversBook.withNewSession { Session session ->
+            AuditReader reader = AuditReaderFactory.get(session)
+            List<Number> revisions = reader.getRevisions(EnversBook, bookId)
+            result.revisionCount = revisions.size()
+            result.history = revisions.collect { Number revision ->
+                EnversBook found = reader.find(EnversBook, bookId, revision)
+                found == null ? null : [found.title, found.pages, found.getClass() == EnversBook]
+            }
+            List rows = reader.createQuery().forRevisionsOfEntity(EnversBook, false, true).resultList
+            result.revisionTypes = rows.collect { Object[] row -> ((RevisionType) row[2]).name() }.sort()
+            result.rowClasses = rows.collect { Object[] row -> row[0].getClass().simpleName }.sort()
+            result.entitiesOnly = reader.createQuery().forRevisionsOfEntity(EnversBook, true, true).resultList
+                    *.getClass()*.simpleName.sort()
+            EnversBook novel = reader.find(EnversBook, novelId, reader.getRevisions(EnversBook, novelId).first())
+            result.novel = [novel.getClass().simpleName, novel instanceof EnversNovel ? ((EnversNovel) novel).genre : null]
+            result.atRevision = reader.createQuery().forEntitiesAtRevision(EnversBook, revisions.first()).resultList
+                    .collect { EnversBook found -> [found.getClass().simpleName, found.title] }.sort { it[1] }
+        }
+        return result
+    }
+
+    def "an audited domain class has audit tables, revisions and audit queries that return real instances in generated mode"() {
+        given:
+        HibernateDatastore generated = boot(true, [EnversBook, EnversNovel])
+
+        expect: 'the audit tables are mapped'
+        columnsByTable(generated).keySet().containsAll(['envers_book_aud', 'revinfo'])
+
+        when:
+        Map history = bookHistory()
+
+        then: 'each change is a revision and the audit queries return the domain classes'
+        history.revisionCount == 3
+        history.revisionTypes == ['ADD', 'ADD', 'DEL', 'MOD']
+        history.history == [['one', 10, true], ['two', 10, true], null]
+        history.rowClasses == ['EnversBook', 'EnversBook', 'EnversBook', 'EnversNovel']
+        history.entitiesOnly == ['EnversBook', 'EnversBook', 'EnversBook', 'EnversNovel']
+        history.novel == ['EnversNovel', 'drama']
+        history.atRevision.collect { it[0] }.toSet() == ['EnversBook', 'EnversNovel'].toSet()
+    }
+
+    def "generated mode audits the same entities and writes the same revisions as binder mode"() {
+        when:
+        HibernateDatastore binderDatastore = boot(false, [EnversBook, EnversNovel])
+        Map binderTables = columnsByTable(binderDatastore)
+        Map binderHistory = bookHistory()
+        HibernateDatastore generatedDatastore = boot(true, [EnversBook, EnversNovel])
+        Map generatedTables = columnsByTable(generatedDatastore)
+        Map generatedHistory = bookHistory()
+
+        then: 'the same tables exist'
+        binderTables.keySet() == generatedTables.keySet()
+
+        and: 'the audited entities are bound alike and the revision tables are equal'
+        binderTables.findAll { String name, List<String> columns -> !name.endsWith('_aud') } ==
+                generatedTables.findAll { String name, List<String> columns -> !name.endsWith('_aud') }
+
+        and: 'the audit table of the entity differs only in the columns named in the next feature'
+        binderTables['envers_book_aud'] - generatedTables['envers_book_aud'] == ['secret', 'version']
+        generatedTables['envers_book_aud'] - binderTables['envers_book_aud'] == []
+
+        and: 'the revisions and the audit queries return the same'
+        binderHistory == generatedHistory
+    }
+
+    def "a field level @NotAudited and the optimistic locking version are honoured the way Envers does for annotated entities"() {
+        when:
+        Map binder = columnsByTable(boot(false, [EnversBook, EnversNovel]))
+        Map generated = columnsByTable(boot(true, [EnversBook, EnversNovel]))
+        Map generatedWithVersion = columnsByTable(boot(true, [EnversBook, EnversNovel],
+                ['hibernate.additionalProperties': ['org.hibernate.envers.do_not_audit_optimistic_locking_field': 'false']]))
+
+        then: 'binder mode audits both, because Envers reads the getter of a property accessed property and the Groovy field is the one annotated, and the version has no @Version annotation to recognise'
+        binder['envers_book_aud'].containsAll(['secret', 'version'])
+
+        and: 'generated mode puts the annotation on the field Envers reads and the version carries @Version'
+        !generated['envers_book_aud'].contains('secret')
+        !generated['envers_book_aud'].contains('version')
+
+        and: 'Envers\' own setting audits the version in generated mode too'
+        generatedWithVersion['envers_book_aud'].contains('version')
+        !generatedWithVersion['envers_book_aud'].contains('secret')
+    }
+
+    private static Map shelfHistory() {
+        Long shelfId
+        EnversShelf.withTransaction {
+            EnversShelf shelf = new EnversShelf(name: 'shelf')
+            shelf.addToItems(new EnversItem(label: 'a'))
+            shelf.addToItems(new EnversItem(label: 'b'))
+            shelfId = shelf.save(flush: true, failOnError: true).id
+        }
+        EnversShelf.withTransaction {
+            EnversShelf shelf = EnversShelf.get(shelfId)
+            shelf.addToItems(new EnversItem(label: 'c'))
+            shelf.save(flush: true, failOnError: true)
+        }
+        Map result = [:]
+        EnversShelf.withNewSession { Session session ->
+            AuditReader reader = AuditReaderFactory.get(session)
+            List<Number> revisions = reader.getRevisions(EnversShelf, shelfId)
+            result.revisionCount = revisions.size()
+            result.itemLabels = revisions.collect { Number revision ->
+                EnversShelf found = reader.find(EnversShelf, shelfId, revision)
+                found.items*.label.sort()
+            }
+            result.itemClasses = reader.find(EnversShelf, shelfId, revisions.last()).items*.getClass()*.simpleName.toSet()
+        }
+        return result
+    }
+
+    def "a hasMany collection of audited entities is audited in generated mode"() {
+        given:
+        boot(true, [EnversShelf, EnversItem])
+
+        when:
+        Map history = shelfHistory()
+
+        then:
+        columnsByTable(datastore).keySet().containsAll(['envers_shelf_aud', 'envers_item_aud'])
+        history.revisionCount == 2
+        history.itemLabels == [['a', 'b'], ['a', 'b', 'c']]
+        history.itemClasses == ['EnversItem'].toSet()
+    }
+
+    @PendingFeature(reason = 'binder mode: Envers builds the audit mapping of a hasMany collection before the domain binder has run its second passes and fails with a NullPointerException on the missing collection key')
+    def "a hasMany collection of audited entities is audited in binder mode"() {
+        given:
+        boot(false, [EnversShelf, EnversItem])
+
+        expect:
+        shelfHistory().itemLabels == [['a', 'b'], ['a', 'b', 'c']]
+    }
+}
+
+@Audited
+@Entity
+class EnversBook {
+    String title
+    Integer pages
+    @NotAudited
+    String secret
+}
+
+@Audited
+@Entity
+class EnversNovel extends EnversBook {
+    String genre
+}
+
+@Audited
+@Entity
+class EnversShelf {
+    String name
+    static hasMany = [items: EnversItem]
+}
+
+@Audited
+@Entity
+class EnversItem {
+    String label
+    static belongsTo = [shelf: EnversShelf]
+}


(grails-core) 05/14: fix: bind an explicit lazy true collection as extra-lazy in generated classes

Posted by bo...@apache.org.
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 a5a4328421ec9d132d5865feb225e886ee89a58d
Author: Walter Duque de Estrada <wb...@mac.com>
AuthorDate: Tue Oct 6 10:18:51 2026 -0500

    fix: bind an explicit lazy true collection as extra-lazy in generated classes
    
    lazy: true on a collection makes the domain binder bind an extra-lazy collection (size(),
    contains() and isEmpty() do not initialize it). Hibernate 7's annotation binder hard-codes
    extraLazy = false and has no annotation for it, so the generator used to reject the entity.
    The facets now carry extraLazy and the binder of the generated classes sets it on the bound
    collection, for collections of values and of entities. The same spec shows identical runtime
    behaviour in binder mode. The two rejections and the REFUSALS entry of the DDL differential
    are gone; the differential records the annotation-path difference as known.
    
    Co-Authored-By: Claude Sonnet 5.5 <no...@anthropic.com>
---
 .../cfg/domainbinding/jpa/CollectionFacets.groovy  |   4 +
 .../jpa/GeneratedDomainClassBinder.groovy          |  20 ++++
 .../domainbinding/jpa/GrailsDomainGenerator.groovy |   8 +-
 .../cfg/domainbinding/jpa/ToManyFacets.groovy      |   4 +
 .../GeneratedDomainClassesExtraLazySpec.groovy     | 124 +++++++++++++++++++++
 ...eneratedDomainClassesDdlDifferentialSpec.groovy |   1 -
 .../GrailsDomainGeneratorDifferentialSpec.groovy   |  18 ++-
 .../GrailsDomainGeneratorOptionCoverageSpec.groovy |  22 +++-
 .../jpa/GrailsDomainGeneratorSpec.groovy           |  27 +++--
 9 files changed, 201 insertions(+), 27 deletions(-)

diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/CollectionFacets.groovy b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/CollectionFacets.groovy
index 6483b6e518..f1a34957ad 100644
--- a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/CollectionFacets.groovy
+++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/CollectionFacets.groovy
@@ -36,6 +36,9 @@ import org.hibernate.FetchMode
  * a composite identifier; {@code referencedKeys} names the column of the owner's key each one points at, and is empty unless the
  * owner has a composite identifier. {@code key} is the first of the key columns.</p>
  *
+ * <p>{@code extraLazy} is an explicit {@code lazy: true}: the binder makes the collection extra-lazy, which Hibernate's annotation
+ * binder cannot state, so {@code GeneratedDomainClassBinder} sets it on the collection after binding.</p>
+ *
  * @since 9.0
  */
 @CompileStatic
@@ -48,6 +51,7 @@ record CollectionFacets(
     ColumnFacets element,
     ColumnFacets index,
     boolean lazy,
+    boolean extraLazy,
     FetchMode fetchMode,
     int batchSize,
     String cacheUsage,
diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GeneratedDomainClassBinder.groovy b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GeneratedDomainClassBinder.groovy
index a98888c328..9e57d5c928 100644
--- a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GeneratedDomainClassBinder.groovy
+++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GeneratedDomainClassBinder.groovy
@@ -358,6 +358,7 @@ class GeneratedDomainClassBinder implements SessionFactoryBuilderFactory {
             property.propertyAccessorName = propertyBinder.accessorName((HibernatePersistentProperty) persistentProperty)
         }
         if (property.value instanceof Collection && persistentProperty instanceof HibernatePersistentProperty) {
+            alignExtraLazy((Collection) property.value, (HibernatePersistentProperty) persistentProperty)
             alignCollectionTable((Collection) property.value, (HibernatePersistentProperty) persistentProperty)
         }
         if (property.value instanceof Component && persistentProperty instanceof Embedded) {
@@ -371,6 +372,25 @@ class GeneratedDomainClassBinder implements SessionFactoryBuilderFactory {
         }
     }
 
+    /**
+     * An explicit {@code lazy: true} on a collection makes the domain binder bind an extra-lazy collection ({@code size()},
+     * {@code contains()} and {@code isEmpty()} do not initialize it). Hibernate 7's annotation binder always binds an ordinary
+     * lazy collection and has no annotation for the extra-lazy kind, so the flag is set on the bound collection.
+     */
+    private void alignExtraLazy(Collection collection, HibernatePersistentProperty property) {
+        boolean extraLazy
+        if (property instanceof HibernateToManyEntityProperty) {
+            extraLazy = generator.toManyFacets((HibernateToManyEntityProperty) property).extraLazy()
+        } else if (property instanceof HibernateBasicProperty) {
+            extraLazy = generator.collectionFacets((HibernateBasicProperty) property).extraLazy()
+        } else {
+            return
+        }
+        if (extraLazy) {
+            collection.extraLazy = true
+        }
+    }
+
     /**
      * Hibernate's annotation binder forces the key and element columns of a collection table not null ("I break the spec, but it's
      * for good"), where the domain binder leaves them nullable unless they are part of the primary key, and a set whose element
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 0024c5e359..cd33c05917 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
@@ -1164,9 +1164,6 @@ class GrailsDomainGenerator {
         if (constraintProblem != null) {
             return constraintProblem
         }
-        if (mapped.lazy == Boolean.TRUE) {
-            return 'an explicit lazy: true makes the binder bind an extra-lazy collection, which Hibernate 7 annotations cannot state'
-        }
         if (mapped.joinTable.keys != null && mapped.joinTable.keys.size() > 1) {
             return 'the join table has a composite key'
         }
@@ -1312,6 +1309,7 @@ class GrailsDomainGenerator {
                 collectionElementFacets(property, kind),
                 collectionIndexFacets(property, kind),
                 property.isLazy(),
+                property.getLazy() == Boolean.TRUE,
                 FetchMode.JOIN == mapped.fetchMode ? FetchMode.JOIN : FetchMode.SELECT,
                 Math.max(property.batchSize, 0),
                 property.cacheUsage,
@@ -1477,9 +1475,6 @@ class GrailsDomainGenerator {
         if (mapped.type != null) {
             return 'a type is mapped on the collection property itself, which the binder applies to the collection and its element alike'
         }
-        if (mapped.lazy == Boolean.TRUE) {
-            return 'an explicit lazy: true makes the binder bind an extra-lazy collection, which Hibernate 7 annotations cannot state'
-        }
         String constraintProblem = collectionConstraintProblem(property)
         if (constraintProblem != null) {
             return constraintProblem
@@ -1622,6 +1617,7 @@ class GrailsDomainGenerator {
                 element,
                 collectionIndexFacets(property, kind),
                 property.isLazy(),
+                property.getLazy() == Boolean.TRUE,
                 FetchMode.JOIN == mapped.fetchMode ? FetchMode.JOIN : FetchMode.SELECT,
                 Math.max(property.batchSize, 0),
                 property.cacheUsage,
diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/ToManyFacets.groovy b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/ToManyFacets.groovy
index 63d6018591..2acc0b2fc6 100644
--- a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/ToManyFacets.groovy
+++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/ToManyFacets.groovy
@@ -44,6 +44,9 @@ import org.hibernate.FetchMode
  * a composite identifier; {@code referencedKeys} names the column of the owner's key each one points at, and is empty unless the
  * owner has a composite identifier. {@code key} is the first of the key columns.</p>
  *
+ * <p>{@code extraLazy} is an explicit {@code lazy: true}: the binder makes the collection extra-lazy, which Hibernate's annotation
+ * binder cannot state, so {@code GeneratedDomainClassBinder} sets it on the collection after binding.</p>
+ *
  * @since 9.0
  */
 @CompileStatic
@@ -59,6 +62,7 @@ record ToManyFacets(
     ColumnFacets element,
     ColumnFacets index,
     boolean lazy,
+    boolean extraLazy,
     FetchMode fetchMode,
     int batchSize,
     String cacheUsage,
diff --git a/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/generated/GeneratedDomainClassesExtraLazySpec.groovy b/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/generated/GeneratedDomainClassesExtraLazySpec.groovy
new file mode 100644
index 0000000000..06e87a6707
--- /dev/null
+++ b/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/generated/GeneratedDomainClassesExtraLazySpec.groovy
@@ -0,0 +1,124 @@
+/*
+ * 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 grails.gorm.tests.HibernateGormDatastoreSpec
+import grails.persistence.Entity
+import org.hibernate.collection.spi.PersistentCollection
+import org.hibernate.persister.collection.CollectionPersister
+
+/**
+ * A collection mapped {@code lazy: true} is extra-lazy: {@code size()}, {@code contains()} and {@code isEmpty()} ask the
+ * database and leave the collection uninitialized. Hibernate 7's annotation binder cannot state that, so the generated-class
+ * binding sets it on the bound collection; the specs read it back from the session factory and from a loaded owner.
+ */
+class GeneratedDomainClassesExtraLazySpec extends HibernateGormDatastoreSpec {
+
+    void setupSpec() {
+        registerGeneratedDomainClasses(GdcLazyOwner, GdcLazyItem, GdcLazyTagged, GdcPlainOwner)
+    }
+
+    private static CollectionPersister persister(def sessionFactory, Class owner, String property) {
+        return sessionFactory.mappingMetamodel.getCollectionDescriptor("${owner.name}.${property}".toString())
+    }
+
+    def "a collection mapped lazy true is bound extra-lazy, one mapped with the default is not"() {
+        expect:
+        persister(sessionFactory, GdcLazyOwner, 'items').isExtraLazy()
+        persister(sessionFactory, GdcLazyTagged, 'tags').isExtraLazy()
+        !persister(sessionFactory, GdcPlainOwner, 'plainItems').isExtraLazy()
+    }
+
+    def "size, contains and isEmpty of an extra-lazy collection of entities do not initialize it"() {
+        given:
+        GdcLazyOwner owner = new GdcLazyOwner(name: 'o')
+        owner.addToItems(new GdcLazyItem(label: 'a'))
+        owner.addToItems(new GdcLazyItem(label: 'b'))
+        owner.save(flush: true)
+        sessionFactory.currentSession.clear()
+
+        when:
+        GdcLazyOwner loaded = GdcLazyOwner.get(owner.id)
+        int itemCount = loaded.items.size()
+        boolean empty = loaded.items.isEmpty()
+
+        then:
+        itemCount == 2
+        !empty
+        !((PersistentCollection) loaded.items).wasInitialized()
+    }
+
+    def "size of an extra-lazy collection of values does not initialize it"() {
+        given:
+        GdcLazyTagged owner = new GdcLazyTagged(name: 't')
+        owner.addToTags('x')
+        owner.addToTags('y')
+        owner.save(flush: true)
+        sessionFactory.currentSession.clear()
+
+        when:
+        GdcLazyTagged loaded = GdcLazyTagged.get(owner.id)
+        int tagCount = loaded.tags.size()
+
+        then:
+        tagCount == 2
+        !((PersistentCollection) loaded.tags).wasInitialized()
+    }
+}
+
+@Entity
+class GdcLazyOwner {
+
+    String name
+    Set<GdcLazyItem> items
+
+    static hasMany = [items: GdcLazyItem]
+
+    static mapping = {
+        items lazy: true
+    }
+}
+
+@Entity
+class GdcLazyTagged {
+
+    String name
+    Set<String> tags
+
+    static hasMany = [tags: String]
+
+    static mapping = {
+        tags lazy: true
+    }
+}
+
+@Entity
+class GdcPlainOwner {
+
+    String name
+    Set<GdcLazyItem> plainItems
+
+    static hasMany = [plainItems: GdcLazyItem]
+}
+
+@Entity
+class GdcLazyItem {
+
+    String label
+}
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 6d7bfbbac5..0ea4d5e793 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
@@ -86,7 +86,6 @@ class GeneratedDomainClassesDdlDifferentialSpec extends Specification {
             'a type is mapped on the collection property itself',
             'an index or a unique group is mapped on the collection property',
             'is a collection inside an embedded type',
-            'an explicit lazy: true makes the binder bind an extra-lazy collection',
             'the property is mapped lazy: true',
             'declares a natural id but is a subclass',
             'which is HibernateEmbeddedProperty',
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 530447ed0d..d62dae38a8 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
@@ -798,7 +798,7 @@ class GrailsDomainGeneratorDifferentialSpec extends HibernateGormDatastoreSpec {
                 schema   : [facets.schema(), collection.collectionTable.schema],
                 catalog  : [facets.catalog(), collection.collectionTable.catalog],
                 lazy     : [facets.lazy(), collection.lazy],
-                extraLazy: [false, collection.extraLazy],
+                extraLazy: [facets.extraLazy(), collection.extraLazy],
                 fetchMode: [facets.fetchMode(), collection.fetchMode == FetchMode.JOIN ? FetchMode.JOIN : FetchMode.SELECT],
                 batchSize: [facets.batchSize(), Math.max(collection.batchSize, 0)],
                 cache    : [facets.cacheUsage(), collection.cacheConcurrencyStrategy],
@@ -1620,7 +1620,7 @@ class GrailsDomainGeneratorDifferentialSpec extends HibernateGormDatastoreSpec {
         Map<String, List> pairs = [
                 kind       : [facets.kind(), kindOf(collection)],
                 lazy       : [facets.lazy(), collection.lazy],
-                extraLazy  : [false, collection.extraLazy],
+                extraLazy  : [facets.extraLazy(), collection.extraLazy],
                 fetchMode  : [facets.fetchMode(), collection.fetchMode == FetchMode.JOIN ? FetchMode.JOIN : FetchMode.SELECT],
                 batchSize  : [facets.batchSize(), Math.max(collection.batchSize, 0)],
                 cache      : [facets.cacheUsage(), collection.cacheConcurrencyStrategy],
@@ -1682,7 +1682,7 @@ class GrailsDomainGeneratorDifferentialSpec extends HibernateGormDatastoreSpec {
                 kind        : [kindOf(bound), kindOf(annotated)],
                 role        : [roleProperty(bound), roleProperty(annotated)],
                 lazy        : [bound.lazy, annotated.lazy],
-                extraLazy   : [bound.extraLazy, annotated.extraLazy],
+                extraLazy   : [bound.extraLazy && !annotated.extraLazy ? extraLazyAligned(known) : bound.extraLazy, annotated.extraLazy],
                 fetchMode   : [bound.fetchMode == FetchMode.JOIN ? FetchMode.JOIN : FetchMode.SELECT,
                                annotated.fetchMode == FetchMode.JOIN ? FetchMode.JOIN : FetchMode.SELECT],
                 batchSize   : [Math.max(bound.batchSize, 0), Math.max(annotated.batchSize, 0)],
@@ -1767,7 +1767,7 @@ class GrailsDomainGeneratorDifferentialSpec extends HibernateGormDatastoreSpec {
                 schema      : [bound.collectionTable.schema, annotated.collectionTable.schema],
                 catalog     : [bound.collectionTable.catalog, annotated.collectionTable.catalog],
                 lazy        : [bound.lazy, annotated.lazy],
-                extraLazy   : [bound.extraLazy, annotated.extraLazy],
+                extraLazy   : [bound.extraLazy && !annotated.extraLazy ? extraLazyAligned(known) : bound.extraLazy, annotated.extraLazy],
                 fetchMode   : [bound.fetchMode == FetchMode.JOIN ? FetchMode.JOIN : FetchMode.SELECT,
                                annotated.fetchMode == FetchMode.JOIN ? FetchMode.JOIN : FetchMode.SELECT],
                 batchSize   : [Math.max(bound.batchSize, 0), Math.max(annotated.batchSize, 0)],
@@ -1919,6 +1919,16 @@ class GrailsDomainGeneratorDifferentialSpec extends HibernateGormDatastoreSpec {
         return found
     }
 
+    /**
+     * Hibernate's annotation binder cannot state an extra-lazy collection (it hard-codes {@code extraLazy = false}); the binder
+     * that wires the generated classes sets the flag after binding, and the real-boot spec proves it. Here the annotation
+     * path is read before that step, so the difference is recorded as known and the annotated value is the expected one.
+     */
+    private static boolean extraLazyAligned(Map<String, Integer> known) {
+        known['an extra-lazy collection (lazy: true): Hibernate 7 annotations cannot state it; GeneratedDomainClassBinder sets it after binding']++
+        return false
+    }
+
     private static List<String> compareAnnotatedDiscriminator(String where, RootClass bound, RootClass annotated) {
         if ((bound.discriminator != null) != (annotated.discriminator != null)) {
             return ["${where} discriminator: generator=${annotated.discriminator != null} binder=${bound.discriminator != null}".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
index 0de9109c02..32803eccf2 100644
--- 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
@@ -36,6 +36,9 @@ 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
+import org.grails.orm.hibernate.cfg.domainbinding.hibernate.HibernateBasicProperty
+import org.grails.orm.hibernate.cfg.domainbinding.hibernate.HibernatePersistentProperty
+import org.grails.orm.hibernate.cfg.domainbinding.hibernate.HibernateToManyEntityProperty
 
 /**
  * Guards the rule that {@link GrailsDomainGenerator} never drops a mapping option without a word. Every option of
@@ -399,15 +402,30 @@ class GrailsDomainGeneratorOptionCoverageSpec extends GrailsDomainGeneratorSuppo
         ]
     }
 
+    /**
+     * What the generator decides for the group: the generated classes (annotations and fields) and the facets that no annotation
+     * carries but the binding of the generated classes applies to the bound collections, such as an extra-lazy collection.
+     */
     private String signature() {
         Map<GrailsHibernatePersistentEntity, Class<?>> classes = generateGroup(GenCovOwner, GenCovTarget)
-        return classes.values().collect { Class<?> generated ->
+        GrailsDomainGenerator generator = newGenerator()
+        return classes.collect { GrailsHibernatePersistentEntity entity, 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(';') + '#' + extraLazyCollections(generator, entity)
         }.join('\n')
     }
+
+    private static String extraLazyCollections(GrailsDomainGenerator generator, GrailsHibernatePersistentEntity entity) {
+        return entity.hibernatePersistentProperties.findAll { HibernatePersistentProperty property ->
+            if (property instanceof HibernateBasicProperty && generator.supports(property)) {
+                return generator.collectionFacets((HibernateBasicProperty) property).extraLazy()
+            }
+            return property instanceof HibernateToManyEntityProperty && generator.supports(property) &&
+                    generator.toManyFacets((HibernateToManyEntityProperty) property).extraLazy()
+        }*.name.sort().join(',')
+    }
 }
 
 enum GenCovMode {
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 c1c5762f25..5c3c36d2d9 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
@@ -116,6 +116,7 @@ import org.grails.orm.hibernate.cfg.PropertyConfig
 import org.grails.orm.hibernate.cfg.HibernateMappingContext
 import org.grails.orm.hibernate.connections.HibernateConnectionSourceSettings
 import org.grails.orm.hibernate.cfg.domainbinding.hibernate.HibernateBasicProperty
+import org.grails.orm.hibernate.cfg.domainbinding.hibernate.HibernateToManyEntityProperty
 import org.grails.orm.hibernate.cfg.domainbinding.hibernate.HibernateEmbeddedProperty
 import org.grails.orm.hibernate.cfg.domainbinding.hibernate.HibernateManyToOneProperty
 import org.grails.orm.hibernate.cfg.domainbinding.hibernate.HibernateOneToOneProperty
@@ -1202,14 +1203,16 @@ class GrailsDomainGeneratorSpec extends HibernateGormDatastoreSpec {
         name << ['tags', 'scores', 'aliases', 'labels', 'attrs', 'kinds', 'ranks']
     }
 
-    void "an explicit lazy true is an extra-lazy collection, which annotations cannot state, so it is rejected by name"() {
+    void "an explicit lazy true is an extra-lazy collection: the facets say so, and the fetch type follows the binder's lazy facet"() {
         when:
-        generate(GenCollLazy)
+        GrailsHibernatePersistentEntity entity = unbound(GenCollLazy)
+        CollectionFacets facets = newGenerator().collectionFacets((HibernateBasicProperty) entity.getHibernatePropertyByName('tags'))
+        Class<?> generatedLazy = generate(GenCollLazy)
 
         then:
-        UnsupportedOperationException e = thrown()
-        e.message.contains('Collection property [tags] of [' + GenCollLazy.name + ']')
-        e.message.contains('extra-lazy')
+        facets.extraLazy()
+        generatedLazy.getDeclaredField('tags').getAnnotation(ElementCollection).fetch() == (facets.lazy() ? FetchType.LAZY : FetchType.EAGER)
+        !newGenerator().collectionFacets((HibernateBasicProperty) unbound(GenCollSingle).getHibernatePropertyByName('tags')).extraLazy()
     }
 
     void "a map of enums is rejected by name, because the binder itself cannot export its schema"() {
@@ -1648,18 +1651,14 @@ class GrailsDomainGeneratorSpec extends HibernateGormDatastoreSpec {
         tags.isAnnotationPresent(ManyToMany)
     }
 
-    void "an extra-lazy collection is rejected by name"() {
+    void "an explicit lazy true on a collection of entities is an extra-lazy collection in the facets"() {
         given:
-        GrailsHibernatePersistentEntity entity = unbound(domain)
-        HibernatePersistentProperty property = entity.getHibernatePropertyByName(name)
+        GrailsHibernatePersistentEntity entity = unbound(GenOmLazyOwner)
+        HibernateToManyEntityProperty property = (HibernateToManyEntityProperty) entity.getHibernatePropertyByName('tags')
 
         expect:
-        !newGenerator().supports(property)
-        newGenerator().unsupportedReason(entity, property).contains(reason)
-
-        where:
-        domain          | name     | reason
-        GenOmLazyOwner  | 'tags'   | 'extra-lazy'
+        newGenerator().supports(property)
+        newGenerator().toManyFacets(property).extraLazy()
     }
 
     void "Hibernate's own annotation binder reads a bidirectional collection as an inverse one-to-many on the generated target"() {


(grails-core) 10/14: fix: take the identifier of a table per hierarchy from its root in the GORM column snapshot

Posted by bo...@apache.org.
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 1269efa13ec7a05dca5be2eb29f173a258715536
Author: Walter Duque de Estrada <wb...@mac.com>
AuthorDate: Tue Oct 6 11:46:13 2026 -0500

    fix: take the identifier of a table per hierarchy from its root in the GORM column snapshot
    
    GormColumnSnapshotGenerator looked up the first entity bound to the table of a
    column. The domain binder lists a single table subclass before its root, so the
    identifier test (root class only) failed and the id column of the hierarchy lost
    its autoIncrement in the snapshot and in the generated changelog.
    
    The generated domain classes list the root first, which the snapshot comparison
    between the two modes made visible.
    
    Co-Authored-By: Claude Sonnet 5.5 <no...@anthropic.com>
---
 .../liquibase/GormColumnSnapshotGenerator.groovy   |  8 ++-
 .../GormColumnSnapshotGeneratorSpec.groovy         | 57 ++++++++++++++++++++++
 2 files changed, 63 insertions(+), 2 deletions(-)

diff --git a/grails-data-hibernate7/dbmigration/src/main/groovy/org/grails/plugins/databasemigration/liquibase/GormColumnSnapshotGenerator.groovy b/grails-data-hibernate7/dbmigration/src/main/groovy/org/grails/plugins/databasemigration/liquibase/GormColumnSnapshotGenerator.groovy
index 39b99c8fed..bae0b42f2c 100644
--- a/grails-data-hibernate7/dbmigration/src/main/groovy/org/grails/plugins/databasemigration/liquibase/GormColumnSnapshotGenerator.groovy
+++ b/grails-data-hibernate7/dbmigration/src/main/groovy/org/grails/plugins/databasemigration/liquibase/GormColumnSnapshotGenerator.groovy
@@ -86,8 +86,12 @@ class GormColumnSnapshotGenerator implements SnapshotGenerator {
 
         GrailsHibernatePersistentEntity gpe = (GrailsHibernatePersistentEntity) entity
 
-        if (isIdentifier(pc, column.name)) {
-            applyGormIdentitySettings(column, gpe)
+        // a subclass of a table per hierarchy shares the table of its root, and Hibernate may list it first: the identifier
+        // and its generator belong to the root
+        PersistentClass identifierOwner = pc.table.is(pc.rootClass.table) ? pc.rootClass : pc
+        if (isIdentifier(identifierOwner, column.name)) {
+            PersistentEntity owner = mappingContext.getPersistentEntity(identifierOwner.className ?: identifierOwner.entityName)
+            applyGormIdentitySettings(column, owner instanceof GrailsHibernatePersistentEntity ? (GrailsHibernatePersistentEntity) owner : gpe)
         } else {
             PersistentProperty prop = resolveGormProperty(gpe, column.name)
             if (prop) {
diff --git a/grails-data-hibernate7/dbmigration/src/test/groovy/org/grails/plugins/databasemigration/liquibase/GormColumnSnapshotGeneratorSpec.groovy b/grails-data-hibernate7/dbmigration/src/test/groovy/org/grails/plugins/databasemigration/liquibase/GormColumnSnapshotGeneratorSpec.groovy
index 54f28ebcd9..4355ace280 100644
--- a/grails-data-hibernate7/dbmigration/src/test/groovy/org/grails/plugins/databasemigration/liquibase/GormColumnSnapshotGeneratorSpec.groovy
+++ b/grails-data-hibernate7/dbmigration/src/test/groovy/org/grails/plugins/databasemigration/liquibase/GormColumnSnapshotGeneratorSpec.groovy
@@ -40,6 +40,7 @@ import org.hibernate.boot.spi.MetadataBuildingOptions
 import org.hibernate.dialect.H2Dialect
 import org.hibernate.mapping.PersistentClass
 import org.hibernate.mapping.RootClass
+import org.hibernate.mapping.SingleTableSubclass
 import org.hibernate.mapping.BasicValue
 import org.hibernate.mapping.Table as HibernateTable
 import spock.lang.Specification
@@ -220,4 +221,60 @@ class GormColumnSnapshotGeneratorSpec extends Specification {
         !result.isNullable()
         result.getAutoIncrementInformation() != null
     }
+
+    def "the identifier of a table per hierarchy root gets the identity settings when a subclass of the table is listed first"() {
+        given:
+        Column example = new Column(name: "id")
+        Table table = new Table(name: "test_table")
+        example.setRelation(table)
+
+        Column chainResult = new Column(name: "id")
+        chainResult.setRelation(table)
+        chainResult.setNullable(true)
+
+        SnapshotGeneratorChain chain = Mock()
+        DatabaseSnapshot snapshot = Mock()
+        GormDatabase database = Mock()
+        HibernateDatastore datastore = Mock()
+        Metadata metadata = Mock()
+        HibernateMappingContext mappingContext = Mock()
+        MetadataBuildingContext buildingContext = createMetadataBuildingContext()
+
+        RootClass root = new RootClass(buildingContext)
+        root.setEntityName("TestEntity")
+        root.setClassName("com.example.TestEntity")
+        HibernateTable hTable = new HibernateTable("hibernate", "test_table")
+        root.setTable(hTable)
+        BasicValue identifier = new BasicValue(buildingContext, hTable)
+        identifier.addColumn(new org.hibernate.mapping.Column("id"))
+        root.setIdentifier(identifier)
+        SingleTableSubclass sub = new SingleTableSubclass(root, buildingContext)
+        sub.setEntityName("SubEntity")
+        sub.setClassName("com.example.SubEntity")
+
+        GrailsHibernatePersistentEntity rootEntity = Mock()
+        GrailsHibernatePersistentEntity subEntity = Mock()
+        Mapping gormMapping = Mock()
+        HibernateSimpleIdentity gormIdentity = Mock()
+
+        when:
+        Column result = generator.snapshot(example, snapshot, chain)
+
+        then:
+        1 * chain.snapshot(example, snapshot) >> chainResult
+        snapshot.database >> database
+        database.gormDatastore >> datastore
+        database.metadata >> metadata
+        datastore.mappingContext >> mappingContext
+
+        metadata.getEntityBindings() >> [sub, root]
+        mappingContext.getPersistentEntity("com.example.TestEntity") >> rootEntity
+        mappingContext.getPersistentEntity("com.example.SubEntity") >> subEntity
+        rootEntity.getMappedForm() >> gormMapping
+        gormMapping.getIdentity() >> gormIdentity
+        gormIdentity.determineGeneratorName(_) >> "identity"
+
+        !result.isNullable()
+        result.getAutoIncrementInformation() != null
+    }
 }


(grails-core) 07/14: feat: state a registered type that converts its value as a JPA converter in generated classes

Posted by bo...@apache.org.
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 58f6323b287483801faa85ce0b3445235168399d
Author: Walter Duque de Estrada <wb...@mac.com>
AuthorDate: Tue Oct 6 10:33:37 2026 -0500

    feat: state a registered type that converts its value as a JPA converter in generated classes
    
    A property mapped type: 'yes_no', 'true_false' or 'numeric_boolean' resolves in the domain
    binder to Hibernate's registered type, which wraps a JPA attribute converter. The generator
    rejected it because no annotation names a registered type. It now writes @Convert with the
    converter class the registered type carries, together with the registered type's JDBC type
    code: numeric_boolean is a TINYINT while the converter alone resolves to an INTEGER, which
    reading the generated classes back through Hibernate's annotation binder showed. Limited to
    a scalar property of the registered type's Java type; collection elements, identifiers and
    enums stay rejected by name. The differential compares converter class and JDBC type with the
    binder's, the DDL differential covers the schema, and a real-boot spec stores and reads back
    the converted values.
    
    Co-Authored-By: Claude Sonnet 5.5 <no...@anthropic.com>
---
 .../domainbinding/jpa/GrailsDomainGenerator.groovy | 27 ++++++---
 .../cfg/domainbinding/jpa/TypeFacets.groovy        | 11 ++--
 .../GeneratedDomainClassesConverterSpec.groovy     | 65 ++++++++++++++++++++++
 .../GrailsDomainGeneratorDifferentialSpec.groovy   |  7 +++
 .../jpa/GrailsDomainGeneratorSpec.groovy           | 57 ++++++++++++++++++-
 5 files changed, 155 insertions(+), 12 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 93174b4979..253c5d88d8 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
@@ -25,6 +25,7 @@ import java.lang.reflect.Modifier
 import groovy.transform.CompileStatic
 import jakarta.persistence.AssociationOverride
 import jakarta.persistence.AssociationOverrides
+import jakarta.persistence.AttributeConverter
 import jakarta.persistence.AttributeOverride
 import jakarta.persistence.AttributeOverrides
 import jakarta.persistence.Basic
@@ -32,6 +33,7 @@ import jakarta.persistence.Cacheable
 import jakarta.persistence.CascadeType
 import jakarta.persistence.CollectionTable
 import jakarta.persistence.Column as JpaColumn
+import jakarta.persistence.Convert
 import jakarta.persistence.DiscriminatorColumn
 import jakarta.persistence.DiscriminatorType
 import jakarta.persistence.DiscriminatorValue
@@ -1021,6 +1023,12 @@ class GrailsDomainGenerator {
         }
         BasicType<?> registered = typeConfiguration.basicTypeRegistry.getRegisteredType(name)
         boolean identity = property instanceof HibernateSimpleIdentityProperty
+        if (registered != null && registered.valueConverter instanceof AttributeConverter && parameters.isEmpty() && !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
+            return new TypeDecision(true, name, new TypeFacets(
+                    null, registered.jdbcType.defaultSqlTypeCode, parameters, null, registered.valueConverter.getClass()))
+        }
         if (registered != null && registered.valueConverter == null && parameters.isEmpty() && !isEnum &&
                 (identity || registered.javaTypeDescriptor.javaTypeClass == boxed(type))) {
             Class<?> registeredJava = registered.javaTypeDescriptor.javaTypeClass
@@ -1033,7 +1041,8 @@ class GrailsDomainGenerator {
         } else if (isEnum) {
             problem = 'is a registered type on an enum, which the binder binds with its own type parameters'
         } else if (registered.valueConverter != null) {
-            problem = 'is a registered type that converts its value, which no annotation states'
+            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 {
@@ -2321,7 +2330,7 @@ class GrailsDomainGenerator {
         }
         TypeFacets type = typeFacets(property)
         if (type != null) {
-            annotations << typeAnnotation(type)
+            annotations.addAll(typeAnnotations(type))
         } else if (property instanceof HibernateEnumProperty) {
             annotations << enumAnnotation((HibernateEnumProperty) property)
         }
@@ -2587,7 +2596,7 @@ class GrailsDomainGenerator {
         }
         TypeFacets type = typeFacets(property)
         if (type != null) {
-            annotations << typeAnnotation(type)
+            annotations.addAll(typeAnnotations(type))
         } else if (property instanceof HibernateEnumProperty) {
             annotations << enumAnnotation((HibernateEnumProperty) property)
         }
@@ -2763,18 +2772,22 @@ class GrailsDomainGenerator {
                 .build()]
     }
 
-    private static AnnotationDescription typeAnnotation(TypeFacets facets) {
+    private static List<AnnotationDescription> typeAnnotations(TypeFacets facets) {
+        if (facets.converter() != null) {
+            return [AnnotationDescription.Builder.ofType(Convert).define('converter', TypeDescription.ForLoadedType.of(facets.converter())).build(),
+                    AnnotationDescription.Builder.ofType(JdbcTypeCode).define('value', facets.jdbcTypeCode().intValue()).build()]
+        }
         if (facets.jdbcTypeCode() != null) {
-            return AnnotationDescription.Builder.ofType(JdbcTypeCode).define('value', facets.jdbcTypeCode().intValue()).build()
+            return [AnnotationDescription.Builder.ofType(JdbcTypeCode).define('value', facets.jdbcTypeCode().intValue()).build()]
         }
         List<AnnotationDescription> parameters = facets.parameters().collect { String name, String value ->
             AnnotationDescription.Builder.ofType(Parameter).define('name', name).define('value', value).build()
         }
-        return AnnotationDescription.Builder.ofType(Type)
+        return [AnnotationDescription.Builder.ofType(Type)
                 .define('value', TypeDescription.ForLoadedType.of(facets.userType()))
                 .defineAnnotationArray('parameters', TypeDescription.ForLoadedType.of(Parameter),
                         parameters as AnnotationDescription[])
-                .build()
+                .build()]
     }
 
     private AnnotationDescription enumAnnotation(HibernateEnumProperty property) {
diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/TypeFacets.groovy b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/TypeFacets.groovy
index a5053319a3..976e77ff24 100644
--- a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/TypeFacets.groovy
+++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/TypeFacets.groovy
@@ -22,10 +22,12 @@ import groovy.transform.CompileStatic
 
 /**
  * The explicit Hibernate type the domain binder gives a basic property, in the two forms an annotated class can
- * state it: a {@code UserType} class with its parameters ({@code @Type}), or the JDBC type of a registered legacy
- * type name such as {@code text} ({@code @JdbcTypeCode}).
+ * state it: a {@code UserType} class with its parameters ({@code @Type}), the JDBC type of a registered legacy
+ * type name such as {@code text} ({@code @JdbcTypeCode}), or the converter of a registered type name that converts its
+ * value, such as {@code yes_no} ({@code @Convert} together with the {@code @JdbcTypeCode} of the registered type, which can differ
+ * from the one the converter alone resolves to: {@code numeric_boolean} is a TINYINT, the converter alone an INTEGER).
  *
- * <p>Exactly one of {@code userType} and {@code jdbcTypeCode} is set. {@code parameters} is only meaningful for a
+ * <p>Exactly one of {@code userType} and {@code jdbcTypeCode} is set, and {@code converter} only next to {@code jdbcTypeCode}. {@code parameters} is only meaningful for a
  * user type and is never {@code null}. {@code javaType} is the Java type the registered type maps when it is not the
  * property's own, which only an identifier can have (its value comes from a generator, so the mapping may name a type for a
  * value the property's class does not hold); the generated field then has that type.</p>
@@ -37,5 +39,6 @@ record TypeFacets(
     Class<?> userType,
     Integer jdbcTypeCode,
     Map<String, String> parameters,
-    Class<?> javaType = null) {
+    Class<?> javaType = null,
+    Class<?> converter = null) {
 }
diff --git a/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/generated/GeneratedDomainClassesConverterSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/generated/GeneratedDomainClassesConverterSpec.groovy
new file mode 100644
index 0000000000..7f13c4d7ed
--- /dev/null
+++ b/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/generated/GeneratedDomainClassesConverterSpec.groovy
@@ -0,0 +1,65 @@
+/*
+ * 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 grails.gorm.tests.HibernateGormDatastoreSpec
+import grails.persistence.Entity
+
+/**
+ * A property mapped with a registered type name that converts its value ({@code type: 'yes_no'}) is stored through the same
+ * converter when the entity is bound through the generated classes: the column holds the converted value and the property
+ * reads back as a Boolean.
+ */
+class GeneratedDomainClassesConverterSpec extends HibernateGormDatastoreSpec {
+
+    void setupSpec() {
+        registerGeneratedDomainClasses(GdcConverted)
+    }
+
+    def "the converted columns hold the converted values and the properties read back"() {
+        given:
+        GdcConverted saved = new GdcConverted(yesNo: true, trueFalse: false, numeric: true).save(flush: true)
+        List row = sessionFactory.currentSession.createNativeQuery(
+                "select yes_no, true_false, numeric from gdc_converted where id = ${saved.id}".toString(), Object[]).singleResult as List
+        sessionFactory.currentSession.clear()
+
+        when:
+        GdcConverted loaded = GdcConverted.get(saved.id)
+
+        then:
+        row == ['Y', 'F', 1]
+        loaded.yesNo
+        !loaded.trueFalse
+        loaded.numeric
+    }
+}
+
+@Entity
+class GdcConverted {
+
+    Boolean yesNo
+    Boolean trueFalse
+    Boolean numeric
+
+    static mapping = {
+        yesNo type: 'yes_no'
+        trueFalse type: 'true_false'
+        numeric type: 'numeric_boolean'
+    }
+}
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 d62dae38a8..cd39cafc11 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
@@ -1074,6 +1074,13 @@ class GrailsDomainGeneratorDifferentialSpec extends HibernateGormDatastoreSpec {
             if (actualParameters != facets.parameters()) {
                 found << "${where} typeParameters: generator=${facets.parameters()} binder=${actualParameters}".toString()
             }
+        } else if (facets.converter() != null) {
+            explicitTypes["registered ${value.typeName} (converter)".toString()]++
+            BasicValue.Resolution<?> resolution = value.resolve()
+            if (value.typeName != property.getTypeName(type) || resolution.valueConverter?.getClass() != facets.converter() ||
+                    resolution.jdbcType.defaultSqlTypeCode != facets.jdbcTypeCode()) {
+                found << "${where} converter: generator=${facets.converter()}/${facets.jdbcTypeCode()} binder=${resolution.valueConverter?.getClass()}/${resolution.jdbcType.defaultSqlTypeCode} (type ${value.typeName})".toString()
+            }
         } else {
             explicitTypes["registered ${value.typeName}".toString()]++
             Integer actual = value.resolve().jdbcType.defaultSqlTypeCode
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 5c3c36d2d9..dd43c06446 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
@@ -35,6 +35,7 @@ import jakarta.persistence.Column
 import jakarta.persistence.DiscriminatorColumn
 import jakarta.persistence.DiscriminatorType
 import jakarta.persistence.DiscriminatorValue
+import jakarta.persistence.Convert
 import jakarta.persistence.ElementCollection
 import jakarta.persistence.Embeddable
 import jakarta.persistence.Embedded
@@ -72,6 +73,9 @@ import org.hibernate.annotations.FetchMode
 import org.hibernate.annotations.Formula
 import org.hibernate.annotations.IdGeneratorType
 import org.hibernate.annotations.JdbcTypeCode
+import org.hibernate.type.NumericBooleanConverter
+import org.hibernate.type.TrueFalseConverter
+import org.hibernate.type.YesNoConverter
 import org.hibernate.annotations.NotFound
 import org.hibernate.annotations.NotFoundAction
 import org.hibernate.annotations.Type
@@ -155,7 +159,7 @@ class GrailsDomainGeneratorSpec extends HibernateGormDatastoreSpec {
                 GenEmbedOwner, GenEmbedOther, GenEmbedBase, GenEmbedChild, GenEmbedFormulaOwner, GenCollSingle, GenCollKinds, GenCollLazy,
                 GenFkTarget, GenFkOwned, GenFkOwner, GenFkCascades, GenFkNodeA, GenFkNodeB, GenFkHasOneOwner, GenFkHasOneDetail, GenOneFace, GenOneNose, GenFkManyOne, GenFkOneSide, GenFkSub, GenFkSubRoot,
                 GenOmParent, GenOmChild, GenOmListed, GenOmOrdered, GenOmOrphan, GenOmFetched, GenOmTag, GenOmStep, GenOmKept, GenOmSortedOwner, GenOmMapOwner, GenMapBidiOwner, GenMapBidiChild, GenEmbAssocOwner,
-                GenMmStudent, GenMmCourse, GenMmPerson, GenMmNoOwnerA, GenMmNoOwnerB, GenParamsOnly, GenDecimal, GenUnversioned, GenUnversionedRoot, GenUnversionedChild)
+                GenMmStudent, GenMmCourse, GenMmPerson, GenMmNoOwnerA, GenMmNoOwnerB, GenParamsOnly, GenDecimal, GenUnversioned, GenUnversionedRoot, GenUnversionedChild, GenConverted)
     }
 
     List<StandardServiceRegistry> registries = []
@@ -356,6 +360,42 @@ class GrailsDomainGeneratorSpec extends HibernateGormDatastoreSpec {
         !typed.getDeclaredField('body').isAnnotationPresent(Type)
     }
 
+    void "a registered type name that converts its value becomes the JPA converter it wraps"() {
+        when:
+        Class<?> converted = generate(GenConverted)
+
+        then:
+        converted.getDeclaredField('yesNo').getAnnotation(Convert).converter() == YesNoConverter
+        converted.getDeclaredField('trueFalse').getAnnotation(Convert).converter() == TrueFalseConverter
+        converted.getDeclaredField('numeric').getAnnotation(Convert).converter() == NumericBooleanConverter
+        !converted.getDeclaredField('yesNo').isAnnotationPresent(Type)
+        converted.getDeclaredField('yesNo').getAnnotation(JdbcTypeCode).value() == java.sql.Types.CHAR
+        converted.getDeclaredField('numeric').getAnnotation(JdbcTypeCode).value() == java.sql.Types.TINYINT
+        !converted.getDeclaredField('plain').isAnnotationPresent(Convert)
+    }
+
+    void "Hibernate's own annotation binder resolves a converted type like the domain binder"() {
+        given:
+        Map<GrailsHibernatePersistentEntity, Class<?>> classes = generateGroup(GenConverted)
+        PersistentClass bound = entity(GenConverted).persistentClass
+        PersistentClass read = annotationMetadata(classes.values()).getEntityBinding(classes[entity(GenConverted)].name)
+
+        expect:
+        ['yesNo', 'trueFalse', 'numeric'].every { String name ->
+            BasicValue.Resolution<?> expected = ((BasicValue) bound.getProperty(name).value).resolve()
+            BasicValue.Resolution<?> actual = ((BasicValue) read.getProperty(name).value).resolve()
+            converterClass(expected) == converterClass(actual) &&
+                    expected.jdbcType.defaultSqlTypeCode == actual.jdbcType.defaultSqlTypeCode &&
+                    expected.domainJavaType.javaTypeClass == actual.domainJavaType.javaTypeClass
+        }
+        ((BasicValue) bound.getProperty('yesNo').value).resolve().valueConverter instanceof YesNoConverter
+    }
+
+    private static Class<?> converterClass(BasicValue.Resolution<?> resolution) {
+        def converter = resolution.valueConverter
+        return converter.hasProperty('converterBean') ? converter.converterBean.beanClass : converter.getClass()
+    }
+
     void "a property without an explicit type carries no type annotation"() {
         when:
         Class<?> typed = generate(GenTyped)
@@ -2499,6 +2539,21 @@ class GenTyped {
     }
 }
 
+@Entity
+class GenConverted {
+
+    Boolean yesNo
+    Boolean trueFalse
+    Boolean numeric
+    Boolean plain
+
+    static mapping = {
+        yesNo type: 'yes_no'
+        trueFalse type: 'true_false'
+        numeric type: 'numeric_boolean'
+    }
+}
+
 @Entity
 class GenParamsOnly {
 


(grails-core) 14/14: test: assert that generated carrier classes do not leak through serialization and by-name lookups

Posted by bo...@apache.org.
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 06b118ea3cb7c64f564322916d521d005a119589
Author: Walter Duque de Estrada <wb...@mac.com>
AuthorDate: Tue Oct 6 12:20:35 2026 -0500

    test: assert that generated carrier classes do not leak through serialization and by-name lookups
    
    Co-Authored-By: Claude Sonnet 5.5 <no...@anthropic.com>
---
 .../GeneratedDomainClassesSerializationSpec.groovy | 343 +++++++++++++++++++++
 1 file changed, 343 insertions(+)

diff --git a/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/generated/GeneratedDomainClassesSerializationSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/generated/GeneratedDomainClassesSerializationSpec.groovy
new file mode 100644
index 0000000000..afe41d37c7
--- /dev/null
+++ b/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/generated/GeneratedDomainClassesSerializationSpec.groovy
@@ -0,0 +1,343 @@
+/*
+ *  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 groovy.transform.CompileStatic
+import grails.gorm.annotation.Entity
+import org.hibernate.Hibernate
+import org.hibernate.LazyInitializationException
+import org.hibernate.Session
+import org.hibernate.SessionFactory
+import org.hibernate.dialect.H2Dialect
+import org.hibernate.metamodel.MappingMetamodel
+import org.hibernate.persister.entity.EntityPersister
+import org.hibernate.proxy.HibernateProxy
+import spock.lang.AutoCleanup
+import spock.lang.PendingFeature
+import spock.lang.Specification
+
+import org.grails.datastore.mapping.core.DatastoreUtils
+import org.grails.orm.hibernate.HibernateDatastore
+
+/**
+ * What the generated carrier classes may not do: leak. They have the names of the real domain classes and live in a loader of
+ * their own, so serialization, by-name lookups and every Class that Hibernate hands out must give the real class, as in
+ * binder mode.
+ */
+class GeneratedDomainClassesSerializationSpec extends Specification {
+
+    @AutoCleanup
+    HibernateDatastore datastore
+
+    private HibernateDatastore boot(boolean generated) {
+        datastore?.close()
+        datastore = new HibernateDatastore(DatastoreUtils.createPropertyResolver([
+                'dataSource.url'                  : "jdbc:h2:mem:gdcSer${generated}${System.nanoTime()};LOCK_TIMEOUT=10000".toString(),
+                'dataSource.dbCreate'             : 'create-drop',
+                'dataSource.dialect'              : H2Dialect.name,
+                'hibernate.hbm2ddl.auto'          : 'create-drop',
+                'hibernate.cache.queries'         : 'false',
+                'hibernate.cache.use_query_cache' : 'false',
+                'hibernate.generatedDomainClasses': generated,
+        ]), GdcSerAuthor, GdcSerBook, GdcSerPet, GdcSerDog, GdcSerComposite)
+        return datastore
+    }
+
+    private static byte[] serialize(Object value) {
+        ByteArrayOutputStream bytes = new ByteArrayOutputStream()
+        new ObjectOutputStream(bytes).withCloseable { ObjectOutputStream out -> out.writeObject(value) }
+        return bytes.toByteArray()
+    }
+
+    private static Object deserialize(byte[] bytes) {
+        return new ObjectInputStream(new ByteArrayInputStream(bytes)).withCloseable { ObjectInputStream input -> input.readObject() }
+    }
+
+    private static boolean leaks(Class<?> type) {
+        return type != null && (type.name.startsWith('org.grails.orm.hibernate.generated') ||
+                type.classLoader?.getClass()?.simpleName == 'ByteArrayClassLoader')
+    }
+
+    private Map scenario(boolean generated) {
+        HibernateDatastore booted = boot(generated)
+        Map r = [:]
+        Long authorId
+        GdcSerAuthor.withTransaction {
+            GdcSerAuthor author = new GdcSerAuthor(name: 'a', home: new GdcSerAddress(street: 's', city: 'c'), status: GdcSerStatus.OPEN)
+            author.addToBooks(new GdcSerBook(title: 'b1'))
+            author.addToBooks(new GdcSerBook(title: 'b2'))
+            author.save(flush: true, failOnError: true)
+            authorId = author.id
+            new GdcSerDog(name: 'rex', breed: 'lab').save(flush: true, failOnError: true)
+            new GdcSerComposite(keyA: 'x', keyB: 'y', label: 'c').save(flush: true, failOnError: true)
+        }
+
+        GdcSerAuthor detached = GdcSerAuthor.withNewSession { GdcSerAuthor found = GdcSerAuthor.get(authorId); found.books.size(); found }
+        byte[] bytes = serialize(detached)
+        GdcSerAuthor copy = (GdcSerAuthor) deserialize(bytes)
+        r.detachedCopy = [copy.getClass() == GdcSerAuthor, copy.name, copy.books*.title.sort(), copy.books.getClass().name,
+                          copy.home.getClass() == GdcSerAddress, copy.status, copy.books.first().getClass() == GdcSerBook]
+        r.streamMentionsGenerated = new String(bytes, 'ISO-8859-1').contains('org.grails.orm.hibernate.generated')
+
+        GdcSerAuthor lazy = GdcSerAuthor.withNewSession { GdcSerAuthor.get(authorId) }
+        GdcSerAuthor lazyCopy = (GdcSerAuthor) deserialize(serialize(lazy))
+        r.lazyInitializedBefore = Hibernate.isInitialized(lazyCopy.books)
+        try {
+            lazyCopy.books.size()
+            r.lazyAccess = 'loaded'
+        } catch (LazyInitializationException ignored) {
+            r.lazyAccess = 'LazyInitializationException'
+        }
+
+        copy.name = 'renamed'
+        copy.books.find { it.title == 'b1' }.title = 'b1 changed'
+        GdcSerAuthor.withTransaction {
+            GdcSerAuthor.withSession { Session s -> s.merge(copy) }
+        }
+        r.afterMerge = GdcSerAuthor.withNewSession {
+            GdcSerAuthor found = GdcSerAuthor.get(authorId)
+            [found.name, found.books*.title.sort(), found.version]
+        }
+        GdcSerAuthor reattached = (GdcSerAuthor) deserialize(serialize(GdcSerAuthor.withNewSession { GdcSerAuthor.get(authorId) }))
+        reattached.name = 'reattached'
+        GdcSerAuthor.withTransaction { reattached.attach(); reattached.save(flush: true) }
+        r.afterAttach = GdcSerAuthor.withNewSession { GdcSerAuthor.get(authorId).name }
+
+        GdcSerAuthor.withNewSession { Session s ->
+            Object proxy = s.getReference(GdcSerAuthor, authorId)
+            r.proxy = [proxy instanceof HibernateProxy, proxy.getClass().superclass == GdcSerAuthor,
+                       ProxyInspector.persistentClass(proxy) == GdcSerAuthor,
+                       ProxyInspector.entityName(proxy) == GdcSerAuthor.name,
+                       s.getEntityName(proxy) == GdcSerAuthor.name, Hibernate.getClass(proxy) == GdcSerAuthor,
+                       Hibernate.isInitialized(proxy)]
+            try {
+                Object proxyCopy = deserialize(serialize(proxy))
+                r.proxySerialization = [proxyCopy instanceof HibernateProxy, proxyCopy.getClass().superclass == GdcSerAuthor,
+                                        proxyCopy.name]
+            } catch (Exception e) {
+                r.proxySerialization = e.class.name
+            }
+        }
+
+        GdcSerAuthor.withNewSession { Session s ->
+            SessionFactory sf = s.sessionFactory
+            MappingMetamodel mm = sf.mappingMetamodel
+            EntityPersister persister = mm.getEntityDescriptor(GdcSerAuthor.name)
+            GdcSerAuthor found = s.get(GdcSerAuthor.name, authorId)
+            r.byName = [
+                    persister.mappedClass == GdcSerAuthor,
+                    mm.getEntityDescriptor(GdcSerAuthor).entityName,
+                    mm.findEntityDescriptor(GdcSerAuthor.name)?.mappedClass == GdcSerAuthor,
+                    mm.getEntityDescriptor(GdcSerPet).subclassEntityNames.toList().sort(),
+                    Class.forName(persister.entityName) == GdcSerAuthor,
+                    Class.forName(persister.entityName, true, Thread.currentThread().contextClassLoader) == GdcSerAuthor,
+                    found.getClass() == GdcSerAuthor,
+                    s.getEntityName(found),
+                    sf.metamodel.entity(GdcSerAuthor).javaType == GdcSerAuthor,
+                    sf.metamodel.entity(GdcSerAuthor.name).javaType == GdcSerAuthor,
+                    sf.metamodel.embeddable(GdcSerAddress).javaType == GdcSerAddress,
+                    booted.metadata.getEntityBinding(GdcSerAuthor.name).mappedClass == GdcSerAuthor,
+                    booted.metadata.getEntityBinding(GdcSerAuthor.name).proxyInterface == GdcSerAuthor,
+                    s.createQuery("from ${GdcSerAuthor.name} a where a.name = :n".toString(), GdcSerAuthor).setParameter('n', 'reattached').resultList.size(),
+                    s.createQuery('from GdcSerAuthor', GdcSerAuthor).resultList.size(),
+                    s.createEntityGraph(GdcSerAuthor).graphedType.javaType == GdcSerAuthor,
+                    s.criteriaBuilder.createQuery(GdcSerAuthor).from(GdcSerAuthor).javaType == GdcSerAuthor,
+            ]
+            r.polymorphic = [s.get(GdcSerPet, GdcSerPet.list().first().id).getClass() == GdcSerDog,
+                             s.getEntityName(s.get(GdcSerPet, GdcSerPet.list().first().id)),
+                             s.createQuery('from GdcSerPet', GdcSerPet).resultList*.getClass()*.name]
+            GdcSerAddress probeAddress = new GdcSerAddress(street: 's', city: 'c')
+            r.embeddedQueries = [
+                    s.createQuery('select a.home from GdcSerAuthor a', GdcSerAddress).resultList.collect { [it.getClass() == GdcSerAddress, it.city] },
+                    s.createQuery('from GdcSerAuthor a where a.home = :h', GdcSerAuthor).setParameter('h', probeAddress).resultList.size(),
+                    s.createQuery('from GdcSerAuthor a where a.home.city = :c', GdcSerAuthor).setParameter('c', 'c').resultList.size(),
+                    GdcSerAuthor.findAllByHome(probeAddress).size(),
+                    GdcSerAuthor.where { home.city == 'c' }.count(),
+            ]
+            r.propertyTypes = [home: mm.getEntityDescriptor(GdcSerAuthor).getPropertyType('home').returnedClass.name,
+                               id  : mm.getEntityDescriptor(GdcSerComposite).identifierType.returnedClass.name]
+            r.composite = s.createQuery('from GdcSerComposite', GdcSerComposite).resultList.collect { [it.getClass() == GdcSerComposite, it.keyA] }
+        }
+
+        Set<Class<?>> seen = new LinkedHashSet<Class<?>>()
+        Closure collect = { Closure<Object> source ->
+            try {
+                Object value = source.call()
+                if (value instanceof Class) {
+                    seen << (Class<?>) value
+                }
+            } catch (Exception ignored) {
+                // a part of the model that has no such class
+            }
+        }
+        HibernateDatastore ds = booted
+        MappingMetamodel mm = ds.sessionFactory.mappingMetamodel
+        mm.forEachEntityDescriptor { EntityPersister p ->
+            collect { p.mappedClass }
+            collect { p.concreteProxyClass }
+            collect { p.entityMetamodel.mappedClass }
+            collect { p.identifierMapping.getJavaType().javaTypeClass }
+            collect { p.representationStrategy.mappedJavaType.javaTypeClass }
+            collect { p.identifierType.returnedClass }
+            collect { ds.metadata.getEntityBinding(p.entityName).mappedClass }
+            collect { ds.metadata.getEntityBinding(p.entityName).proxyInterface }
+            p.attributeMappings.each { attribute -> collect { attribute.getJavaType().javaTypeClass } }
+            p.entityMetamodel.properties.each { property -> collect { property.type.returnedClass } }
+        }
+        ds.sessionFactory.metamodel.managedTypes.each { type -> collect { type.javaType } }
+        ds.sessionFactory.metamodel.entities.each { entity -> entity.attributes.each { attribute -> collect { attribute.javaType } } }
+        r.leakedClasses = seen.findAll { it != null && leaks(it) }*.name.sort()
+        r.mappedClasses = seen.findAll { it != null }*.name.findAll { it.startsWith('grails.gorm.tests.generated') }.toSet().sort()
+        return r
+    }
+
+    private static final Map<Boolean, Map> RESULTS = [:]
+
+    private Map results(boolean generated) {
+        return RESULTS.computeIfAbsent(generated) { scenario(generated) }
+    }
+
+    def "real instances serialize and deserialize as the real classes, detached, with their collections, and reattach"() {
+        when:
+        Map generated = results(true)
+        Map binder = results(false)
+
+        then: 'a detached entity with an initialized collection and an embedded value comes back as the real classes'
+        generated.detachedCopy == [true, 'a', ['b1', 'b2'], 'org.hibernate.collection.spi.PersistentSet', true, GdcSerStatus.OPEN, true]
+        !generated.streamMentionsGenerated
+
+        and: 'an uninitialized collection stays lazy and fails outside a session, as it does in binder mode'
+        generated.lazyInitializedBefore == false
+        generated.lazyAccess == 'LazyInitializationException'
+
+        and: 'the detached copy merges into a new session, cascading to its collection, and a deserialized entity reattaches'
+        generated.afterMerge == ['renamed', ['b1 changed', 'b2'], 1]
+        generated.afterAttach == 'reattached'
+
+        and: 'all of it is what binder mode does'
+        ['detachedCopy', 'streamMentionsGenerated', 'lazyInitializedBefore', 'lazyAccess', 'afterMerge', 'afterAttach'].every {
+            generated[it] == binder[it]
+        }
+    }
+
+    def "proxies are proxies of the real class, and a serialized proxy deserializes as binder mode's does"() {
+        when:
+        Map generated = results(true)
+        Map binder = results(false)
+
+        then: 'superclass, persistent class, entity name and Hibernate.getClass are the real ones'
+        generated.proxy == [true, true, true, true, true, true, true]
+        generated.proxySerialization == binder.proxySerialization
+        generated.proxySerialization[2] == 'reattached'
+    }
+
+    def "lookups by entity name, class name and class give the real class"() {
+        when:
+        Map generated = results(true)
+        Map binder = results(false)
+
+        then:
+        generated.byName == [
+                true, GdcSerAuthor.name, true, [GdcSerDog.name, GdcSerPet.name], true, true, true, GdcSerAuthor.name,
+                true, true, true, true, true, 1, 1, true, true,
+        ]
+        generated.byName == binder.byName
+        generated.polymorphic == [true, GdcSerDog.name, [GdcSerDog.name]]
+        generated.polymorphic == binder.polymorphic
+        generated.composite == [[true, 'x']]
+        generated.embeddedQueries == binder.embeddedQueries
+        generated.embeddedQueries == [[[true, 'c']], 1, 1, 1, 1]
+    }
+
+    def "no class that the mapping and JPA metamodels hand out is a generated class, but the cached type of an embedded value and of a composite identifier"() {
+        when:
+        Map generated = results(true)
+        Map binder = results(false)
+
+        then: 'the model has the real classes, under their own names'
+        binder.leakedClasses == []
+        generated.mappedClasses == binder.mappedClasses
+        generated.mappedClasses.containsAll([GdcSerAuthor, GdcSerBook, GdcSerPet, GdcSerDog, GdcSerComposite, GdcSerAddress]*.name)
+
+        and: 'Hibernate builds the component type of an embedded property and of a composite identifier before the switch to the real classes and caches its class, which has no public way to be reset'
+        generated.leakedClasses == [
+                'org.grails.orm.hibernate.generated.grails_gorm_tests_generated_GdcSerAddress_Embeddable',
+                'org.grails.orm.hibernate.generated.grails_gorm_tests_generated_GdcSerComposite_Id',
+        ]
+    }
+
+    @PendingFeature(reason = 'Component.getType() caches the component class when Hibernate first asks for the type while binding the generated classes; Component has no public way to reset it')
+    def "the returned class of an embedded property and of a composite identifier is the real class"() {
+        expect:
+        results(true).leakedClasses == []
+    }
+}
+
+@CompileStatic
+class ProxyInspector {
+    static Class<?> persistentClass(Object proxy) {
+        return ((HibernateProxy) proxy).getHibernateLazyInitializer().getPersistentClass()
+    }
+
+    static String entityName(Object proxy) {
+        return ((HibernateProxy) proxy).getHibernateLazyInitializer().getEntityName()
+    }
+}
+
+enum GdcSerStatus { OPEN, CLOSED }
+
+class GdcSerAddress implements Serializable {
+    String street
+    String city
+}
+
+@Entity
+class GdcSerAuthor implements Serializable {
+    String name
+    GdcSerStatus status
+    GdcSerAddress home
+    static embedded = ['home']
+    static hasMany = [books: GdcSerBook]
+}
+
+@Entity
+class GdcSerBook implements Serializable {
+    String title
+    static belongsTo = [author: GdcSerAuthor]
+}
+
+@Entity
+class GdcSerPet implements Serializable {
+    String name
+}
+
+@Entity
+class GdcSerDog extends GdcSerPet {
+    String breed
+}
+
+@Entity
+class GdcSerComposite implements Serializable {
+    String keyA
+    String keyB
+    String label
+    static mapping = {
+        id composite: ['keyA', 'keyB']
+    }
+}


(grails-core) 02/14: fix: bind a GORM entity from annotatedClasses through the generated domain classes

Posted by bo...@apache.org.
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 84c2007a1790bf206b64b269b9c42b0507e8c618
Author: Walter Duque de Estrada <wb...@mac.com>
AuthorDate: Tue Oct 6 09:52:56 2026 -0500

    fix: bind a GORM entity from annotatedClasses through the generated domain classes
    
    Hibernate keeps the first class it is given for a name. A GORM entity registered through
    hibernate.annotatedClasses was handed to Hibernate's own sources as the real class, which
    Hibernate cannot read as an entity, so it hid the class generated under the same name and
    the entity stayed unmapped (Could not resolve entity class). With the generated domain
    classes on, such an entity is only registered for the mapping context; JPA entities and
    plain classes are still handed over. Found by running AdditionalClassBindingSpec in
    generated mode.
    
    Co-Authored-By: Claude Sonnet 5.5 <no...@anthropic.com>
---
 .../HibernateMappingContextConfiguration.groovy    | 13 ++++
 ...neratedDomainClassesAnnotatedClassesSpec.groovy | 77 ++++++++++++++++++++++
 2 files changed, 90 insertions(+)

diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/HibernateMappingContextConfiguration.groovy b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/HibernateMappingContextConfiguration.groovy
index 08df1007d7..f3c1eee3e0 100644
--- a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/HibernateMappingContextConfiguration.groovy
+++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/HibernateMappingContextConfiguration.groovy
@@ -112,6 +112,9 @@ class HibernateMappingContextConfiguration extends Configuration
      * Whether Hibernate's annotation binder binds classes generated from the GORM mapping instead of the domain binder
      * building Hibernate's boot model by hand. The real domain classes are still what is persisted and loaded.
      *
+     * <p>Set it before the annotated classes are added: with the switch on, {@link #addAnnotatedClass} keeps the GORM entities
+     * that are not JPA entities away from Hibernate's own sources (see there).</p>
+     *
      * @param generatedDomainClasses {@code true} to bind generated classes; {@code false} (the default) for the domain binder
      */
     void setGeneratedDomainClasses(boolean generatedDomainClasses) {
@@ -206,9 +209,19 @@ class HibernateMappingContextConfiguration extends Configuration
         return this
     }
 
+    /**
+     * Registers the class for the mapping context and hands it to Hibernate's own sources. With
+     * {@link #setGeneratedDomainClasses generated domain classes} a GORM entity that is not a JPA entity is not handed to
+     * Hibernate: the class generated for it has the same name, and Hibernate keeps the first class it is given for a name, so
+     * the real class, which Hibernate cannot read as an entity, would hide the generated one and leave the entity unmapped.
+     * The entity is bound through the mapping context as usual.
+     */
     @Override
     Configuration addAnnotatedClass(Class annotatedClass) {
         additionalClasses.add(annotatedClass)
+        if (generatedDomainClasses && GormEntity.isAssignableFrom(annotatedClass) && !annotatedClass.isAnnotationPresent(Entity)) {
+            return this
+        }
         return super.addAnnotatedClass(annotatedClass)
     }
 
diff --git a/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/generated/GeneratedDomainClassesAnnotatedClassesSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/generated/GeneratedDomainClassesAnnotatedClassesSpec.groovy
new file mode 100644
index 0000000000..bd86591f5c
--- /dev/null
+++ b/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/generated/GeneratedDomainClassesAnnotatedClassesSpec.groovy
@@ -0,0 +1,77 @@
+/*
+ * 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 grails.gorm.annotation.Entity
+import spock.lang.AutoCleanup
+import spock.lang.Shared
+import spock.lang.Specification
+
+import org.grails.datastore.mapping.core.DatastoreUtils
+import org.grails.orm.hibernate.HibernateDatastore
+
+/**
+ * A GORM entity that reaches the datastore through {@code hibernate.annotatedClasses} is bound by the generated-domain-class
+ * path like one passed to the constructor: Hibernate keeps the first class it is given for a name, so the real class must not
+ * hide the class generated under the same name.
+ */
+class GeneratedDomainClassesAnnotatedClassesSpec extends Specification {
+
+    @Shared
+    @AutoCleanup
+    HibernateDatastore datastore = new HibernateDatastore(
+            DatastoreUtils.createPropertyResolver([
+                    'hibernate.generatedDomainClasses': true,
+                    'hibernate.annotatedClasses'      : [GdcAnnotatedThing],
+                    'dataSource.url'                  : 'jdbc:h2:mem:gdcAnnotatedClasses;LOCK_TIMEOUT=10000',
+                    'dataSource.dbCreate'             : 'create-drop',
+                    'hibernate.hbm2ddl.auto'          : 'create-drop',
+            ]),
+            GdcConstructorThing)
+
+    void "an entity registered through annotatedClasses is bound under its own name and persists real instances"() {
+        when:
+        GdcAnnotatedThing saved = GdcAnnotatedThing.withNewTransaction {
+            new GdcAnnotatedThing(name: 'a').save(flush: true)
+        }
+        GdcAnnotatedThing loaded = GdcAnnotatedThing.withNewSession { GdcAnnotatedThing.get(saved.id) }
+
+        then:
+        datastore.sessionFactory.mappingMetamodel.getEntityDescriptor(GdcAnnotatedThing).entityName == GdcAnnotatedThing.name
+        loaded.class == GdcAnnotatedThing
+        loaded.name == 'a'
+    }
+
+    void "an entity passed to the constructor is bound next to it"() {
+        expect:
+        GdcConstructorThing.withNewSession { GdcConstructorThing.count() } == 0
+    }
+}
+
+@Entity
+class GdcAnnotatedThing {
+
+    String name
+}
+
+@Entity
+class GdcConstructorThing {
+
+    String name
+}