Java 8 Time API - ZonedDateTime - specify default ZoneId when parsing

RuntimeException picture RuntimeException · Sep 14, 2017 · Viewed 16.8k times · Source

I am trying to write a generic method to return a ZonedDateTime given a date as String and its format.

How do we make ZonedDateTime to use the default ZoneId if it is not specified in the date String?

It can be done with java.util.Calendar, but I want to use the Java 8 time API.

This question here is using a fixed time zone. I am specifying the format as an argument. Both the date and its format are String arguments. More generic.

Code and output below:

public class DateUtil {
    /** Convert a given String to ZonedDateTime. Use default Zone in string does not have zone.  */
    public ZonedDateTime parseToZonedDateTime(String date, String dateFormat) {
        //use java.time from java 8
        DateTimeFormatter formatter = DateTimeFormatter.ofPattern(dateFormat);
        ZonedDateTime zonedDateTime = ZonedDateTime.parse(date, formatter);
        return zonedDateTime;
    }

    public static void main(String args[]) {
        DateUtil dateUtil = new DateUtil();
        System.out.println(dateUtil.parseToZonedDateTime("2017-09-14 15:00:00+0530", "yyyy-MM-dd HH:mm:ssZ"));
        System.out.println(dateUtil.parseToZonedDateTime("2017-09-14 15:00:00", "yyyy-MM-dd HH:mm:ss"));
    }
}

Output

2017-09-14T15:00+05:30
Exception in thread "main" java.time.format.DateTimeParseException: Text '2017-09-14 15:00:00' could not be parsed: Unable to obtain ZonedDateTime from TemporalAccessor: {},ISO resolved to 2017-09-14T15:00 of type java.time.format.Parsed
    at java.time.format.DateTimeFormatter.createError(DateTimeFormatter.java:1920)
    at java.time.format.DateTimeFormatter.parse(DateTimeFormatter.java:1855)
    at java.time.ZonedDateTime.parse(ZonedDateTime.java:597)
    at com.nam.sfmerchstorefhs.util.DateUtil.parseToZonedDateTime(DateUtil.java:81)
    at com.nam.sfmerchstorefhs.util.DateUtil.main(DateUtil.java:97)
Caused by: java.time.DateTimeException: Unable to obtain ZonedDateTime from TemporalAccessor: {},ISO resolved to 2017-09-14T15:00 of type java.time.format.Parsed
    at java.time.ZonedDateTime.from(ZonedDateTime.java:565)
    at java.time.format.Parsed.query(Parsed.java:226)
    at java.time.format.DateTimeFormatter.parse(DateTimeFormatter.java:1851)
    ... 3 more
Caused by: java.time.DateTimeException: Unable to obtain ZoneId from TemporalAccessor: {},ISO resolved to 2017-09-14T15:00 of type java.time.format.Parsed
    at java.time.ZoneId.from(ZoneId.java:466)
    at java.time.ZonedDateTime.from(ZonedDateTime.java:553)
    ... 5 more

Answer

user7605325 picture user7605325 · Sep 14, 2017

A ZonedDateTime needs a timezone or an offset to be built, and the second input doesn't have it. (It contains only a date and time).

So you need to check if it's possible to build a ZonedDateTime, and if it's not, you'll have to choose an arbitrary zone for it (as the input has no indication about what's the timezone being used, you must choose one to be used).

One alternative is to first try to create a ZonedDateTime and if it's not possible, then create a LocalDateTime and convert it to a timezone:

public ZonedDateTime parseToZonedDateTime(String date, String dateFormat) {
    // use java.time from java 8
    DateTimeFormatter formatter = DateTimeFormatter.ofPattern(dateFormat);
    ZonedDateTime zonedDateTime = null;
    try {
        zonedDateTime = ZonedDateTime.parse(date, formatter);
    } catch (DateTimeException e) {
        // couldn't parse to a ZoneDateTime, try LocalDateTime
        LocalDateTime dt = LocalDateTime.parse(date, formatter);

        // convert to a timezone
        zonedDateTime = dt.atZone(ZoneId.systemDefault());
    }
    return zonedDateTime;
}

In the code above, I'm using ZoneId.systemDefault(), which gets the JVM default timezone, but this can be changed without notice, even at runtime, so it's better to always make it explicit which one you're using.

