Subversion-Projekte lars-tiefland.laravel_shop

Revision

Details | Letzte Änderung | Log anzeigen | RSS feed

Revision Autor Zeilennr. Zeile
148 lars 1
<?php
2
 
3
declare(strict_types=1);
4
 
5
namespace Cron;
6
 
7
use DateTime;
8
use DateTimeImmutable;
9
use DateTimeInterface;
10
use DateTimeZone;
11
use Exception;
12
use InvalidArgumentException;
13
use LogicException;
14
use RuntimeException;
15
use Webmozart\Assert\Assert;
16
 
17
/**
18
 * CRON expression parser that can determine whether or not a CRON expression is
19
 * due to run, the next run date and previous run date of a CRON expression.
20
 * The determinations made by this class are accurate if checked run once per
21
 * minute (seconds are dropped from date time comparisons).
22
 *
23
 * Schedule parts must map to:
24
 * minute [0-59], hour [0-23], day of month, month [1-12|JAN-DEC], day of week
25
 * [1-7|MON-SUN], and an optional year.
26
 *
27
 * @see http://en.wikipedia.org/wiki/Cron
28
 */
29
class CronExpression
30
{
31
    public const MINUTE = 0;
32
    public const HOUR = 1;
33
    public const DAY = 2;
34
    public const MONTH = 3;
35
    public const WEEKDAY = 4;
36
 
37
    /** @deprecated */
38
    public const YEAR = 5;
39
 
40
    public const MAPPINGS = [
41
        '@yearly' => '0 0 1 1 *',
42
        '@annually' => '0 0 1 1 *',
43
        '@monthly' => '0 0 1 * *',
44
        '@weekly' => '0 0 * * 0',
45
        '@daily' => '0 0 * * *',
46
        '@midnight' => '0 0 * * *',
47
        '@hourly' => '0 * * * *',
48
    ];
49
 
50
    /**
51
     * @var array CRON expression parts
52
     */
53
    protected $cronParts;
54
 
55
    /**
56
     * @var FieldFactoryInterface CRON field factory
57
     */
58
    protected $fieldFactory;
59
 
60
    /**
61
     * @var int Max iteration count when searching for next run date
62
     */
63
    protected $maxIterationCount = 1000;
64
 
65
    /**
66
     * @var array Order in which to test of cron parts
67
     */
68
    protected static $order = [
69
        self::YEAR,
70
        self::MONTH,
71
        self::DAY,
72
        self::WEEKDAY,
73
        self::HOUR,
74
        self::MINUTE,
75
    ];
76
 
77
    /**
78
     * @var array<string, string>
79
     */
80
    private static $registeredAliases = self::MAPPINGS;
81
 
82
    /**
83
     * Registered a user defined CRON Expression Alias.
84
     *
85
     * @throws LogicException If the expression or the alias name are invalid
86
     *                         or if the alias is already registered.
87
     */
88
    public static function registerAlias(string $alias, string $expression): void
89
    {
90
        try {
91
            new self($expression);
92
        } catch (InvalidArgumentException $exception) {
93
            throw new LogicException("The expression `$expression` is invalid", 0, $exception);
94
        }
95
 
96
        $shortcut = strtolower($alias);
97
        if (1 !== preg_match('/^@\w+$/', $shortcut)) {
98
            throw new LogicException("The alias `$alias` is invalid. It must start with an `@` character and contain alphanumeric (letters, numbers, regardless of case) plus underscore (_).");
99
        }
100
 
101
        if (isset(self::$registeredAliases[$shortcut])) {
102
            throw new LogicException("The alias `$alias` is already registered.");
103
        }
104
 
105
        self::$registeredAliases[$shortcut] = $expression;
106
    }
107
 
108
    /**
109
     * Unregistered a user defined CRON Expression Alias.
110
     *
111
     * @throws LogicException If the user tries to unregister a built-in alias
112
     */
113
    public static function unregisterAlias(string $alias): bool
114
    {
115
        $shortcut = strtolower($alias);
116
        if (isset(self::MAPPINGS[$shortcut])) {
117
            throw new LogicException("The alias `$alias` is a built-in alias; it can not be unregistered.");
118
        }
119
 
120
        if (!isset(self::$registeredAliases[$shortcut])) {
121
            return false;
122
        }
123
 
124
        unset(self::$registeredAliases[$shortcut]);
125
 
126
        return true;
127
    }
128
 
129
    /**
130
     * Tells whether a CRON Expression alias is registered.
131
     */
132
    public static function supportsAlias(string $alias): bool
133
    {
134
        return isset(self::$registeredAliases[strtolower($alias)]);
135
    }
136
 
137
    /**
138
     * Returns all registered aliases as an associated array where the aliases are the key
139
     * and their associated expressions are the values.
140
     *
141
     * @return array<string, string>
142
     */
143
    public static function getAliases(): array
144
    {
145
        return self::$registeredAliases;
146
    }
147
 
148
    /**
149
     * @deprecated since version 3.0.2, use __construct instead.
150
     */
151
    public static function factory(string $expression, FieldFactoryInterface $fieldFactory = null): CronExpression
152
    {
153
        /** @phpstan-ignore-next-line */
154
        return new static($expression, $fieldFactory);
155
    }
156
 
157
    /**
158
     * Validate a CronExpression.
159
     *
160
     * @param string $expression the CRON expression to validate
161
     *
162
     * @return bool True if a valid CRON expression was passed. False if not.
163
     */
164
    public static function isValidExpression(string $expression): bool
165
    {
166
        try {
167
            new CronExpression($expression);
168
        } catch (InvalidArgumentException $e) {
169
            return false;
170
        }
171
 
172
        return true;
173
    }
174
 
175
    /**
176
     * Parse a CRON expression.
177
     *
178
     * @param string $expression CRON expression (e.g. '8 * * * *')
179
     * @param null|FieldFactoryInterface $fieldFactory Factory to create cron fields
180
     */
181
    public function __construct(string $expression, FieldFactoryInterface $fieldFactory = null)
182
    {
183
        $shortcut = strtolower($expression);
184
        $expression = self::$registeredAliases[$shortcut] ?? $expression;
185
 
186
        $this->fieldFactory = $fieldFactory ?: new FieldFactory();
187
        $this->setExpression($expression);
188
    }
189
 
190
    /**
191
     * Set or change the CRON expression.
192
     *
193
     * @param string $value CRON expression (e.g. 8 * * * *)
194
     *
195
     * @throws \InvalidArgumentException if not a valid CRON expression
196
     *
197
     * @return CronExpression
198
     */
199
    public function setExpression(string $value): CronExpression
200
    {
201
        $split = preg_split('/\s/', $value, -1, PREG_SPLIT_NO_EMPTY);
202
        Assert::isArray($split);
203
 
204
        $this->cronParts = $split;
205
        if (\count($this->cronParts) < 5) {
206
            throw new InvalidArgumentException(
207
                $value . ' is not a valid CRON expression'
208
            );
209
        }
210
 
211
        foreach ($this->cronParts as $position => $part) {
212
            $this->setPart($position, $part);
213
        }
214
 
215
        return $this;
216
    }
217
 
218
    /**
219
     * Set part of the CRON expression.
220
     *
221
     * @param int $position The position of the CRON expression to set
222
     * @param string $value The value to set
223
     *
224
     * @throws \InvalidArgumentException if the value is not valid for the part
225
     *
226
     * @return CronExpression
227
     */
228
    public function setPart(int $position, string $value): CronExpression
229
    {
230
        if (!$this->fieldFactory->getField($position)->validate($value)) {
231
            throw new InvalidArgumentException(
232
                'Invalid CRON field value ' . $value . ' at position ' . $position
233
            );
234
        }
235
 
236
        $this->cronParts[$position] = $value;
237
 
238
        return $this;
239
    }
240
 
241
    /**
242
     * Set max iteration count for searching next run dates.
243
     *
244
     * @param int $maxIterationCount Max iteration count when searching for next run date
245
     *
246
     * @return CronExpression
247
     */
248
    public function setMaxIterationCount(int $maxIterationCount): CronExpression
249
    {
250
        $this->maxIterationCount = $maxIterationCount;
251
 
252
        return $this;
253
    }
254
 
255
    /**
256
     * Get a next run date relative to the current date or a specific date
257
     *
258
     * @param string|\DateTimeInterface $currentTime      Relative calculation date
259
     * @param int                       $nth              Number of matches to skip before returning a
260
     *                                                    matching next run date.  0, the default, will return the
261
     *                                                    current date and time if the next run date falls on the
262
     *                                                    current date and time.  Setting this value to 1 will
263
     *                                                    skip the first match and go to the second match.
264
     *                                                    Setting this value to 2 will skip the first 2
265
     *                                                    matches and so on.
266
     * @param bool                      $allowCurrentDate Set to TRUE to return the current date if
267
     *                                                    it matches the cron expression.
268
     * @param null|string               $timeZone         TimeZone to use instead of the system default
269
     *
270
     * @throws \RuntimeException on too many iterations
271
     * @throws \Exception
272
     *
273
     * @return \DateTime
274
     */
275
    public function getNextRunDate($currentTime = 'now', int $nth = 0, bool $allowCurrentDate = false, $timeZone = null): DateTime
276
    {
277
        return $this->getRunDate($currentTime, $nth, false, $allowCurrentDate, $timeZone);
278
    }
279
 
280
    /**
281
     * Get a previous run date relative to the current date or a specific date.
282
     *
283
     * @param string|\DateTimeInterface $currentTime      Relative calculation date
284
     * @param int                       $nth              Number of matches to skip before returning
285
     * @param bool                      $allowCurrentDate Set to TRUE to return the
286
     *                                                    current date if it matches the cron expression
287
     * @param null|string               $timeZone         TimeZone to use instead of the system default
288
     *
289
     * @throws \RuntimeException on too many iterations
290
     * @throws \Exception
291
     *
292
     * @return \DateTime
293
     *
294
     * @see \Cron\CronExpression::getNextRunDate
295
     */
296
    public function getPreviousRunDate($currentTime = 'now', int $nth = 0, bool $allowCurrentDate = false, $timeZone = null): DateTime
297
    {
298
        return $this->getRunDate($currentTime, $nth, true, $allowCurrentDate, $timeZone);
299
    }
300
 
301
    /**
302
     * Get multiple run dates starting at the current date or a specific date.
303
     *
304
     * @param int $total Set the total number of dates to calculate
305
     * @param string|\DateTimeInterface|null $currentTime Relative calculation date
306
     * @param bool $invert Set to TRUE to retrieve previous dates
307
     * @param bool $allowCurrentDate Set to TRUE to return the
308
     *                               current date if it matches the cron expression
309
     * @param null|string $timeZone TimeZone to use instead of the system default
310
     *
311
     * @return \DateTime[] Returns an array of run dates
312
     */
313
    public function getMultipleRunDates(int $total, $currentTime = 'now', bool $invert = false, bool $allowCurrentDate = false, $timeZone = null): array
314
    {
315
        $timeZone = $this->determineTimeZone($currentTime, $timeZone);
316
 
317
        if ('now' === $currentTime) {
318
            $currentTime = new DateTime();
319
        } elseif ($currentTime instanceof DateTime) {
320
            $currentTime = clone $currentTime;
321
        } elseif ($currentTime instanceof DateTimeImmutable) {
322
            $currentTime = DateTime::createFromFormat('U', $currentTime->format('U'));
323
        } elseif (\is_string($currentTime)) {
324
            $currentTime = new DateTime($currentTime);
325
        }
326
 
327
        Assert::isInstanceOf($currentTime, DateTime::class);
328
        $currentTime->setTimezone(new DateTimeZone($timeZone));
329
 
330
        $matches = [];
331
        for ($i = 0; $i < $total; ++$i) {
332
            try {
333
                $result = $this->getRunDate($currentTime, 0, $invert, $allowCurrentDate, $timeZone);
334
            } catch (RuntimeException $e) {
335
                break;
336
            }
337
 
338
            $allowCurrentDate = false;
339
            $currentTime = clone $result;
340
            $matches[] = $result;
341
        }
342
 
343
        return $matches;
344
    }
345
 
346
    /**
347
     * Get all or part of the CRON expression.
348
     *
349
     * @param int|string|null $part specify the part to retrieve or NULL to get the full
350
     *                     cron schedule string
351
     *
352
     * @return null|string Returns the CRON expression, a part of the
353
     *                     CRON expression, or NULL if the part was specified but not found
354
     */
355
    public function getExpression($part = null): ?string
356
    {
357
        if (null === $part) {
358
            return implode(' ', $this->cronParts);
359
        }
360
 
361
        if (array_key_exists($part, $this->cronParts)) {
362
            return $this->cronParts[$part];
363
        }
364
 
365
        return null;
366
    }
367
 
368
    /**
369
     * Gets the parts of the cron expression as an array.
370
     *
371
     * @return string[]
372
     *   The array of parts that make up this expression.
373
     */
374
    public function getParts()
375
    {
376
        return $this->cronParts;
377
    }
378
 
379
    /**
380
     * Helper method to output the full expression.
381
     *
382
     * @return string Full CRON expression
383
     */
384
    public function __toString(): string
385
    {
386
        return (string) $this->getExpression();
387
    }
388
 
389
    /**
390
     * Determine if the cron is due to run based on the current date or a
391
     * specific date.  This method assumes that the current number of
392
     * seconds are irrelevant, and should be called once per minute.
393
     *
394
     * @param string|\DateTimeInterface $currentTime Relative calculation date
395
     * @param null|string               $timeZone    TimeZone to use instead of the system default
396
     *
397
     * @return bool Returns TRUE if the cron is due to run or FALSE if not
398
     */
399
    public function isDue($currentTime = 'now', $timeZone = null): bool
400
    {
401
        $timeZone = $this->determineTimeZone($currentTime, $timeZone);
402
 
403
        if ('now' === $currentTime) {
404
            $currentTime = new DateTime();
405
        } elseif ($currentTime instanceof DateTime) {
406
            $currentTime = clone $currentTime;
407
        } elseif ($currentTime instanceof DateTimeImmutable) {
408
            $currentTime = DateTime::createFromFormat('U', $currentTime->format('U'));
409
        } elseif (\is_string($currentTime)) {
410
            $currentTime = new DateTime($currentTime);
411
        }
412
 
413
        Assert::isInstanceOf($currentTime, DateTime::class);
414
        $currentTime->setTimezone(new DateTimeZone($timeZone));
415
 
416
        // drop the seconds to 0
417
        $currentTime->setTime((int) $currentTime->format('H'), (int) $currentTime->format('i'), 0);
418
 
419
        try {
420
            return $this->getNextRunDate($currentTime, 0, true)->getTimestamp() === $currentTime->getTimestamp();
421
        } catch (Exception $e) {
422
            return false;
423
        }
424
    }
425
 
426
    /**
427
     * Get the next or previous run date of the expression relative to a date.
428
     *
429
     * @param string|\DateTimeInterface|null $currentTime Relative calculation date
430
     * @param int $nth Number of matches to skip before returning
431
     * @param bool $invert Set to TRUE to go backwards in time
432
     * @param bool $allowCurrentDate Set to TRUE to return the
433
     *                               current date if it matches the cron expression
434
     * @param string|null $timeZone  TimeZone to use instead of the system default
435
     *
436
     * @throws \RuntimeException on too many iterations
437
     * @throws Exception
438
     *
439
     * @return \DateTime
440
     */
441
    protected function getRunDate($currentTime = null, int $nth = 0, bool $invert = false, bool $allowCurrentDate = false, $timeZone = null): DateTime
442
    {
443
        $timeZone = $this->determineTimeZone($currentTime, $timeZone);
444
 
445
        if ($currentTime instanceof DateTime) {
446
            $currentDate = clone $currentTime;
447
        } elseif ($currentTime instanceof DateTimeImmutable) {
448
            $currentDate = DateTime::createFromFormat('U', $currentTime->format('U'));
449
        } elseif (\is_string($currentTime)) {
450
            $currentDate = new DateTime($currentTime);
451
        } else {
452
            $currentDate = new DateTime('now');
453
        }
454
 
455
        Assert::isInstanceOf($currentDate, DateTime::class);
456
        $currentDate->setTimezone(new DateTimeZone($timeZone));
457
        // Workaround for setTime causing an offset change: https://bugs.php.net/bug.php?id=81074
458
        $currentDate = DateTime::createFromFormat("!Y-m-d H:iO", $currentDate->format("Y-m-d H:iP"), $currentDate->getTimezone());
459
        if ($currentDate === false) {
460
            throw new \RuntimeException('Unable to create date from format');
461
        }
462
        $currentDate->setTimezone(new DateTimeZone($timeZone));
463
 
464
        $nextRun = clone $currentDate;
465
 
466
        // We don't have to satisfy * or null fields
467
        $parts = [];
468
        $fields = [];
469
        foreach (self::$order as $position) {
470
            $part = $this->getExpression($position);
471
            if (null === $part || '*' === $part) {
472
                continue;
473
            }
474
            $parts[$position] = $part;
475
            $fields[$position] = $this->fieldFactory->getField($position);
476
        }
477
 
478
        if (isset($parts[self::DAY]) && isset($parts[self::WEEKDAY])) {
479
            $domExpression = sprintf('%s %s %s %s *', $this->getExpression(0), $this->getExpression(1), $this->getExpression(2), $this->getExpression(3));
480
            $dowExpression = sprintf('%s %s * %s %s', $this->getExpression(0), $this->getExpression(1), $this->getExpression(3), $this->getExpression(4));
481
 
482
            $domExpression = new self($domExpression);
483
            $dowExpression = new self($dowExpression);
484
 
485
            $domRunDates = $domExpression->getMultipleRunDates($nth + 1, $currentTime, $invert, $allowCurrentDate, $timeZone);
486
            $dowRunDates = $dowExpression->getMultipleRunDates($nth + 1, $currentTime, $invert, $allowCurrentDate, $timeZone);
487
 
488
            if ($parts[self::DAY] === '?' || $parts[self::DAY] === '*') {
489
                $domRunDates = [];
490
            }
491
 
492
            if ($parts[self::WEEKDAY] === '?' || $parts[self::WEEKDAY] === '*') {
493
                $dowRunDates = [];
494
            }
495
 
496
            $combined = array_merge($domRunDates, $dowRunDates);
497
            usort($combined, function ($a, $b) {
498
                return $a->format('Y-m-d H:i:s') <=> $b->format('Y-m-d H:i:s');
499
            });
500
            if ($invert) {
501
                $combined = array_reverse($combined);
502
            }
503
 
504
            return $combined[$nth];
505
        }
506
 
507
        // Set a hard limit to bail on an impossible date
508
        for ($i = 0; $i < $this->maxIterationCount; ++$i) {
509
            foreach ($parts as $position => $part) {
510
                $satisfied = false;
511
                // Get the field object used to validate this part
512
                $field = $fields[$position];
513
                // Check if this is singular or a list
514
                if (false === strpos($part, ',')) {
515
                    $satisfied = $field->isSatisfiedBy($nextRun, $part, $invert);
516
                } else {
517
                    foreach (array_map('trim', explode(',', $part)) as $listPart) {
518
                        if ($field->isSatisfiedBy($nextRun, $listPart, $invert)) {
519
                            $satisfied = true;
520
 
521
                            break;
522
                        }
523
                    }
524
                }
525
 
526
                // If the field is not satisfied, then start over
527
                if (!$satisfied) {
528
                    $field->increment($nextRun, $invert, $part);
529
 
530
                    continue 2;
531
                }
532
            }
533
 
534
            // Skip this match if needed
535
            if ((!$allowCurrentDate && $nextRun == $currentDate) || --$nth > -1) {
536
                $this->fieldFactory->getField(self::MINUTE)->increment($nextRun, $invert, $parts[self::MINUTE] ?? null);
537
                continue;
538
            }
539
 
540
            return $nextRun;
541
        }
542
 
543
        // @codeCoverageIgnoreStart
544
        throw new RuntimeException('Impossible CRON expression');
545
        // @codeCoverageIgnoreEnd
546
    }
547
 
548
    /**
549
     * Workout what timeZone should be used.
550
     *
551
     * @param string|\DateTimeInterface|null $currentTime Relative calculation date
552
     * @param string|null $timeZone TimeZone to use instead of the system default
553
     *
554
     * @return string
555
     */
556
    protected function determineTimeZone($currentTime, ?string $timeZone): string
557
    {
558
        if (null !== $timeZone) {
559
            return $timeZone;
560
        }
561
 
562
        if ($currentTime instanceof DateTimeInterface) {
563
            return $currentTime->getTimezone()->getName();
564
        }
565
 
566
        return date_default_timezone_get();
567
    }
568
}