Skip to content

Add Date to LocalDateTime conversion methods to bridge legacy and modern date APIs #1385

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 12 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -452,8 +452,9 @@
</build>
</profile>

<!-- Profile inherited from `commmons-parent` -->
<profile>
<id>java9+</id>
<id>java-9-up</id>
<activation>
<jdk>[9,)</jdk>
</activation>
Expand All @@ -462,6 +463,32 @@
<!-- LANG-1667: allow tests to access private fields/methods of java.base/java.util such as ArrayList via reflection -->
<argLine>-Xmx512m --add-opens java.base/java.lang.reflect=ALL-UNNAMED --add-opens java.base/java.lang=ALL-UNNAMED --add-opens java.base/java.util=ALL-UNNAMED --add-opens java.base/java.time=ALL-UNNAMED --add-opens java.base/java.time.chrono=ALL-UNNAMED</argLine>
</properties>
<build>
<plugins>
<!--
~ Modifies the inherited Moditect configuration to add `java.sql` as `static` (optional) dependency.
-->
<plugin>
<groupId>org.moditect</groupId>
<artifactId>moditect-maven-plugin</artifactId>
<executions>
<execution>
<id>add-module-infos</id>
<configuration>
<module>
<moduleInfo>
<requires>
static java.sql;
*;
</requires>
</moduleInfo>
</module>
</configuration>
</execution>
</executions>
</plugin>
Comment on lines +468 to +489
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I attempted to update the inherited Moditect Maven Plugin configuration to include the following directive in the generated module descriptor:

requires static java.sql;

However, due to moditect/moditect#262, this change currently has no effect.

@garydgregory, should we keep the directive in place in anticipation of the issue being fixed, or remove it for now and reintroduce it once the plugin supports it correctly?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ppkarwasz
Well, no, since we shouldn't depend on java[x].sql in the first place.

All,
Maybe this kind of this-to-that conversion code belongs in Commons BeanUtils.

In Lang, DateUtils has only one kind of conversion ATM with 'toCalendar'(...). I'm not sure we want to open the door to more convertions, then there would be endless combinations due to the scale of the Java Time package.

</plugins>
</build>
</profile>
<profile>
<id>java15</id>
Expand Down
81 changes: 81 additions & 0 deletions src/main/java/org/apache/commons/lang3/time/DateUtils.java
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,12 @@
*/
package org.apache.commons.lang3.time;

