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

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

commit 195f87baf41197e84938ca09aba5eb6159c090b8
Author: Walter Duque de Estrada <[email protected]>
AuthorDate: Tue Feb 24 13:57:18 2026 -0600

    update tests
---
 .../groovy/grails/orm/CriteriaMethodInvoker.java   |   4 +-
 .../grails/orm/CriteriaMethodInvokerSpec.groovy    | 285 +++++++++++++
 .../orm/HibernateCriteriaBuilderDirectSpec.groovy  | 378 +++++++++++++++++
 .../grails/orm/hibernate/cfg/MappingSpec.groovy    |  85 ++++
 .../orm/hibernate/cfg/PropertyConfigSpec.groovy    | 457 +++++++++++++++++++++
 5 files changed, 1207 insertions(+), 2 deletions(-)

diff --git 
a/grails-data-hibernate7/core/src/main/groovy/grails/orm/CriteriaMethodInvoker.java
 
b/grails-data-hibernate7/core/src/main/groovy/grails/orm/CriteriaMethodInvoker.java
index ae696b3d64..ef68e6fb66 100644
--- 
a/grails-data-hibernate7/core/src/main/groovy/grails/orm/CriteriaMethodInvoker.java
+++ 
b/grails-data-hibernate7/core/src/main/groovy/grails/orm/CriteriaMethodInvoker.java
@@ -200,7 +200,7 @@ public class CriteriaMethodInvoker {
     return UNHANDLED;
   }
 
