You are here

Importer.php in Default Content Deploy 8

File

src/Importer.php
View source
<?php

namespace Drupal\default_content_deploy;

use Drupal\Core\Cache\CacheBackendInterface;
use Drupal\Core\Entity\EntityChangedInterface;
use Drupal\Core\Entity\EntityRepositoryInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\Session\AccountSwitcherInterface;
use Drupal\hal\LinkManager\LinkManagerInterface;
use Rogervila\ArrayDiffMultidimensional;
use Symfony\Component\Serializer\Serializer;

/**
 * A service for handling import of default content.
 *
 * The importContent() method is almost duplicate of
 *   \Drupal\default_content\Importer::importContent with injected code for
 *   content update. We are waiting for better DC code structure in a future.
 */
class Importer {

  /**
   * Deploy manager.
   *
   * @var \Drupal\default_content_deploy\DeployManager
   */
  protected $deployManager;

  /**
   * The module handler service.
   *
   * @var \Drupal\Core\Extension\ModuleHandlerInterface
   */
  private $moduleHandler;

  /**
   * Scanned files.
   *
   * @var object[]
   */
  private $files;

  /**
   * Directory to import.
   *
   * @var string
   */
  private $folder;

  /**
   * Data to import.
   *
   * @var array
   */
  private $dataToImport = [];

  /**
   * Is remove changes of an old content.
   *
   * @var bool
   */
  protected $forceOverride;

  /**
   * The Entity repository manager.
   *
   * @var \Drupal\Core\Entity\EntityRepositoryInterface
   */
  protected $entityRepository;

  /**
   * The cache data.
   *
   * @var \Drupal\Core\Cache\CacheBackendInterface
   */
  protected $cache;

  /**
   * Memoization for references that have already been discovered.
   *
   * @var array
   */
  protected $discoveredReferences = [];

  /**
   * The serializer service.
   *
   * @var \Symfony\Component\Serializer\Serializer
   */
  protected $serializer;

  /**
   * The entity type manager.
   *
   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
   */
  protected $entityTypeManager;

  /**
   * The link manager service.
   *
   * @var \Drupal\hal\LinkManager\LinkManagerInterface
   */
  protected $linkManager;

  /**
   * The file system scanner.
   *
   * @var \Drupal\default_content\ScannerInterface
   */
  protected $scanner;

  /**
   * The account switcher.
   *
   * @var \Drupal\Core\Session\AccountSwitcherInterface
   */
  protected $accountSwitcher;

  /**
   * DCD Exporter.
   *
   * @var \Drupal\default_content_deploy\Exporter
   */
  protected $exporter;

  /**
   * @var array
   */
  protected $entityLookup = [];

  /**
   * @var array
   */
  protected $entityIdLookup = [];

  /**
   * Constructs the default content deploy manager.
   *
   * @param \Symfony\Component\Serializer\Serializer $serializer
   *   The serializer service.
   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
   *   The entity type manager service.
   * @param \Drupal\hal\LinkManager\LinkManagerInterface $link_manager
   *   The link manager service.
   * @param \Drupal\Core\Session\AccountSwitcherInterface $account_switcher
   *   The account switcher.
   * @param \Drupal\default_content_deploy\DeployManager $deploy_manager
   *   Deploy manager.
   * @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
   *   The module handler service.
   * @param \Drupal\Core\Entity\EntityRepositoryInterface $entity_repository
   *   The Entity repository manager.
   * @param \Drupal\Core\Cache\CacheBackendInterface $cache
   *   The cache data.
   */
  public function __construct(Serializer $serializer, EntityTypeManagerInterface $entity_type_manager, LinkManagerInterface $link_manager, AccountSwitcherInterface $account_switcher, DeployManager $deploy_manager, ModuleHandlerInterface $module_handler, EntityRepositoryInterface $entity_repository, CacheBackendInterface $cache, Exporter $exporter) {
    $this->serializer = $serializer;
    $this->entityTypeManager = $entity_type_manager;
    $this->linkManager = $link_manager;
    $this->accountSwitcher = $account_switcher;
    $this->deployManager = $deploy_manager;
    $this->moduleHandler = $module_handler;
    $this->entityRepository = $entity_repository;
    $this->cache = $cache;
    $this->exporter = $exporter;
  }

