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] }
