<?php
/*
* This file is part of the CleverAge/ProcessBundle package.
*
* Copyright (C) 2017-2018 Clever-Age
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace CleverAge\ProcessBundle\Task\Database;

use CleverAge\ProcessBundle\Model\AbstractConfigurableTask;
use CleverAge\ProcessBundle\Model\FinalizableTaskInterface;
use CleverAge\ProcessBundle\Model\IterableTaskInterface;
use CleverAge\ProcessBundle\Model\ProcessState;
use Doctrine\Common\Persistence\ManagerRegistry;
use Doctrine\DBAL\Driver\PDOStatement;
use Psr\Log\LoggerInterface;
use Psr\Log\LogLevel;
use Symfony\Component\OptionsResolver\OptionsResolver;

/**
 * Fetch entities from doctrine
 *
 * @author Valentin Clavreul <vclavreul@clever-age.com>
 * @author Vincent Chalnot <vchalnot@clever-age.com>
 */
class DatabaseReaderTask extends AbstractConfigurableTask implements IterableTaskInterface, FinalizableTaskInterface
{
    /** @var LoggerInterface */
    protected $logger;

    /** @var ManagerRegistry */
    protected $doctrine;

    /** @var PDOStatement */
    protected $statement;

    /** @var array|mixed */
    protected $nextItem;

    /**
     * @param LoggerInterface $logger
     * @param ManagerRegistry $doctrine
     */
    public function __construct(LoggerInterface $logger, ManagerRegistry $doctrine)
    {
        $this->logger = $logger;
        $this->doctrine = $doctrine;
    }

    /**
     * Moves the internal pointer to the next element,
     * return true if the task has a next element
     * return false if the task has terminated it's iteration
     *
     * @param ProcessState $state
     *
     * @throws \LogicException
     *
     * @return bool
     */
    public function next(ProcessState $state)
    {
        if (!$this->statement) {
            return false;
        }

        $this->nextItem = $this->statement->fetch();

        return (bool) $this->nextItem;
    }

    /**
     * @param ProcessState $state
     *
     * @throws \InvalidArgumentException
     * @throws \Symfony\Component\OptionsResolver\Exception\ExceptionInterface
     * @throws \Doctrine\DBAL\DBALException
     */
    public function execute(ProcessState $state)
    {
        $options = $this->getOptions($state);
        if (!$this->statement) {
            $this->statement = $this->initializeStatement($state);
        }

        // Check if the next item has been stored by the next() call
        if (null !== $this->nextItem) {
            $result = $this->nextItem;
            $this->nextItem = null;
        } else {
            $result = $this->statement->fetch();
        }

        // Handle empty results
        if (false === $result) {
            $logContext = ['options' => $options];
            $this->logger->log($options['empty_log_level'], 'Empty resultset for query', $logContext);
            $state->setSkipped(true);
            $this->statement = null;

            return;
        }

        if (null !== $options['paginate']) {
            $results = [];
            $i = 0;
            while (false !== $result && $i++ < $options['paginate']) {
                $results[] = $result;
                $result = $this->statement->fetch();
            }
            $state->setOutput($results);
        } else {
            $state->setOutput($result);
        }
    }

    /**
     * @param ProcessState $state
     */
    public function finalize(ProcessState $state)
    {
        if ($this->statement) {
            $this->statement->closeCursor();
        }
    }

    /**
     * @param ProcessState $state
     *
     * @throws \Symfony\Component\OptionsResolver\Exception\ExceptionInterface
     * @throws \InvalidArgumentException
     * @throws \Doctrine\DBAL\DBALException
     *
     * @return \Doctrine\DBAL\Driver\Statement
     */
    protected function initializeStatement(ProcessState $state)
    {
        $options = $this->getOptions($state);
        $connection = $this->getConnection($state);
        $sql = $options['sql'];

        if (null === $sql) {
            $qb = $connection->createQueryBuilder();
            $qb
                ->select('tbl.*')
                ->from($options['table'], 'tbl');

            if ($options['limit']) {
                $qb->setMaxResults($options['limit']);
            }
            if ($options['offset']) {
                $qb->setFirstResult($options['offset']);
            }

            $sql = $qb->getSQL();
        }

        return $connection->executeQuery($sql);
    }

    /**
     * {@inheritdoc}
     */
    protected function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setRequired(
            [
                'table',
            ]
        );
        $resolver->setAllowedTypes('table', ['string']);
        $resolver->setDefaults(
            [
                'connection' => null,
                'sql' => null,
                'limit' => null,
                'offset' => null,
                'paginate' => null,
                'empty_log_level' => LogLevel::WARNING,
            ]
        );
        $resolver->setAllowedTypes('connection', ['NULL', 'string']);
        $resolver->setAllowedTypes('sql', ['NULL', 'string']);
        $resolver->setAllowedTypes('paginate', ['NULL', 'int']);
        $resolver->setAllowedTypes('limit', ['NULL', 'integer']);
        $resolver->setAllowedTypes('offset', ['NULL', 'integer']);
        $resolver->setAllowedValues(
            'empty_log_level',
            [
                LogLevel::ALERT,
                LogLevel::CRITICAL,
                LogLevel::DEBUG,
                LogLevel::EMERGENCY,
                LogLevel::ERROR,
                LogLevel::INFO,
                LogLevel::NOTICE,
                LogLevel::WARNING,
            ]
        );
    }

    /**
     * @param ProcessState $state
     *
     * @throws \InvalidArgumentException
     * @throws \Symfony\Component\OptionsResolver\Exception\ExceptionInterface
     *
     * @return \Doctrine\DBAL\Connection
     */
    protected function getConnection(ProcessState $state)
    {
        /** @noinspection PhpIncompatibleReturnTypeInspection */

        return $this->doctrine->getConnection($this->getOption($state, 'connection'));
    }
}


<?php
/*
 * This file is part of the CleverAge/ProcessBundle package.
 *
 * Copyright (C) 2017-2018 Clever-Age
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace CleverAge\ProcessBundle\Task;

use CleverAge\ProcessBundle\Model\AbstractConfigurableTask;
use CleverAge\ProcessBundle\Model\FlushableTaskInterface;
use CleverAge\ProcessBundle\Model\ProcessState;
use Symfony\Component\OptionsResolver\OptionsResolver;

/**
 * Count the number of times the task is processed and continue every N iteration (skip the rest of the time)
 * Flush at the end with the actual count
 *
 * @author Vincent Chalnot <vchalnot@clever-age.com>
 */
class CounterTask extends AbstractConfigurableTask implements FlushableTaskInterface
{
    /** @var int */
    protected $counter = 0;

    /**
     * @param ProcessState $state
     */
    public function execute(ProcessState $state): void
    {
        $this->counter++;
        $modulo = $this->getOption($state, 'flush_every');
        if (0 === $this->counter % $modulo) {
            $state->setOutput($this->counter);
        } else {
            $state->setSkipped(true);
        }
    }

    /**
     * Condition is inversed during flush
     *
     * @param ProcessState $state
     */
    public function flush(ProcessState $state): void
    {
        $modulo = $this->getOption($state, 'flush_every');
        if (0 === $this->counter % $modulo) {
            $state->setSkipped(true);
        } else {
            $state->setOutput($this->counter);
        }
    }

    /**
     * @param OptionsResolver $resolver
     */
    protected function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setRequired(
            [
                'flush_every',
            ]
        );
        $resolver->setAllowedTypes('flush_every', ['int']);
    }
}


<?php
/*
 * This file is part of the CleverAge/ProcessBundle package.
 *
 * Copyright (C) 2017-2018 Clever-Age
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace CleverAge\ProcessBundle\Transformer;

use Symfony\Component\OptionsResolver\OptionsResolver;

/**
 * Explode a string to an array based on a split character
 *
 * @author Valentin Clavreul <vclavreul@clever-age.com>
 * @author Vincent Chalnot <vchalnot@clever-age.com>
 */
class ExplodeTransformer implements ConfigurableTransformerInterface
{
    /**
     * Must return the transformed $value
     *
     * @param mixed $value
     * @param array $options
     *
     * @throws \Symfony\Component\OptionsResolver\Exception\ExceptionInterface
     *
     * @return mixed $value
     */
    public function transform($value, array $options = [])
    {
        $resolver = new OptionsResolver();
        $this->configureOptions($resolver);
        $options = $resolver->resolve($options);

        if (null === $value || '' === $value) {
            return [];
        }

        return explode($options['delimiter'], $value);
    }

    /**
     * Returns the unique code to identify the transformer
     *
     * @return string
     */
    public function getCode()
    {
        return 'explode';
    }

    /**
     * @param OptionsResolver $resolver
     *
     * @throws \Symfony\Component\OptionsResolver\Exception\ExceptionInterface
     */
    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setRequired(
            [
                'delimiter',
            ]
        );
        $resolver->setAllowedTypes('delimiter', ['string']);
    }
}


<?php
/*
 * This file is part of the CleverAge/ProcessBundle package.
 *
 * Copyright (C) 2017-2018 Clever-Age
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace CleverAge\ProcessBundle\Transformer;

use Symfony\Component\OptionsResolver\OptionsResolver;

/**
 * Use sprintf() function to format string
 *
 * @author Valentin Clavreul <vclavreul@clever-age.com>
 * @author Vincent Chalnot <vchalnot@clever-age.com>
 * @author Corentin Bouix <cbouix@clever-age.com>
 */
class SprintfTransformer implements ConfigurableTransformerInterface
{
    /**
     * {@inheritDoc}
     */
    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setRequired('format');
        $resolver->setDefault('format', '%s');
        $resolver->setAllowedTypes('format', 'string');
    }

    /**
     * {@inheritDoc}
     * @throws \UnexpectedValueException
     */
    public function transform($value, array $options = [])
    {
        if (!\is_array($value)) {
            $value = [$value];
        }

        return vsprintf($options['format'], $value);
    }

    /**
     * {@inheritDoc}
     */
    public function getCode()
    {
        return 'sprintf';
    }
}


