Completed
Push — master ( 1ec9e1...9268d3 )
by Jared
01:31
created

Model::getErrors()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 8

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 8
rs 10
c 0
b 0
f 0
cc 2
nc 2
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\BelongsToMany;
24
use Pulsar\Relation\Relation;
25
use Pulsar\Relation\RelationFactory;
26
use Symfony\Component\EventDispatcher\EventDispatcher;
27
28
/**
29
 * Class Model.
30
 *
31
 * @method Query             where($where, $value = null, $condition = null)
32
 * @method Query             limit($limit)
33
 * @method Query             start($start)
34
 * @method Query             sort($sort)
35
 * @method Query             join($model, $column, $foreignKey)
36
 * @method Query             with($k)
37
 * @method Iterator          all()
38
 * @method array|static|null first($limit = 1)
39
 * @method int               count()
40
 * @method number            sum($property)
41
 * @method number            average($property)
42
 * @method number            max($property)
43
 * @method number            min($property)
44
 */
45
abstract class Model implements ArrayAccess
46
{
47
    const IMMUTABLE = 0;
48
    const MUTABLE_CREATE_ONLY = 1;
49
    const MUTABLE = 2;
50
51
    const TYPE_STRING = 'string';
52
    const TYPE_INTEGER = 'integer';
53
    const TYPE_FLOAT = 'float';
54
    const TYPE_BOOLEAN = 'boolean';
55
    const TYPE_DATE = 'date';
56
    const TYPE_OBJECT = 'object';
57
    const TYPE_ARRAY = 'array';
58
59
    const RELATIONSHIP_HAS_ONE = 'has_one';
60
    const RELATIONSHIP_HAS_MANY = 'has_many';
61
    const RELATIONSHIP_BELONGS_TO = 'belongs_to';
62
    const RELATIONSHIP_BELONGS_TO_MANY = 'belongs_to_many';
63
64
    const DEFAULT_ID_PROPERTY = 'id';
65
66
    /////////////////////////////
67
    // Model visible variables
68
    /////////////////////////////
69
70
    /**
71
     * List of model ID property names.
72
     *
73
     * @var array
74
     */
75
    protected static $ids = [self::DEFAULT_ID_PROPERTY];
76
77
    /**
78
     * Property definitions expressed as a key-value map with
79
     * property names as the keys.
80
     * i.e. ['enabled' => ['type' => Model::TYPE_BOOLEAN]].
81
     *
82
     * @var array
83
     */
84
    protected static $properties = [];
85
86
    /**
87
     * @var array
88
     */
89
    private static $dispatchers;
90
91
    /**
92
     * @var bool
93
     */
94
    private $hasId;
95
96
    /**
97
     * @var array
98
     */
99
    private $idValues;
100
101
    /**
102
     * @var array
103
     */
104
    protected $_values = [];
105
106
    /**
107
     * @var array
108
     */
109
    protected $_unsaved = [];
110
111
    /**
112
     * @var bool
113
     */
114
    protected $_persisted = false;
115
116
    /**
117
     * @var array
118
     */
119
    protected $_relationships = [];
120
121
    /**
122
     * @var bool
123
     */
124
    private $loaded = false;
125
126
    /**
127
     * @var Errors
128
     */
129
    private $errors;
130
131
    /**
132
     * @var bool
133
     */
134
    private $ignoreUnsaved;
135
136
    /////////////////////////////
137
    // Base model variables
138
    /////////////////////////////
139
140
    /**
141
     * @var array
142
     */
143
    private static $defaultIDProperty = [
144
        'type' => self::TYPE_INTEGER,
145
        'mutable' => self::IMMUTABLE,
146
    ];
147
148
    /**
149
     * @var array
150
     */
151
    private static $timestampProperties = [
152
        'created_at' => [
153
            'type' => self::TYPE_DATE,
154
            'validate' => 'timestamp|db_timestamp',
155
        ],
156
        'updated_at' => [
157
            'type' => self::TYPE_DATE,
158
            'validate' => 'timestamp|db_timestamp',
159
        ],
160
    ];
161
162
    /**
163
     * @var array
164
     */
165
    private static $softDeleteProperties = [
166
        'deleted_at' => [
167
            'type' => self::TYPE_DATE,
168
            'validate' => 'timestamp|db_timestamp',
169
            'null' => true,
170
        ],
171
    ];
172
173
    /**
174
     * @var array
175
     */
176
    private static $initialized = [];
177
178
    /**
179
     * @var DriverInterface
180
     */
181
    private static $driver;
182
183
    /**
184
     * @var array
185
     */
186
    private static $accessors = [];
187
188
    /**
189
     * @var array
190
     */
191
    private static $mutators = [];
192
193
    /**
194
     * Creates a new model object.
195
     *
196
     * @param array|string|Model|false $id     ordered array of ids or comma-separated id string
197
     * @param array                    $values optional key-value map to pre-seed model
198
     */
199
    public function __construct($id = false, array $values = [])
200
    {
201
        // initialize the model
202
        $this->init();
203
204
        // parse the supplied model ID
205
        $this->parseId($id);
206
207
        // load any given values
208
        if (count($values) > 0) {
209
            $this->refreshWith($values);
210
        }
211
    }
212
213
    /**
214
     * Performs initialization on this model.
215
     */
216
    private function init()
217
    {
218
        // ensure the initialize function is called only once
219
        $k = static::class;
220
        if (!isset(self::$initialized[$k])) {
221
            $this->initialize();
222
            self::$initialized[$k] = true;
223
        }
224
    }
225
226
    /**
227
     * The initialize() method is called once per model. This is a great
228
     * place to install event listeners.
229
     */
230
    protected function initialize()
231
    {
232
    }
233
234
    /**
235
     * Installs the `created_at` and `updated_at` properties.
236
     */
237
    private static function installAutoTimestamps()
238
    {
239
        static::$properties = array_replace(self::$timestampProperties, static::$properties);
240
241
        self::creating(function (ModelEvent $event) {
242
            $model = $event->getModel();
243
            $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...
244
            $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...
245
        });
246
247
        self::updating(function (ModelEvent $event) {
248
            $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...
249
        });
250
    }
251
252
    /**
253
     * Installs the `deleted_at` properties.
254
     */
255
    private static function installSoftDelete()
256
    {
257
        static::$properties = array_replace(self::$softDeleteProperties, static::$properties);
258
    }
259
260
    /**
261
     * Parses the given ID, which can be a single or composite primary key.
262
     *
263
     * @param mixed $id
264
     */
265
    private function parseId($id)
266
    {
267
        if (is_array($id)) {
268
            // A model can be supplied as a primary key
269
            foreach ($id as &$el) {
270
                if ($el instanceof self) {
271
                    $el = $el->id();
272
                }
273
            }
274
275
            // The IDs come in as the same order as ::$ids.
276
            // We need to match up the elements on that
277
            // input into a key-value map for each ID property.
278
            $ids = [];
279
            $idQueue = array_reverse($id);
280
            $this->hasId = true;
281
            foreach (static::$ids as $k => $f) {
282
                // type cast
283
                if (count($idQueue) > 0) {
284
                    $idProperty = static::getProperty($f);
285
                    $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 284 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...
286
                } else {
287
                    $ids[$f] = false;
288
                    $this->hasId = false;
289
                }
290
            }
291
292
            $this->idValues = $ids;
293
        } elseif ($id instanceof self) {
294
            // A model can be supplied as a primary key
295
            $this->hasId = $id->hasId;
296
            $this->idValues = $id->ids();
297
        } else {
298
            // type cast the single primary key
299
            $idName = static::$ids[0];
300
            $this->hasId = false;
301
            if (false !== $id) {
302
                $idProperty = static::getProperty($idName);
303
                $id = Type::cast($idProperty, $id);
0 ignored issues
show
Bug introduced by
It seems like $idProperty defined by static::getProperty($idName) on line 302 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...
304
                $this->hasId = true;
305
            }
306
307
            $this->idValues = [$idName => $id];
308
        }
309
    }
310
311
    /**
312
     * Sets the driver for all models.
313
     */
314
    public static function setDriver(DriverInterface $driver)
315
    {
316
        self::$driver = $driver;
317
    }
318
319
    /**
320
     * Gets the driver for all models.
321
     *
322
     * @throws DriverMissingException when a driver has not been set yet
323
     */
324
    public static function getDriver(): DriverInterface
325
    {
326
        if (!self::$driver) {
327
            throw new DriverMissingException('A model driver has not been set yet.');
328
        }
329
330
        return self::$driver;
331
    }
332
333
    /**
334
     * Clears the driver for all models.
335
     */
336
    public static function clearDriver()
337
    {
338
        self::$driver = null;
339
    }
340
341
    /**
342
     * Gets the name of the model, i.e. User.
343
     */
344
    public static function modelName(): string
345
    {
346
        // strip namespacing
347
        $paths = explode('\\', static::class);
348
349
        return end($paths);
350
    }
351
352
    /**
353
     * Gets the model ID.
354
     *
355
     * @return string|number|false ID
356
     */
357
    public function id()
358
    {
359
        if (!$this->hasId) {
360
            return false;
361
        }
362
363
        if (1 == count($this->idValues)) {
364
            return reset($this->idValues);
365
        }
366
367
        $result = [];
368
        foreach (static::$ids as $k) {
369
            $result[] = $this->idValues[$k];
370
        }
371
372
        return implode(',', $result);
373
    }
374
375
    /**
376
     * Gets a key-value map of the model ID.
377
     *
378
     * @return array ID map
379
     */
380
    public function ids(): array
381
    {
382
        return $this->idValues;
383
    }
384
385
    /////////////////////////////
386
    // Magic Methods
387
    /////////////////////////////
388
389
    /**
390
     * Converts the model into a string.
391
     *
392
     * @return string
393
     */
394
    public function __toString()
395
    {
396
        $values = array_merge($this->_values, $this->_unsaved, $this->idValues);
397
        ksort($values);
398
399
        return static::class.'('.json_encode($values, JSON_PRETTY_PRINT).')';
400
    }
401
402
    /**
403
     * Shortcut to a get() call for a given property.
404
     *
405
     * @param string $name
406
     *
407
     * @return mixed
408
     */
409
    public function __get($name)
410
    {
411
        $result = $this->get([$name]);
412
413
        return reset($result);
414
    }
415
416
    /**
417
     * Sets an unsaved value.
418
     *
419
     * @param string $name
420
     * @param mixed  $value
421
     */
422
    public function __set($name, $value)
423
    {
424
        // if changing property, remove relation model
425
        if (isset($this->_relationships[$name])) {
426
            unset($this->_relationships[$name]);
427
        }
428
429
        // call any mutators
430
        $mutator = self::getMutator($name);
431
        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...
432
            $this->_unsaved[$name] = $this->$mutator($value);
433
        } else {
434
            $this->_unsaved[$name] = $value;
435
        }
436
    }
