
Laravel: как отфильтровать данные по параметрам из строки запроса
05.03.2019 23:34 | Laravel
Под строкой запроса в данном случае подразумевается так называемая query string из адресной строки браузера. Статья написана по одному из видео на laracasts c небольшими дополнениями.
Версия фреймворка: 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?category=1&brands=1,2&price
, соответственно, будут вызваны:
- метод
category
c параметром 1 - метод
brands
c параметром 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?categories=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
Всё работает так, как мы и планировали.
Итак, какой профит мы получили?
- контроллер остаётся чистым и тонким
- для добавления нового фильтра нужно всего лишь добавить одноименный метод в соответствующий контроллер и больше ничего
На этом всё. Успехов!