Completed
Push — master ( 477b17...1ec9e1 )
by Jared
01:32
created

Model::valid()   A

Complexity

Conditions 4
Paths 6

Size

Total Lines 21

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 21
rs 9.584
c 0
b 0
f 0
cc 4
nc 6
nop 0
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] = Type::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\Type::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 = Type::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\Type::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 the definition of all model properties.
573
     */
574
    public static function getProperties(): Definition
575
    {
576
        $properties = [];
577
        foreach (static::$properties as $k => $property) {
578
            $properties[$k] = new Property($property);
579
        }
580
581
        return new Definition($properties);
582
    }
583
584
    /**
585
     * Gets the definition of a specific property.
586
     *
587
     * @param string $property property to lookup
588
     */
589
    public static function getProperty(string $property): ?Property
590
    {
591
        return self::getProperties()->get($property);
592
    }
593
594
    /**
595
     * Gets the names of the model ID properties.
596
     */
597
    public static function getIDProperties(): array
598
    {
599
        return static::$ids;
600
    }
601
602
    /**
603
     * Checks if the model has a property.
604
     *
605
     * @param string $property property
606
     *
607
     * @return bool has property
608
     */
609
    public static function hasProperty(string $property): bool
610
    {
611
        return self::getProperties()->has($property);
612
    }
613
614
    /**
615
     * Gets the mutator method name for a given proeprty name.
616
     * Looks for methods in the form of `setPropertyValue`.
617
     * i.e. the mutator for `last_name` would be `setLastNameValue`.
618
     *
619
     * @param string $property property
620
     *
621
     * @return string|null method name if it exists
622
     */
623
    public static function getMutator(string $property): ?string
624
    {
625
        $class = get_called_class();
626
627
        $k = $class.':'.$property;
628
        if (!array_key_exists($k, self::$mutators)) {
629
            $inflector = Inflector::get();
630
            $method = 'set'.$inflector->camelize($property).'Value';
631
632
            if (!method_exists($class, $method)) {
633
                $method = null;
634
            }
635
636
            self::$mutators[$k] = $method;
637
        }
638
639
        return self::$mutators[$k];
640
    }
641
642
    /**
643
     * Gets the accessor method name for a given proeprty name.
644
     * Looks for methods in the form of `getPropertyValue`.
645
     * i.e. the accessor for `last_name` would be `getLastNameValue`.
646
     *
647
     * @param string $property property
648
     *
649
     * @return string|null method name if it exists
650
     */
651
    public static function getAccessor(string $property): ?string
652
    {
653
        $class = get_called_class();
654
655
        $k = $class.':'.$property;
656
        if (!array_key_exists($k, self::$accessors)) {
657
            $inflector = Inflector::get();
658
            $method = 'get'.$inflector->camelize($property).'Value';
659
660
            if (!method_exists($class, $method)) {
661
                $method = null;
662
            }
663
664
            self::$accessors[$k] = $method;
665
        }
666
667
        return self::$accessors[$k];
668
    }
669
670
    /////////////////////////////
671
    // CRUD Operations
672
    /////////////////////////////
673
674
    /**
675
     * Gets the tablename for storing this model.
676
     */
677
    public function getTablename(): string
678
    {
679
        $inflector = Inflector::get();
680
681
        return $inflector->camelize($inflector->pluralize(static::modelName()));
682
    }
683
684
    /**
685
     * Gets the ID of the connection in the connection manager
686
     * that stores this model.
687
     */
688
    public function getConnection(): ?string
689
    {
690
        return null;
691
    }
692
693
    protected function usesTransactions(): bool
694
    {
695
        return false;
696
    }
697
698
    /**
699
     * Saves the model.
700
     *
701
     * @return bool true when the operation was successful
702
     */
703
    public function save(): bool
704
    {
705
        if (!$this->hasId) {
706
            return $this->create();
707
        }
708
709
        return $this->set();
710
    }
711
712
    /**
713
     * Saves the model. Throws an exception when the operation fails.
714
     *
715
     * @throws ModelException when the model cannot be saved
716
     */
717
    public function saveOrFail()
718
    {
719
        if (!$this->save()) {
720
            $msg = 'Failed to save '.static::modelName();
721
            if ($validationErrors = $this->getErrors()->all()) {
722
                $msg .= ': '.implode(', ', $validationErrors);
723
            }
724
725
            throw new ModelException($msg);
726
        }
727
    }
728
729
    /**
730
     * Creates a new model.
731
     *
732
     * @param array $data optional key-value properties to set
733
     *
734
     * @return bool true when the operation was successful
735
     *
736
     * @throws BadMethodCallException when called on an existing model
737
     */
