├── tests ├── fixtures │ ├── core │ │ ├── foo.bar.yml │ │ └── fr │ │ │ └── foo.bar.yml │ ├── merge1 │ │ ├── bar.foo.yml │ │ ├── foo.bar.yml │ │ └── fr │ │ │ ├── bar.foo.yml │ │ │ └── foo.bar.yml │ └── merge2 │ │ ├── baa.baa.yml │ │ ├── foo.bar.yml │ │ ├── de │ │ └── foo.bar.yml │ │ └── fr │ │ └── foo.bar.yml ├── bootstrap.php ├── ConfigSyncMergeFactoryTest.php ├── ConfigSyncMergeTestBase.php └── ConfigStorageTest.php ├── .gitignore ├── src ├── Exception │ ├── InvalidStorage.php │ └── UnsupportedMethod.php ├── ConfigSyncMergeFactory.php └── ConfigStorage.php ├── .travis.yml ├── phpunit.xml.dist ├── drupal.services.yml ├── composer.json └── README.md /tests/fixtures/core/foo.bar.yml: -------------------------------------------------------------------------------- 1 | value: core 2 | -------------------------------------------------------------------------------- /tests/fixtures/core/fr/foo.bar.yml: -------------------------------------------------------------------------------- 1 | value: fr-core 2 | -------------------------------------------------------------------------------- /tests/fixtures/merge1/bar.foo.yml: -------------------------------------------------------------------------------- 1 | value: merge1 2 | -------------------------------------------------------------------------------- /tests/fixtures/merge1/foo.bar.yml: -------------------------------------------------------------------------------- 1 | value: merge1 2 | -------------------------------------------------------------------------------- /tests/fixtures/merge2/baa.baa.yml: -------------------------------------------------------------------------------- 1 | value: merge2 2 | -------------------------------------------------------------------------------- /tests/fixtures/merge2/foo.bar.yml: -------------------------------------------------------------------------------- 1 | value: merge2 2 | -------------------------------------------------------------------------------- /tests/fixtures/merge1/fr/bar.foo.yml: -------------------------------------------------------------------------------- 1 | value: fr-merge1 2 | -------------------------------------------------------------------------------- /tests/fixtures/merge1/fr/foo.bar.yml: -------------------------------------------------------------------------------- 1 | value: fr-merge1 2 | -------------------------------------------------------------------------------- /tests/fixtures/merge2/de/foo.bar.yml: -------------------------------------------------------------------------------- 1 | value: de-merge2 2 | -------------------------------------------------------------------------------- /tests/fixtures/merge2/fr/foo.bar.yml: -------------------------------------------------------------------------------- 1 | value: fr-merge2 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor/ 2 | composer.lock 3 | phpunit.xml 4 | /cover/ 5 | -------------------------------------------------------------------------------- /tests/bootstrap.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ./tests/ 6 | 7 | 8 | 9 | 10 | ./src/ 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /drupal.services.yml: -------------------------------------------------------------------------------- 1 | services: 2 | config_sync_merge.config.storage.staging: 3 | class: alexpott\ConfigSyncMerge\ConfigStorage 4 | factory: config_sync_merge.factory:getSync 5 | decorates: config.storage.staging 6 | public: false 7 | config_sync_merge.factory: 8 | class: alexpott\ConfigSyncMerge\ConfigSyncMergeFactory 9 | arguments: ['@settings', '@config_sync_merge.config.storage.staging.inner'] 10 | public: false 11 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "alexpott/config-sync-merge", 3 | "description": "Merge multiple Drupal config directories into a single sync storage", 4 | "type": "library", 5 | "license": "GPL-2.0+", 6 | "authors": [ 7 | { 8 | "name": "Alex Pott", 9 | "email": "alex.a.pott@googlemail.com" 10 | } 11 | ], 12 | "require": { 13 | "drupal/core": "^8.3" 14 | }, 15 | "autoload": { 16 | "psr-4": { 17 | "alexpott\\ConfigSyncMerge\\": "src/" 18 | } 19 | }, 20 | "autoload-dev": { 21 | "psr-4": { 22 | "alexpott\\ConfigSyncMerge\\Tests\\": "tests/" 23 | } 24 | }, 25 | "require-dev": { 26 | "phpunit/phpunit": "^5.7", 27 | "mikey179/vfsStream": "^1.6" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /tests/ConfigSyncMergeFactoryTest.php: -------------------------------------------------------------------------------- 1 | $this->convertDirsToVfs($dirs) 19 | ]); 20 | $factory = new ConfigSyncMergeFactory($settings, $this->core); 21 | $this->assertSame($expected, $factory->getSync()->listAll($prefix)); 22 | } 23 | 24 | } 25 | -------------------------------------------------------------------------------- /src/ConfigSyncMergeFactory.php: -------------------------------------------------------------------------------- 1 | settings = $settings; 36 | $this->coreSyncStorage = $coreSyncStorage; 37 | } 38 | 39 | /** 40 | * {@inheritdoc} 41 | */ 42 | public function getSync() 43 | { 44 | $storages = [$this->coreSyncStorage]; 45 | foreach ($this->settings->get('config_sync_merge_directories', []) as $directory) { 46 | $storages[$directory] = new FileStorage($directory); 47 | } 48 | return new ConfigStorage($storages); 49 | } 50 | 51 | } 52 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Config sync merge 2 | ## About 3 | This library allows you to merge configuration from other directories with your 4 | Drupal site's configuration sync directory. The additional directories are 5 | treated as read-only. If configuration exists in multiple locations, your 6 | configuration sync directory is read first and then the other directories in the 7 | order that they are defined in settings.php. 8 | 9 | If you export configuration using ```drush config-export``` it will only make 10 | changes to your configuration sync directory. The other directories will not 11 | be changed. If configuration being saved is an exact match for configuration in 12 | an other directory it will not be written to your config sync directory. If the 13 | configuration is different, it will be written. 14 | 15 | **IMPORTANT:** Configuration in the deepest directory should represent a complete 16 | site. This will make it easier to manage configuration removals. 17 | 18 | ## Installation 19 | Get the code: 20 | ```bash 21 | composer require alexpott/config-sync-merge 22 | ``` 23 | 24 | Add the following lines to your settings.php: 25 | ```php 26 | $settings['container_yamls'][] = $app_root . '/vendor/alexpott/config-sync-merge/drupal.services.yml'; 27 | $settings['config_sync_merge_directories'] = [ 28 | 'PATH/TO/ADDITIONAL/CONFIG' 29 | ]; 30 | ``` 31 | 32 | NB: If you have changed the vendor location you will need to alter the first line. 33 | 34 | ## Suggested workflows 35 | @todo -------------------------------------------------------------------------------- /tests/ConfigSyncMergeTestBase.php: -------------------------------------------------------------------------------- 1 | getName()); 29 | $root = vfsStream::setup('root'); 30 | $this->fixtures = vfsStream::copyFromFileSystem(__DIR__ . '/fixtures', $root); 31 | $this->core = new FileStorage(vfsStream::url('root') . '/core'); 32 | } 33 | 34 | /** 35 | * @param string[] $dirs List of directory names relative to the fixtures directory 36 | * @return string[] List of VFS directory paths. 37 | */ 38 | protected function convertDirsToVfs($dirs) 39 | { 40 | $root = vfsStream::url('root'); 41 | foreach ($dirs as &$dir) { 42 | $dir = $root . '/' . $dir; 43 | } 44 | return $dirs; 45 | } 46 | 47 | 48 | public function dataProviderTestListAll() 49 | { 50 | return [ 51 | [[], '', ['foo.bar']], 52 | [[], 'foo', ['foo.bar']], 53 | [[], 'bar', []], 54 | [['merge1'], '', ['bar.foo', 'foo.bar']], 55 | [['merge1'], 'bar', ['bar.foo']], 56 | [['merge1'], 'baa', []], 57 | [['merge1', 'merge2'], '', ['baa.baa', 'bar.foo', 'foo.bar']], 58 | ]; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/ConfigStorage.php: -------------------------------------------------------------------------------- 1 | $storage) { 45 | if (!($storage instanceof StorageInterface)) { 46 | throw new InvalidStorage('All storages must implement \Drupal\Core\Config\StorageInterface'); 47 | } 48 | if ($storage->getCollectionName() !== $collection) { 49 | $storages[$key] = $storage->createCollection($collection); 50 | } 51 | } 52 | $this->storages = $storages; 53 | } 54 | 55 | /** 56 | * {@inheritdoc} 57 | */ 58 | public function exists($name) 59 | { 60 | foreach($this->storages as $storage) { 61 | if ($storage->exists($name)) { 62 | return TRUE; 63 | } 64 | } 65 | return FALSE; 66 | } 67 | 68 | /** 69 | * {@inheritdoc} 70 | */ 71 | public function read($name) 72 | { 73 | foreach($this->storages as $storage) { 74 | if ($storage->exists($name)) { 75 | // Return the data from the first storage where the 76 | // configuration is found. 77 | return $storage->read($name); 78 | } 79 | } 80 | return FALSE; 81 | } 82 | 83 | /** 84 | * {@inheritdoc} 85 | */ 86 | public function readMultiple(array $names) 87 | { 88 | $data = []; 89 | foreach($this->storages as $storage) { 90 | // Remove any already read configuration names from the list to 91 | // read. 92 | $names = array_diff($names, array_keys($data)); 93 | // Merge in any new data. Note that $data is the second argument to 94 | // array_merge() to ensure that nothing is overwritten. This is 95 | // unnecessary because of the line above but also reducing the 96 | // amount of configuration to read is a performance optimisation. 97 | $data = array_merge($storage->readMultiple($names), $data); 98 | } 99 | ksort($data); 100 | return $data; 101 | } 102 | 103 | /** 104 | * {@inheritdoc} 105 | */ 106 | public function write($name, array $data) 107 | { 108 | // Only need to write if data is different. 109 | if ($this->read($name) === $data) { 110 | return TRUE; 111 | } 112 | // Only write to the first storage. 113 | return $this->storages[0]->write($name, $data); 114 | } 115 | 116 | /** 117 | * {@inheritdoc} 118 | */ 119 | public function delete($name) 120 | { 121 | // We only delete from the first storage. 122 | return $this->storages[0]->delete($name); 123 | } 124 | 125 | /** 126 | * {@inheritdoc} 127 | */ 128 | public function rename($name, $new_name) 129 | { 130 | throw new UnsupportedMethod('Renaming is not supported'); 131 | } 132 | 133 | /** 134 | * {@inheritdoc} 135 | */ 136 | public function encode($data) 137 | { 138 | return $this->storages[0]->encode($data); 139 | } 140 | 141 | /** 142 | * {@inheritdoc} 143 | */ 144 | public function decode($raw) 145 | { 146 | return $this->storages[0]->decode($raw); 147 | } 148 | 149 | /** 150 | * {@inheritdoc} 151 | */ 152 | public function listAll($prefix = '') 153 | { 154 | $list = []; 155 | foreach($this->storages as $storage) { 156 | $list = array_merge($list, $storage->listAll($prefix)); 157 | } 158 | $list = array_unique($list); 159 | sort($list); 160 | return $list; 161 | } 162 | 163 | /** 164 | * {@inheritdoc} 165 | */ 166 | public function deleteAll($prefix = '') 167 | { 168 | // We only delete from the first storage. 169 | return $this->storages[0]->deleteAll($prefix); 170 | } 171 | 172 | /** 173 | * {@inheritdoc} 174 | */ 175 | public function createCollection($collection) 176 | { 177 | return new static( 178 | $this->storages, 179 | $collection 180 | ); 181 | } 182 | 183 | /** 184 | * {@inheritdoc} 185 | */ 186 | public function getAllCollectionNames() 187 | { 188 | $collections = []; 189 | foreach($this->storages as $storage) { 190 | $collections = array_merge($collections, $storage->getAllCollectionNames()); 191 | } 192 | $collections = array_unique($collections); 193 | sort($collections); 194 | return $collections; 195 | } 196 | 197 | /** 198 | * {@inheritdoc} 199 | */ 200 | public function getCollectionName() 201 | { 202 | return $this->storages[0]->getCollectionName(); 203 | } 204 | 205 | } -------------------------------------------------------------------------------- /tests/ConfigStorageTest.php: -------------------------------------------------------------------------------- 1 | core]; 23 | foreach ($this->convertDirsToVfs($dirs) as $directory) { 24 | $storages[] = new FileStorage($directory); 25 | } 26 | return $storages; 27 | } 28 | 29 | public function testConstructorExceptionInvalidStorage() 30 | { 31 | $this->expectException(InvalidStorage::class); 32 | $this->expectExceptionMessage('All storages must implement \Drupal\Core\Config\StorageInterface'); 33 | new ConfigStorage(['a']); 34 | } 35 | 36 | public function testConstructorExceptionNoStorage() 37 | { 38 | $this->expectException(InvalidStorage::class); 39 | $this->expectExceptionMessage('ConfigStorage requires at least one storage to be passed to the constructor'); 40 | new ConfigStorage([]); 41 | } 42 | 43 | /** 44 | * @dataProvider dataProviderTestExists 45 | */ 46 | public function testExists(array $dirs, $name, $expected) 47 | { 48 | $config_sync_merge = new ConfigStorage($this->getStorages($dirs)); 49 | $this->assertSame($expected, $config_sync_merge->exists($name)); 50 | } 51 | 52 | public function dataProviderTestExists() 53 | { 54 | return [ 55 | [[], 'foo.bar', TRUE], 56 | [[], 'bar.foo', FALSE], 57 | [['merge1'], 'foo.bar', TRUE], 58 | [['merge1'], 'bar.foo', TRUE], 59 | [['merge1'], 'baa.baa', FALSE], 60 | [['merge1', 'merge2'], 'baa.baa', TRUE], 61 | ]; 62 | } 63 | 64 | /** 65 | * @dataProvider dataProviderTestRead 66 | */ 67 | public function testRead(array $dirs, $name, $expected) 68 | { 69 | $config_sync_merge = new ConfigStorage($this->getStorages($dirs)); 70 | $this->assertSame($expected, $config_sync_merge->read($name)); 71 | } 72 | 73 | public function dataProviderTestRead() 74 | { 75 | return [ 76 | [[], 'foo.bar', ['value' => 'core']], 77 | [[], 'bar.foo', FALSE], 78 | [['merge1'], 'foo.bar', ['value' => 'core']], 79 | [['merge1'], 'bar.foo', ['value' => 'merge1']], 80 | [['merge1'], 'baa.baa', FALSE], 81 | [['merge1', 'merge2'], 'baa.baa', ['value' => 'merge2']], 82 | [['merge1', 'merge2'], 'foo.bar', ['value' => 'core']], 83 | ]; 84 | } 85 | 86 | /** 87 | * @dataProvider dataProviderTestReadMultiple 88 | */ 89 | public function testReadMultiple(array $dirs, array $names, $expected) 90 | { 91 | $config_sync_merge = new ConfigStorage($this->getStorages($dirs)); 92 | $this->assertSame($expected, $config_sync_merge->readMultiple($names)); 93 | } 94 | 95 | public function dataProviderTestReadMultiple() 96 | { 97 | return [ 98 | [[], ['foo.bar'], ['foo.bar' => ['value' => 'core']]], 99 | [[], ['bar.foo'], []], 100 | [['merge1'], ['foo.bar', 'bar.foo'], ['bar.foo' => ['value' => 'merge1'], 'foo.bar' => ['value' => 'core']]], 101 | [['merge1'], ['foo.bar', 'bar.foo', 'baa.baa'], ['bar.foo' => ['value' => 'merge1'], 'foo.bar' => ['value' => 'core']]], 102 | [['merge1', 'merge2'], ['foo.bar', 'bar.foo', 'baa.baa'], ['baa.baa' => ['value' => 'merge2'], 'bar.foo' => ['value' => 'merge1'], 'foo.bar' => ['value' => 'core']]], 103 | [['merge1', 'merge2'], ['a.a', 'b.b'], []], 104 | 105 | ]; 106 | } 107 | 108 | public function testWrite() { 109 | $config_sync_merge = new ConfigStorage($this->getStorages(['merge1', 'merge2'])); 110 | $config_sync_merge->write('test.write', ['test' => 'data']); 111 | $this->assertTrue(file_exists(vfsStream::url('root') . '/core/test.write.yml')); 112 | $vfs_dirs = $this->convertDirsToVfs(['merge1', 'merge2']); 113 | foreach ($vfs_dirs as $dir) { 114 | $this->assertFalse(file_exists($dir . '/test.write.yml')); 115 | } 116 | 117 | // Ensure that we only write to the first storage when the values are different. 118 | $config_sync_merge->write('baa.baa', ['value' => 'merge2']); 119 | $this->assertFalse(file_exists(vfsStream::url('root') . '/core/baa.baa.yml')); 120 | $this->assertSame(['value' => 'merge2'], $config_sync_merge->read('baa.baa')); 121 | 122 | $config_sync_merge->write('baa.baa', ['value' => 'override_merge2_write']); 123 | $this->assertTrue(file_exists(vfsStream::url('root') . '/core/baa.baa.yml')); 124 | $this->assertSame(['value' => 'override_merge2_write'], $config_sync_merge->read('baa.baa')); 125 | $this->assertSame(['value' => 'merge2'], $config_sync_merge->decode(file_get_contents($vfs_dirs[1] . '/baa.baa.yml'))); 126 | } 127 | 128 | public function testDelete() { 129 | $config_sync_merge = new ConfigStorage($this->getStorages(['merge1', 'merge2'])); 130 | $this->assertFalse($config_sync_merge->delete('a.a')); 131 | $this->assertTrue(file_exists(vfsStream::url('root') . '/core/foo.bar.yml')); 132 | $this->assertTrue($config_sync_merge->delete('foo.bar')); 133 | $this->assertFalse(file_exists(vfsStream::url('root') . '/core/foo.bar.yml')); 134 | $vfs_dirs = $this->convertDirsToVfs(['merge1', 'merge2']); 135 | // The only storages are not writable so the file will not be deleted. 136 | foreach ($vfs_dirs as $dir) { 137 | $this->assertTrue(file_exists($dir . '/foo.bar.yml')); 138 | } 139 | 140 | // You can't delete configuration from later storages. 141 | $this->assertFalse($config_sync_merge->delete('baa.baa')); 142 | $this->assertTrue($config_sync_merge->exists('baa.baa')); 143 | } 144 | 145 | public function testRename() { 146 | $config_sync_merge = new ConfigStorage($this->getStorages(['merge1', 'merge2'])); 147 | $this->expectException(UnsupportedMethod::class); 148 | $this->expectExceptionMessage('Renaming is not supported'); 149 | $config_sync_merge->rename('foo.bar', 'a.a'); 150 | } 151 | 152 | public function testEncode() { 153 | $config_sync_merge = new ConfigStorage($this->getStorages([])); 154 | $this->assertSame("foo: bar\n", $config_sync_merge->encode(['foo' => 'bar'])); 155 | } 156 | 157 | public function testDecode() { 158 | $config_sync_merge = new ConfigStorage($this->getStorages([])); 159 | $this->assertSame(['foo' => 'bar'], $config_sync_merge->decode("foo: bar\n")); 160 | } 161 | 162 | /** 163 | * @dataProvider dataProviderTestListAll 164 | */ 165 | public function testListAll(array $dirs, $prefix, $expected) 166 | { 167 | $config_sync_merge = new ConfigStorage($this->getStorages($dirs)); 168 | $this->assertSame($expected, $config_sync_merge->listAll($prefix)); 169 | } 170 | 171 | /** 172 | * @dataProvider dataProviderTestDeleteAll 173 | */ 174 | public function testDeleteAll(array $dirs, $prefix, $expected, array $list) 175 | { 176 | $config_sync_merge = new ConfigStorage($this->getStorages($dirs)); 177 | $this->assertSame($expected, $config_sync_merge->deleteAll($prefix)); 178 | 179 | // Ensure that only configuration in the first storage is deleted. 180 | $this->assertSame([], $this->core->listAll($prefix)); 181 | $this->assertSame($list, $config_sync_merge->listAll()); 182 | } 183 | 184 | public function dataProviderTestDeleteAll() 185 | { 186 | return [ 187 | [[], '', TRUE, []], 188 | [[], 'foo', TRUE, []], 189 | [[], 'bar', TRUE, ['foo.bar']], 190 | [['merge1'], '', TRUE, ['bar.foo', 'foo.bar']], 191 | [['merge1', 'merge2'], '', TRUE, ['baa.baa', 'bar.foo', 'foo.bar']], 192 | ]; 193 | } 194 | 195 | public function testCreateCollection() 196 | { 197 | $storages = $this->getStorages(['merge1', 'merge2']); 198 | $config_sync_merge = new ConfigStorage($storages); 199 | $config_sync_merge = $config_sync_merge->createCollection('fr'); 200 | $this->assertSame(['value' => 'fr-core'], $config_sync_merge->read('foo.bar')); 201 | $this->assertFalse($config_sync_merge->exists('baa.baa')); 202 | $this->assertSame(['value' => 'fr-merge1'], $config_sync_merge->read('bar.foo')); 203 | 204 | $config_sync_merge = new ConfigStorage($storages, 'fr'); 205 | $this->assertSame(['value' => 'fr-core'], $config_sync_merge->read('foo.bar')); 206 | $this->assertFalse($config_sync_merge->exists('baa.baa')); 207 | $this->assertSame(['value' => 'fr-merge1'], $config_sync_merge->read('bar.foo')); 208 | 209 | $config_sync_merge = new ConfigStorage($storages, 'lol'); 210 | $this->assertSame([], $config_sync_merge->listAll()); 211 | $config_sync_merge = new ConfigStorage($storages); 212 | $this->assertSame([], $config_sync_merge->createCollection('lol')->listAll()); 213 | } 214 | 215 | public function testGetCollectionName() 216 | { 217 | $storages = $this->getStorages(['merge1', 'merge2']); 218 | $config_sync_merge = new ConfigStorage($storages); 219 | $this->assertSame('', $config_sync_merge->getCollectionName()); 220 | $config_sync_merge = $config_sync_merge->createCollection('fr'); 221 | $this->assertSame('fr', $config_sync_merge->getCollectionName()); 222 | 223 | $config_sync_merge = new ConfigStorage($storages, 'fr'); 224 | $this->assertSame('fr', $config_sync_merge->getCollectionName()); 225 | 226 | $config_sync_merge = new ConfigStorage($storages, 'lol'); 227 | $this->assertSame('lol', $config_sync_merge->getCollectionName()); 228 | } 229 | /** 230 | * @dataProvider dataProviderTestGetAllCollectionNames 231 | */ 232 | public function testGetAllCollectionNames($dirs, $expected) { 233 | $config_sync_merge = new ConfigStorage($this->getStorages($dirs)); 234 | $this->assertSame($expected, $config_sync_merge->getAllCollectionNames()); 235 | } 236 | 237 | public function dataProviderTestGetAllCollectionNames() { 238 | return [ 239 | [[], ['fr']], 240 | [['merge1'], ['fr']], 241 | [['merge1', 'merge2'], ['de', 'fr']], 242 | ]; 243 | } 244 | } 245 | 246 | namespace Drupal\Core\Config; 247 | 248 | if (!function_exists('drupal_chmod')) { 249 | function drupal_chmod($uri, $mode = NULL) { 250 | return TRUE; 251 | } 252 | } 253 | 254 | if (!function_exists('drupal_unlink')) { 255 | function drupal_unlink($uri) { 256 | return unlink($uri); 257 | } 258 | } --------------------------------------------------------------------------------