Posts tagged Celsus

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.

Set Operations in PHP

The standard set operations of Intersection, Union and Difference are well-understood and have a wide number of applications. Martin Fowler gives a neat example here, for modelling recurring events where there are multiple conditions.

“The last day of every second month” for example, can be represented by an intersection of two conditions, “last day of the month” and “every 2 months”. More generally, they can be applied to any situation where you want to do combinations of criteria using AND and OR.

To model this in PHP, we can define a number of Set Operation classes to which we can attach 2 or more elements for testing. The 3 classes, Union, Intersect and Difference will all be constructed in the same way, so constructor code can be placed in a common ancestor:

abstract class Celsus_Set_Operation_Abstract {
	protected $_setInterface = null;

	public function __construct($setInterface) {
		if (!is_string($setInterface)) {
			throw new Celsus_Exception("Interface must be a string.");
		}
		if (!interface_exists($setInterface, true)) {
			throw new Celsus_Exception("$setInterface is not a valid interface.");
		}
		$this->_setInterface = $setInterface;
	}

	/**
	 * Adds an element to the set.
	 *
	 * @param StdClass $element
	 */
	public function addElement($element) {
		if ($element instanceof $this->_setInterface || $element instanceof Celsus_Set_Operation_Abstract) {
			$this->_elements[] = $element;
		} else {
			throw new Celsus_Exception("Element must implement $this->_setInterface or Set");
		}
	}
}

In order to test the elements of the set, we need to ensure that they are all testable in the same way. We could force a particular method name, such as ‘contains’ or ‘includes’, but it turns out we don’t have to. As long as they all have the same testing function, we don’t actually care what it is called, and library code should be as transparent as possible. Therefore we construct the set operator with an interface, which all set elements must implement.

The addElement function provides a method by which we can add elements to the set. If the element doesn’t meet the required interface, it is rejected.

Next, let’s define a set of classes that can be compared for inclusion or exclusion. The only requirement is that they must all implement the same interface containing a single method, and that that method returns a boolean.

interface Celsus_Test_Set_Interface {
	public function acceptable();
}

class Celsus_Test_Set_Acceptable implements Celsus_Test_Set_Interface {
	public function acceptable() {
		return true;
	}
}

class Celsus_Test_Set_Unacceptable implements Celsus_Test_Set_Interface {
	public function acceptable() {
		return false;
	}
}

This is about as simple as you can get. The ‘Acceptable’ class represents an object for which the criteria is always passed. The ‘Unacceptable’ class represents one for which the criteria is always failed. In practise, these would be more complex. For dates, for example, you could test whether the supplied date met the “last day of the month criteria”. These simple classes will suffice for now though, and allow us to discuss the set mechanisms.

An intersection is true if every element of the set meets the criteria, and can be represented as follows:

class Celsus_Set_Operation_Intersect extends Celsus_Set_Operation_Abstract
	protected function __call($method, $arguments) {
		// First check that the method we are calling is defined in the specified interface.
		if (!method_exists($this->_setInterface, $method)) {
			throw new Celsus_Exception("$method is not defined in $this->_setInterface");
		}

		if (!$this->_elements) {
			// We don't have any elements.
			return false;
		}

		// Iterate through the elements, calling the supplied method name on each.
		// If any return false, this intersection is false.
		foreach ($this->_elements as $element) {
			if (!call_user_func_array(array($element, $method), $arguments)) {
				return false;
			}
		}
		return true;
	}

Because we don’t know what the name of the containing function is going to be, we use the magic __call method to trap any method calls. We test that method name against the interface we constructed the Intersection with, and if it exists, we can begin. If no elements have been added to the set, the intersection returns false. Finally, we iterate through each element in the set and test its comparer function. As an intersection, if any element fails its test, the intersection fails.

We can demonstrate this using our simple test cases above:

$intersection = new Celsus_Set_Operation_Intersection('Celsus_Test_Set_Interface');
$intersection->addElement(new Celsus_Test_Set_Acceptable());
$intersection->addElement(new Celsus_Test_Set_Acceptable());
var_dump($intersection->acceptable()); // True

$intersection2 = new Celsus_Set_Operation_Intersection('Celsus_Test_Set_Interface');
$intersection2->addElement(new Celsus_Test_Set_Acceptable());
$intersection2->addElement(new Celsus_Test_Set_Unacceptable());
var_dump($intersection->acceptable());  // False.

First, we construct the intersection, then we add some elements, and finally we test. Note that the method call is to ‘acceptable()’, which is the method specified in the interface used to construct the intersection.

Similar classes can be constructed for Union and Difference which work in much the same way. Set difference is a little more complex, however, as we have to define both a list of criteria to exclude, and a list of criteria to include.

But what if we want to combine these set operations, for more complex tests? As a further refinement we can allow the set operations to themselves include other set operations, which allows for complex constructions like the following:

$intersection = new Celsus_Set_Operation_Intersection('Celsus_Test_Set_Interface');
$elementA = new Celsus_Test_Set_Acceptable();
$elementB = new Celsus_Test_Set_Acceptable();
$elementC = new Celsus_Test_Set_Unacceptable();
$intersection->addElement($elementA);
$intersection->addElement($elementB);
$union = new Celsus_Set_Operation_Union('Celsus_Test_Set_Interface');
$union->addElement($intersection);
$union->addElement($elementC);
var_dump($intersection->acceptable());  // True.

Which translates to “(elementA AND elementB) OR elementC”. The flexible structure allows any combination of these, at any depth.

A full set of code and unit tests that implement all of the above is available here. If you have any questions on their use, let me know in the comments.

From here, the next step is to define a more useful set of criteria. Using dates as an example, we can arrange for readable code that quickly determines whether a date is in a given set of recurrence conditions:

$intersection = new Celsus_Set_Operation_Intersection('Celsus_Temporal_Expression_Interface');
$intersection->addElements(array($every_month, $last_day_of_the_month));
var_dump($intersection->includes('2010-01-31')); // True
var_dump($intersection->includes('2010-06-02')); // False

I’ll go through the steps on how to achieve that in another post.