Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 32
0.00% covered (danger)
0.00%
0 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
TemplateCollectionFeature
0.00% covered (danger)
0.00%
0 / 32
0.00% covered (danger)
0.00%
0 / 7
156
0.00% covered (danger)
0.00%
0 / 1
 getKeywords
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 __construct
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 addCollection
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 parseValue
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
20
 doApply
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 getFilterQuery
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 doGetFilterQuery
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace GrowthExperiments;
4
5use CirrusSearch\Parser\AST\KeywordFeatureNode;
6use CirrusSearch\Query\Builder\QueryBuildingContext;
7use CirrusSearch\Query\FilterQueryFeature;
8use CirrusSearch\Query\QueryHelper;
9use CirrusSearch\Query\SimpleKeywordFeature;
10use CirrusSearch\Search\Filters;
11use CirrusSearch\Search\SearchContext;
12use CirrusSearch\WarningCollector;
13use Elastica\Query\AbstractQuery;
14use GrowthExperiments\Config\Validation\GrowthConfigValidation;
15use MediaWiki\Linker\LinkTarget;
16use MediaWiki\Title\TitleFactory;
17use MediaWiki\Title\TitleValue;
18use Wikimedia\Assert\Assert;
19
20/**
21 * Like HasTemplateFeature, but operates on a collection of templates from a pre-defined list
22 * instead of directly via user input. The search is case-sensitive, so the templates in the
23 * pre-defined list should be also. HasTemplateFeature isn't extended directly to avoid strong
24 * coupling.
25 */
26class TemplateCollectionFeature extends SimpleKeywordFeature implements FilterQueryFeature {
27
28    /**
29     * ElasticSearch doesn't allow more than 1024 clauses in a boolean query. Limit at 800
30     * to leave some space to combine hastemplatecollection with other search terms.
31     */
32    public const MAX_TEMPLATES_IN_COLLECTION = GrowthConfigValidation::MAX_TEMPLATES_IN_COLLECTION;
33
34    private array $templates;
35    private TitleFactory $titleFactory;
36
37    /** @inheritDoc */
38    protected function getKeywords() {
39        return [ 'hastemplatecollection' ];
40    }
41
42    /**
43     * @param string $collectionName
44     * @param string[]|LinkTarget[] $templates
45     * @param TitleFactory $titleFactory
46     */
47    public function __construct( string $collectionName, array $templates, TitleFactory $titleFactory ) {
48        $this->titleFactory = $titleFactory;
49        $this->addCollection( $collectionName, $templates );
50    }
51
52    /**
53     * @param string $collectionName
54     * @param string[]|LinkTarget[] $templates
55     */
56    public function addCollection( string $collectionName, array $templates ): void {
57        Assert::parameter(
58            count( $templates ) <= self::MAX_TEMPLATES_IN_COLLECTION,
59            '$templates',
60            'Maximum ' . self::MAX_TEMPLATES_IN_COLLECTION . ' templates allowed in collection.'
61        );
62        $this->templates[$collectionName] = $templates;
63    }
64
65    /** @inheritDoc */
66    public function parseValue(
67        $key, $value, $quotedValue, $valueDelimiter, $suffix, WarningCollector $warningCollector
68    ) {
69        // If an undefined collection name is used in the user-provided input, then just return no results.
70        if ( !isset( $this->templates[$value] ) ) {
71            $warningCollector->addWarning( 'growthexperiments-templatecollectionfeature-invalid-collection',
72                $value );
73            return [ 'templates' => [] ];
74        }
75        $templates = [];
76        foreach ( $this->templates[$value] as $template ) {
77            if ( $template instanceof TitleValue ) {
78                $title = $this->titleFactory->newFromLinkTarget( $template );
79            } else {
80                $title = $this->titleFactory->newFromText( $template );
81            }
82            $templates[] = $title->getPrefixedText();
83        }
84        return [ 'templates' => $templates ];
85    }
86
87    /** @inheritDoc */
88    protected function doApply( SearchContext $context, $key, $value, $quotedValue, $negated ) {
89        $filter = $this->doGetFilterQuery(
90            $this->parseValue( $key, $value, $quotedValue, '', '', $context ) );
91        if ( $filter === null && !$negated ) {
92            // If there are no templates in a collection, it shouldn't match anything. If the
93            // keyword is negated, it should be a no-op, so returning null works.
94            $context->setResultsPossible( false );
95        }
96        return [ $filter, false ];
97    }
98
99    /** @inheritDoc */
100    public function getFilterQuery( KeywordFeatureNode $node, QueryBuildingContext $context ) {
101        // TODO handle the null case once CirrusSearch starts using this method.
102        return $this->doGetFilterQuery( $node->getParsedValue() );
103    }
104
105    /**
106     * @param string[][] $parsedValue
107     * @return AbstractQuery|null
108     */
109    protected function doGetFilterQuery( array $parsedValue ) {
110        return Filters::booleanOr( array_map(
111            static function ( $v )  {
112                return QueryHelper::matchPage( 'template.keyword', $v );
113            },
114            $parsedValue['templates']
115        ), false );
116    }
117
118}