Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
68.67% covered (warning)
68.67%
274 / 399
34.04% covered (danger)
34.04%
16 / 47
CRAP
0.00% covered (danger)
0.00%
0 / 1
DatabaseSqlite
68.67% covered (warning)
68.67%
274 / 399
34.04% covered (danger)
34.04%
16 / 47
631.77
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%
13 / 13
100.00% covered (success)
100.00%
1 / 1
3
 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
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 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 * 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->setCurrentDomain( $this->currentDomain );
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|null $dbName Database name (or null 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->setCurrentDomain( $this->currentDomain );
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->setCurrentDomain( $domain );
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        [ $db, $pt ] = $this->platform->getDatabaseAndTableIdentifier( $table );
474        if ( isset( $this->sessionTempTables[$db][$pt] ) ) {
475            return true; // already known to exist
476        }
477
478        $encTable = $this->addQuotes( $pt );
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    public function indexInfo( $table, $index, $fname = __METHOD__ ) {
490        $indexName = $this->platform->indexName( $index );
491        $components = $this->platform->qualifiedTableComponents( $table );
492        $tableRaw = end( $components );
493        $query = new Query(
494            'PRAGMA index_list(' . $this->addQuotes( $tableRaw ) . ')',
495            self::QUERY_IGNORE_DBO_TRX | self::QUERY_CHANGE_NONE,
496            'PRAGMA'
497        );
498        $res = $this->query( $query, $fname );
499
500        foreach ( $res as $row ) {
501            if ( $row->name === $indexName ) {
502                return [ 'unique' => (bool)$row->unique ];
503            }
504        }
505
506        return false;
507    }
508
509    public function replace( $table, $uniqueKeys, $rows, $fname = __METHOD__ ) {
510        $this->platform->normalizeUpsertParams( $uniqueKeys, $rows );
511        if ( !$rows ) {
512            return;
513        }
514        $encTable = $this->tableName( $table );
515        [ $sqlColumns, $sqlTuples ] = $this->platform->makeInsertLists( $rows );
516        // https://sqlite.org/lang_insert.html
517        // Note that any auto-increment columns on conflicting rows will be reassigned
518        // due to combined DELETE+INSERT semantics. This will be reflected in insertId().
519        $query = new Query(
520            "REPLACE INTO $encTable ($sqlColumns) VALUES $sqlTuples",
521            self::QUERY_CHANGE_ROWS,
522            'REPLACE',
523            $table
524        );
525        $this->query( $query, $fname );
526    }
527
528    protected function isConnectionError( $errno ) {
529        return $errno == 17; // SQLITE_SCHEMA;
530    }
531
532    protected function isKnownStatementRollbackError( $errno ) {
533        // ON CONFLICT ROLLBACK clauses make it so that SQLITE_CONSTRAINT error is
534        // ambiguous with regard to whether it implies a ROLLBACK or an ABORT happened.
535        // https://sqlite.org/lang_createtable.html#uniqueconst
536        // https://sqlite.org/lang_conflict.html
537        return false;
538    }
539
540    public function serverIsReadOnly() {
541        $this->assertHasConnectionHandle();
542
543        $path = $this->getDbFilePath();
544
545        return ( !self::isProcessMemoryPath( $path ) && !is_writable( $path ) );
546    }
547
548    /**
549     * @return string Wikitext of a link to the server software's web site
550     */
551    public function getSoftwareLink() {
552        return "[{{int:version-db-sqlite-url}} SQLite]";
553    }
554
555    /**
556     * @return string Version information from the database
557     */
558    public function getServerVersion() {
559        if ( $this->version === null ) {
560            $this->version = $this->getBindingHandle()->getAttribute( PDO::ATTR_SERVER_VERSION );
561        }
562
563        return $this->version;
564    }
565
566    /**
567     * Get information about a given field
568     * Returns false if the field does not exist.
569     *
570     * @param string $table
571     * @param string $field
572     * @return SQLiteField|false False on failure
573     */
574    public function fieldInfo( $table, $field ) {
575        $components = $this->platform->qualifiedTableComponents( $table );
576        $tableRaw = end( $components );
577        $query = new Query(
578            'PRAGMA table_info(' . $this->addQuotes( $tableRaw ) . ')',
579            self::QUERY_IGNORE_DBO_TRX | self::QUERY_CHANGE_NONE,
580            'PRAGMA'
581        );
582        $res = $this->query( $query, __METHOD__ );
583        foreach ( $res as $row ) {
584            if ( $row->name == $field ) {
585                return new SQLiteField( $row, $tableRaw );
586            }
587        }
588
589        return false;
590    }
591
592    protected function doBegin( $fname = '' ) {
593        if ( $this->trxMode != '' ) {
594            $sql = "BEGIN {$this->trxMode}";
595        } else {
596            $sql = 'BEGIN';
597        }
598        $query = new Query( $sql, self::QUERY_CHANGE_TRX, 'BEGIN' );
599        $this->query( $query, $fname );
600    }
601
602    /**
603     * @param string $s
604     * @return string
605     */
606    public function strencode( $s ) {
607        return substr( $this->addQuotes( $s ), 1, -1 );
608    }
609
610    /**
611     * @param string $b
612     * @return Blob
613     */
614    public function encodeBlob( $b ) {
615        return new Blob( $b );
616    }
617
618    /**
619     * @param Blob|string $b
620     * @return string
621     */
622    public function decodeBlob( $b ) {
623        if ( $b instanceof Blob ) {
624            $b = $b->fetch();
625        }
626        if ( $b === null ) {
627            // An empty blob is decoded as null in PHP before PHP 8.1.
628            // It was probably fixed as a side-effect of caa710037e663fd78f67533b29611183090068b2
629            $b = '';
630        }
631
632        return $b;
633    }
634
635    public function addQuotes( $s ) {
636        if ( $s instanceof RawSQLValue ) {
637            return $s->toSql();
638        }
639        if ( $s instanceof Blob ) {
640            return "x'" . bin2hex( $s->fetch() ) . "'";
641        } elseif ( is_bool( $s ) ) {
642            return (string)(int)$s;
643        } elseif ( is_int( $s ) ) {
644            return (string)$s;
645        } elseif ( strpos( (string)$s, "\0" ) !== false ) {
646            // SQLite doesn't support \0 in strings, so use the hex representation as a workaround.
647            // This is a known limitation of SQLite's mprintf function which PDO
648            // should work around, but doesn't. I have reported this to php.net as bug #63419:
649            // https://bugs.php.net/bug.php?id=63419
650            // There was already a similar report for SQLite3::escapeString, bug #62361:
651            // https://bugs.php.net/bug.php?id=62361
652            // There is an additional bug regarding sorting this data after insert
653            // on older versions of sqlite shipped with ubuntu 12.04
654            // https://phabricator.wikimedia.org/T74367
655            $this->logger->debug(
656                __FUNCTION__ .
657                ': Quoting value containing null byte. ' .
658                'For consistency all binary data should have been ' .
659                'first processed with self::encodeBlob()'
660            );
661            return "x'" . bin2hex( (string)$s ) . "'";
662        } else {
663            return $this->getBindingHandle()->quote( (string)$s );
664        }
665    }
666
667    public function doLockIsFree( string $lockName, string $method ) {
668        // Only locks by this thread will be checked
669        return true;
670    }
671
672    public function doLock( string $lockName, string $method, int $timeout ) {
673        $status = $this->lockMgr->lock( [ $lockName ], LockManager::LOCK_EX, $timeout );
674        if (
675            $this->lockMgr instanceof FSLockManager &&
676            $status->hasMessage( 'lockmanager-fail-openlock' )
677        ) {
678            throw new DBError( $this, "Cannot create directory \"{$this->getLockFileDirectory()}\"" );
679        }
680
681        return $status->isOK() ? microtime( true ) : null;
682    }
683
684    public function doUnlock( string $lockName, string $method ) {
685        return $this->lockMgr->unlock( [ $lockName ], LockManager::LOCK_EX )->isGood();
686    }
687
688    /**
689     * @param string $oldName
690     * @param string $newName
691     * @param bool $temporary
692     * @param string $fname
693     * @return bool|IResultWrapper
694     * @throws RuntimeException
695     */
696    public function duplicateTableStructure(
697        $oldName, $newName, $temporary = false, $fname = __METHOD__
698    ) {
699        $query = new Query(
700            "SELECT sql FROM sqlite_master WHERE tbl_name=" .
701            $this->addQuotes( $oldName ) . " AND type='table'",
702            self::QUERY_IGNORE_DBO_TRX | self::QUERY_CHANGE_NONE,
703            'SELECT'
704        );
705        $res = $this->query( $query, $fname );
706        $obj = $res->fetchObject();
707        if ( !$obj ) {
708            throw new RuntimeException( "Couldn't retrieve structure for table $oldName" );
709        }
710        $sqlCreateTable = $obj->sql;
711        $sqlCreateTable = preg_replace(
712            '/(?<=\W)"?' .
713                preg_quote( trim( $this->platform->addIdentifierQuotes( $oldName ), '"' ), '/' ) .
714                '"?(?=\W)/',
715            $this->platform->addIdentifierQuotes( $newName ),
716            $sqlCreateTable,
717            1
718        );
719        $flags = self::QUERY_CHANGE_SCHEMA | self::QUERY_PSEUDO_PERMANENT;
720        if ( $temporary ) {
721            if ( preg_match( '/^\\s*CREATE\\s+VIRTUAL\\s+TABLE\b/i', $sqlCreateTable ) ) {
722                $this->logger->debug(
723                    "Table $oldName is virtual, can't create a temporary duplicate." );
724            } else {
725                $sqlCreateTable = str_replace(
726                    'CREATE TABLE',
727                    'CREATE TEMPORARY TABLE',
728                    $sqlCreateTable
729                );
730            }
731        }
732
733        $query = new Query(
734            $sqlCreateTable,
735            $flags,
736            $temporary ? 'CREATE TEMPORARY' : 'CREATE',
737            // Use a dot to avoid double-prefixing in Database::getTempTableWrites()
738            '.' . $newName
739        );
740        $res = $this->query( $query, $fname );
741
742        $query = new Query(
743            'PRAGMA INDEX_LIST(' . $this->addQuotes( $oldName ) . ')',
744            self::QUERY_IGNORE_DBO_TRX | self::QUERY_CHANGE_NONE,
745            'PRAGMA'
746        );
747        // Take over indexes
748        $indexList = $this->query( $query, $fname );
749        foreach ( $indexList as $index ) {
750            if ( strpos( $index->name, 'sqlite_autoindex' ) === 0 ) {
751                continue;
752            }
753
754            if ( $index->unique ) {
755                $sqlIndex = 'CREATE UNIQUE INDEX';
756            } else {
757                $sqlIndex = 'CREATE INDEX';
758            }
759            // Try to come up with a new index name, given indexes have database scope in SQLite
760            $indexName = $newName . '_' . $index->name;
761            $sqlIndex .= ' ' . $this->platform->addIdentifierQuotes( $indexName ) .
762                ' ON ' . $this->platform->addIdentifierQuotes( $newName );
763
764            $query = new Query(
765                'PRAGMA INDEX_INFO(' . $this->addQuotes( $index->name ) . ')',
766                self::QUERY_IGNORE_DBO_TRX | self::QUERY_CHANGE_NONE,
767                'PRAGMA'
768            );
769            $indexInfo = $this->query( $query, $fname );
770            $fields = [];
771            foreach ( $indexInfo as $indexInfoRow ) {
772                $fields[$indexInfoRow->seqno] = $this->addQuotes( $indexInfoRow->name );
773            }
774
775            $sqlIndex .= '(' . implode( ',', $fields ) . ')';
776
777            $query = new Query(
778                $sqlIndex,
779                self::QUERY_CHANGE_SCHEMA | self::QUERY_PSEUDO_PERMANENT,
780                'CREATE',
781                $newName
782            );
783            $this->query( $query, __METHOD__ );
784        }
785
786        return $res;
787    }
788
789    /**
790     * List all tables on the database
791     *
792     * @param string|null $prefix Only show tables with this prefix, e.g. mw_
793     * @param string $fname Calling function name
794     *
795     * @return array
796     */
797    public function listTables( $prefix = null, $fname = __METHOD__ ) {
798        $query = new Query(
799            "SELECT name FROM sqlite_master WHERE type = 'table'",
800            self::QUERY_IGNORE_DBO_TRX | self::QUERY_CHANGE_NONE,
801            'SELECT'
802        );
803        $result = $this->query( $query, $fname );
804
805        $endArray = [];
806
807        foreach ( $result as $table ) {
808            $vars = get_object_vars( $table );
809            $table = array_pop( $vars );
810
811            if ( !$prefix || strpos( $table, $prefix ) === 0 ) {
812                if ( strpos( $table, 'sqlite_' ) !== 0 ) {
813                    $endArray[] = $table;
814                }
815            }
816        }
817
818        return $endArray;
819    }
820
821    public function truncateTable( $table, $fname = __METHOD__ ) {
822        $this->startAtomic( $fname );
823        // Use "truncate" optimization; https://www.sqlite.org/lang_delete.html
824        $query = new Query(
825            "DELETE FROM " . $this->tableName( $table ),
826            self::QUERY_CHANGE_SCHEMA,
827            'DELETE',
828            $table
829        );
830        $this->query( $query, $fname );
831
832        $encMasterTable = $this->platform->addIdentifierQuotes( 'sqlite_sequence' );
833        $encSequenceName = $this->addQuotes( $this->tableName( $table, 'raw' ) );
834        $query = new Query(
835            "DELETE FROM $encMasterTable WHERE name = $encSequenceName",
836            self::QUERY_CHANGE_SCHEMA,
837            'DELETE',
838            'sqlite_sequence'
839        );
840        $this->query( $query, $fname );
841
842        $this->endAtomic( $fname );
843    }
844
845    public function setTableAliases( array $aliases ) {
846        parent::setTableAliases( $aliases );
847        if ( $this->isOpen() ) {
848            $this->attachDatabasesFromTableAliases();
849        }
850    }
851
852    /**
853     * Issue ATTATCH statements for all unattached foreign DBs in table aliases
854     */
855    private function attachDatabasesFromTableAliases() {
856        foreach ( $this->platform->getTableAliases() as $params ) {
857            if (
858                $params['dbname'] !== $this->getDBname() &&
859                !isset( $this->sessionAttachedDbs[$params['dbname']] )
860            ) {
861                $this->attachDatabase( $params['dbname'], false, __METHOD__ );
862                $this->sessionAttachedDbs[$params['dbname']] = true;
863            }
864        }
865    }
866
867    public function databasesAreIndependent() {
868        return true;
869    }
870
871    protected function doHandleSessionLossPreconnect() {
872        $this->sessionAttachedDbs = [];
873        // Release all locks, via FSLockManager::__destruct, as the base class expects;
874        $this->lockMgr = null;
875        // Create a new lock manager instance
876        $this->lockMgr = $this->makeLockManager();
877    }
878
879    protected function doFlushSession( $fname ) {
880        // Release all locks, via FSLockManager::__destruct, as the base class expects
881        $this->lockMgr = null;
882        // Create a new lock manager instance
883        $this->lockMgr = $this->makeLockManager();
884    }
885
886    /**
887     * @return PDO
888     */
889    protected function getBindingHandle() {
890        return parent::getBindingHandle();
891    }
892
893    protected function getInsertIdColumnForUpsert( $table ) {
894        $components = $this->platform->qualifiedTableComponents( $table );
895        $tableRaw = end( $components );
896        $query = new Query(
897            'PRAGMA table_info(' . $this->addQuotes( $tableRaw ) . ')',
898            self::QUERY_IGNORE_DBO_TRX | self::QUERY_CHANGE_NONE,
899            'PRAGMA'
900        );
901        $res = $this->query( $query, __METHOD__ );
902        foreach ( $res as $row ) {
903            if ( $row->pk && strtolower( $row->type ) === 'integer' ) {
904                return $row->name;
905            }
906        }
907
908        return null;
909    }
910}