DEV Community

psstepniewski
psstepniewski

Posted on

java.time can be tricky. Know it better!

Motivation

Recently, when I was implementing system module, I temporarily had used database mocks. Later, when I replaced mocks with real database I got UnsupportedOperationException on java.util.Date#toInstant calls. It made me realize: I need to get to know java.time package better.

java.time package

java.time package contains few classes to represent date or time, the main are:

  • Instant - point on the time-line, stores number of seconds since 1970-01-01T00:00:00Z (epoch) and nanoseconds part of second,
  • LocalDate - represents only date,
  • LocalTime - represents only time (with nanoseconds precision),
  • LocalDateTime - represents date and time,
  • OffsetDateTime - represents date, time and zone offset,
  • ZonedDateTime - represents date, time, zone offset and time zone.

In simplification, it can be illustrated as the following:

java.time classes

Start testing!

Good way to know these classes better is to write few tests. You can find all my tests on github.

Parsing java.sql objects to java.time.Instant

UnsupportedOperationException about which I wrote in the Motivation section was thrown while parsing from java.sql package class to java.time package class. java.sql package contains three main classes:

  • Date - represents only date,
  • Time - represents only time,
  • Timestamp - represents date and time.

Tests show, that the following assertions are true. These two toInstant calls throw UnsupportedOperationException.

Date sqlDate = new java.sql.Date(Instant.now().toEpochMilli());
assertThrows(UnsupportedOperationException.class, sqlDate::toInstant);
Enter fullscreen mode Exit fullscreen mode
Date sqlTime = new java.sql.Time(Instant.now().toEpochMilli());
assertThrows(UnsupportedOperationException.class, sqlTime::toInstant);
Enter fullscreen mode Exit fullscreen mode

But the following code will execute without any Exception.

Date sqlTimestamp = new java.sql.Timestamp(Instant.now().toEpochMilli());
assertDoesNotThrow(sqlTimestamp::toInstant);
Enter fullscreen mode Exit fullscreen mode

Date from java.sql has no time part (in contrast to result of new java.util.Date() call), so it cannot be parsed to Instant
java.sql.Date#toInstant doesn't assume that 2021-07-18 means 2021-07-18 00:00:00.000, the time part is simply unknown.
Similarly Time has no date part. Only Timestamp has specified both parts: date and time, so it can be translated to Instant.

There is one tricky thing. All mentioned java.sql classes are child of java.util.Date. java.util.Date can be understood as numer of milliseconds since 1970-01-01T00:00:00Z (epoch). java.util.Date implements toInstant method.If you (like me) will use database mocks which return java.util.Date and you will use java.util.Date#toInstant method, then after switching to real database you may be surprised by UnsupportedOperationException. It is because your database access solution (like Hibernate) can put java.sql.Date (which doesn't implement toInstant method) under java.util.Date reference (which implements toInstant method)! For example, It will happen if you use @Temporal(TemporalType.Date) JPA annotation.

Parsing String to java.time objects

java.time introduces DateTimeFormatter class which supports bidirectional translations between String and java.time objects. It is similar to SimpleDateFormat class, but comparing to it DateTimeFormatter is thread-safe. Let's assume we want to parse String "2021-12-03T10:15:30Z" (contains date, time and zone offset; Z is equal to +0000 offset) to main java.time classes, all the following assertions are true:

final String toParse = "2021-12-03T10:15:30Z";
inal DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ssX");

assertDoesNotThrow(() -> formatter.parse(toParse, Instant::from));
assertDoesNotThrow(() -> LocalTime.parse(toParse, formatter));
assertDoesNotThrow(() -> LocalDate.parse(toParse, formatter));
assertDoesNotThrow(() -> LocalDateTime.parse(toParse, formatter));
assertDoesNotThrow(() -> OffsetDateTime.parse(toParse, formatter));
assertDoesNotThrow(() -> ZonedDateTime.parse(toParse, formatter));

assertEquals(LocalTime.parse(toParse, formatter), LocalTime.of(10, 15, 30, 0));
assertEquals(LocalDate.parse(toParse, formatter), LocalDate.of(2021, 12, 3));
assertEquals(LocalDateTime.parse(toParse, formatter), LocalDateTime.of(2021, 12, 3, 10, 15, 30, 0));
assertEquals(OffsetDateTime.parse(toParse, formatter), OffsetDateTime.of(2021, 12, 3, 10, 15, 30, 0, ZoneOffset.of("Z")));
assertEquals(ZonedDateTime.parse(toParse, formatter), ZonedDateTime.of(2021, 12, 3, 10, 15, 30, 0, ZoneId.of("Z")));
Enter fullscreen mode Exit fullscreen mode

Pay attention that if you parse String to poorer java.time object you can lose information, but an Exception is not thrown! After parsing String "2021-12-03T10:15:30Z" to LocalDate you will keep information only about date and time, offset will be lost.

On the other hand if you try to parse String "2021-12-03T10:15:30" (contains date and time but not offset) to broader java.time object you will get DateTimeParseException. The following assertions are true:

final String toParse = "2021-12-03T10:15:30";
final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss");

assertThrows(DateTimeParseException.class, () -> formatter.parse(toParse, Instant::from));
assertThrows(DateTimeParseException.class, () -> OffsetDateTime.parse(toParse, formatter));
assertThrows(DateTimeParseException.class, () -> ZonedDateTime.parse(toParse, formatter));
Enter fullscreen mode Exit fullscreen mode

Interesting thing is that java.time.ZoneId class is parent of java.time.ZoneOffset class. The following assertions are true:

final String toParse = "2021-12-03T10:15:30-08";
final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ssX");