738
    public function create(array $data = []): bool
739
    {
740
        if ($this->hasId) {
741
            throw new BadMethodCallException('Cannot call create() on an existing model');
742
        }
743
744
        // mass assign values passed into create()
745
        $this->setValues($data);
746
747
        // clear any previous errors
748
        $this->getErrors()->clear();
749
750
        // start a DB transaction if needed
751
        $usesTransactions = $this->usesTransactions();
752
        if ($usesTransactions) {
753
            self::$driver->startTransaction($this->getConnection());
754
        }
755
756
        // dispatch the model.creating event
757
        if (!$this->performDispatch(ModelEvent::CREATING, $usesTransactions)) {
758
            return false;
759
        }
760
761
        $requiredProperties = [];
762
        foreach (self::getProperties()->all() as $name => $property) {
763
            // build a list of the required properties
764
            if ($property->isRequired()) {
765
                $requiredProperties[] = $name;
766
            }
767
768
            // add in default values
769
            if (!array_key_exists($name, $this->_unsaved) && isset($property['default'])) {
770
                $this->_unsaved[$name] = $property->getDefault();
771
            }
772
        }
773
774
        // validate the values being saved
775
        $validated = true;
776
        $insertArray = [];
777
        foreach ($this->_unsaved as $name => $value) {
778
            // exclude if value does not map to a property
779
            if (!self::getProperties()->has($name)) {
780
                continue;
781
            }
782
783
            $property = self::getProperty($name);
784
785
            // cannot insert immutable values
786
            // (unless using the default value)
787
            if ($property->isImmutable() && $value !== $property->getDefault()) {
788
                continue;
789
            }
790
791
            $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 783 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...
792
            $insertArray[$name] = $value;
793
        }
794
795
        // check for required fields
796
        foreach ($requiredProperties as $name) {
797
            if (!isset($insertArray[$name])) {
798
                $params = [
799
                    'field' => $name,
800
                    'field_name' => $this->getPropertyTitle($name),
801
                ];
802
                $this->getErrors()->add('pulsar.validation.required', $params);
803
804
                $validated = false;
805
            }
806
        }
807
808
        if (!$validated) {
809
            // when validations fail roll back any database transaction
810
            if ($usesTransactions) {
811
                self::$driver->rollBackTransaction($this->getConnection());
812
            }
813
814
            return false;
815
        }
816
817
        $created = self::$driver->createModel($this, $insertArray);
818
819
        if ($created) {
820
            // determine the model's new ID
821
            $this->getNewID();
822
823
            // store the persisted values to the in-memory cache
824
            $this->_unsaved = [];
825
            $this->refreshWith(array_replace($this->idValues, $insertArray));
826
827
            // dispatch the model.created event
828
            if (!$this->performDispatch(ModelEvent::CREATED, $usesTransactions)) {
829
                return false;
830
            }
831
        }
832
833
        // commit the transaction, if used
834
        if ($usesTransactions) {
835
            self::$driver->commitTransaction($this->getConnection());
836
        }
837
838
        return $created;
839
    }
840
841
    /**
842
     * Ignores unsaved values when fetching the next value.
843
     *
844
     * @return $this
845
     */
846
    public function ignoreUnsaved()
847
    {
848
        $this->ignoreUnsaved = true;
849
850
        return $this;
851
    }
852
853
    /**
854
     * Fetches property values from the model.
855
     *
856
     * This method looks up values in this order:
857
     * IDs, local cache, unsaved values, storage layer, defaults
858
     *
859
     * @param array $properties list of property names to fetch values of
860
     */
861
    public function get(array $properties): array
862
    {
863
        // load the values from the IDs and local model cache
864
        $values = array_replace($this->ids(), $this->_values);
865
866
        // unless specified, use any unsaved values
867
        $ignoreUnsaved = $this->ignoreUnsaved;
868
        $this->ignoreUnsaved = false;
869
870
        if (!$ignoreUnsaved) {
871
            $values = array_replace($values, $this->_unsaved);
872
        }
873
874
        // see if there are any model properties that do not exist.
875
        // when true then this means the model needs to be hydrated
876
        // NOTE: only looking at model properties and excluding dynamic/non-existent properties
877
        $modelProperties = self::getProperties()->propertyNames();
878
        $numMissing = count(array_intersect($modelProperties, array_diff($properties, array_keys($values))));
879
880
        if ($numMissing > 0 && !$this->loaded) {
881
            // load the model from the storage layer, if needed
882
            $this->refresh();
883
884
            $values = array_replace($values, $this->_values);
885
886
            if (!$ignoreUnsaved) {
887
                $values = array_replace($values, $this->_unsaved);
888
            }
889
        }
890
891
        // build a key-value map of the requested properties
892
        $return = [];
893
        foreach ($properties as $k) {
894
            $return[$k] = $this->getValue($k, $values);
895
        }
896
897
        return $return;
898
    }