<?php
/*
 * This file is part of the CleverAge/ProcessBundle package.
 *
 * Copyright (C) 2017-2018 Clever-Age
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace CleverAge\ProcessBundle\Transformer;

use Symfony\Component\OptionsResolver\OptionsResolver;

/**
 * Transformer aiming to take a date as an input (object or a format defined string) to strictly output aa \DateTime
 *
 * @example in YML config
 * transformers:
 *     date_parser:
 *         format: Y-m-d
 */
class DateParserTransformer implements ConfigurableTransformerInterface
{

    /**
     * @param mixed $value
     * @param array $options
     *
     * @return mixed|string
     */
    public function transform($value, array $options = [])
    {
        if (!$value || $value instanceof \DateTime) {
            return $value;
        }

        $date = \DateTime::createFromFormat($options['format'], $value);

        if (!$date) {
            throw new \UnexpectedValueException('Given value cannot be parsed into a date');
        }

        return $date;
    }

    /**
     * @return string
     */
    public function getCode()
    {
        return 'date_parser';
    }

    /**
     * @param OptionsResolver $resolver
     *
     * @throws \Symfony\Component\OptionsResolver\Exception\UndefinedOptionsException
     * @throws \Symfony\Component\OptionsResolver\Exception\AccessException
     */
    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setRequired('format');
        $resolver->setAllowedTypes('format', 'string');
    }
}


<?php
/*
* This file is part of the CleverAge/ProcessBundle package.
*
* Copyright (C) 2017-2018 Clever-Age
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace CleverAge\ProcessBundle\Task\Validation;

use CleverAge\ProcessBundle\Model\AbstractConfigurableTask;
use CleverAge\ProcessBundle\Model\ProcessState;
use Psr\Log\LoggerInterface;
use Sidus\BaseBundle\Validator\Mapping\Loader\BaseLoader;
use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\ConstraintViolationInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface;

/**
 * Validate the input and pass it to the output
 *
 * @author Valentin Clavreul <vclavreul@clever-age.com>
 * @author Vincent Chalnot <vchalnot@clever-age.com>
 */
class ValidatorTask extends AbstractConfigurableTask
{
    /** @var LoggerInterface */
    protected $logger;

    /** @var ValidatorInterface */
    protected $validator;

    /**
     * @param LoggerInterface    $logger
     * @param ValidatorInterface $validator
     */
    public function __construct(LoggerInterface $logger, ValidatorInterface $validator)
    {
        $this->logger = $logger;
        $this->validator = $validator;
    }

    /**
     * @param ProcessState $state
     *
     * @throws \Symfony\Component\OptionsResolver\Exception\ExceptionInterface
     * @throws \UnexpectedValueException
     */
    public function execute(ProcessState $state)
    {
        $options = $this->getOptions($state);
        $violations = $this->validator->validate(
            $state->getInput(),
            $this->getOption($state, 'constraints'),
            $options['groups']
        );

        if (0 < $violations->count()) {
            /** @var  $violation ConstraintViolationInterface */
            foreach ($violations as $violation) {
                $invalidValue = $violation->getInvalidValue();

                $logContext = [
                    'property' => $violation->getPropertyPath(),
                    'violation_code' => $violation->getCode(),
                    'invalid_value' => $invalidValue,
                ];
                if ($this->getOption($state, 'log_errors')) {
                    $this->logger->warning($violation->getMessage(), $logContext);
                }
            }

            throw new \UnexpectedValueException("{$violations->count()} constraint violations detected on validation");
        }

        $state->setOutput($state->getInput());
    }

    /**
     * {@inheritDoc}
     */
    protected function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefault('log_errors', true);
        $resolver->addAllowedTypes('log_errors', ['bool']);

        $resolver->setDefault('groups', null);
        $resolver->addAllowedTypes('groups', ['NULL', 'array']);

        $resolver->setDefault('constraints', null);
        $resolver->addAllowedTypes('constraints', ['NULL', 'array']);
        $resolver->setNormalizer(
            'constraints',
            function (Options $options, $constraints) {
                if (null === $constraints) {
                    return null;
                }

                return (new BaseLoader())->loadCustomConstraints($constraints);
            }
        );
    }
}


<?php
/*
 * This file is part of the CleverAge/ProcessBundle package.
 *
 * Copyright (C) 2017-2018 Clever-Age
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace CleverAge\ProcessBundle\Transformer;

use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;

/**
 * Normalize the given value based on options
 *
 * @author Valentin Clavreul <vclavreul@clever-age.com>
 * @author Vincent Chalnot <vchalnot@clever-age.com>
 */
class NormalizeTransformer implements ConfigurableTransformerInterface
{
    /** @var NormalizerInterface */
    protected $normalizer;

    /**
     * @param NormalizerInterface $normalizer
     */
    public function __construct(NormalizerInterface $normalizer)
    {
        $this->normalizer = $normalizer;
    }

    /**
     * @param OptionsResolver $resolver
     *
     * @throws \Symfony\Component\OptionsResolver\Exception\AccessException
     * @throws \Symfony\Component\OptionsResolver\Exception\UndefinedOptionsException
     */
    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults(
            [
                'format' => null,
                'context' => [],
            ]
        );
        $resolver->setAllowedTypes('format', ['NULL', 'string']);
        $resolver->setAllowedTypes('context', ['array']);
    }

    /**
     * @param mixed $value
     * @param array $options
     *
     * @throws \Symfony\Component\OptionsResolver\Exception\UndefinedOptionsException
     * @throws \Symfony\Component\OptionsResolver\Exception\OptionDefinitionException
     * @throws \Symfony\Component\OptionsResolver\Exception\NoSuchOptionException
     * @throws \Symfony\Component\OptionsResolver\Exception\MissingOptionsException
     * @throws \Symfony\Component\OptionsResolver\Exception\InvalidOptionsException
     * @throws \Symfony\Component\OptionsResolver\Exception\AccessException
     * @throws \Symfony\Component\Serializer\Exception\LogicException
     * @throws \Symfony\Component\Serializer\Exception\InvalidArgumentException
     * @throws \Symfony\Component\Serializer\Exception\CircularReferenceException
     * @throws \Symfony\Component\OptionsResolver\Exception\ExceptionInterface
     *
     * @return mixed
     */
    public function transform($value, array $options = [])
    {
        $resolver = new OptionsResolver();
        $this->configureOptions($resolver);
        $options = $resolver->resolve($options);

        return $this->normalizer->normalize(
            $value,
            $options['format'],
            $options['context']
        );
    }

    /**
     * Returns the unique code to identify the transformer
     *
     * @return string
     */
    public function getCode()
    {
        return 'normalize';
    }
}


<?php
/*
* This file is part of the CleverAge/ProcessBundle package.
*
* Copyright (C) 2017-2018 Clever-Age
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace CleverAge\ProcessBundle\Transformer;

use CleverAge\ProcessBundle\Exception\TransformerException;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;

/**
 * Read a property from the input value and return it
 *
 * @author Madeline Veyrenc <mveyrenc@clever-age.com>
 */
class RecursivePropertySetterTransformer implements ConfigurableTransformerInterface
{
    /** @var PropertyAccessorInterface */
    protected $accessor;

    /**
     * @param PropertyAccessorInterface $accessor
     */
    public function __construct(PropertyAccessorInterface $accessor)
    {
        $this->accessor = $accessor;
    }

    /**
     * Must return the transformed $value
     *
     * @param mixed $value
     * @param array $options
     *
     * @throws \Symfony\Component\PropertyAccess\Exception\NoSuchPropertyException
     * @throws \CleverAge\ProcessBundle\Exception\TransformerException
     * @throws \Symfony\Component\OptionsResolver\Exception\ExceptionInterface
     * @throws \Symfony\Component\PropertyAccess\Exception\InvalidArgumentException
     * @throws \Symfony\Component\PropertyAccess\Exception\AccessException
     * @throws \Symfony\Component\PropertyAccess\Exception\UnexpectedTypeException
     *
     * @return mixed $value
     */
    public function transform($value, array $options = [])
    {
        $resolver = new OptionsResolver();
        $this->configureOptions($resolver);
        /** @noinspection ExceptionsAnnotatingAndHandlingInspection */
        $options = $resolver->resolve($options);

        if (null === $value && $options['ignore_null']) {
            return null;
        }

        if ($options['ignore_missing'] && !$this->accessor->isReadable($value, $options['iterator'])) {
            return null;
        }

        $iterable = $this->accessor->getValue($value, $options['iterator']);
        if (!is_iterable($iterable)) {
            throw new TransformerException($options['iterator'], 0, 'Property not an iterable');
        }

        $protertiesToSet = [];
        foreach ($options['set_properties'] as $propertyName => $propertyValuePath) {
            $protertiesValue = null;
            if ($options['ignore_missing'] && !$this->accessor->isReadable($value, $propertyValuePath)) {
                $protertiesValue = null;
            } else {
                $protertiesValue = $this->accessor->getValue($value, $propertyValuePath);
                if (null === $protertiesValue && !$options['ignore_null']) {
                    throw new TransformerException($propertyValuePath, 0, 'Property is null');
                }
            }
            $protertiesToSet[$propertyName] = $protertiesValue;
        }

        foreach ($iterable as &$item) {
            foreach ($protertiesToSet as $protertyName => $propertyValue) {
                try {
                    $this->accessor->setValue($item, $protertyName, $propertyValue);
                } catch (\Symfony\Component\PropertyAccess\Exception\NoSuchPropertyException $e) {
                    if ($item instanceof \stdClass) {
                        $item = (object) array_merge((array) $item, [$protertyName => $propertyValue]);
                    } else {
                        throw $e;
                    }
                }
            }
        }

        return $iterable;
    }

    /**
     * Returns the unique code to identify the transformer
     *
     * @return string
     */
    public function getCode()
    {
        return 'recursive_property_setter';
    }

    /**
     * @param OptionsResolver $resolver
     *
     * @throws \Symfony\Component\OptionsResolver\Exception\ExceptionInterface
     */
    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setRequired(
            [
                'iterator',
                'set_properties',
            ]
        );

        $resolver->setDefaults(
            [
                'ignore_null' => false,
                'ignore_missing' => false,
            ]
        );

