Completed
Push — master ( 4fd0f8...3b1739 )
by Colin
07:33
created

SlugService::createSlug()   B

Complexity

Conditions 4
Paths 6

Size

Total Lines 22
Code Lines 13

Duplication

Lines 0
Ratio 0 %

Importance

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