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 82c46d8095302f8ba55e3811ec0b03aeead0bf6b
Author: Walter Duque de Estrada <[email protected]>
AuthorDate: Thu Aug 13 09:53:31 2026 -0500

    Close remaining coverage gaps in addCriteriaCall and association property 
queries
    
    Follow-up to the prior where-query DSL coverage pass: the two largest
    remaining gaps in DetachedCriteriaTransformer were addCriteriaCall
    (71%/55%) and handleAssociationQueryViaPropertyExpression (54%/39%).
    Add four specs targeting the specific uncovered branches:
    
    - Aggregate functions called directly (not via .of()) with a
      non-property argument or an unknown property, and the property()
      pseudo-function combined with a subquery-mappable operator
      (rewritten into an *All subquery criterion).
    - Property-name and self-class aliases (`def t = someProperty`,
      `def a = Domain`) compared against association or plain properties,
      rewritten into *Property criterion calls.
    - A function call wrapped around a two-level association path, a
      distinct branch from both the single-level case and the plain
      (non-function) multi-level comparison.
    - A direct dotted comparison against an embedded (non-domain) property,
      distinct from the existing block-call embedded syntax coverage.
    
    Where execution needs a live, GORM-enhanced PersistentEntity this
    module doesn't have, these compile to the transform's own
    CANONICALIZATION phase and inspect the resulting AST for the exact
    rewrite produced, rather than only asserting the source compiles.
    
    Coverage moves 78% -> 86% instruction, 59% -> 64% branch overall;
    addCriteriaCall to 95%/68%, handleAssociationQueryViaPropertyExpression
    to 89%/63%. The one remaining gap in the class
    (getPropertyNamesForAssociation's null-fallback check) is confirmed
    dead code - that method can never return null.
    
    Co-Authored-By: Claude Sonnet 5 <[email protected]>
---
 .../WhereQueryAggregateSubqueryErrorSpec.groovy    | 131 ++++++++++++++
 .../WhereQueryEmbeddedPropertyPathSpec.groovy      | 196 +++++++++++++++++++++
 ...reQueryMultiLevelAssociationFunctionSpec.groovy | 138 +++++++++++++++
 .../transform/WhereQueryPropertyAliasSpec.groovy   | 177 +++++++++++++++++++
 4 files changed, 642 insertions(+)

diff --git 
a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/query/transform/WhereQueryAggregateSubqueryErrorSpec.groovy
 
b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/query/transform/WhereQueryAggregateSubqueryErrorSpec.groovy
new file mode 100644
index 0000000000..e80e12e758
--- /dev/null
+++ 
b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/query/transform/WhereQueryAggregateSubqueryErrorSpec.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
+
+/**
+ * When an aggregate function such as {@code avg(...)} is called directly 
(bare, implicit-this) rather
+ * than via the {@code .of()} subquery form, {@code 
DetachedCriteriaTransformer#addCriteriaCall} requires
+ * its single argument to be a plain property reference: a compile error is 
raised if the argument is an
+ * expression (e.g. {@code price + 1}) rather than a variable/constant, and a 
different compile error is
+ * raised if it textually looks like a property reference but does not 
actually resolve to one on the
+ * current class. Separately, the {@code property(...)} pseudo aggregate 
function (distinct from a real
+ * aggregate like {@code avg}/{@code sum}) combined with one of the 
subquery-mappable comparison operators
+ * (eq/gt/lt/ge/le) is rewritten into a {@code *All} subquery criterion (e.g. 
{@code gtAll}) rather than a
+ * plain comparison. All three cases only append/build in-memory {@link 
grails.gorm.DetachedCriteria}
+ * state, so - like {@link WhereQueryOperatorSpec} - they can be verified 
without a live datastore.
+ */
+class WhereQueryAggregateSubqueryErrorSpec 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 AggSubqueryBook {
+    String title
+    BigDecimal price
+    BigDecimal total
+    Integer minStock
+
+    static DetachedCriteria<AggSubqueryBook> propertySubqueryQuery = 
AggSubqueryBook.where {
+        price > property(minStock)
+    }
+}
+'''
+
+    private static Class<?> compile() {
+        new GroovyClassLoader().parseClass(SOURCE)
+    }
+
+    void "the property() pseudo aggregate combined with a subquery-mappable 
operator produces an *All subquery criterion"() {
+        given:
+        Query.GreaterThanAll criterion = 
compile().propertySubqueryQuery.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 carries a plain property projection rather than an 
aggregate function'
+        subquery.projections.size() == 1
+        subquery.projections[0].class == Query.PropertyProjection
+        ((Query.PropertyProjection) subquery.projections[0]).propertyName == 
'minStock'
+        subquery.criteria.empty
+    }
+
+    void "calling an aggregate function directly with a non-property 
expression argument fails to compile"() {
+        when:
+        new GroovyClassLoader().parseClass('''
+import grails.gorm.annotation.Entity
+import org.grails.datastore.gorm.query.transform.ApplyDetachedCriteriaTransform
+
+@ApplyDetachedCriteriaTransform
+@Entity
+class AggExpressionArgBook {
+    BigDecimal price
+    BigDecimal total
+
+    static findInvalid() {
+        AggExpressionArgBook.where {
+            total > avg(price + 1)
+        }
+    }
+}
+''')
+
+        then:
+        MultipleCompilationErrorsException e = thrown()
+        e.message.contains('Cannot use aggregate function avg on expressions')
+    }
+
+    void "calling an aggregate function directly on an unknown property fails 
to compile"() {
+        when:
+        new GroovyClassLoader().parseClass('''
+import grails.gorm.annotation.Entity
+import org.grails.datastore.gorm.query.transform.ApplyDetachedCriteriaTransform
+
+@ApplyDetachedCriteriaTransform
+@Entity
+class AggUnknownPropertyBook {
+    BigDecimal price
+    BigDecimal total
+
+    static findInvalid() {
+        AggUnknownPropertyBook.where {
+            total > avg(nonExistentProperty)
+        }
+    }
+}
+''')
+
+        then:
+        MultipleCompilationErrorsException e = thrown()
+        e.message.contains('Cannot use aggregate function avg on property 
"nonExistentProperty"')
+        e.message.contains('no such property on class')
+    }
+}
diff --git 
a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/query/transform/WhereQueryEmbeddedPropertyPathSpec.groovy
 
b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/query/transform/WhereQueryEmbeddedPropertyPathSpec.groovy
new file mode 100644
index 0000000000..4b077b98fb
--- /dev/null
+++ 
b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/query/transform/WhereQueryEmbeddedPropertyPathSpec.groovy
@@ -0,0 +1,196 @@
+/*
+ *  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.ast.ASTNode
+import org.codehaus.groovy.ast.ClassNode
+import org.codehaus.groovy.ast.CodeVisitorSupport
+import org.codehaus.groovy.ast.ModuleNode
+import org.codehaus.groovy.ast.expr.ArgumentListExpression
+import org.codehaus.groovy.ast.expr.ConstantExpression
+import org.codehaus.groovy.ast.expr.ConstructorCallExpression
+import org.codehaus.groovy.ast.expr.Expression
+import org.codehaus.groovy.ast.expr.MethodCallExpression
+import org.codehaus.groovy.control.CompilationUnit
+import org.codehaus.groovy.control.MultipleCompilationErrorsException
+import org.codehaus.groovy.control.Phases
+
+import spock.lang.Specification
+
+/**
+ * {@link WhereQueryEmbeddedBlockTransformSpec} covers querying an embedded 
(non-domain, plain Groovy)
+ * property via the block-call syntax ({@code extRef { value == search }}), 
which
+ * {@code DetachedCriteriaTransformer} routes through {@code 
handleAssociationMethodCallExpression}. A
+ * direct DOTTED comparison against an embedded property with no block ({@code 
extRef.value == "x"})
+ * instead goes through the {@code AstUtils.isGroovyType(type)} branch of
+ * {@code #handleAssociationQueryViaPropertyExpression} - a distinct code path 
from both the block-call
+ * form and the domain-association branch that immediately follows it in the 
same method (the one
+ * {@link WhereQueryAssociationPathSpec} and {@link 
WhereQueryFunctionCallSpec} exercise for real
+ * associations).
+ * <p>
+ * Like the other association branches, executing the rewritten query needs a 
GORM-enhanced entity this
+ * module has none of, so these are verified by compiling to the 
CANONICALIZATION phase - the phase the
+ * transform itself runs in - and inspecting the resulting AST for the {@code 
eq('value', 'x')} call the
+ * embedded branch generates, or for the compile error the branch's own 
unknown-property check raises.
+ */
+class WhereQueryEmbeddedPropertyPathSpec 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 SOURCE = '''
+import grails.gorm.DetachedCriteria
+import grails.gorm.annotation.Entity
+
+class EmbedPathQueryService {
+    protected DetachedCriteria<EmbedPathBook> findByRefValue(String search) {
+        EmbedPathBook.where {
+            extRef.value == search
+        }
+    }
+
+    protected DetachedCriteria<EmbedPathBook> findByRefPublishedYear(int yr) {
+        EmbedPathBook.where {
+            year(extRef.published) == yr
+        }
+    }
+}
+
+@Entity
+class EmbedPathBook {
+    String description
+    EmbedPathExternalRef extRef
+
+    static embedded = ['extRef']
+}
+
+class EmbedPathExternalRef {
+    String provider
+    String value
+    Date published
+}
+'''
+
+    private static ClassNode compileToClassNode(String source, String 
className) {
+        CompilationUnit unit = new CompilationUnit(new GroovyClassLoader())
+        def sourceUnit = unit.addSource('Source.groovy', source)
+        unit.compile(Phases.CANONICALIZATION)
+        ModuleNode moduleNode = sourceUnit.getAST()
+        (ClassNode) moduleNode.classes.find { ClassNode cn -> cn.name == 
className }
+    }
+
+    private static List<MethodCallExpression> methodCallsIn(ASTNode node) {
+        List<MethodCallExpression> calls = []
+        CodeVisitorSupport visitor = new CodeVisitorSupport() {
+            @Override
+            void visitMethodCallExpression(MethodCallExpression call) {
+                calls << call
+                super.visitMethodCallExpression(call)
+            }
+        }
+        node.visit(visitor)
+        calls
+    }
+
+    private static String constantArg(MethodCallExpression call, int index) {
+        Expression arg = ((ArgumentListExpression) 
call.arguments).getExpression(index)
+        ((ConstantExpression) arg).value as String
+    }
+
+    void "a direct dotted comparison against an embedded property compiles and 
generates a nested embedded closure"() {
+        given:
+        GroovyClassLoader gcl = new GroovyClassLoader()
+
+        when:
+        gcl.parseClass(SOURCE)
+
+        then:
+        noExceptionThrown()
+
+        and: 'a nested closure was synthesized for the embedded delegate call'
+        List<Class<?>> queryClosures = gcl.loadedClasses.findAll {
+            it.name.contains('_findByRefValue_')
+        }.sort { it.name.count('$_closure') }
+        queryClosures.size() == 2
+        queryClosures.last().name.count('$_closure') == 1
+    }
+
+    void "a direct dotted comparison against an embedded property rewrites to 
an eq call on the embedded property name"() {
+        given:
+        ClassNode classNode = compileToClassNode(SOURCE, 
'EmbedPathQueryService')
+
+        when:
+        def method = classNode.methods.find { it.name == 'findByRefValue' }
+        List<MethodCallExpression> eqCalls = 
methodCallsIn(method.code).findAll { it.methodAsString == 'eq' }
+
+        then: 'the embedded property name (not the full "extRef.value" path) 
was used as the criterion property'
+        eqCalls.any { constantArg(it, 0) == 'value' }
+    }
+
+    void "a function call through an embedded property rewrites to a 
FunctionCallingCriterion on the embedded property name"() {
+        given:
+        ClassNode classNode = compileToClassNode(SOURCE, 
'EmbedPathQueryService')
+
+        when:
+        def method = classNode.methods.find { it.name == 
'findByRefPublishedYear' }
+        List<ConstructorCallExpression> constructorCalls = []
+        def visitor = new CodeVisitorSupport() {
+            @Override
+            void visitConstructorCallExpression(ConstructorCallExpression 
call) {
+                constructorCalls << call
+                super.visitConstructorCallExpression(call)
+            }
+        }
+        method.code.visit(visitor)
+
+        then:
+        constructorCalls.any { 
it.type.name.endsWith('FunctionCallingCriterion') }
+    }
+
+    void "querying an unknown property on an embedded association fails to 
compile"() {
+        when:
+        new GroovyClassLoader().parseClass('''
+import grails.gorm.annotation.Entity
+import org.grails.datastore.gorm.query.transform.ApplyDetachedCriteriaTransform
+
+@ApplyDetachedCriteriaTransform
+@Entity
+class EmbedPathUnknownPropBook {
+    String description
+    EmbedPathUnknownPropExternalRef extRef
+
+    static embedded = ['extRef']
+
+    static findInvalid() {
+        EmbedPathUnknownPropBook.where {
+            extRef.unknownProperty == "x"
+        }
+    }
+}
+
+class EmbedPathUnknownPropExternalRef {
+    String provider
+    String value
+}
+''')
+
+        then:
+        MultipleCompilationErrorsException e = thrown()
+        e.message.contains('Cannot query property "unknownProperty"')
+    }
+}
diff --git 
a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/query/transform/WhereQueryMultiLevelAssociationFunctionSpec.groovy
 
b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/query/transform/WhereQueryMultiLevelAssociationFunctionSpec.groovy
new file mode 100644
index 0000000000..0cf7a433db
--- /dev/null
+++ 
b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/query/transform/WhereQueryMultiLevelAssociationFunctionSpec.groovy
@@ -0,0 +1,138 @@
+/*
+ *  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.ast.ASTNode
+import org.codehaus.groovy.ast.ClassNode
+import org.codehaus.groovy.ast.CodeVisitorSupport
+import org.codehaus.groovy.ast.ModuleNode
+import org.codehaus.groovy.ast.expr.ArgumentListExpression
+import org.codehaus.groovy.ast.expr.ConstantExpression
+import org.codehaus.groovy.ast.expr.ConstructorCallExpression
+import org.codehaus.groovy.control.CompilationUnit
+import org.codehaus.groovy.control.Phases
+
+import spock.lang.Specification
+
+/**
+ * {@link WhereQueryFunctionCallSpec} covers a SQL function such as {@code 
year(...)} wrapping a
+ * single-level association property (e.g. {@code year(author.birthDate)}), 
which is routed through the
+ * {@code objectExpression instanceof VariableExpression} branch of
+ * {@code 
DetachedCriteriaTransformer#handleAssociationQueryViaPropertyExpression}. 
Wrapping a function
+ * call around a TWO-level association path (e.g. {@code 
year(author.publisher.foundedDate)}) instead
+ * walks the nested {@code while (objectExpression instanceof 
PropertyExpression)} loop that resolves the
+ * root variable and replays each path segment as a {@code 
delegate.<association> { ... }} call, and only
+ * at the innermost segment dispatches to {@code handleFunctionCall} - a 
distinct branch from the one
+ * {@link WhereQueryAssociationPathSpec}'s multi-level test exercises (that 
test compares the association
+ * property directly, with no wrapping function, so it never reaches {@code 
handleFunctionCall} at all).
+ * <p>
+ * Like all association criteria, executing the resulting query needs a 
GORM-enhanced entity that this
+ * module deliberately has none of, so the association walk itself is verified 
structurally (source
+ * compiles, one nested closure per path segment - as in {@link 
WhereQueryAssociationPathSpec}). The
+ * function-call dispatch specifically is verified by compiling to the 
CANONICALIZATION phase - the same
+ * phase the transform runs in - and inspecting the resulting AST directly for 
the
+ * {@code FunctionCallingCriterion} construction that only {@code 
handleFunctionCall} emits.
+ */
+class WhereQueryMultiLevelAssociationFunctionSpec 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 SOURCE = '''
+import grails.gorm.DetachedCriteria
+import grails.gorm.annotation.Entity
+
+class MultiLevelFuncQueryService {
+    protected DetachedCriteria<MultiLevelFuncBook> 
findByPublisherFoundedYear(int yr) {
+        MultiLevelFuncBook.where {
+            year(author.publisher.foundedDate) == yr
+        }
+    }
+}
+
+@Entity
+class MultiLevelFuncBook {
+    String title
+    MultiLevelFuncAuthor author
+}
+
+@Entity
+class MultiLevelFuncAuthor {
+    String name
+    MultiLevelFuncPublisher publisher
+}
+
+@Entity
+class MultiLevelFuncPublisher {
+    Date foundedDate
+}
+'''
+
+    private static List<ASTNode> findQueryClosures(GroovyClassLoader gcl) {
+        gcl.loadedClasses.findAll {
+            it.name.contains('_findByPublisherFoundedYear_')
+        }
+    }
+
+    void "a function call through a two-level association path compiles and 
generates one nested closure per path segment"() {
+        given:
+        GroovyClassLoader gcl = new GroovyClassLoader()
+
+        when:
+        gcl.parseClass(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).sort { 
it.name.count('$_closure') }
+        queryClosures.size() == 3
+        queryClosures.last().name.count('$_closure') == 2
+    }
+
+    void "a function call through a two-level association path dispatches to 
handleFunctionCall, building a FunctionCallingCriterion"() {
+        given:
+        CompilationUnit unit = new CompilationUnit(new GroovyClassLoader())
+        def sourceUnit = unit.addSource('Source.groovy', SOURCE)
+
+        when:
+        unit.compile(Phases.CANONICALIZATION)
+        ModuleNode moduleNode = sourceUnit.getAST()
+        ClassNode serviceClass = moduleNode.classes.find { it.name == 
'MultiLevelFuncQueryService' }
+
+        List<ConstructorCallExpression> constructorCalls = []
+        def visitor = new CodeVisitorSupport() {
+            @Override
+            void visitConstructorCallExpression(ConstructorCallExpression 
call) {
+                constructorCalls << call
+                super.visitConstructorCallExpression(call)
+            }
+        }
+        def method = serviceClass.methods.find { it.name == 
'findByPublisherFoundedYear' }
+        method.code.visit(visitor)
+
+        then: 'a FunctionCallingCriterion was constructed for the year() 
function, applied against the innermost association property'
+        constructorCalls.any { 
it.type.name.endsWith('FunctionCallingCriterion') }
+
+        and: 'the function name was passed through untouched'
+        constructorCalls.any { call ->
+            call.type.name.endsWith('FunctionCallingCriterion') &&
+                    ((ConstantExpression) ((ArgumentListExpression) 
call.arguments).getExpression(0)).value == 'year'
+        }
+    }
+}
diff --git 
a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/query/transform/WhereQueryPropertyAliasSpec.groovy
 
b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/query/transform/WhereQueryPropertyAliasSpec.groovy
new file mode 100644
index 0000000000..3133954445
--- /dev/null
+++ 
b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/query/transform/WhereQueryPropertyAliasSpec.groovy
@@ -0,0 +1,177 @@
+/*
+ *  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.ast.ASTNode
+import org.codehaus.groovy.ast.ClassNode
+import org.codehaus.groovy.ast.CodeVisitorSupport
+import org.codehaus.groovy.ast.ModuleNode
+import org.codehaus.groovy.ast.expr.ArgumentListExpression
+import org.codehaus.groovy.ast.expr.ConstantExpression
+import org.codehaus.groovy.ast.expr.Expression
+import org.codehaus.groovy.ast.expr.MethodCallExpression
+import org.codehaus.groovy.control.CompilationUnit
+import org.codehaus.groovy.control.Phases
+
+import spock.lang.Specification
+
+/**
+ * A where-block can declare a local alias for a property (e.g. {@code def t = 
someProperty}, handled by
+ * {@code DetachedCriteriaTransformer#addStatementToNewQuery} around its 
{@code createAlias}-generating
+ * declaration branch - see {@link WhereQueryStaticFieldSpec}) or a self-alias 
for the whole domain class
+ * (e.g. {@code def a = SomeDomain}, which calls {@code this.setAlias(...)} 
and records the alias against
+ * the class itself rather than a property name). A later comparison that 
references either kind of alias
+ * on the right-hand side is rewritten by {@code 
DetachedCriteriaTransformer#addCriteriaCall} /
+ * {@code #handleAssociationQueryViaPropertyExpression} into an {@code 
*Property} comparison method
+ * (eqProperty, gtProperty, ...) built from the alias text directly, rather 
than a plain value comparison.
+ * <p>
+ * Executing the resulting query would require a live, GORM-enhanced {@code 
PersistentEntity} - the alias
+ * methods ({@code createAlias}, {@code setAlias}) that back these branches 
resolve association metadata at
+ * runtime, which this module deliberately has none of (see the note on
+ * {@code WhereQueryStaticFieldSpec#"assigning an existing property 
name..."}). These branches are
+ * therefore verified precisely but without execution: the source is compiled 
to the CANONICALIZATION
+ * phase - the same phase the transform itself runs in - and the resulting AST 
is inspected directly for
+ * the generated {@code *Property} method call, which proves the branch under 
test produced exactly the
+ * rewrite expected rather than merely that the source happened to compile.
+ */
+class WhereQueryPropertyAliasSpec extends Specification {
+
+    private static ClassNode compileToClassNode(String source, String 
className) {
+        CompilationUnit unit = new CompilationUnit(new GroovyClassLoader())
+        def sourceUnit = unit.addSource('Source.groovy', source)
+        unit.compile(Phases.CANONICALIZATION)
+        ModuleNode moduleNode = sourceUnit.getAST()
+        (ClassNode) moduleNode.classes.find { ClassNode cn -> cn.name == 
className }
+    }
+
+    private static List<MethodCallExpression> methodCallsIn(ASTNode node) {
+        List<MethodCallExpression> calls = []
+        CodeVisitorSupport visitor = new CodeVisitorSupport() {
+            @Override
+            void visitMethodCallExpression(MethodCallExpression call) {
+                calls << call
+                super.visitMethodCallExpression(call)
+            }
+        }
+        node.visit(visitor)
+        calls
+    }
+
+    private static List<MethodCallExpression> methodCallsNamed(ClassNode 
classNode, String methodName, String staticMethodName) {
+        def method = classNode.methods.find { it.name == staticMethodName }
+        methodCallsIn(method.code).findAll { it.methodAsString == methodName }
+    }
+
+    private static String constantArg(MethodCallExpression call, int index) {
+        Expression arg = ((ArgumentListExpression) 
call.arguments).getExpression(index)
+        ((ConstantExpression) arg).value as String
+    }
+
+    void "comparing an association property against a property-name alias 
produces an eqProperty call"() {
+        given:
+        String source = '''
+import grails.gorm.annotation.Entity
+import org.grails.datastore.gorm.query.transform.ApplyDetachedCriteriaTransform
+
+@ApplyDetachedCriteriaTransform
+@Entity
+class AliasAssocPropBook {
+    String someStringProperty
+    AliasAssocPropAuthor someAssociation
+
+    static findAliasedAssocMatch() {
+        AliasAssocPropBook.where {
+            def t = someStringProperty
+            someAssociation.assocProp == t.whatever
+        }
+    }
+}
+
+@Entity
+class AliasAssocPropAuthor {
+    String assocProp
+}
+'''
+
+        when:
+        ClassNode classNode = compileToClassNode(source, 'AliasAssocPropBook')
+        List<MethodCallExpression> eqPropertyCalls = 
methodCallsNamed(classNode, 'eqProperty', 'findAliasedAssocMatch')
+
+        then: 'the association property comparison against the alias was 
rewritten to compare against the alias text directly'
+        eqPropertyCalls.any { constantArg(it, 0) == 'assocProp' && 
constantArg(it, 1) == 't.whatever' }
+    }
+
+    void "comparing a plain property against a property-name alias produces an 
eqProperty call"() {
+        given:
+        String source = '''
+import grails.gorm.annotation.Entity
+import org.grails.datastore.gorm.query.transform.ApplyDetachedCriteriaTransform
+
+@ApplyDetachedCriteriaTransform
+@Entity
+class AliasVarPropBook {
+    String someStringProperty
+    String otherProperty
+
+    static findAliasedVarMatch() {
+        AliasVarPropBook.where {
+            def t = someStringProperty
+            otherProperty == t.whatever
+        }
+    }
+}
+'''
+
+        when:
+        ClassNode classNode = compileToClassNode(source, 'AliasVarPropBook')
+        List<MethodCallExpression> eqPropertyCalls = 
methodCallsNamed(classNode, 'eqProperty', 'findAliasedVarMatch')
+
+        then: 'the plain property comparison against the alias was rewritten 
to compare against the alias text directly'
+        eqPropertyCalls.any { constantArg(it, 0) == 'otherProperty' && 
constantArg(it, 1) == 't.whatever' }
+    }
+
+    void "comparing two self-aliases of the same domain class against each 
other produces an eqProperty call"() {
+        given:
+        String source = '''
+import grails.gorm.annotation.Entity
+import org.grails.datastore.gorm.query.transform.ApplyDetachedCriteriaTransform
+
+@ApplyDetachedCriteriaTransform
+@Entity
+class AliasSelfBook {
+    String someProperty
+
+    static findSelfAliasMatch() {
+        AliasSelfBook.where {
+            def a = AliasSelfBook
+            def b = AliasSelfBook
+            a.someProperty == b.someProperty
+        }
+    }
+}
+'''
+
+        when:
+        ClassNode classNode = compileToClassNode(source, 'AliasSelfBook')
+        List<MethodCallExpression> eqPropertyCalls = 
methodCallsNamed(classNode, 'eqProperty', 'findSelfAliasMatch')
+
+        then: 'the comparison between the two self-aliased references was 
rewritten into an eqProperty call'
+        eqPropertyCalls.any { constantArg(it, 0) == 'a.someProperty' && 
constantArg(it, 1) == 'b.someProperty' }
+    }
+}

Reply via email to