AbstractModel::getCompositeKey()
last analyzed

Size

Total Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 1
c 0
b 0
f 0
nc 1
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
            $collection = $this->getStore()->createEmbedCollection($embeddedPropMeta, $properties[$key]);
123
            if ($collection->getHash() === $this->get($key)->getHash()) {
124
                // The current collection is the same as the incoming collection.
125
                continue;
126
            }
127
128
            // The incoming collection is different. Clear the current collection and push the new values.
129
            $this->clear($key);
130
            foreach ($collection as $value) {
131
                $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...
132
            }
133
        }
134
135
        $this->doDirtyCheck();
136
        return $this;
137
    }
138
139
    /**
140
     * Clears a property value.
141
     * For an attribute, will set the value to null.
142
     * For collections, will clear the collection contents.
143
     *
144
     * @api
145
     * @param   string  $key    The property key.
146
     * @return  self
147
     */
148
    public function clear($key)
149
    {
150
        if (true === $this->isAttribute($key)) {
151
            return $this->setAttribute($key, null);
152
        }
153
        if (true === $this->isEmbedHasOne($key)) {
154
            return $this->setEmbedHasOne($key, null);
155
        }
156
        if (true === $this->isEmbedHasMany($key)) {
157
            $collection = $this->hasManyEmbeds->get($key);
158
            $collection->clear();
159
            $this->doDirtyCheck();
160
            return $this;
161
        }
162
        return $this;
163
    }
164
165
    /**
166
     * Creates a new Embed model instance for the provided property key.
167
     *
168
     * @param   string  $key
169
     * @return  Embed
170
     * @throws  \RuntimeException
171
     */
172
    public function createEmbedFor($key)
173
    {
174
        if (false === $this->isEmbed($key)) {
175
            throw new \RuntimeException(sprintf('Unable to create an Embed instance for property key "%s" - the property is not an embed.', $key));
176
        }
177
178
        $embedMeta = $this->getMetadata()->getEmbed($key)->embedMeta;
179
        $embed = $this->getStore()->loadEmbed($embedMeta, []);
180
        $embed->getState()->setNew();
181
        return $embed;
182
    }
183
184
    /**
185
     * Gets a model property.
186
     * Returns null if the property does not exist on the model or is not set.
187
     *
188
     * @api
189
     * @param   string  $key    The property field key.
190
     * @return  Model|Model[]|Embed|Collections\EmbedCollection|null|mixed
191
     */
192
    public function get($key)
193
    {
194
        if (true === $this->isAttribute($key)) {
195
            return $this->getAttribute($key);
196
        }
197
        if (true === $this->isEmbed($key)) {
198
            return $this->getEmbed($key);
199
        }
200
    }
201
202
    /**
203
     * Gets the current change set of properties.
204
     *
205
     * @api
206
     * @return  array
207
     */
208
    public function getChangeSet()
209
    {
210
        return [
211
            'attributes'    => $this->filterNotSavedProperties($this->attributes->calculateChangeSet()),
212
            'embedOne'      => $this->hasOneEmbeds->calculateChangeSet(),
213
            'embedMany'     => $this->hasManyEmbeds->calculateChangeSet(),
214
        ];
215
    }
216
217
    /**
218
     * Gets the composite key of the model.
219
     *
220
     * @api
221
     * @return  string
222
     */
223
    abstract public function getCompositeKey();
224
225
    /**
226
     * Gets the metadata for this model.
227
     *
228
     * @api
229
     * @return  AttributeInterface
230
     */
231
    public function getMetadata()
232
    {
233
        return $this->metadata;
234
    }
235
236
    /**
237
     * Gets the model state object.
238
     *
239
     * @todo    Should this be public? State setting should likely be locked from the outside world.
240
     * @return  State
241
     */
242
    public function getState()
243
    {
244
        return $this->state;
245
    }
246
247
    /**
248
     * Gets the model store.
249
     *
250
     * @api
251
     * @return  Store
252
     */
253
    public function getStore()
254
    {
255
        return $this->store;
256
    }
257
258
    /**
259
     * Initializes the model and loads its attributes and relationships.
260
     *
261
     * @todo    Made public so collections can initialize models. Not sure if we want this??
262
     * @param   array|null      $properties     The db properties to apply.
263
     * @return  self
264
     */
265
    public function initialize(array $properties = null)
