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 3746fe39c25c45472eb42e3b05606fb0a0b5dac7
Author: Walter Duque de Estrada <[email protected]>
AuthorDate: Thu Feb 26 12:30:09 2026 -0600

    Update tests and Javadoc for HibernateCriteriaBuilder
---
 .../grails/orm/HibernateCriteriaBuilder.java       |  26 +-
 .../orm/HibernateCriteriaBuilderDirectSpec.groovy  | 228 ++++++++-
 .../grails/orm/HibernateCriteriaBuilderSpec.groovy | 523 ++++++++++++++-------
 3 files changed, 583 insertions(+), 194 deletions(-)

diff --git 
a/grails-data-hibernate7/core/src/main/groovy/grails/orm/HibernateCriteriaBuilder.java
 
b/grails-data-hibernate7/core/src/main/groovy/grails/orm/HibernateCriteriaBuilder.java
index 1f0b0ab774..2cc6fee48d 100644
--- 
a/grails-data-hibernate7/core/src/main/groovy/grails/orm/HibernateCriteriaBuilder.java
+++ 
b/grails-data-hibernate7/core/src/main/groovy/grails/orm/HibernateCriteriaBuilder.java
@@ -53,12 +53,15 @@ import org.slf4j.LoggerFactory;
 import org.springframework.core.convert.ConversionService;
 
 /**
- * Wraps the Hibernate Criteria API in a builder. The builder can be retrieved 
through the
- * "createCriteria()" dynamic static method of Grails domain classes (Example 
in Groovy):
+ * Implements the GORM criteria DSL for Hibernate 7+. The builder exposes a 
Groovy-closure
+ * DSL that is translated into JPA Criteria queries via {@link 
HibernateQuery}. It is the
+ * backing implementation for the {@code createCriteria()} and {@code 
withCriteria()} dynamic
+ * static methods that GORM adds to every domain class.
  *
+ * <h2>DSL usage via domain class</h2>
  * <pre>
  *         def c = Account.createCriteria()
- *         def results = c {
+ *         def results = c.list {
  *             projections {
  *                 groupProperty("branch")
  *             }
@@ -72,16 +75,27 @@ import org.springframework.core.convert.ConversionService;
  *         }
  * </pre>
  *
- * <p>The builder can also be instantiated standalone with a SessionFactory 
and persistent Class
- * instance:
+ * <h2>Programmatic instantiation</h2>
+ * <p>The builder requires a {@link SessionFactory}, the target persistent 
class, and the
+ * {@link org.grails.orm.hibernate.AbstractHibernateDatastore} that owns the 
session:
  *
  * <pre>
- *      new HibernateCriteriaBuilder(clazz, sessionFactory).list {
+ *      new HibernateCriteriaBuilder(Account, sessionFactory, datastore).list {
  *         eq("firstName", "Fred")
  *      }
  * </pre>
  *
+ * <h2>Architecture</h2>
+ * <p>Closure method calls in the DSL are dispatched through
+ * {@code invokeMethod} → {@code CriteriaMethodInvoker} → {@link 
HibernateQuery}, which
+ * translates each GORM constraint into the equivalent JPA Criteria predicate.
+ * {@link grails.gorm.DetachedCriteria} can also be passed in place of a 
closure to support
+ * multi-tenant and reusable query fragments.
+ *
  * @author Graeme Rocher
+ * @author walterduquedeestrada
+ * @see HibernateQuery
+ * @see grails.gorm.DetachedCriteria
  */
 @Slf4j
 @SuppressWarnings("PMD.AvoidDuplicateLiterals")
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 d7a747b915..f876c89167 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
@@ -5,15 +5,15 @@ import grails.gorm.specs.HibernateGormDatastoreSpec
 import jakarta.persistence.criteria.JoinType
 import org.grails.datastore.mapping.query.Query
 import spock.lang.Shared
+import java.math.RoundingMode
 
 /**
- * Direct (non-DSL) tests for the thin-wrapper methods of {@link 
HibernateCriteriaBuilder}.
+ * Integration tests for {@link HibernateCriteriaBuilder}: covers both direct 
method
+ * invocations (for JaCoCo line-level coverage) and DSL-closure invocations 
against a real
+ * in-memory datastore.
  * <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.
+ * For a readable, Mock-based living-documentation spec of the DSL API see
+ * {@link HibernateCriteriaBuilderSpec}.
  */
 class HibernateCriteriaBuilderDirectSpec extends HibernateGormDatastoreSpec {
 
@@ -30,6 +30,222 @@ class HibernateCriteriaBuilderDirectSpec extends 
HibernateGormDatastoreSpec {
                 manager.hibernateDatastore)
     }
 