        $resolver->setAllowedTypes('iterator', ['string']);
        $resolver->setAllowedTypes('set_properties', ['array']);
        $resolver->setAllowedTypes('ignore_null', ['boolean']);
        $resolver->setAllowedTypes('ignore_missing', ['boolean']);
    }
}


<?php
/*
 * This file is part of the CleverAge/ProcessBundle package.
 *
 * Copyright (C) 2017-2018 Clever-Age
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace CleverAge\ProcessBundle\Task;

use CleverAge\ProcessBundle\Model\AbstractConfigurableTask;
use CleverAge\ProcessBundle\Model\FlushableTaskInterface;
use CleverAge\ProcessBundle\Model\IterableTaskInterface;
use CleverAge\ProcessBundle\Model\ProcessState;
use Psr\Log\LoggerInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

/**
 * A Batch task that iterate on flush
 * It's mainly an example task since it's not useful as-is, but the processInput method may allow custom overrides
 *
 * @author Valentin Clavreul <vclavreul@clever-age.com>
 */
class IterableBatchTask extends AbstractConfigurableTask implements FlushableTaskInterface, IterableTaskInterface
{

    /** @var \SplQueue */
    protected $outputQueue;

    /** @var bool */
    protected $flushMode = false;

    /** @var LoggerInterface */
    protected $logger;

    /**
     * IterableBatchTask constructor.
     *
     * @param LoggerInterface $logger
     */
    public function __construct(LoggerInterface $logger)
    {
        $this->logger = $logger;
    }

    /**
     * @param ProcessState $state
     */
    public function initialize(ProcessState $state)
    {
        parent::initialize($state);
        $this->outputQueue = new \SplQueue();
    }

    /**
     * @param ProcessState $state
     */
    public function flush(ProcessState $state)
    {
        $this->flushMode = true;
        if ($this->outputQueue->isEmpty()) {
            $state->setSkipped(true);
        } else {
            $state->setOutput($this->outputQueue->dequeue());
        }
    }

    /**
     * @param ProcessState $state
     *
     * @throws \Symfony\Component\OptionsResolver\Exception\ExceptionInterface
     * @throws \InvalidArgumentException
     */
    public function execute(ProcessState $state)
    {
        $batchCount = $this->getOption($state, 'batch_count');

        // Register new input
        if (!$this->flushMode) {
            $this->outputQueue->enqueue($this->processInput($state));
        }

        // Detect flushing
        if (null !== $batchCount && \count($this->outputQueue) >= $batchCount) {
            $this->flushMode = true;
        }

        // Flush or skip
        if ($this->flushMode) {
            $state->setOutput($this->outputQueue->dequeue());
        } else {
            $state->setSkipped(true);
        }
    }

    /**
     * @param ProcessState $state
     *
     * @return bool
     */
    public function next(ProcessState $state)
    {
        // Stop flushing once over
        if (!\count($this->outputQueue)) {
            $this->flushMode = false;
        }

        return $this->flushMode;
    }

    /**
     * @param OptionsResolver $resolver
     *
     * @throws \Symfony\Component\OptionsResolver\Exception\AccessException
     */
    protected function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults(
            [
                'batch_count' => 10,
            ]
        );

        $resolver->setAllowedTypes('batch_count', 'integer');
    }

    /**
     * Override this method to add a custom processing behavior
     *
     * @param ProcessState $state
     *
     * @return mixed
     */
    protected function processInput(ProcessState $state)
    {
        return $state->getInput();
    }
}


<?php
/*
 * This file is part of the CleverAge/ProcessBundle package.
 *
 * Copyright (C) 2017-2018 Clever-Age
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace CleverAge\ProcessBundle\Filesystem;

/**
 * Read and write CSV resources through a simple API.
 *
 * @author Valentin Clavreul <vclavreul@clever-age.com>
 * @author Vincent Chalnot <vchalnot@clever-age.com>
 */
class CsvResource implements FileStreamInterface
{
    /** @var string */
    protected $delimiter;

    /** @var string */
    protected $enclosure;

    /** @var string */
    protected $escape;

    /** @var resource */
    protected $handler;

    /** @var int */
    protected $lineCount;

    /** @var array */
    protected $headers;

    /** @var bool */
    protected $manualHeaders = false;

    /** @var int */
    protected $headerCount;

    /** @var int */
    protected $currentLine = 0;

    /** @var bool */
    protected $closed;

    /** @var bool */
    protected $seekCalled = false;

    /**
     * @param resource $resource
     * @param string   $delimiter CSV delimiter
     * @param string   $enclosure
     * @param string   $escape
     * @param array    $headers   Leave null to read the headers from the file
     *
     * @throws \UnexpectedValueException
     */
    public function __construct(
        $resource,
        $delimiter = ',',
        $enclosure = '"',
        $escape = '\\',
        array $headers = null
    ) {
        if (!\is_resource($resource)) {
            $type = \gettype($resource);
            throw new \UnexpectedValueException("Resource argument must be a resource, '{$type}' given");
        }
        $this->delimiter = $delimiter;
        $this->enclosure = $enclosure;
        $this->escape = $escape;

        $this->handler = $resource;
        $this->headers = $this->parseHeaders($headers);
        $this->headerCount = \count($this->headers);
    }

    /**
     * @return string
     */
    public function getDelimiter(): string
    {
        return $this->delimiter;
    }

    /**
     * @return string
     */
    public function getEnclosure(): string
    {
        return $this->enclosure;
    }

    /**
     * @return string
     */
    public function getEscape(): string
    {
        return $this->escape;
    }

    /**
     * @return resource
     */
    public function getHandler()
    {
        return $this->handler;
    }

    /**
     * Warning! This method will rewind the file to the beginning before and after counting the lines!
     *
     * @throws \RuntimeException
     *
     * @return int
     */
    public function getLineCount(): int
    {
        if (null === $this->lineCount) {
            $this->rewind();
            $line = 0;
            while (!$this->isEndOfFile()) {
                ++$line;
                $this->readRaw();
            }
            $this->rewind();

            $this->lineCount = $line;
        }

        return $this->lineCount;
    }

    /**
     * @return array
     */
    public function getHeaders(): array
    {
        return $this->headers;
    }

    /**
     * @return int
     */
    public function getHeaderCount(): int
    {
        return $this->headerCount;
    }

    /**
     * Write headers to the file
     *
     * @throws \RuntimeException
     */
    public function writeHeaders(): void
    {
        $this->writeRaw($this->headers);
    }

    /**
     * @throws \LogicException
     *
     * @return int
     */
    public function getCurrentLine(): int
    {
        if ($this->seekCalled) {
            throw new \LogicException('Cannot get current line number after calling "seek": the line number is lost');
        }

        return $this->currentLine;
    }

    /**
     * @throws \RuntimeException
     *
     * @return bool
     */
    public function isEndOfFile(): bool
    {
        $this->assertOpened();

        return feof($this->handler);
    }

    /**
     * Warning, this function will return exactly the same value as the fgetcsv() function.
     *
     * @param null|int $length
     *
     * @throws \RuntimeException
     *
     * @return array|false
     */
    public function readRaw($length = null)
    {
        $this->assertOpened();
        ++$this->currentLine;

        return fgetcsv($this->handler, $length, $this->delimiter, $this->enclosure, $this->escape);
    }

    /**
     * @param int|null $length
     *
     * @throws \UnexpectedValueException
     * @throws \RuntimeException
     *
     * @return array
     */
    public function readLine($length = null): ?array
    {
        $values = $this->readRaw($length);

        if (false === $values) {
            if ($this->isEndOfFile()) {
                return null;
            }
            $message = "Unable to parse data on line {$this->currentLine} for {$this->getResourceName()}";
            throw new \UnexpectedValueException($message);
        }

        $count = \count($values);
        if ($count !== $this->headerCount) {
            $message = "Number of columns not matching on line {$this->currentLine} for {$this->getResourceName()}: ";
            $message .= "{$count} columns for {$this->headerCount} headers";
            throw new \UnexpectedValueException($message);
        }

        $combined = array_combine($this->headers, $values);
        if (false === $combined) {
            throw new \RuntimeException('Cannot combine headers with values');
        }

        return $combined;
    }

    /**
     * Warning, this function will return exactly the same value as the fgetcsv() function.
     *
     * @param array $fields
     *
     * @throws \RuntimeException
     *
     * @return int
     */
    public function writeRaw(array $fields): int
    {
        $this->assertOpened();
        ++$this->currentLine;

        return fputcsv($this->handler, $fields, $this->delimiter, $this->enclosure, $this->escape);
    }

    /**
     * @param array $fields
     *
     * @throws \RuntimeException
     *
     * @return int
     */
    public function writeLine(array $fields): int
    {
        $count = \count($fields);
        if ($count !== $this->headerCount) {
            $message = "Trying to write an invalid number of columns for {$this->getResourceName()}: ";
            $message .= "{$count} columns for {$this->headerCount} headers";
            throw new \UnexpectedValueException($message);
        }

        $parsedFields = [];
        foreach ($this->headers as $column) {
            if (!array_key_exists($column, $fields)) {
                $message = "Missing column {$column} in given fields for {$this->getResourceName()}";
                throw new \UnexpectedValueException($message);
            }
            $parsedFields[$column] = $fields[$column];
        }

        $length = $this->writeRaw($parsedFields);
        if (false === $length) {
            throw new \RuntimeException("Unable to write data to {$this->getResourceName()}");
        }

        return $length;
    }

    /**
     * This methods rewinds the file to the first line of data, skipping the headers.
     *
     * @throws \RuntimeException
     */
    public function rewind(): void
    {
        $this->assertOpened();
        if (!rewind($this->handler)) {
            throw new \RuntimeException("Unable to rewind '{$this->getResourceName()}'");
        }
        $this->currentLine = 0;
        if (!$this->manualHeaders) {
            $this->readRaw(); // skip headers
        }
    }

    /**
     * @throws \RuntimeException
     *
     * @return int
     */
    public function tell(): int
    {
        $this->assertOpened();

        return ftell($this->handler);
    }

    /**
     * @param int $offset
     *
     * @throws \RuntimeException
     *
     * @return int
     */
    public function seek($offset): int
    {
        $this->assertOpened();
        $this->seekCalled = true;

        return fseek($this->handler, $offset);
    }

