Completed
Push — master ( 450182...fa1991 )
by Jared
03:02 queued 01:33
created

Model::setRelationCollection()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 6

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 6
rs 10
c 0
b 0
f 0
cc 1
nc 1
nop 2
1
<?php
2
3
/**
4
 * @author Jared King <[email protected]>
5
 *
6
 * @see http://jaredtking.com
7
 *
8
 * @copyright 2015 Jared King
9
 * @license MIT
10
 */
11
12
namespace Pulsar;
13
14
use ArrayAccess;
15
use BadMethodCallException;
16
use ICanBoogie\Inflector;
17
use InvalidArgumentException;
18
use Pulsar\Driver\DriverInterface;
19
use Pulsar\Exception\DriverMissingException;
20
use Pulsar\Exception\MassAssignmentException;
21
use Pulsar\Exception\ModelException;
22
use Pulsar\Exception\ModelNotFoundException;
23
use Pulsar\Relation\BelongsTo;
24
use Pulsar\Relation\BelongsToMany;
25
use Pulsar\Relation\HasMany;
26
use Pulsar\Relation\HasOne;
27
use Pulsar\Relation\Relation;
28
use Symfony\Component\EventDispatcher\EventDispatcher;
29
30
/**
31
 * Class Model.
32
 *
33
 * @method Query             where($where, $value = null, $condition = null)
34
 * @method Query             limit($limit)
35
 * @method Query             start($start)
36
 * @method Query             sort($sort)
37
 * @method Query             join($model, $column, $foreignKey)
38
 * @method Query             with($k)
39
 * @method Iterator          all()
40
 * @method array|static|null first($limit = 1)
41
 * @method int               count()
42
 * @method number            sum($property)
43
 * @method number            average($property)
44
 * @method number            max($property)
45
 * @method number            min($property)
46
 */
