Eloquent: уникальный слаг записи

Опубликовано 3203 просмотра
Eloquent: уникальный слаг записи

ЧПУ (человеко-понятный урл) уже давно является одним из факторов, о которых не говорят во время обсуждения списка работ над сайтом но который де-факто является обязательным пунктом в списке. Лично у меня за последние несколько лет (а быть может и больше) не было ни одного сайта, где бы отсутствовал этот элемент.

Для каждой модели, имеющей отдельную страницу для вывода информации со строкой из хранилища при создании и/или обновлении строки генерируется слаг. Самый простой вариант если вы используете Laravel или отдельно пакет illuminate/database - воспользоваться идущей в комплекте функцией str_slug. Она заменяет невалидные символы на валидные, чистит строку от невозможных для преобразования символов и приводит ее к нижнему регистру.

use Illuminate\Support\Str;

$slug = Str::slug('Какая-то запись.'); // kakaya-to-zapis

Но на этапе разработки необходимо предусмотреть вариант развития событий, когда в хранилище уже будет существовать строка с указанным пользователем слагом. Я хочу предложить свое решение в виде трейта, который можно использовать в любом месте при создании новой или обновлении существующей строки.

Трейт содержит:

  • приватное свойство $slugMaxLength - максимальное число символов слага
  • приватное свойство $slugKeyName - название колонки таблицы с слагом
  • публичный метод generateSlug($model, string $slug) - возвращает уникальный слаг

Первым аргументом метода указывается название модели или экземпляр существующей модели Eloquent. Первый вариант ищет совпадения по всей таблице, при втором из выборки исключается указанная строка. Проще говоря, указание аргумента как строки необходимо при создании элемента, как экземпляра модели - при обновлении.

Второй аргумент - строка, которая будет преобразована в слаг. Форматирование строки функцией str_slug не обязательно - эта работа будет выполнена внутри.

<?php

namespace App\Traits;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;

/**
 * Trait UniqueModelSlug
 * @package App\Traits
 */
trait UniqueModelSlug
{
    private $slugMaxLength = 50;

    private $slugKeyName = 'slug';

    /**
     * @param string|Model $model
     * @param string $slug
     *
     * @return string
     */
    public function generateSlug($model, string $slug)
    {
        $model = $model instanceof Model ? $model->where($model->getKeyName(), '!=', $model->getKey()) : new $model;
        $slug = Str::limit(Str::slug($slug), $this->slugMaxLength, '');

        // Если слаг не существует - возвращаем исходную строку
        if(!$model->where($this->slugKeyName, $slug)->exists()) {
            return $slug;
        }

        // Удаляем число в начале строки
        $slug = preg_replace('/^(\d+)-(\w+)$/', '$2', $slug);

        // Ищем в базе совпадения
        $similarModels = $model->whereRaw("`$this->slugKeyName` REGEXP ?", '^[[:digit:]]+-'.Str::limit($slug, intval($this->slugMaxLength / 2)))->pluck($this->slugKeyName);

        // не находим - возвращаем слаг с номером 1
        // иначе - номер перед слагом + 1
        if($similarModels->isEmpty()) {
            return Str::limit("1-$slug", $this->slugMaxLength, '');
        }

        $lastSlugNumModel = 0;

        foreach($similarModels as $i) {
            $num = preg_replace('/^(\d+)-(.+)$/', '$1', $i);

            if($num > $lastSlugNumModel) {
                $lastSlugNumModel = $num;
            }
        }

        $lastSlugNum = $lastSlugNumModel + 1;

        return Str::limit("$lastSlugNum-$slug", $this->slugMaxLength, '');
    }

    /**
     * @param int $length
     * @return $this
     */
    public function setSlugMaxLength(int $length)
    {
        $this->slugMaxLength = $length;

        return $this;
    }

    /**
     * @param string $name
     * @return $this
     */
    public function setSlugKeyName(string $name)
    {
        $this->slugKeyName = $name;

        return $this;
    }
}

Как работает метод? Первым делом $slug форматируется функцией str_slug с обрезанием лишних символов. Затем происходит запрос в хранилище на существование строки с таким слагом. Если результат отрицательный - возврашается отформативанный слаг.

Регулярным выражение удаляется число в начале слага если оно существует. Происходит получение первой записи с конца со слагом, подходящим под регулярное выражение число-$slug. Если ничего не найдено - возвращается строка 1-$slug.

Достаем из полученного слага число, увеличиваем его на 1 и возвращаем число+1-$slug. Разберем на примере.

<?php

namespace App\Http\Controllers;

use App\Traits\UniqueModelSlug;
use App\Models\Post;

class PostController()
{
    use UniqueModelSlug;

    public function store()
    {
        $existsPost = Post::where('slug', 'blog')
            ->firstOrFail(); // Строка с слагом blog существует

        $name = 'Блог';

        $post = new Post();
        $post->slug - $this->generateSlug(
            Post::class, // указываем модель строкой, т.к. добавляем новую строку
            $name
        ); // 1-blog

        $post->save();
    }

    public function update(Post $post)
    {    
        $existsPost = Post::where('slug', 'blog')
            ->where('id', '!=', $post->id)
            ->firstOrFail(); // Другая строка с слагом blog существует

        $name = 'Блог';

        $post->slug = $this->generateSlug(
            $post, // указываем модель экземпляром, т.к. обновляем существующую строку
            $name
        ); // 1-blog

        $post->save();
    }
}