diff --git a/.gitignore b/.gitignore index 21bcd5c..daf8ba7 100644 --- a/.gitignore +++ b/.gitignore @@ -3,5 +3,6 @@ /.env /.idea/ /vendor/ +*.result.cache composer.lock coverage.xml diff --git a/composer.json b/composer.json index 63fa8f4..4be0730 100644 --- a/composer.json +++ b/composer.json @@ -26,7 +26,7 @@ "php": ">=7.0" }, "require-dev": { - "phpunit/phpunit": "^6.5 || ^7.5" + "phpunit/phpunit": "^6.5 || ^7.5 || ^8.5" }, "scripts": { "test": "phpunit", diff --git a/src/Expression.php b/src/Expression.php index fe2f998..5d1dc65 100644 --- a/src/Expression.php +++ b/src/Expression.php @@ -13,6 +13,8 @@ namespace Ahc\Cron; +use DateTime; + /** * Cron Expression Parser. * @@ -32,10 +34,14 @@ class Expression /** @var Normalizer */ protected $normalizer; + /** @var Ticks */ + protected $ticks; + public function __construct(SegmentChecker $checker = null, Normalizer $normalizer = null) { $this->checker = $checker ?: new SegmentChecker; $this->normalizer = $normalizer ?: new Normalizer; + $this->ticks = new Ticks($this); if (null === static::$instance) { static::$instance = $this; @@ -64,6 +70,55 @@ public static function isDue(string $expr, $time = null): bool return static::instance()->isCronDue($expr, $time); } + /** + * Next DateTime when the expr would be due again. + * + * @param string $expr The cron expression. + * @param mixed $time The timestamp to validate the cron expr against. Defaults to now. + * + * @throws \RuntimeException + * @throws \UnexpectedValueException + * + * @return \DateTime + */ + public function nextTick(string $expr, $time = null): DateTime + { + return $this->ticks->next($expr, $time); + } + + /** + * Next DateTime when the expr would be due again. + * + * @param string $expr The cron expression. + * @param mixed $time The timestamp to validate the cron expr against. Defaults to now. + * + * @throws \RuntimeException + * @throws \UnexpectedValueException + * + * @return \DateTime + */ + public static function next(string $expr, $time = null): DateTime + { + return static::instance()->nextTick($expr, $time); + } + + /** + * Next date time as formatted string when the expr would be due again. + * + * @param string $expr The cron expression. + * @param mixed $time The timestamp to validate the cron expr against. Defaults to now. + * @param string $fmt The format + * + * @throws \RuntimeException + * @throws \UnexpectedValueException + * + * @return \DateTime + */ + public static function nextf(string $expr, $time = null, string $fmt = 'Y-m-d H:i:s'): string + { + return static::next($expr, $time)->format($fmt); + } + /** * Filter only the jobs that are due. * @@ -88,10 +143,15 @@ public static function getDues(array $jobs, $time = null): array * @return bool */ public function isCronDue(string $expr, $time = null): bool + { + return $this->segmentsDue($this->segments($expr), $time); + } + + public function segmentsDue(array $segments, $time = null): bool { $this->checker->setReference(new ReferenceTime($time)); - foreach (\explode(' ', $this->normalizer->normalizeExpr($expr)) as $pos => $segment) { + foreach ($segments as $pos => $segment) { if ($segment === '*' || $segment === '?') { continue; } @@ -104,6 +164,16 @@ public function isCronDue(string $expr, $time = null): bool return true; } + public function segments(string $expr): array + { + return \explode(' ', $this->normalizer->normalizeExpr($expr)); + } + + public function segmentChecker(): SegmentChecker + { + return $this->checker; + } + /** * Filter only the jobs that are due. * diff --git a/src/ReferenceTime.php b/src/ReferenceTime.php index cf77269..1be89fc 100644 --- a/src/ReferenceTime.php +++ b/src/ReferenceTime.php @@ -13,6 +13,8 @@ namespace Ahc\Cron; +use DateTime; + /** * @method int minute() * @method int hour() @@ -45,14 +47,50 @@ class ReferenceTime /** @var array The Magic methods */ protected $methods = []; + public $timestamp; + public function __construct($time) { - $timestamp = $this->normalizeTime($time); - - $this->values = $this->parse($timestamp); + $this->reset($this->normalizeTime($time)); $this->methods = (new \ReflectionClass($this))->getConstants(); } + public function reset(int $timestamp) + { + $this->timestamp = $timestamp; + $this->values = $this->parse($timestamp); + } + + public function add(int $sec) + { + $this->reset($this->timestamp + $sec); + } + + public function addMonth() + { + $year = $this->values[self::YEAR]; + $month = $this->values[self::MONTH] + 1; + + if ($month > 12) { + [$year, $month] = [$year + 1, 1]; + } + + $new = "$year-$month-" . date('d H:i:s', $this->timestamp); + $this->reset(\strtotime($new)); + } + + public function addYear() + { + $year = $this->values[self::YEAR] + 1; + $new = "$year-" . date('m-d H:i:s', $this->timestamp); + $this->reset(\strtotime($new)); + } + + public function dateTime(): DateTime + { + return new DateTime(date('Y-m-d H:i:s', $this->timestamp)); + } + public function __call(string $method, array $args): int { $method = \preg_replace('/^GET/', '', \strtoupper($method)); @@ -81,7 +119,7 @@ protected function normalizeTime($time): int $time = \time(); } elseif (\is_string($time)) { $time = \strtotime($time); - } elseif ($time instanceof \DateTime) { + } elseif ($time instanceof DateTime) { $time = $time->getTimestamp(); } diff --git a/src/SegmentChecker.php b/src/SegmentChecker.php index 0d6b709..06ef141 100644 --- a/src/SegmentChecker.php +++ b/src/SegmentChecker.php @@ -33,9 +33,11 @@ public function __construct(Validator $validator = null) $this->validator = $validator ?: new Validator; } - public function setReference(ReferenceTime $reference) + public function setReference(ReferenceTime $reference): self { $this->reference = $reference; + + return $this; } /** @@ -96,7 +98,7 @@ protected function checkModifier(string $offset, int $pos): bool return $this->validator->isValidWeekDay($offset, $this->reference); } - $this->validator->unexpectedValue($pos, $offset); + throw $this->validator->unexpectedValue($pos, $offset); // @codeCoverageIgnoreStart } // @codeCoverageIgnoreEnd diff --git a/src/Ticks.php b/src/Ticks.php new file mode 100644 index 0000000..edb0e66 --- /dev/null +++ b/src/Ticks.php @@ -0,0 +1,97 @@ + + * + * + * Licensed under MIT license. + */ + +namespace Ahc\Cron; + +use DateTime; + +/** + * Ticks for next/prev ticks of an expr based on given time. + * Next tick support is experimental. + * Prev tick is not yet implemented. + */ +class Ticks +{ + /** @var Expression */ + protected $expr; + + protected $limits = [ + ReferenceTime::MINUTE => 60, + ReferenceTime::HOUR => 24, + ReferenceTime::MONTHDAY => 31, + ReferenceTime::MONTH => 12, + ReferenceTime::WEEKDAY => 366, + ReferenceTime::YEAR => 100, + ]; + + public function __construct(Expression $expr) + { + $this->expr = $expr; + } + + public function next(string $expr, $time = null): DateTime + { + $checker = $this->expr->segmentChecker(); + $segments = $this->expr->segments($expr); + + $iter = 500; + $ref = new ReferenceTime($time); + $ref->add(60 - $ref->timestamp % 60); // truncate seconds + + over: + while ($iter > 0) { + $iter--; + foreach ($segments as $pos => $seg) { + if ($seg === '*' || $seg === '?') { + continue; + } + [$new, $isOk] = $this->bumpUntilDue($checker, $seg, $pos, $ref); + if ($isOk) { + $ref = $new; + goto over; + } + } + } + + $date = $ref->dateTime(); + if ($this->expr->segmentsDue($segments, $date)) { + return $date; + } + + throw new \RuntimeException('Tried so hard'); + } + + private function bumpUntilDue(SegmentChecker $checker, string $seg, int $pos, ReferenceTime $ref): array + { + $iter = $this->limits[$pos]; + while ($iter > 0) { + $iter--; + if ($checker->setReference($ref)->checkDue($seg, $pos)) { + return [$ref, true]; + } + if ($pos === ReferenceTime::MINUTE) { + $ref->add(60); + } elseif ($pos === ReferenceTime::HOUR) { + $ref->add(3600); + } elseif ($pos === ReferenceTime::MONTHDAY || $pos === ReferenceTime::WEEKDAY) { + $ref->add(86400); + } elseif ($pos === ReferenceTime::MONTH) { + $ref->addMonth(); + } elseif ($pos === ReferenceTime::YEAR) { + $ref->addYear(); + } + } + + return [$ref, false]; + } +} diff --git a/src/Validator.php b/src/Validator.php index 1f3acce..c849bba 100644 --- a/src/Validator.php +++ b/src/Validator.php @@ -13,6 +13,8 @@ namespace Ahc\Cron; +use UnexpectedValueException; + /** * Cron segment validator. * @@ -108,7 +110,7 @@ public function isValidMonthDay(string $value, ReferenceTime $reference): bool return $this->isClosestWeekDay((int) $value, $month, $reference); } - $this->unexpectedValue(2, $value); + throw $this->unexpectedValue(2, $value); // @codeCoverageIgnoreStart } @@ -153,7 +155,7 @@ public function isValidWeekDay(string $value, ReferenceTime $reference): bool } if (!\strpos($value, '#')) { - $this->unexpectedValue(4, $value); + throw $this->unexpectedValue(4, $value); } list($day, $nth) = \explode('#', \str_replace('7#', '0#', $value)); @@ -171,11 +173,11 @@ public function isValidWeekDay(string $value, ReferenceTime $reference): bool * @param int $pos * @param string $value * - * @throws \UnexpectedValueException + * @return \UnexpectedValueException */ - public function unexpectedValue(int $pos, string $value) + public function unexpectedValue(int $pos, string $value): UnexpectedValueException { - throw new \UnexpectedValueException( + return new UnexpectedValueException( \sprintf('Invalid offset value at segment #%d: %s', $pos, $value) ); } diff --git a/tests/ExpressionTest.php b/tests/ExpressionTest.php index 8d1622a..c0a107b 100644 --- a/tests/ExpressionTest.php +++ b/tests/ExpressionTest.php @@ -28,11 +28,11 @@ public function test_isDue($expr, $time, $foo, $expected, $throwsAt = false) /** * @dataProvider invalidScheduleProvider - * - * @expectedException \UnexpectedValueException */ public function test_isDue_on_invalid_expression($expr, $time, $foo, $expected, $throwsAt = false) { + $this->expectException(\UnexpectedValueException::class); + Expression::isDue($expr, $time); } @@ -40,29 +40,27 @@ public function test_isCronDue() { $expr = new Expression; - $this->assertInternalType('boolean', $expr->isCronDue('*/1 * * * *', time())); + $this->assertIsBool($expr->isCronDue('*/1 * * * *', time())); } - /** - * @expectedException \UnexpectedValueException - */ public function test_isDue_throws_if_expr_invalid() { + $this->expectException(\UnexpectedValueException::class); + Expression::isDue('@invalid'); } - /** - * @expectedException \UnexpectedValueException - */ public function test_isDue_throws_if_modifier_invalid() { + $this->expectException(\UnexpectedValueException::class); + Expression::isDue('* * 2L * *'); } public function test_filter_getDues() { $jobs = [ - 'job1' => '*/2 */2 * * *', + 'job0' => '*/2 */2 * * *', 'job1' => '* 20,21,22 * * *', 'job3' => '7-9 * */9 * *', 'job4' => '*/5 * * * *', @@ -73,6 +71,11 @@ public function test_filter_getDues() $this->assertSame(['job1', 'job4', 'job5'], Expression::getDues($jobs, '2015-08-10 21:50:00')); } + public function test_next() + { + $this->assertSame('2024-01-01 01:10:00', Expression::nextf('10,20 * * * * *', '2024-01-01 01:01:02')); + } + /** * Data provider for cron schedule. *