This is an automated email from the ASF dual-hosted git repository.

jamesfredley pushed a commit to branch fix/binddata-null-missing-stale-data
in repository https://gitbox.apache.org/repos/asf/grails-core.git

commit 6309f18a1fed026420dc71bc700341ac55d4897e
Author: James Fredley <[email protected]>
AuthorDate: Thu Jul 9 19:34:15 2026 -0400

    Add opt-in nullMissing support to bindData for stale-data clearing
    
    When nullMissing is true and an include allowlist is provided, omitted
    allowlisted properties are set to null. Default remains leave-unchanged.
    
    Assisted-by: Sisyphus:xai/grok-4.5 [gpt-coding]
---
 .../src/en/guide/upgrading/upgrading80x.adoc       |   3 +
 grails-doc/src/en/ref/Controllers/bindData.adoc    |   7 +-
 .../grails/web/servlet/BindDataMethodTests.groovy  | 225 ++++++++
 .../grails/web/databinding/DataBinder.groovy       |   3 +-
 .../grails/web/databinding/DataBindingUtils.java   | 602 ++++++++++++++++++++-
 5 files changed, 837 insertions(+), 3 deletions(-)

diff --git a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc 
b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc
index 37e6b139e9..85a41fabdc 100644
--- a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc
+++ b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc
@@ -815,6 +815,9 @@ The Spring annotations still work, so this is non-blocking, 
but new code should
 * **Namespaced link generation is namespace-aware.**
 When a link, form action, pagination link, sortable column link, redirect, 
