Completed
Branch feature/pre-split (7f7f80)
by Anton
04:48
created

RecordEntity::assertField()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 10
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
eloc 6
nc 2
nop 1
dl 0
loc 10
rs 9.4285
c 0
b 0
f 0
1
<?php
2
/**
3
 * components
4
 *
5
 * @author    Wolfy-J
6
 */
7
namespace Spiral\ORM;
8
9
use Spiral\Core\Component;
10
use Spiral\Core\Traits\SaturateTrait;
11
use Spiral\Models\AccessorInterface;
12
use Spiral\Models\SchematicEntity;
13
use Spiral\Models\Traits\SolidableTrait;
14
use Spiral\ORM\Commands\DeleteCommand;
15
use Spiral\ORM\Commands\InsertCommand;
16
use Spiral\ORM\Commands\NullCommand;
17
use Spiral\ORM\Commands\UpdateCommand;
18
use Spiral\ORM\Entities\RelationBucket;
19
use Spiral\ORM\Events\RecordEvent;
20
use Spiral\ORM\Exceptions\FieldException;
21
use Spiral\ORM\Exceptions\RecordException;
22
use Spiral\ORM\Exceptions\RelationException;
23
24
/**
25
 * Provides ActiveRecord-less abstraction for carried data with ability to automatically apply
26
 * setters, getters, generate update, insert and delete sequences and access nested relations.
27
 *
28
 * Class implementations statically analyzed to define DB schema.
29
 *
30
 * @see RecordEntity::SCHEMA
31
 *
32
 * Potentially requires split for StateWatcher.
33
 */