47
abstract class Model implements ArrayAccess
48
{
49
    const IMMUTABLE = 0;
50
    const MUTABLE_CREATE_ONLY = 1;
51
    const MUTABLE = 2;
52
53
    const TYPE_STRING = 'string';
54
    const TYPE_INTEGER = 'integer';
55
    const TYPE_FLOAT = 'float';
56
    const TYPE_BOOLEAN = 'boolean';
57
    const TYPE_DATE = 'date';
58
    const TYPE_OBJECT = 'object';
59
    const TYPE_ARRAY = 'array';
60
61
    const RELATIONSHIP_HAS_ONE = 'has_one';
62
    const RELATIONSHIP_HAS_MANY = 'has_many';
63
    const RELATIONSHIP_BELONGS_TO = 'belongs_to';
64
    const RELATIONSHIP_BELONGS_TO_MANY = 'belongs_to_many';
65
66
    const DEFAULT_ID_PROPERTY = 'id';
67
68
    /////////////////////////////
69
    // Model visible variables
70
    /////////////////////////////
71
72
    /**
73
     * List of model ID property names.
74
     *
75
     * @var array
76
     */
77
    protected static $ids = [self::DEFAULT_ID_PROPERTY];
78
79
    /**
80
     * Property definitions expressed as a key-value map with
81
     * property names as the keys.
82
     * i.e. ['enabled' => ['type' => Model::TYPE_BOOLEAN]].
83
     *
84
     * @var array
85
     */
86
    protected static $properties = [];
87
88
    /**
89
     * @var array
90
     */
91
    private static $dispatchers;
92
93
    /**
94
     * @var bool
95
     */
96
    private $hasId;
97
98
    /**
99
     * @var array
100
     */
101
    private $idValues;
102
103
    /**
104
     * @var array
105
     */
106
    protected $_values = [];
107
108
    /**
109
     * @var array
110
     */
111
    protected $_unsaved = [];
112
113
    /**
114
     * @var bool
115
     */
116
    protected $_persisted = false;
117
118
    /**
119
     * @var array
120
     */
121
    protected $_relationships = [];
122
123
    /**
124
     * @var bool
125
     */
126
    private $loaded = false;
127
128
    /**
129
     * @var Errors
130
     */
131
    private $errors;
132
133
    /**
134
     * @var bool
135
     */
136
    private $ignoreUnsaved;
137
138
    /////////////////////////////
139
    // Base model variables
140
    /////////////////////////////
141
142
    /**
143
     * @var array
144
     */
145
    private static $propertyDefinitionBase = [
146
        'type' => null,
147
        'mutable' => self::MUTABLE,
148
        'null' => false,
149
        'unique' => false,
150
        'required' => false,
151
    ];
152
153
    /**
154
     * @var array
155
     */
156
    private static $defaultIDProperty = [
157
        'type' => self::TYPE_INTEGER,
158
        'mutable' => self::IMMUTABLE,
159
    ];
160
161
    /**
162
     * @var array
163
     */
164
    private static $timestampProperties = [
165
        'created_at' => [
166
            'type' => self::TYPE_DATE,
167
            'validate' => 'timestamp|db_timestamp',
168
        ],
169
        'updated_at' => [
170
            'type' => self::TYPE_DATE,
171
            'validate' => 'timestamp|db_timestamp',
172
        ],
173
    ];
174
175
    /**
176
     * @var array
177
     */
178
    private static $softDeleteProperties = [
179
        'deleted_at' => [
180
            'type' => self::TYPE_DATE,
181
            'validate' => 'timestamp|db_timestamp',
182
            'null' => true,
183
        ],
184
    ];
185
186
    /**
187
     * @var array
188
     */
189
    private static $initialized = [];
190
191
    /**
192
     * @var DriverInterface
193
     */
194
    private static $driver;
195
196
    /**
197
     * @var array
198
     */
199
    private static $accessors = [];
200
201
    /**
202
     * @var array
203
     */
204
    private static $mutators = [];
205
206
    /**
207
     * Creates a new model object.
208
     *
209
     * @param array|string|Model|false $id     ordered array of ids or comma-separated id string
210
     * @param array                    $values optional key-value map to pre-seed model
211
     */
212
    public function __construct($id = false, array $values = [])
213
    {
214
        // initialize the model
215
        $this->init();
216
217
        // parse the supplied model ID
218
        $this->parseId($id);
219
220
        // load any given values
221
        if (count($values) > 0) {
222
            $this->refreshWith($values);
223
        }
224
    }
225
226
    /**
227
     * Performs initialization on this model.
228
     */
229
    private function init()
230
    {
231
        // ensure the initialize function is called only once
232
        $k = get_called_class();
233
        if (!isset(self::$initialized[$k])) {
234
            $this->initialize();
235
            self::$initialized[$k] = true;
236
        }
237
    }
238
239
    /**
240
     * The initialize() method is called once per model. It's used
241
     * to perform any one-off tasks before the model gets
242
     * constructed. This is a great place to add any model
243
     * properties. When extending this method be sure to call
244
     * parent::initialize() as some important stuff happens here.
245
     * If extending this method to add properties then you should
246
     * call parent::initialize() after adding any properties.
247
     */
248
    protected function initialize()
249
    {
250
        // load the driver
251
        static::getDriver();
252
253
        // add in the default ID property
254
        if (static::$ids == [self::DEFAULT_ID_PROPERTY] && !isset(static::$properties[self::DEFAULT_ID_PROPERTY])) {
255
            static::$properties[self::DEFAULT_ID_PROPERTY] = self::$defaultIDProperty;
256
        }
257
258
        // generates created_at and updated_at timestamps
259
        if (property_exists($this, 'autoTimestamps')) {
260
            $this->installAutoTimestamps();
261
        }
262
263
        // generates deleted_at timestamps
264
        if (property_exists($this, 'softDelete')) {
265
            $this->installSoftDelete();
266
        }
267
268
        // fill in each property by extending the property
269
        // definition base
270
        foreach (static::$properties as $k => &$property) {
271
            $property = array_replace(self::$propertyDefinitionBase, $property);
272
273
            // populate relationship property settings
274
            if (isset($property['relation'])) {
275
                // this is added for BC with older versions of pulsar
276
                // that only supported belongs to relationships
277
                if (!isset($property['relation_type'])) {
278
                    $property['relation_type'] = self::RELATIONSHIP_BELONGS_TO;
279
                    $property['local_key'] = $k;
280
                }
281
282
                $relation = $this->getRelationshipManager($k);
283
                if (!isset($property['foreign_key'])) {
284
                    $property['foreign_key'] = $relation->getForeignKey();
285
                }
286
287
                if (!isset($property['local_key'])) {
288
                    $property['local_key'] = $relation->getLocalKey();
289
                }
290
291
                if (!isset($property['pivot_tablename']) && $relation instanceof BelongsToMany) {
292
                    $property['pivot_tablename'] = $relation->getTablename();
293
                }
294
            }
295
        }
296
297
        // order the properties array by name for consistency
298
        // since it is constructed in a random order
299
        ksort(static::$properties);
300
    }
301
302
    /**
303
     * Installs the `created_at` and `updated_at` properties.
304
     */
305
    private function installAutoTimestamps()
306
    {
307
        static::$properties = array_replace(self::$timestampProperties, static::$properties);
308
309
        self::creating(function (ModelEvent $event) {
310
            $model = $event->getModel();
311
            $model->created_at = time();
0 ignored issues
show
Documentation introduced by
The property created_at does not exist on object<Pulsar\Model>. Since you implemented __set, maybe consider adding a @property annotation.

Since your code implements the magic setter _set, this function will be called for any write access on an undefined variable. You can add the @property annotation to your class or interface to document the existence of this variable.

<?php

/**
 * @property int $x
 * @property int $y
 * @property string $text
 */
class MyLabel
{
    private $properties;

    private $allowedProperties = array('x', 'y', 'text');

    public function __get($name)
    {
        if (isset($properties[$name]) && in_array($name, $this->allowedProperties)) {
            return $properties[$name];
        } else {
            return null;
        }
    }

    public function __set($name, $value)
    {
        if (in_array($name, $this->allowedProperties)) {
            $properties[$name] = $value;
        } else {
            throw new \LogicException("Property $name is not defined.");
        }
    }

}

Since the property has write access only, you can use the @property-write annotation instead.

Of course, you may also just have mistyped another name, in which case you should fix the error.

See also the PhpDoc documentation for @property.

Loading history...
312
            $model->updated_at = time();
0 ignored issues
show
Documentation introduced by
The property updated_at does not exist on object<Pulsar\Model>. Since you implemented __set, maybe consider adding a @property annotation.

Since your code implements the magic setter _set, this function will be called for any write access on an undefined variable. You can add the @property annotation to your class or interface to document the existence of this variable.

<?php

/**
 * @property int $x
 * @property int $y
 * @property string $text
 */
class MyLabel
{
    private $properties;

    private $allowedProperties = array('x', 'y', 'text');

    public function __get($name)
    {
        if (isset($properties[$name]) && in_array($name, $this->allowedProperties)) {
            return $properties[$name];
        } else {
            return null;
        }
    }

    public function __set($name, $value)
    {
        if (in_array($name, $this->allowedProperties)) {
            $properties[$name] = $value;
        } else {
            throw new \LogicException("Property $name is not defined.");
        }
    }

}

Since the property has write access only, you can use the @property-write annotation instead.

Of course, you may also just have mistyped another name, in which case you should fix the error.

See also the PhpDoc documentation for @property.

Loading history...
313
        });
314
315
        self::updating(function (ModelEvent $event) {
316
            $event->getModel()->updated_at = time();
0 ignored issues
show
Documentation introduced by
The property updated_at does not exist on object<Pulsar\Model>. Since you implemented __set, maybe consider adding a @property annotation.

Since your code implements the magic setter _set, this function will be called for any write access on an undefined variable. You can add the @property annotation to your class or interface to document the existence of this variable.

<?php

/**
 * @property int $x
 * @property int $y
 * @property string $text
 */
class MyLabel
{
    private $properties;

    private $allowedProperties = array('x', 'y', 'text');

    public function __get($name)
    {
        if (isset($properties[$name]) && in_array($name, $this->allowedProperties)) {
            return $properties[$name];
        } else {
            return null;
        }
    }

    public function __set($name, $value)
    {
        if (in_array($name, $this->allowedProperties)) {
            $properties[$name] = $value;
        } else {
            throw new \LogicException("Property $name is not defined.");
        }
    }

}

Since the property has write access only, you can use the @property-write annotation instead.

Of course, you may also just have mistyped another name, in which case you should fix the error.

See also the PhpDoc documentation for @property.

Loading history...
317
        });
318
    }
319
320
    /**
321
     * Installs the `deleted_at` properties.
322
     */
323
    private function installSoftDelete()
324
    {
325
        static::$properties = array_replace(self::$softDeleteProperties, static::$properties);
326
    }
327
328
    /**
329
     * Parses the given ID, which can be a single or composite primary key.
330
     *
331
     * @param mixed $id
332
     */
333
    private function parseId($id)
334
    {
335
        if (is_array($id)) {
336
            // A model can be supplied as a primary key
337
            foreach ($id as &$el) {
338
                if ($el instanceof self) {
339
                    $el = $el->id();
340
                }
341
            }
342
343
            // The IDs come in as the same order as ::$ids.
344
            // We need to match up the elements on that
345
            // input into a key-value map for each ID property.
346
            $ids = [];
347
            $idQueue = array_reverse($id);
348
            $this->hasId = true;
349
            foreach (static::$ids as $k => $f) {
350
                // type cast
351
                if (count($idQueue) > 0) {
352
                    $idProperty = static::getProperty($f);
353
                    $ids[$f] = static::cast($idProperty, array_pop($idQueue));
0 ignored issues
show
Bug introduced by
It seems like $idProperty defined by static::getProperty($f) on line 352 can be null; however, Pulsar\Model::cast() does not accept null, maybe add an additional type check?

Unless you are absolutely sure that the expression can never be null because of other conditions, we strongly recommend to add an additional type check to your code:

/** @return stdClass|null */
function mayReturnNull() { }

function doesNotAcceptNull(stdClass $x) { }

// With potential error.
function withoutCheck() {
    $x = mayReturnNull();
    doesNotAcceptNull($x); // Potential error here.
}

// Safe - Alternative 1
function withCheck1() {
    $x = mayReturnNull();
    if ( ! $x instanceof stdClass) {
        throw new \LogicException('$x must be defined.');
    }
    doesNotAcceptNull($x);
}

// Safe - Alternative 2
function withCheck2() {
    $x = mayReturnNull();
    if ($x instanceof stdClass) {
        doesNotAcceptNull($x);
    }
}
Loading history...
354
                } else {
355
                    $ids[$f] = false;
356
                    $this->hasId = false;
357
                }
358
            }
359
360
            $this->idValues = $ids;
361
        } elseif ($id instanceof self) {
362
            // A model can be supplied as a primary key
363
            $this->hasId = $id->hasId;
364
            $this->idValues = $id->ids();
365
        } else {
366
            // type cast the single primary key
367
            $idName = static::$ids[0];
368
            $this->hasId = false;
369
            if (false !== $id) {
370
                $idProperty = static::getProperty($idName);
371
                $id = static::cast($idProperty, $id);
0 ignored issues
show
Bug introduced by
It seems like $idProperty defined by static::getProperty($idName) on line 370 can be null; however, Pulsar\Model::cast() does not accept null, maybe add an additional type check?

Unless you are absolutely sure that the expression can never be null because of other conditions, we strongly recommend to add an additional type check to your code:

/** @return stdClass|null */
function mayReturnNull() { }

function doesNotAcceptNull(stdClass $x) { }

// With potential error.
function withoutCheck() {
    $x = mayReturnNull();
    doesNotAcceptNull($x); // Potential error here.
}

// Safe - Alternative 1
function withCheck1() {
    $x = mayReturnNull();
    if ( ! $x instanceof stdClass) {
        throw new \LogicException('$x must be defined.');
    }
    doesNotAcceptNull($x);
}

// Safe - Alternative 2
function withCheck2() {
    $x = mayReturnNull();
    if ($x instanceof stdClass) {
        doesNotAcceptNull($x);
    }
}
Loading history...
372
                $this->hasId = true;
373
            }
374
375
            $this->idValues = [$idName => $id];
376
        }
377
    }
378
379
    /**
380
     * Sets the driver for all models.
381
     */
382
    public static function setDriver(DriverInterface $driver)
383
    {
384
        self::$driver = $driver;
385
    }
386
387
    /**
388
     * Gets the driver for all models.
389
     *
390
     * @throws DriverMissingException when a driver has not been set yet
391
     */
392
    public static function getDriver(): DriverInterface
393
    {
394
        if (!self::$driver) {
395
            throw new DriverMissingException('A model driver has not been set yet.');
396
        }
397
398
        return self::$driver;
399
    }
400
401
    /**
402
     * Clears the driver for all models.
403
     */
404
    public static function clearDriver()
405
    {
406
        self::$driver = null;
407
    }
408
409
    /**
410
     * Gets the name of the model, i.e. User.
411
     */
412
    public static function modelName(): string
413
    {
414
        // strip namespacing
415
        $paths = explode('\\', get_called_class());
416
417
        return end($paths);
418
    }
419
420
    /**
421
     * Gets the model ID.
422
     *
423
     * @return string|number|false ID
424
     */
425
    public function id()
426
    {
427
        if (!$this->hasId) {
428
            return false;
429
        }
430
431
        if (1 == count($this->idValues)) {
432
            return reset($this->idValues);
433
        }
434
435
        $result = [];
436
        foreach (static::$ids as $k) {
437
            $result[] = $this->idValues[$k];
438
        }
439
440
        return implode(',', $result);
441
    }
442
443
    /**
444
     * Gets a key-value map of the model ID.
445
     *
446
     * @return array ID map
447
     */
448
    public function ids(): array
449
    {
450
        return $this->idValues;
451
    }
452
453
    /////////////////////////////
454
    // Magic Methods
455
    /////////////////////////////
456
457
    /**
458
     * Converts the model into a string.
459
     *
460
     * @return string
461
     */
462
    public function __toString()
463
    {
464
        $values = array_merge($this->_values, $this->_unsaved, $this->idValues);
465
        ksort($values);
466
467
        return get_called_class().'('.json_encode($values, JSON_PRETTY_PRINT).')';
468
    }
469
470
    /**
471
     * Shortcut to a get() call for a given property.
472
     *
473
     * @param string $name
474
     *
475
     * @return mixed
476
     */
477
    public function __get($name)
478
    {
479
        $result = $this->get([$name]);
480
481
        return reset($result);
482
    }
483
484
    /**
485
     * Sets an unsaved value.
486
     *
487
     * @param string $name
488
     * @param mixed  $value
489
     */
490
    public function __set($name, $value)
491
    {
492
        // if changing property, remove relation model
493
        if (isset($this->_relationships[$name])) {
494
            unset($this->_relationships[$name]);
495
        }
496
497
        // call any mutators
498
        $mutator = self::getMutator($name);
499
        if ($mutator) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $mutator of type string|null 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...
500
            $this->_unsaved[$name] = $this->$mutator($value);
501
        } else {
502
            $this->_unsaved[$name] = $value;
503
        }
504
    }
