diff --git a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc index 37e6b139e90..85a41fabdc6 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 `namespace: null` from Groovy code or `namespace=""` in a GSP tag to target the non-namespaced controller explicitly. +* **`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 a809e63528d..9081584336b 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 d1b9fbfee64..c6cd4c272b1 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 children = [] + Map contacts = [:] + ProtectedAddress protectedAddress = new ProtectedAddress() + List protectedChildren = [] } class Address { String country } + +class Child { + String name + Integer age +} + +class Contact { + String type + String value +} + +class ProtectedCommandObject { + public static final List $defaultDatabindingWhiteList = ['visible'] + + String visible + String protectedValue +} + +class ProtectedAddress { + public static final List $defaultDatabindingWhiteList = ['country'] + + String country + String secret +} + +class ProtectedChild { + public static final List $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 a920ce5f61b..80cdd26d616 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 422a3bf99c3..7386a4bf9a5 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 @@ 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 static void bindToCollection(final Class targetType, final Collect * @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 static BindingResult bindObjectToInstance(Object object, Object source, L //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 static BindingResult bindObjectToInstance(Object object, Object source, L @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 static BindingResult bindObjectToDomainInstance(PersistentEntity entity, 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 static BindingResult bindObjectToDomainInstance(PersistentEntity entity, 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 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 getIndexedSourcePropertyNames(Object source, String indexedSourcePropertyPrefix) { + Set 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 getSourcePropertyNames(Object source) { + Set 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};