34
abstract class RecordEntity extends SchematicEntity implements RecordInterface
35
{
36
    use SaturateTrait, SolidableTrait;
37
38
    /*
39
     * Begin set of behaviour and description constants.
40
     * ================================================
41
     */
42
43
    /**
44
     * Set of schema sections needed to describe entity behaviour.
45
     */
46
    const SH_PRIMARIES = 0;
47
    const SH_DEFAULTS  = 1;
48
    const SH_RELATIONS = 6;
49
50
    /**
51
     * Default ORM relation types, see ORM configuration and documentation for more information.
52
     *
53
     * @see RelationSchemaInterface
54
     * @see RelationSchema
55
     */
56
    const HAS_ONE      = 101;
57
    const HAS_MANY     = 102;
58
    const BELONGS_TO   = 103;
59
    const MANY_TO_MANY = 104;
60
61
    /**
62
     * Morphed relation types are usually created by inversion or equivalent of primary relation
63
     * types.
64
     *
65
     * @see RelationSchemaInterface
66
     * @see RelationSchema
67
     * @see MorphedRelation
68
     */
69
    const BELONGS_TO_MORPHED = 108;
70
    const MANY_TO_MORPHED    = 109;
71
72
    /**
73
     * Constants used to declare relations in record schema, used in normalized relation schema.
74
     *
75
     * @see RelationSchemaInterface
76
     */
77
    const OUTER_KEY         = 901; //Outer key name
78
    const INNER_KEY         = 902; //Inner key name
79
    const MORPH_KEY         = 903; //Morph key name
80
    const PIVOT_TABLE       = 904; //Pivot table name
81
    const PIVOT_COLUMNS     = 905; //Pre-defined pivot table columns
82
    const PIVOT_DEFAULTS    = 906; //Pre-defined pivot table default values
83
    const THOUGHT_INNER_KEY = 907; //Pivot table options
84
    const THOUGHT_OUTER_KEY = 908; //Pivot table options
85
    const WHERE             = 909; //Where conditions
86
    const WHERE_PIVOT       = 910; //Where pivot conditions
87
88
    /**
89
     * Additional constants used to control relation schema behaviour.
90
     *
91
     * @see RecordEntity::SCHEMA
92
     * @see RelationSchemaInterface
93
     */
94
    const INVERSE           = 1001; //Relation should be inverted to parent record
95
    const CREATE_CONSTRAINT = 1002; //Relation should create foreign keys (default)
96
    const CONSTRAINT_ACTION = 1003; //Default relation foreign key delete/update action (CASCADE)
97
    const CREATE_PIVOT      = 1004; //Many-to-Many should create pivot table automatically (default)
98
    const NULLABLE          = 1005; //Relation can be nullable (default)
99
    const CREATE_INDEXES    = 1006; //Indication that relation is allowed to create required indexes
100
    const MORPHED_ALIASES   = 1007; //Aliases for morphed sub-relations
101
102
    /**
103
     * Set of columns to be used in relation (attention, make sure that loaded records are set as
104
     * NON SOLID if you planning to modify their data).
105
     */
106
    const RELATION_COLUMNS = 1009;
107
108
    /**
109
     * Constants used to declare indexes in record schema.
110
     *
111
     * @see Record::INDEXES
112
     */
113
    const INDEX  = 1000;            //Default index type
114
    const UNIQUE = 2000;            //Unique index definition
115
116
    /*
117
     * ================================================
118
     * End set of behaviour and description constants.
119
     */
120
121
    /**
122
     * Model behaviour configurations.
123
     */
124
    const SECURED   = '*';
125
    const HIDDEN    = [];
126
    const FILLABLE  = [];
127
    const SETTERS   = [];
128
    const GETTERS   = [];
129
    const ACCESSORS = [];
130
131
    /**
132
     * Record relations and columns can be described in one place - record schema.
133
     * Attention: while defining table structure make sure that ACTIVE_SCHEMA constant is set to t
134
     * rue.
135
     *
136
     * Example:
137
     * const SCHEMA = [
138
     *      'id'        => 'primary',
139
     *      'name'      => 'string',
140
     *      'biography' => 'text'
141
     * ];
142
     *
143
     * You can pass additional options for some of your columns:
144
     * const SCHEMA = [
145
     *      'pinCode' => 'string(128)',         //String length
146
     *      'status'  => 'enum(active, hidden)', //Enum values
147
     *      'balance' => 'decimal(10, 2)'       //Decimal size and precision
148
     * ];
149
     *
150
     * Every created column will be stated as NOT NULL with forced default value, if you want to
151
     * have nullable columns, specify special data key: protected $schema = [
152
     *      'name'      => 'string, nullable'
153
     * ];
154
     *
155
     * You can easily combine table and relations definition in one schema:
156
     * const SCHEMA = [
157
     *      'id'          => 'bigPrimary',
158
     *      'name'        => 'string',
159
     *      'email'       => 'string',
160
     *      'phoneNumber' => 'string(32)',
161
     *
162
     *      //Relations
163
     *      'profile'     => [
164
     *          self::HAS_ONE => 'Records\Profile',
165
     *          self::INVERSE => 'user'
166
     *      ],
167
     *      'roles'       => [
168
     *          self::MANY_TO_MANY => 'Records\Role',
169
     *          self::INVERSE => 'users'
170
     *      ]
171
     * ];
172
     *
173
     * @var array
174
     */
175
    const SCHEMA = [];
176
177
    /**
178
     * Default field values.
179
     *
180
     * @var array
181
     */
182
    const DEFAULTS = [];
183
184
    /**
185
     * Set of indexes to be created for associated record table, indexes only created when record is
186
     * not abstract and has active schema set to true.
187
     *
188
     * Use constants INDEX and UNIQUE to describe indexes, you can also create compound indexes:
189
     * const INDEXES = [
190
     *      [self::UNIQUE, 'email'],
191
     *      [self::INDEX, 'board_id'],
192
     *      [self::INDEX, 'board_id', 'check_id']
193
     * ];
194
     *
195
     * @var array
196
     */
197
    const INDEXES = [];
198
199
    /**
200
     * Record behaviour definition.
201
     *
202
     * @var array
203
     */
204
    private $recordSchema = [];
205
206
    /**
207
     * Record state.
208
     *
209
     * @var int
210
     */
211
    private $state;
212
213
    /**
214
     * Record field updates (changed values). This array contain set of initial property values if
215
     * any of them changed.
216
     *
217
     * @var array
218
     */
219
    private $changes = [];
220
221
    /**
222
     * AssociatedRelation bucket. Manages declared record relations.
223
     *
224
     * @var RelationBucket
225
     */
226
    protected $relations;
227
228
    /**
229
     * Parent ORM instance, responsible for relation initialization and lazy loading operations.
230
     *
231
     * @invisible
232
     * @var ORMInterface
233
     */
234
    protected $orm;
235
236
    /**
237
     * Initiate entity inside or outside of ORM scope using given fields and state.
238
     *
239
     * @param array             $data
240
     * @param int               $state
241
     * @param ORMInterface|null $orm
242
     * @param array|null        $schema
243
     */
244
    public function __construct(
245
        array $data = [],
246
        int $state = ORMInterface::STATE_NEW,
247
        ORMInterface $orm = null,
248
        array $schema = null
249
    ) {//We can use global container as fallback if no default values were provided
250
        $this->orm = $this->saturate($orm, ORMInterface::class);
251
252
        //Use supplied schema or fetch one from ORM
253
        $this->recordSchema = !empty($schema) ? $schema : $this->orm->define(
254
            static::class,
255
            ORMInterface::R_SCHEMA
256
        );
257
258
        $this->state = $state;
259
        if ($this->state == ORMInterface::STATE_NEW) {
260
            //Non loaded records should be in solid state by default
261
            $this->solidState(true);
262
        }
263
264
        $this->relations = new RelationBucket($this, $this->orm);
265
        $this->relations->extractRelations($data);
266
267
        parent::__construct($data + $this->recordSchema[self::SH_DEFAULTS], $this->recordSchema);
268
    }
269
270
    /**
271
     * Check if entity been loaded (non new).
272
     *
273
     * @return bool
274
     */
275
    public function isLoaded(): bool
276
    {
277
        return $this->state != ORMInterface::STATE_NEW;
278
    }
279
280
    /**
281
     * Current model state.
282
     *
283
     * @return int
284
     */
285
    public function getState(): int
286
    {
287
        return $this->state;
288
    }
289
290
    /**
291
     * {@inheritdoc}
292
     *
293
     * @throws RelationException
294
     */
295
    public function getField(string $name, $default = null, bool $filter = true)
296
    {
297
        if ($this->relations->has($name)) {
298
            return $this->relations->getRelated($name);
299
        }
300
301
        $this->assertField($name);
302
303
        return parent::getField($name, $default, $filter);
304
    }
305
306
    /**
307
     * {@inheritdoc}
308
     *
309
     * @param bool $registerChanges Track field changes.
310
     *
311
     * @throws RelationException
312
     */
313
    public function setField(
314
        string $name,
315
        $value,
316
        bool $filter = true,
317
        bool $registerChanges = true
318
    ) {
319
        if ($this->relations->has($name)) {
320
            //Would not work with relations which do not represent singular entities
321
            $this->relations->setRelated($name, $value);
322
323
            return;
324
        }
325
326
        $this->assertField($name);
327
        if ($registerChanges) {
328
            $this->registerChange($name);
329
        }
330
331
        parent::setField($name, $value, $filter);
332
    }
333
334
    /**
335
     * {@inheritdoc}
336
     */
337
    public function hasField(string $name): bool
338
    {
339
        if ($this->relations->has($name)) {
340
            return $this->relations->hasRelated($name);
341
        }
342
343
        return parent::hasField($name);
344
    }
345
346
    /**
347
     * {@inheritdoc}
348
     *
349
     * @throws FieldException
350
     * @throws RelationException
351
     */
352
    public function __unset($offset)
353
    {
354
        if ($this->relations->has($offset)) {
355
            //Flush associated relation value if possible
356
            $this->relations->flushRelated($offset);
357
358
            return;
359
        }
360
361
        if (!$this->isNullable($offset)) {
362
            throw new FieldException("Unable to unset not nullable field '{$offset}'");
363
        }
364
365
        $this->setField($offset, null, false);
366
    }
367
368
    /**
369
     * {@inheritdoc}
370
     *
371
     * Method does not check updates in nested relation, but only in primary record.
372
     *
373
     * @param string $field Check once specific field changes.
374
     */
375
    public function hasChanges(string $field = null): bool
376
    {
377
        //Check updates for specific field
378
        if (!empty($field)) {
379
            if (array_key_exists($field, $this->changes)) {
380
                return true;
381
            }
382
383
            //Do not force accessor creation
384
            $value = $this->getField($field, null, false);
385
            if ($value instanceof RecordAccessorInterface && $value->hasUpdates()) {
386
                return true;
387
            }
388
389
            return false;
390
        }
391
392
        if (!empty($this->changes)) {
393
            return true;
394
        }
395
396
        //Do not force accessor creation
397
        foreach ($this->getFields(false) as $value) {
398
            //Checking all fields for changes (handled internally)
399
            if ($value instanceof RecordAccessorInterface && $value->hasUpdates()) {
400
                return true;
401
            }
402
        }
403
404
        return false;
405
    }
406
407
    /**
408
     * {@inheritdoc}
409
     *
410
     * @param bool $queueRelations
411
     *
412
     * @throws RecordException
413
     * @throws RelationException
414
     */
415
    public function queueSave(bool $queueRelations = true): CommandInterface
416
    {
417
        if ($this->state == ORMInterface::STATE_READONLY) {
418
            //Nothing to do on readonly entities
419
            return new NullCommand();
420
        }
421
422
        if (!$this->isLoaded()) {
423
            $command = $this->prepareInsert();
424
        } else {
425
            if ($this->hasChanges() || $this->solidState) {
426
                $command = $this->prepareUpdate();
427
428
            } else {
429
                $command = new NullCommand();
430
            }
431
        }
432
433
        //Changes are flushed BEFORE entity is saved, this is required to present
434
        //recursive update loops
435
        $this->flushChanges();
436
437
        //Relation commands
438
        if ($queueRelations) {
439
            //Queue relations before and after parent command (if needed)
440
            return $this->relations->queueRelations($command);
441
        }
442
443
        return $command;
444
    }
445
446
    /**
447
     * {@inheritdoc}
448
     *
449
     * @throws RecordException
450
     * @throws RelationException
451
     */
452
    public function queueDelete(): CommandInterface
453
    {
454
        if ($this->state == ORMInterface::STATE_READONLY || !$this->isLoaded()) {
455
            //Nothing to do
456
            return new NullCommand();
457
        }
458
459
        return $this->prepareDelete();
460
    }
461
462
    /**
463
     * @return array
464
     */
465
    public function __debugInfo()
466
    {
467
        return [
468
            'database'  => $this->orm->define(static::class, ORMInterface::R_DATABASE),
469
            'table'     => $this->orm->define(static::class, ORMInterface::R_TABLE),
470
            'fields'    => $this->getFields(),
471
            'relations' => $this->relations
472
        ];
473
    }
474
475
    /**
476
     * {@inheritdoc}
477
     *
478
     * DocumentEntity will pass ODM instance as part of accessor context.
479
     *
480
     * @see CompositionDefinition
481
     */
482
    protected function createAccessor(
483
        $accessor,
484
        string $name,
485
        $value,
486
        array $context = []
487
    ): AccessorInterface {
488
        //Giving ORM as context
489
        return parent::createAccessor($accessor, $name, $value, $context + ['orm' => $this->orm]);
490
    }
491
492
    /**
493
     * {@inheritdoc}
494
     */
495
    protected function iocContainer()
496
    {
497
        if ($this->orm instanceof Component) {
498
            //Forwarding IoC scope to parent ORM instance
499
            return $this->orm->iocContainer();
500
        }
501
502
        return parent::iocContainer();
503
    }
504
505
    /*
506
     * Code below used to generate transaction commands.
507
     */
508
509
    /**
510
     * Change object state.
511
     *
512
     * @param int $state
513
     */
514
    private function setState(int $state)
515
    {
516
        $this->state = $state;
517
    }
518
519
    /**
520
     * @return InsertCommand
521
     */
522
    private function prepareInsert(): InsertCommand
523
    {
524
        //Entity indicates it's own status
525
        $this->setState(ORMInterface::STATE_SCHEDULED_INSERT);
526
527
        $command = new InsertCommand(
528
            $this->packValue(),
0 ignored issues
show
Unused Code introduced by
The call to InsertCommand::__construct() has too many arguments starting with $this->packValue().

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress.

In this case you can add the @ignore PhpDoc annotation to the duplicate definition and it will be ignored.

Loading history...
529
            $this->orm->define(static::class, ORMInterface::R_DATABASE),
530
            $this->orm->define(static::class, ORMInterface::R_TABLE)
531
        );
532
533
        $this->dispatch('insert', new RecordEvent($this, $command));
534
535
        //Executed when transaction successfully completed
536
        $command->onComplete(function ($command) {
0 ignored issues
show
Unused Code introduced by
The parameter $command is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
Unused Code introduced by
The call to InsertCommand::onComplete() has too many arguments starting with function ($command) { ...\RecordEvent($this)); }.

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress.

In this case you can add the @ignore PhpDoc annotation to the duplicate definition and it will be ignored.

Loading history...
537
            $this->setState(ORMInterface::STATE_LOADED);
538
            $this->dispatch('created', new RecordEvent($this));
0 ignored issues
show
Bug introduced by
The call to RecordEvent::__construct() misses a required argument $command.

This check looks for function calls that miss required arguments.

Loading history...
539
540
            //Sync context?
541
        });
542
543
        return $command;
544
    }