437
438
    /**
439
     * Checks if an unsaved value or property exists by this name.
440
     *
441
     * @param string $name
442
     *
443
     * @return bool
444
     */
445
    public function __isset($name)
446
    {
447
        return array_key_exists($name, $this->_unsaved) || static::hasProperty($name);
448
    }
449
450
    /**
451
     * Unsets an unsaved value.
452
     *
453
     * @param string $name
454
     */
455
    public function __unset($name)
456
    {
457
        if (array_key_exists($name, $this->_unsaved)) {
458
            // if changing property, remove relation model
459
            if (isset($this->_relationships[$name])) {
460
                unset($this->_relationships[$name]);
461
            }
462
463
            unset($this->_unsaved[$name]);
464
        }
465
    }
466
467
    /////////////////////////////
468
    // ArrayAccess Interface
469
    /////////////////////////////
470
471
    public function offsetExists($offset)
472
    {
473
        return isset($this->$offset);
474
    }
475
476
    public function offsetGet($offset)
477
    {
478
        return $this->$offset;
479
    }
480
481
    public function offsetSet($offset, $value)
482
    {
483
        $this->$offset = $value;
484
    }
485
486
    public function offsetUnset($offset)
487
    {
488
        unset($this->$offset);
489
    }
490
491
    public static function __callStatic($name, $parameters)
