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

borinquenkid pushed a commit to branch 
test/document-datamapping-core-transformers
in repository https://gitbox.apache.org/repos/asf/grails-core.git

commit 961724f838798e55a6d4f9ae927aa36bb0c7868f
Author: Walter Duque de Estrada <[email protected]>
AuthorDate: Thu Aug 13 00:11:40 2026 -0500

    Add broad test coverage for DetachedCriteriaTransformer's where-query DSL
    
    DetachedCriteriaTransformer sat at 43% instruction / 33% branch coverage
    despite being the core of the where{}/find{}/findAll{} query DSL
    rewriting. Add six specs covering the DSL surface that was previously
    untested: comparison/collection operators (==, !=, >, <, in, between,
    size(), property-to-property, and/or), negation, SQL function calls
    (year, lower, etc.), association property paths, static-field where
    declarations across every supported statement kind (if/else, for,
    while, switch, try/catch/finally, return), and closure-cast-to-
    DetachedCriteria assignments.
    
    Where a static field is initialized directly from Domain.where{}, the
    transform builds a real DetachedCriteria with no live datastore
    required, so most specs assert on the actual Query.Criterion objects
    produced via the public getCriteria()/getProjections() API rather than
    only on generated-code structure.
    
    Coverage moves 43% -> 78% instruction, 33% -> 59% branch.
    
    Co-Authored-By: Claude Sonnet 5 <[email protected]>
---
 .../transform/WhereQueryAssociationPathSpec.groovy | 181 +++++++++++++
 .../WhereQueryDetachedCriteriaCastSpec.groovy      | 131 ++++++++++
 .../transform/WhereQueryFunctionCallSpec.groovy    | 150 +++++++++++
 .../query/transform/WhereQueryNegationSpec.groovy  | 121 +++++++++
 .../query/transform/WhereQueryOperatorSpec.groovy  | 283 +++++++++++++++++++++
 .../transform/WhereQueryStaticFieldSpec.groovy     | 252 ++++++++++++++++++
 6 files changed, 1118 insertions(+)

diff --git 
a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/query/transform/WhereQueryAssociationPathSpec.groovy
 
b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/query/transform/WhereQueryAssociationPathSpec.groovy
new file mode 100644
index 0000000000..16da101551
--- /dev/null
+++ 
b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/query/transform/WhereQueryAssociationPathSpec.groovy
@@ -0,0 +1,181 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.grails.datastore.gorm.query.transform
+
+import org.codehaus.groovy.control.MultipleCompilationErrorsException
+
+import spock.lang.Specification
+
+/**
+ * A property-path expression such as {@code author.name} or a deeper {@code 
author.publisher.name} is
+ * rewritten by {@code 
DetachedCriteriaTransformer#handleAssociationQueryViaPropertyExpression} into
+ * nested {@code delegate.<association> { ... }} calls, one per path segment. 
Those delegate calls are
+ * dynamic (routed through {@code AbstractDetachedCriteria#methodMissing}) and 
need the target class to be
+ * GORM-enhanced against a live datastore to resolve - something this module 
deliberately has none of - so
+ * these associations are verified structurally: the source compiles (or fails 
to, for the invalid-property
+ * cases) and a nested closure is synthesized per association segment walked.
+ */
+class WhereQueryAssociationPathSpec extends Specification {
+
+    // The domain class names must be unique across the test JVM because
+    // AstPropertyResolveUtils caches resolved properties statically by class 
name
+    private static final String SINGLE_LEVEL_SOURCE = '''
+import grails.gorm.DetachedCriteria
+import grails.gorm.annotation.Entity
+
+class AssocPathSingleQueryService {
+    protected DetachedCriteria<AssocPathSingleBook> findByAuthorName(String 
name) {
+        AssocPathSingleBook.where {
+            author.name == name
+        }
+    }
+}
+
+@Entity
+class AssocPathSingleBook {
+    String title
+    AssocPathSingleAuthor author
+}
+
+@Entity
+class AssocPathSingleAuthor {
+    String name
+}
+'''
+
+    private static final String MULTI_LEVEL_SOURCE = '''
+import grails.gorm.DetachedCriteria
+import grails.gorm.annotation.Entity
+
+class AssocPathMultiQueryService {
+    protected DetachedCriteria<AssocPathMultiBook> findByPublisherName(String 
name) {
+        AssocPathMultiBook.where {
+            author.publisher.name == name
+        }
+    }
+}
+
+@Entity
+class AssocPathMultiBook {
+    String title
+    AssocPathMultiAuthor author
+}
+
+@Entity
+class AssocPathMultiAuthor {
+    String name
+    AssocPathMultiPublisher publisher
+}
+
+@Entity
+class AssocPathMultiPublisher {
+    String name
+}
+'''
+
+    private static List<Class<?>> findQueryClosures(GroovyClassLoader gcl, 
String methodName) {
+        gcl.loadedClasses.findAll { it.name.contains("_${methodName}_") }
+    }
+
+    void "a single-level association property path compiles and generates one 
nested association closure"() {
+        given:
+        GroovyClassLoader gcl = new GroovyClassLoader()
+
+        when:
+        gcl.parseClass(SINGLE_LEVEL_SOURCE)
+
+        then:
+        noExceptionThrown()
+
+        and: 'one closure for the outer where-block and one nested closure for 
the association segment walked'
+        List<Class<?>> queryClosures = findQueryClosures(gcl, 
'findByAuthorName').sort { it.name.count('$_closure') }
+        queryClosures.size() == 2
+        queryClosures.last().name.count('$_closure') == 1
+    }
+
+    void "a multi-level association property path compiles and generates a 
closure per path segment"() {
+        given:
+        GroovyClassLoader gcl = new GroovyClassLoader()
+
+        when:
+        gcl.parseClass(MULTI_LEVEL_SOURCE)
+
+        then:
+        noExceptionThrown()
+
+        and: 'one closure for the outer where-block, one for each of the two 
association segments walked'
+        List<Class<?>> queryClosures = findQueryClosures(gcl, 
'findByPublisherName').sort { it.name.count('$_closure') }
+        queryClosures.size() == 3
+        queryClosures.last().name.count('$_closure') == 2
+    }
+
+    void "querying an unknown property on a single-level association fails to 
compile"() {
+        when:
+        new GroovyClassLoader().parseClass('''
+import grails.gorm.annotation.Entity
+import org.grails.datastore.gorm.query.transform.ApplyDetachedCriteriaTransform
+
+@ApplyDetachedCriteriaTransform
+@Entity
+class AssocPathUnknownPropBook {
+    String title
+    AssocPathUnknownPropAuthor author
+
+    static findInvalid() {
+        AssocPathUnknownPropBook.where {
+            author.unknownProperty == "x"
+        }
+    }
+}
+
+@Entity
+class AssocPathUnknownPropAuthor {
+    String name
+}
+''')
+
+        then:
+        MultipleCompilationErrorsException e = thrown()
+        e.message.contains('Cannot query property "unknownProperty"')
+    }
+
+    void "querying an unknown top-level property fails to compile"() {
+        when:
+        new GroovyClassLoader().parseClass('''
+import grails.gorm.annotation.Entity
+import org.grails.datastore.gorm.query.transform.ApplyDetachedCriteriaTransform
+
+@ApplyDetachedCriteriaTransform
+@Entity
+class AssocPathUnknownTopLevelBook {
+    String title
+
+    static findInvalid() {
+        AssocPathUnknownTopLevelBook.where {
+            unknownProperty == "x"
+        }
+    }
+}
+''')
+
+        then:
+        MultipleCompilationErrorsException e = thrown()
+        e.message.contains('Cannot query on property "unknownProperty"')
+    }
+}
diff --git 
a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/query/transform/WhereQueryDetachedCriteriaCastSpec.groovy
 
