Completed
Branch feature/pre-split (211a78)
by Anton
05:08
created

RecordEntity::ormSchema()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

Changes 6
Bugs 0 Features 0
Metric Value
cc 1
eloc 2
c 6
b 0
f 0
nc 1
nop 0
dl 0
loc 4
rs 10
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 $recordState = false;
156
157
    /**
158
     * Errors in relations and accessors.
159
     *
160
     * @var array
161
     */
162
    private $innerErrors = [];
163
164
    /**
165
     * SolidState will force record data to be saved as one big update set without any generating
166
     * separate update statements for changed columns.
167
     *
168
     * @var bool
169
     */
170
    private $solidState = false;
171
172
    /**
173
     * Record field updates (changed values).
174
     *
175
     * @var array
176
     */
177
    private $updates = [];
178
179
    /**
180
     * Constructed and pre-cached set of record relations. Relation will be in a form of data array
181
     * to be created on demand.
182
     *
183
     * @see relation()
184
     * @see __call()
185
     * @see __set()
186
     * @see __get()
187
     *
188
     * @var RelationInterface[]|array
189
     */
190
    protected $relations = [];
191
192
    /**
193
     * @invisible
194
     *
195
     * @var ORMInterface|ORM
196
     */
197
    protected $orm = null;
198
199
    /**
200
     * Schema provided by ORM component.
201
     *
202
     * @var array
203
     */
204
    protected $ormSchema = [];
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->recordState = (bool)$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
     * Change record solid state. SolidState will force record data to be saved as one big update
237
     * set without any generating separate update statements for changed columns.
238
     *
239
     * Attention, you have to carefully use forceUpdate flag with records without primary keys due
240
     * update criteria (WHERE condition) can not be easy constructed for records with primary key.
241
     *
242
     * @param bool $solidState
243
     * @param bool $forceUpdate Mark all fields as changed to force update later.
244
     *
245
     * @return $this
246
     */
247
    public function solidState($solidState, $forceUpdate = false)
248
    {
249
        $this->solidState = $solidState;
250
251
        if ($forceUpdate) {
252
            if (!empty($this->ormSchema[ORMInterface::M_PRIMARY_KEY])) {
253
                $this->updates = $this->stateCriteria();
254
            } else {
255
                $this->updates = $this->ormSchema[ORMInterface::M_COLUMNS];
256
            }
257
        }
258
259
        return $this;
260
    }
261
262
    /**
263
     * Is record is solid state?
264
     *
265
     * @see solidState()
266
     *
267
     * @return bool
268
     */
269
    public function isSolid()
270
    {
271
        return $this->solidState;
272
    }
273
274
    /**
275
     * {@inheritdoc}
276
     */
277
    public function recordRole()
278
    {
279
        return $this->ormSchema[ORMInterface::M_ROLE_NAME];
280
    }
281
282
    /**
283
     * {@inheritdoc}
284
     */
285
    public function isLoaded()
286
    {
287
        return (bool)$this->recordState && !$this->isDeleted();
288
    }
289
290
    /**
291
     * {@inheritdoc}
292
     */
293
    public function isDeleted()
294
    {
295
        return $this->recordState === self::DELETED;
296
    }
297
298
    /**
299
     * {@inheritdoc}
300
     */
301
    public function primaryKey()
302
    {
303
        if (!$this->hasField(ORMInterface::M_PRIMARY_KEY)) {
304
            throw new RecordException("Record does not have determinated primary key");
305
        }
306
307
        return $this->getField('_id', null, false);
308
    }
309
310
    /**
311
     * {@inheritdoc}
312
     *
313
     * @see   $fillable
314
     * @see   $secured
315
     * @see   isFillable()
316
     *
317
     * @param array|\Traversable $fields
318
     * @param bool               $all Fill all fields including non fillable.
319
     *
320
     * @return $this
321
     *
322
     * @throws AccessorExceptionInterface
323
     */
324
    public function setFields($fields = [], $all = false)
