Skip to content

M6 Bug Fix generated GSP views: scaffolded count model field type and welcome page static compilation - #16322

Open
codeconsole wants to merge 6 commits into
apache:8.0.xfrom
codeconsole:fix/scaffold-count-type-8.0.x
Open

M6 Bug Fix generated GSP views: scaffolded count model field type and welcome page static compilation#16322
codeconsole wants to merge 6 commits into
apache:8.0.xfrom
codeconsole:fix/scaffold-count-type-8.0.x

Conversation

@codeconsole

@codeconsole codeconsole commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Description

Two defects that stop a freshly generated app from working. Both are in the generated GSP views, and both surface the moment you use the app the generator produces.

1. Scaffolded index.gsp declares the count model field as Integer

grails generate-views produces an index.gsp whose typed model directive declares:

@{ model="List<website.Sample> sampleList; Integer sampleCount" }

but the Service.groovy template declares Long count(), and Controller.groovy passes that value straight through as the ${propertyName}Count model entry. Model fields are populated by reflective Field.set, which does no numeric conversion, so every scaffolded index view throws on first render:

java.lang.IllegalArgumentException: Can not set java.lang.Integer field
  ..._views_sample_index_gsp.sampleCount to java.lang.Long
    at org.grails.gsp.GroovyPage.applyModelFieldsFromBinding(GroovyPage.java:185)

Declaring the field as Long matches what the service returns.

While tracking this down, the error itself proved harder to read than it needed to be. GroovyPage.applyModelFieldsFromBinding caught only IllegalAccessException, which cannot occur — GroovyPageMetaInfo.initializeModelFields already calls ReflectionUtils.makeAccessible on every model field. The one failure that can occur, a type mismatch, escaped uncaught and surfaced as a bare JDK reflection message naming the mangled generated page class. It now reports something actionable:

GroovyPagesException: Model field 'sampleCount' is declared as java.lang.Integer
  but the model supplied an instance of java.lang.Long. Declare the field with a
  type the model value is assignable to; model values are not coerced.

Strict assignment is kept deliberately — coercing would silently narrow Long to Integer, and the typed model directive exists to give these pages real types.

2. Welcome page does not compile under GSP static compilation

Adding the following to a generated app's build.gradle fails compileGroovyPages:

grails {
    compileStatic {
        all = true
        gsp = true
    }
}
[Static type checking] - Cannot find matching method java.lang.Object#toLowerCase()
[Static type checking] - No such property: name for class: java.lang.Object

Four sort closures in the welcome page take untyped parameters, so static type checking infers Object for the element and rejects the calls on it. Each loses the element type for a different reason:

Site Why the element is Object
plugin list .collect { p, i -> [plugin: p, order: ...] } — map literal values are Object
domainsByPlugin the groupBy closure returns a def local, so the key is Object
appListeners same map-literal inference
mimeTypes applicationContext.getBean('mimeTypes') returns Object

Fixed by typing the closure parameters and converting the values whose static type is Object. The plugin and mime type comparisons need the concrete element type, so those are cast.

One wrinkle worth recording: grails.plugins.GrailsPlugin cannot be imported into a GSP, because the compiler already auto-imports the unrelated grails.plugins.metadata.GrailsPlugin annotation and the collision fails the build with The name GrailsPlugin is already declared. The cast uses the qualified name instead.

grails-forge-core's resource copy and the web profile skeleton copy of this page were byte-identical, so both are updated and remain identical.

The scaffolding Service template declares `Long count()` and the
Controller template passes its result as the `${propertyName}Count`
model value, but index.gsp declared the matching typed model field as
`Integer`. Model fields are populated by reflective Field.set, which
performs no numeric conversion, so every scaffolded index view threw
IllegalArgumentException on first render.

Declare the field as Long so it matches what the service returns.

GroovyPage.applyModelFieldsFromBinding only caught IllegalAccessException,
which cannot occur because GroovyPageMetaInfo already makes each model
field accessible. The one failure that can occur, a type mismatch, escaped
uncaught and surfaced as a bare JDK reflection message naming the mangled
generated page class. Catch IllegalArgumentException and report the field,
the declared type, the supplied type and the page instead.
Enabling static GSP compilation in a generated app:

    grails {
        compileStatic {
            all = true
            gsp = true
        }
    }

