Passed
Branch master (018ba4)
by Mathieu
06:43
created

MetadataLoader   F

Complexity

Total Complexity 62

Size/Duplication

Total Lines 554
Duplicated Lines 18.23 %

Importance

Changes 0
Metric Value
dl 101
loc 554
rs 3.8461
c 0
b 0
f 0
wmc 62

22 Methods

Rating   Name   Duplication   Size   Complexity  
A addPaths() 0 7 2
C classLineage() 0 39 8
D loadJsonFile() 38 38 9
A cachePool() 0 9 2
A setBasePath() 12 12 2
B identToClassname() 0 35 2
A loadData() 0 19 4
A filenameFromIdent() 0 6 1
B loadFile() 20 20 5
A basePath() 0 3 1
A classnameToIdent() 0 16 2
A loadFileFromIdent() 0 5 1
B load() 0 29 6
A paths() 0 3 1
A setCachePool() 0 5 1
A setPaths() 0 6 1
A loadDataArray() 0 12 3
A resolvePath() 16 16 4
A hierarchy() 0 13 3
A __construct() 0 6 1
A validatePath() 0 3 1
A addPath() 9 9 2

How to fix   Duplicated Code    Complexity   

Duplicated Code

Duplicate code is one of the most pungent code smells. A rule that is often used is to re-structure code once it is duplicated in three or more places.

Common duplication problems, and corresponding solutions are:

Complex Class

 Tip:   Before tackling complexity, make sure that you eliminate any duplication first. This often can reduce the size of classes significantly.

Complex classes like MetadataLoader often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use MetadataLoader, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
namespace Charcoal\Model\Service;
4
5
use RuntimeException;
6
use InvalidArgumentException;
7
8
// From PSR-3
9
use Psr\Log\LoggerAwareInterface;
10
use Psr\Log\LoggerAwareTrait;
11
12
// From PSR-6
13
use Psr\Cache\CacheItemPoolInterface;
14
15
// From 'charcoal-core'
16
use Charcoal\Model\MetadataInterface;
17
18
/**
19
 * Load metadata from JSON file(s).
20
 *
21
 * The Metadata Loader is different than the `FileLoader` class it extends mainly because
22
 * it tries to find all files matching  the "ident" in all search path and merge them together
23
 * in an array, to be filled in a `Metadata` object.
24
 *
25
 * If `ident` is an actual class name, then it will also try to load all the JSON matching
26
 * the class' parents and interfaces.
27
 */
