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

ModelStructureProperty::getStructureMetadata()   A

Complexity

Conditions 3
Paths 2

Size

Total Lines 8

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 8
rs 10
c 0
b 0
f 0
cc 3
nc 2
nop 0
1
<?php
2
3
namespace Charcoal\Property;
4
5
use PDO;
6
use ArrayAccess;
7
use RuntimeException;
8
use InvalidArgumentException;
9
use UnexpectedValueException;
10
11
// From Pimple
12
use Pimple\Container;
13
14
// From 'charcoal-core'
15
use Charcoal\Model\DescribableInterface;
16
use Charcoal\Model\MetadataInterface;
17
use Charcoal\Model\ModelInterface;
18
use Charcoal\Model\Model;
19
20
// From 'charcoal-factory'
21
use Charcoal\Factory\FactoryInterface;
22
23
// From 'charcoal-property'
24
use Charcoal\Property\StructureProperty;
25
use Charcoal\Property\Structure\StructureMetadata;
26
use Charcoal\Property\Structure\StructureModel;
27
28
/**
29
 * Model Structure Data Property
30
 *
31
 * Allows for multiple complex entries to a property, which are stored
32
 * as a JSON string in the model's storage source. Typical use cases would be
33
 * {@see \Charcoal\Cms\Property\TemplateOptionsProperty template options},
34
 * {@see \Charcoal\Property\MapStructureProperty geolocation coordinates},
35
 * details for a log, or a list of addresses or people.
36
 *
37
 * The property's "structured_metadata" attribute allows one to build a virtual
38
 * model using much of the same specifications used for defining object models.
39
 * This allows you to constrain the kind of structure you need to store.
40
 * For any values that can't be bound to a model-like structure, consider using
41
 * {@see StructureProperty}.
42
 *
43
 * ## Examples
44
 *
45
 * **Example #1 — Address**
46
 *
47
 * With the use of the {@see \Charcoal\Admin\Widget\FormGroup\StructureFormGroup Structure Form Group},
48
 * a form UI can be embedded in the object form widget.
49
 *
50
 * ```json
51
 * {
52
 *     "properties": {
53
 *         "street_address": {
54
 *             "type": "string",
55
 *             "input_type": "charcoal/admin/property/input/textarea",
56
 *             "label": "Street Address"
57
 *         },
58
 *         "locality": {
59
 *             "type": "string",
60
 *             "label": "Municipality"
61
 *         },
62
 *         "administrative_area": {
63
 *             "type": "string",
64
 *             "multiple": true,
65
 *             "label": "Administrative Division(s)"
66
 *         },
67
 *         "postal_code": {
68
 *             "type": "string",
69
 *             "label": "Postal Code"
70
 *         },
71
 *         "country": {
72
 *             "type": "string",
73
 *             "label": "Country"
74
 *         }
75
 *     },
76
 *     "admin": {
77
 *         "form_group": {
78
 *             "title": "Address",
79
 *             "show_header": false,
80
 *             "properties": [
81
 *                 "street_address",
82
 *                 "locality",
83
 *                 "postal_code",
84
 *                 "administrative_area",
85
 *                 "country"
86
 *             ],
87
 *             "layout": {
88
 *                 "structure": [
89
 *                     { "columns": [ 1 ] },
90
 *                     { "columns": [ 5, 1 ] },
91
 *                     { "columns": [ 1, 1 ] }
92
 *                 ]
93
 *             }
94
 *         }
95
 *     }
96
 * }
97
 * ```
98
 */
