From c9d013079972e46469bfb8b77e87b5076d12e10b Mon Sep 17 00:00:00 2001 From: Jitendra Adhikari Date: Tue, 14 May 2024 16:31:09 +0700 Subject: [PATCH 1/8] chore(deps): bump phpunit --- .gitignore | 1 + composer.json | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) 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", From 65eb5dfca5861a8f8e7f6f7ec1338e311cb35724 Mon Sep 17 00:00:00 2001 From: Jitendra Adhikari Date: Tue, 14 May 2024 16:37:22 +0700 Subject: [PATCH 2/8] feat(ref): add helpers for ticks --- src/ReferenceTime.php | 46 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 42 insertions(+), 4 deletions(-) diff --git a/src/ReferenceTime.php b/src/ReferenceTime.php index cf77269..445eb56 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(); } From 5fca5c1ac2da3b086eca7e9b5ff6b6370c9b1c52 Mon Sep 17 00:00:00 2001 From: Jitendra Adhikari Date: Tue, 14 May 2024 16:39:12 +0700 Subject: [PATCH 3/8] feat(expr): add next tick methods, extract segments due --- src/Expression.php | 72 +++++++++++++++++++++++++++++++++++++++++- src/SegmentChecker.php | 4 ++- 2 files changed, 74 insertions(+), 2 deletions(-) diff --git a/src/Expression.php b/src/Expression.php index fe2f998..6bc025d 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/SegmentChecker.php b/src/SegmentChecker.php index 0d6b709..ed3d454 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; } /** From aedeafdbd419c8cba34df6e1bb4aa9e724d73f6f Mon Sep 17 00:00:00 2001 From: Jitendra Adhikari Date: Tue, 14 May 2024 16:39:37 +0700 Subject: [PATCH 4/8] feat(ticks): experimental next tick support --- src/Ticks.php | 96 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 src/Ticks.php diff --git a/src/Ticks.php b/src/Ticks.php new file mode 100644 index 0000000..4615611 --- /dev/null +++ b/src/Ticks.php @@ -0,0 +1,96 @@ + + * + * + * 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); + } else if ($pos === ReferenceTime::HOUR) { + $ref->add(3600); + } else if ($pos === ReferenceTime::MONTHDAY || $pos === ReferenceTime::WEEKDAY) { + $ref->add(86400); + } else if ($pos === ReferenceTime::MONTH) { + $ref->addMonth(); + } else if ($pos === ReferenceTime::YEAR) { + $ref->addYear(); + } + } + + return [$ref, false]; + } +} From 5126369ba5817762e2f93ff7684a0784fddca327 Mon Sep 17 00:00:00 2001 From: Jitendra Adhikari Date: Tue, 14 May 2024 16:40:11 +0700 Subject: [PATCH 5/8] test: next* --- tests/ExpressionTest.php | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/tests/ExpressionTest.php b/tests/ExpressionTest.php index 8d1622a..f862855 100644 --- a/tests/ExpressionTest.php +++ b/tests/ExpressionTest.php @@ -29,10 +29,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 +41,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 +72,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. * From 8c1b9b7d2243688c1050cb889e935af734bbf7d2 Mon Sep 17 00:00:00 2001 From: Jitendra Adhikari Date: Tue, 14 May 2024 16:49:09 +0700 Subject: [PATCH 6/8] refactor: unexpected exception --- src/SegmentChecker.php | 2 +- src/Validator.php | 12 +++++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/SegmentChecker.php b/src/SegmentChecker.php index ed3d454..06ef141 100644 --- a/src/SegmentChecker.php +++ b/src/SegmentChecker.php @@ -98,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/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) ); } From 1c77ea6b41344b4dea403be4f7e46c1fdc997468 Mon Sep 17 00:00:00 2001 From: StyleCI Bot Date: Fri, 24 May 2024 16:21:53 +0000 Subject: [PATCH 7/8] Apply fixes from StyleCI [ci skip] [skip ci] --- src/Expression.php | 6 +++--- src/Ticks.php | 11 ++++++----- tests/ExpressionTest.php | 1 - 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/Expression.php b/src/Expression.php index 6bc025d..5d1dc65 100644 --- a/src/Expression.php +++ b/src/Expression.php @@ -71,7 +71,7 @@ public static function isDue(string $expr, $time = null): bool } /** - * Next DateTime when the expr would be due again + * 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. @@ -87,7 +87,7 @@ public function nextTick(string $expr, $time = null): DateTime } /** - * Next DateTime when the expr would be due again + * 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. @@ -103,7 +103,7 @@ public static function next(string $expr, $time = null): DateTime } /** - * Next date time as formatted string when the expr would be due again + * 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. diff --git a/src/Ticks.php b/src/Ticks.php index 4615611..edb0e66 100644 --- a/src/Ticks.php +++ b/src/Ticks.php @@ -52,7 +52,7 @@ public function next(string $expr, $time = null): DateTime while ($iter > 0) { $iter--; foreach ($segments as $pos => $seg) { - if ($seg === "*" || $seg === "?") { + if ($seg === '*' || $seg === '?') { continue; } [$new, $isOk] = $this->bumpUntilDue($checker, $seg, $pos, $ref); @@ -67,6 +67,7 @@ public function next(string $expr, $time = null): DateTime if ($this->expr->segmentsDue($segments, $date)) { return $date; } + throw new \RuntimeException('Tried so hard'); } @@ -80,13 +81,13 @@ private function bumpUntilDue(SegmentChecker $checker, string $seg, int $pos, Re } if ($pos === ReferenceTime::MINUTE) { $ref->add(60); - } else if ($pos === ReferenceTime::HOUR) { + } elseif ($pos === ReferenceTime::HOUR) { $ref->add(3600); - } else if ($pos === ReferenceTime::MONTHDAY || $pos === ReferenceTime::WEEKDAY) { + } elseif ($pos === ReferenceTime::MONTHDAY || $pos === ReferenceTime::WEEKDAY) { $ref->add(86400); - } else if ($pos === ReferenceTime::MONTH) { + } elseif ($pos === ReferenceTime::MONTH) { $ref->addMonth(); - } else if ($pos === ReferenceTime::YEAR) { + } elseif ($pos === ReferenceTime::YEAR) { $ref->addYear(); } } diff --git a/tests/ExpressionTest.php b/tests/ExpressionTest.php index f862855..c0a107b 100644 --- a/tests/ExpressionTest.php +++ b/tests/ExpressionTest.php @@ -28,7 +28,6 @@ public function test_isDue($expr, $time, $foo, $expected, $throwsAt = false) /** * @dataProvider invalidScheduleProvider - * */ public function test_isDue_on_invalid_expression($expr, $time, $foo, $expected, $throwsAt = false) { From 6e5cfcdb244da24b13bea882a011afd466a3cfa4 Mon Sep 17 00:00:00 2001 From: Jitendra Adhikari Date: Fri, 24 May 2024 23:27:16 +0700 Subject: [PATCH 8/8] fix: ref time manipulation --- src/ReferenceTime.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ReferenceTime.php b/src/ReferenceTime.php index 445eb56..1be89fc 100644 --- a/src/ReferenceTime.php +++ b/src/ReferenceTime.php @@ -75,14 +75,14 @@ public function addMonth() [$year, $month] = [$year + 1, 1]; } - $new = "$year-$month" . date('-d H:i:s', $this->timestamp); + $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); + $new = "$year-" . date('m-d H:i:s', $this->timestamp); $this->reset(\strtotime($new)); }