Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
47.56% covered (danger)
47.56%
39 / 82
25.00% covered (danger)
25.00%
2 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
FancyTitleResultsType
47.56% covered (danger)
47.56%
39 / 82
25.00% covered (danger)
25.00%
2 / 8
107.06
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
 getSourceFiltering
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getHighlightingConfiguration
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
6
 transformElasticsearchResult
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 chooseBestTitleOrRedirect
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 createEmptyResult
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 transformOneElasticResult
86.67% covered (warning)
86.67%
26 / 30
0.00% covered (danger)
0.00%
0 / 1
10.24
 resolveRedirectHighlight
83.33% covered (warning)
83.33%
10 / 12
0.00% covered (danger)
0.00%
0 / 1
6.17
1<?php
2
3namespace CirrusSearch\Search;
4
5use CirrusSearch\Searcher;
6use Elastica\ResultSet as ElasticaResultSet;
7use MediaWiki\Logger\LoggerFactory;
8
9/**
10 * Returns titles categorized based on how they matched - redirect or name.
11 */
12class FancyTitleResultsType extends TitleResultsType {
13    /** @var string */
14    private $matchedAnalyzer;
15
16    /**
17     * Build result type.   The matchedAnalyzer is required to detect if the match
18     * was from the title or a redirect (and is kind of a leaky abstraction.)
19     *
20     * @param string $matchedAnalyzer the analyzer used to match the title
21     * @param TitleHelper|null $titleHelper
22     */
23    public function __construct( $matchedAnalyzer, TitleHelper $titleHelper = null ) {
24        parent::__construct( $titleHelper );
25        $this->matchedAnalyzer = $matchedAnalyzer;
26    }
27
28    public function getSourceFiltering() {
29        return [ 'namespace', 'title', 'namespace_text', 'wiki', 'redirect' ];
30    }
31
32    /**
33     * @param array $extraHighlightFields
34     * @return array|null
35     */
36    public function getHighlightingConfiguration( array $extraHighlightFields = [] ) {
37        global $wgCirrusSearchUseExperimentalHighlighter;
38
39        if ( $wgCirrusSearchUseExperimentalHighlighter ) {
40            // This is much less esoteric then the plain highlighter based
41            // invocation but does the same thing.  The magic is that the none
42            // fragmenter still fragments on multi valued fields.
43            $entireValue = [
44                'type' => 'experimental',
45                'fragmenter' => 'none',
46                'number_of_fragments' => 1,
47            ];
48            $manyValues = [
49                'type' => 'experimental',
50                'fragmenter' => 'none',
51                'order' => 'score',
52            ];
53        } else {
54            // This is similar to the FullTextResults type but against the near_match and
55            // with the plain highlighter.  Near match because that is how the field is
56            // queried.  Plain highlighter because we don't want to add the FVH's space
57            // overhead for storing extra stuff and we don't need it for combining fields.
58            $entireValue = [
59                'type' => 'plain',
60                'number_of_fragments' => 0,
61            ];
62            $manyValues = [
63                'type' => 'plain',
64                'fragment_size' => 10000,   // We want the whole value but more than this is crazy
65                'order' => 'score',
66            ];
67        }
68        $manyValues[ 'number_of_fragments' ] = 30;
69        return [
70            'pre_tags' => [ Searcher::HIGHLIGHT_PRE ],
71            'post_tags' => [ Searcher::HIGHLIGHT_POST ],
72            'fields' => [
73                "title.$this->matchedAnalyzer" => $entireValue,
74                "title.{$this->matchedAnalyzer}_asciifolding" => $entireValue,
75                "redirect.title.$this->matchedAnalyzer" => $manyValues,
76                "redirect.title.{$this->matchedAnalyzer}_asciifolding" => $manyValues,
77            ],
78        ];
79    }
80
81    /**
82     * Convert the results to titles.
83     *
84     * @param ElasticaResultSet $resultSet
85     * @return array[] Array of arrays, each with optional keys:
86     *   titleMatch => a title if the title matched
87     *   redirectMatches => an array of redirect matches, one per matched redirect
88     */
89    public function transformElasticsearchResult( ElasticaResultSet $resultSet ) {
90        $results = [];
91        foreach ( $resultSet->getResults() as $r ) {
92            $results[] = $this->transformOneElasticResult( $r );
93        }
94        return $results;
95    }
96
97    /**
98     * Finds best title or redirect
99     * @param array $match array returned by self::transformOneElasticResult
100     * @return \Title|false choose best
101     */
102    public static function chooseBestTitleOrRedirect( array $match ) {
103        // TODO maybe dig around in the redirect matches and find the best one?
104        return $match['titleMatch'] ?? $match['redirectMatches'][0] ?? false;
105    }
106
107    /**
108     * @return array
109     */
110    public function createEmptyResult() {
111        return [];
112    }
113
114    /**
115     * Transform a result from elastic into an array of Titles.
116     *
117     * @param \Elastica\Result $r
118     * @param int[] $namespaces Prefer
119     * @return \Title[] with the following keys :
120     *   titleMatch => a title if the title matched
121     *   redirectMatches => an array of redirect matches, one per matched redirect
122     */
123    public function transformOneElasticResult( \Elastica\Result $r, array $namespaces = [] ) {
124        $title = $this->getTitleHelper()->makeTitle( $r );
125        $highlights = $r->getHighlights();
126        $resultForTitle = [];
127
128        // Now we have to use the highlights to figure out whether it was the title or the redirect
129        // that matched.  It is kind of a shame we can't really give the highlighting to the client
130        // though.
131        if ( isset( $highlights["title.$this->matchedAnalyzer"] ) ) {
132            $resultForTitle['titleMatch'] = $title;
133        } elseif ( isset( $highlights["title.{$this->matchedAnalyzer}_asciifolding"] ) ) {
134            $resultForTitle['titleMatch'] = $title;
135        }
136        $redirectHighlights = [];
137
138        if ( isset( $highlights["redirect.title.$this->matchedAnalyzer"] ) ) {
139            $redirectHighlights = $highlights["redirect.title.$this->matchedAnalyzer"];
140        }
141        if ( isset( $highlights["redirect.title.{$this->matchedAnalyzer}_asciifolding"] ) ) {
142            $redirectHighlights =
143                array_merge( $redirectHighlights,
144                    $highlights["redirect.title.{$this->matchedAnalyzer}_asciifolding"] );
145        }
146        if ( $redirectHighlights !== [] ) {
147            $source = $r->getSource();
148            $docRedirects = [];
149            if ( isset( $source['redirect'] ) ) {
150                foreach ( $source['redirect'] as $docRedir ) {
151                    $docRedirects[$docRedir['title']][] = $docRedir;
152                }
153            }
154            foreach ( $redirectHighlights as $redirectTitleString ) {
155                $resultForTitle['redirectMatches'][] = $this->resolveRedirectHighlight(
156                    $r, $redirectTitleString, $docRedirects, $namespaces );
157            }
158        }
159        if ( $resultForTitle === [] ) {
160            // We're not really sure where the match came from so lets just pretend it was the title.
161            LoggerFactory::getInstance( 'CirrusSearch' )
162                ->warning( "Title search result type hit a match but we can't " .
163                    "figure out what caused the match: {namespace}:{title}",
164                    [ 'namespace' => $r->namespace, 'title' => $r->title ] );
165            $resultForTitle['titleMatch'] = $title;
166        }
167
168        return $resultForTitle;
169    }
170
171    /**
172     * @param \Elastica\Result $r Elasticsearch result
173     * @param string $redirectTitleString Highlighted string returned from elasticsearch
174     * @param array $docRedirects Map from title string to list of redirects from elasticsearch source document
175     * @param int[] $namespaces Prefered namespaces to source redirects from
176     * @return \Title
177     */
178    private function resolveRedirectHighlight( \Elastica\Result $r, $redirectTitleString, array $docRedirects, $namespaces ) {
179        // The match was against a redirect so we should replace the $title with one that
180        // represents the redirect.
181        // The first step is to strip the actual highlighting from the title.
182        $redirectTitleString = str_replace( [ Searcher::HIGHLIGHT_PRE, Searcher::HIGHLIGHT_POST ],
183            '', $redirectTitleString );
184
185        if ( !isset( $docRedirects[$redirectTitleString] ) ) {
186            // Instead of getting the redirect's real namespace we're going to just use the namespace
187            // of the title.  This is not great.
188            // TODO: Should we just bail at this point?
189            return $this->getTitleHelper()->makeRedirectTitle( $r, $redirectTitleString, $r->namespace );
190        }
191
192        $redirs = $docRedirects[$redirectTitleString];
193        if ( count( $redirs ) === 1 ) {
194            // may or may not be the right namespace, but we don't seem to have any other options.
195            return $this->getTitleHelper()->makeRedirectTitle( $r, $redirectTitleString, $redirs[0]['namespace'] );
196        }
197
198        if ( $namespaces ) {
199            foreach ( $redirs as $redir ) {
200                if ( array_search( $redir['namespace'], $namespaces ) ) {
201                    return $this->getTitleHelper()->makeRedirectTitle( $r, $redirectTitleString, $redir['namespace'] );
202                }
203            }
204        }
205        // Multiple redirects with same text from different namespaces, but none of them match the requested namespaces. What now?
206        return $this->getTitleHelper()->makeRedirectTitle( $r, $redirectTitleString, $redirs[0]['namespace'] );
207    }
208}