mirror of
https://github.com/spring-projects/spring-framework.git
synced 2026-09-17 16:39:29 +00:00
Extract bean property path support in PropertyPath
Prior to this commit, bean property path support would be duplicated in `AbstractNestablePropertyAccessor` and `PropertyAccessorUtils`. This means they both supported the parsing, validation and extraction of path segments. Implementations were not always in sync and could cause issues. This commit introduces a new `PropertyPath` type that holds the canonical form of the property path and the parsed path segments for property access. This implements an efficient parser that rejects invalid property paths early if they don't match the new grammar. `PropertyPath.parse(String, Options)` additionally accepts a maximum nesting depth, rejecting an excessively deep path immediately after parsing and before any navigation of an object graph begins. This moves the implementation introduced in gh-37252, but keeps the public configuration in place. `InvalidPropertyPathException` is introduced to report a syntactically invalid path, as distinct from a syntactically valid path that happens not to resolve against a particular target object (see `NotReadablePropertyException` and `NotWritablePropertyException`). It extends `PropertyAccessException`, but not `InvalidPropertyException`. Malformed paths should be collected into `PropertyBatchUpdateException` alongside other per-property failures. See gh-37275
This commit is contained in:
+72
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* 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;
|
||||
|
||||
/**
|
||||
* Exception thrown when a property path is not a well-formed property path
|
||||
* according to the grammar implemented by {@link PropertyPath}.
|
||||
*
|
||||
* <p>This signals a syntactically invalid path, as opposed to a
|
||||
* syntactically valid path that happens not to resolve against a particular
|
||||
* target object. The latter cases are reported as {@link NotReadablePropertyException}
|
||||
* or {@link NotWritablePropertyException} instead.
|
||||
*
|
||||
* <p>Extends {@link PropertyAccessException} so that a malformed path
|
||||
* encountered while binding a single property value can be collected into a
|
||||
* {@link PropertyBatchUpdateException} alongside other per-property failures,
|
||||
* rather than aborting the whole binding operation.
|
||||
*
|
||||
* @author Brian Clozel
|
||||
* @since 7.1
|
||||
* @see PropertyPath#parse(String)
|
||||
*/
|
||||
@SuppressWarnings("serial")
|
||||
public class InvalidPropertyPathException extends PropertyAccessException {
|
||||
|
||||
/**
|
||||
* Error code that an {@code InvalidPropertyPathException} is registered with.
|
||||
*/
|
||||
public static final String ERROR_CODE = "invalidPropertyPath";
|
||||
|
||||
|
||||
private final String propertyPath;
|
||||
|
||||
|
||||
/**
|
||||
* Create a new {@code InvalidPropertyPathException}.
|
||||
* @param propertyPath the offending property path
|
||||
* @param reason a description of the grammar rule that was violated
|
||||
*/
|
||||
public InvalidPropertyPathException(String propertyPath, String reason) {
|
||||
super("Invalid property path '" + propertyPath + "': " + reason, null);
|
||||
this.propertyPath = propertyPath;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Return the offending property path.
|
||||
*/
|
||||
public String getPropertyPath() {
|
||||
return this.propertyPath;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getErrorCode() {
|
||||
return ERROR_CODE;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,539 @@
|
||||
/*
|
||||
* 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.Collections;
|
||||
import java.util.List;
|
||||
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
import org.springframework.util.Assert;
|
||||
|
||||
/**
|
||||
* A parsed bean property path, such as {@code "person.addresses[1].city"}.
|
||||
*
|
||||
* <p>Parses bean property paths for property access: it decides whether a given
|
||||
* string is a well-formed property path (see grammar) and where each path
|
||||
* segment begins and ends. The {@linkplain #canonicalName() canonical form}
|
||||
* is used for policy matching and error reporting.
|
||||
* The {@linkplain #segments() structured segment list} is used for property
|
||||
* navigation within beans.
|
||||
*
|
||||
* <p>The grammar is:
|
||||
* <pre>
|
||||
* PropertyPath := Segment ('.' Segment)*
|
||||
* Segment := Name Index* -- Name may be empty only if at least one Index follows
|
||||
* Name := char* excluding '.', '[', ']'
|
||||
* Index := '[' Key ']'
|
||||
* Key := QuotedKey | RawKey
|
||||
* QuotedKey := "'" [^']* "'" | '"' [^"]* '"'
|
||||
* RawKey := char* excluding quote characters, with balanced '[' / ']' nesting
|
||||
* </pre>
|
||||
*
|
||||
* <p>Invalid property paths are rejected with {@link InvalidPropertyPathException}.
|
||||
*
|
||||
* @author Brian Clozel
|
||||
* @since 7.1
|
||||
*/
|
||||
public final class PropertyPath {
|
||||
|
||||
|
||||
private final String canonicalName;
|
||||
|
||||
private final List<Segment> segments;
|
||||
|
||||
|
||||
private PropertyPath(String canonicalName, List<Segment> segments) {
|
||||
this.canonicalName = canonicalName;
|
||||
this.segments = segments;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Parse the given property path.
|
||||
* @param path the property path to parse; an empty string is a valid path
|
||||
* with no segments
|
||||
* @return the parsed property path
|
||||
* @throws InvalidPropertyPathException if the given path is not a
|
||||
* well-formed property path
|
||||
*/
|
||||
public static PropertyPath parse(String path) throws InvalidPropertyPathException {
|
||||
return parse(path, Options.UNLIMITED);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the given property path, rejecting it if it exceeds the given {@code options}.
|
||||
* @param path the property path to parse; an empty string is a valid path
|
||||
* with no segments
|
||||
* @param options the parsing options to apply
|
||||
* @return the parsed property path
|
||||
* @throws InvalidPropertyPathException if the given path is not a
|
||||
* well-formed property path, or if it exceeds the given options
|
||||
*/
|
||||
public static PropertyPath parse(String path, Options options) throws InvalidPropertyPathException {
|
||||
Assert.notNull(path, "Property path must not be null");
|
||||
Assert.notNull(options, "Options must not be null");
|
||||
if (path.isEmpty()) {
|
||||
return new PropertyPath("", Collections.emptyList());
|
||||
}
|
||||
PropertyPath parsed = new Parser(path).parse();
|
||||
int nestingDepth = parsed.segments.size() - 1;
|
||||
if (nestingDepth > options.maxNestedPathDepth) {
|
||||
throw new InvalidPropertyPathException(path,
|
||||
"nesting depth exceeds the maximum of " + options.maxNestedPathDepth);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@code path}'s canonical form, or {@code path} itself if it is not a
|
||||
* well-formed property path (including a {@code null} path, for which
|
||||
* this returns an empty string).
|
||||
* <p>A convenience for callers with nothing better to fall back to than
|
||||
* the original string, such as canonicalizing a user-supplied field name
|
||||
* for display, comparison, or configuration matching, as opposed to
|
||||
* {@link #parse(String)} itself, whose non-throwing behavior would be the
|
||||
* wrong default for a caller that is about to navigate an object graph.
|
||||
* @param path the property path to canonicalize, possibly {@code null}
|
||||
* @return the canonical form of {@code path}, or {@code path} unchanged
|
||||
* (or an empty string, if {@code path} is {@code null}) if it is not a
|
||||
* well-formed property path
|
||||
* @since 7.1
|
||||
*/
|
||||
public static String canonicalNameOrOriginal(@Nullable String path) {
|
||||
if (path == null) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
return parse(path).canonicalName();
|
||||
}
|
||||
catch (InvalidPropertyPathException ex) {
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the canonical string form of this path.
|
||||
* <p>Unnecessary surrounding quotes are removed from keys:
|
||||
* {@code map['key'].name} → {@code map[key].name}.
|
||||
*/
|
||||
public String canonicalName() {
|
||||
return this.canonicalName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the segments of this path, in order, as an unmodifiable list.
|
||||
*/
|
||||
public List<Segment> segments() {
|
||||
return this.segments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the sub-path made up of this path's segments from the given
|
||||
* index to the end, such as the {@code "country.name"} sub-path of
|
||||
* {@code "address.country.name"} from index 1.
|
||||
* @param fromIndex the index of the first segment to include (inclusive)
|
||||
* @return the sub-path starting at {@code fromIndex}
|
||||
* @throws IndexOutOfBoundsException if {@code fromIndex} is negative
|
||||
* @throws IllegalArgumentException if {@code fromIndex} is greater than
|
||||
* {@link #segments()}{@code .size()}
|
||||
*/
|
||||
public PropertyPath subPath(int fromIndex) {
|
||||
if (fromIndex == 0) {
|
||||
return this;
|
||||
}
|
||||
List<Segment> subSegments = this.segments.subList(fromIndex, this.segments.size());
|
||||
StringBuilder subCanonicalName = new StringBuilder();
|
||||
for (int i = 0; i < subSegments.size(); i++) {
|
||||
if (i > 0) {
|
||||
subCanonicalName.append(PropertyAccessor.NESTED_PROPERTY_SEPARATOR_CHAR);
|
||||
}
|
||||
subCanonicalName.append(subSegments.get(i).toCanonicalName());
|
||||
}
|
||||
return new PropertyPath(subCanonicalName.toString(), subSegments);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(@Nullable Object other) {
|
||||
return (this == other || (other instanceof PropertyPath that &&
|
||||
this.canonicalName.equals(that.canonicalName)));
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return this.canonicalName.hashCode();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return this.canonicalName;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* A dot-separated segment of a property path. It consists of a
|
||||
* property name and the keys of any indexes applied to it, if the
|
||||
* target property is an indexed collection.
|
||||
* <p>For example, the path {@code "map[key].name"} has two segments:
|
||||
* {@code Segment["map", ["key"]]} and {@code Segment["name", []]}.
|
||||
* @param name the property name, which is empty only for a root-level
|
||||
* indexed access such as {@code "[user]"}
|
||||
* @param keys the keys of the indexes applied to the property, with any
|
||||
* surrounding quotes removed; never {@code null}, possibly empty
|
||||
*/
|
||||
public record Segment(String name, List<String> keys) {
|
||||
|
||||
public Segment(String name, List<String> keys) {
|
||||
Assert.notNull(name, "Segment name must not be null");
|
||||
Assert.notNull(keys, "Segment keys must not be null");
|
||||
this.name = name;
|
||||
this.keys = List.copyOf(keys);
|
||||
}
|
||||
|
||||
/**
|
||||
* The canonical format of this segment alone: its name, followed by each
|
||||
* key wrapped in brackets, quoted only when necessary.
|
||||
*/
|
||||
public String toCanonicalName() {
|
||||
StringBuilder canonicalNameBuilder = new StringBuilder();
|
||||
canonicalNameBuilder.append(this.name);
|
||||
for (String key : this.keys) {
|
||||
appendCanonicalKey(canonicalNameBuilder, key);
|
||||
}
|
||||
return canonicalNameBuilder.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a new segment instance with the same name, but dropping the last key.
|
||||
*/
|
||||
public Segment withoutLastKey() {
|
||||
if (this.keys.isEmpty()) {
|
||||
return this;
|
||||
}
|
||||
return new Segment(this.name, this.keys.subList(0, this.keys.size() - 1));
|
||||
}
|
||||
|
||||
/**
|
||||
* Append the canonical form of the given key, which is the key
|
||||
* unquoted, or the key re-quoted if it contains illegal raw chars,
|
||||
* such as {@code map['a]b']}.
|
||||
*/
|
||||
private void appendCanonicalKey(StringBuilder canonicalNameBuilder, String key) {
|
||||
char quoteChar = canonicalQuoteChar(key);
|
||||
canonicalNameBuilder.append(PropertyAccessor.PROPERTY_KEY_PREFIX_CHAR);
|
||||
if (quoteChar != 0) {
|
||||
canonicalNameBuilder.append(quoteChar);
|
||||
}
|
||||
canonicalNameBuilder.append(key);
|
||||
if (quoteChar != 0) {
|
||||
canonicalNameBuilder.append(quoteChar);
|
||||
}
|
||||
canonicalNameBuilder.append(PropertyAccessor.PROPERTY_KEY_SUFFIX_CHAR);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the quote character to surround the given key with in the
|
||||
* canonical name, or {@code 0} if the key needs no quoting.
|
||||
*/
|
||||
private static char canonicalQuoteChar(String key) {
|
||||
int depth = 0;
|
||||
boolean balanced = true;
|
||||
boolean containsSingleQuote = false;
|
||||
boolean containsDoubleQuote = false;
|
||||
for (int i = 0; i < key.length(); i++) {
|
||||
switch (key.charAt(i)) {
|
||||
case PropertyAccessor.PROPERTY_KEY_PREFIX_CHAR -> depth++;
|
||||
case PropertyAccessor.PROPERTY_KEY_SUFFIX_CHAR -> {
|
||||
if (--depth < 0) {
|
||||
balanced = false;
|
||||
}
|
||||
}
|
||||
case '\'' -> containsSingleQuote = true;
|
||||
case '"' -> containsDoubleQuote = true;
|
||||
default -> {
|
||||
// Ordinary key character.
|
||||
}
|
||||
}
|
||||
}
|
||||
if (balanced && depth == 0 && !containsSingleQuote && !containsDoubleQuote) {
|
||||
return 0;
|
||||
}
|
||||
return (containsSingleQuote && !containsDoubleQuote ? '"' : '\'');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Options to customize the parsing for {@link PropertyPath}.
|
||||
*/
|
||||
public static final class Options {
|
||||
|
||||
/**
|
||||
* Options with no limit on nesting depth.
|
||||
*/
|
||||
public static final Options UNLIMITED = new Options(Integer.MAX_VALUE);
|
||||
|
||||
private final int maxNestedPathDepth;
|
||||
|
||||
private Options(int maxNestedPathDepth) {
|
||||
this.maxNestedPathDepth = maxNestedPathDepth;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an {@link Options} instance that rejects a path whose nesting
|
||||
* depth exceeds the given maximum.
|
||||
* @param maxNestedPathDepth the maximum nesting depth
|
||||
*/
|
||||
public static Options withMaxNestedPathDepth(int maxNestedPathDepth) {
|
||||
Assert.isTrue(maxNestedPathDepth >= 0, "'maxNestedPathDepth' must not be negative");
|
||||
return new Options(maxNestedPathDepth);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Parser for a property path string.
|
||||
* <p>Each stage of the enforced grammar is modeled by a separate {@link State}.
|
||||
*/
|
||||
private static final class Parser {
|
||||
|
||||
private final String path;
|
||||
|
||||
private final List<Segment> segments = new ArrayList<>(2);
|
||||
|
||||
private final StringBuilder canonicalName;
|
||||
|
||||
// Offset of the character currently being processed.
|
||||
private int pos;
|
||||
|
||||
// Offset at which the current segment's name starts.
|
||||
private int segmentStart;
|
||||
|
||||
// Offset at which the current segment's name ends, or -1 if not yet known.
|
||||
private int segmentNameEnd = -1;
|
||||
|
||||
// Keys collected for the current segment
|
||||
private @Nullable List<String> keys;
|
||||
|
||||
// Offset at which the current key starts.
|
||||
private int keyStart;
|
||||
|
||||
// Bracket nesting depth within the current raw key.
|
||||
private int depth;
|
||||
|
||||
// The quote character that opened the current quoted key.
|
||||
private char quoteChar;
|
||||
|
||||
Parser(String path) {
|
||||
this.path = path;
|
||||
this.canonicalName = new StringBuilder(path.length());
|
||||
}
|
||||
|
||||
PropertyPath parse() {
|
||||
State state = State.NAME;
|
||||
for (; this.pos < this.path.length(); this.pos++) {
|
||||
state = state.process(this.path.charAt(this.pos), this);
|
||||
}
|
||||
state.onEof(this);
|
||||
return new PropertyPath(this.canonicalName.toString(), Collections.unmodifiableList(this.segments));
|
||||
}
|
||||
|
||||
private void addKey(String key) {
|
||||
List<String> keys = this.keys;
|
||||
if (keys == null) {
|
||||
keys = new ArrayList<>(2);
|
||||
this.keys = keys;
|
||||
}
|
||||
keys.add(key);
|
||||
}
|
||||
|
||||
private void endSegment() {
|
||||
String name = this.path.substring(this.segmentStart, this.segmentNameEnd);
|
||||
List<String> keys = (this.keys != null ? this.keys : Collections.emptyList());
|
||||
if (name.isEmpty() && keys.isEmpty()) {
|
||||
throw new InvalidPropertyPathException(this.path,
|
||||
"empty path segment (at position " + this.segmentStart + ")");
|
||||
}
|
||||
if (!this.segments.isEmpty()) {
|
||||
this.canonicalName.append(PropertyAccessor.NESTED_PROPERTY_SEPARATOR_CHAR);
|
||||
}
|
||||
Segment segment = new Segment(name, keys);
|
||||
this.segments.add(segment);
|
||||
this.canonicalName.append(segment.toCanonicalName());
|
||||
this.keys = null;
|
||||
this.segmentNameEnd = -1;
|
||||
}
|
||||
|
||||
private InvalidPropertyPathException error(String reason) {
|
||||
return new InvalidPropertyPathException(this.path, reason + " (at position " + this.pos + ")");
|
||||
}
|
||||
|
||||
private InvalidPropertyPathException unclosedIndex() {
|
||||
return error("unclosed '" + PropertyAccessor.PROPERTY_KEY_PREFIX_CHAR + "'");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private enum State {
|
||||
|
||||
// Reading a segment name, before any index of that segment
|
||||
NAME {
|
||||
@Override
|
||||
State process(char ch, Parser parser) {
|
||||
if (ch == PropertyAccessor.NESTED_PROPERTY_SEPARATOR_CHAR) {
|
||||
parser.segmentNameEnd = parser.pos;
|
||||
parser.endSegment();
|
||||
parser.segmentStart = parser.pos + 1;
|
||||
return this;
|
||||
}
|
||||
if (ch == PropertyAccessor.PROPERTY_KEY_PREFIX_CHAR) {
|
||||
parser.segmentNameEnd = parser.pos;
|
||||
return INDEX_OPEN;
|
||||
}
|
||||
if (ch == PropertyAccessor.PROPERTY_KEY_SUFFIX_CHAR) {
|
||||
throw parser.error("unexpected '" + PropertyAccessor.PROPERTY_KEY_SUFFIX_CHAR +
|
||||
"' without a matching '" + PropertyAccessor.PROPERTY_KEY_PREFIX_CHAR + "'");
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
void onEof(Parser parser) {
|
||||
parser.segmentNameEnd = parser.path.length();
|
||||
parser.endSegment();
|
||||
}
|
||||
},
|
||||
|
||||
// Immediately after the "[" that opens an index
|
||||
INDEX_OPEN {
|
||||
@Override
|
||||
State process(char ch, Parser parser) {
|
||||
if (ch == PropertyAccessor.PROPERTY_KEY_SUFFIX_CHAR) {
|
||||
// An empty key, as in "map[]".
|
||||
parser.addKey("");
|
||||
return AFTER_INDEX;
|
||||
}
|
||||
if (ch == '\'' || ch == '"') {
|
||||
parser.quoteChar = ch;
|
||||
parser.keyStart = parser.pos + 1;
|
||||
return QUOTED_KEY;
|
||||
}
|
||||
parser.keyStart = parser.pos;
|
||||
// A key that itself opens a bracket level, as in "map[[a]]"
|
||||
parser.depth = (ch == PropertyAccessor.PROPERTY_KEY_PREFIX_CHAR ? 1 : 0);
|
||||
return RAW_KEY;
|
||||
}
|
||||
|
||||
@Override
|
||||
void onEof(Parser parser) {
|
||||
throw parser.unclosedIndex();
|
||||
}
|
||||
},
|
||||
|
||||
// Inside an unquoted key, tracking the depth of bracket nesting.
|
||||
RAW_KEY {
|
||||
@Override
|
||||
State process(char ch, Parser parser) {
|
||||
if (ch == PropertyAccessor.PROPERTY_KEY_PREFIX_CHAR) {
|
||||
parser.depth++;
|
||||
return this;
|
||||
}
|
||||
if (ch == PropertyAccessor.PROPERTY_KEY_SUFFIX_CHAR) {
|
||||
if (parser.depth == 0) {
|
||||
parser.addKey(parser.path.substring(parser.keyStart, parser.pos));
|
||||
return AFTER_INDEX;
|
||||
}
|
||||
parser.depth--;
|
||||
return this;
|
||||
}
|
||||
if (ch == '\'' || ch == '"') {
|
||||
throw parser.error("unexpected quote '" + ch + "' in an unquoted key; " +
|
||||
"quote the whole key to use quote characters within it");
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
void onEof(Parser parser) {
|
||||
throw parser.unclosedIndex();
|
||||
}
|
||||
},
|
||||
|
||||
// Inside a quoted key, looking for the closing quote.
|
||||
QUOTED_KEY {
|
||||
@Override
|
||||
State process(char ch, Parser parser) {
|
||||
if (ch == parser.quoteChar) {
|
||||
parser.addKey(parser.path.substring(parser.keyStart, parser.pos));
|
||||
return QUOTE_CLOSED;
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
void onEof(Parser parser) {
|
||||
throw parser.error("unterminated quote '" + parser.quoteChar + "'");
|
||||
}
|
||||
},
|
||||
|
||||
// Immediately after a closing quote, only "]" is legal.
|
||||
QUOTE_CLOSED {
|
||||
@Override
|
||||
State process(char ch, Parser parser) {
|
||||
if (ch != PropertyAccessor.PROPERTY_KEY_SUFFIX_CHAR) {
|
||||
throw parser.error("unexpected '" + ch + "' after a closing quote; expected '" +
|
||||
PropertyAccessor.PROPERTY_KEY_SUFFIX_CHAR + "'");
|
||||
}
|
||||
return AFTER_INDEX;
|
||||
}
|
||||
|
||||
@Override
|
||||
void onEof(Parser parser) {
|
||||
throw parser.unclosedIndex();
|
||||
}
|
||||
},
|
||||
|
||||
// Immediately after an index's "]" , only "." or "[" are legal.
|
||||
AFTER_INDEX {
|
||||
@Override
|
||||
State process(char ch, Parser parser) {
|
||||
if (ch == PropertyAccessor.NESTED_PROPERTY_SEPARATOR_CHAR) {
|
||||
parser.endSegment();
|
||||
parser.segmentStart = parser.pos + 1;
|
||||
return NAME;
|
||||
}
|
||||
if (ch == PropertyAccessor.PROPERTY_KEY_PREFIX_CHAR) {
|
||||
return INDEX_OPEN;
|
||||
}
|
||||
throw parser.error("unexpected '" + ch + "' after an index; expected '" +
|
||||
PropertyAccessor.NESTED_PROPERTY_SEPARATOR_CHAR + "', '" +
|
||||
PropertyAccessor.PROPERTY_KEY_PREFIX_CHAR + "', or the end of the path");
|
||||
}
|
||||
|
||||
@Override
|
||||
void onEof(Parser parser) {
|
||||
parser.endSegment();
|
||||
}
|
||||
};
|
||||
|
||||
abstract State process(char ch, Parser parser);
|
||||
|
||||
abstract void onEof(Parser parser);
|
||||
}
|
||||
|
||||
}
|
||||
+410
@@ -0,0 +1,410 @@
|
||||
/*
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
/*
|
||||
* 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.List;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
|
||||
import org.springframework.beans.PropertyPath.Segment;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
|
||||
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
|
||||
|
||||
/**
|
||||
* Tests for {@link PropertyPath}.
|
||||
*/
|
||||
class PropertyPathTests {
|
||||
|
||||
@Test
|
||||
void canonicalNameOfWellFormedPath() {
|
||||
assertThat(canonicalName("name")).isEqualTo("name");
|
||||
assertThat(canonicalName("person.name")).isEqualTo("person.name");
|
||||
assertThat(canonicalName("person.addresses[1].city")).isEqualTo("person.addresses[1].city");
|
||||
assertThat(canonicalName("map[key1]")).isEqualTo("map[key1]");
|
||||
assertThat(canonicalName("map['key1']")).isEqualTo("map[key1]");
|
||||
assertThat(canonicalName("map[\"key1\"]")).isEqualTo("map[key1]");
|
||||
assertThat(canonicalName("map['key1'].name")).isEqualTo("map[key1].name");
|
||||
assertThat(canonicalName("map[key1][key2]")).isEqualTo("map[key1][key2]");
|
||||
assertThat(canonicalName("map['key1'][\"key2\"]")).isEqualTo("map[key1][key2]");
|
||||
assertThat(canonicalName("map[key[0]]")).isEqualTo("map[key[0]]");
|
||||
assertThat(canonicalName("map['key[0]']")).isEqualTo("map[key[0]]");
|
||||
assertThat(canonicalName("map['key[0]'].name")).isEqualTo("map[key[0]].name");
|
||||
assertThat(canonicalName("users['admin[0]']")).isEqualTo("users[admin[0]]");
|
||||
assertThat(canonicalName("map[]")).isEqualTo("map[]");
|
||||
assertThat(canonicalName("map['']")).isEqualTo("map[]");
|
||||
assertThat(canonicalName("map[\"\"]")).isEqualTo("map[]");
|
||||
assertThat(canonicalName("map[my.key]")).isEqualTo("map[my.key]");
|
||||
assertThat(canonicalName("map[[a]]")).isEqualTo("map[[a]]");
|
||||
assertThat(canonicalName("[user]")).isEqualTo("[user]");
|
||||
assertThat(canonicalName("[user].name")).isEqualTo("[user].name");
|
||||
}
|
||||
|
||||
@Test
|
||||
void canonicalNameKeepsQuotesWhenRequired() {
|
||||
assertThat(canonicalName("map['a]b']")).isEqualTo("map['a]b']");
|
||||
assertThat(canonicalName("map['a[b']")).isEqualTo("map['a[b']");
|
||||
assertThat(canonicalName("map[\"a'b\"]")).isEqualTo("map[\"a'b\"]");
|
||||
assertThat(canonicalName("map['a\"b']")).isEqualTo("map['a\"b']");
|
||||
}
|
||||
|
||||
@Test
|
||||
void parseEmptyPath() {
|
||||
PropertyPath path = PropertyPath.parse("");
|
||||
|
||||
assertThat(path.canonicalName()).isEmpty();
|
||||
assertThat(path.segments()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void parseSimplePath() {
|
||||
assertThat(PropertyPath.parse("name").segments())
|
||||
.containsExactly(new Segment("name", List.of()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void parseNestedPath() {
|
||||
assertThat(PropertyPath.parse("person.addresses[1].city").segments())
|
||||
.containsExactly(
|
||||
new Segment("person", List.of()),
|
||||
new Segment("addresses", List.of("1")),
|
||||
new Segment("city", List.of()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void parseMultipleIndexesInOneSegment() {
|
||||
assertThat(PropertyPath.parse("map[key1][key2]").segments())
|
||||
.containsExactly(new Segment("map", List.of("key1", "key2")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void parseRootLevelIndexedAccess() {
|
||||
assertThat(PropertyPath.parse("[user]").segments())
|
||||
.containsExactly(new Segment("", List.of("user")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void parseStripsQuotesFromKeys() {
|
||||
assertThat(PropertyPath.parse("map['key1'][\"key2\"]").segments())
|
||||
.containsExactly(new Segment("map", List.of("key1", "key2")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void parseKeepsDotsInsideKeysOpaque() {
|
||||
assertThat(PropertyPath.parse("map[my.key].name").segments())
|
||||
.containsExactly(
|
||||
new Segment("map", List.of("my.key")),
|
||||
new Segment("name", List.of()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void parseKeepsNestedBracketsInRawKey() {
|
||||
assertThat(PropertyPath.parse("map[key[0]]").segments())
|
||||
.containsExactly(new Segment("map", List.of("key[0]")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void parseKeepsUnbalancedBracketInQuotedKey() {
|
||||
assertThat(PropertyPath.parse("map['a]b']").segments())
|
||||
.containsExactly(new Segment("map", List.of("a]b")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void parseEmptyKey() {
|
||||
assertThat(PropertyPath.parse("map[]").segments())
|
||||
.containsExactly(new Segment("map", List.of("")));
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"", "name", "person.name", "person.addresses[1].city", "map[key1]",
|
||||
"map['key1']", "map[\"key1\"]", "map[key1][key2]", "map[key[0]]", "map['key[0]']",
|
||||
"map[]", "map['']", "map[my.key]", "map[[a]]", "[user]", "[user].name",
|
||||
"map['a]b']", "map['a[b']", "map[\"a'b\"]", "map['a\"b']", "map['']['a]b']"})
|
||||
void canonicalNameIdempotent(String path) {
|
||||
PropertyPath parsed = PropertyPath.parse(path);
|
||||
|
||||
PropertyPath reparsed = PropertyPath.parse(parsed.canonicalName());
|
||||
|
||||
assertThat(reparsed.segments()).isEqualTo(parsed.segments());
|
||||
assertThat(reparsed.canonicalName()).isEqualTo(parsed.canonicalName());
|
||||
assertThat(reparsed).isEqualTo(parsed);
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"name", "person.name", "person.addresses[1].city", "person.map[key1]",
|
||||
"person.map['key1']", "person.map[\"key1\"]", "map[key1].map[key2]", "map['key[0]']",
|
||||
"person.map['a]b']", "person.map['a[b']", "person.map[\"a'b\"]", "person.map['a\"b']", "person.map['']['a]b']"})
|
||||
void canonicalPathAndSegmentsAreEquivalent(String path) {
|
||||
PropertyPath parsed = PropertyPath.parse(path);
|
||||
assertThat(parsed.segments().stream()
|
||||
.map(Segment::toCanonicalName)
|
||||
.reduce((a, b) -> a + "." + b))
|
||||
.hasValue(parsed.canonicalName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void segmentsCannotBeModified() {
|
||||
PropertyPath path = PropertyPath.parse("map[key1]");
|
||||
|
||||
assertThat(path.segments()).hasSize(1);
|
||||
assertThatExceptionOfType(UnsupportedOperationException.class)
|
||||
.isThrownBy(() -> path.segments().clear());
|
||||
assertThatExceptionOfType(UnsupportedOperationException.class)
|
||||
.isThrownBy(() -> path.segments().get(0).keys().clear());
|
||||
}
|
||||
|
||||
@Test
|
||||
void equalsAndHashCodeUseCanonicalName() {
|
||||
assertThat(PropertyPath.parse("map['key1'].name"))
|
||||
.isEqualTo(PropertyPath.parse("map[key1].name"))
|
||||
.hasSameHashCodeAs(PropertyPath.parse("map[key1].name"));
|
||||
assertThat(PropertyPath.parse("map[key1]")).isNotEqualTo(PropertyPath.parse("map[key2]"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void toStringReturnsCanonicalName() {
|
||||
assertThat(PropertyPath.parse("map['key1'].name")).hasToString("map[key1].name");
|
||||
}
|
||||
|
||||
@Test
|
||||
void parseRejectsNull() {
|
||||
assertThatIllegalArgumentException().isThrownBy(() -> PropertyPath.parse(null));
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"map[key1]other", "map[key1]other.name", "map[key1]IGNORED[key2]",
|
||||
"options[priority]X", "options[security]IGNORED[role]", "map[key1]]", "[user]x"})
|
||||
void parseRejectsTextAfterIndex(String path) {
|
||||
assertThatExceptionOfType(InvalidPropertyPathException.class)
|
||||
.isThrownBy(() -> PropertyPath.parse(path))
|
||||
.withMessageContaining("after an index")
|
||||
.satisfies(ex -> assertThat(ex.getPropertyPath()).isEqualTo(path));
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"map[key1", "map[", "map[[a]", "map[a][", "map['a'"})
|
||||
void parseRejectsUnclosedIndex(String path) {
|
||||
assertThatExceptionOfType(InvalidPropertyPathException.class)
|
||||
.isThrownBy(() -> PropertyPath.parse(path))
|
||||
.withMessageContaining("unclosed '['");
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"map]", "map]name", "publication.].published", "address.].city", "]"})
|
||||
void parseRejectsUnmatchedIndexSuffix(String path) {
|
||||
assertThatExceptionOfType(InvalidPropertyPathException.class)
|
||||
.isThrownBy(() -> PropertyPath.parse(path))
|
||||
.withMessageContaining("without a matching '['");
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {".name", "person.", "person..name", ".", "..", "map[key1]."})
|
||||
void parseRejectsEmptySegment(String path) {
|
||||
assertThatExceptionOfType(InvalidPropertyPathException.class)
|
||||
.isThrownBy(() -> PropertyPath.parse(path))
|
||||
.withMessageContaining("empty path segment");
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"map[']", "map[\"]", "map['key1]", "map[\"key1]", "map['a"})
|
||||
void parseRejectsUnterminatedQuote(String path) {
|
||||
assertThatExceptionOfType(InvalidPropertyPathException.class)
|
||||
.isThrownBy(() -> PropertyPath.parse(path))
|
||||
.withMessageContaining("unterminated quote");
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"map[don't]", "map[a'b]", "map[a\"b]"})
|
||||
void parseRejectsQuoteInRawKey(String path) {
|
||||
assertThatExceptionOfType(InvalidPropertyPathException.class)
|
||||
.isThrownBy(() -> PropertyPath.parse(path))
|
||||
.withMessageContaining("in an unquoted key");
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"map['a'b']", "map['a'b]", "map[\"a\"b\"]"})
|
||||
void parseRejectsTextAfterClosingQuote(String path) {
|
||||
assertThatExceptionOfType(InvalidPropertyPathException.class)
|
||||
.isThrownBy(() -> PropertyPath.parse(path))
|
||||
.withMessageContaining("after a closing quote");
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidPropertyPathExceptionIsAPropertyAccessException() {
|
||||
assertThatExceptionOfType(InvalidPropertyPathException.class)
|
||||
.isThrownBy(() -> PropertyPath.parse("map[key1]other"))
|
||||
.isInstanceOf(PropertyAccessException.class)
|
||||
.withMessageContaining("Invalid property path 'map[key1]other'")
|
||||
.satisfies(ex -> {
|
||||
assertThat(ex.getErrorCode()).isEqualTo(InvalidPropertyPathException.ERROR_CODE);
|
||||
assertThat(ex.getPropertyPath()).isEqualTo("map[key1]other");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void failsWhenExceedsNestingDepth() {
|
||||
assertThatExceptionOfType(InvalidPropertyPathException.class)
|
||||
.isThrownBy(() ->
|
||||
PropertyPath.parse("one.two.three[1].four", PropertyPath.Options.withMaxNestedPathDepth(2)))
|
||||
.withMessageContaining("nesting depth exceeds the maximum of 2");
|
||||
}
|
||||
|
||||
@Test
|
||||
void subPathFromZeroReturnsSamePath() {
|
||||
PropertyPath path = PropertyPath.parse("address.country.name");
|
||||
assertThat(path.subPath(0)).isSameAs(path);
|
||||
}
|
||||
|
||||
@Test
|
||||
void subPathDropsLeadingSegments() {
|
||||
PropertyPath subPath = PropertyPath.parse("address.country[0].name").subPath(1);
|
||||
assertThat(subPath.canonicalName()).isEqualTo("country[0].name");
|
||||
assertThat(subPath.segments()).containsExactly(
|
||||
new Segment("country", List.of("0")),
|
||||
new Segment("name", List.of()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void subPathAtLastIndexIsEmpty() {
|
||||
PropertyPath subPath = PropertyPath.parse("address.name").subPath(2);
|
||||
assertThat(subPath.canonicalName()).isEmpty();
|
||||
assertThat(subPath.segments()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void subPathRejectsOutOfBoundsIndex() {
|
||||
PropertyPath path = PropertyPath.parse("address.name");
|
||||
assertThatIllegalArgumentException().isThrownBy(() -> path.subPath(3));
|
||||
}
|
||||
|
||||
|
||||
private static String canonicalName(String path) {
|
||||
return PropertyPath.parse(path).canonicalName();
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user