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