Completed
Push — master ( 5a44ae...0c75f6 )
by Stéphane
14:22
created

src/Entities/Entity.php (2 issues)

Upgrade to new PHP Analysis Engine

These results are based on our legacy PHP analysis, consider migrating to our new PHP analysis engine instead. Learn more

1
<?php namespace Rocket\Entities;
2
3
use Illuminate\Database\Eloquent\ModelNotFoundException;
4
use Illuminate\Support\Collection;
5
use Illuminate\Support\Facades\DB;
6
use InvalidArgumentException;
7
use Rocket\Entities\Exceptions\EntityNotFoundException;
8
use Rocket\Entities\Exceptions\InvalidFieldTypeException;
9
use Rocket\Entities\Exceptions\NonExistentFieldException;
10
use Rocket\Entities\Exceptions\NoPublishedRevisionForLanguageException;
11
use Rocket\Entities\Exceptions\NoRevisionForLanguageException;
12
use Rocket\Entities\Exceptions\ReservedFieldNameException;
13
use Rocket\Entities\Exceptions\RevisionEntityMismatchException;
14
use Rocket\Entities\Exceptions\RevisionNotFoundException;
15
16
/**
17
 * Entity manager
18
 *
19
 * @property int $id The content ID
20
 * @property int $language_id The language in which this entity is
21
 * @property int $revision_id The current revision id
22
 * @property string $type The type of the Entity
23
 * @property bool $published Is this content published
24
 * @property \Rocket\Entities\Revision[] $revisions all revisions in this content
25
 * @property-read \DateTime $created_at
26
 * @property-read \DateTime $updated_at
27
 */
