├── src
├── Resources
│ ├── doc
│ │ └── schema.png
│ └── config
│ │ ├── exceptions_services.yaml
│ │ ├── messenger_services.yaml
│ │ └── services.yaml
├── Runner
│ ├── PendingDataflowRunnerInterface.php
│ ├── PendingDataflowRunner.php
│ └── MessengerDataflowRunner.php
├── Event
│ ├── CrEvent.php
│ ├── Events.php
│ └── ProcessingEvent.php
├── Processor
│ ├── JobProcessorInterface.php
│ └── JobProcessor.php
├── Exceptions
│ ├── InterruptedProcessingException.php
│ ├── UnsupportedItemTypeException.php
│ └── UnknownDataflowTypeException.php
├── ExceptionsHandler
│ ├── ExceptionHandlerInterface.php
│ ├── NullExceptionHandler.php
│ └── FilesystemExceptionHandler.php
├── DataflowType
│ ├── AutoUpdateCountInterface.php
│ ├── DataflowTypeInterface.php
│ ├── Writer
│ │ ├── DelegateWriterInterface.php
│ │ ├── WriterInterface.php
│ │ ├── PortWriterAdapter.php
│ │ ├── CollectionWriter.php
│ │ └── DelegatorWriter.php
│ ├── Dataflow
│ │ ├── DataflowInterface.php
│ │ ├── Dataflow.php
│ │ └── AMPAsyncDataflow.php
│ ├── Result.php
│ ├── AMPAsyncDataflowBuilder.php
│ ├── AbstractDataflowType.php
│ └── DataflowBuilder.php
├── MessengerMode
│ ├── JobMessage.php
│ └── JobMessageHandler.php
├── Manager
│ ├── ScheduledDataflowManagerInterface.php
│ └── ScheduledDataflowManager.php
├── Validator
│ └── Constraints
│ │ ├── Frequency.php
│ │ └── FrequencyValidator.php
├── Factory
│ └── ConnectionFactory.php
├── Registry
│ ├── DataflowTypeRegistryInterface.php
│ └── DataflowTypeRegistry.php
├── Logger
│ ├── BufferHandler.php
│ └── DelegatingLogger.php
├── DependencyInjection
│ ├── Compiler
│ │ ├── DataflowTypeCompilerPass.php
│ │ ├── DefaultLoggerCompilerPass.php
│ │ ├── ExceptionCompilerPass.php
│ │ └── BusCompilerPass.php
│ ├── CodeRhapsodieDataflowExtension.php
│ └── Configuration.php
├── Repository
│ ├── InitFromDbTrait.php
│ ├── ScheduledDataflowRepository.php
│ └── JobRepository.php
├── CodeRhapsodieDataflowBundle.php
├── Gateway
│ └── JobGateway.php
├── Command
│ ├── SchemaCommand.php
│ ├── RunPendingDataflowsCommand.php
│ ├── ScheduleListCommand.php
│ ├── ChangeScheduleStatusCommand.php
│ ├── ExecuteDataflowCommand.php
│ ├── JobShowCommand.php
│ ├── DatabaseSchemaCommand.php
│ └── AddScheduledDataflowCommand.php
├── SchemaProvider
│ └── DataflowSchemaProvider.php
└── Entity
│ ├── ScheduledDataflow.php
│ └── Job.php
├── sonar-project.properties
├── UPGRADE.md
├── .gitignore
├── .github
└── workflows
│ ├── ci.yml
│ ├── semicolons-kudos.yaml
│ └── build.yml
├── Tests
├── bootstrap.php
├── DataflowType
│ ├── Writer
│ │ ├── PortWriterAdapterTest.php
│ │ ├── CollectionWriterTest.php
│ │ └── DelegatorWriterTest.php
│ ├── Dataflow
│ │ └── AMPAsyncDataflowTest.php
│ └── AbstractDataflowTypeTest.php
├── MessengerMode
│ └── JobMessageHandlerTest.php
├── Registry
│ └── DataflowTypeRegistryTest.php
├── Runner
│ ├── PendingDataflowRunnerTest.php
│ └── MessengerDataflowRunnerTest.php
├── Validator
│ └── Constraints
│ │ └── FrequencyValidatorTest.php
├── Processor
│ └── JobProcessorTest.php
└── Manager
│ └── ScheduledDataflowManagerTest.php
├── rector.php
├── phpunit.xml
├── LICENSE
├── .php-cs-fixer.dist.php
├── CONTRIBUTING.md
├── composer.json
├── CHANGELOG.md
└── README.md
/src/Resources/doc/schema.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/code-rhapsodie/dataflow-bundle/HEAD/src/Resources/doc/schema.png
--------------------------------------------------------------------------------
/sonar-project.properties:
--------------------------------------------------------------------------------
1 | sonar.projectKey=code-rhapsodie_dataflow-bundle_AYvYuaAwWE9sbcQmD1vw
2 |
3 | sonar.sources=src
4 | sonar.tests=Tests
--------------------------------------------------------------------------------
/UPGRADE.md:
--------------------------------------------------------------------------------
1 | # Upgrade from v1.x to v2.0
2 |
3 | [BC] `JobRepository` and `ScheduledDataflowRepository` are no longer a Doctrine ORM repository.
4 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | vendor
2 | composer.lock
3 | .phpunit.result.cache
4 | .php_cs.cache
5 | .php_cs
6 | .idea
7 | .phpunit.cache
8 | .php-version
9 | .php-cs-fixer.cache
--------------------------------------------------------------------------------
/src/Runner/PendingDataflowRunnerInterface.php:
--------------------------------------------------------------------------------
1 | jobId;
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/src/Resources/config/exceptions_services.yaml:
--------------------------------------------------------------------------------
1 | services:
2 | CodeRhapsodie\DataflowBundle\ExceptionsHandler\ExceptionHandlerInterface: '@CodeRhapsodie\DataflowBundle\ExceptionsHandler\FilesystemExceptionHandler'
3 | CodeRhapsodie\DataflowBundle\ExceptionsHandler\FilesystemExceptionHandler:
4 | arguments:
5 | $filesystem: ~ # Filled in compiler pass
6 |
--------------------------------------------------------------------------------
/src/DataflowType/DataflowTypeInterface.php:
--------------------------------------------------------------------------------
1 | job;
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/src/DataflowType/Writer/WriterInterface.php:
--------------------------------------------------------------------------------
1 | writer->prepare();
16 | }
17 |
18 | public function write($item)
19 | {
20 | $this->writer->writeItem((array) $item);
21 | }
22 |
23 | public function finish()
24 | {
25 | $this->writer->finish();
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/src/Exceptions/UnknownDataflowTypeException.php:
--------------------------------------------------------------------------------
1 | processor->process($this->repository->find($message->getJobId()));
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/src/Runner/PendingDataflowRunner.php:
--------------------------------------------------------------------------------
1 | repository->findNextPendingDataflow())) {
19 | $this->processor->process($job);
20 | }
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/.github/workflows/semicolons-kudos.yaml:
--------------------------------------------------------------------------------
1 |
2 | name: Kudos for Code
3 | on:
4 | push:
5 | branches: ["master"]
6 | workflow_dispatch:
7 |
8 | jobs:
9 | kudos:
10 | name: Semicolons Kudos
11 | permissions: write-all
12 | runs-on: ubuntu-latest
13 | steps:
14 | - uses: actions/checkout@v4
15 | with:
16 | fetch-depth: 0
17 | - uses: LoremLabs/kudos-for-code-action@latest
18 | with:
19 | search-dir: "."
20 | destination: "artifact"
21 | generate-nomerges: true
22 | generate-validemails: true
23 | generate-limitdepth: 0
24 | generate-fromrepo: true
25 | analyze-repo: false
26 | skip-ids: ""
27 |
--------------------------------------------------------------------------------
/src/Resources/config/messenger_services.yaml:
--------------------------------------------------------------------------------
1 | services:
2 | CodeRhapsodie\DataflowBundle\Runner\PendingDataflowRunnerInterface: '@CodeRhapsodie\DataflowBundle\Runner\MessengerDataflowRunner'
3 | CodeRhapsodie\DataflowBundle\Runner\MessengerDataflowRunner:
4 | arguments:
5 | $repository: '@CodeRhapsodie\DataflowBundle\Repository\JobRepository'
6 | $bus: ~ # Filled in compiler pass
7 |
8 | CodeRhapsodie\DataflowBundle\MessengerMode\JobMessageHandler:
9 | arguments:
10 | $repository: '@CodeRhapsodie\DataflowBundle\Repository\JobRepository'
11 | $processor: '@CodeRhapsodie\DataflowBundle\Processor\JobProcessorInterface'
12 | tags: ['messenger.message_handler']
13 |
--------------------------------------------------------------------------------
/src/Factory/ConnectionFactory.php:
--------------------------------------------------------------------------------
1 | connectionName = $connectionName;
23 | }
24 |
25 | public function getConnection(): \Doctrine\DBAL\Connection
26 | {
27 | return $this->container->get(\sprintf('doctrine.dbal.%s_connection', $this->connectionName));
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/rector.php:
--------------------------------------------------------------------------------
1 | paths([
12 | __DIR__ . '/src',
13 | __DIR__ . '/Tests',
14 | ]);
15 |
16 | // register a single rule
17 | $rectorConfig->rule(InlineConstructorDefaultToPropertyRector::class);
18 |
19 | $rectorConfig->sets([
20 | SymfonySetList::SYMFONY_70,
21 | SymfonySetList::SYMFONY_CODE_QUALITY,
22 | SymfonySetList::SYMFONY_CONSTRUCTOR_INJECTION,
23 | LevelSetList::UP_TO_PHP_80,
24 | ]);
25 | };
26 |
--------------------------------------------------------------------------------
/phpunit.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | ./Tests
10 |
11 |
12 |
13 |
14 | ./src/
15 |
16 |
17 | Tests/
18 | vendor/
19 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/src/Registry/DataflowTypeRegistryInterface.php:
--------------------------------------------------------------------------------
1 | repository->findNextPendingDataflow())) {
21 | $this->bus->dispatch(new JobMessage($job->getId()));
22 | $job->setStatus(Job::STATUS_QUEUED);
23 | $this->repository->save($job);
24 | }
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/src/Logger/BufferHandler.php:
--------------------------------------------------------------------------------
1 | buffer;
21 | $this->buffer = [];
22 |
23 | return $logs;
24 | }
25 |
26 | protected function write(array|LogRecord $record): void
27 | {
28 | $this->buffer[] = $record['formatted'];
29 | }
30 |
31 | protected function getDefaultFormatter(): FormatterInterface
32 | {
33 | return new LineFormatter(self::FORMAT);
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/src/Logger/DelegatingLogger.php:
--------------------------------------------------------------------------------
1 | loggers[] = $logger;
23 | }
24 | }
25 |
26 | public function log($level, $message, array $context = []): void
27 | {
28 | foreach ($this->loggers as $logger) {
29 | $logger->log($level, $message, $context);
30 | }
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/src/DependencyInjection/Compiler/DataflowTypeCompilerPass.php:
--------------------------------------------------------------------------------
1 | has(DataflowTypeRegistry::class)) {
22 | return;
23 | }
24 |
25 | $registry = $container->findDefinition(DataflowTypeRegistry::class);
26 | foreach ($container->findTaggedServiceIds('coderhapsodie.dataflow.type') as $id => $tags) {
27 | $registry->addMethodCall('registerDataflowType', [new Reference($id)]);
28 | }
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/Tests/DataflowType/Writer/PortWriterAdapterTest.php:
--------------------------------------------------------------------------------
1 | getMockBuilder(\Port\Writer::class)
15 | ->onlyMethods(['prepare', 'finish', 'writeItem'])
16 | ->getMock()
17 | ;
18 | $writer
19 | ->expects($this->once())
20 | ->method('prepare')
21 | ;
22 | $writer
23 | ->expects($this->once())
24 | ->method('finish')
25 | ;
26 | $writer
27 | ->expects($this->once())
28 | ->method('writeItem')
29 | ->with([$value])
30 | ;
31 |
32 | $adapter = new PortWriterAdapter($writer);
33 | $adapter->prepare();
34 | $adapter->write($value);
35 | $adapter->finish();
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright (c) 2019 Code Rhapsodie
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining a copy
4 | of this software and associated documentation files (the "Software"), to deal
5 | in the Software without restriction, including without limitation the rights
6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | copies of the Software, and to permit persons to whom the Software is furnished
8 | to do so, subject to the following conditions:
9 |
10 | The above copyright notice and this permission notice shall be included in all
11 | copies or substantial portions of the Software.
12 |
13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
19 | THE SOFTWARE.
20 |
--------------------------------------------------------------------------------
/src/ExceptionsHandler/FilesystemExceptionHandler.php:
--------------------------------------------------------------------------------
1 | filesystem->write(\sprintf('dataflow-job-%s.log', $jobId), json_encode($exceptions));
23 | }
24 |
25 | public function find(int $jobId): ?array
26 | {
27 | try {
28 | if (!$this->filesystem->fileExists(\sprintf('dataflow-job-%s.log', $jobId))) {
29 | return [];
30 | }
31 |
32 | return json_decode($this->filesystem->read(\sprintf('dataflow-job-%s.log', $jobId)), true);
33 | } catch (FilesystemException) {
34 | return [];
35 | }
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/src/DependencyInjection/Compiler/DefaultLoggerCompilerPass.php:
--------------------------------------------------------------------------------
1 | getParameter('coderhapsodie.dataflow.default_logger');
18 | if (!$container->has($defaultLogger)) {
19 | return;
20 | }
21 |
22 | foreach ([ExecuteDataflowCommand::class, JobProcessor::class] as $serviceId) {
23 | if (!$container->has($serviceId)) {
24 | continue;
25 | }
26 |
27 | $definition = $container->findDefinition($serviceId);
28 | $definition->addMethodCall('setLogger', [new Reference($defaultLogger)]);
29 | }
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/src/DataflowType/Writer/CollectionWriter.php:
--------------------------------------------------------------------------------
1 | writer->prepare();
24 | }
25 |
26 | public function write($collection)
27 | {
28 | if (!is_iterable($collection)) {
29 | throw new UnsupportedItemTypeException(\sprintf('Item to write was expected to be an iterable, received %s.', get_debug_type($collection)));
30 | }
31 |
32 | foreach ($collection as $item) {
33 | $this->writer->write($item);
34 | }
35 | }
36 |
37 | public function finish()
38 | {
39 | $this->writer->finish();
40 | }
41 |
42 | public function supports($item): bool
43 | {
44 | return is_iterable($item);
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/src/DependencyInjection/Compiler/ExceptionCompilerPass.php:
--------------------------------------------------------------------------------
1 | hasParameter('coderhapsodie.dataflow.flysystem_service')) {
18 | return;
19 | }
20 |
21 | $flysystem = $container->getParameter('coderhapsodie.dataflow.flysystem_service');
22 | if (!$container->has($flysystem)) {
23 | throw new InvalidArgumentException(\sprintf('Service "%s" not found', $flysystem));
24 | }
25 |
26 | $definition = $container->findDefinition(FilesystemExceptionHandler::class);
27 | $definition->setArgument('$filesystem', new Reference($flysystem));
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/src/DependencyInjection/Compiler/BusCompilerPass.php:
--------------------------------------------------------------------------------
1 | hasParameter('coderhapsodie.dataflow.bus')) {
18 | return;
19 | }
20 |
21 | $bus = $container->getParameter('coderhapsodie.dataflow.bus');
22 | if (!$container->has($bus)) {
23 | throw new InvalidArgumentException(\sprintf('Service "%s" not found', $bus));
24 | }
25 |
26 | if (!$container->has(MessengerDataflowRunner::class)) {
27 | return;
28 | }
29 |
30 | $definition = $container->findDefinition(MessengerDataflowRunner::class);
31 | $definition->setArgument('$bus', new Reference($bus));
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/.php-cs-fixer.dist.php:
--------------------------------------------------------------------------------
1 | in('src')
5 | //->in('tests')
6 | ->files()->name('*.php');
7 |
8 | $config = new PhpCsFixer\Config();
9 | $config->setRules([
10 | '@Symfony' => true,
11 | '@Symfony:risky' => true,
12 | '@PSR12' => true,
13 | 'array_syntax' => [
14 | 'syntax' => 'short',
15 | ],
16 | 'combine_consecutive_unsets' => true,
17 | 'native_function_invocation' => [
18 | 'include' => [
19 | '@compiler_optimized',
20 | ],
21 | ],
22 | 'no_extra_blank_lines' => [
23 | 'tokens' => [
24 | 'break',
25 | 'continue',
26 | 'extra',
27 | 'return',
28 | 'throw',
29 | 'use',
30 | 'parenthesis_brace_block',
31 | 'square_brace_block',
32 | 'curly_brace_block',
33 | ],
34 | ],
35 | 'ordered_class_elements' => true,
36 | 'ordered_imports' => true,
37 | 'yoda_style' => [
38 | 'equal' => false,
39 | 'identical' => false,
40 | 'less_and_greater' => false,
41 | 'always_move_variable' => false,
42 | ],
43 | ])
44 | ->setRiskyAllowed(true)
45 | ->setFinder(
46 | $finder
47 | );
48 |
49 | return $config;
50 |
--------------------------------------------------------------------------------
/src/Repository/InitFromDbTrait.php:
--------------------------------------------------------------------------------
1 | getFields() as $key => $type) {
17 | if ($type === 'datetime' && $datas[$key] !== null) {
18 | $datas[$key] = new \DateTime($datas[$key]);
19 | }
20 | }
21 |
22 | return $datas;
23 | }
24 |
25 | private function initArray(array $datas): array
26 | {
27 | if (!\is_array($datas['options'])) {
28 | $datas['options'] = $this->strToArray($datas['options']);
29 | }
30 | if (\array_key_exists('exceptions', $datas) && !\is_array($datas['exceptions'])) {
31 | $datas['exceptions'] = $this->strToArray($datas['exceptions']);
32 | }
33 |
34 | return $datas;
35 | }
36 |
37 | private function strToArray($value): array
38 | {
39 | if ($value === null) {
40 | return [];
41 | }
42 |
43 | $array = json_decode($value, true, 512, \JSON_THROW_ON_ERROR);
44 |
45 | return ($array === false) ? [] : $array;
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/src/CodeRhapsodieDataflowBundle.php:
--------------------------------------------------------------------------------
1 | addCompilerPass(new DataflowTypeCompilerPass())
32 | ->addCompilerPass(new DefaultLoggerCompilerPass())
33 | ->addCompilerPass(new BusCompilerPass())
34 | ->addCompilerPass(new ExceptionCompilerPass())
35 | ;
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/Tests/DataflowType/Dataflow/AMPAsyncDataflowTest.php:
--------------------------------------------------------------------------------
1 | addStep(static fn($item) => $item + 1);
18 | $dataflow->addStep(static function($item): \Generator {
19 | yield new Delayed(10); //delay 10 milliseconds
20 | return $item * 2;
21 | });
22 | $dataflow->addWriter(new class($result) implements WriterInterface {
23 | private $buffer;
24 |
25 | public function __construct(&$buffer) {
26 | $this->buffer = &$buffer;
27 | }
28 |
29 | public function prepare()
30 | {
31 | }
32 |
33 | public function write($item)
34 | {
35 | $this->buffer[] = $item;
36 | }
37 |
38 | public function finish()
39 | {
40 | }
41 | });
42 | $dataflow->process();
43 |
44 | self::assertSame([4, 6, 8], $result);
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/src/Validator/Constraints/FrequencyValidator.php:
--------------------------------------------------------------------------------
1 | context->buildViolation($constraint->message)
31 | ->setParameter('{{ string }}', $value)
32 | ->addViolation()
33 | ;
34 |
35 | return;
36 | }
37 |
38 | $now = new \DateTime();
39 | $dt = clone $now;
40 | $dt->add($interval);
41 |
42 | if ($dt <= $now) {
43 | $this->context->buildViolation($constraint->message)
44 | ->setParameter('{{ string }}', $value)
45 | ->addViolation()
46 | ;
47 | }
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/Tests/MessengerMode/JobMessageHandlerTest.php:
--------------------------------------------------------------------------------
1 | repository = $this->createMock(JobRepository::class);
24 | $this->processor = $this->createMock(JobProcessorInterface::class);
25 |
26 | $this->handler = new JobMessageHandler($this->repository, $this->processor);
27 | }
28 |
29 | public function testInvoke()
30 | {
31 | $message = new JobMessage($id = 32);
32 |
33 | $this->repository
34 | ->expects($this->once())
35 | ->method('find')
36 | ->with($id)
37 | ->willReturn($job = new Job())
38 | ;
39 |
40 | $this->processor
41 | ->expects($this->once())
42 | ->method('process')
43 | ->with($job)
44 | ;
45 |
46 | ($this->handler)($message);
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/src/Registry/DataflowTypeRegistry.php:
--------------------------------------------------------------------------------
1 | fqcnRegistry[$fqcnOrAlias])) {
24 | return $this->fqcnRegistry[$fqcnOrAlias];
25 | }
26 |
27 | if (isset($this->aliasesRegistry[$fqcnOrAlias])) {
28 | return $this->aliasesRegistry[$fqcnOrAlias];
29 | }
30 |
31 | throw UnknownDataflowTypeException::create($fqcnOrAlias, [...array_keys($this->fqcnRegistry), ...array_keys($this->aliasesRegistry)]);
32 | }
33 |
34 | public function listDataflowTypes(): iterable
35 | {
36 | return $this->fqcnRegistry;
37 | }
38 |
39 | public function registerDataflowType(DataflowTypeInterface $dataflowType): void
40 | {
41 | $this->fqcnRegistry[$dataflowType::class] = $dataflowType;
42 | foreach ($dataflowType->getAliases() as $alias) {
43 | $this->aliasesRegistry[$alias] = $dataflowType;
44 | }
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/Tests/Registry/DataflowTypeRegistryTest.php:
--------------------------------------------------------------------------------
1 | registry = new DataflowTypeRegistry();
18 | }
19 |
20 | public function testEverything()
21 | {
22 | $alias1 = 'alias1';
23 | $alias2 = 'alias2';
24 |
25 | /** @var MockObject|DataflowTypeInterface $type */
26 | $type = $this->createMock(DataflowTypeInterface::class);
27 | $type
28 | ->expects($this->once())
29 | ->method('getAliases')
30 | ->willReturn([$alias1, $alias2])
31 | ;
32 |
33 | $this->registry->registerDataflowType($type);
34 |
35 | $this->assertSame($type, $this->registry->getDataflowType($type::class));
36 | $this->assertSame($type, $this->registry->getDataflowType($alias1));
37 | $this->assertSame($type, $this->registry->getDataflowType($alias2));
38 | $this->assertContains($type, $this->registry->listDataflowTypes());
39 | }
40 |
41 | public function testUnknown()
42 | {
43 | $this->expectException(UnknownDataflowTypeException::class);
44 | $this->registry->getDataflowType('unknown');
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/src/Gateway/JobGateway.php:
--------------------------------------------------------------------------------
1 | repository->find($jobId);
21 |
22 | return $this->loadExceptions($job);
23 | }
24 |
25 | public function save(Job $job): void
26 | {
27 | if (!$this->exceptionHandler instanceof NullExceptionHandler) {
28 | $this->exceptionHandler->save($job->getId(), $job->getExceptions());
29 | $job->setExceptions([]);
30 | }
31 |
32 | $this->repository->save($job);
33 | }
34 |
35 | public function findLastForDataflowId(int $scheduleId): ?Job
36 | {
37 | $job = $this->repository->findLastForDataflowId($scheduleId);
38 |
39 | return $this->loadExceptions($job);
40 | }
41 |
42 | private function loadExceptions(?Job $job): ?Job
43 | {
44 | if ($job === null || $this->exceptionHandler instanceof NullExceptionHandler) {
45 | return $job;
46 | }
47 |
48 | $this->exceptionHandler->save($job->getId(), $job->getExceptions());
49 |
50 | return $job->setExceptions($this->exceptionHandler->find($job->getId()));
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/Tests/DataflowType/AbstractDataflowTypeTest.php:
--------------------------------------------------------------------------------
1 | 'Test value'];
16 | $values = [1, 2, 3];
17 | $testCase = $this;
18 |
19 | $dataflowType = new class($label, $options, $values, $testCase) extends AbstractDataflowType
20 | {
21 | public function __construct(private string $label, private array $options, private array $values, private TestCase $testCase)
22 | {
23 | }
24 |
25 | public function getLabel(): string
26 | {
27 | return $this->label;
28 | }
29 |
30 | protected function configureOptions(OptionsResolver $optionsResolver): void
31 | {
32 | $optionsResolver->setDefined('testOption');
33 | }
34 |
35 | protected function buildDataflow(DataflowBuilder $builder, array $options): void
36 | {
37 | $builder->setReader($this->values);
38 | $this->testCase->assertSame($this->options, $options);
39 | }
40 | };
41 |
42 | $result = $dataflowType->process($options);
43 | $this->assertSame($label, $result->getName());
44 | $this->assertSame(count($values), $result->getTotalProcessedCount());
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/Tests/Runner/PendingDataflowRunnerTest.php:
--------------------------------------------------------------------------------
1 | repository = $this->createMock(JobRepository::class);
21 | $this->processor = $this->createMock(JobProcessorInterface::class);
22 |
23 | $this->runner = new PendingDataflowRunner($this->repository, $this->processor);
24 | }
25 |
26 | public function testRunPendingDataflows()
27 | {
28 | $job1 = new Job();
29 | $job2 = new Job();
30 |
31 | $this->repository
32 | ->expects($this->exactly(3))
33 | ->method('findNextPendingDataflow')
34 | ->willReturnOnConsecutiveCalls($job1, $job2, null)
35 | ;
36 |
37 | $matcher = $this->exactly(2);
38 | $this->processor
39 | ->expects($matcher)
40 | ->method('process')
41 | ->with($this->callback(fn($arg) => match ($matcher->numberOfInvocations()) {
42 | 1 => $arg === $job1,
43 | 2 => $arg === $job2,
44 | default => false,
45 | }))
46 | ;
47 |
48 | $this->runner->runPendingDataflows();
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/src/DataflowType/Result.php:
--------------------------------------------------------------------------------
1 | elapsed = $startTime->diff($endTime);
23 | $this->errorCount = \count($exceptions);
24 | $this->successCount = $totalProcessedCount - $this->errorCount;
25 | $this->exceptions = $exceptions;
26 | }
27 |
28 | public function getName(): string
29 | {
30 | return $this->name;
31 | }
32 |
33 | public function getStartTime(): \DateTimeInterface
34 | {
35 | return $this->startTime;
36 | }
37 |
38 | public function getEndTime(): \DateTimeInterface
39 | {
40 | return $this->endTime;
41 | }
42 |
43 | public function getElapsed(): \DateInterval
44 | {
45 | return $this->elapsed;
46 | }
47 |
48 | public function getErrorCount(): int
49 | {
50 | return $this->errorCount;
51 | }
52 |
53 | public function getSuccessCount(): int
54 | {
55 | return $this->successCount;
56 | }
57 |
58 | public function getTotalProcessedCount(): int
59 | {
60 | return $this->totalProcessedCount;
61 | }
62 |
63 | public function hasErrors(): bool
64 | {
65 | return $this->errorCount > 0;
66 | }
67 |
68 | public function getExceptions(): array
69 | {
70 | return $this->exceptions;
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | Contributing
2 | ============
3 |
4 | Thank you for contributing to this project!
5 |
6 | Bug reports
7 | -----------
8 |
9 | If you find a bug, please submit an issue. Try to be as detailed as possible
10 | in your problem description to help us fix the bug.
11 |
12 | Feature requests
13 | ----------------
14 |
15 | If you wish to propose a feature, please submit an issue. Try to explain your
16 | use case as fully as possible to help us understand why you think the feature
17 | should be added.
18 |
19 | Creating a pull request (PR)
20 | ----------------------------
21 |
22 | First [fork the repository](https://help.github.com/articles/fork-a-repo/) on
23 | GitHub.
24 |
25 | Then clone your fork:
26 |
27 | ```bash
28 | $ git clone https://github.com/code-rhapsodie/dataflow-bundle.git
29 | $ git checkout -b bug-or-feature-description
30 | ```
31 |
32 | And install the dependencies:
33 |
34 | ```bash
35 | $ composer install
36 | ```
37 |
38 | Write your code and add tests. Then run the tests:
39 |
40 | ```bash
41 | $ vendor/bin/phpunit
42 | ```
43 |
44 | Commit your changes and push them to GitHub:
45 |
46 | ```bash
47 | $ git commit -m "Fix nasty bug"
48 | $ git push -u origin bug-or-feature-description
49 | ```
50 |
51 | Then [create a pull request](https://help.github.com/articles/creating-a-pull-request/)
52 | on GitHub.
53 |
54 | If you need to make some changes, commit and push them as you like. When asked
55 | to squash your commits, do so as follows:
56 |
57 | ```bash
58 | git rebase -i
59 | git push origin bug-or-feature-description -f
60 | ```
61 |
62 | Coding standard
63 | ---------------
64 |
65 | This project follows the [Symfony](https://symfony.com/doc/current/contributing/code/standards.html) coding style.
66 | Please make sure your pull requests adhere to this standard.
67 |
68 | To fix, execute this command after [download PHP CS Fixer](https://cs.symfony.com/):
69 |
70 | ```shell script
71 | $ php php-cs-fixer.phar fix
72 | ```
73 |
--------------------------------------------------------------------------------
/Tests/DataflowType/Writer/CollectionWriterTest.php:
--------------------------------------------------------------------------------
1 | expectException(UnsupportedItemTypeException::class);
15 |
16 | $writer = new CollectionWriter($this->createMock(WriterInterface::class));
17 | $writer->write('Not an iterable');
18 | }
19 |
20 | public function testSupports()
21 | {
22 | $writer = new CollectionWriter($this->createMock(WriterInterface::class));
23 |
24 | $this->assertTrue($writer->supports([]));
25 | $this->assertTrue($writer->supports(new \ArrayIterator([])));
26 | $this->assertFalse($writer->supports(''));
27 | $this->assertFalse($writer->supports(0));
28 | }
29 |
30 | public function testAll()
31 | {
32 | $values = ['a', 'b', 'c'];
33 |
34 | $embeddedWriter = $this->createMock(WriterInterface::class);
35 | $embeddedWriter
36 | ->expects($this->once())
37 | ->method('prepare')
38 | ;
39 | $embeddedWriter
40 | ->expects($this->once())
41 | ->method('finish')
42 | ;
43 | $matcher = $this->exactly(count($values));
44 | $embeddedWriter
45 | ->expects($matcher)
46 | ->method('write')
47 | ->with($this->callback(fn($arg) => $arg === $values[$matcher->numberOfInvocations() - 1]))
48 | ;
49 |
50 | $writer = new CollectionWriter($embeddedWriter);
51 | $writer->prepare();
52 | $writer->write($values);
53 | $writer->finish();
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/src/DependencyInjection/CodeRhapsodieDataflowExtension.php:
--------------------------------------------------------------------------------
1 | load('services.yaml');
22 |
23 | $container
24 | ->registerForAutoconfiguration(DataflowTypeInterface::class)
25 | ->addTag('coderhapsodie.dataflow.type')
26 | ;
27 | $configuration = new Configuration();
28 | $config = $this->processConfiguration($configuration, $configs);
29 |
30 | $container->setParameter('coderhapsodie.dataflow.dbal_default_connection', $config['dbal_default_connection']);
31 | $container->setParameter('coderhapsodie.dataflow.default_logger', $config['default_logger']);
32 | $container->setParameter('coderhapsodie.dataflow.exceptions_mode.type', $config['exceptions_mode']['type']);
33 |
34 | if ($config['exceptions_mode']['type'] === 'file') {
35 | $container->setParameter('coderhapsodie.dataflow.flysystem_service', $config['exceptions_mode']['flysystem_service']);
36 | $loader->load('exceptions_services.yaml');
37 | }
38 |
39 | if ($config['messenger_mode']['enabled']) {
40 | $container->setParameter('coderhapsodie.dataflow.bus', $config['messenger_mode']['bus']);
41 | $loader->load('messenger_services.yaml');
42 | }
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/src/DataflowType/AMPAsyncDataflowBuilder.php:
--------------------------------------------------------------------------------
1 | name = $name;
27 |
28 | return $this;
29 | }
30 |
31 | public function setReader(iterable $reader): self
32 | {
33 | $this->reader = $reader;
34 |
35 | return $this;
36 | }
37 |
38 | public function addStep(callable $step, int $priority = 0, int $scale = 1): self
39 | {
40 | $this->steps[$priority][] = ['step' => $step, 'scale' => $scale];
41 |
42 | return $this;
43 | }
44 |
45 | public function addWriter(WriterInterface $writer): self
46 | {
47 | $this->writers[] = $writer;
48 |
49 | return $this;
50 | }
51 |
52 | public function getDataflow(): DataflowInterface
53 | {
54 | $dataflow = new AMPAsyncDataflow($this->reader, $this->name, $this->loopInterval, $this->emitInterval);
55 |
56 | krsort($this->steps);
57 | foreach ($this->steps as $stepArray) {
58 | foreach ($stepArray as $step) {
59 | $dataflow->addStep($step['step'], $step['scale']);
60 | }
61 | }
62 |
63 | foreach ($this->writers as $writer) {
64 | $dataflow->addWriter($writer);
65 | }
66 |
67 | return $dataflow;
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/src/Command/SchemaCommand.php:
--------------------------------------------------------------------------------
1 | setHelp('The %command.name% help you to generate SQL Query to create or update your database schema for this bundle')
27 | ->addOption('update', null, InputOption::VALUE_NONE, 'Dump only the update SQL queries.')
28 | ->addOption('connection', null, InputOption::VALUE_REQUIRED, 'Define the DBAL connection to use')
29 | ;
30 | }
31 |
32 | protected function execute(InputInterface $input, OutputInterface $output): int
33 | {
34 | $io = new SymfonyStyle($input, $output);
35 | $io->warning('This command is deprecated and will be removed in 6.0, use this command "code-rhapsodie:dataflow:database-schema" instead.');
36 |
37 | $options = array_filter($input->getOptions());
38 |
39 | // add -- before each keys
40 | $options = array_combine(
41 | array_map(fn ($key) => '--'.$key, array_keys($options)),
42 | array_values($options)
43 | );
44 |
45 | $options['--dump-sql'] = true;
46 |
47 | $inputArray = new ArrayInput([
48 | 'command' => 'code-rhapsodie:dataflow:database-schema',
49 | ...$options,
50 | ]);
51 |
52 | return $this->getApplication()->doRun($inputArray, $output);
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/Tests/Validator/Constraints/FrequencyValidatorTest.php:
--------------------------------------------------------------------------------
1 | validator->validate($value, new Frequency());
22 |
23 | $this->assertNoViolation();
24 | }
25 |
26 | public static function getValidValues()
27 | {
28 | return [
29 | ['3 days'],
30 | ['2 weeks'],
31 | ['1 month'],
32 | ['first sunday'],
33 | ];
34 | }
35 |
36 | public function testInvalidValue()
37 | {
38 | $constraint = new Frequency([
39 | 'message' => 'testMessage',
40 | ]);
41 |
42 | $this->validator->validate('wrong value', $constraint);
43 |
44 | $this->buildViolation('testMessage')
45 | ->setParameter('{{ string }}', 'wrong value')
46 | ->assertRaised()
47 | ;
48 | }
49 |
50 | #[DataProvider('getNegativeValues')]
51 | public function testNegativeIntervals($value)
52 | {
53 | $constraint = new Frequency([
54 | 'message' => 'testMessage',
55 | ]);
56 |
57 | $this->validator->validate($value, $constraint);
58 |
59 | $this->buildViolation('testMessage')
60 | ->setParameter('{{ string }}', $value)
61 | ->assertRaised()
62 | ;
63 | }
64 |
65 | public static function getNegativeValues()
66 | {
67 | return [
68 | ['now'],
69 | ['-1 day'],
70 | ['last month'],
71 | ];
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/src/Manager/ScheduledDataflowManager.php:
--------------------------------------------------------------------------------
1 | connection->beginTransaction();
25 | try {
26 | foreach ($this->scheduledDataflowRepository->findReadyToRun() as $scheduled) {
27 | if ($this->jobRepository->findPendingForScheduledDataflow($scheduled) !== null) {
28 | continue;
29 | }
30 |
31 | $this->createPendingForScheduled($scheduled);
32 | $this->updateScheduledDataflowNext($scheduled);
33 | }
34 | } catch (\Throwable $e) {
35 | $this->connection->rollBack();
36 | throw $e;
37 | }
38 | $this->connection->commit();
39 | }
40 |
41 | private function updateScheduledDataflowNext(ScheduledDataflow $scheduled): void
42 | {
43 | $interval = \DateInterval::createFromDateString($scheduled->getFrequency());
44 | $next = clone $scheduled->getNext();
45 | $now = new \DateTime();
46 |
47 | while ($next < $now) {
48 | $next->add($interval);
49 | }
50 |
51 | $scheduled->setNext($next);
52 | $this->scheduledDataflowRepository->save($scheduled);
53 | }
54 |
55 | private function createPendingForScheduled(ScheduledDataflow $scheduled): void
56 | {
57 | $this->jobRepository->save(Job::createFromScheduledDataflow($scheduled));
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/src/DataflowType/Writer/DelegatorWriter.php:
--------------------------------------------------------------------------------
1 | delegates as $delegate) {
27 | $delegate->prepare();
28 | }
29 | }
30 |
31 | public function write($item)
32 | {
33 | foreach ($this->delegates as $delegate) {
34 | if (!$delegate->supports($item)) {
35 | continue;
36 | }
37 |
38 | $delegate->write($item);
39 |
40 | return;
41 | }
42 |
43 | throw new UnsupportedItemTypeException(\sprintf('None of the registered delegate writers support the received item of type %s', get_debug_type($item)));
44 | }
45 |
46 | public function finish()
47 | {
48 | foreach ($this->delegates as $delegate) {
49 | $delegate->finish();
50 | }
51 | }
52 |
53 | public function supports($item): bool
54 | {
55 | foreach ($this->delegates as $delegate) {
56 | if ($delegate->supports($item)) {
57 | return true;
58 | }
59 | }
60 |
61 | return false;
62 | }
63 |
64 | /**
65 | * Registers a collection of delegates.
66 | *
67 | * @param iterable|DelegateWriterInterface[] $delegates
68 | */
69 | public function addDelegates(iterable $delegates): void
70 | {
71 | foreach ($delegates as $delegate) {
72 | $this->addDelegate($delegate);
73 | }
74 | }
75 |
76 | /**
77 | * Registers one delegate.
78 | */
79 | public function addDelegate(DelegateWriterInterface $delegate): void
80 | {
81 | $this->delegates[] = $delegate;
82 | }
83 | }
84 |
--------------------------------------------------------------------------------
/src/Command/RunPendingDataflowsCommand.php:
--------------------------------------------------------------------------------
1 | setHelp(
36 | <<<'EOF'
37 | The %command.name% command runs dataflows according to the schedule defined in the UI by the user.
38 | EOF
39 | )
40 | ->addOption('connection', null, InputOption::VALUE_REQUIRED, 'Define the DBAL connection to use');
41 | }
42 |
43 | protected function execute(InputInterface $input, OutputInterface $output): int
44 | {
45 | if (!$this->lock()) {
46 | $output->writeln('The command is already running in another process.');
47 |
48 | return 0;
49 | }
50 |
51 | if ($input->getOption('connection') !== null) {
52 | $this->connectionFactory->setConnectionName($input->getOption('connection'));
53 | }
54 |
55 | $this->manager->createJobsFromScheduledDataflows();
56 | $this->runner->runPendingDataflows();
57 |
58 | $this->release();
59 |
60 | return 0;
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/src/Command/ScheduleListCommand.php:
--------------------------------------------------------------------------------
1 | setHelp('The %command.name% lists all scheduled dataflows.')
31 | ->addOption('connection', null, InputOption::VALUE_REQUIRED, 'Define the DBAL connection to use');
32 | }
33 |
34 | protected function execute(InputInterface $input, OutputInterface $output): int
35 | {
36 | if ($input->getOption('connection') !== null) {
37 | $this->connectionFactory->setConnectionName($input->getOption('connection'));
38 | }
39 | $io = new SymfonyStyle($input, $output);
40 | $display = [];
41 | $schedules = $this->scheduledDataflowRepository->listAllOrderedByLabel();
42 | foreach ($schedules as $schedule) {
43 | $display[] = [
44 | $schedule['id'],
45 | $schedule['label'],
46 | $schedule['enabled'] ? 'yes' : 'no',
47 | $schedule['startTime'] ? (new \DateTime($schedule['startTime']))->format('Y-m-d H:i:s') : '-',
48 | $schedule['next'] ? (new \DateTime($schedule['next']))->format('Y-m-d H:i:s') : '-',
49 | ];
50 | }
51 |
52 | $io->table(['id', 'label', 'enabled?', 'last execution', 'next execution'], $display);
53 |
54 | return 0;
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/src/DataflowType/AbstractDataflowType.php:
--------------------------------------------------------------------------------
1 | saveDate = new \DateTime('+1 minute');
32 |
33 | $optionsResolver = new OptionsResolver();
34 | $this->configureOptions($optionsResolver);
35 | $options = $optionsResolver->resolve($options);
36 |
37 | $builder = $this->createDataflowBuilder();
38 | $builder->setName($this->getLabel());
39 | $builder->addAfterItemProcessor(function (int|string $index, mixed $item, int $count) use ($jobId) {
40 | if ($jobId === null || $this->saveDate > new \DateTime()) {
41 | return;
42 | }
43 |
44 | $this->repository->updateCount($jobId, $count);
45 | $this->saveDate = new \DateTime('+1 minute');
46 | });
47 | $this->buildDataflow($builder, $options);
48 | $dataflow = $builder->getDataflow();
49 | if ($dataflow instanceof LoggerAwareInterface && $this->logger instanceof LoggerInterface) {
50 | $dataflow->setLogger($this->logger);
51 | }
52 |
53 | return $dataflow->process();
54 | }
55 |
56 | public function setRepository(JobRepository $repository): void
57 | {
58 | $this->repository = $repository;
59 | }
60 |
61 | protected function createDataflowBuilder(): DataflowBuilder
62 | {
63 | return new DataflowBuilder();
64 | }
65 |
66 | /**
67 | * @codeCoverageIgnore
68 | */
69 | protected function configureOptions(OptionsResolver $optionsResolver): void
70 | {
71 | }
72 |
73 | abstract protected function buildDataflow(DataflowBuilder $builder, array $options): void;
74 | }
75 |
--------------------------------------------------------------------------------
/src/DependencyInjection/Configuration.php:
--------------------------------------------------------------------------------
1 | getRootNode();
17 |
18 | $rootNode
19 | ->children()
20 | ->scalarNode('dbal_default_connection')
21 | ->defaultValue('default')
22 | ->end()
23 | ->scalarNode('default_logger')
24 | ->defaultValue('logger')
25 | ->end()
26 | ->arrayNode('messenger_mode')
27 | ->addDefaultsIfNotSet()
28 | ->children()
29 | ->booleanNode('enabled')
30 | ->defaultFalse()
31 | ->end()
32 | ->scalarNode('bus')
33 | ->defaultValue('messenger.default_bus')
34 | ->end()
35 | ->end()
36 | ->validate()
37 | ->ifTrue(static fn ($v): bool => $v['enabled'] && !interface_exists(MessageBusInterface::class))
38 | ->thenInvalid('You need "symfony/messenger" in order to use Dataflow messenger mode.')
39 | ->end()
40 | ->end()
41 | ->arrayNode('exceptions_mode')
42 | ->addDefaultsIfNotSet()
43 | ->children()
44 | ->scalarNode('type')
45 | ->defaultValue('database')
46 | ->end()
47 | ->scalarNode('flysystem_service')
48 | ->end()
49 | ->end()
50 | ->validate()
51 | ->ifTrue(static fn ($v): bool => $v['type'] === 'file' && !class_exists('\League\Flysystem\Filesystem'))
52 | ->thenInvalid('You need "league/flysystem" to use Dataflow file exception mode.')
53 | ->end()
54 | ->end()
55 | ->end()
56 | ;
57 |
58 | return $treeBuilder;
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/src/DataflowType/DataflowBuilder.php:
--------------------------------------------------------------------------------
1 | name = $name;
29 |
30 | return $this;
31 | }
32 |
33 | public function setReader(iterable $reader): self
34 | {
35 | $this->reader = $reader;
36 |
37 | return $this;
38 | }
39 |
40 | public function addStep(callable $step, int $priority = 0): self
41 | {
42 | $this->steps[$priority][] = $step;
43 |
44 | return $this;
45 | }
46 |
47 | public function addWriter(WriterInterface $writer): self
48 | {
49 | $this->writers[] = $writer;
50 |
51 | return $this;
52 | }
53 |
54 | public function setCustomExceptionIndex(callable $callable): self
55 | {
56 | $this->customExceptionIndex = \Closure::fromCallable($callable);
57 |
58 | return $this;
59 | }
60 |
61 | public function addAfterItemProcessor(callable $callable): self
62 | {
63 | $this->afterItemProcessors[] = \Closure::fromCallable($callable);
64 |
65 | return $this;
66 | }
67 |
68 | public function getDataflow(): DataflowInterface
69 | {
70 | $dataflow = new Dataflow($this->reader, $this->name);
71 |
72 | krsort($this->steps);
73 | foreach ($this->steps as $stepArray) {
74 | foreach ($stepArray as $step) {
75 | $dataflow->addStep($step);
76 | }
77 | }
78 |
79 | foreach ($this->writers as $writer) {
80 | $dataflow->addWriter($writer);
81 | }
82 |
83 | if (\is_callable($this->customExceptionIndex)) {
84 | $dataflow->setCustomExceptionIndex($this->customExceptionIndex);
85 | }
86 |
87 | $dataflow->setAfterItemProcessors($this->afterItemProcessors);
88 |
89 | return $dataflow;
90 | }
91 | }
92 |
--------------------------------------------------------------------------------
/src/SchemaProvider/DataflowSchemaProvider.php:
--------------------------------------------------------------------------------
1 | createTable(JobRepository::TABLE_NAME);
22 | $tableJob->addColumn('id', 'integer', [
23 | 'autoincrement' => true,
24 | ]);
25 | $tableJob->setPrimaryKey(['id']);
26 |
27 | $tableJob->addColumn('scheduled_dataflow_id', 'integer', ['notnull' => false]);
28 | $tableJob->addColumn('status', 'integer', ['notnull' => true]);
29 | $tableJob->addColumn('label', 'string', ['notnull' => true, 'length' => 255]);
30 | $tableJob->addColumn('dataflow_type', 'string', ['notnull' => true, 'length' => 255]);
31 | $tableJob->addColumn('options', 'json', ['notnull' => true]);
32 | $tableJob->addColumn('requested_date', 'datetime', ['notnull' => false]);
33 | $tableJob->addColumn('count', 'integer', ['notnull' => false]);
34 | $tableJob->addColumn('exceptions', 'json', ['notnull' => false]);
35 | $tableJob->addColumn('start_time', 'datetime', ['notnull' => false]);
36 | $tableJob->addColumn('end_time', 'datetime', ['notnull' => false]);
37 |
38 | $tableSchedule = $schema->createTable(ScheduledDataflowRepository::TABLE_NAME);
39 | $tableSchedule->addColumn('id', 'integer', [
40 | 'autoincrement' => true,
41 | ]);
42 | $tableSchedule->setPrimaryKey(['id']);
43 | $tableSchedule->addColumn('label', 'string', ['notnull' => true, 'length' => 255]);
44 | $tableSchedule->addColumn('dataflow_type', 'string', ['notnull' => true, 'length' => 255]);
45 | $tableSchedule->addColumn('options', 'json', ['notnull' => true]);
46 | $tableSchedule->addColumn('frequency', 'string', ['notnull' => true, 'length' => 255]);
47 | $tableSchedule->addColumn('next', 'datetime', ['notnull' => false]);
48 | $tableSchedule->addColumn('enabled', 'boolean', ['notnull' => true]);
49 |
50 | $tableJob->addForeignKeyConstraint($tableSchedule->getName(), ['scheduled_dataflow_id'], ['id']);
51 | $tableJob->addIndex(['status'], 'idx_status');
52 |
53 | return $schema;
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/Tests/Runner/MessengerDataflowRunnerTest.php:
--------------------------------------------------------------------------------
1 | repository = $this->createMock(JobRepository::class);
23 | $this->bus = $this->createMock(MessageBusInterface::class);
24 |
25 | $this->runner = new MessengerDataflowRunner($this->repository, $this->bus);
26 | }
27 |
28 | public function testRunPendingDataflows()
29 | {
30 | $job1 = (new Job())->setId($id1 = 10);
31 | $job2 = (new Job())->setId($id2 = 20);
32 |
33 | $this->repository
34 | ->expects($this->exactly(3))
35 | ->method('findNextPendingDataflow')
36 | ->willReturnOnConsecutiveCalls($job1, $job2, null)
37 | ;
38 | $matcher = $this->exactly(2);
39 | $this->repository
40 | ->expects($matcher)
41 | ->method('save')
42 | ->with($this->callback(fn($arg) => match ($matcher->numberOfInvocations()) {
43 | 1 => $arg === $job1,
44 | 2 => $arg === $job2,
45 | default => false,
46 | }))
47 | ;
48 |
49 | $matcher = $this->exactly(2);
50 | $this->bus
51 | ->expects($matcher)
52 | ->method('dispatch')
53 | ->with($this->callback(fn($arg) => match ($matcher->numberOfInvocations()) {
54 | 1 => $arg instanceof JobMessage && $arg->getJobId() === $id1,
55 | 2 => $arg instanceof JobMessage && $arg->getJobId() === $id2,
56 | default => false,
57 | }))
58 | ->willReturnOnConsecutiveCalls(
59 | new Envelope(new JobMessage($id1)),
60 | new Envelope(new JobMessage($id2))
61 | )
62 | ;
63 |
64 | $this->runner->runPendingDataflows();
65 |
66 | $this->assertSame(Job::STATUS_QUEUED, $job1->getStatus());
67 | $this->assertSame(Job::STATUS_QUEUED, $job2->getStatus());
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "code-rhapsodie/dataflow-bundle",
3 | "description": "Data processing framework inspired by PortPHP",
4 | "type": "symfony-bundle",
5 | "keywords": [
6 | "dataflow",
7 | "import",
8 | "export",
9 | "data processing"
10 | ],
11 | "license": "MIT",
12 | "authors": [
13 | {
14 | "name": "Jérémy J.",
15 | "email": "jeremy@code-rhapsodie.fr",
16 | "role": "Developer"
17 | },
18 | {
19 | "name": "Jean-Baptiste Nahan",
20 | "email": "jean-baptiste@code-rhapsodie.fr",
21 | "role": "Developer"
22 | },
23 | {
24 | "name": "Manuel Farrica",
25 | "email": "manuel@code-rhapsodie.fr",
26 | "role": "Developer"
27 | },
28 | {
29 | "name": "Code Rhapsodie",
30 | "homepage": "https://www.code-rhapsodie.fr/"
31 | }
32 | ],
33 | "autoload": {
34 | "psr-4": {
35 | "CodeRhapsodie\\DataflowBundle\\": "src/"
36 | }
37 | },
38 | "autoload-dev": {
39 | "psr-4": {
40 | "CodeRhapsodie\\DataflowBundle\\Tests\\": "Tests/"
41 | }
42 | },
43 | "require": {
44 | "php": "^8.0",
45 | "ext-json": "*",
46 | "doctrine/dbal": "^3.0||^4.0",
47 | "doctrine/doctrine-bundle": "^2.0",
48 | "monolog/monolog": "^2.0||^3.0",
49 | "psr/log": "^1.1||^2.0||^3.0",
50 | "symfony/config": "^7.0",
51 | "symfony/console": "^7.0",
52 | "symfony/dependency-injection": "^7.0",
53 | "symfony/event-dispatcher": "^7.0",
54 | "symfony/http-kernel": "^7.0",
55 | "symfony/lock": "^7.0",
56 | "symfony/monolog-bridge": "^7.0",
57 | "symfony/options-resolver": "^7.0",
58 | "symfony/validator": "^7.0",
59 | "symfony/yaml": "^7.0"
60 | },
61 | "require-dev": {
62 | "amphp/amp": "^2.5",
63 | "friendsofphp/php-cs-fixer": "^3.75",
64 | "phpunit/phpunit": "^11",
65 | "portphp/portphp": "^1.9",
66 | "rector/rector": "^2.0",
67 | "symfony/messenger": "^7.0"
68 | },
69 | "suggest": {
70 | "amphp/amp": "Provide asynchronous steps for your dataflows",
71 | "portphp/portphp": "Provides generic readers, steps and writers for your dataflows.",
72 | "symfony/messenger": "Allows messenger mode, i.e. letting workers run jobs"
73 | },
74 | "config": {
75 | "sort-packages": true
76 | },
77 | "extra": {
78 | "branch-alias": {
79 | "dev-master": "2.1.x-dev",
80 | "dev-v2.0.x": "2.0.x-dev",
81 | "dev-v1.x": "1.x-dev"
82 | }
83 | }
84 | }
85 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Version 5.4.1
2 | * Fix File Exceptions integration
3 |
4 | # Version 5.4.0
5 | * Add possibility to save exceptions in file
6 |
7 | # Version 5.3.1
8 | * Fix interface naming
9 |
10 | # Version 5.3.0
11 | * Added auto update count processed item while running job
12 |
13 | # Version 5.2.0
14 | * Added custom index for job status
15 |
16 | # Version 5.1.0
17 | * Refactor SchemaDump command
18 |
19 | # Version 5.0.1
20 | * Fix compatibility with doctrine 4
21 |
22 | # Version 5.0.0
23 | * Initiate Kudos on dataflow-bundle
24 | * Added Symfony 7 support
25 | * Removed Symfony 6 compatibility
26 | * Removed Symfony 5 compatibility
27 | * Removed Symfony 4 compatibility
28 | * Removed Symfony 3 compatibility
29 | * Changed README.md
30 | * Added CI
31 |
32 | # Version 4.1.3
33 | * Fix log exception argument typing
34 |
35 | # Version 4.1.2
36 | * Fix DBAL 2.12 compatibility break
37 |
38 | # Version 4.1.0
39 |
40 | * Added custom index for exception log
41 |
42 | # Version 4.0.0
43 |
44 | * Added Symfony 6 support
45 | * PHP minimum requirements bumped to 8.0
46 |
47 | # Version 3.1.0
48 |
49 | * Added optional "messenger mode", to delegate jobs execution to workers from the Symfony messenger component
50 | * Added support for asynchronous steps execution, using the AMPHP library (contribution from [matyo91](https://github.com/matyo91))
51 |
52 | # Version 3.0.0
53 |
54 | * Added PHP 8 support
55 | * PHP minimum requirements bumped to 7.3
56 | * Added Doctrine DBAL 3 support
57 | * Doctrine DBAL minimum requirements bumped to 2.12
58 |
59 | # Version 2.2.0
60 |
61 | * Improve logging Dataflow job
62 |
63 | # Version 2.1.1
64 |
65 | * Fixed some Symfony 5 compatibility issues
66 |
67 | # Version 2.1.0
68 |
69 | * Added CollectionWriter and DelegatorWriter
70 | * Adding Symfony 5.0 compatibility
71 | * Save all exceptions caught in the log for `code-rhapsodie:dataflow:execute`
72 | * Added more output when errors occured during `code-rhapsodie:dataflow:execute`
73 |
74 | # Version 2.0.2
75 |
76 | * Fixed the connection proxy class created by the factory
77 |
78 | # Version 2.0.1
79 |
80 | * Fixed next execution time not increasing for scheduled dataflows
81 |
82 | # Version 2.0.0
83 |
84 | * Add Doctrine DBAL multi-connection support
85 | * Add configuration to define the default Doctrine DBAL connection
86 | * Remove Doctrine ORM
87 | * Rewrite repositories
88 |
89 | # Version 1.0.1
90 |
91 | * Fix lost dependency
92 | * Fix schedule removing
93 |
94 | # Version 1.0.0
95 |
96 | Initial version
97 |
98 | * Define and configure a Dataflow
99 | * Run the Job scheduled
100 | * Run one Dataflow from the command line
101 | * Define the schedule for a Dataflow from the command line
102 | * Enable/Disable a scheduled Dataflow from the command line
103 | * Display the list of scheduled Dataflow from the command line
104 | * Display the result for the last Job for a Dataflow from the command line
105 |
--------------------------------------------------------------------------------
/src/Processor/JobProcessor.php:
--------------------------------------------------------------------------------
1 | beforeProcessing($job);
37 |
38 | $dataflowType = $this->registry->getDataflowType($job->getDataflowType());
39 | if ($dataflowType instanceof AutoUpdateCountInterface) {
40 | $dataflowType->setRepository($this->repository);
41 | }
42 |
43 | $loggers = [new Logger('dataflow_internal', [$bufferHandler = new BufferHandler()])];
44 | if (isset($this->logger)) {
45 | $loggers[] = $this->logger;
46 | }
47 | $logger = new DelegatingLogger($loggers);
48 |
49 | if ($dataflowType instanceof LoggerAwareInterface) {
50 | $dataflowType->setLogger($logger);
51 | }
52 |
53 | $result = $dataflowType->process($job->getOptions(), $job->getId());
54 |
55 | if (!$dataflowType instanceof LoggerAwareInterface) {
56 | foreach ($result->getExceptions() as $index => $e) {
57 | $logger->error($e, ['index' => $index]);
58 | }
59 | }
60 |
61 | $this->afterProcessing($job, $result, $bufferHandler);
62 | }
63 |
64 | private function beforeProcessing(Job $job): void
65 | {
66 | $this->dispatcher->dispatch(new ProcessingEvent($job), Events::BEFORE_PROCESSING);
67 |
68 | $job
69 | ->setStatus(Job::STATUS_RUNNING)
70 | ->setStartTime(new \DateTime())
71 | ;
72 | $this->jobGateway->save($job);
73 | }
74 |
75 | private function afterProcessing(Job $job, Result $result, BufferHandler $bufferLogger): void
76 | {
77 | $job
78 | ->setEndTime($result->getEndTime())
79 | ->setStatus(Job::STATUS_COMPLETED)
80 | ->setCount($result->getSuccessCount())
81 | ->setExceptions($bufferLogger->clearBuffer())
82 | ;
83 |
84 | $this->jobGateway->save($job);
85 |
86 | $this->dispatcher->dispatch(new ProcessingEvent($job), Events::AFTER_PROCESSING);
87 | }
88 | }
89 |
--------------------------------------------------------------------------------
/src/Command/ChangeScheduleStatusCommand.php:
--------------------------------------------------------------------------------
1 | setHelp('The %command.name% command able you to change schedule status.')
33 | ->addArgument('schedule-id', InputArgument::REQUIRED, 'Id of the schedule')
34 | ->addOption('enable', null, InputOption::VALUE_NONE, 'Enable the schedule')
35 | ->addOption('disable', null, InputOption::VALUE_NONE, 'Disable the schedule')
36 | ->addOption('connection', null, InputOption::VALUE_REQUIRED, 'Define the DBAL connection to use');
37 | }
38 |
39 | protected function execute(InputInterface $input, OutputInterface $output): int
40 | {
41 | if ($input->getOption('connection') !== null) {
42 | $this->connectionFactory->setConnectionName($input->getOption('connection'));
43 | }
44 | $io = new SymfonyStyle($input, $output);
45 | /** @var ScheduledDataflow|null $schedule */
46 | $schedule = $this->scheduledDataflowRepository->find((int) $input->getArgument('schedule-id'));
47 |
48 | if (!$schedule) {
49 | $io->error(\sprintf('Cannot find scheduled dataflow with id "%d".', $input->getArgument('schedule-id')));
50 |
51 | return 1;
52 | }
53 |
54 | if ($input->getOption('enable') && $input->getOption('disable')) {
55 | $io->error('You cannot pass enable and disable options in the same time.');
56 |
57 | return 2;
58 | }
59 | if (!$input->getOption('enable') && !$input->getOption('disable')) {
60 | $io->error('You must pass enable or disable option.');
61 |
62 | return 3;
63 | }
64 |
65 | try {
66 | $schedule->setEnabled($input->getOption('enable'));
67 | $this->scheduledDataflowRepository->save($schedule);
68 | $io->success(\sprintf('Schedule with id "%s" has been successfully updated.', $schedule->getId()));
69 | } catch (\Exception $e) {
70 | $io->error(\sprintf('An error occured when changing schedule status : "%s".', $e->getMessage()));
71 |
72 | return 4;
73 | }
74 |
75 | return 0;
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/src/Command/ExecuteDataflowCommand.php:
--------------------------------------------------------------------------------
1 | setHelp(
40 | <<<'EOF'
41 | The %command.name% command runs one dataflow with the provided options.
42 |
43 | php %command.full_name% App\Dataflow\MyDataflow '{"option1": "value1", "option2": "value2"}'
44 | EOF
45 | )
46 | ->addArgument('fqcn', InputArgument::REQUIRED, 'FQCN or alias of the dataflow type')
47 | ->addArgument('options', InputArgument::OPTIONAL, 'Options for the dataflow type as a json string', '[]')
48 | ->addOption('connection', null, InputOption::VALUE_REQUIRED, 'Define the DBAL connection to use');
49 | }
50 |
51 | protected function execute(InputInterface $input, OutputInterface $output): int
52 | {
53 | if ($input->getOption('connection') !== null) {
54 | $this->connectionFactory->setConnectionName($input->getOption('connection'));
55 | }
56 | $fqcnOrAlias = $input->getArgument('fqcn');
57 | $options = json_decode($input->getArgument('options'), true, 512, \JSON_THROW_ON_ERROR);
58 | $io = new SymfonyStyle($input, $output);
59 |
60 | $dataflowType = $this->registry->getDataflowType($fqcnOrAlias);
61 | if ($dataflowType instanceof AutoUpdateCountInterface) {
62 | $dataflowType->setRepository($this->jobRepository);
63 | }
64 |
65 | if ($dataflowType instanceof LoggerAwareInterface && isset($this->logger)) {
66 | $dataflowType->setLogger($this->logger);
67 | }
68 |
69 | $result = $dataflowType->process($options);
70 |
71 | $io->writeln('Executed: '.$result->getName());
72 | $io->writeln('Start time: '.$result->getStartTime()->format('Y/m/d H:i:s'));
73 | $io->writeln('End time: '.$result->getEndTime()->format('Y/m/d H:i:s'));
74 | $io->writeln('Success: '.$result->getSuccessCount());
75 |
76 | if ($result->hasErrors()) {
77 | $io->error("Errors: {$result->getErrorCount()}\nExceptions traces are available in the logs.");
78 |
79 | return 1;
80 | }
81 |
82 | return 0;
83 | }
84 | }
85 |
--------------------------------------------------------------------------------
/Tests/Processor/JobProcessorTest.php:
--------------------------------------------------------------------------------
1 | repository = $this->createMock(JobRepository::class);
29 | $this->registry = $this->createMock(DataflowTypeRegistryInterface::class);
30 | $this->dispatcher = $this->createMock(EventDispatcherInterface::class);
31 | $this->jobGateway = $this->createMock(JobGateway::class);
32 |
33 | $this->processor = new JobProcessor($this->repository, $this->registry, $this->dispatcher, $this->jobGateway);
34 | }
35 |
36 | public function testProcess()
37 | {
38 | $now = new \DateTimeImmutable();
39 | $job = (new Job())
40 | ->setStatus(Job::STATUS_PENDING)
41 | ->setDataflowType($type = 'type')
42 | ->setOptions($options = ['option1' => 'value1'])
43 | ;
44 |
45 | $matcher = $this->exactly(2);
46 | $this->dispatcher
47 | ->expects($matcher)
48 | ->method('dispatch')
49 | ->with(
50 | $this->callback(fn($arg) => $arg instanceof ProcessingEvent && $arg->getJob() === $job),
51 | $this->callback(fn($arg) => match ($matcher->numberOfInvocations()) {
52 | 1 => $arg === Events::BEFORE_PROCESSING,
53 | 2 => $arg === Events::AFTER_PROCESSING,
54 | default => false,
55 | })
56 | );
57 |
58 | $dataflowType = $this->createMock(DataflowTypeInterface::class);
59 |
60 | $this->registry
61 | ->expects($this->once())
62 | ->method('getDataflowType')
63 | ->with($type)
64 | ->willReturn($dataflowType)
65 | ;
66 |
67 | $bag = [new \Exception('message1')];
68 |
69 | $result = new Result('name', new \DateTimeImmutable(), $end = new \DateTimeImmutable(), $count = 10, $bag);
70 |
71 | $dataflowType
72 | ->expects($this->once())
73 | ->method('process')
74 | ->with($options)
75 | ->willReturn($result)
76 | ;
77 |
78 | $this->jobGateway
79 | ->expects($this->exactly(2))
80 | ->method('save')
81 | ;
82 |
83 | $this->processor->process($job);
84 |
85 | $this->assertGreaterThanOrEqual($now, $job->getStartTime());
86 | $this->assertSame(Job::STATUS_COMPLETED, $job->getStatus());
87 | $this->assertSame($end, $job->getEndTime());
88 | $this->assertSame($count - count($bag), $job->getCount());
89 | }
90 | }
91 |
--------------------------------------------------------------------------------
/src/DataflowType/Dataflow/Dataflow.php:
--------------------------------------------------------------------------------
1 | steps[] = $step;
41 |
42 | return $this;
43 | }
44 |
45 | /**
46 | * @return $this
47 | */
48 | public function addWriter(WriterInterface $writer): self
49 | {
50 | $this->writers[] = $writer;
51 |
52 | return $this;
53 | }
54 |
55 | /**
56 | * @return $this
57 | */
58 | public function setCustomExceptionIndex(callable $callable): self
59 | {
60 | $this->customExceptionIndex = \Closure::fromCallable($callable);
61 |
62 | return $this;
63 | }
64 |
65 | /**
66 | * @param array $processors
67 | */
68 | public function setAfterItemProcessors(array $processors): self
69 | {
70 | $this->afterItemProcessors = array_map(fn (callable $callable) => \Closure::fromCallable($callable), $processors);
71 |
72 | return $this;
73 | }
74 |
75 | public function process(): Result
76 | {
77 | $count = 0;
78 | $exceptions = [];
79 | $startTime = new \DateTime();
80 |
81 | try {
82 | foreach ($this->writers as $writer) {
83 | $writer->prepare();
84 | }
85 |
86 | foreach ($this->reader as $index => $item) {
87 | try {
88 | $this->processItem($item);
89 | } catch (\Throwable $e) {
90 | $exceptionIndex = $index;
91 | try {
92 | if (\is_callable($this->customExceptionIndex)) {
93 | $exceptionIndex = (string) ($this->customExceptionIndex)($item, $index);
94 | }
95 | } catch (\Throwable $e2) {
96 | $exceptions[$index] = $e2;
97 | $this->logException($e2, $index);
98 | }
99 | $exceptions[$exceptionIndex] = $e;
100 | $this->logException($e, $exceptionIndex);
101 | }
102 |
103 | ++$count;
104 |
105 | foreach ($this->afterItemProcessors as $afterItemProcessor) {
106 | $afterItemProcessor($index, $item, $count);
107 | }
108 | }
109 |
110 | foreach ($this->writers as $writer) {
111 | $writer->finish();
112 | }
113 | } catch (\Throwable $e) {
114 | $exceptions[] = $e;
115 | $this->logException($e);
116 | }
117 |
118 | return new Result($this->name, $startTime, new \DateTime(), $count, $exceptions);
119 | }
120 |
121 | private function processItem(mixed $item): void
122 | {
123 | foreach ($this->steps as $step) {
124 | $item = \call_user_func($step, $item);
125 |
126 | if ($item === false) {
127 | return;
128 | }
129 | }
130 |
131 | foreach ($this->writers as $writer) {
132 | $writer->write($item);
133 | }
134 | }
135 |
136 | private function logException(\Throwable $e, string|int|null $index = null): void
137 | {
138 | if (!isset($this->logger)) {
139 | return;
140 | }
141 |
142 | $this->logger->error($e, ['exception' => $e, 'index' => $index]);
143 | }
144 | }
145 |
--------------------------------------------------------------------------------
/src/Command/JobShowCommand.php:
--------------------------------------------------------------------------------
1 | 'Pending',
25 | Job::STATUS_RUNNING => 'Running',
26 | Job::STATUS_COMPLETED => 'Completed',
27 | ];
28 |
29 | public function __construct(private JobGateway $jobGateway, private ConnectionFactory $connectionFactory)
30 | {
31 | parent::__construct();
32 | }
33 |
34 | protected function configure(): void
35 | {
36 | $this
37 | ->setHelp('The %command.name% display job details for schedule or specific job.')
38 | ->addOption('job-id', null, InputOption::VALUE_REQUIRED, 'Id of the job to get details')
39 | ->addOption('schedule-id', null, InputOption::VALUE_REQUIRED, 'Id of schedule for last execution details')
40 | ->addOption('details', null, InputOption::VALUE_NONE, 'Display full details')
41 | ->addOption('connection', null, InputOption::VALUE_REQUIRED, 'Define the DBAL connection to use');
42 | }
43 |
44 | protected function execute(InputInterface $input, OutputInterface $output): int
45 | {
46 | if ($input->getOption('connection') !== null) {
47 | $this->connectionFactory->setConnectionName($input->getOption('connection'));
48 | }
49 |
50 | $io = new SymfonyStyle($input, $output);
51 |
52 | $jobId = (int) $input->getOption('job-id');
53 | $scheduleId = (int) $input->getOption('schedule-id');
54 | if ($jobId && $scheduleId) {
55 | $io->error('You must use `job-id` OR `schedule-id` option, not the 2 in the same time.');
56 |
57 | return 1;
58 | }
59 |
60 | if ($scheduleId) {
61 | $job = $this->jobGateway->findLastForDataflowId($scheduleId);
62 | } elseif ($jobId) {
63 | $job = $this->jobGateway->find($jobId);
64 | } else {
65 | $io->error('You must pass `job-id` or `schedule-id` option.');
66 |
67 | return 2;
68 | }
69 |
70 | if ($job === null) {
71 | $io->error('Cannot find job :/');
72 |
73 | return 3;
74 | }
75 |
76 | /** @var Job $job */
77 | $display = [
78 | ['Job id', $job->getId()],
79 | ['Label', $job->getLabel()],
80 | ['Requested at', $job->getRequestedDate()->format('Y-m-d H:i:s')],
81 | ['Started at', $job->getStartTime() ? $job->getStartTime()->format('Y-m-d H:i:s') : '-'],
82 | ['Ended at', $job->getEndTime() ? $job->getEndTime()->format('Y-m-d H:i:s') : '-'],
83 | ['Object number', $job->getCount()],
84 | ['Errors', \count((array) $job->getExceptions())],
85 | ['Status', $this->translateStatus($job->getStatus())],
86 | ];
87 | if ($input->getOption('details')) {
88 | $display[] = ['Type', $job->getDataflowType()];
89 | $display[] = ['Options', json_encode($job->getOptions(), \JSON_THROW_ON_ERROR)];
90 | $io->section('Summary');
91 | }
92 |
93 | $io->table(['Field', 'Value'], $display);
94 | if ($input->getOption('details')) {
95 | $io->section('Exceptions');
96 | $exceptions = array_map(fn (string $exception) => substr($exception, 0, 900).'…', $job->getExceptions());
97 |
98 | $io->write($exceptions);
99 | }
100 |
101 | return 0;
102 | }
103 |
104 | private function translateStatus(int $status): string
105 | {
106 | return self::STATUS_MAPPING[$status] ?? 'Unknown status';
107 | }
108 | }
109 |
--------------------------------------------------------------------------------
/src/Command/DatabaseSchemaCommand.php:
--------------------------------------------------------------------------------
1 | setHelp('The %command.name% help you to generate SQL Query to create or update your database schema for this bundle')
34 | ->addOption('dump-sql', null, InputOption::VALUE_NONE, 'Dump only the update SQL queries.')
35 | ->addOption('update', null, InputOption::VALUE_NONE, 'Dump/execute only the update SQL queries.')
36 | ->addOption('connection', null, InputOption::VALUE_REQUIRED, 'Define the DBAL connection to use');
37 | }
38 |
39 | protected function execute(InputInterface $input, OutputInterface $output): int
40 | {
41 | $io = new SymfonyStyle($input, $output);
42 |
43 | if ($input->getOption('connection') !== null) {
44 | $this->connectionFactory->setConnectionName($input->getOption('connection'));
45 | }
46 |
47 | $connection = $this->connectionFactory->getConnection();
48 |
49 | $schemaProvider = new DataflowSchemaProvider();
50 | $schema = $schemaProvider->createSchema();
51 |
52 | $sqls = $schema->toSql($connection->getDatabasePlatform());
53 |
54 | if ($input->getOption('update')) {
55 | $sm = $connection->createSchemaManager();
56 |
57 | $tableArray = [JobRepository::TABLE_NAME, ScheduledDataflowRepository::TABLE_NAME];
58 | $tables = [];
59 | foreach ($sm->listTables() as $table) {
60 | /** @var Table $table */
61 | if (\in_array($table->getName(), $tableArray)) {
62 | $tables[] = $table;
63 | }
64 | }
65 |
66 | $namespaces = [];
67 |
68 | if ($connection->getDatabasePlatform()->supportsSchemas()) {
69 | $namespaces = $sm->listSchemaNames();
70 | }
71 |
72 | $sequences = [];
73 |
74 | if ($connection->getDatabasePlatform()->supportsSequences()) {
75 | $sequences = $sm->listSequences();
76 | }
77 |
78 | $oldSchema = new Schema($tables, $sequences, $sm->createSchemaConfig(), $namespaces);
79 |
80 | $sqls = $connection->getDatabasePlatform()->getAlterSchemaSQL((new Comparator($connection->getDatabasePlatform()))->compareSchemas($oldSchema, $schema));
81 |
82 | if (empty($sqls)) {
83 | $io->info('There is no update SQL queries.');
84 | }
85 | }
86 |
87 | if ($input->getOption('dump-sql')) {
88 | $io->text('Execute these SQL Queries on your database:');
89 | foreach ($sqls as $sql) {
90 | $io->text($sql.';');
91 | }
92 |
93 | return Command::SUCCESS;
94 | }
95 |
96 | if (!$io->askQuestion(new ConfirmationQuestion('Are you sure to update database ?', true))) {
97 | $io->text('Execution canceled.');
98 |
99 | return Command::SUCCESS;
100 | }
101 |
102 | foreach ($sqls as $sql) {
103 | $connection->executeQuery($sql);
104 | }
105 |
106 | $io->success(\sprintf('%d queries executed.', \count($sqls)));
107 |
108 | return parent::SUCCESS;
109 | }
110 | }
111 |
--------------------------------------------------------------------------------
/src/Entity/ScheduledDataflow.php:
--------------------------------------------------------------------------------
1 | 0) {
54 | throw new \LogicException('The first argument of '.__METHOD__.' must be contains: "'.implode(', ', $lost).'"');
55 | }
56 |
57 | $scheduledDataflow = new self();
58 | $scheduledDataflow->id = $datas['id'] === null ? null : (int) $datas['id'];
59 |
60 | $scheduledDataflow->setLabel($datas['label']);
61 | $scheduledDataflow->setDataflowType($datas['dataflow_type']);
62 | $scheduledDataflow->setOptions($datas['options']);
63 | $scheduledDataflow->setFrequency($datas['frequency']);
64 | $scheduledDataflow->setNext($datas['next']);
65 | $scheduledDataflow->setEnabled($datas['enabled'] === null ? null : (bool) $datas['enabled']);
66 |
67 | return $scheduledDataflow;
68 | }
69 |
70 | public function toArray(): array
71 | {
72 | return [
73 | 'id' => $this->getId(),
74 | 'label' => $this->getLabel(),
75 | 'dataflow_type' => $this->getDataflowType(),
76 | 'options' => $this->getOptions(),
77 | 'frequency' => $this->getFrequency(),
78 | 'next' => $this->getNext(),
79 | 'enabled' => $this->getEnabled(),
80 | ];
81 | }
82 |
83 | public function setId(int $id): self
84 | {
85 | $this->id = $id;
86 |
87 | return $this;
88 | }
89 |
90 | public function getId(): ?int
91 | {
92 | return $this->id;
93 | }
94 |
95 | public function getLabel(): ?string
96 | {
97 | return $this->label;
98 | }
99 |
100 | public function setLabel(?string $label): self
101 | {
102 | $this->label = $label;
103 |
104 | return $this;
105 | }
106 |
107 | public function getDataflowType(): ?string
108 | {
109 | return $this->dataflowType;
110 | }
111 |
112 | public function setDataflowType(?string $dataflowType): self
113 | {
114 | $this->dataflowType = $dataflowType;
115 |
116 | return $this;
117 | }
118 |
119 | public function getOptions(): ?array
120 | {
121 | return $this->options;
122 | }
123 |
124 | public function setOptions(?array $options): self
125 | {
126 | $this->options = $options;
127 |
128 | return $this;
129 | }
130 |
131 | public function getFrequency(): ?string
132 | {
133 | return $this->frequency;
134 | }
135 |
136 | public function setFrequency(?string $frequency): self
137 | {
138 | $this->frequency = $frequency;
139 |
140 | return $this;
141 | }
142 |
143 | public function getNext(): ?\DateTimeInterface
144 | {
145 | return $this->next;
146 | }
147 |
148 | public function setNext(?\DateTimeInterface $next): self
149 | {
150 | $this->next = $next;
151 |
152 | return $this;
153 | }
154 |
155 | public function getEnabled(): ?bool
156 | {
157 | return $this->enabled;
158 | }
159 |
160 | public function setEnabled(?bool $enabled): self
161 | {
162 | $this->enabled = $enabled;
163 |
164 | return $this;
165 | }
166 | }
167 |
--------------------------------------------------------------------------------
/Tests/Manager/ScheduledDataflowManagerTest.php:
--------------------------------------------------------------------------------
1 | connection = $this->createMock(Connection::class);
24 | $this->scheduledDataflowRepository = $this->createMock(ScheduledDataflowRepository::class);
25 | $this->jobRepository = $this->createMock(JobRepository::class);
26 |
27 | $this->manager = new ScheduledDataflowManager($this->connection, $this->scheduledDataflowRepository, $this->jobRepository);
28 | }
29 |
30 | public function testCreateJobsFromScheduledDataflows()
31 | {
32 | $scheduled1 = new ScheduledDataflow();
33 | $scheduled2 = (new ScheduledDataflow())
34 | ->setId(-1)
35 | ->setDataflowType($type = 'testType')
36 | ->setOptions($options = ['opt' => 'val'])
37 | ->setNext($next = new \DateTime())
38 | ->setLabel($label = 'testLabel')
39 | ->setFrequency($frequency = '1 year')
40 | ;
41 |
42 | $this->scheduledDataflowRepository
43 | ->expects($this->once())
44 | ->method('findReadyToRun')
45 | ->willReturn([$scheduled1, $scheduled2])
46 | ;
47 |
48 | $matcher = $this->exactly(2);
49 | $this->jobRepository
50 | ->expects($matcher)
51 | ->method('findPendingForScheduledDataflow')
52 | ->with($this->callback(fn($arg) => match ($matcher->numberOfInvocations()) {
53 | 1 => $arg === $scheduled1,
54 | 2 => $arg === $scheduled2,
55 | default => false,
56 | }))
57 | ->willReturnOnConsecutiveCalls(new Job(), null)
58 | ;
59 |
60 | $this->connection
61 | ->expects($this->once())
62 | ->method('beginTransaction')
63 | ;
64 | $this->jobRepository
65 | ->expects($this->once())
66 | ->method('save')
67 | ->with(
68 | $this->callback(fn(Job $job) => $job->getStatus() === Job::STATUS_PENDING
69 | && $job->getDataflowType() === $type
70 | && $job->getOptions() === $options
71 | && $job->getRequestedDate() == $next
72 | && $job->getLabel() === $label
73 | && $job->getScheduledDataflowId() === $scheduled2->getId())
74 | )
75 | ;
76 |
77 | $this->scheduledDataflowRepository
78 | ->expects($this->once())
79 | ->method('save')
80 | ->with($scheduled2)
81 | ;
82 |
83 | $this->connection
84 | ->expects($this->once())
85 | ->method('commit')
86 | ;
87 |
88 | $this->manager->createJobsFromScheduledDataflows();
89 |
90 | $this->assertEquals($next->add(\DateInterval::createFromDateString($frequency)), $scheduled2->getNext());
91 | }
92 |
93 | public function testCreateJobsFromScheduledDataflowsWithError()
94 | {
95 | $scheduled1 = new ScheduledDataflow();
96 |
97 | $this->scheduledDataflowRepository
98 | ->expects($this->once())
99 | ->method('findReadyToRun')
100 | ->willReturn([$scheduled1])
101 | ;
102 |
103 | $this->jobRepository
104 | ->expects($this->exactly(1))
105 | ->method('findPendingForScheduledDataflow')
106 | ->with($scheduled1)
107 | ->willThrowException(new \Exception())
108 | ;
109 |
110 | $this->connection
111 | ->expects($this->once())
112 | ->method('beginTransaction')
113 | ;
114 | $this->jobRepository
115 | ->expects($this->never())
116 | ->method('save')
117 | ;
118 |
119 | $this->connection
120 | ->expects($this->never())
121 | ->method('commit')
122 | ;
123 | $this->connection
124 | ->expects($this->once())
125 | ->method('rollBack')
126 | ;
127 |
128 | $this->expectException(\Exception::class);
129 |
130 | $this->manager->createJobsFromScheduledDataflows();
131 | }
132 | }
133 |
--------------------------------------------------------------------------------
/src/Repository/ScheduledDataflowRepository.php:
--------------------------------------------------------------------------------
1 | createQueryBuilder();
38 | $qb->andWhere($qb->expr()->lte('next', $qb->createNamedParameter(new \DateTime(), 'datetime')))
39 | ->andWhere($qb->expr()->eq('enabled', $qb->createNamedParameter(1, ParameterType::INTEGER)))
40 | ->orderBy('next', 'ASC')
41 | ;
42 |
43 | $stmt = $qb->executeQuery();
44 | if ($stmt->rowCount() === 0) {
45 | return [];
46 | }
47 | while (false !== ($row = $stmt->fetchAssociative())) {
48 | yield ScheduledDataflow::createFromArray($this->initDateTime($this->initArray($row)));
49 | }
50 | }
51 |
52 | public function find(int $scheduleId): ?ScheduledDataflow
53 | {
54 | $qb = $this->createQueryBuilder();
55 | $qb->andWhere($qb->expr()->eq('id', $qb->createNamedParameter($scheduleId, ParameterType::INTEGER)))
56 | ->setMaxResults(1)
57 | ;
58 |
59 | return $this->returnFirstOrNull($qb);
60 | }
61 |
62 | public function findAllOrderedByLabel(): iterable
63 | {
64 | $qb = $this->createQueryBuilder();
65 | $qb->orderBy('label', 'ASC');
66 |
67 | $stmt = $qb->executeQuery();
68 | if ($stmt->rowCount() === 0) {
69 | return [];
70 | }
71 | while (false !== ($row = $stmt->fetchAssociative())) {
72 | yield ScheduledDataflow::createFromArray($this->initDateTime($this->initArray($row)));
73 | }
74 | }
75 |
76 | public function listAllOrderedByLabel(): array
77 | {
78 | $query = $this->connection->createQueryBuilder()
79 | ->from(static::TABLE_NAME, 'w')
80 | ->select('w.id', 'w.label', 'w.enabled', 'w.next', 'max(j.start_time) as startTime')
81 | ->leftJoin('w', JobRepository::TABLE_NAME, 'j', 'j.scheduled_dataflow_id = w.id')
82 | ->orderBy('w.label', 'ASC')
83 | ->groupBy('w.id');
84 |
85 | return $query->executeQuery()->fetchAllAssociative();
86 | }
87 |
88 | public function save(ScheduledDataflow $scheduledDataflow)
89 | {
90 | $datas = $scheduledDataflow->toArray();
91 | unset($datas['id']);
92 |
93 | if (\is_array($datas['options'])) {
94 | $datas['options'] = json_encode($datas['options'], \JSON_THROW_ON_ERROR);
95 | }
96 |
97 | if ($scheduledDataflow->getId() === null) {
98 | $this->connection->insert(static::TABLE_NAME, $datas, $this->getFields());
99 | $scheduledDataflow->setId((int) $this->connection->lastInsertId());
100 |
101 | return;
102 | }
103 | $this->connection->update(static::TABLE_NAME, $datas, ['id' => $scheduledDataflow->getId()], $this->getFields());
104 | }
105 |
106 | public function delete(int $id): void
107 | {
108 | $this->connection->beginTransaction();
109 | try {
110 | $this->connection->delete(JobRepository::TABLE_NAME, ['scheduled_dataflow_id' => $id]);
111 | $this->connection->delete(static::TABLE_NAME, ['id' => $id]);
112 | } catch (\Throwable $e) {
113 | $this->connection->rollBack();
114 | throw $e;
115 | }
116 |
117 | $this->connection->commit();
118 | }
119 |
120 | public function createQueryBuilder($alias = null): QueryBuilder
121 | {
122 | $qb = $this->connection->createQueryBuilder();
123 | $qb->select('*')
124 | ->from(static::TABLE_NAME, $alias);
125 |
126 | return $qb;
127 | }
128 |
129 | private function returnFirstOrNull(QueryBuilder $qb): ?ScheduledDataflow
130 | {
131 | $stmt = $qb->executeQuery();
132 | if ($stmt->rowCount() === 0) {
133 | return null;
134 | }
135 |
136 | return ScheduledDataflow::createFromArray($this->initDateTime($this->initArray($stmt->fetchAssociative())));
137 | }
138 |
139 | private function getFields(): array
140 | {
141 | return [
142 | 'id' => ParameterType::INTEGER,
143 | 'label' => ParameterType::STRING,
144 | 'dataflow_type' => ParameterType::STRING,
145 | 'options' => ParameterType::STRING,
146 | 'frequency' => ParameterType::STRING,
147 | 'next' => 'datetime',
148 | 'enabled' => ParameterType::BOOLEAN,
149 | ];
150 | }
151 | }
152 |
--------------------------------------------------------------------------------
/Tests/DataflowType/Writer/DelegatorWriterTest.php:
--------------------------------------------------------------------------------
1 | delegateInt = $this->createMock(DelegateWriterInterface::class);
21 | $this->delegateInt->method('supports')->willReturnCallback(fn($argument) => is_int($argument));
22 |
23 | $this->delegateString = $this->createMock(DelegateWriterInterface::class);
24 | $this->delegateString->method('supports')->willReturnCallback(fn($argument) => is_string($argument));
25 |
26 | $this->delegateArray = $this->createMock(DelegateWriterInterface::class);
27 | $this->delegateArray->method('supports')->willReturnCallback(fn($argument) => is_array($argument));
28 |
29 | $this->delegatorWriter = new DelegatorWriter();
30 | $this->delegatorWriter->addDelegates([
31 | $this->delegateInt,
32 | $this->delegateString,
33 | $this->delegateArray,
34 | ]);
35 | }
36 |
37 | public function testUnsupported()
38 | {
39 | $this->expectException(UnsupportedItemTypeException::class);
40 |
41 | $this->delegatorWriter->write(new \stdClass());
42 | }
43 |
44 | public function testStopAtFirstSupportingDelegate()
45 | {
46 | $value = 0;
47 |
48 | $this->delegateInt->expects($this->once())->method('supports');
49 | $this->delegateInt
50 | ->expects($this->once())
51 | ->method('write')
52 | ->with($value)
53 | ;
54 | $this->delegateString->expects($this->never())->method('supports');
55 | $this->delegateArray->expects($this->never())->method('supports');
56 | $this->delegateString->expects($this->never())->method('write');
57 | $this->delegateArray->expects($this->never())->method('write');
58 |
59 | $this->delegatorWriter->write($value);
60 | }
61 |
62 | public function testNotSupported()
63 | {
64 | $value = new \stdClass();
65 |
66 | $this->delegateInt
67 | ->expects($this->once())
68 | ->method('supports')
69 | ->with($value)
70 | ;
71 | $this->delegateString
72 | ->expects($this->once())
73 | ->method('supports')
74 | ->with($value)
75 | ;
76 | $this->delegateArray
77 | ->expects($this->once())
78 | ->method('supports')
79 | ->with($value)
80 | ;
81 |
82 | $this->assertFalse($this->delegatorWriter->supports($value));
83 | }
84 |
85 | public function testSupported()
86 | {
87 | $value = '';
88 |
89 | $this->delegateInt
90 | ->expects($this->once())
91 | ->method('supports')
92 | ->with($value)
93 | ;
94 | $this->delegateString
95 | ->expects($this->once())
96 | ->method('supports')
97 | ->with($value)
98 | ;
99 | $this->delegateArray
100 | ->expects($this->never())
101 | ->method('supports')
102 | ;
103 |
104 | $this->assertTrue($this->delegatorWriter->supports($value));
105 | }
106 |
107 | public function testAll()
108 | {
109 | $value = ['a'];
110 |
111 | $this->delegateInt
112 | ->expects($this->once())
113 | ->method('supports')
114 | ->with($value)
115 | ;
116 | $this->delegateString
117 | ->expects($this->once())
118 | ->method('supports')
119 | ->with($value)
120 | ;
121 | $this->delegateArray
122 | ->expects($this->once())
123 | ->method('supports')
124 | ->with($value)
125 | ;
126 |
127 | $this->delegateInt->expects($this->once())->method('prepare');
128 | $this->delegateString->expects($this->once())->method('prepare');
129 | $this->delegateArray->expects($this->once())->method('prepare');
130 |
131 | $this->delegateInt->expects($this->once())->method('finish');
132 | $this->delegateString->expects($this->once())->method('finish');
133 | $this->delegateArray->expects($this->once())->method('finish');
134 |
135 | $this->delegateInt->expects($this->never())->method('write');
136 | $this->delegateString->expects($this->never())->method('write');
137 | $this->delegateArray
138 | ->expects($this->once())
139 | ->method('write')
140 | ->with($value)
141 | ;
142 |
143 | $this->delegatorWriter->prepare();
144 | $this->delegatorWriter->write($value);
145 | $this->delegatorWriter->finish();
146 | }
147 | }
148 |
--------------------------------------------------------------------------------
/src/Command/AddScheduledDataflowCommand.php:
--------------------------------------------------------------------------------
1 | setHelp('The %command.name% allows you to create a new scheduled dataflow.')
34 | ->addOption('label', null, InputOption::VALUE_REQUIRED, 'Label of the scheduled dataflow')
35 | ->addOption('type', null, InputOption::VALUE_REQUIRED, 'Type of the scheduled dataflow (FQCN)')
36 | ->addOption(
37 | 'options',
38 | null,
39 | InputOption::VALUE_OPTIONAL,
40 | 'Options of the scheduled dataflow (ex: {"option1": "value1", "option2": "value2"})'
41 | )
42 | ->addOption('frequency', null, InputOption::VALUE_REQUIRED, 'Frequency of the scheduled dataflow')
43 | ->addOption('first_run', null, InputOption::VALUE_REQUIRED, 'Date for the first run of the scheduled dataflow (Y-m-d H:i:s)')
44 | ->addOption('enabled', null, InputOption::VALUE_REQUIRED, 'State of the scheduled dataflow')
45 | ->addOption('connection', null, InputOption::VALUE_REQUIRED, 'Define the DBAL connection to use');
46 | }
47 |
48 | protected function execute(InputInterface $input, OutputInterface $output): int
49 | {
50 | if ($input->getOption('connection') !== null) {
51 | $this->connectionFactory->setConnectionName($input->getOption('connection'));
52 | }
53 | $choices = [];
54 | $typeMapping = [];
55 | foreach ($this->registry->listDataflowTypes() as $fqcn => $dataflowType) {
56 | $choices[] = $dataflowType->getLabel();
57 | $typeMapping[$dataflowType->getLabel()] = $fqcn;
58 | }
59 |
60 | $io = new SymfonyStyle($input, $output);
61 | $label = $input->getOption('label');
62 | if (!$label) {
63 | $label = $io->ask('What is the scheduled dataflow label?');
64 | }
65 | $type = $input->getOption('type');
66 | if (!$type) {
67 | $selectedType = $io->choice('What is the scheduled dataflow type?', $choices);
68 | $type = $typeMapping[$selectedType];
69 | }
70 | $options = $input->getOption('options');
71 | if (!$options) {
72 | $options = $io->ask(
73 | 'What are the launch options for the scheduled dataflow? (ex: {"option1": "value1", "option2": "value2"})',
74 | json_encode([])
75 | );
76 | }
77 | $frequency = $input->getOption('frequency');
78 | if (!$frequency) {
79 | $frequency = $io->choice(
80 | 'What is the frequency for the scheduled dataflow?',
81 | ScheduledDataflow::AVAILABLE_FREQUENCIES
82 | );
83 | }
84 | $firstRun = $input->getOption('first_run');
85 | if (!$firstRun) {
86 | $firstRun = $io->ask('When is the first execution of the scheduled dataflow (format: Y-m-d H:i:s)?');
87 | }
88 | $enabled = $input->getOption('enabled');
89 | if (!$enabled) {
90 | $enabled = $io->confirm('Enable the scheduled dataflow?');
91 | }
92 |
93 | $newScheduledDataflow = ScheduledDataflow::createFromArray([
94 | 'id' => null,
95 | 'label' => $label,
96 | 'dataflow_type' => $type,
97 | 'options' => json_decode($options, true, 512, \JSON_THROW_ON_ERROR),
98 | 'frequency' => $frequency,
99 | 'next' => new \DateTime($firstRun),
100 | 'enabled' => $enabled,
101 | ]);
102 |
103 | $errors = $this->validator->validate($newScheduledDataflow);
104 | if (\count($errors) > 0) {
105 | $io->error((string) $errors);
106 |
107 | return 2;
108 | }
109 |
110 | $this->scheduledDataflowRepository->save($newScheduledDataflow);
111 | $io->success(\sprintf(
112 | 'New scheduled dataflow "%s" (id:%d) was created successfully.',
113 | $newScheduledDataflow->getLabel(),
114 | $newScheduledDataflow->getId()
115 | ));
116 |
117 | return 0;
118 | }
119 | }
120 |
--------------------------------------------------------------------------------
/src/Resources/config/services.yaml:
--------------------------------------------------------------------------------
1 | services:
2 | _defaults:
3 | public: false
4 |
5 | CodeRhapsodie\DataflowBundle\Registry\DataflowTypeRegistryInterface: '@CodeRhapsodie\DataflowBundle\Registry\DataflowTypeRegistry'
6 | CodeRhapsodie\DataflowBundle\Registry\DataflowTypeRegistry:
7 |
8 | CodeRhapsodie\DataflowBundle\Command\AddScheduledDataflowCommand:
9 | arguments:
10 | $registry: '@CodeRhapsodie\DataflowBundle\Registry\DataflowTypeRegistryInterface'
11 | $scheduledDataflowRepository: '@CodeRhapsodie\DataflowBundle\Repository\ScheduledDataflowRepository'
12 | $validator: '@validator'
13 | $connectionFactory: '@CodeRhapsodie\DataflowBundle\Factory\ConnectionFactory'
14 | tags: ['console.command']
15 |
16 | CodeRhapsodie\DataflowBundle\Command\ChangeScheduleStatusCommand:
17 | arguments:
18 | $scheduledDataflowRepository: '@CodeRhapsodie\DataflowBundle\Repository\ScheduledDataflowRepository'
19 | $connectionFactory: '@CodeRhapsodie\DataflowBundle\Factory\ConnectionFactory'
20 | tags: ['console.command']
21 |
22 | CodeRhapsodie\DataflowBundle\Command\ExecuteDataflowCommand:
23 | arguments:
24 | $registry: '@CodeRhapsodie\DataflowBundle\Registry\DataflowTypeRegistryInterface'
25 | $connectionFactory: '@CodeRhapsodie\DataflowBundle\Factory\ConnectionFactory'
26 | $jobRepository: '@CodeRhapsodie\DataflowBundle\Repository\JobRepository'
27 | tags: ['console.command']
28 |
29 | CodeRhapsodie\DataflowBundle\Command\JobShowCommand:
30 | arguments:
31 | $jobGateway: '@CodeRhapsodie\DataflowBundle\Gateway\JobGateway'
32 | $connectionFactory: '@CodeRhapsodie\DataflowBundle\Factory\ConnectionFactory'
33 | tags: ['console.command']
34 |
35 | CodeRhapsodie\DataflowBundle\Command\RunPendingDataflowsCommand:
36 | arguments:
37 | $manager: '@CodeRhapsodie\DataflowBundle\Manager\ScheduledDataflowManagerInterface'
38 | $runner: '@CodeRhapsodie\DataflowBundle\Runner\PendingDataflowRunnerInterface'
39 | $connectionFactory: '@CodeRhapsodie\DataflowBundle\Factory\ConnectionFactory'
40 | tags: ['console.command']
41 |
42 | CodeRhapsodie\DataflowBundle\Command\ScheduleListCommand:
43 | arguments:
44 | $scheduledDataflowRepository: '@CodeRhapsodie\DataflowBundle\Repository\ScheduledDataflowRepository'
45 | $connectionFactory: '@CodeRhapsodie\DataflowBundle\Factory\ConnectionFactory'
46 | tags: ['console.command']
47 |
48 | CodeRhapsodie\DataflowBundle\Command\SchemaCommand:
49 | deprecated:
50 | package: 'code-rhapsodie/dataflow-bundle'
51 | version: '5.0'
52 | tags: ['console.command']
53 |
54 | CodeRhapsodie\DataflowBundle\Command\DatabaseSchemaCommand:
55 | arguments:
56 | $connectionFactory: '@CodeRhapsodie\DataflowBundle\Factory\ConnectionFactory'
57 | tags: [ 'console.command' ]
58 |
59 | CodeRhapsodie\DataflowBundle\Repository\ScheduledDataflowRepository:
60 | lazy: true
61 | arguments: ['@coderhapsodie.dataflow.connection']
62 |
63 | CodeRhapsodie\DataflowBundle\Repository\JobRepository:
64 | lazy: true
65 | arguments: ['@coderhapsodie.dataflow.connection']
66 |
67 | coderhapsodie.dataflow.connection: "@coderhapsodie.dataflow.connection.internal"
68 |
69 | coderhapsodie.dataflow.connection.internal:
70 | lazy: true
71 | class: Doctrine\DBAL\Connection
72 | factory: ['@CodeRhapsodie\DataflowBundle\Factory\ConnectionFactory', 'getConnection']
73 |
74 | CodeRhapsodie\DataflowBundle\Factory\ConnectionFactory:
75 | arguments: ['@service_container', '%coderhapsodie.dataflow.dbal_default_connection%']
76 |
77 | CodeRhapsodie\DataflowBundle\Manager\ScheduledDataflowManagerInterface: '@CodeRhapsodie\DataflowBundle\Manager\ScheduledDataflowManager'
78 | CodeRhapsodie\DataflowBundle\Manager\ScheduledDataflowManager:
79 | arguments:
80 | $connection: '@coderhapsodie.dataflow.connection'
81 | $scheduledDataflowRepository: '@CodeRhapsodie\DataflowBundle\Repository\ScheduledDataflowRepository'
82 | $jobRepository: '@CodeRhapsodie\DataflowBundle\Repository\JobRepository'
83 |
84 | CodeRhapsodie\DataflowBundle\Runner\PendingDataflowRunnerInterface: '@CodeRhapsodie\DataflowBundle\Runner\PendingDataflowRunner'
85 | CodeRhapsodie\DataflowBundle\Runner\PendingDataflowRunner:
86 | arguments:
87 | $repository: '@CodeRhapsodie\DataflowBundle\Repository\JobRepository'
88 | $processor: '@CodeRhapsodie\DataflowBundle\Processor\JobProcessorInterface'
89 |
90 | CodeRhapsodie\DataflowBundle\Processor\JobProcessorInterface: '@CodeRhapsodie\DataflowBundle\Processor\JobProcessor'
91 | CodeRhapsodie\DataflowBundle\Processor\JobProcessor:
92 | arguments:
93 | $repository: '@CodeRhapsodie\DataflowBundle\Repository\JobRepository'
94 | $registry: '@CodeRhapsodie\DataflowBundle\Registry\DataflowTypeRegistryInterface'
95 | $dispatcher: '@event_dispatcher'
96 | $jobGateway: '@CodeRhapsodie\DataflowBundle\Gateway\JobGateway'
97 | CodeRhapsodie\DataflowBundle\ExceptionsHandler\ExceptionHandlerInterface: '@CodeRhapsodie\DataflowBundle\ExceptionsHandler\NullExceptionHandler'
98 | CodeRhapsodie\DataflowBundle\ExceptionsHandler\NullExceptionHandler:
99 | CodeRhapsodie\DataflowBundle\Gateway\JobGateway:
100 | arguments:
101 | $repository: '@CodeRhapsodie\DataflowBundle\Repository\JobRepository'
102 | $exceptionHandler: '@CodeRhapsodie\DataflowBundle\ExceptionsHandler\ExceptionHandlerInterface'
103 |
--------------------------------------------------------------------------------
/src/DataflowType/Dataflow/AMPAsyncDataflow.php:
--------------------------------------------------------------------------------
1 | steps[] = [$step, $scale];
51 |
52 | return $this;
53 | }
54 |
55 | /**
56 | * @return $this
57 | */
58 | public function addWriter(WriterInterface $writer): self
59 | {
60 | $this->writers[] = $writer;
61 |
62 | return $this;
63 | }
64 |
65 | public function process(): Result
66 | {
67 | $count = 0;
68 | $exceptions = [];
69 | $startTime = new \DateTime();
70 |
71 | try {
72 | foreach ($this->writers as $writer) {
73 | $writer->prepare();
74 | }
75 |
76 | $deferred = new Deferred();
77 | $resolved = false; // missing $deferred->isResolved() in version 2.5
78 | $producer = new Producer(function (callable $emit) {
79 | foreach ($this->reader as $index => $item) {
80 | yield new Delayed($this->emitInterval);
81 | yield $emit([$index, $item]);
82 | }
83 | });
84 |
85 | $watcherId = Loop::repeat($this->loopInterval, function () use ($deferred, &$resolved, $producer, &$count, &$exceptions) {
86 | if (yield $producer->advance()) {
87 | $it = $producer->getCurrent();
88 | [$index, $item] = $it;
89 | $this->states[$index] = [$index, 0, $item];
90 | } elseif (!$resolved && \count($this->states) === 0) {
91 | $resolved = true;
92 | $deferred->resolve();
93 | }
94 |
95 | foreach ($this->states as $state) {
96 | $this->processState($state, $count, $exceptions);
97 | }
98 | });
99 |
100 | wait($deferred->promise());
101 | Loop::cancel($watcherId);
102 |
103 | foreach ($this->writers as $writer) {
104 | $writer->finish();
105 | }
106 | } catch (\Throwable $e) {
107 | $exceptions[] = $e;
108 | $this->logException($e);
109 | }
110 |
111 | return new Result($this->name, $startTime, new \DateTime(), $count, $exceptions);
112 | }
113 |
114 | /**
115 | * @param int $count internal count reference
116 | * @param array $exceptions internal exceptions
117 | */
118 | private function processState(mixed $state, int &$count, array &$exceptions): void
119 | {
120 | [$readIndex, $stepIndex, $item] = $state;
121 | if ($stepIndex < \count($this->steps)) {
122 | if (!isset($this->stepsJobs[$stepIndex])) {
123 | $this->stepsJobs[$stepIndex] = [];
124 | }
125 | [$step, $scale] = $this->steps[$stepIndex];
126 | if ((is_countable($this->stepsJobs[$stepIndex]) ? \count($this->stepsJobs[$stepIndex]) : 0) < $scale && !isset($this->stepsJobs[$stepIndex][$readIndex])) {
127 | $this->stepsJobs[$stepIndex][$readIndex] = true;
128 | /** @var Promise $promise */
129 | $promise = coroutine($step)($item);
130 | $promise->onResolve(function (?\Throwable $exception = null, $newItem = null) use ($stepIndex, $readIndex, &$exceptions) {
131 | if ($exception) {
132 | $exceptions[$stepIndex] = $exception;
133 | $this->logException($exception, (string) $stepIndex);
134 | } elseif ($newItem === false) {
135 | unset($this->states[$readIndex]);
136 | } else {
137 | $this->states[$readIndex] = [$readIndex, $stepIndex + 1, $newItem];
138 | }
139 |
140 | unset($this->stepsJobs[$stepIndex][$readIndex]);
141 | });
142 | }
143 | } else {
144 | unset($this->states[$readIndex]);
145 |
146 | foreach ($this->writers as $writer) {
147 | $writer->write($item);
148 | }
149 |
150 | ++$count;
151 | }
152 | }
153 |
154 | private function logException(\Throwable $e, ?string $index = null): void
155 | {
156 | if (!isset($this->logger)) {
157 | return;
158 | }
159 |
160 | $this->logger->error($e, ['exception' => $e, 'index' => $index]);
161 | }
162 | }
163 |
--------------------------------------------------------------------------------
/src/Repository/JobRepository.php:
--------------------------------------------------------------------------------
1 | createQueryBuilder();
34 | $qb
35 | ->andWhere($qb->expr()->eq('id', $qb->createNamedParameter($jobId, ParameterType::INTEGER)))
36 | ;
37 |
38 | return $this->returnFirstOrNull($qb);
39 | }
40 |
41 | public function findOneshotDataflows(): iterable
42 | {
43 | $qb = $this->createQueryBuilder();
44 | $qb
45 | ->andWhere($qb->expr()->isNull('scheduled_dataflow_id'))
46 | ->andWhere($qb->expr()->eq('status', $qb->createNamedParameter(Job::STATUS_PENDING, ParameterType::INTEGER)));
47 | $stmt = $qb->executeQuery();
48 | if ($stmt->rowCount() === 0) {
49 | return [];
50 | }
51 | while (false !== ($row = $stmt->fetchAssociative())) {
52 | yield Job::createFromArray($this->initDateTime($this->initArray($row)));
53 | }
54 | }
55 |
56 | public function findPendingForScheduledDataflow(ScheduledDataflow $scheduled): ?Job
57 | {
58 | $qb = $this->createQueryBuilder();
59 | $qb
60 | ->andWhere($qb->expr()->eq('scheduled_dataflow_id', $qb->createNamedParameter($scheduled->getId(), ParameterType::INTEGER)))
61 | ->andWhere($qb->expr()->eq('status', $qb->createNamedParameter(Job::STATUS_PENDING, ParameterType::INTEGER)));
62 |
63 | return $this->returnFirstOrNull($qb);
64 | }
65 |
66 | public function findNextPendingDataflow(): ?Job
67 | {
68 | $qb = $this->createQueryBuilder();
69 | $qb->andWhere($qb->expr()->lte('requested_date', $qb->createNamedParameter(new \DateTime(), 'datetime')))
70 | ->andWhere($qb->expr()->eq('status', $qb->createNamedParameter(Job::STATUS_PENDING, ParameterType::INTEGER)))
71 | ->orderBy('requested_date', 'ASC')
72 | ->setMaxResults(1)
73 | ;
74 |
75 | return $this->returnFirstOrNull($qb);
76 | }
77 |
78 | public function findLastForDataflowId(int $dataflowId): ?Job
79 | {
80 | $qb = $this->createQueryBuilder();
81 | $qb->andWhere($qb->expr()->eq('scheduled_dataflow_id', $qb->createNamedParameter($dataflowId, ParameterType::INTEGER)))
82 | ->orderBy('requested_date', 'DESC')
83 | ->setMaxResults(1)
84 | ;
85 |
86 | return $this->returnFirstOrNull($qb);
87 | }
88 |
89 | public function findLatests(): iterable
90 | {
91 | $qb = $this->createQueryBuilder();
92 | $qb
93 | ->orderBy('requested_date', 'DESC')
94 | ->setMaxResults(20);
95 | $stmt = $qb->executeQuery();
96 | if ($stmt->rowCount() === 0) {
97 | return [];
98 | }
99 | while (false !== ($row = $stmt->fetchAssociative())) {
100 | yield Job::createFromArray($row);
101 | }
102 | }
103 |
104 | public function findForScheduled(int $id): iterable
105 | {
106 | $qb = $this->createQueryBuilder();
107 | $qb->andWhere($qb->expr()->eq('scheduled_dataflow_id', $qb->createNamedParameter($id, ParameterType::INTEGER)))
108 | ->orderBy('requested_date', 'DESC')
109 | ->setMaxResults(20);
110 | $stmt = $qb->executeQuery();
111 | if ($stmt->rowCount() === 0) {
112 | return [];
113 | }
114 | while (false !== ($row = $stmt->fetchAssociative())) {
115 | yield Job::createFromArray($row);
116 | }
117 | }
118 |
119 | public function save(Job $job)
120 | {
121 | $datas = $job->toArray();
122 | unset($datas['id']);
123 |
124 | if (\is_array($datas['options'])) {
125 | $datas['options'] = json_encode($datas['options'], \JSON_THROW_ON_ERROR);
126 | }
127 | if (\is_array($datas['exceptions'])) {
128 | $datas['exceptions'] = json_encode($datas['exceptions'], \JSON_THROW_ON_ERROR);
129 | }
130 |
131 | if ($job->getId() === null) {
132 | $this->connection->insert(static::TABLE_NAME, $datas, $this->getFields());
133 | $job->setId((int) $this->connection->lastInsertId());
134 |
135 | return;
136 | }
137 | $this->connection->update(static::TABLE_NAME, $datas, ['id' => $job->getId()], $this->getFields());
138 | }
139 |
140 | public function updateCount(int $jobId, int $count): void
141 | {
142 | $this->connection->update(static::TABLE_NAME, ['count' => $count], ['id' => $jobId]);
143 | }
144 |
145 | public function createQueryBuilder($alias = null): QueryBuilder
146 | {
147 | $qb = $this->connection->createQueryBuilder();
148 | $qb->select('*')
149 | ->from(static::TABLE_NAME, $alias);
150 |
151 | return $qb;
152 | }
153 |
154 | private function returnFirstOrNull(QueryBuilder $qb): ?Job
155 | {
156 | $stmt = $qb->executeQuery();
157 | if ($stmt->rowCount() === 0) {
158 | return null;
159 | }
160 |
161 | return Job::createFromArray($this->initDateTime($this->initArray($stmt->fetchAssociative())));
162 | }
163 |
164 | private function getFields(): array
165 | {
166 | return [
167 | 'id' => ParameterType::INTEGER,
168 | 'status' => ParameterType::INTEGER,
169 | 'label' => ParameterType::STRING,
170 | 'dataflow_type' => ParameterType::STRING,
171 | 'options' => ParameterType::STRING,
172 | 'requested_date' => 'datetime',
173 | 'scheduled_dataflow_id' => ParameterType::INTEGER,
174 | 'count' => ParameterType::INTEGER,
175 | 'exceptions' => ParameterType::STRING,
176 | 'start_time' => 'datetime',
177 | 'end_time' => 'datetime',
178 | ];
179 | }
180 | }
181 |
--------------------------------------------------------------------------------
/src/Entity/Job.php:
--------------------------------------------------------------------------------
1 | setStatus(static::STATUS_PENDING)
68 | ->setDataflowType($scheduled->getDataflowType())
69 | ->setOptions($scheduled->getOptions())
70 | ->setRequestedDate(clone $scheduled->getNext())
71 | ->setLabel($scheduled->getLabel())
72 | ->setScheduledDataflowId($scheduled->getId());
73 | }
74 |
75 | public static function createFromArray(array $datas)
76 | {
77 | $lost = array_diff(static::KEYS, array_keys($datas));
78 | if (\count($lost) > 0) {
79 | throw new \LogicException('The first argument of '.__METHOD__.' must be contains: "'.implode(', ', $lost).'"');
80 | }
81 |
82 | $job = new self();
83 | $job->id = $datas['id'] === null ? null : (int) $datas['id'];
84 | $job->setStatus($datas['status'] === null ? null : (int) $datas['status']);
85 | $job->setLabel($datas['label']);
86 | $job->setDataflowType($datas['dataflow_type']);
87 | $job->setOptions($datas['options']);
88 | $job->setRequestedDate($datas['requested_date']);
89 | $job->setScheduledDataflowId($datas['scheduled_dataflow_id'] === null ? null : (int) $datas['scheduled_dataflow_id']);
90 | $job->setCount($datas['count'] === null ? null : (int) $datas['count']);
91 | $job->setExceptions($datas['exceptions']);
92 | $job->setStartTime($datas['start_time']);
93 | $job->setEndTime($datas['end_time']);
94 |
95 | return $job;
96 | }
97 |
98 | public function toArray(): array
99 | {
100 | return [
101 | 'id' => $this->getId(),
102 | 'status' => $this->getStatus(),
103 | 'label' => $this->getLabel(),
104 | 'dataflow_type' => $this->getDataflowType(),
105 | 'options' => $this->getOptions(),
106 | 'requested_date' => $this->getRequestedDate(),
107 | 'scheduled_dataflow_id' => $this->getScheduledDataflowId(),
108 | 'count' => $this->getCount(),
109 | 'exceptions' => $this->getExceptions(),
110 | 'start_time' => $this->getStartTime(),
111 | 'end_time' => $this->getEndTime(),
112 | ];
113 | }
114 |
115 | public function setId(int $id): self
116 | {
117 | $this->id = $id;
118 |
119 | return $this;
120 | }
121 |
122 | public function getId(): ?int
123 | {
124 | return $this->id;
125 | }
126 |
127 | public function getStatus(): int
128 | {
129 | return $this->status;
130 | }
131 |
132 | public function setStatus(int $status): self
133 | {
134 | $this->status = $status;
135 |
136 | return $this;
137 | }
138 |
139 | public function getLabel(): ?string
140 | {
141 | return $this->label;
142 | }
143 |
144 | public function setLabel(?string $label): self
145 | {
146 | $this->label = $label;
147 |
148 | return $this;
149 | }
150 |
151 | public function getDataflowType(): ?string
152 | {
153 | return $this->dataflowType;
154 | }
155 |
156 | public function setDataflowType(?string $dataflowType): self
157 | {
158 | $this->dataflowType = $dataflowType;
159 |
160 | return $this;
161 | }
162 |
163 | public function getOptions(): ?array
164 | {
165 | return $this->options;
166 | }
167 |
168 | public function setOptions(?array $options): self
169 | {
170 | $this->options = $options;
171 |
172 | return $this;
173 | }
174 |
175 | public function getRequestedDate(): ?\DateTimeInterface
176 | {
177 | return $this->requestedDate;
178 | }
179 |
180 | public function setRequestedDate(?\DateTimeInterface $requestedDate): self
181 | {
182 | $this->requestedDate = $requestedDate;
183 |
184 | return $this;
185 | }
186 |
187 | public function getScheduledDataflowId(): ?int
188 | {
189 | return $this->scheduledDataflowId;
190 | }
191 |
192 | public function setScheduledDataflowId(?int $scheduledDataflowId): self
193 | {
194 | $this->scheduledDataflowId = $scheduledDataflowId;
195 |
196 | return $this;
197 | }
198 |
199 | public function getCount(): ?int
200 | {
201 | return $this->count;
202 | }
203 |
204 | public function setCount(?int $count): self
205 | {
206 | $this->count = $count;
207 |
208 | return $this;
209 | }
210 |
211 | public function getExceptions(): ?array
212 | {
213 | return $this->exceptions;
214 | }
215 |
216 | public function setExceptions(?array $exceptions): self
217 | {
218 | $this->exceptions = $exceptions;
219 |
220 | return $this;
221 | }
222 |
223 | public function getStartTime(): ?\DateTimeInterface
224 | {
225 | return $this->startTime;
226 | }
227 |
228 | public function setStartTime(?\DateTimeInterface $startTime): self
229 | {
230 | $this->startTime = $startTime;
231 |
232 | return $this;
233 | }
234 |
235 | public function getEndTime(): ?\DateTimeInterface
236 | {
237 | return $this->endTime;
238 | }
239 |
240 | public function setEndTime(?\DateTimeInterface $endTime): self
241 | {
242 | $this->endTime = $endTime;
243 |
244 | return $this;
245 | }
246 | }
247 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Code Rhapsodie Dataflow Bundle
2 |
3 | DataflowBundle is a bundle for Symfony 3.4+
4 | providing an easy way to create import / export dataflow.
5 |
6 | | Dataflow | Symfony | Support |
7 | |----------|--------------------------|---------|
8 | | 5.x | 7.x | yes |
9 | | 4.x | 3.4 \| 4.x \| 5.x \| 6.x | yes |
10 | | 3.x | 3.4 \| 4.x \| 5.x | no |
11 | | 2.x | 3.4 \| 4.x | no |
12 | | 1.x | 3.4 \| 4.x | no |
13 |
14 | Dataflow uses a linear generic workflow in three parts:
15 |
16 | * one reader
17 | * any number of steps that can be synchronous or asynchronous
18 | * one or more writers
19 |
20 | The reader can read data from anywhere and return data row by row. Each step processes the current row data.
21 | The steps are executed in the order in which they are added.
22 | And, one or more writers save the row anywhere you want.
23 |
24 | As the following schema shows, you can define more than one dataflow:
25 |
26 | 
27 |
28 | # Features
29 |
30 | * Define and configure a Dataflow
31 | * Run the Job scheduled
32 | * Run one Dataflow from the command line
33 | * Define the schedule for a Dataflow from the command line
34 | * Enable/Disable a scheduled Dataflow from the command line
35 | * Display the list of scheduled Dataflow from the command line
36 | * Display the result for the last Job for a Dataflow from the command line
37 | * Work with multiple Doctrine DBAL connections
38 |
39 | ## Installation
40 |
41 | Security notice: Symfony 4.x is not supported before 4.1.12, see https://github.com/advisories/GHSA-pgwj-prpq-jpc2
42 |
43 | ### Add the dependency
44 |
45 | To install this bundle, run this command :
46 |
47 | ```shell script
48 | $ composer require code-rhapsodie/dataflow-bundle
49 | ```
50 |
51 | #### Suggest
52 |
53 | You can use the generic readers, writers and steps from [PortPHP](https://github.com/portphp/portphp).
54 |
55 | For the writers, you must use the adapter `CodeRhapsodie\DataflowBundle\DataflowType\Writer\PortWriterAdapter` like
56 | this:
57 |
58 | ```php
59 | addWriter(new \CodeRhapsodie\DataflowBundle\DataflowType\Writer\PortWriterAdapter($streamWriter));
64 | // ...
65 | ```
66 |
67 | ### Register the bundle
68 |
69 | Add `CodeRhapsodie\DataflowBundle\CodeRhapsodieDataflowBundle::class => ['all' => true],
70 | ` in the `config/bundles.php` file.
71 |
72 | Like this:
73 |
74 | ```php
75 | ['all' => true],
80 | // ...
81 | ];
82 | ```
83 |
84 | ### Update the database
85 |
86 | This bundle uses Doctrine DBAL to store Dataflow schedule into the database table (`cr_dataflow_scheduled`)
87 | and jobs (`cr_dataflow_job`).
88 |
89 | If you use [Doctrine Migration Bundle](https://symfony.com/doc/master/bundles/DoctrineMigrationsBundle/index.html)
90 | or [Phinx](https://phinx.org/)
91 | or [Kaliop Migration Bundle](https://github.com/kaliop-uk/ezmigrationbundle) or whatever,
92 | you can add a new migration with the generated SQL query from this command:
93 |
94 | ```shell script
95 | $ bin/console code-rhapsodie:dataflow:dump-schema
96 | ```
97 |
98 | If you have already the tables, you can add a new migration with the generated update SQL query from this command:
99 |
100 | ```shell script
101 | $ bin/console code-rhapsodie:dataflow:dump-schema --update
102 | ```
103 |
104 | ## Configuration
105 |
106 | By default, the Doctrine DBAL connection used is `default`. You can configure the default connection.
107 | Add this configuration into your Symfony configuration:
108 |
109 | ```yaml
110 | code_rhapsodie_dataflow:
111 | dbal_default_connection: test #Name of the default connection used by Dataflow bundle
112 | ```
113 |
114 | By default, the `logger` service will be used to log all exceptions and custom messages.
115 | If you want to use another logger, like a specific Monolog handler, Add this configuration:
116 |
117 | ```yaml
118 | code_rhapsodie_dataflow:
119 | default_logger: monolog.logger.custom #Service ID of the logger you want Dataflow to use
120 | ```
121 |
122 | ### Messenger mode
123 |
124 | Dataflow can delegate the execution of its jobs to the Symfony messenger component, if available.
125 | This allows jobs to be executed concurrently by workers instead of sequentially.
126 |
127 | To enable messenger mode:
128 |
129 | ```yaml
130 | code_rhapsodie_dataflow:
131 | messenger_mode:
132 | enabled: true
133 | # bus: 'messenger.default_bus' #Service ID of the bus you want Dataflow to use, if not the default one
134 | ```
135 |
136 | You also need to route Dataflow messages to the proper transport:
137 |
138 | ```yaml
139 | # config/packages/messenger.yaml
140 | framework:
141 | messenger:
142 | transports:
143 | async: '%env(MESSENGER_TRANSPORT_DSN)%'
144 |
145 | routing:
146 | CodeRhapsodie\DataflowBundle\MessengerMode\JobMessage: async
147 | ```
148 |
149 | ### Exceptions mode
150 | Dataflow can save exceptions in any filesystem you want.
151 | This allows dataflow to save exceptions in filesystem instead of the database
152 | You have to install `league/flysystem`.
153 |
154 | To enable exceptions mode:
155 |
156 | ```yaml
157 | code_rhapsodie_dataflow:
158 | exceptions_mode:
159 | type: 'file'
160 | flysystem_service: 'app.filesystem' #The name of the \League\Flysystem\Filesystem service
161 | ```
162 |
163 | ## Define a dataflow type
164 |
165 | This bundle uses a fixed and simple workflow structure in order to let you focus on the data processing logic part of
166 | your dataflow.
167 |
168 | A dataflow type defines the different parts of your dataflow. A dataflow is made of:
169 |
170 | - exactly one *Reader*
171 | - any number of *Steps*
172 | - one or more *Writers*
173 |
174 | Dataflow types can be configured with options.
175 |
176 | A dataflow type must implement `CodeRhapsodie\DataflowBundle\DataflowType\DataflowTypeInterface`.
177 |
178 | To help with creating your dataflow types, an abstract
179 | class `CodeRhapsodie\DataflowBundle\DataflowType\AbstractDataflowType`
180 | is provided, allowing you to define your dataflow through a handy
181 | builder `CodeRhapsodie\DataflowBundle\DataflowType\DataflowBuilder`.
182 |
183 | This is an example to define one class DataflowType:
184 |
185 | ```php
186 | myReader = $myReader;
203 | $this->myWriter = $myWriter;
204 | }
205 |
206 | protected function buildDataflow(DataflowBuilder $builder, array $options): void
207 | {
208 | $this->myWriter->setDestinationFilePath($options['to-file']);
209 |
210 | $builder
211 | ->setReader($this->myReader->read($options['from-file']))
212 | ->addStep(function ($data) use ($options) {
213 | // TODO : Write your code here...
214 | return $data;
215 | })
216 | ->addWriter($this->myWriter)
217 | ;
218 | }
219 |
220 | protected function configureOptions(OptionsResolver $optionsResolver): void
221 | {
222 | $optionsResolver->setDefaults(['to-file' => '/tmp/dataflow.csv', 'from-file' => null]);
223 | $optionsResolver->setRequired('from-file');
224 | }
225 |
226 | public function getLabel(): string
227 | {
228 | return 'My First Dataflow';
229 | }
230 |
231 | public function getAliases(): iterable
232 | {
233 | return ['mfd'];
234 | }
235 | }
236 |
237 | ```
238 |
239 | Dataflow types must be tagged with `coderhapsodie.dataflow.type`.
240 |
241 | If you're using Symfony auto-configuration for your services, this tag will be automatically added to all services
242 | implementing `DataflowTypeInterface`.
243 |
244 | Otherwise, manually add the tag `coderhapsodie.dataflow.type` in your dataflow type service configuration:
245 |
246 | ```yaml
247 | ```yaml
248 | CodeRhapsodie\DataflowExemple\DataflowType\MyFirstDataflowType:
249 | tags:
250 | - { name: coderhapsodie.dataflow.type }
251 | ```
252 |
253 | ### Use options for your dataflow type
254 |
255 | The `AbstractDataflowType` can help you define options for your Dataflow type.
256 |
257 | Add this method in your DataflowType class:
258 |
259 | ```php
260 | setDefaults(['to-file' => '/tmp/dataflow.csv', 'from-file' => null]);
270 | $optionsResolver->setRequired('from-file');
271 | }
272 |
273 | }
274 | ```
275 |
276 | With this configuration, the option `fileName` is required. For an advanced usage of the option resolver, read
277 | the [Symfony documentation](https://symfony.com/doc/current/components/options_resolver.html).
278 |
279 | For asynchronous management, `AbstractDataflowType` come with two default options :
280 |
281 | - loopInterval : default to 0. Update this interval if you wish customise the `tick` loop duration.
282 | - emitInterval : default to 0. Update this interval to have a control when reader must emit new data in the flow
283 | pipeline.
284 |
285 | ### Logging
286 |
287 | All exceptions will be caught and written in the logger.
288 | If you want to add custom messages in the log, you can inject the logger in your readers / steps / writers.
289 | If your DataflowType class extends `AbstractDataflowType`, the logger is accessible as `$this->logger`.
290 |
291 | ```php
292 | myWriter->setLogger($this->logger);
302 | }
303 |
304 | }
305 | ```
306 |
307 | When using the `code-rhapsodie:dataflow:run-pending` command, this logger will also be used to save the log in the
308 | corresponding job in the database.
309 |
310 | ### Check if your DataflowType is ready
311 |
312 | Execute this command to check if your DataflowType is correctly registered:
313 |
314 | ```shell script
315 | $ bin/console debug:container --tag coderhapsodie.dataflow.type
316 | ```
317 |
318 | The result is like this:
319 |
320 | ```
321 | Symfony Container Public and Private Services Tagged with "coderhapsodie.dataflow.type" Tag
322 | ===========================================================================================
323 |
324 | ---------------------------------------------------------------- ----------------------------------------------------------------
325 | Service ID Class name
326 | ---------------------------------------------------------------- ----------------------------------------------------------------
327 | CodeRhapsodie\DataflowExemple\DataflowType\MyFirstDataflowType CodeRhapsodie\DataflowExemple\DataflowType\MyFirstDataflowType
328 | ---------------------------------------------------------------- ----------------------------------------------------------------
329 |
330 | ```
331 |
332 | ### Readers
333 |
334 | *Readers* provide the dataflow with elements to import / export. Usually, elements are read from an external resource (
335 | file, database, webservice, etc).
336 |
337 | A *Reader* can be any `iterable`.
338 |
339 | The only constraint on the returned elements typing is that they cannot be `false`.
340 |
341 | The reader can be a generator like this example :
342 |
343 | ```php
344 | setReader(($this->myReader)())
371 | ```
372 |
373 | ### Steps
374 |
375 | *Steps* are operations performed on the elements before they are handled by the *Writers*. Usually, steps are either:
376 |
377 | - converters, that alter the element
378 | - filters, that conditionally prevent further operations on the element
379 | - generators, that can include asynchronous operations
380 |
381 | A *Step* can be any callable, taking the element as its argument, and returning either:
382 |
383 | - the element, possibly altered
384 | - `false`, if no further operations should be performed on this element
385 |
386 | A few examples:
387 |
388 | ```php
389 | addStep(function ($item) {
392 | // Titles are changed to all caps before export
393 | $item['title'] = strtoupper($item['title']);
394 |
395 | return $item;
396 | });
397 |
398 | // asynchronous step with 2 scale factor
399 | $builder->addStep(function ($item): \Generator {
400 | yield new \Amp\Delayed(1000); // asynchronous processing for 1 second long
401 |
402 | // Titles are changed to all caps before export
403 | $item['title'] = strtolower($item['title']);
404 |
405 | return $item;
406 | }, 2);
407 |
408 | $builder->addStep(function ($item) {
409 | // Private items are not exported
410 | if ($item['private']) {
411 | return false;
412 | }
413 |
414 | return $item;
415 | });
416 | //[...]
417 | ```
418 |
419 | Note : you can ensure writing order for asynchronous operations if all steps are scaled at 1 factor.
420 |
421 | ### Writers
422 |
423 | *Writers* perform the actual import / export operations.
424 |
425 | A *Writer* must implement `CodeRhapsodie\DataflowBundle\DataflowType\Writer\WriterInterface`.
426 | As this interface is not compatible with `Port\Writer`, the
427 | adapter `CodeRhapsodie\DataflowBundle\DataflowType\Writer\PortWriterAdapter` is provided.
428 |
429 | This example show how to use the predefined PhpPort Writer :
430 |
431 | ```php
432 | $builder->addWriter(new PortWriterAdapter(new \Port\FileWriter()));
433 | ```
434 |
435 | Or your own Writer:
436 |
437 | ```php
438 | path = $path;
452 | }
453 |
454 | public function prepare()
455 | {
456 | if (null === $this->path) {
457 | throw new \Exception('Define the destination file name before use');
458 | }
459 | if (!$this->fh = fopen($this->path, 'w')) {
460 | throw new \Exception('Unable to open in write mode the output file.');
461 | }
462 | }
463 |
464 | public function write($item)
465 | {
466 | fputcsv($this->fh, $item);
467 | }
468 |
469 | public function finish()
470 | {
471 | fclose($this->fh);
472 | }
473 | }
474 | ```
475 |
476 | #### CollectionWriter
477 |
478 | If you want to write multiple items from a single item read, you can use the generic `CollectionWriter`. This writer
479 | will iterate over any `iterable` it receives, and pass each item from that collection to your own writer that handles
480 | single items.
481 |
482 | ```php
483 | $builder->addWriter(new CollectionWriter($mySingleItemWriter));
484 | ```
485 |
486 | #### DelegatorWriter
487 |
488 | If you want to call different writers depending on what item is read, you can use the generic `DelegatorWriter`.
489 |
490 | As an example, let's suppose our items are arrays with the first entry being either `product` or `order`. We want to use
491 | a different writer based on that value.
492 |
493 | First, create your writers implementing `DelegateWriterInterface` (this interface extends `WriterInterface` so your
494 | writers can still be used without the `DelegatorWriter`).
495 |
496 | ```php
497 | addDelegate(new ProductWriter());
561 | $delegatorWriter->addDelegate(new OrderWriter());
562 |
563 | $builder->addWriter($delegatorWriter);
564 | }
565 | ```
566 |
567 | During execution, the `DelegatorWriter` will simply pass each item received to its first delegate (in the order those
568 | were added) that supports it. If no delegate supports an item, an exception will be thrown.
569 |
570 | ## Queue
571 |
572 | All pending dataflow job processes are stored in a queue into the database.
573 |
574 | Add this command into your crontab for execute all queued jobs:
575 |
576 | ```shell script
577 | $ SYMFONY_ENV=prod php bin/console code-rhapsodie:dataflow:run-pending
578 | ```
579 |
580 | ## Commands
581 |
582 | Several commands are provided to manage schedules and run jobs.
583 |
584 | `code-rhapsodie:dataflow:run-pending` Executes job in the queue according to their schedule.
585 |
586 | When messenger mode is enabled, jobs will still be created according to their schedule, but execution will be handled by
587 | the messenger component instead.
588 |
589 | `code-rhapsodie:dataflow:schedule:list` Display the list of dataflows scheduled.
590 |
591 | `code-rhapsodie:dataflow:schedule:change-status` Enable or disable a scheduled dataflow
592 |
593 | `code-rhapsodie:dataflow:schedule:add` Add the schedule for a dataflow.
594 |
595 | `code-rhapsodie:dataflow:job:show` Display the last result of a job.
596 |
597 | `code-rhapsodie:dataflow:execute` Let you execute one dataflow job.
598 |
599 | `code-rhapsodie:dataflow:dump-schema` Generates schema create / update SQL queries
600 |
601 | ### Work with many databases
602 |
603 | All commands have a `--connection` option to define what Doctrine DBAL connection to use during execution.
604 |
605 | Example:
606 |
607 | This command uses the `default` DBAL connection to generate all schema update queries.
608 |
609 | ```shell script
610 | $ bin/console code-rhapsodie:dataflow:dump-schema --update --connection=default
611 | ```
612 |
613 | To execute all pending job for a specific connection use:
614 |
615 | ```shell script
616 | # Run for dataflow DBAL connection
617 | $ bin/console code-rhapsodie:dataflow:run-pending --connection=dataflow
618 | # Run for default DBAL connection
619 | $ bin/console code-rhapsodie:dataflow:run-pending --connection=default
620 | ```
621 |
622 | # Issues and feature requests
623 |
624 | Please report issues and request features at https://github.com/code-rhapsodie/dataflow-bundle/issues.
625 |
626 | Please note that only the last release of the 4.x and the 5.x versions of this bundle are actively supported.
627 |
628 | # Contributing
629 |
630 | Contributions are very welcome. Please see [CONTRIBUTING.md](CONTRIBUTING.md) for
631 | details. Thanks to [everyone who has contributed](https://github.com/code-rhapsodie/dataflow-bundle/graphs/contributors)
632 | already.
633 |
634 | # License
635 |
636 | This package is licensed under the [MIT license](LICENSE).
637 |
--------------------------------------------------------------------------------