From 16ae9b08442ab8e697884a85b35c651c4e28b9be Mon Sep 17 00:00:00 2001 From: Loulier Guillaume Date: Tue, 24 Nov 2020 17:09:18 +0100 Subject: [PATCH] feat(index): SearchResult introduced --- src/Client.php | 2 +- src/Endpoints/Indexes.php | 26 ++- src/Search/SearchResult.php | 198 +++++++++++++++++++++ tests/Endpoints/KeysAndPermissionsTest.php | 2 +- tests/Endpoints/SearchTest.php | 149 +++++++++++++++- tests/Search/SearchResultTest.php | 158 ++++++++++++++++ 6 files changed, 524 insertions(+), 11 deletions(-) create mode 100644 src/Search/SearchResult.php create mode 100644 tests/Search/SearchResultTest.php diff --git a/src/Client.php b/src/Client.php index 1c6703ab..47b10dc7 100644 --- a/src/Client.php +++ b/src/Client.php @@ -17,9 +17,9 @@ class Client { + use HandlesDumps; use HandlesIndex; use HandlesSystem; - use HandlesDumps; private $http; diff --git a/src/Endpoints/Indexes.php b/src/Endpoints/Indexes.php index 2cae3bbd..6fcbb2be 100644 --- a/src/Endpoints/Indexes.php +++ b/src/Endpoints/Indexes.php @@ -11,6 +11,7 @@ use MeiliSearch\Endpoints\Delegates\HandlesSettings; use MeiliSearch\Exceptions\HTTPRequestException; use MeiliSearch\Exceptions\TimeOutException; +use MeiliSearch\Search\SearchResult; class Indexes extends Endpoint { @@ -72,7 +73,7 @@ public function show(): ?array public function update($body): array { - return $this->http->put(self::PATH.'/'.$this->uid, $body); + return $this->http->put(self::PATH.'/'.$this->uid, $body); } public function delete(): array @@ -117,14 +118,31 @@ public function waitForPendingUpdate($updateId, $timeoutInMs = 5000, $intervalIn // Search - public function search($query, array $options = []): array + /** + * @param string $query + * + * @return SearchResult|array + */ + public function search($query, array $searchParams = [], array $options = []) { $parameters = array_merge( ['q' => $query], - $options + $searchParams ); - return $this->http->post(self::PATH.'/'.$this->uid.'/search', $parameters); + $result = $this->http->post(self::PATH.'/'.$this->uid.'/search', $parameters); + + if (\array_key_exists('raw', $options) && $options['raw']) { + return $result; + } + + $searchResult = new SearchResult($result); + + if (\array_key_exists('filteringHits', $options) && \is_callable($options['filteringHits'])) { + $searchResult = $searchResult->filter($options['filteringHits']); + } + + return $searchResult; } // Stats diff --git a/src/Search/SearchResult.php b/src/Search/SearchResult.php new file mode 100644 index 00000000..bf6fead2 --- /dev/null +++ b/src/Search/SearchResult.php @@ -0,0 +1,198 @@ +> + */ + private $hits; + + /** + * @var int + */ + private $offset; + + /** + * @var int + */ + private $limit; + + /** + * @var int + */ + private $nbHits; + + /** + * @var bool + */ + private $exhaustiveNbHits; + + /** + * @var int + */ + private $processingTimeMs; + + /** + * @var string + */ + private $query; + + /** + * @var bool|null + */ + private $exhaustiveFacetsCount; + + /** + * @var array + */ + private $facetsDistribution; + + /** + * @var array + */ + private $raw; + + public function __construct(array $body) + { + $this->hits = $body['hits'] ?? []; + $this->offset = $body['offset']; + $this->limit = $body['limit']; + $this->nbHits = $body['nbHits']; + $this->exhaustiveNbHits = $body['exhaustiveNbHits'] ?? false; + $this->processingTimeMs = $body['processingTimeMs']; + $this->query = $body['query']; + $this->exhaustiveFacetsCount = $body['exhaustiveFacetsCount'] ?? null; + $this->facetsDistribution = $body['facetsDistribution'] ?? []; + $this->raw = $body; + } + + /** + * Return a new {@see SearchResult} instance with the hits filtered using `array_filter($this->hits, $callback, ARRAY_FILTER_USE_BOTH)`. + * + * The $callback receives both the current hit and the key, in that order. + * + * The method DOES not trigger a new search. + * + * @return SearchResult + */ + public function filter(callable $callback): self + { + $results = array_filter($this->hits, $callback, ARRAY_FILTER_USE_BOTH); + + $this->hits = $results; + $this->nbHits = \count($results); + + return $this; + } + + public function getHit(int $key, $default = null) + { + return $this->hits[$key] ?? $default; + } + + /** + * @return array + */ + public function getHits(): array + { + return $this->hits; + } + + public function getOffset(): int + { + return $this->offset; + } + + public function getLimit(): int + { + return $this->limit; + } + + public function getMatches(): int + { + return $this->nbHits; + } + + public function getNbHits(): int + { + return \count($this->hits); + } + + public function getExhaustiveNbHits(): bool + { + return $this->exhaustiveNbHits; + } + + public function getProcessingTimeMs(): int + { + return $this->processingTimeMs; + } + + public function getQuery(): string + { + return $this->query; + } + + public function getExhaustiveFacetsCount(): ?bool + { + return $this->exhaustiveFacetsCount; + } + + /** + * @return array + */ + public function getFacetsDistribution(): array + { + return $this->facetsDistribution; + } + + /** + * Return the original search result. + * + * @return array + */ + public function getRaw(): array + { + return $this->raw; + } + + public function toArray(): array + { + return [ + 'hits' => $this->hits, + 'offset' => $this->offset, + 'limit' => $this->limit, + 'matches' => $this->nbHits, + 'nbHits' => \count($this->hits), + 'exhaustiveNbHits' => $this->exhaustiveNbHits, + 'processingTimeMs' => $this->processingTimeMs, + 'query' => $this->query, + 'exhaustiveFacetsCount' => $this->exhaustiveFacetsCount, + 'facetsDistribution' => $this->facetsDistribution, + ]; + } + + public function json(): string + { + return \json_encode($this->toArray(), JSON_PRETTY_PRINT); + } + + public function getIterator(): ArrayIterator + { + return new ArrayIterator($this->hits); + } + + public function count(): int + { + return \count($this->hits); + } +} diff --git a/tests/Endpoints/KeysAndPermissionsTest.php b/tests/Endpoints/KeysAndPermissionsTest.php index 58f941df..df155983 100644 --- a/tests/Endpoints/KeysAndPermissionsTest.php +++ b/tests/Endpoints/KeysAndPermissionsTest.php @@ -28,7 +28,7 @@ public function testSearchingIfPublicKeyProvided(): void $newClient = new Client(self::HOST, $this->getKeys()['public']); $response = $newClient->getIndex('index')->search('test'); - $this->assertArrayHasKey('hits', $response); + $this->assertArrayHasKey('hits', $response->toArray()); } public function testGetSettingsIfPrivateKeyProvided(): void diff --git a/tests/Endpoints/SearchTest.php b/tests/Endpoints/SearchTest.php index 336ddf8f..a640b7cf 100644 --- a/tests/Endpoints/SearchTest.php +++ b/tests/Endpoints/SearchTest.php @@ -23,43 +23,83 @@ public function testBasicSearch(): void { $response = $this->index->search('prince'); + $this->assertArrayHasKey('hits', $response->toArray()); + $this->assertArrayHasKey('offset', $response->toArray()); + $this->assertArrayHasKey('limit', $response->toArray()); + $this->assertArrayHasKey('processingTimeMs', $response->toArray()); + $this->assertArrayHasKey('query', $response->toArray()); + $this->assertSame(2, $response->getNbHits()); + $this->assertCount(2, $response->getHits()); + + $response = $this->index->search('prince', [], [ + 'raw' => true, + ]); + $this->assertArrayHasKey('hits', $response); $this->assertArrayHasKey('offset', $response); $this->assertArrayHasKey('limit', $response); $this->assertArrayHasKey('processingTimeMs', $response); $this->assertArrayHasKey('query', $response); - $this->assertCount(2, $response['hits']); + $this->assertSame(2, $response['nbHits']); } public function testBasicEmptySearch(): void { $response = $this->index->search(''); + $this->assertArrayHasKey('hits', $response->toArray()); + $this->assertArrayHasKey('offset', $response->toArray()); + $this->assertArrayHasKey('limit', $response->toArray()); + $this->assertArrayHasKey('processingTimeMs', $response->toArray()); + $this->assertArrayHasKey('query', $response->toArray()); + $this->assertCount(7, $response->getHits()); + + $response = $this->index->search('', [], [ + 'raw' => true, + ]); + $this->assertArrayHasKey('hits', $response); $this->assertArrayHasKey('offset', $response); $this->assertArrayHasKey('limit', $response); $this->assertArrayHasKey('processingTimeMs', $response); $this->assertArrayHasKey('query', $response); - $this->assertCount(7, $response['hits']); + $this->assertSame(7, $response['nbHits']); } public function testBasicPlaceholderSearch(): void { $response = $this->index->search(null); + $this->assertArrayHasKey('hits', $response->toArray()); + $this->assertArrayHasKey('offset', $response->toArray()); + $this->assertArrayHasKey('limit', $response->toArray()); + $this->assertArrayHasKey('processingTimeMs', $response->toArray()); + $this->assertArrayHasKey('query', $response->toArray()); + $this->assertCount(\count(self::DOCUMENTS), $response->getHits()); + + $response = $this->index->search(null, [], [ + 'raw' => true, + ]); + $this->assertArrayHasKey('hits', $response); $this->assertArrayHasKey('offset', $response); $this->assertArrayHasKey('limit', $response); $this->assertArrayHasKey('processingTimeMs', $response); $this->assertArrayHasKey('query', $response); - $this->assertCount(\count(self::DOCUMENTS), $response['hits']); + $this->assertSame(\count(self::DOCUMENTS), $response['nbHits']); } public function testSearchWithOptions(): void { $response = $this->index->search('prince', ['limit' => 1]); - $this->assertCount(1, $response['hits']); + $this->assertCount(1, $response->getHits()); + + $response = $this->index->search('prince', ['limit' => 1], [ + 'raw' => true, + ]); + + $this->assertSame(1, \count($response['hits'])); } public function testBasicSearchIfNoPrimaryKeyAndDocumentProvided(): void @@ -68,12 +108,23 @@ public function testBasicSearchIfNoPrimaryKeyAndDocumentProvided(): void $res = $emptyIndex->search('prince'); + $this->assertArrayHasKey('hits', $res->toArray()); + $this->assertArrayHasKey('offset', $res->toArray()); + $this->assertArrayHasKey('limit', $res->toArray()); + $this->assertArrayHasKey('processingTimeMs', $res->toArray()); + $this->assertArrayHasKey('query', $res->toArray()); + $this->assertCount(0, $res->getHits()); + + $res = $emptyIndex->search('prince', [], [ + 'raw' => true, + ]); + $this->assertArrayHasKey('hits', $res); $this->assertArrayHasKey('offset', $res); $this->assertArrayHasKey('limit', $res); $this->assertArrayHasKey('processingTimeMs', $res); $this->assertArrayHasKey('query', $res); - $this->assertCount(0, $res['hits']); + $this->assertSame(0, $res['nbHits']); } public function testExceptionIfNoIndexWhenSearching(): void @@ -99,6 +150,26 @@ public function testParametersArray(): void 'matches' => true, ]); + $this->assertArrayHasKey('_matchesInfo', $response->getHit(0)); + $this->assertArrayHasKey('title', $response->getHit(0)['_matchesInfo']); + $this->assertArrayHasKey('_formatted', $response->getHit(0)); + $this->assertArrayNotHasKey('comment', $response->getHit(0)); + $this->assertArrayNotHasKey('comment', $response->getHit(0)['_matchesInfo']); + $this->assertSame('Petit Prince', $response->getHit(0)['_formatted']['title']); + + $response = $this->index->search('prince', [ + 'limit' => 5, + 'offset' => 0, + 'attributesToRetrieve' => ['id', 'title'], + 'attributesToCrop' => ['id', 'title'], + 'cropLength' => 6, + 'attributesToHighlight' => ['title'], + 'filters' => 'title = "Le Petit Prince"', + 'matches' => true, + ], [ + 'raw' => true, + ]); + $this->assertArrayHasKey('_matchesInfo', $response['hits'][0]); $this->assertArrayHasKey('title', $response['hits'][0]['_matchesInfo']); $this->assertArrayHasKey('_formatted', $response['hits'][0]); @@ -120,6 +191,26 @@ public function testParametersCanBeAStar(): void 'matches' => true, ]); + $this->assertArrayHasKey('_matchesInfo', $response->getHit(0)); + $this->assertArrayHasKey('title', $response->getHit(0)['_matchesInfo']); + $this->assertArrayHasKey('_formatted', $response->getHit(0)); + $this->assertArrayHasKey('comment', $response->getHit(0)); + $this->assertArrayNotHasKey('comment', $response->getHit(0)['_matchesInfo']); + $this->assertSame('Petit Prince', $response->getHit(0)['_formatted']['title']); + + $response = $this->index->search('prince', [ + 'limit' => 5, + 'offset' => 0, + 'attributesToRetrieve' => ['*'], + 'attributesToCrop' => ['*'], + 'cropLength' => 6, + 'attributesToHighlight' => ['*'], + 'filters' => 'title = "Le Petit Prince"', + 'matches' => true, + ], [ + 'raw' => true, + ]); + $this->assertArrayHasKey('_matchesInfo', $response['hits'][0]); $this->assertArrayHasKey('title', $response['hits'][0]['_matchesInfo']); $this->assertArrayHasKey('_formatted', $response['hits'][0]); @@ -136,6 +227,20 @@ public function testBasicSearchWithFacetsDistribution(): void $response = $this->index->search('prince', [ 'facetsDistribution' => ['genre'], ]); + $this->assertSame(2, $response->getMatches()); + $this->assertArrayHasKey('facetsDistribution', $response->toArray()); + $this->assertArrayHasKey('exhaustiveFacetsCount', $response->toArray()); + $this->assertArrayHasKey('genre', $response->getFacetsDistribution()); + $this->assertTrue($response->getExhaustiveFacetsCount()); + $this->assertSame($response->getFacetsDistribution()['genre']['fantasy'], 1); + $this->assertSame($response->getFacetsDistribution()['genre']['adventure'], 1); + $this->assertSame($response->getFacetsDistribution()['genre']['romance'], 0); + + $response = $this->index->search('prince', [ + 'facetsDistribution' => ['genre'], + ], [ + 'raw' => true, + ]); $this->assertSame(2, $response['nbHits']); $this->assertArrayHasKey('facetsDistribution', $response); $this->assertArrayHasKey('exhaustiveFacetsCount', $response); @@ -154,6 +259,16 @@ public function testBasicSearchWithFacetFilters(): void $response = $this->index->search('prince', [ 'facetFilters' => [['genre:fantasy']], ]); + $this->assertSame(1, $response->getMatches()); + $this->assertArrayNotHasKey('facetsDistribution', $response->getRaw()); + $this->assertArrayNotHasKey('exhaustiveFacetsCount', $response->getRaw()); + $this->assertSame(4, $response->getHit(0)['id']); + + $response = $this->index->search('prince', [ + 'facetFilters' => [['genre:fantasy']], + ], [ + 'raw' => true, + ]); $this->assertSame(1, $response['nbHits']); $this->assertArrayNotHasKey('facetsDistribution', $response); $this->assertArrayNotHasKey('exhaustiveFacetsCount', $response); @@ -168,6 +283,16 @@ public function testBasicSearchWithMultipleFacetFilters(): void $response = $this->index->search('prince', [ 'facetFilters' => ['genre:fantasy', ['genre:fantasy', 'genre:fantasy']], ]); + $this->assertSame(1, $response->getMatches()); + $this->assertArrayNotHasKey('facetsDistribution', $response->getRaw()); + $this->assertArrayNotHasKey('exhaustiveFacetsCount', $response->getRaw()); + $this->assertSame(4, $response->getHit(0)['id']); + + $response = $this->index->search('prince', [ + 'facetFilters' => ['genre:fantasy', ['genre:fantasy', 'genre:fantasy']], + ], [ + 'raw' => true, + ]); $this->assertSame(1, $response['nbHits']); $this->assertArrayNotHasKey('facetsDistribution', $response); $this->assertArrayNotHasKey('exhaustiveFacetsCount', $response); @@ -183,6 +308,20 @@ public function testCustomSearchWithFacetFiltersAndAttributesToRetrieve(): void 'facetFilters' => [['genre:fantasy']], 'attributesToRetrieve' => ['id', 'title'], ]); + $this->assertSame(1, $response->getMatches()); + $this->assertArrayNotHasKey('facetsDistribution', $response->getRaw()); + $this->assertArrayNotHasKey('exhaustiveFacetsCount', $response->getRaw()); + $this->assertSame(4, $response->getHit(0)['id']); + $this->assertArrayHasKey('id', $response->getHit(0)); + $this->assertArrayHasKey('title', $response->getHit(0)); + $this->assertArrayNotHasKey('comment', $response->getHit(0)); + + $response = $this->index->search('prince', [ + 'facetFilters' => [['genre:fantasy']], + 'attributesToRetrieve' => ['id', 'title'], + ], [ + 'raw' => true, + ]); $this->assertSame(1, $response['nbHits']); $this->assertArrayNotHasKey('facetsDistribution', $response); $this->assertArrayNotHasKey('exhaustiveFacetsCount', $response); diff --git a/tests/Search/SearchResultTest.php b/tests/Search/SearchResultTest.php new file mode 100644 index 00000000..eb501e26 --- /dev/null +++ b/tests/Search/SearchResultTest.php @@ -0,0 +1,158 @@ + [ + [ + 'title' => 'American Pie 2', + 'poster' => 'https://image.tmdb.org/t/p/w1280/q4LNgUnRfltxzp3gf1MAGiK5LhV.jpg', + 'overview' => 'The whole gang are back and as close as ever. They decide to get even closer by spending the summer together at a beach house. They decide to hold the biggest...', + 'release_date' => 997405200, + ], + [ + 'id' => '190859', + 'title' => 'American Sniper', + 'poster' => 'https://image.tmdb.org/t/p/w1280/svPHnYE7N5NAGO49dBmRhq0vDQ3.jpg', + 'overview' => 'U.S. Navy SEAL Chris Kyle takes his sole mission—protect his comrades—to heart and becomes one of the most lethal snipers in American history. His pinpoint accuracy not only saves countless lives but also makes him a prime...', + 'release_date' => 1418256000, + ], + ], + 'offset' => 0, + 'limit' => 20, + 'nbHits' => 976, + 'exhaustiveNbHits' => false, + 'processingTimeMs' => 35, + 'query' => 'american', + ]); + + static::assertSame(2, $result->count()); + static::assertNotEmpty($result->getHits()); + + static::assertEquals([ + 'title' => 'American Pie 2', + 'poster' => 'https://image.tmdb.org/t/p/w1280/q4LNgUnRfltxzp3gf1MAGiK5LhV.jpg', + 'overview' => 'The whole gang are back and as close as ever. They decide to get even closer by spending the summer together at a beach house. They decide to hold the biggest...', + 'release_date' => 997405200, + ], $result->getHit(0)); + static::assertEquals([ + 'id' => '190859', + 'title' => 'American Sniper', + 'poster' => 'https://image.tmdb.org/t/p/w1280/svPHnYE7N5NAGO49dBmRhq0vDQ3.jpg', + 'overview' => 'U.S. Navy SEAL Chris Kyle takes his sole mission—protect his comrades—to heart and becomes one of the most lethal snipers in American history. His pinpoint accuracy not only saves countless lives but also makes him a prime...', + 'release_date' => 1418256000, + ], $result->getHit(1)); + static::assertNull($result->getHit(2)); + static::assertSame(0, $result->getOffset()); + static::assertSame(20, $result->getLimit()); + static::assertSame(2, $result->getNbHits()); + static::assertSame(976, $result->getMatches()); + static::assertFalse($result->getExhaustiveNbHits()); + static::assertSame(35, $result->getProcessingTimeMs()); + static::assertSame('american', $result->getQuery()); + static::assertNull($result->getExhaustiveFacetsCount()); + static::assertEmpty($result->getFacetsDistribution()); + static::assertSame(2, $result->getIterator()->count()); + + static::assertArrayHasKey('hits', $result->toArray()); + static::assertArrayHasKey('offset', $result->toArray()); + static::assertArrayHasKey('limit', $result->toArray()); + static::assertArrayHasKey('nbHits', $result->toArray()); + static::assertArrayHasKey('exhaustiveNbHits', $result->toArray()); + static::assertArrayHasKey('processingTimeMs', $result->toArray()); + static::assertArrayHasKey('query', $result->toArray()); + static::assertArrayHasKey('exhaustiveFacetsCount', $result->toArray()); + static::assertArrayHasKey('facetsDistribution', $result->toArray()); + } + + public function testSearchResultCanBeFiltered(): void + { + $result = new SearchResult([ + 'hits' => [ + [ + 'title' => 'American Pie 2', + 'poster' => 'https://image.tmdb.org/t/p/w1280/q4LNgUnRfltxzp3gf1MAGiK5LhV.jpg', + 'overview' => 'The whole gang are back and as close as ever. They decide to get even closer by spending the summer together at a beach house. They decide to hold the biggest...', + 'release_date' => 997405200, + ], + [ + 'id' => '190859', + 'title' => 'American Sniper', + 'poster' => 'https://image.tmdb.org/t/p/w1280/svPHnYE7N5NAGO49dBmRhq0vDQ3.jpg', + 'overview' => 'U.S. Navy SEAL Chris Kyle takes his sole mission—protect his comrades—to heart and becomes one of the most lethal snipers in American history. His pinpoint accuracy not only saves countless lives but also makes him a prime...', + 'release_date' => 1418256000, + ], + ], + 'offset' => 0, + 'limit' => 20, + 'nbHits' => 976, + 'exhaustiveNbHits' => false, + 'processingTimeMs' => 35, + 'query' => 'american', + ]); + + $filteredResults = $result->filter(function (array $hit, int $_): bool { + return 'AMERICAN SNIPER' === strtoupper($hit['title']); + }); + + static::assertSame(1, $filteredResults->count()); + static::assertNull($result->getHit(0)); + static::assertEquals([ + 'id' => '190859', + 'title' => 'American Sniper', + 'poster' => 'https://image.tmdb.org/t/p/w1280/svPHnYE7N5NAGO49dBmRhq0vDQ3.jpg', + 'overview' => 'U.S. Navy SEAL Chris Kyle takes his sole mission—protect his comrades—to heart and becomes one of the most lethal snipers in American history. His pinpoint accuracy not only saves countless lives but also makes him a prime...', + 'release_date' => 1418256000, + ], $result->getHit(1)); + } + + public function testResultCanBeReturnedAsJson(): void + { + $result = new SearchResult([ + 'hits' => [ + [ + 'title' => 'American Pie 2', + 'poster' => 'https://image.tmdb.org/t/p/w1280/q4LNgUnRfltxzp3gf1MAGiK5LhV.jpg', + 'overview' => 'The whole gang are back and as close as ever. They decide to get even closer by spending the summer together at a beach house. They decide to hold the biggest...', + 'release_date' => 997405200, + ], + [ + 'id' => '190859', + 'title' => 'American Sniper', + 'poster' => 'https://image.tmdb.org/t/p/w1280/svPHnYE7N5NAGO49dBmRhq0vDQ3.jpg', + 'overview' => 'U.S. Navy SEAL Chris Kyle takes his sole mission—protect his comrades—to heart and becomes one of the most lethal snipers in American history. His pinpoint accuracy not only saves countless lives but also makes him a prime...', + 'release_date' => 1418256000, + ], + ], + 'offset' => 0, + 'limit' => 20, + 'nbHits' => 976, + 'exhaustiveNbHits' => false, + 'processingTimeMs' => 35, + 'query' => 'american', + ]); + + $json = $result->json(); + + static::assertStringContainsString('hits', $json); + static::assertStringContainsString('offset', $json); + static::assertStringContainsString('limit', $json); + static::assertStringContainsString('matches', $json); + static::assertStringContainsString('nbHits', $json); + static::assertStringContainsString('exhaustiveNbHits', $json); + static::assertStringContainsString('processingTimeMs', $json); + static::assertStringContainsString('query', $json); + static::assertStringContainsString('exhaustiveFacetsCount', $json); + static::assertStringContainsString('facetsDistribution', $json); + } +}