Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
49.28% covered (danger)
49.28%
34 / 69
22.22% covered (danger)
22.22%
4 / 18
CRAP
0.00% covered (danger)
0.00%
0 / 1
SearchSuggestionSet
49.28% covered (danger)
49.28%
34 / 69
22.22% covered (danger)
22.22%
4 / 18
260.39
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
20
 hasMoreResults
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getSuggestions
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 map
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 filter
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 append
75.00% covered (warning)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
6.56
 appendAll
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 rescore
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 prepend
75.00% covered (warning)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
6.56
 remove
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 getBestScore
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 getWorstScore
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 getSize
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 shrink
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 buildPageMap
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 fromTitles
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 fromStrings
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 emptySuggestionSet
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3/**
4 * Search suggestion sets
5 *
6 * @license GPL-2.0-or-later
7 */
8
9use MediaWiki\Title\Title;
10
11/**
12 * A set of search suggestions.
13 * The set is always ordered by score, with the best match first.
14 */
15class SearchSuggestionSet {
16    /**
17     * @var SearchSuggestion[]
18     */
19    private $suggestions = [];
20
21    /**
22     * @var array
23     */
24    private $pageMap = [];
25
26    /**
27     * @var bool Are more results available?
28     */
29    private $hasMoreResults;
30
31    /**
32     * Builds a new set of suggestions.
33     *
34     * NOTE: the array should be sorted by score (higher is better),
35     * in descending order.
36     * SearchSuggestionSet will not try to re-order this input array.
37     * Providing an unsorted input array is a mistake and will lead to
38     * unexpected behaviors.
39     *
40     * @param SearchSuggestion[] $suggestions (must be sorted by score)
41     * @param bool $hasMoreResults Are more results available?
42     */
43    public function __construct( array $suggestions, $hasMoreResults = false ) {
44        $this->hasMoreResults = $hasMoreResults;
45        foreach ( $suggestions as $suggestion ) {
46            $pageID = $suggestion->getSuggestedTitleID();
47            if ( $pageID && empty( $this->pageMap[$pageID] ) ) {
48                $this->pageMap[$pageID] = true;
49            }
50            $this->suggestions[] = $suggestion;
51        }
52    }
53
54    /**
55     * @return bool Are more results available?
56     */
57    public function hasMoreResults() {
58        return $this->hasMoreResults;
59    }
60
61    /**
62     * Get the list of suggestions.
63     * @return SearchSuggestion[]
64     */
65    public function getSuggestions() {
66        return $this->suggestions;
67    }
68
69    /**
70     * Call array_map on the suggestions array
71     * @param callable $callback
72     * @return array
73     */
74    public function map( $callback ) {
75        return array_map( $callback, $this->suggestions );
76    }
77
78    /**
79     * Filter the suggestions array
80     * @param callable $callback Callable accepting single SearchSuggestion
81     *  instance returning bool false to remove the item.
82     * @return int The number of suggestions removed
83     */
84    public function filter( $callback ) {
85        $before = count( $this->suggestions );
86        $this->suggestions = array_values( array_filter( $this->suggestions, $callback ) );
87        return $before - count( $this->suggestions );
88    }
89
90    /**
91     * Add a new suggestion at the end.
92     * If the score of the new suggestion is greater than the worst one,
93     * the new suggestion score will be updated (worst - 1).
94     */
95    public function append( SearchSuggestion $suggestion ) {
96        $pageID = $suggestion->getSuggestedTitleID();
97        if ( $pageID && isset( $this->pageMap[$pageID] ) ) {
98            return;
99        }
100        if ( $this->getSize() > 0 && $suggestion->getScore() >= $this->getWorstScore() ) {
101            $suggestion->setScore( $this->getWorstScore() - 1 );
102        }
103        $this->suggestions[] = $suggestion;
104        if ( $pageID ) {
105            $this->pageMap[$pageID] = true;
106        }
107    }
108
109    /**
110     * Add suggestion set to the end of the current one.
111     */
112    public function appendAll( SearchSuggestionSet $set ) {
113        foreach ( $set->getSuggestions() as $sugg ) {
114            $this->append( $sugg );
115        }
116    }
117
118    /**
119     * Move the suggestion at index $key to the first position
120     * @param int $key
121     */
122    public function rescore( $key ) {
123        $removed = array_splice( $this->suggestions, $key, 1 );
124        unset( $this->pageMap[$removed[0]->getSuggestedTitleID()] );
125        $this->prepend( $removed[0] );
126    }
127
128    /**
129     * Add a new suggestion at the top. If the new suggestion score
130     * is lower than the best one its score will be updated (best + 1)
131     */
132    public function prepend( SearchSuggestion $suggestion ) {
133        $pageID = $suggestion->getSuggestedTitleID();
134        if ( $pageID && isset( $this->pageMap[$pageID] ) ) {
135            return;
136        }
137        if ( $this->getSize() > 0 && $suggestion->getScore() <= $this->getBestScore() ) {
138            $suggestion->setScore( $this->getBestScore() + 1 );
139        }
140        array_unshift( $this->suggestions, $suggestion );
141        if ( $pageID ) {
142            $this->pageMap[$pageID] = true;
143        }
144    }
145
146    /**
147     * Remove a suggestion from the set.
148     * Removes the first suggestion that has the same article id or the same suggestion text.
149     * @param SearchSuggestion $suggestion
150     * @return bool true if something was removed
151     */
152    public function remove( SearchSuggestion $suggestion ): bool {
153        foreach ( $this->suggestions as $k => $s ) {
154            $titleId = $s->getSuggestedTitleID();
155            if ( ( $titleId != null && $titleId === $suggestion->getSuggestedTitleID() )
156                || $s->getText() === $suggestion->getText()
157            ) {
158                array_splice( $this->suggestions, $k, 1 );
159                unset( $this->pageMap[$s->getSuggestedTitleID()] );
160                return true;
161            }
162        }
163        return false;
164    }
165
166    /**
167     * @return float the best score in this suggestion set
168     */
169    public function getBestScore() {
170        if ( !$this->suggestions ) {
171            return 0;
172        }
173        return $this->suggestions[0]->getScore();
174    }
175
176    /**
177     * @return float the worst score in this set
178     */
179    public function getWorstScore() {
180        if ( !$this->suggestions ) {
181            return 0;
182        }
183        return end( $this->suggestions )->getScore();
184    }
185
186    /**
187     * @return int the number of suggestion in this set
188     */
189    public function getSize() {
190        return count( $this->suggestions );
191    }
192
193    /**
194     * Remove any extra elements in the suggestions set
195     * @param int $limit the max size of this set.
196     */
197    public function shrink( $limit ) {
198        if ( count( $this->suggestions ) > $limit ) {
199            $this->suggestions = array_slice( $this->suggestions, 0, $limit );
200            $this->pageMap = self::buildPageMap( $this->suggestions );
201            $this->hasMoreResults = true;
202        }
203    }
204
205    /**
206     * Build an array of true with the page ids present in $suggestion as keys.
207     *
208     * @param array $suggestions
209     * @return array<int,bool>
210     */
211    private static function buildPageMap( array $suggestions ): array {
212        $pageMap = [];
213        foreach ( $suggestions as $suggestion ) {
214            $pageID = $suggestion->getSuggestedTitleID();
215            if ( $pageID ) {
216                $pageMap[$pageID] = true;
217            }
218        }
219        return $pageMap;
220    }
221
222    /**
223     * Builds a new set of suggestion based on a title array.
224     * Useful when using a backend that supports only Titles.
225     *
226     * NOTE: Suggestion scores will be generated.
227     *
228     * @param Title[] $titles
229     * @param bool $hasMoreResults Are more results available?
230     * @return SearchSuggestionSet
231     */
232    public static function fromTitles( array $titles, $hasMoreResults = false ) {
233        $score = count( $titles );
234        $suggestions = array_map( static function ( $title ) use ( &$score ) {
235            return SearchSuggestion::fromTitle( $score--, $title );
236        }, $titles );
237        return new SearchSuggestionSet( $suggestions, $hasMoreResults );
238    }
239
240    /**
241     * Builds a new set of suggestion based on a string array.
242     *
243     * NOTE: Suggestion scores will be generated.
244     *
245     * @param string[] $titles
246     * @param bool $hasMoreResults Are more results available?
247     * @return SearchSuggestionSet
248     */
249    public static function fromStrings( array $titles, $hasMoreResults = false ) {
250        $score = count( $titles );
251        $suggestions = array_map( static function ( $title ) use ( &$score ) {
252            return SearchSuggestion::fromText( $score--, $title );
253        }, $titles );
254        return new SearchSuggestionSet( $suggestions, $hasMoreResults );
255    }
256
257    /**
258     * @return SearchSuggestionSet an empty suggestion set
259     */
260    public static function emptySuggestionSet() {
261        return new SearchSuggestionSet( [] );
262    }
263}