001/*
002 * Licensed to the Apache Software Foundation (ASF) under one or more
003 * contributor license agreements.  See the NOTICE file distributed with
004 * this work for additional information regarding copyright ownership.
005 * The ASF licenses this file to You under the Apache License, Version 2.0
006 * (the "License"); you may not use this file except in compliance with
007 * the License.  You may obtain a copy of the License at
008 *
009 *      http://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017package org.apache.commons.beanutils2.converters;
018
019import java.text.DateFormat;
020import java.text.ParsePosition;
021import java.text.SimpleDateFormat;
022import java.time.Instant;
023import java.time.LocalDate;
024import java.time.LocalDateTime;
025import java.time.OffsetDateTime;
026import java.time.ZoneId;
027import java.time.ZonedDateTime;
028import java.time.temporal.TemporalAccessor;
029import java.util.Calendar;
030import java.util.Date;
031import java.util.Locale;
032import java.util.TimeZone;
033
034import org.apache.commons.beanutils2.ConversionException;
035
036/**
037 * {@link org.apache.commons.beanutils2.Converter} implementation that handles conversion to and from <strong>date/time</strong> objects.
038 * <p>
039 * This implementation handles conversion for the following <em>date/time</em> types.
040 * <ul>
041 * <li>{@link java.util.Date}</li>
042 * <li>{@link java.util.Calendar}</li>
043 * <li>{@link java.time.LocalDate}</li>
044 * <li>{@link java.time.LocalDateTime}</li>
045 * <li>{@link java.time.OffsetDateTime}</li>
046 * <li>{@link java.time.ZonedDateTime}</li>
047 * <li>{@link java.sql.Date}</li>
048 * <li>{@link java.sql.Time}</li>
049 * <li>{@link java.sql.Timestamp}</li>
050 * </ul>
051 *
052 * <h2>String Conversions (to and from)</h2> This class provides a number of ways in which date/time conversions to/from Strings can be achieved:
053 * <ul>
054 * <li>Using the SHORT date format for the default Locale, configure using:
055 * <ul>
056 * <li>{@code setUseLocaleFormat(true)}</li>
057 * </ul>
058 * </li>
059 * <li>Using the SHORT date format for a specified Locale, configure using:
060 * <ul>
061 * <li>{@code setLocale(Locale)}</li>
062 * </ul>
063 * </li>
064 * <li>Using the specified date pattern(s) for the default Locale, configure using:
065 * <ul>
066 * <li>Either {@code setPattern(String)} or {@code setPatterns(String[])}</li>
067 * </ul>
068 * </li>
069 * <li>Using the specified date pattern(s) for a specified Locale, configure using:
070 * <ul>
071 * <li>{@code setPattern(String)} or {@code setPatterns(String[]) and...}</li>
072 * <li>{@code setLocale(Locale)}</li>
073 * </ul>
074 * </li>
075 * <li>If none of the above are configured the {@code toDate(String)} method is used to convert from String to Date and the Dates's {@code toString()} method
076 * used to convert from Date to String.</li>
077 * </ul>
078 *
079 * <p>
080 * The <strong>Time Zone</strong> to use with the date format can be specified using the {@link #setTimeZone(TimeZone)} method.
081 *
082 * @param <D> The default value type.
083 * @since 1.8.0
084 */
085public abstract class DateTimeConverter<D> extends AbstractConverter<D> {
086
087    private String[] patterns;
088    private String displayPatterns;
089    private Locale locale;
090    private TimeZone timeZone;
091    private boolean useLocaleFormat;
092
093    /**
094     * Constructs a Date/Time <em>Converter</em> that throws a {@code ConversionException} if an error occurs.
095     */
096    public DateTimeConverter() {
097    }
098
099    /**
100     * Constructs a Date/Time <em>Converter</em> that returns a default value if an error occurs.
101     *
102     * @param defaultValue The default value to be returned if the value to be converted is missing or an error occurs converting the value.
103     */
104    public DateTimeConverter(final D defaultValue) {
105        super(defaultValue);
106    }
107
108    /**
109     * Convert an input Date/Calendar object into a String.
110     * <p>
111     * <strong>N.B.</strong>If the converter has been configured to with one or more patterns (using {@code setPatterns()}), then the first pattern will be used
112     * to format the date into a String. Otherwise the default {@code DateFormat} for the default locale (and <em>style</em> if configured) will be used.
113     *
114     * @param value The input value to be converted
115     * @return the converted String value.
116     * @throws IllegalArgumentException if an error occurs converting to a String
117     */
118    @Override
119    protected String convertToString(final Object value) {
120        Date date = null;
121        if (value instanceof Date) {
122            date = (Date) value;
123        } else if (value instanceof Calendar) {
124            date = ((Calendar) value).getTime();
125        } else if (value instanceof Long) {
126            date = new Date(((Long) value).longValue());
127        } else if (value instanceof LocalDateTime) {
128            date = java.sql.Timestamp.valueOf((LocalDateTime) value);
129        } else if (value instanceof LocalDate) {
130            date = java.sql.Date.valueOf((LocalDate) value);
131        } else if (value instanceof ZonedDateTime) {
132            date = Date.from(((ZonedDateTime) value).toInstant());
133        } else if (value instanceof OffsetDateTime) {
134            date = Date.from(((OffsetDateTime) value).toInstant());
135        } else if (value instanceof TemporalAccessor) {
136            // Backstop for other TemporalAccessor implementations.
137            date = Date.from(Instant.from((TemporalAccessor) value));
138        }
139
140        String result = null;
141        if (useLocaleFormat && date != null) {
142            DateFormat format = null;
143            if (patterns != null && patterns.length > 0) {
144                format = getFormat(patterns[0]);
145            } else {
146                format = getFormat(locale, timeZone);
147            }
148            logFormat("Formatting", format);
149            result = format.format(date);
150            if (log().isDebugEnabled()) {
151                log().debug("    Converted  to String using format '" + result + "'");
152            }
153        } else {
154            result = value.toString();
155            if (log().isDebugEnabled()) {
156                log().debug("    Converted  to String using toString() '" + result + "'");
157            }
158        }
159        return result;
160    }
161
162    /**
163     * Convert the input object into a Date object of the specified type.
164     * <p>
165     * This method handles conversions between the following types:
166     * <ul>
167     * <li>{@link java.util.Date}</li>
168     * <li>{@link java.util.Calendar}</li>
169     * <li>{@link java.time.LocalDate}</li>
170     * <li>{@link java.time.LocalDateTime}</li>
171     * <li>{@link java.time.OffsetDateTime}</li>
172     * <li>{@link java.time.ZonedDateTime}</li>
173     * <li>{@link java.sql.Date}</li>
174     * <li>{@link java.sql.Time}</li>
175     * <li>{@link java.sql.Timestamp}</li>
176     * </ul>
177     *
178     * It also handles conversion from a {@code String} to any of the above types.
179     * <p>
180     *
181     * For {@code String} conversion, if the converter has been configured with one or more patterns (using {@code setPatterns()}), then the conversion is
182     * attempted with each of the specified patterns. Otherwise the default {@code DateFormat} for the default locale (and <em>style</em> if configured) will be
183     * used.
184     *
185     * @param <T>        The desired target type of the conversion.
186     * @param targetType Data type to which this value should be converted.
187     * @param value      The input value to be converted.
188     * @return The converted value.
189     * @throws Exception if conversion cannot be performed successfully
190     */
191    @Override
192    protected <T> T convertToType(final Class<T> targetType, final Object value) throws Exception {
193        final Class<?> sourceType = value.getClass();
194
195        // Handle java.sql.Timestamp
196        if (value instanceof java.sql.Timestamp) {
197
198            // N.B. Prior to JDK 1.4 the Timestamp's getTime() method
199            // didn't include the milliseconds. The following code
200            // ensures it works consistently across JDK versions
201            final java.sql.Timestamp timestamp = (java.sql.Timestamp) value;
202            long timeInMillis = timestamp.getTime() / 1000 * 1000;
203            timeInMillis += timestamp.getNanos() / 1000000;
204
205            return toDate(targetType, timeInMillis);
206        }
207
208        // Handle Date (includes java.sql.Date & java.sql.Time)
209        if (value instanceof Date) {
210            final Date date = (Date) value;
211            return toDate(targetType, date.getTime());
212        }
213
214        // Handle Calendar
215        if (value instanceof Calendar) {
216            final Calendar calendar = (Calendar) value;
217            return toDate(targetType, calendar.getTime().getTime());
218        }
219
220        // Handle Long
221        if (value instanceof Long) {
222            final Long longObj = (Long) value;
223            return toDate(targetType, longObj.longValue());
224        }
225
226        // Handle LocalDate
227        if (value instanceof LocalDate) {
228            final LocalDate date = (LocalDate) value;
229            return toDate(targetType, date.atStartOfDay(getZoneId()).toInstant().toEpochMilli());
230        }
231
232        // Handle LocalDateTime
233        if (value instanceof LocalDateTime) {
234            final LocalDateTime date = (LocalDateTime) value;
235            return toDate(targetType, date.atZone(getZoneId()).toInstant().toEpochMilli());
236        }
237
238        // Handle ZonedDateTime
239        if (value instanceof ZonedDateTime) {
240            final ZonedDateTime date = (ZonedDateTime) value;
241            return toDate(targetType, date.toInstant().toEpochMilli());
242        }
243
244        // Handle OffsetDateTime
245        if (value instanceof OffsetDateTime) {
246            final OffsetDateTime date = (OffsetDateTime) value;
247            return toDate(targetType, date.toInstant().toEpochMilli());
248        }
249
250        // Convert all other types to String & handle
251        final String stringValue = toTrim(value);
252        if (stringValue.isEmpty()) {
253            return handleMissing(targetType);
254        }
255
256        // Parse the Date/Time
257        if (useLocaleFormat) {
258            Calendar calendar = null;
259            if (patterns != null && patterns.length > 0) {
260                calendar = parse(sourceType, targetType, stringValue);
261            } else {
262                final DateFormat format = getFormat(locale, timeZone);
263                calendar = parse(sourceType, targetType, stringValue, format);
264            }
265            if (Calendar.class.isAssignableFrom(targetType)) {
266                return targetType.cast(calendar);
267            }
268            return toDate(targetType, calendar.getTime().getTime());
269        }
270
271        // Default String conversion
272        return toDate(targetType, stringValue);
273    }
274
275    /**
276     * Gets a {@code DateFormat} for the Locale.
277     *
278     * @param locale   The Locale to create the Format with (may be null)
279     * @param timeZone The Time Zone create the Format with (may be null)
280     * @return A Date Format.
281     */
282    protected DateFormat getFormat(final Locale locale, final TimeZone timeZone) {
283        DateFormat format = null;
284        if (locale == null) {
285            format = DateFormat.getDateInstance(DateFormat.SHORT);
286        } else {
287            format = DateFormat.getDateInstance(DateFormat.SHORT, locale);
288        }
289        if (timeZone != null) {
290            format.setTimeZone(timeZone);
291        }
292        return format;
293    }
294
295    /**
296     * Create a date format for the specified pattern.
297     *
298     * @param pattern The date pattern
299     * @return The DateFormat
300     */
301    private DateFormat getFormat(final String pattern) {
302        final DateFormat format = new SimpleDateFormat(pattern);
303        if (timeZone != null) {
304            format.setTimeZone(timeZone);
305        }
306        return format;
307    }
308
309    /**
310     * Gets the Locale for the <em>Converter</em> (or {@code null} if none specified).
311     *
312     * @return The locale to use for conversion
313     */
314    public Locale getLocale() {
315        return locale;
316    }
317
318    /**
319     * Gets the date format patterns used to convert dates to/from a {@link String} (or {@code null} if none specified).
320     *
321     * @see SimpleDateFormat
322     * @return Array of format patterns.
323     */
324    public String[] getPatterns() {
325        return patterns.clone();
326    }
327
328    /**
329     * Gets the Time Zone to use when converting dates (or {@code null} if none specified.
330     *
331     * @return The Time Zone.
332     */
333    public TimeZone getTimeZone() {
334        return timeZone;
335    }
336
337    /**
338     * Gets the {@code java.time.ZoneId</code> from the <code>java.util.Timezone} set or use the system default if no time zone is set.
339     *
340     * @return the {@code ZoneId}
341     */
342    private ZoneId getZoneId() {
343        return timeZone == null ? ZoneId.systemDefault() : timeZone.toZoneId();
344    }
345
346    /**
347     * Log the {@code DateFormat} creation.
348     *
349     * @param action The action the format is being used for
350     * @param format The Date format
351     */
352    private void logFormat(final String action, final DateFormat format) {
353        if (log().isDebugEnabled()) {
354            final StringBuilder buffer = new StringBuilder(45);
355            buffer.append("    ");
356            buffer.append(action);
357            buffer.append(" with Format");
358            if (format instanceof SimpleDateFormat) {
359                buffer.append("[");
360                buffer.append(((SimpleDateFormat) format).toPattern());
361                buffer.append("]");
362            }
363            buffer.append(" for ");
364            if (locale == null) {
365                buffer.append("default locale");
366            } else {
367                buffer.append("locale[");
368                buffer.append(locale);
369                buffer.append("]");
370            }
371            if (timeZone != null) {
372                buffer.append(", TimeZone[");
373                buffer.append(timeZone);
374                buffer.append("]");
375            }
376            log().debug(buffer.toString());
377        }
378    }
379
380    /**
381     * Parse a String date value using the set of patterns.
382     *
383     * @param sourceType The type of the value being converted
384     * @param targetType The type to convert the value to.
385     * @param value      The String date value.
386     * @return The converted Date object.
387     * @throws Exception if an error occurs parsing the date.
388     */
389    private Calendar parse(final Class<?> sourceType, final Class<?> targetType, final String value) throws Exception {
390        Exception firstEx = null;
391        for (final String pattern : patterns) {
392            try {
393                return parse(sourceType, targetType, value, getFormat(pattern));
394            } catch (final Exception ex) {
395                if (firstEx == null) {
396                    firstEx = ex;
397                }
398            }
399        }
400        if (patterns.length > 1) {
401            throw ConversionException.format("Error converting '%s' to '%s' using  patterns '%s'", toString(sourceType), toString(targetType), displayPatterns);
402        }
403        if (firstEx != null) {
404            throw firstEx;
405        }
406        return null;
407    }
408
409    /**
410     * Parse a String into a {@code Calendar} object using the specified {@code DateFormat}.
411     *
412     * @param sourceType The type of the value being converted
413     * @param targetType The type to convert the value to
414     * @param value      The String date value.
415     * @param format     The DateFormat to parse the String value.
416     * @return The converted Calendar object.
417     * @throws ConversionException if the String cannot be converted.
418     */
419    private Calendar parse(final Class<?> sourceType, final Class<?> targetType, final String value, final DateFormat format) {
420        logFormat("Parsing", format);
421        format.setLenient(false);
422        final ParsePosition pos = new ParsePosition(0);
423        final Date parsedDate = format.parse(value, pos); // ignore the result (use the Calendar)
424        final int errorIndex = pos.getErrorIndex();
425        if (errorIndex >= 0 || pos.getIndex() != value.length() || parsedDate == null) {
426            String msg = "Error converting '" + toString(sourceType) + "' to '" + toString(targetType) + "'";
427            if (format instanceof SimpleDateFormat) {
428                final SimpleDateFormat simpleFormat = (SimpleDateFormat) format;
429                msg += String.format(" using pattern '%s', localized pattern '%s', errorIndex %,d, calendar type %s, this %s", simpleFormat.toPattern(),
430                        simpleFormat.toLocalizedPattern(), errorIndex, format.getCalendar().getClass().getSimpleName(), this);
431            }
432            if (log().isDebugEnabled()) {
433                log().debug("    " + msg);
434            }
435            throw new ConversionException(msg);
436        }
437        return format.getCalendar();
438    }
439
440    /**
441     * Sets the Locale for the <em>Converter</em>.
442     *
443     * @param locale The Locale.
444     */
445    public void setLocale(final Locale locale) {
446        this.locale = locale;
447        setUseLocaleFormat(true);
448    }
449
450    /**
451     * Sets a date format pattern to use to convert dates to/from a {@link String}.
452     *
453     * @see SimpleDateFormat
454     * @param pattern The format pattern.
455     */
456    public void setPattern(final String pattern) {
457        setPatterns(new String[] { pattern });
458    }
459
460    /**
461     * Sets the date format patterns to use to convert dates to/from a {@link String}.
462     *
463     * @see SimpleDateFormat
464     * @param patterns Array of format patterns.
465     */
466    public void setPatterns(final String[] patterns) {
467        this.patterns = patterns != null ? patterns.clone() : null;
468        if (this.patterns != null && this.patterns.length > 1) {
469            displayPatterns = String.join(", ", this.patterns);
470        }
471        setUseLocaleFormat(true);
472    }
473
474    /**
475     * Sets the Time Zone to use when converting dates.
476     *
477     * @param timeZone The Time Zone.
478     */
479    public void setTimeZone(final TimeZone timeZone) {
480        this.timeZone = timeZone;
481    }
482
483    /**
484     * Indicate whether conversion should use a format/pattern or not.
485     *
486     * @param useLocaleFormat {@code true} if the format for the locale should be used, otherwise {@code false}
487     */
488    public void setUseLocaleFormat(final boolean useLocaleFormat) {
489        this.useLocaleFormat = useLocaleFormat;
490    }
491
492    /**
493     * Convert a long value to the specified Date type for this <em>Converter</em>.
494     * <p>
495     *
496     * This method handles conversion to the following types:
497     * <ul>
498     * <li>{@link java.util.Date}</li>
499     * <li>{@link java.util.Calendar}</li>
500     * <li>{@link java.time.LocalDate}</li>
501     * <li>{@link java.time.LocalDateTime}</li>
502     * <li>{@link java.time.ZonedDateTime}</li>
503     * <li>{@link java.sql.Date}</li>
504     * <li>{@link java.sql.Time}</li>
505     * <li>{@link java.sql.Timestamp}</li>
506     * </ul>
507     *
508     * @param <T>   The target type
509     * @param type  The Date type to convert to
510     * @param value The long value to convert.
511     * @return The converted date value.
512     */
513    private <T> T toDate(final Class<T> type, final long value) {
514        // java.util.Date
515        if (type.equals(Date.class)) {
516            return type.cast(new Date(value));
517        }
518
519        // java.sql.Date
520        if (type.equals(java.sql.Date.class)) {
521            return type.cast(new java.sql.Date(value));
522        }
523
524        // java.sql.Time
525        if (type.equals(java.sql.Time.class)) {
526            return type.cast(new java.sql.Time(value));
527        }
528
529        // java.sql.Timestamp
530        if (type.equals(java.sql.Timestamp.class)) {
531            return type.cast(new java.sql.Timestamp(value));
532        }
533
534        // java.time.LocalDateTime
535        if (type.equals(LocalDate.class)) {
536            final LocalDate localDate = Instant.ofEpochMilli(value).atZone(getZoneId()).toLocalDate();
537            return type.cast(localDate);
538        }
539
540        // java.time.LocalDateTime
541        if (type.equals(LocalDateTime.class)) {
542            final LocalDateTime localDateTime = Instant.ofEpochMilli(value).atZone(getZoneId()).toLocalDateTime();
543            return type.cast(localDateTime);
544        }
545
546        // java.time.ZonedDateTime
547        if (type.equals(ZonedDateTime.class)) {
548            final ZonedDateTime zonedDateTime = ZonedDateTime.ofInstant(Instant.ofEpochMilli(value), getZoneId());
549            return type.cast(zonedDateTime);
550        }
551
552        // java.time.OffsetDateTime
553        if (type.equals(OffsetDateTime.class)) {
554            final OffsetDateTime offsetDateTime = OffsetDateTime.ofInstant(Instant.ofEpochMilli(value), getZoneId());
555            return type.cast(offsetDateTime);
556        }
557
558        // java.util.Calendar
559        if (type.equals(Calendar.class)) {
560            Calendar calendar = null;
561            if (locale == null && timeZone == null) {
562                calendar = Calendar.getInstance();
563            } else if (locale == null) {
564                calendar = Calendar.getInstance(timeZone);
565            } else if (timeZone == null) {
566                calendar = Calendar.getInstance(locale);
567            } else {
568                calendar = Calendar.getInstance(timeZone, locale);
569            }
570            calendar.setTime(new Date(value));
571            calendar.setLenient(false);
572            return type.cast(calendar);
573        }
574
575        final String msg = toString(getClass()) + " cannot handle conversion to '" + toString(type) + "'";
576        if (log().isWarnEnabled()) {
577            log().warn("    " + msg);
578        }
579        throw new ConversionException(msg);
580    }
581
582    /**
583     * Default String to Date conversion.
584     * <p>
585     * This method handles conversion from a String to the following types:
586     * <ul>
587     * <li>{@link java.sql.Date}</li>
588     * <li>{@link java.sql.Time}</li>
589     * <li>{@link java.sql.Timestamp}</li>
590     * </ul>
591     * <p>
592     * <strong>N.B.</strong> No default String conversion mechanism is provided for {@link java.util.Date} and {@link java.util.Calendar} type.
593     *
594     * @param <T>   The target type
595     * @param type  The date type to convert to
596     * @param value The String value to convert.
597     * @return The converted Number value.
598     */
599    private <T> T toDate(final Class<T> type, final String value) {
600        // java.sql.Date
601        if (type.equals(java.sql.Date.class)) {
602            try {
603                return type.cast(java.sql.Date.valueOf(value));
604            } catch (final IllegalArgumentException e) {
605                throw new ConversionException("String must be in JDBC format [yyyy-MM-dd] to create a java.sql.Date");
606            }
607        }
608
609        // java.sql.Time
610        if (type.equals(java.sql.Time.class)) {
611            try {
612                return type.cast(java.sql.Time.valueOf(value));
613            } catch (final IllegalArgumentException e) {
614                throw new ConversionException("String must be in JDBC format [HH:mm:ss] to create a java.sql.Time");
615            }
616        }
617
618        // java.sql.Timestamp
619        if (type.equals(java.sql.Timestamp.class)) {
620            try {
621                return type.cast(java.sql.Timestamp.valueOf(value));
622            } catch (final IllegalArgumentException e) {
623                throw new ConversionException("String must be in JDBC format [yyyy-MM-dd HH:mm:ss.fffffffff] " + "to create a java.sql.Timestamp");
624            }
625        }
626
627        final String msg = toString(getClass()) + " does not support default String to '" + toString(type) + "' conversion.";
628        if (log().isWarnEnabled()) {
629            log().warn("    " + msg);
630            log().warn("    (N.B. Re-configure Converter or use alternative implementation)");
631        }
632        throw new ConversionException(msg);
633    }
634
635    /**
636     * Provide a String representation of this date/time converter.
637     *
638     * @return A String representation of this date/time converter
639     */
640    @Override
641    public String toString() {
642        final StringBuilder buffer = new StringBuilder();
643        buffer.append(toString(getClass()));
644        buffer.append("[UseDefault=");
645        buffer.append(isUseDefault());
646        buffer.append(", UseLocaleFormat=");
647        buffer.append(useLocaleFormat);
648        if (displayPatterns != null) {
649            buffer.append(", Patterns={");
650            buffer.append(displayPatterns);
651            buffer.append('}');
652        }
653        if (locale != null) {
654            buffer.append(", Locale=");
655            buffer.append(locale);
656        }
657        if (timeZone != null) {
658            buffer.append(", TimeZone=");
659            buffer.append(timeZone);
660        }
661        buffer.append(']');
662        return buffer.toString();
663    }
664}