492
    {
493
        // Any calls to unkown static methods should be deferred to
494
        // the query. This allows calls like User::where()
495
        // to replace User::query()->where().
496
        return call_user_func_array([static::query(), $name], $parameters);
497
    }
498
499
    /////////////////////////////
500
    // Property Definitions
501
    /////////////////////////////
502
503
    /**
504
     * The buildDefinition() method is called once per model. It's used
505
     * to generate the model definition. This is a great place to add any
506
     * dynamic model properties.
507
     */
508
    public static function buildDefinition(): Definition
509
    {
510
        // add in the default ID property
511
        if (static::$ids == [self::DEFAULT_ID_PROPERTY] && !isset(static::$properties[self::DEFAULT_ID_PROPERTY])) {
512
            static::$properties[self::DEFAULT_ID_PROPERTY] = self::$defaultIDProperty;
513
        }
514
515
        // generates created_at and updated_at timestamps
516
        if (property_exists(static::class, 'autoTimestamps')) {
517
            self::installAutoTimestamps();
518
        }
519
520
        // generates deleted_at timestamps
521
        if (property_exists(static::class, 'softDelete')) {
522
            self::installSoftDelete();
523
        }
524
525
        $properties = [];
526
        foreach (static::$properties as $k => $property) {
527
            // populate relationship property settings
528
            if (isset($property['relation'])) {
529
                // this is added for BC with older versions of pulsar
530
                // that only supported belongs to relationships
531
                if (!isset($property['relation_type'])) {
532
                    $property['relation_type'] = self::RELATIONSHIP_BELONGS_TO;
533
                    $property['local_key'] = $k;
534
                }
535
536
                $tempProperty = new Property($property);
537
                $relation = RelationFactory::make(new static(), $k, $tempProperty);
538
                if (!isset($property['foreign_key'])) {
539
                    $property['foreign_key'] = $relation->getForeignKey();
540
                }
541
542
                if (!isset($property['local_key'])) {
543
                    $property['local_key'] = $relation->getLocalKey();
544
                }
545
546
                if (!isset($property['pivot_tablename']) && $relation instanceof BelongsToMany) {
547
                    $property['pivot_tablename'] = $relation->getTablename();
548
                }
549
            }
550
551
            $properties[$k] = new Property($property);
552
        }
553
554
        // order the properties array by name for consistency
555
        // since it is constructed in a random order
556
        ksort($properties);
557
558
        return new Definition($properties);
559
    }
560
561
    /**
562
     * Gets the definition of all model properties.
563
     */
564
    public static function getProperties(): Definition
565
    {
566
        return Definition::make(static::class);
567
    }
568
569
    /**
570
     * Gets the definition of a specific property.
571
     *
572
     * @param string $property property to lookup
573
     */
574
    public static function getProperty(string $property): ?Property
575
    {
576
        return self::getProperties()->get($property);
577
    }
578
579
    /**
580
     * Gets the names of the model ID properties.
581
     */
582
    public static function getIDProperties(): array
583
    {
584
        return static::$ids;
585
    }
586
587
    /**
588
     * Checks if the model has a property.
589
     *
590
     * @param string $property property
591
     *
592
     * @return bool has property
593
     */
594
    public static function hasProperty(string $property): bool
595
    {
596
        return self::getProperties()->has($property);
597
    }
598
599
    /**
600
     * Gets the mutator method name for a given proeprty name.
601
     * Looks for methods in the form of `setPropertyValue`.
602
     * i.e. the mutator for `last_name` would be `setLastNameValue`.
603
     *
604
     * @param string $property property
605
     *
606
     * @return string|null method name if it exists
607
     */
608
    public static function getMutator(string $property): ?string
609
    {
610
        $class = static::class;
611
612
        $k = $class.':'.$property;
613
        if (!array_key_exists($k, self::$mutators)) {
614
            $inflector = Inflector::get();
615
            $method = 'set'.$inflector->camelize($property).'Value';
616
617
            if (!method_exists($class, $method)) {
618
                $method = null;
619
            }
620
621
            self::$mutators[$k] = $method;
622
        }
623
624
        return self::$mutators[$k];
625
    }
626
627
    /**
628
     * Gets the accessor method name for a given proeprty name.
629
     * Looks for methods in the form of `getPropertyValue`.
630
     * i.e. the accessor for `last_name` would be `getLastNameValue`.
631
     *
632
     * @param string $property property
633
     *
634
     * @return string|null method name if it exists
635
     */
636
    public static function getAccessor(string $property): ?string
637
    {
638
        $class = static::class;
639
640
        $k = $class.':'.$property;
641
        if (!array_key_exists($k, self::$accessors)) {
642
            $inflector = Inflector::get();
643
            $method = 'get'.$inflector->camelize($property).'Value';
644
645
            if (!method_exists($class, $method)) {
646
                $method = null;
647
            }
648
649
            self::$accessors[$k] = $method;
650
        }
651
652
        return self::$accessors[$k];
653
    }
654
655
    /////////////////////////////
656
    // CRUD Operations
657
    /////////////////////////////
658
659
    /**
660
     * Gets the tablename for storing this model.
661
     */
662
    public function getTablename(): string
663
    {
664
        $inflector = Inflector::get();
665
666
        return $inflector->camelize($inflector->pluralize(static::modelName()));
667
    }
668
669
    /**
670
     * Gets the ID of the connection in the connection manager
671
     * that stores this model.
672
     */