899
900
    /**
901
     * Gets a property value from the model.
902
     *
903
     * Values are looked up in this order:
904
     *  1. unsaved values
905
     *  2. local values
906
     *  3. default value
907
     *  4. null
908
     *
909
     * @param string $property
910
     *
911
     * @return mixed
912
     */
913
    protected function getValue($property, array $values)
914
    {
915
        $value = null;
916
917
        if (array_key_exists($property, $values)) {
918
            $value = $values[$property];
919
        } elseif (static::hasProperty($property)) {
920
            $value = $this->_values[$property] = self::getProperty($property)->getDefault();
921
        }
922
923
        // call any accessors
924
        if ($accessor = self::getAccessor($property)) {
925
            $value = $this->$accessor($value);
926
        }
927
928
        return $value;
929
    }
930
931
    /**
932
     * Populates a newly created model with its ID.
933
     */
934
    protected function getNewID()
935
    {
936
        $ids = [];
937
        $namedIds = [];
938
        foreach (static::$ids as $k) {
939
            // attempt use the supplied value if the ID property is mutable
940
            $property = static::getProperty($k);
941
            if (!$property->isImmutable() && isset($this->_unsaved[$k])) {
942
                $id = $this->_unsaved[$k];
943
            } else {
944
                $id = self::$driver->getCreatedID($this, $k);
945
            }
946
947
            $ids[] = $id;
948
            $namedIds[$k] = $id;
949
        }
950
951
        $this->hasId = true;
952
        $this->idValues = $namedIds;
953
    }
954
955
    /**
956
     * Sets a collection values on the model from an untrusted input.
957
     *
958
     * @param array $values
959
     *
960
     * @throws MassAssignmentException when assigning a value that is protected or not whitelisted
961
     *
962
     * @return $this
963
     */
964
    public function setValues($values)
965
    {
966
        // check if the model has a mass assignment whitelist
967
        $permitted = (property_exists($this, 'permitted')) ? static::$permitted : false;
968
969
        // if no whitelist, then check for a blacklist
970
        $protected = (!is_array($permitted) && property_exists($this, 'protected')) ? static::$protected : false;
971
972
        foreach ($values as $k => $value) {
973
            // check for mass assignment violations
974
            if (($permitted && !in_array($k, $permitted)) ||
975
                ($protected && in_array($k, $protected))) {
976
                throw new MassAssignmentException("Mass assignment of $k on ".static::modelName().' is not allowed');
977
            }
978
979
            $this->$k = $value;
980
        }
981
982
        return $this;
983
    }
984
985
    /**
986
     * Converts the model to an array.
987
     */
988
    public function toArray(): array
989
    {
990
        // build the list of properties to retrieve
991
        $properties = self::getProperties()->propertyNames();
992
993
        // remove any hidden properties
994
        $hide = (property_exists($this, 'hidden')) ? static::$hidden : [];
995
        $properties = array_diff($properties, $hide);
996
997
        // add any appended properties
998
        $append = (property_exists($this, 'appended')) ? static::$appended : [];
999
        $properties = array_merge($properties, $append);
1000
1001
        // get the values for the properties
1002
        $result = $this->get($properties);
1003
1004
        foreach ($result as $k => &$value) {
1005
            // convert any models to arrays
1006
            if ($value instanceof self) {
1007
                $value = $value->toArray();
1008
            }
1009
        }
1010
1011
        return $result;
1012
    }
1013
1014
    /**
1015
     * Updates the model.
1016
     *
1017
     * @param array $data optional key-value properties to set
1018
     *
1019
     * @return bool true when the operation was successful
1020
     *
1021
     * @throws BadMethodCallException when not called on an existing model
1022
     */
1023
    public function set(array $data = []): bool
