Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.87% covered (success)
94.87%
74 / 78
83.33% covered (warning)
83.33%
10 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
PrefixFeature
94.87% covered (success)
94.87%
74 / 78
83.33% covered (warning)
83.33%
10 / 12
33.15
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 defaultNSPrefixParser
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 greedy
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getKeywords
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getCrossSearchStrategy
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 doApply
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
6
 parseValue
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 internalParseValue
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
6
 buildQuery
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
5
 getFilterQuery
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 alterSearchContextNamespace
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
 asContextualFilter
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3namespace CirrusSearch\Query;
4
5use CirrusSearch\CrossSearchStrategy;
6use CirrusSearch\Parser\AST\KeywordFeatureNode;
7use CirrusSearch\Parser\NamespacePrefixParser;
8use CirrusSearch\Query\Builder\ContextualFilter;
9use CirrusSearch\Query\Builder\FilterBuilder;
10use CirrusSearch\Query\Builder\QueryBuildingContext;
11use CirrusSearch\Search\SearchContext;
12use CirrusSearch\WarningCollector;
13use Elastica\Query\AbstractQuery;
14use Elastica\Query\BoolQuery;
15use Elastica\Query\Term;
16use MediaWiki\Search\SearchEngine;
17use Wikimedia\Assert\Assert;
18
19/**
20 * Handles the prefix: keyword for matching titles. Can be used to
21 * specify a namespace, a prefix of the title, or both. Note that
22 * unlike other keyword features this greedily uses everything after
23 * the prefix: keyword, so must be used at the end of the query. Also
24 * note that this will override namespace filters previously applied
25 * to the SearchContext.
26 *
27 * Examples:
28 *   prefix:Calif
29 *   prefix:Talk:
30 *   prefix:Talk:Calif
31 *   prefix:California Cou
32 *   prefix:"California Cou"
33 */
34class PrefixFeature extends SimpleKeywordFeature implements FilterQueryFeature {
35    private const KEYWORD = 'prefix';
36
37    /**
38     * key value to set in the array returned by KeywordFeature::parsedValue()
39     * to instruct the parser that additional namespaces are needed
40     * for the query to function properly.
41     * NOTE: a value of 'all' means that all namespaces are required
42     * are required.
43     * @see KeywordFeature::parsedValue()
44     */
45    public const PARSED_NAMESPACES = 'parsed_namespaces';
46
47    /**
48     * @var NamespacePrefixParser
49     */
50    private $namespacePrefixParser;
51
52    public function __construct( ?NamespacePrefixParser $namespacePrefixParser = null ) {
53        $this->namespacePrefixParser = $namespacePrefixParser ?? self::defaultNSPrefixParser();
54    }
55
56    private static function defaultNSPrefixParser(): NamespacePrefixParser {
57        return new class() implements NamespacePrefixParser {
58            /** @inheritDoc */
59            public function parse( $query ) {
60                return SearchEngine::parseNamespacePrefixes( $query, true, false );
61            }
62        };
63    }
64
65    /**
66     * @return bool
67     */
68    public function greedy() {
69        return true;
70    }
71
72    /**
73     * @return string[]
74     */
75    protected function getKeywords() {
76        return [ self::KEYWORD ];
77    }
78
79    /**
80     * @param KeywordFeatureNode $node
81     * @return CrossSearchStrategy
82     */
83    public function getCrossSearchStrategy( KeywordFeatureNode $node ) {
84        $parsedValue = $node->getParsedValue();
85        $namespace = $parsedValue['namespace'] ?? null;
86        if ( $namespace === null || $namespace <= NS_CATEGORY_TALK ) {
87            // we allow crosssearches for "standard" namespaces
88            return CrossSearchStrategy::allWikisStrategy();
89        } else {
90            return CrossSearchStrategy::hostWikiOnlyStrategy();
91        }
92    }
93
94    /**
95     * @param SearchContext $context
96     * @param string $key
97     * @param string $value
98     * @param string $quotedValue
99     * @param bool $negated
100     * @return array
101     */
102    protected function doApply( SearchContext $context, $key, $value, $quotedValue, $negated ) {
103        $parsedValue = $this->parseValue( $key, $value, $quotedValue, '', '', $context );
104        '@phan-var array $parsedValue';
105        $namespace = $parsedValue['namespace'] ?? null;
106        if ( !$negated ) {
107            self::alterSearchContextNamespace( $context, $namespace );
108        } else {
109            if (
110                $namespace !== 'all' &&
111                $namespace !== null &&
112                $context->getNamespaces() &&
113                !in_array( $namespace, $context->getNamespaces() )
114            ) {
115                $context->addWarning( 'cirrussearch-keyword-prefix-exclusion-on-unselected-namespace' );
116            }
117        }
118        $prefixQuery = $this->buildQuery( $parsedValue['value'], $namespace );
119        return [ $prefixQuery, false ];
120    }
121
122    /**
123     * @param string $key
124     * @param string $value
125     * @param string $quotedValue
126     * @param string $valueDelimiter
127     * @param string $suffix
128     * @param WarningCollector $warningCollector
129     * @return array|false|null
130     */
131    public function parseValue( $key, $value, $quotedValue, $valueDelimiter, $suffix, WarningCollector $warningCollector ) {
132        return $this->internalParseValue( $value );
133    }
134
135    /**
136     * Parse the value of the prefix keyword mainly to extract the namespace prefix
137     * @param string $value
138     * @return array|false|null
139     */
140    private function internalParseValue( $value ) {
141        $trimQuote = '/^"([^"]*)"\s*$/';
142        $value = preg_replace( $trimQuote, "$1", $value );
143        // NS_MAIN by default
144        $namespaces = [ NS_MAIN ];
145
146        // Suck namespaces out of $value. Note that this overrides provided
147        // namespace filters.
148        $queryAndNamespace = $this->namespacePrefixParser->parse( $value );
149        if ( $queryAndNamespace !== false ) {
150            // parseNamespacePrefixes returns the whole query if it's made of single namespace prefix
151            $value = $value === $queryAndNamespace[0] ? '' : $queryAndNamespace[0];
152            $namespaces = $queryAndNamespace[1];
153
154            // Redo best effort quote trimming on the resulting value
155            $value = preg_replace( $trimQuote, "$1", $value );
156        }
157        Assert::postcondition( $namespaces === null || count( $namespaces ) === 1,
158            "namespace can only be an array with one value or null" );
159        $value = trim( $value );
160        // All titles in namespace
161        if ( $value === '' ) {
162            $value = null;
163        }
164        if ( $namespaces !== null ) {
165            return [
166                'namespace' => reset( $namespaces ),
167                'value' => $value,
168                self::PARSED_NAMESPACES => $namespaces,
169            ];
170        } else {
171            return [
172                'value' => $value,
173                self::PARSED_NAMESPACES => 'all',
174            ];
175        }
176    }
177
178    /**
179     * @param string|null $value
180     * @param int|null $namespace
181     * @return AbstractQuery|null null in the case of prefix:all:
182     */
183    private function buildQuery( $value = null, $namespace = null ) {
184        $nsFilter = null;
185        $prefixQuery = null;
186        if ( $value !== null ) {
187            $prefixQuery = new \Elastica\Query\MatchQuery();
188            $prefixQuery->setFieldQuery( 'title.prefix', $value );
189        }
190        if ( $namespace !== null ) {
191            $nsFilter = new Term( [ 'namespace' => $namespace ] );
192        }
193        if ( $prefixQuery !== null && $nsFilter !== null ) {
194            $query = new BoolQuery();
195            $query->addMust( $prefixQuery );
196            $query->addMust( $nsFilter );
197            return $query;
198        }
199
200        return $nsFilter ?? $prefixQuery;
201    }
202
203    /**
204     * @param KeywordFeatureNode $node
205     * @param QueryBuildingContext $context
206     * @return AbstractQuery|null
207     */
208    public function getFilterQuery( KeywordFeatureNode $node, QueryBuildingContext $context ) {
209        // @phan-suppress-next-line PhanTypeArraySuspiciousNullable
210        return $this->buildQuery( $node->getParsedValue()['value'],
211            $node->getParsedValue()['namespace'] ?? null );
212    }
213
214    /**
215     * Alter the set of namespaces in the SearchContext
216     * This is a special (and historic) behavior of the prefix keyword
217     * it has the ability to extend the list requested namespaces to the ones
218     * it wants to query.
219     *
220     * @param SearchContext $context
221     * @param int|null $namespace
222     */
223    private static function alterSearchContextNamespace( SearchContext $context, $namespace ) {
224        if ( $namespace === null && $context->getNamespaces() ) {
225            $context->setNamespaces( null );
226        } elseif ( $context->getNamespaces() &&
227                   !in_array( $namespace, $context->getNamespaces() ) ) {
228            $namespaces = $context->getNamespaces();
229            $namespaces[] = $namespace;
230            $context->setNamespaces( $namespaces );
231        }
232    }
233
234    /**
235     * @param string $prefix
236     * @param NamespacePrefixParser|null $namespacePrefixParser
237     * @return ContextualFilter
238     */
239    public static function asContextualFilter( $prefix, ?NamespacePrefixParser $namespacePrefixParser = null ) {
240        $feature = new self( $namespacePrefixParser );
241        $parsedValue = $feature->internalParseValue( $prefix );
242        $namespace = $parsedValue['namespace'] ?? null;
243        // @phan-suppress-next-line PhanTypeArraySuspiciousNullable
244        $query = $feature->buildQuery( $parsedValue['value'], $namespace );
245        return new class( $query, $namespace !== null ? [ $namespace ] : [] ) implements ContextualFilter {
246            /**
247             * @var AbstractQuery
248             */
249            private $query;
250
251            /**
252             * @var int[]
253             */
254            private $namespaces;
255
256            /** @inheritDoc */
257            public function __construct( $query, array $namespaces ) {
258                $this->query = $query;
259                $this->namespaces = $namespaces;
260            }
261
262            public function populate( FilterBuilder $filteringContext ) {
263                $filteringContext->must( $this->query );
264            }
265
266            /**
267             * @return int[]|null
268             */
269            public function requiredNamespaces() {
270                return $this->namespaces;
271            }
272        };
273    }
274}