Unverified Commit 80fd4ba0 authored by Alex Pott's avatar Alex Pott
Browse files

Issue #3532694 by phenaproxima, alexpott, nicxvan, berdir, mstrelan, larowlan,...

Issue #3532694 by phenaproxima, alexpott, nicxvan, berdir, mstrelan, larowlan, murz: Add a command-line utility to export content in YAML format
parent 1124c4f9
Loading
Loading
Loading
Loading
Loading
+0 −6
Original line number Diff line number Diff line
@@ -45704,12 +45704,6 @@
	'count' => 1,
	'path' => __DIR__ . '/tests/Drupal/FunctionalTests/BrowserTestBaseTest.php',
];
$ignoreErrors[] = [
	'message' => '#^Method Drupal\\\\FunctionalTests\\\\DefaultContent\\\\ContentImportTest\\:\\:createEntityReferenceField\\(\\) has no return type specified\\.$#',
	'identifier' => 'missingType.return',
	'count' => 1,
	'path' => __DIR__ . '/tests/Drupal/FunctionalTests/DefaultContent/ContentImportTest.php',
];
$ignoreErrors[] = [
	'message' => '#^Method Drupal\\\\FunctionalTests\\\\Entity\\\\EntityBundleListCacheTest\\:\\:assertCacheContext\\(\\) has no return type specified\\.$#',
	'identifier' => 'missingType.return',
+2 −0
Original line number Diff line number Diff line
@@ -76,6 +76,8 @@ services:
    arguments: ['@config.manager', '@config.storage', '@config.typed', '@config.factory']
  Drupal\Core\DefaultContent\Importer:
    autowire: true
  Drupal\Core\DefaultContent\Exporter:
    autowire: true
  Drupal\Core\DefaultContent\AdminAccountSwitcher:
    arguments:
      $isSuperUserAccessEnabled: '%security.enable_super_user%'
+76 −0
Original line number Diff line number Diff line
<?php

declare(strict_types=1);

namespace Drupal\Core\DefaultContent;

use Drupal\Core\Command\BootableCommandTrait;
use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Serialization\Yaml;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;

/**
 * Exports a single content entity in YAML format.
 *
 * @internal
 *    This API is experimental.
 */
final class ContentExportCommand extends Command {

  use BootableCommandTrait;

  public function __construct(object $class_loader) {
    parent::__construct('content:export');
    $this->classLoader = $class_loader;
  }

  /**
   * {@inheritdoc}
   */
  protected function configure(): void {
    $this
      ->setDescription('Exports a single content entity in YAML format.')
      ->addArgument('entity_type_id', InputArgument::REQUIRED, 'The type of entity to export (e.g., node, taxonomy_term).')
      ->addArgument('entity_id', InputArgument::REQUIRED, 'The ID of the entity to export. Will usually be a number.');
  }

  /**
   * {@inheritdoc}
   */
  protected function execute(InputInterface $input, OutputInterface $output): int {
    $io = new SymfonyStyle($input, $output);
    $container = $this->boot()->getContainer();

    $entity_type_id = $input->getArgument('entity_type_id');
    $entity_id = $input->getArgument('entity_id');
    $entity_type_manager = $container->get(EntityTypeManagerInterface::class);

    if (!$entity_type_manager->hasDefinition($entity_type_id)) {
      $io->error("The entity type \"$entity_type_id\" does not exist.");
      return 1;
    }

    if (!$entity_type_manager->getDefinition($entity_type_id)->entityClassImplements(ContentEntityInterface::class)) {
      $io->error("$entity_type_id is not a content entity type.");
      return 1;
    }

    $entity = $entity_type_manager
      ->getStorage($entity_type_id)
      ->load($entity_id);
    if (!$entity instanceof ContentEntityInterface) {
      $io->error("$entity_type_id $entity_id does not exist.");
      return 1;
    }

    $data = $container->get(Exporter::class)->export($entity);
    $io->write(Yaml::encode($data));
    return 0;
  }

}
+59 −0
Original line number Diff line number Diff line
<?php

declare(strict_types=1);

namespace Drupal\Core\DefaultContent;

use Drupal\Core\Entity\ContentEntityInterface;

/**
 * Collects metadata about an entity being exported.
 *
 * @internal
 *   This API is experimental.
 */
final class ExportMetadata {

  /**
   * The collected export metadata.
   */
  private array $metadata = ['version' => '1.0'];

  public function __construct(ContentEntityInterface $entity) {
    $this->metadata['entity_type'] = $entity->getEntityTypeId();
    $this->metadata['uuid'] = $entity->uuid();

    $entity_type = $entity->getEntityType();
    if ($entity_type->hasKey('bundle')) {
      $this->metadata['bundle'] = $entity->bundle();
    }
    if ($entity_type->hasKey('langcode')) {
      $this->metadata['default_langcode'] = $entity->language()->getId();
    }
  }

  /**
   * Returns the collected metadata as an array.
   *
   * @return array
   *   The collected export metadata.
   */
  public function get(): array {
    return $this->metadata;
  }

  /**
   * Adds a dependency on another content entity.
   *
   * @param \Drupal\Core\Entity\ContentEntityInterface $entity
   *   The entity we depend upon.
   */
  public function addDependency(ContentEntityInterface $entity): void {
    $uuid = $entity->uuid();
    if ($uuid === $this->metadata['uuid']) {
      throw new \LogicException('An entity cannot depend on itself.');
    }
    $this->metadata['depends'][$uuid] = $entity->getEntityTypeId();
  }

}
+222 −0
Original line number Diff line number Diff line
<?php

declare(strict_types=1);

namespace Drupal\Core\DefaultContent;

use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\Core\Field\FieldItemInterface;
use Drupal\Core\Field\Plugin\Field\FieldType\EntityReferenceItemInterface;
use Drupal\Core\Field\Plugin\Field\FieldType\PasswordItem;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\TypedData\PrimitiveInterface;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;

/**
 * Handles exporting content entities.
 *
 * @internal
 *   This API is experimental.
 */
final readonly class Exporter {

  public function __construct(
    private EventDispatcherInterface $eventDispatcher,
  ) {}

  /**
   * Exports a single content entity to a file.
   *
   * @param \Drupal\Core\Entity\ContentEntityInterface $entity
   *   The entity to export.
   *
   * @return array{'_meta': array, 'default': array<array>, 'translations': array<string, array<array>>}
   *   The exported entity data.
   */
  public function export(ContentEntityInterface $entity): array {
    $event = new PreExportEvent($entity);

    $field_definitions = $entity->getFieldDefinitions();
    // Ignore serial (integer) entity IDs by default, along with a number of
    // other keys that aren't useful for default content.
    $id_key = $entity->getEntityType()->getKey('id');
    if ($id_key && $field_definitions[$id_key]->getType() === 'integer') {
      $event->setEntityKeyExportable('id', FALSE);
    }
    $event->setEntityKeyExportable('uuid', FALSE);
    $event->setEntityKeyExportable('revision', FALSE);
    $event->setEntityKeyExportable('langcode', FALSE);
    $event->setEntityKeyExportable('bundle', FALSE);
    $event->setEntityKeyExportable('default_langcode', FALSE);
    $event->setEntityKeyExportable('revision_default', FALSE);
    $event->setEntityKeyExportable('revision_created', FALSE);

    // Default content has no history, so it doesn't make much sense to export
    // `changed` fields.
    foreach ($field_definitions as $name => $definition) {
      if ($definition->getType() === 'changed') {
        $event->setExportable($name, FALSE);
      }
    }
    // Exported user accounts should include the hashed password.
    $event->setCallback('field_item:password', function (PasswordItem $item): array {
      return $item->set('pre_hashed', TRUE)->getValue();
    });
    // Ensure that all entity reference fields mark the referenced entity as a
    // dependency of the entity being exported.
    $event->setCallback('field_item:entity_reference', $this->exportReference(...));
    $event->setCallback('field_item:file', $this->exportReference(...));
    $event->setCallback('field_item:image', $this->exportReference(...));

    // Dispatch the event so modules can add and customize export callbacks, and
    // mark certain fields as ignored.
    $this->eventDispatcher->dispatch($event);

    $data = [];
    $metadata = new ExportMetadata($entity);

    foreach ($entity->getTranslationLanguages() as $langcode => $language) {
      $translation = $entity->getTranslation($langcode);
      $values = $this->exportTranslation($translation, $metadata, $event->getCallbacks(), $event->getAllowList());

      if ($translation->isDefaultTranslation()) {
        $data['default'] = $values;
      }
      else {
        $data['translations'][$langcode] = $values;
      }
    }
    // Add the metadata we've collected (e.g., dependencies) while exporting
    // this entity and its translations.
    $data['_meta'] = $metadata->get();

    return $data;
  }

  /**
   * Exports a single translation of a content entity.
   *
   * Any fields that are explicitly marked non-exportable (including computed
   * properties by default) will not be exported.
   *
   * @param \Drupal\Core\Entity\ContentEntityInterface $translation
   *   The translation to export.
   * @param \Drupal\Core\DefaultContent\ExportMetadata $metadata
   *   Any metadata about the entity being exported (e.g., dependencies).
   * @param callable[] $callbacks
   *   Custom export functions for specific field types, keyed by field type.
   * @param array<string, bool> $allow_list
   *   An array of booleans that indicate whether a specific field should be
   *   exported or not, even if it is computed. Keyed by field name.
   *
   * @return array
   *   The exported translation.
   */
  private function exportTranslation(ContentEntityInterface $translation, ExportMetadata $metadata, array $callbacks, array $allow_list): array {
    $data = [];

    foreach ($translation->getFields() as $name => $items) {
      // Skip the field if it's empty, or it was explicitly disallowed, or is a
      // computed field that wasn't explicitly allowed.
      $allowed = $allow_list[$name] ?? NULL;
      if ($allowed === FALSE || ($allowed === NULL && $items->getDataDefinition()->isComputed()) || $items->isEmpty()) {
        continue;
      }

      // Try to find a callback for this specific field, then for the field's
      // data type, and finally fall back to a generic callback.
      $data_type = $items->getFieldDefinition()
        ->getItemDefinition()
        ->getDataType();
      $callback = $callbacks[$name] ?? $callbacks[$data_type] ?? $this->exportFieldItem(...);

      /** @var \Drupal\Core\Field\FieldItemInterface $item */
      foreach ($items as $item) {
        $values = $callback($item, $metadata);
        // If the callback returns NULL, this item should not be exported.
        if (is_array($values)) {
          $data[$name][] = $values;
        }
      }
    }
    return $data;
  }

  /**
   * Exports a single field item generically.
   *
   * Any properties of the item that are explicitly marked non-exportable (which
   * includes computed properties by default) will not be exported.
   *
   * Field types that need special handling should provide a custom callback
   * function to the exporter by subscribing to
   * \Drupal\Core\DefaultContent\PreExportEvent.
   *
   * @param \Drupal\Core\Field\FieldItemInterface $item
   *   The field item to export.
   *
   * @return array
   *   The exported field values.
   *
   * @see \Drupal\Core\DefaultContent\PreExportEvent::setCallback()
   */
  private function exportFieldItem(FieldItemInterface $item): array {
    $custom_serialized = Importer::getCustomSerializedPropertyNames($item);

    $values = [];
    foreach ($item->getProperties() as $name => $property) {
      $value = $property instanceof PrimitiveInterface ? $property->getCastedValue() : $property->getValue();

      if (is_string($value) && in_array($name, $custom_serialized, TRUE)) {
        $value = unserialize($value);
      }
      $values[$name] = $value;
    }
    return $values;
  }

  /**
   * Exports an entity reference field item.
   *
   * @param \Drupal\Core\Field\Plugin\Field\FieldType\EntityReferenceItemInterface&\Drupal\Core\Field\FieldItemInterface $item
   *   The field item to export.
   * @param \Drupal\Core\DefaultContent\ExportMetadata $metadata
   *   Any metadata about the entity being exported (e.g., dependencies).
   *
   * @return array|null
   *   The exported field values, or NULL if no entity is referenced and the
   *   item should not be exported.
   */
  private function exportReference(EntityReferenceItemInterface&FieldItemInterface $item, ExportMetadata $metadata): ?array {
    $entity = $item->get('entity')->getValue();
    // No entity is referenced, so there's nothing else we can do here.
    if ($entity === NULL) {
      return NULL;
    }
    $values = $this->exportFieldItem($item);

    if ($entity instanceof ContentEntityInterface) {
      // If the referenced entity is user 0 or 1, we can skip further
      // processing because user 0 is guaranteed to exist, and user 1 is
      // guaranteed to have existed at some point. Either way, there's no chance
      // of accidentally referencing the wrong entity on import.
      if ($entity instanceof AccountInterface && intval($entity->id()) < 2) {
        return array_map('intval', $values);
      }
      // Mark the referenced entity as a dependency of the one we're exporting.
      $metadata->addDependency($entity);

      $entity_type = $entity->getEntityType();
      // If the referenced entity ID is numeric, refer to it by UUID, which is
      // portable. If the ID isn't numeric, assume it's meant to be consistent
      // (like a config entity ID) and leave the reference as-is. Workspaces
      // are an example of an entity type that should be treated this way.
      if ($entity_type->hasKey('id') && $entity->getFieldDefinition($entity_type->getKey('id'))->getType() === 'integer') {
        $values['entity'] = $entity->uuid();
        unset($values['target_id']);
      }
    }
    return $values;
  }

}
Loading