Completed
Push — master ( c538dd...450182 )
by Jared
02:36 queued 01:12
created

Model::__construct()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 13

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 13
rs 9.8333
c 0
b 0
f 0
cc 2
nc 2
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
     * @deprecated
1428
     *
1429
     * Sets the model for a one-to-many relationship
1430
     *
1431
     * @return $this
1432
     */
1433
    public function setRelationCollection(string $k, iterable $models)
1434
    {
1435
        $this->_relationships[$k] = $models;
1436
1437
        return $this;
1438
    }
1439
1440
    /**
1441
     * Sets the model for a one-to-one relationship (has-one or belongs-to) as null.
1442
     *
1443
     * @return $this
1444
     */
1445
    public function clearRelation(string $k)
1446
    {
1447
        $this->$k = null;
1448
        $this->_relationships[$k] = null;
1449
1450
        return $this;
1451
    }
1452
1453
    /**
1454
     * Builds a relationship manager object for a given property.
1455
     *
1456
     * @param array $k
1457
     *
1458
     * @throws InvalidArgumentException when the relationship manager cannot be created
1459
     */
1460
    public function getRelationshipManager(string $k): Relation
1461
    {
1462
        $property = static::getProperty($k);
0 ignored issues
show
Bug introduced by
It seems like $k defined by parameter $k on line 1460 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...
1463
        if (!$property) {
1464
            throw new InvalidArgumentException('Property "'.$k.'" does not exist.');
1465
        }
1466
1467
        $relationModelClass = $property->getRelation();
1468
        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...
1469
            throw new InvalidArgumentException('Property "'.$k.'" does not have a relationship.');
1470
        }
1471
1472
        $foreignKey = $property->getForeignKey();
1473
        $localKey = $property->getLocalKey();
1474
        $relationType = $property->getRelationType();
1475
1476
        if (self::RELATIONSHIP_HAS_ONE == $relationType) {
1477
            return $this->hasOne($relationModelClass, $foreignKey, $localKey);
1478
        }
1479
1480
        if (self::RELATIONSHIP_HAS_MANY == $relationType) {
1481
            return $this->hasMany($relationModelClass, $foreignKey, $localKey);
1482
        }
1483
1484
        if (self::RELATIONSHIP_BELONGS_TO == $relationType) {
1485
            return $this->belongsTo($relationModelClass, $foreignKey, $localKey);
1486
        }
1487
1488
        if (self::RELATIONSHIP_BELONGS_TO_MANY == $relationType) {
1489
            $pivotTable = $property->getPivotTablename();
1490
1491
            return $this->belongsToMany($relationModelClass, $pivotTable, $foreignKey, $localKey);
1492
        }
1493
1494
        throw new InvalidArgumentException('Relationship type on "'.$k.'" property not supported: '.$relationType);
1495
    }
1496
1497
    /**
1498
     * Creates the parent side of a One-To-One relationship.
1499
     *
1500
     * @param string $model      foreign model class
1501
     * @param string $foreignKey identifying key on foreign model
1502
     * @param string $localKey   identifying key on local model
1503
     */
1504
    public function hasOne($model, $foreignKey = '', $localKey = ''): HasOne
1505
    {
1506
        return new HasOne($this, $localKey, $model, $foreignKey);
1507
    }
1508
1509
    /**
1510
     * Creates the child side of a One-To-One or One-To-Many relationship.
1511
     *
1512
     * @param string $model      foreign model class
1513
     * @param string $foreignKey identifying key on foreign model
1514
     * @param string $localKey   identifying key on local model
1515
     */
1516
    public function belongsTo($model, $foreignKey = '', $localKey = ''): BelongsTo
1517
    {
1518
        return new BelongsTo($this, $localKey, $model, $foreignKey);
1519
    }
1520
1521
    /**
1522
     * Creates the parent side of a Many-To-One or Many-To-Many relationship.
1523
     *
1524
     * @param string $model      foreign model class
1525
     * @param string $foreignKey identifying key on foreign model
1526
     * @param string $localKey   identifying key on local model
1527
     */
1528
    public function hasMany($model, $foreignKey = '', $localKey = ''): HasMany
1529
    {
1530
        return new HasMany($this, $localKey, $model, $foreignKey);
1531
    }