99
class ModelStructureProperty extends StructureProperty
100
{
101
    /**
102
     * Track the state of loaded metadata for the structure.
103
     *
104
     * @var boolean
105
     */
106
    private $isStructureFinalized = false;
107
108
    /**
109
     * The metadata interfaces to use as the structure.
110
     *
111
     * These are paths (PSR-4) to import.
112
     *
113
     * @var array
114
     */
115
    private $structureInterfaces = [];
116
117
    /**
118
     * Store the property's structure.
119
     *
120
     * @var MetadataInterface|array|null
121
     */
122
    private $structureMetadata;
123
124
    /**
125
     * Store the property's "terminal" structure.
126
     *
127
     * This represents the value of "structure_metadata" key on a property definition.
128
     * This should always be merged last, after the interfaces are imported.
129
     *
130
     * @var MetadataInterface|array|null
131
     */
132
    private $terminalStructureMetadata;
133
134
    /**
135
     * Store the property's model prototype.
136
     *
137
     * @var ArrayAccess|DescribableInterface|null
138
     */
139
    private $structurePrototype;
140
141
    /**
142
     * The class name of the "structure" collection to use.
143
     *
144
     * Must be a fully-qualified PHP namespace and an implementation of {@see ArrayAccess}.
145
     *
146
     * @var string
147
     */
148
    private $structureModelClass = StructureModel::class;
149
150
    /**
151
     * Store the factory instance.
152
     *
153
     * @var FactoryInterface
154
     */
155
    protected $structureModelFactory;
156
157
    /**
158
     * Retrieve the property's type identifier.
159
     *
160
     * @return string
161
     */
162
    public function type()
163
    {
164
        return 'model-structure';
165
    }
166
167
    /**
168
     * Retrieve the property's structure.
169
     *
170
     * @return MetadataInterface|null
171
     */
172
    public function getStructureMetadata()
173
    {
174
        if ($this->structureMetadata === null || $this->isStructureFinalized === false) {
175
            $this->structureMetadata = $this->loadStructureMetadata();
176
        }
177
178
        return $this->structureMetadata;
179
    }
180
181
    /**
182
     * Set the property's structure.
183
     *
184
     * @param  MetadataInterface|array|null $data The property's structure (fields, data).
185
     * @throws InvalidArgumentException If the structure is invalid.
186
     * @return self
187
     */
188
    public function setStructureMetadata($data)
189
    {
190
        if ($data === null) {
191
            $this->structureMetadata = $data;
192
            $this->terminalStructureMetadata = $data;
193
        } elseif (is_array($data)) {
194
            $struct = $this->createStructureMetadata();
195
            $struct->merge($data);
196
197
            $this->structureMetadata = $struct;
198
            $this->terminalStructureMetadata = $data;
199
        } elseif ($data instanceof MetadataInterface) {
200
            $this->structureMetadata = $data;
201
            $this->terminalStructureMetadata = $data;
202
        } else {
203
            throw new InvalidArgumentException(sprintf(
204
                'Structure [%s] is invalid (must be array or an instance of %s).',
205
                (is_object($data) ? get_class($data) : gettype($data)),
206
                StructureMetadata::class
207
            ));
208
        }
209
210
        $this->isStructureFinalized = false;
211
212
        return $this;
213
    }
214
215
    /**
216
     * Retrieve the metadata interfaces used by the property as a structure.
217
     *
218
     * @return array
219
     */
220
    public function getStructureInterfaces()
221
    {
222
        if (empty($this->structureInterfaces)) {
223
            return $this->structureInterfaces;
224
        }
225
226
        return array_keys($this->structureInterfaces);
227
    }
228
229
    /**
230
     * Set the given metadata interfaces for the property to use as a structure.
231
     *
232
     * @param  array $interfaces One or more metadata interfaces to use.
233
     * @return self
234
     */
235
    public function setStructureInterfaces(array $interfaces)
236
    {
237
        $this->structureInterfaces = [];
238
239
        $this->addStructureInterfaces($interfaces);
240
241
        return $this;
242
    }
243
244
    /**
245
     * Add the given metadata interfaces for the property to use as a structure.
246
     *
247
     * @param  array $interfaces One or more metadata interfaces to use.
248
     * @return self
249
     */
250
    public function addStructureInterfaces(array $interfaces)
251
    {
252
        foreach ($interfaces as $interface) {
253
            $this->addStructureInterface($interface);
254
        }
255
256
        return $this;
257
    }
258
259
    /**
260
     * Add the given metadata interfaces for the property to use as a structure.
261
     *
262
     * @param  string $interface A metadata interface to use.
263
     * @throws InvalidArgumentException If the interface is not a string.
264
     * @return self
265
     */
266
    public function addStructureInterface($interface)
267
    {
268
        if (!is_string($interface)) {
269
            throw new InvalidArgumentException(sprintf(
270
                'Structure interface must to be a string, received %s',
271
                is_object($interface) ? get_class($interface) : gettype($interface)
272
            ));
273
        }
274
275
        if (!empty($interface)) {
276
            $interface = $this->parseStructureInterface($interface);
277
278
            $this->structureInterfaces[$interface] = true;
279
            $this->isStructureFinalized = false;
280
        }
281
282
        return $this;
283
    }
284
285
    /**
286
     * Load the property's structure.
287
     *
288
     * @return MetadataInterface
289
     */
290
    protected function loadStructureMetadata()
291
    {
292
        $structureMetadata = null;
293
294
        if ($this->isStructureFinalized === false) {
295
            $this->isStructureFinalized = true;
296
297
            $structureInterfaces = $this->getStructureInterfaces();
298
            if (!empty($structureInterfaces)) {
299
                $metadataLoader = $this->metadataLoader();
300
                $metadataClass  = $this->getStructureMetadataClass();
301
302
                $structureKey = $structureInterfaces;
303
                array_unshift($structureKey, $this->ident());
304
                $structureKey = 'property/structure='.$metadataLoader->serializeMetaKey($structureKey);
305
306
                $structureMetadata = $metadataLoader->load(
0 ignored issues
show
Bug Compatibility introduced by
The expression $metadataLoader->load($s... $structureInterfaces); of type Charcoal\Model\MetadataInterface|array adds the type array to the return on line 322 which is incompatible with the return type documented by Charcoal\Property\ModelS...::loadStructureMetadata of type Charcoal\Model\MetadataInterface.
Loading history...
307
                    $structureKey,
308
                    $metadataClass,
309
                    $structureInterfaces
310
                );
311
            }
312
        }
313
314
        if ($structureMetadata === null) {
315
            $structureMetadata = $this->createStructureMetadata();
316
        }
317
318
        if ($this->terminalStructureMetadata) {
319
            $structureMetadata->merge($this->terminalStructureMetadata);
0 ignored issues
show
Bug introduced by
It seems like $this->terminalStructureMetadata can also be of type object<Charcoal\Model\MetadataInterface>; however, Charcoal\Config\ConfigInterface::merge() does only seem to accept array|object<Traversable>, maybe add an additional type check?

If a method or function can return multiple different values and unless you are sure that you only can receive a single value in this context, we recommend to add an additional type check:

/**
 * @return array|string
 */
function returnsDifferentValues($x) {
    if ($x) {
        return 'foo';
    }

    return array();
}

$x = returnsDifferentValues($y);
if (is_array($x)) {
    // $x is an array.
}

If this a common case that PHP Analyzer should handle natively, please let us know by opening an issue.

Loading history...
320
        }
321
322
        return $structureMetadata;
323
    }
324
325
    /**
326
     * Retrieve a singleton of the structure model for prototyping.
327
     *
328
     * @return ArrayAccess|DescribableInterface
329
     */
330
    public function structureProto()
331
    {
332
        if ($this->structurePrototype === null) {
333
            $model = $this->createStructureModel();
334
335
            if ($model instanceof DescribableInterface) {
336
                $model->setMetadata($this->getStructureMetadata());
337
            }
338
339
            $this->structurePrototype = $model;
340
        }
341
342
        return $this->structurePrototype;
343
    }
344
345
    /**
346
     * Set the class name of the data-model structure.
347
     *
348
     * @param  string $className The class name of the structure.
349
     * @throws InvalidArgumentException If the class name is not a string.
350
     * @return self
351
     */
352
    protected function setStructureModelClass($className)
353
    {
354
        if (!is_string($className)) {
355
            throw new InvalidArgumentException(
356
                'Structure class name must be a string.'
357
            );
358
        }
359
360
        $this->structureModelClass = $className;
361
362
        return $this;
363
    }
364
365
    /**
366
     * Retrieve the class name of the data-model structure.
367
     *
368
     * @return string
369
     */
370
    public function getStructureModelClass()
371
    {
372
        return $this->structureModelClass;
373
    }
374
375
    /**
376
     * Convert the given value into a structure.
377
     *
378
     * Options:
379
     * - `default_data` (_boolean_|_array_) — If TRUE, the default data defined
380
     *   in the structure's metadata is merged. If an array, that is merged.
381
     *
382
     * @param  mixed                   $val     The value to "structurize".
383
     * @param  array|MetadataInterface $options Optional structure options.
384
     * @throws InvalidArgumentException If the options are invalid.
385
     * @return ModelInterface|ModelInterface[]
386
     */
387
    public function structureVal($val, $options = [])
388
    {
389
        if ($val === null) {
390
            return ($this['multiple'] ? [] : null);
391
        }
392
393
        $metadata = clone $this->getStructureMetadata();
394
395
        if ($options instanceof MetadataInterface) {
396
            $metadata->merge($options);
0 ignored issues
show
Documentation introduced by
$options is of type object<Charcoal\Model\MetadataInterface>, but the function expects a array|object<Traversable>.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
397
        } elseif ($options === null) {
398
            $options = [];
399
        } elseif (is_array($options)) {
400
            if (isset($options['metadata'])) {
401
                $metadata->merge($options['metadata']);
402
            }
403
        } else {
404
            throw new InvalidArgumentException(sprintf(
405
                'Structure value options must to be an array or an instance of %2$s, received %1$s',
406
                is_object($options) ? get_class($options) : gettype($options),
407
                StructureMetadata::class
408
            ));
409
        }
410
411
        $defaultData = [];
412
        if (isset($options['default_data'])) {
413
            if (is_bool($options['default_data'])) {
414
                $withDefaultData = $options['default_data'];
415
                if ($withDefaultData) {
416
                    $defaultData = $metadata->defaultData();
417
                }
418
            } elseif (is_array($options['default_data'])) {
419
                $withDefaultData = true;
0 ignored issues
show
Unused Code introduced by
$withDefaultData is not used, you could remove the assignment.

This check looks for variable assignements that are either overwritten by other assignments or where the variable is not used subsequently.

$myVar = 'Value';
$higher = false;

if (rand(1, 6) > 3) {
    $higher = true;
} else {
    $higher = false;
}

Both the $myVar assignment in line 1 and the $higher assignment in line 2 are dead. The first because $myVar is never used and the second because $higher is always overwritten for every possible time line.

Loading history...
420
                $defaultData     = $options['default_data'];
421
            }
422
        }
423
424
        $val = $this->parseVal($val);
425
426
        if ($this['multiple']) {
427
            $entries = [];
428
            foreach ($val as $v) {
429
                $entries[] = $this->createStructureModelWith($metadata, $defaultData, $v);
430
            }
431
432
            return $entries;
433
        } else {
434
            return $this->createStructureModelWith($metadata, $defaultData, $val);
435
        }
436
    }
437
438
    /**
439
     * Retrieve the structure as a plain array.
440
     *
441
     * @return array
442
     */
443
    public function toStructure()
444
    {
445
        return $this->structureVal($this->val());
0 ignored issues
show
Deprecated Code introduced by
The method Charcoal\Property\AbstractProperty::val() has been deprecated.

This method has been deprecated.

Loading history...
446
    }
447
448
    /**
449
     * @param null|string $model Model ident.
450
     * @return ArrayAccess|DescribableInterface|mixed
451
     * @throws UnexpectedValueException If the structure is invalid.
452
     */
453 View Code Duplication
    public function toModel($model = null)
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...
454
    {
455
        if ($model) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $model of type null|string is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
456
            $structure = $this->structureModelFactory()->create($model);
457
458
            if (!$structure instanceof ArrayAccess) {
459
                throw new UnexpectedValueException(sprintf(
460
                    'Structure [%s] must implement [%s]',
461
                    $model,
462
                    ArrayAccess::class
463
                ));
464
            }
465
466
            return $structure;
467
        }
468
469
        return $this->structureProto();
470
    }
