diff --git a/demoentityimporter/README.md b/demoentityimporter/README.md new file mode 100644 index 0000000..654784d --- /dev/null +++ b/demoentityimporter/README.md @@ -0,0 +1,66 @@ +# Demo: register a custom entity importer + +This module shows how a PrestaShop 9.2+ module plugs its own entity importer +into the core import engine (Advanced Parameters > Import) — and actually +imports something: each CSV row becomes a `DemoNote` Doctrine entity. + +## How it works + +1. The importer class extends + `PrestaShop\PrestaShop\Core\Import\Engine\EntityImporter\AbstractEntityImporter` + ([src/Importer/DemoNoteImporter.php](src/Importer/DemoNoteImporter.php)), + which provides the cursor-resumable batch loop, the phase-id guard and + the default unit count (implementing `EntityImporterInterface` directly + also works for full control). It declares two phases — a pausing + `validation` phase (note required, max 255 characters) and a `database` + phase that persists one `DemoNote` per row (create, or update when the + mapped `id` column matches an existing note). +2. The service is registered with `autoconfigure: true` + ([config/services.yml](config/services.yml)). The core registers the + `core.import.entity_importer` tag for every autoconfigured service + implementing the interface — no manual tag needed. +3. The core `EntityImporterRegistry` collects every tagged importer: the + import page entity dropdown, the mapping screen field list and the batch + execution all discover the module importer automatically. +4. The importer reuses the core engine services instead of re-implementing + file handling: `ResumableFileReaderInterface` (cursor-resumable reads of + the normalized working file — header/skip rows are already stripped at + normalization) and `RowMapper` (column-to-field mapping). +5. The `DemoNote` entity ([src/Entity/DemoNote.php](src/Entity/DemoNote.php)) + lives in `src/Entity`, which the core maps automatically for active + modules — no Doctrine configuration needed. Its table is created at + module install ([demoentityimporter.php](demoentityimporter.php)). + +## Verify + +After installing the module: + +```bash +php bin/console debug:container --tag=core.import.entity_importer +``` + +should list `DemoNoteImporter` next to the core importers. + +A sample import file is provided in [sample/demo_notes.csv](sample/demo_notes.csv): + +```csv +id;note +;First imported note +;Second imported note +``` + +(the empty `id` column means "create"; put an existing note id there to +update it instead). + +## Install + +```bash +cd modules/demoentityimporter +composer dumpautoload +``` + +then install the module (BO module manager or `php bin/console prestashop:module install demoentityimporter`). + +## Requirements + +- PrestaShop 9.2.0 or newer (import engine introduced by [PrestaShop/PrestaShop#41907](https://github.com/PrestaShop/PrestaShop/issues/41907)). diff --git a/demoentityimporter/composer.json b/demoentityimporter/composer.json new file mode 100644 index 0000000..7ca27ea --- /dev/null +++ b/demoentityimporter/composer.json @@ -0,0 +1,19 @@ +{ + "name": "prestashop/demoentityimporter", + "description": "PrestaShop example module: register a custom entity importer into the core import engine", + "license": "AFL-3.0", + "authors": [ + { + "name": "PrestaShop Core team" + } + ], + "autoload": { + "psr-4": { + "PrestaShop\\Module\\DemoEntityImporter\\": "src/" + } + }, + "config": { + "prepend-autoloader": false + }, + "type": "prestashop-module" +} diff --git a/demoentityimporter/config.xml b/demoentityimporter/config.xml new file mode 100644 index 0000000..5d723ea --- /dev/null +++ b/demoentityimporter/config.xml @@ -0,0 +1,11 @@ + + + demoentityimporter + + + + + + 0 + 1 + \ No newline at end of file diff --git a/demoentityimporter/config/services.yml b/demoentityimporter/config/services.yml new file mode 100644 index 0000000..d8ab2d0 --- /dev/null +++ b/demoentityimporter/config/services.yml @@ -0,0 +1,10 @@ +services: + _defaults: + public: true + autowire: true + # autoconfigure is what gets the service automatically tagged as an + # entity importer (the core registers the tag for every autoconfigured + # service implementing EntityImporterInterface) + autoconfigure: true + + PrestaShop\Module\DemoEntityImporter\Importer\DemoNoteImporter: ~ diff --git a/demoentityimporter/demoentityimporter.php b/demoentityimporter/demoentityimporter.php new file mode 100644 index 0000000..2dcc93c --- /dev/null +++ b/demoentityimporter/demoentityimporter.php @@ -0,0 +1,72 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +declare(strict_types=1); + +if (!defined('_PS_VERSION_')) { + exit; +} + +class DemoEntityImporter extends Module +{ + public function __construct() + { + $this->name = 'demoentityimporter'; + $this->author = 'PrestaShop'; + $this->version = '1.0.0'; + $this->ps_versions_compliancy = ['min' => '9.2.0', 'max' => '9.99.99']; + + parent::__construct(); + + $this->displayName = $this->trans('Demo - register a custom entity importer', [], 'Modules.Demoentityimporter.Admin'); + $this->description = $this->trans('Shows how a module registers its own importer into the core import engine and persists a Doctrine entity.', [], 'Modules.Demoentityimporter.Admin'); + } + + public function install(): bool + { + return parent::install() && $this->installDatabase(); + } + + public function uninstall(): bool + { + return parent::uninstall() && $this->uninstallDatabase(); + } + + /** + * Creates the table backing the DemoNote Doctrine entity + * (src/Entity/DemoNote.php). + */ + private function installDatabase(): bool + { + return Db::getInstance()->execute(' + CREATE TABLE IF NOT EXISTS `' . _DB_PREFIX_ . 'demo_note` ( + `id_demo_note` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `note` VARCHAR(255) NOT NULL, + `date_add` DATETIME NOT NULL, + PRIMARY KEY (`id_demo_note`) + ) ENGINE=' . _MYSQL_ENGINE_ . ' DEFAULT CHARSET=utf8mb4; + '); + } + + private function uninstallDatabase(): bool + { + return Db::getInstance()->execute('DROP TABLE IF EXISTS `' . _DB_PREFIX_ . 'demo_note`'); + } +} diff --git a/demoentityimporter/sample/demo_notes.csv b/demoentityimporter/sample/demo_notes.csv new file mode 100644 index 0000000..1dfab1e --- /dev/null +++ b/demoentityimporter/sample/demo_notes.csv @@ -0,0 +1,3 @@ +id;note +;First imported note +;Second imported note diff --git a/demoentityimporter/src/Entity/DemoNote.php b/demoentityimporter/src/Entity/DemoNote.php new file mode 100644 index 0000000..00066e1 --- /dev/null +++ b/demoentityimporter/src/Entity/DemoNote.php @@ -0,0 +1,90 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +declare(strict_types=1); + +namespace PrestaShop\Module\DemoEntityImporter\Entity; + +use DateTime; +use Doctrine\ORM\Mapping as ORM; + +/** + * The entity the demo importer persists. Module entities living in + * src/Entity are mapped automatically by the core (annotation driver, + * active modules only) — no extra Doctrine configuration is needed. + * + * @ORM\Table() + * @ORM\Entity() + */ +class DemoNote +{ + /** + * @var int + * + * @ORM\Id + * + * @ORM\Column(name="id_demo_note", type="integer") + * + * @ORM\GeneratedValue(strategy="AUTO") + */ + private $id; + + /** + * @var string + * + * @ORM\Column(name="note", type="string", length=255) + */ + private $note; + + /** + * @var DateTime + * + * @ORM\Column(name="date_add", type="datetime") + */ + private $dateAdd; + + public function __construct(string $note) + { + $this->note = $note; + $this->dateAdd = new DateTime(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getNote(): string + { + return $this->note; + } + + public function setNote(string $note): self + { + $this->note = $note; + + return $this; + } + + public function getDateAdd(): DateTime + { + return $this->dateAdd; + } +} diff --git a/demoentityimporter/src/Importer/DemoNoteImporter.php b/demoentityimporter/src/Importer/DemoNoteImporter.php new file mode 100644 index 0000000..cb6512e --- /dev/null +++ b/demoentityimporter/src/Importer/DemoNoteImporter.php @@ -0,0 +1,163 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +declare(strict_types=1); + +namespace PrestaShop\Module\DemoEntityImporter\Importer; + +use Doctrine\ORM\EntityManagerInterface; +use PrestaShop\Module\DemoEntityImporter\Entity\DemoNote; +use PrestaShop\PrestaShop\Core\Import\Engine\EntityImporter\AbstractEntityImporter; +use PrestaShop\PrestaShop\Core\Import\Engine\EntityImporter\RowMapper; +use PrestaShop\PrestaShop\Core\Import\Engine\ImportMessage; +use PrestaShop\PrestaShop\Core\Import\Engine\ImportPhaseDefinition; +use PrestaShop\PrestaShop\Core\Import\Engine\ImportRunContext; +use PrestaShop\PrestaShop\Core\Import\Engine\PhaseBatchResult; +use PrestaShop\PrestaShop\Core\Import\EntityField\EntityField; +use PrestaShop\PrestaShop\Core\Import\EntityField\EntityFieldCollection; +use PrestaShop\PrestaShop\Core\Import\EntityField\EntityFieldCollectionInterface; +use PrestaShop\PrestaShop\Core\Import\File\ResumableFileReaderInterface; +use Throwable; + +/** + * A complete module importer: it validates the file in a pausing + * 'validation' phase, then persists one DemoNote Doctrine entity per row in + * the 'database' phase (create, or update when an id column matches an + * existing note). + * + * Because the service is autoconfigured (see config/services.yml), the core + * automatically tags it and it appears in the EntityImporterRegistry next to + * the core importers: the import page entity dropdown, the mapping screen + * and the batch execution all pick it up with no extra wiring. + * + * The heavy lifting comes from the core: + * - extending AbstractEntityImporter provides the cursor-resumable batch + * loop (iterateBatch), the phase-id guard and the default unit count; + * - ResumableFileReaderInterface and RowMapper are core services injected + * as-is. + * + * See PrestaShop\PrestaShop\Core\Import\Engine\EntityImporter\ProductImporter + * in the core for the full-scale reference implementation. + */ +class DemoNoteImporter extends AbstractEntityImporter +{ + public const ENTITY_TYPE = 'demo_note'; + + protected const NOTE_MAX_LENGTH = 255; + + public function __construct( + ResumableFileReaderInterface $fileReader, + RowMapper $rowMapper, + protected readonly EntityManagerInterface $entityManager, + ) { + parent::__construct($fileReader, $rowMapper); + } + + public function getEntityType(): string + { + return self::ENTITY_TYPE; + } + + public function getLabel(): string + { + return 'Demo notes'; + } + + public function getFields(): EntityFieldCollectionInterface + { + return EntityFieldCollection::createFromArray([ + new EntityField('id', 'ID'), + new EntityField('note', 'Note', '', true), + ]); + } + + public function getPhases(): array + { + return [ + new ImportPhaseDefinition(ImportPhaseDefinition::PHASE_VALIDATION, 'Validating notes', true), + new ImportPhaseDefinition(ImportPhaseDefinition::PHASE_DATABASE, 'Importing notes'), + ]; + } + + public function processPhaseBatch(string $phaseId, ImportRunContext $context, int $limit): PhaseBatchResult + { + $this->assertKnownPhase($phaseId); + + if (ImportPhaseDefinition::PHASE_VALIDATION === $phaseId) { + return $this->iterateBatch($context, $limit, function (array $row, int $rowIndex): array { + $messages = []; + $note = $row['note'] ?? ''; + if ('' === $note) { + $messages[] = $this->message(ImportMessage::SEVERITY_ERROR, ImportPhaseDefinition::PHASE_VALIDATION, 'The note text is required.', $rowIndex); + } elseif (mb_strlen($note) > self::NOTE_MAX_LENGTH) { + $messages[] = $this->message(ImportMessage::SEVERITY_ERROR, ImportPhaseDefinition::PHASE_VALIDATION, sprintf('The note exceeds %d characters.', self::NOTE_MAX_LENGTH), $rowIndex); + } + + // an error marks the row as skipped for the later phases + return ['messages' => $messages, 'skipped' => $this->containsError($messages)]; + }); + } + + return $this->iterateBatch($context, $limit, function (array $row, int $rowIndex) use ($context): array { + if ($context->isRowSkipped($rowIndex)) { + return ['messages' => [], 'skipped' => false]; + } + + try { + $this->importRow($row); + } catch (Throwable $e) { + // a failing row must fail THIS ROW only, never the batch + return [ + 'messages' => [$this->message(ImportMessage::SEVERITY_ERROR, ImportPhaseDefinition::PHASE_DATABASE, sprintf('The note could not be saved: %s', $e->getMessage()), $rowIndex)], + 'skipped' => true, + ]; + } + + return ['messages' => [], 'skipped' => false]; + }); + } + + /** + * @param array $row mapped row values + */ + protected function importRow(array $row): void + { + $note = null; + $id = $row['id'] ?? ''; + if (ctype_digit($id)) { + $note = $this->entityManager->find(DemoNote::class, (int) $id); + } + + if (null !== $note) { + $note->setNote($row['note']); + } else { + $note = new DemoNote($row['note']); + $this->entityManager->persist($note); + } + + $this->entityManager->flush(); + } + + protected function message(string $severity, string $phase, string $text, int $rowIndex): ImportMessage + { + return new ImportMessage($severity, $phase, $text, $rowIndex, 'note'); + } + +}