325
    {
326
        parent::setFields($fields, $all);
327
328
        foreach ($fields as $name => $nested) {
329
330
            //We can fill data of embedded of relations (usually HAS ONE)
331
            if ($this->embeddedRelation($name) && !empty($relation = $this->relation($name))) {
332
                //Getting related object
333
                $related = $relation->getRelated();
334
335
                if ($related instanceof EntityInterface) {
336
                    $related->setFields($nested);
337
                }
338
            }
339
        }
340
341
        return $this;
342
    }
343
344
    /**
345
     * {@inheritdoc}
346
     *
347
     * Must track field updates.
348
     */
349
    public function setField($name, $value, $filter = true)
350
    {
351
        if (!$this->hasField($name)) {
352
            throw new FieldException("Undefined field '{$name}' in '" . static::class . "'");
353
        }
354
355
        //Original field value
356
        $original = $this->getField($name, null, false);
357
358
        if (is_null($value) && in_array($name, $this->ormSchema[ORM::M_NULLABLE])) {
359
            //Bypassing filter for nullable values
360
            parent::setField($name, null, false);
361
        } else {
362
            parent::setField($name, $value, $filter);
363
        }
364
365 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...
366
            $this->updates[$name] = $original instanceof AccessorInterface
367
                ? $original->serializeData()
368
                : $original;
369
        }
370
    }
371
372
    /**
373
     * {@inheritdoc}
374
     *
375
     * Record will skip filtration for nullable fields.
376
     */
377
    public function getField($name, $default = null, $filter = true)
378
    {
379
        if (!$this->hasField($name)) {
380
            throw new FieldException("Undefined field '{$name}' in '" . static::class . "'");
381
        }
382
383
        $value = parent::getField($name, $default, false);
384
        if ($value === null && in_array($name, $this->ormSchema[ORM::M_NULLABLE])) {
385
            return $value;
386
        }
387
388
        return parent::getField($name, $default, $filter);
389
    }
390
391
    /**
392
     * {@inheritdoc}
393
     *
394
     * @see relation()
395
     */
396
    public function __get($offset)
397
    {
398
        if (isset($this->ormSchema[ORMInterface::M_RELATIONS][$offset])) {
399
            //Bypassing call to relation
400
            return $this->relation($offset)->getRelated();
401
        }
402
403
        return $this->getField($offset, true);
404
    }
405
406
    /**
407
     * {@inheritdoc}
408
     *
409
     * @see relation()
410
     */
411
    public function __set($offset, $value)
412
    {
413
        if (isset($this->ormSchema[ORMInterface::M_RELATIONS][$offset])) {
414
            //Bypassing call to relation
415
            $this->relation($offset)->associate($value);
416
417
            return;
418
        }
419
420
        $this->setField($offset, $value, true);
421
    }
422
423
    /**
424
     * {@inheritdoc}
425
     *
426
     * @throws FieldException
427
     */
428
    public function __unset($offset)
429
    {
430
        throw new FieldException('Records fields can not be unsetted');
431
    }
432
433
    /**
434
     * {@inheritdoc}
435
     */
436
    public function __isset($name)
437
    {
438
        if (isset($this->ormSchema[ORMInterface::M_RELATIONS][$name])) {
439
            return !empty($this->relation($name)->getRelated());
440
        }
441
442
        return parent::__isset($name);
443
    }
444
445
    /**
446
     * Direct access to relation by it's name.
447
     *
448
     * @see relation()
449
     *
450
     * @param string $method
451
     * @param array  $arguments
452
     *
453
     * @return RelationInterface|mixed|AccessorInterface
454
     */
455
    public function __call($method, array $arguments)
456
    {
457
        if (isset($this->ormSchema[ORMInterface::M_RELATIONS][$method])) {
458
            return $this->relation($method);
459
        }
460
461
        //See FIELD_FORMAT constant
462
        return parent::__call($method, $arguments);
463
    }
464
465
    /**
466
     * Get or create record relation by it's name and pre-loaded (optional) set of data.
467
     *
468
     * @param string $name
469
     *
470
     * @return RelationInterface
471
     *
472
     * @throws RelationException
473
     * @throws RecordException
474
     */
475
    public function relation($name)