assertDoesNotThrow(() -> ZonedDateTime.parse(toParse, formatter)); // ZoneOffset (-08) as ZoneId
assertEquals(ZonedDateTime.parse(toParse, formatter), ZonedDateTime.of(2021, 12, 3, 10, 15, 30, 0, ZoneOffset.of("-08")));
Enter fullscreen mode Exit fullscreen mode

Be aware that to get Instant class from other java.time objects you need to provide information about offset. ZonedDateTime and OffsetDateTime keep this information, but if you will use for example LocalDateTime you must provide zone definition in LocalDateTime#toInstant call.
In simplification Instant is number of nanoseconds since 1970-01-01T00:00:00Z(UTC), so to get Instant object you need to know ZoneOffset. java.time.DateTimeFormatter#parse will not use UTC or your system ZoneOffset by default. The following assertions
are true:

final String toParse = "2021-12-03T10:15:30"; //ZoneId is not provided
final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss");

assertThrows(DateTimeParseException.class, () -> formatter.parse(toParse, Instant::from)); //Instant must know ZoneId
assertDoesNotThrow(() -> LocalDateTime.parse(toParse, formatter).toInstant(ZoneOffset.UTC)); //ZoneId provided in toInstant call
Enter fullscreen mode Exit fullscreen mode

Last test, if you try to parse String which not match to DateTimeFormatter pattern you will get exception, even if your String is broader than given DateTimeFormatter pattern. For example if you try parse String equals to 2021-12-03T10:15:30Z with DateTimeFormatter pattern equals to yyyy-MM-dd'T'HH:mm:ss (comparing to parsed String pattern does not handle zone offset) you will get exception. The following assertion is true:

final String toParse = "2021-12-03T10:15:30Z";
final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss");

assertThrows(DateTimeParseException.class, () -> formatter.parse(toParse));
Enter fullscreen mode Exit fullscreen mode

comparing java.time package objects

Be aware if you cast between java.time objects you can lose information. For example if you call java.time.OffsetDateTime#toLocalDateTime() on two object which has equal date and time but different offsets, the returned LocalDateTime will be equal (zone offset data will be lost). The following assertion is true:

OffsetDateTime offsetDateTime1 = OffsetDateTime.of(2021, 11, 10, 10, 11, 12, 0, ZoneOffset.of("+01"));
OffsetDateTime offsetDateTime2 = OffsetDateTime.of(2021, 11, 10, 10, 11, 12, 0, ZoneOffset.of("+05"));
assertEquals(offsetDateTime1.toLocalDateTime(), offsetDateTime2.toLocalDateTime());
Enter fullscreen mode Exit fullscreen mode

If you want to compare two java.time objects as points in time, the best choice is Instant class. For example if you have two OffsetDateTime objects which represents the same point in time but with different zone offsets (both 2021-11-10 10:11:12.000+01 and 2021-11-10 15:11:12.000+06 represents the same point in time equals to 2021-11-10 09:11:12+00) they will be not equal. OffsetDateTime#equals does not compare points in time but check if compared objects have equal dates, times and zone offsets. To compare point in times use Instant class instead. The following assertions are true (all the following offset variables represent the same point in time equal to 2021-11-10T09:11:12Z):

OffsetDateTime offset1 = OffsetDateTime.of(2021, 11, 10, 10, 11, 12, 0, ZoneOffset.of("+01"));
OffsetDateTime offset2 = OffsetDateTime.of(2021, 11, 10, 15, 11, 12, 0, ZoneOffset.of("+06"));
OffsetDateTime offset3 = OffsetDateTime.of(2021, 11, 10, 10, 11, 12, 0, ZoneOffset.of("+01"));

assertEquals(offset1.toInstant(), offset2.toInstant());
assertNotEquals(offset1, offset2);
assertEquals(offset1, offset3);
Enter fullscreen mode Exit fullscreen mode

Differences between ZonedDateTime and OffsetDateTime can be not clear at the beginning. Main difference is ZonedDateTime can switch its zone offset. For example in Poland (Europe/Warsaw time zone) two zone offsets are used: +02 offset is used in the Summer (from the end of March to the end of October) and +01 offset is used in the Winter (from the end of October to the end of March). Two zone offsets requires time change, one of them took place on 25.10.2020: time was set back from 3 am to 2 am. Consequently, the following assertions are true:

ZonedDateTime zonedDateTime1 = ZonedDateTime.of(2020, 10, 25, 2, 0, 0, 0, ZoneId.of("Europe/Warsaw"));
assertEquals(zonedDateTime1.plusHours(1).toLocalDateTime(), zonedDateTime1.toLocalDateTime());

assertNotEquals(zonedDateTime1.plusHours(1).getOffset(), zonedDateTime1.getOffset());
Enter fullscreen mode Exit fullscreen mode

ZonedDateTime keep information about ZoneOffset so it is aware whether time has been set back.

And it's all. I hope after reading this post you know java.time package better :)


Originally published at https://stepniewski.tech.

Top comments (1)

Collapse
 
stealthmusic profile image
Jan Wedel

I like you visual explanation about how the java.time classes relate to each other. This is really important to understand, especially when designing a system that works with time data. Do you need offset, time zone or is the UTC timestamp sufficient? Use the right tool for the job.
Also, I learned that one should never parse time stamps with such a formatter. We had lots of bug because it was
not able to correctly handle all ISO timestamp feature. Especially milli and microseconds wich can (and will be) removed from a string when all zeros. This breaks most formatter/parsers.
Eventually, I can only suggest to use .parse or toString() to convert back and forth.