Completed
Push — master ( b659c5...c65770 )
by Michael
10:22
created

ConfigManager::removeConfigFile()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 10
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 7
CRAP Score 2

Importance

Changes 0
Metric Value
dl 0
loc 10
ccs 7
cts 7
cp 1
rs 9.4285
c 0
b 0
f 0
cc 2
eloc 7
nc 2
nop 1
crap 2
1
<?php
2
declare(strict_types = 1);
3
/**
4
 * Contains class ConfigManager.
5
 *
6
 * PHP version 7.0+
7
 *
8
 * LICENSE:
9
 * This file is part of Yet Another Php Eve Api Library also know as Yapeal
10
 * which can be used to access the Eve Online API data and place it into a
11
 * database.
12
 * Copyright (C) 2016-2017 Michael Cummings
13
 *
14
 * This program is free software: you can redistribute it and/or modify it
15
 * under the terms of the GNU Lesser General Public License as published by the
16
 * Free Software Foundation, either version 3 of the License, or (at your
17
 * option) any later version.
18
 *
19
 * This program is distributed in the hope that it will be useful, but WITHOUT
20
 * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
21
 * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License
22
 * for more details.
23
 *
24
 * You should have received a copy of the GNU Lesser General Public License
25
 * along with this program. If not, see
26
 * <http://spdx.org/licenses/LGPL-3.0.html>.
27
 *
28
 * You should be able to find a copy of this license in the COPYING-LESSER.md
29
 * file. A copy of the GNU GPL should also be available in the COPYING.md file.
30
 *
31
 * @author    Michael Cummings <[email protected]>
32
 * @copyright 2016-2017 Michael Cummings
33
 * @license   LGPL-3.0+
34
 */
35
namespace Yapeal\Configuration;
36
37
use Yapeal\Container\ContainerInterface;
38
39
/**
40
 * Class ConfigManager.
41
 */
