Completed
Pull Request — master (#507)
by
unknown
01:31
created

SlugService::generateSuffix()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 19

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 19
rs 9.6333
c 0
b 0
f 0
cc 2
nc 2
nop 3
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
        if (is_callable($from)) {
132
            $value = $from( $this->model );
133
            return is_string($value) ? $value : null;
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 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...
166
            $slugEngine = $this->getSlugEngine($attribute);
167
            $slug = $slugEngine->slugify($source, $separator);
168
        } elseif (is_callable($method)) {
169
            $slug = call_user_func($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
     * @return \Cocur\Slugify\Slugify
195
     */
196
    protected function getSlugEngine(string $attribute): Slugify
197
    {
198
        static $slugEngines = [];
199
200
        $key = get_class($this->model) . '.' . $attribute;
201
202
        if (!array_key_exists($key, $slugEngines)) {
203
            $engine = new Slugify();
204
            if (method_exists($this->model, 'customizeSlugEngine')) {
205
                $engine = $this->model->customizeSlugEngine($engine, $attribute);
206
            }
207
208
            $slugEngines[$key] = $engine;
209
        }
210
211
        return $slugEngines[$key];
212
    }
213
214
    /**
215
     * Checks that the given slug is not a reserved word.
216
     *
217
     * @param string $slug
218
     * @param array $config
219
     * @param string $attribute
220
     *
221
     * @return string
222
     * @throws \UnexpectedValueException
223
     */
224
    protected function validateSlug(string $slug, array $config, string $attribute): string
225
    {
226
        $separator = $config['separator'];
227
        $reserved = $config['reserved'];
228
229
        if ($reserved === null) {
230
            return $slug;
231
        }
232
233
        // check for reserved names
234
        if ($reserved instanceof \Closure) {
235
            $reserved = $reserved($this->model);
236
        }
237
238
        if (is_array($reserved)) {
239
            if (in_array($slug, $reserved)) {
240
                $method = $config['uniqueSuffix'];
241 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...
242
                    $suffix = $this->generateSuffix($slug, $separator, collect($reserved));
243
                } elseif (is_callable($method)) {
244
                    $suffix = $method($slug, $separator, collect($reserved));
245
                } else {
246
                    throw new \UnexpectedValueException('Sluggable "uniqueSuffix" for ' . get_class($this->model) . ':' . $attribute . ' is not null, or a closure.');
247
                }
248
249
                return $slug . $separator . $suffix;
250
            }
251
252
            return $slug;
253
        }
254
255
        throw new \UnexpectedValueException('Sluggable "reserved" for ' . get_class($this->model) . ':' . $attribute . ' is not null, an array, or a closure that returns null/array.');
256
    }
257
258
    /**
259
     * Checks if the slug should be unique, and makes it so if needed.
260
     *
261
     * @param string $slug
262
     * @param array $config
263
     * @param string $attribute
264
     *
265
     * @return string
266
     * @throws \UnexpectedValueException
267
     */
268
    protected function makeSlugUnique(string $slug, array $config, string $attribute): string
269
    {
270
        if (!$config['unique']) {
271
            return $slug;
272
        }
273
274
        $separator = $config['separator'];
275
276
        // find all models where the slug is like the current one
277
        $list = $this->getExistingSlugs($slug, $attribute, $config);
278
279
        // if ...
280
        // 	a) the list is empty, or
281
        // 	b) our slug isn't in the list
282
        // ... we are okay
283
        if (
284
            $list->count() === 0 ||
285
            $list->contains($slug) === false
286
        ) {
287
            return $slug;
288
        }
289
290
        // if our slug is in the list, but
291
        // 	a) it's for our model, or
292
        //  b) it looks like a suffixed version of our slug
293
        // ... we are also okay (use the current slug)
294
        if ($list->has($this->model->getKey())) {
295
            $currentSlug = $list->get($this->model->getKey());
296
297
            if (
298
                $currentSlug === $slug ||
299
                !$slug || strpos($currentSlug, $slug) === 0
300
            ) {
301
                return $currentSlug;
302
            }
303
        }
304
305
        $method = $config['uniqueSuffix'];
306 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...
307
            $suffix = $this->generateSuffix($slug, $separator, $list);
308
        } elseif (is_callable($method)) {
309
            $suffix = $method($slug, $separator, $list);
310
        } else {
311
            throw new \UnexpectedValueException('Sluggable "uniqueSuffix" for ' . get_class($this->model) . ':' . $attribute . ' is not null, or a closure.');
312
        }
313
314
        return $slug . $separator . $suffix;
315
    }
316
317
    /**
318
     * Generate a unique suffix for the given slug (and list of existing, "similar" slugs.
319
     *
320
     * @param string $slug
321
     * @param string $separator
322
     * @param \Illuminate\Support\Collection $list
323
     *
324
     * @return string
325
     */
326
    protected function generateSuffix(string $slug, string $separator, Collection $list): string
327
    {
328
        $len = strlen($slug . $separator);
329
330
        // If the slug already exists, but belongs to
331
        // our model, return the current suffix.
332
        if ($list->search($slug) === $this->model->getKey()) {
333
            $suffix = explode($separator, $slug);
334
335
            return end($suffix);
336
        }
337
338
        $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...
339
            return (int) substr($value, $len);
340
        });
