From 52bd9a1206bc13ec9ae6ece9fbef3d5d6b8745e3 Mon Sep 17 00:00:00 2001
From: Nathaniel Catchpole <catch@35733.no-reply.drupal.org>
Date: Fri, 29 Mar 2019 15:12:46 +0000
Subject: [PATCH] Issue #3036193 by bircher, mpotter, larowlan, alexpott: Add
 ExportStorage to allow config export in third party tools

---
 core/core.services.yml                        |   3 +
 .../Drupal/Core/Config/ReadOnlyStorage.php    | 118 +++++++++++
 .../src/Controller/ConfigController.php       |  31 ++-
 .../Core/Config/ConfigExportStorageTest.php   |  52 +++++
 .../Tests/Core/Config/ReadOnlyStorageTest.php | 196 ++++++++++++++++++
 5 files changed, 392 insertions(+), 8 deletions(-)
 create mode 100644 core/lib/Drupal/Core/Config/ReadOnlyStorage.php
 create mode 100644 core/tests/Drupal/KernelTests/Core/Config/ConfigExportStorageTest.php
 create mode 100644 core/tests/Drupal/Tests/Core/Config/ReadOnlyStorageTest.php

diff --git a/core/core.services.yml b/core/core.services.yml
index 08b09d99534a..a4327bda9bc4 100644
--- a/core/core.services.yml
+++ b/core/core.services.yml
@@ -315,6 +315,9 @@ services:
     public: false
     tags:
       - { name: backend_overridable }
+  config.storage.export:
+    class: Drupal\Core\Config\ReadOnlyStorage
+    arguments: ['@config.storage']
   # @deprecated in Drupal 8.0.x and will be removed before 9.0.0. Use
   #   config.storage.sync instead.
   # @see https://www.drupal.org/node/2574957
