Completed
Push — master ( 947bd5...03f7a3 )
by Colin
07:50
created

Taggable::hasTag()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 10

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 5
CRAP Score 2.0185

Importance

Changes 0
Metric Value
dl 0
loc 10
ccs 5
cts 6
cp 0.8333
rs 9.9332
c 0
b 0
f 0
cc 2
nc 2
nop 1
crap 2.0185
1
<?php namespace Cviebrock\EloquentTaggable;
2
3
use Cviebrock\EloquentTaggable\Exceptions\NoTagsSpecifiedException;
4
use Cviebrock\EloquentTaggable\Models\Tag;
5
use Cviebrock\EloquentTaggable\Services\TagService;
6
use Illuminate\Database\Eloquent\Builder;
7
use Illuminate\Database\Eloquent\Collection;
8
use Illuminate\Database\Eloquent\Relations\MorphToMany;
9
use Illuminate\Database\Query\JoinClause;
10
11
12
/**
13
 * Class Taggable
14
 *
15
 * @package Cviebrock\EloquentTaggable
16
 */
17
trait Taggable
18
{
19
20
    /**
21
     * Boot the trait.
22 23
     *
23
     * Listen for the deleting event of a model, then remove the relation between it and tags
24 23
     */
25 23
    protected static function bootTaggable(): void
26
    {
27
        static::deleting(function ($model) {
28
            if (!method_exists($model, 'runSoftDelete') || $model->isForceDeleting()) {
29
                $model->detag();
30
            }
31
        });
32
    }
33
34
    /**
35 23
     * Get a collection of all tags the model has.
36
     *
37 23
     * @return \Illuminate\Database\Eloquent\Relations\MorphToMany
38
     */
39 23
    public function tags(): MorphToMany
40 23
    {
41 23
        $model = config('taggable.model');
42
        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...
43 23
            ->withTimestamps();
44
    }
45
46
    /**
47
     * Attach one or multiple tags to the model.
48
     *
49
     * @param string|array $tags
50
     *
51
     * @return self
52
     */
53 1
    public function tag($tags): self
54
    {
55 1
        $tags = app(TagService::class)->buildTagArray($tags);
56
57 1
        foreach ($tags as $tagName) {
58 1
            $this->addOneTag($tagName);
59 1
            $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...
60
        }
61 1
62
        return $this;
63
    }
64
65
    /**
66
     * Detach one or multiple tags from the model.
67
     *
68
     * @param string|array $tags
69
     *
70
     * @return self
71 1
     */
72
    public function untag($tags): self
73 1
    {
74
        $tags = app(TagService::class)->buildTagArray($tags);
75
76
        foreach ($tags as $tagName) {
77
            $this->removeOneTag($tagName);
78
        }
79
80
        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...
81 2
    }
82
83 2
    /**
84
     * Remove all tags from the model and assign the given ones.
85 2
     *
86
     * @param string|array $tags
87
     *
88
     * @return self
89
     */
90
    public function retag($tags): self
91
    {
92
        return $this->detag()->tag($tags);
93 23
    }
94
95 23
    /**
96
     * Remove all tags from the model.
97 23
     *
98 23
     * @return self
99 23
     */
100 23
    public function detag(): self
101
    {
102
        $this->tags()->sync([]);
103
104
        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...
105
    }
106
107 1
    /**
108
     * Add one tag to the model.
109 1
     *
110
     * @param string $tagName
111 1
     */
112 1
    protected function addOneTag(string $tagName): void
113 1
    {
114 1
        /** @var Tag $tag */
115
        $tag = app(TagService::class)->findOrCreate($tagName);
116
        $tagKey = $tag->getKey();
117
118
        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...
119
            $this->tags()->attach($tagKey);
120
        }
121
    }
122 13
123
    /**
124 2
     * Remove one tag from the model
125 13
     *
126
     * @param string $tagName
127
     */
128
    protected function removeOneTag(string $tagName): void
129
    {
130
        $tag = app(TagService::class)->find($tagName);
131
132
        if ($tag) {
133 2
            $this->tags()->detach($tag);
134
        }
135 2
    }
136
137
    /**
138
     * Get all the tags of the model as a delimited string.
139
     *
140
     * @return string
141
     */