made compileGroovyPages fail on the welcome page. Four sort closures took
untyped parameters, so static type checking inferred java.lang.Object for
the element and rejected the property and method calls on it:

    Cannot find matching method java.lang.Object#toLowerCase()
    No such property: name for class: java.lang.Object

Type the closure parameters, and convert the values whose static type is
Object rather than String. The plugin and mime type comparisons need the
concrete element type, so those are cast; grails.plugins.GrailsPlugin is
referenced by its qualified name because GSP already auto-imports the
unrelated grails.plugins.metadata.GrailsPlugin annotation.

The forge resource and the web profile skeleton carry byte-identical
copies of this page, so both are updated.
@codecov

codecov Bot commented Sep 7, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 54.7318%. Comparing base (7801ced) to head (8855b88).

Additional details and impacted files

Impacted file tree graph

@@                Coverage Diff                 @@
##                8.0.x     #16322        +/-   ##
==================================================
+ Coverage     54.7302%   54.7318%   +0.0017%     
+ Complexity      20538      20537         -1     
==================================================
  Files            2104       2104                
  Lines          101149     101155         +6     
  Branches        17966      17966                
==================================================
+ Hits            55359      55364         +5     
+ Misses          37772      37771         -1     
- Partials         8018       8020         +2     
Files with missing lines Coverage Δ
...ore/src/main/groovy/org/grails/gsp/GroovyPage.java 78.0000% <100.0000%> (+0.5410%) ⬆️

... and 8 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

The mime type sort closure spelled out grails.web.mime.MimeType at both
parameters, which is long and reads poorly. Nothing in the GSP default
imports declares a competing MimeType, so a page import works here and
the closure reads as `MimeType a, MimeType b`.

The plugin comparison keeps its qualified name: GSP always imports
grails.plugins.metadata.GrailsPlugin, so importing grails.plugins.GrailsPlugin
fails with "The name GrailsPlugin is already declared".
The previous commit made the page compile statically, but did it by
annotating the comparators rather than by restoring the element types,
which left three closures heavier than the rest of the page. Every other
sort here reads `sort { it.something }`.

Derive the domain grouping key as a String instead of from an untyped
local, and the sort needs no annotation at all. Declare the listener maps
as Map<String, String>, and the toString() calls guarding the comparison
are unnecessary. Sort the plugin rows by a single key so the cast appears
once rather than on both sides.

The plugin comparison still needs its cast and its qualified name: the
rows are heterogeneous maps, so the value is Object, and GSP always
imports grails.plugins.metadata.GrailsPlugin.

Do not name a closure parameter `it`. It compiles ahead of time, but the
page is parsed again at runtime when reloading is on, and that path
rejects it with "The current parameter list already contains a parameter
of the name it".
Changing the field to Long fixed views generated alongside the generated
service, which declares Long count(), but broke dynamic scaffolding. A
controller using `static scaffold = X` is backed by RestfulController,
whose countResources() returns Integer, so those pages then failed the
other way:

    Model field 'authorCount' is declared as java.lang.Long
    but the model supplied an instance of java.lang.Integer

One template serves both paths, so it cannot name either concrete type.
Number accepts what each supplies, and the pagination comparison still
compiles statically against it.

Covered by a test that renders the field from Integer, Long, Short and
BigInteger, so neither supplier can regress the other again.
@testlens-app

testlens-app Bot commented Sep 8, 2026

Copy link
Copy Markdown

✅ All tests passed ✅

🏷️ Commit: 8855b88
▶️ Tests: 81338 executed
⚪️ Checks: 91/91 completed


Learn more about TestLens at testlens.app/docs.

@codeconsole
codeconsole requested review from jdaugherty and matrei and removed request for jdaugherty September 8, 2026 03:43
@codeconsole codeconsole added this to the grails:8.0.0-RC1 milestone Sep 8, 2026
@codeconsole codeconsole changed the title Fix generated GSP views: scaffolded count model field type and welcome page static compilation M6 Bug Fix generated GSP views: scaffolded count model field type and welcome page static compilation Sep 8, 2026
@matrei

