MetadataLoader::paths()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 1
eloc 1
nc 1
nop 0
dl 0
loc 3
rs 10
c 2
b 0
f 0
1
<?php
2
3
namespace Charcoal\Model\Service;
4
5
use RuntimeException;
6
use InvalidArgumentException;
7
use UnexpectedValueException;
8
9
// From PSR-3
10
use Psr\Log\LoggerAwareInterface;
11
use Psr\Log\LoggerAwareTrait;
12
13
// From PSR-6
14
use Psr\Cache\CacheItemPoolInterface;
15
16
// From 'charcoal-core'
17
use Charcoal\Model\MetadataInterface;
18
19
/**
20
 * Load metadata from JSON file(s).
21
 *
22
 * The Metadata Loader is different than the `FileLoader` class it extends mainly because
23
 * it tries to find all files matching  the "ident" in all search path and merge them together
24
 * in an array, to be filled in a `Metadata` object.
25
 *
26
 * If `ident` is an actual class name, then it will also try to load all the JSON matching
27
 * the class' parents and interfaces.
28
 */
29
final class MetadataLoader implements LoggerAwareInterface
30
{
31
    use LoggerAwareTrait;
32
33
    /**
34
     * The PSR-6 caching service.
35
     *
36
     * @var CacheItemPoolInterface
37
     */
38
    private $cachePool;
39
40
    /**
41
     * The cache of metadata instances, indexed by metadata identifier.
42
     *
43
     * @var MetadataInterface[]
44
     */
45
    private static $metadataCache = [];
46
47
    /**
48
     * The cache of class/interface lineages.
49
     *
50
     * @var array
51
     */
52
    private static $lineageCache = [];
53
54
    /**
55
     * The cache of snake-cased words.
56
     *
57
     * @var array
58
     */
59
    private static $snakeCache = [];
60
61
    /**
62
     * The cache of camel-cased words.
63
     *
64
     * @var array
65
     */
66
    private static $camelCache = [];
67
68
    /**
69
     * The base path to prepend to any relative paths to search in.
70
     *
71
     * @var string
72
     */
73
    private $basePath = '';
74
75
    /**
76
     * The paths to search in.
77
     *
78
     * @var array
79
     */
80
    private $paths = [];
81
82
    /**
83
     * Return new MetadataLoader object.
84
     *
85
     * The application's metadata paths, if any, are merged with
86
     * the loader's search paths.
87
     *
88
     * # Required dependencie
89
     * - `logger`
90
     * - `cache`
91
     * - `paths`
92
     * - `base_path`
93
     *
94
     * @param  array $data The loader's dependencies.
95
     * @return void
96
     */
97
    public function __construct(array $data = null)
98
    {
99
        $this->setLogger($data['logger']);
100
        $this->setCachePool($data['cache']);
101
        $this->setBasePath($data['base_path']);
102
        $this->setPaths($data['paths']);
103
    }
104
105
    /**
106
     * Load the metadata for the given identifier or interfaces.
107
     *
108
     * Notes:
109
     * - If the requested dataset is found, it will be stored in the cache service.
110
     * - If the provided metadata container is an {@see MetadataInterface object},
111
     *   it will be stored for the lifetime of the script (whether it be a longer
112
     *   running process or a web request).
113
     *
114
     * @param  string $ident    The metadata identifier to load.
115
     * @param  mixed  $metadata The metadata type to load the dataset into.
116
     *     If $metadata is a {@see MetadataInterface} instance, the requested dataset will be merged into the object.
117
     *     If $metadata is a class name, the requested dataset will be stored in a new instance of that class.
118
     *     If $metadata is an array, the requested dataset will be merged into the array.
119
     * @param  array  $idents   The metadata identifier(s) to load.
120
     *     If $idents is provided, $ident will be used as the cache key
121
     *     and $idents are loaded instead.
122
     * @throws InvalidArgumentException If the identifier is not a string.
123
     * @return MetadataInterface|array Returns the dataset, for the given $ident,
124
     *     as an array or an instance of {@see MetadataInterface}.
125
     *     See $metadata for more details.
126
     */
127
    public function load($ident, $metadata = [], array $idents = null)
128
    {
129
        if (!is_string($ident)) {
0 ignored issues
show
introduced by
The condition is_string($ident) is always true.
Loading history...
130
            throw new InvalidArgumentException(sprintf(
131
                'Metadata identifier must be a string, received %s',
132
                is_object($ident) ? get_class($ident) : gettype($ident)
133
            ));
134
        }
135
136
        if (strpos($ident, '\\') !== false) {
137
            $ident = $this->metaKeyFromClassName($ident);
138
        }
139
140
        $valid = $this->validateMetadataContainer($metadata, $metadataType, $targetMetadata);
141
        if ($valid === false) {
142
            throw new InvalidArgumentException(sprintf(
143
                'Metadata object must be a class name or instance of %s, received %s',
144
                MetadataInterface::class,
145
                is_object($metadata) ? get_class($metadata) : gettype($metadata)
146
            ));
147
        }
148
149
        if (isset(static::$metadataCache[$ident])) {
0 ignored issues
show
Bug introduced by
Since $metadataCache 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 $metadataCache to at least protected.
Loading history...
150
            $cachedMetadata = static::$metadataCache[$ident];
151
152
            if (is_object($targetMetadata)) {
153
                return $targetMetadata->merge($cachedMetadata);
154
            } elseif (is_array($targetMetadata)) {
155
                return array_replace_recursive($targetMetadata, $cachedMetadata->data());
156
            }
157
158
            return $cachedMetadata;
159
        }
160
161
        $data = $this->loadMetadataFromCache($ident, $idents);
162
163
        if (is_object($targetMetadata)) {
164
            return $targetMetadata->merge($data);
165
        } elseif (is_array($targetMetadata)) {
166
            return array_replace_recursive($targetMetadata, $data);
167
        }
168
169
        $targetMetadata = new $metadataType;
170
        $targetMetadata->setData($data);
171
172
        static::$metadataCache[$ident] = $targetMetadata;
173
174
        return $targetMetadata;
175
    }
176
177
    /**
178
     * Fetch the metadata for the given identifier.
179
     *
180
     * @param  string $ident The metadata identifier to load.
181
     * @throws InvalidArgumentException If the identifier is not a string.
182
     * @return array
183
     */
184
    public function loadMetadataByKey($ident)
185
    {
186
        if (!is_string($ident)) {
0 ignored issues
show
introduced by
The condition is_string($ident) is always true.
Loading history...
187
            throw new InvalidArgumentException(
188
                'Metadata identifier must be a string'
189
            );
190
        }
191
192
        $lineage  = $this->hierarchy($ident);
193
        $metadata = [];
194
        foreach ($lineage as $metaKey) {
195
            $data = $this->loadMetadataFromSource($metaKey);
196
            if (is_array($data)) {
197
                $metadata = array_replace_recursive($metadata, $data);
198
            }
199
        }
200
201
        return $metadata;
202
    }
203
204
    /**
205
     * Fetch the metadata for the given identifiers.
206
     *
207
     * @param  array $idents One or more metadata identifiers to load.
208
     * @return array
209
     */
210
    public function loadMetadataByKeys(array $idents)
211
    {
212
        $metadata = [];
213
        foreach ($idents as $metaKey) {
214
            $data = $this->loadMetadataByKey($metaKey);
215
            if (is_array($data)) {
216
                $metadata = array_replace_recursive($metadata, $data);
217
            }
218
        }
219
220
        return $metadata;
221
    }
222
223
    /**
224
     * Build a class/interface lineage from the given snake-cased namespace.
225
     *
226
     * @param  string $ident The FQCN (in snake-case) to load the hierarchy from.
227
     * @return array
228
     */
229
    private function hierarchy($ident)
230
    {
231
        if (!is_string($ident)) {
0 ignored issues
show
introduced by
The condition is_string($ident) is always true.
Loading history...
232
            return [];
233
        }
234
235
        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...
236
            return static::$lineageCache[$ident];
237
        }
238
239
        $classname = $this->classNameFromMetaKey($ident);
240
241
        return $this->classLineage($classname, $ident);
242
    }
