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