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\CrossSearchStrategy;
6use CirrusSearch\Parser\AST\KeywordFeatureNode;
7use CirrusSearch\Query\Builder\QueryBuildingContext;
8use CirrusSearch\Search\SearchContext;
9use CirrusSearch\SearchConfig;
10use CirrusSearch\WarningCollector;
11use Elastica\Query\AbstractQuery;
12use MediaWiki\Config\Config;
13use MediaWiki\MediaWikiServices;
14use MediaWiki\Page\PageRecord;
15use 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 */
34class 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}