diff --git a/spring-context/src/main/java/org/springframework/validation/FieldError.java b/spring-context/src/main/java/org/springframework/validation/FieldError.java index a6cd51aa41..774f38cb9f 100644 --- a/spring-context/src/main/java/org/springframework/validation/FieldError.java +++ b/spring-context/src/main/java/org/springframework/validation/FieldError.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2018 the original author or authors. + * Copyright 2002-2023 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. @@ -125,7 +125,7 @@ public class FieldError extends ObjectError { @Override public String toString() { return "Field error in object '" + getObjectName() + "' on field '" + this.field + - "': rejected value [" + ObjectUtils.nullSafeToString(this.rejectedValue) + "]; " + + "': rejected value [" + ObjectUtils.nullSafeConciseToString(this.rejectedValue) + "]; " + resolvableToString(); } diff --git a/spring-context/src/test/java/org/springframework/format/datetime/DateFormattingTests.java b/spring-context/src/test/java/org/springframework/format/datetime/DateFormattingTests.java index 948040c3b4..2ea58f5e06 100644 --- a/spring-context/src/test/java/org/springframework/format/datetime/DateFormattingTests.java +++ b/spring-context/src/test/java/org/springframework/format/datetime/DateFormattingTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2023 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. @@ -133,7 +133,7 @@ public class DateFormattingTests { assertThat(exception) .hasMessageContaining("for property 'styleDate'") .hasCauseInstanceOf(ConversionFailedException.class).cause() - .hasMessageContaining("for value '99/01/01'") + .hasMessageContaining("for value [99/01/01]") .hasCauseInstanceOf(IllegalArgumentException.class).cause() .hasMessageContaining("Parse attempt failed for value [99/01/01]") .hasCauseInstanceOf(ParseException.class).cause() @@ -353,7 +353,7 @@ public class DateFormattingTests { assertThat(fieldError.unwrap(TypeMismatchException.class)) .hasMessageContaining("for property 'patternDateWithFallbackPatterns'") .hasCauseInstanceOf(ConversionFailedException.class).cause() - .hasMessageContaining("for value '210302'") + .hasMessageContaining("for value [210302]") .hasCauseInstanceOf(IllegalArgumentException.class).cause() .hasMessageContaining("Parse attempt failed for value [210302]") .hasCauseInstanceOf(ParseException.class).cause() diff --git a/spring-context/src/test/java/org/springframework/format/datetime/standard/DateTimeFormattingTests.java b/spring-context/src/test/java/org/springframework/format/datetime/standard/DateTimeFormattingTests.java index cefeeeef94..392fdd61c6 100644 --- a/spring-context/src/test/java/org/springframework/format/datetime/standard/DateTimeFormattingTests.java +++ b/spring-context/src/test/java/org/springframework/format/datetime/standard/DateTimeFormattingTests.java @@ -368,7 +368,7 @@ class DateTimeFormattingTests { assertThat(fieldError.unwrap(TypeMismatchException.class)) .hasMessageContaining("for property 'isoLocalDate'") .hasCauseInstanceOf(ConversionFailedException.class).cause() - .hasMessageContaining("for value '2009-31-10'") + .hasMessageContaining("for value [2009-31-10]") .hasCauseInstanceOf(IllegalArgumentException.class).cause() .hasMessageContaining("Parse attempt failed for value [2009-31-10]") .hasCauseInstanceOf(DateTimeParseException.class).cause() @@ -606,7 +606,7 @@ class DateTimeFormattingTests { assertThat(fieldError.unwrap(TypeMismatchException.class)) .hasMessageContaining("for property 'patternLocalDateWithFallbackPatterns'") .hasCauseInstanceOf(ConversionFailedException.class).cause() - .hasMessageContaining("for value '210302'") + .hasMessageContaining("for value [210302]") .hasCauseInstanceOf(IllegalArgumentException.class).cause() .hasMessageContaining("Parse attempt failed for value [210302]") .hasCauseInstanceOf(DateTimeParseException.class).cause() diff --git a/spring-core/src/main/java/org/springframework/core/convert/ConversionFailedException.java b/spring-core/src/main/java/org/springframework/core/convert/ConversionFailedException.java index 8cb7b4ee11..9f47c83175 100644 --- a/spring-core/src/main/java/org/springframework/core/convert/ConversionFailedException.java +++ b/spring-core/src/main/java/org/springframework/core/convert/ConversionFailedException.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2017 the original author or authors. + * Copyright 2002-2023 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. @@ -49,7 +49,7 @@ public class ConversionFailedException extends ConversionException { @Nullable Object value, Throwable cause) { super("Failed to convert from type [" + sourceType + "] to type [" + targetType + - "] for value '" + ObjectUtils.nullSafeToString(value) + "'", cause); + "] for value [" + ObjectUtils.nullSafeConciseToString(value) + "]", cause); this.sourceType = sourceType; this.targetType = targetType; this.value = value; diff --git a/spring-core/src/main/java/org/springframework/util/ObjectUtils.java b/spring-core/src/main/java/org/springframework/util/ObjectUtils.java index 0fdab9bee9..a24f84d485 100644 --- a/spring-core/src/main/java/org/springframework/util/ObjectUtils.java +++ b/spring-core/src/main/java/org/springframework/util/ObjectUtils.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2023 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. @@ -17,8 +17,13 @@ package org.springframework.util; import java.lang.reflect.Array; +import java.net.URI; +import java.net.URL; +import java.time.temporal.Temporal; import java.util.Arrays; import java.util.Collection; +import java.util.Date; +import java.util.Locale; import java.util.Map; import java.util.Optional; import java.util.StringJoiner; @@ -630,6 +635,7 @@ public abstract class ObjectUtils { * Returns a {@code "null"} String if {@code obj} is {@code null}. * @param obj the object to build a String representation for * @return a String representation of {@code obj} + * @see #nullSafeConciseToString(Object) */ public static String nullSafeToString(@Nullable Object obj) { if (obj == null) { @@ -885,4 +891,73 @@ public abstract class ObjectUtils { return stringJoiner.toString(); } + /** + * Generate a null-safe, concise string representation of the supplied object + * as described below. + *

Favor this method over {@link #nullSafeToString(Object)} when you need + * the length of the generated string to be limited. + *

Returns: + *

+ *

In the context of this method, a simple type is any of the following: + * a primitive or primitive wrapper (excluding {@code Void} and {@code void}), + * an enum, a Number, a Date, a Temporal, a URI, a URL, or a Locale. + * @param obj the object to build a string representation for + * @return a concise string representation of the supplied object + * @since 5.3.27 + * @see #nullSafeToString(Object) + * @see StringUtils#truncate(CharSequence) + */ + public static String nullSafeConciseToString(@Nullable Object obj) { + if (obj == null) { + return "null"; + } + if (obj instanceof Class clazz) { + return clazz.getName(); + } + if (obj instanceof CharSequence charSequence) { + return StringUtils.truncate(charSequence); + } + Class type = obj.getClass(); + if (isSimpleValueType(type)) { + String str = obj.toString(); + if (str != null) { + return StringUtils.truncate(str); + } + } + return type.getTypeName() + "@" + getIdentityHexString(obj); + } + + /** + * Copy of {@link org.springframework.beans.BeanUtils#isSimpleValueType(Class)}. + *

Check if the given type represents a "simple" value type: a primitive or + * primitive wrapper, an enum, a String or other CharSequence, a Number, a + * Date, a Temporal, a URI, a URL, a Locale, or a Class. + *

{@code Void} and {@code void} are not considered simple value types. + * @param type the type to check + * @return whether the given type represents a "simple" value type + */ + private static boolean isSimpleValueType(Class type) { + return (Void.class != type && void.class != type && + (ClassUtils.isPrimitiveOrWrapper(type) || + Enum.class.isAssignableFrom(type) || + CharSequence.class.isAssignableFrom(type) || + Number.class.isAssignableFrom(type) || + Date.class.isAssignableFrom(type) || + Temporal.class.isAssignableFrom(type) || + URI.class == type || + URL.class == type || + Locale.class == type || + Class.class == type)); + } + } diff --git a/spring-core/src/test/java/org/springframework/util/ObjectUtilsTests.java b/spring-core/src/test/java/org/springframework/util/ObjectUtilsTests.java index 8d23c0c9c1..64e283b216 100644 --- a/spring-core/src/test/java/org/springframework/util/ObjectUtilsTests.java +++ b/spring-core/src/test/java/org/springframework/util/ObjectUtilsTests.java @@ -17,15 +17,24 @@ package org.springframework.util; import java.io.IOException; +import java.net.URI; +import java.net.URL; import java.sql.SQLException; +import java.time.LocalDate; +import java.util.ArrayList; import java.util.Collections; +import java.util.Date; import java.util.HashMap; import java.util.HashSet; +import java.util.List; +import java.util.Locale; import java.util.Set; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; import static org.springframework.util.ObjectUtils.isEmpty; @@ -791,7 +800,135 @@ class ObjectUtilsTests { .withMessage("Constant [bogus] does not exist in enum type org.springframework.util.ObjectUtilsTests$Tropes"); } - private void assertEqualHashCodes(int expected, Object array) { + @Nested + class NullSafeConciseToStringTests { + + private static final String truncated = " (truncated)..."; + private static final int truncatedLength = 100 + truncated.length(); + + @Test + void nullSafeConciseToStringForNull() { + assertThat(ObjectUtils.nullSafeConciseToString(null)).isEqualTo("null"); + } + + @Test + void nullSafeConciseToStringForClass() { + assertThat(ObjectUtils.nullSafeConciseToString(String.class)).isEqualTo("java.lang.String"); + } + + @Test + void nullSafeConciseToStringForStrings() { + String repeat100 = "X".repeat(100); + String repeat101 = "X".repeat(101); + + assertThat(ObjectUtils.nullSafeConciseToString("foo")).isEqualTo("foo"); + assertThat(ObjectUtils.nullSafeConciseToString(repeat100)).isEqualTo(repeat100); + assertThat(ObjectUtils.nullSafeConciseToString(repeat101)).hasSize(truncatedLength).endsWith(truncated); + } + + @Test + void nullSafeConciseToStringForStringBuilders() { + String repeat100 = "X".repeat(100); + String repeat101 = "X".repeat(101); + + assertThat(ObjectUtils.nullSafeConciseToString(new StringBuilder("foo"))).isEqualTo("foo"); + assertThat(ObjectUtils.nullSafeConciseToString(new StringBuilder(repeat100))).isEqualTo(repeat100); + assertThat(ObjectUtils.nullSafeConciseToString(new StringBuilder(repeat101))).hasSize(truncatedLength).endsWith(truncated); + } + + @Test + void nullSafeConciseToStringForEnum() { + assertThat(ObjectUtils.nullSafeConciseToString(Tropes.FOO)).isEqualTo("FOO"); + } + + @Test + void nullSafeConciseToStringForNumber() { + assertThat(ObjectUtils.nullSafeConciseToString(42L)).isEqualTo("42"); + assertThat(ObjectUtils.nullSafeConciseToString(99.1234D)).isEqualTo("99.1234"); + } + + @Test + void nullSafeConciseToStringForDate() { + Date date = new Date(); + assertThat(ObjectUtils.nullSafeConciseToString(date)).isEqualTo(date.toString()); + } + + @Test + void nullSafeConciseToStringForTemporal() { + LocalDate localDate = LocalDate.now(); + assertThat(ObjectUtils.nullSafeConciseToString(localDate)).isEqualTo(localDate.toString()); + } + + @Test + void nullSafeConciseToStringForUri() { + String uri = "https://www.example.com/?foo=1&bar=2&baz=3"; + assertThat(ObjectUtils.nullSafeConciseToString(URI.create(uri))).isEqualTo(uri); + + uri += "&qux=" + "4".repeat(60); + assertThat(ObjectUtils.nullSafeConciseToString(URI.create(uri))) + .hasSize(truncatedLength) + .startsWith(uri.subSequence(0, 100)) + .endsWith(truncated); + } + + @Test + void nullSafeConciseToStringForUrl() throws Exception { + String url = "https://www.example.com/?foo=1&bar=2&baz=3"; + assertThat(ObjectUtils.nullSafeConciseToString(new URL(url))).isEqualTo(url); + + url += "&qux=" + "4".repeat(60); + assertThat(ObjectUtils.nullSafeConciseToString(new URL(url))) + .hasSize(truncatedLength) + .startsWith(url.subSequence(0, 100)) + .endsWith(truncated); + } + + @Test + void nullSafeConciseToStringForLocale() { + assertThat(ObjectUtils.nullSafeConciseToString(Locale.GERMANY)).isEqualTo("de_DE"); + } + + @Test + void nullSafeConciseToStringForArraysAndCollections() { + List list = List.of("a", "b", "c"); + assertThat(ObjectUtils.nullSafeConciseToString(new int[][] {{1, 2}, {3, 4}})).startsWith(prefix(int[][].class)); + assertThat(ObjectUtils.nullSafeConciseToString(list.toArray())).startsWith(prefix(Object[].class)); + assertThat(ObjectUtils.nullSafeConciseToString(list.toArray(String[]::new))).startsWith(prefix(String[].class)); + assertThat(ObjectUtils.nullSafeConciseToString(new ArrayList<>(list))).startsWith(prefix(ArrayList.class)); + assertThat(ObjectUtils.nullSafeConciseToString(new HashSet<>(list))).startsWith(prefix(HashSet.class)); + } + + @Test + void nullSafeConciseToStringForCustomTypes() { + class ExplosiveType { + @Override + public String toString() { + throw new UnsupportedOperationException("no-go"); + } + } + ExplosiveType explosiveType = new ExplosiveType(); + assertThatExceptionOfType(UnsupportedOperationException.class).isThrownBy(explosiveType::toString); + assertThat(ObjectUtils.nullSafeConciseToString(explosiveType)).startsWith(prefix(ExplosiveType.class)); + + class WordyType { + @Override + public String toString() { + return "blah blah".repeat(20); + } + } + WordyType wordyType = new WordyType(); + assertThat(wordyType).asString().hasSizeGreaterThanOrEqualTo(180 /* 9x20 */); + assertThat(ObjectUtils.nullSafeConciseToString(wordyType)).startsWith(prefix(WordyType.class)); + } + + private static String prefix(Class clazz) { + return clazz.getTypeName() + "@"; + } + + } + + + private static void assertEqualHashCodes(int expected, Object array) { int actual = ObjectUtils.nullSafeHashCode(array); assertThat(actual).isEqualTo(expected); assertThat(array.hashCode()).isNotEqualTo(actual);