266
    {
267
        $attributes = [];
268
        $embedOne = [];
269
        $embedMany = [];
270
271
        if (null !== $properties) {
272
            $attributes = $this->applyDefaultAttrValues($attributes);
273
            foreach ($properties as $key => $value) {
274
                if (true === $this->isAttribute($key)) {
275
                    // Load attribute.
276
                    $attributes[$key] = $this->convertAttributeValue($key, $value);
277
                } else if (true === $this->isEmbedHasOne($key) && is_array($value)) {
278
                    // Load embed one.
279
                    $embedOne[$key] = $this->getStore()->loadEmbed($this->getMetadata()->getEmbed($key)->embedMeta, $value);
280
                }
281
            }
282
        }
283
284
        foreach ($this->getMetadata()->getEmbeds() as $key => $embeddedPropMeta) {
285
            // Always load embedded collections, regardless if data is set.
286
            if (true === $embeddedPropMeta->isOne()) {
287
                continue;
288
            }
289
            $embeds = !isset($properties[$key]) ? [] : $properties[$key];
290
            $embedMany[$key] = $this->getStore()->createEmbedCollection($embeddedPropMeta, $embeds);
291
        }
292
293
        $this->attributes    = (null === $this->attributes) ? new Attributes($attributes) : $this->attributes->replace($attributes);
294
        $this->hasOneEmbeds  = (null === $this->hasOneEmbeds) ? new Embeds\HasOne($embedOne) : $this->hasOneEmbeds->replace($embedOne);
295
        $this->hasManyEmbeds = (null === $this->hasManyEmbeds) ? new Embeds\HasMany($embedMany) : $this->hasManyEmbeds->replace($embedMany);
296
297
        if (true === $this->getState()->is('new')) {
298
            // Ensure default values are applied to new models.
299
            $this->apply([]);
300
        }
301
302
        $this->doDirtyCheck();
303
        return $this;
304
    }
305
306
    /**
307
     * Determines if a property key is an attribute.
308
     *
309
     * @api
310
     * @param   string  $key    The property key.
311
     * @return  bool
312
     */
313
    public function isAttribute($key)
314
    {
315
        return $this->getMetadata()->hasAttribute($key);
316
    }
317
318
    /**
319
     * Determines if the model is currently dirty.
320
     *
321
     * @api
322
     * @return  bool
323
     */
324
    public function isDirty()
325
    {
326
        return true === $this->attributes->areDirty()
327
            || true === $this->hasOneEmbeds->areDirty()
328
            || true === $this->hasManyEmbeds->areDirty()
329
        ;
330
    }
331
332
    /**
333
     * Determines if a property key is an embedded property.
334
     *
335
     * @api
336
     * @param   string  $key    The property key.
337
     * @return  bool
338
     */
339
    public function isEmbed($key)
340
    {
341
        return $this->getMetadata()->hasEmbed($key);
342
    }
343
344
    /**
345
     * Determines if a property key is a has-many embed.
346
     *
347
     * @api
348
     * @param   string  $key    The property key.
349
     * @return  bool
350
     */
351
    public function isEmbedHasMany($key)
352
    {
353
        if (false === $this->isEmbed($key)) {
354
            return false;
355
        }
356
        return $this->getMetadata()->getEmbed($key)->isMany();
357
    }
358
359
    /**
360
     * Determines if a property key is a has-one embed.
361
     *
362
     * @api
363
     * @param   string  $key    The property key.
364
     * @return  bool
365
     */
366
    public function isEmbedHasOne($key)
367
    {
368
        if (false === $this->isEmbed($key)) {
369
            return false;
370
        }
371
        return $this->getMetadata()->getEmbed($key)->isOne();
372
    }
373
374
    /**
375
     * Pushes an Embed into a has-many embed collection.
376
     * This method must be used for has-many embeds. Direct set is not supported.
377
     * To completely replace call clear() first and then pushEmbed() the new Embeds.
378
     *
379
     * @api
380
     * @param   string  $key
381
     * @param   Embed   $embed
382
     * @return  self
383
     */
384
    public function pushEmbed($key, Embed $embed)