b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/query/transform/WhereQueryDetachedCriteriaCastSpec.groovy
new file mode 100644
index 0000000000..c9b7ce8c24
--- /dev/null
+++ 
b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/query/transform/WhereQueryDetachedCriteriaCastSpec.groovy
@@ -0,0 +1,131 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.grails.datastore.gorm.query.transform
+
+import org.codehaus.groovy.control.MultipleCompilationErrorsException
+import org.grails.datastore.mapping.query.Query
+
+import spock.lang.Specification
+
+/**
+ * A closure cast to {@code DetachedCriteria<SomeDomain>} - for example
+ * {@code def query = { name == value } as DetachedCriteria<Book>} - is 
rewritten by
+ * {@code DetachedCriteriaTransformer#handleDetachedCriteriaCast}. Unlike the 
static-field form, the cast
+ * form is applied both to instance fields ({@code visitField}) and to local 
variable declarations
+ * ({@code visitDeclarationExpression}), and in both cases the transform 
replaces the initializer with the
+ * transformed closure itself (the cast is dropped once the closure body has 
been rewritten). Because the
+ * resulting closure only builds flat, non-association criteria in these 
specs, it can be executed directly
+ * against a plain {@link grails.gorm.DetachedCriteria} via the public {@code 
build(Closure)} API without
+ * any live datastore.
+ */
+class WhereQueryDetachedCriteriaCastSpec extends Specification {
+
+    // The domain class names must be unique across the test JVM because
+    // AstPropertyResolveUtils caches resolved properties statically by class 
name
+    private static final String FIELD_CAST_SOURCE = '''
+import grails.gorm.DetachedCriteria
+import grails.gorm.annotation.Entity
+
+class CastFieldQueryHolder {
+    Closure priceQuery = { price > 100 } as DetachedCriteria<CastFieldBook>
+}
+
+@Entity
+class CastFieldBook {
+    String title
+    BigDecimal price
+}
+'''
+
+    private static final String LOCAL_VAR_CAST_SOURCE = '''
+import grails.gorm.DetachedCriteria
+import grails.gorm.annotation.Entity
+
+class CastLocalVarQueryHolder {
+    Closure makeQuery() {
+        def query = {
+            if (true) {
+                title == "Local"
+            }
+        } as DetachedCriteria<CastLocalVarBook>
+        return query
+    }
+}
+
+@Entity
+class CastLocalVarBook {
+    String title
+}
+'''
+
+    void "a closure cast on an instance field is transformed and can be built 
into real criteria"() {
+        given:
+        GroovyClassLoader gcl = new GroovyClassLoader()
+        gcl.parseClass(FIELD_CAST_SOURCE)
+        def holder = 
gcl.loadClass('CastFieldQueryHolder').getDeclaredConstructor().newInstance()
+        Closure transformedClosure = holder.priceQuery
+
+        when:
+        def criteria = new 
grails.gorm.DetachedCriteria(gcl.loadClass('CastFieldBook')).build(transformedClosure)
+
+        then:
+        criteria.criteria.size() == 1
+        criteria.criteria[0] instanceof Query.GreaterThan
+        ((Query.GreaterThan) criteria.criteria[0]).property == 'price'
+        ((Query.GreaterThan) criteria.criteria[0]).value == 100
+    }
+
+    void "a closure cast on a local variable declaration is transformed, 
including a nested if statement in its body"() {
+        given:
+        GroovyClassLoader gcl = new GroovyClassLoader()
+        gcl.parseClass(LOCAL_VAR_CAST_SOURCE)
+        def holder = 
gcl.loadClass('CastLocalVarQueryHolder').getDeclaredConstructor().newInstance()
+        Closure transformedClosure = holder.makeQuery()
+
+        when:
+        def criteria = new 
grails.gorm.DetachedCriteria(gcl.loadClass('CastLocalVarBook')).build(transformedClosure)
+
+        then:
+        criteria.criteria.size() == 1
+        criteria.criteria[0] instanceof Query.Equals
+        ((Query.Equals) criteria.criteria[0]).property == 'title'
+        ((Query.Equals) criteria.criteria[0]).value == 'Local'
+    }
+
+    void "a closure cast referencing an unknown property fails to compile"() {
+        when:
+        new GroovyClassLoader().parseClass('''
+import grails.gorm.DetachedCriteria
+import grails.gorm.annotation.Entity
+
+class CastInvalidPropertyQueryHolder {
+    Closure query = { unknownProperty == "x" } as 
DetachedCriteria<CastInvalidPropertyBook>
+}
+
+@Entity
+class CastInvalidPropertyBook {
+    String title
+}
+''')
+
+        then:
+        MultipleCompilationErrorsException e = thrown()
+        e.message.contains('Cannot query on property "unknownProperty"')
+    }
+}
diff --git 
a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/query/transform/WhereQueryFunctionCallSpec.groovy
 
