Skip to content

Commit

Permalink
Cron parser
Browse files Browse the repository at this point in the history
  • Loading branch information
mtdowling committed Apr 21, 2011
0 parents commit 8be6958
Show file tree
Hide file tree
Showing 6 changed files with 376 additions and 0 deletions.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
nbproject
coverage
phpunit.xml
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
Cron Parser
===========

The Cron\Parser class can parse a CRON expression, determine if it is due to run, and calculate the next run date of the expression. The parser can handle simple increment of ranges (e.g. */12), intervals (e.g. 0-9), and lists (e.g. 1,2,3).

Requirements
------------

#. PHP 5.3+
#. PHPUnit is required to run the unit tests

CRON Expressions
----------------

A CRON expression is a string representing the schedule for a particular command to execute. The parts of a CRON schedule are as follows:

* * * * * command to be executed
- - - - -
| | | | |
| | | | |
| | | | +----- day of week (0 - 7) (Sunday=0 or 7)
| | | +---------- month (1 - 12)
| | +--------------- day of month (1 - 31)
| +-------------------- hour (0 - 23)
+------------------------- min (0 - 59)

TODO
----

Here are the features lacking in the current implementation:

#. Implement complex increment ranges (e.g. 3-59/15)
#. Implement L for last day of the week and last day of the month
#. Implement W to find the closest day of the week for a day of the month (e.g. 1W)
#. Implement the pound sign for the day of the week field to handle things like "the second friday of a given month"
24 changes: 24 additions & 0 deletions phpunit.xml.dist
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="./tests/bootstrap.php"
colors="true"
processIsolation="false"
stopOnFailure="false"
syntaxCheck="false"
convertErrorsToExceptions="true"
convertNoticesToExceptions="true"
convertWarningsToExceptions="true"
testSuiteLoaderClass="PHPUnit_Runner_StandardTestSuiteLoader">

<testsuites>
<testsuite name="Cron">
<directory>./tests</directory>
</testsuite>
</testsuites>

<filter>
<whitelist>
<directory suffix=".php">./src/Cron</directory>
</whitelist>
</filter>

</phpunit>
211 changes: 211 additions & 0 deletions src/Parser.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
<?php

namespace Cron;

use DateInterval;
use DateTime;
use InvalidArgumentException;

