Completed
Push — master ( 6d56bb...c32ae8 )
by Jared
01:30
created

Model::cast()   A

Complexity

Conditions 5
Paths 4

Size

Total Lines 20

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 20
rs 9.2888
c 0
b 0
f 0
cc 5
nc 4
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 BadMethodCallException;
15
use ICanBoogie\Inflector;
16
use Pulsar\Driver\DriverInterface;
17
use Pulsar\Exception\DriverMissingException;
18
use Pulsar\Exception\MassAssignmentException;
19
use Pulsar\Exception\ModelException;
20
use Pulsar\Exception\ModelNotFoundException;
21
use Pulsar\Relation\BelongsTo;
22
use Pulsar\Relation\BelongsToMany;
23
use Pulsar\Relation\HasMany;
24
use Pulsar\Relation\HasOne;
25
use Pulsar\Relation\Relation;
26
use Symfony\Component\EventDispatcher\EventDispatcher;
27
28
/**
29
 * Class Model.
30
 *
31
 * @method Query             where($where, $value = null, $condition = null)
32
 * @method Query             limit($limit)
33
 * @method Query             start($start)
34
 * @method Query             sort($sort)
35
 * @method Query             join($model, $column, $foreignKey)
36
 * @method Query             with($k)
37
 * @method Iterator          all()
38
 * @method array|static|null first($limit = 1)
39
 * @method int               count()
40
 * @method number            sum($property)
41
 * @method number            average($property)
42
 * @method number            max($property)
43
 * @method number            min($property)
44
 */
