Importer.php

Same filename and directory in other branches
  1. 10 core/lib/Drupal/Core/DefaultContent/Importer.php

Namespace

Drupal\Core\DefaultContent

File

core/lib/Drupal/Core/DefaultContent/Importer.php

View source
<?php

declare (strict_types=1);
namespace Drupal\Core\DefaultContent;

use Drupal\Component\Plugin\PluginInspectionInterface;
use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\Core\Entity\EntityRepositoryInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Entity\Plugin\DataType\EntityReference;
use Drupal\Core\Field\FieldItemInterface;
use Drupal\Core\File\FileSystemInterface;
use Drupal\Core\Installer\InstallerKernel;
use Drupal\Core\Language\LanguageManagerInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\file\FileInterface;
use Drupal\link\Plugin\Field\FieldType\LinkItem;
use Drupal\user\EntityOwnerInterface;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;

/**
 * A service for handling import of content.
 *
 * @internal
 *   This API is experimental.
 */
final class Importer implements LoggerAwareInterface {
  use LoggerAwareTrait;
  
  /**
   * The dependencies of the currently importing entity, if any.
   *
   * The keys are the UUIDs of the dependencies, and the values are arrays with
   * two members: the entity type ID of the dependency, and the UUID to load.
   *
   * @var array<string, string[]>|null
   */
  private ?array $dependencies = NULL;
  public function __construct(private readonly EntityTypeManagerInterface $entityTypeManager, private readonly AdminAccountSwitcher $accountSwitcher, private readonly FileSystemInterface $fileSystem, private readonly LanguageManagerInterface $languageManager, private readonly EntityRepositoryInterface $entityRepository, private readonly EventDispatcherInterface $eventDispatcher) {
  }
  
  /**
   * Imports content entities from disk.
   *
   * @param \Drupal\Core\DefaultContent\Finder $content
   *   The content finder, which has information on the entities to create
   *   in the necessary dependency order.
   * @param \Drupal\Core\DefaultContent\Existing $existing
   *   (optional) What to do if one of the entities being imported already
   *   exists, by UUID:
   *   - \Drupal\Core\DefaultContent\Existing::Error: Throw an exception.
   *   - \Drupal\Core\DefaultContent\Existing::Skip: Leave the existing entity
   *     as-is.
   * @param \Drupal\Core\Session\AccountInterface|null $account
   *   (optional) The account to use when importing the entities. Defaults to
   *   the administrator account.
   *
   * @throws \Drupal\Core\DefaultContent\ImportException
   *   - If any of the entities being imported are not content entities.
   *   - If any of the entities being imported already exists, by UUID, and
   *     $existing is \Drupal\Core\DefaultContent\Existing::Error.
   */
  public function importContent(Finder $content, Existing $existing = Existing::Error, ?AccountInterface $account = NULL) : void {
    if (count($content->data) === 0) {
      return;
    }
    $event = new PreImportEvent($content, $existing);
    $skip = $this->eventDispatcher
      ->dispatch($event)
      ->getSkipList();
    if ($account !== NULL) {
      $this->accountSwitcher
        ->switchTo($account);
    }
    else {
      $account = $this->accountSwitcher
        ->switchToAdministrator();
    }
    try {
      /** @var array{_meta: array<mixed>} $decoded */
      foreach ($content->data as $decoded) {
        [
          'uuid' => $uuid,
          'entity_type' => $entity_type_id,
          'path' => $path,
        ] = $decoded['_meta'];
        assert(is_string($uuid));
        assert(is_string($entity_type_id));
        assert(is_string($path));
        // The event subscribers asked to skip importing this entity. If they
        // explained why, log that.
        if (array_key_exists($uuid, $skip)) {
          if ($skip[$uuid]) {
            $this->logger?->info('Skipped importing @entity_type @uuid because: %reason', [
              '@entity_type' => $entity_type_id,
              '@uuid' => $uuid,
              '%reason' => $skip[$uuid],
            ]);
          }
          continue;
        }
        $entity_type = $this->entityTypeManager
          ->getDefinition($entity_type_id);
        /** @var \Drupal\Core\Entity\EntityTypeInterface $entity_type */
        if (!$entity_type->entityClassImplements(ContentEntityInterface::class)) {
          throw new ImportException("Content entity {$uuid} is a '{$entity_type_id}', which is not a content entity type.");
        }
        $entity = $this->entityRepository
          ->loadEntityByUuid($entity_type_id, $uuid);
        if ($entity) {
          if ($existing === Existing::Skip) {
            continue;
          }
          else {
            throw new ImportException("{$entity_type_id} {$uuid} already exists.");
          }
        }
        $entity = $this->toEntity($decoded)
          ->enforceIsNew();
        // Ensure that the entity is not owned by the anonymous user.
        if ($entity instanceof EntityOwnerInterface && empty($entity->getOwnerId())) {
          $entity->setOwnerId($account->id());
        }
        // If a file exists in the same folder, copy it to the designated
        // target URI.
        if ($entity instanceof FileInterface) {
          $this->copyFileAssociatedWithEntity(dirname($path), $entity);
        }
        $violations = $entity->validate();
        if (count($violations) > 0) {
          throw new InvalidEntityException($violations, $path);
        }
        $entity->save();
      }
    } finally {
      $this->accountSwitcher
        ->switchBack();
    }
  }
  
