Test Failed
Push — develop ( 105ddb...c73f86 )
by Jimmy
49s queued 14s
created

Model::getOrderByParameter()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 1
c 1
b 0
f 0
dl 0
loc 3
rs 10
cc 1
nc 1
nop 0
1
<?php
2
3
namespace Spinen\Halo\Support;
4
5
use ArrayAccess;
6
use GuzzleHttp\Exception\GuzzleException;
7
use Illuminate\Contracts\Support\Arrayable;
8
use Illuminate\Contracts\Support\Jsonable;
9
use Illuminate\Database\Eloquent\Concerns\HasAttributes;
10
use Illuminate\Database\Eloquent\Concerns\HasTimestamps;
11
use Illuminate\Database\Eloquent\Concerns\HidesAttributes;
12
use Illuminate\Database\Eloquent\JsonEncodingException;
13
use Illuminate\Support\Carbon;
14
use Illuminate\Support\Facades\Date;
15
use Illuminate\Support\Str;
16
use Illuminate\Support\Traits\Conditionable;
17
use JsonSerializable;
18
use LogicException;
19
use Spinen\Halo\Concerns\HasClient;
20
use Spinen\Halo\Exceptions\InvalidRelationshipException;
21
use Spinen\Halo\Exceptions\ModelNotFoundException;
22
use Spinen\Halo\Exceptions\ModelReadonlyException;
23
use Spinen\Halo\Exceptions\NoClientException;
24
use Spinen\Halo\Exceptions\TokenException;
25
use Spinen\Halo\Exceptions\UnableToSaveException;
26
use Spinen\Halo\Support\Relations\BelongsTo;
27
use Spinen\Halo\Support\Relations\ChildOf;
28
use Spinen\Halo\Support\Relations\HasMany;
29
use Spinen\Halo\Support\Relations\Relation;
30
31
/**
32
 * Class Model
33
 *
34
 * NOTE: Since we are trying to give a Laravel like feel when interacting
35
 * with the API, there are sections of this code that is very heavily
36
 * patterned/inspired directly from Laravel's Model class.
37
 */