45
abstract class Model implements \ArrayAccess
46
{
47
    const IMMUTABLE = 0;
48
    const MUTABLE_CREATE_ONLY = 1;
49
    const MUTABLE = 2;
50
51
    const TYPE_STRING = 'string';
52
    const TYPE_INTEGER = 'integer';
53
    const TYPE_FLOAT = 'float';
54
    const TYPE_BOOLEAN = 'boolean';
55
    const TYPE_DATE = 'date';
56
    const TYPE_OBJECT = 'object';
57
    const TYPE_ARRAY = 'array';
58
59
    const RELATIONSHIP_HAS_ONE = 'has_one';
60
    const RELATIONSHIP_HAS_MANY = 'has_many';
61
    const RELATIONSHIP_BELONGS_TO = 'belongs_to';
62
    const RELATIONSHIP_BELONGS_TO_MANY = 'belongs_to_many';
63
64
    const DEFAULT_ID_PROPERTY = 'id';
65
66
    /////////////////////////////
67
    // Model visible variables
68
    /////////////////////////////
69
70
    /**
71
     * List of model ID property names.
72
     *
73
     * @var array
74
     */
75
    protected static $ids = [self::DEFAULT_ID_PROPERTY];
76
77
    /**
78
     * Property definitions expressed as a key-value map with
79
     * property names as the keys.
80
     * i.e. ['enabled' => ['type' => Model::TYPE_BOOLEAN]].
81
     *
82
     * @var array
83
     */
84
    protected static $properties = [];
85
86
    /**
87
     * @var array
88
     */
89
    protected static $dispatchers;
90
91
    /**
92
     * @var number|string|false
93
     */
94
    protected $_id;
95
96
    /**
97
     * @var array
98
     */
99
    protected $_ids;
100
101
    /**
102
     * @var array
103
     */
104
    protected $_values = [];
105
106
    /**
107
     * @var array
108
     */
109
    protected $_unsaved = [];
110
111
    /**
112
     * @var bool
113
     */
114
    protected $_persisted = false;
115
116
    /**
117
     * @var bool
118
     */
119
    protected $_loaded = false;
120
121
    /**
122
     * @var array
123
     */
124
    protected $_relationships = [];
125
126
    /**
127
     * @var Errors
128
     */
129
    protected $_errors;
130
131
    /////////////////////////////
132
    // Base model variables
133
    /////////////////////////////
134
135
    /**
136
     * @var array
137
     */
138
    private static $propertyDefinitionBase = [
139
        'type' => null,
140
        'mutable' => self::MUTABLE,
141
        'null' => false,
142
        'unique' => false,
143
        'required' => false,
144
    ];
145
146
    /**
147
     * @var array
148
     */
149
    private static $defaultIDProperty = [
150
        'type' => self::TYPE_INTEGER,
151
        'mutable' => self::IMMUTABLE,
152
    ];
153
154
    /**
155
     * @var array
156
     */
157
    private static $timestampProperties = [
158
        'created_at' => [
159
            'type' => self::TYPE_DATE,
160
            'validate' => 'timestamp|db_timestamp',
161
        ],
162
        'updated_at' => [
163
            'type' => self::TYPE_DATE,
164
            'validate' => 'timestamp|db_timestamp',
165
        ],
166
    ];
167
168
    /**
169
     * @var array
170
     */
171
    private static $softDeleteProperties = [
172
        'deleted_at' => [
173
            'type' => self::TYPE_DATE,
174
            'validate' => 'timestamp|db_timestamp',
175
            'null' => true,
176
        ],
177
    ];
178
179
    /**
180
     * @var array
181
     */
182
    private static $initialized = [];
183
184
    /**
185
     * @var DriverInterface
186
     */
187
    private static $driver;
188
189
    /**
190
     * @var array
191
     */
192
    private static $accessors = [];
193
194
    /**
195
     * @var array
196
     */
197
    private static $mutators = [];
198
199
    /**
200
     * @var bool
201
     */
202
    private $_ignoreUnsaved;
203
204
    /**
205
     * Creates a new model object.
206
     *
207
     * @param array|string|Model|false $id     ordered array of ids or comma-separated id string
208
     * @param array                    $values optional key-value map to pre-seed model
209
     */
210
    public function __construct($id = false, array $values = [])
211
    {
212
        // initialize the model
213
        $this->init();
214
215
        // parse the supplied model ID
216
        $this->parseId($id);
217
218
        // load any given values
219
        if (count($values) > 0) {
220
            $this->refreshWith($values);
221
        }
222
    }
223
224
    /**
225
     * Performs initialization on this model.
226
     */
227
    private function init()
228
    {
229
        // ensure the initialize function is called only once
230
        $k = get_called_class();
231
        if (!isset(self::$initialized[$k])) {
232
            $this->initialize();
233
            self::$initialized[$k] = true;
234
        }
235
    }
236
237
    /**
238
     * The initialize() method is called once per model. It's used
239
     * to perform any one-off tasks before the model gets
240
     * constructed. This is a great place to add any model
241
     * properties. When extending this method be sure to call
242
     * parent::initialize() as some important stuff happens here.
243
     * If extending this method to add properties then you should
244
     * call parent::initialize() after adding any properties.
245
     */
246
    protected function initialize()
247
    {
248
        // load the driver
249
        static::getDriver();
250
251
        // add in the default ID property
252
        if (static::$ids == [self::DEFAULT_ID_PROPERTY] && !isset(static::$properties[self::DEFAULT_ID_PROPERTY])) {
253
            static::$properties[self::DEFAULT_ID_PROPERTY] = self::$defaultIDProperty;
254
        }
255
256
        // generates created_at and updated_at timestamps
257
        if (property_exists($this, 'autoTimestamps')) {
258
            $this->installAutoTimestamps();
259
        }
260
261
        // generates deleted_at timestamps
262
        if (property_exists($this, 'softDelete')) {
263
            $this->installSoftDelete();
264
        }
265
266
        // fill in each property by extending the property
267
        // definition base
268
        foreach (static::$properties as $k => &$property) {
269
            $property = array_replace(self::$propertyDefinitionBase, $property);
270
271
            // populate relationship property settings
272
            if (isset($property['relation'])) {
273
                // this is added for BC with older versions of pulsar
274
                // that only supported belongs to relationships
275
                if (!isset($property['relation_type'])) {
276
                    $property['relation_type'] = self::RELATIONSHIP_BELONGS_TO;
277
                    $property['local_key'] = $k;
278
                }
279
280
                $relation = $this->getRelationshipManager($k);
281
                if (!isset($property['foreign_key'])) {
282
                    $property['foreign_key'] = $relation->getForeignKey();
283
                }
284
285
                if (!isset($property['local_key'])) {
286
                    $property['local_key'] = $relation->getLocalKey();
287
                }
288
289
                if (!isset($property['pivot_tablename']) && $relation instanceof BelongsToMany) {
290
                    $property['pivot_tablename'] = $relation->getTablename();
291
                }
292
            }
293
        }
294
295
        // order the properties array by name for consistency
296
        // since it is constructed in a random order
297
        ksort(static::$properties);
298
    }
299
300
    /**
301
     * Installs the `created_at` and `updated_at` properties.
302
     */
303
    private function installAutoTimestamps()
304
    {
305
        static::$properties = array_replace(self::$timestampProperties, static::$properties);
306
307
        self::creating(function (ModelEvent $event) {
308
            $model = $event->getModel();
309
            $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...
310
            $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...
311
        });
312
313
        self::updating(function (ModelEvent $event) {
314
            $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...
315
        });
316
    }
317
318
    /**
319
     * Installs the `deleted_at` properties.
320
     */
321
    private function installSoftDelete()
322
    {
323
        static::$properties = array_replace(self::$softDeleteProperties, static::$properties);
324
    }
325
326
    /**
327
     * Parses the given ID, which can be a single or composite primary key.
328
     *
329
     * @param mixed $id
330
     */
331
    private function parseId($id)
332
    {
333
        if (is_array($id)) {
334
            // A model can be supplied as a primary key
335
            foreach ($id as &$el) {
336
                if ($el instanceof self) {
337
                    $el = $el->id();
338
                }
339
            }
340
341
            // The IDs come in as the same order as ::$ids.
342
            // We need to match up the elements on that
343
            // input into a key-value map for each ID property.
344
            $ids = [];
345
            $idQueue = array_reverse($id);
346
            foreach (static::$ids as $k => $f) {
347
                // type cast
348
                if (count($idQueue) > 0) {
349
                    $idProperty = static::getProperty($f);
350
                    $ids[$f] = static::cast($idProperty, array_pop($idQueue));
0 ignored issues
show
Bug introduced by
It seems like $idProperty defined by static::getProperty($f) on line 349 can also be of type null; however, Pulsar\Model::cast() does only seem to accept array, maybe add an additional type check?

If a method or function can return multiple different values and unless you are sure that you only can receive a single value in this context, we recommend to add an additional type check:

/**
 * @return array|string
 */
function returnsDifferentValues($x) {
    if ($x) {
        return 'foo';
    }

    return array();
}

$x = returnsDifferentValues($y);
if (is_array($x)) {
    // $x is an array.
}

If this a common case that PHP Analyzer should handle natively, please let us know by opening an issue.

Loading history...
351
                } else {
352
                    $ids[$f] = false;
353
                }
354
            }
355
356
            $this->_id = implode(',', $id);
357
            $this->_ids = $ids;
358
        } elseif ($id instanceof self) {
359
            // A model can be supplied as a primary key
360
            $this->_id = $id->id();
361
            $this->_ids = $id->ids();
362
        } else {
363
            // type cast the single primary key
364
            $idName = static::$ids[0];
365
            if (false !== $id) {
366
                $idProperty = static::getProperty($idName);
367
                $id = static::cast($idProperty, $id);
0 ignored issues
show
Bug introduced by
It seems like $idProperty defined by static::getProperty($idName) on line 366 can also be of type null; however, Pulsar\Model::cast() does only seem to accept array, maybe add an additional type check?

If a method or function can return multiple different values and unless you are sure that you only can receive a single value in this context, we recommend to add an additional type check:

/**
 * @return array|string
 */
function returnsDifferentValues($x) {
    if ($x) {
        return 'foo';
    }

    return array();
}

$x = returnsDifferentValues($y);
if (is_array($x)) {
    // $x is an array.
}

If this a common case that PHP Analyzer should handle natively, please let us know by opening an issue.

Loading history...
368
            }
369
370
            $this->_id = $id;
371
            $this->_ids = [$idName => $id];
372
        }
373
    }
374
375
    /**
376
     * Sets the driver for all models.
377
     *
378
     * @param DriverInterface $driver
379
     */
380
    public static function setDriver(DriverInterface $driver)
381
    {
382
        self::$driver = $driver;
383
    }
384
385
    /**
386
     * Gets the driver for all models.
387
     *
388
     * @return DriverInterface
389
     *
390
     * @throws DriverMissingException when a driver has not been set yet
391
     */
392
    public static function getDriver(): DriverInterface
393
    {
394
        if (!self::$driver) {
395
            throw new DriverMissingException('A model driver has not been set yet.');
396
        }
397
398
        return self::$driver;
399
    }
400
401
    /**
402
     * Clears the driver for all models.
403
     */
404
    public static function clearDriver()
405
    {
406
        self::$driver = null;
407
    }
408
409
    /**
410
     * Gets the name of the model, i.e. User.
411
     *
412
     * @return string
413
     */
414
    public static function modelName(): string
415
    {
416
        // strip namespacing
417
        $paths = explode('\\', get_called_class());
418
419
        return end($paths);
420
    }
421
422
    /**
423
     * Gets the model ID.
424
     *
425
     * @return string|number|false ID
426
     */
427
    public function id()
428
    {
429
        return $this->_id;
430
    }
431
432
    /**
433
     * Gets a key-value map of the model ID.
434
     *
435
     * @return array ID map
436
     */
437
    public function ids(): array
438
    {
439
        return $this->_ids;
440
    }
441
442
    /////////////////////////////
443
    // Magic Methods
444
    /////////////////////////////
445
446
    /**
447
     * Converts the model into a string.
448
     *
449
     * @return string
450
     */
451
    public function __toString()
452
    {
453
        $values = array_merge($this->_values, $this->_unsaved, $this->_ids);
454
        ksort($values);
455
456
        return get_called_class().'('.json_encode($values, JSON_PRETTY_PRINT).')';
457
    }
458
459
    /**
460
     * Shortcut to a get() call for a given property.
461
     *
462
     * @param string $name
463
     *
464
     * @return mixed
465
     */
466
    public function __get($name)
467
    {
468
        $result = $this->get([$name]);
469
470
        return reset($result);
471
    }
472
473
    /**
474
     * Sets an unsaved value.
475
     *
476
     * @param string $name
477
     * @param mixed  $value
478
     */
479
    public function __set($name, $value)
480
    {
481
        // if changing property, remove relation model
482
        if (isset($this->_relationships[$name])) {
483
            unset($this->_relationships[$name]);
484
        }
485
486
        // call any mutators
487
        $mutator = self::getMutator($name);
488
        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...
489
            $this->_unsaved[$name] = $this->$mutator($value);
490
        } else {
491
            $this->_unsaved[$name] = $value;
492
        }
493
    }
