├── .github
└── workflows
│ ├── pull-request.yml
│ └── scout-import.yml
├── .gitignore
├── LICENSE
├── README.md
├── composer.json
├── config
└── scout.php
├── phpunit.xml
├── src
├── Classes
│ └── TypesenseDocumentIndexResponse.php
├── Concerns
│ └── HasScopedApiKey.php
├── Engines
│ └── TypesenseEngine.php
├── Interfaces
│ └── TypesenseDocument.php
├── Mixin
│ └── BuilderMixin.php
├── Typesense.php
├── TypesenseFacade.php
└── TypesenseServiceProvider.php
└── tests
├── Feature
├── MultiSearchTest.php
├── PaginateTest.php
└── SearchableTest.php
├── Fixtures
└── SearchableUserModel.php
└── TestCase.php
/.github/workflows/pull-request.yml:
--------------------------------------------------------------------------------
1 | name: Run Package Tests
2 |
3 | on:
4 | pull_request:
5 | branches: [master]
6 |
7 | jobs:
8 | tests:
9 | runs-on: ubuntu-latest
10 | timeout-minutes: 15
11 | strategy:
12 | matrix:
13 | node-version: [16.x]
14 | typesense-version: [0.24.0]
15 | steps:
16 | - name: Checkout code
17 | uses: actions/checkout@v2
18 | with:
19 | submodules: 'recursive'
20 |
21 | - name: Start Typesense
22 | uses: jirevwe/typesense-github-action@v1.0.1
23 | with:
24 | typesense-version: ${{ matrix.typesense-version }}
25 | typesense-port: 8108
26 | typesense-api-key: xyz
27 |
28 | - name: Composer Validate
29 | run: composer validate
30 |
31 | - name: Cache Composer Packages
32 | id: composer-cache
33 | uses: actions/cache@v2
34 | with:
35 | path: vendor
36 | key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }}
37 | restore-keys: |
38 | ${{ runner.os }}-php-
39 |
40 | - name: Cache dependencies
41 | uses: actions/cache@v2
42 | with:
43 | path: |
44 | ~/.npm
45 | key: ${{ runner.os }}-node-${{ matrix.node-version }}-${{ hashFiles('**/package-lock.json') }}
46 |
47 | - name: Install Dependencies
48 | run: |
49 | composer update --prefer-dist --no-interaction --no-progress
50 |
51 | - name: Run Tests
52 | run: vendor/bin/phpunit tests
53 |
--------------------------------------------------------------------------------
/.github/workflows/scout-import.yml:
--------------------------------------------------------------------------------
1 | name: Run Project Tests
2 |
3 | on:
4 | pull_request:
5 | branches: [master]
6 |
7 | jobs:
8 | scout-import-test:
9 | runs-on: ubuntu-latest
10 | timeout-minutes: 15
11 | strategy:
12 | matrix:
13 | node-version: [16.x]
14 | typesense-version: [0.24.0]
15 |
16 | steps:
17 | - name: Start Typesense
18 | uses: jirevwe/typesense-github-action@v1.0.1
19 | with:
20 | typesense-version: ${{ matrix.typesense-version }}
21 | typesense-port: 8108
22 | typesense-api-key: xyz
23 |
24 | - name: Curl Typesense
25 | run: sleep 10 && curl http://localhost:8108/health
26 |
27 | - name: Install MySQL client
28 | run: sudo apt-get update && sudo apt-get install mysql-client
29 |
30 | - name: Run MySQL client
31 | run: sudo service mysql start
32 |
33 | - name: Run SQL script
34 | run: |
35 | mysql -h 127.0.0.1 -u root -p'root' -e "CREATE DATABASE test;"
36 |
37 | - name: Checkout repository
38 | uses: actions/checkout@v2
39 | with:
40 | repository: arayiksmbatyan/scout-import-tests
41 |
42 | - name: Composer Validate
43 | run: composer validate
44 |
45 | - name: Cache Composer Packages
46 | id: composer-cache
47 | uses: actions/cache@v2
48 | with:
49 | path: vendor
50 | key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }}
51 | restore-keys: |
52 | ${{ runner.os }}-php-
53 |
54 | - name: Cache dependencies
55 | uses: actions/cache@v2
56 | with:
57 | path: |
58 | ~/.npm
59 | key: ${{ runner.os }}-node-${{ matrix.node-version }}-${{ hashFiles('**/package-lock.json') }}
60 |
61 | - name: Prepare Application Environment
62 | run: |
63 | cp .env.example .env
64 |
65 | - name: Install Dependencies
66 | run: |
67 | composer update --prefer-dist --no-interaction --no-progress
68 |
69 | - name: Generate Application Key
70 | run: php artisan key:generate
71 |
72 | - name: Run Migrations and Seeders
73 | run: php artisan migrate --seed
74 |
75 | - name: Run Tests
76 | run: php artisan test
77 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .idea
2 | .tmp
3 | /composer.lock
4 | vendor
5 | .phpunit.result.cache
6 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021-present Typesense, Inc
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Laravel Scout Typesense Driver
2 |
3 | This package makes it easy to add full text search support to your models with Laravel 7.\* to 11.\*.
4 |
5 | > [!IMPORTANT]
6 | > The features from the Scout driver in this repo have been merged upstream into [Laravel Scout natively](https://laravel.com/docs/11.x/scout#typesense).
7 | >
8 | > So we've temporarily paused development in this repo and plan to instead address any issues or improvements in the native [Laravel Scout](https://github.com/laravel/scout) driver instead.
9 | >
10 | > If there are any Typesense-specific features that would be hard to implement in Laravel Scout natively (since we need to maintain consistency with all the other drivers), then at that point we plan to add those features into this driver and maintain it as a "Scout Extended Driver" of sorts. But it's too early to tell if we'd want to do this, so we're in a holding pattern on this repo for now.
11 | >
12 | > In the meantime, we recommend switching to the native Laravel Scout driver and report any issues in the [Laravel Scout repo](https://github.com/laravel/scout).
13 |
14 |
19 |
20 | ## Contents
21 |
22 | - [Installation](#installation)
23 | - [Usage](#usage)
24 | - [Migrating from devloopsnet/laravel-typesense](#migrating-from-devloopsnetlaravel-typesense)
25 | - [Authors](#authors)
26 | - [License](#license)
27 |
28 |
29 | ## Installation
30 | The Typesense PHP SDK uses httplug to interface with various PHP HTTP libraries through a single API.
31 |
32 | First, install the correct httplug adapter based on your `guzzlehttp/guzzle` version. For example, if you're on
33 | Laravel 8, which includes Guzzle 7, then run this:
34 |
35 | ```bash
36 | composer require php-http/guzzle7-adapter
37 | ```
38 |
39 | Then install the driver:
40 |
41 | ```bash
42 | composer require typesense/laravel-scout-typesense-driver
43 | ```
44 |
45 | And add the service provider:
46 |
47 | ```php
48 | // config/app.php
49 | 'providers' => [
50 | // ...
51 | Typesense\LaravelTypesense\TypesenseServiceProvider::class,
52 | ],
53 | ```
54 |
55 | Ensure you have Laravel Scout as a provider too otherwise you will get an "unresolvable dependency" error
56 |
57 | ```php
58 | // config/app.php
59 | 'providers' => [
60 | // ...
61 | Laravel\Scout\ScoutServiceProvider::class,
62 | ],
63 | ```
64 |
65 | Add `SCOUT_DRIVER=typesense` to your `.env` file
66 |
67 | Then you should publish `scout.php` configuration file to your config directory
68 |
69 | ```bash
70 | php artisan vendor:publish --provider="Laravel\Scout\ScoutServiceProvider"
71 | ```
72 |
73 | In your `config/scout.php` add:
74 |
75 | ```php
76 |
77 | 'typesense' => [
78 | 'api_key' => 'abcd',
79 | 'nodes' => [
80 | [
81 | 'host' => 'localhost',
82 | 'port' => '8108',
83 | 'path' => '',
84 | 'protocol' => 'http',
85 | ],
86 | ],
87 | 'nearest_node' => [
88 | 'host' => 'localhost',
89 | 'port' => '8108',
90 | 'path' => '',
91 | 'protocol' => 'http',
92 | ],
93 | 'connection_timeout_seconds' => 2,
94 | 'healthcheck_interval_seconds' => 30,
95 | 'num_retries' => 3,
96 | 'retry_interval_seconds' => 1,
97 | ],
98 | ```
99 |
100 | ## Usage
101 |
102 | If you are unfamiliar with Laravel Scout, we suggest reading it's [documentation](https://laravel.com/docs/11.x/scout) first.
103 |
104 | After you have installed scout and the Typesense driver, you need to add the
105 | `Searchable` trait to your models that you want to make searchable. Additionaly,
106 | define the fields you want to make searchable by defining the `toSearchableArray` method on the model and implement `TypesenseSearch`:
107 |
108 | ```php
109 | toArray(),
130 | [
131 | // Cast id to string and turn created_at into an int32 timestamp
132 | // in order to maintain compatibility with the Typesense index definition below
133 | 'id' => (string) $this->id,
134 | 'created_at' => $this->created_at->timestamp,
135 | ]
136 | );
137 | }
138 |
139 | /**
140 | * The Typesense schema to be created.
141 | *
142 | * @return array
143 | */
144 | public function getCollectionSchema(): array {
145 | return [
146 | 'name' => $this->searchableAs(),
147 | 'fields' => [
148 | [
149 | 'name' => 'id',
150 | 'type' => 'string',
151 | ],
152 | [
153 | 'name' => 'name',
154 | 'type' => 'string',
155 | ],
156 | [
157 | 'name' => 'created_at',
158 | 'type' => 'int64',
159 | ],
160 | ],
161 | 'default_sorting_field' => 'created_at',
162 | ];
163 | }
164 |
165 | /**
166 | * The fields to be queried against. See https://typesense.org/docs/0.24.0/api/search.html.
167 | *
168 | * @return array
169 | */
170 | public function typesenseQueryBy(): array {
171 | return [
172 | 'name',
173 | ];
174 | }
175 | }
176 | ```
177 |
178 | Then, sync the data with the search service like:
179 |
180 | `php artisan scout:import App\\Models\\Todo`
181 |
182 | After that you can search your models with:
183 |
184 | `Todo::search('Test')->get();`
185 |
186 | ## Adding via Query
187 | The `searchable()` method will chunk the results of the query and add the records to your search index. Examples:
188 |
189 | ```php
190 | $todo = Todo::find(1);
191 | $todo->searchable();
192 |
193 | $todos = Todo::where('created_at', '<', now())->get();
194 | $todos->searchable();
195 | ```
196 |
197 | ### Multi Search
198 | You can send multiple search requests in a single HTTP request, using the Multi-Search feature.
199 | ```php
200 | $searchRequests = [
201 | [
202 | 'collection' => 'todo',
203 | 'q' => 'todo'
204 | ],
205 | [
206 | 'collection' => 'todo',
207 | 'q' => 'foo'
208 | ]
209 | ];
210 |
211 | Todo::search('')->searchMulti($searchRequests)->paginateRaw();
212 | ```
213 |
214 | ### Generate Scoped Search Key
215 |
216 | You can generate scoped search API keys that have embedded search parameters in them. This is useful in a few different scenarios:
217 | 1. You can index data from multiple users/customers in a single Typesense collection (aka multi-tenancy) and create scoped search keys with embedded `filter_by` parameters that only allow users access to their own subset of data.
218 | 2. You can embed any [search parameters](https://typesense.org/docs/0.24.0/api/search.html#search-parameters) (for eg: `exclude_fields` or `limit_hits`) to prevent users from being able to modify it client-side.
219 |
220 | When you use these scoped search keys in a search API call, the parameters you embedded in them will be automatically applied by Typesense and users will not be able to override them.
221 |
222 | ```php
223 | search('todo')->get();
241 | ```
242 |
243 | ## Migrating from devloopsnet/laravel-typesense
244 | - Replace `devloopsnet/laravel-typesense` in your composer.json requirements with `typesense/laravel-scout-typesense-driver`
245 | - The Scout driver is now called `typesense`, instead of `typesensesearch`. This should be reflected by setting the SCOUT_DRIVER env var to `typesense`,
246 | and changing the config/scout.php config key from `typesensesearch` to `typesense`
247 | - Instead of importing `Devloops\LaravelTypesense\*`, you should import `Typesense\LaravelTypesense\*`
248 | - Instead of models implementing `Devloops\LaravelTypesense\Interfaces\TypesenseSearch`, they should implement `Typesense\LaravelTypesense\Interfaces\TypesenseDocument`
249 |
250 | ## Authors
251 | This package was originally authored by [Abdullah Al-Faqeir](https://github.com/AbdullahFaqeir) and his company DevLoops: https://github.com/devloopsnet/laravel-scout-typesense-engine. It has since been adopted into the Typesense Github org.
252 |
253 | Other key contributors include:
254 |
255 | - [hi019](https://github.com/hi019)
256 | - [Philip Manavopoulos](https://github.com/manavo)
257 |
258 | ## License
259 |
260 | The MIT License (MIT). Please see the [License File](LICENSE.md) for more information.
261 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "typesense/laravel-scout-typesense-driver",
3 | "description": "Laravel Scout Driver for Typesense",
4 | "keywords": [
5 | "laravel",
6 | "typesense",
7 | "search"
8 | ],
9 | "type": "library",
10 | "homepage": "https://typesense.org",
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 | "name": "hi019",
21 | "homepage": "https://github.com/hi019",
22 | "role": "Developer"
23 | }
24 | ],
25 | "minimum-stability": "stable",
26 | "autoload": {
27 | "psr-4": {
28 | "Typesense\\LaravelTypesense\\": "src/",
29 | "Typesense\\LaravelTypesense\\Tests\\": "tests/"
30 | }
31 | },
32 | "extra": {
33 | "laravel": {
34 | "providers": [
35 | "Typesense\\LaravelTypesense\\TypesenseServiceProvider"
36 | ],
37 | "aliases": {
38 | "Typesense": "Typesense\\LaravelTypesense\\TypesenseFacade"
39 | }
40 | }
41 | },
42 | "require": {
43 | "php": "^8.0",
44 | "laravel/scout": "^8.0|^9.0|^10.0",
45 | "illuminate/bus": "^7.0|^8.0|^9.0|^10.0|^11.0",
46 | "illuminate/contracts": "^7.0|^8.0|^9.0|^10.0|^11.0",
47 | "illuminate/database": "^7.0|^8.0|^9.0|^10.0|^11.0",
48 | "illuminate/pagination": "^7.0|^8.0|^9.0|^10.0|^11.0",
49 | "illuminate/queue": "^7.0|^8.0|^9.0|^10.0|^11.0",
50 | "illuminate/support": "^7.0|^8.0|^9.0|^10.0|^11.0",
51 | "typesense/typesense-php": "^4.2"
52 | },
53 | "config": {
54 | "platform": {
55 | "php": "8.0"
56 | },
57 | "allow-plugins": {
58 | "pestphp/pest-plugin": true,
59 | "php-http/discovery": true
60 | }
61 | },
62 | "suggest": {
63 | "typesense/typesense-php": "Required to use the Typesense php client."
64 | },
65 | "require-dev": {
66 | "phpunit/phpunit": "^8.0|^9.0|^10.5",
67 | "mockery/mockery": "^1.3",
68 | "orchestra/testbench": "^6.17|^7.0|^8.0|^9.0",
69 | "symfony/http-client": "^5.4|^7.0"
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/config/scout.php:
--------------------------------------------------------------------------------
1 | env('SCOUT_DRIVER', 'typesense'),
19 |
20 | /*
21 | |--------------------------------------------------------------------------
22 | | Index Prefix
23 | |--------------------------------------------------------------------------
24 | |
25 | | Here you may specify a prefix that will be applied to all search index
26 | | names used by Scout. This prefix may be useful if you have multiple
27 | | "tenants" or applications sharing the same search infrastructure.
28 | |
29 | */
30 |
31 | 'prefix' => env('SCOUT_PREFIX', ''),
32 |
33 | /*
34 | |--------------------------------------------------------------------------
35 | | Queue Data Syncing
36 | |--------------------------------------------------------------------------
37 | |
38 | | This option allows you to control if the operations that sync your data
39 | | with your search engines are queued. When this is set to "true" then
40 | | all automatic data syncing will get queued for better performance.
41 | |
42 | */
43 |
44 | 'queue' => env('SCOUT_QUEUE', false),
45 |
46 | /*
47 | |--------------------------------------------------------------------------
48 | | Database Transactions
49 | |--------------------------------------------------------------------------
50 | |
51 | | This configuration option determines if your data will only be synced
52 | | with your search indexes after every open database transaction has
53 | | been committed, thus preventing any discarded data from syncing.
54 | |
55 | */
56 |
57 | 'after_commit' => false,
58 |
59 | /*
60 | |--------------------------------------------------------------------------
61 | | Chunk Sizes
62 | |--------------------------------------------------------------------------
63 | |
64 | | These options allow you to control the maximum chunk size when you are
65 | | mass importing data into the search engine. This allows you to fine
66 | | tune each of these chunk sizes based on the power of the servers.
67 | |
68 | */
69 |
70 | 'chunk' => [
71 | 'searchable' => 500,
72 | 'unsearchable' => 500,
73 | ],
74 |
75 | /*
76 | |--------------------------------------------------------------------------
77 | | Soft Deletes
78 | |--------------------------------------------------------------------------
79 | |
80 | | This option allows to control whether to keep soft deleted records in
81 | | the search indexes. Maintaining soft deleted records can be useful
82 | | if your application still needs to search for the records later.
83 | |
84 | */
85 |
86 | 'soft_delete' => false,
87 |
88 | /*
89 | |--------------------------------------------------------------------------
90 | | Identify User
91 | |--------------------------------------------------------------------------
92 | |
93 | | This option allows you to control whether to notify the search engine
94 | | of the user performing the search. This is sometimes useful if the
95 | | engine supports any analytics based on this application's users.
96 | |
97 | | Supported engines: "algolia"
98 | |
99 | */
100 |
101 | 'identify' => env('SCOUT_IDENTIFY', false),
102 |
103 | /*
104 | |--------------------------------------------------------------------------
105 | | Algolia Configuration
106 | |--------------------------------------------------------------------------
107 | |
108 | | Here you may configure your Algolia settings. Algolia is a cloud hosted
109 | | search engine which works great with Scout out of the box. Just plug
110 | | in your application ID and admin API key to get started searching.
111 | |
112 | */
113 |
114 | 'algolia' => [
115 | 'id' => env('ALGOLIA_APP_ID', ''),
116 | 'secret' => env('ALGOLIA_SECRET', ''),
117 | ],
118 |
119 | /*
120 | |--------------------------------------------------------------------------
121 | | MeiliSearch Configuration
122 | |--------------------------------------------------------------------------
123 | |
124 | | Here you may configure your MeiliSearch settings. MeiliSearch is an open
125 | | source search engine with minimal configuration. Below, you can state
126 | | the host and key information for your own MeiliSearch installation.
127 | |
128 | | See: https://docs.meilisearch.com/guides/advanced_guides/configuration.html
129 | |
130 | */
131 |
132 | 'meilisearch' => [
133 | 'host' => env('MEILISEARCH_HOST', 'http://localhost:7700'),
134 | 'key' => env('MEILISEARCH_KEY', null),
135 | 'index-settings' => [
136 | // 'users' => [
137 | // 'filterableAttributes'=> ['id', 'name', 'email'],
138 | // ],
139 | ],
140 | ],
141 |
142 | 'typesense' => [
143 | 'api_key' => 'xyz',
144 | 'nodes' => [
145 | [
146 | 'host' => 'localhost',
147 | 'port' => '8108',
148 | 'path' => '',
149 | 'protocol' => 'http',
150 | ],
151 | ],
152 | 'nearest_node' => [
153 | 'host' => 'localhost',
154 | 'port' => '8108',
155 | 'path' => '',
156 | 'protocol' => 'http',
157 | ],
158 | 'connection_timeout_seconds' => 2,
159 | 'healthcheck_interval_seconds' => 30,
160 | 'num_retries' => 3,
161 | 'retry_interval_seconds' => 1,
162 | ],
163 | ];
164 |
--------------------------------------------------------------------------------
/phpunit.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
9 | ./tests/Feature
10 |
11 |
12 |
13 |
14 | ./app
15 | ./src
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
--------------------------------------------------------------------------------
/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/Concerns/HasScopedApiKey.php:
--------------------------------------------------------------------------------
1 | setScopedApiKey($key);
16 |
17 | return new static;
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/src/Engines/TypesenseEngine.php:
--------------------------------------------------------------------------------
1 |
22 | */
23 | class TypesenseEngine extends Engine
24 | {
25 | /**
26 | * @var Typesense
27 | */
28 | private Typesense $typesense;
29 |
30 | /**
31 | * @var array
32 | */
33 | private array $groupBy = [];
34 |
35 | /**
36 | * @var int
37 | */
38 | private int $groupByLimit = 3;
39 |
40 | /**
41 | * @var string
42 | */
43 | private string $startTag = '';
44 |
45 | /**
46 | * @var string
47 | */
48 | private string $endTag = '';
49 |
50 | /**
51 | * @var int
52 | */
53 | private int $limitHits = -1;
54 |
55 | /**
56 | * @var array
57 | */
58 | private array $locationOrderBy = [];
59 |
60 | /**
61 | * @var array
62 | */
63 | private array $facetBy = [];
64 |
65 | /**
66 | * @var int
67 | */
68 | private int $maxFacetValues = 10;
69 |
70 | /**
71 | * @var bool
72 | */
73 | private bool $useCache = false;
74 |
75 | /**
76 | * @var int
77 | */
78 | private int $cacheTtl = 60;
79 |
80 | /**
81 | * @var int
82 | */
83 | private int $snippetThreshold = 30;
84 |
85 | /**
86 | * @var bool
87 | */
88 | private bool $exhaustiveSearch = false;
89 |
90 | /**
91 | * @var bool
92 | */
93 | private bool $prioritizeExactMatch = true;
94 |
95 | /**
96 | * @var bool
97 | */
98 | private bool $enableOverrides = true;
99 |
100 | /**
101 | * @var int
102 | */
103 | private int $highlightAffixNumTokens = 4;
104 |
105 | /**
106 | * @var string
107 | */
108 | private string $facetQuery = '';
109 |
110 | /**
111 | * @var string
112 | */
113 | private string $infix = 'off';
114 |
115 | /**
116 | * @var array
117 | */
118 | private array $includeFields = [];
119 |
120 | /**
121 | * @var array
122 | */
123 | private array $excludeFields = [];
124 |
125 | /**
126 | * @var array
127 | */
128 | private array $highlightFields = [];
129 |
130 | /**
131 | * @var array
132 | */
133 | private array $highlightFullFields = [];
134 |
135 | /**
136 | * @var array
137 | */
138 | private array $pinnedHits = [];
139 |
140 | /**
141 | * @var array
142 | */
143 | private array $hiddenHits = [];
144 |
145 | /**
146 | * @var array
147 | */
148 | private array $optionsMulti = [];
149 |
150 | /**
151 | * @var string|null
152 | */
153 | private ?string $prefix = null;
154 |
155 | /**
156 | * TypesenseEngine constructor.
157 | *
158 | * @param Typesense $typesense
159 | */
160 | public function __construct(Typesense $typesense)
161 | {
162 | $this->typesense = $typesense;
163 | }
164 |
165 | /**
166 | * @param \Illuminate\Database\Eloquent\Collection|Model[] $models
167 | *
168 | * @throws \Http\Client\Exception
169 | * @throws \JsonException
170 | * @throws \Typesense\Exceptions\TypesenseClientError
171 | * @noinspection NotOptimalIfConditionsInspection
172 | */
173 | public function update($models): void
174 | {
175 | $collection = $this->typesense->getCollectionIndex($models->first());
176 |
177 | if ($this->usesSoftDelete($models->first()) && config('scout.soft_delete', false)) {
178 | $models->each->pushSoftDeleteMetadata();
179 | }
180 |
181 | if (!$this->usesSoftDelete($models->first()) || is_null($models->first()?->deleted_at) || config('scout.soft_delete', false)) {
182 | $this->typesense->importDocuments($collection, $models->map(fn($m) => $m->toSearchableArray())
183 | ->toArray());
184 | }
185 | }
186 |
187 | /**
188 | * @param \Illuminate\Database\Eloquent\Collection $models
189 | *
190 | * @throws \Http\Client\Exception
191 | * @throws \Typesense\Exceptions\TypesenseClientError
192 | */
193 | public function delete($models): void
194 | {
195 | $models->each(function (Model $model) {
196 | $collectionIndex = $this->typesense->getCollectionIndex($model);
197 |
198 | // TODO look into this vs $model->getKey()
199 | $this->typesense->deleteDocument($collectionIndex, $model->getScoutKey());
200 | });
201 | }
202 |
203 | /**
204 | * @param \Laravel\Scout\Builder $builder
205 | *
206 | * @return mixed
207 | * @throws \Http\Client\Exception
208 | *
209 | * @throws \Typesense\Exceptions\TypesenseClientError
210 | */
211 | public function search(Builder $builder): mixed
212 | {
213 | return $this->performSearch($builder, array_filter($this->buildSearchParams($builder, 1, $builder->limit)));
214 | }
215 |
216 | /**
217 | * @param \Laravel\Scout\Builder $builder
218 | * @param int $perPage
219 | * @param int $page
220 | *
221 | * @return mixed
222 | * @throws \Http\Client\Exception
223 | *
224 | * @throws \Typesense\Exceptions\TypesenseClientError
225 | */
226 | public function paginate(Builder $builder, $perPage, $page): mixed
227 | {
228 | return $this->performSearch($builder, array_filter($this->buildSearchParams($builder, $page, $perPage)));
229 | }
230 |
231 | /**
232 | * @param \Laravel\Scout\Builder $builder
233 | * @param int $page
234 | * @param int|null $perPage
235 | *
236 | * @return array
237 | */
238 | private function buildSearchParams(Builder $builder, int $page, int|null $perPage): array
239 | {
240 | $params = [
241 | 'q' => $builder->query,
242 | 'query_by' => implode(',', $builder->model->typesenseQueryBy()),
243 | 'filter_by' => $this->filters($builder),
244 | 'per_page' => $perPage,
245 | 'page' => $page,
246 | 'highlight_start_tag' => $this->startTag,
247 | 'highlight_end_tag' => $this->endTag,
248 | 'snippet_threshold' => $this->snippetThreshold,
249 | 'exhaustive_search' => $this->exhaustiveSearch,
250 | 'use_cache' => $this->useCache,
251 | 'cache_ttl' => $this->cacheTtl,
252 | 'prioritize_exact_match' => $this->prioritizeExactMatch,
253 | 'enable_overrides' => $this->enableOverrides,
254 | 'highlight_affix_num_tokens' => $this->highlightAffixNumTokens,
255 | 'infix' => $this->infix,
256 | ];
257 |
258 | if ($this->limitHits > 0) {
259 | $params['limit_hits'] = $this->limitHits;
260 | }
261 |
262 | if (!empty($this->groupBy)) {
263 | $params['group_by'] = implode(',', $this->groupBy);
264 | $params['group_limit'] = $this->groupByLimit;
265 | }
266 |
267 | if (!empty($this->facetBy)) {
268 | $params['facet_by'] = implode(',', $this->facetBy);
269 | $params['max_facet_values'] = $this->maxFacetValues;
270 | }
271 |
272 | if (!empty($this->facetQuery)) {
273 | $params['facet_query'] = $this->facetQuery;
274 | }
275 |
276 | if (!empty($this->includeFields)) {
277 | $params['include_fields'] = implode(',', $this->includeFields);
278 | }
279 |
280 | if (!empty($this->excludeFields)) {
281 | $params['exclude_fields'] = implode(',', $this->excludeFields);
282 | }
283 |
284 | if (!empty($this->highlightFields)) {
285 | $params['highlight_fields'] = implode(',', $this->highlightFields);
286 | }
287 |
288 | if (!empty($this->highlightFullFields)) {
289 | $params['highlight_full_fields'] = implode(',', $this->highlightFullFields);
290 | }
291 |
292 | if (!empty($this->pinnedHits)) {
293 | $params['pinned_hits'] = implode(',', $this->pinnedHits);
294 | }
295 |
296 | if (!empty($this->hiddenHits)) {
297 | $params['hidden_hits'] = implode(',', $this->hiddenHits);
298 | }
299 |
300 | if (!empty($this->locationOrderBy)) {
301 | $params['sort_by'] = $this->parseOrderByLocation(...$this->locationOrderBy);
302 | }
303 |
304 | if (!empty($builder->orders)) {
305 | if (!empty($params['sort_by'])) {
306 | $params['sort_by'] .= ',';
307 | } else {
308 | $params['sort_by'] = '';
309 | }
310 | $params['sort_by'] .= $this->parseOrderBy($builder->orders);
311 | }
312 |
313 | if (!empty($this->prefix)) {
314 | $params['prefix'] = $this->prefix;
315 | }
316 |
317 | return $params;
318 | }
319 |
320 | /**
321 | * Parse location order by for sort_by.
322 | *
323 | * @param string $column
324 | * @param float $lat
325 | * @param float $lng
326 | * @param string $direction
327 | *
328 | * @return string
329 | * @noinspection PhpPureAttributeCanBeAddedInspection
330 | */
331 | private function parseOrderByLocation(string $column, float $lat, float $lng, string $direction = 'asc'): string
332 | {
333 | $direction = Str::lower($direction) === 'asc' ? 'asc' : 'desc';
334 | $str = $column . '(' . $lat . ', ' . $lng . ')';
335 |
336 | return $str . ':' . $direction;
337 | }
338 |
339 | /**
340 | * Parse sort_by fields.
341 | *
342 | * @param array $orders
343 | *
344 | * @return string
345 | */
346 | private function parseOrderBy(array $orders): string
347 | {
348 | $sortByArr = [];
349 | foreach ($orders as $order) {
350 | $sortByArr[] = $order['column'] . ':' . $order['direction'];
351 | }
352 |
353 | return implode(',', $sortByArr);
354 | }
355 |
356 | /**
357 | * @param \Laravel\Scout\Builder $builder
358 | * @param array $options
359 | *
360 | * @return mixed
361 | * @throws \Http\Client\Exception
362 | *
363 | * @throws \Typesense\Exceptions\TypesenseClientError
364 | */
365 | protected function performSearch(Builder $builder, array $options = []): mixed
366 | {
367 | $documents = $this->typesense->getCollectionIndex($builder->model)
368 | ->getDocuments();
369 | if ($builder->callback) {
370 | return call_user_func($builder->callback, $documents, $builder->query, $options);
371 | }
372 | if(!$this->optionsMulti)
373 | {
374 | $documents = $this->typesense->getCollectionIndex($builder->model)
375 | ->getDocuments();
376 | if ($builder->callback) {
377 | return call_user_func($builder->callback, $documents, $builder->query, $options);
378 | }
379 |
380 | return $documents->search($options);
381 | } else {
382 | return $this->typesense->multiSearch(["searches" => $this->optionsMulti], $options);
383 | }
384 | }
385 |
386 | /**
387 | * Prepare filters.
388 | *
389 | * @param Builder $builder
390 | *
391 | * @return string
392 | */
393 | protected function filters(Builder $builder): string
394 | {
395 | $whereFilter = collect($builder->wheres)
396 | ->map([
397 | $this,
398 | 'parseWhereFilter',
399 | ])
400 | ->values()
401 | ->implode(' && ');
402 |
403 | $whereInFilter = collect($builder->whereIns)
404 | ->map([
405 | $this,
406 | 'parseWhereInFilter',
407 | ])
408 | ->values()
409 | ->implode(' && ');
410 |
411 | return $whereFilter . (
412 | ($whereFilter !== '' && $whereInFilter !== '') ? ' && ' : ''
413 | ) . $whereInFilter;
414 | }
415 |
416 | /**
417 | * Parse typesense where filter.
418 | *
419 | * @param array|string $value
420 | * @param string $key
421 | *
422 | * @return string
423 | */
424 | public function parseWhereFilter(array|string $value, string $key): string
425 | {
426 | if (is_array($value)) {
427 | return sprintf('%s:%s', $key, implode('', $value));
428 | }
429 |
430 | return sprintf('%s:=%s', $key, $value);
431 | }
432 |
433 | /**
434 | * Parse typesense whereIn filter.
435 | *
436 | * @param array $value
437 | * @param string $key
438 | *
439 | * @return string
440 | */
441 | public function parseWhereInFilter(array $value, string $key): string
442 | {
443 | return sprintf('%s:=%s', $key, '[' . implode(', ', $value) . ']');
444 | }
445 |
446 | /**
447 | * @param mixed $results
448 | *
449 | * @return \Illuminate\Support\Collection
450 | */
451 | public function mapIds($results): Collection
452 | {
453 | return collect($results['hits'])
454 | ->pluck('document.id')
455 | ->values();
456 | }
457 |
458 | /**
459 | * @param \Laravel\Scout\Builder $builder
460 | * @param mixed $results
461 | * @param \Illuminate\Database\Eloquent\Model $model
462 | *
463 | * @return \Illuminate\Database\Eloquent\Collection
464 | */
465 | public function map(Builder $builder, $results, $model): \Illuminate\Database\Eloquent\Collection
466 | {
467 | if ($this->getTotalCount($results) === 0) {
468 | return $model->newCollection();
469 | }
470 |
471 | $hits = isset($results['grouped_hits']) && !empty($results['grouped_hits']) ?
472 | $results['grouped_hits'] :
473 | $results['hits'];
474 | $pluck = isset($results['grouped_hits']) && !empty($results['grouped_hits']) ?
475 | 'hits.0.document.id' :
476 | 'document.id';
477 |
478 | $objectIds = collect($hits)
479 | ->pluck($pluck)
480 | ->values()
481 | ->all();
482 |
483 | $objectIdPositions = array_flip($objectIds);
484 |
485 | return $model->getScoutModelsByIds($builder, $objectIds)
486 | ->filter(static function ($model) use ($objectIds) {
487 | return in_array($model->getScoutKey(), $objectIds, false);
488 | })
489 | ->sortBy(static function ($model) use ($objectIdPositions) {
490 | return $objectIdPositions[$model->getScoutKey()];
491 | })
492 | ->values();
493 | }
494 |
495 | /**
496 | * @inheritDoc
497 | */
498 | public function getTotalCount($results): int
499 | {
500 | return (int)($results['found'] ?? 0);
501 | }
502 |
503 | /**
504 | * @param \Illuminate\Database\Eloquent\Model $model
505 | *
506 | * @throws \Http\Client\Exception
507 | * @throws \Typesense\Exceptions\TypesenseClientError
508 | */
509 | public function flush($model): void
510 | {
511 | $collection = $this->typesense->getCollectionIndex($model);
512 | $collection->delete();
513 | }
514 |
515 | /**
516 | * @param $model
517 | *
518 | * @return bool
519 | */
520 | protected function usesSoftDelete($model): bool
521 | {
522 | return in_array(SoftDeletes::class, class_uses_recursive($model), true);
523 | }
524 |
525 | /**
526 | * @param \Laravel\Scout\Builder $builder
527 | * @param mixed $results
528 | * @param \Illuminate\Database\Eloquent\Model $model
529 | *
530 | * @return \Illuminate\Support\LazyCollection
531 | */
532 | public function lazyMap(Builder $builder, $results, $model): LazyCollection
533 | {
534 | if ((int)($results['found'] ?? 0) === 0) {
535 | return LazyCollection::make($model->newCollection());
536 | }
537 |
538 | $objectIds = collect($results['hits'])
539 | ->pluck('document.id')
540 | ->values()
541 | ->all();
542 |
543 | $objectIdPositions = array_flip($objectIds);
544 |
545 | return $model->queryScoutModelsByIds($builder, $objectIds)
546 | ->cursor()
547 | ->filter(static function ($model) use ($objectIds) {
548 | return in_array($model->getScoutKey(), $objectIds, false);
549 | })
550 | ->sortBy(static function ($model) use ($objectIdPositions) {
551 | return $objectIdPositions[$model->getScoutKey()];
552 | })
553 | ->values();
554 | }
555 |
556 | /**
557 | * @param string $name
558 | * @param array $options
559 | *
560 | * @return void
561 | * @throws \Exception
562 | *
563 | */
564 | public function createIndex($name, array $options = []): void
565 | {
566 | throw new Exception('Typesense indexes are created automatically upon adding objects.');
567 | }
568 |
569 | /**
570 | * You can aggregate search results into groups or buckets by specify one or more group_by fields. Separate multiple fields with a comma.
571 | *
572 | * @param mixed $groupBy
573 | *
574 | * @return $this
575 | */
576 | public function groupBy(array $groupBy): static
577 | {
578 | $this->groupBy = $groupBy;
579 |
580 | return $this;
581 | }
582 |
583 | /**
584 | * Maximum number of hits to be returned for every group. (default: 3).
585 | *
586 | * @param int $groupByLimit
587 | *
588 | * @return $this
589 | */
590 | public function groupByLimit(int $groupByLimit): static
591 | {
592 | $this->groupByLimit = $groupByLimit;
593 |
594 | return $this;
595 | }
596 |
597 | /**
598 | * The start tag used for the highlighted snippets. (default: ).
599 | *
600 | * @param string $startTag
601 | *
602 | * @return $this
603 | */
604 | public function setHighlightStartTag(string $startTag): static
605 | {
606 | $this->startTag = $startTag;
607 |
608 | return $this;
609 | }
610 |
611 | /**
612 | * The end tag used for the highlighted snippets. (default: ).
613 | *
614 | * @param string $endTag
615 | *
616 | * @return $this
617 | */
618 | public function setHighlightEndTag(string $endTag): static
619 | {
620 | $this->endTag = $endTag;
621 |
622 | return $this;
623 | }
624 |
625 | /**
626 | * Maximum number of hits that can be fetched from the collection (default: no limit).
627 | *
628 | * (page * per_page) should be less than this number for the search request to return results.
629 | *
630 | * @param int $limitHits
631 | *
632 | * @return $this
633 | */
634 | public function limitHits(int $limitHits): static
635 | {
636 | $this->limitHits = $limitHits;
637 |
638 | return $this;
639 | }
640 |
641 | /**
642 | * A list of fields that will be used for faceting your results on. Separate multiple fields with a comma.
643 | *
644 | * @param mixed $facetBy
645 | *
646 | * @return $this
647 | */
648 | public function facetBy(array $facetBy): static
649 | {
650 | $this->facetBy = $facetBy;
651 |
652 | return $this;
653 | }
654 |
655 | /**
656 | * Maximum number of facet values to be returned.
657 | *
658 | * @param int $maxFacetValues
659 | *
660 | * @return $this
661 | */
662 | public function setMaxFacetValues(int $maxFacetValues): static
663 | {
664 | $this->maxFacetValues = $maxFacetValues;
665 |
666 | return $this;
667 | }
668 |
669 | /**
670 | * Facet values that are returned can now be filtered via this parameter.
671 | *
672 | * The matching facet text is also highlighted. For example, when faceting by category,
673 | * you can set facet_query=category:shoe to return only facet values that contain the prefix "shoe".
674 | *
675 | * @param string $facetQuery
676 | *
677 | * @return $this
678 | */
679 | public function facetQuery(string $facetQuery): static
680 | {
681 | $this->facetQuery = $facetQuery;
682 |
683 | return $this;
684 | }
685 |
686 | /**
687 | * Comma-separated list of fields from the document to include in the search result.
688 | *
689 | * @param mixed $includeFields
690 | *
691 | * @return $this
692 | */
693 | public function setIncludeFields(array $includeFields): static
694 | {
695 | $this->includeFields = $includeFields;
696 |
697 | return $this;
698 | }
699 |
700 | /**
701 | * Comma-separated list of fields from the document to exclude in the search result.
702 | *
703 | * @param mixed $excludeFields
704 | *
705 | * @return $this
706 | */
707 | public function setExcludeFields(array $excludeFields): static
708 | {
709 | $this->excludeFields = $excludeFields;
710 |
711 | return $this;
712 | }
713 |
714 | /**
715 | * Comma separated list of fields that should be highlighted with snippetting.
716 | *
717 | * You can use this parameter to highlight fields that you don't query for, as well.
718 | *
719 | * @param mixed $highlightFields
720 | *
721 | * @return $this
722 | */
723 | public function setHighlightFields(array $highlightFields): static
724 | {
725 | $this->highlightFields = $highlightFields;
726 |
727 | return $this;
728 | }
729 |
730 | /**
731 | * A list of records to unconditionally include in the search results at specific positions.
732 | *
733 | * @param mixed $pinnedHits
734 | *
735 | * @return $this
736 | */
737 | public function setPinnedHits(array $pinnedHits): static
738 | {
739 | $this->pinnedHits = $pinnedHits;
740 |
741 | return $this;
742 | }
743 |
744 | /**
745 | * A list of records to unconditionally hide from search results.
746 | *
747 | * @param mixed $hiddenHits
748 | *
749 | * @return $this
750 | */
751 | public function setHiddenHits(array $hiddenHits): static
752 | {
753 | $this->hiddenHits = $hiddenHits;
754 |
755 | return $this;
756 | }
757 |
758 | /**
759 | * Comma separated list of fields which should be highlighted fully without snippeting.
760 | *
761 | * @param mixed $highlightFullFields
762 | *
763 | * @return $this
764 | */
765 | public function setHighlightFullFields(array $highlightFullFields): static
766 | {
767 | $this->highlightFullFields = $highlightFullFields;
768 |
769 | return $this;
770 | }
771 |
772 | /**
773 | * The number of tokens that should surround the highlighted text on each side.
774 | *
775 | * This controls the length of the snippet.
776 | *
777 | * @param int $highlightAffixNumTokens
778 | *
779 | * @return $this
780 | */
781 | public function setHighlightAffixNumTokens(int $highlightAffixNumTokens): static
782 | {
783 | $this->highlightAffixNumTokens = $highlightAffixNumTokens;
784 |
785 | return $this;
786 | }
787 |
788 | /**
789 | * Set the infix search option for the field.
790 | *
791 | * @param string $infix The infix search option to enable for the field.
792 | * Possible values: "off" (disabled, default), "always" (along with regular search),
793 | * "fallback" (if regular search produces no results).
794 | * @return $this
795 | */
796 | public function setInfix(string $infix): static
797 | {
798 | $this->infix = $infix;
799 |
800 | return $this;
801 | }
802 |
803 | /**
804 | * Field values under this length will be fully highlighted, instead of showing a snippet of relevant portion.
805 | *
806 | * @param int $snippetThreshold
807 | *
808 | * @return $this
809 | */
810 | public function setSnippetThreshold(int $snippetThreshold): static
811 | {
812 | $this->snippetThreshold = $snippetThreshold;
813 |
814 | return $this;
815 | }
816 |
817 | /**
818 | * Setting this to true will make Typesense consider all variations of prefixes and typo corrections of the words
819 | *
820 | * in the query exhaustively, without stopping early when enough results are found.
821 | *
822 | * @param bool $exhaustiveSearch
823 | *
824 | * @return $this
825 | */
826 | public function exhaustiveSearch(bool $exhaustiveSearch): static
827 | {
828 | $this->exhaustiveSearch = $exhaustiveSearch;
829 |
830 | return $this;
831 | }
832 |
833 | /**
834 | * Enable server side caching of search query results. By default, caching is disabled.
835 | *
836 | * @param bool $useCache
837 | *
838 | * @return $this
839 | */
840 | public function setUseCache(bool $useCache): static
841 | {
842 | $this->useCache = $useCache;
843 |
844 | return $this;
845 | }
846 |
847 | /**
848 | * The duration (in seconds) that determines how long the search query is cached.
849 | *
850 | * @param int $cacheTtl
851 | *
852 | * @return $this
853 | */
854 | public function setCacheTtl(int $cacheTtl): static
855 | {
856 | $this->cacheTtl = $cacheTtl;
857 |
858 | return $this;
859 | }
860 |
861 | /**
862 | * By default, Typesense prioritizes documents whose field value matches exactly with the query.
863 | *
864 | * @param bool $prioritizeExactMatch
865 | *
866 | * @return $this
867 | */
868 | public function setPrioritizeExactMatch(bool $prioritizeExactMatch): static
869 | {
870 | $this->prioritizeExactMatch = $prioritizeExactMatch;
871 |
872 | return $this;
873 | }
874 |
875 | /**
876 | * Indicates that the last word in the query should be treated as a prefix, and not as a whole word.
877 | *
878 | * You can also control the behavior of prefix search on a per field basis.
879 | * For example, if you are querying 3 fields and want to enable prefix searching only on the first field, use ?prefix=true,false,false.
880 | * The order should match the order of fields in query_by.
881 | * If a single value is specified for prefix the same value is used for all fields specified in query_by.
882 | *
883 | * @param string $prefix
884 | *
885 | * @return $this
886 | */
887 | public function setPrefix(string $prefix): static
888 | {
889 | $this->prefix = $prefix;
890 |
891 | return $this;
892 | }
893 |
894 | /**
895 | * If you have some overrides defined but want to disable all of them for a particular search query
896 | *
897 | * @param bool $enableOverrides
898 | *
899 | * @return $this
900 | */
901 | public function enableOverrides(bool $enableOverrides): static
902 | {
903 | $this->enableOverrides = $enableOverrides;
904 |
905 | return $this;
906 | }
907 |
908 | /**
909 | * If you want to search multi queries in the same call
910 | *
911 | * @param array $optionsMulti
912 | *
913 | * @return $this
914 | */
915 | public function searchMulti(array $optionsMulti): static
916 | {
917 | $this->optionsMulti = $optionsMulti;
918 |
919 | return $this;
920 | }
921 |
922 | /**
923 | * Add location to order by clause.
924 | *
925 | * @param string $column
926 | * @param float $lat
927 | * @param float $lng
928 | * @param string $direction
929 | *
930 | * @return $this
931 | */
932 | public function orderByLocation(string $column, float $lat, float $lng, string $direction): static
933 | {
934 | $this->locationOrderBy = [
935 | 'column' => $column,
936 | 'lat' => $lat,
937 | 'lng' => $lng,
938 | 'direction' => $direction,
939 | ];
940 |
941 | return $this;
942 | }
943 |
944 | /**
945 | * @param string $name
946 | *
947 | * @return array
948 | * @throws \Typesense\Exceptions\TypesenseClientError
949 | * @throws \Http\Client\Exception
950 | *
951 | * @throws \Typesense\Exceptions\ObjectNotFound
952 | */
953 | public function deleteIndex($name): array
954 | {
955 | return $this->typesense->deleteCollection($name);
956 | }
957 | }
958 |
--------------------------------------------------------------------------------
/src/Interfaces/TypesenseDocument.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 | /**
125 | * @param array $facetBy
126 | *
127 | * @return \Closure
128 | */
129 | public function facetBy(): Closure
130 | {
131 | return function (array $facetBy) {
132 | $this->engine()
133 | ->facetBy($facetBy);
134 |
135 | return $this;
136 | };
137 | }
138 |
139 | /**
140 | * @param int $maxFacetValues
141 | *
142 | * @return \Closure
143 | */
144 | public function setMaxFacetValues(): Closure
145 | {
146 | return function (int $maxFacetValues) {
147 | $this->engine()
148 | ->setMaxFacetValues($maxFacetValues);
149 |
150 | return $this;
151 | };
152 | }
153 |
154 | /**
155 | * @param string $facetQuery
156 | *
157 | * @return \Closure
158 | */
159 | public function facetQuery(): Closure
160 | {
161 | return function (string $facetQuery) {
162 | $this->engine()
163 | ->facetQuery($facetQuery);
164 |
165 | return $this;
166 | };
167 | }
168 |
169 | /**
170 | * @param array $includeFields
171 | *
172 | * @return \Closure
173 | */
174 | public function setIncludeFields(): Closure
175 | {
176 | return function (array $includeFields) {
177 | $this->engine()
178 | ->setIncludeFields($includeFields);
179 |
180 | return $this;
181 | };
182 | }
183 |
184 | /**
185 | * @param array $excludeFields
186 | *
187 | * @return \Closure
188 | */
189 | public function setExcludeFields(): Closure
190 | {
191 | return function (array $excludeFields) {
192 | $this->engine()
193 | ->setExcludeFields($excludeFields);
194 |
195 | return $this;
196 | };
197 | }
198 |
199 | /**
200 | * @param array $highlightFields
201 | *
202 | * @return \Closure
203 | */
204 | public function setHighlightFields(): Closure
205 | {
206 | return function (array $highlightFields) {
207 | $this->engine()
208 | ->setHighlightFields($highlightFields);
209 |
210 | return $this;
211 | };
212 | }
213 |
214 | /**
215 | * @param array $pinnedHits
216 | *
217 | * @return \Closure
218 | */
219 | public function setPinnedHits(): Closure
220 | {
221 | return function (array $pinnedHits) {
222 | $this->engine()
223 | ->setPinnedHits($pinnedHits);
224 |
225 | return $this;
226 | };
227 | }
228 |
229 | /**
230 | * @param array $hiddenHits
231 | *
232 | * @return \Closure
233 | */
234 | public function setHiddenHits(): Closure
235 | {
236 | return function (array $hiddenHits) {
237 | $this->engine()
238 | ->setHiddenHits($hiddenHits);
239 |
240 | return $this;
241 | };
242 | }
243 |
244 | /**
245 | * @param array $highlightFullFields
246 | *
247 | * @return \Closure
248 | */
249 | public function setHighlightFullFields(): Closure
250 | {
251 | return function (array $highlightFullFields) {
252 | $this->engine()
253 | ->setHighlightFullFields($highlightFullFields);
254 |
255 | return $this;
256 | };
257 | }
258 |
259 | /**
260 | * @param int $highlightAffixNumTokens
261 | *
262 | * @return \Closure
263 | */
264 | public function setHighlightAffixNumTokens(): Closure
265 | {
266 | return function (int $highlightAffixNumTokens) {
267 | $this->engine()
268 | ->setHighlightAffixNumTokens($highlightAffixNumTokens);
269 |
270 | return $this;
271 | };
272 | }
273 |
274 | /**
275 | * @param string $infix
276 | *
277 | * @return \Closure
278 | */
279 | public function setInfix(): Closure
280 | {
281 | return function (string $infix) {
282 | $this->engine()
283 | ->setInfix($infix);
284 |
285 | return $this;
286 | };
287 | }
288 |
289 | /**
290 | * @param int $snippetThreshold
291 | *
292 | * @return \Closure
293 | */
294 | public function setSnippetThreshold(): Closure
295 | {
296 | return function (int $snippetThreshold) {
297 | $this->engine()
298 | ->setSnippetThreshold($snippetThreshold);
299 |
300 | return $this;
301 | };
302 | }
303 |
304 | /**
305 | * @param bool $exhaustiveSearch
306 | *
307 | * @return \Closure
308 | */
309 | public function exhaustiveSearch(): Closure
310 | {
311 | return function (bool $exhaustiveSearch) {
312 | $this->engine()
313 | ->exhaustiveSearch($exhaustiveSearch);
314 |
315 | return $this;
316 | };
317 | }
318 |
319 | /**
320 | * @param bool $useCache
321 | *
322 | * @return \Closure
323 | */
324 | public function setUseCache(): Closure
325 | {
326 | return function (bool $useCache) {
327 | $this->engine()
328 | ->setUseCache($useCache);
329 |
330 | return $this;
331 | };
332 | }
333 |
334 | /**
335 | * @param int $cacheTtl
336 | *
337 | * @return \Closure
338 | */
339 | public function setCacheTtl(): Closure
340 | {
341 | return function (int $cacheTtl) {
342 | $this->engine()
343 | ->setCacheTtl($cacheTtl);
344 |
345 | return $this;
346 | };
347 | }
348 |
349 | /**
350 | * @param bool $prioritizeExactMatch
351 | *
352 | * @return \Closure
353 | */
354 | public function setPrioritizeExactMatch(): Closure
355 | {
356 | return function (bool $prioritizeExactMatch) {
357 | $this->engine()
358 | ->setPrioritizeExactMatch($prioritizeExactMatch);
359 |
360 | return $this;
361 | };
362 | }
363 |
364 | /**
365 | * @param string $prefix
366 | *
367 | * @return \Closure
368 | */
369 | public function setPrefix(): Closure
370 | {
371 | return function (string $prefix) {
372 | $this->engine()
373 | ->setPrefix($prefix);
374 |
375 | return $this;
376 | };
377 | }
378 |
379 | /**
380 | * @param bool $enableOverrides
381 | *
382 | * @return \Closure
383 | */
384 | public function enableOverrides(): Closure
385 | {
386 | return function (bool $enableOverrides) {
387 | $this->engine()
388 | ->enableOverrides($enableOverrides);
389 |
390 | return $this;
391 | };
392 | }
393 |
394 | /**
395 | * @param array $searchRequests
396 | *
397 | * @return \Closure
398 | */
399 | public function searchMulti(): Closure
400 | {
401 | return function (array $searchRequests) {
402 | $this->engine()->searchMulti($searchRequests);
403 |
404 | return $this;
405 | };
406 | }
407 | }
408 |
--------------------------------------------------------------------------------
/src/Typesense.php:
--------------------------------------------------------------------------------
1 |
24 | */
25 | class Typesense
26 | {
27 | /**
28 | * @var \Typesense\Client
29 | */
30 | private Client $client;
31 |
32 | /**
33 | * Typesense constructor.
34 | *
35 | * @param \Typesense\Client $client
36 | */
37 | public function __construct(Client $client)
38 | {
39 | $this->client = $client;
40 | }
41 |
42 | /**
43 | * @return \Typesense\Client
44 | */
45 | public function getClient(): Client
46 | {
47 | return $this->client;
48 | }
49 |
50 | /**
51 | * @param $key
52 | * @return void
53 | * @throws \Illuminate\Contracts\Container\BindingResolutionException
54 | * @throws \ReflectionException
55 | * @throws \Typesense\Exceptions\ConfigError
56 | */
57 | public function setScopedApiKey($key): void
58 | {
59 | $config = Config::get('scout.typesense');
60 | $config['api_key'] = $key;
61 | $client = new Client($config);
62 |
63 | app()[EngineManager::class]->extend('typesense', static function () use ($client) {
64 | return new TypesenseEngine(new Typesense($client));
65 | });
66 | Builder::mixin(app()->make(BuilderMixin::class));
67 | }
68 |
69 | /**
70 | * @param $model
71 | *
72 | * @throws \Typesense\Exceptions\TypesenseClientError
73 | * @throws \Http\Client\Exception
74 | *
75 | * @return \Typesense\Collection
76 | */
77 | private function getOrCreateCollectionFromModel($model): Collection
78 | {
79 | $index = $this->client->getCollections()->{$model->searchableAs()};
80 |
81 | try {
82 | $index->retrieve();
83 |
84 | return $index;
85 | } catch (ObjectNotFound $exception) {
86 | $this->client->getCollections()
87 | ->create($model->getCollectionSchema());
88 |
89 | return $this->client->getCollections()->{$model->searchableAs()};
90 | }
91 | }
92 |
93 | /**
94 | * @param $model
95 | *
96 | * @throws \Typesense\Exceptions\TypesenseClientError
97 | * @throws \Http\Client\Exception
98 | *
99 | * @return \Typesense\Collection
100 | */
101 | public function getCollectionIndex($model): Collection
102 | {
103 | return $this->getOrCreateCollectionFromModel($model);
104 | }
105 |
106 | /**
107 | * @param \Typesense\Collection $collectionIndex
108 | * @param $array
109 | *
110 | * @throws \Typesense\Exceptions\ObjectNotFound
111 | * @throws \Typesense\Exceptions\TypesenseClientError
112 | * @throws \Http\Client\Exception
113 | *
114 | * @return \Typesense\LaravelTypesense\Classes\TypesenseDocumentIndexResponse
115 | */
116 | public function upsertDocument(Collection $collectionIndex, $array): TypesenseDocumentIndexResponse
117 | {
118 | /**
119 | * @var $document Document
120 | */
121 | $document = $collectionIndex->getDocuments()[$array['id']];
122 |
123 | try {
124 | $document->retrieve();
125 | $document->delete();
126 |
127 | return new TypesenseDocumentIndexResponse(200, true, null, $collectionIndex->getDocuments()
128 | ->create($array));
129 | } catch (ObjectNotFound) {
130 | return new TypesenseDocumentIndexResponse(200, true, null, $collectionIndex->getDocuments()
131 | ->create($array));
132 | }
133 | }
134 |
135 | /**
136 | * @param \Typesense\Collection $collectionIndex
137 | * @param $modelId
138 | *
139 | * @throws \Typesense\Exceptions\ObjectNotFound
140 | * @throws \Typesense\Exceptions\TypesenseClientError
141 | * @throws \Http\Client\Exception
142 | *
143 | * @return array
144 | */
145 | public function deleteDocument(Collection $collectionIndex, $modelId): array
146 | {
147 | /**
148 | * @var $document Document
149 | */
150 | $document = $collectionIndex->getDocuments()[(string) $modelId];
151 |
152 | try {
153 | $document->retrieve();
154 |
155 | return $document->delete();
156 | } catch (\Exception $exception) {
157 | return [];
158 | }
159 | }
160 |
161 | /**
162 | * @param \Typesense\Collection $collectionIndex
163 | * @param array $query
164 | *
165 | * @throws \Typesense\Exceptions\TypesenseClientError
166 | * @throws \Http\Client\Exception
167 | *
168 | * @return array
169 | */
170 | public function deleteDocuments(Collection $collectionIndex, array $query): array
171 | {
172 | return $collectionIndex->getDocuments()
173 | ->delete($query);
174 | }
175 |
176 | /**
177 | * @param \Typesense\Collection $collectionIndex
178 | * @param $documents
179 | * @param string $action
180 | *
181 | * @throws \JsonException
182 | * @throws \Typesense\Exceptions\TypesenseClientError
183 | * @throws \Http\Client\Exception
184 | *
185 | * @return \Illuminate\Support\Collection
186 | */
187 | public function importDocuments(Collection $collectionIndex, $documents, string $action = 'upsert'): \Illuminate\Support\Collection
188 | {
189 | $importedDocuments = $collectionIndex->getDocuments()
190 | ->import($documents, ['action' => $action]);
191 |
192 | $result = [];
193 | foreach ($importedDocuments as $importedDocument) {
194 | if (!$importedDocument['success']) {
195 | throw new TypesenseClientError("Error importing document: {$importedDocument['error']}");
196 | }
197 |
198 | $result[] = new TypesenseDocumentIndexResponse($importedDocument['code'] ?? 0, $importedDocument['success'], $importedDocument['error'] ?? null, json_decode($importedDocument['document'] ?? '[]', true, 512, JSON_THROW_ON_ERROR));
199 | }
200 |
201 | return collect($result);
202 | }
203 |
204 | /**
205 | * @param string $collectionName
206 | *
207 | * @throws \Typesense\Exceptions\ObjectNotFound
208 | * @throws \Typesense\Exceptions\TypesenseClientError
209 | * @throws \Http\Client\Exception
210 | *
211 | * @return array
212 | */
213 | public function deleteCollection(string $collectionName): array
214 | {
215 | return $this->client->getCollections()->{$collectionName}->delete();
216 | }
217 |
218 | /**
219 | * @param array $searchRequests
220 | * @param array $commonSearchParams
221 | *
222 | * @return array
223 | * @throws \Typesense\Exceptions\TypesenseClientError
224 | * @throws \Http\Client\Exception
225 | */
226 | public function multiSearch(array $searchRequests, array $commonSearchParams): array
227 | {
228 | return $this->client->multiSearch->perform($searchRequests, $commonSearchParams);
229 | }
230 | }
231 |
--------------------------------------------------------------------------------
/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 TypesenseEngine(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 |
--------------------------------------------------------------------------------
/tests/Feature/MultiSearchTest.php:
--------------------------------------------------------------------------------
1 | setUpFaker();
16 | $this->loadLaravelMigrations();
17 |
18 | SearchableUserModel::create([
19 | 'name' => 'Laravel Typsense',
20 | 'email' => 'typesense@example.com',
21 | 'password' => bcrypt('password'),
22 | ]);
23 | SearchableUserModel::create([
24 | 'name' => 'Laravel Typsense',
25 | 'email' => 'fake@example.com',
26 | 'password' => bcrypt('password'),
27 | ]);
28 | SearchableUserModel::create([
29 | 'name' => 'Laravel Typsense',
30 | 'email' => 'test@example.com',
31 | 'password' => bcrypt('password'),
32 | ]);
33 | }
34 |
35 | public function testSearchByEmail()
36 | {
37 | $searchRequests = [
38 | [
39 | 'collection' => 'users',
40 | 'q' => 'Laravel Typsense'
41 | ],
42 | [
43 | 'collection' => 'users',
44 | 'q' => 'typesense@example.com'
45 | ]
46 | ];
47 |
48 | $response = SearchableUserModel::search('')->searchMulti($searchRequests)->paginateRaw();
49 |
50 | $this->assertCount(2, $response->items()['results']);
51 | $this->assertEquals(3, $response->items()['results'][0]['found']);
52 | $this->assertEquals("test@example.com", $response->items()['results'][0]['hits'][0]['document']['email']);
53 |
54 | }
55 |
56 | public function testSearchByName()
57 | {
58 | $searchRequests = [
59 | [
60 | 'collection' => 'users',
61 | 'q' => 'Laravel Typsense'
62 | ],
63 | [
64 | 'collection' => 'users',
65 | 'q' => 'typesense@example.com'
66 | ]
67 | ];
68 |
69 | $response = SearchableUserModel::search('')->searchMulti($searchRequests)->paginateRaw();
70 |
71 | $this->assertCount(2, $response->items()['results']);
72 | $this->assertEquals(1, $response->items()['results'][1]['found']);
73 | $this->assertEquals("typesense@example.com", $response->items()['results'][1]['hits'][0]['document']['email']);
74 | }
75 |
76 | public function testSearchByWrongQueryParams()
77 | {
78 | $searchRequests = [
79 | [
80 | 'collection' => 'users',
81 | 'q' => 'Wrong Params'
82 | ],
83 | [
84 | 'collection' => 'users',
85 | 'q' => 'wrong@example.com'
86 | ]
87 | ];
88 |
89 | $response = SearchableUserModel::search('')->searchMulti($searchRequests)->paginateRaw();
90 | $this->assertEquals(0, $response->items()['results'][0]['found']);
91 | $this->assertEquals(0, $response->items()['results'][1]['found']);
92 | }
93 | }
94 |
--------------------------------------------------------------------------------
/tests/Feature/PaginateTest.php:
--------------------------------------------------------------------------------
1 | setUpFaker();
17 | $this->loadLaravelMigrations();
18 |
19 | SearchableUserModel::create([
20 | 'name' => 'Laravel Typsense',
21 | 'email' => 'typesense@example.com',
22 | 'password' => bcrypt('password'),
23 | ]);
24 | SearchableUserModel::create([
25 | 'name' => 'Laravel Typsense',
26 | 'email' => 'fake@example.com',
27 | 'password' => bcrypt('password'),
28 | ]);
29 | SearchableUserModel::create([
30 | 'name' => 'Laravel Typsense',
31 | 'email' => 'test@example.com',
32 | 'password' => bcrypt('password'),
33 | ]);
34 | }
35 |
36 | public function testPaginate()
37 | {
38 | $response = SearchableUserModel::search('Laravel Typsense')->paginate();
39 |
40 | $this->assertInstanceOf(LengthAwarePaginator::class, $response);
41 | $this->assertInstanceOf(SearchableUserModel::class, $response->items()[0]);
42 | $this->assertEquals(3, $response->total());
43 | $this->assertEquals(1, $response->lastPage());
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/tests/Feature/SearchableTest.php:
--------------------------------------------------------------------------------
1 | setUpFaker();
16 | $this->loadLaravelMigrations();
17 |
18 | SearchableUserModel::create([
19 | 'name' => 'Laravel Typsense',
20 | 'email' => 'typesense@example.com',
21 | 'password' => bcrypt('password'),
22 | ]);
23 | SearchableUserModel::create([
24 | 'name' => 'Laravel Typsense',
25 | 'email' => 'fake@example.com',
26 | 'password' => bcrypt('password'),
27 | ]);
28 | }
29 |
30 | public function testSearchByEmail()
31 | {
32 | $models = SearchableUserModel::search('typesense@example.com')->get();
33 |
34 | $this->assertCount(1, $models);
35 | }
36 |
37 | public function testSearchByName()
38 | {
39 | $models = SearchableUserModel::search('Laravel Typsense')->get();
40 |
41 | $this->assertCount(2, $models);
42 | }
43 |
44 | public function testSearchByWrongQueryParam()
45 | {
46 | $models = SearchableUserModel::search('test@example.com')->get();
47 |
48 | $this->assertCount(0, $models);
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/tests/Fixtures/SearchableUserModel.php:
--------------------------------------------------------------------------------
1 | toArray(),
21 | [
22 | 'id' => (string)$this->id,
23 | 'created_at' => $this->created_at->timestamp,
24 | ]
25 | );
26 | }
27 |
28 | public function getCollectionSchema(): array
29 | {
30 | return [
31 | 'name' => $this->searchableAs(),
32 | 'fields' => [
33 | [
34 | 'name' => 'id',
35 | 'type' => 'string',
36 | 'facet' => true,
37 | ],
38 | [
39 | 'name' => 'name',
40 | 'type' => 'string',
41 | 'facet' => true,
42 | ],
43 | [
44 | 'name' => 'email',
45 | 'type' => 'string',
46 | 'facet' => true,
47 | ],
48 | [
49 | 'name' => 'created_at',
50 | 'type' => 'int64',
51 | 'facet' => true,
52 | ],
53 | ],
54 | 'default_sorting_field' => 'created_at',
55 | ];
56 | }
57 |
58 | public function typesenseQueryBy(): array
59 | {
60 | return [
61 | 'name',
62 | 'email',
63 | ];
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/tests/TestCase.php:
--------------------------------------------------------------------------------
1 | singleton(EngineManager::class, function ($app) {
17 | return new EngineManager($app);
18 | });
19 |
20 | return [TypesenseServiceProvider::class];
21 | }
22 |
23 | protected function defineEnvironment($app)
24 | {
25 | $this->mergeConfigFrom($app, __DIR__.'/../config/scout.php', 'scout');
26 | }
27 |
28 | private function mergeConfigFrom($app, $path, $key)
29 | {
30 | $config = $app['config']->get($key, []);
31 |
32 | $app['config']->set($key, array_merge(require $path, $config));
33 | }
34 | }
35 |
--------------------------------------------------------------------------------