505
506
    /**
507
     * Checks if an unsaved value or property exists by this name.
508
     *
509
     * @param string $name
510
     *
511
     * @return bool
512
     */
513
    public function __isset($name)
514
    {
515
        return array_key_exists($name, $this->_unsaved) || static::hasProperty($name);
516
    }
517
518
    /**
519
     * Unsets an unsaved value.
520
     *
521
     * @param string $name
522
     */
523
    public function __unset($name)
524
    {
525
        if (array_key_exists($name, $this->_unsaved)) {
526
            // if changing property, remove relation model
527
            if (isset($this->_relationships[$name])) {
528
                unset($this->_relationships[$name]);
529
            }
530
531
            unset($this->_unsaved[$name]);
532
        }
533
    }
534
535
    /////////////////////////////
536
    // ArrayAccess Interface
537
    /////////////////////////////
538
539
    public function offsetExists($offset)
540
    {
541
        return isset($this->$offset);
542
    }
543
544
    public function offsetGet($offset)
545
    {
546
        return $this->$offset;
547
    }
548
549
    public function offsetSet($offset, $value)
550
    {
551
        $this->$offset = $value;
552
    }
553
554
    public function offsetUnset($offset)
555
    {
556
        unset($this->$offset);
557
    }
558
559
    public static function __callStatic($name, $parameters)
560
    {
561
        // Any calls to unkown static methods should be deferred to
562
        // the query. This allows calls like User::where()
563
        // to replace User::query()->where().
564
        return call_user_func_array([static::query(), $name], $parameters);
565
    }
566
567
    /////////////////////////////
568
    // Property Definitions
569
    /////////////////////////////
570
571
    /**
572
     * Gets all the property definitions for the model.
573
     *
574
     * @return array key-value map of properties
575
     */
576
    public static function getProperties(): array
577
    {
578
        return static::$properties;
579
    }
580
581
    /**
582
     * Gets a property defition for the model.
583
     *
584
     * @param string $property property to lookup
585
     */
586
    public static function getProperty(string $property): ?Property
587
    {
588
        if (!isset(static::$properties[$property])) {
589
            return null;
590
        }
591
592
        return new Property(static::$properties[$property]);
593
    }
594
595
    /**
596
     * Gets the names of the model ID properties.
597
     */
598
    public static function getIDProperties(): array
599
    {
600
        return static::$ids;
601
    }
602
603
    /**
604
     * Checks if the model has a property.
605
     *
606
     * @param string $property property
607
     *
608
     * @return bool has property
609
     */
610
    public static function hasProperty(string $property): bool
611
    {
612
        return isset(static::$properties[$property]);
613
    }
614
615
    /**
616
     * Gets the mutator method name for a given proeprty name.
617
     * Looks for methods in the form of `setPropertyValue`.
618
     * i.e. the mutator for `last_name` would be `setLastNameValue`.
619
     *
620
     * @param string $property property
621
     *
622
     * @return string|null method name if it exists
623
     */
624
    public static function getMutator(string $property): ?string
625
    {
626
        $class = get_called_class();
627
628
        $k = $class.':'.$property;
629
        if (!array_key_exists($k, self::$mutators)) {
630
            $inflector = Inflector::get();
631
            $method = 'set'.$inflector->camelize($property).'Value';
632
633
            if (!method_exists($class, $method)) {
634
                $method = null;
635
            }
636
637
            self::$mutators[$k] = $method;
638
        }
639
640
        return self::$mutators[$k];
641
    }
642
643
    /**
644
     * Gets the accessor method name for a given proeprty name.
645
     * Looks for methods in the form of `getPropertyValue`.
646
     * i.e. the accessor for `last_name` would be `getLastNameValue`.
647
     *
648
     * @param string $property property
649
     *
650
     * @return string|null method name if it exists
651
     */
652
    public static function getAccessor(string $property): ?string
653
    {
654
        $class = get_called_class();
655
656
        $k = $class.':'.$property;
657
        if (!array_key_exists($k, self::$accessors)) {
658
            $inflector = Inflector::get();
659
            $method = 'get'.$inflector->camelize($property).'Value';
660
661
            if (!method_exists($class, $method)) {
662
                $method = null;
663
            }
664
665
            self::$accessors[$k] = $method;
666
        }
667
668
        return self::$accessors[$k];
669
    }
670
671
    /**
672
     * Marshals a value for a given property from storage.
673
     *
674
     * @param mixed $value
675
     *
676
     * @return mixed type-casted value
677
     */
678
    public static function cast(Property $property, $value)
679
    {
680
        if (null === $value) {
681
            return null;
682
        }
683
684
        // handle empty strings as null
685
        if ($property->isNullable() && '' === $value) {
686
            return null;
687
        }
688
689
        $m = 'to_'.$property->getType();
690
691
        if (!method_exists(Type::class, $m)) {
692
            return $value;
693
        }
694
695
        return Type::$m($value);
696
    }
697
698
    /////////////////////////////
699
    // CRUD Operations
700
    /////////////////////////////
701
702
    /**
703
     * Gets the tablename for storing this model.
704
     */
705
    public function getTablename(): string
706
    {
707
        $inflector = Inflector::get();
708
709
        return $inflector->camelize($inflector->pluralize(static::modelName()));
710
    }
711
712
    /**
713
     * Gets the ID of the connection in the connection manager
714
     * that stores this model.
715
     */
716
    public function getConnection(): ?string
717
    {
718
        return null;
719
    }
720
721
    protected function usesTransactions(): bool
722
    {
723
        return false;
724
    }
725
726
    /**
727
     * Saves the model.
728
     *
729
     * @return bool true when the operation was successful
730
     */
