├── LICENSE.md
├── README.md
├── composer.json
├── config
└── scout.php
├── src
├── Attributes
│ ├── SearchUsingFullText.php
│ └── SearchUsingPrefix.php
├── Builder.php
├── Console
│ ├── DeleteAllIndexesCommand.php
│ ├── DeleteIndexCommand.php
│ ├── FlushCommand.php
│ ├── ImportCommand.php
│ ├── IndexCommand.php
│ ├── QueueImportCommand.php
│ └── SyncIndexSettingsCommand.php
├── Contracts
│ ├── PaginatesEloquentModels.php
│ ├── PaginatesEloquentModelsUsingDatabase.php
│ └── UpdatesIndexSettings.php
├── EngineManager.php
├── Engines
│ ├── Algolia3Engine.php
│ ├── Algolia4Engine.php
│ ├── AlgoliaEngine.php
│ ├── CollectionEngine.php
│ ├── DatabaseEngine.php
│ ├── Engine.php
│ ├── MeilisearchEngine.php
│ ├── NullEngine.php
│ └── TypesenseEngine.php
├── Events
│ ├── ModelsFlushed.php
│ └── ModelsImported.php
├── Exceptions
│ ├── NotSupportedException.php
│ └── ScoutException.php
├── Jobs
│ ├── MakeRangeSearchable.php
│ ├── MakeSearchable.php
│ ├── RemoveFromSearch.php
│ └── RemoveableScoutCollection.php
├── ModelObserver.php
├── Scout.php
├── ScoutServiceProvider.php
├── Searchable.php
└── SearchableScope.php
└── testbench.yaml
/LICENSE.md:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) Taylor Otwell
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
13 | all 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
21 | THE SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |

