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' } + } +}