+    // ─── DSL integration: data-driven scenarios ────────────────────────────
+
+    def setupData() {
+        def fred = new DirectAccount(balance: 250, firstName: "Fred", 
lastName: "Flintstone", branch: "Bedrock").save(failOnError: true)
+        def barney = new DirectAccount(balance: 500, firstName: "Barney", 
lastName: "Rubble", branch: "Bedrock").save(failOnError: true)
+        new DirectAccount(balance: 100, firstName: "Wilma", lastName: 
"Flintstone", branch: "Bedrock").save(failOnError: true)
+        new DirectAccount(balance: 1000, firstName: "Pebbles", lastName: 
"Flintstone", branch: "Slate Rock and Gravel").save(failOnError: true)
+        new DirectAccount(balance: 50, firstName: "Bam-Bam", lastName: 
"Rubble", branch: null).save(failOnError: true)
+        fred.addToTransactions(new DirectTransaction(amount: 10))
+        fred.addToTransactions(new DirectTransaction(amount: 20))
+        fred.save()
+        barney.addToTransactions(new DirectTransaction(amount: 50))
+        barney.save(flush: true, failOnError: true)
+        fred
+    }
+
+    void "get with eq criteria returns matching entity"() {
+        given: setupData()
+        when:
+        def result = builder.get { eq("firstName", "Fred") }
+        then:
+        result.firstName == "Fred"
+    }
+
+    void "get with idEq criteria returns correct entity"() {
+        given:
+        def fred = setupData()
+        when:
+        def result = builder.get { idEq(fred.id) }
+        then:
+        result.id == fred.id
+        result.firstName == "Fred"
+    }
+
+    void "list with compound criteria filters correctly"() {
+        given: setupData()
+        when:
+        def results = builder.list {
+            gt("balance", BigDecimal.valueOf(200))
+            or {
+                eq("lastName", "Flintstone")
+                like("branch", "Bedrock")
+            }
+            'in'("firstName", ["Fred", "Barney", "Pebbles"])
+        }
+        then:
+        results.size() == 3
+        results*.firstName.sort() == ["Barney", "Fred", "Pebbles"]
+    }
+
+    void "ilike criteria matches case-insensitively"() {
+        given: setupData()
+        when:
+        def results = builder.list { ilike("firstName", "fr%") }
+        then:
+        results.size() == 1
+        results[0].firstName == "Fred"
+    }
+
+    void "rlike criteria matches by regexp"() {
+        given: setupData()
+        when:
+        def results = builder.list { rlike("firstName", "^F.*") }
+        then:
+        results.size() == 1
+        results[0].firstName == "Fred"
+    }
+
+    void "between criteria selects inclusive range"() {
+        given: setupData()
+        when:
+        def results = builder.list { between("balance", 
BigDecimal.valueOf(100), BigDecimal.valueOf(300)) }
+        then:
+        results.size() == 2
+        results*.firstName.sort() == ["Fred", "Wilma"]
+    }
+
+    void "sizeEq criteria filters by collection size"() {
+        given: setupData()
+        when:
+        def results = builder.list { sizeEq("transactions", 2) }
+        then:
+        results.size() == 1
+        results[0].firstName == "Fred"
+    }
+
+    void "isEmpty and isNotEmpty criteria split collection membership"() {
+        given: setupData()
+        when:
+        def emptyResults    = builder.list { isEmpty("transactions") }
+        def notEmptyResults = builder.list { isNotEmpty("transactions") }
+        then:
+        emptyResults.size() == 3
+        notEmptyResults.size() == 2
+    }
+
+    void "isNull criteria returns entities with null property"() {
+        given: setupData()
+        when:
+        def results = builder.list { isNull("branch") }
+        then:
+        results.size() == 1
+        results[0].firstName == "Bam-Bam"
+    }
+
+    void "count projection returns row count"() {
+        given: setupData()
+        when:
+        def count = builder.get {
+            projections { count() }
+            eq("lastName", "Flintstone")
+        }
+        then:
+        count == 3
+    }
+
+    void "sum and avg projections aggregate correctly"() {
+        given: setupData()
+        when:
+        def projections = builder.get {
+            projections {
+                sum('balance')
+                avg('balance')
+            }
+            eq("branch", "Bedrock")
+        }
+        then:
+        projections[0] == 850
+        new BigDecimal(projections[1]).setScale(2, RoundingMode.HALF_UP) == 
283.33
+    }
+
+    void "ordering and pagination slice results correctly"() {
+        given: setupData()
+        when:
+        def results = builder.list(max: 2, offset: 1) { order("firstName", 
"asc") }
+        then:
+        results.size() == 2
+        results*.firstName == ["Barney", "Fred"]
+    }
+
+    void "association closure filters via join"() {
+        given: setupData()
+        when:
+        def results = builder.list {
+            transactions { gt("amount", 40) }
+        }
+        then:
+        results.size() == 1
+        results[0].firstName == "Barney"
+    }
+
+    void "ne ge le criteria filter correctly"() {
+        given: setupData()
+        when:
+        def results = builder.list {
+            ne("firstName", "Fred")
+            ge("balance", BigDecimal.valueOf(60))
+            le("balance", BigDecimal.valueOf(1000))
+        }
+        then:
+        results*.firstName.toSet() == ["Barney", "Wilma", "Pebbles"] as Set
+    }
+
+    void "isNotNull and sizeGe filter combined"() {
+        given: setupData()
+        when:
+        def results = builder.list {
+            isNotNull("branch")
+            sizeGe("transactions", 1)
+        }
+        then:
+        results*.firstName.toSet() == ["Fred", "Barney"] as Set
+    }
+
+    void "groupProperty countDistinct min max projections return results"() {
+        given: setupData()
+        when:
+        def results = builder.list {
+            projections {
+                groupProperty("lastName")
+                countDistinct("firstName")
+                min("balance")
+                max("balance")
+            }
+        }
+        then:
+        results.size() >= 1
+    }
+
+    void "inList array variant and firstResult paginate correctly"() {
+        given: setupData()
+        when:
+        def list1 = builder.list { 'in'("firstName", ["Fred", "Barney"] as 
Object[]) }
+        def list2 = builder.list { inList("firstName", ["Fred", "Wilma"] as 
Object[]) }
+        def paged = builder.list(max: 1) {
+            order("firstName", "asc")
+            firstResult(2)
+        }
+        then:
+        list1.size() > 0
+        list2.size() > 0
+        paged.size() == 1
+        paged[0].firstName == "Fred"
+    }
+
+    void "nested association criteria with between filters correctly"() {
+        given: setupData()
+        when:
+        def results = builder.list {
+            transactions { between("amount", BigDecimal.valueOf(15), 
BigDecimal.valueOf(25)) }
+        }
+        then:
+        results.size() == 1
+        results[0].firstName == "Fred"
+    }
+
     // ─── Comparison predicates ─────────────────────────────────────────────
 
     def "eq(String, Object) delegates and returns this"() {
diff --git 
a/grails-data-hibernate7/core/src/test/groovy/grails/orm/HibernateCriteriaBuilderSpec.groovy
 
b/grails-data-hibernate7/core/src/test/groovy/grails/orm/HibernateCriteriaBuilderSpec.groovy
index 3e63b316fb..91dc5f8384 100644
--- 
a/grails-data-hibernate7/core/src/test/groovy/grails/orm/HibernateCriteriaBuilderSpec.groovy
+++ 
b/grails-data-hibernate7/core/src/test/groovy/grails/orm/HibernateCriteriaBuilderSpec.groovy
@@ -2,208 +2,321 @@ package grails.orm
 
 import grails.gorm.annotation.Entity
 import grails.gorm.specs.HibernateGormDatastoreSpec
-import java.math.RoundingMode
-
+import org.grails.datastore.mapping.query.api.BuildableCriteria
+
+/**
+ * Living documentation for the {@link HibernateCriteriaBuilder} DSL.
+ * <p>
+ * Every feature method below demonstrates one DSL idiom that a developer can 
copy into
+ * application code. Tests are backed by a real in-memory datastore so they 
also verify
+ * that each DSL call produces the correct SQL and returns the expected 
results.
+ * <p>
+ * For low-level method coverage (JaCoCo line hits) see
+ * {@link HibernateCriteriaBuilderDirectSpec}.
+ *
+ * <h2>DSL entry points</h2>
+ * <pre>
+ *   // via domain class
+ *   def c = Account.createCriteria()
+ *   def results = c.list { eq("firstName", "Fred") }
+ *
+ *   // shorthand
+ *   def results = Account.withCriteria { eq("firstName", "Fred") }
+ * </pre>
+ */
 class HibernateCriteriaBuilderSpec extends HibernateGormDatastoreSpec {
 
     def setupSpec() {
-        manager.addAllDomainClasses([Account, Transaction])
+        manager.addAllDomainClasses([CriteriaAccount, CriteriaTransaction])
     }
 
-    HibernateCriteriaBuilder builder
+    BuildableCriteria c
 
     def setup() {
-        def fred = new Account(balance: 250, firstName: "Fred", lastName: 
"Flintstone", branch: "Bedrock").save(failOnError: true)
-        def barney = new Account(balance: 500, firstName: "Barney", lastName: 
"Rubble", branch: "Bedrock").save(failOnError: true)
-        new Account(balance: 100, firstName: "Wilma", lastName: "Flintstone", 
branch: "Bedrock").save(failOnError: true)
-        new Account(balance: 1000, firstName: "Pebbles", lastName: 
"Flintstone", branch: "Slate Rock and Gravel").save(failOnError: true)
-        new Account(balance: 50, firstName: "Bam-Bam", lastName: "Rubble", 
branch: null).save(failOnError: true)
-
-        fred.addToTransactions(new Transaction(amount: 10))
-        fred.addToTransactions(new Transaction(amount: 20))
-        fred.save()
+        c = new HibernateCriteriaBuilder(CriteriaAccount, 
manager.hibernateDatastore.sessionFactory, manager.hibernateDatastore)
 
-        barney.addToTransactions(new Transaction(amount: 50))
-        barney.save(flush: true, failOnError: true)
+        def fred   = new CriteriaAccount(balance: 250,  firstName: "Fred",    
lastName: "Flintstone", branch: "Bedrock").save(failOnError: true)
+        def barney = new CriteriaAccount(balance: 500,  firstName: "Barney",  
lastName: "Rubble",     branch: "Bedrock").save(failOnError: true)
+        new CriteriaAccount(balance: 100,  firstName: "Wilma",   lastName: 
"Flintstone", branch: "Bedrock").save(failOnError: true)
+        new CriteriaAccount(balance: 1000, firstName: "Pebbles", lastName: 
"Flintstone", branch: "Slate Rock and Gravel").save(failOnError: true)
+        new CriteriaAccount(balance: 50,   firstName: "Bam-Bam", lastName: 
"Rubble",     branch: null).save(failOnError: true)
 
-        builder = new HibernateCriteriaBuilder(Account, 
manager.hibernateDatastore.sessionFactory, manager.hibernateDatastore)
+        fred.addToTransactions(new CriteriaTransaction(amount: 10))
+        fred.addToTransactions(new CriteriaTransaction(amount: 20))
+        fred.save()
+        barney.addToTransactions(new CriteriaTransaction(amount: 50))
+        barney.save(flush: true, failOnError: true)
     }
 
-    void "test get with eq criteria"() {
-        when:
-        def result = builder.get {
-            eq("firstName", "Fred")
-        }
+    // ─── Equality ──────────────────────────────────────────────────────────
 
+    /**
+     * {@code eq} — exact equality.
+     * <pre>
+     *   Account.withCriteria { eq("firstName", "Fred") }
+     * </pre>
+     */
+    void "eq matches exact property value"() {
+        when:
+        def result = c.get { eq("firstName", "Fred") }
         then:
         result.firstName == "Fred"
     }
 
-    void "test idEq criteria"() {
+    /**
+     * {@code idEq} — shorthand for equality on the identity property.
+     * <pre>
+     *   Account.withCriteria { idEq(fred.id) }
+     * </pre>
+     */
+    void "idEq matches by primary key"() {
         given:
-        def fred = Account.findByFirstName("Fred")
-
+        def fred = CriteriaAccount.findByFirstName("Fred")
         when:
-        def result = builder.get {
-            idEq(fred.id)
-        }
-
+        def result = c.get { idEq(fred.id) }
         then:
         result.id == fred.id
-        result.firstName == "Fred"
     }
 
-    void "test list with various criteria"() {
+    /**
+     * {@code ne} — not-equal.
+     * <pre>
+     *   Account.withCriteria { ne("firstName", "Fred") }
+     * </pre>
+     */
+    void "ne excludes the named value"() {
         when:
-        def results = builder.list {
-            gt("balance", BigDecimal.valueOf(200))
-            or {
-                eq("lastName", "Flintstone")
-                like("branch", "Bedrock")
-            }
-            'in'("firstName", ["Fred", "Barney", "Pebbles"])
-        }
-
+        def results = c.list { ne("firstName", "Fred") }
         then:
-        results.size() == 3
-        results*.firstName.sort() == ["Barney", "Fred", "Pebbles"]
+        !results*.firstName.contains("Fred")
+        results.size() == 4
     }
 
-    void "test ilike criteria"() {
-        when:
-        def results = builder.list {
-            ilike("firstName", "fr%")
-        }
+    // ─── Range / comparison ────────────────────────────────────────────────
 
+    /**
+     * {@code between} — inclusive range on a single property.
+     * <pre>
+     *   Account.withCriteria { between("balance", 100, 300) }
+     * </pre>
+     */
+    void "between selects values within the inclusive range"() {
+        when:
+        def results = c.list { between("balance", BigDecimal.valueOf(100), 
BigDecimal.valueOf(300)) }
         then:
-        results.size() == 1
-        results[0].firstName == "Fred"
+        results*.firstName.sort() == ["Fred", "Wilma"]
     }
 
-    void "test rlike criteria"() {
+    /**
+     * {@code gt} / {@code ge} / {@code lt} / {@code le} — numeric comparisons.
+     * <pre>
+     *   Account.withCriteria {
+     *       ge("balance", 60)
+     *       le("balance", 1000)
+     *   }
+     * </pre>
+     */
+    void "gt ge lt le filter by comparison"() {
         when:
-        def results = builder.list {
-            rlike("firstName", "^F.*")
+        def results = c.list {
+            ge("balance", BigDecimal.valueOf(100))
+            lt("balance", BigDecimal.valueOf(600))
         }
+        then:
+        results*.firstName.sort() == ["Barney", "Fred", "Wilma"]
+    }
+
+    // ─── String matching ───────────────────────────────────────────────────
 
+    /**
+     * {@code like} — SQL LIKE pattern (case-sensitive, {@code %} and {@code 
_} wildcards).
+     * <pre>
+     *   Account.withCriteria { like("firstName", "Fr%") }
+     * </pre>
+     */
+    void "like matches SQL LIKE pattern"() {
+        when:
+        def results = c.list { like("firstName", "Fr%") }
         then:
         results.size() == 1
         results[0].firstName == "Fred"
     }
 
-    void "test between criteria"() {
+    /**
+     * {@code ilike} — case-insensitive LIKE.
+     * <pre>
+     *   Account.withCriteria { ilike("firstName", "fr%") }
+     * </pre>
+     */
+    void "ilike matches case-insensitively"() {
         when:
-        def results = builder.list {
-            between("balance", BigDecimal.valueOf(100), 
BigDecimal.valueOf(300))
-        }
-
+        def results = c.list { ilike("firstName", "FR%") }
         then:
-        results.size() == 2
-        results*.firstName.sort() == ["Fred", "Wilma"]
+        results.size() == 1
+        results[0].firstName == "Fred"
     }
 
-    void "test sizeEq criteria"() {
+    /**
+     * {@code rlike} — regular-expression match (dialect-dependent).
+     * <pre>
+     *   Account.withCriteria { rlike("firstName", "^F.*") }
+     * </pre>
+     */
+    void "rlike matches by regular expression"() {
         when:
-        def results = builder.list {
-            sizeEq("transactions", 2)
-        }
-
+        def results = c.list { rlike("firstName", "^F.*") }
         then:
         results.size() == 1
         results[0].firstName == "Fred"
     }
 
-    void "test isEmpty and isNotEmpty criteria"() {
-        when:
-        def emptyResults = builder.list {
-            isEmpty("transactions")
-        }
-        def notEmptyResults = builder.list {
-            isNotEmpty("transactions")
-        }
+    // ─── Null / empty ──────────────────────────────────────────────────────
 
+    /**
+     * {@code isNull} / {@code isNotNull} — null-check predicates.
+     * <pre>
+     *   Account.withCriteria { isNull("branch") }
+     * </pre>
+     */
+    void "isNull and isNotNull split on null property"() {
+        when:
+        def nullBranch    = c.list { isNull("branch") }
+        def nonNullBranch = c.list { isNotNull("branch") }
         then:
-        emptyResults.size() == 3 // Wilma, Pebbles, Bam-Bam
-        notEmptyResults.size() == 2 // Fred, Barney
+        nullBranch.size() == 1
+        nullBranch[0].firstName == "Bam-Bam"
+        nonNullBranch.size() == 4
     }
 
-    void "test isNull criteria"() {
+    /**
+     * {@code isEmpty} / {@code isNotEmpty} — empty-collection predicates.
+     * <pre>
+     *   Account.withCriteria { isEmpty("transactions") }
+     * </pre>
+     */
+    void "isEmpty and isNotEmpty split on collection emptiness"() {
         when:
-        def results = builder.list {
-            isNull("branch")
-        }
-
+        def empty    = c.list { isEmpty("transactions") }
+        def nonEmpty = c.list { isNotEmpty("transactions") }
         then:
-        results.size() == 1
-        results[0].firstName == "Bam-Bam"
+        empty.size() == 3
+        nonEmpty.size() == 2
     }
 
-    void "test count projection"() {
+    // ─── In / allEq ────────────────────────────────────────────────────────
+
+    /**
+     * {@code in} / {@code inList} — membership in a collection or array.
+     * <pre>
+     *   Account.withCriteria { 'in'("firstName", ["Fred", "Barney"]) }
+     *   Account.withCriteria { inList("firstName", ["Fred", "Barney"]) }
+     * </pre>
+     */
+    void "in and inList filter to members of the supplied set"() {
         when:
-        def count = builder.get {
-            projections {
-                count()
-            }
-            eq("lastName", "Flintstone")
-        }
+        def results = c.list { 'in'("firstName", ["Fred", "Barney"]) }
+        then:
+        results*.firstName.sort() == ["Barney", "Fred"]
+    }
 
+    /**
+     * {@code allEq} — all properties must equal the supplied values (AND 
shorthand).
+     * <pre>
+     *   Account.withCriteria { allEq(firstName: "Fred", lastName: 
"Flintstone") }
+     * </pre>
+     */
+    void "allEq matches all supplied key-value pairs"() {
+        when:
+        def results = c.list { allEq(firstName: "Fred", lastName: 
"Flintstone") }
         then:
-        count == 3
+        results.size() == 1
+        results[0].firstName == "Fred"
     }
 
-    void "test sum and avg projection"() {
+    // ─── Logical combinators ───────────────────────────────────────────────
+
+    /**
+     * {@code and} / {@code or} / {@code not} — logical grouping of predicates.
+     * <pre>
+     *   Account.withCriteria {
+     *       or {
+     *           eq("lastName", "Flintstone")
+     *           like("branch", "Bedrock")
+     *       }
+     *   }
+     * </pre>
+     */
+    void "or combinator unions two predicates"() {
         when:
-        def projections = builder.get {
-            projections {
-                sum('balance')
-                avg('balance')
+        def results = c.list {
+            gt("balance", BigDecimal.valueOf(200))
+            or {
+                eq("lastName", "Flintstone")
+                like("branch", "Bedrock")
             }
-            eq("branch", "Bedrock")
+            'in'("firstName", ["Fred", "Barney", "Pebbles"])
         }
-
         then:
-        projections[0] == 850
-        new BigDecimal(projections[1]).setScale(2, RoundingMode.HALF_UP) == 
283.33
+        results*.firstName.sort() == ["Barney", "Fred", "Pebbles"]
     }
 
-    void "test ordering and pagination"() {
+    // ─── Association traversal ─────────────────────────────────────────────
+
+    /**
+     * Closure named after an association property traverses the join.
+     * <pre>
+     *   Account.withCriteria {
+     *       transactions { gt("amount", 40) }
+     *   }
+     * </pre>
+     */
+    void "association closure navigates into joined entity"() {
         when:
-        def results = builder.list(max: 2, offset: 1) {
-            order("firstName", "asc")
+        def results = c.list {
+            transactions { gt("amount", 40) }
         }
-
         then:
-        results.size() == 2
-        results*.firstName == ["Barney", "Fred"]
+        results.size() == 1
+        results[0].firstName == "Barney"
     }
 
-    void "test query on association"() {
+    /**
+     * Multiple predicates inside an association closure are ANDed.
+     * <pre>
+     *   Account.withCriteria {
+     *       transactions { between("amount", 15, 25) }
+     *   }
+     * </pre>
+     */
+    void "association closure with between narrows the join correctly"() {
         when:
-        def results = builder.list {
-            transactions {
-                gt("amount", 40)
-            }
+        def results = c.list {
+            transactions { between("amount", BigDecimal.valueOf(15), 
BigDecimal.valueOf(25)) }
         }
-
         then:
         results.size() == 1
-        results[0].firstName == "Barney"
+        results[0].firstName == "Fred"
     }
 
-    void "test inequality and between criteria"() {
+    // ─── Collection-size constraints ───────────────────────────────────────
+
+    /**
+     * {@code sizeEq} / {@code sizeGt} / {@code sizeGe} / etc.
+     * <pre>
+     *   Account.withCriteria { sizeEq("transactions", 2) }
+     * </pre>
+     */
+    void "sizeEq filters by exact collection size"() {
         when:
-        def results = builder.list {
-            ne("firstName", "Fred")
-            ge("balance", BigDecimal.valueOf(60))
-            le("balance", BigDecimal.valueOf(1000))
-        }
+        def results = c.list { sizeEq("transactions", 2) }
         then:
-        results*.firstName.toSet() == ["Barney", "Wilma", "Pebbles"] as Set
+        results.size() == 1
+        results[0].firstName == "Fred"
     }
 
-    void "test isNotNull and size constraints on association"() {
+    void "sizeGe filters by minimum collection size"() {
         when:
-        def results = builder.list {
+        def results = c.list {
             isNotNull("branch")
             sizeGe("transactions", 1)
         }
@@ -211,52 +324,88 @@ class HibernateCriteriaBuilderSpec extends 
HibernateGormDatastoreSpec {
         results*.firstName.toSet() == ["Fred", "Barney"] as Set
     }
 
-    void "test property to property comparisons and ordering desc"() {
-        when:
-        def results = builder.list {
-            geProperty("balance", "balance") // always true, validates path
-            eqProperty("firstName", "firstName")
-            neProperty("firstName", "lastName")
-            gtProperty("balance", "balance") // always false for same property
-            order("balance", "desc")
-        }
-        then:
-        results.size() == 0 // because gtProperty("balance", "balance") is 
false
+    // ─── Property-to-property comparisons ─────────────────────────────────
 
+    /**
+     * {@code eqProperty} / {@code neProperty} / {@code gtProperty} etc. 
compare two
+     * properties of the same entity.
+     * <pre>
+     *   Account.withCriteria { neProperty("firstName", "lastName") }
+     * </pre>
+     */
+    void "neProperty excludes rows where two properties are equal"() {
         when:
-        results = builder.list {
-            leProperty("balance", "balance")
-            ltProperty("balance", "balance")
-        }
+        // All 5 accounts have different firstName and lastName, so neProperty 
returns all
+        def results = c.list { neProperty("firstName", "lastName") }
         then:
-        results.size() == 0
+        results.size() == 5
     }
 
-    void "test nested criteria with aliases"() {
+    // ─── Projections ───────────────────────────────────────────────────────
+
+    /**
+     * {@code projections} block selects scalar aggregates instead of entity 
rows.
+     *
+     * <h3>count</h3>
+     * <pre>
+     *   Account.withCriteria {
+     *       projections { count() }
+     *       eq("lastName", "Flintstone")
+     *   }
+     * </pre>
+     */
+    void "count projection returns the number of matching rows"() {
         when:
-        def results = builder.list {
-            transactions {
-                eq("amount", BigDecimal.valueOf(50))
-            }
+        def count = c.get {
+            projections { count() }
+            eq("lastName", "Flintstone")
         }
         then:
-        results.size() == 1
-        results[0].firstName == "Barney"
+        count == 3
+    }
 
+    /**
+     * <h3>sum / avg</h3>
+     * <pre>
+     *   Account.withCriteria {
+     *       projections {
+     *           sum('balance')
+     *           avg('balance')
+     *       }
+     *       eq("branch", "Bedrock")
+     *   }
+     * </pre>
+     */
+    void "sum and avg projections aggregate numeric properties"() {
         when:
-        results = builder.list {
-            transactions {
-                between("amount", BigDecimal.valueOf(15), 
BigDecimal.valueOf(25))
+        def row = c.get {
+            projections {
+                sum('balance')
+                avg('balance')
             }
+            eq("branch", "Bedrock")
         }
         then:
-        results.size() == 1
-        results[0].firstName == "Fred"
+        row[0] == 850
+        new BigDecimal(row[1]).setScale(2, java.math.RoundingMode.HALF_UP) == 
283.33
     }
 
-    void "test projections countDistinct groupProperty min max"() {
+    /**
+     * <h3>groupProperty / countDistinct / min / max</h3>
+     * <pre>
+     *   Account.withCriteria {
+     *       projections {
+     *           groupProperty("lastName")
+     *           countDistinct("firstName")
+     *           min("balance")
+     *           max("balance")
+     *       }
+     *   }
+     * </pre>
+     */
+    void "groupProperty countDistinct min max projections aggregate per 
group"() {
         when:
-        def results = builder.list {
+        def results = c.list {
             projections {
                 groupProperty("lastName")
                 countDistinct("firstName")
@@ -265,64 +414,74 @@ class HibernateCriteriaBuilderSpec extends 
HibernateGormDatastoreSpec {
             }
         }
         then:
-        results.size() >= 1
+        results.size() == 2   // Flintstone and Rubble
     }
 
-    void "test inList array and collection variants and firstResult"() {
+    // ─── Ordering ──────────────────────────────────────────────────────────
+
+    /**
+     * {@code order} — sorts results.
+     * <pre>
+     *   Account.withCriteria { order("firstName", "asc") }
+     *   Account.withCriteria { order("balance", "desc") }
+     * </pre>
+     */
+    void "order sorts results ascending or descending"() {
         when:
-        def list1 = builder.list {
-            'in'("firstName", ["Fred", "Barney"] as Object[]) // array via 
'in' alias
-        }
-        def list2 = builder.list {
-            inList("firstName", ["Fred", "Wilma"] as Object[]) // array variant
-        }
-        def paged = builder.list(max: 1) {
-            order("firstName", "asc")
-            firstResult(2)
-        }
+        def asc  = c.list { order("firstName", "asc") }
+        def desc = c.list { order("balance", "desc") }
         then:
-        list1 instanceof List && list1.size() > 0
-        list2 instanceof List && list2.size() > 0
-        paged.size() == 1
-        paged[0].firstName == "Fred"
+        asc.first().firstName  == "Bam-Bam"
+        desc.first().firstName == "Pebbles"
     }
 
-    void "test eq with ignoreCase param path and like/ilike methods"() {
+    // ─── Pagination ────────────────────────────────────────────────────────
+
+    /**
+     * {@code maxResults} / {@code firstResult} and the map-argument variants 
limit and
+     * offset the result set.
+     * <pre>
+     *   Account.withCriteria(max: 2, offset: 1) { order("firstName", "asc") }
+     * </pre>
+     */
+    void "max and offset paginate the result set"() {
         when:
-        def results = builder.list {
-            eq("firstName", "Fr", [ignoreCase: true]) // should fallback to 
like with %Fr%
-        }
-        def likeRes = builder.list {
-            like("branch", "%Bedrock%")
-        }
-        def ilikeRes = builder.list {
-            ilike("branch", "%BEDROCK%")
+        def results = c.list(max: 2, offset: 1) { order("firstName", "asc") }
+        then:
+        results.size() == 2
+        results*.firstName == ["Barney", "Fred"]
+    }
+
+    void "firstResult and maxResults inside the closure paginate 
independently"() {
+        when:
+        def results = c.list(max: 1) {
+            order("firstName", "asc")
+            firstResult(2)
         }
         then:
-        results instanceof List
-        likeRes.size() >= 1
-        ilikeRes.size() >= 1
+        results.size() == 1
+        results[0].firstName == "Fred"
     }
 }
 
 @Entity
-class Account {
+class CriteriaAccount {
     String firstName
     String lastName
     BigDecimal balance
     String branch
-    Set<Transaction> transactions
+    Set<CriteriaTransaction> transactions
 
-    static hasMany = [transactions: Transaction]
+    static hasMany = [transactions: CriteriaTransaction]
     static constraints = {
         branch nullable: true
     }
 }
 
 @Entity
-class Transaction {
+class CriteriaTransaction {
     BigDecimal amount
     Date dateCreated
 
-    static belongsTo = [account: Account]
+    static belongsTo = [account: CriteriaAccount]
 }


Reply via email to