  /**
   * Is remove changes of an old content.
   *
   * @param bool $is_override
   *
   * @return \Drupal\default_content_deploy\Importer
   */
  public function setForceOverride(bool $is_override) {
    $this->forceOverride = $is_override;
    return $this;
  }

  /**
   * Set directory to import.
   *
   * @param string $folder
   *   The content folder.
   *
   * @return \Drupal\default_content_deploy\Importer
   */
  public function setFolder(string $folder) {
    $this->folder = $folder;
    return $this;
  }

  /**
   * Get directory to import.
   *
   * @return string
   *   The content folder.
   *
   * @throws \Exception
   */
  protected function getFolder() {
    return $this->folder ?: $this->deployManager
      ->getContentFolder();
  }

  /**
   * Get Imported data result.
   *
   * @return array
   */
  public function getResult() {
    return $this->dataToImport;
  }

  /**
   * Import data from JSON and create new entities, or update existing.
   *
   * @return $this
   *
   * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
   * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException
   * @throws \Exception
   */
  public function prepareForImport() {

    // @todo remove because of changes in core >= 9.2
    $this->cache
      ->delete('hal:links:relations');
    $this->files = $this
      ->scan($this
      ->getFolder());
    foreach ($this->files as $file) {
      $uuid = str_replace('.json', '', $file->name);
      if (!isset($this->dataToImport[$uuid])) {
        $this
          ->decodeFile($file);
      }
    }
    return $this;
  }

  /**
   * Returns a list of file objects.
   *
   * @param string $directory
   *   Absolute path to the directory to search.
   *
   * @return object[]
   *   List of stdClass objects with name and uri properties.
   */
  public function scan($directory) {

    // Use Unix paths regardless of platform, skip dot directories, follow
    // symlinks (to allow extensions to be linked from elsewhere), and return
    // the RecursiveDirectoryIterator instance to have access to getSubPath(),
    // since SplFileInfo does not support relative paths.
    $flags = \FilesystemIterator::UNIX_PATHS;
    $flags |= \FilesystemIterator::SKIP_DOTS;
    $flags |= \FilesystemIterator::CURRENT_AS_SELF;
    $directory_iterator = new \RecursiveDirectoryIterator($directory, $flags);
    $iterator = new \RecursiveIteratorIterator($directory_iterator);
    $files = [];

    /* @var \SplFileInfo $file_info */
    foreach ($iterator as $file_info) {

      // Skip directories and non-json files.
      if ($file_info
        ->isDir() || $file_info
        ->getExtension() != 'json') {
        continue;
      }
      $file = new \stdClass();
      $file->name = $file_info
        ->getFilename();
      $file->uri = $file_info
        ->getPathname();
      $files[$file->uri] = $file;
    }
    return $files;
  }

  /**
   * Import to entity.
   *
   * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
   * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException
   * @throws \Drupal\Core\Entity\EntityStorageException
   */
  public function import() {
    $files = $this->dataToImport;
    if (PHP_SAPI === 'cli') {
      $root_user = $this->entityTypeManager
        ->getStorage('user')
        ->load(1);
      $this->accountSwitcher
        ->switchTo($root_user);
    }

    // All entities with entity references will be imported two times to ensure
    // that all entity references are present and valid. Path aliases will be
    // imported last to have a chance to rewrite them to the new ids of newly
    // created entities.
    for ($i = 0; $i <= 2; $i++) {
      foreach ($files as $uuid => &$file) {
        if ($file['status'] !== 'skip') {
          $entity_type = $file['entity_type_id'];
          if ($i !== 2 && $entity_type === 'path_alias') {
            continue;
          }
          $this->linkManager
            ->setLinkDomain($this
            ->getLinkDomain($file));
          $class = $this->entityTypeManager
            ->getDefinition($entity_type)
            ->getClass();
          $this
            ->preDenormalize($file, $entity_type);

          /** @var \Drupal\Core\Entity\ContentEntityInterface $entity */
          $entity = $this->serializer
            ->denormalize($file['data'], $class, 'hal_json', [
            'request_method' => 'POST',
          ]);
          $entity
            ->enforceIsNew($file['is_new']);
          $entity
            ->save();
          $this->entityIdLookup[$uuid] = $entity
            ->id();
          if (empty($file['references']) || $i === 1) {

            // Don't handle entities without references twice. Don't handle
            // entities with references again in the third run for path aliases.
            unset($files[$uuid]);
          }
          else {

            // In the second run new entities should be updated.
            $file['status'] = 'update';
            $file['is_new'] = FALSE;
            $file['data'][$file['key_id']][0]['value'] = $entity
              ->id();
          }
        }
      }
      unset($file);
    }

    // @todo is this still needed?
    $this->linkManager
      ->setLinkDomain(FALSE);
    if (PHP_SAPI === 'cli') {
      $this->accountSwitcher
        ->switchBack();
    }
  }

