Completed
Branch feature/pre-split (d91fae)
by Anton
05:19
created

RecordEntity::initiateRelation()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 18
Code Lines 11

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 2
eloc 11
c 1
b 0
f 0
nc 2
nop 3
dl 0
loc 18
rs 9.4285
1
<?php
2
/**
3
 * Spiral Framework.
4
 *
5
 * @license   MIT
6
 * @author    Anton Titov (Wolfy-J)
7
 */
8
namespace Spiral\ORM;
9
10
use Spiral\Core\Component;
11
use Spiral\Core\Exceptions\SugarException;
12
use Spiral\Core\Traits\SaturateTrait;
13
use Spiral\Models\AccessorInterface;
14
use Spiral\Models\EntityInterface;
15
use Spiral\Models\Events\EntityEvent;
16
use Spiral\Models\Exceptions\AccessorExceptionInterface;
17
use Spiral\Models\SchematicEntity;
18
use Spiral\ORM\Exceptions\FieldException;
19
use Spiral\ORM\Exceptions\RecordException;
20
use Spiral\ORM\Exceptions\RelationException;
21
use Spiral\Validation\ValidatesInterface;
22
23
/**
24
 * RecordEntity is base data entity for ORM component, it used to describe related table schema,
25
 * filters, validations and relations to other records. You can count Record class as ActiveRecord
26
 * pattern. ORM component will automatically analyze existed Records and create cached version of
27
 * their schema.
28
 *
29
 * Configuration properties:
30
 * - schema
31
 * - defaults
32
 * - secured (* by default)
33
 * - fillable
34
 * - validates
35
 * - database
36
 * - table
37
 * - indexes
38
 *
39
 * @todo: Add ability to set primary key manually, for example fpr uuid like fields.
40
 */