471
472
    /**
473
     * PropertyInterface::save().
474
     * @param  mixed $val The value, at time of saving.
475
     * @return mixed
476
     */
477
    public function save($val)
478
    {
479
        $val = parent::save($val);
480
481
        if ($this['multiple']) {
482
            $proto = $this->structureProto();
483
            if ($proto instanceof ModelInterface) {
484
                $objs = (array)$this->structureVal($val);
485
                $val  = [];
486
                if (!empty($objs)) {
487
                    $val  = [];
488
                    foreach ($objs as $obj) {
489
                        $obj->saveProperties();
490
                        $val[] = $obj->data();
491
                    }
492
                }
493
            }
494
        } else {
495
            $obj = $this->structureVal($val);
496
            if ($obj instanceof ModelInterface) {
497
                $obj->saveProperties();
0 ignored issues
show
Bug introduced by
The method saveProperties() does not exist on Charcoal\Model\ModelInterface. Did you maybe mean properties()?

This check marks calls to methods that do not seem to exist on an object.

This is most likely the result of a method being renamed without all references to it being renamed likewise.

Loading history...
498
                $val = $obj->data();
499
            }
500
        }
501
502
        return $val;
503
    }
504
505
    /**
506
     * Inject dependencies from a DI Container.
507
     *
508
     * @param  Container $container A dependencies container instance.
509
     * @return void
510
     */
