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}