673
    public function getConnection(): ?string
674
    {
675
        return null;
676
    }
677
678
    protected function usesTransactions(): bool
679
    {
680
        return false;
681
    }
682
683
    /**
684
     * Saves the model.
685
     *
686
     * @return bool true when the operation was successful
687
     */
688
    public function save(): bool
689
    {
690
        if (!$this->hasId) {
691
            return $this->create();
692
        }
693
694
        return $this->set();
695
    }
696
697
    /**
698
     * Saves the model. Throws an exception when the operation fails.
699
     *
700
     * @throws ModelException when the model cannot be saved
701
     */
702
    public function saveOrFail()
703
    {
704
        if (!$this->save()) {
705
            $msg = 'Failed to save '.static::modelName();
706
            if ($validationErrors = $this->getErrors()->all()) {
707
                $msg .= ': '.implode(', ', $validationErrors);
708
            }
709
710
            throw new ModelException($msg);
711
        }
712
    }
713
714
    /**
715
     * Creates a new model.
716
     *
717
     * @param array $data optional key-value properties to set
718
     *
719
     * @return bool true when the operation was successful
720
     *
721
     * @throws BadMethodCallException when called on an existing model
722
     */
723
    public function create(array $data = []): bool
724
    {
725
        if ($this->hasId) {
726
            throw new BadMethodCallException('Cannot call create() on an existing model');
727
        }
728
729
        // mass assign values passed into create()
730
        $this->setValues($data);
731
732
        // clear any previous errors
733
        $this->getErrors()->clear();
734
735
        // start a DB transaction if needed
736
        $usesTransactions = $this->usesTransactions();
737
        if ($usesTransactions) {
738
            self::$driver->startTransaction($this->getConnection());
739
        }
740
741
        // dispatch the model.creating event
742
        if (!$this->performDispatch(ModelEvent::CREATING, $usesTransactions)) {
743
            return false;
744
        }
745
746
        $requiredProperties = [];
747
        foreach (self::getProperties()->all() as $name => $property) {
748
            // build a list of the required properties
749
            if ($property->isRequired()) {
750
                $requiredProperties[] = $name;
751
            }
752
753
            // add in default values
754
            if (!array_key_exists($name, $this->_unsaved) && isset($property['default'])) {
755
                $this->_unsaved[$name] = $property->getDefault();
756
            }
757
        }
758
759
        // validate the values being saved
760
        $validated = true;
761
        $insertArray = [];
762
        foreach ($this->_unsaved as $name => $value) {
763
            // exclude if value does not map to a property
764
            if (!self::getProperties()->has($name)) {
765
                continue;
766
            }
767
768
            $property = self::getProperty($name);
769
770
            // cannot insert immutable values
771
            // (unless using the default value)
772
            if ($property->isImmutable() && $value !== $property->getDefault()) {
773
                continue;
774
            }
775
776
            $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 768 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...
777
            $insertArray[$name] = $value;
778
        }
779
780
        // check for required fields
781
        foreach ($requiredProperties as $name) {
782
            if (!isset($insertArray[$name])) {
783
                $params = [
784
                    'field' => $name,
785
                    'field_name' => $this->getPropertyTitle($name),
786
                ];
787
                $this->getErrors()->add('pulsar.validation.required', $params);
788
789
                $validated = false;
790
            }
791
        }
792
793
        if (!$validated) {
794
            // when validations fail roll back any database transaction
795
            if ($usesTransactions) {
796
                self::$driver->rollBackTransaction($this->getConnection());
797
            }
798
799
            return false;
800
        }
801
802
        $created = self::$driver->createModel($this, $insertArray);
803
804
        if ($created) {
805
            // determine the model's new ID
806
            $this->getNewID();
807
808
            // store the persisted values to the in-memory cache
809
            $this->_unsaved = [];
810
            $this->refreshWith(array_replace($this->idValues, $insertArray));
811
812
            // dispatch the model.created event
813
            if (!$this->performDispatch(ModelEvent::CREATED, $usesTransactions)) {
814
                return false;
815
            }
816
        }
817
818
        // commit the transaction, if used
819
        if ($usesTransactions) {
820
            self::$driver->commitTransaction($this->getConnection());
821
        }
822
823
        return $created;
824
    }
825
826
    /**
827
     * Ignores unsaved values when fetching the next value.
828
     *
829
     * @return $this
830
     */
831
    public function ignoreUnsaved()
832
    {
833
        $this->ignoreUnsaved = true;
834
835
        return $this;
836
    }
837
838
    /**
839
     * Fetches property values from the model.
840
     *
841
     * This method looks up values in this order:
842
     * IDs, local cache, unsaved values, storage layer, defaults
843
     *
844
     * @param array $properties list of property names to fetch values of
845
     */
846
    public function get(array $properties): array
847
    {
848
        // load the values from the IDs and local model cache
849
        $values = array_replace($this->ids(), $this->_values);
850
851
        // unless specified, use any unsaved values
852
        $ignoreUnsaved = $this->ignoreUnsaved;
853
        $this->ignoreUnsaved = false;
854
855
        if (!$ignoreUnsaved) {
856
            $values = array_replace($values, $this->_unsaved);
857
        }
858
859
        // see if there are any model properties that do not exist.
860
        // when true then this means the model needs to be hydrated
861
        // NOTE: only looking at model properties and excluding dynamic/non-existent properties
862
        $modelProperties = self::getProperties()->propertyNames();
863
        $numMissing = count(array_intersect($modelProperties, array_diff($properties, array_keys($values))));
864
865
        if ($numMissing > 0 && !$this->loaded) {
866
            // load the model from the storage layer, if needed
867
            $this->refresh();
868
869
            $values = array_replace($values, $this->_values);
870
871
            if (!$ignoreUnsaved) {
872
                $values = array_replace($values, $this->_unsaved);
873
            }
874
        }
875
876
        // build a key-value map of the requested properties
877
        $return = [];
878
        foreach ($properties as $k) {
879
            $return[$k] = $this->getValue($k, $values);
880
        }
881
882
        return $return;
883
    }
