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
31.12
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%
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            /** @inheritDoc */
58            public function parse( $query ) {
59                return \SearchEngine::parseNamespacePrefixes( $query, true, false );
60            }
61        };
62    }
63
64    /**
65     * @return bool
66     */
67    public function greedy() {
68        return true;
69    }
70
71    /**
72     * @return string[]
73     */
74    protected function getKeywords() {
75        return [ self::KEYWORD ];
76    }
77
78    /**
79     * @param KeywordFeatureNode $node
80     * @return CrossSearchStrategy
81     */
82    public function getCrossSearchStrategy( KeywordFeatureNode $node ) {
83        $parsedValue = $node->getParsedValue();
84        $namespace = $parsedValue['namespace'] ?? null;
85        if ( $namespace === null || $namespace <= NS_CATEGORY_TALK ) {
86            // we allow crosssearches for "standard" namespaces
87            return CrossSearchStrategy::allWikisStrategy();
88        } else {
89            return CrossSearchStrategy::hostWikiOnlyStrategy();
90        }
91    }
92
93    /**
94     * @param SearchContext $context
95     * @param string $key
96     * @param string $value
97     * @param string $quotedValue
98     * @param bool $negated
99     * @return array
100     */
101    protected function doApply( SearchContext $context, $key, $value, $quotedValue, $negated ) {
102        $parsedValue = $this->parseValue( $key, $value, $quotedValue, '', '', $context );
103        '@phan-var array $parsedValue';
104        $namespace = $parsedValue['namespace'] ?? null;
105        self::alterSearchContextNamespace( $context, $namespace );
106        $prefixQuery = $this->buildQuery( $parsedValue['value'], $namespace );
107        return [ $prefixQuery, false ];
108    }
109
110    /**
111     * @param string $key
112     * @param string $value
113     * @param string $quotedValue
114     * @param string $valueDelimiter
115     * @param string $suffix
116     * @param WarningCollector $warningCollector
117     * @return array|false|null
118     */
119    public function parseValue( $key, $value, $quotedValue, $valueDelimiter, $suffix, WarningCollector $warningCollector ) {
120        return $this->internalParseValue( $value );
121    }
122
123    /**
124     * Parse the value of the prefix keyword mainly to extract the namespace prefix
125     * @param string $value
126     * @return array|false|null
127     */
128    private function internalParseValue( $value ) {
129        $trimQuote = '/^"([^"]*)"\s*$/';
130        $value = preg_replace( $trimQuote, "$1", $value );
131        // NS_MAIN by default
132        $namespaces = [ NS_MAIN ];
133
134        // Suck namespaces out of $value. Note that this overrides provided
135        // namespace filters.
136        $queryAndNamespace = $this->namespacePrefixParser->parse( $value );
137        if ( $queryAndNamespace !== false ) {
138            // parseNamespacePrefixes returns the whole query if it's made of single namespace prefix
139            $value = $value === $queryAndNamespace[0] ? '' : $queryAndNamespace[0];
140            $namespaces = $queryAndNamespace[1];
141
142            // Redo best effort quote trimming on the resulting value
143            $value = preg_replace( $trimQuote, "$1", $value );
144        }
145        Assert::postcondition( $namespaces === null || count( $namespaces ) === 1,
146            "namespace can only be an array with one value or null" );
147        $value = trim( $value );
148        // All titles in namespace
149        if ( $value === '' ) {
150            $value = null;
151        }
152        if ( $namespaces !== null ) {
153            return [
154                'namespace' => reset( $namespaces ),
155                'value' => $value,
156                self::PARSED_NAMESPACES => $namespaces,
157            ];
158        } else {
159            return [
160                'value' => $value,
161                self::PARSED_NAMESPACES => 'all',
162            ];
163        }
164    }
165
166    /**
167     * @param string|null $value
168     * @param int|null $namespace
169     * @return AbstractQuery|null null in the case of prefix:all:
170     */
171    private function buildQuery( $value = null, $namespace = null ) {
172        $nsFilter = null;
173        $prefixQuery = null;
174        if ( $value !== null ) {
175            $prefixQuery = new \Elastica\Query\MatchQuery();
176            $prefixQuery->setFieldQuery( 'title.prefix', $value );
177        }
178        if ( $namespace !== null ) {
179            $nsFilter = new Term( [ 'namespace' => $namespace ] );
180        }
181        if ( $prefixQuery !== null && $nsFilter !== null ) {
182            $query = new BoolQuery();
183            $query->addMust( $prefixQuery );
184            $query->addMust( $nsFilter );
185            return $query;
186        }
187
188        return $nsFilter ?? $prefixQuery;
189    }
190
191    /**
192     * @param KeywordFeatureNode $node
193     * @param QueryBuildingContext $context
194     * @return AbstractQuery|null
195     */
196    public function getFilterQuery( KeywordFeatureNode $node, QueryBuildingContext $context ) {
197        // @phan-suppress-next-line PhanTypeArraySuspiciousNullable
198        return $this->buildQuery( $node->getParsedValue()['value'],
199            $node->getParsedValue()['namespace'] ?? null );
200    }
201
202    /**
203     * Adds a prefix filter to the search context
204     * @param SearchContext $context
205     * @param string $prefix
206     * @param NamespacePrefixParser|null $namespacePrefixParser
207     */
208    public static function prepareSearchContext( SearchContext $context, $prefix, ?NamespacePrefixParser $namespacePrefixParser = null ) {
209        $filter = self::asContextualFilter( $prefix, $namespacePrefixParser );
210        $filter->populate( $context );
211        $namespaces = $filter->requiredNamespaces();
212        Assert::postcondition( $namespaces !== null && count( $namespaces ) <= 1,
213            'PrefixFeature must extract one or all namespaces' );
214        self::alterSearchContextNamespace( $context,
215            count( $namespaces ) === 1 ? reset( $namespaces ) : null );
216    }
217
218    /**
219     * Alter the set of namespaces in the SearchContext
220     * This is a special (and historic) behavior of the prefix keyword
221     * it has the ability to extend the list requested namespaces to the ones
222     * it wants to query.
223     *
224     * @param SearchContext $context
225     * @param int|null $namespace
226     */
227    private static function alterSearchContextNamespace( SearchContext $context, $namespace ) {
228        if ( $namespace === null && $context->getNamespaces() ) {
229            $context->setNamespaces( null );
230        } elseif ( $context->getNamespaces() &&
231                   !in_array( $namespace, $context->getNamespaces() ) ) {
232            $namespaces = $context->getNamespaces();
233            $namespaces[] = $namespace;
234            $context->setNamespaces( $namespaces );
235        }
236    }
237
238    /**
239     * @param string $prefix
240     * @param NamespacePrefixParser|null $namespacePrefixParser
241     * @return ContextualFilter
242     */
243    public static function asContextualFilter( $prefix, ?NamespacePrefixParser $namespacePrefixParser = null ) {
244        $feature = new self( $namespacePrefixParser );
245        $parsedValue = $feature->internalParseValue( $prefix );
246        $namespace = $parsedValue['namespace'] ?? null;
247        // @phan-suppress-next-line PhanTypeArraySuspiciousNullable
248        $query = $feature->buildQuery( $parsedValue['value'], $namespace );
249        return new class( $query, $namespace !== null ? [ $namespace ] : [] ) implements ContextualFilter {
250            /**
251             * @var AbstractQuery
252             */
253            private $query;
254
255            /**
256             * @var int[]
257             */
258            private $namespaces;
259
260            /** @inheritDoc */
261            public function __construct( $query, array $namespaces ) {
262                $this->query = $query;
263                $this->namespaces = $namespaces;
264            }
265
266            public function populate( FilterBuilder $filteringContext ) {
267                $filteringContext->must( $this->query );
268            }
269
270            /**
271             * @return int[]|null
272             */
273            public function requiredNamespaces() {
274                return $this->namespaces;
275            }
276        };
277    }
278}