1532
1533
    /**
1534
     * Creates the child side of a Many-To-Many relationship.
1535
     *
1536
     * @param string $model      foreign model class
1537
     * @param string $tablename  pivot table name
1538
     * @param string $foreignKey identifying key on foreign model
1539
     * @param string $localKey   identifying key on local model
1540
     */
1541
    public function belongsToMany($model, $tablename = '', $foreignKey = '', $localKey = ''): BelongsToMany
1542
    {
1543
        return new BelongsToMany($this, $localKey, $tablename, $model, $foreignKey);
1544
    }
1545
1546
    /////////////////////////////
1547
    // Events
1548
    /////////////////////////////
1549
1550
    /**
1551
     * Gets the event dispatcher.
1552
     */
1553
    public static function getDispatcher($ignoreCache = false): EventDispatcher
1554
    {
1555
        $class = get_called_class();
1556
        if ($ignoreCache || !isset(self::$dispatchers[$class])) {
1557
            self::$dispatchers[$class] = new EventDispatcher();
1558
        }
1559
1560
        return self::$dispatchers[$class];
1561
    }
1562
1563
    /**
1564
     * Subscribes to a listener to an event.
1565
     *
1566
     * @param string $event    event name
1567
     * @param int    $priority optional priority, higher #s get called first
1568
     */
1569
    public static function listen(string $event, callable $listener, int $priority = 0)
1570
    {
1571
        static::getDispatcher()->addListener($event, $listener, $priority);
1572
    }
1573
1574
    /**
1575
     * Adds a listener to the model.creating and model.updating events.
1576
     */
1577
    public static function saving(callable $listener, int $priority = 0)
1578
    {
1579
        static::listen(ModelEvent::CREATING, $listener, $priority);
1580
        static::listen(ModelEvent::UPDATING, $listener, $priority);
1581
    }
1582
1583
    /**
1584
     * Adds a listener to the model.created and model.updated events.
1585
     */
1586
    public static function saved(callable $listener, int $priority = 0)
1587
    {
1588
        static::listen(ModelEvent::CREATED, $listener, $priority);
1589
        static::listen(ModelEvent::UPDATED, $listener, $priority);
1590
    }
1591
1592
    /**
1593
     * Adds a listener to the model.creating event.
1594
     */
1595
    public static function creating(callable $listener, int $priority = 0)
1596
    {
1597
        static::listen(ModelEvent::CREATING, $listener, $priority);
1598
    }
1599
1600
    /**
1601
     * Adds a listener to the model.created event.
1602
     */
1603
    public static function created(callable $listener, int $priority = 0)
1604
    {
1605
        static::listen(ModelEvent::CREATED, $listener, $priority);
1606
    }
1607
1608
    /**
1609
     * Adds a listener to the model.updating event.
1610
     */
1611
    public static function updating(callable $listener, int $priority = 0)
1612
    {
1613
        static::listen(ModelEvent::UPDATING, $listener, $priority);
1614
    }
1615
1616
    /**
1617
     * Adds a listener to the model.updated event.
1618
     */
1619
    public static function updated(callable $listener, int $priority = 0)
1620
    {
1621
        static::listen(ModelEvent::UPDATED, $listener, $priority);
1622
    }
1623
1624
    /**
1625
     * Adds a listener to the model.deleting event.
1626
     */
1627
    public static function deleting(callable $listener, int $priority = 0)
1628
    {
1629
        static::listen(ModelEvent::DELETING, $listener, $priority);
1630
    }
1631
1632
    /**
1633
     * Adds a listener to the model.deleted event.
1634
     */
1635
    public static function deleted(callable $listener, int $priority = 0)
1636
    {
1637
        static::listen(ModelEvent::DELETED, $listener, $priority);
1638
    }
1639
1640
    /**
1641
     * Dispatches the given event and checks if it was successful.
1642
     *
1643
     * @return bool true if the events were successfully propagated
1644
     */
1645
    private function performDispatch(string $eventName, bool $usesTransactions): bool
1646
    {
1647
        $event = new ModelEvent($this);
1648
        static::getDispatcher()->dispatch($event, $eventName);
1649
1650
        // when listeners fail roll back any database transaction
1651
        if ($event->isPropagationStopped()) {
1652
            if ($usesTransactions) {
1653
                self::$driver->rollBackTransaction($this->getConnection());
1654
            }
1655
1656
            return false;
1657
        }
1658
1659
        return true;
1660
    }
1661
1662
    /////////////////////////////