243
244
    /**
245
     * Build a class/interface lineage from the given PHP namespace.
246
     *
247
     * @param  string      $class The FQCN to load the hierarchy from.
248
     * @param  string|null $ident Optional. The snake-cased $class.
249
     * @return array
250
     */
251
    private function classLineage($class, $ident = null)
252
    {
253
        if (!is_string($class)) {
0 ignored issues
show
introduced by
The condition is_string($class) is always true.
Loading history...
254
            return [];
255
        }
256
257
        if ($ident === null) {
258
            $ident = $this->metaKeyFromClassName($class);
259
        }
260
261
        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...
262
            return static::$lineageCache[$ident];
263
        }
264
265
        $class = $this->classNameFromMetaKey($ident);
266
267
        if (!class_exists($class) && !interface_exists($class)) {
268
            return [ $ident ];
269
        }
270
271
        $classes   = array_values(class_parents($class));
272
        $classes   = array_reverse($classes);
273
        $classes[] = $class;
274
275
        $hierarchy = [];
276
        foreach ($classes as $class) {
0 ignored issues
show
introduced by
$class is overwriting one of the parameters of this function.
Loading history...
277
            $implements = array_values(class_implements($class));
278
            $implements = array_reverse($implements);
279
            foreach ($implements as $interface) {
280
                $hierarchy[$this->metaKeyFromClassName($interface)] = 1;
281
            }
282
            $hierarchy[$this->metaKeyFromClassName($class)] = 1;
283
        }