The API uses IANA timezones names (always in the format Region/City, like America/Sao_Paulo or Europe/Berlin). Avoid using the 3-letter abbreviations (like CST or PST) because they are ambiguous and not standard.

You can get a list of available timezones (and choose the one that fits best your system) by calling ZoneId.getAvailableZoneIds().

If you want to use a specific timezone, just use ZoneId.of("America/New_York") (or any other valid name returned by ZoneId.getAvailableZoneIds(), New York is just an example) instead of ZoneId.systemDefault().


Another alternative is to use parseBest() method, that tries to create a suitable date object (using a list of TemporalQuery's) until it creates the type you want:

public ZonedDateTime parseToZonedDateTime(String date, String dateFormat) {
    DateTimeFormatter formatter = DateTimeFormatter.ofPattern(dateFormat);

    // try to create a ZonedDateTime, if it fails, try LocalDateTime
    TemporalAccessor parsed = formatter.parseBest(date, ZonedDateTime::from, LocalDateTime::from);

    // if it's a ZonedDateTime, return it
    if (parsed instanceof ZonedDateTime) {
        return (ZonedDateTime) parsed;
    }
    if (parsed instanceof LocalDateTime) {
        // convert LocalDateTime to JVM default timezone
        LocalDateTime dt = (LocalDateTime) parsed;
        return dt.atZone(ZoneId.systemDefault());
    }

    // if it can't be parsed, return null or throw exception?
    return null;
}

In this case, I just used ZonedDateTime::from and LocalDateTime::from, so the formatter will try to first create a ZonedDateTime, and if it's not possible, then it tries to create a LocalDateTime.

Then I check what was the type returned and do the actions accordingly. You can add as many types you want (all main types, such as LocalDate, LocalTime, OffsetDateTime and so on, have a from method that works with parseBest - you can also create your own custom TemporalQuery if you want, but I think the built-in methods are enough for this case).


Daylight Saving Time

When converting a LocalDateTime to a ZonedDateTime using the atZone() method, there are some tricky cases regarding Daylight Saving Time (DST).

I'm going to use the timezone I live in (America/Sao_Paulo) as example, but this can happen at any timezone with DST.

In São Paulo, DST started at October 16th 2016: at midnight, clocks shifted 1 hour forward from midnight to 1 AM (and the offset changes from -03:00 to -02:00). So all local times between 00:00 and 00:59 didn't exist in this timezone (you can also think that clocks changed from 23:59:59.999999999 directly to 01:00). If I create a local date in this interval, it's adjusted to the next valid moment:

ZoneId zone = ZoneId.of("America/Sao_Paulo");

// October 16th 2016 at midnight, DST started in Sao Paulo
LocalDateTime d = LocalDateTime.of(2016, 10, 16, 0, 0, 0, 0);
ZonedDateTime z = d.atZone(zone);
System.out.println(z);// adjusted to 2017-10-15T01:00-02:00[America/Sao_Paulo]

When DST ends: in February 19th 2017 at midnight, clocks shifted back 1 hour, from midnight to 23 PM of 18th (and the offset changes from -02:00 to -03:00). So all local times from 23:00 to 23:59 existed twice (in both offsets: -03:00 and -02:00), and you must decide which one you want. By default, it uses the offset before DST ends, but you can use the withLaterOffsetAtOverlap() method to get the offset after DST ends:

// February 19th 2017 at midnight, DST ends in Sao Paulo
// local times from 23:00 to 23:59 at 18th exist twice
LocalDateTime d = LocalDateTime.of(2017, 2, 18, 23, 0, 0, 0);
// by default, it gets the offset before DST ends
ZonedDateTime beforeDST = d.atZone(zone);
System.out.println(beforeDST); // before DST end: 2018-02-17T23:00-02:00[America/Sao_Paulo]

// get the offset after DST ends
ZonedDateTime afterDST = beforeDST.withLaterOffsetAtOverlap();
System.out.println(afterDST); // after DST end: 2018-02-17T23:00-03:00[America/Sao_Paulo]

Note that the dates before and after DST ends have different offsets (-02:00 and -03:00). If you're working with a timezone that has DST, keep in mind that those corner-cases can happen.