├── .gitignore ├── LICENSE.md ├── README.md ├── composer.json └── src ├── Classes └── TypesenseDocumentIndexResponse.php ├── Engines └── TypesenseSearchEngine.php ├── Interfaces └── TypesenseSearch.php ├── Mixin └── BuilderMixin.php ├── Typesense.php ├── TypesenseFacade.php └── TypesenseServiceProvider.php /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .tmp 3 | /composer.lock 4 | vendor 5 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright 2020 Devloops LLC 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

⚠️ This project has moved to the official Typesense Github org: https://github.com/typesense/laravel-scout-typesense-driver. 2 | It was adopted as the official Typesense PHP client on Dec 2021 and ongoing development will take place there.

3 |

Please upgrade to the `typesense/laravel-scout-typesense-driver` composer package to receive new updates.

4 | 5 |

The rest of this Readme file is kept as is for posterity.

6 | 7 | 8 |

9 | 10 | [![Latest Version on Packagist](https://img.shields.io/packagist/v/devloopsnet/laravel-typesense.svg?style=for-the-badge)](https://packagist.org/packages/devloopsnet/laravel-typesense) ![Postcardware](https://img.shields.io/badge/Postcardware-%F0%9F%92%8C-197593?style=for-the-badge) 11 | 12 | [![Codacy Badge](https://api.codacy.com/project/badge/Grade/98c6531ca2f141cc9c9de037a15b9c4c)](https://app.codacy.com/gh/devloopsnet/laravel-scout-typesense-engine?utm_source=github.com&utm_medium=referral&utm_content=devloopsnet/laravel-scout-typesense-engine&utm_campaign=Badge_Grade_Settings) [![PHP from Packagist](https://img.shields.io/packagist/php-v/devloopsnet/laravel-typesense?style=flat-square)](https://packagist.org/packages/devloopsnet/laravel-typesense) [![Total Downloads](https://img.shields.io/packagist/dt/devloopsnet/laravel-typesense.svg?style=flat-square)](https://packagist.org/packages/devloopsnet/laravel-typesense) [![StyleCI](https://github.styleci.io/repos/253329257/shield?branch=master)](https://github.styleci.io/repos/253329257?branch=master) 13 | 14 |

15 | # Laravel Scout Typesense Engine 16 | 17 | Typesense engine for laravel/scout https://github.com/typesense/typesense . 18 | 19 |

20 | laravel-scout-typesense-engine
 21 |  socialcard 22 |

23 | This package makes it easy to add full text search support to your models with Laravel 7.* to 8.*. 24 | 25 | ## Contents 26 | 27 | - [Installation](#installation) 28 | - [Usage](#usage) 29 | - [Author](#author) 30 | - [License](#license) 31 | 32 | ## Installation 33 | 34 | You can install the package via composer: 35 | 36 | ``` bash 37 | composer require devloopsnet/laravel-typesense 38 | ``` 39 | 40 | Add the service provider: 41 | 42 | ```php 43 | // config/app.php 44 | 'providers' => [ 45 | // ... 46 | Devloops\LaravelTypesense\TypesenseServiceProvider::class, 47 | ], 48 | ``` 49 | 50 | Ensure you have Laravel Scout as a provider too otherwise you will get an "unresolvable dependency" error 51 | 52 | ```php 53 | // config/app.php 54 | 'providers' => [ 55 | // ... 56 | Laravel\Scout\ScoutServiceProvider::class, 57 | ], 58 | ``` 59 | 60 | Add `SCOUT_DRIVER=typesense` to your `.env` file 61 | 62 | Then you should publish `scout.php` configuration file to your config directory 63 | 64 | ```bash 65 | php artisan vendor:publish --provider="Laravel\Scout\ScoutServiceProvider" 66 | ``` 67 | 68 | In your `config/scout.php` add: 69 | 70 | ```php 71 | 72 | 'typesense' => [ 73 | 'api_key' => 'abcd', 74 | 'nodes' => [ 75 | [ 76 | 'host' => 'localhost', 77 | 'port' => '8108', 78 | 'path' => '', 79 | 'protocol' => 'http', 80 | ], 81 | ], 82 | 'nearest_node' => [ 83 | 'host' => 'localhost', 84 | 'port' => '8108', 85 | 'path' => '', 86 | 'protocol' => 'http', 87 | ], 88 | 'connection_timeout_seconds' => 2, 89 | 'healthcheck_interval_seconds' => 30, 90 | 'num_retries' => 3, 91 | 'retry_interval_seconds' => 1, 92 | ], 93 | ``` 94 | 95 | ## Usage 96 | 97 | After you have installed scout and the Typesense driver, you need to add the 98 | `Searchable` trait to your models that you want to make searchable. Additionaly, define the fields you want to make searchable by defining the `toSearchableArray` method on the model and implement `TypesenseSearch`: 99 | 100 | ```php 101 | toArray(); 121 | 122 | // Customize array... 123 | 124 | return $array; 125 | } 126 | 127 | public function getCollectionSchema(): array { 128 | return [ 129 | 'name' => $this->searchableAs(), 130 | 'fields' => [ 131 | [ 132 | 'name' => 'title', 133 | 'type' => 'string', 134 | ], 135 | [ 136 | 'name' => 'created_at', 137 | 'type' => 'int32', 138 | ], 139 | ], 140 | 'default_sorting_field' => 'created_at', 141 | ]; 142 | } 143 | 144 | public function typesenseQueryBy(): array { 145 | return [ 146 | 'name', 147 | ]; 148 | } 149 | 150 | } 151 | ``` 152 | 153 | Then, sync the data with the search service like: 154 | 155 | `php artisan scout:import App\\Post` 156 | 157 | After that you can search your models with: 158 | 159 | ```php 160 | $search = Post::search('Bugs Bunny'); 161 | ``` 162 | 163 | ### Or 164 | 165 | ```php 166 | $search = Post::search('Bugs Bunny',function (\Laravel\Scout\Builder $builder,\Typesense\Documents $documents, string $query, array $params){ 167 | return $documents->search($params); 168 | }); 169 | ``` 170 | 171 | Then you can apply your where(s) to the builder as follows : 172 | 173 | ```php 174 | 175 | //This way the default operator := will be used 176 | $search->where('created_at', now()->unix()); 177 | 178 | //Or specially for typesense engine you can add typesense operator to the where statement 179 | $search->where('created_at', [ 180 | '>=', 181 | now()->unix() 182 | ]); 183 | 184 | ``` 185 | 186 | *Note : For geolocation search, make sure to send an empty operator as follows 187 | 188 | ```php 189 | $search->where('location', [ 190 | '', 191 | [ 192 | 48.86093481609114, 193 | 2.33698396872901 194 | ] 195 | ]); 196 | 197 | ``` 198 | 199 | ## Extended/Added methods to Scout Builder 200 | 201 | #### Check [Typesense Search](https://typesense.org/docs/0.21.0/api/documents.html#search) for reference. 202 | 203 | - Group by 204 | 205 | ```php 206 | $search->groupBy(['name', 'created_at']) 207 | //or 208 | $search->groupBy('name', 'created_at') 209 | ``` 210 | 211 | - Order 212 | 213 | ```php 214 | $search->orderBy('name','desc') 215 | ``` 216 | 217 | - Location Order 218 | 219 | ```php 220 | $search->orderByLocation('location',48.853, 2.344, 'desc') 221 | //or 222 | $search->orderByLocation('location',48.853, 2.344, 'asc') 223 | ``` 224 | 225 | - Group by limit 226 | 227 | ```php 228 | $search->groupByLimit(200) 229 | ``` 230 | 231 | - Highlight start tag 232 | 233 | ```php 234 | $search->setHighlightStartTag('') 235 | ``` 236 | 237 | - Highlight end tag 238 | 239 | ```php 240 | $search->setHighlightEndTag('') 241 | ``` 242 | 243 | - Hits limit 244 | 245 | ```php 246 | $search->limitHits(200) 247 | ``` 248 | 249 | ## Adding via Query 250 | 251 | The `searchable()` method will chunk the results of the query and add the records to your search index. 252 | 253 | ```php 254 | $post = Post::find(1); 255 | ``` 256 | 257 | ### You may also add record via collection... 258 | 259 | ```php 260 | $post->searchable(); 261 | ``` 262 | 263 | #### ---- OR 264 | 265 | ```php 266 | $posts = Post::where('year', '>', '2018')->get(); 267 | ``` 268 | 269 | You may also add records via collections... 270 | 271 | ```php 272 | $posts->searchable(); 273 | ``` 274 | 275 | ## Author 276 | 277 | - [Abdullah Al-Faqeir](https://github.com/abdullahfaqeir) 278 | - [Contributors](https://github.com/devloopsnet/laravel-scout-typesense-engine/graphs/contributors) 279 | 280 | ## License 281 | 282 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 283 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "devloopsnet/laravel-typesense", 3 | "description": "Typesense laravel/scout engine", 4 | "keywords": [ 5 | "laravel", 6 | "typesense", 7 | "search" 8 | ], 9 | "type": "library", 10 | "homepage": "https://www.devloops.net", 11 | "license": "MIT", 12 | "authors": [ 13 | { 14 | "name": "Abdullah Al-Faqeir", 15 | "email": "abdullah@devloops.net", 16 | "homepage": "https://www.devloops.net", 17 | "role": "Developer" 18 | } 19 | ], 20 | "minimum-stability": "stable", 21 | "autoload": { 22 | "psr-4": { 23 | "Devloops\\LaravelTypesense\\": "src/" 24 | } 25 | }, 26 | "extra": { 27 | "laravel": { 28 | "providers": [ 29 | "Devloops\\LaravelTypesense\\TypesenseServiceProvider" 30 | ], 31 | "aliases": { 32 | "Typesense": "Devloops\\LaravelTypesense\\TypesenseFacade" 33 | } 34 | } 35 | }, 36 | "require": { 37 | "php": "^8.0", 38 | "laravel/scout": "^8.0|^9.0", 39 | "illuminate/bus": "^7.0|^8.0", 40 | "illuminate/contracts": "^7.0|^8.0", 41 | "illuminate/database": "^7.0|^8.0", 42 | "illuminate/pagination": "^7.0|^8.0", 43 | "illuminate/queue": "^7.0|^8.0", 44 | "illuminate/support": "^7.0|^8.0", 45 | "typesense/typesense-php": "^4.0" 46 | }, 47 | "config": { 48 | "platform": { 49 | "php": "8.0" 50 | } 51 | }, 52 | "suggest": { 53 | "typesense/typesense-php": "Required to use the Typesense php client." 54 | }, 55 | "require-dev": { 56 | "phpunit/phpunit": "^8.0|^9.0", 57 | "mockery/mockery": "^1.3" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/Classes/TypesenseDocumentIndexResponse.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | class TypesenseDocumentIndexResponse 13 | { 14 | public function __construct(private ?int $code, private bool $success, private ?string $error = null, private ?array $document = null) 15 | { 16 | } 17 | 18 | /** 19 | * @return int|null 20 | */ 21 | public function getCode(): ?int 22 | { 23 | return $this->code; 24 | } 25 | 26 | /** 27 | * @return bool 28 | */ 29 | public function isSuccess(): bool 30 | { 31 | return $this->success; 32 | } 33 | 34 | /** 35 | * @return string|null 36 | */ 37 | public function getError(): ?string 38 | { 39 | return $this->error; 40 | } 41 | 42 | /** 43 | * @return array|null 44 | */ 45 | public function getDocument(): ?array 46 | { 47 | return $this->document; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Engines/TypesenseSearchEngine.php: -------------------------------------------------------------------------------- 1 | 21 | */ 22 | class TypesenseSearchEngine extends Engine 23 | { 24 | /** 25 | * @var \Devloops\LaravelTypesense\Typesense 26 | */ 27 | private Typesense $typesense; 28 | 29 | /** 30 | * @var array 31 | */ 32 | private array $groupBy = []; 33 | 34 | /** 35 | * @var int 36 | */ 37 | private int $groupByLimit = 3; 38 | 39 | /** 40 | * @var string 41 | */ 42 | private string $startTag = ''; 43 | 44 | /** 45 | * @var string 46 | */ 47 | private string $endTag = ''; 48 | 49 | /** 50 | * @var int 51 | */ 52 | private int $limitHits = -1; 53 | 54 | /** 55 | * @var array 56 | */ 57 | private array $locationOrderBy = []; 58 | 59 | /** 60 | * TypesenseSearchEngine constructor. 61 | * 62 | * @param \Devloops\LaravelTypesense\Typesense $typesense 63 | */ 64 | public function __construct(Typesense $typesense) 65 | { 66 | $this->typesense = $typesense; 67 | } 68 | 69 | /** 70 | * @param \Illuminate\Database\Eloquent\Collection|Model[] $models 71 | * 72 | * @throws \Http\Client\Exception 73 | * @throws \JsonException 74 | * @throws \Typesense\Exceptions\TypesenseClientError 75 | * @noinspection NotOptimalIfConditionsInspection 76 | */ 77 | public function update($models): void 78 | { 79 | $collection = $this->typesense->getCollectionIndex($models->first()); 80 | 81 | if ($this->usesSoftDelete($models->first()) && $models->first()->softDelete) { 82 | $models->each->pushSoftDeleteMetadata(); 83 | } 84 | 85 | $this->typesense->importDocuments($collection, $models->map(fn ($m) => $m->toSearchableArray()) 86 | ->toArray()); 87 | } 88 | 89 | /** 90 | * @param \Illuminate\Database\Eloquent\Collection $models 91 | * 92 | * @throws \Http\Client\Exception 93 | * @throws \Typesense\Exceptions\TypesenseClientError 94 | */ 95 | public function delete($models): void 96 | { 97 | $models->each(function (Model $model) { 98 | $collectionIndex = $this->typesense->getCollectionIndex($model); 99 | 100 | $this->typesense->deleteDocument($collectionIndex, $model->getScoutKey()); 101 | }); 102 | } 103 | 104 | /** 105 | * @param \Laravel\Scout\Builder $builder 106 | * 107 | * @throws \Typesense\Exceptions\TypesenseClientError 108 | * @throws \Http\Client\Exception 109 | * 110 | * @return mixed 111 | */ 112 | public function search(Builder $builder): mixed 113 | { 114 | return $this->performSearch($builder, array_filter($this->buildSearchParams($builder, 1, $builder->limit))); 115 | } 116 | 117 | /** 118 | * @param \Laravel\Scout\Builder $builder 119 | * @param int $perPage 120 | * @param int $page 121 | * 122 | * @throws \Typesense\Exceptions\TypesenseClientError 123 | * @throws \Http\Client\Exception 124 | * 125 | * @return mixed 126 | */ 127 | public function paginate(Builder $builder, $perPage, $page): mixed 128 | { 129 | return $this->performSearch($builder, array_filter($this->buildSearchParams($builder, $page, $perPage))); 130 | } 131 | 132 | /** 133 | * @param \Laravel\Scout\Builder $builder 134 | * @param int $page 135 | * @param int $perPage 136 | * 137 | * @return array 138 | */ 139 | private function buildSearchParams(Builder $builder, int $page, int $perPage): array 140 | { 141 | $params = [ 142 | 'q' => $builder->query, 143 | 'query_by' => implode(',', $builder->model->typesenseQueryBy()), 144 | 'filter_by' => $this->filters($builder), 145 | 'per_page' => $perPage, 146 | 'page' => $page, 147 | 'highlight_start_tag' => $this->startTag, 148 | 'highlight_end_tag' => $this->endTag, 149 | ]; 150 | 151 | if ($this->limitHits > 0) { 152 | $params['limit_hits'] = $this->limitHits; 153 | } 154 | 155 | if (!empty($this->groupBy)) { 156 | $params['group_by'] = implode(',', $this->groupBy); 157 | $params['group_limit'] = $this->groupByLimit; 158 | } 159 | 160 | if (!empty($this->locationOrderBy)) { 161 | $params['sort_by'] = $this->parseOrderByLocation(...$this->locationOrderBy); 162 | } 163 | 164 | if (!empty($builder->orders)) { 165 | if (!empty($params['sort_by'])) { 166 | $params['sort_by'] .= ','; 167 | } else { 168 | $params['sort_by'] = ''; 169 | } 170 | $params['sort_by'] .= $this->parseOrderBy($builder->orders); 171 | } 172 | 173 | return $params; 174 | } 175 | 176 | /** 177 | * Parse location order by for sort_by. 178 | * 179 | * @param string $column 180 | * @param float $lat 181 | * @param float $lng 182 | * @param string $direction 183 | * 184 | * @return string 185 | * @noinspection PhpPureAttributeCanBeAddedInspection 186 | */ 187 | private function parseOrderByLocation(string $column, float $lat, float $lng, string $direction = 'asc'): string 188 | { 189 | $direction = Str::lower($direction) === 'asc' ? 'asc' : 'desc'; 190 | $str = $column.'('.$lat.', '.$lng.')'; 191 | 192 | return $str.':'.$direction; 193 | } 194 | 195 | /** 196 | * Parse sort_by fields. 197 | * 198 | * @param array $orders 199 | * 200 | * @return string 201 | */ 202 | private function parseOrderBy(array $orders): string 203 | { 204 | $sortByArr = []; 205 | foreach ($orders as $order) { 206 | $sortByArr[] = $order['column'].':'.$order['direction']; 207 | } 208 | 209 | return implode(',', $sortByArr); 210 | } 211 | 212 | /** 213 | * @param \Laravel\Scout\Builder $builder 214 | * @param array $options 215 | * 216 | * @throws \Typesense\Exceptions\TypesenseClientError 217 | * @throws \Http\Client\Exception 218 | * 219 | * @return mixed 220 | */ 221 | protected function performSearch(Builder $builder, array $options = []): mixed 222 | { 223 | $documents = $this->typesense->getCollectionIndex($builder->model) 224 | ->getDocuments(); 225 | if ($builder->callback) { 226 | return call_user_func($builder->callback, $documents, $builder->query, $options); 227 | } 228 | 229 | return $documents->search($options); 230 | } 231 | 232 | /** 233 | * Prepare filters. 234 | * 235 | * @param Builder $builder 236 | * 237 | * @return string 238 | */ 239 | protected function filters(Builder $builder): string 240 | { 241 | return collect($builder->wheres) 242 | ->map([ 243 | $this, 244 | 'parseFilters', 245 | ]) 246 | ->values() 247 | ->implode(' && '); 248 | } 249 | 250 | /** 251 | * Parse typesense filters. 252 | * 253 | * @param array|string $value 254 | * @param string $key 255 | * 256 | * @return string 257 | */ 258 | public function parseFilters(array|string $value, string $key): string 259 | { 260 | if (is_array($value)) { 261 | return sprintf('%s:%s', $key, implode('', $value)); 262 | } 263 | 264 | return sprintf('%s:=%s', $key, $value); 265 | } 266 | 267 | /** 268 | * @param mixed $results 269 | * 270 | * @return \Illuminate\Support\Collection 271 | */ 272 | public function mapIds($results): Collection 273 | { 274 | return collect($results['hits']) 275 | ->pluck('document.id') 276 | ->values(); 277 | } 278 | 279 | /** 280 | * @param \Laravel\Scout\Builder $builder 281 | * @param mixed $results 282 | * @param \Illuminate\Database\Eloquent\Model $model 283 | * 284 | * @return \Illuminate\Database\Eloquent\Collection 285 | */ 286 | public function map(Builder $builder, $results, $model): \Illuminate\Database\Eloquent\Collection 287 | { 288 | if ($this->getTotalCount($results) === 0) { 289 | return $model->newCollection(); 290 | } 291 | 292 | $objectIds = collect($results['hits']) 293 | ->pluck('document.id') 294 | ->values() 295 | ->all(); 296 | 297 | $objectIdPositions = array_flip($objectIds); 298 | 299 | return $model->getScoutModelsByIds($builder, $objectIds) 300 | ->filter(static function ($model) use ($objectIds) { 301 | return in_array($model->getScoutKey(), $objectIds, false); 302 | }) 303 | ->sortBy(static function ($model) use ($objectIdPositions) { 304 | return $objectIdPositions[$model->getScoutKey()]; 305 | }) 306 | ->values(); 307 | } 308 | 309 | /** 310 | * @inheritDoc 311 | */ 312 | public function getTotalCount($results): int 313 | { 314 | return (int) ($results['found'] ?? 0); 315 | } 316 | 317 | /** 318 | * @param \Illuminate\Database\Eloquent\Model $model 319 | * 320 | * @throws \Http\Client\Exception 321 | * @throws \Typesense\Exceptions\TypesenseClientError 322 | */ 323 | public function flush($model): void 324 | { 325 | $collection = $this->typesense->getCollectionIndex($model); 326 | $collection->delete(); 327 | } 328 | 329 | /** 330 | * @param $model 331 | * 332 | * @return bool 333 | */ 334 | protected function usesSoftDelete($model): bool 335 | { 336 | return in_array(SoftDeletes::class, class_uses_recursive($model), true); 337 | } 338 | 339 | /** 340 | * @param \Laravel\Scout\Builder $builder 341 | * @param mixed $results 342 | * @param \Illuminate\Database\Eloquent\Model $model 343 | * 344 | * @return \Illuminate\Support\LazyCollection 345 | */ 346 | public function lazyMap(Builder $builder, $results, $model): LazyCollection 347 | { 348 | if ((int) ($results['found'] ?? 0) === 0) { 349 | return LazyCollection::make($model->newCollection()); 350 | } 351 | 352 | $objectIds = collect($results['hits']) 353 | ->pluck('document.id') 354 | ->values() 355 | ->all(); 356 | 357 | $objectIdPositions = array_flip($objectIds); 358 | 359 | return $model->queryScoutModelsByIds($builder, $objectIds) 360 | ->cursor() 361 | ->filter(static function ($model) use ($objectIds) { 362 | return in_array($model->getScoutKey(), $objectIds, false); 363 | }) 364 | ->sortBy(static function ($model) use ($objectIdPositions) { 365 | return $objectIdPositions[$model->getScoutKey()]; 366 | }) 367 | ->values(); 368 | } 369 | 370 | /** 371 | * @param string $name 372 | * @param array $options 373 | * 374 | * @throws \Exception 375 | * 376 | * @return void 377 | */ 378 | public function createIndex($name, array $options = []): void 379 | { 380 | throw new Exception('Typesense indexes are created automatically upon adding objects.'); 381 | } 382 | 383 | /** 384 | * You can aggregate search results into groups or buckets by specify one or more group_by fields. Separate multiple fields with a comma. 385 | * 386 | * @param mixed $groupBy 387 | * 388 | * @return $this 389 | */ 390 | public function groupBy(array $groupBy): static 391 | { 392 | $this->groupBy = $groupBy; 393 | 394 | return $this; 395 | } 396 | 397 | /** 398 | * Maximum number of hits to be returned for every group. (default: 3). 399 | * 400 | * @param int $groupByLimit 401 | * 402 | * @return $this 403 | */ 404 | public function groupByLimit(int $groupByLimit): static 405 | { 406 | $this->groupByLimit = $groupByLimit; 407 | 408 | return $this; 409 | } 410 | 411 | /** 412 | * The start tag used for the highlighted snippets. (default: ). 413 | * 414 | * @param string $startTag 415 | * 416 | * @return $this 417 | */ 418 | public function setHighlightStartTag(string $startTag): static 419 | { 420 | $this->startTag = $startTag; 421 | 422 | return $this; 423 | } 424 | 425 | /** 426 | * The end tag used for the highlighted snippets. (default: ). 427 | * 428 | * @param string $endTag 429 | * 430 | * @return $this 431 | */ 432 | public function setHighlightEndTag(string $endTag): static 433 | { 434 | $this->endTag = $endTag; 435 | 436 | return $this; 437 | } 438 | 439 | /** 440 | * Maximum number of hits that can be fetched from the collection (default: no limit). 441 | * 442 | * (page * per_page) should be less than this number for the search request to return results. 443 | * 444 | * @param int $limitHits 445 | * 446 | * @return $this 447 | */ 448 | public function limitHits(int $limitHits): static 449 | { 450 | $this->limitHits = $limitHits; 451 | 452 | return $this; 453 | } 454 | 455 | /** 456 | * Add location to order by clause. 457 | * 458 | * @param string $column 459 | * @param float $lat 460 | * @param float $lng 461 | * @param string $direction 462 | * 463 | * @return $this 464 | */ 465 | public function orderByLocation(string $column, float $lat, float $lng, string $direction): static 466 | { 467 | $this->locationOrderBy = [ 468 | 'column' => $column, 469 | 'lat' => $lat, 470 | 'lng' => $lng, 471 | 'direction' => $direction, 472 | ]; 473 | 474 | return $this; 475 | } 476 | 477 | /** 478 | * @param string $name 479 | * 480 | * @throws \Typesense\Exceptions\ObjectNotFound 481 | * @throws \Typesense\Exceptions\TypesenseClientError 482 | * @throws \Http\Client\Exception 483 | * 484 | * @return array 485 | */ 486 | public function deleteIndex($name): array 487 | { 488 | return $this->typesense->deleteCollection($name); 489 | } 490 | } 491 | -------------------------------------------------------------------------------- /src/Interfaces/TypesenseSearch.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | class BuilderMixin 17 | { 18 | /** 19 | * @return \Closure 20 | */ 21 | public function count(): Closure 22 | { 23 | return function () { 24 | return $this->engine() 25 | ->getTotalCount($this->engine() 26 | ->search($this)); 27 | }; 28 | } 29 | 30 | /** 31 | * @param string $column 32 | * @param float $lat 33 | * @param float $lng 34 | * @param string $direction 35 | * 36 | * @return \Closure 37 | */ 38 | public function orderByLocation(): Closure 39 | { 40 | return function (string $column, float $lat, float $lng, string $direction = 'asc') { 41 | $this->engine() 42 | ->orderByLocation($column, $lat, $lng, $direction); 43 | 44 | return $this; 45 | }; 46 | } 47 | 48 | /** 49 | * @param array|string $groupBy 50 | * 51 | * @return \Closure 52 | */ 53 | public function groupBy(): Closure 54 | { 55 | return function (array|string $groupBy) { 56 | $groupBy = is_array($groupBy) ? $groupBy : func_get_args(); 57 | $this->engine() 58 | ->groupBy($groupBy); 59 | 60 | return $this; 61 | }; 62 | } 63 | 64 | /** 65 | * @param int $groupByLimit 66 | * 67 | * @return \Closure 68 | */ 69 | public function groupByLimit(): Closure 70 | { 71 | return function (int $groupByLimit) { 72 | $this->engine() 73 | ->groupByLimit($groupByLimit); 74 | 75 | return $this; 76 | }; 77 | } 78 | 79 | /** 80 | * @param string $startTag 81 | * 82 | * @return \Closure 83 | */ 84 | public function setHighlightStartTag(): Closure 85 | { 86 | return function (string $startTag) { 87 | $this->engine() 88 | ->setHighlightStartTag($startTag); 89 | 90 | return $this; 91 | }; 92 | } 93 | 94 | /** 95 | * @param string $endTag 96 | * 97 | * @return \Closure 98 | */ 99 | public function setHighlightEndTag(): Closure 100 | { 101 | return function (string $endTag) { 102 | $this->engine() 103 | ->setHighlightEndTag($endTag); 104 | 105 | return $this; 106 | }; 107 | } 108 | 109 | /** 110 | * @param int $limitHits 111 | * 112 | * @return \Closure 113 | */ 114 | public function limitHits(): Closure 115 | { 116 | return function (int $limitHits) { 117 | $this->engine() 118 | ->limitHits($limitHits); 119 | 120 | return $this; 121 | }; 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/Typesense.php: -------------------------------------------------------------------------------- 1 | 17 | */ 18 | class Typesense 19 | { 20 | /** 21 | * @var \Typesense\Client 22 | */ 23 | private Client $client; 24 | 25 | /** 26 | * Typesense constructor. 27 | * 28 | * @param \Typesense\Client $client 29 | */ 30 | public function __construct(Client $client) 31 | { 32 | $this->client = $client; 33 | } 34 | 35 | /** 36 | * @return \Typesense\Client 37 | */ 38 | public function getClient(): Client 39 | { 40 | return $this->client; 41 | } 42 | 43 | /** 44 | * @param $model 45 | * 46 | * @throws \Typesense\Exceptions\TypesenseClientError 47 | * @throws \Http\Client\Exception 48 | * 49 | * @return \Typesense\Collection 50 | */ 51 | private function getOrCreateCollectionFromModel($model): Collection 52 | { 53 | $index = $this->client->getCollections()->{$model->searchableAs()}; 54 | 55 | try { 56 | $index->retrieve(); 57 | 58 | return $index; 59 | } catch (ObjectNotFound $exception) { 60 | $this->client->getCollections() 61 | ->create($model->getCollectionSchema()); 62 | 63 | return $this->client->getCollections()->{$model->searchableAs()}; 64 | } 65 | } 66 | 67 | /** 68 | * @param $model 69 | * 70 | * @throws \Typesense\Exceptions\TypesenseClientError 71 | * @throws \Http\Client\Exception 72 | * 73 | * @return \Typesense\Collection 74 | */ 75 | public function getCollectionIndex($model): Collection 76 | { 77 | return $this->getOrCreateCollectionFromModel($model); 78 | } 79 | 80 | /** 81 | * @param \Typesense\Collection $collectionIndex 82 | * @param $array 83 | * 84 | * @throws \Typesense\Exceptions\ObjectNotFound 85 | * @throws \Typesense\Exceptions\TypesenseClientError 86 | * @throws \Http\Client\Exception 87 | * 88 | * @return \Devloops\LaravelTypesense\Classes\TypesenseDocumentIndexResponse 89 | */ 90 | public function upsertDocument(Collection $collectionIndex, $array): TypesenseDocumentIndexResponse 91 | { 92 | /** 93 | * @var $document Document 94 | */ 95 | $document = $collectionIndex->getDocuments()[$array['id']] ?? null; 96 | if ($document === null) { 97 | throw new ObjectNotFound(); 98 | } 99 | 100 | try { 101 | $document->retrieve(); 102 | $document->delete(); 103 | 104 | return new TypesenseDocumentIndexResponse(200, true, null, $collectionIndex->getDocuments() 105 | ->create($array)); 106 | } catch (ObjectNotFound) { 107 | return new TypesenseDocumentIndexResponse(200, true, null, $collectionIndex->getDocuments() 108 | ->create($array)); 109 | } 110 | } 111 | 112 | /** 113 | * @param \Typesense\Collection $collectionIndex 114 | * @param $modelId 115 | * 116 | * @throws \Typesense\Exceptions\ObjectNotFound 117 | * @throws \Typesense\Exceptions\TypesenseClientError 118 | * @throws \Http\Client\Exception 119 | * 120 | * @return array 121 | */ 122 | public function deleteDocument(Collection $collectionIndex, $modelId): array 123 | { 124 | /** 125 | * @var $document Document 126 | */ 127 | $document = $collectionIndex->getDocuments()[(string) $modelId] ?? null; 128 | if ($document === null) { 129 | throw new ObjectNotFound(); 130 | } 131 | 132 | return $document->delete(); 133 | } 134 | 135 | /** 136 | * @param \Typesense\Collection $collectionIndex 137 | * @param array $query 138 | * 139 | * @throws \Typesense\Exceptions\TypesenseClientError 140 | * @throws \Http\Client\Exception 141 | * 142 | * @return array 143 | */ 144 | public function deleteDocuments(Collection $collectionIndex, array $query): array 145 | { 146 | return $collectionIndex->getDocuments() 147 | ->delete($query); 148 | } 149 | 150 | /** 151 | * @param \Typesense\Collection $collectionIndex 152 | * @param $documents 153 | * @param string $action 154 | * 155 | * @throws \JsonException 156 | * @throws \Typesense\Exceptions\TypesenseClientError 157 | * @throws \Http\Client\Exception 158 | * 159 | * @return \Illuminate\Support\Collection 160 | */ 161 | public function importDocuments(Collection $collectionIndex, $documents, string $action = 'upsert'): \Illuminate\Support\Collection 162 | { 163 | $importedDocuments = $collectionIndex->getDocuments() 164 | ->import($documents, ['action' => $action]); 165 | $result = []; 166 | foreach ($importedDocuments as $importedDocument) { 167 | $result[] = new TypesenseDocumentIndexResponse($importedDocument['code'] ?? 0, $importedDocument['success'], $importedDocument['error'] ?? null, json_decode($importedDocument['document'] ?? '[]', true, 512, JSON_THROW_ON_ERROR)); 168 | } 169 | 170 | return collect($result); 171 | } 172 | 173 | /** 174 | * @param string $collectionName 175 | * 176 | * @throws \Typesense\Exceptions\ObjectNotFound 177 | * @throws \Typesense\Exceptions\TypesenseClientError 178 | * @throws \Http\Client\Exception 179 | * 180 | * @return array 181 | */ 182 | public function deleteCollection(string $collectionName): array 183 | { 184 | $index = $this->client->getCollections()->{$collectionName} ?? null; 185 | if ($index === null) { 186 | throw new ObjectNotFound(); 187 | } 188 | 189 | return $index->delete(); 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /src/TypesenseFacade.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | class TypesenseFacade extends Facade 15 | { 16 | /** 17 | * @return string 18 | */ 19 | public static function getFacadeAccessor(): string 20 | { 21 | return 'typesense'; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/TypesenseServiceProvider.php: -------------------------------------------------------------------------------- 1 | 19 | */ 20 | class TypesenseServiceProvider extends ServiceProvider 21 | { 22 | /** 23 | * @throws \ReflectionException 24 | * @throws \Illuminate\Contracts\Container\BindingResolutionException 25 | */ 26 | public function boot(): void 27 | { 28 | $this->app[EngineManager::class]->extend('typesense', static function ($app) { 29 | $client = new Client(Config::get('scout.typesense')); 30 | 31 | return new TypesenseSearchEngine(new Typesense($client)); 32 | }); 33 | 34 | $this->registerMacros(); 35 | } 36 | 37 | /** 38 | * Register singletons and aliases. 39 | */ 40 | public function register(): void 41 | { 42 | $this->app->singleton(Typesense::class, static function () { 43 | $client = new Client(Config::get('scout.typesense')); 44 | 45 | return new Typesense($client); 46 | }); 47 | 48 | $this->app->alias(Typesense::class, 'typesense'); 49 | } 50 | 51 | /** 52 | * @throws \ReflectionException 53 | * @throws \Illuminate\Contracts\Container\BindingResolutionException 54 | */ 55 | private function registerMacros(): void 56 | { 57 | Builder::mixin($this->app->make(BuilderMixin::class)); 58 | } 59 | } 60 | --------------------------------------------------------------------------------