diff --git a/README.md b/README.md index edc21e6..d4a68f3 100644 --- a/README.md +++ b/README.md @@ -344,4 +344,7 @@ zenstruck_schedule: # Email subject (leave blank to use extension default) subject: null + + # Additional Configuration/Metadata + config: [] ``` diff --git a/src/Command/ScheduleListCommand.php b/src/Command/ScheduleListCommand.php index d1b4324..3a6cc36 100644 --- a/src/Command/ScheduleListCommand.php +++ b/src/Command/ScheduleListCommand.php @@ -104,6 +104,18 @@ private function renderDetail(Schedule $schedule, SymfonyStyle $io): int $details[] = ['Next Run' => $task->getNextRun()->format('D, M d, Y @ g:i (e O)')]; $this->renderDefinitionList($io, $details); + + $config = []; + + foreach ($task->config()->humanized() as $key => $value) { + $config[] = [$key => $value]; + } + + if (\count($config)) { + $io->block('Additional Configuration:'); + $this->renderDefinitionList($io, $config); + } + $this->renderExtenstions($io, 'Task', $task->getExtensions()); $issues = \iterator_to_array($this->getTaskIssues($task), false); diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index 6fe5d6e..c415b72 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -246,6 +246,10 @@ private static function taskConfiguration(): ArrayNodeDefinition ->append(self::createPingExtension('ping_on_failure', 'Ping a url if the task failed')) ->append(self::createEmailExtension('email_after', 'Send email after task runs')) ->append(self::createEmailExtension('email_on_failure', 'Send email if task fails')) + ->arrayNode('config') + ->info('Additional Configuration/Metadata') + ->scalarPrototype()->end() + ->end() ->end() ->end() ; diff --git a/src/EventListener/TaskConfigurationSubscriber.php b/src/EventListener/TaskConfigurationSubscriber.php index 8d2fac4..1809376 100644 --- a/src/EventListener/TaskConfigurationSubscriber.php +++ b/src/EventListener/TaskConfigurationSubscriber.php @@ -88,6 +88,10 @@ private function addTask(Schedule $schedule, array $config): void $task->emailOnFailure($config['email_on_failure']['to'], $config['email_on_failure']['subject']); } + foreach ($config['config'] as $key => $value) { + $task->config()->set($key, $value); + } + $schedule->add($task); } diff --git a/src/Schedule/Task.php b/src/Schedule/Task.php index 189ac90..55c5403 100644 --- a/src/Schedule/Task.php +++ b/src/Schedule/Task.php @@ -9,6 +9,7 @@ use Zenstruck\ScheduleBundle\Schedule\Extension\PingExtension; use Zenstruck\ScheduleBundle\Schedule\Extension\SingleServerExtension; use Zenstruck\ScheduleBundle\Schedule\Extension\WithoutOverlappingExtension; +use Zenstruck\ScheduleBundle\Schedule\Task\Config; use Zenstruck\ScheduleBundle\Schedule\Task\TaskRunContext; /** @@ -30,10 +31,12 @@ abstract class Task private $description; private $expression = self::DEFAULT_EXPRESSION; private $timezone; + private $config; public function __construct(string $description) { $this->description = $description; + $this->config = new Config(); } final public function __toString(): string @@ -91,6 +94,14 @@ final public function description(string $description): self return $this; } + /** + * Set extra configuration/metadata for this task. + */ + final public function config(): Config + { + return $this->config; + } + /** * The timezone this task should run in. * diff --git a/src/Schedule/Task/CompoundTask.php b/src/Schedule/Task/CompoundTask.php index 6fe22fe..3d43db4 100644 --- a/src/Schedule/Task/CompoundTask.php +++ b/src/Schedule/Task/CompoundTask.php @@ -69,6 +69,10 @@ public function getIterator(): iterable $task->addExtension($extension); } + foreach ($this->config()->all() as $key => $value) { + $task->config()->set($key, $task->config()->get($key, $value)); + } + yield $task; } } diff --git a/src/Schedule/Task/Config.php b/src/Schedule/Task/Config.php new file mode 100644 index 0000000..60a5283 --- /dev/null +++ b/src/Schedule/Task/Config.php @@ -0,0 +1,65 @@ + + */ +final class Config +{ + private $data = []; + + /** + * @param mixed $value + */ + public function set(string $name, $value): self + { + $this->data[$name] = $value; + + return $this; + } + + /** + * @param mixed $default + * + * @return mixed + */ + public function get(string $name, $default = null) + { + return $this->data[$name] ?? $default; + } + + public function all(): array + { + return $this->data; + } + + public function humanized(): array + { + $ret = []; + + foreach ($this->data as $key => $value) { + switch (true) { + case \is_bool($value): + $value = $value ? 'yes' : 'no'; + + break; + + case \is_scalar($value): + break; + + case \is_object($value): + $value = \sprintf('(%s)', \get_class($value)); + + break; + + default: + $value = \sprintf('(%s)', \gettype($value)); + } + + $ret[$key] = $value; + } + + return $ret; + } +} diff --git a/tests/Command/ScheduleListCommandTest.php b/tests/Command/ScheduleListCommandTest.php index 242d0b4..78be144 100644 --- a/tests/Command/ScheduleListCommandTest.php +++ b/tests/Command/ScheduleListCommandTest.php @@ -328,6 +328,43 @@ public function shows_schedule_issue_for_duplicate_task_id() $this->assertStringContainsString('[ERROR] Task "MockTask: task3" (* * * * *) is duplicated 3 times. Make their descriptions unique to fix.', $output); } + /** + * @test + */ + public function shows_extra_configuration_in_detail_view(): void + { + $task = (new MockTask('my task'))->cron('@daily'); + $task->config()->set('config1', 'config1 value'); + $task->config()->set('config2', ['an', 'array']); + $task->config()->set('config3', true); + $task->config()->set('config4', false); + $task->config()->set('config5', new Schedule()); + $runner = (new MockScheduleBuilder()) + ->addTask($task) + ->getRunner() + ; + + $command = new ScheduleListCommand($runner, new ExtensionHandlerRegistry([])); + $command->setHelperSet(new HelperSet([new FormatterHelper()])); + $command->setApplication(new Application()); + $commandTester = new CommandTester($command); + + $commandTester->execute(['--detail' => null]); + $output = $this->normalizeOutput($commandTester); + + $this->assertStringContainsString('Additional Configuration', $output); + $this->assertStringContainsString('config1', $output); + $this->assertStringContainsString('config1 value', $output); + $this->assertStringContainsString('config2', $output); + $this->assertStringContainsString('(array)', $output); + $this->assertStringContainsString('config3', $output); + $this->assertStringContainsString('yes', $output); + $this->assertStringContainsString('config4', $output); + $this->assertStringContainsString('no', $output); + $this->assertStringContainsString('config5', $output); + $this->assertStringContainsString('('.Schedule::class.')', $output); + } + private function normalizeOutput(CommandTester $tester): string { return \preg_replace('/\s+/', ' ', \str_replace("\n", '', $tester->getDisplay(true))); diff --git a/tests/DependencyInjection/ZenstruckScheduleExtensionTest.php b/tests/DependencyInjection/ZenstruckScheduleExtensionTest.php index 4879cc3..33c605f 100644 --- a/tests/DependencyInjection/ZenstruckScheduleExtensionTest.php +++ b/tests/DependencyInjection/ZenstruckScheduleExtensionTest.php @@ -702,6 +702,25 @@ public function between_and_unless_between_config_can_be_shortened() $this->assertSame('13:15', $config['unless_between']['end']); } + /** + * @test + */ + public function task_config_must_be_an_array(): void + { + $this->expectException(InvalidConfigurationException::class); + $this->expectExceptionMessage('Invalid type for path "zenstruck_schedule.tasks.0.config"'); + + $this->load([ + 'tasks' => [ + [ + 'task' => 'my:command', + 'frequency' => '0 * * * *', + 'config' => 'not an array', + ], + ], + ]); + } + protected function getContainerExtensions(): array { return [new ZenstruckScheduleExtension()]; diff --git a/tests/EventListener/TaskConfigurationSubscriberTest.php b/tests/EventListener/TaskConfigurationSubscriberTest.php index dce38eb..dfdae9b 100644 --- a/tests/EventListener/TaskConfigurationSubscriberTest.php +++ b/tests/EventListener/TaskConfigurationSubscriberTest.php @@ -274,6 +274,31 @@ public function full_task_configuration() $this->assertSame('On Task Failure, email output to "sales@example.com"', (string) $extensions[8]); } + /** + * @test + */ + public function can_add_task_config(): void + { + $schedule = $this->createSchedule([ + [ + 'task' => 'my:command', + 'frequency' => '0 * * * *', + 'config' => [ + 'foo' => 'bar', + 'bar' => 'foo', + ], + ], + ]); + + $this->assertSame( + [ + 'foo' => 'bar', + 'bar' => 'foo', + ], + $schedule->all()[0]->config()->all() + ); + } + private function createSchedule(array $taskConfig): Schedule { $processor = new Processor(); diff --git a/tests/Schedule/Task/CompoundTaskTest.php b/tests/Schedule/Task/CompoundTaskTest.php index 88b6808..50ccece 100644 --- a/tests/Schedule/Task/CompoundTaskTest.php +++ b/tests/Schedule/Task/CompoundTaskTest.php @@ -4,6 +4,7 @@ use PHPUnit\Framework\TestCase; use Zenstruck\ScheduleBundle\Schedule\Task\CompoundTask; +use Zenstruck\ScheduleBundle\Tests\Fixture\MockTask; /** * @author Kevin Bond @@ -22,4 +23,41 @@ public function cannot_nest_compound_tasks() $task->add(new CompoundTask()); } + + /** + * @test + */ + public function config_is_passed_to_sub_tasks(): void + { + $task = new CompoundTask(); + $task->add(new MockTask('subtask1')); + $task->add(new MockTask('subtask2')); + $task->config()->set('foo', 'bar'); + $task->config()->set('bar', 'foo'); + + [$subtask1, $subtask2] = \iterator_to_array($task); + + $this->assertSame('bar', $subtask1->config()->get('foo')); + $this->assertSame('foo', $subtask1->config()->get('bar')); + $this->assertSame('bar', $subtask2->config()->get('foo')); + $this->assertSame('foo', $subtask2->config()->get('bar')); + } + + /** + * @test + */ + public function config_on_sub_tasks_takes_precedence_over_compound_task(): void + { + $subTask = new MockTask('subtask'); + $subTask->config()->set('key2', 'subtask value2'); + $task = new CompoundTask(); + $task->config()->set('key1', 'compound value1'); + $task->config()->set('key2', 'compound value2'); + $task->add($subTask); + + [$subTask] = \iterator_to_array($task); + + $this->assertSame('compound value1', $subTask->config()->get('key1')); + $this->assertSame('subtask value2', $subTask->config()->get('key2')); + } } diff --git a/tests/Schedule/TaskTest.php b/tests/Schedule/TaskTest.php index 7d59d8e..7268cb0 100644 --- a/tests/Schedule/TaskTest.php +++ b/tests/Schedule/TaskTest.php @@ -4,6 +4,7 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Mime\Email; +use Zenstruck\ScheduleBundle\Schedule; use Zenstruck\ScheduleBundle\Schedule\Extension\SingleServerExtension; use Zenstruck\ScheduleBundle\Schedule\Task; use Zenstruck\ScheduleBundle\Tests\Fixture\MockTask; @@ -207,6 +208,48 @@ public function can_add_single_server_extension() $this->assertInstanceOf(SingleServerExtension::class, $task->getExtensions()[0]); } + /** + * @test + */ + public function can_add_and_retrieve_task_config(): void + { + $task = self::task(); + $task->config()->set('foo', 'bar'); + $task->config()->set('bar', 'baz')->set('baz', 'foo'); + + $this->assertSame('bar', $task->config()->get('foo')); + $this->assertSame('baz', $task->config()->get('bar')); + $this->assertSame('foo', $task->config()->get('baz')); + $this->assertNull($task->config()->get('invalid')); + $this->assertSame('default', $task->config()->get('invalid', 'default')); + $this->assertSame([ + 'foo' => 'bar', + 'bar' => 'baz', + 'baz' => 'foo', + ], $task->config()->all()); + } + + /** + * @test + */ + public function can_get_humanized_config(): void + { + $task = self::task(); + $task->config()->set('number', 2); + $task->config()->set('true', true); + $task->config()->set('false', false); + $task->config()->set('array', ['bar']); + $task->config()->set('object', new Schedule()); + + $this->assertSame([ + 'number' => 2, + 'true' => 'yes', + 'false' => 'no', + 'array' => '(array)', + 'object' => '('.Schedule::class.')', + ], $task->config()->humanized()); + } + private static function task(string $description = 'task description'): Task { return new MockTask($description);