diff --git a/src/CollectionAlias/CollectionAlias.php b/src/CollectionAlias/CollectionAlias.php index 88a8f5c..a2f5566 100644 --- a/src/CollectionAlias/CollectionAlias.php +++ b/src/CollectionAlias/CollectionAlias.php @@ -56,4 +56,19 @@ private function collectionExists(string $name): bool return false; } } + + public function revertName(string $name): string + { + // Remove the timestamp suffix (format: -Y-m-d-H-i-s) + $nameWithoutTimestamp = preg_replace('/-\d{4}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}$/', '', $name) ?? throw new AliasException("Unable to revert name '$name'."); + + // Remove the collection template prefix/suffix + $pattern = str_replace('%s', '(.+)', preg_quote($this->collectionTemplate, '/')); + + if (preg_match('/^'.$pattern.'$/', $nameWithoutTimestamp, $matches)) { + return $matches[1]; + } + + throw new AliasException("Unable to revert name '$name' with template '{$this->collectionTemplate}'."); + } } diff --git a/src/CollectionAlias/CollectionAliasInterface.php b/src/CollectionAlias/CollectionAliasInterface.php index cbb22f7..ad60386 100644 --- a/src/CollectionAlias/CollectionAliasInterface.php +++ b/src/CollectionAlias/CollectionAliasInterface.php @@ -6,5 +6,7 @@ interface CollectionAliasInterface { public function getName(string $name): string; + public function revertName(string $name): string; + public function switch(string $shortName, string $longName): void; } diff --git a/src/Query/SearchQueryWithCollectionInterface.php b/src/Query/SearchQueryWithCollectionInterface.php new file mode 100644 index 0000000..7041b50 --- /dev/null +++ b/src/Query/SearchQueryWithCollectionInterface.php @@ -0,0 +1,8 @@ +collectionName; + } + + public function toArray(): array + { + return $this->searchQuery->toArray(); + } +} diff --git a/src/Resources/config/services.yaml b/src/Resources/config/services.yaml index febd574..f53e83f 100644 --- a/src/Resources/config/services.yaml +++ b/src/Resources/config/services.yaml @@ -82,3 +82,9 @@ services: $enabled: '%biblioverse_typesense.config.auto_update%' tags: - { name: doctrine.event_subscriber, connection: default } + + + Biblioverse\TypesenseBundle\Search\Hydrate\HydrateUnion: + autowire: true + arguments: + $entityMapping: '%biblioverse_typesense.config.entity_mapping%' diff --git a/src/Search/Hydrate/HydrateSearchResult.php b/src/Search/Hydrate/HydrateSearchResult.php index b400552..bb0bf57 100644 --- a/src/Search/Hydrate/HydrateSearchResult.php +++ b/src/Search/Hydrate/HydrateSearchResult.php @@ -6,6 +6,7 @@ use Biblioverse\TypesenseBundle\Search\Results\SearchResultsHydrated; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\Mapping\ClassMetadata; +use Symfony\Component\PropertyAccess\PropertyAccessorInterface; /** * @template T of object @@ -14,7 +15,7 @@ */ class HydrateSearchResult implements HydrateSearchResultInterface { - public function __construct(private readonly EntityManagerInterface $entityManager) + public function __construct(private readonly EntityManagerInterface $entityManager, private readonly PropertyAccessorInterface $propertyAccessor) { } @@ -88,4 +89,24 @@ private function getIdNameFromMetadata(ClassMetadata $classMetadata): string return $identifiers[0]; } + + public function getId(object $object): string + { + $classMetadata = $this->entityManager->getClassMetadata($object::class); + if ($classMetadata->isIdentifierComposite) { + throw new \RuntimeException(sprintf('Composite identifier not supported to be hydrated for class: %s', $classMetadata->getName())); + } + $idField = $this->getIdNameFromMetadata($classMetadata); + + $value = $this->propertyAccessor->getValue($object, $idField); + if ($value === null) { + throw new \RuntimeException(sprintf('Unable to read identifier field for class %s: Value is null', $classMetadata->getName())); + } + + if (false === is_scalar($value)) { + throw new \RuntimeException(sprintf('Unable to read identifier field for class %s: Value is not scalar', $classMetadata->getName())); + } + + return (string) $value; + } } diff --git a/src/Search/Hydrate/HydrateSearchResultInterface.php b/src/Search/Hydrate/HydrateSearchResultInterface.php index f1eec13..e429898 100644 --- a/src/Search/Hydrate/HydrateSearchResultInterface.php +++ b/src/Search/Hydrate/HydrateSearchResultInterface.php @@ -16,4 +16,6 @@ interface HydrateSearchResultInterface * @return SearchResultsHydrated */ public function hydrate(string $class, SearchResults $searchResults): SearchResultsHydrated; + + public function getId(object $object): string; } diff --git a/src/Search/Hydrate/HydrateUnion.php b/src/Search/Hydrate/HydrateUnion.php new file mode 100644 index 0000000..4f33944 --- /dev/null +++ b/src/Search/Hydrate/HydrateUnion.php @@ -0,0 +1,100 @@ + */ + private readonly HydrateSearchResultInterface $hydrateSearchResult, + /** @var array> Map of Entity class and collections */ + private readonly array $entityMapping, + ) { + } + + /** + * @return SearchResultsHydrated + */ + public function hydrate(SearchResults $searchResults): SearchResultsHydrated + { + if ($searchResults->getUnionRequestParameters() === null) { + throw new \LogicException('Union request parameters not set'); + } + + $hitsByCollection = []; + foreach ($searchResults->getHits() as $hit) { + $collection = $this->getCollectionFromHit($hit); + $hitsByCollection[$collection][] = $hit; + } + + $hydratedByCollectionAndIds = []; + foreach ($hitsByCollection as $collection => $hits) { + $hydratedByCollectionAndIds[$collection] = $this->hydrateHitsById($collection, $hits); + } + + $hydratedResults = []; + foreach ($searchResults->getHits() as $hit) { + $collection = $this->getCollectionFromHit($hit); + $id = $hit['document']['id'] ?? null; + if ($id === null || false === is_scalar($id)) { + throw new \RuntimeException('One hit has no identifier: '.json_encode($hit, \JSON_THROW_ON_ERROR)); + } + $id = (string) $id; + if (false === array_key_exists($id, $hydratedByCollectionAndIds[$collection])) { + // Skip Hits not in database + continue; + } + $hydratedResults[$collection.'_'.$id] = $hydratedByCollectionAndIds[$collection][$id]; + } + + return SearchResultsHydrated::fromResultAndCollection($searchResults, $hydratedResults); + } + + /** + * @param Hit $hit + */ + private function getCollectionFromHit(array $hit): string + { + $collection = $hit['collection'] ?? throw new \LogicException('You hit has no collection, which is not normal for union search'); + + return $this->removeCollectionAlias($collection); + } + + private function removeCollectionAlias(string $collection): string + { + return $this->collectionAlias->revertName($collection); + } + + /** + * @param array $hits + * + * @return array Object indexed by ids (as string) + */ + private function hydrateHitsById(string $collection, array $hits): array + { + foreach ($this->entityMapping as $class => $mapped_collection) { + if (false === in_array($collection, $mapped_collection)) { + continue; + } + $results = $this->hydrateSearchResult->hydrate($class, new SearchResults([ + 'hits' => $hits, + ]))->getResults(); + $response = []; + foreach ($results as $result) { + $response[$this->hydrateSearchResult->getId($result)] = $result; + } + + return $response; + } + throw new \RuntimeException(sprintf('Collection %s is not supported to be hydrated', $collection)); + } +} diff --git a/src/Search/Results/AbstractSearchResults.php b/src/Search/Results/AbstractSearchResults.php index 18a98a3..096f1d6 100644 --- a/src/Search/Results/AbstractSearchResults.php +++ b/src/Search/Results/AbstractSearchResults.php @@ -12,9 +12,10 @@ * * @phpstan-type Document array * @phpstan-type Highlight array{field:string, snippet?: string, 'matched_tokens': string[]} - * @phpstan-type Hit array{document: Document, highlight?: Highlight} + * @phpstan-type Hit array{document: Document, highlight?: array,highlights?: list, 'collection'?: string} * @phpstan-type FacetCountItem array{count: int, value: string, highlighted: string} * @phpstan-type FacetCount array{field_name: string, sampled: bool, stats: array{total_values: int}, counts: FacetCountItem[]} + * @phpstan-type UnionRequestParameters array */ abstract class AbstractSearchResults implements \ArrayAccess, \IteratorAggregate, \Countable { @@ -83,6 +84,12 @@ public function getTotalPage(): ?int public function getPerPage(): ?int { + if ($this->getUnionRequestParameters() !== null) { + $result = $this->getUnionRequestParameter(0, 'per_page'); + + return is_int($result) ? $result : null; + } + $params = $this->offsetGet('request_params'); if (!is_array($params)) { return null; @@ -160,6 +167,21 @@ public function getFacetCounts(): array return $data; } + /** + * @return UnionRequestParameters|null + */ + public function getUnionRequestParameters(): ?array + { + if (!$this->offsetExists('union_request_params')) { + return null; + } + + /** @var UnionRequestParameters $data */ + $data = $this->data['union_request_params']; + + return $data; + } + /** * @return \Traversable */ @@ -169,4 +191,14 @@ abstract public function getIterator(): \Traversable; * @return array */ abstract public function getResults(): array; + + private function getUnionRequestParameter(int $int, string $key): mixed + { + $payload = $this->getUnionRequestParameters(); + if (!isset($payload[$int]) || false === array_key_exists($key, $payload[$int])) { + return null; + } + + return $payload[$int][$key]; + } } diff --git a/src/Search/Results/SearchResults.php b/src/Search/Results/SearchResults.php index 2f3430c..e0fffd0 100644 --- a/src/Search/Results/SearchResults.php +++ b/src/Search/Results/SearchResults.php @@ -12,11 +12,11 @@ class SearchResults extends AbstractSearchResults public function getIterator(): \Traversable { $data = []; - if ($this->offsetExists('hits') && is_array($this->data['hits'])) { + if (is_array($this->data['hits']) && $this->offsetExists('hits')) { $data = $this->data['hits']; } /** @var array> $data */ - $data = array_filter(array_map(function (mixed $hits): mixed { + $data = array_filter(array_map(static function (mixed $hits): mixed { if (!is_array($hits) || $hits === [] || !isset($hits['document'])) { return null; } diff --git a/src/Search/Search.php b/src/Search/Search.php index 61f90f4..28ec911 100644 --- a/src/Search/Search.php +++ b/src/Search/Search.php @@ -4,7 +4,8 @@ use Biblioverse\TypesenseBundle\Client\ClientInterface; use Biblioverse\TypesenseBundle\Exception\SearchException; -use Biblioverse\TypesenseBundle\Query\SearchQuery; +use Biblioverse\TypesenseBundle\Query\SearchQueryInterface; +use Biblioverse\TypesenseBundle\Query\SearchQueryWithCollectionInterface; use Biblioverse\TypesenseBundle\Search\Results\SearchResults; use Http\Client\Exception; use Typesense\Exceptions\TypesenseClientError; @@ -18,7 +19,7 @@ public function __construct(private readonly ClientInterface $client) /** * @throws SearchException */ - public function search(string $collectionName, SearchQuery $searchQuery): SearchResults + public function search(string $collectionName, SearchQueryInterface $searchQuery): SearchResults { try { /** @var array $result */ @@ -30,4 +31,53 @@ public function search(string $collectionName, SearchQuery $searchQuery): Search throw new SearchException($e->getMessage(), $e->getCode(), $e); } } + + /** + * @param SearchQueryWithCollectionInterface[] $searchQueries + * @param array $queryParameters + * + * @return SearchResults[] + */ + public function multiSearch(array $searchQueries, array $queryParameters = []): array + { + $rawSearchQueries = array_map(fn (SearchQueryWithCollectionInterface $searchQueryWithCollection) => ['collection' => $searchQueryWithCollection->getCollection()] + $searchQueryWithCollection->toArray(), $searchQueries); + try { + /** @var array{'results'?: array, 'union_request_params'?: array} $rawResult * */ + $rawResult = $this->client->getMultiSearch() + ->perform(['searches' => $rawSearchQueries], $queryParameters); + + // Union search has only one result + if (array_key_exists('union_request_params', $rawResult)) { + return [new SearchResults($rawResult)]; + } + + $results = $rawResult['results'] ?? []; + $response = []; + /** + * @var int $index + * @var array $result + */ + foreach ($results as $index => $result) { + if (isset($result['error'], $result['code'])) { + $this->throwMultiSearchException($index, $result); + } + $response[] = new SearchResults($result); + } + + return $response; + } catch (TypesenseClientError|Exception $e) { + throw new SearchException($e->getMessage(), $e->getCode(), $e); + } + } + + /** + * @param array $result + */ + private function throwMultiSearchException(int $index, array $result): void + { + $code = is_resource($result['code']) || !is_int($result['code']) ? 0 : $result['code']; + $error = is_resource($result['error']) || !is_string($result['error']) ? 'Error' : $result['error']; + + throw new SearchException(sprintf('Multi-search sub-result error at %d: %s %s', $index, $code, $error)); + } } diff --git a/src/Search/SearchCollection.php b/src/Search/SearchCollection.php index 876c170..c968831 100644 --- a/src/Search/SearchCollection.php +++ b/src/Search/SearchCollection.php @@ -3,6 +3,9 @@ namespace Biblioverse\TypesenseBundle\Search; use Biblioverse\TypesenseBundle\Query\SearchQuery; +use Biblioverse\TypesenseBundle\Query\SearchQueryInterface; +use Biblioverse\TypesenseBundle\Query\SearchQueryWithCollectionInterface; +use Biblioverse\TypesenseBundle\Query\SearchQueryWithWithCollectionAdapter; use Biblioverse\TypesenseBundle\Search\Hydrate\HydrateSearchResultInterface; use Biblioverse\TypesenseBundle\Search\Results\SearchResults; use Biblioverse\TypesenseBundle\Search\Results\SearchResultsHydrated; @@ -37,4 +40,32 @@ public function search(SearchQuery $searchQuery): SearchResultsHydrated return $this->hydrateSearchResult->hydrate($this->entityClass, $searchResults); } + + /** + * @param SearchQueryWithCollectionInterface[] $searchQueries + * @param array $queryParameters + * + * @return array> + */ + public function multisearch(array $searchQueries, array $queryParameters = []): array + { + $searchQueriesWithCollection = array_map(fn (SearchQueryInterface $searchQuery) => new SearchQueryWithWithCollectionAdapter($searchQuery, $this->collectionName), $searchQueries); + + $searchResults = $this->search->multisearch($searchQueriesWithCollection, $queryParameters); + + return array_map(fn (SearchResults $searchResults) => $this->hydrateSearchResult->hydrate($this->entityClass, $searchResults), $searchResults); + } + + /** + * @param SearchQuery[] $searchQueries + * @param array $queryParameters + * + * @return array + */ + public function multisearchRaw(array $searchQueries, array $queryParameters = []): array + { + $searchQueriesWithCollection = array_map(fn (SearchQueryInterface $searchQuery) => new SearchQueryWithWithCollectionAdapter($searchQuery, $this->collectionName), $searchQueries); + + return $this->search->multisearch($searchQueriesWithCollection, $queryParameters); + } } diff --git a/src/Search/SearchCollectionInterface.php b/src/Search/SearchCollectionInterface.php index 94f8dc2..67df2f1 100644 --- a/src/Search/SearchCollectionInterface.php +++ b/src/Search/SearchCollectionInterface.php @@ -4,6 +4,7 @@ use Biblioverse\TypesenseBundle\Exception\SearchException; use Biblioverse\TypesenseBundle\Query\SearchQuery; +use Biblioverse\TypesenseBundle\Query\SearchQueryInterface; use Biblioverse\TypesenseBundle\Search\Results\SearchResults; use Biblioverse\TypesenseBundle\Search\Results\SearchResultsHydrated; @@ -19,5 +20,19 @@ interface SearchCollectionInterface */ public function search(SearchQuery $searchQuery): SearchResultsHydrated; + /** + * @param SearchQueryInterface[] $searchQueries + * + * @return list> + */ + public function multisearch(array $searchQueries): array; + public function searchRaw(SearchQuery $searchQuery): SearchResults; + + /** + * @param SearchQueryInterface[] $searchQueries é + * + * @return SearchResults[] + */ + public function multisearchRaw(array $searchQueries): array; } diff --git a/src/Search/SearchInterface.php b/src/Search/SearchInterface.php index 2a701f9..5127809 100644 --- a/src/Search/SearchInterface.php +++ b/src/Search/SearchInterface.php @@ -4,6 +4,7 @@ use Biblioverse\TypesenseBundle\Exception\SearchException; use Biblioverse\TypesenseBundle\Query\SearchQuery; +use Biblioverse\TypesenseBundle\Query\SearchQueryWithCollectionInterface; use Biblioverse\TypesenseBundle\Search\Results\SearchResults; interface SearchInterface @@ -12,4 +13,12 @@ interface SearchInterface * @throws SearchException */ public function search(string $collectionName, SearchQuery $searchQuery): SearchResults; + + /** + * @param SearchQueryWithCollectionInterface[] $searchQueries + * @param array $queryParameters + * + * @return SearchResults[] + */ + public function multiSearch(array $searchQueries, array $queryParameters = []): array; } diff --git a/tests/CollectionAlias/CollectionAliasTest.php b/tests/CollectionAlias/CollectionAliasTest.php index 25b297b..47d2990 100644 --- a/tests/CollectionAlias/CollectionAliasTest.php +++ b/tests/CollectionAlias/CollectionAliasTest.php @@ -24,6 +24,9 @@ public function testTemplate(): void // Date is injected $this->assertStringStartsWith('pre-books-suffix-'.date('Y'), $collectionAlias->getName('books')); + + // Revert works + $this->assertSame('books', $collectionAlias->revertName('pre-books-suffix-'.date('Y-m-d-H-i-s'))); } public function testSwitch(): void diff --git a/tests/Query/SearchQueryWithCollectionAdapterTest.php b/tests/Query/SearchQueryWithCollectionAdapterTest.php new file mode 100644 index 0000000..7e1047e --- /dev/null +++ b/tests/Query/SearchQueryWithCollectionAdapterTest.php @@ -0,0 +1,24 @@ + '12']; + } + }; + + $searchQueryWithWithCollectionAdapter = new SearchQueryWithWithCollectionAdapter($query, 'def'); + $this->assertSame('def', $searchQueryWithWithCollectionAdapter->getCollection()); + $this->assertSame(['q' => '12'], $searchQueryWithWithCollectionAdapter->toArray()); + } +} diff --git a/tests/Query/SearchTest.php b/tests/Query/SearchTest.php new file mode 100644 index 0000000..0a984be --- /dev/null +++ b/tests/Query/SearchTest.php @@ -0,0 +1,315 @@ + $result + * + * @throws \PHPUnit\Framework\MockObject\Exception + */ + private function withSearchResult(string $collectionName, array $result): ClientInterface + { + $docs = $this->createMock(Documents::class); + $docs->expects($this->once())->method('search')->willReturn($result); + + $collection = new Collection('collection', $this->createMock(ApiCall::class)); + $collection->documents = $docs; + + $client = $this->createMock(ClientInterface::class); + $client->expects($this->once())->method('getCollection')->with($collectionName)->willReturn($collection); + + return $client; + } + + /** + * @param array $result + */ + private function withMultiSearchResult(array $result): ClientInterface + { + $multiSearch = $this->createMock(MultiSearch::class); + $multiSearch->expects($this->any())->method('perform')->willReturn($result); + + $client = $this->createMock(ClientInterface::class); + $client->expects($this->once())->method('getMultiSearch')->willReturn($multiSearch); + + return $client; + } + + public function testEmptySearch(): void + { + $client = $this->withSearchResult('mycollection', []); + + $search = new Search($client); + $searchResults = $search->search('mycollection', new SearchQuery(q: 'test', queryBy: 'name')); + + $this->assertCount(0, $searchResults->getResults()); + } + + public function testOneResultSearch(): void + { + $client = $this->withSearchResult('mybooks', [ + 'found' => 1, + 'hits' => [ + 0 => [ + 'document' => [ + 'id' => '117', + 'title' => 'My Book title', + 'updated' => 1738617395, + 'verified' => true, + ], + ], + ], + 'out_of' => 42, + 'page' => 1, + 'request_params' => [ + 'collection_name' => 'mybooks', + 'first_q' => 'book', + 'per_page' => 200, + 'q' => 'book', + ], + 'search_cutoff' => false, + 'search_time_ms' => 6, + ]); + + $search = new Search($client); + $searchResults = $search->search('mybooks', new SearchQuery(q: 'test', queryBy: 'name')); + + $this->assertCount(1, $searchResults->getResults()); + $this->assertSame('My Book title', $searchResults->getResults()[0]['title']); + } + + public function testMultiSearchUnion(): void + { + $client = $this->withMultiSearchResult([ + 'found' => 31, + 'hits' => [ + 0 => [ + 'collection' => 'books-2025-10-03-11-19-09', + 'document' => [ + 'age' => 'enum.agecategories.notset', + 'authors' => [ + 0 => 'Peter Yaworski', + ], + 'book_path' => 'p/peter-yaworski/real-world-bug-hunting-a-field-guide-to-web-hacking/', + 'extension' => 'epub', + 'favorite' => [], + 'hidden' => [], + 'id' => '136', + 'publisher' => 'No Starch Press, Inc.', + 'read' => [], + 'sortable_id' => 136, + 'summary' => '"Real-World Bug Hunting: A Field Guide to Web Hacking" by Peter Yaworski is a practical guide that equips readers with the knowledge and techniques needed to identify and exploit vulnerabilities in web applications. Through real-world examples and hands-on exercises, the book offers valuable insights into the world of ethical hacking and bug bounty programs.', + 'summary_empty' => false, + 'tags' => [ + 0 => 'Informatique', + 1 => 'Sécurité Informatique', + 2 => 'Hacking', + 3 => 'Développement Web', + ], + 'tags_empty' => false, + 'title' => 'Real-World Bug Hunting: A Field Guide to Web Hacking', + 'updated' => 1735038510, + 'verified' => true, + ], + 'highlight' => [ + 'title' => [ + 'matched_tokens' => [ + 0 => 'A', + ], + 'snippet' => 'Real-World Bug Hunting: A Field Guide to Web Hacking', + ], + ], + 'highlights' => [ + 0 => [ + 'field' => 'title', + 'matched_tokens' => [ + 0 => 'A', + ], + 'snippet' => 'Real-World Bug Hunting: A Field Guide to Web Hacking', + ], + ], + 'search_index' => 0, + 'text_match' => 578730123365187705, + 'text_match_info' => [ + 'best_field_score' => '1108091338752', + 'best_field_weight' => 15, + 'fields_matched' => 1, + 'num_tokens_dropped' => 0, + 'score' => '578730123365187705', + 'tokens_matched' => 1, + 'typo_prefix_score' => 0, + ], + ], + 1 => [ + 'collection' => 'books-2025-10-03-11-19-09', + 'document' => [ + 'age' => 'enum.agecategories.notset', + 'authors' => [ + 0 => 'author1', + ], + 'book_path' => 'sample.pdf', + 'extension' => 'epub', + 'favorite' => [], + 'hidden' => [], + 'id' => '134', + 'publisher' => 'No Starch Press', + 'read' => [], + 'sortable_id' => 134, + 'summary' => '"mybook" by author1 provides a practical guide to penetration testing techniques', + 'summary_empty' => false, + 'tags' => [ + 0 => 'Cybersecurity', + 1 => 'Ethical Hacking', + 2 => 'Information Security', + 3 => 'Technology', + 4 => 'Computer Science', + 5 => 'Informatique', + 7 => 'Hacking', + ], + 'tags_empty' => false, + 'title' => 'mybook about blabla', + 'updated' => 1735550597, + 'verified' => false, + ], + 'highlight' => [ + 'title' => [ + 'matched_tokens' => [ + 0 => 'A', + ], + 'snippet' => 'blabla', + ], + ], + 'highlights' => [ + 0 => [ + 'field' => 'title', + 'matched_tokens' => [ + 0 => 'A', + ], + 'snippet' => 'blabla', + ], + ], + 'search_index' => 0, + 'text_match' => 578730123365187705, + 'text_match_info' => [ + 'best_field_score' => '1108091338752', + 'best_field_weight' => 15, + 'fields_matched' => 1, + 'num_tokens_dropped' => 0, + 'score' => '578730123365187705', + 'tokens_matched' => 1, + 'typo_prefix_score' => 0, + ], + ], + ], + 'out_of' => 71, + 'page' => 1, + 'search_cutoff' => false, + 'search_time_ms' => 0, + 'union_request_params' => [ + 0 => [ + 'collection' => 'books-2025-10-03-11-19-09', + 'first_q' => 'a', + 'found' => 22, + 'per_page' => 2, + 'q' => 'a', + ], + 1 => [ + 'collection' => 'books-2025-10-03-11-19-09', + 'found' => 9, + 'per_page' => 2, + 'q' => 'b', + ], + ], + ]); + $search = new Search($client); + $searchResults = $search->multiSearch([ + new SearchQueryWithWithCollectionAdapter(new SearchQuery(q: 'a', queryBy: 'title'), 'books'), + new SearchQueryWithWithCollectionAdapter(new SearchQuery(q: 'b', queryBy: 'title'), 'books'), + ], ['union' => true]); + $this->assertCount(1, $searchResults); + $this->assertEquals(31, $searchResults[0]->getFound()); + $this->assertCount(2, $searchResults[0]->getResults()); + $this->assertArrayHasKey('collection', $searchResults[0]->getHits()[0]); + $this->assertEquals('books-2025-10-03-11-19-09', $searchResults[0]->getHits()[0]['collection']); + $this->assertEquals(2, $searchResults[0]->getPerPage()); + $this->assertEquals(ceil(31 / 2), $searchResults[0]->getTotalPage()); + } + + public function testMultiSearch(): void + { + $client = $this->withMultiSearchResult([ + 'results' => [ + 0 => [ + 'found' => 1, + 'hits' => [ + 0 => [ + 'document' => [ + 'id' => '117', + 'title' => 'Book title', + ], + 'highlight' => [ + 'title' => [ + 'matched_tokens' => [ + 0 => 'Book', + ], + 'snippet' => 'Book title', + ], + ], + 'highlights' => [ + 0 => [ + 'field' => 'title', + 'matched_tokens' => [ + 0 => 'book', + ], + 'snippet' => 'Book title', + ], + ], + 'text_match' => 578730054645710969, + 'text_match_info' => [ + 'best_field_score' => '1108057784320', + 'best_field_weight' => 15, + 'fields_matched' => 1, + 'score' => '578730054645710969', + 'tokens_matched' => 1, + ], + ], + ], + 'out_of' => 42, + 'page' => 1, + 'request_params' => [ + 'collection_name' => 'mybookcollection', + 'first_q' => 'book', + 'per_page' => 200, + 'q' => 'book', + ], + 'search_cutoff' => false, + 'search_time_ms' => 3, + ], + ], + ]); + + $search = new Search($client); + $searchResults = $search->multiSearch([new SearchQueryWithWithCollectionAdapter(new SearchQuery(q: 'book', queryBy: 'title'), 'mybookcollection')]); + + $this->assertCount(1, $searchResults); + $this->assertCount(1, $searchResults[0]->getResults()); + $this->assertCount(1, $searchResults[0]->getResults()); + + /** @var SearchResults $result */ + $result = $searchResults[0]->getResults()[0]; + $this->assertSame('117', $result['id']); + } +} diff --git a/tests/Search/SearchResultTest.php b/tests/Search/SearchResultTest.php index 4ea5e9a..503fe65 100644 --- a/tests/Search/SearchResultTest.php +++ b/tests/Search/SearchResultTest.php @@ -61,6 +61,45 @@ class SearchResultTest extends \PHPUnit\Framework\TestCase 'total_pages' => 12, ]; + public const DATA_MULTISEARCH_UNION = [ + 'found' => 3, + 'hits' => [ + [ + 'collection' => 'books-2025-10-03-11-19-09', + 'document' => [ + 'title' => 'My book 1', + 'id' => '124', + ], + 'highlight' => [], + 'highlights' => [], + ], + [ + 'collection' => 'books-2025-10-03-11-19-09', + 'document' => [ + 'title' => 'my book 2', + 'id' => '125', + ], + 'highlight' => [], + 'highlights' => [], + ], + ], + 'out_of' => 3, + 'page' => 1, + 'search_cutoff' => false, + 'search_time_ms' => 0, + 'union_request_params' => [ + [ + 'collection' => 'books', + 'q' => 'a', + ], + [ + 'collection' => 'posts', + 'first_q' => '*', + 'q' => 'b', + ], + ], + ]; + public function testFound(): void { $searchResults = $this->getSearchResult(); @@ -122,6 +161,13 @@ public function testGetRequestParametersEmpty(): void $this->assertEquals([], $searchResults->getRequestParameters()); } + public function testMultisearchUnion(): void + { + $data = self::DATA_MULTISEARCH_UNION; + $searchResults = $this->getSearchResult($data); + $this->assertEquals(self::DATA_MULTISEARCH_UNION['union_request_params'], $searchResults->getUnionRequestParameters()); + } + public function testGetHighlightEmpty(): void { $data = self::DATA;