clean install

This commit is contained in:
2024-12-10 15:08:16 +01:00
commit e14eb2d8fd
31193 changed files with 3555714 additions and 0 deletions

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types = 1);
namespace Drupal\migrate_plus\Annotation;
use Drupal\Component\Annotation\Plugin;
/**
* Defines an authentication annotation object.
*
* Plugin namespace: Plugin\migrate_plus\authentication.
*
* @see \Drupal\migrate_plus\AuthenticationPluginBase
* @see \Drupal\migrate_plus\AuthenticationPluginInterface
* @see \Drupal\migrate_plus\AuthenticationPluginManager
* @see plugin_api
*
* @Annotation
*/
class Authentication extends Plugin {
/**
* The plugin ID.
*/
public string $id;
/**
* The title of the plugin.
*/
public string $title;
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types = 1);
namespace Drupal\migrate_plus\Annotation;
use Drupal\Component\Annotation\Plugin;
/**
* Defines a data fetcher annotation object.
*
* Plugin namespace: Plugin\migrate_plus\data_fetcher.
*
* @see \Drupal\migrate_plus\DataFetcherPluginBase
* @see \Drupal\migrate_plus\DataFetcherPluginInterface
* @see \Drupal\migrate_plus\DataFetcherPluginManager
* @see plugin_api
*
* @Annotation
*/
class DataFetcher extends Plugin {
/**
* The plugin ID.
*/
public string $id;
/**
* The title of the plugin.
*/
public string $title;
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types = 1);
namespace Drupal\migrate_plus\Annotation;
use Drupal\Component\Annotation\Plugin;
/**
* Defines a data parser annotation object.
*
* Plugin namespace: Plugin\migrate_plus\data_parser.
*
* @see \Drupal\migrate_plus\DataParserPluginBase
* @see \Drupal\migrate_plus\DataParserPluginInterface
* @see \Drupal\migrate_plus\DataParserPluginManager
* @see plugin_api
*
* @Annotation
*/
class DataParser extends Plugin {
/**
* The plugin ID.
*
* @var string
*/
public string $id;
/**
* The title of the plugin.
*/
public string $title;
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types = 1);
namespace Drupal\migrate_plus;
use Drupal\Core\Plugin\PluginBase;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Defines a base authentication implementation.
*
* @see \Drupal\migrate_plus\Annotation\Authentication
* @see \Drupal\migrate_plus\AuthenticationPluginInterface
* @see \Drupal\migrate_plus\AuthenticationPluginManager
* @see plugin_api
*/
abstract class AuthenticationPluginBase extends PluginBase implements AuthenticationPluginInterface {
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): self {
return new static($configuration, $plugin_id, $plugin_definition);
}
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types = 1);
namespace Drupal\migrate_plus;
/**
* Defines an interface for authenticaion handlers.
*
* @see \Drupal\migrate_plus\Annotation\Authentication
* @see \Drupal\migrate_plus\AuthenticationPluginBase
* @see \Drupal\migrate_plus\AuthenticationPluginManager
* @see plugin_api
*/
interface AuthenticationPluginInterface {
/**
* Performs authentication, returning any options to be added to the request.
*
* @return array
* Options (such as Authentication headers) to be added to the request.
*
* @link http://docs.guzzlephp.org/en/latest/request-options.html
*/
public function getAuthenticationOptions(): array;
}

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types = 1);
namespace Drupal\migrate_plus;
use Drupal\migrate_plus\Annotation\Authentication;
use Drupal\Core\Cache\CacheBackendInterface;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\Plugin\DefaultPluginManager;
/**
* Provides a plugin manager for authentication handlers.
*
* @see \Drupal\migrate_plus\Annotation\DataFetcher
* @see \Drupal\migrate_plus\DataFetcherPluginBase
* @see \Drupal\migrate_plus\DataFetcherPluginInterface
* @see plugin_api
*/
class AuthenticationPluginManager extends DefaultPluginManager {
/**
* Constructs a new AuthenticationPluginManager.
*
* @param \Traversable $namespaces
* An object that implements \Traversable which contains the root paths
* keyed by the corresponding namespace to look for plugin implementations.
* @param \Drupal\Core\Cache\CacheBackendInterface $cache_backend
* Cache backend instance to use.
* @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
* The module handler to invoke the alter hook with.
*/
public function __construct(\Traversable $namespaces, CacheBackendInterface $cache_backend, ModuleHandlerInterface $module_handler) {
parent::__construct('Plugin/migrate_plus/authentication', $namespaces, $module_handler, AuthenticationPluginInterface::class, Authentication::class);
$this->alterInfo('authentication_info');
$this->setCacheBackend($cache_backend, 'migrate_plus_plugins_authentication');
}
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types = 1);
namespace Drupal\migrate_plus;
use Drupal\Core\Plugin\PluginBase;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Defines a base data fetcher implementation.
*
* @see \Drupal\migrate_plus\Annotation\DataFetcher
* @see \Drupal\migrate_plus\DataFetcherPluginInterface
* @see \Drupal\migrate_plus\DataFetcherPluginManager
* @see plugin_api
*/
abstract class DataFetcherPluginBase extends PluginBase implements DataFetcherPluginInterface {
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): self {
return new static($configuration, $plugin_id, $plugin_definition);
}
/**
* {@inheritdoc}
*/
public function getNextUrls(string $url): array {
return [];
}
}

View File

@@ -0,0 +1,67 @@
<?php
declare(strict_types = 1);
namespace Drupal\migrate_plus;
use Psr\Http\Message\ResponseInterface;
/**
* Defines an interface for data fetchers.
*
* @see \Drupal\migrate_plus\Annotation\DataFetcher
* @see \Drupal\migrate_plus\DataFetchPluginBase
* @see \Drupal\migrate_plus\DataFetcherPluginManager
* @see plugin_api
*/
interface DataFetcherPluginInterface {
/**
* Set the client headers.
*
* @param array $headers
* An array of the headers to set on the HTTP request.
*/
public function setRequestHeaders(array $headers): void;
/**
* Get the currently set request headers.
*/
public function getRequestHeaders(): array;
/**
* Return content.
*
* @param string $url
* URL to retrieve from.
*
* @return string
* Content at the given url.
*/
public function getResponseContent(string $url): string;
/**
* Return Http Response object for a given url.
*
* @param string $url
* URL to retrieve from.
*
* @return \Psr\Http\Message\ResponseInterface
* The HTTP response message.
*/
public function getResponse(string $url): ResponseInterface;
/**
* Collect next urls from the metadata of a paged response.
*
* Examples of this include HTTP headers and file naming conventions.
*
* @param string $url
* URL of the resource to check for pager metadata.
*
* @return array
* Array of URIs.
*/
public function getNextUrls(string $url): array;
}

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types = 1);
namespace Drupal\migrate_plus;
use Drupal\migrate_plus\Annotation\DataFetcher;
use Drupal\Core\Cache\CacheBackendInterface;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\Plugin\DefaultPluginManager;
/**
* Provides a plugin manager for data fetchers.
*
* @see \Drupal\migrate_plus\Annotation\DataFetcher
* @see \Drupal\migrate_plus\DataFetcherPluginBase
* @see \Drupal\migrate_plus\DataFetcherPluginInterface
* @see plugin_api
*/
class DataFetcherPluginManager extends DefaultPluginManager {
/**
* Constructs a new DataFetcherPluginManager.
*
* @param \Traversable $namespaces
* An object that implements \Traversable which contains the root paths
* keyed by the corresponding namespace to look for plugin implementations.
* @param \Drupal\Core\Cache\CacheBackendInterface $cache_backend
* Cache backend instance to use.
* @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
* The module handler to invoke the alter hook with.
*/
public function __construct(\Traversable $namespaces, CacheBackendInterface $cache_backend, ModuleHandlerInterface $module_handler) {
parent::__construct('Plugin/migrate_plus/data_fetcher', $namespaces, $module_handler, DataFetcherPluginInterface::class, DataFetcher::class);
$this->alterInfo('data_fetcher_info');
$this->setCacheBackend($cache_backend, 'migrate_plus_plugins_data_fetcher');
}
}

View File

@@ -0,0 +1,248 @@
<?php
declare(strict_types = 1);
namespace Drupal\migrate_plus;
use Drupal\Core\Plugin\PluginBase;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Defines a base data parser implementation.
*
* @see \Drupal\migrate_plus\Annotation\DataParser
* @see \Drupal\migrate_plus\DataParserPluginInterface
* @see \Drupal\migrate_plus\DataParserPluginManager
* @see plugin_api
*/
abstract class DataParserPluginBase extends PluginBase implements DataParserPluginInterface {
/**
* List of source urls.
*
* @var string[]
*/
protected ?array $urls;
/**
* Index of the currently-open url.
*/
protected ?int $activeUrl = NULL;
/**
* String indicating how to select an item's data from the source.
*
* @var string|int
*/
protected $itemSelector;
/**
* Current item when iterating.
*
* @var mixed
*/
protected $currentItem = NULL;
/**
* Value of the ID for the current item when iterating.
*/
protected ?array $currentId = NULL;
/**
* The data retrieval client.
*/
protected DataFetcherPluginInterface $dataFetcher;
/**
* {@inheritdoc}
*/
public function __construct(array $configuration, $plugin_id, $plugin_definition) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
$this->urls = $configuration['urls'];
$this->itemSelector = $configuration['item_selector'] ?? '';
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): self {
return new static($configuration, $plugin_id, $plugin_definition);
}
/**
* Returns the initialized data fetcher plugin.
*/
public function getDataFetcherPlugin(): DataFetcherPluginInterface {
if (!isset($this->dataFetcher)) {
$this->dataFetcher = \Drupal::service('plugin.manager.migrate_plus.data_fetcher')->createInstance($this->configuration['data_fetcher_plugin'], $this->configuration);
}
return $this->dataFetcher;
}
/**
* {@inheritdoc}
*/
public function rewind(): void {
$this->activeUrl = NULL;
$this->next();
}
/**
* {@inheritdoc}
*/
public function next(): void {
$this->currentItem = $this->currentId = NULL;
if (is_null($this->activeUrl)) {
if (!$this->nextSource()) {
// No data to import.
return;
}
}
// At this point, we have a valid open source url, try to fetch a row from
// it.
$this->fetchNextRow();
// If there was no valid row there, try the next url (if any).
if (is_null($this->currentItem)) {
while ($this->nextSource()) {
$this->fetchNextRow();
if ($this->valid()) {
break;
}
}
}
if ($this->valid()) {
foreach ($this->configuration['ids'] as $id_field_name => $id_info) {
$this->currentId[$id_field_name] = $this->currentItem[$id_field_name];
}
}
}
/**
* Opens the specified URL.
*
* @param string $url
* URL to open.
*/
abstract protected function openSourceUrl(string $url): bool;
/**
* Retrieves the next row of data. populating currentItem.
*/
abstract protected function fetchNextRow(): void;
/**
* Advances the data parser to the next source url.
*/
protected function nextSource(): bool {
if (empty($this->urls)) {
return FALSE;
}
while ($this->activeUrl === NULL || (count($this->urls) - 1) > $this->activeUrl) {
if (is_null($this->activeUrl)) {
$this->activeUrl = 0;
}
else {
// Increment the activeUrl so we try to load the next source.
++$this->activeUrl;
if ($this->activeUrl >= count($this->urls)) {
return FALSE;
}
}
if ($this->openSourceUrl($this->urls[$this->activeUrl])) {
if (!empty($this->configuration['pager'])) {
$this->addNextUrls($this->activeUrl);
}
// We have a valid source.
return TRUE;
}
}
return FALSE;
}
/**
* Add next page of source data following the active URL.
*
* @param int $activeUrl
* The index within the source URL array to insert the next URL resource.
* This is parameterized to enable custom plugins to control the ordering of
* next URLs injected into the source URL backlog.
*/
protected function addNextUrls(int $activeUrl = 0): void {
$next_urls = $this->getNextUrls($this->urls[$this->activeUrl]);
if (!empty($next_urls)) {
array_splice($this->urls, $activeUrl + 1, 0, $next_urls);
$this->urls = array_values(array_unique($this->urls));
}
}
/**
* Collected the next urls from a paged response.
*
* @param string $url
* URL of the currently active source.
*
* @return array
* Array of URLs representing next paged resources.
*/
protected function getNextUrls(string $url): array {
return $this->getDataFetcherPlugin()->getNextUrls($url);
}
/**
* {@inheritdoc}
*/
public function current(): mixed {
return $this->currentItem;
}
/**
* {@inheritdoc}
*/
public function currentUrl(): ?string {
$index = $this->activeUrl ?: \array_key_first($this->urls);
return $this->urls[$index] ?? NULL;
}
/**
* {@inheritdoc}
*/
public function key(): ?array {
return $this->currentId;
}
/**
* {@inheritdoc}
*/
public function valid(): bool {
return !empty($this->currentItem);
}
/**
* {@inheritdoc}
*/
public function count(): int {
return iterator_count($this);
}
/**
* Return the selectors used to populate each configured field.
*
* @return string[]
* Array of selectors, keyed by field name.
*/
protected function fieldSelectors(): array {
$fields = [];
foreach ($this->configuration['fields'] as $field_info) {
if (isset($field_info['selector'])) {
$fields[$field_info['name']] = $field_info['selector'];
}
}
return $fields;
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types = 1);
namespace Drupal\migrate_plus;
/**
* Defines an interface for data parsers.
*
* @see \Drupal\migrate_plus\Annotation\DataParser
* @see \Drupal\migrate_plus\DataParserPluginBase
* @see \Drupal\migrate_plus\DataParserPluginManager
* @see plugin_api
*/
interface DataParserPluginInterface extends \Iterator, \Countable {
/**
* Returns current source URL.
*
* @return string|null
* The URL currently parsed on success, otherwise NULL.
*/
public function currentUrl(): ?string;
}

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types = 1);
namespace Drupal\migrate_plus;
use Drupal\migrate_plus\Annotation\DataParser;
use Drupal\Core\Cache\CacheBackendInterface;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\Plugin\DefaultPluginManager;
/**
* Provides a plugin manager for data parsers.
*
* @see \Drupal\migrate_plus\Annotation\DataParser
* @see \Drupal\migrate_plus\DataParserPluginBase
* @see \Drupal\migrate_plus\DataParserPluginInterface
* @see plugin_api
*/
class DataParserPluginManager extends DefaultPluginManager {
/**
* Constructs a new DataParserPluginManager.
*
* @param \Traversable $namespaces
* An object that implements \Traversable which contains the root paths
* keyed by the corresponding namespace to look for plugin implementations.
* @param \Drupal\Core\Cache\CacheBackendInterface $cache_backend
* Cache backend instance to use.
* @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
* The module handler to invoke the alter hook with.
*/
public function __construct(\Traversable $namespaces, CacheBackendInterface $cache_backend, ModuleHandlerInterface $module_handler) {
parent::__construct('Plugin/migrate_plus/data_parser', $namespaces, $module_handler, DataParserPluginInterface::class, DataParser::class);
$this->alterInfo('data_parser_info');
$this->setCacheBackend($cache_backend, 'migrate_plus_plugins_data_parser');
}
}

View File

@@ -0,0 +1,109 @@
<?php
declare(strict_types = 1);
namespace Drupal\migrate_plus\Entity;
use Drupal\Core\Cache\Cache;
use Drupal\Core\Config\Entity\ConfigEntityBase;
use Drupal\Core\Entity\EntityTypeInterface;
/**
* Defines the Migration entity.
*
* The migration entity stores the information about a single migration, like
* the source, process and destination plugins.
*
* @ConfigEntityType(
* id = "migration",
* label = @Translation("Migration"),
* entity_keys = {
* "id" = "id",
* "label" = "label",
* "weight" = "weight",
* "status" = "status"
* },
* config_export = {
* "id",
* "class",
* "field_plugin_method",
* "cck_plugin_method",
* "migration_tags",
* "migration_group",
* "status",
* "label",
* "source",
* "process",
* "destination",
* "migration_dependencies",
* },
* )
*/
class Migration extends ConfigEntityBase implements MigrationInterface {
/**
* The migration ID (machine name).
*/
protected ?string $id;
/**
* The human-readable label for the migration.
*/
protected ?string $label;
/**
* {@inheritdoc}
*/
protected function invalidateTagsOnSave($update): void {
parent::invalidateTagsOnSave($update);
\Drupal::service('plugin.manager.migration')->clearCachedDefinitions();
// TODO: remove after 10.1 and earlier support sunsets.
Cache::invalidateTags(['migration_plugins']);
}
/**
* {@inheritdoc}
*/
protected static function invalidateTagsOnDelete(EntityTypeInterface $entity_type, array $entities): void {
parent::invalidateTagsOnDelete($entity_type, $entities);
\Drupal::service('plugin.manager.migration')->clearCachedDefinitions();
// TODO: remove after 10.1 and earlier support sunsets.
Cache::invalidateTags(['migration_plugins']);
}
/**
* Create a configuration entity from a core migration plugin's configuration.
*
* Note the list of properties being transplanted from the plugin instance or
* definition into the Migration config entity must remain in sync with the
* keys listed in the "config_export" annotation key of this class.
*
* @param string $plugin_id
* ID of a migration plugin managed by MigrationPluginManager.
* @param string $new_plugin_id
* ID to use for the new configuration entity.
*
* A Migration configuration entity (not saved to persistent storage).
*/
public static function createEntityFromPlugin($plugin_id, $new_plugin_id): self {
$entity_array = [];
$migration_details = [];
/** @var \Drupal\migrate\Plugin\MigrationPluginManagerInterface $plugin_manager */
$plugin_manager = \Drupal::service('plugin.manager.migration');
/** @var \Drupal\migrate\Plugin\Migration $migration_plugin */
$migration_plugin = $plugin_manager->createInstance($plugin_id);
$entity_array['id'] = $new_plugin_id;
$plugin_definition = $migration_plugin->getPluginDefinition();
$migration_details['class'] = $plugin_definition['class'];
$entity_array['migration_tags'] = $migration_plugin->getMigrationTags();
$entity_array['label'] = $migration_plugin->label();
$entity_array['source'] = $migration_plugin->getSourceConfiguration();
$entity_array['destination'] = $migration_plugin->getDestinationConfiguration();
$entity_array['process'] = $migration_plugin->getProcess();
$entity_array['migration_dependencies'] = $migration_plugin->getMigrationDependencies();
return static::create($entity_array);
}
}

View File

