Unverified Commit 3bfa87d3 authored by Alex Pott's avatar Alex Pott
Browse files

Issue #3547119 by phenaproxima, thejimbirch, wotnak, oily, smustgrave: Add a...

Issue #3547119 by phenaproxima, thejimbirch, wotnak, oily, smustgrave: Add a command to export all content of a particular entity type, with optional filtering by bundle
parent 050d15b0
Loading
Loading
Loading
Loading
Loading
+85 −30
Changes for core/lib/Drupal/Core/DefaultContent/ContentExportCommand.php: 85 added lines, 30 removed lines.
Original line number Diff line number Diff line
@@ -5,6 +5,9 @@
namespace Drupal\Core\DefaultContent;

use Drupal\Core\Command\BootableCommandTrait;
use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\Core\Entity\ContentEntityStorageInterface;
use Drupal\Core\Entity\EntityTypeBundleInfoInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\File\FileSystemInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
@@ -13,12 +16,11 @@
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Drupal\Core\Entity\ContentEntityInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;

/**
 * Exports a single content entity in YAML format.
 * Exports content entities in YAML format.
 *
 * @internal
 *    This API is experimental.
@@ -38,11 +40,16 @@ public function __construct(object $class_loader) {
   */
  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.')
      ->setDescription('Exports content entities in YAML format.')
      ->addArgument('entity_type_id', InputArgument::REQUIRED, 'The entity type to export (e.g., node, taxonomy_term).')
      ->addArgument('entity_id', InputArgument::OPTIONAL, 'The ID of the entity to export. Will usually be a number.')
      ->addOption('with-dependencies', 'W', InputOption::VALUE_NONE, "Recursively export all of the entities referenced by this entity into a directory structure.")
      ->addOption('dir', 'd', InputOption::VALUE_REQUIRED, 'The path where content should be exported.');
      ->addOption('bundle', 'b', InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Only export entities of the specified bundle(s).')
      ->addOption('dir', 'd', InputOption::VALUE_REQUIRED, 'The path where content should be exported.')
      ->addUsage('node 42')
      ->addUsage('node 3 --with-dependencies --dir=/path/to/content')
      ->addUsage('media --bundle=image --dir=images')
      ->addUsage('taxonomy_term --bundle=tags --bundle=categories --dir=terms');
  }

  /**
@@ -54,57 +61,105 @@ protected function execute(InputInterface $input, OutputInterface $output): int

    $entity_type_id = $input->getArgument('entity_type_id');
    $entity_id = $input->getArgument('entity_id');
    $bundles = $input->getOption('bundle');
    $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.");
    // Confirm that all specified bundles exist.
    if ($bundles) {
      $unknown_bundles = array_diff(
        $bundles,
        array_keys($container->get(EntityTypeBundleInfoInterface::class)->getBundleInfo($entity_type_id)),
      );
      if ($unknown_bundles) {
        $io->error("These bundles do not exist on the $entity_type_id entity type: " . implode(', ', $unknown_bundles));
        return 1;
      }

    $exporter = $container->get(Exporter::class);
    }

    $dir = $input->getOption('dir');
    $with_dependencies = $input->getOption('with-dependencies');
    if ($with_dependencies && empty($dir)) {
      throw new RuntimeException('The --dir option is required when exporting with dependencies.');
    $exporter = $container->get(Exporter::class);

    // If we're going to export multiple entities, or a single entity with its
    // dependencies, require the `--dir` option.
    if (empty($dir) && (empty($entity_id) || $with_dependencies)) {
      throw new RuntimeException('The --dir option is required to export multiple entities, or a single entity with its dependencies.');
    }
    $file_system = $container->get(FileSystemInterface::class);

    $count = 0;
    $storage = $entity_type_manager->getStorage($entity_type_id);
    foreach ($this->loadEntities($storage, $entity_id, $bundles) as $entity) {
      if ($with_dependencies) {
      $count = $exporter->exportWithDependencies($entity, $dir);

      $message = (string) $this->formatPlural($count, 'One item was exported to @dir.', '@count items were exported to @dir.', [
        '@dir' => $file_system->realpath($dir),
      ]);
      $io->success($message);
        $count += $exporter->exportWithDependencies($entity, $dir);
      }
      elseif ($dir) {
        $exporter->exportToFile($entity, $dir);

      $message = (string) $this->t('The @type "@label" was exported to @dir.', [
        '@type' => $entity->getEntityType()->getSingularLabel(),
        '@label' => $entity->label(),
        '@dir' => $file_system->realpath($dir),
      ]);
      $io->success($message);
        $count++;
      }
      else {
        $io->write((string) $exporter->export($entity));
        return 0;
      }
    }

    // If we were trying to export a specific entity and it didn't get exported,
    // that's an error.
    if ($entity_id && $count === 0) {
      $io->error("$entity_type_id $entity_id does not exist.");
      if ($bundles) {
        $io->caution('Maybe this entity is not one of the specified bundles: ' . implode(', ', $bundles));
      }
      return 1;
    }

    $file_system = $container->get(FileSystemInterface::class);
    $message = (string) $this->formatPlural(
      $count,
      'One entity was exported to @dir.',
      '@count entities were exported to @dir.',
      ['@dir' => $file_system->realpath($dir)],
    );
    $io->success($message);
    return 0;
  }

  /**
   * Find entities to export and yield them one by one.
   *
   * @param \Drupal\Core\Entity\ContentEntityStorageInterface $storage
   *   The entity storage handler.
   * @param string|int|null $entity_id
   *   The ID of the specific entity to load, or NULL to load all entities
   *   (probably filtered by bundle).
   * @param string[] $bundles
   *   (optional) The bundles to filter by.
   *
   * @return iterable<\Drupal\Core\Entity\ContentEntityInterface>
   *   A generator that yields content entities.
   */
  private function loadEntities(ContentEntityStorageInterface $storage, string|int|null $entity_id, array $bundles = []): iterable {
    $query = $storage->getQuery()
      ->accessCheck(FALSE);

    $entity_type = $storage->getEntityType();
    if ($bundles) {
      $query->condition($entity_type->getKey('bundle'), $bundles, 'IN');
    }
    if ($entity_id) {
      $query->condition($entity_type->getKey('id'), $entity_id);
    }
    foreach ($query->execute() as $id) {
      yield $storage->load($id);
    }
  }

}
+128 −3
Changes for core/tests/Drupal/FunctionalTests/DefaultContent/ContentExportTest.php: 128 added lines, 3 removed lines.
Original line number Diff line number Diff line
@@ -18,9 +18,11 @@
use Drupal\file\Entity\File;
use Drupal\FunctionalTests\Core\Recipe\RecipeTestTrait;
use Drupal\media\Entity\Media;
use Drupal\node\Entity\Node;
use Drupal\taxonomy\Entity\Term;
use Drupal\Tests\BrowserTestBase;
use Drupal\Tests\field\Traits\EntityReferenceFieldCreationTrait;
use Drupal\Tests\taxonomy\Traits\TaxonomyTestTrait;
use Drupal\user\Entity\User;
use Drupal\user\UserInterface;
use PHPUnit\Framework\Attributes\CoversClass;
@@ -40,6 +42,7 @@ class ContentExportTest extends BrowserTestBase {

  use EntityReferenceFieldCreationTrait;
  use RecipeTestTrait;
  use TaxonomyTestTrait;

  /**
   * {@inheritdoc}
@@ -200,7 +203,7 @@ public function testExportSingleEntityToDirectory(): void {
      "--dir=$dir",
    ]);
    $this->assertSame(0, $process->wait());
    $this->assertStringContainsString('The file "' . $file->label() . '" was exported to ', $process->getOutput());
    $this->assertStringContainsString('One entity was exported to', $process->getOutput());
    $this->assertFileExists($dir . '/file/' . $file->uuid() . '.yml');
    $this->assertFileExists($dir . '/file/' . $file->getFilename());
  }
@@ -233,14 +236,14 @@ public function testExportWithDependencies(): void {
    // With no `--dir` option, we should get an error.
    $process = $this->runDrupalCommand($command);
    $this->assertGreaterThan(0, $process->wait());
    $this->assertStringContainsString('The --dir option is required when exporting with dependencies.', $process->getErrorOutput());
    $this->assertStringContainsString('The --dir option is required to export multiple entities', $process->getErrorOutput());

    $command[] = "--dir=public://content";
    $process = $this->runDrupalCommand($command);
    $this->assertSame(0, $process->wait());
    $expected_output_dir = $this->container->get(FileSystemInterface::class)
      ->realpath('public://content');
    $this->assertStringContainsString('5 items were exported to ', $process->getOutput());
    $this->assertStringContainsString('5 entities were exported to ', $process->getOutput());

    $this->assertFileExists($expected_output_dir . '/node/' . $node->uuid() . '.yml');
    $this->assertFileExists($expected_output_dir . '/taxonomy_term/' . $node->field_tags[0]->entity->uuid() . '.yml');
@@ -369,6 +372,128 @@ public function testExportFileEntityWithMissingPhysicalFile(): void {
    $this->assertTrue($logger->hasRecordThatPasses($predicate));
  }

  /**
   * Tests exporting entities filtered by type.
   */
  public function testExportEntitiesFilteredByType(): void {
    // We should get an error if we try to export a non-existent entity type.
    $process = $this->runDrupalCommand(['content:export', 'camels']);
    $this->assertSame(1, $process->wait());
    $this->assertStringContainsString('The entity type "camels" does not exist.', $process->getOutput());

    // We should get an error if we try to export a config entity.
    $process = $this->runDrupalCommand(['content:export', 'taxonomy_vocabulary']);
    $this->assertSame(1, $process->wait());
    $this->assertStringContainsString('taxonomy_vocabulary is not a content entity type.', $process->getOutput());

    $content = Node::loadMultiple();
    $this->assertNotEmpty($content);

    $command = ['content:export', 'node'];
    // With no `--dir` option, we should get an error.
    $process = $this->runDrupalCommand($command);
    $this->assertGreaterThan(0, $process->wait());
    $this->assertStringContainsString('The --dir option is required to export multiple entities', $process->getErrorOutput());

    $command[] = "--dir=public://content";
    $process = $this->runDrupalCommand($command);
    $this->assertSame(0, $process->wait());
    $expected_output_dir = $this->container->get(FileSystemInterface::class)
      ->realpath('public://content');
    $this->assertStringContainsString(count($content) . ' entities were exported to ', $process->getOutput());

    /** @var \Drupal\node\NodeInterface $node */
    foreach ($content as $node) {
      $this->assertFileExists($expected_output_dir . '/node/' . $node->uuid() . '.yml');
    }
  }

  /**
   * Tests exporting entities filtered by bundle.
   */
  public function testExportEntitiesFilteredByBundle(): void {
    $command = ['content:export', 'node', '--bundle=article'];

    // With no `--dir` option, we should get an error.
    $process = $this->runDrupalCommand($command);
    $this->assertGreaterThan(0, $process->wait());
    $this->assertStringContainsString('The --dir option is required to export multiple entities', $process->getErrorOutput());

    $command[] = "--dir=public://content";
    $process = $this->runDrupalCommand($command);
    $this->assertSame(0, $process->wait());
    $expected_output_dir = $this->container->get(FileSystemInterface::class)
      ->realpath('public://content');
    $this->assertStringContainsString('2 entities were exported to ', $process->getOutput());

    $this->assertFileExists($expected_output_dir . '/node/2d3581c3-92c7-4600-8991-a0d4b3741198.yml');
    $this->assertFileExists($expected_output_dir . '/node/e1714f23-70c0-4493-8e92-af1901771921.yml');

    // Create two additional taxonomy vocabularies, with two terms each, to
    // test multiple `--bundle` options.
    $vocabulary1 = $this->createVocabulary();
    $vocabulary2 = $this->createVocabulary();
    $term1 = $this->createTerm($vocabulary1)->uuid();
    $term2 = $this->createTerm($vocabulary1)->uuid();
    $term3 = $this->createTerm($vocabulary2)->uuid();
    $term4 = $this->createTerm($vocabulary2)->uuid();

    $process = $this->runDrupalCommand([
      'content:export',
      'taxonomy_term',
      '--bundle=tags',
      '--bundle=' . $vocabulary2->id(),
      '--dir=public://content',
    ]);
    $this->assertSame(0, $process->wait());
    $this->assertStringContainsString('4 entities were exported to ', $process->getOutput());

    $tags = $this->container->get(EntityTypeManagerInterface::class)
      ->getStorage('taxonomy_term')
      ->loadByProperties(['vid' => 'tags']);
    $this->assertCount(2, $tags);
    foreach ($tags as $tag) {
      $this->assertFileExists($expected_output_dir . '/taxonomy_term/' . $tag->uuid() . '.yml');
    }
    $this->assertFileDoesNotExist($expected_output_dir . '/taxonomy_term/' . $term1 . '.yml');
    $this->assertFileDoesNotExist($expected_output_dir . '/taxonomy_term/' . $term2 . '.yml');
    $this->assertFileExists($expected_output_dir . '/taxonomy_term/' . $term3 . '.yml');
    $this->assertFileExists($expected_output_dir . '/taxonomy_term/' . $term4 . '.yml');

    // Export a single entity, with the bundle filter matching the entity's
    // bundle.
    $process = $this->runDrupalCommand([
      'content:export',
      'taxonomy_term',
      1,
      '--bundle=tags',
    ]);
    $this->assertSame(0, $process->wait());

    // If we try that with a mismatched bundle filter, it should result in no
    // entity being exported, which is an error, but a hint should be given.
    $process = $this->runDrupalCommand([
      'content:export',
      'taxonomy_term',
      1,
      '--bundle=' . $vocabulary1->id(),
    ]);
    $this->assertSame(1, $process->wait());
    $output = $process->getOutput();
    $this->assertStringContainsString('taxonomy_term 1 does not exist.', $output);
    $this->assertStringContainsString('Maybe this entity is not one of the specified bundles: ' . $vocabulary1->id(), $output);

    // We should get an error if we try to export bundles that don't exist.
    $process = $this->runDrupalCommand([
      'content:export',
      'taxonomy_term',
      '--bundle=junk',
      '--bundle=tags',
    ]);
    $this->assertSame(1, $process->wait());
    $this->assertStringContainsString('These bundles do not exist on the taxonomy_term entity type: junk', $process->getOutput());
  }

  /**
   * Recursively sorts an array by key.
   *