385
    {
386
        if (true === $this->isEmbedHasOne($key)) {
387
            return $this->setEmbedHasOne($key, $embed);
388
        }
389
        if (false === $this->isEmbedHasMany($key)) {
390
            return $this;
391
        }
392
        $this->touch();
393
        $collection = $this->hasManyEmbeds->get($key);
394
        $collection->push($embed);
395
        $this->doDirtyCheck();
396
        return $this;
397
    }
398
399
    /**
400
     * Removes a specific Embed from a has-many embed collection.
401
     *
402
     * @api
403
     * @param   string  $key    The has-many embed key.
404
     * @param   Embed   $embed  The embed to remove from the collection.
405
     * @return  self
406
     */
407 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...
408
    {
409
        if (false === $this->isEmbedHasMany($key)) {
410
            return $this;
411
        }
412
        $this->touch();
413
        $collection = $this->hasManyEmbeds->get($key);
414
        $collection->remove($embed);
415
        $this->doDirtyCheck();
416
        return $this;
417
    }
418
419
    /**
420
     * Rolls back a model to its original values.
421
     *
422
     * @api
423
     * @return  self
424
     */
425
    public function rollback()
426
    {
427
        $this->attributes->rollback();
428
        $this->hasOneEmbeds->rollback();
429
        $this->hasManyEmbeds->rollback();
430
        $this->doDirtyCheck();
431
        return $this;
432
    }
433
434
    /**
435
     * Sets a model property.
436
     *
437
     * @api
438
     * @param   string  $key            The property field key.
439
     * @param   Model|Embed|null|mixed  The value to set.
440
     * @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...
441
     */
442
    public function set($key, $value)
443
    {
444
        if (true === $this->isAttribute($key)) {
445
            return $this->setAttribute($key, $value);
446
        }
447
        if (true === $this->isEmbed($key)) {
448
            return $this->setEmbed($key, $value);
449
        }
450
    }
451
452
    /**
453
     * Determines if the model uses a particlar mixin.
454
     *
455
     * @api
456
     * @param   string  $name
457
     * @return  bool
458
     */
459
    public function usesMixin($name)
460
    {
461
        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...
462
    }
463
464
    /**
465
     * Applies default attribute values from metadata, if set.
466
     *
467
     * @param   array   $attributes     The attributes to apply the defaults to.
468
     * @return  array
469
     */
470
    protected function applyDefaultAttrValues(array $attributes = [])
471
    {
472
        // Set defaults for each attribute.
473
        foreach ($this->getMetadata()->getAttributes() as $key => $attrMeta) {
474
            if (!isset($attrMeta->defaultValue) || isset($attributes[$key])) {
475
                continue;
476
            }
477
            $attributes[$key] = $this->convertAttributeValue($key, $attrMeta->defaultValue);
478
        }
479
        return $attributes;
480
    }
481
482
    /**
483
     * Converts an attribute value to the appropriate data type.
484
     *
485
     * @param   string  $key
486
     * @param   mixed   $value
487
     * @return  mixed
488
     */
489
    protected function convertAttributeValue($key, $value)
490
    {
491
        return $this->store->convertAttributeValue($this->getDataType($key), $value);
492
    }
493
494
    /**
495
     * Does a dirty check and sets the state to this model.
496
     *
497
     * @return  self
498
     */
499
    protected function doDirtyCheck()
500
    {
501
        $this->getState()->setDirty($this->isDirty());
502
        return $this;
503
    }
504
505
    /**
506
     * Removes properties marked as non-saved.
507
     *
508
     * @param   array   $properties
509
     * @return  array
510
     */
511
    protected function filterNotSavedProperties(array $properties)
512
    {
513 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...
514
            if (true === $propMeta->shouldSave() || !isset($properties[$fieldKey])) {
515
                continue;
516
            }
517
            unset($properties[$fieldKey]);
518
        }
519
        return $properties;
520
    }
521
522
    /**
523
     * Gets an attribute value.
524
     *
525
     * @param   string  $key    The attribute key (field) name.
526
     * @return  mixed
527
     */
528
    protected function getAttribute($key)
529
    {
530
        if (true === $this->isCalculatedAttribute($key)) {
531
            return $this->getCalculatedAttribute($key);
532
        }
533
        $this->touch();
534
        return $this->attributes->get($key);
535
    }
536
537
    /**
538
     * Gets a calculated attribute value.
539
     *
540
     * @param   string  $key    The attribute key (field) name.
541
     * @return  mixed
542
     */
