Verified Commit 1e10d0d2 authored by Lee Rowlands's avatar Lee Rowlands
Browse files

feat: #3594337 Add a command to export configuration

By: mradcliffe
By: bircher
By: moshe weitzman
By: jb044
By: larowlan
parent 5f4a8ff8
Loading
Loading
Loading
Loading
Loading
+6 −0
Changes for core/.phpstan-baseline.php: 6 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -3169,6 +3169,12 @@
	'count' => 1,
	'path' => __DIR__ . '/lib/Drupal/Core/Config/ImportStorageTransformer.php',
];
$ignoreErrors[] = [
	'message' => '#^Method Drupal\\\\Core\\\\Config\\\\Command\\\\ConfigExportCommand\\:\\:replaceStorageContents\\(\\) has no return type specified\\.$#',
	'identifier' => 'missingType.return',
	'count' => 1,
	'path' => __DIR__ . '/lib/Drupal/Core/Config/Command/ConfigExportCommand.php',
];
$ignoreErrors[] = [
	'message' => '#^Method Drupal\\\\Core\\\\Config\\\\Importer\\\\ConfigImporterBatch\\:\\:finish\\(\\) has no return type specified\\.$#',
	'identifier' => 'missingType.return',
+133 −0
Changes for core/lib/Drupal/Core/Config/Command/ConfigExportCommand.php: 133 added lines, 0 removed lines.
Original line number Diff line number Diff line
<?php

declare(strict_types=1);

namespace Drupal\Core\Config\Command;

use Drupal\Core\Command\Exception\UserAbortException;
use Drupal\Core\Config\ConfigDirectoryNotDefinedException;
use Drupal\Core\Config\StorageComparer;
use Drupal\Core\Config\StorageCopyTrait;
use Drupal\Core\Config\StorageInterface;
use Drupal\Core\Config\SyncFactory;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Attribute\Option;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\DependencyInjection\Attribute\Autowire;

/**
 * Export configuration.
 *
 * @internal
 */
#[AsCommand(
  name: 'config:export',
  description: 'Export configuration.',
)]
final class ConfigExportCommand extends Command {

  use StringTranslationTrait;
  use StorageCopyTrait;

  public function __construct(
    #[Autowire(service: 'config.storage.export')]
    private readonly StorageInterface $export,
    private readonly SyncFactory $syncFactory,
  ) {
    parent::__construct();
  }

  /**
   * Export the configuration.
   *
   * @param \Symfony\Component\Console\Style\SymfonyStyle $io
   *   The symfony style.
   * @param bool $yes
   *   The command option to skip the confirmation question.
   *
   * @return int
   *   The command result.
   */
  public function __invoke(
    SymfonyStyle $io,
    #[Option(description: 'Answer the confirmation question with yes.', shortcut: 'y')]
    bool $yes = FALSE,
  ): int {
    // Resolve the sync storage here rather than in the constructor: the
    // factory throws when no sync directory is configured, and a throwing
    // constructor would break `dr list` for every command.
    try {
      $sync = $this->syncFactory->get();
    }
    catch (ConfigDirectoryNotDefinedException $e) {
      $io->error($e->getMessage());
      return self::FAILURE;
    }

    $comparer = new StorageComparer($this->export, $sync);
    $comparer->createChangelist();

    if (!$comparer->hasChanges()) {
      $io->success((string) $this->t('The active configuration to export matches the configuration in the sync directory.'));
      return self::SUCCESS;
    }

    $this->renderTable($comparer, $io);

    if (!$yes && !$io->confirm((string) $this->t('Do you want to export the configuration?'))) {
      throw new UserAbortException('Cancelled');
    }

    self::replaceStorageContents($this->export, $sync);

    $io->success((string) $this->t('Configuration successfully exported'));

    return self::SUCCESS;
  }