731
    public function save(): bool
732
    {
733
        if (!$this->hasId) {
734
            return $this->create();
735
        }
736
737
        return $this->set();
738
    }
739
740
    /**
741
     * Saves the model. Throws an exception when the operation fails.
742
     *
743
     * @throws ModelException when the model cannot be saved
744
     */
745
    public function saveOrFail()
746
    {
747
        if (!$this->save()) {
748
            $msg = 'Failed to save '.static::modelName();
749
            if ($validationErrors = $this->getErrors()->all()) {
750
                $msg .= ': '.implode(', ', $validationErrors);
751
            }
752
753
            throw new ModelException($msg);
754
        }
755
    }
756
757
    /**
758
     * Creates a new model.
759
     *
760
     * @param array $data optional key-value properties to set
761
     *
762
     * @return bool true when the operation was successful
763
     *
764
     * @throws BadMethodCallException when called on an existing model
765
     */
766
    public function create(array $data = []): bool
767
    {
768
        if ($this->hasId) {
769
            throw new BadMethodCallException('Cannot call create() on an existing model');
770
        }
771
772
        // mass assign values passed into create()
773
        $this->setValues($data);
774
775
        // clear any previous errors
776
        $this->getErrors()->clear();
777
778
        // start a DB transaction if needed
779
        $usesTransactions = $this->usesTransactions();
780
        if ($usesTransactions) {
781
            self::$driver->startTransaction($this->getConnection());
782
        }
783
784
        // dispatch the model.creating event
785
        if (!$this->performDispatch(ModelEvent::CREATING, $usesTransactions)) {
786
            return false;
787
        }
788
789
        $requiredProperties = [];
790
        foreach (static::$properties as $name => $property) {
791
            // build a list of the required properties
792
            if ($property['required']) {
793
                $requiredProperties[] = $name;
794
            }
795
796
            // add in default values
797
            if (!array_key_exists($name, $this->_unsaved) && array_key_exists('default', $property)) {
798
                $this->_unsaved[$name] = $property['default'];
799
            }
800
        }
801
802
        // validate the values being saved
803
        $validated = true;
804
        $insertArray = [];
805
        foreach ($this->_unsaved as $name => $value) {
806
            // exclude if value does not map to a property
807
            if (!isset(static::$properties[$name])) {
808
                continue;
809
            }
810
811
            $property = self::getProperty($name);
812
813
            // cannot insert immutable values
814
            // (unless using the default value)
815
            if ($property->isImmutable() && $value !== $property->getDefault()) {
816
                continue;
817
            }
818
819
            $validated = $validated && $this->filterAndValidate($property, $name, $value);
0 ignored issues
show
Bug introduced by
It seems like $property defined by self::getProperty($name) on line 811 can be null; however, Pulsar\Model::filterAndValidate() does not accept null, maybe add an additional type check?

Unless you are absolutely sure that the expression can never be null because of other conditions, we strongly recommend to add an additional type check to your code:

/** @return stdClass|null */
function mayReturnNull() { }

function doesNotAcceptNull(stdClass $x) { }

// With potential error.
function withoutCheck() {
    $x = mayReturnNull();
    doesNotAcceptNull($x); // Potential error here.
}

// Safe - Alternative 1
function withCheck1() {
    $x = mayReturnNull();
    if ( ! $x instanceof stdClass) {
        throw new \LogicException('$x must be defined.');
    }
    doesNotAcceptNull($x);
}

// Safe - Alternative 2
function withCheck2() {
    $x = mayReturnNull();
    if ($x instanceof stdClass) {
        doesNotAcceptNull($x);
    }
}
Loading history...
820
            $insertArray[$name] = $value;
821
        }
822
823
        // check for required fields
824
        foreach ($requiredProperties as $name) {
825
            if (!isset($insertArray[$name])) {
826
                $params = [
827
                    'field' => $name,
828
                    'field_name' => $this->getPropertyTitle($name),
829
                ];
830
                $this->getErrors()->add('pulsar.validation.required', $params);
831
832
                $validated = false;
833
            }
834
        }
835
836
        if (!$validated) {
837
            // when validations fail roll back any database transaction
838
            if ($usesTransactions) {
839
                self::$driver->rollBackTransaction($this->getConnection());
840
            }
841
842
            return false;
843
        }
844
845
        $created = self::$driver->createModel($this, $insertArray);
846
847
        if ($created) {
848
            // determine the model's new ID
849
            $this->getNewID();
850
851
            // store the persisted values to the in-memory cache
852
            $this->_unsaved = [];
853
            $this->refreshWith(array_replace($this->idValues, $insertArray));
854
855
            // dispatch the model.created event
856
            if (!$this->performDispatch(ModelEvent::CREATED, $usesTransactions)) {
857
                return false;
858
            }
859
        }
860
861
        // commit the transaction, if used
862
        if ($usesTransactions) {
863
            self::$driver->commitTransaction($this->getConnection());
864
        }
865
866
        return $created;
867
    }
868
869
    /**
870
     * Ignores unsaved values when fetching the next value.
871
     *
872
     * @return $this
873
     */
874
    public function ignoreUnsaved()
875
    {
876
        $this->ignoreUnsaved = true;
877
878
        return $this;
879
    }
880
881
    /**
882
     * Fetches property values from the model.
883
     *
884
     * This method looks up values in this order:
885
     * IDs, local cache, unsaved values, storage layer, defaults
886
     *
887
     * @param array $properties list of property names to fetch values of
888
     */
889
    public function get(array $properties): array
890
    {
891
        // load the values from the IDs and local model cache
892
        $values = array_replace($this->ids(), $this->_values);
893
894
        // unless specified, use any unsaved values
895
        $ignoreUnsaved = $this->ignoreUnsaved;
896
        $this->ignoreUnsaved = false;
897
898
        if (!$ignoreUnsaved) {
899
            $values = array_replace($values, $this->_unsaved);
900
        }
901
902
        // see if there are any model properties that do not exist.
903
        // when true then this means the model needs to be hydrated
904
        // NOTE: only looking at model properties and excluding dynamic/non-existent properties
905
        $modelProperties = array_keys(static::$properties);
906
        $numMissing = count(array_intersect($modelProperties, array_diff($properties, array_keys($values))));
907
908
        if ($numMissing > 0 && !$this->loaded) {
909
            // load the model from the storage layer, if needed
910
            $this->refresh();
911
912
            $values = array_replace($values, $this->_values);
913
914
            if (!$ignoreUnsaved) {
915
                $values = array_replace($values, $this->_unsaved);
916
            }
917
        }
918
919
        // build a key-value map of the requested properties
920
        $return = [];
921
        foreach ($properties as $k) {
922
            $return[$k] = $this->getValue($k, $values);
923
        }
924
925
        return $return;
926
    }
927
928
    /**
929
     * Gets a property value from the model.
930
     *
931
     * Values are looked up in this order:
932
     *  1. unsaved values
933
     *  2. local values
934
     *  3. default value
935
     *  4. null
936
     *
937
     * @param string $property
938
     *
939
     * @return mixed
940
     */
941
    protected function getValue($property, array $values)
942
    {
943
        $value = null;
944
945
        if (array_key_exists($property, $values)) {
946
            $value = $values[$property];
947
        } elseif (static::hasProperty($property)) {
948
            $value = $this->_values[$property] = self::getProperty($property)->getDefault();
949
        }
950
951
        // call any accessors
952
        if ($accessor = self::getAccessor($property)) {
953
            $value = $this->$accessor($value);
954
        }
955
956
        return $value;
957
    }
958
959
    /**
960
     * Populates a newly created model with its ID.
961
     */
962
    protected function getNewID()
963
    {
964
        $ids = [];
965
        $namedIds = [];
966
        foreach (static::$ids as $k) {
967
            // attempt use the supplied value if the ID property is mutable
968
            $property = static::getProperty($k);
969
            if (!$property->isImmutable() && isset($this->_unsaved[$k])) {
970
                $id = $this->_unsaved[$k];
971
            } else {
972
                $id = self::$driver->getCreatedID($this, $k);
973
            }
974
975
            $ids[] = $id;
976
            $namedIds[$k] = $id;
977
        }
978
979
        $this->hasId = true;
980
        $this->idValues = $namedIds;
981
    }
