Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
50.12% covered (warning)
50.12%
206 / 411
19.61% covered (danger)
19.61%
10 / 51
CRAP
0.00% covered (danger)
0.00%
0 / 1
DatabaseSqlite
50.12% covered (warning)
50.12%
206 / 411
19.61% covered (danger)
19.61%
10 / 51
2396.54
0.00% covered (danger)
0.00%
0 / 1
 __construct
85.00% covered (warning)
85.00%
17 / 20
0.00% covered (danger)
0.00%
0 / 1
5.08
 getAttributes
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 newStandaloneInstance
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 getType
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 open
52.94% covered (warning)
52.94%
27 / 51
0.00% covered (danger)
0.00%
0 / 1
51.77
 getDefaultPragmas
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 getDbFilePath
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getLockFileDirectory
60.00% covered (warning)
60.00%
3 / 5
0.00% covered (danger)
0.00%
0 / 1
6.60
 makeLockManager
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
2.01
 closeConnection
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 generateFileName
40.00% covered (danger)
40.00%
4 / 10
0.00% covered (danger)
0.00%
0 / 1
7.46
 generateDatabaseName
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 isProcessMemoryPath
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getFulltextSearchModule
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
12
 attachDatabase
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 doSingleStatementQuery
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 doSelectDomain
81.82% covered (warning)
81.82%
18 / 22
0.00% covered (danger)
0.00%
0 / 1
4.10
 lastInsertId
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 lastError
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 lastErrno
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 tableExists
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
6
 indexInfo
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
20
 indexUnique
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
20
 replace
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
2.00
 textFieldSize
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 wasDeadlock
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 wasReadOnlyError
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isConnectionError
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isKnownStatementRollbackError
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 serverIsReadOnly
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getSoftwareLink
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getServerVersion
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 fieldInfo
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
12
 doBegin
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 strencode
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 encodeBlob
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 decodeBlob
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 addQuotes
86.67% covered (warning)
86.67%
13 / 15
0.00% covered (danger)
0.00%
0 / 1
5.06
 doLockIsFree
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 doLock
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
20
 doUnlock
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 duplicateTableStructure
97.10% covered (success)
97.10%
67 / 69
0.00% covered (danger)
0.00%
0 / 1
9
 listTables
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
30
 truncateTable
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
2
 setTableAliases
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 attachDatabasesFromTableAliases
20.00% covered (danger)
20.00%
1 / 5
0.00% covered (danger)
0.00%
0 / 1
12.19
 databasesAreIndependent
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 doHandleSessionLossPreconnect
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 doFlushSession
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getBindingHandle
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getInsertIdColumnForUpsert
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
20
1<?php
2/**
3 * This program is free software; you can redistribute it and/or modify
4 * it under the terms of the GNU General Public License as published by
5 * the Free Software Foundation; either version 2 of the License, or
6 * (at your option) any later version.
7 *
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 * GNU General Public License for more details.
12 *
13 * You should have received a copy of the GNU General Public License along
14 * with this program; if not, write to the Free Software Foundation, Inc.,
15 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 * http://www.gnu.org/copyleft/gpl.html
17 *
18 * @file
19 */
20namespace Wikimedia\Rdbms;
21
22use FSLockManager;
23use LockManager;
24use NullLockManager;
25use PDO;
26use PDOException;
27use PDOStatement;
28use RuntimeException;
29use Wikimedia\Rdbms\Platform\SqlitePlatform;
30use Wikimedia\Rdbms\Platform\SQLPlatform;
31use Wikimedia\Rdbms\Replication\ReplicationReporter;
32
33/**
34 * This is the SQLite database abstraction layer.
35 *
36 * See docs/sqlite.txt for development notes about MediaWiki's sqlite schema.
37 *
38 * @ingroup Database
39 */
40class DatabaseSqlite extends Database {
41    /** @var string|null Directory for SQLite database files listed under their DB name */
42    protected $dbDir;
43    /** @var string|null Explicit path for the SQLite database file */
44    protected $dbPath;
45    /** @var string Transaction mode */
46    protected $trxMode;
47
48    /** @var PDO|null */
49    protected $conn;
50
51    /** @var LockManager|null (hopefully on the same server as the DB) */
52    protected $lockMgr;
53
54    /** @var string|null */
55    private $version;
56
57    /** @var array List of shared database already attached to this connection */
58    private $sessionAttachedDbs = [];
59
60    /** @var string[] See https://www.sqlite.org/lang_transaction.html */
61    private const VALID_TRX_MODES = [ '', 'DEFERRED', 'IMMEDIATE', 'EXCLUSIVE' ];
62
63    /** @var string[][] */
64    private const VALID_PRAGMAS = [
65        // Optimizations or requirements regarding fsync() usage
66        'synchronous' => [ 'EXTRA', 'FULL', 'NORMAL', 'OFF' ],
67        // Optimizations for TEMPORARY tables
68        'temp_store' => [ 'FILE', 'MEMORY' ],
69        // Optimizations for disk use and page cache
70        'mmap_size' => 'integer',
71        // How many DB pages to keep in memory
72        'cache_size' => 'integer',
73    ];
74
75    /** @var SQLPlatform */
76    protected $platform;
77
78    /**
79     * Additional params include:
80     *   - dbDirectory : directory containing the DB and the lock file directory
81     *   - dbFilePath  : use this to force the path of the DB file
82     *   - trxMode     : one of (deferred, immediate, exclusive)
83     * @param array $params
84     */
85    public function __construct( array $params ) {
86        if ( isset( $params['dbFilePath'] ) ) {
87            $this->dbPath = $params['dbFilePath'];
88            if ( !isset( $params['dbname'] ) || $params['dbname'] === '' ) {
89                $params['dbname'] = self::generateDatabaseName( $this->dbPath );
90            }
91        } elseif ( isset( $params['dbDirectory'] ) ) {
92            $this->dbDir = $params['dbDirectory'];
93        }
94
95        parent::__construct( $params );
96
97        $this->trxMode = strtoupper( $params['trxMode'] ?? '' );
98
99        $this->lockMgr = $this->makeLockManager();
100        $this->platform = new SqlitePlatform(
101            $this,
102            $this->logger,
103            $this->currentDomain,
104            $this->errorLogger
105        );
106        $this->replicationReporter = new ReplicationReporter(
107            $params['topologyRole'],
108            $this->logger,
109            $params['srvCache']
110        );
111    }
112
113    public static function getAttributes() {
114        return [
115            self::ATTR_DB_IS_FILE => true,
116            self::ATTR_DB_LEVEL_LOCKING => true
117        ];
118    }
119
120    /**
121     * @param string $filename
122     * @param array $p Options map; supports:
123     *   - flags       : (same as __construct counterpart)
124     *   - trxMode     : (same as __construct counterpart)
125     *   - dbDirectory : (same as __construct counterpart)
126     * @return DatabaseSqlite
127     * @since 1.25
128     */
129    public static function newStandaloneInstance( $filename, array $p = [] ) {
130        $p['dbFilePath'] = $filename;
131        $p['schema'] = null;
132        $p['tablePrefix'] = '';
133        /** @var DatabaseSqlite $db */
134        $db = ( new DatabaseFactory() )->create( 'sqlite', $p );
135        '@phan-var DatabaseSqlite $db';
136
137        return $db;
138    }
139
140    /**
141     * @return string
142     */
143    public function getType() {
144        return 'sqlite';
145    }
146
147    protected function open( $server, $user, $password, $db, $schema, $tablePrefix ) {
148        $this->close( __METHOD__ );
149
150        // Note that for SQLite, $server, $user, and $pass are ignored
151
152        if ( $schema !== null ) {
153            throw $this->newExceptionAfterConnectError( "Got schema '$schema'; not supported." );
154        }
155
156        if ( $this->dbPath !== null ) {
157            $path = $this->dbPath;
158        } elseif ( $this->dbDir !== null ) {
159            $path = self::generateFileName( $this->dbDir, $db );
160        } else {
161            throw $this->newExceptionAfterConnectError( "DB path or directory required" );
162        }
163
164        // Check if the database file already exists but is non-readable
165        if ( !self::isProcessMemoryPath( $path ) && is_file( $path ) && !is_readable( $path ) ) {
166            throw $this->newExceptionAfterConnectError( 'SQLite database file is not readable' );
167        } elseif ( !in_array( $this->trxMode, self::VALID_TRX_MODES, true ) ) {
168            throw $this->newExceptionAfterConnectError( "Got mode '{$this->trxMode}' for BEGIN" );
169        }
170
171        $attributes = [
172            PDO::ATTR_ERRMODE => PDO::ERRMODE_SILENT,
173            // Starting with PHP 8.1, The SQLite PDO returns proper types instead
174            // of strings or null for everything. We cast every non-null value to
175            // string to restore the old behavior.
176            PDO::ATTR_STRINGIFY_FETCHES => true
177        ];
178        if ( $this->getFlag( self::DBO_PERSISTENT ) ) {
179            // Persistent connections can avoid some schema index reading overhead.
180            // On the other hand, they can cause horrible contention with DBO_TRX.
181            if ( $this->getFlag( self::DBO_TRX ) || $this->getFlag( self::DBO_DEFAULT ) ) {
182                $this->logger->warning(
183                    __METHOD__ . ": ignoring DBO_PERSISTENT due to DBO_TRX or DBO_DEFAULT",
184                    $this->getLogContext()
185                );
186            } else {
187                $attributes[PDO::ATTR_PERSISTENT] = true;
188            }
189        }
190
191        try {
192            // Open the database file, creating it if it does not yet exist
193            $this->conn = new PDO( "sqlite:$path", null, null, $attributes );
194        } catch ( PDOException $e ) {
195            throw $this->newExceptionAfterConnectError( $e->getMessage() );
196        }
197
198        $this->currentDomain = new DatabaseDomain( $db, null, $tablePrefix );
199        $this->platform->setPrefix( $tablePrefix );
200
201        try {
202            // Enforce LIKE to be case sensitive, just like MySQL
203            $query = new Query(
204                'PRAGMA case_sensitive_like = 1',
205                self::QUERY_CHANGE_TRX | self::QUERY_NO_RETRY,
206                'PRAGMA'
207            );
208            $this->query( $query, __METHOD__ );
209            // Set any connection-level custom PRAGMA options
210            $pragmas = array_intersect_key( $this->connectionVariables, self::VALID_PRAGMAS );
211            $pragmas += $this->getDefaultPragmas();
212            foreach ( $pragmas as $name => $value ) {
213                $allowed = self::VALID_PRAGMAS[$name];
214                if (
215                    ( is_array( $allowed ) && in_array( $value, $allowed, true ) ) ||
216                    ( is_string( $allowed ) && gettype( $value ) === $allowed )
217                ) {
218                    $query = new Query(
219                        "PRAGMA $name = $value",
220                        self::QUERY_CHANGE_TRX | self::QUERY_NO_RETRY,
221                        'PRAGMA',
222                        null,
223                        "PRAGMA $name = '?'"
224                    );
225                    $this->query( $query, __METHOD__ );
226                }
227            }
228            $this->attachDatabasesFromTableAliases();
229        } catch ( RuntimeException $e ) {
230            throw $this->newExceptionAfterConnectError( $e->getMessage() );
231        }
232    }
233
234    /**
235     * @return array Map of (name => value) for default values to set via PRAGMA
236     */
237    private function getDefaultPragmas() {
238        $variables = [];
239
240        if ( !$this->cliMode ) {
241            $variables['temp_store'] = 'MEMORY';
242        }
243
244        return $variables;
245    }
246
247    /**
248     * @return string|null SQLite DB file path
249     * @throws DBUnexpectedError
250     * @since 1.25
251     */
252    public function getDbFilePath() {
253        return $this->dbPath ?? self::generateFileName( $this->dbDir, $this->getDBname() );
254    }
255
256    /**
257     * @return string|null Lock file directory
258     */
259    public function getLockFileDirectory() {
260        if ( $this->dbPath !== null && !self::isProcessMemoryPath( $this->dbPath ) ) {
261            return dirname( $this->dbPath ) . '/locks';
262        } elseif ( $this->dbDir !== null && !self::isProcessMemoryPath( $this->dbDir ) ) {
263            return $this->dbDir . '/locks';
264        }
265
266        return null;
267    }
268
269    /**
270     * Initialize/reset the LockManager instance
271     *
272     * @return LockManager
273     */
274    private function makeLockManager(): LockManager {
275        $lockDirectory = $this->getLockFileDirectory();
276        if ( $lockDirectory !== null ) {
277            return new FSLockManager( [
278                'domain' => $this->getDomainID(),
279                'lockDirectory' => $lockDirectory,
280            ] );
281        } else {
282            return new NullLockManager( [ 'domain' => $this->getDomainID() ] );
283        }
284    }
285
286    /**
287     * Does not actually close the connection, just destroys the reference for GC to do its work
288     * @return bool
289     */
290    protected function closeConnection() {
291        $this->conn = null;
292        // Release all locks, via FSLockManager::__destruct, as the base class expects
293        $this->lockMgr = null;
294
295        return true;
296    }
297
298    /**
299     * Generates a database file name. Explicitly public for installer.
300     * @param string $dir Directory where database resides
301     * @param string|bool $dbName Database name (or false from Database::factory, validated here)
302     * @return string
303     * @throws DBUnexpectedError
304     */
305    public static function generateFileName( $dir, $dbName ) {
306        if ( $dir == '' ) {
307            throw new DBUnexpectedError( null, __CLASS__ . ": no DB directory specified" );
308        } elseif ( self::isProcessMemoryPath( $dir ) ) {
309            throw new DBUnexpectedError(
310                null,
311                __CLASS__ . ": cannot use process memory directory '$dir'"
312            );
313        } elseif ( !strlen( $dbName ) ) {
314            throw new DBUnexpectedError( null, __CLASS__ . ": no DB name specified" );
315        }
316
317        return "$dir/$dbName.sqlite";
318    }
319
320    /**
321     * @param string $path
322     * @return string
323     */
324    private static function generateDatabaseName( $path ) {
325        if ( preg_match( '/^(:memory:$|file::memory:)/', $path ) ) {
326            // E.g. "file::memory:?cache=shared" => ":memory":
327            return ':memory:';
328        } elseif ( preg_match( '/^file::([^?]+)\?mode=memory(&|$)/', $path, $m ) ) {
329            // E.g. "file:memdb1?mode=memory" => ":memdb1:"
330            return ":{$m[1]}:";
331        } else {
332            // E.g. "/home/.../some_db.sqlite3" => "some_db"
333            return preg_replace( '/\.sqlite\d?$/', '', basename( $path ) );
334        }
335    }
336
337    /**
338     * @param string $path
339     * @return bool
340     */
341    private static function isProcessMemoryPath( $path ) {
342        return preg_match( '/^(:memory:$|file:(:memory:|[^?]+\?mode=memory(&|$)))/', $path );
343    }
344
345    /**
346     * Returns version of currently supported SQLite fulltext search module or false if none present.
347     * @return string|false
348     */
349    public static function getFulltextSearchModule() {
350        static $cachedResult = null;
351        if ( $cachedResult !== null ) {
352            return $cachedResult;
353        }
354        $cachedResult = false;
355        $table = 'dummy_search_test';
356
357        $db = self::newStandaloneInstance( ':memory:' );
358        if ( $db->query(
359            "CREATE VIRTUAL TABLE $table USING FTS3(dummy_field)",
360            __METHOD__,
361            IDatabase::QUERY_SILENCE_ERRORS
362        ) ) {
363            $cachedResult = 'FTS3';
364        }
365        $db->close( __METHOD__ );
366
367        return $cachedResult;
368    }
369
370    /**
371     * Attaches external database to the connection handle
372     *
373     * @see https://sqlite.org/lang_attach.html
374     *
375     * @param string $name Database name to be used in queries like
376     *   SELECT foo FROM dbname.table
377     * @param bool|string $file Database file name. If omitted, will be generated
378     *   using $name and configured data directory
379     * @param string $fname Calling function name
380     * @return IResultWrapper
381     */
382    public function attachDatabase( $name, $file = false, $fname = __METHOD__ ) {
383        $file = is_string( $file ) ? $file : self::generateFileName( $this->dbDir, $name );
384        $encFile = $this->addQuotes( $file );
385        $query = new Query(
386            "ATTACH DATABASE $encFile AS $name",
387            self::QUERY_CHANGE_TRX,
388            'ATTACH'
389        );
390        return $this->query( $query, $fname );
391    }
392
393    protected function doSingleStatementQuery( string $sql ): QueryStatus {
394        $res = $this->getBindingHandle()->query( $sql );
395        // Note that rowCount() returns 0 for SELECT for SQLite
396        return new QueryStatus(
397            $res instanceof PDOStatement ? new SqliteResultWrapper( $res ) : $res,
398            $res ? $res->rowCount() : 0,
399            $this->lastError(),
400            $this->lastErrno()
401        );
402    }
403
404    protected function doSelectDomain( DatabaseDomain $domain ) {
405        if ( $domain->getSchema() !== null ) {
406            throw new DBExpectedError(
407                $this,
408                __CLASS__ . ": domain '{$domain->getId()}' has a schema component"
409            );
410        }
411
412        $database = $domain->getDatabase();
413        // A null database means "don't care" so leave it as is and update the table prefix
414        if ( $database === null ) {
415            $this->currentDomain = new DatabaseDomain(
416                $this->currentDomain->getDatabase(),
417                null,
418                $domain->getTablePrefix()
419            );
420            $this->platform->setPrefix( $domain->getTablePrefix() );
421
422            return true;
423        }
424
425        if ( $database !== $this->getDBname() ) {
426            throw new DBExpectedError(
427                $this,
428                __CLASS__ . ": cannot change database (got '$database')"
429            );
430        }
431
432        // Update that domain fields on success (no exception thrown)
433        $this->currentDomain = $domain;
434        $this->platform->setPrefix( $domain->getTablePrefix() );
435
436        return true;
437    }
438
439    protected function lastInsertId() {
440        // PDO::lastInsertId yields a string :(
441        return (int)$this->getBindingHandle()->lastInsertId();
442    }
443
444    /**
445     * @return string
446     */
447    public function lastError() {
448        if ( is_object( $this->conn ) ) {
449            $e = $this->conn->errorInfo();
450
451            return $e[2] ?? $this->lastConnectError;
452        }
453
454        return 'No database connection';
455    }
456
457    /**
458     * @return int
459     */
460    public function lastErrno() {
461        if ( is_object( $this->conn ) ) {
462            $info = $this->conn->errorInfo();
463
464            if ( isset( $info[1] ) ) {
465                return $info[1];
466            }
467        }
468
469        return 0;
470    }
471
472    public function tableExists( $table, $fname = __METHOD__ ) {
473        $tableRaw = $this->tableName( $table, 'raw' );
474        if ( isset( $this->sessionTempTables[$tableRaw] ) ) {
475            return true; // already known to exist
476        }
477
478        $encTable = $this->addQuotes( $tableRaw );
479        $query = new Query(
480            "SELECT 1 FROM sqlite_master WHERE type='table' AND name=$encTable",
481            self::QUERY_IGNORE_DBO_TRX | self::QUERY_CHANGE_NONE,
482            'SELECT'
483        );
484        $res = $this->query( $query, __METHOD__ );
485
486        return (bool)$res->numRows();
487    }
488
489    /**
490     * Returns information about an index
491     * Returns false if the index does not exist
492     * - if errors are explicitly ignored, returns NULL on failure
493     *
494     * @param string $table
495     * @param string $index
496     * @param string $fname
497     * @return array|false
498     */
499    public function indexInfo( $table, $index, $fname = __METHOD__ ) {
500        $query = new Query(
501            'PRAGMA index_info(' . $this->addQuotes( $this->platform->indexName( $index ) ) . ')',
502            self::QUERY_IGNORE_DBO_TRX | self::QUERY_CHANGE_NONE,
503            'PRAGMA'
504        );
505        $res = $this->query( $query, $fname );
506        if ( !$res || $res->numRows() == 0 ) {
507            return false;
508        }
509        $info = [];
510        foreach ( $res as $row ) {
511            $info[] = $row->name;
512        }
513
514        return $info;
515    }
516
517    /**
518     * @param string $table
519     * @param string $index
520     * @param string $fname
521     * @return bool|null
522     */
523    public function indexUnique( $table, $index, $fname = __METHOD__ ) {
524        $row = $this->selectRow( 'sqlite_master', '*',
525            [
526                'type' => 'index',
527                'name' => $this->platform->indexName( $index ),
528            ], $fname );
529        if ( !$row || !isset( $row->sql ) ) {
530            return null;
531        }
532
533        // $row->sql will be of the form CREATE [UNIQUE] INDEX ...
534        $indexPos = strpos( $row->sql, 'INDEX' );
535        if ( $indexPos === false ) {
536            return null;
537        }
538        $firstPart = substr( $row->sql, 0, $indexPos );
539        $options = explode( ' ', $firstPart );
540
541        return in_array( 'UNIQUE', $options );
542    }
543
544    public function replace( $table, $uniqueKeys, $rows, $fname = __METHOD__ ) {
545        $this->platform->normalizeUpsertParams( $uniqueKeys, $rows );
546        if ( !$rows ) {
547            return;
548        }
549        $encTable = $this->tableName( $table );
550        [ $sqlColumns, $sqlTuples ] = $this->platform->makeInsertLists( $rows );
551        // https://sqlite.org/lang_insert.html
552        // Note that any auto-increment columns on conflicting rows will be reassigned
553        // due to combined DELETE+INSERT semantics. This will be reflected in insertId().
554        $query = new Query(
555            "REPLACE INTO $encTable ($sqlColumns) VALUES $sqlTuples",
556            self::QUERY_CHANGE_ROWS,
557            'REPLACE',
558            $table
559        );
560        $this->query( $query, $fname );
561    }
562
563    /**
564     * Returns the size of a text field, or -1 for "unlimited"
565     * In SQLite this is SQLITE_MAX_LENGTH, by default 1 GB. No way to query it though.
566     *
567     * @param string $table
568     * @param string $field
569     * @return int
570     */
571    public function textFieldSize( $table, $field ) {
572        wfDeprecated( __METHOD__, '1.43' );
573        return -1;
574    }
575
576    /**
577     * @return bool
578     */
579    public function wasDeadlock() {
580        return $this->lastErrno() == 5; // SQLITE_BUSY
581    }
582
583    /**
584     * @return bool
585     */
586    public function wasReadOnlyError() {
587        return $this->lastErrno() == 8; // SQLITE_READONLY;
588    }
589
590    protected function isConnectionError( $errno ) {
591        return $errno == 17; // SQLITE_SCHEMA;
592    }
593
594    protected function isKnownStatementRollbackError( $errno ) {
595        // ON CONFLICT ROLLBACK clauses make it so that SQLITE_CONSTRAINT error is
596        // ambiguous with regard to whether it implies a ROLLBACK or an ABORT happened.
597        // https://sqlite.org/lang_createtable.html#uniqueconst
598        // https://sqlite.org/lang_conflict.html
599        return false;
600    }
601
602    public function serverIsReadOnly() {
603        $this->assertHasConnectionHandle();
604
605        $path = $this->getDbFilePath();
606
607        return ( !self::isProcessMemoryPath( $path ) && !is_writable( $path ) );
608    }
609
610    /**
611     * @return string Wikitext of a link to the server software's web site
612     */
613    public function getSoftwareLink() {
614        return "[{{int:version-db-sqlite-url}} SQLite]";
615    }
616
617    /**
618     * @return string Version information from the database
619     */
620    public function getServerVersion() {
621        if ( $this->version === null ) {
622            $this->version = $this->getBindingHandle()->getAttribute( PDO::ATTR_SERVER_VERSION );
623        }
624
625        return $this->version;
626    }
627
628    /**
629     * Get information about a given field
630     * Returns false if the field does not exist.
631     *
632     * @param string $table
633     * @param string $field
634     * @return SQLiteField|false False on failure
635     */
636    public function fieldInfo( $table, $field ) {
637        $tableRaw = $this->tableName( $table, 'raw' );
638        $query = new Query(
639            'PRAGMA table_info(' . $this->addQuotes( $tableRaw ) . ')',
640            self::QUERY_IGNORE_DBO_TRX | self::QUERY_CHANGE_NONE,
641            'PRAGMA'
642        );
643        $res = $this->query( $query, __METHOD__ );
644        foreach ( $res as $row ) {
645            if ( $row->name == $field ) {
646                return new SQLiteField( $row, $tableRaw );
647            }
648        }
649
650        return false;
651    }
652
653    protected function doBegin( $fname = '' ) {
654        if ( $this->trxMode != '' ) {
655            $sql = "BEGIN {$this->trxMode}";
656        } else {
657            $sql = 'BEGIN';
658        }
659        $query = new Query( $sql, self::QUERY_CHANGE_TRX, 'BEGIN' );
660        $this->query( $query, $fname );
661    }
662
663    /**
664     * @param string $s
665     * @return string
666     */
667    public function strencode( $s ) {
668        return substr( $this->addQuotes( $s ), 1, -1 );
669    }
670
671    /**
672     * @param string $b
673     * @return Blob
674     */
675    public function encodeBlob( $b ) {
676        return new Blob( $b );
677    }
678
679    /**
680     * @param Blob|string $b
681     * @return string
682     */
683    public function decodeBlob( $b ) {
684        if ( $b instanceof Blob ) {
685            $b = $b->fetch();
686        }
687        if ( $b === null ) {
688            // An empty blob is decoded as null in PHP before PHP 8.1.
689            // It was probably fixed as a side-effect of caa710037e663fd78f67533b29611183090068b2
690            $b = '';
691        }
692
693        return $b;
694    }
695
696    /**
697     * @param string|int|float|null|bool|Blob $s
698     * @return string
699     */
700    public function addQuotes( $s ) {
701        if ( $s instanceof Blob ) {
702            return "x'" . bin2hex( $s->fetch() ) . "'";
703        } elseif ( is_bool( $s ) ) {
704            return (string)(int)$s;
705        } elseif ( is_int( $s ) ) {
706            return (string)$s;
707        } elseif ( strpos( (string)$s, "\0" ) !== false ) {
708            // SQLite doesn't support \0 in strings, so use the hex representation as a workaround.
709            // This is a known limitation of SQLite's mprintf function which PDO
710            // should work around, but doesn't. I have reported this to php.net as bug #63419:
711            // https://bugs.php.net/bug.php?id=63419
712            // There was already a similar report for SQLite3::escapeString, bug #62361:
713            // https://bugs.php.net/bug.php?id=62361
714            // There is an additional bug regarding sorting this data after insert
715            // on older versions of sqlite shipped with ubuntu 12.04
716            // https://phabricator.wikimedia.org/T74367
717            $this->logger->debug(
718                __FUNCTION__ .
719                ': Quoting value containing null byte. ' .
720                'For consistency all binary data should have been ' .
721                'first processed with self::encodeBlob()'
722            );
723            return "x'" . bin2hex( (string)$s ) . "'";
724        } else {
725            return $this->getBindingHandle()->quote( (string)$s );
726        }
727    }
728
729    public function doLockIsFree( string $lockName, string $method ) {
730        // Only locks by this thread will be checked
731        return true;
732    }
733
734    public function doLock( string $lockName, string $method, int $timeout ) {
735        $status = $this->lockMgr->lock( [ $lockName ], LockManager::LOCK_EX, $timeout );
736        if (
737            $this->lockMgr instanceof FSLockManager &&
738            $status->hasMessage( 'lockmanager-fail-openlock' )
739        ) {
740            throw new DBError( $this, "Cannot create directory \"{$this->getLockFileDirectory()}\"" );
741        }
742
743        return $status->isOK() ? microtime( true ) : null;
744    }
745
746    public function doUnlock( string $lockName, string $method ) {
747        return $this->lockMgr->unlock( [ $lockName ], LockManager::LOCK_EX )->isGood();
748    }
749
750    /**
751     * @param string $oldName
752     * @param string $newName
753     * @param bool $temporary
754     * @param string $fname
755     * @return bool|IResultWrapper
756     * @throws RuntimeException
757     */
758    public function duplicateTableStructure(
759        $oldName, $newName, $temporary = false, $fname = __METHOD__
760    ) {
761        $query = new Query(
762            "SELECT sql FROM sqlite_master WHERE tbl_name=" .
763            $this->addQuotes( $oldName ) . " AND type='table'",
764            self::QUERY_IGNORE_DBO_TRX | self::QUERY_CHANGE_NONE,
765            'SELECT'
766        );
767        $res = $this->query( $query, $fname );
768        $obj = $res->fetchObject();
769        if ( !$obj ) {
770            throw new RuntimeException( "Couldn't retrieve structure for table $oldName" );
771        }
772        $sqlCreateTable = $obj->sql;
773        $sqlCreateTable = preg_replace(
774            '/(?<=\W)"?' .
775                preg_quote( trim( $this->platform->addIdentifierQuotes( $oldName ), '"' ), '/' ) .
776                '"?(?=\W)/',
777            $this->platform->addIdentifierQuotes( $newName ),
778            $sqlCreateTable,
779            1
780        );
781        $flags = self::QUERY_CHANGE_SCHEMA | self::QUERY_PSEUDO_PERMANENT;
782        if ( $temporary ) {
783            if ( preg_match( '/^\\s*CREATE\\s+VIRTUAL\\s+TABLE\b/i', $sqlCreateTable ) ) {
784                $this->logger->debug(
785                    "Table $oldName is virtual, can't create a temporary duplicate." );
786            } else {
787                $sqlCreateTable = str_replace(
788                    'CREATE TABLE',
789                    'CREATE TEMPORARY TABLE',
790                    $sqlCreateTable
791                );
792            }
793        }
794
795        $query = new Query(
796            $sqlCreateTable,
797            $flags,
798            $temporary ? 'CREATE TEMPORARY' : 'CREATE',
799            // Use a dot to avoid double-prefixing in Database::getTempTableWrites()
800            '.' . $newName
801        );
802        $res = $this->query( $query, $fname );
803
804        $query = new Query(
805            'PRAGMA INDEX_LIST(' . $this->addQuotes( $oldName ) . ')',
806            self::QUERY_IGNORE_DBO_TRX | self::QUERY_CHANGE_NONE,
807            'PRAGMA'
808        );
809        // Take over indexes
810        $indexList = $this->query( $query, $fname );
811        foreach ( $indexList as $index ) {
812            if ( strpos( $index->name, 'sqlite_autoindex' ) === 0 ) {
813                continue;
814            }
815
816            if ( $index->unique ) {
817                $sqlIndex = 'CREATE UNIQUE INDEX';
818            } else {
819                $sqlIndex = 'CREATE INDEX';
820            }
821            // Try to come up with a new index name, given indexes have database scope in SQLite
822            $indexName = $newName . '_' . $index->name;
823            $sqlIndex .= ' ' . $this->platform->addIdentifierQuotes( $indexName ) .
824                ' ON ' . $this->platform->addIdentifierQuotes( $newName );
825
826            $query = new Query(
827                'PRAGMA INDEX_INFO(' . $this->addQuotes( $index->name ) . ')',
828                self::QUERY_IGNORE_DBO_TRX | self::QUERY_CHANGE_NONE,
829                'PRAGMA'
830            );
831            $indexInfo = $this->query( $query, $fname );
832            $fields = [];
833            foreach ( $indexInfo as $indexInfoRow ) {
834                $fields[$indexInfoRow->seqno] = $this->addQuotes( $indexInfoRow->name );
835            }
836
837            $sqlIndex .= '(' . implode( ',', $fields ) . ')';
838
839            $query = new Query(
840                $sqlIndex,
841                self::QUERY_CHANGE_SCHEMA | self::QUERY_PSEUDO_PERMANENT,
842                'CREATE',
843                $newName
844            );
845            $this->query( $query, __METHOD__ );
846        }
847
848        return $res;
849    }
850
851    /**
852     * List all tables on the database
853     *
854     * @param string|null $prefix Only show tables with this prefix, e.g. mw_
855     * @param string $fname Calling function name
856     *
857     * @return array
858     */
859    public function listTables( $prefix = null, $fname = __METHOD__ ) {
860        $query = new Query(
861            "SELECT name FROM sqlite_master WHERE type = 'table'",
862            self::QUERY_IGNORE_DBO_TRX | self::QUERY_CHANGE_NONE,
863            'SELECT'
864        );
865        $result = $this->query( $query, $fname );
866
867        $endArray = [];
868
869        foreach ( $result as $table ) {
870            $vars = get_object_vars( $table );
871            $table = array_pop( $vars );
872
873            if ( !$prefix || strpos( $table, $prefix ) === 0 ) {
874                if ( strpos( $table, 'sqlite_' ) !== 0 ) {
875                    $endArray[] = $table;
876                }
877            }
878        }
879
880        return $endArray;
881    }
882
883    public function truncateTable( $table, $fname = __METHOD__ ) {
884        $this->startAtomic( $fname );
885        // Use "truncate" optimization; https://www.sqlite.org/lang_delete.html
886        $query = new Query(
887            "DELETE FROM " . $this->tableName( $table ),
888            self::QUERY_CHANGE_SCHEMA,
889            'DELETE',
890            $table
891        );
892        $this->query( $query, $fname );
893
894        $encMasterTable = $this->platform->addIdentifierQuotes( 'sqlite_sequence' );
895        $encSequenceName = $this->addQuotes( $this->tableName( $table, 'raw' ) );
896        $query = new Query(
897            "DELETE FROM $encMasterTable WHERE name = $encSequenceName",
898            self::QUERY_CHANGE_SCHEMA,
899            'DELETE',
900            'sqlite_sequence'
901        );
902        $this->query( $query, $fname );
903
904        $this->endAtomic( $fname );
905    }
906
907    public function setTableAliases( array $aliases ) {
908        parent::setTableAliases( $aliases );
909        if ( $this->isOpen() ) {
910            $this->attachDatabasesFromTableAliases();
911        }
912    }
913
914    /**
915     * Issue ATTATCH statements for all unattached foreign DBs in table aliases
916     */
917    private function attachDatabasesFromTableAliases() {
918        foreach ( $this->platform->getTableAliases() as $params ) {
919            if (
920                $params['dbname'] !== $this->getDBname() &&
921                !isset( $this->sessionAttachedDbs[$params['dbname']] )
922            ) {
923                $this->attachDatabase( $params['dbname'], false, __METHOD__ );
924                $this->sessionAttachedDbs[$params['dbname']] = true;
925            }
926        }
927    }
928
929    public function databasesAreIndependent() {
930        return true;
931    }
932
933    protected function doHandleSessionLossPreconnect() {
934        $this->sessionAttachedDbs = [];
935        // Release all locks, via FSLockManager::__destruct, as the base class expects;
936        $this->lockMgr = null;
937        // Create a new lock manager instance
938        $this->lockMgr = $this->makeLockManager();
939    }
940
941    protected function doFlushSession( $fname ) {
942        // Release all locks, via FSLockManager::__destruct, as the base class expects
943        $this->lockMgr = null;
944        // Create a new lock manager instance
945        $this->lockMgr = $this->makeLockManager();
946    }
947
948    /**
949     * @return PDO
950     */
951    protected function getBindingHandle() {
952        return parent::getBindingHandle();
953    }
954
955    protected function getInsertIdColumnForUpsert( $table ) {
956        $tableRaw = $this->tableName( $table, 'raw' );
957        $query = new Query(
958            'PRAGMA table_info(' . $this->addQuotes( $tableRaw ) . ')',
959            self::QUERY_IGNORE_DBO_TRX | self::QUERY_CHANGE_NONE,
960            'PRAGMA'
961        );
962        $res = $this->query( $query, __METHOD__ );
963        foreach ( $res as $row ) {
964            if ( $row->pk && strtolower( $row->type ) === 'integer' ) {
965                return $row->name;
966            }
967        }
968
969        return null;
970    }
971}