Entity::__get()   B
last analyzed

Complexity

Conditions 9
Paths 9

Size

Total Lines 38

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 19
CRAP Score 9

Importance

Changes 0
Metric Value
cc 9
nc 9
nop 1
dl 0
loc 38
ccs 19
cts 19
cp 1
crap 9
rs 7.7564
c 0
b 0
f 0
1
<?php namespace Rocket\Entities;
2
3
/**
4
 * The Entity main class is the master of everything related to Entities.
5
 *
6
 * It handles the creation, storage, modification and deletion of Entities and their Revisions.
7
 */
8
9
use Illuminate\Database\Eloquent\ModelNotFoundException;
10
use Illuminate\Support\Collection;
11
use Illuminate\Support\Facades\DB;
12
use Illuminate\Support\Str;
13
use InvalidArgumentException;
14
use Rocket\Entities\Exceptions\EntityNotFoundException;
15
use Rocket\Entities\Exceptions\InvalidFieldTypeException;
16
use Rocket\Entities\Exceptions\MultipleFieldAssignmentException;
17
use Rocket\Entities\Exceptions\NonExistentFieldException;
18
use Rocket\Entities\Exceptions\NoPublishedRevisionForLanguageException;
19
use Rocket\Entities\Exceptions\NoRevisionForLanguageException;
20
use Rocket\Entities\Exceptions\ReservedFieldNameException;
21
use Rocket\Entities\Exceptions\RevisionEntityMismatchException;
22
use Rocket\Entities\Exceptions\RevisionNotFoundException;
23
24
/**
25
 * Entity manager
26
 *
27
 * @property int $id The content ID
28
 * @property int $language_id The language in which this entity is
29
 * @property int $revision_id The current revision id
30
 * @property string $type The type of the Entity
31
 * @property bool $published Is this content published
32
 * @property-read bool $publishedRevision Is the revision published
33
 * @property-read \Rocket\Entities\Revision[] $revisions all revisions in this content
34
 * @property-read \DateTime $created_at
35
 * @property-read \DateTime $updated_at
36
 */