@@ -0,0 +1,95 @@
<?php
declare(strict_types = 1);
namespace Drupal\migrate_plus\Entity;
use Drupal\Core\Cache\Cache;
use Drupal\Core\Config\Entity\ConfigEntityBase;
/**
* Defines the Migration Group entity.
*
* The migration group entity is used to group active migrations, as well as to
* store shared migration configuration.
*
* @ConfigEntityType(
* id = "migration_group",
* label = @Translation("Migration Group"),
* module = "migrate_plus",
* handlers = {
* },
* entity_keys = {
* "id" = "id",
* "label" = "label"
* },
* config_export = {
* "id",
* "label",
* "description",
* "source_type",
* "module",
* "shared_configuration",
* },
* )
*/
class MigrationGroup extends ConfigEntityBase implements MigrationGroupInterface {
/**
* The migration group ID (machine name).
*/
protected ?string $id;
/**
* The human-readable label for the migration group.
*/
protected ?string $label;
/**
* {@inheritdoc}
*/
public function delete(): void {
// Delete all migrations contained in this group.
$query = \Drupal::entityQuery('migration')
// Access check false because if the user has access to deleting
// migration groups they should have access to deleting related migration.
->accessCheck(FALSE)
->condition('migration_group', $this->id());
$names = $query->execute();
// Order the migrations according to their dependencies.
/** @var MigrationInterface[] $migrations */
$migrations = \Drupal::entityTypeManager()->getStorage('migration')->loadMultiple($names);
// Delete in reverse order, so dependencies are never violated.
$migrations = array_reverse($migrations);
foreach ($migrations as $migration) {
$migration->delete();
}
// Finally, delete the group itself.
parent::delete();
}
/**
* {@inheritdoc}
*/
public function calculateDependencies(): array {
parent::calculateDependencies();
// Make sure we save any explicit module dependencies.
if ($provider = $this->get('module')) {
$this->addDependency('module', $provider);
}
return $this->dependencies;
}
/**
* {@inheritdoc}
*/
protected function invalidateTagsOnSave($update): void {
parent::invalidateTagsOnSave($update);
Cache::invalidateTags(['migration_plugins']);
}
}

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types = 1);
namespace Drupal\migrate_plus\Entity;
use Drupal\Core\Config\Entity\ConfigEntityInterface;
/**
* Interface for migration groups.
*/
interface MigrationGroupInterface extends ConfigEntityInterface {}

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types = 1);
namespace Drupal\migrate_plus\Entity;
use Drupal\Core\Config\Entity\ConfigEntityInterface;
/**
* Interface for migrations.
*/
interface MigrationInterface extends ConfigEntityInterface {}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types = 1);
namespace Drupal\migrate_plus\Event;
/**
* Defines the row preparation event for the migration system.
*
* @see \Drupal\migrate\Event\MigratePrepareRowEvent
*/
final class MigrateEvents {
/**
* Name of the event fired when preparing a source data row.
*
* This event allows modules to perform an action whenever the source plugin
* has read the inital source data into a Row object. Typically, this would be
* used to add data to the row, manipulate the data into a canonical form, or
* signal by exception that the row should be skipped. The event listener
* method receives a \Drupal\migrate_plus\Event\MigratePrepareRowEvent
* instance.
*
* @Event
*
* @see \Drupal\migrate_plus\Event\MigratePrepareRowEvent
*
* @var string
*/
public const PREPARE_ROW = 'migrate_plus.prepare_row';
/**
* Name of the event fired when a source item is missing.
*
* This event allows modules to perform an action whenever a specific item is
* missing from the source. The event listener method receives a
* \Drupal\migrate\Event\MigrateRowDeleteEvent instance.
*
* @Event
*
* @see \Drupal\migrate\Event\MigrateRowDeleteEvent
*/
public const MISSING_SOURCE_ITEM = 'migrate_plus.missing_source_item';
}

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types = 1);
namespace Drupal\migrate_plus\Event;
use Drupal\migrate\Plugin\MigrateSourceInterface;
use Drupal\migrate\Plugin\MigrationInterface;
use Drupal\migrate\Row;
use Symfony\Contracts\EventDispatcher\Event;
/**
* Wraps a prepare-row event for event listeners.
*/
class MigratePrepareRowEvent extends Event {
protected Row $row;
protected MigrateSourceInterface $source;
protected MigrationInterface $migration;
/**
* Constructs a prepare-row event object.
*
* @param \Drupal\migrate\Row $row
* Row of source data to be analyzed/manipulated.
* @param \Drupal\migrate\Plugin\MigrateSourceInterface $source
* Source plugin that is the source of the event.
* @param \Drupal\migrate\Plugin\MigrationInterface $migration
* Migration entity.
*/
public function __construct(Row $row, MigrateSourceInterface $source, MigrationInterface $migration) {
$this->row = $row;
$this->source = $source;
$this->migration = $migration;
}
/**
* Gets the row object.
*/
public function getRow(): Row {
return $this->row;
}
/**
* Gets the source plugin.
*/
public function getSource(): MigrateSourceInterface {
return $this->source;
}
/**
* Gets the migration plugin.
*/
public function getMigration(): MigrationInterface {
return $this->migration;
}
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types = 1);
namespace Drupal\migrate_plus\Plugin;
use Drupal\Component\Plugin\Derivative\DeriverBase;
use Drupal\migrate_plus\Entity\Migration;
/**
* Expose migration entities in the active config store as derivative plugins.
*/
class MigrationConfigDeriver extends DeriverBase {
/**
* {@inheritdoc}
*/
public function getDerivativeDefinitions($base_plugin_definition): array {
// Always rederive from scratch, because changes may have been made without
// clearing our internal cache.
$this->derivatives = [];
$migrations = Migration::loadMultiple();
/** @var \Drupal\migrate_plus\Entity\MigrationInterface $migration */
foreach ($migrations as $id => $migration) {
if (!$migration->status()) {
continue;
}
$this->derivatives[$id] = $migration->toArray();
}
return $this->derivatives;
}
}

View File

@@ -0,0 +1,296 @@
<?php
declare(strict_types = 1);
namespace Drupal\migrate_plus\Plugin\migrate\destination;
use Drupal\Core\Database\Connection;
use Drupal\Core\Database\Database;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\migrate\Event\ImportAwareInterface;
use Drupal\migrate\Event\MigrateImportEvent;
use Drupal\migrate\MigrateException;
use Drupal\migrate\MigrateSkipProcessException;
use Drupal\migrate\Plugin\migrate\destination\DestinationBase;
use Drupal\migrate\Plugin\MigrationInterface;
use Drupal\migrate\Row;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Provides table destination plugin.
*
* Use this plugin for a table not registered with Drupal Schema API.
*
* Examples:
*
* @code
* destination:
* plugin: table
* # Key for the database connection to use for inserting records.
* database_key: roads_db
* # DB table for storage.
* table_name: roads
* # Maximum number of rows to insert in one query.
* batch_size: 3
* # Fields used by migrate to identify table rows uniquely. At least one
* # field is required.
* id_fields:
* name:
* type: string
* suburb:
* type: string
* ward:
* type: string
* # Mapping of column names to values set in migrate process.
* fields:
* name: name
* owner: owner
* suburb: suburb
* ward: ward
* type: type
* @endcode
*
* For numeric id fields, migrate can generate the values on-the-fly, by
* enabling use_auto_increment; in such case, the id field may be ommitted from
* the 'fields' section:
*
* @code
* destination:
* plugin: table
* # ...
* id_fields:
* my_id_field:
* type: integer
* use_auto_increment: true
* # ...
* fields:
* non_my_id_field_1: non_my_id_field_1
* non_my_id_field_2: non_my_id_field_2
* @endcode
*
* @MigrateDestination(
* id = "table"
* )
*/
class Table extends DestinationBase implements ContainerFactoryPluginInterface, ImportAwareInterface {
/**
* The name of the destination table.
*/
protected string $tableName;
/**
* IDMap compatible array of id fields.
*/
protected array $idFields;
/**
* Array of fields present on the destination table.
*/
protected array $fields;
protected Connection $dbConnection;
/**
* Maximum number of rows to insert in one query.
*/
protected int $batchSize = 1;
/**
* The query object being built row-by-row.
*
* @var array
*/
protected array $rowsToInsert = [];
/**
* The highest ID seen or created so far on this table.
*
* @var int
*/
protected int $lastId = 0;
/**
* Constructs a new Table.
*
* @param array $configuration
* A configuration array containing information about the plugin instance.
* @param string $plugin_id
* The plugin_id for the plugin instance.
* @param mixed $plugin_definition
* The plugin implementation definition.
* @param \Drupal\migrate\Plugin\MigrationInterface $migration
* The migration.
* @param \Drupal\Core\Database\Connection $connection
* The database connection.
*/
public function __construct(array $configuration, $plugin_id, $plugin_definition, MigrationInterface $migration, Connection $connection) {
parent::__construct($configuration, $plugin_id, $plugin_definition, $migration);
$this->dbConnection = $connection;
$this->tableName = $configuration['table_name'];
$this->idFields = $configuration['id_fields'];
$this->fields = $configuration['fields'] ?? [];
$this->batchSize = $configuration['batch_size'] ?? 1;
$this->supportsRollback = TRUE;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition, MigrationInterface $migration = NULL): self {
$db_key = !empty($configuration['database_key']) ? $configuration['database_key'] : NULL;
return new static(
$configuration,
$plugin_id,
$plugin_definition,
$migration,
Database::getConnection('default', $db_key)
);
}
/**
* {@inheritdoc}
*/
public function getIds(): array {
if (empty($this->idFields)) {
throw new MigrateException('Id fields are required for a table destination');
}
return $this->idFields;
}
/**
* {@inheritdoc}
*/
public function fields(MigrationInterface $migration = NULL): array {
return $this->fields;
}
/**
* {@inheritdoc}
*/
public function import(Row $row, array $old_destination_id_values = []) {
// Skip batching (if configured) for updates.
$batch_inserts = ($this->batchSize > 1 && empty($old_destination_id_values));
$ids = [];
foreach ($this->idFields as $field => $fieldInfo) {
if ($row->hasDestinationProperty($field)) {
$ids[$field] = $row->getDestinationProperty($field);
}
elseif (!$row->hasDestinationProperty($field) && empty($fieldInfo['use_auto_increment'])) {
throw new MigrateSkipProcessException('All the id fields are required for a table migration.');
}
// When batching, we do the auto-incrementing ourselves.
elseif ($batch_inserts && $fieldInfo['use_auto_increment']) {
if (count($this->rowsToInsert) === 0) {
// Get the highest existing ID, so we will create IDs above it.
$this->lastId = (int) $this->dbConnection->query("SELECT MAX($field) AS MaxId FROM {{$this->tableName}}")
->fetchField();
}
$id = ++$this->lastId;
$ids[$field] = $id;
$row->setDestinationProperty($field, $id);
}
}
// When batching, make sure we have the same properties in the same order
// every time.
$values = [];
if ($batch_inserts) {
$destination_properties = array_keys($this->migration->getProcess());
$destination_properties = [
...$destination_properties,
...array_keys($this->idFields),
];
sort($destination_properties);
$destination_values = $row->getDestination();
foreach ($destination_properties as $property_name) {
$values[$property_name] = $destination_values[$property_name] ?? NULL;
}
}
else {
$values = $row->getDestination();
}
if ($this->fields) {
$values = array_intersect_key($values, $this->fields);
}
if ($batch_inserts) {
$this->rowsToInsert[] = $values;
if (count($this->rowsToInsert) >= $this->batchSize) {
$this->flushInserts();
}
$status = TRUE;
}
// Row contains empty id field with use_auto_increment enabled.
elseif (count($ids) < count($this->idFields)) {
$status = $id = $this->dbConnection->insert($this->tableName)
->fields($values)
->execute();
foreach ($this->idFields as $field => $fieldInfo) {
if (isset($fieldInfo['use_auto_increment']) && $fieldInfo['use_auto_increment'] === TRUE && !$row->hasDestinationProperty($field)) {
$row->setDestinationProperty($field, $id);
$ids[$field] = $id;
}
}
}
else {
$status = $this->dbConnection->merge($this->tableName)
->keys($ids)
->fields($values)
->execute();
}
return $status ? $ids : FALSE;
}
/**
* {@inheritdoc}
*/
public function rollback(array $destination_identifier): void {
$delete = $this->dbConnection->delete($this->tableName);
foreach ($destination_identifier as $field => $value) {
$delete->condition($field, $value);
}
$delete->execute();
}
/**
* Execute the insert query and reset everything.
*/
public function flushInserts(): void {
if (count($this->rowsToInsert) > 0) {
$batch_query = $this->dbConnection->insert($this->tableName)
->fields(array_keys($this->rowsToInsert[0]));
foreach ($this->rowsToInsert as $row) {
$batch_query->values(array_values($row));
}
// Empty the queue first, so if the statement throws an error we don't
// end up here trying to execute the same statement (plus one row).
$this->rowsToInsert = [];
$batch_query->execute();
}
}
/**
* {@inheritDoc}
*/
public function preImport(MigrateImportEvent $event): void {
}
/**
* {@inheritDoc}
*/
public function postImport(MigrateImportEvent $event): void {
// At the conclusion of a given migration, make sure batched inserts go in.
$this->flushInserts();
}
/**
* Make absolutely sure batched inserts are processed (especially for stubs).
*/
public function __destruct() {
// At the conclusion of a given migration, make sure batched inserts go in.
$this->flushInserts();
}
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types = 1);
namespace Drupal\migrate_plus\Plugin\migrate\process;
use Drupal\migrate\MigrateException;
use Drupal\migrate\MigrateExecutableInterface;
use Drupal\migrate\ProcessPluginBase;
use Drupal\migrate\Row;
/**
* Performs an array_pop() on a source array.
*
* @MigrateProcessPlugin(
* id = "array_pop",
* handle_multiples = TRUE
* )
*
* The "extract" plugin in core can extract array values when indexes are
* already known. This plugin helps extract the last value in an array by
* performing a "pop" operation.
*
* Example: Say, the migration source has an associative array of names in
* a property called "authors" and the keys in the array can vary, you
* can extract the last value like this:
*
* @code
* last_author:
* plugin: array_pop
* source: authors
* @endcode
*/
class ArrayPop extends ProcessPluginBase {
/**
* {@inheritdoc}
*/
public function transform($value, MigrateExecutableInterface $migrate_executable, Row $row, $destination_property) {
if (!is_array($value)) {
throw new MigrateException('Input should be an array.');
}
return array_pop($value);
}
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types = 1);
namespace Drupal\migrate_plus\Plugin\migrate\process;
use Drupal\migrate\MigrateException;
use Drupal\migrate\MigrateExecutableInterface;
use Drupal\migrate\ProcessPluginBase;
use Drupal\migrate\Row;
/**
* Performs an array_shift() on a source array.
*
* @MigrateProcessPlugin(
* id = "array_shift",
* handle_multiples = TRUE
* )
*
* The "extract" plugin in core can extract array values when indexes are
* already known. This plugin helps extract the first value in an array by
* performing a "shift" operation.
*
* Example: Say, the migration source has an associative array of names in
* a property called "authors" and the keys in the array can vary, you
* can extract the first value like this:
*
* @code
* first_author:
* plugin: array_shift
* source: authors
* @endcode
*/
class ArrayShift extends ProcessPluginBase {
/**
* {@inheritdoc}
*/
public function transform($value, MigrateExecutableInterface $migrate_executable, Row $row, $destination_property) {
if (!is_array($value)) {
throw new MigrateException('Input should be an array.');
}
return array_shift($value);
}
}

View File

@@ -0,0 +1,126 @@
<?php
declare(strict_types=1);
namespace Drupal\migrate_plus\Plugin\migrate\process;
use Drupal\Component\Utility\NestedArray;
use Drupal\migrate\MigrateException;
use Drupal\migrate\MigrateExecutableInterface;
use Drupal\migrate\ProcessPluginBase;
use Drupal\migrate\Row;
/**
* Builds an array based on configuration, source, destination, and pipeline.
*
* Usage:
*
* @code
* process:
* bar:
* plugin: array_template
* source: foo
* template:
* key: literal string
* properties:
* - source:field_body/0/value
* - dest:field_body/0/value
* - pipeline:some/nested/key
* @endcode
*
* The result is an array with the same structure (string and numeric keys,
* nesting) as the template. Any string value starting with 'source:' or 'dest:'
* is replaced by the corresponding source or destination property. Do not
* prefix destination properties with '@'. The string value 'pipeline:' is
* replaced with the source, or the previous value from the process pipeline.
* You can also extract keys using the '/' separator.
*
* For example, to convert an indexed array to a keyed array,
*
* @code
* process:
* field_paragraph:
* - plugin: migration_lookup
* # ...
* - plugin: array_template
* template:
* target_id: pipeline:0
* target_revision_id: pipeline:1
* @endcode
*
* If you want a literal string like 'source:foo' in the result, then a
* work-around is to define a constant in the source configuration:
*
* @code
* source:
* # ...
* constants:
* do_not_process_me: source:foo
* process:
* some_field:
* - plugin: array_template
* template:
* - source:constants/do_not_process_me
* @endcode
*
* @MigrateProcessPlugin(id = "array_template")
*/
final class ArrayTemplate extends ProcessPluginBase {
/**
* {@inheritdoc}
*/
public function __construct(array $configuration, $plugin_id, array $plugin_definition) {
if (!is_array($configuration['template'] ?? NULL)) {
throw new \InvalidArgumentException('The "template" must be set to an array.');
}
parent::__construct($configuration, $plugin_id, $plugin_definition);
}
/**
* {@inheritdoc}
*/
public function transform($value, MigrateExecutableInterface $migrate_executable, Row $row, $destination_property): array {
$template = $this->configuration['template'] ?? NULL;
$args = ['row' => $row, 'pipeline' => $value];
array_walk_recursive($template, [$this, 'process'], $args);
return $template;
}
/**
* Replaces source, destination, or pipeline with the correct value.
*
* @param mixed $value
* The array value as provided by arraywalk_recursive(): any type other than
* array. Passed by reference.
* @param string $key
* The array key as provided by arraywalk_recursive(): ignored.
* @param array $args
* An array with the keys
* - row: the current Row object;
* - pipeline: the pipeline or source value for the process.
*/
protected function process(&$value, string $key, array $args): void {
if (!is_string($value)) {
return;
}
[$type, $key] = explode(':', "$value:", 2);
if ($key === '') {
return;
}
// Strip the added ':'.
$key = substr($key, 0, -1);
['row' => $row, 'pipeline' => $pipeline] = $args;
$value = match($type) {
'source' => $row->getSourceProperty($key),
'dest' => $row->getDestinationProperty($key),
'pipeline' => $key === '' ? $pipeline : NestedArray::getValue($pipeline, explode('/', $key)),
default => $value,
};
}
}

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types = 1);
namespace Drupal\migrate_plus\Plugin\migrate\process;
use Drupal\migrate\MigrateExecutableInterface;
use Drupal\migrate\Row;
/**
* Returns EntityLookup for a given default value if input is empty.
*
* Available configuration keys:
* - default_value: The default value that will be used as for the entity lookup.
* For additional configuration keys, refer to the parent class.
*
* Example:
* @code
* process:
* uid:
* -
* plugin: migration_lookup
* migration: users
* source: author
* -
* plugin: default_entity_value
* entity_type: user
* value_key: name
* ignore_case: true
* default_value: editorial
* @endcode
*
* In this example, it will look up the source value of author in the users
* migration and if not found, use entity lookup to find a user with "editorial"
* username.
*
* @see \Drupal\migrate_plus\Plugin\migrate\process\EntityLookup
*
* @MigrateProcessPlugin(
* id = "default_entity_value",
* handle_multiples = TRUE
* )
*/
class DefaultEntityValue extends EntityLookup {
/**
* {@inheritdoc}
*/
public function transform($value, MigrateExecutableInterface $migrate_executable, Row $row, $destination_property) {
if (!empty($value)) {
return $value;
}
return parent::transform($this->configuration['default_value'], $migrate_executable, $row, $destination_property);
}
}

View File