142
    public function getTagListAttribute(): string
143 11
    {
144
        return app(TagService::class)->makeTagList($this);
145 11
    }
146
147
    /**
148
     * Get all normalized tags of a model as a delimited string.
149
     *
150
     * @return string
151
     */
152
    public function getTagListNormalizedAttribute(): string
153 2
    {
154
        return app(TagService::class)->makeTagList($this, 'normalized');
155 2
    }
156
157
    /**
158
     * Get all tags of a model as an array.
159
     *
160
     * @return array
161
     */
162
    public function getTagArrayAttribute(): array
163
    {
164
        return app(TagService::class)->makeTagArray($this);
165
    }
166 1
167
    /**
168 1
     * Get all normalized tags of a model as an array.
169
     *
170
     * @return array
171 1
     */
172 1
    public function getTagArrayNormalizedAttribute(): array
173
    {
174
        return app(TagService::class)->makeTagArray($this, 'normalized');
175
    }
176
177
    /**
178
     * Determine if a given tag is attached to the model.
179
     *
180
     * @param Tag|string $tag
181
     *
182
     * @return bool
183 1
     */
184
    public function hasTag($tag): bool
185 1
    {
186
        if ($tag instanceof Tag) {
187 1
            $normalized = $tag->getAttribute('normalized');
188
        } else {
189
            $normalized = app(TagService::class)->normalize($tag);
190
        }
191 1
192 1
        return in_array($normalized, $this->getTagArrayNormalizedAttribute(), true);
193 1
    }
194
195
    /**
196
     * Query scope for models that have all of the given tags.
197
     *
198
     * @param Builder $query
199
     * @param array|string $tags
200
     *
201
     * @return Builder
202
     * @throws \Cviebrock\EloquentTaggable\Exceptions\NoTagsSpecifiedException
203 1
     * @throws \ErrorException
204
     */
205 1
    public function scopeWithAllTags(Builder $query, $tags): Builder
206
    {
207
        /** @var TagService $service */
208
        $service = app(TagService::class);
209
        $normalized = $service->buildTagArrayNormalized($tags);
210
211
        // If there are no tags specified, then there
212
        // can't be any results so short-circuit
213 2
        if (count($normalized) === 0) {
214
            if (config('taggable.throwEmptyExceptions')) {
215
                throw new NoTagsSpecifiedException('Empty tag data passed to withAllTags scope.');
216 2
            }
217
218 2
            return $query->where(\DB::raw(1), 0);
219
        }
220
221
        $tagKeys = $service->getTagModelKeys($normalized);
222
223
        // If some of the tags specified don't exist, then there can't
224
        // be any models with all the tags, so so short-circuit
225
        if (count($tagKeys) !== count($normalized)) {
226 1
            return $query->where(\DB::raw(1), 0);
227
        }
228 1
229
        $alias = strtolower(__FUNCTION__);
230
        $morphTagKeyName = $this->getQualifiedRelatedPivotKeyNameWithAlias($alias);
231
232
        return $this->prepareTableJoin($query, 'inner', $alias)
233
            ->whereIn($morphTagKeyName, $tagKeys)
234
            ->havingRaw("COUNT({$morphTagKeyName}) = ?", [count($tagKeys)]);
235
    }
236
237
    /**
238
     * Query scope for models that have any of the given tags.
239
     *
240
     * @param Builder $query
241
     * @param array|string $tags
242
     *
243
     * @return Builder
244
     * @throws \Cviebrock\EloquentTaggable\Exceptions\NoTagsSpecifiedException
245
     * @throws \ErrorException
246
     */
247
    public function scopeWithAnyTags(Builder $query, $tags): Builder