/**
* CRON expression parser that can determine whether or not a CRON expression is
* due to run and the next run date of a cron schedule. The determinations made
* by this class are accurate if checked run once per minute.
*
* The parser can handle ranges (10-12) and intervals (*\/10).
*
* Schedule parts must map to:
* minute [0-59], hour [0-23], day of month, month [1-12], day of week [1-7]
*
* @author Michael Dowling <mtdowling@gmail.com>
* @link http://en.wikipedia.org/wiki/Cron
*/
class Parser
{
const MINUTE = 0;
const HOUR = 1;
const DAY = 2;
const MONTH = 3;
const WEEKDAY = 4;

/**
* @var array CRON expression parts
*/
private $cronParts;

/**
* Parse a CRON expression
*
* @param string $schedule CRON expression schedule string (e.g. '8 * * * *')
*
* @throws InvalidArgumentException if not a valid CRON expression
*/
public function __construct($schedule)
{
$this->cronParts = explode(' ', $schedule);

if (count($this->cronParts) != 5) {
throw new InvalidArgumentException(
$schedule . ' is not a valid CRON expression'
);
}
}

/**
* Get the date in which the CRON will run next
*
* @param string $currentTime (optional) Optionally set the current date
* time for testing purposes or disregarding the current second
*
* @return DateTime
*/
public function getNextRunDate($currentTime = 'now')
{
$currentDate = $currentTime instanceof DateTime
? $currentTime
: new DateTime($currentTime ?: 'now');

$nextRun = clone $currentDate;
$nextRun->setTime($nextRun->format('H'), $nextRun->format('i'), 0);

// Set a hard limit to bail on an impossible date
for ($i = 0; $i < 10000; $i++) {

// Adjust the month until it matches. Reset day to 1 and reset time.
if (!$this->unitSatisfiesCron($nextRun, 'm', $this->getExpression(self::MONTH))) {
$nextRun->add(new DateInterval('P1M'));
$nextRun->setDate($nextRun->format('Y'), $nextRun->format('m'), 1);
$nextRun->setTime(0, 0, 0);
continue;
}

// Adjust the day of the month by incrementing the day until it matches. Reset time.
if (!$this->unitSatisfiesCron($nextRun, 'd', $this->getExpression(self::DAY))) {
$nextRun->add(new DateInterval('P1D'));
$nextRun->setTime(0, 0, 0);
continue;
}

// Adjust the day of week by incrementing the day until it matches. Resest time.
if (!$this->unitSatisfiesCron($nextRun, 'N', $this->getExpression(self::WEEKDAY))) {
$nextRun->add(new DateInterval('P1D'));
$nextRun->setTime(0, 0, 0);
continue;
}

// Adjust the hour until it matches the set hour. Set seconds and minutes to 0
if (!$this->unitSatisfiesCron($nextRun, 'H', $this->getExpression(self::HOUR))) {
$nextRun->add(new DateInterval('PT1H'));
$nextRun->setTime($nextRun->format('H'), 0, 0);
continue;
}

// Adjust the minutes until it matches a set minute
if (!$this->unitSatisfiesCron($nextRun, 'i', $this->getExpression(self::MINUTE))) {
$nextRun->add(new DateInterval('PT1M'));
continue;
}

// If the suggested next run time is not after the current time, then keep iterating
if ($currentTime != 'now' && $currentDate > $nextRun) {
$nextRun->add(new DateInterval('PT1M'));
continue;
}

break;
}

return $nextRun;
}

/**
* Get all or part of the CRON expression
*
* @param string $part (optional) Specify the part to retrieve or NULL to
* get the full cron schedule string.
*
* @return string|null Returns the CRON expression, a part of the
* CRON expression, or NULL if the part was specified but not found
*/
public function getExpression($part = null)
{
if (null === $part) {
return implode(' ', $this->cronParts);
} else if (array_key_exists($part, $this->cronParts)) {
return $this->cronParts[$part];
}

return null;
}

/**
* Deterime if the cron is due to run based on the current time. Unless
* a string is passed, this method assumes that the current number of
* seconds are irrelevant, and that this method will be called once per
* minute.
*
* @param string|DateTime $currentTime (optional) Set the current time
* If left NULL, the current time is used, less seconds
* If a DateTime object is passed, the DateTime is used less seconds
* If a string is used, the exact strotime of the string is used
*
* @return bool Returns TRUE if the cron is due to run or FALSE if not
*/
public function isDue($currentTime = null)
{
if (null === $currentTime || 'now' === $currentTime) {
$currentDate = date('Y-m-d H:i');
$currentTime = strtotime($currentDate);
} else if ($currentTime instanceof DateTime) {
$currentDate = $currentTime->format('Y-m-d H:i');
$currentTime = strtotime($currentDate);
} else {
$currentDate = $currentTime;
$currentTime = strtotime($currentTime);
}

return $this->getNextRunDate($currentDate)->getTimestamp() == $currentTime;
}

/**
* Check if a date/time unit value satisfies a crontab unit
*
* @param DateTime $nextRun Current next run date
* @param string $unit Date/time unit type (e.g. Y, m, d, H, i)
* @param string $schedule Cron schedule variable
*
* @return bool Returns TRUE if the unit satisfies the constraint
*/
protected function unitSatisfiesCron(DateTime $nextRun, $unit, $schedule)
{
if ($schedule === '*') {
return true;
}

$unitValue = (int) $nextRun->format($unit);

// Check increments of ranges
if (strpos($schedule, '*/') !== false) {
list($delimiter, $interval) = explode('*/', $schedule);
return $unitValue % (int) $interval == 0;
}

// Check intervals
if (strpos($schedule, '-')) {
list($first, $last) = explode('-', $schedule);
return $unitValue >= $first && $unitValue <= $last;
}

// Check lists of values
if (strpos($schedule, ',')) {
foreach (array_map('trim', explode(',', $schedule)) as $test) {
if ($this->unitSatisfiesCron($nextRun, $unit, $test)) {
return true;
}
}

return false;
}

return $unitValue == (int) $schedule;
}
}
95 changes: 95 additions & 0 deletions tests/ParserTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
<?php

namespace Cron\Tests;

use Cron\Parser;

