diff --git a/src/Scout/ScoutEngine.php b/src/Scout/ScoutEngine.php index 5b866ded2..99a558307 100644 --- a/src/Scout/ScoutEngine.php +++ b/src/Scout/ScoutEngine.php @@ -25,7 +25,6 @@ use TypeError; use function array_column; -use function array_filter; use function array_flip; use function array_map; use function array_merge; @@ -146,10 +145,7 @@ public function delete($models): void */ public function search(Builder $builder) { - return $this->performSearch($builder, array_filter([ - 'filters' => $this->filters($builder), - 'limit' => $builder->limit, - ])); + return $this->performSearch($builder); } /** @@ -165,17 +161,16 @@ public function paginate(Builder $builder, $perPage, $page) assert(is_int($perPage), new TypeError(sprintf('Argument #2 ($perPage) must be of type int, %s given', get_debug_type($perPage)))); assert(is_int($page), new TypeError(sprintf('Argument #3 ($page) must be of type int, %s given', get_debug_type($page)))); - return $this->performSearch($builder, array_filter([ - 'filters' => $this->filters($builder), - 'limit' => (int) $perPage, - 'offset' => ($page - 1) * $perPage, - ])); + $builder = clone $builder; + $builder->take($perPage); + + return $this->performSearch($builder, $perPage * ($page - 1)); } /** * Perform the given search on the engine. */ - protected function performSearch(Builder $builder, array $searchParams = []): array + protected function performSearch(Builder $builder, ?int $offset = null): array { $collection = $this->getSearchableCollection($builder->model); @@ -184,7 +179,7 @@ protected function performSearch(Builder $builder, array $searchParams = []): ar $builder->callback, $collection, $builder->query, - $searchParams, + $offset, ); return $result instanceof Cursor ? $result->toArray() : $result; @@ -215,6 +210,18 @@ protected function performSearch(Builder $builder, array $searchParams = []): ar ], ]; + if ($builder->orders) { + $pipeline[0]['$search']['sort'] = array_merge(...array_map(fn ($order) => [$order['column'] => $order['direction'] === 'asc' ? 1 : -1], $builder->orders)); + } + + if ($builder->limit) { + $pipeline[] = ['$limit' => $builder->limit]; + } + + if ($offset) { + $pipeline[] = ['$skip' => $offset]; + } + $options = [ 'allowDiskUse' => true, 'typeMap' => ['root' => 'object', 'document' => 'array', 'array' => 'array'], diff --git a/tests/Models/SearchableModel.php b/tests/Models/SearchableModel.php index a0985657c..796002b99 100644 --- a/tests/Models/SearchableModel.php +++ b/tests/Models/SearchableModel.php @@ -29,6 +29,11 @@ public function indexableAs(): string public function getScoutKey(): string { - return 'key_' . $this->id; + return $this->getAttribute($this->getScoutKeyName()) ?: 'key_' . $this->getKey(); + } + + public function getScoutKeyName(): string + { + return 'scout_key'; } } diff --git a/tests/Scout/ScoutEngineTest.php b/tests/Scout/ScoutEngineTest.php index 09ae190ba..26ae0133e 100644 --- a/tests/Scout/ScoutEngineTest.php +++ b/tests/Scout/ScoutEngineTest.php @@ -7,6 +7,8 @@ use DateTimeImmutable; use Illuminate\Database\Eloquent\Collection as EloquentCollection; use Laravel\Scout\Builder; +use Laravel\Scout\Jobs\RemoveFromSearch; +use Laravel\Scout\Tests\Unit\AlgoliaEngineTest; use Mockery as m; use MongoDB\BSON\Document; use MongoDB\BSON\Regex; @@ -20,9 +22,14 @@ use MongoDB\Model\BSONDocument; use PHPUnit\Framework\Attributes\DataProvider; +use function serialize; +use function unserialize; + /** Unit tests that do not require an Atlas Search cluster */ class ScoutEngineTest extends TestCase { + private const EXPECTED_SEARCH_OPTIONS = ['allowDiskUse' => true, 'typeMap' => ['root' => 'object', 'document' => 'array', 'array' => 'array']]; + /** @param callable(): Builder $builder */ #[DataProvider('provideSearchPipelines')] public function testSearch(Closure $builder, array $expectedPipeline): void @@ -36,8 +43,7 @@ public function testSearch(Closure $builder, array $expectedPipeline): void $cursor = m::mock(CursorInterface::class); $cursor->shouldReceive('toArray')->once()->with()->andReturn($data); - $options = ['allowDiskUse' => true, 'typeMap' => ['root' => 'object', 'document' => 'array', 'array' => 'array']]; - $collection->shouldReceive('aggregate')->once()->with($expectedPipeline, $options)->andReturn($cursor); + $collection->shouldReceive('aggregate')->once()->with($expectedPipeline, self::EXPECTED_SEARCH_OPTIONS)->andReturn($cursor); $engine = new ScoutEngine($database, softDelete: false, prefix: ''); $result = $engine->search($builder()); @@ -157,6 +163,66 @@ function () { public function testPaginate() { + $perPage = 5; + $page = 3; + + $database = m::mock(Database::class); + $collection = m::mock(Collection::class); + $cursor = m::mock(CursorInterface::class); + $database->shouldReceive('selectCollection') + ->with('table_searchable') + ->andReturn($collection); + $collection->shouldReceive('aggregate') + ->once() + ->withArgs(function (...$args) { + self::assertSame([ + [ + '$search' => [ + 'index' => 'scout', + 'text' => [ + 'query' => 'mustang', + 'path' => [ + 'wildcard' => '*', + ], + 'fuzzy' => [ + 'maxEdits' => 2, + ], + ], + 'count' => [ + 'type' => 'lowerBound', + ], + 'sort' => [ + 'name' => -1, + ], + ], + ], + [ + '$addFields' => [ + 'search_meta' => '$$SEARCH_META', + ], + ], + [ + '$limit' => 5, + ], + [ + '$skip' => 10, + ], + ], $args[0]); + + $this->assertSame(self::EXPECTED_SEARCH_OPTIONS, $args[1]); + + return true; + }) + ->andReturn($cursor); + $cursor->shouldReceive('toArray') + ->once() + ->with() + ->andReturn([['_id' => 'key_1'], ['_id' => 'key_2']]); + + $engine = new ScoutEngine($database, softDelete: false, prefix: ''); + $builder = new Builder(new SearchableModel(), 'mustang'); + $builder->orderBy('name', 'desc'); + $engine->paginate($builder, $perPage, $page); } #[DataProvider('provideResultsForMapIds')] @@ -254,18 +320,17 @@ public function testUpdateWithSoftDelete(): void [ 'updateOne' => [ ['_id' => 'key_1'], - ['$setOnInsert' => ['_id' => 'key_1'], '$set' => ['id' => 1, 'date' => new UTCDateTime($date)]], + ['$setOnInsert' => ['_id' => 'key_1'], '$set' => ['id' => 1]], ['upsert' => true], ], ], ]); + $model = new SearchableModel(['id' => 1]); + $model->delete(); + $engine = new ScoutEngine($database, softDelete: false, prefix: ''); - $engine->update(EloquentCollection::make([ - (new SearchableModel([ - 'id' => 1, - ])), - ])); + $engine->update(EloquentCollection::make([$model])); } public function testDelete(): void @@ -286,6 +351,28 @@ public function testDelete(): void ])); } + /** @see AlgoliaEngineTest::test_delete_with_removeable_scout_collection_using_custom_search_key */ + public function testDeleteWithRemoveableScoutCollection(): void + { + $job = new RemoveFromSearch(EloquentCollection::make([ + new SearchableModel(['id' => 5, 'scout_key' => 'key_5']), + ])); + + $job = unserialize(serialize($job)); + + $database = m::mock(Database::class); + $collection = m::mock(Collection::class); + $database->shouldReceive('selectCollection') + ->with('table_indexable') + ->andReturn($collection); + $collection->shouldReceive('deleteMany') + ->once() + ->with(['_id' => ['$in' => ['key_5']]]); + + $engine = new ScoutEngine($database, softDelete: false, prefix: 'ignored_prefix_'); + $engine->delete($job->models); + } + public function testDeleteAll(): void { $collectionNames = ['scout-prefix-table1', 'scout-prefix-table2']; diff --git a/tests/Scout/SearchableTest.php b/tests/Scout/ScoutIntegrationTest.php similarity index 98% rename from tests/Scout/SearchableTest.php rename to tests/Scout/ScoutIntegrationTest.php index f367c31df..2817b9f82 100644 --- a/tests/Scout/SearchableTest.php +++ b/tests/Scout/ScoutIntegrationTest.php @@ -9,7 +9,7 @@ use function Orchestra\Testbench\artisan; use function sleep; -class SearchableTest extends TestCase +class ScoutIntegrationTest extends TestCase { use SearchableTests { defineScoutDatabaseMigrations as baseDefineScoutDatabaseMigrations;