Completed
Pull Request — master (#488)
by
unknown
01:22
created

SlugService   F

Complexity

Total Complexity 62

Size/Duplication

Total Lines 447
Duplicated Lines 4.92 %

Coupling/Cohesion

Components 1
Dependencies 4

Importance

Changes 0
Metric Value
wmc 62
lcom 1
cbo 4
dl 22
loc 447
rs 3.44
c 0
b 0
f 0

14 Methods

Rating   Name   Duplication   Size   Complexity  
A slug() 0 24 4
A getConfiguration() 0 6 1
B buildSlug() 0 38 9
A needsSlugging() 0 15 4
A getSlugSources() 0 17 3
B generateSlug() 8 29 8
A getSlugEngine() 0 17 3
B validateSlug() 7 33 7
B makeSlugUnique() 7 48 10
A generateSuffix() 0 19 2
A getExistingSlugs() 0 25 4
A usesSoftDeleting() 0 4 1
A createSlug() 0 26 5
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\Arr;
6
use Illuminate\Support\Collection;
7
8
/**
9
 * Class SlugService
10
 *
11
 * @package Cviebrock\EloquentSluggable\Services
12
 */
13
class SlugService
14
{
15
16
    /**
17
     * @var \Illuminate\Database\Eloquent\Model;
18
     */
19
    protected $model;
20
21
    /**
22
     * Slug the current model.
23
     *
24
     * @param \Illuminate\Database\Eloquent\Model $model
25
     * @param bool $force
26
     *
27
     * @return bool
28
     */
29
    public function slug(Model $model, bool $force = false): bool
30
    {
31
        $this->setModel($model);
32
33
        $attributes = [];
34
35
        foreach ($this->model->sluggable() as $attribute => $config) {
36
            if (is_numeric($attribute)) {
37
                $attribute = $config;
38
                $config    = $this->getConfiguration();
39
            } else {
40
                $config = $this->getConfiguration($config);
41
            }
42
43
            $slug = $this->buildSlug($attribute, $config, $force);
44
45
            if ($slug !== null) {
46
                $this->model->setAttribute($attribute, $slug);
47
                $attributes[] = $attribute;
48
            }
49
        }
50
51
        return $this->model->isDirty($attributes);
52
    }
53
54
    /**
55
     * Get the sluggable configuration for the current model,
56
     * including default values where not specified.
57
     *
58
     * @param array $overrides
59
     *
60
     * @return array
61
     */
62
    public function getConfiguration(array $overrides = []): array
63
    {
64
        $defaultConfig = config('sluggable', []);
65
66
        return array_merge($defaultConfig, $overrides);
67
    }
68
69
    /**
70
     * Build the slug for the given attribute of the current model.
71
     *
72
     * @param string $attribute
73
     * @param array $config
74
     * @param bool $force
75
     *
76
     * @return null|string
77
     */
78
    public function buildSlug(string $attribute, array $config, bool $force = null)
79
    {
80
        $slug = $this->model->getAttribute($attribute);
81
82
        if ($force || $this->needsSlugging($attribute, $config)) {
83
            $sources = $this->getSlugSources($config['source']);
84
85
            if (is_array($sources) && !empty($sources)) {
86
                $slugs = [];
87
88
                foreach ($sources as $index => $source) {
89
                    if (empty($source)) {
90
                        continue;
91
                    }
92
93
                    // If $source already has the field separator, we assume
94
                    // it's already a slug
95
                    if (strpos($source, $config['field_separator']) === false) {
96
                        $slug = $this->generateSlug($source, $config, $attribute);
97
                        $slug = $this->validateSlug($slug, $config, $attribute);
98
                    }
99
100
                    // We only need to make the last part of the slug unique
101
                    if ($index === count($sources) - 1) {
102
                        $slug = $this->makeSlugUnique($slug, $config, $attribute);
103
                    } else {
104
                        $slug = $source;
105
                    }
106
107
                    $slugs[] = $slug;
108
                }
109
110
                $slug = implode($config['field_separator'], $slugs);
111
            }
112
        }
113
114
        return $slug;
115
    }
116
117
    /**
118
     * Determines whether the model needs slugging.
119
     *
120
     * @param string $attribute
121
     * @param array $config
122
     *
123
     * @return bool
124
     */
125
    protected function needsSlugging(string $attribute, array $config): bool
126
    {
127
        if (
128
            $config['onUpdate'] === true ||
129
            empty($this->model->getAttributeValue($attribute))
130
        ) {
131
            return true;
132
        }
133
134
        if ($this->model->isDirty($attribute)) {
135
            return false;
136
        }
137
138
        return (!$this->model->exists);
139
    }
140
141
    /**
142
     * Get the source strings for the slug.
143
     *
144
     * @param mixed $from
145
     *
146
     * @return array
147
     */
148
    protected function getSlugSources($from): array
149
    {
150
        if (is_null($from)) {
151
            return $this->model->__toString();
152
        }
153
154
        $sourceStrings = array_map(function ($key) {
155
            $value = data_get($this->model, $key);
156
            if (is_bool($value)) {
157
                $value = (int) $value;
158
            }
159
160
            return $value;
161
        }, (array) $from);
162
163
        return $sourceStrings;
164
    }
165
166
    /**
167
     * Generate a slug from the given source string.
168
     *
169
     * @param string $source
170
     * @param array $config
171
     * @param string $attribute
172
     *
173
     * @return string
174
     * @throws \UnexpectedValueException
175
     */
176
    protected function generateSlug(string $source, array $config, string $attribute): string
177
    {
178
        $separator          = $config['separator'];
179
        $method             = $config['method'];
180
        $maxLength          = $config['maxLength'];
181
        $maxLengthKeepWords = $config['maxLengthKeepWords'];
182
183 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...
184
            $slugEngine = $this->getSlugEngine($attribute);
185
            $slug       = $slugEngine->slugify($source, $separator);
186
        } elseif (is_callable($method)) {
187
            $slug = call_user_func($method, $source, $separator);
188
        } else {
189
            throw new \UnexpectedValueException('Sluggable "method" for ' . get_class($this->model) . ':' . $attribute . ' is not callable nor null.');
190
        }
191
192
        $len = mb_strlen($slug);
193
        if (is_string($slug) && $maxLength && $len > $maxLength) {
194
            $reverseOffset    = $maxLength - $len;
195
            $lastSeparatorPos = mb_strrpos($slug, $separator, $reverseOffset);
196
            if ($maxLengthKeepWords && $lastSeparatorPos !== false) {
197
                $slug = mb_substr($slug, 0, $lastSeparatorPos);
198
            } else {
199
                $slug = trim(mb_substr($slug, 0, $maxLength), $separator);
200
            }
201
        }
202
203
        return $slug;
204
    }
205
206
    /**
207
     * Return a class that has a `slugify()` method, used to convert
208
     * strings into slugs.
209
     *
210
     * @param string $attribute
211
     *
212
     * @return \Cocur\Slugify\Slugify
213
     */
214
    protected function getSlugEngine(string $attribute): Slugify
215
    {
216
        static $slugEngines = [];
217
218
        $key = get_class($this->model) . '.' . $attribute;
219
220
        if (!array_key_exists($key, $slugEngines)) {
221
            $engine = new Slugify();
222
            if (method_exists($this->model, 'customizeSlugEngine')) {
223
                $engine = $this->model->customizeSlugEngine($engine, $attribute);
224
            }
225
226
            $slugEngines[$key] = $engine;
227
        }
228
229
        return $slugEngines[$key];
230
    }
231
232
    /**
233
     * Checks that the given slug is not a reserved word.
234
     *
235
     * @param string $slug
236
     * @param array $config
237
     * @param string $attribute
238
     *
239
     * @return string
240
     * @throws \UnexpectedValueException
241
     */
242
    protected function validateSlug(string $slug, array $config, string $attribute): string
243
    {
244
        $separator = $config['separator'];
245
        $reserved  = $config['reserved'];
246
247
        if ($reserved === null) {
248
            return $slug;
249
        }
250
251
        // check for reserved names
252
        if ($reserved instanceof \Closure) {
253
            $reserved = $reserved($this->model);
254
        }
255
256
        if (is_array($reserved)) {
257
            if (in_array($slug, $reserved)) {
258
                $method = $config['uniqueSuffix'];
259 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...
260
                    $suffix = $this->generateSuffix($slug, $separator, collect($reserved));
261
                } elseif (is_callable($method)) {
262
                    $suffix = $method($slug, $separator, collect($reserved));
263
                } else {
264
                    throw new \UnexpectedValueException('Sluggable "uniqueSuffix" for ' . get_class($this->model) . ':' . $attribute . ' is not null, or a closure.');
265
                }
266
267
                return $slug . $separator . $suffix;
268
            }
269
270
            return $slug;
271
        }
272
273
        throw new \UnexpectedValueException('Sluggable "reserved" for ' . get_class($this->model) . ':' . $attribute . ' is not null, an array, or a closure that returns null/array.');
274
    }
