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