982
983
    /**
984
     * Sets a collection values on the model from an untrusted input.
985
     *
986
     * @param array $values
987
     *
988
     * @throws MassAssignmentException when assigning a value that is protected or not whitelisted
989
     *
990
     * @return $this
991
     */
992
    public function setValues($values)
993
    {
994
        // check if the model has a mass assignment whitelist
995
        $permitted = (property_exists($this, 'permitted')) ? static::$permitted : false;
996
997
        // if no whitelist, then check for a blacklist
998
        $protected = (!is_array($permitted) && property_exists($this, 'protected')) ? static::$protected : false;
999
1000
        foreach ($values as $k => $value) {
1001
            // check for mass assignment violations
1002
            if (($permitted && !in_array($k, $permitted)) ||
1003
                ($protected && in_array($k, $protected))) {
1004
                throw new MassAssignmentException("Mass assignment of $k on ".static::modelName().' is not allowed');
1005
            }
1006
1007
            $this->$k = $value;
1008
        }
1009
1010
        return $this;
1011
    }
1012
1013
    /**
1014
     * Converts the model to an array.
1015
     */
1016
    public function toArray(): array
1017
    {
1018
        // build the list of properties to retrieve
1019
        $properties = array_keys(static::$properties);
1020
1021
        // remove any hidden properties
1022
        $hide = (property_exists($this, 'hidden')) ? static::$hidden : [];
1023
        $properties = array_diff($properties, $hide);
1024
1025
        // add any appended properties
1026
        $append = (property_exists($this, 'appended')) ? static::$appended : [];
1027
        $properties = array_merge($properties, $append);
1028
1029
        // get the values for the properties
1030
        $result = $this->get($properties);
1031
1032
        foreach ($result as $k => &$value) {
1033
            // convert any models to arrays
1034
            if ($value instanceof self) {
1035
                $value = $value->toArray();
1036
            }
1037
        }
1038
1039
        return $result;
1040
    }
1041
1042
    /**
1043
     * Updates the model.
1044
     *
1045
     * @param array $data optional key-value properties to set
1046
     *
1047
     * @return bool true when the operation was successful
1048
     *
1049
     * @throws BadMethodCallException when not called on an existing model
1050
     */
1051
    public function set(array $data = []): bool
1052
    {
1053
        if (!$this->hasId) {
1054
            throw new BadMethodCallException('Can only call set() on an existing model');
1055
        }
1056
1057
        // mass assign values passed into set()
1058
        $this->setValues($data);
1059
1060
        // clear any previous errors
1061
        $this->getErrors()->clear();
1062
1063
        // not updating anything?
1064
        if (0 == count($this->_unsaved)) {
1065
            return true;
1066
        }
1067
1068
        // start a DB transaction if needed
1069
        $usesTransactions = $this->usesTransactions();
1070
        if ($usesTransactions) {
1071
            self::$driver->startTransaction($this->getConnection());
1072
        }
1073
1074
        // dispatch the model.updating event
1075
        if (!$this->performDispatch(ModelEvent::UPDATING, $usesTransactions)) {
1076
            return false;
1077
        }
1078
1079
        // validate the values being saved
1080
        $validated = true;
1081
        $updateArray = [];
1082
        foreach ($this->_unsaved as $name => $value) {
1083
            // exclude if value does not map to a property
1084
            if (!isset(static::$properties[$name])) {
1085
                continue;
1086
            }
1087
1088
            $property = self::getProperty($name);
1089
1090
            // can only modify mutable properties
1091
            if (!$property->isMutable()) {
1092
                continue;
1093
            }
1094
1095
            $validated = $validated && $this->filterAndValidate($property, $name, $value);
0 ignored issues
show
Bug introduced by
It seems like $property defined by self::getProperty($name) on line 1088 can be null; however, Pulsar\Model::filterAndValidate() does not accept null, maybe add an additional type check?

Unless you are absolutely sure that the expression can never be null because of other conditions, we strongly recommend to add an additional type check to your code:

/** @return stdClass|null */
function mayReturnNull() { }

function doesNotAcceptNull(stdClass $x) { }

// With potential error.
function withoutCheck() {
    $x = mayReturnNull();
    doesNotAcceptNull($x); // Potential error here.
}

// Safe - Alternative 1
function withCheck1() {
    $x = mayReturnNull();
    if ( ! $x instanceof stdClass) {
        throw new \LogicException('$x must be defined.');
    }
    doesNotAcceptNull($x);
}

// Safe - Alternative 2
function withCheck2() {
    $x = mayReturnNull();
    if ($x instanceof stdClass) {
        doesNotAcceptNull($x);
    }
}
Loading history...
1096
            $updateArray[$name] = $value;
1097
        }
1098
1099
        if (!$validated) {
1100
            // when validations fail roll back any database transaction
1101
            if ($usesTransactions) {
1102
                self::$driver->rollBackTransaction($this->getConnection());
1103
            }
1104
1105
            return false;
1106
        }
1107
1108
        $updated = self::$driver->updateModel($this, $updateArray);
1109
1110
        if ($updated) {
1111
            // store the persisted values to the in-memory cache
1112
            $this->_unsaved = [];
1113
            $this->refreshWith(array_replace($this->_values, $updateArray));
1114
1115
            // dispatch the model.updated event
1116
            if (!$this->performDispatch(ModelEvent::UPDATED, $usesTransactions)) {
1117
                return false;
1118
            }
1119
        }
1120
1121
        // commit the transaction, if used
1122
        if ($usesTransactions) {
1123
            self::$driver->commitTransaction($this->getConnection());
1124
        }
1125
1126
        return $updated;
1127
    }
1128
1129
    /**
1130
     * Delete the model.
1131
     *
1132
     * @return bool true when the operation was successful
1133
     */
1134
    public function delete(): bool
1135
    {
1136
        if (!$this->hasId) {
1137
            throw new BadMethodCallException('Can only call delete() on an existing model');
1138
        }
1139
1140
        // clear any previous errors
1141
        $this->getErrors()->clear();
1142
1143
        // start a DB transaction if needed
1144
        $usesTransactions = $this->usesTransactions();
1145
        if ($usesTransactions) {
1146
            self::$driver->startTransaction($this->getConnection());
1147
        }
1148
1149
        // dispatch the model.deleting event
1150
        if (!$this->performDispatch(ModelEvent::DELETING, $usesTransactions)) {
1151
            return false;
1152
        }
1153
1154
        // perform a hard (default) or soft delete
1155
        $hardDelete = true;
1156
        if (property_exists($this, 'softDelete')) {
1157
            $t = time();
1158
            $this->deleted_at = $t;
0 ignored issues
show
Documentation introduced by
The property deleted_at does not exist on object<Pulsar\Model>. Since you implemented __set, maybe consider adding a @property annotation.

Since your code implements the magic setter _set, this function will be called for any write access on an undefined variable. You can add the @property annotation to your class or interface to document the existence of this variable.

<?php

/**
 * @property int $x
 * @property int $y
 * @property string $text
 */
class MyLabel
{
    private $properties;

    private $allowedProperties = array('x', 'y', 'text');

    public function __get($name)
    {
        if (isset($properties[$name]) && in_array($name, $this->allowedProperties)) {
            return $properties[$name];
        } else {
            return null;
        }
    }

    public function __set($name, $value)
    {
        if (in_array($name, $this->allowedProperties)) {
            $properties[$name] = $value;
        } else {
            throw new \LogicException("Property $name is not defined.");
        }
    }

}

Since the property has write access only, you can use the @property-write annotation instead.

Of course, you may also just have mistyped another name, in which case you should fix the error.

See also the PhpDoc documentation for @property.

Loading history...
1159
            $t = $this->filterAndValidate(static::getProperty('deleted_at'), 'deleted_at', $t);
0 ignored issues
show
Bug introduced by
It seems like static::getProperty('deleted_at') can be null; however, filterAndValidate() does not accept null, maybe add an additional type check?

Unless you are absolutely sure that the expression can never be null because of other conditions, we strongly recommend to add an additional type check to your code:

/** @return stdClass|null */
function mayReturnNull() { }

function doesNotAcceptNull(stdClass $x) { }

// With potential error.
function withoutCheck() {
    $x = mayReturnNull();
    doesNotAcceptNull($x); // Potential error here.
}

// Safe - Alternative 1
function withCheck1() {
    $x = mayReturnNull();
    if ( ! $x instanceof stdClass) {
        throw new \LogicException('$x must be defined.');
    }
    doesNotAcceptNull($x);
}

// Safe - Alternative 2
function withCheck2() {
    $x = mayReturnNull();
    if ($x instanceof stdClass) {
        doesNotAcceptNull($x);
    }
}
Loading history...
1160
            $deleted = self::$driver->updateModel($this, ['deleted_at' => $t]);
1161
            $hardDelete = false;
1162
        } else {
1163
            $deleted = self::$driver->deleteModel($this);
1164
        }
1165
1166
        if ($deleted) {
1167
            // dispatch the model.deleted event
1168
            if (!$this->performDispatch(ModelEvent::DELETED, $usesTransactions)) {
1169
                return false;
1170
            }
1171
1172
            if ($hardDelete) {
1173
                $this->_persisted = false;
1174
            }
1175
        }
1176
1177
        // commit the transaction, if used
1178
        if ($usesTransactions) {
1179
            self::$driver->commitTransaction($this->getConnection());
1180
        }
1181
1182
        return $deleted;
1183
    }