@@ -0,0 +1,254 @@
<?php
declare(strict_types = 1);
namespace Drupal\migrate_plus\Plugin\migrate\process;
use Drupal\Component\Utility\Html;
use Drupal\migrate\MigrateException;
use Drupal\migrate\MigrateExecutableInterface;
use Drupal\migrate\Plugin\MigrationInterface;
use Drupal\migrate\ProcessPluginBase;
use Drupal\migrate\Row;
use Masterminds\HTML5;
/**
* Handles string to DOM and back conversions.
*
* Available configuration keys:
* - method: Action to perform. Possible values:
* - import: string to DomDocument.
* - export: DomDocument to string.
* - non_root: (optional) Assume the passed HTML is not a complete hierarchy,
* but only a subset inside body element. Defaults to true.
*
* The following keys are only used if the method is 'import':
* - log_messages: (optional) When parsing HTML, libxml may trigger
* warnings. If this option is set to true, it will log them as migration
* messages. Otherwise, it will not handle it in a special way. Defaults to
* true.
* - version: (optional) The version number of the document as part of the XML
* declaration. Defaults to '1.0'.
* - encoding: (optional) The encoding of the document as part of the XML
* declaration. Defaults to 'UTF-8'.
* - import_method: (optional) What parser to use. Possible values:
* - 'html': (default) use dom extension parsing.
* - 'html5': use html5 parsing.
* - 'xml': use XML parsing.
*
* @codingStandardsIgnoreStart
*
* Examples:
* @code
* process:
* 'body/value':
* -
* plugin: dom
* method: import
* source: 'body/0/value'
* -
* plugin: dom
* method: export
* @endcode
* This example above will convert the input string to a DOMDocument object and
* back, with no explicit processing. It should have few noticeable effects.
*
* @code
* process:
* 'body/value':
* -
* plugin: dom
* method: import
* source: 'body/0/value'
* non_root: true
* log_messages: true
* version: '1.0'
* encoding: UTF-8
* -
* plugin: dom
* method: export
* non_root: true
* @endcode
* This example above will have the same effect as the previous example, since
* it specifies the default values for all the optional parameters.
*
* @codingStandardsIgnoreEnd
*
* @MigrateProcessPlugin(
* id = "dom"
* )
*/
class Dom extends ProcessPluginBase {
/**
* If parsing warnings should be logged as migrate messages.
*/
protected bool $logMessages = TRUE;
/**
* The HTML contains only the piece inside the body element.
*/
protected bool $nonRoot = TRUE;
/**
* {@inheritdoc}
*/
public function __construct(array $configuration, $plugin_id, $plugin_definition) {
if (!isset($configuration['method'])) {
throw new \InvalidArgumentException('The "method" must be set.');
}
if (!in_array($configuration['method'], ['import', 'export'])) {
throw new \InvalidArgumentException('The "method" must be "import" or "export".');
}
$configuration['import_method'] = $configuration['import_method'] ?? 'html';
if (!in_array($configuration['import_method'], ['html', 'html5', 'xml'])) {
throw new \InvalidArgumentException('The "import_method" must be "html", "html5", or "xml".');
}
parent::__construct($configuration, $plugin_id, $plugin_definition);
$this->configuration += $this->defaultValues();
$this->logMessages = (bool) $this->configuration['log_messages'];
$this->nonRoot = (bool) $this->configuration['non_root'];
}
/**
* Supply default values of all optional parameters.
*
* An array with keys the optional parameters and values the corresponding
* defaults.
*/
protected function defaultValues(): array {
return [
'non_root' => TRUE,
'log_messages' => TRUE,
'version' => '1.0',
'encoding' => 'UTF-8',
];
}
/**
* Converts a HTML string into a DOMDocument.
*
* It is not using \Drupal\Component\Utility\Html::load() because it ignores
* all errors on import, and therefore incompatible with log_messages
* option.
*
* @param mixed $value
* The string to be imported.
* @param \Drupal\migrate\MigrateExecutableInterface $migrate_executable
* The migration in which this process is being executed.
* @param \Drupal\migrate\Row $row
* The row from the source to process. Normally, just transforming the value
* is adequate but very rarely you might need to change two columns at the
* same time or something like that.
* @param string $destination_property
* The destination property currently worked on. This is only used together
* with the $row above.
*
* The document object based on the provided string.
*
* @throws \Drupal\migrate\MigrateException
* When the received $value is not a string.
*/
public function import($value, MigrateExecutableInterface $migrate_executable, Row $row, string $destination_property): \DOMDocument {
if (!is_string($value)) {
throw new MigrateException('Cannot import a non-string value.');
}
if ($this->logMessages) {
set_error_handler(static function ($errno, $errstr) use ($migrate_executable): void {
$migrate_executable->saveMessage($errstr, MigrationInterface::MESSAGE_WARNING);
});
}
if ($this->nonRoot) {
$html = $this->getNonRootHtml($value);
}
else {
$html = $value;
}
$document = new \DOMDocument($this->configuration['version'], $this->configuration['encoding']);
switch ($this->configuration['import_method']) {
case 'html5':
$html5 = new HTML5([
'target_document' => $document,
'disable_html_ns' => TRUE,
]);
$html5->loadHTML($html);
break;
case 'xml':
$document->loadXML($html);
break;
case 'html':
default:
$document->loadHTML($html);
}
if ($this->logMessages) {
restore_error_handler();
}
return $document;
}
/**
* Converts a DOMDocument into a HTML string.
*
* @param mixed $value
* The document to be exported.
* @param \Drupal\migrate\MigrateExecutableInterface $migrate_executable
* The migration in which this process is being executed.
* @param \Drupal\migrate\Row $row
* The row from the source to process. Normally, just transforming the value
* is adequate but very rarely you might need to change two columns at the
* same time or something like that.
* @param string $destination_property
* The destination property currently worked on. This is only used together
* with the $row above.
*
* @return string
* The HTML string corresponding to the provided document object.
*
* @throws \Drupal\migrate\MigrateException
* When the received $value is not a \DOMDocument.
*/
public function export($value, MigrateExecutableInterface $migrate_executable, Row $row, string $destination_property) {
if (!$value instanceof \DOMDocument) {
$value_description = (gettype($value) == 'object') ? get_class($value) : gettype($value);
throw new MigrateException(sprintf('Cannot export a "%s".', $value_description));
}
if ($this->nonRoot) {
return Html::serialize($value);
}
return $value->saveHTML();
}
/**
* Builds an full html string based on a partial.
*
* @param string $partial
* A subset of a full html string. For instance the contents of the body
* element.
*/
protected function getNonRootHtml(string $partial): string {
$replacements = [
"\n" => '',
'!encoding' => strtolower($this->configuration['encoding']),
'!value' => $partial,
];
// Prepend the html with a header using the configured source encoding.
// By default, loadHTML() assumes ISO-8859-1.
$html_template = <<<EOD
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head><meta http-equiv="Content-Type" content="text/html; charset=!encoding" /></head>
<body>!value</body>
</html>
EOD;
return strtr($html_template, $replacements);
}
}

View File

@@ -0,0 +1,205 @@
<?php
declare(strict_types = 1);
namespace Drupal\migrate_plus\Plugin\migrate\process;
use Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException;
use Drupal\Core\Config\ConfigFactory;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\migrate\MigrateExecutableInterface;
use Drupal\migrate\Plugin\MigrationInterface;
use Drupal\migrate\Row;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Apply Editor styles to configured elements.
*
* Replace HTML elements with elements and classes specified in the Styles menu
* of the WYSIWYG editor.
*
* Available configuration keys:
* - format: the text format to inspect for style options (optional,
* defaults to 'basic_html').
* - rules: an array of keyed arrays, with the following keys:
* - xpath: an XPath expression for the elements to replace.
* - style: the label of the item in the Styles menu to use.
* - depth: the number of parent elements to remove (optional, defaults to 0).
*
* Example:
*
* @code
* process:
* 'body/value':
* -
* plugin: dom
* method: import
* source: 'body/0/value'
* -
* plugin: dom_apply_styles
* format: full_html
* rules:
* -
* xpath: '//b'
* style: Bold
* -
* xpath: '//span/i'
* style: Italic
* depth: 1
* -
* plugin: dom
* method: export
* @endcode
*
* This will replace <b>...</b> with whatever style is labeled "Bold" in the
* Full HTML text format, perhaps <strong class="foo">...</strong>.
* It will also replace <span><i>...</i></span> with the style labeled "Italic"
* in that text format, perhaps <em class="foo bar">...</em>.
* You may get unexpected results if there is anything between the two opening
* tags or between the two closing tags. That is, the code assumes that
* '<span><i>' is closed with '</i></span>' exactly.
*
* @MigrateProcessPlugin(
* id = "dom_apply_styles"
* )
*/
class DomApplyStyles extends DomProcessBase implements ContainerFactoryPluginInterface {
protected ConfigFactory $configFactory;
/**
* Styles from the WYSIWYG editor.
*/
protected array $styles = [];
/**
* {@inheritdoc}
*/
public function __construct(array $configuration, $plugin_id, $plugin_definition, ConfigFactory $config_factory) {
$configuration += ['format' => 'basic_html'];
parent::__construct($configuration, $plugin_id, $plugin_definition);
$this->configFactory = $config_factory;
$this->setStyles($configuration['format']);
$this->validateRules();
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition, MigrationInterface $migration = NULL): self {
return new static(
$configuration,
$plugin_id,
$plugin_definition,
$container->get('config.factory')
);
}
/**
* {@inheritdoc}
*/
public function transform($value, MigrateExecutableInterface $migrate_executable, Row $row, $destination_property): \DOMDocument {
$this->init($value, $destination_property);
foreach ($this->configuration['rules'] as $rule) {
$this->apply($rule);
}
return $this->document;
}
/**
* Retrieve the list of styles based on configuration.
*
* The styles configuration is a string: styles are separated by "\r\n", and
* each one has the format 'element(\.class)*|label'.
* Convert this to an array with 'label' => 'element.class', and save as
* $this->styles.
*
* @param string $format
* The text format from which to get configured styles.
*
* @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
*/
protected function setStyles($format): void {
if (empty($format) || !is_string($format)) {
$message = 'The "format" option must be a non-empty string.';
throw new InvalidPluginDefinitionException($this->getPluginId(), $message);
}
$editor_config = $this->configFactory->get("editor.editor.$format");
if ($editor_config->get('editor') === 'ckeditor') {
$editor_styles = $editor_config->get('settings.plugins.stylescombo.styles') ?? '';
foreach (explode("\r\n", $editor_styles) as $rule) {
if (preg_match('/(.*)\|(.*)/', $rule, $matches)) {
$this->styles[$matches[2]] = $matches[1];
}
}
}
else if ($editor_config->get('editor') === 'ckeditor5') {
$editor_styles = $editor_config->get('settings.plugins.ckeditor5_style.styles') ?? [];
foreach ($editor_styles as $editor_style) {
if (preg_match('/<(.*) class="(.*)">/', $editor_style['element'], $matches)) {
$this->styles[$editor_style['label']] = $matches[1] . '.' . $matches[2];
}
}
}
}
/**
* Validate the configured rules.
*
* @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
*/
protected function validateRules(): void {
if (!array_key_exists('rules', $this->configuration) || !is_array($this->configuration['rules'])) {
$message = 'The "rules" option must be an array.';
throw new InvalidPluginDefinitionException($this->getPluginId(), $message);
}
foreach ($this->configuration['rules'] as $rule) {
if (empty($rule['xpath']) || empty($rule['style'])) {
$message = 'The "xpath" and "style" options are required for each rule.';
throw new InvalidPluginDefinitionException($this->getPluginId(), $message);
}
if (empty($this->styles[$rule['style']])) {
$message = sprintf('The style "%s" is not defined.', $rule['style']);
throw new InvalidPluginDefinitionException($this->getPluginId(), $message);
}
}
}
/**
* Apply a rule to the document.
*
* Search $this->document for elements matching 'xpath' and replace them with
* the HTML elements and classes in $this->styles specified by 'style'.
* If 'depth' is positive, then replace additional parent elements as well.
*
* @param string[] $rule
* An array with keys 'xpath', 'style', and (optional) 'depth'.
*/
protected function apply(array $rule): void {
// An entry in $this->styles has the format element(\.class)*: for example,
// 'p' or 'a.button' or 'div.col-xs-6.col-md-4'.
// @see setStyles()
[$element, $classes] = explode('.', $this->styles[$rule['style']] . '.', 2);
$classes = trim(str_replace('.', ' ', $classes));
foreach ($this->xpath->query($rule['xpath']) as $node) {
$new_node = $this->document->createElement($element);
foreach ($node->childNodes as $child) {
$new_node->appendChild($child->cloneNode(TRUE));
}
if ($classes) {
$new_node->setAttribute('class', $classes);
}
$old_node = $node;
if (!empty($rule['depth'])) {
for ($i = 0; $i < $rule['depth']; $i++) {
$old_node = $old_node->parentNode;
}
}
$old_node->parentNode->replaceChild($new_node, $old_node);
}
}
}

View File

@@ -0,0 +1,214 @@
<?php
declare(strict_types = 1);
namespace Drupal\migrate_plus\Plugin\migrate\process;
use Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\migrate\MigrateExecutableInterface;
use Drupal\migrate\Plugin\MigratePluginManagerInterface;
use Drupal\migrate\Plugin\MigrationInterface;
use Drupal\migrate\Row;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* String replacements on a source dom based on migration lookup.
*
* Meant to be used after dom process plugin.
*
* Available configuration keys:
* - mode: What to modify. Possible values:
* - attribute: One element attribute.
* - xpath: XPath query expression that will produce the \DOMNodeList to walk.
* - attribute_options: A map of options related to the attribute mode. Required
* when mode is attribute. The keys can be:
* - name: Name of the attribute to match and modify.
* - search: Regular expression to use. It should contain at least one
* parenthesized subpattern which will be used as the ID passed to
* migration_lookup process plugin.
* - replace: Default value to use for replacements on migrations, if not
* specified on the migration. It should contain the '[mapped-id]' string
* where the looked-up migration value will be placed.
* - migrations: A map of options indexed by migration machine name. Possible
* option values are:
* - replace: See replace option lines above.
* - no_stub: If TRUE, then do not create stub entities during migration lookup.
* Optional, defaults to TRUE.
*
* Example:
*
* @code
* process:
* 'body/value':
* -
* plugin: dom
* method: import
* source: 'body/0/value'
* -
* plugin: dom_migration_lookup
* mode: attribute
* xpath: '//a'
* attribute_options:
* name: href
* search: '@/user/(\d+)@'
* replace: '/user/[mapped-id]'
* migrations:
* users:
* replace: '/user/[mapped-id]'
* people:
* replace: '/people/[mapped-id]'
* -
* plugin: dom
* method: export
* @endcode
*
* @MigrateProcessPlugin(
* id = "dom_migration_lookup"
* )
*/
class DomMigrationLookup extends DomStrReplace implements ContainerFactoryPluginInterface {
protected MigrationInterface $migration;
protected MigratePluginManagerInterface $processPluginManager;
/**
* Parameters passed to transform method, except the first, value.
*
* This helps to pass values to another process plugin.
*/
protected array $transformParameters = [];
/**
* {@inheritdoc}
*/
public function __construct(array $configuration, $plugin_id, $plugin_definition, MigrationInterface $migration, MigratePluginManagerInterface $process_plugin_manager) {
$configuration += ['no_stub' => TRUE];
$default_replace_missing = empty($configuration['replace']);
if ($default_replace_missing) {
$configuration['replace'] = 'prevent-requirement-fail';
}
parent::__construct($configuration, $plugin_id, $plugin_definition);
if ($default_replace_missing) {
unset($this->configuration['replace']);
}
$this->migration = $migration;
$this->processPluginManager = $process_plugin_manager;
if (empty($this->configuration['migrations'])) {
throw new InvalidPluginDefinitionException(
$this->getPluginId(),
"Configuration option 'migration' is required."
);
}
if (!is_array($this->configuration['migrations'])) {
throw new InvalidPluginDefinitionException(
$this->getPluginId(),
"Configuration option 'migration' should be a keyed array."
);
}
// Add missing values if possible.
$default_replace = $this->configuration['replace'] ?? NULL;
foreach ($this->configuration['migrations'] as $migration_name => $configuration_item) {
if (!empty($configuration_item['replace'])) {
continue;
}
if (is_null($default_replace)) {
throw new InvalidPluginDefinitionException(
$this->getPluginId(),
"Please define either a global replace for all migrations, or a specific one for 'migrations.$migration_name'."
);
}
$this->configuration['migrations'][$migration_name]['replace'] = $default_replace;
}
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition, MigrationInterface $migration = NULL): self {
return new static(
$configuration,
$plugin_id,
$plugin_definition,
$migration,
$container->get('plugin.manager.migrate.process')
);
}
/**
* {@inheritdoc}
*/
public function transform($value, MigrateExecutableInterface $migrate_executable, Row $row, $destination_property): \DOMDocument {
$this->init($value, $destination_property);
$this->transformParameters = [
'migrate_executable' => $migrate_executable,
'row' => $row,
'destination_property' => $destination_property,
];
foreach ($this->xpath->query($this->configuration['xpath']) as $html_node) {
$subject = $this->getSubject($html_node);
if (empty($subject)) {
// Could not find subject, skip processing.
continue;
}
$search = $this->getSearch();
if (!preg_match($search, $subject, $matches)) {
// No match found, skip processing.
continue;
}
$id = $matches[1];
// Walk through defined migrations looking for a map.
foreach ($this->configuration['migrations'] as $migration_name => $configuration) {
$mapped_id = $this->migrationLookup($id, $migration_name);
if (!is_null($mapped_id)) {
// Not using getReplace(), since this implementation depends on the
// migration.
$replace = str_replace('[mapped-id]', $mapped_id, $configuration['replace']);
$this->doReplace($html_node, $search, $replace, $subject);
break;
}
}
}
return $this->document;
}
/**
* {@inheritdoc}
*/
protected function doReplace(\DOMElement $html_node, $search, $replace, $subject): void {
$new_subject = preg_replace($search, $replace, $subject);
$this->postReplace($html_node, $new_subject);
}
/**
* Lookup the migration mapped ID on one migration.
*
* @param mixed $id
* The ID to search with migration_lookup process plugin.
* @param string $migration_name
* The migration to look into machine name.
*
* @return string|null
* The found mapped ID, or NULL if not found on the provided migration.
*/
protected function migrationLookup($id, $migration_name): ?string {
$mapped_id = NULL;
$parameters = [
$id,
$this->transformParameters['migrate_executable'],
$this->transformParameters['row'],
$this->transformParameters['destination_property'],
];
$plugin_configuration = [
'migration' => $migration_name,
'no_stub' => $this->configuration['no_stub'],
];
$migration_lookup_plugin = $this->processPluginManager
->createInstance('migration_lookup', $plugin_configuration, $this->migration);
$mapped_id = call_user_func_array([$migration_lookup_plugin, 'transform'], $parameters);
return $mapped_id;
}
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types = 1);
namespace Drupal\migrate_plus\Plugin\migrate\process;
use Drupal\migrate\MigrateSkipRowException;
use Drupal\migrate\ProcessPluginBase;
/**
* Base class for process plugins that work with \DOMDocument objects.
*
* Use Dom::import() to convert a string to a \DOMDocument object, then plugins
* derived from this class to manipulate the object, then Dom::export() to
* convert back to a string.
*/
abstract class DomProcessBase extends ProcessPluginBase {
protected ?\DOMDocument $document = NULL;
protected ?\DOMXPath $xpath = NULL;
/**
* Initialize the class properties.
*
* @param mixed $value
* Process plugin value.
* @param string $destination_property
* The name of the destination being processed. Used to generate an error
* message.
*
* @throws \Drupal\migrate\MigrateSkipRowException
* If $value is not a \DOMDocument object.
*/
protected function init($value, string $destination_property) {
if (!($value instanceof \DOMDocument)) {
$message = sprintf(
'The %s plugin in the %s process pipeline requires a \DOMDocument object. You can use the dom plugin to convert a string to \DOMDocument.',
$this->getPluginId(),
$destination_property
);
throw new MigrateSkipRowException($message);
}
$this->document = $value;
$this->xpath = new \DOMXPath($this->document);
}
}

View File