41
class RecordEntity extends SchematicEntity implements RecordInterface
42
{
43
    use SaturateTrait;
44
45
    /**
46
     * We are going to inherit parent validation rules, this will let spiral translator know about
47
     * it and merge i18n messages.
48
     *
49
     * @see TranslatorTrait
50
     */
51
    const I18N_INHERIT_MESSAGES = true;
52
53
    /**
54
     * Field format declares how entity must process magic setters and getters. Available values:
55
     * camelCase, tableize.
56
     */
57
    const FIELD_FORMAT = 'tableize';
58
59
    /**
60
     * ORM records are be divided by two sections: active and passive records. When record is active
61
     * ORM allowed to modify associated record table using declared schema and created relations.
62
     *
63
     * Passive records (ACTIVE_SCHEMA = false) however can only read table schema from database and
64
     * forbidden to do any schema modification either by record or by relations.
65
     *
66
     * You can use ACTIVE_SCHEMA = false in cases where you need to create an ActiveRecord for
67
     * existed table.
68
     *
69
     * @see RecordSchema
70
     * @see \Spiral\ORM\Entities\SchemaBuilder
71
     */
72
    const ACTIVE_SCHEMA = true;
73
74
    /**
75
     * Indication that record were deleted.
76
     */
77
    const DELETED = 900;
78
79
    /**
80
     * Default ORM relation types, see ORM configuration and documentation for more information,
81
     * i had to remove 200 lines of comments to make record little bit smaller.
82
     *
83
     * @see RelationSchemaInterface
84
     * @see RelationSchema
85
     */
86
    const HAS_ONE      = 101;
87
    const HAS_MANY     = 102;
88
    const BELONGS_TO   = 103;
89
    const MANY_TO_MANY = 104;
90
91
    /**
92
     * Morphed relation types are usually created by inversion or equivalent of primary relation
93
     * types.
94
     *
95
     * @see RelationSchemaInterface
96
     * @see RelationSchema
97
     * @see MorphedRelation
98
     */
99
    const BELONGS_TO_MORPHED = 108;
100
    const MANY_TO_MORPHED    = 109;
101
102
    /**
103
     * Constants used to declare relations in record schema, used in normalized relation schema.
104
     *
105
     * @see RelationSchemaInterface
106
     */
107
    const OUTER_KEY         = 901; //Outer key name
108
    const INNER_KEY         = 902; //Inner key name
109
    const MORPH_KEY         = 903; //Morph key name
110
    const PIVOT_TABLE       = 904; //Pivot table name
111
    const PIVOT_COLUMNS     = 905; //Pre-defined pivot table columns
112
    const PIVOT_DEFAULTS    = 906; //Pre-defined pivot table default values
113
    const THOUGHT_INNER_KEY = 907; //Pivot table options
114
    const THOUGHT_OUTER_KEY = 908; //Pivot table options
115
    const WHERE             = 909; //Where conditions
116
    const WHERE_PIVOT       = 910; //Where pivot conditions
117
118
    /**
119
     * Additional constants used to control relation schema behaviour.
120
     *
121
     * @see Record::$schema
122
     * @see RelationSchemaInterface
123
     */
124
    const INVERSE           = 1001; //Relation should be inverted to parent record
125
    const CONSTRAINT        = 1002; //Relation should create foreign keys (default)
126
    const CONSTRAINT_ACTION = 1003; //Default relation foreign key delete/update action (CASCADE)
127
    const CREATE_PIVOT      = 1004; //Many-to-Many should create pivot table automatically (default)
128
    const NULLABLE          = 1005; //Relation can be nullable (default)
129
    const CREATE_INDEXES    = 1006; //Indication that relation is allowed to create required indexes
130
    const MORPHED_ALIASES   = 1007; //Aliases for morphed sub-relations
131
132
    /**
133
     * Relations marked as embedded will be automatically saved/validated with parent model. In
134
     * addition such models data can be set using setFields method (only for ONE relations).
135
     *
136
     * @see setFields()
137
     * @see save()
138
     * @see validate()
139
     */
140
    const EMBEDDED_RELATION = 1008;
141
142
    /**
143
     * Constants used to declare indexes in record schema.
144
     *
145
     * @see Record::$indexes
146
     */
147
    const INDEX  = 1000;            //Default index type
148
    const UNIQUE = 2000;            //Unique index definition
149
150
    /**
151
     * Indicates that record data were loaded from database (not recently created).
152
     *
153
     * @var bool
154
     */
155
    private $loaded = false;
156
157
    /**
158
     * Schema provided by ORM component.
159
     *
160
     * @var array
161
     */
162
    private $ormSchema = [];
163
164
    /**
165
     * Errors in relations and accessors.
166
     *
167
     * @var array
168
     */
169
    private $nestedErrors = [];
170
171
    /**
172
     * SolidState will force record data to be saved as one big update set without any generating
173
     * separate update statements for changed columns.
174
     *
175
     * @var bool
176
     */
177
    private $solidState = false;
178
179
    /**
180
     * Record field updates (changed values).
181
     *
182
     * @var array
183
     */
184
    private $updates = [];
185
186
    /**
187
     * Constructed and pre-cached set of record relations. Relation will be in a form of data array
188
     * to be created on demand.
189
     *
190
     * @see relation()
191
     * @see __call()
192
     * @see __set()
193
     * @see __get()
194
     *
195
     * @var RelationInterface[]|array
196
     */
197
    protected $relations = [];
198
199
    /**
200
     * @invisible
201
     *
202
     * @var ORMInterface|ORM
203
     */
204
    protected $orm = null;
205
206
    /**
207
     * {@inheritdoc}
208
     *
209
     * @param null|array $schema
210
     *
211
     * @throws SugarException
212
     */
213
    public function __construct(
214
        array $data = [],
215
        $loaded = false,
216
        ORMInterface $orm = null,
217
        array $schema = []
218
    ) {
219
        $this->loaded = $loaded;
220
221
        //We can use global container as fallback if no default values were provided
222
        $this->orm = $this->saturate($orm, ORMInterface::class);
223
        $this->ormSchema = !empty($schema) ? $schema : $this->orm->schema(static::class);
224
225
        $this->extractRelations($data);
226
227
        if (!$this->isLoaded()) {
228
            //Non loaded records should be in solid state by default and require initial validation
229
            $this->solidState(true)->invalidate();
230
        }
231
232
        parent::__construct($data + $this->ormSchema[ORMInterface::M_COLUMNS], $this->ormSchema);
233
234
    }
235
236
    /**
237
     * Change record solid state. SolidState will force record data to be saved as one big update
238
     * set without any generating separate update statements for changed columns.
239
     *
240
     * Attention, you have to carefully use forceUpdate flag with records without primary keys due
241
     * update criteria (WHERE condition) can not be easy constructed for records with primary key.
242
     *
243
     * @param bool $solidState
244
     * @param bool $forceUpdate Mark all fields as changed to force update later.
245
     *
246
     * @return $this
247
     */
248
    public function solidState($solidState, $forceUpdate = false)
249
    {
250
        $this->solidState = $solidState;
251
252
        if ($forceUpdate) {
253
            if (!empty($this->ormSchema[ORMInterface::M_PRIMARY_KEY])) {
254
                $this->updates = $this->stateCriteria();
255
            } else {
256
                $this->updates = $this->ormSchema[ORMInterface::M_COLUMNS];
257
            }
258
        }
259
260
        return $this;
261
    }
262
263
    /**
264
     * Is record is solid state?
265
     *
266
     * @see solidState()
267
     *
268
     * @return bool
269
     */
270
    public function isSolid()
271
    {
272
        return $this->solidState;
273
    }
274
275
    /**
276
     * {@inheritdoc}
277
     */
278
    public function recordRole()
279
    {
280
        return $this->ormSchema[ORMInterface::M_ROLE_NAME];
281
    }
282
283
    /**
284
     * {@inheritdoc}
285
     */
286
    public function isLoaded()
287
    {
288
        return (bool)$this->loaded && !$this->isDeleted();
289
    }
290
291
    /**
292
     * {@inheritdoc}
293
     */
294
    public function isDeleted()
295
    {
296
        return $this->loaded === self::DELETED;
297
    }
298
299
    /**
300
     * {@inheritdoc}
301
     */
302
    public function primaryKey()
303
    {
304
        if (!$this->hasField(ORMInterface::M_PRIMARY_KEY)) {
305
            throw new RecordException("Record does not have determinated primary key");
306
        }
307
308
        return $this->getField('_id', null, false);
309
    }
310
311
    /**
312
     * {@inheritdoc}
313
     *
314
     * @see   $fillable
315
     * @see   $secured
316
     * @see   isFillable()
317
     *
318
     * @param array|\Traversable $fields
319
     * @param bool               $all Fill all fields including non fillable.
320
     *
321
     * @return $this
322
     *
323
     * @throws AccessorExceptionInterface
324
     */
325
    public function setFields($fields = [], $all = false)
326
    {
327
        parent::setFields($fields, $all);
328
329
        foreach ($fields as $name => $nested) {
330
331
            //We can fill data of embedded of relations (usually HAS ONE)
332
            if ($this->embeddedRelation($name) && !empty($relation = $this->relation($name))) {
333
                //Getting related object
334
                $related = $relation->getRelated();
335
336
                if ($related instanceof EntityInterface) {
337
                    $related->setFields($nested);
338
                }
339
            }
340
        }
341
342
        return $this;
343
    }
344
345
    /**
346
     * {@inheritdoc}
347
     *
348
     * Must track field updates.
349
     */
350
    public function setField($name, $value, $filter = true)
351
    {
352
        if (!$this->hasField($name)) {
353
            throw new FieldException("Undefined field '{$name}' in '" . static::class . "'");
354
        }
355
356
        //Original field value
357
        $original = $this->getField($name, null, false);
358
359
        if (is_null($value) && in_array($name, $this->ormSchema[ORM::M_NULLABLE])) {
360
            //Bypassing filter for nullable values
361
            parent::setField($name, null, false);
362
        } else {
363
            parent::setField($name, $value, $filter);
364
        }
365
366 View Code Duplication
        if (!array_key_exists($name, $this->updates)) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
367
            $this->updates[$name] = $original instanceof AccessorInterface
368
                ? $original->serializeData()
369
                : $original;
370
        }
371
    }
