Passed
Push — master ( 495efb...ace576 )
by Brian
06:14 queued 01:46
created

CronExpression::setPart()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 11
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 2
eloc 5
c 1
b 0
f 0
nc 2
nop 2
dl 0
loc 11
rs 10
1
<?php
2
3
/**
4
 * CRON expression parser that can determine whether or not a CRON expression is
5
 * due to run, the next run date and previous run date of a CRON expression.
6
 * The determinations made by this class are accurate if checked run once per
7
 * minute (seconds are dropped from date time comparisons).
8
 *
9
 * Schedule parts must map to:
10
 * minute [0-59], hour [0-23], day of month, month [1-12|JAN-DEC], day of week
11
 * [1-7|MON-SUN], and an optional year.
12
 *
13
 * @author Michael Dowling <[email protected]>
14
 * @link http://en.wikipedia.org/wiki/Cron
15
 */
16
class CronExpression
17
{
18
    const MINUTE = 0;
19
    const HOUR = 1;
20
    const DAY = 2;
21
    const MONTH = 3;
22
    const WEEKDAY = 4;
23
    const YEAR = 5;
24
25
    /**
26
     * @var array CRON expression parts
27
     */
28
    private $cronParts;
29
30
    /**
31
     * @var CronExpression_FieldFactory CRON field factory
32
     */
33
    private $fieldFactory;
34
35
    /**
36
     * @var array Order in which to test of cron parts
37
     */
38
    private static $order = array(self::YEAR, self::MONTH, self::DAY, self::WEEKDAY, self::HOUR, self::MINUTE);
39
40
    /**
41
     * Factory method to create a new CronExpression.
42
     *
43
     * @param string $expression The CRON expression to create.  There are
44
     *      several special predefined values which can be used to substitute the
45
     *      CRON expression:
46
     *
47
     *      @yearly, @annually) - Run once a year, midnight, Jan. 1 - 0 0 1 1 *
48
     *      @monthly - Run once a month, midnight, first of month - 0 0 1 * *
49
     *      @weekly - Run once a week, midnight on Sun - 0 0 * * 0
50
     *      @daily - Run once a day, midnight - 0 0 * * *
51
     *      @hourly - Run once an hour, first minute - 0 * * * *
52
     *
53
*@param CronExpression_FieldFactory $fieldFactory (optional) Field factory to use
54
     *
55
     * @return CronExpression
56
     */
57
    public static function factory($expression, CronExpression_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 self($expression, $fieldFactory ? $fieldFactory : new CronExpression_FieldFactory());
73
    }
74
75
    /**
76
     * Parse a CRON expression
77
     *
78
     * @param string       $expression   CRON expression (e.g. '8 * * * *')
79
     * @param CronExpression_FieldFactory $fieldFactory Factory to create cron fields
80
     */
81
    public function __construct($expression, CronExpression_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);
0 ignored issues
show
Documentation Bug introduced by
It seems like preg_split('/\s/', $valu...1, PREG_SPLIT_NO_EMPTY) can also be of type false. However, the property $cronParts is declared as type array. Maybe add an additional type check?

Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a mixed type is assigned to a property that is type hinted more strictly.

For example, imagine you have a variable $accountId that can either hold an Id object or false (if there is no account id yet). Your code now assigns that value to the id property of an instance of the Account class. This class holds a proper account, so the id value must no longer be false.

Either this assignment is in error or a type check should be added for that assignment.

class Id
{
    public $id;

    public function __construct($id)
    {
        $this->id = $id;
    }

}

class Account
{
    /** @var  Id $id */
    public $id;
}

$account_id = false;

if (starsAreRight()) {
    $account_id = new Id(42);
}

$account = new Account();
if ($account instanceof Id)
{
    $account->id = $account_id;
}
Loading history...
98
        if (count($this->cronParts) < 5) {
0 ignored issues
show
Bug introduced by
It seems like $this->cronParts can also be of type false; however, parameter $var of count() does only seem to accept Countable|array, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

98
        if (count(/** @scrutinizer ignore-type */ $this->cronParts) < 5) {
Loading history...
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 (optional) Relative calculation date
137
     * @param int             $nth         (optional) 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 (optional) Set to TRUE to return the
144
     *     current date if 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      (optional) Relative calculation date
158
     * @param int             $nth              (optional) Number of matches to skip before returning
159
     * @param bool            $allowCurrentDate (optional) 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 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      (optional) Relative calculation date
176
     * @param bool            $invert           (optional) Set to TRUE to retrieve previous dates
177
     * @param bool            $allowCurrentDate (optional) 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 (optional) Specify the part to retrieve or NULL to
196
     *      get the full 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 (optional) 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 = $currentTime->format('Y-m-d H:i');
238
            $currentTime = strtotime($currentDate);
239
        } else {
240
            $currentTime = new DateTime($currentTime);
241
            $currentTime->setTime($currentTime->format('H'), $currentTime->format('i'), 0);
0 ignored issues
show
Bug introduced by
$currentTime->format('i') of type string is incompatible with the type integer expected by parameter $minute of DateTime::setTime(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

241
            $currentTime->setTime($currentTime->format('H'), /** @scrutinizer ignore-type */ $currentTime->format('i'), 0);
Loading history...
Bug introduced by
$currentTime->format('H') of type string is incompatible with the type integer expected by parameter $hour of DateTime::setTime(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

241
            $currentTime->setTime(/** @scrutinizer ignore-type */ $currentTime->format('H'), $currentTime->format('i'), 0);
Loading history...
242
            $currentDate = $currentTime->format('Y-m-d H:i');
243
            $currentTime = (int)($currentTime->getTimestamp());
244
        }
245
246
        return $this->getNextRunDate($currentDate, 0, true)->getTimestamp() == $currentTime;
247
    }
248
249
    /**
250
     * Get the next or previous run date of the expression relative to a date
251
     *
252
     * @param string|DateTime $currentTime      (optional) Relative calculation date
253
     * @param int             $nth              (optional) Number of matches to skip before returning
254
     * @param bool            $invert           (optional) Set to TRUE to go backwards in time
255
     * @param bool            $allowCurrentDate (optional) Set to TRUE to return the
256
     *     current date if it matches the cron expression
257
     *
258
     * @return DateTime
259
     * @throws RuntimeException on too many iterations
260
     */
261
    protected function getRunDate($currentTime = null, $nth = 0, $invert = false, $allowCurrentDate = false)
262
    {
263
        if ($currentTime instanceof DateTime) {
264
            $currentDate = $currentTime;
265
        } else {
266
            $currentDate = new DateTime($currentTime ? $currentTime : 'now');
267
            $currentDate->setTimezone(new DateTimeZone(date_default_timezone_get()));
268
        }
269
270
        $currentDate->setTime($currentDate->format('H'), $currentDate->format('i'), 0);
0 ignored issues
show
Bug introduced by
$currentDate->format('H') of type string is incompatible with the type integer expected by parameter $hour of DateTime::setTime(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

270
        $currentDate->setTime(/** @scrutinizer ignore-type */ $currentDate->format('H'), $currentDate->format('i'), 0);
Loading history...
Bug introduced by
$currentDate->format('i') of type string is incompatible with the type integer expected by parameter $minute of DateTime::setTime(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

270
        $currentDate->setTime($currentDate->format('H'), /** @scrutinizer ignore-type */ $currentDate->format('i'), 0);
Loading history...
271
        $nextRun = clone $currentDate;
272
        $nth = (int) $nth;
273
274
        // Set a hard limit to bail on an impossible date
275
        for ($i = 0; $i < 1000; $i++) {
276
277
            foreach (self::$order as $position) {
278
                $part = $this->getExpression($position);
279
                if (null === $part) {
280
                    continue;
281
                }
282
283
                $satisfied = false;
284
                // Get the field object used to validate this part
285
                $field = $this->fieldFactory->getField($position);
286
                // Check if this is singular or a list
287
                if (strpos($part, ',') === false) {
288
                    $satisfied = $field->isSatisfiedBy($nextRun, $part);
289
                } else {
290
                    foreach (array_map('trim', explode(',', $part)) as $listPart) {
291
                        if ($field->isSatisfiedBy($nextRun, $listPart)) {
292
                            $satisfied = true;
293
                            break;
294
                        }
295
                    }
296
                }
297
298
                // If the field is not satisfied, then start over
299
                if (!$satisfied) {
300
                    $field->increment($nextRun, $invert);
301
                    continue 2;
302
                }
303
            }
304
305
            // Skip this match if needed
306
            if ((!$allowCurrentDate && $nextRun == $currentDate) || --$nth > -1) {
307
                $this->fieldFactory->getField(0)->increment($nextRun, $invert);
308
                continue;
309
            }
310
311
            return $nextRun;
312
        }
313
314
        // @codeCoverageIgnoreStart
315
        throw new RuntimeException('Impossible CRON expression');
316
        // @codeCoverageIgnoreEnd
317
    }
318
}
319