diff --git a/grails-core/src/main/groovy/grails/compiler/GrailsCompileStatic.groovy b/grails-core/src/main/groovy/grails/compiler/GrailsCompileStatic.groovy index 50d08049078..764e6a277f1 100644 --- a/grails-core/src/main/groovy/grails/compiler/GrailsCompileStatic.groovy +++ b/grails-core/src/main/groovy/grails/compiler/GrailsCompileStatic.groovy @@ -33,5 +33,6 @@ import groovy.transform.CompileStatic 'org.grails.compiler.WhereQueryTypeCheckingExtension', 'org.grails.compiler.DynamicFinderTypeCheckingExtension', 'org.grails.compiler.DomainMappingTypeCheckingExtension', + 'org.grails.compiler.CriteriaTypeCheckingExtension', 'org.grails.compiler.RelationshipManagementMethodTypeCheckingExtension']) @interface GrailsCompileStatic {} diff --git a/grails-core/src/main/groovy/org/grails/compiler/CriteriaTypeCheckingExtension.groovy b/grails-core/src/main/groovy/org/grails/compiler/CriteriaTypeCheckingExtension.groovy index 826135dee08..22d015df293 100644 --- a/grails-core/src/main/groovy/org/grails/compiler/CriteriaTypeCheckingExtension.groovy +++ b/grails-core/src/main/groovy/org/grails/compiler/CriteriaTypeCheckingExtension.groovy @@ -63,10 +63,28 @@ class CriteriaTypeCheckingExtension extends TypeCheckingDSL { null } + private static final List CRITERIA_TERMINALS = + ['list', 'listDistinct', 'get', 'count', 'scroll'] + protected boolean isCriteriaCall(MethodCall call) { - call instanceof MethodCallExpression && - call.objectExpression instanceof ClassExpression && - GrailsASTUtils.isDomainClass(call.objectExpression.type, null) && - (call.method.value == 'withCriteria' || call.method.value == 'createCriteria') + if (!(call instanceof MethodCallExpression)) { + return false + } + // Domain.withCriteria { } / Domain.createCriteria() — the criteria closure is the direct argument. + if (call.objectExpression instanceof ClassExpression && + GrailsASTUtils.isDomainClass(call.objectExpression.type, null) && + (call.method.value == 'withCriteria' || call.method.value == 'createCriteria')) { + return true + } + // Domain.createCriteria().list/get/count/... { } — the closure is the argument to the terminal + // call chained on the builder, so its body must be type-checked in a criteria scope too. + if (call.method.value in CRITERIA_TERMINALS && + call.objectExpression instanceof MethodCallExpression && + call.objectExpression.method.value == 'createCriteria' && + call.objectExpression.objectExpression instanceof ClassExpression && + GrailsASTUtils.isDomainClass(call.objectExpression.objectExpression.type, null)) { + return true + } + false } } diff --git a/grails-datamapping-tck/src/main/groovy/org/apache/grails/data/testing/tck/support/StaticCompiledCriteriaQueries.groovy b/grails-datamapping-tck/src/main/groovy/org/apache/grails/data/testing/tck/support/StaticCompiledCriteriaQueries.groovy new file mode 100644 index 00000000000..30c3aeb6828 --- /dev/null +++ b/grails-datamapping-tck/src/main/groovy/org/apache/grails/data/testing/tck/support/StaticCompiledCriteriaQueries.groovy @@ -0,0 +1,102 @@ +/* + * 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.apache.grails.data.testing.tck.support + +import grails.compiler.GrailsCompileStatic + +import org.apache.grails.data.testing.tck.domains.TestEntity + +/** + * Criteria queries compiled with {@code @GrailsCompileStatic} so that the bytecode produced by + * the {@code CriteriaTypeCheckingExtension} is executed by the TCK against every GORM + * implementation. Calls declared on the {@code Criteria} API (eq, like, or, order) compile to + * static delegate dispatch, while calls that are not on the API (projections, min, countDistinct, + * property, maxResults and the {@code count} terminal chained on {@code createCriteria()}) only + * compile because the extension falls back to dynamic dispatch — executing them here proves that + * dispatch resolves against the criteria builder of the implementation under test. + */ +@GrailsCompileStatic +class StaticCompiledCriteriaQueries { + + static List listByNameLike(String pattern) { + (List) TestEntity.createCriteria().list { + like('name', pattern) + } + } + + static List listByNameLikeLimited(String pattern, int limit) { + (List) TestEntity.createCriteria().list { + like('name', pattern) + maxResults(limit) + } + } + + static List listPaginatedOrderedByAge(int max, int offset) { + (List) TestEntity.createCriteria().list(max: max, offset: offset) { + order('age') + } + } + + static Number countByNameLike(String pattern) { + (Number) TestEntity.createCriteria().count { + like('name', pattern) + } + } + + static TestEntity getByName(String name) { + (TestEntity) TestEntity.createCriteria().get { + eq('name', name) + } + } + + static Number minAge() { + (Number) TestEntity.createCriteria().get { + projections { + min('age') + } + } + } + + static Number countDistinctAges() { + (Number) TestEntity.createCriteria().get { + projections { + countDistinct('age') + } + } + } + + static List namesMatching(String pattern) { + (List) TestEntity.withCriteria { + like('name', pattern) + projections { + property('name') + } + } + } + + static List listByNameLikeOrAge(String pattern, int age) { + (List) TestEntity.withCriteria { + or { + like('name', pattern) + eq('age', age) + } + order('age', 'asc') + } + } +} diff --git a/grails-datamapping-tck/src/main/groovy/org/apache/grails/data/testing/tck/tests/StaticCompiledCriteriaSpec.groovy b/grails-datamapping-tck/src/main/groovy/org/apache/grails/data/testing/tck/tests/StaticCompiledCriteriaSpec.groovy new file mode 100644 index 00000000000..f015b0549cc --- /dev/null +++ b/grails-datamapping-tck/src/main/groovy/org/apache/grails/data/testing/tck/tests/StaticCompiledCriteriaSpec.groovy @@ -0,0 +1,153 @@ +/* + * 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.apache.grails.data.testing.tck.tests + +import org.apache.grails.data.testing.tck.base.GrailsDataTckSpec +import org.apache.grails.data.testing.tck.domains.ChildEntity +import org.apache.grails.data.testing.tck.domains.TestEntity +import org.apache.grails.data.testing.tck.support.StaticCompiledCriteriaQueries + +/** + * Executes the criteria queries that {@link StaticCompiledCriteriaQueries} compiled with + * {@code @GrailsCompileStatic} against the GORM implementation under test, verifying that the + * dispatch produced by the {@code CriteriaTypeCheckingExtension} behaves identically to the + * dynamically compiled queries covered by {@code CriteriaBuilderSpec}. + */ +class StaticCompiledCriteriaSpec extends GrailsDataTckSpec { + + @Override + void setupSpec() { + manager.registerDomainClasses(TestEntity, ChildEntity) + } + + private static void seedPeople() { + def age = 40 + ['Bob', 'Fred', 'Barney', 'Frank'].each { + new TestEntity(name: it, age: age++, child: new ChildEntity(name: "$it Child")).save() + } + } + + void 'Test statically compiled list with a criteria closure on a chained terminal'() { + given: + seedPeople() + + when: + def results = StaticCompiledCriteriaQueries.listByNameLike('B%') + + then: + 2 == results.size() + results.every { it instanceof TestEntity } + } + + void 'Test statically compiled list with a dynamically dispatched maxResults call'() { + given: + seedPeople() + + when: + def results = StaticCompiledCriteriaQueries.listByNameLikeLimited('B%', 1) + + then: + 1 == results.size() + } + + void 'Test statically compiled paginated list with named arguments and a chained terminal'() { + given: + seedPeople() + + when: + def results = StaticCompiledCriteriaQueries.listPaginatedOrderedByAge(2, 1) + + then: + 2 == results.size() + 'Fred' == results[0].name + 'Barney' == results[1].name + } + + void 'Test statically compiled count terminal chained on createCriteria()'() { + given: + seedPeople() + + when: + def result = StaticCompiledCriteriaQueries.countByNameLike('B%') + + then: + 2 == result + } + + void 'Test statically compiled get with an equals criterion'() { + given: + seedPeople() + + when: + TestEntity result = StaticCompiledCriteriaQueries.getByName('Bob') + + then: + result != null + 'Bob' == result.name + 40 == result.age + } + + void 'Test statically compiled min projection'() { + given: + seedPeople() + + when: + def result = StaticCompiledCriteriaQueries.minAge() + + then: + 40 == result + } + + void 'Test statically compiled count distinct projection'() { + given: + seedPeople() + new TestEntity(name: 'Chuck', age: 43, child: new ChildEntity(name: 'Chuckie')).save() + + when: + def result = StaticCompiledCriteriaQueries.countDistinctAges() + + then: + 4 == result + } + + void 'Test statically compiled property projection inside withCriteria'() { + given: + seedPeople() + + when: + def results = StaticCompiledCriteriaQueries.namesMatching('B%') + + then: + ['Barney', 'Bob'] == results.sort() + } + + void 'Test statically compiled disjunction with ordering'() { + given: + seedPeople() + + when: + def results = StaticCompiledCriteriaQueries.listByNameLikeOrAge('B%', 41) + + then: + 3 == results.size() + 'Bob' == results[0].name + 'Fred' == results[1].name + 'Barney' == results[2].name + } +} diff --git a/grails-test-suite-uber/src/test/groovy/grails/compiler/GrailsCompileStaticCompilationErrorsSpec.groovy b/grails-test-suite-uber/src/test/groovy/grails/compiler/GrailsCompileStaticCompilationErrorsSpec.groovy index a866f9cf0eb..86b62ac5122 100644 --- a/grails-test-suite-uber/src/test/groovy/grails/compiler/GrailsCompileStaticCompilationErrorsSpec.groovy +++ b/grails-test-suite-uber/src/test/groovy/grails/compiler/GrailsCompileStaticCompilationErrorsSpec.groovy @@ -417,7 +417,52 @@ class GrailsCompileStaticCompilationErrorsSpec extends Specification { then: 'no errors are thrown' c } - + + void 'Test compiling a class which invokes a chained createCriteria() terminal on a domain class'() { + given: + def gcl = new GroovyClassLoader() + + when: 'the criteria builder closure is the argument to a terminal chained directly on createCriteria()' + def c = gcl.parseClass(''' + package grails.compiler + + @GrailsCompileStatic + class SomeClass { + + def someMethod() { + Person.createCriteria().list { + cache true + eq 'name', 'Anakin' + } + + Person.createCriteria().list(max: 4, offset: 2) { + cache true + eq 'name', 'Anakin' + } + + Person.createCriteria().listDistinct { + eq 'name', 'Anakin' + } + + Person.createCriteria().get { + eq 'name', 'Anakin' + } + + Person.createCriteria().count { + eq 'name', 'Anakin' + } + + Person.createCriteria().scroll { + eq 'name', 'Anakin' + } + } + } + '''.stripIndent()) + + then: 'no errors are thrown' + c + } + void 'Test compiling a domain class with a mapping block'() { given: def gcl = new GroovyClassLoader()