Completed
Push — master ( dd6356...9040ae )
by Jacob
9s
created

AbstractModel::apply()   C

Complexity

Conditions 11
Paths 32

Size

Total Lines 40
Code Lines 25

Duplication

Lines 0
Ratio 0 %

Importance

Changes 5
Bugs 0 Features 2
Metric Value
c 5
b 0
f 2
dl 0
loc 40
rs 5.2653
cc 11
eloc 25
nc 32
nop 1

How to fix   Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
namespace As3\Modlr\Models;
4
5
use As3\Modlr\Metadata\Interfaces\AttributeInterface;
6
use As3\Modlr\Store\Store;
7
8
/**
9
 * Represents a record from a persistence (database) layer.
10
 * Can either be a root record, or an embedded fragment of a root record.
11
 *
12
 * @author Jacob Bare <[email protected]>
13
 */
14
abstract class AbstractModel
15
{
16
    /**
17
     * The model's attributes
18
     *
19
     * @var Attributes
20
     */
21
    protected $attributes;
22
23
    /**
24
     * The Model's has-one embeds
25
     *
26
     * @var Embeds\HasOne
27
     */
28
    protected $hasOneEmbeds;
29
30
    /**
31
     * The Model's has-many embeds
32
     *
33
     * @var Embeds\HasMany
34
     */
35
    protected $hasManyEmbeds;
36
37
    /**
38
     * The metadata that defines this Model.
39
     *
40
     * @var AttributeInterface
41
     */
42
    protected $metadata;
43
44
    /**
45
     * The model state.
46
     *
47
     * @var State
48
     */
49
    protected $state;
50
51
    /**
52
     * The Model Store for handling lifecycle operations.
53
     *
54
     * @var Store
55
     */
56
    protected $store;
57
58
    /**
59
     * Constructor.
60
     *
61
     * @param   AttributeInterface  $metadata
62
     * @param   Store               $store
63
     * @param   array|null          $properties
64
     */
65
    public function __construct(AttributeInterface $metadata, Store $store, array $properties = null)
66
    {
67
        $this->state = new State();
68
        $this->metadata = $metadata;
69
        $this->store = $store;
70
        $this->initialize($properties);
71
    }
72
73
    /**
74
     * Cloner.
75
     * Ensures sub objects are also cloned.
76
     *
77
     */
78
    public function __clone()
79
    {
80
        $this->attributes = clone $this->attributes;
81
        $this->hasOneEmbeds = clone $this->hasOneEmbeds;
82
        $this->hasManyEmbeds = clone $this->hasManyEmbeds;
83
        $this->state = clone $this->state;
84
    }
85
86
    /**
87
     * Applies an array of raw model properties to the model instance.
88
     *
89
     * @todo    Confirm that we want this method. It's currently used for creating and updating via the API adapter. Also see initialize()
90
     * @param   array   $properties     The properties to apply.
91
     * @return  self
92
     */
93
    public function apply(array $properties)
94
    {
95
        $properties = $this->applyDefaultAttrValues($properties);
96
        foreach ($properties as $key => $value) {
97
            if (true === $this->isAttribute($key)) {
98
                $this->set($key, $value);
99
                continue;
100
            }
101
102
            if (true === $this->isEmbedHasOne($key)) {
103
                if (empty($value)) {
104
                    $this->clear($key);
105
                    continue;
106
                }
107
                $embed = $this->get($key) ?: $this->createEmbedFor($key, $value);
0 ignored issues
show
Unused Code introduced by
The call to AbstractModel::createEmbedFor() has too many arguments starting with $value.

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress.

In this case you can add the @ignore PhpDoc annotation to the duplicate definition and it will be ignored.

Loading history...
108
                if (!is_array($value)) {
109
                    continue;
110
                }
111
                $embed->apply($value);
112
                $this->set($key, $embed);
113
                continue;
114
            }
115
        }
116
117
        foreach ($this->getMetadata()->getEmbeds() as $key => $embeddedPropMeta) {
118
            if (true === $embeddedPropMeta->isOne() || !isset($properties[$key])) {
119
                continue;
120
            }
121
122
            // @todo This will always mark the model as dirty, even if the applied embed values are the same as the original.
123
            $this->clear($key);
124
            $collection = $this->getStore()->createEmbedCollection($embeddedPropMeta, $properties[$key]);
125
            foreach ($collection as $value) {
126
                $this->pushEmbed($key, $value);
0 ignored issues
show
Compatibility introduced by
$value of type object<As3\Modlr\Models\AbstractModel> is not a sub-type of object<As3\Modlr\Models\Embed>. It seems like you assume a child class of the class As3\Modlr\Models\AbstractModel to be always present.

This check looks for parameters that are defined as one type in their type hint or doc comment but seem to be used as a narrower type, i.e an implementation of an interface or a subclass.

Consider changing the type of the parameter or doing an instanceof check before assuming your parameter is of the expected type.

Loading history...
127
            }
128
        }
129
130
        $this->doDirtyCheck();
131
        return $this;
132
    }
133
134
    /**
135
     * Clears a property value.
136
     * For an attribute, will set the value to null.
137
     * For collections, will clear the collection contents.
138
     *
139
     * @api
140
     * @param   string  $key    The property key.
141
     * @return  self
142
     */
143
    public function clear($key)
144
    {
145
        if (true === $this->isAttribute($key)) {
146
            return $this->setAttribute($key, null);
147
        }
148
        if (true === $this->isEmbedHasOne($key)) {
149
            return $this->setEmbedHasOne($key, null);
150
        }
151
        if (true === $this->isEmbedHasMany($key)) {
152
            $collection = $this->hasManyEmbeds->get($key);
153
            $collection->clear();
154
            $this->doDirtyCheck();
155
            return $this;
156
        }
157
        return $this;
158
    }
159
160
    /**
161
     * Creates a new Embed model instance for the provided property key.
162
     *
163
     * @param   string  $key
164
     * @return  Embed
165
     * @throws  \RuntimeException
166
     */
167
    public function createEmbedFor($key)
168
    {
169
        if (false === $this->isEmbed($key)) {
170
            throw new \RuntimeException(sprintf('Unable to create an Embed instance for property key "%s" - the property is not an embed.', $key));
171
        }
172
173
        $embedMeta = $this->getMetadata()->getEmbed($key)->embedMeta;
174
        $embed = $this->getStore()->loadEmbed($embedMeta, []);
175
        $embed->getState()->setNew();
176
        return $embed;
177
    }
178
179
    /**
180
     * Gets a model property.
181
     * Returns null if the property does not exist on the model or is not set.
182
     *
183
     * @api
184
     * @param   string  $key    The property field key.
185
     * @return  Model|Model[]|Embed|Collections\EmbedCollection|null|mixed
186
     */
187
    public function get($key)
188
    {
189
        if (true === $this->isAttribute($key)) {
190
            return $this->getAttribute($key);
191
        }
192
        if (true === $this->isEmbed($key)) {
193
            return $this->getEmbed($key);
194
        }
195
    }
196
197
    /**
198
     * Gets the current change set of properties.
199
     *
200
     * @api
201
     * @return  array
202
     */
203
    public function getChangeSet()
204
    {
205
        return [
206
            'attributes'    => $this->filterNotSavedProperties($this->attributes->calculateChangeSet()),
207
            'embedOne'      => $this->hasOneEmbeds->calculateChangeSet(),
208
            'embedMany'     => $this->hasManyEmbeds->calculateChangeSet(),
209
        ];
210
    }
211
212
    /**
213
     * Gets the composite key of the model.
214
     *
215
     * @api
216
     * @return  string
217
     */
218
    abstract public function getCompositeKey();
219
220
    /**
221
     * Gets the metadata for this model.
222
     *
223
     * @api
224
     * @return  AttributeInterface
225
     */
226
    public function getMetadata()
227
    {
228
        return $this->metadata;
229
    }
230
231
    /**
232
     * Gets the model state object.
233
     *
234
     * @todo    Should this be public? State setting should likely be locked from the outside world.
235
     * @return  State
236
     */
237
    public function getState()
238
    {
239
        return $this->state;
240
    }
241
242
    /**
243
     * Gets the model store.
244
     *
245
     * @api
246
     * @return  Store
247
     */
248
    public function getStore()
249
    {
250
        return $this->store;
251
    }
252
253
    /**
254
     * Initializes the model and loads its attributes and relationships.
255
     *
256
     * @todo    Made public so collections can initialize models. Not sure if we want this??
257
     * @param   array|null      $properties     The db properties to apply.
258
     * @return  self
259
     */
260
    public function initialize(array $properties = null)
261
    {
262
        $attributes = [];
263
        $embedOne = [];
264
        $embedMany = [];
265
266
        if (null !== $properties) {
267
            $attributes = $this->applyDefaultAttrValues($attributes);
268
            foreach ($properties as $key => $value) {
269
                if (true === $this->isAttribute($key)) {
270
                    // Load attribute.
271
                    $attributes[$key] = $this->convertAttributeValue($key, $value);
272
                } else if (true === $this->isEmbedHasOne($key) && is_array($value)) {
273
                    // Load embed one.
274
                    $embedOne[$key] = $this->getStore()->loadEmbed($this->getMetadata()->getEmbed($key)->embedMeta, $value);
275
                }
276
            }
277
        }
278
279
        foreach ($this->getMetadata()->getEmbeds() as $key => $embeddedPropMeta) {
280
            // Always load embedded collections, regardless if data is set.
281
            if (true === $embeddedPropMeta->isOne()) {
282
                continue;
283
            }
284
            $embeds = !isset($properties[$key]) ? [] : $properties[$key];
285
            $embedMany[$key] = $this->getStore()->createEmbedCollection($embeddedPropMeta, $embeds);
286
        }
287
288
        $this->attributes    = (null === $this->attributes) ? new Attributes($attributes) : $this->attributes->replace($attributes);
289
        $this->hasOneEmbeds  = (null === $this->hasOneEmbeds) ? new Embeds\HasOne($embedOne) : $this->hasOneEmbeds->replace($embedOne);
290
        $this->hasManyEmbeds = (null === $this->hasManyEmbeds) ? new Embeds\HasMany($embedMany) : $this->hasManyEmbeds->replace($embedMany);
291
292
        if (true === $this->getState()->is('new')) {
293
            // Ensure default values are applied to new models.
294
            $this->apply([]);
295
        }
296
297
        $this->doDirtyCheck();
298
        return $this;
299
    }
300
301
    /**
302
     * Determines if a property key is an attribute.
303
     *
304
     * @api
305
     * @param   string  $key    The property key.
306
     * @return  bool
307
     */
308
    public function isAttribute($key)
309
    {
310
        return $this->getMetadata()->hasAttribute($key);
311
    }
312
313
    /**
314
     * Determines if the model is currently dirty.
315
     *
316
     * @api
317
     * @return  bool
318
     */
319
    public function isDirty()
320
    {
321
        return true === $this->attributes->areDirty()
322
            || true === $this->hasOneEmbeds->areDirty()
323
            || true === $this->hasManyEmbeds->areDirty()
324
        ;
325
    }
326
327
    /**
328
     * Determines if a property key is an embedded property.
329
     *
330
     * @api
331
     * @param   string  $key    The property key.
332
     * @return  bool
333
     */
334
    public function isEmbed($key)
335
    {
336
        return $this->getMetadata()->hasEmbed($key);
337
    }
338
339
    /**
340
     * Determines if a property key is a has-many embed.
341
     *
342
     * @api
343
     * @param   string  $key    The property key.
344
     * @return  bool
345
     */
346
    public function isEmbedHasMany($key)
347
    {
348
        if (false === $this->isEmbed($key)) {
349
            return false;
350
        }
351
        return $this->getMetadata()->getEmbed($key)->isMany();
352
    }
353
354
    /**
355
     * Determines if a property key is a has-one embed.
356
     *
357
     * @api
358
     * @param   string  $key    The property key.
359
     * @return  bool
360
     */
361
    public function isEmbedHasOne($key)
362
    {
363
        if (false === $this->isEmbed($key)) {
364
            return false;
365
        }
366
        return $this->getMetadata()->getEmbed($key)->isOne();
367
    }
368
369
    /**
370
     * Pushes an Embed into a has-many embed collection.
371
     * This method must be used for has-many embeds. Direct set is not supported.
372
     * To completely replace call clear() first and then pushEmbed() the new Embeds.
373
     *
374
     * @api
375
     * @param   string  $key
376
     * @param   Embed   $embed
377
     * @return  self
378
     */
379
    public function pushEmbed($key, Embed $embed)
380
    {
381
        if (true === $this->isEmbedHasOne($key)) {
382
            return $this->setEmbedHasOne($key, $embed);
383
        }
384
        if (false === $this->isEmbedHasMany($key)) {
385
            return $this;
386
        }
387
        $this->touch();
388
        $collection = $this->hasManyEmbeds->get($key);
389
        $collection->push($embed);
390
        $this->doDirtyCheck();
391
        return $this;
392
    }
393
394
    /**
395
     * Removes a specific Embed from a has-many embed collection.
396
     *
397
     * @api
398
     * @param   string  $key    The has-many embed key.
399
     * @param   Embed   $embed  The embed to remove from the collection.
400
     * @return  self
401
     */
402 View Code Duplication
    public function removeEmbed($key, Embed $embed)
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...
403
    {
404
        if (false === $this->isEmbedHasMany($key)) {
405
            return $this;
406
        }
407
        $this->touch();
408
        $collection = $this->hasManyEmbeds->get($key);
409
        $collection->remove($embed);
410
        $this->doDirtyCheck();
411
        return $this;
412
    }
413
414
    /**
415
     * Rolls back a model to its original values.
416
     *
417
     * @api
418
     * @return  self
419
     */
420
    public function rollback()
421
    {
422
        $this->attributes->rollback();
423
        $this->hasOneEmbeds->rollback();
424
        $this->hasManyEmbeds->rollback();
425
        $this->doDirtyCheck();
426
        return $this;
427
    }
428
429
    /**
430
     * Sets a model property.
431
     *
432
     * @api
433
     * @param   string  $key            The property field key.
434
     * @param   Model|Embed|null|mixed  The value to set.
435
     * @return  self.
0 ignored issues
show
Documentation introduced by
The doc-type self. could not be parsed: Unknown type name "self." at position 0. (view supported doc-types)

This check marks PHPDoc comments that could not be parsed by our parser. To see which comment annotations we can parse, please refer to our documentation on supported doc-types.

Loading history...
436
     */
437
    public function set($key, $value)
438
    {
439
        if (true === $this->isAttribute($key)) {
440
            return $this->setAttribute($key, $value);
441
        }
442
        if (true === $this->isEmbed($key)) {
443
            return $this->setEmbed($key, $value);
444
        }
445
    }
446
447
    /**
448
     * Determines if the model uses a particlar mixin.
449
     *
450
     * @api
451
     * @param   string  $name
452
     * @return  bool
453
     */
454
    public function usesMixin($name)
455
    {
456
        return $this->getMetadata()->hasMixin($name);
0 ignored issues
show
Bug introduced by
It seems like you code against a concrete implementation and not the interface As3\Modlr\Metadata\Interfaces\AttributeInterface as the method hasMixin() does only exist in the following implementations of said interface: As3\Modlr\Metadata\EmbedMetadata, As3\Modlr\Metadata\EntityMetadata.

Let’s take a look at an example:

interface User
{
    /** @return string */
    public function getPassword();
}

class MyUser implements User
{
    public function getPassword()
    {
        // return something
    }

    public function getDisplayName()
    {
        // return some name.
    }
}

class AuthSystem
{
    public function authenticate(User $user)
    {
        $this->logger->info(sprintf('Authenticating %s.', $user->getDisplayName()));
        // do something.
    }
}

In the above example, the authenticate() method works fine as long as you just pass instances of MyUser. However, if you now also want to pass a different implementation of User which does not have a getDisplayName() method, the code will break.

Available Fixes

  1. Change the type-hint for the parameter:

    class AuthSystem
    {
        public function authenticate(MyUser $user) { /* ... */ }
    }
    
  2. Add an additional type-check:

    class AuthSystem
    {
        public function authenticate(User $user)
        {
            if ($user instanceof MyUser) {
                $this->logger->info(/** ... */);
            }
    
            // or alternatively
            if ( ! $user instanceof MyUser) {
                throw new \LogicException(
                    '$user must be an instance of MyUser, '
                   .'other instances are not supported.'
                );
            }
    
        }
    }
    
Note: PHP Analyzer uses reverse abstract interpretation to narrow down the types inside the if block in such a case.
  1. Add the method to the interface:

    interface User
    {
        /** @return string */
        public function getPassword();
    
        /** @return string */
        public function getDisplayName();
    }
    
Loading history...
457
    }
458
459
    /**
460
     * Applies default attribute values from metadata, if set.
461
     *
462
     * @param   array   $attributes     The attributes to apply the defaults to.
463
     * @return  array
464
     */
465
    protected function applyDefaultAttrValues(array $attributes = [])
466
    {
467
        // Set defaults for each attribute.
468
        foreach ($this->getMetadata()->getAttributes() as $key => $attrMeta) {
469
            if (!isset($attrMeta->defaultValue) || isset($attributes[$key])) {
470
                continue;
471
            }
472
            $attributes[$key] = $this->convertAttributeValue($key, $attrMeta->defaultValue);
473
        }
474
        return $attributes;
475
    }
476
477
    /**
478
     * Converts an attribute value to the appropriate data type.
479
     *
480
     * @param   string  $key
481
     * @param   mixed   $value
482
     * @return  mixed
483
     */
484
    protected function convertAttributeValue($key, $value)
485
    {
486
        return $this->store->convertAttributeValue($this->getDataType($key), $value);
487
    }
488
489
    /**
490
     * Does a dirty check and sets the state to this model.
491
     *
492
     * @return  self
493
     */
494
    protected function doDirtyCheck()
495
    {
496
        $this->getState()->setDirty($this->isDirty());
497
        return $this;
498
    }
499
500
    /**
501
     * Removes properties marked as non-saved.
502
     *
503
     * @param   array   $properties
504
     * @return  array
505
     */
506
    protected function filterNotSavedProperties(array $properties)
507
    {
508 View Code Duplication
        foreach ($this->getMetadata()->getAttributes() as $fieldKey => $propMeta) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across 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...
509
            if (true === $propMeta->shouldSave() || !isset($properties[$fieldKey])) {
510
                continue;
511
            }
512
            unset($properties[$fieldKey]);
513
        }
514
        return $properties;
515
    }
