Создание сексуального фильтра запросов

Фильтр запросов… знакомая проблема при разработке системы. Но когда вы начинаете кодить, у каждого разработчика возникает множество знакомых вопросов: «Где мне разместить эту логику запросов? Как я должен управлять ею для удобства использования?». Честно говоря, для каждого проекта, который я разрабатываю, я пишу в своем стиле, основываясь на опыте создания предыдущих проектов. И каждый раз, когда я начинаю новый проект, я задаю себе один и тот же вопрос, на этот раз — как мне организовать фильтр запросов! Эту статью можно рассматривать как пошаговую разработку системы фильтра запросов, с соответствующими проблемами.

Контекст

На момент написания статьи я использую Laravel 9, на PHP 8.1 и MySQL 8. Я считаю, что технологический стек не является существенной проблемой, здесь мы сфокусируемся в основном на построении системы фильтрации запросов. В этой статье я продемонстрирую построение фильтра для таблицы users.

<?php

use IlluminateDatabaseMigrationsMigration;
use IlluminateDatabaseSchemaBlueprint;
use IlluminateSupportFacadesSchema;

return new class extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('users', function (Blueprint $table) {
            $table->id();
            $table->string('name');
            $table->string('email')->unique();
            $table->string('gender', 10)->nullable()->index();
            $table->boolean('is_active')->default(true)->index();
            $table->boolean('is_admin')->default(false)->index();
            $table->timestamp('birthday')->nullable();
            $table->timestamp('email_verified_at')->nullable();
            $table->string('password');
            $table->rememberToken();
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('users');
    }
}
Вход в полноэкранный режим Выход из полноэкранного режима

Кроме того, я также использую Laravel Telescope, чтобы легко отслеживать выполнение запроса.

Начальная точка

В первые дни освоения Laravel я часто напрямую вызывал фильтр прямо в контроллере. Просто, без магии, легко понять, но у этого способа есть проблемы:

  • Большое количество логики, размещенной в контроллере, приводит к его разбуханию
  • Невозможно повторное использование
  • Повторяется много одних и тех же операций
<?php

namespace AppHttpControllers;

use AppModelsUser;
use IlluminateHttpRequest;

class UserController extends Controller
{
    public function __invoke(Request $request)
    {
        // /users?name=ryder&email=hartman&gender=male&is_active=1&is_admin=0&birthday=2014-11-30
        $query = User::query();

        if ($request->has('name')) {
            $query->where('name', 'like', "%{$request->input('name')}%");
        }

        if ($request->has('email')) {
            $query->where('email', 'like', "%{$request->input('email')}%");
        }

        if ($request->has('gender')) {
            $query->where('gender', $request->input('gender'));
        }

        if ($request->has('is_active')) {
            $query->where('is_active', $request->input('is_active') ? 1 : 0);
        }

        if ($request->has('is_admin')) {
            $query->where('is_admin', $request->input('is_admin') ? 1 : 0);
        }

        if ($request->has('birthday')) {
            $query->whereDate('birthday', $request->input('birthday'));
        }

        return $query->paginate();

        // select * from `users` where `name` like '%ryder%' and `email` like '%hartman%' and `gender` = 'male' and `is_active` = 1 and `is_admin` = 0 and date(`birthday`) = '2014-11-30' limit 15 offset 0
    }
}
Вход в полноэкранный режим Выход из полноэкранного режима

Использование локального диапазона

Чтобы иметь возможность скрыть логику во время фильтрации, давайте попробуем использовать локальную область действия Laravel. Превратите запросы в диапазоны функций в модели User:


// User.php
public function scopeName(Builder $query): Builder
{
    if (request()->has('name')) {
        $query->where('name', 'like', "%" . request()->input('name') . "%");
    }
    return $query;
}

public function scopeEmail(Builder $query): Builder
{
    if (request()->has('email')) {
        $query->where('email', 'like', "%" . request()->input('email') . "%");
    }
    return $query;
}

