500 lines
18 KiB
PHP
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();
|
|
}
|
|
}
|