JodaTime and Solr Date Math

Let me be blunt: If you’re still using java.util.Calendar et al. in your Java code you just failed the code review. JodaTime is a compelling replacement for the original JDK date/time classes in pretty much every respect.

The following code fragment illustrates basic usage of a few JodaTime classes: DateTime, DateTimeFormatter, and DateTimeZone. The toUtcDate() method converts a ISO 8601 formatted DateTime to its UTC equivalent. DateTimeFormatters are used to both read and write DateTimes.

	private static final DateTimeZone ZONE_UTC = DateTimeZone.UTC;

	private static final DateTimeFormatter ISO_PARSE_FORMAT =
		ISODateTimeFormat.dateOptionalTimeParser().withOffsetParsed();

	private static final DateTimeFormatter ISO_PRINT_FORMAT =
		ISODateTimeFormat.dateTimeNoMillis();

	/**
	 * Generate the UTC version of an ISO 8601 datetime.
	 *
	 * @param iso8601
	 * @return ISO 8601 datetime
	 */
	public static String toUtcDate(final String iso8601) {
		DateTime dt = ISO_PARSE_FORMAT.parseDateTime(iso8601);
		DateTime utcDt = dt.withZone(ZONE_UTC);
		return utcDt.toString(ISO_PRINT_FORMAT);
	}

We’re going to delve a little more deeply into JodaTime by implementing a parser for (something very close to) Solr Date Format. The Solr search engine uses this language to perform simple arithmetic on timestamps. The interpretation is straightforward: add or subtract various integral time units from a base time (which can be “now”). A few examples:

2011-10-31T12:34:56-5years+3months
now-90days
2011-02-14T23:23:23+9months/day

The only subtlety is the “/” operator which rounds to the nearest time unit. The last expression above represents 9 months after Valentine’s day rounded to the nearest day boundary.

Here’s the code in its entirety with commentary to follow:

package synaptic;

import java.util.regex.Matcher;
import java.util.regex.Pattern;

import org.joda.time.DateMidnight;
import org.joda.time.DateTime;
import org.joda.time.DateTimeZone;
import org.joda.time.format.DateTimeFormatter;
import org.joda.time.format.ISODateTimeFormat;

/**
 * Illustrate JodaTime usage by parsing Solr-ish date math expressions.
 */
public class Yoda {
	
	private static final DateTimeZone ZONE_UTC = DateTimeZone.UTC;
	
	private static final DateTimeFormatter ISO_PARSE_FORMAT =
		ISODateTimeFormat.dateOptionalTimeParser().withOffsetParsed();
	
	private static final DateTimeFormatter ISO_PRINT_FORMAT =
		ISODateTimeFormat.dateTimeNoMillis();
	
	/**
	 * Generate the GMT version of an ISO 8601 datetime.
	 * 
	 * @param iso8601
	 * @return ISO 8601 datetime
	 */
	public static String toUtcDate(final String iso8601) {
		DateTime dt = ISO_PARSE_FORMAT.parseDateTime(iso8601);
		DateTime utcDt = dt.withZone(ZONE_UTC);
		return utcDt.toString(ISO_PRINT_FORMAT);
	}
	
	/**
	 * Generate the GMT version of midnight on the given ISO 8601 datetime.
	 * 
	 * @param iso8601
	 * @return
	 */
	public static String toUtcDateFloor(final String iso8601) {
		DateTime dt = ISO_PARSE_FORMAT.parseDateTime(iso8601);
		DateMidnight dm = dt.toDateMidnight();
		DateTime utcDt = dm.toDateTime().withZone(ZONE_UTC);
		return utcDt.toString(ISO_PRINT_FORMAT);
	}
	
	private static final String REGEX_ISO8601 =
		"\\d{4}(-\\d{2}-\\d{2}(T(\\d{2}(:\\d{2}(:\\d{2}(\\.\\d{3})?)?)?)?(([+-]\\d{2}(:?\\d{2})?)|Z)?)?)?";
	
	private static final String REGEX_ISO8601_PREFIX = "(" + REGEX_ISO8601 + ").*";
	
	private static final String REGEX_OPARG =
		"([+-/])(\\p{Digit}*)(years?|months?|weeks?|days?|hours?|minutes?|seconds?)";
	
	private static final Pattern PATTERN_ISO8601_PREFIX =
		Pattern.compile(REGEX_ISO8601_PREFIX);
	
	private static final Pattern PATTERN_OPARG =
		Pattern.compile(REGEX_OPARG);
	
	/**
	 * Extract the ISO 8601 formatted datetime prefix from a string.
	 * 
	 * @param arg
	 * @return ISO 8601 formatted datetime or null if none
	 */
	public static String datePrefix(final String arg) {
		Matcher matcher = PATTERN_ISO8601_PREFIX.matcher(arg);
		return matcher.matches() ? matcher.group(1) : null;
	}
	