@@ -0,0 +1,106 @@
<?php
declare(strict_types = 1);
namespace Drupal\migrate_plus\Plugin\migrate\process;
use Drupal\migrate\MigrateExecutableInterface;
use Drupal\migrate\Row;
/**
* Remove nodes / attributes of a node from a DOMDocument object.
*
* Configuration:
* - selector: An XPath selector.
* - limit: (optional) The maximum number of nodes / attributes to remove.
* - mode: (optional) What to remove. Possible values:
* - element: An element (default option).
* - attribute: An element's attribute.
* - attribute: An attribute name (required if mode is attribute)
*
* Examples:
*
* @code
* process:
* bar:
* -
* plugin: dom
* method: import
* source: text_field
* -
* plugin: dom_remove
* selector: //img
* limit: 2
* -
* plugin: dom
* method: export
* @endcode
*
* This example will remove the first two <img> elements from the source text
* (if there are that many). Omit 'limit: 2' to remove all <img> elements.
*
* @code
* process:
* bar:
* -
* plugin: dom
* method: import
* source: text_field
* -
* plugin: dom_remove
* mode: attribute
* selector: //*[@style]
* attribute: style
* -
* plugin: dom
* method: export
* @endcode
*
* This example will remove "style" attributes from all tags.
*
* @MigrateProcessPlugin(
* id = "dom_remove"
* )
*/
class DomRemove extends DomProcessBase {
/**
* {@inheritdoc}
*/
public function __construct(array $configuration, $plugin_id, $plugin_definition) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
$this->configuration['mode'] = $this->configuration['mode'] ?? 'element';
if ($this->configuration['mode'] === 'attribute' && !isset($this->configuration['attribute'])) {
throw new \InvalidArgumentException('The "attribute" must be set if "mode" is set to "attribute".');
}
}
/**
* {@inheritdoc}
*/
public function transform($value, MigrateExecutableInterface $migrate_executable, Row $row, $destination_property): \DOMDocument {
$this->init($value, $destination_property);
$walking_dead = [];
// The PHP docs for removeChild() explain that you need to do this in two
// steps.
foreach ($this->xpath->query($this->configuration['selector']) as $node) {
if (isset($this->configuration['limit']) && count($walking_dead) >= $this->configuration['limit']) {
break;
}
$walking_dead[] = $node;
}
foreach ($walking_dead as $node) {
switch ($this->configuration['mode']) {
case 'attribute':
$node->removeAttribute($this->configuration['attribute']);
break;
case 'element':
$node->parentNode->removeChild($node);
break;
}
}
return $this->document;
}
}

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types = 1);
namespace Drupal\migrate_plus\Plugin\migrate\process;
use Drupal\migrate\MigrateExecutableInterface;
use Drupal\migrate\Row;
/**
* Select strings from a DOMDocument object.
*
* Configuration:
* - selector: An XPath selector that resolves to a string.
* - limit: (optional) The maximum number of results to return.
*
* Usage:
*
* @code
* process:
* bar:
* -
* plugin: dom
* method: import
* source: text_field
* -
* plugin: dom_select
* selector: //img/@src
* @endcode
*
* This example will return an array of the src attributes of all <img> tags in
* the source text. Add 'limit: 1' to return at most one result.
*
* @MigrateProcessPlugin(
* id = "dom_select"
* )
*/
class DomSelect extends DomProcessBase {
/**
* {@inheritdoc}
*/
public function transform($value, MigrateExecutableInterface $migrate_executable, Row $row, $destination_property): array {
$this->init($value, $destination_property);
$values = [];
foreach ($this->xpath->query($this->configuration['selector']) as $node) {
if (isset($this->configuration['limit']) && count($values) >= $this->configuration['limit']) {
break;
}
$values[] = $node->nodeValue;
}
return $values;
}
}

View File

@@ -0,0 +1,305 @@
<?php
declare(strict_types = 1);
namespace Drupal\migrate_plus\Plugin\migrate\process;
use Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException;
use Drupal\migrate\MigrateExecutableInterface;
use Drupal\migrate\Row;
/**
* String replacements on a source dom.
*
* Analogous to str_replace process plugin, but based on a \DOMDocument instead
* of a string.
* Meant to be used after dom process plugin.
*
* Available configuration keys:
* - mode: What to modify. Possible values:
* - attribute: One element attribute.
* - element: An element name.
* - xpath: XPath query expression that will produce the \DOMNodeList to walk.
* - attribute_options: A map of options related to the attribute mode. Required
* when mode is attribute. The keys can be:
* - name: Name of the attribute to match and modify.
* - search: pattern to match.
* - replace: value to replace the searched pattern with.
* - regex: Use regular expression replacement.
* - case_insensitive: Case insensitive search. Only valid when regex is false.
*
* Examples:
*
* @code
* process:
* 'body/value':
* -
* plugin: dom
* method: import
* source: 'body/0/value'
* -
* plugin: dom_str_replace
* mode: attribute
* xpath: '//a'
* attribute_options:
* name: href
* search: 'foo'
* replace: 'bar'
* -
* plugin: dom_str_replace
* mode: attribute
* xpath: '//a'
* attribute_options:
* name: href
* regex: true
* search: '/foo/'
* replace: 'bar'
* -
* plugin: dom_str_replace
* mode: element
* xpath: '//b'
* search: 'b'
* replace: 'strong'
* -
* plugin: dom_str_replace
* mode: attribute
* xpath: //a
* attribute_options:
* name: href
* regex: true
* search: '/foo-(\d+)/'
* replace: 'bar-$1'
* -
* plugin: dom
* method: export
* @endcode
*
* @MigrateProcessPlugin(
* id = "dom_str_replace"
* )
*/
class DomStrReplace extends DomProcessBase {
/**
* {@inheritdoc}
*/
public function __construct(array $configuration, $plugin_id, $plugin_definition) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
$this->configuration += [
'case_insensitive' => FALSE,
'regex' => FALSE,
];
$options_validation = [
'xpath' => NULL,
'mode' => [
'attribute' => [
'attribute_options' => NULL,
],
'element' => [],
],
'search' => NULL,
'replace' => NULL,
];
foreach ($options_validation as $option_name => $possible_values) {
if (empty($this->configuration[$option_name])) {
if ($option_name === 'replace' && isset($this->configuration[$option_name])) {
// Allow empty string for replace.
continue;
}
throw new InvalidPluginDefinitionException(
$this->getPluginId(),
"Configuration option '$option_name' is required."
);
}
if (!empty($possible_values) && !array_key_exists($this->configuration[$option_name], $possible_values)) {
throw new InvalidPluginDefinitionException(
$this->getPluginId(),
sprintf(
'Configuration option "%s" only accepts the following values: %s.',
$option_name,
implode(', ', array_keys($possible_values))
)
);
}
}
$mode = $this->configuration['mode'];
$mode_validation = $options_validation['mode'][$mode];
foreach ($mode_validation as $option_name => $possible_values) {
if (empty($this->configuration[$option_name])) {
throw new InvalidPluginDefinitionException(
$this->getPluginId(),
"Configuration option '$option_name' is required for mode '$mode'."
);
}
if (!is_null($possible_values) && !in_array($this->configuration[$option_name], $possible_values)) {
throw new InvalidPluginDefinitionException(
$this->getPluginId(),
sprintf(
'Configuration option "%s" only accepts the following values: %s.',
$option_name,
implode(', ', $possible_values)
)
);
}
}
$mode_validation = $options_validation['mode'][$this->configuration['mode']];
foreach ($mode_validation as $option_name => $possible_values) {
if (empty($this->configuration[$option_name])) {
throw new InvalidPluginDefinitionException(
$this->getPluginId(),
"Configuration option '$option_name' is required for mode '$mode'."
);
}
if (!is_null($possible_values) && !in_array($this->configuration[$option_name], $possible_values)) {
throw new InvalidPluginDefinitionException(
$this->getPluginId(),
sprintf(
'Configuration option "%s" only accepts the following values: %s.',
$option_name,
implode(', ', $possible_values)
)
);
}
}
$mode_validation = $options_validation['mode'][$this->configuration['mode']];
foreach ($mode_validation as $option_name => $possible_values) {
if (empty($this->configuration[$option_name])) {
throw new InvalidPluginDefinitionException(
$this->getPluginId(),
"Configuration option '$option_name' is required for mode '$mode'."
);
}
if (!is_null($possible_values) && !in_array($this->configuration[$option_name], $possible_values)) {
throw new InvalidPluginDefinitionException(
$this->getPluginId(),
sprintf(
'Configuration option "%s" only accepts the following values: %s.',
$option_name,
implode(', ', $possible_values)
)
);
}
}
}
/**
* {@inheritdoc}
*/
public function transform($value, MigrateExecutableInterface $migrate_executable, Row $row, $destination_property) {
$this->init($value, $destination_property);
foreach ($this->xpath->query($this->configuration['xpath']) as $html_node) {
$subject = $this->getSubject($html_node);
if (empty($subject)) {
// Could not find subject, skip processing.
continue;
}
$search = $this->getSearch();
$replace = $this->getReplace();
$this->doReplace($html_node, $search, $replace, $subject);
}
return $this->document;
}
/**
* Retrieves the right subject string.
*
* @param \DOMElement $node
* The current element from iteration.
*
* @return string
* The string to use a subject on search.
*/
protected function getSubject(\DOMElement $node): string {
switch ($this->configuration['mode']) {
case 'attribute':
return $node->getAttribute($this->configuration['attribute_options']['name']);
case 'element':
return $node->nodeName;
}
}
/**
* Retrieves the right search string based on configuration.
*
* @return string
* The value to be searched.
*/
protected function getSearch(): string {
switch ($this->configuration['mode']) {
case 'attribute':
case 'element':
return $this->configuration['search'];
}
}
/**
* Retrieves the right replace string based on configuration.
*
* @return string
* The value to use for replacement.
*/
protected function getReplace(): string {
switch ($this->configuration['mode']) {
case 'attribute':
case 'element':
return $this->configuration['replace'];
}
}
/**
* Retrieves the right replace string based on configuration.
*
* @param \DOMElement $html_node
* The current element from iteration.
* @param string $search
* The search string or pattern.
* @param string $replace
* The replacement string.
* @param string $subject
* The string on which to perform the substitution.
*/
protected function doReplace(\DOMElement $html_node, string $search, string $replace, string $subject): void {
if ($this->configuration['regex']) {
$function = 'preg_replace';
}
elseif ($this->configuration['case_insensitive']) {
$function = 'str_ireplace';
}
else {
$function = 'str_replace';
}
$new_subject = $function($search, $replace, $subject);
$this->postReplace($html_node, $new_subject);
}
/**
* Performs post-replace actions.
*
* @param \DOMElement $html_node
* The current element from iteration.
* @param string $new_subject
* The new value to use.
*/
protected function postReplace(\DOMElement $html_node, string $new_subject): void {
switch ($this->configuration['mode']) {
case 'attribute':
$html_node->setAttribute($this->configuration['attribute_options']['name'], $new_subject);
break;
case 'element':
$new_node = $this->document->createElement($new_subject);
foreach ($html_node->childNodes as $child) {
$new_node->appendChild($child->cloneNode(TRUE));
}
foreach ($html_node->attributes as $attribute) {
$new_node->setAttribute($attribute->name, $attribute->value);
}
$html_node->parentNode->replaceChild($new_node, $html_node);
break;
}
}
}

View File

@@ -0,0 +1,137 @@
<?php
declare(strict_types = 1);
namespace Drupal\migrate_plus\Plugin\migrate\process;
use Drupal\Component\Utility\NestedArray;
use Drupal\migrate\MigrateExecutableInterface;
use Drupal\migrate\Plugin\migrate\process\Get;
use Drupal\migrate\Plugin\MigratePluginManagerInterface;
use Drupal\migrate\Plugin\MigrationInterface;
use Drupal\migrate\Row;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* This plugin generates entities within the process plugin.
*
* All the configuration from the lookup plugin applies here. In its most
* simple form, this plugin needs no configuration. If there are fields on the
* generated entity that are required or need some value, their values can be
* provided via values and/or default_values configuration options.
*
* Available configuration keys:
* - default_values: (optional) A keyed array of default static values to be
* used for the generated entity.
* - values: (optional) A keyed array of values to be used for the generated
* entity. It supports source and destination fields as you would normally use
* in a process pipeline.
*
* Example:
* @code
* destination:
* plugin: 'entity:node'
* process:
* foo: bar
* field_tags:
* plugin: entity_generate
* source: tags
* default_values:
* description: Default description
* values:
* field_long_description: some_source_field
* field_foo: '@foo'
* @endcode
*
* @see \Drupal\migrate_plus\Plugin\migrate\process\EntityLookup
*
* @MigrateProcessPlugin(
* id = "entity_generate"
* )
*/
class EntityGenerate extends EntityLookup {
protected ?Row $row = NULL;
protected ?MigrateExecutableInterface $migrateExecutable = NULL;
protected ?MigratePluginManagerInterface $processPluginManager = NULL;
protected ?Get $getProcessPlugin = NULL;
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $pluginId, $pluginDefinition, MigrationInterface $migration = NULL): self {
$instance = parent::create($container, $configuration, $pluginId, $pluginDefinition, $migration);
$instance->processPluginManager = $container->get('plugin.manager.migrate.process');
return $instance;
}
/**
* {@inheritdoc}
*/
public function transform($value, MigrateExecutableInterface $migrate_executable, Row $row, $destination_property) {
$this->row = $row;
$this->migrateExecutable = $migrate_executable;
// Creates an entity if the lookup determines it doesn't exist.
if (!($result = parent::transform($value, $migrate_executable, $row, $destination_property))) {
$result = $this->generateEntity($value);
}
return $result;
}
/**
* Generates an entity for a given value.
*
* @param string $value
* Value to use in creation of the entity.
*
* @return int|string
* The entity id of the generated entity.
*/
protected function generateEntity($value) {
if (!empty($value)) {
$entity = $this->entityTypeManager
->getStorage($this->lookupEntityType)
->create($this->entity($value));
$entity->save();
return $entity->id();
}
}
/**
* Fabricate an entity.
*
* This is intended to be extended by implementing classes to provide for more
* dynamic default values, rather than just static ones.
*
* @param mixed $value
* Primary value to use in creation of the entity.
*
* Entity value array.
*/
protected function entity($value): array {
$entity_values = [$this->lookupValueKey => $value];
if ($this->lookupBundleKey) {
$entity_values[$this->lookupBundleKey] = $this->lookupBundle;
}
// Gather any static default values for properties/fields.
if (isset($this->configuration['default_values']) && is_array($this->configuration['default_values'])) {
foreach ($this->configuration['default_values'] as $key => $default_value) {
$entity_values[$key] = $default_value;
}
}
// Gather any additional properties/fields.
if (isset($this->configuration['values']) && is_array($this->configuration['values'])) {
foreach ($this->configuration['values'] as $key => $property) {
$source_value = $this->row->get($property);
NestedArray::setValue($entity_values, explode(Row::PROPERTY_SEPARATOR, $key), $source_value, TRUE);
}
}
return $entity_values;
}
}

View File

@@ -0,0 +1,302 @@
<?php
declare(strict_types = 1);
namespace Drupal\migrate_plus\Plugin\migrate\process;
use Drupal\Core\Entity\EntityFieldManagerInterface;
use Drupal\Core\Entity\EntityReferenceSelection\SelectionPluginManagerInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Entity\Query\QueryInterface;
use Drupal\Core\Field\FieldItemList;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\migrate\MigrateException;
use Drupal\migrate\MigrateExecutableInterface;
use Drupal\migrate\Plugin\MigrationInterface;
use Drupal\migrate\ProcessPluginBase;
use Drupal\migrate\Row;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* This plugin looks for existing entities.
*
* In its most simple form, this plugin needs no configuration, and determines
* the configuration automatically. This requires the migration's process to
* define a default value for the destination entity's bundle key, and the
* destination field this plugin is on to be a supported type.
*
* Available configuration keys:
* - entity_type: (optional) The ID of the entity type to query for.
* - value_key: (optional) The name of the entity field on which the source
* value will be queried. If omitted, defaults to one of the following
* depending on the destination field type:
* - entity_reference: The entity label key.
* - file: The uri field.
* - image: The uri field.
* - operator: (optional) The comparison operator supported by entity query:
* See \Drupal\Core\Entity\Query\QueryInterface::condition() for available
* values. Defaults to '=' for scalar values and 'IN' for arrays.
* - bundle_key: (optional) The name of the bundle field on the entity type
* being queried.
* - bundle: (optional) The value to query for the bundle - can be a string or
* an array.
* - access_check: (optional) Indicates if access to the entity for this user
* will be checked. Default is true.
* - ignore_case: (optional) Whether to ignore case in the query. Defaults to
* false, meaning the query is case-sensitive by default. Works only with
* strict operators: '=' and 'IN'.
* - destination_field: (optional) If specified, and if the plugin's source
* value is an array, the result array's items will be themselves arrays of
* the form [destination_field => ENTITY_ID].
*
* @codingStandardsIgnoreStart
*
* Example usage with minimal configuration:
* @code
* destination:
* plugin: 'entity:node'
* process:
* type:
* plugin: default_value
* default_value: page
* field_tags:
* plugin: entity_lookup
* access_check: false
* source: tags
* @endcode
* In this example above, the access check is disabled.
*
* Example usage with full configuration:
* @code
* field_tags:
* plugin: entity_lookup
* source: tags
* value_key: name
* bundle_key: vid
* bundle: tags
* entity_type: taxonomy_term
* ignore_case: true
* operator: STARTS_WITH
* @endcode
*
* @codingStandardsIgnoreEnd
*
* @see \Drupal\Core\Entity\Query\QueryInterface::condition()
*
* @MigrateProcessPlugin(
* id = "entity_lookup",
* handle_multiples = TRUE
* )
*/
class EntityLookup extends ProcessPluginBase implements ContainerFactoryPluginInterface {
protected ?EntityTypeManagerInterface $entityTypeManager;
protected EntityFieldManagerInterface $entityFieldManager;
protected MigrationInterface $migration;
protected SelectionPluginManagerInterface $selectionPluginManager;
protected ?string $destinationEntityType;
protected ?string $destinationBundleKey = NULL;
protected ?string $lookupValueKey = NULL;
protected ?string $lookupBundleKey = NULL;
protected $lookupBundle = NULL;
protected ?string $lookupEntityType = NULL;
protected ?string $destinationProperty;
protected bool $accessCheck = TRUE;
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $pluginId, $pluginDefinition, MigrationInterface $migration = NULL) {
$instance = new static(
$configuration,
$pluginId,
$pluginDefinition
);
$instance->migration = $migration;
$instance->entityTypeManager = $container->get('entity_type.manager');
$instance->entityFieldManager = $container->get('entity_field.manager');
$instance->selectionPluginManager = $container->get('plugin.manager.entity_reference_selection');
$pluginIdParts = explode(':', $instance->migration->getDestinationPlugin()->getPluginId());
$instance->destinationEntityType = empty($pluginIdParts[1]) ? NULL : $pluginIdParts[1];
$instance->destinationBundleKey = $instance->destinationEntityType ? $instance->entityTypeManager->getDefinition($instance->destinationEntityType)->getKey('bundle') : NULL;
return $instance;
}
/**
* {@inheritdoc}
*/
public function transform($value, MigrateExecutableInterface $migrate_executable, Row $row, $destination_property) {
// If the source data is an empty array, return the same.
if (gettype($value) === 'array' && count($value) === 0) {
return [];
}
// In case of subfields ('field_reference/target_id'), extract the field
// name only.
$parts = explode('/', $destination_property);
$destination_property = reset($parts);
$this->determineLookupProperties($destination_property);
$this->destinationProperty = $this->configuration['destination_field'] ?? NULL;
return $this->query($value);
}
/**
* Determine the lookup properties from config or target field configuration.
*
* @param string $destinationProperty
* The destination property currently worked on. This is only used together
* with the $row above.
*/
protected function determineLookupProperties(string $destinationProperty): void {
if (isset($this->configuration['access_check'])) {
$this->accessCheck = (bool) $this->configuration['access_check'];
}
if (!empty($this->configuration['value_key'])) {
$this->lookupValueKey = $this->configuration['value_key'];
}
if (!empty($this->configuration['bundle_key'])) {
$this->lookupBundleKey = $this->configuration['bundle_key'];
}
if (!empty($this->configuration['bundle'])) {
$this->lookupBundle = $this->configuration['bundle'];
}
if (!empty($this->configuration['entity_type'])) {
$this->lookupEntityType = $this->configuration['entity_type'];
}
if (empty($this->lookupValueKey) || empty($this->lookupBundleKey) || empty($this->lookupBundle) || empty($this->lookupEntityType)) {
// See if we can introspect the lookup properties from destination field.
if (!empty($this->migration->getProcess()[$this->destinationBundleKey][0]['default_value'])) {
$destinationEntityBundle = $this->migration->getProcess()[$this->destinationBundleKey][0]['default_value'];
$fieldConfig = $this->entityFieldManager->getFieldDefinitions($this->destinationEntityType, $destinationEntityBundle)[$destinationProperty]->getConfig($destinationEntityBundle);
switch ($fieldConfig->getType()) {
case 'entity_reference':
if (empty($this->lookupBundle)) {
$handlerSettings = $fieldConfig->getSetting('handler_settings');
$bundles = array_filter((array) ($handlerSettings['target_bundles'] ?? []));
if (count($bundles) == 1) {
$this->lookupBundle = reset($bundles);
}
// This was added in 8.1.x is not supported in 8.0.x.
elseif (!empty($handlerSettings['auto_create']) && !empty($handlerSettings['auto_create_bundle'])) {
$this->lookupBundle = reset($handlerSettings['auto_create_bundle']);
}
}
// Make an assumption that if the selection handler can target more
// than one type of entity that we will use the first entity type.
$fieldHandler = $fieldConfig->getSetting('handler');
$selection = $this->selectionPluginManager->createInstance($fieldHandler);
$this->lookupEntityType = $this->lookupEntityType ?: reset($selection->getPluginDefinition()['entity_types']);
$this->lookupValueKey = $this->lookupValueKey ?: $this->entityTypeManager->getDefinition($this->lookupEntityType)->getKey('label');
$this->lookupBundleKey = $this->lookupBundleKey ?: $this->entityTypeManager->getDefinition($this->lookupEntityType)->getKey('bundle');
break;
case 'file':
case 'image':
$this->lookupEntityType = 'file';
$this->lookupValueKey = $this->lookupValueKey ?: 'uri';
break;
default:
throw new MigrateException(sprintf('Destination field type %s is not a recognized reference type.', $fieldConfig->getType()));
}
}
}
// If there aren't enough lookup properties available by now, then bail.
if (empty($this->lookupValueKey)) {
throw new MigrateException('The entity_lookup plugin requires a value_key, none located.');
}
if (!empty($this->lookupBundleKey) && empty($this->lookupBundle)) {
throw new MigrateException('The entity_lookup plugin found no bundle but destination entity requires one.');
}
if (empty($this->lookupEntityType)) {
throw new MigrateException('The entity_lookup plugin requires a entity_type, none located.');
}
}
/**
* Checks for the existence of some value.
*
* @param mixed $value
* The value to query.
*
* @return mixed|null
* Entity id if the queried entity exists. Otherwise NULL.
*/
protected function query($value) {
$query = $this->doGetQuery($value);
return $this->processResults($query->execute(), $value);
}
private function doGetQuery($value): QueryInterface {
$operator = !empty($this->configuration['operator']) ? $this->configuration['operator'] : '=';
$multiple = is_array($value);
// Apply correct operator for multiple values.
if ($multiple && $operator === '=') {
$operator = 'IN';
}
$query = $this->entityTypeManager->getStorage($this->lookupEntityType)
->getQuery()
->accessCheck($this->accessCheck)
->condition($this->lookupValueKey, $value, $operator);
// Sqlite and possibly others returns data in a non-deterministic order.
// Make it deterministic.
if ($multiple) {
$query->sort($this->lookupValueKey, 'DESC');
}
if ($this->lookupBundleKey) {
$query->condition($this->lookupBundleKey, (array) $this->lookupBundle, 'IN');
}
return $query;
}
private function processResults($results, $original_value) {
if (empty($results)) {
return NULL;
}
// Entity queries typically are case-insensitive. Therefore, we need to
// handle case-sensitive filtering as a post-query step. By default, it
// filters case-insensitive. Change to true if that is not the desired
// outcome.
$ignoreCase = !empty($this->configuration['ignore_case']) ?: FALSE;
$operator = !empty($this->configuration['operator']) ? $this->configuration['operator'] : '=';
$multiple = is_array($original_value);
// Do a case-sensitive comparison only for strict operators.
if (!$ignoreCase && in_array($operator, ['=', 'IN'], TRUE)) {
// Returns the entity's identifier.
foreach ($results as $k => $identifier) {
$entity = $this->entityTypeManager->getStorage($this->lookupEntityType)->load($identifier);
$result_value = $entity->get($this->lookupValueKey);
// If the value is a non-empty field, extract its first value's main
// property (most of the time "value" but sometimes "target_id" or
// anything declared by the field item).
if ($result_value instanceof FieldItemList && !$result_value->isEmpty()) {
$property = $result_value->first()->mainPropertyName();
$result_value = $result_value->{$property};
}
if (($multiple && !in_array($result_value, $original_value, TRUE)) || (!$multiple && $result_value !== $original_value)) {
unset($results[$k]);
}
}
}
if ($multiple && !empty($this->destinationProperty)) {
array_walk($results, function (&$value): void {
$value = [$this->destinationProperty => $value];
});
}
return $multiple ? array_values($results) : reset($results);
}
}

