Completed
Push35d3bb...a50fd3
passed — Build
created

Entity::findRevision()   D

↳ Parent: Entity

Complexity

Conditions 9
Paths 15

Duplication

Lines 0
Ratio 0 %

Size

Total Lines 37
Code Lines 22

Code Coverage

Tests 20
CRAP Score 9.0086

Importance

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