516
517
    /**
518
     * Gets an attribute value.
519
     *
520
     * @param   string  $key    The attribute key (field) name.
521
     * @return  mixed
522
     */
523
    protected function getAttribute($key)
524
    {
525
        if (true === $this->isCalculatedAttribute($key)) {
526
            return $this->getCalculatedAttribute($key);
527
        }
528
        $this->touch();
529
        return $this->attributes->get($key);
530
    }
531
532
    /**
533
     * Gets a calculated attribute value.
534
     *
535
     * @param   string  $key    The attribute key (field) name.
536
     * @return  mixed
537
     */
538
    protected function getCalculatedAttribute($key)
539
    {
540
        $attrMeta = $this->getMetadata()->getAttribute($key);
541
        $class  = $attrMeta->calculated['class'];
542
        $method = $attrMeta->calculated['method'];
543
544
        $value = $class::$method($this);
545
        return $this->convertAttributeValue($key, $value);
546
    }
547
548
    /**
549
     * Gets a data type from an attribute key.
550
     *
551
     * @param   string  $key The attribute key.
552
     * @return  string
553
     */
554
    protected function getDataType($key)
555
    {
556
        return $this->getMetadata()->getAttribute($key)->dataType;
557
    }
558
559
    /**
560
     * Gets an embed value.
561
     *
562
     * @param   string  $key    The embed key (field) name.
563
     * @return  Embed|Collections\EmbedCollection|null
564
     */