-  private Object trySimpleCriteria(String name, CriteriaMethods method, 
Object[] args) {
+  protected Object trySimpleCriteria(String name, CriteriaMethods method, 
Object[] args) {
     if (args.length != 1 || args[0] == null) {
       return UNHANDLED;
     }
@@ -230,7 +230,7 @@ public class CriteriaMethodInvoker {
     return UNHANDLED;
   }
 
-  private Object tryPropertyCriteria(CriteriaMethods method, Object[] args) {
+  protected Object tryPropertyCriteria(CriteriaMethods method, Object[] args) {
     if (method == null || args.length < 2 || !(args[0] instanceof String)) {
       return UNHANDLED;
     }
diff --git 
a/grails-data-hibernate7/core/src/test/groovy/grails/orm/CriteriaMethodInvokerSpec.groovy
 
b/grails-data-hibernate7/core/src/test/groovy/grails/orm/CriteriaMethodInvokerSpec.groovy
index 4cc10bcfdf..3f9c34f5da 100644
--- 
a/grails-data-hibernate7/core/src/test/groovy/grails/orm/CriteriaMethodInvokerSpec.groovy
+++ 
b/grails-data-hibernate7/core/src/test/groovy/grails/orm/CriteriaMethodInvokerSpec.groovy
@@ -167,6 +167,291 @@ class CriteriaMethodInvokerSpec extends Specification {
         _ * builder.getMetaClass() >> 
GroovySystem.metaClassRegistry.getMetaClass(HibernateCriteriaBuilder)
         thrown(MissingMethodException)
     }
+
+    // ─── trySimpleCriteria ─────────────────────────────────────────────────
+    // Both methods are protected, so the same-package spec calls them 
directly.
+
+    void "trySimpleCriteria: idEq delegates to builder.eq('id', value)"() {
+        when:
+        invoker.trySimpleCriteria('idEq', CriteriaMethods.ID_EQUALS, [42L] as 
Object[])
+
+        then:
+        1 * builder.eq('id', 42L)
+    }
+
+    void "trySimpleCriteria: isNull with String delegates to 
hibernateQuery.isNull"() {
+        when:
+        invoker.trySimpleCriteria('isNull', CriteriaMethods.IS_NULL, 
['branch'] as Object[])
+
+        then:
+        1 * builder.calculatePropertyName('branch') >> 'branch'
+        1 * query.isNull('branch')
+    }
+
+    void "trySimpleCriteria: isNotNull with String delegates to 
hibernateQuery.isNotNull"() {
+        when:
+        invoker.trySimpleCriteria('isNotNull', CriteriaMethods.IS_NOT_NULL, 
['branch'] as Object[])
+
+        then:
+        1 * builder.calculatePropertyName('branch') >> 'branch'
+        1 * query.isNotNull('branch')
+    }
+
+    void "trySimpleCriteria: isEmpty with String delegates to 
hibernateQuery.isEmpty"() {
+        when:
+        invoker.trySimpleCriteria('isEmpty', CriteriaMethods.IS_EMPTY, 
['transactions'] as Object[])
+
+        then:
+        1 * builder.calculatePropertyName('transactions') >> 'transactions'
+        1 * query.isEmpty('transactions')
+    }
+
+    void "trySimpleCriteria: isNotEmpty with String delegates to 
hibernateQuery.isNotEmpty"() {
+        when:
+        invoker.trySimpleCriteria('isNotEmpty', CriteriaMethods.IS_NOT_EMPTY, 
['transactions'] as Object[])
+
+        then:
+        1 * builder.calculatePropertyName('transactions') >> 'transactions'
+        1 * query.isNotEmpty('transactions')
+    }
+
+    void "trySimpleCriteria: non-String arg to isNull calls 
throwRuntimeException"() {
+        given:
+        builder.throwRuntimeException(_ as RuntimeException) >> { throw it[0] }
+
+        when:
+        invoker.trySimpleCriteria('isNull', CriteriaMethods.IS_NULL, [42] as 
Object[])
+
+        then:
+        thrown(IllegalArgumentException)
+    }
+
+    void "trySimpleCriteria: null value returns UNHANDLED without touching 
builder"() {
+        when:
+        def result = invoker.trySimpleCriteria('isNull', 
CriteriaMethods.IS_NULL, [null] as Object[])
+
+        then:
+        result != null  // UNHANDLED sentinel object
+        0 * builder.calculatePropertyName(_)
+    }
+
+    void "trySimpleCriteria: null method returns UNHANDLED"() {
+        when:
+        def result = invoker.trySimpleCriteria('unknown', null, ['x'] as 
Object[])
+
+        then:
+        result != null  // UNHANDLED sentinel
+        0 * builder._
+    }
+
+    // ─── tryPropertyCriteria ───────────────────────────────────────────────
+
+    void "tryPropertyCriteria: rlike delegates to builder.rlike"() {
+        when:
+        invoker.tryPropertyCriteria(CriteriaMethods.RLIKE, ['firstName', 
'^F.*'] as Object[])
+
+        then:
+        1 * builder.calculatePropertyName('firstName') >> 'firstName'
+        1 * builder.rlike('firstName', '^F.*')
+    }
+
+    void "tryPropertyCriteria: between delegates to builder.between"() {
+        when:
+        invoker.tryPropertyCriteria(CriteriaMethods.BETWEEN, ['balance', 10, 
100] as Object[])
+
+        then:
+        1 * builder.calculatePropertyName('balance') >> 'balance'
+        1 * builder.between('balance', 10, 100)
+    }
+
+    void "tryPropertyCriteria: eq delegates to builder.eq"() {
+        when:
+        invoker.tryPropertyCriteria(CriteriaMethods.EQUALS, ['firstName', 
'Fred'] as Object[])
+
+        then:
+        1 * builder.calculatePropertyName('firstName') >> 'firstName'
+        1 * builder.eq('firstName', 'Fred')
+    }
+
+    void "tryPropertyCriteria: eq with Map params delegates to 
builder.eq(prop, val, map)"() {
+        given:
+        def params = [ignoreCase: true]
+
+        when:
+        invoker.tryPropertyCriteria(CriteriaMethods.EQUALS, ['firstName', 
'Fred', params] as Object[])
+
+        then:
+        1 * builder.calculatePropertyName('firstName') >> 'firstName'
+        1 * builder.eq('firstName', 'Fred', params)
+    }
+
+    void "tryPropertyCriteria: eqProperty delegates to builder.eqProperty"() {
+        when:
+        invoker.tryPropertyCriteria(CriteriaMethods.EQUALS_PROPERTY, 
['firstName', 'lastName'] as Object[])
+
+        then:
+        1 * builder.calculatePropertyName('firstName') >> 'firstName'
+        1 * builder.eqProperty('firstName', 'lastName')
+    }
+
+    void "tryPropertyCriteria: gt delegates to builder.gt"() {
+        when:
+        invoker.tryPropertyCriteria(CriteriaMethods.GREATER_THAN, ['balance', 
100] as Object[])
+
+        then:
+        1 * builder.calculatePropertyName('balance') >> 'balance'
+        1 * builder.gt('balance', 100)
+    }
+
+    void "tryPropertyCriteria: gtProperty delegates to builder.gtProperty"() {
+        when:
+        invoker.tryPropertyCriteria(CriteriaMethods.GREATER_THAN_PROPERTY, 
['balance', 'balance'] as Object[])
+
+        then:
+        1 * builder.calculatePropertyName('balance') >> 'balance'
+        1 * builder.gtProperty('balance', 'balance')
+    }
+
+    void "tryPropertyCriteria: ge delegates to builder.ge"() {
+        when:
+        invoker.tryPropertyCriteria(CriteriaMethods.GREATER_THAN_OR_EQUAL, 
['balance', 100] as Object[])
+
+        then:
+        1 * builder.calculatePropertyName('balance') >> 'balance'
+        1 * builder.ge('balance', 100)
+    }
+
+    void "tryPropertyCriteria: geProperty delegates to builder.geProperty"() {
+        when:
+        
invoker.tryPropertyCriteria(CriteriaMethods.GREATER_THAN_OR_EQUAL_PROPERTY, 
['balance', 'balance'] as Object[])
+
+        then:
+        1 * builder.calculatePropertyName('balance') >> 'balance'
+        1 * builder.geProperty('balance', 'balance')
+    }
+
+    void "tryPropertyCriteria: ilike delegates to builder.ilike"() {
+        when:
+        invoker.tryPropertyCriteria(CriteriaMethods.ILIKE, ['firstName', 
'fr%'] as Object[])
+
+        then:
+        1 * builder.calculatePropertyName('firstName') >> 'firstName'
+        1 * builder.ilike('firstName', 'fr%')
+    }
+
+    void "tryPropertyCriteria: in with Collection delegates to builder.in"() {
+        given:
+        def names = ['Fred', 'Barney']
+
+        when:
+        invoker.tryPropertyCriteria(CriteriaMethods.IN, ['firstName', names] 
as Object[])
+
+        then:
+        1 * builder.calculatePropertyName('firstName') >> 'firstName'
+        1 * builder.in('firstName', names)
+    }
+
+    void "tryPropertyCriteria: in with Object[] delegates to builder.in"() {
+        given:
+        def names = ['Fred', 'Barney'] as Object[]
+
+        when:
+        invoker.tryPropertyCriteria(CriteriaMethods.IN, ['firstName', names] 
as Object[])
+
+        then:
+        1 * builder.calculatePropertyName('firstName') >> 'firstName'
+        1 * builder.in('firstName', names)
+    }
+
+    void "tryPropertyCriteria: lt delegates to builder.lt"() {
+        when:
+        invoker.tryPropertyCriteria(CriteriaMethods.LESS_THAN, ['balance', 
500] as Object[])
+
+        then:
+        1 * builder.calculatePropertyName('balance') >> 'balance'
+        1 * builder.lt('balance', 500)
+    }
+
+    void "tryPropertyCriteria: ltProperty delegates to builder.ltProperty"() {
+        when:
+        invoker.tryPropertyCriteria(CriteriaMethods.LESS_THAN_PROPERTY, 
['balance', 'balance'] as Object[])
+
+        then:
+        1 * builder.calculatePropertyName('balance') >> 'balance'
+        1 * builder.ltProperty('balance', 'balance')
+    }
+
+    void "tryPropertyCriteria: le delegates to builder.le"() {
+        when:
+        invoker.tryPropertyCriteria(CriteriaMethods.LESS_THAN_OR_EQUAL, 
['balance', 500] as Object[])
+
+        then:
+        1 * builder.calculatePropertyName('balance') >> 'balance'
+        1 * builder.le('balance', 500)
+    }
+
+    void "tryPropertyCriteria: leProperty delegates to builder.leProperty"() {
+        when:
+        
invoker.tryPropertyCriteria(CriteriaMethods.LESS_THAN_OR_EQUAL_PROPERTY, 
['balance', 'balance'] as Object[])
+
+        then:
+        1 * builder.calculatePropertyName('balance') >> 'balance'
+        1 * builder.leProperty('balance', 'balance')
+    }
+
+    void "tryPropertyCriteria: like delegates to builder.like"() {
+        when:
+        invoker.tryPropertyCriteria(CriteriaMethods.LIKE, ['firstName', 'Fr%'] 
as Object[])
+
+        then:
+        1 * builder.calculatePropertyName('firstName') >> 'firstName'
+        1 * builder.like('firstName', 'Fr%')
+    }
+
+    void "tryPropertyCriteria: ne delegates to builder.ne"() {
+        when:
+        invoker.tryPropertyCriteria(CriteriaMethods.NOT_EQUAL, ['firstName', 
'Fred'] as Object[])
+
+        then:
+        1 * builder.calculatePropertyName('firstName') >> 'firstName'
+        1 * builder.ne('firstName', 'Fred')
+    }
+
+    void "tryPropertyCriteria: neProperty delegates to builder.neProperty"() {
+        when:
+        invoker.tryPropertyCriteria(CriteriaMethods.NOT_EQUAL_PROPERTY, 
['firstName', 'lastName'] as Object[])
+
+        then:
+        1 * builder.calculatePropertyName('firstName') >> 'firstName'
+        1 * builder.neProperty('firstName', 'lastName')
+    }
+
+    void "tryPropertyCriteria: sizeEq delegates to builder.sizeEq"() {
+        when:
+        invoker.tryPropertyCriteria(CriteriaMethods.SIZE_EQUALS, 
['transactions', 2] as Object[])
+
+        then:
+        1 * builder.calculatePropertyName('transactions') >> 'transactions'
+        1 * builder.sizeEq('transactions', 2)
+    }
+
+    void "tryPropertyCriteria: null method returns UNHANDLED"() {
+        when:
+        def result = invoker.tryPropertyCriteria(null, ['x', 'y'] as Object[])
+
+        then:
+        result != null  // UNHANDLED sentinel
+        0 * builder._
+    }
+
+    void "tryPropertyCriteria: non-String first arg returns UNHANDLED"() {
+        when:
+        def result = invoker.tryPropertyCriteria(CriteriaMethods.EQUALS, [42, 
'Fred'] as Object[])
+
+        then:
+        result != null  // UNHANDLED sentinel
+        0 * builder._
+    }
 }
 
 class InvokerAccount {
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
new file mode 100644
index 0000000000..d7a747b915
--- /dev/null
+++ 
b/grails-data-hibernate7/core/src/test/groovy/grails/orm/HibernateCriteriaBuilderDirectSpec.groovy
@@ -0,0 +1,378 @@
+package grails.orm
+
+import grails.gorm.annotation.Entity
+import grails.gorm.specs.HibernateGormDatastoreSpec
+import jakarta.persistence.criteria.JoinType
+import org.grails.datastore.mapping.query.Query
+import spock.lang.Shared
+
+/**
+ * Direct (non-DSL) tests for the thin-wrapper methods of {@link 
HibernateCriteriaBuilder}.
+ * <p>
+ * The existing {@link HibernateCriteriaBuilderSpec} exercises the same 
methods through the
+ * Groovy DSL (closures routed via {@code invokeMethod} → {@code 
CriteriaMethodInvoker}).
+ * JaCoCo cannot trace method-body coverage through that dynamic dispatch path.
+ * These tests call every wrapper method as a direct Java-style invocation so 
JaCoCo
+ * sees the actual method bodies executed.
+ */
+class HibernateCriteriaBuilderDirectSpec extends HibernateGormDatastoreSpec {
+
+    @Shared HibernateCriteriaBuilder builder
+
+    def setupSpec() {
+        manager.addAllDomainClasses([DirectAccount, DirectTransaction])
+    }
+
+    def setup() {
+        builder = new HibernateCriteriaBuilder(
+                DirectAccount,
+                manager.hibernateDatastore.sessionFactory,
+                manager.hibernateDatastore)
+    }
+
+    // ─── Comparison predicates ─────────────────────────────────────────────
+
+    def "eq(String, Object) delegates and returns this"() {
+        expect: builder.eq("firstName", "Fred").is(builder)
+    }
+
+    def "eq(String, Object, Map) delegates and returns this"() {
+        expect: builder.eq("firstName", "Fred", 
Collections.emptyMap()).is(builder)
+    }
+
+    def "eq with ignoreCase=true calls like on hibernateQuery"() {
+        expect: builder.eq("firstName", "Fred", [ignoreCase: true]).is(builder)
+    }
+
+    def "eq(Map, String, Object) groovy-map-first form delegates and returns 
this"() {
+        expect: builder.eq(Collections.emptyMap(), "firstName", 
"Fred").is(builder)
+    }
+
+    def "ne(String, Object) delegates and returns this"() {
+        expect: builder.ne("firstName", "Fred").is(builder)
+    }
+
+    def "gt(String, Object) delegates and returns this"() {
+        expect: builder.gt("balance", BigDecimal.ONE).is(builder)
+    }
+
+    def "ge(String, Object) delegates and returns this"() {
+        expect: builder.ge("balance", BigDecimal.ONE).is(builder)
+    }
+
+    def "lt(String, Object) delegates and returns this"() {
+        expect: builder.lt("balance", BigDecimal.TEN).is(builder)
+    }
+
+    def "le(String, Object) delegates and returns this"() {
+        expect: builder.le("balance", BigDecimal.TEN).is(builder)
+    }
+
+    def "gte(String, Object) aliases ge and returns this"() {
+        expect: builder.gte("balance", BigDecimal.ONE).is(builder)
+    }
+
+    def "lte(String, Object) aliases le and returns this"() {
+        expect: builder.lte("balance", BigDecimal.TEN).is(builder)
+    }
+
+    def "like(String, Object) delegates and returns this"() {
+        expect: builder.like("firstName", "Fr%").is(builder)
+    }
+
+    def "ilike(String, Object) delegates and returns this"() {
+        expect: builder.ilike("firstName", "fr%").is(builder)
+    }
+
+    def "rlike(String, Object) delegates and returns this"() {
+        expect: builder.rlike("firstName", "^Fr.*").is(builder)
+    }
+
+    def "between(String, Object, Object) delegates and returns this"() {
+        expect: builder.between("balance", BigDecimal.ONE, 
BigDecimal.TEN).is(builder)
+    }
+
+    // ─── Null / empty ──────────────────────────────────────────────────────
+
+    def "isEmpty(String) delegates and returns this"() {
+        expect: builder.isEmpty("transactions").is(builder)
+    }
+
+    def "isNotEmpty(String) delegates and returns this"() {
+        expect: builder.isNotEmpty("transactions").is(builder)
+    }
+
+    def "isNull(String) delegates and returns this"() {
+        expect: builder.isNull("branch").is(builder)
+    }
+
+    def "isNotNull(String) delegates and returns this"() {
+        expect: builder.isNotNull("branch").is(builder)
+    }
+
+    // ─── Identity equality ─────────────────────────────────────────────────
+
+    def "idEq(Object) aliases eq('id', o) and returns this"() {
+        given: def fred = new DirectAccount(balance: 100, firstName: "Fred", 
lastName: "F").save(failOnError: true, flush: true)
+        expect: builder.idEq(fred.id).is(builder)
+    }
+
+    def "idEquals(Object) aliases idEq and returns this"() {
+        given: def fred = DirectAccount.first()
+        expect: builder.idEquals(fred?.id ?: 1L).is(builder)
+    }
+
+    // ─── 'in' variants ────────────────────────────────────────────────────
+
+    def "in(String, Collection) delegates and returns this"() {
+        expect: builder.in("firstName", ["Fred", "Barney"]).is(builder)
+    }
+
+    def "in(String, Object[]) delegates and returns this"() {
+        expect: builder.in("firstName", ["Fred", "Barney"] as 
Object[]).is(builder)
+    }
+
+    def "inList(String, Collection) delegates to in and returns this"() {
+        expect: builder.inList("firstName", ["Fred", "Barney"]).is(builder)
+    }
+
+    def "inList(String, Object[]) delegates to in and returns this"() {
+        expect: builder.inList("firstName", ["Fred", "Barney"] as 
Object[]).is(builder)
+    }
+
+    // ─── allEq ────────────────────────────────────────────────────────────
+
+    def "allEq(Map) delegates and returns this"() {
+        expect: builder.allEq([firstName: "Fred", lastName: 
"Flintstone"]).is(builder)
+    }
+
+    // ─── Property comparisons ──────────────────────────────────────────────
+
+    def "eqProperty(String, String) delegates and returns this"() {
+        expect: builder.eqProperty("firstName", "firstName").is(builder)
+    }
+
+    def "neProperty(String, String) delegates and returns this"() {
+        expect: builder.neProperty("firstName", "lastName").is(builder)
+    }
+
+    def "gtProperty(String, String) delegates and returns this"() {
+        expect: builder.gtProperty("balance", "balance").is(builder)
+    }
+
+    def "geProperty(String, String) delegates and returns this"() {
+        expect: builder.geProperty("balance", "balance").is(builder)
+    }
+
+    def "ltProperty(String, String) delegates and returns this"() {
+        expect: builder.ltProperty("balance", "balance").is(builder)
+    }
+
+    def "leProperty(String, String) delegates and returns this"() {
+        expect: builder.leProperty("balance", "balance").is(builder)
+    }
+
+    // ─── Collection-size constraints ───────────────────────────────────────
+
+    def "sizeEq(String, int) delegates and returns this"() {
+        expect: builder.sizeEq("transactions", 2).is(builder)
+    }
+
+    def "sizeGt(String, int) delegates and returns this"() {
+        expect: builder.sizeGt("transactions", 0).is(builder)
+    }
+
+    def "sizeGe(String, int) delegates and returns this"() {
+        expect: builder.sizeGe("transactions", 1).is(builder)
+    }
+
+    def "sizeLe(String, int) delegates and returns this"() {
+        expect: builder.sizeLe("transactions", 5).is(builder)
+    }
+
+    def "sizeLt(String, int) delegates and returns this"() {
+        expect: builder.sizeLt("transactions", 3).is(builder)
+    }
+
+    def "sizeNe(String, int) delegates and returns this"() {
+        expect: builder.sizeNe("transactions", 0).is(builder)
+    }
+
+    // ─── Ordering ──────────────────────────────────────────────────────────
+
+    def "order(String) delegates via Order and returns this"() {
+        expect: builder.order("firstName").is(builder)
+    }
+
+    def "order(String, 'asc') sets ascending order and returns this"() {
+        expect: builder.order("firstName", "asc").is(builder)
+    }
+
+    def "order(String, 'desc') sets descending order and returns this"() {
+        expect: builder.order("balance", "desc").is(builder)
+    }
+
+    def "order(String, unrecognised) defaults to ascending and returns this"() 
{
+        expect: builder.order("balance", "unknown").is(builder)
+    }
+
+    def "order(Query.Order) delegates and returns this"() {
+        expect: builder.order(new Query.Order("firstName")).is(builder)
+    }
+
+    // ─── Pagination / fetch ────────────────────────────────────────────────
+
+    def "firstResult(int) delegates and returns this"() {
+        expect: builder.firstResult(5).is(builder)
+    }
+
+    def "maxResults(int) delegates and returns this"() {
+        expect: builder.maxResults(10).is(builder)
+    }
+
+    // ─── Join / select ─────────────────────────────────────────────────────
+
+    def "join(String) delegates with INNER join and returns this"() {
+        expect: builder.join("transactions").is(builder)
+    }
+
+    def "join(String, JoinType) delegates and returns this"() {
+        expect: builder.join("transactions", JoinType.LEFT).is(builder)
+    }
+
+    def "select(String) delegates and returns this"() {
+        expect: builder.select("balance").is(builder)
+    }
+
+    // ─── Cache / readOnly / lock ───────────────────────────────────────────
+
+    def "cache(boolean) sets flag and returns this"() {
+        expect: builder.cache(true).is(builder)
+    }
+
+    def "readOnly(boolean) sets flag and returns this"() {
+        expect: builder.readOnly(true).is(builder)
+    }
+
+    def "lock(boolean) sets flag without throwing"() {
+        when: builder.lock(true)
+        then: noExceptionThrown()
+    }
+
+    // ─── Projection wrappers ───────────────────────────────────────────────
+
+    def "property(String) adds property projection and returns this"() {
+        expect: builder.property("firstName").is(builder)
+    }
+
+    def "avg(String) adds avg projection and returns this"() {
+        expect: builder.avg("balance").is(builder)
+    }
+
+    def "distinct(String) adds distinct projection and returns this"() {
+        expect: builder.distinct("firstName").is(builder)
+    }
+
+    def "count() adds count projection and returns non-null ProjectionList"() {
+        expect: builder.count() != null
+    }
+
+    def "countDistinct(String) adds countDistinct projection and returns 
this"() {
+        expect: builder.countDistinct("firstName").is(builder)
+    }
+
+    def "groupProperty(String) adds groupProperty projection and returns 
this"() {
+        expect: builder.groupProperty("lastName").is(builder)
+    }
+
+    def "min(String) adds min projection and returns this"() {
+        expect: builder.min("balance").is(builder)
+    }
+
+    def "max(String) adds max projection and returns this"() {
+        expect: builder.max("balance").is(builder)
+    }
+
+    def "sum(String) adds sum projection and returns this"() {
+        expect: builder.sum("balance").is(builder)
+    }
+
+    def "rowCount() delegates to count and returns non-null ProjectionList"() {
+        expect: builder.rowCount() != null
+    }
+
+    def "id() adds id projection and returns this"() {
+        expect: builder.id().is(builder)
+    }
+
+    // ─── State flags ───────────────────────────────────────────────────────
+
+    def "setUniqueResult and isUniqueResult are symmetric"() {
+        when: builder.setUniqueResult(true)
+        then: builder.isUniqueResult()
+    }
+
+    def "setPaginationEnabledList and isPaginationEnabledList are symmetric"() 
{
+        when: builder.setPaginationEnabledList(true)
+        then: builder.isPaginationEnabledList()
+    }
+
+    def "setScroll(boolean) sets scroll flag"() {
+        when: builder.setScroll(true)
+        then: noExceptionThrown()
+    }
+
+    def "setCount(boolean) sets count flag"() {
+        when: builder.setCount(true)
+        then: builder.isCount()
+    }
+
+    def "setDistinct(boolean) sets distinct flag"() {
+        when: builder.setDistinct(true)
+        then: builder.isDistinct()
+    }
+
+    def "setDefaultFlushMode and getDefaultFlushMode are symmetric"() {
+        when: builder.setDefaultFlushMode(2)
+        then: builder.getDefaultFlushMode() == 2
+    }
+
+    def "getTargetClass returns the entity class"() {
+        expect: builder.targetClass == DirectAccount
+    }
+
+    def "getHibernateQuery returns non-null"() {
+        expect: builder.hibernateQuery != null
+    }
+
+    def "getSessionFactory returns non-null"() {
+        expect: builder.sessionFactory != null
+    }
+
+    def "getCriteriaBuilder returns non-null"() {
+        expect: builder.criteriaBuilder != null
+    }
+
+    def "setTargetClass updates the target class"() {
+        when: builder.targetClass = DirectTransaction
+        then: builder.targetClass == DirectTransaction
+    }
+}
+
+@Entity
+class DirectAccount {
+    String firstName
+    String lastName
+    BigDecimal balance
+    String branch
+    Set<DirectTransaction> transactions
+
+    static hasMany = [transactions: DirectTransaction]
+    static constraints = { branch nullable: true }
+}
+
+@Entity
+class DirectTransaction {
+    BigDecimal amount
+    static belongsTo = [account: DirectAccount]
+}
diff --git 
a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/MappingSpec.groovy
 