matrei commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

AI Review Findings

Head 8855b88340, base 8.0.x at 0980623481. The head is 103 commits behind the base but merges clean (git merge-tree reports no conflicts). Both defects in the description are real and both fixes work:

  • Compiling the base-branch welcome page statically (copied into grails-test-examples/gsp-compile-static) fails with 4 type-checking errors at three sites, the plugins sort, the domains-by-plugin sort and the mime-types sort. The PR's page compiles, and the compiled class extends CompileStaticGroovyPage, so it really was compiled statically rather than falling back.
  • The scaffolded index.gsp expanded for a domain class compiles statically with Number and the ${count} > params.int('max') comparison type-checks.
  • GspCompileStaticSpec: 30 tests pass. Checkstyle on grails-gsp-core main: 0 violations.

The findings below are about the choice the GroovyPage change makes, and about what would keep either bug from coming back. None of them changes the welcome page hunks.

[P2] Assign model fields the way Groovy assigns, instead of declaring that model values are not coerced

References:

  • grails-gsp/core/src/main/groovy/org/grails/gsp/GroovyPage.java:180-197 (applyModelFieldsFromBinding)
  • grails-scaffolding/src/main/templates/scaffolding/index.gsp:1

The new message states a rule, "model values are not coerced", that is the opposite of what the rest of the page does. <g:set type="int" var="total" value="${books.size()}"/> converts (the static compilation guide says so explicitly), and plain Groovy converts too:

Integer i = 42L      // 42, java.lang.Integer
int p = 42L          // 42
String s = "${40+2}" // GString assigned to String

A model field declared Integer bookCount is the one place in a page where the same assignment throws. That declaration is also the natural one to write: GORM's count() returns Integer (GormEntity.groovy:732), the generated service returns Long, and RestfulController.countResources() returns Integer, so any page author who declares the count with the type of whichever source they looked at has a coin-flip chance of a render failure. String fields fed a GString from a controller fail the same way today.

DefaultTypeTransformation.castToType is Groovy's own assignment conversion, so it gives exactly the semantics a def-free Groovy assignment would:

try {
    field.set(this, DefaultTypeTransformation.castToType(value, field.getType()));
} catch (IllegalArgumentException | GroovyCastException e) {
    throw new GroovyPagesException("Model field '" + field.getName() + "' is declared as " +
            field.getType().getName() + " but the model supplied an instance of " +
            value.getClass().getName() + '.', e, -1, getGroovyPageFileName());
}

I ran the PR's spec with that in place plus a data-driven case:

declared supplied result
Integer 42L renders 42
int 42L renders 42
Long 42 renders 42
long 42 as Short renders 42
String "${40 + 2}" (GString) renders 42
Number 42 as BigInteger renders 42
Integer new Date() GroovyPagesException naming the field, cause GroovyCastException

The only existing test that changes is a model value of the wrong type names the field and both types, which would flip from Integer/Long to a pair that genuinely cannot be converted, Integer/Date say. Everything else in the spec passes unchanged. The prototype is not in the working tree.

The Number change in the scaffold template is still the honest type for a value that is Long from one controller and Integer from the other, so keep it either way. With coercion it stops being load-bearing, and every already-generated Integer view in existing 8.0.x applications starts working as well, which the template change alone does not give them.

[P2] Document what a declared model field does with a value of another type

References:

  • grails-doc/src/en/guide/theWebLayer/gsp/gspStaticCompilation.adoc:28-56 (Declaring the Model)
  • grails-doc/src/en/guide/theWebLayer/gsp/gspStaticCompilation.adoc:208

The guide says nothing about what happens when the supplied value's type differs from the declaration. The only nearby sentence, at line 208, covers the framework-supplied names and says they fail with a GroovyCastException. Whichever way the finding above is decided, that section needs one sentence: either "a model value is converted the way a Groovy assignment converts it, so a Long count satisfies an Integer field" or "a model value must be assignable to the declared type; a mismatch fails at render naming the field". The behaviour is user-facing and is exactly what this PR changes, so this is the doc coverage the contributing rules ask for.