1184
1185
    /**
1186
     * Restores a soft-deleted model.
1187
     */
1188
    public function restore(): bool
1189
    {
1190
        if (!property_exists($this, 'softDelete') || !$this->deleted_at) {
0 ignored issues
show
Documentation introduced by
The property deleted_at does not exist on object<Pulsar\Model>. Since you implemented __get, maybe consider adding a @property annotation.

Since your code implements the magic getter _get, this function will be called for any read access on an undefined variable. You can add the @property annotation to your class or interface to document the existence of this variable.

<?php

/**
 * @property int $x
 * @property int $y
 * @property string $text
 */
class MyLabel
{
    private $properties;

    private $allowedProperties = array('x', 'y', 'text');

    public function __get($name)
    {
        if (isset($properties[$name]) && in_array($name, $this->allowedProperties)) {
            return $properties[$name];
        } else {
            return null;
        }
    }

    public function __set($name, $value)
    {
        if (in_array($name, $this->allowedProperties)) {
            $properties[$name] = $value;
        } else {
            throw new \LogicException("Property $name is not defined.");
        }
    }

}

If the property has read access only, you can use the @property-read annotation instead.

Of course, you may also just have mistyped another name, in which case you should fix the error.

See also the PhpDoc documentation for @property.

Loading history...
1191
            throw new BadMethodCallException('Can only call restore() on a soft-deleted model');
1192
        }
1193
1194
        // start a DB transaction if needed
1195
        $usesTransactions = $this->usesTransactions();
1196
        if ($usesTransactions) {
1197
            self::$driver->startTransaction($this->getConnection());
1198
        }
1199
1200
        // dispatch the model.updating event
1201
        if (!$this->performDispatch(ModelEvent::UPDATING, $usesTransactions)) {
1202
            return false;
1203
        }
1204
1205
        $this->deleted_at = null;
0 ignored issues
show
Documentation introduced by
The property deleted_at does not exist on object<Pulsar\Model>. Since you implemented __set, maybe consider adding a @property annotation.

Since your code implements the magic setter _set, this function will be called for any write access on an undefined variable. You can add the @property annotation to your class or interface to document the existence of this variable.

<?php

/**
 * @property int $x
 * @property int $y
 * @property string $text
 */
class MyLabel
{
    private $properties;

    private $allowedProperties = array('x', 'y', 'text');

    public function __get($name)
    {
        if (isset($properties[$name]) && in_array($name, $this->allowedProperties)) {
            return $properties[$name];
        } else {
            return null;
        }
    }

    public function __set($name, $value)
    {
        if (in_array($name, $this->allowedProperties)) {
            $properties[$name] = $value;
        } else {
            throw new \LogicException("Property $name is not defined.");
        }
    }

}

Since the property has write access only, you can use the @property-write annotation instead.

Of course, you may also just have mistyped another name, in which case you should fix the error.

See also the PhpDoc documentation for @property.

Loading history...
1206
        $restored = self::$driver->updateModel($this, ['deleted_at' => null]);
1207
1208
        if ($restored) {
1209
            // dispatch the model.updated event
1210
            if (!$this->performDispatch(ModelEvent::UPDATED, $usesTransactions)) {
1211
                return false;
1212
            }
1213
        }
1214
1215
        // commit the transaction, if used
1216
        if ($usesTransactions) {
1217
            self::$driver->commitTransaction($this->getConnection());
1218
        }
1219
1220
        return $restored;
1221
    }
1222
1223
    /**
1224
     * Checks if the model has been deleted.
1225
     */
1226
    public function isDeleted(): bool
1227
    {
1228
        if (property_exists($this, 'softDelete') && $this->deleted_at) {
0 ignored issues
show
Documentation introduced by
The property deleted_at does not exist on object<Pulsar\Model>. Since you implemented __get, maybe consider adding a @property annotation.

Since your code implements the magic getter _get, this function will be called for any read access on an undefined variable. You can add the @property annotation to your class or interface to document the existence of this variable.

<?php

/**
 * @property int $x
 * @property int $y
 * @property string $text
 */
class MyLabel
{
    private $properties;

    private $allowedProperties = array('x', 'y', 'text');

    public function __get($name)
    {
        if (isset($properties[$name]) && in_array($name, $this->allowedProperties)) {
            return $properties[$name];
        } else {
            return null;
        }
    }

    public function __set($name, $value)
    {
        if (in_array($name, $this->allowedProperties)) {
            $properties[$name] = $value;
        } else {
            throw new \LogicException("Property $name is not defined.");
        }
    }

}

If the property has read access only, you can use the @property-read annotation instead.

Of course, you may also just have mistyped another name, in which case you should fix the error.

See also the PhpDoc documentation for @property.

Loading history...
1229
            return true;
1230
        }
1231
1232
        return !$this->_persisted;
1233
    }
1234
1235
    /////////////////////////////
1236
    // Queries
1237
    /////////////////////////////
1238
1239
    /**
1240
     * Generates a new query instance.
1241
     */
1242
    public static function query(): Query
1243
    {
1244
        // Create a new model instance for the query to ensure
1245
        // that the model's initialize() method gets called.
1246
        // Otherwise, the property definitions will be incomplete.
1247
        $model = new static();
1248
        $query = new Query($model);
1249
1250
        // scope soft-deleted models to only include non-deleted models
1251
        if (property_exists($model, 'softDelete')) {
1252
            $query->where('deleted_at IS NOT NULL');
1253
        }
1254
1255
        return $query;
1256
    }
1257
1258
    /**
1259
     * Generates a new query instance that includes soft-deleted models.
1260
     */
1261
    public static function withDeleted(): Query
1262
    {
1263
        // Create a new model instance for the query to ensure
1264
        // that the model's initialize() method gets called.
1265
        // Otherwise, the property definitions will be incomplete.
1266
        $model = new static();
1267
1268
        return new Query($model);
1269
    }
1270
1271
    /**
1272
     * Finds a single instance of a model given it's ID.
1273
     *
1274
     * @param mixed $id
1275
     *
1276
     * @return static|null
1277
     */
1278
    public static function find($id): ?self
1279
    {
1280
        $ids = [];
1281
        $id = (array) $id;
1282
        foreach (static::$ids as $j => $k) {
1283
            if (isset($id[$j])) {
1284
                $ids[$k] = $id[$j];
1285
            }
1286
        }
1287
1288
        // malformed ID
1289
        if (count($ids) < count(static::$ids)) {
1290
            return null;
1291
        }
1292
1293
        return static::query()->where($ids)->first();
1294
    }
1295
1296
    /**
1297
     * Finds a single instance of a model given it's ID or throws an exception.
1298
     *
1299
     * @param mixed $id
1300
     *
1301
     * @return static
1302
     *
1303
     * @throws ModelNotFoundException when a model could not be found
1304
     */
1305
    public static function findOrFail($id): self
1306
    {
1307
        $model = static::find($id);
1308
        if (!$model) {
1309
            throw new ModelNotFoundException('Could not find the requested '.static::modelName());
1310
        }
1311
1312
        return $model;
1313
    }
