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     */
255    public static function forRewrittenQuery(
256        SearchQuery $original,
257        $term,
258        NamespacePrefixParser $namespacePrefixParser,
259        CirrusSearchHookRunner $cirrusSearchHookRunner
260    ): SearchQueryBuilder {
261        Assert::precondition( $original->isAllowRewrite(), 'The original query must allow rewrites' );
262        // Hack to prevent a second pass on this cleaning algo because its destructive
263        $config = new HashSearchConfig( [ 'CirrusSearchStripQuestionMarks' => 'no' ],
264            [ HashSearchConfig::FLAG_INHERIT ], $original->getSearchConfig() );
265
266        $builder = self::newFTSearchQueryBuilder( $config, $term, $namespacePrefixParser, $cirrusSearchHookRunner );
267        $builder->contextualFilters = $original->getContextualFilters();
268        $builder->forcedProfiles = $original->getForcedProfiles();
269        $builder->initialNamespaces = $original->getInitialNamespaces();
270        $builder->sort = $original->getSort();
271        $builder->randomSeed = $original->getRandomSeed();
272        $builder->debugOptions = $original->getDebugOptions();
273        $builder->limit = $original->getLimit();
274        $builder->offset = $original->getOffset();
275        $builder->crossProjectSearch = false;
276        $builder->crossLanguageSearch = false;
277        $builder->extraIndicesSearch = $original->getInitialCrossSearchStrategy()->isExtraIndicesSearchSupported();
278        $builder->withDYMSuggestion = false;
279        $builder->allowRewrite = false;
280        $builder->extraFieldsToExtract = $original->getExtraFieldsToExtract();
281        return $builder;
282    }
283
284    /**
285     * @return SearchQuery
286     */
287    public function build(): SearchQuery {
288        return new SearchQuery(
289            $this->parsedQuery,
290            $this->initialNamespaces,
291            new CrossSearchStrategy(
292                $this->crossProjectSearch && $this->searchConfig->isCrossProjectSearchEnabled(),
293                $this->crossLanguageSearch && $this->searchConfig->isCrossLanguageSearchEnabled(),
294                $this->extraIndicesSearch
295            ),
296            $this->contextualFilters,
297            $this->searchEngineEntryPoint,
298            $this->sort,
299            $this->randomSeed,
300            $this->forcedProfiles,
301            $this->offset,
302            $this->limit,
303            $this->debugOptions ?? CirrusDebugOptions::defaultOptions(),
304            $this->searchConfig,
305            $this->withDYMSuggestion,
306            $this->allowRewrite,
307            $this->profileContextParameters,
308            $this->extraFieldsToExtract,
309            $this->provideAllSnippets
310        );
311    }
312
313    /**
314     * @param string $name
315     * @param ContextualFilter $filter
316     * @return SearchQueryBuilder
317     */
318    public function addContextualFilter( $name, ContextualFilter $filter ): SearchQueryBuilder {
319        Assert::parameter( !array_key_exists( $name, $this->contextualFilters ),
320            '$name', "context filter $name already set" );
321        $this->contextualFilters[$name] = $filter;
322        return $this;
323    }
324
325    /**
326     * @param int[] $initialNamespaces
327     * @return SearchQueryBuilder
328     */
329    public function setInitialNamespaces( array $initialNamespaces ): SearchQueryBuilder {
330        $this->initialNamespaces = $initialNamespaces;
331
332        return $this;
333    }
334
335    /**
336     * @param bool $crossProjectSearch
337     * @return SearchQueryBuilder
338     */
339    public function setCrossProjectSearch( $crossProjectSearch ): SearchQueryBuilder {
340        $this->crossProjectSearch = $crossProjectSearch;
341
342        return $this;
343    }
344
345    /**
346     * @param bool $crossLanguageSearch
347     * @return SearchQueryBuilder
348     */
349    public function setCrossLanguageSearch( $crossLanguageSearch ): SearchQueryBuilder {
350        $this->crossLanguageSearch = $crossLanguageSearch;
351
352        return $this;
353    }
354
355    /**
356     * @param string $searchEngineEntryPoint
357     * @return SearchQueryBuilder
358     */
359    public function setSearchEngineEntryPoint( $searchEngineEntryPoint ): SearchQueryBuilder {
360        $this->searchEngineEntryPoint = $searchEngineEntryPoint;
361
362        return $this;
363    }
364
365    /**
366     * @param string $sort
367     * @return SearchQueryBuilder
368     */
369    public function setSort( $sort ): SearchQueryBuilder {
370        $this->sort = $sort;
371
372        return $this;
373    }
374
375    /**
376     * @param int $offset
377     * @return SearchQueryBuilder
378     */
379    public function setOffset( $offset ): SearchQueryBuilder {
380        $this->offset = $offset;
381
382        return $this;
383    }
384
385    /**
386     * @param int $limit
387     * @return SearchQueryBuilder
388     */
389    public function setLimit( $limit ): SearchQueryBuilder {
390        $this->limit = $limit;
391
392        return $this;
393    }
394
395    /**
396     * @param int|null $randomSeed
397     * @return $this
398     */
399    public function setRandomSeed( ?int $randomSeed ): SearchQueryBuilder {
400        $this->randomSeed = $randomSeed;
401
402        return $this;
403    }
404
405    /**
406     * @param CirrusDebugOptions $debugOptions
407     * @return SearchQueryBuilder
408     */
409    public function setDebugOptions( CirrusDebugOptions $debugOptions ): SearchQueryBuilder {
410        $this->debugOptions = $debugOptions;
411
412        return $this;
413    }
414
415    /**
416     * @param bool $withDYMSuggestion
417     * @return SearchQueryBuilder
418     */
419    public function setWithDYMSuggestion( $withDYMSuggestion ): SearchQueryBuilder {
420        $this->withDYMSuggestion = $withDYMSuggestion;
421
422        return $this;
423    }
424
425    /**
426     * @param bool $extraIndicesSearch
427     * @return SearchQueryBuilder
428     */
429    public function setExtraIndicesSearch( $extraIndicesSearch ): SearchQueryBuilder {
430        $this->extraIndicesSearch = $extraIndicesSearch;
431
432        return $this;
433    }
434
435    /**
436     * @param string $type
437     * @param string $forcedProfile
438     * @return SearchQueryBuilder
439     */
440    public function addForcedProfile( $type, $forcedProfile ): SearchQueryBuilder {
441        $this->forcedProfiles[$type] = $forcedProfile;
442        return $this;
443    }
444
445    /**
446     * @param bool $allowRewrite
447     * @return SearchQueryBuilder
448     */
449    public function setAllowRewrite( $allowRewrite ): SearchQueryBuilder {
450        $this->allowRewrite = $allowRewrite;
451
452        return $this;
453    }
454
455    /**
456     * @param string $key
457     * @param string $value
458     * @return SearchQueryBuilder
459     * @see \CirrusSearch\Profile\ContextualProfileOverride
460     */
461    public function addProfileContextParameter( $key, $value ): SearchQueryBuilder {
462        $this->profileContextParameters[$key] = $value;
463        return $this;
464    }
465
466    /**
467     * @param string[] $fields
468     * @return SearchQueryBuilder
469     */
470    public function setExtraFieldsToExtract( array $fields ): SearchQueryBuilder {
471        $this->extraFieldsToExtract = $fields;
472        return $this;
473    }
474
475    public function setProvideAllSnippets( bool $shouldProvide ): SearchQueryBuilder {
476        $this->provideAllSnippets = $shouldProvide;
477        return $this;
478    }
479}