b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/query/transform/WhereQueryFunctionCallSpec.groovy
new file mode 100644
index 0000000000..71b31e791c
--- /dev/null
+++ 
b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/query/transform/WhereQueryFunctionCallSpec.groovy
@@ -0,0 +1,150 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.grails.datastore.gorm.query.transform
+
+import org.codehaus.groovy.control.MultipleCompilationErrorsException
+import org.grails.datastore.mapping.query.Query
+import org.grails.datastore.mapping.query.criteria.FunctionCallingCriterion
+
+import spock.lang.Specification
+
+/**
+ * SQL functions such as {@code year(...)}, {@code lower(...)} etc. (see
+ * {@code DetachedCriteriaTransformer#SUPPORTED_FUNCTIONS}) are only 
recognised on the left-hand side of
+ * a comparison, where {@code DetachedCriteriaTransformer#handleFunctionCall} 
rewrites them into a
+ * {@link FunctionCallingCriterion}. A function call on a direct property 
never needs a live datastore, so
+ * it can be built and inspected in memory; a function call through an 
association property additionally
+ * exercises the association-walking branch of {@code 
handleAssociationQueryViaPropertyExpression}, which -
+ * like all association criteria - requires a GORM-enhanced entity to execute, 
so that variant is only
+ * verified structurally (the source compiles and a nested association closure 
is generated).
+ */
+class WhereQueryFunctionCallSpec extends Specification {
+
+    // The domain class name must be unique across the test JVM because
+    // AstPropertyResolveUtils caches resolved properties statically by class 
name
+    private static final String DIRECT_SOURCE = '''
+import grails.gorm.DetachedCriteria
+import grails.gorm.annotation.Entity
+
+@Entity
+class FuncQueryBook {
+    String title
+    Date published
+
+    static DetachedCriteria<FuncQueryBook> byPublishedYear = 
FuncQueryBook.where {
+        year(published) == 2020
+    }
+
+    static DetachedCriteria<FuncQueryBook> byLowerTitle = FuncQueryBook.where {
+        lower(title) == "effective java"
+    }
+}
+'''
+
+    private static final String ASSOCIATION_SOURCE = '''
+import grails.gorm.DetachedCriteria
+import grails.gorm.annotation.Entity
+
+class FuncQueryAssocBookQueryService {
+    protected DetachedCriteria<FuncQueryAssocBook> findByAuthorBirthYear(int 
yr) {
+        FuncQueryAssocBook.where {
+            year(author.birthDate) == yr
+        }
+    }
+}
+
+@Entity
+class FuncQueryAssocBook {
+    String title
+    FuncQueryAssocAuthor author
+}
+
+@Entity
+class FuncQueryAssocAuthor {
+    Date birthDate
+}
+'''
+
+    private static Class<?> compileDirect() {
+        new GroovyClassLoader().parseClass(DIRECT_SOURCE)
+    }
+
+    void "a function call on a direct property produces a 
FunctionCallingCriterion"() {
+        given:
+        FunctionCallingCriterion criterion = 
compileDirect().byPublishedYear.criteria[0]
+
+        expect:
+        criterion.functionName == 'year'
+        criterion.propertyCriterion instanceof Query.Equals
+        criterion.propertyCriterion.property == 'published'
+        criterion.propertyCriterion.value == 2020
+    }
+
+    void "a different supported function (lower) also produces a 
FunctionCallingCriterion"() {
+        given:
+        FunctionCallingCriterion criterion = 
compileDirect().byLowerTitle.criteria[0]
+
+        expect:
+        criterion.functionName == 'lower'
+        criterion.propertyCriterion.property == 'title'
+        criterion.propertyCriterion.value == 'effective java'
+    }
+
+    void "a function call through an association property compiles and 
generates a nested association closure"() {
+        given:
+        GroovyClassLoader gcl = new GroovyClassLoader()
+
+        when:
+        gcl.parseClass(ASSOCIATION_SOURCE)
+
+        then:
+        noExceptionThrown()
+
+        and: 'a nested closure was synthesized for the association block 
driven by the function call'
+        List<Class<?>> queryClosures = gcl.loadedClasses.findAll {
+            it.name.contains('_findByAuthorBirthYear_')
+        }
+        queryClosures.size() > 1
+    }
+
+    void "a function call used on the right-hand side of a comparison fails to 
compile"() {
+        when:
+        new GroovyClassLoader().parseClass('''
+import grails.gorm.annotation.Entity
+import org.grails.datastore.gorm.query.transform.ApplyDetachedCriteriaTransform
+
+@ApplyDetachedCriteriaTransform
+@Entity
+class RhsFunctionCallBook {
+    String title
+    Date published
+
+    static findInvalid() {
+        RhsFunctionCallBook.where {
+            title == year(published)
+        }
+    }
+}
+''')
+
+        then:
+        MultipleCompilationErrorsException e = thrown()
+        e.message.contains('Function calls can currently only be used on the 
left-hand side of expressions')
+    }
+}
diff --git 
a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/query/transform/WhereQueryNegationSpec.groovy
 
