Completed
Push — master ( 8deba6...86cc20 )
by Colin
11:53 queued 01:54
created

Taggable::renameTag()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

Changes 0
Metric Value
dl 0
loc 4
ccs 0
cts 0
cp 0
rs 10
c 0
b 0
f 0
cc 1
nc 1
nop 2
crap 2
1
<?php namespace Cviebrock\EloquentTaggable;
2
3
use Cviebrock\EloquentTaggable\Events\ModelTagged;
4
use Cviebrock\EloquentTaggable\Events\ModelUntagged;
5
use Cviebrock\EloquentTaggable\Exceptions\NoTagsSpecifiedException;
6
use Cviebrock\EloquentTaggable\Models\Tag;
7
use Cviebrock\EloquentTaggable\Services\TagService;
8
use Illuminate\Database\Eloquent\Builder;
9
use Illuminate\Database\Eloquent\Collection;
10
use Illuminate\Database\Eloquent\Relations\MorphToMany;
11
use Illuminate\Database\Query\JoinClause;
12
13
14
/**
15
 * Class Taggable
16
 *
17
 * @package Cviebrock\EloquentTaggable
18
 */
19
trait Taggable
20
{
21
    /**
22 23
     * Property to control sequence on alias
23
     *
24 23
     * @var int
25 23
     */
26
    private $taggableAliasSequence = 0;
27
28
    /**
29
     * Boot the trait.
30
     *
31
     * Listen for the deleting event of a model, then remove the relation between it and tags
32
     */
33
    protected static function bootTaggable(): void
34
    {
35 23
        static::deleting(function ($model) {
36
            if (!method_exists($model, 'runSoftDelete') || $model->isForceDeleting()) {
37 23
                $model->detag();
38
            }
39 23
        });
40 23
    }
41 23
42
    /**
43 23
     * Get a collection of all tags the model has.
44
     *
45
     * @return \Illuminate\Database\Eloquent\Relations\MorphToMany
46
     */
47
    public function tags(): MorphToMany
48
    {
49
        $model = config('taggable.model');
50
        return $this->morphToMany($model, 'taggable', 'taggable_taggables', 'taggable_id', 'tag_id')
0 ignored issues
show
Bug introduced by
It seems like morphToMany() must be provided by classes using this trait. How about adding it as abstract method to this trait?

This check looks for methods that are used by a trait but not required by it.

To illustrate, let’s look at the following code example

trait Idable {
    public function equalIds(Idable $other) {
        return $this->getId() === $other->getId();
    }
}

The trait Idable provides a method equalsId that in turn relies on the method getId(). If this method does not exist on a class mixing in this trait, the method will fail.

Adding the getId() as an abstract method to the trait will make sure it is available.

Loading history...
51
            ->withTimestamps();
52
    }
53 1
54
    /**
55 1
     * Attach one or multiple tags to the model.
56
     *
57 1
     * @param string|array $tags
58 1
     *
59 1
     * @return self
60
     */
61 1
    public function tag($tags): self
62
    {
63
        $tags = app(TagService::class)->buildTagArray($tags);
64
65
        foreach ($tags as $tagName) {
66
            $this->addOneTag($tagName);
67
            $this->load('tags');
0 ignored issues
show
Bug introduced by
It seems like load() must be provided by classes using this trait. How about adding it as abstract method to this trait?

This check looks for methods that are used by a trait but not required by it.

To illustrate, let’s look at the following code example

trait Idable {
    public function equalIds(Idable $other) {
        return $this->getId() === $other->getId();
    }
}

The trait Idable provides a method equalsId that in turn relies on the method getId(). If this method does not exist on a class mixing in this trait, the method will fail.

Adding the getId() as an abstract method to the trait will make sure it is available.

Loading history...
68
        }
69
70
        event(new ModelTagged($this, $tags));
71 1
72
        return $this;
73 1
    }
74
75
    /**
76
     * Detach one or multiple tags from the model.
77
     *
78
     * @param string|array $tags
79
     *
80
     * @return self
81 2
     */
82
    public function untag($tags): self
83 2
    {
84
        $tags = app(TagService::class)->buildTagArray($tags);
85 2
86
        foreach ($tags as $tagName) {
87
            $this->removeOneTag($tagName);
88
        }
89
90
        event(new ModelUntagged($this, $tags));
91
92
        return $this->load('tags');
0 ignored issues
show
Bug introduced by
It seems like load() must be provided by classes using this trait. How about adding it as abstract method to this trait?

This check looks for methods that are used by a trait but not required by it.

To illustrate, let’s look at the following code example

trait Idable {
    public function equalIds(Idable $other) {
        return $this->getId() === $other->getId();
    }
}

The trait Idable provides a method equalsId that in turn relies on the method getId(). If this method does not exist on a class mixing in this trait, the method will fail.

Adding the getId() as an abstract method to the trait will make sure it is available.

Loading history...
93 23
    }
94
95 23
    /**
96
     * Remove all tags from the model and assign the given ones.
97 23
     *
98 23
     * @param string|array $tags
99 23
     *
100 23
     * @return self
101
     */
102
    public function retag($tags): self
103
    {
104
        return $this->detag()->tag($tags);
105
    }
106
107 1
    /**
108
     * Remove all tags from the model.
109 1
     *
110
     * @return self
111 1
     */
112 1
    public function detag(): self
113 1
    {
114 1
        $this->tags()->sync([]);
115
116
        return $this->load('tags');
0 ignored issues
show
Bug introduced by
It seems like load() must be provided by classes using this trait. How about adding it as abstract method to this trait?

This check looks for methods that are used by a trait but not required by it.

To illustrate, let’s look at the following code example

trait Idable {
    public function equalIds(Idable $other) {
        return $this->getId() === $other->getId();
    }
}

The trait Idable provides a method equalsId that in turn relies on the method getId(). If this method does not exist on a class mixing in this trait, the method will fail.

Adding the getId() as an abstract method to the trait will make sure it is available.

Loading history...
117
    }
118
119
    /**
120
     * Add one tag to the model.
121
     *
122 13
     * @param string $tagName
123
     */
124 2
    protected function addOneTag(string $tagName): void
125 13
    {
126
        /** @var Tag $tag */
127
        $tag = app(TagService::class)->findOrCreate($tagName);
128
        $tagKey = $tag->getKey();
129
130
        if (!$this->getAttribute('tags')->contains($tagKey)) {
0 ignored issues
show
Bug introduced by
It seems like getAttribute() must be provided by classes using this trait. How about adding it as abstract method to this trait?

This check looks for methods that are used by a trait but not required by it.

To illustrate, let’s look at the following code example

trait Idable {
    public function equalIds(Idable $other) {
        return $this->getId() === $other->getId();
    }
}

The trait Idable provides a method equalsId that in turn relies on the method getId(). If this method does not exist on a class mixing in this trait, the method will fail.

Adding the getId() as an abstract method to the trait will make sure it is available.

Loading history...
131
            $this->tags()->attach($tagKey);
132
        }
133 2
    }
134
135 2
    /**
136
     * Remove one tag from the model
137
     *
138
     * @param string $tagName
139
     */
140
    protected function removeOneTag(string $tagName): void
141
    {
142
        $tag = app(TagService::class)->find($tagName);
143 11
144
        if ($tag) {
145 11
            $this->tags()->detach($tag);
146
        }
147
    }
148
149
    /**
150
     * Get all the tags of the model as a delimited string.
151
     *
152
     * @return string
153 2
     */
154
    public function getTagListAttribute(): string
155 2
    {
156
        return app(TagService::class)->makeTagList($this);
157
    }
158
159
    /**
160
     * Get all normalized tags of a model as a delimited string.
161
     *
162
     * @return string
163
     */
164
    public function getTagListNormalizedAttribute(): string
165
    {
166 1
        return app(TagService::class)->makeTagList($this, 'normalized');
167
    }
168 1
169
    /**
170
     * Get all tags of a model as an array.
171 1
     *
172 1
     * @return array
173
     */
174
    public function getTagArrayAttribute(): array
175
    {
176
        return app(TagService::class)->makeTagArray($this);
177
    }
178
179
    /**
180
     * Get all normalized tags of a model as an array.
181
     *
182
     * @return array
183 1
     */
184
    public function getTagArrayNormalizedAttribute(): array
185 1
    {
186
        return app(TagService::class)->makeTagArray($this, 'normalized');
187 1
    }
188
189
    /**
190
     * Determine if a given tag is attached to the model.
191 1
     *
192 1
     * @param Tag|string $tag
193 1
     *
194
     * @return bool
195
     */
196
    public function hasTag($tag): bool
197
    {
198
        if ($tag instanceof Tag) {
199
            $normalized = $tag->getAttribute('normalized');
200
        } else {
201
            $normalized = app(TagService::class)->normalize($tag);
202
        }
203 1
204
        return in_array($normalized, $this->getTagArrayNormalizedAttribute(), true);
205 1
    }
206
207
    /**
208
     * Query scope for models that have all of the given tags.
209
     *
210
     * @param Builder $query
211
     * @param array|string $tags
212
     *
213 2
     * @return Builder
214
     * @throws \Cviebrock\EloquentTaggable\Exceptions\NoTagsSpecifiedException
215
     * @throws \ErrorException
216 2
     */
217
    public function scopeWithAllTags(Builder $query, $tags): Builder
218 2
    {
219
        /** @var TagService $service */
220
        $service = app(TagService::class);
221
        $normalized = $service->buildTagArrayNormalized($tags);
222
223
        // If there are no tags specified, then there
224
        // can't be any results so short-circuit
225
        if (count($normalized) === 0) {
226 1
            if (config('taggable.throwEmptyExceptions')) {
227
                throw new NoTagsSpecifiedException('Empty tag data passed to withAllTags scope.');
228 1
            }
229
230
            return $query->where(\DB::raw(1), 0);
231
        }
232
233
        $tagKeys = $service->getTagModelKeys($normalized);
234
235
        // If some of the tags specified don't exist, then there can't
236
        // be any models with all the tags, so so short-circuit
237
        if (count($tagKeys) !== count($normalized)) {
238
            return $query->where(\DB::raw(1), 0);
239
        }
240
241
        $alias = $this->taggableCreateNewAlias(__FUNCTION__);
242
        $morphTagKeyName = $this->getQualifiedRelatedPivotKeyNameWithAlias($alias);
243
244
        return $this->prepareTableJoin($query, 'inner', $alias)
245
            ->whereIn($morphTagKeyName, $tagKeys)
246
            ->havingRaw("COUNT(DISTINCT {$morphTagKeyName}) = ?", [count($tagKeys)]);
247
    }
248
249
    /**
250
     * Query scope for models that have any of the given tags.
251
     *
252
     * @param Builder $query
253
     * @param array|string $tags
254
     *
255
     * @return Builder
256
     * @throws \Cviebrock\EloquentTaggable\Exceptions\NoTagsSpecifiedException
257
     * @throws \ErrorException
258
     */
259
    public function scopeWithAnyTags(Builder $query, $tags): Builder
260
    {
261
        /** @var TagService $service */
262
        $service = app(TagService::class);
263
        $normalized = $service->buildTagArrayNormalized($tags);
264
265
        // If there are no tags specified, then there is
266
        // no filtering to be done so short-circuit
267
        if (count($normalized) === 0) {
268
            if (config('taggable.throwEmptyExceptions')) {
269
                throw new NoTagsSpecifiedException('Empty tag data passed to withAnyTags scope.');
270
            }
271
272
            return $query->where(\DB::raw(1), 0);
273
        }
274
275
        $tagKeys = $service->getTagModelKeys($normalized);
276
277
        $alias = $this->taggableCreateNewAlias(__FUNCTION__);
278
        $morphTagKeyName = $this->getQualifiedRelatedPivotKeyNameWithAlias($alias);
279
280
        return $this->prepareTableJoin($query, 'inner', $alias)
281
            ->whereIn($morphTagKeyName, $tagKeys);
282
    }
283
284
    /**
285
     * Query scope for models that have any tag.
286
     *
287
     * @param Builder $query
288
     *
289
     * @return Builder
290
     */
291
    public function scopeIsTagged(Builder $query): Builder
292
    {
293
        $alias = $this->taggableCreateNewAlias(__FUNCTION__);
294
        return $this->prepareTableJoin($query, 'inner', $alias);
295
    }
296
297
    /**
298
     * Query scope for models that do not have all of the given tags.
299
     *
300
     * @param Builder $query
301
     * @param string|array $tags
302
     * @param bool $includeUntagged
303
     *
304
     * @return Builder
305
     * @throws \ErrorException
306
     */
307
    public function scopeWithoutAllTags(Builder $query, $tags, bool $includeUntagged = false): Builder
308
    {
309
        /** @var TagService $service */
310
        $service = app(TagService::class);
311
        $normalized = $service->buildTagArrayNormalized($tags);
312
        $tagKeys = $service->getTagModelKeys($normalized);
313
        $tagKeyList = implode(',', $tagKeys);
314
315
        $alias = $this->taggableCreateNewAlias(__FUNCTION__);
316
        $morphTagKeyName = $this->getQualifiedRelatedPivotKeyNameWithAlias($alias);
317
318
        $query = $this->prepareTableJoin($query, 'left', $alias)
319
            ->havingRaw("COUNT(DISTINCT CASE WHEN ({$morphTagKeyName} IN ({$tagKeyList})) THEN {$morphTagKeyName} ELSE NULL END) < ?",
320
                [count($tagKeys)]);
321
322
        if (!$includeUntagged) {
323
            $query->havingRaw("COUNT(DISTINCT {$morphTagKeyName}) > 0");
324
        }
325
326
        return $query;
327
    }
328
329
    /**
330
     * Query scope for models that do not have any of the given tags.
331
     *
332
     * @param Builder $query
333
     * @param string|array $tags
334
     * @param bool $includeUntagged
335
     *
336
     * @return Builder
337
     * @throws \ErrorException
338
     */
339
    public function scopeWithoutAnyTags(Builder $query, $tags, bool $includeUntagged = false): Builder
340
    {
341
        /** @var TagService $service */
342
        $service = app(TagService::class);
343
        $normalized = $service->buildTagArrayNormalized($tags);
344
        $tagKeys = $service->getTagModelKeys($normalized);
345
        $tagKeyList = implode(',', $tagKeys);
346
347
        $alias = $this->taggableCreateNewAlias(__FUNCTION__);
348
        $morphTagKeyName = $this->getQualifiedRelatedPivotKeyNameWithAlias($alias);
349
350
        $query = $this->prepareTableJoin($query, 'left', $alias)
351
            ->havingRaw("COUNT(DISTINCT CASE WHEN ({$morphTagKeyName} IN ({$tagKeyList})) THEN {$morphTagKeyName} ELSE NULL END) = 0");
352
353
        if (!$includeUntagged) {
354
            $query->havingRaw("COUNT(DISTINCT {$morphTagKeyName}) > 0");
355
        }
356
357
        return $query;
358
    }
359
360
    /**
361
     * Query scope for models that does not have have any tags.
362
     *
363
     * @param Builder $query
364
     *
365
     * @return Builder
366
     */
367
    public function scopeIsNotTagged(Builder $query): Builder
368
    {
369
        $alias = $this->taggableCreateNewAlias(__FUNCTION__);
370
        $morphForeignKeyName = $this->getQualifiedForeignPivotKeyNameWithAlias($alias);
371
372
        return $this->prepareTableJoin($query, 'left', $alias)
373
            ->havingRaw("COUNT(DISTINCT {$morphForeignKeyName}) = 0");
374
    }
375
376
    /**
377
     * @param Builder $query
378
     * @param string $joinType
379
     *
380
     * @return Builder
381
     */
382
    private function prepareTableJoin(Builder $query, string $joinType, string $alias): Builder
383
    {
384
        $morphTable = $this->tags()->getTable();
385
        $morphTableAlias = $morphTable.'_'.$alias;
386
387
        $modelKeyName = $this->getQualifiedKeyName();
0 ignored issues
show
Bug introduced by
It seems like getQualifiedKeyName() must be provided by classes using this trait. How about adding it as abstract method to this trait?

This check looks for methods that are used by a trait but not required by it.

To illustrate, let’s look at the following code example

trait Idable {
    public function equalIds(Idable $other) {
        return $this->getId() === $other->getId();
    }
}

The trait Idable provides a method equalsId that in turn relies on the method getId(). If this method does not exist on a class mixing in this trait, the method will fail.

Adding the getId() as an abstract method to the trait will make sure it is available.

Loading history...
388
        $morphForeignKeyName = $this->getQualifiedForeignPivotKeyNameWithAlias($alias);
389
390
        $morphTypeName = $morphTableAlias.'.'. $this->tags()->getMorphType();
391
        $morphClass = $this->tags()->getMorphClass();
392
393
        $closure = function(JoinClause $join) use ($modelKeyName, $morphForeignKeyName, $morphTypeName, $morphClass) {
394
            $join->on($modelKeyName, $morphForeignKeyName)
395
                ->where($morphTypeName, $morphClass);
396
        };
397
398
        return $query
399
            ->select($this->getTable() . '.*')
0 ignored issues
show
Bug introduced by
It seems like getTable() must be provided by classes using this trait. How about adding it as abstract method to this trait?

This check looks for methods that are used by a trait but not required by it.

To illustrate, let’s look at the following code example

trait Idable {
    public function equalIds(Idable $other) {
        return $this->getId() === $other->getId();
    }
}

The trait Idable provides a method equalsId that in turn relies on the method getId(). If this method does not exist on a class mixing in this trait, the method will fail.

Adding the getId() as an abstract method to the trait will make sure it is available.

Loading history...
400
            ->join($morphTable.' as '.$morphTableAlias, $closure, null, null, $joinType)
401
            ->groupBy($modelKeyName);
402
    }
403
404
    /**
405
     * Get a collection of all the tag models used for the called class.
406
     *
407
     * @return Collection
408
     */
409
    public static function allTagModels(): Collection
410
    {
411
        return app(TagService::class)->getAllTags(static::class);
412
    }
413
414
    /**
415
     * Get an array of all tags used for the called class.
416
     *
417
     * @return array
418
     */
419
    public static function allTags(): array
420
    {
421
        /** @var \Illuminate\Database\Eloquent\Collection $tags */
422
        $tags = static::allTagModels();
423
424
        return $tags->pluck('name')->sort()->all();
425
    }
426
427
    /**
428
     * Get all the tags used for the called class as a delimited string.
429
     *
430
     * @return string
431
     */
432
    public static function allTagsList(): string
433
    {
434
        return app(TagService::class)->joinList(static::allTags());
435
    }
436
437
    /**
438
     * Rename one the tags for the called class.
439
     *
440
     * @param string $oldTag
441
     * @param string $newTag
442
     *
443
     * @return int
444
     */
445
    public static function renameTag(string $oldTag, string $newTag): int
446
    {
447
        return app(TagService::class)->renameTags($oldTag, $newTag, static::class);
448
    }
449
450
    /**
451
     * Get the most popular tags for the called class.
452
     *
453
     * @param int $limit
454
     * @param int $minCount
455
     *
456
     * @return array
457
     */
458
    public static function popularTags(int $limit = null, int $minCount = 1): array
459
    {
460
        /** @var Collection $tags */
461
        $tags = app(TagService::class)->getPopularTags($limit, static::class, $minCount);
462
463
        return $tags->pluck('taggable_count', 'name')->all();
464
    }
465
466
    /**
467
     * Get the most popular tags for the called class.
468
     *
469
     * @param int $limit
470
     * @param int $minCount
471
     *
472
     * @return array
473
     */
474
    public static function popularTagsNormalized(int $limit = null, int $minCount = 1): array
475
    {
476
        /** @var Collection $tags */
477
        $tags = app(TagService::class)->getPopularTags($limit, static::class, $minCount);
478
479
        return $tags->pluck('taggable_count', 'normalized')->all();
480
    }
481
482
    /**
483
     * Returns the Related Pivot Key Name with the table alias.
484
     *
485
     * @param string $alias
486
     *
487
     * @return string
488
     */
489
    private function getQualifiedRelatedPivotKeyNameWithAlias(string $alias): string
490
    {
491
        $morph = $this->tags();
492
493
        return $morph->getTable() . '_' . $alias .
494
            '.' . $morph->getRelatedPivotKeyName();
495
    }
496
497
    /**
498
     * Returns the Foreign Pivot Key Name with the table alias.
499
     *
500
     * @param string $alias
501
     *
502
     * @return string
503
     */
504
    private function getQualifiedForeignPivotKeyNameWithAlias(string $alias): string
505
    {
506
        $morph = $this->tags();
507
508
        return $morph->getTable() . '_' . $alias .
509
            '.' . $morph->getForeignPivotKeyName();
510
    }
511
512
    /**
513
     * Create a new alias to use on scopes to be able to combine many scopes
514
     *
515
     * @param string $scope
516
     * 
517
     * @return string
518
     */
519
    private function taggableCreateNewAlias(string $scope)
520
    {
521
        $this->taggableAliasSequence++;
522
        $alias = strtolower($scope) . '_' . $this->taggableAliasSequence;
523
524
        return $alias;
525
    }
526
}
527