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