Completed
Push — master ( 0634a9...c9b86f )
by Jared
01:31
created

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