001/*
002 * Licensed under the Apache License, Version 2.0 (the "License");
003 * you may not use this file except in compliance with the License.
004 * You may obtain a copy of the License at
005 *
006 *     http://www.apache.org/licenses/LICENSE-2.0
007 *
008 * Unless required by applicable law or agreed to in writing, software
009 * distributed under the License is distributed on an "AS IS" BASIS,
010 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
011 * See the License for the specific language governing permissions and
012 * limitations under the License.
013 */
014package org.gbif.api.util;
015
016import java.text.ParseException;
017import java.time.DateTimeException;
018import java.time.LocalDate;
019import java.time.LocalDateTime;
020import java.time.OffsetDateTime;
021import java.time.Year;
022import java.time.YearMonth;
023import java.time.ZoneOffset;
024import java.time.chrono.IsoChronology;
025import java.time.format.DateTimeFormatter;
026import java.time.format.DateTimeFormatterBuilder;
027import java.time.format.DateTimeParseException;
028import java.time.format.ResolverStyle;
029import java.time.format.SignStyle;
030import java.time.temporal.ChronoField;
031import java.time.temporal.Temporal;
032import java.time.temporal.TemporalAccessor;
033import java.util.regex.Pattern;
034
035import org.apache.commons.lang3.StringUtils;
036
037import static org.gbif.api.model.common.search.SearchConstants.QUERY_WILDCARD;
038
039/**
040 * Utility class that parses date values, the allowed date formats are: "yyyy-MM-dd", "yyyy-MM" or "yyyy".
041 *
042 * Note: Does not handle times or time zones.  This is used for search queries/predicates with date ranges.
043 * Date ranges are closed at the lower bound, and open at the upper bound, to allow less-than predicates
044 * to match times on the upper bound date (i.e. after midnight).
045 */
046public class IsoDateParsingUtils {
047
048  /**
049   * A year, or a year-month, or a year-month-day, or a year-month-day-hour-minute, or a year-month-day-hour-minute-second,
050   * or a year-month-day-hour-minute-second-fraction — all with an optional timezone.
051   */
052  private static final DateTimeFormatter DATE_TIME_PATTERN =
053    DateTimeFormatter.ofPattern("[yyyy[-MM[-dd['T'HH:mm[:ss[.SSSSSSSSS][.SSSSSSSS][.SSSSSSS][.SSSSSS][.SSSSS][.SSSS][.SSS][.SS][.S]]]]][XXXXX][XXXX][XXX][XX][X]]");
054
055  public static final String SIMPLE_ISO_DATE_STR_PATTERN = "\\d{4}(?:-\\d{1,2}(?:-\\d{1,2})?)?";
056
057  // match formats 'yyyy', 'yyyy-MM', 'yyyy-M', 'yyyy-MM-dd', 'yyyy-MM-d' and 'yyyy-M-d'.
058  public static final Pattern SIMPLE_ISO_PATTERN = Pattern.compile(SIMPLE_ISO_DATE_STR_PATTERN);
059
060  // Format as yyyy-MM-dd.
061  public static final DateTimeFormatter ISO_DATE_FORMATTER = DateTimeFormatter.ISO_LOCAL_DATE.withZone(ZoneOffset.UTC);
062
063  /**
064   * Enumerations with the allowed date formats by the occurrence search service.
065   */
066  public enum IsoDateFormat {
067    YEAR_MONTH_DAY(new DateTimeFormatterBuilder()
068      .appendValue(ChronoField.YEAR, 4, 10, SignStyle.EXCEEDS_PAD)
069      .appendLiteral('-')
070      .appendValue(ChronoField.MONTH_OF_YEAR, 1, 2, SignStyle.NEVER)
071      .appendLiteral('-')
072      .appendValue(ChronoField.DAY_OF_MONTH, 1, 2, SignStyle.NEVER)
073      .toFormatter()),
074    YEAR_MONTH(new DateTimeFormatterBuilder()
075      .appendValue(ChronoField.YEAR, 4, 10, SignStyle.EXCEEDS_PAD)
076      .appendLiteral('-')
077      .appendValue(ChronoField.MONTH_OF_YEAR, 1, 2, SignStyle.NEVER)
078      .toFormatter()),
079    YEAR(new DateTimeFormatterBuilder()
080      .appendValue(ChronoField.YEAR, 4, 10, SignStyle.EXCEEDS_PAD)
081      .toFormatter());
082
083    private final DateTimeFormatter dateFormatter;
084
085    /**
086     * Private constructors.
087     * Receives a pattern and creates a strict DateTimeFormatter.
088     */
089    IsoDateFormat(DateTimeFormatter dateFormatter) {
090      this.dateFormatter = dateFormatter
091        .withZone(ZoneOffset.UTC)
092        .withResolverStyle(ResolverStyle.STRICT)
093        .withChronology(IsoChronology.INSTANCE);
094    }
095
096    /**
097     * Checks if the parameter "value" can be parsed using the date format.
098     */
099    public boolean isValidDate(String value) {
100      try {
101        return (parseDate(value) != null);
102      } catch (ParseException e) {
103        return false;
104      }
105    }
106
107    /**
108     * Try to parse a string with the current date format.
109     */
110    public TemporalAccessor parseDate(String value) throws ParseException {
111      if (QUERY_WILDCARD.equals(value)) {
112        return null;
113      }
114
115      try {
116        switch (this) {
117          case YEAR_MONTH_DAY:
118            return dateFormatter.parse(value, LocalDate::from);
119
120          case YEAR_MONTH:
121            return dateFormatter.parse(value, YearMonth::from);
122
123          case YEAR:
124            return dateFormatter.parse(value, Year::from);
125        }
126      } catch (DateTimeException e) {
127        throw new ParseException(value + " is not a valid date", 0);
128      }
129      throw new ParseException(value + " is not a valid date", 0);
130    }
131
132    /**
133     * Returns the earliest date of a possible closed range, e.g. 2000-01-01 for 2000.
134     */
135    public LocalDate earliestDate(String value) throws ParseException {
136      if (QUERY_WILDCARD.equals(value)) {
137        return null;
138      }
139
140      TemporalAccessor ta = parseDate(value);
141      switch (this) {
142        case YEAR_MONTH_DAY:
143          return LocalDate.from(ta);
144
145        case YEAR_MONTH:
146          YearMonth yearMonth = YearMonth.from(parseDate(value));
147          return yearMonth.atDay(1);
148
149        case YEAR:
150          Year year = Year.from(parseDate(value));
151          return year.atDay(1);
152      }
153
154      throw new ParseException(value + " is not a valid date", 0);
155    }
156
157    /**
158     * Returns the latest date of a possible open range, e.g. 2001-01-01 for 2000.
159     */
160    public LocalDate latestDate(String value) throws ParseException {
161      if (QUERY_WILDCARD.equals(value)) {
162        return null;
163      }
164
165      TemporalAccessor ta = parseDate(value);
166      switch (this) {
167        case YEAR_MONTH_DAY:
168          return LocalDate.from(ta).plusDays(1);
169
170        case YEAR_MONTH:
171          YearMonth yearMonth = YearMonth.from(parseDate(value));
172          return yearMonth.atEndOfMonth().plusDays(1);
173
174        case YEAR:
175          Year year = Year.from(parseDate(value));
176          return year.atDay(year.isLeap() ? 366 : 365).plusDays(1);
177      }
178
179      throw new ParseException(value + " is not a valid date", 0);
180    }
181  }
182
183  /**
184   * Default private constructor.
185   */
186  private IsoDateParsingUtils() {
187    // empty block
188  }
189
190  /**
191   * Iterates over all the OccurrenceDateFormat's and returns the first one that parses successfully the value.
192   * @throws IllegalArgumentException in case of unparsable dates
193   */
194  public static IsoDateFormat getFirstDateFormatMatch(String value) throws IllegalArgumentException {
195    // 4 digits for a year must exist
196    if (SIMPLE_ISO_PATTERN.matcher(value).find()) {
197      for (IsoDateFormat dateFormat : IsoDateFormat.values()) {
198        if (dateFormat.isValidDate(value)) {
199          return dateFormat;
200        }
201      }
202    }
203    throw new IllegalArgumentException(value + " is not a valid date");
204  }
205
206  /**
207   * Parses an ISO 8601-format date or date-time.
208   */
209  public static Temporal parseTemporal(String value) {
210    if (QUERY_WILDCARD.equals(value)) {
211      return null;
212    }
213
214    if (value == null || value.isEmpty()) {
215      return null;
216    }
217
218    // parse string
219    return (Temporal) DATE_TIME_PATTERN.parseBest(
220      value,
221      OffsetDateTime::from,
222      LocalDateTime::from,
223      LocalDate::from,
224      YearMonth::from,
225      Year::from);
226  }
227
228  /**
229   * Parses a single date value. The date is interpreted using the first available date format that successfully parses
230   * the value.
231   * @return the lowest possible date according to the precision given or null in case of *
232   * @throws IllegalArgumentException in case of unparsable dates
233   */
234  public static LocalDate parseDate(String value) {
235    if (StringUtils.isEmpty(value)) {
236      throw new IllegalArgumentException("Date parameter can't be null or empty");
237    }
238
239    // could be a wildcard
240    if (QUERY_WILDCARD.equals(value)) {
241      return null;
242    }
243
244    try {
245      return getFirstDateFormatMatch(value).earliestDate(value);
246    } catch (DateTimeParseException | ParseException e) {
247      throw new IllegalArgumentException(String.format("%s is not a valid date parameter", value));
248    }
249  }
250
251  /**
252   * Parses a closed-open range of dates, using the most specific format for each side of the range.
253   * Returns null for an unbounded side of the range (*).
254   */
255  public static Range<LocalDate> parseDateRange(String value) {
256    if (StringUtils.isEmpty(value)) {
257      throw new IllegalArgumentException("Date parameter can't be null or empty");
258    }
259    final String[] dateValues = value.split(",");
260
261    if (dateValues.length == 1) {
262      try {
263        final LocalDate lowerDate = parseDate(dateValues[0]);
264        final LocalDate upperDate = getFirstDateFormatMatch(dateValues[0]).latestDate(dateValues[0]);
265
266        return Range.closed(lowerDate, upperDate);
267      } catch (ParseException e) {
268        throw new IllegalArgumentException(String.format("%s is not a valid date parameter", value));
269      }
270    }
271
272    if (dateValues.length == 2) {
273      try {
274        final LocalDate lowerDate = parseDate(dateValues[0]);
275        LocalDate upperDate = parseDate(dateValues[1]);
276
277        if (upperDate != null) {
278          upperDate = getFirstDateFormatMatch(dateValues[1]).latestDate(dateValues[1]);
279        }
280
281        return Range.closed(lowerDate, upperDate);
282      } catch (ParseException e) {
283        throw new IllegalArgumentException(String.format("%s is not a valid date parameter", value));
284      }
285    }
286
287    throw new IllegalArgumentException("Date value must be a single value or a range");
288  }
289
290  /**
291   * Remove the offset, either ignoring it or using it to adjust the time.
292   */
293  public static TemporalAccessor stripOffsetOrZone(TemporalAccessor temporalAccessor, boolean ignoreOffset) {
294    if (temporalAccessor == null) {
295      return null;
296    } else if (!ignoreOffset && temporalAccessor.isSupported(ChronoField.OFFSET_SECONDS)) {
297      return (temporalAccessor.query(OffsetDateTime::from)).atZoneSameInstant(ZoneOffset.UTC).toLocalDateTime();
298    } else {
299      return temporalAccessor.isSupported(ChronoField.SECOND_OF_DAY) ? temporalAccessor.query(LocalDateTime::from) : temporalAccessor;
300    }
301  }
302
303  /**
304   * Remove the offset, either ignoring it or using it to adjust the time, unless the offset is 0 (UTC).
305   */
306  public static TemporalAccessor stripOffsetOrZoneExceptUTC(TemporalAccessor temporalAccessor, boolean ignoreOffset) {
307    if (temporalAccessor == null) {
308      return null;
309    } else if (temporalAccessor.isSupported(ChronoField.OFFSET_SECONDS) && temporalAccessor.get(ChronoField.OFFSET_SECONDS) == 0) {
310      return (temporalAccessor.query(OffsetDateTime::from)).atZoneSameInstant(ZoneOffset.UTC);
311    } else {
312      return stripOffsetOrZone(temporalAccessor, ignoreOffset);
313    }
314  }
315}