Completed
Push — master ( 1df6a1...c54487 )
by Colin
05:49
created

Taggable::untagById()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 7

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 3
CRAP Score 1

Importance

Changes 0
Metric Value
dl 0
loc 7
ccs 3
cts 3
cp 1
rs 10
c 0
b 0
f 0
cc 1
nc 1
nop 1
crap 1
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
        $table = config('taggable.tables.taggable_taggables', 'taggable_taggables');
51
        return $this->morphToMany($model, 'taggable', $table, '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...
52
            ->withTimestamps();
53 1
    }
54
55 1
    /**
56
     * Attach one or multiple tags to the model.
57 1
     *
58 1
     * @param string|array $tags
59 1
     *
60
     * @return self
61 1
     */
62
    public function tag($tags): self
63
    {
64
        $tags = app(TagService::class)->buildTagArray($tags);
65
66
        foreach ($tags as $tagName) {
67
            $this->addOneTag($tagName);
68
            $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...
69
        }
70
71 1
        event(new ModelTagged($this, $tags));
72
73 1
        return $this;
74
    }
75
76
    /**
77
     * Attach one or more existing tags to a model,
78
     * identified by the tag's IDs.
79
     *
80
     * @param int|int[] $ids
81 2
     *
82
     * @return $this
83 2
     */
84
    public function tagById($ids): self
85 2
    {
86
        $tags = app(TagService::class)->findByIds($ids);
87
        $names = $tags->pluck('name')->all();
88
89
        return $this->tag($names);
90
    }
91
92
    /**
93 23
     * Detach one or multiple tags from the model.
94
     *
95 23
     * @param string|array $tags
96
     *
97 23
     * @return self
98 23
     */
99 23
    public function untag($tags): self
100 23
    {
101
        $tags = app(TagService::class)->buildTagArray($tags);
102
103
        foreach ($tags as $tagName) {
104
            $this->removeOneTag($tagName);
105
        }
106
107 1
        event(new ModelUntagged($this, $tags));
108
109 1
        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...
110
    }
111 1
112 1
    /**
113 1
     * Detach one or more existing tags to a model,
114 1
     * identified by the tag's IDs.
115
     *
116
     * @param int|int[] $ids
117
     *
118
     * @return $this
119
     */
120
    public function untagById($ids): self
121
    {
122 13
        $tags = app(TagService::class)->findByIds($ids);
123
        $names = $tags->pluck('name')->all();
124 2
125 13
        return $this->untag($names);
126
    }
127
128
    /**
129
     * Remove all tags from the model and assign the given ones.
130
     *
131
     * @param string|array $tags
132
     *
133 2
     * @return self
134
     */
135 2
    public function retag($tags): self
136
    {
137
        return $this->detag()->tag($tags);
138
    }
139
140
    /**
141
     * Remove all tags from the model and assign the given ones by ID.
142
     *
143 11
     * @param int|int[] $ids
144
     *
145 11
     * @return self
146
     */
147
    public function retagById($ids): self
148
    {
149
        return $this->detag()->tagById($ids);
150
    }
151
152
    /**
153 2
     * Remove all tags from the model.
154
     *
155 2
     * @return self
156
     */
157
    public function detag(): self
158
    {
159
        $this->tags()->sync([]);
160
161
        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...
162
    }
163
164
    /**
165
     * Add one tag to the model.
166 1
     *
167
     * @param string $tagName
168 1
     */
169
    protected function addOneTag(string $tagName): void
170
    {
171 1
        /** @var Tag $tag */
172 1
        $tag = app(TagService::class)->findOrCreate($tagName);
173
        $tagKey = $tag->getKey();
174
175
        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...
176
            $this->tags()->attach($tagKey);
177
        }
178
    }
179
180
    /**
181
     * Remove one tag from the model
182
     *
183 1
     * @param string $tagName
184
     */
185 1
    protected function removeOneTag(string $tagName): void
186
    {
187 1
        $tag = app(TagService::class)->find($tagName);
188
189
        if ($tag) {
190
            $this->tags()->detach($tag);
191 1
        }
192 1
    }
193 1
194
    /**
195
     * Get all the tags of the model as a delimited string.
196
     *
197
     * @return string
198
     */
199
    public function getTagListAttribute(): string
200
    {
201
        return app(TagService::class)->makeTagList($this);
202
    }
