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