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/08/30 22:31:50 UTC

(grails-core) branch test/grails-data-hibernate7-coverage created (now 7fce7d8d22)

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

borinquenkid pushed a change to branch test/grails-data-hibernate7-coverage
in repository https://gitbox.apache.org/repos/asf/grails-core.git


      at 7fce7d8d22 Add HibernateGrailsPluginSpec covering doWithSpring bean wiring

This branch includes the following new commits:

     new afe0e342f9 Add coverage for AggregatePersistenceContextInterceptor
     new 2fa63d7959 Extend HibernateCriteriaBuilderDirectSpec for low-level coverage
     new 87c7e7e822 Close coverage gaps on DetachedAssociationFunction and IdentityEnumType
     new 677fac53b7 Close proxy-unwrap and delegate-overload gaps on GrailsHibernateUtil
     new 8bf603dc0a Cover composite-identity join-key derivation in ManyToOneBinder
     new 00aa400fb6 Close remaining gaps on ChildHibernateDatastore, JpaQueryContext, SelectHqlQuery
     new 7fce7d8d22 Add HibernateGrailsPluginSpec covering doWithSpring bean wiring

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



(grails-core) 01/07: Add coverage for AggregatePersistenceContextInterceptor

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

borinquenkid pushed a commit to branch test/grails-data-hibernate7-coverage
in repository https://gitbox.apache.org/repos/asf/grails-core.git

commit afe0e342f948912eb15bb8036b75aa6e56b480bd
Author: Walter Duque de Estrada <wb...@mac.com>
AuthorDate: Sun Aug 30 16:15:00 2026 -0500

    Add coverage for AggregatePersistenceContextInterceptor
    
    AbstractMultipleDataSourceAggregatePersistenceContextInterceptor had no
    dedicated Spec and sat at 24.5% line coverage despite fanning out
    lifecycle calls (init/destroy/flush/clear/isOpen/setReadOnly/
    setReadWrite/disconnect/reconnect) across every configured data source.
    New Spec drives its concrete AggregatePersistenceContextInterceptor
    subclass through a two-datasource HibernateDatastore, raising coverage
    to 86.8%.
    
    Co-Authored-By: Claude Sonnet 5 <no...@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01QAjfckcY4EJsxranv1RyGV
---
 ...gregatePersistenceContextInterceptorSpec.groovy | 201 +++++++++++++++++++++
 1 file changed, 201 insertions(+)

