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