diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4b563a4..e47a3e1 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -11,6 +11,14 @@ on: jobs: tests: + services: + search-server: + image: opensearchproject/opensearch + ports: + - 9200:9200 + env: + discovery.type: single-node + plugins.security.disabled: 'true' runs-on: ubuntu-latest strategy: diff --git a/README.md b/README.md index 94e1e5d..5742d12 100644 --- a/README.md +++ b/README.md @@ -22,12 +22,9 @@ composer require zing/laravel-scout-opensearch return [ // ... 'opensearch' => [ - "access_key" => env('OPENSEARCH_ACCESS_KEY', 'your-opensearch-access-key'), - "secret" => env('OPENSEARCH_SECRET', 'your-opensearch-secret'), - "host" => env('OPENSEARCH_HOST', 'your-opensearch-host'), - "options" => [ - "debug" => env('OPENSEARCH_DEBUG', false), - ], + 'hosts' => [env('OPENSEARCH_HOST', 'localhost:9200')], + 'basicAuthentication' => [env('OPENSEARCH_USERNAME', 'admin'), env('OPENSEARCH_PASSWORD', 'admin')], + 'retries' => env('OPENSEARCH_RETRYS', 2), ], ]; ``` @@ -41,7 +38,7 @@ class SearchableModel extends Model public function searchableAs(): string { - return '{{APP_NAME}}.{{TABLE_NAME}}'; + return 'searchable_models_index'; } /** diff --git a/composer.json b/composer.json index 863d560..c1719bf 100644 --- a/composer.json +++ b/composer.json @@ -19,14 +19,15 @@ "sort-packages": true, "preferred-install": "dist", "allow-plugins": { - "dealerdirect/phpcodesniffer-composer-installer": true + "dealerdirect/phpcodesniffer-composer-installer": true, + "php-http/discovery": true } }, "require": { "php": "^8.0", "ext-json": "*", "laravel/scout": "^8.5 || ^9.1 || ^10.0", - "zing/alibabacloud-opensearch": "^3.3" + "opensearch-project/opensearch-php": "^2.0" }, "require-dev": { "mockery/mockery": "~1.3.3 || ^1.4.2", diff --git a/phpstan.neon b/phpstan.neon index 8e96229..3298475 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -7,9 +7,13 @@ parameters: paths: - src - tests + excludePaths: + analyse: + - tests/OpenSearchEngineTest.php checkGenericClassInNonGenericObjectType: false ignoreErrors: - '#Call to an undefined method Illuminate\\Database\\Eloquent\\Model::getScoutKey\(\).#' + - '#Call to an undefined method Illuminate\\Database\\Eloquent\\Model::getScoutKeyName\(\).#' - '#Call to an undefined method Illuminate\\Database\\Eloquent\\Model::getScoutModelsByIds\(\).#' - '#Call to an undefined method Illuminate\\Database\\Eloquent\\Model::queryScoutModelsByIds\(\).#' - '#Call to an undefined method Illuminate\\Database\\Eloquent\\Model::scoutMetadata\(\).#' @@ -17,4 +21,5 @@ parameters: - '#Call to an undefined method Illuminate\\Database\\Eloquent\\Model::toSearchableArray\(\).#' - '#Call to an undefined method Illuminate\\Database\\Eloquent\\Builder::unsearchable\(\).#' - '#Call to an undefined method Illuminate\\Support\\HigherOrderCollectionProxy::pushSoftDeleteMetadata\(\).#' - - '#Trying to access array offset on value of type null#' + - '#Call to an undefined method Illuminate\\Support\\HigherOrderCollectionProxy::getScoutKey\(\).#' + - '#Parameter \#1 ...\$arrays of function array_merge expects array, int given.#' diff --git a/phpunit.xml.dist b/phpunit.xml.dist index f1f9d20..701c56d 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,13 +1,14 @@ - - - - ./src - - + + ./tests + + + ./src + + diff --git a/src/Engines/OpenSearchEngine.php b/src/Engines/OpenSearchEngine.php index eabf211..0bd56fc 100644 --- a/src/Engines/OpenSearchEngine.php +++ b/src/Engines/OpenSearchEngine.php @@ -8,48 +8,34 @@ use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Support\Collection; use Illuminate\Support\LazyCollection; -use Illuminate\Support\Str; use Laravel\Scout\Builder; use Laravel\Scout\Engines\Engine; -use OpenSearch\Client\AppClient; -use OpenSearch\Client\DocumentClient; -use OpenSearch\Client\OpenSearchClient; -use OpenSearch\Client\SearchClient; -use OpenSearch\Generated\Common\OpenSearchResult; -use OpenSearch\Util\SearchParamsBuilder; +use Laravel\Scout\Jobs\RemoveableScoutCollection; +use OpenSearch\Client; class OpenSearchEngine extends Engine { - protected DocumentClient $document; - - protected SearchClient $search; - - protected AppClient $app; - /** * Create a new engine instance. - * - * @param bool $softDelete */ public function __construct( - /** - * The Algolia client. - */ - protected OpenSearchClient $openSearchClient, - protected $softDelete = false + protected Client $client, + protected bool $softDelete = false ) { - $this->document = new DocumentClient($openSearchClient); - $this->search = new SearchClient($openSearchClient); - $this->app = new AppClient($openSearchClient); } + /** + * Update the given model in the index. + * + * @param \Illuminate\Database\Eloquent\Collection $models + */ public function update($models): void { if ($models->isEmpty()) { return; } - /** @var \Illuminate\Database\Eloquent\Model $model */ + /** @var \Illuminate\Database\Eloquent\Model $model First model for search index */ $model = $models->first(); if ($this->usesSoftDelete($model) && $this->softDelete) { $models->each->pushSoftDeleteMetadata(); @@ -61,119 +47,157 @@ public function update($models): void return null; } - return array_merge([ + return array_merge($searchableData, $model->scoutMetadata(), [ 'id' => $model->getScoutKey(), - ], $searchableData, $model->scoutMetadata()); + ]); })->filter() ->values() ->all(); if ($objects !== []) { + $data = []; foreach ($objects as $object) { - $this->document->add($object); + $data[] = [ + 'index' => [ + '_index' => $model->searchableAs(), + '_id' => $object['id'], + ], + ]; + $data[] = $object; } - $searchableAs = $model->searchableAs(); - $this->document->commit($this->getAppName($searchableAs), $this->getTableName($searchableAs)); + $this->client->bulk([ + 'index' => $model->searchableAs(), + 'body' => $data, + ]); } } - protected function getAppName(string $searchableAs): string - { - return Str::before($searchableAs, '.'); - } - - protected function getTableName(string $searchableAs): string - { - return Str::after($searchableAs, '.'); - } - + /** + * Remove the given model from the index. + * + * @param \Illuminate\Database\Eloquent\Collection $models + */ public function delete($models): void { if ($models->isEmpty()) { return; } - $objects = $models->map(static fn ($model): array => [ - 'id' => $model->getScoutKey(), - ])->values() - ->all(); - foreach ($objects as $object) { - $this->document->remove($object); - } - /** @var \Illuminate\Database\Eloquent\Model $model */ $model = $models->first(); - $searchableAs = $model->searchableAs(); - $this->document->commit($this->getAppName($searchableAs), $this->getTableName($searchableAs)); + + $keys = $models instanceof RemoveableScoutCollection + ? $models->pluck($model->getScoutKeyName()) + : $models->map->getScoutKey(); + + $data = $keys->map(static fn ($object): array => [ + 'delete' => [ + '_index' => $model->searchableAs(), + '_id' => $object, + ], + ])->all(); + + $this->client->bulk([ + 'index' => $model->searchableAs(), + 'body' => $data, + ]); } + /** + * Perform the given search on the engine. + */ public function search(Builder $builder): mixed { return $this->performSearch($builder, array_filter([ - 'query' => $builder->query, - 'hits' => $builder->limit, - 'appName' => $this->getAppName($builder->model->searchableAs()), - 'format' => 'fulljson', - 'start' => 0, + 'size' => $builder->limit, ])); } /** - * @param mixed $perPage - * @param mixed $page + * Perform the given search on the engine. + * + * @param int $perPage + * @param int $page */ public function paginate(Builder $builder, $perPage, $page): mixed { - return $this->performSearch($builder, array_filter([ - 'query' => $builder->query, - 'hits' => $perPage, - 'appName' => $this->getAppName($builder->model->searchableAs()), - 'format' => 'fulljson', - 'start' => $perPage * ($page - 1), - ])); + return $this->performSearch($builder, [ + 'size' => $perPage, + 'from' => $perPage * ($page - 1), + ]); } /** + * Perform the given search on the engine. + * * @param array $options */ protected function performSearch(Builder $builder, array $options = []): mixed { - if ($builder->callback instanceof \Closure) { - return \call_user_func($builder->callback, $this->search, $builder->query, $options); + $index = $builder->index ?: $builder->model->searchableAs(); + if (property_exists($builder, 'options')) { + $options = array_merge($builder->options, $options); } - $query = $options['query']; - $options['query'] = \is_string($query) ? sprintf("'%s'", $query) : $query; - $searchParamsBuilder = new SearchParamsBuilder($options); - if (empty($builder->orders)) { - $searchParamsBuilder->addSort('id', SearchParamsBuilder::SORT_DECREASE); + if ($builder->callback instanceof \Closure) { + return \call_user_func($builder->callback, $this->client, $builder->query, $options); } - foreach ($builder->orders as $order) { - if ($order['direction'] === 'desc') { - $direction = SearchParamsBuilder::SORT_DECREASE; - $searchParamsBuilder->addSort($order['column'], $direction); - } elseif ($order['direction'] === 'asc') { - $direction = SearchParamsBuilder::SORT_INCREASE; - $searchParamsBuilder->addSort($order['column'], $direction); - } + $query = $builder->query; + $options['query'] = [ + 'query_string' => [ + 'query' => $query, + ], + ]; + $filter = collect($builder->wheres) + ->map(static fn ($value, $key): array => [ + 'term' => [ + $key => $value, + ], + ])->values(); + + if (property_exists($builder, 'whereIns')) { + $filter = $filter->merge(collect($builder->whereIns)->map(static fn ($values, $key): array => [ + 'terms' => [ + $key => $values, + ], + ])->values())->values(); } - foreach ($builder->wheres as $key => $value) { - $searchParamsBuilder->setFilter(sprintf('%s=%s', $key, $value)); + if ($filter->isNotEmpty()) { + $options['query'] = [ + 'bool' => [ + 'filter' => $filter->all(), + 'must' => [$options['query']], + ], + ]; } - $result = $this->search->execute($searchParamsBuilder->build()); - - /** @var array $searchResult */ - $searchResult = json_decode($result->result, true, 512, JSON_THROW_ON_ERROR); - - return $searchResult['result'] ?? null; + $options['sort'] = collect($builder->orders)->map(static fn ($order): array => [ + $order['column'] => [ + 'order' => $order['direction'], + ], + ])->whenEmpty(static fn (): Collection => collect([ + [ + 'id' => [ + 'order' => 'desc', + ], + ], + ]))->all(); + + $result = $this->client->search([ + 'index' => $index, + 'body' => $options, + ]); + + return $result['hits'] ?? null; } /** - * @param array{items: mixed[]|null}|null $results + * Pluck and return the primary keys of the given results. + * + * @param array{hits: mixed[]|null}|null $results */ public function mapIds($results): Collection { @@ -181,12 +205,16 @@ public function mapIds($results): Collection return collect(); } - return collect($results['items'])->pluck('fields.id')->values(); + return collect($results['hits'])->pluck('_id')->values(); } /** - * @param array{items: mixed[]|null}|null $results + * Map the given results to instances of the given model. + * + * @param array{hits: mixed[]|null}|null $results * @param \Illuminate\Database\Eloquent\Model $model + * + * @return \Illuminate\Database\Eloquent\Collection */ public function map(Builder $builder, $results, $model): mixed { @@ -194,15 +222,15 @@ public function map(Builder $builder, $results, $model): mixed return $model->newCollection(); } - if (! isset($results['items'])) { + if (! isset($results['hits'])) { return $model->newCollection(); } - if ($results['items'] === []) { + if ($results['hits'] === []) { return $model->newCollection(); } - $objectIds = collect($results['items'])->pluck('fields.id')->values()->all(); + $objectIds = collect($results['hits'])->pluck('_id')->values()->all(); $objectIdPositions = array_flip($objectIds); @@ -214,24 +242,24 @@ public function map(Builder $builder, $results, $model): mixed /** * Map the given results to instances of the given model via a lazy collection. * - * @param array{items: mixed[]|null}|null $results + * @param array{hits: mixed[]|null}|null $results * @param \Illuminate\Database\Eloquent\Model $model */ - public function lazyMap(Builder $builder, $results, $model): mixed + public function lazyMap(Builder $builder, $results, $model): LazyCollection { if ($results === null) { return LazyCollection::make($model->newCollection()); } - if (! isset($results['items'])) { + if (! isset($results['hits'])) { return LazyCollection::make($model->newCollection()); } - if ($results['items'] === []) { + if ($results['hits'] === []) { return LazyCollection::make($model->newCollection()); } - $objectIds = collect($results['items'])->pluck('fields.id')->values()->all(); + $objectIds = collect($results['hits'])->pluck('_id')->values()->all(); $objectIdPositions = array_flip($objectIds); return $model->queryScoutModelsByIds($builder, $objectIds) @@ -241,13 +269,20 @@ public function lazyMap(Builder $builder, $results, $model): mixed } /** - * @param array|null $results + * Get the total count from a raw result returned by the engine. + * + * @param mixed $results */ - public function getTotalCount($results): mixed + public function getTotalCount($results): int { - return $results['total'] ?? 0; + return $results['total']['value'] ?? 0; } + /** + * Flush all of the model's records from the engine. + * + * @param \Illuminate\Database\Eloquent\Model $model + */ public function flush($model): void { $model->newQuery() @@ -260,20 +295,35 @@ public function flush($model): void * * @param string $name * @param array $options + * + * @return array{acknowledged: bool, shards_acknowledged: bool, index: string} + * + * @phpstan-return array */ - public function createIndex($name, array $options = []): OpenSearchResult + public function createIndex($name, array $options = []): array { - return $this->app->save($name); + return $this->client->indices() + ->create([ + 'index' => $name, + 'body' => $options, + ]); } /** * Delete a search index. * * @param string $name + * + * @return array{acknowledged: bool} + * + * @phpstan-return array */ - public function deleteIndex($name): OpenSearchResult + public function deleteIndex($name): array { - return $this->app->removeById($name); + return $this->client->indices() + ->delete([ + 'index' => $name, + ]); } /** @@ -283,4 +333,17 @@ protected function usesSoftDelete(Model $model): bool { return \in_array(SoftDeletes::class, class_uses_recursive($model), true); } + + /** + * Dynamically call the OpenSearch client instance. + * + * @param string $method + * @param array $parameters + * + * @return mixed + */ + public function __call($method, $parameters) + { + return $this->client->{$method}(...$parameters); + } } diff --git a/src/OpenSearchServiceProvider.php b/src/OpenSearchServiceProvider.php index 61752b0..0331513 100644 --- a/src/OpenSearchServiceProvider.php +++ b/src/OpenSearchServiceProvider.php @@ -6,7 +6,8 @@ use Illuminate\Support\ServiceProvider; use Laravel\Scout\EngineManager; -use OpenSearch\Client\OpenSearchClient; +use OpenSearch\Client; +use OpenSearch\ClientBuilder; use Zing\LaravelScout\OpenSearch\Engines\OpenSearchEngine; class OpenSearchServiceProvider extends ServiceProvider @@ -15,7 +16,7 @@ public function boot(): void { resolve(EngineManager::class)->extend( 'opensearch', - static fn (): OpenSearchEngine => new OpenSearchEngine(resolve(OpenSearchClient::class), config( + static fn (): OpenSearchEngine => new OpenSearchEngine(resolve(Client::class), config( 'scout.soft_delete', false )) @@ -24,15 +25,9 @@ public function boot(): void public function register(): void { - $this->app->singleton(OpenSearchClient::class, static function ($app): OpenSearchClient { - $config = $app['config']->get('scout.opensearch'); - - return new OpenSearchClient( - $config['access_key'], - $config['secret'], - $config['host'], - $config['options'] ?? [] - ); - }); + $this->app->singleton( + Client::class, + static fn ($app): Client => ClientBuilder::fromConfig($app['config']->get('scout.opensearch')) + ); } } diff --git a/tests/Fixtures/CustomKeySearchableModel.php b/tests/Fixtures/CustomKeySearchableModel.php new file mode 100644 index 0000000..fa13d16 --- /dev/null +++ b/tests/Fixtures/CustomKeySearchableModel.php @@ -0,0 +1,13 @@ +getKey(); + } +} diff --git a/tests/Fixtures/EmptySearchableModel.php b/tests/Fixtures/EmptySearchableModel.php new file mode 100644 index 0000000..5a0c1a3 --- /dev/null +++ b/tests/Fixtures/EmptySearchableModel.php @@ -0,0 +1,16 @@ + + */ + public function toSearchableArray(): array + { + return []; + } +} diff --git a/tests/Fixtures/SearchableModel.php b/tests/Fixtures/SearchableModel.php new file mode 100644 index 0000000..51b3b15 --- /dev/null +++ b/tests/Fixtures/SearchableModel.php @@ -0,0 +1,33 @@ + + */ + protected $fillable = ['id']; + + public function searchableAs(): string + { + return 'table'; + } + + /** + * @return array + */ + public function scoutMetadata() + { + return []; + } +} diff --git a/tests/Fixtures/SoftDeletedEmptySearchableModel.php b/tests/Fixtures/SoftDeletedEmptySearchableModel.php new file mode 100644 index 0000000..869ebf2 --- /dev/null +++ b/tests/Fixtures/SoftDeletedEmptySearchableModel.php @@ -0,0 +1,26 @@ + + */ + public function toSearchableArray(): array + { + return []; + } + + /** + * @return array{__soft_deleted: int} + */ + public function scoutMetadata(): array + { + return [ + '__soft_deleted' => 1, + ]; + } +} diff --git a/tests/OpenSearchEngineTest.php b/tests/OpenSearchEngineTest.php index 934f290..1f3f70a 100644 --- a/tests/OpenSearchEngineTest.php +++ b/tests/OpenSearchEngineTest.php @@ -4,16 +4,22 @@ namespace Zing\LaravelScout\OpenSearch\Tests; +use Illuminate\Container\Container; use Illuminate\Database\Eloquent\Collection; use Illuminate\Foundation\Testing\DatabaseTransactions; +use Illuminate\Support\Facades\Config; +use Illuminate\Support\LazyCollection; use Laravel\Scout\Builder; use Laravel\Scout\EngineManager; -use Mockery; -use OpenSearch\Client\OpenSearchClient; -use OpenSearch\Client\SearchClient; -use OpenSearch\Generated\Common\OpenSearchResult; -use OpenSearch\Util\SearchParamsBuilder; +use Laravel\Scout\Jobs\RemoveFromSearch; +use Mockery as m; +use OpenSearch\Client; +use OpenSearch\ClientBuilder; use Zing\LaravelScout\OpenSearch\Engines\OpenSearchEngine; +use Zing\LaravelScout\OpenSearch\Tests\Fixtures\CustomKeySearchableModel; +use Zing\LaravelScout\OpenSearch\Tests\Fixtures\EmptySearchableModel; +use Zing\LaravelScout\OpenSearch\Tests\Fixtures\SearchableModel; +use Zing\LaravelScout\OpenSearch\Tests\Fixtures\SoftDeletedEmptySearchableModel; /** * @internal @@ -22,873 +28,580 @@ final class OpenSearchEngineTest extends TestCase { use DatabaseTransactions; - /** - * @var \Mockery\MockInterface&\OpenSearch\Client\OpenSearchClient - */ - private $client; - - private OpenSearchEngine $openSearchEngine; - protected function setUp(): void { - parent::setUp(); - - $this->client = \Mockery::mock(OpenSearchClient::class); - $this->openSearchEngine = new OpenSearchEngine($this->client); - resolve(EngineManager::class)->extend('opensearch', fn (): OpenSearchEngine => $this->openSearchEngine); + Config::shouldReceive('get')->with('scout.after_commit', m::any())->andReturn(false); + Config::shouldReceive('get')->with('scout.soft_delete', m::any())->andReturn(false); } - public function testUpdate(): void + public function testUpdateAddsObjectsToIndex(): void { - $this->client->shouldReceive('post') - ->withArgs(['/apps/app/table/actions/bulk', new Mockery\Matcher\AnyArgs()]) - ->times(1) - ->andReturn(new OpenSearchResult([ - 'result' => '{ - "errors": [], - "request_id": "150116724719940316170289", - "status": "OK", - "result": true -}', - ])); - $this->openSearchEngine->update((new SearchableModel())->newCollection()); - $this->openSearchEngine->update(Collection::make([new SearchableModel()])); + $client = m::mock(Client::class); + $client->shouldReceive('bulk') + ->once() + ->with([ + 'index' => 'table', + 'body' => [ + [ + 'index' => [ + '_index' => 'table', + '_id' => 1, + ], + ], + [ + 'id' => 1, + ], + ], + ]); + + $engine = new OpenSearchEngine($client); + $engine->update(Collection::make([ + new SearchableModel([ + 'id' => 1, + ]), + ])); } - public function testUpdateWithSoftDelete(): void + public function testDeleteRemovesObjectsToIndex(): void { - $this->client->shouldReceive('post') - ->withArgs(['/apps/app/table/actions/bulk', new Mockery\Matcher\AnyArgs()]) - ->times(1) - ->andReturn(new OpenSearchResult([ - 'result' => '{ - "errors": [], - "request_id": "150116724719940316170289", - "status": "OK", - "result": true -}', - ])); - $openSearchEngine = new OpenSearchEngine($this->client, true); - $openSearchEngine->update(Collection::make([new SearchableModel()])); + $client = m::mock(Client::class); + $client->shouldReceive('bulk') + ->once() + ->with([ + 'index' => 'table', + 'body' => [ + [ + 'delete' => [ + '_index' => 'table', + '_id' => 1, + ], + ], + ], + ]); + + $engine = new OpenSearchEngine($client); + $engine->delete(Collection::make([ + new SearchableModel([ + 'id' => 1, + ]), + ])); } - public function testUpdateWithEmpty(): void + public function testDeleteRemovesObjectsToIndexWithACustomSearchKey(): void { - $model = \Mockery::mock(SearchableModel::class); - $model->shouldReceive('toSearchableArray') - ->andReturn([])->once(); - $this->openSearchEngine->update(Collection::make([$model])); + $client = m::mock(Client::class); + $client->shouldReceive('bulk') + ->once() + ->with([ + 'index' => 'table', + 'body' => [ + [ + 'delete' => [ + '_index' => 'table', + '_id' => 'my-opensearch-key.5', + ], + ], + ], + ]); + + $engine = new OpenSearchEngine($client); + $engine->delete(Collection::make([ + new CustomKeySearchableModel([ + 'id' => 5, + ]), + ])); } - public function testDelete(): void + public function testDeleteWithRemoveableScoutCollectionUsingCustomSearchKey(): void { - $this->client->shouldReceive('post') - ->withArgs(['/apps/app/table/actions/bulk', new Mockery\Matcher\AnyArgs()]) - ->times(1) - ->andReturn(new OpenSearchResult([ - 'result' => '{ - "errors": [], - "request_id": "150116724719940316170289", - "status": "OK", - "result": true -}', - ])); - $this->openSearchEngine->delete((new SearchableModel())->newCollection()); - $this->openSearchEngine->delete(Collection::make([new SearchableModel()])); + if (! class_exists(RemoveFromSearch::class)) { + self::markTestSkipped('Support for RemoveFromSearch available since 9.0.'); + } + $job = new RemoveFromSearch(Collection::make([ + new CustomKeySearchableModel([ + 'id' => 5, + ]), + ])); + + $job = unserialize(serialize($job)); + + $client = m::mock(Client::class); + $client->shouldReceive('bulk') + ->once() + ->with([ + 'index' => 'table', + 'body' => [ + [ + 'delete' => [ + '_index' => 'table', + '_id' => 'my-opensearch-key.5', + ], + ], + ], + ]); + $engine = new OpenSearchEngine($client); + $engine->delete($job->models); } - public function testSearch(): void + public function testRemoveFromSearchJobUsesCustomSearchKey(): void { - $this->client->shouldReceive('get') - ->times(1) - ->andReturn(new OpenSearchResult([ - 'result' => '{ - "status": "OK", - "request_id": "155310917017444091100003", - "result": { - "searchtime": 0.031081, - "total": 1, - "num": 1, - "viewtotal": 1, - "compute_cost": [ - { - "index_name": "84922", - "value": 0.292 - } - ], - "items": [ - { - "fields": { - "id": "10", - "name": "我是一条新文档的标题", - "phone": "18312345678", - "index_name": "app_schema_demo" - }, - "property": {}, - "attribute": {}, - "variableValue": {}, - "sortExprValues": [ - "10", - "10000.1354238242" - ] - } - ], - "facet": [] - }, - "qp": [ - { - "app_name": "84922", - "query_correction_info": [ - { - "index": "default", - "original_query": "平果手机充电器", - "corrected_query": "苹果手机充电器", - "correction_level": 1, - "processor_name": "spell_check" - } - ] + if (! class_exists(RemoveFromSearch::class)) { + self::markTestSkipped('Support for RemoveFromSearch available since 9.0.'); } - ], - "errors": [], - "tracer": "", - "ops_request_misc": "%7B%22request%5Fid%22%3A%22155310917017444091100003%22%7D" -}', - ])); - $builder = new Builder(new SearchableModel(), 'zonda'); - $builder->where('foo', 1); - $builder->orderBy('id', 'desc'); + $job = new RemoveFromSearch(Collection::make([ + new CustomKeySearchableModel([ + 'id' => 5, + ]), + ])); - $this->openSearchEngine->search($builder); - } + $job = unserialize(serialize($job)); - public function testPaginate(): void - { - $this->client->shouldReceive('get') - ->times(1) - ->andReturn(new OpenSearchResult([ - 'result' => '{ - "status": "OK", - "request_id": "155310917017444091100003", - "result": { - "searchtime": 0.031081, - "total": 1, - "num": 1, - "viewtotal": 1, - "compute_cost": [ - { - "index_name": "84922", - "value": 0.292 - } - ], - "items": [ - { - "fields": { - "id": "10", - "name": "我是一条新文档的标题", - "phone": "18312345678", - "index_name": "app_schema_demo" - }, - "property": {}, - "attribute": {}, - "variableValue": {}, - "sortExprValues": [ - "10", - "10000.1354238242" - ] - } - ], - "facet": [] - }, - "qp": [ - { - "app_name": "84922", - "query_correction_info": [ - { - "index": "default", - "original_query": "平果手机充电器", - "corrected_query": "苹果手机充电器", - "correction_level": 1, - "processor_name": "spell_check" - } - ] - } - ], - "errors": [], - "tracer": "", - "ops_request_misc": "%7B%22request%5Fid%22%3A%22155310917017444091100003%22%7D" -}', - ])); - $builder = new Builder(new SearchableModel(), 'zonda'); - $builder->where('foo', 1); - $builder->orderBy('id', 'desc'); - $builder->orderBy('rank'); + Container::getInstance()->bind(EngineManager::class, function () { + $engine = m::mock(OpenSearchEngine::class); + + $engine->shouldReceive('delete') + ->once() + ->with(m::on(function ($collection) { + $keyName = ($model = $collection->first()) + ->getScoutKeyName(); + + return $model->getAttributes()[$keyName] === 'my-opensearch-key.5'; + })); + + $manager = m::mock(EngineManager::class); + + $manager->shouldReceive('engine') + ->once() + ->andReturn($engine); + + return $manager; + }); - self::assertIsArray($this->openSearchEngine->paginate($builder, 15, 1)); + $job->handle(); } - public function testSearchFailed(): void + public function testSearchSendsCorrectParametersToAlgolia(): void { - $this->client->shouldReceive('get') - ->times(1) - ->andReturn(new OpenSearchResult([ - 'result' => '{ - "errors": [ - { - "code": 2001, - "message": "待查应用不存在.待查应用不存在。", - "params": { - "friendly_message": "待查应用不存在。" - } - } - ], - "request_id": "150116732819940316116461", - "status": "FAIL" -}', - ])); + $client = m::mock(Client::class); + $client->shouldReceive('search') + ->once() + ->with([ + 'index' => 'table', + 'body' => [ + 'query' => [ + 'bool' => [ + 'filter' => [ + [ + 'term' => [ + 'foo' => 1, + ], + ], + ], + 'must' => [ + [ + 'query_string' => [ + 'query' => 'zonda', + ], + ], + ], + ], + ], + 'sort' => [ + [ + 'id' => [ + 'order' => 'desc', + ], + ], + ], + ], + ]); + + $engine = new OpenSearchEngine($client); $builder = new Builder(new SearchableModel(), 'zonda'); $builder->where('foo', 1); - $builder->orderBy('id', 'desc'); - - self::assertNull($this->openSearchEngine->search($builder)); + $engine->search($builder); } - public function testCallback(): void + public function testSearchSendsCorrectParametersToAlgoliaForWhereInSearch(): void { - $this->client->shouldReceive('get') - ->times(1) - ->andReturn(new OpenSearchResult([ - 'result' => '{ - "status": "OK", - "request_id": "155310917017444091100003", - "result": { - "searchtime": 0.031081, - "total": 1, - "num": 1, - "viewtotal": 1, - "compute_cost": [ - { - "index_name": "84922", - "value": 0.292 - } - ], - "items": [ - { - "fields": { - "id": "10", - "name": "我是一条新文档的标题", - "phone": "18312345678", - "index_name": "app_schema_demo" - }, - "property": {}, - "attribute": {}, - "variableValue": {}, - "sortExprValues": [ - "10", - "10000.1354238242" - ] - } - ], - "facet": [] - }, - "qp": [ - { - "app_name": "84922", - "query_correction_info": [ - { - "index": "default", - "original_query": "平果手机充电器", - "corrected_query": "苹果手机充电器", - "correction_level": 1, - "processor_name": "spell_check" - } - ] + if (! method_exists(Builder::class, 'whereIn')) { + self::markTestSkipped('Support for whereIn available since 9.0.'); } - ], - "errors": [], - "tracer": "", - "ops_request_misc": "%7B%22request%5Fid%22%3A%22155310917017444091100003%22%7D" -}', - ])); - $builder = new Builder( - new SearchableModel(), - 'huayra', - function (SearchClient $client, $query, $params): OpenSearchResult { - $this->assertNotEmpty($params); - $this->assertSame('huayra', $query); - - return $client->execute((new SearchParamsBuilder())->build()); - } - ); - $this->openSearchEngine->search($builder); - } - - public function testCreateIndex(): void - { - $this->client->shouldReceive('post') - ->withArgs(['/apps', 'test']) - ->times(1) - ->andReturn(new OpenSearchResult([ - 'result' => '{ - "errors": [], - "request_id": "150116724719940316170289", - "status": "OK", - "result": true -}', - ])); - $this->openSearchEngine->createIndex('test'); - } + $client = m::mock(Client::class); + $client->shouldReceive('search') + ->once() + ->with([ + 'index' => 'table', + 'body' => [ + 'query' => [ + 'bool' => [ + 'filter' => [ + [ + 'term' => [ + 'foo' => 1, + ], + ], + [ + 'terms' => [ + 'bar' => [1, 2], + ], + ], + ], + 'must' => [ + [ + 'query_string' => [ + 'query' => 'zonda', + ], + ], + ], + ], + ], + 'sort' => [ + [ + 'id' => [ + 'order' => 'desc', + ], + ], + ], + ], + ]); - public function testDeleteIndex(): void - { - $this->client->shouldReceive('delete') - ->withArgs(['/apps/test']) - ->times(1) - ->andReturn(new OpenSearchResult([ - 'result' => '{ - "errors": [], - "request_id": "150116724719940316170289", - "status": "OK", - "result": true -}', - ])); - $this->openSearchEngine->deleteIndex('test'); + $engine = new OpenSearchEngine($client); + $builder = new Builder(new SearchableModel(), 'zonda'); + $builder->where('foo', 1) + ->whereIn('bar', [1, 2]); + $engine->search($builder); } - public function testSeachable(): void + public function testSearchSendsCorrectParametersToAlgoliaForEmptyWhereInSearch(): void { - $this->client->shouldReceive('post') - ->andReturn(new OpenSearchResult([ - 'result' => '{ - "errors": [], - "request_id": "150116724719940316170289", - "status": "OK", - "result": true -}', - ])); - $model = SearchableModel::query()->create([ - 'name' => 'test', - ]); - $result = <<shouldReceive('search') + ->once() + ->with([ + 'index' => 'table', + 'body' => [ + 'query' => [ + 'bool' => [ + 'filter' => [ + [ + 'term' => [ + 'foo' => 1, + ], + ], + [ + 'terms' => [ + 'bar' => [], + ], + ], + ], + 'must' => [ + [ + 'query_string' => [ + 'query' => 'zonda', + ], + ], + ], + ], ], - "items": [ - { - "fields": { - "id": "{$model->getKey()}", - "name": "我是一条新文档的标题", - "phone": "18312345678", - "index_name": "app_schema_demo" - }, - "property": {}, - "attribute": {}, - "variableValue": {}, - "sortExprValues": [ - "10", - "10000.1354238242" - ] - } + 'sort' => [ + [ + 'id' => [ + 'order' => 'desc', + ], + ], ], - "facet": [] - }, - "qp": [ - { - "app_name": "84922", - "query_correction_info": [ - { - "index": "default", - "original_query": "平果手机充电器", - "corrected_query": "苹果手机充电器", - "correction_level": 1, - "processor_name": "spell_check" - } - ] - } ], - "errors": [], - "tracer": "", - "ops_request_misc": "%7B%22request%5Fid%22%3A%22155310917017444091100003%22%7D" - } - CODE_SAMPLE; - - $this->client->shouldReceive('get') - ->times(1) - ->andReturn(new OpenSearchResult([ - 'result' => $result, - ])); + ]); - self::assertCount(1, SearchableModel::search('test')->get()); + $engine = new OpenSearchEngine($client); + $builder = new Builder(new SearchableModel(), 'zonda'); + $builder->where('foo', 1) + ->whereIn('bar', []); + $engine->search($builder); } - public function testSeachableFailed(): void + public function testMapCorrectlyMapsResultsToModels(): void { - $this->client->shouldReceive('post') - ->andReturn(new OpenSearchResult([ - 'result' => '{ - "errors": [], - "request_id": "150116724719940316170289", - "status": "OK", - "result": true -}', + $client = m::mock(Client::class); + $engine = new OpenSearchEngine($client); + + $model = m::mock(SearchableModel::class); + $model->shouldReceive('getScoutModelsByIds') + ->andReturn($models = Collection::make([ + new SearchableModel([ + 'id' => 1, + ]), ])); - SearchableModel::query()->create([ - 'name' => 'test', - ]); - $jsonData = [ - 'errors' => [ + + $builder = m::mock(Builder::class); + + $results = $engine->map($builder, [ + 'nbHits' => 1, + 'hits' => [ [ - 'code' => 2001, - 'message' => '待查应用不存在.待查应用不存在。', - 'params' => [ - 'friendly_message' => '待查应用不存在。', - ], + '_id' => 1, + 'id' => 1, ], ], - 'request_id' => '150116732819940316116461', - 'status' => 'FAIL', - ]; - $result = json_encode($jsonData); - - $this->client->shouldReceive('get') - ->times(1) - ->andReturn(new OpenSearchResult([ - 'result' => $result, - ])); + ], $model); - self::assertCount(0, SearchableModel::search('test')->get()); + self::assertCount(1, $results); } - public function testSearchEmpty(): void + public function testMapMethodRespectsOrder(): void { - $this->client->shouldReceive('post') - ->andReturn(new OpenSearchResult([ - 'result' => '{ - "errors": [], - "request_id": "150116724719940316170289", - "status": "OK", - "result": true -}', + $client = m::mock(Client::class); + $engine = new OpenSearchEngine($client); + + $model = m::mock(SearchableModel::class); + $model->shouldReceive('getScoutModelsByIds') + ->andReturn($models = Collection::make([ + new SearchableModel([ + 'id' => 1, + ]), + new SearchableModel([ + 'id' => 2, + ]), + new SearchableModel([ + 'id' => 3, + ]), + new SearchableModel([ + 'id' => 4, + ]), ])); - SearchableModel::query()->create([ - 'name' => 'test', - ]); - $jsonData = [ - 'status' => 'OK', - 'request_id' => '155310917017444091100003', - 'result' => [ - 'searchtime' => 0.031081, - 'total' => 1, - 'num' => 1, - 'viewtotal' => 1, - 'compute_cost' => [ - [ - 'index_name' => '84922', - 'value' => 0.292, - ], + + $builder = m::mock(Builder::class); + + $results = $engine->map($builder, [ + 'nbHits' => 4, + 'hits' => [ + [ + '_id' => 1, + 'id' => 1, ], - 'items' => [], - 'facet' => [], - ], - 'qp' => [ [ - 'app_name' => '84922', - 'query_correction_info' => [ - [ - 'index' => 'default', - 'original_query' => '平果手机充电器', - 'corrected_query' => '苹果手机充电器', - 'correction_level' => 1, - 'processor_name' => 'spell_check', - ], - ], + '_id' => 2, + 'id' => 2, + ], + [ + '_id' => 4, + 'id' => 4, + ], + [ + '_id' => 3, + 'id' => 3, ], ], - 'errors' => [], - 'tracer' => '', + ], $model); - 'ops_request_misc' => '%7B%22request%5Fid%22%3A%22155310917017444091100003%22%7D', - ]; - $result = json_encode($jsonData); + self::assertCount(4, $results); - $this->client->shouldReceive('get') - ->times(1) - ->andReturn(new OpenSearchResult([ - 'result' => $result, - ])); - self::assertCount(0, SearchableModel::search('test')->get()); + // It's important we assert with array keys to ensure + // they have been reset after sorting. + self::assertSame([ + 0 => [ + 'id' => 1, + ], + 1 => [ + 'id' => 2, + ], + 2 => [ + 'id' => 4, + ], + 3 => [ + 'id' => 3, + ], + ], $results->toArray()); } - public function testCursor(): void + public function testLazyMapCorrectlyMapsResultsToModels(): void { if (! method_exists(Builder::class, 'cursor')) { self::markTestSkipped('Support for cursor available since 9.0.'); } - - $this->client->shouldReceive('post') - ->andReturn(new OpenSearchResult([ - 'result' => '{ - "errors": [], - "request_id": "150116724719940316170289", - "status": "OK", - "result": true -}', + $client = m::mock(Client::class); + $engine = new OpenSearchEngine($client); + + $model = m::mock(SearchableModel::class); + $model->shouldReceive('queryScoutModelsByIds->cursor') + ->andReturn($models = LazyCollection::make([ + new SearchableModel([ + 'id' => 1, + ]), ])); - $lazyCollection = SearchableModel::query()->create([ - 'name' => 'test', - ]); - $jsonData = [ - 'status' => 'OK', - 'request_id' => '155310917017444091100003', - 'result' => [ - 'searchtime' => 0.031081, - 'total' => 1, - 'num' => 1, - 'viewtotal' => 1, - 'compute_cost' => [ - [ - 'index_name' => '84922', - 'value' => 0.292, - ], - ], - 'items' => [], - 'facet' => [], - ], - 'qp' => [ + + $builder = m::mock(Builder::class); + + $results = $engine->lazyMap($builder, [ + 'nbHits' => 1, + 'hits' => [ [ - 'app_name' => '84922', - 'query_correction_info' => [ - [ - 'index' => 'default', - 'original_query' => '平果手机充电器', - 'corrected_query' => '苹果手机充电器', - 'correction_level' => 1, - 'processor_name' => 'spell_check', - ], - ], + '_id' => 1, + 'id' => 1, ], ], - 'errors' => [], - 'tracer' => '', + ], $model); - 'ops_request_misc' => '%7B%22request%5Fid%22%3A%22155310917017444091100003%22%7D', - ]; - $result = json_encode($jsonData); - - $this->client->shouldReceive('get') - ->times(1) - ->andReturn(new OpenSearchResult([ - 'result' => $result, - ])); - foreach (SearchableModel::search('test')->cursor() as $lazyCollection) { - self::assertInstanceOf(SearchableModel::class, $lazyCollection); - } - - $result = <<getKey()}, - "name": "我是一条新文档的标题", - "phone": "18312345678", - "index_name": "app_schema_demo" - }, - "property": {}, - "attribute": {}, - "variableValue": {}, - "sortExprValues": [ - "10", - "10000.1354238242" - ] - } - ], - "facet": [] - }, - "qp": [ - { - "app_name": "84922", - "query_correction_info": [ - { - "index": "default", - "original_query": "平果手机充电器", - "corrected_query": "苹果手机充电器", - "correction_level": 1, - "processor_name": "spell_check" - } - ] - } - ], - "errors": [], - "tracer": "", - "ops_request_misc": "%7B%22request%5Fid%22%3A%22155310917017444091100003%22%7D" - } - CODE_SAMPLE; - - $this->client->shouldReceive('get') - ->times(1) - ->andReturn(new OpenSearchResult([ - 'result' => $result, - ])); - foreach (SearchableModel::search('test')->cursor() as $lazyCollection) { - self::assertInstanceOf(SearchableModel::class, $lazyCollection); - } + self::assertCount(1, $results); } - public function testCursorFailed(): void + public function testLazyMapMethodRespectsOrder(): void { if (! method_exists(Builder::class, 'cursor')) { self::markTestSkipped('Support for cursor available since 9.0.'); } - - $this->client->shouldReceive('post') - ->andReturn(new OpenSearchResult([ - 'result' => '{ - "errors": [], - "request_id": "150116724719940316170289", - "status": "OK", - "result": true -}', + $client = m::mock(Client::class); + $engine = new OpenSearchEngine($client); + + $model = m::mock(SearchableModel::class); + $model->shouldReceive('queryScoutModelsByIds->cursor') + ->andReturn($models = LazyCollection::make([ + new SearchableModel([ + 'id' => 1, + ]), + new SearchableModel([ + 'id' => 2, + ]), + new SearchableModel([ + 'id' => 3, + ]), + new SearchableModel([ + 'id' => 4, + ]), ])); - $lazyCollection = SearchableModel::query()->create([ - 'name' => 'test', - ]); - $jsonData = [ - 'status' => 'OK', - 'request_id' => '155310917017444091100003', - 'result' => [ - 'searchtime' => 0.031081, - 'total' => 1, - 'num' => 1, - 'viewtotal' => 1, - 'compute_cost' => [ - [ - 'index_name' => '84922', - 'value' => 0.292, - ], + + $builder = m::mock(Builder::class); + + $results = $engine->lazyMap($builder, [ + 'hits' => [ + [ + '_id' => 1, + 'id' => 1, ], - 'items' => [], - 'facet' => [], - ], - 'qp' => [ [ - 'app_name' => '84922', - 'query_correction_info' => [ - [ - 'index' => 'default', - 'original_query' => '平果手机充电器', - 'corrected_query' => '苹果手机充电器', - 'correction_level' => 1, - 'processor_name' => 'spell_check', - ], - ], + '_id' => 2, + 'id' => 2, + ], + [ + '_id' => 4, + 'id' => 4, + ], + [ + '_id' => 3, + 'id' => 3, ], ], - 'errors' => [], - 'tracer' => '', - - 'ops_request_misc' => '%7B%22request%5Fid%22%3A%22155310917017444091100003%22%7D', - ]; - $result = json_encode($jsonData); + ], $model); - $this->client->shouldReceive('get') - ->times(1) - ->andReturn(new OpenSearchResult([ - 'result' => $result, - ])); - foreach (SearchableModel::search('test')->cursor() as $lazyCollection) { - self::assertInstanceOf(SearchableModel::class, $lazyCollection); - } + self::assertCount(4, $results); - $jsonData = [ - 'errors' => [ - [ - 'code' => 2001, - 'message' => '待查应用不存在.待查应用不存在。', - 'params' => [ - 'friendly_message' => '待查应用不存在。', - ], - ], + // It's important we assert with array keys to ensure + // they have been reset after sorting. + self::assertSame([ + 0 => [ + 'id' => 1, ], - 'request_id' => '150116732819940316116461', - 'status' => 'FAIL', - ]; - $result = json_encode($jsonData); - - $this->client->shouldReceive('get') - ->times(1) - ->andReturn(new OpenSearchResult([ - 'result' => $result, - ])); - self::assertCount(0, SearchableModel::search('test')->cursor()); + 1 => [ + 'id' => 2, + ], + 2 => [ + 'id' => 4, + ], + 3 => [ + 'id' => 3, + ], + ], $results->toArray()); } - public function testPaginate2(): void + public function testAModelIsIndexedWithACustomAlgoliaKey(): void { - $this->client->shouldReceive('post') - ->andReturn(new OpenSearchResult([ - 'result' => '{ - "errors": [], - "request_id": "150116724719940316170289", - "status": "OK", - "result": true -}', - ])); - $lazyCollection = SearchableModel::query()->create([ - 'name' => 'test', - ]); - $result = <<shouldReceive('bulk') + ->once() + ->with([ + 'index' => 'table', + 'body' => [ + [ + 'index' => [ + '_index' => 'table', + '_id' => 'my-opensearch-key.1', + ], ], - "items": [ - { - "fields": { - "id": {$lazyCollection->getKey()}, - "name": "我是一条新文档的标题", - "phone": "18312345678", - "index_name": "app_schema_demo" - }, - "property": {}, - "attribute": {}, - "variableValue": {}, - "sortExprValues": [ - "10", - "10000.1354238242" - ] - } + [ + 'id' => 'my-opensearch-key.1', ], - "facet": [] - }, - "qp": [ - { - "app_name": "84922", - "query_correction_info": [ - { - "index": "default", - "original_query": "平果手机充电器", - "corrected_query": "苹果手机充电器", - "correction_level": 1, - "processor_name": "spell_check" - } - ] - } ], - "errors": [], - "tracer": "", - "ops_request_misc": "%7B%22request%5Fid%22%3A%22155310917017444091100003%22%7D" - } - CODE_SAMPLE; - - $this->client->shouldReceive('get') - ->times(1) - ->andReturn(new OpenSearchResult([ - 'result' => $result, - ])); - self::assertSame(1, SearchableModel::search('test')->paginate()->total()); - $this->client->shouldReceive('get') - ->times(1) - ->andReturn(new OpenSearchResult([ - 'result' => $result, - ])); - self::assertSame(1, SearchableModel::search('test')->query(static function (): void { - })->paginate() - ->total()); + ]); + + $engine = new OpenSearchEngine($client); + $engine->update(Collection::make([ + new CustomKeySearchableModel([ + 'id' => 1, + ]), + ])); } - public function testPaginateFailed(): void + public function testAModelIsRemovedWithACustomAlgoliaKey(): void { - $this->client->shouldReceive('post') - ->andReturn(new OpenSearchResult([ - 'result' => '{ - "errors": [], - "request_id": "150116724719940316170289", - "status": "OK", - "result": true -}', - ])); - SearchableModel::query()->create([ - 'name' => 'test', - ]); - $jsonData = [ - 'errors' => [ - [ - 'code' => 2001, - 'message' => '待查应用不存在.待查应用不存在。', - 'params' => [ - 'friendly_message' => '待查应用不存在。', + $client = m::mock(Client::class); + $client->shouldReceive('bulk') + ->once() + ->with([ + 'index' => 'table', + 'body' => [ + [ + 'delete' => [ + '_index' => 'table', + '_id' => 'my-opensearch-key.1', + ], ], ], - ], - 'request_id' => '150116732819940316116461', - 'status' => 'FAIL', - ]; - $result = json_encode($jsonData); - - $this->client->shouldReceive('get') - ->times(1) - ->andReturn(new OpenSearchResult([ - 'result' => $result, - ])); - self::assertSame(0, SearchableModel::search('test')->paginate()->total()); - $this->client->shouldReceive('get') - ->times(1) - ->andReturn(new OpenSearchResult([ - 'result' => $result, - ])); - self::assertSame(0, SearchableModel::search('test')->query(static function (): void { - })->paginate() - ->total()); + ]); + + $engine = new OpenSearchEngine($client); + $engine->delete(Collection::make([ + new CustomKeySearchableModel([ + 'id' => 1, + ]), + ])); + } + + public function testFlushAModelWithACustomAlgoliaKey(): void + { + $builder = m::mock(Builder::class); + $builder->shouldReceive('unsearchable') + ->once() + ->withNoArgs(); + $model = m::mock(CustomKeySearchableModel::class); + $model->shouldReceive('getKeyName') + ->withNoArgs() + ->andReturn('table'); + $model->shouldReceive('newQuery->orderBy') + ->with('table') + ->andReturn($builder); + + $engine = new OpenSearchEngine(ClientBuilder::fromConfig([])); + $engine->flush($model); + } + + public function testUpdateEmptySearchableArrayDoesNotAddObjectsToIndex(): void + { + $client = m::mock(Client::class); + + $client->shouldNotReceive('bulk'); + $engine = new OpenSearchEngine($client); + $engine->update(Collection::make([new EmptySearchableModel()])); } - public function testFlush(): void + public function testUpdateEmptySearchableArrayFromSoftDeletedModelDoesNotAddObjectsToIndex(): void { - $this->client->shouldReceive('post') - ->andReturn(new OpenSearchResult([ - 'result' => '{ - "errors": [], - "request_id": "150116724719940316170289", - "status": "OK", - "result": true -}', - ]))->twice(); - SearchableModel::query()->create([ - 'name' => 'test', - ]); - SearchableModel::removeAllFromSearch(); + $client = m::mock(Client::class); + $client->shouldNotReceive('bulk'); + $engine = new OpenSearchEngine($client, true); + $engine->update(Collection::make([new SoftDeletedEmptySearchableModel()])); } } diff --git a/tests/ScoutTest.php b/tests/ScoutTest.php index bfe3263..2d330ca 100644 --- a/tests/ScoutTest.php +++ b/tests/ScoutTest.php @@ -4,14 +4,119 @@ namespace Zing\LaravelScout\OpenSearch\Tests; +use Illuminate\Foundation\Testing\WithFaker; +use Laravel\Scout\Builder; + /** * @internal */ final class ScoutTest extends TestCase { + use WithFaker; + + protected function setUp(): void + { + parent::setUp(); + + $searchableModel = new SearchableModel(); + $searchableModel->searchableUsing() + ->createIndex($searchableModel->searchableAs()); + } + + protected function tearDown(): void + { + $searchableModel = new SearchableModel(); + $searchableModel->searchableUsing() + ->deleteIndex($searchableModel->searchableAs()); + + parent::tearDown(); + } + public function testSearch(): void { - $this->expectException(\Throwable::class); - SearchableModel::search('test')->get(); + SearchableModel::query()->create([ + 'name' => 'test search 1', + ]); + SearchableModel::query()->create([ + 'name' => 'test search 2', + ]); + SearchableModel::query()->create([ + 'name' => 'test search 3', + ]); + SearchableModel::query()->create([ + 'name' => 'test search 4', + ]); + SearchableModel::query()->create([ + 'name' => 'test search 5', + ]); + SearchableModel::query()->create([ + 'name' => 'test search 6', + ]); + SearchableModel::query()->create([ + 'name' => 'not matched', + ]); + sleep(1); + self::assertCount(6, SearchableModel::search('test')->get()); + SearchableModel::query()->firstOrFail()->delete(); + sleep(1); + self::assertCount(5, SearchableModel::search('test')->get()); + self::assertCount(1, SearchableModel::search('test')->paginate(2, 'page', 3)->items()); + if (method_exists(Builder::class, 'cursor')) { + self::assertCount(5, SearchableModel::search('test')->cursor()); + } + + self::assertCount(5, SearchableModel::search('test')->keys()); + SearchableModel::removeAllFromSearch(); + sleep(1); + self::assertCount(0, SearchableModel::search('test')->get()); + self::assertCount(0, SearchableModel::search('test')->paginate(2, 'page', 3)->items()); + if (method_exists(Builder::class, 'cursor')) { + self::assertCount(0, SearchableModel::search('test')->cursor()); + } + + self::assertCount(0, SearchableModel::search('test')->keys()); + } + + public function testOrderBy(): void + { + SearchableModel::query()->create([ + 'name' => 'test search 1', + ]); + SearchableModel::query()->create([ + 'name' => 'test search 2', + ]); + SearchableModel::query()->create([ + 'name' => 'test search 3', + ]); + sleep(1); + self::assertSame(3, SearchableModel::search('test')->first()->getKey()); + self::assertSame(1, SearchableModel::search('test')->orderBy('id')->first()->getKey()); + self::assertSame(3, SearchableModel::search('test')->orderBy('id', 'desc')->first()->getKey()); + } + + public function testWhere(): void + { + SearchableModel::query()->create([ + 'name' => 'test', + 'is_visible' => 1, + ]); + SearchableModel::query()->create([ + 'name' => 'test', + 'is_visible' => 1, + ]); + SearchableModel::query()->create([ + 'name' => 'test', + 'is_visible' => 0, + ]); + SearchableModel::query()->create([ + 'name' => 'nothing', + ]); + sleep(1); + self::assertCount(3, SearchableModel::search('test')->get()); + self::assertCount(2, SearchableModel::search('test')->where('is_visible', 1)->get()); + if (method_exists(Builder::class, 'whereIn')) { + self::assertCount(3, SearchableModel::search('test')->whereIn('is_visible', [0, 1])->get()); + self::assertCount(0, SearchableModel::search('test')->whereIn('is_visible', [])->get()); + } } } diff --git a/tests/SearchableModel.php b/tests/SearchableModel.php index c0b8daa..d7bd7ee 100644 --- a/tests/SearchableModel.php +++ b/tests/SearchableModel.php @@ -9,6 +9,9 @@ use Laravel\Scout\Searchable; /** + * @property string $name + * @property int $is_visible + * * @method static static|\Illuminate\Database\Eloquent\Builder|\Illuminate\Database\Query\Builder query() */ class SearchableModel extends Model @@ -18,7 +21,7 @@ class SearchableModel extends Model public function searchableAs(): string { - return 'app.table'; + return 'searchable-model'; } /** @@ -28,11 +31,13 @@ public function toSearchableArray(): array { return [ 'id' => $this->getScoutKey(), + 'name' => $this->name, + 'is_visible' => $this->is_visible, ]; } /** * @var string[] */ - protected $fillable = ['name']; + protected $fillable = ['name', 'is_visible']; } diff --git a/tests/TestCase.php b/tests/TestCase.php index 8ca8697..2054db3 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -8,6 +8,7 @@ use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\DB; use Laravel\Scout\ScoutServiceProvider; +use OpenSearch\ClientBuilder; use Orchestra\Testbench\TestCase as BaseTestCase; use Zing\LaravelScout\OpenSearch\OpenSearchServiceProvider; @@ -52,9 +53,11 @@ protected function getEnvironmentSetUp($app): void ); Config::set('scout.driver', 'opensearch'); Config::set('scout.opensearch', [ - 'access_key' => 'your-opensearch-access-key', - 'secret' => 'your-opensearch-secret', - 'host' => 'your-opensearch-host', + 'hosts' => ['localhost:9200'], + 'retries' => 2, + 'handler' => ClientBuilder::multiHandler(), + 'basicAuthentication' => ['admin', 'admin'], + 'sslVerification' => false, ]); }