Completed
Push — master ( 762de8...ae630f )
by Colin
01:24
created

SlugService::getSlugSource()   A

Complexity

Conditions 3
Paths 2

Size

Total Lines 17

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 17
rs 9.7
c 0
b 0
f 0
cc 3
nc 2
nop 1
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
                if ($method === null) {
241
                    $suffix = $this->generateSuffix($slug, $separator, collect($reserved));
242
                } elseif (is_callable($method)) {
243
                    $suffix = $method($slug, $separator, collect($reserved));
244
                } else {
245
                    throw new \UnexpectedValueException('Sluggable "uniqueSuffix" for ' . get_class($this->model) . ':' . $attribute . ' is not null, or a closure.');
246
                }
247
248
                return $slug . $separator . $suffix;
249
            }
250
251
            return $slug;
252
        }
253
254
        throw new \UnexpectedValueException('Sluggable "reserved" for ' . get_class($this->model) . ':' . $attribute . ' is not null, an array, or a closure that returns null/array.');
255
    }
256
257
    /**
258
     * Checks if the slug should be unique, and makes it so if needed.
259
     *
260
     * @param string $slug
261
     * @param array $config
262
     * @param string $attribute
263
     *
264
     * @return string
265
     * @throws \UnexpectedValueException
266
     */
267
    protected function makeSlugUnique(string $slug, array $config, string $attribute): string
268
    {
269
        if (!$config['unique']) {
270
            return $slug;
271
        }
272
273
        $separator = $config['separator'];
274
275
        // find all models where the slug is like the current one
276
        $list = $this->getExistingSlugs($slug, $attribute, $config);
277
278
        // if ...
279
        // 	a) the list is empty, or
280
        // 	b) our slug isn't in the list
281
        // ... we are okay
282
        if (
283
            $list->count() === 0 ||
284
            $list->contains($slug) === false
285
        ) {
286
            return $slug;
287
        }
288
289
        // if our slug is in the list, but
290
        // 	a) it's for our model, or
291
        //  b) it looks like a suffixed version of our slug
292
        // ... we are also okay (use the current slug)
293
        if ($list->has($this->model->getKey())) {
294
            $currentSlug = $list->get($this->model->getKey());
295
296
            if (
297
                $currentSlug === $slug ||
298
                !$slug || strpos($currentSlug, $slug) === 0
299
            ) {
300
                return $currentSlug;
301
            }
302
        }
303
304
        $method = $config['uniqueSuffix'];
305
        if ($method === null) {
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
        $query->withUniqueSlugConstraints($this->model, $attribute, $config, $slug);
363
364
        // include trashed models if required
365
        if ($includeTrashed && $this->usesSoftDeleting()) {
366
            $query->withTrashed();
367
        }
368
369
        // get the list of all matching slugs
370
        $results = $query->select([$attribute, $this->model->getQualifiedKeyName()])
371
            ->get()
372
            ->toBase();
373
374
        // key the results and return
375
        return $results->pluck($attribute, $this->model->getKeyName());
376
    }
377
378
    /**
379
     * Does this model use softDeleting?
380
     *
381
     * @return bool
382
     */
383
    protected function usesSoftDeleting(): bool
384
    {
385
        return method_exists($this->model, 'bootSoftDeletes');
386
    }
387
388
    /**
389
     * Generate a unique slug for a given string.
390
     *
391
     * @param \Illuminate\Database\Eloquent\Model|string $model
392
     * @param string $attribute
393
     * @param string $fromString
394
     * @param array|null $config
395
     *
396
     * @return string
397
     * @throws \InvalidArgumentException
398
     * @throws \UnexpectedValueException
399
     */
400
    public static function createSlug($model, string $attribute, string $fromString, array $config = null): string
401
    {
402
        if (is_string($model)) {
403
            $model = new $model;
404
        }
405
        /** @var static $instance */
406
        $instance = (new static())->setModel($model);
407
408
        if ($config === null) {
409
            $config = Arr::get($model->sluggable(), $attribute);
410
            if ($config === null) {
411
                $modelClass = get_class($model);
412
                throw new \InvalidArgumentException("Argument 2 passed to SlugService::createSlug ['{$attribute}'] is not a valid slug attribute for model {$modelClass}.");
413
            }
414
        } elseif (!is_array($config)) {
415
            throw new \UnexpectedValueException('SlugService::createSlug expects an array or null as the fourth argument; ' . gettype($config) . ' given.');
416
        }
417
418
        $config = $instance->getConfiguration($config);
419
420
        $slug = $instance->generateSlug($fromString, $config, $attribute);
421
        $slug = $instance->validateSlug($slug, $config, $attribute);
422
        $slug = $instance->makeSlugUnique($slug, $config, $attribute);
423
424
        return $slug;
425
    }
426
427
    /**
428
     * @param \Illuminate\Database\Eloquent\Model $model
429
     *
430
     * @return $this
431
     */
432
    public function setModel(Model $model): self
433
    {
434
        $this->model = $model;
435
436
        return $this;
437
    }
438
}
439