View File

@@ -0,0 +1,174 @@
<?php
declare(strict_types = 1);
namespace Drupal\migrate_plus\Plugin\migrate\process;
use Drupal\Core\Entity\EntityStorageInterface;
use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\migrate\MigrateException;
use Drupal\migrate\MigrateExecutableInterface;
use Drupal\migrate\ProcessPluginBase;
use Drupal\migrate\Row;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Extracts a specified field's value from an entity.
*
* This considers the source value to be an entity ID, and returns a field
* value from that field. The type of the entity and the field name must be
* specified. Optionally, the field property should be specified if it is not
* the default of 'value'.
*
* Available configuration keys:
* - entity_type: The entity type ID to query for.
* - field_name: The machine name of the field to be extracted from the loaded
* entity.
* - langcode: (optional) The language code of entity translation. If given, the
* entity translation is loaded. It can only be used with content entities.
*
* Example:
* @code
* process:
* field_foo:
* plugin: entity_value
* source: field_noderef/0/target_id
* entity_type: node
* langcode: es
* field_name: field_foo
* @endcode
*
* In this example field_foo field value will be retrieved from Spanish
* translation of the loaded node.
*
* @MigrateProcessPlugin(
* id = "entity_value",
* )
*/
class EntityValue extends ProcessPluginBase implements ContainerFactoryPluginInterface {
protected EntityTypeManagerInterface $entityTypeManager;
protected string $fieldName;
protected ?string $langCodeRef;
protected EntityStorageInterface $entityStorage;
/**
* Flag indicating whether there are multiple values.
*/
protected ?bool $multiple = NULL;
/**
* Creates a EntityValue instance.
*
* @param array $configuration
* A configuration array containing information about the plugin instance.
* @param string $plugin_id
* The plugin_id for the plugin instance.
* @param mixed $plugin_definition
* The plugin implementation definition.
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager.
*
* @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
* @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException
* @throws \InvalidArgumentException
*/
public function __construct(
array $configuration,
$plugin_id,
$plugin_definition,
EntityTypeManagerInterface $entity_type_manager
) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
$this->entityTypeManager = $entity_type_manager;
if (empty($this->configuration['entity_type'])) {
throw new \InvalidArgumentException("'entity_type' configuration must be specified for the entity_value process plugin.");
}
$entity_type = $this->configuration['entity_type'];
$this->entityStorage = $this->entityTypeManager->getStorage($entity_type);
$this->langCodeRef = $this->configuration['langcode'] ?? NULL;
if (empty($this->configuration['field_name'])) {
throw new \InvalidArgumentException("'field_name' configuration must be specified for migrate_plus_entity_value process plugin.");
}
$this->fieldName = $this->configuration['field_name'];
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): self {
return new static(
$configuration,
$plugin_id,
$plugin_definition,
$container->get('entity_type.manager')
);
}
/**
* {@inheritdoc}
*/
public function transform($value, MigrateExecutableInterface $migrate_executable, Row $row, $destination_property) {
$this->multiple = is_array($value);
if (!isset($value)) {
return [];
}
$ids = $this->multiple ? $value : [$value];
$entities = $this->loadEntities($ids);
$langcode = $this->langCodeRef;
$arrays = array_map(function (EntityInterface $entity) use ($langcode) {
if ($entity instanceof ContentEntityInterface) {
if ($langcode) {
$entity = $entity->getTranslation($langcode);
}
else {
$entity = $entity->getUntranslated();
}
}
elseif ($langcode) {
throw new MigrateException('Langcode can only be used with content entities currently.');
}
try {
return $entity->get($this->fieldName)->getValue();
}
catch (\Exception $e) {
// Re-throw any exception thrown by the entity system.
throw new MigrateException("Got exception reading field value {$this->fieldName} entity with ID {$entity->id()} in migrate_plus_entity_value process plugin:" . $e->getMessage());
}
}, $entities);
return $this->multiple ? array_values($arrays) : ($arrays ? reset($arrays) : []);
}
/**
* {@inheritdoc}
*/
public function multiple(): bool {
return $this->multiple;
}
/**
* Load entities.
*
* @param array $ids
* The entity IDs.
*
* @return \Drupal\Core\Entity\EntityInterface[]
* The entities.
*/
protected function loadEntities(array $ids): array {
$entities = $this->entityStorage->loadMultiple($ids);
return $entities;
}
}

View File

@@ -0,0 +1,205 @@
<?php
declare(strict_types = 1);
namespace Drupal\migrate_plus\Plugin\migrate\process;
use Drupal\Core\File\FileSystemInterface;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\migrate\MigrateExecutableInterface;
use Drupal\migrate\MigrateSkipProcessException;
use Drupal\migrate\ProcessPluginBase;
use Drupal\migrate\Row;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Copy a file from a blob into a file.
*
* The source value is an indexed array of two values:
* - The destination URI, e.g. 'public://example.txt'.
* - The binary blob data.
*
* Available configuration keys:
* - reuse: (optional) Indicates whether to overwrite existing files. If TRUE,
* then existing files won't be replaced, and previously copied files will be
* reused. Defaults to FALSE.
*
* Examples:
* @code
* uri:
* plugin: file_blob
* source:
* - 'public://example.txt'
* - blob
* @endcode
* Above, a basic configuration.
*
* @code
* source:
* constants:
* destination: public://images
* process:
* destination_blob:
* plugin: callback
* callable: base64_decode
* source:
* - blob
* destination_basename:
* plugin: callback
* callable: basename
* source: file_name
* destination_path:
* plugin: concat
* source:
* - constants/destination
* - @destination_basename
* uri:
* plugin: file_blob
* source:
* - @destination_path
* - @destination_blob
* @endcode
*
* In the example above, it is necessary to manipulate the values before they
* are processed by this plugin. This is because this plugin takes a binary blob
* and saves it as a file. In many cases, as in this example, the data is base64
* encoded and should be decoded first. In destination_blob, the incoming data
* is decoded from base64 to binary. The destination_path element is
* concatenating the base filename with the destination directory set in the
* constants to create the final path. The resulting values are then referenced
* as the source of the file_blob plugin.
*
* @MigrateProcessPlugin(
* id = "file_blob"
* )
*/
class FileBlob extends ProcessPluginBase implements ContainerFactoryPluginInterface {
protected FileSystemInterface $fileSystem;
/**
* Constructs a file_blob process plugin.
*
* @param array $configuration
* The plugin configuration.
* @param string $plugin_id
* The plugin ID.
* @param mixed $plugin_definition
* The plugin definition.
* @param \Drupal\Core\File\FileSystemInterface $file_system
* The file system service.
*/
public function __construct(array $configuration, $plugin_id, $plugin_definition, FileSystemInterface $file_system) {
$configuration += [
'reuse' => FALSE,
];
parent::__construct($configuration, $plugin_id, $plugin_definition);
$this->fileSystem = $file_system;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): self {
return new static(
$configuration,
$plugin_id,
$plugin_definition,
$container->get('file_system')
);
}
/**
* {@inheritdoc}
*/
public function transform($value, MigrateExecutableInterface $migrate_executable, Row $row, $destination_property) {
// If we're stubbing a file entity, return a URI of NULL so it will get
// stubbed by the general process.
if ($row->isStub()) {
return NULL;
}
[$destination, $blob] = $value;
// Determine if we're going to overwrite existing files or not touch them.
$replace = $this->getOverwriteMode();
// Create the directory or modify permissions if necessary
$dir = $this->getDirectory($destination);
$success = $this->fileSystem->prepareDirectory($dir, FileSystemInterface::CREATE_DIRECTORY | FileSystemInterface::MODIFY_PERMISSIONS);
if (!$success) {
throw new MigrateSkipProcessException("Could not create directory '$dir'");
}
// Attempt to save the file
if (!$this->putFile($destination, $blob, $replace)) {
throw new MigrateSkipProcessException("Blob data could not be copied to $destination.");
}
return $destination;
}
/**
* Try to save the file.
*
* @param string $destination
* The destination path or URI.
* @param string $blob
* The base64 encoded file contents.
* @param int $replace
* (optional) either FileSystemInterface::EXISTS_REPLACE; (default) or
* FileSystemInterface::EXISTS_ERROR, depending on the configuration.
*
* @return bool|string
* File path on success, FALSE on failure.
*/
protected function putFile(string $destination, string $blob, int $replace = FileSystemInterface::EXISTS_REPLACE) {
$path = $this->fileSystem->getDestinationFilename($destination, $replace);
if ($path) {
if (file_put_contents($path, $blob)) {
return $path;
}
else {
return FALSE;
}
}
// File was already copied.
return $destination;
}
/**
* Determines how to handle file conflicts.
*
* Either FileSystemInterface::EXISTS_REPLACE; (default) or
* FileSystemInterface::EXISTS_ERROR, depending on the configuration.
*/
protected function getOverwriteMode(): int {
if (isset($this->configuration['reuse']) && !empty($this->configuration['reuse'])) {
return FileSystemInterface::EXISTS_ERROR;
}
return FileSystemInterface::EXISTS_REPLACE;
}
/**
* Returns the directory component of a URI or path.
*
* For URIs like public://foo.txt, the full physical path of public://
* will be returned, since a scheme by itself will trip up certain file
* API functions (such as file_prepare_directory()).
*
* @param string $uri
* The URI or path.
*
* @return string|false
* The directory component of the path or URI, or FALSE if it could not
* be determined.
*/
protected function getDirectory(string $uri) {
$dir = $this->fileSystem->dirname($uri);
if (substr($dir, -3) === '://') {
return $this->fileSystem->realpath($dir);
}
return $dir;
}
}

View File

@@ -0,0 +1,128 @@
<?php
declare(strict_types = 1);
namespace Drupal\migrate_plus\Plugin\migrate\process;
use Drupal\migrate\MigrateExecutableInterface;
use Drupal\migrate\MigrateSkipProcessException;
use Drupal\migrate\ProcessPluginBase;
use Drupal\migrate\Row;
/**
* Allow a source value to pass through the gate conditionally.
*
* Imagine the source value as wanting to get through the gate.
* We provide a different source/destination field that acts as the key.
* We compare to a set of valid keys. We declare whether the key locks
* the gate or unlocks the gate.
*
* This is different from skip_on_value because in that plugin, the source
* is compared to a value. In this plugin, the source is not compared to
* anything. The source just wants to get through a gate that is operated
* by another source/destination field.
*
* Unlike skip_on_value, there is no configurable method. The method is
* essentially restricted to 'process'.
*
* The source is not modified if it passes through the gate.
*
* @MigrateProcessPlugin(
* id = "gate"
* )
*
* Available configuration keys:
* - use_as_key: source or destination field to be used as the key to the gate.
* - valid_keys: Value or array of values that are valid keys.
* - key_direction: lock or unlock.
*
* @codingStandardsIgnoreStart
*
* Examples:
*
* Migrate an email address if an opt_in field is set.
* @code
* field_email:
* plugin: gate
* source: email
* use_as_key: opt_in
* valid_keys: TRUE
* key_direction: unlock
* @endcode
*
* Colorado requires salary_range data to be displayed. Only migrate
* salary_range if state is CO. Maybe the data is sloppy and sometimes they use
* the full state name.
* @code
* field_salary_range:
* plugin: gate
* source: salary_range
* use_as_key: state_abbr
* valid_keys:
* - CO
* - Colorado
* key_direction: unlock
* @endcode
*
* While importing baseball players, don't import batting averages for
* pitchers. The position we use as the key to the gate is stored in a
* destination field, indicated by @.
* @code
* field_batting_average:
* plugin: gate
* source: batting_average
* use_as_key: @position
* valid_keys:
* - RHP
* - LHP
* key_direction: lock
* @endcode
*
* @codingStandardsIgnoreEnd
*/
class Gate extends ProcessPluginBase {
/**
* {@inheritdoc}
*/
public function __construct(array $configuration, $plugin_id, $plugin_definition) {
if (!array_key_exists('valid_keys', $configuration)) {
throw new \InvalidArgumentException('Gate plugin is missing valid_keys configuration.');
}
if (!array_key_exists('use_as_key', $configuration)) {
throw new \InvalidArgumentException('Gate plugin is missing use_as_key configuration.');
}
if (!array_key_exists('key_direction', $configuration)) {
throw new \InvalidArgumentException('Gate plugin is missing key_direction configuration.');
}
if (!in_array($configuration['key_direction'], ['lock', 'unlock'], TRUE)) {
throw new \InvalidArgumentException('Gate plugin only accepts the following values for key_direction: lock and unlock.');
}
parent::__construct($configuration, $plugin_id, $plugin_definition);
}
/**
* {@inheritdoc}
*/
public function transform($value, MigrateExecutableInterface $migrate_executable, Row $row, $destination_property) {
$valid_keys = (array) $this->configuration['valid_keys'];
$key = $row->get($this->configuration['use_as_key']);
$key_is_valid = in_array($key, $valid_keys, TRUE);
$key_direction = $this->configuration['key_direction'];
$value_can_pass = ($key_is_valid && $key_direction == 'unlock') || (!$key_is_valid && $key_direction == 'lock');
if ($value_can_pass) {
return $value;
}
else {
if ($key_direction == 'lock') {
$message = sprintf('Processing of destination property %s was skipped: Gate was locked by property %s with value %s.', $destination_property, $this->configuration['use_as_key'], $key);
}
else {
$message = sprintf('Processing of destination property %s was skipped: Gate was not unlocked by property %s with value %s. ', $destination_property, $this->configuration['use_as_key'], $key);
}
throw new MigrateSkipProcessException($message);
}
}
}

View File

