Completed
Push — master ( dd5943...0634a9 )
by Jared
04:03
created

Model::creating()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 4
rs 10
c 0
b 0
f 0
cc 1
nc 1
nop 2
1
<?php
2
3
/**
4
 * @author Jared King <[email protected]>
5
 *
6
 * @see http://jaredtking.com
7
 *
8
 * @copyright 2015 Jared King
9
 * @license MIT
10
 */
11
12
namespace Pulsar;
13
14
use ArrayAccess;
15
use BadMethodCallException;
16
use ICanBoogie\Inflector;
17
use InvalidArgumentException;
18
use Pulsar\Driver\DriverInterface;
19
use Pulsar\Exception\DriverMissingException;
20
use Pulsar\Exception\MassAssignmentException;
21
use Pulsar\Exception\ModelException;
22
use Pulsar\Exception\ModelNotFoundException;
23
use Pulsar\Relation\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
    protected $_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
     * @deprecated
523
     *
524
     * Gets the definition of all model properties
525
     */
526
    public static function getProperties(): Definition
527
    {
528
        return DefinitionBuilder::get(static::class);
529
    }
530
531
    /**
532
     * @deprecated
533
     *
534
     * Gets the definition of a specific property
535
     *
536
     * @param string $property property to lookup
537
     */
538
    public static function getProperty(string $property): ?Property
539
    {
540
        return static::definition()->get($property);
541
    }
542
543
    /**
544
     * @deprecated
545
     *
546
     * Checks if the model has a property
547
     *
548
     * @param string $property property
549
     *
550
     * @return bool has property
551
     */
552
    public static function hasProperty(string $property): bool
553
    {
554
        return static::definition()->has($property);
555
    }
556
557
    /////////////////////////////
558
    // CRUD Operations
559
    /////////////////////////////
560
561
    /**
562
     * Gets the table name for storing this model.
563
     */
564
    public function getTablename(): string
565
    {
566
        if (!$this->tablename) {
567
            $inflector = Inflector::get();
568
569
            $this->tablename = $inflector->camelize($inflector->pluralize(static::modelName()));
570
        }
571
572
        return $this->tablename;
573
    }
574
575
    /**
576
     * Gets the ID of the connection in the connection manager
577
     * that stores this model.
578
     */
579
    public function getConnection(): ?string
580
    {
581
        return null;
582
    }
583
584
    protected function usesTransactions(): bool
585
    {
586
        return false;
587
    }
588
589
    /**
590
     * Saves the model.
591
     *
592
     * @return bool true when the operation was successful
593
     */
594
    public function save(): bool
595
    {
596
        if (!$this->hasId) {
597
            return $this->create();
598
        }
599
600
        return $this->set();
601
    }
602
603
    /**
604
     * Saves the model. Throws an exception when the operation fails.
605
     *
606
     * @throws ModelException when the model cannot be saved
607
     */
608
    public function saveOrFail()
609
    {
610
        if (!$this->save()) {
611
            $msg = 'Failed to save '.static::modelName();
612
            if ($validationErrors = $this->getErrors()->all()) {
613
                $msg .= ': '.implode(', ', $validationErrors);
614
            }
615
616
            throw new ModelException($msg);
617
        }
618
    }
619
620
    /**
621
     * Creates a new model.
622
     *
623
     * @param array $data optional key-value properties to set
624
     *
625
     * @return bool true when the operation was successful
626
     *
627
     * @throws BadMethodCallException when called on an existing model
628
     */
629
    public function create(array $data = []): bool