    /**
     * @return bool
     */
    public function close(): bool
    {
        if ($this->closed) {
            return true;
        }

        $this->closed = fclose($this->handler);

        return $this->closed;
    }

    /**
     * @return bool
     */
    public function isManualHeaders(): bool
    {
        return $this->manualHeaders;
    }

    /**
     * @return bool
     */
    public function isClosed(): bool
    {
        return $this->closed;
    }

    /**
     * Closes the resource when the object is destroyed.
     */
    public function __destruct()
    {
        $this->close();
    }

    /**
     * @throws \RuntimeException
     */
    protected function assertOpened(): void
    {
        if ($this->closed) {
            throw new \RuntimeException("{$this->getResourceName()} was closed earlier");
        }
    }

    /**
     * @param array $headers
     *
     * @throws \UnexpectedValueException
     *
     * @return array
     */
    protected function parseHeaders(array $headers = null): array
    {
        // If headers are not passed in the constructor but file is readable, try to read headers from file
        if (null === $headers) {
            $headers = fgetcsv($this->handler, 0, $this->delimiter, $this->enclosure, $this->escape);
            if (false === $headers || 0 === \count($headers)) {
                throw new \UnexpectedValueException("Unable to read headers for {$this->getResourceName()}");
            }
            // Remove BOM if any
            $bom = pack('H*', 'EFBBBF');
            $headers[0] = preg_replace("/^{$bom}/", '', $headers[0]);

            return $headers;
        }

        $this->manualHeaders = true;
        if (null === $headers || !\is_array($headers)) {
            throw new \UnexpectedValueException(
                "Invalid headers for {$this->getResourceName()}, you need to pass the headers manually"
            );
        }
        if (0 === \count($headers)) {
            throw new \UnexpectedValueException(
                "Empty headers for {$this->getResourceName()}, you need to pass the headers manually"
            );
        }

        return $headers;
    }

    /**
     * @return string
     */
    protected function getResourceName(): string
    {
        return "CSV resource '{$this->handler}'";
    }
}


<?php
/*
 * This file is part of the CleverAge/ProcessBundle package.
 *
 * Copyright (C) 2017-2018 Clever-Age
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace CleverAge\ProcessBundle\Transformer;

use CleverAge\ProcessBundle\Exception\TransformerException;
use CleverAge\ProcessBundle\Registry\TransformerRegistry;
use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver;

/**
 * Trait TransformerTrait
 *
 * @package CleverAge\ProcessBundle\Transformer
 * @author  Madeline Veyrenc <mveyrenc@clever-age.com>
 */
trait TransformerTrait
{

    /** @var TransformerRegistry */
    protected $transformerRegistry;

    /**
     * @param array $transformers
     * @param mixed $value
     *
     * @throws \CleverAge\ProcessBundle\Exception\TransformerException
     *
     * @return mixed
     */
    protected function applyTransformers(array $transformers, $value)
    {
        /** @noinspection ForeachSourceInspection */
        foreach ($transformers as $transformerCode => $transformerOptions) {
            try {
                $transformerCode = $this->getCleanedTransfomerCode($transformerCode);
                $transformer = $this->transformerRegistry->getTransformer($transformerCode);
                $value = $transformer->transform(
                    $value,
                    $transformerOptions ?: []
                );
            } catch (\Throwable $exception) {
                throw new TransformerException($transformerCode, 0, $exception);
            }
        }

        return $value;
    }

    /**
     * This allows to use transformer codes suffixes to avoid limitations to the "transformers" option using codes as
     * keys This way you can chain multiple times the same transformer. Without this, it would silently call only the
     * 1st one.
     *
     * @example
     * transformers:
     *     callback#1:
     *         callback: array_filter
     *     callback#2:
     *         callback: array_reverse
     *
     *
     * @param string $transformerCode
     *
     * @throws \CleverAge\ProcessBundle\Exception\MissingTransformerException
     *
     * @return string
     */
    protected function getCleanedTransfomerCode(string $transformerCode)
    {
        $match = preg_match('/([^#]+)(#[\d]+)?/', $transformerCode, $parts);

        if (1 === $match && $this->transformerRegistry->hasTransformer($parts[1])) {
            return $parts[1];
        }

        return $transformerCode;
    }

    /**
     * @param \Symfony\Component\OptionsResolver\OptionsResolver $resolver
     * @throws \CleverAge\ProcessBundle\Exception\MissingTransformerException
     * @throws \Symfony\Component\OptionsResolver\Exception\ExceptionInterface
     * @throws \Symfony\Component\OptionsResolver\Exception\UndefinedOptionsException
     * @throws \Symfony\Component\OptionsResolver\Exception\OptionDefinitionException
     * @throws \Symfony\Component\OptionsResolver\Exception\NoSuchOptionException
     * @throws \Symfony\Component\OptionsResolver\Exception\MissingOptionsException
     * @throws \Symfony\Component\OptionsResolver\Exception\InvalidOptionsException
     * @throws \Symfony\Component\OptionsResolver\Exception\AccessException
     */
    protected function configureTransformersOptions(OptionsResolver $resolver)
    {
        $resolver->setDefault('transformers', []);
        $resolver->setAllowedTypes('transformers', ['array']);
        /** @noinspection PhpUnusedParameterInspection */
        $resolver->setNormalizer( // This logic is duplicated from the array_map transformer @todo fix me
            'transformers',
            function (Options $options, $transformers) {
                /** @var array $transformers */
                foreach ($transformers as $transformerCode => &$transformerOptions) {
                    $transformerOptionsResolver = new OptionsResolver();
                    $transformerCode = $this->getCleanedTransfomerCode($transformerCode);
                    $transformer = $this->transformerRegistry->getTransformer($transformerCode);
                    if ($transformer instanceof ConfigurableTransformerInterface) {
                        $transformer->configureOptions($transformerOptionsResolver);
                        $transformerOptions = $transformerOptionsResolver->resolve(
                            $transformerOptions ?? []
                        );
                    }
                }

                return $transformers;
            }
        );
    }

}


<?php
/*
* This file is part of the CleverAge/ProcessBundle package.
*
* Copyright (C) 2017-2018 Clever-Age
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace CleverAge\ProcessBundle\Task;

use CleverAge\ProcessBundle\Configuration\TaskConfiguration;
use CleverAge\ProcessBundle\Exception\TransformerException;
use CleverAge\ProcessBundle\Model\AbstractConfigurableTask;
use CleverAge\ProcessBundle\Model\ProcessState;
use CleverAge\ProcessBundle\Registry\TransformerRegistry;
use CleverAge\ProcessBundle\Transformer\TransformerInterface;
use CleverAge\ProcessBundle\Transformer\TransformerTrait;
use Psr\Log\LoggerInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

/**
 * Transform an array of data based on mapping and sub-transformers
 *
 * @author Valentin Clavreul <vclavreul@clever-age.com>
 * @author Vincent Chalnot <vchalnot@clever-age.com>
 */
class TransformerTask extends AbstractConfigurableTask
{
    use TransformerTrait;

    /** @var LoggerInterface */
    protected $logger;

    /** @var TransformerInterface */
    protected $transformer;

    /**
     * @param LoggerInterface     $logger
     * @param TransformerRegistry $transformerRegistry
     *
     */
    public function __construct(LoggerInterface $logger, TransformerRegistry $transformerRegistry)
    {
        $this->logger = $logger;
        $this->transformerRegistry = $transformerRegistry;
    }

    /**
     * @param ProcessState $state
     *
     * @throws \Symfony\Component\OptionsResolver\Exception\ExceptionInterface
     * @throws \CleverAge\ProcessBundle\Exception\MissingTransformerException
     * @throws \UnexpectedValueException
     * @throws \Symfony\Component\OptionsResolver\Exception\AccessException
     * @throws \Symfony\Component\OptionsResolver\Exception\InvalidOptionsException
     * @throws \Symfony\Component\OptionsResolver\Exception\MissingOptionsException
     * @throws \Symfony\Component\OptionsResolver\Exception\NoSuchOptionException
     * @throws \Symfony\Component\OptionsResolver\Exception\OptionDefinitionException
     * @throws \Symfony\Component\OptionsResolver\Exception\UndefinedOptionsException
     */
    public function execute(ProcessState $state)
    {
        $output = null;
        $options = $this->getOptions($state);

        try {
            $output = $this->applyTransformers($options['transformers'], $state->getInput());
        } catch (TransformerException $e) {
            $state->addErrorContextValue('error', $e->getPrevious()->getMessage());
            $state->setException($e);

            return;
        }
        $state->setOutput($output);
    }

    /**
     * @param OptionsResolver $resolver
     *
     * @throws \Symfony\Component\OptionsResolver\Exception\UndefinedOptionsException
     * @throws \Symfony\Component\OptionsResolver\Exception\OptionDefinitionException
     * @throws \Symfony\Component\OptionsResolver\Exception\NoSuchOptionException
     * @throws \Symfony\Component\OptionsResolver\Exception\MissingOptionsException
     * @throws \Symfony\Component\OptionsResolver\Exception\InvalidOptionsException
     * @throws \Symfony\Component\OptionsResolver\Exception\AccessException
     * @throws \CleverAge\ProcessBundle\Exception\MissingTransformerException
     * @throws \Symfony\Component\OptionsResolver\Exception\ExceptionInterface
     */
    protected function configureOptions(OptionsResolver $resolver)
    {
        $this->configureTransformersOptions($resolver);
    }
}


<?php
/*
* This file is part of the CleverAge/ProcessBundle package.
*
* Copyright (C) 2017-2018 Clever-Age
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace CleverAge\ProcessBundle\Task\Doctrine;

use Doctrine\ORM\EntityRepository;
use Psr\Log\LogLevel;
use Symfony\Component\OptionsResolver\OptionsResolver;

/**
 * Easily extendable task to query entities in their repository
 *
 * @author Valentin Clavreul <vclavreul@clever-age.com>
 * @author Vincent Chalnot <vchalnot@clever-age.com>
 */
