* 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\BigramPhraseFields; use WapplerSystems\Meilisearch\Domain\Search\Query\ParameterBuilder\Elevation; use WapplerSystems\Meilisearch\Domain\Search\Query\ParameterBuilder\Faceting; use WapplerSystems\Meilisearch\Domain\Search\Query\ParameterBuilder\FieldCollapsing; use WapplerSystems\Meilisearch\Domain\Search\Query\ParameterBuilder\Filters; use WapplerSystems\Meilisearch\Domain\Search\Query\ParameterBuilder\Grouping; use WapplerSystems\Meilisearch\Domain\Search\Query\ParameterBuilder\Highlighting; use WapplerSystems\Meilisearch\Domain\Search\Query\ParameterBuilder\PhraseFields; use WapplerSystems\Meilisearch\Domain\Search\Query\ParameterBuilder\QueryFields; use WapplerSystems\Meilisearch\Domain\Search\Query\ParameterBuilder\ReturnFields; use WapplerSystems\Meilisearch\Domain\Search\Query\ParameterBuilder\Slops; use WapplerSystems\Meilisearch\Domain\Search\Query\ParameterBuilder\Sorting; use WapplerSystems\Meilisearch\Domain\Search\Query\ParameterBuilder\Sortings; use WapplerSystems\Meilisearch\Domain\Search\Query\ParameterBuilder\Spellchecking; use WapplerSystems\Meilisearch\Domain\Search\Query\ParameterBuilder\TrigramPhraseFields; use WapplerSystems\Meilisearch\Domain\Site\SiteHashService; use WapplerSystems\Meilisearch\Domain\Site\SiteRepository; use WapplerSystems\Meilisearch\FieldProcessor\PageUidToHierarchy; use WapplerSystems\Meilisearch\System\Configuration\TypoScriptConfiguration; use WapplerSystems\Meilisearch\System\Logging\MeilisearchLogManager; use WapplerSystems\Meilisearch\Util; use TYPO3\CMS\Core\Utility\GeneralUtility; use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer; /** * The concrete QueryBuilder contains all TYPO3 specific initialization logic of meilisearch queries, for TYPO3. */ class QueryBuilder extends AbstractQueryBuilder { /** * Additional filters, which will be added to the query, as well as to * suggest queries. * * @var array */ protected $additionalFilters = []; /** * @var TypoScriptConfiguration */ protected $typoScriptConfiguration = null; /** * @var MeilisearchLogManager; */ protected $logger = null; /** * @var SiteHashService */ protected $siteHashService = null; /** * QueryBuilder constructor. * @param TypoScriptConfiguration|null $configuration * @param MeilisearchLogManager|null $meilisearchLogManager * @param SiteHashService|null $siteHashService */ public function __construct(TypoScriptConfiguration $configuration = null, MeilisearchLogManager $meilisearchLogManager = null, SiteHashService $siteHashService = null) { $this->typoScriptConfiguration = $configuration ?? Util::getMeilisearchConfiguration(); $this->logger = $meilisearchLogManager ?? GeneralUtility::makeInstance(MeilisearchLogManager::class, /** @scrutinizer ignore-type */ __CLASS__); $this->siteHashService = $siteHashService ?? GeneralUtility::makeInstance(SiteHashService::class); } /** * @param string $queryString * @return QueryBuilder */ public function newSearchQuery($queryString): QueryBuilder { $this->queryToBuild = $this->getSearchQueryInstance((string)$queryString); return $this; } /** * @param string $queryString * @return QueryBuilder */ public function newSuggestQuery($queryString): QueryBuilder { $this->queryToBuild = $this->getSuggestQueryInstance($queryString); return $this; } /** * Initializes the Query object and SearchComponents and returns * the initialized query object, when a search should be executed. * * @param string|null $rawQuery * @param int $resultsPerPage * @param array $additionalFiltersFromRequest * @return SearchQuery */ public function buildSearchQuery($rawQuery, $resultsPerPage = 10, array $additionalFiltersFromRequest = []) : SearchQuery { if ($this->typoScriptConfiguration->getLoggingQuerySearchWords()) { $this->logger->log(MeilisearchLogManager::INFO, 'Received search query', [$rawQuery]); } /* @var $query SearchQuery */ return $this->newSearchQuery($rawQuery) ->useResultsPerPage($resultsPerPage) ->useReturnFieldsFromTypoScript() ->useQueryFieldsFromTypoScript() ->useInitialQueryFromTypoScript() ->useFiltersFromTypoScript() ->useFilterArray($additionalFiltersFromRequest) ->useFacetingFromTypoScript() ->useVariantsFromTypoScript() ->useGroupingFromTypoScript() ->useHighlightingFromTypoScript() ->usePhraseFieldsFromTypoScript() ->useBigramPhraseFieldsFromTypoScript() ->useTrigramPhraseFieldsFromTypoScript() ->useOmitHeader(false) ->getQuery(); } /** * Builds a SuggestQuery with all applied filters. * * @param string $queryString * @param array $additionalFilters * @param integer $requestedPageId * @param string $groupList * @return SuggestQuery */ public function buildSuggestQuery(string $queryString, array $additionalFilters, int $requestedPageId, string $groupList) : SuggestQuery { $this->newSuggestQuery($queryString) ->useFiltersFromTypoScript() ->useSiteHashFromTypoScript($requestedPageId) ->useUserAccessGroups(explode(',', $groupList)) ->useOmitHeader(); if (!empty($additionalFilters)) { $this->useFilterArray($additionalFilters); } return $this->queryToBuild; } /** * Returns Query for Search which finds document for given page. * Note: The Connection is per language as recommended in ext-meilisearch docs. * * @return Query */ public function buildPageQuery($pageId) { $siteRepository = GeneralUtility::makeInstance(SiteRepository::class); $site = $siteRepository->getSiteByPageId($pageId); return $this->newSearchQuery('') ->useQueryString('*:*') ->useFilter('(type:pages AND uid:' . $pageId . ') OR (*:* AND pid:' . $pageId . ' NOT type:pages)', 'type') ->useFilter('siteHash:' . $site->getSiteHash(), 'siteHash') ->useReturnFields(ReturnFields::fromString('*')) ->useSortings(Sortings::fromString('type asc, title asc')) ->useQueryType('standard') ->getQuery(); } /** * Returns a query for single record * * @return Query */ public function buildRecordQuery($type, $uid, $pageId): Query { $siteRepository = GeneralUtility::makeInstance(SiteRepository::class); $site = $siteRepository->getSiteByPageId($pageId); return $this->newSearchQuery('') ->useQueryString('*:*') ->useFilter('type:' . $type . ' AND uid:' . $uid, 'type') ->useFilter('siteHash:' . $site->getSiteHash(), 'siteHash') ->useReturnFields(ReturnFields::fromString('*')) ->useSortings(Sortings::fromString('type asc, title asc')) ->useQueryType('standard') ->getQuery(); } /** * @return QueryBuilder */ public function useSlopsFromTypoScript(): QueryBuilder { return $this->useSlops(Slops::fromTypoScriptConfiguration($this->typoScriptConfiguration)); } /** * Uses the configured boost queries from typoscript * * @return QueryBuilder */ public function useBoostQueriesFromTypoScript(): QueryBuilder { $searchConfiguration = $this->typoScriptConfiguration->getSearchConfiguration(); if (!empty($searchConfiguration['query.']['boostQuery'])) { return $this->useBoostQueries($searchConfiguration['query.']['boostQuery']); } if (!empty($searchConfiguration['query.']['boostQuery.'])) { $boostQueries = $searchConfiguration['query.']['boostQuery.']; return $this->useBoostQueries(array_values($boostQueries)); } return $this; } /** * Uses the configured boostFunction from the typoscript configuration. * * @return QueryBuilder */ public function useBoostFunctionFromTypoScript(): QueryBuilder { $searchConfiguration = $this->typoScriptConfiguration->getSearchConfiguration(); if (!empty($searchConfiguration['query.']['boostFunction'])) { return $this->useBoostFunction($searchConfiguration['query.']['boostFunction']); } return $this; } /** * Uses the configured minimumMatch from the typoscript configuration. * * @return QueryBuilder */ public function useMinimumMatchFromTypoScript(): QueryBuilder { $searchConfiguration = $this->typoScriptConfiguration->getSearchConfiguration(); if (!empty($searchConfiguration['query.']['minimumMatch'])) { return $this->useMinimumMatch($searchConfiguration['query.']['minimumMatch']); } return $this; } /** * @return QueryBuilder */ public function useTieParameterFromTypoScript(): QueryBuilder { $searchConfiguration = $this->typoScriptConfiguration->getSearchConfiguration(); if (empty($searchConfiguration['query.']['tieParameter'])) { return $this; } return $this->useTieParameter($searchConfiguration['query.']['tieParameter']); } /** * Applies the configured query fields from the typoscript configuration. * * @return QueryBuilder */ public function useQueryFieldsFromTypoScript(): QueryBuilder { return $this->useQueryFields(QueryFields::fromString($this->typoScriptConfiguration->getSearchQueryQueryFields())); } /** * Applies the configured return fields from the typoscript configuration. * * @return QueryBuilder */ public function useReturnFieldsFromTypoScript(): QueryBuilder { $returnFieldsArray = (array)$this->typoScriptConfiguration->getSearchQueryReturnFieldsAsArray(['*', 'score']); return $this->useReturnFields(ReturnFields::fromArray($returnFieldsArray)); } /** * Can be used to apply the allowed sites from plugin.tx_meilisearch.search.query.allowedSites to the query. * * @param int $requestedPageId * @return QueryBuilder */ public function useSiteHashFromTypoScript(int $requestedPageId): QueryBuilder { $queryConfiguration = $this->typoScriptConfiguration->getObjectByPathOrDefault('plugin.tx_meilisearch.search.query.', []); $allowedSites = $this->siteHashService->getAllowedSitesForPageIdAndAllowedSitesConfiguration($requestedPageId, $queryConfiguration['allowedSites']); return $this->useSiteHashFromAllowedSites($allowedSites); } /** * Can be used to apply a list of allowed sites to the query. * * @param string $allowedSites * @return QueryBuilder */ public function useSiteHashFromAllowedSites($allowedSites): QueryBuilder { $isAnySiteAllowed = trim($allowedSites) === '*'; if ($isAnySiteAllowed) { // no filter required return $this; } $allowedSites = GeneralUtility::trimExplode(',', $allowedSites); $filters = []; foreach ($allowedSites as $site) { $siteHash = $this->siteHashService->getSiteHashForDomain($site); $filters[] = 'siteHash:"' . $siteHash . '"'; } $siteHashFilterString = implode(' OR ', $filters); return $this->useFilter($siteHashFilterString, 'siteHash'); } /** * Can be used to filter the result on an applied list of user groups. * * @param array $groups * @return QueryBuilder */ public function useUserAccessGroups(array $groups): QueryBuilder { $groups = array_map('intval', $groups); $groups[] = 0; // always grant access to public documents $groups = array_unique($groups); sort($groups, SORT_NUMERIC); $accessFilter = '{!typo3access}' . implode(',', $groups); $this->queryToBuild->removeFilterQuery('access'); return $this->useFilter($accessFilter, 'access'); } /** * Applies the configured initial query settings to set the alternative query for meilisearch as required. * * @return QueryBuilder */ public function useInitialQueryFromTypoScript(): QueryBuilder { if ($this->typoScriptConfiguration->getSearchInitializeWithEmptyQuery() || $this->typoScriptConfiguration->getSearchQueryAllowEmptyQuery()) { // empty main query, but using a "return everything" // alternative query in q.alt $this->useAlternativeQuery('*:*'); } if ($this->typoScriptConfiguration->getSearchInitializeWithQuery()) { $this->useAlternativeQuery($this->typoScriptConfiguration->getSearchInitializeWithQuery()); } return $this; } /** * Applies the configured facets from the typoscript configuration on the query. * * @return QueryBuilder */ public function useFacetingFromTypoScript(): QueryBuilder { return $this->useFaceting(Faceting::fromTypoScriptConfiguration($this->typoScriptConfiguration)); } /** * Applies the configured variants from the typoscript configuration on the query. * * @return QueryBuilder */ public function useVariantsFromTypoScript(): QueryBuilder { return $this->useFieldCollapsing(FieldCollapsing::fromTypoScriptConfiguration($this->typoScriptConfiguration)); } /** * Applies the configured groupings from the typoscript configuration to the query. * * @return QueryBuilder */ public function useGroupingFromTypoScript(): QueryBuilder { return $this->useGrouping(Grouping::fromTypoScriptConfiguration($this->typoScriptConfiguration)); } /** * Applies the configured highlighting from the typoscript configuration to the query. * * @return QueryBuilder */ public function useHighlightingFromTypoScript(): QueryBuilder { return $this->useHighlighting(Highlighting::fromTypoScriptConfiguration($this->typoScriptConfiguration)); } /** * Applies the configured filters (page section and other from typoscript). * * @return QueryBuilder */ public function useFiltersFromTypoScript(): QueryBuilder { $filters = Filters::fromTypoScriptConfiguration($this->typoScriptConfiguration); $this->queryToBuild->setFilterQueries($filters->getValues()); $this->useFilterArray($this->getAdditionalFilters()); $searchQueryFilters = $this->typoScriptConfiguration->getSearchQueryFilterConfiguration(); if (!is_array($searchQueryFilters) || count($searchQueryFilters) <= 0) { return $this; } // special filter to limit search to specific page tree branches if (array_key_exists('__pageSections', $searchQueryFilters)) { $pageIds = GeneralUtility::trimExplode(',', $searchQueryFilters['__pageSections']); $this->usePageSectionsFromPageIds($pageIds); $this->typoScriptConfiguration->removeSearchQueryFilterForPageSections(); } return $this; } /** * Applies the configured elevation from the typoscript configuration. * * @return QueryBuilder */ public function useElevationFromTypoScript(): QueryBuilder { return $this->useElevation(Elevation::fromTypoScriptConfiguration($this->typoScriptConfiguration)); } /** * Applies the configured spellchecking from the typoscript configuration. * * @return QueryBuilder */ public function useSpellcheckingFromTypoScript(): QueryBuilder { return $this->useSpellchecking(Spellchecking::fromTypoScriptConfiguration($this->typoScriptConfiguration)); } /** * Applies the passed pageIds as __pageSection filter. * * @param array $pageIds * @return QueryBuilder */ public function usePageSectionsFromPageIds(array $pageIds = []): QueryBuilder { $filters = []; /** @var $processor PageUidToHierarchy */ $processor = GeneralUtility::makeInstance(PageUidToHierarchy::class); $hierarchies = $processor->process($pageIds); foreach ($hierarchies as $hierarchy) { $lastLevel = array_pop($hierarchy); $filters[] = 'rootline:"' . $lastLevel . '"'; } $pageSectionsFilterString = implode(' OR ', $filters); return $this->useFilter($pageSectionsFilterString, 'pageSections'); } /** * Applies the configured phrase fields from the typoscript configuration to the query. * * @return QueryBuilder */ public function usePhraseFieldsFromTypoScript(): QueryBuilder { return $this->usePhraseFields(PhraseFields::fromTypoScriptConfiguration($this->typoScriptConfiguration)); } /** * Applies the configured bigram phrase fields from the typoscript configuration to the query. * * @return QueryBuilder */ public function useBigramPhraseFieldsFromTypoScript(): QueryBuilder { return $this->useBigramPhraseFields(BigramPhraseFields::fromTypoScriptConfiguration($this->typoScriptConfiguration)); } /** * Applies the configured trigram phrase fields from the typoscript configuration to the query. * * @return QueryBuilder */ public function useTrigramPhraseFieldsFromTypoScript(): QueryBuilder { return $this->useTrigramPhraseFields(TrigramPhraseFields::fromTypoScriptConfiguration($this->typoScriptConfiguration)); } /** * Retrieves the configuration filters from the TypoScript configuration, except the __pageSections filter. * * @return array */ public function getAdditionalFilters() : array { // when we've build the additionalFilter once, we could return them if (count($this->additionalFilters) > 0) { return $this->additionalFilters; } $searchQueryFilters = $this->typoScriptConfiguration->getSearchQueryFilterConfiguration(); if (!is_array($searchQueryFilters) || count($searchQueryFilters) <= 0) { return []; } $cObj = GeneralUtility::makeInstance(ContentObjectRenderer::class); // all other regular filters foreach ($searchQueryFilters as $filterKey => $filter) { // the __pageSections filter should not be handled as additional filter if ($filterKey === '__pageSections') { continue; } $filterIsArray = is_array($searchQueryFilters[$filterKey]); if ($filterIsArray) { continue; } $hasSubConfiguration = is_array($searchQueryFilters[$filterKey . '.']); if ($hasSubConfiguration) { $filter = $cObj->stdWrap($searchQueryFilters[$filterKey], $searchQueryFilters[$filterKey . '.']); } $this->additionalFilters[$filterKey] = $filter; } return $this->additionalFilters; } /** * @param string $rawQuery * @return SearchQuery */ protected function getSearchQueryInstance(string $rawQuery): SearchQuery { $query = GeneralUtility::makeInstance(SearchQuery::class); $query->setQuery($rawQuery); return $query; } /** * @param string $rawQuery * @return SuggestQuery */ protected function getSuggestQueryInstance($rawQuery): SuggestQuery { $query = GeneralUtility::makeInstance(SuggestQuery::class, /** @scrutinizer ignore-type */ $rawQuery, /** @scrutinizer ignore-type */ $this->typoScriptConfiguration); return $query; } }