1*37748cd8SNickeau<?php 2*37748cd8SNickeau 3*37748cd8SNickeaunamespace Cron; 4*37748cd8SNickeau 5*37748cd8SNickeauuse DateTime; 6*37748cd8SNickeauuse DateTimeImmutable; 7*37748cd8SNickeauuse DateTimeZone; 8*37748cd8SNickeauuse Exception; 9*37748cd8SNickeauuse InvalidArgumentException; 10*37748cd8SNickeauuse RuntimeException; 11*37748cd8SNickeau 12*37748cd8SNickeau/** 13*37748cd8SNickeau * CRON expression parser that can determine whether or not a CRON expression is 14*37748cd8SNickeau * due to run, the next run date and previous run date of a CRON expression. 15*37748cd8SNickeau * The determinations made by this class are accurate if checked run once per 16*37748cd8SNickeau * minute (seconds are dropped from date time comparisons). 17*37748cd8SNickeau * 18*37748cd8SNickeau * Schedule parts must map to: 19*37748cd8SNickeau * minute [0-59], hour [0-23], day of month, month [1-12|JAN-DEC], day of week 20*37748cd8SNickeau * [1-7|MON-SUN], and an optional year. 21*37748cd8SNickeau * 22*37748cd8SNickeau * @link http://en.wikipedia.org/wiki/Cron 23*37748cd8SNickeau */ 24*37748cd8SNickeauclass CronExpression 25*37748cd8SNickeau{ 26*37748cd8SNickeau const MINUTE = 0; 27*37748cd8SNickeau const HOUR = 1; 28*37748cd8SNickeau const DAY = 2; 29*37748cd8SNickeau const MONTH = 3; 30*37748cd8SNickeau const WEEKDAY = 4; 31*37748cd8SNickeau const YEAR = 5; 32*37748cd8SNickeau 33*37748cd8SNickeau /** 34*37748cd8SNickeau * @var array CRON expression parts 35*37748cd8SNickeau */ 36*37748cd8SNickeau private $cronParts; 37*37748cd8SNickeau 38*37748cd8SNickeau /** 39*37748cd8SNickeau * @var FieldFactory CRON field factory 40*37748cd8SNickeau */ 41*37748cd8SNickeau private $fieldFactory; 42*37748cd8SNickeau 43*37748cd8SNickeau /** 44*37748cd8SNickeau * @var int Max iteration count when searching for next run date 45*37748cd8SNickeau */ 46*37748cd8SNickeau private $maxIterationCount = 1000; 47*37748cd8SNickeau 48*37748cd8SNickeau /** 49*37748cd8SNickeau * @var array Order in which to test of cron parts 50*37748cd8SNickeau */ 51*37748cd8SNickeau private static $order = array(self::YEAR, self::MONTH, self::DAY, self::WEEKDAY, self::HOUR, self::MINUTE); 52*37748cd8SNickeau 53*37748cd8SNickeau /** 54*37748cd8SNickeau * Factory method to create a new CronExpression. 55*37748cd8SNickeau * 56*37748cd8SNickeau * @param string $expression The CRON expression to create. There are 57*37748cd8SNickeau * several special predefined values which can be used to substitute the 58*37748cd8SNickeau * CRON expression: 59*37748cd8SNickeau * 60*37748cd8SNickeau * `@yearly`, `@annually` - Run once a year, midnight, Jan. 1 - 0 0 1 1 * 61*37748cd8SNickeau * `@monthly` - Run once a month, midnight, first of month - 0 0 1 * * 62*37748cd8SNickeau * `@weekly` - Run once a week, midnight on Sun - 0 0 * * 0 63*37748cd8SNickeau * `@daily` - Run once a day, midnight - 0 0 * * * 64*37748cd8SNickeau * `@hourly` - Run once an hour, first minute - 0 * * * * 65*37748cd8SNickeau * @param FieldFactory $fieldFactory Field factory to use 66*37748cd8SNickeau * 67*37748cd8SNickeau * @return CronExpression 68*37748cd8SNickeau */ 69*37748cd8SNickeau public static function factory($expression, FieldFactory $fieldFactory = null) 70*37748cd8SNickeau { 71*37748cd8SNickeau $mappings = array( 72*37748cd8SNickeau '@yearly' => '0 0 1 1 *', 73*37748cd8SNickeau '@annually' => '0 0 1 1 *', 74*37748cd8SNickeau '@monthly' => '0 0 1 * *', 75*37748cd8SNickeau '@weekly' => '0 0 * * 0', 76*37748cd8SNickeau '@daily' => '0 0 * * *', 77*37748cd8SNickeau '@hourly' => '0 * * * *' 78*37748cd8SNickeau ); 79*37748cd8SNickeau 80*37748cd8SNickeau if (isset($mappings[$expression])) { 81*37748cd8SNickeau $expression = $mappings[$expression]; 82*37748cd8SNickeau } 83*37748cd8SNickeau 84*37748cd8SNickeau return new static($expression, $fieldFactory ?: new FieldFactory()); 85*37748cd8SNickeau } 86*37748cd8SNickeau 87*37748cd8SNickeau /** 88*37748cd8SNickeau * Validate a CronExpression. 89*37748cd8SNickeau * 90*37748cd8SNickeau * @param string $expression The CRON expression to validate. 91*37748cd8SNickeau * 92*37748cd8SNickeau * @return bool True if a valid CRON expression was passed. False if not. 93*37748cd8SNickeau * @see \Cron\CronExpression::factory 94*37748cd8SNickeau */ 95*37748cd8SNickeau public static function isValidExpression($expression) 96*37748cd8SNickeau { 97*37748cd8SNickeau try { 98*37748cd8SNickeau self::factory($expression); 99*37748cd8SNickeau } catch (InvalidArgumentException $e) { 100*37748cd8SNickeau return false; 101*37748cd8SNickeau } 102*37748cd8SNickeau 103*37748cd8SNickeau return true; 104*37748cd8SNickeau } 105*37748cd8SNickeau 106*37748cd8SNickeau /** 107*37748cd8SNickeau * Parse a CRON expression 108*37748cd8SNickeau * 109*37748cd8SNickeau * @param string $expression CRON expression (e.g. '8 * * * *') 110*37748cd8SNickeau * @param FieldFactory $fieldFactory Factory to create cron fields 111*37748cd8SNickeau */ 112*37748cd8SNickeau public function __construct($expression, FieldFactory $fieldFactory) 113*37748cd8SNickeau { 114*37748cd8SNickeau $this->fieldFactory = $fieldFactory; 115*37748cd8SNickeau $this->setExpression($expression); 116*37748cd8SNickeau } 117*37748cd8SNickeau 118*37748cd8SNickeau /** 119*37748cd8SNickeau * Set or change the CRON expression 120*37748cd8SNickeau * 121*37748cd8SNickeau * @param string $value CRON expression (e.g. 8 * * * *) 122*37748cd8SNickeau * 123*37748cd8SNickeau * @return CronExpression 124*37748cd8SNickeau * @throws \InvalidArgumentException if not a valid CRON expression 125*37748cd8SNickeau */ 126*37748cd8SNickeau public function setExpression($value) 127*37748cd8SNickeau { 128*37748cd8SNickeau $this->cronParts = preg_split('/\s/', $value, -1, PREG_SPLIT_NO_EMPTY); 129*37748cd8SNickeau if (count($this->cronParts) < 5) { 130*37748cd8SNickeau throw new InvalidArgumentException( 131*37748cd8SNickeau $value . ' is not a valid CRON expression' 132*37748cd8SNickeau ); 133*37748cd8SNickeau } 134*37748cd8SNickeau 135*37748cd8SNickeau foreach ($this->cronParts as $position => $part) { 136*37748cd8SNickeau $this->setPart($position, $part); 137*37748cd8SNickeau } 138*37748cd8SNickeau 139*37748cd8SNickeau return $this; 140*37748cd8SNickeau } 141*37748cd8SNickeau 142*37748cd8SNickeau /** 143*37748cd8SNickeau * Set part of the CRON expression 144*37748cd8SNickeau * 145*37748cd8SNickeau * @param int $position The position of the CRON expression to set 146*37748cd8SNickeau * @param string $value The value to set 147*37748cd8SNickeau * 148*37748cd8SNickeau * @return CronExpression 149*37748cd8SNickeau * @throws \InvalidArgumentException if the value is not valid for the part 150*37748cd8SNickeau */ 151*37748cd8SNickeau public function setPart($position, $value) 152*37748cd8SNickeau { 153*37748cd8SNickeau if (!$this->fieldFactory->getField($position)->validate($value)) { 154*37748cd8SNickeau throw new InvalidArgumentException( 155*37748cd8SNickeau 'Invalid CRON field value ' . $value . ' at position ' . $position 156*37748cd8SNickeau ); 157*37748cd8SNickeau } 158*37748cd8SNickeau 159*37748cd8SNickeau $this->cronParts[$position] = $value; 160*37748cd8SNickeau 161*37748cd8SNickeau return $this; 162*37748cd8SNickeau } 163*37748cd8SNickeau 164*37748cd8SNickeau /** 165*37748cd8SNickeau * Set max iteration count for searching next run dates 166*37748cd8SNickeau * 167*37748cd8SNickeau * @param int $maxIterationCount Max iteration count when searching for next run date 168*37748cd8SNickeau * 169*37748cd8SNickeau * @return CronExpression 170*37748cd8SNickeau */ 171*37748cd8SNickeau public function setMaxIterationCount($maxIterationCount) 172*37748cd8SNickeau { 173*37748cd8SNickeau $this->maxIterationCount = $maxIterationCount; 174*37748cd8SNickeau 175*37748cd8SNickeau return $this; 176*37748cd8SNickeau } 177*37748cd8SNickeau 178*37748cd8SNickeau /** 179*37748cd8SNickeau * Get a next run date relative to the current date or a specific date 180*37748cd8SNickeau * 181*37748cd8SNickeau * @param string|\DateTime $currentTime Relative calculation date 182*37748cd8SNickeau * @param int $nth Number of matches to skip before returning a 183*37748cd8SNickeau * matching next run date. 0, the default, will return the current 184*37748cd8SNickeau * date and time if the next run date falls on the current date and 185*37748cd8SNickeau * time. Setting this value to 1 will skip the first match and go to 186*37748cd8SNickeau * the second match. Setting this value to 2 will skip the first 2 187*37748cd8SNickeau * matches and so on. 188*37748cd8SNickeau * @param bool $allowCurrentDate Set to TRUE to return the current date if 189*37748cd8SNickeau * it matches the cron expression. 190*37748cd8SNickeau * 191*37748cd8SNickeau * @return \DateTime 192*37748cd8SNickeau * @throws \RuntimeException on too many iterations 193*37748cd8SNickeau */ 194*37748cd8SNickeau public function getNextRunDate($currentTime = 'now', $nth = 0, $allowCurrentDate = false) 195*37748cd8SNickeau { 196*37748cd8SNickeau return $this->getRunDate($currentTime, $nth, false, $allowCurrentDate); 197*37748cd8SNickeau } 198*37748cd8SNickeau 199*37748cd8SNickeau /** 200*37748cd8SNickeau * Get a previous run date relative to the current date or a specific date 201*37748cd8SNickeau * 202*37748cd8SNickeau * @param string|\DateTime $currentTime Relative calculation date 203*37748cd8SNickeau * @param int $nth Number of matches to skip before returning 204*37748cd8SNickeau * @param bool $allowCurrentDate Set to TRUE to return the 205*37748cd8SNickeau * current date if it matches the cron expression 206*37748cd8SNickeau * 207*37748cd8SNickeau * @return \DateTime 208*37748cd8SNickeau * @throws \RuntimeException on too many iterations 209*37748cd8SNickeau * @see \Cron\CronExpression::getNextRunDate 210*37748cd8SNickeau */ 211*37748cd8SNickeau public function getPreviousRunDate($currentTime = 'now', $nth = 0, $allowCurrentDate = false) 212*37748cd8SNickeau { 213*37748cd8SNickeau return $this->getRunDate($currentTime, $nth, true, $allowCurrentDate); 214*37748cd8SNickeau } 215*37748cd8SNickeau 216*37748cd8SNickeau /** 217*37748cd8SNickeau * Get multiple run dates starting at the current date or a specific date 218*37748cd8SNickeau * 219*37748cd8SNickeau * @param int $total Set the total number of dates to calculate 220*37748cd8SNickeau * @param string|\DateTime $currentTime Relative calculation date 221*37748cd8SNickeau * @param bool $invert Set to TRUE to retrieve previous dates 222*37748cd8SNickeau * @param bool $allowCurrentDate Set to TRUE to return the 223*37748cd8SNickeau * current date if it matches the cron expression 224*37748cd8SNickeau * 225*37748cd8SNickeau * @return array Returns an array of run dates 226*37748cd8SNickeau */ 227*37748cd8SNickeau public function getMultipleRunDates($total, $currentTime = 'now', $invert = false, $allowCurrentDate = false) 228*37748cd8SNickeau { 229*37748cd8SNickeau $matches = array(); 230*37748cd8SNickeau for ($i = 0; $i < max(0, $total); $i++) { 231*37748cd8SNickeau try { 232*37748cd8SNickeau $matches[] = $this->getRunDate($currentTime, $i, $invert, $allowCurrentDate); 233*37748cd8SNickeau } catch (RuntimeException $e) { 234*37748cd8SNickeau break; 235*37748cd8SNickeau } 236*37748cd8SNickeau } 237*37748cd8SNickeau 238*37748cd8SNickeau return $matches; 239*37748cd8SNickeau } 240*37748cd8SNickeau 241*37748cd8SNickeau /** 242*37748cd8SNickeau * Get all or part of the CRON expression 243*37748cd8SNickeau * 244*37748cd8SNickeau * @param string $part Specify the part to retrieve or NULL to get the full 245*37748cd8SNickeau * cron schedule string. 246*37748cd8SNickeau * 247*37748cd8SNickeau * @return string|null Returns the CRON expression, a part of the 248*37748cd8SNickeau * CRON expression, or NULL if the part was specified but not found 249*37748cd8SNickeau */ 250*37748cd8SNickeau public function getExpression($part = null) 251*37748cd8SNickeau { 252*37748cd8SNickeau if (null === $part) { 253*37748cd8SNickeau return implode(' ', $this->cronParts); 254*37748cd8SNickeau } elseif (array_key_exists($part, $this->cronParts)) { 255*37748cd8SNickeau return $this->cronParts[$part]; 256*37748cd8SNickeau } 257*37748cd8SNickeau 258*37748cd8SNickeau return null; 259*37748cd8SNickeau } 260*37748cd8SNickeau 261*37748cd8SNickeau /** 262*37748cd8SNickeau * Helper method to output the full expression. 263*37748cd8SNickeau * 264*37748cd8SNickeau * @return string Full CRON expression 265*37748cd8SNickeau */ 266*37748cd8SNickeau public function __toString() 267*37748cd8SNickeau { 268*37748cd8SNickeau return $this->getExpression(); 269*37748cd8SNickeau } 270*37748cd8SNickeau 271*37748cd8SNickeau /** 272*37748cd8SNickeau * Determine if the cron is due to run based on the current date or a 273*37748cd8SNickeau * specific date. This method assumes that the current number of 274*37748cd8SNickeau * seconds are irrelevant, and should be called once per minute. 275*37748cd8SNickeau * 276*37748cd8SNickeau * @param string|\DateTime $currentTime Relative calculation date 277*37748cd8SNickeau * 278*37748cd8SNickeau * @return bool Returns TRUE if the cron is due to run or FALSE if not 279*37748cd8SNickeau */ 280*37748cd8SNickeau public function isDue($currentTime = 'now') 281*37748cd8SNickeau { 282*37748cd8SNickeau if ('now' === $currentTime) { 283*37748cd8SNickeau $currentDate = date('Y-m-d H:i'); 284*37748cd8SNickeau $currentTime = strtotime($currentDate); 285*37748cd8SNickeau } elseif ($currentTime instanceof DateTime) { 286*37748cd8SNickeau $currentDate = clone $currentTime; 287*37748cd8SNickeau // Ensure time in 'current' timezone is used 288*37748cd8SNickeau $currentDate->setTimezone(new DateTimeZone(date_default_timezone_get())); 289*37748cd8SNickeau $currentDate = $currentDate->format('Y-m-d H:i'); 290*37748cd8SNickeau $currentTime = strtotime($currentDate); 291*37748cd8SNickeau } elseif ($currentTime instanceof DateTimeImmutable) { 292*37748cd8SNickeau $currentDate = DateTime::createFromFormat('U', $currentTime->format('U')); 293*37748cd8SNickeau $currentDate->setTimezone(new DateTimeZone(date_default_timezone_get())); 294*37748cd8SNickeau $currentDate = $currentDate->format('Y-m-d H:i'); 295*37748cd8SNickeau $currentTime = strtotime($currentDate); 296*37748cd8SNickeau } else { 297*37748cd8SNickeau $currentTime = new DateTime($currentTime); 298*37748cd8SNickeau $currentTime->setTime($currentTime->format('H'), $currentTime->format('i'), 0); 299*37748cd8SNickeau $currentDate = $currentTime->format('Y-m-d H:i'); 300*37748cd8SNickeau $currentTime = $currentTime->getTimeStamp(); 301*37748cd8SNickeau } 302*37748cd8SNickeau 303*37748cd8SNickeau try { 304*37748cd8SNickeau return $this->getNextRunDate($currentDate, 0, true)->getTimestamp() == $currentTime; 305*37748cd8SNickeau } catch (Exception $e) { 306*37748cd8SNickeau return false; 307*37748cd8SNickeau } 308*37748cd8SNickeau } 309*37748cd8SNickeau 310*37748cd8SNickeau /** 311*37748cd8SNickeau * Get the next or previous run date of the expression relative to a date 312*37748cd8SNickeau * 313*37748cd8SNickeau * @param string|\DateTime $currentTime Relative calculation date 314*37748cd8SNickeau * @param int $nth Number of matches to skip before returning 315*37748cd8SNickeau * @param bool $invert Set to TRUE to go backwards in time 316*37748cd8SNickeau * @param bool $allowCurrentDate Set to TRUE to return the 317*37748cd8SNickeau * current date if it matches the cron expression 318*37748cd8SNickeau * 319*37748cd8SNickeau * @return \DateTime 320*37748cd8SNickeau * @throws \RuntimeException on too many iterations 321*37748cd8SNickeau */ 322*37748cd8SNickeau protected function getRunDate($currentTime = null, $nth = 0, $invert = false, $allowCurrentDate = false) 323*37748cd8SNickeau { 324*37748cd8SNickeau if ($currentTime instanceof DateTime) { 325*37748cd8SNickeau $currentDate = clone $currentTime; 326*37748cd8SNickeau } elseif ($currentTime instanceof DateTimeImmutable) { 327*37748cd8SNickeau $currentDate = DateTime::createFromFormat('U', $currentTime->format('U')); 328*37748cd8SNickeau $currentDate->setTimezone($currentTime->getTimezone()); 329*37748cd8SNickeau } else { 330*37748cd8SNickeau $currentDate = new DateTime($currentTime ?: 'now'); 331*37748cd8SNickeau $currentDate->setTimezone(new DateTimeZone(date_default_timezone_get())); 332*37748cd8SNickeau } 333*37748cd8SNickeau 334*37748cd8SNickeau $currentDate->setTime($currentDate->format('H'), $currentDate->format('i'), 0); 335*37748cd8SNickeau $nextRun = clone $currentDate; 336*37748cd8SNickeau $nth = (int) $nth; 337*37748cd8SNickeau 338*37748cd8SNickeau // We don't have to satisfy * or null fields 339*37748cd8SNickeau $parts = array(); 340*37748cd8SNickeau $fields = array(); 341*37748cd8SNickeau foreach (self::$order as $position) { 342*37748cd8SNickeau $part = $this->getExpression($position); 343*37748cd8SNickeau if (null === $part || '*' === $part) { 344*37748cd8SNickeau continue; 345*37748cd8SNickeau } 346*37748cd8SNickeau $parts[$position] = $part; 347*37748cd8SNickeau $fields[$position] = $this->fieldFactory->getField($position); 348*37748cd8SNickeau } 349*37748cd8SNickeau 350*37748cd8SNickeau // Set a hard limit to bail on an impossible date 351*37748cd8SNickeau for ($i = 0; $i < $this->maxIterationCount; $i++) { 352*37748cd8SNickeau 353*37748cd8SNickeau foreach ($parts as $position => $part) { 354*37748cd8SNickeau $satisfied = false; 355*37748cd8SNickeau // Get the field object used to validate this part 356*37748cd8SNickeau $field = $fields[$position]; 357*37748cd8SNickeau // Check if this is singular or a list 358*37748cd8SNickeau if (strpos($part, ',') === false) { 359*37748cd8SNickeau $satisfied = $field->isSatisfiedBy($nextRun, $part); 360*37748cd8SNickeau } else { 361*37748cd8SNickeau foreach (array_map('trim', explode(',', $part)) as $listPart) { 362*37748cd8SNickeau if ($field->isSatisfiedBy($nextRun, $listPart)) { 363*37748cd8SNickeau $satisfied = true; 364*37748cd8SNickeau break; 365*37748cd8SNickeau } 366*37748cd8SNickeau } 367*37748cd8SNickeau } 368*37748cd8SNickeau 369*37748cd8SNickeau // If the field is not satisfied, then start over 370*37748cd8SNickeau if (!$satisfied) { 371*37748cd8SNickeau $field->increment($nextRun, $invert, $part); 372*37748cd8SNickeau continue 2; 373*37748cd8SNickeau } 374*37748cd8SNickeau } 375*37748cd8SNickeau 376*37748cd8SNickeau // Skip this match if needed 377*37748cd8SNickeau if ((!$allowCurrentDate && $nextRun == $currentDate) || --$nth > -1) { 378*37748cd8SNickeau $this->fieldFactory->getField(0)->increment($nextRun, $invert, isset($parts[0]) ? $parts[0] : null); 379*37748cd8SNickeau continue; 380*37748cd8SNickeau } 381*37748cd8SNickeau 382*37748cd8SNickeau return $nextRun; 383*37748cd8SNickeau } 384*37748cd8SNickeau 385*37748cd8SNickeau // @codeCoverageIgnoreStart 386*37748cd8SNickeau throw new RuntimeException('Impossible CRON expression'); 387*37748cd8SNickeau // @codeCoverageIgnoreEnd 388*37748cd8SNickeau } 389*37748cd8SNickeau} 390