diff --git a/grails-databinding-core/src/main/groovy/grails/databinding/FrameworkPropertyNames.java b/grails-databinding-core/src/main/groovy/grails/databinding/FrameworkPropertyNames.java new file mode 100644 index 00000000000..89f5c81469d --- /dev/null +++ b/grails-databinding-core/src/main/groovy/grails/databinding/FrameworkPropertyNames.java @@ -0,0 +1,55 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package grails.databinding; + +import java.util.Set; + +/** + * Property names managed by the language runtime or Grails rather than ordinary request data. + *

+ * Intrinsic runtime properties are never request-bindable. Grails-managed domain properties + * are excluded from generated allowlists by default, but may still bind and be cleared when an + * application explicitly opts them in (for example {@code bindable: true}); intrinsic runtime + * properties remain protected. + */ +public final class FrameworkPropertyNames { + + /** + * Language / MetaClass properties that must never be bound from request data. + */ + public static final Set INTRINSIC_RUNTIME_PROPERTIES = Set.of( + "class", "classLoader", "protectionDomain", "metaClass", "metaPropertyValues", "properties"); + + /** + * Grails domain lifecycle properties excluded from default binding allowlists but eligible + * for {@code clearMissing} when explicitly included. + */ + public static final Set GRAILS_MANAGED_PROPERTIES = Set.of( + "errors", "id", "version", "dateCreated", "lastUpdated"); + + /** + * Union of {@link #INTRINSIC_RUNTIME_PROPERTIES} and {@link #GRAILS_MANAGED_PROPERTIES}. + */ + public static final Set FRAMEWORK_MANAGED_PROPERTIES = Set.of( + "class", "classLoader", "protectionDomain", "metaClass", "metaPropertyValues", "properties", + "errors", "id", "version", "dateCreated", "lastUpdated"); + + private FrameworkPropertyNames() { + } +} diff --git a/grails-databinding-core/src/main/groovy/grails/databinding/SimpleDataBinder.groovy b/grails-databinding-core/src/main/groovy/grails/databinding/SimpleDataBinder.groovy index ff51e81f5a8..c84fe8b77b2 100755 --- a/grails-databinding-core/src/main/groovy/grails/databinding/SimpleDataBinder.groovy +++ b/grails-databinding-core/src/main/groovy/grails/databinding/SimpleDataBinder.groovy @@ -271,19 +271,25 @@ class SimpleDataBinder implements DataBinder { } protected boolean isOkToBind(String propName, List whiteList, List blackList) { - 'class' != propName && 'classLoader' != propName && 'protectionDomain' != propName && 'metaClass' != propName && 'metaPropertyValues' != propName && 'properties' != propName && !blackList?.contains(propName) && (whiteList == null || isBindAllBindingIncludeList(whiteList) || whiteList.contains(propName) || whiteList.find { it -> it?.toString()?.startsWith(propName + '.') }) + // Only intrinsic runtime properties are hard-denied here. Grails-managed domain + // properties (id, version, dateCreated, lastUpdated, errors) may still bind when + // explicitly allowlisted (e.g. bindable: true); intrinsic runtime properties remain + // hard-denied while Grails-managed properties follow the explicit binding allowlist. + !FrameworkPropertyNames.INTRINSIC_RUNTIME_PROPERTIES.contains(propName) && !blackList?.contains(propName) && + (whiteList == null || isBindAllBindingIncludeList(whiteList) || whiteList.contains(propName) || + whiteList.any { item -> item?.toString()?.startsWith(propName + '.') }) } /** * Marker include list meaning "bind every eligible property". Used when an * explicit exclude-only bind must not intersect the class allowlist. */ - static List getBindAllBindingIncludeList() { + protected static List getBindAllBindingIncludeList() { BIND_ALL_BINDING_INCLUDE_LIST } - static boolean isBindAllBindingIncludeList(List includeList) { - includeList instanceof BindAllBindingIncludeList + protected static boolean isBindAllBindingIncludeList(List includeList) { + includeList.is(BIND_ALL_BINDING_INCLUDE_LIST) } private static final class BindAllBindingIncludeList extends ArrayList { diff --git a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc index a33dc7de530..bd34bb094ab 100644 --- a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc +++ b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc @@ -826,6 +826,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: [...], clearMissing: true])` now clears 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 `clearMissing: 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 ec42df40178..cfa329b70f9 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'], clearMissing: true]) ---- @@ -57,7 +60,7 @@ 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 `clearMissing: true` with an `include` list to clear 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'. If no `include` list is supplied, `bindData` uses the target class default binding behavior. By default, statically typed instance properties bind for compatibility unless they are marked `bindable: false`. Existing `bindable: true` declarations and explicit `include` lists continue to bind exactly the properties they name without configuration changes. An empty `include` list binds no properties. @@ -73,6 +76,19 @@ Use `include` to allow only the properties needed for a request: bindData(target, params, [include: ['firstName', 'lastName']]) ---- +Include entries may use wildcard suffixes for nested properties. `address.*` includes all +properties nested under `address`, while `address_*` is the corresponding underscore-form +used for nested binding paths and generated binding allowlists: + +[source,groovy] +---- +bindData(target, params, [include: ['address.*']]) +bindData(target, params, [include: ['address_*']]) +---- + +When `clearMissing: true` is used, omitted properties matched by either wildcard form are +cleared, subject to the normal `exclude` and bindability rules. + For controller action command object parameters, use `grails.web.databinding.BindAllowed` to allow request binding for only the listed properties: [source,groovy] @@ -88,6 +104,8 @@ class PersonController { See the link:{constraintsRefFromRef}bindable.html[bindable] constraint documentation for more information on controlling default bindability. Applications may set `grails.databinding.denyByDefault=true` to opt into deny-by-default binding allowlists. In secure mode, permit a property with `bindable: true`, an explicit `include` list, or `@BindAllowed` on a controller action command object parameter. +`clearMissing` 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. + Only boolean values and the strings `'true'` and `'false'` are recognised for `grails.databinding.denyByDefault`. String matching ignores case and surrounding whitespace. An unrecognised value logs a warning and enables secure binding. 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. diff --git a/grails-test-suite-web/src/test/groovy/org/grails/web/binding/DefaultASTDatabindingHelperDomainClassSpecialPropertiesSpec.groovy b/grails-test-suite-web/src/test/groovy/org/grails/web/binding/DefaultASTDatabindingHelperDomainClassSpecialPropertiesSpec.groovy index 1f1e1d115d9..e3912796196 100644 --- a/grails-test-suite-web/src/test/groovy/org/grails/web/binding/DefaultASTDatabindingHelperDomainClassSpecialPropertiesSpec.groovy +++ b/grails-test-suite-web/src/test/groovy/org/grails/web/binding/DefaultASTDatabindingHelperDomainClassSpecialPropertiesSpec.groovy @@ -18,41 +18,44 @@ */ package org.grails.web.binding +import groovy.transform.CompileStatic + +import spock.lang.Issue +import spock.lang.Specification + import grails.config.Settings import grails.gorm.dirty.checking.DirtyCheck import grails.persistence.Entity import grails.util.Holders -import groovy.transform.CompileStatic +import grails.web.databinding.DataBindingUtils +import grails.web.databinding.GrailsWebDataBinder import org.grails.config.PropertySourcesConfig import org.grails.validation.ConstraintEvalUtils -import spock.lang.Issue -import spock.lang.Specification -class DefaultASTDatabindingHelperDomainClassSpecialPropertiesSpec extends - Specification { +class DefaultASTDatabindingHelperDomainClassSpecialPropertiesSpec extends Specification { private def originalConfig def setup() { ConstraintEvalUtils.clearDefaultConstraints() originalConfig = Holders.config - Holders.setConfig(new PropertySourcesConfig([(Settings.DATABINDING_DENY_BY_DEFAULT): true])) - grails.web.databinding.DataBindingUtils.clearBindingCaches() - grails.web.databinding.GrailsWebDataBinder.resetWarnedBindingShapes() + Holders.config = new PropertySourcesConfig([(Settings.DATABINDING_DENY_BY_DEFAULT): true]) + DataBindingUtils.clearBindingCaches() + GrailsWebDataBinder.resetWarnedBindingShapes() } def cleanup() { ConstraintEvalUtils.clearDefaultConstraints() - Holders.setConfig(originalConfig) - grails.web.databinding.DataBindingUtils.clearBindingCaches() - grails.web.databinding.GrailsWebDataBinder.resetWarnedBindingShapes() + Holders.config = originalConfig + DataBindingUtils.clearBindingCaches() + GrailsWebDataBinder.resetWarnedBindingShapes() } @Issue('GRAILS-11173') void 'Test binding to special properties in a domain class'() { when: - Date now = new Date() - SomeDomainClass obj = new SomeDomainClass(dateCreated: now, lastUpdated: now) + def now = new Date() + def obj = new SomeDomainClass(dateCreated: now, lastUpdated: now) then: obj.dateCreated == null @@ -140,8 +143,7 @@ class DefaultASTDatabindingHelperDomainClassSpecialPropertiesSpec extends void 'Test unconfigured binding remains permissive and preserves bindable false'() { given: - def configuredConfig = Holders.config - Holders.setConfig(null) + Holders.config = null when: def obj = new DomainWithSecureBindableDefault(name: 'Grace', title: 'Admiral', role: 'Admin') @@ -150,9 +152,6 @@ class DefaultASTDatabindingHelperDomainClassSpecialPropertiesSpec extends obj.name == 'Grace' obj.title == 'Admiral' obj.role == null - - cleanup: - Holders.setConfig(configuredConfig) } @Issue('https://github.com/apache/grails-core/issues/15795') 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 8e9e15e9103..d47bda908ed 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 @@ -43,7 +43,7 @@ class BindDataMethodTests extends Specification implements ControllerUnitTest children = [] + Map contacts = [:] + ProtectedAddress protectedAddress = new ProtectedAddress() + List protectedChildren = [] } @@ -958,10 +1383,69 @@ class ExistingAllowlistCommandObject { } } +class ComplexPropertyWildcardSiblingCommandObject { + Address foo = new Address() + String foo_admin + + static constraints = { + foo bindable: true + } +} + class NoAllowlistCommandObject { String username } +class NoAllowlistCollectionCommandObject { + List children = [] +} + +class NoAllowlistChild { + String name + Integer age +} + +class FrameworkManagedCommandObject { + Long id + Long version + Date dateCreated + Date lastUpdated + String errors +} + +class PrimitiveCommandObject { + boolean active +} + +class FailingClearCommandObject { + private String currentValue = 'existing' + + String getValue() { + currentValue + } + + void setValue(String value) { + throw new IllegalStateException('value cannot be cleared') + } +} + +class ErrorCollectingCommandObject implements Validateable { + Integer count + private String currentValue = 'existing' + + String getValue() { + currentValue + } + + void setValue(String value) { + throw new IllegalStateException('value cannot be cleared') + } +} + +class NestedFailingClearCommandObject { + FailingClearCommandObject child +} + class RecordingGrailsWebDataBinder extends GrailsWebDataBinder { final List warnings = [] @@ -979,3 +1463,53 @@ class RecordingGrailsWebDataBinder extends GrailsWebDataBinder { warnings << message } } + +class Child { + String name + Integer age + + static constraints = { + name bindable: true + age bindable: true + } +} + +class Contact { + String type + String value + + static constraints = { + type bindable: true + value bindable: true + } +} + +class ProtectedCommandObject { + String visible + String protectedValue + + static constraints = { + visible bindable: true + protectedValue bindable: false + } +} + +class ProtectedAddress { + String country + String secret + + static constraints = { + country bindable: true + secret bindable: false + } +} + +class ProtectedChild { + String name + String secret + + static constraints = { + name bindable: true + secret bindable: false + } +} 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 68412393022..cddf7c6d193 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 @@ -72,7 +72,8 @@ trait DataBinder { includeList = [DefaultASTDatabindingHelper.NO_BINDABLE_PROPERTIES] } List excludeList = convertToListIfCharSequence(includeExclude?.exclude) - DataBindingUtils.bindObjectToInstance(target, bindingSource, includeList, excludeList, filter) + boolean clearMissing = includeExclude?.clearMissing == true + DataBindingUtils.bindObjectToInstance(target, bindingSource, includeList, excludeList, filter, clearMissing) } @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 c3e7dd008b2..e5adb5d30f8 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 @@ -52,7 +52,6 @@ import grails.databinding.CollectionDataBindingSource; import grails.databinding.DataBinder; import grails.databinding.DataBindingSource; -import grails.databinding.SimpleDataBinder; import grails.util.Environment; import grails.util.Holders; import grails.validation.ValidationErrors; @@ -515,15 +514,33 @@ 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); + } + + /** + * Binds source values and optionally clears omitted explicitly included properties after binding. + * + * @param object The object to bind to + * @param source The source object + * @param include The explicit list of properties to include. {@code clearMissing} is ignored when this is {@code null} + * @param exclude The list of properties to exclude. Excluded and {@code bindable: false} properties are never cleared + * @param filter The prefix to filter by + * @param clearMissing Whether omitted explicitly included properties should be cleared after normal binding completes + * @return A BindingResult for request/body or binding exceptions. When clearing is active, it also contains normal + * binding errors already stored on the target and null-clearing failures, or null when no such errors occur. + * Clearing runs after normal binder listeners have completed and does not emit listener callbacks. + */ + public static BindingResult bindObjectToInstance(Object object, Object source, List include, List exclude, String filter, boolean clearMissing) { + boolean explicitInclude = include != null; if (include == null) { if (exclude == null || isDenyByDefaultEnabled()) { include = getBindingIncludeList(object); } else { // Exclude-only in compatibility mode must not intersect the class allowlist. - include = SimpleDataBinder.getBindAllBindingIncludeList(); + include = GrailsWebDataBinder.bindAllBindingIncludeList(); } } - else if (include.isEmpty() && !SimpleDataBinder.isBindAllBindingIncludeList(include)) { + else if (include.isEmpty() && !GrailsWebDataBinder.isBindAllIncludeList(include)) { include = Collections.singletonList(DefaultASTDatabindingHelper.NO_BINDABLE_PROPERTIES); } GrailsApplication application = Holders.findApplication(); @@ -535,7 +552,7 @@ else if (include.isEmpty() && !SimpleDataBinder.isBindAllBindingIncludeList(incl //no-op } } - return bindObjectToDomainInstance(entity, object, source, include, exclude, filter); + return bindObjectToDomainInstance(entity, object, source, include, exclude, filter, clearMissing && explicitInclude); } /** @@ -559,10 +576,41 @@ public static BindingResult bindObjectToDomainInstance(PersistentEntity entity, if (exclude == null || isDenyByDefaultEnabled()) { include = getBindingIncludeList(object); } else { - include = SimpleDataBinder.getBindAllBindingIncludeList(); + include = GrailsWebDataBinder.bindAllBindingIncludeList(); + } + } + else if (include.isEmpty() && !GrailsWebDataBinder.isBindAllIncludeList(include)) { + include = Collections.singletonList(DefaultASTDatabindingHelper.NO_BINDABLE_PROPERTIES); + } + return bindObjectToDomainInstance(entity, object, source, include, exclude, filter, false); + } + + /** + * Binds source values to a domain instance and optionally clears omitted explicitly included properties after binding. + * + * @param entity The persistent entity metadata, if available + * @param object The object to bind to + * @param source The source object + * @param include The explicit non-null list of properties to include. {@code clearMissing} is ignored when this is {@code null} + * @param exclude The list of properties to exclude. Excluded and {@code bindable: false} properties are never cleared + * @param filter The prefix to filter by + * @param clearMissing Whether omitted explicitly included properties should be cleared after normal binding completes + * @return A BindingResult for request/body or binding exceptions. When clearing is active, it also contains normal + * binding errors already stored on the target and null-clearing failures, or null when no such errors occur. + * Clearing runs after normal binder listeners have completed and does not emit listener callbacks. + */ + @SuppressWarnings("unchecked") + public static BindingResult bindObjectToDomainInstance(PersistentEntity entity, Object object, + Object source, List include, List exclude, String filter, boolean clearMissing) { + boolean explicitInclude = include != null; + if (include == null) { + if (exclude == null || isDenyByDefaultEnabled()) { + include = getBindingIncludeList(object); + } else { + include = GrailsWebDataBinder.bindAllBindingIncludeList(); } } - else if (include.isEmpty() && !SimpleDataBinder.isBindAllBindingIncludeList(include)) { + else if (include.isEmpty() && !GrailsWebDataBinder.isBindAllIncludeList(include)) { include = Collections.singletonList(DefaultASTDatabindingHelper.NO_BINDABLE_PROPERTIES); } BindingResult bindingResult = null; @@ -572,6 +620,21 @@ else if (include.isEmpty() && !SimpleDataBinder.isBindAllBindingIncludeList(incl final DataBindingSource bindingSource = createDataBindingSource(grailsApplication, object.getClass(), source); final DataBinder grailsWebDataBinder = getGrailsWebDataBinder(grailsApplication); grailsWebDataBinder.bind(object, bindingSource, filter, include, exclude); + if (clearMissing && explicitInclude && !include.isEmpty()) { + BeanPropertyBindingResult clearMissingResult = new BeanPropertyBindingResult(object, object.getClass().getName()); + MetaClass targetMetaClass = GroovySystem.getMetaClassRegistry().getMetaClass(object.getClass()); + if (targetMetaClass.hasProperty(object, "errors") != null) { + Object existingErrors = targetMetaClass.getProperty(object, "errors"); + if (existingErrors instanceof BindingResult) { + clearMissingResult.addAllErrors((BindingResult) existingErrors); + } + } + MissingPropertyClearer.clearMissingIncludedProperties( + object, bindingSource, include, exclude, filter, clearMissingResult); + if (clearMissingResult.hasErrors()) { + bindingResult = clearMissingResult; + } + } } catch (InvalidRequestBodyException e) { String messageCode = "invalidRequestBody"; Class objectType = object.getClass(); diff --git a/grails-web-databinding/src/main/groovy/grails/web/databinding/GrailsWebDataBinder.groovy b/grails-web-databinding/src/main/groovy/grails/web/databinding/GrailsWebDataBinder.groovy index 89725ae02a3..e559583b342 100644 --- a/grails-web-databinding/src/main/groovy/grails/web/databinding/GrailsWebDataBinder.groovy +++ b/grails-web-databinding/src/main/groovy/grails/web/databinding/GrailsWebDataBinder.groovy @@ -42,6 +42,7 @@ import org.springframework.validation.ObjectError import grails.core.GrailsApplication import grails.databinding.BindingFormat import grails.databinding.DataBindingSource +import grails.databinding.FrameworkPropertyNames import grails.databinding.SimpleDataBinder import grails.databinding.SimpleMapDataBindingSource import grails.databinding.TypedStructuredBindingEditor @@ -83,10 +84,6 @@ class GrailsWebDataBinder extends SimpleDataBinder { private static final Logger LOG = LoggerFactory.getLogger(GrailsWebDataBinder) private static final int MAX_WARNED_BINDING_SHAPES = 1024 private static final Set WARNED_BINDING_SHAPES = new LinkedHashSet<>() - private static final Set FRAMEWORK_MANAGED_PROPERTIES = [ - 'class', 'errors', 'id', 'version', 'dateCreated', 'lastUpdated' - ] as Set - protected GrailsApplication grailsApplication protected MessageSource messageSource boolean trimStrings = true @@ -146,12 +143,20 @@ class GrailsWebDataBinder extends SimpleDataBinder { if (includeList == null) { return getBindingIncludeList(object) } - if (includeList.isEmpty() && !isBindAllBindingIncludeList(includeList)) { + if (includeList.isEmpty() && !isBindAllIncludeList(includeList)) { return [DefaultASTDatabindingHelper.NO_BINDABLE_PROPERTIES] } includeList } + static List bindAllBindingIncludeList() { + SimpleDataBinder.getBindAllBindingIncludeList() + } + + static boolean isBindAllIncludeList(List includeList) { + SimpleDataBinder.isBindAllBindingIncludeList(includeList) + } + @Override protected void doBind(object, DataBindingSource source, String filter, List whiteList, List blackList, DataBindingListener listener, errors) { def observationRegistry = resolveObservationRegistry() @@ -243,7 +248,7 @@ class GrailsWebDataBinder extends SimpleDataBinder { boolean allowed = super.isOkToBind(property, whiteList, blackList) if (!allowed && DataBindingUtils.isGeneratedBindingIncludeList(whiteList) && super.isOkToBind(property, null, blackList) && - !FRAMEWORK_MANAGED_PROPERTIES.contains(property.name)) { + !FrameworkPropertyNames.FRAMEWORK_MANAGED_PROPERTIES.contains(property.name)) { warnAboutIgnoredBindingProperty(bindingTargetType.get(), property.name) } allowed @@ -634,7 +639,7 @@ class GrailsWebDataBinder extends SimpleDataBinder { instance = referencedType.getDeclaredConstructor().newInstance() } catch (NoSuchMethodException | IllegalAccessException ignored) { if (value instanceof Map) { - if (isBindAllBindingIncludeList(includeList) || + if (isBindAllIncludeList(includeList) || !DataBindingUtils.isDenyByDefaultEnabled()) { return referencedType.newInstance(filterUnbindableMapConstructorArguments(referencedType, (Map) value)) } diff --git a/grails-web-databinding/src/main/groovy/grails/web/databinding/MissingPropertyClearer.java b/grails-web-databinding/src/main/groovy/grails/web/databinding/MissingPropertyClearer.java new file mode 100644 index 00000000000..2a031bf929c --- /dev/null +++ b/grails-web-databinding/src/main/groovy/grails/web/databinding/MissingPropertyClearer.java @@ -0,0 +1,823 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package grails.web.databinding; + +import java.lang.reflect.Field; +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.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import groovy.lang.GroovySystem; +import groovy.lang.MetaClass; +import groovy.lang.MetaProperty; + +import org.springframework.validation.BindingResult; +import org.springframework.validation.FieldError; + +import grails.databinding.DataBindingSource; +import grails.databinding.FrameworkPropertyNames; + +import static grails.web.databinding.DataBindingUtils.addUnbindablePropertyNames; +import static grails.web.databinding.DataBindingUtils.getBindingIncludeList; + +/** + * Clears explicitly included properties omitted from a binding source. + * + * @since 8.0 + */ +final class MissingPropertyClearer { + + private static final String BLANK = ""; + private static final ThreadLocal BINDING_RESULT = new ThreadLocal<>(); + private static final ThreadLocal CLEAR_PATH = new ThreadLocal<>(); + + private MissingPropertyClearer() { + } + + static void clearMissingIncludedProperties(Object object, DataBindingSource bindingSource, List include, List exclude, + String filter, BindingResult bindingResult) { + List expandedInclude = expandWildcardIncludes(object, include); + BindingResult previousBindingResult = BINDING_RESULT.get(); + BINDING_RESULT.set(bindingResult); + try { + for (Object includedProperty : expandedInclude) { + if (includedProperty instanceof CharSequence) { + String propertyName = includedProperty.toString(); + if (isClearMissingPropertyBindable(object, propertyName, expandedInclude, exclude)) { + if (assignNullToMissingIndexedProperties(object, bindingSource, propertyName, filter)) { + continue; + } + if (!bindingSourceContainsProperty(bindingSource, propertyName, filter)) { + setPropertyToNull(object, propertyName); + } + } + } + } + } + finally { + if (previousBindingResult == null) { + BINDING_RESULT.remove(); + } + else { + BINDING_RESULT.set(previousBindingResult); + } + } + } + + private static List expandWildcardIncludes(Object object, List include) { + List expandedInclude = new ArrayList(); + for (Object includedProperty : include) { + if (!(includedProperty instanceof CharSequence)) { + expandedInclude.add(includedProperty); + continue; + } + String propertyName = includedProperty.toString(); + if (propertyName.endsWith(".*")) { + expandNestedWildcard(object, propertyName.substring(0, propertyName.length() - 2), expandedInclude); + } + else if (propertyName.endsWith("_*")) { + expandSiblingWildcard(object, propertyName.substring(0, propertyName.length() - 2), expandedInclude); + } + else { + expandedInclude.add(propertyName); + } + } + return expandedInclude; + } + + private static void expandNestedWildcard(Object object, String propertyPath, List expandedInclude) { + Object nestedObject = getPropertyValue(object, propertyPath); + if (nestedObject instanceof Collection) { + int index = 0; + for (Object item : (Collection) nestedObject) { + expandObjectProperties(item, propertyPath + "[" + index + "]", expandedInclude); + index++; + } + } + else if (nestedObject instanceof Map) { + for (Object entryObject : ((Map) nestedObject).entrySet()) { + Map.Entry entry = (Map.Entry) entryObject; + expandObjectProperties(entry.getValue(), propertyPath + "[" + entry.getKey() + "]", expandedInclude); + } + } + else { + expandObjectProperties(nestedObject, propertyPath, expandedInclude); + } + } + + private static void expandSiblingWildcard(Object object, String propertyPrefix, List expandedInclude) { + for (Object property : getMetaProperties(object)) { + String propertyName = ((MetaProperty) property).getName(); + if (propertyName.startsWith(propertyPrefix + "_")) { + expandedInclude.add(propertyName); + } + } + } + + private static void expandObjectProperties(Object object, String propertyPath, List expandedInclude) { + if (object == null) { + return; + } + for (Object property : getMetaProperties(object)) { + expandedInclude.add(propertyPath + "." + ((MetaProperty) property).getName()); + } + } + + private static List getMetaProperties(Object object) { + if (object == null) { + return Collections.emptyList(); + } + MetaClass metaClass = GroovySystem.getMetaClassRegistry().getMetaClass(object.getClass()); + return metaClass.getProperties(); + } + + private static boolean isClearMissingPropertyBindable(Object object, String propertyName, List include, List exclude) { + if (object == null) { + return false; + } + String allowlistPropertyName = removePropertyIndexes(propertyName); + List bindingIncludeList = getBindingIncludeList(object); + List bindingExcludeList = normalizePropertyIndexes(addUnbindablePropertyNames(object, exclude)); + if (!isClearMissingPropertyPathAllowed(allowlistPropertyName, bindingIncludeList, include, bindingExcludeList)) { + 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 && !isClearMissingPropertyBindable(item, nestedPropertyName, getNestedIncludeList(include, propertyName), null)) { + return false; + } + } + return true; + } + if (nestedObject instanceof Map) { + for (Object value : ((Map) nestedObject).values()) { + if (value != null && !isClearMissingPropertyBindable(value, nestedPropertyName, getNestedIncludeList(include, propertyName), null)) { + return false; + } + } + return true; + } + return nestedObject == null || isClearMissingPropertyBindable(nestedObject, nestedPropertyName, getNestedIncludeList(include, propertyName), null); + } + + private static String removePropertyIndexes(String propertyName) { + return propertyName.replaceAll("\\[[^]]*]", ""); + } + + private static List normalizePropertyIndexes(List propertyNames) { + if (propertyNames == null) { + return Collections.emptyList(); + } + List normalizedPropertyNames = new ArrayList(propertyNames.size()); + for (Object propertyName : propertyNames) { + normalizedPropertyNames.add(propertyName instanceof CharSequence ? removePropertyIndexes(propertyName.toString()) : propertyName); + } + return normalizedPropertyNames; + } + + private static List getNestedIncludeList(List include, String propertyName) { + if (include == null || include.isEmpty()) { + return Collections.emptyList(); + } + String normalizedPropertyName = removePropertyIndexes(propertyName); + int separator = propertyPathSeparator(normalizedPropertyName); + if (separator == -1) { + return Collections.emptyList(); + } + String rootPropertyName = normalizedPropertyName.substring(0, separator); + List nestedIncludeList = new ArrayList(); + for (Object includedProperty : include) { + if (includedProperty instanceof CharSequence) { + String includedPropertyName = removePropertyIndexes(includedProperty.toString()); + int includedPropertySeparator = propertyPathSeparator(includedPropertyName); + if (includedPropertySeparator != -1 && rootPropertyName.equals(includedPropertyName.substring(0, includedPropertySeparator))) { + nestedIncludeList.add(includedPropertyName.substring(includedPropertySeparator + 1)); + } + } + } + return nestedIncludeList; + } + + private static boolean isPropertyExcluded(String propertyName, List excludeList) { + if (excludeList == null) { + return false; + } + for (Object item : excludeList) { + String excludeName = item == null ? null : item.toString(); + if (excludeName != null && (excludeName.equals(propertyName) || propertyName.startsWith(excludeName + ".") || + (excludeName.endsWith(".*") && propertyName.startsWith(excludeName.substring(0, excludeName.length() - 1))) || + (excludeName.endsWith("_*") && propertyName.startsWith(excludeName.substring(0, excludeName.length() - 1))))) { + return true; + } + } + return false; + } + + private static boolean isClearMissingPropertyPathAllowed(String propertyName, List generatedIncludeList, List explicitIncludeList, List excludeList) { + if (isIntrinsicRuntimeProperty(propertyName) || isPropertyExcluded(propertyName, excludeList)) { + return false; + } + return isClearMissingPropertyIncluded(propertyName, generatedIncludeList) || + isClearMissingPropertyIncluded(propertyName, explicitIncludeList); + } + + private static boolean isIntrinsicRuntimeProperty(String propertyName) { + int separator = propertyPathSeparator(propertyName); + String rootPropertyName = separator == -1 ? propertyName : propertyName.substring(0, separator); + return FrameworkPropertyNames.INTRINSIC_RUNTIME_PROPERTIES.contains(rootPropertyName); + } + + private static boolean isClearMissingPropertyIncluded(String propertyName, List includeList) { + if (includeList == null) { + return false; + } + for (Object includedProperty : includeList) { + if (includedProperty instanceof CharSequence) { + String includedPropertyName = removePropertyIndexes(includedProperty.toString()); + if (includedPropertyName.equals(propertyName)) { + return true; + } + if (includedPropertyName.endsWith(".*")) { + String prefix = includedPropertyName.substring(0, includedPropertyName.length() - 2); + if (propertyName.startsWith(prefix + ".")) { + return true; + } + } + if (includedPropertyName.endsWith("_*")) { + String prefix = includedPropertyName.substring(0, includedPropertyName.length() - 2); + if (propertyName.startsWith(prefix + ".") || propertyName.startsWith(prefix + "_")) { + return true; + } + } + } + } + return false; + } + + 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 previousClearPath = CLEAR_PATH.get(); + CLEAR_PATH.set(propertyName); + try { + 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]); + } + } + finally { + if (previousClearPath == null) { + CLEAR_PATH.remove(); + } + else { + CLEAR_PATH.set(previousClearPath); + } + } + } + + 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 (RuntimeException e) { + addClearError(propertyName, 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()); + MetaProperty metaProperty = mc.hasProperty(object, propertyName); + if (metaProperty != null) { + mc.setProperty(object, propertyName, + metaProperty.getType().isPrimitive() ? primitiveDefault(metaProperty.getType()) : 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 (RuntimeException e) { + addClearError(propertyName, e); + } + } + + private static void addClearError(String propertyName, RuntimeException exception) { + BindingResult bindingResult = BINDING_RESULT.get(); + if (bindingResult == null) { + return; + } + String clearPath = CLEAR_PATH.get(); + String field = clearPath == null ? propertyName : clearPath; + bindingResult.addError(new FieldError(bindingResult.getObjectName(), field, null, true, + new String[] { "typeMismatch." + field, "typeMismatch" }, null, + "Failed to clear omitted included property: " + exception.getMessage())); + } + + private static Object primitiveDefault(Class primitiveType) { + if (Boolean.TYPE.equals(primitiveType)) { + return false; + } + if (Character.TYPE.equals(primitiveType)) { + return Character.valueOf('\0'); + } + if (Byte.TYPE.equals(primitiveType)) { + return Byte.valueOf((byte) 0); + } + if (Short.TYPE.equals(primitiveType)) { + return Short.valueOf((short) 0); + } + if (Integer.TYPE.equals(primitiveType)) { + return Integer.valueOf(0); + } + if (Long.TYPE.equals(primitiveType)) { + return Long.valueOf(0L); + } + if (Float.TYPE.equals(primitiveType)) { + return Float.valueOf(0F); + } + if (Double.TYPE.equals(primitiveType)) { + return Double.valueOf(0D); + } + return null; + } + + private static Integer parseIndex(String index) { + try { + return Integer.valueOf(index); + } + catch (NumberFormatException e) { + return null; + } + } + +}