372
373
    /**
374
     * {@inheritdoc}
375
     *
376
     * Record will skip filtration for nullable fields.
377
     */
378
    public function getField($name, $default = null, $filter = true)
379
    {
380
        if (!$this->hasField($name)) {
381
            throw new FieldException("Undefined field '{$name}' in '" . static::class . "'");
382
        }
383
384
        $value = parent::getField($name, $default, false);
385
        if ($value === null && in_array($name, $this->ormSchema[ORM::M_NULLABLE])) {
386
            if (!isset($this->ormSchema[ORMInterface::M_MUTATORS][self::MUTATOR_ACCESSOR][$name])) {
387
                //We can skip setters for null values, but not accessors
388
                return $value;
389
            }
390
        }
391
392
        return parent::getField($name, $default, $filter);
393
    }
394
395
    /**
396
     * {@inheritdoc}
397
     *
398
     * @see relation()
399
     */
400
    public function __get($offset)
401
    {
402
        if (isset($this->ormSchema[ORMInterface::M_RELATIONS][$offset])) {
403
            //Bypassing call to relation
404
            return $this->relation($offset)->getRelated();
405
        }
406
407
        return $this->getField($offset, true);
408
    }
409
410
    /**
411
     * {@inheritdoc}
412
     *
413
     * @see relation()
414
     */
415
    public function __set($offset, $value)
416
    {
417
        if (isset($this->ormSchema[ORMInterface::M_RELATIONS][$offset])) {
418
            //Bypassing call to relation
419
            $this->relation($offset)->associate($value);
420
421
            return;
422
        }
423
424
        $this->setField($offset, $value, true);
425
    }
