How to use java.time.ZonedDateTime / LocalDateTime in p:calendar

Tiny picture Tiny · Jan 19, 2016 · Viewed 12k times · Source

I had been using Joda Time for date-time manipulation in a Java EE application in which a string representation of date-time submitted by the associated client had been converted using the following conversion routine before submitting it to a database i.e. in the getAsObject() method in a JSF converter.

org.joda.time.format.DateTimeFormatter formatter = org.joda.time.format.DateTimeFormat.forPattern("dd-MMM-yyyy hh:mm:ss a Z").withZone(DateTimeZone.UTC);
DateTime dateTime = formatter.parseDateTime("05-Jan-2016 03:04:44 PM +0530");

System.out.println(formatter.print(dateTime));

The local time zone given is 5 hours and 30 minutes ahead of UTC / GMT. Therefore, the conversion to UTC should deduct 5 hours and 30 minutes from the date-time given which happens correctly using Joda Time. It displays the following output as expected.

05-Jan-2016 09:34:44 AM +0000

► The time zone offset +0530 in place of +05:30 has been taken because it is dependent upon <p:calendar> which submits a zone offset in this format. It does not seem possible to change this behaviour of <p:calendar> (This question itself would not have been needed otherwise).


The same thing is however broken, if attempted using the Java Time API in Java 8.

java.time.format.DateTimeFormatter formatter = java.time.format.DateTimeFormatter.ofPattern("dd-MMM-yyyy hh:mm:ss a Z").withZone(ZoneOffset.UTC);
ZonedDateTime dateTime = ZonedDateTime.parse("05-Jan-2016 03:04:44 PM +0530", formatter);

System.out.println(formatter.format(dateTime));

It unexpectedly displays the following incorrect output.

05-Jan-2016 03:04:44 PM +0000

Obviously, the date-time converted is not according to UTC in which it is supposed to convert.

It requires the following changes to be adopted for it to work correctly.

java.time.format.DateTimeFormatter formatter = java.time.format.DateTimeFormatter.ofPattern("dd-MMM-yyyy hh:mm:ss a z").withZone(ZoneOffset.UTC);
ZonedDateTime dateTime = ZonedDateTime.parse("05-Jan-2016 03:04:44 PM +05:30", formatter);

System.out.println(formatter.format(dateTime));

Which in turn displays the following.

05-Jan-2016 09:34:44 AM Z

Z has been replaced with z and +0530 has been replaced with +05:30.

Why these two APIs have a different behaviour in this regard has been ignored wholeheartedly in this question.

What middle way approach can be considered for <p:calendar> and Java Time in Java 8 to work consistently and coherently though <p:calendar> internally uses SimpleDateFormat along with java.util.Date?


The unsuccessful test scenario in JSF.

The converter :

@FacesConverter("dateTimeConverter")
public class DateTimeConverter implements Converter {

    @Override
    public Object getAsObject(FacesContext context, UIComponent component, String value) {
        if (value == null || value.isEmpty()) {
            return null;
        }

        try {
            return ZonedDateTime.parse(value, DateTimeFormatter.ofPattern("dd-MMM-yyyy hh:mm:ss a Z").withZone(ZoneOffset.UTC));
        } catch (IllegalArgumentException | DateTimeException e) {
            throw new ConverterException(new FacesMessage(FacesMessage.SEVERITY_ERROR, null, "Message"), e);
        }
    }

    @Override
    public String getAsString(FacesContext context, UIComponent component, Object value) {
        if (value == null) {
            return "";
        }

        if (!(value instanceof ZonedDateTime)) {
            throw new ConverterException("Message");
        }

        return DateTimeFormatter.ofPattern("dd-MMM-yyyy hh:mm:ss a z").withZone(ZoneId.of("Asia/Kolkata")).format(((ZonedDateTime) value));
        // According to a time zone of a specific user.
    }
}

XHTML having <p:calendar>.

<p:calendar  id="dateTime"
             timeZone="Asia/Kolkata"
             pattern="dd-MMM-yyyy hh:mm:ss a Z"
             value="#{bean.dateTime}"
             showOn="button"
             required="true"
             showButtonPanel="true"
             navigator="true">
    <f:converter converterId="dateTimeConverter"/>
