From 3a7b22c2b16de361cd8a5997cf0cfed7d5ff9ecb Mon Sep 17 00:00:00 2001 From: Greg Anderson Date: Mon, 31 Jul 2017 13:10:25 -0700 Subject: [PATCH 1/5] Add a ConfigOverlay class. --- src/Config.php | 8 +-- src/ConfigInterface.php | 11 +-- src/ConfigOverlay.php | 134 ++++++++++++++++++++++++++++++++++++ tests/ConfigOverlayTest.php | 114 ++++++++++++++++++++++++++++++ 4 files changed, 259 insertions(+), 8 deletions(-) create mode 100644 src/ConfigOverlay.php create mode 100644 tests/ConfigOverlayTest.php diff --git a/src/Config.php b/src/Config.php index 7ec7a1b..ef65c15 100644 --- a/src/Config.php +++ b/src/Config.php @@ -36,12 +36,12 @@ public function has($key) /** * {@inheritdoc} */ - public function get($key, $defaultOverride = null) + public function get($key, $defaultFallback = null) { if ($this->has($key)) { return $this->config->get($key); } - return $this->getDefault($key, $defaultOverride); + return $this->getDefault($key, $defaultFallback); } /** @@ -84,9 +84,9 @@ public function hasDefault($key) /** * {@inheritdoc} */ - public function getDefault($key, $defaultOverride = null) + public function getDefault($key, $defaultFallback = null) { - return $this->hasDefault($key) ? $this->defaults[$key] : $defaultOverride; + return $this->hasDefault($key) ? $this->defaults[$key] : $defaultFallback; } /** diff --git a/src/ConfigInterface.php b/src/ConfigInterface.php index d260f75..d4ebad9 100644 --- a/src/ConfigInterface.php +++ b/src/ConfigInterface.php @@ -12,11 +12,14 @@ public function has($key); * Fetch a configuration value * * @param string $key Which config item to look up - * @param string|null $defaultOverride Override usual default value with a different default. Deprecated; provide defaults to the config processor instead. + * @param string|null $defaultFallback Fallback default value to use when + * configuration object has neither a value nor a default. Use is + * discouraged; use default context in ConfigOverlay, or provide defaults + * using a config processor. * * @return mixed */ - public function get($key, $defaultOverride = null); + public function get($key, $defaultFallback = null); /** * Set a config value @@ -55,11 +58,11 @@ public function hasDefault($key); * Return the default value for a given configuration item. * * @param string $key - * @param mixed $defaultOverride + * @param mixed $defaultFallback * * @return mixed */ - public function getDefault($key, $defaultOverride = null); + public function getDefault($key, $defaultFallback = null); /** * Set the default value for a configuration setting. This allows us to diff --git a/src/ConfigOverlay.php b/src/ConfigOverlay.php new file mode 100644 index 0000000..9270ec1 --- /dev/null +++ b/src/ConfigOverlay.php @@ -0,0 +1,134 @@ +contexts['default'] = new Config(); + $this->contexts['process'] = new Config(); + } + + /** + * Add a named configuration object to the configuration overlay. + * Configuration objects added LAST have HIGHEST priority, with the + * exception of the fact that the process context always has the + * highest priority. + */ + public function addContext($name, ConfigInterface $config) + { + $process = $this->contexts['process']; + unset($this->contexts['process']); + unset($this->contexts[$name]); + $this->contexts[$name] = $config; + $this->contexts['process'] = $process; + } + + public function hasContext($name) + { + return isset($this->contexts[$name]); + } + + public function getContext($name) + { + if ($this->hasContext($name)) { + return $this->contexts[$name]; + } + return new Config(); + } + + /** + * Determine if a non-default config value exists. + */ + public function findContext($key) + { + foreach (array_reverse($this->contexts) as $name => $config) { + if ($config->has($key)) { + return $config; + } + } + return false; + } + + /** + * @inheritdoc + */ + public function has($key) + { + return $this->findContext($key) != false; + } + + /** + * @inheritdoc + */ + public function get($key, $default = null) + { + $context = $this->findContext($key); + if ($context) { + return $context->get($key, $default); + } + return $default; + } + + /** + * @inheritdoc + */ + public function set($key, $value) + { + return $this->contexts['process']->set($key, $value); + } + + /** + * @inheritdoc + */ + public function import($data) + { + throw new \Exception('The method "import" is not supported for the ConfigOverlay class.'); + } + + /** + * @inheritdoc + */ + public function export() + { + $export = []; + foreach ($this->contexts as $name => $config) { + $export = array_merge_recursive($export, $config->export()); + } + return $export; + } + + /** + * @inheritdoc + */ + public function hasDefault($key) + { + return $this->contexts['default']->has($key); + } + + /** + * @inheritdoc + */ + public function getDefault($key, $default = null) + { + return $this->contexts['default']->get($key, $default); + } + + /** + * @inheritdoc + */ + public function setDefault($key, $value) + { + return $this->contexts['default']->set($key, $value); + } +} diff --git a/tests/ConfigOverlayTest.php b/tests/ConfigOverlayTest.php new file mode 100644 index 0000000..da996eb --- /dev/null +++ b/tests/ConfigOverlayTest.php @@ -0,0 +1,114 @@ +import([ + 'hidden-by-a' => 'alias hidden-by-a', + 'hidden-by-process' => 'alias hidden-by-process', + 'options' =>[ + 'a-a' => 'alias-a', + ], + 'command' => [ + 'foo' => [ + 'bar' => [ + 'command' => [ + 'options' => [ + 'a-b' => 'alias-b', + ], + ], + ], + ], + ], + ]); + + $configFileContext = new Config(); + $configFileContext->import([ + 'hidden-by-cf' => 'config-file hidden-by-cf', + 'hidden-by-a' => 'config-file hidden-by-a', + 'hidden-by-process' => 'config-file hidden-by-process', + 'options' =>[ + 'cf-a' => 'config-file-a', + ], + 'command' => [ + 'foo' => [ + 'bar' => [ + 'command' => [ + 'options' => [ + 'cf-b' => 'config-file-b', + ], + ], + ], + ], + ], + ]); + + $this->overlay = new ConfigOverlay(); + $this->overlay->set('hidden-by-process', 'process-h'); + $this->overlay->addContext('cf', $configFileContext); + $this->overlay->addContext('a', $aliasContext); + $this->overlay->setDefault('df-a', 'default'); + $this->overlay->setDefault('hidden-by-a', 'default hidden-by-a'); + $this->overlay->setDefault('hidden-by-cf', 'default hidden-by-cf'); + $this->overlay->setDefault('hidden-by-process', 'default hidden-by-process'); + } + + public function testGetPriority() + { + $this->assertEquals('process-h', $this->overlay->get('hidden-by-process')); + $this->assertEquals('config-file hidden-by-cf', $this->overlay->get('hidden-by-cf')); + $this->assertEquals('alias hidden-by-a', $this->overlay->get('hidden-by-a')); + } + + public function testDefault() + { + $this->assertEquals('alias-a', $this->overlay->get('options.a-a')); + $this->assertEquals('alias-a', $this->overlay->get('options.a-a', 'ignored')); + $this->assertEquals('default', $this->overlay->getDefault('df-a', 'ignored')); + $this->assertEquals('nsv', $this->overlay->getDefault('a-a', 'nsv')); + + $this->overlay->setDefault('df-a', 'new value'); + $this->assertEquals('new value', $this->overlay->getDefault('df-a', 'ignored')); + } + + public function testExport() + { + $data = $this->overlay->export(); + + $this->assertEquals('config-file-a', $data['options']['cf-a']); + $this->assertEquals('alias-a', $data['options']['a-a']); + } + + /** + * @expectedException Exception + */ + public function testImport() + { + $data = $this->overlay->import(['a' => 'value']); + } + + public function testChangePriority() + { + // Get and re-add the 'cf' context. Now, it should have a higher + // priority than the 'alias' context, but should still have a lower + // priority than the 'process' context. + $configFileContext = $this->overlay->getContext('cf'); + $this->overlay->addContext('cf', $configFileContext); + + // These asserts are the same as in testGetPriority + $this->assertEquals('process-h', $this->overlay->get('hidden-by-process')); + $this->assertEquals('config-file hidden-by-cf', $this->overlay->get('hidden-by-cf')); + + // This one has changed: the config-file value is now found instead + // of the alias value. + $this->assertEquals('config-file hidden-by-a', $this->overlay->get('hidden-by-a')); + } +} From ae68833cadc21085c87ba98e3f246950d081bdea Mon Sep 17 00:00:00 2001 From: Greg Anderson Date: Mon, 31 Jul 2017 13:28:08 -0700 Subject: [PATCH 2/5] Increase coverage. --- tests/ConfigOverlayTest.php | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/ConfigOverlayTest.php b/tests/ConfigOverlayTest.php index da996eb..20de3aa 100644 --- a/tests/ConfigOverlayTest.php +++ b/tests/ConfigOverlayTest.php @@ -111,4 +111,17 @@ public function testChangePriority() // of the alias value. $this->assertEquals('config-file hidden-by-a', $this->overlay->get('hidden-by-a')); } + + public function testDoesNotHave() + { + $context = $this->overlay->getContext('no-such-context'); + $data = $context->export(); + $this->assertEquals('[]', json_encode($data)); + + $this->assertTrue(!$this->overlay->has('no-such-key')); + $this->assertTrue(!$this->overlay->hasDefault('no-such-default')); + + $this->assertEquals('no-such-value', $this->overlay->get('no-such-key', 'no-such-value')); + + } } From 8f073a27de17574da91e0a6da8544b2c1169baef Mon Sep 17 00:00:00 2001 From: Greg Anderson Date: Mon, 31 Jul 2017 14:11:49 -0700 Subject: [PATCH 3/5] Ensure we always return the correct '$this' in the ConfigOverlay class. --- src/ConfigOverlay.php | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/ConfigOverlay.php b/src/ConfigOverlay.php index 9270ec1..c5ebd5e 100644 --- a/src/ConfigOverlay.php +++ b/src/ConfigOverlay.php @@ -32,6 +32,8 @@ public function addContext($name, ConfigInterface $config) unset($this->contexts[$name]); $this->contexts[$name] = $config; $this->contexts['process'] = $process; + + return $this; } public function hasContext($name) @@ -85,7 +87,8 @@ public function get($key, $default = null) */ public function set($key, $value) { - return $this->contexts['process']->set($key, $value); + $this->contexts['process']->set($key, $value); + return $this; } /** @@ -129,6 +132,7 @@ public function getDefault($key, $default = null) */ public function setDefault($key, $value) { - return $this->contexts['default']->set($key, $value); + $this->contexts['default']->set($key, $value); + return $this; } } From 943d05fc0a838c7b6af4c75b376983af9d498097 Mon Sep 17 00:00:00 2001 From: Greg Anderson Date: Mon, 31 Jul 2017 15:13:25 -0700 Subject: [PATCH 4/5] Adjust priority handling strategy. Provide an 'addPlaceholder' method to define the priority of the overlayed contexts. --- src/ConfigOverlay.php | 53 ++++++++++++++++++++++++++++++------- tests/ConfigOverlayTest.php | 46 +++++++++++++++++++++++++++++--- 2 files changed, 86 insertions(+), 13 deletions(-) diff --git a/src/ConfigOverlay.php b/src/ConfigOverlay.php index c5ebd5e..56a5004 100644 --- a/src/ConfigOverlay.php +++ b/src/ConfigOverlay.php @@ -13,10 +13,13 @@ class ConfigOverlay implements ConfigInterface { protected $contexts = []; + const DEFAULT_CONTEXT = 'default'; + const PROCESS_CONTEXT = 'process'; + public function __construct() { - $this->contexts['default'] = new Config(); - $this->contexts['process'] = new Config(); + $this->contexts[self::DEFAULT_CONTEXT] = new Config(); + $this->contexts[self::PROCESS_CONTEXT] = new Config(); } /** @@ -24,18 +27,48 @@ public function __construct() * Configuration objects added LAST have HIGHEST priority, with the * exception of the fact that the process context always has the * highest priority. + * + * If a context has already been added, its priority will not change. */ public function addContext($name, ConfigInterface $config) { - $process = $this->contexts['process']; - unset($this->contexts['process']); - unset($this->contexts[$name]); + $process = $this->contexts[self::PROCESS_CONTEXT]; + unset($this->contexts[self::PROCESS_CONTEXT]); $this->contexts[$name] = $config; - $this->contexts['process'] = $process; + $this->contexts[self::PROCESS_CONTEXT] = $process; return $this; } + /** + * Add a placeholder context that will be prioritized higher than + * existing contexts. This is done to ensure that contexts added + * later will maintain a higher priority if the placeholder context + * is later relaced with a different configuration set via addContext(). + * + * @param string $name + * @return $this + */ + public function addPlaceholder($name) + { + return $this->addContext($name, new Config()); + } + + /** + * Increase the priority of the named context such that it is higher + * in priority than any existing context except for the 'process' + * context. + * + * @param string $name + * @return $this + */ + public function increasePriority($name) + { + $config = $this->getContext($name); + unset($this->contexts[$name]); + return $this->addContext($name, $config); + } + public function hasContext($name) { return isset($this->contexts[$name]); @@ -87,7 +120,7 @@ public function get($key, $default = null) */ public function set($key, $value) { - $this->contexts['process']->set($key, $value); + $this->contexts[self::PROCESS_CONTEXT]->set($key, $value); return $this; } @@ -116,7 +149,7 @@ public function export() */ public function hasDefault($key) { - return $this->contexts['default']->has($key); + return $this->contexts[self::DEFAULT_CONTEXT]->has($key); } /** @@ -124,7 +157,7 @@ public function hasDefault($key) */ public function getDefault($key, $default = null) { - return $this->contexts['default']->get($key, $default); + return $this->contexts[self::DEFAULT_CONTEXT]->get($key, $default); } /** @@ -132,7 +165,7 @@ public function getDefault($key, $default = null) */ public function setDefault($key, $value) { - $this->contexts['default']->set($key, $value); + $this->contexts[self::DEFAULT_CONTEXT]->set($key, $value); return $this; } } diff --git a/tests/ConfigOverlayTest.php b/tests/ConfigOverlayTest.php index 20de3aa..e73e853 100644 --- a/tests/ConfigOverlayTest.php +++ b/tests/ConfigOverlayTest.php @@ -95,13 +95,24 @@ public function testImport() $data = $this->overlay->import(['a' => 'value']); } + public function testMaintainPriority() + { + // Get and re-add the 'cf' context. Its priority should not change. + $configFileContext = $this->overlay->getContext('cf'); + $this->overlay->addContext('cf', $configFileContext); + + // These asserts are the same as in testGetPriority + $this->assertEquals('process-h', $this->overlay->get('hidden-by-process')); + $this->assertEquals('config-file hidden-by-cf', $this->overlay->get('hidden-by-cf')); + $this->assertEquals('alias hidden-by-a', $this->overlay->get('hidden-by-a')); + } + public function testChangePriority() { - // Get and re-add the 'cf' context. Now, it should have a higher + // Increase the priority of the 'cf' context. Now, it should have a higher // priority than the 'alias' context, but should still have a lower // priority than the 'process' context. - $configFileContext = $this->overlay->getContext('cf'); - $this->overlay->addContext('cf', $configFileContext); + $this->overlay->increasePriority('cf'); // These asserts are the same as in testGetPriority $this->assertEquals('process-h', $this->overlay->get('hidden-by-process')); @@ -112,6 +123,35 @@ public function testChangePriority() $this->assertEquals('config-file hidden-by-a', $this->overlay->get('hidden-by-a')); } + public function testPlaceholder() + { + $this->overlay->addPlaceholder('lower'); + + $higherContext = new Config(); + $higherContext->import(['priority-test' => 'higher']); + + $lowerContext = new Config(); + $lowerContext->import(['priority-test' => 'lower']); + + // Usually 'lower' would have the highest priority, since it is + // added last. However, our earlier call to 'addPlaceholder' reserves + // a spot for it, so the 'higher' context will end up with a higher + // priority. + $this->overlay->addContext('higher', $higherContext); + $this->overlay->addContext('lower', $lowerContext); + $this->assertEquals('higher', $this->overlay->get('priority-test', 'neither')); + + // Test to see that we can change the value of the 'higher' context, + // and the change will be reflected in the overlay. + $higherContext->set('priority-test', 'changed'); + $this->assertEquals('changed', $this->overlay->get('priority-test', 'neither')); + + // Test to see that the 'process' context still has the highest priority. + $this->overlay->set('priority-test', 'process'); + $higherContext->set('priority-test', 'ignored'); + $this->assertEquals('process', $this->overlay->get('priority-test', 'neither')); + } + public function testDoesNotHave() { $context = $this->overlay->getContext('no-such-context'); From 2ebac31d45380d47bf515091832545a98e86ce64 Mon Sep 17 00:00:00 2001 From: Greg Anderson Date: Fri, 11 Aug 2017 13:26:11 -0700 Subject: [PATCH 5/5] Add a description of configuration overlays to the README. --- README.md | 33 +++++++++++++++++++++++++++++++++ src/ConfigOverlay.php | 5 +++++ 2 files changed, 38 insertions(+) diff --git a/README.md b/README.md index 65be8da..2cc894e 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,39 @@ $value = $config->get('a.b.c'); ``` [dflydev/dot-access-data](https://github.com/dflydev/dot-access-data) is leveraged to provide this capability. +### Configuration Overlays + +Optionally, you may use the ConfigOverlay class to combine multiple configuration objects implamenting ConfigInterface into a single, prioritized configuration object. It is not necessary to use a configuration overlay; if your only goal is to merge configuration from multiple files, you may follow the example above to extend a processor with multiple configuration files, and then import the result into a single configuration object. This will cause newer configuration items to overwrite any existing values stored under the same key. + +A configuration overlay can achieve the same end result without overwriting any config values. The advantage of doing this is that different configuration overlays could be used to create separate "views" on different collections of configuration. A configuration overlay is also useful if you wish to temporarily override some configuration values, and then put things back the way they were by removing the overlay. +``` +use Consolidation\Config\Config; +use Consolidation\Config\ConfigOverlay; +use Consolidation\Config\YamlConfigLoader; +use Consolidation\Config\ConfigProcessor; + +$config1 = new Config(); +$config2 = new Config(); +$loader = new YamlConfigLoader(); +$processor = new ConfigProcessor(); +$processor->extend($loader->load('c1.yml')); +$config1->import($processor->export()); +$processor = new ConfigProcessor(); +$processor->extend($loader->load('c2.yml')); +$config2->import($processor->export()); + +$configOverlay = (new ConfigOverlay()) + ->addContext('one', $config1) + ->addContext('two', $config2); + +$value = $configOverlay->get('key'); + +$configOverlay->removeContext('two'); + +$value = $configOverlay->get('key'); +``` +The first call to `$configOverlay->get('key')`, above, will return the value from `key` in `$config2`, if it exists, or from `$config1` otherwise. The second call to the same function, after `$config2` is removed, will only consider configuration values stored in `$config1`. + ## External Examples ### Load Configuration Files with Symfony/Config diff --git a/src/ConfigOverlay.php b/src/ConfigOverlay.php index 56a5004..b8fc1fb 100644 --- a/src/ConfigOverlay.php +++ b/src/ConfigOverlay.php @@ -82,6 +82,11 @@ public function getContext($name) return new Config(); } + public function removeContext($name) + { + unset($this->contexts[$name]); + } + /** * Determine if a non-default config value exists. */