public function scopeGender(Builder $query): Builder
{
    if (request()->has('gender')) {
        $query->where('gender', request()->input('gender'));
    }
    return $query;
}

public function scopeIsActive(Builder $query): Builder
{
    if (request()->has('is_active')) {
        $query->where('is_active', request()->input('is_active') ? 1 : 0);
    }
    return $query;
}

public function scopeIsAdmin(Builder $query): Builder
{
    if (request()->has('is_admin')) {
        $query->where('is_admin', request()->input('is_admin') ? 1 : 0);
    }
    return $query;
}

public function scopeBirthday(Builder $query): Builder
{
    if (request()->has('birthday')) {
        $query->where('birthday', request()->input('birthday'));
    }
    return $query;
}

// UserController.php
public function __invoke(Request $request)
{
    // /users?name=john&email=desmond&gender=female&is_active=1&is_admin=0&birthday=2015-04-11

     $query = User::query()
             ->name()
            ->email()
            ->gender()
            ->isActive()
            ->isAdmin()
            ->birthday();

    return $query->paginate();

    // select * from `users` where `name` like '%john%' and `email` like '%desmond%' and `gender` = 'female' and `is_active` = 1 and `is_admin` = 0 and `birthday` = '2015-04-11' limit 15 offset 0
}
Войдите в полноэкранный режим Выход из полноэкранного режима

При такой настройке мы перенесли большую часть операций с базой данных в класс модели, но повторений кода получилось довольно много. В примере 2 фильтры области видимости для name и email одинаковы, то же самое для групп gender birthday и is_active is_admin. Мы будем подходить к группировке аналогично функции запроса.

// User.php
public function scopeRelativeFilter(Builder $query, $inputName): Builder
{
    if (request()->has($inputName)) {
        $query->where($inputName, 'like', "%" . request()->input($inputName) . "%");
    }
    return $query;
}

public function scopeExactFilter(Builder $query, $inputName): Builder
{
    if (request()->has($inputName)) {
        $query->where($inputName, request()->input($inputName));
    }
    return $query;
}

public function scopeBooleanFilter(Builder $query, $inputName): Builder
{
    if (request()->has($inputName)) {
        $query->where($inputName, request()->input($inputName) ? 1 : 0);
    }
    return $query;
}


// UserController.php
public function __invoke(Request $request)
{
    // /users?name=john&email=desmond&gender=female&is_active=1&is_admin=0&birthday=2015-04-11

    $query = User::query()
        ->relativeFilter('name')
        ->relativeFilter('email')
        ->exactFilter('gender')
        ->booleanFilter('is_active')
        ->booleanFilter('is_admin')
        ->exactFilter('birthday');

    return $query->paginate();

    // select * from `users` where `name` like '%john%' and `email` like '%desmond%' and `gender` = 'female' and `is_active` = 1 and `is_admin` = 0 and `birthday` = '2015-04-11' limit 15 offset 0
}
Вход в полноэкранный режим Выход из полноэкранного режима

На данном этапе мы сгруппировали большинство дубликатов. Однако немного сложно удалить оператор if или распространить эти фильтры на другую модель. Мы ищем способ полностью решить эту проблему.

Использовать паттерн Pipeline

Паттерн проектирования Pipeline — это паттерн проектирования, который обеспечивает возможность построения и выполнения последовательности действий шаг за шагом. Laravel имеет встроенный Pipeline, который облегчает нам применение этого паттерна проектирования в действии, но по какой-то причине он не указан в официальной документации. Сам Laravel также применяет Pipeline для промежуточного звена между запросом и ответом. Самый простой вариант использования Pipeline в Laravel выглядит следующим образом

app(IlluminatePipelinePipeline::class)
    ->send($intialData)
    ->through($pipes)
    ->thenReturn(); // data with pipes applied
Войти в полноэкранный режим Выйти из полноэкранного режима

Для нашей задачи можно передать в конвейер исходный запрос User:query(), пройти через шаги фильтрации и вернуть построитель запроса с примененными фильтрами.

