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 since1970-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:
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);
Date sqlTime = new java.sql.Time(Instant.now().toEpochMilli());
assertThrows(UnsupportedOperationException.class, sqlTime::toInstant);
But the following code will execute without any Exception
.
Date sqlTimestamp = new java.sql.Timestamp(Instant.now().toEpochMilli());
assertDoesNotThrow(sqlTimestamp::toInstant);
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")));
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));
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")));
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
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));
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());
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);
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());
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)
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.