Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 155
0.00% covered (danger)
0.00%
0 / 19
CRAP
0.00% covered (danger)
0.00%
0 / 1
SearchMySQL
0.00% covered (danger)
0.00%
0 / 155
0.00% covered (danger)
0.00%
0 / 19
2256
0.00% covered (danger)
0.00%
0 / 1
 parseQuery
0.00% covered (danger)
0.00%
0 / 50
0.00% covered (danger)
0.00%
0 / 1
182
 regexTerm
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 legalSearchChars
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 doSearchTextInDB
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 doSearchTitleInDB
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 searchInternal
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 supports
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
 queryFeatures
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
20
 queryNamespaces
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 getQueryBuilder
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 getIndexField
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 queryMain
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 getCountQueryBuilder
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 update
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 updateTitle
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 delete
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 normalizeText
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
6
 stripForSearchCallback
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 minSearchLength
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
20
1<?php
2/**
3 * MySQL search engine
4 *
5 * Copyright (C) 2004 Brooke Vibber <bvibber@wikimedia.org>
6 * https://www.mediawiki.org/
7 *
8 * @license GPL-2.0-or-later
9 * @file
10 * @ingroup Search
11 */
12
13use MediaWiki\MediaWikiServices;
14use Wikimedia\AtEase\AtEase;
15use Wikimedia\Rdbms\IDatabase;
16use Wikimedia\Rdbms\IExpression;
17use Wikimedia\Rdbms\LikeValue;
18use Wikimedia\Rdbms\SelectQueryBuilder;
19
20/**
21 * Search engine hook for MySQL
22 * @ingroup Search
23 */
24class SearchMySQL extends SearchDatabase {
25    /** @var bool */
26    protected $strictMatching = true;
27
28    /** @var int|null */
29    private static $mMinSearchLength;
30
31    /**
32     * Parse the user's query and transform it into two SQL fragments:
33     * a WHERE condition and an ORDER BY expression
34     *
35     * @param string $filteredText
36     * @param bool $fulltext
37     *
38     * @return array
39     */
40    private function parseQuery( $filteredText, $fulltext ) {
41        $lc = $this->legalSearchChars( self::CHARS_NO_SYNTAX ); // Minus syntax chars (" and *)
42        $searchon = '';
43        $this->searchTerms = [];
44
45        # @todo FIXME: This doesn't handle parenthetical expressions.
46        $m = [];
47        if ( preg_match_all( '/([-+<>~]?)(([' . $lc . ']+)(\*?)|"[^"]*")/',
48                $filteredText, $m, PREG_SET_ORDER )
49        ) {
50            $services = MediaWikiServices::getInstance();
51            $contLang = $services->getContentLanguage();
52            $langConverter = $services->getLanguageConverterFactory()->getLanguageConverter( $contLang );
53            foreach ( $m as $bits ) {
54                AtEase::suppressWarnings();
55                [ /* all */, $modifier, $term, $nonQuoted, $wildcard ] = $bits;
56                AtEase::restoreWarnings();
57
58                if ( $nonQuoted != '' ) {
59                    $term = $nonQuoted;
60                    $quote = '';
61                } else {
62                    $term = str_replace( '"', '', $term );
63                    $quote = '"';
64                }
65
66                if ( $searchon !== '' ) {
67                    $searchon .= ' ';
68                }
69                if ( $this->strictMatching && ( $modifier == '' ) ) {
70                    // If we leave this out, boolean op defaults to OR which is rarely helpful.
71                    $modifier = '+';
72                }
73
74                // Some languages such as Serbian store the input form in the search index,
75                // so we may need to search for matches in multiple writing system variants.
76                $convertedVariants = $langConverter->autoConvertToAllVariants( $term );
77                if ( is_array( $convertedVariants ) ) {
78                    $variants = array_unique( array_values( $convertedVariants ) );
79                } else {
80                    $variants = [ $term ];
81                }
82
83                // The low-level search index does some processing on input to work
84                // around problems with minimum lengths and encoding in MySQL's
85                // fulltext engine.
86                // For Chinese this also inserts spaces between adjacent Han characters.
87                $strippedVariants = array_map( $contLang->normalizeForSearch( ... ), $variants );
88
89                // Some languages such as Chinese force all variants to a canonical
90                // form when stripping to the low-level search index, so to be sure
91                // let's check our variants list for unique items after stripping.
92                $strippedVariants = array_unique( $strippedVariants );
93
94                $searchon .= $modifier;
95                if ( count( $strippedVariants ) > 1 ) {
96                    $searchon .= '(';
97                }
98                foreach ( $strippedVariants as $stripped ) {
99                    $stripped = $this->normalizeText( $stripped );
100                    if ( $nonQuoted && str_contains( $stripped, ' ' ) ) {
101                        // Hack for Chinese: we need to toss in quotes for
102                        // multiple-character phrases since normalizeForSearch()
103                        // added spaces between them to make word breaks.
104                        $stripped = '"' . trim( $stripped ) . '"';
105                    }
106                    $searchon .= "$quote$stripped$quote$wildcard ";
107                }
108                if ( count( $strippedVariants ) > 1 ) {
109                    $searchon .= ')';
110                }
111
112                // Match individual terms or quoted phrase in result highlighting...
113                // Note that variants will be introduced in a later stage for highlighting!
114                $regexp = $this->regexTerm( $term, $wildcard );
115                $this->searchTerms[] = $regexp;
116            }
117            wfDebug( __METHOD__ . ": Would search with '$searchon'" );
118            wfDebug( __METHOD__ . ': Match with /' . implode( '|', $this->searchTerms ) . "/" );
119        } else {
120            wfDebug( __METHOD__ . ": Can't understand search query '{$filteredText}'" );
121        }
122
123        $dbr = $this->dbProvider->getReplicaDatabase();
124        $searchon = $dbr->addQuotes( $searchon );
125        $field = $this->getIndexField( $fulltext );
126        return [
127            " MATCH($field) AGAINST($searchon IN BOOLEAN MODE) ",
128            " MATCH($field) AGAINST($searchon IN NATURAL LANGUAGE MODE) DESC "
129        ];
130    }
131
132    private function regexTerm( string $string, ?string $wildcard ): string {
133        $regex = preg_quote( $string, '/' );
134        if ( MediaWikiServices::getInstance()->getContentLanguage()->hasWordBreaks() ) {
135            if ( $wildcard ) {
136                // Don't cut off the final bit!
137                $regex = "\b$regex";
138            } else {
139                $regex = "\b$regex\b";
140            }
141        } else {
142            // For Chinese, words may legitimately abut other words in the text literal.
143            // Don't add \b boundary checks... note this could cause false positives
144            // for Latin chars.
145        }
146        return $regex;
147    }
148
149    /** @inheritDoc */
150    public function legalSearchChars( $type = self::CHARS_ALL ) {
151        $searchChars = parent::legalSearchChars( $type );
152
153        // In the MediaWiki UI, search strings containing (just) a hyphen are translated into
154        //     MATCH(si_title) AGAINST('+- ' IN BOOLEAN MODE)
155        // which is not valid.
156
157        // From <https://dev.mysql.com/doc/refman/8.0/en/fulltext-boolean.html>:
158        // "InnoDB full-text search does not support... a plus and minus sign combination ('+-')"
159
160        // See also https://phabricator.wikimedia.org/T221560
161        $searchChars = preg_replace( '/\\\\-/', '', $searchChars );
162
163        if ( $type === self::CHARS_ALL ) {
164            // " for phrase, * for wildcard
165            $searchChars = "\"*" . $searchChars;
166        }
167        return $searchChars;
168    }
169
170    /**
171     * Perform a full text search query and return a result set.
172     *
173     * @param string $term Raw search term
174     * @return SqlSearchResultSet|null
175     */
176    protected function doSearchTextInDB( $term ) {
177        return $this->searchInternal( $term, true );
178    }
179
180    /**
181     * Perform a title-only search query and return a result set.
182     *
183     * @param string $term Raw search term
184     * @return SqlSearchResultSet|null
185     */
186    protected function doSearchTitleInDB( $term ) {
187        return $this->searchInternal( $term, false );
188    }
189
190    protected function searchInternal( string $term, bool $fulltext ): ?SqlSearchResultSet {
191        // This seems out of place, why is this called with empty term?
192        if ( trim( $term ) === '' ) {
193            return null;
194        }
195
196        $filteredTerm = $this->filter( $term );
197        $queryBuilder = $this->getQueryBuilder( $filteredTerm, $fulltext );
198        $resultSet = $queryBuilder->caller( __METHOD__ )->fetchResultSet();
199
200        $queryBuilder = $this->getCountQueryBuilder( $filteredTerm, $fulltext );
201        $total = (int)$queryBuilder->caller( __METHOD__ )->fetchField();
202
203        return new SqlSearchResultSet( $resultSet, $this->searchTerms, $total );
204    }
205
206    /** @inheritDoc */
207    public function supports( $feature ) {
208        switch ( $feature ) {
209            case 'title-suffix-filter':
210                return true;
211            default:
212                return parent::supports( $feature );
213        }
214    }
215
216    /**
217     * Add special conditions
218     * @param SelectQueryBuilder $queryBuilder
219     * @since 1.18
220     */
221    protected function queryFeatures( SelectQueryBuilder $queryBuilder ) {
222        foreach ( $this->features as $feature => $value ) {
223            if ( $feature === 'title-suffix-filter' && $value ) {
224                $dbr = $this->dbProvider->getReplicaDatabase();
225                $queryBuilder->andWhere(
226                    $dbr->expr( 'page_title', IExpression::LIKE, new LikeValue( $dbr->anyString(), $value ) )
227                );
228            }
229        }
230    }
231
232    /**
233     * Add namespace conditions
234     * @param SelectQueryBuilder $queryBuilder
235     * @since 1.18 (changed)
236     */
237    private function queryNamespaces( $queryBuilder ) {
238        if ( is_array( $this->namespaces ) ) {
239            if ( count( $this->namespaces ) === 0 ) {
240                $this->namespaces[] = NS_MAIN;
241            }
242            $queryBuilder->andWhere( [ 'page_namespace' => $this->namespaces ] );
243        }
244    }
245
246    /**
247     * Construct the SQL query builder to do the search.
248     * @param string $filteredTerm
249     * @param bool $fulltext
250     * @return SelectQueryBuilder
251     * @since 1.41
252     */
253    private function getQueryBuilder( $filteredTerm, $fulltext ): SelectQueryBuilder {
254        $queryBuilder = $this->dbProvider->getReplicaDatabase()->newSelectQueryBuilder();
255
256        $this->queryMain( $queryBuilder, $filteredTerm, $fulltext );
257        $this->queryFeatures( $queryBuilder );
258        $this->queryNamespaces( $queryBuilder );
259        $queryBuilder->limit( $this->limit )
260            ->offset( $this->offset );
261
262        return $queryBuilder;
263    }
264
265    /**
266     * Picks which field to index on, depending on what type of query.
267     * @param bool $fulltext
268     * @return string
269     */
270    private function getIndexField( $fulltext ) {
271        return $fulltext ? 'si_text' : 'si_title';
272    }
273
274    /**
275     * Get the base part of the search query.
276     *
277     * @param SelectQueryBuilder $queryBuilder Search query builder
278     * @param string $filteredTerm
279     * @param bool $fulltext
280     * @since 1.18 (changed)
281     */
282    private function queryMain( SelectQueryBuilder $queryBuilder, $filteredTerm, $fulltext ) {
283        $match = $this->parseQuery( $filteredTerm, $fulltext );
284        $queryBuilder->select( [ 'page_id', 'page_namespace', 'page_title' ] )
285            ->from( 'page' )
286            ->join( 'searchindex', null, 'page_id=si_page' )
287            ->where( $match[0] )
288            ->orderBy( $match[1] );
289    }
290
291    /**
292     * @since 1.41 (changed)
293     * @param string $filteredTerm
294     * @param bool $fulltext
295     * @return SelectQueryBuilder
296     */
297    private function getCountQueryBuilder( $filteredTerm, $fulltext ): SelectQueryBuilder {
298        $match = $this->parseQuery( $filteredTerm, $fulltext );
299        $queryBuilder = $this->dbProvider->getReplicaDatabase()->newSelectQueryBuilder()
300            ->select( 'COUNT(*)' )
301            ->from( 'page' )
302            ->join( 'searchindex', null, 'page_id=si_page' )
303            ->where( $match[0] );
304
305        $this->queryFeatures( $queryBuilder );
306        $this->queryNamespaces( $queryBuilder );
307
308        return $queryBuilder;
309    }
310
311    /**
312     * Create or update the search index record for the given page.
313     * Title and text should be pre-processed.
314     *
315     * @param int $id
316     * @param string $title
317     * @param string $text
318     */
319    public function update( $id, $title, $text ) {
320        $this->dbProvider->getPrimaryDatabase()->newReplaceQueryBuilder()
321            ->replaceInto( 'searchindex' )
322            ->uniqueIndexFields( [ 'si_page' ] )
323            ->row( [
324                'si_page' => $id,
325                'si_title' => $this->normalizeText( $title ),
326                'si_text' => $this->normalizeText( $text )
327            ] )
328            ->caller( __METHOD__ )->execute();
329    }
330
331    /**
332     * Update a search index record's title only.
333     * Title should be pre-processed.
334     *
335     * @param int $id
336     * @param string $title
337     */
338    public function updateTitle( $id, $title ) {
339        $this->dbProvider->getPrimaryDatabase()->newUpdateQueryBuilder()
340            ->update( 'searchindex' )
341            ->set( [ 'si_title' => $this->normalizeText( $title ) ] )
342            ->where( [ 'si_page' => $id ] )
343            ->caller( __METHOD__ )->execute();
344    }
345
346    /**
347     * Delete an indexed page
348     * Title should be pre-processed.
349     *
350     * @param int $id Page id that was deleted
351     * @param string $title Title of page that was deleted
352     */
353    public function delete( $id, $title ) {
354        $this->dbProvider->getPrimaryDatabase()->newDeleteQueryBuilder()
355            ->deleteFrom( 'searchindex' )
356            ->where( [ 'si_page' => $id ] )
357            ->caller( __METHOD__ )->execute();
358    }
359
360    /**
361     * Converts some characters for MySQL's indexing to grok it correctly,
362     * and pads short words to overcome limitations.
363     * @param string $string
364     * @return string
365     */
366    public function normalizeText( $string ) {
367        $out = parent::normalizeText( $string );
368
369        // MySQL fulltext index doesn't grok utf-8, so we
370        // need to fold cases and convert to hex
371        $out = preg_replace_callback(
372            "/([\\xc0-\\xff][\\x80-\\xbf]*)/",
373            $this->stripForSearchCallback( ... ),
374            MediaWikiServices::getInstance()->getContentLanguage()->lc( $out ) );
375
376        // And to add insult to injury, the default indexing
377        // ignores short words... Pad them so we can pass them
378        // through without reconfiguring the server...
379        $minLength = $this->minSearchLength();
380        if ( $minLength > 1 ) {
381            $n = $minLength - 1;
382            $out = preg_replace(
383                "/\b(\w{1,$n})\b/",
384                "$1u800",
385                $out );
386        }
387
388        // Periods within things like hostnames and IP addresses
389        // are also important -- we want a search for "example.com"
390        // or "192.168.1.1" to work sensibly.
391        // MySQL's search seems to ignore them, so you'd match on
392        // "example.wikipedia.com" and "192.168.83.1" as well.
393        return preg_replace(
394            "/(\w)\.(\w|\*)/u",
395            "$1u82e$2",
396            $out
397        );
398    }
399
400    /**
401     * Armor a case-folded UTF-8 string to get through MySQL's
402     * fulltext search without being mucked up by funny charset
403     * settings or anything else of the sort.
404     * @param array $matches
405     * @return string
406     */
407    protected function stripForSearchCallback( $matches ) {
408        return 'u8' . bin2hex( $matches[1] );
409    }
410
411    /**
412     * Check MySQL server's ft_min_word_len setting so we know
413     * if we need to pad short words...
414     *
415     * @return int
416     */
417    protected function minSearchLength() {
418        if ( self::$mMinSearchLength === null ) {
419            $sql = "SHOW GLOBAL VARIABLES LIKE 'ft\\_min\\_word\\_len'";
420
421            $dbr = $this->dbProvider->getReplicaDatabase();
422            // The real type is still IDatabase, but IReplicaDatabase is used for safety.
423            '@phan-var IDatabase $dbr';
424            // phpcs:ignore MediaWiki.Usage.DbrQueryUsage.DbrQueryFound
425            $result = $dbr->query( $sql, __METHOD__ );
426            $row = $result->fetchObject();
427            $result->free();
428
429            if ( $row && $row->Variable_name == 'ft_min_word_len' ) {
430                self::$mMinSearchLength = intval( $row->Value );
431            } else {
432                self::$mMinSearchLength = 0;
433            }
434        }
435        return self::$mMinSearchLength;
436    }
437}