630
    {
631
        if ($this->hasId) {
632
            throw new BadMethodCallException('Cannot call create() on an existing model');
633
        }
634
635
        // mass assign values passed into create()
636
        $this->setValues($data);
637
638
        // clear any previous errors
639
        $this->getErrors()->clear();
640
641
        // start a DB transaction if needed
642
        $usesTransactions = $this->usesTransactions();
643
        if ($usesTransactions) {
644
            self::$driver->startTransaction($this->getConnection());
645
        }
646
647
        // dispatch the model.creating event
648
        if (!$this->performDispatch(ModelEvent::CREATING, $usesTransactions)) {
649
            return false;
650
        }
651
652
        $requiredProperties = [];
653
        foreach (static::definition()->all() as $name => $property) {
654
            // build a list of the required properties
655
            if ($property->isRequired()) {
656
                $requiredProperties[] = $property;
657
            }
658
659
            // add in default values
660
            if (!array_key_exists($name, $this->_unsaved) && $property->hasDefault()) {
661
                $this->_unsaved[$name] = $property->getDefault();
662
            }
663
        }
664
665
        // save any relationships
666
        if (!$this->saveRelationships($usesTransactions)) {
667
            return false;
668
        }
669
670
        // validate the values being saved
671
        $validated = true;
672
        $insertArray = [];
673
        $preservedValues = [];
674
        foreach ($this->_unsaved as $name => $value) {
675
            // exclude if value does not map to a property
676
            $property = static::definition()->get($name);
677
            if (!$property) {
678
                continue;
679
            }
680
681
            // check if this property is persisted to the DB
682
            if (!$property->isPersisted()) {
683
                $preservedValues[$name] = $value;
684
                continue;
685
            }
686
687
            // cannot insert immutable values
688
            // (unless using the default value)
689
            if ($property->isImmutable() && $value !== $property->getDefault()) {
690
                continue;
691
            }
692
693
            $validated = $validated && Validator::validateProperty($this, $property, $value);
694
            $insertArray[$name] = $value;
695
        }
696
697
        // check for required fields
698
        foreach ($requiredProperties as $property) {
699
            $name = $property->getName();
700
            if (!isset($insertArray[$name]) && !isset($preservedValues[$name])) {
701
                $params = [
702
                    'field' => $name,
703
                    'field_name' => $property->getTitle($this),
704
                ];
705
                $this->getErrors()->add('pulsar.validation.required', $params);
706
707
                $validated = false;
708
            }
709
        }
710
711
        if (!$validated) {
712
            // when validations fail roll back any database transaction
713
            if ($usesTransactions) {
714
                self::$driver->rollBackTransaction($this->getConnection());
715
            }
716
717
            return false;
718
        }
719
720
        $created = self::$driver->createModel($this, $insertArray);
721
722
        if ($created) {
723
            // determine the model's new ID
724
            $this->getNewId();
725
726
            // store the persisted values to the in-memory cache
727
            $this->_unsaved = [];
728
            $this->refreshWith(array_replace($this->idValues, $preservedValues, $insertArray));
729
730
            // dispatch the model.created event
731
            if (!$this->performDispatch(ModelEvent::CREATED, $usesTransactions)) {
732
                return false;
733
            }
734
        }
735
736
        // commit the transaction, if used
737
        if ($usesTransactions) {
738
            self::$driver->commitTransaction($this->getConnection());
739
        }
740
741
        return $created;
742
    }
743
744
    /**
745
     * Ignores unsaved values when fetching the next value.
746
     *
747
     * @return $this
748
     */
749
    public function ignoreUnsaved()
750
    {
751
        $this->ignoreUnsaved = true;
752
753
        return $this;
754
    }
755
756
    /**
757
     * Fetches property values from the model.
758
     *
759
     * This method looks up values in this order:
760
     * IDs, local cache, unsaved values, storage layer, defaults
761
     *
762
     * @param array $properties list of property names to fetch values of
763
     */
764
    public function get(array $properties): array
765
    {
766
        // load the values from the IDs and local model cache
767
        $values = array_replace($this->ids(), $this->_values);
768
769
        // unless specified, use any unsaved values
770
        $ignoreUnsaved = $this->ignoreUnsaved;
771
        $this->ignoreUnsaved = false;
772
773
        if (!$ignoreUnsaved) {
774
            $values = array_replace($values, $this->_unsaved);
775
        }
776
777
        // see if there are any model properties that do not exist.
778
        // when true then this means the model needs to be hydrated
779
        // NOTE: only looking at model properties and excluding dynamic/non-existent properties
780
        $modelProperties = static::definition()->propertyNames();
781
        $numMissing = count(array_intersect($modelProperties, array_diff($properties, array_keys($values))));
782
783
        if ($numMissing > 0 && !$this->loaded) {
784
            // load the model from the storage layer, if needed
785
            $this->refresh();
786
787
            $values = array_replace($values, $this->_values);
788
789
            if (!$ignoreUnsaved) {
790
                $values = array_replace($values, $this->_unsaved);
791
            }
792
        }
793
794
        // build a key-value map of the requested properties
795
        $return = [];
796
        foreach ($properties as $k) {
797
            $return[$k] = $this->getValue($k, $values);
798
        }
799
800
        return $return;
801
    }
