Model   F
last analyzed

Complexity

Total Complexity 61

Size/Duplication

Total Lines 743
Duplicated Lines 0 %

Test Coverage

Coverage 100%

Importance

Changes 5
Bugs 0 Features 0
Metric Value
wmc 61
eloc 136
c 5
b 0
f 0
dl 0
loc 743
ccs 157
cts 157
cp 1
rs 3.52

38 Methods

Rating   Name   Duplication   Size   Complexity  
A getKeyName() 0 3 1
A __toString() 0 3 1
A fill() 0 7 2
A assumeForeignKey() 0 3 1
A __unset() 0 3 1
A getKey() 0 3 1
A __isset() 0 3 1
B getPath() 0 26 7
A getKeyType() 0 3 1
A __get() 0 3 1
A delete() 0 16 3
A getIncrementing() 0 3 1
A __set() 0 7 2
A asDateTime() 0 7 3
A __construct() 0 11 1
A isNested() 0 3 1
A toArray() 0 3 1
A relationLoaded() 0 3 1
A offsetUnset() 0 3 1
A childOf() 0 9 1
A belongsTo() 0 8 1
A jsonSerialize() 0 3 1
A newInstance() 0 7 1
A setRelation() 0 5 1
A givenOne() 0 4 2
A offsetExists() 0 3 1
A offsetGet() 0 3 1
A offsetSet() 0 7 2
A saveOrFail() 0 7 2
A getResponseCollectionKey() 0 3 1
A toJson() 0 11 2
A hasMany() 0 7 1
A getResponseKey() 0 3 1
A setReadonly() 0 5 1
A newFromBuilder() 0 7 1
A givenMany() 0 8 2
A save() 0 40 5
A getRelationshipFromMethod() 0 18 3

How to fix   Complexity   

Complex Class

Complex classes like Model often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use Model, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
namespace Spinen\ClickUp\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 JsonSerializable;
17
use LogicException;
18
use Spinen\ClickUp\Concerns\HasClient;
19
use Spinen\ClickUp\Exceptions\InvalidRelationshipException;
20
use Spinen\ClickUp\Exceptions\ModelNotFoundException;
21
use Spinen\ClickUp\Exceptions\ModelReadonlyException;
22
use Spinen\ClickUp\Exceptions\NoClientException;
23
use Spinen\ClickUp\Exceptions\TokenException;
24
use Spinen\ClickUp\Exceptions\UnableToSaveException;
25
use Spinen\ClickUp\Support\Relations\BelongsTo;
26
use Spinen\ClickUp\Support\Relations\ChildOf;
27
use Spinen\ClickUp\Support\Relations\HasMany;
28
use Spinen\ClickUp\Support\Relations\Relation;
29
30
/**
31
 * Class Model
32
 *
33
 * @package Spinen\ClickUp\Support
34
 */