884
885
    /**
886
     * Gets a property value from the model.
887
     *
888
     * Values are looked up in this order:
889
     *  1. unsaved values
890
     *  2. local values
891
     *  3. default value
892
     *  4. null
893
     *
894
     * @param string $property
895
     *
896
     * @return mixed
897
     */
898
    protected function getValue($property, array $values)
899
    {
900
        $value = null;
901
902
        if (array_key_exists($property, $values)) {
903
            $value = $values[$property];
904
        } elseif (static::hasProperty($property)) {
905
            $value = $this->_values[$property] = self::getProperty($property)->getDefault();
906
        }
907
908
        // call any accessors
909
        if ($accessor = self::getAccessor($property)) {
910
            $value = $this->$accessor($value);
911
        }
912
913
        return $value;
914
    }
915
916
    /**
917
     * Populates a newly created model with its ID.
918
     */
919
    protected function getNewID()
920
    {
921
        $ids = [];
922
        $namedIds = [];
923
        foreach (static::$ids as $k) {
924
            // attempt use the supplied value if the ID property is mutable
925
            $property = static::getProperty($k);
926
            if (!$property->isImmutable() && isset($this->_unsaved[$k])) {
927
                $id = $this->_unsaved[$k];
928
            } else {
929
                $id = self::$driver->getCreatedID($this, $k);
930
            }
931
932
            $ids[] = $id;
933
            $namedIds[$k] = $id;
934
        }
935
936
        $this->hasId = true;
937
        $this->idValues = $namedIds;
938
    }
939
940
    /**
941
     * Sets a collection values on the model from an untrusted input.
942
     *
943
     * @param array $values
944
     *
945
     * @throws MassAssignmentException when assigning a value that is protected or not whitelisted
946
     *
947
     * @return $this
948
     */
949
    public function setValues($values)
950
    {
951
        // check if the model has a mass assignment whitelist
952
        $permitted = (property_exists($this, 'permitted')) ? static::$permitted : false;
953
954
        // if no whitelist, then check for a blacklist
955
        $protected = (!is_array($permitted) && property_exists($this, 'protected')) ? static::$protected : false;
956
957
        foreach ($values as $k => $value) {
958
            // check for mass assignment violations
959
            if (($permitted && !in_array($k, $permitted)) ||
960
                ($protected && in_array($k, $protected))) {
961
                throw new MassAssignmentException("Mass assignment of $k on ".static::modelName().' is not allowed');
962
            }
963
964
            $this->$k = $value;
965
        }
966
967
        return $this;
968
    }
969
970
    /**
971
     * Converts the model to an array.
972
     */
973
    public function toArray(): array
974
    {
975
        // build the list of properties to retrieve
976
        $properties = self::getProperties()->propertyNames();
977
978
        // remove any hidden properties
979
        $hide = (property_exists($this, 'hidden')) ? static::$hidden : [];
980
        $properties = array_diff($properties, $hide);
981
982
        // add any appended properties
983
        $append = (property_exists($this, 'appended')) ? static::$appended : [];
984
        $properties = array_merge($properties, $append);
985
986
        // get the values for the properties
987
        $result = $this->get($properties);
988
989
        foreach ($result as $k => &$value) {
990
            // convert any models to arrays
991
            if ($value instanceof self) {
992
                $value = $value->toArray();
993
            }
994
        }
995
996
        return $result;
997
    }
998
999
    /**
1000
     * Updates the model.
1001
     *
1002
     * @param array $data optional key-value properties to set
1003
     *
1004
     * @return bool true when the operation was successful
1005
     *
1006
     * @throws BadMethodCallException when not called on an existing model
1007
     */
1008
    public function set(array $data = []): bool
1009
    {
1010
        if (!$this->hasId) {
1011
            throw new BadMethodCallException('Can only call set() on an existing model');
1012
        }
1013
1014
        // mass assign values passed into set()
1015
        $this->setValues($data);
1016
1017
        // clear any previous errors
1018
        $this->getErrors()->clear();
1019
1020
        // not updating anything?
1021
        if (0 == count($this->_unsaved)) {
1022
            return true;
1023
        }
1024
1025
        // start a DB transaction if needed
1026
        $usesTransactions = $this->usesTransactions();
1027
        if ($usesTransactions) {
1028
            self::$driver->startTransaction($this->getConnection());
1029
        }
1030
1031
        // dispatch the model.updating event
1032
        if (!$this->performDispatch(ModelEvent::UPDATING, $usesTransactions)) {
1033
            return false;
1034
        }
1035
1036
        // validate the values being saved
1037
        $validated = true;
1038
        $updateArray = [];
1039
        foreach ($this->_unsaved as $name => $value) {
1040
            // exclude if value does not map to a property
1041
            if (!self::getProperties()->has($name)) {
1042
                continue;
1043
            }
1044
1045
            $property = self::getProperty($name);
1046
1047
            // can only modify mutable properties
1048
            if (!$property->isMutable()) {
1049
                continue;
1050
            }
1051
1052
            $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 1045 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...
1053
            $updateArray[$name] = $value;
1054
        }
1055
1056
        if (!$validated) {
1057
            // when validations fail roll back any database transaction
1058
            if ($usesTransactions) {
1059
                self::$driver->rollBackTransaction($this->getConnection());
1060
            }
1061
1062
            return false;
1063
        }
1064
1065
        $updated = self::$driver->updateModel($this, $updateArray);
1066
1067
        if ($updated) {
1068
            // store the persisted values to the in-memory cache
1069
            $this->_unsaved = [];
1070
            $this->refreshWith(array_replace($this->_values, $updateArray));
1071
1072
            // dispatch the model.updated event
1073
            if (!$this->performDispatch(ModelEvent::UPDATED, $usesTransactions)) {
1074
                return false;
1075
            }
1076
        }
1077
1078
        // commit the transaction, if used
1079
        if ($usesTransactions) {
1080
            self::$driver->commitTransaction($this->getConnection());
1081
        }
1082
1083
        return $updated;
1084
    }