802
803
    /**
804
     * Gets a property value from the model.
805
     *
806
     * Values are looked up in this order:
807
     *  1. unsaved values
808
     *  2. local values
809
     *  3. default value
810
     *  4. null
811
     *
812
     * @return mixed
813
     */
814
    private function getValue(string $name, array $values)
815
    {
816
        $value = null;
817
818
        if (array_key_exists($name, $values)) {
819
            $value = $values[$name];
820
        } elseif ($property = static::definition()->get($name)) {
821
            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...
822
                $relationship = $this->getRelationship($property);
823
                $value = $this->_values[$name] = $relationship->getResults();
824
            } else {
825
                $value = $this->_values[$name] = $property->getDefault();
826
            }
827
        }
828
829
        // call any accessors
830
        if ($accessor = self::getAccessor($name)) {
831
            $value = $this->$accessor($value);
832
        }
833
834
        return $value;
835
    }
836
837
    /**
838
     * Populates a newly created model with its ID.
839
     */
840
    private function getNewId()
841
    {
842
        $ids = [];
843
        $namedIds = [];
844
        foreach (static::$ids as $k) {
845
            // attempt use the supplied value if the ID property is mutable
846
            $property = static::definition()->get($k);
847
            if (!$property->isImmutable() && isset($this->_unsaved[$k])) {
848
                $id = $this->_unsaved[$k];
849
            } else {
850
                $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 846 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...
851
            }
852
853
            $ids[] = $id;
854
            $namedIds[$k] = $id;
855
        }
856
857
        $this->hasId = true;
858
        $this->idValues = $namedIds;
859
    }
860
861
    /**
862
     * Sets a collection values on the model from an untrusted input.
863
     *
864
     * @param array $values
865
     *
866
     * @throws MassAssignmentException when assigning a value that is protected or not whitelisted
867
     *
868
     * @return $this
869
     */
870
    public function setValues($values)
871
    {
872
        // check if the model has a mass assignment whitelist
873
        $permitted = (property_exists($this, 'permitted')) ? static::$permitted : false;
874
875
        // if no whitelist, then check for a blacklist
876
        $protected = (!is_array($permitted) && property_exists($this, 'protected')) ? static::$protected : false;
877
878
        foreach ($values as $k => $value) {
879
            // check for mass assignment violations
880
            if (($permitted && !in_array($k, $permitted)) ||
881
                ($protected && in_array($k, $protected))) {
882
                throw new MassAssignmentException("Mass assignment of $k on ".static::modelName().' is not allowed');
883
            }
884
885
            $this->$k = $value;
886
        }
887
888
        return $this;
889
    }
890
891
    /**
892
     * Converts the model to an array.
893
     */
894
    public function toArray(): array
895
    {
896
        // build the list of properties to retrieve
897
        $properties = static::definition()->propertyNames();
898
899
        // remove any relationships
900
        $relationships = [];
901
        foreach (static::definition()->all() as $property) {
902
            if ($property->getRelationshipType() && !$property->isPersisted()) {
903
                $relationships[] = $property->getName();
904
            }
905
        }
906
        $properties = array_diff($properties, $relationships);
907
908
        // remove any hidden properties
909
        $hide = (property_exists($this, 'hidden')) ? static::$hidden : [];
910
        $properties = array_diff($properties, $hide);
911
912
        // add any appended properties
913
        $append = (property_exists($this, 'appended')) ? static::$appended : [];
914
        $properties = array_merge($properties, $append);
915
916
        // get the values for the properties
917
        $result = $this->get($properties);
918
919
        foreach ($result as $k => &$value) {
920
            // convert arrays of models to arrays
921
            if (is_array($value)) {
922
                foreach ($value as &$subValue) {
923
                    if ($subValue instanceof Model) {
924
                        $subValue = $subValue->toArray();
925
                    }
926
                }
927
            }
928
929
            // convert any models to arrays
930
            if ($value instanceof self) {
931
                $value = $value->toArray();
932
            }
933
        }
934
935
        return $result;
936
    }
