Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,14 @@ import groovy.transform.CompileStatic
*
*/
@AnnotationCollector
@CompileStatic(extensions=['org.grails.compiler.ValidateableTypeCheckingExtension',
'org.grails.compiler.NamedQueryTypeCheckingExtension',
'org.grails.compiler.HttpServletRequestTypeCheckingExtension',
'org.grails.compiler.WhereQueryTypeCheckingExtension',
'org.grails.compiler.DynamicFinderTypeCheckingExtension',
'org.grails.compiler.DomainMappingTypeCheckingExtension',
'org.grails.compiler.RelationshipManagementMethodTypeCheckingExtension'])
@CompileStatic(extensions = [
'org.grails.compiler.ControllerTagLibTypeCheckingExtension',
'org.grails.compiler.DomainMappingTypeCheckingExtension',
'org.grails.compiler.DynamicFinderTypeCheckingExtension',
'org.grails.compiler.HttpServletRequestTypeCheckingExtension',
'org.grails.compiler.NamedQueryTypeCheckingExtension',
'org.grails.compiler.RelationshipManagementMethodTypeCheckingExtension',
'org.grails.compiler.ValidateableTypeCheckingExtension',
'org.grails.compiler.WhereQueryTypeCheckingExtension',
])
@interface GrailsCompileStatic {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/*
* 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.compiler

import org.codehaus.groovy.ast.ClassNode
import org.codehaus.groovy.ast.expr.MethodCallExpression
import org.codehaus.groovy.ast.expr.PropertyExpression
import org.codehaus.groovy.ast.expr.VariableExpression
import org.codehaus.groovy.transform.stc.GroovyTypeCheckingExtensionSupport
import org.grails.core.artefact.ControllerArtefactHandler

/**
* A type-checking extension that allows {@code @GrailsCompileStatic} controllers
* to invoke tag library methods without compile-time errors.
*
* <p>Tag calls in controllers are dispatched at runtime through
* {@code TagLibraryInvoker#methodMissing} and
* {@code TagLibraryInvoker#propertyMissing}. These hooks are
* invisible to the static type checker, so this extension marks the affected
* expressions as dynamic, silencing the false-positive errors while preserving
* full type checking for all other code in the controller.
*
* <p>Controller detection mirrors {@code ControllerActionTransformer}: a class is
* treated as a controller when its qualified name ends with {@code "Controller"}.
*
* <p>Two calling patterns are supported:
* <ul>
* <li>Direct calls on {@code this}: {@code link(controller: 'home')},
* {@code message(code: 'key')}</li>
* <li>Namespaced calls via a namespace dispatcher property:
* {@code g.message(code: 'key')}, {@code my.customTag(attr: 'val')}</li>
* </ul>
*
* @since 7.0
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tiny thing - since this PR targets 8.0.0-SNAPSHOT and whatsNew.adoc describes the change under "introduced in Grails 8", @since 8.0 would maybe be more accurate here?

*/
class ControllerTagLibTypeCheckingExtension extends GroovyTypeCheckingExtensionSupport.TypeCheckingDSL {

@Override
Object run() {
beforeVisitClass { ClassNode classNode ->
newScope {
isController = classNode.name.endsWith(ControllerArtefactHandler.TYPE)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since detection is purely by name suffix, an inner class called something like FooController declared inside, say, a service would also receive the silencing treatment. Probably rare in practice - but might be worth a Javadoc note so the behavior is documented?

dynamicNamespaceProperties = [] as Set
}
}

afterVisitClass { ClassNode classNode ->
scopeExit()
}
Comment on lines +56 to +65
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor consistency note: the other class-scoped extensions in this package (ValidateableTypeCheckingExtension, DomainMappingTypeCheckingExtension, NamedQueryTypeCheckingExtension) pair beforeVisitClass / afterVisitClass with an outer setup { newScope() } / finish { scopeExit() } guard:

setup { newScope() }
finish { scopeExit() }

The currentScope? null-safe accesses below make this functionally safe without them, but could you maybe add the outer pair to keep this in lockstep with the rest of the package?


unresolvedVariable { VariableExpression ve ->
if (currentScope?.isController) {
currentScope.dynamicNamespaceProperties << ve
return makeDynamic(ve)
}
null
}
Comment on lines +67 to +73
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This part might be a bit broader than necessary - marking every unresolved variable in a controller as dynamic could end up silencing genuine typos. For example:

@GrailsCompileStatic
class BookController {
    BookService bookSvc
    def index() {
        bookSvce.list()  // typo - 'bookSvce' is unresolved
    }
}

Here bookSvce would be silently made dynamic (and added to dynamicNamespaceProperties, so the subsequent .list() call is also silenced) instead of producing the compile error @GrailsCompileStatic users probably expect.

Could it maybe be worth narrowing this? One option would be to defer the dynamic mark until the variable is actually used as the receiver of a method call (i.e. only silence the namespace-dispatcher access pattern <ident>.<method>(...)), so standalone typos still surface. Just a thought - happy to be wrong if there's a reason you've gone broader.


unresolvedProperty { PropertyExpression pe ->
if (currentScope?.isController && isThisReceiver(pe)) {
currentScope.dynamicNamespaceProperties << pe
return makeDynamic(pe)
}
null
}
Comment on lines +75 to +81
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just flagging a subtle case here: this hook silences def t = link at compile time, but at runtime TagLibraryInvoker.propertyMissing only returns a NamespacedTagDispatcher for namespace names - it doesn't return a Closure for tag-property access the way TagLibrary.propertyMissing does inside a tag library itself. So a controller with def t = link would compile cleanly but throw MissingPropertyException at runtime.

Probably fine as a known limitation, but maybe worth a Javadoc note here (or a line in the user docs) so the boundary is explicit?


methodNotFound { receiver, name, argList, argTypes, call ->
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you maybe add parameter types here for consistency with the other extensions in this package? They all use the fully typed form:

methodNotFound { ClassNode receiver, String name, ArgumentListExpression argList, ClassNode[] argTypes, MethodCall call ->

if (!currentScope?.isController) return null
if (isThisReceiver(call)) return makeDynamic(call)
if (call instanceof MethodCallExpression && call.objectExpression in currentScope.dynamicNamespaceProperties) return makeDynamic(call)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cosmetic only - line 84 uses currentScope?.isController but this line dereferences currentScope.dynamicNamespaceProperties directly. They're functionally equivalent given the guard above, but currentScope?.dynamicNamespaceProperties here might read a touch more uniformly?

null
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The other extensions in this package all end run() with an explicit null. Could you maybe add one here for consistency? Just a style nit.


private boolean isThisReceiver(expr) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you maybe type this parameter (perhaps Expression expr)? Helps line up with the rest of the codebase's static-typing lean.

if (!(expr instanceof MethodCallExpression || expr instanceof PropertyExpression)) return false
expr.implicitThis || (expr.objectExpression instanceof VariableExpression && expr.objectExpression.thisExpression)
}
}
25 changes: 25 additions & 0 deletions grails-doc/src/en/guide/introduction/whatsNew.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,28 @@ The Grails Gradle extension now defaults `preserveParameterNames` to `true`, so

Tag library unit tests also clean up and rebuild TagLib metadata automatically between features.
Tests that use `TagLibUnitTest` no longer need to manage `purgeTagLibMetaClass`, and specs that mock additional tag libraries continue to work across feature methods.

==== @GrailsCompileStatic on Controllers That Use Tag Libraries

Controllers annotated with `@GrailsCompileStatic` can now invoke tag library methods without compile-time errors.

Both calling patterns are supported out of the box:

[source,groovy]
----
import grails.compiler.GrailsCompileStatic

@GrailsCompileStatic
class BookController {

def index() {
// Direct call in the default namespace
response.writer << link(controller: 'book', action: 'list')

// Namespaced call via a dispatcher property
response.writer << my.customTag(attr: 'value')
}
}
----

The new `ControllerTagLibTypeCheckingExtension` (bundled with `@GrailsCompileStatic`) recognises controller classes by convention and marks tag dispatch points as permissible dynamic calls, while leaving the rest of the controller fully type-checked.
Original file line number Diff line number Diff line change
Expand Up @@ -103,4 +103,32 @@ class SomeClass {

Code that is marked with `GrailsCompileStatic` will all be statically compiled except for Grails specific interactions that cannot be statically compiled but that `GrailsCompileStatic` can identify as permissible for dynamic dispatch. These include things like invoking dynamic finders and DSL code in configuration blocks like constraints and mapping closures in domain classes.

Care must be taken when deciding to statically compile code. There are benefits associated with static compilation but in order to take advantage of those benefits you are giving up the power and flexibility of dynamic dispatch. For example if code is statically compiled it cannot take advantage of runtime metaprogramming enhancements which may be provided by plugins.
Care must be taken when deciding to statically compile code. There are benefits associated with static compilation but in order to take advantage of those benefits you are giving up the power and flexibility of dynamic dispatch. For example if code is statically compiled it cannot take advantage of runtime metaprogramming enhancements which may be provided by plugins.

===== Tag Library Calls in Controllers

Controllers annotated with `@GrailsCompileStatic` can invoke tag library methods without compile errors.
Tag dispatch is handled at runtime through `TagLibraryInvoker`, and `@GrailsCompileStatic` includes a built-in type-checking extension that recognises these call sites and allows them to compile.

Both calling patterns work:

[source,groovy]
----
import grails.compiler.GrailsCompileStatic

@GrailsCompileStatic
class BookController {

def index() {
// Direct call — tag in the default namespace invoked on `this`
response.writer << link(controller: 'book', action: 'list')

// Namespaced call — namespace dispatcher property, then tag method
response.writer << g.message(code: 'book.list.title')
}
}
----

Only controller classes (those whose name ends with `Controller`) receive this treatment.
All other code in the controller remains fully type-checked.
If you need to opt a single method out of static compilation, use `@GrailsCompileStatic(TypeCheckingMode.SKIP)` on that method.
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/*
* 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.web.taglib

import grails.artefact.Artefact
import grails.compiler.GrailsCompileStatic
import grails.testing.web.controllers.ControllerUnitTest
import spock.lang.Specification

class ControllerCompileStaticTagLibSpec extends Specification implements ControllerUnitTest<CompileStaticTagController> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could it maybe be worth adding a negative test alongside these positive ones? Something pinning down the boundary - e.g. a spec asserting that a genuinely undefined reference inside an @GrailsCompileStatic controller still fails to compile - would help guard against the extension drifting to silence more than intended in future changes.

Not a blocker, just a thought.

void setup() {
mockTagLibs(CompileStaticDefaultTagLib, CompileStaticNamespacedTagLib)
}

void "controller with @GrailsCompileStatic can call a default-namespace tag directly"() {
when:
controller.useDefaultNamespaceTag()

then:
response.contentAsString == 'hello! World'
}

void "controller with @GrailsCompileStatic can call a tag via namespace dispatcher property"() {
when:
controller.useNamespacedTag()

then:
response.contentAsString == 'hello! World'
}
}

@Artefact('Controller')
@GrailsCompileStatic
class CompileStaticTagController {

def useDefaultNamespaceTag() {
// tag in default namespace invoked directly on this; dispatched at runtime
// through TagLibraryInvoker.methodMissing
response.writer << greet(name: 'World')
}

def useNamespacedTag() {
// namespace dispatcher property resolved at runtime through
// TagLibraryInvoker.propertyMissing, tag invoked on the resulting dispatcher
response.writer << cst.greet(name: 'World')
}
}

@Artefact('TagLib')
class CompileStaticDefaultTagLib {
Closure greet = { attrs, body ->
out << "hello! ${attrs.name}"
}
}

@Artefact('TagLib')
class CompileStaticNamespacedTagLib {
static namespace = 'cst'

Closure greet = { attrs, body ->
out << "hello! ${attrs.name}"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/*
* 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 demo

import grails.compiler.GrailsCompileStatic

/**
* Demonstrates that a controller annotated with {@code @GrailsCompileStatic} can
* invoke tag library methods — both in the default namespace (direct call) and via
* a namespace dispatcher property — without compile errors.
*/
@GrailsCompileStatic
class CompileStaticController {

def invokeDefaultNamespaceTag() {
// link() is a core tag in the default 'g' namespace; invoked directly on this
response.writer << link(controller: 'demo', action: 'clearDatabase')
}

def invokeNamespacedTag() {
// one.sayHello() accesses the 'one' namespace dispatcher, then invokes the tag
response.writer << one.sayHello()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/*
* 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 demo

import grails.testing.mixin.integration.Integration
import org.apache.grails.testing.http.client.HttpClientSupport
import spock.lang.Specification
import spock.lang.Tag

@Integration
@Tag('http-client')
class CompileStaticControllerSpec extends Specification implements HttpClientSupport {

void 'controller with @GrailsCompileStatic can call a default-namespace tag directly'() {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tiny convenience nit: this integration spec and the unit spec at src/test/groovy/demo/CompileStaticControllerSpec.groovy share the same fully qualified name demo.CompileStaticControllerSpec. Different source sets so the build is fine, but --tests "demo.CompileStaticControllerSpec" becomes ambiguous when running them individually.

Could you maybe rename one to something like CompileStaticControllerIntegrationSpec (or CompileStaticControllerHttpSpec)?

when:
def response = http('/compileStatic/invokeDefaultNamespaceTag')

then:
response.assertContains('<a href="/demo/clearDatabase"></a>')
}

void 'controller with @GrailsCompileStatic can call a tag via namespace dispatcher property'() {
when:
def response = http('/compileStatic/invokeNamespacedTag')

then:
response.assertEquals('BEFORE Hello From SecondTagLib AFTER')
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/*
* 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 demo

import grails.testing.web.controllers.ControllerUnitTest
import spock.lang.Specification

class CompileStaticControllerSpec extends Specification implements ControllerUnitTest<CompileStaticController> {

void setup() {
mockTagLibs FirstTagLib, SecondTagLib
}

void 'controller with @GrailsCompileStatic can call a default-namespace tag directly'() {
when:
controller.invokeDefaultNamespaceTag()

then:
response.text == '<a href="/demo/clearDatabase"></a>'
}

void 'controller with @GrailsCompileStatic can call a tag via namespace dispatcher property'() {
when:
controller.invokeNamespacedTag()

then:
response.text == 'BEFORE Hello From SecondTagLib AFTER'
}
}
Loading