1314
1315
    /**
1316
     * Tells if this model instance has been persisted to the data layer.
1317
     *
1318
     * NOTE: this does not actually perform a check with the data layer
1319
     */
1320
    public function persisted(): bool
1321
    {
1322
        return $this->_persisted;
1323
    }
1324
1325
    /**
1326
     * Loads the model from the storage layer.
1327
     *
1328
     * @return $this
1329
     */
1330
    public function refresh()
1331
    {
1332
        if (!$this->hasId) {
1333
            return $this;
1334
        }
1335
1336
        $values = self::$driver->loadModel($this);
1337
1338
        if (!is_array($values)) {
1339
            return $this;
1340
        }
1341
1342
        // clear any relations
1343
        $this->_relationships = [];
1344
1345
        return $this->refreshWith($values);
1346
    }
1347
1348
    /**
1349
     * Loads values into the model.
1350
     *
1351
     * @param array $values values
1352
     *
1353
     * @return $this
1354
     */
1355
    public function refreshWith(array $values)
1356
    {
1357
        // type cast the values
1358
        foreach ($values as $k => &$value) {
1359
            if ($property = static::getProperty($k)) {
1360
                $value = static::cast($property, $value);
1361
            }
1362
        }
1363
1364
        $this->loaded = true;
1365
        $this->_persisted = true;
1366
        $this->_values = $values;
1367
1368
        return $this;
1369
    }
1370
1371
    /**
1372
     * Clears the cache for this model.
1373
     *
1374
     * @return $this
1375
     */
1376
    public function clearCache()
1377
    {
1378
        $this->loaded = false;
1379
        $this->_unsaved = [];
1380
        $this->_values = [];
1381
        $this->_relationships = [];
1382
1383
        return $this;
1384
    }
1385
1386
    /////////////////////////////
1387
    // Relationships
1388
    /////////////////////////////
1389
1390
    /**
1391
     * @deprecated
1392
     *
1393
     * Gets the model(s) for a relationship
1394
     *
1395
     * @param string $k property
1396
     *
1397
     * @throws InvalidArgumentException when the relationship manager cannot be created
1398
     *
1399
     * @return Model|array|null
1400
     */
1401
    public function relation(string $k)
1402
    {
1403
        if (!array_key_exists($k, $this->_relationships)) {
1404
            $relation = $this->getRelationshipManager($k);
1405
            $this->_relationships[$k] = $relation->getResults();
1406
        }
1407
1408
        return $this->_relationships[$k];
1409
    }
1410
1411
    /**
1412
     * @deprecated
1413
     *
1414
     * Sets the model for a one-to-one relationship (has-one or belongs-to)
1415
     *
1416
     * @return $this
1417
     */
1418
    public function setRelation(string $k, Model $model)
1419
    {
1420
        $this->$k = $model->id();
1421
        $this->_relationships[$k] = $model;
1422
1423
        return $this;
1424
    }
1425
1426
    /**
1427
     * Sets the model for a one-to-one relationship (has-one or belongs-to) as null.
1428
     *
1429
     * @return $this
1430
     */
1431
    public function clearRelation(string $k)
1432
    {
1433
        $this->$k = null;
1434
        $this->_relationships[$k] = null;
1435
1436
        return $this;
1437
    }
1438
1439
    /**
1440
     * Builds a relationship manager object for a given property.
1441
     *
1442
     * @param array $k
1443
     *
1444
     * @throws InvalidArgumentException when the relationship manager cannot be created
1445
     */
1446
    public function getRelationshipManager(string $k): Relation
1447
    {
1448
        $property = static::getProperty($k);
0 ignored issues
show
Bug introduced by
It seems like $k defined by parameter $k on line 1446 can also be of type array; however, Pulsar\Model::getProperty() does only seem to accept string, maybe add an additional type check?

This check looks at variables that have been passed in as parameters and are passed out again to other methods.

If the outgoing method call has stricter type requirements than the method itself, an issue is raised.

An additional type check may prevent trouble.

Loading history...
1449
        if (!$property) {
1450
            throw new InvalidArgumentException('Property "'.$k.'" does not exist.');
1451
        }
1452
1453
        $relationModelClass = $property->getRelation();
1454
        if (!$relationModelClass) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $relationModelClass of type string|null is loosely compared to false; 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...
1455
            throw new InvalidArgumentException('Property "'.$k.'" does not have a relationship.');
1456
        }
1457
1458
        $foreignKey = $property->getForeignKey();
1459
        $localKey = $property->getLocalKey();
1460
        $relationType = $property->getRelationType();
1461
1462
        if (self::RELATIONSHIP_HAS_ONE == $relationType) {
1463
            return $this->hasOne($relationModelClass, $foreignKey, $localKey);
1464
        }
1465
1466
        if (self::RELATIONSHIP_HAS_MANY == $relationType) {
1467
            return $this->hasMany($relationModelClass, $foreignKey, $localKey);
1468
        }
1469
1470
        if (self::RELATIONSHIP_BELONGS_TO == $relationType) {
1471
            return $this->belongsTo($relationModelClass, $foreignKey, $localKey);
1472
        }
1473
1474
        if (self::RELATIONSHIP_BELONGS_TO_MANY == $relationType) {
1475
            $pivotTable = $property->getPivotTablename();
1476
1477
            return $this->belongsToMany($relationModelClass, $pivotTable, $foreignKey, $localKey);
1478
        }
1479
1480
        throw new InvalidArgumentException('Relationship type on "'.$k.'" property not supported: '.$relationType);
1481
    }
1482
1483
    /**
1484
     * Creates the parent side of a One-To-One relationship.
1485
     *
1486
     * @param string $model      foreign model class
1487
     * @param string $foreignKey identifying key on foreign model
1488
     * @param string $localKey   identifying key on local model
1489
     */
1490
    public function hasOne($model, $foreignKey = '', $localKey = ''): HasOne
1491
    {
1492
        return new HasOne($this, $localKey, $model, $foreignKey);
1493
    }
1494
1495
    /**
1496
     * Creates the child side of a One-To-One or One-To-Many relationship.
1497
     *
1498
     * @param string $model      foreign model class
1499
     * @param string $foreignKey identifying key on foreign model
1500
     * @param string $localKey   identifying key on local model
1501
     */
1502
    public function belongsTo($model, $foreignKey = '', $localKey = ''): BelongsTo
1503
    {
1504
        return new BelongsTo($this, $localKey, $model, $foreignKey);
1505
    }
1506
1507
    /**
1508
     * Creates the parent side of a Many-To-One or Many-To-Many relationship.
1509
     *
1510
     * @param string $model      foreign model class
1511
     * @param string $foreignKey identifying key on foreign model
1512
     * @param string $localKey   identifying key on local model
1513
     */
1514
    public function hasMany($model, $foreignKey = '', $localKey = ''): HasMany
1515
    {
1516
        return new HasMany($this, $localKey, $model, $foreignKey);
1517
    }
1518
1519
    /**
1520
     * Creates the child side of a Many-To-Many relationship.
1521
     *
1522
     * @param string $model      foreign model class
1523
     * @param string $tablename  pivot table name
1524
     * @param string $foreignKey identifying key on foreign model
1525
     * @param string $localKey   identifying key on local model
1526
     */
1527
    public function belongsToMany($model, $tablename = '', $foreignKey = '', $localKey = ''): BelongsToMany
1528
    {
1529
        return new BelongsToMany($this, $localKey, $tablename, $model, $foreignKey);
1530
    }
1531
1532
    /////////////////////////////
1533
    // Events
1534
    /////////////////////////////
1535
1536
    /**
1537
     * Gets the event dispatcher.
1538
     */
1539
    public static function getDispatcher($ignoreCache = false): EventDispatcher
1540
    {
1541
        $class = get_called_class();
1542
        if ($ignoreCache || !isset(self::$dispatchers[$class])) {
1543
            self::$dispatchers[$class] = new EventDispatcher();
1544
        }
1545
1546
        return self::$dispatchers[$class];
1547
    }
1548
1549
    /**
1550
     * Subscribes to a listener to an event.
1551
     *
1552
     * @param string $event    event name
1553
     * @param int    $priority optional priority, higher #s get called first
1554
     */
1555
    public static function listen(string $event, callable $listener, int $priority = 0)
1556
    {
1557
        static::getDispatcher()->addListener($event, $listener, $priority);
1558
    }
