Completed
Branch develop (85a9c8)
by Anton
05:44
created

Relation::isLoadable()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
c 1
b 0
f 0
dl 0
loc 4
rs 10
cc 1
eloc 2
nc 1
nop 0
1
<?php
2
/**
3
 * Spiral Framework.
4
 *
5
 * @license   MIT
6
 * @author    Anton Titov (Wolfy-J)
7
 */
8
namespace Spiral\ORM\Entities;
9
10
use Spiral\Core\Component;
11
use Spiral\Models\ActiveEntityInterface;
12
use Spiral\Models\EntityInterface;
13
use Spiral\Models\IdentifiedInterface;
14
use Spiral\ORM\Entities\Traits\AliasTrait;
15
use Spiral\ORM\Exceptions\RelationException;
16
use Spiral\ORM\ORM;
17
use Spiral\ORM\RecordEntity;
18
use Spiral\ORM\RecordInterface;
19
use Spiral\ORM\RelationInterface;
20
21
/**
22
 * Abstract implementation of ORM Relations, provides access to associated instances, use ORM entity
23
 * cache and record iterators. In additional, relation can be serialized into json, or iterated when
24
 * needed.
25
 *
26
 * This abstract implement built to work with ORM Record classes.
27
 */
28
abstract class Relation extends Component implements
29
    RelationInterface,
30
    \Countable,
31
    \IteratorAggregate,
32
    \JsonSerializable
