Completed
Branch feature/pre-split (4cb052)
by Anton
03:34
created

RecordEntity::flushChanges()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 10
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 3
eloc 5
nc 3
nop 0
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
    private $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->get($name);
299
        }
300
301
        $this->assertField($name);
302
303
        return parent::getField($name, $default, $filter);
304
    }
305
306
    /**
307
     * {@inheritdoc}
308
     *
309
     * Tracks field changes.
310
     *
311
     * @throws RelationException
312
     */
313
    public function setField(string $name, $value, bool $filter = true)
314
    {
315
        if ($this->relations->has($name)) {
316
317
            //Would not work with relations which do not represent singular entities
318
            $this->relations->set($name, $value);
319
320
            return;
321
        }
322
323
        $this->assertField($name);
324
        $this->registerChange($name);
325
326
        parent::setField($name, $value, $filter);
327
    }
328
329
    /**
330
     * {@inheritdoc}
331
     */
332
    public function hasField(string $name): bool
333
    {
334
        if ($this->relations->has($name)) {
335
            return true;
336
        }
337
338
        return parent::hasField($name);
339
    }
340
341
    /**
342
     * {@inheritdoc}
343
     *
344
     * @throws FieldException
345
     * @throws RelationException
346
     */
347
    public function __unset($offset)
348
    {
349
        if ($this->relations->has($offset)) {
350
            //Flush associated relation value if possible
351
            $this->relations->flush($offset);
352
353
            return;
354
        }
355
356
        if (!$this->isNullable($offset)) {
357
            throw new FieldException("Unable to unset not nullable field '{$offset}'");
358
        }
359
360
        $this->setField($offset, null, false);
361
    }
362
363
    /**
364
     * {@inheritdoc}
365
     *
366
     * Method does not check updates in nested relation, but only in primary record.
367
     *
368
     * @param string $field Check once specific field changes.
369
     */
370
    public function hasChanges(string $field = null): bool
371
    {
372
        //Check updates for specific field
373
        if (!empty($field)) {
374
            if (array_key_exists($field, $this->changes)) {
375
                return true;
376
            }
377
378
            //Do not force accessor creation
379
            $value = $this->getField($field, null, false);
380
            if ($value instanceof RecordAccessorInterface && $value->hasUpdates()) {
381
                return true;
382
            }
383
384
            return false;
385
        }
386
387
        if (!empty($this->changes)) {
388
            return true;
389
        }
390
391
        //Do not force accessor creation
392
        foreach ($this->getFields(false) as $value) {
393
            //Checking all fields for changes (handled internally)
394
            if ($value instanceof RecordAccessorInterface && $value->hasUpdates()) {
395
                return true;
396
            }
397
        }
398
399
        return false;
400
    }
401
402
    /**
403
     * {@inheritdoc}
404
     *
405
     * @param bool $queueRelations
406
     *
407
     * @throws RecordException
408
     * @throws RelationException
409
     */
410
    public function queueSave(bool $queueRelations = true): CommandInterface
411
    {
412
        if ($this->state == ORMInterface::STATE_READONLY) {
413
            //Nothing to do on readonly entities
414
            return new NullCommand();
415
        }
416
417
        if (!$this->isLoaded()) {
418
            $command = $this->prepareInsert();
419
        } else {
420
            if ($this->hasChanges() || $this->solidState) {
421
                $command = $this->prepareUpdate();
422
            } else {
423
                $command = new NullCommand();
424
            }
425
        }
426
427
        //Relation commands
428
        if ($queueRelations) {
429
            //Queue relations before and after parent command (if needed)
430
            return $this->relations->queueRelations($command);
431
        }
432
433
        return $command;
434
    }
435
436
    /**
437
     * {@inheritdoc}
438
     *
439
     * @throws RecordException
440
     * @throws RelationException
441
     */
442
    public function queueDelete(): CommandInterface
443
    {
444
        if ($this->state == ORMInterface::STATE_READONLY || !$this->isLoaded()) {
445
            //Nothing to do
446
            return new NullCommand();
447
        }
448
449
        return $this->prepareDelete();
450
    }
451
452
    /**
453
     * @return array
454
     */
455
    public function __debugInfo()