28
abstract class Entity
29
{
30
    /**
31
     * @var array<class> The list of field types, filled with the configuration
32
     */
33
    public static $types;
34
35
    /**
36
     * The content represented by this entity
37
     *
38
     * @var Content
39
     */
40
    protected $content; //id, created_at, type, published
41
42
    /**
43
     * The revision represented by this entity
44
     *
45
     * @var Revision
46
     */
47
    protected $revision; //language_id, updated_at, type, published
48
49
    /**
50
     * The fields in this entity
51
     *
52
     * @var array<FieldCollection>
53
     */
54
    protected $data;
55
56
    /**
57
     * Entity constructor.
58
     *
59
     * @param int $language_id The language this specific entity is in
60
     */
61 90
    public function __construct($language_id)
62
    {
63 90
        if (!is_numeric($language_id) || $language_id == 0) {
64 3
            throw new InvalidArgumentException("You must set a valid 'language_id'.");
65
        }
66
67 87
        $fields = $this->getFields();
68
69 87
        $this->initialize($fields);
70
71 81
        $this->type = $this->getContentType();
72 81
        $this->language_id = $language_id;
73 81
    }
74
75
    /**
76
     * Creates the Content, Revision and FieldCollections
77
     *
78
     * @param array $fields The fields and their configurations
79
     * @throws InvalidFieldTypeException
80
     * @throws ReservedFieldNameException
81
     */
82 87
    protected function initialize(array $fields)
83
    {
84 87
        $this->content = new Content;
85 87
        $this->revision = new Revision;
86
87 87
        foreach ($fields as $field => $settings) {
88 87
            $this->data[$field] = $this->initializeField($field, $settings);
89 81
        }
90 81
    }
91
92
    /**
93
     * Validate configuration and prepare a FieldCollection
94
     *
95
     * @param string $field
96
     * @param array $settings
97
     * @throws InvalidFieldTypeException
98
     * @throws ReservedFieldNameException
99
     * @return FieldCollection
100
     */
101 87
    protected function initializeField($field, $settings)
102
    {
103 87
        if ($this->isContentField($field) || $this->isRevisionField($field)) {
104 3
            throw new ReservedFieldNameException(
105 3
                "The field '$field' cannot be used in '" . get_class($this) . "' as it is a reserved name"
106 3
            );
107
        }
108
109 84
        $type = $settings['type'];
110
111 84
        if (!array_key_exists($type, self::$types)) {
112 3
            throw new InvalidFieldTypeException("Unkown type '$type' in '" . get_class($this) . "'");
113
        }
114
115 81
        $settings['type'] = self::$types[$settings['type']];
116
117 81
        return FieldCollection::initField($settings);
118
    }
119
120
    /**
121
     * Return the fields in this entity
122
     *
123
     * @return array
124
     */
125
    abstract public function getFields();
126
127
    /**
128
     * Get the database friendly content type
129
     *
130
     * @return string
131
     */
132 87
    public static function getContentType()
133
    {
134 87
        return str_replace('\\', '', snake_case((new \ReflectionClass(get_called_class()))->getShortName()));
135
    }
136
137
    /**
138
     * Create a new revision based on the same content ID but without the content.
139
     * Very useful if you want to add a new language
140
     *
141
     * @param int $language_id
142
     * @return static
143
     */
144 3
    public function newRevision($language_id = null)
145
    {
146 3
        $created = new static($language_id ?: $this->language_id);
147 3
        $created->content = $this->content;
148
149 3
        return $created;
150
    }
151
152
    /**
153
     * Check if the field is related to the content
154
     *
155
     * @param string $field
156
     * @return bool
157
     */
158 87
    protected function isContentField($field)
159
    {
160 87
        return in_array($field, ['id', 'created_at', 'type', 'published']);
161
    }
162
163
    /**
164
     * Check if the field exists on the entity
165
     *
166
     * @param string $field
167
     * @return bool
168
     */
169 69
    public function hasField($field)
170
    {
171 69
        return array_key_exists($field, $this->data);
172
    }
173
174
    /**
175
     * Get a field's FieldCollection.
176
     *
177
     * Be careful as this gives you the real field instances.
178
     *
179
     * @param string $field
180
     * @return FieldCollection
181
     */
182 63
    public function getField($field)
183
    {
184 63
        return $this->data[$field];
185
    }
186
187
    /**
188
     * Check if the field is related to the revision
189
     *
190
     * @param string $field
191
     * @return bool
192
     */
193 84
    protected function isRevisionField($field)
194
    {
195 84
        return in_array($field, ['language_id', 'updated_at', 'published']);
196
    }
197
198
    /**
199
     * Dynamically retrieve attributes on the model.
200
     *
201
     * @param string $key
202
     * @throws NonExistentFieldException
203
     * @return $this|bool|\Carbon\Carbon|\DateTime|mixed|static
204
     */
205 72
    public function __get($key)
206
    {
207 72
        if ($this->isContentField($key)) {
208 40
            return $this->content->getAttribute($key);
209
        }
210
211 69
        if ($this->isRevisionField($key)) {
212 9
            return $this->revision->getAttribute($key);
213
        }
214
215 63
        if ($this->hasField($key)) {
216 60
            return $this->getField($key);
217
        }
218
219 15
        if ($key == 'revision_id') {
220 12
            return $this->revision->id;
221
        }
222
223 12
        if ($key == 'revisions') {
224 9
            return $this->content->revisions;
225
        }
226
227 3
        throw new NonExistentFieldException("Field '$key' doesn't exist in '" . get_class($this) . "'");
228
    }
229
230
    /**
231
     * Dynamically set attributes on the model.
232
     *
233
     * @param string $key
234
     * @param mixed $value
235
     * @throws NonExistentFieldException
236
     */
237 81
    public function __set($key, $value)
238
    {
239 81
        if ($this->isContentField($key)) {
240 81
            $this->content->setAttribute($key, $value);
241
242 81
            return;
243
        }
244
245 81
        if ($this->isRevisionField($key)) {
246 81
            $this->revision->setAttribute($key, $value);
247
248 81
            return;
249
        }
250
251 21
        if ($this->hasField($key)) {
252 18
            $this->setOnField($this->getField($key), $value);
253
254 18
            return;
255
        }
256
257 3
        throw new NonExistentFieldException("Field '$key' doesn't exist in '" . get_class($this) . "'");
258
    }
259
260
    /**
261
     * Set values on a field
262
     *
263
     * @param FieldCollection $field
264
     * @param $value
265
     */
266 18
    protected function setOnField(FieldCollection $field, $value)
267
    {
268 18
        if (!is_array($value)) {
269 6
            $field->offsetSet(0, $value);
270
271 6
            return;
272
        }
273
274 12
        $field->clear();
275
276
        // This happens when the array is
277
        // replaced completely by another array
278 12
        if (count($value)) {
279 3
            foreach ($value as $k => $v) {
280 3
                $field->offsetSet($k, $v);
281 3
            }
282 3
        }
283 12
    }
284
285
    /**
286
     * Get all field types in this Entity.
287
     *
288
     * @return Collection
289
     */
290 23
    protected function getFieldTypes()
291
    {
292 23
        return (new Collection($this->getFields()))
293
            ->map(function ($options) {
294 23
                return $options['type'];
295 23
            })
296 23
            ->values()
297 23
            ->unique()
298
            ->map(function ($type) {
299 23
                return self::$types[$type];
300 23
            });
301
    }
302
303
    /**
304
     * @param int $id The content ID
305
     * @param int $language_id The language ID
306
     * @param int $revision_id The revision ID which you want to load, this is optional
307
     * @throws NoPublishedRevisionForLanguageException
308
     * @throws NoRevisionForLanguageException
309
     * @throws RevisionEntityMismatchException
310
     * @throws RevisionNotFoundException
311
     * @return Revision
312
     */
313 30
    protected static function findRevision($id, $language_id, $revision_id = null) {
314
        try {
315 30
            if ($revision_id) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $revision_id of type integer|null is loosely compared to true; this is ambiguous if the integer can be zero. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For integer values, zero is a special case, in particular the following results might be unexpected:

0   == false // true
0   == null  // true
123 == false // false
123 == null  // false

// It is often better to use strict comparison
0 === false // false
0 === null  // false
Loading history...
316 8
                $revision = Revision::findOrFail($revision_id);
317
318 5
                if ($revision->content_id != $id) {
319 3
                    throw new RevisionEntityMismatchException("This revision doesn't belong to this entity");
320
                }
321
322 2
                return $revision;
323
            }
324
325 24
            return Revision::where('content_id', $id)
326 24
                ->where('language_id', $language_id)
327 24
                ->where('published', true)
328 24
                ->firstOrFail();
329 15
        } catch (ModelNotFoundException $e) {
330
331 12
            if ($revision_id) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $revision_id of type integer|null is loosely compared to true; this is ambiguous if the integer can be zero. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For integer values, zero is a special case, in particular the following results might be unexpected:

0   == false // true
0   == null  // true
123 == false // false
123 == null  // false

// It is often better to use strict comparison
0 === false // false
0 === null  // false
Loading history...
332 3
                throw new RevisionNotFoundException("This revision doesn't exist", 0, $e);
333
            }
334
335 9
            $count = Revision::where('content_id', $id)->where('language_id', $language_id)->count();
336
337 9
            if ($count) {
338 2
                $message = "There are revisions in language_id='$language_id' for Entity '$id' but none is published";
339 2
                throw new NoPublishedRevisionForLanguageException($message, 0, $e);
340
            } else {
341 7
                $message = "There no revisions in language_id='$language_id' for Entity '$id' but none is published";
342 7
                throw new NoRevisionForLanguageException($message, 0, $e);
343
            }
344
        }