	/**
	 * Compute the GMT ISO 8601 datetime corresponding to the given solr-ish datemath
	 * expression.
	 * 
	 * @param arg
	 * @return ISO 8601 formatted datetime or null if bad argument
	 */
	public static String evalDateMath(final String arg) {
		String text = arg;
		DateTime dt = null;
		if (text.startsWith("now") || text.startsWith("NOW")) {
			text = text.substring("now".length());
			dt = new DateTime();
		}
		else {
			String prefix = datePrefix(text);			
			if (prefix == null) {
				return null;
			}
			text = text.substring(prefix.length());
			dt = ISO_PARSE_FORMAT.parseDateTime(prefix);
		}
		text = text.toLowerCase();
		
		if (!text.isEmpty()) {
			Matcher matcher = PATTERN_OPARG.matcher(text);
			while (matcher.find()) {
				String op = matcher.group(1);
				int num = matcher.group(2).isEmpty() ? 1
						: Integer.parseInt(matcher.group(2));
				String unit = matcher.group(3);			
				if ("/".equals(op)) {
					if (unit.startsWith("year")) {
						dt = dt.year().roundHalfFloorCopy();
					}
					else if (unit.startsWith("month")) {
						dt = dt.monthOfYear().roundHalfFloorCopy();
					}
					else if (unit.startsWith("week")) {
						dt = dt.weekOfWeekyear().roundHalfFloorCopy();
					}
					else if (unit.startsWith("day")) {
						dt = dt.dayOfMonth().roundHalfFloorCopy();
					}
					else if (unit.startsWith("hour")) {
						dt = dt.hourOfDay().roundHalfFloorCopy();
					}
					else if (unit.startsWith("minute")) {
						dt = dt.minuteOfHour().roundHalfFloorCopy();
					}
					else if (unit.startsWith("second")) {
						dt = dt.secondOfMinute().roundHalfFloorCopy();
					}
				}
				else {
					if ("-".equals(op)) {
						num = -num;
					}
					if (unit.startsWith("year")) {
						dt = dt.plusYears(num);
					}
					else if (unit.startsWith("month")) {
						dt = dt.plusMonths(num);
					}
					else if (unit.startsWith("week")) {
						dt = dt.plusWeeks(num);
					}
					else if (unit.startsWith("day")) {
						dt = dt.plusDays(num);
					}
					else if (unit.startsWith("hour")) {
						dt = dt.plusHours(num);
					}
					else if (unit.startsWith("minute")) {
						dt = dt.plusMinutes(num);
					}
					else if (unit.startsWith("second")) {
						dt = dt.plusSeconds(num);
					}					
				}
			}
		}
		
		DateTime utcDt = dt.withZone(ZONE_UTC);
		return utcDt.toString(ISO_PRINT_FORMAT);
	}
	
	
	
	/**
	 * @param args
	 */
	public static void main(String[] args) {
		System.out.println(toUtcDate("2001"));
		System.out.println(toUtcDate("2001-04-01T12:34:56"));
		System.out.println(toUtcDate("2001-04-01T12:34:56-07"));
		System.out.println();
		
		System.out.println(datePrefix("2001-3years+5months"));
		System.out.println(datePrefix("2001-04-01T12:34:56-0730-3years+5months"));
		System.out.println(datePrefix("2001-04-01T12:34:56-07:30-3years-1month-2weeks"));
		System.out.println();
		
		System.out.println(evalDateMath("now"));
		System.out.println(evalDateMath("now-2months"));
		System.out.println(evalDateMath("2001-07-04/day"));
		System.out.println(evalDateMath("2001-07-04T00:00:00+5years+21days"));
		System.out.println(evalDateMath("2001-04-01T12:34:56-0730-3years+5months+90seconds"));
		System.out.println(evalDateMath("2001-04-01T12:34:56-07:30-3years-1month-2weeks/day"));
		System.out.println(evalDateMath("2011-02-14T23:23:23+9months/day"));
		System.out.println(evalDateMath("soon"));
		System.out.println();
	}

}

The evalDateMath() method in line 82 begins by attempting to pull the ISO 8601 datetime prefix off the argument string. Then it proceeds to add/subtract/round sequential arithmetic clauses.

Regular expressions lend themselves nicely to this problem since Solr Date Math syntax is essentially a ISO 8601 datetime followed by any number of ( operator [integer] time_unit ) tuples. The loop beginning at line 101 grabs each such tuple in sequence and updates the time accordingly. For the “/” operator the code selects the appropriate DateTime.Property from the current time base and calls the roundHalfFloorCopy() method to produce the new rounded time. For the “+” and “-” operators the code calls the appropriate plusYears(), plusMonths(), etc. method to create the updated time.

Note that DateTime is immutable (and thread-safe) so new DateTime objects are generated at each step in the date math evaluation.

Here’s the output:

2001-01-01T05:00:00Z
2001-04-01T16:34:56Z
2001-04-01T19:34:56Z

2001
2001-04-01T12:34:56-0730
2001-04-01T12:34:56-07:30

2011-09-07T02:16:00Z
2011-07-07T02:16:00Z
2001-07-04T04:00:00Z
2006-07-25T04:00:00Z
1998-09-01T20:06:26Z
1998-02-16T07:30:00Z
2011-11-15T05:00:00Z
null

Hopefully this post will inspire you to investigate JodaTime more thoroughly. It’s a more intuitive, performant, documented, and powerful alternative to the JDK date/time manipulation classes.

P.S. There is one small deficiency in our implementation above. Can you find it?

This entry was posted in Technical. Bookmark the permalink.

Leave a Reply

Your email address will not be published. Required fields are marked *

*


*

You may use these HTML tags and attributes: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <strike> <strong>