494
495
    /**
496
     * Checks if an unsaved value or property exists by this name.
497
     *
498
     * @param string $name
499
     *
500
     * @return bool
501
     */
502
    public function __isset($name)
503
    {
504
        return array_key_exists($name, $this->_unsaved) || static::hasProperty($name);
505
    }
506
507
    /**
508
     * Unsets an unsaved value.
509
     *
510
     * @param string $name
511
     */
512
    public function __unset($name)
513
    {
514
        if (array_key_exists($name, $this->_unsaved)) {
515
            // if changing property, remove relation model
516
            if (isset($this->_relationships[$name])) {
517
                unset($this->_relationships[$name]);
518
            }
519
520
            unset($this->_unsaved[$name]);
521
        }
522
    }
523
524
    /////////////////////////////
525
    // ArrayAccess Interface
526
    /////////////////////////////
527
528
    public function offsetExists($offset)
529
    {
530
        return isset($this->$offset);
531
    }
532
533
    public function offsetGet($offset)
534
    {
535
        return $this->$offset;
536
    }
537
538
    public function offsetSet($offset, $value)
539
    {
540
        $this->$offset = $value;
541
    }
542
543
    public function offsetUnset($offset)
544
    {
545
        unset($this->$offset);
546
    }
547
548
    public static function __callStatic($name, $parameters)
549
    {
550
        // Any calls to unkown static methods should be deferred to
551
        // the query. This allows calls like User::where()
552
        // to replace User::query()->where().
553
        return call_user_func_array([static::query(), $name], $parameters);
554
    }
555
556
    /////////////////////////////
557
    // Property Definitions
558
    /////////////////////////////
559
560
    /**
561
     * Gets all the property definitions for the model.
562
     *
563
     * @return array key-value map of properties
564
     */
565
    public static function getProperties(): array
566
    {
567
        return static::$properties;
568
    }
569
570
    /**
571
     * Gets a property defition for the model.
572
     *
573
     * @param string $property property to lookup
574
     *
575
     * @return array|null property
576
     */
577
    public static function getProperty(string $property): ?array
578
    {
579
        return array_value(static::$properties, $property);
580
    }
581
582
    /**
583
     * Gets the names of the model ID properties.
584
     *
585
     * @return array
586
     */
587
    public static function getIDProperties(): array
588
    {
589
        return static::$ids;
590
    }
591
592
    /**
593
     * Checks if the model has a property.
594
     *
595
     * @param string $property property
596
     *
597
     * @return bool has property
598
     */
599
    public static function hasProperty(string $property): bool
600
    {
601
        return isset(static::$properties[$property]);
602
    }
603
604
    /**
605
     * Gets the mutator method name for a given proeprty name.
606
     * Looks for methods in the form of `setPropertyValue`.
607
     * i.e. the mutator for `last_name` would be `setLastNameValue`.
608
     *
609
     * @param string $property property
610
     *
611
     * @return string|null method name if it exists
612
     */
613
    public static function getMutator(string $property): ?string
614
    {
615
        $class = get_called_class();
616
617
        $k = $class.':'.$property;
618
        if (!array_key_exists($k, self::$mutators)) {
619
            $inflector = Inflector::get();
620
            $method = 'set'.$inflector->camelize($property).'Value';
621
622
            if (!method_exists($class, $method)) {
623
                $method = null;
624
            }
625
626
            self::$mutators[$k] = $method;
627
        }
628
629
        return self::$mutators[$k];
630
    }
631
632
    /**
633
     * Gets the accessor method name for a given proeprty name.
634
     * Looks for methods in the form of `getPropertyValue`.
635
     * i.e. the accessor for `last_name` would be `getLastNameValue`.
636
     *
637
     * @param string $property property
638
     *
639
     * @return string|null method name if it exists
640
     */
641
    public static function getAccessor(string $property): ?string
642
    {
643
        $class = get_called_class();
644
645
        $k = $class.':'.$property;
646
        if (!array_key_exists($k, self::$accessors)) {
647
            $inflector = Inflector::get();
648
            $method = 'get'.$inflector->camelize($property).'Value';
649
650
            if (!method_exists($class, $method)) {
651
                $method = null;
652
            }
653
654
            self::$accessors[$k] = $method;
655
        }
656
657
        return self::$accessors[$k];
658
    }
659
660
    /**
661
     * Marshals a value for a given property from storage.
662
     *
663
     * @param array $property
664
     * @param mixed $value
665
     *
666
     * @return mixed type-casted value
667
     */
668
    public static function cast(array $property, $value)
669
    {
670
        if (null === $value) {
671
            return;
672
        }
673
674
        // handle empty strings as null
675
        if ($property['null'] && '' == $value) {
676
            return;
677
        }
678
679
        $type = array_value($property, 'type');
680
        $m = 'to_'.$type;
681
682
        if (!method_exists(Property::class, $m)) {
683
            return $value;
684
        }
685
686
        return Property::$m($value);
687
    }
688
689
    /////////////////////////////
690
    // CRUD Operations
691
    /////////////////////////////
692
693
    /**
694
     * Gets the tablename for storing this model.
695
     *
696
     * @return string
697
     */
698
    public function getTablename(): string
699
    {
700
        $inflector = Inflector::get();
701
702
        return $inflector->camelize($inflector->pluralize(static::modelName()));
703
    }
704
705
    /**
706
     * Gets the ID of the connection in the connection manager
707
     * that stores this model.
708
     *
709
     * @return string|null
710
     */
711
    public function getConnection(): ?string
712
    {
713
        return null;
714
    }
715
716
    protected function usesTransactions(): bool
717
    {
718
        return false;
719
    }
720
721
    /**
722
     * Saves the model.
723
     *
724
     * @return bool true when the operation was successful
725
     */
726
    public function save(): bool
727
    {
728
        if (false === $this->_id) {
729
            return $this->create();
730
        }
731
732
        return $this->set();
733
    }
734
735
    /**
736
     * Saves the model. Throws an exception when the operation fails.
737
     *
738
     * @throws ModelException when the model cannot be saved
739
     */
740
    public function saveOrFail()
741
    {
742
        if (!$this->save()) {
743
            $msg = 'Failed to save '.static::modelName();
744
            if ($validationErrors = $this->getErrors()->all()) {
745
                $msg .= ': '.implode(', ', $validationErrors);
746
            }
747
748
            throw new ModelException($msg);
749
        }
750
    }
751
752
    /**
753
     * Creates a new model.
754
     *
755
     * @param array $data optional key-value properties to set
756
     *
757
     * @return bool true when the operation was successful
758
     *
759
     * @throws BadMethodCallException when called on an existing model
760
     */
761
    public function create(array $data = []): bool