1559
1560
    /**
1561
     * Adds a listener to the model.creating and model.updating events.
1562
     */
1563
    public static function saving(callable $listener, int $priority = 0)
1564
    {
1565
        static::listen(ModelEvent::CREATING, $listener, $priority);
1566
        static::listen(ModelEvent::UPDATING, $listener, $priority);
1567
    }
1568
1569
    /**
1570
     * Adds a listener to the model.created and model.updated events.
1571
     */
1572
    public static function saved(callable $listener, int $priority = 0)
1573
    {
1574
        static::listen(ModelEvent::CREATED, $listener, $priority);
1575
        static::listen(ModelEvent::UPDATED, $listener, $priority);
1576
    }
1577
1578
    /**
1579
     * Adds a listener to the model.creating event.
1580
     */
1581
    public static function creating(callable $listener, int $priority = 0)
1582
    {
1583
        static::listen(ModelEvent::CREATING, $listener, $priority);
1584
    }
1585
1586
    /**
1587
     * Adds a listener to the model.created event.
1588
     */
1589
    public static function created(callable $listener, int $priority = 0)
1590
    {
1591
        static::listen(ModelEvent::CREATED, $listener, $priority);
1592
    }
1593
1594
    /**
1595
     * Adds a listener to the model.updating event.
1596
     */
1597
    public static function updating(callable $listener, int $priority = 0)
1598
    {
1599
        static::listen(ModelEvent::UPDATING, $listener, $priority);
1600
    }
1601
1602
    /**
1603
     * Adds a listener to the model.updated event.
1604
     */
1605
    public static function updated(callable $listener, int $priority = 0)
1606
    {
1607
        static::listen(ModelEvent::UPDATED, $listener, $priority);
1608
    }
1609
1610
    /**
1611
     * Adds a listener to the model.deleting event.
1612
     */
1613
    public static function deleting(callable $listener, int $priority = 0)
1614
    {
1615
        static::listen(ModelEvent::DELETING, $listener, $priority);
1616
    }
1617
1618
    /**
1619
     * Adds a listener to the model.deleted event.
1620
     */
1621
    public static function deleted(callable $listener, int $priority = 0)
1622
    {
1623
        static::listen(ModelEvent::DELETED, $listener, $priority);
1624
    }
1625
1626
    /**
1627
     * Dispatches the given event and checks if it was successful.
1628
     *
1629
     * @return bool true if the events were successfully propagated
1630
     */
1631
    private function performDispatch(string $eventName, bool $usesTransactions): bool
1632
    {
1633
        $event = new ModelEvent($this);
1634
        static::getDispatcher()->dispatch($event, $eventName);
1635
1636
        // when listeners fail roll back any database transaction
1637
        if ($event->isPropagationStopped()) {
1638
            if ($usesTransactions) {
1639
                self::$driver->rollBackTransaction($this->getConnection());
1640
            }
1641
1642
            return false;
1643
        }
1644
1645
        return true;
1646
    }
1647
1648
    /////////////////////////////
1649
    // Validation
1650
    /////////////////////////////
1651
1652
    /**
1653
     * Gets the error stack for this model.
1654
     */
1655
    public function getErrors(): Errors
1656
    {
1657
        if (!$this->errors) {
1658
            $this->errors = new Errors();
1659
        }
1660
1661
        return $this->errors;
1662
    }
1663
1664
    /**
1665
     * Checks if the model in its current state is valid.
1666
     */
1667
    public function valid(): bool
1668
    {
1669
        // clear any previous errors
1670
        $this->getErrors()->clear();
1671
1672
        // run the validator against the model values
1673
        $values = $this->_unsaved + $this->_values;
1674
1675
        $validated = true;
1676
        foreach ($values as $k => $v) {
1677
            $property = static::getProperty($k);
1678
            $validated = $this->filterAndValidate($property, $k, $v) && $validated;
0 ignored issues
show
Bug introduced by
It seems like $property defined by static::getProperty($k) on line 1677 can be null; however, Pulsar\Model::filterAndValidate() does not accept null, maybe add an additional type check?

Unless you are absolutely sure that the expression can never be null because of other conditions, we strongly recommend to add an additional type check to your code:

/** @return stdClass|null */
function mayReturnNull() { }

function doesNotAcceptNull(stdClass $x) { }

// With potential error.
function withoutCheck() {
    $x = mayReturnNull();
    doesNotAcceptNull($x); // Potential error here.
}

// Safe - Alternative 1
function withCheck1() {
    $x = mayReturnNull();
    if ( ! $x instanceof stdClass) {
        throw new \LogicException('$x must be defined.');
    }
    doesNotAcceptNull($x);
}

// Safe - Alternative 2
function withCheck2() {
    $x = mayReturnNull();
    if ($x instanceof stdClass) {
        doesNotAcceptNull($x);
    }
}
Loading history...
1679
        }
1680
1681
        // add back any modified unsaved values
1682
        foreach (array_keys($this->_unsaved) as $k) {
1683
            $this->_unsaved[$k] = $values[$k];
1684
        }
1685
1686
        return $validated;
1687
    }
1688
1689
    /**
1690
     * Validates and marshals a value to storage.
1691
     *
1692
     * @param Property $property property definition
1693
     * @param string   $name     property name
1694
     * @param mixed    $value
1695
     */
1696
    private function filterAndValidate(Property $property, string $name, &$value): bool
1697
    {
1698
        // assume empty string is a null value for properties
1699
        // that are marked as optionally-null
1700
        if ($property->isNullable() && ('' === $value || null === $value)) {
1701
            $value = null;
1702
1703
            return true;
1704
        }
1705
1706
        // validate
1707
        list($valid, $value) = $this->validateValue($property, $name, $value);
1708
1709
        // unique?
1710
        if ($valid && $property->isUnique() && (!$this->hasId || $value != $this->ignoreUnsaved()->$name)) {
1711
            $valid = $this->checkUniqueness($name, $value);
1712
        }
1713
1714
        return $valid;
1715
    }
1716
1717
    /**
1718
     * Validates a value for a property.
1719
     *
1720
     * @param Property $property property definition
1721
     * @param string   $name     property name
1722
     * @param mixed    $value
1723
     */
1724
    private function validateValue(Property $property, string $name, $value): array
1725
    {
1726
        $valid = true;
1727
1728
        $error = 'pulsar.validation.failed';
1729
        $validateRules = $property->getValidationRules();
1730
        if (is_callable($validateRules)) {
1731
            $valid = call_user_func_array($validateRules, [$value]);
1732
        } elseif ($validateRules) {
1733
            $validator = new Validator($validateRules);
1734
            $valid = $validator->validate($value);
1735
            $error = 'pulsar.validation.'.$validator->getFailingRule();
1736
        }
1737
1738
        if (!$valid) {
1739
            $params = [
1740
                'field' => $name,
1741
                'field_name' => $this->getPropertyTitle($name),
1742
            ];
1743
            $this->getErrors()->add($error, $params);
1744
        }
1745
1746
        return [$valid, $value];
1747
    }
1748
1749
    /**
1750
     * Checks if a value is unique for a property.
1751
     *
1752
     * @param string $name  property name
1753
     * @param mixed  $value
1754
     */
1755
    private function checkUniqueness(string $name, $value): bool
1756
    {
1757
        $n = static::query()->where([$name => $value])->count();
1758
        if ($n > 0) {
1759
            $params = [
1760
                'field' => $name,
1761
                'field_name' => $this->getPropertyTitle($name),
1762
            ];
1763
            $this->getErrors()->add('pulsar.validation.unique', $params);
1764
1765
            return false;
1766
        }
1767
1768
        return true;
1769
    }
1770
1771
    /**
1772
     * Gets the humanized name of a property.
1773
     *
1774
     * @param string $name property name
1775
     */
1776
    private function getPropertyTitle(string $name): string
1777
    {
1778
        // look up the property from the translator first
1779
        if ($translator = $this->getErrors()->getTranslator()) {
1780
            $k = 'pulsar.properties.'.static::modelName().'.'.$name;
1781
            $title = $translator->translate($k);
1782
            if ($title != $k) {
1783
                return $title;
1784
            }
1785
        }
1786
1787
        // otherwise just attempt to title-ize the property name
1788
        return Inflector::get()->titleize($name);
1789
    }
1790
}
1791