3. Know java.time better

2021-07-18 java

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:

	    2021-07-18   20:14:33.010    +0200     [Europe/Warsaw]
	    <-------->   <----------> <---------> <--------------->
	     LocalDate    LocalTime   ZoneOffset      TimeZone
	    <----------------------->
		 LocalDateTime
	    <----------------------------------->
	                OffsetDateTime
  	    <----------------------------------------------------->
	                          ZonedDateTime       	

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. So 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 :)