543
    protected function getCalculatedAttribute($key)
544
    {
545
        $attrMeta = $this->getMetadata()->getAttribute($key);
546
        $class  = $attrMeta->calculated['class'];
547
        $method = $attrMeta->calculated['method'];
548
549
        $value = $class::$method($this);
550
        return $this->convertAttributeValue($key, $value);
551
    }
552
553
    /**
554
     * Gets a data type from an attribute key.
555
     *
556
     * @param   string  $key The attribute key.
557
     * @return  string
558
     */
559
    protected function getDataType($key)
560
    {
561
        return $this->getMetadata()->getAttribute($key)->dataType;
562
    }
563
564
    /**
565
     * Gets an embed value.
566
     *
567
     * @param   string  $key    The embed key (field) name.
568
     * @return  Embed|Collections\EmbedCollection|null
569
     */
570
    protected function getEmbed($key)
571
    {
572
        if (true === $this->isEmbedHasOne($key)) {
573
            $this->touch();
574
            return $this->hasOneEmbeds->get($key);
575
        }
576
        if (true === $this->isEmbedHasMany($key)) {
577
            $this->touch();
578
            return $this->hasManyEmbeds->get($key);
579
        }
580
        return null;
581
    }
582
583
    /**
584
     * Determines if an attribute key is calculated.
585
     *
586
     * @param   string  $key    The attribute key.
587
     * @return  bool
588
     */
589
    protected function isCalculatedAttribute($key)
590
    {
591
        if (false === $this->isAttribute($key)) {
592
            return false;
593
        }
594
        return $this->getMetadata()->getAttribute($key)->isCalculated();
595
    }
596
597
    /**
598
     * Sets an attribute value.
599
     * Will convert the value to the proper, internal PHP/Modlr data type.
600
     * Will do a dirty check immediately after setting.
601
     *
602
     * @param   string  $key    The attribute key (field) name.
603
     * @param   mixed   $value  The value to apply.
604
     * @return  self
605
     */
606
    protected function setAttribute($key, $value)
607
    {
608
        if (true === $this->isCalculatedAttribute($key)) {
609
            return $this;
610
        }
611
        $this->touch();
612
        $value = $this->convertAttributeValue($key, $value);
613
        $this->attributes->set($key, $value);
614
        $this->doDirtyCheck();
615
        return $this;
616
    }
617
618
    /**
619
     * Sets an embed value.
620
     *
621
     * @param   string      $key
622
     * @param   Embed|null  $value
623
     * @return  self
624
     */
625
    protected function setEmbed($key, $value)
626
    {
627
        if (true === $this->isEmbedHasOne($key)) {
628
            return $this->setEmbedHasOne($key, $value);
629
        }
630
        if (true === $this->isEmbedHasMany($key)) {
631
            throw new \RuntimeException('You cannot set a hasMany embed directly. Please access using pushEmbed(), clear(), and/or remove()');
632
        }
633
        return $this;
634
    }
635
636
    /**
637
     * Sets a has-one embed.
638
     *
639
     * @param   string      $key    The embed key (field) name.
640
     * @param   Embed|null  $embed  The embed to relate.
641
     * @return  self
642
     */
643 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...
644
    {
645
        if (null !== $embed) {
646
            $this->validateEmbedSet($key, $embed->getName());
647
        }
648
        $this->touch();
649
        $this->hasOneEmbeds->set($key, $embed);
650
        $this->doDirtyCheck();
651
        return $this;
652
    }
653
654
    /**
655
     * Touches the model.
656
     * Must be handled the the extending class.
657
     *
658
     * @param   bool    $force  Whether to force the load, even if the model is currently loaded.
659
     * @return  self
660
     */
661
    protected function touch($force = false)
662
    {
663
        return $this;
664
    }
665
666
    /**
667
     * Validates that the model type (from a Model or Collection instance) can be set to the relationship field.
668
     *
669
     * @param   string  $embedKey   The embed field key.
670
     * @param   string  $embedName  The embed name that is being set.
671
     * @return  self
672
     */
673
    protected function validateEmbedSet($embedKey, $embedName)
674
    {
675
        $embededPropMeta = $this->getMetadata()->getEmbed($embedKey);
676
        $this->getStore()->validateEmbedSet($embededPropMeta->embedMeta, $embedName);
677
        return $this;
678
    }
679
}
680