Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.95% covered (success)
95.95%
71 / 74
85.71% covered (warning)
85.71%
12 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
DeepcatFeature
95.95% covered (success)
95.95%
71 / 74
85.71% covered (warning)
85.71%
12 / 14
26
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 getCrossSearchStrategy
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
 getFeatureName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 doApply
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 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%
12 / 12
100.00% covered (success)
100.00%
1 / 1
3
 decideUiWarning
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 getCategoryPrefix
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 logRequest
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 fetchCategories
92.00% covered (success)
92.00%
23 / 25
0.00% covered (danger)
0.00%
0 / 1
3.00
 buildPropertyPathClauses
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 getFilterQuery
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 doGetFilterQuery
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3namespace CirrusSearch\Query;
4
5use CirrusSearch\CachedSparqlClient;
6use CirrusSearch\CrossSearchStrategy;
7use CirrusSearch\Parser\AST\KeywordFeatureNode;
8use CirrusSearch\Query\Builder\QueryBuildingContext;
9use CirrusSearch\Search\SearchContext;
10use CirrusSearch\SearchConfig;
11use CirrusSearch\Util;
12use CirrusSearch\WarningCollector;
13use Elastica\Query\AbstractQuery;
14use Elastica\Query\Terms;
15use MediaWiki\Config\Config;
16use MediaWiki\Logger\LoggerFactory;
17use MediaWiki\MediaWikiServices;
18use MediaWiki\Sparql\SparqlException;
19use MediaWiki\Title\Title;
20
21/**
22 * Filters by category or its subcategories. E.g. if category Vehicles includes Cars
23 * and Boats, then search for Vehicles would match pages in Vehicles, Cars and Boats.
24 *
25 * Syntax:
26 *  deepcat:Vehicles
27 */
28class DeepcatFeature extends SimpleKeywordFeature implements FilterQueryFeature {
29    /**
30     * Max lookup depth
31     * @var int
32     */
33    private $depth;
34    /**
35     * Max number of categories
36     * @var int
37     */
38    private $limit;
39    /**
40     * Category URL prefix for this wiki
41     * @var string|null (lazy loaded)
42     */
43    private $prefix;
44    /**
45     * @var CachedSparqlClient
46     */
47    private $sparql;
48
49    /**
50     * User agent to use for SPARQL queries
51     */
52    public const USER_AGENT = 'CirrusSearch deepcat feature';
53    /**
54     * Timeout (in seconds) for SPARQL query.
55     * TODO: make configurable?
56     */
57    public const TIMEOUT = 3;
58
59    /**
60     * @param Config $config
61     * @param ?CachedSparqlClient $sparql
62     */
63    public function __construct( Config $config, ?CachedSparqlClient $sparql = null ) {
64        $this->depth = (int)$config->get( 'CirrusSearchCategoryDepth' );
65        $this->limit = (int)$config->get( 'CirrusSearchCategoryMax' );
66        $endpoint = $config->get( 'CirrusSearchCategoryEndpoint' );
67        if ( $endpoint !== null && $endpoint !== '' ) {
68            $this->sparql = $sparql ?? MediaWikiServices::getInstance()->getService( 'CirrusCategoriesClient' );
69        }
70    }
71
72    /**
73     * @param KeywordFeatureNode $node
74     * @return CrossSearchStrategy
75     */
76    public function getCrossSearchStrategy( KeywordFeatureNode $node ) {
77        // the category tree is wiki specific
78        return CrossSearchStrategy::hostWikiOnlyStrategy();
79    }
80
81    /**
82     * @return string[] The list of keywords this feature is supposed to match
83     */
84    protected function getKeywords() {
85        return [ 'deepcat', 'deepcategory' ];
86    }
87
88    /**
89     * @param string $key
90     * @param string $valueDelimiter
91     * @return string
92     */
93    public function getFeatureName( $key, $valueDelimiter ) {
94        return 'deepcategory';
95    }
96
97    /**
98     * Applies the detected keyword from the search term. May apply changes
99     * either to $context directly, or return a filter to be added.
100     *
101     * @param SearchContext $context
102     * @param string $key The keyword
103     * @param string $value The value attached to the keyword with quotes stripped and escaped
104     *  quotes un-escaped.
105     * @param string $quotedValue The original value in the search string, including quotes if used
106     * @param bool $negated Is the search negated? Not used to generate the returned AbstractQuery,
107     *  that will be negated as necessary. Used for any other building/context necessary.
108     * @return array Two element array, first an AbstractQuery or null to apply to the
109     *  query. Second a boolean indicating if the quotedValue should be kept in the search
110     *  string.
111     */
112    protected function doApply( SearchContext $context, $key, $value, $quotedValue, $negated ) {
113        $filter = $this->doGetFilterQuery( $this->doExpand( $value, $context ) );
114        if ( $filter === null ) {
115            $context->setResultsPossible( false );
116        }
117
118        return [ $filter, false ];
119    }
120
121    /**
122     * @param KeywordFeatureNode $node
123     * @param SearchConfig $config
124     * @param WarningCollector $warningCollector
125     * @return array
126     */
127    public function expand( KeywordFeatureNode $node, SearchConfig $config, WarningCollector $warningCollector ) {
128        return $this->doExpand( $node->getValue(), $warningCollector );
129    }
130
131    /**
132     * @param string $value
133     * @param WarningCollector $warningCollector
134     * @return array
135     */
136    private function doExpand( $value, WarningCollector $warningCollector ) {
137        if ( !$this->sparql ) {
138            $warningCollector->addWarning( 'cirrussearch-feature-deepcat-endpoint' );
139            return [];
140        }
141
142        $startQueryTime = microtime( true );
143        try {
144            $categories = $this->fetchCategories( $value, $warningCollector );
145        } catch ( SparqlException $e ) {
146            // Not publishing exception here because it can contain too many details including IPs, etc.
147            $warningCollector->addWarning( $this->decideUiWarning( $e ) );
148            LoggerFactory::getInstance( 'CirrusSearch' )
149                ->warning( 'Deepcat SPARQL Exception: ' . $e->getMessage() );
150            $categories = [ $value ];
151        }
152        $this->logRequest( $startQueryTime );
153        return $categories;
154    }
155
156    private function decideUiWarning( SparqlException $e ): string {
157        $message = $e->getMessage();
158        // This could alternatively be a 500 error if blazegraph timed out
159        // prior to the http client timing out, but that doesn't happen due
160        // to http and blazegraph timeouts being set to the same value.
161        if ( strpos( $message, 'HTTP request timed out.' ) !== false ) {
162            return 'cirrussearch-feature-deepcat-timeout';
163        } else {
164            return 'cirrussearch-feature-deepcat-exception';
165        }
166    }
167
168    /**
169     * Get URL prefix for full category URL for this wiki.
170     * @return bool|string
171     */
172    private function getCategoryPrefix() {
173        if ( $this->prefix === null ) {
174            $title = Title::makeTitle( NS_CATEGORY, 'ZZ' );
175            $fullName = $title->getFullURL( '', false, PROTO_CANONICAL );
176            $this->prefix = substr( $fullName, 0, -2 );
177        }
178        return $this->prefix;
179    }
180
181    /**
182     * Record stats data for the request.
183     * @param float $startQueryTime
184     */
185    private function logRequest( $startQueryTime ) {
186        $timeTaken = intval( 1000 * ( microtime( true ) - $startQueryTime ) );
187        Util::getStatsFactory()
188            ->getTiming( 'deepcat_sparql_query_seconds' )
189            ->observe( $timeTaken );
190    }
191
192    /**
193     * Get child categories using SPARQL service.
194     * @param string $rootCategory Category to start looking from
195     * @param WarningCollector $warningCollector
196     * @return string[] List of subcategories.
197     * Note that the list may be incomplete due to limitations of the service.
198     * @throws SparqlException
199     */
200    private function fetchCategories( $rootCategory, WarningCollector $warningCollector ) {
201        $title = Title::makeTitleSafe( NS_CATEGORY, $rootCategory );
202        if ( $title === null ) {
203            $warningCollector->addWarning( 'cirrussearch-feature-deepcat-invalid-title' );
204            return [];
205        }
206        $fullName = $title->getFullURL( '', false, PROTO_CANONICAL );
207        $limit1 = $this->limit + 1;
208        $propertyPathClauses = $this->buildPropertyPathClauses( $this->depth );
209        $query = <<<SPARQL
210SELECT ?out (MIN(?d) AS ?depth) WHERE {
211 BIND (<$fullName> AS ?in)
212 { BIND (<$fullName> AS ?out) . BIND(0 AS ?d) }
213$propertyPathClauses
214} GROUP BY ?out ORDER BY ASC(?depth) LIMIT $limit1
215SPARQL;
216        $result = $this->sparql->query( $query );
217
218        if ( count( $result ) > $this->limit ) {
219            // We went over the limit.
220            $warningCollector->addWarning( 'cirrussearch-feature-deepcat-toomany' );
221            Util::getStatsFactory()
222                ->getCounter( 'deepcat_too_many_total' )
223                ->increment();
224            $result = array_slice( $result, 0, $this->limit );
225        }
226
227        $prefixLen = strlen( $this->getCategoryPrefix() );
228        return array_map( static function ( $row ) use ( $prefixLen ) {
229            // TODO: maybe we want to check the prefix is indeed the same?
230            // It should be but who knows...
231            return rawurldecode( substr( $row['out'], $prefixLen ) );
232        }, $result );
233    }
234
235    /**
236     * Builds a set of UNION clauses relying on explicit property paths to traverse each depth
237     * independently. The output form is:
238     * <pre>
239     * UNION { ?out mediawiki:isInCategory ?in . BIND(1 AS ?d) }
240     * UNION { ?out mediawiki:isInCategory/mediawiki:isInCategory ?in . BIND(2 AS ?d) }
241     * ...
242     * </pre>
243     * @param int $depth max depth to traverse
244     * @return string
245     */
246    private function buildPropertyPathClauses( int $depth ): string {
247        $clauses = "";
248        for ( $i = 1; $i <= $depth; $i++ ) {
249            $path = implode( "/", array_fill( 0, $i, "mediawiki:isInCategory" ) );
250            // expected line is (for a depth of 2):
251            // UNION { ?out mediawiki:isInCategory/mediawiki:isInCategory ?in . BIND(2 AS ?d) }
252            $clauses .= " UNION { ?out $path ?in . BIND($i AS ?d) }\n";
253        }
254        return $clauses;
255    }
256
257    /**
258     * @param KeywordFeatureNode $node
259     * @param QueryBuildingContext $context
260     * @return AbstractQuery|null
261     */
262    public function getFilterQuery( KeywordFeatureNode $node, QueryBuildingContext $context ) {
263        return $this->doGetFilterQuery( $context->getKeywordExpandedData( $node ) );
264    }
265
266    /**
267     * @param array $categories
268     * @return \Elastica\Query\AbstractQuery|null
269     */
270    protected function doGetFilterQuery( array $categories ) {
271        if ( $categories == [] ) {
272            return null;
273        }
274
275        // Categories are stored in search with spaces, but deepcat has the underscored variant
276        foreach ( $categories as &$cat ) {
277            $cat = strtr( $cat, '_', ' ' );
278        }
279
280        return new Terms( 'category.lowercase_keyword', $categories );
281    }
282}