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