Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions doc/06_Extending/02_Events.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ Listening for events customizes import behaviour without replacing any component

| Event | Fired |
|---|---|
| `PreInterpretFileEvent` | Before the interpreter starts reading the source file. |
| `PreQueueRowEvent` | For every extracted row, before it is added to the processing queue. |
| `DataObject\PreSaveEvent` | Before an imported data object is saved. |
| `DataObject\PostSaveEvent` | After an imported data object is saved. |
| `DataObject\ProcessElementExceptionEvent` | When processing a record throws an exception. |
Expand All @@ -23,6 +25,87 @@ that failed, when the failure can be attributed to one.

`PostPreparationEvent` exposes the configuration name, the execution type, and whether the source file was interpreted.

## Interpretation-Stage Events

The two interpretation-stage events customize how the source file turns into import rows without writing a custom
interpreter. Both expose the configuration name and the execution type, and both are also dispatched (with
`isPreview()` returning `true`) when Pimcore Studio renders the source preview and the available mapping columns, so
the configuration UI shows exactly the data an actual import would produce. The preview dispatches `PreQueueRowEvent`
only for the record being displayed - preceding records are not replayed. Listeners that carry state across rows (for
example a group marker taken from an earlier row) should check `isPreview()` and fall back to stateless behavior, as
the carried state is not available in preview mode.

### PreInterpretFileEvent

Dispatched before the interpreter validates and reads the source file. `setPath()` replaces the file that gets
interpreted - use it to normalize a file (transcode it, strip a report preamble, rewrite delimiters) while keeping the
standard interpreter. It also marks the start of an interpretation run, which stateful `PreQueueRowEvent` listeners can
use as a reset signal.

### PreQueueRowEvent

Dispatched for every row the interpreter extracted, right before the row is added to the processing queue. The listener
receives the row exactly as the interpreter produced it - before the delta check, the resolver's identifier extraction,
and the mapping pipeline - so changed values (including the ID column) affect which element a row resolves to.

| Method | Purpose |
|---|---|
| `getOriginalRow()` | The row as extracted, unaffected by other listeners. |
| `getRows()` / `setRows(array $rows)` | The rows that will be queued. Set one row to modify it, an empty array to skip it, or multiple rows to fan the source row out into multiple elements. |
| `skipRow(bool $keepInCleanupIdentifierCache = false)` | Skip the row. See the cleanup warning below. |

```php
namespace App\EventListener;

use Pimcore\Bundle\DataImporterBundle\Event\PreQueueRowEvent;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;

#[AsEventListener]
final class ProductRowListener
{
public function __invoke(PreQueueRowEvent $event): void
{
if ($event->getConfigName() !== 'my-product-import') {
return;
}

$row = $event->getOriginalRow();

// skip discontinued products, but keep their existing objects
if (($row['status'] ?? '') === 'discontinued') {
$event->skipRow(keepInCleanupIdentifierCache: true);

return;
}

// add a computed column that mapping, resolver and location strategies can use
$row['path'] = '/products/' . $row['category'] . '/' . $row['sku'];

// fan out: one source row per configured sales channel becomes one element each
$rows = [];
foreach (explode(',', $row['channels']) as $channel) {
$rows[] = ['channel' => $channel] + $row;
}

$event->setRows($rows);
}
}
```

:::warning

When the import uses an active cleanup strategy, every element whose identifier is not seen during interpretation is
deleted or unpublished. A skipped row's element counts as "not seen". Skip rows with
`skipRow(keepInCleanupIdentifierCache: true)` when their existing elements must survive the cleanup.

:::

Custom interpreters that extend `AbstractInterpreter` get both events automatically: the interpreter compiler pass
wires the event dispatcher into every tagged interpreter service that provides a `setEventDispatcher()` method, and
the base class dispatches the events in `interpretFile()` and `processImportRow()`. An interpreter that implements
`InterpreterInterface` directly still receives the dispatcher through that setter, but must dispatch
`PreInterpretFileEvent` and `PreQueueRowEvent` itself from its own reading loop.

## Example