937
938
    /**
939
     * Checks if the unsaved value for a property is present and
940
     * is different from the original value.
941
     *
942
     * @property string|null $name
943
     * @property bool        $hasChanged when true, checks if the unsaved value is different from the saved value
944
     */
945
    public function dirty(?string $name = null, bool $hasChanged = false): bool
946
    {
947
        if (!$name) {
948
            if ($hasChanged) {
949
                throw new \RuntimeException('Checking if all properties have changed is not supported');
950
            }
951
952
            return count($this->_unsaved) > 0;
953
        }
954
955
        if (!array_key_exists($name, $this->_unsaved)) {
956
            return false;
957
        }
958
959
        if (!$hasChanged) {
960
            return true;
961
        }
962
963
        return $this->$name !== $this->ignoreUnsaved()->$name;
964
    }
965
966
    /**
967
     * Updates the model.
968
     *
969
     * @param array $data optional key-value properties to set
970
     *
971
     * @return bool true when the operation was successful
972
     *
973
     * @throws BadMethodCallException when not called on an existing model
974
     */
975
    public function set(array $data = []): bool
976
    {
977
        if (!$this->hasId) {
978
            throw new BadMethodCallException('Can only call set() on an existing model');
979
        }
980
981
        // mass assign values passed into set()
982
        $this->setValues($data);
983
984
        // clear any previous errors
985
        $this->getErrors()->clear();
986
987
        // not updating anything?
988
        if (0 == count($this->_unsaved)) {
989
            return true;
990
        }
991
992
        // start a DB transaction if needed
993
        $usesTransactions = $this->usesTransactions();
994
        if ($usesTransactions) {
995
            self::$driver->startTransaction($this->getConnection());
996
        }
997
998
        // dispatch the model.updating event
999
        if (!$this->performDispatch(ModelEvent::UPDATING, $usesTransactions)) {
1000
            return false;
1001
        }
1002
1003
        // save any relationships
1004
        if (!$this->saveRelationships($usesTransactions)) {
1005
            return false;
1006
        }
1007
1008
        // validate the values being saved
1009
        $validated = true;
1010
        $updateArray = [];
1011
        $preservedValues = [];
1012
        foreach ($this->_unsaved as $name => $value) {
1013
            // exclude if value does not map to a property
1014
            if (!static::definition()->has($name)) {
1015
                continue;
1016
            }
1017
1018
            $property = static::definition()->get($name);
1019
1020
            // check if this property is persisted to the DB
1021
            if (!$property->isPersisted()) {
1022
                $preservedValues[$name] = $value;
1023
                continue;
1024
            }
1025
1026
            // can only modify mutable properties
1027
            if (!$property->isMutable()) {
1028
                continue;
1029
            }
1030
1031
            $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 1018 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...
1032
            $updateArray[$name] = $value;
1033
        }
1034
1035
        if (!$validated) {
1036
            // when validations fail roll back any database transaction
1037
            if ($usesTransactions) {
1038
                self::$driver->rollBackTransaction($this->getConnection());
1039
            }
1040
1041
            return false;
1042
        }
1043
1044
        $updated = self::$driver->updateModel($this, $updateArray);
1045
1046
        if ($updated) {
1047
            // store the persisted values to the in-memory cache
1048
            $this->_unsaved = [];
1049
            $this->refreshWith(array_replace($this->_values, $preservedValues, $updateArray));
1050
1051
            // dispatch the model.updated event
1052
            if (!$this->performDispatch(ModelEvent::UPDATED, $usesTransactions)) {
1053
                return false;
1054
            }
1055
        }
1056
1057
        // commit the transaction, if used
1058
        if ($usesTransactions) {
1059
            self::$driver->commitTransaction($this->getConnection());
1060
        }
1061
1062
        return $updated;
1063
    }