762
    {
763
        if (false !== $this->_id) {
764
            throw new BadMethodCallException('Cannot call create() on an existing model');
765
        }
766
767
        // mass assign values passed into create()
768
        $this->setValues($data);
769
770
        // clear any previous errors
771
        $this->getErrors()->clear();
772
773
        // start a DB transaction if needed
774
        $usesTransactions = $this->usesTransactions();
775
        if ($usesTransactions) {
776
            self::$driver->startTransaction($this->getConnection());
777
        }
778
779
        // dispatch the model.creating event
780
        if (!$this->performDispatch(ModelEvent::CREATING, $usesTransactions)) {
781
            return false;
782
        }
783
784
        $requiredProperties = [];
785
        foreach (static::$properties as $name => $property) {
786
            // build a list of the required properties
787
            if ($property['required']) {
788
                $requiredProperties[] = $name;
789
            }
790
791
            // add in default values
792
            if (!array_key_exists($name, $this->_unsaved) && array_key_exists('default', $property)) {
793
                $this->_unsaved[$name] = $property['default'];
794
            }
795
        }
796
797
        // validate the values being saved
798
        $validated = true;
799
        $insertArray = [];
800
        foreach ($this->_unsaved as $name => $value) {
801
            // exclude if value does not map to a property
802
            if (!isset(static::$properties[$name])) {
803
                continue;
804
            }
805
806
            $property = static::$properties[$name];
807
808
            // cannot insert immutable values
809
            // (unless using the default value)
810
            if (self::IMMUTABLE == $property['mutable'] && $value !== $this->getPropertyDefault($property)) {
811
                continue;
812
            }
813
814
            $validated = $validated && $this->filterAndValidate($property, $name, $value);
815
            $insertArray[$name] = $value;
816
        }
817
818
        // check for required fields
819
        foreach ($requiredProperties as $name) {
820
            if (!isset($insertArray[$name])) {
821
                $params = [
822
                    'field' => $name,
823
                    'field_name' => $this->getPropertyTitle($name),
824
                ];
825
                $this->getErrors()->add('pulsar.validation.required', $params);
826
827
                $validated = false;
828
            }
829
        }
830
831
        if (!$validated) {
832
            // when validations fail roll back any database transaction
833
            if ($usesTransactions) {
834
                self::$driver->rollBackTransaction($this->getConnection());
835
            }
836
837
            return false;
838
        }
839
840
        $created = self::$driver->createModel($this, $insertArray);
841
842
        if ($created) {
843
            // determine the model's new ID
844
            $this->getNewID();
845
846
            // store the persisted values to the in-memory cache
847
            $this->_unsaved = [];
848
            $this->refreshWith(array_replace($this->_ids, $insertArray));
849
850
            // dispatch the model.created event
851
            if (!$this->performDispatch(ModelEvent::CREATED, $usesTransactions)) {
852
                return false;
853
            }
854
        }
855
856
        // commit the transaction, if used
857
        if ($usesTransactions) {
858
            self::$driver->commitTransaction($this->getConnection());
859
        }
860
861
        return $created;
862
    }
863
864
    /**
865
     * Ignores unsaved values when fetching the next value.
866
     *
867
     * @return $this
868
     */
869
    public function ignoreUnsaved()
870
    {
871
        $this->_ignoreUnsaved = true;
872
873
        return $this;
874
    }
875
876
    /**
877
     * Fetches property values from the model.
878
     *
879
     * This method looks up values in this order:
880
     * IDs, local cache, unsaved values, storage layer, defaults
881
     *
882
     * @param array $properties list of property names to fetch values of
883
     *
884
     * @return array
885
     */
886
    public function get(array $properties): array
887
    {
888
        // load the values from the IDs and local model cache
889
        $values = array_replace($this->ids(), $this->_values);
890
891
        // unless specified, use any unsaved values
892
        $ignoreUnsaved = $this->_ignoreUnsaved;
893
        $this->_ignoreUnsaved = false;
894
895
        if (!$ignoreUnsaved) {
896
            $values = array_replace($values, $this->_unsaved);
897
        }
898
899
        // see if there are any model properties that do not exist.
900
        // when true then this means the model needs to be hydrated
901
        // NOTE: only looking at model properties and excluding dynamic/non-existent properties
902
        $modelProperties = array_keys(static::$properties);
903
        $numMissing = count(array_intersect($modelProperties, array_diff($properties, array_keys($values))));
904
905
        if ($numMissing > 0 && !$this->_loaded) {
906
            // load the model from the storage layer, if needed
907
            $this->refresh();
908
909
            $values = array_replace($values, $this->_values);
910
911
            if (!$ignoreUnsaved) {
912
                $values = array_replace($values, $this->_unsaved);
913
            }
914
        }
915
916
        // build a key-value map of the requested properties
917
        $return = [];
918
        foreach ($properties as $k) {
919
            $return[$k] = $this->getValue($k, $values);
920
        }
921
922
        return $return;
923
    }
924
925
    /**
926
     * Gets a property value from the model.
927
     *
928
     * Values are looked up in this order:
929
     *  1. unsaved values
930
     *  2. local values
931
     *  3. default value
932
     *  4. null
933
     *
934
     * @param string $property
935
     * @param array  $values
936
     *
937
     * @return mixed
938
     */
939
    protected function getValue($property, array $values)
940
    {
941
        $value = null;
942
943
        if (array_key_exists($property, $values)) {
944
            $value = $values[$property];
945
        } elseif (static::hasProperty($property)) {
946
            $value = $this->_values[$property] = $this->getPropertyDefault(static::$properties[$property]);
947
        }
948
949
        // call any accessors
950
        if ($accessor = self::getAccessor($property)) {
951
            $value = $this->$accessor($value);
952
        }
953
954
        return $value;
955
    }
956
957
    /**
958
     * Populates a newly created model with its ID.
959
     */
960
    protected function getNewID()
961
    {
962
        $ids = [];
963
        $namedIds = [];
964
        foreach (static::$ids as $k) {
965
            // attempt use the supplied value if the ID property is mutable
966
            $property = static::getProperty($k);
967
            if (in_array($property['mutable'], [self::MUTABLE, self::MUTABLE_CREATE_ONLY]) && isset($this->_unsaved[$k])) {
968
                $id = $this->_unsaved[$k];
969
            } else {
970
                $id = self::$driver->getCreatedID($this, $k);
971
            }
972
973
            $ids[] = $id;
974
            $namedIds[$k] = $id;
975
        }
976
977
        $this->_id = implode(',', $ids);
978
        $this->_ids = $namedIds;
979
    }
980
981
    /**
982
     * Sets a collection values on the model from an untrusted input.
983
     *
984
     * @param array $values
985
     *
986
     * @throws MassAssignmentException when assigning a value that is protected or not whitelisted
987
     *
988
     * @return $this
989
     */
990
    public function setValues($values)
991
    {
992
        // check if the model has a mass assignment whitelist
993
        $permitted = (property_exists($this, 'permitted')) ? static::$permitted : false;
994
995
        // if no whitelist, then check for a blacklist
996
        $protected = (!is_array($permitted) && property_exists($this, 'protected')) ? static::$protected : false;
997
998
        foreach ($values as $k => $value) {
999
            // check for mass assignment violations
1000
            if (($permitted && !in_array($k, $permitted)) ||
1001
                ($protected && in_array($k, $protected))) {
1002
                throw new MassAssignmentException("Mass assignment of $k on ".static::modelName().' is not allowed');
1003
            }
1004
1005
            $this->$k = $value;
1006
        }
1007
1008
        return $this;
1009
    }