import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.text.ParseException;
import java.text.ParsePosition;
import java.time.Instant;
import java.time.LocalDateTime;
import java.util.Calendar;
import java.util.Date;
import java.util.Iterator;
Expand Down Expand Up @@ -202,6 +206,28 @@ private enum ModifyType {
* A month range, the week starting on Monday.
*/
public static final int RANGE_MONTH_MONDAY = 6;
/**
* The {@link Class} for {@link java.sql.Timestamp}.
*/
private static final Class<?> timestampClass;
/**
* The {@link Method} for {@link java.sql.Timestamp#getNanos()}.
*/
private static final Method timestampGetNanosMethod;

static {
Class<?> clazz;
Method method;
try {
clazz = Class.forName("java.sql.Timestamp");
method = clazz.getMethod("getNanos");
} catch (ClassNotFoundException | NoSuchMethodException ex) {
clazz = null;
method = null;
}
timestampClass = clazz;
timestampGetNanosMethod = method;
}

/**
* Adds to a date returning a new object.
Expand Down Expand Up @@ -1625,6 +1651,61 @@ public static Calendar toCalendar(final Date date, final TimeZone tz) {
return c;
}

/**
* Converts a {@link Date} into a {@link LocalDateTime}, using the default time zone.
* @param date the date to convert to a LocalDateTime
* @return the created LocalDateTime
* @throws NullPointerException if {@code date} is null
* @since 3.18
*/
public static LocalDateTime toLocalDateTime(final Date date) {
return toLocalDateTime(date, TimeZone.getDefault());
}

/**
* Converts a {@link Date} into a {@link LocalDateTime}
* @param date the date to convert to a LocalDateTime
* @param tz the time zone of the {@code date}
* @return the created LocalDateTime
* @throws NullPointerException if {@code date} is null
* @since 3.18
*/
public static LocalDateTime toLocalDateTime(final Date date, final TimeZone tz) {
Objects.requireNonNull(date, "date");
Objects.requireNonNull(tz, "tz");
final LocalDateTime localDateTime = LocalDateTime.ofInstant(Instant.ofEpochMilli(date.getTime()), tz.toZoneId());
if (isTimestamp(date)) {
return localDateTime.withNano(extractNanosFromSqlTimestamp(date));
}
return localDateTime;
}

/**
* Get the nanosecond part in the {@link java.sql.Timestamp} object. without requiring the java.sql module.
*
* @param date The date is a {@link java.sql.Timestamp} object.
* If it is not a {@link java.sql.Timestamp} object,
* it will return 0.
* @return The nanosecond part of the {@link java.sql.Timestamp} object.
*/
private static int extractNanosFromSqlTimestamp(Date date) {
if (timestampClass == null) {
return 0;
}
try {
return (int) timestampGetNanosMethod.invoke(date);
} catch (IllegalAccessException | InvocationTargetException e) {
return 0;
}
}

/**
* Check to see if obj is an instance of {@link java.sql.Timestamp} without requiring the java.sql module.
*/
private static boolean isTimestamp(final Date date) {
return timestampClass != null && timestampClass.isAssignableFrom(date.getClass());
}

/**
* Truncates a date, leaving the field specified as the most
* significant field.
Expand Down
107 changes: 107 additions & 0 deletions src/test/java/org/apache/commons/lang3/time/DateUtilsTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -28,19 +28,25 @@
import java.text.DateFormat;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.Calendar;
import java.util.Date;
import java.util.GregorianCalendar;
import java.util.Iterator;
import java.util.Locale;
import java.util.NoSuchElementException;
import java.util.TimeZone;
import java.util.stream.Stream;

import org.apache.commons.lang3.AbstractLangTest;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.junitpioneer.jupiter.DefaultLocale;
import org.junitpioneer.jupiter.ReadsDefaultLocale;
import org.junitpioneer.jupiter.WritesDefaultLocale;
Expand Down Expand Up @@ -1285,6 +1291,107 @@ public void testToCalendarWithTimeZoneNull() {
assertThrows(NullPointerException.class, () -> DateUtils.toCalendar(date1, null));
}

private static Stream<Arguments> dateConversionProvider() {
return Stream.of(
Arguments.of(
java.sql.Date.valueOf("2000-01-01"),
LocalDateTime.of(2000, 1, 1, 0, 0, 0)
),
Comment on lines +1294 to +1299
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you add some test cases with java.util.Date?

Arguments.of(
java.sql.Date.valueOf("1970-01-01"),
LocalDateTime.of(1970, 1, 1, 0, 0, 0)
),
Arguments.of(
java.sql.Time.valueOf("12:30:45"),
LocalDateTime.of(1970, 1, 1, 12, 30, 45)
),
Arguments.of(
java.sql.Time.valueOf("23:59:59"),
LocalDateTime.of(1970, 1, 1, 23, 59, 59)
),
Arguments.of(
java.sql.Timestamp.valueOf("2000-01-01 12:30:45.123456789"),
LocalDateTime.of(2000, 1, 1, 12, 30, 45, 123_456_789)
),
Arguments.of(
java.sql.Timestamp.valueOf("2000-01-01 12:30:45.987654321"),
LocalDateTime.of(2000, 1, 1, 12, 30, 45, 987_654_321)
)
);
}

private static Stream<Arguments> dateWithTimeZoneProvider() {
return Stream.of(
Arguments.of(
java.sql.Timestamp.valueOf("2000-01-01 12:30:45"),
TimeZone.getTimeZone("America/New_York"),
LocalDateTime.ofInstant(
java.sql.Timestamp.valueOf("2000-01-01 12:30:45").toInstant(),
TimeZone.getTimeZone("America/New_York").toZoneId()
)
),
Comment on lines +1323 to +1332
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you add some test cases with java.sql.Date?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you add some test cases with java.sql.Date?

OK, I'll add some now.

Arguments.of(
java.sql.Timestamp.valueOf("2023-03-12 02:30:00"),
TimeZone.getTimeZone("America/New_York"),
LocalDateTime.ofInstant(
java.sql.Timestamp.valueOf("2023-03-12 02:30:00").toInstant(),
TimeZone.getTimeZone("America/New_York").toZoneId()
)
),
Arguments.of(
Date.from(LocalDateTime.of(2023, 1, 1, 0, 0)
.atOffset(ZoneOffset.UTC)
.toInstant()),
TimeZone.getTimeZone("America/New_York"),
LocalDateTime.of(2022, 12, 31, 19, 0)
),
Arguments.of(
Date.from(LocalDateTime.of(2023, 3, 12, 7, 0)
.atOffset(ZoneOffset.UTC)
.toInstant()),
TimeZone.getTimeZone("America/New_York"),
LocalDateTime.of(2023, 3, 12, 3, 0)
),
Arguments.of(
Date.from(LocalDateTime.of(2023, 1, 1, 0, 0)
.atOffset(ZoneOffset.UTC)
.toInstant()),
TimeZone.getTimeZone("Pacific/Kiritimati"),
LocalDateTime.of(2023, 1, 1, 14, 0)
)
);
}

@ParameterizedTest
@MethodSource("dateConversionProvider")
void testToLocalDateTimeWithDate(final Date sqlDate, final LocalDateTime expected) {
final LocalDateTime result = DateUtils.toLocalDateTime(sqlDate);
assertNotNull(result);
assertEquals(expected, result);
}

@ParameterizedTest
@MethodSource("dateWithTimeZoneProvider")
void testToLocalDateTimeWithDate(
final Date date,
final TimeZone timeZone,
final LocalDateTime expected) {
final LocalDateTime result;
if (timeZone != null) {
result = DateUtils.toLocalDateTime(date, timeZone);
} else {
result = DateUtils.toLocalDateTime(date);
}
assertEquals(expected, result);
}

@Test
void shouldThrowNullPointerExceptionWhenDateIsNull() {
assertThrows(NullPointerException.class, () -> DateUtils.toLocalDateTime(null));
assertThrows(NullPointerException.class, () -> DateUtils.toLocalDateTime(null, TimeZone.getDefault()));
assertThrows(NullPointerException.class, () -> DateUtils.toLocalDateTime(new Date(), null));
}

/**
* Tests various values with the trunc method
*
Expand Down
Loading