  /**
   * Prints the change list of a storage comparer as a table.
   *
   * @param \Drupal\Core\Config\StorageComparer $comparer
   *   The comparer holding the change list to render.
   * @param \Symfony\Component\Console\Style\SymfonyStyle $io
   *   The input and output style.
   */
  protected function renderTable(StorageComparer $comparer, SymfonyStyle $io): void {
    $translations = [
      'delete' => (string) $this->t('Delete'),
      'update' => (string) $this->t('Update'),
      'create' => (string) $this->t('Create'),
      'rename' => (string) $this->t('Rename'),
    ];
    $styles = [
      'delete' => 'fg=white;bg=red',
      'update' => 'fg=black;bg=yellow',
      'create' => 'fg=white;bg=green',
      'rename' => 'fg=black;bg=cyan',
    ];
    $header = [
      (string) $this->t('Collection'),
      (string) $this->t('Config'),
      (string) $this->t('Operation'),
    ];

    $rows = [];
    foreach ($comparer->getAllCollectionNames() as $collection) {
      foreach ($comparer->getChangelist(NULL, $collection) as $op => $names) {
        foreach ($names as $name) {
          $rows[] = [
            $collection,
            $op !== 'rename' ? $name : str_replace('::', ' -> ', $name),
            \sprintf('<%s>%s</>', $styles[$op], $translations[$op]),
          ];
        }
      }
    }

    $io->table($header, $rows);
  }

}
+186 −0
Changes for core/tests/Drupal/KernelTests/Core/Config/ConfigExportCommandTest.php: 186 added lines, 0 removed lines.
Original line number Diff line number Diff line
<?php

declare(strict_types=1);

namespace Drupal\KernelTests\Core\Config;

use Drupal\Core\Command\Exception\UserAbortException;
use Drupal\Core\Config\Command\ConfigExportCommand;
use Drupal\Core\Config\MemoryStorage;
use Drupal\Core\Config\StorageInterface;
use Drupal\Core\Config\SyncFactory;
use Drupal\Core\Site\Settings;
use Drupal\KernelTests\KernelTestBase;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Tester\CommandTester;

// cspell:ignore arrr

/**
 * Tests the 'config:export' command.
 *
 * This command performs the same configuration export a user can trigger
 * from the Configuration module's user interface, except it writes the
 * result directly into the sync directory rather than a downloaded archive.
 *
 * @see \Drupal\Tests\config\Functional\ConfigExportUITest
 */
#[Group('config')]
#[RunTestsInSeparateProcesses]
#[CoversClass(ConfigExportCommand::class)]
class ConfigExportCommandTest extends KernelTestBase {

  /**
   * {@inheritdoc}
   */
  protected static $modules = ['system', 'config_test'];

  /**
   * {@inheritdoc}
   */
  protected function setUp(): void {
    parent::setUp();
    $this->installConfig(['system', 'config_test']);
    $this->config('system.site')
      ->set('name', 'dr config')
      ->set('slogan', 'dr config for export')
      ->save();

    // Set up an override.
    // @see \Drupal\Tests\config\Functional\ConfigExportUITest::testExport()
    $GLOBALS['config']['system.maintenance']['message'] = 'Foo';
  }

  /**
   * Builds a command tester for the 'config:export' command.
   */
  private function commandTester(?Settings $settings = NULL): CommandTester {
    // The SyncFactory service is private, so build it by hand.
    $sync_factory = new SyncFactory(
      $this->container->getParameter('app.root'),
      $settings ?? Settings::getInstance(),
      $this->container->get('config.storage'),
      $this->container->get('extension.list.module'),
      $this->container->get('extension.list.theme'),
      $this->container->get('config.parsing_cache'),
    );
    $command = new ConfigExportCommand($this->container->get('config.storage.export'), $sync_factory);
    return new CommandTester($command);
  }

  /**
   * Tests the command fails cleanly when no sync directory is defined.
   */
  public function testUndefinedSyncDirectory(): void {
    $settings = Settings::getAll();
    unset($settings['config_sync_directory']);
    $tester = $this->commandTester(new Settings($settings));
    self::assertSame(Command::FAILURE, $tester->execute(['--yes' => TRUE]));
    self::assertStringContainsString('config sync directory is not defined', $tester->getDisplay());
  }

