Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
68 / 68
100.00% covered (success)
100.00%
7 / 7
CRAP
100.00% covered (success)
100.00%
1 / 1
HasRecommendationFeature
100.00% covered (success)
100.00%
68 / 68
100.00% covered (success)
100.00%
7 / 7
19
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 doApply
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 getKeywords
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 parseValue
100.00% covered (success)
100.00%
39 / 39
100.00% covered (success)
100.00%
1 / 1
7
 doGetFilterQuery
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
3
 doGetRankedQueries
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 getFilterQuery
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3namespace CirrusSearch\Query;
4
5use CirrusSearch\Extra\Query\TermFreq;
6use CirrusSearch\Parser\AST\KeywordFeatureNode;
7use CirrusSearch\Query\Builder\QueryBuildingContext;
8use CirrusSearch\Search\Filters;
9use CirrusSearch\Search\SearchContext;
10use CirrusSearch\Search\WeightedTagsHooks;
11use CirrusSearch\WarningCollector;
12use Elastica\Query\AbstractQuery;
13use Elastica\Query\MatchQuery;
14use MediaWiki\Message\Message;
15
16/**
17 * Filters the result set based on the existing article recommendation.
18 * Currently we handle link and image recommendations.
19 *
20 * Examples:
21 *   hasrecommendation:image
22 *   hasrecommendation:link|image
23 */
24class HasRecommendationFeature extends SimpleKeywordFeature implements FilterQueryFeature {
25    public const WARN_MESSAGE_INVALID_THRESHOLD = "cirrussearch-invalid-keyword-threshold";
26
27    /**
28     * Limit filtering to 5 recommendation types. Arbitrarily chosen, but should be more
29     * than enough and some sort of limit has to be enforced.
30     */
31    public const QUERY_LIMIT = 5;
32    private int $maxScore;
33
34    public function __construct( int $maxScore ) {
35        $this->maxScore = $maxScore;
36    }
37
38    /** @inheritDoc */
39    protected function doApply( SearchContext $context, $key, $value, $quotedValue, $negated ) {
40        $parsedValue = $this->parseValue( $key, $value, $quotedValue, '', '', $context );
41        if ( !$negated ) {
42            $query = $this->doGetRankedQueries( $parsedValue );
43            if ( $query !== null ) {
44                $context->addNonTextQuery( $query );
45            }
46        }
47        return [ $this->doGetFilterQuery( $parsedValue ), false ];
48    }
49
50    /** @inheritDoc */
51    protected function getKeywords() {
52        return [ 'hasrecommendation' ];
53    }
54
55    /**
56     * @param string $key
57     * @param string $value
58     * @param string $quotedValue
59     * @param string $valueDelimiter
60     * @param string $suffix
61     * @param WarningCollector $warningCollector
62     * @return array|false|null
63     */
64    public function parseValue( $key, $value, $quotedValue, $valueDelimiter, $suffix,
65                                WarningCollector $warningCollector ) {
66        $recFlags = explode( "|", $value );
67        if ( count( $recFlags ) > self::QUERY_LIMIT ) {
68            $warningCollector->addWarning(
69                'cirrussearch-feature-too-many-conditions',
70                $key,
71                self::QUERY_LIMIT
72            );
73            $recFlags = array_slice( $recFlags, 0, self::QUERY_LIMIT );
74        }
75        $recFlags = array_map(
76            function ( string $k ) use ( $warningCollector ): array {
77                $matches = [];
78                preg_match( '/(?<tag>[^<>=]+)((?<comp>>=|<=|[<>=])(?<thresh>.*$))?/', $k, $matches );
79                $comp = null;
80                $threshold = null;
81                $tag = $k;
82                if ( isset( $matches['comp'] ) ) {
83                    $invalidThreshold = false;
84                    $tag = $matches['tag'];
85                    if ( !is_numeric( $matches['thresh'] ) ) {
86                        $invalidThreshold = true;
87                    } else {
88                        $t = floatval( $matches['thresh'] );
89                        if ( $t <= 1.0 && $t >= 0.0 ) {
90                            $threshold = $t;
91                            $comp = $matches['comp'];
92                        } else {
93                            $invalidThreshold = true;
94                        }
95                    }
96                    if ( $invalidThreshold ) {
97                        $warningCollector->addWarning( self::WARN_MESSAGE_INVALID_THRESHOLD,
98                            Message::plaintextParam( $matches['thresh'] ) );
99                    }
100                }
101                $boostedTag = $this->parseBoost( $tag, $warningCollector );
102                return [
103                    'flag' => $boostedTag['term'],
104                    'comp' => $comp,
105                    'threshold' => $threshold,
106                    'boost' => $boostedTag['boost'],
107                ];
108            },
109            $recFlags
110        );
111        return [ 'recommendationflags' => $recFlags ];
112    }
113
114    /**
115     * @param array[] $parsedValue
116     * @return AbstractQuery|null
117     */
118    private function doGetFilterQuery( array $parsedValue ): ?AbstractQuery {
119        $queries = [];
120        foreach ( $parsedValue['recommendationflags'] as $recFlag ) {
121            $tagValue = "recommendation." . $recFlag['flag'] . '/exists';
122            if ( $recFlag['comp'] ) {
123                $queries[] = new TermFreq(
124                    WeightedTagsHooks::FIELD_NAME,
125                    $tagValue,
126                    $recFlag['comp'],
127                    (int)( $recFlag['threshold'] * $this->maxScore )
128                );
129            } else {
130                $queries[] = ( new MatchQuery() )->setFieldQuery( WeightedTagsHooks::FIELD_NAME, $tagValue );
131            }
132        }
133        return Filters::booleanOr( $queries, false );
134    }
135
136    /**
137     * @param array $parsedValue
138     * @return AbstractQuery|\Elastica\Query\BoolQuery|\Elastica\Query\MatchAll|null
139     */
140    private function doGetRankedQueries( array $parsedValue ) {
141        $queries = [];
142        foreach ( $parsedValue['recommendationflags'] as $recFlag ) {
143            $tagValue = "recommendation." . $recFlag['flag'] . '/exists';
144            if ( $recFlag['boost'] !== null ) {
145                $queries[] = ( new MatchQuery() )
146                    ->setFieldQuery( WeightedTagsHooks::FIELD_NAME, $tagValue )
147                    ->setFieldBoost( WeightedTagsHooks::FIELD_NAME, $recFlag['boost'] );
148            }
149        }
150        return Filters::booleanOr( $queries, false );
151    }
152
153    /**
154     * @param KeywordFeatureNode $node
155     * @param QueryBuildingContext $context
156     * @return AbstractQuery|null
157     */
158    public function getFilterQuery( KeywordFeatureNode $node, QueryBuildingContext $context ): ?AbstractQuery {
159        return $this->doGetFilterQuery( $node->getParsedValue() );
160    }
161
162}