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']);
  }

}

Главная | Обратная связь

drupal hosting | друпал хостинг | it patrol .inc