b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/MappingSpec.groovy
index 3e1cbc07b6..91f286da1f 100644
--- 
a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/MappingSpec.groovy
+++ 
b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/MappingSpec.groovy
@@ -117,6 +117,91 @@ class MappingSpec extends HibernateGormDatastoreSpec {
         thrown(MissingMethodException)
     }
 
+    // --- getOrInitializePropertyConfig (protected, same-package access) ---
+
+    void "getOrInitializePropertyConfig creates a new PropertyConfig when none 
exists"() {
+        given:
+        Mapping mapping = new Mapping()
+
+        when:
+        PropertyConfig pc = mapping.getOrInitializePropertyConfig('age')
+
+        then:
+        pc != null
+        mapping.columns['age'].is(pc)
+    }
+
+    void "getOrInitializePropertyConfig returns existing PropertyConfig when 
already set"() {
+        given:
+        Mapping mapping = new Mapping()
+        PropertyConfig existing = new PropertyConfig()
+        mapping.columns['age'] = existing
+
+        when:
+        PropertyConfig pc = mapping.getOrInitializePropertyConfig('age')
+
+        then:
+        pc.is(existing)
+    }
+
+    void "getOrInitializePropertyConfig clones global constraint when 
present"() {
+        given:
+        Mapping mapping = new Mapping()
+        PropertyConfig global = new PropertyConfig()
+        global.column('default_col')
+        mapping.columns['*'] = global
+
+        when:
+        PropertyConfig pc = mapping.getOrInitializePropertyConfig('someField')
+
+        then:
+        pc != null
+        !pc.is(global)                  // cloned, not the same instance
+        pc.firstColumnIsColumnCopy      // single-column clone sets the flag
+    }
+
+    // --- cloneGlobalConstraint (protected, same-package access) ---
+
+    void "cloneGlobalConstraint returns a clone with firstColumnIsColumnCopy 
set for single column"() {
+        given:
+        Mapping mapping = new Mapping()
+        PropertyConfig global = new PropertyConfig()
+        global.column('shared_col')
+        mapping.columns['*'] = global
+
+        when:
+        PropertyConfig cloned = mapping.cloneGlobalConstraint()
+
+        then:
+        cloned != null
+        !cloned.is(global)
+        cloned.firstColumnIsColumnCopy
+    }
+
+    // --- PropertyConfig.checkHasSingleColumn (protected, same-package 
access) ---
+
+    void "checkHasSingleColumn does not throw when only one column is 
configured"() {
+        given:
+        PropertyConfig pc = new PropertyConfig()
+        pc.column('my_col')
+
+        expect:
+        pc.checkHasSingleColumn()  // no exception
+    }
+
+    void "checkHasSingleColumn throws when multiple columns are configured"() {
+        given:
+        PropertyConfig pc = new PropertyConfig()
+        pc.columns << new ColumnConfig(name: 'col_a')
+        pc.columns << new ColumnConfig(name: 'col_b')
+
+        when:
+        pc.checkHasSingleColumn()
+
+        then:
+        thrown(RuntimeException)
+    }
+
 }
 
 // --- Test Domain Classes ---
