From 66d7fe88a32179c6556615a31de948a316937281 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Tue, 13 Aug 2024 16:52:36 +0200 Subject: [PATCH 1/2] Scout --- composer.json | 1 + 1 file changed, 1 insertion(+) diff --git a/composer.json b/composer.json index af060bb3c..2be786d21 100644 --- a/composer.json +++ b/composer.json @@ -34,6 +34,7 @@ }, "require-dev": { "mongodb/builder": "^0.2", + "laravel/scout": "^10.11", "league/flysystem-gridfs": "^3.28", "league/flysystem-read-only": "^3.0", "phpunit/phpunit": "^10.3", From 3dd16c819796a00468a925b231dcde207e5e12e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Fri, 16 Aug 2024 19:56:32 +0200 Subject: [PATCH 2/2] Laravel Scout --- src/AtlasSearchScoutServiceProvider.php | 32 +++ src/Scout/AtlasSearchEngine.php | 355 ++++++++++++++++++++++++ tests/Scout/AtlasSearchScoutTest.php | 41 +++ tests/Scout/Post.php | 11 + tests/ServiceProviderTest.php | 22 ++ 5 files changed, 461 insertions(+) create mode 100644 src/AtlasSearchScoutServiceProvider.php create mode 100644 src/Scout/AtlasSearchEngine.php create mode 100644 tests/Scout/AtlasSearchScoutTest.php create mode 100644 tests/Scout/Post.php create mode 100644 tests/ServiceProviderTest.php diff --git a/src/AtlasSearchScoutServiceProvider.php b/src/AtlasSearchScoutServiceProvider.php new file mode 100644 index 000000000..c5d8ac28d --- /dev/null +++ b/src/AtlasSearchScoutServiceProvider.php @@ -0,0 +1,32 @@ +app->extend(EngineManager::class, function (EngineManager $engineManager) { + $engineManager->extend('atlas_search', function ($app) { + $connectionName = config('scout.atlas_search.connection'); + $connection = $app->make('db')->connection($connectionName); + + if (! $connection instanceof Connection) { + throw new InvalidArgumentException(sprintf('The MongoDB connection for Atlas Search must be a MongoDB connection. Got "%s". Set configuration "scout.atlas_search.connection"', $connection->getDriverName())); + } + + return new AtlasSearchEngine($connection); + }); + + return $engineManager; + }); + } +} diff --git a/src/Scout/AtlasSearchEngine.php b/src/Scout/AtlasSearchEngine.php new file mode 100644 index 000000000..137c6f7d6 --- /dev/null +++ b/src/Scout/AtlasSearchEngine.php @@ -0,0 +1,355 @@ +isEmpty()) { + return; + } + + $collection = $this->mongodb->getCollection($models->first()->indexableAs()); + + if ($this->usesSoftDelete($models->first()) && $this->softDelete) { + $models->each->pushSoftDeleteMetadata(); + } + + $bulk = []; + foreach ($models as $model) { + $searchableData = $model->toSearchableArray(); + + if ($searchableData) { + unset($searchableData['_id']); + + $bulk[] = [ + 'updateOne' => [ + ['_id' => $model->getScoutKey()], + [ + '$set' => array_merge( + $searchableData, + $model->scoutMetadata(), + ), + '$setOnInsert' => ['_id' => $model->getScoutKey()], + ], + ['upsert' => true], + ], + ]; + } + } + + if (! empty($bulk)) { + $collection->bulkWrite($bulk); + } + } + + /** + * Remove the given model from the index. + * + * @param Collection $models + * + * @return void + */ + public function delete($models) + { + if ($models->isEmpty()) { + return; + } + + $collection = $this->mongodb->getCollection($models->first()->indexableAs()); + + $bulk = []; + foreach ($models as $model) { + $bulk[] = [ + 'deleteOne' => [ + ['_id' => $model->getScoutKey()], + ], + ]; + } + + if (! empty($bulk)) { + $collection->bulkWrite($bulk); + } + } + + /** + * Perform the given search on the engine. + * + * @param Builder $builder + * + * @return mixed + */ + public function search(Builder $builder) + { + return $this->performSearch($builder, array_filter([ + 'numericFilters' => $this->filters($builder), + 'hitsPerPage' => $builder->limit, + ])); + } + + /** + * Perform the given search on the engine. + * + * @param Builder $builder + * @param int $perPage + * @param int $page + * + * @return mixed + */ + public function paginate(Builder $builder, $perPage, $page) + { + return $this->performSearch($builder, [ + 'numericFilters' => $this->filters($builder), + 'hitsPerPage' => $perPage, + 'page' => $page - 1, + ]); + } + + /** + * Perform the given search on the engine. + * + * @param Builder $builder + * @param array $options + * + * @return mixed + */ + protected function performSearch(Builder $builder, array $options = []) + { + $algolia = $this->algolia->initIndex( + $builder->index ?: $builder->model->searchableAs(), + ); + + $options = array_merge($builder->options, $options); + + if ($builder->callback) { + return call_user_func( + $builder->callback, + $algolia, + $builder->query, + $options, + ); + } + + return $algolia->search($builder->query, $options); + } + + /** + * Get the filter array for the query. + * + * @param Builder $builder + * + * @return array + */ + protected function filters(Builder $builder) + { + $wheres = collect($builder->wheres)->map(function ($value, $key) { + return $key . '=' . $value; + })->values(); + + return $wheres->merge(collect($builder->whereIns)->map(function ($values, $key) { + if (empty($values)) { + return '0=1'; + } + + return collect($values)->map(function ($value) use ($key) { + return $key . '=' . $value; + })->all(); + })->values())->values()->all(); + } + + /** + * Pluck and return the primary keys of the given results. + * + * @param mixed $results + * + * @return \Illuminate\Support\Collection + */ + public function mapIds($results) + { + return collect($results)->pluck('_id')->values(); + } + + /** + * Map the given results to instances of the given model. + * + * @param Builder $builder + * @param mixed $results + * @param Model $model + * + * @return Collection + */ + public function map(Builder $builder, $results, $model) + { + if (count($results) === 0) { + return $model->newCollection(); + } + + $objectIds = collect($results)->pluck('_id')->values()->all(); + + $objectIdPositions = array_flip($objectIds); + + return $model->getScoutModelsByIds( + $builder, + $objectIds, + )->filter(function ($model) use ($objectIds) { + return in_array($model->getScoutKey(), $objectIds); + })->map(function ($model) use ($results, $objectIdPositions) { + $result = $results['hits'][$objectIdPositions[$model->getScoutKey()]] ?? []; + + foreach ($result as $key => $value) { + if ($key !== '_id' && $key[0] ?? '' === '_') { + $model->withScoutMetadata($key, $value); + } + } + + return $model; + })->sortBy(function ($model) use ($objectIdPositions) { + return $objectIdPositions[$model->getScoutKey()]; + })->values(); + } + + /** + * Map the given results to instances of the given model via a lazy collection. + * + * @param Builder $builder + * @param mixed $results + * @param Model $model + * + * @return LazyCollection + */ + public function lazyMap(Builder $builder, $results, $model) + { + if (count($results['hits']) === 0) { + return LazyCollection::make($model->newCollection()); + } + + $objectIds = collect($results['hits'])->pluck('objectID')->values()->all(); + $objectIdPositions = array_flip($objectIds); + + return $model->queryScoutModelsByIds( + $builder, + $objectIds, + )->cursor()->filter(function ($model) use ($objectIds) { + return in_array($model->getScoutKey(), $objectIds); + })->map(function ($model) use ($results, $objectIdPositions) { + $result = $results['hits'][$objectIdPositions[$model->getScoutKey()]] ?? []; + + foreach ($result as $key => $value) { + if (substr($key, 0, 1) === '_') { + $model->withScoutMetadata($key, $value); + } + } + + return $model; + })->sortBy(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 $results['nbHits']; + } + + /** + * Flush all of the model's records from the engine. + * + * @param Model $model + * + * @return void + */ + public function flush($model) + { + $this->mongodb->getCollection($model->indexableAs())->deleteMany([]); + } + + /** + * Create a search index. + * + * @param string $name + * @param array $options + * + * @return mixed + * + * @throws ServerException + */ + public function createIndex($name, array $options = []) + { + $this->mongodb->getMongoDB()->createCollection($name); + $this->mongodb->getCollection($name)->createSearchIndex([ + 'name' => 'laravel_scout', + ]); + } + + /** + * Delete a search index. + * + * @param string $name + * + * @return mixed + */ + public function deleteIndex($name) + { + return $this->mongodb->getCollection($name)->drop(); + } + + /** + * Determine if the given model uses soft deletes. + * + * @param Model $model + * + * @return bool + */ + protected function usesSoftDelete($model) + { + return in_array(SoftDeletes::class, class_uses_recursive($model)); + } + + private function getMongoDBCollections(Collection $models): \MongoDB\Laravel\Collection + { + return $this->mongodb->getCollection($models->first()->indexableAs()); + } +} diff --git a/tests/Scout/AtlasSearchScoutTest.php b/tests/Scout/AtlasSearchScoutTest.php new file mode 100644 index 000000000..85969efdd --- /dev/null +++ b/tests/Scout/AtlasSearchScoutTest.php @@ -0,0 +1,41 @@ + 'First Post', 'content' => 'First Post Content'], + ['title' => 'Second Post', 'content' => 'Second Post Content'], + ['title' => 'Third Post', 'content' => 'Third Post Content'], + ]); + } + + public function tearDown(): void + { + Post::truncate(); + + parent::tearDown(); + } + + public function testGetScoutModelsByIds() + { + $post = Post::where('title', 'First Post')->first(); + + $builder = $this->createMock(Builder::class); + + $posts = (new Post())->getScoutModelsByIds($builder, [ + (string) $post->id, + ]); + + $this->assertCount(1, $posts); + $this->assertSame($post->title, $posts->first()->title); + } +} diff --git a/tests/Scout/Post.php b/tests/Scout/Post.php new file mode 100644 index 000000000..dab70b5a0 --- /dev/null +++ b/tests/Scout/Post.php @@ -0,0 +1,11 @@ +load([MongoDBServiceProvider::class]); + } +}