abstract class AbstractDoctrineQueryTask extends AbstractDoctrineTask
{
    /**
     * {@inheritdoc}
     */
    protected function configureOptions(OptionsResolver $resolver)
    {
        parent::configureOptions($resolver);
        $resolver->setRequired(
            [
                'class_name',
            ]
        );
        $resolver->setAllowedTypes('class_name', ['string']);
        $resolver->setDefaults(
            [
                'criteria' => [],
                'order_by' => [],
                'limit' => null,
                'offset' => null,
                'empty_log_level' => LogLevel::WARNING,
            ]
        );
        $resolver->setAllowedTypes('criteria', ['array']);
        $resolver->setAllowedTypes('order_by', ['array']);
        $resolver->setAllowedTypes('limit', ['NULL', 'integer']);
        $resolver->setAllowedTypes('offset', ['NULL', 'integer']);
        $resolver->setAllowedValues(
            'empty_log_level',
            [
                LogLevel::ALERT,
                LogLevel::CRITICAL,
                LogLevel::DEBUG,
                LogLevel::EMERGENCY,
                LogLevel::ERROR,
                LogLevel::INFO,
                LogLevel::NOTICE,
                LogLevel::WARNING,
            ]
        );
    }

    /**
     * @param EntityRepository $repository
     * @param array            $criteria
     * @param array            $orderBy
     * @param int              $limit
     * @param int              $offset
     *
     * @throws \UnexpectedValueException
     *
     * @return \Doctrine\ORM\QueryBuilder
     */
    protected function getQueryBuilder(
        EntityRepository $repository,
        array $criteria,
        array $orderBy,
        $limit = null,
        $offset = null
    ) {
        $qb = $repository->createQueryBuilder('e');
        foreach ($criteria as $field => $value) {
            if (preg_match('/[^a-zA-Z0-9]/', $field)) {
                throw new \UnexpectedValueException("Forbidden field name '{$field}'");
            }
            $parameterName = uniqid('param', false);
            if (null === $value) {
                $qb->andWhere("e.{$field} IS NULL");
            } else {
                if (\is_array($value)) {
                    $qb->andWhere("e.{$field} IN (:{$parameterName})");
                } else {
                    $qb->andWhere("e.{$field} = :{$parameterName}");
                }
                $qb->setParameter($parameterName, $value);
            }
        }
        /** @noinspection ForeachSourceInspection */
        foreach ($orderBy as $field => $order) {
            $qb->addOrderBy("e.{$field}", $order);
        }
        if (null !== $limit) {
            $qb->setMaxResults($limit);
        }
        if (null !== $offset) {
            $qb->setFirstResult($offset);
        }

        return $qb;
    }
}


<?php
/*
 * This file is part of the CleverAge/ProcessBundle package.
 *
 * Copyright (C) 2017-2018 Clever-Age
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace CleverAge\ProcessBundle\Transformer;

use Symfony\Component\OptionsResolver\OptionsResolver;

/**
 * Transform a value to another value based on a conversion table
 *
 * @author Valentin Clavreul <vclavreul@clever-age.com>
 * @author Vincent Chalnot <vchalnot@clever-age.com>
 */
class ConvertValueTransformer implements ConfigurableTransformerInterface
{
    /**
     * Must return the transformed $value
     *
     * @param mixed $value
     * @param array $options
     *
     * @throws \Symfony\Component\OptionsResolver\Exception\ExceptionInterface
     * @throws \UnexpectedValueException
     *
     * @return mixed $value
     */
    public function transform($value, array $options = [])
    {
        if (null === $value) {
            return $value;
        }

        $resolver = new OptionsResolver();
        $this->configureOptions($resolver);
        $options = $resolver->resolve($options);

        if (!is_string($value) && !is_int($value)) { // If not a valid array index
            if (!$options['auto_cast']) {
                $type = gettype($value);
                throw new \UnexpectedValueException(
                    "Value of type {$type} is not a valid array index, set auto_cast to true to cast it to a string"
                );
            }
            $value = (string) $value; // Let's cast it to string
        }

        if (!array_key_exists($value, $options['map'])) {
            if ($options['keep_missing']) {
                return $value;
            }
            if (!$options['ignore_missing']) {
                throw new \UnexpectedValueException("Missing value in map '{$value}'");
            }

            return null;
        }

        return $options['map'][$value];
    }

    /**
     * Returns the unique code to identify the transformer
     *
     * @return string
     */
    public function getCode()
    {
        return 'convert_value';
    }

    /**
     * @param OptionsResolver $resolver
     *
     * @throws \Symfony\Component\OptionsResolver\Exception\ExceptionInterface
     */
    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setRequired(
            [
                'map',
            ]
        );
        $resolver->setAllowedTypes('map', ['array']);
        $resolver->setDefaults(
            [
                'ignore_missing' => false,
                'keep_missing' => false,
                'auto_cast' => false,
            ]
        );
        $resolver->setAllowedTypes('ignore_missing', ['boolean']);
        $resolver->setAllowedTypes('keep_missing', ['boolean']);
        $resolver->setAllowedTypes('auto_cast', ['boolean']);
    }
}


<?php
/*
 * This file is part of the CleverAge/ProcessBundle package.
 *
 * Copyright (C) 2017-2018 Clever-Age
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace CleverAge\ProcessBundle\Task\File;

use CleverAge\ProcessBundle\Model\ProcessState;
use CleverAge\ProcessBundle\Task\AbstractIterableOutputTask;
use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Yaml\Yaml;

/**
 * Reads a YAML file and iterate over its root elements
 *
 * @author Valentin Clavreul <vclavreul@clever-age.com>
 * @author Vincent Chalnot <vchalnot@clever-age.com>
 */
class YamlReaderTask extends AbstractIterableOutputTask
{
    /**
     * @param OptionsResolver $resolver
     *
     * @throws \Symfony\Component\OptionsResolver\Exception\UndefinedOptionsException
     * @throws \Symfony\Component\OptionsResolver\Exception\AccessException
     * @throws \UnexpectedValueException
     */
    protected function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setRequired(
            [
                'file_path',
            ]
        );
        $resolver->setAllowedTypes('file_path', ['string']);
        $resolver->setNormalizer(
            'file_path',
            function (Options $options, $value) {
                if (!file_exists($value)) {
                    throw new \UnexpectedValueException("File not found: {$value}");
                }

                return $value;
            }
        );
    }

    /**
     * @param ProcessState $state
     *
     * @throws \InvalidArgumentException
     * @throws \Symfony\Component\Yaml\Exception\ParseException
     * @throws \Symfony\Component\OptionsResolver\Exception\ExceptionInterface
     *
     * @return \Iterator
     */
    protected function initializeIterator(ProcessState $state): \Iterator
    {
        $filePath = $this->getOption($state, 'file_path');
        $content = Yaml::parseFile($filePath);
        if (!\is_array($content)) {
            throw new \InvalidArgumentException("File content is not an array: {$filePath}");
        }

        return new \ArrayIterator($content);
    }
}


<?php
/*
 * This file is part of the CleverAge/ProcessBundle package.
 *
 * Copyright (C) 2017-2018 Clever-Age
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace CleverAge\ProcessBundle\Task\Event;

use CleverAge\ProcessBundle\Model\AbstractConfigurableTask;
use CleverAge\ProcessBundle\Model\ProcessState;
use CleverAge\ProcessBundle\Event\EventDispatcherTaskEvent;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

/**
 * Call the Symfony event dispatcher
 * If defined as passive (which is the default), it automatically set the output from the input
 *
 * @author  Valentin Clavreul <vclavreul@clever-age.com>
 * @author  Vincent Chalnot <vchalnot@clever-age.com>
 * @author  Madeline Veyrenc <mveyrenc@clever-age.com>
 */
class EventDispatcherTask extends AbstractConfigurableTask
{
    /** @var EventDispatcherInterface */
    protected $eventDispatcher;

    /**
     * @param EventDispatcherInterface $eventDispatcher
     */
    public function __construct(EventDispatcherInterface $eventDispatcher)
    {
        $this->eventDispatcher = $eventDispatcher;
    }

    /**
     * @param ProcessState $state
     *
     * @throws \Symfony\Component\OptionsResolver\Exception\ExceptionInterface
     */
    public function execute(ProcessState $state)
    {
        $options = $this->getOptions($state);
        if ($options['passive']) {
            $state->setOutput($state->getInput());
        }

        $event = new EventDispatcherTaskEvent($state);

        $this->eventDispatcher->dispatch($options['event_name'], $event);
    }

    /**
     * @param OptionsResolver $resolver
     *
     * @throws \Symfony\Component\OptionsResolver\Exception\AccessException
     * @throws \Symfony\Component\OptionsResolver\Exception\UndefinedOptionsException
     */
    protected function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setRequired(
            [
                'event_name',
            ]
        );
        $resolver->setDefault('passive', true);
        $resolver->setAllowedTypes('event_name', ['string']);
        $resolver->setAllowedTypes('passive', ['boolean']);
    }
}


<?php
/*
 * This file is part of the CleverAge/ProcessBundle package.
 *
 * Copyright (C) 2017-2018 Clever-Age
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace CleverAge\ProcessBundle\Transformer;

use Symfony\Component\OptionsResolver\OptionsResolver;

/**
 * Transformer aiming to take a date as an input (object or string) and format it according to options.
 * In input it takes any value understood by \DateTime.
 *
 * @example in YML config
 * transformers:
 *     date_format:
 *         format: Y-m-d
 *
 * @deprecated the input string value will be removed in next version, use date_parser just before
 * @TODO v1.2 : remove string input
 */
class DateFormatTransformer implements ConfigurableTransformerInterface
{
    /**
     * @param mixed $value
     * @param array $options
     *
     * @return mixed|string
     */
    public function transform($value, array $options = [])
    {
        if (!$value) {
            return $value;
        }

        if ($value instanceof \DateTime) {
            $date = $value;
        } elseif (is_string($value)) {
            $date = new \DateTime($value);
        } else {
            throw new \UnexpectedValueException('Given value cannot be parsed into a date');
        }

        return $date->format($options['format']);
    }

    /**
     * @return string
     */
    public function getCode()
    {
        return 'date_format';
    }

