Overview

Namespaces

  • Charcoal
    • Loader
    • Model
      • Service
      • ServiceProvider
    • Source
      • Database
    • Validator

Classes

  • Charcoal\Loader\CollectionLoader
  • Charcoal\Loader\FileLoader
  • Charcoal\Model\AbstractMetadata
  • Charcoal\Model\AbstractModel
  • Charcoal\Model\Collection
  • Charcoal\Model\Model
  • Charcoal\Model\ModelMetadata
  • Charcoal\Model\ModelValidator
  • Charcoal\Model\Service\MetadataLoader
  • Charcoal\Model\Service\ModelBuilder
  • Charcoal\Model\Service\ModelLoader
  • Charcoal\Model\Service\ModelLoaderBuilder
  • Charcoal\Model\ServiceProvider\ModelServiceProvider
  • Charcoal\Source\AbstractSource
  • Charcoal\Source\Database\DatabaseFilter
  • Charcoal\Source\Database\DatabaseOrder
  • Charcoal\Source\Database\DatabasePagination
  • Charcoal\Source\DatabaseSource
  • Charcoal\Source\DatabaseSourceConfig
  • Charcoal\Source\Filter
  • Charcoal\Source\Order
  • Charcoal\Source\Pagination
  • Charcoal\Source\SourceConfig
  • Charcoal\Validator\AbstractValidator
  • Charcoal\Validator\ValidatorResult

Interfaces

  • Charcoal\Model\CollectionInterface
  • Charcoal\Model\DescribableInterface
  • Charcoal\Model\MetadataInterface
  • Charcoal\Model\ModelInterface
  • Charcoal\Source\DatabaseSourceInterface
  • Charcoal\Source\FilterInterface
  • Charcoal\Source\OrderInterface
  • Charcoal\Source\PaginationInterface
  • Charcoal\Source\SourceInterface
  • Charcoal\Source\StorableInterface
  • Charcoal\Validator\ValidatableInterface
  • Charcoal\Validator\ValidatorInterface

Traits

  • Charcoal\Model\DescribableTrait
  • Charcoal\Source\StorableTrait
  • Charcoal\Validator\ValidatableTrait
  • Overview
  • Namespace
  • Class
  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:      * Set the cache service.
 99:      *
