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..1284b9161
--- /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 used instead of the full class name in the dropdown 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 dropdown to specify which entity
+you are importing is available base on the configuration. There is also an action **Import** on the list view of the
+entities that will link directly to this page with the entity selected in the dropdown.
+
+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\Column\ColumnBuilder\ColumnBuilderInterface**.
+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 fall back 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.
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