Completed
Push — master ( e252bc...4c491c )
by Colin
01:26
created

SlugService   C

Complexity

Total Complexity 55

Size/Duplication

Total Lines 419
Duplicated Lines 5.25 %

Coupling/Cohesion

Components 1
Dependencies 3

Importance

Changes 0
Metric Value
wmc 55
lcom 1
cbo 3
dl 22
loc 419
rs 6.8
c 0
b 0
f 0

14 Methods

Rating   Name   Duplication   Size   Complexity  
B buildSlug() 0 16 5
A slug() 0 23 3
A getConfiguration() 0 6 1
A needsSlugging() 0 15 4
A getSlugSource() 0 17 3
C generateSlug() 8 29 8
A getSlugEngine() 0 17 3
C validateSlug() 7 33 7
C makeSlugUnique() 7 48 9
A generateSuffix() 0 19 2
B getExistingSlugs() 0 25 4
A usesSoftDeleting() 0 4 1
B createSlug() 0 22 4
A setModel() 0 6 1

How to fix   Duplicated Code    Complexity   

Duplicated Code

Duplicate code is one of the most pungent code smells. A rule that is often used is to re-structure code once it is duplicated in three or more places.

Common duplication problems, and corresponding solutions are:

Complex Class

 Tip:   Before tackling complexity, make sure that you eliminate any duplication first. This often can reduce the size of classes significantly.

Complex classes like SlugService 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. You can also have a look at the cohesion graph to spot any un-connected, or weakly-connected components.

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 SlugService, and based on these observations, apply Extract Interface, too.

1
<?php namespace Cviebrock\EloquentSluggable\Services;
2
3
use Cocur\Slugify\Slugify;
4
use Illuminate\Database\Eloquent\Model;
5
use Illuminate\Support\Collection;
6
7
/**
8
 * Class SlugService
9
 *
10
 * @package Cviebrock\EloquentSluggable\Services
11
 */
12
class SlugService
13
{
14
15
    /**
16
     * @var \Illuminate\Database\Eloquent\Model;
17
     */
18
    protected $model;
19
20
    /**
21
     * Slug the current model.
22
     *
23
     * @param \Illuminate\Database\Eloquent\Model $model
24
     * @param bool $force
25
     *
26
     * @return bool
27
     */
28
    public function slug(Model $model, bool $force = false): bool
29
    {
30
        $this->setModel($model);
31
32
        $attributes = [];
33
34
        foreach ($this->model->sluggable() as $attribute => $config) {
35
            if (is_numeric($attribute)) {
36
                $attribute = $config;
37
                $config = $this->getConfiguration();
38
            } else {
39
                $config = $this->getConfiguration($config);
40
            }
41
42
            $slug = $this->buildSlug($attribute, $config, $force);
43
44
            $this->model->setAttribute($attribute, $slug);
45
46
            $attributes[] = $attribute;
47
        }
48
49
        return $this->model->isDirty($attributes);
50
    }
51
52
    /**
53
     * Get the sluggable configuration for the current model,
54
     * including default values where not specified.
55
     *
56
     * @param array $overrides
57
     *
58
     * @return array
59
     */
60
    public function getConfiguration(array $overrides = []): array
61
    {
62
        $defaultConfig = config('sluggable', []);
63
64
        return array_merge($defaultConfig, $overrides);
65
    }
66
67
    /**
68
     * Build the slug for the given attribute of the current model.
69
     *
70
     * @param string $attribute
71
     * @param array $config
72
     * @param bool $force
73
     *
74
     * @return null|string
75
     */
76
    public function buildSlug(string $attribute, array $config, bool $force = null)
77
    {
78
        $slug = $this->model->getAttribute($attribute);
79
80
        if ($force || $this->needsSlugging($attribute, $config)) {
81
            $source = $this->getSlugSource($config['source']);
82
83
            if ($source || is_numeric($source)) {
84
                $slug = $this->generateSlug($source, $config, $attribute);
85
                $slug = $this->validateSlug($slug, $config, $attribute);
86
                $slug = $this->makeSlugUnique($slug, $config, $attribute);
87
            }
88
        }
89
90
        return $slug;
91
    }
92
93
    /**
94
     * Determines whether the model needs slugging.
95
     *
96
     * @param string $attribute
97
     * @param array $config
98
     *
99
     * @return bool
100
     */
101
    protected function needsSlugging(string $attribute, array $config): bool
102
    {
103
        if (
104
            $config['onUpdate'] === true ||
105
            empty($this->model->getAttributeValue($attribute))
106
        ) {
107
            return true;
108
        }
109
110
        if ($this->model->isDirty($attribute)) {
111
            return false;
112
        }
113
114
        return (!$this->model->exists);
115
    }
116
117
    /**
118
     * Get the source string for the slug.
119
     *
120
     * @param mixed $from
121
     *
122
     * @return string
123
     */
124
    protected function getSlugSource($from): string
125
    {
126
        if (is_null($from)) {
127
            return $this->model->__toString();
128
        }
129
130
        $sourceStrings = array_map(function ($key) {
131
            $value = data_get($this->model, $key);
132
            if (is_bool($value)) {
133
                $value = (int)$value;
134
            }
135
136
            return $value;
137
        }, (array)$from);
138
139
        return implode($sourceStrings, ' ');
140
    }
141
142
    /**
143
     * Generate a slug from the given source string.
144
     *
145
     * @param string $source
146
     * @param array $config
147
     * @param string $attribute
148
     *
149
     * @return string
150
     * @throws \UnexpectedValueException
151
     */
152
    protected function generateSlug(string $source, array $config, string $attribute): string
153
    {
154
        $separator = $config['separator'];
155
        $method = $config['method'];
156
        $maxLength = $config['maxLength'];
157
        $maxLengthKeepWords = $config['maxLengthKeepWords'];
158
159 View Code Duplication
        if ($method === null) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
160
            $slugEngine = $this->getSlugEngine($attribute);
161
            $slug = $slugEngine->slugify($source, $separator);
162
        } elseif (is_callable($method)) {
163
            $slug = call_user_func($method, $source, $separator);
164
        } else {
165
            throw new \UnexpectedValueException('Sluggable "method" for '.get_class($this->model).':'.$attribute.' is not callable nor null.');
166
        }
167
168
        $len = mb_strlen($slug);
169
        if (is_string($slug) && $maxLength && $len > $maxLength) {
170
            $reverseOffset = $maxLength - $len;
171
            $lastSeparatorPos = mb_strrpos($slug, $separator, $reverseOffset);
172
            if ($maxLengthKeepWords && $lastSeparatorPos !== false) {
173
                $slug = mb_substr($slug, 0, $lastSeparatorPos);
174
            } else {
175
                $slug = trim(mb_substr($slug, 0, $maxLength), $separator);
176
            }
177
        }
178
179
        return $slug;
180
    }