    /**
     * @param OptionsResolver $resolver
     *
     * @throws \Symfony\Component\OptionsResolver\Exception\UndefinedOptionsException
     * @throws \Symfony\Component\OptionsResolver\Exception\AccessException
     */
    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setRequired('format');
        $resolver->setAllowedTypes('format', 'string');
    }
}


<?php
/*
 * This file is part of the CleverAge/ProcessBundle package.
 *
 * Copyright (C) 2017-2018 Clever-Age
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace CleverAge\ProcessBundle\Transformer;

use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;

/**
 * Denormalize the given value based on options
 *
 * @author Valentin Clavreul <vclavreul@clever-age.com>
 * @author Vincent Chalnot <vchalnot@clever-age.com>
 */
class DenormalizeTransformer implements ConfigurableTransformerInterface
{
    /** @var DenormalizerInterface */
    protected $denormalizer;

    /**
     * @param DenormalizerInterface $denormalizer
     */
    public function __construct(DenormalizerInterface $denormalizer)
    {
        $this->denormalizer = $denormalizer;
    }

    /**
     * @param OptionsResolver $resolver
     *
     * @throws \Symfony\Component\OptionsResolver\Exception\AccessException
     * @throws \Symfony\Component\OptionsResolver\Exception\UndefinedOptionsException
     */
    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setRequired(
            [
                'class',
            ]
        );
        $resolver->setAllowedTypes('class', ['string']);
        $resolver->setDefaults(
            [
                'format' => null,
                'context' => [],
            ]
        );
        $resolver->setAllowedTypes('format', ['NULL', 'string']);
        $resolver->setAllowedTypes('context', ['array']);
    }

    /**
     * @param mixed $value
     * @param array $options
     *
     * @throws \Symfony\Component\OptionsResolver\Exception\UndefinedOptionsException
     * @throws \Symfony\Component\OptionsResolver\Exception\OptionDefinitionException
     * @throws \Symfony\Component\OptionsResolver\Exception\NoSuchOptionException
     * @throws \Symfony\Component\OptionsResolver\Exception\MissingOptionsException
     * @throws \Symfony\Component\OptionsResolver\Exception\InvalidOptionsException
     * @throws \Symfony\Component\OptionsResolver\Exception\AccessException
     * @throws \Symfony\Component\Serializer\Exception\UnexpectedValueException
     * @throws \Symfony\Component\Serializer\Exception\RuntimeException
     * @throws \Symfony\Component\Serializer\Exception\LogicException
     * @throws \Symfony\Component\Serializer\Exception\InvalidArgumentException
     * @throws \Symfony\Component\Serializer\Exception\ExtraAttributesException
     * @throws \Symfony\Component\Serializer\Exception\BadMethodCallException
     * @throws \Symfony\Component\OptionsResolver\Exception\ExceptionInterface
     *
     * @return mixed
     */
    public function transform($value, array $options = [])
    {
        $resolver = new OptionsResolver();
        $this->configureOptions($resolver);
        $options = $resolver->resolve($options);

        return $this->denormalizer->denormalize(
            $value,
            $options['class'],
            $options['format'],
            $options['context']
        );
    }

    /**
     * Returns the unique code to identify the transformer
     *
     * @return string
     */
    public function getCode()
    {
        return 'denormalize';
    }
}


<?php
/*
 * This file is part of the CleverAge/ProcessBundle package.
 *
 * Copyright (C) 2017-2018 Clever-Age
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace CleverAge\ProcessBundle\Task;

use CleverAge\ProcessBundle\Model\AbstractConfigurableTask;
use CleverAge\ProcessBundle\Model\ProcessState;
use CleverAge\ProcessBundle\Transformer\ConditionTrait;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\PropertyAccess\PropertyAccessor;

/**
 * Skip inputs under given matching conditions
 * - equality is softly checked
 * - unexisting key is the same as null
 */
class FilterTask extends AbstractConfigurableTask
{

    use ConditionTrait;

    /**
     * {@inheritDoc}
     */
    public function initialize(ProcessState $state)
    {
        parent::initialize($state);
        $this->accessor = new PropertyAccessor();
    }

    /**
     * {@inheritDoc}
     *
     * @throws \Symfony\Component\OptionsResolver\Exception\ExceptionInterface
     * @throws \Symfony\Component\PropertyAccess\Exception\UnexpectedTypeException
     * @throws \Symfony\Component\PropertyAccess\Exception\InvalidArgumentException
     * @throws \Symfony\Component\PropertyAccess\Exception\AccessException
     * @throws \InvalidArgumentException
     */
    public function execute(ProcessState $state)
    {
        $input = $state->getInput();
        if (!$this->checkCondition($input, $this->getOptions($state))) {
            $state->setSkipped(true);

            return;
        }

        $state->setOutput($input);
    }

    /**
     * {@inheritDoc}
     */
    protected function configureOptions(OptionsResolver $resolver)
    {
        $this->configureConditionOptions($resolver);
    }
}


<?php
/*
 * This file is part of the CleverAge/ProcessBundle package.
 *
 * Copyright (C) 2017-2018 Clever-Age
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace CleverAge\ProcessBundle\Registry;

use function array_key_exists;
use function array_keys;
use CleverAge\ProcessBundle\Configuration\ProcessConfiguration;
use CleverAge\ProcessBundle\Configuration\TaskConfiguration;
use CleverAge\ProcessBundle\Exception\MissingProcessException;
use Psr\Log\LogLevel;

/**
 * Build and holds all the process configurations
 *
 * @author Valentin Clavreul <vclavreul@clever-age.com>
 * @author Vincent Chalnot <vchalnot@clever-age.com>
 */
class ProcessConfigurationRegistry
{
    /** @var array */
    protected $rawConfiguration;

    /** @var string */
    protected $defaultErrorStrategy;

    /** @var ProcessConfiguration[] */
    protected $processConfigurations = [];

    /**
     * @param array  $rawConfiguration
     * @param string $defaultErrorStrategy
     */
    public function __construct(array $rawConfiguration, string $defaultErrorStrategy)
    {
        $this->rawConfiguration = $rawConfiguration;
        $this->defaultErrorStrategy = $defaultErrorStrategy;
    }

    /**
     * @param string $processCode
     *
     * @throws MissingProcessException
     *
     * @return ProcessConfiguration
     */
    public function getProcessConfiguration(string $processCode): ProcessConfiguration
    {
        if (!$this->hasProcessConfiguration($processCode)) {
            throw new MissingProcessException($processCode);
        }
        $this->resolveConfiguration($processCode);

        return $this->processConfigurations[$processCode];
    }

    /**
     * @return ProcessConfiguration[]
     */
    public function getProcessConfigurations(): array
    {
        foreach (array_keys($this->rawConfiguration) as $processCode) {
            $this->resolveConfiguration($processCode);
        }

        return $this->processConfigurations;
    }

    /**
     * @param string $processCode
     *
     * @return bool
     */
    public function hasProcessConfiguration(string $processCode): bool
    {
        return array_key_exists($processCode, $this->rawConfiguration);
    }

    /**
     * @param string $processCode
     */
    protected function resolveConfiguration(string $processCode): void
    {
        if (array_key_exists($processCode, $this->processConfigurations)) {
            return;
        }
        $rawProcessConfiguration = $this->rawConfiguration[$processCode];
        /** @var TaskConfiguration[] $taskConfigurations */
        $taskConfigurations = [];
        /** @noinspection ForeachSourceInspection */
        foreach ($rawProcessConfiguration['tasks'] as $taskCode => $rawTaskConfiguration) {
            if (\count($rawTaskConfiguration['errors']) > 0) {
                if (\count($rawTaskConfiguration['error_outputs']) > 0) {
                    $m = "Don't define both 'errors' and 'error_outputs' for task {$taskCode}, these options ";
                    $m .= "are the same, 'errors' is deprecated, just use the new one 'error_outputs'";
                    throw new \LogicException($m);
                }
                $rawTaskConfiguration['error_outputs'] = $rawTaskConfiguration['errors'];
            }
            $taskConfigurations[$taskCode] = new TaskConfiguration(
                $taskCode,
                $rawTaskConfiguration['service'],
                $rawTaskConfiguration['options'],
                $rawTaskConfiguration['description'],
                $rawTaskConfiguration['help'],
                $rawTaskConfiguration['outputs'],
                $rawTaskConfiguration['error_outputs'],
                $rawTaskConfiguration['error_strategy'] ?? $this->defaultErrorStrategy,
                $rawTaskConfiguration['log_errors'] ? $rawTaskConfiguration['log_level'] : LogLevel::DEBUG
            );
        }

        $processConfig = new ProcessConfiguration(
            $processCode,
            $taskConfigurations,
            $rawProcessConfiguration['options'],
            $rawProcessConfiguration['entry_point'],
            $rawProcessConfiguration['end_point'],
            $rawProcessConfiguration['description'],
            $rawProcessConfiguration['help'],
            $rawProcessConfiguration['public']
        );

        // Set links between tasks
        foreach ($taskConfigurations as $taskConfig) {
            foreach ($taskConfig->getOutputs() as $nextTaskCode) {
                $nextTaskConfig = $processConfig->getTaskConfiguration($nextTaskCode);
                $taskConfig->addNextTaskConfiguration($nextTaskConfig);
                $nextTaskConfig->addPreviousTaskConfiguration($taskConfig);
            }

            foreach ($taskConfig->getErrorOutputs() as $errorTaskCode) {
                $errorTaskConfig = $processConfig->getTaskConfiguration($errorTaskCode);
                $taskConfig->addErrorTaskConfiguration($errorTaskConfig);
                $errorTaskConfig->addPreviousTaskConfiguration($taskConfig);
            }
        }

        // Mark error branches
        foreach ($taskConfigurations as $taskConfig) {
            foreach ($taskConfig->getErrorTasksConfigurations() as $errorTaskConfig) {
                $this->markErrorBranch($errorTaskConfig);
            }
        }

        // Un-mark non-error branch (may be important for task that are in both branches)
        foreach ($processConfig->getMainTaskGroup() as $taskCode) {
            $task = $taskConfigurations[$taskCode];
            if ($task->isRoot()) {
                $this->markErrorBranch($task, false);
            }
        }

        $this->processConfigurations[$processCode] = $processConfig;
    }