37
abstract class Entity
38
{
39
    /**
40
     * @var int This key identifies where the only item is stored in single item fields.
41
     */
42
    public static $SINGLE_ITEM_KEY = 0;
43
44
    /**
45
     * @var array<class> The list of field types, filled by the ServiceProvider from a configuration file.
46
     */
47
    public static $types;
48
49
    /**
50
     * @var Content The content represented by this entity
51
     */
52
    protected $content; //id, created_at, type, published
53
54
    /**
55
     * @var Revision The revision represented by this entity
56
     */
57
    protected $revision; //language_id, updated_at, type, published
58
59
    /**
60
     * @var FieldCollection[] The fields in this entity
61
     */
62
    protected $data;
63
64
    /**
65
     * Entity constructor.
66
     *
67
     * @param int $language_id The language this specific entity is in
68
     */
69 111
    public function __construct($language_id)
70
    {
71 111
        if (!is_numeric($language_id) || $language_id == 0) {
72 3
            throw new InvalidArgumentException("You must set a valid 'language_id'.");
73
        }
74
75 108
        $fields = $this->getFields();
76
77 108
        $this->initialize($fields);
78
79 102
        $this->type = $this->getContentType();
80 102
        $this->language_id = $language_id;
81 102
    }
82
83
    /**
84
     * Creates the Content, Revision and FieldCollections
85
     *
86
     * @param array $fields The fields and their configurations
87
     * @throws InvalidFieldTypeException
88
     * @throws ReservedFieldNameException
89
     */
90 108
    protected function initialize(array $fields)
91
    {
92 108
        $this->content = new Content;
93 108
        $this->revision = new Revision;
94
95 108
        foreach ($fields as $field => $settings) {
96 108
            $this->data[$field] = $this->initializeField($field, $settings);
97 102
        }
98 102
    }
99
100
    /**
101
     * Validate configuration and prepare a FieldCollection
102
     *
103
     * @param string $field
104
     * @param array $settings
105
     * @throws InvalidFieldTypeException
106
     * @throws ReservedFieldNameException
107
     * @return FieldCollection
108
     */
109 108
    protected function initializeField($field, $settings)
110
    {
111 108
        if ($this->isContentField($field) || $this->isRevisionField($field)) {
112 3
            throw new ReservedFieldNameException(
113 3
                "The field '$field' cannot be used in '" . get_class($this) . "' as it is a reserved name"
114 3
            );
115
        }
116
117 105
        $type = $settings['type'];
118
119 105
        if (!array_key_exists($type, self::$types)) {
120 3
            throw new InvalidFieldTypeException("Unkown type '$type' in '" . get_class($this) . "'");
121
        }
122
123 102
        $settings['type'] = self::$types[$settings['type']];
124
125 102
        return FieldCollection::initField($settings);
126
    }
127
128
    /**
129
     * Return the fields in this entity
130
     *
131
     * @return array
132
     */
133
    abstract public function getFields();
134
135
    /**
136
     * Get the database friendly content type
137
     *
138
     * @return string
139
     */
140 108
    public static function getContentType()
141
    {
142 108
        return str_replace('\\', '', Str::snake((new \ReflectionClass(get_called_class()))->getShortName()));
143
    }
144
145
    /**
146
     * Create a new revision based on the same content ID but without the content.
147
     * Very useful if you want to add a new language
148
     *
149
     * @param int $language_id
150
     * @return static
151
     */
152 3
    public function newRevision($language_id = null)
153
    {
154 3
        $created = new static($language_id ?: $this->language_id);
155 3
        $created->content = $this->content;
156
157 3
        return $created;
158
    }
159
160
    /**
161
     * Check if the field is related to the content
162
     *
163
     * @param string $field
164
     * @return bool
165
     */
166 108
    protected function isContentField($field)
167
    {
168 108
        return in_array($field, ['id', 'created_at', 'type', 'published']);
169
    }
170
171
    /**
172
     * Check if the field exists on the entity
173
     *
174
     * @param string $field
175
     * @return bool
176
     */
177 90
    public function hasField($field)
178
    {
179 90
        return array_key_exists($field, $this->data);
180
    }
181
182
    /**
183
     * Get a field's FieldCollection.
184
     *
185
     * Be careful as this gives you the real field instances.
186
     *
187
     * @param string $field
188
     * @return FieldCollection
189
     */
190 84
    public function getField($field)
191
    {
192 84
        return $this->data[$field];
193
    }
194
195
    /**
196
     * Check if the field is related to the revision
197
     *
198
     * @param string $field
199
     * @return bool
200
     */
201 105
    protected function isRevisionField($field)
202
    {
203 105
        return in_array($field, ['language_id', 'updated_at', 'published']);
204
    }
205
206
    /**
207
     * Dynamically retrieve attributes on the model.
208
     *
209
     * @param string $key
210
     * @throws NonExistentFieldException
211
     * @return $this|bool|\Carbon\Carbon|\DateTime|mixed|static
212
     */
213 90
    public function __get($key)
214
    {
215 90
        if ($this->isContentField($key)) {
216 60
            return $this->content->getAttribute($key);
217
        }
218
219 87
        if ($this->isRevisionField($key)) {
220 9
            return $this->revision->getAttribute($key);
221
        }
222
223 81
        if ($this->hasField($key)) {
224 78
            $field = $this->getField($key);
225
226 78
            if ($field->getMaxItems() != 1) {
227 75
                return $field;
228
            }
229
230 9
            if ($field->has(static::$SINGLE_ITEM_KEY)) {
231 9
                return $field->get(static::$SINGLE_ITEM_KEY)->value;
232
            }
233
234 3
            return null;
235
        }
236
237 25
        if ($key == 'revision_id') {
238 16
            return $this->revision->id;
239
        }
240
241 20
        if ($key == 'revisions') {
242 9
            return $this->content->revisions;
243
        }
244
245 11
        if ($key == 'publishedRevision') {
246 8
            return $this->revision->published;
247
        }
248
249 3
        throw new NonExistentFieldException("Field '$key' doesn't exist in '" . get_class($this) . "'");
250
    }
251
252
    /**
253
     * Dynamically set attributes on the model.
254
     *
255
     * @param string $key
256
     * @param mixed $value
257
     * @throws NonExistentFieldException
258
     */
259 102
    public function __set($key, $value)
260
    {
261 102
        if ($this->isContentField($key)) {
262 102
            $this->content->setAttribute($key, $value);
263
264 102
            return;
265
        }
266
267 102
        if ($this->isRevisionField($key)) {
268 102
            $this->revision->setAttribute($key, $value);
269
270 102
            return;
271
        }
272
273 30
        if ($this->hasField($key)) {
274 27
            $this->setOnField($this->getField($key), $value);
275
276 24
            return;
277
        }
278
279 3
        throw new NonExistentFieldException("Field '$key' doesn't exist in '" . get_class($this) . "'");
280
    }
281
282
    /**
283
     * Set values on a field
284
     *
285
     * @param FieldCollection $field
286
     * @param $value
287
     * @throws MultipleFieldAssignmentException
288
     */
289 27
    protected function setOnField(FieldCollection $field, $value)
290
    {
291 27
        if (!is_array($value)) {
292 15
            if ($field->getMaxItems() != 1) {
293 3
                throw new MultipleFieldAssignmentException('You cannot assign a value to replace a multiple field');
294
            }
295
296 12
            $field->offsetSet(static::$SINGLE_ITEM_KEY, $value);
297
298 12
            return;
299
        }
300
301 18
        $field->clear();
302
303
        // This happens when the array is
304
        // replaced completely by another array
305 18
        if (count($value)) {
306 9
            foreach ($value as $k => $v) {
307 9
                $field->offsetSet($k, $v);
308 9
            }
309 9
        }
310 18
    }
311
312
    /**
313
     * Get all field types in this Entity.
314
     *
315
     * @return Collection
316
     */
317 43
    protected function getFieldTypes()
318
    {
319 43
        return (new Collection($this->getFields()))
320
            ->map(function ($options) {
321 43
                return $options['type'];
322 43
            })
323 43
            ->values()
324 43
            ->unique()
325
            ->map(function ($type) {
326 43
                return self::$types[$type];
327 43
            });
328
    }
329
330
    /**
331
     * Find the requested Revision.
332
     *
333
     * If a revision_id it will be requested against the requested ID and Language.
334
     *
335
     * If none is requested, it will find the revision that is published in that language.
336
     *
337
     * @param int $id The content ID
338
     * @param int $language_id The language ID
339
     * @param int $revision_id The revision ID which you want to load, this is optional
340
     * @throws NoPublishedRevisionForLanguageException
341
     * @throws NoRevisionForLanguageException
342
     * @throws RevisionEntityMismatchException
343
     * @throws RevisionNotFoundException
344
     * @return Revision
345
     */
346 50
    protected static function findRevision($id, $language_id, $revision_id = null)
347
    {
348
        try {
349 50
            if (is_numeric($revision_id) && $revision_id != 0) {
350 10
                $revision = Revision::findOrFail($revision_id);
351
352 7
                if ($revision->content_id != $id) {
353 3
                    throw new RevisionEntityMismatchException("This revision doesn't belong to this entity");
354
                }
355
356 4
                if ($revision->language_id != $language_id) {
357
                    //TODO :: throw an exception in this case as well
358
                }
359
360 4
                return $revision;
361
            }
362
363 44
            return Revision::where('content_id', $id)
364 44
                ->where('language_id', $language_id)
365 44
                ->where('published', true)
366 44
                ->firstOrFail();
367 17
        } catch (ModelNotFoundException $e) {
368 14
            if (is_numeric($revision_id) && $revision_id != 0) {
369 3
                throw new RevisionNotFoundException("This revision doesn't exist", 0, $e);
370
            }
371
372 11
            $count = Revision::where('content_id', $id)->where('language_id', $language_id)->count();
373
374 11
            if ($count) {
375 2
                $message = "There are revisions in language_id='$language_id' for Entity '$id' but none is published";
376 2
                throw new NoPublishedRevisionForLanguageException($message, 0, $e);
377
            } else {
378 9
                $message = "There no revisions in language_id='$language_id' for Entity '$id' but none is published";
379 9
                throw new NoRevisionForLanguageException($message, 0, $e);
380
            }
381
        }
382
    }
383
384
    /**
385
     * Find the latest valid revision for this entity
386
     *
387
     * @param int $id The content ID
388
     * @param int $language_id The language ID
389
     * @param int $revision_id The revision ID which you want to load, this is optional
390
     * @throws EntityNotFoundException
391
     * @throws NoPublishedRevisionForLanguageException
392
     * @throws NoRevisionForLanguageException
393
     * @throws RevisionEntityMismatchException
394
     * @throws RevisionNotFoundException
395
     * @return static
396
     */
397 57
    public static function find($id, $language_id, $revision_id = null)
398
    {
399 57
        $instance = new static($language_id);
400
401
        try {
402 57
            $instance->content = Content::findOrFail($id);
403 57
        } catch (ModelNotFoundException $e) {
404 7
            throw new EntityNotFoundException("The entity with id '$id' doesn't exist", 0, $e);
405
        }
406
407 50
        $instance->revision = static::findRevision($id, $language_id, $revision_id);
408
409 33
        $instance->getFieldTypes()
410
            ->each(function ($type) use ($instance) {
411 33
                $type::where('revision_id', $instance->revision->id)
412 33
                    ->get()
413
                    ->each(function (Field $value) use ($instance) {
414 33
                        $instance->data[$value->name][$value->weight] = $value;
415 33
                    });
416 33
            });
417
418 33
        return $instance;
419
    }
420
421
    /**
422
     * Save a revision
423
     *
424
     * @param bool $newRevision Should we create a new revision, false by default
425
     * @param bool $publishRevision Should we immediately publish this revision, true by default
426
     */
427 60
    public function save($newRevision = false, $publishRevision = true)
428
    {
429 60
        if ($newRevision) {
430 24
            $revision = new Revision;
431 24
            $revision->language_id = $this->revision->language_id;
432
433 24
            $this->revision = $revision;
434 24
        }
435
436 60
        DB::transaction(
437
            function () use ($newRevision, $publishRevision) {
438 60
                $this->saveContent();
439
440 60
                $this->saveRevision($publishRevision);
441
442
                // Prepare and save fields
443 60
                foreach (array_keys($this->data) as $fieldName) {
444
                    /** @var FieldCollection $field */
445 60
                    $field = $this->data[$fieldName];
446
447 60
                    if (!$newRevision) {
448
                        $field->deleted()->each(function (Field $value) {
449 3
                            $value->delete();
450 51
                        });
451 51
                    }
452
453
                    $field->each(function (Field $value, $key) use ($newRevision, $fieldName) {
454 60
                        $value->weight = $key;
455 60
                        $value->name = $fieldName;
456 60
                        $this->saveField($value, $newRevision);
457 60
                    });
458
459 60
                    $field->syncOriginal();
460 60
                }
461 57
            }
462 60
        );
463 57
    }
464
465
    /**
466
     * Save the content
467
     */
468 60
    protected function saveContent()
469
    {
470 60
        $this->content->save();
471 60
    }
472
473
    /**
474
     * Save the revision
475
     *
476
     * @param bool $publishRevision Should we immediately publish this revision, true by default
477
     */
478 60
    protected function saveRevision($publishRevision)
479
    {
480 60
        if (!$this->revision->exists && !$publishRevision) {
481 12
            $this->revision->published = $publishRevision;
482 12
        }
483
484 60
        $this->revision->content_id = $this->content->id;
485 60
        $this->revision->save();
486
487 60
        if ($publishRevision) {
488 51
            $this->unpublishOtherRevisions();
489 51
        }
490 60
    }
491
492
    /**
493
     * Unpublish the revisions other than this one.
494
     * Only for the same content_id and language_id
495
     */
496 51
    protected function unpublishOtherRevisions()
497
    {
498 51
        if ($this->content->wasRecentlyCreated) {
499 51
            return;
500
        }
501
502
        // Unpublish all other revisions
503 18
        Revision::where('content_id', $this->content->id)
504 18
            ->where('language_id', $this->revision->language_id)
505 18
            ->where('id', '!=', $this->revision->id)
506 18
            ->update(['published' => false]);
507 18
    }
508
509
    /**
510
     * Save a single field instance
511
     *
512
     * @param Field $field The field instance to save
513
     * @param bool $newRevision Should we create a new revision?
514
     */
515 60
    protected function saveField(Field $field, $newRevision)
516
    {
517
        // If we create a new revision, this will
518
        // reinit the field to a non-saved field
519
        // and create a new row in the database
520 60
        if ($newRevision) {
521 24
            $field->id = null;
522 24
            $field->exists = false;
523 24
        }
524
525 60
        $field->revision_id = $this->revision->id;
526
527 60
        $field->save();
528 57
    }
529
530
    /**
531
     * Convert the Entity to an array.
532
     *
533
     * @return array
534
     */
535 27
    public function toArray()
536
    {
537
        $content = [
538 27
            'id' => $this->content->id,
539 27
            '_content' => $this->content->toArray(),
540 27
            '_revision' => $this->revision->toArray(),
541 27
        ];
542
543 27
        foreach ($this->data as $field => $data) {
544 27
            $content[$field] = $data->toArray();
545 27
        }
546
547 27
        return $content;
548
    }
549
550
    /**
551
     * Delete this entity and all the underlying Revisions.
552
     *
553
     * @param bool $clear Should we clear the fields after deleting the revision ?
554
     */
555 4
    public function delete($clear = true)
556
    {
557 4
        $revisions = Revision::where('content_id', $this->content->id)->get();
558
559 4
        $ids = $revisions->pluck('id');
560
561
        $this->getFieldTypes()->each(function ($type) use ($ids) {
562 4
            $type::whereIn('revision_id', $ids)->delete();
563 4
        });
564
565 4
        Revision::whereIn('id', $ids)->delete();
566 4
        $this->revision->exists = false;
567
568
        // TODO :: add an event system to be able to remove this content from entity fields
569
570 4
        $this->content->delete();
571
572 4
        if ($clear) {
573 2
            $this->revision->id = null;
574 2
            $this->content->id = null;
575 2
            $this->clearFields();
576 2
        }
577 4
    }
578
579
    /**
580
     * Delete the current revision.
581
     *
582
     * @param bool $clear Should we clear the fields after deleting the revision ?
583
     */
584
    public function deleteRevision($clear = true)
585
    {
586 8
        $this->getFieldTypes()->each(function ($type) {
587 8
            $type::where('revision_id', $this->revision->id)->delete();
588 8
        });
589
590
        // If this revision is currently
591
        // published, we need to publish
592
        // another revision in place.
593 8
        if ($this->revision->published && $this->revision->exists) {
594
            //TODO :: improve this logic
595 8
            Revision::where('content_id', $this->content->id)
596 8
                ->where('id', '!=', $this->revision->id)
597 8
                ->take(1)
598 8
                ->update(['published' => true]);
599 8
        }
600
601 8
        $this->revision->delete();
602
603 8
        if ($clear) {
604 5
            $this->clearFields();
605 5
        }
606 8
    }
607
608
    /**
609
     * Clear all the fields from their content
610
     */
611 7
    protected function clearFields()
612
    {
613
        // Void all the fields
614 7
        foreach (array_keys($this->data) as $fieldName) {
615
            /** @var FieldCollection $field */
616 7
            $field = $this->data[$fieldName];
617
618 7
            $field->clear();
619 7
            $field->syncOriginal();
620 7
        }
621 7
    }
622
623
    /**
624
     * Publish the current revision and unpublish the other revisions of the same language.
625
     */
626 2
    public function publishRevision()
627
    {
628 2
        $this->revision->published = true;
629 2
        $this->revision->save();
630
631 2
        $this->unpublishOtherRevisions();
632 2
    }
633
}
634