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:
Brian Clozel
2026-09-17 15:37:40 +02:00
parent 0d079ea435
commit cd110ad14e
13 changed files with 390 additions and 725 deletions
@@ -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