Completed
Branch feature/pre-split (cb15b4)
by Anton
03:23
created

RecordEntity::stateCriteria()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 16
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 3
eloc 7
nc 3
nop 0
dl 0
loc 16
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\Events\RecordEvent;
19
use Spiral\ORM\Exceptions\FieldException;
20
21
/**
22
 * Provides ActiveRecord-less abstraction for carried data with ability to automatically apply
23
 * setters, getters, generate update, insert and delete sequences and access nested relations.
24
 *
25
 * Class implementations statically analyzed to define DB schema.
26
 *
27
 * @see RecordEntity::SCHEMA
28
 */
29
abstract class RecordEntity extends SchematicEntity implements RecordInterface
30
{
31
    use SaturateTrait, SolidableTrait;
32
33
    /*
34
     * Begin set of behaviour and description constants.
35
     * ================================================
36
     */
37
38
    /**
39
     * Set of schema sections needed to describe entity behaviour.
40
     */
41
    const SH_PRIMARIES = 0;
42
    const SH_DEFAULTS  = 1;
43
    const SH_RELATIONS = 6;
44
45
    /**
46
     * Default ORM relation types, see ORM configuration and documentation for more information.
47
     *
48
     * @see RelationSchemaInterface
49
     * @see RelationSchema
50
     */
51
    const HAS_ONE      = 101;
52
    const HAS_MANY     = 102;
53
    const BELONGS_TO   = 103;
54
    const MANY_TO_MANY = 104;
55
56
    /**
57
     * Morphed relation types are usually created by inversion or equivalent of primary relation
58
     * types.
59
     *
60
     * @see RelationSchemaInterface
61
     * @see RelationSchema
62
     * @see MorphedRelation
63
     */
64
    const BELONGS_TO_MORPHED = 108;
65
    const MANY_TO_MORPHED    = 109;
66
67
    /**
68
     * Constants used to declare relations in record schema, used in normalized relation schema.
69
     *
70
     * @see RelationSchemaInterface
71
     */
72
    const OUTER_KEY         = 901; //Outer key name
73
    const INNER_KEY         = 902; //Inner key name
74
    const MORPH_KEY         = 903; //Morph key name
75
    const PIVOT_TABLE       = 904; //Pivot table name
76
    const PIVOT_COLUMNS     = 905; //Pre-defined pivot table columns
77
    const PIVOT_DEFAULTS    = 906; //Pre-defined pivot table default values
78
    const THOUGHT_INNER_KEY = 907; //Pivot table options
79
    const THOUGHT_OUTER_KEY = 908; //Pivot table options
80
    const WHERE             = 909; //Where conditions
81
    const WHERE_PIVOT       = 910; //Where pivot conditions
82
83
    /**
84
     * Additional constants used to control relation schema behaviour.
85
     *
86
     * @see RecordEntity::SCHEMA
87
     * @see RelationSchemaInterface
88
     */
89
    const INVERSE           = 1001; //Relation should be inverted to parent record
90
    const CREATE_CONSTRAINT = 1002; //Relation should create foreign keys (default)
91
    const CONSTRAINT_ACTION = 1003; //Default relation foreign key delete/update action (CASCADE)
92
    const CREATE_PIVOT      = 1004; //Many-to-Many should create pivot table automatically (default)
93
    const NULLABLE          = 1005; //Relation can be nullable (default)
94
    const CREATE_INDEXES    = 1006; //Indication that relation is allowed to create required indexes
95
    const MORPHED_ALIASES   = 1007; //Aliases for morphed sub-relations
96
97
    /**
98
     * Set of columns to be used in relation (attention, make sure that loaded records are set as
99
     * NON SOLID if you planning to modify their data).
100
     */
101
    const RELATION_COLUMNS = 1009;
102
103
    /**
104
     * Constants used to declare indexes in record schema.
105
     *
106
     * @see Record::INDEXES
107
     */
108
    const INDEX  = 1000;            //Default index type
109
    const UNIQUE = 2000;            //Unique index definition
110
111
    /*
112
     * ================================================
113
     * End set of behaviour and description constants.
114
     */
115
116
    /**
117
     * Model behaviour configurations.
118
     */
119
    const SECURED   = '*';
120
    const HIDDEN    = [];
121
    const FILLABLE  = [];
122
    const SETTERS   = [];
123
    const GETTERS   = [];
124
    const ACCESSORS = [];
125
126
    /**
127
     * Record relations and columns can be described in one place - record schema.
128
     * Attention: while defining table structure make sure that ACTIVE_SCHEMA constant is set to t
129
     * rue.
130
     *
131
     * Example:
132
     * const SCHEMA = [
133
     *      'id'        => 'primary',
134
     *      'name'      => 'string',
135
     *      'biography' => 'text'
136
     * ];
137
     *
138
     * You can pass additional options for some of your columns:
139
     * const SCHEMA = [
140
     *      'pinCode' => 'string(128)',         //String length
141
     *      'status'  => 'enum(active, hidden)', //Enum values
142
     *      'balance' => 'decimal(10, 2)'       //Decimal size and precision
143
     * ];
144
     *
145
     * Every created column will be stated as NOT NULL with forced default value, if you want to
146
     * have nullable columns, specify special data key: protected $schema = [
147
     *      'name'      => 'string, nullable'
148
     * ];
149
     *
150
     * You can easily combine table and relations definition in one schema:
151
     * const SCHEMA = [
152
     *      'id'          => 'bigPrimary',
153
     *      'name'        => 'string',
154
     *      'email'       => 'string',
155
     *      'phoneNumber' => 'string(32)',
156
     *
157
     *      //Relations
158
     *      'profile'     => [
159
     *          self::HAS_ONE => 'Records\Profile',
160
     *          self::INVERSE => 'user'
161
     *      ],
162
     *      'roles'       => [
163
     *          self::MANY_TO_MANY => 'Records\Role',
164
     *          self::INVERSE => 'users'
165
     *      ]
166
     * ];
167
     *
168
     * @var array
169
     */
170
    const SCHEMA = [];
171
172
    /**
173
     * Default field values.
174
     *
175
     * @var array
176
     */
177
    const DEFAULTS = [];
178
179
    /**
180
     * Set of indexes to be created for associated record table, indexes only created when record is
181
     * not abstract and has active schema set to true.
182
     *
183
     * Use constants INDEX and UNIQUE to describe indexes, you can also create compound indexes:
184
     * const INDEXES = [
185
     *      [self::UNIQUE, 'email'],
186
     *      [self::INDEX, 'board_id'],
187
     *      [self::INDEX, 'board_id', 'check_id']
188
     * ];
189
     *
190
     * @var array
191
     */
192
    const INDEXES = [];
193
194
    /**
195
     * Record behaviour definition.
196
     *
197
     * @var array
198
     */
199
    private $recordSchema = [];
200
201
    /**
202
     * Record state.
203
     *
204
     * @var int
205
     */
206
    private $state;
207
208
    /**
209
     * Record field updates (changed values). This array contain set of initial property values if
210
     * any of them changed.
211
     *
212
     * @var array
213
     */
214
    private $changes = [];
215
216
    /**
217
     * Associated relation instances and/or initial loaded data.
218
     *
219
     * @var array
220
     */
221
    private $relations = [];
222
223
    /**
224
     * Parent ORM instance, responsible for relation initialization and lazy loading operations.
225
     *
226
     * @invisible
227
     * @var ORMInterface
228
     */
229
    protected $orm;
230
231
    /**
232
     * Initiate entity inside or outside of ORM scope using given fields and state.
233
     *
234
     * @param array             $fields
235
     * @param int               $state
236
     * @param ORMInterface|null $orm
237
     * @param array|null        $schema
238
     */
239
    public function __construct(
240
        array $fields = [],
241
        int $state = ORMInterface::STATE_NEW,
242
        ORMInterface $orm = null,
243
        array $schema = null
244
    ) {//We can use global container as fallback if no default values were provided
245
        $this->orm = $this->saturate($orm, ORMInterface::class);
246
247
        //Use supplied schema or fetch one from ORM
248
        $this->recordSchema = !empty($schema) ? $schema : $this->orm->define(
249
            static::class,
250
            ORMInterface::R_SCHEMA
251
        );
252
253
        $this->state = $state;
254
        if ($this->state == ORMInterface::STATE_NEW) {
255
            //Non loaded records should be in solid state by default
256
            $this->solidState(true);
257
        }
258
259
        $this->extractRelations($fields);
260
        parent::__construct($fields + $this->recordSchema[self::SH_DEFAULTS], $this->recordSchema);
261
    }
262
263
    /**
264
     * Check if entity been loaded (non new).
265
     *
266
     * @return bool
267
     */
268
    public function isLoaded(): bool
269
    {
270
        return $this->state != ORMInterface::STATE_NEW;
271
    }
272
273
    /**
274
     * Current model state.
275
     *
276
     * @return int
277
     */
278
    public function getState(): int
279
    {
280
        return $this->state;
281
    }
282
283
    /**
284
     * {@inheritdoc}
285
     */
286
    public function getField(string $name, $default = null, bool $filter = true)
287
    {
288
        if (!$this->hasField($name) && !isset($this->recordSchema[self::SH_RELATIONS][$name])) {
289
            throw new FieldException(sprintf(
290
                "No such property '%s' in '%s', check schema being relevant",
291
                $name,
292
                get_called_class()
293
            ));
294
        }
295
296
        //todo: get relation
297
298
        return parent::getField($name, $default, $filter);
299
    }
300
301
    /**
302
     * {@inheritdoc}
303
     *
304
     * Tracks field changes.
305
     */
306
    public function setField(string $name, $value, bool $filter = true)
307
    {
308
        //todo: check if relation
309
310
        if (!$this->hasField($name)) {
311
            //We are only allowing to modify existed fields, this is strict schema
312
            throw new FieldException(sprintf(
313
                "No such property '%s' in '%s', check schema being relevant",
314
                $name,
315
                get_called_class()
316
            ));
317
        }
318
319
        $this->registerChange($name);
320
321
        parent::setField($name, $value, $filter);
322
    }
323
324
    /**
325
     * {@inheritdoc}
326
     */
327
    public function __isset($name)
328
    {
329
        //todo: if relation
330
331
        return parent::__isset($name);
332
    }
333
334
    /**
335
     * {@inheritdoc}
336
     *
337
     * @throws FieldException
338
     */
339
    public function __unset($offset)
340
    {
341
        if (!$this->isNullable($offset)) {
342
            throw new FieldException("Unable to unset not nullable field '{$offset}'");
343
        }
344
345
        $this->setField($offset, null, false);
346
    }
347
348
    /**
349
     * {@inheritdoc}
350
     *
351
     * Method does not check updates in nested relation, but only in primary record.
352
     *
353
     * @param string $field Check once specific field changes.
354
     */
355
    public function hasUpdates(string $field = null): bool
356
    {
357
        //Check updates for specific field
358
        if (!empty($field)) {
359
            if (array_key_exists($field, $this->changes)) {
360
                return true;
361
            }
362
363
            //Do not force accessor creation
364
            $value = $this->getField($field, null, false);
365
            if ($value instanceof RecordAccessorInterface && $value->hasUpdates()) {
366
                return true;
367
            }
368
369
            return false;
370
        }
371
372
        if (!empty($this->changes)) {
373
            return true;
374
        }
375
376
        //Do not force accessor creation
377
        foreach ($this->getFields(false) as $value) {
378
            //Checking all fields for changes (handled internally)
379
            if ($value instanceof RecordAccessorInterface && $value->hasUpdates()) {
380
                return true;
381
            }
382
        }
383
384
        return false;
385
    }
386
387
    /**
388
     * {@inheritdoc}
389
     *
390
     * @param bool $queueRelations
391
     */
392
    public function queueSave(bool $queueRelations = true): CommandInterface
393
    {
394
        if ($this->state == ORMInterface::STATE_READONLY) {
395
            //Nothing to do on readonly entities
396
            return new NullCommand();
397
        }
398
399
        if (!$this->isLoaded()) {
400
            $command = $this->prepareInsert();
401
        } else {
402
            $command = $this->prepareUpdate();
403
        }
404
405
        //Relation commands
406
        if ($queueRelations) {
0 ignored issues
show
Unused Code introduced by
This if statement is empty and can be removed.

This check looks for the bodies of if statements that have no statements or where all statements have been commented out. This may be the result of changes for debugging or the code may simply be obsolete.

These if bodies can be removed. If you have an empty if but statements in the else branch, consider inverting the condition.

if (rand(1, 6) > 3) {
//print "Check failed";
} else {
    print "Check succeeded";
}

could be turned into

if (rand(1, 6) <= 3) {
    print "Check succeeded";
}

This is much more concise to read.

Loading history...
407
            //This is magical part
408
        }
409
410
        return $command;
411
    }
412
413
    /**
414
     * {@inheritdoc}
415
     */
416
    public function queueDelete(): CommandInterface
417
    {
418
        if ($this->state == ORMInterface::STATE_READONLY || !$this->isLoaded()) {
419
            //Nothing to do
420
            return new NullCommand();
421
        }
422
423
        return $this->prepareDelete();
424
    }
425
426
    /**
427
     * @return array
428
     */
429
    public function __debugInfo()
430
    {
431
        return [
432
            'database'  => $this->orm->define(static::class, ORMInterface::R_DATABASE),
433
            'table'     => $this->orm->define(static::class, ORMInterface::R_TABLE),
434
            'fields'    => $this->getFields(),
435
            'relations' => $this->relations
436
        ];
437
    }
438
439
    /**
440
     * {@inheritdoc}
441
     *
442
     * DocumentEntity will pass ODM instance as part of accessor context.
443
     *
444
     * @see CompositionDefinition
445
     */
446
    protected function createAccessor(
447
        $accessor,
448
        string $name,
449
        $value,
450
        array $context = []
451
    ): AccessorInterface {
452
        //Giving ORM as context
453
        return parent::createAccessor($accessor, $name, $value, $context + ['orm' => $this->orm]);
454
    }
455
456
    /**
457
     * {@inheritdoc}
458
     */
459
    protected function iocContainer()
460
    {
461
        if ($this->orm instanceof Component) {
462
            //Forwarding IoC scope to parent ORM instance
463
            return $this->orm->iocContainer();
464
        }
465
466
        return parent::iocContainer();
467
    }
468
469
    /*
470
     * Code below used to generate transaction commands.
471
     */
472
473
    /**
474
     * Change object state.
475
     *
476
     * @param int $state
477
     */
478
    private function setState(int $state)
479
    {
480
        $this->state = $state;
481
    }
482
483
    /**
484
     * @return InsertCommand
485
     */
486
    private function prepareInsert(): InsertCommand
487
    {
488
        //Entity indicates it's own status
489
        $this->setState(ORMInterface::STATE_SCHEDULED_INSERT);
490
        $this->dispatch('insert', new RecordEvent($this));
491
492
        $command = new InsertCommand(
493
            $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...
494
            $this->orm->define(static::class, ORMInterface::R_DATABASE),
495
            $this->orm->define(static::class, ORMInterface::R_TABLE)
496
        );
497
498
        //Executed when transaction successfully completed
499
        $command->onComplete(function () {
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...
500
            $this->setState(ORMInterface::STATE_LOADED);
501
            $this->flushUpdates();
502
            $this->dispatch('created', new RecordEvent($this));
503
        });
504
505
        return $command;
506
    }
507
508
    /**
509
     * @return UpdateCommand
510
     */
511
    private function prepareUpdate(): UpdateCommand
512
    {
513
        //Entity indicates it's own status
514
        $this->setState(ORMInterface::STATE_SCHEDULED_UPDATE);
515
        $this->dispatch('update', new RecordEvent($this));
516
517
        $command = new UpdateCommand(
518
            $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...
519
            $this->compileUpdates(true),
520
            $this->orm->define(static::class, ORMInterface::R_DATABASE),
521
            $this->orm->define(static::class, ORMInterface::R_TABLE)
522
        );
523
524
        //Executed when transaction successfully completed
525
        $command->onComplete(function () {
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...
526
            $this->setState(ORMInterface::STATE_LOADED);
527
            $this->flushUpdates();
528
            $this->dispatch('updated', new RecordEvent($this));
529
        });
530
531
        return $command;
532
    }
533
534
    /**
535
     * @return DeleteCommand
536
     */
537
    private function prepareDelete(): DeleteCommand
538
    {
539
        //Entity indicates it's own status
540
        $this->setState(ORMInterface::STATE_SCHEDULED_DELETE);
541
        $this->dispatch('delete', new RecordEvent($this));
542
543
        $command = new DeleteCommand(
544
            $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...
545
            $this->orm->define(static::class, ORMInterface::R_DATABASE),
546
            $this->orm->define(static::class, ORMInterface::R_TABLE)
547
        );
548
549
        //Executed when transaction successfully completed
550
        $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...
551
            $this->setState(ORMInterface::STATE_DELETED);
552
            $this->dispatch('deleted', new RecordEvent($this));
553
        });