  /**
   * Copies a file from default content directory to the site's file system.
   *
   * @param string $path
   *   The path to the file to copy.
   * @param \Drupal\file\FileInterface $entity
   *   The file entity.
   */
  private function copyFileAssociatedWithEntity(string $path, FileInterface &$entity) : void {
    $destination = $entity->getFileUri();
    assert(is_string($destination));
    // If the source file doesn't exist, there's nothing we can do.
    $source = $path . '/' . basename($destination);
    if (!file_exists($source)) {
      $this->logger?->warning("File entity %name was imported, but the associated file (@path) was not found.", [
        '%name' => $entity->label(),
        '@path' => $source,
      ]);
      return;
    }
    $copy_file = TRUE;
    if (file_exists($destination)) {
      $source_hash = hash_file('sha256', $source);
      assert(is_string($source_hash));
      $destination_hash = hash_file('sha256', $destination);
      assert(is_string($destination_hash));
      if (hash_equals($source_hash, $destination_hash) && $this->entityTypeManager
        ->getStorage('file')
        ->loadByProperties([
        'uri' => $destination,
      ]) === []) {
        // If the file hashes match and the file is not already a managed file
        // then do not copy a new version to the file system. This prevents
        // re-installs during development from creating unnecessary duplicates.
        $copy_file = FALSE;
      }
    }
    $target_directory = dirname($destination);
    $this->fileSystem
      ->prepareDirectory($target_directory, FileSystemInterface::CREATE_DIRECTORY);
    if ($copy_file) {
      $uri = $this->fileSystem
        ->copy($source, $destination);
      $entity->setFileUri($uri);
    }
  }
  
  /**
   * Converts an array of content entity data to a content entity object.
   *
   * @param array<string, array<mixed>> $data
   *   The entity data.
   *
   * @return \Drupal\Core\Entity\ContentEntityInterface
   *   The unsaved entity.
   *
   * @throws \Drupal\Core\DefaultContent\ImportException
   *   If the `entity_type` or `uuid` meta keys are not set.
   */
  private function toEntity(array $data) : ContentEntityInterface {
    if (empty($data['_meta']['entity_type'])) {
      throw new ImportException('The entity type metadata must be specified.');
    }
    if (empty($data['_meta']['uuid'])) {
      throw new ImportException('The uuid metadata must be specified.');
    }
    $is_root = FALSE;
    // @see ::loadEntityDependency()
    if ($this->dependencies === NULL && !empty($data['_meta']['depends'])) {
      $is_root = TRUE;
      foreach ($data['_meta']['depends'] as $uuid => $entity_type) {
        assert(is_string($uuid));
        assert(is_string($entity_type));
        $this->dependencies[$uuid] = [
          $entity_type,
          $uuid,
        ];
      }
    }
    [
      'entity_type' => $entity_type,
    ] = $data['_meta'];
    assert(is_string($entity_type));
    /** @var \Drupal\Core\Entity\EntityTypeInterface $entity_type */
    $entity_type = $this->entityTypeManager
      ->getDefinition($entity_type);
    $values = [
      'uuid' => $data['_meta']['uuid'],
    ];
    if (!empty($data['_meta']['bundle'])) {
      $values[$entity_type->getKey('bundle')] = $data['_meta']['bundle'];
    }
    if (!empty($data['_meta']['default_langcode'])) {
      $data = $this->verifyNormalizedLanguage($data);
      $values[$entity_type->getKey('langcode')] = $data['_meta']['default_langcode'];
    }
    /** @var \Drupal\Core\Entity\ContentEntityInterface $entity */
    $entity = $this->entityTypeManager
      ->getStorage($entity_type->id())
      ->create($values);
    foreach ($data['default'] as $field_name => $values) {
      $this->setFieldValues($entity, $field_name, $values);
    }
    foreach ($data['translations'] ?? [] as $langcode => $translation_data) {
      if ($this->languageManager
        ->getLanguage($langcode)) {
        $translation = $entity->addTranslation($langcode, $entity->toArray());
        foreach ($translation_data as $field_name => $values) {
          $this->setFieldValues($translation, $field_name, $values);
        }
      }
    }
    if ($is_root) {
      $this->dependencies = NULL;
    }
    return $entity;
  }
  
