diff --git a/docs/config-layering.rst b/docs/config-layering.rst new file mode 100644 index 0000000..941742e --- /dev/null +++ b/docs/config-layering.rst @@ -0,0 +1,94 @@ +Configuration Layering +====================== + +ArrayKit provides explicit configuration-layer semantics for applications that +compose defaults, file-backed configuration, and runtime overrides. + +ConfigMerge +----------- + +``ConfigMerge`` recursively merges associative configuration maps while treating +lists as atomic values. A higher-precedence list replaces the lower list instead +of merging numeric indexes. + +.. code-block:: php + + ['name' => 'Example', 'debug' => false], + 'middleware' => ['auth', 'csrf'], + ], + [ + 'app' => ['debug' => true], + 'middleware' => ['api'], + ], + ); + + // middleware is ['api'], not ['api', 'csrf']. + +Use ``ConfigMerge::mergeMany()`` when composing multiple layers in precedence +order. + +LayeredLazyFileConfig +--------------------- + +``LayeredLazyFileConfig`` composes three layers with this precedence: + +``fallback < lazy source < overrides`` + +Only the requested namespace is materialized. Exact path reads are resolved from +the fully merged namespace, so list replacement and scalar shadowing cannot leak +values from lower-precedence layers. + +.. code-block:: php + + ['debug' => false], + ], + overrides: [ + 'app' => ['debug' => true], + ], + namespaces: ['app', 'cache', 'database'], + ); + + $debug = $config->get('app.debug'); + +``all()`` materializes the configured namespace set. ``warmNamespaceCache()`` +and ``clearNamespaceCache()`` delegate generated source-cache lifecycle to the +underlying lazy source. + +Resilient Lazy Source Cache +--------------------------- + +``ResilientLazyFileConfig`` treats generated namespace-cache corruption as a +cache miss and retries the authoritative source namespace. Invalid source files +still fail normally; resilience applies only to disposable generated cache +artifacts. + +Malformed or invalid ``__flat.php`` indexes are also treated as cache misses. +This keeps an acceleration artifact from preventing source configuration from +loading. + +Environment Enumeration +----------------------- + +``Environment::all()`` enumerates all three runtime sources with the same +precedence used by ``Environment::get()``: + +1. ``$_ENV``; +2. non-HTTP ``$_SERVER`` values; +3. process values returned by ``getenv()``. + +This means process-only variables are visible during complete environment +enumeration as well as direct lookup. diff --git a/docs/index.rst b/docs/index.rst index 0dd81ce..f99499e 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -25,6 +25,7 @@ Contents collection config lazy-config + config-layering traits-and-helpers migration rule-reference diff --git a/src/Config/Concerns/LazyFileConfigCacheTrait.php b/src/Config/Concerns/LazyFileConfigCacheTrait.php index 74cd239..38e9374 100644 --- a/src/Config/Concerns/LazyFileConfigCacheTrait.php +++ b/src/Config/Concerns/LazyFileConfigCacheTrait.php @@ -141,9 +141,7 @@ protected function collectFlatLeafIndex(string $namespace, array $namespaceData, } } - /** - * @return string[] - */ + /** @return string[] */ protected function discoverNamespaces(): array { $namespaces = []; @@ -222,20 +220,20 @@ protected function loadFlatLeafIndex(): void } $this->flatLeafIndex = []; - $path = $this->flatLeafIndexPath(); - if ($path === null || !is_file($path) || !is_readable($path)) { - $this->flatLeafIndexLoaded = true; - - return; - } - $loaded = include $path; - if (!is_array($loaded)) { - throw new UnexpectedValueException("Config file [{$path}] must return an array."); + if ($path !== null && is_file($path) && is_readable($path)) { + try { + $loaded = include $path; + if (is_array($loaded)) { + $this->flatLeafIndex = $this->filterFlatLeafIndex($loaded); + } + } catch (\Throwable) { + // Generated flat indexes are disposable acceleration artifacts. + // A corrupt index is a cache miss; namespace/source loading remains authoritative. + } } - $this->flatLeafIndex = $this->filterFlatLeafIndex($loaded); $this->flatLeafIndexLoaded = true; } @@ -267,8 +265,8 @@ protected function writeFlatLeafIndexFromCacheDirectory(): void } $index = $this->buildFlatLeafIndexFromDirectory($directory); - ksort($index); + if (!$this->writeCacheFile($indexPath, "flatLeafIndexLoaded = true; } - /** - * @param array $index - */ + /** @param array $index */ private function addFlatLeafIndexValue(array &$index, string $path, mixed $value): void { if ( @@ -293,11 +289,10 @@ private function addFlatLeafIndexValue(array &$index, string $path, mixed $value } } - /** - * @return array - */ + /** @return array */ private function buildFlatLeafIndexFromDirectory(string $directory): array { + /** @var array $index */ $index = []; $entries = scandir($directory); if ($entries === false) { @@ -311,18 +306,20 @@ private function buildFlatLeafIndexFromDirectory(string $directory): array } $namespace = substr($entry, 0, -strlen($suffix)); - if ($namespace === '') { + if ($namespace === '' || preg_match('/^[A-Za-z0-9_-]+$/', $namespace) !== 1) { continue; } - if (!preg_match('/^[A-Za-z0-9_-]+$/', $namespace)) { + $path = $directory . DIRECTORY_SEPARATOR . $entry; + + try { + $loaded = include $path; + } catch (\Throwable) { continue; } - $path = $directory . DIRECTORY_SEPARATOR . $entry; - $loaded = include $path; if (!is_array($loaded)) { - throw new UnexpectedValueException("Config file [{$path}] must return an array."); + continue; } $this->collectFlatLeafIndex($namespace, $loaded, $index); @@ -337,6 +334,7 @@ private function buildFlatLeafIndexFromDirectory(string $directory): array */ private function filterFlatLeafIndex(array $loaded): array { + /** @var array $index */ $index = []; foreach ($loaded as $key => $value) { @@ -363,11 +361,7 @@ private function flushAllNamespaceCacheFiles(): void $entries = scandir($directory); if ($entries !== false) { foreach ($entries as $entry) { - if ($entry === '.' || $entry === '..') { - continue; - } - - if (!$this->isOwnedNamespaceCacheEntry($entry)) { + if ($entry === '.' || $entry === '..' || !$this->isOwnedNamespaceCacheEntry($entry)) { continue; } @@ -406,9 +400,6 @@ private function isOwnedNamespaceCacheEntry(string $entry): bool return $namespace !== '' && preg_match('/^[A-Za-z0-9_-]+$/', $namespace) === 1; } - /** - * Hold one exclusive lock across namespace writes/deletes and flat-index rebuilding. - */ private function withNamespaceCacheLock(\Closure $operation): static { $directory = $this->namespaceCacheDirectory; diff --git a/src/Config/ConfigMerge.php b/src/Config/ConfigMerge.php new file mode 100644 index 0000000..c2cef99 --- /dev/null +++ b/src/Config/ConfigMerge.php @@ -0,0 +1,60 @@ + $base + * @param array $overlay + * @return array + */ + public static function merge(array $base, array $overlay): array + { + foreach ($overlay as $key => $value) { + if ( + array_key_exists($key, $base) + && self::isMap($base[$key]) + && self::isMap($value) + ) { + /** @var array $baseValue */ + $baseValue = $base[$key]; + /** @var array $overlayValue */ + $overlayValue = $value; + $base[$key] = self::merge($baseValue, $overlayValue); + + continue; + } + + $base[$key] = $value; + } + + return $base; + } + + /** + * @param iterable> $layers + * @return array + */ + public static function mergeMany(iterable $layers): array + { + $merged = []; + + foreach ($layers as $layer) { + $merged = self::merge($merged, $layer); + } + + return $merged; + } +} diff --git a/src/Config/LayeredLazyFileConfig.php b/src/Config/LayeredLazyFileConfig.php new file mode 100644 index 0000000..6cc294a --- /dev/null +++ b/src/Config/LayeredLazyFileConfig.php @@ -0,0 +1,150 @@ + */ + private array $knownNamespaces = []; + + /** @var array */ + private array $materializedNamespaces = []; + + /** + * @param array $fallback + * @param array $overrides + * @param list $namespaces + */ + public function __construct( + string $directory, + ?string $namespaceCacheDirectory = null, + private array $fallback = [], + private array $overrides = [], + array $namespaces = [], + string $extension = 'php', + ) { + $this->source = new ResilientLazyFileConfig( + directory: $directory, + extension: $extension, + namespaceCacheDirectory: $namespaceCacheDirectory, + ); + + foreach ([...array_keys($this->fallback), ...array_keys($this->overrides), ...$namespaces] as $namespace) { + if ($namespace !== '') { + $this->knownNamespaces[$namespace] = true; + } + } + } + + /** @return array */ + #[\Override] + public function all(): array + { + foreach (array_keys($this->knownNamespaces) as $namespace) { + $this->materializeNamespace($namespace); + } + + $items = []; + foreach (parent::all() as $key => $value) { + if (is_string($key)) { + $items[$key] = $value; + } + } + + return $items; + } + + public function clearNamespaceCache(): static + { + $this->source->flushNamespaceCache(); + + return $this; + } + + public function namespaceCacheDirectory(): ?string + { + return $this->source->namespaceCacheDirectory(); + } + + /** + * @param string|array|null $namespaces + */ + public function warmNamespaceCache(string|array|null $namespaces = null): static + { + $this->source->warmNamespaceCache($namespaces ?? array_keys($this->knownNamespaces)); + + return $this; + } + + #[\Override] + protected function resolveRawValue(int|string $key): mixed + { + if (!is_string($key) || $key === '') { + return parent::resolveRawValue($key); + } + + $namespace = $this->namespaceFromPath($key); + $this->materializeNamespace($namespace); + + return parent::resolveRawValue($key); + } + + private function materializeNamespace(string $namespace): void + { + if (isset($this->materializedNamespaces[$namespace])) { + return; + } + + $missing = $this->missingValueMarker(); + $source = $this->source->get($namespace, $missing); + $layers = []; + + if (array_key_exists($namespace, $this->fallback)) { + $layers[] = [$namespace => $this->fallback[$namespace]]; + } + + if ($source !== $missing) { + $layers[] = [$namespace => $source]; + } + + if (array_key_exists($namespace, $this->overrides)) { + $layers[] = [$namespace => $this->overrides[$namespace]]; + } + + if ($layers !== []) { + $merged = ConfigMerge::mergeMany($layers); + if (array_key_exists($namespace, $merged)) { + $this->items[$namespace] = $merged[$namespace]; + } + } + + $this->knownNamespaces[$namespace] = true; + $this->materializedNamespaces[$namespace] = true; + $this->flushReadCache(); + } + + private function namespaceFromPath(string $path): string + { + $dot = strpos($path, '.'); + $namespace = $dot === false ? $path : substr($path, 0, $dot); + $namespace = trim($namespace); + + if ($namespace === '') { + return $path; + } + + return $namespace; + } +} diff --git a/src/Config/ResilientLazyFileConfig.php b/src/Config/ResilientLazyFileConfig.php new file mode 100644 index 0000000..197114b --- /dev/null +++ b/src/Config/ResilientLazyFileConfig.php @@ -0,0 +1,68 @@ +loadedNamespaces[$namespace])) { + return; + } + + $loaded = $this->loadCachedNamespace($namespace); + if ($loaded === null) { + $source = $this->resolveNamespaceFile($namespace); + if ($source === null) { + $this->loadedNamespaces[$namespace] = true; + + return; + } + + $loaded = include $source; + if (!is_array($loaded)) { + throw new UnexpectedValueException("Config file [{$source}] must return an array."); + } + } + + $this->loadedNamespaces[$namespace] = true; + + if (!array_key_exists($namespace, $this->items)) { + $this->items[$namespace] = $loaded; + + return; + } + + if (is_array($this->items[$namespace])) { + $this->items[$namespace] = array_replace_recursive($loaded, $this->items[$namespace]); + } + } + + /** + * @return array|null + */ + private function loadCachedNamespace(string $namespace): ?array + { + $cache = $this->resolveCachedNamespaceFile($namespace); + if ($cache === null) { + return null; + } + + try { + $loaded = include $cache; + } catch (\Throwable) { + return null; + } + + return is_array($loaded) ? $loaded : null; + } +} diff --git a/src/Config/Support/Environment.php b/src/Config/Support/Environment.php index bd9be98..0e325bb 100644 --- a/src/Config/Support/Environment.php +++ b/src/Config/Support/Environment.php @@ -21,8 +21,9 @@ public static function all(bool $includeHttpServerValues = false): array ); $env = array_filter($_ENV, is_string(...), ARRAY_FILTER_USE_KEY); + $process = getenv(); - return $env + $server; + return $env + $server + $process; } public static function get(?string $key = null, mixed $default = null): mixed diff --git a/tests/Feature/ConfigLayeringTest.php b/tests/Feature/ConfigLayeringTest.php new file mode 100644 index 0000000..056a5da --- /dev/null +++ b/tests/Feature/ConfigLayeringTest.php @@ -0,0 +1,181 @@ +layerConfigPath = sys_get_temp_dir() . '/arraykit-layer-source-' . bin2hex(random_bytes(5)); + $this->layerCachePath = sys_get_temp_dir() . '/arraykit-layer-cache-' . bin2hex(random_bytes(5)); + mkdir($this->layerConfigPath, 0777, true); + mkdir($this->layerCachePath, 0777, true); +}); + +afterEach(function () { + configLayeringRemove($this->layerConfigPath); + configLayeringRemove($this->layerCachePath); +}); + +it('recursively merges maps while replacing lists atomically', function () { + $merged = ConfigMerge::merge( + [ + 'app' => ['name' => 'ArrayKit', 'debug' => false], + 'middleware' => ['auth', 'csrf'], + 'cache' => ['driver' => 'file'], + ], + [ + 'app' => ['debug' => true], + 'middleware' => ['api'], + 'cache' => false, + ], + ); + + expect($merged)->toBe([ + 'app' => ['name' => 'ArrayKit', 'debug' => true], + 'middleware' => ['api'], + 'cache' => false, + ]); +}); + +it('merges many configuration layers in order', function () { + expect(ConfigMerge::mergeMany([ + ['app' => ['name' => 'base'], 'hosts' => ['a', 'b']], + ['app' => ['debug' => false]], + ['app' => ['name' => 'override'], 'hosts' => ['x']], + ]))->toBe([ + 'app' => ['name' => 'override', 'debug' => false], + 'hosts' => ['x'], + ]); +}); + +it('keeps exact layered reads equivalent to fully merged namespace reads', function () { + configLayeringWrite($this->layerConfigPath, 'app', [ + 'servers' => ['a', 'b'], + 'cache' => ['driver' => 'redis'], + 'nested' => ['source' => true], + ]); + + $config = new LayeredLazyFileConfig( + directory: $this->layerConfigPath, + fallback: [ + 'app' => [ + 'fallback' => true, + 'nested' => ['fallback' => true], + ], + ], + overrides: [ + 'app' => [ + 'servers' => ['x'], + 'cache' => false, + 'nested' => ['override' => true], + ], + ], + namespaces: ['app'], + ); + + expect($config->get('app.servers'))->toBe(['x']) + ->and($config->get('app.servers.1', 'missing'))->toBe('missing') + ->and($config->get('app.cache.driver', 'missing'))->toBe('missing') + ->and($config->get('app.fallback'))->toBeTrue() + ->and($config->get('app.nested'))->toBe([ + 'fallback' => true, + 'source' => true, + 'override' => true, + ]); +}); + +it('materializes layered namespaces with fallback source override precedence', function () { + configLayeringWrite($this->layerConfigPath, 'db', [ + 'host' => 'source', + 'options' => ['timeout' => 5], + ]); + + $config = new LayeredLazyFileConfig( + directory: $this->layerConfigPath, + fallback: ['db' => ['host' => 'fallback', 'port' => 3306]], + overrides: ['db' => ['host' => 'override']], + namespaces: ['db'], + ); + + expect($config->all())->toBe([ + 'db' => [ + 'host' => 'override', + 'port' => 3306, + 'options' => ['timeout' => 5], + ], + ]); +}); + +it('falls back to source when a generated namespace cache returns invalid data', function () { + configLayeringWrite($this->layerConfigPath, 'db', ['host' => 'source']); + file_put_contents($this->layerCachePath . '/db.php', "layerConfigPath, + namespaceCacheDirectory: $this->layerCachePath, + ); + + expect($config->get('db.host'))->toBe('source'); +}); + +it('falls back to source when generated cache php is malformed', function () { + configLayeringWrite($this->layerConfigPath, 'db', ['host' => 'source']); + file_put_contents($this->layerCachePath . '/db.php', "layerCachePath . '/__flat.php', "layerConfigPath, + namespaceCacheDirectory: $this->layerCachePath, + ); + + expect($config->get('db.host'))->toBe('source'); +}); + +it('does not hide invalid source configuration behind resilient cache behavior', function () { + file_put_contents($this->layerConfigPath . '/db.php', "layerCachePath . '/db.php', "layerConfigPath, + namespaceCacheDirectory: $this->layerCachePath, + ); + + expect(fn () => $config->get('db.host'))->toThrow(UnexpectedValueException::class); +}); diff --git a/tests/Feature/EnvironmentSupportTest.php b/tests/Feature/EnvironmentSupportTest.php new file mode 100644 index 0000000..0f2d8b0 --- /dev/null +++ b/tests/Feature/EnvironmentSupportTest.php @@ -0,0 +1,77 @@ +toBe('process-value') + ->and(Environment::get($key))->toBe('process-value') + ->and(Environment::has($key))->toBeTrue(); + } finally { + if ($envExists) { + $_ENV[$key] = $envValue; + } else { + unset($_ENV[$key]); + } + + if ($serverExists) { + $_SERVER[$key] = $serverValue; + } else { + unset($_SERVER[$key]); + } + + putenv($previous === false ? $key : $key . '=' . $previous); + } +}); + +it('keeps environment source precedence consistent across get and all', function () { + $key = 'ARRAYKIT_ENV_PRECEDENCE_' . strtoupper(bin2hex(random_bytes(4))); + $previous = getenv($key); + $envExists = array_key_exists($key, $_ENV); + $envValue = $_ENV[$key] ?? null; + $serverExists = array_key_exists($key, $_SERVER); + $serverValue = $_SERVER[$key] ?? null; + + putenv($key . '=process'); + $_SERVER[$key] = 'server'; + $_ENV[$key] = 'env'; + + try { + expect(Environment::get($key))->toBe('env') + ->and(Environment::all()[$key] ?? null)->toBe('env'); + + unset($_ENV[$key]); + expect(Environment::get($key))->toBe('server') + ->and(Environment::all()[$key] ?? null)->toBe('server'); + + unset($_SERVER[$key]); + expect(Environment::get($key))->toBe('process') + ->and(Environment::all()[$key] ?? null)->toBe('process'); + } finally { + if ($envExists) { + $_ENV[$key] = $envValue; + } else { + unset($_ENV[$key]); + } + + if ($serverExists) { + $_SERVER[$key] = $serverValue; + } else { + unset($_SERVER[$key]); + } + + putenv($previous === false ? $key : $key . '=' . $previous); + } +});