</p:calendar>

<p:message for="dateTime"/>

<p:commandButton value="Submit" update="display" actionListener="#{bean.action}"/><br/><br/>

<h:outputText id="display" value="#{bean.dateTime}">
    <f:converter converterId="dateTimeConverter"/>
</h:outputText>

The time zone is fully transparently dependent upon the user's current time zone.

The bean having nothing other than a single property.

@ManagedBean
@ViewScoped
public class Bean implements Serializable {

    private ZonedDateTime dateTime; // Getter and setter.
    private static final long serialVersionUID = 1L;

    public Bean() {}

    public void action() {
        // Do something.
    }
}

This will work in an unexpected way as demonstrated in the second last example / middle in the first three code snippets.

Specifically, if you enter 05-Jan-2016 12:00:00 AM +0530, it will redisplay 05-Jan-2016 05:30:00 AM IST because the original conversion of 05-Jan-2016 12:00:00 AM +0530 to UTC in the converter fails.

Conversion from a local time zone whose offset is +05:30 to UTC and then conversion from UTC back to that time zone must obviously redisplay the same date-time as inputted through the calendar component which is the rudimentary functionality of the converter given.


Update:

The JPA converter converting to and from java.sql.Timestamp and java.time.ZonedDateTime.

import java.sql.Timestamp;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import javax.persistence.AttributeConverter;
import javax.persistence.Converter;

@Converter(autoApply = true)
public final class JodaDateTimeConverter implements AttributeConverter<ZonedDateTime, Timestamp> {

    @Override
    public Timestamp convertToDatabaseColumn(ZonedDateTime dateTime) {
        return dateTime == null ? null : Timestamp.from(dateTime.toInstant());
    }

    @Override
    public ZonedDateTime convertToEntityAttribute(Timestamp timestamp) {
        return timestamp == null ? null : ZonedDateTime.ofInstant(timestamp.toInstant(), ZoneOffset.UTC);
    }
}

Answer

BalusC picture BalusC · Jan 21, 2016

Your concrete problem is that you migrated from Joda's zoneless date time instance DateTime to Java8's zoned date time instance ZonedDateTime instead of Java8's zoneless date time instance LocalDateTime.

Using ZonedDateTime (or OffsetDateTime) instead of LocalDateTime requires at least 2 additional changes:

  1. Do not force a time zone (offset) during date time conversion. Instead, the time zone of the input string, if any, will be used during parsing, and the time zone stored in ZonedDateTime instance must be used during formatting.

    The DateTimeFormatter#withZone() will only give confusing results with ZonedDateTime as it will act as fallback during parsing (it's only used when time zone is absent in input string or format pattern), and it will act as override during formatting (the time zone stored in ZonedDateTime is entirely ignored). This is the root cause of your observable problem. Just omitting withZone() while creating the formatter should fix it.

    Do note that when you have specified a converter, and don't have timeOnly="true", then you don't need to specify <p:calendar timeZone>. Even when you do, you'd rather like to use TimeZone.getTimeZone(zonedDateTime.getZone()) instead of hardcoding it.

  2. You need to carry the time zone (offset) along over all layers, including the database. If your database, however, has a "date time without time zone" column type, then the time zone information gets lost during persist and you will run into trouble when serving back from database.

    It's unclear which DB you're using, but keep in mind that some DBs doesn't support a TIMESTAMP WITH TIME ZONE column type as known from Oracle and PostgreSQL DBs. For example, MySQL does not support it. You'd need a second column.

If those changes are not acceptable, then you need to step back to LocalDateTime and rely on fixed/predefined time zone throughout all layers, including the database. Usually UTC is used for this.


Dealing with ZonedDateTime in JSF and JPA

When using ZonedDateTime with an appropriate TIMESTAMP WITH TIME ZONE DB column type, use the below JSF converter to convert between String in the UI and ZonedDateTime in the model. This converter will lookup the pattern and locale attributes from the parent component. If the parent component doesn't natively support a pattern or locale attribute, simply add them as <f:attribute name="..." value="...">. If the locale attribute is absent, the (default) <f:view locale> will be used instead. There is no timeZone attribute for the reason as explained in #1 here above.

