Completed
Pull Request — master (#465)
by
unknown
11:44
created

SlugService::makeSlugUnique()   B

Complexity

Conditions 10
Paths 12

Size

Total Lines 51

Duplication

Lines 7
Ratio 13.73 %

Importance

Changes 0
Metric Value
dl 7
loc 51
rs 7.2024
c 0
b 0
f 0
cc 10
nc 12
nop 3

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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
        //  c) it's not empty
290
        // ... we are also okay (use the current slug)
291
        if ($this->model->{$attribute}) {
292
            if ($list->has($this->model->getKey())) {
293
                $currentSlug = $list->get($this->model->getKey());
294
295
                if (
296
                    $currentSlug === $slug ||
297
                    strpos($currentSlug, $slug) === 0
298
                ) {
299
                    return $currentSlug;
300
                }
301
            }
302
        }
303
304
        $method = $config['uniqueSuffix'];
305 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...
306
            $suffix = $this->generateSuffix($slug, $separator, $list);
307
        } elseif (is_callable($method)) {
308
            $suffix = $method($slug, $separator, $list);
309
        } else {
310
            throw new \UnexpectedValueException('Sluggable "uniqueSuffix" for ' . get_class($this->model) . ':' . $attribute . ' is not null, or a closure.');
311
        }
312
313
        return $slug . $separator . $suffix;
314
    }
315
316
    /**
317
     * Generate a unique suffix for the given slug (and list of existing, "similar" slugs.
318
     *
319
     * @param string $slug
320
     * @param string $separator
321
     * @param \Illuminate\Support\Collection $list
322
     *
323
     * @return string
324
     */
325
    protected function generateSuffix(string $slug, string $separator, Collection $list): string
326
    {
327
        $len = strlen($slug . $separator);
328
329
        // If the slug already exists, but belongs to
330
        // our model, return the current suffix.
331
        if ($list->search($slug) === $this->model->getKey()) {
332
            $suffix = explode($separator, $slug);
333
334
            return end($suffix);
335
        }
336
337
        $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...
338
            return (int) substr($value, $len);
339
        });
340
341
        // find the highest value and return one greater.
342
        return $list->max() + 1;
343
    }
344
345
    /**
346
     * Get all existing slugs that are similar to the given slug.
347
     *
348
     * @param string $slug
349
     * @param string $attribute
350
     * @param array $config
351
     *
352
     * @return \Illuminate\Support\Collection
353
     */
354
    protected function getExistingSlugs(string $slug, string $attribute, array $config): Collection
355
    {
356
        $includeTrashed = $config['includeTrashed'];
357
358
        $query = $this->model->newQuery()
359
            ->findSimilarSlugs($attribute, $config, $slug);
360
361
        // use the model scope to find similar slugs
362
        if (method_exists($this->model, 'scopeWithUniqueSlugConstraints')) {
363
            $query->withUniqueSlugConstraints($this->model, $attribute, $config, $slug);
364
        }
365
366
        // include trashed models if required
367
        if ($includeTrashed && $this->usesSoftDeleting()) {
368
            $query->withTrashed();
369
        }
370
371
        // get the list of all matching slugs
372
        $results = $query->select([$attribute, $this->model->getQualifiedKeyName()])
373
            ->get()
374
            ->toBase();
375
376
        // key the results and return
377
        return $results->pluck($attribute, $this->model->getKeyName());
378
    }
379
380
    /**
381
     * Does this model use softDeleting?
382
     *
383
     * @return bool
384
     */
385
    protected function usesSoftDeleting(): bool
386
    {
387
        return method_exists($this->model, 'bootSoftDeletes');
388
    }
389
390
    /**
391
     * Generate a unique slug for a given string.
392
     *
393
     * @param \Illuminate\Database\Eloquent\Model|string $model
394
     * @param string $attribute
395
     * @param string $fromString
396
     * @param array|null $config
397
     *
398
     * @return string
399
     * @throws \InvalidArgumentException
400
     * @throws \UnexpectedValueException
401
     */
402
    public static function createSlug($model, string $attribute, string $fromString, array $config = null): string
403
    {
404
        if (is_string($model)) {
405
            $model = new $model;
406
        }
407
        /** @var static $instance */
408
        $instance = (new static())->setModel($model);
409
410
        if ($config === null) {
411
            $config = Arr::get($model->sluggable(), $attribute);
412
            if ($config === null) {
413
                $modelClass = get_class($model);
414
                throw new \InvalidArgumentException("Argument 2 passed to SlugService::createSlug ['{$attribute}'] is not a valid slug attribute for model {$modelClass}.");
415
            }
416
        } elseif (!is_array($config)) {
417
            throw new \UnexpectedValueException('SlugService::createSlug expects an array or null as the fourth argument; ' . gettype($config) . ' given.');
418
        }
419
420
        $config = $instance->getConfiguration($config);
421
422
        $slug = $instance->generateSlug($fromString, $config, $attribute);
423
        $slug = $instance->validateSlug($slug, $config, $attribute);
424
        $slug = $instance->makeSlugUnique($slug, $config, $attribute);
425
426
        return $slug;
427
    }
428
429
    /**
430
     * @param \Illuminate\Database\Eloquent\Model $model
431
     *
432
     * @return $this
433
     */
434
    public function setModel(Model $model)
435
    {
436
        $this->model = $model;
437
438
        return $this;
439
    }
440
}
441