511
    protected function setDependencies(Container $container)
512
    {
513
        parent::setDependencies($container);
514
515
        $this->setStructureModelFactory($container['model/factory']);
516
    }
517
518
    /**
519
     * Retrieve the structure model factory.
520
     *
521
     * @throws RuntimeException If the model factory was not previously set.
522
     * @return FactoryInterface
523
     */
524
    protected function structureModelFactory()
525
    {
526
        if (!isset($this->structureModelFactory)) {
527
            throw new RuntimeException(sprintf(
528
                'Model Factory is not defined for "%s"',
529
                get_class($this)
530
            ));
531
        }
532
533
        return $this->structureModelFactory;
534
    }
535
536
    /**
537
     * Set an structure model factory.
538
     *
539
     * @param FactoryInterface $factory The model factory, to create objects.
540
     * @return self
541
     */
542
    private function setStructureModelFactory(FactoryInterface $factory)
543
    {
544
        $this->structureModelFactory = $factory;
545
546
        return $this;
547
    }
548
549
    /**
550
     * Parse a metadata identifier from given interface.
551
     *
552
     * Change `\` and `.` to `/` and force lowercase
553
     *
554
     * @param  string $interface A metadata interface to convert.
555
     * @return string
556
     */
557
    protected function parseStructureInterface($interface)
