Laravel: як відфільтрувати дані за параметрами з рядка запиту

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

 

Для закріплення і кращого розуміння пройдемося ще раз по коду:

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

Все працює так, як ми і планували.

Отже, який профіт ми отримали?

  • контролер залишається чистим і тонким
  • для додавання нового фільтра потрібно всього лише додати однойменний метод в відповідний контролер і більше нічого

На цьому все. Успіхів!