From 38d0ee4ea79ed8db0ff0a477c3197c378dca903a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Fri, 27 Dec 2024 11:45:56 +0100 Subject: [PATCH] Implement autocomplete --- src/Eloquent/Builder.php | 14 ++++ src/Query/Builder.php | 23 +++++-- tests/AtlasSearchTest.php | 139 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 172 insertions(+), 4 deletions(-) create mode 100644 tests/AtlasSearchTest.php diff --git a/src/Eloquent/Builder.php b/src/Eloquent/Builder.php index 4fd4880df..7c7400455 100644 --- a/src/Eloquent/Builder.php +++ b/src/Eloquent/Builder.php @@ -5,6 +5,7 @@ namespace MongoDB\Laravel\Eloquent; use Illuminate\Database\Eloquent\Builder as EloquentBuilder; +use Illuminate\Support\Collection; use MongoDB\BSON\Document; use MongoDB\Driver\CursorInterface; use MongoDB\Driver\Exception\WriteException; @@ -16,6 +17,7 @@ use function array_key_exists; use function array_merge; use function collect; +use function compact; use function is_array; use function is_object; use function iterator_to_array; @@ -69,6 +71,18 @@ public function aggregate($function = null, $columns = ['*']) return $result ?: $this; } + public function search(...$args) + { + $results = $this->toBase()->search(...$args); + + return $this->model->hydrate($results->all()); + } + + public function autocomplete(string $path, string $query, bool|array $fuzzy = false, string $tokenOrder = 'any'): Collection + { + return $this->toBase()->autocomplete(...compact('path', 'query', 'fuzzy', 'tokenOrder')); + } + /** @inheritdoc */ public function update(array $values, array $options = []) { diff --git a/src/Query/Builder.php b/src/Query/Builder.php index d718d8248..d90b92526 100644 --- a/src/Query/Builder.php +++ b/src/Query/Builder.php @@ -23,6 +23,7 @@ use MongoDB\BSON\ObjectID; use MongoDB\BSON\Regex; use MongoDB\BSON\UTCDateTime; +use MongoDB\Builder\Search; use MongoDB\Builder\Stage\FluentFactoryTrait; use MongoDB\Builder\Type\SearchOperatorInterface; use MongoDB\Driver\Cursor; @@ -41,6 +42,7 @@ use function blank; use function call_user_func; use function call_user_func_array; +use function compact; use function count; use function ctype_xdigit; use function date_default_timezone_get; @@ -1522,10 +1524,23 @@ public function search( ?array $sort = null, ?bool $returnStoredSource = null, ?array $tracking = null, - ): Collection|LazyCollection { - return $this->aggregate() - ->search(...array_filter(func_get_args(), fn ($arg) => $arg !== null)) - ->get(); + ): Collection { + return $this->aggregate()->search(...array_filter(func_get_args(), fn ($arg) => $arg !== null))->get(); + } + + /** @return Collection */ + public function autocomplete(string $path, string $query, bool|array $fuzzy = false, string $tokenOrder = 'any'): Collection + { + $args = compact('path', 'query', 'fuzzy', 'tokenOrder'); + if ($args['fuzzy'] === true) { + $args['fuzzy'] = ['maxEdits' => 2]; + } elseif ($args['fuzzy'] === false) { + unset($args['fuzzy']); + } + + return $this->aggregate()->search( + Search::autocomplete(...$args), + )->get()->pluck($path); } /** diff --git a/tests/AtlasSearchTest.php b/tests/AtlasSearchTest.php new file mode 100644 index 000000000..292521e1d --- /dev/null +++ b/tests/AtlasSearchTest.php @@ -0,0 +1,139 @@ + 'Introduction to Algorithms'], + ['title' => 'Clean Code: A Handbook of Agile Software Craftsmanship'], + ['title' => 'Design Patterns: Elements of Reusable Object-Oriented Software'], + ['title' => 'The Pragmatic Programmer: Your Journey to Mastery'], + ['title' => 'Artificial Intelligence: A Modern Approach'], + ['title' => 'Structure and Interpretation of Computer Programs'], + ['title' => 'Code Complete: A Practical Handbook of Software Construction'], + ['title' => 'The Art of Computer Programming'], + ['title' => 'Computer Networks'], + ['title' => 'Operating System Concepts'], + ['title' => 'Database System Concepts'], + ['title' => 'Compilers: Principles, Techniques, and Tools'], + ['title' => 'Introduction to the Theory of Computation'], + ['title' => 'Modern Operating Systems'], + ['title' => 'Computer Organization and Design'], + ['title' => 'The Mythical Man-Month: Essays on Software Engineering'], + ['title' => 'Algorithms'], + ['title' => 'Understanding Machine Learning: From Theory to Algorithms'], + ['title' => 'Deep Learning'], + ['title' => 'Pattern Recognition and Machine Learning'], + ]); + + $collection = $this->getConnection('mongodb')->getCollection('books'); + assert($collection instanceof Collection); + try { + $collection->createSearchIndex([ + 'mappings' => [ + 'fields' => [ + 'title' => [ + [ + 'type' => 'string', + 'analyzer' => 'lucene.english', + ], + [ + 'type' => 'autocomplete', + 'analyzer' => 'lucene.english', + ], + ], + ], + ], + ]); + } catch (ServerException $e) { + if (in_array($e->getCode(), [40324, 115, 6047401, 31082])) { + self::markTestSkipped('Atlas Search not supported'); + } + + throw $e; + } + + // Wait for the index to be ready + do { + usleep(10_000); + $index = $collection->listSearchIndexes(['name' => 'default'])->current(); + } while ($index['status'] !== 'READY'); + } + + public function tearDown(): void + { + $this->getConnection('mongodb')->getCollection('books')->drop(); + + parent::tearDown(); + } + + public function testEloquentBuilderSearch() + { + $results = Book::search(Search::text('title', 'systems')); + + self::assertInstanceOf(\Illuminate\Database\Eloquent\Collection::class, $results); + self::assertCount(3, $results); + self::assertInstanceOf(Book::class, $results->first()); + self::assertSame([ + 'Operating System Concepts', + 'Database System Concepts', + 'Modern Operating Systems', + ], $results->pluck('title')->all()); + } + + public function testDatabaseBuilderSearch() + { + $results = $this->getConnection('mongodb')->table('books') + ->search(Search::text('title', 'systems')); + + self::assertInstanceOf(\Illuminate\Support\Collection::class, $results); + self::assertCount(3, $results); + self::assertIsArray($results->first()); + self::assertSame([ + 'Operating System Concepts', + 'Database System Concepts', + 'Modern Operating Systems', + ], $results->pluck('title')->all()); + } + + public function testEloquentBuilderAutocomplete() + { + $results = Book::autocomplete('title', 'system'); + + self::assertInstanceOf(\Illuminate\Support\Collection::class, $results); + self::assertCount(3, $results); + self::assertSame([ + 'Operating System Concepts', + 'Database System Concepts', + 'Modern Operating Systems', + ], $results->all()); + } + + public function testDatabaseBuilderAutocomplete() + { + $results = $this->getConnection('mongodb')->table('books') + ->autocomplete('title', 'system'); + + self::assertInstanceOf(\Illuminate\Support\Collection::class, $results); + self::assertCount(3, $results); + self::assertSame([ + 'Operating System Concepts', + 'Database System Concepts', + 'Modern Operating Systems', + ], $results->all()); + } +}