1064
1065
    /**
1066
     * Delete the model.
1067
     *
1068
     * @return bool true when the operation was successful
1069
     */
1070
    public function delete(): bool
1071
    {
1072
        if (!$this->hasId) {
1073
            throw new BadMethodCallException('Can only call delete() on an existing model');
1074
        }
1075
1076
        // clear any previous errors
1077
        $this->getErrors()->clear();
1078
1079
        // start a DB transaction if needed
1080
        $usesTransactions = $this->usesTransactions();
1081
        if ($usesTransactions) {
1082
            self::$driver->startTransaction($this->getConnection());
1083
        }
1084
1085
        // dispatch the model.deleting event
1086
        if (!$this->performDispatch(ModelEvent::DELETING, $usesTransactions)) {
1087
            return false;
1088
        }
1089
1090
        // perform a hard (default) or soft delete
1091
        $hardDelete = true;
1092
        if (property_exists($this, 'softDelete')) {
1093
            $t = time();
1094
            $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...
1095
            $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...
1096
            $deleted = self::$driver->updateModel($this, ['deleted_at' => $t]);
1097
            $hardDelete = false;
1098
        } else {
1099
            $deleted = self::$driver->deleteModel($this);
1100
        }
1101
1102
        if ($deleted) {
1103
            // dispatch the model.deleted event
1104
            if (!$this->performDispatch(ModelEvent::DELETED, $usesTransactions)) {
1105
                return false;
1106
            }
1107
1108
            if ($hardDelete) {
1109
                $this->_persisted = false;
1110
            }
1111
        }
1112
1113
        // commit the transaction, if used
1114
        if ($usesTransactions) {
1115
            self::$driver->commitTransaction($this->getConnection());
1116
        }
1117
1118
        return $deleted;
1119
    }
1120
1121
    /**
1122
     * Restores a soft-deleted model.
1123
     */
1124
    public function restore(): bool
1125
    {
1126
        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...
1127
            throw new BadMethodCallException('Can only call restore() on a soft-deleted model');
1128
        }
1129
1130
        // start a DB transaction if needed
1131
        $usesTransactions = $this->usesTransactions();
1132
        if ($usesTransactions) {
1133
            self::$driver->startTransaction($this->getConnection());
1134
        }
1135
1136
        // dispatch the model.updating event
1137
        if (!$this->performDispatch(ModelEvent::UPDATING, $usesTransactions)) {
1138
            return false;
1139
        }
1140
1141
        $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...
1142
        $restored = self::$driver->updateModel($this, ['deleted_at' => null]);
1143
1144
        if ($restored) {
1145
            // dispatch the model.updated event
1146
            if (!$this->performDispatch(ModelEvent::UPDATED, $usesTransactions)) {
1147
                return false;
1148
            }
1149
        }
1150
1151
        // commit the transaction, if used
1152
        if ($usesTransactions) {
1153
            self::$driver->commitTransaction($this->getConnection());
1154
        }
1155
1156
        return $restored;
1157
    }
1158
1159
    /**
1160
     * Checks if the model has been deleted.
1161
     */
1162
    public function isDeleted(): bool
1163
    {
1164
        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...
1165
            return true;
1166
        }
1167
1168
        return !$this->_persisted;
1169
    }
1170
1171
    /////////////////////////////
1172
    // Queries
1173
    /////////////////////////////
1174
1175
    /**
1176
     * Generates a new query instance.
1177
     */
1178
    public static function query(): Query
1179
    {
1180
        // Create a new model instance for the query to ensure
1181
        // that the model's initialize() method gets called.
1182
        // Otherwise, the property definitions will be incomplete.
1183
        $model = new static();
1184
        $query = new Query($model);
1185
1186
        // scope soft-deleted models to only include non-deleted models
1187
        if (property_exists($model, 'softDelete')) {
1188
            $query->where('deleted_at IS NOT NULL');
1189
        }
1190
1191
        return $query;
1192
    }
1193
1194
    /**
1195
     * Generates a new query instance that includes soft-deleted models.
1196
     */
1197
    public static function withDeleted(): Query