284
285
        $hierarchy = array_keys($hierarchy);
286
287
        static::$lineageCache[$ident] = $hierarchy;
288
289
        return $hierarchy;
290
    }
291
292
    /**
293
     * Load a metadataset from the cache.
294
     *
295
     * @param  string $ident  The metadata identifier to load / cache key for $idents.
296
     * @param  array  $idents If provided, $ident is used as the cache key
297
     *     and these metadata identifiers are loaded instead.
298
     * @return array The data associated with the metadata identifier.
299
     */
300
    private function loadMetadataFromCache($ident, array $idents = null)
301
    {
302
        $cacheKey  = $this->cacheKeyFromMetaKey($ident);
303
        $cacheItem = $this->cachePool()->getItem($cacheKey);
304
305
        if ($cacheItem->isHit()) {
306
            $metadata = $cacheItem->get();
307
308
            /** Backwards compatibility */
309
            if ($metadata instanceof MetadataInterface) {
310
                $metadata = $metadata->data();
311
                $cacheItem->set($metadata);
312
                $this->cachePool()->save($cacheItem);
313
            }
314
315
            return $metadata;
316
        } else {
317
            if (empty($idents)) {
318
                $metadata = $this->loadMetadataByKey($ident);
319
            } else {
320
                $metadata = $this->loadMetadataByKeys($idents);
321
            }
322
323
            $cacheItem->set($metadata);
324
            $this->cachePool()->save($cacheItem);
325
        }
326
327
        return $metadata;
328
    }
329
330
    /**
331
     * Load a metadata file from the given metdata identifier.
332
     *
333
     * The file is converted to JSON, the only supported format.
334
     *
335
     * @param  string $ident The metadata identifier to fetch.
336
     * @return array|null An associative array on success, NULL on failure.
337
     */
338
    private function loadMetadataFromSource($ident)
339
    {
340
        $path = $this->filePathFromMetaKey($ident);
341
        return $this->loadFile($path);
342
    }
343
344
    /**
345
     * Load a file as an array.
346
     *
347
     * Supported file types: JSON.
348
     *
349
     * @param  string $path A file path to resolve and fetch.
350
     * @return array|null An associative array on success, NULL on failure.
351
     */
352
    private function loadFile($path)
353
    {
354
        if (file_exists($path)) {
355
            return $this->loadJsonFile($path);
356
        }
357
358
        $dirs = $this->paths();
359
        if (empty($dirs)) {
360
            return null;
361
        }
362
363
        $data = [];
364
        $dirs = array_reverse($dirs);
365
        foreach ($dirs as $dir) {
366
            $file = $dir.DIRECTORY_SEPARATOR.$path;
367
            if (file_exists($file)) {
368
                $data = array_replace_recursive($data, $this->loadJsonFile($file));
369
            }
370
        }
371
372
        if (empty($data)) {
373
            return null;
374
        }
375
376
        return $data;
377
    }
378
379
    /**
380
     * Load a JSON file as an array.
381
     *
382
     * @param  string $path A path to a JSON file.
383
     * @throws UnexpectedValueException If the file can not correctly be parsed into an array.
384
     * @return array An associative array on success.
385
     */