203 1
204
    /**
205 1
     * Get all normalized tags of a model as a delimited string.
206
     *
207
     * @return string
208
     */
209
    public function getTagListNormalizedAttribute(): string
210
    {
211
        return app(TagService::class)->makeTagList($this, 'normalized');
212
    }
213 2
214
    /**
215
     * Get all tags of a model as an array.
216 2
     *
217
     * @return array
218 2
     */
219
    public function getTagArrayAttribute(): array
220
    {
221
        return app(TagService::class)->makeTagArray($this);
222
    }
223
224
    /**
225
     * Get all normalized tags of a model as an array.
226 1
     *
227
     * @return array
228 1
     */
229
    public function getTagArrayNormalizedAttribute(): array
230
    {
231
        return app(TagService::class)->makeTagArray($this, 'normalized');
232
    }
233
234
    /**
235
     * Determine if a given tag is attached to the model.
236
     *
237
     * @param Tag|string $tag
238
     *
239
     * @return bool
240
     */
241
    public function hasTag($tag): bool
242
    {
243
        if ($tag instanceof Tag) {
244
            $normalized = $tag->getAttribute('normalized');
245
        } else {
246
            $normalized = app(TagService::class)->normalize($tag);
247
        }
248
249
        return in_array($normalized, $this->getTagArrayNormalizedAttribute(), true);
250
    }
251
252
    /**
253
     * Query scope for models that have all of the given tags.
254
     *
255
     * @param Builder $query
256
     * @param array|string $tags
257
     *
258
     * @return Builder
259
     * @throws \Cviebrock\EloquentTaggable\Exceptions\NoTagsSpecifiedException
260
     * @throws \ErrorException
261
     */
262
    public function scopeWithAllTags(Builder $query, $tags): Builder
263
    {
264
        /** @var TagService $service */
265
        $service = app(TagService::class);
266
        $normalized = $service->buildTagArrayNormalized($tags);
267
268
        // If there are no tags specified, then there
269
        // can't be any results so short-circuit
270
        if (count($normalized) === 0) {
271
            if (config('taggable.throwEmptyExceptions')) {
272
                throw new NoTagsSpecifiedException('Empty tag data passed to withAllTags scope.');
273
            }
274
275
            return $query->where(\DB::raw(1), 0);
276
        }
277
278
        $tagKeys = $service->getTagModelKeys($normalized);
279
280
        // If some of the tags specified don't exist, then there can't
281
        // be any models with all the tags, so so short-circuit
282
        if (count($tagKeys) !== count($normalized)) {
283
            return $query->where(\DB::raw(1), 0);
284
        }
285
286
        $alias = $this->taggableCreateNewAlias(__FUNCTION__);
287
        $morphTagKeyName = $this->getQualifiedRelatedPivotKeyNameWithAlias($alias);
288
289
        return $this->prepareTableJoin($query, 'inner', $alias)
290
            ->whereIn($morphTagKeyName, $tagKeys)
291
            ->havingRaw("COUNT(DISTINCT {$morphTagKeyName}) = ?", [count($tagKeys)]);
292
    }
293
294
    /**
295
     * Query scope for models that have any of the given tags.
296
     *
297
     * @param Builder $query
298
     * @param array|string $tags
299
     *
300
     * @return Builder
301
     * @throws \Cviebrock\EloquentTaggable\Exceptions\NoTagsSpecifiedException
302
     * @throws \ErrorException
303
     */
304
    public function scopeWithAnyTags(Builder $query, $tags): Builder
305
    {
306
        /** @var TagService $service */
307
        $service = app(TagService::class);
308
        $normalized = $service->buildTagArrayNormalized($tags);
309
310
        // If there are no tags specified, then there is
311
        // no filtering to be done so short-circuit
312
        if (count($normalized) === 0) {
313
            if (config('taggable.throwEmptyExceptions')) {
314
                throw new NoTagsSpecifiedException('Empty tag data passed to withAnyTags scope.');
315
            }
316
317
            return $query->where(\DB::raw(1), 0);
318
        }
319
320
        $tagKeys = $service->getTagModelKeys($normalized);
321
322
        $alias = $this->taggableCreateNewAlias(__FUNCTION__);
323
        $morphTagKeyName = $this->getQualifiedRelatedPivotKeyNameWithAlias($alias);
324
325
        return $this->prepareTableJoin($query, 'inner', $alias)
326
            ->whereIn($morphTagKeyName, $tagKeys);
327
    }