1010
1011
    /**
1012
     * Converts the model to an array.
1013
     *
1014
     * @return array
1015
     */
1016
    public function toArray(): array
1017
    {
1018
        // build the list of properties to retrieve
1019
        $properties = array_keys(static::$properties);
1020
1021
        // remove any hidden properties
1022
        $hide = (property_exists($this, 'hidden')) ? static::$hidden : [];
1023
        $properties = array_diff($properties, $hide);
1024
1025
        // add any appended properties
1026
        $append = (property_exists($this, 'appended')) ? static::$appended : [];
1027
        $properties = array_merge($properties, $append);
1028
1029
        // get the values for the properties
1030
        $result = $this->get($properties);
1031
1032
        foreach ($result as $k => &$value) {
1033
            // convert any models to arrays
1034
            if ($value instanceof self) {
1035
                $value = $value->toArray();
1036
            }
1037
        }
1038
1039
        // DEPRECATED
1040
        // apply the transformation hook
1041
        if (method_exists($this, 'toArrayHook')) {
1042
            $this->toArrayHook($result, [], [], []);
0 ignored issues
show
Bug introduced by
The method toArrayHook() does not exist on Pulsar\Model. Did you maybe mean toArray()?

This check marks calls to methods that do not seem to exist on an object.

This is most likely the result of a method being renamed without all references to it being renamed likewise.

Loading history...
1043
        }
1044
1045
        return $result;
1046
    }
1047
1048
    /**
1049
     * Updates the model.
1050
     *
1051
     * @param array $data optional key-value properties to set
1052
     *
1053
     * @return bool true when the operation was successful
1054
     *
1055
     * @throws BadMethodCallException when not called on an existing model
1056
     */
1057
    public function set(array $data = []): bool
1058
    {
1059
        if (false === $this->_id) {
1060
            throw new BadMethodCallException('Can only call set() on an existing model');
1061
        }
1062
1063
        // mass assign values passed into set()
1064
        $this->setValues($data);
1065
1066
        // clear any previous errors
1067
        $this->getErrors()->clear();
1068
1069
        // not updating anything?
1070
        if (0 == count($this->_unsaved)) {
1071
            return true;
1072
        }
1073
1074
        // start a DB transaction if needed
1075
        $usesTransactions = $this->usesTransactions();
1076
        if ($usesTransactions) {
1077
            self::$driver->startTransaction($this->getConnection());
1078
        }
1079
1080
        // dispatch the model.updating event
1081
        if (!$this->performDispatch(ModelEvent::UPDATING, $usesTransactions)) {
1082
            return false;
1083
        }
1084
1085
        // validate the values being saved
1086
        $validated = true;
1087
        $updateArray = [];
1088
        foreach ($this->_unsaved as $name => $value) {
1089
            // exclude if value does not map to a property
1090
            if (!isset(static::$properties[$name])) {
1091
                continue;
1092
            }
1093
1094
            $property = static::$properties[$name];
1095
1096
            // can only modify mutable properties
1097
            if (self::MUTABLE != $property['mutable']) {
1098
                continue;
1099
            }
1100
1101
            $validated = $validated && $this->filterAndValidate($property, $name, $value);
1102
            $updateArray[$name] = $value;
1103
        }
1104
1105
        if (!$validated) {
1106
            // when validations fail roll back any database transaction
1107
            if ($usesTransactions) {
1108
                self::$driver->rollBackTransaction($this->getConnection());
1109
            }
1110
1111
            return false;
1112
        }
1113
1114
        $updated = self::$driver->updateModel($this, $updateArray);
1115
1116
        if ($updated) {
1117
            // store the persisted values to the in-memory cache
1118
            $this->_unsaved = [];
1119
            $this->refreshWith(array_replace($this->_values, $updateArray));
1120
1121
            // dispatch the model.updated event
1122
            if (!$this->performDispatch(ModelEvent::UPDATED, $usesTransactions)) {
1123
                return false;
1124
            }
1125
        }
1126
1127
        // commit the transaction, if used
1128
        if ($usesTransactions) {
1129
            self::$driver->commitTransaction($this->getConnection());
1130
        }
1131
1132
        return $updated;
1133
    }
1134
1135
    /**
1136
     * Delete the model.
1137
     *
1138
     * @return bool true when the operation was successful
1139
     */
1140
    public function delete(): bool
1141
    {
1142
        if (false === $this->_id) {
1143
            throw new BadMethodCallException('Can only call delete() on an existing model');
1144
        }
1145
1146
        // clear any previous errors
1147
        $this->getErrors()->clear();
1148
1149
        // start a DB transaction if needed
1150
        $usesTransactions = $this->usesTransactions();
1151
        if ($usesTransactions) {
1152
            self::$driver->startTransaction($this->getConnection());
1153
        }
1154
1155
        // dispatch the model.deleting event
1156
        if (!$this->performDispatch(ModelEvent::DELETING, $usesTransactions)) {
1157
            return false;
1158
        }
1159
1160
        // perform a hard (default) or soft delete
1161
        $hardDelete = true;
1162
        if (property_exists($this, 'softDelete')) {
1163
            $t = time();
1164
            $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...
1165
            $t = $this->filterAndValidate(static::getProperty('deleted_at'), 'deleted_at', $t);
0 ignored issues
show
Bug introduced by
It seems like static::getProperty('deleted_at') targeting Pulsar\Model::getProperty() can also be of type null; however, Pulsar\Model::filterAndValidate() does only seem to accept array, maybe add an additional type check?

This check looks at variables that are passed out again to other methods.

If the outgoing method call has stricter type requirements than the method itself, an issue is raised.

An additional type check may prevent trouble.

Loading history...
1166
            $deleted = self::$driver->updateModel($this, ['deleted_at' => $t]);
1167
            $hardDelete = false;
1168
        } else {
1169
            $deleted = self::$driver->deleteModel($this);
1170
        }
1171
1172
        if ($deleted) {
1173
            // dispatch the model.deleted event
1174
            if (!$this->performDispatch(ModelEvent::DELETED, $usesTransactions)) {
1175
                return false;
1176
            }
1177
1178
            if ($hardDelete) {
1179
                $this->_persisted = false;
1180
            }
1181
        }
1182
1183
        // commit the transaction, if used
1184
        if ($usesTransactions) {
1185
            self::$driver->commitTransaction($this->getConnection());
1186
        }
1187
1188
        return $deleted;
1189
    }
1190
1191
    /**
1192
     * Restores a soft-deleted model.
1193
     *
1194
     * @return bool
1195
     */
1196
    public function restore(): bool
1197
    {
1198
        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...
1199
            throw new BadMethodCallException('Can only call restore() on a soft-deleted model');
1200
        }
1201
1202
        // start a DB transaction if needed
1203
        $usesTransactions = $this->usesTransactions();
1204
        if ($usesTransactions) {
1205
            self::$driver->startTransaction($this->getConnection());
1206
        }