38
abstract class Model implements Arrayable, ArrayAccess, Jsonable, JsonSerializable
39
{
40
    use Conditionable;
41
    use HasAttributes {
0 ignored issues
show
introduced by
The trait Illuminate\Database\Eloq...\Concerns\HasAttributes requires some properties which are not provided by Spinen\Halo\Support\Model: $preventsLazyLoading, $name, $map, $set, $withCaching, $withObjectCaching, $get, $value
Loading history...
42
        asDateTime as originalAsDateTime;
43
    }
44
    use HasClient;
45
    use HasTimestamps;
46
    use HidesAttributes;
47
48
    /**
49
     * Indicates if the model exists.
50
     */
51
    public bool $exists = false;
52
53
    /**
54
     * Indicates if the IDs are auto-incrementing.
55
     */
56
    public bool $incrementing = false;
57
58
    /**
59
     * The "type" of the primary key ID.
60
     */
61
    protected string $keyType = 'int';
62
63
    /**
64
     * Is resource nested behind parentModel
65
     *
66
     * Several of the endpoints are nested behind another model for relationship, but then to
67
     * interact with the specific model, then are not nested.  This property will know when to
68
     * keep the specific model nested.
69
     */
70
    protected bool $nested = false;
71
72
    /**
73
     * Parameter for order by direction
74
     *
75
     * Default is "$orderByParameter . 'desc'"
76
     */
77
    protected ?string $orderByDirectionParameter = null;
78
79
    /**
80
     * Parameter for order by column
81
     */
82
    protected string $orderByParameter = 'order';
83
84
    /**
85
     * Optional parentModel instance
86
     */
87
    public ?Model $parentModel;
88
89
    /**
90
     * Path to API endpoint.
91
     */
92
    protected string $path;
93
94
    /**
95
     * The primary key for the model.
96
     */
97
    protected string $primaryKey = 'id';
98
99
    /**
100
     * Is the model readonly?
101
     */
102
    protected bool $readonlyModel = false;
103
104
    /**
105
     * The loaded relationships for the model.
106
     */
107
    protected array $relations = [];
108
109
    /**
110
     * Some of the responses have the collections under a property
111
     */
112
    protected ?string $responseCollectionKey = null;
113
114
    /**
115
     * Some of the responses have the data under a property
116
     */
117
    protected ?string $responseKey = null;
118
119
    /**
120
     * Are timestamps in milliseconds?
121
     */
122
    protected bool $timestampsInMilliseconds = true;
123
124
    /**
125
     * Indicates if the model was inserted during the current request lifecycle.
126
     *
127
     * @var bool
128
     */
129
    public $wasRecentlyCreated = false;
130
131
    /**
132
     * The name of the "created at" column.
133
     *
134
     * @var string
135
     */
136
    const CREATED_AT = null;
137
138
    /**
139
     * The name of the "updated at" column.
140
     *
141
     * @var string
142
     */
143
    const UPDATED_AT = null;
144
145
    /**
146
     * Model constructor.
147
     */
148
    public function __construct(?array $attributes = [], Model $parentModel = null)
149
    {
150
        // All dates from API comes as epoch with milliseconds
151
        $this->dateFormat = 'Uv';
152
        // None of these models will use timestamps, but need the date casting
153
        $this->timestamps = false;
154
155
        $this->syncOriginal();
156
157
        $this->fill($attributes);
158
        $this->parentModel = $parentModel;
159
    }
160
161
    /**
162
     * Dynamically retrieve attributes on the model.
163
     */
164
    public function __get(string $key)
165
    {
166
        return $this->getAttribute($this->keyMap($key));
167
    }
168
169
    /**
170
     * Determine if an attribute or relation exists on the model.
171
     */
172
    public function __isset(string $key): bool
173
    {
174
        return $this->offsetExists($this->keyMap($key));
175
    }
176
177
    /**
178
     * Dynamically set attributes on the model.
179
     *
180
     * @param  string  $key
181
     * @return void
182
     *
183
     * @throws ModelReadonlyException
184
     */
185
    public function __set($key, $value)
186
    {
187
        if ($this->readonlyModel) {
188
            throw new ModelReadonlyException();
189
        }
190
191
        $this->setAttribute($this->keyMap($key), $value);
192
    }
193
194
    /**
195
     * Convert the model to its string representation.
196
     *
197
     * @return string
198
     */
199
    public function __toString()
200
    {
201
        return $this->toJson();
202
    }
203
204
    /**
205
     * Unset an attribute on the model.
206
     *
207
     * @param  string  $key
208
     * @return void
209
     */
210
    public function __unset($key)
211
    {
212
        $this->offsetUnset($this->keyMap($key));
213
    }
214
215
    /**
216
     * Return a timestamp as DateTime object.
217
     *
218
     * @return Carbon
219
     */
220
    protected function asDateTime($value)
221
    {
222
        if (is_numeric($value) && $this->timestampsInMilliseconds) {
223
            return Date::createFromTimestampMs($value);
224
        }
225
226
        return $this->originalAsDateTime($value);
227
    }
228
229
    /**
230
     * Assume foreign key
231
     *
232
     * @param  string  $related
233
     */
234
    protected function assumeForeignKey($related): string
235
    {
236
        return Str::snake((new $related())->getResponseKey()).'_id';
237
    }
238
239
    /**
240
     * Relationship that makes the model belongs to another model
241
     *
242
     * @param  string  $related
243
     * @param  string|null  $foreignKey
244
     *
245
     * @throws InvalidRelationshipException
246
     * @throws ModelNotFoundException
247
     * @throws NoClientException
248
     */
249
    public function belongsTo($related, $foreignKey = null): BelongsTo
250
    {
251
        $foreignKey = $foreignKey ?? $this->assumeForeignKey($related);
252
253
        $builder = (new Builder())->setClass($related)
254
                                  ->setClient($this->getClient());
255
256
        return new BelongsTo($builder, $this, $foreignKey);
257
    }
258
259
    /**
260
     * Relationship that makes the model child to another model
261
     *
262
     * @param  string  $related
263
     * @param  string|null  $foreignKey
264
     *
265
     * @throws InvalidRelationshipException
266
     * @throws ModelNotFoundException
267
     * @throws NoClientException
268
     */
269
    public function childOf($related, $foreignKey = null): ChildOf
270
    {
271
        $foreignKey = $foreignKey ?? $this->assumeForeignKey($related);
272
273
        $builder = (new Builder())->setClass($related)
274
                                  ->setClient($this->getClient())
275
                                  ->setParent($this);
276
277
        return new ChildOf($builder, $this, $foreignKey);
278
    }
279
280
    /**
281
     * Convert boolean to a string as their API expects "true"/"false
282
     */
283
    protected function convertBoolToString(mixed $value): mixed
284
    {
285
        return match (true) {
286
            is_array($value) => array_map([$this, 'convertBoolToString'], $value),
287
            is_bool($value) => $value ? 'true' : 'false',
288
            default => $value,
289
        };
290
    }
291
292
    /**
293
     * Delete the model from Halo
294
     *
295
     * @throws NoClientException
296
     * @throws TokenException
297
     */
298
    public function delete(): bool
299
    {
300
        // TODO: Make sure that the model supports being deleted
301
        if ($this->readonlyModel) {
302
            return false;
303
        }
304
305
        try {
306
            $this->getClient()
307
                 ->delete($this->getPath());
308
309
            return true;
310
        } catch (GuzzleException $e) {
311
            // TODO: Do something with the error
312
313
            return false;
314
        }
315
    }
316
317
    /**
318
     * Fill the model with the supplied properties
319
     */
320
    public function fill(?array $attributes = []): self
321
    {
322
        foreach ((array) $attributes as $attribute => $value) {
323
            $this->setAttribute($attribute, $value);
324
        }
325
326
        return $this;
327
    }
328
329
    /**
330
     * Get the value indicating whether the IDs are incrementing.
331
     */
332
    public function getIncrementing(): bool
333
    {
334
        return $this->incrementing;
335
    }
336
337
    /**
338
     * Get the value of the model's primary key.
339
     */
340
    public function getKey()
341
    {
342
        return $this->getAttribute($this->getKeyName());
343
    }
344
345
    /**
346
     * Get the primary key for the model.
347
     */
348
    public function getKeyName(): string
349
    {
350
        return $this->primaryKey;
351
    }
352
353
    /**
354
     * Get the auto-incrementing key type.
355
     */
356
    public function getKeyType(): string
357
    {
358
        return $this->keyType;
359
    }
360
361
    /**
362
     * Get the parameter the endpoint uses to sort.
363
     */
364
    public function getOrderByDirectionParameter(): string
365
    {
366
        return $this->orderByDirectionParameter ?? $this->getOrderByParameter().'desc';
367
    }
368
369
    /**
370
     * Get the parameter the endpoint uses to sort.
371
     */
372
    public function getOrderByParameter(): string
373
    {
374
        return $this->orderByParameter;
375
    }
376
377
    /**
378
     * Build API path
379
     *
380
     * Put anything on the end of the URI that is passed in
381
     *
382
     * @param  string|null  $extra
383
     * @param  array|null  $query
384
     * @return string
385
     */
386
    public function getPath($extra = null, array $query = []): ?string
387
    {
388
        // Start with path to resource without "/" on end
389
        $path = rtrim($this->path, '/');
390
391
        // If have an id, then put it on the end
392
        if ($this->getKey()) {
393
            $path .= '/'.$this->getKey();
394
        }
395
396
        // Stick any extra things on the end
397
        if (! is_null($extra)) {
398
            $path .= '/'.ltrim($extra, '/');
399
        }
400
401
        if (! empty($query)) {
402
            $path .= '?'.http_build_query($this->convertBoolToString($query));
403
        }
404
405
        // If there is a parentModel & not have an id (unless for nested), then prepend parentModel
406
        if (! is_null($this->parentModel) && (! $this->getKey() || $this->isNested())) {
407
            return $this->parentModel->getPath($path);
408
        }
409
410
        return $path;
411
    }
412
413
    /**
414
     * Get a relationship value from a method.
415
     *
416
     * @param  string  $method
417
     *
418
     * @throws LogicException
419
     */
420
    public function getRelationshipFromMethod($method)
421
    {
422
        $relation = $this->{$method}();
423
424
        if (! $relation instanceof Relation) {
425
            $exception_message = is_null($relation)
426
                ? '%s::%s must return a relationship instance, but "null" was returned. Was the "return" keyword used?'
427
                : '%s::%s must return a relationship instance.';
428
429
            throw new LogicException(
430
                sprintf($exception_message, static::class, $method)
431
            );
432
        }
433
434
        return tap(
435
            $relation->getResults(),
436
            function ($results) use ($method) {
437
                $this->setRelation($method, $results);
438
            }
439
        );
440
    }
441
442
    /**
443
     * Name of the wrapping key when response is a collection
444
     *
445
     * If none provided, assume plural version responseKey
446
     */
447
    public function getResponseCollectionKey(): ?string
448
    {
449
        return $this->responseCollectionKey ?? Str::plural($this->getResponseKey());
450
    }
451
452
    /**
453
     * Name of the wrapping key of response
454
     *
455
     * If none provided, assume camelCase of class name
456
     */
457
    public function getResponseKey(): ?string
458
    {
459
        return $this->responseKey ?? Str::camel(class_basename(static::class));
460
    }
461
462
    /**
463
     * Many of the results include collection of related data, so cast it
464
     *
465
     * @param  string  $related
466
     * @param  array  $given
467
     * @param  bool  $reset Some of the values are nested under a property, so peel it off
468
     *
469
     * @throws NoClientException
470
     */
471
    public function givenMany($related, $given, $reset = false): Collection
472
    {
473
        /** @var Model $model */
474
        $model = (new $related([], $this->parentModel))->setClient($this->getClient());
475
476
        return (new Collection($given))->map(
0 ignored issues
show
Bug introduced by
$given of type array is incompatible with the type Illuminate\Contracts\Support\Arrayable expected by parameter $items of Spinen\Halo\Support\Collection::__construct(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

476
        return (new Collection(/** @scrutinizer ignore-type */ $given))->map(
Loading history...
477
            function ($attributes) use ($model, $reset) {
478
                return $model->newFromBuilder($reset ? reset($attributes) : $attributes);
479
            }
480
        );
481
    }
482
483
    /**
484
     * Many of the results include related data, so cast it to object
485
     *
486
     * @param  string  $related
487
     * @param  array  $attributes
488
     * @param  bool  $reset Some of the values are nested under a property, so peel it off
489
     *
490
     * @throws NoClientException
491
     */
492
    public function givenOne($related, $attributes, $reset = false): Model
493
    {
494
        return (new $related([], $this->parentModel))->setClient($this->getClient())
495
                                                     ->newFromBuilder($reset ? reset($attributes) : $attributes);
496
    }
497
498
    /**
499
     * Relationship that makes the model have a collection of another model
500
     *
501
     * @param  string  $related
502
     *
503
     * @throws InvalidRelationshipException
504
     * @throws ModelNotFoundException
505
     * @throws NoClientException
506
     */
507
    public function hasMany($related): HasMany
508
    {
509
        $builder = (new Builder())->setClass($related)
510
                                  ->setClient($this->getClient())
511
                                  ->setParent($this);
512
513
        return new HasMany($builder, $this);
514
    }
515
516
    /**
517
     * Is endpoint nested behind another endpoint
518
     */
519
    public function isNested(): bool
520
    {
521
        return $this->nested ?? false;
522
    }
523
524
    /**
525
     * Convert the object into something JSON serializable.
526
     */
527
    public function jsonSerialize(): array
528
    {
529
        return $this->toArray();
530
    }
531
532
    /**
533
     * Map keys to names that are more standard to our use
534
     */
535
    protected function keyMap(string $key): string
536
    {
537
        // TODO: Is this a good idea?
538
        return match ($key) {
539
            'color' => 'colour',
540
            default => $key,
541
        };
542
    }
543
544
    /**
545
     * Create a new model instance that is existing.
546
     *
547
     * @param  array  $attributes
548
     * @return static
549
     */
550
    public function newFromBuilder($attributes = []): self
551
    {
552
        $model = $this->newInstance([], true);
553
554
        $model->setRawAttributes((array) $attributes, true);
555
556
        return $model;
557
    }
558
559
    /**
560
     * Create a new instance of the given model.
561
     *
562
     * Provides a convenient way for us to generate fresh model instances of this current model.
563
     * It is particularly useful during the hydration of new objects via the builder.
564
     *
565
     * @param  bool  $exists
566
     * @return static
567
     */
568
    public function newInstance(array $attributes = [], $exists = false): self
569
    {
570
        $model = (new static($attributes, $this->parentModel))->setClient($this->client);
571
572
        $model->exists = $exists;
573
574
        return $model;
575
    }
576
577
    /**
578
     * Determine if the given attribute exists.
579
     */
580
    public function offsetExists($offset): bool
581
    {
582
        return ! is_null($this->getAttribute($offset));
583
    }
584
585
    /**
586
     * Get the value for a given offset.
587
     */
588
    public function offsetGet($offset): mixed
589
    {
590
        return $this->getAttribute($offset);
591
    }
592
593
    /**
594
     * Set the value for a given offset.
595
     *
596
     *
597
     * @throws ModelReadonlyException
598
     */
599
    public function offsetSet($offset, $value): void
600
    {
601
        if ($this->readonlyModel) {
602
            throw new ModelReadonlyException();
603
        }
604
605
        $this->setAttribute($offset, $value);
606
    }
607
608
    /**
609
     * Unset the value for a given offset.
610
     */
611
    public function offsetUnset($offset): void
612
    {
613
        unset($this->attributes[$offset], $this->relations[$offset]);
614
    }
615
616
    /**
617
     * Laravel allows control of accessing missing attributes, so we just return false
618
     *
619
     * @return bool
620
     */
621
    public static function preventsAccessingMissingAttributes()
622
    {
623
        return false;
624
    }
625
626
    /**
627
     * Determine if the given relation is loaded.
628
     *
629
     * @param  string  $key
630
     */
631
    public function relationLoaded($key): bool
632
    {
633
        return array_key_exists($key, $this->relations);
634
    }
635
636
    /**
637
     * Laravel allows the resolver to be set at runtime, so we just return null
638
     *
639
     * @param  string  $class
640
     * @param  string  $key
641
     * @return null
642
     */
643
    public function relationResolver($class, $key)
644
    {
645
        return null;
646
    }
647
648
    /**
649
     * Save the model in Halo
650
     *
651
     * @throws NoClientException
652
     * @throws TokenException
653
     */
654
    public function save(): bool
655
    {
656
        // TODO: Make sure that the model supports being saved
657
        if ($this->readonlyModel) {
658
            return false;
659
        }
660
661
        try {
662
            if (! $this->isDirty()) {
663
                return true;
664
            }
665
666
            if ($this->exists) {
667
                // TODO: If we get null from the PUT, throw/handle exception
668
                $response = $this->getClient()
669
                                 ->put($this->getPath(), $this->getDirty());
670
671
                // Record the changes
672
                $this->syncChanges();
673
674
                // Reset the model with the results as we get back the full model
675
                $this->setRawAttributes($response, true);
0 ignored issues
show
Bug introduced by
It seems like $response can also be of type null; however, parameter $attributes of Spinen\Halo\Support\Model::setRawAttributes() does only seem to accept array, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

675
                $this->setRawAttributes(/** @scrutinizer ignore-type */ $response, true);
Loading history...
676
677
                return true;
678
            }
679
680
            $response = $this->getClient()
681
                             ->post($this->getPath(), $this->toArray());
682
683
            $this->exists = true;
684
685
            $this->wasRecentlyCreated = true;
686
687
            // Reset the model with the results as we get back the full model
688
            $this->setRawAttributes($response, true);
689
690
            return true;
691
        } catch (GuzzleException $e) {
692
            // TODO: Do something with the error
693
694
            return false;
695
        }
696
    }
697
698
    /**
699
     * Save the model in Halo, but raise error if fail
700
     *
701
     * @throws NoClientException
702
     * @throws TokenException
703
     * @throws UnableToSaveException
704
     */
705
    public function saveOrFail(): bool
706
    {
707
        if (! $this->save()) {
708
            throw new UnableToSaveException();
709
        }
710
711
        return true;
712
    }
713
714
    /**
715
     * Set the readonly
716
     *
717
     * @param  bool  $readonly
718
     * @return $this
719
     */
720
    public function setReadonly($readonly = true): self
721
    {
722
        $this->readonlyModel = $readonly;
723
724
        return $this;
725
    }
726
727
    /**
728
     * Set the given relationship on the model.
729
     *
730
     * @param  string  $relation
731
     * @return $this
732
     */
733
    public function setRelation($relation, $value): self
734
    {
735
        $this->relations[$relation] = $value;
736
737
        return $this;
738
    }
739
740
    /**
741
     * Convert the model instance to an array.
742
     */
743
    public function toArray(): array
744
    {
745
        return array_merge($this->attributesToArray(), $this->relationsToArray());
746
    }
747
748
    /**
749
     * Convert the model instance to JSON.
750
     *
751
     * @param  int  $options
752
     *
753
     * @throws JsonEncodingException
754
     */
755
    public function toJson($options = 0): string
756
    {
757
        $json = json_encode($this->jsonSerialize(), $options);
758
759
        // @codeCoverageIgnoreStart
760
        if (JSON_ERROR_NONE !== json_last_error()) {
761
            throw JsonEncodingException::forModel($this, json_last_error_msg());
762
        }
763
        // @codeCoverageIgnoreEnd
764
765
        return $json;
766
    }
767
}
768