├── .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 | [](https://packagist.org/packages/devloopsnet/laravel-typesense) 
11 |
12 | [](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) [](https://packagist.org/packages/devloopsnet/laravel-typesense) [](https://packagist.org/packages/devloopsnet/laravel-typesense) [](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 |
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 |
--------------------------------------------------------------------------------