From 7900315f23a68f9a7e31f91d185577d85fb3e99f Mon Sep 17 00:00:00 2001 From: Sam Brannen <104798+sbrannen@users.noreply.github.com> Date: Wed, 9 Jul 2025 12:31:44 +0200 Subject: [PATCH 1/2] Introduce Date-to-Instant and Instant-to-Date converters In order to avoid unnecessary use of reflection and to simplify native image deployments, this commit introduces explicit support for automatic conversions from java.util.Date to java.time.Instant and vice versa. To achieve that, this commit introduces an InstantToDateConverter and a DateToInstantConverter and registers them automatically in DefaultConversionService. See gh-35156 Closes gh-35175 --- .../support/DateToInstantConverter.java | 45 ++++++++++ .../support/DefaultConversionService.java | 2 + .../support/InstantToDateConverter.java | 49 +++++++++++ .../DefaultConversionServiceTests.java | 86 +++++++++++++++++++ 4 files changed, 182 insertions(+) create mode 100644 spring-core/src/main/java/org/springframework/core/convert/support/DateToInstantConverter.java create mode 100644 spring-core/src/main/java/org/springframework/core/convert/support/InstantToDateConverter.java diff --git a/spring-core/src/main/java/org/springframework/core/convert/support/DateToInstantConverter.java b/spring-core/src/main/java/org/springframework/core/convert/support/DateToInstantConverter.java new file mode 100644 index 00000000000..74d1b9c70fd --- /dev/null +++ b/spring-core/src/main/java/org/springframework/core/convert/support/DateToInstantConverter.java @@ -0,0 +1,45 @@ +/* + * 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.core.convert.support; + +import java.time.Instant; +import java.util.Date; + +import org.springframework.core.convert.converter.Converter; + +/** + * Convert a {@link java.util.Date} to a {@link java.time.Instant}. + * + *

This includes conversion support for {@link java.sql.Timestamp} and other + * subtypes of {@code java.util.Date}. Note, however, that an attempt to convert + * a {@link java.sql.Date} or {@link java.sql.Time} to a {@code java.time.Instant} + * results in an {@link UnsupportedOperationException} since those types do not + * have time or date components, respectively. + * + * @author Sam Brannen + * @since 6.2.9 + * @see Date#toInstant() + * @see InstantToDateConverter + */ +final class DateToInstantConverter implements Converter { + + @Override + public Instant convert(Date date) { + return date.toInstant(); + } + +} diff --git a/spring-core/src/main/java/org/springframework/core/convert/support/DefaultConversionService.java b/spring-core/src/main/java/org/springframework/core/convert/support/DefaultConversionService.java index b597b912466..7ba1285348d 100644 --- a/spring-core/src/main/java/org/springframework/core/convert/support/DefaultConversionService.java +++ b/spring-core/src/main/java/org/springframework/core/convert/support/DefaultConversionService.java @@ -91,6 +91,8 @@ public class DefaultConversionService extends GenericConversionService { addCollectionConverters(converterRegistry); converterRegistry.addConverter(new ByteBufferConverter((ConversionService) converterRegistry)); + converterRegistry.addConverter(new DateToInstantConverter()); + converterRegistry.addConverter(new InstantToDateConverter()); converterRegistry.addConverter(new StringToTimeZoneConverter()); converterRegistry.addConverter(new ZoneIdToTimeZoneConverter()); converterRegistry.addConverter(new ZonedDateTimeToCalendarConverter()); diff --git a/spring-core/src/main/java/org/springframework/core/convert/support/InstantToDateConverter.java b/spring-core/src/main/java/org/springframework/core/convert/support/InstantToDateConverter.java new file mode 100644 index 00000000000..56185c16790 --- /dev/null +++ b/spring-core/src/main/java/org/springframework/core/convert/support/InstantToDateConverter.java @@ -0,0 +1,49 @@ +/* + * 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.core.convert.support; + +import java.time.Instant; +import java.util.Date; + +import org.springframework.core.convert.TypeDescriptor; +import org.springframework.core.convert.converter.ConditionalConverter; +import org.springframework.core.convert.converter.Converter; + +/** + * Convert a {@link java.time.Instant} to a {@link java.util.Date}. + * + *

This does not include conversion support for target types which are subtypes + * of {@code java.util.Date}. + * + * @author Sam Brannen + * @since 6.2.9 + * @see Date#from(Instant) + * @see DateToInstantConverter + */ +final class InstantToDateConverter implements ConditionalConverter, Converter { + + @Override + public boolean matches(TypeDescriptor sourceType, TypeDescriptor targetType) { + return targetType.getType().equals(Date.class); + } + + @Override + public Date convert(Instant instant) { + return Date.from(instant); + } + +} diff --git a/spring-core/src/test/java/org/springframework/core/convert/converter/DefaultConversionServiceTests.java b/spring-core/src/test/java/org/springframework/core/convert/converter/DefaultConversionServiceTests.java index 903b1857ede..88113d9f8a6 100644 --- a/spring-core/src/test/java/org/springframework/core/convert/converter/DefaultConversionServiceTests.java +++ b/spring-core/src/test/java/org/springframework/core/convert/converter/DefaultConversionServiceTests.java @@ -22,12 +22,16 @@ import java.math.BigDecimal; import java.math.BigInteger; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; +import java.sql.Time; +import java.sql.Timestamp; +import java.time.Instant; import java.time.ZoneId; import java.util.AbstractList; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.Currency; +import java.util.Date; import java.util.EnumSet; import java.util.HashMap; import java.util.LinkedHashMap; @@ -973,6 +977,88 @@ class DefaultConversionServiceTests { assertThat((Object) conversionService.convert(Optional.empty(), Optional.class)).isSameAs(Optional.empty()); } + @Test // gh-35175 + void convertDateToInstant() { + TypeDescriptor dateDescriptor = TypeDescriptor.valueOf(Date.class); + TypeDescriptor instantDescriptor = TypeDescriptor.valueOf(Instant.class); + Date date = new Date(); + + // Conversion performed by DateToInstantConverter. + assertThat(conversionService.convert(date, dateDescriptor, instantDescriptor)) + .isEqualTo(date.toInstant()); + } + + @Test // gh-35175 + void convertSqlDateToInstant() { + TypeDescriptor sqlDateDescriptor = TypeDescriptor.valueOf(java.sql.Date.class); + TypeDescriptor instantDescriptor = TypeDescriptor.valueOf(Instant.class); + java.sql.Date sqlDate = new java.sql.Date(System.currentTimeMillis()); + + // DateToInstantConverter blindly invokes toInstant() on any java.util.Date + // subtype, which results in an UnsupportedOperationException since + // java.sql.Date does not have a time component. However, even if + // DateToInstantConverter were not registered, ObjectToObjectConverter + // would still attempt to invoke toInstant() on a java.sql.Date by convention, + // which results in the same UnsupportedOperationException. + assertThatExceptionOfType(ConversionFailedException.class) + .isThrownBy(() -> conversionService.convert(sqlDate, sqlDateDescriptor, instantDescriptor)) + .withCauseExactlyInstanceOf(UnsupportedOperationException.class); + } + + @Test // gh-35175 + void convertSqlTimeToInstant() { + TypeDescriptor timeDescriptor = TypeDescriptor.valueOf(Time.class); + TypeDescriptor instantDescriptor = TypeDescriptor.valueOf(Instant.class); + Time time = new Time(System.currentTimeMillis()); + + // DateToInstantConverter blindly invokes toInstant() on any java.util.Date + // subtype, which results in an UnsupportedOperationException since + // java.sql.Date does not have a time component. However, even if + // DateToInstantConverter were not registered, ObjectToObjectConverter + // would still attempt to invoke toInstant() on a java.sql.Date by convention, + // which results in the same UnsupportedOperationException. + assertThatExceptionOfType(ConversionFailedException.class) + .isThrownBy(() -> conversionService.convert(time, timeDescriptor, instantDescriptor)) + .withCauseExactlyInstanceOf(UnsupportedOperationException.class); + } + + @Test // gh-35175 + void convertSqlTimestampToInstant() { + TypeDescriptor timestampDescriptor = TypeDescriptor.valueOf(Timestamp.class); + TypeDescriptor instantDescriptor = TypeDescriptor.valueOf(Instant.class); + Timestamp timestamp = new Timestamp(System.currentTimeMillis()); + + // Conversion performed by DateToInstantConverter. + assertThat(conversionService.convert(timestamp, timestampDescriptor, instantDescriptor)) + .isEqualTo(timestamp.toInstant()); + } + + @Test // gh-35175 + void convertInstantToDate() { + TypeDescriptor instantDescriptor = TypeDescriptor.valueOf(Instant.class); + TypeDescriptor dateDescriptor = TypeDescriptor.valueOf(Date.class); + Date date = new Date(); + Instant instant = date.toInstant(); + + // Conversion performed by InstantToDateConverter. + assertThat(conversionService.convert(instant, instantDescriptor, dateDescriptor)) + .isExactlyInstanceOf(Date.class) + .isEqualTo(date); + } + + @Test + void convertInstantToSqlTimestamp() { + TypeDescriptor instantDescriptor = TypeDescriptor.valueOf(Instant.class); + TypeDescriptor timestampDescriptor = TypeDescriptor.valueOf(Timestamp.class); + Timestamp timestamp = new Timestamp(System.currentTimeMillis()); + Instant instant = timestamp.toInstant(); + + // Conversion performed by ObjectToObjectConverter. + assertThat(conversionService.convert(instant, instantDescriptor, timestampDescriptor)) + .isExactlyInstanceOf(Timestamp.class) + .isEqualTo(timestamp); + } + // test fields and helpers From 3dc22379a02849551ee00d776a050591a8d380aa Mon Sep 17 00:00:00 2001 From: Sam Brannen <104798+sbrannen@users.noreply.github.com> Date: Wed, 9 Jul 2025 12:35:10 +0200 Subject: [PATCH 2/2] Register runtime hints for Instant-to-Timestamp conversion If an application depends on automatic type conversion from java.time.Instant to java.sql.Timestamp, the ObjectToObjectConverter performs the conversion based on convention, by using reflection to invoke Timestamp.from(Instant). However, when running in a native image a user needs to explicitly register runtime hints for that particular use of reflection. To assist users who are running their applications in a native image, this commit automatically registers the necessary runtime hints for Timestamp.from(Instant) so that users do not have to. See gh-35175 Closes gh-35156 --- .../ObjectToObjectConverterRuntimeHints.java | 9 ++++++++ ...ectToObjectConverterRuntimeHintsTests.java | 23 ++++++++++++------- 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/spring-core/src/main/java/org/springframework/aot/hint/support/ObjectToObjectConverterRuntimeHints.java b/spring-core/src/main/java/org/springframework/aot/hint/support/ObjectToObjectConverterRuntimeHints.java index e0996284cb7..536582464be 100644 --- a/spring-core/src/main/java/org/springframework/aot/hint/support/ObjectToObjectConverterRuntimeHints.java +++ b/spring-core/src/main/java/org/springframework/aot/hint/support/ObjectToObjectConverterRuntimeHints.java @@ -16,6 +16,7 @@ package org.springframework.aot.hint.support; +import java.time.Instant; import java.time.LocalDate; import java.util.Collections; import java.util.List; @@ -33,6 +34,7 @@ import org.springframework.lang.Nullable; * {@code org.springframework.core.convert.support.ObjectToObjectConverter}. * * @author Sebastien Deleuze + * @author Sam Brannen * @since 6.0 */ class ObjectToObjectConverterRuntimeHints implements RuntimeHintsRegistrar { @@ -40,6 +42,7 @@ class ObjectToObjectConverterRuntimeHints implements RuntimeHintsRegistrar { @Override public void registerHints(RuntimeHints hints, @Nullable ClassLoader classLoader) { ReflectionHints reflectionHints = hints.reflection(); + TypeReference sqlDateTypeReference = TypeReference.of("java.sql.Date"); reflectionHints.registerTypeIfPresent(classLoader, sqlDateTypeReference.getName(), hint -> hint .withMethod("toLocalDate", Collections.emptyList(), ExecutableMode.INVOKE) @@ -47,8 +50,14 @@ class ObjectToObjectConverterRuntimeHints implements RuntimeHintsRegistrar { .withMethod("valueOf", List.of(TypeReference.of(LocalDate.class)), ExecutableMode.INVOKE) .onReachableType(sqlDateTypeReference)); + TypeReference sqlTimestampTypeReference = TypeReference.of("java.sql.Timestamp"); + reflectionHints.registerTypeIfPresent(classLoader, sqlTimestampTypeReference.getName(), hint -> hint + .withMethod("from", List.of(TypeReference.of(Instant.class)), ExecutableMode.INVOKE) + .onReachableType(sqlTimestampTypeReference)); + reflectionHints.registerTypeIfPresent(classLoader, "org.springframework.http.HttpMethod", builder -> builder.withMethod("valueOf", List.of(TypeReference.of(String.class)), ExecutableMode.INVOKE)); + reflectionHints.registerTypeIfPresent(classLoader, "java.net.URI", MemberCategory.INVOKE_DECLARED_CONSTRUCTORS); } diff --git a/spring-core/src/test/java/org/springframework/aot/hint/support/ObjectToObjectConverterRuntimeHintsTests.java b/spring-core/src/test/java/org/springframework/aot/hint/support/ObjectToObjectConverterRuntimeHintsTests.java index 8c480a8a717..a537f3ecbf3 100644 --- a/spring-core/src/test/java/org/springframework/aot/hint/support/ObjectToObjectConverterRuntimeHintsTests.java +++ b/spring-core/src/test/java/org/springframework/aot/hint/support/ObjectToObjectConverterRuntimeHintsTests.java @@ -17,6 +17,7 @@ package org.springframework.aot.hint.support; import java.net.URI; +import java.time.Instant; import java.time.LocalDate; import org.junit.jupiter.api.BeforeEach; @@ -24,38 +25,44 @@ import org.junit.jupiter.api.Test; import org.springframework.aot.hint.RuntimeHints; import org.springframework.aot.hint.RuntimeHintsRegistrar; -import org.springframework.aot.hint.predicate.RuntimeHintsPredicates; import org.springframework.core.io.support.SpringFactoriesLoader; import org.springframework.util.ClassUtils; import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.aot.hint.predicate.RuntimeHintsPredicates.reflection; /** * Tests for {@link ObjectToObjectConverterRuntimeHints}. * * @author Sebastien Deleuze + * @author Sam Brannen */ class ObjectToObjectConverterRuntimeHintsTests { - private RuntimeHints hints; + private final RuntimeHints hints = new RuntimeHints(); + @BeforeEach void setup() { - this.hints = new RuntimeHints(); SpringFactoriesLoader.forResourceLocation("META-INF/spring/aot.factories") - .load(RuntimeHintsRegistrar.class).forEach(registrar -> registrar - .registerHints(this.hints, ClassUtils.getDefaultClassLoader())); + .load(RuntimeHintsRegistrar.class) + .forEach(registrar -> registrar.registerHints(this.hints, ClassUtils.getDefaultClassLoader())); } @Test void javaSqlDateHasHints() throws NoSuchMethodException { - assertThat(RuntimeHintsPredicates.reflection().onMethod(java.sql.Date.class, "toLocalDate")).accepts(this.hints); - assertThat(RuntimeHintsPredicates.reflection().onMethod(java.sql.Date.class.getMethod("valueOf", LocalDate.class))).accepts(this.hints); + assertThat(reflection().onMethod(java.sql.Date.class, "toLocalDate")).accepts(this.hints); + assertThat(reflection().onMethod(java.sql.Date.class.getMethod("valueOf", LocalDate.class))).accepts(this.hints); + } + + @Test // gh-35156 + void javaSqlTimestampHasHints() throws NoSuchMethodException { + assertThat(reflection().onMethod(java.sql.Timestamp.class.getMethod("from", Instant.class))).accepts(this.hints); } @Test void uriHasHints() throws NoSuchMethodException { - assertThat(RuntimeHintsPredicates.reflection().onConstructor(URI.class.getConstructor(String.class))).accepts(this.hints); + assertThat(reflection().onConstructor(URI.class.getConstructor(String.class))).accepts(this.hints); } }