565
    protected function getEmbed($key)
566
    {
567
        if (true === $this->isEmbedHasOne($key)) {
568
            $this->touch();
569
            return $this->hasOneEmbeds->get($key);
570
        }
571
        if (true === $this->isEmbedHasMany($key)) {
572
            $this->touch();
573
            return $this->hasManyEmbeds->get($key);
574
        }
575
        return null;
576
    }
577
578
    /**
579
     * Determines if an attribute key is calculated.
580
     *
581
     * @param   string  $key    The attribute key.
582
     * @return  bool
583
     */
584
    protected function isCalculatedAttribute($key)
585
    {
586
        if (false === $this->isAttribute($key)) {
587
            return false;
588
        }
589
        return $this->getMetadata()->getAttribute($key)->isCalculated();
590
    }
591
592
    /**
593
     * Sets an attribute value.
594
     * Will convert the value to the proper, internal PHP/Modlr data type.
595
     * Will do a dirty check immediately after setting.
596
     *
597
     * @param   string  $key    The attribute key (field) name.
598
     * @param   mixed   $value  The value to apply.
599
     * @return  self
600
     */
601
    protected function setAttribute($key, $value)
602
    {
603
        if (true === $this->isCalculatedAttribute($key)) {
604
            return $this;
605
        }
606
        $this->touch();
607
        $value = $this->convertAttributeValue($key, $value);
608
        $this->attributes->set($key, $value);
609
        $this->doDirtyCheck();
610
        return $this;
611
    }