1085
1086
    /**
1087
     * Delete the model.
1088
     *
1089
     * @return bool true when the operation was successful
1090
     */
1091
    public function delete(): bool
1092
    {
1093
        if (!$this->hasId) {
1094
            throw new BadMethodCallException('Can only call delete() on an existing model');
1095
        }
1096
1097
        // clear any previous errors
1098
        $this->getErrors()->clear();
1099
1100
        // start a DB transaction if needed
1101
        $usesTransactions = $this->usesTransactions();
1102
        if ($usesTransactions) {
1103
            self::$driver->startTransaction($this->getConnection());
1104
        }
1105
1106
        // dispatch the model.deleting event
1107
        if (!$this->performDispatch(ModelEvent::DELETING, $usesTransactions)) {
1108
            return false;
1109
        }
1110
1111
        // perform a hard (default) or soft delete
1112
        $hardDelete = true;
1113
        if (property_exists($this, 'softDelete')) {
1114
            $t = time();
1115
            $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...
1116
            $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...
1117
            $deleted = self::$driver->updateModel($this, ['deleted_at' => $t]);
1118
            $hardDelete = false;
1119
        } else {
1120
            $deleted = self::$driver->deleteModel($this);
1121
        }
1122
1123
        if ($deleted) {
1124
            // dispatch the model.deleted event
1125
            if (!$this->performDispatch(ModelEvent::DELETED, $usesTransactions)) {
1126
                return false;
1127
            }
1128
1129
            if ($hardDelete) {
1130
                $this->_persisted = false;
1131
            }
1132
        }
1133
1134
        // commit the transaction, if used
1135
        if ($usesTransactions) {
1136
            self::$driver->commitTransaction($this->getConnection());
1137
        }
1138
1139
        return $deleted;
1140
    }
1141
1142
    /**
1143
     * Restores a soft-deleted model.
1144
     */
1145
    public function restore(): bool
1146
    {
1147
        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...
1148
            throw new BadMethodCallException('Can only call restore() on a soft-deleted model');
1149
        }
1150
1151
        // start a DB transaction if needed
1152
        $usesTransactions = $this->usesTransactions();
1153
        if ($usesTransactions) {
1154
            self::$driver->startTransaction($this->getConnection());
1155
        }
1156
1157
        // dispatch the model.updating event
1158
        if (!$this->performDispatch(ModelEvent::UPDATING, $usesTransactions)) {
1159
            return false;
1160
        }
1161
1162
        $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...
1163
        $restored = self::$driver->updateModel($this, ['deleted_at' => null]);
1164
1165
        if ($restored) {
1166
            // dispatch the model.updated event
1167
            if (!$this->performDispatch(ModelEvent::UPDATED, $usesTransactions)) {
1168
                return false;
1169
            }
1170
        }
1171
1172
        // commit the transaction, if used
1173
        if ($usesTransactions) {
1174
            self::$driver->commitTransaction($this->getConnection());
1175
        }
1176
1177
        return $restored;
1178
    }
1179
1180
    /**
1181
     * Checks if the model has been deleted.
1182
     */
1183
    public function isDeleted(): bool
1184
    {
1185
        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...
1186
            return true;
1187
        }
1188
1189
        return !$this->_persisted;
1190
    }
1191
1192
    /////////////////////////////
1193
    // Queries
1194
    /////////////////////////////
1195
1196
    /**
1197
     * Generates a new query instance.
1198
     */
1199
    public static function query(): Query
1200
    {
1201
        // Create a new model instance for the query to ensure
1202
        // that the model's initialize() method gets called.
1203
        // Otherwise, the property definitions will be incomplete.
1204
        $model = new static();
1205
        $query = new Query($model);
1206
1207
        // scope soft-deleted models to only include non-deleted models
1208
        if (property_exists($model, 'softDelete')) {
1209
            $query->where('deleted_at IS NOT NULL');
1210
        }
1211
1212
        return $query;
1213
    }
1214
1215
    /**
1216
     * Generates a new query instance that includes soft-deleted models.
1217
     */
1218
    public static function withDeleted(): Query
1219
    {
1220
        // Create a new model instance for the query to ensure
1221
        // that the model's initialize() method gets called.
1222
        // Otherwise, the property definitions will be incomplete.
1223
        $model = new static();
1224
1225
        return new Query($model);
1226
    }
1227
1228
    /**
1229
     * Finds a single instance of a model given it's ID.
1230
     *
1231
     * @param mixed $id
1232
     *
1233
     * @return static|null
1234
     */
1235
    public static function find($id): ?self
1236
    {
1237
        $ids = [];
1238
        $id = (array) $id;
1239
        foreach (static::$ids as $j => $k) {
1240
            if (isset($id[$j])) {
1241
                $ids[$k] = $id[$j];
1242
            }
1243
        }
1244
1245
        // malformed ID
1246
        if (count($ids) < count(static::$ids)) {
1247
            return null;
1248
        }
1249
1250
        return static::query()->where($ids)->first();
1251
    }
1252
1253
    /**
1254
     * Finds a single instance of a model given it's ID or throws an exception.
1255
     *
1256
     * @param mixed $id
1257
     *
1258
     * @return static
1259
     *
1260
     * @throws ModelNotFoundException when a model could not be found
1261
     */
1262
    public static function findOrFail($id): self
1263
    {
1264
        $model = static::find($id);
1265
        if (!$model) {
1266
            throw new ModelNotFoundException('Could not find the requested '.static::modelName());
1267
        }
1268
1269
        return $model;
1270
    }
1271
1272
    /**
1273
     * Tells if this model instance has been persisted to the data layer.
1274
     *
1275
     * NOTE: this does not actually perform a check with the data layer
1276
     */