b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/query/transform/WhereQueryNegationSpec.groovy
new file mode 100644
index 0000000000..fa5eb79f59
--- /dev/null
+++ 
b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/query/transform/WhereQueryNegationSpec.groovy
@@ -0,0 +1,121 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.grails.datastore.gorm.query.transform
+
+import org.codehaus.groovy.control.MultipleCompilationErrorsException
+import org.grails.datastore.mapping.query.Query
+
+import spock.lang.Specification
+
+/**
+ * {@code !(...)} inside a where-query is rewritten by {@code 
DetachedCriteriaTransformer#handleNegation}
+ * into a {@code this.not { ... }} call, which builds a {@link Query.Negation} 
junction. Negation only
+ * accepts a binary expression as its operand; anything else is a compile-time 
error.
+ */
+class WhereQueryNegationSpec extends Specification {
+
+    // The domain class name must be unique across the test JVM because
+    // AstPropertyResolveUtils caches resolved properties statically by class 
name
+    private static final String SOURCE = '''
+import grails.gorm.DetachedCriteria
+import grails.gorm.annotation.Entity
+
+@Entity
+class NegQueryBook {
+    String title
+    BigDecimal price
+
+    static DetachedCriteria<NegQueryBook> singleNegation = NegQueryBook.where {
+        !(title == "Excluded")
+    }
+
+    static DetachedCriteria<NegQueryBook> negationOfDisjunction = 
NegQueryBook.where {
+        !(title == "Foo" || title == "Bar")
+    }
+
+    static DetachedCriteria<NegQueryBook> negationCombinedWithConjunction = 
NegQueryBook.where {
+        title == "Foo" && !(price > 10)
+    }
+}
+'''
+
+    private static Class<?> compile() {
+        new GroovyClassLoader().parseClass(SOURCE)
+    }
+
+    void "negating a single criterion produces a Negation junction wrapping 
it"() {
+        given:
+        Query.Negation negation = compile().singleNegation.criteria[0]
+
+        expect:
+        negation.criteria.size() == 1
+        negation.criteria[0] instanceof Query.Equals
+        ((Query.Equals) negation.criteria[0]).property == 'title'
+        ((Query.Equals) negation.criteria[0]).value == 'Excluded'
+    }
+
+    void "negating a disjunction produces a Negation junction wrapping a 
Disjunction"() {
+        given:
+        Query.Negation negation = compile().negationOfDisjunction.criteria[0]
+
+        expect:
+        negation.criteria.size() == 1
+        negation.criteria[0] instanceof Query.Disjunction
+        ((Query.Disjunction) negation.criteria[0]).criteria.size() == 2
+    }
+
+    void "negation combined with a non-negated criterion via && produces a 
Conjunction containing a Negation"() {
+        given:
+        Query.Conjunction conjunction = 
compile().negationCombinedWithConjunction.criteria[0]
+
+        expect:
+        conjunction.criteria.size() == 2
+        conjunction.criteria[0] instanceof Query.Equals
+        conjunction.criteria[1] instanceof Query.Negation
+
+        and:
+        Query.Negation negation = conjunction.criteria[1]
+        negation.criteria[0] instanceof Query.GreaterThan
+        ((Query.GreaterThan) negation.criteria[0]).property == 'price'
+    }
+
+    void "negating a non-binary expression fails to compile"() {
+        when:
+        new GroovyClassLoader().parseClass('''
+import grails.gorm.annotation.Entity
+import org.grails.datastore.gorm.query.transform.ApplyDetachedCriteriaTransform
+
+@ApplyDetachedCriteriaTransform
+@Entity
+class InvalidNegationBook {
+    String title
+
+    static findInvalid() {
+        InvalidNegationBook.where {
+            !(title)
+        }
+    }
+}
+''')
+
+        then:
+        MultipleCompilationErrorsException e = thrown()
+        e.message.contains('You can only negate a binary expressions in 
queries')
+    }
+}
diff --git 
a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/query/transform/WhereQueryOperatorSpec.groovy
 