  /**
   * Gets url from file for set to Link manager.
   *
   * @param array $file
   */
  protected function getLinkDomain($file) {
    $link = $file['data']['_links']['type']['href'];
    $url_data = parse_url($link);
    $host = "{$url_data['scheme']}://{$url_data['host']}";
    return !isset($url_data['port']) ? $host : "{$host}:{$url_data['port']}";
  }

  /**
   * Prepare file to import.
   *
   * @param $file
   *
   * @return $this
   *
   * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
   * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException
   * @throws \Exception
   */
  protected function decodeFile($file) {

    // Check that this file has not been decoded already.
    if (array_key_exists($file->name, $this->discoveredReferences)) {
      return $this;
    }

    // Get parsed data.
    $parsed_data = file_get_contents($file->uri);

    // Decode.
    $decode = $this->serializer
      ->decode($parsed_data, 'hal_json');
    $references = $this
      ->getReferences($decode);

    // Record that we have checked references of current file.
    $this->discoveredReferences[$file->name] = $file;
    if ($references) {
      foreach ($references as $reference) {
        $this
          ->decodeFile($reference);
      }
    }

    // Prepare data for import.
    $link = $decode['_links']['type']['href'];
    $data_to_import = [
      'data' => $decode,
      'entity_type_id' => $this
        ->getEntityTypeByLink($link),
      'references' => $references,
    ];
    $this
      ->preAddToImport($data_to_import);
    $this
      ->addToImport($data_to_import);
    return $this;
  }

  /**
   * Here we can edit data`s value before importing.
   *
   * @param $data
   *
   * @return $this
   *
   * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException
   * @throws \Drupal\Core\Entity\EntityStorageException
   * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
   */
  protected function preAddToImport(&$data) {
    $decode = $data['data'];
    $uuid = $decode['uuid'][0]['value'];
    $entity_type_id = $data['entity_type_id'];

    /** @var \Drupal\Core\Entity\ContentEntityInterface $entity */
    $entity = $this->entityRepository
      ->loadEntityByUuid($entity_type_id, $uuid);
    $entity_type_object = $this->entityTypeManager
      ->getDefinition($entity_type_id);

    // Keys of entity.
    $key_id = $entity_type_object
      ->getKey('id');
    $key_revision_id = $entity_type_object
      ->getKey('revision');

    // Some old exports don't have the entity ID.
    if (isset($decode[$key_id][0]['value'])) {
      $this->entityLookup[$entity_type_id][$decode[$key_id][0]['value']] = $uuid;
    }
    if ($entity) {
      $is_new = FALSE;
      $status = 'update';

      // Replace entity ID.
      $decode[$key_id][0]['value'] = $entity
        ->id();

      // Skip if the Changed time the same or less in the file.
      if ($entity instanceof EntityChangedInterface) {
        $changed_time_file = 0;
        foreach ($decode['changed'] as $changed) {
          $changed_time = strtotime($changed['value']);
          if ($changed_time > $changed_time_file) {
            $changed_time_file = $changed_time;
          }
        }
        if (!$this->forceOverride && $changed_time_file <= $entity
          ->getChangedTimeAcrossTranslations()) {
          $status = 'skip';
        }
      }
      elseif (!$this->forceOverride) {
        $this->linkManager
          ->setLinkDomain($this
          ->getLinkDomain($data));
        $current_entity_decoded = $this->serializer
          ->decode($this->exporter
          ->getSerializedContent($entity), 'hal_json');
        $diff = ArrayDiffMultidimensional::looseComparison($decode, $current_entity_decoded);
        if (!$diff) {
          $status = 'skip';
        }

        // @todo is this still needed?
        $this->linkManager
          ->setLinkDomain(FALSE);
      }
    }
    else {
      $status = 'create';
      $is_new = TRUE;

      // Ignore ID for creating a new entity.
      unset($decode[$key_id]);
    }

    // @see path_entity_base_field_info().
    // @todo offer an event to let third party modules register their content
    //       types.
    if (in_array($entity_type_id, [
      'taxonomy_term',
      'node',
      'media',
    ])) {
      unset($decode['path']);
    }

    // Ignore revision and id of entity.
    unset($decode[$key_revision_id]);
    $data['is_new'] = $is_new;
    $data['status'] = $status;
    $data['data'] = $decode;
    $data['key_id'] = $key_id;
    return $this;
  }