1277
    public function persisted(): bool
1278
    {
1279
        return $this->_persisted;
1280
    }
1281
1282
    /**
1283
     * Loads the model from the storage layer.
1284
     *
1285
     * @return $this
1286
     */
1287
    public function refresh()
1288
    {
1289
        if (!$this->hasId) {
1290
            return $this;
1291
        }
1292
1293
        $values = self::$driver->loadModel($this);
1294
1295
        if (!is_array($values)) {
1296
            return $this;
1297
        }
1298
1299
        // clear any relations
1300
        $this->_relationships = [];
1301
1302
        return $this->refreshWith($values);
1303
    }
1304
1305
    /**
1306
     * Loads values into the model.
1307
     *
1308
     * @param array $values values
1309
     *
1310
     * @return $this
1311
     */
1312
    public function refreshWith(array $values)
1313
    {
1314
        // type cast the values
1315
        foreach ($values as $k => &$value) {
1316
            if ($property = static::getProperty($k)) {
1317
                $value = Type::cast($property, $value);
1318
            }
1319
        }
1320
1321
        $this->loaded = true;
1322
        $this->_persisted = true;
1323
        $this->_values = $values;
1324
1325
        return $this;
1326
    }
1327
1328
    /**
1329
     * Clears the cache for this model.
1330
     *
1331
     * @return $this
1332
     */
1333
    public function clearCache()
1334
    {
1335
        $this->loaded = false;
1336
        $this->_unsaved = [];
1337
        $this->_values = [];
1338
        $this->_relationships = [];
1339
1340
        return $this;
1341
    }
1342
1343
    /////////////////////////////
1344
    // Relationships
1345
    /////////////////////////////
1346
1347
    /**
1348
     * @deprecated
1349
     *
1350
     * Gets the model(s) for a relationship
1351
     *
1352
     * @param string $k property
1353
     *
1354
     * @throws InvalidArgumentException when the relationship manager cannot be created
1355
     *
1356
     * @return Model|array|null
1357
     */
1358
    public function relation(string $k)
1359
    {
1360
        if (!array_key_exists($k, $this->_relationships)) {
1361
            $relation = RelationFactory::make($this, $k, self::getProperty($k));
0 ignored issues
show
Bug introduced by
It seems like self::getProperty($k) can be null; however, make() 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...
1362
            $this->_relationships[$k] = $relation->getResults();
1363
        }
1364
1365
        return $this->_relationships[$k];
1366
    }
1367
1368
    /**
1369
     * @deprecated
1370
     *
1371
     * Sets the model for a one-to-one relationship (has-one or belongs-to)
1372
     *
1373
     * @return $this
1374
     */
1375
    public function setRelation(string $k, Model $model)
1376
    {
1377
        $this->$k = $model->id();
1378
        $this->_relationships[$k] = $model;
1379
1380
        return $this;
1381
    }
1382
1383
    /**
1384
     * @deprecated
1385
     *
1386
     * Sets the model for a one-to-many relationship
1387
     *
1388
     * @return $this
1389
     */
1390
    public function setRelationCollection(string $k, iterable $models)
1391
    {
1392
        $this->_relationships[$k] = $models;
1393
1394
        return $this;
1395
    }
1396
1397
    /**
1398
     * Sets the model for a one-to-one relationship (has-one or belongs-to) as null.
1399
     *
1400
     * @return $this
1401
     */
1402
    public function clearRelation(string $k)
1403
    {
1404
        $this->$k = null;
1405
        $this->_relationships[$k] = null;
1406
1407
        return $this;
1408
    }
1409
1410
    /////////////////////////////
1411
    // Events
1412
    /////////////////////////////
1413
1414
    /**
1415
     * Gets the event dispatcher.
1416
     */
1417
    public static function getDispatcher($ignoreCache = false): EventDispatcher
1418
    {
1419
        $class = static::class;
1420
        if ($ignoreCache || !isset(self::$dispatchers[$class])) {
1421
            self::$dispatchers[$class] = new EventDispatcher();
1422
        }
1423
1424
        return self::$dispatchers[$class];
1425
    }
1426
1427
    /**
1428
     * Subscribes to a listener to an event.
1429
     *
1430
     * @param string $event    event name
1431
     * @param int    $priority optional priority, higher #s get called first
1432
     */
1433
    public static function listen(string $event, callable $listener, int $priority = 0)
1434
    {
1435
        static::getDispatcher()->addListener($event, $listener, $priority);
1436
    }
1437
1438
    /**
1439
     * Adds a listener to the model.creating and model.updating events.
1440
     */
1441
    public static function saving(callable $listener, int $priority = 0)
1442
    {
1443
        static::listen(ModelEvent::CREATING, $listener, $priority);
1444
        static::listen(ModelEvent::UPDATING, $listener, $priority);
1445
    }
1446
1447
    /**
1448
     * Adds a listener to the model.created and model.updated events.
1449
     */
1450
    public static function saved(callable $listener, int $priority = 0)
1451
    {
1452
        static::listen(ModelEvent::CREATED, $listener, $priority);
1453
        static::listen(ModelEvent::UPDATED, $listener, $priority);
1454
    }
1455
1456
    /**
1457
     * Adds a listener to the model.creating event.
1458
     */
1459
    public static function creating(callable $listener, int $priority = 0)
1460
    {
1461
        static::listen(ModelEvent::CREATING, $listener, $priority);
1462
    }
1463
1464
    /**
1465
     * Adds a listener to the model.created event.
1466
     */
1467
    public static function created(callable $listener, int $priority = 0)
1468
    {
1469
        static::listen(ModelEvent::CREATED, $listener, $priority);
1470
    }
1471
1472
    /**
1473
     * Adds a listener to the model.updating event.
1474
     */
1475
    public static function updating(callable $listener, int $priority = 0)