diff --git a/core/lib/Drupal/Core/Config/ReadOnlyStorage.php b/core/lib/Drupal/Core/Config/ReadOnlyStorage.php
new file mode 100644
index 000000000000..d03560d2d251
--- /dev/null
+++ b/core/lib/Drupal/Core/Config/ReadOnlyStorage.php
@@ -0,0 +1,118 @@
+<?php
+
+namespace Drupal\Core\Config;
+
+/**
+ * A ReadOnlyStorage decorates a storage and does not allow writing to it.
+ */
+class ReadOnlyStorage implements StorageInterface {
+
+  /**
+   * The config storage that we are decorating.
+   *
+   * @var \Drupal\Core\Config\StorageInterface
+   */
+  protected $storage;
+
+  /**
+   * Create a ReadOnlyStorage decorating another storage.
+   *
+   * @param \Drupal\Core\Config\StorageInterface $storage
+   *   The decorated storage.
+   */
+  public function __construct(StorageInterface $storage) {
+    $this->storage = $storage;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function exists($name) {
+    return $this->storage->exists($name);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function read($name) {
+    return $this->storage->read($name);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function readMultiple(array $names) {
+    return $this->storage->readMultiple($names);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function write($name, array $data) {
+    throw new \BadMethodCallException(__METHOD__ . ' is not allowed on a ReadOnlyStorage');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function delete($name) {
+    throw new \BadMethodCallException(__METHOD__ . ' is not allowed on a ReadOnlyStorage');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function rename($name, $new_name) {
+    throw new \BadMethodCallException(__METHOD__ . ' is not allowed on a ReadOnlyStorage');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function encode($data) {
+    return $this->storage->encode($data);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function decode($raw) {
+    return $this->storage->decode($raw);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function listAll($prefix = '') {
+    return $this->storage->listAll($prefix);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function deleteAll($prefix = '') {
+    throw new \BadMethodCallException(__METHOD__ . ' is not allowed on a ReadOnlyStorage');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function createCollection($collection) {
+    return new static($this->storage->createCollection($collection));
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getAllCollectionNames() {
+    return $this->storage->getAllCollectionNames();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getCollectionName() {
+    return $this->storage->getCollectionName();
+  }
+
+}
diff --git a/core/modules/config/src/Controller/ConfigController.php b/core/modules/config/src/Controller/ConfigController.php
index 1a72b9951bae..7e70978aa527 100644
--- a/core/modules/config/src/Controller/ConfigController.php
+++ b/core/modules/config/src/Controller/ConfigController.php
@@ -41,6 +41,13 @@ class ConfigController implements ContainerInjectionInterface {
    */
   protected $configManager;
 
+  /**
+   * The export storage.
+   *
+   * @var \Drupal\Core\Config\StorageInterface
+   */
+  protected $exportStorage;
+
   /**
    * The file download controller.
    *
@@ -72,7 +79,8 @@ public static function create(ContainerInterface $container) {
       $container->get('config.manager'),
       new FileDownloadController(),
       $container->get('diff.formatter'),
-      $container->get('file_system')
+      $container->get('file_system'),
+      $container->get('config.storage.export')
     );
   }
 
@@ -91,14 +99,21 @@ public static function create(ContainerInterface $container) {
    *   The diff formatter.
    * @param \Drupal\Core\File\FileSystemInterface $file_system
    *   The file system.
+   * @param \Drupal\Core\Config\StorageInterface $export_storage
+   *   The export storage.
    */
-  public function __construct(StorageInterface $target_storage, StorageInterface $source_storage, ConfigManagerInterface $config_manager, FileDownloadController $file_download_controller, DiffFormatter $diff_formatter, FileSystemInterface $file_system) {
+  public function __construct(StorageInterface $target_storage, StorageInterface $source_storage, ConfigManagerInterface $config_manager, FileDownloadController $file_download_controller, DiffFormatter $diff_formatter, FileSystemInterface $file_system, StorageInterface $export_storage = NULL) {
     $this->targetStorage = $target_storage;
     $this->sourceStorage = $source_storage;
     $this->configManager = $config_manager;
     $this->fileDownloadController = $file_download_controller;
     $this->diffFormatter = $diff_formatter;
     $this->fileSystem = $file_system;
+    if (is_null($export_storage)) {
+      @trigger_error('The config.storage.export service must be passed to ConfigController::__construct(), it is required before Drupal 9.0.0. See https://www.drupal.org/node/3037022.', E_USER_DEPRECATED);
+      $export_storage = \Drupal::service('config.storage.export');
+    }
+    $this->exportStorage = $export_storage;
   }
 
   /**
@@ -113,13 +128,13 @@ public function downloadExport() {
     }
 
     $archiver = new ArchiveTar(file_directory_temp() . '/config.tar.gz', 'gz');
-    // Get raw configuration data without overrides.
-    foreach ($this->configManager->getConfigFactory()->listAll() as $name) {
-      $archiver->addString("$name.yml", Yaml::encode($this->configManager->getConfigFactory()->get($name)->getRawData()));
+    // Add all contents of the export storage to the archive.
+    foreach ($this->exportStorage->listAll() as $name) {
+      $archiver->addString("$name.yml", Yaml::encode($this->exportStorage->read($name)));
     }
-    // Get all override data from the remaining collections.
-    foreach ($this->targetStorage->getAllCollectionNames() as $collection) {
-      $collection_storage = $this->targetStorage->createCollection($collection);
+    // Get all  data from the remaining collections.
+    foreach ($this->exportStorage->getAllCollectionNames() as $collection) {
+      $collection_storage = $this->exportStorage->createCollection($collection);
       foreach ($collection_storage->listAll() as $name) {
         $archiver->addString(str_replace('.', '/', $collection) . "/$name.yml", Yaml::encode($collection_storage->read($name)));
       }
diff --git a/core/tests/Drupal/KernelTests/Core/Config/ConfigExportStorageTest.php b/core/tests/Drupal/KernelTests/Core/Config/ConfigExportStorageTest.php
new file mode 100644
index 000000000000..89755e0a5325
--- /dev/null
+++ b/core/tests/Drupal/KernelTests/Core/Config/ConfigExportStorageTest.php
@@ -0,0 +1,52 @@
+<?php
+
+namespace Drupal\KernelTests\Core\Config;
+
+use Drupal\KernelTests\KernelTestBase;
+
+/**
+ * Tests configuration export storage.
+ *
+ * @group config
+ */
+class ConfigExportStorageTest extends KernelTestBase {
+
+  /**
+   * Modules to enable.
+   *
+   * @var array
+   */
+  public static $modules = ['system', 'config_test'];
+
+  protected function setUp() {
+    parent::setUp();
+    $this->installConfig(['system', 'config_test']);
+  }
+
+  /**
+   * Tests configuration override.
+   */
+  public function testExportStorage() {
+    /** @var \Drupal\Core\Config\StorageInterface $active */
+    $active = $this->container->get('config.storage');
+    /** @var \Drupal\Core\Config\StorageInterface $export */
+    $export = $this->container->get('config.storage.export');
+
+    // Test that the active and the export storage contain the same config.
+    $this->assertNotEmpty($active->listAll());
+    $this->assertEquals($active->listAll(), $export->listAll());
+    foreach ($active->listAll() as $name) {
+      $this->assertEquals($active->read($name), $export->read($name));
+    }
+
+    // Test that the export storage is read-only.
+    try {
+      $export->deleteAll();
+      $this->fail("export storage must not allow editing");
+    }
+    catch (\BadMethodCallException $exception) {
+      $this->pass("Exception is thrown.");
+    }
+  }
+
+}
diff --git a/core/tests/Drupal/Tests/Core/Config/ReadOnlyStorageTest.php b/core/tests/Drupal/Tests/Core/Config/ReadOnlyStorageTest.php
new file mode 100644
index 000000000000..06b3ab87d6b2
--- /dev/null
+++ b/core/tests/Drupal/Tests/Core/Config/ReadOnlyStorageTest.php
@@ -0,0 +1,196 @@
+<?php
+
+namespace Drupal\Tests\Core\Config;
+
+use Drupal\Core\Config\MemoryStorage;
+use Drupal\Core\Config\ReadOnlyStorage;
+use Drupal\Core\Config\StorageCopyTrait;
+use Drupal\Core\Config\StorageInterface;
+use Drupal\Tests\UnitTestCase;
+
+/**
+ * @coversDefaultClass \Drupal\Core\Config\ReadOnlyStorage
+ * @group Config
+ */
+class ReadOnlyStorageTest extends UnitTestCase {
+
+  use StorageCopyTrait;
+
+  /**
+   * The memory storage containing the data.
+   *
+   * @var \Drupal\Core\Config\MemoryStorage
+   */
+  protected $memory;
+
+  /**
+   * The read-only storage under test.
+   *
+   * @var \Drupal\Core\Config\ReadOnlyStorage
+   */
+  protected $storage;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    // Set up a memory storage we can manipulate to set fixtures.
+    $this->memory = new MemoryStorage();
+    // Wrap the memory storage in the read-only storage to test it.
+    $this->storage = new ReadOnlyStorage($this->memory);
+  }
+
+  /**
+   * @covers ::exists
+   * @covers ::read
+   * @covers ::readMultiple
+   * @covers ::listAll
+   *
+   * @dataProvider readMethodsProvider
+   */
+  public function testReadOperations($method, $arguments, $fixture) {
+    $this->setRandomFixtureConfig($fixture);
+
+    $expected = call_user_func_array([$this->memory, $method], $arguments);
+    $actual = call_user_func_array([$this->storage, $method], $arguments);
+    $this->assertEquals($expected, $actual);
+  }
+
+  /**
+   * Provide the methods that work transparently.
+   *
+   * @return array
+   *   The data.
+   */
+  public function readMethodsProvider() {
+    $fixture = [
+      StorageInterface::DEFAULT_COLLECTION => ['config.a', 'config.b', 'other.a'],
+    ];
+
+    $data = [];
+    $data[] = ['exists', ['config.a'], $fixture];
+    $data[] = ['exists', ['not.existing'], $fixture];
+    $data[] = ['read', ['config.a'], $fixture];
+    $data[] = ['read', ['not.existing'], $fixture];
+    $data[] = ['readMultiple', [['config.a', 'config.b', 'not']], $fixture];
+    $data[] = ['listAll', [''], $fixture];
+    $data[] = ['listAll', ['config'], $fixture];
+    $data[] = ['listAll', ['none'], $fixture];
+
+    return $data;
+  }
+
+  /**
+   * @covers ::write
+   * @covers ::delete
+   * @covers ::rename
+   * @covers ::deleteAll
+   *
+   * @dataProvider writeMethodsProvider
+   */
+  public function testWriteOperations($method, $arguments, $fixture) {
+    $this->setRandomFixtureConfig($fixture);
+
+    // Create an independent memory storage as a backup.
+    $backup = new MemoryStorage();
+    static::replaceStorageContents($this->memory, $backup);
+
+    try {
+      call_user_func_array([$this->storage, $method], $arguments);
+      $this->fail("exception not thrown");
+    }
+    catch (\BadMethodCallException $exception) {
+      $this->assertEquals(ReadOnlyStorage::class . '::' . $method . ' is not allowed on a ReadOnlyStorage', $exception->getMessage());
+    }
+
+    // Assert that the memory storage has not been altered.
+    $this->assertTrue($backup == $this->memory);
+  }
+
+  /**
+   * Provide the methods that throw an exception.
+   *
+   * @return array
+   *   The data
+   */
+  public function writeMethodsProvider() {
+    $fixture = [
+      StorageInterface::DEFAULT_COLLECTION => ['config.a', 'config.b'],
+    ];
+
+    $data = [];
+    $data[] = ['write', ['config.a', (array) $this->getRandomGenerator()->object()], $fixture];
+    $data[] = ['write', [$this->randomMachineName(), (array) $this->getRandomGenerator()->object()], $fixture];
+    $data[] = ['delete', ['config.a'], $fixture];
+    $data[] = ['delete', [$this->randomMachineName()], $fixture];
+    $data[] = ['rename', ['config.a', 'config.b'], $fixture];
+    $data[] = ['rename', ['config.a', $this->randomMachineName()], $fixture];
+    $data[] = ['rename', [$this->randomMachineName(), $this->randomMachineName()], $fixture];
+    $data[] = ['deleteAll', [''], $fixture];
+    $data[] = ['deleteAll', ['config'], $fixture];
+    $data[] = ['deleteAll', ['other'], $fixture];
+
+    return $data;
+  }
+
+  /**
+   * @covers ::getAllCollectionNames
+   * @covers ::getCollectionName
+   * @covers ::createCollection
+   */
+  public function testCollections() {
+    $fixture = [
+      StorageInterface::DEFAULT_COLLECTION => [$this->randomMachineName()],
+      'A' => [$this->randomMachineName()],
+      'B' => [$this->randomMachineName()],
+      'C' => [$this->randomMachineName()],
+    ];
+    $this->setRandomFixtureConfig($fixture);
+
+    $this->assertEquals(['A', 'B', 'C'], $this->storage->getAllCollectionNames());
+    foreach (array_keys($fixture) as $collection) {
+      $storage = $this->storage->createCollection($collection);
+      // Assert that the collection storage is still a read-only storage.
+      $this->assertInstanceOf(ReadOnlyStorage::class, $storage);
+      $this->assertEquals($collection, $storage->getCollectionName());
+    }
+  }
+
+  /**
+   * @covers ::encode
+   * @covers ::decode
+   */
+  public function testEncodeDecode() {
+    $array = (array) $this->getRandomGenerator()->object();
+    $string = $this->getRandomGenerator()->string();
+
+    // Assert reversibility of encoding and decoding.
+    $this->assertEquals($array, $this->storage->decode($this->storage->encode($array)));
+    $this->assertEquals($string, $this->storage->encode($this->storage->decode($string)));
+    // Assert same results as the decorated storage.
+    $this->assertEquals($this->memory->encode($array), $this->storage->encode($array));
+    $this->assertEquals($this->memory->decode($string), $this->storage->decode($string));
+  }
+
+  /**
+   * Generate random config in the memory storage.
+   *
+   * @param array $config
+   *   The config keys, keyed by the collection.
+   */
+  protected function setRandomFixtureConfig($config) {
+    // Erase previous fixture.
+    foreach (array_merge([StorageInterface::DEFAULT_COLLECTION], $this->memory->getAllCollectionNames()) as $collection) {
+      $this->memory->createCollection($collection)->deleteAll();
+    }
+
+    foreach ($config as $collection => $keys) {
+      $storage = $this->memory->createCollection($collection);
+      foreach ($keys as $key) {
+        // Create some random config.
+        $storage->write($key, (array) $this->getRandomGenerator()->object());
+      }
+    }
+  }
+
+}
-- 
GitLab