476
    {
477
        if (array_key_exists($name, $this->relations)) {
478
            if ($this->relations[$name] instanceof RelationInterface) {
479
                return $this->relations[$name];
480
            }
481
482
            //Been pre-loaded
483
            return $this->initiateRelation($name, $this->relations[$name], true);
484
        }
485
486
        //Initiating empty relation object
487
        return $this->initiateRelation($name, null, false);
488
    }
489
490
    /**
491
     * {@inheritdoc}
492
     *
493
     * @param string $field Specific field name to check for updates.
494
     */
495
    public function hasUpdates($field = null)
496
    {
497 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...
498
            if (!empty($this->updates)) {
499
                return true;
500
            }
501
502
            foreach ($this->getFields(false) as $field => $value) {
503
                if ($value instanceof RecordAccessorInterface && $value->hasUpdates()) {
504
                    return true;
505
                }
506
            }
507
508
            return false;
509
        }
510
511
        if (array_key_exists($field, $this->updates)) {
512
            return true;
513
        }
514
515
        $value = $this->getField($field);
516
        if ($value instanceof RecordAccessorInterface && $value->hasUpdates()) {
517
            return true;
518
        }
519
520
        return false;
521
    }
522
523
    /**
524
     * {@inheritdoc}
525
     */
526
    public function flushUpdates()
527
    {
528
        $this->updates = [];
529
530
        foreach ($this->getFields(false) as $value) {
531
            if ($value instanceof RecordAccessorInterface) {
532
                $value->flushUpdates();
533
            }
534
        }
535
    }
536
537
    /**
538
     * Create set of fields to be sent to UPDATE statement.
539
     *
540
     * @return array
541
     */
542
    public function compileUpdates()
543
    {
544
        if (!$this->hasUpdates() && !$this->isSolid()) {
545
            return [];
546
        }
547
548
        if ($this->isSolid()) {
549
            return $this->serializeData();
550
        }
551
552
        $updates = [];
553
        foreach ($this->getFields(false) as $field => $value) {
554
            if ($field == $this->ormSchema[ORMInterface::M_PRIMARY_KEY]) {
555
                continue;
556
            }
557
558
            if ($value instanceof RecordAccessorInterface) {
559
                if ($value->hasUpdates()) {
560
                    $updates[$field] = $value->compileUpdates($field);
561
                    continue;
562
                }
563
564
                //Will be handled as normal update if needed
565
                $value = $value->serializeData();
566
            }
567
568
            if (array_key_exists($field, $this->updates)) {
569
                $updates[$field] = $value;
570
            }
571
        }
572
573
        return $updates;
574
    }
575
576
    /**
577
     * @return array
578
     */
579
    public function __debugInfo()
580
    {
581
        $info = [
582
            'table'  => $this->ormSchema[ORMInterface::M_DB] . '/' . $this->ormSchema[ORMInterface::M_TABLE],
583
            'fields' => $this->getFields(),
584
            'errors' => $this->getErrors(),
585
        ];
586
587
        return $info;
588
    }
589
590
    /**
591
     * {@inheritdoc}
592
     */
593
    public function isValid()
594
    {
595
        return parent::isValid() && empty($this->innerErrors);
596
    }
597
598
    /**
599
     * {@inheritdoc}
600
     */
601
    public function getErrors($reset = false)
602
    {
603
        return parent::getErrors($reset) + $this->innerErrors;
604
    }
605
606
    /**
607
     * Change record loaded state.
608
     *
609
     * @param bool|mixed $state
610
     *
611
     * @return $this
612
     */
613
    protected function loadedState($state)
614
    {
615
        $this->recordState = $state;
616
617
        return $this;
618
    }
619
620
    /**
621
     * {@inheritdoc}
622
     *
623
     * Will validate every embedded relation.
624
     *
625
     * @param bool $reset
626
     *
627
     * @throws RecordException
628
     */
629 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...
630
    {
631
        $this->innerErrors = [];
632
633
        foreach ($this->relations as $name => $relation) {
634
            if (!$relation instanceof ValidatesInterface) {
635
                //Never constructed
636
                continue;
637
            }
638
639
            if ($this->embeddedRelation($name) && !$relation->isValid()) {
640
                $this->innerErrors[$name] = $relation->getErrors($reset);
641
            }
642
        }
643
644
        parent::validate($reset);
645
646
        return $this->hasErrors() && empty($this->innerErrors);
647
    }