chain, or include targets a controller without an explicit `namespace`, Grails 
now resolves the namespace automatically. In the normal case, where only one 
controller has the target name, `controller` and `action` generate the correct 
namespaced or non-namespaced URL. Ambiguity only occurs when multiple 
controllers share the same name. In that case, specify `namespace` to choose 
the target explicitly. Pass `namesp [...]
 
+* **`bindData` can clear omitted included fields.**
+`bindData(target, source, [include: [...], nullMissing: true])` now assigns 
`null` to included properties that are absent from the binding source. The 
behavior is opt-in, requires an `include` list, and does not apply globally. 
Existing `bindData` calls without `nullMissing: true` keep omitted fields 
unchanged.
+
 ==== 21. Tag Library Test Cleanup Changes
 
 Grails 8 removes the `purgeTagLibMetaClass` test hook used by some web and 
TagLib unit tests.
diff --git a/grails-doc/src/en/ref/Controllers/bindData.adoc 
b/grails-doc/src/en/ref/Controllers/bindData.adoc
index a809e63528..9081584336 100644
--- a/grails-doc/src/en/ref/Controllers/bindData.adoc
+++ b/grails-doc/src/en/ref/Controllers/bindData.adoc
@@ -45,6 +45,9 @@ bindData(target, params, [exclude: ['firstName', 
'lastName']], "author")
 
 // using inclusive map
 bindData(target, params, [include: ['firstName', 'lastName']], "author")
+
+// clear included properties omitted from the source
+bindData(target, params, [include: ['firstName', 'lastName'], nullMissing: 
true])
 ----
 
 
@@ -57,11 +60,13 @@ Arguments:
 
 * `target` - The target object to bind to
 * `params` - A `Map` of source parameters, often the link:params.html[params] 
object when used in a controller
-* `includesExcludes` - (Optional) A map with 'include' and/or 'exclude' lists 
containing the names of properties to either include or exclude.
+* `includesExcludes` - (Optional) A map with 'include' and/or 'exclude' lists 
containing the names of properties to either include or exclude. Set 
`nullMissing: true` with an `include` list to assign `null` to included 
properties that are omitted from the binding source.
 * `prefix` - (Optional) A string representing a prefix to use to filter 
parameters. The method will automatically append a '.' when matching the prefix 
to parameters, so you can use 'author' to filter for parameters such as 
'author.name'.
 
 NOTE: Note that if an empty List or no List is provided as a value for the 
`include` parameter then all statically typed instance properties will be 
subject to binding if they are not explicitly excluded. See the 
link:{constraintsRefFromRef}bindable.html[bindable] constraint documentation 
for more information on how to control what is bindable and what is not.
 
+`nullMissing` is opt-in and only applies when an `include` list is provided. 
This is useful for update forms where an omitted allowed field should clear an 
existing value instead of leaving stale persisted data. Excluded properties are 
not cleared.
+
 The underlying implementation uses Spring's Data Binding framework. If the 
target is a domain class, type conversion errors are stored in the `errors` 
property of the domain class.
 
 Refer to the section on link:{guidePath}theWebLayer.html#dataBinding[data 
binding] in the user guide for more information.
diff --git 
a/grails-test-suite-web/src/test/groovy/org/grails/web/servlet/BindDataMethodTests.groovy
 
b/grails-test-suite-web/src/test/groovy/org/grails/web/servlet/BindDataMethodTests.groovy
index d1b9fbfee6..c6cd4c272b 100644
--- 
a/grails-test-suite-web/src/test/groovy/org/grails/web/servlet/BindDataMethodTests.groovy
+++ 
b/grails-test-suite-web/src/test/groovy/org/grails/web/servlet/BindDataMethodTests.groovy
@@ -120,6 +120,124 @@ class BindDataMethodTests extends Specification 
implements ControllerUnitTest<Bi
         target.name == 'Lee Butts'
         target.email == null
     }
+
+    void 'Test bindData With Null Missing Clears Omitted Included Field'() {
+        when:
+        def model = controller.bindWithNullMissing()
+        def target = model.target
+
+        then:
+        target.name == 'Updated Name'
+        target.email == null
+    }
+
+    void 'Test bindData Without Null Missing Leaves Omitted Included Field'() {
+        when:
+        def model = controller.bindWithoutNullMissing()
+        def target = model.target
+
+        then:
+        target.name == 'Updated Name'
+        target.email == '[email protected]'
+    }
+
+    void 'Test bindData With Null Missing Without Include Leaves Omitted 
Field'() {
+        when:
+        def model = controller.bindWithNullMissingAndNoInclude()
+        def target = model.target
+
+        then:
+        target.name == 'Updated Name'
+        target.email == '[email protected]'
+    }
+
+    void 'Test bindData With Null Missing Leaves Excluded Omitted Field'() {
+        when:
+        def model = controller.bindWithNullMissingAndExclude()
+        def target = model.target
+
+        then:
+        target.name == 'Updated Name'
+        target.email == '[email protected]'
+    }
+
+    void 'Test bindData With Null Missing Leaves Root Excluded Nested Field'() 
{
+        when:
+        def model = controller.bindWithNullMissingAndRootExclude()
+        def target = model.target
+
+        then:
+        target.address.country == 'Existing Country'
+    }
+
+    void 'Test bindData With Null Missing Accepts Blank Included Field'() {
+        when:
+        def model = controller.bindWithNullMissingAndBlankValue()
+        def target = model.target
+
+        then:
+        target.name == 'Updated Name'
+        target.email == null
+    }
+
+    void 'Test bindData With Null Missing Handles Nested Indexed Paths'() {
+        when:
+        def model = controller.bindWithNullMissingAndIndexedPath()
+        def target = model.target
+
+        then:
+        target.children[0].name == null
+        target.children[1].name == 'Second Child'
+    }
+
+    void 'Test bindData With Null Missing Handles Prefixed Nested Indexed 
Paths'() {
+        when:
+        def model = controller.bindWithNullMissingAndPrefixedIndexedPath()
+        def target = model.target
+
+        then:
+        target.children[0].name == null
+        target.children[1].name == 'Second Child'
+    }
+
+    void 'Test bindData With Null Missing Handles Map Indexed Paths'() {
+        when:
+        def model = controller.bindWithNullMissingAndMapPath()
+        def target = model.target
+
+        then:
+        target.contacts.home.value == null
+    }
+
+    void 'Test bindData With Null Missing Honors Bindable False'() {
+        when:
+        def model = controller.bindWithNullMissingAndBindableFalse()
+        def target = model.target
+
+        then:
+        target.visible == 'updated'
+        target.protectedValue == 'keep-me'
+    }
+
+    void 'Test bindData With Null Missing Honors Nested Bindable False'() {
+        when:
+        def model = controller.bindWithNullMissingAndNestedBindableFalse()
+        def target = model.target
+
+        then:
+        target.protectedAddress.country == null
+        target.protectedAddress.secret == 'keep-me'
+    }
+
+    void 'Test bindData With Null Missing Honors Collection Element Bindable 
False'() {
+        when:
+        def model = 
controller.bindWithNullMissingAndCollectionElementBindableFalse()
+        def target = model.target
+
+        then:
+        target.protectedChildren[0].name == 'Existing Child'
+        target.protectedChildren[0].secret == 'keep-me'
+    }
 }
 
 @Artefact('Controller')
@@ -183,14 +301,121 @@ class BindingController {
         bindData target, [ 'mark.name' : 'Marc Palmer', 'mark.email' : 
'dontwantthis', 'lee.name': 'Lee Butts', 'lee.email': '[email protected]'], 
disallowed, filter
         [target: target]
     }
+
+    def bindWithNullMissing() {
+        def target = new CommandObject(name: 'Existing Name', email: 
'[email protected]')
+        bindData target, [name: 'Updated Name'], [include: ['name', 'email'], 
nullMissing: true]
+        [target: target]
+    }
+
+    def bindWithoutNullMissing() {
+        def target = new CommandObject(name: 'Existing Name', email: 
'[email protected]')
+        bindData target, [name: 'Updated Name'], [include: ['name', 'email']]
+        [target: target]
+    }
+
+    def bindWithNullMissingAndNoInclude() {
+        def target = new CommandObject(name: 'Existing Name', email: 
'[email protected]')
+        bindData target, [name: 'Updated Name'], [nullMissing: true]
+        [target: target]
+    }
+
+    def bindWithNullMissingAndExclude() {
+        def target = new CommandObject(name: 'Existing Name', email: 
'[email protected]')
+        bindData target, [name: 'Updated Name'], [include: ['name', 'email'], 
exclude: ['email'], nullMissing: true]
+        [target: target]
+    }
+
+    def bindWithNullMissingAndRootExclude() {
+        def target = new CommandObject(address: new Address(country: 'Existing 
Country'))
+        bindData target, [name: 'Updated Name'], [include: 
['address.country'], exclude: ['address'], nullMissing: true]
+        [target: target]
+    }
+
+    def bindWithNullMissingAndBlankValue() {
+        def target = new CommandObject(name: 'Existing Name', email: 
'[email protected]')
+        bindData target, [name: 'Updated Name', email: ''], [include: ['name', 
'email'], nullMissing: true]
+        [target: target]
+    }
+
+    def bindWithNullMissingAndIndexedPath() {
+        def target = new CommandObject(children: [new Child(name: 'First 
Child'), new Child(name: 'Second Child')])
+        bindData target, ['children[0].age': '7'], [include: 
['children.name'], nullMissing: true]
+        [target: target]
+    }
+
+    def bindWithNullMissingAndPrefixedIndexedPath() {
+        def target = new CommandObject(children: [new Child(name: 'First 
Child'), new Child(name: 'Second Child')])
+        bindData target, ['lee.children[0].age': '7'], [include: 
['children.name'], nullMissing: true], 'lee'
+        [target: target]
+    }
+
+    def bindWithNullMissingAndMapPath() {
+        def target = new CommandObject(contacts: [home: new Contact(value: 
'555-1234')])
+        bindData target, ['contacts[home].type': 'phone'], [include: 
['contacts[home].value'], nullMissing: true]
+        [target: target]
+    }
+
+    def bindWithNullMissingAndBindableFalse() {
+        def target = new ProtectedCommandObject(visible: 'old', 
protectedValue: 'keep-me')
+        bindData target, [visible: 'updated'], [include: ['visible', 
'protectedValue'], nullMissing: true]
+        [target: target]
+    }
+
+    def bindWithNullMissingAndNestedBindableFalse() {
+        def target = new CommandObject(protectedAddress: new 
ProtectedAddress(country: 'Existing Country', secret: 'keep-me'))
+        bindData target, [name: 'Updated Name'], [include: 
['protectedAddress.country', 'protectedAddress.secret'], nullMissing: true]
+        [target: target]
+    }
+
+    def bindWithNullMissingAndCollectionElementBindableFalse() {
+        def target = new CommandObject(protectedChildren: [new 
ProtectedChild(name: 'Existing Child', secret: 'keep-me')])
+        bindData target, ['protectedChildren[0].name': 'Updated Child'], 
[include: ['protectedChildren.name', 'protectedChildren.secret'], nullMissing: 
true]
+        [target: target]
+    }
 }
 
 class CommandObject {
     String name
     String email
     Address address = new Address()
+    List<Child> children = []
+    Map<String, Contact> contacts = [:]
+    ProtectedAddress protectedAddress = new ProtectedAddress()
+    List<ProtectedChild> protectedChildren = []
 }
 
 class Address {
     String country
 }
+
+class Child {
+    String name
+    Integer age
+}
+
+class Contact {
+    String type
+    String value
+}
+
+class ProtectedCommandObject {
+    public static final List<String> $defaultDatabindingWhiteList = ['visible']
+
+    String visible
+    String protectedValue
+}
+
+class ProtectedAddress {
+    public static final List<String> $defaultDatabindingWhiteList = ['country']
+
+    String country
+    String secret
+}
+
+class ProtectedChild {
+    public static final List<String> $defaultDatabindingWhiteList = ['name']
+
+    String name
+    String secret
+}
diff --git 
a/grails-web-databinding/src/main/groovy/grails/web/databinding/DataBinder.groovy
 
b/grails-web-databinding/src/main/groovy/grails/web/databinding/DataBinder.groovy
index a920ce5f61..80cdd26d61 100644
--- 
a/grails-web-databinding/src/main/groovy/grails/web/databinding/DataBinder.groovy
+++ 
b/grails-web-databinding/src/main/groovy/grails/web/databinding/DataBinder.groovy
@@ -67,7 +67,8 @@ trait DataBinder {
     BindingResult bindData(target, bindingSource, Map includeExclude, String 
filter) {
         List includeList = convertToListIfCharSequence(includeExclude?.include)
         List excludeList = convertToListIfCharSequence(includeExclude?.exclude)
-        DataBindingUtils.bindObjectToInstance(target, bindingSource, 
includeList, excludeList, filter)
+        boolean nullMissing = includeExclude?.nullMissing == true
+        DataBindingUtils.bindObjectToInstance(target, bindingSource, 
includeList, excludeList, filter, nullMissing)
     }
 
     @Generated
diff --git 
a/grails-web-databinding/src/main/groovy/grails/web/databinding/DataBindingUtils.java
 
b/grails-web-databinding/src/main/groovy/grails/web/databinding/DataBindingUtils.java
index 422a3bf99c..7386a4bf9a 100644
--- 
a/grails-web-databinding/src/main/groovy/grails/web/databinding/DataBindingUtils.java
+++ 
b/grails-web-databinding/src/main/groovy/grails/web/databinding/DataBindingUtils.java
@@ -21,15 +21,21 @@ package grails.web.databinding;
 import java.lang.reflect.Field;
 import java.lang.reflect.InvocationTargetException;
 import java.lang.reflect.Modifier;
+import java.lang.reflect.ParameterizedType;
+import java.lang.reflect.Type;
+import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.HashMap;
+import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 import java.util.concurrent.ConcurrentHashMap;
 
 import groovy.lang.GroovySystem;
 import groovy.lang.MetaClass;
+import groovy.lang.MetaProperty;
 
 import jakarta.servlet.ServletRequest;
 
@@ -212,6 +218,11 @@ public class DataBindingUtils {
      * @return A BindingResult if there were errors or null if it was 
successful
      */
     public static BindingResult bindObjectToInstance(Object object, Object 
source, List include, List exclude, String filter) {
+        return bindObjectToInstance(object, source, include, exclude, filter, 
false);
+    }
+
+    public static BindingResult bindObjectToInstance(Object object, Object 
source, List include, List exclude, String filter, boolean nullMissing) {
+        boolean explicitInclude = include != null;
         if (include == null && exclude == null) {
             include = getBindingIncludeList(object);
         }
@@ -224,7 +235,7 @@ public class DataBindingUtils {
                 //no-op
             }
         }
-        return bindObjectToDomainInstance(entity, object, source, include, 
exclude, filter);
+        return bindObjectToDomainInstance(entity, object, source, include, 
exclude, filter, nullMissing && explicitInclude);
     }
 
     /**
@@ -244,6 +255,12 @@ public class DataBindingUtils {
     @SuppressWarnings("unchecked")
     public static BindingResult bindObjectToDomainInstance(PersistentEntity 
entity, Object object,
                                                            Object source, List 
include, List exclude, String filter) {
+        return bindObjectToDomainInstance(entity, object, source, include, 
exclude, filter, false);
+    }
+
+    @SuppressWarnings("unchecked")
+    public static BindingResult bindObjectToDomainInstance(PersistentEntity 
entity, Object object,
+                                                           Object source, List 
include, List exclude, String filter, boolean nullMissing) {
         BindingResult bindingResult = null;
         GrailsApplication grailsApplication = Holders.findApplication();
 
@@ -251,6 +268,9 @@ public class DataBindingUtils {
             final DataBindingSource bindingSource = 
createDataBindingSource(grailsApplication, object.getClass(), source);
             final DataBinder grailsWebDataBinder = 
getGrailsWebDataBinder(grailsApplication);
             grailsWebDataBinder.bind(object, bindingSource, filter, include, 
exclude);
+            if (nullMissing && include != null && !include.isEmpty()) {
+                assignNullToMissingIncludedProperties(object, bindingSource, 
include, exclude, filter);
+            }
         } catch (InvalidRequestBodyException e) {
             String messageCode = "invalidRequestBody";
             Class objectType = object.getClass();
@@ -300,6 +320,586 @@ public class DataBindingUtils {
         return bindingResult;
     }
 
+    private static void assignNullToMissingIncludedProperties(Object object, 
DataBindingSource bindingSource, List include, List exclude, String filter) {
+        for (Object includedProperty : include) {
+            if (includedProperty instanceof CharSequence) {
+                String propertyName = includedProperty.toString();
+                if (propertyName.indexOf('*') == -1 && 
!isExcludedProperty(propertyName, exclude) && isBindingAllowed(object, 
propertyName)) {
+                    if (assignNullToMissingIndexedProperties(object, 
bindingSource, propertyName, filter)) {
+                        continue;
+                    }
+                    if (!bindingSourceContainsProperty(bindingSource, 
propertyName, filter)) {
+                        setPropertyToNull(object, propertyName);
+                    }
+                }
+            }
+        }
+    }
+
+    private static boolean isExcludedProperty(String propertyName, List 
exclude) {
+        if (exclude == null) {
+            return false;
+        }
+        for (Object excludedProperty : exclude) {
+            if (excludedProperty instanceof CharSequence) {
+                String excludedPropertyName = excludedProperty.toString();
+                if (propertyName.equals(excludedPropertyName) || 
propertyName.startsWith(excludedPropertyName + ".") || 
rootPropertyName(propertyName).equals(excludedPropertyName)) {
+                    return true;
+                }
+            }
+        }
+        return false;
+    }
+
+    private static boolean isBindingAllowed(Object object, String 
propertyName) {
+        if (object == null) {
+            return false;
+        }
+
+        if (!isPropertyAllowedByWhitelist(object, propertyName)) {
+            return false;
+        }
+
+        int separator = propertyPathSeparator(propertyName);
+        if (separator == -1) {
+            return true;
+        }
+
+        Object nestedObject = getPropertyValue(object, 
propertyName.substring(0, separator));
+        String nestedPropertyName = propertyName.substring(separator + 1);
+        if (nestedObject instanceof Collection) {
+            for (Object item : (Collection) nestedObject) {
+                if (item != null && !isBindingAllowed(item, 
nestedPropertyName)) {
+                    return false;
+                }
+            }
+            return true;
+        }
+        if (nestedObject instanceof Map) {
+            for (Object value : ((Map) nestedObject).values()) {
+                if (value != null && !isBindingAllowed(value, 
nestedPropertyName)) {
+                    return false;
+                }
+            }
+            return true;
+        }
+        return nestedObject == null || isBindingAllowed(nestedObject, 
nestedPropertyName);
+    }
+
+    private static boolean isPropertyAllowedByWhitelist(Object object, String 
propertyName) {
+        List bindingIncludeList = getBindingIncludeList(object);
+        if (bindingIncludeList == null || bindingIncludeList.isEmpty()) {
+            return true;
+        }
+        for (Object includedProperty : bindingIncludeList) {
+            if (includedProperty instanceof CharSequence) {
+                String includedPropertyName = includedProperty.toString();
+                if (propertyName.equals(includedPropertyName) || 
includedPropertyName.startsWith(propertyName + ".")) {
+                    return true;
+                }
+            }
+        }
+        return false;
+    }
+
+    private static String rootPropertyName(String propertyName) {
+        int dotIndex = propertyName.indexOf('.');
+        int bracketIndex = propertyName.indexOf('[');
+        int endIndex = -1;
+        if (dotIndex > -1 && bracketIndex > -1) {
+            endIndex = Math.min(dotIndex, bracketIndex);
+        }
+        else if (dotIndex > -1) {
+            endIndex = dotIndex;
+        }
+        else if (bracketIndex > -1) {
+            endIndex = bracketIndex;
+        }
+        return endIndex == -1 ? propertyName : propertyName.substring(0, 
endIndex);
+    }
+
+    private static boolean assignNullToMissingIndexedProperties(Object object, 
DataBindingSource bindingSource, String propertyName, String filter) {
+        String sourcePropertyName = filter == null ? propertyName : filter + 
"." + propertyName;
+        return assignNullToMissingIndexedProperties(object, bindingSource, 
BLANK, sourcePropertyName, propertyName);
+    }
+
+    private static boolean assignNullToMissingIndexedProperties(Object object, 
Object source, String targetPathPrefix, String sourcePropertyName, String 
targetPropertyName) {
+        int sourceSeparator = propertyPathSeparator(sourcePropertyName);
+        int targetSeparator = propertyPathSeparator(targetPropertyName);
+        if (sourceSeparator == -1 || targetSeparator == -1) {
+            return false;
+        }
+
+        String sourceRootPropertyName = sourcePropertyName.substring(0, 
sourceSeparator);
+        String targetRootPropertyName = targetPropertyName.substring(0, 
targetSeparator);
+        String nestedSourcePropertyName = 
sourcePropertyName.substring(sourceSeparator + 1);
+        String nestedTargetPropertyName = 
targetPropertyName.substring(targetSeparator + 1);
+        String[] sourceSegments = splitPropertyPath(sourcePropertyName);
+        String[] targetSegments = splitPropertyPath(targetPropertyName);
+        if (sourceSegments.length > targetSegments.length) {
+            if (containsSourceProperty(source, sourceRootPropertyName)) {
+                return assignNullToMissingIndexedProperties(object, 
getSourcePropertyValue(source, sourceRootPropertyName), targetPathPrefix, 
nestedSourcePropertyName, targetPropertyName);
+            }
+            int sourceRootSegmentCount = sourceSegments.length - 
targetSegments.length + 1;
+            sourceRootPropertyName = joinPropertyPath(sourceSegments, 0, 
sourceRootSegmentCount);
+            nestedSourcePropertyName = joinPropertyPath(sourceSegments, 
sourceRootSegmentCount, sourceSegments.length);
+            targetRootPropertyName = targetSegments[0];
+            nestedTargetPropertyName = joinPropertyPath(targetSegments, 1, 
targetSegments.length);
+        }
+
+        if (containsSourceProperty(source, sourceRootPropertyName)) {
+            Object nestedSource = getSourcePropertyValue(source, 
sourceRootPropertyName);
+            if (nestedSource instanceof Collection) {
+                return assignNullToMissingCollectionProperties(object, 
(Collection) nestedSource, targetPathPrefix, targetRootPropertyName, 
nestedSourcePropertyName, nestedTargetPropertyName);
+            }
+            Object targetObject = getTargetObject(object, targetPathPrefix);
+            if (nestedSource instanceof Map && hasNestedSourceEntries((Map) 
nestedSource) && shouldExpandMapEntries(targetObject, targetObject == null ? 
null : targetObject.getClass(), targetRootPropertyName)) {
+                return assignNullToMissingMapProperties(object, (Map) 
nestedSource, targetPathPrefix, targetRootPropertyName, 
nestedSourcePropertyName, nestedTargetPropertyName);
+            }
+        }
+
+        boolean indexed = false;
+        String indexedSourcePropertyPrefix = sourceRootPropertyName + "[";
+        for (String indexedSourcePropertyName : 
getIndexedSourcePropertyNames(source, indexedSourcePropertyPrefix)) {
+            indexed = true;
+            String targetIndexedPropertyName = 
appendPropertyPath(targetPathPrefix, targetRootPropertyName + 
indexedSourcePropertyName.substring(sourceRootPropertyName.length()));
+            if (containsSourceProperty(source, indexedSourcePropertyName)) {
+                Object nestedSource = getSourcePropertyValue(source, 
indexedSourcePropertyName);
+                if (!assignNullToMissingIndexedProperties(object, 
nestedSource, targetIndexedPropertyName, nestedSourcePropertyName, 
nestedTargetPropertyName) && !containsPropertyPath(nestedSource, 
nestedSourcePropertyName)) {
+                    setPropertyToNull(object, targetIndexedPropertyName + "." 
+ nestedTargetPropertyName);
+                }
+            }
+            else if (!containsPropertyPath(source, indexedSourcePropertyName + 
"." + nestedSourcePropertyName)) {
+                setPropertyToNull(object, targetIndexedPropertyName + "." + 
nestedTargetPropertyName);
+            }
+        }
+        return indexed;
+    }
+
+    private static boolean assignNullToMissingCollectionProperties(Object 
object, Collection collection, String targetPathPrefix, String 
targetRootPropertyName, String nestedSourcePropertyName, String 
nestedTargetPropertyName) {
+        int index = 0;
+        for (Object item : collection) {
+            String targetIndexedPropertyName = 
appendPropertyPath(targetPathPrefix, targetRootPropertyName + "[" + index + 
"]");
+            assignNullToMissingNestedProperty(object, item, 
targetIndexedPropertyName, nestedSourcePropertyName, nestedTargetPropertyName);
+            index++;
+        }
+        return true;
+    }
+
+    private static boolean assignNullToMissingMapProperties(Object object, Map 
map, String targetPathPrefix, String targetRootPropertyName, String 
nestedSourcePropertyName, String nestedTargetPropertyName) {
+        for (Object entryObject : map.entrySet()) {
+            Map.Entry entry = (Map.Entry) entryObject;
+            String targetIndexedPropertyName = 
appendPropertyPath(targetPathPrefix, targetRootPropertyName + "[" + 
entry.getKey() + "]");
+            assignNullToMissingNestedProperty(object, entry.getValue(), 
targetIndexedPropertyName, nestedSourcePropertyName, nestedTargetPropertyName);
+        }
+        return true;
+    }
+
+    private static void assignNullToMissingNestedProperty(Object object, 
Object nestedSource, String targetIndexedPropertyName, String 
nestedSourcePropertyName, String nestedTargetPropertyName) {
+        if (!assignNullToMissingIndexedProperties(object, nestedSource, 
targetIndexedPropertyName, nestedSourcePropertyName, nestedTargetPropertyName) 
&& !containsPropertyPath(nestedSource, nestedSourcePropertyName)) {
+            setPropertyToNull(object, targetIndexedPropertyName + "." + 
nestedTargetPropertyName);
+        }
+    }
+
+    private static String joinPropertyPath(String[] segments, int start, int 
end) {
+        StringBuilder propertyPath = new StringBuilder();
+        for (int i = start; i < end; i++) {
+            if (propertyPath.length() > 0) {
+                propertyPath.append('.');
+            }
+            propertyPath.append(segments[i]);
+        }
+        return propertyPath.toString();
+    }
+
+    private static boolean bindingSourceContainsProperty(DataBindingSource 
bindingSource, String propertyName, String filter) {
+        String sourcePropertyName = filter == null ? propertyName : filter + 
"." + propertyName;
+        int exactPrefixSegments = filter == null ? 0 : 
splitPropertyPath(filter).length;
+        return containsPropertyPath(bindingSource, sourcePropertyName, 
exactPrefixSegments) || containsPropertyPath(bindingSource, 
checkboxMarkerPropertyName(sourcePropertyName), exactPrefixSegments);
+    }
+
+    private static boolean containsPropertyPath(Object source, String 
propertyName) {
+        return containsPropertyPath(source, propertyName, 0);
+    }
+
+    private static boolean containsPropertyPath(Object source, String 
propertyName, int exactPrefixSegments) {
+        if (containsSourceProperty(source, propertyName)) {
+            return true;
+        }
+        if (containsIndexedPropertyPath(source, propertyName, 
exactPrefixSegments)) {
+            return true;
+        }
+        int separator = propertyPathSeparator(propertyName);
+        if (separator == -1) {
+            return false;
+        }
+        String rootPropertyName = propertyName.substring(0, separator);
+        if (!containsSourceProperty(source, rootPropertyName)) {
+            return containsIndexedNestedPropertyPath(source, rootPropertyName, 
propertyName.substring(separator + 1));
+        }
+        Object nestedSource = getSourcePropertyValue(source, rootPropertyName);
+        String nestedPropertyName = propertyName.substring(separator + 1);
+        if (nestedSource instanceof Collection) {
+            for (Object item : (Collection) nestedSource) {
+                if (containsPropertyPath(item, nestedPropertyName)) {
+                    return true;
+                }
+            }
+            return false;
+        }
+        return containsPropertyPath(nestedSource, nestedPropertyName);
+    }
+
+    private static boolean containsIndexedNestedPropertyPath(Object source, 
String rootPropertyName, String nestedPropertyName) {
+        String indexedSourcePropertyPrefix = rootPropertyName + "[";
+        for (String indexedSourcePropertyName : 
getIndexedSourcePropertyNames(source, indexedSourcePropertyPrefix)) {
+            if (containsSourceProperty(source, indexedSourcePropertyName) && 
containsPropertyPath(getSourcePropertyValue(source, indexedSourcePropertyName), 
nestedPropertyName)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private static boolean containsIndexedPropertyPath(Object source, String 
propertyName, int exactPrefixSegments) {
+        for (String indexedPropertyName : getSourcePropertyNames(source)) {
+            if (indexedPropertyPathMatches(indexedPropertyName, propertyName, 
exactPrefixSegments)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private static int propertyPathSeparator(String propertyName) {
+        return propertyPathSeparator(propertyName, false);
+    }
+
+    private static int propertyPathSeparator(String propertyName, boolean 
last) {
+        int separator = -1;
+        int bracketDepth = 0;
+        for (int i = 0; i < propertyName.length(); i++) {
+            char character = propertyName.charAt(i);
+            if (character == '[') {
+                bracketDepth++;
+            }
+            else if (character == ']' && bracketDepth > 0) {
+                bracketDepth--;
+            }
+            else if (character == '.' && bracketDepth == 0) {
+                if (!last) {
+                    return i;
+                }
+                separator = i;
+            }
+        }
+        return separator;
+    }
+
+    private static String[] splitPropertyPath(String propertyName) {
+        List<String> segments = new ArrayList<>();
+        StringBuilder segment = new StringBuilder();
+        int bracketDepth = 0;
+        for (int i = 0; i < propertyName.length(); i++) {
+            char character = propertyName.charAt(i);
+            if (character == '.' && bracketDepth == 0) {
+                segments.add(segment.toString());
+                segment.setLength(0);
+            }
+            else {
+                if (character == '[') {
+                    bracketDepth++;
+                }
+                else if (character == ']' && bracketDepth > 0) {
+                    bracketDepth--;
+                }
+                segment.append(character);
+            }
+        }
+        segments.add(segment.toString());
+        return segments.toArray(new String[0]);
+    }
+
+    private static boolean indexedPropertyPathMatches(String 
indexedPropertyName, String propertyName, int exactPrefixSegments) {
+        String[] indexedPropertySegments = 
splitPropertyPath(indexedPropertyName);
+        String[] propertySegments = splitPropertyPath(propertyName);
+        if (indexedPropertySegments.length != propertySegments.length) {
+            return false;
+        }
+        for (int i = 0; i < propertySegments.length; i++) {
+            if (indexedPropertySegments[i].equals(propertySegments[i])) {
+                continue;
+            }
+            if (i < exactPrefixSegments) {
+                return false;
+            }
+            if (!indexedSegmentMatches(indexedPropertySegments[i], 
propertySegments[i])) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    private static boolean indexedSegmentMatches(String indexedSegment, String 
segment) {
+        return indexedSegment.startsWith(segment + "[") && 
indexedSegment.endsWith("]");
+    }
+
+    private static Set<String> getIndexedSourcePropertyNames(Object source, 
String indexedSourcePropertyPrefix) {
+        Set<String> indexedSourcePropertyNames = new LinkedHashSet<>();
+        for (String propertyName : getSourcePropertyNames(source)) {
+            if (propertyName.startsWith(indexedSourcePropertyPrefix)) {
+                int closingIndex = propertyName.indexOf(']', 
indexedSourcePropertyPrefix.length());
+                if (closingIndex > -1) {
+                    indexedSourcePropertyNames.add(propertyName.substring(0, 
closingIndex + 1));
+                }
+            }
+        }
+        return indexedSourcePropertyNames;
+    }
+
+    private static boolean containsSourceProperty(Object source, String 
propertyName) {
+        if (source instanceof DataBindingSource) {
+            return ((DataBindingSource) source).containsProperty(propertyName);
+        }
+        if (source instanceof Map) {
+            return ((Map) source).containsKey(propertyName);
+        }
+        return false;
+    }
+
+    private static Set<String> getSourcePropertyNames(Object source) {
+        Set<String> propertyNames = new LinkedHashSet<>();
+        if (source instanceof DataBindingSource) {
+            propertyNames.addAll(((DataBindingSource) 
source).getPropertyNames());
+        }
+        else if (source instanceof Map) {
+            for (Object key : ((Map) source).keySet()) {
+                propertyNames.add(key.toString());
+            }
+        }
+        return propertyNames;
+    }
+
+    private static Object getSourcePropertyValue(Object source, String 
propertyName) {
+        if (source instanceof DataBindingSource) {
+            return ((DataBindingSource) source).getPropertyValue(propertyName);
+        }
+        return ((Map) source).get(propertyName);
+    }
+
+    private static String checkboxMarkerPropertyName(String propertyName) {
+        int separator = propertyPathSeparator(propertyName, true);
+        if (separator == -1) {
+            return "_" + propertyName;
+        }
+        return propertyName.substring(0, separator + 1) + "_" + 
propertyName.substring(separator + 1);
+    }
+
+    private static boolean hasNestedSourceEntries(Map map) {
+        for (Object value : map.values()) {
+            if (value instanceof Map || value instanceof Collection || value 
instanceof DataBindingSource) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private static boolean shouldExpandMapEntries(Object target, Class 
targetType, String propertyName) {
+        Object value = getTargetPropertyValue(target, propertyName);
+        if (value instanceof Map && hasStructuredTargetMapValues((Map) value)) 
{
+            return true;
+        }
+
+        Class mapValueType = getMapValueType(target, targetType, propertyName);
+        return mapValueType != null && isStructuredMapValueType(mapValueType);
+    }
+
+    private static boolean hasStructuredTargetMapValues(Map map) {
+        for (Object value : map.values()) {
+            if (value != null && isStructuredMapValueType(value.getClass())) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private static boolean isStructuredMapValueType(Class valueType) {
+        Package valuePackage = valueType.getPackage();
+        return !valueType.isPrimitive() &&
+            (valuePackage == null || 
!valuePackage.getName().startsWith("java.")) &&
+            !CharSequence.class.isAssignableFrom(valueType) &&
+            !Number.class.isAssignableFrom(valueType) &&
+            !Boolean.class.isAssignableFrom(valueType) &&
+            !Enum.class.isAssignableFrom(valueType) &&
+            !Map.class.isAssignableFrom(valueType) &&
+            !Collection.class.isAssignableFrom(valueType) &&
+            !Object.class.equals(valueType);
+    }
+
+    private static Class getMapValueType(Object target, Class targetType, 
String propertyName) {
+        Class resolvedTargetType = target == null ? targetType : 
target.getClass();
+        if (resolvedTargetType == null) {
+            return null;
+        }
+
+        MetaClass mc = 
GroovySystem.getMetaClassRegistry().getMetaClass(resolvedTargetType);
+        MetaProperty metaProperty = mc.getMetaProperty(propertyName);
+        if (metaProperty == null || 
!Map.class.isAssignableFrom(metaProperty.getType())) {
+            return null;
+        }
+
+        Field field = findField(resolvedTargetType, propertyName);
+        if (field == null) {
+            return null;
+        }
+        return getMapValueType(field.getGenericType());
+    }
+
+    private static Class getMapValueType(Type type) {
+        if (!(type instanceof ParameterizedType)) {
+            return null;
+        }
+
+        Type[] typeArguments = ((ParameterizedType) 
type).getActualTypeArguments();
+        if (typeArguments.length < 2) {
+            return null;
+        }
+        Type valueType = typeArguments[1];
+        if (valueType instanceof Class) {
+            return (Class) valueType;
+        }
+        if (valueType instanceof ParameterizedType && ((ParameterizedType) 
valueType).getRawType() instanceof Class) {
+            return (Class) ((ParameterizedType) valueType).getRawType();
+        }
+        return null;
+    }
+
+    private static Field findField(Class type, String propertyName) {
+        Class currentType = type;
+        while (currentType != null) {
+            try {
+                return currentType.getDeclaredField(propertyName);
+            }
+            catch (NoSuchFieldException e) {
+                currentType = currentType.getSuperclass();
+            }
+        }
+        return null;
+    }
+
+    private static Object getTargetObject(Object object, String 
targetPathPrefix) {
+        if (targetPathPrefix == null || targetPathPrefix.length() == 0) {
+            return object;
+        }
+
+        Object targetObject = object;
+        for (String propertyName : splitPropertyPath(targetPathPrefix)) {
+            if (targetObject == null) {
+                return null;
+            }
+            targetObject = getPropertyValue(targetObject, propertyName);
+        }
+        return targetObject;
+    }
+
+    private static Object getTargetPropertyValue(Object target, String 
propertyName) {
+        if (target == null) {
+            return null;
+        }
+
+        try {
+            MetaClass mc = 
GroovySystem.getMetaClassRegistry().getMetaClass(target.getClass());
+            return mc.getProperty(target, propertyName);
+        }
+        catch (Exception e) {
+            return null;
+        }
+    }
+
+    private static String appendPropertyPath(String parentPath, String 
propertyName) {
+        if (parentPath == null || parentPath.length() == 0) {
+            return propertyName;
+        }
+        return parentPath + "." + propertyName;
+    }
+
+    private static void setPropertyToNull(Object object, String propertyName) {
+        String[] propertyNames = splitPropertyPath(propertyName);
+        Object currentObject = object;
+        for (int i = 0; i < propertyNames.length - 1 && currentObject != null; 
i++) {
+            currentObject = getPropertyValue(currentObject, propertyNames[i]);
+        }
+        if (currentObject != null) {
+            setPropertyValueToNull(currentObject, 
propertyNames[propertyNames.length - 1]);
+        }
+    }
+
+    private static Object getPropertyValue(Object object, String propertyName) 
{
+        int bracket = propertyName.indexOf('[');
+        try {
+            if (bracket == -1) {
+                MetaClass mc = 
GroovySystem.getMetaClassRegistry().getMetaClass(object.getClass());
+                return mc.getProperty(object, propertyName);
+            }
+
+            MetaClass mc = 
GroovySystem.getMetaClassRegistry().getMetaClass(object.getClass());
+            Object indexedProperty = mc.getProperty(object, 
propertyName.substring(0, bracket));
+            return getIndexedValue(indexedProperty, 
propertyName.substring(bracket + 1, propertyName.indexOf(']', bracket)));
+        }
+        catch (Exception e) {
+            return null;
+        }
+    }
+
+    private static Object getIndexedValue(Object indexedProperty, String 
index) {
+        if (indexedProperty instanceof List) {
+            List list = (List) indexedProperty;
+            Integer parsedIndex = parseIndex(index);
+            return parsedIndex != null && parsedIndex >= 0 && parsedIndex < 
list.size() ? list.get(parsedIndex) : null;
+        }
+        if (indexedProperty instanceof Map) {
+            return ((Map) indexedProperty).get(index);
+        }
+        return null;
+    }
+
+    private static void setPropertyValueToNull(Object object, String 
propertyName) {
+        int bracket = propertyName.indexOf('[');
+        try {
+            if (bracket == -1) {
+                MetaClass mc = 
GroovySystem.getMetaClassRegistry().getMetaClass(object.getClass());
+                if (mc.hasProperty(object, propertyName) != null) {
+                    mc.setProperty(object, propertyName, null);
+                }
+                return;
+            }
+
+            MetaClass mc = 
GroovySystem.getMetaClassRegistry().getMetaClass(object.getClass());
+            Object indexedProperty = mc.getProperty(object, 
propertyName.substring(0, bracket));
+            String index = propertyName.substring(bracket + 1, 
propertyName.indexOf(']', bracket));
+            if (indexedProperty instanceof List) {
+                List list = (List) indexedProperty;
+                Integer parsedIndex = parseIndex(index);
+                if (parsedIndex != null && parsedIndex >= 0 && parsedIndex < 
list.size()) {
+                    list.set(parsedIndex, null);
+                }
+            }
+            else if (indexedProperty instanceof Map) {
+                ((Map) indexedProperty).put(index, null);
+            }
+        }
+        catch (Exception e) {
+            // ignore invalid indexed nullMissing paths
+        }
+    }
+
+    private static Integer parseIndex(String index) {
+        try {
+            return Integer.valueOf(index);
+        }
+        catch (NumberFormatException e) {
+            return null;
+        }
+    }
+
     protected static String[] getMessageCodes(String messageCode,
             Class objectType) {
         String[] codes = {objectType.getName() + "." + messageCode, 
messageCode};


Reply via email to