Use depth-aware bracket parsing in PropertyAccessorUtils

Previously, canonicalPropertyName() located the end of a [key]
expression via a naive indexOf("]") search, while
AbstractNestablePropertyAccessor.getPropertyNameKeyEnd() -- used during
actual property resolution -- tracked bracket nesting depth. This meant
the two methods could disagree on the canonical form of a property
path whose map key itself contains bracket characters (e.g.,
map['key[0]']).

Similarly, getNestedPropertySeparatorIndex() tracked whether a dot
separator occurs inside a [key] expression using a simple boolean
toggle that flips on both '[' and ']', which produces an incorrect
result when a key contains an odd net count of inner bracket
characters.

To address those inconsistencies, this commit extracts the private
getPropertyNameKeyEnd() method from AbstractNestablePropertyAccessor to
a package-private static utility method in PropertyAccessorUtils, so
that canonicalPropertyName() can reuse the same depth-aware bracket
matching, and getNestedPropertySeparatorIndex() has been reworked to
track bracket nesting depth instead of toggling a boolean flag.

Closes gh-36999
This commit is contained in:
Sam Brannen
2026-08-28 19:11:20 +02:00
parent 3eb93a4873
commit 1b56f58999
3 changed files with 67 additions and 34 deletions
@@ -938,7 +938,7 @@ public abstract class AbstractNestablePropertyAccessor extends AbstractPropertyA
int keyStart = propertyName.indexOf(PROPERTY_KEY_PREFIX, searchIndex);
searchIndex = -1;
if (keyStart != -1) {
int keyEnd = getPropertyNameKeyEnd(propertyName, keyStart + PROPERTY_KEY_PREFIX.length());
int keyEnd = PropertyAccessorUtils.getPropertyNameKeyEnd(propertyName, keyStart + PROPERTY_KEY_PREFIX.length());
if (keyEnd != -1) {
if (actualName == null) {
actualName = propertyName.substring(0, keyStart);
@@ -963,32 +963,6 @@ public abstract class AbstractNestablePropertyAccessor extends AbstractPropertyA
return tokens;
}
private int getPropertyNameKeyEnd(String propertyName, int startIndex) {
int unclosedPrefixes = 0;
int length = propertyName.length();
for (int i = startIndex; i < length; i++) {
switch (propertyName.charAt(i)) {
case PropertyAccessor.PROPERTY_KEY_PREFIX_CHAR -> {
// The property name contains opening prefix(es)...
unclosedPrefixes++;
}
case PropertyAccessor.PROPERTY_KEY_SUFFIX_CHAR -> {
if (unclosedPrefixes == 0) {
// No unclosed prefix(es) in the property name (left) ->
// this is the suffix we are looking for.
return i;
}
else {
// This suffix does not close the initial prefix but rather
// just one that occurred within the property name.
unclosedPrefixes--;
}
}
}
}
return -1;
}
@Override
public String toString() {
String className = getClass().getName();
@@ -23,6 +23,7 @@ import org.jspecify.annotations.Nullable;
* according to the {@link PropertyAccessor} interface.
*
* @author Juergen Hoeller
* @author Sam Brannen
* @since 1.2.6
*/
public abstract class PropertyAccessorUtils {
@@ -81,21 +82,23 @@ public abstract class PropertyAccessorUtils {
/**
* Determine the first (or last) nested property separator in the
* given property path, ignoring dots in keys (like "map[my.key]").
* <p>Also tracks bracket nesting depth so that keys containing an unbalanced
* number of {@code [} or {@code ]} characters do not interfere with the
* detection of separators that precede or follow the key.
* @param propertyPath the property path to check
* @param last whether to return the last separator rather than the first
* @return the index of the nested property separator, or -1 if none
*/
private static int getNestedPropertySeparatorIndex(String propertyPath, boolean last) {
boolean inKey = false;
int depth = 0;
int length = propertyPath.length();
int i = (last ? length - 1 : 0);
while (last ? i >= 0 : i < length) {
switch (propertyPath.charAt(i)) {
case PropertyAccessor.PROPERTY_KEY_PREFIX_CHAR, PropertyAccessor.PROPERTY_KEY_SUFFIX_CHAR -> {
inKey = !inKey;
}
case PropertyAccessor.PROPERTY_KEY_PREFIX_CHAR -> depth += (last ? -1 : 1);
case PropertyAccessor.PROPERTY_KEY_SUFFIX_CHAR -> depth += (last ? 1 : -1);
case PropertyAccessor.NESTED_PROPERTY_SEPARATOR_CHAR -> {
if (!inKey) {
if (depth <= 0) {
return i;
}
}
@@ -150,8 +153,7 @@ public abstract class PropertyAccessorUtils {
int keyStart = sb.indexOf(PropertyAccessor.PROPERTY_KEY_PREFIX, searchIndex);
searchIndex = -1;
if (keyStart != -1) {
int keyEnd = sb.indexOf(
PropertyAccessor.PROPERTY_KEY_SUFFIX, keyStart + PropertyAccessor.PROPERTY_KEY_PREFIX.length());
int keyEnd = getPropertyNameKeyEnd(sb, keyStart + PropertyAccessor.PROPERTY_KEY_PREFIX.length());
if (keyEnd != -1) {
String key = sb.substring(keyStart + PropertyAccessor.PROPERTY_KEY_PREFIX.length(), keyEnd);
if (key.length() > 1 && ((key.startsWith("'") && key.endsWith("'")) ||
@@ -185,4 +187,42 @@ public abstract class PropertyAccessorUtils {
return result;
}
/**
* Determine the end of the {@code [key]} expression that starts at the
* given {@code startIndex}, tracking bracket nesting depth so that any
* {@code [} or {@code ]} characters contained within the key itself (for
* example, in a quoted key such as {@code "['key[0]']"}) do not interfere
* with the detection of the actual closing bracket.
* @param propertyName the property name (or path) to search
* @param startIndex the index to start searching from (immediately after
* the opening {@code [})
* @return the index of the matching closing {@code ]}, or -1 if none
* @since 7.1
*/
static int getPropertyNameKeyEnd(CharSequence propertyName, int startIndex) {
int unclosedPrefixes = 0;
int length = propertyName.length();
for (int i = startIndex; i < length; i++) {
switch (propertyName.charAt(i)) {
case PropertyAccessor.PROPERTY_KEY_PREFIX_CHAR -> {
// The property name contains opening prefix(es)...
unclosedPrefixes++;
}
case PropertyAccessor.PROPERTY_KEY_SUFFIX_CHAR -> {
if (unclosedPrefixes == 0) {
// No unclosed prefix(es) in the property name (left) ->
// this is the suffix we are looking for.
return i;
}
else {
// This suffix does not close the initial prefix but rather
// just one that occurred within the property name.
unclosedPrefixes--;
}
}
}
}
return -1;
}
}
@@ -49,12 +49,24 @@ class PropertyAccessorUtilsTests {
void getFirstNestedPropertySeparatorIndex() {
assertThat(PropertyAccessorUtils.getFirstNestedPropertySeparatorIndex("[user]")).isEqualTo(-1);
assertThat(PropertyAccessorUtils.getFirstNestedPropertySeparatorIndex("user.name")).isEqualTo(4);
assertThat(PropertyAccessorUtils.getFirstNestedPropertySeparatorIndex("map[key[0]].name")).isEqualTo(11);
// A dot nested two (unclosed) bracket levels deep must not be mistaken
// for a top-level separator, even though the net number of brackets
// seen so far is even.
assertThat(PropertyAccessorUtils.getFirstNestedPropertySeparatorIndex("a[b[c.d]]")).isEqualTo(-1);
}
@Test
void getLastNestedPropertySeparatorIndex() {
assertThat(PropertyAccessorUtils.getLastNestedPropertySeparatorIndex("[user]")).isEqualTo(-1);
assertThat(PropertyAccessorUtils.getLastNestedPropertySeparatorIndex("user.address.street")).isEqualTo(12);
assertThat(PropertyAccessorUtils.getLastNestedPropertySeparatorIndex("map[key[0]].name")).isEqualTo(11);
// Symmetric case to the one above, but scanning from the end: a dot
// preceded (from the right) by two unmatched closing brackets must
// not be mistaken for a top-level separator.
assertThat(PropertyAccessorUtils.getLastNestedPropertySeparatorIndex("d.c]b]a")).isEqualTo(-1);
}
@Test
@@ -83,6 +95,13 @@ class PropertyAccessorUtilsTests {
assertThat(PropertyAccessorUtils.canonicalPropertyName("map[\"key1]")).isEqualTo("map[\"key1]");
assertThat(PropertyAccessorUtils.canonicalPropertyName("map[']")).isEqualTo("map[']");
assertThat(PropertyAccessorUtils.canonicalPropertyName("map[\"]")).isEqualTo("map[\"]");
// Keys that themselves contain bracket characters must be resolved
// using depth-aware parsing, consistent with how the property
// accessor resolves the same paths during actual data binding.
assertThat(PropertyAccessorUtils.canonicalPropertyName("map[\"key[0]\"]")).isEqualTo("map[key[0]]");
assertThat(PropertyAccessorUtils.canonicalPropertyName("map['key[0]'].name")).isEqualTo("map[key[0]].name");
assertThat(PropertyAccessorUtils.canonicalPropertyName("users['admin[0]']")).isEqualTo("users[admin[0]]");
}
@Test