Adjust a data object right before it is saved:
Expand Down
47 changes: 47 additions & 0 deletions src/DataSource/Interpreter/AbstractInterpreter.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
use Pimcore\Bundle\ApplicationLoggerBundle\ApplicationLogger;
use Pimcore\Bundle\ApplicationLoggerBundle\FileObject;
use Pimcore\Bundle\DataImporterBundle\DataSource\Interpreter\DeltaChecker\DeltaChecker;
use Pimcore\Bundle\DataImporterBundle\Event\PreInterpretFileEvent;
use Pimcore\Bundle\DataImporterBundle\Event\PreQueueRowEvent;
use Pimcore\Bundle\DataImporterBundle\Exception\InvalidInputException;
use Pimcore\Bundle\DataImporterBundle\PimcoreDataImporterBundle;
use Pimcore\Bundle\DataImporterBundle\Processing\ImportProcessingService;
Expand All @@ -23,6 +25,7 @@
use Pimcore\Model\Tool\TmpStore;
use Pimcore\Tool\Admin;
use Psr\Log\LoggerAwareTrait;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;

/**
* @internal
Expand All @@ -45,6 +48,8 @@ abstract class AbstractInterpreter implements InterpreterInterface

protected Resolver $resolver;

protected ?EventDispatcherInterface $eventDispatcher = null;

/**
* @var string[]
*/
Expand Down Expand Up @@ -128,11 +133,22 @@ public function setResolver(Resolver $resolver): void
$this->resolver = $resolver;
}

public function setEventDispatcher(?EventDispatcherInterface $eventDispatcher): void
{
$this->eventDispatcher = $eventDispatcher;
}

public function interpretFile(string $path): bool
{
$success = false;
$this->resetIdentifierCache();

if ($this->eventDispatcher !== null) {
$event = new PreInterpretFileEvent($this->configName, $this->executionType, $path);
$this->eventDispatcher->dispatch($event);
$path = $event->getPath();
}

if ($this->fileValid($path)) {
$archiveLogMessage = 'Interpreted source file and created queue items.';
$this->doInterpretFileAndCallProcessRow($path);
Expand Down Expand Up @@ -161,6 +177,37 @@ public function interpretFile(string $path): bool
abstract protected function doInterpretFileAndCallProcessRow(string $path): void;

protected function processImportRow(array $data)
{
foreach ($this->applyPreQueueRowEvent($data) as $row) {
$this->addRowToQueue($row);
}
}

/**
* Lets PreQueueRowEvent listeners modify, skip, or fan out the row the interpreter
* extracted. Without a dispatcher or listeners the row passes through unchanged.
*
* @return array<int, array> the rows to queue
*/
private function applyPreQueueRowEvent(array $data): array
{
if ($this->eventDispatcher === null) {
return [$data];
}

$event = new PreQueueRowEvent($this->configName, $this->executionType, $data);
$this->eventDispatcher->dispatch($event);

if ($event->isRowSkipped() && $event->shouldKeepSkippedRowInIdentifierCache()) {
// Register the skipped row's identifier so an active cleanup strategy does not
// treat its existing element as removed from the source.
$this->addToIdentifierCache($event->getOriginalRow());
Comment thread
alexbaat marked this conversation as resolved.
}

return $event->getRows();
}

private function addRowToQueue(array $data): void
{
$this->assertValidRowEncoding($data);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,16 @@ public function process(ContainerBuilder $container): void
foreach ($tags as $attributes) {
$interpreters[$attributes['type']] = new Reference($id);
}

// Wire the event dispatcher into every interpreter that supports it (built-in
// and custom alike), so PreInterpretFileEvent/PreQueueRowEvent are dispatched.
$definition = $container->getDefinition($id);
$class = $definition->getClass() ?? $id;
if (method_exists($class, 'setEventDispatcher')
&& !$definition->hasMethodCall('setEventDispatcher')
) {
$definition->addMethodCall('setEventDispatcher', [new Reference('event_dispatcher')]);
}
}
}

Expand Down
63 changes: 63 additions & 0 deletions src/Event/PreInterpretFileEvent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);

/**
* This source file is available under the terms of the
* Pimcore Open Core License (POCL)
* Full copyright and license information is available in
* LICENSE.md which is distributed with this source code.
*
* @copyright Copyright (c) Pimcore GmbH (https://www.pimcore.com)
* @license Pimcore Open Core License (POCL)
*/

namespace Pimcore\Bundle\DataImporterBundle\Event;