181
182
    /**
183
     * Return a class that has a `slugify()` method, used to convert
184
     * strings into slugs.
185
     *
186
     * @param string $attribute
187
     *
188
     * @return \Cocur\Slugify\Slugify
189
     */
190
    protected function getSlugEngine(string $attribute): Slugify
191
    {
192
        static $slugEngines = [];
193
194
        $key = get_class($this->model).'.'.$attribute;
195
196
        if (!array_key_exists($key, $slugEngines)) {
197
            $engine = new Slugify();
198
            if (method_exists($this->model, 'customizeSlugEngine')) {
199
                $engine = $this->model->customizeSlugEngine($engine, $attribute);
200
            }
201
202
            $slugEngines[$key] = $engine;
203
        }
204
205
        return $slugEngines[$key];
206
    }
207
208
    /**
209
     * Checks that the given slug is not a reserved word.
210
     *
211
     * @param string $slug
212
     * @param array $config
213
     * @param string $attribute
214
     *
215
     * @return string
216
     * @throws \UnexpectedValueException
217
     */
218
    protected function validateSlug(string $slug, array $config, string $attribute): string
219
    {
220
        $separator = $config['separator'];
221
        $reserved = $config['reserved'];
222
223
        if ($reserved === null) {
224
            return $slug;
225
        }
226
227
        // check for reserved names
228
        if ($reserved instanceof \Closure) {
229
            $reserved = $reserved($this->model);
230
        }
231
232
        if (is_array($reserved)) {
233
            if (in_array($slug, $reserved)) {
234
                $method = $config['uniqueSuffix'];
235 View Code Duplication
                if ($method === null) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
236
                    $suffix = $this->generateSuffix($slug, $separator, collect($reserved));
237
                } elseif (is_callable($method)) {
238
                    $suffix = $method($slug, $separator, collect($reserved));
239
                } else {
240
                    throw new \UnexpectedValueException('Sluggable "uniqueSuffix" for '.get_class($this->model).':'.$attribute.' is not null, or a closure.');
241
                }
242
243
                return $slug.$separator.$suffix;
244
            }
245
246
            return $slug;
247
        }
248
249
        throw new \UnexpectedValueException('Sluggable "reserved" for '.get_class($this->model).':'.$attribute.' is not null, an array, or a closure that returns null/array.');
250
    }
251
252
    /**
253
     * Checks if the slug should be unique, and makes it so if needed.
254
     *
255
     * @param string $slug
256
     * @param array $config
257
     * @param string $attribute
258
     *
259
     * @return string
260
     * @throws \UnexpectedValueException
261
     */
262
    protected function makeSlugUnique(string $slug, array $config, string $attribute): string
