java.time can be tricky. Know it better!
Tags: java, programming, time, beginners
Published: 2021-12-05 18:46:28


## 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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/bjyoq5k17rxflhc2r29s.png)
 

## Start testing!
Good way to know these classes better is to write few tests. You can find all my tests on [github](https://stepniewski.tech/L/2h6bkn6iS).


### 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`.
```java
Date sqlDate = new java.sql.Date(Instant.now().toEpochMilli());
assertThrows(UnsupportedOperationException.class, sqlDate::toInstant);
```
```java
Date sqlTime = new java.sql.Time(Instant.now().toEpochMilli());
assertThrows(UnsupportedOperationException.class, sqlTime::toInstant);
```

But the following code will execute without any `Exception`.
```java
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:

```java
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:

```java
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:

```java
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:


```java
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:

```java
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:

```java
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`):
```java
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:

```java
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](https://stepniewski.tech/L/BaT64YCh).