From 6611bd7f1cc4c658c4d114954feecbe1602148fa Mon Sep 17 00:00:00 2001 From: Jason Bosco Date: Tue, 9 Jan 2024 09:42:40 -0600 Subject: [PATCH] [10.x] Add Typesense engine (#773) * Integrate Typesense * Changes for Scout * Add search options * Add Unit tests * Rename search options and remove Index Response class * Code style fixes * Typesense Engine Style Fixes * Trailing Comma Style Changes * Code Doc Spaces * Code Doc Spaces * Add DocBlocks to all methods * Add dots to all comments * formatting * formatting * Add default configs * Formatting * Formatting * Remove unnecessary empty line * Change Mapping * Config Params * Typesense Collection Settings: Query Params * Formatting * Formatting * Rename methods and parameters * Query By As String * formatting * Use Typsense v4.8 * Use Typsense v4.9 * Use Typsense v4.9 * Update Suggestion * fix bug * Typesense Engine -> Update Method Changes * Typesense Engine -> Update Method Changes:Tests * Tests for With/Only Trashed Methods * Tests for With/Only Trashed Methods Changes * formatting * remove link --------- Co-authored-by: Sergey Karakhanyan Co-authored-by: Arayik Co-authored-by: Taylor Otwell --- composer.json | 4 +- config/scout.php | 62 +++- src/EngineManager.php | 30 ++ src/Engines/TypesenseEngine.php | 549 +++++++++++++++++++++++++++++ tests/Unit/TypesenseEngineTest.php | 306 ++++++++++++++++ 5 files changed, 949 insertions(+), 2 deletions(-) create mode 100644 src/Engines/TypesenseEngine.php create mode 100644 tests/Unit/TypesenseEngineTest.php diff --git a/composer.json b/composer.json index d9ee6f63..21e94e52 100644 --- a/composer.json +++ b/composer.json @@ -25,6 +25,7 @@ }, "require-dev": { "algolia/algoliasearch-client-php": "^3.2", + "typesense/typesense-php": "^4.9", "meilisearch/meilisearch-php": "^1.0", "mockery/mockery": "^1.0", "orchestra/testbench": "^7.31|^8.11", @@ -54,7 +55,8 @@ }, "suggest": { "algolia/algoliasearch-client-php": "Required to use the Algolia engine (^3.2).", - "meilisearch/meilisearch-php": "Required to use the Meilisearch engine (^1.0)." + "meilisearch/meilisearch-php": "Required to use the Meilisearch engine (^1.0).", + "typesense/typesense-php": "Required to use the Typesense engine (^4.9)." }, "config": { "sort-packages": true, diff --git a/config/scout.php b/config/scout.php index 481d9c3e..ac1a88ea 100644 --- a/config/scout.php +++ b/config/scout.php @@ -11,7 +11,8 @@ | using Laravel Scout. This connection is used when syncing all models | to the search service. You should adjust this based on your needs. | - | Supported: "algolia", "meilisearch", "database", "collection", "null" + | Supported: "algolia", "meilisearch", "typesense", + | "database", "collection", "null" | */ @@ -139,4 +140,63 @@ ], ], + /* + |-------------------------------------------------------------------------- + | Typesense Configuration + |-------------------------------------------------------------------------- + | + | Here you may configure your Typesense settings. Typesense is an open + | source search engine using minimal configuration. Below, you will + | state the host, key, and schema configuration for the instance. + | + */ + + 'typesense' => [ + 'client-settings' => [ + 'api_key' => env('TYPESENSE_API_KEY', 'xyz'), + 'nodes' => [ + [ + 'host' => env('TYPESENSE_HOST', 'localhost'), + 'port' => env('TYPESENSE_PORT', '8108'), + 'path' => env('TYPESENSE_PATH', ''), + 'protocol' => env('TYPESENSE_PROTOCOL', 'http'), + ], + ], + 'nearest_node' => [ + 'host' => env('TYPESENSE_HOST', 'localhost'), + 'port' => env('TYPESENSE_PORT', '8108'), + 'path' => env('TYPESENSE_PATH', ''), + 'protocol' => env('TYPESENSE_PROTOCOL', 'http'), + ], + 'connection_timeout_seconds' => env('TYPESENSE_CONNECTION_TIMEOUT_SECONDS', 2), + 'healthcheck_interval_seconds' => env('TYPESENSE_HEALTHCHECK_INTERVAL_SECONDS', 30), + 'num_retries' => env('TYPESENSE_NUM_RETRIES', 3), + 'retry_interval_seconds' => env('TYPESENSE_RETRY_INTERVAL_SECONDS', 1), + ], + 'model-settings' => [ + // User::class => [ + // 'collection-schema' => [ + // 'fields' => [ + // [ + // 'name' => 'id', + // 'type' => 'string', + // ], + // [ + // 'name' => 'name', + // 'type' => 'string', + // ], + // [ + // 'name' => 'created_at', + // 'type' => 'int64', + // ], + // ], + // 'default_sorting_field' => 'created_at', + // ], + // 'search-parameters' => [ + // 'query_by' => 'name' + // ], + // ], + ], + ], + ]; diff --git a/src/EngineManager.php b/src/EngineManager.php index e4b538a3..08d73f92 100644 --- a/src/EngineManager.php +++ b/src/EngineManager.php @@ -12,8 +12,10 @@ use Laravel\Scout\Engines\DatabaseEngine; use Laravel\Scout\Engines\MeilisearchEngine; use Laravel\Scout\Engines\NullEngine; +use Laravel\Scout\Engines\TypesenseEngine; use Meilisearch\Client as MeilisearchClient; use Meilisearch\Meilisearch; +use Typesense\Client as Typesense; class EngineManager extends Manager { @@ -142,6 +144,34 @@ protected function ensureMeilisearchClientIsInstalled() throw new Exception('Please install the suggested Meilisearch client: meilisearch/meilisearch-php.'); } + /** + * Create a Typesense engine instance. + * + * @return \Laravel\Scout\Engines\TypesenseEngine + * + * @throws \Typesense\Exceptions\ConfigError + */ + public function createTypesenseDriver() + { + $this->ensureTypesenseClientIsInstalled(); + + return new TypesenseEngine(new Typesense(config('scout.typesense.client-settings'))); + } + + /** + * Ensure the Typesense client is installed. + * + * @return void + * + * @throws Exception + */ + protected function ensureTypesenseClientIsInstalled() + { + if (! class_exists(Typesense::class)) { + throw new Exception('Please install the suggested Typesense client: typesense/typesense-php.'); + } + } + /** * Create a database engine instance. * diff --git a/src/Engines/TypesenseEngine.php b/src/Engines/TypesenseEngine.php new file mode 100644 index 00000000..5a285336 --- /dev/null +++ b/src/Engines/TypesenseEngine.php @@ -0,0 +1,549 @@ +typesense = $typesense; + } + + /** + * Update the given model in the index. + * + * @param \Illuminate\Database\Eloquent\Collection|Model[] $models + * + * @throws \Http\Client\Exception + * @throws \JsonException + * @throws \Typesense\Exceptions\TypesenseClientError + * + * @noinspection NotOptimalIfConditionsInspection + */ + public function update($models) + { + if ($models->isEmpty()) { + return; + } + + $collection = $this->getOrCreateCollectionFromModel($models->first()); + + if ($this->usesSoftDelete($models->first()) && config('scout.soft_delete', false)) { + $models->each->pushSoftDeleteMetadata(); + } + + $objects = $models->map(function ($model) { + if (empty($searchableData = $model->toSearchableArray())) { + return; + } + + return array_merge( + $searchableData, + $model->scoutMetadata(), + ); + })->filter()->values()->all(); + + if (! empty($objects)) { + $this->importDocuments( + $collection, + $objects + ); + } + } + + /** + * Import the given documents into the index. + * + * @param \TypesenseCollection $collectionIndex + * @param array $documents + * @param string $action + * @return \Illuminate\Support\Collection + * + * @throws \JsonException + * @throws \Typesense\Exceptions\TypesenseClientError + * @throws \Http\Client\Exception + */ + protected function importDocuments(TypesenseCollection $collectionIndex, array $documents, string $action = 'upsert'): Collection + { + $importedDocuments = $collectionIndex->getDocuments()->import($documents, ['action' => $action]); + + $results = []; + + foreach ($importedDocuments as $importedDocument) { + if (! $importedDocument['success']) { + throw new TypesenseClientError("Error importing document: {$importedDocument['error']}"); + } + + $results[] = $this->createImportSortingDataObject( + $importedDocument + ); + } + + return collect($results); + } + + /** + * Create an import sorting data object for a given document. + * + * @param array $document + * @return \stdClass + * + * @throws \JsonException + */ + protected function createImportSortingDataObject($document) + { + $data = new stdClass; + + $data->code = $document['code'] ?? 0; + $data->success = $document['success']; + $data->error = $document['error'] ?? null; + $data->document = json_decode($document['document'] ?? '[]', true, 512, JSON_THROW_ON_ERROR); + + return $data; + } + + /** + * Remove the given model from the index. + * + * @param \Illuminate\Database\Eloquent\Collection $models + * @return void + * + * @throws \Http\Client\Exception + * @throws \Typesense\Exceptions\TypesenseClientError + */ + public function delete($models) + { + $models->each(function (Model $model) { + $this->deleteDocument( + $this->getOrCreateCollectionFromModel($model), + $model->getScoutKey() + ); + }); + } + + /** + * Delete a document from the index. + * + * @param \TypesenseCollection $collectionIndex + * @param mixed $modelId + * @return array + * + * @throws \Typesense\Exceptions\ObjectNotFound + * @throws \Typesense\Exceptions\TypesenseClientError + * @throws \Http\Client\Exception + */ + protected function deleteDocument(TypesenseCollection $collectionIndex, $modelId): array + { + $document = $collectionIndex->getDocuments()[(string) $modelId]; + + try { + $document->retrieve(); + + return $document->delete(); + } catch (Exception $exception) { + return []; + } + } + + /** + * Perform the given search on the engine. + * + * @param \Laravel\Scout\Builder $builder + * @return mixed + * + * @throws \Http\Client\Exception + * @throws \Typesense\Exceptions\TypesenseClientError + */ + public function search(Builder $builder) + { + return $this->performSearch( + $builder, + array_filter($this->buildSearchParameters($builder, 1, $builder->limit)) + ); + } + + /** + * Perform the given search on the engine with pagination. + * + * @param \Laravel\Scout\Builder $builder + * @param int $perPage + * @param int $page + * @return mixed + * + * @throws \Http\Client\Exception + * @throws \Typesense\Exceptions\TypesenseClientError + */ + public function paginate(Builder $builder, $perPage, $page) + { + return $this->performSearch( + $builder, + array_filter($this->buildSearchParameters($builder, $page, $perPage)) + ); + } + + /** + * Perform the given search on the engine. + * + * @param \Laravel\Scout\Builder $builder + * @param array $options + * @return mixed + * + * @throws \Http\Client\Exception + * @throws \Typesense\Exceptions\TypesenseClientError + */ + protected function performSearch(Builder $builder, array $options = []): mixed + { + $documents = $this->getOrCreateCollectionFromModel($builder->model)->getDocuments(); + + if ($builder->callback) { + return call_user_func($builder->callback, $documents, $builder->query, $options); + } + + return $documents->search($options); + } + + /** + * Build the search parameters for a given Scout query builder. + * + * @param \Laravel\Scout\Builder $builder + * @param int $page + * @param int|null $perPage + * @return array + */ + public function buildSearchParameters(Builder $builder, int $page, int|null $perPage): array + { + $parameters = [ + 'q' => $builder->query, + 'query_by' => config('scout.typesense.model-settings.'.get_class($builder->model).'.search-parameters.query_by') ?? '', + 'filter_by' => $this->filters($builder), + 'per_page' => $perPage, + 'page' => $page, + 'highlight_start_tag' => '', + 'highlight_end_tag' => '', + 'snippet_threshold' => 30, + 'exhaustive_search' => false, + 'use_cache' => false, + 'cache_ttl' => 60, + 'prioritize_exact_match' => true, + 'enable_overrides' => true, + 'highlight_affix_num_tokens' => 4, + ]; + + if (! empty($this->searchParameters)) { + $parameters = array_merge($parameters, $this->searchParameters); + } + + if (! empty($builder->orders)) { + if (! empty($parameters['sort_by'])) { + $parameters['sort_by'] .= ','; + } else { + $parameters['sort_by'] = ''; + } + + $parameters['sort_by'] .= $this->parseOrderBy($builder->orders); + } + + return $parameters; + } + + /** + * Prepare the filters for a given search query. + * + * @param \Laravel\Scout\Builder $builder + * @return string + */ + protected function filters(Builder $builder): string + { + $whereFilter = collect($builder->wheres) + ->map(fn ($value, $key) => $this->parseWhereFilter($value, $key)) + ->values() + ->implode(' && '); + + $whereInFilter = collect($builder->whereIns) + ->map(fn ($value, $key) => $this->parseWhereInFilter($value, $key)) + ->values() + ->implode(' && '); + + return $whereFilter.( + ($whereFilter !== '' && $whereInFilter !== '') ? ' && ' : '' + ).$whereInFilter; + } + + /** + * Create a "where" filter string. + * + * @param array|string $value + * @param string $key + * @return string + */ + protected function parseWhereFilter(array|string $value, string $key): string + { + return is_array($value) + ? sprintf('%s:%s', $key, implode('', $value)) + : sprintf('%s:=%s', $key, $value); + } + + /** + * Create a "where in" filter string. + * + * @param array $value + * @param string $key + * @return string + */ + protected function parseWhereInFilter(array $value, string $key): string + { + return sprintf('%s:=%s', $key, '['.implode(', ', $value).']'); + } + + /** + * Parse the order by fields for the query. + * + * @param array $orders + * @return string + */ + protected function parseOrderBy(array $orders): string + { + $orderBy = []; + + foreach ($orders as $order) { + $orderBy[] = $order['column'].':'.$order['direction']; + } + + return implode(',', $orderBy); + } + + /** + * Pluck and return the primary keys of the given results. + * + * @param mixed $results + * @return \Illuminate\Support\Collection + */ + public function mapIds($results) + { + return collect($results['hits']) + ->pluck('document.id') + ->values(); + } + + /** + * Map the given results to instances of the given model. + * + * @param \Laravel\Scout\Builder $builder + * @param mixed $results + * @param \Illuminate\Database\Eloquent\Model $model + * @return \Illuminate\Database\Eloquent\Collection + */ + public function map(Builder $builder, $results, $model) + { + if ($this->getTotalCount($results) === 0) { + return $model->newCollection(); + } + + $hits = isset($results['grouped_hits']) && ! empty($results['grouped_hits']) + ? $results['grouped_hits'] + : $results['hits']; + + $pluck = isset($results['grouped_hits']) && ! empty($results['grouped_hits']) + ? 'hits.0.document.id' + : 'document.id'; + + $objectIds = collect($hits) + ->pluck($pluck) + ->values() + ->all(); + + $objectIdPositions = array_flip($objectIds); + + return $model->getScoutModelsByIds($builder, $objectIds) + ->filter(static function ($model) use ($objectIds) { + return in_array($model->getScoutKey(), $objectIds, false); + }) + ->sortBy(static function ($model) use ($objectIdPositions) { + return $objectIdPositions[$model->getScoutKey()]; + }) + ->values(); + } + + /** + * Map the given results to instances of the given model via a lazy collection. + * + * @param \Laravel\Scout\Builder $builder + * @param mixed $results + * @param \Illuminate\Database\Eloquent\Model $model + * @return \Illuminate\Support\LazyCollection + */ + public function lazyMap(Builder $builder, $results, $model) + { + if ((int) ($results['found'] ?? 0) === 0) { + return LazyCollection::make($model->newCollection()); + } + + $objectIds = collect($results['hits']) + ->pluck('document.id') + ->values() + ->all(); + + $objectIdPositions = array_flip($objectIds); + + return $model->queryScoutModelsByIds($builder, $objectIds) + ->cursor() + ->filter(static function ($model) use ($objectIds) { + return in_array($model->getScoutKey(), $objectIds, false); + }) + ->sortBy(static function ($model) use ($objectIdPositions) { + return $objectIdPositions[$model->getScoutKey()]; + }) + ->values(); + } + + /** + * Get the total count from a raw result returned by the engine. + * + * @param mixed $results + * @return int + */ + public function getTotalCount($results) + { + return (int) ($results['found'] ?? 0); + } + + /** + * Flush all the model's records from the engine. + * + * @param \Illuminate\Database\Eloquent\Model $model + * + * @throws \Http\Client\Exception + * @throws \Typesense\Exceptions\TypesenseClientError + */ + public function flush($model) + { + $this->getOrCreateCollectionFromModel($model)->delete(); + } + + /** + * Create a search index. + * + * @param string $name + * @param array $options + * @return void + * + * @throws \Exception + */ + public function createIndex($name, array $options = []) + { + throw new Exception('Typesense indexes are created automatically upon adding objects.'); + } + + /** + * Delete a search index. + * + * @param string $name + * @return array + * + * @throws \Typesense\Exceptions\TypesenseClientError + * @throws \Http\Client\Exception + * @throws \Typesense\Exceptions\ObjectNotFound + */ + public function deleteIndex($name) + { + return $this->typesense->getCollections()->{$name}->delete(); + } + + /** + * Get collection from model or create new one. + * + * @param \Illuminate\Database\Eloquent\Model $model + * @return \TypesenseCollection + * + * @throws \Typesense\Exceptions\TypesenseClientError + * @throws \Http\Client\Exception + */ + protected function getOrCreateCollectionFromModel($model): TypesenseCollection + { + $index = $this->typesense->getCollections()->{$model->searchableAs()}; + + try { + $index->retrieve(); + + return $index; + } catch (ObjectNotFound $exception) { + $schema = config('scout.typesense.model-settings.'.get_class($model).'.collection-schema') ?? []; + + if (! isset($schema['name'])) { + $schema['name'] = $model->searchableAs(); + } + + $this->typesense->getCollections()->create($schema); + + return $this->typesense->getCollections()->{$model->searchableAs()}; + } + } + + /** + * Determine if model uses soft deletes. + * + * @param \Illuminate\Database\Eloquent\Model $model + * @return bool + */ + protected function usesSoftDelete($model): bool + { + return in_array(SoftDeletes::class, class_uses_recursive($model), true); + } + + /** + * Set the search options provided by user. + * + * @param array $options + * @return $this + */ + public function setSearchParameters(array $options): static + { + $this->searchParameters = $options; + + return $this; + } + + /** + * Dynamically proxy missing methods to the Typesense client instance. + * + * @param string $method + * @param array $parameters + * @return mixed + */ + public function __call($method, $parameters) + { + return $this->typesense->$method(...$parameters); + } +} diff --git a/tests/Unit/TypesenseEngineTest.php b/tests/Unit/TypesenseEngineTest.php new file mode 100644 index 00000000..3f80b858 --- /dev/null +++ b/tests/Unit/TypesenseEngineTest.php @@ -0,0 +1,306 @@ +createMock(TypesenseClient::class); + $this->engine = $this->getMockBuilder(TypesenseEngine::class) + ->setConstructorArgs([$typesenseClient]) + ->onlyMethods(['getOrCreateCollectionFromModel', 'buildSearchParameters']) + ->getMock(); + } + + protected function tearDown(): void + { + Container::getInstance()->flush(); + m::close(); + } + + public function test_update_method(): void + { + // Mock models and their methods + $models = [ + $this->createMock(SearchableModel::class), + ]; + + $models[0]->expects($this->once()) + ->method('toSearchableArray') + ->willReturn(['id' => 1, 'name' => 'Model 1']); + + $models[0]->expects($this->once()) + ->method('scoutMetadata') + ->willReturn([]); + + // Mock the getOrCreateCollectionFromModel method + $collection = $this->createMock(TypesenseCollection::class); + $documents = $this->createMock(Documents::class); + $collection->expects($this->once()) + ->method('getDocuments') + ->willReturn($documents); + $documents->expects($this->once()) + ->method('import') + ->with( + [['id' => 1, 'name' => 'Model 1']], ['action' => 'upsert'], + ) + ->willReturn([[ + 'success' => true, + ]]); + + $this->engine->expects($this->once()) + ->method('getOrCreateCollectionFromModel') + ->willReturn($collection); + + // Call the update method + $this->engine->update(collect($models)); + } + + public function test_delete_method(): void + { + // Mock models and their methods + $models = [ + $this->createMock(SearchableModel::class), + ]; + + $models[0]->expects($this->once()) + ->method('getScoutKey') + ->willReturn(1); + + // Mock the getOrCreateCollectionFromModel and deleteDocument methods + $collection = $this->createMock(TypesenseCollection::class); + $documents = $this->createMock(Documents::class); + $collection->expects($this->once()) + ->method('getDocuments') + ->willReturn($documents); + + $this->engine->expects($this->once()) + ->method('getOrCreateCollectionFromModel') + ->willReturn($collection); + + // Call the delete method + $this->engine->delete(collect($models)); + } + + public function test_search_method(): void + { + // Mock the Builder + $builder = $this->createMock(Builder::class); + + // Mock the buildSearchParameters method + $this->engine->expects($this->once()) + ->method('buildSearchParameters') + ->with($builder, 1) + ->willReturn([ + 'q' => $builder->query, + 'query_by' => 'id', + 'filter_by' => '', + 'per_page' => 10, + 'page' => 1, + 'highlight_start_tag' => '', + 'highlight_end_tag' => '', + 'snippet_threshold' => 30, + 'exhaustive_search' => false, + 'use_cache' => false, + 'cache_ttl' => 60, + 'prioritize_exact_match' => true, + 'enable_overrides' => true, + 'highlight_affix_num_tokens' => 4, + ]); + + // Call the search method + $this->engine->search($builder); + } + + public function test_paginate_method(): void + { + // Mock the Builder + $builder = $this->createMock(Builder::class); + + // Mock the buildSearchParameters method + $this->engine->expects($this->once()) + ->method('buildSearchParameters') + ->with($builder, 2, 10) + ->willReturn([ + 'q' => $builder->query, + 'query_by' => 'id', + 'filter_by' => '', + 'per_page' => 10, + 'page' => 2, + 'highlight_start_tag' => '', + 'highlight_end_tag' => '', + 'snippet_threshold' => 30, + 'exhaustive_search' => false, + 'use_cache' => false, + 'cache_ttl' => 60, + 'prioritize_exact_match' => true, + 'enable_overrides' => true, + 'highlight_affix_num_tokens' => 4, + ]); + + // Call the paginate method + $this->engine->paginate($builder, 10, 2); + } + + public function test_map_ids_method(): void + { + // Sample search results + $results = [ + 'hits' => [ + ['document' => ['id' => 1]], + ['document' => ['id' => 2]], + ['document' => ['id' => 3]], + ], + ]; + + // Call the mapIds method + $mappedIds = $this->engine->mapIds($results); + + // Assert that the result is an instance of Collection + $this->assertInstanceOf(Collection::class, $mappedIds); + + // Assert that the mapped IDs match the expected IDs + $this->assertEquals([1, 2, 3], $mappedIds->toArray()); + } + + public function test_get_total_count_method(): void + { + // Sample search results with 'found' key + $resultsWithFound = ['found' => 5]; + + // Sample search results without 'found' key + $resultsWithoutFound = ['hits' => []]; + + // Call the getTotalCount method with results containing 'found' + $totalCountWithFound = $this->engine->getTotalCount($resultsWithFound); + + // Call the getTotalCount method with results without 'found' + $totalCountWithoutFound = $this->engine->getTotalCount($resultsWithoutFound); + + // Assert that the total count is correctly extracted from the results + $this->assertEquals(5, $totalCountWithFound); + $this->assertEquals(0, $totalCountWithoutFound); + } + + public function test_flush_method(): void + { + // Mock a model instance + $model = $this->createMock(Model::class); + + $collection = $this->createMock(TypesenseCollection::class); + // Mock the getOrCreateCollectionFromModel method + $this->engine->expects($this->once()) + ->method('getOrCreateCollectionFromModel') + ->with($model) + ->willReturn($collection); + + // Mock the delete method of the TypesenseCollection + $collection->expects($this->once()) + ->method('delete'); + + // Call the flush method + $this->engine->flush($model); + } + + public function test_create_index_method_throws_exception(): void + { + // Define the expected exception class and message + $expectedException = \Exception::class; + $expectedExceptionMessage = 'Typesense indexes are created automatically upon adding objects.'; + + // Use PHPUnit's expectException method to assert that the specified exception is thrown + $this->expectException($expectedException); + $this->expectExceptionMessage($expectedExceptionMessage); + + // Call the createIndex method which should throw an exception + $this->engine->createIndex('test_index'); + } + + public function test_set_search_params_method(): void + { + // Mock the Builder + $builder = $this->createMock(Builder::class); + + // Mock the buildSearchParameters method + $this->engine->expects($this->once()) + ->method('buildSearchParameters') + ->with($builder, 1) + ->willReturn([ + 'q' => $builder->query, + 'query_by' => 'id', + 'filter_by' => '', + 'per_page' => 10, + 'page' => 1, + 'highlight_start_tag' => '', + 'highlight_end_tag' => '', + 'snippet_threshold' => 30, + 'exhaustive_search' => false, + 'use_cache' => false, + 'cache_ttl' => 60, + 'prioritize_exact_match' => true, + 'enable_overrides' => true, + 'highlight_affix_num_tokens' => 4, + ]); + + // Call the search method + $this->engine->setSearchParameters(['query_by' => 'id'])->search($builder); + } + + public function test_soft_deleted_objects_are_returned_with_only_trashed_method() + { + // Create a mock of SearchableModel + $searchableModel = m::mock(SearchableModel::class)->makePartial(); + + // Mock the search method to return a collection with a soft-deleted object + $searchableModel->shouldReceive('search')->with('Soft Deleted Object')->andReturnSelf(); + $searchableModel->shouldReceive('onlyTrashed')->andReturnSelf(); + $searchableModel->shouldReceive('get')->andReturn(collect([ + new SearchableModel(['id' => 1, 'name' => 'Soft Deleted Object']), + ])); + + // Perform the search with onlyTrashed() using the mocked model + $results = $searchableModel::search('Soft Deleted Object')->onlyTrashed()->get(); + + // Assert that the soft deleted object is returned + $this->assertCount(1, $results); + $this->assertEquals(1, $results->first()->id); + } + + public function test_soft_deleted_objects_are_returned_with_with_trashed_method() + { + // Create a mock of SearchableModel + $searchableModel = m::mock(SearchableModel::class)->makePartial(); + + // Mock the search method to return a collection with a soft-deleted object + $searchableModel->shouldReceive('search')->with('Soft Deleted Object')->andReturnSelf(); + $searchableModel->shouldReceive('withTrashed')->andReturnSelf(); + $searchableModel->shouldReceive('get')->andReturn(collect([ + new SearchableModel(['id' => 1, 'name' => 'Soft Deleted Object']), + ])); + + // Perform the search with withTrashed() using the mocked model + $results = $searchableModel::search('Soft Deleted Object')->withTrashed()->get(); + + // Assert that the soft deleted object is returned + $this->assertCount(1, $results); + $this->assertEquals(1, $results->first()->id); + } +}