Laravel: как отфильтровать данные по параметрам из строки запроса

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();
    }
}

 

Для закрепления и лучшего понимания пройдёмся ещё раз по коду:

  1. В контроллере ProductController вызываем метод filter и передаём в него объект ProductFilters
  2. В модели в scopeFilters вызываем метод apply переданного объекта, которому в свою очередь передаём строитель запросов Builder
  3. Поскольку в классе-родителе QueryFilter в контсруктор внедрён Request, у нас будет доступ ко всем данным запроса. Метод apply перебирает в цикле все параметры, и на каждом из них вызывает одноименный метод (если такой существует), передавая ему в качестве аргумента значение. Если в адресной строке, у нас было x=y, то будет вызван метод x(y).
  4. Каждый из вызванных методов, модифицирует запрос, добавляя к нему соответствующие условия
  5. Метод apply возвращает модифицированный запрос в  scopeFilter модели, который, в свою очередь возвращает запрос в контроллер
  6. В контроллере к запросу добавляется 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

Всё работает так, как мы и планировали.

Итак, какой профит мы получили?

  • контроллер остаётся чистым и тонким
  • для добавления нового фильтра нужно всего лишь добавить одноименный метод в соответствующий контроллер и больше ничего 

На этом всё. Успехов!