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