1024
    {
1025
        if (!$this->hasId) {
1026
            throw new BadMethodCallException('Can only call set() on an existing model');
1027
        }
1028
1029
        // mass assign values passed into set()
1030
        $this->setValues($data);
1031
1032
        // clear any previous errors
1033
        $this->getErrors()->clear();
1034
1035
        // not updating anything?
1036
        if (0 == count($this->_unsaved)) {
1037
            return true;
1038
        }
1039
1040
        // start a DB transaction if needed
1041
        $usesTransactions = $this->usesTransactions();
1042
        if ($usesTransactions) {
1043
            self::$driver->startTransaction($this->getConnection());
1044
        }
1045
1046
        // dispatch the model.updating event
1047
        if (!$this->performDispatch(ModelEvent::UPDATING, $usesTransactions)) {
1048
            return false;
1049
        }
1050
1051
        // validate the values being saved
1052
        $validated = true;
1053
        $updateArray = [];
1054
        foreach ($this->_unsaved as $name => $value) {
1055
            // exclude if value does not map to a property
1056
            if (!self::getProperties()->has($name)) {
1057
                continue;
1058
            }
1059
1060
            $property = self::getProperty($name);
1061
1062
            // can only modify mutable properties
1063
            if (!$property->isMutable()) {
1064
                continue;
1065
            }
1066
1067
            $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 1060 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...
1068
            $updateArray[$name] = $value;
1069
        }
1070
1071
        if (!$validated) {
1072
            // when validations fail roll back any database transaction
1073
            if ($usesTransactions) {
1074
                self::$driver->rollBackTransaction($this->getConnection());
1075
            }
1076
1077
            return false;
1078
        }
1079
1080
        $updated = self::$driver->updateModel($this, $updateArray);
1081
1082
        if ($updated) {
1083
            // store the persisted values to the in-memory cache
1084
            $this->_unsaved = [];
1085
            $this->refreshWith(array_replace($this->_values, $updateArray));
1086
1087
            // dispatch the model.updated event
1088
            if (!$this->performDispatch(ModelEvent::UPDATED, $usesTransactions)) {
1089
                return false;
1090
            }
1091
        }
1092
1093
        // commit the transaction, if used
1094
        if ($usesTransactions) {
1095
            self::$driver->commitTransaction($this->getConnection());
1096
        }
1097
1098
        return $updated;
1099
    }
1100
1101
    /**
1102
     * Delete the model.
1103
     *
1104
     * @return bool true when the operation was successful
1105
     */
1106
    public function delete(): bool
1107
    {
1108
        if (!$this->hasId) {
1109
            throw new BadMethodCallException('Can only call delete() on an existing model');
1110
        }
1111
1112
        // clear any previous errors
1113
        $this->getErrors()->clear();
1114
1115
        // start a DB transaction if needed
1116
        $usesTransactions = $this->usesTransactions();
1117
        if ($usesTransactions) {
1118
            self::$driver->startTransaction($this->getConnection());
1119
        }
1120
1121
        // dispatch the model.deleting event
1122
        if (!$this->performDispatch(ModelEvent::DELETING, $usesTransactions)) {
1123
            return false;
1124
        }
1125
1126
        // perform a hard (default) or soft delete
1127
        $hardDelete = true;
1128
        if (property_exists($this, 'softDelete')) {
1129
            $t = time();
1130
            $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...
1131
            $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...
1132
            $deleted = self::$driver->updateModel($this, ['deleted_at' => $t]);
1133
            $hardDelete = false;
1134
        } else {
1135
            $deleted = self::$driver->deleteModel($this);
1136
        }
1137
1138
        if ($deleted) {
1139
            // dispatch the model.deleted event
1140
            if (!$this->performDispatch(ModelEvent::DELETED, $usesTransactions)) {
1141
                return false;
1142
            }
1143
1144
            if ($hardDelete) {
1145
                $this->_persisted = false;
1146
            }
1147
        }
1148
1149
        // commit the transaction, if used
1150
        if ($usesTransactions) {
1151
            self::$driver->commitTransaction($this->getConnection());
1152
        }
1153
1154
        return $deleted;
1155
    }
1156
1157
    /**
1158
     * Restores a soft-deleted model.
1159
     */
1160
    public function restore(): bool
1161
    {
1162
        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...
1163
            throw new BadMethodCallException('Can only call restore() on a soft-deleted model');
1164
        }
1165
1166
        // start a DB transaction if needed
1167
        $usesTransactions = $this->usesTransactions();
1168
        if ($usesTransactions) {
1169
            self::$driver->startTransaction($this->getConnection());
1170
        }
1171
1172
        // dispatch the model.updating event
1173
        if (!$this->performDispatch(ModelEvent::UPDATING, $usesTransactions)) {
1174
            return false;
1175
        }
1176
1177
        $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...
1178
        $restored = self::$driver->updateModel($this, ['deleted_at' => null]);
1179
1180
        if ($restored) {
1181
            // dispatch the model.updated event
1182
            if (!$this->performDispatch(ModelEvent::UPDATED, $usesTransactions)) {
1183
                return false;
1184
            }
1185
        }
1186
1187
        // commit the transaction, if used