426
427
    /**
428
     * {@inheritdoc}
429
     *
430
     * @throws FieldException
431
     */
432
    public function __unset($offset)
433
    {
434
        throw new FieldException('Records fields can not be unsetted');
435
    }
436
437
    /**
438
     * {@inheritdoc}
439
     */
440
    public function __isset($name)
441
    {
442
        if (isset($this->ormSchema[ORMInterface::M_RELATIONS][$name])) {
443
            return !empty($this->relation($name)->getRelated());
444
        }
445
446
        return parent::__isset($name);
447
    }
448
449
    /**
450
     * Direct access to relation by it's name.
451
     *
452
     * @see relation()
453
     *
454
     * @param string $method
455
     * @param array  $arguments
456
     *
457
     * @return RelationInterface|mixed|AccessorInterface
458
     */
459
    public function __call($method, array $arguments)
460
    {
461
        if (isset($this->ormSchema[ORMInterface::M_RELATIONS][$method])) {
462
            return $this->relation($method);
463
        }
464
465
        //See FIELD_FORMAT constant
466
        return parent::__call($method, $arguments);
467
    }
468
469
    /**
470
     * Get or create record relation by it's name and pre-loaded (optional) set of data.
471
     *
472
     * @param string $name
473
     *
474
     * @return RelationInterface
475
     *
476
     * @throws RelationException
477
     * @throws RecordException
478
     */
479
    public function relation($name)
480
    {
481
        if (array_key_exists($name, $this->relations)) {
482
            if ($this->relations[$name] instanceof RelationInterface) {
483
                return $this->relations[$name];
484
            }
485
486
            //Been preloaded
487
            return $this->initiateRelation($name, $this->relations[$name], true);
488
        }
489
490
        //Initiating empty relation object
491
        return $this->initiateRelation($name, null, false);
492
    }
493
494
    /**
495
     * {@inheritdoc}
496
     *
497
     * @param string $field Specific field name to check for updates.
498
     */
499
    public function hasUpdates($field = null)
500
    {
501 View Code Duplication
        if (empty($field)) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
502
            if (!empty($this->updates)) {
503
                return true;
504
            }
505
506
            foreach ($this->getFields(false) as $field => $value) {
507
                if ($value instanceof RecordAccessorInterface && $value->hasUpdates()) {
508
                    return true;
509
                }
510
            }
511
512
            return false;
513
        }
514
515
        if (array_key_exists($field, $this->updates)) {
516
            return true;
517
        }
518
519
        $value = $this->getField($field);
520
        if ($value instanceof RecordAccessorInterface && $value->hasUpdates()) {
521
            return true;
522
        }
523
524
        return false;
525
    }
526
527
    /**
528
     * {@inheritdoc}
529
     */
530
    public function flushUpdates()
531
    {
532
        $this->updates = [];
533
534
        foreach ($this->getFields(false) as $value) {
535
            if ($value instanceof RecordAccessorInterface) {
536
                $value->flushUpdates();
537
            }
538
        }
539
    }
540
541
    /**
542
     * Create set of fields to be sent to UPDATE statement.
543
     *
544
     * @return array
545
     */
546
    public function compileUpdates()
547
    {
548
        if (!$this->hasUpdates() && !$this->isSolid()) {
549
            return [];
550
        }
551
552
        if ($this->isSolid()) {
553
            return $this->serializeData();
554
        }
555
556
        $updates = [];
557
        foreach ($this->getFields(false) as $field => $value) {
558
            if ($field == $this->ormSchema[ORMInterface::M_PRIMARY_KEY]) {
559
                continue;
560
            }
561
562
            if ($value instanceof RecordAccessorInterface) {
563
                if ($value->hasUpdates()) {
564
                    $updates[$field] = $value->compileUpdates($field);
565
                    continue;
566
                }
567
568
                //Will be handled as normal update if needed
569
                $value = $value->serializeData();
570
            }
571
572
            if (array_key_exists($field, $this->updates)) {
573
                $updates[$field] = $value;
574
            }
575
        }
576
577
        return $updates;
578
    }
579
580
    /**
581
     * @return array
582
     */
583
    public function __debugInfo()
584
    {
585
        $info = [
586
            'table'  => $this->ormSchema[ORMInterface::M_DB] . '/' . $this->ormSchema[ORMInterface::M_TABLE],
587
            'fields' => $this->getFields(),
588
            'errors' => $this->getErrors(),
589
        ];
590
591
        return $info;
592
    }
593
594
    /**
595
     * {@inheritdoc}
596
     */
597
    public function isValid()
598
    {
599
        return parent::isValid() && empty($this->nestedErrors);
600
    }