386
    private function loadJsonFile($path)
387
    {
388
        $data = json_decode(file_get_contents($path), true);
389
        if (json_last_error() !== JSON_ERROR_NONE) {
390
            $error = json_last_error_msg() ?: 'Unknown error';
391
            throw new UnexpectedValueException(
392
                sprintf('JSON file "%s" could not be parsed: "%s"', $path, $error)
393
            );
394
        }
395
396
        if (!is_array($data)) {
397
            throw new UnexpectedValueException(
398
                sprintf('JSON file "%s" does not return an array', $path)
399
            );
400
        }
401
402
        return $data;
403
    }
404
405
    /**
406
     * Generate a store key.
407
     *
408
     * @param  string|string[] $ident The metadata identifier(s) to convert.
409
     * @return string
410
     */
411
    public function serializeMetaKey($ident)
412
    {
413
        if (is_array($ident)) {
414
            sort($ident);
415
            $ident = implode(':', $ident);
416
        }
417
418
        return md5($ident);
419
    }
420
421
    /**
422
     * Generate a cache key.
423
     *
424
     * @param  string $ident The metadata identifier to convert.
425
     * @return string
426
     */
427
    public function cacheKeyFromMetaKey($ident)
428
    {
429
        $cacheKey = 'metadata/'.str_replace('/', '.', $ident);
430
        return $cacheKey;
431
    }
432
433
    /**
434
     * Convert a snake-cased namespace to a file path.
435
     *
436
     * @param  string $ident The metadata identifier to convert.
437
     * @return string
438
     */
439
    private function filePathFromMetaKey($ident)
440
    {
441
        $filename  = str_replace('\\', '.', $ident);
442
        $filename .= '.json';
443
444
        return $filename;
445
    }
446
447
    /**
448
     * Convert a kebab-cased namespace to CamelCase.
449
     *
450
     * @param  string $ident The metadata identifier to convert.
451
     * @return string Returns a valid PHP namespace.
452
     */
453
    private function classNameFromMetaKey($ident)
454
    {
455
        $key = $ident;
456
457
        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...
458
            return static::$camelCache[$key];
459
        }
460
461
        // Change "foo-bar" to "fooBar"
462
        $parts = explode('-', $ident);
463
        array_walk(
464
            $parts,
465
            function(&$i) {
466
                $i = ucfirst($i);
467
            }
468
        );
469
        $ident = implode('', $parts);
470
471
        // Change "/foo/bar" to "\Foo\Bar"
472
        $classname = str_replace('/', '\\', $ident);
473
        $parts     = explode('\\', $classname);
474
475
        array_walk(
476
            $parts,
477
            function(&$i) {
478
                $i = ucfirst($i);
479
            }
480
        );
481
482
        $classname = trim(implode('\\', $parts), '\\');
483
484
        static::$camelCache[$key]       = $classname;
485
        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...
486
487
        return $classname;
488
    }
489
490
    /**
491
     * Convert a CamelCase namespace to kebab-case.
492
     *
493
     * @param  string $class The FQCN to convert.
494
     * @return string Returns a kebab-cased namespace.
495
     */
496
    private function metaKeyFromClassName($class)
497
    {
498
        $key = trim($class, '\\');
499
500
        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...
501
            return static::$snakeCache[$key];
502
        }
503
504
        $ident = strtolower(preg_replace('/([a-z])([A-Z])/', '$1-$2', $class));
505
        $ident = str_replace('\\', '/', strtolower($ident));
506
        $ident = ltrim($ident, '/');
507
508
        static::$snakeCache[$key]   = $ident;
509
        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...
510
511
        return $ident;
512
    }
513
514
    /**
515
     * Validate a metadata type or container.
516
     *
517
     * If specified, the method will also resolve the metadata type or container.
518
     *
519
     * @param  mixed       $metadata The metadata type or container to validate.
520
     * @param  string|null $type     If provided, then it is filled with the resolved metadata type.
521
     * @param  mixed|null  $bag      If provided, then it is filled with the resolved metadata container.
522
     * @return boolean
523
     */
524
    private function validateMetadataContainer($metadata, &$type = null, &$bag = null)
