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