328
329
    /**
330
     * Query scope for models that have any tag.
331
     *
332
     * @param Builder $query
333
     *
334
     * @return Builder
335
     */
336
    public function scopeIsTagged(Builder $query): Builder
337
    {
338
        $alias = $this->taggableCreateNewAlias(__FUNCTION__);
339
        return $this->prepareTableJoin($query, 'inner', $alias);
340
    }
341
342
    /**
343
     * Query scope for models that do not have all of the given tags.
344
     *
345
     * @param Builder $query
346
     * @param string|array $tags
347
     * @param bool $includeUntagged
348
     *
349
     * @return Builder
350
     * @throws \ErrorException
351
     */
352
    public function scopeWithoutAllTags(Builder $query, $tags, bool $includeUntagged = false): Builder
353
    {
354
        /** @var TagService $service */
355
        $service = app(TagService::class);
356
        $normalized = $service->buildTagArrayNormalized($tags);
357
        $tagKeys = $service->getTagModelKeys($normalized);
358
        $tagKeyList = implode(',', $tagKeys);
359
360
        $alias = $this->taggableCreateNewAlias(__FUNCTION__);
361
        $morphTagKeyName = $this->getQualifiedRelatedPivotKeyNameWithAlias($alias);
362
363
        $query = $this->prepareTableJoin($query, 'left', $alias)
364
            ->havingRaw("COUNT(DISTINCT CASE WHEN ({$morphTagKeyName} IN ({$tagKeyList})) THEN {$morphTagKeyName} ELSE NULL END) < ?",
365
                [count($tagKeys)]);
366
367
        if (!$includeUntagged) {
368
            $query->havingRaw("COUNT(DISTINCT {$morphTagKeyName}) > 0");
369
        }
370
371
        return $query;
372
    }
373
374
    /**
375
     * Query scope for models that do not have any of the given tags.
376
     *
377
     * @param Builder $query
378
     * @param string|array $tags
379
     * @param bool $includeUntagged
380
     *
381
     * @return Builder
382
     * @throws \ErrorException
383
     */
384
    public function scopeWithoutAnyTags(Builder $query, $tags, bool $includeUntagged = false): Builder
385
    {
386
        /** @var TagService $service */
387
        $service = app(TagService::class);
388
        $normalized = $service->buildTagArrayNormalized($tags);
389
        $tagKeys = $service->getTagModelKeys($normalized);
390
        $tagKeyList = implode(',', $tagKeys);
391
392
        $alias = $this->taggableCreateNewAlias(__FUNCTION__);
393
        $morphTagKeyName = $this->getQualifiedRelatedPivotKeyNameWithAlias($alias);
394
395
        $query = $this->prepareTableJoin($query, 'left', $alias)
396
            ->havingRaw("COUNT(DISTINCT CASE WHEN ({$morphTagKeyName} IN ({$tagKeyList})) THEN {$morphTagKeyName} ELSE NULL END) = 0");
397
398
        if (!$includeUntagged) {
399
            $query->havingRaw("COUNT(DISTINCT {$morphTagKeyName}) > 0");
400
        }
401
402
        return $query;
403
    }
404
405
    /**
406
     * Query scope for models that does not have have any tags.
407
     *
408
     * @param Builder $query
409
     *
410
     * @return Builder
411
     */
412
    public function scopeIsNotTagged(Builder $query): Builder
413
    {
414
        $alias = $this->taggableCreateNewAlias(__FUNCTION__);
415
        $morphForeignKeyName = $this->getQualifiedForeignPivotKeyNameWithAlias($alias);
416
417
        return $this->prepareTableJoin($query, 'left', $alias)
418
            ->havingRaw("COUNT(DISTINCT {$morphForeignKeyName}) = 0");
419
    }
420
421
    /**
422
     * @param Builder $query
423
     * @param string $joinType
424
     *
425
     * @return Builder
426
     */
427
    private function prepareTableJoin(Builder $query, string $joinType, string $alias): Builder
