diff --git a/src/Eloquent/Builder.php b/src/Eloquent/Builder.php index 4fd4880df..fe0fec95d 100644 --- a/src/Eloquent/Builder.php +++ b/src/Eloquent/Builder.php @@ -5,7 +5,10 @@ namespace MongoDB\Laravel\Eloquent; use Illuminate\Database\Eloquent\Builder as EloquentBuilder; +use Illuminate\Database\Eloquent\Collection; +use Illuminate\Database\Eloquent\Model; use MongoDB\BSON\Document; +use MongoDB\Builder\Type\SearchOperatorInterface; use MongoDB\Driver\CursorInterface; use MongoDB\Driver\Exception\WriteException; use MongoDB\Laravel\Connection; @@ -21,7 +24,10 @@ use function iterator_to_array; use function property_exists; -/** @method \MongoDB\Laravel\Query\Builder toBase() */ +/** + * @method \MongoDB\Laravel\Query\Builder toBase() + * @template TModel of Model + */ class Builder extends EloquentBuilder { private const DUPLICATE_KEY_ERROR = 11000; @@ -49,6 +55,7 @@ class Builder extends EloquentBuilder 'insertusing', 'max', 'min', + 'autocomplete', 'pluck', 'pull', 'push', @@ -69,6 +76,31 @@ public function aggregate($function = null, $columns = ['*']) return $result ?: $this; } + /** + * Performs a full-text search of the field or fields in an Atlas collection. + * + * @see https://www.mongodb.com/docs/atlas/atlas-search/aggregation-stages/search/ + * + * @return Collection + */ + public function search( + SearchOperatorInterface|array $operator, + ?string $index = null, + ?array $highlight = null, + ?bool $concurrent = null, + ?string $count = null, + ?string $searchAfter = null, + ?string $searchBefore = null, + ?bool $scoreDetails = null, + ?array $sort = null, + ?bool $returnStoredSource = null, + ?array $tracking = null, + ): Collection { + $results = $this->toBase()->search($operator, $index, $highlight, $concurrent, $count, $searchAfter, $searchBefore, $scoreDetails, $sort, $returnStoredSource, $tracking); + + return $this->model->hydrate($results->all()); + } + /** @inheritdoc */ public function update(array $values, array $options = []) { diff --git a/src/Query/Builder.php b/src/Query/Builder.php index c62709ce5..0e9e028bb 100644 --- a/src/Query/Builder.php +++ b/src/Query/Builder.php @@ -23,13 +23,16 @@ 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; use Override; use RuntimeException; use stdClass; use function array_fill_keys; +use function array_filter; use function array_is_list; use function array_key_exists; use function array_map; @@ -1490,6 +1493,68 @@ public function options(array $options) return $this; } + /** + * Performs a full-text search of the field or fields in an Atlas collection. + * NOTE: $search is only available for MongoDB Atlas clusters, and is not available for self-managed deployments. + * + * @see https://www.mongodb.com/docs/atlas/atlas-search/aggregation-stages/search/ + * + * @return Collection + */ + public function search( + SearchOperatorInterface|array $operator, + ?string $index = null, + ?array $highlight = null, + ?bool $concurrent = null, + ?string $count = null, + ?string $searchAfter = null, + ?string $searchBefore = null, + ?bool $scoreDetails = null, + ?array $sort = null, + ?bool $returnStoredSource = null, + ?array $tracking = null, + ): Collection { + // Forward named arguments to the search stage, skip null values + $args = array_filter([ + 'operator' => $operator, + 'index' => $index, + 'highlight' => $highlight, + 'concurrent' => $concurrent, + 'count' => $count, + 'searchAfter' => $searchAfter, + 'searchBefore' => $searchBefore, + 'scoreDetails' => $scoreDetails, + 'sort' => $sort, + 'returnStoredSource' => $returnStoredSource, + 'tracking' => $tracking, + ], fn ($arg) => $arg !== null); + + return $this->aggregate()->search(...$args)->get(); + } + + /** + * Performs an autocomplete search of the field using an Atlas Search index. + * NOTE: $search is only available for MongoDB Atlas clusters, and is not available for self-managed deployments. + * You must create an Atlas Search index with an autocomplete configuration before you can use this stage. + * + * @see https://www.mongodb.com/docs/atlas/atlas-search/autocomplete/ + * + * @return Collection + */ + public function autocomplete(string $path, string $query, bool|array $fuzzy = false, string $tokenOrder = 'any'): Collection + { + $args = ['path' => $path, 'query' => $query, 'tokenOrder' => $tokenOrder]; + if ($fuzzy === true) { + $args['fuzzy'] = ['maxEdits' => 2]; + } elseif ($fuzzy !== false) { + $args['fuzzy'] = $fuzzy; + } + + return $this->aggregate()->search( + Search::autocomplete(...$args), + )->get()->pluck($path); + } + /** * Apply the connection's session to options if it's not already specified. */ diff --git a/tests/AtlasSearchTest.php b/tests/AtlasSearchTest.php index cfab2347a..4dc58e902 100644 --- a/tests/AtlasSearchTest.php +++ b/tests/AtlasSearchTest.php @@ -2,7 +2,10 @@ namespace MongoDB\Laravel\Tests; +use Illuminate\Database\Eloquent\Collection as EloquentCollection; +use Illuminate\Support\Collection as LaravelCollection; use Illuminate\Support\Facades\Schema; +use MongoDB\Builder\Search; use MongoDB\Collection as MongoDBCollection; use MongoDB\Driver\Exception\ServerException; use MongoDB\Laravel\Schema\Builder; @@ -43,6 +46,7 @@ public function setUp(): void $collection = $this->getConnection('mongodb')->getCollection('books'); assert($collection instanceof MongoDBCollection); + try { $collection->createSearchIndex([ 'mappings' => [ @@ -50,6 +54,7 @@ public function setUp(): void 'title' => [ ['type' => 'string', 'analyzer' => 'lucene.english'], ['type' => 'autocomplete', 'analyzer' => 'lucene.english'], + ['type' => 'token'], ], ], ], @@ -135,4 +140,63 @@ public function testGetIndexes() self::assertSame($expected, $indexes); } + + public function testEloquentBuilderSearch() + { + $results = Book::search( + sort: ['title' => 1], + operator: Search::text('title', 'systems'), + ); + + self::assertInstanceOf(EloquentCollection::class, $results); + self::assertCount(3, $results); + self::assertInstanceOf(Book::class, $results->first()); + self::assertSame([ + 'Database System Concepts', + 'Modern Operating Systems', + 'Operating System Concepts', + ], $results->pluck('title')->all()); + } + + public function testDatabaseBuilderSearch() + { + $results = $this->getConnection('mongodb')->table('books') + ->search(Search::text('title', 'systems'), sort: ['title' => 1]); + + self::assertInstanceOf(LaravelCollection::class, $results); + self::assertCount(3, $results); + self::assertIsArray($results->first()); + self::assertSame([ + 'Database System Concepts', + 'Modern Operating Systems', + 'Operating System Concepts', + ], $results->pluck('title')->all()); + } + + public function testEloquentBuilderAutocomplete() + { + $results = Book::autocomplete('title', 'system'); + + self::assertInstanceOf(LaravelCollection::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(LaravelCollection::class, $results); + self::assertCount(3, $results); + self::assertSame([ + 'Operating System Concepts', + 'Database System Concepts', + 'Modern Operating Systems', + ], $results->all()); + } }