diff --git a/.gitignore b/.gitignore index 27eecba..67a7cb4 100644 --- a/.gitignore +++ b/.gitignore @@ -4,5 +4,5 @@ docs vendor coverage .phpunit.result.cache -.phpunit.result.cache .idea +.env diff --git a/README.md b/README.md index 4618a67..97493ee 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ Hey! We've built a Docker-based deployment tool to launch apps and sites fully c * Support for cross-model [pagination](https://laravel.com/docs/master/pagination#introduction). * Search through single or multiple columns. * Search through (nested) relationships. +* Support for Full-Text Search, even through relationships. * Order by (cross-model) columns or by relevance. * Use [constraints](https://laravel.com/docs/master/eloquent#retrieving-models) and [scoped queries](https://laravel.com/docs/master/eloquent#query-scopes). * [Eager load relationships](https://laravel.com/docs/master/eloquent-relationships#eager-loading) for each model. @@ -47,7 +48,13 @@ You can install the package via composer: composer require protonemedia/laravel-cross-eloquent-search ``` -## Upgrading from v1 +## Upgrading from v2 to v3 + +* The `get` method has been renamed to `search`. +* The `addWhen` method has been removed in favor of [`when`](#usage). +* By default, the results are sorted by the *updated* column, which is the `updated_at` column in most cases. If you don't use timestamps, it will now use the primary key by default. + +## Upgrading from v1 to v2 * The `startWithWildcard` method has been renamed to `beginWithWildcard`. * The default order column is now evaluated by the `getUpdatedAtColumn` method. Previously it was hard-coded to `updated_at`. You still can use [another column](#sorting) to order by. @@ -55,16 +62,16 @@ composer require protonemedia/laravel-cross-eloquent-search ## Usage -Start your search query by adding one or more models to search through. Call the `add` method with the model's class name and the column you want to search through. Then call the `get` method with the search term, and you'll get a `\Illuminate\Database\Eloquent\Collection` instance with the results. +Start your search query by adding one or more models to search through. Call the `add` method with the model's class name and the column you want to search through. Then call the `search` method with the search term, and you'll get a `\Illuminate\Database\Eloquent\Collection` instance with the results. -The results are sorted in ascending order by the *updated* column by default. In most cases, this column is `updated_at`. If you've [customized](https://laravel.com/docs/master/eloquent#timestamps) your model's `UPDATED_AT` constant, or overwritten the `getUpdatedAtColumn` method, this package will use the customized column. Of course, you can [order by another column](#sorting) as well. +The results are sorted in ascending order by the *updated* column by default. In most cases, this column is `updated_at`. If you've [customized](https://laravel.com/docs/master/eloquent#timestamps) your model's `UPDATED_AT` constant, or overwritten the `getUpdatedAtColumn` method, this package will use the customized column. If you don't use timestamps at all, it will use the primary key by default. Of course, you can [order by another column](#sorting) as well. ```php use ProtoneMedia\LaravelCrossEloquentSearch\Search; $results = Search::add(Post::class, 'title') ->add(Video::class, 'title') - ->get('howto'); + ->search('howto'); ``` If you care about indentation, you can optionally use the `new` method on the facade: @@ -73,7 +80,7 @@ If you care about indentation, you can optionally use the `new` method on the fa Search::new() ->add(Post::class, 'title') ->add(Video::class, 'title') - ->get('howto'); + ->search('howto'); ``` You can add multiple models at once by using the `addMany` method: @@ -82,16 +89,16 @@ You can add multiple models at once by using the `addMany` method: Search::addMany([ [Post::class, 'title'], [Video::class, 'title'], -])->get('howto'); +])->search('howto'); ``` -There's also an `addWhen` method, that adds the model when the first argument given to the method evaluates to `true`: +There's also an `when` method to apply certain clauses based on another condition: ```php Search::new() - ->addWhen($user, Post::class, 'title') - ->addWhen($user->isAdmin(), Video::class, 'title') - ->get('howto'); + ->when($user->isVerified(), fn($search) => $search->add(Post::class, 'title')) + ->when($user->isAdmin(), fn($search) => $search->add(Video::class, 'title')) + ->search('howto'); ``` ### Wildcards @@ -102,7 +109,7 @@ By default, we split up the search term, and each keyword will get a wildcard sy Search::add(Post::class, 'title') ->add(Video::class, 'title') ->beginWithWildcard() - ->get('os'); + ->search('os'); ``` *Note: in previous versions of this package, this method was called `startWithWildcard()`.* @@ -114,7 +121,7 @@ Search::add(Post::class, 'title') ->add(Video::class, 'title') ->beginWithWildcard() ->endWithWildcard(false) - ->get('os'); + ->search('os'); ``` ### Multi-word search @@ -124,7 +131,7 @@ Multi-word search is supported out of the box. Simply wrap your phrase into doub ```php Search::add(Post::class, 'title') ->add(Video::class, 'title') - ->get('"macos big sur"'); + ->search('"macos big sur"'); ``` You can disable the parsing of the search term by calling the `dontParseTerm` method, which gives you the same results as using double-quotes. @@ -133,7 +140,7 @@ You can disable the parsing of the search term by calling the `dontParseTerm` me Search::add(Post::class, 'title') ->add(Video::class, 'title') ->dontParseTerm() - ->get('macos big sur'); + ->search('macos big sur'); ``` ### Sorting @@ -144,7 +151,7 @@ If you want to sort the results by another column, you can pass that column to t Search::add(Post::class, 'title', 'published_at') ->add(Video::class, 'title', 'released_at') ->orderByDesc() - ->get('learn'); + ->search('learn'); ``` You can call the `orderByRelevance` method to sort the results by the number of occurrences of the search terms. Imagine these two sentences: @@ -158,7 +165,7 @@ If you search for *Apple iPad*, the second sentence will come up first, as there Search::add(Post::class, 'title') ->beginWithWildcard() ->orderByRelevance() - ->get('Apple iPad'); + ->search('Apple iPad'); ``` Ordering by relevance is *not* supported if you're searching through (nested) relationships. @@ -173,12 +180,12 @@ Search::new() ->orderByModel([ Post::class, Video::class, Comment::class, ]) - ->get('Artisan School'); + ->search('Artisan School'); ``` ### Pagination -We highly recommend paginating your results. Call the `paginate` method before the `get` method, and you'll get an instance of `\Illuminate\Contracts\Pagination\LengthAwarePaginator` as a result. The `paginate` method takes three (optional) parameters to customize the paginator. These arguments are [the same](https://laravel.com/docs/master/pagination#introduction) as Laravel's database paginator. +We highly recommend paginating your results. Call the `paginate` method before the `search` method, and you'll get an instance of `\Illuminate\Contracts\Pagination\LengthAwarePaginator` as a result. The `paginate` method takes three (optional) parameters to customize the paginator. These arguments are [the same](https://laravel.com/docs/master/pagination#introduction) as Laravel's database paginator. ```php Search::add(Post::class, 'title') @@ -188,7 +195,7 @@ Search::add(Post::class, 'title') // or ->paginate($perPage = 15, $pageName = 'page', $page = 1) - ->get('build'); + ->search('build'); ``` You may also use [simple pagination](https://laravel.com/docs/master/pagination#simple-pagination). This will return an instance of `\Illuminate\Contracts\Pagination\Paginator`, which is not length aware: @@ -201,7 +208,7 @@ Search::add(Post::class, 'title') // or ->simplePaginate($perPage = 15, $pageName = 'page', $page = 1) - ->get('build'); + ->search('build'); ``` ### Constraints and scoped queries @@ -211,7 +218,7 @@ Instead of the class name, you can also pass an instance of the [Eloquent query ```php Search::add(Post::published(), 'title') ->add(Video::where('views', '>', 2500), 'title') - ->get('compile'); + ->search('compile'); ``` ### Multiple columns per model @@ -221,7 +228,7 @@ You can search through multiple columns by passing an array of columns as the se ```php Search::add(Post::class, ['title', 'body']) ->add(Video::class, ['title', 'subtitle']) - ->get('eloquent'); + ->search('eloquent'); ``` ### Search through (nested) relationships @@ -231,7 +238,30 @@ You can search through (nested) relationships by using the *dot* notation: ```php Search::add(Post::class, ['comments.body']) ->add(Video::class, ['posts.user.biography']) - ->get('solution'); + ->search('solution'); +``` + +### Full-Text Search + +You may use [MySQL's Full-Text Search](https://laravel.com/docs/master/queries#full-text-where-clauses) by using the `addFullText` method. You can search through a single or multiple columns (using [full text indexes](https://laravel.com/docs/master/migrations#available-index-types)), and you can specify a set of options, for example, to specify the mode. You can even regular and full-text searches in one query: + +```php +Search::new() + ->add(Post::class, 'title') + ->addFullText(Video::class, 'title', ['mode' => 'boolean']) + ->addFullText(Blog::class, ['title', 'subtitle', 'body'], ['mode' => 'boolean']) + ->search('framework -css'); +``` + +If you want to search through relationships, you need to pass in an array where the array key contains the relation, while the value is an array of columns: + +```php +Search::new() + ->addFullText(Page::class, [ + 'posts' => ['title', 'body'], + 'sections' => ['title', 'subtitle', 'body'], + ], ) + ->search('framework -css'); ``` ### Sounds like @@ -243,7 +273,7 @@ Search::new() ->add(Post::class, 'framework') ->add(Video::class, 'framework') ->soundsLike() - ->get('larafel'); + ->search('larafel'); ``` ### Eager load relationships @@ -253,19 +283,19 @@ Not much to explain here, but this is supported as well :) ```php Search::add(Post::with('comments'), 'title') ->add(Video::with('likes'), 'title') - ->get('guitar'); + ->search('guitar'); ``` ### Getting results without searching -You call the `get` method without a term or with an empty term. In this case, you can discard the second argument of the `add` method. With the `orderBy` method, you can set the column to sort by (previously the third argument): +You call the `search` method without a term or with an empty term. In this case, you can discard the second argument of the `add` method. With the `orderBy` method, you can set the column to sort by (previously the third argument): ```php Search::add(Post::class) ->orderBy('published_at') ->add(Video::class) ->orderBy('released_at') - ->get(); + ->search(); ``` ### Counting records @@ -287,7 +317,7 @@ Search::add(Post::class, 'title') ->add(Video::class, 'title') ->includeModelType() ->paginate() - ->get('foo'); + ->search('foo'); // Example result with model identifier. { @@ -318,9 +348,32 @@ Search::add(Post::class, 'title') By default, it uses the `type` key, but you can customize this by passing the key to the method. +You can also customize the `type` value by adding a public method `searchType()` to your model to override the default class base name. + ```php -Search::new() - ->includeModelType('model_type'); +class Video extends Model +{ + public function searchType() + { + return 'awesome_video'; + } +} + +// Example result with searchType() method. +{ + "current_page": 1, + "data": [ + { + "id": 1, + "video_id": null, + "title": "foo", + "published_at": null, + "created_at": "2021-12-03T09:39:10.000000Z", + "updated_at": "2021-12-03T09:39:10.000000Z", + "type": "awesome_video", + } + ], + ... ``` ### Standalone parser diff --git a/src/ModelToSearchThrough.php b/src/ModelToSearchThrough.php index 778fe5c..c929bc9 100644 --- a/src/ModelToSearchThrough.php +++ b/src/ModelToSearchThrough.php @@ -39,6 +39,11 @@ class ModelToSearchThrough */ protected array $fullTextOptions = []; + /** + * Full-text through relation. + */ + protected ?string $fullTextRelation = null; + /** * @param \Illuminate\Database\Eloquent\Builder $builder * @param \Illuminate\Support\Collection $columns @@ -46,15 +51,17 @@ class ModelToSearchThrough * @param integer $key * @param bool $fullText * @param array $fullTextOptions + * @param string $fullTextRelation */ - public function __construct(Builder $builder, Collection $columns, string $orderByColumn, int $key, bool $fullText = false, array $fullTextOptions = []) + public function __construct(Builder $builder, Collection $columns, string $orderByColumn, int $key, bool $fullText = false, array $fullTextOptions = [], string $fullTextRelation = null) { - $this->builder = $builder; - $this->columns = $columns; - $this->orderByColumn = $orderByColumn; - $this->key = $key; - $this->fullText = $fullText; - $this->fullTextOptions = $fullTextOptions; + $this->builder = $builder; + $this->columns = $columns; + $this->orderByColumn = $orderByColumn; + $this->key = $key; + $this->fullText = $fullText; + $this->fullTextOptions = $fullTextOptions; + $this->fullTextRelation = $fullTextRelation; } /** @@ -90,6 +97,18 @@ public function getColumns(): Collection return $this->columns; } + /** + * Set a collection with all columns or relations to search through. + * + * @return $this + */ + public function setColumns(Collection $columns): self + { + $this->columns = $columns; + + return $this; + } + /** * Get a collection with all qualified columns * to search through. @@ -162,7 +181,7 @@ public function getQualifiedOrderByColumnName(): string * * @return boolean */ - public function searchFullText(): bool + public function isFullTextSearch(): bool { return $this->fullText; } @@ -172,8 +191,62 @@ public function searchFullText(): bool * * @return array */ - public function fullTextOptions(): array + public function getFullTextOptions(): array { return $this->fullTextOptions; } + + /** + * Full-text through relation. + * + * @return string|null + */ + public function getFullTextRelation(): ?string + { + return $this->fullTextRelation; + } + + /** + * Full-text through relation. + * + * @return $this + */ + public function setFullTextRelation(?string $fullTextRelation = null): self + { + $this->fullTextRelation = $fullTextRelation; + + return $this; + } + + /** + * Clone the current instance. + * + * @return static + */ + public function clone(): static + { + return new static($this->builder, $this->columns, $this->orderByColumn, $this->key, $this->fullText, $this->fullTextOptions, $this->fullTextRelation); + } + + /** + * Split the current instance into multiple based on relation search. + * + * @return \Illuminate\Support\Collection + */ + public function toGroupedCollection(): Collection + { + if ($this->columns->all() === $this->columns->flatten()->all()) { + return Collection::wrap($this); + } + + $collection = Collection::make(); + + foreach ($this->columns as $relation => $columns) { + $collection->push( + $this->clone()->setColumns(Collection::wrap($columns))->setFullTextRelation($relation) + ); + } + + return $collection; + } } diff --git a/src/Search.php b/src/Search.php index 347c8b8..14949d0 100644 --- a/src/Search.php +++ b/src/Search.php @@ -14,7 +14,7 @@ * @method static \ProtoneMedia\LaravelCrossEloquentSearch\Searcher endWithWildcard(bool $state) * @method static \ProtoneMedia\LaravelCrossEloquentSearch\Searcher soundsLike(bool $state) * @method static \ProtoneMedia\LaravelCrossEloquentSearch\Searcher add($query, $columns, string $orderByColumn = null) - * @method static \ProtoneMedia\LaravelCrossEloquentSearch\Searcher addWhen($value, $query, $columns, string $orderByColumn = null) + * @method static \ProtoneMedia\LaravelCrossEloquentSearch\Searcher when($value, callable $callback = null, callable $default = null) * @method static \ProtoneMedia\LaravelCrossEloquentSearch\Searcher addMany($queries) * @method static \ProtoneMedia\LaravelCrossEloquentSearch\Searcher paginate($perPage = 15, $pageName = 'page', $page = null) * @method static \ProtoneMedia\LaravelCrossEloquentSearch\Searcher simplePaginate($perPage = 15, $pageName = 'page', $page = null) diff --git a/src/Searcher.php b/src/Searcher.php index f61c44a..652f9ce 100644 --- a/src/Searcher.php +++ b/src/Searcher.php @@ -198,12 +198,21 @@ public function includeModelType(string $key = 'type'): self */ public function add($query, $columns = null, string $orderByColumn = null): self { + /** @var Builder $builder */ $builder = is_string($query) ? $query::query() : $query; + if (is_null($orderByColumn)) { + $model = $builder->getModel(); + + $orderByColumn = $model->usesTimestamps() + ? $model->getUpdatedAtColumn() + : $model->getKeyName(); + } + $modelToSearchThrough = new ModelToSearchThrough( $builder, Collection::wrap($columns), - $orderByColumn ?: $builder->getModel()->getUpdatedAtColumn(), + $orderByColumn, $this->modelsToSearchThrough->count(), ); @@ -230,24 +239,6 @@ public function addFullText($query, $columns = null, array $options = [], string return $this; } - /** - * Apply the model if the value is truthy. - * - * @param mixed $value - * @param \Illuminate\Database\Eloquent\Builder|string $query - * @param string|array|\Illuminate\Support\Collection $columns - * @param string $orderByColumn - * @return self - */ - public function addWhen($value, $query, $columns = null, string $orderByColumn = null): self - { - if (!$value) { - return $this; - } - - return $this->add($query, $columns, $orderByColumn); - } - /** * Loop through the queries and add them. * @@ -429,20 +420,35 @@ public function addSearchQueryToBuilder(Builder $builder, ModelToSearchThrough $ } $builder->where(function (Builder $query) use ($modelToSearchThrough) { - if ($modelToSearchThrough->searchFullText()) { - return $this->addWhereTermsToQuery( - $query, - $modelToSearchThrough->getColumns()->map(fn ($column) => $modelToSearchThrough->qualifyColumn($column))->all(), - true, - $modelToSearchThrough->fullTextOptions() - ); + if (!$modelToSearchThrough->isFullTextSearch()) { + return $modelToSearchThrough->getColumns()->each(function ($column) use ($query, $modelToSearchThrough) { + Str::contains($column, '.') + ? $this->addNestedRelationToQuery($query, $column) + : $this->addWhereTermsToQuery($query, $modelToSearchThrough->qualifyColumn($column)); + }); } - $modelToSearchThrough->getColumns()->each(function ($column) use ($query, $modelToSearchThrough) { - Str::contains($column, '.') - ? $this->addNestedRelationToQuery($query, $column, $modelToSearchThrough->searchFullText()) - : $this->addWhereTermsToQuery($query, $modelToSearchThrough->qualifyColumn($column), $modelToSearchThrough->searchFullText()); - }); + $modelToSearchThrough + ->toGroupedCollection() + ->each(function (ModelToSearchThrough $modelToSearchThrough) use ($query) { + if ($relation = $modelToSearchThrough->getFullTextRelation()) { + $query->orWhereHas($relation, function ($relationQuery) use ($modelToSearchThrough) { + $relationQuery->where(function ($query) use ($modelToSearchThrough) { + $query->orWhereFullText( + $modelToSearchThrough->getColumns()->all(), + $this->rawTerms, + $modelToSearchThrough->getFullTextOptions() + ); + }); + }); + } else { + $query->orWhereFullText( + $modelToSearchThrough->getColumns()->map(fn ($column) => $modelToSearchThrough->qualifyColumn($column))->all(), + $this->rawTerms, + $modelToSearchThrough->getFullTextOptions() + ); + } + }); }); } @@ -473,16 +479,10 @@ private function addNestedRelationToQuery(Builder $query, string $nestedRelation * * @param \Illuminate\Database\Eloquent\Builder $builder * @param array|string $columns - * @param bool $fullText - * @param array $fullTextOptions * @return void */ - private function addWhereTermsToQuery(Builder $query, $column, bool $fullText = false, array $fullTextOptions = []) + private function addWhereTermsToQuery(Builder $query, $column) { - if ($fullText) { - return $query->orWhereFullText($column, $this->rawTerms, $fullTextOptions); - } - $column = $this->ignoreCase ? (new MySqlGrammar)->wrap($column) : $column; $this->terms->each(function ($term) use ($query, $column) { @@ -739,7 +739,7 @@ public function count(string $terms = null): int * @param string $terms * @return \Illuminate\Database\Eloquent\Collection|\Illuminate\Contracts\Pagination\LengthAwarePaginator */ - public function get(string $terms = null) + public function search(string $terms = null) { $this->initializeTerms($terms ?: ''); @@ -766,7 +766,9 @@ public function get(string $terms = null) $model = $modelsPerType->get($modelKey)->get($item->$modelKey); if ($this->includeModelTypeWithKey) { - $model->setAttribute($this->includeModelTypeWithKey, class_basename($model)); + $searchType = method_exists($model, 'searchType') ? $model->searchType() : class_basename($model); + + $model->setAttribute($this->includeModelTypeWithKey, $searchType); } return $model; diff --git a/tests/SearchTest.php b/tests/SearchTest.php index 1d84ff6..38aa773 100644 --- a/tests/SearchTest.php +++ b/tests/SearchTest.php @@ -27,7 +27,7 @@ public function it_can_search_two_models_and_orders_by_updated_at_by_default() $results = Search::add(Post::class, 'title') ->add(Video::class, 'title') - ->get('foo'); + ->search('foo'); $this->assertInstanceOf(Collection::class, $results); $this->assertCount(2, $results); @@ -51,7 +51,7 @@ public function it_can_search_in_multiple_columns() $results = Search::add(Post::class, 'title') ->add(Video::class, ['title', 'subtitle']) - ->get('foo'); + ->search('foo'); $this->assertCount(3, $results); @@ -100,7 +100,7 @@ public function it_can_search_for_a_phrase() $results = Search::add(Post::class, 'title') ->add(Video::class, 'title') - ->get('"bar bar"'); + ->search('"bar bar"'); $this->assertCount(1, $results); @@ -118,7 +118,7 @@ public function it_has_an_option_to_dont_split_the_search_term() $results = Search::add(Post::class, 'title') ->add(Video::class, 'title') ->dontParseTerm() - ->get('bar bar'); + ->search('bar bar'); $this->assertCount(1, $results); @@ -138,7 +138,7 @@ public function it_has_an_option_to_ignore_the_case() ->add(VideoJson::class, 'title->nl') ->beginWithWildcard() ->ignoreCase() - ->get('FOO'); + ->search('FOO'); $this->assertCount(2, $results); } @@ -172,7 +172,7 @@ public function it_can_search_without_a_term() $results = Search::new() ->add(Post::class)->orderBy('updated_at') ->add(Video::class)->orderBy('published_at') - ->get(); + ->search(); $this->assertCount(4, $results); } @@ -186,9 +186,9 @@ public function it_can_conditionally_add_queries() Video::create(['title' => 'bar']); $results = Search::new() - ->addWhen(true, Post::class, 'title') - ->addWhen(false, Video::class, 'title', 'published_at') - ->get('foo'); + ->when(true, fn (Searcher $searcher) => $searcher->add(Post::class, 'title')) + ->when(false, fn (Searcher $searcher) => $searcher->add(Video::class, 'title', 'published_at')) + ->search('foo'); $this->assertCount(1, $results); $this->assertTrue($results->first()->is($postA)); @@ -203,7 +203,7 @@ public function it_can_add_many_models_at_once() $results = Search::addMany([ [Video::class, 'title'], [Video::class, 'subtitle', 'created_at'], - ])->get('foo'); + ])->search('foo'); $this->assertCount(2, $results); @@ -216,8 +216,8 @@ public function it_can_search_on_the_left_side_of_the_term() { Video::create(['title' => 'foo']); - $this->assertCount(1, Search::add(Video::class, 'title')->get('fo')); - $this->assertCount(0, Search::add(Video::class, 'title')->endWithWildcard(false)->get('fo')); + $this->assertCount(1, Search::add(Video::class, 'title')->search('fo')); + $this->assertCount(0, Search::add(Video::class, 'title')->endWithWildcard(false)->search('fo')); } /** @test */ @@ -225,8 +225,8 @@ public function it_can_use_the_sounds_like_operator() { Video::create(['title' => 'laravel']); - $this->assertCount(0, Search::add(Video::class, 'title')->get('larafel')); - $this->assertCount(1, Search::add(Video::class, 'title')->soundsLike()->get('larafel')); + $this->assertCount(0, Search::add(Video::class, 'title')->search('larafel')); + $this->assertCount(1, Search::add(Video::class, 'title')->soundsLike()->search('larafel')); } /** @test */ @@ -237,7 +237,7 @@ public function it_can_search_twice_in_the_same_table() $results = Search::add(Video::class, 'title') ->add(Video::class, 'subtitle') - ->get('foo'); + ->search('foo'); $this->assertCount(2, $results); @@ -256,7 +256,7 @@ public function it_lets_you_specify_a_custom_order_by_column_and_direction() $results = Search::add(Post::class, 'title', 'published_at') ->add(Video::class, 'title', 'published_at') ->orderByDesc() - ->get('foo'); + ->search('foo'); $this->assertCount(2, $results); @@ -274,7 +274,7 @@ public function it_accepts_a_query_builder() $results = Search::add(Post::whereNotNull('published_at'), 'title') ->add(Video::whereNotNull('published_at'), 'title') - ->get('foo'); + ->search('foo'); $this->assertCount(1, $results); @@ -293,8 +293,8 @@ public function it_can_paginate_the_results() ->add(Video::class, 'title', 'published_at') ->orderByDesc(); - $resultsPage1 = $search->paginate(2, 'page', 1)->get('foo'); - $resultsPage2 = $search->paginate(2, 'page', 2)->get('foo'); + $resultsPage1 = $search->paginate(2, 'page', 1)->search('foo'); + $resultsPage2 = $search->paginate(2, 'page', 2)->search('foo'); $this->assertInstanceOf(LengthAwarePaginator::class, $resultsPage1); $this->assertInstanceOf(LengthAwarePaginator::class, $resultsPage2); @@ -321,7 +321,7 @@ public function it_can_eager_load_relations() } $results = Search::add(Post::with('comments')->withCount('comments'), 'title') - ->get('foo'); + ->search('foo'); $this->assertCount(1, $results); $this->assertEquals(10, $results->first()->comments_count); @@ -348,7 +348,7 @@ public function it_can_search_through_relations() ->beginWithWildcard(false) ->endWithWildcard(false) ->add(Video::class, 'posts.comments.body') - ->get('comment4'); + ->search('comment4'); $this->assertCount(1, $results); $this->assertTrue($results->first()->is($videoB)); @@ -357,7 +357,7 @@ public function it_can_search_through_relations() ->beginWithWildcard(false) ->endWithWildcard(false) ->add(Video::class, ['title', 'posts.comments.body']) - ->get('foo1 comment4'); + ->search('foo1 comment4'); $this->assertCount(2, $results); @@ -366,7 +366,7 @@ public function it_can_search_through_relations() ->endWithWildcard(false) ->add(Video::class, ['title', 'posts.comments.body']) ->add(Post::class, ['title', 'comments.body']) - ->get('foo1 foo2 comment4'); + ->search('foo1 foo2 comment4'); $this->assertCount(4, $results); @@ -388,7 +388,7 @@ public function it_doesnt_add_term_constraints_when_the_search_query_is_empty() $results = Search::new() ->add(Video::class, ['title', 'posts.title']) - ->get(); + ->search(); $this->assertCount(2, $results); } @@ -407,7 +407,7 @@ public function it_can_sort_by_model_order() ->orderByModel([ Comment::class, Post::class, Video::class, ]) - ->get('foo'); + ->search('foo'); $this->assertInstanceOf(Comment::class, $results->get(0)); $this->assertInstanceOf(Post::class, $results->get(1)); @@ -422,7 +422,7 @@ public function it_can_sort_by_model_order() Post::class, Video::class, Comment::class, ]) ->orderByDesc() - ->get('foo'); + ->search('foo'); $this->assertInstanceOf(Comment::class, $results->get(0)); $this->assertInstanceOf(Video::class, $results->get(1)); @@ -434,7 +434,7 @@ public function it_can_sort_by_model_order() ->add(Video::class, ['title']) ->add(Comment::class, ['body']) ->orderByModel(Comment::class) - ->get('foo'); + ->search('foo'); $this->assertInstanceOf(Comment::class, $results->get(0)); } @@ -451,7 +451,7 @@ public function it_respects_the_regular_order_when_ordering_by_model_type() ->add(Post::class, 'title', 'published_at') ->add(Video::class, 'title', 'published_at') ->orderByModel([Video::class, Post::class]) - ->get('foo'); + ->search('foo'); $this->assertCount(4, $results); @@ -474,7 +474,7 @@ public function it_respects_the_relevance_order_when_ordering_by_model_type() ->beginWithWildcard() ->orderByRelevance() ->orderByModel([Video::class, Post::class]) - ->get('Apple iPad'); + ->search('Apple iPad'); $this->assertCount(4, $results); $this->assertTrue($results->first()->is($videoB), $results->toJson()); @@ -494,7 +494,7 @@ public function it_cant_order_by_relevance_when_searching_through_nested_relatio ->orderByRelevance(); try { - $search->get('bar'); + $search->search('bar'); } catch (OrderByRelevanceException $e) { return $this->assertTrue(true); } @@ -512,7 +512,7 @@ public function it_can_sort_by_word_occurrence() ->add(Video::class, ['title', 'subtitle']) ->beginWithWildcard() ->orderByRelevance() - ->get('Apple iPad'); + ->search('Apple iPad'); $this->assertCount(2, $results); $this->assertTrue($results->first()->is($videoB)); @@ -527,7 +527,7 @@ public function it_doesnt_fail_when_the_terms_are_empty() $results = Search::new() ->add(Video::class) ->orderByRelevance() - ->get(); + ->search(); $this->assertCount(2, $results); } @@ -539,7 +539,7 @@ public function it_uses_length_aware_paginator_by_default() ->add(Video::class, 'title', 'published_at') ->orderByDesc(); - $results = $search->paginate()->get('foo'); + $results = $search->paginate()->search('foo'); $this->assertInstanceOf(LengthAwarePaginator::class, $results); } @@ -552,7 +552,7 @@ public function it_can_use_simple_paginator() ->add(Video::class, 'title', 'published_at') ->orderByDesc(); - $results = $search->simplePaginate()->get('foo'); + $results = $search->simplePaginate()->search('foo'); $this->assertInstanceOf(Paginator::class, $results); } @@ -570,8 +570,8 @@ public function it_can_simple_paginate_the_results() ->add(Video::class, 'title', 'published_at') ->orderByDesc(); - $resultsPage1 = $search->simplePaginate(2, 'page', 1)->get('foo'); - $resultsPage2 = $search->simplePaginate(2, 'page', 2)->get('foo'); + $resultsPage1 = $search->simplePaginate(2, 'page', 1)->search('foo'); + $resultsPage2 = $search->simplePaginate(2, 'page', 2)->search('foo'); $this->assertInstanceOf(Paginator::class, $resultsPage1); $this->assertInstanceOf(Paginator::class, $resultsPage2); @@ -596,12 +596,27 @@ public function it_includes_a_model_identifier_to_search_results() ->add(Video::class, 'title', 'title') ->includeModelType() ->paginate() - ->get('ba'); + ->search('ba'); $this->assertEquals($search->toArray()['data'][0]['type'], class_basename(Post::class)); $this->assertEquals($search->toArray()['data'][1]['type'], class_basename(Video::class)); } + /** @test */ + public function it_includes_a_custom_model_identifier_to_search_results() + { + Post::create(['title' => 'bar']); + Video::create(['title' => 'baz']); + + $search = Search::new() + ->add(VideoJson::class, 'title', 'title') + ->includeModelType() + ->paginate() + ->search('ba'); + + $this->assertEquals($search->toArray()['data'][0]['type'], 'awesome_video'); + } + /** @test */ public function it_supports_full_text_search() { @@ -619,7 +634,7 @@ public function it_supports_full_text_search() ->add(Post::class, 'title') ->addFullText(Blog::class, ['title', 'subtitle', 'body'], ['mode' => 'boolean']) ->addFullText(Page::class, ['title', 'subtitle', 'body'], ['mode' => 'boolean']) - ->get('framework -css'); + ->search('framework -css'); $this->assertCount(4, $results); @@ -630,7 +645,35 @@ public function it_supports_full_text_search() } /** @test */ - public function it_returns_data_consistently() { + public function it_supports_full_text_search_on_relations() + { + $videoA = Video::create(['title' => 'Page A']); + $videoB = Video::create(['title' => 'Page B']); + $videoC = Video::create(['title' => 'Page C']); + $videoD = Video::create(['title' => 'Page D']); + + $videoA->blogs()->create(['title' => 'Laravel Framework', 'subtitle' => 'PHP', 'body' => 'Ad nostrud adipisicing deserunt labore reprehenderit ']); + $videoB->blogs()->create(['title' => 'Tailwind Framework', 'subtitle' => 'CSS', 'body' => 'aute do commodo ea magna dolor cupidatat ullamco commodo.']); + $videoC->pages()->create(['title' => 'Laravel Framework', 'subtitle' => 'PHP', 'body' => 'Ad nostrud adipisicing deserunt labore reprehenderit ']); + $videoD->pages()->create(['title' => 'Tailwind Framework', 'subtitle' => 'CSS', 'body' => 'aute do commodo ea magna dolor cupidatat ullamco commodo.']); + + $results = Search::new() + ->beginWithWildcard() + ->addFullText(Video::class, [ + 'blogs' => ['title', 'subtitle', 'body'], + 'pages' => ['title', 'subtitle', 'body'], + ], ) + ->search('framework -css'); + + $this->assertCount(2, $results); + + $this->assertTrue($results->contains($videoA)); + $this->assertTrue($results->contains($videoC)); + } + + /** @test */ + public function it_returns_data_consistently() + { Carbon::setTestNow(now()); $postA = Post::create(['title' => 'Laravel Framework']); @@ -642,15 +685,15 @@ public function it_returns_data_consistently() { $resultA = Search::addMany([ [Post::query(), 'title'], - ])->get(''); + ])->search(''); $resultB = Search::addMany([ [Post::query(), 'title'], [Blog::query(), 'title'], - ])->get(''); - + ])->search(''); + $this->assertCount(2, $resultA); - $this->assertCount(2, $resultB); + $this->assertCount(2, $resultB); $this->assertTrue($resultA->first()->is($postA)); $this->assertTrue($resultB->first()->is($postA)); @@ -667,7 +710,7 @@ public function it_can_conditionally_apply_ordering() $results = Search::add(Post::class, 'title') ->when(true, fn (Searcher $searcher) => $searcher->orderByDesc()) - ->get('foo'); + ->search('foo'); $this->assertInstanceOf(Collection::class, $results); $this->assertCount(2, $results); diff --git a/tests/Video.php b/tests/Video.php index 9e3ca12..50c69e7 100644 --- a/tests/Video.php +++ b/tests/Video.php @@ -10,4 +10,14 @@ public function posts() { return $this->hasMany(Post::class); } + + public function blogs() + { + return $this->hasMany(Blog::class); + } + + public function pages() + { + return $this->hasMany(Page::class); + } } diff --git a/tests/VideoJson.php b/tests/VideoJson.php index 4e1ec01..4bb4924 100644 --- a/tests/VideoJson.php +++ b/tests/VideoJson.php @@ -9,4 +9,9 @@ class VideoJson extends Model protected $table = 'videos'; protected $casts = ['title' => 'array']; + + public function searchType() + { + return 'awesome_video'; + } } diff --git a/tests/create_tables.php b/tests/create_tables.php index 276c547..37505f2 100644 --- a/tests/create_tables.php +++ b/tests/create_tables.php @@ -47,19 +47,23 @@ public function up() $table->fullText(['title', 'subtitle']); $table->fullText(['title', 'subtitle', 'body']); + $table->unsignedInteger('video_id')->nullable(); + $table->timestamps(); }); Schema::create('pages', function (Blueprint $table) { $table->bigIncrements('id'); $table->string('title'); - $table->string('subtitle'); - $table->string('body'); + $table->string('subtitle')->nullable(); + $table->string('body')->nullable(); $table->fullText('title'); $table->fullText(['title', 'subtitle']); $table->fullText(['title', 'subtitle', 'body']); + $table->unsignedInteger('video_id')->nullable(); + $table->timestamps(); }); }