1663
    // Validation
1664
    /////////////////////////////
1665
1666
    /**
1667
     * Gets the error stack for this model.
1668
     */
1669
    public function getErrors(): Errors
1670
    {
1671
        if (!$this->errors) {
1672
            $this->errors = new Errors();
1673
        }
1674
1675
        return $this->errors;
1676
    }
1677
1678
    /**
1679
     * Checks if the model in its current state is valid.
1680
     */
1681
    public function valid(): bool
1682
    {
1683
        // clear any previous errors
1684
        $this->getErrors()->clear();
1685
1686
        // run the validator against the model values
1687
        $values = $this->_unsaved + $this->_values;
1688
1689
        $validated = true;
1690
        foreach ($values as $k => $v) {
1691
            $property = static::getProperty($k);
1692
            $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 1691 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...
1693
        }
1694
1695
        // add back any modified unsaved values
1696
        foreach (array_keys($this->_unsaved) as $k) {
1697
            $this->_unsaved[$k] = $values[$k];
1698
        }
1699
1700
        return $validated;
1701
    }
1702
1703
    /**
1704
     * Validates and marshals a value to storage.
1705
     *
1706
     * @param Property $property property definition
1707
     * @param string   $name     property name
1708
     * @param mixed    $value
1709
     */
1710
    private function filterAndValidate(Property $property, string $name, &$value): bool
1711
    {
1712
        // assume empty string is a null value for properties
1713
        // that are marked as optionally-null
1714
        if ($property->isNullable() && ('' === $value || null === $value)) {
1715
            $value = null;
1716
1717
            return true;
1718
        }
1719
1720
        // validate
1721
        list($valid, $value) = $this->validateValue($property, $name, $value);
1722
1723
        // unique?
1724
        if ($valid && $property->isUnique() && (!$this->hasId || $value != $this->ignoreUnsaved()->$name)) {
1725
            $valid = $this->checkUniqueness($name, $value);
1726
        }
1727
1728
        return $valid;
1729
    }
1730
1731
    /**
1732
     * Validates a value for a property.
1733
     *
1734
     * @param Property $property property definition
1735
     * @param string   $name     property name
1736
     * @param mixed    $value
1737
     */
1738
    private function validateValue(Property $property, string $name, $value): array
1739
    {
1740
        $valid = true;
1741
1742
        $error = 'pulsar.validation.failed';
1743
        $validateRules = $property->getValidationRules();
1744
        if (is_callable($validateRules)) {
1745
            $valid = call_user_func_array($validateRules, [$value]);
1746
        } elseif ($validateRules) {
1747
            $validator = new Validator($validateRules);
1748
            $valid = $validator->validate($value);
1749
            $error = 'pulsar.validation.'.$validator->getFailingRule();
1750
        }
1751
1752
        if (!$valid) {
1753
            $params = [
1754
                'field' => $name,
1755
                'field_name' => $this->getPropertyTitle($name),
1756
            ];
1757
            $this->getErrors()->add($error, $params);
1758
        }
1759
1760
        return [$valid, $value];
1761
    }
1762
1763
    /**
1764
     * Checks if a value is unique for a property.
1765
     *
1766
     * @param string $name  property name
1767
     * @param mixed  $value
1768
     */
1769
    private function checkUniqueness(string $name, $value): bool
1770
    {
1771
        $n = static::query()->where([$name => $value])->count();
1772
        if ($n > 0) {
1773
            $params = [
1774
                'field' => $name,
1775
                'field_name' => $this->getPropertyTitle($name),
1776
            ];
1777
            $this->getErrors()->add('pulsar.validation.unique', $params);
1778
1779
            return false;
1780
        }
1781
1782
        return true;
1783
    }
1784
1785
    /**
1786
     * Gets the humanized name of a property.
1787
     *
1788
     * @param string $name property name
1789
     */
1790
    private function getPropertyTitle(string $name): string
1791
    {
1792
        // look up the property from the translator first
1793
        if ($translator = $this->getErrors()->getTranslator()) {
1794
            $k = 'pulsar.properties.'.static::modelName().'.'.$name;
1795
            $title = $translator->translate($k);
1796
            if ($title != $k) {
1797
                return $title;
1798
            }
1799
        }
1800
1801
        // otherwise just attempt to title-ize the property name
1802
        return Inflector::get()->titleize($name);
1803
    }
1804
}
1805