[P2] Neither failure had a test that could catch it, and this PR adds coverage for the engine but not for the two pages

References:

  • grails-test-examples/gsp-compile-static/build.gradle
  • grails-test-examples/scaffolding-fields/grails-app/controllers/scaffoldingfields/EmployeeController.groovy:32
  • grails-gsp/core/src/test/groovy/org/grails/gsp/GspCompileStaticSpec.groovy:241-274

CI was green on 8.0.x with both bugs present, and stays green if either regresses:

  • Nothing compiles the welcome page statically. gsp-compile-static has its own small pages only. A Copy step that drops grails-profiles/web/skeleton/grails-app/views/index.gsp into that app's views ahead of compileGroovyPages is the check I ran by hand above, and it fails on the base branch with the four errors. The forge copy is already pinned to the profile copy by test the profile skeleton mirrors the forge welcome templates in GrailsGspSpec, so one copy is enough.
  • Nothing renders a service-backed scaffolded index. scaffolding-fields and hyphenated use static scaffold = Domain, which goes through RestfulServiceController.countResources() and supplies an Integer, so the generated-service Long path is never rendered. The new spec cases prove the engine accepts a Long for a Number field; they do not prove the template declares Number. A test that expands scaffolding/index.gsp for a domain class and renders it with [bookList: [], bookCount: 3L] would pin the template itself.

Nit: the listener sort hunk is not needed

References:

  • grails-profiles/web/skeleton/grails-app/views/index.gsp:570
  • grails-forge/grails-forge-core/src/main/resources/gsp/index.gsp:570

Map<String, String> a, Map<String, String> b on the listener sort compiles either way: the collect above it builds maps whose values are all String, so a.name already infers as String. I reverted that one hunk on the PR's page and it compiled. The description says four closures fail; three do. Harmless, and fine to keep for symmetry with the other three.

Nit: the scaffolding guide describes model names that the templates no longer use

References:

  • grails-doc/src/en/guide/scaffolding.adoc:191

Pre-existing and out of scope, noting it because it is the doc a reader would go to for the count field: it says the standard views expect <propertyName>InstanceList and <propertyName>Instance, while the templates bind <propertyName>List and <propertyName>Count.

Confirmed

  • The grails.plugins.GrailsPlugin fully-qualified cast is needed: GroovyPageParser.DEFAULT_IMPORTS imports grails.plugins.metadata.GrailsPlugin into every page, so a page import of the plugin interface would clash.
  • The rewritten sorts keep their ordering. The one-argument sort on the plugin rows and on the domainsByPlugin entries orders by the same lower-cased key the comparators used, and List.sort(Closure) still sorts in place as before.
  • The groupBy closure now returns a typed String, which is what lets it.key.toLowerCase() type-check on the entry.
  • Reading the model value before the try changes nothing: only IllegalAccessException was caught before, so a failing getProperty propagated then and propagates now.

Verification

  • ./gradlew :grails-gsp-core:test --tests org.grails.gsp.GspCompileStaticSpec: 30 tests, 0 failures on the PR head.
  • ./gradlew :grails-gsp-core:checkstyleMain: 0 violations.
  • ./gradlew :grails-test-examples-gsp-compile-static:compileGroovyPages with the PR's welcome page and an expanded scaffold index.gsp (List<gspstatic.Book> bookList; Number bookCount) copied into the app: success, both classes extend CompileStaticGroovyPage.
  • Same task with the merge-base welcome page: fails, Cannot find matching method java.lang.Object#toLowerCase() at generated lines 42, 413 and 1340 plus No such property: name for class: java.lang.Object at 1340.
  • Same task with the PR's page and only the listener sort hunk reverted: success.
  • Coercion prototype in GroovyPage.java plus 7 temporary spec cases: 37 tests, the single failure being the PR's Integer/Long mismatch case, as expected. Both files restored afterwards; git status shows no tracked changes.
  • The copied pages were removed from grails-test-examples/gsp-compile-static afterwards.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

3 participants