Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.20% covered (success)
97.20%
139 / 143
91.30% covered (success)
91.30%
21 / 23
CRAP
0.00% covered (danger)
0.00%
0 / 1
SearchQueryBuilder
97.20% covered (success)
97.20%
139 / 143
91.30% covered (success)
91.30%
21 / 23
30
0.00% covered (danger)
0.00%
0 / 1
 newFTSearchQueryBuilder
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
1
 forCrossProjectSearch
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 copyQueryForCrossSearch
100.00% covered (success)
100.00%
30 / 30
100.00% covered (success)
100.00%
1 / 1
6
 forCrossLanguageSearch
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 forRewrittenQuery
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
1
 build
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
3
 addContextualFilter
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 setInitialNamespaces
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setCrossProjectSearch
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setCrossLanguageSearch
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setSearchEngineEntryPoint
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 setSort
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setOffset
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setLimit
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setRandomSeed
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setDebugOptions
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setWithDYMSuggestion
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setExtraIndicesSearch
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 addForcedProfile
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setAllowRewrite
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 addProfileContextParameter
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setExtraFieldsToExtract
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setProvideAllSnippets
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace CirrusSearch\Search;
4
5use CirrusSearch\CirrusDebugOptions;
6use CirrusSearch\CirrusSearchHookRunner;
7use CirrusSearch\CrossSearchStrategy;
8use CirrusSearch\HashSearchConfig;
9use CirrusSearch\Parser\AST\ParsedQuery;
10use CirrusSearch\Parser\NamespacePrefixParser;
11use CirrusSearch\Parser\QueryParserFactory;
12use CirrusSearch\Query\Builder\ContextualFilter;
13use CirrusSearch\SearchConfig;
14use MediaWiki\MainConfigNames;
15use Wikimedia\Assert\Assert;
16
17/**
18 * Builder for SearchQuery
19 */
20final class SearchQueryBuilder {
21
22    /**
23     * @var ParsedQuery
24     */
25    private $parsedQuery;
26
27    /**
28     * @var int[]|null
29     */
30    private $initialNamespaces;
31
32    /**
33     * @var bool
34     */
35    private $crossProjectSearch = false;
36
37    /**
38     * @var bool
39     */
40    private $crossLanguageSearch = false;
41
42    /**
43     * @var bool
44     */
45    private $extraIndicesSearch = false;
46
47    /**
48     * @var ContextualFilter[]
49     */
50    private $contextualFilters = [];
51
52    /**
53     * @var string
54     */
55    private $searchEngineEntryPoint;
56
57    /**
58     * @var string
59     */
60    private $sort;
61
62    /**
63     * @var int|null
64     */
65    private $randomSeed;
66
67    /**
68     * @var string[]
69     */
70    private $forcedProfiles = [];
71
72    /**
73     * @var int
74     */
75    private $offset;
76
77    /**
78     * @var int
79     */
80    private $limit;
81
82    /**
83     * @var CirrusDebugOptions
84     */
85    private $debugOptions;
86
87    /**
88     * @var SearchConfig
89     */
90    private $searchConfig;
91
92    /**
93     * @var bool
94     */
95    private $withDYMSuggestion = false;
96
97    /**
98     * @var bool
99     */
100    private $allowRewrite = false;
101
102    /**
103     * @var string[] parameters for the SearchProfileService
104     * @see \CirrusSearch\Profile\ContextualProfileOverride
105     */
106    private $profileContextParameters = [];
107
108    /**
109     * @var string[] list of extra fields to extract
110     */
111    private $extraFieldsToExtract = [];
112
113    /**
114     * @var bool When false the engine will decide the best fields to highlight, when
115     *  true all fields will be highlighted (with increased computational cost).
116     */
117    private $provideAllSnippets = false;
118
119    /**
120     * Construct a new FT (FullText) SearchQueryBuilder using the config
121     * and query string provided.
122     *
123     * NOTE: this method will parse the query string and set all builder attributes
124     * to Fulltext search defaults.
125     *
126     * @param SearchConfig $config
127     * @param string $queryString
128     * @param NamespacePrefixParser $namespacePrefixParser
129     * @param CirrusSearchHookRunner $cirrusSearchHookRunner
130     * @return SearchQueryBuilder
131     * @throws \CirrusSearch\Parser\ParsedQueryClassifierException
132     * @throws \CirrusSearch\Parser\QueryStringRegex\SearchQueryParseException
133     */
134    public static function newFTSearchQueryBuilder(
135        SearchConfig $config,
136        $queryString,
137        NamespacePrefixParser $namespacePrefixParser,
138        CirrusSearchHookRunner $cirrusSearchHookRunner
139    ): SearchQueryBuilder {
140        $builder = new self();
141        $builder->parsedQuery = QueryParserFactory::newFullTextQueryParser( $config,
142            $namespacePrefixParser, $cirrusSearchHookRunner )->parse( $queryString );
143        $builder->initialNamespaces = [ NS_MAIN ];
144        $builder->sort = \SearchEngine::DEFAULT_SORT;
145        $builder->randomSeed = null;
146        $builder->debugOptions = CirrusDebugOptions::defaultOptions();
147        $builder->limit = 10;
148        $builder->offset = 0;
149        $builder->searchConfig = $config;
150        $builder->forcedProfiles = [];
151        $builder->searchEngineEntryPoint = SearchQuery::SEARCH_TEXT;
152        $builder->crossProjectSearch = true;
153        $builder->crossLanguageSearch = true;
154        $builder->extraIndicesSearch = true;
155        $builder->withDYMSuggestion = true;
156        $builder->allowRewrite = false;
157        $builder->provideAllSnippets = false;
158        return $builder;
159    }
160
161    /**
162     * Recreate a SearchQueryBuilder using an existing query and the target wiki SearchConfig.
163     *
164     * @param SearchConfig $config
165     * @param SearchQuery $query
166     * @return SearchQueryBuilder
167     */
168    public static function forCrossProjectSearch( SearchConfig $config, SearchQuery $query ): SearchQueryBuilder {
169        Assert::parameter( !$config->isLocalWiki(), '$config', 'must not be the local wiki config' );
170        Assert::precondition( $query->getCrossSearchStrategy()->isCrossProjectSearchSupported(),
171            'Trying to build a query for a cross-project search but the original query does not ' .
172            'support such searches.' );
173
174        $builder = self::copyQueryForCrossSearch( $config, $query );
175        $builder->offset = 0;
176        $builder->limit = $query->getSearchConfig()->get( 'CirrusSearchNumCrossProjectSearchResults' );
177        return $builder;
178    }
179
180    /**
181     * @param SearchConfig $config
182     * @param SearchQuery $original
183     * @return SearchQueryBuilder
184     */
185    private static function copyQueryForCrossSearch( SearchConfig $config, SearchQuery $original ): SearchQueryBuilder {
186        Assert::precondition( $original->getContextualFilters() === [], 'The initial must not have contextual filters' );
187        $builder = new self();
188        $builder->parsedQuery = $original->getParsedQuery();
189        $builder->searchEngineEntryPoint = $original->getSearchEngineEntryPoint();
190
191        if ( $original->isUsingDefaultSearchedNamespaces() && $config->has( MainConfigNames::NamespacesToBeSearchedDefault ) ) {
192            // If we search for default namespaces we assume the user wants to search for default namespaces
193            // on the cross wiki.
194            $namespaces = array_map( static fn ( $n ) => intval( $n ),
195                array_keys( $config->get( MainConfigNames::NamespacesToBeSearchedDefault ), true ) );
196        } else {
197            // For the rest only allow core namespaces. We can't be sure any others exist
198            $namespaces = $original->getInitialNamespaces();
199            if ( $namespaces !== null ) {
200                $namespaces = array_filter( $namespaces, static function ( $namespace ) {
201                    return $namespace <= NS_CATEGORY_TALK;
202                } );
203            }
204        }
205
206        $builder->initialNamespaces = $namespaces;
207        $builder->sort = $original->getSort();
208        $builder->randomSeed = $original->getRandomSeed();
209        $builder->debugOptions = $original->getDebugOptions();
210        $builder->searchConfig = $config;
211        $builder->profileContextParameters = $original->getProfileContextParameters();
212
213        $forcedProfiles = [];
214
215        // Copy forced profiles only if they exist on the target wiki.
216        foreach ( $original->getForcedProfiles() as $type => $name ) {
217            if ( $config->getProfileService()->hasProfile( $type, $name ) ) {
218                $forcedProfiles[$type] = $name;
219            }
220        }
221        // we do not copy extraFieldsToExtract as we have no way to know if they are available on a
222        // target wiki
223        $builder->extraFieldsToExtract = [];
224
225        $builder->forcedProfiles = $forcedProfiles;
226        // We force to false, during cross project/lang searches
227        // and we explicitely disable DYM suggestions
228        $builder->crossProjectSearch = false;
229        $builder->crossLanguageSearch = false;
230        $builder->extraIndicesSearch = false;
231        $builder->withDYMSuggestion = false;
232        $builder->allowRewrite = false;
233        return $builder;
234    }
235
236    /**
237     * @param SearchConfig $config
238     * @param SearchQuery $original
239     * @return SearchQueryBuilder
240     */
241    public static function forCrossLanguageSearch( SearchConfig $config, SearchQuery $original ) {
242        Assert::parameter( !$config->isLocalWiki(), '$config', 'must not be the local wiki config' );
243        Assert::precondition( $original->getCrossSearchStrategy()->isCrossLanguageSearchSupported(),
244            'Trying to build a query for a cross-language search but the original query does not ' .
245            'support such searches.' );
246
247        $builder = self::copyQueryForCrossSearch( $config, $original );
248        $builder->offset = $original->getOffset();
249        $builder->limit = $original->getLimit();
250        return $builder;
251    }
252
253    /**
254     * @param SearchQuery $original
255     * @param string $term
256     * @param NamespacePrefixParser $namespacePrefixParser
257     * @param CirrusSearchHookRunner $cirrusSearchHookRunner
258     * @return SearchQueryBuilder
259     * @throws \CirrusSearch\Parser\QueryStringRegex\SearchQueryParseException
260     */
261    public static function forRewrittenQuery(
262        SearchQuery $original,
263        $term,
264        NamespacePrefixParser $namespacePrefixParser,
265        CirrusSearchHookRunner $cirrusSearchHookRunner
266    ): SearchQueryBuilder {
267        Assert::precondition( $original->isAllowRewrite(), 'The original query must allow rewrites' );
268        // Hack to prevent a second pass on this cleaning algo because its destructive
269        $config = new HashSearchConfig( [ 'CirrusSearchStripQuestionMarks' => 'no' ],
270            [ HashSearchConfig::FLAG_INHERIT ], $original->getSearchConfig() );
271
272        $builder = self::newFTSearchQueryBuilder( $config, $term, $namespacePrefixParser, $cirrusSearchHookRunner );
273        $builder->contextualFilters = $original->getContextualFilters();
274        $builder->forcedProfiles = $original->getForcedProfiles();
275        $builder->initialNamespaces = $original->getInitialNamespaces();
276        $builder->sort = $original->getSort();
277        $builder->randomSeed = $original->getRandomSeed();
278        $builder->debugOptions = $original->getDebugOptions();
279        $builder->limit = $original->getLimit();
280        $builder->offset = $original->getOffset();
281        $builder->crossProjectSearch = false;
282        $builder->crossLanguageSearch = false;
283        $builder->extraIndicesSearch = $original->getInitialCrossSearchStrategy()->isExtraIndicesSearchSupported();
284        $builder->withDYMSuggestion = false;
285        $builder->allowRewrite = false;
286        $builder->extraFieldsToExtract = $original->getExtraFieldsToExtract();
287        return $builder;
288    }
289
290    public function build(): SearchQuery {
291        return new SearchQuery(
292            $this->parsedQuery,
293            $this->initialNamespaces,
294            new CrossSearchStrategy(
295                $this->crossProjectSearch && $this->searchConfig->isCrossProjectSearchEnabled(),
296                $this->crossLanguageSearch && $this->searchConfig->isCrossLanguageSearchEnabled(),
297                $this->extraIndicesSearch
298            ),
299            $this->contextualFilters,
300            $this->searchEngineEntryPoint,
301            $this->sort,
302            $this->randomSeed,
303            $this->forcedProfiles,
304            $this->offset,
305            $this->limit,
306            $this->debugOptions ?? CirrusDebugOptions::defaultOptions(),
307            $this->searchConfig,
308            $this->withDYMSuggestion,
309            $this->allowRewrite,
310            $this->profileContextParameters,
311            $this->extraFieldsToExtract,
312            $this->provideAllSnippets
313        );
314    }
315
316    /**
317     * @param string $name
318     * @param ContextualFilter $filter
319     * @return SearchQueryBuilder
320     */
321    public function addContextualFilter( $name, ContextualFilter $filter ): SearchQueryBuilder {
322        Assert::parameter( !array_key_exists( $name, $this->contextualFilters ),
323            '$name', "context filter $name already set" );
324        $this->contextualFilters[$name] = $filter;
325        return $this;
326    }
327
328    /**
329     * @param int[] $initialNamespaces
330     * @return SearchQueryBuilder
331     */
332    public function setInitialNamespaces( array $initialNamespaces ): SearchQueryBuilder {
333        $this->initialNamespaces = $initialNamespaces;
334
335        return $this;
336    }
337
338    /**
339     * @param bool $crossProjectSearch
340     * @return SearchQueryBuilder
341     */
342    public function setCrossProjectSearch( $crossProjectSearch ): SearchQueryBuilder {
343        $this->crossProjectSearch = $crossProjectSearch;
344
345        return $this;
346    }
347
348    /**
349     * @param bool $crossLanguageSearch
350     * @return SearchQueryBuilder
351     */
352    public function setCrossLanguageSearch( $crossLanguageSearch ): SearchQueryBuilder {
353        $this->crossLanguageSearch = $crossLanguageSearch;
354
355        return $this;
356    }
357
358    /**
359     * @param string $searchEngineEntryPoint
360     * @return SearchQueryBuilder
361     */
362    public function setSearchEngineEntryPoint( $searchEngineEntryPoint ): SearchQueryBuilder {
363        $this->searchEngineEntryPoint = $searchEngineEntryPoint;
364
365        return $this;
366    }
367
368    /**
369     * @param string $sort
370     * @return SearchQueryBuilder
371     */
372    public function setSort( $sort ): SearchQueryBuilder {
373        $this->sort = $sort;
374
375        return $this;
376    }
377
378    /**
379     * @param int $offset
380     * @return SearchQueryBuilder
381     */
382    public function setOffset( $offset ): SearchQueryBuilder {
383        $this->offset = $offset;
384
385        return $this;
386    }
387
388    /**
389     * @param int $limit
390     * @return SearchQueryBuilder
391     */
392    public function setLimit( $limit ): SearchQueryBuilder {
393        $this->limit = $limit;
394
395        return $this;
396    }
397
398    /**
399     * @param int|null $randomSeed
400     * @return $this
401     */
402    public function setRandomSeed( ?int $randomSeed ): SearchQueryBuilder {
403        $this->randomSeed = $randomSeed;
404
405        return $this;
406    }
407
408    public function setDebugOptions( CirrusDebugOptions $debugOptions ): SearchQueryBuilder {
409        $this->debugOptions = $debugOptions;
410
411        return $this;
412    }
413
414    /**
415     * @param bool $withDYMSuggestion
416     * @return SearchQueryBuilder
417     */
418    public function setWithDYMSuggestion( $withDYMSuggestion ): SearchQueryBuilder {
419        $this->withDYMSuggestion = $withDYMSuggestion;
420
421        return $this;
422    }
423
424    /**
425     * @param bool $extraIndicesSearch
426     * @return SearchQueryBuilder
427     */
428    public function setExtraIndicesSearch( $extraIndicesSearch ): SearchQueryBuilder {
429        $this->extraIndicesSearch = $extraIndicesSearch;
430
431        return $this;
432    }
433
434    /**
435     * @param string $type
436     * @param string $forcedProfile
437     * @return SearchQueryBuilder
438     */
439    public function addForcedProfile( $type, $forcedProfile ): SearchQueryBuilder {
440        $this->forcedProfiles[$type] = $forcedProfile;
441        return $this;
442    }
443
444    /**
445     * @param bool $allowRewrite
446     * @return SearchQueryBuilder
447     */
448    public function setAllowRewrite( $allowRewrite ): SearchQueryBuilder {
449        $this->allowRewrite = $allowRewrite;
450
451        return $this;
452    }
453
454    /**
455     * @param string $key
456     * @param string $value
457     * @return SearchQueryBuilder
458     * @see \CirrusSearch\Profile\ContextualProfileOverride
459     */
460    public function addProfileContextParameter( $key, $value ): SearchQueryBuilder {
461        $this->profileContextParameters[$key] = $value;
462        return $this;
463    }
464
465    /**
466     * @param string[] $fields
467     * @return SearchQueryBuilder
468     */
469    public function setExtraFieldsToExtract( array $fields ): SearchQueryBuilder {
470        $this->extraFieldsToExtract = $fields;
471        return $this;
472    }
473
474    public function setProvideAllSnippets( bool $shouldProvide ): SearchQueryBuilder {
475        $this->provideAllSnippets = $shouldProvide;
476        return $this;
477    }
478}