456
    {
457
        return [
458
            'database'  => $this->orm->define(static::class, ORMInterface::R_DATABASE),
459
            'table'     => $this->orm->define(static::class, ORMInterface::R_TABLE),
460
            'fields'    => $this->getFields(),
461
            'relations' => $this->relations
462
        ];
463
    }
464
465
    /**
466
     * {@inheritdoc}
467
     *
468
     * DocumentEntity will pass ODM instance as part of accessor context.
469
     *
470
     * @see CompositionDefinition
471
     */
472
    protected function createAccessor(
473
        $accessor,
474
        string $name,
475
        $value,
476
        array $context = []
477
    ): AccessorInterface {
478
        //Giving ORM as context
479
        return parent::createAccessor($accessor, $name, $value, $context + ['orm' => $this->orm]);
480
    }
481
482
    /**
483
     * {@inheritdoc}
484
     */
485
    protected function iocContainer()
486
    {
487
        if ($this->orm instanceof Component) {
488
            //Forwarding IoC scope to parent ORM instance
489
            return $this->orm->iocContainer();
490
        }
491
492
        return parent::iocContainer();
493
    }
494
495
    /*
496
     * Code below used to generate transaction commands.
497
     */
498
499
    /**
500
     * Change object state.
501
     *
502
     * @param int $state
503
     */
504
    private function setState(int $state)
505
    {
506
        $this->state = $state;
507
    }
508
509
    /**
510
     * @return InsertCommand
511
     */
512
    private function prepareInsert(): InsertCommand
513
    {
514
        //Entity indicates it's own status
515
        $this->setState(ORMInterface::STATE_SCHEDULED_INSERT);
516
517
        $command = new InsertCommand(
518
            $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...
519
            $this->orm->define(static::class, ORMInterface::R_DATABASE),
520
            $this->orm->define(static::class, ORMInterface::R_TABLE)
521
        );
522
523
        $this->dispatch('insert', new RecordEvent($this, $command));
524
525
        //Executed when transaction successfully completed
526
        $command->onComplete(function ($command) {
0 ignored issues
show
Bug introduced by
The method onComplete() does not seem to exist on object<Spiral\ORM\Commands\InsertCommand>.

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
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...
527
            $this->setState(ORMInterface::STATE_LOADED);
528
            $this->flushChanges();
529
            $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...
530
        });
531
532
        return $command;
533
    }
534
535
    /**
536
     * @return UpdateCommand
537
     */
538
    private function prepareUpdate(): UpdateCommand
539
    {
540
        //Entity indicates it's own status
541
        $this->setState(ORMInterface::STATE_SCHEDULED_UPDATE);
542
543
        $command = new UpdateCommand(
544
            $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...
545
            $this->packChanges(true),
546
            $this->orm->define(static::class, ORMInterface::R_DATABASE),
547
            $this->orm->define(static::class, ORMInterface::R_TABLE)
548
        );
549
550
        $this->dispatch('update', new RecordEvent($this, $command));
551
552
        //Executed when transaction successfully completed
553
        $command->onComplete(function ($command) {
0 ignored issues
show
Bug introduced by
The method onComplete() does not seem to exist on object<Spiral\ORM\Commands\UpdateCommand>.

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
554
            $this->setState(ORMInterface::STATE_LOADED);
555
            $this->flushChanges();
556
            $this->dispatch('updated', new RecordEvent($this, $command));
557
        });
558
559
        return $command;
560
    }
561
562
    /**
563
     * @return DeleteCommand
564
     */
565
    private function prepareDelete(): DeleteCommand
566
    {
567
        //Entity indicates it's own status
568
        $this->setState(ORMInterface::STATE_SCHEDULED_DELETE);
569
        $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...
570
571
        $command = new DeleteCommand(
572
            $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...
573
            $this->orm->define(static::class, ORMInterface::R_DATABASE),
574
            $this->orm->define(static::class, ORMInterface::R_TABLE)
575
        );
576
577
        //Executed when transaction successfully completed
578
        $command->onComplete(function () {
0 ignored issues
show
Bug introduced by
The method onComplete() does not seem to exist on object<Spiral\ORM\Commands\DeleteCommand>.

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

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