@@ -0,0 +1,71 @@
<?php
declare(strict_types = 1);
namespace Drupal\migrate_plus\Plugin\migrate\process;
use Drupal\migrate\MigrateException;
use Drupal\migrate\MigrateExecutableInterface;
use Drupal\migrate\ProcessPluginBase;
use Drupal\migrate\Row;
/**
* This plugin merges arrays together.
*
* @MigrateProcessPlugin(
* id = "merge"
* )
*
* Use to merge several fields into one. In the following example, imagine a D7
* node with a field_collections field and an image field that migrations were
* written for to make paragraph entities in D8. We would like to add those
* paragraph entities to the 'paragraphs_field'. Consider the following:
*
* source:
* plugin: d7_node
* process:
* temp_body:
* plugin: sub_process
* source: field_section
* process:
* target_id:
* plugin: migration_lookup
* migration: field_collection_field_section_to_paragraph
* source: value
* temp_images:
* plugin: sub_process
* source: field_image
* process:
* target_id:
* plugin: migration_lookup
* migration: image_entities_to_paragraph
* source: fid
* paragraphs_field:
* plugin: merge
* source:
* - '@temp_body'
* - '@temp_images'
* destination:
* plugin: 'entity:node'
*/
class Merge extends ProcessPluginBase {
/**
* {@inheritdoc}
*/
public function transform($value, MigrateExecutableInterface $migrate_executable, Row $row, $destination_property): array {
if (!is_array($value)) {
throw new MigrateException(sprintf('Merge process failed for destination property (%s): input is not an array.', $destination_property));
}
$new_value = [];
foreach ($value as $i => $item) {
if (!is_array($item)) {
throw new MigrateException(sprintf('Merge process failed for destination property (%s): index (%s) in the source value is not an array that can be merged.', $destination_property, $i));
}
$new_value[] = $item;
}
return array_merge(...$new_value);
}
}

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types = 1);
namespace Drupal\migrate_plus\Plugin\migrate\process;
use Drupal\migrate\MigrateExecutableInterface;
use Drupal\migrate\ProcessPluginBase;
use Drupal\migrate\Row;
/**
* Treat an array of values as a separate / individual values.
*
* @code
* process:
* field_authors:
* -
* plugin: explode
* delimiter: ', '
* source: authors
* -
* plugin: single_value
* -
* plugin: callback
* callable: custom_sort_authors
* -
* plugin: multiple_values
* @endcode
*
* Assume the "authors" field contains comma separated author names.
*
* We split the names into multiple values and then use the "single_value"
* plugin to treat them as a single array of author names. After that, we
* pass the values through a custom sort. Callback multiple setting is false. To
* convert from a single value to multiple, use the "multiple_values" plugin. It
* will make the next plugin treat the values individually instead of an array
* of values.
*
* @MigrateProcessPlugin(
* id = "multiple_values",
* handle_multiples = TRUE
* )
*/
class MultipleValues extends ProcessPluginBase {
/**
* {@inheritdoc}
*/
public function transform($value, MigrateExecutableInterface $migrate_executable, Row $row, $destination_property) {
return $value;
}
/**
* {@inheritdoc}
*/
public function multiple(): bool {
return TRUE;
}
}

View File

@@ -0,0 +1,82 @@
<?php
declare(strict_types = 1);
namespace Drupal\migrate_plus\Plugin\migrate\process;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\migrate\Plugin\migrate\process\Callback;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Provides a plugin to use a callable from a service class.
*
* Available configuration keys:
* - service: The ID of the service (e.g. file.mime_type.guesser).
* - method: The name of the service public method.
* All options for the callback plugin can be used, except for 'callable',
* which will be ignored.
*
* Since Drupal 9.2.0, it is possible to supply multiple arguments using
* unpack_source property. See: https://www.drupal.org/node/3205079
*
* Examples:
*
* @code
* process:
* filemime:
* plugin: service
* service: file.mime_type.guesser
* method: guessMimeType
* source: filename
* @endcode
*
* @code
* source:
* # plugin ...
* constants:
* langcode: en
* slash: /
* process:
* transliterated_value:
* plugin: service
* service: transliteration
* method: transliterate
* unpack_source: true
* source:
* - original_value
* - constants/langcode
* - constants/slash
* @endcode
*
* @see \Drupal\migrate\Plugin\migrate\process\Callback
*
* @MigrateProcessPlugin(
* id = "service"
* )
*/
class Service extends Callback implements ContainerFactoryPluginInterface {
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): self {
if (!isset($configuration['service'])) {
throw new \InvalidArgumentException('The "service" must be set.');
}
if (!isset($configuration['method'])) {
throw new \InvalidArgumentException('The "method" must be set.');
}
if (!$container->has($configuration['service'])) {
throw new \InvalidArgumentException(sprintf('You have requested the non-existent service "%s".', $configuration['service']));
}
$service = $container->get($configuration['service']);
if (!method_exists($service, $configuration['method'])) {
throw new \InvalidArgumentException(sprintf('The "%s" service has no method "%s".', $configuration['service'], $configuration['method']));
}
$configuration['callable'] = [$service, $configuration['method']];
return new static($configuration, $plugin_id, $plugin_definition);
}
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types = 1);
namespace Drupal\migrate_plus\Plugin\migrate\process;
use Drupal\migrate\MigrateExecutableInterface;
use Drupal\migrate\ProcessPluginBase;
use Drupal\migrate\Row;
/**
* Treat an array of values as a single value.
*
* @code
* process:
* field_authors:
* -
* plugin: explode
* delimiter: ', '
* source: authors
* -
* plugin: single_value
* @endcode
*
* Assume the "authors" field contains comma separated author names.
*
* After the explode, we end up with each author name as an individual value.
* But if we want to perform a sort on all values using a callback, we will
* need to send all the values to a callable together as an array of author
* names. Calling the "single_value" plugin in such a case will combine all the
* values into a single array for the next plugin.
*
* @MigrateProcessPlugin(
* id = "single_value",
* handle_multiples = TRUE
* )
*/
class SingleValue extends ProcessPluginBase {
/**
* {@inheritdoc}
*/
public function transform($value, MigrateExecutableInterface $migrate_executable, Row $row, $destination_property) {
return $value;
}
}

View File

@@ -0,0 +1,195 @@
<?php
declare(strict_types = 1);
namespace Drupal\migrate_plus\Plugin\migrate\process;
use Drupal\migrate\MigrateExecutableInterface;
use Drupal\migrate\MigrateSkipProcessException;
use Drupal\migrate\MigrateSkipRowException;
use Drupal\migrate\ProcessPluginBase;
use Drupal\migrate\Row;
/**
* If the source evaluates to a configured value, skip processing or whole row.
*
* @MigrateProcessPlugin(
* id = "skip_on_value"
* )
*
* Available configuration keys:
* - value: An single value or array of values against which the source value
* should be compared.
* - not_equals: (optional) If set, skipping occurs when values are not equal.
* - method: What to do if the input value equals to value given in
* configuration key value. Possible values:
* - row: Skips the entire row.
* - process: Prevents further processing of the input property
* - message: (optional) A message to be logged in the {migrate_message_*} table
* for this row. Messages are only logged for the 'row' method. If not set,
* nothing is logged in the message table.
*
* @codingStandardsIgnoreStart
*
* Examples:
*
* Example usage with minimal configuration:
* @code
* type:
* plugin: skip_on_value
* source: content_type
* method: process
* value: blog
* @endcode
* The above example will skip further processing of the input property if
* the content_type source field equals "blog".
*
* Example usage with a FieldAPI value:
* @code
* field_fruit:
* plugin: skip_on_value
* source: field_fruit/0/value
* method: row
* value: apple
* @endcode
* The above example will skip the entire row if the "fruit" field is set to
* "apple". When attempting to access values from a simple Field API-based value
* the "0/value" suffix must be used, otherwise it will fail with an "Array to
* string conversion" error.
*
* Example usage with full configuration:
* @code
* type:
* plugin: skip_on_value
* not_equals: true
* source: content_type
* method: row
* value:
* - article
* - testimonial
* message: 'Not an article nor a testimonial content type'
* @endcode
* The above example will skip processing any row for which the source row's
* content type field is not "article" or "testimonial", and log the message 'Not
* an article nor a testimonial content type' to the message table.
*
* @codingStandardsIgnoreEnd
*/
class SkipOnValue extends ProcessPluginBase {
/**
* {@inheritdoc}
*/
public function __construct(array $configuration, $plugin_id, $plugin_definition) {
if (empty($configuration['value']) && !array_key_exists('value', $configuration)) {
throw new \InvalidArgumentException('Skip on value plugin is missing value configuration.');
}
parent::__construct($configuration, $plugin_id, $plugin_definition);
}
/**
* Skips the current row when input value evaluates to a configured value.
*
* @param mixed $value
* The input value.
* @param \Drupal\migrate\MigrateExecutableInterface $migrate_executable
* The migration in which this process is being executed.
* @param \Drupal\migrate\Row $row
* The row from the source to process.
* @param string $destination_property
* The destination property currently worked on. This is only used together
* with the $row above.
*
* @return mixed
* The input value, $value, if it doesn't evaluate to a configured value.
*
* @throws \Drupal\migrate\MigrateSkipRowException
* Thrown if the source property evaluates to a configured value and the
* row should be skipped, records with STATUS_IGNORED status in the map.
*/
public function row($value, MigrateExecutableInterface $migrate_executable, Row $row, string $destination_property) {
$message = !empty($this->configuration['message']) ? $this->configuration['message'] : '';
if (is_array($this->configuration['value'])) {
$value_in_array = FALSE;
$not_equals = isset($this->configuration['not_equals']);
foreach ($this->configuration['value'] as $skipValue) {
$value_in_array |= $this->compareValue($value, $skipValue);
}
if (($not_equals && !$value_in_array) || (!$not_equals && $value_in_array)) {
throw new MigrateSkipRowException($message);
}
}
elseif ($this->compareValue($value, $this->configuration['value'], !isset($this->configuration['not_equals']))) {
throw new MigrateSkipRowException($message);
}
return $value;
}
/**
* Stops processing the current property.
*
* Stop when input value evaluates to a configured value.
*
* @param mixed $value
* The input value.
* @param \Drupal\migrate\MigrateExecutableInterface $migrate_executable
* The migration in which this process is being executed.
* @param \Drupal\migrate\Row $row
* The row from the source to process.
* @param string $destination_property
* The destination property currently worked on. This is only used together
* with the $row above.
*
* @return mixed
* The input value, $value, if it doesn't evaluate to a configured value.
*
* @throws \Drupal\migrate\MigrateSkipProcessException
* Thrown if the source property evaluates to a configured value and rest
* of the process should be skipped.
*/
public function process($value, MigrateExecutableInterface $migrate_executable, Row $row, string $destination_property) {
if (is_array($this->configuration['value'])) {
$value_in_array = FALSE;
$not_equals = isset($this->configuration['not_equals']);
foreach ($this->configuration['value'] as $skipValue) {
$value_in_array |= $this->compareValue($value, $skipValue);
}
if (($not_equals && !$value_in_array) || (!$not_equals && $value_in_array)) {
throw new MigrateSkipProcessException();
}
}
elseif ($this->compareValue($value, $this->configuration['value'], !isset($this->configuration['not_equals']))) {
throw new MigrateSkipProcessException();
}
return $value;
}
/**
* Compare values to see if they are equal.
*
* @param mixed $value
* Actual value.
* @param mixed $skipValue
* Value to compare against.
* @param bool $equal
* Compare as equal or not equal.
*
* True if the compare successfully, FALSE otherwise.
*/
protected function compareValue($value, $skipValue, bool $equal = TRUE): bool {
if ($equal) {
return (string) $value == (string) $skipValue;
}
return (string) $value != (string) $skipValue;
}
}

View File

@@ -0,0 +1,146 @@
<?php
declare(strict_types = 1);
namespace Drupal\migrate_plus\Plugin\migrate\process;
use Drupal\migrate\MigrateExecutableInterface;
use Drupal\migrate\ProcessPluginBase;
use Drupal\migrate\Row;
/**
* Uses either str_replace() or preg_replace() function on a source string.
*
* Available configuration keys:
* - search: The value or pattern being searched for. It can be either a string
* or an array with strings.
* - replace: The replacement value that replaces found search values. It can be
* either a string or an array with strings.
* - regex: (optional) If not empty, then preg_replace() function will be used
* instead of str_replace(). Defaults to FALSE.
* - case_insensitive: (optional) If not empty, then str_ireplace() function
* will be used instead of str_replace(). Defaults to FALSE. Ignored if
* 'regex' is enabled.
*
* Depending on the value of 'regex', the rules for
* @link http://php.net/manual/function.str-replace.php str_replace @endlink
* or
* @link http://php.net/manual/function.preg-replace.php preg_replace @endlink
* apply. This means that you can provide arrays as values, your replace string
* can include backreferences, etc.
*
* To do a simple hardcoded string replace, use the following:
* @code
* field_text:
* plugin: str_replace
* source: text
* search: et
* replace: that
* @endcode
* If the value of text is "vero eos et accusam et justo vero" in source,
* field_text will be "vero eos that accusam that justo vero".
*
* Case-insensitive searches can be achieved using the following:
* @code
* field_text:
* plugin: str_replace
* case_insensitive: true
* source: text
* search: vero
* replace: that
* @endcode
* If the value of text is "VERO eos et accusam et justo vero" in source,
* field_text will be "that eos et accusam et justo that".
*
* Also, regular expressions can be matched using:
* @code
* field_text:
* plugin: str_replace
* regex: true
* source: text
* search: /[0-9]{3}/
* replace: the
* @endcode
* If the value of text is "vero eos et 123 accusam et justo 123 duo" in source,
* field_text will be "vero eos et the accusam et justo the duo".
*
* Multiple values can be matched like this:
* @code
* field_text:
* plugin: str_replace
* source: text
* search: ["AT", "CH", "DK"]
* replace: ["Austria", "Switzerland", "Denmark"]
* @endcode
*
* Replace with a regex backreference like this:
* @code
* field_text:
* plugin: str_replace
* regex: true
* source: text
* search: /@(\S+)/
* replace: $1
* @endcode
* If the value of text is "@username" in source, field_text will be "username".
*
* @MigrateProcessPlugin(
* id = "str_replace"
* )
*/
class StrReplace extends ProcessPluginBase {
/**
* Flag indicating whether there are multiple values.
*/
protected ?bool $multiple = NULL;
/**
* {@inheritdoc}
*/
public function __construct(array $configuration, $plugin_id, $plugin_definition) {
if (!isset($configuration['search'])) {
throw new \InvalidArgumentException('The "search" must be set.');
}
if (!isset($configuration['replace'])) {
throw new \InvalidArgumentException('The "replace" must be set.');
}
parent::__construct($configuration, $plugin_id, $plugin_definition);
}
/**
* {@inheritdoc}
*/
public function transform($value, MigrateExecutableInterface $migrate_executable, Row $row, $destination_property) {
$this->multiple = is_array($value);
$this->configuration += [
'case_insensitive' => FALSE,
'regex' => FALSE,
];
$function = 'str_replace';
if ($this->configuration['case_insensitive']) {
$function = 'str_ireplace';
}
if ($this->configuration['regex']) {
$function = 'preg_replace';
}
if($this->multiple) {
foreach($value as $key => $item) {
$item = (string) $item;
$value[$key] = $function($this->configuration['search'], $this->configuration['replace'], $item);
}
return $value;
}
$value = (string) $value;
return $function($this->configuration['search'], $this->configuration['replace'], $value);
}
/**
* {@inheritdoc}
*/
public function multiple(): bool {
return $this->multiple;
}
}

View File

@@ -0,0 +1,90 @@
<?php
declare(strict_types = 1);
namespace Drupal\migrate_plus\Plugin\migrate\process;
use Drupal\migrate\MigrateExecutableInterface;
use Drupal\migrate\Row;
use Drupal\migrate\ProcessPluginBase;
/**
* Exchange rows and columns.
*
* Examples:
*
* @code
* process:
* bar:
* -
* plugin: transpose
* source:
* - foo0
* - foo1
* - foo2
* @endcode
*
* This will create an array of 3-element, numerically indexed arrays. Each
* array will have one element from each of the source properties.
*
* @code
* process:
* field_link:
* -
* plugin: transpose
* source:
* - link_url
* - link_text
* -
* plugin: sub_process
* process:
* uri:
* -
* plugin: extract
* source:
* - 0
* index:
* - 0
* -
* plugin: link_uri
* validate_route: false
* title:
* -
* plugin: extract
* source:
* - 1
* index:
* - 0
* @endcode
*
* Suppose the source property link_url has the URL for three links, and the
* source property link_text has the corresponding link text:
* [url0, url1, url2] and [text0, text1, text2].
* Then the transpose plugin produces
* [[url0, text0], [url1, text1], [url2, text2]].
* Inside sub_process, the extract plugin in this example takes each
* [url, text] subarray and assigns uri: url, title: text.
*
* @MigrateProcessPlugin(
* id = "transpose"
* )
*/
class Transpose extends ProcessPluginBase {
/**
* {@inheritdoc}
*/
public function transform($table, MigrateExecutableInterface $migrate_executable, Row $row, $destination_property) {
// Make sure that $table is an array of arrays.
if (!is_array($table) || $table == []) {
return [];
}
foreach ($table as &$value) {
$value = (array) $value;
}
// @see https://stackoverflow.com/a/47718734/3130080
return array_map(NULL, ...$table);
}
}

View File

@@ -0,0 +1,62 @@
<?php
declare(strict_types = 1);
namespace Drupal\migrate_plus\Plugin\migrate\source;
use Drupal\migrate\Plugin\migrate\source\SourcePluginBase;
use Drupal\migrate\Plugin\MigrationInterface;
/**
* Generally-useful extensions to the core SourcePluginBase.
*/
abstract class SourcePluginExtension extends SourcePluginBase {
/**
* Information on the source fields to be extracted from the data.
*
* @var array[]
* Array of field information keyed by field names. A 'label' subkey
* describes the field for migration tools; a 'path' subkey provides the
* source-specific path for obtaining the value.
*/
protected $fields = [];
/**
* Description of the unique ID fields for this source.
*
* @var array[]
* Each array member is keyed by a field name, with a value that is an
* array with a single member with key 'type' and value a column type such
* as 'integer'.
*/
protected $ids = [];
/**
* {@inheritdoc}
*/
public function __construct(array $configuration, $plugin_id, $plugin_definition, MigrationInterface $migration) {
parent::__construct($configuration, $plugin_id, $plugin_definition, $migration);
$this->fields = $configuration['fields'];
$this->ids = $configuration['ids'];
}
/**
* {@inheritdoc}
*/
public function fields(): array {
$fields = [];
foreach ($this->fields as $field_info) {
$fields[$field_info['name']] = $field_info['label'] ?? $field_info['name'];
}
return $fields;
}
/**
* {@inheritdoc}
*/
public function getIds(): array {
return $this->ids;
}
}

View File

@@ -0,0 +1,164 @@
<?php
declare(strict_types = 1);
namespace Drupal\migrate_plus\Plugin\migrate\source;
use Drupal\Core\Database\Query\SelectInterface;
use Drupal\Core\State\StateInterface;
use Drupal\migrate\Exception\RequirementsException;
use Drupal\migrate\Plugin\migrate\source\SqlBase;
use Drupal\migrate\Plugin\MigrationInterface;
/**
* SQL table source plugin.
*
* Available configuration keys:
* - table_name: The base table name.
* - id_fields: Fields used by migrate to identify table rows uniquely. At least
* one field is required.
* - fields: (optional) An indexed array of columns present in the source table.
* Leave empty to retrieve all columns.
*
* Examples:
*
* @code
* source:
* plugin: table
* table_name: colors
* id_fields:
* color_name:
* type: string
* hex:
* type: string
* fields:
* color_name: color_name
* hex: hex
* @endcode
*
* In this example color data is retrieved from the source table.
*
* @code
* source:
* plugin: table
* table_name: autoban
* id_fields:
* type:
* type: string
* message:
* type: string
* threshold:
* type: integer
* user_type:
* type: integer
* ip_type:
* type: integer
* referer:
* type: string
* fields:
* type: type
* message: message
* threshold: threshold
* user_type: user_type
* ip_type: ip_type
* referer: referer
* @endcode
*
* In this example shows how to retrieve data from autoban source table.
*
* For additional configuration keys, refer to the parent classes.
*
* @see \Drupal\migrate\Plugin\migrate\source\SqlBase
*
* @MigrateSource(
* id = "table"
* )
*/
class Table extends SqlBase {
/**
* Table alias.
*
* @var string
*/
public const TABLE_ALIAS = 't';
/**
* The name of the destination table.
*
* @var string
*/
protected string $tableName;
/**
* IDMap compatible array of id fields.
*
* @var array
*/
protected array $idFields;
/**
* Array of fields present on the destination table.
*
* @var array
*/
protected array $fields;
/**
* {@inheritdoc}
*/
public function __construct(array $configuration, $plugin_id, $plugin_definition, MigrationInterface $migration, StateInterface $state) {
if (empty($configuration['table_name'])) {
throw new \InvalidArgumentException('Table plugin is missing table_name property configuration.');
}
if (!array_key_exists('id_fields', $configuration)) {
throw new \InvalidArgumentException('Table plugin is missing id_fields property configuration.');
}
if (!is_array($configuration['id_fields'])) {
throw new \InvalidArgumentException('Table plugin configuration property id_fields must be an array.');
}
if (array_key_exists('fields', $configuration) and !is_array($configuration['fields'])) {
throw new \InvalidArgumentException('Table plugin configuration property fields must be an array.');
}
parent::__construct($configuration, $plugin_id, $plugin_definition, $migration, $state);
$this->tableName = $configuration['table_name'];
// Insert alias in id_fields.
foreach ($configuration['id_fields'] as &$field) {
$field['alias'] = static::TABLE_ALIAS;
}
$this->idFields = $configuration['id_fields'];
$this->fields = $configuration['fields'] ?? [];
}
/**
* {@inheritdoc}
*/
public function query(): SelectInterface {
return $this->select($this->tableName, static::TABLE_ALIAS)->fields(static::TABLE_ALIAS, $this->fields);
}
/**
* {@inheritdoc}
*/
public function fields(): array {
return $this->fields;
}
/**
* {@inheritdoc}
*/
public function getIds(): array {
return $this->idFields;
}
/**
* {@inheritdoc}
*/
public function checkRequirements(): void {
if (!$this->getDatabase()->schema()->tableExists($this->tableName)) {
throw new RequirementsException("Source database table '{$this->tableName}' does not exist", ['source_table' => $this->tableName]);
}
parent::checkRequirements();
}
}