/**
* @author Michael Dowling <mtdowling@gmail.com>
*/
class ParserTest extends \PHPUnit_Framework_TestCase
{
/**
* @covers Cron\Parser::__construct
* @covers Cron\Parser::getExpression
*/
public function testParsesCronSchedule()
{
$cron = new Parser('1 2-4 * 4,5,6 */3', '2010-09-10 12:00:00');
$this->assertEquals('1', $cron->getExpression(Parser::MINUTE));
$this->assertEquals('2-4', $cron->getExpression(Parser::HOUR));
$this->assertEquals('*', $cron->getExpression(Parser::DAY));
$this->assertEquals('4,5,6', $cron->getExpression(Parser::MONTH));
$this->assertEquals('*/3', $cron->getExpression(Parser::WEEKDAY));
$this->assertEquals('1 2-4 * 4,5,6 */3', $cron->getExpression());
$this->assertNull($cron->getExpression('foo'));
}

/**
* @covers Cron\Parser::__construct
* @expectedException InvalidArgumentException
*/
public function testInvalidCronsWillFail()
{
// Only four values
$cron = new Parser('* * * 1');
}

/**
* Data provider for cron schedule
*
* @return array
*/
public function scheduleProvider()
{
return array(
array('*/2 */2 * * *', '2015-08-10 21:47:27', '2015-08-10 22:00:00', false),
array('* * * * *', '2015-08-10 21:50:37', '2015-08-10 21:51:00', true),
array('* 20,21,22 * * *', '2015-08-10 21:50:00', '2015-08-10 21:50:00', true),
// Handles CSV values
array('* 20,22 * * *', '2015-08-10 21:50:00', '2015-08-10 22:00:00', false),
// CSV values can be complex
array('* 5,21-22 * * *', '2015-08-10 21:50:00', '2015-08-10 21:50:00', true),
array('7-9 * */9 * *', '2015-08-10 22:02:33', '2015-08-18 00:07:00', false),
// Minutes 12-19, every 3 hours, every 5 days, in June, on Sunday
array('12-19 */3 */5 6 7', '2015-08-10 22:05:51', '2016-06-05 00:12:00', false),
// 15th minute, of the second hour, every 15 days, in January, every Friday
array('15 2 */15 1 */5', '2015-08-10 22:10:19', '2016-01-15 02:15:00', false),
// 15th minute, of the second hour, every 15 days, in January, Tuesday-Friday
array('15 2 */15 1 2-5', '2015-08-10 22:10:19', '2016-01-15 02:15:00', false),
array('1 * * * 7', '2015-08-10 21:47:27', '2015-08-16 00:01:00', false),
// Test with exact times
array('47 21 * * *', strtotime('2015-08-10 21:47:30'), '2015-08-11 21:47:00', false),
);
}

/**
* @covers Cron\Parser::isDue
* @covers Cron\Parser::getNextRunDate
* @covers Cron\Parser::unitSatisfiesCron
* @dataProvider scheduleProvider
*/
public function testDeterminesIfCronIsDue($schedule, $relativeTime, $nextRun, $isDue)
{
$cron = new Parser($schedule);
if (is_string($relativeTime)) {
$relativeTime = new \DateTime($relativeTime);
} else if (is_int($relativeTime)) {
$relativeTime = date('Y-m-d H:i:s', $relativeTime);
}
$this->assertEquals($isDue, $cron->isDue($relativeTime));
$this->assertEquals(new \DateTime($nextRun), $cron->getNextRunDate($relativeTime));
}

/**
* @covers Cron\Parser::isDue
*/
public function testIsDueHandlesDifferentDates()
{
$cron = new Parser('* * * * *');
$this->assertTrue($cron->isDue());
$this->assertTrue($cron->isDue('now'));
$this->assertTrue($cron->isDue(new \DateTime('now')));
$this->assertTrue($cron->isDue(date('Y-m-d H:i')));
}
}
8 changes: 8 additions & 0 deletions tests/bootstrap.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<?php

namespace Cron\Tests;

error_reporting(E_ALL | E_STRICT);

require_once 'PHPUnit/TextUI/TestRunner.php';
require_once __DIR__ . DIRECTORY_SEPARATOR . '..' . DIRECTORY_SEPARATOR . 'src' . DIRECTORY_SEPARATOR . 'Parser.php';

0 comments on commit 8be6958

Please sign in to comment.