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