248
    {
249
        /** @var TagService $service */
250
        $service = app(TagService::class);
251
        $normalized = $service->buildTagArrayNormalized($tags);
252
253
        // If there are no tags specified, then there is
254
        // no filtering to be done so short-circuit
255
        if (count($normalized) === 0) {
256
            if (config('taggable.throwEmptyExceptions')) {
257
                throw new NoTagsSpecifiedException('Empty tag data passed to withAnyTags scope.');
258
            }
259
260
            return $query->where(\DB::raw(1), 0);
261
        }
262
263
        $tagKeys = $service->getTagModelKeys($normalized);
264
265
        $alias = strtolower(__FUNCTION__);
266
        $morphTagKeyName = $this->getQualifiedRelatedPivotKeyNameWithAlias($alias);
267
268
        return $this->prepareTableJoin($query, 'inner', $alias)
269
            ->whereIn($morphTagKeyName, $tagKeys);
270
    }
271
272
    /**
273
     * Query scope for models that have any tag.
274
     *
275
     * @param Builder $query
276
     *
277
     * @return Builder
278
     */
279
    public function scopeIsTagged(Builder $query): Builder
280
    {
281
        $alias = strtolower(__FUNCTION__);
282
        return $this->prepareTableJoin($query, 'inner', $alias);
283
    }
284
285
    /**
286
     * Query scope for models that do not have all of the given tags.
287
     *
288
     * @param Builder $query
289
     * @param string|array $tags
290
     * @param bool $includeUntagged
291
     *
292
     * @return Builder
293
     * @throws \ErrorException
294
     */
295
    public function scopeWithoutAllTags(Builder $query, $tags, bool $includeUntagged = false): Builder
296
    {
297
        /** @var TagService $service */
298
        $service = app(TagService::class);
299
        $normalized = $service->buildTagArrayNormalized($tags);
300
        $tagKeys = $service->getTagModelKeys($normalized);
301
        $tagKeyList = implode(',', $tagKeys);
302
303
        $alias = strtolower(__FUNCTION__);
304
        $morphTagKeyName = $this->getQualifiedRelatedPivotKeyNameWithAlias($alias);
305
306
        $query = $this->prepareTableJoin($query, 'left', $alias)
307
            ->havingRaw("COUNT(DISTINCT CASE WHEN ({$morphTagKeyName} IN ({$tagKeyList})) THEN {$morphTagKeyName} ELSE NULL END) < ?",
308
                [count($tagKeys)]);
309
310
        if (!$includeUntagged) {
311
            $query->havingRaw("COUNT(DISTINCT {$morphTagKeyName}) > 0");
312
        }
313
314
        return $query;
315
    }
316
317
    /**
318
     * Query scope for models that do not have any of the given tags.
319
     *
320
     * @param Builder $query
321
     * @param string|array $tags
322
     * @param bool $includeUntagged
323
     *
324
     * @return Builder
325
     * @throws \ErrorException
326
     */
327
    public function scopeWithoutAnyTags(Builder $query, $tags, bool $includeUntagged = false): Builder
328
    {
329
        /** @var TagService $service */
330
        $service = app(TagService::class);
331
        $normalized = $service->buildTagArrayNormalized($tags);
332
        $tagKeys = $service->getTagModelKeys($normalized);
333
        $tagKeyList = implode(',', $tagKeys);
334
335
        $alias = strtolower(__FUNCTION__);
336
        $morphTagKeyName = $this->getQualifiedRelatedPivotKeyNameWithAlias($alias);
337
338
        $query = $this->prepareTableJoin($query, 'left', $alias)
339
            ->havingRaw("COUNT(DISTINCT CASE WHEN ({$morphTagKeyName} IN ({$tagKeyList})) THEN {$morphTagKeyName} ELSE NULL END) = 0");
340
341
        if (!$includeUntagged) {
342
            $query->havingRaw("COUNT(DISTINCT {$morphTagKeyName}) > 0");
343
        }
344
345
        return $query;
346
    }
347
348
    /**
349
     * Query scope for models that does not have have any tags.
350
     *
351
     * @param Builder $query
352
     *
353
     * @return Builder
354
     */
355
    public function scopeIsNotTagged(Builder $query): Builder
356
    {
357
        $alias = strtolower(__FUNCTION__);
358
        $morphForeignKeyName = $this->getQualifiedForeignPivotKeyNameWithAlias($alias);
359
360
        return $this->prepareTableJoin($query, 'left', $alias)
361
            ->havingRaw("COUNT(DISTINCT {$morphForeignKeyName}) = 0");
362
    }