1188
        if ($usesTransactions) {
1189
            self::$driver->commitTransaction($this->getConnection());
1190
        }
1191
1192
        return $restored;
1193
    }
1194
1195
    /**
1196
     * Checks if the model has been deleted.
1197
     */
1198
    public function isDeleted(): bool
1199
    {
1200
        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...
1201
            return true;
1202
        }
1203
1204
        return !$this->_persisted;
1205
    }
1206
1207
    /////////////////////////////
1208
    // Queries
1209
    /////////////////////////////
1210
1211
    /**
1212
     * Generates a new query instance.
1213
     */
1214
    public static function query(): Query
1215
    {
1216
        // Create a new model instance for the query to ensure
1217
        // that the model's initialize() method gets called.
1218
        // Otherwise, the property definitions will be incomplete.
1219
        $model = new static();
1220
        $query = new Query($model);
1221
1222
        // scope soft-deleted models to only include non-deleted models
1223
        if (property_exists($model, 'softDelete')) {
1224
            $query->where('deleted_at IS NOT NULL');
1225
        }
1226
1227
        return $query;
1228
    }
1229
1230
    /**
1231
     * Generates a new query instance that includes soft-deleted models.
1232
     */
1233
    public static function withDeleted(): Query
1234
    {
1235
        // Create a new model instance for the query to ensure
1236
        // that the model's initialize() method gets called.
1237
        // Otherwise, the property definitions will be incomplete.
1238
        $model = new static();
1239
1240
        return new Query($model);
1241
    }
1242
1243
    /**
1244
     * Finds a single instance of a model given it's ID.
1245
     *
1246
     * @param mixed $id
1247
     *
1248
     * @return static|null
1249
     */
1250
    public static function find($id): ?self
1251
    {
1252
        $ids = [];
1253
        $id = (array) $id;
1254
        foreach (static::$ids as $j => $k) {
1255
            if (isset($id[$j])) {
1256
                $ids[$k] = $id[$j];
1257
            }
1258
        }
1259
1260
        // malformed ID
1261
        if (count($ids) < count(static::$ids)) {
1262
            return null;
1263
        }
1264
1265
        return static::query()->where($ids)->first();
1266
    }
1267
1268
    /**
1269
     * Finds a single instance of a model given it's ID or throws an exception.
1270
     *
1271
     * @param mixed $id
1272
     *
1273
     * @return static
1274
     *
1275
     * @throws ModelNotFoundException when a model could not be found
1276
     */
1277
    public static function findOrFail($id): self
1278
    {
1279
        $model = static::find($id);
1280
        if (!$model) {
1281
            throw new ModelNotFoundException('Could not find the requested '.static::modelName());
1282
        }
1283
1284
        return $model;
1285
    }
1286
1287
    /**
1288
     * Tells if this model instance has been persisted to the data layer.
1289
     *
1290
     * NOTE: this does not actually perform a check with the data layer
1291
     */
1292
    public function persisted(): bool
1293
    {
1294
        return $this->_persisted;
1295
    }
1296
1297
    /**
1298
     * Loads the model from the storage layer.
1299
     *
1300
     * @return $this
1301
     */
1302
    public function refresh()
1303
    {
1304
        if (!$this->hasId) {
1305
            return $this;
1306
        }
1307
1308
        $values = self::$driver->loadModel($this);
1309
1310
        if (!is_array($values)) {
1311
            return $this;
1312
        }
1313
1314
        // clear any relations
1315
        $this->_relationships = [];
1316
1317
        return $this->refreshWith($values);
1318
    }
1319
1320
    /**
1321
     * Loads values into the model.
1322
     *
1323
     * @param array $values values
1324
     *
1325
     * @return $this
1326
     */
1327
    public function refreshWith(array $values)
1328
    {
1329
        // type cast the values
1330
        foreach ($values as $k => &$value) {
1331
            if ($property = static::getProperty($k)) {
1332
                $value = Type::cast($property, $value);
1333
            }
1334
        }
1335
1336
        $this->loaded = true;
1337
        $this->_persisted = true;
1338
        $this->_values = $values;
1339
1340
        return $this;
1341
    }
1342
1343
    /**
1344
     * Clears the cache for this model.
1345
     *
1346
     * @return $this
1347
     */
1348
    public function clearCache()
1349
    {
1350
        $this->loaded = false;
1351
        $this->_unsaved = [];
1352
        $this->_values = [];
1353
        $this->_relationships = [];
1354
1355
        return $this;
1356
    }
1357
1358
    /////////////////////////////
1359
    // Relationships
1360
    /////////////////////////////
