diff --git a/src/Bundle/ChillMainBundle/Export/Formatter/SpreadSheetFormatter.php b/src/Bundle/ChillMainBundle/Export/Formatter/SpreadSheetFormatter.php index 45d85d8aa..87c46dfbb 100644 --- a/src/Bundle/ChillMainBundle/Export/Formatter/SpreadSheetFormatter.php +++ b/src/Bundle/ChillMainBundle/Export/Formatter/SpreadSheetFormatter.php @@ -12,110 +12,25 @@ declare(strict_types=1); namespace Chill\MainBundle\Export\Formatter; use Chill\MainBundle\Export\ExportGenerationContext; +use Chill\MainBundle\Export\ExportInterface; use Chill\MainBundle\Export\ExportManagerAwareInterface; +use Chill\MainBundle\Export\FormattedExportGeneration; use Chill\MainBundle\Export\FormatterInterface; use Chill\MainBundle\Export\Helper\ExportManagerAwareTrait; use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\Form\Extension\Core\Type\FormType; use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\HttpFoundation\BinaryFileResponse; use Symfony\Component\HttpFoundation\Response; use Symfony\Contracts\Translation\TranslatableInterface; use Symfony\Contracts\Translation\TranslatorInterface; -class SpreadSheetFormatter implements FormatterInterface, ExportManagerAwareInterface +final class SpreadSheetFormatter implements FormatterInterface, ExportManagerAwareInterface { use ExportManagerAwareTrait; - /** - * an array where keys are the aggregators aliases and - * values are the data. - * - * replaced when `getResponse` is called. - */ - protected array $aggregatorsData; - - /** - * The export. - * - * replaced when `getResponse` is called. - * - * @var \Chill\MainBundle\Export\ExportInterface - */ - protected $export; - - - /** - * array containing value of export form. - * - * replaced when `getResponse` is called. - * - * @var array - */ - protected $exportData; - - /** - * replaced when `getResponse` is called. - * - * @var array - */ - protected $filtersData; - - /** - * replaced when `getResponse` is called. - * - * @var array - */ - protected $formatterData; - - /** - * The result, as returned by the export. - * - * replaced when `getResponse` is called. - * - * @var array - */ - protected $result; - - /** - * replaced when `getResponse` is called. - * - * @var array - */ - // protected $labels; - - /** - * temporary file to store spreadsheet. - * - * @var string - */ - protected $tempfile; - - /** - * @var TranslatorInterface - */ - protected $translator; - - /** - * cache for displayable result. - * - * This cache is reset when `getResponse` is called. - * - * The array's keys are the keys in the raw result, and - * values are the callable which will transform the raw result to - * displayable result. - */ - private ?array $cacheDisplayableResult = null; - - /** - * Whethe `cacheDisplayableResult` is initialized or not. - */ - private bool $cacheDisplayableResultIsInitialized = false; - - public function __construct(TranslatorInterface $translatorInterface) - { - $this->translator = $translatorInterface; - } + public function __construct(private readonly TranslatorInterface $translator) {} public function buildForm( FormBuilderInterface $builder, @@ -178,6 +93,51 @@ class SpreadSheetFormatter implements FormatterInterface, ExportManagerAwareInte return 'SpreadSheet (xlsx, ods)'; } + public function generate( + $result, + $formatterData, + string $exportAlias, + array $exportData, + array $filtersData, + array $aggregatorsData, + ExportGenerationContext $context, + ) { + // Initialize local variables instead of class properties + /** @var ExportInterface $export */ + $export = $this->getExportManager()->getExport($exportAlias); + + // Initialize cache variables + $cacheDisplayableResult = $this->initializeDisplayable($result, $export, $exportData, $aggregatorsData); + + $tempfile = \tempnam(\sys_get_temp_dir(), ''); + + if (false === $tempfile) { + throw new \RuntimeException('Unable to create temporary file'); + } + + $this->generateContent( + $context, + $tempfile, + $result, + $formatterData, + $export, + $exportData, + $filtersData, + $aggregatorsData, + $cacheDisplayableResult, + ); + + $result = new FormattedExportGeneration( + file_get_contents($tempfile), + $this->getContentType($formatterData['format']), + ); + + // remove the temp file from disk + \unlink($tempfile); + + return $result; + } + public function getResponse( $result, $formatterData, @@ -187,33 +147,10 @@ class SpreadSheetFormatter implements FormatterInterface, ExportManagerAwareInte array $aggregatorsData, ExportGenerationContext $context, ): Response { - // store all data when the process is initiated - $this->result = $result; - $this->formatterData = $formatterData; - $this->export = $this->getExportManager()->getExport($exportAlias); - $this->exportData = $exportData; - $this->filtersData = $filtersData; - $this->aggregatorsData = $aggregatorsData; + $formattedResult = $this->generate($result, $formatterData, $exportAlias, $exportData, $filtersData, $aggregatorsData, $context); - // reset cache - $this->cacheDisplayableResult = []; - $this->cacheDisplayableResultIsInitialized = false; - - $response = new Response(); - $response->headers->set( - 'Content-Type', - $this->getContentType($this->formatterData['format']) - ); - - $this->tempfile = \tempnam(\sys_get_temp_dir(), ''); - $this->generateContent($context); - - $f = \fopen($this->tempfile, 'rb'); - $response->setContent(\stream_get_contents($f)); - fclose($f); - - // remove the temp file from disk - \unlink($this->tempfile); + $response = new BinaryFileResponse($formattedResult->content); + $response->headers->set('Content-Type', $formattedResult->contentType); return $response; } @@ -223,7 +160,7 @@ class SpreadSheetFormatter implements FormatterInterface, ExportManagerAwareInte return 'tabular'; } - protected function addContentTable( + private function addContentTable( Worksheet $worksheet, $sortedResults, $line, @@ -245,11 +182,11 @@ class SpreadSheetFormatter implements FormatterInterface, ExportManagerAwareInte * * @return int the line number after the last description */ - protected function addFiltersDescription(Worksheet &$worksheet, ExportGenerationContext $context) + private function addFiltersDescription(Worksheet &$worksheet, ExportGenerationContext $context, array $filtersData) { $line = 3; - foreach ($this->filtersData as $alias => $data) { + foreach ($filtersData as $alias => $data) { $filter = $this->getExportManager()->getFilter($alias); $description = $filter->describeAction($data, $context); if (\is_array($description)) { @@ -274,26 +211,22 @@ class SpreadSheetFormatter implements FormatterInterface, ExportManagerAwareInte * * return the line number where the next content (i.e. result) should * be appended. - * - * @param int $line - * - * @return int */ - protected function addHeaders( + private function addHeaders( Worksheet &$worksheet, array $globalKeys, - $line, - ) { + int $line, + array $cacheDisplayableResult = [], + ): int { // get the displayable form of headers $displayables = []; - foreach ($globalKeys as $key) { - $displayable = $this->getDisplayableResult($key, '_header'); + $displayable = $this->getDisplayableResult($key, '_header', $cacheDisplayableResult); if ($displayable instanceof TranslatableInterface) { $displayables[] = $displayable->trans($this->translator, $this->translator->getLocale()); } else { - $displayables[] = $this->translator->trans($this->getDisplayableResult($key, '_header')); + $displayables[] = $this->translator->trans($this->getDisplayableResult($key, '_header', $cacheDisplayableResult)); } } @@ -311,9 +244,9 @@ class SpreadSheetFormatter implements FormatterInterface, ExportManagerAwareInte * Add the title to the worksheet and merge the cell containing * the title. */ - protected function addTitleToWorkSheet(Worksheet &$worksheet) + private function addTitleToWorkSheet(Worksheet &$worksheet, $export) { - $worksheet->setCellValue('A1', $this->getTitle()); + $worksheet->setCellValue('A1', $this->getTitle($export)); $worksheet->mergeCells('A1:G1'); } @@ -322,14 +255,14 @@ class SpreadSheetFormatter implements FormatterInterface, ExportManagerAwareInte * * @return array where 1st member is spreadsheet, 2nd is worksheet */ - protected function createSpreadsheet() + private function createSpreadsheet($export) { $spreadsheet = new \PhpOffice\PhpSpreadsheet\Spreadsheet(); $worksheet = $spreadsheet->getActiveSheet(); // setting the worksheet title and code name $worksheet - ->setTitle($this->getTitle()) + ->setTitle($this->getTitle($export)) ->setCodeName('result'); return [$spreadsheet, $worksheet]; @@ -338,29 +271,38 @@ class SpreadSheetFormatter implements FormatterInterface, ExportManagerAwareInte /** * Generate the content and write it to php://temp. */ - protected function generateContent(ExportGenerationContext $context) - { - [$spreadsheet, $worksheet] = $this->createSpreadsheet(); + private function generateContent( + ExportGenerationContext $context, + string $tempfile, + $result, + $formatterData, + $export, + array $exportData, + array $filtersData, + array $aggregatorsData, + array $cacheDisplayableResult, + ) { + [$spreadsheet, $worksheet] = $this->createSpreadsheet($export); - $this->addTitleToWorkSheet($worksheet); - $line = $this->addFiltersDescription($worksheet, $context); + $this->addTitleToWorkSheet($worksheet, $export); + $line = $this->addFiltersDescription($worksheet, $context, $filtersData); - // at this point, we are going to sort retsults for an easier manipulation + // at this point, we are going to sort results for an easier manipulation [$sortedResult, $exportKeys, $aggregatorKeys, $globalKeys] = - $this->sortResult(); + $this->sortResult($result, $export, $exportData, $aggregatorsData, $formatterData, $cacheDisplayableResult); - $line = $this->addHeaders($worksheet, $globalKeys, $line); + $line = $this->addHeaders($worksheet, $globalKeys, $line, $cacheDisplayableResult); - $line = $this->addContentTable($worksheet, $sortedResult, $line); + $this->addContentTable($worksheet, $sortedResult, $line); - $writer = match ($this->formatterData['format']) { + $writer = match ($formatterData['format']) { 'ods' => \PhpOffice\PhpSpreadsheet\IOFactory::createWriter($spreadsheet, 'Ods'), 'xlsx' => \PhpOffice\PhpSpreadsheet\IOFactory::createWriter($spreadsheet, 'Xlsx'), 'csv' => \PhpOffice\PhpSpreadsheet\IOFactory::createWriter($spreadsheet, 'Csv'), default => throw new \LogicException(), }; - $writer->save($this->tempfile); + $writer->save($tempfile); } /** @@ -369,7 +311,7 @@ class SpreadSheetFormatter implements FormatterInterface, ExportManagerAwareInte * * @return string[] an array containing the keys of aggregators */ - protected function getAggregatorKeysSorted() + private function getAggregatorKeysSorted(array $aggregatorsData, array $formatterData) { // empty array for aggregators keys $keys = []; @@ -377,7 +319,7 @@ class SpreadSheetFormatter implements FormatterInterface, ExportManagerAwareInte // during sorting $aggregatorKeyAssociation = []; - foreach ($this->aggregatorsData as $alias => $data) { + foreach ($aggregatorsData as $alias => $data) { $aggregator = $this->exportManager->getAggregator($alias); $aggregatorsKeys = $aggregator->getQueryKeys($data); // append the keys from aggregator to the $keys existing array @@ -389,9 +331,9 @@ class SpreadSheetFormatter implements FormatterInterface, ExportManagerAwareInte } // sort the result using the form - usort($keys, function ($a, $b) use ($aggregatorKeyAssociation) { - $A = $this->formatterData[$aggregatorKeyAssociation[$a]]['order']; - $B = $this->formatterData[$aggregatorKeyAssociation[$b]]['order']; + usort($keys, function ($a, $b) use ($aggregatorKeyAssociation, $formatterData) { + $A = $formatterData[$aggregatorKeyAssociation[$a]]['order']; + $B = $formatterData[$aggregatorKeyAssociation[$b]]['order']; if ($A === $B) { return 0; @@ -407,7 +349,7 @@ class SpreadSheetFormatter implements FormatterInterface, ExportManagerAwareInte return $keys; } - protected function getContentType($format) + private function getContentType($format) { switch ($format) { case 'csv': @@ -424,23 +366,20 @@ class SpreadSheetFormatter implements FormatterInterface, ExportManagerAwareInte /** * Get the displayable result. - * - * @param string $key */ - protected function getDisplayableResult($key, mixed $value) - { - if (false === $this->cacheDisplayableResultIsInitialized) { - $this->initializeCache($key); - } - + private function getDisplayableResult( + string $key, + mixed $value, + array $cacheDisplayableResult, + ): string|TranslatableInterface|\DateTimeInterface|int|float|bool { $value ??= ''; - return \call_user_func($this->cacheDisplayableResult[$key], $value); + return \call_user_func($cacheDisplayableResult[$key], $value); } - protected function getTitle(): string + private function getTitle($export): string { - $original = $this->export->getTitle(); + $original = $export->getTitle(); if ($original instanceof TranslatableInterface) { $title = $original->trans($this->translator, $this->translator->getLocale()); @@ -455,8 +394,13 @@ class SpreadSheetFormatter implements FormatterInterface, ExportManagerAwareInte return $title; } - protected function initializeCache($key) - { + private function initializeDisplayable( + $result, + ExportInterface $export, + array $exportData, + array $aggregatorsData, + ): array { + $cacheDisplayableResult = []; /* * this function follows the following steps : * @@ -469,12 +413,11 @@ class SpreadSheetFormatter implements FormatterInterface, ExportManagerAwareInte // 1. create an associative array with key and export / aggregator $keysExportElementAssociation = []; // keys for export - foreach ($this->export->getQueryKeys($this->exportData) as $key) { - $keysExportElementAssociation[$key] = [$this->export, - $this->exportData, ]; + foreach ($export->getQueryKeys($exportData) as $key) { + $keysExportElementAssociation[$key] = [$export, $exportData]; } // keys for aggregator - foreach ($this->aggregatorsData as $alias => $data) { + foreach ($aggregatorsData as $alias => $data) { $aggregator = $this->getExportManager()->getAggregator($alias); foreach ($aggregator->getQueryKeys($data) as $key) { @@ -487,7 +430,7 @@ class SpreadSheetFormatter implements FormatterInterface, ExportManagerAwareInte $allValues = []; // store all the values in an array - foreach ($this->result as $row) { + foreach ($result as $row) { foreach ($keys as $key) { $allValues[$key][] = $row[$key]; } @@ -498,15 +441,14 @@ class SpreadSheetFormatter implements FormatterInterface, ExportManagerAwareInte foreach ($keysExportElementAssociation as $key => [$element, $data]) { // handle the case when there is not results lines (query is empty) if ([] === $allValues) { - $this->cacheDisplayableResult[$key] = $element->getLabels($key, ['_header'], $data); + $cacheDisplayableResult[$key] = $element->getLabels($key, ['_header'], $data); } else { - $this->cacheDisplayableResult[$key] = + $cacheDisplayableResult[$key] = $element->getLabels($key, \array_unique($allValues[$key]), $data); } } - // the cache is initialized ! - $this->cacheDisplayableResultIsInitialized = true; + return $cacheDisplayableResult; } /** @@ -544,23 +486,28 @@ class SpreadSheetFormatter implements FormatterInterface, ExportManagerAwareInte * ) * ``` */ - protected function sortResult() - { + private function sortResult( + $result, + ExportInterface $export, + array $exportData, + array $aggregatorsData, + array $formatterData, + array $cacheDisplayableResult, + ) { // get the keys for each row - $exportKeys = $this->export->getQueryKeys($this->exportData); - $aggregatorKeys = $this->getAggregatorKeysSorted(); - + $exportKeys = $export->getQueryKeys($exportData); + $aggregatorKeys = $this->getAggregatorKeysSorted($aggregatorsData, $formatterData); $globalKeys = \array_merge($aggregatorKeys, $exportKeys); - $sortedResult = \array_map(function ($row) use ($globalKeys) { + $sortedResult = \array_map(function ($row) use ($globalKeys, $cacheDisplayableResult) { $newRow = []; foreach ($globalKeys as $key) { - $newRow[] = $this->getDisplayableResult($key, $row[$key]); + $newRow[] = $this->getDisplayableResult($key, $row[$key], $cacheDisplayableResult); } return $newRow; - }, $this->result); + }, $result); \array_multisort($sortedResult); diff --git a/src/Bundle/ChillMainBundle/Tests/Export/Formatter/SpreadsheetFormatterTest.php b/src/Bundle/ChillMainBundle/Tests/Export/Formatter/SpreadsheetFormatterTest.php new file mode 100644 index 000000000..bf1da2701 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Tests/Export/Formatter/SpreadsheetFormatterTest.php @@ -0,0 +1,138 @@ +prophesize(\Symfony\Contracts\Translation\TranslatorInterface::class); + $translator->getLocale()->willReturn('en'); + $exportManager = $this->prophesize(ExportManager::class); + + $result = + [ + ['export_count_activity' => 1, 'person_age' => 65, 'aggregator_some' => 'label0'], // row 0 + ]; + $exportAlias = 'count_activity_linked_to_person'; + $formatterData = + ['format' => 'xlsx', 'person_age_aggregator' => ['order' => 1], 'aggregator2' => ['order' => 2]]; + $exportData = []; + $filtersData = + [ + 'person_age_filter' => ['min_age' => 18, 'max_age' => 120, 'date_calc' => new RollingDate(RollingDate::T_TODAY)], + 'filter2' => [], + ]; + $aggregatorsData = + [ + 'person_age_aggregator' => ['date_age_calculation' => new RollingDate(RollingDate::T_TODAY)], + 'aggregator2' => [], + ]; + $context = + new ExportGenerationContext($user = new User()); + + $export = $this->prophesize(ExportInterface::class); + $export->getTitle()->willReturn('Count activity linked to person'); + $translator->trans('Count activity linked to person')->willReturn('Count activity linked to person'); + $export->getQueryKeys($exportData)->willReturn(['export_count_activity']); + $export->getLabels('export_count_activity', [1], $exportData) + ->willReturn(fn (int|string $value): int|string => '_header' === $value ? 'Count activities' : $value); + $translator->trans('Count activities')->willReturn('Count activities'); + $exportManager->getExport($exportAlias)->willReturn($export->reveal()); + + $aggregator = $this->prophesize(\Chill\MainBundle\Export\AggregatorInterface::class); + $aggregator->getTitle()->willReturn('Person age'); + $aggregator->getQueryKeys($aggregatorsData['person_age_aggregator'])->willReturn(['person_age']); + $aggregator->getLabels('person_age', [65], $aggregatorsData['person_age_aggregator']) + ->willReturn(fn (int|string $value): int|string => '_header' === $value ? 'Group by age' : $value); + $translator->trans('Group by age')->willReturn('Group by age'); + $exportManager->getAggregator('person_age_aggregator')->willReturn($aggregator->reveal()); + + $aggregator2 = $this->prophesize(\Chill\MainBundle\Export\AggregatorInterface::class); + $aggregator2->getTitle()->willReturn(new TranslatableMessage('Some')); + $aggregator2->getQueryKeys($aggregatorsData['aggregator2'])->willReturn(['aggregator_some']); + $aggregator2->getLabels('aggregator_some', ['label0'], $aggregatorsData['aggregator2']) + ->willReturn(fn (int|string $value): TranslatableMessage => new TranslatableMessage('_header' === $value ? 'Aggregator 2 header' : $value)); + $translator->trans('Aggregator 2 header', [], null, 'en')->willReturn('Aggregator 2 header'); + $translator->trans('label0', [], null, 'en')->willReturn('label0'); + $exportManager->getAggregator('aggregator2')->willReturn($aggregator2->reveal()); + + $filter = $this->prophesize(\Chill\MainBundle\Export\FilterInterface::class); + $filter->getTitle()->willReturn('Person by age'); + $filter->describeAction($filtersData['person_age_filter'], $context) + ->willReturn(['Filter by age, from {{ start }} to {{ end }}', ['{{ start }}' => '18', '{{ end }}' => '120']]); + $translator->trans('Filter by age, from {{ start }} to {{ end }}', ['{{ start }}' => '18', '{{ end }}' => '120']) + ->willReturn('Filter by age, from 18 to 120'); + $exportManager->getFilter('person_age_filter')->willReturn($filter->reveal()); + + $filter2 = $this->prophesize(\Chill\MainBundle\Export\FilterInterface::class); + $filter2->getTitle()->willReturn(new TranslatableMessage('Some other filter')); + $filter2->describeAction($filtersData['filter2'], $context) + ->willReturn(new TranslatableMessage('Other filter description')); + $translator->trans('Other filter description', [], null, 'en') + ->willReturn('Some other filter description'); + $exportManager->getFilter('filter2')->willReturn($filter2->reveal()); + + + // create the formatter + $formatter = new SpreadSheetFormatter($translator->reveal()); + $formatter->setExportManager($exportManager->reveal()); + + $result = $formatter->generate( + $result, + $formatterData, + $exportAlias, + $exportData, + $filtersData, + $aggregatorsData, + $context, + ); + + $tempFile = tempnam(sys_get_temp_dir(), 'test_spreadsheet_formatter_'); + file_put_contents($tempFile, $result->content); + $spreadsheet = IOFactory::load($tempFile); + $cells = $spreadsheet->getActiveSheet()->rangeToArray( + 'A1:G6', + null, + false, + true, + true, + ); + unlink($tempFile); + + self::assertEquals('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', $result->contentType); + self::assertEquals($cells[1], ['A' => 'Count activity linked to perso…', 'B' => null, 'C' => null, 'D' => null, 'E' => null, 'F' => null, 'G' => null]); + self::assertEquals($cells[2], ['A' => null, 'B' => null, 'C' => null, 'D' => null, 'E' => null, 'F' => null, 'G' => null]); + self::assertEquals($cells[3], ['A' => 'Filter by age, from 18 to 120', 'B' => null, 'C' => null, 'D' => null, 'E' => null, 'F' => null, 'G' => null]); + self::assertEquals($cells[4], ['A' => 'Some other filter description', 'B' => null, 'C' => null, 'D' => null, 'E' => null, 'F' => null, 'G' => null]); + self::assertEquals($cells[5], ['A' => 'Group by age', 'B' => 'Aggregator 2 header', 'C' => 'Count activities', 'D' => null, 'E' => null, 'F' => null, 'G' => null]); + self::assertEquals($cells[6], ['A' => 65, 'B' => 'label0', 'C' => 1, 'D' => null, 'E' => null, 'F' => null, 'G' => null]); + } +}