Improved exporter service

This commit is contained in:
Jan Böhmer 2023-03-11 22:40:53 +01:00
parent 1dfcffe70d
commit 3b36b2a4dc
3 changed files with 171 additions and 56 deletions

View file

@ -29,7 +29,6 @@ use Doctrine\ORM\Mapping as ORM;
* *
* @ORM\MappedSuperclass(repositoryClass="App\Repository\AbstractPartsContainingRepository") * @ORM\MappedSuperclass(repositoryClass="App\Repository\AbstractPartsContainingRepository")
*/ */
abstract class abstract class AbstractPartsContainingDBElement extends AbstractStructuralDBElement
AbstractPartsContainingDBElement extends AbstractStructuralDBElement
{ {
} }

View file

@ -23,6 +23,7 @@ declare(strict_types=1);
namespace App\Services\ImportExportSystem; namespace App\Services\ImportExportSystem;
use App\Entity\Base\AbstractNamedDBElement; use App\Entity\Base\AbstractNamedDBElement;
use Symfony\Component\OptionsResolver\OptionsResolver;
use function in_array; use function in_array;
use InvalidArgumentException; use InvalidArgumentException;
use function is_array; use function is_array;
@ -32,6 +33,7 @@ use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\ResponseHeaderBag; use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\Serializer\SerializerInterface; use Symfony\Component\Serializer\SerializerInterface;
use function Symfony\Component\String\u;
/** /**
* Use this class to export an entity to multiple file formats. * Use this class to export an entity to multiple file formats.
@ -42,104 +44,136 @@ class EntityExporter
public function __construct(SerializerInterface $serializer) public function __construct(SerializerInterface $serializer)
{ {
/*$encoders = [new XmlEncoder(), new JsonEncoder(), new CSVEncoder(), new YamlEncoder()];
$normalizers = [new ObjectNormalizer(), new DateTimeNormalizer()];
$this->serializer = new Serializer($normalizers, $encoders);
$this->serializer-> */
$this->serializer = $serializer; $this->serializer = $serializer;
} }
protected function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefault('format', 'csv');
$resolver->setAllowedValues('format', ['csv', 'json', 'xml', 'yaml']);
$resolver->setDefault('csv_delimiter', ',');
$resolver->setAllowedTypes('csv_delimiter', 'string');
$resolver->setDefault('level', 'extended');
$resolver->setAllowedValues('level', ['simple', 'extended', 'full']);
$resolver->setDefault('include_children', false);
$resolver->setAllowedTypes('include_children', 'bool');
}
/** /**
* Exports an Entity or an array of entities to multiple file formats. * Export the given entities using the given options.
* @param AbstractNamedDBElement|AbstractNamedDBElement[] $entities The data to export
* @param array $options The options to use for exporting
* @return string The serialized data
*/
public function exportEntities($entities, array $options): string
{
if (!is_array($entities)) {
$entities = [$entities];
}
//Ensure that all entities are of type AbstractNamedDBElement
$entity_type = null;
foreach ($entities as $entity) {
if (!$entity instanceof AbstractNamedDBElement) {
throw new InvalidArgumentException('All entities must be of type AbstractNamedDBElement!');
}
}
$resolver = new OptionsResolver();
$this->configureOptions($resolver);
$options = $resolver->resolve($options);
//If include children is set, then we need to add the include_children group
$groups = [$options['level']];
if ($options['include_children']) {
$groups[] = 'include_children';
}
return $this->serializer->serialize($entities, $options['format'],
[
'groups' => $groups,
'as_collection' => true,
'csv_delimiter' => $options['csv_delimiter'],
'xml_root_node_name' => 'PartDBExport',
]
);
}
/**
* Exports an Entity or an array of entities to multiple file formats.
* *
* @param Request $request the request that should be used for option resolving * @param Request $request the request that should be used for option resolving
* @param AbstractNamedDBElement|object[] $entity * @param AbstractNamedDBElement|object[] $entities
* *
* @return Response the generated response containing the exported data * @return Response the generated response containing the exported data
* *
* @throws ReflectionException * @throws ReflectionException
*/ */
public function exportEntityFromRequest($entity, Request $request): Response public function exportEntityFromRequest($entities, Request $request): Response
{ {
$format = $request->get('format') ?? 'json'; $options = [
'format' => $request->get('format') ?? 'json',
'level' => $request->get('level') ?? 'extended',
'include_children' => $request->request->getBoolean('include_children') ?? false,
];
//Check if we have one of the supported formats if (!is_array($entities)) {
if (!in_array($format, ['json', 'csv', 'yaml', 'xml'], true)) { $entities = [$entities];
throw new InvalidArgumentException('Given format is not supported!');
} }
//Check export verbosity level //Do the serialization with the given options
$level = $request->get('level') ?? 'extended'; $serialized_data = $this->exportEntities($entities, $options);
if (!in_array($level, ['simple', 'extended', 'full'], true)) {
throw new InvalidArgumentException('Given level is not supported!');
}
//Check for include children option $response = new Response($serialized_data);
$include_children = $request->get('include_children') ?? false;
//Check which groups we need to export, based on level and include_children //Resolve the format
$groups = [$level]; $optionsResolver = new OptionsResolver();
if ($include_children) { $this->configureOptions($optionsResolver);
$groups[] = 'include_children'; $options = $optionsResolver->resolve($options);
}
//Determine the content type for the response
//Plain text should work for all types //Plain text should work for all types
$content_type = 'text/plain'; $content_type = 'text/plain';
//Try to use better content types based on the format //Try to use better content types based on the format
$format = $options['format'];
switch ($format) { switch ($format) {
case 'xml': case 'xml':
$content_type = 'application/xml'; $content_type = 'application/xml';
break; break;
case 'json': case 'json':
$content_type = 'application/json'; $content_type = 'application/json';
break; break;
} }
//Ensure that we always serialize an array. This makes it easier to import the data again.
if (is_array($entity)) {
$entity_array = $entity;
} else {
$entity_array = [$entity];
}
$serialized_data = $this->serializer->serialize($entity_array, $format,
[
'groups' => $groups,
'as_collection' => true,
'csv_delimiter' => ';', //Better for Excel
'xml_root_node_name' => 'PartDBExport',
]);
$response = new Response($serialized_data);
$response->headers->set('Content-Type', $content_type); $response->headers->set('Content-Type', $content_type);
//If view option is not specified, then download the file. //If view option is not specified, then download the file.
if (!$request->get('view')) { if (!$request->get('view')) {
if ($entity instanceof AbstractNamedDBElement) {
$entity_name = $entity->getName();
} elseif (is_array($entity)) {
if (empty($entity)) {
throw new InvalidArgumentException('$entity must not be empty!');
}
//Use the class name of the first element for the filename //Determine the filename
$reflection = new ReflectionClass($entity[0]); //When we only have one entity, then we can use the name of the entity
$entity_name = $reflection->getShortName(); if (count($entities) === 1) {
$entity_name = $entities[0]->getName();
} else { } else {
throw new InvalidArgumentException('$entity type is not supported!'); //Use the class name of the first element for the filename otherwise
$reflection = new ReflectionClass($entities[0]);
$entity_name = $reflection->getShortName();
} }
$level = $options['level'];
$filename = 'export_'.$entity_name.'_'.$level.'.'.$format; $filename = 'export_'.$entity_name.'_'.$level.'.'.$format;
// Create the disposition of the file // Create the disposition of the file
$disposition = $response->headers->makeDisposition( $disposition = $response->headers->makeDisposition(
ResponseHeaderBag::DISPOSITION_ATTACHMENT, ResponseHeaderBag::DISPOSITION_ATTACHMENT,
$filename, $filename,
$string = preg_replace('![^'.preg_quote('-', '!').'a-z0-_9\s]+!', '', strtolower($filename)) u($filename)->ascii()->toString(),
); );
// Set the content disposition // Set the content disposition
$response->headers->set('Content-Disposition', $disposition); $response->headers->set('Content-Disposition', $disposition);

View file

@ -0,0 +1,82 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
namespace App\Tests\Services\ImportExportSystem;
use App\Entity\Parts\Category;
use App\Services\ImportExportSystem\EntityExporter;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Request;
class EntityExporterTest extends WebTestCase
{
/**
* @var EntityExporter
*/
protected $service;
protected function setUp(): void
{
parent::setUp();
self::bootKernel();
$this->service = self::getContainer()->get(EntityExporter::class);
}
private function getEntities(): array
{
$entity1 = (new Category())->setName('Enitity 1')->setComment('Test');
$entity1_1 = (new Category())->setName('Enitity 1.1')->setParent($entity1);
$entity2 = (new Category())->setName('Enitity 2');
return [$entity1, $entity1_1, $entity2];
}
public function testExportStructuralEntities(): void
{
$entities = $this->getEntities();
$json_without_children = $this->service->exportEntities($entities, ['format' => 'json', 'level' => 'simple']);
$this->assertSame('[{"comment":"Test","name":"Enitity 1","type":null},{"comment":"","name":"Enitity 1.1","type":null},{"comment":"","name":"Enitity 2","type":null}]',
$json_without_children);
$json_with_children = $this->service->exportEntities($entities,
['format' => 'json', 'level' => 'simple', 'include_children' => true]);
$this->assertSame('[{"children":[{"children":[],"comment":"","name":"Enitity 1.1","type":null}],"comment":"Test","name":"Enitity 1","type":null},{"children":[],"comment":"","name":"Enitity 1.1","type":null},{"children":[],"comment":"","name":"Enitity 2","type":null}]',
$json_with_children);
}
public function testExportEntityFromRequest(): void
{
$entities = $this->getEntities();
$request = new Request();
$request->request->set('format', 'json');
$request->request->set('level', 'simple');
$response = $this->service->exportEntityFromRequest($entities, $request);
$this->assertSame('[{"comment":"Test","name":"Enitity 1","type":null},{"comment":"","name":"Enitity 1.1","type":null},{"comment":"","name":"Enitity 2","type":null}]',
$response->getContent());
$this->assertSame('application/json', $response->headers->get('Content-Type'));
$this->assertNotEmpty($response->headers->get('Content-Disposition'));
}
}