28
final class MetadataLoader implements LoggerAwareInterface
29
{
30
    use LoggerAwareTrait;
31
32
    /**
33
     * The PSR-6 caching service.
34
     *
35
     * @var CacheItemPoolInterface $cachePool
36
     */
37
    private $cachePool;
38
39
    /**
40
     * The cache of class/interface lineages.
41
     *
42
     * @var array
43
     */
44
    private static $lineageCache = [];
45
46
    /**
47
     * The cache of snake-cased words.
48
     *
49
     * @var array
50
     */
51
    private static $snakeCache = [];
52
53
    /**
54
     * The cache of camel-cased words.
55
     *
56
     * @var array
57
     */
58
    private static $camelCache = [];
59
60
    /**
61
     * The base path to prepend to any relative paths to search in.
62
     *
63
     * @var string
64
     */
65
    private $basePath = '';
66
67
    /**
68
     * The paths to search in.
69
     *
70
     * @var array
71
     */
72
    private $paths = [];
73
74
    /**
75
     * Return new MetadataLoader object.
76
     *
77
     * The application's metadata paths, if any, are merged with
78
     * the loader's search paths.
79
     *
80
     * # Required dependencie
81
     * - `logger`
82
     * - `cache`
83
     * - `paths`
84
     * - `base_path`
85
     *
86
     * @param  array $data The loader's dependencies.
87
     * @return void
88
     */
89
    public function __construct(array $data = null)
90
    {
91
        $this->setLogger($data['logger']);
92
        $this->setCachePool($data['cache']);
93
        $this->setBasePath($data['base_path']);
94
        $this->setPaths($data['paths']);
95
    }
96
97
    /**
98
     * Load the metadata for the given identifier or interfaces.
99
     *
100
     * @param  string            $ident      The metadata identifier to load or
101
     *     to use as the cache key if $interfaces is provided.
102
     * @param  MetadataInterface $metadata   The metadata object to load into.
103
     * @param  array|null        $interfaces One or more metadata identifiers to load.
104
     * @throws InvalidArgumentException If the identifier is not a string.
105
     * @return MetadataInterface Returns the cached metadata instance or if it's stale or empty,
106
     *     loads a fresh copy of the data into $metadata and returns it;
107
     */
108
    public function load($ident, MetadataInterface $metadata, array $interfaces = null)
109
    {
110
        if (!is_string($ident)) {
111
            throw new InvalidArgumentException(
112
                'Metadata identifier must be a string'
113
            );
114
        }
115
116
        if (is_array($interfaces) && empty($interfaces)) {
117
            $interfaces = null;
118
        }
119
120
        $cacheKey  = 'metadata/'.str_replace('/', '.', $ident);
121
        $cacheItem = $this->cachePool()->getItem($cacheKey);
122
123
        if (!$cacheItem->isHit()) {
124
            if ($interfaces === null) {
125
                $data = $this->loadData($ident);
126
            } else {
127
                $data = $this->loadDataArray($interfaces);
128
            }
129
            $metadata->setData($data);
130
131
            $this->cachePool()->save($cacheItem->set($metadata));
132
133
            return $metadata;
134
        }
135
136
        return $cacheItem->get();
137
    }
138
139
    /**
140
     * Fetch the metadata for the given identifier.
141
     *
142
     * @param  string $ident The metadata identifier to load.
143
     * @throws InvalidArgumentException If the identifier is not a string.
144
     * @return array
145
     */
146
    public function loadData($ident)
147
    {
148
        if (!is_string($ident)) {
149
            throw new InvalidArgumentException(
150
                'Metadata identifier must be a string'
151
            );
152
        }
153
154
        $lineage = $this->hierarchy($ident);
155
        $catalog = [];
156
        foreach ($lineage as $id) {
157
            $data = $this->loadFileFromIdent($id);
158
159
            if (is_array($data)) {
160
                $catalog = array_replace_recursive($catalog, $data);
161
            }
162
        }
163
164
        return $catalog;
165
    }
166
167
    /**
168
     * Fetch the metadata for the given identifiers.
169
     *
170
     * @param  array $idents One or more metadata identifiers to load.
171
     * @return array
172
     */
173
    public function loadDataArray(array $idents)
174
    {
175
        $catalog = [];
176
        foreach ($idents as $id) {
177
            $data = $this->loadData($id);
178
179
            if (is_array($data)) {
180
                $catalog = array_replace_recursive($catalog, $data);
181
            }
182
        }
183
184
        return $catalog;
185
    }
186
187
    /**
188
     * Set the cache service.
189
     *
190
     * @param  CacheItemPoolInterface $cache A PSR-6 compliant cache pool instance.
191
     * @return self
192
     */
193
    private function setCachePool(CacheItemPoolInterface $cache)
194
    {
195
        $this->cachePool = $cache;
196
197
        return $this;
198
    }
199
200
    /**
201
     * Retrieve the cache service.
202
     *
203
     * @throws RuntimeException If the cache service was not previously set.
204
     * @return CacheItemPoolInterface
205
     */
206
    private function cachePool()
207
    {
208
        if (!isset($this->cachePool)) {
209
            throw new RuntimeException(
210
                sprintf('Cache Pool is not defined for "%s"', get_class($this))
211
            );
212
        }
213
214
        return $this->cachePool;
215
    }
216
217
    /**
218
     * Assign a base path for relative search paths.
219
     *
220
     * @param  string $basePath The base path to use.
221
     * @throws InvalidArgumentException If the base path parameter is not a string.
222
     * @return self
223
     */
224 View Code Duplication
    private function setBasePath($basePath)
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
225
    {
226
        if (!is_string($basePath)) {
227
            throw new InvalidArgumentException(
228
                'Base path must be a string'
229
            );
230
        }
231
232
        $basePath = realpath($basePath);
233
        $this->basePath = rtrim($basePath, '/\\').DIRECTORY_SEPARATOR;
0 ignored issues
show
Bug introduced by
It seems like $basePath can also be of type false; however, parameter $str of rtrim() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

233
        $this->basePath = rtrim(/** @scrutinizer ignore-type */ $basePath, '/\\').DIRECTORY_SEPARATOR;
Loading history...
234
235
        return $this;
236
    }
237
238
    /**
239
     * Retrieve the base path for relative search paths.
240
     *
241
     * @return string
242
     */
243
    private function basePath()
244
    {
245
        return $this->basePath;
246
    }
247
248
    /**
249
     * Assign a list of paths.
250
     *
251
     * @param  string[] $paths The list of paths to add.
252
     * @return self
253
     */
254
    private function setPaths(array $paths)
255
    {
256
        $this->paths = [];
257
        $this->addPaths($paths);
258
259
        return $this;
260
    }
261
262
    /**
263
     * Retrieve the searchable paths.
264
     *
265
     * @return string[]
266
     */
267
    private function paths()
268
    {
269
        return $this->paths;
270
    }
271
272
    /**
273
     * Append a list of paths.
274
     *
275
     * @param  string[] $paths The list of paths to add.
276
     * @return self
277
     */
278
    private function addPaths(array $paths)
279
    {
280
        foreach ($paths as $path) {
281
            $this->addPath($path);
282
        }
283
284
        return $this;
285
    }
286
287
    /**
288
     * Append a path.
289
     *
290
     * @param  string $path A file or directory path.
291
     * @throws InvalidArgumentException If the path does not exist or is invalid.
292
     * @return self
293
     */
294 View Code Duplication
    private function addPath($path)
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
295
    {
296
        $path = $this->resolvePath($path);
297
298
        if ($this->validatePath($path)) {
299
            $this->paths[] = $path;
300
        }
301
302
        return $this;
303
    }
304
305
    /**
306
     * Parse a relative path using the base path if needed.
307
     *
308
     * @param  string $path The path to resolve.
309
     * @throws InvalidArgumentException If the path is invalid.
310
     * @return string
311
     */
312 View Code Duplication
    private function resolvePath($path)
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
313
    {
314
        if (!is_string($path)) {
315
            throw new InvalidArgumentException(
316
                'Path needs to be a string'
317
            );
318
        }
319
320
        $basePath = $this->basePath();
321
        $path = ltrim($path, '/\\');
322
323
        if ($basePath && strpos($path, $basePath) === false) {
324
            $path = $basePath.$path;
325
        }
326
327
        return $path;
328
    }
329
330
    /**
331
     * Validate a resolved path.
332
     *
333
     * @param  string $path The path to validate.
334
     * @return string
335
     */
336
    private function validatePath($path)
337
    {
338
        return file_exists($path);
0 ignored issues
show
Bug Best Practice introduced by
The expression return file_exists($path) returns the type boolean which is incompatible with the documented return type string.
Loading history...
339
    }
340
341
    /**
342
     * Build a class/interface lineage from the given snake-cased namespace.
343
     *
344
     * @param  string $ident The FQCN (in snake-case) to load the hierarchy from.
345
     * @return array
346
     */
347
    private function hierarchy($ident)
348
    {
349
        if (!is_string($ident)) {
350
            return [];
351
        }
352
353
        if (isset(static::$lineageCache[$ident])) {
0 ignored issues
show
Bug introduced by
Since $lineageCache is declared private, accessing it with static will lead to errors in possible sub-classes; you can either use self, or increase the visibility of $lineageCache to at least protected.
Loading history...
354
            return static::$lineageCache[$ident];
355
        }
356
357
        $classname = $this->identToClassname($ident);
358
359
        return $this->classLineage($classname, $ident);
360
    }
361
362
    /**
363
     * Build a class/interface lineage from the given PHP namespace.
364
     *
365
     * @param  string      $classname The FQCN to load the hierarchy from.
366
     * @param  string|null $ident     Optional. The snake-cased $classname.
367
     * @return array
368
     */
369
    private function classLineage($classname, $ident = null)
370
    {
371
        if (!is_string($classname)) {
372
            return [];
373
        }
374
375
        if ($ident === null) {
376
            $ident = $this->classnameToIdent($classname);
377
        }
378
379
        if (isset(static::$lineageCache[$ident])) {
0 ignored issues
show
Bug introduced by
Since $lineageCache is declared private, accessing it with static will lead to errors in possible sub-classes; you can either use self, or increase the visibility of $lineageCache to at least protected.
Loading history...
380
            return static::$lineageCache[$ident];
381
        }
382
383
        $classname = $this->identToClassname($ident);
384
385
        if (!class_exists($classname) && !interface_exists($classname)) {
386
            return [ $ident ];
387
        }
388
389
        $classes   = array_values(class_parents($classname));
390
        $classes   = array_reverse($classes);
391
        $classes[] = $classname;
392
393
        $hierarchy = [];
394
        foreach ($classes as $class) {
395
            $implements = array_values(class_implements($class));
396
            $implements = array_reverse($implements);
397
            foreach ($implements as $interface) {
398
                $hierarchy[$this->classnameToIdent($interface)] = 1;
399
            }
400
            $hierarchy[$this->classnameToIdent($class)] = 1;
401
        }
402
403
        $hierarchy = array_keys($hierarchy);
404
405
        static::$lineageCache[$ident] = $hierarchy;
406
407
        return $hierarchy;
408
    }
409
410
    /**
411
     * Load a metadata file from the given metdata identifier.
412
     *
413
     * The file is converted to JSON, the only supported format.
414
     *
415
     * @param  string $ident The metadata identifier to fetch.
416
     * @return array|null
417
     */
418
    private function loadFileFromIdent($ident)
419
    {
420
        $filename = $this->filenameFromIdent($ident);
421
422
        return $this->loadFile($filename);
423
    }
424
425
    /**
426
     * Load a metadata file.
427
     *
428
     * Supported file types: JSON.
429
     *
430
     * @param  string $filename A supported metadata file.
431
     * @return array|null
432
     */
433 View Code Duplication
    private function loadFile($filename)
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
434
    {
435
        if (file_exists($filename)) {
436
            return $this->loadJsonFile($filename);
437
        }
438
439
        $paths = $this->paths();
440
441
        if (empty($paths)) {
442
            return null;
443
        }
444
445
        foreach ($paths as $basePath) {
446
            $file = $basePath.DIRECTORY_SEPARATOR.$filename;
447
            if (file_exists($file)) {
448
                return $this->loadJsonFile($file);
449
            }
450
        }
451
452
        return null;
453
    }
454
455
    /**
456
     * Load the contents of a JSON file.
457
     *
458
     * @param  mixed $filename The file path to retrieve.
459
     * @throws InvalidArgumentException If a JSON decoding error occurs.
460
     * @return array|null
461
     */
462 View Code Duplication
    private function loadJsonFile($filename)
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
463
    {
464
        $content = file_get_contents($filename);
465
466
        if ($content === null) {
467
            return null;
468
        }
469
470
        $data  = json_decode($content, true);
0 ignored issues
show
Bug introduced by
It seems like $content can also be of type false; however, parameter $json of json_decode() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

470
        $data  = json_decode(/** @scrutinizer ignore-type */ $content, true);
Loading history...
471
        $error = json_last_error();
472
473
        if ($error == JSON_ERROR_NONE) {
474
            return $data;
475
        }
476
477
        $issue = 'Unknown error';
478
        switch ($error) {
479
            case JSON_ERROR_NONE:
480
                break;
481
            case JSON_ERROR_DEPTH:
482
                $issue = 'Maximum stack depth exceeded';
483
                break;
484
            case JSON_ERROR_STATE_MISMATCH:
485
                $issue = 'Underflow or the modes mismatch';
486
                break;
487
            case JSON_ERROR_CTRL_CHAR:
488
                $issue = 'Unexpected control character found';
489
                break;
490
            case JSON_ERROR_SYNTAX:
491
                $issue = 'Syntax error, malformed JSON';
492
                break;
493
            case JSON_ERROR_UTF8:
494
                $issue = 'Malformed UTF-8 characters, possibly incorrectly encoded';
495
                break;
496
        }
497
498
        throw new InvalidArgumentException(
499
            sprintf('JSON %s could not be parsed: "%s"', $filename, $issue)
500
        );
501
    }
502
503
    /**
504
     * Convert a snake-cased namespace to a file path.
505
     *
506
     * @param  string $ident The identifier to convert.
507
     * @return string
508
     */
509
    private function filenameFromIdent($ident)
510
    {
511
        $filename  = str_replace([ '\\' ], '.', $ident);
512
        $filename .= '.json';
513
514
        return $filename;
515
    }
516
517
    /**
518
     * Convert a snake-cased namespace to CamelCase.
519
     *
520
     * @param  string $ident The namespace to convert.
521
     * @return string Returns a valid PHP namespace.
522
     */
523
    private function identToClassname($ident)
524
    {
525
        $key = $ident;
526
527
        if (isset(static::$camelCache[$key])) {
0 ignored issues
show
Bug introduced by
Since $camelCache is declared private, accessing it with static will lead to errors in possible sub-classes; you can either use self, or increase the visibility of $camelCache to at least protected.
Loading history...
528
            return static::$camelCache[$key];
529
        }
530
531
        // Change "foo-bar" to "fooBar"
532
        $parts = explode('-', $ident);
533
        array_walk(
534
            $parts,
535
            function(&$i) {
536
                $i = ucfirst($i);
537
            }
538
        );
539
        $ident = implode('', $parts);
540
541
        // Change "/foo/bar" to "\Foo\Bar"
542
        $classname = str_replace('/', '\\', $ident);
543
        $parts     = explode('\\', $classname);
544
545
        array_walk(
546
            $parts,
547
            function(&$i) {
548
                $i = ucfirst($i);
549
            }
550
        );
551
552
        $classname = trim(implode('\\', $parts), '\\');
553
554
        static::$camelCache[$key]       = $classname;
555
        static::$snakeCache[$classname] = $key;
0 ignored issues
show
Bug introduced by
Since $snakeCache is declared private, accessing it with static will lead to errors in possible sub-classes; you can either use self, or increase the visibility of $snakeCache to at least protected.
Loading history...
556
557
        return $classname;
558
    }
559
560
    /**
561
     * Convert a CamelCase namespace to snake-case.
562
     *
563
     * @param  string $classname The namespace to convert.
564
     * @return string Returns a snake-cased namespace.
565
     */
566
    private function classnameToIdent($classname)
567
    {
568
        $key = trim($classname, '\\');
569
570
        if (isset(static::$snakeCache[$key])) {
0 ignored issues
show
Bug introduced by
Since $snakeCache is declared private, accessing it with static will lead to errors in possible sub-classes; you can either use self, or increase the visibility of $snakeCache to at least protected.
Loading history...
571
            return static::$snakeCache[$key];
572
        }
573
574
        $ident = strtolower(preg_replace('/([a-z])([A-Z])/', '$1-$2', $classname));
575
        $ident = str_replace('\\', '/', strtolower($ident));
576
        $ident = ltrim($ident, '/');
577
578
        static::$snakeCache[$key]   = $ident;
579
        static::$camelCache[$ident] = $key;
0 ignored issues
show
Bug introduced by
Since $camelCache is declared private, accessing it with static will lead to errors in possible sub-classes; you can either use self, or increase the visibility of $camelCache to at least protected.
Loading history...
580
581
        return $ident;
582
    }
583
}
584