diff --git a/UPGRADE-3.x.md b/UPGRADE-3.x.md index a8d6cd6a..f8e637bc 100644 --- a/UPGRADE-3.x.md +++ b/UPGRADE-3.x.md @@ -1,2 +1,33 @@ UPGRADE 3.x =========== + +UPGRADE FROM 3.x to 3.x +======================= + +## Formatters for writers + +- Added `Sonata\Exporter\Formatter\BoolFormatter`, `Sonata\Exporter\Formatter\DateIntervalFormatter`, `Sonata\Exporter\Formatter\DateTimeFormatter`, + `Sonata\Exporter\Formatter\EnumFormatter`, `Sonata\Exporter\Formatter\IterableFormatter`, `Sonata\Exporter\Formatter\StringableFormatter` and + `Sonata\Exporter\Formatter\SymfonyTranslationFormatter` + classes to be used within implementations of `Sonata\Exporter\Formatter\Writer\FormatAwareInterface`. +- Deprecated `Sonata\Exporter\Writer\FormattedBoolWriter`, use `Sonata\Exporter\Formatter\BoolFormatter` instead. +- Deprecated arguments `dateTimeFormat` and `useBackedEnumValue` in `Sonata\Exporter\Source\AbstractPropertySourceIterator::__construct()` and + their children classes. To disable the source formatting you MUST pass `true` in argument `disableSourceFormatters` and use + `Sonata\Exporter\Formatter\Writer\FormatAwareInterface::addFormatter()` in your writers instead. + +## Symfony Bridge + +- Added `sonata_exporter.writers.{writer}.formatters` configuration in order to determine which formatters will be used by each writer. + + ```yaml + sonata_exporter: + writers: + csv: + formatters: + - datetime + - enum + # - ... + ``` + + By default, "bool", "dateinterval", "datetime", "enum", "iterable" and "stringable" formatters are configured. + If "symfony/translations-contracts" is installed, "symfony_translator" formatter is also enabled. diff --git a/composer.json b/composer.json index af3eed8a..2e9a78f4 100644 --- a/composer.json +++ b/composer.json @@ -48,6 +48,7 @@ "symfony/phpunit-bridge": "^6.2 || ^7.0", "symfony/property-access": "^5.4 || ^6.2 || ^7.0", "symfony/routing": "^5.4 || ^6.2 || ^7.0", + "symfony/translation-contracts": "^3.0.2", "vimeo/psalm": "^5.0" }, "conflict": { diff --git a/docs/index.rst b/docs/index.rst index e9d888aa..3381bed5 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,8 +1,8 @@ =============== -Sonata exporter +Sonata Exporter =============== -Sonata exporter is a library to export data from one source to an output in an efficient way. +Sonata Exporter is a library to export data from one source to an output in an efficient way. It is highly performance-oriented. Summary diff --git a/docs/reference/installation.rst b/docs/reference/installation.rst index 5ead5b6d..1e9eecd8 100644 --- a/docs/reference/installation.rst +++ b/docs/reference/installation.rst @@ -7,12 +7,18 @@ Installation The easiest way to install is to require it with Composer: -.. code-block:: bash +.. code-block:: shell composer require sonata-project/exporter -For support of the XLSX format, require this package with Composer: +For support of the XLSX format, require this package: -.. code-block:: bash +.. code-block:: shell composer require phpoffice/phpspreadsheet + +If you need the ``SymfonyTranslationFormatter`` formatter, require this package: + +.. code-block:: shell + + composer require symfony/translation-contracts diff --git a/docs/reference/introduction.rst b/docs/reference/introduction.rst index aff2981d..30b09eac 100644 --- a/docs/reference/introduction.rst +++ b/docs/reference/introduction.rst @@ -1,8 +1,8 @@ =============== -Sonata exporter +Sonata Exporter =============== -Sonata exporter allows you to convert large amount of data from a source to an output format +Sonata Exporter allows you to convert large amount of data from a source to an output format (most generally to a file) by streaming it (hence avoiding too much memory consumption). Usage diff --git a/docs/reference/outputs.rst b/docs/reference/outputs.rst index e30f0ec4..1a882109 100644 --- a/docs/reference/outputs.rst +++ b/docs/reference/outputs.rst @@ -2,17 +2,36 @@ Outputs ======= -Several output formatters are supported: +Several output writers are supported: -* CSV -* GSA Feed (Google Search Appliance) +* `CSV`_ +* `GSA Feed`_ (Google Search Appliance) * In Memory (for test purposes mostly) -* JSON -* Sitemap -* XML -* Excel XML -* XLSX (SpreadsheetML format for Microsoft Excel) +* `JSON`_ +* `Sitemap`_ (Sitemaps XML) +* `XLS XML`_ (Microsoft Excel 5.0/95 Workbook) +* `XLSX`_ (Excel Workbook) +* `XML`_ You may also create your own. To do so, simply create a class that implements the ``Exporter\Writer\WriterInterface``, -or better, if you know what ``Content-Type`` header should be used along with -your output and what format it produces, ``TypedWriterInterface``. +or better, if you know what ``Content-Type`` header should be used along with your output and what format it produces, ``TypedWriterInterface``. + +You can transform the output through the following formatters: + +* ``BoolFormatter``: Transforms boolean values to the configured strings (defaults to ``true`` => "yes", ``false`` => "no") +* ``DateIntervalFormatter``: Transforms ``\DateInterval`` objects to their ISO-8601 duration representation +* ``DateTimeFormatter``: Transforms ``\DateTimeInterface`` objects to the configured date representation (defaults to ``\DateTimeInterface::RFC2822``) +* ``EnumFormatter``: Transforms enumeration cases to a string representation (from the enum cases or values) depending on the enumeration type + (``\UnitEnum`` or ``\BackedEnum``) and the ``$useBackedEnumValue`` parameter (defaults to ``true``) +* ``IterableFormatter``: Transforms an iterable value to their string representation +* ``StringableFormatter``: Transforms stringable objects to their string representation (the one configured in the ``__toString()`` method) +* ``SymfonyTranslationFormatter``: Transforms messages (strings or objects implementing ``TranslatableInterface``) into their translation based + on the given configuration (parameters, domain, locale). It requires the "symfony/translation-contracts" package. + +.. _`CSV`: https://datatracker.ietf.org/doc/html/rfc4180 +.. _`GSA Feed`: https://developers.google.com/search-appliance +.. _`JSON`: https://www.json.org/json-en.html +.. _`Sitemap`: https://www.sitemaps.org/protocol.html +.. _`XLS XML`: https://support.microsoft.com/en-us/office/file-formats-that-are-supported-in-excel-0943ff2c-6014-4e8d-aaea-b83d51d46247#ID0EDT +.. _`XLSX`: https://support.microsoft.com/en-us/office/file-formats-that-are-supported-in-excel-0943ff2c-6014-4e8d-aaea-b83d51d46247#ID0EDT +.. _`XML`: https://www.w3.org/TR/xml/ diff --git a/docs/reference/sources.rst b/docs/reference/sources.rst index 774247dc..b83ce726 100644 --- a/docs/reference/sources.rst +++ b/docs/reference/sources.rst @@ -4,16 +4,16 @@ Sources You may export data from various sources: -* PHP Array +* Chain (can aggregate data from several different iterators) * CSV * Doctrine Query (ORM & ODM supported) * PDO Statement +* PHP Array * PHP Iterator instance * PHP Iterator with a callback on current +* Sitemap (Takes another iterator) * XML -* Excel XML +* XLS XML * XLSX (SpreadsheetML format for Microsoft Excel) -* Sitemap (Takes another iterator) -* Chain (can aggregate data from several different iterators) You may also create your own. To do so, create a class that implements ``\Iterator``. diff --git a/docs/reference/symfony.rst b/docs/reference/symfony.rst index 21b4b3c2..f24fadbb 100644 --- a/docs/reference/symfony.rst +++ b/docs/reference/symfony.rst @@ -38,6 +38,7 @@ Each service parameter has a configuration counterpart: The CSV writer service ~~~~~~~~~~~~~~~~~~~~~~ + This service can be configured through the following parameters: * ``sonata.exporter.writer.csv.filename``: defaults to ``php://output`` @@ -102,3 +103,14 @@ The default writers list can be altered through configuration: default_writers: - csv - json + +The default formatters +---------------------- + +* ``sonata.exporter.formatter.bool`` +* ``sonata.exporter.formatter.dateinterval`` +* ``sonata.exporter.formatter.datetime`` +* ``sonata.exporter.formatter.enum`` +* ``sonata.exporter.formatter.iterable`` +* ``sonata.exporter.formatter.stringable`` +* ``sonata.exporter.formatter.symfony_translator`` diff --git a/src/Bridge/Symfony/DependencyInjection/Configuration.php b/src/Bridge/Symfony/DependencyInjection/Configuration.php index c7e4980d..405f4b4b 100644 --- a/src/Bridge/Symfony/DependencyInjection/Configuration.php +++ b/src/Bridge/Symfony/DependencyInjection/Configuration.php @@ -16,6 +16,7 @@ use PhpOffice\PhpSpreadsheet\Spreadsheet; use Symfony\Component\Config\Definition\Builder\TreeBuilder; use Symfony\Component\Config\Definition\ConfigurationInterface; +use Symfony\Contracts\Translation\TranslatorInterface; /** * This is the class that validates and merges configuration from your app/config files. @@ -75,6 +76,10 @@ public function getConfigTreeBuilder(): TreeBuilder ->defaultValue(false) ->info('include the byte order mark') ->end() + ->arrayNode('formatters') + ->defaultValue($this->getDefaultFormatters()) + ->prototype('scalar')->end() + ->end() ->end() ->end() ->arrayNode('json') @@ -84,6 +89,10 @@ public function getConfigTreeBuilder(): TreeBuilder ->defaultValue('php://output') ->info('path to the output file') ->end() + ->arrayNode('formatters') + ->defaultValue($this->getDefaultFormatters()) + ->prototype('scalar')->end() + ->end() ->end() ->end() ->arrayNode('xls') @@ -97,6 +106,10 @@ public function getConfigTreeBuilder(): TreeBuilder ->defaultValue(true) ->info('add column names as the first line') ->end() + ->arrayNode('formatters') + ->defaultValue($this->getDefaultFormatters()) + ->prototype('scalar')->end() + ->end() ->end() ->end() ->arrayNode('xlsx') @@ -114,6 +127,10 @@ public function getConfigTreeBuilder(): TreeBuilder ->defaultValue(true) ->info('add filters in the first line') ->end() + ->arrayNode('formatters') + ->defaultValue($this->getDefaultFormatters()) + ->prototype('scalar')->end() + ->end() ->end() ->end() ->arrayNode('xml') @@ -135,6 +152,10 @@ public function getConfigTreeBuilder(): TreeBuilder ->defaultValue('data') ->info('name of elements corresponding to rows') ->end() + ->arrayNode('formatters') + ->defaultValue($this->getDefaultFormatters()) + ->prototype('scalar')->end() + ->end() ->end() ->end() ->end() @@ -157,4 +178,18 @@ private function getDefaultWriters(): array return $fields; } + + /** + * @return string[] + */ + private function getDefaultFormatters(): array + { + $formatters = ['bool', 'dateinterval', 'datetime', 'enum', 'iterable', 'stringable']; + + if (interface_exists(TranslatorInterface::class)) { + $formatters[] = 'symfony_translator'; + } + + return $formatters; + } } diff --git a/src/Bridge/Symfony/DependencyInjection/SonataExporterExtension.php b/src/Bridge/Symfony/DependencyInjection/SonataExporterExtension.php index 326b212e..7c07a999 100644 --- a/src/Bridge/Symfony/DependencyInjection/SonataExporterExtension.php +++ b/src/Bridge/Symfony/DependencyInjection/SonataExporterExtension.php @@ -17,6 +17,7 @@ use Symfony\Component\Config\FileLocator; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Loader\PhpFileLoader; +use Symfony\Component\DependencyInjection\Reference; use Symfony\Component\HttpKernel\DependencyInjection\Extension; /** @@ -60,6 +61,14 @@ private function configureExporter(ContainerBuilder $container, array $config): private function configureWriters(ContainerBuilder $container, array $config): void { foreach ($config as $format => $settings) { + if ($container->hasDefinition('sonata.exporter.writer.'.$format)) { + $writer = $container->getDefinition('sonata.exporter.writer.'.$format); + + foreach ($config[$format]['formatters'] as $formatter) { + $writer->addMethodCall('addFormatter', [new Reference('sonata.exporter.formatter.'.$formatter)]); + } + } + foreach ($settings as $key => $value) { $container->setParameter(sprintf( 'sonata.exporter.writer.%s.%s', diff --git a/src/Bridge/Symfony/Resources/config/services.php b/src/Bridge/Symfony/Resources/config/services.php index fc0b3d55..40f95ffb 100644 --- a/src/Bridge/Symfony/Resources/config/services.php +++ b/src/Bridge/Symfony/Resources/config/services.php @@ -16,11 +16,19 @@ use PhpOffice\PhpSpreadsheet\Spreadsheet; use Sonata\Exporter\Exporter; use Sonata\Exporter\ExporterInterface; +use Sonata\Exporter\Formatter\BoolFormatter; +use Sonata\Exporter\Formatter\DateIntervalFormatter; +use Sonata\Exporter\Formatter\DateTimeFormatter; +use Sonata\Exporter\Formatter\EnumFormatter; +use Sonata\Exporter\Formatter\IterableFormatter; +use Sonata\Exporter\Formatter\StringableFormatter; +use Sonata\Exporter\Formatter\SymfonyTranslationFormatter; use Sonata\Exporter\Writer\CsvWriter; use Sonata\Exporter\Writer\JsonWriter; use Sonata\Exporter\Writer\XlsWriter; use Sonata\Exporter\Writer\XlsxWriter; use Sonata\Exporter\Writer\XmlWriter; +use Symfony\Contracts\Translation\TranslatorInterface; return static function (ContainerConfigurator $containerConfigurator): void { $services = $containerConfigurator->services(); @@ -67,4 +75,22 @@ $services->alias(Exporter::class, 'sonata.exporter.exporter'); $services->alias(ExporterInterface::class, 'sonata.exporter.exporter'); + + $services->set('sonata.exporter.formatter.bool', BoolFormatter::class) + ->tag('sonata.exporter.formatter'); + $services->set('sonata.exporter.formatter.dateinterval', DateIntervalFormatter::class) + ->tag('sonata.exporter.formatter'); + $services->set('sonata.exporter.formatter.datetime', DateTimeFormatter::class) + ->tag('sonata.exporter.formatter'); + $services->set('sonata.exporter.formatter.enum', EnumFormatter::class) + ->tag('sonata.exporter.formatter'); + $services->set('sonata.exporter.formatter.iterable', IterableFormatter::class) + ->tag('sonata.exporter.formatter'); + $services->set('sonata.exporter.formatter.stringable', StringableFormatter::class) + ->tag('sonata.exporter.formatter'); + + if (interface_exists(TranslatorInterface::class)) { + $services->set('sonata.exporter.formatter.symfony_translator', SymfonyTranslationFormatter::class) + ->tag('sonata.exporter.formatter'); + } }; diff --git a/src/Formatter/BoolFormatter.php b/src/Formatter/BoolFormatter.php new file mode 100644 index 00000000..acb8177a --- /dev/null +++ b/src/Formatter/BoolFormatter.php @@ -0,0 +1,39 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Sonata\Exporter\Formatter; + +final class BoolFormatter implements FormatterInterface +{ + private const LABEL_TRUE = 'yes'; + private const LABEL_FALSE = 'no'; + + public function __construct( + private string $trueLabel = self::LABEL_TRUE, + private string $falseLabel = self::LABEL_FALSE + ) { + } + + public function format(array $data): array + { + foreach ($data as $key => $value) { + if (!\is_bool($value)) { + continue; + } + + $data[$key] = $value ? $this->trueLabel : $this->falseLabel; + } + + return $data; + } +} diff --git a/src/Formatter/DateIntervalFormatter.php b/src/Formatter/DateIntervalFormatter.php new file mode 100644 index 00000000..0503b8ec --- /dev/null +++ b/src/Formatter/DateIntervalFormatter.php @@ -0,0 +1,69 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Sonata\Exporter\Formatter; + +final class DateIntervalFormatter implements FormatterInterface +{ + private const DATE_PARTS = [ + 'y' => 'Y', + 'm' => 'M', + 'd' => 'D', + ]; + private const TIME_PARTS = [ + 'h' => 'H', + 'i' => 'M', + 's' => 'S', + ]; + + public function format(array $data): array + { + foreach ($data as $key => $value) { + if (!$value instanceof \DateInterval) { + continue; + } + + $data[$key] = self::getDuration($value); + } + + return $data; + } + + /** + * @return string An ISO8601 duration + */ + private static function getDuration(\DateInterval $interval): string + { + $datePart = ''; + + foreach (self::DATE_PARTS as $datePartAttribute => $datePartAttributeString) { + if ($interval->$datePartAttribute !== 0) { + $datePart .= $interval->$datePartAttribute.$datePartAttributeString; + } + } + + $timePart = ''; + + foreach (self::TIME_PARTS as $timePartAttribute => $timePartAttributeString) { + if ($interval->$timePartAttribute !== 0) { + $timePart .= $interval->$timePartAttribute.$timePartAttributeString; + } + } + + if ('' === $datePart && '' === $timePart) { + return 'P0Y'; + } + + return 'P'.$datePart.('' !== $timePart ? 'T'.$timePart : ''); + } +} diff --git a/src/Formatter/DateTimeFormatter.php b/src/Formatter/DateTimeFormatter.php new file mode 100644 index 00000000..5198fbf8 --- /dev/null +++ b/src/Formatter/DateTimeFormatter.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Sonata\Exporter\Formatter; + +final class DateTimeFormatter implements FormatterInterface +{ + public function __construct( + private string $dateTimeFormat = \DateTimeInterface::RFC2822 + ) { + } + + public function format(array $data): array + { + foreach ($data as $key => $value) { + if (!$value instanceof \DateTimeInterface) { + continue; + } + + $data[$key] = $value->format($this->dateTimeFormat); + } + + return $data; + } +} diff --git a/src/Formatter/EnumFormatter.php b/src/Formatter/EnumFormatter.php new file mode 100644 index 00000000..9cbe7ceb --- /dev/null +++ b/src/Formatter/EnumFormatter.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Sonata\Exporter\Formatter; + +final class EnumFormatter implements FormatterInterface +{ + public function __construct( + private bool $useBackedEnumValue = true + ) { + } + + public function format(array $data): array + { + foreach ($data as $key => $value) { + if (!$value instanceof \UnitEnum) { + continue; + } + + if ($this->useBackedEnumValue && $value instanceof \BackedEnum) { + $data[$key] = $value->value; + + continue; + } + + $data[$key] = $value->name; + } + + return $data; + } +} diff --git a/src/Formatter/FormatterInterface.php b/src/Formatter/FormatterInterface.php new file mode 100644 index 00000000..e66f12ff --- /dev/null +++ b/src/Formatter/FormatterInterface.php @@ -0,0 +1,24 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Sonata\Exporter\Formatter; + +interface FormatterInterface +{ + /** + * @param array $data + * + * @return array + */ + public function format(array $data): array; +} diff --git a/src/Formatter/IterableFormatter.php b/src/Formatter/IterableFormatter.php new file mode 100644 index 00000000..d6547efb --- /dev/null +++ b/src/Formatter/IterableFormatter.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Sonata\Exporter\Formatter; + +final class IterableFormatter implements FormatterInterface +{ + public function format(array $data): array + { + foreach ($data as $key => $value) { + if (!is_iterable($value)) { + continue; + } + + if (!\is_array($value)) { + $value = iterator_to_array($value); + } + + $data[$key] = '['.array_reduce($value, static function (?string $carry, mixed $item): string { + $item = (string) $item; + + if (null === $carry) { + return $item; + } + + return $carry.', '.$item; + }).']'; + } + + return $data; + } +} diff --git a/src/Formatter/StringableFormatter.php b/src/Formatter/StringableFormatter.php new file mode 100644 index 00000000..6f8977ba --- /dev/null +++ b/src/Formatter/StringableFormatter.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Sonata\Exporter\Formatter; + +final class StringableFormatter implements FormatterInterface +{ + public function format(array $data): array + { + foreach ($data as $key => $value) { + if (!$value instanceof \Stringable) { + continue; + } + + $data[$key] = (string) $value; + } + + return $data; + } +} diff --git a/src/Formatter/SymfonyTranslationFormatter.php b/src/Formatter/SymfonyTranslationFormatter.php new file mode 100644 index 00000000..4defabdd --- /dev/null +++ b/src/Formatter/SymfonyTranslationFormatter.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Sonata\Exporter\Formatter; + +use Symfony\Contracts\Translation\TranslatableInterface; +use Symfony\Contracts\Translation\TranslatorInterface; + +final class SymfonyTranslationFormatter implements FormatterInterface +{ + /** + * @param array $parameters + */ + public function __construct( + private TranslatorInterface $translator, + private array $parameters = [], + private ?string $domain = null, + private ?string $locale = null + ) { + } + + public function format(array $data): array + { + foreach ($data as $key => $value) { + if (\is_string($value)) { + $data[$key] = $this->translator->trans($value, $this->parameters, $this->domain, $this->locale); + + continue; + } + + if ($value instanceof TranslatableInterface) { + $data[$key] = $value->trans($this->translator, $this->locale); + } + } + + return $data; + } +} diff --git a/src/Source/AbstractPropertySourceIterator.php b/src/Source/AbstractPropertySourceIterator.php index 830be23e..a61bc24c 100644 --- a/src/Source/AbstractPropertySourceIterator.php +++ b/src/Source/AbstractPropertySourceIterator.php @@ -13,6 +13,8 @@ namespace Sonata\Exporter\Source; +use Sonata\Exporter\Formatter\DateTimeFormatter; +use Sonata\Exporter\Formatter\EnumFormatter; use Symfony\Component\PropertyAccess\Exception\UnexpectedTypeException; use Symfony\Component\PropertyAccess\PropertyAccess; use Symfony\Component\PropertyAccess\PropertyAccessor; @@ -23,11 +25,18 @@ */ abstract class AbstractPropertySourceIterator implements \Iterator { + /** + * NEXT_MAJOR: Remove this constant. + */ private const DATE_PARTS = [ 'y' => 'Y', 'm' => 'M', 'd' => 'D', ]; + + /** + * NEXT_MAJOR: Remove this constant. + */ private const TIME_PARTS = [ 'h' => 'H', 'i' => 'M', @@ -38,15 +47,57 @@ abstract class AbstractPropertySourceIterator implements \Iterator protected PropertyAccessor $propertyAccessor; + /** + * @deprecated since sonata-project/exporter 3.x. + */ + protected string $dateTimeFormat = 'r'; + + /** + * @deprecated since sonata-project/exporter 3.x. + */ + protected bool $useBackedEnumValue = true; + /** * @param string[] $fields Fields to export */ public function __construct( protected array $fields, - protected string $dateTimeFormat = 'r', - protected bool $useBackedEnumValue = true + ?string $dateTimeFormat = null, + ?bool $useBackedEnumValue = null, + /** + * NEXT_MAJOR: Remove this property. + */ + private bool $disableSourceFormatters = false ) { $this->propertyAccessor = PropertyAccess::createPropertyAccessor(); + + if ($this->disableSourceFormatters) { + return; + } + + if (null !== $dateTimeFormat) { + @trigger_error(sprintf( + 'Passing a value as argument 2 in "%s()" is deprecated since sonata-project/exporter 3.x and will be removed in version 4.0,' + .' use "%s" instead.', + __METHOD__, + DateTimeFormatter::class, + ), \E_USER_DEPRECATED); + + /** @psalm-suppress DeprecatedProperty */ + $this->dateTimeFormat = $dateTimeFormat; + } + + if (null !== $useBackedEnumValue) { + @trigger_error(sprintf( + 'Passing a value as argument 3 in "%s()" is deprecated since sonata-project/exporter 3.x and will be removed in version 4.0,' + .' use "%s" instead.', + __METHOD__, + EnumFormatter::class, + ), \E_USER_DEPRECATED); + + /** @psalm-suppress DeprecatedProperty */ + $this->useBackedEnumValue = $useBackedEnumValue; + } } /** @@ -76,21 +127,41 @@ public function valid(): bool abstract public function rewind(): void; + /** + * @deprecated since sonata-project/exporter 3.x. + * + * @psalm-suppress DeprecatedProperty + */ public function setDateTimeFormat(string $dateTimeFormat): void { $this->dateTimeFormat = $dateTimeFormat; } + /** + * @deprecated since sonata-project/exporter 3.x. + * + * @psalm-suppress DeprecatedProperty + */ public function getDateTimeFormat(): string { return $this->dateTimeFormat; } + /** + * @deprecated since sonata-project/exporter 3.x. + * + * @psalm-suppress DeprecatedProperty + */ public function useBackedEnumValue(bool $useBackedEnumValue): void { $this->useBackedEnumValue = $useBackedEnumValue; } + /** + * @deprecated since sonata-project/exporter 3.x. + * + * @psalm-suppress DeprecatedProperty + */ public function isBackedEnumValueInUse(): bool { return $this->useBackedEnumValue; @@ -120,7 +191,13 @@ protected function getCurrentData(object|array $current): array try { $propertyValue = $this->propertyAccessor->getValue($current, new PropertyPath($propertyPath)); - $data[$name] = $this->getValue($propertyValue); + // NEXT_MAJOR: Remove this condition. + if (!$this->disableSourceFormatters) { + /** @psalm-suppress DeprecatedMethod */ + $propertyValue = $this->getValue($propertyValue); + } + + $data[$name] = $propertyValue; } catch (UnexpectedTypeException) { // Non existent object in path will be ignored but a wrong path will still throw exceptions $data[$name] = null; @@ -130,6 +207,11 @@ protected function getCurrentData(object|array $current): array return $data; } + /** + * @deprecated since sonata-project/exporter 3.x. + * + * @psalm-suppress DeprecatedMethod, DeprecatedProperty + */ protected function getValue(mixed $value): bool|int|float|string|null { return match (true) { diff --git a/src/Source/DoctrineODMQuerySourceIterator.php b/src/Source/DoctrineODMQuerySourceIterator.php index 8d06c698..2326119d 100644 --- a/src/Source/DoctrineODMQuerySourceIterator.php +++ b/src/Source/DoctrineODMQuerySourceIterator.php @@ -25,13 +25,20 @@ final class DoctrineODMQuerySourceIterator extends AbstractPropertySourceIterato public function __construct( Query $query, array $fields, - string $dateTimeFormat = \DateTimeInterface::ATOM, + ?string $dateTimeFormat = null, private int $batchSize = 100, - bool $useBackedEnumValue = true + ?bool $useBackedEnumValue = null, + bool $disableSourceFormatters = false ) { $this->query = clone $query; - parent::__construct($fields, $dateTimeFormat, $useBackedEnumValue); + parent::__construct($fields, $dateTimeFormat, $useBackedEnumValue, $disableSourceFormatters); + + // NEXT_MAJOR: Remove this condition. + if (null === $dateTimeFormat) { + /** @psalm-suppress DeprecatedProperty */ + $this->dateTimeFormat = \DateTimeInterface::ATOM; + } } /** diff --git a/src/Source/DoctrineORMQuerySourceIterator.php b/src/Source/DoctrineORMQuerySourceIterator.php index 47fe2cf0..47ca9e75 100644 --- a/src/Source/DoctrineORMQuerySourceIterator.php +++ b/src/Source/DoctrineORMQuerySourceIterator.php @@ -25,9 +25,10 @@ final class DoctrineORMQuerySourceIterator extends AbstractPropertySourceIterato public function __construct( Query $query, array $fields, - string $dateTimeFormat = 'r', + ?string $dateTimeFormat = null, private int $batchSize = 100, - bool $useBackedEnumValue = true, + ?bool $useBackedEnumValue = null, + bool $disableSourceFormatters = false ) { $this->query = clone $query; $this->query->setParameters($query->getParameters()); @@ -35,7 +36,7 @@ public function __construct( $this->query->setHint($name, $value); } - parent::__construct($fields, $dateTimeFormat, $useBackedEnumValue); + parent::__construct($fields, $dateTimeFormat, $useBackedEnumValue, $disableSourceFormatters); } /** diff --git a/src/Writer/AbstractWriter.php b/src/Writer/AbstractWriter.php new file mode 100644 index 00000000..beb8f609 --- /dev/null +++ b/src/Writer/AbstractWriter.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Sonata\Exporter\Writer; + +use Sonata\Exporter\Formatter\FormatterInterface; + +abstract class AbstractWriter implements FormatAwareInterface +{ + /** + * @var array + */ + protected array $formatters = []; + + public function addFormatter(FormatterInterface $formatter): void + { + $this->formatters[] = $formatter; + } + + /** + * @param array $data + * + * @return array + */ + protected function format(array $data): array + { + foreach ($this->formatters as $formatter) { + $data = $formatter->format($data); + } + + return $data; + } +} diff --git a/src/Writer/CsvWriter.php b/src/Writer/CsvWriter.php index 2093ea52..47f048a6 100644 --- a/src/Writer/CsvWriter.php +++ b/src/Writer/CsvWriter.php @@ -18,7 +18,7 @@ /** * @author Thomas Rabaix */ -final class CsvWriter implements TypedWriterInterface +final class CsvWriter extends AbstractWriter implements TypedWriterInterface { /** * @var resource|null @@ -105,7 +105,7 @@ public function write(array $data): void EXCEPTION); } - $result = @fputcsv($this->getFile(), $data, $this->delimiter, $this->enclosure, $this->escape); + $result = @fputcsv($this->getFile(), $this->format($data), $this->delimiter, $this->enclosure, $this->escape); if (false === $result) { throw new InvalidDataFormatException(); diff --git a/src/Writer/FormatAwareInterface.php b/src/Writer/FormatAwareInterface.php new file mode 100644 index 00000000..8f4714c1 --- /dev/null +++ b/src/Writer/FormatAwareInterface.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Sonata\Exporter\Writer; + +use Sonata\Exporter\Formatter\FormatterInterface; + +interface FormatAwareInterface +{ + public function addFormatter(FormatterInterface $formatter): void; +} diff --git a/src/Writer/FormattedBoolWriter.php b/src/Writer/FormattedBoolWriter.php index a1f65220..80a60cfa 100644 --- a/src/Writer/FormattedBoolWriter.php +++ b/src/Writer/FormattedBoolWriter.php @@ -17,6 +17,8 @@ * Format boolean before use another writer. * * @author Robin Chalas + * + * @deprecated since version 3.x, use `\Sonata\Exporter\Formatter\BoolFormatter` instead. */ final class FormattedBoolWriter implements WriterInterface { diff --git a/src/Writer/InMemoryWriter.php b/src/Writer/InMemoryWriter.php index ef66f6af..141d1e63 100644 --- a/src/Writer/InMemoryWriter.php +++ b/src/Writer/InMemoryWriter.php @@ -13,7 +13,7 @@ namespace Sonata\Exporter\Writer; -final class InMemoryWriter implements WriterInterface +final class InMemoryWriter extends AbstractWriter implements WriterInterface { /** * @var array @@ -32,7 +32,7 @@ public function close(): void public function write(array $data): void { - $this->elements[] = $data; + $this->elements[] = $this->format($data); } /** diff --git a/src/Writer/JsonWriter.php b/src/Writer/JsonWriter.php index 5980c2bc..2f40be83 100644 --- a/src/Writer/JsonWriter.php +++ b/src/Writer/JsonWriter.php @@ -16,7 +16,7 @@ /** * @author Thomas Rabaix */ -final class JsonWriter implements TypedWriterInterface +final class JsonWriter extends AbstractWriter implements TypedWriterInterface { /** * @var resource|null @@ -72,7 +72,7 @@ public function close(): void public function write(array $data): void { - fwrite($this->getFile(), ($this->position > 0 ? ',' : '').json_encode($data, \JSON_THROW_ON_ERROR)); + fwrite($this->getFile(), ($this->position > 0 ? ',' : '').json_encode($this->format($data), \JSON_THROW_ON_ERROR)); ++$this->position; } diff --git a/src/Writer/XlsWriter.php b/src/Writer/XlsWriter.php index 40d3b9f4..8d5282a9 100644 --- a/src/Writer/XlsWriter.php +++ b/src/Writer/XlsWriter.php @@ -16,7 +16,7 @@ /** * @author Thomas Rabaix */ -final class XlsWriter implements TypedWriterInterface +final class XlsWriter extends AbstractWriter implements TypedWriterInterface { /** * @var resource|null @@ -77,9 +77,10 @@ public function write(array $data): void $this->init($data); fwrite($this->getFile(), ''); - foreach ($data as $value) { + foreach ($this->format($data) as $value) { fwrite($this->getFile(), sprintf('%s', $value)); } + fwrite($this->getFile(), ''); ++$this->position; diff --git a/src/Writer/XlsxWriter.php b/src/Writer/XlsxWriter.php index cad04e17..1e89d870 100644 --- a/src/Writer/XlsxWriter.php +++ b/src/Writer/XlsxWriter.php @@ -25,7 +25,7 @@ /** * @author Willem Verspyck */ -final class XlsxWriter implements TypedWriterInterface +final class XlsxWriter extends AbstractWriter implements TypedWriterInterface { private string $filename; @@ -97,7 +97,7 @@ public function write(array $data): void $column = 1; - foreach ($data as $value) { + foreach ($this->format($data) as $value) { $dataFormat = $this->getDataFormat($value); $dataValue = $this->getDataValue($value); diff --git a/src/Writer/XmlExcelWriter.php b/src/Writer/XmlExcelWriter.php index 6b5693c0..f48a3bd0 100644 --- a/src/Writer/XmlExcelWriter.php +++ b/src/Writer/XmlExcelWriter.php @@ -18,7 +18,7 @@ * * @author Vincent Touzet */ -final class XmlExcelWriter implements WriterInterface +final class XmlExcelWriter extends AbstractWriter implements WriterInterface { /** * @var resource|null @@ -71,7 +71,7 @@ public function write(array $data): void ++$this->position; } - fwrite($this->getFile(), $this->getXmlString($data)); + fwrite($this->getFile(), $this->getXmlString($this->format($data))); ++$this->position; } diff --git a/src/Writer/XmlWriter.php b/src/Writer/XmlWriter.php index d241c03c..ecdaeb9e 100644 --- a/src/Writer/XmlWriter.php +++ b/src/Writer/XmlWriter.php @@ -19,7 +19,7 @@ /** * @author Thomas Rabaix */ -final class XmlWriter implements TypedWriterInterface +final class XmlWriter extends AbstractWriter implements TypedWriterInterface { /** * @var resource|null @@ -78,8 +78,8 @@ public function write(array $data): void { fwrite($this->getFile(), sprintf("<%s>\n", $this->childElement)); - foreach ($data as $k => $v) { - $this->generateNode($k, $v); + foreach ($this->format($data) as $k => $v) { + $this->generateNode((string) $k, $v); } fwrite($this->getFile(), sprintf("\n", $this->childElement)); @@ -92,11 +92,13 @@ private function generateNode(string $name, mixed $value): void { if (\is_array($value)) { throw new RuntimeException('Not implemented'); - } elseif (\is_scalar($value) || null === $value) { - fwrite($this->getFile(), sprintf("<%s>\n", $name, (string) $value, $name)); - } else { + } + + if (!\is_scalar($value) && null !== $value) { throw new InvalidDataFormatException('Invalid data'); } + + fwrite($this->getFile(), sprintf("<%s>\n", $name, (string) $value, $name)); } /** diff --git a/tests/Bridge/Symfony/DependencyInjection/ConfigurationTest.php b/tests/Bridge/Symfony/DependencyInjection/ConfigurationTest.php index 4f8680cb..b91dc347 100644 --- a/tests/Bridge/Symfony/DependencyInjection/ConfigurationTest.php +++ b/tests/Bridge/Symfony/DependencyInjection/ConfigurationTest.php @@ -42,24 +42,69 @@ public function testDefault(): void 'escape' => '\\', 'show_headers' => true, 'with_bom' => false, + 'formatters' => [ + 'bool', + 'dateinterval', + 'datetime', + 'enum', + 'iterable', + 'stringable', + 'symfony_translator', + ], ], 'json' => [ 'filename' => 'php://output', + 'formatters' => [ + 'bool', + 'dateinterval', + 'datetime', + 'enum', + 'iterable', + 'stringable', + 'symfony_translator', + ], ], 'xls' => [ 'filename' => 'php://output', 'show_headers' => true, + 'formatters' => [ + 'bool', + 'dateinterval', + 'datetime', + 'enum', + 'iterable', + 'stringable', + 'symfony_translator', + ], ], 'xlsx' => [ 'filename' => 'php://output', 'show_headers' => true, 'show_filters' => true, + 'formatters' => [ + 'bool', + 'dateinterval', + 'datetime', + 'enum', + 'iterable', + 'stringable', + 'symfony_translator', + ], ], 'xml' => [ 'filename' => 'php://output', 'show_headers' => true, 'main_element' => 'datas', 'child_element' => 'data', + 'formatters' => [ + 'bool', + 'dateinterval', + 'datetime', + 'enum', + 'iterable', + 'stringable', + 'symfony_translator', + ], ], ], ]); diff --git a/tests/Formatter/BoolFormatterTest.php b/tests/Formatter/BoolFormatterTest.php new file mode 100644 index 00000000..2e9ed1c8 --- /dev/null +++ b/tests/Formatter/BoolFormatterTest.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Sonata\Exporter\Tests\Formatter; + +use PHPUnit\Framework\TestCase; +use Sonata\Exporter\Formatter\BoolFormatter; +use Sonata\Exporter\Writer\InMemoryWriter; + +final class BoolFormatterTest extends TestCase +{ + public function testFotmatter(): void + { + $data = ['john', 'doe', false, true]; + $expected = [ + ['john', 'doe', 'no', 'yes'], + ]; + $writer = new InMemoryWriter(); + $writer->addFormatter(new BoolFormatter('yes', 'no')); + $writer->open(); + $writer->write($data); + + static::assertSame($expected, $writer->getElements()); + + $writer->close(); + } +} diff --git a/tests/Formatter/DateIntervalFormatterTest.php b/tests/Formatter/DateIntervalFormatterTest.php new file mode 100644 index 00000000..c8506e52 --- /dev/null +++ b/tests/Formatter/DateIntervalFormatterTest.php @@ -0,0 +1,67 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Sonata\Exporter\Tests\Formatter; + +use PHPUnit\Framework\TestCase; +use Sonata\Exporter\Formatter\DateIntervalFormatter; +use Sonata\Exporter\Writer\InMemoryWriter; + +final class DateIntervalFormatterTest extends TestCase +{ + /** + * @dataProvider provideFormatterCases + */ + public function testFormatter(\DateInterval $dateInterval, string $expected): void + { + $data = [ + 'name' => 'john', + 'lastname' => 'doe', + 'dateInterval' => $dateInterval, + ]; + $writer = new InMemoryWriter(); + $writer->addFormatter(new DateIntervalFormatter()); + $writer->open(); + $writer->write($data); + + $exportedItems = $writer->getElements(); + + static::assertArrayHasKey(0, $exportedItems); + + $firstItem = $exportedItems[0]; + + static::assertSame($expected, $firstItem['dateInterval']); + + $writer->close(); + } + + /** + * @phpstan-return iterable + */ + public function provideFormatterCases(): iterable + { + yield [new \DateInterval('P1Y'), 'P1Y']; + yield [new \DateInterval('P1M'), 'P1M']; + yield [new \DateInterval('P1D'), 'P1D']; + yield [new \DateInterval('PT1H'), 'PT1H']; + yield [new \DateInterval('PT1M'), 'PT1M']; + yield [new \DateInterval('PT1S'), 'PT1S']; + yield [new \DateInterval('P1Y1M'), 'P1Y1M']; + yield [new \DateInterval('P1Y1M1D'), 'P1Y1M1D']; + yield [new \DateInterval('P1Y1M1DT1H'), 'P1Y1M1DT1H']; + yield [new \DateInterval('P1Y1M1DT1H1M'), 'P1Y1M1DT1H1M']; + yield [new \DateInterval('P1Y1M1DT1H1M1S'), 'P1Y1M1DT1H1M1S']; + yield [new \DateInterval('P0Y'), 'P0Y']; + yield [new \DateInterval('PT0S'), 'P0Y']; + } +} diff --git a/tests/Formatter/DateTimeFormatterTest.php b/tests/Formatter/DateTimeFormatterTest.php new file mode 100644 index 00000000..03a29f1e --- /dev/null +++ b/tests/Formatter/DateTimeFormatterTest.php @@ -0,0 +1,65 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Sonata\Exporter\Tests\Formatter; + +use PHPUnit\Framework\TestCase; +use Sonata\Exporter\Formatter\DateTimeFormatter; +use Sonata\Exporter\Writer\InMemoryWriter; + +final class DateTimeFormatterTest extends TestCase +{ + /** + * @dataProvider provideFormatterCases + */ + public function testFormatter(\DateTimeInterface $dateTime, DateTimeFormatter $formatter, string $expected): void + { + $data = [ + 'name' => 'john', + 'lastname' => 'doe', + 'date' => $dateTime, + ]; + $writer = new InMemoryWriter(); + $writer->addFormatter($formatter); + $writer->open(); + $writer->write($data); + + $exportedItems = $writer->getElements(); + + static::assertArrayHasKey(0, $exportedItems); + + $firstItem = $exportedItems[0]; + + static::assertSame($expected, $firstItem['date']); + + $writer->close(); + } + + /** + * @phpstan-return iterable + */ + public function provideFormatterCases(): iterable + { + $dateTimeImmutable = new \DateTimeImmutable('1986-03-22 21:45:00'); + + yield [$dateTimeImmutable, new DateTimeFormatter(), 'Sat, 22 Mar 1986 21:45:00 +0000']; + yield [$dateTimeImmutable, new DateTimeFormatter('Y-m-d H:i:s'), '1986-03-22 21:45:00']; + yield [$dateTimeImmutable, new DateTimeFormatter('Y-m-d'), '1986-03-22']; + + $dateTime = new \DateTime('1986-03-22 21:45:00'); + + yield [$dateTime, new DateTimeFormatter(), 'Sat, 22 Mar 1986 21:45:00 +0000']; + yield [$dateTime, new DateTimeFormatter('Y-m-d H:i:s'), '1986-03-22 21:45:00']; + yield [$dateTime, new DateTimeFormatter('Y-m-d'), '1986-03-22']; + } +} diff --git a/tests/Formatter/EnumFormatterTest.php b/tests/Formatter/EnumFormatterTest.php new file mode 100644 index 00000000..f9c968b7 --- /dev/null +++ b/tests/Formatter/EnumFormatterTest.php @@ -0,0 +1,62 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Sonata\Exporter\Tests\Formatter; + +use PHPUnit\Framework\TestCase; +use Sonata\Exporter\Formatter\EnumFormatter; +use Sonata\Exporter\Tests\Source\Fixtures\Element; +use Sonata\Exporter\Tests\Source\Fixtures\Suit; +use Sonata\Exporter\Writer\InMemoryWriter; + +/** + * @requires PHP >= 8.1 + */ +final class EnumFormatterTest extends TestCase +{ + /** + * @dataProvider provideFormatterCases + */ + public function testFormatter(\UnitEnum $enumCase, EnumFormatter $formatter, string $expected): void + { + $data = [ + 'name' => 'john', + 'lastname' => 'doe', + 'choice' => $enumCase, + ]; + $writer = new InMemoryWriter(); + $writer->addFormatter($formatter); + $writer->open(); + $writer->write($data); + + $exportedItems = $writer->getElements(); + + static::assertArrayHasKey(0, $exportedItems); + + $firstItem = $exportedItems[0]; + + static::assertSame($expected, $firstItem['choice']); + + $writer->close(); + } + + /** + * @phpstan-return iterable + */ + public function provideFormatterCases(): iterable + { + yield [Element::Hydrogen, new EnumFormatter(), 'Hydrogen']; + yield [Suit::Diamonds, new EnumFormatter(), 'D']; + yield [Suit::Diamonds, new EnumFormatter(false), 'Diamonds']; + } +} diff --git a/tests/Formatter/FormatterTest.php b/tests/Formatter/FormatterTest.php new file mode 100644 index 00000000..67b7150b --- /dev/null +++ b/tests/Formatter/FormatterTest.php @@ -0,0 +1,103 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Sonata\Exporter\Tests\Formatter; + +use PHPUnit\Framework\TestCase; +use Sonata\Exporter\Formatter\BoolFormatter; +use Sonata\Exporter\Formatter\DateTimeFormatter; +use Sonata\Exporter\Formatter\EnumFormatter; +use Sonata\Exporter\Formatter\StringableFormatter; +use Sonata\Exporter\Formatter\SymfonyTranslationFormatter; +use Sonata\Exporter\Tests\Source\Fixtures\ObjectWithToString; +use Sonata\Exporter\Tests\Source\Fixtures\Suit; +use Sonata\Exporter\Writer\InMemoryWriter; +use Symfony\Contracts\Translation\TranslatorInterface; + +final class FormatterTest extends TestCase +{ + /** + * @requires PHP >= 8.1 + */ + public function testMultipleFormatters(): void + { + $data = [ + 'name' => 'john', + 'lastname' => 'doe', + 'date' => new \DateTimeImmutable('1986-03-22 21:45:00'), + 'blocked' => true, + 'enabled' => false, + 'suitChoice' => Suit::Hearts, + 'stringable' => new ObjectWithToString('hello'), + ]; + $writer = new InMemoryWriter(); + $writer->addFormatter(new DateTimeFormatter('Y-m-d')); + $writer->addFormatter(new BoolFormatter('yes', 'no')); + $writer->addFormatter(new EnumFormatter()); + $writer->addFormatter(new StringableFormatter()); + $writer->addFormatter(new SymfonyTranslationFormatter($this->getTranslator(), locale: 'es')); + $writer->open(); + $writer->write($data); + + $exportedItems = $writer->getElements(); + + static::assertArrayHasKey(0, $exportedItems); + + $firstItem = $exportedItems[0]; + + static::assertIsArray($firstItem); + static::assertSame('1986-03-22', $firstItem['date']); + static::assertSame('Sí', $firstItem['blocked']); + static::assertSame('no', $firstItem['enabled']); + static::assertSame('H', $firstItem['suitChoice']); + static::assertSame('hello', $firstItem['stringable']); + + $writer->close(); + } + + private function getTranslator(): TranslatorInterface + { + return new class() implements TranslatorInterface { + /** + * @phpstan-var array>> + */ + private array $catalog = [ + 'messages' => [ + 'en' => [ + 'yes' => 'Yes', + ], + 'es' => [ + 'yes' => 'Sí', + ], + ], + ]; + + /** + * @param array $parameters + */ + public function trans(string $id, array $parameters = [], ?string $domain = null, ?string $locale = null): string + { + $domain ??= 'messages'; + $locale ??= $this->getLocale(); + $message = $this->catalog[$domain][$locale][$id] ?? $id; + + return strtr($message, $parameters); + } + + public function getLocale(): string + { + return 'en'; + } + }; + } +} diff --git a/tests/Formatter/IterableFormatterTest.php b/tests/Formatter/IterableFormatterTest.php new file mode 100644 index 00000000..4044b167 --- /dev/null +++ b/tests/Formatter/IterableFormatterTest.php @@ -0,0 +1,68 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Sonata\Exporter\Tests\Formatter; + +use PHPUnit\Framework\TestCase; +use Sonata\Exporter\Formatter\IterableFormatter; +use Sonata\Exporter\Writer\InMemoryWriter; + +final class IterableFormatterTest extends TestCase +{ + /** + * @dataProvider provideFormatterCases + * + * @param iterable $value + */ + public function testFormatter(iterable $value, string $expected): void + { + $data = [ + 'name' => 'john', + 'lastname' => 'doe', + 'iterable' => $value, + ]; + $writer = new InMemoryWriter(); + $writer->addFormatter(new IterableFormatter()); + $writer->open(); + $writer->write($data); + + $exportedItems = $writer->getElements(); + + static::assertArrayHasKey(0, $exportedItems); + + $firstItem = $exportedItems[0]; + + static::assertSame($expected, $firstItem['iterable']); + + $writer->close(); + } + + /** + * @phpstan-return iterable, 1: string}> + */ + public function provideFormatterCases(): iterable + { + yield [[1, 2, 3], '[1, 2, 3]']; + yield [['a', 'b', 'c'], '[a, b, c]']; + yield [ + [ + 'a' => 'A', + 'b' => 'B', + 'c' => 'C', + ], + '[A, B, C]', + ]; + yield [new \ArrayIterator([1, 2, 3]), '[1, 2, 3]']; + yield [(static function (): \Generator { yield from [1, 2, 3]; })(), '[1, 2, 3]']; + } +} diff --git a/tests/Formatter/StringableFormatterTest.php b/tests/Formatter/StringableFormatterTest.php new file mode 100644 index 00000000..2598a365 --- /dev/null +++ b/tests/Formatter/StringableFormatterTest.php @@ -0,0 +1,60 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Sonata\Exporter\Tests\Formatter; + +use PHPUnit\Framework\TestCase; +use Sonata\Exporter\Formatter\StringableFormatter; +use Sonata\Exporter\Tests\Source\Fixtures\ObjectWithToString; +use Sonata\Exporter\Writer\InMemoryWriter; + +final class StringableFormatterTest extends TestCase +{ + /** + * @dataProvider provideFormatterCases + */ + public function testFormatter(object $value, string|object $expected): void + { + $data = [ + 'name' => 'john', + 'lastname' => 'doe', + 'object' => $value, + ]; + $writer = new InMemoryWriter(); + $writer->addFormatter(new StringableFormatter()); + $writer->open(); + $writer->write($data); + + $exportedItems = $writer->getElements(); + + static::assertArrayHasKey(0, $exportedItems); + + $firstItem = $exportedItems[0]; + + static::assertSame($expected, $firstItem['object']); + + $writer->close(); + } + + /** + * @phpstan-return iterable + */ + public function provideFormatterCases(): iterable + { + yield [new ObjectWithToString('object with to string'), 'object with to string']; + + $nonStringableObject = new \stdClass(); + + yield [$nonStringableObject, $nonStringableObject]; + } +} diff --git a/tests/Formatter/SymfonyTranslationFormatterTest.php b/tests/Formatter/SymfonyTranslationFormatterTest.php new file mode 100644 index 00000000..1b572247 --- /dev/null +++ b/tests/Formatter/SymfonyTranslationFormatterTest.php @@ -0,0 +1,114 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Sonata\Exporter\Tests\Formatter; + +use PHPUnit\Framework\TestCase; +use Sonata\Exporter\Formatter\SymfonyTranslationFormatter; +use Sonata\Exporter\Writer\InMemoryWriter; +use Symfony\Contracts\Translation\TranslatableInterface; +use Symfony\Contracts\Translation\TranslatorInterface; + +/** + * @requires PHP >= 8.1 + */ +final class SymfonyTranslationFormatterTest extends TestCase +{ + /** + * @dataProvider provideFormatterCases + */ + public function testFormatter(string|TranslatableInterface $value, SymfonyTranslationFormatter $formatter, string $expected): void + { + $data = [ + 'name' => 'john', + 'lastname' => 'doe', + 'translatable' => $value, + ]; + $writer = new InMemoryWriter(); + $writer->addFormatter($formatter); + $writer->open(); + $writer->write($data); + + $exportedItems = $writer->getElements(); + + static::assertArrayHasKey(0, $exportedItems); + + $firstItem = $exportedItems[0]; + + static::assertSame($expected, $firstItem['translatable']); + + $writer->close(); + } + + /** + * @phpstan-return iterable + */ + public function provideFormatterCases(): iterable + { + $translator = new class() implements TranslatorInterface { + /** + * @phpstan-var array>> + */ + private array $catalog = [ + 'messages' => [ + 'en' => [ + 'hello' => 'Hello', + 'greeting' => 'Hello {name}!', + ], + 'es' => [ + 'hello' => 'Hola', + 'greeting' => '¡Hola {name}!', + ], + ], + ]; + + /** + * @param array $parameters + */ + public function trans(string $id, array $parameters = [], ?string $domain = null, ?string $locale = null): string + { + $domain ??= 'messages'; + $locale ??= $this->getLocale(); + $message = $this->catalog[$domain][$locale][$id] ?? $id; + + return strtr($message, $parameters); + } + + public function getLocale(): string + { + return 'en'; + } + }; + + yield ['hello', new SymfonyTranslationFormatter($translator), 'Hello']; + yield ['hello', new SymfonyTranslationFormatter($translator, locale: 'es'), 'Hola']; + yield ['greeting', new SymfonyTranslationFormatter($translator, ['{name}' => 'Javier']), 'Hello Javier!']; + yield ['greeting', new SymfonyTranslationFormatter($translator, ['{name}' => 'Javier'], 'messages', 'es'), '¡Hola Javier!']; + + $translatable = new class('greeting', ['{name}' => 'Javier']) implements TranslatableInterface { + /** + * @param array $parameters + */ + public function __construct(private string $message, private array $parameters = [], private ?string $domain = null) + { + } + + public function trans(TranslatorInterface $translator, ?string $locale = null): string + { + return $translator->trans($this->message, $this->parameters, $this->domain, $locale); + } + }; + + yield [$translatable, new SymfonyTranslationFormatter($translator, locale: 'es'), '¡Hola Javier!']; + } +} diff --git a/tests/Source/AbstractPropertySourceIteratorTest.php b/tests/Source/AbstractPropertySourceIteratorTest.php index 199e5ca5..3d4e06a1 100644 --- a/tests/Source/AbstractPropertySourceIteratorTest.php +++ b/tests/Source/AbstractPropertySourceIteratorTest.php @@ -15,17 +15,34 @@ use PHPUnit\Framework\TestCase; use Sonata\Exporter\Source\AbstractPropertySourceIterator; -use Sonata\Exporter\Tests\Source\Fixtures\Element; -use Sonata\Exporter\Tests\Source\Fixtures\ObjectWithToString; use Sonata\Exporter\Tests\Source\Fixtures\Suit; +use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait; final class AbstractPropertySourceIteratorTest extends TestCase { + use ExpectDeprecationTrait; + /** - * @dataProvider provideGetValueCases + * @dataProvider provideLegacyGetValueCases + * + * @group legacy */ - public function testGetValue(mixed $value, mixed $expected, string $dateFormat = 'r', bool $useBackedEnumValue = true): void + public function testLegacyGetValue(mixed $value, mixed $expected, ?string $dateFormat = null, ?bool $useBackedEnumValue = null): void { + if (null !== $dateFormat) { + $this->expectDeprecation( + 'Passing a value as argument 2 in "Sonata\Exporter\Source\AbstractPropertySourceIterator::__construct()" is deprecated since' + .' sonata-project/exporter 3.x and will be removed in version 4.0, use "Sonata\Exporter\Formatter\DateTimeFormatter" instead.' + ); + } + + if (null !== $useBackedEnumValue) { + $this->expectDeprecation( + 'Passing a value as argument 3 in "Sonata\Exporter\Source\AbstractPropertySourceIterator::__construct()" is deprecated since' + .' sonata-project/exporter 3.x and will be removed in version 4.0, use "Sonata\Exporter\Formatter\EnumFormatter" instead.' + ); + } + $iterator = new class([], $dateFormat, $useBackedEnumValue) extends AbstractPropertySourceIterator { public function rewind(): void { @@ -45,41 +62,18 @@ public function getValue(mixed $value): bool|int|float|string|null /** * @return iterable */ - public function provideGetValueCases(): iterable + public function provideLegacyGetValueCases(): iterable { $datetime = new \DateTime(); $dateTimeImmutable = new \DateTimeImmutable(); - yield [[1, 2, 3], '[1, 2, 3]']; - yield [new \ArrayIterator([1, 2, 3]), '[1, 2, 3]']; - yield [(static function (): \Generator { yield from [1, 2, 3]; })(), '[1, 2, 3]']; - yield [$datetime, $datetime->format('r')]; yield [$datetime, $datetime->format('Y-m-d H:i:s'), 'Y-m-d H:i:s']; - yield [123, 123]; - yield ['123', '123']; - yield [new ObjectWithToString('object with to string'), 'object with to string']; - yield [$dateTimeImmutable, $dateTimeImmutable->format('r')]; yield [$dateTimeImmutable, $dateTimeImmutable->format('Y-m-d H:i:s'), 'Y-m-d H:i:s']; - yield [new \DateInterval('P1Y'), 'P1Y']; - yield [new \DateInterval('P1M'), 'P1M']; - yield [new \DateInterval('P1D'), 'P1D']; - yield [new \DateInterval('PT1H'), 'PT1H']; - yield [new \DateInterval('PT1M'), 'PT1M']; - yield [new \DateInterval('PT1S'), 'PT1S']; - yield [new \DateInterval('P1Y1M'), 'P1Y1M']; - yield [new \DateInterval('P1Y1M1D'), 'P1Y1M1D']; - yield [new \DateInterval('P1Y1M1DT1H'), 'P1Y1M1DT1H']; - yield [new \DateInterval('P1Y1M1DT1H1M'), 'P1Y1M1DT1H1M']; - yield [new \DateInterval('P1Y1M1DT1H1M1S'), 'P1Y1M1DT1H1M1S']; - yield [new \DateInterval('P0Y'), 'P0Y']; - yield [new \DateInterval('PT0S'), 'P0Y']; if (\PHP_VERSION_ID < 80100) { return; } - yield [Element::Hydrogen, 'Hydrogen']; - yield [Suit::Diamonds, 'D']; yield [Suit::Diamonds, 'Diamonds', 'r', false]; } } diff --git a/tests/Source/DoctrineODMQuerySourceIteratorTest.php b/tests/Source/DoctrineODMQuerySourceIteratorTest.php index 9b7dc15b..efe9135c 100644 --- a/tests/Source/DoctrineODMQuerySourceIteratorTest.php +++ b/tests/Source/DoctrineODMQuerySourceIteratorTest.php @@ -50,12 +50,16 @@ protected function tearDown(): void ->execute(); } + /** + * @group legacy + */ public function testGetDateTimeFormat(): void { $query = $this->dm->createQueryBuilder(Document::class)->getQuery(); $iterator = new DoctrineODMQuerySourceIterator($query, []); + /** @psalm-suppress DeprecatedMethod */ static::assertSame(\DateTimeInterface::ATOM, $iterator->getDateTimeFormat()); } diff --git a/tests/Source/DoctrineORMQuerySourceIteratorTest.php b/tests/Source/DoctrineORMQuerySourceIteratorTest.php index 6d21755b..a3562335 100644 --- a/tests/Source/DoctrineORMQuerySourceIteratorTest.php +++ b/tests/Source/DoctrineORMQuerySourceIteratorTest.php @@ -68,7 +68,7 @@ public function testEntityManagerClear(): void ->getQuery(); $batchSize = 2; - $iterator = new DoctrineORMQuerySourceIterator($query, ['id'], 'r', $batchSize); + $iterator = new DoctrineORMQuerySourceIterator($query, ['id'], null, $batchSize); foreach ($iterator as $i => $item) { static::assertSame(0 === $i % $batchSize ? 0 : $i, $this->em->getUnitOfWork()->size()); diff --git a/tests/Writer/CsvWriterTest.php b/tests/Writer/CsvWriterTest.php index d85a524c..bfc20ee7 100644 --- a/tests/Writer/CsvWriterTest.php +++ b/tests/Writer/CsvWriterTest.php @@ -15,6 +15,11 @@ use PHPUnit\Framework\TestCase; use Sonata\Exporter\Exception\InvalidDataFormatException; +use Sonata\Exporter\Formatter\BoolFormatter; +use Sonata\Exporter\Formatter\DateTimeFormatter; +use Sonata\Exporter\Formatter\EnumFormatter; +use Sonata\Exporter\Formatter\IterableFormatter; +use Sonata\Exporter\Tests\Source\Fixtures\Suit; use Sonata\Exporter\Writer\CsvWriter; final class CsvWriterTest extends TestCase @@ -145,4 +150,38 @@ public function testWithBom(): void static::assertIsString($content); static::assertSame($expected, trim($content)); } + + /** + * @requires PHP >= 8.1 + */ + public function testValueFormatting(): void + { + $writer = new CsvWriter($this->filename, ',', '"', '\\', false); + $writer->addFormatter(new BoolFormatter()); + $writer->addFormatter(new DateTimeFormatter()); + $writer->addFormatter(new EnumFormatter()); + $writer->addFormatter(new IterableFormatter()); + $writer->open(); + + $writer->write([ + ' john , ""2"', + 'doe', + '1', + true, + new \DateTimeImmutable('1986-03-22 19:45:54', new \DateTimeZone('America/Argentina/Buenos_Aires')), + Suit::Hearts, + [ + 'foo' => ['bool', 'float'], + 'bar' => ['string', 'int'], + ], + ]); + + $writer->close(); + + $expected = '" john , """"2""",doe,1,yes,"Sat, 22 Mar 1986 19:45:54 -0300",H,"[Array, Array]"'; + + $content = file_get_contents($this->filename); + static::assertIsString($content); + static::assertSame($expected, trim($content)); + } } diff --git a/tests/Writer/FormattedBoolWriterTest.php b/tests/Writer/FormattedBoolWriterTest.php index 164d18bd..833878e4 100644 --- a/tests/Writer/FormattedBoolWriterTest.php +++ b/tests/Writer/FormattedBoolWriterTest.php @@ -21,6 +21,10 @@ * Format boolean before use another writer. * * @author Robin Chalas + * + * @group legacy + * + * @psalm-suppress DeprecatedClass */ final class FormattedBoolWriterTest extends TestCase {