codeconsole commented on code in PR #16282:
URL: https://github.com/apache/grails-core/pull/16282#discussion_r3973849533


##########
grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/dirty/checking/DirtyCheckingSupport.groovy:
##########
@@ -130,4 +130,53 @@ class DirtyCheckingSupport {
         }
         return new DirtyCheckingCollection(coll, parent, property)
     }
+
+    /**
+     * Re-establishes change tracking when a tracked collection or map value 
is replaced
+     * through a generated dirty-checking setter.
+     *
+     * <p>Interception-based stores (MongoDB et al.) install the 
DirtyChecking* wrappers when an
+     * entity is decoded and rely on them exclusively — there is no flush-time 
snapshot
+     * comparison. The generated setter used to store whatever raw value it 
was handed, so
+     * reassigning a collection property replaced the tracked wrapper with a 
plain, untracked
+     * collection and every later in-place mutation became invisible to change 
tracking. The
+     * common defensive re-init {@code if (!entity.items) entity.items = []} 
triggered this on
+     * every load (an empty tracked collection is falsy in Groovy), and 
because the new empty
+     * collection equals the old one the assignment itself was never flagged 
either.
+     *
+     * <p>Tracking is only re-established, never introduced: when the value 
being replaced is
+     * not a tracked wrapper — a transient instance, or a store like Hibernate 
that performs its
+     * own snapshot-based dirty checking and never installs these wrappers — 
the new value is
+     * returned untouched, keeping this a no-op for those cases.
+     *
+     * @param parent The dirty-checkable owner
+     * @param property The property being assigned
+     * @param oldValue The value being replaced
+     * @param newValue The value being assigned
+     * @return The value to store: {@code newValue}, wrapped if it replaces a 
tracked value
+     */
+    static Object rewrap(DirtyCheckable parent, String property, Object 
oldValue, Object newValue) {

Review Comment:
   Took option (b), with one adjustment your repro made necessary: `Neo4jList 
extends DirtyCheckingList`, so an `instanceof` check against the generic 
classes would still have caught the Neo4j wrappers. `rewrap` now gates on the 
**exact class** of the replaced value being one of the five generic wrappers 
(`isGenericWrapper`). A store-specific wrapper or `PersistentCollection` being 
replaced stores the raw value, and the store's persister re-wraps it on save 
exactly as at merge-base.
   
   Added `HasManyReassignDirtyCheckingSpec` in grails-data-neo4j (embedded 
harness) running your exact reassign → save → remove → save → reload sequence — 
verified it fails with the previous `instanceof` gate and passes with the 
exact-class one. There's also a unit spec pinning that a `DirtyCheckingList` 
subclass as the old value is left alone. In `8581d5604f`.
   



##########
grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/dirty/checking/DirtyCheckingMap.groovy:
##########
@@ -34,12 +34,23 @@ class DirtyCheckingMap implements Map, 
DirtyCheckableCollection {
     final DirtyCheckable parent
     final String property
     final int originalSize
+    final boolean assigned

Review Comment:
   Covered rather than documented away, in `8581d5604f`: overrides for the 
`@Delegate`-generated default methods (`putIfAbsent`, `merge`, 
`compute`/`computeIfAbsent`/`computeIfPresent`, `replace`, 
`replace(k,old,new)`, `replaceAll`, `remove(k,v)`), and 
`entrySet()`/`keySet()`/`values()` now return tracking wrappers — which is what 
catches Groovy's `Map.removeAll(Closure)`/`retainAll(Closure)` (they iterate 
`entrySet()`), `keySet().remove(k)` and `values().removeIf { }`. 
`DirtyCheckingList.subList()` returns a tracking view as well, with a spec per 
path.
   
   The one Map path left untracked is `Map.Entry.setValue` during iteration; 
that's now called out explicitly in the docs update.
   



##########
grails-datamapping-core/src/main/groovy/org/grails/compiler/gorm/DirtyCheckingTransformer.groovy:
##########
@@ -94,6 +97,13 @@ class DirtyCheckingTransformer implements 
CompilationUnitAware {
     public static final ClassNode DIRTY_CHECKED_PROPERTY_CLASS_NODE = 
ClassHelper.make(DirtyCheckedProperty)
     public static final ClassNode DIRTY_CHECK_CLASS_NODE = 
ClassHelper.make(DirtyCheck)
     public static final AnnotationNode DIRTY_CHECKED_PROPERTY_ANNOTATION_NODE 
= new AnnotationNode(DIRTY_CHECKED_PROPERTY_CLASS_NODE)
+    private static final ClassNode DIRTY_CHECKING_SUPPORT_CLASS_NODE = 
ClassHelper.make(DirtyCheckingSupport)
+    // Interface-typed collection properties whose generated setter 
re-establishes change
+    // tracking via DirtyCheckingSupport.rewrap. Restricted to the exact 
interfaces the
+    // DirtyChecking* wrappers implement so the cast in the generated setter 
is always valid.
+    private static final Set<String> REWRAPPABLE_TYPE_NAMES = [

Review Comment:
   Done in `8581d5604f`: `SortedSet.name` added here, and both `wrap()` and 
`rewrap()` now produce `DirtyCheckingSortedSet` ahead of the `Set` check — so a 
`TreeSet` keeps its `SortedSet` API behind the wrapper on `Set`-declared 
properties too (the `wrap()` blind spot you pointed out). Specs cover the 
SortedSet-typed reassignment and `wrap(TreeSet)` returning the SortedSet 
wrapper.
   



##########
grails-datamapping-core/src/main/groovy/org/grails/compiler/gorm/DirtyCheckingTransformer.groovy:
##########
@@ -364,7 +374,21 @@ class DirtyCheckingTransformer implements 
CompilationUnitAware {
             final BlockStatement setterBody = new BlockStatement()
             MethodCallExpression markDirtyMethodCall = 
createMarkDirtyMethodCall(markDirtyMethodNode, propertyName, setterParameter)
             setterBody.addStatement(stmt(markDirtyMethodCall))
-            setterBody.addStatement(assignS(propX(varX('this'), fieldName), 
varX(setterParameter)))
+            // Collection/Map-typed properties assign through 
DirtyCheckingSupport.rewrap so a
+            // value that replaces a tracked wrapper (installed by an 
interception-based store's
+            // decoder) stays tracked. Without this, `entity.items = []` over 
a tracked list
+            // stored a plain untracked collection and every later in-place 
mutation was
+            // invisible to change tracking. rewrap is a no-op when the old 
value was untracked,
+            // so stores with their own dirty checking (Hibernate) are 
unaffected.
+            Expression assignedValue
+            if (REWRAPPABLE_TYPE_NAMES.contains(returnType.name)) {

Review Comment:
   Left for a follow-up as you suggested, and now stated explicitly in the docs 
update: the rewritten caveat in `objectMapping/dirtyChecking.adoc` lists 
hand-written setters first among the paths that still require an explicit 
`markDirty(propertyName)` — "a hand-written setter … stores the value it is 
given without re-wrapping, so a collection assigned through it loses tracking 
until the next load."
   



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

Reply via email to