Вам когда-нибудь приходилось применять фильтры к запросу, только когда пользователь действительно применяет их? Метод Laravel query builder when
() специально для вас.
Недавно я столкнулся с этой же проблемой, работая над проектом клиента. Я должен был отобразить список потенциальных клиентов и позволить пользователю условно применить один или несколько доступных фильтров, таких как имя клиента, статус лидов и т. д.
Конечно, это не сложная и не редкая проблема. На самом деле, я должен был реализовать его так или иначе почти в каждом проекте, над которым я работал в своей карьере разработчика. Единственная проблема здесь заключается в том, что чаще всего вы пишете оператор if для каждого из фильтров, чтобы проверить, следует ли его применять к запросу, что, честно говоря, делает код очень уродливым.
Подготовка
Возьмем следующий пример. У нас есть две модели: Post
и Comment
, связанные отношением “один ко многим” (сообщение имеет много комментариев, комментарий принадлежит сообщению). Например, я старался сделать все как можно проще – вместо того чтобы создавать другую Topic
модель для наших тем постов, я использовал строки. В любом случае, вот наши модели и миграции:
// App/Models/Post.php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Post extends Model
{
use HasFactory;
public function comments(): HasMany
{
return $this->hasMany(Comment::class);
}
}
// App/Models/Comment.php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Comment extends Model
{
use HasFactory;
public function post(): BelongsTo
{
return $this->belongsTo(Post::class);
}
}
// database/migrations/2020_10_22_180415_create_posts_table.php
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreatePostsTable extends Migration
{
public function up()
{
Schema::create('posts', function (Blueprint $table) {
$table->id();
$table->string('topic');
$table->string('username');
$table->string('title');
$table->text('content');
$table->timestamps();
});
}
public function down()
{
Schema::dropIfExists('posts');
}
}
// database/migrations/2020_10_22_180506_create_comments_table.php
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateCommentsTable extends Migration
{
public function up()
{
Schema::create('comments', function (Blueprint $table) {
$table->id();
$table->foreignId('post_id')->constrained()->cascadeOnDelete();
$table->string('username');
$table->text('content');
$table->timestamps();
});
}
public function down()
{
Schema::dropIfExists('comments');
}
}
Теперь, ради нашего примера, я создал страницу, которая отображает только список сообщений, каждый блок сообщений содержит свою тему, имя пользователя и количество комментариев. Вот как это выглядит:
Чтобы отобразить сообщения, я использовал следующий код в своем PostsController
классе:
// App/Http/Controllers/PostsController.php
<?php
namespace App\Http\Controllers;
use App\Models\Post;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\Request;
class PostsController extends Controller
{
public function index(Request $request)
{
$posts = Post::query()
->latest()
->withCount('comments')
->get();
return view('posts.index')->with(['posts' => $posts]);
}
}
Применение фильтров
Как вы можете видеть, в списке сообщений есть два фильтра: тема и популярность. Фильтр тем позволяет пользователю выбрать конкретную тему, в то время как фильтр популярности имеет два варианта: все и популярные, которые представляют собой сообщения, имеющие более 10 комментариев.
Мой первый шаг (который совершенно необязателен) к фильтрации нашего запроса состоял в том, чтобы извлечь запрос в отдельный вызываемый метод applyFilters
. На мой взгляд, это просто делает код более читабельным.
Следующий (и последний) довольно прост. Нам нужно проверить, содержит ли строка запроса непустое значение нашего ключа фильтра (topicOfPopularity
), и если да – применить его к фильтру. В приложении можно сделать что-то вроде этого:
// App/Http/Controllers/PostsController.php
<?php
namespace App\Http\Controllers;
use App\Models\Post;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\Request;
class PostsController extends Controller
{
public function index(Request $request)
{
$posts = $this->applyFilters($request);
return view('posts.index')->with(['posts' => $posts]);
}
private function applyFilters(Request $request): Collection
{
$query = Post::query()
->withCount('comments')
->latest();
if ($topic = $request->query('topic')) {
$query->where('topic', $topic);
}
if ($request->query('popularity')) {
$query->having('comments_count', '>', 10);
}
return $query->get();
}
}
Что ж, это работает. Но что, если бы у нас было еще 10 фильтров? Я даже не хочу думать о том, как он будет выглядеть с десятками других фильтров. Меня беспокоит не то, что каждый фильтр имеет свою собственную ссылку в коде. Как видите, каждый фильтр может иметь несколько иную логику. Фильтр topic
имеет простой where
, в то время как фильтр popularity
основан на значении, которое было запрошено с помощью подзапроса (это то, что withCount
делает, чтобы помочь нам избежать проблемы n+1), поэтому мы должны использовать having
вместо where
. И конечно, ваше приложение может иметь фильтры, которые гораздо сложнее.
К счастью, конструктор запросов Laravel оснащен методом when
, который позволяет нам применять предложения к нашим запросам. Из официальной документации Laravel:
Первый аргумент метода when() это значение нашего фильтра. Если он не пуст, то выполнится замыкание, которое передаем вторым аргументом. Замыкание принимает экземпляр Illuminate\Database\Eloquent\Builder и значение нашего фильтра. Замыкание должно вернуть Illuminate\Database\Eloquent\Builder.
Итак, после короткого рефакторинга, вот как выглядит наш PostsController
:
// App/Http/Controllers/PostsController.php
<?php
namespace App\Http\Controllers;
use App\Models\Post;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\Request;
class PostsController extends Controller
{
public function index(Request $request)
{
$posts = $this->applyFilters($request);
return view('posts.index')->with(['posts' => $posts]);
}
private function applyFilters(Request $request): Collection
{
return Post::query()
->withCount('comments')
->latest()
->when($request->query('topic'), fn(Builder $query, $topic) => $query->where('topic', $topic))
->when($request->query('popularity'), fn(Builder $query) => $query->having('comments_count', '>', 10))
->get();
}
}
Вот и все, теперь мы можем фильтровать наши результаты,и мы даже сделали это красиво!