1361
1362
    /**
1363
     * @deprecated
1364
     *
1365
     * Gets the model(s) for a relationship
1366
     *
1367
     * @param string $k property
1368
     *
1369
     * @throws InvalidArgumentException when the relationship manager cannot be created
1370
     *
1371
     * @return Model|array|null
1372
     */
1373
    public function relation(string $k)
1374
    {
1375
        if (!array_key_exists($k, $this->_relationships)) {
1376
            $relation = $this->getRelationshipManager($k);
1377
            $this->_relationships[$k] = $relation->getResults();
1378
        }
1379
1380
        return $this->_relationships[$k];
1381
    }
1382
1383
    /**
1384
     * @deprecated
1385
     *
1386
     * Sets the model for a one-to-one relationship (has-one or belongs-to)
1387
     *
1388
     * @return $this
1389
     */
1390
    public function setRelation(string $k, Model $model)
1391
    {
1392
        $this->$k = $model->id();
1393
        $this->_relationships[$k] = $model;
1394
1395
        return $this;
1396
    }
1397
1398
    /**
1399
     * @deprecated
1400
     *
1401
     * Sets the model for a one-to-many relationship
1402
     *
1403
     * @return $this
1404
     */
1405
    public function setRelationCollection(string $k, iterable $models)
1406
    {
1407
        $this->_relationships[$k] = $models;
1408
1409
        return $this;
1410
    }
1411
1412
    /**
1413
     * Sets the model for a one-to-one relationship (has-one or belongs-to) as null.
1414
     *
1415
     * @return $this
1416
     */
1417
    public function clearRelation(string $k)
1418
    {
1419
        $this->$k = null;
1420
        $this->_relationships[$k] = null;
1421
1422
        return $this;
1423
    }
1424
1425
    /**
1426
     * Builds a relationship manager object for a given property.
1427
     *
1428
     * @param array $k
1429
     *
1430
     * @throws InvalidArgumentException when the relationship manager cannot be created
1431
     */
1432
    public function getRelationshipManager(string $k): Relation
1433
    {
1434
        $property = static::getProperty($k);
0 ignored issues
show
Bug introduced by
It seems like $k defined by parameter $k on line 1432 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...
1435
        if (!$property) {
1436
            throw new InvalidArgumentException('Property "'.$k.'" does not exist.');
1437
        }
1438
1439
        $relationModelClass = $property->getRelation();
1440
        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...
1441
            throw new InvalidArgumentException('Property "'.$k.'" does not have a relationship.');
1442
        }
1443
1444
        $foreignKey = $property->getForeignKey();
1445
        $localKey = $property->getLocalKey();
1446
        $relationType = $property->getRelationType();
1447
1448
        if (self::RELATIONSHIP_HAS_ONE == $relationType) {
1449
            return $this->hasOne($relationModelClass, $foreignKey, $localKey);
1450
        }
1451
1452
        if (self::RELATIONSHIP_HAS_MANY == $relationType) {
1453
            return $this->hasMany($relationModelClass, $foreignKey, $localKey);
1454
        }
1455
1456
        if (self::RELATIONSHIP_BELONGS_TO == $relationType) {
1457
            return $this->belongsTo($relationModelClass, $foreignKey, $localKey);
1458
        }
1459
1460
        if (self::RELATIONSHIP_BELONGS_TO_MANY == $relationType) {
1461
            $pivotTable = $property->getPivotTablename();
1462
1463
            return $this->belongsToMany($relationModelClass, $pivotTable, $foreignKey, $localKey);
1464
        }
1465
1466
        throw new InvalidArgumentException('Relationship type on "'.$k.'" property not supported: '.$relationType);
1467
    }
1468
1469
    /**
1470
     * Creates the parent side of a One-To-One relationship.
1471
     *
1472
     * @param string $model      foreign model class
1473
     * @param string $foreignKey identifying key on foreign model
1474
     * @param string $localKey   identifying key on local model
1475
     */
1476
    public function hasOne($model, $foreignKey = '', $localKey = ''): HasOne
1477
    {
1478
        return new HasOne($this, $localKey, $model, $foreignKey);
1479
    }
1480
1481
    /**
1482
     * Creates the child side of a One-To-One or One-To-Many relationship.
1483
     *
1484
     * @param string $model      foreign model class
1485
     * @param string $foreignKey identifying key on foreign model
1486
     * @param string $localKey   identifying key on local model
1487
     */
1488
    public function belongsTo($model, $foreignKey = '', $localKey = ''): BelongsTo
1489
    {
1490
        return new BelongsTo($this, $localKey, $model, $foreignKey);
1491
    }