545
546
    /**
547
     * @return UpdateCommand
548
     */
549
    private function prepareUpdate(): UpdateCommand
550
    {
551
        //Entity indicates it's own status
552
        $this->setState(ORMInterface::STATE_SCHEDULED_UPDATE);
553
554
        $command = new UpdateCommand(
555
            $this->stateCriteria(),
0 ignored issues
show
Unused Code introduced by
The call to UpdateCommand::__construct() has too many arguments starting with $this->stateCriteria().

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress.

In this case you can add the @ignore PhpDoc annotation to the duplicate definition and it will be ignored.

Loading history...
556
            $this->packChanges(true),
557
            $this->orm->define(static::class, ORMInterface::R_DATABASE),
558
            $this->orm->define(static::class, ORMInterface::R_TABLE)
559
        );
560
561
        $this->dispatch('update', new RecordEvent($this, $command));
562
563
        //Executed when transaction successfully completed
564
        $command->onComplete(function ($command) {
0 ignored issues
show
Unused Code introduced by
The call to UpdateCommand::onComplete() has too many arguments starting with function ($command) { ...nt($this, $command)); }.

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress.

In this case you can add the @ignore PhpDoc annotation to the duplicate definition and it will be ignored.

Loading history...
565
            $this->setState(ORMInterface::STATE_LOADED);
566
            $this->dispatch('updated', new RecordEvent($this, $command));
567
568
            //Sync context?
569
        });