601
602
    /**
603
     * {@inheritdoc}
604
     */
605
    public function getErrors($reset = false)
606
    {
607
        return parent::getErrors($reset) + $this->nestedErrors;
608
    }
609
610
    /**
611
     * Change record loaded state.
612
     *
613
     * @param bool|mixed $state
614
     *
615
     * @return $this
616
     */
617
    protected function loadedState($state)
618
    {
619
        $this->loaded = $state;
620
621
        return $this;
622
    }
623
624
    /**
625
     * {@inheritdoc}
626
     *
627
     * Will validate every embedded relation.
628
     *
629
     * @param bool $reset
630
     *
631
     * @throws RecordException
632
     */
633 View Code Duplication
    protected function validate($reset = false)
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
634
    {
635
        $this->nestedErrors = [];
636
637
        foreach ($this->relations as $name => $relation) {
638
            if (!$relation instanceof ValidatesInterface) {
639
                //Never constructed
640
                continue;
641
            }
642
643
            if ($this->embeddedRelation($name) && !$relation->isValid()) {
644
                $this->nestedErrors[$name] = $relation->getErrors($reset);
645
            }
646
        }
647
648
        parent::validate($reset);
649
650
        return $this->hasErrors() && empty($this->nestedErrors);
651
    }
652
653
    /**
654
     * Get WHERE array to be used to perform record data update or deletion. Usually will include
655
     * record primary key.
656
     *
657
     * @return array
658
     */
659
    protected function stateCriteria()
660
    {
661
        if (!empty($primaryKey = $this->ormSchema[ORMInterface::M_PRIMARY_KEY])) {
662
            return [$primaryKey => $this->primaryKey()];
663
        }
664
665
        //We have to serialize record data
666
        return $this->updates + $this->serializeData();
667
    }
668
669
    /**
670
     * {@inheritdoc}
671
     */
672
    protected function container()
673
    {
674
        if (empty($this->orm) || !$this->orm instanceof Component) {
675
            return parent::container();
676
        }
677
678
        return $this->orm->container();
679
    }
680
681
    /**
682
     * Related and cached ORM schema.
683
     *
684
     * @internal
685
     *
686
     * @return array
687
     */
688
    protected function ormSchema()
689
    {
690
        return $this->ormSchema;
691
    }
692
693
    /**
694
     * Check if relation is embedded.
695
     *
696
     * @internal
697
     *
698
     * @param string $relation
699
     *
700
     * @return bool
701
     */
702
    protected function embeddedRelation($relation)
703
    {
704
        return !empty($this->ormSchema[ORMInterface::M_RELATIONS][$relation][ORMInterface::R_DEFINITION][self::EMBEDDED_RELATION]);
705
    }
706
707
    /**
708
     * @param array $data
709
     */
710
    private function extractRelations(array &$data)
711
    {
712
        $relations = array_intersect_key($data, $this->ormSchema[ORMInterface::M_RELATIONS]);
713
714
        foreach ($relations as $name => $relation) {
715
            $this->relations[$name] = $relation;
716
            unset($data[$name]);
717
        }
718
    }
719
720
    /**
721
     * @param string     $name
722
     * @param array|null $data
723
     * @param bool       $loaded
724
     * @return RelationInterface|void
725
     */
726
    private function initiateRelation($name, $data, $loaded)
727
    {
728
        if (!isset($this->ormSchema[ORMInterface::M_RELATIONS][$name])) {
729
            throw new RecordException(
730
                "Undefined relation {$name} in record " . static::class . '.'
731
            );
732
        }
733
734
        $relation = $this->ormSchema[ORMInterface::M_RELATIONS][$name];
735
736
        return $this->relations[$name] = $this->orm->relation(
737
            $relation[ORMInterface::R_TYPE],
738
            $this,
739
            $relation[ORMInterface::R_DEFINITION],
740
            $data,
741
            $loaded
742
        );
743
    }
744
745
    /**
746
     * {@inheritdoc}
747
     *
748
     * @see   Component::staticContainer()
749
     *
750
     * @param array $fields Record fields to set, will be passed thought filters.
751
     * @param ORM   $orm    ORM component, global container will be called if not instance provided.
752
     * @event created()
753
     */
754 View Code Duplication
    public static function create($fields = [], ORMInterface $orm = null)
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
755
    {
756
        /**
757
         * @var RecordEntity $record
758
         */
759
        $record = new static([], false, $orm);
760
761
        //Forcing validation (empty set of fields is not valid set of fields)
762
        $record->setFields($fields)->dispatch('created', new EntityEvent($record));
763
764
        return $record;
765
    }
766
}