1<?php 2 3declare(strict_types=1); 4 5/* 6 * This file is part of the league/config package. 7 * 8 * (c) Colin O'Dell <colinodell@gmail.com> 9 * 10 * For the full copyright and license information, please view the LICENSE 11 * file that was distributed with this source code. 12 */ 13 14namespace League\Config; 15 16use Dflydev\DotAccessData\Data; 17use Dflydev\DotAccessData\DataInterface; 18use Dflydev\DotAccessData\Exception\DataException; 19use Dflydev\DotAccessData\Exception\InvalidPathException; 20use Dflydev\DotAccessData\Exception\MissingPathException; 21use League\Config\Exception\UnknownOptionException; 22use League\Config\Exception\ValidationException; 23use Nette\Schema\Expect; 24use Nette\Schema\Processor; 25use Nette\Schema\Schema; 26use Nette\Schema\ValidationException as NetteValidationException; 27 28final class Configuration implements ConfigurationBuilderInterface, ConfigurationInterface 29{ 30 /** @psalm-readonly */ 31 private Data $userConfig; 32 33 /** 34 * @var array<string, Schema> 35 * 36 * @psalm-allow-private-mutation 37 */ 38 private array $configSchemas = []; 39 40 /** @psalm-allow-private-mutation */ 41 private Data $finalConfig; 42 43 /** 44 * @var array<string, mixed> 45 * 46 * @psalm-allow-private-mutation 47 */ 48 private array $cache = []; 49 50 /** @psalm-readonly */ 51 private ConfigurationInterface $reader; 52 53 /** 54 * @param array<string, Schema> $baseSchemas 55 */ 56 public function __construct(array $baseSchemas = []) 57 { 58 $this->configSchemas = $baseSchemas; 59 $this->userConfig = new Data(); 60 $this->finalConfig = new Data(); 61 62 $this->reader = new ReadOnlyConfiguration($this); 63 } 64 65 /** 66 * Registers a new configuration schema at the given top-level key 67 * 68 * @psalm-allow-private-mutation 69 */ 70 public function addSchema(string $key, Schema $schema): void 71 { 72 $this->invalidate(); 73 74 $this->configSchemas[$key] = $schema; 75 } 76 77 /** 78 * {@inheritDoc} 79 * 80 * @psalm-allow-private-mutation 81 */ 82 public function merge(array $config = []): void 83 { 84 $this->invalidate(); 85 86 $this->userConfig->import($config, DataInterface::REPLACE); 87 } 88 89 /** 90 * {@inheritDoc} 91 * 92 * @psalm-allow-private-mutation 93 */ 94 public function set(string $key, $value): void 95 { 96 $this->invalidate(); 97 98 try { 99 $this->userConfig->set($key, $value); 100 } catch (DataException $ex) { 101 throw new UnknownOptionException($ex->getMessage(), $key, (int) $ex->getCode(), $ex); 102 } 103 } 104 105 /** 106 * {@inheritDoc} 107 * 108 * @psalm-external-mutation-free 109 */ 110 public function get(string $key) 111 { 112 if (\array_key_exists($key, $this->cache)) { 113 return $this->cache[$key]; 114 } 115 116 try { 117 $this->build(self::getTopLevelKey($key)); 118 119 return $this->cache[$key] = $this->finalConfig->get($key); 120 } catch (InvalidPathException | MissingPathException $ex) { 121 throw new UnknownOptionException($ex->getMessage(), $key, (int) $ex->getCode(), $ex); 122 } 123 } 124 125 /** 126 * {@inheritDoc} 127 * 128 * @psalm-external-mutation-free 129 */ 130 public function exists(string $key): bool 131 { 132 if (\array_key_exists($key, $this->cache)) { 133 return true; 134 } 135 136 try { 137 $this->build(self::getTopLevelKey($key)); 138 139 return $this->finalConfig->has($key); 140 } catch (InvalidPathException | UnknownOptionException $ex) { 141 return false; 142 } 143 } 144 145 /** 146 * @psalm-mutation-free 147 */ 148 public function reader(): ConfigurationInterface 149 { 150 return $this->reader; 151 } 152 153 /** 154 * @psalm-external-mutation-free 155 */ 156 private function invalidate(): void 157 { 158 $this->cache = []; 159 $this->finalConfig = new Data(); 160 } 161 162 /** 163 * Applies the schema against the configuration to return the final configuration 164 * 165 * @throws ValidationException|UnknownOptionException|InvalidPathException 166 * 167 * @psalm-allow-private-mutation 168 */ 169 private function build(string $topLevelKey): void 170 { 171 if ($this->finalConfig->has($topLevelKey)) { 172 return; 173 } 174 175 if (! isset($this->configSchemas[$topLevelKey])) { 176 throw new UnknownOptionException(\sprintf('Missing config schema for "%s"', $topLevelKey), $topLevelKey); 177 } 178 179 try { 180 $userData = [$topLevelKey => $this->userConfig->get($topLevelKey)]; 181 } catch (DataException $ex) { 182 $userData = []; 183 } 184 185 try { 186 $schema = $this->configSchemas[$topLevelKey]; 187 $processor = new Processor(); 188 189 $processed = $processor->process(Expect::structure([$topLevelKey => $schema]), $userData); 190 191 $this->raiseAnyDeprecationNotices($processor->getWarnings()); 192 193 $this->finalConfig->import((array) self::convertStdClassesToArrays($processed)); 194 } catch (NetteValidationException $ex) { 195 throw new ValidationException($ex); 196 } 197 } 198 199 /** 200 * Recursively converts stdClass instances to arrays 201 * 202 * @phpstan-template T 203 * 204 * @param T $data 205 * 206 * @return mixed 207 * 208 * @phpstan-return ($data is \stdClass ? array<string, mixed> : T) 209 * 210 * @psalm-pure 211 */ 212 private static function convertStdClassesToArrays($data) 213 { 214 if ($data instanceof \stdClass) { 215 $data = (array) $data; 216 } 217 218 if (\is_array($data)) { 219 foreach ($data as $k => $v) { 220 $data[$k] = self::convertStdClassesToArrays($v); 221 } 222 } 223 224 return $data; 225 } 226 227 /** 228 * @param string[] $warnings 229 */ 230 private function raiseAnyDeprecationNotices(array $warnings): void 231 { 232 foreach ($warnings as $warning) { 233 @\trigger_error($warning, \E_USER_DEPRECATED); 234 } 235 } 236 237 /** 238 * @throws InvalidPathException 239 */ 240 private static function getTopLevelKey(string $path): string 241 { 242 if (\strlen($path) === 0) { 243 throw new InvalidPathException('Path cannot be an empty string'); 244 } 245 246 $path = \str_replace(['.', '/'], '.', $path); 247 248 $firstDelimiter = \strpos($path, '.'); 249 if ($firstDelimiter === false) { 250 return $path; 251 } 252 253 return \substr($path, 0, $firstDelimiter); 254 } 255} 256