558
    {
559
        $ident = preg_replace('/([a-z])([A-Z])/', '$1-$2', $interface);
560
        $ident = strtolower(str_replace('\\', '/', $ident));
561
562
        return $ident;
563
    }
564
565
    /**
566
     * Create a new metadata object for structures.
567
     *
568
     * Similar to {@see \Charcoal\Model\DescribableTrait::createMetadata()}.
569
     *
570
     * @return MetadataInterface
571
     */
572
    protected function createStructureMetadata()
573
    {
574
        $class = $this->getStructureMetadataClass();
575
        return new $class();
576
    }
577
578
    /**
579
     * Retrieve the class name of the metadata object.
580
     *
581
     * @return string
582
     */
583
    protected function getStructureMetadataClass()
584
    {
585
        return StructureMetadata::class;
586
    }
587
588
    /**
589
     * Create a data-model structure.
590
     *
591
     * @todo   Add support for simple {@see ArrayAccess} models.
592
     * @throws UnexpectedValueException If the structure is invalid.
593
     * @return ArrayAccess
594
     */
595 View Code Duplication
    private function createStructureModel()
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...
596
    {
597
        $structClass = $this->getStructureModelClass();
598
        $structure   = $this->structureModelFactory()->create($structClass);
599
600
        if (!$structure instanceof ArrayAccess) {
601
            throw new UnexpectedValueException(sprintf(
602
                'Structure [%s] must implement [%s]',
603
                $structClass,
604
                ArrayAccess::class
605
            ));
606
        }
607
608
        return $structure;
609
    }
610
611
    /**
612
     * Create a data-model structure.
613
     *
614
     * @param  MetadataInterface $metadata    The model's definition.
615
     * @param  array             ...$datasets The dataset(s) to modelize.
616
     * @throws UnexpectedValueException If the structure is invalid.
617
     * @return DescribableInterface
618
     */
619
    private function createStructureModelWith(
620
        MetadataInterface $metadata,
621
        array ...$datasets
622
    ) {
623
        $model = $this->createStructureModel();
624
        if (!$model instanceof DescribableInterface) {
625
            throw new UnexpectedValueException(sprintf(
626
                'Structure [%s] must implement [%s]',
627
                get_class($model),
628
                DescribableInterface::class
629
            ));
630
        }
631
632
        $model->setMetadata($metadata);
633
634
        if ($datasets) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $datasets of type array[] is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
635
            foreach ($datasets as $data) {
636
                $model->setData($data);
637
            }
638
        }
639
640
        return $model;
641
    }
642
}
643