1492
1493
    /**
1494
     * Creates the parent side of a Many-To-One or Many-To-Many relationship.
1495
     *
1496
     * @param string $model      foreign model class
1497
     * @param string $foreignKey identifying key on foreign model
1498
     * @param string $localKey   identifying key on local model
1499
     */
1500
    public function hasMany($model, $foreignKey = '', $localKey = ''): HasMany
1501
    {
1502
        return new HasMany($this, $localKey, $model, $foreignKey);
1503
    }
1504
1505
    /**
1506
     * Creates the child side of a Many-To-Many relationship.
1507
     *
1508
     * @param string $model      foreign model class
1509
     * @param string $tablename  pivot table name
1510
     * @param string $foreignKey identifying key on foreign model
1511
     * @param string $localKey   identifying key on local model
1512
     */
1513
    public function belongsToMany($model, $tablename = '', $foreignKey = '', $localKey = ''): BelongsToMany
1514
    {
1515
        return new BelongsToMany($this, $localKey, $tablename, $model, $foreignKey);
1516
    }
1517
1518
    /////////////////////////////
1519
    // Events
1520
    /////////////////////////////
1521
1522
    /**
1523
     * Gets the event dispatcher.
1524
     */
1525
    public static function getDispatcher($ignoreCache = false): EventDispatcher
1526
    {
1527
        $class = get_called_class();
1528
        if ($ignoreCache || !isset(self::$dispatchers[$class])) {
1529
            self::$dispatchers[$class] = new EventDispatcher();
1530
        }
1531
1532
        return self::$dispatchers[$class];
1533
    }
1534
1535
    /**
1536
     * Subscribes to a listener to an event.
1537
     *
1538
     * @param string $event    event name
1539
     * @param int    $priority optional priority, higher #s get called first
1540
     */
1541
    public static function listen(string $event, callable $listener, int $priority = 0)
1542
    {
1543
        static::getDispatcher()->addListener($event, $listener, $priority);
1544
    }
1545
1546
    /**
1547
     * Adds a listener to the model.creating and model.updating events.
1548
     */
1549
    public static function saving(callable $listener, int $priority = 0)
1550
    {
1551
        static::listen(ModelEvent::CREATING, $listener, $priority);
1552
        static::listen(ModelEvent::UPDATING, $listener, $priority);
1553
    }
1554
1555
    /**
1556
     * Adds a listener to the model.created and model.updated events.
1557
     */
1558
    public static function saved(callable $listener, int $priority = 0)
1559
    {
1560
        static::listen(ModelEvent::CREATED, $listener, $priority);
1561
        static::listen(ModelEvent::UPDATED, $listener, $priority);
1562
    }
1563
1564
    /**
1565
     * Adds a listener to the model.creating event.
1566
     */
1567
    public static function creating(callable $listener, int $priority = 0)
1568
    {
1569
        static::listen(ModelEvent::CREATING, $listener, $priority);
1570
    }
1571
1572
    /**
1573
     * Adds a listener to the model.created event.
1574
     */
1575
    public static function created(callable $listener, int $priority = 0)
1576
    {
1577
        static::listen(ModelEvent::CREATED, $listener, $priority);
1578
    }
1579
1580
    /**
1581
     * Adds a listener to the model.updating event.
1582
     */
1583
    public static function updating(callable $listener, int $priority = 0)
1584
    {
1585
        static::listen(ModelEvent::UPDATING, $listener, $priority);
1586
    }
1587
1588
    /**
1589
     * Adds a listener to the model.updated event.
1590
     */
1591
    public static function updated(callable $listener, int $priority = 0)
1592
    {
1593
        static::listen(ModelEvent::UPDATED, $listener, $priority);
1594
    }
1595
1596
    /**
1597
     * Adds a listener to the model.deleting event.
1598
     */
1599
    public static function deleting(callable $listener, int $priority = 0)
1600
    {
1601
        static::listen(ModelEvent::DELETING, $listener, $priority);
1602
    }
1603
1604
    /**
1605
     * Adds a listener to the model.deleted event.
1606
     */
1607
    public static function deleted(callable $listener, int $priority = 0)
1608
    {
1609
        static::listen(ModelEvent::DELETED, $listener, $priority);
1610
    }
1611
1612
    /**
1613
     * Dispatches the given event and checks if it was successful.
1614
     *
1615
     * @return bool true if the events were successfully propagated
1616
     */
1617
    private function performDispatch(string $eventName, bool $usesTransactions): bool
1618
    {
1619
        $event = new ModelEvent($this);
1620
        static::getDispatcher()->dispatch($event, $eventName);
1621
1622
        // when listeners fail roll back any database transaction
1623
        if ($event->isPropagationStopped()) {
1624
            if ($usesTransactions) {
1625
                self::$driver->rollBackTransaction($this->getConnection());
1626
            }
1627
1628
            return false;
1629
        }
1630
1631
        return true;
1632
    }
