Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 85 additions & 5 deletions src/QueryBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ class QueryBuilder implements InertiaSerializable

protected ?string $search = null;

/** @var array<int, string> */
protected array $searchableColumns = [];

protected ?string $sortBy = null;

protected ?string $sortDirection = 'asc';
Expand Down Expand Up @@ -81,6 +84,30 @@ public function search(?string $search): static
return $this;
}

/**
* Columns the search term is matched against (OR'd together). Use dot notation
* (e.g. `author.name`) to search a column on a relation.
*
* @param array<int, string>|string $columns
*/
public function searchable(array|string $columns): static
{
$this->searchableColumns = array_values(array_filter(
is_array($columns) ? $columns : func_get_args(),
fn ($column) => is_string($column) && $column !== '',
));

return $this;
}

/**
* @return array<int, string>
*/
public function getSearchableColumns(): array
{
return $this->searchableColumns;
}

public function sortBy(?string $column, ?string $direction = 'asc'): static
{
$this->sortBy = $column;
Expand Down Expand Up @@ -122,9 +149,7 @@ public function apply(Builder $query): Builder
}

// Apply search if configured
if ($this->search !== null && $this->search !== '') {
$this->applySearch($query);
}
$this->applySearch($query);

// Apply sorting
if ($this->sortBy !== null) {
Expand Down Expand Up @@ -163,8 +188,63 @@ protected function resolveSortColumn(string $sortBy): ?string
*/
protected function applySearch(Builder $query): void
{
// Search implementation can be customized per use case
// This is a basic implementation
$term = trim((string) $this->search);

if ($term === '' || $this->searchableColumns === []) {
return;
}

$pattern = '%'.$this->escapeLike($term).'%';

$query->where(function (Builder $query) use ($pattern): void {
foreach ($this->searchableColumns as $column) {
$relation = str_contains($column, '.') ? substr($column, 0, (int) strrpos($column, '.')) : null;

if ($relation !== null && $this->isRelationPath($query->getModel(), $relation)) {
$relatedColumn = substr($column, strrpos($column, '.') + 1);

$query->orWhereHas($relation, function (Builder $query) use ($relatedColumn, $pattern): void {
$this->whereLike($query, $query->qualifyColumn($relatedColumn), $pattern);
});

continue;
}

$query->orWhere(function (Builder $query) use ($column, $pattern): void {
$this->whereLike($query, $column, $pattern);
});
}
});
}

/**
* @param Builder<Model> $query
*/
protected function whereLike(Builder $query, string $column, string $pattern): void
{
$grammar = $query->getQuery()->getGrammar();

// An explicit ESCAPE clause makes the backslash escaping portable (sqlite and
// pgsql have no default escape character; mysql's default is already '\').
$query->whereRaw($grammar->wrap($column).' like ? escape ?', [$pattern, '\\']);
}

protected function escapeLike(string $value): string
{
return str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $value);
}

protected function isRelationPath(Model $model, string $path): bool
{
foreach (explode('.', $path) as $segment) {
if (! $model->isRelation($segment)) {
return false;
}

$model = $model->{$segment}()->getRelated();
}

return true;
}

/**
Expand Down
123 changes: 123 additions & 0 deletions tests/Feature/QueryBuilderSearchTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
<?php

declare(strict_types=1);

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Laravilt\QueryBuilder\QueryBuilder;

class SearchTestAuthor extends Model
{
protected $table = 'search_authors';

protected $guarded = [];

public $timestamps = false;
}

class SearchTestPost extends Model
{
protected $table = 'search_posts';

protected $guarded = [];

public $timestamps = false;

public function author(): BelongsTo
{
return $this->belongsTo(SearchTestAuthor::class, 'author_id');
}
}

beforeEach(function () {
Schema::create('search_authors', function (Blueprint $table) {
$table->id();
$table->string('name');
});

Schema::create('search_posts', function (Blueprint $table) {
$table->id();
$table->string('title');
$table->string('body');
$table->foreignId('author_id')->nullable();
});

$john = SearchTestAuthor::create(['name' => 'John Doe']);
$jane = SearchTestAuthor::create(['name' => 'Jane Smith']);

foreach ([
['title' => 'Laravel Tips', 'body' => 'Eloquent tricks', 'author_id' => $john->id],
['title' => 'Vue Guide', 'body' => 'Composition API with Laravel', 'author_id' => $jane->id],
['title' => '100% Coverage', 'body' => 'Testing', 'author_id' => $jane->id],
['title' => '100 Coverage', 'body' => 'Testing', 'author_id' => null],
['title' => 'snake_case names', 'body' => 'Style', 'author_id' => null],
['title' => 'snakeXcase names', 'body' => 'Style', 'author_id' => null],
['title' => 'C:\\path\\file', 'body' => 'Windows', 'author_id' => null],
['title' => 'C:path', 'body' => 'Windows', 'author_id' => null],
] as $post) {
SearchTestPost::create($post);
}
});

afterEach(function () {
Schema::dropIfExists('search_posts');
Schema::dropIfExists('search_authors');
});

function searchTitles(?string $term, array $columns = ['title']): array
{
return (new QueryBuilder)
->searchable($columns)
->search($term)
->apply(SearchTestPost::query())
->orderBy('id')
->pluck('title')
->all();
}

test('search matches searchable columns with a contains LIKE', function () {
expect(searchTitles('laravel'))->toBe(['Laravel Tips']);
});

test('blank or null search is a no-op', function (?string $term) {
expect(searchTitles($term))->toHaveCount(8);
})->with([null, '', ' ']);

test('search without searchable columns is a no-op', function () {
$count = (new QueryBuilder)->search('Laravel')->apply(SearchTestPost::query())->count();

expect($count)->toBe(8);
});

test('search ORs across multiple columns', function () {
expect(searchTitles('Laravel', ['title', 'body']))->toBe(['Laravel Tips', 'Vue Guide']);
});

test('search group is nested so it does not break other constraints', function () {
$titles = (new QueryBuilder)
->searchable(['title', 'body'])
->search('Laravel')
->apply(SearchTestPost::query()->where('author_id', 2))
->pluck('title')
->all();

expect($titles)->toBe(['Vue Guide']);
});

test('search escapes percent sign', function () {
expect(searchTitles('100%'))->toBe(['100% Coverage']);
});

test('search escapes underscore', function () {
expect(searchTitles('snake_case'))->toBe(['snake_case names']);
});

test('search escapes backslash', function () {
expect(searchTitles('C:\\path'))->toBe(['C:\\path\\file']);
});

test('search supports relation columns via dot notation', function () {
expect(searchTitles('Jane', ['title', 'author.name']))->toBe(['Vue Guide', '100% Coverage']);
});