QueryFilter: концепция фильтрации моделей

от автора

Хочу представить вашему вниманию концепцию организации фильтрации по 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. Но как я и говорил, это концепция и может работать с любым языком или фреймворком.

Ссылки

ссылка на оригинал статьи https://habr.com/ru/post/485520/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *