Completed
Push — master ( 5b425e...08c9ff )
by Jared
01:46
created

Model::setAutoTimestamps()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 8

Duplication

Lines 0
Ratio 0 %

Importance

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