b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/query/transform/WhereQueryOperatorSpec.groovy
new file mode 100644
index 0000000000..7a9f258b97
--- /dev/null
+++ 
b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/query/transform/WhereQueryOperatorSpec.groovy
@@ -0,0 +1,283 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.grails.datastore.gorm.query.transform
+
+import org.grails.datastore.mapping.query.Query
+
+import spock.lang.Specification
+
+/**
+ * {@link DetachedCriteriaTransformer} rewrites the body of a {@code 
where}/{@code find}/{@code findAll}
+ * closure into calls on the enclosing {@link grails.gorm.DetachedCriteria}. A 
static field on a domain
+ * class initialised from {@code Domain.where { ... }} is rewritten by the 
transform into
+ * {@code new DetachedCriteria(Domain).build(closure)} directly (see
+ * {@code DetachedCriteriaTransformer#visitField}), so the resulting static 
field can be built and
+ * inspected entirely in memory - no live datastore is required because the 
criterion methods used here
+ * (eq, gt, between, sizeEq, and, or, ...) only append plain {@link 
Query.Criterion} instances; they never
+ * touch a {@code PersistentEntity} or a connected datastore. This lets these 
specs assert on the exact
+ * criterion objects produced, rather than only on compilation succeeding.
+ */
+class WhereQueryOperatorSpec extends Specification {
+
+    // The domain class name must be unique across the test JVM because
+    // AstPropertyResolveUtils caches resolved properties statically by class 
name
+    private static final String SOURCE = '''
+import grails.gorm.DetachedCriteria
+import grails.gorm.annotation.Entity
+
+@Entity
+class OpQueryBook {
+    String title
+    BigDecimal price
+    Integer minStock
+    Integer maxStock
+    List<String> tags
+    Date published
+
+    static DetachedCriteria<OpQueryBook> eqQuery = OpQueryBook.where { title 
== "Effective Java" }
+    static DetachedCriteria<OpQueryBook> neQuery = OpQueryBook.where { title 
!= "Effective Java" }
+    static DetachedCriteria<OpQueryBook> gtQuery = OpQueryBook.where { price > 
10.0 }
+    static DetachedCriteria<OpQueryBook> ltQuery = OpQueryBook.where { price < 
10.0 }
+    static DetachedCriteria<OpQueryBook> geQuery = OpQueryBook.where { price 
>= 10.0 }
+    static DetachedCriteria<OpQueryBook> leQuery = OpQueryBook.where { price 
<= 10.0 }
+    static DetachedCriteria<OpQueryBook> likeQuery = OpQueryBook.where { title 
==~ "Effective%" }
+    static DetachedCriteria<OpQueryBook> ilikeQuery = OpQueryBook.where { 
title =~ "effective%" }
+    static DetachedCriteria<OpQueryBook> rlikeQuery = OpQueryBook.where { 
title ==~ ~/Effective.+/ }
+    static DetachedCriteria<OpQueryBook> inListQuery = OpQueryBook.where { 
title in ["Foo", "Bar"] }
+    static DetachedCriteria<OpQueryBook> betweenQuery = OpQueryBook.where { 
minStock in 1..100 }
+    static DetachedCriteria<OpQueryBook> isNullQuery = OpQueryBook.where { 
title == null }
+    static DetachedCriteria<OpQueryBook> isNotNullQuery = OpQueryBook.where { 
title != null }
+    static DetachedCriteria<OpQueryBook> sizeEqQuery = OpQueryBook.where { 
tags.size() == 2 }
+    static DetachedCriteria<OpQueryBook> sizeGtQuery = OpQueryBook.where { 
tags.size() > 1 }
+    static DetachedCriteria<OpQueryBook> propertyComparisonQuery = 
OpQueryBook.where { minStock < maxStock }
+    static DetachedCriteria<OpQueryBook> conjunctionQuery = OpQueryBook.where 
{ title == "Foo" && price > 1 }
+    static DetachedCriteria<OpQueryBook> disjunctionQuery = OpQueryBook.where 
{ title == "Foo" || price > 1 }
+    static DetachedCriteria<OpQueryBook> aggregateDirectQuery = 
OpQueryBook.where { price > avg(price) }
+    static DetachedCriteria<OpQueryBook> aggregateOfQuery = OpQueryBook.where 
{ price > avg(price).of { maxStock > 5 } }
+}
+'''
+
+    private static Class<?> compile() {
+        new GroovyClassLoader().parseClass(SOURCE)
+    }
+
+    void "== on a direct property produces an Equals criterion"() {
+        given:
+        Query.Equals criterion = compile().eqQuery.criteria[0]
+
+        expect:
+        criterion.property == 'title'
+        criterion.value == 'Effective Java'
+    }
+
+    void "!= on a direct property produces a NotEquals criterion"() {
+        given:
+        Query.NotEquals criterion = compile().neQuery.criteria[0]
+
+        expect:
+        criterion.property == 'title'
+        criterion.value == 'Effective Java'
+    }
+
+    void "> produces a GreaterThan criterion"() {
+        given:
+        Query.GreaterThan criterion = compile().gtQuery.criteria[0]
+
+        expect:
+        criterion.property == 'price'
+        criterion.value == 10.0
+    }
+
+    void "< produces a LessThan criterion"() {
+        given:
+        Query.LessThan criterion = compile().ltQuery.criteria[0]
+
+        expect:
+        criterion.property == 'price'
+        criterion.value == 10.0
+    }
+
+    void ">= produces a GreaterThanEquals criterion"() {
+        given:
+        Query.GreaterThanEquals criterion = compile().geQuery.criteria[0]
+
+        expect:
+        criterion.property == 'price'
+        criterion.value == 10.0
+    }
+
+    void "<= produces a LessThanEquals criterion"() {
+        given:
+        Query.LessThanEquals criterion = compile().leQuery.criteria[0]
+
+        expect:
+        criterion.property == 'price'
+        criterion.value == 10.0
+    }
+
+    void "==~ produces a Like criterion"() {
+        given:
+        Query.Like criterion = compile().likeQuery.criteria[0]
+
+        expect:
+        criterion.property == 'title'
+        criterion.value == 'Effective%'
+    }
+
+    void "=~ produces an ILike criterion"() {
+        given:
+        Query.ILike criterion = compile().ilikeQuery.criteria[0]
+
+        expect:
+        criterion.property == 'title'
+        criterion.value == 'effective%'
+    }
+
+    void "==~ with a regex pattern produces an RLike criterion"() {
+        given:
+        Query.RLike criterion = compile().rlikeQuery.criteria[0]
+
+        expect:
+        criterion.property == 'title'
+        criterion.pattern == 'Effective.+'
+    }
+
+    void "in with a list literal produces an In criterion"() {
+        given:
+        Query.In criterion = compile().inListQuery.criteria[0]
+
+        expect:
+        criterion.property == 'title'
+        criterion.value == ['Foo', 'Bar']
+    }
+
+    void "in with a range produces a Between criterion"() {
+        given:
+        Query.Between criterion = compile().betweenQuery.criteria[0]
+
+        expect:
+        criterion.property == 'minStock'
+        criterion.from == 1
+        criterion.to == 100
+    }
+
+    void "== null produces an IsNull criterion"() {
+        given:
+        Query.IsNull criterion = compile().isNullQuery.criteria[0]
+
+        expect:
+        criterion.property == 'title'
+    }
+
+    void "!= null produces an IsNotNull criterion"() {
+        given:
+        Query.IsNotNull criterion = compile().isNotNullQuery.criteria[0]
+
+        expect:
+        criterion.property == 'title'
+    }
+
+    void "collection.size() == produces a SizeEquals criterion"() {
+        given:
+        Query.SizeEquals criterion = compile().sizeEqQuery.criteria[0]
+
+        expect:
+        criterion.property == 'tags'
+        criterion.value == 2
+    }
+
+    void "collection.size() > produces a SizeGreaterThan criterion"() {
+        given:
+        Query.SizeGreaterThan criterion = compile().sizeGtQuery.criteria[0]
+
+        expect:
+        criterion.property == 'tags'
+        criterion.value == 1
+    }
+
+    void "comparing two properties of the same class produces a *Property 
criterion"() {
+        given:
+        Query.LessThanProperty criterion = 
compile().propertyComparisonQuery.criteria[0]
+
+        expect:
+        criterion.property == 'minStock'
+        criterion.otherProperty == 'maxStock'
+    }
+
+    void "&& produces a Conjunction wrapping both criteria"() {
+        given:
+        Query.Conjunction junction = compile().conjunctionQuery.criteria[0]
+
+        expect:
+        junction.criteria.size() == 2
+        junction.criteria[0] instanceof Query.Equals
+        junction.criteria[1] instanceof Query.GreaterThan
+    }
+
+    void "|| produces a Disjunction wrapping both criteria"() {
+        given:
+        Query.Disjunction junction = compile().disjunctionQuery.criteria[0]
+
+        expect:
+        junction.criteria.size() == 2
+        junction.criteria[0] instanceof Query.Equals
+        junction.criteria[1] instanceof Query.GreaterThan
+    }
+
+    void "an aggregate function compared directly produces a criterion whose 
value is a projection-only DetachedCriteria"() {
+        given: 'the projection closure that the transform generates is 
automatically built by add(), per AbstractDetachedCriteria#add'
+        Query.GreaterThan criterion = 
compile().aggregateDirectQuery.criteria[0]
+
+        expect:
+        criterion.property == 'price'
+        criterion.value instanceof grails.gorm.DetachedCriteria
+
+        when:
+        grails.gorm.DetachedCriteria projectionQuery = 
(grails.gorm.DetachedCriteria) criterion.value
+
+        then: 'it applies an avg projection for the property and adds no 
further criteria'
+        projectionQuery.projections.size() == 1
+        projectionQuery.projections[0] instanceof Query.AvgProjection
+        ((Query.PropertyProjection) 
projectionQuery.projections[0]).propertyName == 'price'
+        projectionQuery.criteria.empty
+    }
+
+    void "an aggregate function subquery via .of() produces a criterion whose 
value is a built DetachedCriteria"() {
+        given:
+        Query.GreaterThan criterion = compile().aggregateOfQuery.criteria[0]
+
+        expect:
+        criterion.property == 'price'
+        criterion.value instanceof grails.gorm.DetachedCriteria
+
+        when:
+        grails.gorm.DetachedCriteria subquery = (grails.gorm.DetachedCriteria) 
criterion.value
+
+        then: 'the subquery has the avg projection applied'
+        subquery.projections.size() == 1
+        subquery.projections[0] instanceof Query.AvgProjection
+        ((Query.PropertyProjection) subquery.projections[0]).propertyName == 
'price'
+
+        and: 'the subquery has its own additional criterion applied'
+        subquery.criteria.size() == 1
+        subquery.criteria[0] instanceof Query.GreaterThan
+        ((Query.GreaterThan) subquery.criteria[0]).property == 'maxStock'
+        ((Query.GreaterThan) subquery.criteria[0]).value == 5
+    }
+}
diff --git 
a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/query/transform/WhereQueryStaticFieldSpec.groovy
 
