Configuration::merge()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 5
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 3
CRAP Score 1

Importance

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