diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index c2e38e68..15279c78 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1,30 +1,5 @@ parameters: ignoreErrors: - - - message: "#^Cannot cast mixed to string\\.$#" - count: 1 - path: src/batch-box-spout/src/FlatFileReader.php - - - - message: "#^Method Yokai\\\\Batch\\\\Bridge\\\\Box\\\\Spout\\\\FlatFileReader\\:\\:combine\\(\\) should return array\\\\|null but returns array\\\\.$#" - count: 1 - path: src/batch-box-spout/src/FlatFileReader.php - - - - message: "#^Method Yokai\\\\Batch\\\\Bridge\\\\Box\\\\Spout\\\\FlatFileReader\\:\\:rows\\(\\) return type has no value type specified in iterable type array\\.$#" - count: 1 - path: src/batch-box-spout/src/FlatFileReader.php - - - - message: "#^Offset 'encoding' on array\\{delimiter\\?\\: string, enclosure\\?\\: string\\} in isset\\(\\) does not exist\\.$#" - count: 1 - path: src/batch-box-spout/src/FlatFileReader.php - - - - message: "#^Cannot cast mixed to string\\.$#" - count: 1 - path: src/batch-box-spout/src/FlatFileWriter.php - - message: "#^Argument of an invalid type mixed supplied for foreach, only iterables are supported\\.$#" count: 2 diff --git a/src/batch-box-spout/docs/flat-file-item-reader.md b/src/batch-box-spout/docs/flat-file-item-reader.md index ebaa04e1..0a831a96 100644 --- a/src/batch-box-spout/docs/flat-file-item-reader.md +++ b/src/batch-box-spout/docs/flat-file-item-reader.md @@ -1,27 +1,42 @@ # Item reader with CSV/ODS/XLSX files -The [FlatFileReader](../src/FlatFileReader.php) is a reader +The [FlatFileReader](../src/Reader/FlatFileReader.php) is a reader that will read from CSV/ODS/XLSX file and return each line as an array. ```php ';', 'enclosure' => '|']); +// Each lines will be read as simple array +new FlatFileReader( + new StaticValueParameterAccessor('/path/to/file.csv'), + new CSVOptions(';', '|'), + HeaderStrategy::none() +); // Read .ods file -// Each item will be an array_combine of headers constructor arg as key and line as values -new FlatFileReader(Type::ODS, new StaticValueParameterAccessor('/path/to/file.ods'), [], FlatFileReader::HEADERS_MODE_SKIP, ['static', 'header', 'keys']); +// Only sheet named "Sheet name to read" will be read +// Each item will be an array_combine of first line as key and line as values +new FlatFileReader( + new StaticValueParameterAccessor('/path/to/file.ods'), + new ODSOptions(SheetFilter::nameIs('Sheet name to read')), + HeaderStrategy::combine() +); ``` ## On the same subject diff --git a/src/batch-box-spout/docs/flat-file-item-writer.md b/src/batch-box-spout/docs/flat-file-item-writer.md index 3deddf70..e75fdbdd 100644 --- a/src/batch-box-spout/docs/flat-file-item-writer.md +++ b/src/batch-box-spout/docs/flat-file-item-writer.md @@ -1,27 +1,39 @@ # Item writer with CSV/ODS/XLSX files -The [FlatFileWriter](../src/FlatFileWriter.php) is a writer that will write to CSV/ODS/XLSX file and each item will +The [FlatFileWriter](../src/Writer/FlatFileWriter.php) is a writer that will write to CSV/ODS/XLSX file and each item will written its own line. ```php ';', 'enclosure' => '|']); +new FlatFileWriter( + new StaticValueParameterAccessor('/path/to/file.csv'), + new CSVOptions(';', '|') +); // Write items to .ods file -// That File will contain a header line with : static | header | keys -new FlatFileWriter(Type::ODS, new StaticValueParameterAccessor('/path/to/file.ods'), ['static', 'header', 'keys']); +// That file will contain a header line with : static | header | keys +// Change the sheet name data will be written +// Change the default style of each cell +new FlatFileWriter( + new StaticValueParameterAccessor('/path/to/file.ods'), + new ODSOptions('The sheet name', (new StyleBuilder())->setFontBold()->build()), + ['static', 'header', 'keys'] +); ``` ## On the same subject diff --git a/src/batch-box-spout/src/Exception/InvalidRowSizeException.php b/src/batch-box-spout/src/Exception/InvalidRowSizeException.php new file mode 100644 index 00000000..1aec2099 --- /dev/null +++ b/src/batch-box-spout/src/Exception/InvalidRowSizeException.php @@ -0,0 +1,47 @@ + + */ + private array $headers; + /** + * @phpstan-var array + */ + private array $row; + + /** + * @phpstan-param array $headers + * @phpstan-param array $row + */ + public function __construct(array $headers, array $row) + { + parent::__construct('Invalid row size'); + $this->headers = $headers; + $this->row = $row; + } + + /** + * @phpstan-return array + */ + public function getHeaders(): array + { + return $this->headers; + } + + /** + * @phpstan-return array + */ + public function getRow(): array + { + return $this->row; + } +} diff --git a/src/batch-box-spout/src/FlatFileReader.php b/src/batch-box-spout/src/FlatFileReader.php deleted file mode 100644 index a6947520..00000000 --- a/src/batch-box-spout/src/FlatFileReader.php +++ /dev/null @@ -1,186 +0,0 @@ -|null - */ - private ?array $headers; - - private JobParameterAccessorInterface $filePath; - - /** - * @phpstan-param array{delimiter?: string, enclosure?: string} $options - * @phpstan-param list|null $headers - */ - public function __construct( - string $type, - JobParameterAccessorInterface $filePath, - array $options = [], - string $headersMode = self::HEADERS_MODE_NONE, - array $headers = null - ) { - if (!in_array($type, self::TYPES, true)) { - throw UnexpectedValueException::enum(self::TYPES, $type, 'Invalid type.'); - } - if (!in_array($headersMode, self::HEADERS_MODES, true)) { - throw UnexpectedValueException::enum(self::HEADERS_MODES, $headersMode, 'Invalid header mode.'); - } - if ($headers !== null && $headersMode === self::HEADERS_MODE_COMBINE) { - throw new InvalidArgumentException( - sprintf('In "%s" header mode you should not provide header by yourself', self::HEADERS_MODE_COMBINE) - ); - } - - $this->type = $type; - $this->filePath = $filePath; - $this->options = $options; - $this->headersMode = $headersMode; - $this->headers = $headers; - } - - /** - * @inheritDoc - */ - public function read(): iterable - { - $reader = ReaderFactory::createFromType($this->type); - if ($reader instanceof CsvReader) { - $reader->setFieldDelimiter($this->options['delimiter'] ?? ','); - $reader->setFieldEnclosure($this->options['enclosure'] ?? '"'); - if (isset($this->options['encoding'])) { - $reader->setEncoding($this->options['encoding']); - } - } - $reader->open((string)$this->filePath->get($this->jobExecution)); - - $headers = $this->headers; - - foreach ($this->rows($reader) as $rowIndex => $row) { - if ($rowIndex === 1) { - if ($this->headersMode === self::HEADERS_MODE_COMBINE) { - $headers = $row; - } - if (in_array($this->headersMode, [self::HEADERS_MODE_COMBINE, self::HEADERS_MODE_SKIP])) { - continue; - } - } - - if (is_array($headers)) { - $row = $this->combine($headers, $row, $rowIndex); - if ($row === null) { - continue; - } - } - - yield $row; - } - - $reader->close(); - } - - /** - * @phpstan-return Generator - */ - private function rows(ReaderInterface $reader): Generator - { - /** @var SheetInterface $sheet */ - foreach ($reader->getSheetIterator() as $sheet) { - foreach ($sheet->getRowIterator() as $rowIndex => $row) { - if ($row instanceof Row) { - $row = $row->toArray(); - } - - yield $rowIndex => $row; - } - } - } - - /** - * @phpstan-param array $headers - * @phpstan-param array $row - * - * @phpstan-return array|null - */ - private function combine(array $headers, array $row, int $rowIndex): ?array - { - try { - /** @var array|false $combined */ - $combined = @array_combine($headers, $row); - if ($combined === false) { - // @codeCoverageIgnoreStart - // Prior to PHP 8.0 array_combine only trigger a warning - // Now it is throwing a ValueError - throw new \ValueError( - 'array_combine(): Argument #1 ($keys) and argument #2 ($values) ' . - 'must have the same number of elements' - ); - // @codeCoverageIgnoreEnd - } - - return $combined; - } catch (\ValueError $exception) { - $this->jobExecution->addWarning( - new Warning( - 'Expecting row {row} to have exactly {expected} columns(s), but got {actual}.', - [ - '{row}' => (string)$rowIndex, - '{expected}' => (string)count($headers), - '{actual}' => (string)count($row), - ], - ['headers' => $headers, 'row' => $row] - ) - ); - } - - return null; - } -} diff --git a/src/batch-box-spout/src/Reader/FlatFileReader.php b/src/batch-box-spout/src/Reader/FlatFileReader.php new file mode 100644 index 00000000..b3e4cea6 --- /dev/null +++ b/src/batch-box-spout/src/Reader/FlatFileReader.php @@ -0,0 +1,97 @@ +filePath = $filePath; + $this->options = $options; + $this->headerStrategy = $headerStrategy ?? HeaderStrategy::skip(); + } + + /** + * @inheritDoc + */ + public function read(): iterable + { + /** @var string $path */ + $path = $this->filePath->get($this->jobExecution); + + $reader = ReaderFactory::createFromFile($path); + $this->options->configure($reader); + $reader->open($path); + + foreach ($this->rows($reader) as $rowIndex => $row) { + if ($rowIndex === 1) { + if (!$this->headerStrategy->setHeaders($row)) { + continue; + } + } + + try { + yield $this->headerStrategy->getItem($row); + } catch (InvalidRowSizeException $exception) { + $this->jobExecution->addWarning( + new Warning( + 'Expecting row {row} to have exactly {expected} columns(s), but got {actual}.', + [ + '{row}' => (string)$rowIndex, + '{expected}' => (string)count($exception->getHeaders()), + '{actual}' => (string)count($exception->getRow()), + ], + ['headers' => $exception->getHeaders(), 'row' => $exception->getRow()] + ) + ); + } + } + + $reader->close(); + } + + /** + * @phpstan-return Generator> + */ + private function rows(ReaderInterface $reader): Generator + { + foreach ($this->options->getSheets($reader) as $sheet) { + foreach ($sheet->getRowIterator() as $rowIndex => $row) { + if ($row instanceof Row) { + $row = $row->toArray(); + } + + yield $rowIndex => $row; + } + } + } +} diff --git a/src/batch-box-spout/src/Reader/HeaderStrategy.php b/src/batch-box-spout/src/Reader/HeaderStrategy.php new file mode 100644 index 00000000..44ad13e6 --- /dev/null +++ b/src/batch-box-spout/src/Reader/HeaderStrategy.php @@ -0,0 +1,103 @@ +|null + */ + private ?array $headers; + + /** + * @phpstan-param list|null $headers + */ + private function __construct(string $mode, ?array $headers) + { + $this->mode = $mode; + $this->headers = $headers; + } + + /** + * Read file has headers but should be skipped. + * + * @phpstan-param list|null $headers + */ + public static function skip(array $headers = null): self + { + return new self(self::SKIP, $headers); + } + + /** + * Read file has headers and should be used to array_combine each row. + */ + public static function combine(): self + { + return new self(self::COMBINE, null); + } + + /** + * Read file has no headers. + * + * @phpstan-param list|null $headers + */ + public static function none(array $headers = null): self + { + return new self(self::NONE, $headers); + } + + /** + * @phpstan-param list $headers + * @internal + */ + public function setHeaders(array $headers): bool + { + if ($this->mode === self::NONE) { + return true; // row should be read, will be considered as an item + } + if ($this->mode === self::COMBINE) { + $this->headers = $headers; + } + + return false; // row should be skipped, will not be considered as an item + } + + /** + * @throws InvalidRowSizeException + * + * @phpstan-param array $row + * + * @phpstan-return array + * @internal + */ + public function getItem(array $row): array + { + if ($this->headers === null) { + return $row; // headers were not set, read row as is + } + + try { + /** @phpstan-var array $combined */ + $combined = @array_combine($this->headers, $row); + } catch (\ValueError) { + throw new InvalidRowSizeException($this->headers, $row); + } + + return $combined; + } +} diff --git a/src/batch-box-spout/src/Reader/Options/CSVOptions.php b/src/batch-box-spout/src/Reader/Options/CSVOptions.php new file mode 100644 index 00000000..dbeeb072 --- /dev/null +++ b/src/batch-box-spout/src/Reader/Options/CSVOptions.php @@ -0,0 +1,59 @@ +delimiter = $delimiter; + $this->enclosure = $enclosure; + $this->encoding = $encoding; + $this->formatDates = $formatDates; + $this->preserveEmptyRows = $preserveEmptyRows; + } + + /** + * @inheritdoc + */ + public function configure(ReaderInterface $reader): void + { + if (!$reader instanceof CSVReader) { + throw UnexpectedValueException::type(CSVReader::class, $reader); + } + + $reader->setFieldDelimiter($this->delimiter); + $reader->setFieldEnclosure($this->enclosure); + $reader->setEncoding($this->encoding); + $reader->setShouldFormatDates($this->formatDates); + $reader->setShouldPreserveEmptyRows($this->preserveEmptyRows); + } + + /** + * @inheritdoc + */ + public function getSheets(ReaderInterface $reader): iterable + { + return $reader->getSheetIterator(); + } +} diff --git a/src/batch-box-spout/src/Reader/Options/ODSOptions.php b/src/batch-box-spout/src/Reader/Options/ODSOptions.php new file mode 100644 index 00000000..4c923025 --- /dev/null +++ b/src/batch-box-spout/src/Reader/Options/ODSOptions.php @@ -0,0 +1,50 @@ +sheetFilter = $sheetFilter ?? SheetFilter::all(); + $this->formatDates = $formatDates; + $this->preserveEmptyRows = $preserveEmptyRows; + } + + /** + * @inheritDoc + */ + public function configure(ReaderInterface $reader): void + { + if (!$reader instanceof ODSReader) { + throw UnexpectedValueException::type(ODSReader::class, $reader); + } + + $reader->setShouldFormatDates($this->formatDates); + $reader->setShouldPreserveEmptyRows($this->preserveEmptyRows); + } + + /** + * @inheritDoc + */ + public function getSheets(ReaderInterface $reader): iterable + { + yield from $this->sheetFilter->getSheets($reader); + } +} diff --git a/src/batch-box-spout/src/Reader/Options/OptionsInterface.php b/src/batch-box-spout/src/Reader/Options/OptionsInterface.php new file mode 100644 index 00000000..287d34d9 --- /dev/null +++ b/src/batch-box-spout/src/Reader/Options/OptionsInterface.php @@ -0,0 +1,35 @@ + + * @internal + */ + public function getSheets(ReaderInterface $reader): iterable; +} diff --git a/src/batch-box-spout/src/Reader/Options/SheetFilter.php b/src/batch-box-spout/src/Reader/Options/SheetFilter.php new file mode 100644 index 00000000..e4f8947a --- /dev/null +++ b/src/batch-box-spout/src/Reader/Options/SheetFilter.php @@ -0,0 +1,70 @@ +accept = $accept; + } + + /** + * Will read every sheets in file. + */ + public static function all(): self + { + return new self(fn() => true); + } + + /** + * Will read sheets that are at specified indexes. + */ + public static function indexIs(int $index, int ...$indexes): self + { + $indexes[] = $index; + + return new self(fn(SheetInterface $sheet) => \in_array($sheet->getIndex(), $indexes, true)); + } + + /** + * Will read sheets that are named as specified. + */ + public static function nameIs(string $name, string ...$names): self + { + $names[] = $name; + + return new self(fn(SheetInterface $sheet) => \in_array($sheet->getName(), $names, true)); + } + + /** + * @return Generator&SheetInterface[] + * @phpstan-return Generator + * @internal + */ + public function getSheets(ReaderInterface $reader): Generator + { + foreach ($reader->getSheetIterator() as $sheet) { + if (($this->accept)($sheet)) { + yield $sheet; + } + } + } +} diff --git a/src/batch-box-spout/src/Reader/Options/XLSXOptions.php b/src/batch-box-spout/src/Reader/Options/XLSXOptions.php new file mode 100644 index 00000000..536f34ee --- /dev/null +++ b/src/batch-box-spout/src/Reader/Options/XLSXOptions.php @@ -0,0 +1,50 @@ +sheetFilter = $sheetFilter ?? SheetFilter::all(); + $this->formatDates = $formatDates; + $this->preserveEmptyRows = $preserveEmptyRows; + } + + /** + * @inheritDoc + */ + public function configure(ReaderInterface $reader): void + { + if (!$reader instanceof XLSXReader) { + throw UnexpectedValueException::type(XLSXReader::class, $reader); + } + + $reader->setShouldFormatDates($this->formatDates); + $reader->setShouldPreserveEmptyRows($this->preserveEmptyRows); + } + + /** + * @inheritDoc + */ + public function getSheets(ReaderInterface $reader): iterable + { + yield from $this->sheetFilter->getSheets($reader); + } +} diff --git a/src/batch-box-spout/src/FlatFileWriter.php b/src/batch-box-spout/src/Writer/FlatFileWriter.php similarity index 52% rename from src/batch-box-spout/src/FlatFileWriter.php rename to src/batch-box-spout/src/Writer/FlatFileWriter.php index c2548150..26710f0b 100644 --- a/src/batch-box-spout/src/FlatFileWriter.php +++ b/src/batch-box-spout/src/Writer/FlatFileWriter.php @@ -2,13 +2,14 @@ declare(strict_types=1); -namespace Yokai\Batch\Bridge\Box\Spout; +namespace Yokai\Batch\Bridge\Box\Spout\Writer; -use Box\Spout\Common\Type; +use Box\Spout\Common\Entity\Row; use Box\Spout\Writer\Common\Creator\WriterEntityFactory; use Box\Spout\Writer\Common\Creator\WriterFactory; -use Box\Spout\Writer\CSV\Writer as CsvWriter; use Box\Spout\Writer\WriterInterface; +use Box\Spout\Writer\WriterMultiSheetsAbstract; +use Yokai\Batch\Bridge\Box\Spout\Writer\Options\OptionsInterface; use Yokai\Batch\Exception\BadMethodCallException; use Yokai\Batch\Exception\RuntimeException; use Yokai\Batch\Exception\UnexpectedValueException; @@ -20,7 +21,7 @@ use Yokai\Batch\Job\Parameters\JobParameterAccessorInterface; /** - * This {@see ItemReaderInterface} will write to CSV/ODS/XLSX file + * This {@see ItemWriterInterface} will write to CSV/ODS/XLSX file * and each item will written its own line. */ final class FlatFileWriter implements @@ -31,56 +32,28 @@ final class FlatFileWriter implements { use JobExecutionAwareTrait; - private const TYPES = [Type::CSV, Type::XLSX, Type::ODS]; - - /** - * @var string - */ - private string $type; - - /** - * @var JobParameterAccessorInterface - */ private JobParameterAccessorInterface $filePath; + private OptionsInterface $options; /** * @phpstan-var list|null */ private ?array $headers; - - /** - * @var WriterInterface|null - */ private ?WriterInterface $writer = null; - - /** - * @var bool - */ private bool $headersAdded = false; + private ?string $defaultSheet = null; /** - * @phpstan-var array{delimiter?: string, enclosure?: string} - */ - private array $options; - - /** - * @phpstan-param list|null $headers - * @phpstan-param array{delimiter?: string, enclosure?: string} $options + * @phpstan-param list|null $headers */ public function __construct( - string $type, JobParameterAccessorInterface $filePath, - array $headers = null, - array $options = [] + OptionsInterface $options, + array $headers = null ) { - if (!in_array($type, self::TYPES, true)) { - throw UnexpectedValueException::enum(self::TYPES, $type, 'Invalid type.'); - } - - $this->type = $type; $this->filePath = $filePath; - $this->headers = $headers; $this->options = $options; + $this->headers = $headers; } /** @@ -88,20 +61,22 @@ public function __construct( */ public function initialize(): void { - $path = (string)$this->filePath->get($this->jobExecution); - $dir = dirname($path); - if (!@is_dir($dir) && !@mkdir($dir, 0777, true)) { + /** @var string $path */ + $path = $this->filePath->get($this->jobExecution); + $dir = \dirname($path); + if (!@\is_dir($dir) && !@\mkdir($dir, 0777, true)) { throw new RuntimeException( \sprintf('Cannot create dir "%s".', $dir) ); } - $this->writer = WriterFactory::createFromType($this->type); - if ($this->writer instanceof CsvWriter) { - $this->writer->setFieldDelimiter($this->options['delimiter'] ?? ','); - $this->writer->setFieldEnclosure($this->options['enclosure'] ?? '"'); - } + $this->writer = WriterFactory::createFromFile($path); $this->writer->openToFile($path); + $this->options->configure($this->writer); + + if ($this->writer instanceof WriterMultiSheetsAbstract) { + $this->defaultSheet = $this->writer->getCurrentSheet()->getName(); + } } /** @@ -109,22 +84,33 @@ public function initialize(): void */ public function write(iterable $items): void { - if ($this->writer === null) { + $writer = $this->writer; + if ($writer === null) { throw BadMethodCallException::itemComponentNotInitialized($this); } if (!$this->headersAdded) { $this->headersAdded = true; if ($this->headers !== null) { - $this->writer->addRow(WriterEntityFactory::createRowFromArray($this->headers)); + $writer->addRow(WriterEntityFactory::createRowFromArray($this->headers)); } } foreach ($items as $row) { - if (!is_array($row)) { - throw UnexpectedValueException::type('array', $row); + if ($row instanceof WriteToSheetItem) { + $this->changeSheet($row->getSheet()); + $row = $row->getItem(); + } elseif ($this->defaultSheet !== null) { + $this->changeSheet($this->defaultSheet); + } + if (\is_array($row)) { + $row = WriterEntityFactory::createRowFromArray($row); } - $this->writer->addRow(WriterEntityFactory::createRowFromArray($row)); + if (!$row instanceof Row) { + throw UnexpectedValueException::type('array|' . Row::class, $row); + } + + $writer->addRow($row); } } @@ -141,4 +127,21 @@ public function flush(): void $this->writer = null; $this->headersAdded = false; } + + private function changeSheet(string $name): void + { + if (!$this->writer instanceof WriterMultiSheetsAbstract) { + return; + } + + foreach ($this->writer->getSheets() as $sheet) { + if ($sheet->getName() === $name) { + $this->writer->setCurrentSheet($sheet); + return; + } + } + + $sheet = $this->writer->addNewSheetAndMakeItCurrent(); + $sheet->setName($name); + } } diff --git a/src/batch-box-spout/src/Writer/Options/CSVOptions.php b/src/batch-box-spout/src/Writer/Options/CSVOptions.php new file mode 100644 index 00000000..2be1ffdd --- /dev/null +++ b/src/batch-box-spout/src/Writer/Options/CSVOptions.php @@ -0,0 +1,40 @@ +delimiter = $delimiter; + $this->enclosure = $enclosure; + $this->addBOM = $addBOM; + } + + /** + * @inheritdoc + */ + public function configure(WriterInterface $writer): void + { + if (!$writer instanceof CSVWriter) { + throw UnexpectedValueException::type(CSVWriter::class, $writer); + } + + $writer->setFieldDelimiter($this->delimiter); + $writer->setFieldEnclosure($this->enclosure); + $writer->setShouldAddBOM($this->addBOM); + } +} diff --git a/src/batch-box-spout/src/Writer/Options/ODSOptions.php b/src/batch-box-spout/src/Writer/Options/ODSOptions.php new file mode 100644 index 00000000..78daea78 --- /dev/null +++ b/src/batch-box-spout/src/Writer/Options/ODSOptions.php @@ -0,0 +1,42 @@ +sheet = $sheet; + $this->style = $style; + } + + /** + * @inheritDoc + */ + public function configure(WriterInterface $writer): void + { + if (!$writer instanceof ODSWriter) { + throw UnexpectedValueException::type(ODSWriter::class, $writer); + } + + if ($this->sheet) { + $writer->getCurrentSheet()->setName($this->sheet); + } + if ($this->style) { + $writer->setDefaultRowStyle($this->style); + } + } +} diff --git a/src/batch-box-spout/src/Writer/Options/OptionsInterface.php b/src/batch-box-spout/src/Writer/Options/OptionsInterface.php new file mode 100644 index 00000000..37591e1e --- /dev/null +++ b/src/batch-box-spout/src/Writer/Options/OptionsInterface.php @@ -0,0 +1,25 @@ +sheet = $sheet; + $this->style = $style; + } + + /** + * @inheritDoc + */ + public function configure(WriterInterface $writer): void + { + if (!$writer instanceof XLSXWriter) { + throw UnexpectedValueException::type(XLSXWriter::class, $writer); + } + + if ($this->sheet) { + $writer->getCurrentSheet()->setName($this->sheet); + } + if ($this->style) { + $writer->setDefaultRowStyle($this->style); + } + } +} diff --git a/src/batch-box-spout/src/Writer/WriteToSheetItem.php b/src/batch-box-spout/src/Writer/WriteToSheetItem.php new file mode 100644 index 00000000..2cd4bfac --- /dev/null +++ b/src/batch-box-spout/src/Writer/WriteToSheetItem.php @@ -0,0 +1,48 @@ +sheet = $sheet; + $this->item = $item; + } + + /** + * @param array $item + */ + public static function array(string $sheet, array $item, Style $style = null): self + { + return new self($sheet, WriterEntityFactory::createRowFromArray($item, $style)); + } + + public static function row(string $sheet, Row $item): self + { + return new self($sheet, $item); + } + + public function getSheet(): string + { + return $this->sheet; + } + + public function getItem(): Row + { + return $this->item; + } +} diff --git a/src/batch-box-spout/tests/FlatFileReaderTest.php b/src/batch-box-spout/tests/FlatFileReaderTest.php deleted file mode 100644 index 419c6a72..00000000 --- a/src/batch-box-spout/tests/FlatFileReaderTest.php +++ /dev/null @@ -1,265 +0,0 @@ -setJobExecution($jobExecution); - - /** @var \Iterator $got */ - $got = $reader->read(); - self::assertInstanceOf(\Iterator::class, $got); - self::assertSame($expected, iterator_to_array($got)); - } - - public function testInvalidType(): void - { - $this->expectException(UnexpectedValueException::class); - - new FlatFileReader('invalid type', new StaticValueParameterAccessor('/path/to/file')); - } - - /** - * @dataProvider types - */ - public function testInvalidHeadersMode(string $type): void - { - $this->expectException(UnexpectedValueException::class); - - new FlatFileReader($type, new StaticValueParameterAccessor('/path/to/file'), [], 'invalid header mode'); - } - - /** - * @dataProvider types - */ - public function testInvalidHeadersCombineAndHeader(string $type): void - { - $this->expectException(InvalidArgumentException::class); - - new FlatFileReader( - $type, - new StaticValueParameterAccessor('/path/to/file'), - [], - FlatFileReader::HEADERS_MODE_COMBINE, - ['nom', 'prenom'] - ); - } - - /** - * @dataProvider types - */ - public function testMissingFileToRead(string $type): void - { - $this->expectException(CannotAccessParameterException::class); - - $reader = new FlatFileReader($type, new JobExecutionParameterAccessor('undefined')); - $reader->setJobExecution(JobExecution::createRoot('123456789', 'parent')); - - iterator_to_array($reader->read()); - } - - public function testReadWrongLineSize(): void - { - $file = __DIR__ . '/fixtures/wrong-line-size.csv'; - $jobExecution = JobExecution::createRoot('123456789', 'parent'); - $reader = new FlatFileReader( - 'csv', - new StaticValueParameterAccessor($file), - [], - FlatFileReader::HEADERS_MODE_COMBINE - ); - $reader->setJobExecution($jobExecution); - - /** @var \Iterator $result */ - $result = $reader->read(); - self::assertInstanceOf(\Iterator::class, $result); - self::assertSame( - [ - ['firstName' => 'John', 'lastName' => 'Doe'], - ['firstName' => 'Jack', 'lastName' => 'Doe'], - ], - iterator_to_array($result) - ); - - self::assertSame( - 'Expecting row {row} to have exactly {expected} columns(s), but got {actual}.', - $jobExecution->getWarnings()[0]->getMessage() - ); - self::assertSame( - [ - '{row}' => '3', - '{expected}' => '2', - '{actual}' => '3', - ], - $jobExecution->getWarnings()[0]->getParameters() - ); - self::assertSame( - ['headers' => ['firstName', 'lastName'], 'row' => ['Jane', 'Doe', 'too much data']], - $jobExecution->getWarnings()[0]->getContext() - ); - } - - public function testReadCustomCSV(): void - { - $file = __DIR__ . '/fixtures/iso-8859-1.csv'; - $reader = new FlatFileReader( - 'csv', - new StaticValueParameterAccessor($file), - ['delimiter' => ';', 'enclosure' => '|', 'encoding' => 'ISO-8859-1'], - FlatFileReader::HEADERS_MODE_NONE, - null - ); - $reader->setJobExecution(JobExecution::createRoot('123456789', 'parent')); - - self::assertSame([ - ['Gérard', 'À peu près'], - ['Benoît', 'Bien-être'], - ['Gaëlle', 'Ça va'], - ], iterator_to_array($reader->read())); - } - - public function types(): Generator - { - foreach ([Type::CSV, Type::XLSX, Type::ODS] as $type) { - yield [$type]; - } - } - - public function combination(): Generator - { - foreach ($this->types() as [$type]) { - yield [ - $type, - FlatFileReader::HEADERS_MODE_NONE, - null, - [ - ['firstName', 'lastName'], - ['John', 'Doe'], - ['Jane', 'Doe'], - ['Jack', 'Doe'], - ], - ]; - yield [ - $type, - FlatFileReader::HEADERS_MODE_SKIP, - null, - [ - ['John', 'Doe'], - ['Jane', 'Doe'], - ['Jack', 'Doe'], - ], - ]; - yield [ - $type, - FlatFileReader::HEADERS_MODE_COMBINE, - null, - [ - ['firstName' => 'John', 'lastName' => 'Doe'], - ['firstName' => 'Jane', 'lastName' => 'Doe'], - ['firstName' => 'Jack', 'lastName' => 'Doe'], - ], - ]; - - yield [ - $type, - FlatFileReader::HEADERS_MODE_NONE, - ['prenom', 'nom'], - [ - ['prenom' => 'firstName', 'nom' => 'lastName'], - ['prenom' => 'John', 'nom' => 'Doe'], - ['prenom' => 'Jane', 'nom' => 'Doe'], - ['prenom' => 'Jack', 'nom' => 'Doe'], - ], - ]; - yield [ - $type, - FlatFileReader::HEADERS_MODE_SKIP, - ['prenom', 'nom'], - [ - ['prenom' => 'John', 'nom' => 'Doe'], - ['prenom' => 'Jane', 'nom' => 'Doe'], - ['prenom' => 'Jack', 'nom' => 'Doe'], - ], - ]; - - if ($type === Type::CSV) { - yield [ - $type, - FlatFileReader::HEADERS_MODE_COMBINE, - null, - [ - ['firstName' => 'John', 'lastName' => 'Doe'], - ['firstName' => 'Jane', 'lastName' => 'Doe'], - ['firstName' => 'Jack', 'lastName' => 'Doe'], - ], - ['delimiter' => '|'], - __DIR__ . '/fixtures/sample-pipe.csv' - ]; - yield [ - $type, - FlatFileReader::HEADERS_MODE_NONE, - null, - [ - ['firstName', 'lastName'], - ['John', 'Doe'], - ['Jane', 'Doe'], - ['Jack', 'Doe'], - ], - ['delimiter' => '|'], - __DIR__ . '/fixtures/sample-pipe.csv' - ]; - yield [ - $type, - FlatFileReader::HEADERS_MODE_SKIP, - null, - [ - ['John', 'Doe'], - ['Jane', 'Doe'], - ['Jack', 'Doe'], - ], - ['delimiter' => '|'], - __DIR__ . '/fixtures/sample-pipe.csv' - ]; - yield [ - $type, - FlatFileReader::HEADERS_MODE_SKIP, - ['prenom', 'nom'], - [ - ['prenom' => 'John', 'nom' => 'Doe'], - ['prenom' => 'Jane', 'nom' => 'Doe'], - ['prenom' => 'Jack', 'nom' => 'Doe'], - ], - ['delimiter' => '|'], - __DIR__ . '/fixtures/sample-pipe.csv' - ]; - } - } - } -} diff --git a/src/batch-box-spout/tests/FlatFileWriterTest.php b/src/batch-box-spout/tests/FlatFileWriterTest.php deleted file mode 100644 index 8154159f..00000000 --- a/src/batch-box-spout/tests/FlatFileWriterTest.php +++ /dev/null @@ -1,222 +0,0 @@ -setJobExecution(JobExecution::createRoot('123456789', 'export')); - - $writer->initialize(); - $writer->write($itemsToWrite); - $writer->flush(); - $this->assertFileContents($type, $file, $expectedContent); - } - - public function testInvalidType(): void - { - $this->expectException(UnexpectedValueException::class); - - new FlatFileWriter('invalid type', new StaticValueParameterAccessor('/path/to/file')); - } - - /** - * @dataProvider types - */ - public function testSomethingThatIsNotAnArray(string $type): void - { - $this->expectException(UnexpectedValueException::class); - - $file = self::WRITE_DIR . '/not-an-array.' . $type; - - $writer = new FlatFileWriter($type, new StaticValueParameterAccessor($file)); - $writer->setJobExecution(JobExecution::createRoot('123456789', 'export')); - - $writer->initialize(); - $writer->write([true]); - } - - /** - * @dataProvider types - */ - public function testCannotCreateFile(string $type): void - { - $this->expectException(RuntimeException::class); - - $file = '/path/to/a/dir/that/do/not/exists/and/not/creatable/file.' . $type; - - $writer = new FlatFileWriter($type, new StaticValueParameterAccessor($file)); - $writer->setJobExecution(JobExecution::createRoot('123456789', 'export')); - - $writer->initialize(); - } - - /** - * @dataProvider types - */ - public function testShouldInitializeBeforeWrite(string $type): void - { - $this->expectException(BadMethodCallException::class); - - $writer = new FlatFileWriter($type, new StaticValueParameterAccessor('/path/to/file')); - $writer->write([true]); - } - - /** - * @dataProvider types - */ - public function testShouldInitializeBeforeFlush(string $type): void - { - $this->expectException(BadMethodCallException::class); - - $writer = new FlatFileWriter($type, new StaticValueParameterAccessor('/path/to/file')); - $writer->flush(); - } - - /** - * @dataProvider types - */ - public function testMissingFileToWriter(string $type) - { - $this->expectException(CannotAccessParameterException::class); - - $reader = new FlatFileWriter($type, new JobExecutionParameterAccessor('undefined')); - $reader->setJobExecution(JobExecution::createRoot('123456789', 'parent')); - - $reader->initialize(); - } - - public function types(): \Generator - { - foreach ([Type::CSV, Type::XLSX, Type::ODS] as $type) { - yield [$type]; - } - } - - public function combination(): \Generator - { - $headers = ['firstName', 'lastName']; - $items = [ - ['John', 'Doe'], - ['Jane', 'Doe'], - ['Jack', 'Doe'], - ]; - $content = <<types() as [$type]) { - yield [ - $type, - "header-in-items.$type", - null, - array_merge([$headers], $items), - $content, - ]; - yield [ - $type, - "header-in-constructor.$type", - $headers, - $items, - $content, - ]; - if ($type === Type::CSV) { - yield [ - $type, - "header-in-items-with-pipes.$type", - null, - array_merge([$headers], $items), - $contentPipe, - ['delimiter' => '|'], - ]; - yield [ - $type, - "header-in-constructor-with-pipes.$type", - $headers, - $items, - $contentPipe, - ['delimiter' => '|'], - ]; - } - } - } - - private function assertFileContents(string $type, string $filePath, string $inlineData): void - { - $strings = array_merge(...array_map('str_getcsv', explode(PHP_EOL, $inlineData))); - - switch ($type) { - case Type::CSV: - $fileContents = file_get_contents($filePath); - foreach ($strings as $string) { - self::assertStringContainsString($string, $fileContents); - } - break; - - case Type::XLSX: - $pathToSheetFile = $filePath . '#xl/worksheets/sheet1.xml'; - $xmlContents = file_get_contents('zip://' . $pathToSheetFile); - foreach ($strings as $string) { - self::assertStringContainsString($string, $xmlContents); - } - break; - - case Type::ODS: - $xmlReader = new XMLReader(); - $xmlReader->openFileInZip($filePath, 'content.xml'); - $xmlReader->readUntilNodeFound('table:table'); - $sheetXmlAsString = $xmlReader->readOuterXml(); - foreach ($strings as $string) { - self::assertStringContainsString("$string", $sheetXmlAsString); - } - break; - } - } -} diff --git a/src/batch-box-spout/tests/Reader/FlatFileReaderTest.php b/src/batch-box-spout/tests/Reader/FlatFileReaderTest.php new file mode 100644 index 00000000..0eb52170 --- /dev/null +++ b/src/batch-box-spout/tests/Reader/FlatFileReaderTest.php @@ -0,0 +1,302 @@ +setJobExecution($jobExecution); + + /** @var \Iterator $got */ + $got = $reader->read(); + self::assertInstanceOf(\Iterator::class, $got); + self::assertSame($expected, iterator_to_array($got)); + } + + public function sets(): Generator + { + $csv = __DIR__ . '/fixtures/sample.csv'; + $ods = __DIR__ . '/fixtures/sample.ods'; + $xlsx = __DIR__ . '/fixtures/sample.xlsx'; + + // first line is not header + $expected = [ + ['firstName', 'lastName'], + ['John', 'Doe'], + ['Jane', 'Doe'], + ['Jack', 'Doe'], + ]; + yield [ + $csv, + fn() => new CSVOptions(), + fn() => HeaderStrategy::none(), + $expected, + ]; + yield [ + $ods, + fn() => new ODSOptions(), + fn() => HeaderStrategy::none(), + $expected, + ]; + yield [ + $xlsx, + fn() => new XLSXOptions(), + fn() => HeaderStrategy::none(), + $expected, + ]; + + // first line is header and should be skipped + $expected = [ + ['John', 'Doe'], + ['Jane', 'Doe'], + ['Jack', 'Doe'], + ]; + yield [ + $csv, + fn() => new CSVOptions(), + fn() => HeaderStrategy::skip(), + $expected, + ]; + yield [ + $ods, + fn() => new ODSOptions(), + fn() => HeaderStrategy::skip(), + $expected, + ]; + yield [ + $xlsx, + fn() => new XLSXOptions(), + fn() => HeaderStrategy::skip(), + $expected, + ]; + + // first line is header and should be skipped, but headers is provided with static value + $expected = [ + ['prenom' => 'John', 'nom' => 'Doe'], + ['prenom' => 'Jane', 'nom' => 'Doe'], + ['prenom' => 'Jack', 'nom' => 'Doe'], + ]; + yield [ + $csv, + fn() => new CSVOptions(), + fn() => HeaderStrategy::skip(['prenom', 'nom']), + $expected, + ]; + yield [ + $ods, + fn() => new ODSOptions(), + fn() => HeaderStrategy::skip(['prenom', 'nom']), + $expected, + ]; + yield [ + $xlsx, + fn() => new XLSXOptions(), + fn() => HeaderStrategy::skip(['prenom', 'nom']), + $expected, + ]; + + // first line is header and should be skipped + $expected = [ + ['firstName' => 'John', 'lastName' => 'Doe'], + ['firstName' => 'Jane', 'lastName' => 'Doe'], + ['firstName' => 'Jack', 'lastName' => 'Doe'], + ]; + yield [ + $csv, + fn() => new CSVOptions(), + fn() => HeaderStrategy::combine(), + $expected, + ]; + yield [ + $ods, + fn() => new ODSOptions(), + fn() => HeaderStrategy::combine(), + $expected, + ]; + yield [ + $xlsx, + fn() => new XLSXOptions(), + fn() => HeaderStrategy::combine(), + $expected, + ]; + + // non-standard CSV (delimiter and enclosure changed) encoded in ISO-8859 + yield [ + __DIR__ . '/fixtures/iso-8859-1.csv', + fn() => new CSVOptions(';', '|', 'ISO-8859-1'), + fn() => HeaderStrategy::none(), + [ + ['Gérard', 'À peu près'], + ['Benoît', 'Bien-être'], + ['Gaëlle', 'Ça va'], + ], + ]; + + // multi-tab files, 1st tab + $expected = [ + ['firstName' => 'John', 'lastName' => 'Doe'], + ['firstName' => 'Jane', 'lastName' => 'Doe'], + ['firstName' => 'Jack', 'lastName' => 'Doe'], + ]; + yield [ + __DIR__ . '/fixtures/multi-tabs.ods', + fn() => new ODSOptions(SheetFilter::indexIs(0)), + fn() => HeaderStrategy::combine(), + $expected, + ]; + yield [ + __DIR__ . '/fixtures/multi-tabs.xlsx', + fn() => new XLSXOptions(SheetFilter::indexIs(0)), + fn() => HeaderStrategy::combine(), + $expected, + ]; + + // multi-tab files, tab "Français" + $expected = [ + ['prénom' => 'Jean', 'nom' => 'Bon'], + ['prénom' => 'Jeanne', 'nom' => 'Aimar'], + ['prénom' => 'Jacques', 'nom' => 'Ouzi'], + ]; + yield [ + __DIR__ . '/fixtures/multi-tabs.ods', + fn() => new ODSOptions(SheetFilter::nameIs('Français')), + fn() => HeaderStrategy::combine(), + $expected, + ]; + yield [ + __DIR__ . '/fixtures/multi-tabs.xlsx', + fn() => new XLSXOptions(SheetFilter::nameIs('Français')), + fn() => HeaderStrategy::combine(), + $expected, + ]; + + // multi-tab files, all tabs + $expected = [ + ['firstName' => 'John', 'lastName' => 'Doe'], + ['firstName' => 'Jane', 'lastName' => 'Doe'], + ['firstName' => 'Jack', 'lastName' => 'Doe'], + ['prénom' => 'Jean', 'nom' => 'Bon'], + ['prénom' => 'Jeanne', 'nom' => 'Aimar'], + ['prénom' => 'Jacques', 'nom' => 'Ouzi'], + ]; + yield [ + __DIR__ . '/fixtures/multi-tabs.ods', + fn() => new ODSOptions(), + fn() => HeaderStrategy::combine(), + $expected, + ]; + yield [ + __DIR__ . '/fixtures/multi-tabs.xlsx', + fn() => new XLSXOptions(), + fn() => HeaderStrategy::combine(), + $expected, + ]; + } + + public function testReadWrongLineSize(): void + { + $file = __DIR__ . '/fixtures/wrong-line-size.csv'; + $jobExecution = JobExecution::createRoot('123456789', 'parent'); + $reader = new FlatFileReader( + new StaticValueParameterAccessor($file), + new CSVOptions(), + HeaderStrategy::combine() + ); + $reader->setJobExecution($jobExecution); + + /** @var \Iterator $result */ + $result = $reader->read(); + self::assertInstanceOf(\Iterator::class, $result); + self::assertSame( + [ + ['firstName' => 'John', 'lastName' => 'Doe'], + ['firstName' => 'Jack', 'lastName' => 'Doe'], + ], + iterator_to_array($result) + ); + + self::assertSame( + 'Expecting row {row} to have exactly {expected} columns(s), but got {actual}.', + $jobExecution->getWarnings()[0]->getMessage() + ); + self::assertSame( + [ + '{row}' => '3', + '{expected}' => '2', + '{actual}' => '3', + ], + $jobExecution->getWarnings()[0]->getParameters() + ); + self::assertSame( + ['headers' => ['firstName', 'lastName'], 'row' => ['Jane', 'Doe', 'too much data']], + $jobExecution->getWarnings()[0]->getContext() + ); + } + + /** + * @dataProvider wrongOptions + */ + public function testWrongOptions(string $file, callable $options): void + { + $this->expectException(UnexpectedValueException::class); + + $jobExecution = JobExecution::createRoot('123456789', 'parent'); + $reader = new FlatFileReader(new StaticValueParameterAccessor($file), $options()); + $reader->setJobExecution($jobExecution); + + iterator_to_array($reader->read()); + } + + public function wrongOptions(): \Generator + { + // with CSV file, CSVOptions is expected + yield [ + __DIR__ . '/fixtures/sample.csv', + fn() => new XLSXOptions(), + ]; + yield [ + __DIR__ . '/fixtures/sample.csv', + fn() => new ODSOptions(), + ]; + + // with ODS file, ODSOptions is expected + yield [ + __DIR__ . '/fixtures/sample.ods', + fn() => new CSVOptions(), + ]; + yield [ + __DIR__ . '/fixtures/sample.ods', + fn() => new XLSXOptions(), + ]; + + // with XLSX file, XLSXOptions is expected + yield [ + __DIR__ . '/fixtures/sample.xlsx', + fn() => new CSVOptions(), + ]; + yield [ + __DIR__ . '/fixtures/sample.xlsx', + fn() => new ODSOptions(), + ]; + } +} diff --git a/src/batch-box-spout/tests/fixtures/iso-8859-1.csv b/src/batch-box-spout/tests/Reader/fixtures/iso-8859-1.csv similarity index 100% rename from src/batch-box-spout/tests/fixtures/iso-8859-1.csv rename to src/batch-box-spout/tests/Reader/fixtures/iso-8859-1.csv diff --git a/src/batch-box-spout/tests/Reader/fixtures/multi-tabs.ods b/src/batch-box-spout/tests/Reader/fixtures/multi-tabs.ods new file mode 100644 index 00000000..1ba774d4 Binary files /dev/null and b/src/batch-box-spout/tests/Reader/fixtures/multi-tabs.ods differ diff --git a/src/batch-box-spout/tests/Reader/fixtures/multi-tabs.xlsx b/src/batch-box-spout/tests/Reader/fixtures/multi-tabs.xlsx new file mode 100644 index 00000000..6e2d3e8b Binary files /dev/null and b/src/batch-box-spout/tests/Reader/fixtures/multi-tabs.xlsx differ diff --git a/src/batch-box-spout/tests/fixtures/sample.csv b/src/batch-box-spout/tests/Reader/fixtures/sample.csv similarity index 100% rename from src/batch-box-spout/tests/fixtures/sample.csv rename to src/batch-box-spout/tests/Reader/fixtures/sample.csv diff --git a/src/batch-box-spout/tests/fixtures/sample.ods b/src/batch-box-spout/tests/Reader/fixtures/sample.ods similarity index 100% rename from src/batch-box-spout/tests/fixtures/sample.ods rename to src/batch-box-spout/tests/Reader/fixtures/sample.ods diff --git a/src/batch-box-spout/tests/fixtures/sample.xlsx b/src/batch-box-spout/tests/Reader/fixtures/sample.xlsx similarity index 100% rename from src/batch-box-spout/tests/fixtures/sample.xlsx rename to src/batch-box-spout/tests/Reader/fixtures/sample.xlsx diff --git a/src/batch-box-spout/tests/fixtures/wrong-line-size.csv b/src/batch-box-spout/tests/Reader/fixtures/wrong-line-size.csv similarity index 100% rename from src/batch-box-spout/tests/fixtures/wrong-line-size.csv rename to src/batch-box-spout/tests/Reader/fixtures/wrong-line-size.csv diff --git a/src/batch-box-spout/tests/Writer/FlatFileWriterTest.php b/src/batch-box-spout/tests/Writer/FlatFileWriterTest.php new file mode 100644 index 00000000..ce7fb0bd --- /dev/null +++ b/src/batch-box-spout/tests/Writer/FlatFileWriterTest.php @@ -0,0 +1,392 @@ +setJobExecution(JobExecution::createRoot('123456789', 'export')); + + $writer->initialize(); + $writer->write($itemsToWrite); + $writer->flush(); + + self::assertFileContents($file, $expectedContent); + } + + public function sets(): \Generator + { + $headers = ['firstName', 'lastName']; + $items = [ + ['John', 'Doe'], + ['Jane', 'Doe'], + ['Jack', 'Doe'], + ]; + $contentWithoutHeader = <<types() as [$type, $options]) { + yield [ + "no-header.$type", + $options, + null, + $items, + $contentWithoutHeader, + ]; + yield [ + "with-header.$type", + $options, + $headers, + $items, + $contentWithHeader, + ]; + } + + $content = << new CSVOptions(';', '|'), + null, + $items, + $content, + ]; + + $style = (new StyleBuilder()) + ->setFontBold() + ->setFontSize(15) + ->setFontColor(Color::BLUE) + ->setShouldWrapText() + ->setCellAlignment(CellAlignment::RIGHT) + ->setBackgroundColor(Color::YELLOW) + ->build(); + + yield [ + "total-style.xlsx", + fn() => new XLSXOptions('Sheet1 with styles', $style), + null, + $items, + $contentWithoutHeader, + ]; + yield [ + "total-style.ods", + fn() => new ODSOptions('Sheet1 with styles', $style), + null, + $items, + $contentWithoutHeader, + ]; + + $blue = (new StyleBuilder()) + ->setFontBold() + ->setFontColor(Color::BLUE) + ->build(); + $red = (new StyleBuilder()) + ->setFontBold() + ->setFontColor(Color::RED) + ->build(); + $green = (new StyleBuilder()) + ->setFontBold() + ->setFontColor(Color::GREEN) + ->build(); + $styledItems = [ + WriterEntityFactory::createRowFromArray(['John', 'Doe'], $blue), + WriterEntityFactory::createRowFromArray(['Jane', 'Doe'], $red), + WriterEntityFactory::createRowFromArray(['Jack', 'Doe'], $green), + ]; + yield [ + "partial-style.xlsx", + fn() => new XLSXOptions(), + null, + $styledItems, + $contentWithoutHeader, + ]; + yield [ + "partial-style.ods", + fn() => new ODSOptions(), + null, + $styledItems, + $contentWithoutHeader, + ]; + } + + /** + * @dataProvider types + */ + public function testWriteInvalidItem(string $type, callable $options): void + { + $this->expectException(UnexpectedValueException::class); + + $file = self::WRITE_DIR . '/invalid-item.' . $type; + $writer = new FlatFileWriter(new StaticValueParameterAccessor($file), $options()); + $writer->setJobExecution(JobExecution::createRoot('123456789', 'export')); + + $writer->initialize(); + $writer->write([true]); // writer accept collection of array or \Box\Spout\Common\Entity\Row + } + + /** + * @dataProvider types + */ + public function testCannotCreateFile(string $type, callable $options): void + { + $this->expectException(RuntimeException::class); + + $file = '/path/to/a/dir/that/do/not/exists/and/not/creatable/file.' . $type; + $writer = new FlatFileWriter(new StaticValueParameterAccessor($file), $options()); + $writer->setJobExecution(JobExecution::createRoot('123456789', 'export')); + + $writer->initialize(); + } + + /** + * @dataProvider types + */ + public function testShouldInitializeBeforeWrite(string $type, callable $options): void + { + $this->expectException(BadMethodCallException::class); + + $file = self::WRITE_DIR . '/should-initialize-before-write.' . $type; + $writer = new FlatFileWriter(new StaticValueParameterAccessor($file), $options()); + $writer->write([true]); + } + + /** + * @dataProvider types + */ + public function testShouldInitializeBeforeFlush(string $type, callable $options): void + { + $this->expectException(BadMethodCallException::class); + + $file = self::WRITE_DIR . '/should-initialize-before-flush.' . $type; + $writer = new FlatFileWriter(new StaticValueParameterAccessor($file), $options()); + $writer->flush(); + } + + public function types(): \Generator + { + $types = [ + 'csv' => fn() => new CSVOptions(), + 'xlsx' => fn() => new XLSXOptions(), + 'ods' => fn() => new ODSOptions(), + ]; + foreach ($types as $type => $options) { + yield [$type, $options]; + } + } + + /** + * @dataProvider multipleSheetsOptions + */ + public function testWriteMultipleSheets(string $type, callable $options): void + { + $file = self::WRITE_DIR . '/multiple-sheets.' . $type; + self::assertFileDoesNotExist($file); + + $writer = new FlatFileWriter(new StaticValueParameterAccessor($file), $options()); + $writer->setJobExecution(JobExecution::createRoot('123456789', 'export')); + + $writer->initialize(); + $writer->write([ + WriteToSheetItem::array('English', ['John', 'Doe']), + WriteToSheetItem::array('Français', ['Jean', 'Aimar']), + WriteToSheetItem::row('English', WriterEntityFactory::createRowFromArray(['Jack', 'Doe'])), + WriteToSheetItem::row('Français', WriterEntityFactory::createRowFromArray(['Jacques', 'Ouzi'])), + ]); + $writer->flush(); + + if ($type === 'csv') { + self::assertFileContents($file, << fn() => new CSVOptions(), + 'xlsx' => fn() => new XLSXOptions('English'), + 'ods' => fn() => new ODSOptions('English'), + ]; + foreach ($types as $type => $options) { + yield [$type, $options]; + } + } + + /** + * @dataProvider wrongOptions + */ + public function testWrongOptions(string $type, callable $options): void + { + $this->expectException(UnexpectedValueException::class); + + $file = self::WRITE_DIR . '/should-initialize-before-flush.' . $type; + $jobExecution = JobExecution::createRoot('123456789', 'parent'); + $reader = new FlatFileWriter(new StaticValueParameterAccessor($file), $options()); + $reader->setJobExecution($jobExecution); + $reader->initialize(); + } + + public function wrongOptions(): \Generator + { + // with CSV file, CSVOptions is expected + yield [ + 'csv', + fn() => new XLSXOptions(), + ]; + yield [ + 'csv', + fn() => new ODSOptions(), + ]; + + // with ODS file, ODSOptions is expected + yield [ + 'ods', + fn() => new CSVOptions(), + ]; + yield [ + 'ods', + fn() => new XLSXOptions(), + ]; + + // with XLSX file, XLSXOptions is expected + yield [ + 'xlsx', + fn() => new CSVOptions(), + ]; + yield [ + 'xlsx', + fn() => new ODSOptions(), + ]; + } + + private static function assertFileContents(string $filePath, string $inlineData): void + { + $type = \strtolower(\pathinfo($filePath, PATHINFO_EXTENSION)); + $strings = array_merge(...array_map('str_getcsv', explode(PHP_EOL, $inlineData))); + + switch ($type) { + case 'csv': + $fileContents = file_get_contents($filePath); + foreach ($strings as $string) { + self::assertStringContainsString($string, $fileContents); + } + break; + + case 'xlsx': + $pathToSheetFile = $filePath . '#xl/worksheets/sheet1.xml'; + $xmlContents = file_get_contents('zip://' . $pathToSheetFile); + foreach ($strings as $string) { + self::assertStringContainsString("$string", $xmlContents); + } + break; + + case 'ods': + $sheetContent = file_get_contents('zip://' . $filePath . '#content.xml'); + if (!preg_match('#]+>[\s\S]*?<\/table:table>#', $sheetContent, $matches)) { + self::fail('No sheet found in file "' . $filePath . '".'); + } + $sheetXmlAsString = $matches[0]; + foreach ($strings as $string) { + self::assertStringContainsString("$string", $sheetXmlAsString); + } + break; + } + } + + private static function assertSheetContents(string $filePath, string $sheet, string $inlineData): void + { + $type = \strtolower(\pathinfo($filePath, PATHINFO_EXTENSION)); + $strings = array_merge(...array_map('str_getcsv', explode(PHP_EOL, $inlineData))); + + switch ($type) { + case 'csv': + $fileContents = file_get_contents($filePath); + foreach ($strings as $string) { + self::assertStringContainsString($string, $fileContents); + } + break; + + case 'xlsx': + $workbookContent = file_get_contents('zip://' . $filePath . '#xl/workbook.xml'); + if (!preg_match('#$string", $sheetContent); + } + break; + + case 'ods': + $sheetContent = file_get_contents('zip://' . $filePath . '#content.xml'); + $regex = '#[\s\S]*?<\/table:table>#'; + if (!preg_match($regex, $sheetContent, $matches)) { + self::fail('Sheet ' . $sheet . ' was not found in file "' . $filePath . '".'); + } + $sheetXmlAsString = $matches[0]; + foreach ($strings as $string) { + self::assertStringContainsString("$string", $sheetXmlAsString); + } + break; + } + } +} diff --git a/src/batch-box-spout/tests/fixtures/sample-pipe.csv b/src/batch-box-spout/tests/fixtures/sample-pipe.csv deleted file mode 100644 index 5a27f76a..00000000 --- a/src/batch-box-spout/tests/fixtures/sample-pipe.csv +++ /dev/null @@ -1,4 +0,0 @@ -firstName|lastName -John|Doe -Jane|Doe -Jack|Doe diff --git a/src/batch/docs/domain/item-job/item-reader.md b/src/batch/docs/domain/item-job/item-reader.md index b84eb9aa..cb8bc165 100644 --- a/src/batch/docs/domain/item-job/item-reader.md +++ b/src/batch/docs/domain/item-job/item-reader.md @@ -23,7 +23,7 @@ It can be any class implementing [ItemReaderInterface](../../../src/Job/Item/Ite read from an iterable you provide during construction. **Item readers from bridges:** -- [FlatFileReader (`box/spout`)](https://github.com/yokai-php/batch-box-spout/blob/0.x/src/FlatFileReader.php): +- [FlatFileReader (`box/spout`)](https://github.com/yokai-php/batch-box-spout/blob/0.x/src/Reader/FlatFileReader.php): read from any CSV/ODS/XLSX file. - [DoctrineDBALQueryReader (`doctrine/dbal`)](https://github.com/yokai-php/batch-doctrine-dbal/blob/0.x/src/DoctrineDBALQueryReader.php): read execute an SQL query and iterate over results. diff --git a/src/batch/docs/domain/item-job/item-writer.md b/src/batch/docs/domain/item-job/item-writer.md index e024c931..7b32c3af 100644 --- a/src/batch/docs/domain/item-job/item-writer.md +++ b/src/batch/docs/domain/item-job/item-writer.md @@ -25,7 +25,7 @@ It can be any class implementing [ItemWriterInterface](../../../src/Job/Item/Ite write items by inserting/updating in a table via a Doctrine `Connection`. - [ObjectWriter (`doctrine/persistence`)](https://github.com/yokai-php/batch-doctrine-persistence/blob/0.x/src/ObjectWriter.php): write items to any Doctrine `ObjectManager`. -- [FlatFileWriter (`box/spout`)](https://github.com/yokai-php/batch-box-spout/blob/0.x/src/FlatFileWriter.php): +- [FlatFileWriter (`box/spout`)](https://github.com/yokai-php/batch-box-spout/blob/0.x/src/Writer/FlatFileWriter.php): write items to any CSV/ODS/XLSX file. ## On the same subject diff --git a/tests/integration/ImportDevelopersXlsxToORMTest.php b/tests/integration/ImportDevelopersXlsxToORMTest.php index f2d64446..bfab86fb 100644 --- a/tests/integration/ImportDevelopersXlsxToORMTest.php +++ b/tests/integration/ImportDevelopersXlsxToORMTest.php @@ -4,7 +4,6 @@ namespace Yokai\Batch\Sources\Tests\Integration; -use Box\Spout\Common\Type; use Doctrine\ORM\EntityManager; use Doctrine\ORM\Tools\SchemaTool; use Doctrine\ORM\Tools\Setup; @@ -12,7 +11,9 @@ use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; -use Yokai\Batch\Bridge\Box\Spout\FlatFileReader; +use Yokai\Batch\Bridge\Box\Spout\Reader\FlatFileReader; +use Yokai\Batch\Bridge\Box\Spout\Reader\HeaderStrategy; +use Yokai\Batch\Bridge\Box\Spout\Reader\Options\CSVOptions; use Yokai\Batch\Bridge\Doctrine\Persistence\ObjectWriter; use Yokai\Batch\Job\Item\ItemJob; use Yokai\Batch\Job\JobInterface; @@ -82,10 +83,9 @@ protected function createJob(JobExecutionStorageInterface $executionStorage): Jo $csvReader = function (string $file): FlatFileReader { return new FlatFileReader( - Type::CSV, new StaticValueParameterAccessor($file), - [], - FlatFileReader::HEADERS_MODE_COMBINE + new CSVOptions(), + HeaderStrategy::combine() ); }; diff --git a/tests/integration/Job/SplitDeveloperXlsxJob.php b/tests/integration/Job/SplitDeveloperXlsxJob.php index 0c0af3cf..d717987b 100644 --- a/tests/integration/Job/SplitDeveloperXlsxJob.php +++ b/tests/integration/Job/SplitDeveloperXlsxJob.php @@ -8,7 +8,8 @@ use Box\Spout\Common\Type; use Box\Spout\Reader\Common\Creator\ReaderFactory; use Box\Spout\Reader\SheetInterface; -use Yokai\Batch\Bridge\Box\Spout\FlatFileWriter; +use Yokai\Batch\Bridge\Box\Spout\Writer\FlatFileWriter; +use Yokai\Batch\Bridge\Box\Spout\Writer\Options\CSVOptions; use Yokai\Batch\Job\AbstractJob; use Yokai\Batch\Job\Parameters\StaticValueParameterAccessor; use Yokai\Batch\JobExecution; @@ -92,7 +93,7 @@ protected function doExecute(JobExecution $jobExecution): void private function writeToCsv(string $filename, array $data, array $headers): void { - $writer = new FlatFileWriter(Type::CSV, new StaticValueParameterAccessor($filename), $headers); + $writer = new FlatFileWriter(new StaticValueParameterAccessor($filename), new CSVOptions(), $headers); $writer->setJobExecution(JobExecution::createRoot('fake', 'fake')); $writer->initialize(); $writer->write($data); diff --git a/tests/symfony/src/Job/Country/CountryJob.php b/tests/symfony/src/Job/Country/CountryJob.php index 442e783a..61061381 100644 --- a/tests/symfony/src/Job/Country/CountryJob.php +++ b/tests/symfony/src/Job/Country/CountryJob.php @@ -5,7 +5,8 @@ namespace Yokai\Batch\Sources\Tests\Symfony\App\Job\Country; use Symfony\Component\HttpKernel\KernelInterface; -use Yokai\Batch\Bridge\Box\Spout\FlatFileWriter; +use Yokai\Batch\Bridge\Box\Spout\Writer\FlatFileWriter; +use Yokai\Batch\Bridge\Box\Spout\Writer\Options\CSVOptions; use Yokai\Batch\Bridge\Symfony\Framework\JobWithStaticNameInterface; use Yokai\Batch\Job\Item\ElementConfiguratorTrait; use Yokai\Batch\Job\Item\FlushableInterface; @@ -77,7 +78,7 @@ public function __construct(JobExecutionStorageInterface $executionStorage, Kern $headers = \array_merge(['iso2'], $fragments); $this->writer = new ChainWriter([ new SummaryWriter(new StaticValueParameterAccessor('countries')), - new FlatFileWriter('csv', $writePath('csv'), $headers), + new FlatFileWriter($writePath('csv'), new CSVOptions(), $headers), new JsonLinesWriter($writePath('jsonl')), ]); diff --git a/tests/symfony/src/Job/StarWars/AbstractImportStartWarsEntityJob.php b/tests/symfony/src/Job/StarWars/AbstractImportStartWarsEntityJob.php index ac5b3402..6f04f84c 100644 --- a/tests/symfony/src/Job/StarWars/AbstractImportStartWarsEntityJob.php +++ b/tests/symfony/src/Job/StarWars/AbstractImportStartWarsEntityJob.php @@ -7,7 +7,9 @@ use Closure; use Doctrine\Persistence\ManagerRegistry; use Symfony\Component\Validator\Validator\ValidatorInterface; -use Yokai\Batch\Bridge\Box\Spout\FlatFileReader; +use Yokai\Batch\Bridge\Box\Spout\Reader\FlatFileReader; +use Yokai\Batch\Bridge\Box\Spout\Reader\HeaderStrategy; +use Yokai\Batch\Bridge\Box\Spout\Reader\Options\CSVOptions; use Yokai\Batch\Bridge\Doctrine\Persistence\ObjectWriter; use Yokai\Batch\Bridge\Symfony\Framework\JobWithStaticNameInterface; use Yokai\Batch\Bridge\Symfony\Validator\SkipInvalidItemProcessor; @@ -45,10 +47,9 @@ public function __construct( parent::__construct( 50, // could be much higher, but set this way for demo purpose new FlatFileReader( - 'csv', new StaticValueParameterAccessor($file), - [], - FlatFileReader::HEADERS_MODE_COMBINE + new CSVOptions(), + HeaderStrategy::combine() ), new ChainProcessor([ new ArrayMapProcessor(