1633
1634
    /////////////////////////////
1635
    // Validation
1636
    /////////////////////////////
1637
1638
    /**
1639
     * Gets the error stack for this model.
1640
     */
1641
    public function getErrors(): Errors
1642
    {
1643
        if (!$this->errors) {
1644
            $this->errors = new Errors();
1645
        }
1646
1647
        return $this->errors;
1648
    }
1649
1650
    /**
1651
     * Checks if the model in its current state is valid.
1652
     */
1653
    public function valid(): bool
1654
    {
1655
        // clear any previous errors
1656
        $this->getErrors()->clear();
1657
1658
        // run the validator against the model values
1659
        $values = $this->_unsaved + $this->_values;
1660
1661
        $validated = true;
1662
        foreach ($values as $k => $v) {
1663
            $property = static::getProperty($k);
1664
            $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 1663 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...
1665
        }
1666
1667
        // add back any modified unsaved values
1668
        foreach (array_keys($this->_unsaved) as $k) {
1669
            $this->_unsaved[$k] = $values[$k];
1670
        }
1671
1672
        return $validated;
1673
    }
1674
1675
    /**
1676
     * Validates and marshals a value to storage.
1677
     *
1678
     * @param Property $property property definition
1679
     * @param string   $name     property name
1680
     * @param mixed    $value
1681
     */
1682
    private function filterAndValidate(Property $property, string $name, &$value): bool
1683
    {
1684
        // assume empty string is a null value for properties
1685
        // that are marked as optionally-null
1686
        if ($property->isNullable() && ('' === $value || null === $value)) {
1687
            $value = null;
1688
1689
            return true;
1690
        }
1691
1692
        // validate
1693
        list($valid, $value) = $this->validateValue($property, $name, $value);
1694
1695
        // unique?
1696
        if ($valid && $property->isUnique() && (!$this->hasId || $value != $this->ignoreUnsaved()->$name)) {
1697
            $valid = $this->checkUniqueness($name, $value);
1698
        }
1699
1700
        return $valid;
1701
    }
1702
1703
    /**
1704
     * Validates a value for a property.
1705
     *
1706
     * @param Property $property property definition
1707
     * @param string   $name     property name
1708
     * @param mixed    $value
1709
     */
1710
    private function validateValue(Property $property, string $name, $value): array
1711
    {
1712
        $valid = true;
1713
1714
        $error = 'pulsar.validation.failed';
1715
        $validateRules = $property->getValidationRules();
1716
        if (is_callable($validateRules)) {
1717
            $valid = call_user_func_array($validateRules, [$value]);
1718
        } elseif ($validateRules) {
1719
            $validator = new Validator($validateRules);
1720
            $valid = $validator->validate($value);
1721
            $error = 'pulsar.validation.'.$validator->getFailingRule();
1722
        }
1723
1724
        if (!$valid) {
1725
            $params = [
1726
                'field' => $name,
1727
                'field_name' => $this->getPropertyTitle($name),
1728
            ];
1729
            $this->getErrors()->add($error, $params);
1730
        }
1731
1732
        return [$valid, $value];
1733
    }
1734
1735
    /**
1736
     * Checks if a value is unique for a property.
1737
     *
1738
     * @param string $name  property name
1739
     * @param mixed  $value
1740
     */
1741
    private function checkUniqueness(string $name, $value): bool
1742
    {
1743
        $n = static::query()->where([$name => $value])->count();
1744
        if ($n > 0) {
1745
            $params = [
1746
                'field' => $name,
1747
                'field_name' => $this->getPropertyTitle($name),
1748
            ];
1749
            $this->getErrors()->add('pulsar.validation.unique', $params);
1750
1751
            return false;
1752
        }
1753
1754
        return true;
1755
    }
1756
1757
    /**
1758
     * Gets the humanized name of a property.
1759
     *
1760
     * @param string $name property name
1761
     */
1762
    private function getPropertyTitle(string $name): string
1763
    {
1764
        // look up the property from the translator first
1765
        if ($translator = $this->getErrors()->getTranslator()) {
1766
            $k = 'pulsar.properties.'.static::modelName().'.'.$name;
1767
            $title = $translator->translate($k);
1768
            if ($title != $k) {
1769
                return $title;
1770
            }
1771
        }
1772
1773
        // otherwise just attempt to title-ize the property name
1774
        return Inflector::get()->titleize($name);
1775
    }
1776
}
1777