ebs/src/Search/Meilisearch.php
Loïc Vernet 32d91e49a0
Symfony 6.4 update (#590)
* symfony 6.3: removed sensio/framework-extra-bundle

* symfony 6.3: update

* Symfony 6.3.1 update

* chore: composer up

* symfony 6.4 update

* cs: php-code-fixer update

* fix composer.lock

* add php-http required dependencies

* Fix Stan and CS

---------

Co-authored-by: Jérôme Tanghe <jerome.tanghe@les-tilleuls.coop>
2024-10-08 09:50:06 +02:00

320 lines
9.5 KiB
PHP
Executable file

<?php
declare(strict_types=1);
namespace App\Search;
use App\Controller\Product\ProductController;
use App\Dto\Product\Search;
use App\Entity\Address;
use App\Entity\Product;
use App\Entity\User;
use App\Enum\Product\ProductType;
use App\Enum\Product\ProductVisibility;
use App\Repository\ProductRepository;
use App\Search\Document\ProductDocument;
use Meilisearch\Client;
use Meilisearch\Endpoints\Indexes;
use Meilisearch\Search\SearchResult;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Serializer\Exception\ExceptionInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use Webmozart\Assert\Assert;
/**
* Simple service to index and retrieve results from Meilisearch. Class is not final
* so we can use the lazy option.
*/
#[Autoconfigure(lazy: true)]
class Meilisearch
{
final public const PRODUCTS_INDEX = 'products';
final public const PRODUCTS_SWAP_INDEX = self::PRODUCTS_INDEX.'_swap';
final public const PRIMARY_KEY = 'id';
final public const SEARCHABLE_ATTRIBUTES = [
'name',
'categories',
'description',
];
final public const FILTRABLE_ATTRIBUTES = [
'ownerId',
'type',
'visibility',
'categoriesIds',
'groupsIds',
'_geo',
];
final public const SORTABLE_ATTRIBUTES = [
'name',
'_geo',
];
/**
* Main Meilisearch client.
*/
private Client $client;
/**
* Main search index (locally cached).
*/
private ?Indexes $index = null;
public function __construct(
private readonly NormalizerInterface $normalizer,
private readonly ProductRepository $productRepository,
#[Autowire('%meilisearchUrl%')]
private readonly string $meilisearchUrl,
#[Autowire('%meilisearchApiKey%')]
private readonly string $meilisearchApiKey,
) {
$this->client = new Client($this->meilisearchUrl, $this->meilisearchApiKey);
}
/**
* For direct access to main Meili client.
*/
public function getClient(): Client
{
return $this->client;
}
public function getIndex(): Indexes
{
if ($this->index !== null) {
return $this->index;
}
$this->index = $this->client->index(self::PRODUCTS_INDEX);
return $this->index;
}
public function getSwapIndex(): Indexes
{
return $this->client->index(self::PRODUCTS_SWAP_INDEX);
}
public function setSearchableAtttributes(): void
{
$this->getIndex()->updateSearchableAttributes(self::SEARCHABLE_ATTRIBUTES);
}
public function setFiltrableAttributes(): void
{
$this->getIndex()->updateFilterableAttributes(self::FILTRABLE_ATTRIBUTES);
}
public function setSortableAttributes(): void
{
$this->getIndex()->updateSortableAttributes(self::SORTABLE_ATTRIBUTES);
}
/**
* Aplly all settings at once.
*/
public function setSettings(): void
{
$this->setSearchableAtttributes();
$this->setFiltrableAttributes();
$this->setSortableAttributes();
}
/**
* Allows to have full control about the normalization. But with tne 1.1 version
* of Meilisearch, we should be able to pass the document DTO as it is and let
* Meilisearch handle the normalization process.
*
* @return array<string, mixed>
*
* @throws ExceptionInterface
*/
public function normalizeProduct(Product $product): array
{
$productDocument = ProductDocument::fromProduct($product);
/** @var array<string, mixed> $normalized */
$normalized = $this->normalizer->normalize($productDocument, 'array');
return $normalized;
}
public function deleteProduct(Product $product, ?Indexes $index = null): void
{
$index = $index ?? $this->getIndex();
$index->deleteDocument((string) $product->getId());
}
/**
* @throws ExceptionInterface
*/
public function indexProduct(Product $product, ?Indexes $index = null): void
{
$index = $index ?? $this->getIndex();
$index->addDocuments([$this->normalizeProduct($product)], self::PRIMARY_KEY);
}
/**
* @param array<Product> $products
*
* @throws ExceptionInterface
*/
public function indexProducts(array $products, ?Indexes $index = null): void
{
$index = $index ?? $this->getIndex();
$documents = array_map(fn (Product $product) => $this->normalizeProduct($product), $products);
$index->addDocuments($documents, self::PRIMARY_KEY);
}
/**
* Swap indexes to avoid downtime.
*/
public function swapIndexes(): void
{
$this->getClient()->swapIndexes([[self::PRODUCTS_INDEX, self::PRODUCTS_SWAP_INDEX]]);
}
public function searchObjects(Search $searchDto): SearchResult
{
return $this->search($searchDto, ProductType::OBJECT);
}
public function searchServices(Search $searchDto): SearchResult
{
return $this->search($searchDto, ProductType::SERVICE);
}
/**
* Search with a main query and various filtery.
*/
public function search(Search $searchDto, ?ProductType $productType = null): SearchResult
{
$searchParams = [];
$searchParams = $this->withFilters($searchParams, $searchDto, $productType);
$searchParams = $this->withSort($searchParams, $searchDto);
// pagination settings
$searchParams['hitsPerPage'] = ProductController::MAX_ELEMENT_BY_PAGE;
$searchParams['page'] = $searchDto->page;
// option to transform hits to products while keeping the relevance order
$options = ['transformHits' => $this->transformHits(...)];
return $this->getIndex()->search($searchDto->q, $searchParams, $options);
}
/**
* Apply all search filters.
*
* @param array<string, mixed> $searchParams
*
* @return array<string, mixed>
*/
private function withFilters(array $searchParams, Search $searchDto, ?ProductType $productType = null): array
{
$filters = [];
// if the user is NOT logged then he can only view public products
// if the user is logged he will also view the products belonging to its groups
$visibilityFilter = [];
$visibilityFilter[] = 'visibility = '.ProductVisibility::PUBLIC->value;
if ($searchDto->isLogged()) {
Assert::isInstanceOf($searchDto->user, User::class);
$userGroupsIds = $searchDto->user->getUserGroupsIds();
$visibilityFilter[] = 'groupsIds IN [ '.implode(', ', $userGroupsIds).' ]';
}
$filters[] = '( '.implode(' OR ', $visibilityFilter).' )';
// product type as a filter
if ($productType !== null) {
$filters[] = 'type = '.$productType->value;
}
// category filter
if ($searchDto->category !== null) {
$filters[] = 'categoriesIds = '.$searchDto->category->getId();
}
// place filter
if ($searchDto->place !== null) {
$filters[] = \sprintf('ownerId = %s', $searchDto->place->getId());
}
// geo filter
if ($searchDto->hasProximity()) {
Assert::isInstanceOf($searchDto->city, Address::class);
$filters[] = \sprintf('_geoRadius(%s, %s, %d)',
$searchDto->city->getLatitude(),
$searchDto->city->getLongitude(),
(int) $searchDto->distance * 1000 // the distance is in meters, not kilometers
);
}
// Filters are cumulative
$searchParams['filter'] = implode(' AND ', $filters);
return $searchParams;
}
/**
* Apply sort by name or proximity.
*
* @param array<string, mixed> $searchParams
*
* @return array<string, mixed>
*/
private function withSort(array $searchParams, Search $searchDto): array
{
// the proximity search has the priority to sort results
if ($searchDto->hasProximity()) {
Assert::isInstanceOf($searchDto->city, Address::class);
$searchParams['sort'] = [\sprintf('_geoPoint(%s, %s):asc',
$searchDto->city->getLatitude(),
$searchDto->city->getLongitude()),
];
}
// default sort: if no query is specified and not proximity filter then sort by name
if (!$searchDto->hasQuery() && !$searchDto->hasProximity()) {
$searchParams['sort'] = ['name:asc'];
}
return $searchParams;
}
/**
* Transform the hits to an array of product. If a product is not found is it
* simply removed from the results.
*
* @param array<int, array<string, mixed>> $hits
*
* @return array<Product>
*/
private function transformHits(array $hits): array
{
$products = array_map($this->getProduct(...), $hits);
return array_filter($products);
}
/**
* @param array<string, mixed> $hit
*/
private function getProduct(array $hit): ?Product
{
$product = $this->productRepository->find($hit['id'] ?? ''); // don't use null as it raises a doctrine exception
if ($product === null) {
return null;
}
// enrich with the distance to the geoPoint if it is available
if (\array_key_exists('_geoDistance', $hit)) {
$product->setGeoDistance(\is_int($hit['_geoDistance']) ? $hit['_geoDistance'] : null);
}
return $product;
}
}