Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
Comment thread
jdaugherty marked this conversation as resolved.
Outdated
*/
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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Updated the javadoc. This issue exists across the Grails code base though, so we may want to consider annotating controllers so the compiler could then reliably detect a controller instead of being name driven. This probably worth a ticket.

dynamicNamespaceProperties = [] as Set
}
}

afterVisitClass { ClassNode classNode ->
scopeExit()
}
Comment thread
jdaugherty marked this conversation as resolved.

unresolvedVariable { VariableExpression ve ->
if (currentScope?.isController) {
currentScope.dynamicNamespaceProperties << ve
return makeDynamic(ve)
}
null
}
Comment on lines +91 to +97

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I'm still investigating this one.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I tested removing dynamicNamespaceProperties entirely: the tests fail because makeDynamic(ve) alone is insufficient; methodNotFound does fire on dynamic receivers and must
explicitly make the call dynamic. So the tracking is structurally required for namespace dispatch to work.

The only genuine fixes are:

  1. Whitelist known namespace names — scan all TagLib classes at compile time, collect their static namespace values, and only make variables dynamic if the name matches a
    known namespace. This is architecturally sound but requires significant infrastructure.
  2. Accept the limitation — document it (done in the Javadoc "Scope note") and rely on the fact that bookSvce.list() fails immediately at runtime with
    MissingPropertyException.

The negative tests I added do verify a meaningful guarantee: method calls on declared types still fail — bookService.nonExistentMethod() (where bookService is a declared
field) produces a compile error. The silencing only affects undeclared identifiers.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The only other solution would be to somehow notify the compiler of namespaces on the classpath, which would require saving the known namespaces and accessing them in the compiler. This is probably possible, but out of scope of this change. Ithink this is a net benefit, but we probably should ticket this as an enhancement


unresolvedProperty { PropertyExpression pe ->
if (currentScope?.isController && isThisReceiver(pe)) {
currentScope.dynamicNamespaceProperties << pe
return makeDynamic(pe)
}
null
}
Comment thread
jdaugherty marked this conversation as resolved.

methodNotFound { receiver, name, argList, argTypes, call ->
Comment thread
jdaugherty marked this conversation as resolved.
Outdated
if (!currentScope?.isController) return null
if (isThisReceiver(call)) return makeDynamic(call)
if (call instanceof MethodCallExpression && call.objectExpression in currentScope.dynamicNamespaceProperties) return makeDynamic(call)
Comment thread
jdaugherty marked this conversation as resolved.
null
}
}
Comment thread
jdaugherty marked this conversation as resolved.

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> {

Comment thread
jdaugherty marked this conversation as resolved.
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'() {
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