b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/query/transform/WhereQueryStaticFieldSpec.groovy
new file mode 100644
index 0000000000..0875bf1cbc
--- /dev/null
+++ 
b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/query/transform/WhereQueryStaticFieldSpec.groovy
@@ -0,0 +1,252 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.grails.datastore.gorm.query.transform
+
+import org.codehaus.groovy.control.MultipleCompilationErrorsException
+import org.grails.datastore.mapping.query.Query
+
+import spock.lang.Specification
+
+/**
+ * A static field on a domain class initialised from {@code Domain.where { ... 
}} or
+ * {@code Domain.whereLazy { ... }} is handled specially by
+ * {@code DetachedCriteriaTransformer#visitField}: the whole field initializer 
is replaced with
+ * {@code new DetachedCriteria(Domain).build(closure)} (or {@code 
.buildLazy(closure)}), bypassing GORM's
+ * dynamic {@code where} method entirely. That means the field is a real, 
usable
+ * {@link grails.gorm.DetachedCriteria} the moment the class is initialised - 
no live datastore required
+ * for the flat criteria used here - which also makes a static field 
initializer body a convenient,
+ * self-contained place to exercise the different statement kinds
+ * {@code DetachedCriteriaTransformer#addStatementToNewQuery} and
+ * {@code DetachedCriteriaTransformer#flattenStatementIfNecessary} support: 
if/else, for, while, switch,
+ * try/catch/finally, return, plain declarations and alias declarations.
+ */
+class WhereQueryStaticFieldSpec extends Specification {
+
+    // The domain class name must be unique across the test JVM because
+    // AstPropertyResolveUtils caches resolved properties statically by class 
name
+    private static final String SOURCE = '''
+import grails.gorm.DetachedCriteria
+import grails.gorm.annotation.Entity
+
+@Entity
+class StmtKindsQueryBook {
+    String title
+    BigDecimal price
+
+    static DetachedCriteria<StmtKindsQueryBook> lazyQuery = 
StmtKindsQueryBook.whereLazy {
+        title == "Deferred"
+    }
+
+    static DetachedCriteria<StmtKindsQueryBook> ifElseQuery = 
StmtKindsQueryBook.where {
+        if (true) {
+            title == "IfBranch"
+        } else {
+            price > 1
+        }
+    }
+
+    static DetachedCriteria<StmtKindsQueryBook> forLoopQuery = 
StmtKindsQueryBook.where {
+        for (i in 0..1) {
+            title == "Loop"
+        }
+    }
+
+    static DetachedCriteria<StmtKindsQueryBook> whileLoopQuery = 
StmtKindsQueryBook.where {
+        while (false) {
+            title == "Never"
+        }
+    }
+
+    static DetachedCriteria<StmtKindsQueryBook> switchQuery = 
StmtKindsQueryBook.where {
+        switch (1) {
+            case 1:
+                title == "CaseOne"
+        }
+    }
+
+    static DetachedCriteria<StmtKindsQueryBook> tryCatchFinallyQuery = 
StmtKindsQueryBook.where {
+        try {
+            title == "TryBlock"
+        } catch (Exception e) {
+            price > 1
+        } finally {
+            title == "FinallyBlock"
+        }
+    }
+
+    static DetachedCriteria<StmtKindsQueryBook> returnStatementQuery = 
StmtKindsQueryBook.where {
+        return title == "Returned"
+    }
+
+    static DetachedCriteria<StmtKindsQueryBook> declarationPassthroughQuery = 
StmtKindsQueryBook.where {
+        def x = 42
+        title == "Declared"
+    }
+
+    static DetachedCriteria<StmtKindsQueryBook> classAliasQuery = 
StmtKindsQueryBook.where {
+        def a = StmtKindsQueryBook
+        title == "Aliased"
+    }
+}
+'''
+
+    private static Class<?> compile() {
+        new GroovyClassLoader().parseClass(SOURCE)
+    }
+
+    void "a whereLazy static field defers its criteria until first accessed"() 
{
+        given:
+        grails.gorm.DetachedCriteria lazy = compile().lazyQuery
+
+        expect: 'nothing has been built yet'
+        lazy.criteria.empty
+
+        when: 'a criterion is added, which triggers the deferred criteria to 
be applied first'
+        lazy.add(new Query.Equals('price', 1))
+
+        then:
+        lazy.criteria.size() == 2
+        lazy.criteria[0] instanceof Query.Equals
+        ((Query.Equals) lazy.criteria[0]).property == 'title'
+        ((Query.Equals) lazy.criteria[0]).value == 'Deferred'
+        lazy.criteria[1] instanceof Query.Equals
+        ((Query.Equals) lazy.criteria[1]).property == 'price'
+    }
+
+    void "an if/else statement only builds the criteria for the branch 
actually taken"() {
+        given:
+        grails.gorm.DetachedCriteria query = compile().ifElseQuery
+
+        expect:
+        query.criteria.size() == 1
+        query.criteria[0] instanceof Query.Equals
+        ((Query.Equals) query.criteria[0]).property == 'title'
+        ((Query.Equals) query.criteria[0]).value == 'IfBranch'
+    }
+
+    void "a for statement builds one criterion per loop iteration"() {
+        given:
+        grails.gorm.DetachedCriteria query = compile().forLoopQuery
+
+        expect:
+        query.criteria.size() == 2
+        query.criteria.every { it instanceof Query.Equals && it.property == 
'title' && it.value == 'Loop' }
+    }
+
+    void "a while statement whose body never runs builds no criteria"() {
+        given:
+        grails.gorm.DetachedCriteria query = compile().whileLoopQuery
+
+        expect:
+        query.criteria.empty
+    }
+
+    void "a switch statement builds the criteria for the matched case"() {
+        given:
+        grails.gorm.DetachedCriteria query = compile().switchQuery
+
+        expect:
+        query.criteria.size() == 1
+        ((Query.Equals) query.criteria[0]).property == 'title'
+        ((Query.Equals) query.criteria[0]).value == 'CaseOne'
+    }
+
+    void "a try/finally block builds criteria for both the try body and the 
finally body"() {
+        given:
+        grails.gorm.DetachedCriteria query = compile().tryCatchFinallyQuery
+
+        expect: 'the catch is compiled but never executes since the try body 
does not throw'
+        query.criteria.size() == 2
+        ((Query.Equals) query.criteria[0]).value == 'TryBlock'
+        ((Query.Equals) query.criteria[1]).value == 'FinallyBlock'
+    }
+
+    void "a return statement builds the criteria for the returned 
expression"() {
+        given:
+        grails.gorm.DetachedCriteria query = compile().returnStatementQuery
+
+        expect:
+        query.criteria.size() == 1
+        ((Query.Equals) query.criteria[0]).value == 'Returned'
+    }
+
+    void "a plain declaration statement is left untouched and does not prevent 
later criteria from being built"() {
+        given:
+        grails.gorm.DetachedCriteria query = 
compile().declarationPassthroughQuery
+
+        expect:
+        query.criteria.size() == 1
+        ((Query.Equals) query.criteria[0]).value == 'Declared'
+    }
+
+    void "assigning the domain class itself to a variable sets the query 
alias"() {
+        given:
+        grails.gorm.DetachedCriteria query = compile().classAliasQuery
+
+        expect:
+        query.alias == 'a'
+        query.criteria.size() == 1
+        ((Query.Equals) query.criteria[0]).value == 'Aliased'
+    }
+
+    void "assigning an existing property name to a variable inside a where 
block compiles"() {
+        expect: 'the createAlias-generating declaration branch compiles 
cleanly; it is not executed here ' +
+                'because Criteria#createAlias needs a GORM-enhanced entity, 
which this module has none of'
+        new GroovyClassLoader().parseClass('''
+import grails.gorm.DetachedCriteria
+import grails.gorm.annotation.Entity
+
+@Entity
+class StmtKindsPropertyAliasBook {
+    String title
+
+    static DetachedCriteria<StmtKindsPropertyAliasBook> propertyAliasQuery = 
StmtKindsPropertyAliasBook.where {
+        def t = title
+        title == "Aliased"
+    }
+}
+''')
+    }
+
+    void "negating a non-binary expression inside an if block still fails to 
compile"() {
+        when:
+        new GroovyClassLoader().parseClass('''
+import grails.gorm.annotation.Entity
+import org.grails.datastore.gorm.query.transform.ApplyDetachedCriteriaTransform
+
+@ApplyDetachedCriteriaTransform
+@Entity
+class StmtKindsInvalidIfBook {
+    String title
+
+    static findInvalid() {
+        StmtKindsInvalidIfBook.where {
+            if (true) {
+                !(title)
+            }
+        }
+    }
+}
+''')
+
+        then:
+        MultipleCompilationErrorsException e = thrown()
+        e.message.contains('You can only negate a binary expressions in 
queries')
+    }
+}

Reply via email to