1198
    {
1199
        // Create a new model instance for the query to ensure
1200
        // that the model's initialize() method gets called.
1201
        // Otherwise, the property definitions will be incomplete.
1202
        $model = new static();
1203
1204
        return new Query($model);
1205
    }
1206
1207
    /**
1208
     * Finds a single instance of a model given it's ID.
1209
     *
1210
     * @param mixed $id
1211
     *
1212
     * @return static|null
1213
     */
1214
    public static function find($id): ?self
1215
    {
1216
        $ids = [];
1217
        $id = (array) $id;
1218
        foreach (static::$ids as $j => $k) {
1219
            if (isset($id[$j])) {
1220
                $ids[$k] = $id[$j];
1221
            }
1222
        }
1223
1224
        // malformed ID
1225
        if (count($ids) < count(static::$ids)) {
1226
            return null;
1227
        }
1228
1229
        return static::query()->where($ids)->first();
1230
    }
1231
1232
    /**
1233
     * Finds a single instance of a model given it's ID or throws an exception.
1234
     *
1235
     * @param mixed $id
1236
     *
1237
     * @return static
1238
     *
1239
     * @throws ModelNotFoundException when a model could not be found
1240
     */
1241
    public static function findOrFail($id): self
1242
    {
1243
        $model = static::find($id);
1244
        if (!$model) {
1245
            throw new ModelNotFoundException('Could not find the requested '.static::modelName());
1246
        }
1247
1248
        return $model;
1249
    }
1250
1251
    /**
1252
     * Tells if this model instance has been persisted to the data layer.
1253
     *
1254
     * NOTE: this does not actually perform a check with the data layer
1255
     */
1256
    public function persisted(): bool
1257
    {
1258
        return $this->_persisted;
1259
    }
1260
1261
    /**
1262
     * Loads the model from the storage layer.
1263
     *
1264
     * @return $this
1265
     */
1266
    public function refresh()
1267
    {
1268
        if (!$this->hasId) {
1269
            return $this;
1270
        }
1271
1272
        $values = self::$driver->loadModel($this);
1273
1274
        if (!is_array($values)) {
1275
            return $this;
1276
        }
1277
1278
        // clear any relations
1279
        $this->_relationships = [];
1280
1281
        return $this->refreshWith($values);
1282
    }
1283
1284
    /**
1285
     * Loads values into the model.
1286
     *
1287
     * @param array $values values
1288
     *
1289
     * @return $this
1290
     */
1291
    public function refreshWith(array $values)
1292
    {
1293
        // type cast the values
1294
        foreach ($values as $k => &$value) {
1295
            if ($property = static::definition()->get($k)) {
1296
                $value = Type::cast($property, $value);
1297
            }
1298
        }
1299
1300
        $this->loaded = true;
1301
        $this->_persisted = true;
1302
        $this->_values = $values;
1303
1304
        return $this;
1305
    }
1306
1307
    /**
1308
     * Clears the cache for this model.
1309
     *
1310
     * @return $this
1311
     */
1312
    public function clearCache()
1313
    {
1314
        $this->loaded = false;
1315
        $this->_unsaved = [];
1316
        $this->_values = [];
1317
        $this->_relationships = [];
1318
1319
        return $this;
1320
    }
1321
1322
    /////////////////////////////
1323
    // Relationships
1324
    /////////////////////////////
1325
1326
    /**
1327
     * Gets the relationship manager for a property.
1328
     *
1329
     * @throws InvalidArgumentException when the relationship manager cannot be created
1330
     */
1331
    private function getRelationship(Property $property): AbstractRelation
1332
    {
1333
        $name = $property->getName();
1334
        if (!isset($this->relationships[$name])) {
1335
            $this->relationships[$name] = Relationship::make($this, $property);
1336
        }
1337
1338
        return $this->relationships[$name];
1339
    }
1340
1341
    /**
1342
     * Saves any unsaved models attached through a relationship. This will only
1343
     * save attached models that have not been saved yet.
1344
     */
1345
    private function saveRelationships(bool $usesTransactions): bool