363
364
    /**
365
     * @param Builder $query
366
     * @param string $joinType
367
     *
368
     * @return Builder
369
     */
370
    private function prepareTableJoin(Builder $query, string $joinType, string $alias): Builder
371
    {
372
        $morphTable = $this->tags()->getTable();
373
        $morphTableAlias = $morphTable.'_'.$alias;
374
375
        $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...
376
        $morphForeignKeyName = $this->getQualifiedForeignPivotKeyNameWithAlias($alias);
377
378
        $morphTypeName = $morphTableAlias.'.'. $this->tags()->getMorphType();
379
        $morphClass = $this->tags()->getMorphClass();
380
381
        $closure = function(JoinClause $join) use ($modelKeyName, $morphForeignKeyName, $morphTypeName, $morphClass) {
382
            $join->on($modelKeyName, $morphForeignKeyName)
383
                ->where($morphTypeName, $morphClass);
384
        };
385
386
        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...
387
            ->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...
388
            ->join($morphTable.' as '.$morphTableAlias, $closure, null, null, $joinType)
389
            ->groupBy($modelKeyName);
390
    }
391
392
    /**
393
     * Get a collection of all the tag models used for the called class.
394
     *
395
     * @return Collection
396
     */
397
    public static function allTagModels(): Collection
398
    {
399
        return app(TagService::class)->getAllTags(static::class);
400
    }
401
402
    /**
403
     * Get an array of all tags used for the called class.
404
     *
405
     * @return array
406
     */
407
    public static function allTags(): array
408
    {
409
        /** @var \Illuminate\Database\Eloquent\Collection $tags */
410
        $tags = static::allTagModels();
411
412
        return $tags->pluck('name')->sort()->all();
413
    }
414
415
    /**
416
     * Get all the tags used for the called class as a delimited string.
417
     *
418
     * @return string
419
     */
420
    public static function allTagsList(): string
421
    {
422
        return app(TagService::class)->joinList(static::allTags());
423
    }
424
425
    /**
426
     * Rename one the tags for the called class.
427
     *
428
     * @param string $oldTag
429
     * @param string $newTag
430
     *
431
     * @return int
432
     */
433
    public static function renameTag(string $oldTag, string $newTag): int
434
    {
435
        return app(TagService::class)->renameTags($oldTag, $newTag, static::class);
436
    }
437
438
    /**
439
     * Get the most popular tags for the called class.
440
     *
441
     * @param int $limit
442
     * @param int $minCount
443
     *
444
     * @return array
445
     */
446
    public static function popularTags(int $limit = null, int $minCount = 1): array
447
    {
448
        /** @var Collection $tags */
449
        $tags = app(TagService::class)->getPopularTags($limit, static::class, $minCount);
450
451
        return $tags->pluck('taggable_count', 'name')->all();
452
    }
453
454
    /**
455
     * Get the most popular tags for the called class.
456
     *
457
     * @param int $limit
458
     * @param int $minCount
459
     *
460
     * @return array
461
     */
462
    public static function popularTagsNormalized(int $limit = null, int $minCount = 1): array
463
    {
464
        /** @var Collection $tags */
465
        $tags = app(TagService::class)->getPopularTags($limit, static::class, $minCount);
466
467
        return $tags->pluck('taggable_count', 'normalized')->all();
468
    }
469
470
    /**
471
     * Returns the Related Pivot Key Name with the table alias.
472
     *
473
     * @param string $alias
474
     *
475
     * @return string
476
     */
477
    private function getQualifiedRelatedPivotKeyNameWithAlias(string $alias): string
478
    {
479
        $morph = $this->tags();
480
481
        return $morph->getTable() . '_' . $alias .
482
            '.' . $morph->getRelatedPivotKeyName();
483
    }
484
485
    /**
486
     * Returns the Foreign Pivot Key Name with the table alias.
487
     *
488
     * @param string $alias
489
     *
490
     * @return string
491
     */
492
    private function getQualifiedForeignPivotKeyNameWithAlias(string $alias): string
493
    {
494
        $morph = $this->tags();
495
496
        return $morph->getTable() . '_' . $alias .
497
            '.' . $morph->getForeignPivotKeyName();
498
    }
499
500
}
501