@FacesConverter(forClass=ZonedDateTime.class)
public class ZonedDateTimeConverter implements Converter {

    @Override
    public String getAsString(FacesContext context, UIComponent component, Object modelValue) {
        if (modelValue == null) {
            return "";
        }

        if (modelValue instanceof ZonedDateTime) {
            return getFormatter(context, component).format((ZonedDateTime) modelValue);
        } else {
            throw new ConverterException(new FacesMessage(modelValue + " is not a valid ZonedDateTime"));
        }
    }

    @Override
    public Object getAsObject(FacesContext context, UIComponent component, String submittedValue) {
        if (submittedValue == null || submittedValue.isEmpty()) {
            return null;
        }

        try {
            return ZonedDateTime.parse(submittedValue, getFormatter(context, component));
        } catch (DateTimeParseException e) {
            throw new ConverterException(new FacesMessage(submittedValue + " is not a valid zoned date time"), e);
        }
    }

    private DateTimeFormatter getFormatter(FacesContext context, UIComponent component) {
        return DateTimeFormatter.ofPattern(getPattern(component), getLocale(context, component));
    }

    private String getPattern(UIComponent component) {
        String pattern = (String) component.getAttributes().get("pattern");

        if (pattern == null) {
            throw new IllegalArgumentException("pattern attribute is required");
        }

        return pattern;
    }

    private Locale getLocale(FacesContext context, UIComponent component) {
        Object locale = component.getAttributes().get("locale");
        return (locale instanceof Locale) ? (Locale) locale
            : (locale instanceof String) ? new Locale((String) locale)
            : context.getViewRoot().getLocale();
    }

}

And use the below JPA converter to convert between ZonedDateTime in the model and java.util.Calendar in JDBC (the decent JDBC driver will require/use it for TIMESTAMP WITH TIME ZONE typed column):

@Converter(autoApply=true)
public class ZonedDateTimeAttributeConverter implements AttributeConverter<ZonedDateTime, Calendar> {

    @Override
    public Calendar convertToDatabaseColumn(ZonedDateTime entityAttribute) {
        if (entityAttribute == null) {
            return null;
        }

        Calendar calendar = Calendar.getInstance();
        calendar.setTimeInMillis(entityAttribute.toInstant().toEpochMilli());
        calendar.setTimeZone(TimeZone.getTimeZone(entityAttribute.getZone()));
        return calendar;
    }

    @Override
    public ZonedDateTime convertToEntityAttribute(Calendar databaseColumn) {
        if (databaseColumn == null) {
            return null;
        }

        return ZonedDateTime.ofInstant(databaseColumn.toInstant(), databaseColumn.getTimeZone().toZoneId());
    }

}

Dealing with LocalDateTime in JSF and JPA

When using UTC based LocalDateTime with an appropriate UTC based TIMESTAMP (without time zone!) DB column type, use the below JSF converter to convert between String in the UI and LocalDateTime in the model. This converter will lookup the pattern, timeZone and locale attributes from the parent component. If the parent component doesn't natively support a pattern, timeZone and/or locale attribute, simply add them as <f:attribute name="..." value="...">. The timeZone attribute must represent the fallback time zone of the input string (when the pattern doesn't contain a time zone), and the time zone of the output string.

@FacesConverter(forClass=LocalDateTime.class)
public class LocalDateTimeConverter implements Converter {

    @Override
    public String getAsString(FacesContext context, UIComponent component, Object modelValue) {
        if (modelValue == null) {
            return "";
        }

        if (modelValue instanceof LocalDateTime) {
            return getFormatter(context, component).format(ZonedDateTime.of((LocalDateTime) modelValue, ZoneOffset.UTC));
        } else {
            throw new ConverterException(new FacesMessage(modelValue + " is not a valid LocalDateTime"));
        }
    }