diff --git 
a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/PropertyConfigSpec.groovy
 
b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/PropertyConfigSpec.groovy
new file mode 100644
index 0000000000..38687e271f
--- /dev/null
+++ 
b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/PropertyConfigSpec.groovy
@@ -0,0 +1,457 @@
+package org.grails.orm.hibernate.cfg
+
+import org.hibernate.FetchMode
+import spock.lang.Specification
+
+/**
+ * Unit spec for {@link PropertyConfig}.
+ * Placed in the same package to access protected methods directly.
+ */
+class PropertyConfigSpec extends Specification {
+
+    // ─── column(String) 
──────────────────────────────────────────────────────
+
+    void "column(String) adds a new ColumnConfig with the given name"() {
+        given:
+        PropertyConfig pc = new PropertyConfig()
+
+        when:
+        pc.column('my_col')
+
+        then:
+        pc.columns.size() == 1
+        pc.columns[0].name == 'my_col'
+    }
+
+    void "column(String) adds a second ColumnConfig when called twice 
normally"() {
+        given:
+        PropertyConfig pc = new PropertyConfig()
+
+        when:
+        pc.column('col_a')
+        pc.column('col_b')
+
+        then:
+        pc.columns.size() == 2
+    }
+
+    void "column(String) replaces name in-place when firstColumnIsColumnCopy 
is true"() {
+        given:
+        PropertyConfig pc = new PropertyConfig()
+        pc.column('original')
+        pc.firstColumnIsColumnCopy = true
+
+        when:
+        pc.column('replaced')
+
+        then:
+        pc.columns.size() == 1
+        pc.columns[0].name == 'replaced'
+        !pc.firstColumnIsColumnCopy
+    }
+
+    // ─── column(Map) 
─────────────────────────────────────────────────────────
+
+    void "column(Map) adds a ColumnConfig with the given name"() {
+        given:
+        PropertyConfig pc = new PropertyConfig()
+
+        when:
+        pc.column(name: 'map_col')
+
+        then:
+        pc.columns.size() == 1
+        pc.columns[0].name == 'map_col'
+    }
+
+    void "column(Map) configures existing column in-place when 
firstColumnIsColumnCopy is true"() {
+        given:
+        PropertyConfig pc = new PropertyConfig()
+        pc.column('original')
+        pc.firstColumnIsColumnCopy = true
+
+        when:
+        pc.column(name: 'updated')
+
+        then:
+        pc.columns.size() == 1
+        pc.columns[0].name == 'updated'
+        !pc.firstColumnIsColumnCopy
+    }
+
+    // ─── column(Closure) 
─────────────────────────────────────────────────────
+
+    void "column(Closure) adds a ColumnConfig configured by the closure"() {
+        given:
+        PropertyConfig pc = new PropertyConfig()
+
+        when:
+        pc.column { name = 'closure_col' }
+
+        then:
+        pc.columns.size() == 1
+        pc.columns[0].name == 'closure_col'
+    }
+
+    // ─── getColumn / single-column shortcuts 
─────────────────────────────────
+
+    void "getColumn returns null when no columns are configured"() {
+        expect:
+        new PropertyConfig().column == null
+    }
+
+    void "getColumn returns the column name when one column is configured"() {
+        given:
+        PropertyConfig pc = new PropertyConfig()
+        pc.column('the_col')
+
+        expect:
+        pc.column == 'the_col'
+    }
+
+    void "getColumn throws when multiple columns are configured"() {
+        given:
+        PropertyConfig pc = new PropertyConfig()
+        pc.columns << new ColumnConfig(name: 'a')
+        pc.columns << new ColumnConfig(name: 'b')
+
+        when:
+        pc.column
+
+        then:
+        thrown(RuntimeException)
+    }
+
+    void "getSqlType returns null when no columns are configured"() {
+        expect:
+        new PropertyConfig().sqlType == null
+    }
+
+    void "getIndexName returns null when no columns are configured"() {
+        expect:
+        new PropertyConfig().indexName == null
+    }
+
+    void "getEnumType returns 'default' when no columns are configured"() {
+        expect:
+        new PropertyConfig().enumType == 'default'
+    }
+
+    void "getLength returns -1 when no columns are configured"() {
+        expect:
+        new PropertyConfig().length == -1
+    }
+
+    void "getPrecision returns -1 when no columns are configured"() {
+        expect:
+        new PropertyConfig().precision == -1
+    }
+
+    // ─── setUnique / isUnique 
────────────────────────────────────────────────
+
+    void "setUnique propagates to the single column when one column exists"() {
+        given:
+        PropertyConfig pc = new PropertyConfig()
+        pc.column('u_col')
+
+        when:
+        pc.setUnique(true)
+
+        then:
+        pc.columns[0].unique
+        pc.unique
+    }
+
+    void "isUnique delegates to super when no columns exist"() {
+        given:
+        PropertyConfig pc = new PropertyConfig()
+        pc.setUnique(true)
+
+        expect:
+        pc.unique
+    }
+
+    void "isUnique delegates to super when multiple columns exist"() {
+        given:
+        PropertyConfig pc = new PropertyConfig()
+        pc.columns << new ColumnConfig(name: 'a')
+        pc.columns << new ColumnConfig(name: 'b')
+        pc.setUnique(true)
+
+        expect:
+        pc.unique  // falls through to super.isUnique()
+    }
+
+    // ─── FetchMode 
───────────────────────────────────────────────────────────
+
+    void "setFetch(JOIN) maps to EAGER strategy"() {
+        given:
+        PropertyConfig pc = new PropertyConfig()
+
+        when:
+        pc.fetch = FetchMode.JOIN
+
+        then:
+        pc.fetchMode == FetchMode.JOIN
+    }
+
+    void "setFetch(SELECT) maps to LAZY strategy"() {
+        given:
+        PropertyConfig pc = new PropertyConfig()
+
+        when:
+        pc.fetch = FetchMode.SELECT
+
+        then:
+        pc.fetchMode == FetchMode.SELECT
+    }
+
+    void "getFetchMode returns DEFAULT when no strategy is set"() {
+        expect:
+        new PropertyConfig().fetchMode == FetchMode.DEFAULT
+    }
+
+    // ─── cache 
───────────────────────────────────────────────────────────────
+
+    void "cache(Closure) creates and configures a CacheConfig"() {
+        given:
+        PropertyConfig pc = new PropertyConfig()
+
+        when:
+        pc.cache { usage = 'read-only' }
+
+        then:
+        pc.cache != null
+        pc.cache.usage == 'read-only'
+    }
+
+    void "cache(Map) creates and configures a CacheConfig"() {
+        given:
+        PropertyConfig pc = new PropertyConfig()
+
+        when:
+        pc.cache(usage: 'read-write')
+
+        then:
+        pc.cache != null
+        pc.cache.usage == 'read-write'
+    }
+
+    // ─── joinTable 
───────────────────────────────────────────────────────────
+
+    void "joinTable(String) sets the join table name"() {
+        given:
+        PropertyConfig pc = new PropertyConfig()
+
+        when:
+        pc.joinTable('book_authors')
+
+        then:
+        pc.joinTable.name == 'book_authors'
+    }
+
+    void "joinTable(Closure) configures the JoinTable"() {
+        given:
+        PropertyConfig pc = new PropertyConfig()
+
+        when:
+        pc.joinTable { name = 'jt_table' }
+
+        then:
+        pc.joinTable.name == 'jt_table'
+    }
+
+    void "joinTable(Map) sets table name and key column via map"() {
+        given:
+        PropertyConfig pc = new PropertyConfig()
+
+        when:
+        pc.joinTable(name: 'book_tag', key: 'book_id', column: 'tag_id')
+
+        then:
+        pc.joinTable.name == 'book_tag'
+        pc.joinTable.key?.name == 'book_id'
+        pc.joinTable.column?.name == 'tag_id'
+    }
+
+    void "hasJoinKeyMapping returns false when no join table key is set"() {
+        expect:
+        !new PropertyConfig().hasJoinKeyMapping()
+    }
+
+    void "hasJoinKeyMapping returns true when a join table key is set"() {
+        given:
+        PropertyConfig pc = new PropertyConfig()
+        pc.joinTable { key 'author_id' }
+
+        expect:
+        pc.hasJoinKeyMapping()
+    }
+
+    // ─── indexColumn 
─────────────────────────────────────────────────────────
+
+    void "indexColumn(Closure) creates and configures the index column 
PropertyConfig"() {
+        given:
+        PropertyConfig pc = new PropertyConfig()
+
+        when:
+        pc.indexColumn { column('idx_col') }
+
+        then:
+        pc.indexColumn != null
+        pc.indexColumn.column == 'idx_col'
+    }
+
+    // ─── scale 
───────────────────────────────────────────────────────────────
+
+    void "setScale sets scale on the existing column"() {
+        given:
+        PropertyConfig pc = new PropertyConfig()
+        pc.column('s_col')
+
+        when:
+        pc.scale = 4
+
+        then:
+        pc.scale == 4
+        pc.columns[0].scale == 4
+    }
+
+    void "setScale delegates to super when no columns are configured"() {
+        given:
+        PropertyConfig pc = new PropertyConfig()
+
+        when:
+        pc.scale = 3
+
+        then:
+        pc.scale == 3
+    }
+
+    // ─── checkHasSingleColumn (protected, same-package access) 
───────────────
+
+    void "checkHasSingleColumn passes silently for zero columns"() {
+        expect:
+        new PropertyConfig().checkHasSingleColumn()
+    }
+
+    void "checkHasSingleColumn passes silently for exactly one column"() {
+        given:
+        PropertyConfig pc = new PropertyConfig()
+        pc.column('one')
+
+        expect:
+        pc.checkHasSingleColumn()
+    }
+
+    void "checkHasSingleColumn throws for two or more columns"() {
+        given:
+        PropertyConfig pc = new PropertyConfig()
+        pc.columns << new ColumnConfig(name: 'x')
+        pc.columns << new ColumnConfig(name: 'y')
+
+        when:
+        pc.checkHasSingleColumn()
+
+        then:
+        thrown(RuntimeException)
+    }
+
+    // ─── clone 
───────────────────────────────────────────────────────────────
+
+    void "clone produces an independent deep copy of columns"() {
+        given:
+        PropertyConfig pc = new PropertyConfig()
+        pc.column('orig')
+
+        when:
+        PropertyConfig cloned = pc.clone()
+        cloned.columns[0].name = 'changed'
+
+        then:
+        pc.columns[0].name == 'orig'
+    }
+
+    void "clone copies cache independently"() {
+        given:
+        PropertyConfig pc = new PropertyConfig()
+        pc.cache { usage = 'read-only' }
+
+        when:
+        PropertyConfig cloned = pc.clone()
+        cloned.cache.usage = 'read-write'
+
+        then:
+        pc.cache.usage == 'read-only'
+    }
+
+    void "clone copies indexColumn independently"() {
+        given:
+        PropertyConfig pc = new PropertyConfig()
+        pc.indexColumn { column('idx') }
+
+        when:
+        PropertyConfig cloned = pc.clone()
+
+        then:
+        cloned.indexColumn != null
+        !cloned.indexColumn.is(pc.indexColumn)
+    }
+
+    // ─── static factories 
─────────────────────────────────────────────────────
+
+    void "configureNew(Closure) creates a PropertyConfig configured by the 
closure"() {
+        when:
+        PropertyConfig pc = PropertyConfig.configureNew { type = 'string' }
+
+        then:
+        pc != null
+        pc.type == 'string'
+    }
+
+    void "configureNew(Map) creates a PropertyConfig from a map"() {
+        when:
+        PropertyConfig pc = PropertyConfig.configureNew([column: 'map_col'])
+
+        then:
+        pc != null
+        pc.column == 'map_col'
+    }
+
+    void "configureExisting(Map) updates an existing PropertyConfig"() {
+        given:
+        PropertyConfig pc = new PropertyConfig()
+
+        when:
+        PropertyConfig result = PropertyConfig.configureExisting(pc, [column: 
'updated_col'])
+
+        then:
+        result.is(pc)
+        result.column == 'updated_col'
+    }
+
+    void "configureExisting(Closure) delegates the closure to the 
PropertyConfig"() {
+        given:
+        PropertyConfig pc = new PropertyConfig()
+
+        when:
+        PropertyConfig result = PropertyConfig.configureExisting(pc) { type = 
'integer' }
+
+        then:
+        result.is(pc)
+        result.type == 'integer'
+    }
+
+    // ─── deprecated updateable 
───────────────────────────────────────────────
+
+    void "setUpdatable and getUpdateable are symmetric"() {
+        given:
+        PropertyConfig pc = new PropertyConfig()
+
+        when:
+        pc.updatable = false
+
+        then:
+        !pc.updateable
+    }
+}


Reply via email to