codeconsole commented on code in PR #16694:
URL: https://github.com/apache/grails-core/pull/16694#discussion_r4332508958
##########
grails-doc/src/en/guide/upgrading/upgrading80x.adoc:
##########
@@ -1592,6 +1592,32 @@ Subscript assignment, `attrs['gspTagSyntaxCall'] =
value`, now stores an attribu
NOTE: When adding a class that extends `grails.util.TypeConvertingMap`, do not
declare a no-argument `getX()` or `isX()` method on it, or the entry `x`
becomes unreachable for the same reason. Statically compiled callers bind
directly to the accessor, so the collision cannot be corrected at runtime.
+===== 28.4 Map entries named `properties` or `metaClass`
+
+The accessor-first resolution described above applies to every `Map`, not only
to a class that declares an accessor of its own, because every Groovy object
has the `properties` and `metaClass` properties.
+In Groovy 4 `map.properties` and `map['properties']` read the map entry.
+In Groovy 5 both return the Groovy meta-properties of the map object, in
dynamic and in statically compiled code alike, and `map['properties'] = value`
throws `groovy.lang.ReadOnlyPropertyException`.
+`metaClass` behaves the same way, and assigning to it replaces the metaclass
of the map.
Review Comment:
Assigning `metaClass` through a subscript was never a map put, so that part
didn't change in Groovy 5. On 4.0.33, `map['metaClass'] = 'x'` already throws
`GroovyCastException`, in dynamic code, in statically compiled code
(`Map<String, Object>` with a `String` key) and with an `Object`-typed key.
With a `MetaClass` value it replaces the metaclass, as on 5.1.3. What Groovy 5
did change for `metaClass` is the read: `map['metaClass']` and `map.metaClass`
return the entry on 4.0.33 and the metaclass on 5.1.3. The table in the PR
description has the same slip: its Groovy 4 column says `map['metaClass'] = v`
is a map put.
So on 7.x, the same `map[key] = value` sites this PR rewrites already throw
for a `metaClass` key. Only the `properties` half of #16675 is new in Groovy 5.
Not a blocker. Your call whether the `metaClass` part is worth a 7.x backport.
Suggested wording:
```suggestion
`metaClass` reads the same way. Assigning to it through a subscript was
never a map put: in Groovy 4 as in Groovy 5 it sets the metaclass of the map,
and a value that is not a `MetaClass` throws `groovy.lang.GroovyCastException`.
```
##########
grails-doc/src/en/guide/upgrading/upgrading80x.adoc:
##########
@@ -1592,6 +1592,32 @@ Subscript assignment, `attrs['gspTagSyntaxCall'] =
value`, now stores an attribu
NOTE: When adding a class that extends `grails.util.TypeConvertingMap`, do not
declare a no-argument `getX()` or `isX()` method on it, or the entry `x`
becomes unreachable for the same reason. Statically compiled callers bind
directly to the accessor, so the collision cannot be corrected at runtime.
+===== 28.4 Map entries named `properties` or `metaClass`
+
+The accessor-first resolution described above applies to every `Map`, not only
to a class that declares an accessor of its own, because every Groovy object
has the `properties` and `metaClass` properties.
+In Groovy 4 `map.properties` and `map['properties']` read the map entry.
+In Groovy 5 both return the Groovy meta-properties of the map object, in
dynamic and in statically compiled code alike, and `map['properties'] = value`
throws `groovy.lang.ReadOnlyPropertyException`.
+`metaClass` behaves the same way, and assigning to it replaces the metaclass
of the map.
+`class` and `empty` are exempt and still address the map entry.
+
+Such keys are common in data an application does not control; the `properties`
member of a GeoJSON `Feature` is one.
+Grails 8 stores and loads them through the `Map` API, so they round-trip
through a `Map` property or an embedded map of a GORM for MongoDB entity, and
through data binding of a `Map` property, whether the value arrives as a nested
map or as an indexed key such as `attributes[properties]`.
+
+Application code that reads one of these keys from a `Map` must call `get`:
+
+[source,groovy]
+----
+Map feature = place.geoJson // a Map property holding a GeoJSON Feature
+
+feature.get('properties') // the entry
+feature.properties // the meta-properties of the map object
+feature['properties'] // the same, on Groovy 5
+----
+
+The map a loaded GORM `Map` property comes back as is a dirty-checking wrapper
with accessors of its own, named `target`, `parent`, `property`, `originalSize`
and `assigned`.
+The wrapper resolves those five names to the map entry, in dotted and
subscript form, so an entry such as `parent` reads and writes through the
wrapper as it does through the plain map, and as it did in Grails 7.
+The one form that still reaches the accessor is dotted access from statically
compiled code, which binds at compile time as it always has; read such an entry
with `get` there.
Review Comment:
This paragraph is only true for GORM for MongoDB. `DirtyCheckingMap` comes
from the Mongo BSON decoders, and from `DirtyCheckingSupport.rewrap` once a
value is already wrapped. With GORM for Hibernate, the default, a loaded `Map`
property is Hibernate's `PersistentMap`. Its own accessors hide entries of the
same names on Groovy 5, and nothing redirects them.
I checked it with a throwaway spec in `grails-data-hibernate7-core`. It
saved a `Map<String, String> attrs` with `[key: 'K', value: 'V', owner: 'O',
role: 'R', size: 'S', name: 'N']`, cleared the session and reloaded it (the
class is `org.hibernate.collection.spi.PersistentMap`):
| `k` | `attrs.get(k)` | `attrs."$k"` and `attrs[k]` |
|---|---|---|
| `key` | `K` | `1` (the owner's id) |
| `value` | `V` | the map itself |
| `owner` | `O` | the owning entity |
| `role` | `R` | `'grails.gorm.tests.ZzProbeHolder.attrs'` |
| `size` | `S` | `6` |
| `name` | `N` | `N` |
On Groovy 4.0.33 every one of them reads the entry. Running `Introspector`
on `PersistentMap` (hibernate-core 7.4) also lists `session`, `dirty`,
`storedSnapshot`, `directlyAccessible`, `unreferenced`, `rowUpdatePossible`,
`elementRemoved`, `initializing`, `newlyInstantiated` and `temporalIdentifier`.
`key`, `value` and `size` are everyday keys in user data, so Hibernate
applications are more likely to hit this than Mongo ones.
A wrapper isn't an option for Hibernate, so I'm not asking for a code fix in
this PR. This change does already fix binding `attrs[key]` into an existing
`PersistentMap`, through the `put` calls in the binders. But the note shouldn't
tell a Hibernate user that a loaded `Map` property is the dirty-checking
wrapper, and they need the `get` advice for their own names:
```suggestion
With GORM for MongoDB, a loaded `Map` property comes back as a
dirty-checking wrapper with accessors of its own, named `target`, `parent`,
`property`, `originalSize` and `assigned`.
The wrapper resolves those five names to the map entry, in dotted and
subscript form, so an entry such as `parent` reads and writes through the
wrapper as it does through the plain map, and as it did in Grails 7.
The one form that still reaches the accessor is dotted access from
statically compiled code, which binds at compile time as it always has; read
such an entry with `get` there.
With GORM for Hibernate, a loaded `Map` property is Hibernate's
`PersistentMap`, and its accessors are not redirected.
In Groovy 5, `attrs.key`, `attrs['value']`, `attrs.owner`, `attrs.role`,
`attrs.size`, `attrs.session` and `attrs.dirty`, among others, return the state
of the collection rather than the entry; read such an entry with `get`.
```
##########
grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/MapKeysNamedLikeBeanPropertiesSpec.groovy:
##########
@@ -0,0 +1,128 @@
+/*
+ * 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.datastore.gorm.mongo
+
+import grails.persistence.Entity
+import org.apache.grails.data.mongo.core.GrailsDataMongoTckManager
+import org.apache.grails.data.testing.tck.base.GrailsDataTckSpec
+import org.bson.Document
+import org.bson.types.ObjectId
+import spock.lang.Issue
+
+/**
+ * A Map property whose entries are named like bean properties of the map, as
the {@code properties}
+ * member of a GeoJSON Feature is, is stored and loaded like any other entry.
Groovy 5 resolves a
+ * String subscript on a Map as property access, so the codecs go through the
Map API instead, and
+ * the dirty-checking wrapper a loaded map comes back as resolves its own
accessor names
+ * ({@code target}, {@code parent}, {@code property}, {@code originalSize},
{@code assigned}) to
+ * the entries.
+ */
+@Issue('https://github.com/apache/grails-core/issues/16675')
+class MapKeysNamedLikeBeanPropertiesSpec extends
GrailsDataTckSpec<GrailsDataMongoTckManager> {
+
+ static final Map FEATURE = [
+ type : 'Feature',
+ geometry : [type: 'Point', coordinates: [100.0d, 0.0d]],
+ properties : [name: 'Test', metaClass: 'nested'],
+ metaClass : 'top',
+ firstKey : 'first',
+ target : 't',
+ parent : 'p',
+ property : 'q',
+ originalSize: 42,
+ assigned : 'yes'
+ ]
+
+ void setupSpec() {
+ manager.registerDomainClasses(GeoFeature)
+ }
+
+ void "a Map property saved with a key named properties is read back with
the entry intact"() {
+ when: "an entity whose map holds keys named like bean properties is
saved and reloaded"
+ new GeoFeature(name: 'saved', geoJson: new
LinkedHashMap(FEATURE)).save(flush: true, failOnError: true)
+ manager.session.clear()
+ GeoFeature loaded = GeoFeature.findByName('saved')
+
+ then: "every entry survives the round trip"
+ loaded.geoJson.get('properties') == [name: 'Test', metaClass: 'nested']
+ loaded.geoJson.get('metaClass') == 'top'
+ loaded.geoJson.get('firstKey') == 'first'
+ loaded.geoJson == FEATURE
+
+ and: "entries named like the accessors of the loaded map's wrapper
read as entries"
+ loaded.geoJson.target == 't'
Review Comment:
These five assertions only exercise the new `getProperty`/`setProperty`
override if `loaded.geoJson` is a `DirtyCheckingMap`. A plain `LinkedHashMap`
has no `target`/`parent`/... accessors and passes them as well. It is the
wrapper today, because `BasicCollectionTypeDecoder` wraps a raw `Map` property,
but nothing here pins that. If decoding ever stopped wrapping, this block would
still pass and no longer test anything. Suggest asserting the premise (plus
`import org.grails.datastore.mapping.dirty.checking.DirtyCheckingMap`):
```suggestion
and: "entries named like the accessors of the loaded map's wrapper
read as entries"
loaded.geoJson instanceof DirtyCheckingMap
loaded.geoJson.target == 't'
```
--
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]