CronExpression.php 16 KB

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