275
276
    /**
277
     * Checks if the slug should be unique, and makes it so if needed.
278
     *
279
     * @param string $slug
280
     * @param array $config
281
     * @param string $attribute
282
     *
283
     * @return string
284
     * @throws \UnexpectedValueException
285
     */
286
    protected function makeSlugUnique(string $slug, array $config, string $attribute): string
287
    {
288
        if (!$config['unique']) {
289
            return $slug;
290
        }
291
292
        $separator = $config['separator'];
293
294
        // find all models where the slug is like the current one
295
        $list = $this->getExistingSlugs($slug, $attribute, $config);
296
297
        // if ...
298
        //     a) the list is empty, or
299
        //     b) our slug isn't in the list
300
        // ... we are okay
301
        if (
302
            $list->count() === 0 ||
303
            $list->contains($slug) === false
304
        ) {
305
            return $slug;
306
        }
307
308
        // if our slug is in the list, but
309
        //     a) it's for our model, or
310
        //  b) it looks like a suffixed version of our slug
311
        // ... we are also okay (use the current slug)
312
        if ($list->has($this->model->getKey())) {
313
            $currentSlug = $list->get($this->model->getKey());
314
315
            if (
316
                $currentSlug === $slug ||
317
                !$slug || strpos($currentSlug, $slug) === 0
318
            ) {
319
                return $currentSlug;
320
            }
321
        }
322
323
        $method = $config['uniqueSuffix'];
324 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...
325
            $suffix = $this->generateSuffix($slug, $separator, $list);
326
        } elseif (is_callable($method)) {
327
            $suffix = $method($slug, $separator, $list);
328
        } else {
329
            throw new \UnexpectedValueException('Sluggable "uniqueSuffix" for ' . get_class($this->model) . ':' . $attribute . ' is not null, or a closure.');
330
        }
