Completed
Pull Request — master (#79)
by Jacob
02:58
created

AbstractModel::removeEmbed()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 11
Code Lines 8

Duplication

Lines 11
Ratio 100 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
c 1
b 0
f 0
dl 11
loc 11
rs 9.4285
cc 2
eloc 8
nc 2
nop 2
1
<?php
2
3
namespace As3\Modlr\Models;
4
5
use As3\Modlr\Metadata\Interfaces\AttributeInterface;
6
use As3\Modlr\Persister\Record;
7
use As3\Modlr\Store\Store;
8
9
/**
10
 * Represents a record from a persistence (database) layer.
11
 * Can either be a root record, or an embedded fragment of a root record.
12
 *
13
 * @author Jacob Bare <[email protected]>
14
 */
15
abstract class AbstractModel
16
{
17
    /**
18
     * The model's attributes
19
     *
20
     * @var Attributes
21
     */
22
    protected $attributes;
23
24
    /**
25
     * The Model's has-one embeds
26
     *
27
     * @var Embeds\HasOne
28
     */
29
    protected $hasOneEmbeds;
30
31
    /**
32
     * The Model's has-many embeds
33
     *
34
     * @var Embeds\HasMany
35
     */
36
    protected $hasManyEmbeds;
37
38
    /**
39
     * The metadata that defines this Model.
40
     *
41
     * @var AttributeInterface
42
     */
43
    protected $metadata;
44
45
    /**
46
     * The model state.
47
     *
48
     * @var State
49
     */
50
    protected $state;
51
52
    /**
53
     * The Model Store for handling lifecycle operations.
54
     *
55
     * @var Store
56
     */
57
    protected $store;
58
59
    /**
60
     * Constructor.
61
     *
62
     * @param   AttributeInterface  $metadata
63
     * @param   Store               $store
64
     * @param   array|null          $properties
65
     */
66
    public function __construct(AttributeInterface $metadata, Store $store, array $properties = null)
67
    {
68
        $this->state = new State();
69
        $this->metadata = $metadata;
70
        $this->store = $store;
71
        $this->initialize($properties);
72
    }
73
74
    /**
75
     * Cloner.
76
     * Ensures sub objects are also cloned.
77
     *
78
     */
79
    public function __clone()
80
    {
81
        $this->attributes = clone $this->attributes;
82
        $this->hasOneEmbeds = clone $this->hasOneEmbeds;
83
        $this->hasManyEmbeds = clone $this->hasManyEmbeds;
84
        $this->state = clone $this->state;
85
    }
86
87
    /**
88
     * Applies an array of raw model properties to the model instance.
89
     *
90
     * @todo    Confirm that we want this method. It's currently used for creating and updating via the API adapter. Also see initialize()
91
     * @param   array   $properties     The properties to apply.
92
     * @return  self
93
     */
94
    public function apply(array $properties)
95
    {
96
        $properties = $this->applyDefaultAttrValues($properties);
97
        foreach ($properties as $key => $value) {
98
            if (true === $this->isAttribute($key)) {
99
                $this->set($key, $value);
100
                continue;
101
            }
102
103
            if (true === $this->isEmbedHasOne($key)) {
104
                if (empty($value)) {
105
                    $this->clear($key);
106
                    continue;
107
                }
108
                $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...
109
                $embed->apply($value);
110
                $this->set($key, $embed);
111
                continue;
112
            }
113
        }
114
115
        foreach ($this->getMetadata()->getEmbeds() as $key => $embeddedPropMeta) {
116
            if (true === $embeddedPropMeta->isOne() || !isset($properties[$key])) {
117
                continue;
118
            }
119
120
            // @todo This will always mark the model as dirty, even if the applied embed values are the same as the original.
121
            $this->clear($key);
122
            $collection = $this->getStore()->createEmbedCollection($embeddedPropMeta, $properties[$key]);
123
            foreach ($collection as $value) {
124
                $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...
125
            }
126
        }
127
128
        $this->doDirtyCheck();
129
        return $this;
130
    }
131
132
    /**
133
     * Clears a property value.
134
     * For an attribute, will set the value to null.
135
     * For collections, will clear the collection contents.
136
     *
137
     * @api
138
     * @param   string  $key    The property key.
139
     * @return  self
140
     */
141
    public function clear($key)
142
    {
143
        if (true === $this->isAttribute($key)) {
144
            return $this->setAttribute($key, null);
145
        }
146
        if (true === $this->isEmbedHasOne($key)) {
147
            return $this->setEmbedHasOne($key, null);
148
        }
149
        if (true === $this->isEmbedHasMany($key)) {
150
            $collection = $this->hasManyEmbeds->get($key);
151
            $collection->clear();
152
            $this->doDirtyCheck();
153
            return $this;
154
        }
155
        return $this;
156
    }
157
158
    /**
159
     * Creates a new Embed model instance for the provided property key.
160
     *
161
     * @param   string  $key
162
     * @return  Embed
163
     * @throws  \RuntimeException
164
     */
165
    public function createEmbedFor($key)
166
    {
167
        if (false === $this->isEmbed($key)) {
168
            throw new \RuntimeException(sprintf('Unable to create an Embed instance for property key "%s" - the property is not an embed.', $key));
169
        }
170
171
        $embedMeta = $this->getMetadata()->getEmbed($key)->embedMeta;
172
        $embed = $this->getStore()->loadEmbed($embedMeta, []);
173
        $embed->getState()->setNew();
174
        return $embed;
175
    }
176
177
    /**
178
     * Gets a model property.
179
     * Returns null if the property does not exist on the model or is not set.
180
     *
181
     * @api
182
     * @param   string  $key    The property field key.
183
     * @return  Model|Model[]|Embed|Collections\EmbedCollection|null|mixed
184
     */
185
    public function get($key)
186
    {
187
        if (true === $this->isAttribute($key)) {
188
            return $this->getAttribute($key);
189
        }
190
        if (true === $this->isEmbed($key)) {
191
            return $this->getEmbed($key);
192
        }
193
    }
194
195
    /**
196
     * Gets the current change set of properties.
197
     *
198
     * @api
199
     * @return  array
200
     */
201
    public function getChangeSet()
202
    {
203
        return [
204
            'attributes'    => $this->filterNotSavedProperties($this->attributes->calculateChangeSet()),
205
            'embedOne'      => $this->hasOneEmbeds->calculateChangeSet(),
206
            'embedMany'     => $this->hasManyEmbeds->calculateChangeSet(),
207
        ];
208
    }
209
210
    /**
211
     * Gets the metadata for this model.
212
     *
213
     * @api
214
     * @return  AttributeInterface
215
     */
216
    public function getMetadata()
217
    {
218
        return $this->metadata;
219
    }
220
221
    /**
222
     * Gets the model state object.
223
     *
224
     * @todo    Should this be public? State setting should likely be locked from the outside world.
225
     * @return  State
226
     */
227
    public function getState()
228
    {
229
        return $this->state;
230
    }
231
232
    /**
233
     * Gets the model store.
234
     *
235
     * @api
236
     * @return  Store
237
     */
238
    public function getStore()
239
    {
240
        return $this->store;
241
    }
242
243
    /**
244
     * Initializes the model and loads its attributes and relationships.
245
     *
246
     * @todo    Made public so collections can initialize models. Not sure if we want this??
247
     * @param   array|null      $properties     The db properties to apply.
248
     * @return  self
249
     */
250
    public function initialize(array $properties = null)
251
    {
252
        $attributes = [];
253
        $embedOne = [];
254
        $embedMany = [];
255
256
        if (null !== $properties) {
257
            $attributes = $this->applyDefaultAttrValues($attributes);
258
            foreach ($properties as $key => $value) {
259
                if (true === $this->isAttribute($key)) {
260
                    // Load attribute.
261
                    $attributes[$key] = $this->convertAttributeValue($key, $value);
262
                } else if (true === $this->isEmbedHasOne($key)) {
263
                    // Load embed one.
264
                    $embedOne[$key] = $this->getStore()->loadEmbed($this->getMetadata()->getEmbed($key)->embedMeta, $value);
265
                }
266
            }
267
        }
268
269
        foreach ($this->getMetadata()->getEmbeds() as $key => $embeddedPropMeta) {
270
            // Always load embedded collections, regardless if data is set.
271
            if (true === $embeddedPropMeta->isOne()) {
272
                continue;
273
            }
274
            $embeds = !isset($properties[$key]) ? [] : $properties[$key];
275
            $embedMany[$key] = $this->getStore()->createEmbedCollection($embeddedPropMeta, $embeds);
276
        }
277
278
        $this->attributes    = (null === $this->attributes) ? new Attributes($attributes) : $this->attributes->replace($attributes);
279
        $this->hasOneEmbeds  = (null === $this->hasOneEmbeds) ? new Embeds\HasOne($embedOne) : $this->hasOneEmbeds->replace($embedOne);
280
        $this->hasManyEmbeds = (null === $this->hasManyEmbeds) ? new Embeds\HasMany($embedMany) : $this->hasManyEmbeds->replace($embedMany);
281
        $this->doDirtyCheck();
282
        return $this;
283
    }
284
285
    /**
286
     * Determines if a property key is an attribute.
287
     *
288
     * @api
289
     * @param   string  $key    The property key.
290
     * @return  bool
291
     */
292
    public function isAttribute($key)
293
    {
294
        return $this->getMetadata()->hasAttribute($key);
295
    }
296
297
    /**
298
     * Determines if the model is currently dirty.
299
     *
300
     * @api
301
     * @return  bool
302
     */
303
    public function isDirty()
304
    {
305
        return true === $this->attributes->areDirty()
306
            || true === $this->hasOneEmbeds->areDirty()
307
            || true === $this->hasManyEmbeds->areDirty()
308
        ;
309
    }
310
311
    /**
312
     * Determines if a property key is an embedded property.
313
     *
314
     * @api
315
     * @param   string  $key    The property key.
316
     * @return  bool
317
     */
318
    public function isEmbed($key)
319
    {
320
        return $this->getMetadata()->hasEmbed($key);
321
    }
322
323
    /**
324
     * Determines if a property key is a has-many embed.
325
     *
326
     * @api
327
     * @param   string  $key    The property key.
328
     * @return  bool
329
     */
330
    public function isEmbedHasMany($key)
331
    {
332
        if (false === $this->isEmbed($key)) {
333
            return false;
334
        }
335
        return $this->getMetadata()->getEmbed($key)->isMany();
336
    }
337
338
    /**
339
     * Determines if a property key is a has-one embed.
340
     *
341
     * @api
342
     * @param   string  $key    The property key.
343
     * @return  bool
344
     */
345
    public function isEmbedHasOne($key)
346
    {
347
        if (false === $this->isEmbed($key)) {
348
            return false;
349
        }
350
        return $this->getMetadata()->getEmbed($key)->isOne();
351
    }
352
353
    /**
354
     * Pushes an Embed into a has-many embed collection.
355
     * This method must be used for has-many embeds. Direct set is not supported.
356
     * To completely replace call clear() first and then pushEmbed() the new Embeds.
357
     *
358
     * @api
359
     * @param   string  $key
360
     * @param   Embed   $model
0 ignored issues
show
Bug introduced by
There is no parameter named $model. Was it maybe removed?

This check looks for PHPDoc comments describing methods or function parameters that do not exist on the corresponding method or function.

Consider the following example. The parameter $italy is not defined by the method finale(...).

/**
 * @param array $germany
 * @param array $island
 * @param array $italy
 */
function finale($germany, $island) {
    return "2:1";
}

The most likely cause is that the parameter was removed, but the annotation was not.

Loading history...
361
     * @return  self
362
     */
363
    public function pushEmbed($key, Embed $embed)
364
    {
365
        if (true === $this->isEmbedHasOne($key)) {
366
            return $this->setEmbedHasOne($key, $model);
0 ignored issues
show
Bug introduced by
The variable $model does not exist. Did you forget to declare it?

This check marks access to variables or properties that have not been declared yet. While PHP has no explicit notion of declaring a variable, accessing it before a value is assigned to it is most likely a bug.

Loading history...
367
        }
368
        if (false === $this->isEmbedHasMany($key)) {
369
            return $this;
370
        }
371
        $this->touch();
372
        $collection = $this->hasManyEmbeds->get($key);
373
        $collection->push($embed);
374
        $this->doDirtyCheck();
375
        return $this;
376
    }
377
378
    /**
379
     * Removes a specific Embed from a has-many embed collection.
380
     *
381
     * @api
382
     * @param   string  $key    The has-many embed key.
383
     * @param   Model   $embed  The embed to remove from the collection.
384
     * @return  self
385
     */
386 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...
387
    {
388
        if (false === $this->isEmbedHasMany($key)) {
389
            return $this;
390
        }
391
        $this->touch();
392
        $collection = $this->hasManyEmbeds->get($key);
393
        $collection->remove($embed);
394
        $this->doDirtyCheck();
395
        return $this;
396
    }
397
398
    /**
399
     * Rolls back a model to its original values.
400
     *
401
     * @api
402
     * @return  self
403
     */
404
    public function rollback()
405
    {
406
        $this->attributes->rollback();
407
        $this->hasOneEmbeds->rollback();
408
        $this->hasManyEmbeds->rollback();
409
        $this->doDirtyCheck();
410
        return $this;
411
    }
412
413
    /**
414
     * Sets a model property.
415
     *
416
     * @api
417
     * @param   string  $key            The property field key.
418
     * @param   Model|Embed|null|mixed  The value to set.
419
     * @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...
420
     */
421
    public function set($key, $value)
422
    {
423
        if (true === $this->isAttribute($key)) {
424
            return $this->setAttribute($key, $value);
425
        }
426
        if (true === $this->isEmbed($key)) {
427
            return $this->setEmbed($key, $value);
428
        }
429
    }
430
431
    /**
432
     * Determines if the model uses a particlar mixin.
433
     *
434
     * @api
435
     * @param   string  $name
436
     * @return  bool
437
     */
438
    public function usesMixin($name)
439
    {
440
        return $this->metadata->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...
441
    }
442
443
    /**
444
     * Applies default attribute values from metadata, if set.
445
     *
446
     * @param   array   $attributes     The attributes to apply the defaults to.
447
     * @return  array
448
     */
449
    protected function applyDefaultAttrValues(array $attributes = [])
450
    {
451
        // Set defaults for each attribute.
452
        foreach ($this->getMetadata()->getAttributes() as $key => $attrMeta) {
453
            if (!isset($attrMeta->defaultValue) || isset($attributes[$key])) {
454
                continue;
455
            }
456
            $attributes[$key] = $this->convertAttributeValue($key, $attrMeta->defaultValue);
457
        }
458
        return $attributes;
459
    }
460
461
    /**
462
     * Converts an attribute value to the appropriate data type.
463
     *
464
     * @param   string  $key
465
     * @param   mixed   $value
466
     * @return  mixed
467
     */
468
    protected function convertAttributeValue($key, $value)
469
    {
470
        return $this->store->convertAttributeValue($this->getDataType($key), $value);
471
    }
472
473
    /**
474
     * Does a dirty check and sets the state to this model.
475
     *
476
     * @return  self
477
     */
478
    protected function doDirtyCheck()
479
    {
480
        $this->getState()->setDirty($this->isDirty());
481
        return $this;
482
    }
483
484
     /**
485
     * Removes properties marked as non-saved.
486
     *
487
     * @param   array   $properties
488
     * @return  array
489
     */
490
    protected function filterNotSavedProperties(array $properties)
491
    {
492 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...
493
            if (true === $propMeta->shouldSave() || !isset($properties[$fieldKey])) {
494
                continue;
495
            }
496
            unset($properties[$fieldKey]);
497
        }
498
        return $properties;
499
    }
500
501
    /**
502
     * Gets an attribute value.
503
     *
504
     * @param   string  $key    The attribute key (field) name.
505
     * @return  mixed
506
     */
507
    protected function getAttribute($key)
508
    {
509
        if (true === $this->isCalculatedAttribute($key)) {
510
            return $this->getCalculatedAttribute($key);
511
        }
512
        $this->touch();
513
        return $this->attributes->get($key);
514
    }
515
516
    /**
517
     * Gets a calculated attribute value.
518
     *
519
     * @param   string  $key    The attribute key (field) name.
520
     * @return  mixed
521
     */
522
    protected function getCalculatedAttribute($key)
523
    {
524
        $attrMeta = $this->getMetadata()->getAttribute($key);
525
        $class  = $attrMeta->calculated['class'];
526
        $method = $attrMeta->calculated['method'];
527
528
        $value = $class::$method($this);
529
        return $this->convertAttributeValue($key, $value);
530
    }
531
532
    /**
533
     * Gets a data type from an attribute key.
534
     *
535
     * @param   string  $key The attribute key.
536
     * @return  string
537
     */
538
    protected function getDataType($key)
539
    {
540
        return $this->getMetadata()->getAttribute($key)->dataType;
541
    }
542
543
    /**
544
     * Gets an embed value.
545
     *
546
     * @param   string  $key    The embed key (field) name.
547
     * @return  Embed|Collections\EmbedCollection|null
548
     */
549
    protected function getEmbed($key)
550
    {
551
        if (true === $this->isEmbedHasOne($key)) {
552
            $this->touch();
553
            return $this->hasOneEmbeds->get($key);
554
        }
555
        if (true === $this->isEmbedHasMany($key)) {
556
            $this->touch();
557
            return $this->hasManyEmbeds->get($key);
558
        }
559
        return null;
560
    }
561
562
    /**
563
     * Determines if an attribute key is calculated.
564
     *
565
     * @param   string  $key    The attribute key.
566
     * @return  bool
567
     */
568
    protected function isCalculatedAttribute($key)
569
    {
570
        if (false === $this->isAttribute($key)) {
571
            return false;
572
        }
573
        return $this->getMetadata()->getAttribute($key)->isCalculated();
574
    }
575
576
    /**
577
     * Sets an attribute value.
578
     * Will convert the value to the proper, internal PHP/Modlr data type.
579
     * Will do a dirty check immediately after setting.
580
     *
581
     * @param   string  $key    The attribute key (field) name.
582
     * @param   mixed   $value  The value to apply.
583
     * @return  self
584
     */
585
    protected function setAttribute($key, $value)
586
    {
587
        if (true === $this->isCalculatedAttribute($key)) {
588
            return $this;
589
        }
590
        $this->touch();
591
        $value = $this->convertAttributeValue($key, $value);
592
        $this->attributes->set($key, $value);
593
        $this->doDirtyCheck();
594
        return $this;
595
    }
596
597
    /**
598
     * Sets an embed value.
599
     *
600
     * @param   string      $key
601
     * @param   Embed|null  $value
602
     * @return  self
603
     */
604
    protected function setEmbed($key, $value)
605
    {
606
        if (true === $this->isEmbedHasOne($key)) {
607
            return $this->setEmbedHasOne($key, $value);
608
        }
609
        if (true === $this->isEmbedHasMany($key)) {
610
            throw new \RuntimeException('You cannot set a hasMany embed directly. Please access using pushEmbed(), clear(), and/or remove()');
611
        }
612
        return $this;
613
    }
614
615
    /**
616
     * Sets a has-one embed.
617
     *
618
     * @param   string      $key    The embed key (field) name.
619
     * @param   Embed|null  $embed  The embed to relate.
620
     * @return  self
621
     */
622 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...
623
    {
624
        if (null !== $embed) {
625
            $this->validateEmbedSet($key, $embed->getName());
626
        }
627
        $this->touch();
628
        $this->hasOneEmbeds->set($key, $embed);
629
        $this->doDirtyCheck();
630
        return $this;
631
    }
632
633
    /**
634
     * Touches the model.
635
     * Must be handled the the extending class.
636
     *
637
     * @param   bool    $force  Whether to force the load, even if the model is currently loaded.
638
     * @return  self
639
     */
640
    protected function touch($force = false)
641
    {
642
        return $this;
643
    }
644
645
    /**
646
     * Validates that the model type (from a Model or Collection instance) can be set to the relationship field.
647
     *
648
     * @param   string  $embedKey   The embed field key.
649
     * @param   string  $embedName  The embed name that is being set.
650
     * @return  self
651
     */
652
    protected function validateEmbedSet($embedKey, $embedName)
653
    {
654
        $embededPropMeta = $this->getMetadata()->getEmbed($embedKey);
655
        $this->getStore()->validateEmbedSet($embededPropMeta->embedMeta, $embedName);
656
        return $this;
657
    }
658
}
659