    /**
     * @param TaskConfiguration $taskConfig
     * @param bool              $isErrorBranch
     */
    protected function markErrorBranch(TaskConfiguration $taskConfig, $isErrorBranch = true): void
    {
        if ($taskConfig->isInErrorBranch() !== $isErrorBranch) {
            $taskConfig->setInErrorBranch($isErrorBranch);
            foreach ($taskConfig->getNextTasksConfigurations() as $nextTasksConfig) {
                $this->markErrorBranch($nextTasksConfig, $isErrorBranch);
            }
        }
    }
}


<?php
/*
 * This file is part of the CleverAge/ProcessBundle package.
 *
 * Copyright (C) 2017-2018 Clever-Age
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace CleverAge\ProcessBundle\Configuration;

use CleverAge\ProcessBundle\Exception\CircularProcessException;
use CleverAge\ProcessBundle\Exception\MissingTaskConfigurationException;

/**
 * Holds the processes configuration to launch a task
 *
 * @author Valentin Clavreul <vclavreul@clever-age.com>
 * @author Vincent Chalnot <vchalnot@clever-age.com>
 */
class ProcessConfiguration
{
    /** @var string */
    protected $code;

    /** @var array */
    protected $options = [];

    /** @var TaskConfiguration */
    protected $entryPoint;

    /** @var TaskConfiguration */
    protected $endPoint;

    /** @var string */
    protected $description;

    /** @var string */
    protected $help;

    /** @var boolean */
    protected $public;

    /** @var TaskConfiguration[] */
    protected $taskConfigurations;

    /** @var array */
    protected $dependencyGroups;

    /** @var array */
    protected $mainTaskGroup;

    /**
     * @param string              $code
     * @param TaskConfiguration[] $taskConfigurations
     * @param array               $options
     * @param string              $entryPoint
     * @param string              $endPoint
     * @param string              $description
     * @param string              $help
     * @param bool                $public
     */
    public function __construct(
        $code,
        array $taskConfigurations,
        array $options = [],
        $entryPoint = null,
        $endPoint = null,
        $description = '',
        $help = '',
        $public = true
    )
    {
        $this->code = $code;
        $this->taskConfigurations = $taskConfigurations;
        $this->options = $options;
        $this->entryPoint = $entryPoint;
        $this->endPoint = $endPoint;
        $this->description = $description;
        $this->public = $public;
        $this->help = $help;
    }

    /**
     * @return string
     */
    public function getCode(): string
    {
        return $this->code;
    }

    /**
     * @return array
     */
    public function getOptions(): array
    {
        return $this->options;
    }

    /**
     * @throws \CleverAge\ProcessBundle\Exception\MissingTaskConfigurationException
     *
     * @return TaskConfiguration|null
     */
    public function getEntryPoint()
    {
        if (null === $this->entryPoint) {
            return null;
        }

        return $this->getTaskConfiguration($this->entryPoint);
    }

    /**
     * @throws \CleverAge\ProcessBundle\Exception\MissingTaskConfigurationException
     *
     * @return TaskConfiguration|null
     */
    public function getEndPoint()
    {
        if (null === $this->endPoint) {
            return null;
        }

        return $this->getTaskConfiguration($this->endPoint);
    }

    /**
     * @return string
     */
    public function getDescription(): string
    {
        return $this->description;
    }

    /**
     * @return string
     */
    public function getHelp(): string
    {
        return $this->help;
    }

    /**
     * @return bool
     */
    public function isPublic(): bool
    {
        return $this->public;
    }

    /**
     * @return bool
     */
    public function isPrivate(): bool
    {
        return !$this->public;
    }

    /**
     * @return TaskConfiguration[]
     */
    public function getTaskConfigurations(): array
    {
        return $this->taskConfigurations;
    }

    /**
     * @param string $taskCode
     *
     * @throws \CleverAge\ProcessBundle\Exception\MissingTaskConfigurationException
     *
     * @return TaskConfiguration
     */
    public function getTaskConfiguration(string $taskCode): TaskConfiguration
    {
        if (!array_key_exists($taskCode, $this->taskConfigurations)) {
            throw new MissingTaskConfigurationException($taskCode);
        }

        return $this->taskConfigurations[$taskCode];
    }

    /**
     * Group all task by dependencies
     *
     * If one task depend from another, it should come after
     *
     * @throws \CleverAge\ProcessBundle\Exception\MissingTaskConfigurationException
     *
     * @return array
     */
    public function getDependencyGroups(): array
    {
        if (null === $this->dependencyGroups) {
            $this->dependencyGroups = [];
            foreach ($this->getTaskConfigurations() as $taskConfiguration) {
                $isInBranch = false;
                foreach ($this->dependencyGroups as $branch) {
                    if (\in_array($taskConfiguration->getCode(), $branch, true)) {
                        $isInBranch = true;
                        break;
                    }
                }

                if (!$isInBranch) {
                    $dependencies = $this->buildDependencies($taskConfiguration);
                    $dependencies = $this->sortDependencies($dependencies);

                    $this->dependencyGroups[] = $dependencies;
                }
            }
        }

        return $this->dependencyGroups;
    }

    /**
     * Get the main task group that will be executed
     * It may be defined by the entry_point, or the end_point or simply the first task
     *
     * If one task depend from another, it should come after
     *
     * @throws \CleverAge\ProcessBundle\Exception\MissingTaskConfigurationException
     *
     * @return array
     */
    public function getMainTaskGroup(): array
    {
        if (null === $this->mainTaskGroup) {
            $mainTask = $this->getMainTask();

            foreach ($this->getDependencyGroups() as $branch) {
                if (\in_array($mainTask->getCode(), $branch, true)) {
                    $this->mainTaskGroup = $branch;
                    break;
                }
            }
        }

        return $this->mainTaskGroup;
    }

    /**
     * Get the most important task (may be the entry or end task, or simply the first)
     * Used to check which tree should be used
     *
     * @throws \CleverAge\ProcessBundle\Exception\MissingTaskConfigurationException
     *
     * @return TaskConfiguration
     */
    public function getMainTask()
    {
        $entryTask = $this->getEntryPoint();
        if (!$entryTask) {
            $entryTask = $this->getEndPoint();
        }
        if (!$entryTask) {
            $entryTask = reset($this->taskConfigurations);
        }

        return $entryTask;
    }

    /**
     * Assert the process does not contain circular dependencies
     *
     * @throws CircularProcessException
     */
    public function checkCircularDependencies()
    {
        $taskConfigurations = $this->getTaskConfigurations();

        foreach ($taskConfigurations as $taskConfiguration) {
            foreach ($taskConfiguration->getPreviousTasksConfigurations() as $previousTaskConfig) {
                if ($taskConfiguration->getCode() === $previousTaskConfig->getCode()) {
                    throw CircularProcessException::create($this->getCode(), $taskConfiguration->getCode());
                }
            }
            if ($taskConfiguration->hasAncestor($taskConfiguration)) {
                throw CircularProcessException::create($this->getCode(), $taskConfiguration->getCode());
            }
        }
    }

    /**
     * Cross all relations of a task to find all dependencies, and append them to the given array
     *
     * @param TaskConfiguration $taskConfig
     * @param array             $dependencies
     *
     * @return array
     */
    protected function buildDependencies(TaskConfiguration $taskConfig, array &$dependencies = [])
    {
        $code = $taskConfig->getCode();

        // May have been added by previous task
        if (!\in_array($code, $dependencies, true)) {
            $dependencies[] = $code;

            foreach ($taskConfig->getPreviousTasksConfigurations() as $previousTasksConfig) {
                $this->buildDependencies($previousTasksConfig, $dependencies);
            }

            foreach ($taskConfig->getNextTasksConfigurations() as $nextTasksConfig) {
                $this->buildDependencies($nextTasksConfig, $dependencies);
            }

            foreach ($taskConfig->getErrorTasksConfigurations() as $errorTasksConfig) {
                $this->buildDependencies($errorTasksConfig, $dependencies);
            }
        }

        return $dependencies;
    }

    /**
     * Sort the tasks by dependencies
     *
     * @param array $dependencies
     *
     * @throws \CleverAge\ProcessBundle\Exception\MissingTaskConfigurationException
     *
     * @return array
     */
    protected function sortDependencies(array $dependencies)
    {
        if (\count($dependencies) <= 1) {
            return $dependencies;
        }

        try {
            $this->checkCircularDependencies();
        } catch (CircularProcessException $e) {
            // Skipping the sort phase, it will throw later, on runtime
            return $dependencies;
        }

        /** @var int $midOffset */
        $midOffset = \count($dependencies) / 2;
        $midTaskCode = $dependencies[$midOffset];
        $midTask = $this->getTaskConfiguration($midTaskCode);

        $previousTasks = [];
        $independentTasks = [];
        $nextTasks = [];

        foreach ($dependencies as $taskCode) {
            if ($taskCode !== $midTaskCode) {
                $task = $this->getTaskConfiguration($taskCode);

                if ($midTask->hasAncestor($task)) {
                    $previousTasks[] = $taskCode;
                } elseif ($midTask->hasDescendant($task)) {
                    $nextTasks[] = $taskCode;
                } else {
                    $independentTasks[] = $taskCode;
                }
            }
        }

        $previousTasks = $this->sortDependencies($previousTasks);
        $independentTasks = $this->sortDependencies($independentTasks);
        $nextTasks = $this->sortDependencies($nextTasks);

        return array_merge($previousTasks, [$midTaskCode], $independentTasks, $nextTasks);
    }
}


<?php
/*
* This file is part of the CleverAge/ProcessBundle package.
*
* Copyright (C) 2017-2018 Clever-Age
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace CleverAge\ProcessBundle\Model;

use Symfony\Component\OptionsResolver\OptionsResolver;

/**
 * Allow the task to configure it's options, set default basic options for errors handling
 *
 * @author Valentin Clavreul <vclavreul@clever-age.com>
 * @author Vincent Chalnot <vchalnot@clever-age.com>
 */
