├── .github ├── FUNDING.yml └── workflows │ └── run-tests.yml ├── src ├── OrderByRelevanceException.php ├── ServiceProvider.php ├── SearchFactory.php ├── Search.php ├── ModelToSearchThrough.php └── Searcher.php ├── LICENSE.md ├── composer.json ├── CONTRIBUTING.md ├── CHANGELOG.md └── README.md /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [pascalbaljet] 2 | -------------------------------------------------------------------------------- /src/OrderByRelevanceException.php: -------------------------------------------------------------------------------- 1 | app->singleton('laravel-cross-eloquent-search', function () { 15 | return new SearchFactory; 16 | }); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/SearchFactory.php: -------------------------------------------------------------------------------- 1 | forwardCallTo( 31 | $this->new(), 32 | $method, 33 | $parameters 34 | ); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Protone Media B.V. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "protonemedia/laravel-cross-eloquent-search", 3 | "description": "Laravel package to search through multiple Eloquent models. Supports pagination, eager loading relations, single/multiple columns, sorting and scoped queries.", 4 | "keywords": [ 5 | "protonemedia", 6 | "laravel-cross-eloquent-search" 7 | ], 8 | "homepage": "https://github.com/protonemedia/laravel-cross-eloquent-search", 9 | "license": "MIT", 10 | "type": "library", 11 | "authors": [ 12 | { 13 | "name": "Pascal Baljet", 14 | "email": "pascal@protone.media", 15 | "role": "Developer" 16 | } 17 | ], 18 | "require": { 19 | "php": "^8.2|^8.3|^8.4", 20 | "illuminate/support": "^10.48.28|^11.43|^12.0" 21 | }, 22 | "require-dev": { 23 | "mockery/mockery": "^1.4.4", 24 | "orchestra/testbench": "^8.0|^9.0|^10.0", 25 | "phpunit/phpunit": "^10.4|^11.5.3" 26 | }, 27 | "autoload": { 28 | "psr-4": { 29 | "ProtoneMedia\\LaravelCrossEloquentSearch\\": "src" 30 | } 31 | }, 32 | "autoload-dev": { 33 | "psr-4": { 34 | "ProtoneMedia\\LaravelCrossEloquentSearch\\Tests\\": "tests" 35 | } 36 | }, 37 | "scripts": { 38 | "test": "vendor/bin/phpunit", 39 | "test-coverage": "vendor/bin/phpunit --coverage-html coverage" 40 | }, 41 | "config": { 42 | "sort-packages": true 43 | }, 44 | "minimum-stability": "dev", 45 | "prefer-stable": true, 46 | "extra": { 47 | "laravel": { 48 | "providers": [ 49 | "ProtoneMedia\\LaravelCrossEloquentSearch\\ServiceProvider" 50 | ] 51 | } 52 | } 53 | } -------------------------------------------------------------------------------- /src/Search.php: -------------------------------------------------------------------------------- 1 | builder = $builder; 59 | $this->columns = $columns; 60 | $this->orderByColumn = $orderByColumn; 61 | $this->key = $key; 62 | $this->fullText = $fullText; 63 | $this->fullTextOptions = $fullTextOptions; 64 | $this->fullTextRelation = $fullTextRelation; 65 | } 66 | 67 | /** 68 | * Setter for the orderBy column. 69 | * 70 | * @param string $orderByColumn 71 | * @return self 72 | */ 73 | public function orderByColumn(string $orderByColumn): self 74 | { 75 | $this->orderByColumn = $orderByColumn; 76 | 77 | return $this; 78 | } 79 | 80 | /** 81 | * Get a cloned instance of the builder. 82 | * 83 | * @return \Illuminate\Database\Eloquent\Builder 84 | */ 85 | public function getFreshBuilder(): Builder 86 | { 87 | return clone $this->builder; 88 | } 89 | 90 | /** 91 | * Get a collection with all columns or relations to search through. 92 | * 93 | * @return \Illuminate\Support\Collection 94 | */ 95 | public function getColumns(): Collection 96 | { 97 | return $this->columns; 98 | } 99 | 100 | /** 101 | * Set a collection with all columns or relations to search through. 102 | * 103 | * @return $this 104 | */ 105 | public function setColumns(Collection $columns): self 106 | { 107 | $this->columns = $columns; 108 | 109 | return $this; 110 | } 111 | 112 | /** 113 | * Get a collection with all qualified columns 114 | * to search through. 115 | * 116 | * @return \Illuminate\Support\Collection 117 | */ 118 | public function getQualifiedColumns(): Collection 119 | { 120 | return $this->columns->map(fn ($column) => $this->qualifyColumn($column)); 121 | } 122 | 123 | /** 124 | * Get the model instance being queried. 125 | * 126 | * @return \Illuminate\Database\Eloquent\Model 127 | */ 128 | public function getModel(): Model 129 | { 130 | return $this->builder->getModel(); 131 | } 132 | 133 | /** 134 | * Generates a key for the model with a suffix. 135 | * 136 | * @param string $suffix 137 | * @return string 138 | */ 139 | public function getModelKey($suffix = 'key'): string 140 | { 141 | return implode('_', [ 142 | $this->key, 143 | Str::snake(class_basename($this->getModel())), 144 | $suffix, 145 | ]); 146 | } 147 | 148 | /** 149 | * Qualify a column by the model instance. 150 | * 151 | * @param string $column 152 | * @return string 153 | */ 154 | public function qualifyColumn(string $column): string 155 | { 156 | return $this->getModel()->qualifyColumn($column); 157 | } 158 | 159 | /** 160 | * Get the qualified key name. 161 | * 162 | * @return string 163 | */ 164 | public function getQualifiedKeyName(): string 165 | { 166 | return $this->qualifyColumn($this->getModel()->getKeyName()); 167 | } 168 | 169 | /** 170 | * Get the qualified order name. 171 | * 172 | * @return string 173 | */ 174 | public function getQualifiedOrderByColumnName(): string 175 | { 176 | return $this->qualifyColumn($this->orderByColumn); 177 | } 178 | 179 | /** 180 | * Full-text search. 181 | * 182 | * @return boolean 183 | */ 184 | public function isFullTextSearch(): bool 185 | { 186 | return $this->fullText; 187 | } 188 | 189 | /** 190 | * Full-text search options. 191 | * 192 | * @return array 193 | */ 194 | public function getFullTextOptions(): array 195 | { 196 | return $this->fullTextOptions; 197 | } 198 | 199 | /** 200 | * Full-text through relation. 201 | * 202 | * @return string|null 203 | */ 204 | public function getFullTextRelation(): ?string 205 | { 206 | return $this->fullTextRelation; 207 | } 208 | 209 | /** 210 | * Full-text through relation. 211 | * 212 | * @return $this 213 | */ 214 | public function setFullTextRelation(?string $fullTextRelation = null): self 215 | { 216 | $this->fullTextRelation = $fullTextRelation; 217 | 218 | return $this; 219 | } 220 | 221 | /** 222 | * Clone the current instance. 223 | * 224 | * @return static 225 | */ 226 | public function clone(): static 227 | { 228 | return new static($this->builder, $this->columns, $this->orderByColumn, $this->key, $this->fullText, $this->fullTextOptions, $this->fullTextRelation); 229 | } 230 | 231 | /** 232 | * Split the current instance into multiple based on relation search. 233 | * 234 | * @return \Illuminate\Support\Collection 235 | */ 236 | public function toGroupedCollection(): Collection 237 | { 238 | if ($this->columns->all() === $this->columns->flatten()->all()) { 239 | return Collection::wrap($this); 240 | } 241 | 242 | $collection = Collection::make(); 243 | 244 | foreach ($this->columns as $relation => $columns) { 245 | $collection->push( 246 | $this->clone()->setColumns(Collection::wrap($columns))->setFullTextRelation($relation) 247 | ); 248 | } 249 | 250 | return $collection; 251 | } 252 | } 253 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Laravel Cross Eloquent Search 2 | 3 | [![Latest Version on Packagist](https://img.shields.io/packagist/v/protonemedia/laravel-cross-eloquent-search.svg?style=flat-square)](https://packagist.org/packages/protonemedia/laravel-cross-eloquent-search) 4 | ![run-tests](https://github.com/protonemedia/laravel-cross-eloquent-search/workflows/run-tests/badge.svg) 5 | [![Total Downloads](https://img.shields.io/packagist/dt/protonemedia/laravel-cross-eloquent-search.svg?style=flat-square)](https://packagist.org/packages/protonemedia/laravel-cross-eloquent-search) 6 | [![Buy us a tree](https://img.shields.io/badge/Treeware-%F0%9F%8C%B3-lightgreen)](https://plant.treeware.earth/protonemedia/laravel-cross-eloquent-search) 7 | 8 | This Laravel package allows you to search through multiple Eloquent models. It supports sorting, pagination, scoped queries, eager load relationships, and searching through single or multiple columns. 9 | 10 | ## Sponsor Us 11 | 12 | [](https://inertiaui.com/inertia-table?utm_source=github&utm_campaign=laravel-cross-eloquent-search) 13 | 14 | ❤️ We proudly support the community by developing Laravel packages and giving them away for free. If this package saves you time or if you're relying on it professionally, please consider [sponsoring the maintenance and development](https://github.com/sponsors/pascalbaljet) and check out our latest premium package: [Inertia Table](https://inertiaui.com/inertia-table?utm_source=github&utm_campaign=laravel-cross-eloquent-search). Keeping track of issues and pull requests takes time, but we're happy to help! 15 | 16 | ## Requirements 17 | 18 | * PHP 8.2 or higher 19 | * MySQL 8.0+ 20 | * Laravel 10.0+ 21 | 22 | ## Features 23 | 24 | * Search through one or more [Eloquent models](https://laravel.com/docs/master/eloquent). 25 | * Support for cross-model [pagination](https://laravel.com/docs/master/pagination#introduction). 26 | * Search through single or multiple columns. 27 | * Search through (nested) relationships. 28 | * Support for Full-Text Search, even through relationships. 29 | * Order by (cross-model) columns or by relevance. 30 | * Use [constraints](https://laravel.com/docs/master/eloquent#retrieving-models) and [scoped queries](https://laravel.com/docs/master/eloquent#query-scopes). 31 | * [Eager load relationships](https://laravel.com/docs/master/eloquent-relationships#eager-loading) for each model. 32 | * In-database [sorting](https://laravel.com/docs/master/queries#ordering-grouping-limit-and-offset) of the combined result. 33 | * Zero third-party dependencies 34 | 35 | ### 📺 Want to watch an implementation of this package? Rewatch the live stream (skip to 13:44 for the good stuff): [https://youtu.be/WigAaQsPgSA](https://youtu.be/WigAaQsPgSA) 36 | 37 | ## Blog post 38 | 39 | If you want to know more about this package's background, please read [the blog post](https://protone.media/blog/search-through-multiple-eloquent-models-with-our-latest-laravel-package). 40 | 41 | ## Installation 42 | 43 | You can install the package via composer: 44 | 45 | ```bash 46 | composer require protonemedia/laravel-cross-eloquent-search 47 | ``` 48 | 49 | ## Upgrading from v2 to v3 50 | 51 | * The `get` method has been renamed to `search`. 52 | * The `addWhen` method has been removed in favor of [`when`](#usage). 53 | * 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. 54 | 55 | ## Upgrading from v1 to v2 56 | 57 | * The `startWithWildcard` method has been renamed to `beginWithWildcard`. 58 | * 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. 59 | * The `allowEmptySearchQuery` method and `EmptySearchQueryException` class have been removed, but you can still [get results without searching](#getting-results-without-searching). 60 | 61 | ## Usage 62 | 63 | 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. 64 | 65 | 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. 66 | 67 | ```php 68 | use ProtoneMedia\LaravelCrossEloquentSearch\Search; 69 | 70 | $results = Search::add(Post::class, 'title') 71 | ->add(Video::class, 'title') 72 | ->search('howto'); 73 | ``` 74 | 75 | If you care about indentation, you can optionally use the `new` method on the facade: 76 | 77 | ```php 78 | Search::new() 79 | ->add(Post::class, 'title') 80 | ->add(Video::class, 'title') 81 | ->search('howto'); 82 | ``` 83 | 84 | There's also an `when` method to apply certain clauses based on another condition: 85 | 86 | ```php 87 | Search::new() 88 | ->when($user->isVerified(), fn($search) => $search->add(Post::class, 'title')) 89 | ->when($user->isAdmin(), fn($search) => $search->add(Video::class, 'title')) 90 | ->search('howto'); 91 | ``` 92 | 93 | ### Wildcards 94 | 95 | By default, we split up the search term, and each keyword will get a wildcard symbol to do partial matching. Practically this means the search term `apple ios` will result in `apple%` and `ios%`. If you want a wildcard symbol to begin with as well, you can call the `beginWithWildcard` method. This will result in `%apple%` and `%ios%`. 96 | 97 | ```php 98 | Search::add(Post::class, 'title') 99 | ->add(Video::class, 'title') 100 | ->beginWithWildcard() 101 | ->search('os'); 102 | ``` 103 | 104 | *Note: in previous versions of this package, this method was called `startWithWildcard()`.* 105 | 106 | If you want to disable the behaviour where a wildcard is appended to the terms, you should call the `endWithWildcard` method with `false`: 107 | 108 | ```php 109 | Search::add(Post::class, 'title') 110 | ->add(Video::class, 'title') 111 | ->beginWithWildcard() 112 | ->endWithWildcard(false) 113 | ->search('os'); 114 | ``` 115 | 116 | ### Multi-word search 117 | 118 | Multi-word search is supported out of the box. Simply wrap your phrase into double-quotes. 119 | 120 | ```php 121 | Search::add(Post::class, 'title') 122 | ->add(Video::class, 'title') 123 | ->search('"macos big sur"'); 124 | ``` 125 | 126 | You can disable the parsing of the search term by calling the `dontParseTerm` method, which gives you the same results as using double-quotes. 127 | 128 | ```php 129 | Search::add(Post::class, 'title') 130 | ->add(Video::class, 'title') 131 | ->dontParseTerm() 132 | ->search('macos big sur'); 133 | ``` 134 | 135 | ### Sorting 136 | 137 | If you want to sort the results by another column, you can pass that column to the `add` method as a third parameter. Call the `orderByDesc` method to sort the results in descending order. 138 | 139 | ```php 140 | Search::add(Post::class, 'title', 'published_at') 141 | ->add(Video::class, 'title', 'released_at') 142 | ->orderByDesc() 143 | ->search('learn'); 144 | ``` 145 | 146 | You can call the `orderByRelevance` method to sort the results by the number of occurrences of the search terms. Imagine these two sentences: 147 | 148 | * Apple introduces iPhone 13 and iPhone 13 mini 149 | * Apple unveils new iPad mini with breakthrough performance in stunning new design 150 | 151 | If you search for *Apple iPad*, the second sentence will come up first, as there are more matches of the search terms. 152 | 153 | ```php 154 | Search::add(Post::class, 'title') 155 | ->beginWithWildcard() 156 | ->orderByRelevance() 157 | ->search('Apple iPad'); 158 | ``` 159 | 160 | Ordering by relevance is *not* supported if you're searching through (nested) relationships. 161 | 162 | To sort the results by model type, you can use the `orderByModel` method by giving it your preferred order of the models: 163 | 164 | ```php 165 | Search::new() 166 | ->add(Comment::class, ['body']) 167 | ->add(Post::class, ['title']) 168 | ->add(Video::class, ['title', 'description']) 169 | ->orderByModel([ 170 | Post::class, Video::class, Comment::class, 171 | ]) 172 | ->search('Artisan School'); 173 | ``` 174 | 175 | ### Pagination 176 | 177 | 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. 178 | 179 | ```php 180 | Search::add(Post::class, 'title') 181 | ->add(Video::class, 'title') 182 | 183 | ->paginate() 184 | // or 185 | ->paginate($perPage = 15, $pageName = 'page', $page = 1) 186 | 187 | ->search('build'); 188 | ``` 189 | 190 | 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: 191 | 192 | ```php 193 | Search::add(Post::class, 'title') 194 | ->add(Video::class, 'title') 195 | 196 | ->simplePaginate() 197 | // or 198 | ->simplePaginate($perPage = 15, $pageName = 'page', $page = 1) 199 | 200 | ->search('build'); 201 | ``` 202 | 203 | ### Constraints and scoped queries 204 | 205 | Instead of the class name, you can also pass an instance of the [Eloquent query builder](https://laravel.com/docs/master/eloquent#retrieving-models) to the `add` method. This allows you to add constraints to each model. 206 | 207 | ```php 208 | Search::add(Post::published(), 'title') 209 | ->add(Video::where('views', '>', 2500), 'title') 210 | ->search('compile'); 211 | ``` 212 | 213 | ### Multiple columns per model 214 | 215 | You can search through multiple columns by passing an array of columns as the second argument. 216 | 217 | ```php 218 | Search::add(Post::class, ['title', 'body']) 219 | ->add(Video::class, ['title', 'subtitle']) 220 | ->search('eloquent'); 221 | ``` 222 | 223 | ### Search through (nested) relationships 224 | 225 | You can search through (nested) relationships by using the *dot* notation: 226 | 227 | ```php 228 | Search::add(Post::class, ['comments.body']) 229 | ->add(Video::class, ['posts.user.biography']) 230 | ->search('solution'); 231 | ``` 232 | 233 | ### Full-Text Search 234 | 235 | 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 mix regular and full-text searches in one query: 236 | 237 | ```php 238 | Search::new() 239 | ->add(Post::class, 'title') 240 | ->addFullText(Video::class, 'title', ['mode' => 'boolean']) 241 | ->addFullText(Blog::class, ['title', 'subtitle', 'body'], ['mode' => 'boolean']) 242 | ->search('framework -css'); 243 | ``` 244 | 245 | 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: 246 | 247 | ```php 248 | Search::new() 249 | ->addFullText(Page::class, [ 250 | 'posts' => ['title', 'body'], 251 | 'sections' => ['title', 'subtitle', 'body'], 252 | ]) 253 | ->search('framework -css'); 254 | ``` 255 | 256 | ### Sounds like 257 | 258 | MySQL has a *soundex* algorithm built-in so you can search for terms that sound almost the same. You can use this feature by calling the `soundsLike` method: 259 | 260 | ```php 261 | Search::new() 262 | ->add(Post::class, 'framework') 263 | ->add(Video::class, 'framework') 264 | ->soundsLike() 265 | ->search('larafel'); 266 | ``` 267 | 268 | ### Eager load relationships 269 | 270 | Not much to explain here, but this is supported as well :) 271 | 272 | ```php 273 | Search::add(Post::with('comments'), 'title') 274 | ->add(Video::with('likes'), 'title') 275 | ->search('guitar'); 276 | ``` 277 | 278 | ### Getting results without searching 279 | 280 | 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): 281 | 282 | ```php 283 | Search::add(Post::class) 284 | ->orderBy('published_at') 285 | ->add(Video::class) 286 | ->orderBy('released_at') 287 | ->search(); 288 | ``` 289 | 290 | ### Counting records 291 | 292 | You can count the number of results with the `count` method: 293 | 294 | ```php 295 | Search::add(Post::published(), 'title') 296 | ->add(Video::where('views', '>', 2500), 'title') 297 | ->count('compile'); 298 | ``` 299 | 300 | ### Model Identifier 301 | 302 | You can use the `includeModelType` to add the model type to the search result. 303 | 304 | ```php 305 | Search::add(Post::class, 'title') 306 | ->add(Video::class, 'title') 307 | ->includeModelType() 308 | ->paginate() 309 | ->search('foo'); 310 | 311 | // Example result with model identifier. 312 | { 313 | "current_page": 1, 314 | "data": [ 315 | { 316 | "id": 1, 317 | "video_id": null, 318 | "title": "foo", 319 | "published_at": null, 320 | "created_at": "2021-12-03T09:39:10.000000Z", 321 | "updated_at": "2021-12-03T09:39:10.000000Z", 322 | "type": "Post", 323 | }, 324 | { 325 | "id": 1, 326 | "title": "foo", 327 | "subtitle": null, 328 | "published_at": null, 329 | "created_at": "2021-12-03T09:39:10.000000Z", 330 | "updated_at": "2021-12-03T09:39:10.000000Z", 331 | "type": "Video", 332 | }, 333 | ], 334 | ... 335 | } 336 | ``` 337 | 338 | By default, it uses the `type` key, but you can customize this by passing the key to the method. 339 | 340 | You can also customize the `type` value by adding a public method `searchType()` to your model to override the default class base name. 341 | 342 | ```php 343 | class Video extends Model 344 | { 345 | public function searchType() 346 | { 347 | return 'awesome_video'; 348 | } 349 | } 350 | 351 | // Example result with searchType() method. 352 | { 353 | "current_page": 1, 354 | "data": [ 355 | { 356 | "id": 1, 357 | "video_id": null, 358 | "title": "foo", 359 | "published_at": null, 360 | "created_at": "2021-12-03T09:39:10.000000Z", 361 | "updated_at": "2021-12-03T09:39:10.000000Z", 362 | "type": "awesome_video", 363 | } 364 | ], 365 | ... 366 | ``` 367 | 368 | ### Standalone parser 369 | 370 | You can use the parser with the `parseTerms` method: 371 | 372 | ```php 373 | $terms = Search::parseTerms('drums guitar'); 374 | ``` 375 | 376 | You can also pass in a callback as a second argument to loop through each term: 377 | 378 | ```php 379 | Search::parseTerms('drums guitar', function($term, $key) { 380 | // 381 | }); 382 | ``` 383 | 384 | ### Testing 385 | 386 | ``` bash 387 | composer test 388 | ``` 389 | 390 | ### Changelog 391 | 392 | Please see [CHANGELOG](CHANGELOG.md) for more information about what has changed recently. 393 | 394 | ## Contributing 395 | 396 | Please see [CONTRIBUTING](CONTRIBUTING.md) for details. 397 | 398 | ## Other Laravel packages 399 | 400 | * [`Inertia Table`](https://inertiaui.com/inertia-table?utm_source=github&utm_campaign=laravel-cross-eloquent-search): The Ultimate Table for Inertia.js with built-in Query Builder. 401 | * [`Laravel Blade On Demand`](https://github.com/protonemedia/laravel-blade-on-demand): Laravel package to compile Blade templates in memory. 402 | * [`Laravel Eloquent Scope as Select`](https://github.com/protonemedia/laravel-eloquent-scope-as-select): Stop duplicating your Eloquent query scopes and constraints in PHP. This package lets you re-use your query scopes and constraints by adding them as a subquery. 403 | * [`Laravel FFMpeg`](https://github.com/protonemedia/laravel-ffmpeg): This package provides an integration with FFmpeg for Laravel. The storage of the files is handled by Laravel's Filesystem. 404 | * [`Laravel MinIO Testing Tools`](https://github.com/protonemedia/laravel-minio-testing-tools): Run your tests against a MinIO S3 server. 405 | * [`Laravel Mixins`](https://github.com/protonemedia/laravel-mixins): A collection of Laravel goodies. 406 | * [`Laravel Paddle`](https://github.com/protonemedia/laravel-paddle): Paddle.com API integration for Laravel with support for webhooks/events. 407 | * [`Laravel Task Runner`](https://github.com/protonemedia/laravel-task-runner): Write Shell scripts like Blade Components and run them locally or on a remote server. 408 | * [`Laravel Verify New Email`](https://github.com/protonemedia/laravel-verify-new-email): This package adds support for verifying new email addresses: when a user updates its email address, it won't replace the old one until the new one is verified. 409 | * [`Laravel XSS Protection`](https://github.com/protonemedia/laravel-xss-protection): Laravel Middleware to protect your app against Cross-site scripting (XSS). It sanitizes request input, and it can sanatize Blade echo statements. 410 | 411 | ### Security 412 | 413 | If you discover any security-related issues, please email pascal@protone.media instead of using the issue tracker. 414 | 415 | ## Credits 416 | 417 | - [Pascal Baljet](https://github.com/protonemedia) 418 | - [All Contributors](../../contributors) 419 | 420 | ## License 421 | 422 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 423 | 424 | ## Treeware 425 | 426 | This package is [Treeware](https://treeware.earth). If you use it in production, we ask that you [**buy the world a tree**](https://plant.treeware.earth/pascalbaljetmedia/laravel-cross-eloquent-search) to thank us for our work. By contributing to the Treeware forest, you'll create employment for local families and restoring wildlife habitats. 427 | -------------------------------------------------------------------------------- /src/Searcher.php: -------------------------------------------------------------------------------- 1 | modelsToSearchThrough = new Collection; 115 | 116 | $this->orderByAsc(); 117 | } 118 | 119 | /** 120 | * Sort the results in ascending order. 121 | * 122 | * @return self 123 | */ 124 | public function orderByAsc(): self 125 | { 126 | $this->orderByDirection = 'asc'; 127 | 128 | return $this; 129 | } 130 | 131 | /** 132 | * Sort the results in descending order. 133 | * 134 | * @return self 135 | */ 136 | public function orderByDesc(): self 137 | { 138 | $this->orderByDirection = 'desc'; 139 | 140 | return $this; 141 | } 142 | 143 | /** 144 | * Sort the results in relevance order. 145 | * 146 | * @return self 147 | */ 148 | public function orderByRelevance(): self 149 | { 150 | $this->orderByDirection = 'relevance'; 151 | 152 | return $this; 153 | } 154 | 155 | /** 156 | * Sort the results in order of the given models. 157 | * 158 | * @return self 159 | */ 160 | public function orderByModel($modelClasses): self 161 | { 162 | $this->orderByModel = Arr::wrap($modelClasses); 163 | 164 | return $this; 165 | } 166 | 167 | /** 168 | * Disable the parsing of the search term. 169 | */ 170 | public function dontParseTerm(): self 171 | { 172 | $this->parseTerm = false; 173 | 174 | return $this; 175 | } 176 | 177 | /** 178 | * Enable the inclusion of the model type in the search results. 179 | * 180 | * @param string $key 181 | * @return self 182 | */ 183 | public function includeModelType(string $key = 'type'): self 184 | { 185 | $this->includeModelTypeWithKey = $key; 186 | 187 | return $this; 188 | } 189 | 190 | /** 191 | * Add a model to search through. 192 | * 193 | * @param \Illuminate\Database\Eloquent\Builder|string $query 194 | * @param string|array|\Illuminate\Support\Collection $columns 195 | * @param string $orderByColumn 196 | * @param bool $fullText 197 | * @return self 198 | */ 199 | public function add($query, $columns = null, string $orderByColumn = null): self 200 | { 201 | /** @var Builder $builder */ 202 | $builder = is_string($query) ? $query::query() : $query; 203 | 204 | if (is_null($orderByColumn)) { 205 | $model = $builder->getModel(); 206 | 207 | $orderByColumn = $model->usesTimestamps() 208 | ? $model->getUpdatedAtColumn() 209 | : $model->getKeyName(); 210 | } 211 | 212 | $modelToSearchThrough = new ModelToSearchThrough( 213 | $builder, 214 | Collection::wrap($columns), 215 | $orderByColumn, 216 | $this->modelsToSearchThrough->count(), 217 | ); 218 | 219 | $this->modelsToSearchThrough->push($modelToSearchThrough); 220 | 221 | return $this; 222 | } 223 | 224 | public function addFullText($query, $columns = null, array $options = [], string $orderByColumn = null): self 225 | { 226 | $builder = is_string($query) ? $query::query() : $query; 227 | 228 | $modelToSearchThrough = new ModelToSearchThrough( 229 | $builder, 230 | Collection::wrap($columns), 231 | $orderByColumn ?: $builder->getModel()->getUpdatedAtColumn(), 232 | $this->modelsToSearchThrough->count(), 233 | true, 234 | $options 235 | ); 236 | 237 | $this->modelsToSearchThrough->push($modelToSearchThrough); 238 | 239 | return $this; 240 | } 241 | 242 | /** 243 | * Loop through the queries and add them. 244 | * 245 | * @param mixed $value 246 | * @return self 247 | */ 248 | public function addMany($queries): self 249 | { 250 | Collection::make($queries)->each(function ($query) { 251 | $this->add(...$query); 252 | }); 253 | 254 | return $this; 255 | } 256 | 257 | /** 258 | * Set the 'orderBy' column of the latest added model. 259 | * 260 | * @param string $orderByColumn 261 | * @return self 262 | */ 263 | public function orderBy(string $orderByColumn): self 264 | { 265 | $this->modelsToSearchThrough->last()->orderByColumn($orderByColumn); 266 | 267 | return $this; 268 | } 269 | 270 | /** 271 | * Ignore case of terms. 272 | * 273 | * @param boolean $state 274 | * @return self 275 | */ 276 | public function ignoreCase(bool $state = true): self 277 | { 278 | $this->ignoreCase = $state; 279 | 280 | return $this; 281 | } 282 | 283 | /** 284 | * Let's each search term begin with a wildcard. 285 | * 286 | * @param boolean $state 287 | * @return self 288 | */ 289 | public function beginWithWildcard(bool $state = true): self 290 | { 291 | $this->beginWithWildcard = $state; 292 | 293 | return $this; 294 | } 295 | 296 | /** 297 | * Let's each search term end with a wildcard. 298 | * 299 | * @param boolean $state 300 | * @return self 301 | */ 302 | public function endWithWildcard(bool $state = true): self 303 | { 304 | $this->endWithWildcard = $state; 305 | 306 | return $this; 307 | } 308 | 309 | /** 310 | * Use 'sounds like' operator instead of 'like'. 311 | * 312 | * @return self 313 | */ 314 | public function soundsLike(bool $state = true): self 315 | { 316 | $this->soundsLike = $state; 317 | 318 | $this->whereOperator = $state ? 'sounds like' : 'like'; 319 | 320 | return $this; 321 | } 322 | 323 | /** 324 | * Sets the pagination properties. 325 | * 326 | * @param integer $perPage 327 | * @param string $pageName 328 | * @param int|null $page 329 | * @return self 330 | */ 331 | public function paginate($perPage = 15, $pageName = 'page', $page = null): self 332 | { 333 | $this->page = $page ?: Paginator::resolveCurrentPage($pageName); 334 | $this->pageName = $pageName; 335 | $this->perPage = $perPage; 336 | $this->simplePaginate = false; 337 | 338 | return $this; 339 | } 340 | 341 | /** 342 | * Paginate using simple pagination. 343 | * 344 | * @param integer $perPage 345 | * @param string $pageName 346 | * @param int|null $page 347 | * @return self 348 | */ 349 | public function simplePaginate($perPage = 15, $pageName = 'page', $page = null): self 350 | { 351 | $this->paginate($perPage, $pageName, $page); 352 | 353 | $this->simplePaginate = true; 354 | 355 | return $this; 356 | } 357 | 358 | /** 359 | * Parse the terms and loop through them with the optional callable. 360 | * 361 | * @param string $terms 362 | * @param callable $callback 363 | * @return \Illuminate\Support\Collection 364 | */ 365 | public function parseTerms(string $terms, callable $callback = null): Collection 366 | { 367 | $callback = $callback ?: fn () => null; 368 | 369 | return Collection::make(str_getcsv($terms, ' ', '"')) 370 | ->filter() 371 | ->values() 372 | ->when($callback !== null, function ($terms) use ($callback) { 373 | return $terms->each(fn ($value, $key) => $callback($value, $key)); 374 | }); 375 | } 376 | 377 | /** 378 | * Creates a collection out of the given search term. 379 | * 380 | * @param string $terms 381 | * @throws \ProtoneMedia\LaravelCrossEloquentSearch\EmptySearchQueryException 382 | * @return self 383 | */ 384 | protected function initializeTerms(string $terms): self 385 | { 386 | $this->rawTerms = $terms; 387 | 388 | $terms = $this->parseTerm ? $this->parseTerms($terms) : $terms; 389 | 390 | $this->termsWithoutWildcards = Collection::wrap($terms)->filter()->map(function ($term) { 391 | return $this->ignoreCase ? Str::lower($term) : $term; 392 | }); 393 | 394 | $this->terms = Collection::make($this->termsWithoutWildcards)->unless($this->soundsLike, function ($terms) { 395 | return $terms->map(function ($term) { 396 | return implode([ 397 | $this->beginWithWildcard ? '%' : '', 398 | $term, 399 | $this->endWithWildcard ? '%' : '', 400 | ]); 401 | }); 402 | }); 403 | 404 | return $this; 405 | } 406 | 407 | /** 408 | * Adds a where clause to the builder, which encapsulates 409 | * a series 'orWhere' clauses for each column and for 410 | * each search term. 411 | * 412 | * @param \Illuminate\Database\Eloquent\Builder $builder 413 | * @param \ProtoneMedia\LaravelCrossEloquentSearch\ModelToSearchThrough $modelToSearchThrough 414 | * @return void 415 | */ 416 | public function addSearchQueryToBuilder(Builder $builder, ModelToSearchThrough $modelToSearchThrough): void 417 | { 418 | if ($this->termsWithoutWildcards->isEmpty()) { 419 | return; 420 | } 421 | 422 | $builder->where(function (Builder $query) use ($modelToSearchThrough) { 423 | if (!$modelToSearchThrough->isFullTextSearch()) { 424 | return $modelToSearchThrough->getColumns()->each(function ($column) use ($query, $modelToSearchThrough) { 425 | Str::contains($column, '.') 426 | ? $this->addNestedRelationToQuery($query, $column) 427 | : $this->addWhereTermsToQuery($query, $modelToSearchThrough->qualifyColumn($column)); 428 | }); 429 | } 430 | 431 | $modelToSearchThrough 432 | ->toGroupedCollection() 433 | ->each(function (ModelToSearchThrough $modelToSearchThrough) use ($query) { 434 | if ($relation = $modelToSearchThrough->getFullTextRelation()) { 435 | $query->orWhereHas($relation, function ($relationQuery) use ($modelToSearchThrough) { 436 | $relationQuery->where(function ($query) use ($modelToSearchThrough) { 437 | $query->orWhereFullText( 438 | $modelToSearchThrough->getColumns()->all(), 439 | $this->rawTerms, 440 | $modelToSearchThrough->getFullTextOptions() 441 | ); 442 | }); 443 | }); 444 | } else { 445 | $query->orWhereFullText( 446 | $modelToSearchThrough->getColumns()->map(fn ($column) => $modelToSearchThrough->qualifyColumn($column))->all(), 447 | $this->rawTerms, 448 | $modelToSearchThrough->getFullTextOptions() 449 | ); 450 | } 451 | }); 452 | }); 453 | } 454 | 455 | /** 456 | * Adds an 'orWhereHas' clause to the query to search through the given nested relation. 457 | * 458 | * @param \Illuminate\Database\Eloquent\Builder $query 459 | * @param string $column 460 | * @return void 461 | */ 462 | private function addNestedRelationToQuery(Builder $query, string $nestedRelationAndColumn) 463 | { 464 | $segments = explode('.', $nestedRelationAndColumn); 465 | 466 | $column = array_pop($segments); 467 | 468 | $relation = implode('.', $segments); 469 | 470 | $query->orWhereHas($relation, function ($relationQuery) use ($column) { 471 | $relationQuery->where( 472 | fn ($query) => $this->addWhereTermsToQuery($query, $query->qualifyColumn($column)) 473 | ); 474 | }); 475 | } 476 | 477 | /** 478 | * Adds an 'orWhere' clause to search for each term in the given column. 479 | * 480 | * @param \Illuminate\Database\Eloquent\Builder $builder 481 | * @param array|string $columns 482 | * @return void 483 | */ 484 | private function addWhereTermsToQuery(Builder $query, $column) 485 | { 486 | $column = $this->ignoreCase ? (new MySqlGrammar($query->getConnection()))->wrap($column) : $column; 487 | 488 | $this->terms->each(function ($term) use ($query, $column) { 489 | $this->ignoreCase 490 | ? $query->orWhereRaw("LOWER({$column}) {$this->whereOperator} ?", [$term]) 491 | : $query->orWhere($column, $this->whereOperator, $term); 492 | }); 493 | } 494 | 495 | /** 496 | * Adds a word count so we can order by relevance. 497 | * 498 | * @param \Illuminate\Database\Eloquent\Builder $builder 499 | * @param \ProtoneMedia\LaravelCrossEloquentSearch\ModelToSearchThrough $modelToSearchThrough 500 | * @return void 501 | */ 502 | private function addRelevanceQueryToBuilder($builder, $modelToSearchThrough) 503 | { 504 | if (!$this->isOrderingByRelevance() || $this->termsWithoutWildcards->isEmpty()) { 505 | return; 506 | } 507 | 508 | if (Str::contains($modelToSearchThrough->getColumns()->implode(''), '.')) { 509 | throw OrderByRelevanceException::new(); 510 | } 511 | 512 | $expressionsAndBindings = $modelToSearchThrough->getQualifiedColumns()->flatMap(function ($field) use ($modelToSearchThrough) { 513 | $connection = $modelToSearchThrough->getModel()->getConnection(); 514 | $prefix = $connection->getTablePrefix(); 515 | $field = (new MySqlGrammar($connection))->wrap($prefix . $field); 516 | 517 | return $this->termsWithoutWildcards->map(function ($term) use ($field) { 518 | return [ 519 | 'expression' => "COALESCE(CHAR_LENGTH(LOWER({$field})) - CHAR_LENGTH(REPLACE(LOWER({$field}), ?, ?)), 0)", 520 | 'bindings' => [Str::lower($term), Str::substr(Str::lower($term), 1)], 521 | ]; 522 | }); 523 | }); 524 | 525 | $selects = $expressionsAndBindings->map->expression->implode(' + '); 526 | $bindings = $expressionsAndBindings->flatMap->bindings->all(); 527 | 528 | $builder->selectRaw("{$selects} as terms_count", $bindings); 529 | } 530 | 531 | /** 532 | * Builds an array with all qualified columns for 533 | * both the ids and ordering. 534 | * 535 | * @param \ProtoneMedia\LaravelCrossEloquentSearch\ModelToSearchThrough $currentModel 536 | * @return array 537 | */ 538 | protected function makeSelects(ModelToSearchThrough $currentModel): array 539 | { 540 | return $this->modelsToSearchThrough->flatMap(function (ModelToSearchThrough $modelToSearchThrough) use ($currentModel) { 541 | $qualifiedKeyName = $qualifiedOrderByColumnName = $modelOrderKey = 'null'; 542 | 543 | if ($modelToSearchThrough === $currentModel) { 544 | $prefix = $modelToSearchThrough->getModel()->getConnection()->getTablePrefix(); 545 | 546 | $qualifiedKeyName = $prefix . $modelToSearchThrough->getQualifiedKeyName(); 547 | $qualifiedOrderByColumnName = $prefix . $modelToSearchThrough->getQualifiedOrderByColumnName(); 548 | 549 | if ($this->orderByModel) { 550 | $modelOrderKey = array_search( 551 | get_class($modelToSearchThrough->getModel()), 552 | $this->orderByModel ?: [] 553 | ); 554 | 555 | if ($modelOrderKey === false) { 556 | $modelOrderKey = count($this->orderByModel); 557 | } 558 | } 559 | } 560 | 561 | return array_filter([ 562 | DB::raw("{$qualifiedKeyName} as {$modelToSearchThrough->getModelKey()}"), 563 | DB::raw("{$qualifiedOrderByColumnName} as {$modelToSearchThrough->getModelKey('order')}"), 564 | $this->orderByModel ? DB::raw("{$modelOrderKey} as {$modelToSearchThrough->getModelKey('model_order')}") : null, 565 | ]); 566 | })->all(); 567 | } 568 | 569 | /** 570 | * Implodes the qualified order keys with a comma and 571 | * wraps them in a COALESCE method. 572 | * 573 | * @return string 574 | */ 575 | protected function makeOrderBy(): string 576 | { 577 | $modelOrderKeys = $this->modelsToSearchThrough->map->getModelKey('order')->implode(','); 578 | 579 | return "COALESCE({$modelOrderKeys})"; 580 | } 581 | 582 | /** 583 | * Implodes the qualified orderByModel keys with a comma and 584 | * wraps them in a COALESCE method. 585 | * 586 | * @return string 587 | */ 588 | protected function makeOrderByModel(): string 589 | { 590 | $modelOrderKeys = $this->modelsToSearchThrough->map->getModelKey('model_order')->implode(','); 591 | 592 | return "COALESCE({$modelOrderKeys})"; 593 | } 594 | 595 | /** 596 | * Builds the search queries for each given pending model. 597 | * 598 | * @return \Illuminate\Support\Collection 599 | */ 600 | protected function buildQueries(): Collection 601 | { 602 | return $this->modelsToSearchThrough->map(function (ModelToSearchThrough $modelToSearchThrough) { 603 | return $modelToSearchThrough->getFreshBuilder() 604 | ->select($this->makeSelects($modelToSearchThrough)) 605 | ->tap(function ($builder) use ($modelToSearchThrough) { 606 | $this->addSearchQueryToBuilder($builder, $modelToSearchThrough); 607 | $this->addRelevanceQueryToBuilder($builder, $modelToSearchThrough); 608 | }); 609 | }); 610 | } 611 | 612 | /** 613 | * Returns a boolean wether the ordering is set to 'relevance'. 614 | * 615 | * @return boolean 616 | */ 617 | private function isOrderingByRelevance(): bool 618 | { 619 | return $this->orderByDirection === 'relevance'; 620 | } 621 | 622 | /** 623 | * Compiles all queries to one big one which binds everything together 624 | * using UNION statements. 625 | * 626 | * @return 627 | */ 628 | protected function getCompiledQueryBuilder(): QueryBuilder 629 | { 630 | $queries = $this->buildQueries(); 631 | 632 | // take the first query 633 | 634 | /** @var BaseBuilder $firstQuery */ 635 | $firstQuery = $queries->shift()->toBase(); 636 | 637 | // union the other queries together 638 | $queries->each(fn (Builder $query) => $firstQuery->union($query)); 639 | 640 | if ($this->orderByModel) { 641 | $firstQuery->orderBy( 642 | DB::raw($this->makeOrderByModel()), 643 | $this->isOrderingByRelevance() ? 'asc' : $this->orderByDirection 644 | ); 645 | } 646 | 647 | if ($this->isOrderingByRelevance() && $this->termsWithoutWildcards->isNotEmpty()) { 648 | return $firstQuery->orderBy('terms_count', 'desc'); 649 | } 650 | 651 | // sort by the given columns and direction 652 | return $firstQuery->orderBy( 653 | DB::raw($this->makeOrderBy()), 654 | $this->isOrderingByRelevance() ? 'asc' : $this->orderByDirection 655 | ); 656 | } 657 | 658 | /** 659 | * Paginates the compiled query or fetches all results. 660 | * 661 | * @return \Illuminate\Support\Collection|\Illuminate\Contracts\Pagination\LengthAwarePaginator 662 | */ 663 | protected function getIdAndOrderAttributes() 664 | { 665 | $query = $this->getCompiledQueryBuilder(); 666 | 667 | // Determine the pagination method to call on Eloquent\Builder 668 | $paginateMethod = $this->simplePaginate ? 'simplePaginate' : 'paginate'; 669 | 670 | // get all results or limit the results by pagination 671 | return $this->pageName 672 | ? $query->{$paginateMethod}($this->perPage, ['*'], $this->pageName, $this->page) 673 | : $query->get(); 674 | 675 | // the collection will be something like: 676 | // 677 | // [ 678 | // [ 679 | // "0_post_key": null 680 | // "0_post_order": null 681 | // "1_video_key": 3 682 | // "1_video_order": "2020-07-07 19:51:08" 683 | // ], 684 | // [ 685 | // "0_post_key": 1 686 | // "0_post_order": "2020-07-08 19:51:08" 687 | // "1_video_key": null 688 | // "1_video_order": null 689 | // ] 690 | // ] 691 | } 692 | 693 | /** 694 | * Get the models per type. 695 | * 696 | * @param \Illuminate\Support\Collection|\Illuminate\Contracts\Pagination\LengthAwarePaginator $results 697 | * @return \Illuminate\Support\Collection 698 | */ 699 | protected function getModelsPerType($results) 700 | { 701 | return $this->modelsToSearchThrough 702 | ->keyBy->getModelKey() 703 | ->map(function (ModelToSearchThrough $modelToSearchThrough, $key) use ($results) { 704 | $ids = $results->pluck($key)->filter(); 705 | 706 | return $ids->isNotEmpty() 707 | ? $modelToSearchThrough->getFreshBuilder()->whereKey($ids)->get()->keyBy->getKey() 708 | : null; 709 | }); 710 | 711 | // the collection will be something like: 712 | // 713 | // [ 714 | // "0_post_key" => [ 715 | // 1 => PostModel 716 | // ], 717 | // "1_video_key" => [ 718 | // 3 => VideoModel 719 | // ], 720 | // ] 721 | } 722 | 723 | /** 724 | * Retrieve the "count" result of the query. 725 | * 726 | * @param string $terms 727 | * @return integer 728 | */ 729 | public function count(string $terms = null): int 730 | { 731 | $this->initializeTerms($terms ?: ''); 732 | 733 | return $this->getCompiledQueryBuilder()->count(); 734 | } 735 | 736 | /** 737 | * Initialize the search terms, execute the search query and retrieve all 738 | * models per type. Map the results to a Eloquent collection and set 739 | * the collection on the paginator (whenever used). 740 | * 741 | * @param string $terms 742 | * @return \Illuminate\Database\Eloquent\Collection|\Illuminate\Contracts\Pagination\LengthAwarePaginator 743 | */ 744 | public function search(string $terms = null) 745 | { 746 | $this->initializeTerms($terms ?: ''); 747 | 748 | $results = $this->getIdAndOrderAttributes(); 749 | 750 | $modelsPerType = $this->getModelsPerType($results); 751 | 752 | // loop over the results again and replace the object with the related model 753 | return $results->map(function ($item) use ($modelsPerType) { 754 | // from this set, pick '0_post_key' 755 | // 756 | // [ 757 | // "0_post_key": 1 758 | // "0_post_order": "2020-07-08 19:51:08" 759 | // "1_video_key": null 760 | // "1_video_order": null 761 | // ] 762 | 763 | $modelKey = Collection::make($item)->search(function ($value, $key) { 764 | return $value && Str::endsWith($key, '_key'); 765 | }); 766 | 767 | /** @var Model $model */ 768 | $model = $modelsPerType->get($modelKey)->get($item->$modelKey); 769 | 770 | if ($this->includeModelTypeWithKey) { 771 | $searchType = method_exists($model, 'searchType') ? $model->searchType() : class_basename($model); 772 | 773 | $model->setAttribute($this->includeModelTypeWithKey, $searchType); 774 | } 775 | 776 | return $model; 777 | }) 778 | ->pipe(fn (Collection $models) => new EloquentCollection($models)) 779 | ->when($this->pageName, fn (EloquentCollection $models) => $results->setCollection($models)); 780 | } 781 | } 782 | --------------------------------------------------------------------------------