search_api_meilisearch-1.0.x-dev/src/Plugin/search_api/backend/SearchApiMeilisearchBackend.php
src/Plugin/search_api/backend/SearchApiMeilisearchBackend.php
<?php
namespace Drupal\search_api_meilisearch\Plugin\search_api\backend;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Plugin\PluginFormInterface;
use Drupal\search_api\Backend\BackendPluginBase;
use Drupal\search_api\Contrib\AutocompleteBackendInterface;
use Drupal\search_api\IndexInterface;
use Drupal\search_api\Item\FieldInterface;
use Drupal\search_api\Item\ItemInterface;
use Drupal\search_api\Plugin\PluginFormTrait;
use Drupal\search_api\Query\QueryInterface;
use Drupal\search_api\SearchApiException;
use Drupal\search_api_autocomplete\SearchInterface;
use Drupal\search_api_meilisearch\Api\MeilisearchApiException;
use Drupal\search_api_meilisearch\Api\MeilisearchApiServiceInterface;
use Drupal\search_api_meilisearch\Backend\SpecialFieldsInterface;
use Drupal\search_api_meilisearch\Converter\Item\ItemConverterInterface;
use Drupal\search_api_meilisearch\Parser\FilterParserInterface;
use Drupal\search_api_meilisearch\Utility\MeilisearchUtils;
use Drupal\search_api_meilisearch_autocomplete\MeilisearchAutocompleteServiceInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Provides a backend plugin for Search API using Meilisearch.
*
* @SearchApiBackend(
* id = "search_api_meilisearch",
* label = @Translation("Meilisearch"),
* description = @Translation("Index items using Meilisearch Server.")
* )
*/
final class SearchApiMeilisearchBackend extends BackendPluginBase implements AutocompleteBackendInterface, PluginFormInterface, SpecialFieldsInterface {
use PluginFormTrait;
/**
* Meilisearch service for making API calls.
*
* @var \Drupal\search_api_meilisearch\Api\MeilisearchApiServiceInterface
*/
protected MeilisearchApiServiceInterface $meiliService;
/**
* The logger to use for logging messages.
*
* @var \Psr\Log\LoggerInterface
*/
protected $logger;
/**
* The filter parser service.
*
* @var \Drupal\search_api_meilisearch\Parser\FilterParserInterface
*/
protected FilterParserInterface $filterParser;
/**
* The item list converter service.
*
* @var \Drupal\search_api_meilisearch\Converter\Item\ItemConverterInterface
*/
protected ItemConverterInterface $itemConverter;
/**
* Autocomplete Meilisearch service.
*
* @var \Drupal\search_api_meilisearch_autocomplete\MeilisearchAutocompleteServiceInterface|null
*/
protected ?MeilisearchAutocompleteServiceInterface $autocomplete;
/**
* The construct method for the Meilisearch back-end plugin.
*
* @param array $configuration
* Configuration array.
* @param string $plugin_id
* The plugin id.
* @param mixed $plugin_definition
* A plugin definition.
* @param \Drupal\search_api_meilisearch\Api\MeilisearchApiServiceInterface $meiliService
* A MeilisearchApiService instance.
* @param \Psr\Log\LoggerInterface $logger
* A LoggerInterface instance.
* @param \Drupal\search_api_meilisearch\Parser\FilterParserInterface $filterParser
* The filter parser service.
* @param \Drupal\search_api_meilisearch\Converter\Item\ItemConverterInterface $itemConverter
* The item list converter service.
* @param ?\Drupal\search_api_meilisearch_autocomplete\MeilisearchAutocompleteServiceInterface $autocomplete
* The autocomplete Meilisearch service.
*/
public function __construct(array $configuration, $plugin_id, $plugin_definition, MeilisearchApiServiceInterface $meiliService, LoggerInterface $logger, FilterParserInterface $filterParser, ItemConverterInterface $itemConverter, ?MeilisearchAutocompleteServiceInterface $autocomplete = NULL) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
$this->meiliService = $meiliService;
$this->meiliService->setUrl($this->configuration['meilisearch_host_address'] . ':' . $this->configuration['meilisearch_host_port']);
$this->meiliService->setMasterKey($this->configuration['meilisearch_master_key']);
$this->logger = $logger;
$this->filterParser = $filterParser;
$this->itemConverter = $itemConverter;
$this->autocomplete = $autocomplete;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): self {
return new self(
$configuration,
$plugin_id,
$plugin_definition,
$container->get('search_api_meilisearch.api'),
$container->get('logger.channel.search_api_meilisearch'),
$container->get('search_api_meilisearch.filter_parser'),
$container->get('search_api_meilisearch.item_converter'),
$container->has('search_api_meilisearch_autocomplete.autocomplete') ? $container->get('search_api_meilisearch_autocomplete.autocomplete') : NULL
);
}
/**
* {@inheritdoc}
*/
public function viewSettings(): array {
$indexesList = [];
try {
$info = [];
$version = $this->meiliService->connection()->version();
$info[] = [
'label' => $this->t('Version'),
'info' => $version['pkgVersion'],
];
$indexes = $this->meiliService->listIndexes();
foreach ($indexes as $index) {
$indexesList[] = $index->getUid();
}
$info[] = [
'label' => $this->t('Available Meilisearch indexes'),
'info' => !empty(implode(', ', $indexesList)) ? implode(', ', $indexesList) : $this->t('No indexes found.'),
];
return $info;
}
catch (MeilisearchApiException $e) {
$this->handleExceptions($e);
}
return [];
}
/**
* {@inheritdoc}
*/
public function defaultConfiguration(): array {
return [
'meilisearch_host_address' => 'http://127.0.0.1',
'meilisearch_host_port' => 7700,
'meilisearch_master_key' => '',
];
}
/**
* {@inheritdoc}
*/
public function buildConfigurationForm(array $form, FormStateInterface $form_state): array {
$form['meilisearch_host_address'] = [
'#type' => 'textfield',
'#title' => $this->t('Meilisearch Host Address:'),
'#maxlength' => 255,
'#size' => 30,
'#required' => TRUE,
'#description' => $this->t('The host address of the Meilisearch server.'),
'#default_value' => $this->configuration['meilisearch_host_address'],
'#attributes' => [
'placeholder' => 'http://127.0.0.1',
],
];
$form['meilisearch_host_port'] = [
'#type' => 'textfield',
'#title' => $this->t('Meilisearch Host Port:'),
'#maxlength' => 5,
'#size' => 5,
'#required' => TRUE,
'#description' => $this->t('The port of the Meilisearch server.'),
'#attributes' => [
'placeholder' => '7700',
],
'#default_value' => $this->configuration['meilisearch_host_port'],
];
$form['meilisearch_master_key'] = [
'#type' => 'textfield',
'#title' => $this->t('Meilisearch Master Key:'),
'#maxlength' => 255,
'#size' => 64,
'#default_value' => $this->configuration['meilisearch_master_key'],
];
return $form;
}
/**
* {@inheritdoc}
*/
public function validateConfigurationForm(array &$form, FormStateInterface $form_state) {
$hostAddress = $form_state->getValue('meilisearch_host_address');
if (substr($hostAddress, -1) == '/') {
$form_state->setValue('meilisearch_host_address', mb_substr($hostAddress, 0, -1));
}
$hostPort = (int) $form_state->getValue('meilisearch_host_port');
if ($hostPort > 65535 || $hostPort <= 0) {
$form_state->setErrorByName('meilisearch_host_port', $this->t('Host port must be between 1 and 65535'));
}
}
/**
* {@inheritdoc}
*/
public function setConfiguration(array $configuration) {
parent::setConfiguration($configuration);
$this->meiliService->setUrl($this->configuration['meilisearch_host_address'] . ':' . $this->configuration['meilisearch_host_port']);
$this->meiliService->setMasterKey($this->configuration['meilisearch_master_key']);
}
/**
* {@inheritdoc}
*/
public function removeIndex($index) {
if ($index->isReadOnly()) {
return;
}
try {
$this->meiliService->removeIndex($index->id());
}
catch (MeilisearchApiException $e) {
$this->logger->error($e->getMessage());
$this->messenger()->addError($this->t('Could not remove @name index.', ['@name' => $index->id()]));
}
}
/**
* {@inheritdoc}
*/
public function indexItems(IndexInterface $index, array $items): array {
$documents = $this->itemConverter->convertToDocuments($items);
try {
$addDocuments = $this->meiliService->addDocuments($index->id(), $documents);
$task = $this->meiliService->waitForUpdate($addDocuments['taskUid']);
if ($task['status'] === 'failed') {
throw new MeilisearchApiException($task['error']['message']);
}
$this->manageStopWords($index);
}
catch (MeilisearchApiException $e) {
$this->handleExceptions($e);
}
return array_keys($items);
}
/**
* {@inheritdoc}
*/
public function addIndex(IndexInterface $index) {
try {
$createIndex = $this->meiliService->createIndex($index->id());
$task = $this->meiliService->waitForUpdate($createIndex['taskUid']);
if ($task['status'] === 'failed') {
throw new MeilisearchApiException($task['error']['message']);
}
$this->updateIndex($index);
}
catch (MeilisearchApiException $e) {
$this->handleExceptions($e);
}
}
/**
* {@inheritdoc}
*/
public function updateIndex(IndexInterface $index) {
try {
$rankingRules = $this->generateRankingRules();
$oldRankingRules = $this->meiliService->getRankingRules($index->id());
if ($rankingRules !== $oldRankingRules) {
$status = $this->meiliService->setRankingRules($index->id(), $rankingRules);
if (isset($status['taskUid'])) {
$task = $this->meiliService->waitForUpdate($status['taskUid']);
if ($task['status'] === 'failed') {
throw new MeilisearchApiException($task['error']['message']);
}
}
}
$searchableAttributes = $this->generateSearchableAttributes($index);
$oldSearchableAttributes = $this->meiliService->getSearchableAttributes($index->id());
if ($oldSearchableAttributes != $searchableAttributes) {
$status = $this->meiliService->setSearchableAttributes($index->id(), $searchableAttributes);
if (isset($status['taskUid'])) {
$task = $this->meiliService->waitForUpdate($status['taskUid']);
if ($task['status'] === 'failed') {
throw new MeilisearchApiException($task['error']['message']);
}
}
}
$sortableAttributes = $this->generateSortableAttributes($index);
$oldSortableAttributes = $this->meiliService->getSortableAttributes($index->id());
sort($sortableAttributes);
sort($oldSortableAttributes);
if ($oldSortableAttributes !== $sortableAttributes) {
$status = $this->meiliService->setSortableAttributes($index->id(), $sortableAttributes);
if (isset($status['taskUid'])) {
$task = $this->meiliService->waitForUpdate($status['taskUid']);
if ($task['status'] === 'failed') {
throw new MeilisearchApiException($task['error']['message']);
}
}
}
$meiliAttributes = $this->meiliService->getIndex($index->id())->getFilterableAttributes();
$indexFields = $index->getFields(TRUE) + $this->getSpecialFields($index);
unset($indexFields['id']);
$indexAttributes = array_keys($indexFields);
sort($meiliAttributes);
sort($indexAttributes);
if ($meiliAttributes !== $indexAttributes) {
$status = $this->meiliService->setFilterableAttributes($index->id(), $indexAttributes);
if (isset($status['taskUid'])) {
$task = $this->meiliService->waitForUpdate($status['taskUid']);
if ($task['status'] === 'failed') {
throw new MeilisearchApiException($task['error']['message']);
}
}
}
if ($this->synonymsDisabled($index)) {
$status = $this->meiliService->resetSynonyms($index->id());
if (isset($status['taskUid'])) {
$task = $this->meiliService->waitForUpdate($status['taskUid']);
if ($task['status'] === 'failed') {
throw new MeilisearchApiException($task['error']['message']);
}
}
}
}
catch (\Exception $e) {
$this->handleExceptions($e);
}
}
/**
* {@inheritdoc}
*/
public function getItemSpecialFields(ItemInterface $item): array {
return $this->getSpecialFields($item->getIndex(), $item);
}
/**
* {@inheritdoc}
*/
protected function getSpecialFields(IndexInterface $index, ?ItemInterface $item = NULL): array {
$fields = parent::getSpecialFields($index, $item);
$fields['id'] = $this->getFieldsHelper()->createField($index, 'id', [
'type' => 'string',
'original type' => 'string',
]);
if ($item) {
$fields['id']->setValues([MeilisearchUtils::formatAsDocumentId($item->getId())]);
}
return $fields;
}
/**
* Check if synonyms processor was disabled.
*
* @param \Drupal\search_api\IndexInterface $index
* The current index instance.
*
* @return bool
* Returns true if synonyms processor was disabled.
*/
private function synonymsDisabled(IndexInterface $index): bool {
if (!isset($index->original)) {
return FALSE;
}
$synonymsProcessorEnabled = in_array(
'search_api_meilisearch_synonyms',
array_keys($index->getProcessors())
);
$synonymsProcessorEnabledOld = in_array(
'search_api_meilisearch_synonyms',
array_keys($index->original->getProcessors())
);
return !$synonymsProcessorEnabled && $synonymsProcessorEnabledOld;
}
/**
* Generate the Meilisearch ranking rules for the specified search api index.
*
* @return string[]
* The ranking rules.
*/
protected function generateRankingRules(): array {
// We place sort on top, as we want to match by sort first, if a sort is
// present in the query.
// @see https://www.meilisearch.com/docs/learn/core_concepts/relevancy#ranking-rules
return [
'sort',
'words',
'attribute',
'typo',
'proximity',
'exactness',
];
}
/**
* Generates the Meilisearch sortable attributes for the index.
*
* @param \Drupal\search_api\IndexInterface $index
* The search api index.
*
* @return array
* The sortable attributes.
*/
protected function generateSortableAttributes(IndexInterface $index): array {
$fields = $index->getFields(TRUE) + $this->getSpecialFields($index);
unset($fields['id']);
// We allow all fields to be sorted by.
$sortableAttributes = [];
foreach ($fields as $field) {
$sortableAttributes[] = $field->getFieldIdentifier();
}
return $sortableAttributes;
}
/**
* Generates the Meilisearch searchable attributes for the index.
*
* @param \Drupal\search_api\IndexInterface $index
* The search api index.
*
* @return string[]
* The searchable attributes.
*/
protected function generateSearchableAttributes(IndexInterface $index): array {
$fields = $index->getFields() + $this->getSpecialFields($index);
unset($fields['id']);
// Sort the index's fields by boost -> a higher boost setting should appear
// higher in the ranking rules.
usort($fields, function (FieldInterface $a, FieldInterface $b) {
if ($a->getBoost() === $b->getBoost()) {
return 0;
}
return ($a->getBoost() < $b->getBoost()) ? 1 : -1;
});
$searchableFields = [];
foreach ($fields as $field) {
// Only fulltext fields are searchable and have boost settings.
if ($field->getType() === 'text') {
$searchableFields[] = $field->getFieldIdentifier();
}
}
return $searchableFields;
}
/**
* {@inheritdoc}
*/
public function deleteItems(IndexInterface $index, array $item_ids) {
try {
$ids = [];
foreach ($item_ids as $id) {
$ids[] = MeilisearchUtils::formatAsDocumentId($id);
}
$deleteDocuments = $this->meiliService->deleteDocuments($index->id(), $ids);
$task = $this->meiliService->waitForUpdate($deleteDocuments['taskUid']);
if ($task['status'] === 'failed') {
throw new MeilisearchApiException($task['error']['message']);
}
}
catch (MeilisearchApiException $e) {
$this->handleExceptions($e);
}
}
/**
* {@inheritdoc}
*/
public function deleteAllIndexItems(IndexInterface $index, $datasource_id = NULL) {
try {
$deleteAllDocuments = $this->meiliService->deleteAllDocuments($index->id());
$task = $this->meiliService->waitForUpdate($deleteAllDocuments['taskUid']);
if ($task['status'] === 'failed') {
throw new MeilisearchApiException($task['error']['message']);
}
}
catch (MeilisearchApiException $e) {
$this->handleExceptions($e);
}
}
/**
* {@inheritdoc}
*/
public function search(QueryInterface $query) {
$results = $query->getResults();
$options = $query->getOptions();
$index = $query->getIndex();
try {
$meiliOptions = [
'showRankingScore' => TRUE,
];
$filter = $this->filterParser->parseExpression($query->getConditionGroup(), $index);
if ($filter) {
$meiliOptions['filter'] = $filter;
unset($filter);
}
$meiliOptions['limit'] = 0;
$keys = $query->getOriginalKeys() ?? '';
$meiliOptions['offset'] = $options['offset'] ?? 0;
if (isset($options['limit']) && $options['limit'] != 0) {
$meiliOptions['limit'] = (int) $options['limit'];
}
else {
$emptyQuery = $this->meiliService->search($index->id(), $keys, $meiliOptions);
$meiliOptions['limit'] = $emptyQuery->getEstimatedTotalHits();
}
$resultsOptions = [
'relevance_ascending' => FALSE,
'random' => FALSE,
];
$sorts = $query->getSorts();
foreach ($sorts as $field_name => $order) {
if ($order != QueryInterface::SORT_ASC && $order != QueryInterface::SORT_DESC) {
$order = QueryInterface::SORT_ASC;
}
if ($field_name === 'search_api_relevance') {
if ($order === QueryInterface::SORT_ASC) {
$resultsOptions['relevance_ascending'] = TRUE;
}
// Meilisearch already returns the results by relevance in desc order.
continue;
}
if ($field_name === 'search_api_random') {
$resultsOptions['random'] = TRUE;
continue;
}
$meiliOptions['sort'][] = $field_name . ':' . strtolower($order);
}
$data = $this->meiliService->search($index->id(), $keys, $meiliOptions);
if (!$data->getHits()) {
return;
}
$results->setResultCount($data->getEstimatedTotalHits());
if (empty($data->getHits())) {
return;
}
$hits = $data->getHits();
if ($resultsOptions['relevance_ascending'] === TRUE) {
// We need to reverse the search results.
$hits = array_reverse($hits, TRUE);
}
if ($resultsOptions['random'] === TRUE) {
shuffle($hits);
}
foreach ($hits as $row) {
if (!isset($row['search_api_id'])) {
continue;
}
$item = $this->getFieldsHelper()->createItem($index, $row['search_api_id']);
$item->setScore($row['_rankingScore']);
$results->addResultItem($item);
}
}
catch (MeilisearchApiException $e) {
$this->handleExceptions($e);
throw new SearchApiException($e->getMessage(), $e->getCode(), $e);
}
}
/**
* {@inheritdoc}
*/
public function getSupportedFeatures(): array {
return [
'search_api_random_sort',
];
}
/**
* {@inheritdoc}
*/
public function getAutocompleteSuggestions(QueryInterface $query, SearchInterface $search, $incomplete_key, $user_input): array {
return $this->autocomplete->getSuggestions($query, $search, $incomplete_key, $user_input);
}
/**
* Manages the stopwords list.
*
* If the "Stopwords" processor is enabled, add the stopwords entered in
* the settings to the Meilisearch server. Otherwise, reset the list.
*
* @param \Drupal\search_api\IndexInterface $index
* The current index instance.
*
* @return array
* An update id or false if everything fails.
*/
public function manageStopWords(IndexInterface $index): array {
$processors = $index->getProcessors();
if (isset($processors['stopwords'])) {
$indexArray = $index->toArray();
$stopwords = array_values($indexArray['processor_settings']['stopwords']['stopwords']);
try {
return $this->meiliService->updateStopWords($index->id(), $stopwords);
}
catch (MeilisearchApiException $e) {
return $this->handleExceptions($e);
}
}
else {
try {
return $this->meiliService->resetStopWords($index->id());
}
catch (MeilisearchApiException $e) {
return $this->handleExceptions($e);
}
}
}
/**
* {@inheritdoc}
*/
public function isAvailable(): bool {
try {
return $this->meiliService->ping() !== FALSE;
}
catch (\Exception $e) {
$this->logger->error($e->getMessage());
}
return FALSE;
}
/**
* Handles the exceptions.
*
* @param \Exception $exception
* The exception that was thrown.
*
* @return array
* Returns an empty array.
*/
public function handleExceptions(\Exception $exception): array {
$this->logger->error($exception->getMessage());
switch ($exception->getCode()) {
case(403):
$this->messenger()
->addError($this->t('The master key is not correct.'));
return [];
default:
$this->messenger()
->addError($this->t('Search API Meilisearch error(s) occurred. Please check the logs for more information.'));
return [];
}
}
/**
* {@inheritdoc}
*/
public function __sleep(): array {
$properties = array_flip(parent::__sleep());
unset($properties['meiliService']);
return array_keys($properties);
}
/**
* {@inheritdoc}
*/
public function __wakeup(): void {
parent::__wakeup();
/* @phpstan-ignore-next-line */
$container = \Drupal::getContainer();
$this->meiliService = $container->get('search_api_meilisearch.api');
$this->meiliService->setUrl($this->configuration['meilisearch_host_address'] . ':' . $this->configuration['meilisearch_host_port']);
$this->meiliService->setMasterKey($this->configuration['meilisearch_master_key']);
}
}
