Хочу представить вашему вниманию концепцию организации фильтрации по URL-запросу. Для примера буду использовать язык PHP и фреймворк Laravel.
Идея заключается в создании универсального класса QueryFilter для работы с фильтрами.
Использую данный пример, мы будем фильтровать посты (модель Post) по следующим критериям:
Модель Post
Добавляем маршрут:
Создаем файл Resource\Post для вывода в формате JSON:
И сам контроллер с одним единственным действием:
Стандартная фильтрация организуется следующим кодом:
С таким подходом мы сталкиваемся с разрастанием контроллера, что нежелательно.
Смысл такой концепции заключается в том, чтобы использовать для каждой сущности отдельный класс, сопоставляющий методы с каждым полем, по которым предстоит фильтрация.
Фильтровать предстоит по запросу:
Для фильтрации у нас будет класс PostFilter и методы title() и status(). PostFilter будет расширять абстрактный класс QueryFiler который отвечает за сопоставление методов класса с передаваемыми параметрами.
Метод apply()
Класс QueryFIlter имеет метод apply(), который отвечает за вызов фильтров, имеющихся в дочернем классе PostFilter.
Суть в том, что для каждого поля переданного через Request мы имеем отдельный метод в дочернем классе фильтра (класс PostFilter). Это позволяет нам настраивать логику для каждого поля фильтра.
Класс PostFilter
Теперь перейдем к созданию класса PostFilter расширяющего QueryFilter. Как говорилось ранее, этот класс должен содержать методы для каждого поля, по которым нам предстоит фильтрация. В нашем случае методы title($value) и status($value)
Здесь я не вижу смысла для подробного разбора каждого из методов, вполне стандартные запросы. Смысл заключается в том что теперь мы имеет отдельный метод для каждого поля и можем применять какую угодно логику для формирования запроса.
Создаем scopeFilter()
Теперь нам необходимо связать модель и конструктор запросов
Для поиска нам необходимо вызвать метод filter() и передать экземпляр QueryFilter, в нашем случае PostFilter.
Таким образом, вся логика фильтрации обрабатывается вызовом метода filter($postFilter), избавляя контроллер от лишней логики.
Для облегчения повторного использования можно поместить метод scopeFilter в трейт и использовать его для каждой модели, которую необходимо фильтровать.
В Post добавляем:
Осталось добавить в качестве параметра PostFilter в метод контроллера index() и вызвать метод модели filter().
На этом все. Мы переместили всю логику фильтрации в соответствующий класс, соблюдая принцип единой ответственности (S в системе принципов SOLID)
Такой подход к реализации фильтров позволяет придерживаться прицепа тонкий контроллер, а также облегчает написание тестов.
Здесь приведен пример с использование 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. Но как я и говорил, это концепция и может работать с любым языком или фреймворком.
Ссылки
- Блог Junior Grossi
- Готовый пакет от Killian Blais
AlexSpaizNet
Не нравится мне идея разбрасывания SQL-ки по файлам… почему вообще контроллер должен знать (хоть и косвенно теперь через QueryFilter о том что тут присутствует SQL?
Всегда хватало простого Separation of concerns
Controller — тупой, и тупо получает запрос
RequestModel/FormModel/SearchModel (называйте как хотите) — инкапсулирует данные запроса
ServiceLayer — получает нужные зависимости и данные запроса. Вся движуха в плане бизнес логики делается тут и далее использует DAL.
DAL (Repository, DAO) — там уже строим нужную SQL-ку… хоть чистый хоть орм-ный… в одном месте.
Maksclub
Очевидно же, что в коде примера контроллер выступает как тестовый клиент, не более