You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@grails.apache.org by ja...@apache.org on 2026/07/09 23:34:25 UTC
(grails-core) 01/01: Add opt-in nullMissing support to bindData for stale-data clearing
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 <ja...@users.noreply.github.com>
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 == 'existing@example.com'
+ }
+
+ 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 == 'existing@example.com'
+ }
+
+ 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 == 'existing@example.com'
+ }
+
+ 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': 'lee@mail.com'], disallowed, filter
[target: target]
}
+
+ def bindWithNullMissing() {
+ def target = new CommandObject(name: 'Existing Name', email: 'existing@example.com')
+ bindData target, [name: 'Updated Name'], [include: ['name', 'email'], nullMissing: true]
+ [target: target]
+ }
+
+ def bindWithoutNullMissing() {
+ def target = new CommandObject(name: 'Existing Name', email: 'existing@example.com')
+ bindData target, [name: 'Updated Name'], [include: ['name', 'email']]
+ [target: target]
+ }
+
+ def bindWithNullMissingAndNoInclude() {
+ def target = new CommandObject(name: 'Existing Name', email: 'existing@example.com')
+ bindData target, [name: 'Updated Name'], [nullMissing: true]
+ [target: target]
+ }
+
+ def bindWithNullMissingAndExclude() {
+ def target = new CommandObject(name: 'Existing Name', email: 'existing@example.com')
+ 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: 'existing@example.com')
+ 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};