Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
18.92% covered (danger)
18.92%
21 / 111
15.38% covered (danger)
15.38%
2 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
SearchSqlite
19.09% covered (danger)
19.09%
21 / 110
15.38% covered (danger)
15.38%
2 / 13
540.00
0.00% covered (danger)
0.00%
0 / 1
 fulltextSearchSupported
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 parseQuery
0.00% covered (danger)
0.00%
0 / 45
0.00% covered (danger)
0.00%
0 / 1
156
 legalSearchChars
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 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
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
2.00
 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    /** @inheritDoc */
127    public function legalSearchChars( $type = self::CHARS_ALL ) {
128        $searchChars = parent::legalSearchChars( $type );
129        if ( $type === self::CHARS_ALL ) {
130            // " for phrase, * for wildcard
131            $searchChars = "\"*" . $searchChars;
132        }
133        return $searchChars;
134    }
135
136    /** @inheritDoc */
137    protected function doSearchTextInDB( $term ) {
138        return $this->searchInternal( $term, true );
139    }
140
141    /** @inheritDoc */
142    protected function doSearchTitleInDB( $term ) {
143        return $this->searchInternal( $term, false );
144    }
145
146    protected function searchInternal( string $term, bool $fulltext ): ?SqlSearchResultSet {
147        if ( !$this->fulltextSearchSupported() ) {
148            return null;
149        }
150
151        $filteredTerm =
152            $this->filter( MediaWikiServices::getInstance()->getContentLanguage()->lc( $term ) );
153
154        $queryBuilder = $this->getQueryBuilder( $filteredTerm, $fulltext );
155        $resultSet = $queryBuilder->caller( __METHOD__ )->fetchResultSet();
156
157        $countBuilder = $this->getCountQueryBuilder( $filteredTerm, $fulltext );
158        $total = (int)$countBuilder->caller( __METHOD__ )->fetchField();
159
160        return new SqlSearchResultSet( $resultSet, $this->searchTerms, $total );
161    }
162
163    /**
164     * Add namespace conditions
165     */
166    private function queryNamespaces( SelectQueryBuilder $queryBuilder ): void {
167        if ( is_array( $this->namespaces ) ) {
168            if ( count( $this->namespaces ) === 0 ) {
169                $this->namespaces[] = NS_MAIN;
170            }
171            $queryBuilder->andWhere( [ 'page_namespace' => $this->namespaces ] );
172        }
173    }
174
175    /**
176     * Construct the SQL query builder to do the search.
177     */
178    private function getQueryBuilder( string $filteredTerm, bool $fulltext ): SelectQueryBuilder {
179        $queryBuilder = $this->dbProvider->getReplicaDatabase()->newSelectQueryBuilder();
180
181        $this->queryMain( $queryBuilder, $filteredTerm, $fulltext );
182        $this->queryNamespaces( $queryBuilder );
183        $queryBuilder->limit( $this->limit )
184            ->offset( $this->offset );
185
186        return $queryBuilder;
187    }
188
189    /**
190     * Picks which field to index on, depending on what type of query.
191     */
192    private function getIndexField( bool $fulltext ): string {
193        return $fulltext ? 'si_text' : 'si_title';
194    }
195
196    /**
197     * Get the base part of the search query using a builder.
198     */
199    private function queryMain( SelectQueryBuilder $queryBuilder, string $filteredTerm, bool $fulltext ): void {
200        $match = $this->parseQuery( $filteredTerm, $fulltext );
201        $queryBuilder->select( [ 'page_id', 'page_namespace', 'page_title' ] )
202            ->from( 'page' )
203            ->join( 'searchindex', null, 'page_id=searchindex.rowid' )
204            ->where( $match );
205    }
206
207    /**
208     * Build a count query for total matches
209     */
210    private function getCountQueryBuilder( string $filteredTerm, bool $fulltext ): SelectQueryBuilder {
211        $match = $this->parseQuery( $filteredTerm, $fulltext );
212        $queryBuilder = $this->dbProvider->getReplicaDatabase()->newSelectQueryBuilder()
213            ->select( 'COUNT(*)' )
214            ->from( 'page' )
215            ->join( 'searchindex', null, 'page_id=searchindex.rowid' )
216            ->where( $match );
217
218        $this->queryNamespaces( $queryBuilder );
219        return $queryBuilder;
220    }
221
222    /** @inheritDoc */
223    public function update( $id, $title, $text ): void {
224        if ( !$this->fulltextSearchSupported() ) {
225            return;
226        }
227        // @todo find a method to do it in a single request,
228        // couldn't do it so far due to typelessness of FTS3 tables.
229        $dbw = $this->dbProvider->getPrimaryDatabase();
230        $dbw->newDeleteQueryBuilder()
231            ->deleteFrom( 'searchindex' )
232            ->where( [ 'rowid' => $id ] )
233            ->caller( __METHOD__ )->execute();
234        $dbw->newInsertQueryBuilder()
235            ->insertInto( 'searchindex' )
236            ->row( [ 'rowid' => $id, 'si_title' => $title, 'si_text' => $text ] )
237            ->caller( __METHOD__ )->execute();
238    }
239
240    /** @inheritDoc */
241    public function updateTitle( $id, $title ): void {
242        if ( !$this->fulltextSearchSupported() ) {
243            return;
244        }
245
246        $dbw = $this->dbProvider->getPrimaryDatabase();
247        $dbw->newUpdateQueryBuilder()
248            ->update( 'searchindex' )
249            ->set( [ 'si_title' => $title ] )
250            ->where( [ 'rowid' => $id ] )
251            ->caller( __METHOD__ )->execute();
252    }
253}
254
255/** @deprecated class alias since 1.46 */
256class_alias( SearchSqlite::class, 'SearchSqlite' );