From 36809e4e201ddff65e8ab12f9ac141f0700b2a0b Mon Sep 17 00:00:00 2001 From: Aurimas Niekis Date: Sun, 22 Mar 2020 13:58:43 +0900 Subject: [PATCH] Initial Commit --- .editorconfig | 15 ++ .gitattributes | 15 ++ .gitignore | 8 + .php_cs.dist | 23 +++ .scrutinizer.yml | 30 +++ .styleci.yml | 1 + .travis.yml | 36 ++++ CONDUCT.md | 51 +++++ CONTRIBUTING.md | 93 +++++++++ LICENSE | 22 +++ README.md | 181 ++++++++++++++++++ composer.json | 61 ++++++ infection.json.dist | 16 ++ phpunit.xml.dist | 28 +++ src/AurimasNiekisSchedulerBundle.php | 54 ++++++ .../Command/SchedulerExecuteCommand.php | 46 +++++ src/Console/Command/SchedulerListCommand.php | 66 +++++++ src/Console/Command/SchedulerRunCommand.php | 39 ++++ src/NamedScheduledJobInterface.php | 13 ++ src/ScheduledJobInterface.php | 13 ++ src/Scheduler.php | 117 +++++++++++ tests/Fixtures/JobFixture.php | 37 ++++ tests/Fixtures/NamedJobFixture.php | 18 ++ tests/SchedulerTest.php | 158 +++++++++++++++ 24 files changed, 1141 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 .php_cs.dist create mode 100644 .scrutinizer.yml create mode 100644 .styleci.yml create mode 100644 .travis.yml create mode 100644 CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 composer.json create mode 100644 infection.json.dist create mode 100644 phpunit.xml.dist create mode 100644 src/AurimasNiekisSchedulerBundle.php create mode 100644 src/Console/Command/SchedulerExecuteCommand.php create mode 100644 src/Console/Command/SchedulerListCommand.php create mode 100644 src/Console/Command/SchedulerRunCommand.php create mode 100644 src/NamedScheduledJobInterface.php create mode 100644 src/ScheduledJobInterface.php create mode 100644 src/Scheduler.php create mode 100644 tests/Fixtures/JobFixture.php create mode 100644 tests/Fixtures/NamedJobFixture.php create mode 100644 tests/SchedulerTest.php diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..cd8eb86 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +; This file is for unifying the coding style for different editors and IDEs. +; More information at http://editorconfig.org + +root = true + +[*] +charset = utf-8 +indent_size = 4 +indent_style = space +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..3856314 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,15 @@ +# Path-based git attributes +# https://www.kernel.org/pub/software/scm/git/docs/gitattributes.html + +# Ignore all test and documentation with "export-ignore". +/.editorconfig export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/.scrutinizer.yml export-ignore +/.styleci.yml export-ignore +/.travis.yml export-ignore +/PULL_REQUEST_TEMPLATE.md export-ignore +/ISSUE_TEMPLATE.md export-ignore +/phpcs.xml.dist export-ignore +/tests export-ignore +/docs export-ignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3adb4c7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +build +composer.lock +vendor +phpcs.xml +phpunit.xml +.phpunit.result.cache +.php_cs.cache +coverage.clover \ No newline at end of file diff --git a/.php_cs.dist b/.php_cs.dist new file mode 100644 index 0000000..4d69f4c --- /dev/null +++ b/.php_cs.dist @@ -0,0 +1,23 @@ +in([__DIR__ . '/src', __DIR__ . '/tests']); + +return PhpCsFixer\Config::create() + ->setRiskyAllowed(true) + ->setRules( + [ + '@Symfony' => true, + 'binary_operator_spaces' => ['align_double_arrow' => true, 'align_equals' => true], + 'ordered_imports' => true, + 'array_syntax' => ['syntax' => 'short'], + 'void_return' => true, + 'declare_strict_types' => true, + 'yoda_style' => true, + 'increment_style' => ['style' => 'post'], + 'concat_space' => ['spacing' => 'one'], + ] + ) + ->setFinder($finder) + ->setUsingCache(true); + diff --git a/.scrutinizer.yml b/.scrutinizer.yml new file mode 100644 index 0000000..7a64766 --- /dev/null +++ b/.scrutinizer.yml @@ -0,0 +1,30 @@ +build: + nodes: + analysis: + environment: + php: 7.4 + tests: + override: + - command: composer test-ci + coverage: + file: coverage.clover + format: clover + +filter: + excluded_paths: [tests/*] + +checks: + php: + remove_extra_empty_lines: true + remove_php_closing_tag: true + remove_trailing_whitespace: true + fix_use_statements: + remove_unused: true + preserve_multiple: false + preserve_blanklines: true + order_alphabetically: true + fix_php_opening_tag: true + fix_linefeed: true + fix_line_ending: true + fix_identation_4spaces: true + fix_doc_comments: true \ No newline at end of file diff --git a/.styleci.yml b/.styleci.yml new file mode 100644 index 0000000..247a09c --- /dev/null +++ b/.styleci.yml @@ -0,0 +1 @@ +preset: psr2 diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..601489c --- /dev/null +++ b/.travis.yml @@ -0,0 +1,36 @@ +language: php + +cache: + directories: + - $HOME/.composer/cache + +env: + matrix: + - PHP_CS_FIXER_IGNORE_ENV=true + global: + - secure: Cpdeojb2vdl3UlTTIehBmey99olGKf9a+s10nVj4VqVVH9eXWzLeSLs1qzHHR9tnnwRouC5awhYeWVmZHblQzn5a/ZWdFsRoxfORu/Z+R0MSkvv9FqdjIpludr5xzaMXJHq5pBicewfqcfBpWfiE3gGKwcRg4d0zw1hvoBoREIG11yl/2/RIozlMbo6MbIfrKdmD5fvHdzEJC3Hvs68nefGjLEbCucY5WAIKS8upnD05fYGJkG+Ak/A8ke/oLskxk2jWNpKQ/I2bOpslkUgU0VFiFEZUS/nFqLBnsU/hfh67hCcga4xWwSwlEl011L4yutEg3rrYdbs8peAxk+8o44ywXbzr32dXkT5CvbDlV96imdwEW4Ca3Y4/GW7wNVV6ENLwNaJB7PSAvKL1Rtuz8GNzLdZ0FJWQNEL0FSXAaMh1ptHJbpilQb5G0DKIb2sE9ht0AR0RNDxTna3wkWMo55XcsCyFh3ZkuynEBp9ysuHs4OiiDb7eewX34OFIGMt52UXyqZT5we4DLweIodalOPrHGrrMHKsqsEraMo+x6pIAnuPhCgJ4yAOGppnLCdpqY0yOxGi/dJs8MWc0x1bMAN7ISDe7sIZe82Brm9VTQd2hY+AkczhpQHdUbq2tgWRrOjROCLeQ7gSrmBI89C/53XFRplZ00v63dbsyzTYOKmc= + +php: + - 7.4 + +# This triggers builds to run on the new TravisCI infrastructure. +# See: http://docs.travis-ci.com/user/workers/container-based-infrastructure/ +sudo: false + +before_script: + - wget https://github.com/infection/infection/releases/download/0.15.0/infection.phar + - wget https://github.com/infection/infection/releases/download/0.15.0/infection.phar.asc + - gpg --keyserver hkps.pool.sks-keyservers.net --recv-keys 493B4AA0 + - gpg --with-fingerprint --verify infection.phar.asc infection.phar + - chmod +x infection.phar + +before_install: + - travis_retry composer self-update --no-interaction + +install: + - travis_retry composer update --no-interaction --prefer-dist + +script: + - composer cs-check + - composer test-ci + - ./infection.phar --min-msi=48 --threads=4 --only-covered \ No newline at end of file diff --git a/CONDUCT.md b/CONDUCT.md new file mode 100644 index 0000000..cf33a53 --- /dev/null +++ b/CONDUCT.md @@ -0,0 +1,51 @@ +# Contributor Code of Conduct + +As contributors and maintainers of this project, and in the interest of +fostering an open and welcoming community, we pledge to respect all people who +contribute through reporting issues, posting feature requests, updating +documentation, submitting pull requests or patches, and other activities. + +We are committed to making participation in this project a harassment-free +experience for everyone, regardless of level of experience, gender, gender +identity and expression, sexual orientation, disability, personal appearance, +body size, race, ethnicity, age, religion, or nationality. + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery +* Personal attacks +* Trolling or insulting/derogatory comments +* Public or private harassment +* Publishing other's private information, such as physical or electronic + addresses, without explicit permission +* Other unethical or unprofessional conduct + +Project maintainers have the right and responsibility to remove, edit, or +reject comments, commits, code, wiki edits, issues, and other contributions +that are not aligned to this Code of Conduct, or to ban temporarily or +permanently any contributor for other behaviors that they deem inappropriate, +threatening, offensive, or harmful. + +By adopting this Code of Conduct, project maintainers commit themselves to +fairly and consistently applying these principles to every aspect of managing +this project. Project maintainers who do not follow or enforce the Code of +Conduct may be permanently removed from the project team. + +This code of conduct applies both within project spaces and in public spaces +when an individual is representing the project or its community. + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported by contacting a project maintainer at [Aurimas Niekis][email]. All +complaints will be reviewed and investigated and will result in a response that +is deemed necessary and appropriate to the circumstances. Maintainers are +obligated to maintain confidentiality with regard to the reporter of an +incident. + + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 1.3.0, available at +[http://contributor-covenant.org/version/1/3/0/][version] + +[homepage]: http://contributor-covenant.org +[version]: http://contributor-covenant.org/version/1/3/0/ +[email]: mailto:aurimas@niekis.lt diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..386874f --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,93 @@ +# Contributing + +If you're here, you would like to contribute to this repository and you're really welcome! + + +## Bug reports + +If you find a bug or a documentation issue, please report it or even better: fix it :). If you report it, +please be as precise as possible. Here is a little list of required information: + + - Precise description of the bug + - Details of your environment (for example: OS, PHP version, installed extensions) + - Backtrace which might help identifing the bug + + +## Feature requests + +If you think a feature is missing, please report it or even better: implement it :). If you report it, describe the more +precisely what you would like to see implemented and we will discuss what is the best approach for it. If you can do +some research before submitting it and link the resources to your description, you're awesome! It will allow us to more +easily understood/implement it. + + +## Sending a Pull Request + +If you're here, you are going to fix a bug or implement a feature and you're the best! +To do it, first fork the repository, clone it and create a new branch with the following commands: + +``` bash +$ git clone git@github.com:your-name/scheduler-bundle.git +$ git checkout -b feature-or-bug-fix-description +``` + +Then install the dependencies through [Composer](https://getcomposer.org/): + +``` bash +$ composer install +``` + +Write code and tests. When you are ready, run the tests. +(This is usually [PHPUnit](http://phpunit.de/)) + +``` bash +$ composer test +``` + +When you are ready with the code, tested it and documented it, you can commit and push it with the following commands: + +``` bash +$ git commit -m "Feature or bug fix description" +$ git push origin feature-or-bug-fix-description +``` + +**Note:** Please write your commit messages in the imperative and follow the +[guidelines](http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html) for clear and concise messages. + +Then [create a pull request](https://help.github.com/articles/creating-a-pull-request/) on GitHub. + +Please make sure that each individual commit in your pull request is meaningful. +If you had to make multiple intermediate commits while developing, +please squash them before submitting with the following commands +(here, we assume you would like to squash 3 commits in a single one): + +``` bash +$ git rebase -i HEAD~3 +``` + +If your branch conflicts with the master branch, you will need to rebase and repush it with the following commands: + +``` bash +$ git remote add upstream git@github.com:aurimansiekis/scheduler-bundle.git +$ git pull --rebase upstream master +$ git push -f origin feature-or-bug-fix-description +``` + + +## Coding standard + +This repository follows the [PSR-2 standard](http://www.php-fig.org/psr/psr-2/) and so, if you want to contribute, +you must follow these rules. + + +## Semver + +We are trying to follow [semver](http://semver.org/). When you are making BC breaking changes, +please let us know why you think it is important. +In this case, your patch can only be included in the next major version. + + +## Code of Conduct + +This project is released with a [Contributor Code of Conduct](CONDUCT.md). +By participating in this project you agree to abide by its terms. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..78acee2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +The MIT License (MIT) + +Copyright (c) 2020 Aurimas Niekis + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + diff --git a/README.md b/README.md new file mode 100644 index 0000000..b2dbfaa --- /dev/null +++ b/README.md @@ -0,0 +1,181 @@ +# Scheduler Bundle + +[![Latest Version on Packagist][ico-version]][link-packagist] +[![Software License][ico-license]](LICENSE) +[![Build Status][ico-travis]][link-travis] +[![Code Quality][ico-quality]][link-scrutinizer] +[![Code Coverage][ico-coverage]][link-scrutinizer] +[![Mutation testing badge][ico-mutation]][link-mutator] +[![Total Downloads][ico-downloads]][link-downloads] + +[![Email][ico-email]][link-email] + +A simple scheduler bundle for Symfony which provides a way to execute code at specific cron expressions without +modifying cron tab every time. + + +## Install + +Via Composer + +```bash +$ composer require aurimasniekis/scheduler-bundle +``` + +Add line to `bundle.php`: + +```php + ['all' => true], + // ... + AurimasNiekis\SchedulerBundle\AurimasNiekisSchedulerBundle::class => ['all' => true], +]; +``` + +Add the scheduler to cron tab to run every minute: + +```bash +* * * * * /path/to/symfony/install/bin/console scheduler:run 1>> /dev/null 2>&1 +``` + +## Usage + +Scheduler Bundle uses Symfony Container `autoconfigure` feature which automatically registers all services +which implement `ScheduledJobInterface` or `NamedScheduledJobInterface` interface into Scheduler. + +To create a scheduled job you have two options either simple Scheduled Job or Named Scheduled Job. First one uses +class name as job name, second provides method to define a job name. + +```php +> /dev/null 2>&1 +``` + +## Testing + + +Run test cases + +```bash +$ composer test +``` + +Run test cases with coverage (HTML format) + + +```bash +$ composer test-coverage +``` + +Run PHP style checker + +```bash +$ composer cs-check +``` + +Run PHP style fixer + +```bash +$ composer cs-fix +``` + +## Contributing + +Please see [CONTRIBUTING](CONTRIBUTING.md) and [CONDUCT](CONDUCT.md) for details. + + +## License + +Please see [License File](LICENSE) for more information. + +[ico-version]: https://img.shields.io/packagist/v/aurimasniekis/scheduler-bundle.svg?style=flat-square +[ico-license]: https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square +[ico-travis]: https://img.shields.io/travis/com/aurimasniekis/scheduler-bundle/master.svg?style=flat-square +[ico-quality]: https://img.shields.io/scrutinizer/quality/g/aurimasniekis/scheduler-bundle?style=flat-square +[ico-coverage]: https://img.shields.io/scrutinizer/coverage/g/aurimasniekis/scheduler-bundle?style=flat-square +[ico-mutation]: https://img.shields.io/endpoint?style=flat-square&url=https%3A%2F%2Fbadge-api.stryker-mutator.io%2Fgithub.com%2Faurimasniekis%2Fscheduler-bundle%2Fmaster +[ico-downloads]: https://img.shields.io/packagist/dt/aurimasniekis/scheduler-bundle.svg?style=flat-square +[ico-email]: https://img.shields.io/badge/email-aurimas@niekis.lt-blue.svg?style=flat-square + +[link-travis]: https://travis-ci.com/aurimasniekis/scheduler-bundle +[link-packagist]: https://packagist.org/packages/aurimasniekis/scheduler-bundle +[link-scrutinizer]: https://scrutinizer-ci.com/g/aurimasniekis/scheduler-bundle +[link-mutator]: https://dashboard.stryker-mutator.io/reports/github.com/aurimasniekis/scheduler-bundle/master +[link-downloads]: https://packagist.org/packages/aurimasniekis/scheduler-bundle/stats +[link-email]: mailto:aurimas@niekis.lt diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..79f4ab1 --- /dev/null +++ b/composer.json @@ -0,0 +1,61 @@ +{ + "name": "aurimasniekis/scheduler-bundle", + "description": "A simple Symfony Job Scheduling Bundle", + "keywords": [ + "symfony", + "scheduler", + "cron", + "job", + "cronjob" + ], + "homepage": "https://github.com/aurimasniekis/scheduler-bundle", + "type": "symfony-bundle", + "license": "MIT", + "authors": [ + { + "name": "Aurimas Niekis", + "email": "aurimas@niekis.lt", + "homepage": "https://aurimas.niekis.lt" + } + ], + "support": { + "issues": "https://github.com/aurimasniekis/scheduler-bundle", + "source": "https://github.com/aurimasniekis/scheduler-bundle" + }, + "require": { + "php": "^7.4||^8.0", + "dragonmantank/cron-expression": "^2.3", + "psr/log": "^1.1", + "symfony/console": "~4.0||~5.0", + "symfony/framework-bundle": "~4.0||~5.0" + }, + "autoload": { + "psr-4": { + "AurimasNiekis\\SchedulerBundle\\": "src" + } + }, + "autoload-dev": { + "psr-4": { + "AurimasNiekis\\SchedulerBundle\\Tests\\": "tests" + } + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^2", + "phpunit/phpunit": "^9.0" + }, + "scripts": { + "test": "phpunit", + "test-coverage": "phpunit --coverage-html build/html/", + "test-ci": "phpunit --coverage-text --coverage-clover=coverage.clover", + "cs-check": "php-cs-fixer fix --dry-run --diff --diff-format udiff", + "cs-fix": "php-cs-fixer fix" + }, + "extra": { + "branch-alias": { + "dev-master": "1.0-dev" + } + }, + "config": { + "sort-packages": true + } +} diff --git a/infection.json.dist b/infection.json.dist new file mode 100644 index 0000000..f6235d9 --- /dev/null +++ b/infection.json.dist @@ -0,0 +1,16 @@ +{ + "source": { + "directories": [ + "src" + ] + }, + "timeout": 10, + "logs": { + "text": "build/infection/infection.log", + "summary": "build/infection/summary.log", + "perMutator": "build/infection/per-mutator.md", + "badge": { + "branch": "master" + } + } +} diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..280a7fc --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,28 @@ + + + + + + + + + + ./tests/ + + + + + + ./src + + ./src/Console + ./src/AurimasNiekisSchedulerBundle.php + + + + diff --git a/src/AurimasNiekisSchedulerBundle.php b/src/AurimasNiekisSchedulerBundle.php new file mode 100644 index 0000000..406af1e --- /dev/null +++ b/src/AurimasNiekisSchedulerBundle.php @@ -0,0 +1,54 @@ + + */ +class AurimasNiekisSchedulerBundle extends Bundle +{ + public function getContainerExtension() + { + return new class() extends Extension { + public function load(array $configs, ContainerBuilder $container): void + { + $container->registerForAutoconfiguration(ScheduledJobInterface::class) + ->addTag('scheduler.job'); + + $container->register(Scheduler::class) + ->setArgument(0, new TaggedIteratorArgument('scheduler.job')) + ->setAutowired(true) + ->setPublic(true); + + $container->setAlias('scheduler', Scheduler::class); + + $container->register(SchedulerListCommand::class) + ->setAutowired(true) + ->addTag('console.command'); + + $container->register(SchedulerExecuteCommand::class) + ->setAutowired(true) + ->addTag('console.command'); + + $container->register(SchedulerRunCommand::class) + ->setAutowired(true) + ->addTag('console.command'); + } + + public function getAlias() + { + return 'scheduler'; + } + }; + } +} diff --git a/src/Console/Command/SchedulerExecuteCommand.php b/src/Console/Command/SchedulerExecuteCommand.php new file mode 100644 index 0000000..7282f77 --- /dev/null +++ b/src/Console/Command/SchedulerExecuteCommand.php @@ -0,0 +1,46 @@ + + */ +class SchedulerExecuteCommand extends Command +{ + protected static $defaultName = 'scheduler:execute'; + + private Scheduler $scheduler; + + public function __construct(Scheduler $scheduler) + { + parent::__construct(); + + $this->scheduler = $scheduler; + } + + protected function configure(): void + { + $this->setDescription('Execute specific scheduled job') + ->addArgument('name', InputArgument::REQUIRED, 'Scheduled job name or className'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $name = $input->getArgument('name'); + $scheduledJob = $this->scheduler->getScheduledJob($name); + + $output->writeln('Executing Scheduled Job: "' . $name . '"'); + + $this->scheduler->executeJob($scheduledJob); + + return 0; + } +} diff --git a/src/Console/Command/SchedulerListCommand.php b/src/Console/Command/SchedulerListCommand.php new file mode 100644 index 0000000..330e013 --- /dev/null +++ b/src/Console/Command/SchedulerListCommand.php @@ -0,0 +1,66 @@ + + */ +class SchedulerListCommand extends Command +{ + protected static $defaultName = 'scheduler:list'; + + private Scheduler $scheduler; + + public function __construct(Scheduler $scheduler) + { + parent::__construct(); + + $this->scheduler = $scheduler; + } + + protected function configure(): void + { + $this->setDescription('List defined Scheduler jobs'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $schedulerJobs = $this->scheduler->getScheduledJobs(); + + $table = new Table($output); + $table->setHeaderTitle('Scheduled Jobs'); + $table->setHeaders(['Name', 'Expression', 'Next Scheduled Run Times']); + + foreach ($schedulerJobs as $name => $schedulerJob) { + $cronExpression = CronExpression::factory($schedulerJob->getSchedulerExpresion()); + + $table->addRow( + [ + $name, + $schedulerJob->getSchedulerExpresion(), + implode( + ', ', + array_map( + fn (DateTimeInterface $dateTime) => $dateTime->format('c'), + $cronExpression->getMultipleRunDates(3) + ) + ), + ] + ); + } + + $table->render(); + + return 0; + } +} diff --git a/src/Console/Command/SchedulerRunCommand.php b/src/Console/Command/SchedulerRunCommand.php new file mode 100644 index 0000000..d3c29c4 --- /dev/null +++ b/src/Console/Command/SchedulerRunCommand.php @@ -0,0 +1,39 @@ + + */ +class SchedulerRunCommand extends Command +{ + protected static $defaultName = 'scheduler:run'; + + private Scheduler $scheduler; + + public function __construct(Scheduler $scheduler) + { + parent::__construct(); + + $this->scheduler = $scheduler; + } + + protected function configure(): void + { + $this->setDescription('Executes scheduled jobs which satisfies scheduler expresion'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $this->scheduler->execute(); + + return 0; + } +} diff --git a/src/NamedScheduledJobInterface.php b/src/NamedScheduledJobInterface.php new file mode 100644 index 0000000..24ebff7 --- /dev/null +++ b/src/NamedScheduledJobInterface.php @@ -0,0 +1,13 @@ + + */ +interface NamedScheduledJobInterface extends ScheduledJobInterface +{ + public function getName(): string; +} diff --git a/src/ScheduledJobInterface.php b/src/ScheduledJobInterface.php new file mode 100644 index 0000000..01b14cc --- /dev/null +++ b/src/ScheduledJobInterface.php @@ -0,0 +1,13 @@ + + */ +interface ScheduledJobInterface +{ + public function getSchedulerExpresion(): string; +} diff --git a/src/Scheduler.php b/src/Scheduler.php new file mode 100644 index 0000000..163dcde --- /dev/null +++ b/src/Scheduler.php @@ -0,0 +1,117 @@ + + */ +class Scheduler +{ + /** @var ScheduledJobInterface[] */ + private array $scheduledJobs; + + private LoggerInterface $logger; + + public function __construct(iterable $scheduledJobs = [], LoggerInterface $logger = null) + { + $this->scheduledJobs = []; + $this->logger = $logger ?? new NullLogger(); + + foreach ($scheduledJobs as $scheduledJob) { + $this->addScheduledJob($scheduledJob); + } + } + + public function addScheduledJob(ScheduledJobInterface $scheduledJob): self + { + $name = $this->getScheduledJobName($scheduledJob); + + if (false === is_callable($scheduledJob)) { + throw new InvalidArgumentException('ScheduledJob "' . $name . '" must implement `__invoke` method'); + } + + $this->scheduledJobs[$name] = $scheduledJob; + + return $this; + } + + private function getScheduledJobName(ScheduledJobInterface $scheduledJob): string + { + if ($scheduledJob instanceof NamedScheduledJobInterface) { + return $scheduledJob->getName(); + } + + return get_class($scheduledJob); + } + + public function getScheduledJob(string $name): ScheduledJobInterface + { + if (false === isset($this->scheduledJobs[$name])) { + throw new InvalidArgumentException('ScheduledJob with name "' . $name . '" does not exist!'); + } + + return $this->scheduledJobs[$name]; + } + + public function execute(DateTimeInterface $at = null): void + { + $at = $at ?? new DateTime(); + + $this->logger->debug('Executing Scheduler', ['at' => $at]); + + $dueJobs = $this->getDueJobs($at); + + $this->logger->debug( + 'Scheduled Events found to execute', + ['count' => count($dueJobs), 'at' => $at] + ); + + foreach ($dueJobs as $dueJob) { + $this->executeJob($dueJob); + } + } + + public function getDueJobs(DateTimeInterface $at): array + { + $dueJobs = []; + + foreach ($this->getScheduledJobs() as $name => $scheduledJob) { + $cronExpression = CronExpression::factory($scheduledJob->getSchedulerExpresion()); + + if ($cronExpression->isDue($at)) { + $dueJobs[$name] = $scheduledJob; + } + } + + return $dueJobs; + } + + /** + * @return ScheduledJobInterface[] + */ + public function getScheduledJobs(): array + { + return $this->scheduledJobs; + } + + /** + * @param ScheduledJobInterface|callable $scheduledJob + */ + public function executeJob(ScheduledJobInterface $scheduledJob): void + { + $this->logger->debug('Executing scheduled job', ['scheduledJob' => $scheduledJob]); + + $scheduledJob(); + + $this->logger->debug('Executing scheduled job finished', ['scheduledJob' => $scheduledJob]); + } +} diff --git a/tests/Fixtures/JobFixture.php b/tests/Fixtures/JobFixture.php new file mode 100644 index 0000000..dc691ed --- /dev/null +++ b/tests/Fixtures/JobFixture.php @@ -0,0 +1,37 @@ + + */ +class JobFixture implements ScheduledJobInterface +{ + private string $expresion; + private bool $ran; + + public function __construct(string $expresion) + { + $this->expresion = $expresion; + $this->ran = false; + } + + public function isRan(): bool + { + return $this->ran; + } + + public function getSchedulerExpresion(): string + { + return $this->expresion; + } + + public function __invoke(): void + { + $this->ran = true; + } +} diff --git a/tests/Fixtures/NamedJobFixture.php b/tests/Fixtures/NamedJobFixture.php new file mode 100644 index 0000000..ed4aeef --- /dev/null +++ b/tests/Fixtures/NamedJobFixture.php @@ -0,0 +1,18 @@ + + */ +class NamedJobFixture extends JobFixture implements NamedScheduledJobInterface +{ + public function getName(): string + { + return 'named'; + } +} diff --git a/tests/SchedulerTest.php b/tests/SchedulerTest.php new file mode 100644 index 0000000..f7768cc --- /dev/null +++ b/tests/SchedulerTest.php @@ -0,0 +1,158 @@ + + */ +class SchedulerTest extends TestCase +{ + public function testThatJobWithoutInvokeNotAccepted(): void + { + $scheduler = new Scheduler(); + + $job = new class() implements ScheduledJobInterface { + public function getSchedulerExpresion(): string + { + return '@daily'; + } + }; + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/ScheduledJob "[^"]+" must implement `__invoke` method/'); + + $scheduler->addScheduledJob($job); + } + + public function testJobNamedCorrectly(): void + { + $namedJob = new NamedJobFixture('@daily'); + $job = new JobFixture('@daily'); + + $scheduler = new Scheduler( + [ + $namedJob, + $job, + ] + ); + + $expected = [ + 'named' => $namedJob, + JobFixture::class => $job, + ]; + + $this->assertEquals($expected, $scheduler->getScheduledJobs()); + } + + public function testDueJobs(): void + { + $dueJob = new class('* * * * *') extends JobFixture { + }; + $dueSecondJob = new class('* * * * *') extends JobFixture { + }; + $notDueJob = new class('@daily') extends JobFixture { + }; + + $scheduler = new Scheduler([$dueJob, $dueSecondJob, $notDueJob]); + + $expected = [get_class($dueJob) => $dueJob, get_class($dueSecondJob) => $dueSecondJob]; + + $this->assertEquals($expected, $scheduler->getDueJobs(new DateTime())); + } + + public function testInvalidCronExpression(): void + { + $dueJob = new class('foo') extends JobFixture { + }; + $scheduler = new Scheduler([$dueJob]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('foo is not a valid CRON expression'); + + $scheduler->getDueJobs(new DateTime()); + } + + public function testGetScheduledJob(): void + { + $namedJob = new NamedJobFixture('@daily'); + $scheduler = new Scheduler([$namedJob]); + + $this->assertEquals($namedJob, $scheduler->getScheduledJob('named')); + } + + public function testShouldThrowErrorOnNonExisting(): void + { + $scheduler = new Scheduler(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('ScheduledJob with name "named" does not exist!'); + + $scheduler->getScheduledJob('named'); + } + + public function testExecute(): void + { + $dueJob = new NamedJobFixture('* * * * *'); + $dateTime = new DateTime(); + + $logger = $this->createMock(LoggerInterface::class); + + $scheduler = new Scheduler([$dueJob], $logger); + + $logger->expects($this->at(0)) + ->method('debug') + ->with('Executing Scheduler', ['at' => $dateTime]); + + $logger->expects($this->at(1)) + ->method('debug') + ->with('Scheduled Events found to execute', ['count' => 1, 'at' => $dateTime]); + + $logger->expects($this->at(2)) + ->method('debug') + ->with('Executing scheduled job', ['scheduledJob' => $dueJob]); + + $logger->expects($this->at(3)) + ->method('debug') + ->with('Executing scheduled job finished', ['scheduledJob' => $dueJob]); + + $this->assertFalse($dueJob->isRan()); + + $scheduler->execute($dateTime); + + $this->assertTrue($dueJob->isRan()); + } + + public function testExecuteSingleJob(): void + { + $dueJob = new NamedJobFixture('* * * * *'); + + $logger = $this->createMock(LoggerInterface::class); + + $scheduler = new Scheduler([$dueJob], $logger); + + $logger->expects($this->at(0)) + ->method('debug') + ->with('Executing scheduled job', ['scheduledJob' => $dueJob]); + + $logger->expects($this->at(1)) + ->method('debug') + ->with('Executing scheduled job finished', ['scheduledJob' => $dueJob]); + + $this->assertFalse($dueJob->isRan()); + + $scheduler->executeJob($dueJob); + + $this->assertTrue($dueJob->isRan()); + } +}