35
abstract class Model implements Arrayable, ArrayAccess, Jsonable, JsonSerializable
36
{
37
    use HasAttributes {
38
        asDateTime as originalAsDateTime;
39
    }
40
    use HasClient, HasTimestamps, HidesAttributes;
41
42
    /**
43
     * Indicates if the model exists.
44
     *
45
     * @var bool
46
     */
47
    public $exists = false;
48
49
    /**
50
     * Indicates if the IDs are auto-incrementing.
51
     *
52
     * @var bool
53
     */
54
    public $incrementing = false;
55
56
    /**
57
     * The "type" of the primary key ID.
58
     *
59
     * @var string
60
     */
61
    protected $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
     * @var bool
71
     */
72
    protected $nested = false;
73
74
    /**
75
     * Optional parentModel instance
76
     *
77
     * @var Model $parentModel
78
     */
79
    public $parentModel;
80
81
    /**
82
     * Path to API endpoint.
83
     *
84
     * @var string
85
     */
86
    protected $path = null;
87
88
    /**
89
     * The primary key for the model.
90
     *
91
     * @var string
92
     */
93
    protected $primaryKey = 'id';
94
95
    /**
96
     * Is the model readonly?
97
     *
98
     * @var bool
99
     */
100
    protected $readonlyModel = false;
101
102
    /**
103
     * The loaded relationships for the model.
104
     *
105
     * @var array
106
     */
107
    protected $relations = [];
108
109
    /**
110
     * Some of the responses have the collections under a property
111
     *
112
     * @var string|null
113
     */
114
    protected $responseCollectionKey = null;
115
116
    /**
117
     * Some of the responses have the data under a property
118
     *
119
     * @var string|null
120
     */
121
    protected $responseKey = null;
122
123
    /**
124
     * Are timestamps in milliseconds?
125
     *
126
     * @var boolean
127
     */
128
    protected $timestampsInMilliseconds = true;
129
130
    /**
131
     * The name of the "created at" column.
132
     *
133
     * @var string
134
     */
135
    const CREATED_AT = 'created_at';
136
137
    /**
138
     * The name of the "updated at" column.
139
     *
140
     * @var string
141
     */
142
    const UPDATED_AT = 'updated_at';
143
144
    /**
145
     * Model constructor.
146
     *
147
     * @param array|null $attributes
148
     * @param Model|null $parentModel
149
     */
150 198
    public function __construct(array $attributes = [], Model $parentModel = null)
151
    {
152
        // All dates from API comes as epoch with milliseconds
153 198
        $this->dateFormat = 'Uv';
154
        // None of these models will use timestamps, but need the date casting
155 198
        $this->timestamps = false;
156
157 198
        $this->syncOriginal();
158
159 198
        $this->fill($attributes);
160 198
        $this->parentModel = $parentModel;
161 198
    }
162
163
    /**
164
     * Dynamically retrieve attributes on the model.
165
     *
166
     * @param string $key
167
     *
168
     * @return mixed
169
     */
170 94
    public function __get($key)
171
    {
172 94
        return $this->getAttribute($key);
173
    }
174
175
    /**
176
     * Determine if an attribute or relation exists on the model.
177
     *
178
     * @param string $key
179
     *
180
     * @return bool
181
     */
182 2
    public function __isset($key)
183
    {
184 2
        return $this->offsetExists($key);
185
    }
186
187
    /**
188
     * Dynamically set attributes on the model.
189
     *
190
     * @param string $key
191
     * @param mixed $value
192
     *
193
     * @return void
194
     * @throws ModelReadonlyException
195
     */
196 79
    public function __set($key, $value)
197
    {
198 79
        if ($this->readonlyModel) {
199 1
            throw new ModelReadonlyException();
200
        }
201
202 78
        $this->setAttribute($key, $value);
203 78
    }
204
205
    /**
206
     * Convert the model to its string representation.
207
     *
208
     * @return string
209
     */
210 1
    public function __toString()
211
    {
212 1
        return $this->toJson();
213
    }
214
215
    /**
216
     * Unset an attribute on the model.
217
     *
218
     * @param string $key
219
     *
220
     * @return void
221
     */
222 2
    public function __unset($key)
223
    {
224 2
        $this->offsetUnset($key);
225 2
    }
226
227
    /**
228
     * Return a timestamp as DateTime object.
229
     *
230
     * @param  mixed  $value
231
     * @return Carbon
232
     */
233 8
    protected function asDateTime($value)
234
    {
235 8
        if (is_numeric($value) && $this->timestampsInMilliseconds) {
236 1
            return Date::createFromTimestampMs($value);
237
        }
238
239 7
        return $this->originalAsDateTime($value);
240
    }
241
242
    /**
243
     * Assume foreign key
244
     *
245
     * @param string $related
246
     *
247
     * @return string
248
     */
249 30
    protected function assumeForeignKey($related): string
250
    {
251 30
        return Str::snake((new $related())->getResponseKey()) . '_id';
252
    }
253
254
    /**
255
     * Relationship that makes the model belongs to another model
256
     *
257
     * @param string $related
258
     * @param string|null $foreignKey
259
     *
260
     * @return BelongsTo
261
     * @throws InvalidRelationshipException
262
     * @throws ModelNotFoundException
263
     * @throws NoClientException
264
     */
265 8
    public function belongsTo($related, $foreignKey = null): BelongsTo
266
    {
267 8
        $foreignKey = $foreignKey ?? $this->assumeForeignKey($related);
268
269 8
        $builder = (new Builder())->setClass($related)
270 8
                                  ->setClient($this->getClient());
271
272 8
        return new BelongsTo($builder, $this, $foreignKey);
273
    }
274
275
    /**
276
     * Relationship that makes the model child to another model
277
     *
278
     * @param string $related
279
     * @param string|null $foreignKey
280
     *
281
     * @return ChildOf
282
     * @throws InvalidRelationshipException
283
     * @throws ModelNotFoundException
284
     * @throws NoClientException
285
     */
286 25
    public function childOf($related, $foreignKey = null): ChildOf
287
    {
288 25
        $foreignKey = $foreignKey ?? $this->assumeForeignKey($related);
289
290 25
        $builder = (new Builder())->setClass($related)
291 25
                                  ->setClient($this->getClient())
292 25
                                  ->setParent($this);
293
294 25
        return new ChildOf($builder, $this, $foreignKey);
295
    }
296
297
    /**
298
     * Delete the model from ClickUp
299
     *
300
     * @return boolean
301
     * @throws NoClientException
302
     * @throws TokenException
303
     */
304 3
    public function delete(): bool
305
    {
306
        // TODO: Make sure that the model supports being deleted
307 3
        if ($this->readonlyModel) {
308 1
            return false;
309
        }
310
311
        try {
312 2
            $this->getClient()
313 2
                 ->delete($this->getPath());
314
315 1
            return true;
316 1
        } catch (GuzzleException $e) {
317
            // TODO: Do something with the error
318
319 1
            return false;
320
        }
321
    }
322
323
    /**
324
     * Fill the model with the supplied properties
325
     *
326
     * @param array $attributes
327
     *
328
     * @return $this
329
     */
330 198
    public function fill(array $attributes = []): self
331
    {
332 198
        foreach ($attributes as $attribute => $value) {
333 59
            $this->setAttribute($attribute, $value);
334
        }
335
336 198
        return $this;
337
    }
338
339
    /**
340
     * Get the value indicating whether the IDs are incrementing.
341
     *
342
     * @return bool
343
     */
344 130
    public function getIncrementing(): bool
345
    {
346 130
        return $this->incrementing;
347
    }
348
349
    /**
350
     * Get the value of the model's primary key.
351
     *
352
     * @return mixed
353
     */
354 23
    public function getKey()
355
    {
356 23
        return $this->getAttribute($this->getKeyName());
357
    }
358
359
    /**
360
     * Get the primary key for the model.
361
     *
362
     * @return string
363
     */
364 57
    public function getKeyName(): string
365
    {
366 57
        return $this->primaryKey;
367
    }
368
369
    /**
370
     * Get the auto-incrementing key type.
371
     *
372
     * @return string
373
     */
374 1
    public function getKeyType(): string
375
    {
376 1
        return $this->keyType;
377
    }
378
379
    /**
380
     * Build API path
381
     *
382
     * Put anything on the end of the URI that is passed in
383
     *
384
     * @param string|null $extra
385
     * @param array|null $query
386
     *
387
     * @return string
388
     */
389 22
    public function getPath($extra = null, array $query = []): ?string
390
    {
391
        // Start with path to resource without "/" on end
392 22
        $path = rtrim($this->path, '/');
393
394
        // If have an id, then put it on the end
395 22
        if ($this->getKey()) {
396 6
            $path .= '/' . $this->getKey();
397
        }
398
399
        // Stick any extra things on the end
400 22
        if (!is_null($extra)) {
401 4
            $path .= '/' . ltrim($extra, '/');
402
        }
403
404
        // Convert query to querystring format and put on the end
405 22
        if (!empty($query)) {
406 2
            $path .= '?' . http_build_query($query);
407
        }
408
409
        // If there is a parentModel & not have an id (unless for nested), then prepend parentModel
410 22
        if (!is_null($this->parentModel) && (!$this->getKey() || $this->isNested())) {
411 4
            return $this->parentModel->getPath($path);
412
        }
413
414 22
        return $path;
415
    }
416
417
    /**
418
     * Get a relationship value from a method.
419
     *
420
     * @param string $method
421
     *
422
     * @return mixed
423
     *
424
     * @throws LogicException
425
     */
426 5
    public function getRelationshipFromMethod($method)
427
    {
428 5
        $relation = $this->{$method}();
429
430 5
        if (!$relation instanceof Relation) {
431 2
            $exception_message = is_null($relation)
432 1
                ? '%s::%s must return a relationship instance, but "null" was returned. Was the "return" keyword used?'
433 2
                : '%s::%s must return a relationship instance.';
434
435 2
            throw new LogicException(
436 2
                sprintf($exception_message, static::class, $method)
437
            );
438
        }
439
440 3
        return tap(
441 3
            $relation->getResults(),
442
            function ($results) use ($method) {
443 3
                $this->setRelation($method, $results);
444 3
            }
445
        );
446
    }
447
448
    /**
449
     * Name of the wrapping key when response is a collection
450
     *
451
     * If none provided, assume plural version responseKey
452
     *
453
     * @return string|null
454
     */
455 14
    public function getResponseCollectionKey(): ?string
456
    {
457 14
        return $this->responseCollectionKey ?? Str::plural($this->getResponseKey());
458
    }
459
460
    /**
461
     * Name of the wrapping key of response
462
     *
463
     * If none provided, assume camelCase of class name
464
     *
465
     * @return string|null
466
     */
467 46
    public function getResponseKey(): ?string
468
    {
469 46
        return $this->responseKey ?? Str::camel(class_basename(static::class));
470
    }
471
472
    /**
473
     * Many of the results include collection of related data, so cast it
474
     *
475
     * @param string $related
476
     * @param array $given
477
     * @param bool $reset Some of the values are nested under a property, so peel it off
478
     *
479
     * @return Collection
480
     * @throws NoClientException
481
     */
482 20
    public function givenMany($related, $given, $reset = false): Collection
483
    {
484
        /** @var Model $model */
485 20
        $model = (new $related([], $this->parentModel))->setClient($this->getClient());
486
487 20
        return (new Collection($given))->map(
488
            function ($attributes) use ($model, $reset) {
489 1
                return $model->newFromBuilder($reset ? reset($attributes) : $attributes);
490 20
            }
491
        );
492
    }
493
494
    /**
495
     * Many of the results include related data, so cast it to object
496
     *
497
     * @param string $related
498
     * @param array $attributes
499
     * @param bool $reset Some of the values are nested under a property, so peel it off
500
     *
501
     * @return Model
502
     * @throws NoClientException
503
     */
504 16
    public function givenOne($related, $attributes, $reset = false): Model
505
    {
506 16
        return (new $related([], $this->parentModel))->setClient($this->getClient())
507 16
                                                     ->newFromBuilder($reset ? reset($attributes) : $attributes);
508
    }
509
510
    /**
511
     * Relationship that makes the model have a collection of another model
512
     *
513
     * @param string $related
514
     *
515
     * @return HasMany
516
     * @throws InvalidRelationshipException
517
     * @throws ModelNotFoundException
518
     * @throws NoClientException
519
     */
520 27
    public function hasMany($related): HasMany
521
    {
522 27
        $builder = (new Builder())->setClass($related)
523 27
                                  ->setClient($this->getClient())
524 27
                                  ->setParent($this);
525
526 27
        return new HasMany($builder, $this);
527
    }
528
529
    /**
530
     * Is endpoint nested behind another endpoint
531
     *
532
     * @return bool
533
     */
534 2
    public function isNested(): bool
535
    {
536 2
        return $this->nested ?? false;
537
    }
538
539
    /**
540
     * Convert the object into something JSON serializable.
541
     *
542
     * @return array
543
     */
544 1
    public function jsonSerialize(): array
545
    {
546 1
        return $this->toArray();
547
    }
548
549
    /**
550
     * Create a new model instance that is existing.
551
     *
552
     * @param array $attributes
553
     *
554
     * @return static
555
     */
556 24
    public function newFromBuilder($attributes = []): self
557
    {
558 24
        $model = $this->newInstance([], true);
559
560 24
        $model->setRawAttributes((array)$attributes, true);
561
562 24
        return $model;
563
    }
564
565
    /**
566
     * Create a new instance of the given model.
567
     *
568
     * Provides a convenient way for us to generate fresh model instances of this current model.
569
     * It is particularly useful during the hydration of new objects via the builder.
570
     *
571
     * @param array $attributes
572
     * @param bool $exists
573
     *
574
     * @return static
575
     */
576 29
    public function newInstance(array $attributes = [], $exists = false): self
577
    {
578 29
        $model = (new static($attributes, $this->parentModel))->setClient($this->client);
579
580 29
        $model->exists = $exists;
581
582 29
        return $model;
583
    }
584
585
    /**
586
     * Determine if the given attribute exists.
587
     *
588
     * @param mixed $offset
589
     *
590
     * @return bool
591
     */
592 5
    public function offsetExists($offset)
593
    {
594 5
        return !is_null($this->getAttribute($offset));
595
    }
596
597
    /**
598
     * Get the value for a given offset.
599
     *
600
     * @param mixed $offset
601
     *
602
     * @return mixed
603
     */
604 2
    public function offsetGet($offset)
605
    {
606 2
        return $this->getAttribute($offset);
607
    }
608
609
    /**
610
     * Set the value for a given offset.
611
     *
612
     * @param mixed $offset
613
     * @param mixed $value
614
     *
615
     * @return void
616
     * @throws ModelReadonlyException
617
     */
618 2
    public function offsetSet($offset, $value): void
619
    {
620 2
        if ($this->readonlyModel) {
621 1
            throw new ModelReadonlyException();
622
        }
623
624 1
        $this->setAttribute($offset, $value);
625 1
    }
626
627
    /**
628
     * Unset the value for a given offset.
629
     *
630
     * @param mixed $offset
631
     *
632
     * @return void
633
     */
634 3
    public function offsetUnset($offset): void
635
    {
636 3
        unset($this->attributes[$offset], $this->relations[$offset]);
637 3
    }
638
639
    /**
640
     * Determine if the given relation is loaded.
641
     *
642
     * @param string $key
643
     *
644
     * @return bool
645
     */
646 60
    public function relationLoaded($key): bool
647
    {
648 60
        return array_key_exists($key, $this->relations);
649
    }
650
651
    /**
652
     * Save the model in ClickUp
653
     *
654
     * @return bool
655
     * @throws NoClientException
656
     * @throws TokenException
657
     */
658 7
    public function save(): bool
659
    {
660
        // TODO: Make sure that the model supports being saved
661 7
        if ($this->readonlyModel) {
662 1
            return false;
663
        }
664
665
        try {
666 6
            if (!$this->isDirty()) {
667 1
                return true;
668
            }
669
670 5
            if ($this->exists) {
671
                // TODO: If we get null from the PUT, throw/handle exception
672 1
                $response = $this->getClient()
673 1
                                 ->put($this->getPath(), $this->getDirty());
674
675
                // Record the changes
676 1
                $this->syncChanges();
677
678
                // Reset the model with the results as we get back the full model
679 1
                $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\ClickUp\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

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