  /**
   * Tests export of configuration.
   */
  public function testExportCommand(): void {
    $active = $this->container->get('config.storage');
    $sync = $this->container->get('config.storage.sync');

    // Ensure the override is in effect.
    self::assertSame('Foo', \Drupal::config('system.maintenance')->get('message'));
    // Before the first export the config is the same.
    self::assertEmpty($sync->listAll());

    // Execute it with --yes.
    $tester = $this->commandTester();
    $code = $tester->execute(['--yes' => TRUE]);
    $display = $tester->getDisplay();

    self::assertSame(Command::SUCCESS, $code);
    self::assertStringContainsString('config_test.dynamic.dotted.default', $display);
    self::assertStringContainsString('Configuration successfully exported', $display);
    // Ensure the override was not exported.
    $this->assertNotSame('Foo', $sync->read('system.maintenance')['message']);
    // The sync storage and the active are now the same.
    self::assertStorageEquals($active, $sync);

    // Run the command again. We do not expect a confirmation question.
    $tester = $this->commandTester();
    $code = $tester->execute([]);
    self::assertSame(Command::SUCCESS, $code);
    // Nothing changed, normalize tester output because the string is long.
    $display = preg_replace('/\s+/', ' ', $tester->getDisplay(TRUE));
    self::assertStringContainsString('The active configuration to export matches the configuration in the sync directory.', $display);

    // Back up the sync storage.
    $expected = new MemoryStorage();
    $this->copyConfig($sync, $expected);

    // Install the transformation test module.
    $this->enableModules(['config_transformer_test']);
    $this->config('system.site')
      ->set('name', 'dr transformed')
      ->set('slogan', 'dr not transformed for pirates')
      ->save();
    // Reset the export storage so that it does a transformation again.
    (function () {
      unset($this->storage);

    })->call($this->container->get('config.storage.export'));

    // Execute the command but cancel the export.
    $tester = $this->commandTester();
    $tester->setInputs(['no']);
    try {
      $tester->execute([], ['interactive' => TRUE]);
      $this->fail('Expected a UserAbortException to be thrown.');
    }
    catch (UserAbortException) {
      // Expected.
    }
    // The sync storage remains intact.
    self::assertStorageEquals($expected, $sync);

    // Execute the command and answer yes.
    $tester = $this->commandTester();
    $tester->setInputs(['yes']);
    $code = $tester->execute([], ['interactive' => TRUE]);

    self::assertSame(Command::SUCCESS, $code);

    // The test transformer transforms the slogan on export.
    $exported_site = $sync->read('system.site');
    self::assertSame('dr transformed', $exported_site['name']);
    self::assertSame('dr config for export Arrr', $exported_site['slogan']);
  }

  /**
   * Asserts that two config storage objects have the same content.
   *
   * @param \Drupal\Core\Config\StorageInterface $expected
   *   The storage with the expected data.
   * @param \Drupal\Core\Config\StorageInterface $actual
   *   The storage with the actual data.
   * @param string $message
   *   The message to add to the assertion.
   */
  protected static function assertStorageEquals(StorageInterface $expected, StorageInterface $actual, string $message = ''): void {
    // The same collections have to exist.
    static::assertEqualsCanonicalizing($expected->getAllCollectionNames(), $actual->getAllCollectionNames(), $message);
    // Now loop over all collections and assert the data to be equal.
    foreach (array_merge([StorageInterface::DEFAULT_COLLECTION], $expected->getAllCollectionNames()) as $collection) {
      $expected_collection = $expected->createCollection($collection);
      $actual_collection = $actual->createCollection($collection);
      // The same names are present in both.
      static::assertEqualsCanonicalizing($expected_collection->listAll(), $actual_collection->listAll(), $message);
      foreach ($expected_collection->listAll() as $name) {
        // The same data can be read from both.
        static::assertEquals($expected_collection->read($name), $actual_collection->read($name), $message . ' ' . $name);
      }
    }
  }

}