33
{
34
    /**
35
     * {@} table aliases.
36
     */
37
    use AliasTrait;
38
39
    /**
40
     * Relation type, required to fetch record class from relation definition.
41
     */
42
    const RELATION_TYPE = null;
43
44
    /**
45
     * Indication that relation represent multiple records (HAS_MANY relations).
46
     */
47
    const MULTIPLE = false;
48
49
    /**
50
     * Indication that relation data has been loaded from databases.
51
     *
52
     * @var bool
53
     */
54
    protected $loaded = false;
55
56
    /**
57
     * Pre-loaded relation data, can be loaded while parent record, or later. Real data instance
58
     * will be constructed on demand and will keep it pre-loaded context between calls.
59
     *
60
     * @see Record::setContext()
61
     * @var array|null
62
     */
63
    protected $data = [];
64
65
    /**
66
     * Instance of constructed EntityInterface of RecordIterator.
67
     *
68
     * @invisible
69
     * @var mixed|EntityInterface|RecordIterator
70
     */
71
    protected $instance = null;
72
73
    /**
74
     * Parent Record caused relation to be created.
75
     *
76
     * @var RecordInterface
77
     */
78
    protected $parent = null;
79
80
    /**
81
     * Relation definition fetched from ORM schema. Must already be normalized by RelationSchema.
82
     *
83
     * @invisible
84
     * @var array
85
     */
86
    protected $definition = [];
87
88
    /**
89
     * @invisible
90
     * @var ORM
91
     */
92
    protected $orm = null;
93
94
    /**
95
     * @param ORM             $orm
96
     * @param RecordInterface $parent
97
     * @param array           $definition Relation definition, must be normalized by relation
98
     *                                    schema.
99
     * @param mixed           $data       Pre-loaded relation data.
100
     * @param bool            $loaded     Indication that relation data has been loaded.
101
     */
102
    public function __construct(
103
        ORM $orm,
104
        RecordInterface $parent,
105
        array $definition,
106
        $data = null,
107
        $loaded = false
108
    ) {
109
        $this->orm = $orm;
110
        $this->parent = $parent;
111
        $this->definition = $definition;
112
        $this->data = $data;
113
        $this->loaded = $loaded;
114
    }
115
116
    /**
117
     * {@inheritdoc}
118
     */
119
    public function isLoaded()
120
    {
121
        return $this->loaded;
122
    }
123
124
    /**
125
     * {@inheritdoc}
126
     *
127
     * Relation will automatically create related record if relation is not nullable. Usually
128
     * applied for has one relations ($user->profile).
129
     */
130
    public function getRelated()
131
    {
132
        if (!empty($this->instance)) {
133
            //RecordIterator will update context automatically
134
            return $this->instance;
135
        }
136
137
        if (!$this->isLoaded()) {
138
            //Loading data if not already loaded
139
            $this->loadData();
140
        }
141
142
        if (empty($this->data)) {
143
            if (
144
                array_key_exists(RecordEntity::NULLABLE, $this->definition)
145
                && !$this->definition[RecordEntity::NULLABLE]
146
                && !static::MULTIPLE
147
            ) {
148
                //Not nullable relations must always return requested instance
149
                return $this->instance = $this->emptyRecord();
150
            }
151
152
            //Can not be loaded, let's use empty iterator
153
            return static::MULTIPLE ? $this->createIterator() : null;
154
        }
155
156
        if (static::MULTIPLE) {
157
            $instance = $this->createIterator();
158
        } else {
159
            $instance = $this->createRecord();
160
        }
161
162
        return $this->instance = $instance;
163
    }
164
165
    /**
166
     * {@inheritdoc}
167
     */
168
    public function associate(EntityInterface $related = null)
169
    {
170
        if (static::MULTIPLE) {
171
            throw new RelationException(
172
                "Unable to associate relation data (relation represent multiple records)."
173
            );
174
        }
175
176
        //Simplification for morphed relations
177
        if (!is_array($allowed = $this->definition[static::RELATION_TYPE])) {
178
            $allowed = [$allowed];
179
        }
180
181
        if (!is_object($related) || !in_array(get_class($related), $allowed)) {
182
            $allowed = join("', '", $allowed);
183
184
            throw new RelationException(
185
                "Only instances of '{$allowed}' can be assigned to this relation."
186
            );
187
        }
188
189
        //Entity caching
190
        $this->instance = $related;
191
        $this->loaded = true;
192
        $this->data = [];
193
    }
194
195
    /**
196
     * {@inheritdoc}
197
     */
198
    public function saveAssociation($validate = true)
199
    {
200
        if (empty($instance = $this->getRelated())) {
201
            //Nothing to save
202
            return true;
203
        }
204
205
        if (static::MULTIPLE) {
206
            /**
207
             * @var RecordIterator|EntityInterface[] $instance
208
             */
209
            foreach ($instance as $record) {
210
                if (!$this->saveEntity($record, $validate)) {
211
                    return false;
212
                }
213
            }
214
215
            return true;
216
        }
217
218
        return $this->saveEntity($instance, $validate);
219
    }
220
221
    /**
222
     * {@inheritdoc}
223
     */
224
    public function reset(array $data = [], $loaded = false)
225
    {
226
        if ($loaded && !empty($this->data) && $this->data == $data) {
227
            //Nothing to do, context is the same
228
            return;
229
        }
230
231
        if (!$loaded || !($this->instance instanceof EntityInterface)) {
232
            //Flushing instance
233
            $this->instance = null;
234
        }
235
236
        $this->data = $data;
237
        $this->loaded = $loaded;
238
    }
239
240
    /**
241
     * {@inheritdoc}
242
     */
243
    public function isValid()
244
    {
245
        $related = $this->getRelated();
246
        if (!static::MULTIPLE) {
247
            if ($related instanceof EntityInterface) {
248
                return $related->isValid();
249
            }
250
251
            return true;
252
        }
253
254
        /**
255
         * @var RecordIterator|EntityInterface[] $data
256
         */
257
        $hasErrors = false;
258
        foreach ($related as $entity) {
0 ignored issues
show
Bug introduced by
The expression $related of type null|object<Spiral\Models\EntityInterface> is not guaranteed to be traversable. How about adding an additional type check?

There are different options of fixing this problem.

  1. If you want to be on the safe side, you can add an additional type-check:

    $collection = json_decode($data, true);
    if ( ! is_array($collection)) {
        throw new \RuntimeException('$collection must be an array.');
    }
    
    foreach ($collection as $item) { /** ... */ }
    
  2. If you are sure that the expression is traversable, you might want to add a doc comment cast to improve IDE auto-completion and static analysis:

    /** @var array $collection */
    $collection = json_decode($data, true);
    
    foreach ($collection as $item) { /** .. */ }
    
  3. Mark the issue as a false-positive: Just hover the remove button, in the top-right corner of this issue for more options.

Loading history...
259
            if (!$entity->isValid()) {
260
                $hasErrors = true;
261
            }
262
        }
263
264
        return !$hasErrors;
265
    }
266
267
    /**
268
     * {@inheritdoc}
269
     */
270
    public function hasErrors()
271
    {
272
        return !$this->isValid();
273
    }
274
275
    /**
276
     * List of errors associated with parent field, every field must have only one error assigned.
277
     *
278
     * @param bool $reset Clean errors after receiving every message.
279
     * @return array
280
     */
281
    public function getErrors($reset = false)
282
    {
283
        $related = $this->getRelated();
284
285
        if (!static::MULTIPLE) {
286
            if ($related instanceof EntityInterface) {
287
                return $related->getErrors($reset);
288
            }
289
290
            return [];
291
        }
292
293
        /**
294
         * @var RecordIterator|EntityInterface[] $data
295
         */
296
        $errors = [];
297
        foreach ($related as $position => $record) {
0 ignored issues
show
Bug introduced by
The expression $related of type null|object<Spiral\Models\EntityInterface> is not guaranteed to be traversable. How about adding an additional type check?

There are different options of fixing this problem.

  1. If you want to be on the safe side, you can add an additional type-check:

    $collection = json_decode($data, true);
    if ( ! is_array($collection)) {
        throw new \RuntimeException('$collection must be an array.');
    }
    
    foreach ($collection as $item) { /** ... */ }
    
  2. If you are sure that the expression is traversable, you might want to add a doc comment cast to improve IDE auto-completion and static analysis:

    /** @var array $collection */
    $collection = json_decode($data, true);
    
    foreach ($collection as $item) { /** .. */ }
    
  3. Mark the issue as a false-positive: Just hover the remove button, in the top-right corner of this issue for more options.

Loading history...
298
            if (!$record->isValid()) {
299
                $errors[$position] = $record->getErrors($reset);
300
            }
301
        }
302
303
        return !empty($errors);
304
    }
305
306
    /**
307
     * Get selector associated with relation.
308
     *
309
     * @param array $where
310
     * @return RecordSelector
311
     */
312
    public function find(array $where = [])
313
    {
314
        return $this->createSelector()->where($where);
315
    }
316
317
    /**
318
     * {@inheritdoc}
319
     *
320
     * Use getRelation() method to count pre-loaded data.
321
     *
322
     * @return int
323
     */
324
    public function count()
325
    {
326
        return $this->createSelector()->count();
327
    }
328
329
    /**
330
     * Perform iterator on pre-loaded data. Use relation selector to iterate thought custom relation
331
     * query.
332
     *
333
     * @return RecordEntity|RecordEntity[]|RecordIterator
334
     */
335
    public function getIterator()
336
    {
337
        return $this->getRelated();
338
    }
339
340
    /**
341
     * Bypassing call to created selector.
342
     *
343
     * @param string $method
344
     * @param array  $arguments
345
     * @return mixed
346
     */
347
    public function __call($method, array $arguments)
348
    {
349
        return call_user_func_array([$this->createSelector(), $method], $arguments);
350
    }
351
352
    /**
353
     * {@inheritdoc}
354
     */
355
    public function jsonSerialize()
356
    {
357
        return $this->getRelated();
358
    }
359
360
    /**
361
     * {@inheritdoc}
362
     */
363
    protected function container()
364
    {
365
        return $this->orm->container();
366
    }
367
368
    /**
369
     * Class name of outer record.
370
     *
371
     * @return string
372
     */
373
    protected function getClass()
374
    {
375
        return $this->definition[static::RELATION_TYPE];
376
    }
377
378
    /**
379
     * Mount relation keys to parent or children records to ensure their connection. Method called
380
     * when record requests relation save.
381
     *
382
     * @param EntityInterface $record
383
     * @return EntityInterface
384
     */
385
    abstract protected function mountRelation(EntityInterface $record);
386
387
    /**
388
     * Convert pre-loaded relation data to record iterator record.
389
     *
390
     * @return RecordIterator
391
     */
392
    protected function createIterator()
393
    {
394
        return new RecordIterator($this->orm, $this->getClass(), (array)$this->data);
395
    }
396
397
    /**
398
     * Convert pre-loaded relation data to active record record.
399
     *
400
     * @return RecordEntity
401
     */
402
    protected function createRecord()
403
    {
404
        return $this->orm->record($this->getClass(), (array)$this->data);
405
    }
406
407
    /**
408
     * Create empty record to be associated with non nullable relation.
409
     *
410
     * @return RecordEntity
411
     */
412
    protected function emptyRecord()
413
    {
414
        $record = $this->orm->record($this->getClass(), []);
415
        $this->associate($record);
416
417
        return $record;
418
    }
419
420
    /**
421
     * Load relation data based on created selector.
422
     *
423
     * @return array|null
424
     */
425
    protected function loadData()
426
    {
427
        if (!$this->isLoadable()) {
428
            //Nothing to load for unloaded parents
429
            return null;
430
        }
431
432
        $this->loaded = true;
433
        if (static::MULTIPLE) {
434
            return $this->data = $this->createSelector()->fetchData();
435
        }
436
437
        $data = $this->createSelector()->fetchData();
438
        if (isset($data[0])) {
439
            return $this->data = $data[0];
440
        }
441
442
        return null;
443
    }
444
445
    /**
446
     * Internal ORM relation method used to create valid selector used to pre-load relation data or
447
     * create custom query based on relation options.
448
     *
449
     * Must be redeclarated in child implementations.
450
     *
451
     * @return RecordSelector
452
     */
453
    protected function createSelector()
454
    {
455
        return $this->orm->selector($this->getClass());
456
    }
457
458
    /**
459
     * Loadable when parent is loaded as well.
460
     *
461
     * @return bool
462
     */
463
    protected function isLoadable()
464
    {
465
        return $this->parent->isLoaded();
466
    }
467
468
    /**
469
     * Save simple related entity.
470
     *
471
     * @param EntityInterface $entity
472
     * @param bool            $validate
473
     * @return bool|void
474
     */
475
    private function saveEntity(EntityInterface $entity, $validate)
476
    {
477
        if ($entity instanceof RecordInterface && $entity->isDeleted()) {
478
            return true;
479
        }
480
481
        if (!$entity instanceof ActiveEntityInterface) {
482
            throw new RelationException("Unable to save non active entity.");
483
        }
484
485
        $this->mountRelation($entity);
486
        if (!$entity->save($validate)) {
487
            return false;
488
        }
489
490
        if ($entity instanceof IdentifiedInterface) {
491
            $this->orm->cache()->remember($entity);
492
        }
493
494
        return true;
495
    }
496
}
497
498