Passed
Push — main ( c9e139...7f83d8 )
by Colin
11:46 queued 10:05
created

Configuration::getTopLevelKey()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 14
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 8
CRAP Score 3

Importance

Changes 0
Metric Value
eloc 7
c 0
b 0
f 0
dl 0
loc 14
ccs 8
cts 8
cp 1
rs 10
cc 3
nc 3
nop 1
crap 3
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 Dflydev\DotAccessData\Exception\MissingPathException;
21
use League\Config\Exception\UnknownOptionException;
22
use League\Config\Exception\ValidationException;
23
use Nette\Schema\Expect;
24
use Nette\Schema\Processor;
25
use Nette\Schema\Schema;
26
use Nette\Schema\ValidationException as NetteValidationException;
27
28
final 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 36
    public function __construct(array $baseSchemas = [])
57
    {
58 36
        $this->configSchemas = $baseSchemas;
59 36
        $this->userConfig    = new Data();
60 36
        $this->finalConfig   = new Data();
61
62 36
        $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 6
    public function addSchema(string $key, Schema $schema): void
71
    {
72 6
        $this->invalidate();
73
74 6
        $this->configSchemas[$key] = $schema;
75
    }
76
77
    /**
78
     * {@inheritDoc}
79
     *
80
     * @psalm-allow-private-mutation
81
     */
82 6
    public function merge(array $config = []): void
83
    {
84 6
        $this->invalidate();
85
86 6
        $this->userConfig->import($config, DataInterface::REPLACE);
87
    }
88
89
    /**
90
     * {@inheritDoc}
91
     *
92
     * @psalm-allow-private-mutation
93
     */
94 24
    public function set(string $key, $value): void
95
    {
96 24
        $this->invalidate();
97
98
        try {
99 24
            $this->userConfig->set($key, $value);
100 3
        } catch (DataException $ex) {
101 3
            throw new UnknownOptionException($ex->getMessage(), $key, (int) $ex->getCode(), $ex);
102
        }
103
    }
104
105
    /**
106
     * {@inheritDoc}
107
     *
108
     * @psalm-external-mutation-free
109
     */
110 30
    public function get(string $key)
111
    {
112 30
        if (\array_key_exists($key, $this->cache)) {
113 3
            return $this->cache[$key];
114
        }
115
116
        try {
117 30
            $this->build(self::getTopLevelKey($key));
118
119 21
            return $this->cache[$key] = $this->finalConfig->get($key);
120 15
        } 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 6
    public function exists(string $key): bool
131
    {
132 6
        if (\array_key_exists($key, $this->cache)) {
133 3
            return true;
134
        }
135
136
        try {
137 3
            $this->build(self::getTopLevelKey($key));
138
139 3
            return $this->finalConfig->has($key);
140 3
        } catch (InvalidPathException | UnknownOptionException $ex) {
141 3
            return false;
142
        }
143
    }
144
145
    /**
146
     * @psalm-mutation-free
147
     */
148 3
    public function reader(): ConfigurationInterface
149
    {
150 3
        return $this->reader;
151
    }
152
153
    /**
154
     * @psalm-external-mutation-free
155
     */
156 30
    private function invalidate(): void
157
    {
158 30
        $this->cache       = [];
159 30
        $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 33
    private function build(string $topLevelKey): void
170
    {
171 33
        if ($this->finalConfig->has($topLevelKey)) {
172 12
            return;
173
        }
174
175 33
        if (! isset($this->configSchemas[$topLevelKey])) {
176 9
            throw new UnknownOptionException(\sprintf('Missing config schema for "%s"', $topLevelKey), $topLevelKey);
177
        }
178
179
        try {
180 30
            $userData = [$topLevelKey => $this->userConfig->get($topLevelKey)];
181 21
        } catch (DataException $ex) {
182 21
            $userData = [];
183
        }
184
185
        try {
186 30
            $schema    = $this->configSchemas[$topLevelKey];
187 30
            $processor = new Processor();
188
189 30
            $processed = $processor->process(Expect::structure([$topLevelKey => $schema]), $userData);
190
191 24
            $this->raiseAnyDeprecationNotices($processor->getWarnings());
192
193 24
            $this->finalConfig->import((array) 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