// UserController
public function __invoke(Request $request)
{
    // /users?name=john&email=desmond&gender=female&is_active=1&is_admin=0&birthday=2015-04-11

    $query = app(Pipeline::class)
        ->send(User::query())
        ->through([
            // filters
        ])
        ->thenReturn();

    return $query->paginate();
Вход в полноэкранный режим Выход из полноэкранного режима

Теперь нам нужно построить фильтры конвейера:

// File: app/Models/Pipes/RelativeFilter.php

<?php

namespace AppModelsPipes;

use IlluminateDatabaseEloquentBuilder;

class RelativeFilter
{
    public function __construct(protected string $inputName)
    {
    }

    public function handle(Builder $query, Closure $next)
    {
        if (request()->has($this->inputName)) {
            $query->where($this->inputName, 'like', "%" . request()->input($this->inputName) . "%");
        }
        return $next($query);
    }
}


// File: app/Models/Pipes/ExactFilter.php

<?php

namespace AppModelsPipes;

use IlluminateDatabaseEloquentBuilder;

class ExactFilter
{
    public function __construct(protected string $inputName)
    {
    }

    public function handle(Builder $query, Closure $next)
    {
        if (request()->has($this->inputName)) {
            $query->where($this->inputName, request()->input($this->inputName));
        }
        return $next($query);
    }
}

//File: app/Models/Pipes/BooleanFilter.php
<?php

namespace AppModelsPipes;

use IlluminateDatabaseEloquentBuilder;

class BooleanFilter
{
    public function __construct(protected string $inputName)
    {
    }

    public function handle(Builder $query, Closure $next)
    {
        if (request()->has($this->inputName)) {
            $query->where($this->inputName, request()->input($this->inputName) ? 1 : 0);
        }
        return $next($query);
    }
}

// UserController
public function __invoke(Request $request)
{
    // /users?name=john&email=desmond&gender=female&is_active=1&is_admin=0&birthday=2015-04-11

    $query = app(Pipeline::class)
        ->send(User::query())
        ->through([
            new AppModelsPipesRelativeFilter('name'),
            new AppModelsPipesRelativeFilter('email'),
            new AppModelsPipesExactFilter('gender'),
            new AppModelsPipesBooleanFilter('is_active'),
            new AppModelsPipesBooleanFilter('is_admin'),
            new AppModelsPipesExactFilter('birthday'),
        ])
        ->thenReturn();

    return $query->paginate();

    // select * from `users` where `name` like '%john%' and `email` like '%desmond%' and `gender` = 'female' and `is_active` = 1 and `is_admin` = 0 and `birthday` = '2015-04-11' limit 15 offset 0
}
Войти в полноэкранный режим Выход из полноэкранного режима

Перенеся каждую логику запроса в отдельный класс, мы раскрыли возможности настройки, используя ООП, включая полиморфизм, наследование, инкапсуляцию, абстракцию. Например, вы видите, что в функции handle pipe отличается только логика в операторе if, я выделю и абстрагирую ее, создав абстрактный класс BaseFilter.

//File: app/Models/Pipes/BaseFilter.php

<?php

namespace AppModelsPipes;

use IlluminateDatabaseEloquentBuilder;

abstract class BaseFilter
{
    public function __construct(protected string $inputName)
    {
    }

    public function handle(Builder $query, Closure $next)
    {
        if (request()->has($this->inputName)) {
            $query = $this->apply($query);
        }
        return $next($query);
    }

    abstract protected function apply(Builder $query): Builder;
}

// BooleanFilter
class BooleanFilter extends BaseFilter
{
    protected function apply(Builder $query): Builder
    {
        return $query->where($this->inputName, request()->input($this->inputName) ? 1 : 0);
    }
}

// ExactFilter
class ExactFilter extends BaseFilter
{
    protected function apply(Builder $query): Builder
    {
        return $query->where($this->inputName, request()->input($this->inputName));
    }
}

// RelativeFilter
class RelativeFilter extends BaseFilter
{
    protected function apply(Builder $query): Builder
    {
        return $query->where($this->inputName, 'like', "%" . request()->input($this->inputName) . "%");
    }
}
Вход в полноэкранный режим Выход из полноэкранного режима

Теперь, когда наш фильтр интуитивно понятен и очень многоразовый, его легко реализовать и даже расширить, просто создайте трубу, extends BaseFilter и объявите функцию apply и он готов к применению в Pipeline.

Объединение локального диапазона с конвейером

На этом этапе мы попытаемся скрыть Pipeline на контроллере, чтобы сделать наш код чище, создав область видимости, которая вызывает Pipeline внутри модели.

// User.php
public function scopeFilter(Builder $query)
{
    $criteria = $this->filterCriteria();
    return app(IlluminatePipelinePipeline::class)
        ->send($query)
        ->through($criteria)
        ->thenReturn();
}

public function filterCriteria(): array
{
    return [
        new AppModelsPipesRelativeFilter('name'),
        new AppModelsPipesRelativeFilter('email'),
        new AppModelsPipesExactFilter('gender'),
        new AppModelsPipesBooleanFilter('is_active'),
        new AppModelsPipesBooleanFilter('is_admin'),
        new AppModelsPipesExactFilter('birthday'),
    ];
}


// UserController.php
public function __invoke(Request $request)
{
    // /users?name=john&email=desmond&gender=female&is_active=1&is_admin=0&birthday=2015-04-11

    return User::query()
        ->filter()
        ->paginate()
        ->appends($request->query()); // append all current queries into pagination links

    // select * from `users` where `name` like '%john%' and `email` like '%desmond%' and `gender` = 'female' and `is_active` = 1 and `is_admin` = 0 and `birthday` = '2015-04-11' limit 15 offset 0
}
Вход в полноэкранный режим Выход из полноэкранного режима

User теперь может вызывать filter из любого места. Но другие модели также хотят реализовать фильтр, мы создадим Trait, содержащий область видимости и объявим трубопроводы, участвующие в процессе фильтрации, внутри модели.

// User.php

use AppModelsConcernsFilterable;

class User extends Authenticatable {
        use Filterable;

        protected function getFilters()
        {
            return [
                new AppModelsPipesRelativeFilter('name'),
                new AppModelsPipesRelativeFilter('email'),
                new AppModelsPipesExactFilter('gender'),
                new AppModelsPipesBooleanFilter('is_active'),
                new AppModelsPipesBooleanFilter('is_admin'),
                new AppModelsPipesExactFilter('birthday'),
            ];
        }

        // the rest of code



// File: app/Models/Concerns/Filterable.php

namespace AppModelsConcerns;

use IlluminateDatabaseEloquentBuilder;
use IlluminatePipelinePipeline;

trait Filterable
{
    public function scopeFilter(Builder $query)
    {
        $criteria = $this->filterCriteria();
        return app(Pipeline::class)
            ->send($query)
            ->through($criteria)
            ->thenReturn();
    }

    public function filterCriteria(): array
    {
        if (method_exists($this, 'getFilters')) {
            return $this->getFilters();
        }

        return [];
    }
}
Вход в полноэкранный режим Выход из полноэкранного режима

Мы решили проблему «разделяй и властвуй», каждый файл, каждый класс и каждая функция теперь имеют четкую ответственность. Кроме того, код стал чистым, интуитивно понятным и гораздо проще для повторного использования, не так ли! Я размещу код всего процесса Демо в этом посте здесь

Эпилог

Вышеописанное является частью пути, который я прошел, чтобы построить продвинутую систему фильтрации запросов, и в то же время знакомит вас с некоторыми подходами программирования Laravel, такими как Local Scope и особенно Pipeline design pattern. Чтобы быстро и легко применить эту установку к новому проекту, вы можете использовать пакет Pipeline Query Collection, который включает в себя набор предварительно построенных труб, что упрощает его установку и использование. Надеюсь, все поддержат эту идею!

Оцените статью
Procodings.ru
Добавить комментарий