  /**
   * This event is triggered before decoding to an entity.
   *
   * @param $file
   *
   * @return $this
   *
   * @throws \Drupal\Core\Entity\EntityStorageException
   */
  protected function preDenormalize(&$file, $entity_type) {
    $this
      ->updateTargetRevisionId($file['data']);
    if ($entity_type === 'path_alias') {
      $this
        ->updatePathAliasTargetId($file['data']);
    }
    return $this;
  }

  /**
   * Adding prepared data for import.
   *
   * @param $data
   *
   * @return $this
   */
  protected function addToImport($data) {
    $uuid = $data['data']['uuid'][0]['value'];
    $this->dataToImport[$uuid] = $data;
    return $this;
  }

  /**
   * Get all reference by entity array content.
   *
   * @param array $content
   *
   * @return array
   */
  private function getReferences(array $content) {
    $references = [];
    if (isset($content['_embedded'])) {
      foreach ($content['_embedded'] as $link) {
        foreach ($link as $reference) {
          if ($reference) {
            $uuid = $reference['uuid'][0]['value'];
            $path = $this
              ->getPathToFileByName($uuid);
            if ($path) {
              $references[$uuid] = $this->files[$path];
            }
          }
        }
      }
    }
    return $references;
  }

  /**
   * Get path to file by Name.
   *
   * @param $name
   *
   * @return false|int|string
   */
  private function getPathToFileByName($name) {
    $array_column = array_column($this->files, 'name', 'uri');
    return array_search($name . '.json', $array_column);
  }

  /**
   * Get Entity type ID by link.
   *
   * @param $link
   *
   * @return string|string[]
   */
  private function getEntityTypeByLink($link) {
    $type = $this->linkManager
      ->getTypeInternalIds($link);
    if ($type) {
      $entity_type_id = $type['entity_type'];
    }
    else {
      $components = array_reverse(explode('/', $link));
      $entity_type_id = $components[1];
      $this->cache
        ->invalidate('hal:links:types');
    }
    return $entity_type_id;
  }

  /**
   * If this entity contains a reference field with target revision is value,
   * we should to update it.
   *
   * @param $decode
   *
   * @return $this
   *
   * @throws \Drupal\Core\Entity\EntityStorageException
   */
  private function updateTargetRevisionId(&$decode) {
    if (isset($decode['_embedded'])) {
      foreach ($decode['_embedded'] as $link_key => $link) {
        if (array_column($link, 'target_revision_id')) {
          foreach ($link as $ref_key => $reference) {
            $url = $reference['_links']['type']['href'];
            $uuid = $reference['uuid'][0]['value'];
            $entity_type = $this
              ->getEntityTypeByLink($url);
            $entity = $this->entityRepository
              ->loadEntityByUuid($entity_type, $uuid);

            // Update the Target revision id if child entity exist on this site.
            if ($entity) {
              $revision_id = $entity
                ->getRevisionId();
              $decode['_embedded'][$link_key][$ref_key]['target_revision_id'] = $revision_id;
            }
          }
        }
      }
    }
    return $this;
  }

  /**
   * Rewrite path aliases to target entity IDs that were assigned during import.
   *
   * @param $decode
   *
   * @return $this
   */
  private function updatePathAliasTargetId(&$decode) {
    if ($alias = $decode['path'][0]['value'] ?? NULL) {
      if (preg_match('@^/(\\w+)/(\\d+)([/?#].*|)$@', $alias, $matches)) {
        $entity_type_id = str_replace('_', '/', $matches[1]);
        if ($uuid = $this->entityLookup[$entity_type_id][$matches[2]] ?? NULL) {
          if ($id = $this->entityIdLookup[$uuid] ?? NULL) {
            $decode['path'][0]['value'] = '/' . $matches[1] . '/' . $id . $matches[3];
          }
        }
      }
    }
    return $this;
  }

}

Classes

Namesort descending Description
Importer A service for handling import of default content.