    @Override
    public Object getAsObject(FacesContext context, UIComponent component, String submittedValue) {
        if (submittedValue == null || submittedValue.isEmpty()) {
            return null;
        }

        try {
            return ZonedDateTime.parse(submittedValue, getFormatter(context, component)).withZoneSameInstant(ZoneOffset.UTC).toLocalDateTime();
        } catch (DateTimeParseException e) {
            throw new ConverterException(new FacesMessage(submittedValue + " is not a valid local date time"), e);
        }
    }

    private DateTimeFormatter getFormatter(FacesContext context, UIComponent component) {
        DateTimeFormatter formatter = DateTimeFormatter.ofPattern(getPattern(component), getLocale(context, component));
        ZoneId zone = getZoneId(component);
        return (zone != null) ? formatter.withZone(zone) : formatter;
    }

    private String getPattern(UIComponent component) {
        String pattern = (String) component.getAttributes().get("pattern");

        if (pattern == null) {
            throw new IllegalArgumentException("pattern attribute is required");
        }

        return pattern;
    }

    private Locale getLocale(FacesContext context, UIComponent component) {
        Object locale = component.getAttributes().get("locale");
        return (locale instanceof Locale) ? (Locale) locale
            : (locale instanceof String) ? new Locale((String) locale)
            : context.getViewRoot().getLocale();
    }

    private ZoneId getZoneId(UIComponent component) {
        Object timeZone = component.getAttributes().get("timeZone");
        return (timeZone instanceof TimeZone) ? ((TimeZone) timeZone).toZoneId()
            : (timeZone instanceof String) ? ZoneId.of((String) timeZone)
            : null;
    }

}

And use the below JPA converter to convert between LocalDateTime in the model and java.sql.Timestamp in JDBC (the decent JDBC driver will require/use it for TIMESTAMP typed column):

@Converter(autoApply=true)
public class LocalDateTimeAttributeConverter implements AttributeConverter<LocalDateTime, Timestamp> {

    @Override
    public Timestamp convertToDatabaseColumn(LocalDateTime entityAttribute) {
        if (entityAttribute == null) {
            return null;
        }

        return Timestamp.valueOf(entityAttribute);
    }

    @Override
    public LocalDateTime convertToEntityAttribute(Timestamp databaseColumn) {
        if (databaseColumn == null) {
            return null;
        }

        return databaseColumn.toLocalDateTime();
    }

}

Applying LocalDateTimeConverter to your specific case with <p:calendar>

You need to change the below:

  1. As the <p:calendar> doesn't lookup converters by forClass, you'd either need to re-register it with <converter><converter-id>localDateTimeConverter in faces-config.xml, or to alter the annotation as below

     @FacesConverter("localDateTimeConverter")
    
  2. As the <p:calendar> without timeOnly="true" ignores the timeZone, and offers in the popup an option to edit it, you need to remove the timeZone attribute to avoid that the converter gets confused (this attribute is only required when the time zone is absent in the pattern).

  3. You need to specify the desired display timeZone attribute during output (this attribute is not required when using ZonedDateTimeConverter as it's already stored in ZonedDateTime).

Here's the full working snippet:

<p:calendar id="dateTime"
            pattern="dd-MMM-yyyy hh:mm:ss a Z"
            value="#{bean.dateTime}"
            showOn="button"
            required="true"
            showButtonPanel="true"
            navigator="true">
    <f:converter converterId="localDateTimeConverter" />
</p:calendar>

<p:message for="dateTime" autoUpdate="true" />

<p:commandButton value="Submit" update="display" action="#{bean.action}" /><br/><br/>

<h:outputText id="display" value="#{bean.dateTime}">
    <f:converter converterId="localDateTimeConverter" />
    <f:attribute name="pattern" value="dd-MMM-yyyy hh:mm:ss a Z" />
    <f:attribute name="timeZone" value="Asia/Kolkata" />
</h:outputText>

In case you intend to create your own <my:convertLocalDateTime> with attributes, you'd need to add them as bean-like properties with getters/setters to the converter class and register it in *.taglib.xml as demonstrated in this answer: Creating custom tag for Converter with attributes

<h:outputText id="display" value="#{bean.dateTime}">
    <my:convertLocalDateTime pattern="dd-MMM-yyyy hh:mm:ss a Z" 
                             timeZone="Asia/Kolkata" />
</h:outputText>