525
    {
526
        // If variables are provided, clear existing values.
527
        $type = null;
528
        $bag  = null;
529
530
        if (is_array($metadata)) {
531
            $type = 'array';
532
            $bag  = $metadata;
533
            return true;
534
        }
535
536
        if (is_a($metadata, MetadataInterface::class, true)) {
537
            if (is_object($metadata)) {
538
                $type = get_class($metadata);
539
                $bag  = $metadata;
540
                return true;
541
            }
542
            if (is_string($metadata)) {
543
                $type = $metadata;
544
                return true;
545
            }
546
        }
547
548
        return false;
549
    }
550
551
    /**
552
     * Assign a base path for relative search paths.
553
     *
554
     * @param  string $basePath The base path to use.
555
     * @throws InvalidArgumentException If the base path parameter is not a string.
556
     * @return void
557
     */
558
    private function setBasePath($basePath)
559
    {
560
        if (!is_string($basePath)) {
0 ignored issues
show
introduced by
The condition is_string($basePath) is always true.
Loading history...
561
            throw new InvalidArgumentException(
562
                'Base path must be a string'
563
            );
564
        }
565
566
        $basePath = realpath($basePath);
567
        $this->basePath = rtrim($basePath, '/\\').DIRECTORY_SEPARATOR;
568
    }
569
570
    /**
571
     * Retrieve the base path for relative search paths.
572
     *
573
     * @return string
574
     */
575
    private function basePath()
576
    {
577
        return $this->basePath;
578
    }
579
580
    /**
581
     * Assign many search paths.
582
     *
583
     * @param  string[] $paths One or more search paths.
584
     * @return void
585
     */
586
    private function setPaths(array $paths)
587
    {
588
        $this->paths = [];
589
        $this->addPaths($paths);
590
    }
591
592
    /**
593
     * Retrieve search paths.
594
     *
595
     * @return string[]
596
     */
597
    private function paths()
598
    {
599
        return $this->paths;
600
    }
601
602
    /**
603
     * Append many search paths.
604
     *
605
     * @param  string[] $paths One or more search paths.
606
     * @return self
607
     */
608
    private function addPaths(array $paths)
609
    {
610
        foreach ($paths as $path) {
611
            $this->addPath($path);
612
        }
613
614
        return $this;
615
    }
616
617
    /**
618
     * Append a search path.
619
     *
620
     * @param  string $path A directory path.
621
     * @return self
622
     */
623
    private function addPath($path)
624
    {
625
        $path = $this->resolvePath($path);
626
627
        if ($this->validatePath($path)) {
628
            $this->paths[] = $path;
629
        }
630
631
        return $this;
632
    }
633
634
    /**
635
     * Parse a relative path using the base path if needed.
636
     *
637
     * @param  string $path The path to resolve.
638
     * @throws InvalidArgumentException If the path is invalid.
639
     * @return string
640
     */
641
    private function resolvePath($path)
642
    {
643
        if (!is_string($path)) {
0 ignored issues
show
introduced by
The condition is_string($path) is always true.
Loading history...
644
            throw new InvalidArgumentException(
645
                'Path needs to be a string'
646
            );
647
        }
648
649
        $basePath = $this->basePath();
650
        $path = trim($path, '/\\');
651
652
        if ($basePath && strpos($path, $basePath) === false) {
653
            $path = $basePath.$path;
654
        }
655
656
        return $path;
657
    }
658
659
    /**
660
     * Validate a resolved path.
661
     *
662
     * @param  string $path The path to validate.
663
     * @return string
664
     */
665
    private function validatePath($path)
666
    {
667
        return is_dir($path);
0 ignored issues
show
Bug Best Practice introduced by
The expression return is_dir($path) returns the type boolean which is incompatible with the documented return type string.
Loading history...
668
    }
669
670
    /**
671
     * Set the cache service.
672
     *
673
     * @param  CacheItemPoolInterface $cache A PSR-6 compliant cache pool instance.
674
     * @return void
675
     */
676
    private function setCachePool(CacheItemPoolInterface $cache)
677
    {
678
        $this->cachePool = $cache;
679
    }
680
681
    /**
682
     * Retrieve the cache service.
683
     *
684
     * @return CacheItemPoolInterface
685
     */
686
    private function cachePool()
687
    {
688
        return $this->cachePool;
689
    }
690
}
691