use Symfony\Contracts\EventDispatcher\Event;

/**
* Dispatched before an interpreter starts reading the source file, both for real imports
* and for the Studio preview. Listeners may replace the file path, e.g. to normalize the
* file (transcode, strip a report preamble, rewrite delimiters) without replacing the
* interpreter. Stateful PreQueueRowEvent listeners can also use it as a per-run reset signal.
*/
final class PreInterpretFileEvent extends Event
{
public function __construct(
private readonly string $configName,
private readonly string $executionType,
private string $path,
private readonly bool $preview = false,
) {
}

public function getConfigName(): string
{
return $this->configName;
}

public function getExecutionType(): string
{
return $this->executionType;
}

public function getPath(): string
{
return $this->path;
}

public function setPath(string $path): self
{
$this->path = $path;

return $this;
}

/**
* True when the file is being read for the Studio preview instead of an actual import.
*/
public function isPreview(): bool
{
return $this->preview;
}
}
126 changes: 126 additions & 0 deletions src/Event/PreQueueRowEvent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
<?php
declare(strict_types=1);

/**
* This source file is available under the terms of the
* Pimcore Open Core License (POCL)
* Full copyright and license information is available in
* LICENSE.md which is distributed with this source code.
*
* @copyright Copyright (c) Pimcore GmbH (https://www.pimcore.com)
* @license Pimcore Open Core License (POCL)
*/

namespace Pimcore\Bundle\DataImporterBundle\Event;

use Symfony\Contracts\EventDispatcher\Event;

/**
* Dispatched for every row an interpreter extracted from the source file, right before the
* row is added to the processing queue. Listeners can modify the row, skip it, or fan it
* out into multiple rows (each queued and imported as its own element):
*
* - modify: $event->setRows([$changedRow]);
* - skip: $event->skipRow(); or $event->setRows([]);
* - fan-out: $event->setRows([$rowA, $rowB, $rowC]);
*
* When an import uses an active cleanup strategy, every element whose identifier is not seen
* during interpretation gets deleted or unpublished. Skipping a row therefore makes the
* cleanup treat the row's existing element as removed from the source. Use
* $event->skipRow(keepInCleanupIdentifierCache: true) to skip the row but still register its
* identifier, so the existing element is left untouched.
*
* The same event is dispatched (with isPreview() returning true) when the Studio preview
* renders the source columns, so columns added by listeners are visible and mappable in the
* configuration UI. Rows skipped in preview mode are still displayed unmodified.
*/
final class PreQueueRowEvent extends Event
{
/**
* @var array<int, array>
*/
private array $rows;

private bool $keepSkippedRowInIdentifierCache = false;

public function __construct(
private readonly string $configName,
private readonly string $executionType,
private readonly array $originalRow,
private readonly bool $preview = false,
) {
$this->rows = [$originalRow];
}

public function getConfigName(): string
{
return $this->configName;
}

public function getExecutionType(): string
{
return $this->executionType;
}

/**
* The row as the interpreter extracted it, unaffected by any listener.
*/
public function getOriginalRow(): array
{
return $this->originalRow;
}

/**
* The rows that will be queued. Initially exactly the original row.
*
* @return array<int, array>
*/
public function getRows(): array
{
return $this->rows;
}

/**
* Replace the rows to queue: one row to modify, an empty array to skip,
* multiple rows to fan the source row out into multiple elements.
*
* @param array<int, array> $rows
*/
public function setRows(array $rows): self
{
$this->rows = array_values($rows);

return $this;
}

/**
* Skip this row entirely. With $keepInCleanupIdentifierCache set to true the original
* row's identifier is still registered, so an active cleanup strategy does not treat the
* row's existing element as removed from the source.
*/
public function skipRow(bool $keepInCleanupIdentifierCache = false): self
{
$this->rows = [];
$this->keepSkippedRowInIdentifierCache = $keepInCleanupIdentifierCache;

return $this;
}

public function isRowSkipped(): bool
{
return $this->rows === [];
}

public function shouldKeepSkippedRowInIdentifierCache(): bool
{
return $this->keepSkippedRowInIdentifierCache;
}

/**
* True when the row is being read for the Studio preview instead of an actual import.
*/
public function isPreview(): bool
{
return $this->preview;
}
}
Loading
Loading