345
    }
346
347
    /**
348
     * Find the latest valid revision for this entity
349
     *
350
     * @param int $id The content ID
351
     * @param int $language_id The language ID
352
     * @param int $revision_id The revision ID which you want to load, this is optional
353
     * @throws EntityNotFoundException
354
     * @throws NoPublishedRevisionForLanguageException
355
     * @throws NoRevisionForLanguageException
356
     * @throws RevisionEntityMismatchException
357
     * @throws RevisionNotFoundException
358
     * @return static
359
     */
360 37
    public static function find($id, $language_id, $revision_id = null)
361
    {
362 37
        $instance = new static($language_id);
363
364
        try {
365 37
            $instance->content = Content::findOrFail($id);
366 37
        } catch (ModelNotFoundException $e) {
367 7
            throw new EntityNotFoundException("The entity with id '$id' doesn't exist", 0, $e);
368
        }
369
370 30
        $instance->revision = static::findRevision($id, $language_id, $revision_id);
371
372 15
        $instance->getFieldTypes()
373
            ->each(function ($type) use ($instance) {
374 15
                $type::where('revision_id', $instance->revision->id)
375 15
                    ->get()
376
                    ->each(function (Field $value) use ($instance) {
377 15
                        $instance->data[$value->name][$value->weight] = $value;
378 15
                    });
379 15
            });
380
381 15
        return $instance;
382
    }
383
384
    /**
385
     * Save a revision
386
     *
387
     * @param bool $newRevision Should we create a new revision, false by default
388
     * @param bool $publishRevision Should we immediately publish this revision, true by default
389
     */
390 42
    public function save($newRevision = false, $publishRevision = true)
391
    {
392 42
        if ($newRevision) {
393 24
            $revision = new Revision;
394 24
            $revision->language_id = $this->revision->language_id;
395
396 24
            $this->revision = $revision;
397 24
        }
398
399 42
        DB::transaction(
400
            function () use ($newRevision, $publishRevision) {
401 42
                $this->saveContent();
402
403 42
                $this->saveRevision($publishRevision);
404
405
                // Prepare and save fields
406 42
                foreach (array_keys($this->data) as $fieldName) {
407
                    /** @var FieldCollection $field */
408 42
                    $field = $this->data[$fieldName];
409
410 42
                    if (!$newRevision) {
411
                        $field->deleted()->each(function (Field $value) {
412 3
                            $value->delete();
413 27
                        });
414 27
                    }
415
416
                    $field->each(function (Field $value, $key) use ($newRevision, $fieldName) {
417 42
                        $value->weight = $key;
418 42
                        $value->name = $fieldName;
419 42
                        $this->saveField($value, $newRevision);
420 42
                    });
421
422 42
                    $field->syncOriginal();
423 42
                }
424 37
            }
425 42
        );
426 37
    }
