mirror of
https://github.com/spring-projects/spring-framework.git
synced 2026-09-20 04:09:04 +00:00
Use PropertyPath instead of utility methods
Prior to this commit, `DataBinder` and the property accessor hierarchy relied on `PropertyAccessorUtils` and several independent scanners for property paths. This commit migrates all of them to `PropertyPath`, so there is exactly one parser deciding what a well-formed property path is, used identically for policy checks and for actual navigation. This removes long standing protected methods like A`getPropertyAccessorForPropertyPathi` and `getFinalPath` from `bstractNestablePropertyAccessor`. The path is now parsed exactly once per public entry point, via the new `resolvePropertyPath`, which returns a `ResolvedProperty`. Then, property navigation walks the parsed segment list rather than re-scanning partial strings. Any subclass overriding the removed method will need to adapt. This removal initially conflicted with gh-37252 (maxNestedPathDepth support). The public configuration remains but the actual behavior changed; it is replaced with `PropertyPath.Options` which enforces limit right after parsing, before property navigation begins. The exception thrown changes from `InvalidPropertyException` to `InvalidPropertyPathException`. This commmit also reverts the `map[']` / `map["]` quoting behavior from gh-36765 as it is incompatible with the new grammar. See gh-37275
This commit is contained in:
+166
-210
@@ -23,7 +23,6 @@ import java.lang.reflect.InvocationTargetException;
|
||||
import java.lang.reflect.Modifier;
|
||||
import java.lang.reflect.UndeclaredThrowableException;
|
||||
import java.security.PrivilegedActionException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.Iterator;
|
||||
@@ -42,7 +41,6 @@ import org.springframework.core.convert.ConverterNotFoundException;
|
||||
import org.springframework.core.convert.TypeDescriptor;
|
||||
import org.springframework.util.Assert;
|
||||
import org.springframework.util.ObjectUtils;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
/**
|
||||
* A basic {@link ConfigurablePropertyAccessor} that provides the necessary
|
||||
@@ -60,6 +58,7 @@ import org.springframework.util.StringUtils;
|
||||
* @author Rod Johnson
|
||||
* @author Rob Harrop
|
||||
* @author Sam Brannen
|
||||
* @author Brian Clozel
|
||||
* @since 4.2
|
||||
* @see #registerCustomEditor
|
||||
* @see #setPropertyValues
|
||||
@@ -82,11 +81,8 @@ public abstract class AbstractNestablePropertyAccessor extends AbstractPropertyA
|
||||
|
||||
@Nullable Object rootObject;
|
||||
|
||||
/** Map with cached nested Accessors: nested path -> Accessor instance. */
|
||||
private @Nullable Map<String, AbstractNestablePropertyAccessor> nestedPropertyAccessors;
|
||||
|
||||
/** The number of nested properties traversed to reach the wrapped object. */
|
||||
private int nestedPathDepth;
|
||||
/** Map with cached nested Accessors: path segment -> Accessor instance. */
|
||||
private @Nullable Map<PropertyPath.Segment, AbstractNestablePropertyAccessor> nestedPropertyAccessors;
|
||||
|
||||
|
||||
/**
|
||||
@@ -180,7 +176,6 @@ public abstract class AbstractNestablePropertyAccessor extends AbstractPropertyA
|
||||
this.nestedPath = (nestedPath != null ? nestedPath : "");
|
||||
this.rootObject = (!this.nestedPath.isEmpty() ? rootObject : this.wrappedObject);
|
||||
this.nestedPropertyAccessors = null;
|
||||
this.nestedPathDepth = 0;
|
||||
this.typeConverterDelegate = new TypeConverterDelegate(this, this.wrappedObject);
|
||||
}
|
||||
|
||||
@@ -219,61 +214,70 @@ public abstract class AbstractNestablePropertyAccessor extends AbstractPropertyA
|
||||
|
||||
@Override
|
||||
public void setPropertyValue(String propertyName, @Nullable Object value) throws BeansException {
|
||||
AbstractNestablePropertyAccessor nestedPa;
|
||||
ResolvedProperty resolved;
|
||||
try {
|
||||
nestedPa = getPropertyAccessorForPropertyPath(propertyName);
|
||||
resolved = resolvePropertyPath(propertyName);
|
||||
}
|
||||
catch (InvalidPropertyPathException ex) {
|
||||
// A malformed path is a syntax error, distinct from a syntactically
|
||||
// valid path whose intermediate segment genuinely does not exist
|
||||
// (caught below and reported as "not writable" instead).
|
||||
throw new InvalidPropertyPathException(getRootInstance(), this.nestedPath + propertyName, value, ex);
|
||||
}
|
||||
catch (NotReadablePropertyException ex) {
|
||||
throw new NotWritablePropertyException(getRootClass(), this.nestedPath + propertyName,
|
||||
"Nested property in path '" + propertyName + "' does not exist", ex);
|
||||
}
|
||||
PropertyTokenHolder tokens = getPropertyNameTokens(getFinalPath(nestedPa, propertyName));
|
||||
nestedPa.setPropertyValue(tokens, new PropertyValue(propertyName, value));
|
||||
resolved.accessor().setPropertyValue(resolved.segment(), new PropertyValue(propertyName, value));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setPropertyValue(PropertyValue pv) throws BeansException {
|
||||
PropertyTokenHolder tokens = (PropertyTokenHolder) pv.resolvedTokens;
|
||||
if (tokens == null) {
|
||||
PropertyPath.Segment segment = (PropertyPath.Segment) pv.resolvedTokens;
|
||||
if (segment == null) {
|
||||
String propertyName = pv.getName();
|
||||
AbstractNestablePropertyAccessor nestedPa;
|
||||
ResolvedProperty resolved;
|
||||
try {
|
||||
nestedPa = getPropertyAccessorForPropertyPath(propertyName);
|
||||
resolved = resolvePropertyPath(propertyName);
|
||||
}
|
||||
catch (InvalidPropertyPathException ex) {
|
||||
throw new InvalidPropertyPathException(getRootInstance(), this.nestedPath + propertyName, pv.getValue(), ex);
|
||||
}
|
||||
catch (NotReadablePropertyException ex) {
|
||||
throw new NotWritablePropertyException(getRootClass(), this.nestedPath + propertyName,
|
||||
"Nested property in path '" + propertyName + "' does not exist", ex);
|
||||
}
|
||||
tokens = getPropertyNameTokens(getFinalPath(nestedPa, propertyName));
|
||||
if (nestedPa == this) {
|
||||
pv.getOriginalPropertyValue().resolvedTokens = tokens;
|
||||
segment = resolved.segment();
|
||||
if (resolved.accessor() == this) {
|
||||
pv.getOriginalPropertyValue().resolvedTokens = segment;
|
||||
}
|
||||
nestedPa.setPropertyValue(tokens, pv);
|
||||
resolved.accessor().setPropertyValue(segment, pv);
|
||||
}
|
||||
else {
|
||||
setPropertyValue(tokens, pv);
|
||||
setPropertyValue(segment, pv);
|
||||
}
|
||||
}
|
||||
|
||||
protected void setPropertyValue(PropertyTokenHolder tokens, PropertyValue pv) throws BeansException {
|
||||
if (tokens.keys != null) {
|
||||
processKeyedProperty(tokens, pv);
|
||||
protected void setPropertyValue(PropertyPath.Segment segment, PropertyValue pv) throws BeansException {
|
||||
if (!segment.keys().isEmpty()) {
|
||||
processKeyedProperty(segment, pv);
|
||||
}
|
||||
else {
|
||||
processLocalProperty(tokens, pv);
|
||||
processLocalProperty(segment, pv);
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings({"rawtypes", "unchecked"})
|
||||
private void processKeyedProperty(PropertyTokenHolder tokens, PropertyValue pv) {
|
||||
Object propValue = getPropertyHoldingValue(tokens);
|
||||
PropertyHandler ph = getLocalPropertyHandler(tokens.actualName);
|
||||
private void processKeyedProperty(PropertyPath.Segment segment, PropertyValue pv) {
|
||||
Object propValue = getPropertyHoldingValue(segment);
|
||||
PropertyHandler ph = getLocalPropertyHandler(segment.name());
|
||||
if (ph == null) {
|
||||
throw new InvalidPropertyException(
|
||||
getRootClass(), this.nestedPath + tokens.actualName, "No property handler found");
|
||||
getRootClass(), this.nestedPath + segment.name(), "No property handler found");
|
||||
}
|
||||
Assert.state(tokens.keys != null, "No token keys");
|
||||
String lastKey = tokens.keys[tokens.keys.length - 1];
|
||||
List<String> keys = segment.keys();
|
||||
String lastKey = keys.get(keys.size() - 1);
|
||||
String canonicalName = segment.toCanonicalName();
|
||||
|
||||
if (propValue.getClass().isArray()) {
|
||||
Class<?> componentType = propValue.getClass().componentType();
|
||||
@@ -283,33 +287,32 @@ public abstract class AbstractNestablePropertyAccessor extends AbstractPropertyA
|
||||
if (isExtractOldValueForEditor() && arrayIndex < Array.getLength(propValue)) {
|
||||
oldValue = Array.get(propValue, arrayIndex);
|
||||
}
|
||||
Object convertedValue = convertIfNecessary(tokens.canonicalName, oldValue, pv.getValue(),
|
||||
componentType, ph.nested(tokens.keys.length));
|
||||
Object convertedValue = convertIfNecessary(canonicalName, oldValue, pv.getValue(),
|
||||
componentType, ph.nested(keys.size()));
|
||||
int length = Array.getLength(propValue);
|
||||
if (arrayIndex >= length && arrayIndex < getAutoGrowCollectionLimit()) {
|
||||
Object newArray = Array.newInstance(componentType, arrayIndex + 1);
|
||||
System.arraycopy(propValue, 0, newArray, 0, length);
|
||||
int lastKeyIndex = tokens.canonicalName.lastIndexOf('[');
|
||||
String propName = tokens.canonicalName.substring(0, lastKeyIndex);
|
||||
String propName = segment.withoutLastKey().toCanonicalName();
|
||||
setPropertyValue(propName, newArray);
|
||||
propValue = getPropertyValue(propName);
|
||||
}
|
||||
Array.set(propValue, arrayIndex, convertedValue);
|
||||
}
|
||||
catch (IndexOutOfBoundsException ex) {
|
||||
throw new InvalidPropertyException(getRootClass(), this.nestedPath + tokens.canonicalName,
|
||||
"Invalid array index in property path '" + tokens.canonicalName + "'", ex);
|
||||
throw new InvalidPropertyException(getRootClass(), this.nestedPath + canonicalName,
|
||||
"Invalid array index in property path '" + canonicalName + "'", ex);
|
||||
}
|
||||
}
|
||||
|
||||
else if (propValue instanceof List list) {
|
||||
TypeDescriptor requiredType = ph.getCollectionType(tokens.keys.length);
|
||||
TypeDescriptor requiredType = ph.getCollectionType(keys.size());
|
||||
int index = Integer.parseInt(lastKey);
|
||||
Object oldValue = null;
|
||||
if (isExtractOldValueForEditor() && index < list.size()) {
|
||||
oldValue = list.get(index);
|
||||
}
|
||||
Object convertedValue = convertIfNecessary(tokens.canonicalName, oldValue, pv.getValue(),
|
||||
Object convertedValue = convertIfNecessary(canonicalName, oldValue, pv.getValue(),
|
||||
requiredType.getResolvableType().resolve(), requiredType);
|
||||
int size = list.size();
|
||||
if (index >= size && index < getAutoGrowCollectionLimit()) {
|
||||
@@ -318,9 +321,9 @@ public abstract class AbstractNestablePropertyAccessor extends AbstractPropertyA
|
||||
list.add(null);
|
||||
}
|
||||
catch (NullPointerException ex) {
|
||||
throw new InvalidPropertyException(getRootClass(), this.nestedPath + tokens.canonicalName,
|
||||
throw new InvalidPropertyException(getRootClass(), this.nestedPath + canonicalName,
|
||||
"Cannot set element with index " + index + " in List of size " +
|
||||
size + ", accessed using property path '" + tokens.canonicalName +
|
||||
size + ", accessed using property path '" + canonicalName +
|
||||
"': List does not support filling up gaps with null elements");
|
||||
}
|
||||
}
|
||||
@@ -331,15 +334,15 @@ public abstract class AbstractNestablePropertyAccessor extends AbstractPropertyA
|
||||
list.set(index, convertedValue);
|
||||
}
|
||||
catch (IndexOutOfBoundsException ex) {
|
||||
throw new InvalidPropertyException(getRootClass(), this.nestedPath + tokens.canonicalName,
|
||||
"Invalid list index in property path '" + tokens.canonicalName + "'", ex);
|
||||
throw new InvalidPropertyException(getRootClass(), this.nestedPath + canonicalName,
|
||||
"Invalid list index in property path '" + canonicalName + "'", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
else if (propValue instanceof Map map) {
|
||||
TypeDescriptor mapKeyType = ph.getMapKeyType(tokens.keys.length);
|
||||
TypeDescriptor mapValueType = ph.getMapValueType(tokens.keys.length);
|
||||
TypeDescriptor mapKeyType = ph.getMapKeyType(keys.size());
|
||||
TypeDescriptor mapValueType = ph.getMapValueType(keys.size());
|
||||
// IMPORTANT: Do not pass full property name in here - property editors
|
||||
// must not kick in for map keys but rather only for map values.
|
||||
Object convertedMapKey = convertIfNecessary(null, null, lastKey,
|
||||
@@ -350,58 +353,54 @@ public abstract class AbstractNestablePropertyAccessor extends AbstractPropertyA
|
||||
}
|
||||
// Pass full property name and old value in here, since we want full
|
||||
// conversion ability for map values.
|
||||
Object convertedMapValue = convertIfNecessary(tokens.canonicalName, oldValue, pv.getValue(),
|
||||
Object convertedMapValue = convertIfNecessary(canonicalName, oldValue, pv.getValue(),
|
||||
mapValueType.getResolvableType().resolve(), mapValueType);
|
||||
map.put(convertedMapKey, convertedMapValue);
|
||||
}
|
||||
|
||||
else {
|
||||
throw new InvalidPropertyException(getRootClass(), this.nestedPath + tokens.canonicalName,
|
||||
"Property referenced in indexed property path '" + tokens.canonicalName +
|
||||
throw new InvalidPropertyException(getRootClass(), this.nestedPath + canonicalName,
|
||||
"Property referenced in indexed property path '" + canonicalName +
|
||||
"' is neither an array nor a List nor a Map; returned value was [" + propValue + "]");
|
||||
}
|
||||
}
|
||||
|
||||
private Object getPropertyHoldingValue(PropertyTokenHolder tokens) {
|
||||
private Object getPropertyHoldingValue(PropertyPath.Segment segment) {
|
||||
// Apply indexes and map keys: fetch value for all keys but the last one.
|
||||
Assert.state(tokens.keys != null, "No token keys");
|
||||
PropertyTokenHolder getterTokens = new PropertyTokenHolder(tokens.actualName);
|
||||
getterTokens.canonicalName = tokens.canonicalName;
|
||||
getterTokens.keys = new String[tokens.keys.length - 1];
|
||||
System.arraycopy(tokens.keys, 0, getterTokens.keys, 0, tokens.keys.length - 1);
|
||||
|
||||
PropertyPath.Segment getterSegment = segment.withoutLastKey();
|
||||
Object propValue;
|
||||
try {
|
||||
propValue = getPropertyValue(getterTokens);
|
||||
propValue = getPropertyValue(getterSegment);
|
||||
}
|
||||
catch (NotReadablePropertyException ex) {
|
||||
throw new NotWritablePropertyException(getRootClass(), this.nestedPath + tokens.canonicalName,
|
||||
throw new NotWritablePropertyException(getRootClass(), this.nestedPath + segment.toCanonicalName(),
|
||||
"Cannot access indexed value in property referenced " +
|
||||
"in indexed property path '" + tokens.canonicalName + "'", ex);
|
||||
"in indexed property path '" + segment.toCanonicalName() + "'", ex);
|
||||
}
|
||||
|
||||
if (propValue == null) {
|
||||
// null map value case
|
||||
if (isAutoGrowNestedPaths()) {
|
||||
int lastKeyIndex = tokens.canonicalName.lastIndexOf('[');
|
||||
getterTokens.canonicalName = tokens.canonicalName.substring(0, lastKeyIndex);
|
||||
propValue = setDefaultValue(getterTokens);
|
||||
propValue = setDefaultValue(getterSegment);
|
||||
}
|
||||
else {
|
||||
throw new NullValueInNestedPathException(getRootClass(), this.nestedPath + tokens.canonicalName,
|
||||
throw new NullValueInNestedPathException(getRootClass(), this.nestedPath + segment.toCanonicalName(),
|
||||
"Cannot access indexed value in property referenced " +
|
||||
"in indexed property path '" + tokens.canonicalName + "': returned null");
|
||||
"in indexed property path '" + segment.toCanonicalName() + "': returned null");
|
||||
}
|
||||
}
|
||||
return propValue;
|
||||
}
|
||||
|
||||
private void processLocalProperty(PropertyTokenHolder tokens, PropertyValue pv) {
|
||||
PropertyHandler ph = getLocalPropertyHandler(tokens.actualName);
|
||||
private void processLocalProperty(PropertyPath.Segment segment, PropertyValue pv) {
|
||||
// segment.keys() is always empty here (see setPropertyValue(Segment, PropertyValue)
|
||||
// above), so the segment's canonical name is always just its raw name.
|
||||
String name = segment.name();
|
||||
PropertyHandler ph = getLocalPropertyHandler(name);
|
||||
if (ph == null || !ph.isWritable()) {
|
||||
if (pv.isOptional()) {
|
||||
if (logger.isDebugEnabled()) {
|
||||
logger.debug("Ignoring optional value for property '" + tokens.actualName +
|
||||
logger.debug("Ignoring optional value for property '" + name +
|
||||
"' - property not found on bean class [" + getRootClass().getName() + "]");
|
||||
}
|
||||
return;
|
||||
@@ -411,7 +410,7 @@ public abstract class AbstractNestablePropertyAccessor extends AbstractPropertyA
|
||||
// exception would be caught and swallowed higher up anyway...
|
||||
return;
|
||||
}
|
||||
throw createNotWritablePropertyException(tokens.canonicalName);
|
||||
throw createNotWritablePropertyException(name);
|
||||
}
|
||||
|
||||
Object oldValue = null;
|
||||
@@ -433,12 +432,11 @@ public abstract class AbstractNestablePropertyAccessor extends AbstractPropertyA
|
||||
}
|
||||
if (logger.isDebugEnabled()) {
|
||||
logger.debug("Could not read previous value of property '" +
|
||||
this.nestedPath + tokens.canonicalName + "'", ex);
|
||||
this.nestedPath + name + "'", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
valueToApply = convertForProperty(
|
||||
tokens.canonicalName, oldValue, originalValue, ph.toTypeDescriptor());
|
||||
valueToApply = convertForProperty(name, oldValue, originalValue, ph.toTypeDescriptor());
|
||||
}
|
||||
pv.getOriginalPropertyValue().conversionNecessary = (valueToApply != originalValue);
|
||||
}
|
||||
@@ -451,7 +449,7 @@ public abstract class AbstractNestablePropertyAccessor extends AbstractPropertyA
|
||||
}
|
||||
catch (InvocationTargetException ex) {
|
||||
PropertyChangeEvent propertyChangeEvent = new PropertyChangeEvent(
|
||||
getRootInstance(), this.nestedPath + tokens.canonicalName, oldValue, pv.getValue());
|
||||
getRootInstance(), this.nestedPath + name, oldValue, pv.getValue());
|
||||
if (ex.getTargetException() instanceof ClassCastException) {
|
||||
throw new TypeMismatchException(propertyChangeEvent, ph.getPropertyType(), ex.getTargetException());
|
||||
}
|
||||
@@ -466,7 +464,7 @@ public abstract class AbstractNestablePropertyAccessor extends AbstractPropertyA
|
||||
}
|
||||
catch (Exception ex) {
|
||||
PropertyChangeEvent pce = new PropertyChangeEvent(
|
||||
getRootInstance(), this.nestedPath + tokens.canonicalName, oldValue, pv.getValue());
|
||||
getRootInstance(), this.nestedPath + name, oldValue, pv.getValue());
|
||||
throw new MethodInvocationException(pce, ex);
|
||||
}
|
||||
}
|
||||
@@ -495,7 +493,7 @@ public abstract class AbstractNestablePropertyAccessor extends AbstractPropertyA
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (InvalidPropertyException ex) {
|
||||
catch (InvalidPropertyException | InvalidPropertyPathException ex) {
|
||||
// Consider as not determinable.
|
||||
}
|
||||
return null;
|
||||
@@ -504,14 +502,14 @@ public abstract class AbstractNestablePropertyAccessor extends AbstractPropertyA
|
||||
@Override
|
||||
public @Nullable TypeDescriptor getPropertyTypeDescriptor(String propertyName) throws BeansException {
|
||||
try {
|
||||
AbstractNestablePropertyAccessor nestedPa = getPropertyAccessorForPropertyPath(propertyName);
|
||||
String finalPath = getFinalPath(nestedPa, propertyName);
|
||||
PropertyTokenHolder tokens = getPropertyNameTokens(finalPath);
|
||||
PropertyHandler ph = nestedPa.getLocalPropertyHandler(tokens.actualName);
|
||||
ResolvedProperty resolved = resolvePropertyPath(propertyName);
|
||||
PropertyPath.Segment segment = resolved.segment();
|
||||
PropertyHandler ph = resolved.accessor().getLocalPropertyHandler(segment.name());
|
||||
if (ph != null) {
|
||||
if (tokens.keys != null) {
|
||||
List<String> keys = segment.keys();
|
||||
if (!keys.isEmpty()) {
|
||||
if (ph.isReadable() || ph.isWritable()) {
|
||||
return ph.nested(tokens.keys.length);
|
||||
return ph.nested(keys.size());
|
||||
}
|
||||
}
|
||||
else {
|
||||
@@ -521,7 +519,7 @@ public abstract class AbstractNestablePropertyAccessor extends AbstractPropertyA
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (InvalidPropertyException ex) {
|
||||
catch (InvalidPropertyException | InvalidPropertyPathException ex) {
|
||||
// Consider as not determinable.
|
||||
}
|
||||
return null;
|
||||
@@ -540,7 +538,7 @@ public abstract class AbstractNestablePropertyAccessor extends AbstractPropertyA
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch (InvalidPropertyException ex) {
|
||||
catch (InvalidPropertyException | InvalidPropertyPathException ex) {
|
||||
// Cannot be evaluated, so can't be readable.
|
||||
}
|
||||
return false;
|
||||
@@ -559,7 +557,7 @@ public abstract class AbstractNestablePropertyAccessor extends AbstractPropertyA
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch (InvalidPropertyException ex) {
|
||||
catch (InvalidPropertyException | InvalidPropertyPathException ex) {
|
||||
// Cannot be evaluated, so can't be writable.
|
||||
}
|
||||
return false;
|
||||
@@ -594,25 +592,25 @@ public abstract class AbstractNestablePropertyAccessor extends AbstractPropertyA
|
||||
|
||||
@Override
|
||||
public @Nullable Object getPropertyValue(String propertyName) throws BeansException {
|
||||
AbstractNestablePropertyAccessor nestedPa = getPropertyAccessorForPropertyPath(propertyName);
|
||||
PropertyTokenHolder tokens = getPropertyNameTokens(getFinalPath(nestedPa, propertyName));
|
||||
return nestedPa.getPropertyValue(tokens);
|
||||
ResolvedProperty resolved = resolvePropertyPath(propertyName);
|
||||
return resolved.accessor().getPropertyValue(resolved.segment());
|
||||
}
|
||||
|
||||
@SuppressWarnings({"rawtypes", "unchecked"})
|
||||
protected @Nullable Object getPropertyValue(PropertyTokenHolder tokens) throws BeansException {
|
||||
String propertyName = tokens.canonicalName;
|
||||
String actualName = tokens.actualName;
|
||||
protected @Nullable Object getPropertyValue(PropertyPath.Segment segment) throws BeansException {
|
||||
String propertyName = segment.toCanonicalName();
|
||||
String actualName = segment.name();
|
||||
PropertyHandler ph = getLocalPropertyHandler(actualName);
|
||||
if (ph == null || !ph.isReadable()) {
|
||||
throw new NotReadablePropertyException(getRootClass(), this.nestedPath + propertyName);
|
||||
}
|
||||
try {
|
||||
Object value = ph.getValue();
|
||||
if (tokens.keys != null) {
|
||||
List<String> keys = segment.keys();
|
||||
if (!keys.isEmpty()) {
|
||||
if (value == null) {
|
||||
if (isAutoGrowNestedPaths()) {
|
||||
value = setDefaultValue(new PropertyTokenHolder(tokens.actualName));
|
||||
value = setDefaultValue(new PropertyPath.Segment(actualName, List.of()));
|
||||
}
|
||||
else {
|
||||
throw new NullValueInNestedPathException(getRootClass(), this.nestedPath + propertyName,
|
||||
@@ -620,10 +618,10 @@ public abstract class AbstractNestablePropertyAccessor extends AbstractPropertyA
|
||||
"property path '" + propertyName + "': returned null");
|
||||
}
|
||||
}
|
||||
StringBuilder indexedPropertyName = new StringBuilder(tokens.actualName);
|
||||
StringBuilder indexedPropertyName = new StringBuilder(actualName);
|
||||
// apply indexes and map keys
|
||||
for (int i = 0; i < tokens.keys.length; i++) {
|
||||
String key = tokens.keys[i];
|
||||
for (int i = 0; i < keys.size(); i++) {
|
||||
String key = keys.get(i);
|
||||
if (value == null) {
|
||||
throw new NullValueInNestedPathException(getRootClass(), this.nestedPath + propertyName,
|
||||
"Cannot access indexed value of property referenced in indexed " +
|
||||
@@ -722,8 +720,8 @@ public abstract class AbstractNestablePropertyAccessor extends AbstractPropertyA
|
||||
*/
|
||||
protected @Nullable PropertyHandler getPropertyHandler(String propertyName) throws BeansException {
|
||||
Assert.notNull(propertyName, "Property name must not be null");
|
||||
AbstractNestablePropertyAccessor nestedPa = getPropertyAccessorForPropertyPath(propertyName);
|
||||
return nestedPa.getLocalPropertyHandler(getFinalPath(nestedPa, propertyName));
|
||||
ResolvedProperty resolved = resolvePropertyPath(propertyName);
|
||||
return resolved.accessor().getLocalPropertyHandler(resolved.segment().toCanonicalName());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -789,48 +787,55 @@ public abstract class AbstractNestablePropertyAccessor extends AbstractPropertyA
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the last component of the path. Also works if not nested.
|
||||
* @param pa property accessor to work on
|
||||
* @param nestedPath property path we know is nested
|
||||
* @return last component of the path (the property on the target bean)
|
||||
* Resolve a property path to the accessor owning its final segment and
|
||||
* that segment itself, parsing the path exactly once.
|
||||
* @param propertyPath the property path, which may be nested
|
||||
* @return the accessor for the target bean, paired with the final segment
|
||||
* @throws InvalidPropertyPathException if the given path is not a
|
||||
* well-formed property path, or if its nesting depth exceeds
|
||||
* {@link #getMaxNestedPathDepth()}
|
||||
* @since 7.1
|
||||
*/
|
||||
protected String getFinalPath(AbstractNestablePropertyAccessor pa, String nestedPath) {
|
||||
if (pa == this) {
|
||||
return nestedPath;
|
||||
}
|
||||
return nestedPath.substring(PropertyAccessorUtils.getLastNestedPropertySeparatorIndex(nestedPath) + 1);
|
||||
protected ResolvedProperty resolvePropertyPath(String propertyPath) {
|
||||
PropertyPath.Options options = PropertyPath.Options.withMaxNestedPathDepth(getMaxNestedPathDepth());
|
||||
List<PropertyPath.Segment> segments = PropertyPath.parse(propertyPath, options).segments();
|
||||
AbstractNestablePropertyAccessor accessor = getPropertyAccessorForSegments(segments, 0);
|
||||
PropertyPath.Segment segment = finalSegment(propertyPath, segments);
|
||||
return new ResolvedProperty(accessor, segment);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively navigate to return a property accessor for the nested property path.
|
||||
* <p>The default implementation rejects a property path which contains unbalanced
|
||||
* brackets as well as one which exceeds the {@linkplain #getMaxNestedPathDepth()
|
||||
* maximum nesting depth}. An override which does not delegate to {@code super} is
|
||||
* therefore responsible for performing equivalent validation itself.
|
||||
* @param propertyPath property path, which may be nested
|
||||
* @return a property accessor for the target bean
|
||||
* The final segment of an already-parsed property path: the property to
|
||||
* actually get or set. Handles the one case {@link PropertyPath} itself
|
||||
* does not produce a segment for: the empty path ({@code ""}), a valid
|
||||
* property path with zero segments, treated the same as a single segment
|
||||
* with an empty name and no keys — a shape {@link PropertyPath.Segment}'s
|
||||
* own constructor allows even though {@link PropertyPath#parse}'s grammar
|
||||
* validation never produces it.
|
||||
*/
|
||||
protected AbstractNestablePropertyAccessor getPropertyAccessorForPropertyPath(String propertyPath) {
|
||||
if (PropertyAccessorUtils.hasUnbalancedBrackets(propertyPath)) {
|
||||
throw new NotReadablePropertyException(getRootClass(), this.nestedPath + propertyPath,
|
||||
"Property path '" + propertyPath + "' contains unbalanced brackets");
|
||||
}
|
||||
int pos = PropertyAccessorUtils.getFirstNestedPropertySeparatorIndex(propertyPath);
|
||||
// Handle nested properties recursively.
|
||||
if (pos > -1) {
|
||||
int maxNestedPathDepth = getMaxNestedPathDepth();
|
||||
if (this.nestedPathDepth >= maxNestedPathDepth) {
|
||||
throw new InvalidPropertyException(getRootClass(), this.nestedPath + propertyPath,
|
||||
"Nesting depth of property path exceeds the maximum of " + maxNestedPathDepth);
|
||||
}
|
||||
String nestedProperty = propertyPath.substring(0, pos);
|
||||
String nestedPath = propertyPath.substring(pos + 1);
|
||||
AbstractNestablePropertyAccessor nestedPa = getNestedPropertyAccessor(nestedProperty);
|
||||
return nestedPa.getPropertyAccessorForPropertyPath(nestedPath);
|
||||
}
|
||||
else {
|
||||
private static PropertyPath.Segment finalSegment(String propertyPath, List<PropertyPath.Segment> segments) {
|
||||
return (segments.isEmpty() ? new PropertyPath.Segment(propertyPath, List.of()) :
|
||||
segments.get(segments.size() - 1));
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively navigate to return a property accessor for the given,
|
||||
* already-parsed property path segments, peeling one segment off at a
|
||||
* time until only the final segment (the property to actually get or
|
||||
* set on the returned accessor) is left.
|
||||
* @param segments the segments of the full property path, parsed exactly
|
||||
* once by the caller
|
||||
* @param fromIndex the index of the first segment not yet consumed
|
||||
* @return a property accessor for the bean holding the final segment
|
||||
*/
|
||||
private AbstractNestablePropertyAccessor getPropertyAccessorForSegments(
|
||||
List<PropertyPath.Segment> segments, int fromIndex) {
|
||||
|
||||
if (segments.isEmpty() || fromIndex >= segments.size() - 1) {
|
||||
return this;
|
||||
}
|
||||
AbstractNestablePropertyAccessor nestedPa = getNestedPropertyAccessor(segments.get(fromIndex));
|
||||
return nestedPa.getPropertyAccessorForSegments(segments, fromIndex + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -838,68 +843,64 @@ public abstract class AbstractNestablePropertyAccessor extends AbstractPropertyA
|
||||
* Create a new one if not found in the cache.
|
||||
* <p>Note: Caching nested PropertyAccessors is necessary now,
|
||||
* to keep registered custom editors for nested properties.
|
||||
* @param nestedProperty property to create the PropertyAccessor for
|
||||
* @param segment the already-parsed segment to create the PropertyAccessor for
|
||||
* @return the PropertyAccessor instance, either cached or newly created
|
||||
*/
|
||||
private AbstractNestablePropertyAccessor getNestedPropertyAccessor(String nestedProperty) {
|
||||
Map<String, AbstractNestablePropertyAccessor> nestedAccessors = this.nestedPropertyAccessors;
|
||||
private AbstractNestablePropertyAccessor getNestedPropertyAccessor(PropertyPath.Segment segment) {
|
||||
Map<PropertyPath.Segment, AbstractNestablePropertyAccessor> nestedAccessors = this.nestedPropertyAccessors;
|
||||
if (nestedAccessors == null) {
|
||||
nestedAccessors = new HashMap<>();
|
||||
this.nestedPropertyAccessors = nestedAccessors;
|
||||
}
|
||||
// Get value of bean property.
|
||||
PropertyTokenHolder tokens = getPropertyNameTokens(nestedProperty);
|
||||
String canonicalName = tokens.canonicalName;
|
||||
Object value = getPropertyValue(tokens);
|
||||
Object value = getPropertyValue(segment);
|
||||
if (value == null || (value instanceof Optional<?> optional && optional.isEmpty())) {
|
||||
if (isAutoGrowNestedPaths()) {
|
||||
value = setDefaultValue(tokens);
|
||||
value = setDefaultValue(segment);
|
||||
}
|
||||
else {
|
||||
throw new NullValueInNestedPathException(getRootClass(), this.nestedPath + canonicalName);
|
||||
throw new NullValueInNestedPathException(getRootClass(), this.nestedPath + segment.toCanonicalName());
|
||||
}
|
||||
}
|
||||
|
||||
// Lookup cached sub-PropertyAccessor, create new one if not found.
|
||||
AbstractNestablePropertyAccessor nestedPa = nestedAccessors.get(canonicalName);
|
||||
AbstractNestablePropertyAccessor nestedPa = nestedAccessors.get(segment);
|
||||
if (nestedPa == null || nestedPa.getWrappedInstance() != ObjectUtils.unwrapOptional(value)) {
|
||||
String canonicalName = segment.toCanonicalName();
|
||||
if (logger.isTraceEnabled()) {
|
||||
logger.trace("Creating new nested " + getClass().getSimpleName() + " for property '" + canonicalName + "'");
|
||||
}
|
||||
nestedPa = newNestedPropertyAccessor(value, this.nestedPath + canonicalName + NESTED_PROPERTY_SEPARATOR);
|
||||
// Track the nesting depth here rather than in a constructor, so that the
|
||||
// depth is assigned even if a subclass creates the nested property accessor
|
||||
// without copying the configuration of this accessor.
|
||||
nestedPa.nestedPathDepth = this.nestedPathDepth + 1;
|
||||
// Inherit all type-specific PropertyEditors.
|
||||
copyDefaultEditorsTo(nestedPa);
|
||||
copyCustomEditorsTo(nestedPa, canonicalName);
|
||||
nestedAccessors.put(canonicalName, nestedPa);
|
||||
nestedAccessors.put(segment, nestedPa);
|
||||
}
|
||||
else {
|
||||
if (logger.isTraceEnabled()) {
|
||||
logger.trace("Using cached nested property accessor for property '" + canonicalName + "'");
|
||||
logger.trace("Using cached nested property accessor for property '" + segment.toCanonicalName() + "'");
|
||||
}
|
||||
}
|
||||
return nestedPa;
|
||||
}
|
||||
|
||||
private Object setDefaultValue(PropertyTokenHolder tokens) {
|
||||
PropertyValue pv = createDefaultPropertyValue(tokens);
|
||||
setPropertyValue(tokens, pv);
|
||||
Object defaultValue = getPropertyValue(tokens);
|
||||
private Object setDefaultValue(PropertyPath.Segment segment) {
|
||||
PropertyValue pv = createDefaultPropertyValue(segment);
|
||||
setPropertyValue(segment, pv);
|
||||
Object defaultValue = getPropertyValue(segment);
|
||||
Assert.state(defaultValue != null, "Default value must not be null");
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
private PropertyValue createDefaultPropertyValue(PropertyTokenHolder tokens) {
|
||||
TypeDescriptor desc = getPropertyTypeDescriptor(tokens.canonicalName);
|
||||
private PropertyValue createDefaultPropertyValue(PropertyPath.Segment segment) {
|
||||
String canonicalName = segment.toCanonicalName();
|
||||
TypeDescriptor desc = getPropertyTypeDescriptor(canonicalName);
|
||||
if (desc == null) {
|
||||
throw new NullValueInNestedPathException(getRootClass(), this.nestedPath + tokens.canonicalName,
|
||||
throw new NullValueInNestedPathException(getRootClass(), this.nestedPath + canonicalName,
|
||||
"Could not determine property type for auto-growing a default value");
|
||||
}
|
||||
Object defaultValue = newValue(desc.getType(), desc, tokens.canonicalName);
|
||||
return new PropertyValue(tokens.canonicalName, defaultValue);
|
||||
Object defaultValue = newValue(desc.getType(), desc, canonicalName);
|
||||
return new PropertyValue(canonicalName, defaultValue);
|
||||
}
|
||||
|
||||
private Object newValue(Class<?> type, @Nullable TypeDescriptor desc, String name) {
|
||||
@@ -947,44 +948,6 @@ public abstract class AbstractNestablePropertyAccessor extends AbstractPropertyA
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the given property name into the corresponding property name tokens.
|
||||
* @param propertyName the property name to parse
|
||||
* @return representation of the parsed property tokens
|
||||
*/
|
||||
private PropertyTokenHolder getPropertyNameTokens(String propertyName) {
|
||||
String actualName = null;
|
||||
List<String> keys = new ArrayList<>(2);
|
||||
int searchIndex = 0;
|
||||
while (searchIndex != -1) {
|
||||
int keyStart = propertyName.indexOf(PROPERTY_KEY_PREFIX, searchIndex);
|
||||
searchIndex = -1;
|
||||
if (keyStart != -1) {
|
||||
int keyEnd = PropertyAccessorUtils.getPropertyNameKeyEnd(propertyName, keyStart + PROPERTY_KEY_PREFIX.length());
|
||||
if (keyEnd != -1) {
|
||||
if (actualName == null) {
|
||||
actualName = propertyName.substring(0, keyStart);
|
||||
}
|
||||
String key = propertyName.substring(keyStart + PROPERTY_KEY_PREFIX.length(), keyEnd);
|
||||
if (key.length() > 1 && ((key.startsWith("'") && key.endsWith("'")) ||
|
||||
(key.startsWith("\"") && key.endsWith("\"")))) {
|
||||
key = key.substring(1, key.length() - 1);
|
||||
}
|
||||
keys.add(key);
|
||||
searchIndex = keyEnd + PROPERTY_KEY_SUFFIX.length();
|
||||
}
|
||||
}
|
||||
}
|
||||
PropertyTokenHolder tokens = new PropertyTokenHolder(actualName != null ? actualName : propertyName);
|
||||
if (!keys.isEmpty()) {
|
||||
tokens.canonicalName += PROPERTY_KEY_PREFIX +
|
||||
StringUtils.collectionToDelimitedString(keys, PROPERTY_KEY_SUFFIX + PROPERTY_KEY_PREFIX) +
|
||||
PROPERTY_KEY_SUFFIX;
|
||||
tokens.keys = StringUtils.toStringArray(keys);
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
String className = getClass().getName();
|
||||
@@ -995,6 +958,17 @@ public abstract class AbstractNestablePropertyAccessor extends AbstractPropertyA
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* The result of resolving a property path: the accessor owning its final
|
||||
* segment, and that segment itself.
|
||||
* @since 7.1
|
||||
* @param accessor the accessor for the target bean
|
||||
* @param segment the final segment of the resolved path (the property to
|
||||
* actually get or set on {@code accessor})
|
||||
*/
|
||||
protected record ResolvedProperty(AbstractNestablePropertyAccessor accessor, PropertyPath.Segment segment) {}
|
||||
|
||||
|
||||
/**
|
||||
* A handler for a specific property.
|
||||
*/
|
||||
@@ -1051,22 +1025,4 @@ public abstract class AbstractNestablePropertyAccessor extends AbstractPropertyA
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Holder class used to store property tokens.
|
||||
*/
|
||||
protected static class PropertyTokenHolder {
|
||||
|
||||
public PropertyTokenHolder(String name) {
|
||||
this.actualName = name;
|
||||
this.canonicalName = name;
|
||||
}
|
||||
|
||||
public String actualName;
|
||||
|
||||
public String canonicalName;
|
||||
|
||||
public String @Nullable [] keys;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ package org.springframework.beans;
|
||||
|
||||
import java.beans.PropertyDescriptor;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.Objects;
|
||||
|
||||
import org.apache.commons.logging.LogFactory;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
@@ -213,9 +214,17 @@ public class BeanWrapperImpl extends AbstractNestablePropertyAccessor implements
|
||||
|
||||
@Override
|
||||
public PropertyDescriptor getPropertyDescriptor(String propertyName) throws InvalidPropertyException {
|
||||
BeanWrapperImpl nestedBw = (BeanWrapperImpl) getPropertyAccessorForPropertyPath(propertyName);
|
||||
String finalPath = getFinalPath(nestedBw, propertyName);
|
||||
PropertyDescriptor pd = nestedBw.getCachedIntrospectionResults().getPropertyDescriptor(finalPath);
|
||||
ResolvedProperty resolved;
|
||||
try {
|
||||
resolved = resolvePropertyPath(propertyName);
|
||||
}
|
||||
catch (InvalidPropertyPathException ex) {
|
||||
throw new InvalidPropertyException(
|
||||
getRootClass(), getNestedPath() + propertyName, Objects.requireNonNull(ex.getMessage()), ex);
|
||||
}
|
||||
BeanWrapperImpl nestedBw = (BeanWrapperImpl) resolved.accessor();
|
||||
PropertyDescriptor pd = nestedBw.getCachedIntrospectionResults()
|
||||
.getPropertyDescriptor(resolved.segment().toCanonicalName());
|
||||
if (pd == null) {
|
||||
throw new InvalidPropertyException(getRootClass(), getNestedPath() + propertyName,
|
||||
"No property '" + propertyName + "' found");
|
||||
|
||||
+26
@@ -16,6 +16,11 @@
|
||||
|
||||
package org.springframework.beans;
|
||||
|
||||
import java.beans.PropertyChangeEvent;
|
||||
import java.util.Objects;
|
||||
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
/**
|
||||
* Exception thrown when a property path is not a well-formed property path
|
||||
* according to the grammar implemented by {@link PropertyPath}.
|
||||
@@ -56,6 +61,27 @@ public class InvalidPropertyPathException extends PropertyAccessException {
|
||||
this.propertyPath = propertyPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new {@code InvalidPropertyPathException}.
|
||||
* @param propertyChangeEvent the event for the property
|
||||
* @param cause the original parsing exception
|
||||
*/
|
||||
public InvalidPropertyPathException(PropertyChangeEvent propertyChangeEvent, InvalidPropertyPathException cause) {
|
||||
super(propertyChangeEvent, Objects.requireNonNull(cause.getMessage()), cause);
|
||||
this.propertyPath = cause.propertyPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new {@code InvalidPropertyPathException}.
|
||||
* @param source the bean that fired the event
|
||||
* @param propertyName the programmatic name of the property that was changed
|
||||
* @param newValue the new value of the property
|
||||
* @param cause the original parsing exception
|
||||
*/
|
||||
public InvalidPropertyPathException(Object source, String propertyName, @Nullable Object newValue, InvalidPropertyPathException cause) {
|
||||
this(new PropertyChangeEvent(source, propertyName, null, newValue), cause);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Return the offending property path.
|
||||
|
||||
+48
-7
@@ -373,7 +373,7 @@ public class PropertyEditorRegistrySupport implements PropertyEditorRegistry {
|
||||
public boolean hasCustomEditorForElement(@Nullable Class<?> elementType, @Nullable String propertyPath) {
|
||||
if (propertyPath != null && this.customEditorsForPath != null) {
|
||||
for (Map.Entry<String, CustomEditorHolder> entry : this.customEditorsForPath.entrySet()) {
|
||||
if (PropertyAccessorUtils.matchesProperty(entry.getKey(), propertyPath) &&
|
||||
if (matchesProperty(entry.getKey(), propertyPath) &&
|
||||
entry.getValue().getPropertyEditor(elementType) != null) {
|
||||
return true;
|
||||
}
|
||||
@@ -383,6 +383,26 @@ public class PropertyEditorRegistrySupport implements PropertyEditorRegistry {
|
||||
return (elementType != null && this.customEditors != null && this.customEditors.containsKey(elementType));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether {@code registeredPath} is {@code propertyPath} itself or one
|
||||
* indexed element of it.
|
||||
*/
|
||||
private static boolean matchesProperty(String registeredPath, String propertyPath) {
|
||||
String canonicalRegisteredPath = PropertyPath.canonicalNameOrOriginal(registeredPath);
|
||||
String canonicalPropertyPath = PropertyPath.canonicalNameOrOriginal(propertyPath);
|
||||
if (!canonicalRegisteredPath.startsWith(canonicalPropertyPath)) {
|
||||
return false;
|
||||
}
|
||||
if (canonicalRegisteredPath.length() == canonicalPropertyPath.length()) {
|
||||
return true;
|
||||
}
|
||||
if (canonicalRegisteredPath.charAt(canonicalPropertyPath.length()) != PropertyAccessor.PROPERTY_KEY_PREFIX_CHAR) {
|
||||
return false;
|
||||
}
|
||||
return (canonicalRegisteredPath.indexOf(PropertyAccessor.PROPERTY_KEY_SUFFIX_CHAR, canonicalPropertyPath.length() + 1) ==
|
||||
canonicalRegisteredPath.length() - 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the property type for the given property path.
|
||||
* <p>Called by {@link #findCustomEditor} if no required type has been specified,
|
||||
@@ -483,18 +503,25 @@ public class PropertyEditorRegistrySupport implements PropertyEditorRegistry {
|
||||
* will be copied. If this is null, all editors will be copied.
|
||||
*/
|
||||
protected void copyCustomEditorsTo(PropertyEditorRegistry target, @Nullable String nestedProperty) {
|
||||
String actualPropertyName =
|
||||
(nestedProperty != null ? PropertyAccessorUtils.getPropertyName(nestedProperty) : null);
|
||||
String actualPropertyName = (nestedProperty != null ? actualPropertyNameOf(nestedProperty) : null);
|
||||
if (this.customEditors != null) {
|
||||
this.customEditors.forEach(target::registerCustomEditor);
|
||||
}
|
||||
if (this.customEditorsForPath != null) {
|
||||
this.customEditorsForPath.forEach((editorPath, editorHolder) -> {
|
||||
if (nestedProperty != null) {
|
||||
int pos = PropertyAccessorUtils.getFirstNestedPropertySeparatorIndex(editorPath);
|
||||
if (pos != -1) {
|
||||
String editorNestedProperty = editorPath.substring(0, pos);
|
||||
String editorNestedPath = editorPath.substring(pos + 1);
|
||||
PropertyPath editorPropertyPath;
|
||||
try {
|
||||
editorPropertyPath = PropertyPath.parse(editorPath);
|
||||
}
|
||||
catch (InvalidPropertyPathException ex) {
|
||||
// Not a well-formed path; nothing to nest into.
|
||||
return;
|
||||
}
|
||||
List<PropertyPath.Segment> editorSegments = editorPropertyPath.segments();
|
||||
if (editorSegments.size() > 1) {
|
||||
String editorNestedProperty = editorSegments.get(0).toCanonicalName();
|
||||
String editorNestedPath = editorPropertyPath.subPath(1).canonicalName();
|
||||
if (editorNestedProperty.equals(nestedProperty) || editorNestedProperty.equals(actualPropertyName)) {
|
||||
target.registerCustomEditor(
|
||||
editorHolder.getRegisteredType(), editorNestedPath, editorHolder.getPropertyEditor());
|
||||
@@ -509,6 +536,20 @@ public class PropertyEditorRegistrySupport implements PropertyEditorRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The raw name of {@code nestedProperty}'s single segment with its keys stripped,
|
||||
* or {@code null} if malformed.
|
||||
*/
|
||||
private static @Nullable String actualPropertyNameOf(String nestedProperty) {
|
||||
try {
|
||||
List<PropertyPath.Segment> segments = PropertyPath.parse(nestedProperty).segments();
|
||||
return (segments.size() == 1 ? segments.get(0).name() : null);
|
||||
}
|
||||
catch (InvalidPropertyPathException ex) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Add property paths with all variations of stripped keys and/or indexes.
|
||||
|
||||
+15
-2
@@ -42,7 +42,7 @@ import org.springframework.beans.BeanWrapperImpl;
|
||||
import org.springframework.beans.BeansException;
|
||||
import org.springframework.beans.InvalidPropertyException;
|
||||
import org.springframework.beans.MutablePropertyValues;
|
||||
import org.springframework.beans.PropertyAccessorUtils;
|
||||
import org.springframework.beans.PropertyAccessor;
|
||||
import org.springframework.beans.PropertyValue;
|
||||
import org.springframework.beans.PropertyValues;
|
||||
import org.springframework.beans.TypeConverter;
|
||||
@@ -1751,7 +1751,7 @@ public abstract class AbstractAutowireCapableBeanFactory extends AbstractBeanFac
|
||||
*/
|
||||
private boolean isConvertibleProperty(String propertyName, BeanWrapper bw) {
|
||||
try {
|
||||
return !PropertyAccessorUtils.isNestedOrIndexedProperty(propertyName) &&
|
||||
return !isNestedOrIndexedProperty(propertyName) &&
|
||||
BeanUtils.hasUniqueWriteMethod(bw.getPropertyDescriptor(propertyName));
|
||||
}
|
||||
catch (InvalidPropertyException ex) {
|
||||
@@ -1759,6 +1759,19 @@ public abstract class AbstractAutowireCapableBeanFactory extends AbstractBeanFac
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the given property path indicates an indexed or nested property.
|
||||
*/
|
||||
private static boolean isNestedOrIndexedProperty(String propertyName) {
|
||||
for (int i = 0; i < propertyName.length(); i++) {
|
||||
char ch = propertyName.charAt(i);
|
||||
if (ch == PropertyAccessor.NESTED_PROPERTY_SEPARATOR_CHAR || ch == PropertyAccessor.PROPERTY_KEY_PREFIX_CHAR) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the given value for the specified target property.
|
||||
*/
|
||||
|
||||
+6
-8
@@ -301,9 +301,9 @@ abstract class AbstractPropertyAccessorTests {
|
||||
Person target = createPerson("John", "London", "UK");
|
||||
AbstractPropertyAccessor accessor = createAccessor(target);
|
||||
|
||||
assertThatExceptionOfType(NotReadablePropertyException.class)
|
||||
assertThatExceptionOfType(InvalidPropertyPathException.class)
|
||||
.isThrownBy(() -> accessor.getPropertyValue(propertyPath))
|
||||
.withMessageEndingWith("contains unbalanced brackets");
|
||||
.withMessageContaining("Invalid property path '" + propertyPath + "'");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -1384,9 +1384,9 @@ abstract class AbstractPropertyAccessorTests {
|
||||
Person target = createPerson("John", "Paris", "FR");
|
||||
AbstractPropertyAccessor accessor = createAccessor(target);
|
||||
|
||||
assertThatExceptionOfType(NotWritablePropertyException.class)
|
||||
assertThatExceptionOfType(InvalidPropertyPathException.class)
|
||||
.isThrownBy(() -> accessor.setPropertyValue(propertyPath, "Zürich"))
|
||||
.withMessageEndingWith("does not exist");
|
||||
.withMessageContaining("Invalid property path '" + propertyPath + "'");
|
||||
assertThat(target.getAddress().getCity()).isEqualTo("Paris");
|
||||
}
|
||||
|
||||
@@ -1441,8 +1441,6 @@ abstract class AbstractPropertyAccessorTests {
|
||||
assertThat(accessor.getPropertyValue("map[key5[foo]].name")).isEqualTo("name8");
|
||||
assertThat(accessor.getPropertyValue("map['key5[foo]'].name")).isEqualTo("name8");
|
||||
assertThat(accessor.getPropertyValue("map[\"key5[foo]\"].name")).isEqualTo("name8");
|
||||
assertThat(accessor.getPropertyValue("map['].name")).isEqualTo("name9");
|
||||
assertThat(accessor.getPropertyValue("map[\"].name")).isEqualTo("name9");
|
||||
assertThat(accessor.getPropertyValue("iterableMap[key1].name")).isEqualTo("nameC");
|
||||
assertThat(accessor.getPropertyValue("iterableMap[key2][0].name")).isEqualTo("nameA");
|
||||
assertThat(accessor.getPropertyValue("iterableMap[key2][1].name")).isEqualTo("nameB");
|
||||
@@ -1708,9 +1706,9 @@ abstract class AbstractPropertyAccessorTests {
|
||||
}
|
||||
|
||||
private static void assertNestedPathDepthExceeded(ThrowingCallable throwingCallable, int maxDepth) {
|
||||
assertThatExceptionOfType(InvalidPropertyException.class)
|
||||
assertThatExceptionOfType(InvalidPropertyPathException.class)
|
||||
.isThrownBy(throwingCallable)
|
||||
.withMessageEndingWith("Nesting depth of property path exceeds the maximum of " + maxDepth);
|
||||
.withMessageEndingWith("nesting depth exceeds the maximum of " + maxDepth);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@ import java.time.Duration;
|
||||
import java.util.Collections;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
@@ -295,23 +294,18 @@ class BeanWrapperTests extends AbstractPropertyAccessorTests {
|
||||
void incompletelyQuotedKeyLeadsToPropertyException() {
|
||||
TestBean target = new TestBean();
|
||||
BeanWrapper accessor = createAccessor(target);
|
||||
assertThatExceptionOfType(NotWritablePropertyException.class)
|
||||
assertThatExceptionOfType(InvalidPropertyPathException.class)
|
||||
.isThrownBy(() -> accessor.setPropertyValue("[']", "foobar"))
|
||||
.satisfies(ex -> assertThat(ex.getPossibleMatches()).isNull());
|
||||
.withMessageContaining("unterminated quote");
|
||||
}
|
||||
|
||||
@Test // gh-37252
|
||||
void overriddenGetPropertyAccessorForPropertyPathIsInvokedForEachNestedLevel() {
|
||||
TestBean rod = new TestBean("rod", 31);
|
||||
TestBean kerry = new TestBean("kerry", 35);
|
||||
rod.setSpouse(kerry);
|
||||
kerry.setSpouse(rod);
|
||||
|
||||
CountingBeanWrapper accessor = new CountingBeanWrapper(rod);
|
||||
|
||||
assertThat(accessor.getPropertyValue("spouse.spouse.name")).isEqualTo("rod");
|
||||
// Once for "spouse.spouse.name", once for "spouse.name", and once for "name".
|
||||
assertThat(accessor.invocations).hasValue(3);
|
||||
@Test // gh-37275
|
||||
void getPropertyDescriptorForMalformedPathThrowsInvalidPropertyException() {
|
||||
TestBean target = new TestBean();
|
||||
BeanWrapper accessor = createAccessor(target);
|
||||
assertThatExceptionOfType(InvalidPropertyException.class)
|
||||
.isThrownBy(() -> accessor.getPropertyDescriptor("map[unterminated"))
|
||||
.withCauseInstanceOf(InvalidPropertyPathException.class);
|
||||
}
|
||||
|
||||
|
||||
@@ -461,35 +455,4 @@ class BeanWrapperTests extends AbstractPropertyAccessorTests {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A {@link BeanWrapperImpl} which tracks how often
|
||||
* {@link #getPropertyAccessorForPropertyPath(String)} is invoked, in order to
|
||||
* verify that an override is applied to each level of a nested property path.
|
||||
*/
|
||||
private static class CountingBeanWrapper extends BeanWrapperImpl {
|
||||
|
||||
private final AtomicInteger invocations;
|
||||
|
||||
CountingBeanWrapper(Object target) {
|
||||
super(target);
|
||||
this.invocations = new AtomicInteger();
|
||||
}
|
||||
|
||||
private CountingBeanWrapper(Object object, String nestedPath, CountingBeanWrapper parent) {
|
||||
super(object, nestedPath, parent.getRootInstance());
|
||||
this.invocations = parent.invocations;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected BeanWrapperImpl newNestedPropertyAccessor(Object object, String nestedPath) {
|
||||
return new CountingBeanWrapper(object, nestedPath, this);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected AbstractNestablePropertyAccessor getPropertyAccessorForPropertyPath(String propertyPath) {
|
||||
this.invocations.incrementAndGet();
|
||||
return super.getPropertyAccessorForPropertyPath(propertyPath);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
-410
@@ -1,410 +0,0 @@
|
||||
/*
|
||||
* Copyright 2002-present the original author or authors.
|
||||
*
|
||||
* Licensed 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 org.springframework.beans;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
|
||||
import static org.assertj.core.api.Assertions.assertThatNoException;
|
||||
|
||||
/**
|
||||
* Tests that check the behavior changes for a code migration
|
||||
* from {@link PropertyAccessorUtils} and {@link AbstractNestablePropertyAccessor}
|
||||
* to {@link PropertyPath}.
|
||||
* <p>This test class should be removed after migration.
|
||||
*
|
||||
* @author Brian Clozel
|
||||
*/
|
||||
class PropertyPathBehaviorChangeTests {
|
||||
|
||||
/**
|
||||
* The canonical name is the policy-facing form of a path: it is what
|
||||
* {@code DataBinder.isAllowed} matches against {@code allowedFields} and
|
||||
* {@code disallowedFields}, and what {@code AbstractPropertyBindingResult}
|
||||
* reports as the field name.
|
||||
*/
|
||||
@Nested
|
||||
class CanonicalPropertyName {
|
||||
|
||||
@Test
|
||||
void wellFormedPathsAreCanonicalized() {
|
||||
assertCanonical("", "");
|
||||
assertCanonical("name", "name");
|
||||
assertCanonical("person.name", "person.name");
|
||||
assertCanonical("map[key1]", "map[key1]");
|
||||
assertCanonical("map['key1']", "map[key1]");
|
||||
assertCanonical("map[\"key1\"]", "map[key1]");
|
||||
assertCanonical("map[key1][key2]", "map[key1][key2]");
|
||||
assertCanonical("map['key1'].name", "map[key1].name");
|
||||
assertCanonical("map[key[0]]", "map[key[0]]");
|
||||
assertCanonical("map['key[0]']", "map[key[0]]");
|
||||
assertCanonical("map[]", "map[]");
|
||||
assertCanonical("map['']", "map[]");
|
||||
assertCanonical("[user]", "[user]");
|
||||
}
|
||||
|
||||
@Test // Malformed paths are passed through unchanged, not rejected.
|
||||
void malformedPathsArePassedThrough() {
|
||||
assertCanonical("map[key1]other", "map[key1]other");
|
||||
assertCanonical("map[key1]other.name", "map[key1]other.name");
|
||||
assertCanonical("map[key1]IGNORED[key2]", "map[key1]IGNORED[key2]");
|
||||
assertCanonical(".name", ".name");
|
||||
assertCanonical("person.", "person.");
|
||||
assertCanonical("person..name", "person..name");
|
||||
assertCanonical("map[key1", "map[key1");
|
||||
assertCanonical("map]", "map]");
|
||||
assertCanonical("address.].city", "address.].city");
|
||||
}
|
||||
|
||||
@Test // An unterminated quote falls back to treating the quote as literal content.
|
||||
void unterminatedQuotesAreTreatedAsLiteralContent() {
|
||||
assertCanonical("map['key1]", "map['key1]");
|
||||
assertCanonical("map[\"key1]", "map[\"key1]");
|
||||
assertCanonical("map[']", "map[']");
|
||||
assertCanonical("map[\"]", "map[\"]");
|
||||
}
|
||||
|
||||
@Test // Quotes are stripped whenever the key merely starts and ends with one.
|
||||
void outerQuotesAreStrippedWithoutRegardToNesting() {
|
||||
assertCanonical("map['a'b']", "map[a'b]");
|
||||
assertCanonical("map[a'b]", "map[a'b]");
|
||||
assertCanonical("map['a]b']", "map['a]b']");
|
||||
}
|
||||
|
||||
private void assertCanonical(String path, String expected) {
|
||||
assertThat(PropertyAccessorUtils.canonicalPropertyName(path))
|
||||
.as("canonicalPropertyName(\"%s\")", path)
|
||||
.isEqualTo(expected);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* {@code getPropertyName} only strips keys when the path happens to end
|
||||
* with {@code ]}, which makes it inconsistent between otherwise similar
|
||||
* malformed paths.
|
||||
*/
|
||||
@Nested
|
||||
class GetPropertyName {
|
||||
|
||||
@Test
|
||||
void keysAreStrippedOnlyWhenThePathEndsWithAKey() {
|
||||
assertThat(PropertyAccessorUtils.getPropertyName("map[key1]")).isEqualTo("map");
|
||||
assertThat(PropertyAccessorUtils.getPropertyName("map[key1][key2]")).isEqualTo("map");
|
||||
assertThat(PropertyAccessorUtils.getPropertyName("[user]")).isEmpty();
|
||||
|
||||
// Not stripped: the path does not end with ']'.
|
||||
assertThat(PropertyAccessorUtils.getPropertyName("map[key1].name")).isEqualTo("map[key1].name");
|
||||
assertThat(PropertyAccessorUtils.getPropertyName("map[key1]other")).isEqualTo("map[key1]other");
|
||||
|
||||
// Stripped, even though the path is malformed, because it does end with ']'.
|
||||
assertThat(PropertyAccessorUtils.getPropertyName("map[key1]IGNORED[key2]")).isEqualTo("map");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* The access-facing resolution: which property the accessor actually reads
|
||||
* or writes for a given path.
|
||||
*/
|
||||
@Nested
|
||||
class AccessorResolution {
|
||||
|
||||
@Test
|
||||
void wellFormedPathsResolveAsExpected() {
|
||||
assertThat(bind("name")).containsEntry("name", "V");
|
||||
assertThat(bind("nested.name")).containsEntry("nested.name", "V");
|
||||
assertThat(bind("map[key1]")).containsEntry("map", "{key1=V}");
|
||||
assertThat(bind("map['key1']")).containsEntry("map", "{key1=V}");
|
||||
assertThat(bind("map[\"key1\"]")).containsEntry("map", "{key1=V}");
|
||||
assertThat(bind("map[key[0]]")).containsEntry("map", "{key[0]=V}");
|
||||
assertThat(bind("map['key[0]']")).containsEntry("map", "{key[0]=V}");
|
||||
assertThat(bind("map[]")).containsEntry("map", "{=V}");
|
||||
assertThat(bind("map['']")).containsEntry("map", "{=V}");
|
||||
assertThat(bind("list[0]")).containsEntry("list", "[V]");
|
||||
assertThat(bind("nestedMap[a][b]")).containsEntry("nestedMap", "{a={b=V}}");
|
||||
}
|
||||
|
||||
@ParameterizedTest // gh-36999
|
||||
@ValueSource(strings = {"map[key1", "map]", "nested.].name", "nested.[.name",
|
||||
"nested.[[.name", "nested.]].name", "nested.][.name"})
|
||||
void unbalancedBracketsAreRejectedOnWrite(String path) {
|
||||
assertThatExceptionOfType(NotWritablePropertyException.class)
|
||||
.isThrownBy(() -> bind(path))
|
||||
.withMessageContaining("Nested property in path '" + path + "' does not exist");
|
||||
}
|
||||
|
||||
@ParameterizedTest // gh-36999
|
||||
@ValueSource(strings = {"map[key1", "map]", "nested.].name", "nested.[.name"})
|
||||
void unbalancedBracketsAreRejectedOnRead(String path) {
|
||||
BeanWrapperImpl accessor = new BeanWrapperImpl(new Target());
|
||||
|
||||
assertThatExceptionOfType(NotReadablePropertyException.class)
|
||||
.isThrownBy(() -> accessor.getPropertyValue(path))
|
||||
.withMessageEndingWith("contains unbalanced brackets");
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {".name", "person.", "nested..name"})
|
||||
void emptySegmentsAreRejectedOnWrite(String path) {
|
||||
assertThatExceptionOfType(NotWritablePropertyException.class)
|
||||
.isThrownBy(() -> bind(path));
|
||||
}
|
||||
|
||||
/**
|
||||
* The behavior that matters most for {@code DataBinder}: because
|
||||
* {@code ignoreUnknownFields} defaults to {@code true} and
|
||||
* {@code AbstractPropertyAccessor.setPropertyValues} swallows
|
||||
* {@code NotWritablePropertyException} in that mode, a malformed path
|
||||
* is currently dropped without any error being recorded.
|
||||
*/
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"map[key1", "map]", "nested.].name", ".name", "person."})
|
||||
void malformedPathsAreSilentlyDroppedWhenIgnoringUnknownFields(String path) {
|
||||
Target target = new Target();
|
||||
BeanWrapperImpl accessor = new BeanWrapperImpl(target);
|
||||
MutablePropertyValues pvs = new MutablePropertyValues(Map.of(path, "V"));
|
||||
|
||||
assertThatNoException().isThrownBy(() -> accessor.setPropertyValues(pvs, true, true));
|
||||
|
||||
assertThat(target.getMap()).isEmpty();
|
||||
assertThat(target.getName()).isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* The rows where {@link PropertyPath} knowingly differs from the current
|
||||
* implementation. Each test asserts the old behavior and the new behavior
|
||||
* together, so that the diff is explicit rather than discovered later.
|
||||
*/
|
||||
@Nested
|
||||
class IntentionalDivergences {
|
||||
|
||||
/**
|
||||
* The primary target of the refactor: trailing text after an index is
|
||||
* dropped by the accessor but kept by {@code canonicalPropertyName}.
|
||||
*/
|
||||
@Test
|
||||
void trailingTextAfterAnIndexIsDroppedByTheAccessorButKeptInTheCanonicalName() {
|
||||
assertThat(PropertyAccessorUtils.canonicalPropertyName("map[key1]other"))
|
||||
.isEqualTo("map[key1]other");
|
||||
assertThat(bind("map[key1]other")).containsEntry("map", "{key1=V}");
|
||||
|
||||
assertThatExceptionOfType(InvalidPropertyPathException.class)
|
||||
.isThrownBy(() -> PropertyPath.parse("map[key1]other"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void interleavedTextBetweenIndexesIsDroppedByTheAccessor() {
|
||||
assertThat(PropertyAccessorUtils.canonicalPropertyName("nestedMap[a]X[b]"))
|
||||
.isEqualTo("nestedMap[a]X[b]");
|
||||
assertThat(bind("nestedMap[a]X[b]")).containsEntry("nestedMap", "{a={b=V}}");
|
||||
|
||||
assertThatExceptionOfType(InvalidPropertyPathException.class)
|
||||
.isThrownBy(() -> PropertyPath.parse("nestedMap[a]X[b]"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void trailingTextBeforeANestedSeparatorIsDroppedByTheAccessor() {
|
||||
assertThatExceptionOfType(NotWritablePropertyException.class)
|
||||
.isThrownBy(() -> bind("map[key1]other.name"))
|
||||
// The accessor resolved 'map[key1].name', dropping 'other'.
|
||||
.withMessageContaining("map[key1].name");
|
||||
|
||||
assertThatExceptionOfType(InvalidPropertyPathException.class)
|
||||
.isThrownBy(() -> PropertyPath.parse("map[key1]other.name"));
|
||||
}
|
||||
|
||||
/**
|
||||
* A key consisting of nothing but a quote character is
|
||||
* <em>deliberately supported</em> today: commit {@code d3152c11c7}
|
||||
* ("Consistently expose map key quotes", gh-36765) added
|
||||
* {@code map.put("'", …)} and {@code map.put("\"", …)} to the shared
|
||||
* {@code IndexedTestBean} fixture and asserted
|
||||
* {@code getPropertyValue("map['].name")} in
|
||||
* {@link AbstractPropertyAccessorTests}. The lenient fallback it
|
||||
* relies on goes back further, to SPR-14293 ({@code cf0a0cd5d8}),
|
||||
* where treating an unterminated quote as literal content was the
|
||||
* chosen remedy for a {@code StringIndexOutOfBoundsException}.
|
||||
* <p>The strict quote grammar knowingly reverts that: an opened quote
|
||||
* must be closed. This is the one intentional divergence that removes
|
||||
* a documented capability rather than an accident, so it needs
|
||||
* explicit sign-off from the Beans/Core owners before the migration
|
||||
* of {@link PropertyAccessorUtils} lands.
|
||||
* <p>Auditing every property path literal in the accessor test suites
|
||||
* found exactly three affected by the strict grammar, all from this
|
||||
* lineage: {@code map['].name}, {@code map["].name} and {@code [']}.
|
||||
*/
|
||||
@ParameterizedTest // gh-36765
|
||||
@ValueSource(strings = {"map[']", "map[\"]"})
|
||||
void deliberatelySupportedQuoteOnlyKeyNoLongerBinds(String path) {
|
||||
assertThatNoException().isThrownBy(() -> bind(path));
|
||||
|
||||
assertThatExceptionOfType(InvalidPropertyPathException.class)
|
||||
.isThrownBy(() -> PropertyPath.parse(path))
|
||||
.withMessageContaining("unterminated quote");
|
||||
}
|
||||
|
||||
/**
|
||||
* These shapes also bind today, but unlike
|
||||
* {@link #deliberatelySupportedQuoteOnlyKeyNoLongerBinds} they are
|
||||
* asserted nowhere in the test suite: they are incidental consequences
|
||||
* of the same SPR-14293 leniency rather than intended behavior. Under
|
||||
* the strict grammar a raw key may not contain quote characters at all,
|
||||
* and a closing quote must be followed immediately by {@code ]}.
|
||||
*/
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"map[don't]", "map[a'b]", "map['key1]", "map[\"key1]", "map['a'b']"})
|
||||
void incidentallyAcceptedQuoteCharactersInKeysNoLongerBind(String path) {
|
||||
assertThatNoException().isThrownBy(() -> bind(path));
|
||||
|
||||
assertThatExceptionOfType(InvalidPropertyPathException.class)
|
||||
.isThrownBy(() -> PropertyPath.parse(path));
|
||||
}
|
||||
|
||||
/**
|
||||
* Today's canonical name is not a fixed point for nested quotes, so
|
||||
* canonicalizing twice yields a third, different key. Nothing exploits
|
||||
* this because {@code DataBinder} canonicalizes patterns once and
|
||||
* fields once, but it is the fragility that the strict grammar removes:
|
||||
* these inputs no longer parse, and for everything that does parse
|
||||
* {@code PropertyPath.canonicalName()} is idempotent.
|
||||
* @see PropertyPathTests#canonicalNameIdempotent(String) ()
|
||||
*/
|
||||
@Test
|
||||
void canonicalNameIsNotIdempotentForNestedQuotes() {
|
||||
assertThat(PropertyAccessorUtils.canonicalPropertyName("map[''a'']")).isEqualTo("map['a']");
|
||||
assertThat(PropertyAccessorUtils.canonicalPropertyName("map['a']")).isEqualTo("map[a]");
|
||||
|
||||
assertThatExceptionOfType(InvalidPropertyPathException.class)
|
||||
.isThrownBy(() -> PropertyPath.parse("map[''a'']"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Conversely, a quoted key is now opaque, so a key containing an
|
||||
* unbalanced {@code ]} becomes legal where it is rejected today.
|
||||
*/
|
||||
@Test
|
||||
void quotedKeyWithUnbalancedBracketBecomesLegal() {
|
||||
assertThatExceptionOfType(NotWritablePropertyException.class)
|
||||
.isThrownBy(() -> bind("map['a]b']"));
|
||||
|
||||
assertThat(PropertyPath.parse("map['a]b']").segments())
|
||||
.containsExactly(new PropertyPath.Segment("map", List.of("a]b")));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Bind {@code "V"} to the given path and return a description of the
|
||||
* resulting target state, keyed by property name.
|
||||
*/
|
||||
private static Map<String, String> bind(String path) {
|
||||
Target target = new Target();
|
||||
BeanWrapperImpl accessor = new BeanWrapperImpl(target);
|
||||
accessor.setAutoGrowNestedPaths(true);
|
||||
accessor.setPropertyValue(path, "V");
|
||||
return target.describe();
|
||||
}
|
||||
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
public static class Target {
|
||||
|
||||
private String name = "";
|
||||
|
||||
private Target nested;
|
||||
|
||||
private Map<String, String> map = new LinkedHashMap<>();
|
||||
|
||||
private Map<String, Map<String, String>> nestedMap = new LinkedHashMap<>();
|
||||
|
||||
private List<String> list = new ArrayList<>();
|
||||
|
||||
public String getName() {
|
||||
return this.name;
|
||||
}
|
||||
|
||||
public void setName(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public Target getNested() {
|
||||
return this.nested;
|
||||
}
|
||||
|
||||
public void setNested(Target nested) {
|
||||
this.nested = nested;
|
||||
}
|
||||
|
||||
public Map<String, String> getMap() {
|
||||
return this.map;
|
||||
}
|
||||
|
||||
public void setMap(Map<String, String> map) {
|
||||
this.map = map;
|
||||
}
|
||||
|
||||
public Map<String, Map<String, String>> getNestedMap() {
|
||||
return this.nestedMap;
|
||||
}
|
||||
|
||||
public void setNestedMap(Map<String, Map<String, String>> nestedMap) {
|
||||
this.nestedMap = nestedMap;
|
||||
}
|
||||
|
||||
public List<String> getList() {
|
||||
return this.list;
|
||||
}
|
||||
|
||||
public void setList(List<String> list) {
|
||||
this.list = list;
|
||||
}
|
||||
|
||||
Map<String, String> describe() {
|
||||
Map<String, String> description = new HashMap<>();
|
||||
if (!this.name.isEmpty()) {
|
||||
description.put("name", this.name);
|
||||
}
|
||||
if (!this.map.isEmpty()) {
|
||||
description.put("map", this.map.toString());
|
||||
}
|
||||
if (!this.nestedMap.isEmpty()) {
|
||||
description.put("nestedMap", this.nestedMap.toString());
|
||||
}
|
||||
if (!this.list.isEmpty()) {
|
||||
description.put("list", this.list.toString());
|
||||
}
|
||||
if (this.nested != null) {
|
||||
this.nested.describe().forEach((key, value) -> description.put("nested." + key, value));
|
||||
}
|
||||
return description;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
-3
@@ -76,7 +76,6 @@ public class IndexedTestBean {
|
||||
TestBean tb6 = new TestBean("name6", 0);
|
||||
TestBean tb7 = new TestBean("name7", 0);
|
||||
TestBean tb8 = new TestBean("name8", 0);
|
||||
TestBean tb9 = new TestBean("name9", 0);
|
||||
TestBean tbA = new TestBean("nameA", 0);
|
||||
TestBean tbB = new TestBean("nameB", 0);
|
||||
TestBean tbC = new TestBean("nameC", 0);
|
||||
@@ -105,8 +104,6 @@ public class IndexedTestBean {
|
||||
list.add(tbY);
|
||||
this.map.put("key4", list);
|
||||
this.map.put("key5[foo]", tb8);
|
||||
this.map.put("'", tb9);
|
||||
this.map.put("\"", tb9);
|
||||
this.myTestBeans = new MyTestBeans(tbZ);
|
||||
}
|
||||
|
||||
|
||||
+3
-3
@@ -22,8 +22,8 @@ import org.jspecify.annotations.Nullable;
|
||||
|
||||
import org.springframework.beans.BeanUtils;
|
||||
import org.springframework.beans.ConfigurablePropertyAccessor;
|
||||
import org.springframework.beans.PropertyAccessorUtils;
|
||||
import org.springframework.beans.PropertyEditorRegistry;
|
||||
import org.springframework.beans.PropertyPath;
|
||||
import org.springframework.core.convert.ConversionService;
|
||||
import org.springframework.core.convert.TypeDescriptor;
|
||||
import org.springframework.core.convert.support.ConvertingPropertyEditorAdapter;
|
||||
@@ -76,11 +76,11 @@ public abstract class AbstractPropertyBindingResult extends AbstractBindingResul
|
||||
|
||||
/**
|
||||
* Returns the canonical property name.
|
||||
* @see org.springframework.beans.PropertyAccessorUtils#canonicalPropertyName
|
||||
* @see PropertyPath#canonicalNameOrOriginal(String)
|
||||
*/
|
||||
@Override
|
||||
protected String canonicalFieldName(String field) {
|
||||
return PropertyAccessorUtils.canonicalPropertyName(field);
|
||||
return PropertyPath.canonicalNameOrOriginal(field);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -42,12 +42,13 @@ import org.springframework.beans.BeanInstantiationException;
|
||||
import org.springframework.beans.BeanUtils;
|
||||
import org.springframework.beans.ConfigurablePropertyAccessor;
|
||||
import org.springframework.beans.InvalidPropertyException;
|
||||
import org.springframework.beans.InvalidPropertyPathException;
|
||||
import org.springframework.beans.MutablePropertyValues;
|
||||
import org.springframework.beans.PropertyAccessException;
|
||||
import org.springframework.beans.PropertyAccessorUtils;
|
||||
import org.springframework.beans.PropertyBatchUpdateException;
|
||||
import org.springframework.beans.PropertyEditorRegistrar;
|
||||
import org.springframework.beans.PropertyEditorRegistry;
|
||||
import org.springframework.beans.PropertyPath;
|
||||
import org.springframework.beans.PropertyValue;
|
||||
import org.springframework.beans.PropertyValues;
|
||||
import org.springframework.beans.SimpleTypeConverter;
|
||||
@@ -540,9 +541,8 @@ public class DataBinder implements PropertyEditorRegistry, TypeConverter {
|
||||
* {@code "xxx*yyy"} matches (with an arbitrary number of pattern parts), as
|
||||
* well as direct equality.
|
||||
* <p>The default implementation of this method stores allowed field patterns
|
||||
* in {@linkplain PropertyAccessorUtils#canonicalPropertyName(String) canonical}
|
||||
* form. Subclasses which override this method must therefore take this into
|
||||
* account.
|
||||
* in {@linkplain PropertyPath#canonicalName() canonical} form. Subclasses
|
||||
* which override this method must therefore take this into account.
|
||||
* <p>More sophisticated matching can be implemented by overriding the
|
||||
* {@link #isAllowed} method.
|
||||
* <p>Used for binding to fields with {@link #bind(PropertyValues)}, and not
|
||||
@@ -552,7 +552,7 @@ public class DataBinder implements PropertyEditorRegistry, TypeConverter {
|
||||
* @see #isAllowed(String)
|
||||
*/
|
||||
public void setAllowedFields(String @Nullable ... allowedFields) {
|
||||
this.allowedFields = PropertyAccessorUtils.canonicalPropertyNames(allowedFields);
|
||||
this.allowedFields = canonicalPropertyNames(allowedFields);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -573,9 +573,9 @@ public class DataBinder implements PropertyEditorRegistry, TypeConverter {
|
||||
* {@code "xxx*yyy"} matches (with an arbitrary number of pattern parts),
|
||||
* as well as direct equality.
|
||||
* <p>The default implementation of this method stores disallowed field
|
||||
* patterns in {@linkplain PropertyAccessorUtils#canonicalPropertyName(String)
|
||||
* canonical} form, and subsequently pattern matching in {@link #isAllowed}
|
||||
* is case-insensitive. Subclasses that override this method must therefore
|
||||
* patterns in {@linkplain PropertyPath#canonicalName() canonical} form,
|
||||
* and subsequently pattern matching in {@link #isAllowed} is
|
||||
* case-insensitive. Subclasses that override this method must therefore
|
||||
* take this transformation into account.
|
||||
* <p>More sophisticated matching can be implemented by overriding the
|
||||
* {@link #isAllowed} method.
|
||||
@@ -592,16 +592,7 @@ public class DataBinder implements PropertyEditorRegistry, TypeConverter {
|
||||
*/
|
||||
@Deprecated(since = "7.1", forRemoval = true)
|
||||
public void setDisallowedFields(String @Nullable ... disallowedFields) {
|
||||
if (disallowedFields == null) {
|
||||
this.disallowedFields = null;
|
||||
}
|
||||
else {
|
||||
String[] fieldPatterns = new String[disallowedFields.length];
|
||||
for (int i = 0; i < fieldPatterns.length; i++) {
|
||||
fieldPatterns[i] = PropertyAccessorUtils.canonicalPropertyName(disallowedFields[i]);
|
||||
}
|
||||
this.disallowedFields = fieldPatterns;
|
||||
}
|
||||
this.disallowedFields = canonicalPropertyNames(disallowedFields);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -628,7 +619,7 @@ public class DataBinder implements PropertyEditorRegistry, TypeConverter {
|
||||
* @see DefaultBindingErrorProcessor#MISSING_FIELD_ERROR_CODE
|
||||
*/
|
||||
public void setRequiredFields(String @Nullable ... requiredFields) {
|
||||
this.requiredFields = PropertyAccessorUtils.canonicalPropertyNames(requiredFields);
|
||||
this.requiredFields = canonicalPropertyNames(requiredFields);
|
||||
if (logger.isDebugEnabled()) {
|
||||
logger.debug("DataBinder requires binding of required fields [" +
|
||||
StringUtils.arrayToCommaDelimitedString(requiredFields) + "]");
|
||||
@@ -1294,6 +1285,17 @@ public class DataBinder implements PropertyEditorRegistry, TypeConverter {
|
||||
applyPropertyValues(mpvs);
|
||||
}
|
||||
|
||||
private static String @Nullable [] canonicalPropertyNames(String @Nullable [] fields) {
|
||||
if (fields == null) {
|
||||
return null;
|
||||
}
|
||||
String[] result = new String[fields.length];
|
||||
for (int i = 0; i < fields.length; i++) {
|
||||
result[i] = PropertyPath.canonicalNameOrOriginal(fields[i]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the given property values against the allowed fields,
|
||||
* removing values for fields that are not allowed.
|
||||
@@ -1303,11 +1305,23 @@ public class DataBinder implements PropertyEditorRegistry, TypeConverter {
|
||||
*/
|
||||
protected void checkAllowedFields(MutablePropertyValues mpvs) {
|
||||
PropertyValue[] pvs = mpvs.getPropertyValues();
|
||||
PropertyPath.Options options = PropertyPath.Options.withMaxNestedPathDepth(getMaxNestedPathDepth());
|
||||
for (PropertyValue pv : pvs) {
|
||||
String field = PropertyAccessorUtils.canonicalPropertyName(pv.getName());
|
||||
if (!isAllowed(field)) {
|
||||
PropertyPath field;
|
||||
try {
|
||||
field = PropertyPath.parse(pv.getName(), options);
|
||||
}
|
||||
catch (InvalidPropertyPathException ex) {
|
||||
mpvs.removePropertyValue(pv);
|
||||
getBindingResult().recordSuppressedField(field);
|
||||
Object target = getTarget();
|
||||
getBindingErrorProcessor().processPropertyAccessException(
|
||||
new InvalidPropertyPathException(target != null ? target : this, pv.getName(), pv.getValue(), ex),
|
||||
getInternalBindingResult());
|
||||
continue;
|
||||
}
|
||||
if (!isAllowed(field.canonicalName())) {
|
||||
mpvs.removePropertyValue(pv);
|
||||
getBindingResult().recordSuppressedField(field.canonicalName());
|
||||
if (logger.isDebugEnabled()) {
|
||||
logger.debug("Field [" + field + "] has been removed from PropertyValues " +
|
||||
"and will not be bound, because it has not been found in the list of allowed fields");
|
||||
@@ -1327,12 +1341,14 @@ public class DataBinder implements PropertyEditorRegistry, TypeConverter {
|
||||
* matching against disallowed field patterns is case-insensitive.
|
||||
* <p>A field matching a disallowed pattern will not be accepted even if it
|
||||
* also happens to match a pattern in the allowed list.
|
||||
* <p>{@code field} is matched as-is, but it must already have been validated
|
||||
* and canonicalized via {@link PropertyPath} by the caller first.
|
||||
* <p>Can be overridden in subclasses, but care must be taken to honor the
|
||||
* aforementioned contract.
|
||||
* @param field the field to check
|
||||
* @param field the field to check, expected to already be in canonical form
|
||||
* @return {@code true} if the field is allowed
|
||||
* @see #setAllowedFields
|
||||
* @see org.springframework.util.PatternMatchUtils#simpleMatch(String, String)
|
||||
* @see org.springframework.util.PatternMatchUtils#simpleMatch(String[], String)
|
||||
*/
|
||||
protected boolean isAllowed(String field) {
|
||||
String[] allowed = getAllowedFields();
|
||||
@@ -1361,7 +1377,7 @@ public class DataBinder implements PropertyEditorRegistry, TypeConverter {
|
||||
Map<String, PropertyValue> propertyValues = new HashMap<>();
|
||||
PropertyValue[] pvs = mpvs.getPropertyValues();
|
||||
for (PropertyValue pv : pvs) {
|
||||
String canonicalName = PropertyAccessorUtils.canonicalPropertyName(pv.getName());
|
||||
String canonicalName = PropertyPath.canonicalNameOrOriginal(pv.getName());
|
||||
propertyValues.put(canonicalName, pv);
|
||||
}
|
||||
for (String field : requiredFields) {
|
||||
|
||||
+7
-4
@@ -23,6 +23,7 @@ import java.util.Map;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import org.springframework.beans.InvalidPropertyException;
|
||||
import org.springframework.beans.InvalidPropertyPathException;
|
||||
import org.springframework.beans.MutablePropertyValues;
|
||||
import org.springframework.beans.NotWritablePropertyException;
|
||||
import org.springframework.beans.NullValueInNestedPathException;
|
||||
@@ -162,7 +163,7 @@ class DataBinderFieldAccessTests {
|
||||
rod.setSpouse(kerry);
|
||||
kerry.setSpouse(rod);
|
||||
|
||||
DataBinder binder = new DataBinder(rod);
|
||||
DataBinder binder = new DataBinder(rod, "rod");
|
||||
binder.setMaxNestedPathDepth(2);
|
||||
binder.initDirectFieldAccess();
|
||||
|
||||
@@ -173,9 +174,11 @@ class DataBinderFieldAccessTests {
|
||||
|
||||
MutablePropertyValues tooDeep = new MutablePropertyValues();
|
||||
tooDeep.add("spouse.spouse.spouse.name", "Joe");
|
||||
assertThatExceptionOfType(InvalidPropertyException.class)
|
||||
.isThrownBy(() -> binder.bind(tooDeep))
|
||||
.withMessageEndingWith("Nesting depth of property path exceeds the maximum of 2");
|
||||
binder.bind(tooDeep);
|
||||
assertThat(binder.getBindingResult().getFieldErrors("spouse.spouse.spouse.name")).singleElement().satisfies(error -> {
|
||||
assertThat(error.getCode()).isEqualTo(InvalidPropertyPathException.ERROR_CODE);
|
||||
assertThat(error.getRejectedValue()).isEqualTo("Joe");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -42,6 +42,7 @@ import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.BeanWrapper;
|
||||
import org.springframework.beans.ConfigurablePropertyAccessor;
|
||||
import org.springframework.beans.InvalidPropertyException;
|
||||
import org.springframework.beans.InvalidPropertyPathException;
|
||||
import org.springframework.beans.MethodInvocationException;
|
||||
import org.springframework.beans.MutablePropertyValues;
|
||||
import org.springframework.beans.NotWritablePropertyException;
|
||||
@@ -735,6 +736,56 @@ class DataBinderTests {
|
||||
assertThat(binder.getBindingResult().getSuppressedFields()).containsExactlyInAnyOrder("age", "favoriteColor");
|
||||
}
|
||||
|
||||
@Test // gh-37275
|
||||
void bindingWithMalformedFieldNameSurfacesAsFieldErrorRatherThanBeingSilentlyDropped() throws BindException {
|
||||
TestBean rod = new TestBean();
|
||||
DataBinder binder = new DataBinder(rod, "rod");
|
||||
MutablePropertyValues pvs = new MutablePropertyValues();
|
||||
pvs.add("name", "Rod");
|
||||
pvs.add("map[key1]other", "value"); // trailing garbage after a key: not a well-formed property path
|
||||
|
||||
binder.bind(pvs);
|
||||
|
||||
assertThat(rod.getName()).as("well-formed fields still bind").isEqualTo("Rod");
|
||||
assertThat(binder.getBindingResult().getFieldErrors("map[key1]other")).singleElement().satisfies(error -> {
|
||||
assertThat(error.getCode()).isEqualTo(InvalidPropertyPathException.ERROR_CODE);
|
||||
assertThat(error.getRejectedValue()).isEqualTo("value");
|
||||
});
|
||||
assertThatExceptionOfType(BindException.class).isThrownBy(binder::close);
|
||||
}
|
||||
|
||||
@Test // gh-37275
|
||||
void bindingWithMalformedFieldNameIsNotSuppressedEvenWhenAllowedFieldsAreConfigured() throws BindException {
|
||||
TestBean rod = new TestBean();
|
||||
DataBinder binder = new DataBinder(rod, "rod");
|
||||
binder.setAllowedFields("name");
|
||||
MutablePropertyValues pvs = new MutablePropertyValues();
|
||||
pvs.add("name", "Rod");
|
||||
pvs.add("map[key1]other", "value");
|
||||
|
||||
binder.bind(pvs);
|
||||
|
||||
assertThat(binder.getBindingResult().getSuppressedFields()).isEmpty();
|
||||
assertThat(binder.getBindingResult().getFieldErrors("map[key1]other")).hasSize(1);
|
||||
}
|
||||
|
||||
@Test // gh-37275
|
||||
void isAllowedNeverThrowsForMalformedField() {
|
||||
class ExposingBinder extends DataBinder {
|
||||
ExposingBinder(Object target) {
|
||||
super(target, "target");
|
||||
}
|
||||
boolean callIsAllowed(String field) {
|
||||
return isAllowed(field);
|
||||
}
|
||||
}
|
||||
|
||||
ExposingBinder binder = new ExposingBinder(new TestBean());
|
||||
binder.setAllowedFields("name");
|
||||
|
||||
assertThat(binder.callIsAllowed("map[key1]other")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("removal")
|
||||
void bindingWithAllowedAndDisallowedFields() throws BindException {
|
||||
@@ -2080,7 +2131,7 @@ class DataBinderTests {
|
||||
rod.setSpouse(kerry);
|
||||
kerry.setSpouse(rod);
|
||||
|
||||
DataBinder binder = new DataBinder(rod);
|
||||
DataBinder binder = new DataBinder(rod, "rod");
|
||||
binder.setMaxNestedPathDepth(2);
|
||||
|
||||
MutablePropertyValues pvs = new MutablePropertyValues();
|
||||
@@ -2090,9 +2141,11 @@ class DataBinderTests {
|
||||
|
||||
MutablePropertyValues tooDeep = new MutablePropertyValues();
|
||||
tooDeep.add("spouse.spouse.spouse.name", "Joe");
|
||||
assertThatExceptionOfType(InvalidPropertyException.class)
|
||||
.isThrownBy(() -> binder.bind(tooDeep))
|
||||
.withMessageEndingWith("Nesting depth of property path exceeds the maximum of 2");
|
||||
binder.bind(tooDeep);
|
||||
assertThat(binder.getBindingResult().getFieldErrors("spouse.spouse.spouse.name")).singleElement().satisfies(error -> {
|
||||
assertThat(error.getCode()).isEqualTo(InvalidPropertyPathException.ERROR_CODE);
|
||||
assertThat(error.getRejectedValue()).isEqualTo("Joe");
|
||||
});
|
||||
}
|
||||
|
||||
@Test // gh-37252
|
||||
|
||||
Reference in New Issue
Block a user