570
571
        return $command;
572
    }
573
574
    /**
575
     * @return DeleteCommand
576
     */
577
    private function prepareDelete(): DeleteCommand
578
    {
579
        //Entity indicates it's own status
580
        $this->setState(ORMInterface::STATE_SCHEDULED_DELETE);
581
        $this->dispatch('delete', new RecordEvent($this));
0 ignored issues
show
Bug introduced by
The call to RecordEvent::__construct() misses a required argument $command.

This check looks for function calls that miss required arguments.

Loading history...
582
583
        $command = new DeleteCommand(
584
            $this->stateCriteria(),
0 ignored issues
show
Unused Code introduced by
The call to DeleteCommand::__construct() has too many arguments starting with $this->stateCriteria().

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress.

In this case you can add the @ignore PhpDoc annotation to the duplicate definition and it will be ignored.

Loading history...
585
            $this->orm->define(static::class, ORMInterface::R_DATABASE),
586
            $this->orm->define(static::class, ORMInterface::R_TABLE)
587
        );
588
589
        //Executed when transaction successfully completed
590
        $command->onComplete(function () {
0 ignored issues
show
Unused Code introduced by
The call to DeleteCommand::onComplete() has too many arguments starting with function () { $this-...\RecordEvent($this)); }.

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress.

In this case you can add the @ignore PhpDoc annotation to the duplicate definition and it will be ignored.

Loading history...
591
            $this->setState(ORMInterface::STATE_DELETED);
592
            $this->dispatch('deleted', new RecordEvent($this));
0 ignored issues
show
Bug introduced by
The call to RecordEvent::__construct() misses a required argument $command.

This check looks for function calls that miss required arguments.

Loading history...
593
        });
