Completed
Push — master ( 40c610...9750f1 )
by Jared
01:30
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\AbstractRelation;
24
use Pulsar\Relation\Relationship;
25
use Symfony\Component\EventDispatcher\EventDispatcher;
26
27
/**
28
 * Class Model.
29
 *
30
 * @method Query             where($where, $value = null, $condition = null)
31
 * @method Query             limit($limit)
32
 * @method Query             start($start)
33
 * @method Query             sort($sort)
34
 * @method Query             join($model, $column, $foreignKey)
35
 * @method Query             with($k)
36
 * @method Iterator          all()
37
 * @method array|static|null first($limit = 1)
38
 * @method int               count()
39
 * @method number            sum($property)
40
 * @method number            average($property)
41
 * @method number            max($property)
42
 * @method number            min($property)
43
 */
44
abstract class Model implements ArrayAccess
45
{
46
    const DEFAULT_ID_NAME = 'id';
47
48
    /////////////////////////////
49
    // Model visible variables
50
    /////////////////////////////
51
52
    /**
53
     * List of model ID property names.
54
     *
55
     * @var array
56
     */
57
    protected static $ids = [self::DEFAULT_ID_NAME];
58
59
    /**
60
     * Property definitions expressed as a key-value map with
61
     * property names as the keys.
62
     * i.e. ['enabled' => ['type' => Type::BOOLEAN]].
63
     *
64
     * @var array
65
     */
66
    protected static $properties = [];
67
68
    /**
69
     * @var array
70
     */
71
    protected $_values = [];
72
73
    /**
74
     * @var array
75
     */
76
    private $_unsaved = [];
77
78
    /**
79
     * @var bool
80
     */
81
    protected $_persisted = false;
82
83
    /**
84
     * @var array
85
     */
86
    protected $_relationships = [];
87
88
    /**
89
     * @var AbstractRelation[]
90
     */
91
    private $relationships = [];
92
93
    /////////////////////////////
94
    // Base model variables
95
    /////////////////////////////
96
97
    /**
98
     * @var array
99
     */
100
    private static $initialized = [];
101
102
    /**
103
     * @var DriverInterface
104
     */
105
    private static $driver;
106
107
    /**
108
     * @var array
109
     */
110
    private static $accessors = [];
111
112
    /**
113
     * @var array
114
     */
115
    private static $mutators = [];
116
117
    /**
118
     * @var array
119
     */
120
    private static $dispatchers = [];
121
122
    /**
123
     * @var string
124
     */
125
    private $tablename;
126
127
    /**
128
     * @var bool
129
     */
130
    private $hasId;
131
132
    /**
133
     * @var array
134
     */
135
    private $idValues;
136
137
    /**
138
     * @var bool
139
     */
140
    private $loaded = false;
141
142
    /**
143
     * @var Errors
144
     */
145
    private $errors;
146
147
    /**
148
     * @var bool
149
     */
150
    private $ignoreUnsaved;
151
152
    /**
153
     * Creates a new model object.
154
     *
155
     * @param array|string|Model|false $id     ordered array of ids or comma-separated id string
0 ignored issues
show
Bug introduced by
There is no parameter named $id. Was it maybe removed?

This check looks for PHPDoc comments describing methods or function parameters that do not exist on the corresponding method or function.

Consider the following example. The parameter $italy is not defined by the method finale(...).

/**
 * @param array $germany
 * @param array $island
 * @param array $italy
 */
function finale($germany, $island) {
    return "2:1";
}

The most likely cause is that the parameter was removed, but the annotation was not.

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