Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
81.82% covered (warning)
81.82%
90 / 110
33.33% covered (danger)
33.33%
2 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
PrefixSearch
81.82% covered (warning)
81.82%
90 / 110
33.33% covered (danger)
33.33%
2 / 6
45.23
0.00% covered (danger)
0.00%
0 / 1
 search
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 searchWithVariants
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
20
 titles
n/a
0 / 0
n/a
0 / 0
0
 strings
n/a
0 / 0
n/a
0 / 0
0
 searchBackend
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
5.01
 handleResultFromHook
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 specialSearch
94.59% covered (success)
94.59%
35 / 37
0.00% covered (danger)
0.00%
0 / 1
14.03
 defaultSearchBackend
94.29% covered (success)
94.29%
33 / 35
0.00% covered (danger)
0.00%
0 / 1
9.02
1<?php
2/**
3 * Prefix search of page names.
4 *
5 * This program is free software; you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation; either version 2 of the License, or
8 * (at your option) any later version.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
14 *
15 * You should have received a copy of the GNU General Public License along
16 * with this program; if not, write to the Free Software Foundation, Inc.,
17 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18 * http://www.gnu.org/copyleft/gpl.html
19 *
20 * @file
21 */
22
23use MediaWiki\HookContainer\HookRunner;
24use MediaWiki\MediaWikiServices;
25use MediaWiki\Title\MediaWikiTitleCodec;
26use MediaWiki\Title\Title;
27use Wikimedia\Rdbms\IExpression;
28use Wikimedia\Rdbms\LikeValue;
29use Wikimedia\Rdbms\OrExpressionGroup;
30
31/**
32 * Handles searching prefixes of titles and finding any page
33 * names that match. Used largely by the OpenSearch implementation.
34 * @deprecated Since 1.27, Use SearchEngine::defaultPrefixSearch or SearchEngine::completionSearch
35 *
36 * @stable to extend
37 * @ingroup Search
38 */
39abstract class PrefixSearch {
40    /**
41     * Do a prefix search of titles and return a list of matching page names.
42     *
43     * @param string $search
44     * @param int $limit
45     * @param array $namespaces Used if query is not explicitly prefixed
46     * @param int $offset How many results to offset from the beginning
47     * @return (Title|string)[]
48     */
49    public function search( $search, $limit, $namespaces = [], $offset = 0 ) {
50        $search = trim( $search );
51        if ( $search == '' ) {
52            return []; // Return empty result
53        }
54
55        $hasNamespace = SearchEngine::parseNamespacePrefixes( $search, false, true );
56        if ( $hasNamespace !== false ) {
57            [ $search, $namespaces ] = $hasNamespace;
58        }
59
60        return $this->searchBackend( $namespaces, $search, $limit, $offset );
61    }
62
63    /**
64     * Do a prefix search for all possible variants of the prefix
65     * @param string $search
66     * @param int $limit
67     * @param array $namespaces
68     * @param int $offset How many results to offset from the beginning
69     *
70     * @return (Title|string)[]
71     */
72    public function searchWithVariants( $search, $limit, array $namespaces, $offset = 0 ) {
73        $searches = $this->search( $search, $limit, $namespaces, $offset );
74
75        // if the content language has variants, try to retrieve fallback results
76        $fallbackLimit = $limit - count( $searches );
77        if ( $fallbackLimit > 0 ) {
78            $services = MediaWikiServices::getInstance();
79            $fallbackSearches = $services->getLanguageConverterFactory()
80                ->getLanguageConverter( $services->getContentLanguage() )
81                ->autoConvertToAllVariants( $search );
82            $fallbackSearches = array_diff( array_unique( $fallbackSearches ), [ $search ] );
83
84            foreach ( $fallbackSearches as $fbs ) {
85                $fallbackSearchResult = $this->search( $fbs, $fallbackLimit, $namespaces );
86                $searches = array_merge( $searches, $fallbackSearchResult );
87                $fallbackLimit -= count( $fallbackSearchResult );
88
89                if ( $fallbackLimit == 0 ) {
90                    break;
91                }
92            }
93        }
94        return $searches;
95    }
96
97    /**
98     * When implemented in a descendant class, receives an array of Title objects and returns
99     * either an unmodified array or an array of strings corresponding to titles passed to it.
100     *
101     * @param Title[] $titles
102     * @return (Title|string)[]
103     */
104    abstract protected function titles( array $titles );
105
106    /**
107     * When implemented in a descendant class, receives an array of titles as strings and returns
108     * either an unmodified array or an array of Title objects corresponding to strings received.
109     *
110     * @param string[] $strings
111     * @return (Title|string)[]
112     */
113    abstract protected function strings( array $strings );
114
115    /**
116     * Do a prefix search of titles and return a list of matching page names.
117     * @param int[] $namespaces
118     * @param string $search
119     * @param int $limit
120     * @param int $offset How many results to offset from the beginning
121     * @return (Title|string)[]
122     */
123    protected function searchBackend( $namespaces, $search, $limit, $offset ) {
124        if ( count( $namespaces ) == 1 ) {
125            $ns = $namespaces[0];
126            if ( $ns == NS_MEDIA ) {
127                $namespaces = [ NS_FILE ];
128            } elseif ( $ns == NS_SPECIAL ) {
129                return $this->titles( $this->specialSearch( $search, $limit, $offset ) );
130            }
131        }
132        $srchres = [];
133        if ( ( new HookRunner( MediaWikiServices::getInstance()->getHookContainer() ) )->onPrefixSearchBackend(
134            $namespaces, $search, $limit, $srchres, $offset )
135        ) {
136            return $this->titles( $this->defaultSearchBackend( $namespaces, $search, $limit, $offset ) );
137        }
138        return $this->strings(
139            $this->handleResultFromHook( $srchres, $namespaces, $search, $limit, $offset ) );
140    }
141
142    private function handleResultFromHook( $srchres, $namespaces, $search, $limit, $offset ) {
143        if ( $offset === 0 ) {
144            // Only perform exact db match if offset === 0
145            // This is still far from perfect but at least we avoid returning the
146            // same title again and again when the user is scrolling with a query
147            // that matches a title in the db.
148            $rescorer = new SearchExactMatchRescorer();
149            $srchres = $rescorer->rescore( $search, $namespaces, $srchres, $limit );
150        }
151        return $srchres;
152    }
153
154    /**
155     * Prefix search special-case for Special: namespace.
156     *
157     * @param string $search Term
158     * @param int $limit Max number of items to return
159     * @param int $offset Number of items to offset
160     * @return array
161     */
162    protected function specialSearch( $search, $limit, $offset ) {
163        $searchParts = explode( '/', $search, 2 );
164        $searchKey = $searchParts[0];
165        $subpageSearch = $searchParts[1] ?? null;
166
167        // Handle subpage search separately.
168        $spFactory = MediaWikiServices::getInstance()->getSpecialPageFactory();
169        if ( $subpageSearch !== null ) {
170            // Try matching the full search string as a page name
171            $specialTitle = Title::makeTitleSafe( NS_SPECIAL, $searchKey );
172            if ( !$specialTitle ) {
173                return [];
174            }
175            $special = $spFactory->getPage( $specialTitle->getText() );
176            if ( $special ) {
177                $subpages = $special->prefixSearchSubpages( $subpageSearch, $limit, $offset );
178                return array_map( [ $specialTitle, 'getSubpage' ], $subpages );
179            } else {
180                return [];
181            }
182        }
183
184        # normalize searchKey, so aliases with spaces can be found - T27675
185        $contLang = MediaWikiServices::getInstance()->getContentLanguage();
186        $searchKey = str_replace( ' ', '_', $searchKey );
187        $searchKey = $contLang->caseFold( $searchKey );
188
189        // Unlike SpecialPage itself, we want the canonical forms of both
190        // canonical and alias title forms...
191        $keys = [];
192        $listedPages = $spFactory->getListedPages();
193        foreach ( $listedPages as $page => $_obj ) {
194            $keys[$contLang->caseFold( $page )] = [ 'page' => $page, 'rank' => 0 ];
195        }
196
197        foreach ( $contLang->getSpecialPageAliases() as $page => $aliases ) {
198            // Exclude localisation aliases for pages that are not defined (T22885),
199            // e.g. if an extension registers a page based on site configuration.
200            if ( !in_array( $page, $spFactory->getNames() ) ) {
201                continue;
202            }
203            // Exclude aliases for unlisted pages
204            if ( !isset( $listedPages[ $page ] ) ) {
205                continue;
206            }
207
208            foreach ( $aliases as $key => $alias ) {
209                $keys[$contLang->caseFold( $alias )] = [ 'page' => $alias, 'rank' => $key ];
210            }
211        }
212        ksort( $keys );
213
214        $matches = [];
215        foreach ( $keys as $pageKey => $page ) {
216            if ( $searchKey === '' || strpos( $pageKey, $searchKey ) === 0 ) {
217                // T29671: Don't use SpecialPage::getTitleFor() here because it
218                // localizes its input leading to searches for e.g. Special:All
219                // returning Spezial:MediaWiki-Systemnachrichten and returning
220                // Spezial:Alle_Seiten twice when $wgLanguageCode == 'de'
221                $matches[$page['rank']][] = Title::makeTitleSafe( NS_SPECIAL, $page['page'] );
222
223                if ( isset( $matches[0] ) && count( $matches[0] ) >= $limit + $offset ) {
224                    // We have enough items in primary rank, no use to continue
225                    break;
226                }
227            }
228
229        }
230
231        // Ensure keys are in order
232        ksort( $matches );
233        // Flatten the array
234        $matches = array_reduce( $matches, 'array_merge', [] );
235
236        return array_slice( $matches, $offset, $limit );
237    }
238
239    /**
240     * Unless overridden by PrefixSearchBackend hook...
241     * This is case-sensitive (First character may
242     * be automatically capitalized by Title::secureAndSpit()
243     * later on depending on $wgCapitalLinks)
244     *
245     * @param int[]|null $namespaces Namespaces to search in
246     * @param string $search Term
247     * @param int $limit Max number of items to return
248     * @param int $offset Number of items to skip
249     * @return Title[]
250     */
251    public function defaultSearchBackend( $namespaces, $search, $limit, $offset ) {
252        if ( !$namespaces ) {
253            $namespaces = [ NS_MAIN ];
254        }
255
256        if ( in_array( NS_SPECIAL, $namespaces ) ) {
257            // For now, if special is included, ignore the other namespaces
258            return $this->specialSearch( $search, $limit, $offset );
259        }
260
261        // Construct suitable prefix for each namespace. They differ in cases where
262        // some namespaces always capitalize and some don't.
263        $prefixes = [];
264        // Allow to do a prefix search for e.g. "Talk:"
265        if ( $search === '' ) {
266            $prefixes[$search] = $namespaces;
267        } else {
268            // Don't just ignore input like "[[Foo]]", but try to search for "Foo"
269            $search = preg_replace( MediaWikiTitleCodec::getTitleInvalidRegex(), '', $search );
270            foreach ( $namespaces as $namespace ) {
271                $title = Title::makeTitleSafe( $namespace, $search );
272                if ( $title ) {
273                    $prefixes[ $title->getDBkey() ][] = $namespace;
274                }
275            }
276        }
277        if ( !$prefixes ) {
278            return [];
279        }
280
281        $services = MediaWikiServices::getInstance();
282        $dbr = $services->getConnectionProvider()->getReplicaDatabase();
283        // Often there is only one prefix that applies to all requested namespaces,
284        // but sometimes there are two if some namespaces do not always capitalize.
285        $conds = [];
286        foreach ( $prefixes as $prefix => $namespaces ) {
287            $expr = $dbr->expr( 'page_namespace', '=', $namespaces );
288            if ( $prefix !== '' ) {
289                $expr = $expr->and(
290                    'page_title',
291                    IExpression::LIKE,
292                    new LikeValue( $prefix, $dbr->anyString() )
293                );
294            }
295            $conds[] = $expr;
296        }
297
298        $queryBuilder = $dbr->newSelectQueryBuilder()
299            ->select( [ 'page_id', 'page_namespace', 'page_title' ] )
300            ->from( 'page' )
301            ->where( new OrExpressionGroup( ...$conds ) )
302            ->orderBy( [ 'page_title', 'page_namespace' ] )
303            ->limit( $limit )
304            ->offset( $offset );
305        $res = $queryBuilder->caller( __METHOD__ )->fetchResultSet();
306
307        return iterator_to_array( $services->getTitleFactory()->newTitleArrayFromResult( $res ) );
308    }
309}