From 78ce7c575a591f4b64b750d6092bcd4c942f3a05 Mon Sep 17 00:00:00 2001 From: Fady Mondy Date: Tue, 15 Sep 2026 10:01:01 +0300 Subject: [PATCH] fix: implement QueryBuilder search across searchable columns search() previously had no effect because applySearch() was empty. - Add searchable(array|string) to register columns to search. - applySearch() matches the trimmed term with a contains LIKE, OR'd across columns inside a nested where group; blank/null term is a no-op. - Escape %, _ and \ in the term and use an explicit ESCAPE '\' clause so escaping works on sqlite/pgsql as well as mysql. - Dot-notation columns whose prefix is a relation (e.g. author.name) are searched via orWhereHas; otherwise treated as a plain/qualified column. - Add feature tests for matching, blank no-op, multi-column OR, nesting, escaping and relation search. Co-Authored-By: Claude Opus 5 (1M context) --- src/QueryBuilder.php | 90 ++++++++++++++++- tests/Feature/QueryBuilderSearchTest.php | 123 +++++++++++++++++++++++ 2 files changed, 208 insertions(+), 5 deletions(-) create mode 100644 tests/Feature/QueryBuilderSearchTest.php diff --git a/src/QueryBuilder.php b/src/QueryBuilder.php index 307fd71..fb8c552 100644 --- a/src/QueryBuilder.php +++ b/src/QueryBuilder.php @@ -22,6 +22,9 @@ class QueryBuilder implements InertiaSerializable protected ?string $search = null; + /** @var array */ + protected array $searchableColumns = []; + protected ?string $sortBy = null; protected ?string $sortDirection = 'asc'; @@ -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|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 + */ + public function getSearchableColumns(): array + { + return $this->searchableColumns; + } + public function sortBy(?string $column, ?string $direction = 'asc'): static { $this->sortBy = $column; @@ -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) { @@ -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 $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; } /** diff --git a/tests/Feature/QueryBuilderSearchTest.php b/tests/Feature/QueryBuilderSearchTest.php new file mode 100644 index 0000000..fc73a82 --- /dev/null +++ b/tests/Feature/QueryBuilderSearchTest.php @@ -0,0 +1,123 @@ +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']); +});