612
613
    /**
614
     * Sets an embed value.
615
     *
616
     * @param   string      $key
617
     * @param   Embed|null  $value
618
     * @return  self
619
     */
620
    protected function setEmbed($key, $value)
621
    {
622
        if (true === $this->isEmbedHasOne($key)) {
623
            return $this->setEmbedHasOne($key, $value);
624
        }
625
        if (true === $this->isEmbedHasMany($key)) {
626
            throw new \RuntimeException('You cannot set a hasMany embed directly. Please access using pushEmbed(), clear(), and/or remove()');
627
        }
628
        return $this;
629
    }
630
631
    /**
632
     * Sets a has-one embed.
633
     *
634
     * @param   string      $key    The embed key (field) name.
635
     * @param   Embed|null  $embed  The embed to relate.
636
     * @return  self
637
     */
638 View Code Duplication
    protected function setEmbedHasOne($key, Embed $embed = 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...
639
    {
640
        if (null !== $embed) {
641
            $this->validateEmbedSet($key, $embed->getName());
642
        }
643
        $this->touch();
644
        $this->hasOneEmbeds->set($key, $embed);
645
        $this->doDirtyCheck();
646
        return $this;
647
    }
648
649
    /**
650
     * Touches the model.
651
     * Must be handled the the extending class.
652
     *
653
     * @param   bool    $force  Whether to force the load, even if the model is currently loaded.
654
     * @return  self
655
     */
656
    protected function touch($force = false)
657
    {
658
        return $this;
659
    }
660
661
    /**
662
     * Validates that the model type (from a Model or Collection instance) can be set to the relationship field.
663
     *
664
     * @param   string  $embedKey   The embed field key.
665
     * @param   string  $embedName  The embed name that is being set.
666
     * @return  self
667
     */
668
    protected function validateEmbedSet($embedKey, $embedName)
669
    {
670
        $embededPropMeta = $this->getMetadata()->getEmbed($embedKey);
671
        $this->getStore()->validateEmbedSet($embededPropMeta->embedMeta, $embedName);
672
        return $this;
673
    }
674
}
675