1207
1208
        // dispatch the model.updating event
1209
        if (!$this->performDispatch(ModelEvent::UPDATING, $usesTransactions)) {
1210
            return false;
1211
        }
1212
1213
        $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...
1214
        $restored = self::$driver->updateModel($this, ['deleted_at' => null]);
1215
1216
        if ($restored) {
1217
            // dispatch the model.updated event
1218
            if (!$this->performDispatch(ModelEvent::UPDATED, $usesTransactions)) {
1219
                return false;
1220
            }
1221
        }
1222
1223
        // commit the transaction, if used
1224
        if ($usesTransactions) {
1225
            self::$driver->commitTransaction($this->getConnection());
1226
        }
1227
1228
        return $restored;
1229
    }
1230
1231
    /**
1232
     * Checks if the model has been deleted.
1233
     *
1234
     * @return bool
1235
     */
1236
    public function isDeleted(): bool
1237
    {
1238
        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...
1239
            return true;
1240
        }
1241
1242
        return !$this->_persisted;
1243
    }
1244
1245
    /////////////////////////////
1246
    // Queries
1247
    /////////////////////////////
1248
1249
    /**
1250
     * Generates a new query instance.
1251
     *
1252
     * @return Query
1253
     */
1254
    public static function query(): Query
1255
    {
1256
        // Create a new model instance for the query to ensure
1257
        // that the model's initialize() method gets called.
1258
        // Otherwise, the property definitions will be incomplete.
1259
        $model = new static();
1260
        $query = new Query($model);
1261
1262
        // scope soft-deleted models to only include non-deleted models
1263
        if (property_exists($model, 'softDelete')) {
1264
            $query->where('deleted_at IS NOT NULL');
1265
        }
1266
1267
        return $query;
1268
    }
1269
1270
    /**
1271
     * Generates a new query instance that includes soft-deleted models.
1272
     *
1273
     * @return Query
1274
     */
1275
    public static function withDeleted(): Query
1276
    {
1277
        // Create a new model instance for the query to ensure
1278
        // that the model's initialize() method gets called.
1279
        // Otherwise, the property definitions will be incomplete.
1280
        $model = new static();
1281
1282
        return new Query($model);
1283
    }
1284
1285
    /**
1286
     * Finds a single instance of a model given it's ID.
1287
     *
1288
     * @param mixed $id
1289
     *
1290
     * @return static|null
1291
     */
1292
    public static function find($id): ?self
1293
    {
1294
        $ids = [];
1295
        $id = (array) $id;
1296
        foreach (static::$ids as $j => $k) {
1297
            if ($_id = array_value($id, $j)) {
1298
                $ids[$k] = $_id;
1299
            }
1300
        }
1301
1302
        // malformed ID
1303
        if (count($ids) < count(static::$ids)) {
1304
            return null;
1305
        }
1306
1307
        return static::query()->where($ids)->first();
1308
    }
1309
1310
    /**
1311
     * Finds a single instance of a model given it's ID or throws an exception.
1312
     *
1313
     * @param mixed $id
1314
     *
1315
     * @return static
1316
     *
1317
     * @throws ModelNotFoundException when a model could not be found
1318
     */
1319
    public static function findOrFail($id): self
1320
    {
1321
        $model = static::find($id);
1322
        if (!$model) {
1323
            throw new ModelNotFoundException('Could not find the requested '.static::modelName());
1324
        }
1325
1326
        return $model;
1327
    }
1328
1329
    /**
1330
     * Tells if this model instance has been persisted to the data layer.
1331
     *
1332
     * NOTE: this does not actually perform a check with the data layer
1333
     *
1334
     * @return bool
1335
     */
1336
    public function persisted(): bool
1337
    {
1338
        return $this->_persisted;
1339
    }
1340
1341
    /**
1342
     * Loads the model from the storage layer.
1343
     *
1344
     * @return $this
1345
     */
1346
    public function refresh()
1347
    {
1348
        if (false === $this->_id) {
1349
            return $this;
1350
        }
1351
1352
        $values = self::$driver->loadModel($this);
1353
1354
        if (!is_array($values)) {
1355
            return $this;
1356
        }
1357
1358
        // clear any relations
1359
        $this->_relationships = [];
1360
1361
        return $this->refreshWith($values);
1362
    }
1363
1364
    /**
1365
     * Loads values into the model.
1366
     *
1367
     * @param array $values values
1368
     *
1369
     * @return $this
1370
     */
1371
    public function refreshWith(array $values)
1372
    {
1373
        // type cast the values
1374
        foreach ($values as $k => &$value) {
1375
            if ($property = static::getProperty($k)) {
1376
                $value = static::cast($property, $value);
1377
            }
1378
        }
1379
1380
        $this->_loaded = true;
1381
        $this->_persisted = true;
1382
        $this->_values = $values;
1383
1384
        return $this;
1385
    }
1386
1387
    /**
1388
     * Clears the cache for this model.
1389
     *
1390
     * @return $this
1391
     */
1392
    public function clearCache()
1393
    {
1394
        $this->_loaded = false;
1395
        $this->_unsaved = [];
1396
        $this->_values = [];
1397
        $this->_relationships = [];
1398
1399
        return $this;
1400
    }
1401
1402
    /////////////////////////////
1403
    // Relationships
1404
    /////////////////////////////
1405
1406
    /**
1407
     * @deprecated
1408
     *
1409
     * Gets the model(s) for a relationship
1410
     *
1411
     * @param string $k property
1412
     *
1413
     * @throws \InvalidArgumentException when the relationship manager cannot be created
1414
     *
1415
     * @return Model|array|null
1416
     */
1417
    public function relation(string $k)
1418
    {
1419
        if (!array_key_exists($k, $this->_relationships)) {
1420
            $relation = $this->getRelationshipManager($k);
1421
            $this->_relationships[$k] = $relation->getResults();
1422
        }
1423
1424
        return $this->_relationships[$k];
1425
    }
1426
1427
    /**
1428
     * @deprecated
1429
     *
1430
     * Sets the model for a one-to-one relationship (has-one or belongs-to)
1431
     *
1432
     * @param string $k
1433
     * @param Model  $model
1434
     *
1435
     * @return $this
1436
     */
1437
    public function setRelation(string $k, Model $model)
1438
    {
1439
        $this->$k = $model->id();
1440
        $this->_relationships[$k] = $model;
1441
1442
        return $this;
1443
    }
1444
1445
    /**
1446
     * @deprecated
1447
     *
1448
     * Sets the model for a one-to-many relationship
1449
     *
1450
     * @param string   $k
1451
     * @param iterable $models
1452
     *
1453
     * @return $this
1454
     */
1455
    public function setRelationCollection(string $k, iterable $models)
1456
    {
1457
        $this->_relationships[$k] = $models;
1458
1459
        return $this;
1460
    }
1461
1462
    /**
1463
     * Sets the model for a one-to-one relationship (has-one or belongs-to) as null.
1464
     *
1465
     * @param string $k
1466
     *
1467
     * @return $this
1468
     */
1469
    public function clearRelation(string $k)
1470
    {
1471
        $this->$k = null;
1472
        $this->_relationships[$k] = null;
1473
1474
        return $this;
1475
    }
