meilisearch/Classes/Domain/Search/ResultSet/SearchResultSetService.php
2021-04-17 21:20:54 +02:00

500 lines
18 KiB
PHP

<?php
namespace WapplerSystems\Meilisearch\Domain\Search\ResultSet;
/***************************************************************
* Copyright notice
*
* (c) 2015-2016 Timo Schmidt <timo.schmidt@dkd.de>
* All rights reserved
*
* This script is part of the TYPO3 project. The TYPO3 project is
* free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 3 of the License, or
* (at your option) any later version.
*
* The GNU General Public License can be found at
* http://www.gnu.org/copyleft/gpl.html.
*
* This script is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* This copyright notice MUST APPEAR in all copies of the script!
***************************************************************/
use WapplerSystems\Meilisearch\Domain\Search\Query\ParameterBuilder\QueryFields;
use WapplerSystems\Meilisearch\Domain\Search\Query\QueryBuilder;
use WapplerSystems\Meilisearch\Domain\Search\Query\Query;
use WapplerSystems\Meilisearch\Domain\Search\Query\SearchQuery;
use WapplerSystems\Meilisearch\Domain\Search\ResultSet\Result\Parser\ResultParserRegistry;
use WapplerSystems\Meilisearch\Domain\Search\ResultSet\Result\SearchResult;
use WapplerSystems\Meilisearch\Domain\Search\ResultSet\Result\SearchResultCollection;
use WapplerSystems\Meilisearch\Domain\Search\SearchRequest;
use WapplerSystems\Meilisearch\Domain\Search\SearchRequestAware;
use WapplerSystems\Meilisearch\Domain\Variants\VariantsProcessor;
use WapplerSystems\Meilisearch\Query\Modifier\Modifier;
use WapplerSystems\Meilisearch\Search;
use WapplerSystems\Meilisearch\Search\QueryAware;
use WapplerSystems\Meilisearch\Search\SearchAware;
use WapplerSystems\Meilisearch\Search\SearchComponentManager;
use WapplerSystems\Meilisearch\System\Configuration\TypoScriptConfiguration;
use WapplerSystems\Meilisearch\System\Logging\MeilisearchLogManager;
use WapplerSystems\Meilisearch\System\Meilisearch\Document\Document;
use WapplerSystems\Meilisearch\System\Meilisearch\ResponseAdapter;
use WapplerSystems\Meilisearch\System\Meilisearch\MeilisearchIncompleteResponseException;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use WapplerSystems\Meilisearch\Domain\Search\ResultSet\Result\SearchResultBuilder;
use TYPO3\CMS\Extbase\Object\ObjectManager;
use TYPO3\CMS\Extbase\Object\ObjectManagerInterface;
/**
* The SearchResultSetService is responsible to build a SearchResultSet from a SearchRequest.
* It encapsulates the logic to trigger a search in order to be able to reuse it in multiple places.
*
* @author Timo Schmidt <timo.schmidt@dkd.de>
*/
class SearchResultSetService
{
/**
* Track, if the number of results per page has been changed by the current request
*
* @var bool
*/
protected $resultsPerPageChanged = false;
/**
* @var Search
*/
protected $search;
/**
* @var SearchResultSet
*/
protected $lastResultSet = null;
/**
* @var boolean
*/
protected $isMeilisearchAvailable = false;
/**
* @var TypoScriptConfiguration
*/
protected $typoScriptConfiguration;
/**
* @var MeilisearchLogManager
*/
protected $logger = null;
/**
* @var SearchResultBuilder
*/
protected $searchResultBuilder;
/**
* @var QueryBuilder
*/
protected $queryBuilder;
/**
* @var ObjectManagerInterface
*/
protected $objectManager;
/**
* @param TypoScriptConfiguration $configuration
* @param Search $search
* @param MeilisearchLogManager $meilisearchLogManager
* @param SearchResultBuilder $resultBuilder
* @param QueryBuilder $queryBuilder
*/
public function __construct(TypoScriptConfiguration $configuration, Search $search, MeilisearchLogManager $meilisearchLogManager = null, SearchResultBuilder $resultBuilder = null, QueryBuilder $queryBuilder = null)
{
$this->search = $search;
$this->typoScriptConfiguration = $configuration;
$this->logger = $meilisearchLogManager ?? GeneralUtility::makeInstance(MeilisearchLogManager::class, /** @scrutinizer ignore-type */ __CLASS__);
$this->searchResultBuilder = $resultBuilder ?? GeneralUtility::makeInstance(SearchResultBuilder::class);
$this->queryBuilder = $queryBuilder ?? GeneralUtility::makeInstance(QueryBuilder::class, /** @scrutinizer ignore-type */ $configuration, /** @scrutinizer ignore-type */ $meilisearchLogManager);
}
/**
* @param ObjectManagerInterface $objectManager
*/
public function injectObjectManager(ObjectManagerInterface $objectManager)
{
$this->objectManager = $objectManager;
}
/**
* @param bool $useCache
* @return bool
*/
public function getIsMeilisearchAvailable($useCache = true)
{
$this->isMeilisearchAvailable = $this->search->ping($useCache);
return $this->isMeilisearchAvailable;
}
/**
* Retrieves the used search instance.
*
* @return Search
*/
public function getSearch()
{
return $this->search;
}
/**
* @param Query $query
* @param SearchRequest $searchRequest
*/
protected function initializeRegisteredSearchComponents(Query $query, SearchRequest $searchRequest)
{
$searchComponents = $this->getRegisteredSearchComponents();
foreach ($searchComponents as $searchComponent) {
/** @var Search\SearchComponent $searchComponent */
$searchComponent->setSearchConfiguration($this->typoScriptConfiguration->getSearchConfiguration());
if ($searchComponent instanceof QueryAware) {
$searchComponent->setQuery($query);
}
if ($searchComponent instanceof SearchRequestAware) {
$searchComponent->setSearchRequest($searchRequest);
}
$searchComponent->initializeSearchComponent();
}
}
/**
* @return string
*/
protected function getResultSetClassName()
{
return isset($GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['meilisearch']['searchResultSetClassName ']) ?
$GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['meilisearch']['searchResultSetClassName '] : SearchResultSet::class;
}
/**
* Performs a search and returns a SearchResultSet.
*
* @param SearchRequest $searchRequest
* @return SearchResultSet
*/
public function search(SearchRequest $searchRequest)
{
$resultSet = $this->getInitializedSearchResultSet($searchRequest);
$this->lastResultSet = $resultSet;
$resultSet = $this->handleSearchHook('beforeSearch', $resultSet);
if ($this->shouldReturnEmptyResultSetWithoutExecutedSearch($searchRequest)) {
$resultSet->setHasSearched(false);
return $resultSet;
}
$query = $this->queryBuilder->buildSearchQuery($searchRequest->getRawUserQuery(), (int)$searchRequest->getResultsPerPage(), $searchRequest->getAdditionalFilters());
$this->initializeRegisteredSearchComponents($query, $searchRequest);
$resultSet->setUsedQuery($query);
// performing the actual search, sending the query to the Meilisearch server
$query = $this->modifyQuery($query, $searchRequest, $this->search);
$response = $this->doASearch($query, $searchRequest);
if ((int)$searchRequest->getResultsPerPage() === 0) {
// when resultPerPage was forced to 0 we also set the numFound to 0 to hide results, e.g.
// when results for the initial search should not be shown.
// @extensionScannerIgnoreLine
$response->response->numFound = 0;
}
$resultSet->setHasSearched(true);
$resultSet->setResponse($response);
$this->getParsedSearchResults($resultSet);
$resultSet->setUsedAdditionalFilters($this->queryBuilder->getAdditionalFilters());
/** @var $variantsProcessor VariantsProcessor */
$variantsProcessor = GeneralUtility::makeInstance(
VariantsProcessor::class,
/** @scrutinizer ignore-type */ $this->typoScriptConfiguration,
/** @scrutinizer ignore-type */ $this->searchResultBuilder
);
$variantsProcessor->process($resultSet);
/** @var $searchResultReconstitutionProcessor ResultSetReconstitutionProcessor */
$searchResultReconstitutionProcessor = GeneralUtility::makeInstance(ResultSetReconstitutionProcessor::class);
$searchResultReconstitutionProcessor->process($resultSet);
$resultSet = $this->getAutoCorrection($resultSet);
return $this->handleSearchHook('afterSearch', $resultSet);
}
/**
* Uses the configured parser and retrieves the parsed search resutls.
*
* @param SearchResultSet $resultSet
*/
protected function getParsedSearchResults($resultSet)
{
/** @var ResultParserRegistry $parserRegistry */
$parserRegistry = GeneralUtility::makeInstance(ResultParserRegistry::class, /** @scrutinizer ignore-type */ $this->typoScriptConfiguration);
$useRawDocuments = (bool)$this->typoScriptConfiguration->getValueByPathOrDefaultValue('plugin.tx_meilisearch.features.useRawDocuments', false);
$parserRegistry->getParser($resultSet)->parse($resultSet, $useRawDocuments);
}
/**
* Evaluates conditions on the request and configuration and returns true if no search should be triggered and an empty
* SearchResultSet should be returned.
*
* @param SearchRequest $searchRequest
* @return bool
*/
protected function shouldReturnEmptyResultSetWithoutExecutedSearch(SearchRequest $searchRequest)
{
if ($searchRequest->getRawUserQueryIsNull() && !$this->getInitialSearchIsConfigured()) {
// when no rawQuery was passed or no initialSearch is configured, we pass an empty result set
return true;
}
if ($searchRequest->getRawUserQueryIsEmptyString() && !$this->typoScriptConfiguration->getSearchQueryAllowEmptyQuery()) {
// the user entered an empty query string "" or " " and empty querystring is not allowed
return true;
}
return false;
}
/**
* Initializes the SearchResultSet from the SearchRequest
*
* @param SearchRequest $searchRequest
* @return SearchResultSet
*/
protected function getInitializedSearchResultSet(SearchRequest $searchRequest):SearchResultSet
{
/** @var $resultSet SearchResultSet */
$resultSetClass = $this->getResultSetClassName();
$resultSet = $this->objectManager->get($resultSetClass);
$resultSet->setUsedSearchRequest($searchRequest);
$resultSet->setUsedPage((int)$searchRequest->getPage());
$resultSet->setUsedResultsPerPage((int)$searchRequest->getResultsPerPage());
$resultSet->setUsedSearch($this->search);
return $resultSet;
}
/**
* Executes the search and builds a fake response for a current bug in Meilisearch 6.3
*
* @param Query $query
* @param SearchRequest $searchRequest
* @return ResponseAdapter
*/
protected function doASearch($query, $searchRequest): ResponseAdapter
{
// the offset mulitplier is page - 1 but not less then zero
$offsetMultiplier = max(0, $searchRequest->getPage() - 1);
$offSet = $offsetMultiplier * (int)$searchRequest->getResultsPerPage();
$response = $this->search->search($query, $offSet, null);
if($response === null) {
throw new MeilisearchIncompleteResponseException('The response retrieved from meilisearch was incomplete', 1505989678);
}
return $response;
}
/**
* @param SearchResultSet $searchResultSet
* @return SearchResultSet
*/
protected function getAutoCorrection(SearchResultSet $searchResultSet)
{
// no secondary search configured
if (!$this->typoScriptConfiguration->getSearchSpellcheckingSearchUsingSpellCheckerSuggestion()) {
return $searchResultSet;
}
// more then zero results
if ($searchResultSet->getAllResultCount() > 0) {
return $searchResultSet;
}
// no corrections present
if (!$searchResultSet->getHasSpellCheckingSuggestions()) {
return $searchResultSet;
}
$searchResultSet = $this->peformAutoCorrection($searchResultSet);
return $searchResultSet;
}
/**
* @param SearchResultSet $searchResultSet
* @return SearchResultSet
*/
protected function peformAutoCorrection(SearchResultSet $searchResultSet)
{
$searchRequest = $searchResultSet->getUsedSearchRequest();
$suggestions = $searchResultSet->getSpellCheckingSuggestions();
$maximumRuns = $this->typoScriptConfiguration->getSearchSpellcheckingNumberOfSuggestionsToTry(1);
$runs = 0;
foreach ($suggestions as $suggestion) {
$runs++;
$correction = $suggestion->getSuggestion();
$initialQuery = $searchRequest->getRawUserQuery();
$searchRequest->setRawQueryString($correction);
$searchResultSet = $this->search($searchRequest);
if ($searchResultSet->getAllResultCount() > 0) {
$searchResultSet->setIsAutoCorrected(true);
$searchResultSet->setCorrectedQueryString($correction);
$searchResultSet->setInitialQueryString($initialQuery);
break;
}
if ($runs > $maximumRuns) {
break;
}
}
return $searchResultSet;
}
/**
* Allows to modify a query before eventually handing it over to Meilisearch.
*
* @param Query $query The current query before it's being handed over to Meilisearch.
* @param SearchRequest $searchRequest The searchRequest, relevant in the current context
* @param Search $search The search, relevant in the current context
* @throws \UnexpectedValueException
* @return Query The modified query that is actually going to be given to Meilisearch.
*/
protected function modifyQuery(Query $query, SearchRequest $searchRequest, Search $search)
{
// hook to modify the search query
if (is_array($GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['meilisearch']['modifySearchQuery'])) {
foreach ($GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['meilisearch']['modifySearchQuery'] as $classReference) {
$queryModifier = $this->objectManager->get($classReference);
if ($queryModifier instanceof Modifier) {
if ($queryModifier instanceof SearchAware) {
$queryModifier->setSearch($search);
}
if ($queryModifier instanceof SearchRequestAware) {
$queryModifier->setSearchRequest($searchRequest);
}
$query = $queryModifier->modifyQuery($query);
} else {
throw new \UnexpectedValueException(
get_class($queryModifier) . ' must implement interface ' . Modifier::class,
1310387414
);
}
}
}
return $query;
}
/**
* Retrieves a single document from meilisearch by document id.
*
* @param string $documentId
* @return SearchResult
*/
public function getDocumentById($documentId)
{
/* @var $query SearchQuery */
$query = $this->queryBuilder->newSearchQuery($documentId)->useQueryFields(QueryFields::fromString('id'))->getQuery();
$response = $this->search->search($query, 0, 1);
$parsedData = $response->getParsedData();
// @extensionScannerIgnoreLine
$resultDocument = isset($parsedData->response->docs[0]) ? $parsedData->response->docs[0] : null;
if (!$resultDocument instanceof Document) {
throw new \UnexpectedValueException("Response did not contain a valid Document object");
}
return $this->searchResultBuilder->fromApacheMeilisearchDocument($resultDocument);
}
/**
* This method is used to call the registered hooks during the search execution.
*
* @param string $eventName
* @param SearchResultSet $resultSet
* @return SearchResultSet
*/
private function handleSearchHook($eventName, SearchResultSet $resultSet)
{
if (!is_array($GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['meilisearch'][$eventName])) {
return $resultSet;
}
foreach ($GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['meilisearch'][$eventName] as $classReference) {
$afterSearchProcessor = $this->objectManager->get($classReference);
if ($afterSearchProcessor instanceof SearchResultSetProcessor) {
$afterSearchProcessor->process($resultSet);
}
}
return $resultSet;
}
/**
* @return SearchResultSet
*/
public function getLastResultSet()
{
return $this->lastResultSet;
}
/**
* This method returns true when the last search was executed with an empty query
* string or whitespaces only. When no search was triggered it will return false.
*
* @return bool
*/
public function getLastSearchWasExecutedWithEmptyQueryString()
{
$wasEmptyQueryString = false;
if ($this->lastResultSet != null) {
$wasEmptyQueryString = $this->lastResultSet->getUsedSearchRequest()->getRawUserQueryIsEmptyString();
}
return $wasEmptyQueryString;
}
/**
* @return bool
*/
protected function getInitialSearchIsConfigured()
{
return $this->typoScriptConfiguration->getSearchInitializeWithEmptyQuery() || $this->typoScriptConfiguration->getSearchShowResultsOfInitialEmptyQuery() || $this->typoScriptConfiguration->getSearchInitializeWithQuery() || $this->typoScriptConfiguration->getSearchShowResultsOfInitialQuery();
}
/**
* @return mixed
*/
protected function getRegisteredSearchComponents()
{
return GeneralUtility::makeInstance(SearchComponentManager::class)->getSearchComponents();
}
}