1346
    {
1347
        try {
1348
            foreach ($this->_unsaved as $k => $value) {
1349
                if ($value instanceof self && !$value->persisted()) {
1350
                    $property = static::definition()->get($k);
1351
                    if ($property && !$property->isPersisted()) {
1352
                        $value->saveOrFail();
1353
                        // set the model again to update any ID properties
1354
                        $this->$k = $value;
1355
                    }
1356
                } elseif (is_array($value)) {
1357
                    foreach ($value as $subValue) {
1358
                        if ($subValue instanceof self && !$subValue->persisted()) {
1359
                            $property = static::definition()->get($k);
1360
                            if ($property && !$property->isPersisted()) {
1361
                                $subValue->saveOrFail();
1362
                            }
1363
                        }
1364
                    }
1365
                }
1366
            }
1367
        } catch (ModelException $e) {
1368
            $this->getErrors()->add($e->getMessage());
1369
1370
            if ($usesTransactions) {
1371
                self::$driver->rollBackTransaction($this->getConnection());
1372
            }
1373
1374
            return false;
1375
        }
1376
1377
        return true;
1378
    }
1379
1380
    /**
1381
     * This hydrates an individual property in the model. It can be a
1382
     * scalar value or relationship.
1383
     *
1384
     * @internal
1385
     *
1386
     * @param $value
1387
     */
1388
    public function hydrateValue(string $name, $value): void
1389
    {
1390
        if ($property = static::definition()->get($name)) {
1391
            $this->_values[$name] = Type::cast($property, $value);
1392
        } else {
1393
            $this->_values[$name] = $value;
1394
        }
1395
    }
1396
1397
    /**
1398
     * @deprecated
1399
     *
1400
     * Gets the model(s) for a relationship
1401
     *
1402
     * @param string $k property
1403
     *
1404
     * @throws InvalidArgumentException when the relationship manager cannot be created
1405
     *
1406
     * @return Model|array|null
1407
     */
1408
    public function relation(string $k)
1409
    {
1410
        if (!array_key_exists($k, $this->_relationships)) {
1411
            $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...
1412
            $this->_relationships[$k] = $relation->getResults();
1413
        }
1414
1415
        return $this->_relationships[$k];
1416
    }
1417
1418
    /**
1419
     * @deprecated
1420
     *
1421
     * Sets the model for a one-to-one relationship (has-one or belongs-to)
1422
     *
1423
     * @return $this
1424
     */
1425
    public function setRelation(string $k, Model $model)
1426
    {
1427
        $this->$k = $model->id();
1428
        $this->_relationships[$k] = $model;
1429
1430
        return $this;
1431
    }
1432
1433
    /**
1434
     * @deprecated
1435
     *
1436
     * Sets the model for a one-to-many relationship
1437
     *
1438
     * @return $this
1439
     */
1440
    public function setRelationCollection(string $k, iterable $models)
1441
    {
1442
        $this->_relationships[$k] = $models;
1443
1444
        return $this;
1445
    }
1446
1447
    /**
1448
     * @deprecated
1449
     *
1450
     * Sets the model for a one-to-one relationship (has-one or belongs-to) as null
1451
     *
1452
     * @return $this
1453
     */
1454
    public function clearRelation(string $k)
1455
    {
1456
        $this->$k = null;
1457
        $this->_relationships[$k] = null;
1458
1459
        return $this;
1460
    }
1461
1462
    /////////////////////////////
1463
    // Events
1464
    /////////////////////////////
1465
1466
    /**
1467
     * Gets the event dispatcher.
1468
     */
1469
    public static function getDispatcher($ignoreCache = false): EventDispatcher
1470
    {
1471
        $class = static::class;
1472
        if ($ignoreCache || !isset(self::$dispatchers[$class])) {
1473
            self::$dispatchers[$class] = new EventDispatcher();
1474
        }
1475
1476
        return self::$dispatchers[$class];
1477
    }
1478
1479
    /**
1480
     * Subscribes to a listener to an event.
1481
     *
1482
     * @param string $event    event name
1483
     * @param int    $priority optional priority, higher #s get called first
1484
     */
1485
    public static function listen(string $event, callable $listener, int $priority = 0)
1486
    {
1487
        static::getDispatcher()->addListener($event, $listener, $priority);
1488
    }