abstract class AbstractConfigurableTask implements InitializableTaskInterface
{
    /** @var array */
    protected $options;

    /**
     * Only validate the options at initialization, ensuring that the task will not fail at runtime
     *
     * @param ProcessState $state
     *
     * @throws \Symfony\Component\OptionsResolver\Exception\ExceptionInterface
     */
    public function initialize(ProcessState $state)
    {
        $this->getOptions($state);
    }

    /**
     * @param ProcessState $state
     *
     * @throws \Symfony\Component\OptionsResolver\Exception\ExceptionInterface
     *
     * @return array
     */
    protected function getOptions(ProcessState $state)
    {
        if (null === $this->options) {
            $resolver = new OptionsResolver();
            $this->configureOptions($resolver);
            $this->options = $resolver->resolve($state->getContextualizedOptions());
        }

        return $this->options;
    }

    /**
     * @param ProcessState $state
     * @param string       $code
     *
     * @throws \InvalidArgumentException
     * @throws \Symfony\Component\OptionsResolver\Exception\ExceptionInterface
     *
     * @return mixed
     */
    protected function getOption(ProcessState $state, $code)
    {
        $options = $this->getOptions($state);
        if (!array_key_exists($code, $options)) {
            throw new \InvalidArgumentException("Missing option {$code}");
        }

        return $options[$code];
    }

    /**
     * @param OptionsResolver $resolver
     */
    abstract protected function configureOptions(OptionsResolver $resolver);
}


<?php
/*
 * This file is part of the CleverAge/ProcessBundle package.
 *
 * Copyright (C) 2017-2018 Clever-Age
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace CleverAge\ProcessBundle\Task\File\Csv;

use CleverAge\ProcessBundle\Filesystem\CsvFile;
use CleverAge\ProcessBundle\Filesystem\CsvResource;
use CleverAge\ProcessBundle\Model\ProcessState;
use Symfony\Component\OptionsResolver\OptionsResolver;

/**
 * Split long CSV files into smaller ones, keeping the headers
 */
class CsvSplitterTask extends InputCsvReaderTask
{
    /**
     * @param ProcessState $state
     *
     * @throws \Symfony\Component\OptionsResolver\Exception\ExceptionInterface
     * @throws \Symfony\Component\Filesystem\Exception\IOException
     * @throws \LogicException
     * @throws \UnexpectedValueException
     * @throws \RuntimeException
     */
    public function execute(ProcessState $state)
    {
        $options = $this->getOptions($state);
        if (null === $this->csv) {
            $headers = $this->getHeaders($state, $options);
            $csv = new CsvFile(
                $options['file_path'],
                $options['delimiter'],
                $options['enclosure'],
                $options['escape'],
                $headers,
                $options['mode']
            );

            if ($csv->getLineCount() > $options['max_lines']) {
                $this->logger->debug("Found big CSV file ({$csv->getLineCount()} lines), splitting...");
            }
            $this->csv = $csv;
        }

        // Return a temporary file containing a limited number of lines
        $state->setOutput($this->splitCsv($this->csv, $options['max_lines']));
    }

    /**
     * Moves the internal pointer to the next element,
     * return true if the task has a next element
     * return false if the task has terminated it's iteration
     *
     * @param ProcessState $state
     *
     * @throws \LogicException
     * @throws \UnexpectedValueException
     * @throws \RuntimeException
     *
     * @return bool
     */
    public function next(ProcessState $state)
    {
        if (!$this->csv instanceof CsvResource) {
            return false;
        }

        $endOfFile = $this->csv->isEndOfFile();
        if ($endOfFile) {
            $this->csv->close();
            $this->csv = null;
        }

        return !$endOfFile;
    }

    /**
     * @param ProcessState $state
     *
     * @throws \Symfony\Component\Filesystem\Exception\IOException
     */
    public function finalize(ProcessState $state)
    {
        if ($this->csv instanceof CsvResource) {
            $this->csv->close();
            $this->csv = null;
        }
    }

    /**
     * @param CsvFile $csv
     * @param int     $maxLines
     *
     * @throws \RuntimeException
     * @throws \LogicException
     * @throws \UnexpectedValueException
     *
     * @return string
     */
    protected function splitCsv(CsvFile $csv, $maxLines)
    {
        $tmpFilePath = sys_get_temp_dir().DIRECTORY_SEPARATOR.'php_'.uniqid('process', false).'.csv';
        $tmpFile = fopen($tmpFilePath, 'wb+');
        if (false === $tmpFile) {
            throw new \RuntimeException("Unable to open temporary file {$tmpFilePath}");
        }
        $splitCsv = new CsvResource(
            $tmpFile,
            $csv->getDelimiter(),
            $csv->getEnclosure(),
            $csv->getEscape(),
            $csv->getHeaders()
        );
        $splitCsv->writeHeaders();

        while ($splitCsv->getCurrentLine() < $maxLines && !$csv->isEndOfFile()) {
            $raw = $csv->readRaw();
            if (false === $raw) {
                continue; // This is probably an empty line, no harm to skip it
            }
            $splitCsv->writeRaw($raw);
        }
        $splitCsv->close();

        return $tmpFilePath;
    }

    /**
     * @param OptionsResolver $resolver
     *
     * @throws \Symfony\Component\OptionsResolver\Exception\AccessException
     * @throws \Symfony\Component\OptionsResolver\Exception\UndefinedOptionsException
     */
    protected function configureOptions(OptionsResolver $resolver)
    {
        parent::configureOptions($resolver);
        $resolver->setDefaults(
            [
                'max_lines' => 1000,
            ]
        );
    }
}


<?php
/*
* This file is part of the CleverAge/ProcessBundle package.
*
* Copyright (C) 2017-2018 Clever-Age
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace CleverAge\ProcessBundle\Task\Doctrine;

use CleverAge\ProcessBundle\Model\ProcessState;
use Doctrine\Common\Util\ClassUtils;
use Doctrine\ORM\EntityManagerInterface;

/**
 * Remove Doctrine entities
 *
 * @author Valentin Clavreul <vclavreul@clever-age.com>
 * @author Vincent Chalnot <vchalnot@clever-age.com>
 */
class DoctrineRemoverTask extends AbstractDoctrineTask
{
    /**
     * @param ProcessState $state
     *
     * @throws \UnexpectedValueException
     * @throws \Doctrine\ORM\ORMInvalidArgumentException
     * @throws \Symfony\Component\OptionsResolver\Exception\ExceptionInterface
     * @throws \InvalidArgumentException
     * @throws \Doctrine\ORM\ORMException
     */
    public function execute(ProcessState $state)
    {
        $entity = $state->getInput();
        $class = ClassUtils::getClass($entity);
        $entityManager = $this->doctrine->getManagerForClass($class);
        if (!$entityManager instanceof EntityManagerInterface) {
            throw new \UnexpectedValueException("No manager found for class {$class}");
        }
        $entityManager->remove($entity);
        $entityManager->flush();
    }
}


<?php
/*
* This file is part of the CleverAge/ProcessBundle package.
*
* Copyright (C) 2017-2018 Clever-Age
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace CleverAge\ProcessBundle\Task\Doctrine;

use CleverAge\ProcessBundle\Model\FlushableTaskInterface;
use CleverAge\ProcessBundle\Model\ProcessState;
use Doctrine\Common\Util\ClassUtils;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

/**
 * Persists Doctrine entities
 *
 * @author Valentin Clavreul <vclavreul@clever-age.com>
 * @author Vincent Chalnot <vchalnot@clever-age.com>
 */
class DoctrineBatchWriterTask extends AbstractDoctrineTask implements FlushableTaskInterface
{
    /** @var array */
    protected $batch = [];

    /**
     * @param ProcessState $state
     *
     * @throws \Doctrine\ORM\ORMInvalidArgumentException
     * @throws \UnexpectedValueException
     * @throws \Symfony\Component\OptionsResolver\Exception\ExceptionInterface
     * @throws \InvalidArgumentException
     */
    public function flush(ProcessState $state)
    {
        $this->writeBatch($state);
    }

    /**
     * @param ProcessState $state
     *
     * @throws \Doctrine\ORM\ORMInvalidArgumentException
     * @throws \UnexpectedValueException
     * @throws \Symfony\Component\OptionsResolver\Exception\ExceptionInterface
     * @throws \InvalidArgumentException
     */
    public function execute(ProcessState $state)
    {
        $this->batch[] = $state->getInput();

        if (\count($this->batch) >= $this->getOption($state, 'batch_count')) {
            $this->writeBatch($state);
        } else {
            $state->setSkipped(true);
        }
    }

    /**
     * @param OptionsResolver $resolver
     *
     * @throws \UnexpectedValueException
     * @throws \Symfony\Component\OptionsResolver\Exception\UndefinedOptionsException
     * @throws \Symfony\Component\OptionsResolver\Exception\AccessException
     */
    protected function configureOptions(OptionsResolver $resolver)
    {
        parent::configureOptions($resolver);
        $resolver->setDefaults(
            [
                'batch_count' => 10,
            ]
        );
        $resolver->setAllowedTypes('batch_count', ['integer']);
    }

    /**
     * @param ProcessState $state
     *
     * @throws \UnexpectedValueException
     * @throws \Symfony\Component\OptionsResolver\Exception\ExceptionInterface
     * @throws \UnexpectedValueException
     */
    protected function writeBatch(ProcessState $state): void
    {
        if (0 === \count($this->batch)) {
            $state->setSkipped(true);

            return;
        }

        // Support for multiple entity managers is overkill but might be necessary
        $entityManagers = new \SplObjectStorage();
        foreach ($this->batch as $entity) {
            $class = ClassUtils::getClass($entity);
            $entityManager = $this->doctrine->getManagerForClass($class);
            if (!$entityManager instanceof EntityManagerInterface) {
                throw new \UnexpectedValueException("No manager found for class {$class}");
            }
            $entityManager->persist($entity);
            $entityManagers->attach($entityManager);
        }

        foreach ($entityManagers as $entityManager) {
            $entityManager->flush();
        }

        $state->setOutput($this->batch);
        $this->batch = [];
    }
}