diff --git a/spring-context/src/main/java/org/springframework/validation/beanvalidation/SpringValidatorAdapter.java b/spring-context/src/main/java/org/springframework/validation/beanvalidation/SpringValidatorAdapter.java
index 78348e3c1942..94bf95ea275e 100644
--- a/spring-context/src/main/java/org/springframework/validation/beanvalidation/SpringValidatorAdapter.java
+++ b/spring-context/src/main/java/org/springframework/validation/beanvalidation/SpringValidatorAdapter.java
@@ -351,11 +351,77 @@ protected MessageSourceResolvable getResolvableField(String objectName, String f
* @see #getArgumentsForConstraint
*/
protected boolean requiresMessageFormat(ConstraintViolation> violation) {
- return containsSpringStylePlaceholder(violation.getMessage());
+ return containsSpringStylePlaceholder(
+ violation.getMessage(), violation.getConstraintDescriptor().getAttributes());
}
- private static boolean containsSpringStylePlaceholder(@Nullable String message) {
- return (message != null && message.contains("{0}"));
+ /**
+ * Determine the default message to expose for the given constraint violation.
+ *
The default implementation escapes interpolated constraint attribute values
+ * before applying {@link java.text.MessageFormat}, since such values may contain
+ * curly braces from regular expressions or similar syntax.
+ * @param violation the Bean Validation constraint violation, including
+ * BV-defined interpolation of named attribute references in its message
+ * @return the default message for the corresponding Spring error
+ * @since 7.1
+ * @see #requiresMessageFormat
+ */
+ protected String determineDefaultMessage(ConstraintViolation> violation) {
+ String message = violation.getMessage();
+ return (requiresMessageFormat(violation) ?
+ escapeConstraintAttributeValues(message, violation.getConstraintDescriptor().getAttributes()) :
+ message);
+ }
+
+ private static boolean containsSpringStylePlaceholder(@Nullable String message, Map attributes) {
+ if (message == null) {
+ return false;
+ }
+ int index = message.indexOf("{0}");
+ while (index != -1) {
+ if (!isWithinConstraintAttributeValue(message, index, attributes)) {
+ return true;
+ }
+ index = message.indexOf("{0}", index + 3);
+ }
+ return false;
+ }
+
+ private static boolean isWithinConstraintAttributeValue(
+ String message, int index, Map attributes) {
+
+ for (Map.Entry attribute : attributes.entrySet()) {
+ if (INTERNAL_ANNOTATION_ATTRIBUTES.contains(attribute.getKey())) {
+ continue;
+ }
+ if (attribute.getValue() instanceof String value && value.contains("{0}")) {
+ int valueIndex = message.indexOf(value);
+ while (valueIndex != -1) {
+ if (valueIndex <= index && index < valueIndex + value.length()) {
+ return true;
+ }
+ valueIndex = message.indexOf(value, valueIndex + value.length());
+ }
+ }
+ }
+ return false;
+ }
+
+ private static String escapeConstraintAttributeValues(String message, Map attributes) {
+ String result = message;
+ for (Map.Entry attribute : attributes.entrySet()) {
+ if (INTERNAL_ANNOTATION_ATTRIBUTES.contains(attribute.getKey())) {
+ continue;
+ }
+ if (attribute.getValue() instanceof String value && !value.isEmpty()) {
+ result = result.replace(value, escapeMessageFormatPattern(value));
+ }
+ }
+ return result;
+ }
+
+ private static String escapeMessageFormatPattern(String value) {
+ return value.replace("'", "''").replace("{", "'{'").replace("}", "'}'");
}
@@ -453,24 +519,19 @@ public String toString() {
@SuppressWarnings("serial")
private static class ValidationObjectError extends ObjectError implements Serializable {
- private @Nullable transient SpringValidatorAdapter adapter;
-
- private @Nullable transient ConstraintViolation> violation;
+ private final boolean shouldRenderDefaultMessage;
public ValidationObjectError(String objectName, String[] codes, Object[] arguments,
ConstraintViolation> violation, SpringValidatorAdapter adapter) {
- super(objectName, codes, arguments, violation.getMessage());
- this.adapter = adapter;
- this.violation = violation;
+ super(objectName, codes, arguments, adapter.determineDefaultMessage(violation));
+ this.shouldRenderDefaultMessage = adapter.requiresMessageFormat(violation);
wrap(violation);
}
@Override
public boolean shouldRenderDefaultMessage() {
- return (this.adapter != null && this.violation != null ?
- this.adapter.requiresMessageFormat(this.violation) :
- containsSpringStylePlaceholder(getDefaultMessage()));
+ return this.shouldRenderDefaultMessage;
}
}
@@ -481,24 +542,19 @@ public boolean shouldRenderDefaultMessage() {
@SuppressWarnings("serial")
private static class ValidationFieldError extends FieldError implements Serializable {
- private @Nullable transient SpringValidatorAdapter adapter;
-
- private @Nullable transient ConstraintViolation> violation;
+ private final boolean shouldRenderDefaultMessage;
public ValidationFieldError(String objectName, String field, @Nullable Object rejectedValue, String[] codes,
Object[] arguments, ConstraintViolation> violation, SpringValidatorAdapter adapter) {
- super(objectName, field, rejectedValue, false, codes, arguments, violation.getMessage());
- this.adapter = adapter;
- this.violation = violation;
+ super(objectName, field, rejectedValue, false, codes, arguments, adapter.determineDefaultMessage(violation));
+ this.shouldRenderDefaultMessage = adapter.requiresMessageFormat(violation);
wrap(violation);
}
@Override
public boolean shouldRenderDefaultMessage() {
- return (this.adapter != null && this.violation != null ?
- this.adapter.requiresMessageFormat(this.violation) :
- containsSpringStylePlaceholder(getDefaultMessage()));
+ return this.shouldRenderDefaultMessage;
}
}
diff --git a/spring-context/src/test/java/org/springframework/validation/beanvalidation/SpringValidatorAdapterTests.java b/spring-context/src/test/java/org/springframework/validation/beanvalidation/SpringValidatorAdapterTests.java
index 69673345a29d..74b9fc7fd327 100644
--- a/spring-context/src/test/java/org/springframework/validation/beanvalidation/SpringValidatorAdapterTests.java
+++ b/spring-context/src/test/java/org/springframework/validation/beanvalidation/SpringValidatorAdapterTests.java
@@ -191,6 +191,24 @@ void patternMessage() {
assertThat(error.unwrap(ConstraintViolation.class).getPropertyPath().toString()).isEqualTo("email");
}
+ @Test // gh-21750
+ void patternMessageWithRangeQuantifier() throws Exception {
+ BeanWithPatternContainingRangeQuantifier bean = new BeanWithPatternContainingRangeQuantifier();
+ bean.setCode("1234");
+
+ BeanPropertyBindingResult errors = new BeanPropertyBindingResult(bean, "bean");
+ validatorAdapter.validate(bean, errors);
+
+ assertThat(errors.getFieldErrorCount("code")).isEqualTo(1);
+ FieldError error = errors.getFieldError("code");
+ assertThat(error).isNotNull();
+ assertThat(messageSource.getMessage(error, Locale.ENGLISH)).isEqualTo("code must match \"\\d{1,3}\".");
+ assertThat(messageSource.getMessage(SerializationTestUtils.serializeAndDeserialize(error), Locale.ENGLISH))
+ .isEqualTo("code must match \"\\d{1,3}\".");
+ assertThat(error.contains(ConstraintViolation.class)).isTrue();
+ assertThat(error.unwrap(ConstraintViolation.class).getPropertyPath().toString()).isEqualTo("code");
+ }
+
@Test // SPR-16177
void withList() {
Parent parent = new Parent();
@@ -525,6 +543,21 @@ public boolean isValid(Object value, ConstraintValidatorContext context) {
}
+ static class BeanWithPatternContainingRangeQuantifier {
+
+ @Pattern(regexp = "\\d{1,3}", message = "{0} must match \"{regexp}\".")
+ private String code;
+
+ public String getCode() {
+ return code;
+ }
+
+ public void setCode(String code) {
+ this.code = code;
+ }
+ }
+
+
public class BeanWithListElementConstraint {
@Valid