1476
1477
    /**
1478
     * Builds a relationship manager object for a given property.
1479
     *
1480
     * @param array $k
1481
     *
1482
     * @throws \InvalidArgumentException when the relationship manager cannot be created
1483
     *
1484
     * @return Relation
1485
     */
1486
    public function getRelationshipManager(string $k): Relation
1487
    {
1488
        $property = static::getProperty($k);
0 ignored issues
show
Bug introduced by
It seems like $k defined by parameter $k on line 1486 can also be of type array; however, Pulsar\Model::getProperty() does only seem to accept string, maybe add an additional type check?

This check looks at variables that have been passed in as parameters and are passed out again to other methods.

If the outgoing method call has stricter type requirements than the method itself, an issue is raised.

An additional type check may prevent trouble.

Loading history...
1489
        if (!isset($property['relation'])) {
1490
            throw new \InvalidArgumentException('Property "'.$k.'" does not have a relationship.');
1491
        }
1492
1493
        $relationModelClass = $property['relation'];
1494
        $foreignKey = array_value($property, 'foreign_key');
1495
        $localKey = array_value($property, 'local_key');
1496
1497
        if (self::RELATIONSHIP_HAS_ONE == $property['relation_type']) {
1498
            return $this->hasOne($relationModelClass, $foreignKey, $localKey);
1499
        }
1500
1501
        if (self::RELATIONSHIP_HAS_MANY == $property['relation_type']) {
1502
            return $this->hasMany($relationModelClass, $foreignKey, $localKey);
1503
        }
1504
1505
        if (self::RELATIONSHIP_BELONGS_TO == $property['relation_type']) {
1506
            return $this->belongsTo($relationModelClass, $foreignKey, $localKey);
1507
        }
1508
1509
        if (self::RELATIONSHIP_BELONGS_TO_MANY == $property['relation_type']) {
1510
            $pivotTable = array_value($property, 'pivot_tablename');
1511
1512
            return $this->belongsToMany($relationModelClass, $pivotTable, $foreignKey, $localKey);
1513
        }
1514
1515
        throw new \InvalidArgumentException('Relationship type on "'.$k.'" property not supported: '.$property['relation_type']);
1516
    }
1517
1518
    /**
1519
     * Creates the parent side of a One-To-One relationship.
1520
     *
1521
     * @param string $model      foreign model class
1522
     * @param string $foreignKey identifying key on foreign model
1523
     * @param string $localKey   identifying key on local model
1524
     *
1525
     * @return HasOne
1526
     */
1527
    public function hasOne($model, $foreignKey = '', $localKey = ''): HasOne
1528
    {
1529
        return new HasOne($this, $localKey, $model, $foreignKey);
1530
    }
1531
1532
    /**
1533
     * Creates the child side of a One-To-One or One-To-Many relationship.
1534
     *
1535
     * @param string $model      foreign model class
1536
     * @param string $foreignKey identifying key on foreign model
1537
     * @param string $localKey   identifying key on local model
1538
     *
1539
     * @return BelongsTo
1540
     */
1541
    public function belongsTo($model, $foreignKey = '', $localKey = ''): BelongsTo
1542
    {
1543
        return new BelongsTo($this, $localKey, $model, $foreignKey);
1544
    }
1545
1546
    /**
1547
     * Creates the parent side of a Many-To-One or Many-To-Many relationship.
1548
     *
1549
     * @param string $model      foreign model class
1550
     * @param string $foreignKey identifying key on foreign model
1551
     * @param string $localKey   identifying key on local model
1552
     *
1553
     * @return HasMany
1554
     */
1555
    public function hasMany($model, $foreignKey = '', $localKey = ''): HasMany
1556
    {
1557
        return new HasMany($this, $localKey, $model, $foreignKey);
1558
    }
1559
1560
    /**
1561
     * Creates the child side of a Many-To-Many relationship.
1562
     *
1563
     * @param string $model      foreign model class
1564
     * @param string $tablename  pivot table name
1565
     * @param string $foreignKey identifying key on foreign model
1566
     * @param string $localKey   identifying key on local model
1567
     *
1568
     * @return BelongsToMany
1569
     */
1570
    public function belongsToMany($model, $tablename = '', $foreignKey = '', $localKey = ''): BelongsToMany
1571
    {
1572
        return new BelongsToMany($this, $localKey, $tablename, $model, $foreignKey);
1573
    }
1574
1575
    /////////////////////////////
1576
    // Events
1577
    /////////////////////////////
1578
1579
    /**
1580
     * Gets the event dispatcher.
1581
     *
1582
     * @return EventDispatcher
1583
     */
1584
    public static function getDispatcher($ignoreCache = false): EventDispatcher
1585
    {
1586
        $class = get_called_class();
1587
        if ($ignoreCache || !isset(self::$dispatchers[$class])) {
1588
            self::$dispatchers[$class] = new EventDispatcher();
1589
        }
1590
1591
        return self::$dispatchers[$class];
1592
    }
1593
1594
    /**
1595
     * Subscribes to a listener to an event.
1596
     *
1597
     * @param string   $event    event name
1598
     * @param callable $listener
1599
     * @param int      $priority optional priority, higher #s get called first
1600
     */
1601
    public static function listen(string $event, callable $listener, int $priority = 0)
1602
    {
1603
        static::getDispatcher()->addListener($event, $listener, $priority);
1604
    }
1605
1606
    /**
1607
     * Adds a listener to the model.creating and model.updating events.
1608
     *
1609
     * @param callable $listener
1610
     * @param int      $priority
1611
     */
1612
    public static function saving(callable $listener, int $priority = 0)
1613
    {
1614
        static::listen(ModelEvent::CREATING, $listener, $priority);
1615
        static::listen(ModelEvent::UPDATING, $listener, $priority);
1616
    }
1617
1618
    /**
1619
     * Adds a listener to the model.created and model.updated events.
1620
     *
1621
     * @param callable $listener
1622
     * @param int      $priority
1623
     */
1624
    public static function saved(callable $listener, int $priority = 0)
1625
    {
1626
        static::listen(ModelEvent::CREATED, $listener, $priority);
1627
        static::listen(ModelEvent::UPDATED, $listener, $priority);
1628
    }
1629
1630
    /**
1631
     * Adds a listener to the model.creating event.
1632
     *
1633
     * @param callable $listener
1634
     * @param int      $priority
1635
     */
1636
    public static function creating(callable $listener, int $priority = 0)
1637
    {
1638
        static::listen(ModelEvent::CREATING, $listener, $priority);
1639
    }
1640
1641
    /**
1642
     * Adds a listener to the model.created event.
1643
     *
1644
     * @param callable $listener
1645
     * @param int      $priority
1646
     */
1647
    public static function created(callable $listener, int $priority = 0)
1648
    {
1649
        static::listen(ModelEvent::CREATED, $listener, $priority);
1650
    }
1651
1652
    /**
1653
     * Adds a listener to the model.updating event.
1654
     *
1655
     * @param callable $listener
1656
     * @param int      $priority
1657
     */
1658
    public static function updating(callable $listener, int $priority = 0)
1659
    {
1660
        static::listen(ModelEvent::UPDATING, $listener, $priority);
1661
    }
