toolshed-8.x-1.x-dev/modules/toolshed_search/src/Controller/SearchAutocomplete.php
modules/toolshed_search/src/Controller/SearchAutocomplete.php
<?php
namespace Drupal\toolshed_search\Controller;
use Drupal\Component\Plugin\Exception\PluginNotFoundException;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Access\AccessResultInterface;
use Drupal\Core\Cache\CacheableJsonResponse;
use Drupal\Core\Cache\CacheableMetadata;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Entity\Plugin\DataType\EntityAdapter;
use Drupal\Core\Logger\LoggerChannelTrait;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\Utility\Error;
use Drupal\search_api\ParseMode\ParseModePluginManager;
use Drupal\search_api\Query\QueryInterface;
use Drupal\search_api\SearchApiException;
use Drupal\toolshed_search\Plugin\views\filter\EntitySelectionFilter;
use Drupal\views\ViewEntityInterface;
use Drupal\views\ViewExecutableFactory;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
/**
* The Toolshed search autocomplete route controller.
*
* Handles building autocomplete suggestions for Views filter handlers connected
* to a Search API index. Allows for better fulltext autocomplete using a
* Search API query instead of the default Drupal database handler.
*
* Because it uses the Search API index, ensure that the required information
* for entity you wish to search using this is being indexed (for example the
* "bundle", "label", etc...).
*/
class SearchAutocomplete implements ContainerInjectionInterface {
use LoggerChannelTrait;
/**
* The entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected EntityTypeManagerInterface $entityTypeManager;
/**
* The Views executable factory.
*
* @var \Drupal\views\ViewExecutableFactory
*/
protected ViewExecutableFactory $viewExecFactory;
/**
* The Search API parse mode plugin manager for fulltext fields.
*
* @var \Drupal\search_api\ParseMode\ParseModePluginManager
*/
protected ParseModePluginManager $parseModeManager;
/**
* Create a new instance of the SearchAutocomplete route controller.
*
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager.
* @param \Drupal\views\ViewExecutableFactory $view_executable_factory
* The Views executable factory.
* @param \Drupal\search_api\ParseMode\ParseModePluginManager $parse_mode_manager
* The Search API parse mode plugin manager for fulltext fields.
*/
public function __construct(EntityTypeManagerInterface $entity_type_manager, ViewExecutableFactory $view_executable_factory, ParseModePluginManager $parse_mode_manager) {
$this->entityTypeManager = $entity_type_manager;
$this->viewExecFactory = $view_executable_factory;
$this->parseModeManager = $parse_mode_manager;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container): static {
return new static(
$container->get('entity_type.manager'),
$container->get('views.executable'),
$container->get('plugin.manager.search_api.parse_mode')
);
}
/**
* Access check for a views display for autocomplete routes based from views.
*
* @param \Drupal\Core\Session\AccountInterface $account
* The user account to check access for.
* @param \Drupal\views\ViewEntityInterface $view
* The Views configuration entity to base the entity autocomplete values.
* @param string $display
* The ID of the views display being utilized for this autocomplete route.
*
* @return \Drupal\Core\Access\AccessResultInterface
* The access results for the view display.
*/
public function viewAccess(AccountInterface $account, ViewEntityInterface $view, string $display): AccessResultInterface {
$viewExec = $this->viewExecFactory->get($view);
if ($viewExec->setDisplay($display)) {
$displayHandler = $viewExec->display_handler;
$access = AccessResult::allowedIf($displayHandler->access($account))
->addCacheableDependency($displayHandler->getCacheMetadata());
}
else {
$access = AccessResult::forbidden()
->addCacheTags($viewExec->getCacheTags());
}
$access->addCacheContexts(['route']);
return $access;
}
/**
* The route controller for building a View autocomplete entity filter.
*
* @param \Symfony\Component\HttpFoundation\Request $request
* The current HTTP request.
* @param \Drupal\views\ViewEntityInterface $view
* The View configuration entity that owns the filter the suggestions are
* being create for.
* @param string $display
* The View display the contains the filter the suggestions are for.
* @param string $filter_id
* The view filter the suggestions are being built for.
*
* @return \Symfony\Component\HttpFoundation\JsonResponse
* JSON response which is compatible for the Toolshed autocomplete widget
* and can be used for the autocomplete suggestion values.
*/
public function entityAutocomplete(Request $request, ViewEntityInterface $view, string $display, string $filter_id): JsonResponse {
try {
// Setup response caching.
$cacheMeta = new CacheableMetadata();
$cacheMeta->addCacheContexts([
'route',
'url.query_args:q',
]);
$suggestions = [];
$keys = $request->query->get('q');
$viewExec = $this->viewExecFactory->get($view);
if ($keys && $viewExec->setDisplay($display)) {
$displayHandler = $viewExec->display_handler;
$filter = $displayHandler->getHandler('filter', $filter_id);
if (!$filter instanceof EntitySelectionFilter || !($settings = $filter->getAutocompleteSettings())) {
$errorMsg = sprintf('Invalid filter %s for View: "%s" is missing or not a valid entity selection handler.', $filter_id, $view->id());
throw new \InvalidArgumentException($errorMsg);
}
$fulltextOp = $settings['fulltext_op'] ?? 'AND';
$query = $this->createQuery($view)
->setFulltextFields($settings['fulltext_fields'])
->range(0, $settings['limit']);
// If entity results should be limited to a set of allowed bundles.
$query->addCondition('search_api_datasource', "entity:{$settings['entity_type']}");
$this->applyBundleFilter($query, $settings['entity_type'], $settings['bundles']);
try {
if ($settings['parse_mode']) {
$parseMode = $this->parseModeManager->createInstance($settings['parse_mode']);
$parseMode->setConjunction('AND' === $fulltextOp ? 'AND' : 'OR');
$query->setParseMode($parseMode);
}
}
catch (PluginNotFoundException $e) {
// Parse mode is not available, let Search API use the default.
}
// Set keys after the parse mode is set.
$query->keys($keys);
if ('not' === $fulltextOp) {
$tkeys = &$query->getKeys();
if (is_array($tkeys)) {
$tkeys['#negation'] = TRUE;
}
}
$results = [];
// Group multiple value with the same label together.
// @todo make this an option?
foreach ($query->execute()->getResultItems() as $item) {
$entity = $item->getOriginalObject();
if ($entity instanceof EntityAdapter) {
$entity = $entity->getEntity();
}
if ($entity instanceof ContentEntityInterface) {
$key = strtolower($entity->label());
$results[$key]['label'] = $entity->label();
$results[$key]['id'][] = $entity->id();
}
}
$suggestions = [];
foreach ($results as $info) {
$suggestions[] = [
'text' => $info['label'],
'value' => 'id:' . implode(',', $info['id']),
];
}
// Cache these results no longer than 15 minutes so we don't accumulate
// large numbers of cached queries that linger too long.
$cacheMeta
->setCacheMaxAge(900)
->addCacheableDependency($query->getIndex())
->addCacheableDependency($displayHandler->getCacheMetadata());
}
else {
// Rebuild response in case view is updated and the required display
// handler gets added or altered.
$cacheMeta->addCacheTags($view->getCacheTags());
}
$response = new CacheableJsonResponse(['list' => $suggestions]);
$response->addCacheableDependency($cacheMeta);
return $response;
}
catch (\InvalidArgumentException $e) {
throw new NotFoundHttpException($e->getMessage(), $e);
}
catch (SearchApiException $e) {
Error::logException($this->getLogger('toolshed_search'), $e);
throw new BadRequestHttpException('Unable to query required services.', $e);
}
}
/**
* Create a Search API query from the Views information.
*
* @param \Drupal\views\ViewEntityInterface $view
* The view configuration entity to build the search query from.
* @param array $filters
* Additional filter criteria to apply.
*
* @return \Drupal\search_api\Query\QueryInterface
* The query compatible with this Search API index from the View.
*
* @throws \Drupal\search_api\SearchApiException
* When a query instance can't be created for the view.
*/
protected function createQuery(ViewEntityInterface $view, array $filters = []): QueryInterface {
$baseTable = $view->get('base_table') ?: '';
if (preg_match('/^search_api_index_([a-zA-Z_]+)$/', $baseTable, $matches)) {
/** @var \Drupal\search_api\IndexInterface $index */
$index = $this->entityTypeManager
->getStorage('search_api_index')
->load($matches[1]);
return $index->query()
->addTag('entity_autocomplete')
->sort('search_api_relevance', QueryInterface::SORT_DESC);
}
$viewLabel = $view->label() ?: $view->id();
throw new SearchApiException('Unable to create search query for view: ' . $viewLabel);
}
/**
* Apply search filters to ensure results from specific entity bundles.
*
* @param \Drupal\search_api\Query\QueryInterface $query
* The query to add the bundle filters to.
* @param string $entity_type_id
* The target entity type ID that the bundle filter is for.
* @param array $bundles
* The bundle machine name IDs to filter to.
*
* @throws \Drupal\search_api\SearchApiException
* If an entity bundle filter field is not available.
*/
protected function applyBundleFilter(QueryInterface $query, string $entity_type_id, array $bundles): void {
if (empty($bundles)) {
return;
}
$index = $query->getIndex();
$datasourceId = 'entity:' . $entity_type_id;
$bundleKey = $this->entityTypeManager
->getDefinition($entity_type_id)
->getKey('bundle');
foreach ($index->getFields() as $field) {
if (NULL === $field->getDatasourceId() && 'entity_bundle' === $field->getPropertyPath()) {
// Callback function to prepend the entity type to the bundles.
// Makes entity bundle values compatible with this type of index field.
array_walk($bundles, function (&$bundle, $key, $prefix) {
$bundle = $prefix . ':' . $bundle;
}, $entity_type_id);
$query->addCondition($field->getFieldIdentifier(), $bundles, 'IN');
return;
}
elseif ($datasourceId === $field->getDatasourceId() && $bundleKey === $field->getPropertyPath()) {
$query->addCondition($field->getFieldIdentifier(), $bundles, 'IN');
return;
}
}
$this
->getLogger('toolshed_search.entity_autocomplete')
->warning('Unable to find entity bundle field to filter @entity_type in the @index index.', [
'@entity_type' => $entity_type_id,
'@index' => $index->label(),
]);
}
}