263
    {
264
        if (!$config['unique']) {
265
            return $slug;
266
        }
267
268
        $separator = $config['separator'];
269
270
        // find all models where the slug is like the current one
271
        $list = $this->getExistingSlugs($slug, $attribute, $config);
272
273
        // if ...
274
        // 	a) the list is empty, or
0 ignored issues
show
Unused Code Comprehensibility introduced by
36% of this comment could be valid code. Did you maybe forget this after debugging?

Sometimes obsolete code just ends up commented out instead of removed. In this case it is better to remove the code once you have checked you do not need it.

The code might also have been commented out for debugging purposes. In this case it is vital that someone uncomments it again or your project may behave in very unexpected ways in production.

This check looks for comments that seem to be mostly valid code and reports them.

Loading history...
275
        // 	b) our slug isn't in the list
276
        // ... we are okay
277
        if (
278
            $list->count() === 0 ||
279
            $list->contains($slug) === false
280
        ) {
281
            return $slug;
282
        }
283
284
        // if our slug is in the list, but
285
        // 	a) it's for our model, or
286
        //  b) it looks like a suffixed version of our slug
287
        // ... we are also okay (use the current slug)
288
        if ($list->has($this->model->getKey())) {
289
            $currentSlug = $list->get($this->model->getKey());
290
291
            if (
292
                $currentSlug === $slug ||
293
                strpos($currentSlug, $slug) === 0
294
            ) {
295
                return $currentSlug;
296
            }
297
        }
298
299
        $method = $config['uniqueSuffix'];
300 View Code Duplication
        if ($method === null) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
301
            $suffix = $this->generateSuffix($slug, $separator, $list);
302
        } elseif (is_callable($method)) {
303
            $suffix = $method($slug, $separator, $list);
304
        } else {
305
            throw new \UnexpectedValueException('Sluggable "uniqueSuffix" for '.get_class($this->model).':'.$attribute.' is not null, or a closure.');
306
        }
307
308
        return $slug.$separator.$suffix;
309
    }
310
311
    /**
312
     * Generate a unique suffix for the given slug (and list of existing, "similar" slugs.
313
     *
314
     * @param string $slug
315
     * @param string $separator
316
     * @param \Illuminate\Support\Collection $list
317
     *
318
     * @return string
319
     */
320
    protected function generateSuffix(string $slug, string $separator, Collection $list): string
321
    {
322
        $len = strlen($slug.$separator);
323
324
        // If the slug already exists, but belongs to
325
        // our model, return the current suffix.
326
        if ($list->search($slug) === $this->model->getKey()) {
327
            $suffix = explode($separator, $slug);
328
329
            return end($suffix);
330
        }
331
332
        $list->transform(function ($value, $key) use ($len) {
0 ignored issues
show
Unused Code introduced by
The parameter $key is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
333
            return (int)substr($value, $len);
334
        });
335
336
        // find the highest value and return one greater.
337
        return $list->max() + 1;
338
    }
339
340
    /**
341
     * Get all existing slugs that are similar to the given slug.
342
     *
343
     * @param string $slug
344
     * @param string $attribute
345
     * @param array $config
346
     *
347
     * @return \Illuminate\Support\Collection
348
     */
349
    protected function getExistingSlugs(string $slug, string $attribute, array $config): Collection
350
    {
351
        $includeTrashed = $config['includeTrashed'];
352
353
        $query = $this->model->newQuery()
354
            ->findSimilarSlugs($attribute, $config, $slug);
355
356
        // use the model scope to find similar slugs
357
        if (method_exists($this->model, 'scopeWithUniqueSlugConstraints')) {
358
            $query->withUniqueSlugConstraints($this->model, $attribute, $config, $slug);
359
        }
360
361
        // include trashed models if required
362
        if ($includeTrashed && $this->usesSoftDeleting()) {
363
            $query->withTrashed();
364
        }
365
366
        // get the list of all matching slugs
367
        $results = $query->select([$attribute, $this->model->getQualifiedKeyName()])
368
            ->get()
369
            ->toBase();
370
371
        // key the results and return
372
        return $results->pluck($attribute, $this->model->getKeyName());
373
    }
374
375
    /**
376
     * Does this model use softDeleting?
377
     *
378
     * @return bool
379
     */
380
    protected function usesSoftDeleting(): bool
381
    {
382
        return method_exists($this->model, 'bootSoftDeletes');
383
    }
384
385
    /**
386
     * Generate a unique slug for a given string.
387
     *
388
     * @param \Illuminate\Database\Eloquent\Model|string $model
389
     * @param string $attribute
390
     * @param string $fromString
391
     * @param array|null $config
392
     *
393
     * @return string
394
     * @throws \UnexpectedValueException
395
     */
396
    public static function createSlug($model, string $attribute, string $fromString, array $config = null): string
397
    {
398
        if (is_string($model)) {
399
            $model = new $model;
400
        }
401
        /** @var static $instance */
402
        $instance = (new static())->setModel($model);
403
404
        if ($config === null) {
405
            $config = array_get($model->sluggable(), $attribute);
406
        } elseif (!is_array($config)) {
407
            throw new \UnexpectedValueException('SlugService::createSlug expects an array or null as the fourth argument; '.gettype($config).' given.');
408
        }
409
410
        $config = $instance->getConfiguration($config);
411
412
        $slug = $instance->generateSlug($fromString, $config, $attribute);
413
        $slug = $instance->validateSlug($slug, $config, $attribute);
414
        $slug = $instance->makeSlugUnique($slug, $config, $attribute);
415
416
        return $slug;
417
    }
418
419
    /**
420
     * @param \Illuminate\Database\Eloquent\Model $model
421
     *
422
     * @return $this
423
     */
424
    public function setModel(Model $model)
425
    {
426
        $this->model = $model;
427
428
        return $this;
429
    }
430
}
431