Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
94.94% |
75 / 79 |
|
84.62% |
11 / 13 |
CRAP | |
0.00% |
0 / 1 |
PrefixFeature | |
94.94% |
75 / 79 |
|
84.62% |
11 / 13 |
32.13 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
2 | |||
defaultNSPrefixParser | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
greedy | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getKeywords | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getCrossSearchStrategy | |
80.00% |
4 / 5 |
|
0.00% |
0 / 1 |
3.07 | |||
doApply | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
1 | |||
parseValue | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
internalParseValue | |
100.00% |
23 / 23 |
|
100.00% |
1 / 1 |
6 | |||
buildQuery | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
5 | |||
getFilterQuery | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
prepareSearchContext | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
3 | |||
alterSearchContextNamespace | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
5 | |||
asContextualFilter | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
2 |
1 | <?php |
2 | |
3 | namespace CirrusSearch\Query; |
4 | |
5 | use CirrusSearch\CrossSearchStrategy; |
6 | use CirrusSearch\Parser\AST\KeywordFeatureNode; |
7 | use CirrusSearch\Parser\NamespacePrefixParser; |
8 | use CirrusSearch\Query\Builder\ContextualFilter; |
9 | use CirrusSearch\Query\Builder\FilterBuilder; |
10 | use CirrusSearch\Query\Builder\QueryBuildingContext; |
11 | use CirrusSearch\Search\SearchContext; |
12 | use CirrusSearch\WarningCollector; |
13 | use Elastica\Query\AbstractQuery; |
14 | use Elastica\Query\BoolQuery; |
15 | use Elastica\Query\Term; |
16 | use 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 | */ |
33 | class 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 | } |