diff --git a/grails-data-hibernate7/grails-plugin/src/test/groovy/org/grails/plugin/hibernate/support/AggregatePersistenceContextInterceptorSpec.groovy b/grails-data-hibernate7/grails-plugin/src/test/groovy/org/grails/plugin/hibernate/support/AggregatePersistenceContextInterceptorSpec.groovy
new file mode 100644
index 0000000000..18a47f4806
--- /dev/null
+++ b/grails-data-hibernate7/grails-plugin/src/test/groovy/org/grails/plugin/hibernate/support/AggregatePersistenceContextInterceptorSpec.groovy
@@ -0,0 +1,201 @@
+/*
+ *  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.plugin.hibernate.support
+
+import grails.gorm.annotation.Entity
+import org.grails.datastore.mapping.core.DatastoreUtils
+import org.grails.orm.hibernate.HibernateDatastore
+import org.grails.orm.hibernate.support.hibernate7.SessionHolder
+import org.hibernate.FlushMode
+import org.hibernate.SessionFactory
+import org.hibernate.dialect.H2Dialect
+import org.springframework.transaction.support.TransactionSynchronizationManager
+import spock.lang.AutoCleanup
+import spock.lang.Shared
+import spock.lang.Specification
+
+class AggregatePersistenceContextInterceptorSpec extends Specification {
+
+    @Shared Map config = [
+            'dataSource.url':"jdbc:h2:mem:aggPciSpecDB;LOCK_TIMEOUT=10000",
+            'dataSource.dbCreate': 'create-drop',
+            'dataSource.dialect': H2Dialect.name,
+            'hibernate.flush.mode': 'COMMIT',
+            'hibernate.hbm2ddl.auto': 'create-drop',
+            'dataSources.secondary':[url:"jdbc:h2:mem:aggPciSecondaryDB;LOCK_TIMEOUT=10000"],
+    ]
+
+    @Shared @AutoCleanup HibernateDatastore datastore =
+            new HibernateDatastore(DatastoreUtils.createPropertyResolver(config), AggPciBook, AggPciAuthor)
+
+    SessionFactory defaultSessionFactory = datastore.sessionFactory
+    SessionFactory secondarySessionFactory = datastore.getDatastoreForConnection('secondary').sessionFactory
+
+    def setup() {
+        unbindIfBound(defaultSessionFactory)
+        unbindIfBound(secondarySessionFactory)
+    }
+
+    def cleanup() {
+        unbindIfBound(defaultSessionFactory)
+        unbindIfBound(secondarySessionFactory)
+    }
+
+    private static void unbindIfBound(SessionFactory sf) {
+        if (TransactionSynchronizationManager.hasResource(sf)) {
+            TransactionSynchronizationManager.unbindResource(sf)
+        }
+    }
+
+    def "init and destroy open and close a session for every configured data source"() {
+        given: "an aggregate interceptor built from a multi-datasource HibernateDatastore"
+        def interceptor = new AggregatePersistenceContextInterceptor(datastore)
+
+        expect: "no session bound for either datasource initially"
+        !TransactionSynchronizationManager.hasResource(defaultSessionFactory)
+        !TransactionSynchronizationManager.hasResource(secondarySessionFactory)
+
+        when: "init is called"
+        interceptor.init()
+
+        then: "a session is bound for both the default and secondary datasource"
+        TransactionSynchronizationManager.getResource(defaultSessionFactory) instanceof SessionHolder
+        TransactionSynchronizationManager.getResource(secondarySessionFactory) instanceof SessionHolder
+
+        when: "destroy is called"
+        interceptor.destroy()
+
+        then: "both sessions are unbound"
+        !TransactionSynchronizationManager.hasResource(defaultSessionFactory)
+        !TransactionSynchronizationManager.hasResource(secondarySessionFactory)
+    }
+
+    def "isOpen reports true while at least one delegate session is open"() {
+        given: "an aggregate interceptor"
+        def interceptor = new AggregatePersistenceContextInterceptor(datastore)
+
+        expect: "not open before init"
+        !interceptor.isOpen()
+
+        when: "init is called"
+        interceptor.init()
+
+        then: "isOpen reports true"
+        interceptor.isOpen()
+
+        when: "destroy is called"
+        interceptor.destroy()
+
+        then: "isOpen reports false again"
+        !interceptor.isOpen()
+    }
+
+    def "flush and clear propagate to every configured data source"() {
+        given: "an aggregate interceptor with an open session on each datasource"
+        def interceptor = new AggregatePersistenceContextInterceptor(datastore)
+        interceptor.init()
+
+        when: "an entity is saved on each datasource and flush/clear are called"
+        new AggPciBook(title: 'default-book').save()
+        new AggPciAuthor(name: 'secondary-author').save()
+        interceptor.flush()
+        interceptor.clear()
+        interceptor.destroy()
+
+        then: "no exception occurs and both entities are persisted"
+        noExceptionThrown()
+        AggPciBook.withNewSession { AggPciBook.findByTitle('default-book') != null }
+        AggPciAuthor.withNewSession { AggPciAuthor.findByName('secondary-author') != null }
+
+        cleanup:
+        AggPciBook.withTransaction { AggPciBook.list()*.delete(flush: true) }
+        AggPciAuthor.withTransaction { AggPciAuthor.list()*.delete(flush: true) }
+    }
+
+    def "setReadOnly and setReadWrite propagate the flush mode to every configured data source"() {
+        given: "an aggregate interceptor with an open session on each datasource"
+        def interceptor = new AggregatePersistenceContextInterceptor(datastore)
+        interceptor.init()
+
+        when: "setReadOnly is called"
+        interceptor.setReadOnly()
+
+        then: "both sessions switch to manual flushing"
+        sessionFlushMode(defaultSessionFactory) == FlushMode.MANUAL
+        sessionFlushMode(secondarySessionFactory) == FlushMode.MANUAL
+
+        when: "setReadWrite is called"
+        interceptor.setReadWrite()
+
+        then: "both sessions switch back to automatic flushing"
+        sessionFlushMode(defaultSessionFactory) == FlushMode.AUTO
+        sessionFlushMode(secondarySessionFactory) == FlushMode.AUTO
+
+        cleanup:
+        interceptor.destroy()
+    }
+
+    private static FlushMode sessionFlushMode(SessionFactory sf) {
+        ((SessionHolder) TransactionSynchronizationManager.getResource(sf)).session.hibernateFlushMode
+    }
+
+    def "disconnect propagates the unsupported-operation failure from the underlying Hibernate 7 interceptor"() {
+        given: "an aggregate interceptor with an open session on each datasource"
+        def interceptor = new AggregatePersistenceContextInterceptor(datastore)
+        interceptor.init()
+
+        when: "disconnect is called"
+        interceptor.disconnect()
+
+        then: "the underlying per-datasource interceptor's unsupported-operation failure propagates"
+        thrown(UnsupportedOperationException)
+
+        cleanup:
+        interceptor.destroy()
+    }
+
+    def "reconnect propagates the unsupported-operation failure from the underlying Hibernate 7 interceptor"() {
+        given: "an aggregate interceptor with an open session on each datasource"
+        def interceptor = new AggregatePersistenceContextInterceptor(datastore)
+        interceptor.init()
+
+        when: "reconnect is called"
+        interceptor.reconnect()
+
+        then: "the underlying per-datasource interceptor's unsupported-operation failure propagates"
+        thrown(UnsupportedOperationException)
+
+        cleanup:
+        interceptor.destroy()
+    }
+}
+
+@Entity
+class AggPciBook {
+    String title
+}
+
+@Entity
+class AggPciAuthor {
+    String name
+
+    static mapping = {
+        datasource 'secondary'
+    }
+}


(grails-core) 06/07: Close remaining gaps on ChildHibernateDatastore, JpaQueryContext, SelectHqlQuery

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

borinquenkid pushed a commit to branch test/grails-data-hibernate7-coverage
in repository https://gitbox.apache.org/repos/asf/grails-core.git

commit 00aa400fb67ba2054b8a36fdb8389f0ff782bece
Author: Walter Duque de Estrada <wb...@mac.com>
AuthorDate: Sun Aug 30 17:03:49 2026 -0500

    Close remaining gaps on ChildHibernateDatastore, JpaQueryContext, SelectHqlQuery
    
    ChildHibernateDatastore: destroy() (marking destroyed without closing the
    parent's shared session factory) had no case.
    
    JpaQueryContext: the aliases-only/parent-only constructor overloads, the
    forRoot(aliases, root) static factory, setParent, getAliasedExpression's
    parent-delegation when not realized locally, the "{alias}" and
    "{alias}.path" token-resolution delegation to a parent context, and
    clone() were all untested.
    
    SelectHqlQuery: the max/offset builder-method-set fields branch in
    applyQuerySettings/getMax/getOffset (as opposed to the query-args-map
    branch already covered), the no-op setReadOnly compatibility method, and
    executeQuery's delegation to list() were untested.
    
    ChildHibernateDatastore: 80% -> 100%. SelectHqlQuery: 82.9% -> 100%.
    JpaQueryContext: 78.5% -> 95.4%.
    
    Co-Authored-By: Claude Sonnet 5 <no...@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01QAjfckcY4EJsxranv1RyGV
---
 .../ChildHibernateDatastoreUnitSpec.groovy         | 37 ++++++++
 .../orm/hibernate/query/JpaQueryContextSpec.groovy | 98 ++++++++++++++++++++++
 .../orm/hibernate/query/SelectHqlQuerySpec.groovy  | 33 ++++++++
 3 files changed, 168 insertions(+)

diff --git a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/ChildHibernateDatastoreUnitSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/ChildHibernateDatastoreUnitSpec.groovy
index 958c3a53e8..9281b1cef1 100644
--- a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/ChildHibernateDatastoreUnitSpec.groovy
+++ b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/ChildHibernateDatastoreUnitSpec.groovy
@@ -88,4 +88,41 @@ class ChildHibernateDatastoreUnitSpec extends HibernateGormDatastoreSpec {
         cleanup:
         secondaryConnectionSource?.close()
     }
+
+    void "test destroy marks the child as destroyed without closing shared resources"() {
+        given: "A primary datastore (parent) and a child datastore backed by a secondary connection source"
+        HibernateDatastore parent = getDatastore()
+        def secondaryUrl = "jdbc:h2:mem:childDestroyDB;LOCK_TIMEOUT=10000"
+        def dataSource = new DriverManagerDataSource(secondaryUrl, "sa", "")
+        def settings = new HibernateConnectionSourceSettings()
+        def factory = parent.connectionSources.getFactory()
+        def dataSourceConnectionSource = new DataSourceConnectionSource("childDestroy", dataSource, settings.getDataSource())
+        def secondaryConnectionSource = factory.create("childDestroy", dataSourceConnectionSource, settings)
+        def child = new ChildHibernateDatastore(
+                parent,
+                new SingletonConnectionSources(secondaryConnectionSource, parent.connectionSources.getBaseConfiguration()),
+                parent.mappingContext,
+                parent.eventPublisher
+        )
+
+        expect: "not destroyed initially"
+        !child.destroyed
+
+        when: "destroy is called"
+        child.destroy()
+
+        then: "the child is marked destroyed but the parent's shared session factory remains usable"
+        child.destroyed
+        parent.getSessionFactory() != null
+
+        when: "destroy is called again"
+        child.destroy()
+
+        then: "it remains destroyed without error"
+        child.destroyed
+        noExceptionThrown()
+
+        cleanup:
+        secondaryConnectionSource?.close()
+    }
 }
diff --git a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/query/JpaQueryContextSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/query/JpaQueryContextSpec.groovy
index 636e5ad693..ed05d01318 100644
--- a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/query/JpaQueryContextSpec.groovy
+++ b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/query/JpaQueryContextSpec.groovy
@@ -96,4 +96,102 @@ class JpaQueryContextSpec extends Specification {
         context.getFrom("nicknames") == join
         context.getFullyQualifiedExpression("nicknames") == join
     }
+
+    def "test aliases-only and parent-only constructor overloads"() {
+        given:
+        def root = Mock(From)
+        def alias = new HibernateAlias("face", "f", JoinType.INNER)
+
+        when: "constructed with aliases and root but no parent"
+        def aliasesContext = new JpaQueryContext([alias], root)
+
+        then:
+        aliasesContext.hasAlias("f")
+        aliasesContext.getRoot() == root
+
+        when: "constructed with a parent and root but no aliases"
+        def parentContext = new JpaQueryContext(root)
+        def childRoot = Mock(From)
+        def childContext = new JpaQueryContext(parentContext, childRoot)
+
+        then:
+        childContext.getRoot() == childRoot
+        childContext.getFullyQualifiedExpression("{alias}") == root
+    }
+
+    def "test forRoot with aliases static factory"() {
+        given:
+        def root = Mock(From)
+        def alias = new HibernateAlias("face", "f", JoinType.INNER)
+
+        when:
+        def context = JpaQueryContext.forRoot([alias], root)
+
+        then:
+        context.hasAlias("f")
+        context.getRoot() == root
+    }
+
+    def "test setParent reparents an existing context"() {
+        given:
+        def parentRoot = Mock(From)
+        def parentContext = new JpaQueryContext(parentRoot)
+        def faceJoin = Mock(Join)
+        parentContext.registerAlias("f", faceJoin)
+        def orphanContext = new JpaQueryContext(Mock(From))
+
+        expect:
+        !orphanContext.hasAlias("f")
+
+        when:
+        orphanContext.setParent(parentContext)
+
+        then:
+        orphanContext.hasAlias("f")
+    }
+
+    def "test getAliasedExpression delegates to the parent when not realized locally"() {
+        given:
+        def parentRoot = Mock(From)
+        def parentContext = new JpaQueryContext(parentRoot)
+        def faceJoin = Mock(Join)
+        parentContext.registerAlias("f", faceJoin)
+        def subContext = JpaQueryContext.forSubquery(parentContext, Mock(From))
+
+        expect:
+        subContext.getAliasedExpression("f") == faceJoin
+    }
+
+    def "test alias-token resolution delegates to the parent root and path"() {
+        given:
+        def parentRoot = Mock(From)
+        def parentContext = new JpaQueryContext(parentRoot)
+        def subRoot = Mock(From)
+        def subContext = JpaQueryContext.forSubquery(parentContext, subRoot)
+        def namePath = Mock(Path)
+
+        expect:
+        subContext.getFullyQualifiedExpression("{alias}") == parentRoot
+        subContext.getFullyQualifiedPath("{alias}") == parentRoot
+
+        when:
+        def resolved = subContext.getFullyQualifiedPath("{alias}.name")
+
+        then:
+        1 * parentRoot.get("name") >> namePath
+        resolved == namePath
+    }
+
+    def "test clone produces a distinct context instance"() {
+        given:
+        def root = Mock(From)
+        def context = new JpaQueryContext(root)
+
+        when:
+        def cloned = context.clone()
+
+        then:
+        !cloned.is(context)
+        cloned.getRoot() == root
+    }
 }
diff --git a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/query/SelectHqlQuerySpec.groovy b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/query/SelectHqlQuerySpec.groovy
index 083ad35e6c..35226212b9 100644
--- a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/query/SelectHqlQuerySpec.groovy
+++ b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/query/SelectHqlQuerySpec.groovy
@@ -386,6 +386,39 @@ class SelectHqlQuerySpec extends HibernateGormDatastoreSpec {
         hqlQuery != null
     }
 
+    void "applyQuerySettings and getMax/getOffset use the fields set via the builder methods"() {
+        when: "max and offset are set directly via the fluent builder methods rather than query args"
+        def hqlQuery = buildHqlQuery("from SelectHqlQuerySpecBook order by title")
+        hqlQuery.max(2)
+        hqlQuery.offset(1)
+        def results = hqlQuery.list()
+
+        then:
+        results.size() == 2
+        hqlQuery.max == 2
+        hqlQuery.offset == 1
+    }
+
+    void "setReadOnly is a no-op compatibility method"() {
+        when:
+        def hqlQuery = buildHqlQuery("from SelectHqlQuerySpecBook")
+        hqlQuery.setReadOnly(true)
+
+        then:
+        noExceptionThrown()
+    }
+
+    void "executeQuery delegates to list"() {
+        given:
+        def hqlQuery = buildHqlQuery("from SelectHqlQuerySpecBook")
+
+        when: "executeQuery is called directly (protected, same-package access), ignoring its arguments"
+        def results = hqlQuery.executeQuery(null, null)
+
+        then:
+        results.size() == 3
+    }
+
     void "buildQuery handles hints"() {
         given:
         def entity = mappingContext.getPersistentEntity(SelectHqlQuerySpecBook.name)


(grails-core) 05/07: Cover composite-identity join-key derivation in ManyToOneBinder

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

borinquenkid pushed a commit to branch test/grails-data-hibernate7-coverage
in repository https://gitbox.apache.org/repos/asf/grails-core.git

commit 8bf603dc0a030a67c139256b3d4d7277984e491d
Author: Walter Duque de Estrada <wb...@mac.com>
AuthorDate: Sun Aug 30 16:53:37 2026 -0500

    Cover composite-identity join-key derivation in ManyToOneBinder
    
    prepareCircularManyToMany's composite-identity branch (building join
    table keys for a self-referential many-to-many owned by an entity with
    a composite identity) was untested: neither the "reuse existing
    matching join keys" path nor the "derive keys from property names via
    the naming strategy" path was exercised.
    
    ManyToOneBinder: 78.2% -> 100%.
    
    Co-Authored-By: Claude Sonnet 5 <no...@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01QAjfckcY4EJsxranv1RyGV
---
 .../cfg/domainbinding/ManyToOneBinderSpec.groovy   | 100 +++++++++++++++++++++
 1 file changed, 100 insertions(+)

diff --git a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/ManyToOneBinderSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/ManyToOneBinderSpec.groovy
index 78416ba1cc..a0007e555c 100644
--- a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/ManyToOneBinderSpec.groovy
+++ b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/ManyToOneBinderSpec.groovy
@@ -19,8 +19,10 @@
 package org.grails.orm.hibernate.cfg.domainbinding
 
 import grails.gorm.tests.HibernateGormDatastoreSpec
+import org.grails.orm.hibernate.cfg.ColumnConfig
 import org.grails.orm.hibernate.cfg.HibernateCompositeIdentity
 import org.grails.orm.hibernate.cfg.domainbinding.hibernate.*
+import org.grails.orm.hibernate.cfg.JoinTable
 import org.grails.orm.hibernate.cfg.Mapping
 import org.grails.orm.hibernate.cfg.PersistentEntityNamingStrategy
 import org.grails.orm.hibernate.cfg.PropertyConfig
@@ -200,6 +202,104 @@ class ManyToOneBinderSpec extends HibernateGormDatastoreSpec {
         1 * propertyConfig.setJoinTable({ it.keys && it.keys[0].name == "new_prop_id" })
     }
 
+    def "prepareCircularManyToMany reuses existing join-table keys when their count matches the composite identity"() {
+        given: "a circular many-to-many owned by an entity with a composite identity and pre-configured join keys"
+        def property = Mock(HibernateManyToManyProperty)
+        def otherSide = Mock(HibernateManyToManyProperty)
+        def collectionTable = new Table("coll_table")
+
+        PersistentClass ownerClass = new RootClass(metadataBuildingContext)
+        def realCollection = new HibernateMap(metadataBuildingContext, ownerClass)
+        realCollection.setCollectionTable(collectionTable)
+
+        property.getCollection() >> realCollection
+        property.getHibernateInverseSide() >> otherSide
+
+        def compositeId = new HibernateCompositeIdentity()
+        compositeId.setPropertyNames(["partA", "partB"] as String[])
+        def mapping = new Mapping()
+        mapping.setIdentity(compositeId)
+        mapping.setColumns([:])
+
+        def ownerEntity = Mock(GrailsHibernatePersistentEntity) {
+            getMappedForm() >> mapping
+            getHibernateCompositeIdentity() >> Optional.of(compositeId)
+            getHibernateMappedForm() >> mapping
+        }
+
+        def existingJoinTable = new JoinTable()
+        existingJoinTable.setKeys([new ColumnConfig(name: "custom_part_a"), new ColumnConfig(name: "custom_part_b")])
+
+        def propertyConfig = Mock(PropertyConfig)
+        propertyConfig.hasJoinKeyMapping() >> false
+        propertyConfig.getJoinTable() >> existingJoinTable
+
+        otherSide.getHibernateOwner() >> ownerEntity
+        otherSide.getOwner() >> ownerEntity
+        ownerEntity.getName() >> "OwnerEntity"
+
+        otherSide.isCircular() >> true
+        otherSide.getName() >> "compositeProp"
+        otherSide.getMappedForm() >> propertyConfig
+        otherSide.getHibernateMappedForm() >> propertyConfig
+
+        when:
+        def result = binder.bindManyToOne(property, "/test")
+
+        then:
+        result instanceof ManyToOne
+        1 * propertyConfig.setJoinTable({ it.keys*.name == ["custom_part_a", "custom_part_b"] })
+    }
+
+    def "prepareCircularManyToMany derives join keys from property names when existing keys are absent"() {
+        given: "a circular many-to-many owned by an entity with a composite identity and no pre-configured join keys"
+        def property = Mock(HibernateManyToManyProperty)
+        def otherSide = Mock(HibernateManyToManyProperty)
+        def collectionTable = new Table("coll_table")
+
+        PersistentClass ownerClass = new RootClass(metadataBuildingContext)
+        def realCollection = new HibernateMap(metadataBuildingContext, ownerClass)
+        realCollection.setCollectionTable(collectionTable)
+
+        property.getCollection() >> realCollection
+        property.getHibernateInverseSide() >> otherSide
+
+        def compositeId = new HibernateCompositeIdentity()
+        compositeId.setPropertyNames(["partA", "partB"] as String[])
+        def mapping = new Mapping()
+        mapping.setIdentity(compositeId)
+        mapping.setColumns([:])
+
+        def ownerEntity = Mock(GrailsHibernatePersistentEntity) {
+            getMappedForm() >> mapping
+            getHibernateCompositeIdentity() >> Optional.of(compositeId)
+            getHibernateMappedForm() >> mapping
+        }
+
+        def propertyConfig = Mock(PropertyConfig)
+        propertyConfig.hasJoinKeyMapping() >> false
+        propertyConfig.getJoinTable() >> null
+
+        otherSide.getHibernateOwner() >> ownerEntity
+        otherSide.getOwner() >> ownerEntity
+        ownerEntity.getName() >> "OwnerEntity"
+
+        otherSide.isCircular() >> true
+        otherSide.getName() >> "compositeProp"
+        otherSide.getMappedForm() >> propertyConfig
+        otherSide.getHibernateMappedForm() >> propertyConfig
+
+        namingStrategy.resolveColumnName("partA") >> "part_a"
+        namingStrategy.resolveColumnName("partB") >> "part_b"
+
+        when:
+        def result = binder.bindManyToOne(property, "/test")
+
+        then:
+        result instanceof ManyToOne
+        1 * propertyConfig.setJoinTable({ it.keys*.name == ["part_a_id", "part_b_id"] })
+    }
+
     private List mockEntity(boolean composite) {
         def mapping = new Mapping()
         def compositeId = composite ? new HibernateCompositeIdentity() : null


(grails-core) 03/07: Close coverage gaps on DetachedAssociationFunction and IdentityEnumType

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

borinquenkid pushed a commit to branch test/grails-data-hibernate7-coverage
in repository https://gitbox.apache.org/repos/asf/grails-core.git

commit 87c7e7e822d9c54e2295875d343d15c4dcfb3a31
Author: Walter Duque de Estrada <wb...@mac.com>
AuthorDate: Sun Aug 30 16:40:36 2026 -0500

    Close coverage gaps on DetachedAssociationFunction and IdentityEnumType
    
    DetachedAssociationFunction (50%) never had a case for the recursive
    Query.Junction-flattening branch. IdentityEnumType (65.6%) was missing
    its error/fallback paths in setParameterValues (unloadable class,
    Class-attribute fallback, missing enumClass), the BIGINT/VARCHAR jdbc
    type selection branches, getBidiEnumMap's reflection-failure and
    duplicate-id-warning paths, disassemble/assemble, and nullSafeSet's
    null branch — all exercised directly through IdentityEnumType's public
    UserType/ParameterizedType contract methods.
    
    DetachedAssociationFunction: 50% -> 100%.
    IdentityEnumType: 65.6% -> 91.8% (BidiEnumMap: 88.9% -> 100%).
    
    Co-Authored-By: Claude Sonnet 5 <no...@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01QAjfckcY4EJsxranv1RyGV
---
 .../grails/gorm/tests/IdentityEnumTypeSpec.groovy  | 142 +++++++++++++++++++++
 .../query/DetachedAssociationFunctionSpec.groovy   |  19 +++
 2 files changed, 161 insertions(+)

diff --git a/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/IdentityEnumTypeSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/IdentityEnumTypeSpec.groovy
index 47b6afdb71..656e5e448f 100644
--- a/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/IdentityEnumTypeSpec.groovy
+++ b/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/IdentityEnumTypeSpec.groovy
@@ -21,13 +21,18 @@ package grails.gorm.tests
 import grails.gorm.annotation.Entity
 import grails.gorm.transactions.Rollback
 import org.grails.orm.hibernate.HibernateDatastore
+import org.grails.orm.hibernate.cfg.IdentityEnumType
+import org.hibernate.HibernateException
+import org.hibernate.MappingException
 import org.springframework.transaction.PlatformTransactionManager
 import spock.lang.AutoCleanup
 import spock.lang.Shared
 import spock.lang.Specification
 
 import javax.sql.DataSource
+import java.sql.PreparedStatement
 import java.sql.ResultSet
+import java.sql.Types
 
 /**
  * Created by graemerocher on 16/11/16.
@@ -62,6 +67,115 @@ class IdentityEnumTypeSpec extends Specification {
         resultSet.getInt(1) == 100
         FooWithEnum.first().mySuperValue == XEnum.X__TWO
     }
+
+    void "test setParameterValues throws a MappingException when the enum class cannot be loaded"() {
+        given:
+        def type = new IdentityEnumType()
+        def props = new Properties()
+        props.setProperty(IdentityEnumType.PARAM_ENUM_CLASS, "does.not.Exist")
+
+        when:
+        type.setParameterValues(props)
+
+        then:
+        def ex = thrown(MappingException)
+        ex.message.contains("does.not.Exist")
+    }
+
+    void "test setParameterValues falls back to a raw Class attribute when no String enumClass property is set"() {
+        given:
+        def type = new IdentityEnumType()
+        def props = new Properties()
+        props.put(IdentityEnumType.PARAM_ENUM_CLASS, XEnum)
+
+        when:
+        type.setParameterValues(props)
+
+        then:
+        noExceptionThrown()
+        type.returnedClass() == XEnum
+    }
+
+    void "test setParameterValues throws a MappingException when no enumClass parameter is provided"() {
+        given:
+        def type = new IdentityEnumType()
+
+        when:
+        type.setParameterValues(new Properties())
+
+        then:
+        def ex = thrown(MappingException)
+        ex.message.contains("enumClass parameter is required")
+    }
+
+    void "test getBidiEnumMap wraps reflection failures in a HibernateException"() {
+        when:
+        IdentityEnumType.getBidiEnumMap(EnumWithoutId)
+
+        then:
+        def ex = thrown(HibernateException)
+        ex.message.contains("BidiEnumMap")
+    }
+
+    void "test getBidiEnumMap logs a warning for duplicate ids but still builds a usable map"() {
+        when:
+        def map = IdentityEnumType.getBidiEnumMap(EnumWithDuplicateIds)
+
+        then:
+        noExceptionThrown()
+        map.getEnumValue(1) in [EnumWithDuplicateIds.A, EnumWithDuplicateIds.B]
+    }
+
+    void "test setParameterValues selects the BIGINT jdbc type for Long-keyed enums"() {
+        given:
+        def type = new IdentityEnumType()
+        def props = new Properties()
+        props.setProperty(IdentityEnumType.PARAM_ENUM_CLASS, EnumWithLongId.name)
+
+        when:
+        type.setParameterValues(props)
+
+        then:
+        type.getSqlType() == Types.BIGINT
+    }
+
+    void "test setParameterValues falls back to VARCHAR jdbc type for a non-standard id type"() {
+        given:
+        def type = new IdentityEnumType()
+        def props = new Properties()
+        props.setProperty(IdentityEnumType.PARAM_ENUM_CLASS, EnumWithCustomId.name)
+
+        when:
+        type.setParameterValues(props)
+
+        then:
+        type.getSqlType() == Types.VARCHAR
+    }
+
+    void "test disassemble and assemble round-trip a cached value"() {
+        given:
+        def type = new IdentityEnumType()
+
+        expect:
+        type.disassemble(XEnum.X__TWO) == XEnum.X__TWO
+        type.assemble(XEnum.X__TWO, null) == XEnum.X__TWO
+    }
+
+    void "test nullSafeSet sets a SQL NULL when the value is null"() {
+        given:
+        def type = new IdentityEnumType()
+        def props = new Properties()
+        props.setProperty(IdentityEnumType.PARAM_ENUM_CLASS, XEnum.name)
+        type.setParameterValues(props)
+        def statement = Mock(PreparedStatement)
+        def options = Mock(org.hibernate.type.descriptor.WrapperOptions)
+
+        when:
+        type.nullSafeSet(statement, null, 1, options)
+
+        then:
+        1 * statement.setNull(1, Types.INTEGER)
+    }
 }
 
 @Entity
@@ -108,3 +222,31 @@ enum XEnum {
         name
     }
 }
+
+enum EnumWithoutId {
+    A, B
+}
+
+enum EnumWithDuplicateIds {
+    A(1), B(1)
+
+    final int id
+
+    EnumWithDuplicateIds(int id) { this.id = id }
+}
+
+enum EnumWithLongId {
+    A(1L), B(2L)
+
+    final Long id
+
+    EnumWithLongId(Long id) { this.id = id }
+}
+
+enum EnumWithCustomId {
+    A('x' as Character), B('y' as Character)
+
+    final Character id
+
+    EnumWithCustomId(Character id) { this.id = id }
+}
diff --git a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/query/DetachedAssociationFunctionSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/query/DetachedAssociationFunctionSpec.groovy
index bdedf6e6c1..c4c3b69b33 100644
--- a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/query/DetachedAssociationFunctionSpec.groovy
+++ b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/query/DetachedAssociationFunctionSpec.groovy
@@ -64,4 +64,23 @@ class DetachedAssociationFunctionSpec extends Specification {
         then: "it should NOT extract the internal association criteria (isolation)"
         result.isEmpty()
     }
+
+    def "apply recursively flattens association criteria nested inside junctions"() {
+        given: "an association criterion, a non-matching criterion, and a nested junction with another association criterion"
+        def association = Mock(org.grails.datastore.mapping.model.types.Association) {
+            getName() >> "test"
+        }
+        def topLevelAssociationCriteria = new DetachedAssociationCriteria(Object, association)
+        def nestedAssociationCriteria = new DetachedAssociationCriteria(Object, association)
+        def nonMatching = new Query.Equals("prop", "value")
+        def nestedJunction = new Query.Conjunction().add(nestedAssociationCriteria)
+        def junction = new Query.Conjunction().add(topLevelAssociationCriteria).add(nonMatching).add(nestedJunction)
+
+        when:
+        def result = function.apply(junction)
+
+        then: "both association criteria are collected, including the one nested in the inner junction"
+        result.size() == 2
+        result.containsAll([topLevelAssociationCriteria, nestedAssociationCriteria])
+    }
 }


(grails-core) 02/07: Extend HibernateCriteriaBuilderDirectSpec for low-level coverage

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

borinquenkid pushed a commit to branch test/grails-data-hibernate7-coverage
in repository https://gitbox.apache.org/repos/asf/grails-core.git

commit 2fa63d7959b50775d3443044464326e7c7139514
Author: Walter Duque de Estrada <wb...@mac.com>
AuthorDate: Sun Aug 30 16:25:28 2026 -0500

    Extend HibernateCriteriaBuilderDirectSpec for low-level coverage
    
    HibernateCriteriaBuilder sat at 72.7% line coverage and PredicateGenerator
    at 83.2%, with several DSL methods (createAlias, exists/notExists with an
    inline closure, join/lock/cache/readOnly/select hints, eqAll/gtSome/geSome/
    ltSome/leSome, notIn, inList, ignoreCase eq) never exercised through the
    criteria DSL. Adds targeted cases to the existing "for low-level method
    coverage (JaCoCo line hits)" Direct spec, plus a case driving the
    documented PredicateGenerator.registerCriterionHandler extension point.
    
    HibernateCriteriaBuilder: 72.7% -> 89.6%. PredicateGenerator: 83.2% -> 85.2%.
    
    Co-Authored-By: Claude Sonnet 5 <no...@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01QAjfckcY4EJsxranv1RyGV
---
 .../orm/HibernateCriteriaBuilderDirectSpec.groovy  | 130 ++++++++++++++++++++-
 1 file changed, 129 insertions(+), 1 deletion(-)

diff --git a/grails-data-hibernate7/core/src/test/groovy/grails/orm/HibernateCriteriaBuilderDirectSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/grails/orm/HibernateCriteriaBuilderDirectSpec.groovy
index db8e1b6078..e4a8b3a2fd 100644
--- a/grails-data-hibernate7/core/src/test/groovy/grails/orm/HibernateCriteriaBuilderDirectSpec.groovy
+++ b/grails-data-hibernate7/core/src/test/groovy/grails/orm/HibernateCriteriaBuilderDirectSpec.groovy
@@ -21,7 +21,9 @@ package grails.orm
 import grails.gorm.DetachedCriteria
 import grails.gorm.annotation.Entity
 import grails.gorm.tests.HibernateGormDatastoreSpec
+import org.grails.datastore.mapping.query.Query
 import org.grails.datastore.mapping.query.api.BuildableCriteria
+import org.grails.orm.hibernate.query.PredicateGenerator
 import org.hibernate.SessionFactory
 import org.grails.orm.hibernate.HibernateDatastore
 
@@ -231,12 +233,138 @@ class HibernateCriteriaBuilderDirectSpec extends HibernateGormDatastoreSpec {
     void "test idEquals and lte/gte"() {
         given:
         def e = CriteriaTestEntity.findByName("A")
-        
+
         expect:
         c.list { idEquals(e.id) }.size() == 1
         c.list { lte("amount", 10) }.size() == 1
         c.list { gte("amount", 40) }.size() == 1
     }
+
+    void "test exists and notExists with an inline closure subquery"() {
+        expect: "exists/notExists build their subquery against the outer target class when given a closure"
+        c.list { exists { eq("category", "X") } }.size() == 4
+        c.list { notExists { eq("category", "does-not-exist") } }.size() == 4
+    }
+
+    void "test createAlias joins an association with the default and explicit join types"() {
+        given:
+        def a = CriteriaTestEntity.findByName("A")
+        a.addToChildren(new CriteriaTestChild(name: "c1"))
+        a.save(flush: true)
+        def b = CriteriaTestEntity.findByName("B")
+        b.addToChildren(new CriteriaTestChild(name: "c2"))
+        b.save(flush: true)
+
+        expect:
+        c.list { createAlias("children", "ch"); eq("ch.name", "c1") }*.name == ["A"]
+        c.list { createAlias("children", "chLeft", 1); eq("chLeft.name", "c2") }*.name == ["B"]
+        c.list { createAlias("children", "chRight", 2); eq("chRight.name", "c2") }*.name == ["B"]
+    }
+
+    void "test join lock cache and readOnly hints do not affect filtering"() {
+        given:
+        def a = CriteriaTestEntity.findByName("A")
+        a.addToChildren(new CriteriaTestChild(name: "c1"))
+        a.save(flush: true)
+
+        when:
+        def results = c.list {
+            join("children")
+            lock(false)
+            cache(true)
+            readOnly(true)
+            eq("name", "A")
+        }
+
+        then:
+        noExceptionThrown()
+        results*.name.unique() == ["A"]
+    }
+
+    void "test select projects onto a scalar property"() {
+        when:
+        def results = c.list {
+            select("name")
+        }
+
+        then:
+        results.sort() == ["A", "B", "C", "D"]
+    }
+
+    void "test count without an alias"() {
+        expect:
+        c.list { projections { count("name") } }.first() == 4L
+    }
+
+    void "test eqAll with a closure subquery"() {
+        expect: "only 'A' has amount equal to every value returned by the (single-row) subquery"
+        c.list {
+            eqAll("amount", {
+                projections { property("amount") }
+                eq("name", "A")
+            })
+        }*.name == ["A"]
+    }
+
+    void "test gtSome geSome ltSome and leSome with queryable criteria and closures"() {
+        given: "a subquery over the X category amounts [10, 20]"
+        def subquery = new DetachedCriteria(CriteriaTestEntity).build {
+            projections { property("amount") }
+            eq("category", "X")
+        }
+
+        expect:
+        c.list { gtSome("amount", subquery) }*.name.sort() == ["B", "C", "D"]
+        c.list { geSome("amount", subquery) }*.name.sort() == ["A", "B", "C", "D"]
+        c.list { ltSome("amount", subquery) }*.name.sort() == ["A"]
+        c.list { leSome("amount", subquery) }*.name.sort() == ["A", "B"]
+        c.list { gtSome("amount", { projections { property("amount") }; eq("category", "X") }) }*.name.sort() == ["B", "C", "D"]
+        c.list { geSome("amount", { projections { property("amount") }; eq("category", "X") }) }*.name.sort() == ["A", "B", "C", "D"]
+        c.list { ltSome("amount", { projections { property("amount") }; eq("category", "X") }) }*.name.sort() == ["A"]
+        c.list { leSome("amount", { projections { property("amount") }; eq("category", "X") }) }*.name.sort() == ["A", "B"]
+    }
+
+    void "test notIn with queryable criteria and a closure"() {
+        given:
+        def subquery = new DetachedCriteria(CriteriaTestEntity).build {
+            projections { property("category") }
+            eq("category", "X")
+        }
+
+        expect:
+        c.list { notIn("category", subquery) }*.name.sort() == ["C", "D"]
+        c.list { notIn("category", { projections { property("category") }; eq("category", "X") }) }*.name.sort() == ["C", "D"]
+    }
+
+    void "test inList with a collection and with varargs"() {
+        expect:
+        c.list { inList("category", ["X", "Y"]) }.size() == 4
+        c.list { inList("category", "X", "Y") }.size() == 4
+        c.list { inList("category", "Y") }*.name.sort() == ["C", "D"]
+    }
+
+    void "test eq with ignoreCase param delegates to a like criterion"() {
+        expect: "ignoreCase performs a substring match rather than an exact equality check"
+        c.list { eq("name", "A", [ignoreCase: true]) }*.name == ["A"]
+    }
+
+    void "test registerCriterionHandler overrides built-in criterion handling"() {
+        given: "a custom handler registered for the built-in Like criterion that always matches"
+        PredicateGenerator.registerCriterionHandler(Query.Like) { query, root, cb, criterion ->
+            cb.conjunction()
+        }
+
+        when: "a like criterion that would normally match nothing is used"
+        def results = c.list {
+            like("name", "does-not-exist%")
+        }
+
+        then: "the custom handler is invoked instead of the built-in like handling, matching everything"
+        results.size() == 4
+
+        cleanup:
+        PredicateGenerator.clearCustomCriterionHandlers()
+    }
 }
 
 @Entity


(grails-core) 07/07: Add HibernateGrailsPluginSpec covering doWithSpring bean wiring

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

borinquenkid pushed a commit to branch test/grails-data-hibernate7-coverage
in repository https://gitbox.apache.org/repos/asf/grails-core.git

commit 7fce7d8d220af2909f63acb6685c33d479bc6b8b
Author: Walter Duque de Estrada <wb...@mac.com>
AuthorDate: Sun Aug 30 17:14:47 2026 -0500

    Add HibernateGrailsPluginSpec covering doWithSpring bean wiring
    
    HibernateGrailsPlugin.doWithSpring() (the plugin descriptor's Spring
    bean-wiring closure) had 0% coverage. Following the existing repo
    pattern for testing Plugin subclasses in isolation (see
    QuartzGrailsPluginSpec), the closure is driven directly through a
    BeanBuilder against a plain DefaultGrailsApplication and
    GenericApplicationContext, with no plugin manager or full application
    bootstrap required. Covers bean-definition registration, the
    dataSourceNames side effect, the PropertySourcesConfig conversion-service
    installation branch (including invoking the registered String->Class
    converter), the domain-class artefact collection, and the no-op
    onChange() override.
    
    HibernateGrailsPlugin (and its doWithSpring closure / anonymous
    Converter): 0% -> 100%. grails-plugin module overall: 63.7% -> 77.8%.
    
    Co-Authored-By: Claude Sonnet 5 <no...@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01QAjfckcY4EJsxranv1RyGV
---
 .../hibernate/HibernateGrailsPluginSpec.groovy     | 118 +++++++++++++++++++++
 1 file changed, 118 insertions(+)

diff --git a/grails-data-hibernate7/grails-plugin/src/test/groovy/grails/plugin/hibernate/HibernateGrailsPluginSpec.groovy b/grails-data-hibernate7/grails-plugin/src/test/groovy/grails/plugin/hibernate/HibernateGrailsPluginSpec.groovy
new file mode 100644
index 0000000000..ff8163d53d
--- /dev/null
+++ b/grails-data-hibernate7/grails-plugin/src/test/groovy/grails/plugin/hibernate/HibernateGrailsPluginSpec.groovy
@@ -0,0 +1,118 @@
+/*
+ *  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.plugin.hibernate
+
+import grails.core.DefaultGrailsApplication
+import grails.core.GrailsApplication
+import grails.gorm.annotation.Entity
+import grails.spring.BeanBuilder
+import org.grails.config.PropertySourcesConfig
+import org.grails.core.artefact.DomainClassArtefactHandler
+import org.springframework.beans.factory.config.BeanDefinition
+import org.springframework.context.support.GenericApplicationContext
+import org.springframework.core.convert.support.ConfigurableConversionService
+import spock.lang.Specification
+
+class HibernateGrailsPluginSpec extends Specification {
+
+    def "doWithSpring registers the Hibernate datastore beans"() {
+        given:
+        Map<String, BeanDefinition> beans = beanDefinitionsFor([
+                'dataSource.url': 'jdbc:h2:mem:hibernateGrailsPluginSpecDb;LOCK_TIMEOUT=10000',
+        ])
+
+        expect: "the core Hibernate infrastructure beans are registered"
+        beans.containsKey('hibernateDatastore')
+        beans.containsKey('sessionFactory')
+        beans.containsKey('transactionManager')
+    }
+
+    def "doWithSpring records the configured data source names on the plugin"() {
+        given:
+        HibernateGrailsPlugin plugin = pluginFor([
+                'dataSource.url': 'jdbc:h2:mem:hibernateGrailsPluginSpecDsNames;LOCK_TIMEOUT=10000',
+        ])
+
+        when:
+        new BeanBuilder().beans(plugin.doWithSpring())
+
+        then: "the plugin's dataSourceNames were populated as a side effect"
+        plugin.dataSourceNames.contains('default')
+    }
+
+    def "doWithSpring installs a Class converter on the application context's conversion service"() {
+        given: "a PropertySourcesConfig (triggering the conversion-service-installation branch) and our own ApplicationContext, so we can inspect its ConversionService directly afterwards"
+        PropertySourcesConfig config = new PropertySourcesConfig([
+                'dataSource.url': 'jdbc:h2:mem:hibernateGrailsPluginSpecConverter;LOCK_TIMEOUT=10000',
+        ])
+        GenericApplicationContext applicationContext = new GenericApplicationContext()
+        HibernateGrailsPlugin plugin = pluginForConfig(config, applicationContext)
+        ConfigurableConversionService conversionService = applicationContext.environment.conversionService
+
+        expect: "no String->Class converter registered yet"
+        !conversionService.canConvert(String, Class)
+
+        when:
+        new BeanBuilder().beans(plugin.doWithSpring())
+
+        then: "the environment's conversion service can now convert a String to a Class"
+        conversionService.canConvert(String, Class)
+        conversionService.convert(HibernateGrailsPlugin.name, Class) == HibernateGrailsPlugin
+    }
+
+    def "onChange is a no-op"() {
+        given:
+        HibernateGrailsPlugin plugin = new HibernateGrailsPlugin()
+
+        when:
+        plugin.onChange([:])
+
+        then:
+        noExceptionThrown()
+    }
+
+    private Map<String, BeanDefinition> beanDefinitionsFor(Map<String, Object> config) {
+        BeanBuilder beanBuilder = new BeanBuilder()
+        beanBuilder.beans pluginFor(config).doWithSpring()
+        beanBuilder.beanDefinitions
+    }
+
+    private HibernateGrailsPlugin pluginFor(Map<String, Object> config) {
+        pluginForConfig(new PropertySourcesConfig(config), new GenericApplicationContext())
+    }
+
+    private HibernateGrailsPlugin pluginForConfig(PropertySourcesConfig config, GenericApplicationContext applicationContext) {
+        GrailsApplication grailsApplication = new DefaultGrailsApplication()
+        grailsApplication.config = config
+        grailsApplication.registerArtefactHandler(new DomainClassArtefactHandler())
+        grailsApplication.initialise()
+        grailsApplication.addArtefact(DomainClassArtefactHandler.TYPE, HibernateGrailsPluginSpecEntity)
+
+        HibernateGrailsPlugin plugin = new HibernateGrailsPlugin()
+        plugin.grailsApplication = grailsApplication
+        plugin.applicationContext = applicationContext
+        plugin
+    }
+}
+
+@Entity
+class HibernateGrailsPluginSpecEntity {
+    Long id
+    String name
+}


(grails-core) 04/07: Close proxy-unwrap and delegate-overload gaps on GrailsHibernateUtil

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

borinquenkid pushed a commit to branch test/grails-data-hibernate7-coverage
in repository https://gitbox.apache.org/repos/asf/grails-core.git

commit 677fac53b7d7b8e039e74b7bf01cc818666bf603
Author: Walter Duque de Estrada <wb...@mac.com>
AuthorDate: Sun Aug 30 16:48:17 2026 -0500

    Close proxy-unwrap and delegate-overload gaps on GrailsHibernateUtil
    
    setObjectToReadyOnly/setObjectToReadWrite's HibernateProxy-unwrap branches
    were never hit: canModifyReadWriteState requires the target to already be
    initialized, and prior tests fetched proxies via getReference() while the
    entity was still session-managed from an earlier statement, so Hibernate
    handed back the real instance instead of a proxy. Clearing the session
    before getReference() forces a genuine lazy proxy, which is then
    explicitly initialized while remaining a HibernateProxy reference,
    reaching the unwrap branch in both methods. Also adds cases for the
    single-argument unwrapProxy/getAssociationProxy/isInitialized/
    unwrapIfProxy convenience overloads that delegate to the default proxy
    handler.
    
    GrailsHibernateUtil: 77.9% -> 85.3%. The remaining gap is the
    annotation-name-string branch in isDomainClass (grails.persistence.Entity
    and JPA @Entity classes compiled in this project already implement
    GormEntity via the GORM AST transform before that branch is reached) and
    two TRACE-level log lines — not reachable through this module's public
    Groovy-compiled surface.
    
    Co-Authored-By: Claude Sonnet 5 <no...@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01QAjfckcY4EJsxranv1RyGV
---
 .../hibernate/cfg/GrailsHibernateUtilSpec.groovy   | 76 ++++++++++++++++++++++
 1 file changed, 76 insertions(+)

diff --git a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/GrailsHibernateUtilSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/GrailsHibernateUtilSpec.groovy
index 962781c229..64dcaca8a1 100644
--- a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/GrailsHibernateUtilSpec.groovy
+++ b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/GrailsHibernateUtilSpec.groovy
@@ -331,6 +331,82 @@ class GrailsHibernateUtilSpec extends HibernateGormDatastoreSpec {
         expect:
         !GrailsHibernateUtil.isDomainClass(GHUPojoMissingVersion)
     }
+
+    // -------------------------------------------------------------------------
+    // setObjectToReadyOnly / setObjectToReadWrite — proxy-unwrap branches
+    //
+    // canModifyReadWriteState requires Hibernate.isInitialized(target) to be true, so the
+    // proxy-unwrap branch is only reached once a HibernateProxy has already been initialized
+    // while the reference variable itself is still a HibernateProxy instance.
+    // -------------------------------------------------------------------------
+
+    @Rollback
+    def "setObjectToReadyOnly unwraps an already-initialized HibernateProxy"() {
+        given: "a proxy for an already-persisted entity, obtained after clearing the session so it isn't returned as the already-managed instance"
+        def saved = new GHUBook(title: "InitializedProxyBook", version: 0L).save(flush: true, failOnError: true)
+        def session = sessionFactory.currentSession
+        session.clear()
+        def proxy = session.getReference(GHUBook, saved.id)
+        org.hibernate.Hibernate.initialize(proxy)
+
+        expect: "the reference is still a HibernateProxy even though it has been initialized"
+        proxy instanceof HibernateProxy
+
+        when:
+        GrailsHibernateUtil.setObjectToReadyOnly(proxy, sessionFactory)
+
+        then:
+        noExceptionThrown()
+    }
+
+    @Rollback
+    def "setObjectToReadWrite unwraps an already-initialized HibernateProxy previously marked read-only"() {
+        given: "a proxy for an already-persisted entity, obtained after clearing the session so it isn't returned as the already-managed instance"
+        def saved = new GHUBook(title: "ProxyReadWriteRoundTrip", version: 0L).save(flush: true, failOnError: true)
+        def session = sessionFactory.currentSession
+        session.clear()
+        def proxy = session.getReference(GHUBook, saved.id)
+        org.hibernate.Hibernate.initialize(proxy)
+        GrailsHibernateUtil.setObjectToReadyOnly(proxy, sessionFactory)
+
+        expect:
+        proxy instanceof HibernateProxy
+
+        when:
+        GrailsHibernateUtil.setObjectToReadWrite(proxy, sessionFactory)
+
+        then:
+        noExceptionThrown()
+    }
+
+    // -------------------------------------------------------------------------
+    // Single-argument convenience overloads — delegate to the default proxy handler
+    // -------------------------------------------------------------------------
+
+    @Rollback
+    def "single-argument unwrapProxy delegates to the default proxy handler"() {
+        given: "a proxy for an already-persisted entity, obtained after clearing the session"
+        def saved = new GHUBook(title: "DefaultHandlerBook", version: 0L).save(flush: true, failOnError: true)
+        def session = sessionFactory.currentSession
+        session.clear()
+        HibernateProxy proxy = session.getReference(GHUBook, saved.id)
+
+        when:
+        def title = GrailsHibernateUtil.unwrapProxy(proxy).title
+
+        then:
+        title == "DefaultHandlerBook"
+    }
+
+    def "single-argument getAssociationProxy isInitialized and unwrapIfProxy delegate to the default handler"() {
+        given:
+        def book = new GHUBook(title: "PlainDefaultHandlerBook")
+
+        expect: "'title' is a plain String, not a proxy, so getAssociationProxy finds nothing but isInitialized is still true"
+        GrailsHibernateUtil.getAssociationProxy(book, "title") == null
+        GrailsHibernateUtil.isInitialized(book, "title")
+        GrailsHibernateUtil.unwrapIfProxy(book) == book
+    }
 }
 
 @jakarta.persistence.Entity