648
649
    /**
650
     * Get WHERE array to be used to perform record data update or deletion. Usually will include
651
     * record primary key.
652
     *
653
     * @return array
654
     */
655
    protected function stateCriteria()
656
    {
657
        if (!empty($primaryKey = $this->ormSchema[ORMInterface::M_PRIMARY_KEY])) {
658
            return [$primaryKey => $this->primaryKey()];
659
        }
660
661
        //We have to serialize record data
662
        return $this->updates + $this->serializeData();
663
    }
664
665
    /**
666
     * {@inheritdoc}
667
     */
668
    protected function container()
669
    {
670
        if (empty($this->orm) || !$this->orm instanceof Component) {
671
            return parent::container();
672
        }
673
674
        return $this->orm->container();
675
    }
676
677
    /**
678
     * Check if relation is embedded.
679
     *
680
     * @internal
681
     *
682
     * @param string $relation
683
     *
684
     * @return bool
685
     */
686
    protected function embeddedRelation($relation)
687
    {
688
        return !empty($this->ormSchema[ORMInterface::M_RELATIONS][$relation][ORMInterface::R_DEFINITION][self::EMBEDDED_RELATION]);
689
    }
690
691
    /**
692
     * @param array $data
693
     */
694
    private function extractRelations(array &$data)
695
    {
696
        $relations = array_intersect_key($data, $this->ormSchema[ORMInterface::M_RELATIONS]);
697
698
        foreach ($relations as $name => $relation) {
699
            $this->relations[$name] = $relation;
700
            unset($data[$name]);
701
        }
702
    }
703
704
    /**
705
     * @param string     $name
706
     * @param array|null $data
707
     * @param bool       $loaded
708
     * @return RelationInterface|void
709
     */
710
    private function initiateRelation($name, $data, $loaded)
711
    {
712
        if (!isset($this->ormSchema[ORMInterface::M_RELATIONS][$name])) {
713
            throw new RecordException(
714
                "Undefined relation {$name} in record " . static::class . '.'
715
            );
716
        }
717
718
        $relation = $this->ormSchema[ORMInterface::M_RELATIONS][$name];
719
720
        return $this->relations[$name] = $this->orm->relation(
0 ignored issues
show
Bug introduced by
The method relation does only exist in Spiral\ORM\ORM, but not in Spiral\ORM\ORMInterface.

It seems like the method you are trying to call exists only in some of the possible types.

Let’s take a look at an example:

class A
{
    public function foo() { }
}

class B extends A
{
    public function bar() { }
}

/**
 * @param A|B $x
 */
function someFunction($x)
{
    $x->foo(); // This call is fine as the method exists in A and B.
    $x->bar(); // This method only exists in B and might cause an error.
}

Available Fixes

  1. Add an additional type-check:

    /**
     * @param A|B $x
     */
    function someFunction($x)
    {
        $x->foo();
    
        if ($x instanceof B) {
            $x->bar();
        }
    }
    
  2. Only allow a single type to be passed if the variable comes from a parameter:

    function someFunction(B $x) { /** ... */ }
    
Loading history...
721
            $relation[ORMInterface::R_TYPE],
722
            $this,
723
            $relation[ORMInterface::R_DEFINITION],
724
            $data,
725
            $loaded
726
        );
727
    }
728
729
    /**
730
     * {@inheritdoc}
731
     *
732
     * @see   Component::staticContainer()
733
     *
734
     * @param array $fields Record fields to set, will be passed thought filters.
735
     * @param ORM   $orm    ORM component, global container will be called if not instance provided.
736
     * @event created()
737
     */
738 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...
739
    {
740
        /**
741
         * @var RecordEntity $record
742
         */
743
        $record = new static([], false, $orm);
744
745
        //Forcing validation (empty set of fields is not valid set of fields)
746
        $record->setFields($fields)->dispatch('created', new EntityEvent($record));
747
748
        return $record;
749
    }
750
}