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

30.5k views Asked by At

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
6

There are 6 answers

0
AudioBubble On BEST ANSWER

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.

2
Flown On

You can simply add a default value in a DateTimeFormatterBuilder if there is no OFFSET_SECOND:


EDIT: To get the system's default ZoneOffset you have to apply the ZoneRules to the current Instant. The result looks like this:

class DateUtil {
  public ZonedDateTime parseToZonedDateTime(String date, String dateFormat) {
    DateTimeFormatter formatter = DateTimeFormatter.ofPattern(dateFormat);
    LocalDateTime localDateTime = LocalDateTime.parse(date, formatter);
    ZoneOffset defaultOffset =  ZoneId.systemDefault().getRules().getOffset(localDateTime);
    DateTimeFormatter dateTimeFormatter = new DateTimeFormatterBuilder()
            .append(formatter)
            .parseDefaulting(ChronoField.OFFSET_SECONDS, defaultOffset.getTotalSeconds())
            .toFormatter();
    return ZonedDateTime.parse(date, dateTimeFormatter);
  }
}

Output:

2017-09-14T15:00+05:30
2017-09-14T15:00+02:00
0
M. Prokhorov On

There are next to no defaults in java.time library, which is mostly a good thing - what you're seeing is what you get, period.

I would suggest that if your date string doesn't contain Zone - it is a LocalDateTime, and can't be a ZonedDateTime, which is the meaning of exception you're getting (even if verbiage suffered due to overly flexible code structure).

My main suggestion is to parse into local date time if you know that pattern has no Zone information.

However, if you really must, here is another way to do what you want (an alternative solution that does not use exceptions for controlling the flow):

TemporalAccessor parsed = f.parse(string);
if (parsed.query(TemporalQueries.zone()) == null) {
  parsed = f.withZone(ZoneId.systemDefault()).parse(string);
}
return ZonedDateTime.from(parsed);

Here we use intermediate parsing result to determine if the string contained Zone information, and if not, we parse again (using same string, but different printer-parser) so that it will contain a zone this time.

Alternatively, you can make this class, which will save you from parsing second time and should allow you parse zoned date time, assuming all other fields are there:

class TemporalWithZone implements TemporalAccessor {
  private final ZoneId zone;
  private final TemporalAccessor delegate;
  public TemporalWithZone(TemporalAccessor delegate, ZoneId zone) {
    this.delegate = requireNonNull(delegate);
    this.zone = requireNonNull(zone);
  }

  <delegate methods: isSupported(TemporalField), range(TemporalField), getLong(TemporalField)>

  public <R> R query(TemporalQuery<R> query) {
    if (query == TemporalQueries.zone() || query == TemporalQueries.zoneId()) {
      return (R) zone;
    }
    return delegate.query(query);
  }
}
3
coladict On

Just copying this solution from a project I have:

formatter = DateTimeFormatter.ofPattern(dateFormat).withZone(ZONE_UTC);

Once the formatter is compiled, you can call withZone(ZoneId) to create a new formatter with a set timezone.

0
vivek_vara On

As per Java 8 ZonedDateTime implementation, You can not parse date without zone in ZonedDateTime.

To cater given problem you must have to put try catch in case any exception it will consider default time zone.

Please find revised program as 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 = null;
        try {
            zonedDateTime = ZonedDateTime.parse(date, formatter);
        } catch (DateTimeException e) {
            // If date doesn't contains Zone then parse with LocalDateTime 
            LocalDateTime localDateTime = LocalDateTime.parse(date, formatter);
            zonedDateTime = localDateTime.atZone(ZoneId.systemDefault());
        }
        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"));
    }
}

You case refer http://www.codenuclear.com/java-8-date-time-intro for more details on upcoming java features

0
RSloeserwij On

The ZoneId can be specified by using the withZone method in the DateTimeFormatter:

DateTimeFormatter formatter = DateTimeFormatter.ofPattern(dateFormat).withZone("+0530");