Skip to content

Commit 91c58af

Browse files
committed
Introduce ObjectUtils.nullSafeConciseToString()
ObjectUtils.nullSafeToString(Object) exists for generating a string representation of various objects in a "null-safe" manner, including support for object graphs, collections, etc. However, there are times when we would like to generate a "concise", null-safe string representation that does not include an entire object graph (or potentially a collection of object graphs). This commit introduces ObjectUtils.nullSafeConciseToString(Object) to address this need and makes use of the new feature in FieldError and ConversionFailedException. Closes gh-30286
1 parent 0a1aeaf commit 91c58af

File tree

6 files changed

+235
-13
lines changed

6 files changed

+235
-13
lines changed

spring-context/src/main/java/org/springframework/validation/FieldError.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2018 the original author or authors.
2+
* Copyright 2002-2023 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -125,7 +125,7 @@ public int hashCode() {
125125
@Override
126126
public String toString() {
127127
return "Field error in object '" + getObjectName() + "' on field '" + this.field +
128-
"': rejected value [" + ObjectUtils.nullSafeToString(this.rejectedValue) + "]; " +
128+
"': rejected value [" + ObjectUtils.nullSafeConciseToString(this.rejectedValue) + "]; " +
129129
resolvableToString();
130130
}
131131

spring-context/src/test/java/org/springframework/format/datetime/DateFormattingTests.java

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2022 the original author or authors.
2+
* Copyright 2002-2023 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -133,7 +133,7 @@ void styleDateWithInvalidFormat() {
133133
assertThat(exception)
134134
.hasMessageContaining("for property 'styleDate'")
135135
.hasCauseInstanceOf(ConversionFailedException.class).cause()
136-
.hasMessageContaining("for value '99/01/01'")
136+
.hasMessageContaining("for value [99/01/01]")
137137
.hasCauseInstanceOf(IllegalArgumentException.class).cause()
138138
.hasMessageContaining("Parse attempt failed for value [99/01/01]")
139139
.hasCauseInstanceOf(ParseException.class).cause()
@@ -353,7 +353,7 @@ void patternDateWithUnsupportedPattern() {
353353
assertThat(fieldError.unwrap(TypeMismatchException.class))
354354
.hasMessageContaining("for property 'patternDateWithFallbackPatterns'")
355355
.hasCauseInstanceOf(ConversionFailedException.class).cause()
356-
.hasMessageContaining("for value '210302'")
356+
.hasMessageContaining("for value [210302]")
357357
.hasCauseInstanceOf(IllegalArgumentException.class).cause()
358358
.hasMessageContaining("Parse attempt failed for value [210302]")
359359
.hasCauseInstanceOf(ParseException.class).cause()

spring-context/src/test/java/org/springframework/format/datetime/standard/DateTimeFormattingTests.java

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2022 the original author or authors.
2+
* Copyright 2002-2023 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -333,7 +333,7 @@ void isoLocalDateWithInvalidFormat() {
333333
assertThat(fieldError.unwrap(TypeMismatchException.class))
334334
.hasMessageContaining("for property 'isoLocalDate'")
335335
.hasCauseInstanceOf(ConversionFailedException.class).cause()
336-
.hasMessageContaining("for value '2009-31-10'")
336+
.hasMessageContaining("for value [2009-31-10]")
337337
.hasCauseInstanceOf(IllegalArgumentException.class).cause()
338338
.hasMessageContaining("Parse attempt failed for value [2009-31-10]")
339339
.hasCauseInstanceOf(DateTimeParseException.class).cause()
@@ -540,7 +540,7 @@ void patternLocalDateWithUnsupportedPattern() {
540540
assertThat(fieldError.unwrap(TypeMismatchException.class))
541541
.hasMessageContaining("for property 'patternLocalDateWithFallbackPatterns'")
542542
.hasCauseInstanceOf(ConversionFailedException.class).cause()
543-
.hasMessageContaining("for value '210302'")
543+
.hasMessageContaining("for value [210302]")
544544
.hasCauseInstanceOf(IllegalArgumentException.class).cause()
545545
.hasMessageContaining("Parse attempt failed for value [210302]")
546546
.hasCauseInstanceOf(DateTimeParseException.class).cause()

spring-core/src/main/java/org/springframework/core/convert/ConversionFailedException.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2017 the original author or authors.
2+
* Copyright 2002-2023 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -49,7 +49,7 @@ public ConversionFailedException(@Nullable TypeDescriptor sourceType, TypeDescri
4949
@Nullable Object value, Throwable cause) {
5050

5151
super("Failed to convert from type [" + sourceType + "] to type [" + targetType +
52-
"] for value '" + ObjectUtils.nullSafeToString(value) + "'", cause);
52+
"] for value [" + ObjectUtils.nullSafeConciseToString(value) + "]", cause);
5353
this.sourceType = sourceType;
5454
this.targetType = targetType;
5555
this.value = value;

spring-core/src/main/java/org/springframework/util/ObjectUtils.java

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2021 the original author or authors.
2+
* Copyright 2002-2023 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -17,8 +17,13 @@
1717
package org.springframework.util;
1818

1919
import java.lang.reflect.Array;
20+
import java.net.URI;
21+
import java.net.URL;
22+
import java.time.temporal.Temporal;
2023
import java.util.Arrays;
2124
import java.util.Collection;
25+
import java.util.Date;
26+
import java.util.Locale;
2227
import java.util.Map;
2328
import java.util.Optional;
2429
import java.util.StringJoiner;
@@ -653,6 +658,7 @@ public static String nullSafeClassName(@Nullable Object obj) {
653658
* Returns a {@code "null"} String if {@code obj} is {@code null}.
654659
* @param obj the object to build a String representation for
655660
* @return a String representation of {@code obj}
661+
* @see #nullSafeConciseToString(Object)
656662
*/
657663
public static String nullSafeToString(@Nullable Object obj) {
658664
if (obj == null) {
@@ -908,4 +914,73 @@ public static String nullSafeToString(@Nullable short[] array) {
908914
return stringJoiner.toString();
909915
}
910916

917+
/**
918+
* Generate a null-safe, concise string representation of the supplied object
919+
* as described below.
920+
* <p>Favor this method over {@link #nullSafeToString(Object)} when you need
921+
* the length of the generated string to be limited.
922+
* <p>Returns:
923+
* <ul>
924+
* <li>{@code "null"} if {@code obj} is {@code null}</li>
925+
* <li>{@linkplain Class#getName() Class name} if {@code obj} is a {@link Class}</li>
926+
* <li>Potentially {@linkplain StringUtils#truncate(CharSequence) truncated string}
927+
* if {@code obj} is a {@link String} or {@link CharSequence}</li>
928+
* <li>Potentially {@linkplain StringUtils#truncate(CharSequence) truncated string}
929+
* if {@code obj} is a <em>simple type</em> whose {@code toString()} method returns
930+
* a non-null value.</li>
931+
* <li>Otherwise, a string representation of the object's type name concatenated
932+
* with {@code @} and a hex string form of the object's identity hash code</li>
933+
* </ul>
934+
* <p>In the context of this method, a <em>simple type</em> is any of the following:
935+
* a primitive or primitive wrapper (excluding {@code Void} and {@code void}),
936+
* an enum, a Number, a Date, a Temporal, a URI, a URL, or a Locale.
937+
* @param obj the object to build a string representation for
938+
* @return a concise string representation of the supplied object
939+
* @since 5.3.27
940+
* @see #nullSafeToString(Object)
941+
* @see StringUtils#truncate(CharSequence)
942+
*/
943+
public static String nullSafeConciseToString(@Nullable Object obj) {
944+
if (obj == null) {
945+
return "null";
946+
}
947+
if (obj instanceof Class<?>) {
948+
return ((Class<?>) obj).getName();
949+
}
950+
if (obj instanceof CharSequence) {
951+
return StringUtils.truncate((CharSequence) obj);
952+
}
953+
Class<?> type = obj.getClass();
954+
if (isSimpleValueType(type)) {
955+
String str = obj.toString();
956+
if (str != null) {
957+
return StringUtils.truncate(str);
958+
}
959+
}
960+
return type.getTypeName() + "@" + getIdentityHexString(obj);
961+
}
962+
963+
/**
964+
* Copy of {@link org.springframework.beans.BeanUtils#isSimpleValueType(Class)}.
965+
* <p>Check if the given type represents a "simple" value type: a primitive or
966+
* primitive wrapper, an enum, a String or other CharSequence, a Number, a
967+
* Date, a Temporal, a URI, a URL, a Locale, or a Class.
968+
* <p>{@code Void} and {@code void} are not considered simple value types.
969+
* @param type the type to check
970+
* @return whether the given type represents a "simple" value type
971+
*/
972+
private static boolean isSimpleValueType(Class<?> type) {
973+
return (Void.class != type && void.class != type &&
974+
(ClassUtils.isPrimitiveOrWrapper(type) ||
975+
Enum.class.isAssignableFrom(type) ||
976+
CharSequence.class.isAssignableFrom(type) ||
977+
Number.class.isAssignableFrom(type) ||
978+
Date.class.isAssignableFrom(type) ||
979+
Temporal.class.isAssignableFrom(type) ||
980+
URI.class == type ||
981+
URL.class == type ||
982+
Locale.class == type ||
983+
Class.class == type));
984+
}
985+
911986
}

spring-core/src/test/java/org/springframework/util/ObjectUtilsTests.java

Lines changed: 149 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2022 the original author or authors.
2+
* Copyright 2002-2023 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -17,15 +17,25 @@
1717
package org.springframework.util;
1818

1919
import java.io.IOException;
20+
import java.net.URI;
21+
import java.net.URL;
2022
import java.sql.SQLException;
23+
import java.time.LocalDate;
24+
import java.util.ArrayList;
25+
import java.util.Arrays;
2126
import java.util.Collections;
27+
import java.util.Date;
2228
import java.util.HashMap;
2329
import java.util.HashSet;
30+
import java.util.List;
31+
import java.util.Locale;
2432
import java.util.Set;
2533

34+
import org.junit.jupiter.api.Nested;
2635
import org.junit.jupiter.api.Test;
2736

2837
import static org.assertj.core.api.Assertions.assertThat;
38+
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
2939
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
3040
import static org.springframework.util.ObjectUtils.isEmpty;
3141

@@ -816,7 +826,144 @@ void caseInsensitiveValueOf() {
816826
.withMessage("Constant [bogus] does not exist in enum type org.springframework.util.ObjectUtilsTests$Tropes");
817827
}
818828

819-
private void assertEqualHashCodes(int expected, Object array) {
829+
@Nested
830+
class NullSafeConciseToStringTests {
831+
832+
private final String truncated = " (truncated)...";
833+
private final int truncatedLength = 100 + truncated.length();
834+
835+
@Test
836+
void nullSafeConciseToStringForNull() {
837+
assertThat(ObjectUtils.nullSafeConciseToString(null)).isEqualTo("null");
838+
}
839+
840+
@Test
841+
void nullSafeConciseToStringForClass() {
842+
assertThat(ObjectUtils.nullSafeConciseToString(String.class)).isEqualTo("java.lang.String");
843+
}
844+
845+
@Test
846+
void nullSafeConciseToStringForStrings() {
847+
String repeat100 = repeat("X", 100);
848+
String repeat101 = repeat("X", 101);
849+
850+
assertThat(ObjectUtils.nullSafeConciseToString("foo")).isEqualTo("foo");
851+
assertThat(ObjectUtils.nullSafeConciseToString(repeat100)).isEqualTo(repeat100);
852+
assertThat(ObjectUtils.nullSafeConciseToString(repeat101)).hasSize(truncatedLength).endsWith(truncated);
853+
}
854+
855+
@Test
856+
void nullSafeConciseToStringForStringBuilders() {
857+
String repeat100 = repeat("X", 100);
858+
String repeat101 = repeat("X", 101);
859+
860+
assertThat(ObjectUtils.nullSafeConciseToString(new StringBuilder("foo"))).isEqualTo("foo");
861+
assertThat(ObjectUtils.nullSafeConciseToString(new StringBuilder(repeat100))).isEqualTo(repeat100);
862+
assertThat(ObjectUtils.nullSafeConciseToString(new StringBuilder(repeat101))).hasSize(truncatedLength).endsWith(truncated);
863+
}
864+
865+
@Test
866+
void nullSafeConciseToStringForEnum() {
867+
assertThat(ObjectUtils.nullSafeConciseToString(Tropes.FOO)).isEqualTo("FOO");
868+
}
869+
870+
@Test
871+
void nullSafeConciseToStringForNumber() {
872+
assertThat(ObjectUtils.nullSafeConciseToString(42L)).isEqualTo("42");
873+
assertThat(ObjectUtils.nullSafeConciseToString(99.1234D)).isEqualTo("99.1234");
874+
}
875+
876+
@Test
877+
void nullSafeConciseToStringForDate() {
878+
Date date = new Date();
879+
assertThat(ObjectUtils.nullSafeConciseToString(date)).isEqualTo(date.toString());
880+
}
881+
882+
@Test
883+
void nullSafeConciseToStringForTemporal() {
884+
LocalDate localDate = LocalDate.now();
885+
assertThat(ObjectUtils.nullSafeConciseToString(localDate)).isEqualTo(localDate.toString());
886+
}
887+
888+
@Test
889+
void nullSafeConciseToStringForUri() {
890+
String uri = "https://www.example.com/?foo=1&bar=2&baz=3";
891+
assertThat(ObjectUtils.nullSafeConciseToString(URI.create(uri))).isEqualTo(uri);
892+
893+
uri += "&qux=" + repeat("4", 60);
894+
assertThat(ObjectUtils.nullSafeConciseToString(URI.create(uri)))
895+
.hasSize(truncatedLength)
896+
.startsWith(uri.subSequence(0, 100))
897+
.endsWith(truncated);
898+
}
899+
900+
@Test
901+
void nullSafeConciseToStringForUrl() throws Exception {
902+
String url = "https://www.example.com/?foo=1&bar=2&baz=3";
903+
assertThat(ObjectUtils.nullSafeConciseToString(new URL(url))).isEqualTo(url);
904+
905+
url += "&qux=" + repeat("4", 60);
906+
assertThat(ObjectUtils.nullSafeConciseToString(new URL(url)))
907+
.hasSize(truncatedLength)
908+
.startsWith(url.subSequence(0, 100))
909+
.endsWith(truncated);
910+
}
911+
912+
@Test
913+
void nullSafeConciseToStringForLocale() {
914+
assertThat(ObjectUtils.nullSafeConciseToString(Locale.GERMANY)).isEqualTo("de_DE");
915+
}
916+
917+
@Test
918+
void nullSafeConciseToStringForArraysAndCollections() {
919+
List<String> list = Arrays.asList("a", "b", "c");
920+
assertThat(ObjectUtils.nullSafeConciseToString(new int[][] {{1, 2}, {3, 4}})).startsWith(prefix(int[][].class));
921+
assertThat(ObjectUtils.nullSafeConciseToString(list.toArray())).startsWith(prefix(String[].class));
922+
assertThat(ObjectUtils.nullSafeConciseToString(list.toArray(new Object[0]))).startsWith(prefix(Object[].class));
923+
assertThat(ObjectUtils.nullSafeConciseToString(list.toArray(new String[0]))).startsWith(prefix(String[].class));
924+
assertThat(ObjectUtils.nullSafeConciseToString(new ArrayList<>(list))).startsWith(prefix(ArrayList.class));
925+
assertThat(ObjectUtils.nullSafeConciseToString(new HashSet<>(list))).startsWith(prefix(HashSet.class));
926+
}
927+
928+
@Test
929+
void nullSafeConciseToStringForCustomTypes() {
930+
class ExplosiveType {
931+
@Override
932+
public String toString() {
933+
throw new UnsupportedOperationException("no-go");
934+
}
935+
}
936+
ExplosiveType explosiveType = new ExplosiveType();
937+
assertThatExceptionOfType(UnsupportedOperationException.class).isThrownBy(explosiveType::toString);
938+
assertThat(ObjectUtils.nullSafeConciseToString(explosiveType)).startsWith(prefix(ExplosiveType.class));
939+
940+
class WordyType {
941+
@Override
942+
public String toString() {
943+
return repeat("blah blah", 20);
944+
}
945+
}
946+
WordyType wordyType = new WordyType();
947+
assertThat(wordyType).asString().hasSizeGreaterThanOrEqualTo(180 /* 9x20 */);
948+
assertThat(ObjectUtils.nullSafeConciseToString(wordyType)).startsWith(prefix(WordyType.class));
949+
}
950+
951+
private String repeat(String str, int count) {
952+
String result = "";
953+
for (int i = 0; i < count; i++) {
954+
result += str;
955+
}
956+
return result;
957+
}
958+
959+
private String prefix(Class<?> clazz) {
960+
return clazz.getTypeName() + "@";
961+
}
962+
963+
}
964+
965+
966+
private static void assertEqualHashCodes(int expected, Object array) {
820967
int actual = ObjectUtils.nullSafeHashCode(array);
821968
assertThat(actual).isEqualTo(expected);
822969
assertThat(array.hashCode() != actual).isTrue();

0 commit comments

Comments
 (0)