  /**
   * Sets field values based on the normalized data.
   *
   * @param \Drupal\Core\Entity\ContentEntityInterface $entity
   *   The content entity.
   * @param string $field_name
   *   The name of the field.
   * @param array $values
   *   The normalized data for the field.
   */
  private function setFieldValues(ContentEntityInterface $entity, string $field_name, array $values) : void {
    foreach ($values as $delta => $item_value) {
      if (!$entity->get($field_name)
        ->get($delta)) {
        $entity->get($field_name)
          ->appendItem();
      }
      /** @var \Drupal\Core\Field\FieldItemInterface $item */
      $item = $entity->get($field_name)
        ->get($delta);
      // Update the URI based on the target UUID for link fields.
      if (isset($item_value['target_uuid']) && $item instanceof LinkItem) {
        $target_entity = $this->loadEntityDependency($item_value['target_uuid']);
        if ($target_entity) {
          $item_value['uri'] = 'entity:' . $target_entity->getEntityTypeId() . '/' . $target_entity->id();
        }
        unset($item_value['target_uuid']);
      }
      $serialized_property_names = $this->getCustomSerializedPropertyNames($item);
      foreach ($item_value as $property_name => $value) {
        if (\in_array($property_name, $serialized_property_names)) {
          if (\is_string($value)) {
            throw new ImportException("Received string for serialized property {$field_name}.{$delta}.{$property_name}");
          }
          $value = serialize($value);
        }
        $property = $item->get($property_name);
        if ($property instanceof EntityReference) {
          if (is_array($value)) {
            $value = $this->toEntity($value);
          }
          else {
            $value = $this->loadEntityDependency($value);
          }
        }
        $property->setValue($value);
      }
    }
  }
  
  /**
   * Gets the names of all properties the plugin treats as serialized data.
   *
   * This allows the field storage definition or entity type to provide a
   * setting for serialized properties. This can be used for fields that
   * handle serialized data themselves and do not rely on the serialized schema
   * flag.
   *
   * @param \Drupal\Core\Field\FieldItemInterface $field_item
   *   The field item.
   *
   * @return string[]
   *   The property names for serialized properties.
   *
   * @see \Drupal\serialization\Normalizer\SerializedColumnNormalizerTrait::getCustomSerializedPropertyNames
   */
  private function getCustomSerializedPropertyNames(FieldItemInterface $field_item) : array {
    if ($field_item instanceof PluginInspectionInterface) {
      $definition = $field_item->getPluginDefinition();
      $serialized_fields = $field_item->getEntity()
        ->getEntityType()
        ->get('serialized_field_property_names');
      $field_name = $field_item->getFieldDefinition()
        ->getName();
      if (is_array($serialized_fields) && isset($serialized_fields[$field_name]) && is_array($serialized_fields[$field_name])) {
        return $serialized_fields[$field_name];
      }
      if (isset($definition['serialized_property_names']) && is_array($definition['serialized_property_names'])) {
        return $definition['serialized_property_names'];
      }
    }
    return [];
  }
  
  /**
   * Loads the entity dependency by its UUID.
   *
   * @param string $target_uuid
   *   The entity UUID.
   *
   * @return \Drupal\Core\Entity\ContentEntityInterface|null
   *   The loaded entity.
   */
  private function loadEntityDependency(string $target_uuid) : ?ContentEntityInterface {
    if ($this->dependencies && array_key_exists($target_uuid, $this->dependencies)) {
      $entity = $this->entityRepository
        ->loadEntityByUuid(...$this->dependencies[$target_uuid]);
      assert($entity instanceof ContentEntityInterface || $entity === NULL);
      return $entity;
    }
    return NULL;
  }
  
  /**
   * Verifies that the site knows the default language of the normalized entity.
   *
   * Will attempt to switch to an alternative translation or just import it
   * with the site default language.
   *
   * @param array $data
   *   The normalized entity data.
   *
   * @return array
   *   The normalized entity data, possibly with altered default language
   *   and translations.
   */
  private function verifyNormalizedLanguage(array $data) : array {
    $default_langcode = $data['_meta']['default_langcode'];
    $default_language = $this->languageManager
      ->getDefaultLanguage();
    // Check the language. If the default language isn't known, import as one of
    // the available translations if one exists with those values. If none
    // exists, create the entity in the default language. During the installer,
    // when installing with an alternative language, `en` is still the default
    // when modules are installed so check the default language instead.
    if (!$this->languageManager
      ->getLanguage($default_langcode) || InstallerKernel::installationAttempted() && $default_language->getId() !== $default_langcode) {
      $use_default = TRUE;
      foreach ($data['translations'] ?? [] as $langcode => $translation_data) {
        if ($this->languageManager
          ->getLanguage($langcode)) {
          $data['_meta']['default_langcode'] = $langcode;
          $data['default'] = \array_merge($data['default'], $translation_data);
          unset($data['translations'][$langcode]);
          $use_default = FALSE;
          break;

        }
      }
      if ($use_default) {
        $data['_meta']['default_langcode'] = $default_language->getId();
      }
    }
    return $data;
  }

}

Classes

Title Deprecated Summary
Importer A service for handling import of content.

Buggy or inaccurate documentation? Please file an issue. Need support? Need help programming? Connect with the Drupal community.