1476
    {
1477
        static::listen(ModelEvent::UPDATING, $listener, $priority);
1478
    }
1479
1480
    /**
1481
     * Adds a listener to the model.updated event.
1482
     */
1483
    public static function updated(callable $listener, int $priority = 0)
1484
    {
1485
        static::listen(ModelEvent::UPDATED, $listener, $priority);
1486
    }
1487
1488
    /**
1489
     * Adds a listener to the model.deleting event.
1490
     */
1491
    public static function deleting(callable $listener, int $priority = 0)
1492
    {
1493
        static::listen(ModelEvent::DELETING, $listener, $priority);
1494
    }
1495
1496
    /**
1497
     * Adds a listener to the model.deleted event.
1498
     */
1499
    public static function deleted(callable $listener, int $priority = 0)
1500
    {
1501
        static::listen(ModelEvent::DELETED, $listener, $priority);
1502
    }
1503
1504
    /**
1505
     * Dispatches the given event and checks if it was successful.
1506
     *
1507
     * @return bool true if the events were successfully propagated
1508
     */
1509
    private function performDispatch(string $eventName, bool $usesTransactions): bool
1510
    {
1511
        $event = new ModelEvent($this);
1512
        static::getDispatcher()->dispatch($event, $eventName);
1513
1514
        // when listeners fail roll back any database transaction
1515
        if ($event->isPropagationStopped()) {
1516
            if ($usesTransactions) {
1517
                self::$driver->rollBackTransaction($this->getConnection());
1518
            }
1519
1520
            return false;
1521
        }
1522
1523
        return true;
1524
    }
1525
1526
    /////////////////////////////
1527
    // Validation
1528
    /////////////////////////////
1529
1530
    /**
1531
     * Gets the error stack for this model.
1532
     */
1533
    public function getErrors(): Errors
1534
    {
1535
        if (!$this->errors) {
1536
            $this->errors = new Errors();
1537
        }
1538
1539
        return $this->errors;
1540
    }
1541
1542
    /**
1543
     * Checks if the model in its current state is valid.
1544
     */
1545
    public function valid(): bool
1546
    {
1547
        // clear any previous errors
1548
        $this->getErrors()->clear();
1549
1550
        // run the validator against the model values
1551
        $values = $this->_unsaved + $this->_values;
1552
1553
        $validated = true;
1554
        foreach ($values as $k => $v) {
1555
            $property = static::getProperty($k);
1556
            $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 1555 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...
1557
        }
1558
1559
        // add back any modified unsaved values
1560
        foreach (array_keys($this->_unsaved) as $k) {
1561
            $this->_unsaved[$k] = $values[$k];
1562
        }
1563
1564
        return $validated;
1565
    }
1566
1567
    /**
1568
     * Validates and marshals a value to storage.
1569
     *
1570
     * @param Property $property property definition
1571
     * @param string   $name     property name
1572
     * @param mixed    $value
1573
     */
1574
    private function filterAndValidate(Property $property, string $name, &$value): bool
1575
    {
1576
        // assume empty string is a null value for properties
1577
        // that are marked as optionally-null
1578
        if ($property->isNullable() && ('' === $value || null === $value)) {
1579
            $value = null;
1580
1581
            return true;
1582
        }
1583
1584
        // validate
1585
        list($valid, $value) = $this->validateValue($property, $name, $value);
1586
1587
        // unique?
1588
        if ($valid && $property->isUnique() && (!$this->hasId || $value != $this->ignoreUnsaved()->$name)) {
1589
            $valid = $this->checkUniqueness($name, $value);
1590
        }
1591
1592
        return $valid;
1593
    }
1594
1595
    /**
1596
     * Validates a value for a property.
1597
     *
1598
     * @param Property $property property definition
1599
     * @param string   $name     property name
1600
     * @param mixed    $value
1601
     */
1602
    private function validateValue(Property $property, string $name, $value): array
1603
    {
1604
        $valid = true;
1605
1606
        $error = 'pulsar.validation.failed';
1607
        $validateRules = $property->getValidationRules();
1608
        if (is_callable($validateRules)) {
1609
            $valid = call_user_func_array($validateRules, [$value]);
1610
        } elseif ($validateRules) {
1611
            $validator = new Validator($validateRules);
1612
            $valid = $validator->validate($value);
1613
            $error = 'pulsar.validation.'.$validator->getFailingRule();
1614
        }
1615
1616
        if (!$valid) {
1617
            $params = [
1618
                'field' => $name,
1619
                'field_name' => $this->getPropertyTitle($name),
1620
            ];
1621
            $this->getErrors()->add($error, $params);
1622
        }
1623
1624
        return [$valid, $value];
1625
    }
1626
1627
    /**
1628
     * Checks if a value is unique for a property.
1629
     *
1630
     * @param string $name  property name
1631
     * @param mixed  $value
1632
     */
1633
    private function checkUniqueness(string $name, $value): bool
1634
    {
1635
        $n = static::query()->where([$name => $value])->count();
1636
        if ($n > 0) {
1637
            $params = [
1638
                'field' => $name,
1639
                'field_name' => $this->getPropertyTitle($name),
1640
            ];
1641
            $this->getErrors()->add('pulsar.validation.unique', $params);
1642
1643
            return false;
1644
        }
1645
1646
        return true;
1647
    }
1648
1649
    /**
1650
     * Gets the humanized name of a property.
1651
     *
1652
     * @param string $name property name
1653
     */
1654
    private function getPropertyTitle(string $name): string
1655
    {
1656
        // look up the property from the translator first
1657
        if ($translator = $this->getErrors()->getTranslator()) {
1658
            $k = 'pulsar.properties.'.static::modelName().'.'.$name;
1659
            $title = $translator->translate($k);
1660
            if ($title != $k) {
1661
                return $title;
1662
            }
1663
        }
1664
1665
        // otherwise just attempt to title-ize the property name
1666
        return Inflector::get()->titleize($name);
1667
    }
1668
}
1669