554
555
        return $command;
556
    }
557
558
    /**
559
     * Get WHERE array to be used to perform record data update or deletion. Usually will include
560
     * record primary key.
561
     *
562
     * Usually just [ID => value] array.
563
     *
564
     * @return array
565
     */
566
    private function stateCriteria()
567
    {
568
        if (!empty($primaryKey = $this->recordSchema[self::SH_PRIMARIES])) {
569
570
            //Set of primary keys
571
            $state = [];
572
            foreach ($primaryKey as $key) {
573
                $state[$key] = $this->getField($key);
574
            }
575
576
            return $state;
577
        }
578
579
        //Use entity data as where definition
580
        return $this->changes + $this->packValue();
581
    }
582
583
    /**
584
     * Create set of fields to be sent to UPDATE statement.
585
     *
586
     * @param bool $skipPrimaries Remove primary keys from update statement.
587
     *
588
     * @return array
589
     */
590
    private function compileUpdates(bool $skipPrimaries = false): array
591
    {
592
        if (!$this->hasUpdates() && !$this->isSolid()) {
593
            return [];
594
        }
595
596
        if ($this->isSolid()) {
597
            //Solid records always saved as one chunk of data
598
            return $this->packValue();
599
        }
600
601
        $updates = [];
602
        foreach ($this->getFields(false) as $field => $value) {
603
            if (
604
                $skipPrimaries
605
                && in_array($field, $this->recordSchema[self::SH_PRIMARIES])
606
            ) {
607
                continue;
608
            }
609
610
            //Handled by sub-accessor
611
            if ($value instanceof RecordAccessorInterface) {
612
                if ($value->hasUpdates()) {
613
                    $updates[$field] = $value->compileUpdates($field);
614
                    continue;
615
                }
616
617
                $value = $value->packValue();
618
            }
619
620
            //Field change registered
621
            if (array_key_exists($field, $this->changes)) {
622
                $updates[$field] = $value;
623
            }
624
        }
625
626
        return $updates;
627
    }
628
629
    /**
630
     * Indicate that all updates done, reset dirty state.
631
     */
632
    private function flushUpdates()
633
    {
634
        $this->changes = [];
635
636
        foreach ($this->getFields(false) as $field => $value) {
637
            if ($value instanceof RecordAccessorInterface) {
638
                $value->flushUpdates();
639
            }
640
        }
641
    }
642
643
    /**
644
     * @param string $name
645
     */
646
    private function registerChange(string $name)
647
    {
648
        $original = $this->getField($name, null, false);
649
650
        if (!array_key_exists($name, $this->changes)) {
651
            //Let's keep track of how field looked before first change
652
            $this->changes[$name] = $original instanceof AccessorInterface
653
                ? $original->packValue()
654
                : $original;
655
        }
656
    }
657
658
    /**
659
     * Extract relations data from given entity fields.
660
     *
661
     * @param array $data
662
     */
663
    private function extractRelations(array &$data)
664
    {
665
        //Fetch all relations
666
        $relations = array_intersect_key($data, $this->recordSchema[self::SH_RELATIONS]);
667
668
        foreach ($relations as $name => $relation) {
669
            $this->relations[$name] = $relation;
670
            unset($data[$name]);
671
        }
672
    }
673
}