Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 117
0.00% covered (danger)
0.00%
0 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
SearchSqlite
0.00% covered (danger)
0.00%
0 / 116
0.00% covered (danger)
0.00%
0 / 14
1190
0.00% covered (danger)
0.00%
0 / 1
 fulltextSearchSupported
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 parseQuery
0.00% covered (danger)
0.00%
0 / 45
0.00% covered (danger)
0.00%
0 / 1
156
 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 / 4
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 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 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 / 6
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 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 getCountQueryBuilder
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 update
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
6
 updateTitle
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2/**
3 * SQLite search backend, based upon SearchMysql
4 *
5 * @license GPL-2.0-or-later
6 * @file
7 * @ingroup Search
8 */
9
10namespace MediaWiki\Search;
11
12use MediaWiki\MediaWikiServices;
13use Wikimedia\Rdbms\SelectQueryBuilder;
14
15/**
16 * Search engine hook for SQLite
17 * @ingroup Search
18 */
19class SearchSqlite extends SearchDatabase {
20    /**
21     * Whether fulltext search is supported by the current schema
22     */
23    private function fulltextSearchSupported(): bool {
24        $dbr = $this->dbProvider->getReplicaDatabase();
25        $sql = (string)$dbr->newSelectQueryBuilder()
26            ->select( 'sql' )
27            ->from( 'sqlite_master' )
28            ->where( [ 'tbl_name' => $dbr->tableName( 'searchindex', 'raw' ) ] )
29            ->caller( __METHOD__ )->fetchField();
30
31        return ( stristr( $sql, 'fts' ) !== false );
32    }
33
34    /**
35     * Parse the user's query and transform it into an SQL fragment which will
36     * become part of a WHERE clause
37     */
38    private function parseQuery( string $filteredText, bool $fulltext ): string {
39        $lc = $this->legalSearchChars( self::CHARS_NO_SYNTAX ); // Minus syntax chars (" and *)
40        $searchon = '';
41        $this->searchTerms = [];
42
43        $m = [];
44        if ( preg_match_all( '/([-+<>~]?)(([' . $lc . ']+)(\*?)|"[^"]*")/',
45                $filteredText, $m, PREG_SET_ORDER ) ) {
46            foreach ( $m as $bits ) {
47                // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
48                @[ /* all */, $modifier, $term, $nonQuoted, $wildcard ] = $bits;
49
50                if ( $nonQuoted != '' ) {
51                    $term = $nonQuoted;
52                    $quote = '';
53                } else {
54                    $term = str_replace( '"', '', $term );
55                    $quote = '"';
56                }
57
58                if ( $searchon !== '' ) {
59                    $searchon .= ' ';
60                }
61
62                // Some languages such as Serbian store the input form in the search index,
63                // so we may need to search for matches in multiple writing system variants.
64
65                $converter = MediaWikiServices::getInstance()->getLanguageConverterFactory()
66                    ->getLanguageConverter();
67                $convertedVariants = $converter->autoConvertToAllVariants( $term );
68                if ( is_array( $convertedVariants ) ) {
69                    $variants = array_unique( array_values( $convertedVariants ) );
70                } else {
71                    $variants = [ $term ];
72                }
73
74                // The low-level search index does some processing on input to work
75                // around problems with minimum lengths and encoding in MySQL's
76                // fulltext engine.
77                // For Chinese this also inserts spaces between adjacent Han characters.
78                $strippedVariants = array_map(
79                    MediaWikiServices::getInstance()->getContentLanguage()->normalizeForSearch( ... ),
80                    $variants );
81
82                // Some languages such as Chinese force all variants to a canonical
83                // form when stripping to the low-level search index, so to be sure
84                // let's check our variants list for unique items after stripping.
85                $strippedVariants = array_unique( $strippedVariants );
86
87                $searchon .= $modifier;
88                if ( count( $strippedVariants ) > 1 ) {
89                    $searchon .= '(';
90                }
91                $count = 0;
92                foreach ( $strippedVariants as $stripped ) {
93                    if ( $nonQuoted && str_contains( $stripped, ' ' ) ) {
94                        // Hack for Chinese: we need to toss in quotes for
95                        // multiple-character phrases since normalizeForSearch()
96                        // added spaces between them to make word breaks.
97                        $stripped = '"' . trim( $stripped ) . '"';
98                    }
99                    if ( $count > 0 ) {
100                        $searchon .= " OR ";
101                    }
102                    $searchon .= "$quote$stripped$quote$wildcard ";
103                    ++$count;
104                }
105                if ( count( $strippedVariants ) > 1 ) {
106                    $searchon .= ')';
107                }
108
109                // Match individual terms or quoted phrase in result highlighting...
110                // Note that variants will be introduced at a later stage for highlighting!
111                $regexp = $this->regexTerm( $term, $wildcard );
112                $this->searchTerms[] = $regexp;
113            }
114
115        } else {
116            wfDebug( __METHOD__ . ": Can't understand search query '$filteredText'" );
117        }
118
119        $dbr = $this->dbProvider->getReplicaDatabase();
120        $searchon = $dbr->addQuotes( $searchon );
121        $field = $this->getIndexField( $fulltext );
122
123        return " $field MATCH $searchon ";
124    }
125
126    private function regexTerm( string $string, string $wildcard ): string {
127        $regex = preg_quote( $string, '/' );
128        if ( MediaWikiServices::getInstance()->getContentLanguage()->hasWordBreaks() ) {
129            if ( $wildcard ) {
130                // Don't cut off the final bit!
131                $regex = "\b$regex";
132            } else {
133                $regex = "\b$regex\b";
134            }
135        } else {
136            // For Chinese, words may legitimately abut other words in the text literal.
137            // Don't add \b boundary checks... note this could cause false positives
138            // for Latin chars.
139        }
140        return $regex;
141    }
142
143    /** @inheritDoc */
144    public function legalSearchChars( $type = self::CHARS_ALL ) {
145        $searchChars = parent::legalSearchChars( $type );
146        if ( $type === self::CHARS_ALL ) {
147            // " for phrase, * for wildcard
148            $searchChars = "\"*" . $searchChars;
149        }
150        return $searchChars;
151    }
152
153    /** @inheritDoc */
154    protected function doSearchTextInDB( $term ) {
155        return $this->searchInternal( $term, true );
156    }
157
158    /** @inheritDoc */
159    protected function doSearchTitleInDB( $term ) {
160        return $this->searchInternal( $term, false );
161    }
162
163    protected function searchInternal( string $term, bool $fulltext ): ?SqlSearchResultSet {
164        if ( !$this->fulltextSearchSupported() ) {
165            return null;
166        }
167
168        $filteredTerm =
169            $this->filter( MediaWikiServices::getInstance()->getContentLanguage()->lc( $term ) );
170
171        $queryBuilder = $this->getQueryBuilder( $filteredTerm, $fulltext );
172        $resultSet = $queryBuilder->caller( __METHOD__ )->fetchResultSet();
173
174        $countBuilder = $this->getCountQueryBuilder( $filteredTerm, $fulltext );
175        $total = (int)$countBuilder->caller( __METHOD__ )->fetchField();
176
177        return new SqlSearchResultSet( $resultSet, $this->searchTerms, $total );
178    }
179
180    /**
181     * Add namespace conditions
182     */
183    private function queryNamespaces( SelectQueryBuilder $queryBuilder ): void {
184        if ( is_array( $this->namespaces ) ) {
185            if ( count( $this->namespaces ) === 0 ) {
186                $this->namespaces[] = NS_MAIN;
187            }
188            $queryBuilder->andWhere( [ 'page_namespace' => $this->namespaces ] );
189        }
190    }
191
192    /**
193     * Construct the SQL query builder to do the search.
194     */
195    private function getQueryBuilder( string $filteredTerm, bool $fulltext ): SelectQueryBuilder {
196        $queryBuilder = $this->dbProvider->getReplicaDatabase()->newSelectQueryBuilder();
197
198        $this->queryMain( $queryBuilder, $filteredTerm, $fulltext );
199        $this->queryNamespaces( $queryBuilder );
200        $queryBuilder->limit( $this->limit )
201            ->offset( $this->offset );
202
203        return $queryBuilder;
204    }
205
206    /**
207     * Picks which field to index on, depending on what type of query.
208     */
209    private function getIndexField( bool $fulltext ): string {
210        return $fulltext ? 'si_text' : 'si_title';
211    }
212
213    /**
214     * Get the base part of the search query using a builder.
215     */
216    private function queryMain( SelectQueryBuilder $queryBuilder, string $filteredTerm, bool $fulltext ): void {
217        $match = $this->parseQuery( $filteredTerm, $fulltext );
218        $queryBuilder->select( [ 'page_id', 'page_namespace', 'page_title' ] )
219            ->from( 'page' )
220            ->join( 'searchindex', null, 'page_id=searchindex.rowid' )
221            ->where( $match );
222    }
223
224    /**
225     * Build a count query for total matches
226     */
227    private function getCountQueryBuilder( string $filteredTerm, bool $fulltext ): SelectQueryBuilder {
228        $match = $this->parseQuery( $filteredTerm, $fulltext );
229        $queryBuilder = $this->dbProvider->getReplicaDatabase()->newSelectQueryBuilder()
230            ->select( 'COUNT(*)' )
231            ->from( 'page' )
232            ->join( 'searchindex', null, 'page_id=searchindex.rowid' )
233            ->where( $match );
234
235        $this->queryNamespaces( $queryBuilder );
236        return $queryBuilder;
237    }
238
239    /** @inheritDoc */
240    public function update( $id, $title, $text ): void {
241        if ( !$this->fulltextSearchSupported() ) {
242            return;
243        }
244        // @todo find a method to do it in a single request,
245        // couldn't do it so far due to typelessness of FTS3 tables.
246        $dbw = $this->dbProvider->getPrimaryDatabase();
247        $dbw->newDeleteQueryBuilder()
248            ->deleteFrom( 'searchindex' )
249            ->where( [ 'rowid' => $id ] )
250            ->caller( __METHOD__ )->execute();
251        $dbw->newInsertQueryBuilder()
252            ->insertInto( 'searchindex' )
253            ->row( [ 'rowid' => $id, 'si_title' => $title, 'si_text' => $text ] )
254            ->caller( __METHOD__ )->execute();
255    }
256
257    /** @inheritDoc */
258    public function updateTitle( $id, $title ): void {
259        if ( !$this->fulltextSearchSupported() ) {
260            return;
261        }
262
263        $dbw = $this->dbProvider->getPrimaryDatabase();
264        $dbw->newUpdateQueryBuilder()
265            ->update( 'searchindex' )
266            ->set( [ 'si_title' => $title ] )
267            ->where( [ 'rowid' => $id ] )
268            ->caller( __METHOD__ )->execute();
269    }
270}
271
272/** @deprecated class alias since 1.46 */
273class_alias( SearchSqlite::class, 'SearchSqlite' );