diff --git a/src/Builder.php b/src/Builder.php index 851fcf28..f6ca515f 100644 --- a/src/Builder.php +++ b/src/Builder.php @@ -5,7 +5,6 @@ use Illuminate\Container\Container; use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Pagination\Paginator; -use Illuminate\Support\Str; use Illuminate\Support\Traits\Macroable; use Laravel\Scout\Contracts\PaginatesEloquentModels; @@ -444,7 +443,7 @@ protected function getTotalCount($results) $ids = $engine->mapIdsFrom( $results, - Str::afterLast($this->model->getScoutKeyName(), '.') + $this->model->getUnqualifiedScoutKeyName() )->all(); if (count($ids) < $totalCount) { diff --git a/src/Engines/AlgoliaEngine.php b/src/Engines/AlgoliaEngine.php index 3739ee6f..ad4aee5a 100644 --- a/src/Engines/AlgoliaEngine.php +++ b/src/Engines/AlgoliaEngine.php @@ -7,6 +7,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Support\LazyCollection; use Laravel\Scout\Builder; +use Laravel\Scout\Jobs\RemoveableScoutCollection; class AlgoliaEngine extends Engine { @@ -63,9 +64,9 @@ public function update($models) } return array_merge( - ['objectID' => $model->getScoutKey()], $searchableData, - $model->scoutMetadata() + $model->scoutMetadata(), + ['objectID' => $model->getScoutKey()], ); })->filter()->values()->all(); @@ -82,13 +83,17 @@ public function update($models) */ public function delete($models) { + if ($models->isEmpty()) { + return; + } + $index = $this->algolia->initIndex($models->first()->searchableAs()); - $index->deleteObjects( - $models->map(function ($model) { - return $model->getScoutKey(); - })->values()->all() - ); + $keys = $models instanceof RemoveableScoutCollection + ? $models->pluck($models->first()->getUnqualifiedScoutKeyName()) + : $models->map->getScoutKey(); + + $index->deleteObjects($keys->all()); } /** diff --git a/src/Engines/MeiliSearchEngine.php b/src/Engines/MeiliSearchEngine.php index e338f1b5..896fd478 100644 --- a/src/Engines/MeiliSearchEngine.php +++ b/src/Engines/MeiliSearchEngine.php @@ -3,8 +3,8 @@ namespace Laravel\Scout\Engines; use Illuminate\Support\LazyCollection; -use Illuminate\Support\Str; use Laravel\Scout\Builder; +use Laravel\Scout\Jobs\RemoveableScoutCollection; use MeiliSearch\Client as MeiliSearchClient; use MeiliSearch\MeiliSearch; use MeiliSearch\Search\SearchResult; @@ -64,9 +64,9 @@ public function update($models) } return array_merge( - [$model->getKeyName() => $model->getScoutKey()], $searchableData, - $model->scoutMetadata() + $model->scoutMetadata(), + [$model->getKeyName() => $model->getScoutKey()], ); })->filter()->values()->all(); @@ -83,13 +83,17 @@ public function update($models) */ public function delete($models) { + if ($models->isEmpty()) { + return; + } + $index = $this->meilisearch->index($models->first()->searchableAs()); - $index->deleteDocuments( - $models->map->getScoutKey() - ->values() - ->all() - ); + $keys = $models instanceof RemoveableScoutCollection + ? $models->pluck($models->first()->getUnqualifiedScoutKeyName()) + : $models->map->getScoutKey(); + + $index->deleteDocuments($keys->all()); } /** @@ -244,7 +248,7 @@ public function mapIdsFrom($results, $key) */ public function keys(Builder $builder) { - $scoutKey = Str::afterLast($builder->model->getScoutKeyName(), '.'); + $scoutKey = $builder->model->getUnqualifiedScoutKeyName(); return $this->mapIdsFrom($this->search($builder), $scoutKey); } diff --git a/src/Jobs/RemoveFromSearch.php b/src/Jobs/RemoveFromSearch.php index 565b71bf..c566b678 100644 --- a/src/Jobs/RemoveFromSearch.php +++ b/src/Jobs/RemoveFromSearch.php @@ -4,9 +4,7 @@ use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; -use Illuminate\Database\Eloquent\Collection as EloquentCollection; use Illuminate\Queue\SerializesModels; -use Illuminate\Support\Str; class RemoveFromSearch implements ShouldQueue { @@ -15,7 +13,7 @@ class RemoveFromSearch implements ShouldQueue /** * The models to be removed from the search index. * - * @var \Illuminate\Database\Eloquent\Collection + * @var \Laravel\Scout\Jobs\RemoveableScoutCollection */ public $models; @@ -46,35 +44,24 @@ public function handle() * Restore a queueable collection instance. * * @param \Illuminate\Contracts\Database\ModelIdentifier $value - * @return \Illuminate\Database\Eloquent\Collection + * @return \Laravel\Scout\Jobs\RemoveableScoutCollection */ protected function restoreCollection($value) { if (! $value->class || count($value->id) === 0) { - return new EloquentCollection; + return new RemoveableScoutCollection; } - return new EloquentCollection( + return new RemoveableScoutCollection( collect($value->id)->map(function ($id) use ($value) { return tap(new $value->class, function ($model) use ($id) { - $keyName = $this->getUnqualifiedScoutKeyName( - $model->getScoutKeyName() - ); - - $model->forceFill([$keyName => $id]); + $model->setKeyType( + is_string($id) ? 'string' : 'int' + )->forceFill([ + $model->getUnqualifiedScoutKeyName() => $id, + ]); }); }) ); } - - /** - * Get the unqualified Scout key name. - * - * @param string $keyName - * @return string - */ - protected function getUnqualifiedScoutKeyName($keyName) - { - return Str::afterLast($keyName, '.'); - } } diff --git a/src/Searchable.php b/src/Searchable.php index 3e2f0e8e..1a9fd6ce 100644 --- a/src/Searchable.php +++ b/src/Searchable.php @@ -4,6 +4,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Support\Collection as BaseCollection; +use Illuminate\Support\Str; trait Searchable { @@ -389,6 +390,16 @@ public function getScoutKeyName() return $this->getQualifiedKeyName(); } + /** + * Get the unqualified Scout key name. + * + * @return string + */ + public function getUnqualifiedScoutKeyName() + { + return Str::afterLast($this->getScoutKeyName(), '.'); + } + /** * Determine if the current class should use soft deletes with searching. * diff --git a/tests/Unit/AlgoliaEngineTest.php b/tests/Unit/AlgoliaEngineTest.php index debe1b29..3ce27013 100644 --- a/tests/Unit/AlgoliaEngineTest.php +++ b/tests/Unit/AlgoliaEngineTest.php @@ -3,11 +3,14 @@ namespace Laravel\Scout\Tests\Unit; use Algolia\AlgoliaSearch\SearchClient; +use Illuminate\Container\Container; use Illuminate\Database\Eloquent\Collection; use Illuminate\Support\Facades\Config; use Illuminate\Support\LazyCollection; use Laravel\Scout\Builder; +use Laravel\Scout\EngineManager; use Laravel\Scout\Engines\AlgoliaEngine; +use Laravel\Scout\Jobs\RemoveFromSearch; use Laravel\Scout\Tests\Fixtures\EmptySearchableModel; use Laravel\Scout\Tests\Fixtures\SearchableModel; use Laravel\Scout\Tests\Fixtures\SoftDeletedEmptySearchableModel; @@ -25,6 +28,7 @@ protected function setUp(): void protected function tearDown(): void { + Container::getInstance()->flush(); m::close(); } @@ -51,6 +55,59 @@ public function test_delete_removes_objects_to_index() $engine->delete(Collection::make([new SearchableModel(['id' => 1])])); } + public function test_delete_removes_objects_to_index_with_a_custom_search_key() + { + $client = m::mock(SearchClient::class); + $client->shouldReceive('initIndex')->with('table')->andReturn($index = m::mock(Indexes::class)); + $index->shouldReceive('deleteObjects')->once()->with(['my-algolia-key.5']); + + $engine = new AlgoliaEngine($client); + $engine->delete(Collection::make([new AlgoliaCustomKeySearchableModel(['id' => 5])])); + } + + public function test_delete_with_removeable_scout_collection_using_custom_search_key() + { + $job = new RemoveFromSearch(Collection::make([ + new AlgoliaCustomKeySearchableModel(['id' => 5]), + ])); + + $job = unserialize(serialize($job)); + + $client = m::mock(SearchClient::class); + $client->shouldReceive('initIndex')->with('table')->andReturn($index = m::mock(stdClass::class)); + $index->shouldReceive('deleteObjects')->once()->with(['my-algolia-key.5']); + + $engine = new AlgoliaEngine($client); + $engine->delete($job->models); + } + + public function test_remove_from_search_job_uses_custom_search_key() + { + $job = new RemoveFromSearch(Collection::make([ + new AlgoliaCustomKeySearchableModel(['id' => 5]), + ])); + + $job = unserialize(serialize($job)); + + Container::getInstance()->bind(EngineManager::class, function () { + $engine = m::mock(AlgoliaEngine::class); + + $engine->shouldReceive('delete')->once()->with(m::on(function ($collection) { + $keyName = ($model = $collection->first())->getUnqualifiedScoutKeyName(); + + return $model->getAttributes()[$keyName] === 'my-algolia-key.5'; + })); + + $manager = m::mock(EngineManager::class); + + $manager->shouldReceive('engine')->andReturn($engine); + + return $manager; + }); + + $job->handle(); + } + public function test_search_sends_correct_parameters_to_algolia() { $client = m::mock(SearchClient::class); diff --git a/tests/Unit/MeiliSearchEngineTest.php b/tests/Unit/MeiliSearchEngineTest.php index 4d80903a..33822fb5 100644 --- a/tests/Unit/MeiliSearchEngineTest.php +++ b/tests/Unit/MeiliSearchEngineTest.php @@ -2,10 +2,14 @@ namespace Laravel\Scout\Tests\Unit; +use Illuminate\Container\Container; use Illuminate\Database\Eloquent\Collection; +use Illuminate\Support\Facades\Config; use Illuminate\Support\LazyCollection; use Laravel\Scout\Builder; +use Laravel\Scout\EngineManager; use Laravel\Scout\Engines\MeiliSearchEngine; +use Laravel\Scout\Jobs\RemoveFromSearch; use Laravel\Scout\Tests\Fixtures\EmptySearchableModel; use Laravel\Scout\Tests\Fixtures\SearchableModel; use Laravel\Scout\Tests\Fixtures\SoftDeletedEmptySearchableModel; @@ -18,6 +22,18 @@ class MeiliSearchEngineTest extends TestCase { + protected function setUp(): void + { + Config::shouldReceive('get')->with('scout.after_commit', m::any())->andReturn(false); + Config::shouldReceive('get')->with('scout.soft_delete', m::any())->andReturn(false); + } + + protected function tearDown(): void + { + Container::getInstance()->flush(); + m::close(); + } + public function test_update_adds_objects_to_index() { $client = m::mock(Client::class); @@ -43,6 +59,59 @@ public function test_delete_removes_objects_to_index() $engine->delete(Collection::make([new SearchableModel(['id' => 1])])); } + public function test_delete_removes_objects_to_index_with_a_custom_search_key() + { + $client = m::mock(Client::class); + $client->shouldReceive('index')->with('table')->andReturn($index = m::mock(Indexes::class)); + $index->shouldReceive('deleteDocuments')->once()->with(['my-meilisearch-key.5']); + + $engine = new MeiliSearchEngine($client); + $engine->delete(Collection::make([new MeiliSearchCustomKeySearchableModel(['id' => 5])])); + } + + public function test_delete_with_removeable_scout_collection_using_custom_search_key() + { + $job = new RemoveFromSearch(Collection::make([ + new MeiliSearchCustomKeySearchableModel(['id' => 5]), + ])); + + $job = unserialize(serialize($job)); + + $client = m::mock(Client::class); + $client->shouldReceive('index')->with('table')->andReturn($index = m::mock(Indexes::class)); + $index->shouldReceive('deleteDocuments')->once()->with(['my-meilisearch-key.5']); + + $engine = new MeiliSearchEngine($client); + $engine->delete($job->models); + } + + public function test_remove_from_search_job_uses_custom_search_key() + { + $job = new RemoveFromSearch(Collection::make([ + new MeiliSearchCustomKeySearchableModel(['id' => 5]), + ])); + + $job = unserialize(serialize($job)); + + Container::getInstance()->bind(EngineManager::class, function () { + $engine = m::mock(MeiliSearchEngine::class); + + $engine->shouldReceive('delete')->once()->with(m::on(function ($collection) { + $keyName = ($model = $collection->first())->getUnqualifiedScoutKeyName(); + + return $model->getAttributes()[$keyName] === 'my-meilisearch-key.5'; + })); + + $manager = m::mock(EngineManager::class); + + $manager->shouldReceive('engine')->andReturn($engine); + + return $manager; + }); + + $job->handle(); + } + public function test_search_sends_correct_parameters_to_meilisearch() { $client = m::mock(Client::class); @@ -171,7 +240,7 @@ public function test_returns_primary_keys_when_custom_array_order_present() $builder = m::mock(Builder::class); $model = m::mock(stdClass::class); - $model->shouldReceive(['getScoutKeyName' => 'table.custom_key']); + $model->shouldReceive(['getUnqualifiedScoutKeyName' => 'custom_key']); $builder->model = $model; $engine->shouldReceive('keys')->passthru(); @@ -300,7 +369,7 @@ public function test_a_model_is_indexed_with_a_custom_meilisearch_key() $client = m::mock(Client::class); $client->shouldReceive('index')->with('table')->andReturn($index = m::mock(Indexes::class)); $index->shouldReceive('addDocuments')->once()->with([[ - 'id' => 5, + 'id' => 'my-meilisearch-key.5', ]], 'id'); $engine = new MeiliSearchEngine($client); diff --git a/tests/Unit/RemoveableScoutCollectionTest.php b/tests/Unit/RemoveableScoutCollectionTest.php new file mode 100644 index 00000000..1acef29c --- /dev/null +++ b/tests/Unit/RemoveableScoutCollectionTest.php @@ -0,0 +1,53 @@ +with('scout.after_commit', m::any())->andReturn(false); + Config::shouldReceive('get')->with('scout.soft_delete', m::any())->andReturn(false); + } + + public function test_get_queuable_ids() + { + $collection = RemoveableScoutCollection::make([ + new SearchableModel(['id' => 1]), + new SearchableModel(['id' => 2]), + ]); + + $this->assertEquals([1, 2], $collection->getQueueableIds()); + } + + public function test_get_queuable_ids_resolves_custom_scout_keys() + { + $collection = RemoveableScoutCollection::make([ + new SearchCustomKeySearchableModel(['id' => 1]), + new SearchCustomKeySearchableModel(['id' => 2]), + new SearchCustomKeySearchableModel(['id' => 3]), + new SearchCustomKeySearchableModel(['id' => 4]), + ]); + + $this->assertEquals([ + 'custom-key.1', + 'custom-key.2', + 'custom-key.3', + 'custom-key.4', + ], $collection->getQueueableIds()); + } +} + +class SearchCustomKeySearchableModel extends SearchableModel +{ + public function getScoutKey() + { + return 'custom-key.'.$this->getKey(); + } +}