From fc2ec05e42d2c9dfa5c2c1f6dbb46410527037d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Mon, 23 Dec 2024 19:33:33 +0100 Subject: [PATCH 1/7] Add Builder::search --- src/Query/Builder.php | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/src/Query/Builder.php b/src/Query/Builder.php index c62709ce5..d718d8248 100644 --- a/src/Query/Builder.php +++ b/src/Query/Builder.php @@ -24,12 +24,14 @@ use MongoDB\BSON\Regex; use MongoDB\BSON\UTCDateTime; 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 +1492,42 @@ 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/manual/reference/operator/aggregation/search/ + * + * @param SearchOperatorInterface|array $operator Operator to search with. You can provide a specific operator or use the compound operator to run a compound query with multiple operators. + * @param ?string $index Name of the Atlas Search index to use. If omitted, defaults to "default". + * @param ?array $highlight Specifies the highlighting options for displaying search terms in their original context. + * @param ?bool $concurrent Parallelize search across segments on dedicated search nodes. + * @param ?string $count Document that specifies the count options for retrieving a count of the results. + * @param ?string $searchAfter Reference point for retrieving results. searchAfter returns documents starting immediately following the specified reference point. + * @param ?string $searchBefore Reference point for retrieving results. searchBefore returns documents starting immediately before the specified reference point. + * @param ?bool $scoreDetails Flag that specifies whether to retrieve a detailed breakdown of the score for the documents in the results. If omitted, defaults to false. + * @param ?array $sort Document that specifies the fields to sort the Atlas Search results by in ascending or descending order. + * @param ?bool $returnStoredSource Flag that specifies whether to perform a full document lookup on the backend database or return only stored source fields directly from Atlas Search. + * @param ?array $tracking Document that specifies the tracking option to retrieve analytics information on the search terms. + */ + 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|LazyCollection { + return $this->aggregate() + ->search(...array_filter(func_get_args(), fn ($arg) => $arg !== null)) + ->get(); + } + /** * Apply the connection's session to options if it's not already specified. */ From fc5c0b74f6307179fe6683699f47da6d4549771e 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 2/7] Implement autocomplete --- src/Eloquent/Builder.php | 14 ++++++++++ src/Query/Builder.php | 23 ++++++++++++--- tests/AtlasSearchTest.php | 59 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 92 insertions(+), 4 deletions(-) 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 index cfab2347a..74c0fbb42 100644 --- a/tests/AtlasSearchTest.php +++ b/tests/AtlasSearchTest.php @@ -2,7 +2,9 @@ namespace MongoDB\Laravel\Tests; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Support\Facades\Schema; +use MongoDB\Builder\Search; use MongoDB\Collection as MongoDBCollection; use MongoDB\Driver\Exception\ServerException; use MongoDB\Laravel\Schema\Builder; @@ -42,6 +44,7 @@ public function setUp(): void ]); $collection = $this->getConnection('mongodb')->getCollection('books'); + assert($collection instanceof MongoDBCollection); try { $collection->createSearchIndex([ @@ -135,4 +138,60 @@ public function testGetIndexes() self::assertSame($expected, $indexes); } + + public function testEloquentBuilderSearch() + { + $results = Book::search(Search::text('title', 'systems')); + + self::assertInstanceOf(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()); + } } From 5291e7c9fb3fad6416bdc1530e5ed14c231db9a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Fri, 27 Dec 2024 16:55:17 +0100 Subject: [PATCH 3/7] Remove use of compact function --- src/Eloquent/Builder.php | 8 +------- src/Query/Builder.php | 9 ++++----- tests/AtlasSearchTest.php | 13 +++++++------ 3 files changed, 12 insertions(+), 18 deletions(-) diff --git a/src/Eloquent/Builder.php b/src/Eloquent/Builder.php index 7c7400455..bf757c131 100644 --- a/src/Eloquent/Builder.php +++ b/src/Eloquent/Builder.php @@ -5,7 +5,6 @@ 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; @@ -17,7 +16,6 @@ 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; @@ -51,6 +49,7 @@ class Builder extends EloquentBuilder 'insertusing', 'max', 'min', + 'autocomplete', 'pluck', 'pull', 'push', @@ -78,11 +77,6 @@ public function 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 d90b92526..0efb6aa74 100644 --- a/src/Query/Builder.php +++ b/src/Query/Builder.php @@ -42,7 +42,6 @@ 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; @@ -1531,11 +1530,11 @@ public function search( /** @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 = ['path' => $path, 'query' => $query, 'tokenOrder' => $tokenOrder]; + if ($fuzzy === true) { $args['fuzzy'] = ['maxEdits' => 2]; - } elseif ($args['fuzzy'] === false) { - unset($args['fuzzy']); + } elseif ($fuzzy !== false) { + $args['fuzzy'] = $fuzzy; } return $this->aggregate()->search( diff --git a/tests/AtlasSearchTest.php b/tests/AtlasSearchTest.php index 74c0fbb42..31dc5fe15 100644 --- a/tests/AtlasSearchTest.php +++ b/tests/AtlasSearchTest.php @@ -2,7 +2,8 @@ namespace MongoDB\Laravel\Tests; -use Illuminate\Database\Eloquent\Collection; +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; @@ -44,8 +45,8 @@ public function setUp(): void ]); $collection = $this->getConnection('mongodb')->getCollection('books'); - assert($collection instanceof MongoDBCollection); + try { $collection->createSearchIndex([ 'mappings' => [ @@ -143,7 +144,7 @@ public function testEloquentBuilderSearch() { $results = Book::search(Search::text('title', 'systems')); - self::assertInstanceOf(Collection::class, $results); + self::assertInstanceOf(EloquentCollection::class, $results); self::assertCount(3, $results); self::assertInstanceOf(Book::class, $results->first()); self::assertSame([ @@ -158,7 +159,7 @@ public function testDatabaseBuilderSearch() $results = $this->getConnection('mongodb')->table('books') ->search(Search::text('title', 'systems')); - self::assertInstanceOf(\Illuminate\Support\Collection::class, $results); + self::assertInstanceOf(LaravelCollection::class, $results); self::assertCount(3, $results); self::assertIsArray($results->first()); self::assertSame([ @@ -172,7 +173,7 @@ public function testEloquentBuilderAutocomplete() { $results = Book::autocomplete('title', 'system'); - self::assertInstanceOf(\Illuminate\Support\Collection::class, $results); + self::assertInstanceOf(LaravelCollection::class, $results); self::assertCount(3, $results); self::assertSame([ 'Operating System Concepts', @@ -186,7 +187,7 @@ public function testDatabaseBuilderAutocomplete() $results = $this->getConnection('mongodb')->table('books') ->autocomplete('title', 'system'); - self::assertInstanceOf(\Illuminate\Support\Collection::class, $results); + self::assertInstanceOf(LaravelCollection::class, $results); self::assertCount(3, $results); self::assertSame([ 'Operating System Concepts', From 4d08755d3004655f5fbdec340307ac83845b8d11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Thu, 2 Jan 2025 15:42:39 +0100 Subject: [PATCH 4/7] Fix Eloquent Builder signature --- src/Eloquent/Builder.php | 32 ++++++++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/src/Eloquent/Builder.php b/src/Eloquent/Builder.php index bf757c131..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; @@ -70,9 +76,27 @@ public function aggregate($function = null, $columns = ['*']) return $result ?: $this; } - public function search(...$args) - { - $results = $this->toBase()->search(...$args); + /** + * 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()); } From ed5c8f78bb391cbe9fef74e868357d8d2b546c26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Fri, 3 Jan 2025 19:35:50 +0100 Subject: [PATCH 5/7] Fix search argument names --- src/Query/Builder.php | 4 +++- tests/AtlasSearchTest.php | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/Query/Builder.php b/src/Query/Builder.php index 0efb6aa74..3f51778cd 100644 --- a/src/Query/Builder.php +++ b/src/Query/Builder.php @@ -1524,7 +1524,9 @@ public function search( ?bool $returnStoredSource = null, ?array $tracking = null, ): Collection { - return $this->aggregate()->search(...array_filter(func_get_args(), fn ($arg) => $arg !== null))->get(); + $args = array_filter(compact(['operator', 'index', 'highlight', 'concurrent', 'count', 'searchAfter', 'searchBefore', 'scoreDetails', 'sort', 'returnStoredSource', 'tracking']), fn ($arg) => $arg !== null); + + return $this->aggregate()->search(...$args)->get(); } /** @return Collection */ diff --git a/tests/AtlasSearchTest.php b/tests/AtlasSearchTest.php index 31dc5fe15..2658d024e 100644 --- a/tests/AtlasSearchTest.php +++ b/tests/AtlasSearchTest.php @@ -154,6 +154,24 @@ public function testEloquentBuilderSearch() ], $results->pluck('title')->all()); } + public function testEloquentBuilderWithAdvancedParameters() + { + $results = Book::search( + concurrent: true, + operator: Search::text('title', 'systems'), + sort: ['title' => -1], + ); + + self::assertInstanceOf(EloquentCollection::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') From 6abce658b5af771617ec8e5991327c85909b9c5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Fri, 3 Jan 2025 19:45:37 +0100 Subject: [PATCH 6/7] Improve docs --- src/Query/Builder.php | 39 +++++++++++++++++++++++++-------------- 1 file changed, 25 insertions(+), 14 deletions(-) diff --git a/src/Query/Builder.php b/src/Query/Builder.php index 3f51778cd..0e9e028bb 100644 --- a/src/Query/Builder.php +++ b/src/Query/Builder.php @@ -1497,19 +1497,9 @@ public function options(array $options) * 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/manual/reference/operator/aggregation/search/ + * @see https://www.mongodb.com/docs/atlas/atlas-search/aggregation-stages/search/ * - * @param SearchOperatorInterface|array $operator Operator to search with. You can provide a specific operator or use the compound operator to run a compound query with multiple operators. - * @param ?string $index Name of the Atlas Search index to use. If omitted, defaults to "default". - * @param ?array $highlight Specifies the highlighting options for displaying search terms in their original context. - * @param ?bool $concurrent Parallelize search across segments on dedicated search nodes. - * @param ?string $count Document that specifies the count options for retrieving a count of the results. - * @param ?string $searchAfter Reference point for retrieving results. searchAfter returns documents starting immediately following the specified reference point. - * @param ?string $searchBefore Reference point for retrieving results. searchBefore returns documents starting immediately before the specified reference point. - * @param ?bool $scoreDetails Flag that specifies whether to retrieve a detailed breakdown of the score for the documents in the results. If omitted, defaults to false. - * @param ?array $sort Document that specifies the fields to sort the Atlas Search results by in ascending or descending order. - * @param ?bool $returnStoredSource Flag that specifies whether to perform a full document lookup on the backend database or return only stored source fields directly from Atlas Search. - * @param ?array $tracking Document that specifies the tracking option to retrieve analytics information on the search terms. + * @return Collection */ public function search( SearchOperatorInterface|array $operator, @@ -1524,12 +1514,33 @@ public function search( ?bool $returnStoredSource = null, ?array $tracking = null, ): Collection { - $args = array_filter(compact(['operator', 'index', 'highlight', 'concurrent', 'count', 'searchAfter', 'searchBefore', 'scoreDetails', 'sort', 'returnStoredSource', 'tracking']), fn ($arg) => $arg !== null); + // 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(); } - /** @return Collection */ + /** + * 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]; From 715c130996f4471111b0e7eb8a183bd0d95d7af3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Fri, 3 Jan 2025 19:54:14 +0100 Subject: [PATCH 7/7] Fix sort of search results --- tests/AtlasSearchTest.php | 24 +++++------------------- 1 file changed, 5 insertions(+), 19 deletions(-) diff --git a/tests/AtlasSearchTest.php b/tests/AtlasSearchTest.php index 2658d024e..4dc58e902 100644 --- a/tests/AtlasSearchTest.php +++ b/tests/AtlasSearchTest.php @@ -54,6 +54,7 @@ public function setUp(): void 'title' => [ ['type' => 'string', 'analyzer' => 'lucene.english'], ['type' => 'autocomplete', 'analyzer' => 'lucene.english'], + ['type' => 'token'], ], ], ], @@ -141,49 +142,34 @@ public function testGetIndexes() } public function testEloquentBuilderSearch() - { - $results = Book::search(Search::text('title', 'systems')); - - self::assertInstanceOf(EloquentCollection::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 testEloquentBuilderWithAdvancedParameters() { $results = Book::search( - concurrent: true, + sort: ['title' => 1], operator: Search::text('title', 'systems'), - sort: ['title' => -1], ); self::assertInstanceOf(EloquentCollection::class, $results); self::assertCount(3, $results); self::assertInstanceOf(Book::class, $results->first()); self::assertSame([ - 'Operating System Concepts', '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')); + ->search(Search::text('title', 'systems'), sort: ['title' => 1]); self::assertInstanceOf(LaravelCollection::class, $results); self::assertCount(3, $results); self::assertIsArray($results->first()); self::assertSame([ - 'Operating System Concepts', 'Database System Concepts', 'Modern Operating Systems', + 'Operating System Concepts', ], $results->pluck('title')->all()); }