• 07 . 02 . 10
  • In a previous article, I described how set operations could be modelled in PHP. With that foundation, we can begin to generate complex date criteria suitable for modelling recurring events. There are a number of different kinds of date condition, which Martin Fowler terms “Temporal Expressions”. Typical temporal expressions include “Last Day in the Month”, […]

  • Tags

    , , , ,

  • StumbleUpon

Modelling Recurring Events in PHP

In a previous article, I described how set operations could be modelled in PHP. With that foundation, we can begin to generate complex date criteria suitable for modelling recurring events.

There are a number of different kinds of date condition, which Martin Fowler terms “Temporal Expressions”. Typical temporal expressions include “Last Day in the Month”, “Nth Day of the Week” and “Repeats Yearly”. We can model each of these as a separate class and then combine them as necessary to provide a flexible architecture.

In order to take advantage of the set operations we defined previously, all of the classes must implement the same trivial interface:

interface Celsus_Temporal_Expression_Interface {
	/**
	 * Determines whether the date specified is included in this temporal expression.
	 *
	 * @param string $date
	 */
	public function includes($date);
}

For our first example, let’s model conditions like “11th day of the month”. By using negative numbers, we can also use the same class to model “11th day from the end of the month”:

/**
 * Handles scheduling rules like "11th day of the month".
 */
class Celsus_Temporal_Expression_DayOfMonth implements Celsus_Temporal_Expression_Interface {

	/**
	 * The day of the month we are interested in.  If the day is less than zero,
	 * it is interpreted as being from the end of the month.
	 *
	 * @var int
	 */
	private $_day;

	public function __construct($day) {
		$this->_day = $day;
	}

	public function includes($date) {
		return $this->_day > 0 ? $this->_fromStartOfMonth($date) : $this->_fromEndOfMonth($date);
	}

	private function _fromStartOfMonth($date) {
		return $this->_day == date('j', strtotime($date));
	}

	private function _fromEndOfMonth($date) {
		$timestamp = strtotime($date);
		return ((date('t', $timestamp) - date('j', $timestamp)) + 1) == abs($this->_day);
	}
}

Usage is straightforward:

$tenth_of_the_month = new Celsus_Temporal_Expression_DayOfMonth(10);
var_dump($tenth_of_the_month->includes('2010-01-10')); // True
var_dump($tenth_of_the_month->includes('2010-01-07')); // False
$three_days_before_end_of_the_month = new Celsus_Temporal_Expression_DayOfMonth(-3);
var_dump($three_days_before_end_of_the_month->includes('2010-01-10')); // False
var_dump($three_days_before_end_of_the_month->includes('2010-01-28')); // True

A second type of query is of the form “every 3 months”. This is slightly more involved as it needs to also use a specified date as the base from which to start counting:

/**
 * Handles scheduling rules like "every 3 months"
 */
class Celsus_Temporal_Expression_MonthsFromStart implements Celsus_Temporal_Expression_Interface {

	/**
	 * The count specifying the interval of months we are interested in.
	 *
	 * @var int
	 */
	private $_count;

	/**
	 * The start date of the sequence.  The number is stored in ISO
	 * format, i.e 2000-12-31.
	 *
	 * @var string
	 */
	private $_start;

	public function __construct($start, $count) {
		$this->_start = $start;
		$this->_count = $count;
	}

	public function includes($date) {
		// Take the specified month, minus the start month, mod the interval.  If it is
		// zero then this date should be included.
		return (0 == ((date('n', strtotime($date)) - date('n', strtotime($this->_start))) % $this->_count));
	}

}

Again, usage is similar:

$every_two_months = new Celsus_Temporal_Expression_MonthsFromStart('2010-01-01', 2);
var_dump($every_two_months->includes('2010-03-01')); // True
var_dump($every_two_months->includes('2010-04-01')); // False

Now, making use of our set operations previously defined, we can combine them for more complex rules such as “the last day of every quarter”:

$last_day_of_month = new Celsus_Temporal_Expression_DayOfMonth(-1);
$every_three_months = new Celsus_Temporal_Expression_MonthsFromStart('2010-01-01', 3);
$last_day_of_quarter = new Celsus_Set_Operation_Intersection('Celsus_Temporal_Expression_Interface');
$last_day_of_quarter->addElements(array($every_three_months, $last_day_of_month));
var_dump($last_day_of_quarter->includes('2010-01-31')); // False;
var_dump($last_day_of_quarter->includes('2010-06-30')); // True;

In general, intersections are going to be the most useful set operation for this kind of use, but are not the only possibility. If we modify this to use a Union, we can test to see if dates are either the last day of the month, or within a month every 3 months from the start:

$last_day_of_month = new Celsus_Temporal_Expression_DayOfMonth(-1);
$every_three_months = new Celsus_Temporal_Expression_MonthsFromStart('2010-01-01', 3);
$last_of_month_or_every_three_months = new Celsus_Set_Operation_Union('Celsus_Temporal_Expression_Interface');
$last_day_of_quarter->addElements(array($every_three_months, $last_day_of_month));
var_dump($last_of_month_or_every_three_months->includes('2010-02-28')); // True;
var_dump($last_of_month_or_every_three_months->includes('2010-06-05')); // True;

A handful of temporal expressions can be downloaded here. Further ones are left as an exercise.

With an appropriate database schema for persistence of rules, such as Apple’s iCal reference, this provides a comprehensive method for defining complex date ranges and testing them quickly.