From 5561211a962590ca629206d016bfaa330d79487f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Poirier=20Th=C3=A9or=C3=AAt?= Date: Thu, 11 Apr 2024 16:08:46 -0400 Subject: [PATCH] [SonataImportBundle] New SonataImportBundle package --- .github/workflows/after_splitting_test.yaml | 1 + app/migrations/Version20240411203316.php | 60 +++ app/migrations/Version20240417143929.php | 31 ++ app/migrations/Version20240417181931.php | 31 ++ app/src/Entity/Tag.php | 2 +- app/src/Sonata/Admin/UserAdmin.php | 1 + composer.json | 2 + config/bundles.php | 1 + config/packages/draw_sonata_import.yaml | 6 + .../Admin/ColumnAdmin.php | 66 +++ .../Admin/ImportAdmin.php | 384 ++++++++++++++++++ .../ColumnBuilder/ColumnBuilderInterface.php | 12 + .../DoctrineAssociationColumnBuilder.php | 42 ++ .../DoctrineAssociationFieldColumnBuilder.php | 37 ++ .../DoctrineAssociationPathColumnBuilder.php | 63 +++ .../Doctrine/DoctrineFieldColumnBuilder.php | 47 +++ .../NamedBaseIdentifierColumnBuilder.php | 21 + .../ColumnBuilder/ReflectionColumnBuilder.php | 73 ++++ .../Column/ColumnFactory.php | 83 ++++ .../DoctrineMappedToOptionBuilder.php | 51 +++ .../MappedToOptionBuilderInterface.php | 17 + .../Controller/ImportController.php | 28 ++ .../Compiler/CompilerPass.php | 33 ++ .../DependencyInjection/Configuration.php | 54 +++ .../DrawSonataImportExtension.php | 23 ++ .../DrawSonataImportBundle.php | 15 + .../sonata-import-bundle/Entity/Column.php | 187 +++++++++ .../sonata-import-bundle/Entity/Import.php | 287 +++++++++++++ .../Event/AttributeImportEvent.php | 34 ++ .../Extension/ImportExtension.php | 24 ++ packages/sonata-import-bundle/README.md | 54 +++ .../Resources/config/services.yaml | 8 + .../ImportAdmin/button_download.html.twig | 6 + .../views/ImportAdmin/button_import.html.twig | 7 + .../Tests/ColumnFactoryTest.php | 75 ++++ .../Tests/Entity/ImportTest.php | 223 ++++++++++ packages/sonata-import-bundle/composer.json | 31 ++ .../sonata-import-bundle/phpunit.xml.dist | 9 + phpstan-baseline.neon | 5 + 39 files changed, 2133 insertions(+), 1 deletion(-) create mode 100644 app/migrations/Version20240411203316.php create mode 100644 app/migrations/Version20240417143929.php create mode 100644 app/migrations/Version20240417181931.php create mode 100644 config/packages/draw_sonata_import.yaml create mode 100644 packages/sonata-import-bundle/Admin/ColumnAdmin.php create mode 100644 packages/sonata-import-bundle/Admin/ImportAdmin.php create mode 100644 packages/sonata-import-bundle/Column/ColumnBuilder/ColumnBuilderInterface.php create mode 100644 packages/sonata-import-bundle/Column/ColumnBuilder/Doctrine/DoctrineAssociationColumnBuilder.php create mode 100644 packages/sonata-import-bundle/Column/ColumnBuilder/Doctrine/DoctrineAssociationFieldColumnBuilder.php create mode 100644 packages/sonata-import-bundle/Column/ColumnBuilder/Doctrine/DoctrineAssociationPathColumnBuilder.php create mode 100644 packages/sonata-import-bundle/Column/ColumnBuilder/Doctrine/DoctrineFieldColumnBuilder.php create mode 100644 packages/sonata-import-bundle/Column/ColumnBuilder/NamedBaseIdentifierColumnBuilder.php create mode 100644 packages/sonata-import-bundle/Column/ColumnBuilder/ReflectionColumnBuilder.php create mode 100644 packages/sonata-import-bundle/Column/ColumnFactory.php create mode 100644 packages/sonata-import-bundle/Column/MappedToOptionBuilder/DoctrineMappedToOptionBuilder.php create mode 100644 packages/sonata-import-bundle/Column/MappedToOptionBuilder/MappedToOptionBuilderInterface.php create mode 100644 packages/sonata-import-bundle/Controller/ImportController.php create mode 100644 packages/sonata-import-bundle/DependencyInjection/Compiler/CompilerPass.php create mode 100755 packages/sonata-import-bundle/DependencyInjection/Configuration.php create mode 100644 packages/sonata-import-bundle/DependencyInjection/DrawSonataImportExtension.php create mode 100644 packages/sonata-import-bundle/DrawSonataImportBundle.php create mode 100644 packages/sonata-import-bundle/Entity/Column.php create mode 100644 packages/sonata-import-bundle/Entity/Import.php create mode 100644 packages/sonata-import-bundle/Event/AttributeImportEvent.php create mode 100644 packages/sonata-import-bundle/Extension/ImportExtension.php create mode 100644 packages/sonata-import-bundle/README.md create mode 100644 packages/sonata-import-bundle/Resources/config/services.yaml create mode 100644 packages/sonata-import-bundle/Resources/views/ImportAdmin/button_download.html.twig create mode 100644 packages/sonata-import-bundle/Resources/views/ImportAdmin/button_import.html.twig create mode 100644 packages/sonata-import-bundle/Tests/ColumnFactoryTest.php create mode 100644 packages/sonata-import-bundle/Tests/Entity/ImportTest.php create mode 100644 packages/sonata-import-bundle/composer.json create mode 100644 packages/sonata-import-bundle/phpunit.xml.dist diff --git a/.github/workflows/after_splitting_test.yaml b/.github/workflows/after_splitting_test.yaml index 9995aa3e4..e7625bf8f 100644 --- a/.github/workflows/after_splitting_test.yaml +++ b/.github/workflows/after_splitting_test.yaml @@ -29,6 +29,7 @@ jobs: - process - profiling - security + - sonata-import-bundle - sonata-integration-bundle - sonata-extra-bundle - tester diff --git a/app/migrations/Version20240411203316.php b/app/migrations/Version20240411203316.php new file mode 100644 index 000000000..e62e9037e --- /dev/null +++ b/app/migrations/Version20240411203316.php @@ -0,0 +1,60 @@ +addSql('CREATE TABLE import__column ( + id INT AUTO_INCREMENT NOT NULL, + import_id INT UNSIGNED NOT NULL, + header_name VARCHAR(255) NOT NULL, + sample LONGTEXT NOT NULL, + is_identifier TINYINT(1) DEFAULT 0 NOT NULL, + is_ignored TINYINT(1) DEFAULT 0 NOT NULL, + mapped_to VARCHAR(255) DEFAULT NULL, + is_date TINYINT(1) DEFAULT 0 NOT NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + INDEX IDX_9770C911B6A263D9 (import_id), + UNIQUE INDEX import_header_name (import_id, header_name), + PRIMARY KEY(id) + ) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('CREATE TABLE import__import ( + id INT UNSIGNED AUTO_INCREMENT NOT NULL, + entity_class VARCHAR(255) NOT NULL, + file_content LONGTEXT DEFAULT NULL, + state VARCHAR(40) DEFAULT \'new\' NOT NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + PRIMARY KEY(id) + ) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('ALTER TABLE + import__column + ADD + CONSTRAINT FK_9770C911B6A263D9 FOREIGN KEY (import_id) REFERENCES import__import (id) ON DELETE CASCADE'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE import__column DROP FOREIGN KEY FK_9770C911B6A263D9'); + $this->addSql('DROP TABLE import__column'); + $this->addSql('DROP TABLE import__import'); + } +} diff --git a/app/migrations/Version20240417143929.php b/app/migrations/Version20240417143929.php new file mode 100644 index 000000000..133dee3ef --- /dev/null +++ b/app/migrations/Version20240417143929.php @@ -0,0 +1,31 @@ +addSql('CREATE UNIQUE INDEX UNIQ_C052A2E4EA750E8 ON draw_acme__tag (label)'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('DROP INDEX UNIQ_C052A2E4EA750E8 ON draw_acme__tag'); + } +} diff --git a/app/migrations/Version20240417181931.php b/app/migrations/Version20240417181931.php new file mode 100644 index 000000000..a0ce9569a --- /dev/null +++ b/app/migrations/Version20240417181931.php @@ -0,0 +1,31 @@ +addSql('ALTER TABLE import__import ADD insert_when_not_found TINYINT(1) DEFAULT 0 NOT NULL'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE import__import DROP insert_when_not_found'); + } +} diff --git a/app/src/Entity/Tag.php b/app/src/Entity/Tag.php index 88172be24..e0a4808ae 100644 --- a/app/src/Entity/Tag.php +++ b/app/src/Entity/Tag.php @@ -33,7 +33,7 @@ class Tag implements \Stringable #[ORM\Column(name: 'active', type: 'boolean', options: ['default' => 1])] private bool $active = true; - #[ORM\Column(name: 'label', type: 'string', length: 255, nullable: false)] + #[ORM\Column(name: 'label', type: 'string', length: 255, unique: true, nullable: false)] #[ Assert\NotNull, Assert\Length(min: 3, max: 255) diff --git a/app/src/Sonata/Admin/UserAdmin.php b/app/src/Sonata/Admin/UserAdmin.php index 4f249b363..5e08069ff 100644 --- a/app/src/Sonata/Admin/UserAdmin.php +++ b/app/src/Sonata/Admin/UserAdmin.php @@ -78,6 +78,7 @@ protected function configureListFields(ListMapper $list): void ->add('email') ->add('childObject1') ->add('childObject2') + ->add('userTags', 'list') ->add('tags', 'list') ->add('isLocked', 'boolean', ['inverse' => true]); } diff --git a/composer.json b/composer.json index 29d380a67..ee3626131 100644 --- a/composer.json +++ b/composer.json @@ -139,6 +139,7 @@ "draw/profiling": "self.version", "draw/security": "self.version", "draw/sonata-extra-bundle": "self.version", + "draw/sonata-import-bundle": "self.version", "draw/sonata-integration-bundle": "self.version", "draw/tester": "self.version", "draw/tester-bundle": "self.version", @@ -165,6 +166,7 @@ "psr-4": { "Draw\\Bundle\\FrameworkExtraBundle\\": "packages/framework-extra-bundle/", "Draw\\Bundle\\SonataExtraBundle\\": "packages/sonata-extra-bundle/", + "Draw\\Bundle\\SonataImportBundle\\": "packages/sonata-import-bundle/", "Draw\\Bundle\\SonataIntegrationBundle\\": "packages/sonata-integration-bundle/", "Draw\\Bundle\\TesterBundle\\": "packages/tester-bundle/", "Draw\\Bundle\\UserBundle\\": "packages/user-bundle/", diff --git a/config/bundles.php b/config/bundles.php index 7ff01a3e6..e1382c8a5 100644 --- a/config/bundles.php +++ b/config/bundles.php @@ -28,4 +28,5 @@ Draw\Bundle\FrameworkExtraBundle\DrawFrameworkExtraBundle::class => ['all' => true], Doctrine\Bundle\MongoDBBundle\DoctrineMongoDBBundle::class => ['all' => true], Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true], + Draw\Bundle\SonataImportBundle\DrawSonataImportBundle::class => ['all' => true], ]; diff --git a/config/packages/draw_sonata_import.yaml b/config/packages/draw_sonata_import.yaml new file mode 100644 index 000000000..a584577a1 --- /dev/null +++ b/config/packages/draw_sonata_import.yaml @@ -0,0 +1,6 @@ +draw_sonata_import: + classes: + 'App\Entity\Tag': + alias: 'Tag' + 'App\Entity\UserTag': + alias: 'UserTag' diff --git a/packages/sonata-import-bundle/Admin/ColumnAdmin.php b/packages/sonata-import-bundle/Admin/ColumnAdmin.php new file mode 100644 index 000000000..0626a4db6 --- /dev/null +++ b/packages/sonata-import-bundle/Admin/ColumnAdmin.php @@ -0,0 +1,66 @@ + 'Import', + 'show_in_dashboard' => false, + 'model_class' => Column::class, + 'manager_type' => 'orm', + 'label' => 'Column', + ] +)] +class ColumnAdmin extends AbstractAdmin +{ + public function __construct( + #[TaggedIterator(MappedToOptionBuilderInterface::class)] + private iterable $mappedToOptionBuilders + ) { + parent::__construct(); + } + + protected function configureFormFields(FormMapper $form): void + { + $form + ->add('headerName') + ->add('sample') + ->add( + 'mappedTo', + ChoiceType::class, + [ + 'required' => false, + 'choices' => $this->loadMappedToOptions($this->getSubject()), + ] + ) + ->add('isIdentifier') + ->add('isDate') + ->add('isIgnored'); + } + + private function loadMappedToOptions(Column $column): array + { + $options = []; + + foreach ($this->mappedToOptionBuilders as $mappedToOptionBuilder) { + $options = $mappedToOptionBuilder->getOptions( + $column, + $options + ); + } + + return $options; + } +} diff --git a/packages/sonata-import-bundle/Admin/ImportAdmin.php b/packages/sonata-import-bundle/Admin/ImportAdmin.php new file mode 100644 index 000000000..c6e65fe0b --- /dev/null +++ b/packages/sonata-import-bundle/Admin/ImportAdmin.php @@ -0,0 +1,384 @@ + 'Import', + 'model_class' => Import::class, + 'manager_type' => 'orm', + 'label' => 'Import', + 'controller' => ImportController::class, + 'icon' => '', + ] +)] +class ImportAdmin extends AbstractAdmin +{ + public function __construct( + private ColumnFactory $columnFactory, + private EventDispatcherInterface $eventDispatcher, + private ManagerRegistry $managerRegistry, + #[Autowire('%draw.sonata_import.classes%')] + private array $importableClassList + ) { + parent::__construct(); + } + + protected function alterNewInstance(object $object): void + { + if ($entityClass = $this->getRequest()->query->get('entityClass')) { + $object->setEntityClass($entityClass); + } + } + + /** + * @param Import $object + */ + protected function prePersist($object): void + { + /** @var UploadedFile $data */ + $data = $this->getForm()->get('file')->getData(); + $this->processFileUpload($object, $data); + } + + /** + * @param Import $object + */ + protected function postUpdate($object): void + { + if (Import::STATE_VALIDATION === $object->getState()) { + if ($this->processImport($object)) { + $object->setState(Import::STATE_PROCESSED); + $this->getModelManager()->update($object); + } + } + } + + private function processImport(Import $import): bool + { + $flashBag = $this->getRequest()->getSession()->getFlashBag(); + + $file = tempnam(sys_get_temp_dir(), 'csv_'); + file_put_contents($file, $import->getFileContent()); + register_shutdown_function('unlink', $file); + $handle = fopen($file, 'r+'); + $headers = fgetcsv($handle); + + $identifierHeaderName = $import->getIdentifierHeaderName(); + $columnMapping = $import->getColumnMapping(); + $line = 1; + $saved = 0; + $accessor = PropertyAccess::createPropertyAccessor(); + + $identifierColumns = $import->getIdentifierColumns(); + + $identifierHeaderNames = []; + foreach ($identifierColumns as $column) { + $identifierHeaderNames[$column->getHeaderName()] = $column->getMappedTo(); + } + + while (($row = fgetcsv($handle)) !== false) { + ++$line; + $data = array_combine($headers, $row); + $id = $data[$identifierHeaderName]; + $criteria = []; + foreach ($identifierHeaderNames as $headerName => $mappedTo) { + $criteria[$mappedTo] = $data[$headerName]; + } + + $model = $this->findOne($import->getEntityClass(), $criteria, $import->getInsertWhenNotFound()); + + if (null === $model) { + $flashBag->add( + 'sonata_flash_error', + 'Skipped Id ['.implode(', ', $criteria).'] cannot be found at line ['.$line.']. Make sure you are using unique id value.' + ); + continue; + } + + try { + foreach ($columnMapping as $headerName => $column) { + $value = $data[$headerName]; + if ($column->getIsDate()) { + $value = new \DateTime($value); + } + + $this->eventDispatcher->dispatch($event = new AttributeImportEvent($model, $column, $value)); + + if ($event->isPropagationStopped()) { + continue; + } + + $accessor->setValue($model, $column->getMappedTo(), $value); + } + } catch (\Throwable $exception) { + $flashBag->add( + 'sonata_flash_error', + 'Skipped Id ['.$id.'] at line ['.$line.']. Error: '.$exception->getMessage() + ); + continue; + } + + ++$saved; + } + + try { + $this->managerRegistry->getManagerForClass($import->getEntityClass())->flush(); + + $flashBag->add( + 'sonata_flash_success', + 'Entity saved: '.$saved + ); + + return true; + } catch (\Throwable $error) { + $flashBag->add( + 'sonata_flash_error', + 'Error saving data:'.$error->getMessage() + ); + + return false; + } + } + + /** + * The criteria can define path with dot for separator. + * + * @param array $criteria + */ + private function findOne(string $class, array $criteria, bool $create): ?object + { + $manager = $this->managerRegistry->getManagerForClass($class); + + \assert($manager instanceof EntityManagerInterface); + + $parameters = []; + + /** @var array> $relationsCriteria */ + $relationsCriteria = []; + + foreach ($criteria as $key => $value) { + if (1 === substr_count($key, '.')) { + [$object, $field] = explode('.', $key); + $relationsCriteria[$object][$field] = $value; + } else { + $parameters[$key] = $value; + } + } + + $classMetadata = $manager->getClassMetadata($class); + + foreach ($relationsCriteria as $relationName => $objectCriteria) { + $objectClass = $classMetadata->getAssociationTargetClass($relationName); + + $relatedObject = $this->managerRegistry + ->getRepository($objectClass) + ->findOneBy($objectCriteria); + + if (!$relatedObject) { + return null; + } + + $parameters[$relationName] = $relatedObject; + } + + $objects = $manager->getRepository($class)->findBy($parameters); + + if (\count($objects) > 1) { + throw new NonUniqueResultException(); + } + + if (1 === \count($objects)) { + return $objects[0]; + } + + if (!$create) { + return null; + } + + $accessor = PropertyAccess::createPropertyAccessor(); + + $object = new $class(); + foreach ($parameters as $key => $value) { + $accessor->setValue($object, $key, $value); + } + + $manager->persist($object); + + return $object; + } + + private function processFileUpload(Import $import, ?UploadedFile $file = null): void + { + $flashBag = $this->getRequest()->getSession()->getFlashBag(); + + if (null === $file) { + $flashBag->add('sonata_flash_error', 'File not found.'); + + return; + } + + $import->setFileContent(file_get_contents($file->getRealPath())); + + $handle = fopen($file->getRealPath(), 'r'); + + $headers = fgetcsv($handle); + $samples = []; + for ($i = 0; $i < 10; ++$i) { + $row = fgetcsv($handle); + if (!$row) { + break; + } + $samples[] = $row; + } + + $columns = $this->columnFactory->generateColumns( + $import->getEntityClass(), + $headers, + $samples + ); + + foreach ($columns as $column) { + $import->addColumn($column); + } + + $import->setState(Import::STATE_CONFIGURATION); + } + + public function configureListFields(ListMapper $list): void + { + $list + ->addIdentifier('id') + ->add( + 'entityClass', + 'choice', + [ + 'label' => 'Entity', + 'choices' => array_flip($this->getEntityClassChoices()), + ] + ) + ->add('insertWhenNotFound') + ->add('state') + ->add('createdAt'); + } + + public function configureFormFields(FormMapper $form): void + { + /** @var Import $subject */ + $subject = $this->getSubject(); + $form + ->ifTrue(Import::STATE_NEW === $subject->getState()) + ->add( + 'entityClass', + ChoiceType::class, + [ + 'label' => 'Entity', + 'choices' => $this->getEntityClassChoices(), + ] + ) + ->add( + 'file', + FileType::class, + [ + 'mapped' => false, + 'help' => 'CSV File with column headers.', + ] + ) + ->ifEnd() + ->ifTrue(\in_array($subject->getState(), [Import::STATE_CONFIGURATION, Import::STATE_VALIDATION], true)) + ->add('insertWhenNotFound') + ->add( + 'columns', + CollectionType::class, + [ + 'by_reference' => false, + 'btn_add' => false, + 'type_options' => [ + 'btn_delete' => false, + ], + ], + [ + 'edit' => 'inline', + 'inline' => 'table', + ] + ) + ->add( + 'state', + ChoiceType::class, + [ + 'label' => 'Process', + 'help' => 'If you want to process the file', + 'choices' => [ + 'No' => Import::STATE_CONFIGURATION, + 'Yes' => Import::STATE_VALIDATION, + ], + ] + ) + ->ifEnd() + ->ifTrue(Import::STATE_PROCESSED === $subject->getState()) + ->add( + 'insertWhenNotFound', + null, + [ + 'attr' => ['disabled' => true], + ] + ) + ->add( + 'state', + null, + [ + 'attr' => ['disabled' => true], + ] + ) + ->ifEnd(); + } + + private function getEntityClassChoices(): array + { + $choices = []; + foreach ($this->importableClassList as $configuration) { + $label = $configuration['alias'] ?? $configuration['name']; + $choices[$label] = $configuration['name']; + } + + return $choices; + } + + protected function configureRoutes(RouteCollectionInterface $collection): void + { + $collection->add('download', $this->getRouterIdParameter().'/download'); + } + + public function configureActionButtons(array $buttonList, string $action, ?object $object = null): array + { + $buttonList = parent::configureActionButtons($buttonList, $action, $object); + if (\in_array($action, ['edit', 'show'], true)) { + $buttonList['download'] = [ + 'template' => '@DrawSonataImport\ImportAdmin\button_download.html.twig', + ]; + } + + return $buttonList; + } +} diff --git a/packages/sonata-import-bundle/Column/ColumnBuilder/ColumnBuilderInterface.php b/packages/sonata-import-bundle/Column/ColumnBuilder/ColumnBuilderInterface.php new file mode 100644 index 000000000..dc9682d2d --- /dev/null +++ b/packages/sonata-import-bundle/Column/ColumnBuilder/ColumnBuilderInterface.php @@ -0,0 +1,12 @@ +getHeaderName(); + $manager = $this->managerRegistry->getManagerForClass($class); + $metadata = $manager->getClassMetadata($class); + + if (!$metadata instanceof ClassMetadata) { + return null; + } + + foreach ($metadata->associationMappings as $associationName => $associationMapping) { + if (!isset($associationMapping['joinColumns'][0]['name'])) { + continue; + } + + $columnName = $associationMapping['joinColumns'][0]['name']; + if ($columnName === $headerName) { + return (new Column()) + ->setMappedTo($associationName.'.'.$headerName) + ->setIsIdentifier(false) + ->setIsDate(false); + } + } + + return null; + } +} diff --git a/packages/sonata-import-bundle/Column/ColumnBuilder/Doctrine/DoctrineAssociationFieldColumnBuilder.php b/packages/sonata-import-bundle/Column/ColumnBuilder/Doctrine/DoctrineAssociationFieldColumnBuilder.php new file mode 100644 index 000000000..fa6535bca --- /dev/null +++ b/packages/sonata-import-bundle/Column/ColumnBuilder/Doctrine/DoctrineAssociationFieldColumnBuilder.php @@ -0,0 +1,37 @@ +getHeaderName(); + $manager = $this->managerRegistry->getManagerForClass($class); + $metadata = $manager->getClassMetadata($class); + + if (!$metadata instanceof ClassMetadata) { + return null; + } + + $associationMapping = $metadata->associationMappings[$headerName] ?? null; + + if (!$associationMapping) { + return null; + } + + return (new Column()) + ->setMappedTo($headerName.'.'.$associationMapping['joinColumns'][0]['referencedColumnName']) + ->setIsIdentifier(false) + ->setIsDate(false); + } +} diff --git a/packages/sonata-import-bundle/Column/ColumnBuilder/Doctrine/DoctrineAssociationPathColumnBuilder.php b/packages/sonata-import-bundle/Column/ColumnBuilder/Doctrine/DoctrineAssociationPathColumnBuilder.php new file mode 100644 index 000000000..1baa54eb9 --- /dev/null +++ b/packages/sonata-import-bundle/Column/ColumnBuilder/Doctrine/DoctrineAssociationPathColumnBuilder.php @@ -0,0 +1,63 @@ +getHeaderName(); + $manager = $this->managerRegistry->getManagerForClass($class); + $metadata = $manager->getClassMetadata($class); + + if (!$metadata instanceof ClassMetadata) { + return null; + } + + if (1 !== substr_count($headerName, '.')) { + return null; + } + + [$relation, $fieldName] = explode('.', $headerName); + + $associationMapping = $metadata->associationMappings[$relation] ?? null; + + if (!$associationMapping) { + return null; + } + + $targetClass = $associationMapping['targetEntity']; + + $fieldMapping = $this->getFieldMapping($targetClass, $fieldName); + + if (!$fieldMapping) { + return null; + } + + return (new Column()) + ->setMappedTo($relation.'.'.$fieldName) + ->setIsIdentifier($fieldMapping['id'] ?? false) + ->setIsDate(str_starts_with($fieldMapping['type'], 'date')); + } + + private function getFieldMapping(string $class, string $name): ?array + { + $manager = $this->managerRegistry->getManagerForClass($class); + $metadata = $manager->getClassMetadata($class); + + if (!$metadata instanceof ClassMetadata) { + return null; + } + + return $metadata->fieldMappings[$name] ?? null; + } +} diff --git a/packages/sonata-import-bundle/Column/ColumnBuilder/Doctrine/DoctrineFieldColumnBuilder.php b/packages/sonata-import-bundle/Column/ColumnBuilder/Doctrine/DoctrineFieldColumnBuilder.php new file mode 100644 index 000000000..b2968e0b3 --- /dev/null +++ b/packages/sonata-import-bundle/Column/ColumnBuilder/Doctrine/DoctrineFieldColumnBuilder.php @@ -0,0 +1,47 @@ +getHeaderName(); + $manager = $this->managerRegistry->getManagerForClass($class); + $metadata = $manager->getClassMetadata($class); + + if (!$metadata instanceof ClassMetadata) { + return null; + } + + $fieldMapping = $metadata->fieldMappings[$headerName] ?? null; + + if (!$fieldMapping) { + return null; + } + + $columnInfo = (new Column()) + ->setMappedTo($headerName) + ->setIsDate(str_starts_with($fieldMapping['type'], 'date')); + + if ($fieldMapping['id'] ?? false) { + $columnInfo->setIsIdentifier(true); + } + + if (null === $column->getIsIdentifier() && ($fieldMapping['unique'] ?? false)) { + $columnInfo->setIsIdentifier(true); + } + + return $columnInfo; + + } +} diff --git a/packages/sonata-import-bundle/Column/ColumnBuilder/NamedBaseIdentifierColumnBuilder.php b/packages/sonata-import-bundle/Column/ColumnBuilder/NamedBaseIdentifierColumnBuilder.php new file mode 100644 index 000000000..3a6b7ddad --- /dev/null +++ b/packages/sonata-import-bundle/Column/ColumnBuilder/NamedBaseIdentifierColumnBuilder.php @@ -0,0 +1,21 @@ +getHeaderName(); + if (!\in_array(mb_strtolower($headerName), self::$names, true)) { + return null; + } + + return (new Column()) + ->setIsIdentifier(true); + } +} diff --git a/packages/sonata-import-bundle/Column/ColumnBuilder/ReflectionColumnBuilder.php b/packages/sonata-import-bundle/Column/ColumnBuilder/ReflectionColumnBuilder.php new file mode 100644 index 000000000..737097b9a --- /dev/null +++ b/packages/sonata-import-bundle/Column/ColumnBuilder/ReflectionColumnBuilder.php @@ -0,0 +1,73 @@ +getHeaderName(); + + $reflectionClass = new \ReflectionClass($class); + + $method = 'set'.$headerName; + + if (!$reflectionClass->hasMethod($method)) { + return null; + } + + $reflectionMethod = $reflectionClass->getMethod($method); + + $parameters = $reflectionMethod->getParameters(); + + // No parameter cannot be a proper setter + if (0 === \count($parameters)) { + return null; + } + + $parameter = array_shift($parameters); + // More than one parameter and default value is not available it's not a proper setter + foreach ($parameters as $parameter) { + if (!$parameter->isDefaultValueAvailable()) { + return null; + } + } + + $columnInfo = (new Column()) + ->setMappedTo($headerName); + + $type = $parameter->getType(); + + if ($type instanceof \ReflectionNamedType) { + if ($this->isDate($type)) { + $columnInfo->setIsDate(true); + } + + return $columnInfo; + } + + if ($type instanceof \ReflectionUnionType) { + foreach ($type->getTypes() as $type) { + if ($this->isDate($type)) { + $columnInfo->setIsDate(true); + } + } + + return $columnInfo; + } + + return $columnInfo; + } + + private function isDate(\ReflectionNamedType $type): bool + { + $class = $type->getName(); + if (class_exists($class) && is_subclass_of($class, \DateTimeInterface::class)) { + return true; + } + + return false; + } +} diff --git a/packages/sonata-import-bundle/Column/ColumnFactory.php b/packages/sonata-import-bundle/Column/ColumnFactory.php new file mode 100644 index 000000000..c9c8bb41c --- /dev/null +++ b/packages/sonata-import-bundle/Column/ColumnFactory.php @@ -0,0 +1,83 @@ + $columnBuilders + */ + #[TaggedIterator(ColumnBuilderInterface::class)] + private iterable $columnBuilders = [] + ) { + } + + /** + * @return Column[] + */ + public function generateColumns(string $class, array $headers, array $samples): array + { + $columns = []; + foreach ($headers as $index => $headerName) { + $column = (new Column()) + ->setIsDate(false) + ->setHeaderName($headerName); + + $columnSamples = []; + + foreach ($samples as $rowSample) { + $columnSample = $rowSample[$index]; + + if (null === $column->getSample()) { + $column->setSample($columnSample); + } + + if ($columnSample) { + $column->setSample($columnSample); + } + + $columnSamples[] = $columnSample; + } + + foreach ($this->columnBuilders as $extractor) { + $columnInfo = $extractor->extract($class, clone $column, $columnSamples); + if ($columnInfo) { + $this->assign($columnInfo, $column); + } + } + + if (null === $column->getIsIdentifier()) { + $column->setIsIdentifier(false); + } + + $column->setIsIgnored(!$column->getMappedTo()); + if ($column->getIsIdentifier()) { + $column->setIsIgnored(false); + } + + $columns[] = $column; + } + + return $columns; + } + + private function assign(Column $source, Column $target): void + { + if (null !== $source->getIsIdentifier()) { + $target->setIsIdentifier($source->getIsIdentifier()); + } + + if (null !== $source->getIsDate()) { + $target->setIsDate($source->getIsDate()); + } + + if (null !== $source->getMappedTo()) { + $target->setMappedTo($source->getMappedTo()); + } + } +} diff --git a/packages/sonata-import-bundle/Column/MappedToOptionBuilder/DoctrineMappedToOptionBuilder.php b/packages/sonata-import-bundle/Column/MappedToOptionBuilder/DoctrineMappedToOptionBuilder.php new file mode 100644 index 000000000..2cd107b50 --- /dev/null +++ b/packages/sonata-import-bundle/Column/MappedToOptionBuilder/DoctrineMappedToOptionBuilder.php @@ -0,0 +1,51 @@ +getImport()->getEntityClass(); + + $metadata = $this->managerRegistry->getManagerForClass($class)->getClassMetadata($class); + + if (!$metadata instanceof ClassMetadata) { + return $options; + } + + $choices = []; + foreach ($metadata->fieldMappings as $fieldMapping) { + $choices[$fieldMapping['columnName']] = $fieldMapping['columnName']; + } + + foreach ($metadata->associationMappings as $name => $associationMapping) { + $targetClassMetadata = $this->managerRegistry + ->getManagerForClass($associationMapping['targetEntity']) + ->getClassMetadata($associationMapping['targetEntity']); + + if (!$targetClassMetadata instanceof ClassMetadata) { + continue; + } + + foreach ($targetClassMetadata->fieldMappings as $fieldName => $fieldMapping) { + if (!($fieldMapping['id'] ?? false) && !($fieldMapping['unique'] ?? false)) { + continue; + } + + $choices[$name.'.'.$fieldName] = $name.'.'.$fieldName; + } + } + + return $choices; + } +} diff --git a/packages/sonata-import-bundle/Column/MappedToOptionBuilder/MappedToOptionBuilderInterface.php b/packages/sonata-import-bundle/Column/MappedToOptionBuilder/MappedToOptionBuilderInterface.php new file mode 100644 index 000000000..06a83847d --- /dev/null +++ b/packages/sonata-import-bundle/Column/MappedToOptionBuilder/MappedToOptionBuilderInterface.php @@ -0,0 +1,17 @@ + the options to be used for the mapped to field + */ + public function getOptions(Column $column, array $options): array; +} diff --git a/packages/sonata-import-bundle/Controller/ImportController.php b/packages/sonata-import-bundle/Controller/ImportController.php new file mode 100644 index 000000000..4fd97075f --- /dev/null +++ b/packages/sonata-import-bundle/Controller/ImportController.php @@ -0,0 +1,28 @@ +getFileContent()); + + $disposition = $response->headers + ->makeDisposition( + ResponseHeaderBag::DISPOSITION_ATTACHMENT, + 'import_'.$import->getId().'.csv' + ); + + $response->headers->set('Content-Disposition', $disposition); + + return $response; + } +} diff --git a/packages/sonata-import-bundle/DependencyInjection/Compiler/CompilerPass.php b/packages/sonata-import-bundle/DependencyInjection/Compiler/CompilerPass.php new file mode 100644 index 000000000..7ef4fd7b7 --- /dev/null +++ b/packages/sonata-import-bundle/DependencyInjection/Compiler/CompilerPass.php @@ -0,0 +1,33 @@ +getParameter('draw.sonata_import.classes'); + + foreach ($container->findTaggedServiceIds('sonata.admin') as $serviceId => $tags) { + foreach ($tags as $tag) { + if (!isset($tag['model_class'])) { + continue; + } + + if (!isset($classes[$tag['model_class']])) { + continue; + } + + $container->getDefinition($serviceId) + ->addMethodCall('addExtension', [new Reference(ImportExtension::class)]); + + break; + } + } + } +} diff --git a/packages/sonata-import-bundle/DependencyInjection/Configuration.php b/packages/sonata-import-bundle/DependencyInjection/Configuration.php new file mode 100755 index 000000000..4f49144b2 --- /dev/null +++ b/packages/sonata-import-bundle/DependencyInjection/Configuration.php @@ -0,0 +1,54 @@ +getRootNode(); + + $node + ->children() + ->arrayNode('classes') + ->beforeNormalization() + ->always(function ($classes) { + $result = []; + foreach ($classes as $class => $configuration) { + if (\is_string($configuration)) { + $class = $configuration; + $configuration = ['name' => $class]; + } + + if (!isset($configuration['name'])) { + $configuration['name'] = $class; + } + + $result[$class] = $configuration; + } + + return $result; + }) + ->end() + ->useAttributeAsKey('name', false) + ->arrayPrototype() + ->children() + ->scalarNode('name')->isRequired()->end() + ->scalarNode('alias')->end() + ->end() + ->end() + ->end() + ->end(); + + return $treeBuilder; + } +} diff --git a/packages/sonata-import-bundle/DependencyInjection/DrawSonataImportExtension.php b/packages/sonata-import-bundle/DependencyInjection/DrawSonataImportExtension.php new file mode 100644 index 000000000..13ff7ed83 --- /dev/null +++ b/packages/sonata-import-bundle/DependencyInjection/DrawSonataImportExtension.php @@ -0,0 +1,23 @@ +load('services.yaml'); + $container + ->registerForAutoconfiguration(ColumnBuilderInterface::class) + ->addTag('draw.sonata_import.extractor'); + + $container->setParameter('draw.sonata_import.classes', $mergedConfig['classes']); + } +} diff --git a/packages/sonata-import-bundle/DrawSonataImportBundle.php b/packages/sonata-import-bundle/DrawSonataImportBundle.php new file mode 100644 index 000000000..ad728dd93 --- /dev/null +++ b/packages/sonata-import-bundle/DrawSonataImportBundle.php @@ -0,0 +1,15 @@ +addCompilerPass(new CompilerPass()); + } +} diff --git a/packages/sonata-import-bundle/Entity/Column.php b/packages/sonata-import-bundle/Entity/Column.php new file mode 100644 index 000000000..1413ebb50 --- /dev/null +++ b/packages/sonata-import-bundle/Entity/Column.php @@ -0,0 +1,187 @@ + 0])] + private ?bool $isIdentifier = null; + + #[ORM\Column(name: 'is_ignored', type: 'boolean', options: ['default' => 0])] + private ?bool $isIgnored = null; + + /** + * To which attribute this column mapped to. If not mapped it must be ignored. + */ + #[ORM\Column(name: 'mapped_to', type: 'string', nullable: true)] + private ?string $mappedTo = null; + + #[ORM\Column(name: 'is_date', type: 'boolean', nullable: false, options: ['default' => 0])] + private ?bool $isDate = null; + + #[ORM\Column(name: 'created_at', type: 'datetime', nullable: false)] + private ?\DateTime $createdAt = null; + + #[ORM\Column(name: 'updated_at', type: 'datetime', nullable: false)] + private ?\DateTime $updatedAt = null; + + public function getId(): ?int + { + return $this->id; + } + + public function setId(?int $id): static + { + $this->id = $id; + + return $this; + } + + public function getImport(): ?Import + { + return $this->import; + } + + public function setImport(?Import $import): static + { + $this->import = $import; + $import->addColumn($this); + + return $this; + } + + public function getHeaderName(): ?string + { + return $this->headerName; + } + + public function setHeaderName(?string $headerName): static + { + $this->headerName = $headerName; + + return $this; + } + + public function getSample(): ?string + { + return $this->sample; + } + + public function setSample(?string $sample): static + { + $this->sample = $sample; + + return $this; + } + + public function getIsIdentifier(): ?bool + { + return $this->isIdentifier; + } + + public function setIsIdentifier(?bool $isIdentifier): static + { + $this->isIdentifier = $isIdentifier; + + return $this; + } + + public function getIsIgnored(): ?bool + { + return $this->isIgnored; + } + + public function setIsIgnored(?bool $isIgnored): static + { + $this->isIgnored = $isIgnored; + + return $this; + } + + public function getMappedTo(): ?string + { + return $this->mappedTo; + } + + public function setMappedTo(?string $mappedTo): static + { + $this->mappedTo = $mappedTo; + + return $this; + } + + public function getIsDate(): ?bool + { + return $this->isDate; + } + + public function setIsDate(?bool $isDate): static + { + $this->isDate = $isDate; + + return $this; + } + + public function setCreatedAt(\DateTime $createdAt): static + { + $this->createdAt = $createdAt; + + return $this; + } + + public function getUpdatedAt(): ?\DateTime + { + return $this->updatedAt; + } + + public function setUpdatedAt(\DateTime $updatedAt): static + { + $this->updatedAt = $updatedAt; + + return $this; + } + + #[ORM\PrePersist] + #[ORM\PreUpdate] + public function updateTimestamp(PreUpdateEventArgs|PrePersistEventArgs $eventArgs): void + { + if (null === $this->createdAt) { + $this->createdAt = new \DateTime(); + } + + if ($eventArgs instanceof PreUpdateEventArgs && $eventArgs->hasChangedField('updatedAt')) { + return; + } + + $this->updatedAt = new \DateTime(); + } +} diff --git a/packages/sonata-import-bundle/Entity/Import.php b/packages/sonata-import-bundle/Entity/Import.php new file mode 100644 index 000000000..9b25a2392 --- /dev/null +++ b/packages/sonata-import-bundle/Entity/Import.php @@ -0,0 +1,287 @@ + true])] + private ?int $id = null; + + /** + * The class (or class alias) of the entity you want to import. + */ + #[ORM\Column(name: 'entity_class', type: 'string', length: 255, nullable: false)] + private ?string $entityClass = null; + + #[ORM\Column(name: 'insert_when_not_found', type: 'boolean', nullable: false, options: ['default' => false])] + private bool $insertWhenNotFound = false; + + /** + * The content of the file to import. + */ + #[ORM\Column(name: 'file_content', type: 'text', nullable: true)] + private ?string $fileContent = null; + + /** + * @var Selectable&Collection + */ + #[ORM\OneToMany(mappedBy: 'import', targetEntity: Column::class, cascade: ['persist'])] + #[Assert\Count(min: 1, groups: ['validation'])] + #[Assert\Valid] + private Selectable&Collection $columns; + + #[ORM\Column(name: 'state', type: 'string', length: 40, nullable: false, options: ['default' => 'new'])] + private string $state = self::STATE_NEW; + + #[ORM\Column(name: 'created_at', type: 'datetime', nullable: false)] + private ?\DateTime $createdAt = null; + + #[ORM\Column(name: 'updated_at', type: 'datetime', nullable: false)] + private ?\DateTime $updatedAt = null; + + public function __construct() + { + $this->columns = new ArrayCollection(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function setId(?int $id): static + { + $this->id = $id; + + return $this; + } + + public function getEntityClass(): ?string + { + return $this->entityClass; + } + + public function setEntityClass(?string $entityClass): static + { + $this->entityClass = $entityClass; + + return $this; + } + + public function getInsertWhenNotFound(): bool + { + return $this->insertWhenNotFound; + } + + public function setInsertWhenNotFound(bool $insertWhenNotFound): self + { + $this->insertWhenNotFound = $insertWhenNotFound; + + return $this; + } + + public function getFileContent(): ?string + { + return $this->fileContent; + } + + public function setFileContent(?string $fileContent): static + { + $this->fileContent = $fileContent; + + return $this; + } + + /** + * @return Selectable&Collection + */ + public function getColumns(): Selectable&Collection + { + return $this->columns; + } + + public function addColumn(Column $header): static + { + if (!$this->columns->contains($header)) { + $this->columns->add($header); + $header->setImport($this); + } + + return $this; + } + + public function removeColumn(Column $header): static + { + if ($this->columns->contains($header)) { + $this->columns->removeElement($header); + } + + return $this; + } + + public function getState(): ?string + { + return $this->state; + } + + public function setState(?string $state): static + { + $this->state = $state; + + return $this; + } + + public function setCreatedAt(\DateTime $createdAt): static + { + $this->createdAt = $createdAt; + + return $this; + } + + public function getCreatedAt(): ?\DateTime + { + return $this->createdAt; + } + + public function getUpdatedAt(): ?\DateTime + { + return $this->updatedAt; + } + + public function setUpdatedAt(\DateTime $updatedAt): static + { + $this->updatedAt = $updatedAt; + + return $this; + } + + #[ + ORM\PrePersist, + ORM\PreUpdate + ] + public function updateTimestamp(LifecycleEventArgs $eventArgs): static + { + if (null === $this->createdAt) { + $this->createdAt = new \DateTime(); + } + + if ($eventArgs instanceof PreUpdateEventArgs && $eventArgs->hasChangedField('updatedAt')) { + return $this; + } + + $this->updatedAt = new \DateTime(); + + return $this; + } + + #[Assert\Callback(groups: ['validation'])] + public function validateForProcessing(ExecutionContextInterface $context): void + { + $asIdentifier = false; + foreach ($this->columns as $key => $column) { + if ($column->getIsIdentifier()) { + if ($column->getIsIgnored()) { + $context + ->buildViolation('Identifier column "{{ name }}" cannot be ignored.') + ->atPath('columns['.$key.']') + ->setParameter('{{ name }}', $column->getHeaderName()) + ->addViolation(); + } + } + + $asIdentifier = true; + } + + if (!$asIdentifier) { + $context + ->buildViolation('You need a identifier column.') + ->atPath('columns') + ->addViolation(); + } + } + + public function getGroupSequence(): array|Assert\GroupSequence + { + $sequences = ['Import']; + $sequences[] = $this->getState(); + + return $sequences; + } + + public function getIdentifierHeaderName(): ?string + { + foreach ($this->getColumns() as $column) { + if ($column->getIsIdentifier()) { + return $column->getHeaderName(); + } + } + + return null; + } + + /** + * @return Column[] + */ + public function getColumnMapping(): array + { + $criteria = Criteria::create() + ->andWhere(Criteria::expr()->neq('isIdentifier', true)) + ->andWhere(Criteria::expr()->neq('isIgnored', true)); + + $mapping = []; + foreach ($this->columns->matching($criteria) as $column) { + $mapping[$column->getHeaderName()] = $column; + } + + return $mapping; + } + + /** + * @return array + */ + public function getIdentifierColumns(): array + { + $columns = $this->getColumns() + ->matching( + Criteria::create() + ->andWhere(Criteria::expr()->eq('isIdentifier', true)) + ) + ->toArray(); + + if (\count($columns) > 0) { + return $columns; + } + + throw new \RuntimeException('No identifier column set'); + } + + public function __toString(): string + { + return (string) $this->id; + } +} diff --git a/packages/sonata-import-bundle/Event/AttributeImportEvent.php b/packages/sonata-import-bundle/Event/AttributeImportEvent.php new file mode 100644 index 000000000..f6376c7c3 --- /dev/null +++ b/packages/sonata-import-bundle/Event/AttributeImportEvent.php @@ -0,0 +1,34 @@ +entity; + } + + public function getColumn(): Column + { + return $this->column; + } + + public function getValue(): mixed + { + return $this->value; + } +} diff --git a/packages/sonata-import-bundle/Extension/ImportExtension.php b/packages/sonata-import-bundle/Extension/ImportExtension.php new file mode 100644 index 000000000..ca0a5d519 --- /dev/null +++ b/packages/sonata-import-bundle/Extension/ImportExtension.php @@ -0,0 +1,24 @@ + It's currently only support update of existing entities. + +Base on a configuration you can set which entity can be imported. + +When uploading a csv file the system will detect the list of attribute base on a header column. +It will then try to detect the identifier and the mutator to update the entities. + +## Configuration + +Here is an example of the configuration: + +```YAML +draw_sonata_import: + classes: + App\Entity\User: + alias: 'User' #The alias will be use instead of the full class name in the drop down and database + App\Entity\Product: + alias: 'Product' +``` + +This tell the system that it support import for **App\Entity\User** and **App\Entity\Product**. + +## Sonata admin + +A new menu **Import** will be available on the lef menu to create a new import. A drop down to specify which entity +you are importing is available base on the configuration. There is also a action **Import** on the list view of the +entities that will link directly to this page with the entity selected in the drop down. + +You must select a csv file from which the system will detect the header and will try to fill the column information. +You can then adjust it manually and select to **Process** the file. + +## Column Information Extraction + +The system try to extract column information base on a **Draw\Bundle\SonataImportBundle\Extraction\Extractor\ExtractorInterface**. +There is two extractor provider with the system, one that will set if a column is the identifier base on it's name (id only), +another one that will check if there is a **setter** base on the header to assign the mutator. + +## Import + +When importing data a event **Draw\Bundle\SonataImportBundle\Event\AttributeImportEvent** is dispatch for every entity/column. +Listening to this event let you do custom import logic of the data. If you did process the event you need to stop +is propagation. If the event was not stop, the import logic will fallback on the column mutator that was set. + +The setter just do a simple set of the raw value in the column (that is always a string) so if it's a reference to another +object you must implement a listener to do have a custom logic. + +## Todo + +- A better extractor that support column reference by identifier (id or unique column) +- Possibility to create on import (not just update) diff --git a/packages/sonata-import-bundle/Resources/config/services.yaml b/packages/sonata-import-bundle/Resources/config/services.yaml new file mode 100644 index 000000000..925f2d9d0 --- /dev/null +++ b/packages/sonata-import-bundle/Resources/config/services.yaml @@ -0,0 +1,8 @@ +services: + _defaults: + autowire: true # Automatically injects dependencies in your services. + autoconfigure: true # Automatically registers your services as commands, event subscribers, etc. + + Draw\Bundle\SonataImportBundle\: + resource: '../..' + exclude: '../../{DependencyInjection,Entity,Tests}' diff --git a/packages/sonata-import-bundle/Resources/views/ImportAdmin/button_download.html.twig b/packages/sonata-import-bundle/Resources/views/ImportAdmin/button_download.html.twig new file mode 100644 index 000000000..89940515e --- /dev/null +++ b/packages/sonata-import-bundle/Resources/views/ImportAdmin/button_download.html.twig @@ -0,0 +1,6 @@ +
  • + + + Download + +
  • \ No newline at end of file diff --git a/packages/sonata-import-bundle/Resources/views/ImportAdmin/button_import.html.twig b/packages/sonata-import-bundle/Resources/views/ImportAdmin/button_import.html.twig new file mode 100644 index 000000000..06a093491 --- /dev/null +++ b/packages/sonata-import-bundle/Resources/views/ImportAdmin/button_import.html.twig @@ -0,0 +1,7 @@ +
  • + + + Import + +
  • diff --git a/packages/sonata-import-bundle/Tests/ColumnFactoryTest.php b/packages/sonata-import-bundle/Tests/ColumnFactoryTest.php new file mode 100644 index 000000000..5ec10dec0 --- /dev/null +++ b/packages/sonata-import-bundle/Tests/ColumnFactoryTest.php @@ -0,0 +1,75 @@ +columnFactory = new ColumnFactory( + [ + new NamedBaseIdentifierColumnBuilder(), + new ReflectionColumnBuilder() + ] + ); + } + + public function testGenerateColumnsIdentifier(): void + { + $columns = $this->columnFactory + ->generateColumns( + Import::class, + ['id'], + [[12]] + ); + + static::assertCount(1, $columns); + + $column = $columns[0]; + + static::assertInstanceOf(Column::class, $column); + + static::assertSame('id', $column->getHeaderName()); + static::assertSame('id', $column->getMappedTo()); + static::assertTrue($column->getIsIdentifier()); + static::assertFalse($column->getIsIgnored()); + static::assertFalse($column->getIsDate()); + } + + public function testGenerateColumnsDate(): void + { + $columns = $this->columnFactory + ->generateColumns( + Import::class, + ['createdAt'], + ['2018-10-10'] + ); + + static::assertCount(1, $columns); + + $column = $columns[0]; + + static::assertInstanceOf(Column::class, $column); + + static::assertSame('createdAt', $column->getHeaderName()); + static::assertSame('createdAt', $column->getMappedTo()); + static::assertFalse($column->getIsIdentifier()); + static::assertFalse($column->getIsIgnored()); + static::assertTrue($column->getIsDate()); + } +} diff --git a/packages/sonata-import-bundle/Tests/Entity/ImportTest.php b/packages/sonata-import-bundle/Tests/Entity/ImportTest.php new file mode 100644 index 000000000..3641bfdc6 --- /dev/null +++ b/packages/sonata-import-bundle/Tests/Entity/ImportTest.php @@ -0,0 +1,223 @@ +entity = new Import(); + } + + public function testConstruct(): void + { + static::assertInstanceOf(GroupSequenceProviderInterface::class, $this->entity); + } + + public function testIdMutator(): void + { + static::assertNull($this->entity->getId()); + static::assertSame( + $this->entity, + $this->entity->setId(999) + ); + static::assertSame(999, $this->entity->getId()); + } + + public function testEntityClassMutator(): void + { + static::assertNull($this->entity->getEntityClass()); + + static::assertSame( + $this->entity, + $this->entity->setEntityClass($entityClass = 'test') + ); + + static::assertSame($entityClass, $this->entity->getEntityClass()); + } + + public function testFileContentMutator(): void + { + static::assertNull($this->entity->getFileContent()); + + static::assertSame( + $this->entity, + $this->entity->setFileContent($fileContent = 'file content') + ); + + static::assertSame($fileContent, $this->entity->getFileContent()); + } + + public function testStateMutator(): void + { + static::assertSame(Import::STATE_NEW, $this->entity->getState()); + + static::assertSame( + $this->entity, + $this->entity->setState($state = Import::STATE_CONFIGURATION) + ); + + static::assertSame($state, $this->entity->getState()); + } + + public function testColumnsMutator(): void + { + static::assertInstanceOf(Collection::class, $collection = $this->entity->getColumns()); + static::assertCount(0, $collection); + + static::assertSame( + $this->entity, + $this->entity->addColumn($column = new Column()) + ); + + static::assertSame($this->entity, $column->getImport()); + + static::assertTrue($collection->contains($column)); + + static::assertSame( + $this->entity, + $this->entity->removeColumn($column) + ); + + static::assertFalse($collection->contains($column)); + } + + public function testGetUpdatedAtMutator(): void + { + static::assertNull($this->entity->getUpdatedAt()); + + static::assertSame( + $this->entity, + $this->entity->setUpdatedAt($dateTime = new \DateTime()) + ); + + static::assertSame($dateTime, $this->entity->getUpdatedAt()); + } + + public function testGetCreatedAtMutator(): void + { + static::assertNull($this->entity->getCreatedAt()); + + static::assertSame( + $this->entity, + $this->entity->setCreatedAt($dateTime = new \DateTime()) + ); + + static::assertSame($dateTime, $this->entity->getCreatedAt()); + } + + public function testUpdateTimeStamp(): void + { + static::assertNull($this->entity->getCreatedAt()); + static::assertNull($this->entity->getUpdatedAt()); + + $this->entity->updateTimestamp( + new LifecycleEventArgs( + $this->entity, + $this->createMock(EntityManagerInterface::class) + ) + ); + + static::assertInstanceOf(\DateTime::class, $this->entity->getCreatedAt()); + static::assertInstanceOf(\DateTime::class, $dateTime = $this->entity->getUpdatedAt()); + + $this->entity->updateTimestamp( + new LifecycleEventArgs( + $this->entity, + $this->getMockForAbstractClass(ObjectManager::class) + ) + ); + + static::assertNotSame($dateTime, $dateTime = $this->entity->getUpdatedAt()); + + $changeSet = ['updatedAt' => []]; + $this->entity->updateTimestamp( + new PreUpdateEventArgs( + $this->entity, + $this->getMockForAbstractClass(EntityManagerInterface::class), + $changeSet + ) + ); + + static::assertSame($dateTime, $this->entity->getUpdatedAt()); + } + + public function testGetGroupSequence(): void + { + static::assertSame( + ['Import', $this->entity->getState()], + $this->entity->getGroupSequence() + ); + } + + public function testGetColumnMapping(): void + { + $this->entity->addColumn($column1 = new Column()); + $column1->setHeaderName('Column1'); + + $this->entity->addColumn($column = new Column()); + $column->setIsIdentifier(true); // Identifier columns are ignored + + $this->entity->addColumn($column = new Column()); + $column->setIsIgnored(true); + + static::assertSame( + ['Column1' => $column1], + $this->entity->getColumnMapping() + ); + } + + public function testValidateForProcessing(): void + { + $constraint = new Callback('validateForProcessing'); + $validator = Validation::createValidator(); + + static::assertCount(1, $violations = $validator->validate($this->entity, $constraint)); + static::assertSame( + 'You need a identifier column.', + $violations[0]->getMessage() + ); + static::assertSame( + 'columns', + $violations[0]->getPropertyPath() + ); + + $this->entity->addColumn($column = new Column()); + $column->setHeaderName('Id'); + $column->setIsIdentifier(true); + $column->setIsIgnored(true); + + static::assertCount(1, $violations = $validator->validate($this->entity, $constraint)); + static::assertSame( + 'Identifier column "Id" cannot be ignored.', + $violations[0]->getMessage() + ); + static::assertSame( + 'columns[0]', + $violations[0]->getPropertyPath() + ); + } + + public function testToString(): void + { + static::assertSame('', $this->entity->__toString()); + + $this->entity->setId(999); + + static::assertSame('999', $this->entity->__toString()); + } +} diff --git a/packages/sonata-import-bundle/composer.json b/packages/sonata-import-bundle/composer.json new file mode 100644 index 000000000..d475b87c0 --- /dev/null +++ b/packages/sonata-import-bundle/composer.json @@ -0,0 +1,31 @@ +{ + "name": "draw/sonata-import-bundle", + "description": "Generic customisable import system for sonata", + "license": "MIT", + "type": "library", + "keywords": ["draw", "bundle", "symfony"], + "authors": [ + { + "name": "Martin Poirier Theoret", + "email": "mpoiriert@gmail.com" + } + ], + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "minimum-stability": "dev", + "prefer-stable": true, + "autoload": { + "psr-4": { + "Draw\\Bundle\\SonataImportBundle\\": "" + } + }, + "extra": { + "branch-alias": { + "dev-master": "0.11-dev" + } + } +} \ No newline at end of file diff --git a/packages/sonata-import-bundle/phpunit.xml.dist b/packages/sonata-import-bundle/phpunit.xml.dist new file mode 100644 index 000000000..8f9f9f262 --- /dev/null +++ b/packages/sonata-import-bundle/phpunit.xml.dist @@ -0,0 +1,9 @@ + + + + ./Tests + + + \ No newline at end of file diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 14e0db600..1edbbb483 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -30,6 +30,11 @@ parameters: count: 1 path: packages/security/Http/Authenticator/JwtAuthenticator.php + - + message: "#^Call to static method PHPUnit\\\\Framework\\\\Assert\\:\\:assertInstanceOf\\(\\) with 'DateTime' and null will always evaluate to false\\.$#" + count: 2 + path: packages/sonata-import-bundle/Tests/Entity/ImportTest.php + - message: "#^Class Draw\\\\Bundle\\\\SonataIntegrationBundle\\\\User\\\\Security\\\\AdminLoginAuthenticator extends @final class Symfony\\\\Component\\\\Security\\\\Http\\\\Authenticator\\\\FormLoginAuthenticator\\.$#" count: 1