100:      * @param  CacheItemPoolInterface $cache A PSR-6 compliant cache pool instance.
101:      * @return MetadataLoader Chainable
102:      */
103:     private function setCachePool(CacheItemPoolInterface $cache)
104:     {
105:         $this->cachePool = $cache;
106: 
107:         return $this;
108:     }
109: 
110:     /**
111:      * Retrieve the cache service.
112:      *
113:      * @throws RuntimeException If the cache service was not previously set.
114:      * @return CacheItemPoolInterface
115:      */
116:     private function cachePool()
117:     {
118:         if (!isset($this->cachePool)) {
119:             throw new RuntimeException(
120:                 sprintf('Cache Pool is not defined for "%s"', get_class($this))
121:             );
122:         }
123: 
124:         return $this->cachePool;
125:     }
126: 
127:     /**
128:      * Assign a base path for relative search paths.
129:      *
130:      * @param  string $basePath The base path to use.
131:      * @throws InvalidArgumentException If the base path parameter is not a string.
132:      * @return MetadataLoader Chainable
133:      */
134:     private function setBasePath($basePath)
135:     {
136:         if (!is_string($basePath)) {
137:             throw new InvalidArgumentException(
138:                 'Base path must be a string'
139:             );
140:         }
141: 
142:         $basePath = realpath($basePath);
143:         $this->basePath = rtrim($basePath, '/\\').DIRECTORY_SEPARATOR;
144: 
145:         return $this;
146:     }
147: 
148:     /**
149:      * Retrieve the base path for relative search paths.
150:      *
151:      * @return string
152:      */
153:     private function basePath()
154:     {
155:         return $this->basePath;
156:     }
157: 
158:     /**
159:      * Assign a list of paths.
160:      *
161:      * @param  string[] $paths The list of paths to add.
162:      * @return self
163:      */
164:     private function setPaths(array $paths)
165:     {
166:         $this->paths = [];
167:         $this->addPaths($paths);
168: 
169:         return $this;
170:     }
171: 
172:     /**
173:      * Retrieve the searchable paths.
174:      *
175:      * @return string[]
176:      */
177:     private function paths()
178:     {
179:         return $this->paths;
180:     }
181: 
182:     /**
183:      * Append a list of paths.
184:      *
185:      * @param  string[] $paths The list of paths to add.
186:      * @return self
187:      */
188:     private function addPaths(array $paths)
189:     {
190:         foreach ($paths as $path) {
191:             $this->addPath($path);
192:         }
193: 
194:         return $this;
195:     }
196: 
197:     /**
198:      * Append a path.
199:      *
200:      * @param  string $path A file or directory path.
201:      * @throws InvalidArgumentException If the path does not exist or is invalid.
202:      * @return self
203:      */
204:     private function addPath($path)
205:     {
206:         $path = $this->resolvePath($path);
207: 
208:         if ($this->validatePath($path)) {
209:             $this->paths[] = $path;
210:         }
211: 
212:         return $this;
213:     }
214: 
215:     /**
216:      * Parse a relative path using the base path if needed.
217:      *
218:      * @param  string $path The path to resolve.
219:      * @throws InvalidArgumentException If the path is invalid.
220:      * @return string
221:      */
222:     private function resolvePath($path)
223:     {
224:         if (!is_string($path)) {
225:             throw new InvalidArgumentException(
226:                 'Path needs to be a string'
227:             );
228:         }
229: 
230:         $basePath = $this->basePath();
231:         $path = ltrim($path, '/\\');
232: 
233:         if ($basePath && strpos($path, $basePath) === false) {
234:             $path = $basePath.$path;
235:         }
236: 
237:         return $path;
238:     }
239: 
240:     /**
241:      * Validate a resolved path.
242:      *
243:      * @param  string $path The path to validate.
244:      * @return string
245:      */
246:     private function validatePath($path)
247:     {
248:         return file_exists($path);
249:     }
250: 
251:     /**
252:      * Load the metadata for the given identifier or interfaces.
253:      *
254:      * @param  string            $ident      The metadata identifier to load or
255:      *     to use as the cache key if $interfaces is provided.
256:      * @param  MetadataInterface $metadata   The metadata object to load into.
257:      * @param  array|null        $interfaces One or more metadata identifiers to load.
258:      * @throws InvalidArgumentException If the identifier is not a string.
259:      * @return array Returns the cached metadata instance or if it's stale or empty,
260:      *     loads a fresh copy of the data into $metadata and returns it;
261:      */
262:     public function load($ident, MetadataInterface $metadata, array $interfaces = null)
263:     {
264:         if (!is_string($ident)) {
265:             throw new InvalidArgumentException(
266:                 'Metadata identifier must be a string'
267:             );
268:         }
269: 
270:         if (is_array($interfaces) && empty($interfaces)) {
271:             $interfaces = null;
272:         }
273: 
274:         $cacheKey  = 'metadata/'.str_replace('/', '.', $ident);
275:         $cacheItem = $this->cachePool()->getItem($cacheKey);
276: 
277:         if (!$cacheItem->isHit()) {
278:             if ($interfaces === null) {
279:                 $data = $this->loadData($ident);
280:             } else {
281:                 $data = $this->loadDataArray($interfaces);
282:             }
283:             $metadata->setData($data);
284: 
285:             $this->cachePool()->save($cacheItem->set($metadata));
286: 
287:             return $metadata;
288:         }
289: 
290:         return $cacheItem->get();
291:     }
292: 
293:     /**
294:      * Fetch the metadata for the given identifier.
295:      *
296:      * @param  string $ident The metadata identifier to load.
297:      * @throws InvalidArgumentException If the identifier is not a string.
298:      * @return array
299:      */
300:     public function loadData($ident)
301:     {
302:         if (!is_string($ident)) {
303:             throw new InvalidArgumentException(
304:                 'Metadata identifier must be a string'
305:             );
306:         }
307: 
308:         $lineage = $this->hierarchy($ident);
309:         $catalog = [];
310:         foreach ($lineage as $id) {
311:             $data = $this->loadFileFromIdent($id);
312: 
313:             if (is_array($data)) {
314:                 $catalog = array_replace_recursive($catalog, $data);
315:             }
316:         }
317: 
318:         return $catalog;
319:     }
320: 
321:     /**
322:      * Fetch the metadata for the given identifiers.
323:      *
324:      * @param  array $idents One or more metadata identifiers to load.
325:      * @return array
326:      */
327:     public function loadDataArray(array $idents)
328:     {
329:         $catalog = [];
330:         foreach ($idents as $id) {
331:             $data = $this->loadData($id);
332: 
333:             if (is_array($data)) {
334:                 $catalog = array_replace_recursive($catalog, $data);
335:             }
336:         }
337: 
338:         return $catalog;
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])) {
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])) {
380:             return static::$lineageCache[$ident];
381:         }
382: 
383:         $classname = $this->identToClassname($ident);
384: 
385:         if (!class_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)] = $interface;
399:             }
400:             $hierarchy[$this->classnameToIdent($class)] = $class;
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:     private function loadFile($filename)
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:     protected function loadJsonFile($filename)
463:     {
464:         $content = file_get_contents($filename);
465: 
466:         if ($content === null) {
467:             return null;
468:         }
469: 
470:         $data  = json_decode($content, true);
471:         $error = json_last_error();
472: 
473:         if ($error == JSON_ERROR_NONE) {
474:             return $data;
475:         }
476: 
477:         switch ($error) {
478:             case JSON_ERROR_NONE:
479:                 break;
480:             case JSON_ERROR_DEPTH:
481:                 $issue = 'Maximum stack depth exceeded';
482:                 break;
483:             case JSON_ERROR_STATE_MISMATCH:
484:                 $issue = 'Underflow or the modes mismatch';
485:                 break;
486:             case JSON_ERROR_CTRL_CHAR:
487:                 $issue = 'Unexpected control character found';
488:                 break;
489:             case JSON_ERROR_SYNTAX:
490:                 $issue = 'Syntax error, malformed JSON';
491:                 break;
492:             case JSON_ERROR_UTF8:
493:                 $issue = 'Malformed UTF-8 characters, possibly incorrectly encoded';
494:                 break;
495:             default:
496:                 $issue = 'Unknown error';
497:                 break;
498:         }
499: 
500:         throw new InvalidArgumentException(
501:             sprintf('JSON %s could not be parsed: "%s"', $filename, $issue)
502:         );
503:     }
504: 
505:     /**
506:      * Convert a snake-cased namespace to a file path.
507:      *
508:      * @param  string $ident The identifier to convert.
509:      * @return string
510:      */
511:     private function filenameFromIdent($ident)
512:     {
513:         $filename  = str_replace([ '\\' ], '.', $ident);
514:         $filename .= '.json';
515: 
516:         return $filename;
517:     }
518: 
519:     /**
520:      * Convert a snake-cased namespace to CamelCase.
521:      *
522:      * @param  string $ident The namespace to convert.
523:      * @return string Returns a valid PHP namespace.
524:      */
525:     private function identToClassname($ident)
526:     {
527:         $key = $ident;
528: 
529:         if (isset(static::$camelCache[$key])) {
530:             return static::$camelCache[$key];
531:         }
532: 
533:         // Change "foo-bar" to "fooBar"
534:         $parts = explode('-', $ident);
535:         array_walk(
536:             $parts,
537:             function(&$i) {
538:                 $i = ucfirst($i);
539:             }
540:         );
541:         $ident = implode('', $parts);
542: 
543:         // Change "/foo/bar" to "\Foo\Bar"
544:         $classname = str_replace('/', '\\', $ident);
545:         $parts     = explode('\\', $classname);
546: 
547:         array_walk(
548:             $parts,
549:             function(&$i) {
550:                 $i = ucfirst($i);
551:             }
552:         );
553: 
554:         $classname = trim(implode('\\', $parts), '\\');
555: 
556:         static::$camelCache[$key]       = $classname;
557:         static::$snakeCache[$classname] = $key;
558: 
559:         return $classname;
560:     }
561: 
562:     /**
563:      * Convert a CamelCase namespace to snake-case.
564:      *
565:      * @param  string $classname The namespace to convert.
566:      * @return string Returns a snake-cased namespace.
567:      */
568:     private function classnameToIdent($classname)
569:     {
570:         $key = trim($classname, '\\');
571: 
572:         if (isset(static::$snakeCache[$key])) {
573:             return static::$snakeCache[$key];
574:         }
575: 
576:         $ident = strtolower(preg_replace('/([a-z])([A-Z])/', '$1-$2', $classname));
577:         $ident = str_replace('\\', '/', strtolower($ident));
578:         $ident = ltrim($ident, '/');
579: 
580:         static::$snakeCache[$key]   = $ident;
581:         static::$camelCache[$ident] = $key;
582: 
583:         return $ident;
584:     }
585: }
586: 
API documentation generated by ApiGen