From c1410a5ffd23e9bf945280785681ab219eb65a03 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Fri, 5 Jun 2026 11:42:00 -0700 Subject: [PATCH 1/3] Register CriteriaTypeCheckingExtension and cover createCriteria().{} closures CriteriaTypeCheckingExtension existed but was never added to the @GrailsCompileStatic extension list, and isCriteriaCall only recognized the Domain.withCriteria {} / Domain.createCriteria() forms where the closure is the direct argument. The chained Domain.createCriteria(). { } form (closure as the argument to a terminal called on the builder) was not matched, so its scope was never opened. Most chained terminals (list/listDistinct/get/scroll) still type-checked because BuildableCriteria declares them with a Closure parameter and @DelegatesTo resolves the closure body. count(Closure) is not declared on BuildableCriteria, so Domain.createCriteria().count {} failed static compilation with 'Cannot find matching method BuildableCriteria#count(Closure)'. Extend isCriteriaCall to also open a criteria scope for a terminal (list/listDistinct/get/count/scroll) chained directly on createCriteria(), and register the extension in @GrailsCompileStatic so criteria-builder closures type-check under static compilation. Adds a test covering all six chained terminals; count is the case that fails without this change. git log --oneline -1 --- .../compiler/GrailsCompileStatic.groovy | 1 + .../CriteriaTypeCheckingExtension.groovy | 26 ++++++++-- ...sCompileStaticCompilationErrorsSpec.groovy | 47 ++++++++++++++++++- 3 files changed, 69 insertions(+), 5 deletions(-) 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-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() From b10f36fae917292f2416cd791e5acd16d42f7bd1 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Thu, 11 Jun 2026 14:19:13 -0700 Subject: [PATCH 2/3] Execute @GrailsCompileStatic criteria queries in the TCK Registering CriteriaTypeCheckingExtension changes the default compile semantics of every @GrailsCompileStatic class, so the TCK now runs criteria queries that were compiled statically against each GORM implementation. The queries mix calls that resolve statically against the Criteria API (eq, like, or, order) with calls that only compile through the extension's dynamic-dispatch fallback (projections, min, countDistinct, property, maxResults and the count terminal chained on createCriteria(), which is not declared on BuildableCriteria), proving that dispatch behaves identically to dynamic Groovy on the in-memory, Hibernate 5, Hibernate 7 and MongoDB implementations. --- .../StaticCompiledCriteriaQueries.groovy | 102 ++++++++++++ .../tests/StaticCompiledCriteriaSpec.groovy | 153 ++++++++++++++++++ 2 files changed, 255 insertions(+) create mode 100644 grails-datamapping-tck/src/main/groovy/org/apache/grails/data/testing/tck/support/StaticCompiledCriteriaQueries.groovy create mode 100644 grails-datamapping-tck/src/main/groovy/org/apache/grails/data/testing/tck/tests/StaticCompiledCriteriaSpec.groovy 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 + } +} From 34854cbfc114166299c610f3f8136bac0dd1ad60 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Tue, 16 Jun 2026 20:32:40 +0000 Subject: [PATCH 3/3] Run StaticCompiledCriteriaSpec in the Hibernate 7 and Hibernate 5 TCK suites The spec was added to grails-datamapping-tck but was not selected by any @SelectClasses suite, so it never executed against a GORM implementation. Wire it into HibernateSuite and Hibernate5Suite so the criteria queries compiled with @GrailsCompileStatic (exercising CriteriaTypeCheckingExtension) run and assert results against both Hibernate 7 and Hibernate 5. --- .../src/test/groovy/grails/gorm/tests/Hibernate5Suite.groovy | 3 ++- .../src/test/groovy/grails/gorm/tests/HibernateSuite.groovy | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/grails-data-hibernate5/core/src/test/groovy/grails/gorm/tests/Hibernate5Suite.groovy b/grails-data-hibernate5/core/src/test/groovy/grails/gorm/tests/Hibernate5Suite.groovy index 84bf36e2585..7507d70623d 100644 --- a/grails-data-hibernate5/core/src/test/groovy/grails/gorm/tests/Hibernate5Suite.groovy +++ b/grails-data-hibernate5/core/src/test/groovy/grails/gorm/tests/Hibernate5Suite.groovy @@ -19,6 +19,7 @@ package grails.gorm.tests import org.apache.grails.data.testing.tck.tests.FirstAndLastMethodSpec +import org.apache.grails.data.testing.tck.tests.StaticCompiledCriteriaSpec import org.junit.platform.suite.api.SelectClasses import org.junit.platform.suite.api.Suite @@ -26,6 +27,6 @@ import org.junit.platform.suite.api.Suite * Created by graemerocher on 06/07/2016. */ @Suite -@SelectClasses([FirstAndLastMethodSpec]) +@SelectClasses([FirstAndLastMethodSpec, StaticCompiledCriteriaSpec]) class Hibernate5Suite { } diff --git a/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/HibernateSuite.groovy b/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/HibernateSuite.groovy index 32f8cec6285..cff9732b693 100644 --- a/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/HibernateSuite.groovy +++ b/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/HibernateSuite.groovy @@ -19,6 +19,7 @@ package grails.gorm.tests import org.apache.grails.data.testing.tck.tests.FirstAndLastMethodSpec +import org.apache.grails.data.testing.tck.tests.StaticCompiledCriteriaSpec import org.junit.platform.suite.api.SelectClasses import org.junit.platform.suite.api.Suite @@ -26,6 +27,6 @@ import org.junit.platform.suite.api.Suite * Created by graemerocher on 06/07/2016. */ @Suite -@SelectClasses([FirstAndLastMethodSpec]) +@SelectClasses([FirstAndLastMethodSpec, StaticCompiledCriteriaSpec]) class HibernateSuite { }