594
595
        return $command;
596
    }
597
598
    /**
599
     * Get WHERE array to be used to perform record data update or deletion. Usually will include
600
     * record primary key.
601
     *
602
     * Usually just [ID => value] array.
603
     *
604
     * @return array
605
     */
606
    private function stateCriteria()
607
    {
608
        if (!empty($primaryKey = $this->recordSchema[self::SH_PRIMARIES])) {
609
610
            //Set of primary keys
611
            $state = [];
612
            foreach ($primaryKey as $key) {
613
                $state[$key] = $this->getField($key);
614
            }
615
616
            return $state;
617
        }
618
619
        //Use entity data as where definition
620
        return $this->changes + $this->packValue();
621
    }
622
623
    /**
624
     * Create set of fields to be sent to UPDATE statement.
625
     *
626
     * @param bool $skipPrimaries Remove primary keys from update statement.
627
     *
628
     * @return array
629
     */
630
    private function packChanges(bool $skipPrimaries = false): array
631
    {
632
        if (!$this->hasChanges() && !$this->isSolid()) {
633
            return [];
634
        }
635
636
        if ($this->isSolid()) {
637
            //Solid records always saved as one chunk of data
638
            return $this->packValue();
639
        }
640
641
        $updates = [];
642
        foreach ($this->getFields(false) as $field => $value) {
643
            if (
644
                $skipPrimaries
645
                && in_array($field, $this->recordSchema[self::SH_PRIMARIES])
646
            ) {
647
                continue;
648
            }
649
650
            //Handled by sub-accessor
651
            if ($value instanceof RecordAccessorInterface) {
652
                if ($value->hasUpdates()) {
653
                    $updates[$field] = $value->compileUpdates($field);
654
                    continue;
655
                }
656
657
                $value = $value->packValue();
658
            }
659
660
            //Field change registered
661
            if (array_key_exists($field, $this->changes)) {
662
                $updates[$field] = $value;
663
            }
664
        }
665
666
        return $updates;
667
    }
668
669
    /**
670
     * Indicate that all updates done, reset dirty state.
671
     */
672
    private function flushChanges()
673
    {
674
        $this->changes = [];
675
676
        foreach ($this->getFields(false) as $field => $value) {
677
            if ($value instanceof RecordAccessorInterface) {
678
                $value->flushUpdates();
679
            }
680
        }
681
    }
682
683
    /**
684
     * @param string $name
685
     */
686
    private function registerChange(string $name)
687
    {
688
        $original = $this->getField($name, null, false);
689
690
        if (!array_key_exists($name, $this->changes)) {
691
            //Let's keep track of how field looked before first change
692
            $this->changes[$name] = $original instanceof AccessorInterface
693
                ? $original->packValue()
694
                : $original;
695
        }
696
    }
697
698
    /**
699
     * @param string $name
700
     *
701
     * @throws FieldException
702
     */
703
    private function assertField(string $name)
704
    {
705
        if (!$this->hasField($name)) {
706
            throw new FieldException(sprintf(
707
                "No such property '%s' in '%s', check schema being relevant",
708
                $name,
709
                get_called_class()
710
            ));
711
        }
712
    }
713
}