Test Setup Failed
Push — master ( 6592af...c06444 )
by Chauncey
08:19
created

src/Charcoal/Property/ObjectProperty.php (1 issue)

Upgrade to new PHP Analysis Engine

These results are based on our legacy PHP analysis, consider migrating to our new PHP analysis engine instead. Learn more

1
<?php
2
3
namespace Charcoal\Property;
4
5
use Traversable;
6
use RuntimeException;
7
use InvalidArgumentException;
8
9
use PDO;
10
11
// From PSR-6
12
use Psr\Cache\CacheItemPoolInterface;
13
14
// From Pimple
15
use Pimple\Container;
16
17
// From 'charcoal-core'
18
use Charcoal\Loader\CollectionLoader;
19
use Charcoal\Model\ModelInterface;
20
use Charcoal\Model\Service\ModelLoader;
21
use Charcoal\Source\StorableInterface;
22
23
// From 'charcoal-factory'
24
use Charcoal\Factory\FactoryInterface;
25
26
// From 'charcoal-view'
27
use Charcoal\View\ViewableInterface;
28
29
// From 'charcoal-translator'
30
use Charcoal\Translator\Translation;
31
32
// From 'charcoal-property'
33
use Charcoal\Property\AbstractProperty;
34
use Charcoal\Property\SelectablePropertyInterface;
35
36
/**
37
 * Object Property holds a reference to an external object.
38
 *
39
 * The object property implements the full `SelectablePropertyInterface` without using
40
 * its accompanying trait. (`set_choices`, `add_choice`, `choices`, `has_choice`, `choice`).
41
 */
