Хочу представить вашему вниманию концепцию организации фильтрации по URL-запросу. Для примера буду использовать язык PHP и фреймворк Laravel.

Концепция


Идея заключается в создании универсального класса QueryFilter для работы с фильтрами.

GET /posts?title=source&status=active

Использую данный пример, мы будем фильтровать посты (модель Post) по следующим критериям:

  • Наличие слова «source» в поле title;
  • Значение «publish» в поле status;

Пример приложения


Модель Post


<?php

namespace App;

use Illuminate\Database\Eloquent\Model;

class Post extends Model
{
    /**
     * The attributes that are mass assignable.
     *
     * @var array
     */
    protected $fillable = [
        'name',
        'id',
        'title',
        'slug',
        'status',
        'type',
        'published_at',
        'updated_at',
    ];
}


Добавляем маршрут:

Route::get('/posts', 'PostController@index');

Создаем файл Resource\Post для вывода в формате JSON:


namespace App\Http\Resources;

use Illuminate\Http\Resources\Json\JsonResource;

class Post extends JsonResource
{
    /**
     * Transform the resource into an array.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return array
     */
    public function toArray($request)
    {
        return [
            'id' => $this->ID,
            'title' => $this->post_title,
            'slug' => $this->post_name,
            'status' => $this->post_status,
            'type' => $this->post_type,
            'published_at' => $this->post_date,
            'updated_at' => $this->post_modified,
        ];
    }
}

И сам контроллер с одним единственным действием:


namespace App\Http\Controllers;

use App\Http\Resources\Post as PostResource;
use App\Post;

class PostController extends Controller
{
    /**
     * @return \Illuminate\Http\Resources\Json\AnonymousResourceCollection
     */
    public function index()
    {
        $posts = Post::limit(10)->get();

        return PostResource::collection($posts);
    }
}

Стандартная фильтрация организуется следующим кодом:


/**
 * @param Request $request
 * @return \Illuminate\Http\Resources\Json\AnonymousResourceCollection
 */
public function index(Request $request)
{
    $query = Post::limit(10);

    if ($request->filled('status')) {
        $query->where('post_status', $request->get('status'));
    }

    if ($request->filled('title')) {
        $title = $request->get('title');
        $query->where('post_title', 'like', "%$title%");
    }
    $posts = $query->get();

    return PostResource::collection($posts);
}

С таким подходом мы сталкиваемся с разрастанием контроллера, что нежелательно.

Внедрение QueryFilter


Смысл такой концепции заключается в том, чтобы использовать для каждой сущности отдельный класс, сопоставляющий методы с каждым полем, по которым предстоит фильтрация.

Фильтровать предстоит по запросу:

GET /posts?title=source&status=publish

Для фильтрации у нас будет класс PostFilter и методы title() и status(). PostFilter будет расширять абстрактный класс QueryFiler который отвечает за сопоставление методов класса с передаваемыми параметрами.

Метод apply()

Класс QueryFIlter имеет метод apply(), который отвечает за вызов фильтров, имеющихся в дочернем классе PostFilter.


namespace App\Http\Filters;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;

abstract class QueryFilter
{
    /**
     * @var Request
     */
    protected $request;

    /**
     * @var Builder
     */
    protected $builder;

    /**
     * @param Request $request
     */
    public function __construct(Request $request)
    {
        $this->request = $request;
    }

    /**
     * @param Builder $builder
     */
    public function apply(Builder $builder)
    {
        $this->builder = $builder;

        foreach ($this->fields() as $field => $value) {
            $method = camel_case($field);
            if (method_exists($this, $method)) {
                call_user_func_array([$this, $method], (array)$value);
            }
        }
    }

    /**
     * @return array
     */
    protected function fields(): array
    {
        return array_filter(
            array_map('trim', $this->request->all())
        );
    }
}

Суть в том, что для каждого поля переданного через Request мы имеем отдельный метод в дочернем классе фильтра (класс PostFilter). Это позволяет нам настраивать логику для каждого поля фильтра.

Класс PostFilter

Теперь перейдем к созданию класса PostFilter расширяющего QueryFilter. Как говорилось ранее, этот класс должен содержать методы для каждого поля, по которым нам предстоит фильтрация. В нашем случае методы title($value) и status($value)


namespace App\Http\Filters;

use Illuminate\Database\Eloquent\Builder;

class PostFilter extends QueryFilter
{
    /**
     * @param string $status
     */
    public function status(string $status)
    {
        $this->builder->where('post_status', strtolower($status));
    }

    /**
     * @param string $title
     */
    public function title(string $title)
    {
        $words = array_filter(explode(' ', $title));

        $this->builder->where(function (Builder $query) use ($words) {
            foreach ($words as $word) {
                $query->where('post_title', 'like', "%$word%");
            }
        });
    }
}

Здесь я не вижу смысла для подробного разбора каждого из методов, вполне стандартные запросы. Смысл заключается в том что теперь мы имеет отдельный метод для каждого поля и можем применять какую угодно логику для формирования запроса.

Создаем scopeFilter()

Теперь нам необходимо связать модель и конструктор запросов


/**
 * @param Builder $builder
 * @param QueryFilter $filter
 */
public function scopeFilter(Builder $builder, QueryFilter $filter)
{
    $filter->apply($builder);
}
 

Для поиска нам необходимо вызвать метод filter() и передать экземпляр QueryFilter, в нашем случае PostFilter.

$filteredPosts = Post::filter($postFilter)->get();

Таким образом, вся логика фильтрации обрабатывается вызовом метода filter($postFilter), избавляя контроллер от лишней логики.

Для облегчения повторного использования можно поместить метод scopeFilter в трейт и использовать его для каждой модели, которую необходимо фильтровать.


namespace App\Http\Filters;

use Illuminate\Database\Eloquent\Builder;

trait Filterable
{
    /**
     * @param Builder $builder
     * @param QueryFilter $filter
     */
    public function scopeFilter(Builder $builder, QueryFilter $filter)
    {
        $filter->apply($builder);
    }
}

В Post добавляем:



class Post extends CorcelPost
{
    use Filterable;

Осталось добавить в качестве параметра PostFilter в метод контроллера index() и вызвать метод модели filter().


class PostController extends Controller
{
    /**
     * @param PostFilter $filter
     * @return \Illuminate\Http\Resources\Json\ResourceCollection
     */
    public function index(PostFilter $filter)
    {
        $posts = Post::filter($filter)->limit(10)->get();

        return PostResource::collection($posts);
    }
}

На этом все. Мы переместили всю логику фильтрации в соответствующий класс, соблюдая принцип единой ответственности (S в системе принципов SOLID)

Вывод


Такой подход к реализации фильтров позволяет придерживаться прицепа тонкий контроллер, а также облегчает написание тестов.

Здесь приведен пример с использование PHP и Laravel. Но как я и говорил, это концепция и может работать с любым языком или фреймворком.

Ссылки