427
428
    /**
429
     * Save the content
430
     */
431 42
    protected function saveContent()
432
    {
433 42
        $this->content->save();
434 42
    }
435
436
    /**
437
     * Save the revision
438
     *
439
     * @param bool $publishRevision Should we immediately publish this revision, true by default
440
     */
441 42
    protected function saveRevision($publishRevision)
442
    {
443 42
        if (!$this->revision->exists && !$publishRevision) {
444 18
            $this->revision->published = $publishRevision;
445 18
        }
446
447 42
        $this->revision->content_id = $this->content->id;
448 42
        $this->revision->save();
449
450 42
        if ($publishRevision) {
451 27
            $this->unpublishOtherRevisions();
452 27
        }
453 42
    }
454
455
    /**
456
     * Unpublish the revisions other than this one.
457
     * Only for the same content_id and language_id
458
     */
459 27
    protected function unpublishOtherRevisions()
460
    {
461 27
        if ($this->content->wasRecentlyCreated) {
462 27
            return;
463
        }
464
465
        // Unpublish all other revisions
466 9
        Revision::where('content_id', $this->content->id)
467 9
            ->where('language_id', $this->revision->language_id)
468 9
            ->where('id', '!=', $this->revision->id)
469 9
            ->update(['published' => false]);
470 9
    }
471
472
    /**
473
     * Save a single field instance
474
     *
475
     * @param Field $field The field instance to save
476
     * @param bool $newRevision Should we create a new revision?
477
     */
478 42
    protected function saveField(Field $field, $newRevision)
479
    {
480
        // If we create a new revision, this will
481
        // reinit the field to a non-saved field
482
        // and create a new row in the database
483 42
        if ($newRevision) {
484 24
            $field->id = null;
485 24
            $field->exists = false;
486 24
        }
487
488 42
        $field->revision_id = $this->revision->id;
489
490 42
        $field->save();
491 37
    }
492
493
    /**
494
     * Convert the Entity to an array.
495
     *
496
     * @return array
497
     */
498 18
    public function toArray()
499
    {
500
        $content = [
501 18
            'id' => $this->content->id,
502 18
            '_content' => $this->content->toArray(),
503 18
            '_revision' => $this->revision->toArray(),
504 18
        ];
505
506 18
        foreach ($this->data as $field => $data) {
507 18
            $content[$field] = $data->toArray();
508 18
        }
509
510 18
        return $content;
511
    }
512
513 4
    public function delete($clear = true)
514
    {
515 4
        $revisions = Revision::where('content_id', $this->content->id)->get();
516
517 4
        $ids = $revisions->pluck('id');
518
519
        $this->getFieldTypes()->each(function ($type) use ($ids) {
520 4
            $type::whereIn('revision_id', $ids)->delete();
521 4
        });
522
523 4
        Revision::whereIn('id', $ids)->delete();
524 4
        $this->revision->exists = false;
525
526
        // TODO :: add an event system to be able to remove this content from entity fields
527
528 4
        $this->content->delete();
529
530 4
        if ($clear) {
531 2
            $this->revision->id = null;
532 2
            $this->content->id = null;
533 2
            $this->clearFields();
534 2
        }
535 4
    }
536
537
    public function deleteRevision($clear = true)
538
    {
539 4
        $this->getFieldTypes()->each(function ($type) {
540 4
            $type::where('revision_id', $this->revision->id)->delete();
541 4
        });
542
543
        // If this revision is currently
544
        // published, we need to publish
545
        // another revision in place.
546 4
        if ($this->revision->published && $this->revision->exists) {
547
            //TODO :: improve this logic
548
            Revision::where('content_id', $this->content->id)
549
                ->where('id', '!=', $this->revision->id)
550
                ->take(1)
551
                ->update(['published' => true]);
552
        }
553
554 4
        $this->revision->delete();
555
556 4
        if ($clear) {
557 2
            $this->clearFields();
558 2
        }
559 4
    }
560
561 4
    protected function clearFields()
562
    {
563
        // Void all the fields
564 4
        foreach (array_keys($this->data) as $fieldName) {
565
            /** @var FieldCollection $field */
566 4
            $field = $this->data[$fieldName];
567
568 4
            $field->clear();
569 4
            $field->syncOriginal();
570 4
        }
571 4
    }
572
}
573