42
class ObjectProperty extends AbstractProperty implements SelectablePropertyInterface
43
{
44
    const DEFAULT_PATTERN = '{{name}}';
45
46
    /**
47
     * The object type to build the choices from.
48
     *
49
     * @var string
50
     */
51
    private $objType;
52
53
    /**
54
     * The pattern for rendering the choice as a label.
55
     *
56
     * @var string
57
     */
58
    private $pattern = self::DEFAULT_PATTERN;
59
60
    /**
61
     * The available selectable choices.
62
     *
63
     * This collection is built from selected {@see self::$objType}.
64
     *
65
     * @var array
66
     */
67
    protected $choices = [];
68
69
    /**
70
     * Store the collection loader for the current class.
71
     *
72
     * @var CollectionLoader
73
     */
74
    private $collectionLoader;
75
76
    /**
77
     * The rules for pagination the collection of objects.
78
     *
79
     * @var array|null
80
     */
81
    protected $pagination;
82
83
    /**
84
     * The rules for sorting the collection of objects.
85
     *
86
     * @var array|null
87
     */
88
    protected $orders;
89
90
    /**
91
     * The rules for filtering the collection of objects.
92
     *
93
     * @var array|null
94
     */
95
    protected $filters;
96
97
    /**
98
     * Store the PSR-6 caching service.
99
     *
100
     * @var CacheItemPoolInterface
101
     */
102
    private $cachePool;
103
104
    /**
105
     * Store all model loaders.
106
     *
107
     * @var ModelLoader[]
108
     */
109
    protected static $modelLoaders = [];
110
111
    /**
112
     * Store the factory instance for the current class.
113
     *
114
     * @var FactoryInterface
115
     */
116
    private $modelFactory;
117
118
    /**
119
     * @return string
120
     */
121
    public function type()
122
    {
123
        return 'object';
124
    }
125
126
    /**
127
     * Set the object type to build the choices from.
128
     *
129
     * @param  string $objType The object type.
130
     * @throws InvalidArgumentException If the object type is not a string.
131
     * @return self
132
     */
133 View Code Duplication
    public function setObjType($objType)
0 ignored issues
show
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...
134
    {
135
        if (!is_string($objType)) {
136
            throw new InvalidArgumentException(sprintf(
137
                'Object Property "%s": Object type ("obj_type") must be a string, received %s',
138
                $this->ident(),
139
                gettype($objType)
140
            ));
141
        }
142
143
        $this->objType = $objType;
144
145
        return $this;
146
    }
147
148
    /**
149
     * Retrieve the object type to build the choices from.
150
     *
151
     * @throws RuntimeException If the object type was not previously set.
152
     * @return string
153
     */
154
    public function getObjType()
155
    {
156
        if ($this->objType === null) {
157
            throw new RuntimeException(sprintf(
158
                'Object Property "%s": Missing object type ("obj_type")',
159
                $this->ident()
160
            ));
161
        }
162
163
        return $this->objType;
164
    }
165
166
    /**
167
     * @param  string $pattern The render pattern.
168
     * @throws InvalidArgumentException If the pattern is not a string.
169
     * @return ObjectProperty Chainable
170
     */
171
    public function setPattern($pattern)
172
    {
173
        if (!is_string($pattern)) {
174
            throw new InvalidArgumentException(
175
                'The render pattern must be a string.'
176
            );
177
        }
178
179
        $this->pattern = $pattern;
180
181
        return $this;
182
    }
183
184
    /**
185
     * @return string
186
     */
187
    public function getPattern()
188
    {
189
        return $this->pattern;
190
    }
191
192
193
    /**
194
     * @return string
195
     */
196 View Code Duplication
    public function sqlType()
197
    {
198
        if ($this['multiple'] === true) {
199
            return 'TEXT';
200
        } else {
201
            // Read from proto's key
202
            $proto = $this->proto();
203
            $key   = $proto->p($proto->key());
204
205
            return $key->sqlType();
206
        }
207
    }
208
209
    /**
210
     * @return integer
211
     */
212 View Code Duplication
    public function sqlPdoType()
213
    {
214
        if ($this['multiple'] === true) {
215
            return PDO::PARAM_STR;
216
        } else {
217
            // Read from proto's key
218
            $proto = $this->proto();
219
            $key   = $proto->p($proto->key());
220
221
            return $key->sqlPdoType();
222
        }
223
    }
224
225
    /**
226
     * Always return IDs.
227
     *
228
     * @param  mixed $val Value to be parsed.
229
     * @return mixed
230
     */
231
    public function parseOne($val)
232
    {
233
        if ($val instanceof StorableInterface) {
234
            return $val->id();
235
        } else {
236
            return $val;
237
        }
238
    }
239
240
    /**
241
     * Get the property's value in a format suitable for storage.
242
     *
243
     * @param  mixed $val Optional. The value to convert to storage value.
244
     * @return mixed
245
     */
246
    public function storageVal($val)
247
    {
248
        if ($val === null || $val === '') {
249
            // Do not json_encode NULL values
250
            return null;
251
        }
252
253
        $val = $this->parseVal($val);
254
255
        if ($this['multiple']) {
256
            if (is_array($val)) {
257
                $val = implode($this->multipleSeparator(), $val);
258
            }
259
        }
260
261
        if (!is_scalar($val)) {
262
            return json_encode($val);
263
        }
264
265
        return $val;
266
    }
267
268
    /**
269
     * Retrieve a singleton of the {self::$objType} for prototyping.
270
     *
271
     * @return ModelInterface
272
     */
273
    public function proto()
274
    {
275
        return $this->modelFactory()->get($this->getObjType());
276
    }
277
278
    /**
279
     * @param  mixed $val     Optional. The value to to convert for input.
280
     * @param  array $options Unused input options.
281
     * @return string
282
     */
283
    public function inputVal($val, array $options = [])
284
    {
285
        unset($options);
286
287
        if ($val === null) {
288
            return '';
289
        }
290
291
        if (is_string($val)) {
292
            return $val;
293
        }
294
295
        $val = $this->parseVal($val);
296
297
        if ($this['multiple']) {
298
            if (is_array($val)) {
299
                $val = implode($this->multipleSeparator(), $val);
300
            }
301
        }
302
303
        if (!is_scalar($val)) {
304
            return json_encode($val);
305
        }
306
307
        return $val;
308
    }
309
310
    /**
311
     * @param  mixed $val     The value to to convert for display.
312
     * @param  array $options Optional display options.
313
     * @return string
314
     */
315
    public function displayVal($val, array $options = [])
316
    {
317
        if ($val === null) {
318
            return '';
319
        }
320
321
        if (isset($options['pattern'])) {
322
            $pattern = $options['pattern'];
323
        } else {
324
            $pattern = null;
325
        }
326
327
        if (isset($options['lang'])) {
328
            $lang = $options['lang'];
329
        } else {
330
            $lang = null;
331
        }
332
333
        if ($val instanceof ModelInterface) {
334
            $propertyVal = $this->renderObjPattern($val, $pattern, $lang);
335
336
            if (empty($propertyVal) && !is_numeric($propertyVal)) {
337
                $propertyVal = $val->id();
338
            }
339
340
            return $propertyVal;
341
        }
342
343
        /** Parse multilingual values */
344 View Code Duplication
        if ($this['l10n']) {
345
            $propertyValue = $this->l10nVal($val, $options);
346
            if ($propertyValue === null) {
347
                return '';
348
            }
349
        } elseif ($val instanceof Translation) {
350
            $propertyValue = (string)$val;
351
        } else {
352
            $propertyValue = $val;
353
        }
354
355
        /** Parse multiple values / ensure they are of array type. */
356
        if ($this['multiple']) {
357
            if (!is_array($propertyValue)) {
358
                $propertyValue = $this->parseValAsMultiple($propertyValue);
359
            }
360
        } else {
361
            $propertyValue = (array)$propertyValue;
362
        }
363
364
        $values = [];
365
        foreach ($propertyValue as $val) {
366
            $label = null;
367
            if ($val instanceof ModelInterface) {
368
                $label = $this->renderObjPattern($val, $pattern, $lang);
369
            } else {
370
                $obj = $this->loadObject($val);
371
                if (is_object($obj)) {
372
                    $label = $this->renderObjPattern($obj, $pattern, $lang);
373
                }
374
            }
375
376
            if (empty($label) && !is_numeric($label)) {
377
                $label = $val;
378
            }
379
380
            $values[] = $label;
381
        }
382
383
        $separator = $this->multipleSeparator();
384
        if ($separator === ',') {
385
            $separator = ', ';
386
        }
387
388
        return implode($separator, $values);
389
    }
390
391
    /**
392
     * Set the available choices.
393
     *
394
     * @param  array $choices One or more choice structures.
395
     * @return self
396
     */
397
    public function setChoices(array $choices)
398
    {
399
        unset($choices);
400
401
        $this->logger->debug(
402
            'Choices can not be set for object properties. They are auto-generated from objects.'
403
        );
404
405
        return $this;
406
    }
407
408
    /**
409
     * Merge the available choices.
410
     *
411
     * @param  array $choices One or more choice structures.
412
     * @return self
413
     */
414
    public function addChoices(array $choices)
415
    {
416
        unset($choices);
417
418
        $this->logger->debug(
419
            'Choices can not be added for object properties. They are auto-generated from objects.'
420
        );
421
422
        return $this;
423
    }
424
425
    /**
426
     * Add a choice to the available choices.
427
     *
428
     * @param  string       $choiceIdent The choice identifier (will be key / default ident).
429
     * @param  string|array $choice      A string representing the choice label or a structure.
430
     * @return self
431
     */
432
    public function addChoice($choiceIdent, $choice)
433
    {
434
        unset($choiceIdent, $choice);
435
436
        $this->logger->debug(
437
            'Choices can not be added for object properties. They are auto-generated from objects.'
438
        );
439
440
        return $this;
441
    }
442
443
    /**
444
     * Set the rules for pagination the collection of objects.
445
     *
446
     * @param  array $pagination Pagination settings.
447
     * @return ObjectProperty Chainable
448
     */
449
    public function setPagination(array $pagination)
450
    {
451
        $this->pagination = $pagination;
452
453
        return $this;
454
    }
455
456
    /**
457
     * Retrieve the rules for pagination the collection of objects.
458
     *
459
     * @return array|null
460
     */
461
    public function pagination()
462
    {
463
        return $this->pagination;
464
    }
465
466
    /**
467
     * Set the rules for sorting the collection of objects.
468
     *
469
     * @param  array $orders An array of orders.
470
     * @return ObjectProperty Chainable
471
     */
472
    public function setOrders(array $orders)
473
    {
474
        $this->orders = $orders;
475
476
        return $this;
477
    }
478
479
    /**
480
     * Retrieve the rules for sorting the collection of objects.
481
     *
482
     * @return array|null
483
     */
484
    public function orders()
485
    {
486
        return $this->orders;
487
    }
488
489
    /**
490
     * Set the rules for filtering the collection of objects.
491
     *
492
     * @param  array $filters An array of filters.
493
     * @return ObjectProperty Chainable
494
     */
495
    public function setFilters(array $filters)
496
    {
497
        $this->filters = $filters;
498
499
        return $this;
500
    }
501
502
    /**
503
     * Retrieve the rules for filtering the collection of objects.
504
     *
505
     * @return array|null
506
     */
507
    public function filters()
508
    {
509
        return $this->filters;
510
    }
511
512
    /**
513
     * Determine if choices are available.
514
     *
515
     * @return boolean
516
     */
517
    public function hasChoices()
518
    {
519
        if (!$this->proto()->source()->tableExists()) {
520
            return false;
521
        }
522
523
        return ($this->collectionModelLoader()->loadCount() > 0);
524
    }
525
526
    /**
527
     * Retrieve the available choice structures.
528
     *
529
     * @see    SelectablePropertyInterface::choices()
530
     * @return array
531
     */
532
    public function choices()
533
    {
534
        $proto = $this->proto();
535
        if (!$proto->source()->tableExists()) {
536
            return [];
537
        }
538
539
        $objects = $this->collectionModelLoader()->load();
540
        $choices = $this->parseChoices($objects);
541
542
        return $choices;
543
    }
544
545
    /**
546
     * Determine if the given choice is available.
547
     *
548
     * @see    SelectablePropertyInterface::hasChoice()
549
     * @param  string $choiceIdent The choice identifier to lookup.
550
     * @return boolean
551
     */
552
    public function hasChoice($choiceIdent)
553
    {
554
        $obj = $this->loadObject($choiceIdent);
555
556
        return ($obj instanceof ModelInterface && $obj->id() == $choiceIdent);
557
    }
558
559
    /**
560
     * Retrieve the structure for a given choice.
561
     *
562
     * The method can be used to format an object into a choice structure.
563
     *
564
     * @see    SelectablePropertyInterface::choice()
565
     * @param  string $choiceIdent The choice identifier to lookup or object to format.
566
     * @return mixed The matching choice.
567
     */
568
    public function choice($choiceIdent)
569
    {
570
        $obj = $this->loadObject($choiceIdent);
571
572
        if ($obj === null) {
573
            return null;
574
        }
575
576
        $choice = $this->parseChoice($obj);
577
578
        return $choice;
579
    }
580
581
    /**
582
     * Retrieve the label for a given choice.
583
     *
584
     * @see    SelectablePropertyInterface::choiceLabel()
585
     * @param  string|array|ModelInterface $choice The choice identifier to lookup.
586
     * @throws InvalidArgumentException If the choice is invalid.
587
     * @return string|null Returns the label. Otherwise, returns the raw value.
588
     */
589
    public function choiceLabel($choice)
590
    {
591
        if ($choice === null) {
592
            return null;
593
        }
594
595 View Code Duplication
        if (is_array($choice)) {
596
            if (isset($choice['label'])) {
597
                return $choice['label'];
598
            } elseif (isset($choice['value'])) {
599
                return $choice['value'];
600
            } else {
601
                throw new InvalidArgumentException(
602
                    'Choice structure must contain a "label" or "value".'
603
                );
604
            }
605
        }
606
607
        $obj = $this->loadObject($choice);
608
609
        if ($obj === null) {
610
            return $choice;
611
        }
612
613
        return $this->renderObjPattern($obj);
614
    }
615
616
    /**
617
     * Inject dependencies from a DI Container.
618
     *
619
     * @param  Container $container A dependencies container instance.
620
     * @return void
621
     */
622
    protected function setDependencies(Container $container)
623
    {
624
        parent::setDependencies($container);
625
626
        $this->setModelFactory($container['model/factory']);
627
        $this->setCollectionLoader($container['model/collection/loader']);
628
        $this->setCachePool($container['cache']);
629
    }
630
631
    /**
632
     * Retrieve the cache service.
633
     *
634
     * @throws RuntimeException If the cache service was not previously set.
635
     * @return CacheItemPoolInterface
636
     */
637
    protected function cachePool()
638
    {
639
        if (!isset($this->cachePool)) {
640
            throw new RuntimeException(sprintf(
641
                'Cache Pool is not defined for "%s"',
642
                get_class($this)
643
            ));
644
        }
645
646
        return $this->cachePool;
647
    }
648
649
    /**
650
     * Parse the given objects into choice structures.
651
     *
652
     * @param  ModelInterface[]|Traversable $objs One or more objects to format.
653
     * @throws InvalidArgumentException If the collection of objects is not iterable.
654
     * @return array Returns a collection of choice structures.
655
     */
656
    protected function parseChoices($objs)
657
    {
658
        if (!is_array($objs) && !$objs instanceof Traversable) {
659
            throw new InvalidArgumentException('Must be iterable');
660
        }
661
662
        $parsed = [];
663 View Code Duplication
        foreach ($objs as $choice) {
664
            $choice = $this->parseChoice($choice);
665
            if ($choice !== null) {
666
                $choiceIdent = $choice['value'];
667
                $parsed[$choiceIdent] = $choice;
668
            }
669
        }
670
671
        return $parsed;
672
    }
673
674
675
    /**
676
     * Parse the given value into a choice structure.
677
     *
678
     * @param  ModelInterface $obj An object to format.
679
     * @return array Returns a choice structure.
680
     */
681
    protected function parseChoice(ModelInterface $obj)
682
    {
683
        $label  = $this->renderObjPattern($obj);
684
        $choice = [
685
            'value' => $obj->id(),
686
            'label' => $label,
687
            'title' => $label
688
        ];
689
690
        /** @todo Move to {@see \Charcoal\Admin\Property\AbstractSelectableInput::choiceObjMap()} */
691
        if (is_callable([$obj, 'icon'])) {
692
            $choice['icon'] = $obj->icon();
693
        }
694
695
        return $choice;
696
    }
697
698
    /**
699
     * Retrieve the model collection loader.
700
     *
701
     * @throws RuntimeException If the collection loader was not previously set.
702
     * @return CollectionLoader
703
     */
704
    protected function collectionLoader()
705
    {
706
        if ($this->collectionLoader === null) {
707
            throw new RuntimeException(sprintf(
708
                'Collection Loader is not defined for "%s"',
709
                get_class($this)
710
            ));
711
        }
712
713
        return $this->collectionLoader;
714
    }
715
716
    /**
717
     * Retrieve the prepared model collection loader.
718
     *
719
     * @return CollectionLoader
720
     */
721
    protected function collectionModelLoader()
722
    {
723
        $loader = $this->collectionLoader();
724
725
        if (!$loader->hasModel()) {
726
            $loader->setModel($this->proto());
727
728
            $pagination = $this->pagination();
729
            if (!empty($pagination)) {
730
                $loader->setPagination($pagination);
731
            }
732
733
            $orders = $this->orders();
734
            if (!empty($orders)) {
735
                $loader->setOrders($orders);
736
            }
737
738
            $filters = $this->filters();
739
            if (!empty($filters)) {
740
                $loader->setFilters($filters);
741
            }
742
        }
743
744
        return $loader;
745
    }
746
747
    /**
748
     * Render the given object.
749
     *
750
     * @see    Move to \Charcoal\Admin\Property\AbstractSelectableInput::choiceObjMap()
751
     * @param  ModelInterface $obj     The object or view to render as a label.
752
     * @param  string|null    $pattern Optional. The render pattern to render.
753
     * @param  string|null    $lang    The language to return the value in.
754
     * @throws InvalidArgumentException If the pattern is not a string.
755
     * @return string
756
     */
757
    protected function renderObjPattern(ModelInterface $obj, $pattern = null, $lang = null)
758
    {
759
        if ($pattern === null) {
760
            $pattern = $this->getPattern();
761
        } elseif (!is_string($pattern)) {
762
            throw new InvalidArgumentException(
763
                'The render pattern must be a string.'
764
            );
765
        }
766
767
        if ($pattern === '') {
768
            return '';
769
        }
770
771 View Code Duplication
        if ($lang === null) {
772
            $lang = $this->translator()->getLocale();
773
        } elseif (!is_string($lang)) {
774
            throw new InvalidArgumentException(
775
                'The language to render as must be a string.'
776
            );
777
        }
778
779
        $origLang = $this->translator()->getLocale();
780
        $this->translator()->setLocale($lang);
781
782
        if (strpos($pattern, '{{') === false) {
783
            $output = (string)$obj[$pattern];
784
        } elseif (($obj instanceof ViewableInterface) && $obj->view()) {
785
            $output = $obj->renderTemplate($pattern);
786
        } else {
787
            $callback = function($matches) use ($obj) {
788
                $prop = trim($matches[1]);
789
                return (string)$obj[$prop];
790
            };
791
792
            $output = preg_replace_callback('~\{\{\s*(.*?)\s*\}\}~i', $callback, $pattern);
793
        }
794
795
        $this->translator()->setLocale($origLang);
796
797
        return $output;
798
    }
799
800
    /**
801
     * Retrieve an object by its ID.
802
     *
803
     * Loads the object from the cache store or from the storage source.
804
     *
805
     * @param  mixed $objId Object id.
806
     * @return ModelInterface
807
     */
808
    protected function loadObject($objId)
809
    {
810
        if ($objId instanceof ModelInterface) {
811
            return $objId;
812
        }
813
814
        $obj = $this->modelLoader()->load($objId);
815
        if (!$obj->id()) {
816
            return null;
817
        } else {
818
            return $obj;
819
        }
820
    }
821
822
    /**
823
     * Retrieve the model loader.
824
     *
825
     * @param  string $objType The object type.
826
     * @throws InvalidArgumentException If the object type is invalid.
827
     * @return ModelLoader
828
     */
829
    protected function modelLoader($objType = null)
830
    {
831
        if ($objType === null) {
832
            $objType = $this->getObjType();
833
        } elseif (!is_string($objType)) {
834
            throw new InvalidArgumentException(
835
                'Object type must be a string.'
836
            );
837
        }
838
839
        if (isset(self::$modelLoaders[$objType])) {
840
            return self::$modelLoaders[$objType];
841
        }
842
843
        self::$modelLoaders[$objType] = new ModelLoader([
844
            'logger'    => $this->logger,
845
            'obj_type'  => $objType,
846
            'factory'   => $this->modelFactory(),
847
            'cache'     => $this->cachePool()
848
        ]);
849
850
        return self::$modelLoaders[$objType];
851
    }
852
853
    /**
854
     * Retrieve the object model factory.
855
     *
856
     * @throws RuntimeException If the model factory was not previously set.
857
     * @return FactoryInterface
858
     */
859
    protected function modelFactory()
860
    {
861
        if (!isset($this->modelFactory)) {
862
            throw new RuntimeException(sprintf(
863
                'Model Factory is not defined for "%s"',
864
                get_class($this)
865
            ));
866
        }
867
868
        return $this->modelFactory;
869
    }
870
871
    /**
872
     * Set an object model factory.
873
     *
874
     * @param  FactoryInterface $factory The model factory, to create objects.
875
     * @return void
876
     */
877
    private function setModelFactory(FactoryInterface $factory)
878
    {
879
        $this->modelFactory = $factory;
880
    }
881
882
    /**
883
     * Set a model collection loader.
884
     *
885
     * @param  CollectionLoader $loader The collection loader.
886
     * @return void
887
     */
888
    private function setCollectionLoader(CollectionLoader $loader)
889
    {
890
        $this->collectionLoader = $loader;
891
    }
892
893
    /**
894
     * Set the cache service.
895
     *
896
     * @param  CacheItemPoolInterface $cache A PSR-6 compliant cache pool instance.
897
     * @return void
898
     */
899
    private function setCachePool(CacheItemPoolInterface $cache)
900
    {
901
        $this->cachePool = $cache;
902
    }
903
}
904