
Laravel: як відфільтрувати дані за параметрами з рядка запиту
05.03.2019 23:34 | Laravel
Під рядком запиту в даному випадку мається на увазі так звана query string з адресного рядка браузера. Стаття написана за одним з відео на laracasts з невеликим доповненнями.
Версія фреймворка: 5.7
Щоб було з чим працювати, нам знадобиться модель, таблиця і дані. Створюємо модель, міграцію і контролер:
php artisan make:model -mc Product
Відкриваємо файл міграції та доповнюємо код:
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class CreateProductsTable extends Migration
{
public function up()
{
Schema::create('products', function (Blueprint $table) {
$table->increments('id');
$table->unsignedInteger('category_id')->index();
$table->unsignedInteger('brand_id')->index();
$table->string('title')->unique();
$table->text('description');
$table->boolean('active');
$table->unsignedInteger('price');
$table->timestamps();
});
}
public function down()
{
Schema::dropIfExists('products');
}
}
Виконуємо міграції:
php artisan migrate
Створимо фабрику:
php artisan make:factory ProductFactory --model=Product
Замінимо код фабрики на наступний:
<?php
use Faker\Generator as Faker;
$factory->define(App\Product::class, function (Faker $faker) {
return [
'category_id' => $faker->numberBetween(1, 10),
'brand_id' => $faker->numberBetween(1, 25),
'title' => $faker->unique()->sentence(1, 3),
'description' => $faker->paragraph(),
'price' => $faker->numberBetween(500, 5000),
'active' => $faker->boolean()
];
});
Уточню: моделей категорії і брендів у нас немає, але вони і не потрібні для розуміння того, що відбувається. Зрозуміло, в реальному додатку ці моделі будуть створені.
Скористаємося тінкером, щоб згенерувати фейкові дані:
php artisan tinker
>>>factory(App\Product::class, 100)->create();
Було б непогано, якби у нас був метод filter
, який би будував запит, застосовуючи до нього фільтри. Відкриємо модель і додамо:
<?php
namespace App;
use App\Filters\QueryFilter;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
class Product extends Model
{
protected $fillable = [
'category_id', 'brand_id', 'title', 'description', 'price', 'active'
];
public function scopeFilter(Builder $builder, QueryFilter $filters)
{
return $filters->apply($builder);
}
}
Тепер давайте займемося QueryFilter
- це буде абстрактний клас (спадкоємцями якого будуть класи фільтрів під конкретні моделі). Для цього класу і його дітей створимо директорію app/Filters
, і в ній відповідно файл QueryFilter.php
.
Нам определённо нужны параметры адресной строки, которые будет "вынимать" из реквеста, посему последний сразу внедрим в конструктор:
<?php
namespace App\Filters;
use Illuminate\Http\Request;
abstract class QueryFilter
{
protected $request;
public function __construct(Request $request)
{
$this->request = $request;
}
}
Дані з рядка запиту будемо отримувати за допомогою методу filters
:
public function filters()
{
return $this->request->query();
}
З моделі вище пам'ятаємо, що ми припустили наявність методу apply
. У ньому ми будемо перебирати параметри з адресного рядка браузера, викликати відповідні методи, модифікуючи білдер, який і віддамо на виході:
public function apply(Builder $builder)
{
$this->builder = $builder;
foreach ($this->filters() as $name => $value) {
if (method_exists($this, $name)) {
call_user_func_array([$this, $name], array_filter([$value]));
}
}
return $this->builder;
}
Тобто, якщо адресний рядок буде мати вигляд: shop.xx/products?сategory=1&brands=1,2&price
, відповідно, будуть викликані:
- метод
category
з параметром 1 - метод
brands
з параметром 1,2,3 - метод
price
без параметрів (для цього ми використовували вбудовану функціюarray_filter
)
Звісно, якщо метод не існує, то і параметр буде проігноровано. Як бачимо, brands містить кілька значень, які непогано було б трансформувати в масив. Створимо метод і щоб не хардкодити делімітер, додамо властивість, яку, при необхідності, можна буде перевизначити в дочірніх класах:
protected $delimiter = ',';
protected function paramToArray($param)
{
return explode($this->delimiter, $param);
}
Повний код класу:
<?php
namespace App\Filters;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
abstract class QueryFilter
{
protected $request;
protected $builder;
protected $delimiter = ',';
public function __construct(Request $request)
{
$this->request = $request;
}
public function apply(Builder $builder)
{
$this->builder = $builder;
foreach ($this->filters() as $name => $value) {
if (method_exists($this, $name)) {
call_user_func_array([$this, $name], array_filter([$value]));
}
}
return $this->builder;
}
public function filters()
{
return $this->request->query();
}
protected function paramToArray($param)
{
return explode($this->delimiter, $param);
}
}
У тій же директорії Filters
, створимо файл ProductFilter
, який буде містити однойменний клас з фільтрами для продуктів:
<?php
namespace App\Filters;
class ProductFilters extends QueryFilter
{
public function category($id)
{
return $this->builder->where('category_id', $id);
}
public function brands($brandIds)
{
return $this->builder->whereIn('brand_id', $this->paramToArray($brandIds));
}
public function search($keyword)
{
return $this->builder->where('title', 'like', '%'.$keyword.'%');
}
public function price($order = 'asc')
{
return $this->builder->orderBy('price', $order);
}
}
Завдяки виконаній роботі код контролера максимально простий:
<?php
namespace App\Http\Controllers;
use App\Filters\ProductFilters;
use App\Product;
class ProductController extends Controller
{
public function index(ProductFilters $filters)
{
return Product::filter($filters)->get();
}
}
Для закріплення і кращого розуміння пройдемося ще раз по коду:
- У контролері
ProductController
викликаємо методfilter
і передаємо в нього об'єктProductFilters
- У моделі в
scopeFilters
викликаємо метод apply переданого об'єкта, якому в свою чергу передаємо будівельник запитівBuilder
- Оскільки в класі-батьку
QueryFilter
в контсруктор впровадженийRequest
, у на буде доступ до всіх даних запиту. Метод apply перебирає в циклі всі параметри, і на кожному з них викликає однойменний метод (якщо такий існує), передаючи йому в якості аргументу значення. Якщо в адресному рядку, у нас булоx=y
, то буде викликаний методx(y)
.
Кожен з викликаних методів, модифікує запит, додаючи до нього відповідні умови - Метод
apply
повертає модифікований запит в scopeFilter моделі, який, в свою чергу повертає запит в контролер - У контролері до запиту додається
get()
і вуаля - ми отримуємо відфільтровані дані!
Тобто, якщо адресний рядок браузера містить: products?сategories=5&brands=3,7,10&price=asc&search=phone
, то модифікований запит буде виглядати так:
Product::where('category_id', 5)
->whereIn('brand_id', [3, 7, 10])
->where('title', 'like', '%phone%')
->orderBy('price', 'asc')
->get();
Створимо маршрут в routes/web.php
:
Route::get('/products', 'ProductController@index');
Вводимо в браузер вищевказану адресу і тиснемо Enter. Перевірити запит до бд можна скориставшись телескопом. Ось що там побачимо:
select
*
from
`products`
where
`category_id` = "5"
and `brand_id` in ("3", "7", "10")
and `title` like "%phone%"
order by
`price` asc
Все працює так, як ми і планували.
Отже, який профіт ми отримали?
- контролер залишається чистим і тонким
- для додавання нового фільтра потрібно всього лише додати однойменний метод в відповідний контролер і більше нічого
На цьому все. Успіхів!