1662
1663
    /**
1664
     * Adds a listener to the model.updated event.
1665
     *
1666
     * @param callable $listener
1667
     * @param int      $priority
1668
     */
1669
    public static function updated(callable $listener, int $priority = 0)
1670
    {
1671
        static::listen(ModelEvent::UPDATED, $listener, $priority);
1672
    }
1673
1674
    /**
1675
     * Adds a listener to the model.deleting event.
1676
     *
1677
     * @param callable $listener
1678
     * @param int      $priority
1679
     */
1680
    public static function deleting(callable $listener, int $priority = 0)
1681
    {
1682
        static::listen(ModelEvent::DELETING, $listener, $priority);
1683
    }
1684
1685
    /**
1686
     * Adds a listener to the model.deleted event.
1687
     *
1688
     * @param callable $listener
1689
     * @param int      $priority
1690
     */
1691
    public static function deleted(callable $listener, int $priority = 0)
1692
    {
1693
        static::listen(ModelEvent::DELETED, $listener, $priority);
1694
    }
1695
1696
    /**
1697
     * Dispatches the given event and checks if it was successful.
1698
     *
1699
     * @return bool true if the events were successfully propagated
1700
     */
1701
    private function performDispatch(string $eventName, bool $usesTransactions): bool
1702
    {
1703
        $event = new ModelEvent($this);
1704
        static::getDispatcher()->dispatch($event, $eventName);
1705
1706
        // when listeners fail roll back any database transaction
1707
        if ($event->isPropagationStopped()) {
1708
            if ($usesTransactions) {
1709
                self::$driver->rollBackTransaction($this->getConnection());
1710
            }
1711
1712
            return false;
1713
        }
1714
1715
        return true;
1716
    }
1717
1718
    /////////////////////////////
1719
    // Validation
1720
    /////////////////////////////
1721
1722
    /**
1723
     * Gets the error stack for this model.
1724
     *
1725
     * @return Errors
1726
     */
1727
    public function getErrors(): Errors
1728
    {
1729
        if (!$this->_errors) {
1730
            $this->_errors = new Errors();
1731
        }
1732
1733
        return $this->_errors;
1734
    }
1735
1736
    /**
1737
     * Checks if the model in its current state is valid.
1738
     *
1739
     * @return bool
1740
     */
1741
    public function valid(): bool
1742
    {
1743
        // clear any previous errors
1744
        $this->getErrors()->clear();
1745
1746
        // run the validator against the model values
1747
        $values = $this->_unsaved + $this->_values;
1748
1749
        $validated = true;
1750
        foreach ($values as $k => $v) {
1751
            $property = static::getProperty($k);
1752
            $validated = $this->filterAndValidate($property, $k, $v) && $validated;
0 ignored issues
show
Bug introduced by
It seems like $property defined by static::getProperty($k) on line 1751 can also be of type null; however, Pulsar\Model::filterAndValidate() does only seem to accept array, maybe add an additional type check?

If a method or function can return multiple different values and unless you are sure that you only can receive a single value in this context, we recommend to add an additional type check:

/**
 * @return array|string
 */
function returnsDifferentValues($x) {
    if ($x) {
        return 'foo';
    }

    return array();
}

$x = returnsDifferentValues($y);
if (is_array($x)) {
    // $x is an array.
}

If this a common case that PHP Analyzer should handle natively, please let us know by opening an issue.

Loading history...
1753
        }
1754
1755
        // add back any modified unsaved values
1756
        foreach (array_keys($this->_unsaved) as $k) {
1757
            $this->_unsaved[$k] = $values[$k];
1758
        }
1759
1760
        return $validated;
1761
    }
1762
1763
    /**
1764
     * Validates and marshals a value to storage.
1765
     *
1766
     * @param array  $property property definition
1767
     * @param string $name     property name
1768
     * @param mixed  $value
1769
     *
1770
     * @return bool
1771
     */
1772
    private function filterAndValidate(array $property, string $name, &$value): bool
1773
    {
1774
        // assume empty string is a null value for properties
1775
        // that are marked as optionally-null
1776
        if ($property['null'] && empty($value)) {
1777
            $value = null;
1778
1779
            return true;
1780
        }
1781
1782
        // validate
1783
        list($valid, $value) = $this->validateValue($property, $name, $value);
1784
1785
        // unique?
1786
        if ($valid && $property['unique'] && (false === $this->_id || $value != $this->ignoreUnsaved()->$name)) {
1787
            $valid = $this->checkUniqueness($name, $value);
1788
        }
1789
1790
        return $valid;
1791
    }
1792
1793
    /**
1794
     * Validates a value for a property.
1795
     *
1796
     * @param array  $property property definition
1797
     * @param string $name     property name
1798
     * @param mixed  $value
1799
     *
1800
     * @return array
1801
     */
1802
    private function validateValue(array $property, string $name, $value): array
1803
    {
1804
        $valid = true;
1805
1806
        $error = 'pulsar.validation.failed';
1807
        if (isset($property['validate']) && is_callable($property['validate'])) {
1808
            $valid = call_user_func_array($property['validate'], [$value]);
1809
        } elseif (isset($property['validate'])) {
1810
            $validator = new Validator($property['validate']);
1811
            $valid = $validator->validate($value);
1812
            $error = 'pulsar.validation.'.$validator->getFailingRule();
1813
        }
1814
1815
        if (!$valid) {
1816
            $params = [
1817
                'field' => $name,
1818
                'field_name' => $this->getPropertyTitle($name),
1819
            ];
1820
            $this->getErrors()->add($error, $params);
1821
        }
1822
1823
        return [$valid, $value];
1824
    }
1825
1826
    /**
1827
     * Checks if a value is unique for a property.
1828
     *
1829
     * @param string $name  property name
1830
     * @param mixed  $value
1831
     *
1832
     * @return bool
1833
     */
1834
    private function checkUniqueness(string $name, $value): bool
1835
    {
1836
        $n = static::query()->where([$name => $value])->count();
1837
        if ($n > 0) {
1838
            $params = [
1839
                'field' => $name,
1840
                'field_name' => $this->getPropertyTitle($name),
1841
            ];
1842
            $this->getErrors()->add('pulsar.validation.unique', $params);
1843
1844
            return false;
1845
        }
1846
1847
        return true;
1848
    }
1849
1850
    /**
1851
     * Gets the marshaled default value for a property (if set).
1852
     *
1853
     * @param array $property
1854
     *
1855
     * @return mixed
1856
     */
1857
    private function getPropertyDefault(array $property)
1858
    {
1859
        return array_value($property, 'default');
1860
    }
1861
1862
    /**
1863
     * Gets the humanized name of a property.
1864
     *
1865
     * @param string $name property name
1866
     *
1867
     * @return string
1868
     */
1869
    private function getPropertyTitle(string $name): string
1870
    {
1871
        // look up the property from the locale service first
1872
        $k = 'pulsar.properties.'.static::modelName().'.'.$name;
1873
        $locale = $this->getErrors()->getLocale();
1874
        $title = $locale->t($k);
1875
        if ($title != $k) {
1876
            return $title;
1877
        }
1878
1879
        // otherwise just attempt to title-ize the property name
1880
        return Inflector::get()->titleize($name);
1881
    }
1882
}
1883