331
332
        return $slug . $separator . $suffix;
333
    }
334
335
    /**
336
     * Generate a unique suffix for the given slug (and list of existing, "similar" slugs.
337
     *
338
     * @param string $slug
339
     * @param string $separator
340
     * @param \Illuminate\Support\Collection $list
341
     *
342
     * @return string
343
     */
344
    protected function generateSuffix(string $slug, string $separator, Collection $list): string
345
    {
346
        $len = strlen($slug . $separator);
347
348
        // If the slug already exists, but belongs to
349
        // our model, return the current suffix.
350
        if ($list->search($slug) === $this->model->getKey()) {
351
            $suffix = explode($separator, $slug);
352
353
            return end($suffix);
354
        }
355
356
        $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...
357
            return (int) substr($value, $len);
358
        });
359
360
        // find the highest value and return one greater.
361
        return $list->max() + 1;
362
    }
363
364
    /**
365
     * Get all existing slugs that are similar to the given slug.
366
     *
367
     * @param string $slug
368
     * @param string $attribute
369
     * @param array $config
370
     *
371
     * @return \Illuminate\Support\Collection
372
     */
373
    protected function getExistingSlugs(string $slug, string $attribute, array $config): Collection
374
    {
375
        $includeTrashed = $config['includeTrashed'];
376
377
        $query = $this->model->newQuery()
378
            ->findSimilarSlugs($attribute, $config, $slug);
379
380
        // use the model scope to find similar slugs
381
        if (method_exists($this->model, 'scopeWithUniqueSlugConstraints')) {
382
            $query->withUniqueSlugConstraints($this->model, $attribute, $config, $slug);
383
        }
384
385
        // include trashed models if required
386
        if ($includeTrashed && $this->usesSoftDeleting()) {
387
            $query->withTrashed();
388
        }
389
390
        // get the list of all matching slugs
391
        $results = $query->select([$attribute, $this->model->getQualifiedKeyName()])
392
            ->get()
393
            ->toBase();
394
395
        // key the results and return
396
        return $results->pluck($attribute, $this->model->getKeyName());
397
    }
398
399
    /**
400
     * Does this model use softDeleting?
401
     *
402
     * @return bool
403
     */
404
    protected function usesSoftDeleting(): bool
405
    {
406
        return method_exists($this->model, 'bootSoftDeletes');
407
    }
408
409
    /**
410
     * Generate a unique slug for a given string.
411
     *
412
     * @param \Illuminate\Database\Eloquent\Model|string $model
413
     * @param string $attribute
414
     * @param string $fromString
415
     * @param array|null $config
416
     *
417
     * @return string
418
     * @throws \InvalidArgumentException
419
     * @throws \UnexpectedValueException
420
     */
421
    public static function createSlug($model, string $attribute, string $fromString, array $config = null): string
422
    {
423
        if (is_string($model)) {
424
            $model = new $model;
425
        }
426
        /** @var static $instance */
427
        $instance = (new static())->setModel($model);
428
429
        if ($config === null) {
430
            $config = Arr::get($model->sluggable(), $attribute);
431
            if ($config === null) {
432
                $modelClass = get_class($model);
433
                throw new \InvalidArgumentException("Argument 2 passed to SlugService::createSlug ['{$attribute}'] is not a valid slug attribute for model {$modelClass}.");
434
            }
435
        } elseif (!is_array($config)) {
436
            throw new \UnexpectedValueException('SlugService::createSlug expects an array or null as the fourth argument; ' . gettype($config) . ' given.');
437
        }
438
439
        $config = $instance->getConfiguration($config);
440
441
        $slug = $instance->generateSlug($fromString, $config, $attribute);
442
        $slug = $instance->validateSlug($slug, $config, $attribute);
443
        $slug = $instance->makeSlugUnique($slug, $config, $attribute);
444
445
        return $slug;
446
    }
447
448
    /**
449
     * @param \Illuminate\Database\Eloquent\Model $model
450
     *
451
     * @return $this
452
     */
453
    public function setModel(Model $model)
454
    {
455
        $this->model = $model;
456
457
        return $this;
458
    }
459
}
460