Groovy 5.0.x support for Grails 8 + Spring Boot 4#15557
Conversation
This reverts commit 457d6cd.
# Conflicts: # build.gradle # dependencies.gradle # grails-forge/build.gradle # grails-gradle/build.gradle
# Conflicts: # buildSrc/build.gradle # dependencies.gradle # grails-bootstrap/src/main/groovy/org/grails/config/NavigableMap.groovy # grails-gradle/buildSrc/build.gradle
# Conflicts: # dependencies.gradle # gradle/test-config.gradle # grails-forge/settings.gradle # settings.gradle
# Conflicts: # gradle.properties # grails-core/src/test/groovy/org/grails/plugins/BinaryPluginSpec.groovy
… + latest Jackson)
Cherry-picked comprehensive Groovy 5 compat from 9574fe8. Conflict resolutions: - dependencies.gradle: Groovy 5.0.5 GA (not SNAPSHOT) + Jackson 2.21.2 - LoggingTransformer: Keep manual log field injection (avoids Groovy 5 VariableScopeVisitor NPE entirely) - TransactionalTransformSpec: Remove direct Spock feature method invocation (Groovy 5/Spock 2.x incompatible) - grails-test-core/build.gradle: Remove spock-core transitive=false, keep junit-platform-suite - grails-test-suite-uber/build.gradle: Remove spock-core transitive=false and explicit byte-buddy
| timeZone(nullable: true) | ||
| } | ||
|
|
||
| // Manual Comparable implementation (replaces @Sortable which conflicts with @Entity in Groovy 5) |
There was a problem hiding this comment.
Why are Sortable & Entity conflicting in Groovy 5? This seems like a groovy bug or we need to document this.
There was a problem hiding this comment.
Under Groovy 5 the @Sortable AST transform and GORM's @Entity transform clash on the same class - @Sortable generates compareTo/comparators over the persistent properties while @Entity is also rewriting the class, and the combination fails to compile under Groovy 5's stricter checking. Since these are just test fixtures, I replaced @Sortable(includes=[...]) with an equivalent hand-written compareTo/Comparable. It is a test-only workaround for the transform interaction; I can open a Groovy issue with a standalone reproducer if you want it tracked upstream, and it may be worth an upgrade-guide note if it affects user @Entity + @Sortable classes generally.
jdaugherty
left a comment
There was a problem hiding this comment.
This PR could be significantly cleaned up if we revert the transitive change & the binding changes. It would make this a lot easier to review. I also think far more integration tests are excluded than should be. I understand it may be broken when indy=false, but this seems like a major blocker to accepting this PR.
|
|
||
| if (view != null) { | ||
| Map<String, ?> model | ||
| Map<String, Object> model |
There was a problem hiding this comment.
Why isn't ? allowed for Object? @paulk-asert is this an intentional generics change in Groovy?
There was a problem hiding this comment.
Groovy 5's STC got stricter about assigning to a wildcard-typed local: Map<String, ?> model followed by model = map (a raw/Map value) no longer type-checks, so the variable is declared Map<String, Object>. It is purely a local type-declaration change with no runtime effect. @paulk-asert is the tighter handling of ?-typed assignment targets intended in Groovy 5?
|
I found a functional workaround for: Groovy 5 + indy=false: a parameterized controller action throws MissingPropertyException at runtime only in a live application working through that now and then will work through all these comments. |
…meterized controller actions
Under indy=false, ControllerActionTransformer's generated zero-arg dispatch
wrapper binds request parameters to locals and delegates via this.action(p1, p2).
Groovy 5's OptimizingStatementWriter compiles that call as a __$stMC fast/slow
pair; the slow path - taken whenever the controller metaclass is non-standard,
i.e. in any live application - re-resolves the bound locals as dynamic property
reads (this.getProperty("p1")), throwing MissingPropertyException at runtime even
though the compiled fast path is correct.
Tag controllers that declare parameterized actions with
OptimizingStatementWriter.ClassNodeSkip so the fast/slow fork is suppressed and a
single correct path is emitted. This replaces the previous workaround, which
disabled integrationTest for five grails-test-examples modules; that
gradle/boot4-disabled-integration-test-config.gradle file is removed and those
integration tests run again (verified under -PgrailsIndy=false and -PgrailsIndy=true).
This is a workaround for an upstream Groovy defect, not a fix of it; remove once
OptimizingStatementWriter is fixed upstream.
Reproducer: https://github.com/jamesfredley/groovy5-controller-action-param-scope-bug
Assisted-by: claude-code:claude-4.8-opus
Controller action parameter scope under
|
| # | Approach | Result |
|---|---|---|
| T1 | Reuse the original method's BlockStatement as the try-body when exception-wrapping (avoid re-parenting a fresh block) |
Fails - re-parenting was never the cause |
| T11 | Don't exception-wrap the parameterized original; only wrap the generated zero-arg dispatch wrapper | Fails - the defect is in the wrapper's delegating call, not the original method |
| T12 | Drop setMethodTarget(...) on the this.action(...) delegating call |
Fails - the __$stMC fork is still emitted and the slow path still uses getProperty |
| T13 | Re-run VariableScopeVisitor (processVariableScopes) on the generated wrapper |
Fails - classic codegen resolves variables via CompileStack, not the AST scope |
| T14 | Tag the controller ClassNode with OptimizingStatementWriter.ClassNodeSkip to suppress the fast/slow fork |
Works - a single correct path is emitted; all 5 re-enabled grails-test-examples modules pass integrationTest under both indy=false and indy=true, and the :grails-controllers:test unit suite passes |
Identified but not tried
| Approach | Why not |
|---|---|
Set accessedVariable on the delegating-call argument VariableExpressions |
Classic AsmClassGenerator ignores accessedVariable (it resolves by name via CompileStack), so it cannot help - the same reason T13 fails |
Per-statement opt-out via OptimizingStatementWriter.StatementMeta(optimize=false) |
Not a usable API surface: StatementMeta.optimize is private and OptVisitor.addMeta forces it back to true |
| Inline the original action body into the wrapper instead of delegating | Higher risk - returns, exception wrapping, parameter scope, and command-object behaviour would all become the transformer's responsibility |
@CompileStatic the generated wrapper |
The request-binding logic (params.int(...), etc.) is not statically type-checkable |
Force the fast path globally (disabledStandardMetaClass / set __$stMC) |
Global side effects; not a per-controller, production-safe option |
Chosen: T14. It is the minimal, public, per-controller switch - ClassNodeSkip is a documented Groovy API and OptimizingStatementWriter.setNodeMeta(...) returns early when it is present - and it is a workaround for the upstream Groovy defect rather than a fix of it, to be removed once OptimizingStatementWriter is fixed upstream. Standalone reproducer: https://github.com/jamesfredley/groovy5-controller-action-param-scope-bug
|
Do we have a groovy ticket on the indy=false issue? Is this not likely to be fixed? Have you examined a before/after of the byte code to ensure what your AI is saying is actually what's happening? |
GORM's ConfigurationBuilder binds a Class-typed setting (such as hibernate.configClass) by delegating to the property resolver's ConversionService, which relies on a String->Class converter that resolves the name with the framework class loader (DatastoreUtils and HibernateGrailsPlugin use getClass().getClassLoader()). That class loader cannot see an application-defined class such as functional.tests.CustomHibernateMappingContextConfiguration, so the value was silently left unbound; under Spring 7 the deep MapToMapConverter can also throw ConverterNotFoundException for the Class<?> wildcard type. Resolve Class-typed settings directly in ConfigurationBuilder via ClassUtils.forName(name, Thread.currentThread().contextClassLoader), independent of any registered converter and using the application class loader. This matches the canonical GORM approach (grails-testing-support 5539ec0) and re-enables the previously disabled hibernate.configClass assertion in the grails-hibernate test-example BootStrap. Verified by two new specs in HibernateConnectionSourceSettingsSpec, including one that builds the settings from a plain StandardEnvironment with no String->Class converter registered. Assisted-by: claude-code:claude-4.8-opus
The Groovy 5 migration changed the render() guards from `if (template && destination)` to `if (template != null && destination != null)`, which dropped Groovy-truth semantics on the template argument: an empty CharSequence or empty Map value should be falsy and skip rendering. Restore Groovy truth on the template operand for the render(Map) and render(CharSequence, File, ...) overloads in both grails-core and grails-shell-cli, while keeping `destination != null` (the destination File is being created, so it must not be gated on existence). The render(File) and render(Resource) overloads are unchanged, as `!= null` is correct for those non-CharSequence types. Assisted-by: claude-code:claude-4.8-opus
Commit fb725b1 fixed the Groovy 5 indy=false controller-action parameter-scope bug at the framework level via OptimizingStatementWriter.ClassNodeSkip in ControllerActionTransformer. The manual rewrites that converted parameterized actions (def index(Integer max)) to zero-arg form using params.int(...) / params.long(...) are therefore redundant and out of scope for this change. Revert those rewrites to the original parameterized-action form across the grails-test-examples controllers and grails.rest.RestfulController, restoring the documented action style (including RestfulController.index's negative-max guard). Assisted-by: claude-code:claude-4.8-opus
The Micronaut platform ships org.ow2.asm 9.10.1, above the 9.9.1 inherited via grails-base-bom, so five micronaut test-example modules used ext.allowedBomOverrides to exempt the mismatch from the dependency-validator. That escape hatch defeats the validator's purpose. Pin org.ow2.asm:asm and asm-util at 9.10.1 in the micronaut BOM (customBomVersions/customBomDependencies) so the resolved version equals the BOM-managed version, and remove the per-app allowedBomOverrides from all five modules. Also drop the redundant micronaut-specific groovy.version override; it now tracks the main bom (still 5.0.7-SNAPSHOT via combinedVersions). validateDependencyVersions passes for all five micronaut modules. See #15677 / #15467 for the upstream Micronaut platform / Gradle platform work that resolves the asm conflict longer term. Assisted-by: claude-code:claude-4.8-opus
The spec "Test resolve paths for local and request version" was disabled with @IgnoreIf on Groovy 5 because it stubbed the final method GrailsWebRequest.getRequest(), which Spock cannot mock without extra configuration. The resolver under test (SmartViewResolver.resolveView and buildQualifiers) uses the request and response passed in directly and never calls webRequest.getRequest()/getResponse(), so those stubs were superfluous. Drop the @IgnoreIf, the now-unused isGroovy5OrLater() helper and IgnoreIf import, and the two superfluous final-method stubs. No mocking library is needed; all specs in the file pass on Groovy 5. Assisted-by: claude-code:claude-4.8-opus
testAssociationsBinding installs a global metaclass via GroovySpy(Author, global: true). Add an explicit cleanup: block that calls GroovySystem.metaClassRegistry.removeMetaClass(Author) so the spy is torn down even if the feature method fails, keeping the metaclass registry clean for tests that share the fork. Assisted-by: claude-code:claude-4.8-opus
Address review feedback on workaround comments without changing behavior: - AbstractConstraint: explain why ConstrainedProperty.DEFAULT_MESSAGES is null under Groovy 5 (interface field initialization order) and why MESSAGE_BUNDLE is the fallback. - Validateable / BeanPropertyAccessorFactory: explain the reflective defaultNullable() resolution and reference GROOVY-11985 (the upstream TraitReceiverTransformer regression). - ResourceTransform: reword the setVariableScope comment as the API requirement it is, not a Groovy-5-specific workaround. - ServiceTransformSpec: drop the chatty inline note. - grails-gradle docs-config.gradle / tasks/build.gradle: simplify the gradle-embedded Groovy comments. Assisted-by: claude-code:claude-4.8-opus
Add an "Apache Groovy 5 Behavior Changes" section to the Grails 8 upgrade guide covering two stricter behaviours that can affect application code: - Assigning a static field from inside a static closure (constraints/mapping/etc.) under static compilation now requires qualifying the field with the class name. - Indexing a ConfigObject with the subscript operator for a missing key now inserts an empty nested ConfigObject; probe with containsKey/get instead. Assisted-by: claude-code:claude-4.8-opus
Review feedback addressed - 8 commits pushed (
|
Controller-action parameter scope under
|
Forward-merge the 8.0.x base into the Groovy 5 / Spring Boot 4 branch: code-analysis + jacoco convention plugins, SiteMesh 3 enablement, GORM TCK domain registration, bindData id/version dirty-check fix, and the release-drafter overhaul. Conflict: dependencies.gradle - spock.version kept at 2.4-groovy-5.0; this branch targets Groovy 5 and needs the Groovy-5 Spock variant (2.3-groovy-4.0 would break Spock test compilation). - starter-sitemesh.version / spring-webmvc-sitemesh.version adopted from 8.0.x at 3.3.0-SNAPSHOT, the Spring Boot 4 SiteMesh upgrade from #15710, superseding the branch's pre-merge 3.2.3. Assisted-by: claude-code:claude-4.8-opus
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## 8.0.x #15557 +/- ##
===================================================
- Coverage 48.3483% 18.2620% -30.0864%
+ Complexity 15104 258 -14846
===================================================
Files 1870 54 -1816
Lines 85459 3176 -82283
Branches 14901 527 -14374
===================================================
- Hits 41318 580 -40738
+ Misses 37805 2486 -35319
+ Partials 6336 110 -6226 🚀 New features to boost your workflow:
|
Replace the anonymous-HashMap-with-instance-initializer for ConstrainedProperty.DEFAULT_MESSAGES with a Groovy map literal. On Groovy 5 (GROOVY-12063) the bareword references to the sibling DEFAULT_* constants inside such an initializer compile to dynamic getProperty calls on `this` (the HashMap); because the receiver is a Map, the MOP resolves them as key lookups on the still-empty map, so every entry was captured as null. A map literal resolves the constants against the enclosing interface scope and is correct on every Groovy version. This fixes DEFAULT_MESSAGES for all consumers, so the AbstractConstraint MESSAGE_BUNDLE fallback is reverted to the base getDefaultMessage form. Also add the previously-missing default.not.unique.message entry so the map covers every key in DefaultErrorMessages.properties, and make DefaultMessageResolutionSpec iterate the whole bundle as a complete regression guard. Correct the now-disproven root-cause comments: the AbstractConstraint and DefaultMessageResolutionSpec "interface field-initializer order" explanation was wrong (the interface clinit field-init order is actually correct); the real cause is the Map-MOP getProperty dispatch above. Reference GROOVY-12063. Add the GROOVY-12062 id and the non-empty-finally trigger detail to the ControllerActionTransformer ClassNodeSkip comment. Assisted-by: claude-code:claude-4.8-opus
Upstream Groovy bugs filed + root-cause records correctedThe two previously-unfiled Groovy 5 defects in this PR now have tickets, each with a self-contained pure-Groovy reproducer (no Grails dependency), verified as a regression across 4.0.x -> 5.0.5 / 5.0.6 / 5.0.7-SNAPSHOT / 6.0.0-alpha-1 on JDK 21:
Records corrected (commit
|
Forward-merge the 8.0.x base (hibernate7 staging module #15654 plus the pull-forward commits) into the Groovy 5 / Spring Boot 4 branch. Conflicts (4): - .github/workflows/groovy-joint-workflow.yml: kept the branch's --init-script groovy-joint-build.init.gradle hook on the pTML step. - build-logic/plugins/.../SbomPlugin.groovy: kept the branch's superset of CycloneDX license-exception mappings (antlr4-runtime, the org.jline:*@3.30.9 family, liquibase-hibernate5) alongside 8.0.x's jzlib entry. - grails-test-suite-uber/.../DomainClassWithInnerClassUsingStaticCompilationSpec.groovy: took 8.0.x's removal of the namedQueries closure (deprecated feature removed on 8.0.x; the corresponding field and test method were already dropped by the auto-merge, so the closure was orphaned). - grails-data-hibernate7/core/.../HibernateEventListeners.java: accepted 8.0.x's hibernate7 staging file (false rename/delete pairing with a deleted logback.groovy). Assisted-by: claude-code:claude-4.8-opus
The grails-hibernate7-micronaut-bom block in dependencies.gradle had drifted from the grails-micronaut-bom / grails-hibernate5-micronaut-bom block: it still hard-coded groovy.version 5.0.5 and lacked the org.ow2.asm pin those blocks gained in df4750c. Because these BOM entries are declared strictly, the stale groovy 5.0.5 made :grails-test-examples-micronaut-hibernate7:runtimeClasspath unresolvable against the build's 5.0.7-SNAPSHOT, breaking the build, the functional/hibernate/mongo test jobs and the CodeQL analyze job. The missing asm pin separately failed validateDependencyVersions (resolved 9.10.1, expected 9.9.1). Drop the redundant groovy.version override so it inherits 5.0.7-SNAPSHOT via combinedVersions, and pin org.ow2.asm:asm / asm-util to 9.10.1, matching the sibling micronaut BOM block. Assisted-by: claude-code:claude-4.8-opus
The colon-alignment whitespace in the DEFAULT_MESSAGES map literal violated the CodeNarc SpaceAroundMapEntryColon rule (no whitespace is allowed before a map-entry colon), failing :grails-datamapping-validation:codenarcMain. Reformat to a single space after each colon and none before. Keys and values are unchanged. Assisted-by: claude-code:claude-4.8-opus
Removes workaround for Groovy 5 OptimizingStatementWriter slow-path bug for parameterized controller actions. Groovy issue https://issues.apache.org/jira/browse/GROOVY-12062 fixed by apache/groovy#2590 upstream.
✅ All tests passed ✅Test Summary
🏷️ Commit: d3506cf Learn more about TestLens at testlens.app. |
|
GROOVY-12062, Controller-action parameter scope under indy=false, now fixed at the source, workaround removed. |
Adds Apache Groovy 5.0.x support on top of
8.0.x, tracking5.0.7-SNAPSHOTfromGROOVY_5_0_X. Verified on JDK 21 under bothindy=trueand-PgrailsIndy=false.Most of the diff is adapting to Groovy 5's stricter
@CompileStatictype checking and a few API/behaviour changes (MetaClassHelper.capitalizeremoval,Filetruthiness, etc.). The two areas worth a closer review are below.Target stack
GROOVY_5_0_X)AST-level fixes
VariableScopeVisitorNPE. Groovy 5'sVariableScopeVisitor.visitConstructorOrMethodreads the method exceptions array without a null check, so it NPEs on the null-exceptions methods Grails AST transforms create viaClassNode.addMethod(..., null, ...). Normalised with a proxyMethodNodeat theprocessVariableScopeschokepoints (GrailsASTUtils,AstUtils,AbstractMethodDecoratingTransformation). Reproducer: groovy5-variablescope-canonicalization-bug.setVariableScope(new VariableScope())inResourceTransform/AbstractMethodDecoratingTransformationfixes a synthesised-ClosureExpressionnull-scopeClosureWriterNPE. This is an all-version fix (it also reproduces on Groovy 4.0.31), not Groovy-5-specific.Workarounds for open upstream Groovy bugs
Each works around an unfixed Groovy 5 defect (verified still failing on
5.0.7-SNAPSHOT) and can be removed once the upstream fix lands inGROOVY_5_0_X.ControllerActionTransformertags controllers that declare parameterized actions withOptimizingStatementWriter.ClassNodeSkipindy=false, a parameterized action throwsMissingPropertyExceptionin a live app. The generated zero-arg dispatch wrapper binds the request params to locals, delegates viathis.action(p1, p2), and wraps the body in atry/catch/finally.OptimizingStatementWriteremits that call twice behind a__$stMCguard: a fast path that loads the locals correctly and a slow path that re-resolves them as dynamic property reads (getProperty). The non-emptyfinallytriggers aCompileStackscope restore that drops the locals between the two emissions, so the slow path'sgetVariablelookup misses and falls back togetProperty. The slow path is taken whenever the controller metaclass is non-standard - i.e. in any real app (Spring Security, plugins, ...) - so it fails at runtime even though the fast path (and any isolated script, which keeps the standard metaclass) is correct.ClassNodeSkipsuppresses the fork so a single correct path is emitted.ConstrainedProperty.DEFAULT_MESSAGESis built with a Groovy map literal instead of an anonymous-HashMap-with-instance-initialiserDEFAULT_*_MESSAGEconstants inside such an initialiser compile to dynamicgetPropertyreads onthis(theHashMap); because the receiver is aMap, the MOP resolves them as key lookups on the still-empty map, so every entry was captured asnullandDEFAULT_MESSAGES.get(code)returnednull(the interface<clinit>field-init order is actually correct - the earlier "initialiser runs before the constants" explanation was wrong). A map literal resolves the constants against the enclosing interface scope and is correct on every version, so the priorAbstractConstraintMESSAGE_BUNDLEfallback was reverted to the base form.DefaultMessageResolutionSpecValidateable.resolveDefaultNullable()Method.invokereflectionTraitReceiverTransformerrewrites the in-traitthis.defaultNullable()call to a direct trait-helper static call, losing the implementing-class override; reflection is the only path that honours it.The
g.taglibSTC change (GroovyPageTypeCheckingExtension) matches the taglib namespace by name inmethodNotFound- the approach @paulk-asert recommended for GROOVY-12041 - because when the receiver inheritsgetProperty(String)the STCunresolvedVariable/unresolvedPropertycallbacks never fire. Reproducer: groovy5-stc-extension-node-identity-bug.Related open PRs (target
8.0.x)org.ow2.asm:asm9.10.1-vs-9.9.1grails-micronaut-bomversion conflict (inherited from base).8.0.x.javaparser-coreBOM entry bumped here forgroovy-groovydoc.@GrailsCompileStaticsupport for controllers that call tag libsg.taglibSTC change.8.0.xline.