jdaugherty commented on code in PR #16281:
URL: https://github.com/apache/grails-core/pull/16281#discussion_r3904805389
##########
grails-core/src/main/groovy/grails/util/AbstractTypeConvertingMap.java:
##########
@@ -349,6 +351,72 @@ public Set entrySet() {
return wrappedMap.entrySet();
}
+ /**
+ * Subscript access ({@code map['name']}) always addresses a map entry,
never a JavaBean
+ * property of this class.
+ *
+ * <p>Groovy 5 changed runtime method selection for classes implementing
{@link Map}: for a
+ * {@code String} key it prefers {@code DefaultGroovyMethods.getAt(Object,
String)}, which
+ * reads a bean property, over {@code DefaultGroovyMethods.getAt(Map,
Object)}, which reads a
+ * map entry. Declaring the {@code String} overload here keeps map
semantics, so an entry that
+ * happens to share its name with a getter on this class remains reachable.
+ *
+ * @param key the map key
+ * @return the value stored under the key, or {@code null}
+ * @since 8.0.0
+ */
+ public Object getAt(String key) {
+ return get(key);
+ }
+
+ /**
+ * Subscript assignment ({@code map['name'] = value}) always writes a map
entry, never a
+ * JavaBean property of this class. See {@link #getAt(String)} for why the
overload is needed.
+ *
+ * @param key the map key
+ * @param value the value to store
+ * @return the previous value stored under the key, or {@code null}
+ * @since 8.0.0
+ */
+ public Object putAt(String key, Object value) {
+ return put(key, value);
+ }
+
+ /**
+ * Property access ({@code map.name}) reads a map entry rather than a
JavaBean property, so
+ * that entries whose names collide with a getter on this class remain
reachable. Getters such
+ * as {@code getRequest()} are still callable as methods. The {@code
metaClass} property is
+ * excluded because Groovy relies on it.
+ *
+ * @param name the map key
+ * @return the value stored under the key, or {@code null}
+ * @since 8.0.0
+ */
+ @Override
+ public Object getProperty(String name) {
+ if (METACLASS_PROPERTY.equals(name)) {
+ return super.getProperty(name);
+ }
+ return get(name);
+ }
+
+ /**
+ * Property assignment ({@code map.name = value}) writes a map entry
rather than a JavaBean
+ * property of this class. See {@link #getProperty(String)}.
+ *
+ * @param name the map key
+ * @param value the value to store
+ * @since 8.0.0
+ */
+ @Override
+ public void setProperty(String name, Object value) {
Review Comment:
`setProperty` routes every non-`metaClass` name to `put`, which also changes
assignment for names backed by a *real settable* bean property — not only the
read-only getters this PR is about. That is a dynamic-path change, independent
of the static-compilation caveat documented in c9e3c0f.
The only such property in this hierarchy is
`GroovyPageAttributes.gspTagSyntaxCall`. Verified with a replica carrying a
settable `flag` property:
```
Groovy 4.0.33 (no override)
dynamic m.flag = false -> field=false, mapEntry=null # setter
invoked
dynamic m['flag'] = false -> field=false, mapEntry=null # setter
invoked
Groovy 5.1.0 (this PR)
dynamic m.flag = false -> field=true, mapEntry=false # setter
skipped
dynamic m['flag'] = false -> field=true, mapEntry=false # setter
skipped
```
So on Grails 7 both forms called `setGspTagSyntaxCall`; here both write a
map entry and leave the flag at its default. Reads were already map-first on
Groovy 4, so this is specifically a write-side change and it is not in §28.3.
I checked how the framework sets the flag: only the two-arg constructor
(`new GroovyPageAttributes(attrs, false)` in `TagOutput`) and the explicit
`setGspTagSyntaxCall`/`isGspTagSyntaxCall` methods. So nothing inside
grails-core regresses. The exposure is third-party taglib or plugin code using
the property form — it would silently stop suppressing auto-printing, and the
stray `gspTagSyntaxCall` entry then travels with the attribute map into
whatever renders remaining attributes.
Either exempting names that have a real setter, or documenting this in §28.3
and pinning it with a test, would close it. A test that passes today and
captures the difference is suggested on `GroovyPageAttributesTests`.
Minor, same method: the `metaClass` branch could be `return getMetaClass();`
/ `setMetaClass(...)` instead of round-tripping through
`super.getProperty(name)`, which re-enters the metaclass to resolve a property
this class now intercepts. Cheaper per read and avoids the self-referential
path.
##########
grails-core/src/main/groovy/grails/util/AbstractTypeConvertingMap.java:
##########
@@ -349,6 +351,72 @@ public Set entrySet() {
return wrappedMap.entrySet();
}
+ /**
+ * Subscript access ({@code map['name']}) always addresses a map entry,
never a JavaBean
+ * property of this class.
+ *
+ * <p>Groovy 5 changed runtime method selection for classes implementing
{@link Map}: for a
+ * {@code String} key it prefers {@code DefaultGroovyMethods.getAt(Object,
String)}, which
+ * reads a bean property, over {@code DefaultGroovyMethods.getAt(Map,
Object)}, which reads a
+ * map entry. Declaring the {@code String} overload here keeps map
semantics, so an entry that
+ * happens to share its name with a getter on this class remains reachable.
+ *
+ * @param key the map key
+ * @return the value stored under the key, or {@code null}
+ * @since 8.0.0
+ */
+ public Object getAt(String key) {
+ return get(key);
+ }
+
+ /**
+ * Subscript assignment ({@code map['name'] = value}) always writes a map
entry, never a
+ * JavaBean property of this class. See {@link #getAt(String)} for why the
overload is needed.
+ *
+ * @param key the map key
+ * @param value the value to store
+ * @return the previous value stored under the key, or {@code null}
+ * @since 8.0.0
+ */
+ public Object putAt(String key, Object value) {
+ return put(key, value);
+ }
+
+ /**
+ * Property access ({@code map.name}) reads a map entry rather than a
JavaBean property, so
+ * that entries whose names collide with a getter on this class remain
reachable. Getters such
+ * as {@code getRequest()} are still callable as methods. The {@code
metaClass} property is
+ * excluded because Groovy relies on it.
+ *
+ * @param name the map key
+ * @return the value stored under the key, or {@code null}
+ * @since 8.0.0
+ */
+ @Override
+ public Object getProperty(String name) {
Review Comment:
c9e3c0f documents the static-compilation caveat well for `params`, and I
verified `testStaticCompilationBindsPropertySyntaxToTheAccessor` passes. Two
notes on scope, since this override is the shared base for both maps:
**`attrs` is not covered, and it fails more quietly than `params`.** Both
the new NOTE and the new test scope themselves to `params`.
`GroovyPageAttributesTests` has no `@CompileStatic` coverage at all. The
difference matters: on `params`, statically compiled assignment to a colliding
name fails at compile time, which is loud. On `attrs`, `gspTagSyntaxCall` has a
real setter, so it compiles and silently does the wrong thing. Verified against
the real class on Groovy 5.1.0:
```
dynamic attrs.gspTagSyntaxCall = false -> mapEntry=false, field=true
# as intended
@CompileStatic attrs.gspTagSyntaxCall = false -> mapEntry=null, field=false
# setter invoked, map untouched
dynamic attrs.gspTagSyntaxCall (read) -> the map entry
@CompileStatic attrs.gspTagSyntaxCall (read) -> the boolean field
```
The subscript form is correct in both modes, as with `params`.
**Worth stating the underlying rule once, rather than per name.** The reason
the caveat exists is structural: `getProperty`/`setProperty` are runtime hooks,
so the static compiler binds to the declared accessor and never reaches them,
while `getAt(String)`/`putAt(String, Object)` *are* declared methods it
resolves directly. That is why subscript is the portable form for any colliding
name on either map — including ones a subclass adds later. A sentence to that
effect in the Javadoc here would keep the guidance from having to be re-derived
for each new accessor.
For reference, measured on Groovy 4.0.33 vs 5.1.0 with the real classes —
the Groovy 4 static and dynamic columns were identical, so the divergent rows
are Grails 7 → 8 changes rather than pre-existing quirks:
| expression | Grails 7 (Groovy 4) | this PR, dynamic | this PR,
`@CompileStatic` |
|---|---|---|---|
| `params.identifier` read | map entry | map entry | `getIdentifier()` → the
`id` param |
| `params.request` read | map entry | map entry | the `HttpServletRequest` |
| `params.identifier = 'x'` | map put | map put | compile error |
| `params['identifier']` read/write | map | map | map |
| `attrs.gspTagSyntaxCall` read | map entry | map entry | the boolean field |
| `attrs.gspTagSyntaxCall = false` | setter invoked | map put | setter
invoked, map untouched |
##########
grails-test-suite-web/src/test/groovy/org/grails/web/commandobjects/CommandObjectInstantiationSpec.groovy:
##########
@@ -126,6 +126,34 @@ class CommandObjectInstantiationSpec extends Specification
implements Controller
where:
requestMethod << ['POST', 'PUT', 'GET', 'DELETE']
}
+
+ @Issue('https://github.com/apache/grails-core/issues/16280')
+ void 'Test a parameter named identifier does not divert domain command
object resolution'() {
+ given:
+ def target = new DomainClassCommandObject(name: 'Target').save()
+ def decoy = new DomainClassCommandObject(name: 'Decoy').save()
+
+ expect:
+ target.id != null
+ decoy.id != null
+ target.id != decoy.id
+
+ when: 'a request carries both an id and a form field named identifier'
+ request.method = 'POST'
+ params.id = target.id
+ params.identifier = decoy.id
+ controller.domainCommandObject()
+
+ then: 'the command object is resolved from id, not from the identifier
field'
+ response.status == HttpServletResponse.SC_OK
+ model.commandObject.id == target.id
Review Comment:
These two conditions are not actually sensitive to `getIdentifier()`, so the
integration half of this test does not exercise the mechanism its name
describes.
I verified by mutation — changing `GrailsParameterMap.getIdentifier()` to
`return get("identifier")` and re-running. `model.commandObject.id ==
target.id` and `model.commandObject.name == 'Target'` both still passed; the
only condition that failed was the direct `params.getIdentifier() == target.id`
further down.
The reason is that `Controller.initializeCommandObject` reads
`commandObjectBindingSource.getIdentifierValue()` first and only falls back to
`params.getIdentifier()` when that returns null.
`DefaultDataBindingSourceCreator` builds that source as `new
SimpleMapDataBindingSource(grailsWebRequest.getParams())`, whose
`getIdentifierValue()` returns `map['id']` — already `target.id` here — so the
fallback never runs.
So the coverage comes entirely from the direct assertion, which duplicates
`GrailsParameterMapTests.testGetIdentifierAlwaysReadsTheIdParameter`. To pin
the mechanism at this level, drive the fallback — e.g. a binding source with no
`id` key, so `getIdentifierValue()` returns null and `getIdentifier()` is
actually consulted. Otherwise the spec is fine but the "does not divert domain
command object resolution" claim rests on the unit test rather than on this one.
##########
grails-doc/src/en/guide/upgrading/upgrading80x.adoc:
##########
@@ -1488,6 +1488,47 @@ def value = configObject.containsKey(key) ?
configObject.get(key) : null
Grails applies this guard internally when resolving Spring profile activation
keys; you only need it in application code that navigates a raw `ConfigObject`
by subscript.
+===== 28.3 `params` and `attrs` entries that share a name with an accessor
+
+Groovy 5 changed runtime method selection for classes that implement `Map`.
+For a `String` subscript key, and for dotted property access, Groovy now
prefers a JavaBean property of the class over a map entry.
+On `params` (`GrailsParameterMap`) and on tag library `attrs`
(`GroovyPageAttributes`) that made an entry named after an accessor
unreachable: `params['request']` returned the `HttpServletRequest` instead of
the submitted parameter, and `params['identifier'] = value` threw a
`ReadOnlyPropertyException`.
+
+Grails 8 restores the Grails 7 rule.
+In dynamically compiled Groovy, `params['name']`, `params.name`,
`attrs['name']` and `attrs.name` all address a map entry; call the accessor
method when you want the framework object:
+
+[source,groovy]
+----
+params['request'] // the request parameter named "request", or null when
none was submitted
+params.request // the same parameter
+params.getRequest() // the HttpServletRequest
+----
+
+[NOTE]
+====
+This applies to dynamically compiled Groovy.
+Under `@CompileStatic` or `@GrailsCompileStatic` the compiler binds property
syntax to the declared accessor rather than routing through `getProperty`, so
for the two names that collide with an accessor on `params`,
`params.identifier` calls `getIdentifier()` and `params.request` calls
`getRequest()` instead of reading the map entry.
+Writing them that way does not compile: `params.identifier = value` fails with
`Cannot set read-only property: identifier`, and `params['request'] = value`
fails with `Cannot assign value of type java.lang.String to variable of type
jakarta.servlet.http.HttpServletRequest`.
+
+In statically compiled code, read these two parameters with
`params['identifier']` and `params['request']`, and write them with
`params.put('identifier', value)` and `params.put('request', value)`:
+
+[source,groovy]
+----
+@GrailsCompileStatic
+class BookController {
+ def save() {
+ params.put('request', 'submitted value') // params['request'] = ...
does not compile
+ def submitted = params['request'] // the parameter;
params.request is the HttpServletRequest
+ }
+}
+----
+
+Every other parameter name is unaffected, and the subscript form behaves the
same under both compilation modes.
+====
+
+One behavior differs from Grails 7: `params.metaClass` always returns the
Groovy `MetaClass`.
Review Comment:
Worth extending this paragraph to the write side, which the `metaClass`
guard makes asymmetric. Verified on Groovy 5.1.0:
```groovy
params['metaClass'] = 'mc' // stores a map entry
params.metaClass = 'mc' // GroovyCastException: Cannot cast object 'mc'
... to class 'groovy.lang.MetaClass'
```
As written the paragraph only tells readers about reading
`params['metaClass']`, so the exception on the dotted form is a surprise. One
sentence covers it.
##########
grails-gsp/grails-taglib/src/test/groovy/org/grails/taglib/GroovyPageAttributesTests.groovy:
##########
@@ -94,6 +94,38 @@ class GroovyPageAttributesTests {
assert '[one:foo]' == attrs.toString()
}
+ // https://github.com/apache/grails-core/issues/16280
+ @Test
+ void testGspTagSyntaxCallAttributeIsAMapEntry() {
+ def attrs = toGroovyPageAttributes([:])
+
+ attrs['gspTagSyntaxCall'] = 'value'
+
+ assertEquals 'value', attrs['gspTagSyntaxCall']
+ assertEquals 'value', attrs.gspTagSyntaxCall
+ assertTrue attrs.isGspTagSyntaxCall()
+
+ attrs.setGspTagSyntaxCall(false)
+
+ assertFalse attrs.isGspTagSyntaxCall()
+ assertEquals 'value', attrs['gspTagSyntaxCall']
+ }
Review Comment:
This covers the read side and the explicit `setGspTagSyntaxCall(false)`
method form, but never `attrs.gspTagSyntaxCall = false` — the
property-assignment form, which is what the new `setProperty` override changes.
Since this is the only settable bean property in the hierarchy, it is the case
worth pinning.
Two tests, both of which I ran against c9e3c0f. The first **passes** and
captures the dynamic write-side change from Grails 7:
```groovy
// https://github.com/apache/grails-core/issues/16280
@Test
void testPropertyAssignmentDoesNotInvokeTheSetter() {
def attrs = toGroovyPageAttributes([:])
attrs.gspTagSyntaxCall = false
assertEquals false, attrs['gspTagSyntaxCall']
assertTrue attrs.isGspTagSyntaxCall()
}
```
On Groovy 4 that same line invoked `setGspTagSyntaxCall(false)` and stored
nothing in the map, so this is the assertion that documents the difference.
The second **fails** today, and is the `attrs` counterpart of
`testStaticCompilationBindsPropertySyntaxToTheAccessor`:
```groovy
// https://github.com/apache/grails-core/issues/16280
@Test
void testStaticallyCompiledAttributeAccessAddressesTheMap() {
def attrs = toGroovyPageAttributes([:])
StaticallyCompiledAccess.write(attrs)
assertEquals false, attrs['gspTagSyntaxCall']
assertTrue attrs.isGspTagSyntaxCall()
}
@CompileStatic
static class StaticallyCompiledAccess {
static void write(GroovyPageAttributes attrs) {
attrs.gspTagSyntaxCall = false
}
}
```
Both need `import groovy.transform.CompileStatic`. The second fails with
`expected: <false> but was: <null>` — the map entry was never written and the
field was.
If the `attrs` static behavior is accepted as a documented limitation like
`params`, then inverting that second assertion (asserting the field was written
and the map was not) is the right form — it still locks the behavior down and
makes it discoverable instead of silent. Either way I'd not leave it untested,
since this is the one case with no compile error to warn the caller.
##########
grails-gsp/grails-taglib/build.gradle:
##########
@@ -84,6 +84,7 @@ dependencies {
testImplementation 'org.junit.jupiter:junit-jupiter-api'
testImplementation 'org.slf4j:slf4j-nop' // Get rid of warning about
missing slf4j implementation during compilation and tests
testImplementation 'org.spockframework:spock-core'
+ testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine' // Required to
discover the JUnit 5 tests in this module
Review Comment:
Confirmed this is a real gap and not a no-op: reverting only this line makes
`:grails-taglib:test` report BUILD SUCCESSFUL while discovering zero tests, so
`GroovyPageAttributesTests` had never executed.
Worth considering whether it belongs in `gradle/test-config.gradle` instead.
That convention already adds `testRuntimeOnly
'org.junit.platform:junit-platform-launcher'` and calls `useJUnitPlatform()`,
but never adds the Jupiter engine — which is why the failure mode existed at
all. Every other module with JUnit 5 tests gets the engine only transitively,
through `runtimeOnly 'org.junit.jupiter:junit-jupiter-engine'` in
`grails-testing-support-core`; I checked the six modules that declare
`junit-jupiter-api` without an explicit engine and they all resolve it that way
today. If that transitive edge is ever narrowed, the same silent-skip returns
elsewhere with a green build.
Fine to keep the module-local fix and do the convention change separately,
but it would be the fix for the class of problem rather than this instance.
##########
grails-web-common/src/test/groovy/grails/web/servlet/mvc/GrailsParameterMapTests.groovy:
##########
@@ -394,6 +395,212 @@ class GrailsParameterMapTests {
assertEquals "1", theMap['test']
}
+ @Test
+ @Issue("https://github.com/apache/grails-core/issues/16280")
+ void testAddingIdentifierParam() {
+ theMap = new GrailsParameterMap(mockRequest)
+
+ theMap['identifier'] = 'id1'
+
+ assertEquals 'id1', theMap['identifier']
+ }
+
+ @Test
+ @Issue("https://github.com/apache/grails-core/issues/16280")
+ void testSubscriptAccessForNamesThatCollideWithGetters() {
+ theMap = new GrailsParameterMap(mockRequest)
+
+ ['identifier', 'request', 'empty', 'class', 'metaClass', 'plain'].each
{ String name ->
+ theMap[name] = "value of $name".toString()
+ }
+
+ ['identifier', 'request', 'empty', 'class', 'metaClass', 'plain'].each
{ String name ->
+ assertEquals "value of $name".toString(), theMap[name], "subscript
access for [$name] should read the parameter"
+ assertTrue theMap.containsKey(name), "[$name] should be a key of
the map"
+ }
+ }
+
+ @Test
+ @Issue("https://github.com/apache/grails-core/issues/16280")
+ void testPropertyAccessForNamesThatCollideWithGetters() {
+ theMap = new GrailsParameterMap(mockRequest)
+
+ theMap.identifier = 'id1'
+ theMap.request = 'r1'
+ theMap.empty = 'e1'
+
+ assertEquals 'id1', theMap.identifier
+ assertEquals 'r1', theMap.request
+ assertEquals 'e1', theMap.empty
+ assertEquals 'id1', theMap['identifier']
+ assertEquals 'r1', theMap['request']
+ assertEquals 'e1', theMap['empty']
+ }
+
+ @Test
+ @Issue("https://github.com/apache/grails-core/issues/16280")
+ void testGetIdentifierAlwaysReadsTheIdParameter() {
+ mockRequest.addParameter("id", "abc")
+ theMap = new GrailsParameterMap(mockRequest)
+
+ assertEquals 'abc', theMap.getIdentifier()
+
+ // a parameter named "identifier" is an ordinary map entry and must
not divert
+ // getIdentifier(), which Controller.initializeCommandObject() uses to
load a domain object
+ theMap['identifier'] = 'other'
+
+ assertEquals 'abc', theMap.getIdentifier()
+ assertEquals 'other', theMap['identifier']
+ assertEquals 'other', theMap.identifier
+ assertEquals 'abc', theMap['id']
+ }
+
+ @Test
+ @Issue("https://github.com/apache/grails-core/issues/16280")
+ void testGetIdentifierIsNullWhenNoIdParameterWasSubmitted() {
+ theMap = new GrailsParameterMap(mockRequest)
+
+ assertNull theMap.getIdentifier()
+
+ theMap['identifier'] = 'other'
+
+ assertNull theMap.getIdentifier()
+ }
+
+ @Test
+ @Issue("https://github.com/apache/grails-core/issues/16280")
+ void testGetRequestStillReturnsTheRequest() {
+ theMap = new GrailsParameterMap(mockRequest)
+
+ theMap['request'] = 'r1'
+
+ assertSame mockRequest, theMap.getRequest()
+ assertEquals 'r1', theMap['request']
+ assertEquals 'r1', theMap.request
+ }
+
+ @Test
+ @CompileStatic
+ @Issue("https://github.com/apache/grails-core/issues/16280")
+ void testStaticCompilationBindsPropertySyntaxToTheAccessor() {
+ GrailsParameterMap params = new GrailsParameterMap(mockRequest)
+
+ // subscript access addresses the map under @CompileStatic as well,
because getAt(String)
+ // and putAt(String, Object) are declared methods the static compiler
resolves directly
+ params['identifier'] = 'id1'
+ assertEquals 'id1', params['identifier']
+ assertNull params['request']
+
+ // put() is the portable way to write a parameter whose name collides
with an accessor;
+ // params['request'] = 'r1' does not compile, because the static
compiler binds it to the
+ // HttpServletRequest-typed "request" property
+ params.put('request', 'r1')
+ assertEquals 'r1', params['request']
+
+ // property syntax binds to the declared accessor at compile time and
bypasses
+ // getProperty, so it does NOT read the map entry under static
compilation.
+ // Writing it does not compile at all: "params.identifier = value"
fails with
+ // "Cannot set read-only property: identifier".
+ assertNull params.identifier // getIdentifier() reads the
absent "id" parameter
+ assertSame mockRequest, params.request // getRequest()
+ }
+
+ @Test
+ @Issue("https://github.com/apache/grails-core/issues/16280")
+ void testCollidingNamesAreNullWhenNoSuchParameterWasSubmitted() {
+ theMap = new GrailsParameterMap(mockRequest)
+
+ // "request" and "identifier" name parameters, so with no such
parameter submitted they
+ // read as null rather than falling back to
getRequest()/getIdentifier()
+ assertNull theMap['request']
+ assertNull theMap.request
+ assertNull theMap['identifier']
+ assertNull theMap.identifier
+ assertNull theMap.nonexistent
+
+ // the accessors themselves are unaffected
+ assertSame mockRequest, theMap.getRequest()
+ assertNull theMap.getIdentifier() // no "id" parameter was submitted
+ }
+
+ @Test
+ @Issue("https://github.com/apache/grails-core/issues/16280")
+ void testCollidingParameterNamesArrivingFromTheRequest() {
+ mockRequest.addParameter("identifier", "id1")
+ mockRequest.addParameter("request", "r1")
+ theMap = new GrailsParameterMap(mockRequest)
+
+ assertEquals 'id1', theMap['identifier']
+ assertEquals 'r1', theMap['request']
+ assertEquals 'id1', theMap.identifier
+ assertEquals 'r1', theMap.request
+ assertSame mockRequest, theMap.getRequest()
+ }
+
+ @Test
+ @Issue("https://github.com/apache/grails-core/issues/16280")
+ void testMetaClassPropertyIsNotShadowedByTheMap() {
+ theMap = new GrailsParameterMap(mockRequest)
+
+ theMap['metaClass'] = 'mc'
+
+ assertEquals 'mc', theMap['metaClass']
+ assertNotNull theMap.metaClass
+ assertNotEquals 'mc', theMap.metaClass
Review Comment:
Nit: this does catch the `metaClass` guard being removed — the property
would then return the string `'mc'` — but only because of the specific value
stored above. With any other value it would be vacuous, since a `String` is
never equal to a `MetaClass`.
The grails-core twin states the intent directly:
```groovy
assert map.metaClass instanceof MetaClass
```
Worth matching here for consistency.
##########
grails-doc/src/en/guide/upgrading/upgrading80x.adoc:
##########
@@ -1488,6 +1488,47 @@ def value = configObject.containsKey(key) ?
configObject.get(key) : null
Grails applies this guard internally when resolving Spring profile activation
keys; you only need it in application code that navigates a raw `ConfigObject`
by subscript.
+===== 28.3 `params` and `attrs` entries that share a name with an accessor
+
+Groovy 5 changed runtime method selection for classes that implement `Map`.
+For a `String` subscript key, and for dotted property access, Groovy now
prefers a JavaBean property of the class over a map entry.
+On `params` (`GrailsParameterMap`) and on tag library `attrs`
(`GroovyPageAttributes`) that made an entry named after an accessor
unreachable: `params['request']` returned the `HttpServletRequest` instead of
the submitted parameter, and `params['identifier'] = value` threw a
`ReadOnlyPropertyException`.
+
+Grails 8 restores the Grails 7 rule.
+In dynamically compiled Groovy, `params['name']`, `params.name`,
`attrs['name']` and `attrs.name` all address a map entry; call the accessor
method when you want the framework object:
+
+[source,groovy]
+----
+params['request'] // the request parameter named "request", or null when
none was submitted
+params.request // the same parameter
+params.getRequest() // the HttpServletRequest
+----
+
+[NOTE]
+====
+This applies to dynamically compiled Groovy.
+Under `@CompileStatic` or `@GrailsCompileStatic` the compiler binds property
syntax to the declared accessor rather than routing through `getProperty`, so
for the two names that collide with an accessor on `params`,
`params.identifier` calls `getIdentifier()` and `params.request` calls
`getRequest()` instead of reading the map entry.
+Writing them that way does not compile: `params.identifier = value` fails with
`Cannot set read-only property: identifier`, and `params['request'] = value`
fails with `Cannot assign value of type java.lang.String to variable of type
jakarta.servlet.http.HttpServletRequest`.
+
+In statically compiled code, read these two parameters with
`params['identifier']` and `params['request']`, and write them with
`params.put('identifier', value)` and `params.put('request', value)`:
+
+[source,groovy]
+----
+@GrailsCompileStatic
+class BookController {
+ def save() {
+ params.put('request', 'submitted value') // params['request'] = ...
does not compile
+ def submitted = params['request'] // the parameter;
params.request is the HttpServletRequest
+ }
+}
+----
+
+Every other parameter name is unaffected, and the subscript form behaves the
same under both compilation modes.
Review Comment:
This NOTE is a good addition and matches what I measured. Two gaps:
**It only addresses `params`.** `attrs` (`GroovyPageAttributes`) has the
same static-compilation behavior, and there it is quieter, because
`gspTagSyntaxCall` has a real setter — `attrs.gspTagSyntaxCall = false` under
`@CompileStatic` compiles and silently writes the field. Since the section
header covers both `params` and `attrs`, a reader will reasonably assume the
NOTE does too. Suggest either naming `attrs` here or generalising: subscript is
the portable form for any accessor-colliding name on either map.
**"Every other parameter name is unaffected" is true only for reads.**
Assignment to a name backed by a real setter also changed, in dynamically
compiled code: on Grails 7 `attrs.gspTagSyntaxCall = false` and
`attrs['gspTagSyntaxCall'] = false` both invoked `setGspTagSyntaxCall`, and
both now write a map entry instead. That is a second difference from Grails 7
alongside the `metaClass` read, and it is currently undocumented.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]