Completed
Push — master ( 805b75...d36e06 )
by Colin
12s queued 10s
created

SlugService   D

Complexity

Total Complexity 58

Size/Duplication

Total Lines 425
Duplicated Lines 5.18 %

Coupling/Cohesion

Components 1
Dependencies 4

Importance

Changes 0
Metric Value
wmc 58
lcom 1
cbo 4
dl 22
loc 425
rs 4.5599
c 0
b 0
f 0

14 Methods

Rating   Name   Duplication   Size   Complexity  
A slug() 0 24 4
A getConfiguration() 0 6 1
A buildSlug() 0 16 5
A needsSlugging() 0 15 4
A getSlugSource() 0 17 3
B generateSlug() 8 29 8
A getSlugEngine() 0 17 3
B validateSlug() 7 33 7
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
B makeSlugUnique() 7 48 10

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