View File

@@ -0,0 +1,79 @@
<?php
declare(strict_types = 1);
namespace Drupal\migrate_plus\Plugin\migrate\source;
use Drupal\migrate_plus\DataParserPluginInterface;
use Drupal\migrate\Plugin\MigrationInterface;
/**
* Source plugin for retrieving data via URLs.
*
* @MigrateSource(
* id = "url"
* )
*/
class Url extends SourcePluginExtension {
/**
* The source URLs to retrieve.
*
* @var array
*/
protected array $sourceUrls = [];
/**
* The data parser plugin.
*
* @var \Drupal\migrate_plus\DataParserPluginInterface
*/
protected DataParserPluginInterface $dataParserPlugin;
/**
* {@inheritdoc}
*/
public function __construct(array $configuration, $plugin_id, $plugin_definition, MigrationInterface $migration) {
if (!is_array($configuration['urls'])) {
$configuration['urls'] = [$configuration['urls']];
}
parent::__construct($configuration, $plugin_id, $plugin_definition, $migration);
$this->sourceUrls = $configuration['urls'];
}
/**
* Return a string representing the source URLs.
*
* @return string
* Comma-separated list of URLs being imported.
*/
public function __toString(): string {
// This could cause a problem when using a lot of urls, may need to hash.
$urls = implode(', ', $this->sourceUrls);
return $urls;
}
/**
* Returns the initialized data parser plugin.
*
* The data parser plugin.
*/
public function getDataParserPlugin(): DataParserPluginInterface {
if (!isset($this->dataParserPlugin)) {
$this->dataParserPlugin = \Drupal::service('plugin.manager.migrate_plus.data_parser')->createInstance($this->configuration['data_parser_plugin'], $this->configuration);
}
return $this->dataParserPlugin;
}
/**
* Creates and returns a filtered Iterator over the documents.
*
* An iterator over the documents providing source rows that match the
* configured item_selector.
*/
protected function initializeIterator(): DataParserPluginInterface {
return $this->getDataParserPlugin();
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types = 1);
namespace Drupal\migrate_plus\Plugin\migrate_plus\authentication;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\migrate_plus\AuthenticationPluginBase;
/**
* Provides basic authentication for the HTTP resource.
*
* @Authentication(
* id = "basic",
* title = @Translation("Basic")
* )
*/
class Basic extends AuthenticationPluginBase implements ContainerFactoryPluginInterface {
/**
* {@inheritdoc}
*/
public function getAuthenticationOptions(): array {
return [
'auth' => [
$this->configuration['username'],
$this->configuration['password'],
],
];
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types = 1);
namespace Drupal\migrate_plus\Plugin\migrate_plus\authentication;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\migrate_plus\AuthenticationPluginBase;
/**
* Provides digest authentication for the HTTP resource.
*
* @Authentication(
* id = "digest",
* title = @Translation("Digest")
* )
*/
class Digest extends AuthenticationPluginBase implements ContainerFactoryPluginInterface {
/**
* {@inheritdoc}
*/
public function getAuthenticationOptions(): array {
return [
'auth' => [
$this->configuration['username'],
$this->configuration['password'],
'digest',
],
];
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types = 1);
namespace Drupal\migrate_plus\Plugin\migrate_plus\authentication;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\migrate_plus\AuthenticationPluginBase;
/**
* Provides NTLM (Microsoft NTLM) authentication for the HTTP resource.
*
* @Authentication(
* id = "ntlm",
* title = @Translation("Ntlm")
* )
*/
class Ntlm extends AuthenticationPluginBase implements ContainerFactoryPluginInterface {
/**
* {@inheritdoc}
*/
public function getAuthenticationOptions(): array {
return [
'auth' => [
$this->configuration['username'],
$this->configuration['password'],
'ntlm',
],
];
}
}

View File

@@ -0,0 +1,76 @@
<?php
declare(strict_types = 1);
namespace Drupal\migrate_plus\Plugin\migrate_plus\authentication;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\migrate\MigrateException;
use Drupal\migrate_plus\AuthenticationPluginBase;
use GuzzleHttp\Client;
use GuzzleHttp\HandlerStack;
use Sainsburys\Guzzle\Oauth2\GrantType\AuthorizationCode;
use Sainsburys\Guzzle\Oauth2\GrantType\ClientCredentials;
use Sainsburys\Guzzle\Oauth2\GrantType\JwtBearer;
use Sainsburys\Guzzle\Oauth2\GrantType\PasswordCredentials;
use Sainsburys\Guzzle\Oauth2\GrantType\RefreshToken;
use Sainsburys\Guzzle\Oauth2\Middleware\OAuthMiddleware;
/**
* Provides OAuth2 authentication for the HTTP resource.
*
* @link https://packagist.org/packages/sainsburys/guzzle-oauth2-plugin
*
* @Authentication(
* id = "oauth2",
* title = @Translation("OAuth2")
* )
*/
class OAuth2 extends AuthenticationPluginBase implements ContainerFactoryPluginInterface {
/**
* {@inheritdoc}
*/
public function getAuthenticationOptions(): array {
$handlerStack = HandlerStack::create();
$client = new Client([
'handler' => $handlerStack,
'base_uri' => $this->configuration['base_uri'],
'auth' => 'oauth2',
]);
switch ($this->configuration['grant_type']) {
case 'authorization_code':
$grant_type = new AuthorizationCode($client, $this->configuration);
break;
case 'client_credentials':
$grant_type = new ClientCredentials($client, $this->configuration);
break;
case 'urn:ietf:params:oauth:grant-type:jwt-bearer':
$grant_type = new JwtBearer($client, $this->configuration);
break;
case 'password':
$grant_type = new PasswordCredentials($client, $this->configuration);
break;
case 'refresh_token':
$grant_type = new RefreshToken($client, $this->configuration);
break;
default:
throw new MigrateException("Unrecognized grant_type {$this->configuration['grant_type']}.");
}
$middleware = new OAuthMiddleware($client, $grant_type);
return [
'headers' => [
'Authorization' => 'Bearer ' . $middleware->getAccessToken()->getToken(),
],
];
}
}

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types = 1);
namespace Drupal\migrate_plus\Plugin\migrate_plus\data_fetcher;
use GuzzleHttp\Psr7\Response;
use Psr\Http\Message\ResponseInterface;
use Drupal\migrate\MigrateException;
use Drupal\migrate_plus\DataFetcherPluginBase;
/**
* Retrieve data from a local path or general URL for migration.
*
* @DataFetcher(
* id = "file",
* title = @Translation("File")
* )
*/
class File extends DataFetcherPluginBase {
/**
* {@inheritdoc}
*/
public function setRequestHeaders(array $headers): void {
// Does nothing.
}
/**
* {@inheritdoc}
*/
public function getRequestHeaders(): array {
// Does nothing.
return [];
}
/**
* {@inheritdoc}
*/
public function getResponse($url): ResponseInterface {
$response = FALSE;
if (!empty($url)) {
$response = @file_get_contents($url);
}
if ($response === FALSE) {
throw new MigrateException('file parser plugin: could not retrieve data from ' . $url);
}
return new Response(200, [], $response);
}
/**
* {@inheritdoc}
*/
public function getResponseContent(string $url): string {
return (string) $this->getResponse($url)->getBody();
}
}

View File

@@ -0,0 +1,152 @@
<?php
declare(strict_types = 1);
namespace Drupal\migrate_plus\Plugin\migrate_plus\data_fetcher;
use Drupal\Component\Utility\NestedArray;
use GuzzleHttp\Client;
use Drupal\migrate_plus\AuthenticationPluginInterface;
use Psr\Http\Message\ResponseInterface;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\migrate\MigrateException;
use Drupal\migrate_plus\DataFetcherPluginBase;
use GuzzleHttp\Exception\RequestException;
/**
* Retrieve data over an HTTP connection for migration.
*
* Example:
*
* @code
* source:
* plugin: url
* data_fetcher_plugin: http
* headers:
* Accept: application/json
* User-Agent: Internet Explorer 6
* Authorization-Key: secret
* Arbitrary-Header: foobarbaz
* # Guzzle request options can be added.
* # See https://docs.guzzlephp.org/en/stable/request-options.html
* request_options:
* timeout: 300
* allow_redirects: false
* @endcode
*
* @DataFetcher(
* id = "http",
* title = @Translation("HTTP")
* )
*/
class Http extends DataFetcherPluginBase implements ContainerFactoryPluginInterface {
/**
* The HTTP client.
*/
protected ?Client $httpClient;
/**
* The request headers.
*/
protected array $headers = [];
/**
* The data retrieval client.
*/
protected AuthenticationPluginInterface $authenticationPlugin;
/**
* {@inheritdoc}
*/
public function __construct(array $configuration, $plugin_id, $plugin_definition) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
$this->httpClient = \Drupal::httpClient();
// Ensure there is a 'headers' key in the configuration.
$configuration += ['headers' => []];
$this->setRequestHeaders($configuration['headers']);
// Set GET request-method by default.
$configuration += ['method' => 'GET'];
$this->configuration['method'] = $configuration['method'];
}
/**
* Returns the initialized authentication plugin.
*
* The authentication plugin.
*/
public function getAuthenticationPlugin(): AuthenticationPluginInterface {
if (!isset($this->authenticationPlugin)) {
$this->authenticationPlugin = \Drupal::service('plugin.manager.migrate_plus.authentication')->createInstance($this->configuration['authentication']['plugin'], $this->configuration['authentication']);
}
return $this->authenticationPlugin;
}
/**
* {@inheritdoc}
*/
public function setRequestHeaders(array $headers): void {
$this->headers = $headers;
}
/**
* {@inheritdoc}
*/
public function getRequestHeaders(): array {
return !empty($this->headers) ? $this->headers : [];
}
/**
* {@inheritdoc}
*/
public function getResponse($url): ResponseInterface {
try {
$options = ['headers' => $this->getRequestHeaders()];
if (!empty($this->configuration['authentication'])) {
$options = NestedArray::mergeDeep($options, $this->getAuthenticationPlugin()->getAuthenticationOptions());
}
if (!empty($this->configuration['request_options'])) {
$options = NestedArray::mergeDeep($options, $this->configuration['request_options']);
}
$method = $this->configuration['method'] ?? 'GET';
$response = $this->httpClient->request($method, $url, $options);
if (empty($response)) {
throw new MigrateException('No response at ' . $url . '.');
}
}
catch (RequestException $e) {
throw new MigrateException('Error message: ' . $e->getMessage() . ' at ' . $url . '.');
}
return $response;
}
/**
* {@inheritdoc}
*/
public function getResponseContent(string $url): string {
return (string) $this->getResponse($url)->getBody();
}
/**
* {@inheritdoc}
*/
public function getNextUrls(string $url): array {
$next_urls = [];
$headers = $this->getResponse($url)->getHeader('Link');
if (!empty($headers)) {
$headers = explode(',', $headers[0]);
foreach ($headers as $header) {
$matches = [];
preg_match('/^<(.*)>; rel="next"$/', trim($header), $matches);
if (!empty($matches) && !empty($matches[1])) {
$next_urls[] = $matches[1];
}
}
}
return array_merge(parent::getNextUrls($url), $next_urls);
}
}

View File

@@ -0,0 +1,328 @@
<?php
declare(strict_types = 1);
namespace Drupal\migrate_plus\Plugin\migrate_plus\data_parser;
use Drupal\Component\Utility\UrlHelper;
use Drupal\Core\Url;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\migrate\MigrateException;
use Drupal\migrate_plus\DataParserPluginBase;
/**
* Obtain JSON data for migration.
*
* @DataParser(
* id = "json",
* title = @Translation("JSON")
* )
*/
class Json extends DataParserPluginBase implements ContainerFactoryPluginInterface {
/**
* Iterator over the JSON data.
*/
protected ?\ArrayIterator $iterator = NULL;
/**
* The currently saved source url (as a string).
*
* @var string
*/
protected $currentUrl;
/**
* The active url's source data.
*
* @var array
*/
protected $sourceData;
/**
* Retrieves the JSON data and returns it as an array.
*
* @param string $url
* URL of a JSON feed.
* @param string|int $item_selector
* Selector within the data content at which useful data is found.
*
* @throws \GuzzleHttp\Exception\RequestException
*/
protected function getSourceData(string $url, string|int $item_selector = '') {
// Use cached source data if this is the first request or URL is same as the
// last time we made the request.
if ($this->currentUrl != $url || !$this->sourceData) {
$response = $this->getDataFetcherPlugin()->getResponseContent($url);
// Convert objects to associative arrays.
$this->sourceData = json_decode($response, TRUE);
// If json_decode() has returned NULL, it might be that the data isn't
// valid utf8 - see http://php.net/manual/en/function.json-decode.php#86997.
if (!$this->sourceData) {
$utf8response = mb_convert_encoding($response, 'UTF-8');
$this->sourceData = json_decode($utf8response, TRUE);
}
$this->currentUrl = $url;
}
// Backwards-compatibility for depth selection.
if (is_numeric($this->itemSelector)) {
return $this->selectByDepth($this->sourceData, (int) $item_selector);
}
// If the item_selector is an empty string, return all.
if ($item_selector === '') {
return $this->sourceData;
}
// Otherwise, we're using xpath-like selectors.
$selectors = explode('/', trim($item_selector, '/'));
$return = $this->sourceData;
foreach ($selectors as $selector) {
// If the item_selector is missing, return an empty array.
if (!isset($return[$selector])) {
return [];
}
$return = $return[$selector];
}
return $return;
}
/**
* Get the source data for reading.
*
* @param array $raw_data
* Raw data from the JSON feed.
* @param int $item_selector
* Depth within the data content at which useful data is found.
*
* Selected items at the requested depth of the JSON feed.
*/
protected function selectByDepth(array $raw_data, int $item_selector = 0): array {
// Return the results in a recursive iterator that can traverse
// multidimensional arrays.
$iterator = new \RecursiveIteratorIterator(
new \RecursiveArrayIterator($raw_data),
\RecursiveIteratorIterator::SELF_FIRST);
$items = [];
// Backwards-compatibility - an integer item_selector is interpreted as a
// depth. When there is an array of items at the expected depth, pull that
// array out as a distinct item.
$identifierDepth = $item_selector;
$iterator->rewind();
while ($iterator->valid()) {
$item = $iterator->current();
if (is_array($item) && $iterator->getDepth() === $identifierDepth) {
$items[] = $item;
}
$iterator->next();
}
return $items;
}
/**
* {@inheritdoc}
*/
protected function openSourceUrl(string $url): bool {
// (Re)open the provided URL.
$source_data = $this->getSourceData($url, $this->itemSelector);
// Ensure there is source data at the current url.
if (is_null($source_data)) {
return FALSE;
}
$this->iterator = new \ArrayIterator($source_data);
return TRUE;
}
/**
* {@inheritdoc}
*/
protected function fetchNextRow(): void {
$current = $this->iterator->current();
if (is_array($current)) {
foreach ($this->fieldSelectors() as $field_name => $selector) {
$field_data = $current;
$field_selectors = explode('/', trim((string) $selector, '/'));
foreach ($field_selectors as $field_selector) {
if (is_array($field_data) && array_key_exists($field_selector, $field_data)) {
$field_data = $field_data[$field_selector];
}
else {
$field_data = '';
}
}
$this->currentItem[$field_name] = $field_data;
}
if (!empty($this->configuration['include_raw_data'])) {
$this->currentItem['raw'] = $current;
}
$this->iterator->next();
}
}
/**
* {@inheritdoc}
*/
protected function getNextUrls(string $url): array {
$next_urls = [];
// If a pager selector is provided, get the data from the source.
$selector_data = NULL;
if (!empty($this->configuration['pager']['selector'])) {
$selector_data = $this->getSourceData($url, $this->configuration['pager']['selector']);
}
// Logic for each type of pager.
switch ($this->configuration['pager']['type']) {
case 'urls':
if (NULL !== $selector_data) {
if (is_array($selector_data)) {
$next_urls = $selector_data;
}
elseif (filter_var($selector_data, FILTER_VALIDATE_URL)) {
$next_urls[] = $selector_data;
}
}
break;
case 'cursor':
if (NULL !== $selector_data && is_scalar($selector_data)) {
// Just use 'cursor' as a default parameter key if not provided.
$key = !empty($this->configuration['pager']['key']) ? $this->configuration['pager']['key'] : 'cursor';
// Parse the url and replace the cursor param value and rebuild the url.
$path = UrlHelper::parse($url);
$path['query'][$key] = $selector_data;
$next_urls[] = Url::fromUri($path['path'], [
'query' => $path['query'],
'fragment' => $path['fragment'],
])->toString();
}
break;
case 'page':
if (NULL !== $selector_data && is_scalar($selector_data)) {
// Just use 'page' as a default parameter key if not provided.
$key = !empty($this->configuration['pager']['key']) ? $this->configuration['pager']['key'] : 'page';
// Define the max page to generate.
$max = $selector_data + 1;
if (!empty($this->configuration['pager']['selector_max'])) {
$max = $this->getSourceData($url, $this->configuration['pager']['selector_max']);
}
// Parse the url and replace the page param value and rebuild the url.
$path = UrlHelper::parse($url);
for ($page = $selector_data + 1; $page < $max; ++$page) {
$path['query'][$key] = $page;
$next_urls[] = Url::fromUri($path['path'], [
'query' => $path['query'],
'fragment' => $path['fragment'],
])->toString();
}
}
break;
case 'paginator':
// The first pass uses the endpoint's default size.
// @todo Handle first URL set page size on first pass.
if (!isset($this->configuration['pager']['default_num_items'])) {
throw new MigrateException('Pager "default_num_items" must be configured.');
}
$num_items = $this->configuration['pager']['default_num_items'];
// Use 'page' as a default page parameter key if not provided.
$page_key = !empty($this->configuration['pager']['page_key']) ? $this->configuration['pager']['page_key'] : 'page';
// Set default paginator type.
$paginator_type_options = ['page_number', 'starting_item'];
$paginator_type = $paginator_type_options[0];
// Check configured paginator type.
if (!empty($this->configuration['pager']['paginator_type'])) {
if (!in_array($this->configuration['pager']['paginator_type'], $paginator_type_options)) {
// Not set to one of the two available options.
throw new MigrateException(
'Pager "paginator_type" must be configured as either "page_number" or "starting_item" ("page_number" is default).'
);
}
$paginator_type = $this->configuration['pager']['paginator_type'];
}
// Use 'pagesize' as a default page parameter key if not provided.
$size_key = !empty($this->configuration['pager']['size_key']) ? $this->configuration['pager']['size_key'] : 'pagesize';
// Parse the url.
$path = UrlHelper::parse($url);
$curr_page = !empty($path['query'][$page_key]) ? $path['query'][$page_key] : 0;
// @todo Use core's QueryBase and pager.
// @see contrib module external_entities \Entity\Query\External\Query.php for example.
$next_start = $curr_page + $num_items;
$next_end = $num_items;
// Use "page_number" when the pager uses page numbers to determine
// the item to start at, use "starting_item" when the pager uses the
// item number to start at.
if ($paginator_type === 'page_number') {
$next_start = $curr_page + 1;
}
// Replace the paginator param value.
$path['query'][$page_key] = $next_start;
// Replace the size param value.
$path['query'][$size_key] = $next_end;
// If we have a selector that tells us the number of rows returned in
// the current request, use that to decide if we should add the next
// url to the array.
if (NULL !== $selector_data) {
if (is_scalar($selector_data)) {
// If we have a numeric number of rows and the current page is still
// a full page (i.e. the number of items, $selector_data, in this
// page equals the number of items configured, $num_items), advance
// to the next page.
if ($selector_data == $num_items) {
$next_urls[] = Url::fromUri($path['path'], [
'query' => $path['query'],
'fragment' => $path['fragment'],
])->toString();
}
}
else {
// If we have an array of rows
if (count($selector_data) > 0) {
$next_urls[] = Url::fromUri($path['path'], [
'query' => $path['query'],
'fragment' => $path['fragment'],
])->toString();
}
}
}
else {
// Rebuild the url.
$next_urls[] = Url::fromUri($path['path'], [
'query' => $path['query'],
'fragment' => $path['fragment'],
])->toString();
// Service may return 404 for last page, ensure next_urls are valid.
foreach ($next_urls as $key => $next_url) {
try {
$response = $this->getDataFetcherPlugin()->getResponse($next_url);
if ($response->getStatusCode() !== 200) {
unset($next_urls[$key]);
}
}
catch (\Exception $e) {
unset($next_urls[$key]);
}
}
}
break;
}
return array_merge(parent::getNextUrls($url), $next_urls);
}
}

View File

@@ -0,0 +1,94 @@
<?php
declare(strict_types = 1);
namespace Drupal\migrate_plus\Plugin\migrate_plus\data_parser;
use Drupal\migrate\MigrateException;
use Drupal\migrate_plus\DataParserPluginBase;
/**
* Obtain XML data for migration using the SimpleXML API.
*
* SimpleXML parses the whole file into memory, which allows using XPath
* expression in the item selector. For large XML sources it results in
* consuming lots of memory, which can be undesirable. If you run into memory
* issues, then consider using the 'xml' data parser.
*
* @DataParser(
* id = "simple_xml",
* title = @Translation("Simple XML")
* )
*/
class SimpleXml extends DataParserPluginBase {
use XmlTrait;
/**
* Array of matches from item_selector.
*
* @var \SimpleXMLElement[]|bool
*/
protected $matches = [];
/**
* {@inheritdoc}
*/
public function __construct(array $configuration, $plugin_id, $plugin_definition) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
// Suppress errors during parsing, so we can pick them up after.
libxml_use_internal_errors(TRUE);
}
/**
* {@inheritdoc}
*/
protected function openSourceUrl($url): bool {
// Clear XML error buffer. Other Drupal code that executed during the
// migration may have polluted the error buffer and could create false
// positives in our error check below. We are only concerned with errors
// that occur from attempting to load the XML string into an object here.
libxml_clear_errors();
$xml_data = $this->getDataFetcherPlugin()->getResponseContent($url);
$xml = simplexml_load_string(trim($xml_data));
foreach (libxml_get_errors() as $error) {
$error_string = self::parseLibXmlError($error);
throw new MigrateException($error_string);
}
$this->registerNamespaces($xml);
$xpath = $this->configuration['item_selector'];
$this->matches = $xml->xpath($xpath);
return TRUE;
}
/**
* {@inheritdoc}
*/
protected function fetchNextRow(): void {
$target_element = array_shift($this->matches);
// If we've found the desired element, populate the currentItem and
// currentId with its data.
if ($target_element !== FALSE && !is_null($target_element)) {
foreach ($this->fieldSelectors() as $field_name => $xpath) {
foreach ($target_element->xpath($xpath) as $value) {
if ($value->children() && !trim((string) $value)) {
$this->currentItem[$field_name][] = $value;
}
else {
$this->currentItem[$field_name][] = (string) $value;
}
}
}
// Reduce single-value results to scalars.
foreach ($this->currentItem as $field_name => $values) {
if (is_array($values) && count($values) == 1) {
$this->currentItem[$field_name] = reset($values);
}
}
}
}
}

