Completed
Push — master ( 80ea94...c079a8 )
by Jared
01:32
created

Model::filterAndValidate()   B

Complexity

Conditions 7
Paths 7

Size

Total Lines 30

Duplication

Lines 0
Ratio 0 %

Importance

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

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

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

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

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

Loading history...
156
     * @param array                    $values optional key-value map to pre-seed model
157
     */
158
    public function __construct(array $values = [])
159
    {
160
        // initialize the model
161
        $this->init();
162
163
        $ids = [];
164
        $this->hasId = true;
165
        foreach (static::$ids as $name) {
166
            $id = false;
167
            if (array_key_exists($name, $values)) {
168
                $idProperty = static::getProperty($name);
169
                $id = Type::cast($idProperty, $values[$name]);
0 ignored issues
show
Bug introduced by
It seems like $idProperty defined by static::getProperty($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::getProperty($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::hasProperty($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 definition of all model properties.
438
     */
439
    public static function getProperties(): 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 definition of a specific property.
459
     *
460
     * @param string $property property to lookup
461
     */
462
    public static function getProperty(string $property): ?Property
463
    {
464
        return static::getProperties()->get($property);
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
     * Checks if the model has a property.
477
     *
478
     * @param string $property property
479
     *
480
     * @return bool has property
481
     */
482
    public static function hasProperty(string $property): bool
483
    {
484
        return static::getProperties()->has($property);
485
    }
486
487
    /**
488
     * Gets the mutator method name for a given property name.
489
     * Looks for methods in the form of `setPropertyValue`.
490
     * i.e. the mutator for `last_name` would be `setLastNameValue`.
491
     *
492
     * @param string $property property
493
     *
494
     * @return string|null method name if it exists
495
     */
496
    public static function getMutator(string $property): ?string
497
    {
498
        $class = static::class;
499
500
        $k = $class.':'.$property;
501
        if (!array_key_exists($k, self::$mutators)) {
502
            $inflector = Inflector::get();
503
            $method = 'set'.$inflector->camelize($property).'Value';
504
505
            if (!method_exists($class, $method)) {
506
                $method = null;
507
            }
508
509
            self::$mutators[$k] = $method;
510
        }
511
512
        return self::$mutators[$k];
513
    }
514
515
    /**
516
     * Gets the accessor method name for a given property name.
517
     * Looks for methods in the form of `getPropertyValue`.
518
     * i.e. the accessor for `last_name` would be `getLastNameValue`.
519
     *
520
     * @param string $property property
521
     *
522
     * @return string|null method name if it exists
523
     */
524
    public static function getAccessor(string $property): ?string
525
    {
526
        $class = static::class;
527
528
        $k = $class.':'.$property;
529
        if (!array_key_exists($k, self::$accessors)) {
530
            $inflector = Inflector::get();
531
            $method = 'get'.$inflector->camelize($property).'Value';
532
533
            if (!method_exists($class, $method)) {
534
                $method = null;
535
            }
536
537
            self::$accessors[$k] = $method;
538
        }
539
540
        return self::$accessors[$k];
541
    }
542
543
    /////////////////////////////
544
    // CRUD Operations
545
    /////////////////////////////
546
547
    /**
548
     * Gets the table name for storing this model.
549
     */
550
    public function getTablename(): string
551
    {
552
        if (!$this->tablename) {
553
            $inflector = Inflector::get();
554
555
            $this->tablename = $inflector->camelize($inflector->pluralize(static::modelName()));
556
        }
557
558
        return $this->tablename;
559
    }
560
561
    /**
562
     * Gets the ID of the connection in the connection manager
563
     * that stores this model.
564
     */
565
    public function getConnection(): ?string
566
    {
567
        return null;
568
    }
569
570
    protected function usesTransactions(): bool
571
    {
572
        return false;
573
    }
574
575
    /**
576
     * Saves the model.
577
     *
578
     * @return bool true when the operation was successful
579
     */
580
    public function save(): bool
581
    {
582
        if (!$this->hasId) {
583
            return $this->create();
584
        }
585
586
        return $this->set();
587
    }
588
589
    /**
590
     * Saves the model. Throws an exception when the operation fails.
591
     *
592
     * @throws ModelException when the model cannot be saved
593
     */
594
    public function saveOrFail()
595
    {
596
        if (!$this->save()) {
597
            $msg = 'Failed to save '.static::modelName();
598
            if ($validationErrors = $this->getErrors()->all()) {
599
                $msg .= ': '.implode(', ', $validationErrors);
600
            }
601
602
            throw new ModelException($msg);
603
        }
604
    }
605
606
    /**
607
     * Creates a new model.
608
     *
609
     * @param array $data optional key-value properties to set
610
     *
611
     * @return bool true when the operation was successful
612
     *
613
     * @throws BadMethodCallException when called on an existing model
614
     */
615
    public function create(array $data = []): bool
616
    {
617
        if ($this->hasId) {
618
            throw new BadMethodCallException('Cannot call create() on an existing model');
619
        }
620
621
        // mass assign values passed into create()
622
        $this->setValues($data);
623
624
        // clear any previous errors
625
        $this->getErrors()->clear();
626
627
        // start a DB transaction if needed
628
        $usesTransactions = $this->usesTransactions();
629
        if ($usesTransactions) {
630
            self::$driver->startTransaction($this->getConnection());
631
        }
632
633
        // dispatch the model.creating event
634
        if (!$this->performDispatch(ModelEvent::CREATING, $usesTransactions)) {
635
            return false;
636
        }
637
638
        $requiredProperties = [];
639
        foreach (static::getProperties()->all() as $name => $property) {
640
            // build a list of the required properties
641
            if ($property->isRequired()) {
642
                $requiredProperties[] = $property;
643
            }
644
645
            // add in default values
646
            if (!array_key_exists($name, $this->_unsaved) && $property->hasDefault()) {
647
                $this->_unsaved[$name] = $property->getDefault();
648
            }
649
        }
650
651
        // save any relationships
652
        if (!$this->saveRelationships($usesTransactions)) {
653
            return false;
654
        }
655
656
        // validate the values being saved
657
        $validated = true;
658
        $insertArray = [];
659
        $preservedValues = [];
660
        foreach ($this->_unsaved as $name => $value) {
661
            // exclude if value does not map to a property
662
            $property = static::getProperty($name);
663
            if (!$property) {
664
                continue;
665
            }
666
667
            // check if this property is persisted to the DB
668
            if (!$property->isPersisted()) {
669
                $preservedValues[$name] = $value;
670
                continue;
671
            }
672
673
            // cannot insert immutable values
674
            // (unless using the default value)
675
            if ($property->isImmutable() && $value !== $property->getDefault()) {
676
                continue;
677
            }
678
679
            $validated = $validated && $this->filterAndValidate($property, $value);
680
            $insertArray[$name] = $value;
681
        }
682
683
        // check for required fields
684
        foreach ($requiredProperties as $property) {
685
            $name = $property->getName();
686
            if (!isset($insertArray[$name]) && !isset($preservedValues[$name])) {
687
                $params = [
688
                    'field' => $name,
689
                    'field_name' => $property->getTitle($this),
690
                ];
691
                $this->getErrors()->add('pulsar.validation.required', $params);
692
693
                $validated = false;
694
            }
695
        }
696
697
        if (!$validated) {
698
            // when validations fail roll back any database transaction
699
            if ($usesTransactions) {
700
                self::$driver->rollBackTransaction($this->getConnection());
701
            }
702
703
            return false;
704
        }
705
706
        $created = self::$driver->createModel($this, $insertArray);
707
708
        if ($created) {
709
            // determine the model's new ID
710
            $this->getNewID();
711
712
            // store the persisted values to the in-memory cache
713
            $this->_unsaved = [];
714
            $this->refreshWith(array_replace($this->idValues, $preservedValues, $insertArray));
715
716
            // dispatch the model.created event
717
            if (!$this->performDispatch(ModelEvent::CREATED, $usesTransactions)) {
718
                return false;
719
            }
720
        }
721
722
        // commit the transaction, if used
723
        if ($usesTransactions) {
724
            self::$driver->commitTransaction($this->getConnection());
725
        }
726
727
        return $created;
728
    }
729
730
    /**
731
     * Ignores unsaved values when fetching the next value.
732
     *
733
     * @return $this
734
     */
735
    public function ignoreUnsaved()
736
    {
737
        $this->ignoreUnsaved = true;
738
739
        return $this;
740
    }
741
742
    /**
743
     * Fetches property values from the model.
744
     *
745
     * This method looks up values in this order:
746
     * IDs, local cache, unsaved values, storage layer, defaults
747
     *
748
     * @param array $properties list of property names to fetch values of
749
     */
750
    public function get(array $properties): array
751
    {
752
        // load the values from the IDs and local model cache
753
        $values = array_replace($this->ids(), $this->_values);
754
755
        // unless specified, use any unsaved values
756
        $ignoreUnsaved = $this->ignoreUnsaved;
757
        $this->ignoreUnsaved = false;
758
759
        if (!$ignoreUnsaved) {
760
            $values = array_replace($values, $this->_unsaved);
761
        }
762
763
        // see if there are any model properties that do not exist.
764
        // when true then this means the model needs to be hydrated
765
        // NOTE: only looking at model properties and excluding dynamic/non-existent properties
766
        $modelProperties = static::getProperties()->propertyNames();
767
        $numMissing = count(array_intersect($modelProperties, array_diff($properties, array_keys($values))));
768
769
        if ($numMissing > 0 && !$this->loaded) {
770
            // load the model from the storage layer, if needed
771
            $this->refresh();
772
773
            $values = array_replace($values, $this->_values);
774
775
            if (!$ignoreUnsaved) {
776
                $values = array_replace($values, $this->_unsaved);
777
            }
778
        }
779
780
        // build a key-value map of the requested properties
781
        $return = [];
782
        foreach ($properties as $k) {
783
            $return[$k] = $this->getValue($k, $values);
784
        }
785
786
        return $return;
787
    }
788
789
    /**
790
     * Gets a property value from the model.
791
     *
792
     * Values are looked up in this order:
793
     *  1. unsaved values
794
     *  2. local values
795
     *  3. default value
796
     *  4. null
797
     *
798
     * @return mixed
799
     */
800
    private function getValue(string $name, array $values)
801
    {
802
        $value = null;
803
804
        if (array_key_exists($name, $values)) {
805
            $value = $values[$name];
806
        } elseif ($property = static::getProperty($name)) {
807
            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...
808
                $relationship = $this->getRelationship($property);
809
                $value = $this->_values[$name] = $relationship->getResults();
810
            } else {
811
                $value = $this->_values[$name] = $property->getDefault();
812
            }
813
        }
814
815
        // call any accessors
816
        if ($accessor = self::getAccessor($name)) {
817
            $value = $this->$accessor($value);
818
        }
819
820
        return $value;
821
    }
822
823
    /**
824
     * Populates a newly created model with its ID.
825
     */
826
    private function getNewID()
827
    {
828
        $ids = [];
829
        $namedIds = [];
830
        foreach (static::$ids as $k) {
831
            // attempt use the supplied value if the ID property is mutable
832
            $property = static::getProperty($k);
833
            if (!$property->isImmutable() && isset($this->_unsaved[$k])) {
834
                $id = $this->_unsaved[$k];
835
            } else {
836
                $id = self::$driver->getCreatedID($this, $k);
837
            }
838
839
            $ids[] = $id;
840
            $namedIds[$k] = $id;
841
        }
842
843
        $this->hasId = true;
844
        $this->idValues = $namedIds;
845
    }
846
847
    /**
848
     * Sets a collection values on the model from an untrusted input.
849
     *
850
     * @param array $values
851
     *
852
     * @throws MassAssignmentException when assigning a value that is protected or not whitelisted
853
     *
854
     * @return $this
855
     */
856
    public function setValues($values)
857
    {
858
        // check if the model has a mass assignment whitelist
859
        $permitted = (property_exists($this, 'permitted')) ? static::$permitted : false;
860
861
        // if no whitelist, then check for a blacklist
862
        $protected = (!is_array($permitted) && property_exists($this, 'protected')) ? static::$protected : false;
863
864
        foreach ($values as $k => $value) {
865
            // check for mass assignment violations
866
            if (($permitted && !in_array($k, $permitted)) ||
867
                ($protected && in_array($k, $protected))) {
868
                throw new MassAssignmentException("Mass assignment of $k on ".static::modelName().' is not allowed');
869
            }
870
871
            $this->$k = $value;
872
        }
873
874
        return $this;
875
    }
876
877
    /**
878
     * Converts the model to an array.
879
     */
880
    public function toArray(): array
881
    {
882
        // build the list of properties to retrieve
883
        $properties = static::getProperties()->propertyNames();
884
885
        // remove any relationships
886
        $relationships = [];
887
        foreach (static::getProperties()->all() as $property) {
888
            if ($property->getRelationshipType() && !$property->isPersisted()) {
889
                $relationships[] = $property->getName();
890
            }
891
        }
892
        $properties = array_diff($properties, $relationships);
893
894
        // remove any hidden properties
895
        $hide = (property_exists($this, 'hidden')) ? static::$hidden : [];
896
        $properties = array_diff($properties, $hide);
897
898
        // add any appended properties
899
        $append = (property_exists($this, 'appended')) ? static::$appended : [];
900
        $properties = array_merge($properties, $append);
901
902
        // get the values for the properties
903
        $result = $this->get($properties);
904
905
        foreach ($result as $k => &$value) {
906
            // convert arrays of models to arrays
907
            if (is_array($value)) {
908
                foreach ($value as &$subValue) {
909
                    if ($subValue instanceof Model) {
910
                        $subValue = $subValue->toArray();
911
                    }
912
                }
913
            }
914
915
            // convert any models to arrays
916
            if ($value instanceof self) {
917
                $value = $value->toArray();
918
            }
919
        }
920
921
        return $result;
922
    }
923
924
    /**
925
     * Checks if the unsaved value for a property is present and
926
     * is different from the original value.
927
     *
928
     * @property string $name
929
     * @property bool   $hasChanged when true, checks if the unsaved value is different from the saved value
930
     */
931
    public function dirty(string $name, bool $hasChanged = false): bool
932
    {
933
        if (!array_key_exists($name, $this->_unsaved)) {
934
            return false;
935
        }
936
937
        if (!$hasChanged) {
938
            return true;
939
        }
940
941
        return $this->$name !== $this->ignoreUnsaved()->$name;
942
    }
943
944
    /**
945
     * Updates the model.
946
     *
947
     * @param array $data optional key-value properties to set
948
     *
949
     * @return bool true when the operation was successful
950
     *
951
     * @throws BadMethodCallException when not called on an existing model
952
     */
953
    public function set(array $data = []): bool
954
    {
955
        if (!$this->hasId) {
956
            throw new BadMethodCallException('Can only call set() on an existing model');
957
        }
958
959
        // mass assign values passed into set()
960
        $this->setValues($data);
961
962
        // clear any previous errors
963
        $this->getErrors()->clear();
964
965
        // not updating anything?
966
        if (0 == count($this->_unsaved)) {
967
            return true;
968
        }
969
970
        // start a DB transaction if needed
971
        $usesTransactions = $this->usesTransactions();
972
        if ($usesTransactions) {
973
            self::$driver->startTransaction($this->getConnection());
974
        }
975
976
        // dispatch the model.updating event
977
        if (!$this->performDispatch(ModelEvent::UPDATING, $usesTransactions)) {
978
            return false;
979
        }
980
981
        // save any relationships
982
        if (!$this->saveRelationships($usesTransactions)) {
983
            return false;
984
        }
985
986
        // validate the values being saved
987
        $validated = true;
988
        $updateArray = [];
989
        $preservedValues = [];
990
        foreach ($this->_unsaved as $name => $value) {
991
            // exclude if value does not map to a property
992
            if (!static::getProperties()->has($name)) {
993
                continue;
994
            }
995
996
            $property = static::getProperty($name);
997
998
            // check if this property is persisted to the DB
999
            if (!$property->isPersisted()) {
1000
                $preservedValues[$name] = $value;
1001
                continue;
1002
            }
1003
1004
            // can only modify mutable properties
1005
            if (!$property->isMutable()) {
1006
                continue;
1007
            }
1008
1009
            $validated = $validated && $this->filterAndValidate($property, $value);
0 ignored issues
show
Bug introduced by
It seems like $property defined by static::getProperty($name) on line 996 can be null; however, Pulsar\Model::filterAndValidate() does not accept null, maybe add an additional type check?

Unless you are absolutely sure that the expression can never be null because of other conditions, we strongly recommend to add an additional type check to your code:

/** @return stdClass|null */
function mayReturnNull() { }

function doesNotAcceptNull(stdClass $x) { }

// With potential error.
function withoutCheck() {
    $x = mayReturnNull();
    doesNotAcceptNull($x); // Potential error here.
}

// Safe - Alternative 1
function withCheck1() {
    $x = mayReturnNull();
    if ( ! $x instanceof stdClass) {
        throw new \LogicException('$x must be defined.');
    }
    doesNotAcceptNull($x);
}

// Safe - Alternative 2
function withCheck2() {
    $x = mayReturnNull();
    if ($x instanceof stdClass) {
        doesNotAcceptNull($x);
    }
}
Loading history...
1010
            $updateArray[$name] = $value;
1011
        }
1012
1013
        if (!$validated) {
1014
            // when validations fail roll back any database transaction
1015
            if ($usesTransactions) {
1016
                self::$driver->rollBackTransaction($this->getConnection());
1017
            }
1018
1019
            return false;
1020
        }
1021
1022
        $updated = self::$driver->updateModel($this, $updateArray);
1023
1024
        if ($updated) {
1025
            // store the persisted values to the in-memory cache
1026
            $this->_unsaved = [];
1027
            $this->refreshWith(array_replace($this->_values, $preservedValues, $updateArray));
1028
1029
            // dispatch the model.updated event
1030
            if (!$this->performDispatch(ModelEvent::UPDATED, $usesTransactions)) {
1031
                return false;
1032
            }
1033
        }
1034
1035
        // commit the transaction, if used
1036
        if ($usesTransactions) {
1037
            self::$driver->commitTransaction($this->getConnection());
1038
        }
1039
1040
        return $updated;
1041
    }
1042
1043
    /**
1044
     * Delete the model.
1045
     *
1046
     * @return bool true when the operation was successful
1047
     */
1048
    public function delete(): bool
1049
    {
1050
        if (!$this->hasId) {
1051
            throw new BadMethodCallException('Can only call delete() on an existing model');
1052
        }
1053
1054
        // clear any previous errors
1055
        $this->getErrors()->clear();
1056
1057
        // start a DB transaction if needed
1058
        $usesTransactions = $this->usesTransactions();
1059
        if ($usesTransactions) {
1060
            self::$driver->startTransaction($this->getConnection());
1061
        }
1062
1063
        // dispatch the model.deleting event
1064
        if (!$this->performDispatch(ModelEvent::DELETING, $usesTransactions)) {
1065
            return false;
1066
        }
1067
1068
        // perform a hard (default) or soft delete
1069
        $hardDelete = true;
1070
        if (property_exists($this, 'softDelete')) {
1071
            $t = time();
1072
            $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...
1073
            $t = $this->filterAndValidate(static::getProperty('deleted_at'), $t);
0 ignored issues
show
Bug introduced by
It seems like static::getProperty('deleted_at') can be null; however, filterAndValidate() does not accept null, maybe add an additional type check?

Unless you are absolutely sure that the expression can never be null because of other conditions, we strongly recommend to add an additional type check to your code:

/** @return stdClass|null */
function mayReturnNull() { }

function doesNotAcceptNull(stdClass $x) { }

// With potential error.
function withoutCheck() {
    $x = mayReturnNull();
    doesNotAcceptNull($x); // Potential error here.
}

// Safe - Alternative 1
function withCheck1() {
    $x = mayReturnNull();
    if ( ! $x instanceof stdClass) {
        throw new \LogicException('$x must be defined.');
    }
    doesNotAcceptNull($x);
}

// Safe - Alternative 2
function withCheck2() {
    $x = mayReturnNull();
    if ($x instanceof stdClass) {
        doesNotAcceptNull($x);
    }
}
Loading history...
1074
            $deleted = self::$driver->updateModel($this, ['deleted_at' => $t]);
1075
            $hardDelete = false;
1076
        } else {
1077
            $deleted = self::$driver->deleteModel($this);
1078
        }
1079
1080
        if ($deleted) {
1081
            // dispatch the model.deleted event
1082
            if (!$this->performDispatch(ModelEvent::DELETED, $usesTransactions)) {
1083
                return false;
1084
            }
1085
1086
            if ($hardDelete) {
1087
                $this->_persisted = false;
1088
            }
1089
        }
1090
1091
        // commit the transaction, if used
1092
        if ($usesTransactions) {
1093
            self::$driver->commitTransaction($this->getConnection());
1094
        }
1095
1096
        return $deleted;
1097
    }
1098
1099
    /**
1100
     * Restores a soft-deleted model.
1101
     */
1102
    public function restore(): bool
1103
    {
1104
        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...
1105
            throw new BadMethodCallException('Can only call restore() on a soft-deleted model');
1106
        }
1107
1108
        // start a DB transaction if needed
1109
        $usesTransactions = $this->usesTransactions();
1110
        if ($usesTransactions) {
1111
            self::$driver->startTransaction($this->getConnection());
1112
        }
1113
1114
        // dispatch the model.updating event
1115
        if (!$this->performDispatch(ModelEvent::UPDATING, $usesTransactions)) {
1116
            return false;
1117
        }
1118
1119
        $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...
1120
        $restored = self::$driver->updateModel($this, ['deleted_at' => null]);
1121
1122
        if ($restored) {
1123
            // dispatch the model.updated event
1124
            if (!$this->performDispatch(ModelEvent::UPDATED, $usesTransactions)) {
1125
                return false;
1126
            }
1127
        }
1128
1129
        // commit the transaction, if used
1130
        if ($usesTransactions) {
1131
            self::$driver->commitTransaction($this->getConnection());
1132
        }
1133
1134
        return $restored;
1135
    }
1136
1137
    /**
1138
     * Checks if the model has been deleted.
1139
     */
1140
    public function isDeleted(): bool
1141
    {
1142
        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...
1143
            return true;
1144
        }
1145
1146
        return !$this->_persisted;
1147
    }
1148
1149
    /////////////////////////////
1150
    // Queries
1151
    /////////////////////////////
1152
1153
    /**
1154
     * Generates a new query instance.
1155
     */
1156
    public static function query(): Query
1157
    {
1158
        // Create a new model instance for the query to ensure
1159
        // that the model's initialize() method gets called.
1160
        // Otherwise, the property definitions will be incomplete.
1161
        $model = new static();
1162
        $query = new Query($model);
1163
1164
        // scope soft-deleted models to only include non-deleted models
1165
        if (property_exists($model, 'softDelete')) {
1166
            $query->where('deleted_at IS NOT NULL');
1167
        }
1168
1169
        return $query;
1170
    }
1171
1172
    /**
1173
     * Generates a new query instance that includes soft-deleted models.
1174
     */
1175
    public static function withDeleted(): Query
1176
    {
1177
        // Create a new model instance for the query to ensure
1178
        // that the model's initialize() method gets called.
1179
        // Otherwise, the property definitions will be incomplete.
1180
        $model = new static();
1181
1182
        return new Query($model);
1183
    }
1184
1185
    /**
1186
     * Finds a single instance of a model given it's ID.
1187
     *
1188
     * @param mixed $id
1189
     *
1190
     * @return static|null
1191
     */
1192
    public static function find($id): ?self
1193
    {
1194
        $ids = [];
1195
        $id = (array) $id;
1196
        foreach (static::$ids as $j => $k) {
1197
            if (isset($id[$j])) {
1198
                $ids[$k] = $id[$j];
1199
            }
1200
        }
1201
1202
        // malformed ID
1203
        if (count($ids) < count(static::$ids)) {
1204
            return null;
1205
        }
1206
1207
        return static::query()->where($ids)->first();
1208
    }
1209
1210
    /**
1211
     * Finds a single instance of a model given it's ID or throws an exception.
1212
     *
1213
     * @param mixed $id
1214
     *
1215
     * @return static
1216
     *
1217
     * @throws ModelNotFoundException when a model could not be found
1218
     */
1219
    public static function findOrFail($id): self
1220
    {
1221
        $model = static::find($id);
1222
        if (!$model) {
1223
            throw new ModelNotFoundException('Could not find the requested '.static::modelName());
1224
        }
1225
1226
        return $model;
1227
    }
1228
1229
    /**
1230
     * Tells if this model instance has been persisted to the data layer.
1231
     *
1232
     * NOTE: this does not actually perform a check with the data layer
1233
     */
1234
    public function persisted(): bool
1235
    {
1236
        return $this->_persisted;
1237
    }
1238
1239
    /**
1240
     * Loads the model from the storage layer.
1241
     *
1242
     * @return $this
1243
     */
1244
    public function refresh()
1245
    {
1246
        if (!$this->hasId) {
1247
            return $this;
1248
        }
1249
1250
        $values = self::$driver->loadModel($this);
1251
1252
        if (!is_array($values)) {
1253
            return $this;
1254
        }
1255
1256
        // clear any relations
1257
        $this->_relationships = [];
1258
1259
        return $this->refreshWith($values);
1260
    }
1261
1262
    /**
1263
     * Loads values into the model.
1264
     *
1265
     * @param array $values values
1266
     *
1267
     * @return $this
1268
     */
1269
    public function refreshWith(array $values)
1270
    {
1271
        // type cast the values
1272
        foreach ($values as $k => &$value) {
1273
            if ($property = static::getProperty($k)) {
1274
                $value = Type::cast($property, $value);
1275
            }
1276
        }
1277
1278
        $this->loaded = true;
1279
        $this->_persisted = true;
1280
        $this->_values = $values;
1281
1282
        return $this;
1283
    }
1284
1285
    /**
1286
     * Clears the cache for this model.
1287
     *
1288
     * @return $this
1289
     */
1290
    public function clearCache()
1291
    {
1292
        $this->loaded = false;
1293
        $this->_unsaved = [];
1294
        $this->_values = [];
1295
        $this->_relationships = [];
1296
1297
        return $this;
1298
    }
1299
1300
    /////////////////////////////
1301
    // Relationships
1302
    /////////////////////////////
1303
1304
    /**
1305
     * Gets the relationship manager for a property.
1306
     *
1307
     * @throws InvalidArgumentException when the relationship manager cannot be created
1308
     */
1309
    private function getRelationship(Property $property): AbstractRelation
1310
    {
1311
        $name = $property->getName();
1312
        if (!isset($this->relationships[$name])) {
1313
            $this->relationships[$name] = Relationship::make($this, $property);
1314
        }
1315
1316
        return $this->relationships[$name];
1317
    }
1318
1319
    /**
1320
     * Saves any unsaved models attached through a relationship. This will only
1321
     * save attached models that have not been saved yet.
1322
     */
1323
    private function saveRelationships(bool $usesTransactions): bool
1324
    {
1325
        try {
1326
            foreach ($this->_unsaved as $k => $value) {
1327
                if ($value instanceof self) {
1328
                    if (!$value->persisted()) {
1329
                        $value->saveOrFail();
1330
                        // set the model again to update any ID properties
1331
                        $this->$k = $value;
1332
                    }
1333
                } elseif (is_array($value)) {
1334
                    foreach ($value as $subValue) {
1335
                        if ($subValue instanceof self) {
1336
                            $value->saveOrFail();
0 ignored issues
show
Bug introduced by
The method saveOrFail cannot be called on $value (of type array).

Methods can only be called on objects. This check looks for methods being called on variables that have been inferred to never be objects.

Loading history...
1337
                        }
1338
                    }
1339
                }
1340
            }
1341
        } catch (ModelException $e) {
1342
            $this->getErrors()->add($e->getMessage());
1343
1344
            if ($usesTransactions) {
1345
                self::$driver->rollBackTransaction($this->getConnection());
1346
            }
1347
1348
            return false;
1349
        }
1350
1351
        return true;
1352
    }
1353
1354
    /**
1355
     * This hydrates an individual property in the model. It can be a
1356
     * scalar value or relationship.
1357
     *
1358
     * @internal
1359
     *
1360
     * @param $value
1361
     */
1362
    public function hydrateValue(string $name, $value): void
1363
    {
1364
        if ($property = static::getProperty($name)) {
1365
            $this->_values[$name] = Type::cast($property, $value);
1366
        } else {
1367
            $this->_values[$name] = $value;
1368
        }
1369
    }
1370
1371
    /**
1372
     * @deprecated
1373
     *
1374
     * Gets the model(s) for a relationship
1375
     *
1376
     * @param string $k property
1377
     *
1378
     * @throws InvalidArgumentException when the relationship manager cannot be created
1379
     *
1380
     * @return Model|array|null
1381
     */
1382
    public function relation(string $k)
1383
    {
1384
        if (!array_key_exists($k, $this->_relationships)) {
1385
            $relation = Relationship::make($this, static::getProperty($k));
0 ignored issues
show
Bug introduced by
It seems like static::getProperty($k) can be null; however, make() does not accept null, maybe add an additional type check?

Unless you are absolutely sure that the expression can never be null because of other conditions, we strongly recommend to add an additional type check to your code:

/** @return stdClass|null */
function mayReturnNull() { }

function doesNotAcceptNull(stdClass $x) { }

// With potential error.
function withoutCheck() {
    $x = mayReturnNull();
    doesNotAcceptNull($x); // Potential error here.
}

// Safe - Alternative 1
function withCheck1() {
    $x = mayReturnNull();
    if ( ! $x instanceof stdClass) {
        throw new \LogicException('$x must be defined.');
    }
    doesNotAcceptNull($x);
}

// Safe - Alternative 2
function withCheck2() {
    $x = mayReturnNull();
    if ($x instanceof stdClass) {
        doesNotAcceptNull($x);
    }
}
Loading history...
1386
            $this->_relationships[$k] = $relation->getResults();
1387
        }
1388
1389
        return $this->_relationships[$k];
1390
    }
1391
1392
    /**
1393
     * @deprecated
1394
     *
1395
     * Sets the model for a one-to-one relationship (has-one or belongs-to)
1396
     *
1397
     * @return $this
1398
     */
1399
    public function setRelation(string $k, Model $model)
1400
    {
1401
        $this->$k = $model->id();
1402
        $this->_relationships[$k] = $model;
1403
1404
        return $this;
1405
    }
1406
1407
    /**
1408
     * @deprecated
1409
     *
1410
     * Sets the model for a one-to-many relationship
1411
     *
1412
     * @return $this
1413
     */
1414
    public function setRelationCollection(string $k, iterable $models)
1415
    {
1416
        $this->_relationships[$k] = $models;
1417
1418
        return $this;
1419
    }
1420
1421
    /**
1422
     * @deprecated
1423
     *
1424
     * Sets the model for a one-to-one relationship (has-one or belongs-to) as null
1425
     *
1426
     * @return $this
1427
     */
1428
    public function clearRelation(string $k)
1429
    {
1430
        $this->$k = null;
1431
        $this->_relationships[$k] = null;
1432
1433
        return $this;
1434
    }
1435
1436
    /////////////////////////////
1437
    // Events
1438
    /////////////////////////////
1439
1440
    /**
1441
     * Gets the event dispatcher.
1442
     */
1443
    public static function getDispatcher($ignoreCache = false): EventDispatcher
1444
    {
1445
        $class = static::class;
1446
        if ($ignoreCache || !isset(self::$dispatchers[$class])) {
1447
            self::$dispatchers[$class] = new EventDispatcher();
1448
        }
1449
1450
        return self::$dispatchers[$class];
1451
    }
1452
1453
    /**
1454
     * Subscribes to a listener to an event.
1455
     *
1456
     * @param string $event    event name
1457
     * @param int    $priority optional priority, higher #s get called first
1458
     */
1459
    public static function listen(string $event, callable $listener, int $priority = 0)
1460
    {
1461
        static::getDispatcher()->addListener($event, $listener, $priority);
1462
    }
1463
1464
    /**
1465
     * Adds a listener to the model.creating and model.updating events.
1466
     */
1467
    public static function saving(callable $listener, int $priority = 0)
1468
    {
1469
        static::listen(ModelEvent::CREATING, $listener, $priority);
1470
        static::listen(ModelEvent::UPDATING, $listener, $priority);
1471
    }
1472
1473
    /**
1474
     * Adds a listener to the model.created and model.updated events.
1475
     */
1476
    public static function saved(callable $listener, int $priority = 0)
1477
    {
1478
        static::listen(ModelEvent::CREATED, $listener, $priority);
1479
        static::listen(ModelEvent::UPDATED, $listener, $priority);
1480
    }
1481
1482
    /**
1483
     * Adds a listener to the model.creating event.
1484
     */
1485
    public static function creating(callable $listener, int $priority = 0)
1486
    {
1487
        static::listen(ModelEvent::CREATING, $listener, $priority);
1488
    }
1489
1490
    /**
1491
     * Adds a listener to the model.created event.
1492
     */
1493
    public static function created(callable $listener, int $priority = 0)
1494
    {
1495
        static::listen(ModelEvent::CREATED, $listener, $priority);
1496
    }
1497
1498
    /**
1499
     * Adds a listener to the model.updating event.
1500
     */
1501
    public static function updating(callable $listener, int $priority = 0)
1502
    {
1503
        static::listen(ModelEvent::UPDATING, $listener, $priority);
1504
    }
1505
1506
    /**
1507
     * Adds a listener to the model.updated event.
1508
     */
1509
    public static function updated(callable $listener, int $priority = 0)
1510
    {
1511
        static::listen(ModelEvent::UPDATED, $listener, $priority);
1512
    }
1513
1514
    /**
1515
     * Adds a listener to the model.deleting event.
1516
     */
1517
    public static function deleting(callable $listener, int $priority = 0)
1518
    {
1519
        static::listen(ModelEvent::DELETING, $listener, $priority);
1520
    }
1521
1522
    /**
1523
     * Adds a listener to the model.deleted event.
1524
     */
1525
    public static function deleted(callable $listener, int $priority = 0)
1526
    {
1527
        static::listen(ModelEvent::DELETED, $listener, $priority);
1528
    }
1529
1530
    /**
1531
     * Dispatches the given event and checks if it was successful.
1532
     *
1533
     * @return bool true if the events were successfully propagated
1534
     */
1535
    private function performDispatch(string $eventName, bool $usesTransactions): bool
1536
    {
1537
        $event = new ModelEvent($this);
1538
        static::getDispatcher()->dispatch($event, $eventName);
1539
1540
        // when listeners fail roll back any database transaction
1541
        if ($event->isPropagationStopped()) {
1542
            if ($usesTransactions) {
1543
                self::$driver->rollBackTransaction($this->getConnection());
1544
            }
1545
1546
            return false;
1547
        }
1548
1549
        return true;
1550
    }
1551
1552
    /////////////////////////////
1553
    // Validation
1554
    /////////////////////////////
1555
1556
    /**
1557
     * Gets the error stack for this model.
1558
     */
1559
    public function getErrors(): Errors
1560
    {
1561
        if (!$this->errors) {
1562
            $this->errors = new Errors();
1563
        }
1564
1565
        return $this->errors;
1566
    }
1567
1568
    /**
1569
     * Checks if the model in its current state is valid.
1570
     */
1571
    public function valid(): bool
1572
    {
1573
        // clear any previous errors
1574
        $this->getErrors()->clear();
1575
1576
        // run the validator against the model values
1577
        $values = $this->_unsaved + $this->_values;
1578
1579
        $validated = true;
1580
        foreach ($values as $k => $v) {
1581
            $property = static::getProperty($k);
1582
            $validated = $this->filterAndValidate($property, $v) && $validated;
0 ignored issues
show
Bug introduced by
It seems like $property defined by static::getProperty($k) on line 1581 can be null; however, Pulsar\Model::filterAndValidate() does not accept null, maybe add an additional type check?

Unless you are absolutely sure that the expression can never be null because of other conditions, we strongly recommend to add an additional type check to your code:

/** @return stdClass|null */
function mayReturnNull() { }

function doesNotAcceptNull(stdClass $x) { }

// With potential error.
function withoutCheck() {
    $x = mayReturnNull();
    doesNotAcceptNull($x); // Potential error here.
}

// Safe - Alternative 1
function withCheck1() {
    $x = mayReturnNull();
    if ( ! $x instanceof stdClass) {
        throw new \LogicException('$x must be defined.');
    }
    doesNotAcceptNull($x);
}

// Safe - Alternative 2
function withCheck2() {
    $x = mayReturnNull();
    if ($x instanceof stdClass) {
        doesNotAcceptNull($x);
    }
}
Loading history...
1583
        }
1584
1585
        // add back any modified unsaved values
1586
        foreach (array_keys($this->_unsaved) as $k) {
1587
            $this->_unsaved[$k] = $values[$k];
1588
        }
1589
1590
        return $validated;
1591
    }
1592
1593
    /**
1594
     * Validates and marshals a property value prior to saving.
1595
     *
1596
     * @param Property $property property definition
1597
     * @param mixed    $value
1598
     */
1599
    private function filterAndValidate(Property $property, &$value): bool
1600
    {
1601
        // assume empty string is a null value for properties
1602
        // that are marked as optionally-null
1603
        if ($property->isNullable() && ('' === $value || null === $value)) {
1604
            $value = null;
1605
1606
            return true;
1607
        }
1608
1609
        $valid = true;
1610
        $validationRules = $property->getValidationRules();
1611
        if (is_callable($validationRules)) {
1612
            $valid = call_user_func_array($validationRules, [$value]);
1613
            $error = 'pulsar.validation.failed';
1614
        } elseif ($validationRules) {
1615
            $validator = new Validator($validationRules);
1616
            $valid = $validator->validate($value, $this);
1617
            $error = 'pulsar.validation.'.$validator->getFailingRule();
1618
        }
1619
1620
        if (!$valid) {
1621
            $this->getErrors()->add($error, [
0 ignored issues
show
Bug introduced by
The variable $error does not seem to be defined for all execution paths leading up to this point.

If you define a variable conditionally, it can happen that it is not defined for all execution paths.

Let’s take a look at an example:

function myFunction($a) {
    switch ($a) {
        case 'foo':
            $x = 1;
            break;

        case 'bar':
            $x = 2;
            break;
    }

    // $x is potentially undefined here.
    echo $x;
}

In the above example, the variable $x is defined if you pass “foo” or “bar” as argument for $a. However, since the switch statement has no default case statement, if you pass any other value, the variable $x would be undefined.

Available Fixes

  1. Check for existence of the variable explicitly:

    function myFunction($a) {
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
        }
    
        if (isset($x)) { // Make sure it's always set.
            echo $x;
        }
    }
    
  2. Define a default value for the variable:

    function myFunction($a) {
        $x = ''; // Set a default which gets overridden for certain paths.
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
        }
    
        echo $x;
    }
    
  3. Add a value for the missing path:

    function myFunction($a) {
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
    
            // We add support for the missing case.
            default:
                $x = '';
                break;
        }
    
        echo $x;
    }
    
Loading history...
1622
                'field' => $property->getName(),
1623
                'field_name' => $property->getTitle($this),
1624
            ]);
1625
        }
1626
1627
        return $valid;
1628
    }
1629
}
1630