/**
* 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.util.Date;
import java.util.Calendar;
import java.util.TimeZone;
import java.util.Locale;
import java.util.Map;
import java.util.HashMap;
import java.text.ParseException;
import java.util.regex.Pattern;
/**
* 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>
* 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)
* </p>
*
* <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 <code>CALENDAR_UNITS</code>.
* </p>
*
* @version $Id:$
*/
public class DateMathParser {
/**
* A mapping from (uppercased) String labels idenyifying time units,
* to the corresponding Calendar constant 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==DAY</code>,
* <code>MILLI==MILLISECOND</code>)
* </p>
*
* @see Calendar
*/
public static final Map<String,Integer> CALENDAR_UNITS = makeUnitsMap();
/** @see #CALENDAR_UNITS */
private static Map<String,Integer> makeUnitsMap() {
// NOTE: consciously choosing not to support WEEK at this time,
// because of complexity in rounding down to the nearest week
// arround a month/year boundry.
// (Not to mention: it's not clear what people would *expect*)
Map<String,Integer> units = new HashMap<String,Integer>(13);
units.put("YEAR", Calendar.YEAR);
units.put("YEARS", Calendar.YEAR);
units.put("MONTH", Calendar.MONTH);
units.put("MONTHS", Calendar.MONTH);
units.put("DAY", Calendar.DATE);
units.put("DAYS", Calendar.DATE);
units.put("DATE", Calendar.DATE);
units.put("HOUR", Calendar.HOUR_OF_DAY);
units.put("HOURS", Calendar.HOUR_OF_DAY);
units.put("MINUTE", Calendar.MINUTE);
units.put("MINUTES", Calendar.MINUTE);
units.put("SECOND", Calendar.SECOND);
units.put("SECONDS", Calendar.SECOND);
units.put("MILLI", Calendar.MILLISECOND);
units.put("MILLIS", Calendar.MILLISECOND);
units.put("MILLISECOND", Calendar.MILLISECOND);
units.put("MILLISECONDS",Calendar.MILLISECOND);
return units;
}
/**
* Modifies the specified Calendar by "adding" the specified value of units
*
* @exception IllegalArgumentException if unit isn't recognized.
* @see #CALENDAR_UNITS
*/
public static void add(Calendar c, int val, String unit) {
Integer uu = CALENDAR_UNITS.get(unit);
if (null == uu) {
throw new IllegalArgumentException("Adding Unit not recognized: "
+ unit);
}
c.add(uu.intValue(), val);
}
/**
* Modifies the specified Calendar by "rounding" down to the specified unit
*
* @exception IllegalArgumentException if unit isn't recognized.
* @see #CALENDAR_UNITS
*/
public static void round(Calendar c, String unit) {
Integer uu = CALENDAR_UNITS.get(unit);
if (null == uu) {
throw new IllegalArgumentException("Rounding Unit not recognized: "
+ unit);
}
int u = uu.intValue();
switch (u) {
case Calendar.YEAR:
c.clear(Calendar.MONTH);
/* fall through */
case Calendar.MONTH:
c.clear(Calendar.DAY_OF_MONTH);
c.clear(Calendar.DAY_OF_WEEK);
c.clear(Calendar.DAY_OF_WEEK_IN_MONTH);
c.clear(Calendar.DAY_OF_YEAR);
c.clear(Calendar.WEEK_OF_MONTH);
c.clear(Calendar.WEEK_OF_YEAR);
/* fall through */
case Calendar.DATE:
c.clear(Calendar.HOUR_OF_DAY);
c.clear(Calendar.HOUR);
c.clear(Calendar.AM_PM);
/* fall through */
case Calendar.HOUR_OF_DAY:
c.clear(Calendar.MINUTE);
/* fall through */
case Calendar.MINUTE:
c.clear(Calendar.SECOND);
/* fall through */
case Calendar.SECOND:
c.clear(Calendar.MILLISECOND);
break;
default:
throw new IllegalStateException
("No logic for rounding value ("+u+") " + unit);
}
}
private TimeZone zone;
private Locale loc;
private Date now;
/**
* @param tz The TimeZone used for rounding (to determine when hours/days begin)
* @param l The Locale used for rounding (to determine when weeks begin)
* @see Calendar#getInstance(TimeZone,Locale)
*/
public DateMathParser(TimeZone tz, Locale l) {
zone = tz;
loc = l;
setNow(new Date());
}
/** Redefines this instance's concept of "now" */
public void setNow(Date n) {
now = n;
}
/** Returns a cloned of this instance's concept of "now" */
public Date getNow() {
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 {
Calendar cal = Calendar.getInstance(zone, loc);
cal.setTime(getNow());
/* check for No-Op */
if (0==math.length()) {
return cal.getTime();
}
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 {
round(cal, 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.valueOf(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++];
add(cal, 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 cal.getTime();
}
private static Pattern splitter = Pattern.compile("\\b|(?<=\\d)(?=\\D)");
}