blob: efb06912ddccbc06dde8aff44cf5bd9ff2ccb203 [file] [log] [blame]
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.solr.util;
import java.text.ParseException;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeFormatterBuilder;
import java.time.format.DateTimeParseException;
import java.time.temporal.ChronoUnit;
import java.util.Calendar;
import java.util.Date;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import java.util.TimeZone;
import java.util.regex.Pattern;
import org.apache.solr.common.SolrException;
import org.apache.solr.common.params.CommonParams;
import org.apache.solr.request.SolrRequestInfo;
/**
* A Simple Utility class for parsing "math" like strings relating to Dates.
*
* <p>
* The basic syntax support addition, subtraction and rounding at various
* levels of granularity (or "units"). Commands can be chained together
* and are parsed from left to right. '+' and '-' denote addition and
* subtraction, while '/' denotes "round". Round requires only a unit, while
* addition/subtraction require an integer value and a unit.
* Command strings must not include white space, but the "No-Op" command
* (empty string) is allowed....
* </p>
*
* <pre>
* /HOUR
* ... Round to the start of the current hour
* /DAY
* ... Round to the start of the current day
* +2YEARS
* ... Exactly two years in the future from now
* -1DAY
* ... Exactly 1 day prior to now
* /DAY+6MONTHS+3DAYS
* ... 6 months and 3 days in the future from the start of
* the current day
* +6MONTHS+3DAYS/DAY
* ... 6 months and 3 days in the future from now, rounded
* down to nearest day
* </pre>
*
* <p>
* (Multiple aliases exist for the various units of time (ie:
* <code>MINUTE</code> and <code>MINUTES</code>; <code>MILLI</code>,
* <code>MILLIS</code>, <code>MILLISECOND</code>, and
* <code>MILLISECONDS</code>.) The complete list can be found by
* inspecting the keySet of {@link #CALENDAR_UNITS})
* </p>
*
* <p>
* All commands are relative to a "now" which is fixed in an instance of
* DateMathParser such that
* <code>p.parseMath("+0MILLISECOND").equals(p.parseMath("+0MILLISECOND"))</code>
* no matter how many wall clock milliseconds elapse between the two
* distinct calls to parse (Assuming no other thread calls
* "<code>setNow</code>" in the interim). The default value of 'now' is
* the time at the moment the <code>DateMathParser</code> instance is
* constructed, unless overridden by the {@link CommonParams#NOW NOW}
* request param.
* </p>
*
* <p>
* All commands are also affected to the rules of a specified {@link TimeZone}
* (including the start/end of DST if any) which determine when each arbitrary
* day starts. This not only impacts rounding/adding of DAYs, but also
* cascades to rounding of HOUR, MIN, MONTH, YEAR as well. The default
* <code>TimeZone</code> used is <code>UTC</code> unless overridden by the
* {@link CommonParams#TZ TZ}
* request param.
* </p>
*
* <p>
* Historical dates: The calendar computation is completely done with the
* Gregorian system/algorithm. It does <em>not</em> switch to Julian or
* anything else, unlike the default {@link java.util.GregorianCalendar}.
* </p>
* @see SolrRequestInfo#getClientTimeZone
* @see SolrRequestInfo#getNOW
*/
public class DateMathParser {
public static final TimeZone UTC = TimeZone.getTimeZone("UTC");
/** Default TimeZone for DateMath rounding (UTC) */
public static final TimeZone DEFAULT_MATH_TZ = UTC;
/**
* Differs by {@link DateTimeFormatter#ISO_INSTANT} in that it's lenient.
* @see #parseNoMath(String)
*/
public static final DateTimeFormatter PARSER = new DateTimeFormatterBuilder()
.parseCaseInsensitive().parseLenient().appendInstant().toFormatter(Locale.ROOT);
/**
* A mapping from (uppercased) String labels identifying time units,
* to the corresponding {@link ChronoUnit} enum (e.g. "YEARS") used to
* set/add/roll that unit of measurement.
*
* <p>
* A single logical unit of time might be represented by multiple labels
* for convenience (ie: <code>DATE==DAYS</code>,
* <code>MILLI==MILLIS</code>)
* </p>
*
* @see Calendar
*/
public static final Map<String,ChronoUnit> CALENDAR_UNITS = makeUnitsMap();
/** @see #CALENDAR_UNITS */
private static Map<String,ChronoUnit> makeUnitsMap() {
// NOTE: consciously choosing not to support WEEK at this time,
// because of complexity in rounding down to the nearest week
// around a month/year boundary.
// (Not to mention: it's not clear what people would *expect*)
//
// If we consider adding some time of "week" support, then
// we probably need to change "Locale loc" to default to something
// from a param via SolrRequestInfo as well.
Map<String,ChronoUnit> units = new HashMap<>(13);
units.put("YEAR", ChronoUnit.YEARS);
units.put("YEARS", ChronoUnit.YEARS);
units.put("MONTH", ChronoUnit.MONTHS);
units.put("MONTHS", ChronoUnit.MONTHS);
units.put("DAY", ChronoUnit.DAYS);
units.put("DAYS", ChronoUnit.DAYS);
units.put("DATE", ChronoUnit.DAYS);
units.put("HOUR", ChronoUnit.HOURS);
units.put("HOURS", ChronoUnit.HOURS);
units.put("MINUTE", ChronoUnit.MINUTES);
units.put("MINUTES", ChronoUnit.MINUTES);
units.put("SECOND", ChronoUnit.SECONDS);
units.put("SECONDS", ChronoUnit.SECONDS);
units.put("MILLI", ChronoUnit.MILLIS);
units.put("MILLIS", ChronoUnit.MILLIS);
units.put("MILLISECOND", ChronoUnit.MILLIS);
units.put("MILLISECONDS",ChronoUnit.MILLIS);
// NOTE: Maybe eventually support NANOS
return units;
}
/**
* Returns a modified time by "adding" the specified value of units
*
* @exception IllegalArgumentException if unit isn't recognized.
* @see #CALENDAR_UNITS
*/
private static LocalDateTime add(LocalDateTime t, int val, String unit) {
ChronoUnit uu = CALENDAR_UNITS.get(unit);
if (null == uu) {
throw new IllegalArgumentException("Adding Unit not recognized: "
+ unit);
}
return t.plus(val, uu);
}
/**
* Returns a modified time by "rounding" down to the specified unit
*
* @exception IllegalArgumentException if unit isn't recognized.
* @see #CALENDAR_UNITS
*/
private static LocalDateTime round(LocalDateTime t, String unit) {
ChronoUnit uu = CALENDAR_UNITS.get(unit);
if (null == uu) {
throw new IllegalArgumentException("Rounding Unit not recognized: "
+ unit);
}
// note: OffsetDateTime.truncatedTo does not support >= DAYS units so we handle those
switch (uu) {
case YEARS:
return LocalDateTime.of(LocalDate.of(t.getYear(), 1, 1), LocalTime.MIDNIGHT); // midnight is 00:00:00
case MONTHS:
return LocalDateTime.of(LocalDate.of(t.getYear(), t.getMonth(), 1), LocalTime.MIDNIGHT);
case DAYS:
return LocalDateTime.of(t.toLocalDate(), LocalTime.MIDNIGHT);
default:
assert !uu.isDateBased();// >= DAY
return t.truncatedTo(uu);
}
}
/**
* Parses a String which may be a date (in the standard ISO-8601 format)
* followed by an optional math expression.
* The TimeZone is taken from the {@code TZ} param retrieved via {@link SolrRequestInfo}, defaulting to UTC.
* @param now an optional fixed date to use as "NOW". {@link SolrRequestInfo} is consulted if unspecified.
* @param val the string to parse
*/
//TODO this API is a bit clumsy. "now" is rarely used.
public static Date parseMath(Date now, String val) {
return parseMath(now, val, null);
}
/**
* Parses a String which may be a date (in the standard ISO-8601 format)
* followed by an optional math expression.
* @param now an optional fixed date to use as "NOW"
* @param val the string to parse
* @param zone the timezone to use
*/
public static Date parseMath(Date now, String val, TimeZone zone) {
String math;
final DateMathParser p = new DateMathParser(zone);
if (null != now) p.setNow(now);
if (val.startsWith("NOW")) {
math = val.substring("NOW".length());
} else {
final int zz = val.indexOf('Z');
if (zz == -1) {
throw new SolrException(SolrException.ErrorCode.BAD_REQUEST,
"Invalid Date String:'" + val + '\'');
}
math = val.substring(zz+1);
try {
p.setNow(parseNoMath(val.substring(0, zz + 1)));
} catch (DateTimeParseException e) {
throw new SolrException(SolrException.ErrorCode.BAD_REQUEST,
"Invalid Date in Date Math String:'" + val + '\'', e);
}
}
if (null == math || math.equals("")) {
return p.getNow();
}
try {
return p.parseMath(math);
} catch (ParseException e) {
throw new SolrException(SolrException.ErrorCode.BAD_REQUEST,
"Invalid Date Math String:'" +val+'\'',e);
}
}
/**
* Parsing Solr dates <b>without DateMath</b>.
* This is the standard/pervasive ISO-8601 UTC format but is configured with some leniency.
*
* Callers should almost always call {@link #parseMath(Date, String)} instead.
*
* @throws DateTimeParseException if it can't parse
*/
private static Date parseNoMath(String val) {
//TODO write the equivalent of a Date::from; avoids Instant -> Date
return new Date(PARSER.parse(val, Instant::from).toEpochMilli());
}
private TimeZone zone;
private Locale loc;
private Date now;
/**
* Chooses defaults based on the current request.
* @see SolrRequestInfo#getClientTimeZone
* @see SolrRequestInfo#getNOW()
*/
public DateMathParser() {
this(null, null);
}
//TODO Deprecate?
public DateMathParser(TimeZone tz) {
this(null, tz);
}
/**
* @param now The current time. If null, it defaults to {@link SolrRequestInfo#getNOW()}.
* otherwise the current time is assumed.
* @param tz The TimeZone used for rounding (to determine when hours/days begin). If null, then this method defaults
* to the value dictated by the SolrRequestInfo if it exists -- otherwise it uses UTC.
* @see #DEFAULT_MATH_TZ
* @see Calendar#getInstance(TimeZone,Locale)
* @see SolrRequestInfo#getClientTimeZone
*/
public DateMathParser(Date now, TimeZone tz) {
this.now = now;// potentially null; it's okay
if (null == tz) {
SolrRequestInfo reqInfo = SolrRequestInfo.getRequestInfo();
tz = (null != reqInfo) ? reqInfo.getClientTimeZone() : DEFAULT_MATH_TZ;
}
this.zone = (null != tz) ? tz : DEFAULT_MATH_TZ;
}
/**
* @return the time zone
*/
public TimeZone getTimeZone() {
return this.zone;
}
/**
* Defines this instance's concept of "now".
* @see #getNow
*/
public void setNow(Date n) {
now = n;
}
/**
* Returns a clone of this instance's concept of "now" (never null).
*
* If setNow was never called (or if null was specified) then this method
* first defines 'now' as the value dictated by the SolrRequestInfo if it
* exists -- otherwise it uses a new Date instance at the moment getNow()
* is first called.
* @see #setNow
* @see SolrRequestInfo#getNOW
*/
public Date getNow() {
if (now == null) {
SolrRequestInfo reqInfo = SolrRequestInfo.getRequestInfo();
if (reqInfo == null) {
// fall back to current time if no request info set
now = new Date();
} else {
now = reqInfo.getNOW(); // never null
}
}
return (Date) now.clone();
}
/**
* Parses a string of commands relative "now" are returns the resulting Date.
*
* @exception ParseException positions in ParseExceptions are token positions, not character positions.
*/
public Date parseMath(String math) throws ParseException {
/* check for No-Op */
if (0==math.length()) {
return getNow();
}
ZoneId zoneId = zone.toZoneId();
// localDateTime is a date and time local to the timezone specified
LocalDateTime localDateTime = ZonedDateTime.ofInstant(getNow().toInstant(), zoneId).toLocalDateTime();
String[] ops = splitter.split(math);
int pos = 0;
while ( pos < ops.length ) {
if (1 != ops[pos].length()) {
throw new ParseException
("Multi character command found: \"" + ops[pos] + "\"", pos);
}
char command = ops[pos++].charAt(0);
switch (command) {
case '/':
if (ops.length < pos + 1) {
throw new ParseException
("Need a unit after command: \"" + command + "\"", pos);
}
try {
localDateTime = round(localDateTime, ops[pos++]);
} catch (IllegalArgumentException e) {
throw new ParseException
("Unit not recognized: \"" + ops[pos-1] + "\"", pos-1);
}
break;
case '+': /* fall through */
case '-':
if (ops.length < pos + 2) {
throw new ParseException
("Need a value and unit for command: \"" + command + "\"", pos);
}
int val = 0;
try {
val = Integer.parseInt(ops[pos++]);
} catch (NumberFormatException e) {
throw new ParseException
("Not a Number: \"" + ops[pos-1] + "\"", pos-1);
}
if ('-' == command) {
val = 0 - val;
}
try {
String unit = ops[pos++];
localDateTime = add(localDateTime, val, unit);
} catch (IllegalArgumentException e) {
throw new ParseException
("Unit not recognized: \"" + ops[pos-1] + "\"", pos-1);
}
break;
default:
throw new ParseException
("Unrecognized command: \"" + command + "\"", pos-1);
}
}
return Date.from(ZonedDateTime.of(localDateTime, zoneId).toInstant());
}
private static Pattern splitter = Pattern.compile("\\b|(?<=\\d)(?=\\D)");
}