Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
96.67% |
58 / 60 |
|
88.89% |
8 / 9 |
CRAP | |
0.00% |
0 / 1 |
InCategoryFeature | |
96.67% |
58 / 60 |
|
88.89% |
8 / 9 |
20 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
getKeywords | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getCrossSearchStrategy | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
doApply | |
80.00% |
8 / 10 |
|
0.00% |
0 / 1 |
3.07 | |||
parseValue | |
100.00% |
21 / 21 |
|
100.00% |
1 / 1 |
5 | |||
expand | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
doExpand | |
100.00% |
14 / 14 |
|
100.00% |
1 / 1 |
3 | |||
matchPageCategories | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
getFilterQuery | |
100.00% |
4 / 4 |
|
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\Query\Builder\QueryBuildingContext; |
8 | use CirrusSearch\Search\SearchContext; |
9 | use CirrusSearch\SearchConfig; |
10 | use CirrusSearch\WarningCollector; |
11 | use Elastica\Query\AbstractQuery; |
12 | use MediaWiki\Config\Config; |
13 | use MediaWiki\MediaWikiServices; |
14 | use MediaWiki\Page\PageRecord; |
15 | use MediaWiki\Page\PageStore; |
16 | |
17 | /** |
18 | * Filters by one or more categories, specified either by name or by category |
19 | * id. Multiple categories are separated by |. Categories specified by id |
20 | * must follow the syntax `id:<id>`. |
21 | * |
22 | * We emulate template syntax here as best as possible, so things in NS_MAIN |
23 | * are prefixed with ":" and things in NS_TEMPLATE don't have a prefix at all. |
24 | * Since we don't actually index templates like that, munge the query here. |
25 | * |
26 | * Examples: |
27 | * incategory:id:12345 |
28 | * incategory:Music_by_genre |
29 | * incategory:Music_by_genre|Animals |
30 | * incategory:"Music by genre|Animals" |
31 | * incategory:Animals|id:54321 |
32 | * incategory::Something_in_NS_MAIN |
33 | */ |
34 | class InCategoryFeature extends SimpleKeywordFeature implements FilterQueryFeature { |
35 | /** |
36 | * @var int |
37 | */ |
38 | private $maxConditions; |
39 | /** |
40 | * @var PageStore|null |
41 | */ |
42 | private $pageStore; |
43 | |
44 | /** |
45 | * @param Config $config |
46 | * @param PageStore|null $pageStore |
47 | */ |
48 | public function __construct( Config $config, ?PageStore $pageStore = null ) { |
49 | $this->maxConditions = $config->get( 'CirrusSearchMaxIncategoryOptions' ); |
50 | $this->pageStore = $pageStore; |
51 | } |
52 | |
53 | /** |
54 | * @return string[] |
55 | */ |
56 | protected function getKeywords() { |
57 | return [ 'incategory' ]; |
58 | } |
59 | |
60 | /** |
61 | * @param KeywordFeatureNode $node |
62 | * @return CrossSearchStrategy |
63 | */ |
64 | public function getCrossSearchStrategy( KeywordFeatureNode $node ) { |
65 | if ( empty( $node->getParsedValue()['pageIds'] ) ) { |
66 | // We depend on the db to fetch the category by id |
67 | return CrossSearchStrategy::allWikisStrategy(); |
68 | } else { |
69 | return CrossSearchStrategy::hostWikiOnlyStrategy(); |
70 | } |
71 | } |
72 | |
73 | /** |
74 | * @param SearchContext $context |
75 | * @param string $key The keyword |
76 | * @param string $value The value attached to the keyword with quotes stripped |
77 | * @param string $quotedValue The original value in the search string, including quotes if used |
78 | * @param bool $negated Is the search negated? Not used to generate the returned AbstractQuery, |
79 | * that will be negated as necessary. Used for any other building/context necessary. |
80 | * @return array Two element array, first an AbstractQuery or null to apply to the |
81 | * query. Second a boolean indicating if the quotedValue should be kept in the search |
82 | * string. |
83 | */ |
84 | protected function doApply( SearchContext $context, $key, $value, $quotedValue, $negated ) { |
85 | $parsedValue = $this->parseValue( $key, $value, $quotedValue, '', '', $context ); |
86 | if ( $parsedValue === null ) { |
87 | $context->setResultsPossible( false ); |
88 | return [ null, false ]; |
89 | } |
90 | |
91 | $names = $this->doExpand( $key, $parsedValue, $context ); |
92 | |
93 | if ( $names === [] ) { |
94 | $context->setResultsPossible( false ); |
95 | return [ null, false ]; |
96 | } |
97 | |
98 | $filter = $this->matchPageCategories( $names ); |
99 | return [ $filter, false ]; |
100 | } |
101 | |
102 | /** |
103 | * @param string $key |
104 | * @param string $value |
105 | * @param string $quotedValue |
106 | * @param string $valueDelimiter |
107 | * @param string $suffix |
108 | * @param WarningCollector $warningCollector |
109 | * @return array|false|null |
110 | */ |
111 | public function parseValue( $key, $value, $quotedValue, $valueDelimiter, $suffix, WarningCollector $warningCollector ) { |
112 | // en:Programming|id:3041512\ |
113 | $categories = explode( '|', $value );// en:programming |
114 | if ( count( $categories ) > $this->maxConditions ) { |
115 | $warningCollector->addWarning( |
116 | 'cirrussearch-feature-too-many-conditions', |
117 | $key, |
118 | $this->maxConditions |
119 | ); |
120 | $categories = array_slice( |
121 | $categories, |
122 | 0, |
123 | $this->maxConditions |
124 | ); |
125 | } |
126 | |
127 | $pageIds = []; |
128 | $names = []; |
129 | |
130 | foreach ( $categories as $category ) { |
131 | if ( str_starts_with( $category, 'id:' ) ) { |
132 | $pageId = substr( $category, 3 ); |
133 | if ( ctype_digit( $pageId ) ) { |
134 | $pageIds[] = $pageId; |
135 | } |
136 | } else { |
137 | $names[] = $category;// en:programming |
138 | } |
139 | } |
140 | |
141 | return [ 'names' => $names, 'pageIds' => $pageIds ];// en:programming |
142 | } |
143 | |
144 | /** |
145 | * @param KeywordFeatureNode $node |
146 | * @param SearchConfig $config |
147 | * @param WarningCollector $warningCollector |
148 | * @return array |
149 | */ |
150 | public function expand( KeywordFeatureNode $node, SearchConfig $config, WarningCollector $warningCollector ) { |
151 | return $this->doExpand( $node->getKey(), $node->getParsedValue(), $warningCollector ); |
152 | } |
153 | |
154 | /** |
155 | * @param string $key |
156 | * @param array $parsedValue |
157 | * @param WarningCollector $warningCollector |
158 | * @return array |
159 | */ |
160 | private function doExpand( $key, array $parsedValue, WarningCollector $warningCollector ) { |
161 | $names = $parsedValue['names'];// en:programming |
162 | $pageIds = $parsedValue['pageIds']; |
163 | |
164 | $pageStore = $this->pageStore ?? MediaWikiServices::getInstance()->getPageStore(); |
165 | $titles = $pageStore |
166 | ->newSelectQueryBuilder() |
167 | ->wherePageIds( $pageIds ) |
168 | ->caller( __METHOD__ ) |
169 | ->fetchPageRecords(); |
170 | |
171 | $titleFormatter = MediaWikiServices::getInstance()->getTitleFormatter(); |
172 | |
173 | /** @var PageRecord $title */ |
174 | foreach ( $titles as $title ) { |
175 | $names[] = $titleFormatter->getText( $title ); |
176 | } |
177 | |
178 | if ( $names === [] ) { |
179 | $warningCollector->addWarning( 'cirrussearch-incategory-feature-no-valid-categories', $key ); |
180 | } |
181 | return $names;// en:programing |
182 | } |
183 | |
184 | /** |
185 | * Builds an or between many categories that the page could be in. |
186 | * |
187 | * @param string[] $names categories to match |
188 | * @return \Elastica\Query\BoolQuery|null A null return value means all values are filtered |
189 | * and an empty result set should be returned. |
190 | */ |
191 | private function matchPageCategories( array $names ) { |
192 | $filter = new \Elastica\Query\BoolQuery(); |
193 | |
194 | foreach ( $names as $name ) { |
195 | $filter->addShould( QueryHelper::matchCategory( 'category.lowercase_keyword', $name ) ); |
196 | } |
197 | |
198 | return $filter; |
199 | } |
200 | |
201 | /** |
202 | * @param KeywordFeatureNode $node |
203 | * @param QueryBuildingContext $context |
204 | * @return AbstractQuery|null |
205 | */ |
206 | public function getFilterQuery( KeywordFeatureNode $node, QueryBuildingContext $context ) { |
207 | $names = $context->getKeywordExpandedData( $node ); |
208 | if ( $names === [] ) { |
209 | return null; |
210 | } |
211 | return $this->matchPageCategories( $names ); |
212 | } |
213 | } |