1489
1490
    /**
1491
     * Adds a listener to the model.creating and model.updating events.
1492
     */
1493
    public static function saving(callable $listener, int $priority = 0)
1494
    {
1495
        static::listen(ModelEvent::CREATING, $listener, $priority);
1496
        static::listen(ModelEvent::UPDATING, $listener, $priority);
1497
    }
1498
1499
    /**
1500
     * Adds a listener to the model.created and model.updated events.
1501
     */
1502
    public static function saved(callable $listener, int $priority = 0)
1503
    {
1504
        static::listen(ModelEvent::CREATED, $listener, $priority);
1505
        static::listen(ModelEvent::UPDATED, $listener, $priority);
1506
    }
1507
1508
    /**
1509
     * Adds a listener to the model.creating event.
1510
     */
1511
    public static function creating(callable $listener, int $priority = 0)
1512
    {
1513
        static::listen(ModelEvent::CREATING, $listener, $priority);
1514
    }
1515
1516
    /**
1517
     * Adds a listener to the model.created event.
1518
     */
1519
    public static function created(callable $listener, int $priority = 0)
1520
    {
1521
        static::listen(ModelEvent::CREATED, $listener, $priority);
1522
    }
1523
1524
    /**
1525
     * Adds a listener to the model.updating event.
1526
     */
1527
    public static function updating(callable $listener, int $priority = 0)
1528
    {
1529
        static::listen(ModelEvent::UPDATING, $listener, $priority);
1530
    }
1531
1532
    /**
1533
     * Adds a listener to the model.updated event.
1534
     */
1535
    public static function updated(callable $listener, int $priority = 0)
1536
    {
1537
        static::listen(ModelEvent::UPDATED, $listener, $priority);
1538
    }
1539
1540
    /**
1541
     * Adds a listener to the model.deleting event.
1542
     */
1543
    public static function deleting(callable $listener, int $priority = 0)
1544
    {
1545
        static::listen(ModelEvent::DELETING, $listener, $priority);
1546
    }
1547
1548
    /**
1549
     * Adds a listener to the model.deleted event.
1550
     */
1551
    public static function deleted(callable $listener, int $priority = 0)
1552
    {
1553
        static::listen(ModelEvent::DELETED, $listener, $priority);
1554
    }
1555
1556
    /**
1557
     * Dispatches the given event and checks if it was successful.
1558
     *
1559
     * @return bool true if the events were successfully propagated
1560
     */
1561
    private function performDispatch(string $eventName, bool $usesTransactions): bool
1562
    {
1563
        $event = new ModelEvent($this);
1564
        static::getDispatcher()->dispatch($event, $eventName);
1565
1566
        // when listeners fail roll back any database transaction
1567
        if ($event->isPropagationStopped()) {
1568
            if ($usesTransactions) {
1569
                self::$driver->rollBackTransaction($this->getConnection());
1570
            }
1571
1572
            return false;
1573
        }
1574
1575
        return true;
1576
    }
1577
1578
    /////////////////////////////
1579
    // Validation
1580
    /////////////////////////////
1581
1582
    /**
1583
     * Gets the error stack for this model.
1584
     */
1585
    public function getErrors(): Errors
1586
    {
1587
        if (!$this->errors) {
1588
            $this->errors = new Errors();
1589
        }
1590
1591
        return $this->errors;
1592
    }
1593
1594
    /**
1595
     * Checks if the model in its current state is valid.
1596
     */
1597
    public function valid(): bool
1598
    {
1599
        // clear any previous errors
1600
        $this->getErrors()->clear();
1601
1602
        // run the validator against the model values
1603
        $values = $this->_unsaved + $this->_values;
1604
1605
        $validated = true;
1606
        foreach ($values as $k => $v) {
1607
            $property = static::definition()->get($k);
1608
            $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 1607 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...
1609
        }
1610
1611
        // add back any modified unsaved values
1612
        foreach (array_keys($this->_unsaved) as $k) {
1613
            $this->_unsaved[$k] = $values[$k];
1614
        }
1615
1616
        return $validated;
1617
    }
1618
}
1619