View File

@@ -0,0 +1,127 @@
<?php
declare(strict_types = 1);
namespace Drupal\migrate_plus\Plugin\migrate_plus\data_parser;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\migrate\Exception\RequirementsException;
use Drupal\migrate\MigrateException;
use Drupal\migrate_plus\DataParserPluginBase;
/**
* Obtain SOAP data for migration.
*
* @DataParser(
* id = "soap",
* title = @Translation("SOAP")
* )
*/
class Soap extends DataParserPluginBase implements ContainerFactoryPluginInterface {
/**
* Iterator over the SOAP data.
*/
protected ?\ArrayIterator $iterator = NULL;
/**
* Method to call on the SOAP service.
*/
protected string $function;
/**
* Parameters to pass to the SOAP service function.
*/
protected array $parameters;
/**
* Form of the function response - 'xml', 'object', or 'array'.
*/
protected string $responseType;
/**
* {@inheritdoc}
*
* @throws \Drupal\migrate\Exception\RequirementsException
* If PHP SOAP extension is not installed.
*/
public function __construct(array $configuration, $plugin_id, $plugin_definition) {
if (!class_exists('\SoapClient')) {
throw new RequirementsException('The PHP SOAP extension is not installed');
}
parent::__construct($configuration, $plugin_id, $plugin_definition);
$this->function = $configuration['function'];
$this->parameters = $configuration['parameters'];
$this->responseType = $configuration['response_type'];
}
/**
* {@inheritdoc}
*
* @throws \SoapFault
* If there's an error in a SOAP call.
* @throws \Drupal\migrate\MigrateException
* If we can't resolve the SOAP function or its response property.
*/
protected function openSourceUrl($url): bool {
// Will throw SoapFault if there's an error in a SOAP call.
$client = new \SoapClient($url);
// Determine the response property name.
$function_found = FALSE;
foreach ($client->__getFunctions() as $function_signature) {
// E.g., "GetWeatherResponse GetWeather(GetWeather $parameters)".
$response_type = strtok($function_signature, ' ');
$function_name = strtok('(');
if (strcasecmp($function_name, $this->function) === 0) {
$function_found = TRUE;
foreach ($client->__getTypes() as $type_info) {
// E.g., "struct GetWeatherResponse {\n string GetWeatherResult;\n}".
if (preg_match('|struct (.*?) {\s*[a-z]+ (.*?);|is', $type_info, $matches)) {
if ($matches[1] == $response_type) {
$response_property = $matches[2];
}
}
}
break;
}
}
if (!$function_found) {
throw new MigrateException("SOAP function {$this->function} not found.");
}
elseif (!isset($response_property)) {
throw new MigrateException("Response property not found for SOAP function {$this->function}.");
}
$response = $client->{$this->function}($this->parameters);
$response_value = $response->$response_property;
switch ($this->responseType) {
case 'xml':
$xml = simplexml_load_string($response_value);
$this->iterator = new \ArrayIterator($xml->xpath($this->itemSelector));
break;
case 'object':
$this->iterator = new \ArrayIterator($response_value->{$this->itemSelector});
break;
case 'array':
$this->iterator = new \ArrayIterator($response_value[$this->itemSelector]);
break;
}
return TRUE;
}
/**
* {@inheritdoc}
*/
protected function fetchNextRow(): void {
$current = $this->iterator->current();
if ($current) {
foreach ($this->fieldSelectors() as $field_name => $selector) {
$this->currentItem[$field_name] = $current->$selector;
}
$this->iterator->next();
}
}
}

View File

@@ -0,0 +1,354 @@
<?php
declare(strict_types = 1);
namespace Drupal\migrate_plus\Plugin\migrate_plus\data_parser;
use Drupal\Core\File\FileSystemInterface;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\migrate\MigrateException;
use Drupal\migrate_plus\DataParserPluginBase;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Obtain XML data for migration using the XMLReader pull parser.
*
* XMLReader reader performs incremental parsing of an XML file. This allows
* parsing very large XML sources (e.g. 200MB WordPress dumps), which reduces
* the memory usage and increases the performance. The disadvantage is that it's
* not possible to use XPath search across the entire source.
*
* @DataParser(
* id = "xml",
* title = @Translation("XML")
* )
*/
class Xml extends DataParserPluginBase implements ContainerFactoryPluginInterface {
use XmlTrait;
/**
* The XMLReader we are encapsulating.
*/
protected \XMLReader $reader;
/**
* The file system service.
*/
protected FileSystemInterface $fileSystem;
/**
* Array of the element names from the query.
*
* 0-based from the first (root) element. For example, '//file/article' would
* be stored as [0 => 'file', 1 => 'article'].
*/
protected array $elementsToMatch = [];
/**
* An optional xpath predicate.
*
* Restricts the matching elements based on values in their children. Parsed
* from the element query at construct time.
*/
protected ?string $xpathPredicate = NULL;
/**
* Array representing the path to the current element as we traverse the XML.
*
* For example, if in an XML string like '<file><article>...</article></file>'
* we are positioned within the article element, currentPath will be
* [0 => 'file', 1 => 'article'].
*/
protected array $currentPath = [];
/**
* Retains all elements with a given name to support extraction from parents.
*
* This is a hack to support field extraction of values in parents
* of the 'context node' - ie, if $this->fields() has something like '..\nid'.
* Since we are using a streaming xml processor, it is too late to snoop
* around parent elements again once we've located an element of interest. So,
* grab elements with matching names and their depths, and refer back to it
* when building the source row.
*/
protected array $parentXpathCache = [];
/**
* Hash of the element names that should be captured into $parentXpathCache.
*/
protected array $parentElementsOfInterest = [];
/**
* Element name matching mode.
*
* When matching element names, whether to compare to the namespace-prefixed
* name, or the local name.
*/
protected bool $prefixedName = FALSE;
/**
* Constructs a new XML data parser.
*
* @param array $configuration
* A configuration array containing information about the plugin instance.
* @param string $plugin_id
* The plugin_id for the plugin instance.
* @param mixed $plugin_definition
* The plugin implementation definition.
* @param \Drupal\Core\File\FileSystemInterface $file_system
* The file system service.
*/
public function __construct(array $configuration, $plugin_id, $plugin_definition, FileSystemInterface $file_system) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
$this->fileSystem = $file_system;
$this->reader = new \XMLReader();
// Suppress errors during parsing, so we can pick them up after.
libxml_use_internal_errors(TRUE);
// Parse the element query. First capture group is the element path, second
// (if present) is the attribute.
preg_match_all('|^/([^\[]+)\[?(.*?)]?$|', $configuration['item_selector'], $matches);
$element_path = $matches[1][0];
$this->elementsToMatch = explode('/', $element_path);
$predicate = $matches[2][0];
if ($predicate) {
$this->xpathPredicate = $predicate;
}
// If the element path contains any colons, it must be specifying
// namespaces, so we need to compare using the prefixed element
// name in next().
if (strpos($element_path, ':')) {
$this->prefixedName = TRUE;
}
foreach ($this->fieldSelectors() as $field_name => $xpath) {
$prefix = substr($xpath, 0, 3);
if ($prefix === '../') {
$this->parentElementsOfInterest[] = str_replace('../', '', $xpath);
}
elseif ($prefix === '..\\') {
$this->parentElementsOfInterest[] = str_replace('..\\', '', $xpath);
}
}
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): DataParserPluginBase {
return new static(
$configuration,
$plugin_id,
$plugin_definition,
$container->get('file_system')
);
}
/**
* Builds a \SimpleXmlElement rooted at the iterator's current location.
*
* The resulting SimpleXmlElement also contains any child nodes of the current
* element.
*
* @return \SimpleXmlElement|null
* A \SimpleXmlElement when the document is parseable, or null if a
* parsing error occurred.
*
* @throws \Drupal\migrate\MigrateException
*/
protected function getSimpleXml(): ?\SimpleXMLElement {
$node = $this->reader->expand();
if ($node) {
// We must associate the DOMNode with a DOMDocument to be able to import
// it into SimpleXML. Despite appearances, this is almost twice as fast as
// simplexml_load_string($this->readOuterXML());
$dom = new \DOMDocument();
$node = $dom->importNode($node, TRUE);
$dom->appendChild($node);
$sxml_elem = simplexml_import_dom($node);
$this->registerNamespaces($sxml_elem);
return $sxml_elem;
}
else {
foreach (libxml_get_errors() as $error) {
$error_string = self::parseLibXmlError($error);
throw new MigrateException($error_string);
}
return NULL;
}
}
/**
* {@inheritdoc}
*/
public function rewind(): void {
// Reset our path tracker.
$this->currentPath = [];
parent::rewind();
}
/**
* {@inheritdoc}
*/
protected function openSourceUrl($url): bool {
// (Re)open the provided URL.
$this->reader->close();
// Fetch the data and save it to a temporary file.
$xml_data = $this->getDataFetcherPlugin()->getResponseContent($url);
$url = $this->fileSystem->tempnam('temporary://', 'file');
if (file_put_contents($url, $xml_data) === FALSE) {
throw new MigrateException('Unable to save temporary XML');
}
// Clear XML error buffer. Other Drupal code that executed during the
// migration may have polluted the error buffer and could create false
// positives in our error check below. We are only concerned with errors
// that occur from attempting to load the XML string into an object here.
libxml_clear_errors();
return $this->reader->open($url, NULL, \LIBXML_NOWARNING);
}
/**
* {@inheritdoc}
*/
protected function fetchNextRow(): void {
$target_element = NULL;
// Loop over each node in the XML file, looking for elements at a path
// matching the input query string (represented in $this->elementsToMatch).
while ($this->reader->read()) {
if ($this->reader->nodeType == \XMLReader::ELEMENT) {
if ($this->prefixedName) {
$this->currentPath[$this->reader->depth] = $this->reader->name;
if (in_array($this->reader->name, $this->parentElementsOfInterest)) {
$this->parentXpathCache[$this->reader->depth][$this->reader->name][] = $this->getSimpleXml();
}
}
else {
$this->currentPath[$this->reader->depth] = $this->reader->localName;
if (in_array($this->reader->localName, $this->parentElementsOfInterest)) {
$this->parentXpathCache[$this->reader->depth][$this->reader->name][] = $this->getSimpleXml();
}
}
if ($this->currentPath == $this->elementsToMatch) {
// We're positioned to the right element path - build the SimpleXML
// object to enable proper xpath predicate evaluation.
$target_element = $this->getSimpleXml();
if ($target_element !== NULL) {
if (empty($this->xpathPredicate) || $this->predicateMatches($target_element)) {
break;
}
}
}
}
elseif ($this->reader->nodeType == \XMLReader::END_ELEMENT) {
// Remove this element and any deeper ones from the current path.
foreach ($this->currentPath as $depth => $name) {
if ($depth >= $this->reader->depth) {
unset($this->currentPath[$depth]);
}
}
foreach ($this->parentXpathCache as $depth => $elements) {
if ($depth > $this->reader->depth) {
unset($this->parentXpathCache[$depth]);
}
}
}
}
// If we've found the desired element, populate the currentItem and
// currentId with its data.
if ($target_element !== FALSE && !is_null($target_element)) {
foreach ($this->fieldSelectors() as $field_name => $xpath) {
$prefix = substr($xpath, 0, 3);
if (in_array($prefix, ['../', '..\\'])) {
$name = str_replace($prefix, '', $xpath);
$up = substr_count($xpath, $prefix);
$values = $this->getAncestorElements($up, $name);
}
else {
$values = $target_element->xpath($xpath);
}
foreach ($values as $value) {
// If the SimpleXMLElement doesn't render to a string of any sort,
// and has children then return the whole object for the process
// plugin or other row manipulation.
if ($value->children() && !trim((string) $value)) {
$this->currentItem[$field_name][] = $value;
}
else {
$this->currentItem[$field_name][] = (string) $value;
}
}
}
// Reduce single-value arrays to scalars.
foreach ($this->currentItem as $field_name => $values) {
// We cannot use reset for SimpleXmlElement because it might have
// attributes that are not counted. Get the first value, even if there
// are more values available.
if (is_array($values) && count($values) == 1) {
$this->currentItem[$field_name] = reset($values);
}
}
}
}
/**
* Tests whether the iterator's xpath predicate matches the provided element.
*
* Has some limitations esp. in that it is easy to write predicates that
* reference things outside this SimpleXmlElement's tree, but "simpler"
* predicates should work as expected.
*
* @param \SimpleXMLElement $elem
* The element to test.
*
* True if the element matches the predicate, false if not.
*/
protected function predicateMatches(\SimpleXMLElement $elem): bool {
return !empty($elem->xpath('/*[' . $this->xpathPredicate . ']'));
}
/**
* Gets an ancestor SimpleXMLElement, if the element name was registered.
*
* Gets the SimpleXMLElement some number of levels above the iterator
* having the given name, but only for element names that this
* Xml data parser was told to retain for future reference through the
* constructor's $parent_elements_of_interest.
*
* @param int $levels_up
* The number of levels back towards the root of the DOM tree to ascend
* before searching for the named element.
* @param string $name
* The name of the desired element.
*
* @return \SimpleXMLElement|false
* The element matching the level and name requirements, or false if it is
* not present or was not retained.
*/
public function getAncestorElements($levels_up, $name) {
if ($levels_up > 0) {
$levels_up *= -1;
}
$ancestor_depth = $this->reader->depth + $levels_up + 1;
if ($ancestor_depth < 0) {
return FALSE;
}
if (array_key_exists($ancestor_depth, $this->parentXpathCache) && array_key_exists($name, $this->parentXpathCache[$ancestor_depth])) {
return $this->parentXpathCache[$ancestor_depth][$name];
}
else {
return FALSE;
}
}
}

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types = 1);
namespace Drupal\migrate_plus\Plugin\migrate_plus\data_parser;
use Drupal\Core\StringTranslation\TranslatableMarkup;
/**
* Common functionality for XML data parsers.
*/
trait XmlTrait {
/**
* Registers the iterator's namespaces to a SimpleXMLElement.
*
* @param \SimpleXMLElement $xml
* The element to apply namespace registrations to.
*/
protected function registerNamespaces(\SimpleXMLElement $xml): void {
if (isset($this->configuration['namespaces']) && is_array($this->configuration['namespaces'])) {
foreach ($this->configuration['namespaces'] as $prefix => $ns) {
$xml->registerXPathNamespace($prefix, $ns);
}
}
}
/**
* Parses a LibXMLError to a error message string.
*
* @param \LibXMLError $error
* Error thrown by the XML.
*
* @return string
* Error message
*/
public static function parseLibXmlError(\LibXMLError $error): TranslatableMarkup {
$error_code_name = 'Unknown Error';
switch ($error->level) {
case LIBXML_ERR_WARNING:
$error_code_name = t('Warning');
break;
case LIBXML_ERR_ERROR:
$error_code_name = t('Error');
break;
case LIBXML_ERR_FATAL:
$error_code_name = t('Fatal Error');
break;
}
return t(
"@libxmlerrorcodename @libxmlerrorcode: @libxmlerrormessage\nLine: @libxmlerrorline\nColumn: @libxmlerrorcolumn\nFile: @libxmlerrorfile",
[
'@libxmlerrorcodename' => $error_code_name,
'@libxmlerrorcode' => $error->code,
'@libxmlerrormessage' => trim((string) $error->message),
'@libxmlerrorline' => $error->line,
'@libxmlerrorcolumn' => $error->column,
'@libxmlerrorfile' => $error->file,
]
);
}
}