42
class ConfigManager implements ConfigManagementInterface
43
{
44
    /**
45
     * Default priority used with addConfigFile method.
46
     */
47
    const PRIORITY_DEFAULT = 1000;
48
    /**
49
     * ConfigManager constructor.
50
     *
51
     * @param ContainerInterface $dic      Container instance.
52
     * @param array              $settings Additional parameters or objects from non-config file source. Normally only
53
     *                                     used internal by Yapeal-ng for things that need to be added but it should
54
     *                                     still be possible for an application developer to override them so they can
55
     *                                     not be added to the Container directly and end up being protected. Mostly
56
     *                                     things that need to be done at run time like cache/ and log/ directory paths.
57
     */
58 19
    public function __construct(ContainerInterface $dic, array $settings = [])
59
    {
60 19
        $this->configFiles = [];
61 19
        $this->matchYapealOnly = false;
62 19
        $this->setDic($dic);
63 19
        $this->setSettings($settings);
64
    }
65
    /**
66
     * Add a new config file candidate to be used during the composing of settings.
67
     *
68
     * This method is expected to be used with the update() method to change the config files used during the composing
69
     * of settings.
70
     *
71
     * Though Yapeal-ng considers and treats all configuration files as optional the individual settings themselves are
72
     * not and many of them if missing can cause it to not start, to fail, or possible cause other undefined behavior
73
     * to happen instead.
74
     *
75
     * The behavior when adding the same config file with an absolute and relative path or more than one relative path
76
     * is undefined and may change and is considered unsupported. It makes little sense to do so anyway but mentioned
77
     * here so developers known to watch for this edge case.
78
     *
79
     * @param string $pathName Configuration file name with path. Path _should_ be absolute but it is not checked.
80
     * @param int    $priority An integer in the range 0 - PHP_INT_MAX with large number being a higher priority.
81
     *                         The range between 100 and 100000 inclusively are reserved for application developer use
82
     *                         with everything outside that range reserved for internal use only.
83
     * @param bool   $watched  Flag to tell if file should be monitored for changes and updates or read initially and
84
     *                         future changes ignored. Note that the $force flag of update() can be used to override
85
     *                         this parameter.
86
     *
87
     * @return array Return the added config file candidate entry with 'priority' and 'watched'.
88
     * @throws \InvalidArgumentException Throws this exception if you try adding the same $pathFile again. Use
89
     *                                   hasConfigFile() to see if entry already exists.
90
     * @throws \LogicException
91
     */
92 13
    public function addConfigFile(string $pathName, int $priority = self::PRIORITY_DEFAULT, bool $watched = true): array
93
    {
94 13
        if ($this->hasConfigFile($pathName)) {
95 1
            $mess = sprintf('Already added config file %s', $pathName);
96 1
            throw new \InvalidArgumentException($mess);
97
        }
98 13
        clearstatcache(true, $pathName);
99 13
        $this->configFiles[$pathName] = [
100 13
            'instance' => (new YamlConfigFile($pathName))->read(),
101 13
            'pathName' => $pathName,
102 13
            'priority' => $priority,
103 13
            'timestamp' => filemtime($pathName),
104 13
            'watched' => $watched
105
        ];
106 13
        return $this->configFiles[$pathName];
107
    }
108
    /**
109
     * @param string $pathName Configuration file name with path.
110
     * @param bool   $force    Override watched flag. Allows checking of normally unwatched files.
111
     *
112
     * @return bool Returns true if config file was updated, else false
113
     * @throws \InvalidArgumentException
114
     * @throws \LogicException
115
     */
116 12
    public function checkModifiedAndUpdate(string $pathName, bool $force = false): bool
117
    {
118 12
        if (!$this->hasConfigFile($pathName)) {
119 1
            $mess = 'Tried to check unknown config file ' . $pathName;
120 1
            throw new \InvalidArgumentException($mess);
121
        }
122 11
        $configFile = $this->configFiles[$pathName];
123
        /**
124
         * @var ConfigFileInterface $instance
125
         */
126 11
        if ($force || $configFile['watched']) {
127 10
            clearstatcache(true, $pathName);
128 10
            $currentTS = filemtime($pathName);
129 10
            if ($configFile['timestamp'] < $currentTS) {
130 1
                $instance = $configFile['instance'];
131 1
                $instance->read();
132 1
                $configFile['timestamp'] = $currentTS;
133 1
                return true;
134
            }
135
        }
136 10
        return false;
137
    }
138
    /**
139
     * The Create part of the CRUD interface.
140
     *
141
     * Creates a new Yapeal-ng config composed from the settings found in the given current config file(s). This would
142
     * be the closest to the original mode of Yapeal-ng where all the config files are processed once and then use for
143
     * the rest of the time. Both in the classic cron/scheduled task and when using 'yc Y:A' (Yapeal:AutoMagic) command
144
     * this is the closest match to how they worked. All existing settings from the current known config files will be
145
     * forgotten and the $configFiles list will be used to compose the new collection of settings.
146
     *
147
     * If you just need to update the processed config files look at using update() combined with addConfigFiles() and
148
     * removeConfigFile().
149
     *
150
     * One or more config file(s) must have been given and there must be some actual settings found after they have
151
     * been processed or an exception will be thrown.
152
     *
153
     * The $configFiles parameter can be just a plain list (array) of config file names with directory paths. If given
154
     * a plain list like this Yapeal-ng will use the default priority and watched modes as seen in the addConfigFile()
155
     * method. An example of this would look something like this:
156
     *
157
     * <code>
158
     * <?php
159
     * ...
160
     * $manager = new ConfigManager($dic);
161
     * $configFiles = [
162
     *     __DIR__ . '/yapealDefaults.yaml',
163
     *     dirname(__DIR__, 2) . '/config/yapeal.yaml'
164
     * ];
165
     * $manager->create($configFiles);
166
     * ...
167
     * </code>
168
     *
169
     * An example that includes optional priority and watched flags:
170
     * <code>>
171
     * <?php
172
     * ...
173
     * $manager = new ConfigManager($dic);
174
     * $configFiles = [
175
     *     ['pathName' => __DIR__ . '/yapealDefaults.yaml', 'priority' => PHP_INT_MAX, 'watched' => false],
176
     *     ['pathName' => dirname(__DIR__, 2) . '/config/yapeal.yaml', 'priority' => 10],
177
     *     ['pathName' => __DIR__ . '/special/run.yaml']
178
     * ];
179
     * $manager->create($configFiles);
180
     * ...
181
     * </code>
182
     *
183
     * Including either 'priority' or 'watched' is optional and they will receive the same default value(s) as from
184
     * addConfigFile() if not given.
185
     *
186
     * @param array $configFiles A list of config file names with optional priority and watched flag. See example for
187
     *                           how to include them.
188
     *
189
     * @return bool
190
     * @throws \DomainException
191
     * @throws \InvalidArgumentException
192
     * @throws \LogicException
193
     */
194 13
    public function create(array $configFiles): bool
195
    {
196 13
        $this->configFiles = [];
197 13
        foreach ($configFiles as $value) {
198 12
            if (is_string($value)) {
199 8
                $value = ['pathName' => $value];
200 4
            } elseif (is_array($value)) {
201 3
                if (!array_key_exists('pathName', $value)) {
202 1
                    $mess = 'Config file pathName in required';
203 3
                    throw new \InvalidArgumentException($mess);
204
                }
205
            } else {
206 1
                $mess = 'Config file element must be a string or an array but was given ' . gettype($value);
207 1
                throw new \InvalidArgumentException($mess);
208
            }
209 10
            $this->addConfigFile($value['pathName'],
210 10
                $value['priority'] ?? self::PRIORITY_DEFAULT,
211 10
                $value['watched'] ?? true);
212
        }
213 10
        return $this->update();
214
    }
215
    /**
216
     * The Delete part of the CRUD interface.
217
     *
218
     * This both removes all the candidate config files and removes all of their settings so the Container retains only
219
     * those settings it originally had when given. This does _not_ necessarily mean it is fully reset. The reason this
220
     * can't provide a complete reset is that while the other config files were being used their settings might have
221
     * been used in any created callable instances or as substitutions in the original. The only way to insure this
222
     * does not happen would be to not use any substitutions or other settings from outside the original Container
223
     * ones. This shouldn't be an issue as by default only an empty or nearly empty Container is normal given to the
224
     * ConfigManager instance. I just wanted to clearly document this effect to remind myself and anyone else to use
225
     * care when giving a non-empty Container to the ConfigManager instance and the ripple effects they can have and be
226
     * effected by other things.
227
     *
228
     * @return bool
229
     */
230 1
    public function delete(): bool
231
    {
232 1
        $this->removeUnprotectedSettings();
233 1
        $this->configFiles = [];
234 1
        return true;
235
    }
236
    /**
237
     * Allows checking if a config file candidate has already been added.
238
     *
239
     * @param string $pathName Configuration file name with path. Path _should_ be absolute but it is not checked.
240
     *
241
     * @return bool Returns true if candidate entry exist, false if unknown.
242
     */
243 15
    public function hasConfigFile(string $pathName): bool
244
    {
245 15
        return array_key_exists($pathName, $this->configFiles);
246
    }
247
    /**
248
     * The Read part of the CRUD interface.
249
     *
250
     * Since the Container where the settings are kept is one of the main shared objects inside Yapeal-ng this is mostly
251
     * redundant but used this method as a way to return only stuff added by the config files.
252
     *
253
     * @return array
254
     */
255 2
    public function read(): array
256
    {
257 2
        $additions = array_diff($this->dic->keys(), $this->protectedKeys);
258 2
        $settings = [];
259 2
        foreach ($additions as $addition) {
260
            $settings[$addition] = $this->dic[$addition];
261
        }
262 2
        return $settings;
263
    }
264
    /**
265
     * Remove an existing config file candidate entry.
266
     *
267
     * This method is expected to be used with the update() method to change the config files used during the composing
268
     * of settings.
269
     *
270
     * @param string $pathName Configuration file name with path. Path _should_ be absolute but it is not checked.
271
     *
272
     * @return array Return the removed config file candidate entry with 'priority' and 'watched'.
273
     * @see addConfigFile()
274
     * @throws \InvalidArgumentException Throw this exception if there is no matching entry found. Use hasConfigFile()
275
     *                                   to check if the candidate config file entry exists.
276
     */
277 2
    public function removeConfigFile(string $pathName): array
278
    {
279 2
        if (!$this->hasConfigFile($pathName)) {
280 1
            $mess = sprintf('Tried to remove unknown config file %s', $pathName);
281 1
            throw new \InvalidArgumentException($mess);
282
        }
283 1
        $result = $this->configFiles[$pathName];
284 1
        unset($this->configFiles[$pathName]);
285 1
        return $result;
286
    }
287
    /**
288
     * @param ContainerInterface $value
289
     *
290
     * @return self Fluent interface
291
     */
292 19
    public function setDic(ContainerInterface $value): self
293
    {
294 19
        $this->dic = $value;
295 19
        $this->protectedKeys = $value->keys();
296
        // Make self protected key.
297 19
        $this->protectedKeys[] = 'Yapeal.Configuration.Callable.Manager';
298
        // Insure main wiring class is protected key as well.
299 19
        $this->protectedKeys[] = 'Yapeal.Wiring.Callable.Wiring';
300 19
        return $this;
301
    }
302
    /**
303
     * Sets substitutions to require Yapeal prefix or to be more generic.
304
     *
305
     * @param bool $value
306
     *
307
     * @return self Fluent interface
308
     * @see doSubstitutions()
309
     */
310 1
    public function setMatchYapealOnly(bool $value = true): self
311
    {
312 1
        $this->matchYapealOnly = $value;
313 1
        return $this;
314
    }
315
    /**
316
     * @param array $value
317
     *
318
     * @return self Fluent interface
319
     */
320 19
    public function setSettings(array $value = []): self
321
    {
322 19
        $this->settings = $value;
323 19
        return $this;
324
    }
325
    /**
326
     * The Update part of the CRUD interface.
327
     *
328
     * It is expected that this will see little or no use if Yapeal-ng is being used in the typical/original mode via
329
     * direct calls to the Yapeal::autoMagic() method or manually running 'yc Y:A' from the command line but this
330
     * method is expected to be used in a planned future Yapeal-ng daemon. This planned new daemon is one of the main
331
     * reasons this interface and the implementing class are being created so it can be signaled to re-read it's
332
     * configuration or even watch and auto-update it's configuration when it notices changes to any of the given
333
     * config files.
334
     *
335
     * Note that it expected that the addConfigFile() and removeConfigFile() methods have been called already to change
336
     * which config files will be used to compose the new settings.
337
     *
338
     * @param bool $force Override watched flag. Allows checking of normally unwatched files.
339
     *
340
     * @return bool
341
     * @throws \InvalidArgumentException
342
     * @throws \LogicException
343
     */
344 10
    public function update(bool $force = false): bool
345
    {
346 10
        $this->removeUnprotectedSettings();
347 10
        $settings = $this->settings;
348 10
        $this->sortConfigFiles();
349
        /**
350
         * @var ConfigFileInterface $instance
351
         */
352 10
        foreach ($this->configFiles as $pathName => $configFile) {
353 9
            $this->checkModifiedAndUpdate($pathName, $force);
354 9
            $instance = $configFile['instance'];
355 9
            $settings = array_replace($settings, $instance->flattenYaml());
356
        }
357 10
        $this->doSubstitutions($settings);
358 9
        return true;
359
    }
360
    /**
361
     * Looks for and replaces any {Yapeal.*} it finds in values with the corresponding other setting value.
362
     *
363
     * This will replace full value or part of the value. Examples:
364
     *
365
     *     $settings = [
366
     *         'Yapeal.baseDir' => '/my/junk/path/Yapeal/',
367
     *         'Yapeal.libDir' => '{Yapeal.baseDir}lib/'
368
     *         'Yapeal.Sql.dir' => '{Yapeal.libDir}Sql/'
369
     *     ];
370
     *
371
     * After doSubstitutions would be:
372
     *
373
     *     $settings = [
374
     *         'Yapeal.baseDir' => '/my/junk/path/Yapeal/',
375
     *         'Yapeal.libDir' => '/my/junk/path/Yapeal/lib/'
376
     *         'Yapeal.Sql.dir' => '/my/junk/path/Yapeal/lib/Sql/'
377
     *     ];
378
     *
379
     * Note that order in which subs are done is undefined so it could have
380
     * done libDir first and then baseDir into both or done baseDir into libDir
381
     * then libDir into Sql.dir.
382
     *
383
     * Subs from within $settings itself are used first with $dic used to
384
     * fill-in as needed for any unknown ones.
385
     *
386
     * Subs are tried up to 25 times as long as any {Yapeal.*} are found before
387
     * giving up to prevent infinite loop.
388
     *
389
     * @param array $settings
390
     *
391
     * @throws \InvalidArgumentException
392
     */
393 10
    protected function doSubstitutions(array $settings)
394
    {
395 10
        $additions = array_diff(array_keys($settings), $this->protectedKeys);
396 10
        $depth = 0;
397 10
        $maxDepth = 25;
398 10
        $regEx = sprintf('#(.*?)\{((?:%s)(?:\.\w+)+)\}(.*)#', $this->matchYapealOnly ? 'Yapeal' : '\w+');
399
        do {
400 10
            $miss = 0;
401 10
            foreach ($additions as $addition) {
402 8
                if (!is_string($settings[$addition])) {
403 3
                    continue;
404
                }
405 8
                $matched = preg_match($regEx, $settings[$addition], $matches);
406 8
                if (1 === $matched) {
407 3
                    $sub = $this->dic[$matches[2]] ?? $settings[$matches[2]] ?? $matches[2];
408 3
                    $settings[$addition] = $matches[1] . $sub . $matches[3];
409 3
                    if (fnmatch('*{*.*}*', $settings[$addition])) {
410 3
                        ++$miss;
411
                    }
412 7
                } elseif (false === $matched) {
413
                    $constants = array_flip(array_filter(get_defined_constants(),
414
                        function (string $value) {
415
                            return fnmatch('PREG_*_ERROR', $value);
416
                        },
417
                        ARRAY_FILTER_USE_KEY));
418
                    $mess = 'Received preg error ' . $constants[preg_last_error()];
419 8
                    throw new \InvalidArgumentException($mess);
420
                }
421
            }
422 10
            if (++$depth > $maxDepth) {
423 1
                $mess = 'Exceeded maximum depth, check for possible circular reference(s)';
424 1
                throw new \InvalidArgumentException($mess);
425
            }
426 10
        } while (0 < $miss);
427 9
        foreach ($additions as $add) {
428 7
            $this->dic[$add] = $settings[$add];
429
        }
430
    }
431
    /**
432
     * Used to remove any parameters or objects that were added from config files.
433
     */
434 10
    private function removeUnprotectedSettings()
435
    {
436 10
        $subtractions = array_diff($this->dic->keys(), $this->protectedKeys);
437 10
        foreach ($subtractions as $sub) {
438 1
            unset($this->dic[$sub]);
439
        }
440
    }
441
    /**
442
     * Sorts config files by priority/path name order.
443
     *
444
     * Sorted the config files by their descending priority order (largest-smallest). If there are config files with
445
     * equal priorities they will be sorted by descending path name order.
446
     */
447 10
    private function sortConfigFiles()
448
    {
449 10
        uasort($this->configFiles,
450 10
            function ($a, $b) {
451 2
                $sort = $b['priority'] <=> $a['priority'];
452 2
                if (0 === $sort) {
453 1
                    $sort = $b['pathName'] <=> $a['pathName'];
454
                }
455 2
                return $sort;
456 10
            });
457
    }
458
    /**
459
     * @var array $configFiles
460
     */
461
    private $configFiles;
462
    /**
463
     * @var ContainerInterface $dic
464
     */
465
    private $dic;
466
    /**
467
     * Flag used while doing substitutions to decide if generic pattern or Yapeal prefixed one should be used.
468
     *
469
     * @var bool $matchYapealOnly
470
     * @see doSubstitutions()
471
     */
472
    private $matchYapealOnly;
473
    /**
474
     * List of Container keys that are protected from being overwritten.
475
     *
476
     * @var array $protectedKeys
477
     */
478
    private $protectedKeys;
479
    /**
480
     * @var array $settings
481
     */
482
    private $settings;
483
}
484