Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions src/CollectionAlias/CollectionAlias.php
Original file line number Diff line number Diff line change
Expand Up @@ -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}'.");
}
}
2 changes: 2 additions & 0 deletions src/CollectionAlias/CollectionAliasInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
8 changes: 8 additions & 0 deletions src/Query/SearchQueryWithCollectionInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<?php

namespace Biblioverse\TypesenseBundle\Query;

interface SearchQueryWithCollectionInterface extends SearchQueryInterface
{
public function getCollection(): string;
}
20 changes: 20 additions & 0 deletions src/Query/SearchQueryWithWithCollectionAdapter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

namespace Biblioverse\TypesenseBundle\Query;

class SearchQueryWithWithCollectionAdapter implements SearchQueryWithCollectionInterface
{
public function __construct(private readonly SearchQueryInterface $searchQuery, private readonly string $collectionName)
{
}

public function getCollection(): string
{
return $this->collectionName;
}

public function toArray(): array
{
return $this->searchQuery->toArray();
}
}
6 changes: 6 additions & 0 deletions src/Resources/config/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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%'
23 changes: 22 additions & 1 deletion src/Search/Hydrate/HydrateSearchResult.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
{
}

Expand Down Expand Up @@ -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;
}
}
2 changes: 2 additions & 0 deletions src/Search/Hydrate/HydrateSearchResultInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,6 @@ interface HydrateSearchResultInterface
* @return SearchResultsHydrated<T>
*/
public function hydrate(string $class, SearchResults $searchResults): SearchResultsHydrated;

public function getId(object $object): string;
}
100 changes: 100 additions & 0 deletions src/Search/Hydrate/HydrateUnion.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
<?php

namespace Biblioverse\TypesenseBundle\Search\Hydrate;

use Biblioverse\TypesenseBundle\CollectionAlias\CollectionAliasInterface;
use Biblioverse\TypesenseBundle\Search\Results\AbstractSearchResults;
use Biblioverse\TypesenseBundle\Search\Results\SearchResults;
use Biblioverse\TypesenseBundle\Search\Results\SearchResultsHydrated;

/**
* @phpstan-import-type Hit from AbstractSearchResults
*/
class HydrateUnion
{
public function __construct(
private readonly CollectionAliasInterface $collectionAlias,
/** @var HydrateSearchResultInterface<object> */
private readonly HydrateSearchResultInterface $hydrateSearchResult,
/** @var array<class-string, array<int, string>> Map of Entity class and collections */
private readonly array $entityMapping,
) {
}

/**
* @return SearchResultsHydrated<object>
*/
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<Hit> $hits
*
* @return array<string,object> 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));
}
}
34 changes: 33 additions & 1 deletion src/Search/Results/AbstractSearchResults.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,10 @@
*
* @phpstan-type Document array<string,mixed>
* @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<string, mixed>,highlights?: list<Highlight>, '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<int, array{'collection': string, 'found': int, 'q': string, 'per_page': int}>
*/
abstract class AbstractSearchResults implements \ArrayAccess, \IteratorAggregate, \Countable
{
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<int|string, T>
*/
Expand All @@ -169,4 +191,14 @@ abstract public function getIterator(): \Traversable;
* @return array<int|string, T>
*/
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];
}
}
4 changes: 2 additions & 2 deletions src/Search/Results/SearchResults.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<int, array<string, mixed>> $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;
}
Expand Down
54 changes: 52 additions & 2 deletions src/Search/Search.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<string, mixed> $result */
Expand All @@ -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<string, mixed> $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<string, mixed>, 'union_request_params'?: array<string, mixed>} $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<string,mixed> $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<string, mixed> $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));
}
}
Loading
Loading