Completed
Push — master ( a70425...ca8c16 )
by Colin
01:25
created

SlugService::makeSlugUnique()   B

Complexity

Conditions 10
Paths 9

Size

Total Lines 50

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 50
rs 7.2242
c 0
b 0
f 0
cc 10
nc 9
nop 3

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