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