2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 | ## Introduction
11 |
12 | Laravel Scout provides a simple, driver-based solution for adding full-text search to your Eloquent models. Once Scout is installed and configured, it will automatically sync your model changes to your search indexes. Currently, Scout supports:
13 |
14 | - [Algolia](https://www.algolia.com/)
15 | - [Meilisearch](https://github.com/meilisearch/meilisearch)
16 | - [Typesense](https://github.com/typesense/typesense)
17 |
18 | ## Official Documentation
19 |
20 | Documentation for Scout can be found on the [Laravel website](https://laravel.com/docs/master/scout).
21 |
22 | ## Contributing
23 |
24 | Thank you for considering contributing to Scout! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions).
25 |
26 | ## Code of Conduct
27 |
28 | In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct).
29 |
30 | ## Security Vulnerabilities
31 |
32 | Please review [our security policy](https://github.com/laravel/scout/security/policy) on how to report security vulnerabilities.
33 |
34 | ## License
35 |
36 | Laravel Scout is open-sourced software licensed under the [MIT license](LICENSE.md).
37 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "laravel/scout",
3 | "description": "Laravel Scout provides a driver based solution to searching your Eloquent models.",
4 | "keywords": ["algolia", "laravel", "search"],
5 | "license": "MIT",
6 | "support": {
7 | "issues": "https://github.com/laravel/scout/issues",
8 | "source": "https://github.com/laravel/scout"
9 | },
10 | "authors": [
11 | {
12 | "name": "Taylor Otwell",
13 | "email": "taylor@laravel.com"
14 | }
15 | ],
16 | "require": {
17 | "php": "^8.0",
18 | "illuminate/bus": "^9.0|^10.0|^11.0|^12.0",
19 | "illuminate/contracts": "^9.0|^10.0|^11.0|^12.0",
20 | "illuminate/database": "^9.0|^10.0|^11.0|^12.0",
21 | "illuminate/http": "^9.0|^10.0|^11.0|^12.0",
22 | "illuminate/pagination": "^9.0|^10.0|^11.0|^12.0",
23 | "illuminate/queue": "^9.0|^10.0|^11.0|^12.0",
24 | "illuminate/support": "^9.0|^10.0|^11.0|^12.0",
25 | "symfony/console": "^6.0|^7.0"
26 | },
27 | "require-dev": {
28 | "algolia/algoliasearch-client-php": "^3.2|^4.0",
29 | "typesense/typesense-php": "^4.9.3",
30 | "meilisearch/meilisearch-php": "^1.0",
31 | "mockery/mockery": "^1.0",
32 | "orchestra/testbench": "^7.31|^8.11|^9.0|^10.0",
33 | "php-http/guzzle7-adapter": "^1.0",
34 | "phpstan/phpstan": "^1.10",
35 | "phpunit/phpunit": "^9.3|^10.4|^11.5"
36 | },
37 | "conflict": {
38 | "algolia/algoliasearch-client-php": "<3.2.0|>=5.0.0"
39 | },
40 | "autoload": {
41 | "psr-4": {
42 | "Laravel\\Scout\\": "src/"
43 | }
44 | },
45 | "autoload-dev": {
46 | "psr-4": {
47 | "Laravel\\Scout\\Tests\\": "tests/",
48 | "Workbench\\App\\": "workbench/app/",
49 | "Workbench\\Database\\Factories\\": "workbench/database/factories/"
50 | }
51 | },
52 | "extra": {
53 | "branch-alias": {
54 | "dev-master": "10.x-dev"
55 | },
56 | "laravel": {
57 | "providers": [
58 | "Laravel\\Scout\\ScoutServiceProvider"
59 | ]
60 | }
61 | },
62 | "suggest": {
63 | "algolia/algoliasearch-client-php": "Required to use the Algolia engine (^3.2).",
64 | "meilisearch/meilisearch-php": "Required to use the Meilisearch engine (^1.0).",
65 | "typesense/typesense-php": "Required to use the Typesense engine (^4.9)."
66 | },
67 | "config": {
68 | "sort-packages": true,
69 | "allow-plugins": {
70 | "php-http/discovery": true
71 | }
72 | },
73 | "minimum-stability": "dev",
74 | "prefer-stable": true
75 | }
76 |
--------------------------------------------------------------------------------
/config/scout.php:
--------------------------------------------------------------------------------
1 | env('SCOUT_DRIVER', 'collection'),
20 |
21 | /*
22 | |--------------------------------------------------------------------------
23 | | Index Prefix
24 | |--------------------------------------------------------------------------
25 | |
26 | | Here you may specify a prefix that will be applied to all search index
27 | | names used by Scout. This prefix may be useful if you have multiple
28 | | "tenants" or applications sharing the same search infrastructure.
29 | |
30 | */
31 |
32 | 'prefix' => env('SCOUT_PREFIX', ''),
33 |
34 | /*
35 | |--------------------------------------------------------------------------
36 | | Queue Data Syncing
37 | |--------------------------------------------------------------------------
38 | |
39 | | This option allows you to control if the operations that sync your data
40 | | with your search engines are queued. When this is set to "true" then
41 | | all automatic data syncing will get queued for better performance.
42 | |
43 | */
44 |
45 | 'queue' => env('SCOUT_QUEUE', false),
46 |
47 | /*
48 | |--------------------------------------------------------------------------
49 | | Database Transactions
50 | |--------------------------------------------------------------------------
51 | |
52 | | This configuration option determines if your data will only be synced
53 | | with your search indexes after every open database transaction has
54 | | been committed, thus preventing any discarded data from syncing.
55 | |
56 | */
57 |
58 | 'after_commit' => false,
59 |
60 | /*
61 | |--------------------------------------------------------------------------
62 | | Chunk Sizes
63 | |--------------------------------------------------------------------------
64 | |
65 | | These options allow you to control the maximum chunk size when you are
66 | | mass importing data into the search engine. This allows you to fine
67 | | tune each of these chunk sizes based on the power of the servers.
68 | |
69 | */
70 |
71 | 'chunk' => [
72 | 'searchable' => 500,
73 | 'unsearchable' => 500,
74 | ],
75 |
76 | /*
77 | |--------------------------------------------------------------------------
78 | | Soft Deletes
79 | |--------------------------------------------------------------------------
80 | |
81 | | This option allows to control whether to keep soft deleted records in
82 | | the search indexes. Maintaining soft deleted records can be useful
83 | | if your application still needs to search for the records later.
84 | |
85 | */
86 |
87 | 'soft_delete' => false,
88 |
89 | /*
90 | |--------------------------------------------------------------------------
91 | | Identify User
92 | |--------------------------------------------------------------------------
93 | |
94 | | This option allows you to control whether to notify the search engine
95 | | of the user performing the search. This is sometimes useful if the
96 | | engine supports any analytics based on this application's users.
97 | |
98 | | Supported engines: "algolia"
99 | |
100 | */
101 |
102 | 'identify' => env('SCOUT_IDENTIFY', false),
103 |
104 | /*
105 | |--------------------------------------------------------------------------
106 | | Algolia Configuration
107 | |--------------------------------------------------------------------------
108 | |
109 | | Here you may configure your Algolia settings. Algolia is a cloud hosted
110 | | search engine which works great with Scout out of the box. Just plug
111 | | in your application ID and admin API key to get started searching.
112 | |
113 | */
114 |
115 | 'algolia' => [
116 | 'id' => env('ALGOLIA_APP_ID', ''),
117 | 'secret' => env('ALGOLIA_SECRET', ''),
118 | 'index-settings' => [
119 | // 'users' => [
120 | // 'searchableAttributes' => ['id', 'name', 'email'],
121 | // 'attributesForFaceting'=> ['filterOnly(email)'],
122 | // ],
123 | ],
124 | ],
125 |
126 | /*
127 | |--------------------------------------------------------------------------
128 | | Meilisearch Configuration
129 | |--------------------------------------------------------------------------
130 | |
131 | | Here you may configure your Meilisearch settings. Meilisearch is an open
132 | | source search engine with minimal configuration. Below, you can state
133 | | the host and key information for your own Meilisearch installation.
134 | |
135 | | See: https://www.meilisearch.com/docs/learn/configuration/instance_options#all-instance-options
136 | |
137 | */
138 |
139 | 'meilisearch' => [
140 | 'host' => env('MEILISEARCH_HOST', 'http://localhost:7700'),
141 | 'key' => env('MEILISEARCH_KEY'),
142 | 'index-settings' => [
143 | // 'users' => [
144 | // 'filterableAttributes'=> ['id', 'name', 'email'],
145 | // ],
146 | ],
147 | ],
148 |
149 | /*
150 | |--------------------------------------------------------------------------
151 | | Typesense Configuration
152 | |--------------------------------------------------------------------------
153 | |
154 | | Here you may configure your Typesense settings. Typesense is an open
155 | | source search engine using minimal configuration. Below, you will
156 | | state the host, key, and schema configuration for the instance.
157 | |
158 | */
159 |
160 | 'typesense' => [
161 | 'client-settings' => [
162 | 'api_key' => env('TYPESENSE_API_KEY', 'xyz'),
163 | 'nodes' => [
164 | [
165 | 'host' => env('TYPESENSE_HOST', 'localhost'),
166 | 'port' => env('TYPESENSE_PORT', '8108'),
167 | 'path' => env('TYPESENSE_PATH', ''),
168 | 'protocol' => env('TYPESENSE_PROTOCOL', 'http'),
169 | ],
170 | ],
171 | 'nearest_node' => [
172 | 'host' => env('TYPESENSE_HOST', 'localhost'),
173 | 'port' => env('TYPESENSE_PORT', '8108'),
174 | 'path' => env('TYPESENSE_PATH', ''),
175 | 'protocol' => env('TYPESENSE_PROTOCOL', 'http'),
176 | ],
177 | 'connection_timeout_seconds' => env('TYPESENSE_CONNECTION_TIMEOUT_SECONDS', 2),
178 | 'healthcheck_interval_seconds' => env('TYPESENSE_HEALTHCHECK_INTERVAL_SECONDS', 30),
179 | 'num_retries' => env('TYPESENSE_NUM_RETRIES', 3),
180 | 'retry_interval_seconds' => env('TYPESENSE_RETRY_INTERVAL_SECONDS', 1),
181 | ],
182 | // 'max_total_results' => env('TYPESENSE_MAX_TOTAL_RESULTS', 1000),
183 | 'model-settings' => [
184 | // User::class => [
185 | // 'collection-schema' => [
186 | // 'fields' => [
187 | // [
188 | // 'name' => 'id',
189 | // 'type' => 'string',
190 | // ],
191 | // [
192 | // 'name' => 'name',
193 | // 'type' => 'string',
194 | // ],
195 | // [
196 | // 'name' => 'created_at',
197 | // 'type' => 'int64',
198 | // ],
199 | // ],
200 | // 'default_sorting_field' => 'created_at',
201 | // ],
202 | // 'search-parameters' => [
203 | // 'query_by' => 'name'
204 | // ],
205 | // ],
206 | ],
207 | ],
208 |
209 | ];
210 |
--------------------------------------------------------------------------------
/src/Attributes/SearchUsingFullText.php:
--------------------------------------------------------------------------------
1 | columns = Arr::wrap($columns);
33 | $this->options = Arr::wrap($options);
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/src/Attributes/SearchUsingPrefix.php:
--------------------------------------------------------------------------------
1 | columns = Arr::wrap($columns);
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/src/Builder.php:
--------------------------------------------------------------------------------
1 | model = $model;
118 | $this->query = $query;
119 | $this->callback = $callback;
120 |
121 | if ($softDelete) {
122 | $this->wheres['__soft_deleted'] = 0;
123 | }
124 | }
125 |
126 | /**
127 | * Specify a custom index to perform this search on.
128 | *
129 | * @param string $index
130 | * @return $this
131 | */
132 | public function within($index)
133 | {
134 | $this->index = $index;
135 |
136 | return $this;
137 | }
138 |
139 | /**
140 | * Add a constraint to the search query.
141 | *
142 | * @param string $field
143 | * @param mixed $value
144 | * @return $this
145 | */
146 | public function where($field, $value)
147 | {
148 | $this->wheres[$field] = $value;
149 |
150 | return $this;
151 | }
152 |
153 | /**
154 | * Add a "where in" constraint to the search query.
155 | *
156 | * @param string $field
157 | * @param \Illuminate\Contracts\Support\Arrayable|array $values
158 | * @return $this
159 | */
160 | public function whereIn($field, $values)
161 | {
162 | if ($values instanceof Arrayable) {
163 | $values = $values->toArray();
164 | }
165 |
166 | $this->whereIns[$field] = $values;
167 |
168 | return $this;
169 | }
170 |
171 | /**
172 | * Add a "where not in" constraint to the search query.
173 | *
174 | * @param string $field
175 | * @param \Illuminate\Contracts\Support\Arrayable|array $values
176 | * @return $this
177 | */
178 | public function whereNotIn($field, $values)
179 | {
180 | if ($values instanceof Arrayable) {
181 | $values = $values->toArray();
182 | }
183 |
184 | $this->whereNotIns[$field] = $values;
185 |
186 | return $this;
187 | }
188 |
189 | /**
190 | * Include soft deleted records in the results.
191 | *
192 | * @return $this
193 | */
194 | public function withTrashed()
195 | {
196 | unset($this->wheres['__soft_deleted']);
197 |
198 | return $this;
199 | }
200 |
201 | /**
202 | * Include only soft deleted records in the results.
203 | *
204 | * @return $this
205 | */
206 | public function onlyTrashed()
207 | {
208 | return tap($this->withTrashed(), function () {
209 | $this->wheres['__soft_deleted'] = 1;
210 | });
211 | }
212 |
213 | /**
214 | * Set the "limit" for the search query.
215 | *
216 | * @param int $limit
217 | * @return $this
218 | */
219 | public function take($limit)
220 | {
221 | $this->limit = $limit;
222 |
223 | return $this;
224 | }
225 |
226 | /**
227 | * Add an "order" for the search query.
228 | *
229 | * @param string $column
230 | * @param string $direction
231 | * @return $this
232 | */
233 | public function orderBy($column, $direction = 'asc')
234 | {
235 | $this->orders[] = [
236 | 'column' => $column,
237 | 'direction' => strtolower($direction) == 'asc' ? 'asc' : 'desc',
238 | ];
239 |
240 | return $this;
241 | }
242 |
243 | /**
244 | * Add a descending "order by" clause to the search query.
245 | *
246 | * @param string $column
247 | * @return $this
248 | */
249 | public function orderByDesc($column)
250 | {
251 | return $this->orderBy($column, 'desc');
252 | }
253 |
254 | /**
255 | * Add an "order by" clause for a timestamp to the query.
256 | *
257 | * @param string $column
258 | * @return $this
259 | */
260 | public function latest($column = null)
261 | {
262 | if (is_null($column)) {
263 | $column = $this->model->getCreatedAtColumn() ?? 'created_at';
264 | }
265 |
266 | return $this->orderBy($column, 'desc');
267 | }
268 |
269 | /**
270 | * Add an "order by" clause for a timestamp to the query.
271 | *
272 | * @param string $column
273 | * @return $this
274 | */
275 | public function oldest($column = null)
276 | {
277 | if (is_null($column)) {
278 | $column = $this->model->getCreatedAtColumn() ?? 'created_at';
279 | }
280 |
281 | return $this->orderBy($column, 'asc');
282 | }
283 |
284 | /**
285 | * Set extra options for the search query.
286 | *
287 | * @param array $options
288 | * @return $this
289 | */
290 | public function options(array $options)
291 | {
292 | $this->options = $options;
293 |
294 | return $this;
295 | }
296 |
297 | /**
298 | * Set the callback that should have an opportunity to modify the database query.
299 | *
300 | * @param callable $callback
301 | * @return $this
302 | */
303 | public function query($callback)
304 | {
305 | $this->queryCallback = $callback;
306 |
307 | return $this;
308 | }
309 |
310 | /**
311 | * Get the raw results of the search.
312 | *
313 | * @return mixed
314 | */
315 | public function raw()
316 | {
317 | return $this->engine()->search($this);
318 | }
319 |
320 | /**
321 | * Set the callback that should have an opportunity to inspect and modify the raw result returned by the search engine.
322 | *
323 | * @param callable $callback
324 | * @return $this
325 | */
326 | public function withRawResults($callback)
327 | {
328 | $this->afterRawSearchCallback = $callback;
329 |
330 | return $this;
331 | }
332 |
333 | /**
334 | * Get the keys of search results.
335 | *
336 | * @return \Illuminate\Support\Collection
337 | */
338 | public function keys()
339 | {
340 | return $this->engine()->keys($this);
341 | }
342 |
343 | /**
344 | * Get the first result from the search.
345 | *
346 | * @return TModel
347 | */
348 | public function first()
349 | {
350 | return $this->get()->first();
351 | }
352 |
353 | /**
354 | * Get the results of the search.
355 | *
356 | * @return \Illuminate\Database\Eloquent\Collection
357 | */
358 | public function get()
359 | {
360 | return $this->engine()->get($this);
361 | }
362 |
363 | /**
364 | * Get the results of the search as a "lazy collection" instance.
365 | *
366 | * @return \Illuminate\Support\LazyCollection
367 | */
368 | public function cursor()
369 | {
370 | return $this->engine()->cursor($this);
371 | }
372 |
373 | /**
374 | * Paginate the given query into a simple paginator.
375 | *
376 | * @param int $perPage
377 | * @param string $pageName
378 | * @param int|null $page
379 | * @return \Illuminate\Contracts\Pagination\Paginator
380 | */
381 | public function simplePaginate($perPage = null, $pageName = 'page', $page = null)
382 | {
383 | $engine = $this->engine();
384 |
385 | if ($engine instanceof PaginatesEloquentModels) {
386 | return $engine->simplePaginate($this, $perPage, $page)->appends('query', $this->query);
387 | } elseif ($engine instanceof PaginatesEloquentModelsUsingDatabase) {
388 | return $engine->simplePaginateUsingDatabase($this, $perPage, $pageName, $page)->appends('query', $this->query);
389 | }
390 |
391 | $page = $page ?: Paginator::resolveCurrentPage($pageName);
392 |
393 | $perPage = $perPage ?: $this->model->getPerPage();
394 |
395 | $results = $this->model->newCollection($engine->map(
396 | $this,
397 | $this->applyAfterRawSearchCallback($rawResults = $engine->paginate($this, $perPage, $page)),
398 | $this->model
399 | )->all());
400 |
401 | $paginator = Container::getInstance()->makeWith(Paginator::class, [
402 | 'items' => $results,
403 | 'perPage' => $perPage,
404 | 'currentPage' => $page,
405 | 'options' => [
406 | 'path' => Paginator::resolveCurrentPath(),
407 | 'pageName' => $pageName,
408 | ],
409 | ])->hasMorePagesWhen(($perPage * $page) < $engine->getTotalCount($rawResults));
410 |
411 | return $paginator->appends('query', $this->query);
412 | }
413 |
414 | /**
415 | * Paginate the given query into a simple paginator with raw data.
416 | *
417 | * @param int $perPage
418 | * @param string $pageName
419 | * @param int|null $page
420 | * @return \Illuminate\Contracts\Pagination\Paginator
421 | */
422 | public function simplePaginateRaw($perPage = null, $pageName = 'page', $page = null)
423 | {
424 | $engine = $this->engine();
425 |
426 | if ($engine instanceof PaginatesEloquentModels) {
427 | return $engine->simplePaginate($this, $perPage, $page)->appends('query', $this->query);
428 | } elseif ($engine instanceof PaginatesEloquentModelsUsingDatabase) {
429 | return $engine->simplePaginateUsingDatabase($this, $perPage, $pageName, $page)->appends('query', $this->query);
430 | }
431 |
432 | $page = $page ?: Paginator::resolveCurrentPage($pageName);
433 |
434 | $perPage = $perPage ?: $this->model->getPerPage();
435 |
436 | $results = $this->applyAfterRawSearchCallback($engine->paginate($this, $perPage, $page));
437 |
438 | $paginator = Container::getInstance()->makeWith(Paginator::class, [
439 | 'items' => $results,
440 | 'perPage' => $perPage,
441 | 'currentPage' => $page,
442 | 'options' => [
443 | 'path' => Paginator::resolveCurrentPath(),
444 | 'pageName' => $pageName,
445 | ],
446 | ])->hasMorePagesWhen(($perPage * $page) < $engine->getTotalCount($results));
447 |
448 | return $paginator->appends('query', $this->query);
449 | }
450 |
451 | /**
452 | * Paginate the given query into a paginator.
453 | *
454 | * @param int $perPage
455 | * @param string $pageName
456 | * @param int|null $page
457 | * @return \Illuminate\Contracts\Pagination\LengthAwarePaginator
458 | */
459 | public function paginate($perPage = null, $pageName = 'page', $page = null)
460 | {
461 | $engine = $this->engine();
462 |
463 | if ($engine instanceof PaginatesEloquentModels) {
464 | return $engine->paginate($this, $perPage, $page)->appends('query', $this->query);
465 | } elseif ($engine instanceof PaginatesEloquentModelsUsingDatabase) {
466 | return $engine->paginateUsingDatabase($this, $perPage, $pageName, $page)->appends('query', $this->query);
467 | }
468 |
469 | $page = $page ?: Paginator::resolveCurrentPage($pageName);
470 |
471 | $perPage = $perPage ?: $this->model->getPerPage();
472 |
473 | $results = $this->model->newCollection($engine->map(
474 | $this,
475 | $this->applyAfterRawSearchCallback($rawResults = $engine->paginate($this, $perPage, $page)),
476 | $this->model
477 | )->all());
478 |
479 | return Container::getInstance()->makeWith(LengthAwarePaginator::class, [
480 | 'items' => $results,
481 | 'total' => $this->getTotalCount($rawResults),
482 | 'perPage' => $perPage,
483 | 'currentPage' => $page,
484 | 'options' => [
485 | 'path' => Paginator::resolveCurrentPath(),
486 | 'pageName' => $pageName,
487 | ],
488 | ])->appends('query', $this->query);
489 | }
490 |
491 | /**
492 | * Paginate the given query into a paginator with raw data.
493 | *
494 | * @param int $perPage
495 | * @param string $pageName
496 | * @param int|null $page
497 | * @return \Illuminate\Contracts\Pagination\LengthAwarePaginator
498 | */
499 | public function paginateRaw($perPage = null, $pageName = 'page', $page = null)
500 | {
501 | $engine = $this->engine();
502 |
503 | if ($engine instanceof PaginatesEloquentModels) {
504 | return $engine->paginate($this, $perPage, $page)->appends('query', $this->query);
505 | } elseif ($engine instanceof PaginatesEloquentModelsUsingDatabase) {
506 | return $engine->paginateUsingDatabase($this, $perPage, $pageName, $page)->appends('query', $this->query);
507 | }
508 |
509 | $page = $page ?: Paginator::resolveCurrentPage($pageName);
510 |
511 | $perPage = $perPage ?: $this->model->getPerPage();
512 |
513 | $results = $this->applyAfterRawSearchCallback($engine->paginate($this, $perPage, $page));
514 |
515 | return Container::getInstance()->makeWith(LengthAwarePaginator::class, [
516 | 'items' => $results,
517 | 'total' => $this->getTotalCount($results),
518 | 'perPage' => $perPage,
519 | 'currentPage' => $page,
520 | 'options' => [
521 | 'path' => Paginator::resolveCurrentPath(),
522 | 'pageName' => $pageName,
523 | ],
524 | ])->appends('query', $this->query);
525 | }
526 |
527 | /**
528 | * Get the total number of results from the Scout engine, or fallback to query builder.
529 | *
530 | * @param mixed $results
531 | * @return int
532 | */
533 | protected function getTotalCount($results)
534 | {
535 | $engine = $this->engine();
536 |
537 | $totalCount = $engine->getTotalCount($results);
538 |
539 | if (is_null($this->queryCallback)) {
540 | return $totalCount;
541 | }
542 |
543 | $ids = $engine->mapIdsFrom($results, $this->model->getScoutKeyName())->all();
544 |
545 | if (count($ids) < $totalCount) {
546 | $ids = $engine->keys(tap(clone $this, function ($builder) use ($totalCount) {
547 | $builder->take(
548 | is_null($this->limit) ? $totalCount : min($this->limit, $totalCount)
549 | );
550 | }))->all();
551 | }
552 |
553 | return $this->model->queryScoutModelsByIds(
554 | $this, $ids
555 | )->toBase()->getCountForPagination();
556 | }
557 |
558 | /**
559 | * Invoke the "after raw search" callback.
560 | *
561 | * @param mixed $results
562 | * @return mixed
563 | */
564 | public function applyAfterRawSearchCallback($results)
565 | {
566 | if ($this->afterRawSearchCallback) {
567 | $results = call_user_func($this->afterRawSearchCallback, $results) ?: $results;
568 | }
569 |
570 | return $results;
571 | }
572 |
573 | /**
574 | * Get the engine that should handle the query.
575 | *
576 | * @return mixed
577 | */
578 | protected function engine()
579 | {
580 | return $this->model->searchableUsing();
581 | }
582 |
583 | /**
584 | * Get the connection type for the underlying model.
585 | */
586 | public function modelConnectionType(): string
587 | {
588 | return $this->model->getConnection()->getDriverName();
589 | }
590 | }
591 |
--------------------------------------------------------------------------------
/src/Console/DeleteAllIndexesCommand.php:
--------------------------------------------------------------------------------
1 | engine();
36 |
37 | $driver = config('scout.driver');
38 |
39 | if (! method_exists($engine, 'deleteAllIndexes')) {
40 | return $this->error('The ['.$driver.'] engine does not support deleting all indexes.');
41 | }
42 |
43 | try {
44 | $manager->engine()->deleteAllIndexes();
45 |
46 | $this->info('All indexes deleted successfully.');
47 | } catch (Exception $exception) {
48 | $this->error($exception->getMessage());
49 | }
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/src/Console/DeleteIndexCommand.php:
--------------------------------------------------------------------------------
1 | engine()->deleteIndex($name = $this->indexName($this->argument('name')));
38 |
39 | $this->info('Index "'.$name.'" deleted.');
40 | } catch (Exception $exception) {
41 | $this->error($exception->getMessage());
42 | }
43 | }
44 |
45 | /**
46 | * Get the fully-qualified index name for the given index.
47 | *
48 | * @param string $name
49 | * @return string
50 | */
51 | protected function indexName($name)
52 | {
53 | if (class_exists($name)) {
54 | return (new $name)->indexableAs();
55 | }
56 |
57 | $prefix = config('scout.prefix');
58 |
59 | return ! Str::startsWith($name, $prefix) ? $prefix.$name : $name;
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/src/Console/FlushCommand.php:
--------------------------------------------------------------------------------
1 | argument('model');
33 |
34 | $model = new $class;
35 |
36 | $model::removeAllFromSearch();
37 |
38 | $this->info('All ['.$class.'] records have been flushed.');
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/src/Console/ImportCommand.php:
--------------------------------------------------------------------------------
1 | argument('model');
39 |
40 | $model = new $class;
41 |
42 | $events->listen(ModelsImported::class, function ($event) use ($class) {
43 | $key = $event->models->last()->getScoutKey();
44 |
45 | $this->line('Imported ['.$class.'] models up to ID: '.$key);
46 | });
47 |
48 | if ($this->option('fresh')) {
49 | $model::removeAllFromSearch();
50 | }
51 |
52 | $model::makeAllSearchable($this->option('chunk'));
53 |
54 | $events->forget(ModelsImported::class);
55 |
56 | $this->info('All ['.$class.'] records have been imported.');
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/src/Console/IndexCommand.php:
--------------------------------------------------------------------------------
1 | engine();
43 |
44 | try {
45 | $options = [];
46 |
47 | if ($this->option('key')) {
48 | $options = ['primaryKey' => $this->option('key')];
49 | }
50 |
51 | if (class_exists($modelName = $this->argument('name'))) {
52 | $model = new $modelName;
53 | }
54 |
55 | $name = $this->indexName($this->argument('name'));
56 |
57 | $this->createIndex($engine, $name, $options);
58 |
59 | if ($engine instanceof UpdatesIndexSettings) {
60 | $driver = config('scout.driver');
61 |
62 | $class = isset($model) ? get_class($model) : null;
63 |
64 | $settings = config('scout.'.$driver.'.index-settings.'.$name)
65 | ?? config('scout.'.$driver.'.index-settings.'.$class)
66 | ?? [];
67 |
68 | if (isset($model) &&
69 | config('scout.soft_delete', false) &&
70 | in_array(SoftDeletes::class, class_uses_recursive($model))) {
71 | $settings = $engine->configureSoftDeleteFilter($settings);
72 | }
73 |
74 | if ($settings) {
75 | $engine->updateIndexSettings($name, $settings);
76 | }
77 | }
78 |
79 | $this->info('Synchronised index ["'.$name.'"] successfully.');
80 | } catch (Exception $exception) {
81 | $this->error($exception->getMessage());
82 | }
83 | }
84 |
85 | /**
86 | * Create a search index.
87 | *
88 | * @param \Laravel\Scout\Engines\Engine $engine
89 | * @param string $name
90 | * @param array $options
91 | * @return void
92 | */
93 | protected function createIndex(Engine $engine, $name, $options): void
94 | {
95 | try {
96 | $engine->createIndex($name, $options);
97 | } catch (NotSupportedException) {
98 | return;
99 | }
100 | }
101 |
102 | /**
103 | * Get the fully-qualified index name for the given index.
104 | *
105 | * @param string $name
106 | * @return string
107 | */
108 | protected function indexName($name)
109 | {
110 | if (class_exists($name)) {
111 | return (new $name)->indexableAs();
112 | }
113 |
114 | $prefix = config('scout.prefix');
115 |
116 | return ! Str::startsWith($name, $prefix) ? $prefix.$name : $name;
117 | }
118 | }
119 |
--------------------------------------------------------------------------------
/src/Console/QueueImportCommand.php:
--------------------------------------------------------------------------------
1 | argument('model');
39 |
40 | $model = new $class;
41 |
42 | $query = $model::makeAllSearchableQuery();
43 |
44 | $min = $this->option('min') ?? $query->min($model->getScoutKeyName());
45 | $max = $this->option('max') ?? $query->max($model->getScoutKeyName());
46 |
47 | $chunk = max(1, (int) ($this->option('chunk') ?? config('scout.chunk.searchable', 500)));
48 |
49 | if (! $min || ! $max) {
50 | $this->info('No records found for ['.$class.'].');
51 |
52 | return;
53 | }
54 |
55 | if (! is_numeric($min) || ! is_numeric($max)) {
56 | $this->error('The primary key for ['.$class.'] is not numeric.');
57 |
58 | return;
59 | }
60 |
61 | for ($start = $min; $start <= $max; $start += $chunk) {
62 | $end = min($start + $chunk - 1, $max);
63 |
64 | dispatch(new MakeRangeSearchable($class, $start, $end))
65 | ->onQueue($this->option('queue') ?? $model->syncWithSearchUsingQueue())
66 | ->onConnection($model->syncWithSearchUsing());
67 |
68 | $this->line('Queued ['.$class.'] models up to ID: '.$end);
69 | }
70 |
71 | $this->info('All ['.$class.'] records have been queued for importing.');
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/src/Console/SyncIndexSettingsCommand.php:
--------------------------------------------------------------------------------
1 | option('driver') ?: config('scout.driver');
39 |
40 | $engine = $manager->engine($driver);
41 |
42 | if (! $engine instanceof UpdatesIndexSettings) {
43 | return $this->error('The "'.$driver.'" engine does not support updating index settings.');
44 | }
45 |
46 | try {
47 | $indexes = (array) config('scout.'.$driver.'.index-settings', []);
48 |
49 | if (count($indexes)) {
50 | foreach ($indexes as $name => $settings) {
51 | if (! is_array($settings)) {
52 | $name = $settings;
53 |
54 | $settings = [];
55 | }
56 |
57 | if (class_exists($name)) {
58 | $model = new $name;
59 | }
60 |
61 | if (isset($model) &&
62 | config('scout.soft_delete', false) &&
63 | in_array(SoftDeletes::class, class_uses_recursive($model))) {
64 | $settings = $engine->configureSoftDeleteFilter($settings);
65 | }
66 |
67 | $engine->updateIndexSettings($indexName = $this->indexName($name), $settings);
68 |
69 | $this->info('Settings for the ['.$indexName.'] index synced successfully.');
70 | }
71 | } else {
72 | $this->info('No index settings found for the "'.$driver.'" engine.');
73 | }
74 | } catch (Exception $exception) {
75 | $this->error($exception->getMessage());
76 | }
77 | }
78 |
79 | /**
80 | * Get the fully-qualified index name for the given index.
81 | *
82 | * @param string $name
83 | * @return string
84 | */
85 | protected function indexName($name)
86 | {
87 | if (class_exists($name)) {
88 | return (new $name)->indexableAs();
89 | }
90 |
91 | $prefix = config('scout.prefix');
92 |
93 | return ! Str::startsWith($name, $prefix) ? $prefix.$name : $name;
94 | }
95 | }
96 |
--------------------------------------------------------------------------------
/src/Contracts/PaginatesEloquentModels.php:
--------------------------------------------------------------------------------
1 | driver($name);
32 | }
33 |
34 | /**
35 | * Create an Algolia engine instance.
36 | *
37 | * @return \Laravel\Scout\Engines\AlgoliaEngine
38 | */
39 | public function createAlgoliaDriver()
40 | {
41 | $this->ensureAlgoliaClientIsInstalled();
42 |
43 | return version_compare(Algolia::VERSION, '4.0.0', '>=')
44 | ? $this->configureAlgolia4Driver()
45 | : $this->configureAlgolia3Driver();
46 | }
47 |
48 | /**
49 | * Create an Algolia v3 engine instance.
50 | *
51 | * @return \Laravel\Scout\Engines\Algolia3Engine
52 | */
53 | protected function configureAlgolia3Driver()
54 | {
55 | Algolia3UserAgent::addCustomUserAgent('Laravel Scout', Scout::VERSION); // @phpstan-ignore class.notFound
56 |
57 | return Algolia3Engine::make(
58 | config: config('scout.algolia'),
59 | headers: $this->defaultAlgoliaHeaders(),
60 | softDelete: config('scout.soft_delete')
61 | );
62 | }
63 |
64 | /**
65 | * Create an Algolia v4 engine instance.
66 | *
67 | * @return \Laravel\Scout\Engines\Algolia4Engine
68 | */
69 | protected function configureAlgolia4Driver()
70 | {
71 | Algolia4UserAgent::addAlgoliaAgent('Laravel Scout', 'Laravel Scout', Scout::VERSION);
72 |
73 | return Algolia4Engine::make(
74 | config: config('scout.algolia'),
75 | headers: $this->defaultAlgoliaHeaders(),
76 | softDelete: config('scout.soft_delete')
77 | );
78 | }
79 |
80 | /**
81 | * Ensure the Algolia API client is installed.
82 | *
83 | * @return void
84 | *
85 | * @throws \Exception
86 | */
87 | protected function ensureAlgoliaClientIsInstalled()
88 | {
89 | if (class_exists(Algolia::class)) {
90 | return;
91 | }
92 |
93 | throw new Exception('Please install the suggested Algolia client: algolia/algoliasearch-client-php.');
94 | }
95 |
96 | /**
97 | * Set the default Algolia configuration headers.
98 | *
99 | * @return array
100 | */
101 | protected function defaultAlgoliaHeaders()
102 | {
103 | if (! config('scout.identify')) {
104 | return [];
105 | }
106 |
107 | $headers = [];
108 |
109 | if (! config('app.debug') &&
110 | filter_var($ip = request()->ip(), FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)
111 | ) {
112 | $headers['X-Forwarded-For'] = $ip;
113 | }
114 |
115 | if (($user = request()->user()) && method_exists($user, 'getKey')) {
116 | $headers['X-Algolia-UserToken'] = $user->getKey();
117 | }
118 |
119 | return $headers;
120 | }
121 |
122 | /**
123 | * Create a Meilisearch engine instance.
124 | *
125 | * @return \Laravel\Scout\Engines\MeilisearchEngine
126 | */
127 | public function createMeilisearchDriver()
128 | {
129 | $this->ensureMeilisearchClientIsInstalled();
130 |
131 | return new MeilisearchEngine(
132 | $this->container->make(MeilisearchClient::class),
133 | config('scout.soft_delete', false)
134 | );
135 | }
136 |
137 | /**
138 | * Ensure the Meilisearch client is installed.
139 | *
140 | * @return void
141 | *
142 | * @throws \Exception
143 | */
144 | protected function ensureMeilisearchClientIsInstalled()
145 | {
146 | if (class_exists(Meilisearch::class) && version_compare(Meilisearch::VERSION, '1.0.0') >= 0) {
147 | return;
148 | }
149 |
150 | throw new Exception('Please install the suggested Meilisearch client: meilisearch/meilisearch-php.');
151 | }
152 |
153 | /**
154 | * Create a Typesense engine instance.
155 | *
156 | * @return \Laravel\Scout\Engines\TypesenseEngine
157 | *
158 | * @throws \Typesense\Exceptions\ConfigError
159 | */
160 | public function createTypesenseDriver()
161 | {
162 | $config = config('scout.typesense');
163 | $this->ensureTypesenseClientIsInstalled();
164 |
165 | return new TypesenseEngine(new Typesense($config['client-settings']), $config['max_total_results'] ?? 1000);
166 | }
167 |
168 | /**
169 | * Ensure the Typesense client is installed.
170 | *
171 | * @return void
172 | *
173 | * @throws Exception
174 | */
175 | protected function ensureTypesenseClientIsInstalled()
176 | {
177 | if (! class_exists(Typesense::class)) {
178 | throw new Exception('Please install the suggested Typesense client: typesense/typesense-php.');
179 | }
180 | }
181 |
182 | /**
183 | * Create a database engine instance.
184 | *
185 | * @return \Laravel\Scout\Engines\DatabaseEngine
186 | */
187 | public function createDatabaseDriver()
188 | {
189 | return new DatabaseEngine;
190 | }
191 |
192 | /**
193 | * Create a collection engine instance.
194 | *
195 | * @return \Laravel\Scout\Engines\CollectionEngine
196 | */
197 | public function createCollectionDriver()
198 | {
199 | return new CollectionEngine;
200 | }
201 |
202 | /**
203 | * Create a null engine instance.
204 | *
205 | * @return \Laravel\Scout\Engines\NullEngine
206 | */
207 | public function createNullDriver()
208 | {
209 | return new NullEngine;
210 | }
211 |
212 | /**
213 | * Forget all of the resolved engine instances.
214 | *
215 | * @return $this
216 | */
217 | public function forgetEngines()
218 | {
219 | $this->drivers = [];
220 |
221 | return $this;
222 | }
223 |
224 | /**
225 | * Get the default Scout driver name.
226 | *
227 | * @return string
228 | */
229 | public function getDefaultDriver()
230 | {
231 | if (is_null($driver = config('scout.driver'))) {
232 | return 'null';
233 | }
234 |
235 | return $driver;
236 | }
237 | }
238 |
--------------------------------------------------------------------------------
/src/Engines/Algolia3Engine.php:
--------------------------------------------------------------------------------
1 | setDefaultHeaders($headers);
41 |
42 | if (is_int($connectTimeout = Arr::get($config, 'connect_timeout'))) {
43 | $configuration->setConnectTimeout($connectTimeout);
44 | }
45 |
46 | if (is_int($readTimeout = Arr::get($config, 'read_timeout'))) {
47 | $configuration->setReadTimeout($readTimeout);
48 | }
49 |
50 | if (is_int($writeTimeout = Arr::get($config, 'write_timeout'))) {
51 | $configuration->setWriteTimeout($writeTimeout);
52 | }
53 |
54 | if (is_int($batchSize = Arr::get($config, 'batch_size'))) {
55 | $configuration->setBatchSize($batchSize);
56 | }
57 |
58 | return new static(Algolia3SearchClient::createWithConfig($configuration), $softDelete);
59 | }
60 |
61 | /**
62 | * Update the given model in the index.
63 | *
64 | * @param \Illuminate\Database\Eloquent\Collection $models
65 | * @return void
66 | *
67 | * @throws \Algolia\AlgoliaSearch\Exceptions\AlgoliaException
68 | */
69 | public function update($models)
70 | {
71 | if ($models->isEmpty()) {
72 | return;
73 | }
74 |
75 | $index = $this->algolia->initIndex($models->first()->indexableAs());
76 |
77 | if ($this->usesSoftDelete($models->first()) && $this->softDelete) {
78 | $models->each->pushSoftDeleteMetadata();
79 | }
80 |
81 | $objects = $models->map(function ($model) {
82 | if (empty($searchableData = $model->toSearchableArray())) {
83 | return;
84 | }
85 |
86 | return array_merge(
87 | $searchableData,
88 | $model->scoutMetadata(),
89 | ['objectID' => $model->getScoutKey()],
90 | );
91 | })
92 | ->filter()
93 | ->values()
94 | ->all();
95 |
96 | if (! empty($objects)) {
97 | $index->saveObjects($objects);
98 | }
99 | }
100 |
101 | /**
102 | * Remove the given model from the index.
103 | *
104 | * @param \Illuminate\Database\Eloquent\Collection $models
105 | * @return void
106 | */
107 | public function delete($models)
108 | {
109 | if ($models->isEmpty()) {
110 | return;
111 | }
112 |
113 | $index = $this->algolia->initIndex($models->first()->indexableAs());
114 |
115 | $keys = $models instanceof RemoveableScoutCollection
116 | ? $models->pluck($models->first()->getScoutKeyName())
117 | : $models->map->getScoutKey();
118 |
119 | $index->deleteObjects($keys->all());
120 | }
121 |
122 | /**
123 | * Delete a search index.
124 | *
125 | * @param string $name
126 | * @return mixed
127 | */
128 | public function deleteIndex($name)
129 | {
130 | return $this->algolia->initIndex($name)->delete();
131 | }
132 |
133 | /**
134 | * Flush all of the model's records from the engine.
135 | *
136 | * @param \Illuminate\Database\Eloquent\Model $model
137 | * @return void
138 | */
139 | public function flush($model)
140 | {
141 | $index = $this->algolia->initIndex($model->indexableAs());
142 |
143 | $index->clearObjects();
144 | }
145 |
146 | /**
147 | * Perform the given search on the engine.
148 | *
149 | * @param \Laravel\Scout\Builder $builder
150 | * @param array $options
151 | * @return mixed
152 | */
153 | protected function performSearch(Builder $builder, array $options = [])
154 | {
155 | $algolia = $this->algolia->initIndex(
156 | $builder->index ?: $builder->model->searchableAs()
157 | );
158 |
159 | $options = array_merge($builder->options, $options);
160 |
161 | if ($builder->callback) {
162 | return call_user_func(
163 | $builder->callback,
164 | $algolia,
165 | $builder->query,
166 | $options
167 | );
168 | }
169 |
170 | return $algolia->search($builder->query, $options);
171 | }
172 |
173 | /**
174 | * Update the index settings for the given index.
175 | *
176 | * @return void
177 | */
178 | public function updateIndexSettings(string $name, array $settings = [])
179 | {
180 | $this->algolia->initIndex($name)->setSettings($settings);
181 | }
182 | }
183 |
--------------------------------------------------------------------------------
/src/Engines/Algolia4Engine.php:
--------------------------------------------------------------------------------
1 | $config['id'],
40 | 'apiKey' => $config['secret'],
41 | ]), array_filter([
42 | 'batchSize' => transform(Arr::get($config, 'batch_size'), fn ($batchSize) => is_int($batchSize) ? $batchSize : null),
43 | ])))->setDefaultHeaders($headers);
44 |
45 | if (is_int($connectTimeout = Arr::get($config, 'connect_timeout'))) {
46 | $configuration->setConnectTimeout($connectTimeout);
47 | }
48 |
49 | if (is_int($readTimeout = Arr::get($config, 'read_timeout'))) {
50 | $configuration->setReadTimeout($readTimeout);
51 | }
52 |
53 | if (is_int($writeTimeout = Arr::get($config, 'write_timeout'))) {
54 | $configuration->setWriteTimeout($writeTimeout);
55 | }
56 |
57 | return new static(Algolia4SearchClient::createWithConfig($configuration), $softDelete);
58 | }
59 |
60 | /**
61 | * Update the given model in the index.
62 | *
63 | * @param \Illuminate\Database\Eloquent\Collection $models
64 | * @return void
65 | *
66 | * @throws \Algolia\AlgoliaSearch\Exceptions\AlgoliaException
67 | */
68 | public function update($models)
69 | {
70 | if ($models->isEmpty()) {
71 | return;
72 | }
73 |
74 | $index = $models->first()->indexableAs();
75 |
76 | if ($this->usesSoftDelete($models->first()) && $this->softDelete) {
77 | $models->each->pushSoftDeleteMetadata();
78 | }
79 |
80 | $objects = $models->map(function ($model) {
81 | if (empty($searchableData = $model->toSearchableArray())) {
82 | return;
83 | }
84 |
85 | return array_merge(
86 | $searchableData,
87 | $model->scoutMetadata(),
88 | ['objectID' => $model->getScoutKey()],
89 | );
90 | })
91 | ->filter()
92 | ->values()
93 | ->all();
94 |
95 | if (! empty($objects)) {
96 | $this->algolia->saveObjects($index, $objects);
97 | }
98 | }
99 |
100 | /**
101 | * Remove the given model from the index.
102 | *
103 | * @param \Illuminate\Database\Eloquent\Collection $models
104 | * @return void
105 | */
106 | public function delete($models)
107 | {
108 | if ($models->isEmpty()) {
109 | return;
110 | }
111 |
112 | $keys = $models instanceof RemoveableScoutCollection
113 | ? $models->pluck($models->first()->getScoutKeyName())
114 | : $models->map->getScoutKey();
115 |
116 | $this->algolia->deleteObjects($models->first()->indexableAs(), $keys->all());
117 | }
118 |
119 | /**
120 | * Delete a search index.
121 | *
122 | * @param string $name
123 | * @return mixed
124 | */
125 | public function deleteIndex($name)
126 | {
127 | return $this->algolia->deleteIndex($name);
128 | }
129 |
130 | /**
131 | * Flush all of the model's records from the engine.
132 | *
133 | * @param \Illuminate\Database\Eloquent\Model $model
134 | * @return void
135 | */
136 | public function flush($model)
137 | {
138 | $this->algolia->clearObjects($model->indexableAs());
139 | }
140 |
141 | /**
142 | * Perform the given search on the engine.
143 | *
144 | * @param \Laravel\Scout\Builder $builder
145 | * @param array $options
146 | * @return mixed
147 | */
148 | protected function performSearch(Builder $builder, array $options = [])
149 | {
150 | $options = array_merge($builder->options, $options);
151 |
152 | if ($builder->callback) {
153 | return call_user_func(
154 | $builder->callback,
155 | $this->algolia,
156 | $builder->query,
157 | $options
158 | );
159 | }
160 |
161 | $queryParams = array_merge(['query' => $builder->query], $options);
162 |
163 | return $this->algolia->searchSingleIndex(
164 | $builder->index ?: $builder->model->searchableAs(),
165 | $queryParams
166 | );
167 | }
168 |
169 | /**
170 | * Update the index settings for the given index.
171 | *
172 | * @return void
173 | */
174 | public function updateIndexSettings(string $name, array $settings = [])
175 | {
176 | $this->algolia->setSettings($name, $settings);
177 | }
178 | }
179 |
--------------------------------------------------------------------------------
/src/Engines/AlgoliaEngine.php:
--------------------------------------------------------------------------------
1 | algolia = $algolia;
40 | $this->softDelete = $softDelete;
41 | }
42 |
43 | /**
44 | * Perform the given search on the engine.
45 | *
46 | * @param \Laravel\Scout\Builder $builder
47 | * @param array $options
48 | * @return mixed
49 | */
50 | abstract protected function performSearch(Builder $builder, array $options = []);
51 |
52 | /**
53 | * Update the given model in the index.
54 | *
55 | * @param \Illuminate\Database\Eloquent\Collection $models
56 | * @return void
57 | *
58 | * @throws \Algolia\AlgoliaSearch\Exceptions\AlgoliaException
59 | */
60 | abstract public function update($models);
61 |
62 | /**
63 | * Remove the given model from the index.
64 | *
65 | * @param \Illuminate\Database\Eloquent\Collection $models
66 | * @return void
67 | */
68 | abstract public function delete($models);
69 |
70 | /**
71 | * Delete a search index.
72 | *
73 | * @param string $name
74 | * @return mixed
75 | */
76 | abstract public function deleteIndex($name);
77 |
78 | /**
79 | * Flush all of the model's records from the engine.
80 | *
81 | * @param \Illuminate\Database\Eloquent\Model $model
82 | * @return void
83 | */
84 | abstract public function flush($model);
85 |
86 | /**
87 | * Update the index settings for the given index.
88 | *
89 | * @return void
90 | */
91 | abstract public function updateIndexSettings(string $name, array $settings = []);
92 |
93 | /**
94 | * Perform the given search on the engine.
95 | *
96 | * @param \Laravel\Scout\Builder $builder
97 | * @return mixed
98 | */
99 | public function search(Builder $builder)
100 | {
101 | return $this->performSearch($builder, array_filter([
102 | 'numericFilters' => $this->filters($builder),
103 | 'hitsPerPage' => $builder->limit,
104 | ]));
105 | }
106 |
107 | /**
108 | * Perform the given search on the engine.
109 | *
110 | * @param \Laravel\Scout\Builder $builder
111 | * @param int $perPage
112 | * @param int $page
113 | * @return mixed
114 | */
115 | public function paginate(Builder $builder, $perPage, $page)
116 | {
117 | return $this->performSearch($builder, [
118 | 'numericFilters' => $this->filters($builder),
119 | 'hitsPerPage' => $perPage,
120 | 'page' => $page - 1,
121 | ]);
122 | }
123 |
124 | /**
125 | * Get the filter array for the query.
126 | *
127 | * @param \Laravel\Scout\Builder $builder
128 | * @return array
129 | */
130 | protected function filters(Builder $builder)
131 | {
132 | $wheres = collect($builder->wheres)
133 | ->map(fn ($value, $key) => $key.'='.$value)
134 | ->values();
135 |
136 | return $wheres->merge(collect($builder->whereIns)->map(function ($values, $key) {
137 | if (empty($values)) {
138 | return '0=1';
139 | }
140 |
141 | return collect($values)
142 | ->map(fn ($value) => $key.'='.$value)
143 | ->all();
144 | })->values())->values()->all();
145 | }
146 |
147 | /**
148 | * Pluck and return the primary keys of the given results.
149 | *
150 | * @param mixed $results
151 | * @return \Illuminate\Support\Collection
152 | */
153 | public function mapIds($results)
154 | {
155 | return collect($results['hits'])->pluck('objectID')->values();
156 | }
157 |
158 | /**
159 | * Map the given results to instances of the given model.
160 | *
161 | * @param \Laravel\Scout\Builder $builder
162 | * @param mixed $results
163 | * @param \Illuminate\Database\Eloquent\Model $model
164 | * @return \Illuminate\Database\Eloquent\Collection
165 | */
166 | public function map(Builder $builder, $results, $model)
167 | {
168 | if (count($results['hits']) === 0) {
169 | return $model->newCollection();
170 | }
171 |
172 | $objectIds = collect($results['hits'])->pluck('objectID')->values()->all();
173 |
174 | $objectIdPositions = array_flip($objectIds);
175 |
176 | return $model->getScoutModelsByIds($builder, $objectIds)
177 | ->filter(fn ($model) => in_array($model->getScoutKey(), $objectIds))
178 | ->map(function ($model) use ($results, $objectIdPositions) {
179 | $result = $results['hits'][$objectIdPositions[$model->getScoutKey()]] ?? [];
180 |
181 | foreach ($result as $key => $value) {
182 | if (substr($key, 0, 1) === '_') {
183 | $model->withScoutMetadata($key, $value);
184 | }
185 | }
186 |
187 | return $model;
188 | })
189 | ->sortBy(fn ($model) => $objectIdPositions[$model->getScoutKey()])
190 | ->values();
191 | }
192 |
193 | /**
194 | * Map the given results to instances of the given model via a lazy collection.
195 | *
196 | * @param \Laravel\Scout\Builder $builder
197 | * @param mixed $results
198 | * @param \Illuminate\Database\Eloquent\Model $model
199 | * @return \Illuminate\Support\LazyCollection
200 | */
201 | public function lazyMap(Builder $builder, $results, $model)
202 | {
203 | if (count($results['hits']) === 0) {
204 | return LazyCollection::make($model->newCollection());
205 | }
206 |
207 | $objectIds = collect($results['hits'])->pluck('objectID')->values()->all();
208 | $objectIdPositions = array_flip($objectIds);
209 |
210 | return $model->queryScoutModelsByIds($builder, $objectIds)
211 | ->cursor()
212 | ->filter(fn ($model) => in_array($model->getScoutKey(), $objectIds))
213 | ->map(function ($model) use ($results, $objectIdPositions) {
214 | $result = $results['hits'][$objectIdPositions[$model->getScoutKey()]] ?? [];
215 |
216 | foreach ($result as $key => $value) {
217 | if (substr($key, 0, 1) === '_') {
218 | $model->withScoutMetadata($key, $value);
219 | }
220 | }
221 |
222 | return $model;
223 | })
224 | ->sortBy(fn ($model) => $objectIdPositions[$model->getScoutKey()])
225 | ->values();
226 | }
227 |
228 | /**
229 | * Get the total count from a raw result returned by the engine.
230 | *
231 | * @param mixed $results
232 | * @return int
233 | */
234 | public function getTotalCount($results)
235 | {
236 | return $results['nbHits'];
237 | }
238 |
239 | /**
240 | * Create a search index.
241 | *
242 | * @param string $name
243 | * @param array $options
244 | * @return mixed
245 | *
246 | * @throws NotSupportedException
247 | */
248 | public function createIndex($name, array $options = [])
249 | {
250 | throw new NotSupportedException('Algolia indexes are created automatically upon adding objects.');
251 | }
252 |
253 | /**
254 | * Configure the soft delete filter within the given settings.
255 | *
256 | * @return array
257 | */
258 | public function configureSoftDeleteFilter(array $settings = [])
259 | {
260 | $settings['attributesForFaceting'][] = 'filterOnly(__soft_deleted)';
261 |
262 | return $settings;
263 | }
264 |
265 | /**
266 | * Determine if the given model uses soft deletes.
267 | *
268 | * @param \Illuminate\Database\Eloquent\Model $model
269 | * @return bool
270 | */
271 | protected function usesSoftDelete($model)
272 | {
273 | return in_array(SoftDeletes::class, class_uses_recursive($model));
274 | }
275 |
276 | /**
277 | * Dynamically call the Algolia client instance.
278 | *
279 | * @param string $method
280 | * @param array $parameters
281 | * @return mixed
282 | */
283 | public function __call($method, $parameters)
284 | {
285 | return $this->algolia->$method(...$parameters);
286 | }
287 | }
288 |
--------------------------------------------------------------------------------
/src/Engines/CollectionEngine.php:
--------------------------------------------------------------------------------
1 | searchModels($builder)->take($builder->limit);
54 |
55 | return [
56 | 'results' => $models->all(),
57 | 'total' => count($models),
58 | ];
59 | }
60 |
61 | /**
62 | * Perform the given search on the engine.
63 | *
64 | * @param \Laravel\Scout\Builder $builder
65 | * @param int $perPage
66 | * @param int $page
67 | * @return mixed
68 | */
69 | public function paginate(Builder $builder, $perPage, $page)
70 | {
71 | $models = $this->searchModels($builder);
72 |
73 | return [
74 | 'results' => $models->forPage($page, $perPage)->all(),
75 | 'total' => count($models),
76 | ];
77 | }
78 |
79 | /**
80 | * Get the Eloquent models for the given builder.
81 | *
82 | * @param \Laravel\Scout\Builder $builder
83 | * @return \Illuminate\Database\Eloquent\Collection
84 | */
85 | protected function searchModels(Builder $builder)
86 | {
87 | $query = $builder->model->query()
88 | ->when(! is_null($builder->callback), function ($query) use ($builder) {
89 | call_user_func($builder->callback, $query, $builder, $builder->query);
90 | })
91 | ->when(! $builder->callback && count($builder->wheres) > 0, function ($query) use ($builder) {
92 | foreach ($builder->wheres as $key => $value) {
93 | if ($key !== '__soft_deleted') {
94 | $query->where($key, $value);
95 | }
96 | }
97 | })
98 | ->when(! $builder->callback && count($builder->whereIns) > 0, function ($query) use ($builder) {
99 | foreach ($builder->whereIns as $key => $values) {
100 | $query->whereIn($key, $values);
101 | }
102 | })
103 | ->when(! $builder->callback && count($builder->whereNotIns) > 0, function ($query) use ($builder) {
104 | foreach ($builder->whereNotIns as $key => $values) {
105 | $query->whereNotIn($key, $values);
106 | }
107 | })
108 | ->when($builder->orders, function ($query) use ($builder) {
109 | foreach ($builder->orders as $order) {
110 | $query->orderBy($order['column'], $order['direction']);
111 | }
112 | }, function ($query) use ($builder) {
113 | $query->orderBy($builder->model->qualifyColumn($builder->model->getScoutKeyName()), 'desc');
114 | });
115 |
116 | $models = $this->ensureSoftDeletesAreHandled($builder, $query)
117 | ->get()
118 | ->values();
119 |
120 | if (count($models) === 0) {
121 | return $models;
122 | }
123 |
124 | return $models->first()->makeSearchableUsing($models)->filter(function ($model) use ($builder) {
125 | if (! $model->shouldBeSearchable()) {
126 | return false;
127 | }
128 |
129 | if (! $builder->query) {
130 | return true;
131 | }
132 |
133 | $searchables = $model->toSearchableArray();
134 |
135 | foreach ($searchables as $value) {
136 | if (! is_scalar($value)) {
137 | $value = json_encode($value);
138 | }
139 |
140 | if (Str::contains(Str::lower($value), Str::lower($builder->query))) {
141 | return true;
142 | }
143 | }
144 |
145 | return false;
146 | })->values();
147 | }
148 |
149 | /**
150 | * Ensure that soft delete handling is properly applied to the query.
151 | *
152 | * @param \Laravel\Scout\Builder $builder
153 | * @param \Illuminate\Database\Query\Builder $query
154 | * @return \Illuminate\Database\Query\Builder
155 | */
156 | protected function ensureSoftDeletesAreHandled($builder, $query)
157 | {
158 | if (Arr::get($builder->wheres, '__soft_deleted') === 0) {
159 | return $query->withoutTrashed();
160 | } elseif (Arr::get($builder->wheres, '__soft_deleted') === 1) {
161 | return $query->onlyTrashed();
162 | } elseif (in_array(SoftDeletes::class, class_uses_recursive(get_class($builder->model))) &&
163 | config('scout.soft_delete', false)) {
164 | return $query->withTrashed();
165 | }
166 |
167 | return $query;
168 | }
169 |
170 | /**
171 | * Pluck and return the primary keys of the given results.
172 | *
173 | * @param mixed $results
174 | * @return \Illuminate\Support\Collection
175 | */
176 | public function mapIds($results)
177 | {
178 | $results = array_values($results['results']);
179 |
180 | return count($results) > 0
181 | ? collect($results)->pluck($results[0]->getScoutKeyName())
182 | : collect();
183 | }
184 |
185 | /**
186 | * Map the given results to instances of the given model.
187 | *
188 | * @param \Laravel\Scout\Builder $builder
189 | * @param mixed $results
190 | * @param \Illuminate\Database\Eloquent\Model $model
191 | * @return \Illuminate\Database\Eloquent\Collection
192 | */
193 | public function map(Builder $builder, $results, $model)
194 | {
195 | $results = $results['results'];
196 |
197 | if (count($results) === 0) {
198 | return $model->newCollection();
199 | }
200 |
201 | $objectIds = collect($results)
202 | ->pluck($model->getScoutKeyName())
203 | ->values()
204 | ->all();
205 |
206 | $objectIdPositions = array_flip($objectIds);
207 |
208 | return $model->getScoutModelsByIds($builder, $objectIds)
209 | ->filter(fn ($model) => in_array($model->getScoutKey(), $objectIds))
210 | ->sortBy(fn ($model) => $objectIdPositions[$model->getScoutKey()])
211 | ->values();
212 | }
213 |
214 | /**
215 | * Map the given results to instances of the given model via a lazy collection.
216 | *
217 | * @param \Laravel\Scout\Builder $builder
218 | * @param mixed $results
219 | * @param \Illuminate\Database\Eloquent\Model $model
220 | * @return \Illuminate\Support\LazyCollection
221 | */
222 | public function lazyMap(Builder $builder, $results, $model)
223 | {
224 | $results = $results['results'];
225 |
226 | if (count($results) === 0) {
227 | return LazyCollection::empty();
228 | }
229 |
230 | $objectIds = collect($results)
231 | ->pluck($model->getScoutKeyName())
232 | ->values()->all();
233 |
234 | $objectIdPositions = array_flip($objectIds);
235 |
236 | return $model->queryScoutModelsByIds($builder, $objectIds)
237 | ->cursor()
238 | ->filter(fn ($model) => in_array($model->getScoutKey(), $objectIds))
239 | ->sortBy(fn ($model) => $objectIdPositions[$model->getScoutKey()])
240 | ->values();
241 | }
242 |
243 | /**
244 | * Get the total count from a raw result returned by the engine.
245 | *
246 | * @param mixed $results
247 | * @return int
248 | */
249 | public function getTotalCount($results)
250 | {
251 | return $results['total'];
252 | }
253 |
254 | /**
255 | * Flush all of the model's records from the engine.
256 | *
257 | * @param \Illuminate\Database\Eloquent\Model $model
258 | * @return void
259 | */
260 | public function flush($model)
261 | {
262 | //
263 | }
264 |
265 | /**
266 | * Create a search index.
267 | *
268 | * @param string $name
269 | * @param array $options
270 | * @return mixed
271 | *
272 | * @throws \Exception
273 | */
274 | public function createIndex($name, array $options = [])
275 | {
276 | //
277 | }
278 |
279 | /**
280 | * Delete a search index.
281 | *
282 | * @param string $name
283 | * @return mixed
284 | */
285 | public function deleteIndex($name)
286 | {
287 | //
288 | }
289 | }
290 |
--------------------------------------------------------------------------------
/src/Engines/DatabaseEngine.php:
--------------------------------------------------------------------------------
1 | searchModels($builder);
57 |
58 | return [
59 | 'results' => $models,
60 | 'total' => $models->count(),
61 | ];
62 | }
63 |
64 | /**
65 | * Get the Eloquent models for the given builder.
66 | *
67 | * @param \Laravel\Scout\Builder $builder
68 | * @param int|null $page
69 | * @param int|null $perPage
70 | * @return \Illuminate\Database\Eloquent\Collection
71 | */
72 | protected function searchModels(Builder $builder, $page = null, $perPage = null)
73 | {
74 | return $this->buildSearchQuery($builder)
75 | ->when(! is_null($page) && ! is_null($perPage), function ($query) use ($page, $perPage) {
76 | $query->forPage($page, $perPage);
77 | })
78 | ->when($builder->orders, function ($query) use ($builder) {
79 | foreach ($builder->orders as $order) {
80 | $query->orderBy($order['column'], $order['direction']);
81 | }
82 | })
83 | ->when(! $this->getFullTextColumns($builder), function ($query) use ($builder) {
84 | $query->orderBy($builder->model->getTable().'.'.$builder->model->getScoutKeyName(), 'desc');
85 | })
86 | ->when($this->shouldOrderByRelevance($builder), function ($query) use ($builder) {
87 | $this->orderByRelevance($builder, $query);
88 | })
89 | ->get();
90 | }
91 |
92 | /**
93 | * Paginate the given search on the engine.
94 | *
95 | * @param \Laravel\Scout\Builder $builder
96 | * @param int $perPage
97 | * @param int $page
98 | * @return \Illuminate\Contracts\Pagination\LengthAwarePaginator
99 | */
100 | public function paginate(Builder $builder, $perPage, $page)
101 | {
102 | return $this->paginateUsingDatabase($builder, $perPage, 'page', $page);
103 | }
104 |
105 | /**
106 | * Paginate the given search on the engine.
107 | *
108 | * @param \Laravel\Scout\Builder $builder
109 | * @param int $perPage
110 | * @param string $pageName
111 | * @param int $page
112 | * @return \Illuminate\Contracts\Pagination\LengthAwarePaginator
113 | */
114 | public function paginateUsingDatabase(Builder $builder, $perPage, $pageName, $page)
115 | {
116 | return $this->buildSearchQuery($builder)
117 | ->when($builder->orders, function ($query) use ($builder) {
118 | foreach ($builder->orders as $order) {
119 | $query->orderBy($order['column'], $order['direction']);
120 | }
121 | })
122 | ->when(! $this->getFullTextColumns($builder), function ($query) use ($builder) {
123 | $query->orderBy($builder->model->getTable().'.'.$builder->model->getScoutKeyName(), 'desc');
124 | })
125 | ->when($this->shouldOrderByRelevance($builder), function ($query) use ($builder) {
126 | $this->orderByRelevance($builder, $query);
127 | })
128 | ->paginate($perPage, ['*'], $pageName, $page);
129 | }
130 |
131 | /**
132 | * Paginate the given search on the engine using simple pagination.
133 | *
134 | * @param \Laravel\Scout\Builder $builder
135 | * @param int $perPage
136 | * @param int $page
137 | * @return \Illuminate\Contracts\Pagination\Paginator
138 | */
139 | public function simplePaginate(Builder $builder, $perPage, $page)
140 | {
141 | return $this->simplePaginateUsingDatabase($builder, $perPage, 'page', $page);
142 | }
143 |
144 | /**
145 | * Paginate the given query into a simple paginator.
146 | *
147 | * @param int $perPage
148 | * @param string $pageName
149 | * @param int|null $page
150 | * @return \Illuminate\Contracts\Pagination\Paginator
151 | */
152 | public function simplePaginateUsingDatabase(Builder $builder, $perPage, $pageName, $page)
153 | {
154 | return $this->buildSearchQuery($builder)
155 | ->when($builder->orders, function ($query) use ($builder) {
156 | foreach ($builder->orders as $order) {
157 | $query->orderBy($order['column'], $order['direction']);
158 | }
159 | })
160 | ->when(! $this->getFullTextColumns($builder), function ($query) use ($builder) {
161 | $query->orderBy($builder->model->getTable().'.'.$builder->model->getScoutKeyName(), 'desc');
162 | })
163 | ->when($this->shouldOrderByRelevance($builder), function ($query) use ($builder) {
164 | $this->orderByRelevance($builder, $query);
165 | })
166 | ->simplePaginate($perPage, ['*'], $pageName, $page);
167 | }
168 |
169 | /**
170 | * Initialize / build the search query for the given Scout builder.
171 | *
172 | * @param \Laravel\Scout\Builder $builder
173 | * @return \Illuminate\Database\Eloquent\Builder
174 | */
175 | protected function buildSearchQuery(Builder $builder)
176 | {
177 | $query = $this->initializeSearchQuery(
178 | $builder,
179 | array_keys($builder->model->toSearchableArray()),
180 | $this->getPrefixColumns($builder),
181 | $this->getFullTextColumns($builder)
182 | );
183 |
184 | return $this->constrainForSoftDeletes(
185 | $builder, $this->addAdditionalConstraints($builder, $query->take($builder->limit))
186 | );
187 | }
188 |
189 | /**
190 | * Build the initial text search database query for all relevant columns.
191 | *
192 | * @param \Laravel\Scout\Builder $builder
193 | * @param array $columns
194 | * @param array $prefixColumns
195 | * @param array $fullTextColumns
196 | * @return \Illuminate\Database\Eloquent\Builder
197 | */
198 | protected function initializeSearchQuery(Builder $builder, array $columns, array $prefixColumns = [], array $fullTextColumns = [])
199 | {
200 | $query = method_exists($builder->model, 'newScoutQuery')
201 | ? $builder->model->newScoutQuery($builder)
202 | : $builder->model->newQuery();
203 |
204 | if (blank($builder->query)) {
205 | return $query;
206 | }
207 |
208 | [$connectionType] = [
209 | $builder->modelConnectionType(),
210 | ];
211 |
212 | return $query->where(function ($query) use ($connectionType, $builder, $columns, $prefixColumns, $fullTextColumns) {
213 | $canSearchPrimaryKey = ctype_digit($builder->query) &&
214 | in_array($builder->model->getKeyType(), ['int', 'integer']) &&
215 | ($connectionType != 'pgsql' || $builder->query <= PHP_INT_MAX) &&
216 | in_array($builder->model->getScoutKeyName(), $columns);
217 |
218 | if ($canSearchPrimaryKey) {
219 | $query->orWhere($builder->model->getQualifiedKeyName(), $builder->query);
220 | }
221 |
222 | $likeOperator = $connectionType == 'pgsql' ? 'ilike' : 'like';
223 |
224 | foreach ($columns as $column) {
225 | if (in_array($column, $fullTextColumns)) {
226 | continue;
227 | } else {
228 | if ($canSearchPrimaryKey && $column === $builder->model->getScoutKeyName()) {
229 | continue;
230 | }
231 |
232 | $query->orWhere(
233 | $builder->model->qualifyColumn($column),
234 | $likeOperator,
235 | in_array($column, $prefixColumns) ? $builder->query.'%' : '%'.$builder->query.'%',
236 | );
237 | }
238 | }
239 |
240 | if (count($fullTextColumns) > 0) {
241 | $query->orWhereFullText(
242 | array_map(fn ($column) => $builder->model->qualifyColumn($column), $fullTextColumns),
243 | $builder->query,
244 | $this->getFullTextOptions($builder)
245 | );
246 | }
247 | });
248 | }
249 |
250 | /**
251 | * Determine if the query should be ordered by relevance.
252 | */
253 | protected function shouldOrderByRelevance(Builder $builder): bool
254 | {
255 | // MySQL orders by relevance by default, so we will only order by relevance on
256 | // Postgres with no developer-defined orders. If there is developer defined
257 | // order by clauses we will let those take precedence over the relevance.
258 | return $builder->modelConnectionType() === 'pgsql' &&
259 | count($this->getFullTextColumns($builder)) > 0 &&
260 | empty($builder->orders);
261 | }
262 |
263 | /**
264 | * Add an "order by" clause that orders by relevance (Postgres only).
265 | *
266 | * @param \Laravel\Scout\Builder $builder
267 | * @param \Illuminate\Database\Eloquent\Builder $query
268 | * @return \Illuminate\Database\Eloquent\Builder
269 | */
270 | protected function orderByRelevance(Builder $builder, $query)
271 | {
272 | $fullTextColumns = $this->getFullTextColumns($builder);
273 |
274 | $language = $this->getFullTextOptions($builder)['language'] ?? 'english';
275 |
276 | $vectors = collect($fullTextColumns)->map(function ($column) use ($builder, $language) {
277 | return sprintf("to_tsvector('%s', %s)", $language, $builder->model->qualifyColumn($column));
278 | })->implode(' || ');
279 |
280 | return $query->orderByRaw(
281 | sprintf(
282 | 'ts_rank('.$vectors.', %s(?)) desc',
283 | match ($this->getFullTextOptions($builder)['mode'] ?? 'plainto_tsquery') {
284 | 'phrase' => 'phraseto_tsquery',
285 | 'websearch' => 'websearch_to_tsquery',
286 | default => 'plainto_tsquery',
287 | },
288 | ),
289 | [$builder->query]
290 | );
291 | }
292 |
293 | /**
294 | * Add additional, developer defined constraints to the search query.
295 | *
296 | * @param \Laravel\Scout\Builder $builder
297 | * @param \Illuminate\Database\Eloquent\Builder $query
298 | * @return \Illuminate\Database\Eloquent\Builder
299 | */
300 | protected function addAdditionalConstraints(Builder $builder, $query)
301 | {
302 | return $query->when(! is_null($builder->callback), function ($query) use ($builder) {
303 | call_user_func($builder->callback, $query, $builder, $builder->query);
304 | })->when(! $builder->callback && count($builder->wheres) > 0, function ($query) use ($builder) {
305 | foreach ($builder->wheres as $key => $value) {
306 | if ($key !== '__soft_deleted') {
307 | $query->where($key, '=', $value);
308 | }
309 | }
310 | })->when(! $builder->callback && count($builder->whereIns) > 0, function ($query) use ($builder) {
311 | foreach ($builder->whereIns as $key => $values) {
312 | $query->whereIn($key, $values);
313 | }
314 | })->when(! $builder->callback && count($builder->whereNotIns) > 0, function ($query) use ($builder) {
315 | foreach ($builder->whereNotIns as $key => $values) {
316 | $query->whereNotIn($key, $values);
317 | }
318 | })->when(! is_null($builder->queryCallback), function ($query) use ($builder) {
319 | call_user_func($builder->queryCallback, $query);
320 | });
321 | }
322 |
323 | /**
324 | * Ensure that soft delete constraints are properly applied to the query.
325 | *
326 | * @param \Laravel\Scout\Builder $builder
327 | * @param \Illuminate\Database\Eloquent\Builder $query
328 | * @return \Illuminate\Database\Eloquent\Builder
329 | */
330 | protected function constrainForSoftDeletes($builder, $query)
331 | {
332 | if (Arr::get($builder->wheres, '__soft_deleted') === 0) {
333 | return $query->withoutTrashed();
334 | } elseif (Arr::get($builder->wheres, '__soft_deleted') === 1) {
335 | return $query->onlyTrashed();
336 | } elseif (in_array(SoftDeletes::class, class_uses_recursive(get_class($builder->model))) &&
337 | config('scout.soft_delete', false)) {
338 | return $query->withTrashed();
339 | }
340 |
341 | return $query;
342 | }
343 |
344 | /**
345 | * Get the full-text columns for the query.
346 | *
347 | * @param \Laravel\Scout\Builder $builder
348 | * @return array
349 | */
350 | protected function getFullTextColumns(Builder $builder)
351 | {
352 | return $this->getAttributeColumns($builder, SearchUsingFullText::class);
353 | }
354 |
355 | /**
356 | * Get the prefix search columns for the query.
357 | *
358 | * @param \Laravel\Scout\Builder $builder
359 | * @return array
360 | */
361 | protected function getPrefixColumns(Builder $builder)
362 | {
363 | return $this->getAttributeColumns($builder, SearchUsingPrefix::class);
364 | }
365 |
366 | /**
367 | * Get the columns marked with a given attribute.
368 | *
369 | * @param \Laravel\Scout\Builder $builder
370 | * @param string $attributeClass
371 | * @return array
372 | */
373 | protected function getAttributeColumns(Builder $builder, $attributeClass)
374 | {
375 | $columns = [];
376 |
377 | foreach ((new ReflectionMethod($builder->model, 'toSearchableArray'))->getAttributes() as $attribute) {
378 | if ($attribute->getName() !== $attributeClass) {
379 | continue;
380 | }
381 |
382 | $columns = array_merge($columns, Arr::wrap($attribute->getArguments()[0]));
383 | }
384 |
385 | return $columns;
386 | }
387 |
388 | /**
389 | * Get the full-text search options for the query.
390 | *
391 | * @param \Laravel\Scout\Builder $builder
392 | * @return array
393 | */
394 | protected function getFullTextOptions(Builder $builder)
395 | {
396 | $options = [];
397 |
398 | foreach ((new ReflectionMethod($builder->model, 'toSearchableArray'))->getAttributes(SearchUsingFullText::class) as $attribute) {
399 | $arguments = $attribute->getArguments()[1] ?? [];
400 |
401 | $options = array_merge($options, Arr::wrap($arguments));
402 | }
403 |
404 | return $options;
405 | }
406 |
407 | /**
408 | * Pluck and return the primary keys of the given results.
409 | *
410 | * @param mixed $results
411 | * @return \Illuminate\Support\Collection
412 | */
413 | public function mapIds($results)
414 | {
415 | $results = $results['results'];
416 |
417 | return count($results) > 0
418 | ? collect($results->modelKeys())
419 | : collect();
420 | }
421 |
422 | /**
423 | * Map the given results to instances of the given model.
424 | *
425 | * @param \Laravel\Scout\Builder $builder
426 | * @param mixed $results
427 | * @param \Illuminate\Database\Eloquent\Model $model
428 | * @return \Illuminate\Database\Eloquent\Collection
429 | */
430 | public function map(Builder $builder, $results, $model)
431 | {
432 | return $results['results'];
433 | }
434 |
435 | /**
436 | * Map the given results to instances of the given model via a lazy collection.
437 | *
438 | * @param \Laravel\Scout\Builder $builder
439 | * @param mixed $results
440 | * @param \Illuminate\Database\Eloquent\Model $model
441 | * @return \Illuminate\Support\LazyCollection
442 | */
443 | public function lazyMap(Builder $builder, $results, $model)
444 | {
445 | return new LazyCollection($results['results']->all());
446 | }
447 |
448 | /**
449 | * Get the total count from a raw result returned by the engine.
450 | *
451 | * @param mixed $results
452 | * @return int
453 | */
454 | public function getTotalCount($results)
455 | {
456 | return $results['total'];
457 | }
458 |
459 | /**
460 | * Flush all of the model's records from the engine.
461 | *
462 | * @param \Illuminate\Database\Eloquent\Model $model
463 | * @return void
464 | */
465 | public function flush($model)
466 | {
467 | //
468 | }
469 |
470 | /**
471 | * Create a search index.
472 | *
473 | * @param string $name
474 | * @param array $options
475 | * @return mixed
476 | *
477 | * @throws \Exception
478 | */
479 | public function createIndex($name, array $options = [])
480 | {
481 | //
482 | }
483 |
484 | /**
485 | * Delete a search index.
486 | *
487 | * @param string $name
488 | * @return mixed
489 | */
490 | public function deleteIndex($name)
491 | {
492 | //
493 | }
494 | }
495 |
--------------------------------------------------------------------------------
/src/Engines/Engine.php:
--------------------------------------------------------------------------------
1 | mapIds($results);
114 | }
115 |
116 | /**
117 | * Get the results of the query as a Collection of primary keys.
118 | *
119 | * @param \Laravel\Scout\Builder $builder
120 | * @return \Illuminate\Support\Collection
121 | */
122 | public function keys(Builder $builder)
123 | {
124 | return $this->mapIds($this->search($builder));
125 | }
126 |
127 | /**
128 | * Get the results of the given query mapped onto models.
129 | *
130 | * @param \Laravel\Scout\Builder $builder
131 | * @return \Illuminate\Database\Eloquent\Collection
132 | */
133 | public function get(Builder $builder)
134 | {
135 | return $this->map(
136 | $builder,
137 | $builder->applyAfterRawSearchCallback($this->search($builder)),
138 | $builder->model
139 | );
140 | }
141 |
142 | /**
143 | * Get a lazy collection for the given query mapped onto models.
144 | *
145 | * @param \Laravel\Scout\Builder $builder
146 | * @return \Illuminate\Database\Eloquent\Collection
147 | */
148 | public function cursor(Builder $builder)
149 | {
150 | return $this->lazyMap(
151 | $builder,
152 | $builder->applyAfterRawSearchCallback($this->search($builder)),
153 | $builder->model
154 | );
155 | }
156 | }
157 |
--------------------------------------------------------------------------------
/src/Engines/MeilisearchEngine.php:
--------------------------------------------------------------------------------
1 | meilisearch = $meilisearch;
42 | $this->softDelete = $softDelete;
43 | }
44 |
45 | /**
46 | * Update the given model in the index.
47 | *
48 | * @param \Illuminate\Database\Eloquent\Collection $models
49 | * @return void
50 | *
51 | * @throws \Meilisearch\Exceptions\ApiException
52 | */
53 | public function update($models)
54 | {
55 | if ($models->isEmpty()) {
56 | return;
57 | }
58 |
59 | $index = $this->meilisearch->index($models->first()->indexableAs());
60 |
61 | if ($this->usesSoftDelete($models->first()) && $this->softDelete) {
62 | $models->each->pushSoftDeleteMetadata();
63 | }
64 |
65 | $objects = $models->map(function ($model) {
66 | if (empty($searchableData = $model->toSearchableArray())) {
67 | return;
68 | }
69 |
70 | return array_merge(
71 | $searchableData,
72 | $model->scoutMetadata(),
73 | [$model->getScoutKeyName() => $model->getScoutKey()],
74 | );
75 | })
76 | ->filter()
77 | ->values()
78 | ->all();
79 |
80 | if (! empty($objects)) {
81 | $index->addDocuments($objects, $models->first()->getScoutKeyName());
82 | }
83 | }
84 |
85 | /**
86 | * Remove the given model from the index.
87 | *
88 | * @param \Illuminate\Database\Eloquent\Collection $models
89 | * @return void
90 | */
91 | public function delete($models)
92 | {
93 | if ($models->isEmpty()) {
94 | return;
95 | }
96 |
97 | $index = $this->meilisearch->index($models->first()->indexableAs());
98 |
99 | $keys = $models instanceof RemoveableScoutCollection
100 | ? $models->pluck($models->first()->getScoutKeyName())
101 | : $models->map->getScoutKey();
102 |
103 | $index->deleteDocuments($keys->values()->all());
104 | }
105 |
106 | /**
107 | * Perform the given search on the engine.
108 | *
109 | * @return mixed
110 | */
111 | public function search(Builder $builder)
112 | {
113 | return $this->performSearch($builder, array_filter([
114 | 'filter' => $this->filters($builder),
115 | 'hitsPerPage' => $builder->limit,
116 | 'sort' => $this->buildSortFromOrderByClauses($builder),
117 | ]));
118 | }
119 |
120 | /**
121 | * Perform the given search on the engine.
122 | *
123 | * page/hitsPerPage ensures that the search is exhaustive.
124 | *
125 | * @param int $perPage
126 | * @param int $page
127 | * @return mixed
128 | */
129 | public function paginate(Builder $builder, $perPage, $page)
130 | {
131 | return $this->performSearch($builder, array_filter([
132 | 'filter' => $this->filters($builder),
133 | 'hitsPerPage' => (int) $perPage,
134 | 'page' => $page,
135 | 'sort' => $this->buildSortFromOrderByClauses($builder),
136 | ]));
137 | }
138 |
139 | /**
140 | * Perform the given search on the engine.
141 | *
142 | * @return mixed
143 | */
144 | protected function performSearch(Builder $builder, array $searchParams = [])
145 | {
146 | $meilisearch = $this->meilisearch->index($builder->index ?: $builder->model->searchableAs());
147 |
148 | $searchParams = array_merge($builder->options, $searchParams);
149 |
150 | if (array_key_exists('attributesToRetrieve', $searchParams)) {
151 | $searchParams['attributesToRetrieve'] = array_merge(
152 | [$builder->model->getScoutKeyName()],
153 | $searchParams['attributesToRetrieve'],
154 | );
155 | }
156 |
157 | if ($builder->callback) {
158 | $result = call_user_func(
159 | $builder->callback,
160 | $meilisearch,
161 | $builder->query,
162 | $searchParams
163 | );
164 |
165 | $searchResultClass = class_exists(SearchResult::class)
166 | ? SearchResult::class
167 | : \Meilisearch\Search\SearchResult;
168 |
169 | return $result instanceof $searchResultClass ? $result->getRaw() : $result;
170 | }
171 |
172 | return $meilisearch->rawSearch($builder->query, $searchParams);
173 | }
174 |
175 | /**
176 | * Get the filter array for the query.
177 | *
178 | * @return string
179 | */
180 | protected function filters(Builder $builder)
181 | {
182 | $filters = collect($builder->wheres)
183 | ->map(function ($value, $key) {
184 | if (is_bool($value)) {
185 | return sprintf('%s=%s', $key, $value ? 'true' : 'false');
186 | }
187 |
188 | if (is_null($value)) {
189 | return sprintf('%s %s', $key, 'IS NULL');
190 | }
191 |
192 | return is_numeric($value)
193 | ? sprintf('%s=%s', $key, $value)
194 | : sprintf('%s="%s"', $key, $value);
195 | });
196 |
197 | $whereInOperators = [
198 | 'whereIns' => 'IN',
199 | 'whereNotIns' => 'NOT IN',
200 | ];
201 |
202 | foreach ($whereInOperators as $property => $operator) {
203 | if (property_exists($builder, $property)) {
204 | foreach ($builder->{$property} as $key => $values) {
205 | $filters->push(sprintf('%s %s [%s]', $key, $operator, collect($values)->map(function ($value) {
206 | if (is_bool($value)) {
207 | return sprintf('%s', $value ? 'true' : 'false');
208 | }
209 |
210 | return filter_var($value, FILTER_VALIDATE_INT) !== false
211 | ? sprintf('%s', $value)
212 | : sprintf('"%s"', $value);
213 | })->values()->implode(', ')));
214 | }
215 | }
216 | }
217 |
218 | return $filters->values()->implode(' AND ');
219 | }
220 |
221 | /**
222 | * Get the sort array for the query.
223 | */
224 | protected function buildSortFromOrderByClauses(Builder $builder): array
225 | {
226 | return collect($builder->orders)
227 | ->map(fn (array $order) => $order['column'].':'.$order['direction'])
228 | ->toArray();
229 | }
230 |
231 | /**
232 | * Pluck and return the primary keys of the given results.
233 | *
234 | * This expects the first item of each search item array to be the primary key.
235 | *
236 | * @param mixed $results
237 | * @return \Illuminate\Support\Collection
238 | */
239 | public function mapIds($results)
240 | {
241 | if (count($results['hits']) === 0) {
242 | return collect();
243 | }
244 |
245 | $hits = collect($results['hits']);
246 |
247 | $key = key($hits->first());
248 |
249 | return $hits->pluck($key)->values();
250 | }
251 |
252 | /**
253 | * Pluck the given results with the given primary key name.
254 | *
255 | * @param mixed $results
256 | * @param string $key
257 | * @return \Illuminate\Support\Collection
258 | */
259 | public function mapIdsFrom($results, $key)
260 | {
261 | return count($results['hits']) === 0
262 | ? collect()
263 | : collect($results['hits'])->pluck($key)->values();
264 | }
265 |
266 | /**
267 | * Get the results of the query as a Collection of primary keys.
268 | *
269 | * @return \Illuminate\Support\Collection
270 | */
271 | public function keys(Builder $builder)
272 | {
273 | $scoutKey = $builder->model->getScoutKeyName();
274 |
275 | return $this->mapIdsFrom($this->search($builder), $scoutKey);
276 | }
277 |
278 | /**
279 | * Map the given results to instances of the given model.
280 | *
281 | * @param mixed $results
282 | * @param \Illuminate\Database\Eloquent\Model $model
283 | * @return \Illuminate\Database\Eloquent\Collection
284 | */
285 | public function map(Builder $builder, $results, $model)
286 | {
287 | if (is_null($results) || count($results['hits']) === 0) {
288 | return $model->newCollection();
289 | }
290 |
291 | $objectIds = collect($results['hits'])->pluck($model->getScoutKeyName())->values()->all();
292 |
293 | $objectIdPositions = array_flip($objectIds);
294 |
295 | return $model->getScoutModelsByIds($builder, $objectIds)
296 | ->filter(fn ($model) => in_array($model->getScoutKey(), $objectIds))
297 | ->map(function ($model) use ($results, $objectIdPositions) {
298 | $result = $results['hits'][$objectIdPositions[$model->getScoutKey()]] ?? [];
299 |
300 | foreach ($result as $key => $value) {
301 | if (substr($key, 0, 1) === '_') {
302 | $model->withScoutMetadata($key, $value);
303 | }
304 | }
305 |
306 | return $model;
307 | })
308 | ->sortBy(fn ($model) => $objectIdPositions[$model->getScoutKey()])
309 | ->values();
310 | }
311 |
312 | /**
313 | * Map the given results to instances of the given model via a lazy collection.
314 | *
315 | * @param mixed $results
316 | * @param \Illuminate\Database\Eloquent\Model $model
317 | * @return \Illuminate\Support\LazyCollection
318 | */
319 | public function lazyMap(Builder $builder, $results, $model)
320 | {
321 | if (count($results['hits']) === 0) {
322 | return LazyCollection::make($model->newCollection());
323 | }
324 |
325 | $objectIds = collect($results['hits'])->pluck($model->getScoutKeyName())->values()->all();
326 | $objectIdPositions = array_flip($objectIds);
327 |
328 | return $model->queryScoutModelsByIds($builder, $objectIds)
329 | ->cursor()
330 | ->filter(fn ($model) => in_array($model->getScoutKey(), $objectIds))
331 | ->map(function ($model) use ($results, $objectIdPositions) {
332 | $result = $results['hits'][$objectIdPositions[$model->getScoutKey()]] ?? [];
333 |
334 | foreach ($result as $key => $value) {
335 | if (substr($key, 0, 1) === '_') {
336 | $model->withScoutMetadata($key, $value);
337 | }
338 | }
339 |
340 | return $model;
341 | })
342 | ->sortBy(fn ($model) => $objectIdPositions[$model->getScoutKey()])
343 | ->values();
344 | }
345 |
346 | /**
347 | * Get the total count from a raw result returned by the engine.
348 | *
349 | * @param mixed $results
350 | * @return int
351 | */
352 | public function getTotalCount($results)
353 | {
354 | return $results['totalHits'] ?? $results['estimatedTotalHits'];
355 | }
356 |
357 | /**
358 | * Flush all of the model's records from the engine.
359 | *
360 | * @param \Illuminate\Database\Eloquent\Model $model
361 | * @return void
362 | */
363 | public function flush($model)
364 | {
365 | $index = $this->meilisearch->index($model->indexableAs());
366 |
367 | $index->deleteAllDocuments();
368 | }
369 |
370 | /**
371 | * Create a search index.
372 | *
373 | * @param string $name
374 | * @return mixed
375 | *
376 | * @throws \Meilisearch\Exceptions\ApiException
377 | */
378 | public function createIndex($name, array $options = [])
379 | {
380 | try {
381 | $index = $this->meilisearch->getIndex($name);
382 | } catch (ApiException $e) {
383 | $index = null;
384 | }
385 |
386 | if ($index?->getUid() !== null) {
387 | return $index;
388 | }
389 |
390 | return $this->meilisearch->createIndex($name, $options);
391 | }
392 |
393 | /**
394 | * Update the index settings for the given index.
395 | *
396 | * @return void
397 | */
398 | public function updateIndexSettings($name, array $settings = [])
399 | {
400 | $index = $this->meilisearch->index($name);
401 |
402 | $index->updateSettings(Arr::except($settings, 'embedders'));
403 |
404 | if (! empty($settings['embedders'])) {
405 | $index->updateEmbedders($settings['embedders']);
406 | }
407 | }
408 |
409 | /**
410 | * Configure the soft delete filter within the given settings.
411 | *
412 | * @return array
413 | */
414 | public function configureSoftDeleteFilter(array $settings = [])
415 | {
416 | $settings['filterableAttributes'][] = '__soft_deleted';
417 |
418 | return $settings;
419 | }
420 |
421 | /**
422 | * Delete a search index.
423 | *
424 | * @param string $name
425 | * @return mixed
426 | *
427 | * @throws \Meilisearch\Exceptions\ApiException
428 | */
429 | public function deleteIndex($name)
430 | {
431 | return $this->meilisearch->deleteIndex($name);
432 | }
433 |
434 | /**
435 | * Delete all search indexes.
436 | *
437 | * @return mixed
438 | */
439 | public function deleteAllIndexes()
440 | {
441 | $tasks = [];
442 | $limit = 1000000;
443 |
444 | $query = new IndexesQuery;
445 | $query->setLimit($limit);
446 |
447 | $indexes = $this->meilisearch->getIndexes($query);
448 |
449 | foreach ($indexes->getResults() as $index) {
450 | $tasks[] = $index->delete();
451 | }
452 |
453 | return $tasks;
454 | }
455 |
456 | /**
457 | * Determine if the given model uses soft deletes.
458 | *
459 | * @param \Illuminate\Database\Eloquent\Model $model
460 | * @return bool
461 | */
462 | protected function usesSoftDelete($model)
463 | {
464 | return in_array(\Illuminate\Database\Eloquent\SoftDeletes::class, class_uses_recursive($model));
465 | }
466 |
467 | /**
468 | * Dynamically call the Meilisearch client instance.
469 | *
470 | * @param string $method
471 | * @param array $parameters
472 | * @return mixed
473 | */
474 | public function __call($method, $parameters)
475 | {
476 | return $this->meilisearch->$method(...$parameters);
477 | }
478 | }
479 |
--------------------------------------------------------------------------------
/src/Engines/NullEngine.php:
--------------------------------------------------------------------------------
1 | typesense = $typesense;
57 | $this->maxTotalResults = $maxTotalResults;
58 | }
59 |
60 | /**
61 | * Update the given model in the index.
62 | *
63 | * @param \Illuminate\Database\Eloquent\Collection|Model[] $models
64 | *
65 | * @throws \Http\Client\Exception
66 | * @throws \JsonException
67 | * @throws \Typesense\Exceptions\TypesenseClientError
68 | *
69 | * @noinspection NotOptimalIfConditionsInspection
70 | */
71 | public function update($models)
72 | {
73 | if ($models->isEmpty()) {
74 | return;
75 | }
76 |
77 | $collection = $this->getOrCreateCollectionFromModel($models->first());
78 |
79 | if ($this->usesSoftDelete($models->first()) && config('scout.soft_delete', false)) {
80 | $models->each->pushSoftDeleteMetadata();
81 | }
82 |
83 | $objects = $models->map(function ($model) {
84 | if (empty($searchableData = $model->toSearchableArray())) {
85 | return null;
86 | }
87 |
88 | return array_merge(
89 | $searchableData,
90 | $model->scoutMetadata(),
91 | );
92 | })
93 | ->filter()
94 | ->values()
95 | ->all();
96 |
97 | if (! empty($objects)) {
98 | $this->importDocuments(
99 | $collection,
100 | $objects
101 | );
102 | }
103 | }
104 |
105 | /**
106 | * Import the given documents into the index.
107 | *
108 | * @param TypesenseCollection $collectionIndex
109 | * @param array $documents
110 | * @param string $action
111 | * @return \Illuminate\Support\Collection
112 | *
113 | * @throws \JsonException
114 | * @throws \Typesense\Exceptions\TypesenseClientError
115 | * @throws \Http\Client\Exception
116 | */
117 | protected function importDocuments(TypesenseCollection $collectionIndex, array $documents, string $action = 'emplace'): Collection
118 | {
119 | $importedDocuments = $collectionIndex->getDocuments()->import($documents, ['action' => $action]);
120 |
121 | $results = [];
122 |
123 | foreach ($importedDocuments as $importedDocument) {
124 | if (! $importedDocument['success']) {
125 | throw new TypesenseClientError("Error importing document: {$importedDocument['error']}");
126 | }
127 |
128 | $results[] = $this->createImportSortingDataObject(
129 | $importedDocument
130 | );
131 | }
132 |
133 | return collect($results);
134 | }
135 |
136 | /**
137 | * Create an import sorting data object for a given document.
138 | *
139 | * @param array $document
140 | * @return \stdClass
141 | *
142 | * @throws \JsonException
143 | */
144 | protected function createImportSortingDataObject($document)
145 | {
146 | $data = new stdClass;
147 |
148 | $data->code = $document['code'] ?? 0;
149 | $data->success = $document['success'];
150 | $data->error = $document['error'] ?? null;
151 | $data->document = json_decode($document['document'] ?? '[]', true, 512, JSON_THROW_ON_ERROR);
152 |
153 | return $data;
154 | }
155 |
156 | /**
157 | * Remove the given model from the index.
158 | *
159 | * @param \Illuminate\Database\Eloquent\Collection $models
160 | * @return void
161 | *
162 | * @throws \Http\Client\Exception
163 | * @throws \Typesense\Exceptions\TypesenseClientError
164 | */
165 | public function delete($models)
166 | {
167 | $models->each(function (Model $model) {
168 | $this->deleteDocument(
169 | $this->getOrCreateCollectionFromModel($model),
170 | $model->getScoutKey()
171 | );
172 | });
173 | }
174 |
175 | /**
176 | * Delete a document from the index.
177 | *
178 | * @param TypesenseCollection $collectionIndex
179 | * @param mixed $modelId
180 | * @return array
181 | *
182 | * @throws \Typesense\Exceptions\ObjectNotFound
183 | * @throws \Typesense\Exceptions\TypesenseClientError
184 | * @throws \Http\Client\Exception
185 | */
186 | protected function deleteDocument(TypesenseCollection $collectionIndex, $modelId): array
187 | {
188 | $document = $collectionIndex->getDocuments()[(string) $modelId];
189 |
190 | try {
191 | $document->retrieve();
192 |
193 | return $document->delete();
194 | } catch (Exception $exception) {
195 | return [];
196 | }
197 | }
198 |
199 | /**
200 | * Perform the given search on the engine.
201 | *
202 | * @param \Laravel\Scout\Builder $builder
203 | * @return mixed
204 | *
205 | * @throws \Http\Client\Exception
206 | * @throws \Typesense\Exceptions\TypesenseClientError
207 | */
208 | public function search(Builder $builder)
209 | {
210 | // If the limit exceeds Typesense's capabilities, perform a paginated search...
211 | if ($builder->limit >= $this->maxPerPage) {
212 | return $this->performPaginatedSearch($builder);
213 | }
214 |
215 | return $this->performSearch(
216 | $builder,
217 | $this->buildSearchParameters($builder, 1, $builder->limit ?? $this->maxPerPage)
218 | );
219 | }
220 |
221 | /**
222 | * Perform the given search on the engine with pagination.
223 | *
224 | * @param \Laravel\Scout\Builder $builder
225 | * @param int $perPage
226 | * @param int $page
227 | * @return mixed
228 | *
229 | * @throws \Http\Client\Exception
230 | * @throws \Typesense\Exceptions\TypesenseClientError
231 | */
232 | public function paginate(Builder $builder, $perPage, $page)
233 | {
234 | $maxInt = 4294967295;
235 |
236 | $page = max(1, (int) $page);
237 | $perPage = max(1, (int) $perPage);
238 |
239 | if ($page * $perPage > $maxInt) {
240 | $page = floor($maxInt / $perPage);
241 | }
242 |
243 | return $this->performSearch(
244 | $builder,
245 | $this->buildSearchParameters($builder, $page, $perPage)
246 | );
247 | }
248 |
249 | /**
250 | * Perform the given search on the engine.
251 | *
252 | * @param \Laravel\Scout\Builder $builder
253 | * @param array $options
254 | * @return mixed
255 | *
256 | * @throws \Http\Client\Exception
257 | * @throws \Typesense\Exceptions\TypesenseClientError
258 | */
259 | protected function performSearch(Builder $builder, array $options = []): mixed
260 | {
261 | $documents = $this->getOrCreateCollectionFromModel(
262 | $builder->model,
263 | $builder->index,
264 | false,
265 | )->getDocuments();
266 |
267 | if ($builder->callback) {
268 | return call_user_func($builder->callback, $documents, $builder->query, $options);
269 | }
270 |
271 | try {
272 | return $documents->search($options);
273 | } catch (ObjectNotFound) {
274 | $this->getOrCreateCollectionFromModel($builder->model, $builder->index, true);
275 |
276 | return $documents->search($options);
277 | }
278 | }
279 |
280 | /**
281 | * Perform a paginated search on the engine.
282 | *
283 | * @param \Laravel\Scout\Builder $builder
284 | * @return mixed
285 | *
286 | * @throws \Http\Client\Exception
287 | * @throws \Typesense\Exceptions\TypesenseClientError
288 | */
289 | protected function performPaginatedSearch(Builder $builder)
290 | {
291 | $page = 1;
292 | $limit = min($builder->limit ?? $this->maxPerPage, $this->maxPerPage, $this->maxTotalResults);
293 | $remainingResults = min($builder->limit ?? $this->maxTotalResults, $this->maxTotalResults);
294 |
295 | $results = new Collection;
296 |
297 | while ($remainingResults > 0) {
298 | $searchResults = $this->performSearch(
299 | $builder,
300 | $this->buildSearchParameters($builder, $page, $limit)
301 | );
302 |
303 | $results = $results->concat($searchResults['hits'] ?? []);
304 |
305 | if ($page === 1) {
306 | $totalFound = $searchResults['found'] ?? 0;
307 | }
308 |
309 | $remainingResults -= $limit;
310 | $page++;
311 |
312 | if (count($searchResults['hits'] ?? []) < $limit) {
313 | break;
314 | }
315 | }
316 |
317 | return [
318 | 'hits' => $results->all(),
319 | 'found' => $results->count(),
320 | 'out_of' => $totalFound,
321 | 'page' => 1,
322 | 'request_params' => $this->buildSearchParameters($builder, 1, $builder->limit ?? $this->maxPerPage),
323 | ];
324 | }
325 |
326 | /**
327 | * Build the search parameters for a given Scout query builder.
328 | *
329 | * @param \Laravel\Scout\Builder $builder
330 | * @param int $page
331 | * @param int|null $perPage
332 | * @return array
333 | */
334 | public function buildSearchParameters(Builder $builder, int $page, ?int $perPage): array
335 | {
336 | $parameters = [
337 | 'q' => $builder->query,
338 | 'query_by' => config('scout.typesense.model-settings.'.get_class($builder->model).'.search-parameters.query_by') ?? '',
339 | 'filter_by' => $this->filters($builder),
340 | 'per_page' => $perPage,
341 | 'page' => $page,
342 | 'highlight_start_tag' => '',
343 | 'highlight_end_tag' => '',
344 | 'snippet_threshold' => 30,
345 | 'exhaustive_search' => false,
346 | 'use_cache' => false,
347 | 'cache_ttl' => 60,
348 | 'prioritize_exact_match' => true,
349 | 'enable_overrides' => true,
350 | 'highlight_affix_num_tokens' => 4,
351 | 'prefix' => config('scout.typesense.model-settings.'.get_class($builder->model).'.search-parameters.prefix') ?? true,
352 | ];
353 |
354 | if (method_exists($builder->model, 'typesenseSearchParameters')) {
355 | $parameters = array_merge($parameters, $builder->model->typesenseSearchParameters());
356 | }
357 |
358 | if (! empty($builder->options)) {
359 | $parameters = array_merge($parameters, $builder->options);
360 | }
361 |
362 | if (! empty($builder->orders)) {
363 | if (! empty($parameters['sort_by'])) {
364 | $parameters['sort_by'] .= ',';
365 | } else {
366 | $parameters['sort_by'] = '';
367 | }
368 |
369 | $parameters['sort_by'] .= $this->parseOrderBy($builder->orders);
370 | }
371 |
372 | return $parameters;
373 | }
374 |
375 | /**
376 | * Prepare the filters for a given search query.
377 | *
378 | * @param \Laravel\Scout\Builder $builder
379 | * @return string
380 | */
381 | protected function filters(Builder $builder): string
382 | {
383 | $whereFilter = collect($builder->wheres)
384 | ->map(fn ($value, $key) => $this->parseWhereFilter($this->parseFilterValue($value), $key))
385 | ->values()
386 | ->implode(' && ');
387 |
388 | $whereInFilter = collect($builder->whereIns)
389 | ->map(fn ($value, $key) => $this->parseWhereInFilter($this->parseFilterValue($value), $key))
390 | ->values()
391 | ->implode(' && ');
392 |
393 | $whereNotInFilter = collect($builder->whereNotIns)
394 | ->map(fn ($value, $key) => $this->parseWhereNotInFilter($this->parseFilterValue($value), $key))
395 | ->values()
396 | ->implode(' && ');
397 |
398 | $filters = collect([$whereFilter, $whereInFilter, $whereNotInFilter])
399 | ->filter()
400 | ->implode(' && ');
401 |
402 | return $filters;
403 | }
404 |
405 | /**
406 | * Parse the given filter value.
407 | *
408 | * @param array|string|bool|int|float $value
409 | * @return array|bool|float|int|string
410 | */
411 | protected function parseFilterValue(array|string|bool|int|float $value)
412 | {
413 | if (is_array($value)) {
414 | return array_map([$this, 'parseFilterValue'], $value);
415 | }
416 |
417 | if (gettype($value) == 'boolean') {
418 | return $value ? 'true' : 'false';
419 | }
420 |
421 | return $value;
422 | }
423 |
424 | /**
425 | * Create a "where" filter string.
426 | *
427 | * @param array|string $value
428 | * @param string $key
429 | * @return string
430 | */
431 | protected function parseWhereFilter(array|string $value, string $key): string
432 | {
433 | return is_array($value)
434 | ? sprintf('%s:%s', $key, implode('', $value))
435 | : sprintf('%s:=%s', $key, $value);
436 | }
437 |
438 | /**
439 | * Create a "where in" filter string.
440 | *
441 | * @param array $value
442 | * @param string $key
443 | * @return string
444 | */
445 | protected function parseWhereInFilter(array $value, string $key): string
446 | {
447 | return sprintf('%s:=[%s]', $key, implode(', ', $value));
448 | }
449 |
450 | /**
451 | * Create a "where not in" filter string.
452 | *
453 | * @param array|string $value
454 | * @param string $key
455 | * @return string
456 | */
457 | protected function parseWhereNotInFilter(array $value, string $key): string
458 | {
459 | return sprintf('%s:!=[%s]', $key, implode(', ', $value));
460 | }
461 |
462 | /**
463 | * Parse the order by fields for the query.
464 | *
465 | * @param array $orders
466 | * @return string
467 | */
468 | protected function parseOrderBy(array $orders): string
469 | {
470 | $orderBy = [];
471 |
472 | foreach ($orders as $order) {
473 | $orderBy[] = $order['column'].':'.$order['direction'];
474 | }
475 |
476 | return implode(',', $orderBy);
477 | }
478 |
479 | /**
480 | * Pluck and return the primary keys of the given results.
481 | *
482 | * @param mixed $results
483 | * @return \Illuminate\Support\Collection
484 | */
485 | public function mapIds($results)
486 | {
487 | return collect($results['hits'])
488 | ->pluck('document.id')
489 | ->values();
490 | }
491 |
492 | /**
493 | * Map the given results to instances of the given model.
494 | *
495 | * @param \Laravel\Scout\Builder $builder
496 | * @param mixed $results
497 | * @param \Illuminate\Database\Eloquent\Model $model
498 | * @return \Illuminate\Database\Eloquent\Collection
499 | */
500 | public function map(Builder $builder, $results, $model)
501 | {
502 | if ($this->getTotalCount($results) === 0) {
503 | return $model->newCollection();
504 | }
505 |
506 | $hits = isset($results['grouped_hits']) && ! empty($results['grouped_hits'])
507 | ? $results['grouped_hits']
508 | : $results['hits'];
509 |
510 | $pluck = isset($results['grouped_hits']) && ! empty($results['grouped_hits'])
511 | ? 'hits.0.document.id'
512 | : 'document.id';
513 |
514 | $objectIds = collect($hits)
515 | ->pluck($pluck)
516 | ->values()
517 | ->all();
518 |
519 | $objectIdPositions = array_flip($objectIds);
520 |
521 | return $model->getScoutModelsByIds($builder, $objectIds)
522 | ->filter(static fn ($model) => in_array($model->getScoutKey(), $objectIds, false))
523 | ->sortBy(static fn ($model) => $objectIdPositions[$model->getScoutKey()])
524 | ->values();
525 | }
526 |
527 | /**
528 | * Map the given results to instances of the given model via a lazy collection.
529 | *
530 | * @param \Laravel\Scout\Builder $builder
531 | * @param mixed $results
532 | * @param \Illuminate\Database\Eloquent\Model $model
533 | * @return \Illuminate\Support\LazyCollection
534 | */
535 | public function lazyMap(Builder $builder, $results, $model)
536 | {
537 | if ((int) ($results['found'] ?? 0) === 0) {
538 | return LazyCollection::make($model->newCollection());
539 | }
540 |
541 | $objectIds = collect($results['hits'])
542 | ->pluck('document.id')
543 | ->values()
544 | ->all();
545 |
546 | $objectIdPositions = array_flip($objectIds);
547 |
548 | return $model->queryScoutModelsByIds($builder, $objectIds)
549 | ->cursor()
550 | ->filter(static fn ($model) => in_array($model->getScoutKey(), $objectIds, false))
551 | ->sortBy(static fn ($model) => $objectIdPositions[$model->getScoutKey()])
552 | ->values();
553 | }
554 |
555 | /**
556 | * Get the total count from a raw result returned by the engine.
557 | *
558 | * @param mixed $results
559 | * @return int
560 | */
561 | public function getTotalCount($results)
562 | {
563 | return (int) ($results['found'] ?? 0);
564 | }
565 |
566 | /**
567 | * Flush all the model's records from the engine.
568 | *
569 | * @param \Illuminate\Database\Eloquent\Model $model
570 | *
571 | * @throws \Http\Client\Exception
572 | * @throws \Typesense\Exceptions\TypesenseClientError
573 | */
574 | public function flush($model)
575 | {
576 | $this->getOrCreateCollectionFromModel($model)->delete();
577 | }
578 |
579 | /**
580 | * Create a search index.
581 | *
582 | * @param string $name
583 | * @param array $options
584 | * @return void
585 | *
586 | * @throws NotSupportedException
587 | */
588 | public function createIndex($name, array $options = [])
589 | {
590 | throw new NotSupportedException('Typesense indexes are created automatically upon adding objects.');
591 | }
592 |
593 | /**
594 | * Delete a search index.
595 | *
596 | * @param string $name
597 | * @return array
598 | *
599 | * @throws \Typesense\Exceptions\TypesenseClientError
600 | * @throws \Http\Client\Exception
601 | * @throws \Typesense\Exceptions\ObjectNotFound
602 | */
603 | public function deleteIndex($name)
604 | {
605 | return $this->typesense->getCollections()->{$name}->delete();
606 | }
607 |
608 | /**
609 | * Get collection from model or create new one.
610 | *
611 | * @param \Illuminate\Database\Eloquent\Model $model
612 | * @return \Typesense\Collection
613 | *
614 | * @throws \Typesense\Exceptions\TypesenseClientError
615 | * @throws \Http\Client\Exception
616 | */
617 | protected function getOrCreateCollectionFromModel($model, ?string $collectionName = null, bool $indexOperation = true): TypesenseCollection
618 | {
619 | if (! $indexOperation) {
620 | $collectionName = $collectionName ?? $model->searchableAs();
621 | } else {
622 | $collectionName = $model->indexableAs();
623 | }
624 |
625 | $collection = $this->typesense->getCollections()->{$collectionName};
626 |
627 | if (! $indexOperation) {
628 | return $collection;
629 | }
630 |
631 | // Determine if the collection exists in Typesense...
632 | try {
633 | $collection->retrieve();
634 |
635 | // No error means this collection exists on the server...
636 | $collection->setExists(true);
637 |
638 | return $collection;
639 | } catch (TypesenseClientError $e) {
640 | //
641 | }
642 |
643 | $schema = config('scout.typesense.model-settings.'.get_class($model).'.collection-schema') ?? [];
644 |
645 | if (method_exists($model, 'typesenseCollectionSchema')) {
646 | $schema = $model->typesenseCollectionSchema();
647 | }
648 |
649 | if (! isset($schema['name'])) {
650 | $schema['name'] = $model->searchableAs();
651 | }
652 |
653 | try {
654 | // Create the collection in Typesense...
655 | $this->typesense->getCollections()->create($schema);
656 | } catch (ObjectAlreadyExists $e) {
657 | // Collection already exists...
658 | }
659 |
660 | $collection->setExists(true);
661 |
662 | return $collection;
663 | }
664 |
665 | /**
666 | * Determine if model uses soft deletes.
667 | *
668 | * @param \Illuminate\Database\Eloquent\Model $model
669 | * @return bool
670 | */
671 | protected function usesSoftDelete($model): bool
672 | {
673 | return in_array(SoftDeletes::class, class_uses_recursive($model), true);
674 | }
675 |
676 | /**
677 | * Dynamically proxy missing methods to the Typesense client instance.
678 | *
679 | * @param string $method
680 | * @param array $parameters
681 | * @return mixed
682 | */
683 | public function __call($method, $parameters)
684 | {
685 | return $this->typesense->$method(...$parameters);
686 | }
687 | }
688 |
--------------------------------------------------------------------------------
/src/Events/ModelsFlushed.php:
--------------------------------------------------------------------------------
1 | models = $models;
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/src/Events/ModelsImported.php:
--------------------------------------------------------------------------------
1 | models = $models;
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/src/Exceptions/NotSupportedException.php:
--------------------------------------------------------------------------------
1 | class = $class;
45 | $this->start = $start;
46 | $this->end = $end;
47 | }
48 |
49 | /**
50 | * Handle the job.
51 | *
52 | * @return void
53 | */
54 | public function handle()
55 | {
56 | $model = new $this->class;
57 |
58 | $models = $model::makeAllSearchableQuery()
59 | ->whereBetween($model->getScoutKeyName(), [$this->start, $this->end])
60 | ->get()
61 | ->filter
62 | ->shouldBeSearchable();
63 |
64 | if ($models->isEmpty()) {
65 | return;
66 | }
67 |
68 | dispatch(new Scout::$makeSearchableJob($models))
69 | ->onQueue($model->syncWithSearchUsingQueue())
70 | ->onConnection($model->syncWithSearchUsing());
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/src/Jobs/MakeSearchable.php:
--------------------------------------------------------------------------------
1 | models = $models;
29 | }
30 |
31 | /**
32 | * Handle the job.
33 | *
34 | * @return void
35 | */
36 | public function handle()
37 | {
38 | if ($this->models->isEmpty()) {
39 | return;
40 | }
41 |
42 | $this->models->first()->makeSearchableUsing($this->models)->first()->searchableUsing()->update($this->models);
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/src/Jobs/RemoveFromSearch.php:
--------------------------------------------------------------------------------
1 | models = RemoveableScoutCollection::make($models);
29 | }
30 |
31 | /**
32 | * Handle the job.
33 | *
34 | * @return void
35 | */
36 | public function handle()
37 | {
38 | if ($this->models->isNotEmpty()) {
39 | $this->models->first()->searchableUsing()->delete($this->models);
40 | }
41 | }
42 |
43 | /**
44 | * Restore a queueable collection instance.
45 | *
46 | * @param \Illuminate\Contracts\Database\ModelIdentifier $value
47 | * @return \Laravel\Scout\Jobs\RemoveableScoutCollection
48 | */
49 | protected function restoreCollection($value)
50 | {
51 | if (! $value->class || count($value->id) === 0) {
52 | return new RemoveableScoutCollection;
53 | }
54 |
55 | return new RemoveableScoutCollection(
56 | collect($value->id)->map(function ($id) use ($value) {
57 | return tap(new $value->class, function ($model) use ($id) {
58 | $model->setKeyType(
59 | is_string($id) ? 'string' : 'int'
60 | )->forceFill([
61 | $model->getScoutKeyName() => $id,
62 | ]);
63 | });
64 | })
65 | );
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/src/Jobs/RemoveableScoutCollection.php:
--------------------------------------------------------------------------------
1 | isEmpty()) {
18 | return [];
19 | }
20 |
21 | return in_array(Searchable::class, class_uses_recursive($this->first()))
22 | ? $this->map->getScoutKey()->all()
23 | : parent::getQueueableIds();
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/src/ModelObserver.php:
--------------------------------------------------------------------------------
1 | afterCommit = Config::get('scout.after_commit', false);
47 | $this->usingSoftDeletes = Config::get('scout.soft_delete', false);
48 | }
49 |
50 | /**
51 | * Enable syncing for the given class.
52 | *
53 | * @param string $class
54 | * @return void
55 | */
56 | public static function enableSyncingFor($class)
57 | {
58 | unset(static::$syncingDisabledFor[$class]);
59 | }
60 |
61 | /**
62 | * Disable syncing for the given class.
63 | *
64 | * @param string $class
65 | * @return void
66 | */
67 | public static function disableSyncingFor($class)
68 | {
69 | static::$syncingDisabledFor[$class] = true;
70 | }
71 |
72 | /**
73 | * Determine if syncing is disabled for the given class or model.
74 | *
75 | * @param object|string $class
76 | * @return bool
77 | */
78 | public static function syncingDisabledFor($class)
79 | {
80 | $class = is_object($class) ? get_class($class) : $class;
81 |
82 | return isset(static::$syncingDisabledFor[$class]);
83 | }
84 |
85 | /**
86 | * Handle the saved event for the model.
87 | *
88 | * @param \Illuminate\Database\Eloquent\Model $model
89 | * @return void
90 | */
91 | public function saved($model)
92 | {
93 | if (static::syncingDisabledFor($model)) {
94 | return;
95 | }
96 |
97 | if (! $this->forceSaving && ! $model->searchIndexShouldBeUpdated()) {
98 | return;
99 | }
100 |
101 | if (! $model->shouldBeSearchable()) {
102 | if ($model->wasSearchableBeforeUpdate()) {
103 | $model->unsearchable();
104 | }
105 |
106 | return;
107 | }
108 |
109 | $model->searchable();
110 | }
111 |
112 | /**
113 | * Handle the deleted event for the model.
114 | *
115 | * @param \Illuminate\Database\Eloquent\Model $model
116 | * @return void
117 | */
118 | public function deleted($model)
119 | {
120 | if (static::syncingDisabledFor($model)) {
121 | return;
122 | }
123 |
124 | if (! $model->wasSearchableBeforeDelete()) {
125 | return;
126 | }
127 |
128 | if ($this->usingSoftDeletes && $this->usesSoftDelete($model)) {
129 | $this->whileForcingUpdate(function () use ($model) {
130 | $this->saved($model);
131 | });
132 | } else {
133 | $model->unsearchable();
134 | }
135 | }
136 |
137 | /**
138 | * Handle the force deleted event for the model.
139 | *
140 | * @param \Illuminate\Database\Eloquent\Model $model
141 | * @return void
142 | */
143 | public function forceDeleted($model)
144 | {
145 | if (static::syncingDisabledFor($model)) {
146 | return;
147 | }
148 |
149 | $model->unsearchable();
150 | }
151 |
152 | /**
153 | * Handle the restored event for the model.
154 | *
155 | * @param \Illuminate\Database\Eloquent\Model $model
156 | * @return void
157 | */
158 | public function restored($model)
159 | {
160 | $this->whileForcingUpdate(function () use ($model) {
161 | $this->saved($model);
162 | });
163 | }
164 |
165 | /**
166 | * Execute the given callback while forcing updates.
167 | *
168 | * @param \Closure $callback
169 | * @return mixed
170 | */
171 | protected function whileForcingUpdate(Closure $callback)
172 | {
173 | $this->forceSaving = true;
174 |
175 | $result = $callback();
176 |
177 | $this->forceSaving = false;
178 |
179 | return $result;
180 | }
181 |
182 | /**
183 | * Determine if the given model uses soft deletes.
184 | *
185 | * @param \Illuminate\Database\Eloquent\Model $model
186 | * @return bool
187 | */
188 | protected function usesSoftDelete($model)
189 | {
190 | return in_array(SoftDeletes::class, class_uses_recursive($model));
191 | }
192 | }
193 |
--------------------------------------------------------------------------------
/src/Scout.php:
--------------------------------------------------------------------------------
1 | engine($engine);
38 | }
39 |
40 | /**
41 | * Specify the job class that should make models searchable.
42 | *
43 | * @param string $class
44 | * @return void
45 | */
46 | public static function makeSearchableUsing(string $class)
47 | {
48 | static::$makeSearchableJob = $class;
49 | }
50 |
51 | /**
52 | * Specify the job class that should remove models from the search index.
53 | *
54 | * @param string $class
55 | * @return void
56 | */
57 | public static function removeFromSearchUsing(string $class)
58 | {
59 | static::$removeFromSearchJob = $class;
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/src/ScoutServiceProvider.php:
--------------------------------------------------------------------------------
1 | mergeConfigFrom(__DIR__.'/../config/scout.php', 'scout');
25 |
26 | if (class_exists(Meilisearch::class)) {
27 | $this->app->singleton(Meilisearch::class, function ($app) {
28 | $config = $app['config']->get('scout.meilisearch');
29 |
30 | return new Meilisearch(
31 | $config['host'],
32 | $config['key'],
33 | clientAgents: [sprintf('Meilisearch Laravel Scout (v%s)', Scout::VERSION)],
34 | );
35 | });
36 | }
37 |
38 | $this->app->singleton(EngineManager::class, function ($app) {
39 | return new EngineManager($app);
40 | });
41 | }
42 |
43 | /**
44 | * Bootstrap any application services.
45 | *
46 | * @return void
47 | */
48 | public function boot()
49 | {
50 | if ($this->app->runningInConsole()) {
51 | $this->commands([
52 | QueueImportCommand::class,
53 | FlushCommand::class,
54 | ImportCommand::class,
55 | IndexCommand::class,
56 | SyncIndexSettingsCommand::class,
57 | DeleteIndexCommand::class,
58 | DeleteAllIndexesCommand::class,
59 | ]);
60 |
61 | $this->publishes([
62 | __DIR__.'/../config/scout.php' => $this->app['path.config'].DIRECTORY_SEPARATOR.'scout.php',
63 | ]);
64 | }
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/src/Searchable.php:
--------------------------------------------------------------------------------
1 | registerSearchableMacros();
30 | }
31 |
32 | /**
33 | * Register the searchable macros.
34 | *
35 | * @return void
36 | */
37 | public function registerSearchableMacros()
38 | {
39 | $self = $this;
40 |
41 | BaseCollection::macro('searchable', function () use ($self) {
42 | $self->queueMakeSearchable($this);
43 | });
44 |
45 | BaseCollection::macro('unsearchable', function () use ($self) {
46 | $self->queueRemoveFromSearch($this);
47 | });
48 |
49 | BaseCollection::macro('searchableSync', function () use ($self) {
50 | $self->syncMakeSearchable($this);
51 | });
52 |
53 | BaseCollection::macro('unsearchableSync', function () use ($self) {
54 | $self->syncRemoveFromSearch($this);
55 | });
56 | }
57 |
58 | /**
59 | * Dispatch the job to make the given models searchable.
60 | *
61 | * @param \Illuminate\Database\Eloquent\Collection $models
62 | * @return void
63 | */
64 | public function queueMakeSearchable($models)
65 | {
66 | if ($models->isEmpty()) {
67 | return;
68 | }
69 |
70 | if (! config('scout.queue')) {
71 | return $this->syncMakeSearchable($models);
72 | }
73 |
74 | dispatch((new Scout::$makeSearchableJob($models))
75 | ->onQueue($models->first()->syncWithSearchUsingQueue())
76 | ->onConnection($models->first()->syncWithSearchUsing()));
77 | }
78 |
79 | /**
80 | * Synchronously make the given models searchable.
81 | *
82 | * @param \Illuminate\Database\Eloquent\Collection $models
83 | * @return void
84 | */
85 | public function syncMakeSearchable($models)
86 | {
87 | if ($models->isEmpty()) {
88 | return;
89 | }
90 |
91 | return $models->first()->makeSearchableUsing($models)->first()->searchableUsing()->update($models);
92 | }
93 |
94 | /**
95 | * Dispatch the job to make the given models unsearchable.
96 | *
97 | * @param \Illuminate\Database\Eloquent\Collection $models
98 | * @return void
99 | */
100 | public function queueRemoveFromSearch($models)
101 | {
102 | if ($models->isEmpty()) {
103 | return;
104 | }
105 |
106 | if (! config('scout.queue')) {
107 | return $this->syncRemoveFromSearch($models);
108 | }
109 |
110 | dispatch(new Scout::$removeFromSearchJob($models))
111 | ->onQueue($models->first()->syncWithSearchUsingQueue())
112 | ->onConnection($models->first()->syncWithSearchUsing());
113 | }
114 |
115 | /**
116 | * Synchronously make the given models unsearchable.
117 | *
118 | * @param \Illuminate\Database\Eloquent\Collection $models
119 | * @return void
120 | */
121 | public function syncRemoveFromSearch($models)
122 | {
123 | if ($models->isEmpty()) {
124 | return;
125 | }
126 |
127 | return $models->first()->searchableUsing()->delete($models);
128 | }
129 |
130 | /**
131 | * Determine if the model should be searchable.
132 | *
133 | * @return bool
134 | */
135 | public function shouldBeSearchable()
136 | {
137 | return true;
138 | }
139 |
140 | /**
141 | * When updating a model, this method determines if we should update the search index.
142 | *
143 | * @return bool
144 | */
145 | public function searchIndexShouldBeUpdated()
146 | {
147 | return true;
148 | }
149 |
150 | /**
151 | * Perform a search against the model's indexed data.
152 | *
153 | * @param string $query
154 | * @param \Closure $callback
155 | * @return \Laravel\Scout\Builder
156 | */
157 | public static function search($query = '', $callback = null)
158 | {
159 | return app(static::$scoutBuilder ?? Builder::class, [
160 | 'model' => new static,
161 | 'query' => $query,
162 | 'callback' => $callback,
163 | 'softDelete' => static::usesSoftDelete() && config('scout.soft_delete', false),
164 | ]);
165 | }
166 |
167 | /**
168 | * Make all instances of the model searchable.
169 | *
170 | * @param int $chunk
171 | * @return void
172 | */
173 | public static function makeAllSearchable($chunk = null)
174 | {
175 | static::makeAllSearchableQuery()->searchable($chunk);
176 | }
177 |
178 | /**
179 | * Get a query builder for making all instances of the model searchable.
180 | *
181 | * @return \Illuminate\Database\Eloquent\Builder
182 | */
183 | public static function makeAllSearchableQuery()
184 | {
185 | $self = new static;
186 |
187 | $softDelete = static::usesSoftDelete() && config('scout.soft_delete', false);
188 |
189 | return $self->newQuery()
190 | ->when(true, function ($query) use ($self) {
191 | $self->makeAllSearchableUsing($query);
192 | })
193 | ->when($softDelete, function ($query) {
194 | $query->withTrashed();
195 | })
196 | ->orderBy(
197 | $self->qualifyColumn($self->getScoutKeyName())
198 | );
199 | }
200 |
201 | /**
202 | * Modify the collection of models being made searchable.
203 | *
204 | * @param \Illuminate\Support\Collection $models
205 | * @return \Illuminate\Support\Collection
206 | */
207 | public function makeSearchableUsing(BaseCollection $models)
208 | {
209 | return $models;
210 | }
211 |
212 | /**
213 | * Modify the query used to retrieve models when making all of the models searchable.
214 | *
215 | * @param \Illuminate\Database\Eloquent\Builder $query
216 | * @return \Illuminate\Database\Eloquent\Builder
217 | */
218 | protected function makeAllSearchableUsing(EloquentBuilder $query)
219 | {
220 | return $query;
221 | }
222 |
223 | /**
224 | * Make the given model instance searchable.
225 | *
226 | * @return void
227 | */
228 | public function searchable()
229 | {
230 | $this->newCollection([$this])->searchable();
231 | }
232 |
233 | /**
234 | * Synchronously make the given model instance searchable.
235 | *
236 | * @return void
237 | */
238 | public function searchableSync()
239 | {
240 | $this->newCollection([$this])->searchableSync();
241 | }
242 |
243 | /**
244 | * Remove all instances of the model from the search index.
245 | *
246 | * @return void
247 | */
248 | public static function removeAllFromSearch()
249 | {
250 | $self = new static;
251 |
252 | $self->searchableUsing()->flush($self);
253 | }
254 |
255 | /**
256 | * Remove the given model instance from the search index.
257 | *
258 | * @return void
259 | */
260 | public function unsearchable()
261 | {
262 | $this->newCollection([$this])->unsearchable();
263 | }
264 |
265 | /**
266 | * Synchronously remove the given model instance from the search index.
267 | *
268 | * @return void
269 | */
270 | public function unsearchableSync()
271 | {
272 | $this->newCollection([$this])->unsearchableSync();
273 | }
274 |
275 | /**
276 | * Determine if the model existed in the search index prior to an update.
277 | *
278 | * @return bool
279 | */
280 | public function wasSearchableBeforeUpdate()
281 | {
282 | return true;
283 | }
284 |
285 | /**
286 | * Determine if the model existed in the search index prior to deletion.
287 | *
288 | * @return bool
289 | */
290 | public function wasSearchableBeforeDelete()
291 | {
292 | return true;
293 | }
294 |
295 | /**
296 | * Get the requested models from an array of object IDs.
297 | *
298 | * @param \Laravel\Scout\Builder $builder
299 | * @param array $ids
300 | * @return mixed
301 | */
302 | public function getScoutModelsByIds(Builder $builder, array $ids)
303 | {
304 | return $this->queryScoutModelsByIds($builder, $ids)->get();
305 | }
306 |
307 | /**
308 | * Get a query builder for retrieving the requested models from an array of object IDs.
309 | *
310 | * @param \Laravel\Scout\Builder $builder
311 | * @param array $ids
312 | * @return mixed
313 | */
314 | public function queryScoutModelsByIds(Builder $builder, array $ids)
315 | {
316 | $query = static::usesSoftDelete()
317 | ? $this->withTrashed()
318 | : $this->newQuery();
319 |
320 | if ($builder->queryCallback) {
321 | call_user_func($builder->queryCallback, $query);
322 | }
323 |
324 | $whereIn = in_array($this->getScoutKeyType(), ['int', 'integer']) ?
325 | 'whereIntegerInRaw' :
326 | 'whereIn';
327 |
328 | return $query->{$whereIn}(
329 | $this->qualifyColumn($this->getScoutKeyName()), $ids
330 | );
331 | }
332 |
333 | /**
334 | * Enable search syncing for this model.
335 | *
336 | * @return void
337 | */
338 | public static function enableSearchSyncing()
339 | {
340 | ModelObserver::enableSyncingFor(get_called_class());
341 | }
342 |
343 | /**
344 | * Disable search syncing for this model.
345 | *
346 | * @return void
347 | */
348 | public static function disableSearchSyncing()
349 | {
350 | ModelObserver::disableSyncingFor(get_called_class());
351 | }
352 |
353 | /**
354 | * Temporarily disable search syncing for the given callback.
355 | *
356 | * @param callable $callback
357 | * @return mixed
358 | */
359 | public static function withoutSyncingToSearch($callback)
360 | {
361 | static::disableSearchSyncing();
362 |
363 | try {
364 | return $callback();
365 | } finally {
366 | static::enableSearchSyncing();
367 | }
368 | }
369 |
370 | /**
371 | * Get the index name for the model when searching.
372 | *
373 | * @return string
374 | */
375 | public function searchableAs()
376 | {
377 | return config('scout.prefix').$this->getTable();
378 | }
379 |
380 | /**
381 | * Get the index name for the model when indexing.
382 | *
383 | * @return string
384 | */
385 | public function indexableAs()
386 | {
387 | return $this->searchableAs();
388 | }
389 |
390 | /**
391 | * Get the indexable data array for the model.
392 | *
393 | * @return array
394 | */
395 | public function toSearchableArray()
396 | {
397 | return $this->toArray();
398 | }
399 |
400 | /**
401 | * Get the Scout engine for the model.
402 | *
403 | * @return mixed
404 | */
405 | public function searchableUsing()
406 | {
407 | return app(EngineManager::class)->engine();
408 | }
409 |
410 | /**
411 | * Get the queue connection that should be used when syncing.
412 | *
413 | * @return string
414 | */
415 | public function syncWithSearchUsing()
416 | {
417 | return config('scout.queue.connection') ?: config('queue.default');
418 | }
419 |
420 | /**
421 | * Get the queue that should be used with syncing.
422 | *
423 | * @return string
424 | */
425 | public function syncWithSearchUsingQueue()
426 | {
427 | return config('scout.queue.queue');
428 | }
429 |
430 | /**
431 | * Sync the soft deleted status for this model into the metadata.
432 | *
433 | * @return $this
434 | */
435 | public function pushSoftDeleteMetadata()
436 | {
437 | return $this->withScoutMetadata('__soft_deleted', $this->trashed() ? 1 : 0);
438 | }
439 |
440 | /**
441 | * Get all Scout related metadata.
442 | *
443 | * @return array
444 | */
445 | public function scoutMetadata()
446 | {
447 | return $this->scoutMetadata;
448 | }
449 |
450 | /**
451 | * Set a Scout related metadata.
452 | *
453 | * @param string $key
454 | * @param mixed $value
455 | * @return $this
456 | */
457 | public function withScoutMetadata($key, $value)
458 | {
459 | $this->scoutMetadata[$key] = $value;
460 |
461 | return $this;
462 | }
463 |
464 | /**
465 | * Get the value used to index the model.
466 | *
467 | * @return mixed
468 | */
469 | public function getScoutKey()
470 | {
471 | return $this->getKey();
472 | }
473 |
474 | /**
475 | * Get the auto-incrementing key type for querying models.
476 | *
477 | * @return string
478 | */
479 | public function getScoutKeyType()
480 | {
481 | return $this->getKeyType();
482 | }
483 |
484 | /**
485 | * Get the key name used to index the model.
486 | *
487 | * @return mixed
488 | */
489 | public function getScoutKeyName()
490 | {
491 | return $this->getKeyName();
492 | }
493 |
494 | /**
495 | * Determine if the current class should use soft deletes with searching.
496 | *
497 | * @return bool
498 | */
499 | protected static function usesSoftDelete()
500 | {
501 | return in_array(SoftDeletes::class, class_uses_recursive(get_called_class()));
502 | }
503 | }
504 |
--------------------------------------------------------------------------------
/src/SearchableScope.php:
--------------------------------------------------------------------------------
1 | macro('searchable', function (EloquentBuilder $builder, $chunk = null) {
35 | $scoutKeyName = $builder->getModel()->getScoutKeyName();
36 |
37 | $builder->chunkById($chunk ?: config('scout.chunk.searchable', 500), function ($models) {
38 | $models->filter->shouldBeSearchable()->searchable();
39 |
40 | event(new ModelsImported($models));
41 | }, $builder->qualifyColumn($scoutKeyName), $scoutKeyName);
42 | });
43 |
44 | $builder->macro('unsearchable', function (EloquentBuilder $builder, $chunk = null) {
45 | $scoutKeyName = $builder->getModel()->getScoutKeyName();
46 |
47 | $builder->chunkById($chunk ?: config('scout.chunk.unsearchable', 500), function ($models) {
48 | $models->unsearchable();
49 |
50 | event(new ModelsFlushed($models));
51 | }, $builder->qualifyColumn($scoutKeyName), $scoutKeyName);
52 | });
53 |
54 | HasManyThrough::macro('searchable', function ($chunk = null) {
55 | /** @var HasManyThrough $this */
56 | $this->chunkById($chunk ?: config('scout.chunk.searchable', 500), function ($models) {
57 | $models->filter->shouldBeSearchable()->searchable();
58 |
59 | event(new ModelsImported($models));
60 | });
61 | });
62 |
63 | HasManyThrough::macro('unsearchable', function ($chunk = null) {
64 | /** @var HasManyThrough $this */
65 | $this->chunkById($chunk ?: config('scout.chunk.unsearchable', 500), function ($models) {
66 | $models->unsearchable();
67 |
68 | event(new ModelsFlushed($models));
69 | });
70 | });
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/testbench.yaml:
--------------------------------------------------------------------------------
1 | providers:
2 | - Workbench\App\Providers\WorkbenchServiceProvider
3 | - Laravel\Scout\ScoutServiceProvider
4 |
5 | migrations:
6 | - workbench/database/migrations
7 |
8 | workbench:
9 | install: true
10 |
--------------------------------------------------------------------------------