428
    {
429
        $morphTable = $this->tags()->getTable();
430
        $morphTableAlias = $morphTable.'_'.$alias;
431
432
        $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...
433
        $morphForeignKeyName = $this->getQualifiedForeignPivotKeyNameWithAlias($alias);
434
435
        $morphTypeName = $morphTableAlias.'.'. $this->tags()->getMorphType();
436
        $morphClass = $this->tags()->getMorphClass();
437
438
        $closure = function(JoinClause $join) use ($modelKeyName, $morphForeignKeyName, $morphTypeName, $morphClass) {
439
            $join->on($modelKeyName, $morphForeignKeyName)
440
                ->where($morphTypeName, $morphClass);
441
        };
442
443
        return $query
0 ignored issues
show
Bug introduced by
The method select() does not exist on Illuminate\Database\Eloquent\Builder. Did you maybe mean createSelectWithConstraint()?

This check marks calls to methods that do not seem to exist on an object.

This is most likely the result of a method being renamed without all references to it being renamed likewise.

Loading history...
444
            ->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...
445
            ->join($morphTable.' as '.$morphTableAlias, $closure, null, null, $joinType)
446
            ->groupBy($modelKeyName);
447
    }
448
449
    /**
450
     * Get a collection of all the tag models used for the called class.
451
     *
452
     * @return Collection
453
     */
454
    public static function allTagModels(): Collection
455
    {
456
        return app(TagService::class)->getAllTags(static::class);
457
    }
458
459
    /**
460
     * Get an array of all tags used for the called class.
461
     *
462
     * @return array
463
     */
464
    public static function allTags(): array
465
    {
466
        /** @var \Illuminate\Database\Eloquent\Collection $tags */
467
        $tags = static::allTagModels();
468
469
        return $tags->pluck('name')->sort()->all();
470
    }
471
472
    /**
473
     * Get all the tags used for the called class as a delimited string.
474
     *
475
     * @return string
476
     */
477
    public static function allTagsList(): string
478
    {
479
        return app(TagService::class)->joinList(static::allTags());
480
    }
481
482
    /**
483
     * Rename one the tags for the called class.
484
     *
485
     * @param string $oldTag
486
     * @param string $newTag
487
     *
488
     * @return int
489
     */
490
    public static function renameTag(string $oldTag, string $newTag): int
491
    {
492
        return app(TagService::class)->renameTags($oldTag, $newTag, static::class);
493
    }
494
495
    /**
496
     * Get the most popular tags for the called class.
497
     *
498
     * @param int $limit
499
     * @param int $minCount
500
     *
501
     * @return array
502
     */
503
    public static function popularTags(int $limit = null, int $minCount = 1): array
504
    {
505
        /** @var Collection $tags */
506
        $tags = app(TagService::class)->getPopularTags($limit, static::class, $minCount);
507
508
        return $tags->pluck('taggable_count', 'name')->all();
509
    }
510
511
    /**
512
     * Get the most popular tags for the called class.
513
     *
514
     * @param int $limit
515
     * @param int $minCount
516
     *
517
     * @return array
518
     */
519
    public static function popularTagsNormalized(int $limit = null, int $minCount = 1): array
520
    {
521
        /** @var Collection $tags */
522
        $tags = app(TagService::class)->getPopularTags($limit, static::class, $minCount);
523
524
        return $tags->pluck('taggable_count', 'normalized')->all();
525
    }
526
527
    /**
528
     * Returns the Related Pivot Key Name with the table alias.
529
     *
530
     * @param string $alias
531
     *
532
     * @return string
533
     */
534
    private function getQualifiedRelatedPivotKeyNameWithAlias(string $alias): string
535
    {
536
        $morph = $this->tags();
537
538
        return $morph->getTable() . '_' . $alias .
539
            '.' . $morph->getRelatedPivotKeyName();
540
    }
541
542
    /**
543
     * Returns the Foreign Pivot Key Name with the table alias.
544
     *
545
     * @param string $alias
546
     *
547
     * @return string
548
     */
549
    private function getQualifiedForeignPivotKeyNameWithAlias(string $alias): string
550
    {
551
        $morph = $this->tags();
552
553
        return $morph->getTable() . '_' . $alias .
554
            '.' . $morph->getForeignPivotKeyName();
555
    }
556
557
    /**
558
     * Create a new alias to use on scopes to be able to combine many scopes
559
     *
560
     * @param string $scope
561
     * 
562
     * @return string
563
     */
564
    private function taggableCreateNewAlias(string $scope): string
565
    {
566
        $this->taggableAliasSequence++;
567
        $alias = strtolower($scope) . '_' . $this->taggableAliasSequence;
568
569
        return $alias;
570
    }
571
}
572