From 6309f18a1fed026420dc71bc700341ac55d4897e Mon Sep 17 00:00:00 2001 From: James Fredley Date: Thu, 9 Jul 2026 19:34:15 -0400 Subject: [PATCH 01/13] 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 + .../src/en/ref/Controllers/bindData.adoc | 7 +- .../web/servlet/BindDataMethodTests.groovy | 225 +++++++ .../grails/web/databinding/DataBinder.groovy | 3 +- .../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 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}; From cbfd385d3b9a594a4cbec7efcd6c0d06340d55f3 Mon Sep 17 00:00:00 2001 From: James Fredley Date: Sun, 2 Aug 2026 17:09:29 -0400 Subject: [PATCH 02/13] Consolidate framework-managed data binding properties Use one shared property-name set across the core and web binders while preserving the internal bind-all marker across package boundaries. Assisted-by: opencode:gpt-5.6-sol --- .../databinding/FrameworkPropertyNames.java | 34 +++++++++++++++++++ .../databinding/SimpleDataBinder.groovy | 24 +++---------- .../databinding/GrailsWebDataBinder.groovy | 19 +++++++---- 3 files changed, 51 insertions(+), 26 deletions(-) create mode 100644 grails-databinding-core/src/main/groovy/grails/databinding/FrameworkPropertyNames.java 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..9d853fbffec --- /dev/null +++ b/grails-databinding-core/src/main/groovy/grails/databinding/FrameworkPropertyNames.java @@ -0,0 +1,34 @@ +/* + * 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 request data binding. + */ +public final class FrameworkPropertyNames { + + 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 0ca9e7d4988..184bf9b013a 100755 --- a/grails-databinding-core/src/main/groovy/grails/databinding/SimpleDataBinder.groovy +++ b/grails-databinding-core/src/main/groovy/grails/databinding/SimpleDataBinder.groovy @@ -271,35 +271,21 @@ class SimpleDataBinder implements DataBinder { } protected boolean isOkToBind(String propName, List whiteList, List blackList) { - !isFrameworkProperty(propName) && !blackList?.contains(propName) && - (whiteList == null || isBindAllBindingIncludeList(whiteList) || whiteList.contains(propName) || + !FrameworkPropertyNames.FRAMEWORK_MANAGED_PROPERTIES.contains(propName) && !blackList?.contains(propName) && + (whiteList == null || whiteList.is(BIND_ALL_BINDING_INCLUDE_LIST) || whiteList.contains(propName) || whiteList.any { item -> item?.toString()?.startsWith(propName + '.') }) } - static boolean isPropertyExcluded(String propertyName, List excludeList) { - excludeList?.any { item -> - String excludeName = item?.toString() - excludeName == propertyName || propertyName.startsWith(excludeName + '.') || - (excludeName?.endsWith('.*') && propertyName.startsWith(excludeName.substring(0, excludeName.length() - 1))) || - (excludeName?.endsWith('_*') && propertyName.startsWith(excludeName.substring(0, excludeName.length() - 1))) - } ?: false - } - - private static boolean isFrameworkProperty(String propertyName) { - 'class' == propertyName || 'classLoader' == propertyName || 'protectionDomain' == propertyName || - 'metaClass' == propertyName || 'metaPropertyValues' == propertyName || 'properties' == propertyName - } - /** * 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-web-databinding/src/main/groovy/grails/web/databinding/GrailsWebDataBinder.groovy b/grails-web-databinding/src/main/groovy/grails/web/databinding/GrailsWebDataBinder.groovy index d4d91e30bbf..54732a0a7ba 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() @@ -239,7 +244,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 @@ -630,7 +635,7 @@ class GrailsWebDataBinder extends SimpleDataBinder { instance = referencedType.getDeclaredConstructor().newInstance() } catch (NoSuchMethodException | IllegalAccessException ignored) { if (value instanceof Map) { - if (isBindAllBindingIncludeList(includeList) || + if (isBindAllIncludeList(includeList) || DataBindingUtils.isLegacyBindableDefaultEnabled()) { return referencedType.newInstance(filterUnbindableMapConstructorArguments(referencedType, (Map) value)) } From dc719ce0997eefbfb616e28aadd58e039e6e2612 Mon Sep 17 00:00:00 2001 From: James Fredley Date: Sun, 2 Aug 2026 17:19:19 -0400 Subject: [PATCH 03/13] Extract nullMissing property clearing Move omitted-property path handling into a dedicated collaborator, preserve existing binding errors, report clear failures, and reset primitive properties to type defaults. Assisted-by: opencode:gpt-5.6-sol --- .../web/servlet/BindDataMethodTests.groovy | 84 ++ .../web/databinding/DataBindingUtils.java | 669 +--------------- .../NullMissingPropertyClearer.java | 751 ++++++++++++++++++ 3 files changed, 878 insertions(+), 626 deletions(-) create mode 100644 grails-web-databinding/src/main/groovy/grails/web/databinding/NullMissingPropertyClearer.java 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 1246c644f1f..ba6d817e5bf 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 @@ -329,6 +329,9 @@ class BindDataMethodTests extends Specification implements ControllerUnitTest warnings = [] 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 2f8af4ee650..c8a0db2809d 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,8 +21,6 @@ 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.math.BigDecimal; import java.math.BigInteger; import java.net.URL; @@ -33,7 +31,6 @@ 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; @@ -55,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; @@ -87,9 +83,6 @@ public class DataBindingUtils { private static final List NO_BINDING_INCLUDE_LIST = new NoBindingIncludeList(); private static final Map CLASS_TO_BINDING_INCLUDE_LIST = new ConcurrentHashMap<>(); private static final Map CLASS_TO_LEGACY_BINDING_INCLUDE_LIST = new ConcurrentHashMap<>(); - private static final Set FRAMEWORK_MANAGED_PROPERTIES = Set.of( - "class", "classLoader", "protectionDomain", "metaClass", "metaPropertyValues", "properties", - "errors", "id", "version", "dateCreated", "lastUpdated"); private static final Map CLASS_TO_UNBINDABLE_PROPERTY_NAMES = new ConcurrentHashMap<>(); private static final class NoBindingIncludeList extends ArrayList { @@ -525,6 +518,18 @@ public static BindingResult bindObjectToInstance(Object object, Object source, L 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 nullMissing} 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 nullMissing Whether omitted explicitly included properties should be cleared after normal binding completes + * @return A BindingResult containing null-clearing failures, or null when no such failures occurred. 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 nullMissing) { boolean explicitInclude = include != null; if (include == null) { @@ -532,10 +537,10 @@ public static BindingResult bindObjectToInstance(Object object, Object source, L 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(); @@ -564,6 +569,19 @@ else if (include.isEmpty() && !SimpleDataBinder.isBindAllBindingIncludeList(incl * * @return A BindingResult if there were errors or null if it was successful */ + /** + * 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 list of properties to include. {@code nullMissing} 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 nullMissing Whether omitted explicitly included properties should be cleared after normal binding completes + * @return A BindingResult containing null-clearing failures, or null when no such failures occurred. 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) { @@ -571,10 +589,10 @@ public static BindingResult bindObjectToDomainInstance(PersistentEntity entity, if (exclude == null || !isLegacyBindableDefaultEnabled()) { include = getBindingIncludeList(object); } else { - 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); } return bindObjectToDomainInstance(entity, object, source, include, exclude, filter, false); @@ -587,7 +605,7 @@ public static BindingResult bindObjectToDomainInstance(PersistentEntity entity, if (include == null) { include = getBindingIncludeList(object); } - else if (include.isEmpty()) { + else if (include.isEmpty() && !GrailsWebDataBinder.isBindAllIncludeList(include)) { include = Collections.singletonList(DefaultASTDatabindingHelper.NO_BINDABLE_PROPERTIES); } BindingResult bindingResult = null; @@ -598,7 +616,19 @@ else if (include.isEmpty()) { final DataBinder grailsWebDataBinder = getGrailsWebDataBinder(grailsApplication); grailsWebDataBinder.bind(object, bindingSource, filter, include, exclude); if (nullMissing && explicitInclude && !include.isEmpty()) { - assignNullToMissingIncludedProperties(object, bindingSource, include, exclude, filter); + BeanPropertyBindingResult nullMissingResult = 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) { + nullMissingResult.addAllErrors((BindingResult) existingErrors); + } + } + NullMissingPropertyClearer.clearMissingIncludedProperties( + object, bindingSource, include, exclude, filter, nullMissingResult); + if (nullMissingResult.hasErrors()) { + bindingResult = nullMissingResult; + } } } catch (InvalidRequestBodyException e) { String messageCode = "invalidRequestBody"; @@ -649,619 +679,6 @@ else if (include.isEmpty()) { 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 && isNullMissingPropertyBindable(object, propertyName, include, exclude)) { - if (assignNullToMissingIndexedProperties(object, bindingSource, propertyName, filter)) { - continue; - } - if (!bindingSourceContainsProperty(bindingSource, propertyName, filter)) { - setPropertyToNull(object, propertyName); - } - } - } - } - } - - private static boolean isNullMissingPropertyBindable(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 (!isNullMissingPropertyPathAllowed(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 && !isNullMissingPropertyBindable(item, nestedPropertyName, getNestedIncludeList(include, propertyName), null)) { - return false; - } - } - return true; - } - if (nestedObject instanceof Map) { - for (Object value : ((Map) nestedObject).values()) { - if (value != null && !isNullMissingPropertyBindable(value, nestedPropertyName, getNestedIncludeList(include, propertyName), null)) { - return false; - } - } - return true; - } - return nestedObject == null || isNullMissingPropertyBindable(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 isNullMissingPropertyPathAllowed(String propertyName, List generatedIncludeList, List explicitIncludeList, List excludeList) { - if (isFrameworkManagedProperty(propertyName) || SimpleDataBinder.isPropertyExcluded(propertyName, excludeList)) { - return false; - } - return isNullMissingPropertyIncluded(propertyName, generatedIncludeList) || - isNullMissingPropertyIncluded(propertyName, explicitIncludeList); - } - - private static boolean isFrameworkManagedProperty(String propertyName) { - int separator = propertyPathSeparator(propertyName); - String rootPropertyName = separator == -1 ? propertyName : propertyName.substring(0, separator); - return FRAMEWORK_MANAGED_PROPERTIES.contains(rootPropertyName); - } - - private static boolean isNullMissingPropertyIncluded(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[] 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}; diff --git a/grails-web-databinding/src/main/groovy/grails/web/databinding/NullMissingPropertyClearer.java b/grails-web-databinding/src/main/groovy/grails/web/databinding/NullMissingPropertyClearer.java new file mode 100644 index 00000000000..56ba14d0277 --- /dev/null +++ b/grails-web-databinding/src/main/groovy/grails/web/databinding/NullMissingPropertyClearer.java @@ -0,0 +1,751 @@ +/* + * 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. + */ +final class NullMissingPropertyClearer { + + private static final String BLANK = ""; + private static final ThreadLocal BINDING_RESULT = new ThreadLocal<>(); + private static final ThreadLocal CLEAR_PATH = new ThreadLocal<>(); + + private NullMissingPropertyClearer() { + } + static void clearMissingIncludedProperties(Object object, DataBindingSource bindingSource, List include, List exclude, + String filter, BindingResult bindingResult) { + BindingResult previousBindingResult = BINDING_RESULT.get(); + BINDING_RESULT.set(bindingResult); + try { + for (Object includedProperty : include) { + if (includedProperty instanceof CharSequence) { + String propertyName = includedProperty.toString(); + if (propertyName.indexOf('*') == -1 && isNullMissingPropertyBindable(object, propertyName, include, 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 boolean isNullMissingPropertyBindable(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 (!isNullMissingPropertyPathAllowed(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 && !isNullMissingPropertyBindable(item, nestedPropertyName, getNestedIncludeList(include, propertyName), null)) { + return false; + } + } + return true; + } + if (nestedObject instanceof Map) { + for (Object value : ((Map) nestedObject).values()) { + if (value != null && !isNullMissingPropertyBindable(value, nestedPropertyName, getNestedIncludeList(include, propertyName), null)) { + return false; + } + } + return true; + } + return nestedObject == null || isNullMissingPropertyBindable(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 isNullMissingPropertyPathAllowed(String propertyName, List generatedIncludeList, List explicitIncludeList, List excludeList) { + if (isFrameworkManagedProperty(propertyName) || isPropertyExcluded(propertyName, excludeList)) { + return false; + } + return isNullMissingPropertyIncluded(propertyName, generatedIncludeList) || + isNullMissingPropertyIncluded(propertyName, explicitIncludeList); + } + + private static boolean isFrameworkManagedProperty(String propertyName) { + int separator = propertyPathSeparator(propertyName); + String rootPropertyName = separator == -1 ? propertyName : propertyName.substring(0, separator); + return FrameworkPropertyNames.FRAMEWORK_MANAGED_PROPERTIES.contains(rootPropertyName); + } + + private static boolean isNullMissingPropertyIncluded(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; + } + } + +} From 2b53efd3701dd121ee068be8be971042f7ba37a0 Mon Sep 17 00:00:00 2001 From: James Fredley Date: Sun, 2 Aug 2026 17:21:31 -0400 Subject: [PATCH 04/13] Fix domain binding spec cleanup lifecycle Remove the duplicate specification cleanup that referenced per-feature state and prevented the merged test source from compiling. Assisted-by: opencode:gpt-5.6-sol --- ...TDatabindingHelperDomainClassSpecialPropertiesSpec.groovy | 5 ----- 1 file changed, 5 deletions(-) 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 9826be7049c..f3ea9e8f7c8 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 @@ -49,11 +49,6 @@ class DefaultASTDatabindingHelperDomainClassSpecialPropertiesSpec extends grails.web.databinding.GrailsWebDataBinder.resetWarnedBindingShapes() } - def cleanupSpec() { - ConstraintEvalUtils.clearDefaultConstraints() - Holders.setConfig(originalConfig) - } - @Issue('GRAILS-11173') void 'Test binding to special properties in a domain class'() { when: From 50714be10c63d726fd5642e72f299af3d1d08ad7 Mon Sep 17 00:00:00 2001 From: James Fredley Date: Sun, 2 Aug 2026 17:39:16 -0400 Subject: [PATCH 05/13] Keep Grails-managed domain properties bindable when opted in SimpleDataBinder must only hard-deny intrinsic runtime properties. Grails-managed id/version/dateCreated/lastUpdated/errors remain excluded from default allowlists and nullMissing clearing, but can still bind when explicitly allowed (bindable: true). Assisted-by: Sisyphus:xai/grok-4.5 --- .../databinding/FrameworkPropertyNames.java | 23 ++++++++++++++++++- .../databinding/SimpleDataBinder.groovy | 8 +++++-- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/grails-databinding-core/src/main/groovy/grails/databinding/FrameworkPropertyNames.java b/grails-databinding-core/src/main/groovy/grails/databinding/FrameworkPropertyNames.java index 9d853fbffec..092486797a9 100644 --- a/grails-databinding-core/src/main/groovy/grails/databinding/FrameworkPropertyNames.java +++ b/grails-databinding-core/src/main/groovy/grails/databinding/FrameworkPropertyNames.java @@ -21,10 +21,31 @@ import java.util.Set; /** - * Property names managed by the language runtime or Grails rather than request data binding. + * 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 and from {@code nullMissing} clearing by default, + * but may still bind when an application explicitly opts them in (for example + * {@code bindable: true}). */ 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 and + * {@code nullMissing} clearing unless explicitly opted in. + */ + 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"); 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 184bf9b013a..d06982670a4 100755 --- a/grails-databinding-core/src/main/groovy/grails/databinding/SimpleDataBinder.groovy +++ b/grails-databinding-core/src/main/groovy/grails/databinding/SimpleDataBinder.groovy @@ -271,8 +271,12 @@ class SimpleDataBinder implements DataBinder { } protected boolean isOkToBind(String propName, List whiteList, List blackList) { - !FrameworkPropertyNames.FRAMEWORK_MANAGED_PROPERTIES.contains(propName) && !blackList?.contains(propName) && - (whiteList == null || whiteList.is(BIND_ALL_BINDING_INCLUDE_LIST) || whiteList.contains(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); the AST helper and nullMissing + // clearer apply the broader FRAMEWORK_MANAGED_PROPERTIES set. + !FrameworkPropertyNames.INTRINSIC_RUNTIME_PROPERTIES.contains(propName) && !blackList?.contains(propName) && + (whiteList == null || isBindAllBindingIncludeList(whiteList) || whiteList.contains(propName) || whiteList.any { item -> item?.toString()?.startsWith(propName + '.') }) } From 8cc9ceed92bf4b6ac5415f340465c11b2dd8a363 Mon Sep 17 00:00:00 2001 From: Mattias Reichel Date: Tue, 11 Aug 2026 13:12:57 +0200 Subject: [PATCH 06/13] Rename `nullMissing` to `clearMissing` --- .../databinding/FrameworkPropertyNames.java | 4 +- .../databinding/SimpleDataBinder.groovy | 2 +- .../src/en/guide/upgrading/upgrading80x.adoc | 2 +- .../src/en/ref/Controllers/bindData.adoc | 6 +- .../web/servlet/BindDataMethodTests.groovy | 138 +++++++++--------- .../grails/web/databinding/DataBinder.groovy | 4 +- .../web/databinding/DataBindingUtils.java | 28 ++-- ...earer.java => MissingPropertyClearer.java} | 26 ++-- 8 files changed, 106 insertions(+), 104 deletions(-) rename grails-web-databinding/src/main/groovy/grails/web/databinding/{NullMissingPropertyClearer.java => MissingPropertyClearer.java} (96%) diff --git a/grails-databinding-core/src/main/groovy/grails/databinding/FrameworkPropertyNames.java b/grails-databinding-core/src/main/groovy/grails/databinding/FrameworkPropertyNames.java index 092486797a9..51f7834a34c 100644 --- a/grails-databinding-core/src/main/groovy/grails/databinding/FrameworkPropertyNames.java +++ b/grails-databinding-core/src/main/groovy/grails/databinding/FrameworkPropertyNames.java @@ -24,7 +24,7 @@ * 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 and from {@code nullMissing} clearing by default, + * are excluded from generated allowlists and from {@code clearMissing} clearing by default, * but may still bind when an application explicitly opts them in (for example * {@code bindable: true}). */ @@ -38,7 +38,7 @@ public final class FrameworkPropertyNames { /** * Grails domain lifecycle properties excluded from default binding allowlists and - * {@code nullMissing} clearing unless explicitly opted in. + * {@code clearMissing} clearing unless explicitly opted in. */ public static final Set GRAILS_MANAGED_PROPERTIES = Set.of( "errors", "id", "version", "dateCreated", "lastUpdated"); 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 d06982670a4..27c52f72011 100755 --- a/grails-databinding-core/src/main/groovy/grails/databinding/SimpleDataBinder.groovy +++ b/grails-databinding-core/src/main/groovy/grails/databinding/SimpleDataBinder.groovy @@ -273,7 +273,7 @@ class SimpleDataBinder implements DataBinder { protected boolean isOkToBind(String propName, List whiteList, List blackList) { // 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); the AST helper and nullMissing + // explicitly allowlisted (e.g. bindable: true); the AST helper and clearMissing // clearer apply the broader FRAMEWORK_MANAGED_PROPERTIES set. !FrameworkPropertyNames.INTRINSIC_RUNTIME_PROPERTIES.contains(propName) && !blackList?.contains(propName) && (whiteList == null || isBindAllBindingIncludeList(whiteList) || whiteList.contains(propName) || diff --git a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc index b6c373721d3..b645cd9c8f9 100644 --- a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc +++ b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc @@ -826,7 +826,7 @@ The Spring annotations still work, so this is non-blocking, but new code should 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. +`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 diff --git a/grails-doc/src/en/ref/Controllers/bindData.adoc b/grails-doc/src/en/ref/Controllers/bindData.adoc index ad3eb6a79be..16f63f4f051 100644 --- a/grails-doc/src/en/ref/Controllers/bindData.adoc +++ b/grails-doc/src/en/ref/Controllers/bindData.adoc @@ -47,7 +47,7 @@ bindData(target, params, [exclude: ['firstName', 'lastName']], "author") bindData(target, params, [include: ['firstName', 'lastName']], "author") // clear included properties omitted from the source -bindData(target, params, [include: ['firstName', 'lastName'], nullMissing: true]) +bindData(target, params, [include: ['firstName', 'lastName'], clearMissing: true]) ---- @@ -60,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. Set `nullMissing: true` with an `include` list to assign `null` to included properties that are omitted from the binding source. +* `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. @@ -91,7 +91,7 @@ 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. -`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. +`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. 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 2d695b1cbd8..4104a5dd548 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 @@ -147,9 +147,9 @@ class BindDataMethodTests extends Specification implements ControllerUnitTest BINDING_RESULT = new ThreadLocal<>(); private static final ThreadLocal CLEAR_PATH = new ThreadLocal<>(); - private NullMissingPropertyClearer() { + private MissingPropertyClearer() { } static void clearMissingIncludedProperties(Object object, DataBindingSource bindingSource, List include, List exclude, @@ -62,7 +64,7 @@ static void clearMissingIncludedProperties(Object object, DataBindingSource bind for (Object includedProperty : include) { if (includedProperty instanceof CharSequence) { String propertyName = includedProperty.toString(); - if (propertyName.indexOf('*') == -1 && isNullMissingPropertyBindable(object, propertyName, include, exclude)) { + if (propertyName.indexOf('*') == -1 && isClearMissingPropertyBindable(object, propertyName, include, exclude)) { if (assignNullToMissingIndexedProperties(object, bindingSource, propertyName, filter)) { continue; } @@ -83,14 +85,14 @@ static void clearMissingIncludedProperties(Object object, DataBindingSource bind } } - private static boolean isNullMissingPropertyBindable(Object object, String propertyName, List include, List exclude) { + 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 (!isNullMissingPropertyPathAllowed(allowlistPropertyName, bindingIncludeList, include, bindingExcludeList)) { + if (!isClearMissingPropertyPathAllowed(allowlistPropertyName, bindingIncludeList, include, bindingExcludeList)) { return false; } int separator = propertyPathSeparator(propertyName); @@ -102,7 +104,7 @@ private static boolean isNullMissingPropertyBindable(Object object, String prope String nestedPropertyName = propertyName.substring(separator + 1); if (nestedObject instanceof Collection) { for (Object item : (Collection) nestedObject) { - if (item != null && !isNullMissingPropertyBindable(item, nestedPropertyName, getNestedIncludeList(include, propertyName), null)) { + if (item != null && !isClearMissingPropertyBindable(item, nestedPropertyName, getNestedIncludeList(include, propertyName), null)) { return false; } } @@ -110,13 +112,13 @@ private static boolean isNullMissingPropertyBindable(Object object, String prope } if (nestedObject instanceof Map) { for (Object value : ((Map) nestedObject).values()) { - if (value != null && !isNullMissingPropertyBindable(value, nestedPropertyName, getNestedIncludeList(include, propertyName), null)) { + if (value != null && !isClearMissingPropertyBindable(value, nestedPropertyName, getNestedIncludeList(include, propertyName), null)) { return false; } } return true; } - return nestedObject == null || isNullMissingPropertyBindable(nestedObject, nestedPropertyName, getNestedIncludeList(include, propertyName), null); + return nestedObject == null || isClearMissingPropertyBindable(nestedObject, nestedPropertyName, getNestedIncludeList(include, propertyName), null); } private static String removePropertyIndexes(String propertyName) { @@ -172,12 +174,12 @@ private static boolean isPropertyExcluded(String propertyName, List excludeList) return false; } - private static boolean isNullMissingPropertyPathAllowed(String propertyName, List generatedIncludeList, List explicitIncludeList, List excludeList) { + private static boolean isClearMissingPropertyPathAllowed(String propertyName, List generatedIncludeList, List explicitIncludeList, List excludeList) { if (isFrameworkManagedProperty(propertyName) || isPropertyExcluded(propertyName, excludeList)) { return false; } - return isNullMissingPropertyIncluded(propertyName, generatedIncludeList) || - isNullMissingPropertyIncluded(propertyName, explicitIncludeList); + return isClearMissingPropertyIncluded(propertyName, generatedIncludeList) || + isClearMissingPropertyIncluded(propertyName, explicitIncludeList); } private static boolean isFrameworkManagedProperty(String propertyName) { @@ -186,7 +188,7 @@ private static boolean isFrameworkManagedProperty(String propertyName) { return FrameworkPropertyNames.FRAMEWORK_MANAGED_PROPERTIES.contains(rootPropertyName); } - private static boolean isNullMissingPropertyIncluded(String propertyName, List includeList) { + private static boolean isClearMissingPropertyIncluded(String propertyName, List includeList) { if (includeList == null) { return false; } From c4e58c24ec879e814a7e34b88bd7f302d359413f Mon Sep 17 00:00:00 2001 From: Mattias Reichel Date: Tue, 11 Aug 2026 13:20:10 +0200 Subject: [PATCH 07/13] docs: clarify behavior of clearMissing in FrameworkPropertyNames --- .../grails/databinding/FrameworkPropertyNames.java | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/grails-databinding-core/src/main/groovy/grails/databinding/FrameworkPropertyNames.java b/grails-databinding-core/src/main/groovy/grails/databinding/FrameworkPropertyNames.java index 51f7834a34c..9b8d93166a1 100644 --- a/grails-databinding-core/src/main/groovy/grails/databinding/FrameworkPropertyNames.java +++ b/grails-databinding-core/src/main/groovy/grails/databinding/FrameworkPropertyNames.java @@ -24,9 +24,9 @@ * 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 and from {@code clearMissing} clearing by default, - * but may still bind when an application explicitly opts them in (for example - * {@code bindable: true}). + * are excluded from generated allowlists by default, but may still bind when an application + * explicitly opts them in (for example {@code bindable: true}); neither category is cleared + * by {@code clearMissing}. */ public final class FrameworkPropertyNames { @@ -37,8 +37,8 @@ public final class FrameworkPropertyNames { "class", "classLoader", "protectionDomain", "metaClass", "metaPropertyValues", "properties"); /** - * Grails domain lifecycle properties excluded from default binding allowlists and - * {@code clearMissing} clearing unless explicitly opted in. + * Grails domain lifecycle properties excluded from default binding allowlists and always + * excluded from {@code clearMissing} clearing. */ public static final Set GRAILS_MANAGED_PROPERTIES = Set.of( "errors", "id", "version", "dateCreated", "lastUpdated"); From 3352c00b7fee2ebd6a51300cfd033522bfe219af Mon Sep 17 00:00:00 2001 From: Mattias Reichel Date: Tue, 11 Aug 2026 13:25:13 +0200 Subject: [PATCH 08/13] test: improve clarity of test names for clearMissing behavior --- .../web/servlet/BindDataMethodTests.groovy | 46 +++++++++---------- 1 file changed, 23 insertions(+), 23 deletions(-) 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 4104a5dd548..f0457adedc4 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 @@ -147,7 +147,7 @@ class BindDataMethodTests extends Specification implements ControllerUnitTest Date: Tue, 11 Aug 2026 14:05:05 +0200 Subject: [PATCH 09/13] docs: enhance bindData documentation with wildcard usage and clearMissing behavior --- grails-doc/src/en/ref/Controllers/bindData.adoc | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/grails-doc/src/en/ref/Controllers/bindData.adoc b/grails-doc/src/en/ref/Controllers/bindData.adoc index 16f63f4f051..cfa329b70f9 100644 --- a/grails-doc/src/en/ref/Controllers/bindData.adoc +++ b/grails-doc/src/en/ref/Controllers/bindData.adoc @@ -76,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] From 0c876331b0b70973e1e44e49d796ccd632b47fef Mon Sep 17 00:00:00 2001 From: Mattias Reichel Date: Tue, 11 Aug 2026 14:16:07 +0200 Subject: [PATCH 10/13] test: improve clarity of bindData test names and enhance clearMissing behavior --- .../web/servlet/BindDataMethodTests.groovy | 165 ++++++++++++------ 1 file changed, 115 insertions(+), 50 deletions(-) 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 f0457adedc4..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 Date: Tue, 11 Aug 2026 14:17:05 +0200 Subject: [PATCH 11/13] feat: implement wildcard expansion for included properties in clearMissing --- .../databinding/MissingPropertyClearer.java | 78 +++++++++++++++++-- 1 file changed, 73 insertions(+), 5 deletions(-) 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 index bde19a78109..2a031bf929c 100644 --- a/grails-web-databinding/src/main/groovy/grails/web/databinding/MissingPropertyClearer.java +++ b/grails-web-databinding/src/main/groovy/grails/web/databinding/MissingPropertyClearer.java @@ -58,13 +58,14 @@ 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 : include) { + for (Object includedProperty : expandedInclude) { if (includedProperty instanceof CharSequence) { String propertyName = includedProperty.toString(); - if (propertyName.indexOf('*') == -1 && isClearMissingPropertyBindable(object, propertyName, include, exclude)) { + if (isClearMissingPropertyBindable(object, propertyName, expandedInclude, exclude)) { if (assignNullToMissingIndexedProperties(object, bindingSource, propertyName, filter)) { continue; } @@ -85,6 +86,73 @@ static void clearMissingIncludedProperties(Object object, DataBindingSource bind } } + 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; @@ -175,17 +243,17 @@ private static boolean isPropertyExcluded(String propertyName, List excludeList) } private static boolean isClearMissingPropertyPathAllowed(String propertyName, List generatedIncludeList, List explicitIncludeList, List excludeList) { - if (isFrameworkManagedProperty(propertyName) || isPropertyExcluded(propertyName, excludeList)) { + if (isIntrinsicRuntimeProperty(propertyName) || isPropertyExcluded(propertyName, excludeList)) { return false; } return isClearMissingPropertyIncluded(propertyName, generatedIncludeList) || isClearMissingPropertyIncluded(propertyName, explicitIncludeList); } - private static boolean isFrameworkManagedProperty(String propertyName) { + private static boolean isIntrinsicRuntimeProperty(String propertyName) { int separator = propertyPathSeparator(propertyName); String rootPropertyName = separator == -1 ? propertyName : propertyName.substring(0, separator); - return FrameworkPropertyNames.FRAMEWORK_MANAGED_PROPERTIES.contains(rootPropertyName); + return FrameworkPropertyNames.INTRINSIC_RUNTIME_PROPERTIES.contains(rootPropertyName); } private static boolean isClearMissingPropertyIncluded(String propertyName, List includeList) { From 4633c6777ba6e6fe69fb3d4a2672e0c61aac5e73 Mon Sep 17 00:00:00 2001 From: Mattias Reichel Date: Tue, 11 Aug 2026 14:17:26 +0200 Subject: [PATCH 12/13] docs: clarify behavior of clearMissing for Grails-managed properties --- .../grails/databinding/FrameworkPropertyNames.java | 10 +++++----- .../groovy/grails/databinding/SimpleDataBinder.groovy | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/grails-databinding-core/src/main/groovy/grails/databinding/FrameworkPropertyNames.java b/grails-databinding-core/src/main/groovy/grails/databinding/FrameworkPropertyNames.java index 9b8d93166a1..89f5c81469d 100644 --- a/grails-databinding-core/src/main/groovy/grails/databinding/FrameworkPropertyNames.java +++ b/grails-databinding-core/src/main/groovy/grails/databinding/FrameworkPropertyNames.java @@ -24,9 +24,9 @@ * 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 when an application - * explicitly opts them in (for example {@code bindable: true}); neither category is cleared - * by {@code clearMissing}. + * 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 { @@ -37,8 +37,8 @@ public final class FrameworkPropertyNames { "class", "classLoader", "protectionDomain", "metaClass", "metaPropertyValues", "properties"); /** - * Grails domain lifecycle properties excluded from default binding allowlists and always - * excluded from {@code clearMissing} clearing. + * 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"); 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 27c52f72011..c84fe8b77b2 100755 --- a/grails-databinding-core/src/main/groovy/grails/databinding/SimpleDataBinder.groovy +++ b/grails-databinding-core/src/main/groovy/grails/databinding/SimpleDataBinder.groovy @@ -273,8 +273,8 @@ class SimpleDataBinder implements DataBinder { protected boolean isOkToBind(String propName, List whiteList, List blackList) { // 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); the AST helper and clearMissing - // clearer apply the broader FRAMEWORK_MANAGED_PROPERTIES set. + // 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 + '.') }) From ecff934dbc4acc6aead4241ed8f3b7a3e9165fac Mon Sep 17 00:00:00 2001 From: Mattias Reichel Date: Fri, 14 Aug 2026 13:44:50 +0200 Subject: [PATCH 13/13] chore: cleanup `DefaultASTDatabindingHelperDomainClassSpecialPropertiesSpec` --- ...perDomainClassSpecialPropertiesSpec.groovy | 36 +++++++++---------- 1 file changed, 17 insertions(+), 19 deletions(-) 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 81fb9a627c2..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,42 +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.Shared -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 @@ -141,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') @@ -151,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')