How to use java.time.ZonedDateTime / LocalDateTime in p:calendar
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);
}
}
Solution 1:
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:
-
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 withZonedDateTime
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 inZonedDateTime
is entirely ignored). This is the root cause of your observable problem. Just omittingwithZone()
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 useTimeZone.getTimeZone(zonedDateTime.getZone())
instead of hardcoding it. -
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:
-
As the
<p:calendar>
doesn't lookup converters byforClass
, you'd either need to re-register it with<converter><converter-id>localDateTimeConverter
infaces-config.xml
, or to alter the annotation as below@FacesConverter("localDateTimeConverter")
-
As the
<p:calendar>
withouttimeOnly="true"
ignores thetimeZone
, and offers in the popup an option to edit it, you need to remove thetimeZone
attribute to avoid that the converter gets confused (this attribute is only required when the time zone is absent in thepattern
). -
You need to specify the desired display
timeZone
attribute during output (this attribute is not required when usingZonedDateTimeConverter
as it's already stored inZonedDateTime
).
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>