341
342
        // find the highest value and return one greater.
343
        return $list->max() + 1;
344
    }
345
346
    /**
347
     * Get all existing slugs that are similar to the given slug.
348
     *
349
     * @param string $slug
350
     * @param string $attribute
351
     * @param array $config
352
     *
353
     * @return \Illuminate\Support\Collection
354
     */
355
    protected function getExistingSlugs(string $slug, string $attribute, array $config): Collection
356
    {
357
        $includeTrashed = $config['includeTrashed'];
358
359
        $query = $this->model->newQuery()
360
            ->findSimilarSlugs($attribute, $config, $slug);
361
362
        // use the model scope to find similar slugs
363
        if (method_exists($this->model, 'scopeWithUniqueSlugConstraints')) {
364
            $query->withUniqueSlugConstraints($this->model, $attribute, $config, $slug);
365
        }
366
367
        // include trashed models if required
368
        if ($includeTrashed && $this->usesSoftDeleting()) {
369
            $query->withTrashed();
370
        }
371
372
        // get the list of all matching slugs
373
        $results = $query->select([$attribute, $this->model->getQualifiedKeyName()])
374
            ->get()
375
            ->toBase();
376
377
        // key the results and return
378
        return $results->pluck($attribute, $this->model->getKeyName());
379
    }
380
381
    /**
382
     * Does this model use softDeleting?
383
     *
384
     * @return bool
385
     */
386
    protected function usesSoftDeleting(): bool
387
    {
388
        return method_exists($this->model, 'bootSoftDeletes');
389
    }
390
391
    /**
392
     * Generate a unique slug for a given string.
393
     *
394
     * @param \Illuminate\Database\Eloquent\Model|string $model
395
     * @param string $attribute
396
     * @param string $fromString
397
     * @param array|null $config
398
     *
399
     * @return string
400
     * @throws \InvalidArgumentException
401
     * @throws \UnexpectedValueException
402
     */
403
    public static function createSlug($model, string $attribute, string $fromString, array $config = null): string
404
    {
405
        if (is_string($model)) {
406
            $model = new $model;
407
        }
408
        /** @var static $instance */
409
        $instance = (new static())->setModel($model);
410
411
        if ($config === null) {
412
            $config = Arr::get($model->sluggable(), $attribute);
413
            if ($config === null) {
414
                $modelClass = get_class($model);
415
                throw new \InvalidArgumentException("Argument 2 passed to SlugService::createSlug ['{$attribute}'] is not a valid slug attribute for model {$modelClass}.");
416
            }
417
        } elseif (!is_array($config)) {
418
            throw new \UnexpectedValueException('SlugService::createSlug expects an array or null as the fourth argument; ' . gettype($config) . ' given.');
419
        }
420
421
        $config = $instance->getConfiguration($config);
422
423
        $slug = $instance->generateSlug($fromString, $config, $attribute);
424
        $slug = $instance->validateSlug($slug, $config, $attribute);
425
        $slug = $instance->makeSlugUnique($slug, $config, $attribute);
426
427
        return $slug;
428
    }
429
430
    /**
431
     * @param \Illuminate\Database\Eloquent\Model $model
432
     *
433
     * @return $this
434
     */
435
    public function setModel(Model $model)
436
    {
437
        $this->model = $model;
438
439
        return $this;
440
    }
441
}
442