Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 521
0.00% covered (danger)
0.00%
0 / 52
CRAP
0.00% covered (danger)
0.00%
0 / 1
DatabasePostgres
0.00% covered (danger)
0.00%
0 / 521
0.00% covered (danger)
0.00%
0 / 52
19740
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
2
 getType
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 open
0.00% covered (danger)
0.00%
0 / 44
0.00% covered (danger)
0.00%
0 / 1
210
 databasesAreIndependent
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 doSelectDomain
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
12
 makeConnectionString
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 closeConnection
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 doSingleStatementQuery
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
42
 dumpError
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
6
 lastInsertId
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 lastError
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
30
 lastErrno
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 estimateRowCount
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
30
 indexInfo
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
6
 indexAttributes
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
30
 doInsertSelectNative
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
6
 getValueTypesForWithClause
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
20
 isConnectionError
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 isQueryTimeoutError
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
 duplicateTableStructure
0.00% covered (danger)
0.00%
0 / 49
0.00% covered (danger)
0.00%
0 / 1
30
 truncateTable
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 listTables
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
20
 pg_array_parse
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
42
 getSoftwareLink
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getCurrentSchema
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 getSchemas
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 getSearchPath
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 setSearchPath
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 determineCoreSchema
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
20
 getCoreSchema
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getCoreSchemas
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
12
 getServerVersion
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 relationExists
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
30
 tableExists
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 sequenceExists
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 constraintExists
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
20
 schemaExists
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
 roleExists
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 fieldInfo
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 / 2
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
 strencode
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 addQuotes
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
56
 streamStatementEnd
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 doLockIsFree
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 doLock
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
6
 doUnlock
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 doFlushSession
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 serverIsReadOnly
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 getInsertIdColumnForUpsert
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
56
 getAttributes
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
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 */
20
21// Suppress UnusedPluginSuppression because Phan on PHP 7.4 and PHP 8.1 need different suppressions
22// @phan-file-suppress UnusedPluginSuppression,UnusedPluginFileSuppression
23
24namespace Wikimedia\Rdbms;
25
26use RuntimeException;
27use Wikimedia\Rdbms\Platform\PostgresPlatform;
28use Wikimedia\Rdbms\Replication\ReplicationReporter;
29use Wikimedia\WaitConditionLoop;
30
31/**
32 * Postgres database abstraction layer.
33 *
34 * @ingroup Database
35 */
36class DatabasePostgres extends Database {
37    /** @var int */
38    private $port;
39    /** @var string */
40    private $tempSchema;
41    /** @var float|string */
42    private $numericVersion;
43
44    /** @var resource|null */
45    private $lastResultHandle;
46
47    /** @var PostgresPlatform */
48    protected $platform;
49
50    /**
51     * @see Database::__construct()
52     * @param array $params Additional parameters include:
53     *   - port: A port to append to the hostname
54     */
55    public function __construct( array $params ) {
56        $this->port = intval( $params['port'] ?? null );
57        parent::__construct( $params );
58
59        $this->platform = new PostgresPlatform(
60            $this,
61            $this->logger,
62            $this->currentDomain,
63            $this->errorLogger
64        );
65        $this->replicationReporter = new ReplicationReporter(
66            $params['topologyRole'],
67            $this->logger,
68            $params['srvCache']
69        );
70    }
71
72    public function getType() {
73        return 'postgres';
74    }
75
76    protected function open( $server, $user, $password, $db, $schema, $tablePrefix ) {
77        if ( !function_exists( 'pg_connect' ) ) {
78            throw $this->newExceptionAfterConnectError(
79                "Postgres functions missing, have you compiled PHP with the --with-pgsql\n" .
80                "option? (Note: if you recently installed PHP, you may need to restart your\n" .
81                "webserver and database)"
82            );
83        }
84
85        $this->close( __METHOD__ );
86
87        $connectVars = [
88            // A database must be specified in order to connect to Postgres. If $dbName is not
89            // specified, then use the standard "postgres" database that should exist by default.
90            'dbname' => ( $db !== null && $db !== '' ) ? $db : 'postgres',
91            'user' => $user,
92            'password' => $password
93        ];
94        if ( $server !== null && $server !== '' ) {
95            $connectVars['host'] = $server;
96        }
97        if ( $this->port > 0 ) {
98            $connectVars['port'] = $this->port;
99        }
100        if ( $this->ssl ) {
101            $connectVars['sslmode'] = 'require';
102        }
103        $connectString = $this->makeConnectionString( $connectVars );
104
105        $this->installErrorHandler();
106        try {
107            $this->conn = pg_connect( $connectString, PGSQL_CONNECT_FORCE_NEW ) ?: null;
108        } catch ( RuntimeException $e ) {
109            $this->restoreErrorHandler();
110            throw $this->newExceptionAfterConnectError( $e->getMessage() );
111        }
112        $error = $this->restoreErrorHandler();
113
114        if ( !$this->conn ) {
115            throw $this->newExceptionAfterConnectError( $error ?: $this->lastError() );
116        }
117
118        try {
119            // Since no transaction is active at this point, any SET commands should apply
120            // for the entire session (e.g. will not be reverted on transaction rollback).
121            // See https://www.postgresql.org/docs/8.3/sql-set.html
122            $variables = [
123                'client_encoding' => 'UTF8',
124                'datestyle' => 'ISO, YMD',
125                'timezone' => 'GMT',
126                'standard_conforming_strings' => 'on',
127                'bytea_output' => 'escape',
128                'client_min_messages' => 'ERROR'
129            ];
130            foreach ( $variables as $var => $val ) {
131                $sql = 'SET ' . $this->platform->addIdentifierQuotes( $var ) . ' = ' . $this->addQuotes( $val );
132                $query = new Query( $sql, self::QUERY_NO_RETRY | self::QUERY_CHANGE_TRX, 'SET' );
133                $this->query( $query, __METHOD__ );
134            }
135            $this->determineCoreSchema( $schema );
136            $this->currentDomain = new DatabaseDomain( $db, $schema, $tablePrefix );
137            $this->platform->setCurrentDomain( $this->currentDomain );
138        } catch ( RuntimeException $e ) {
139            throw $this->newExceptionAfterConnectError( $e->getMessage() );
140        }
141    }
142
143    public function databasesAreIndependent() {
144        return true;
145    }
146
147    public function doSelectDomain( DatabaseDomain $domain ) {
148        $database = $domain->getDatabase();
149        if ( $database === null ) {
150            // A null database means "don't care" so leave it as is and update the table prefix
151            $this->currentDomain = new DatabaseDomain(
152                $this->currentDomain->getDatabase(),
153                $domain->getSchema() ?? $this->currentDomain->getSchema(),
154                $domain->getTablePrefix()
155            );
156            $this->platform->setCurrentDomain( $this->currentDomain );
157        } elseif ( $this->getDBname() !== $database ) {
158            // Postgres doesn't support selectDB in the same way MySQL does.
159            // So if the DB name doesn't match the open connection, open a new one
160            $this->open(
161                $this->connectionParams[self::CONN_HOST],
162                $this->connectionParams[self::CONN_USER],
163                $this->connectionParams[self::CONN_PASSWORD],
164                $database,
165                $domain->getSchema(),
166                $domain->getTablePrefix()
167            );
168        } else {
169            $this->currentDomain = $domain;
170            $this->platform->setCurrentDomain( $domain );
171        }
172
173        return true;
174    }
175
176    /**
177     * @param string[] $vars
178     * @return string
179     */
180    private function makeConnectionString( $vars ) {
181        $s = '';
182        foreach ( $vars as $name => $value ) {
183            $s .= "$name='" . str_replace( [ "\\", "'" ], [ "\\\\", "\\'" ], $value ) . "' ";
184        }
185
186        return $s;
187    }
188
189    protected function closeConnection() {
190        return $this->conn ? pg_close( $this->conn ) : true;
191    }
192
193    public function doSingleStatementQuery( string $sql ): QueryStatus {
194        $conn = $this->getBindingHandle();
195
196        $sql = mb_convert_encoding( $sql, 'UTF-8' );
197        // Clear any previously left over result
198        while ( $priorRes = pg_get_result( $conn ) ) {
199            pg_free_result( $priorRes );
200        }
201
202        if ( pg_send_query( $conn, $sql ) === false ) {
203            throw new DBUnexpectedError( $this, "Unable to post new query to PostgreSQL\n" );
204        }
205
206        // Newer PHP versions use PgSql\Result instead of resource variables
207        // https://www.php.net/manual/en/function.pg-get-result.php
208        $pgRes = pg_get_result( $conn );
209        // Phan on PHP 7.4 and PHP 8.1 need different suppressions
210        // @phan-suppress-next-line PhanTypeMismatchProperty,PhanTypeMismatchPropertyProbablyReal
211        $this->lastResultHandle = $pgRes;
212        $res = pg_result_error( $pgRes ) ? false : $pgRes;
213
214        return new QueryStatus(
215            // @phan-suppress-next-line PhanTypeMismatchArgument
216            is_bool( $res ) ? $res : new PostgresResultWrapper( $this, $conn, $res ),
217            $pgRes ? pg_affected_rows( $pgRes ) : 0,
218            $this->lastError(),
219            $this->lastErrno()
220        );
221    }
222
223    protected function dumpError() {
224        $diags = [
225            PGSQL_DIAG_SEVERITY,
226            PGSQL_DIAG_SQLSTATE,
227            PGSQL_DIAG_MESSAGE_PRIMARY,
228            PGSQL_DIAG_MESSAGE_DETAIL,
229            PGSQL_DIAG_MESSAGE_HINT,
230            PGSQL_DIAG_STATEMENT_POSITION,
231            PGSQL_DIAG_INTERNAL_POSITION,
232            PGSQL_DIAG_INTERNAL_QUERY,
233            PGSQL_DIAG_CONTEXT,
234            PGSQL_DIAG_SOURCE_FILE,
235            PGSQL_DIAG_SOURCE_LINE,
236            PGSQL_DIAG_SOURCE_FUNCTION
237        ];
238        foreach ( $diags as $d ) {
239            $this->logger->debug( sprintf( "PgSQL ERROR(%d): %s",
240                // @phan-suppress-next-line PhanTypeMismatchArgumentInternal
241                $d, pg_result_error_field( $this->lastResultHandle, $d ) ) );
242        }
243    }
244
245    protected function lastInsertId() {
246        // Avoid using query() to prevent unwanted side-effects like changing affected
247        // row counts or connection retries. Note that lastval() is connection-specific.
248        // Note that this causes "lastval is not yet defined in this session" errors if
249        // nextval() was never directly or implicitly triggered (error out any transaction).
250        $qs = $this->doSingleStatementQuery( "SELECT lastval() AS id" );
251
252        return $qs->res ? (int)$qs->res->fetchRow()['id'] : 0;
253    }
254
255    public function lastError() {
256        if ( $this->conn ) {
257            if ( $this->lastResultHandle ) {
258                // @phan-suppress-next-line PhanTypeMismatchArgumentInternal
259                return pg_result_error( $this->lastResultHandle );
260            } else {
261                return pg_last_error() ?: $this->lastConnectError;
262            }
263        }
264
265        return $this->getLastPHPError() ?: 'No database connection';
266    }
267
268    public function lastErrno() {
269        if ( $this->lastResultHandle ) {
270            // @phan-suppress-next-line PhanTypeMismatchArgumentInternal
271            $lastErrno = pg_result_error_field( $this->lastResultHandle, PGSQL_DIAG_SQLSTATE );
272            if ( $lastErrno !== false ) {
273                return $lastErrno;
274            }
275        }
276
277        return '00000';
278    }
279
280    public function estimateRowCount( $table, $var = '*', $conds = '',
281        $fname = __METHOD__, $options = [], $join_conds = []
282    ): int {
283        $conds = $this->platform->normalizeConditions( $conds, $fname );
284        $column = $this->platform->extractSingleFieldFromList( $var );
285        if ( is_string( $column ) && !in_array( $column, [ '*', '1' ] ) ) {
286            $conds[] = "$column IS NOT NULL";
287        }
288
289        $options['EXPLAIN'] = true;
290        $res = $this->select( $table, $var, $conds, $fname, $options, $join_conds );
291        $rows = -1;
292        if ( $res ) {
293            $row = $res->fetchRow();
294            $count = [];
295            if ( preg_match( '/rows=(\d+)/', $row[0], $count ) ) {
296                $rows = (int)$count[1];
297            }
298        }
299
300        return $rows;
301    }
302
303    public function indexInfo( $table, $index, $fname = __METHOD__ ) {
304        $components = $this->platform->qualifiedTableComponents( $table );
305        $encTable = $this->addQuotes( end( $components ) );
306        $encIndex = $this->addQuotes( $this->platform->indexName( $index ) );
307        $query = new Query(
308            "SELECT indexname,indexdef FROM pg_indexes " .
309                "WHERE tablename=$encTable AND indexname=$encIndex",
310            self::QUERY_IGNORE_DBO_TRX | self::QUERY_CHANGE_NONE,
311            'SELECT'
312        );
313        $res = $this->query( $query );
314        $row = $res->fetchObject();
315
316        if ( $row ) {
317            return [ 'unique' => ( strpos( $row->indexdef, 'CREATE UNIQUE ' ) === 0 ) ];
318        }
319
320        return false;
321    }
322
323    public function indexAttributes( $index, $schema = false ) {
324        if ( $schema === false ) {
325            $schemas = $this->getCoreSchemas();
326        } else {
327            $schemas = [ $schema ];
328        }
329
330        $eindex = $this->addQuotes( $index );
331
332        $flags = self::QUERY_IGNORE_DBO_TRX | self::QUERY_CHANGE_NONE;
333        foreach ( $schemas as $schema ) {
334            $eschema = $this->addQuotes( $schema );
335            /*
336             * A subquery would be not needed if we didn't care about the order
337             * of attributes, but we do
338             */
339            $sql = <<<__INDEXATTR__
340
341                SELECT opcname,
342                    attname,
343                    i.indoption[s.g] as option,
344                    pg_am.amname
345                FROM
346                    (SELECT generate_series(array_lower(isub.indkey,1), array_upper(isub.indkey,1)) AS g
347                        FROM
348                            pg_index isub
349                        JOIN pg_class cis
350                            ON cis.oid=isub.indexrelid
351                        JOIN pg_namespace ns
352                            ON cis.relnamespace = ns.oid
353                        WHERE cis.relname=$eindex AND ns.nspname=$eschema) AS s,
354                    pg_attribute,
355                    pg_opclass opcls,
356                    pg_am,
357                    pg_class ci
358                    JOIN pg_index i
359                        ON ci.oid=i.indexrelid
360                    JOIN pg_class ct
361                        ON ct.oid = i.indrelid
362                    JOIN pg_namespace n
363                        ON ci.relnamespace = n.oid
364                    WHERE
365                        ci.relname=$eindex AND n.nspname=$eschema
366                        AND    attrelid = ct.oid
367                        AND    i.indkey[s.g] = attnum
368                        AND    i.indclass[s.g] = opcls.oid
369                        AND    pg_am.oid = opcls.opcmethod
370__INDEXATTR__;
371            $query = new Query( $sql, $flags, 'SELECT' );
372            $res = $this->query( $query, __METHOD__ );
373            $a = [];
374            if ( $res ) {
375                foreach ( $res as $row ) {
376                    $a[] = [
377                        $row->attname,
378                        $row->opcname,
379                        $row->amname,
380                        $row->option ];
381                }
382                return $a;
383            }
384        }
385        return null;
386    }
387
388    protected function doInsertSelectNative(
389        $destTable,
390        $srcTable,
391        array $varMap,
392        $conds,
393        $fname,
394        array $insertOptions,
395        array $selectOptions,
396        $selectJoinConds
397    ) {
398        if ( in_array( 'IGNORE', $insertOptions ) ) {
399            // Use "ON CONFLICT DO" if we have it for IGNORE
400            $destTableEnc = $this->tableName( $destTable );
401
402            $selectSql = $this->selectSQLText(
403                $srcTable,
404                array_values( $varMap ),
405                $conds,
406                $fname,
407                $selectOptions,
408                $selectJoinConds
409            );
410
411            $sql = "INSERT INTO $destTableEnc (" . implode( ',', array_keys( $varMap ) ) . ') ' .
412                $selectSql . ' ON CONFLICT DO NOTHING';
413            $query = new Query( $sql, self::QUERY_CHANGE_ROWS, 'INSERT', $destTable );
414            $this->query( $query, $fname );
415        } else {
416            parent::doInsertSelectNative( $destTable, $srcTable, $varMap, $conds, $fname,
417                $insertOptions, $selectOptions, $selectJoinConds );
418        }
419    }
420
421    public function getValueTypesForWithClause( $table ) {
422        $typesByColumn = [];
423
424        $flags = self::QUERY_IGNORE_DBO_TRX | self::QUERY_CHANGE_NONE;
425        $encTable = $this->addQuotes( $table );
426        foreach ( $this->getCoreSchemas() as $schema ) {
427            $encSchema = $this->addQuotes( $schema );
428            $sql = "SELECT column_name,udt_name " .
429                "FROM information_schema.columns " .
430                "WHERE table_name = $encTable AND table_schema = $encSchema";
431            $query = new Query( $sql, $flags, 'SELECT' );
432            $res = $this->query( $query, __METHOD__ );
433            if ( $res->numRows() ) {
434                foreach ( $res as $row ) {
435                    $typesByColumn[$row->column_name] = $row->udt_name;
436                }
437                break;
438            }
439        }
440
441        return $typesByColumn;
442    }
443
444    protected function isConnectionError( $errno ) {
445        // https://www.postgresql.org/docs/9.2/static/errcodes-appendix.html
446        static $codes = [ '08000', '08003', '08006', '08001', '08004', '57P01', '57P03', '53300' ];
447
448        return in_array( $errno, $codes, true );
449    }
450
451    protected function isQueryTimeoutError( $errno ) {
452        // https://www.postgresql.org/docs/9.2/static/errcodes-appendix.html
453        return ( $errno === '57014' );
454    }
455
456    protected function isKnownStatementRollbackError( $errno ) {
457        return false; // transaction has to be rolled-back from error state
458    }
459
460    public function duplicateTableStructure(
461        $oldName, $newName, $temporary = false, $fname = __METHOD__
462    ) {
463        $newNameE = $this->platform->addIdentifierQuotes( $newName );
464        $oldNameE = $this->platform->addIdentifierQuotes( $oldName );
465
466        $temporary = $temporary ? 'TEMPORARY' : '';
467        $query = new Query(
468            "CREATE $temporary TABLE $newNameE " .
469            "(LIKE $oldNameE INCLUDING DEFAULTS INCLUDING INDEXES)",
470            self::QUERY_PSEUDO_PERMANENT | self::QUERY_CHANGE_SCHEMA,
471            $temporary ? 'CREATE TEMPORARY' : 'CREATE',
472            // Use a dot to avoid double-prefixing in Database::getTempTableWrites()
473            '.' . $newName
474        );
475        $ret = $this->query( $query, $fname );
476        if ( !$ret ) {
477            return $ret;
478        }
479
480        $sql = 'SELECT attname FROM pg_class c'
481            . ' JOIN pg_namespace n ON (n.oid = c.relnamespace)'
482            . ' JOIN pg_attribute a ON (a.attrelid = c.oid)'
483            . ' JOIN pg_attrdef d ON (c.oid=d.adrelid and a.attnum=d.adnum)'
484            . ' WHERE relkind = \'r\''
485            . ' AND nspname = ' . $this->addQuotes( $this->getCoreSchema() )
486            . ' AND relname = ' . $this->addQuotes( $oldName )
487            . ' AND pg_get_expr(adbin, adrelid) LIKE \'nextval(%\'';
488        $query = new Query(
489            $sql,
490            self::QUERY_IGNORE_DBO_TRX | self::QUERY_CHANGE_NONE,
491            'SELECT'
492        );
493
494        $res = $this->query( $query, $fname );
495        $row = $res->fetchObject();
496        if ( $row ) {
497            $field = $row->attname;
498            $newSeq = "{$newName}_{$field}_seq";
499            $fieldE = $this->platform->addIdentifierQuotes( $field );
500            $newSeqE = $this->platform->addIdentifierQuotes( $newSeq );
501            $newSeqQ = $this->addQuotes( $newSeq );
502            $query = new Query(
503                "CREATE $temporary SEQUENCE $newSeqE OWNED BY $newNameE.$fieldE",
504                self::QUERY_CHANGE_SCHEMA,
505                'CREATE',
506                // Do not treat this is as a table modification on top of the CREATE above.
507                null
508            );
509            $this->query( $query, $fname );
510            $query = new Query(
511                "ALTER TABLE $newNameE ALTER COLUMN $fieldE SET DEFAULT nextval({$newSeqQ}::regclass)",
512                self::QUERY_CHANGE_SCHEMA,
513                'ALTER',
514                // Do not treat this is as a table modification on top of the CREATE above.
515                null
516            );
517            $this->query( $query, $fname );
518        }
519
520        return $ret;
521    }
522
523    public function truncateTable( $table, $fname = __METHOD__ ) {
524        $sql = "TRUNCATE TABLE " . $this->tableName( $table ) . " RESTART IDENTITY";
525        $query = new Query( $sql, self::QUERY_CHANGE_SCHEMA, 'TRUNCATE', $table );
526        $this->query( $query, $fname );
527    }
528
529    /**
530     * @param string $prefix Only show tables with this prefix, e.g. mw_
531     * @param string $fname Calling function name
532     * @return string[]
533     * @suppress SecurityCheck-SQLInjection array_map not recognized T204911
534     */
535    public function listTables( $prefix = '', $fname = __METHOD__ ) {
536        $eschemas = implode( ',', array_map( [ $this, 'addQuotes' ], $this->getCoreSchemas() ) );
537        $query = new Query(
538            "SELECT DISTINCT tablename FROM pg_tables WHERE schemaname IN ($eschemas)",
539            self::QUERY_IGNORE_DBO_TRX | self::QUERY_CHANGE_NONE,
540            'SELECT'
541        );
542        $result = $this->query( $query, $fname );
543        $endArray = [];
544
545        foreach ( $result as $table ) {
546            $vars = get_object_vars( $table );
547            $table = array_pop( $vars );
548            if ( $prefix == '' || strpos( $table, $prefix ) === 0 ) {
549                $endArray[] = $table;
550            }
551        }
552
553        return $endArray;
554    }
555
556    /**
557     * Posted by cc[plus]php[at]c2se[dot]com on 25-Mar-2009 09:12
558     * to https://www.php.net/manual/en/ref.pgsql.php
559     *
560     * Parsing a postgres array can be a tricky problem, he's my
561     * take on this, it handles multi-dimensional arrays plus
562     * escaping using a nasty regexp to determine the limits of each
563     * data-item.
564     *
565     * This should really be handled by PHP PostgreSQL module
566     *
567     * @since 1.19
568     * @param string $text Postgreql array returned in a text form like {a,b}
569     * @param string[] &$output
570     * @param int|false $limit
571     * @param int $offset
572     * @return string[]
573     */
574    private function pg_array_parse( $text, &$output, $limit = false, $offset = 1 ) {
575        if ( $limit === false ) {
576            $limit = strlen( $text ) - 1;
577            $output = [];
578        }
579        if ( $text == '{}' ) {
580            return $output;
581        }
582        do {
583            if ( $text[$offset] != '{' ) {
584                preg_match( "/(\\{?\"([^\"\\\\]|\\\\.)*\"|[^,{}]+)+([,}]+)/",
585                    $text, $match, 0, $offset );
586                $offset += strlen( $match[0] );
587                $output[] = ( $match[1][0] != '"'
588                    ? $match[1]
589                    : stripcslashes( substr( $match[1], 1, -1 ) ) );
590                if ( $match[3] == '},' ) {
591                    return $output;
592                }
593            } else {
594                $offset = $this->pg_array_parse( $text, $output, $limit, $offset + 1 );
595            }
596        } while ( $limit > $offset );
597
598        return $output;
599    }
600
601    public function getSoftwareLink() {
602        return '[{{int:version-db-postgres-url}} PostgreSQL]';
603    }
604
605    /**
606     * Return current schema (executes SELECT current_schema())
607     * Needs transaction
608     *
609     * @since 1.19
610     * @return string Default schema for the current session
611     */
612    public function getCurrentSchema() {
613        $query = new Query(
614            "SELECT current_schema()",
615            self::QUERY_IGNORE_DBO_TRX | self::QUERY_CHANGE_NONE,
616            'SELECT'
617        );
618        $res = $this->query( $query, __METHOD__ );
619        $row = $res->fetchRow();
620
621        return $row[0];
622    }
623
624    /**
625     * Return list of schemas which are accessible without schema name
626     * This is list does not contain magic keywords like "$user"
627     * Needs transaction
628     *
629     * @see getSearchPath()
630     * @see setSearchPath()
631     * @since 1.19
632     * @return array List of actual schemas for the current session
633     */
634    public function getSchemas() {
635        $query = new Query(
636            "SELECT current_schemas(false)",
637            self::QUERY_IGNORE_DBO_TRX | self::QUERY_CHANGE_NONE,
638            'SELECT'
639        );
640        $res = $this->query( $query, __METHOD__ );
641        $row = $res->fetchRow();
642        $schemas = [];
643
644        /* PHP pgsql support does not support array type, "{a,b}" string is returned */
645
646        return $this->pg_array_parse( $row[0], $schemas );
647    }
648
649    /**
650     * Return search patch for schemas
651     * This is different from getSchemas() since it contain magic keywords
652     * (like "$user").
653     * Needs transaction
654     *
655     * @since 1.19
656     * @return array How to search for table names schemas for the current user
657     */
658    public function getSearchPath() {
659        $query = new Query(
660            "SHOW search_path",
661            self::QUERY_IGNORE_DBO_TRX | self::QUERY_CHANGE_NONE,
662            'SHOW'
663        );
664        $res = $this->query( $query, __METHOD__ );
665        $row = $res->fetchRow();
666
667        /* PostgreSQL returns SHOW values as strings */
668
669        return explode( ",", $row[0] );
670    }
671
672    /**
673     * Update search_path, values should already be sanitized
674     * Values may contain magic keywords like "$user"
675     * @since 1.19
676     *
677     * @param string[] $search_path List of schemas to be searched by default
678     */
679    private function setSearchPath( $search_path ) {
680        $query = new Query(
681            "SET search_path = " . implode( ", ", $search_path ),
682            self::QUERY_CHANGE_TRX,
683            'SET'
684        );
685        $this->query( $query, __METHOD__ );
686    }
687
688    /**
689     * Determine default schema for the current application
690     * Adjust this session schema search path if desired schema exists
691     * and is not already there.
692     *
693     * We need to have name of the core schema stored to be able
694     * to query database metadata.
695     *
696     * This will be also called by the installer after the schema is created
697     *
698     * @since 1.19
699     *
700     * @param string|null $desiredSchema
701     */
702    public function determineCoreSchema( $desiredSchema ) {
703        if ( $this->trxLevel() ) {
704            // We do not want the schema selection to change on ROLLBACK or INSERT SELECT.
705            // See https://www.postgresql.org/docs/8.3/sql-set.html
706            throw new DBUnexpectedError(
707                $this,
708                __METHOD__ . ": a transaction is currently active"
709            );
710        }
711
712        if ( $this->schemaExists( $desiredSchema ) ) {
713            if ( in_array( $desiredSchema, $this->getSchemas() ) ) {
714                $this->platform->setCoreSchema( $desiredSchema );
715                $this->logger->debug(
716                    "Schema \"" . $desiredSchema . "\" already in the search path\n" );
717            } else {
718                // Prepend the desired schema to the search path (T17816)
719                $search_path = $this->getSearchPath();
720                array_unshift( $search_path, $this->platform->addIdentifierQuotes( $desiredSchema ) );
721                $this->setSearchPath( $search_path );
722                $this->platform->setCoreSchema( $desiredSchema );
723                $this->logger->debug(
724                    "Schema \"" . $desiredSchema . "\" added to the search path\n" );
725            }
726        } else {
727            $this->platform->setCoreSchema( $this->getCurrentSchema() );
728            $this->logger->debug(
729                "Schema \"" . $desiredSchema . "\" not found, using current \"" .
730                $this->getCoreSchema() . "\"\n" );
731        }
732    }
733
734    /**
735     * Return schema name for core application tables
736     *
737     * @since 1.19
738     * @return string Core schema name
739     */
740    public function getCoreSchema() {
741        return $this->platform->getCoreSchema();
742    }
743
744    /**
745     * Return schema names for temporary tables and core application tables
746     *
747     * @since 1.31
748     * @return string[] schema names
749     */
750    public function getCoreSchemas() {
751        if ( $this->tempSchema ) {
752            return [ $this->tempSchema, $this->getCoreSchema() ];
753        }
754        $query = new Query(
755            "SELECT nspname FROM pg_catalog.pg_namespace n WHERE n.oid = pg_my_temp_schema()",
756            self::QUERY_IGNORE_DBO_TRX | self::QUERY_CHANGE_NONE,
757            'SELECT'
758        );
759        $res = $this->query( $query, __METHOD__ );
760        $row = $res->fetchObject();
761        if ( $row ) {
762            $this->tempSchema = $row->nspname;
763            return [ $this->tempSchema, $this->getCoreSchema() ];
764        }
765
766        return [ $this->getCoreSchema() ];
767    }
768
769    public function getServerVersion() {
770        if ( !isset( $this->numericVersion ) ) {
771            // Works on PG 7.4+
772            $this->numericVersion = pg_version( $this->getBindingHandle() )['server'];
773        }
774
775        return $this->numericVersion;
776    }
777
778    /**
779     * Query whether a given relation exists (in the given schema, or the
780     * default mw one if not given)
781     * @param string $table
782     * @param array|string $types
783     * @return bool
784     */
785    private function relationExists( $table, $types ) {
786        if ( !is_array( $types ) ) {
787            $types = [ $types ];
788        }
789        $schemas = $this->getCoreSchemas();
790        $components = $this->platform->qualifiedTableComponents( $table );
791        $etable = $this->addQuotes( end( $components ) );
792        foreach ( $schemas as $schema ) {
793            $eschema = $this->addQuotes( $schema );
794            $sql = "SELECT 1 FROM pg_catalog.pg_class c, pg_catalog.pg_namespace n "
795                . "WHERE c.relnamespace = n.oid AND c.relname = $etable AND n.nspname = $eschema "
796                . "AND c.relkind IN ('" . implode( "','", $types ) . "')";
797            $query = new Query(
798                $sql,
799                self::QUERY_IGNORE_DBO_TRX | self::QUERY_CHANGE_NONE,
800                'SELECT'
801            );
802            $res = $this->query( $query, __METHOD__ );
803            if ( $res && $res->numRows() ) {
804                return true;
805            }
806        }
807
808        return false;
809    }
810
811    public function tableExists( $table, $fname = __METHOD__ ) {
812        return $this->relationExists( $table, [ 'r', 'v' ] );
813    }
814
815    public function sequenceExists( $sequence ) {
816        return $this->relationExists( $sequence, 'S' );
817    }
818
819    public function constraintExists( $table, $constraint ) {
820        foreach ( $this->getCoreSchemas() as $schema ) {
821            $sql = sprintf( "SELECT 1 FROM information_schema.table_constraints " .
822                "WHERE constraint_schema = %s AND table_name = %s AND constraint_name = %s",
823                $this->addQuotes( $schema ),
824                $this->addQuotes( $table ),
825                $this->addQuotes( $constraint )
826            );
827            $query = new Query(
828                $sql,
829                self::QUERY_IGNORE_DBO_TRX | self::QUERY_CHANGE_NONE,
830                'SELECT'
831            );
832            $res = $this->query( $query, __METHOD__ );
833            if ( $res && $res->numRows() ) {
834                return true;
835            }
836        }
837        return false;
838    }
839
840    /**
841     * Query whether a given schema exists. Returns true if it does, false if it doesn't.
842     * @param string|null $schema
843     * @return bool
844     */
845    public function schemaExists( $schema ) {
846        if ( !strlen( $schema ?? '' ) ) {
847            return false; // short-circuit
848        }
849        $query = new Query(
850            "SELECT 1 FROM pg_catalog.pg_namespace " .
851            "WHERE nspname = " . $this->addQuotes( $schema ) . " LIMIT 1",
852            self::QUERY_IGNORE_DBO_TRX | self::QUERY_CHANGE_NONE,
853            'SELECT'
854        );
855        $res = $this->query( $query, __METHOD__ );
856
857        return ( $res->numRows() > 0 );
858    }
859
860    /**
861     * Returns true if a given role (i.e. user) exists, false otherwise.
862     * @param string $roleName
863     * @return bool
864     */
865    public function roleExists( $roleName ) {
866        $query = new Query(
867            "SELECT 1 FROM pg_catalog.pg_roles " .
868            "WHERE rolname = " . $this->addQuotes( $roleName ) . " LIMIT 1",
869            self::QUERY_IGNORE_DBO_TRX | self::QUERY_CHANGE_NONE,
870            'SELECT'
871        );
872        $res = $this->query( $query, __METHOD__ );
873
874        return ( $res->numRows() > 0 );
875    }
876
877    /**
878     * @param string $table
879     * @param string $field
880     * @return PostgresField|null
881     */
882    public function fieldInfo( $table, $field ) {
883        return PostgresField::fromText( $this, $table, $field );
884    }
885
886    public function encodeBlob( $b ) {
887        $conn = $this->getBindingHandle();
888
889        return new PostgresBlob( pg_escape_bytea( $conn, $b ) );
890    }
891
892    public function decodeBlob( $b ) {
893        if ( $b instanceof PostgresBlob ) {
894            $b = $b->fetch();
895        } elseif ( $b instanceof Blob ) {
896            return $b->fetch();
897        }
898
899        return pg_unescape_bytea( $b );
900    }
901
902    public function strencode( $s ) {
903        // Should not be called by us
904        return pg_escape_string( $this->getBindingHandle(), (string)$s );
905    }
906
907    public function addQuotes( $s ) {
908        if ( $s instanceof RawSQLValue ) {
909            return $s->toSql();
910        }
911        $conn = $this->getBindingHandle();
912
913        if ( $s === null ) {
914            return 'NULL';
915        } elseif ( is_bool( $s ) ) {
916            return (string)intval( $s );
917        } elseif ( is_int( $s ) ) {
918            return (string)$s;
919        } elseif ( $s instanceof Blob ) {
920            if ( $s instanceof PostgresBlob ) {
921                $s = $s->fetch();
922            } else {
923                $s = pg_escape_bytea( $conn, $s->fetch() );
924            }
925            return "'$s'";
926        }
927
928        return "'" . pg_escape_string( $conn, (string)$s ) . "'";
929    }
930
931    public function streamStatementEnd( &$sql, &$newLine ) {
932        # Allow dollar quoting for function declarations
933        if ( str_starts_with( $newLine, '$mw$' ) ) {
934            if ( $this->delimiter ) {
935                $this->delimiter = false;
936            } else {
937                $this->delimiter = ';';
938            }
939        }
940
941        return parent::streamStatementEnd( $sql, $newLine );
942    }
943
944    public function doLockIsFree( string $lockName, string $method ) {
945        $query = new Query(
946            $this->platform->lockIsFreeSQLText( $lockName ),
947            self::QUERY_CHANGE_LOCKS,
948            'SELECT'
949        );
950        $res = $this->query( $query, $method );
951        $row = $res->fetchObject();
952
953        return (bool)$row->unlocked;
954    }
955
956    public function doLock( string $lockName, string $method, int $timeout ) {
957        $query = new Query(
958            $this->platform->lockSQLText( $lockName, $timeout ),
959            self::QUERY_CHANGE_LOCKS,
960            'SELECT'
961        );
962
963        $acquired = null;
964        $loop = new WaitConditionLoop(
965            function () use ( $query, $method, &$acquired ) {
966                $res = $this->query( $query, $method );
967                $row = $res->fetchObject();
968
969                if ( $row->acquired !== null ) {
970                    $acquired = (float)$row->acquired;
971
972                    return WaitConditionLoop::CONDITION_REACHED;
973                }
974
975                return WaitConditionLoop::CONDITION_CONTINUE;
976            },
977            $timeout
978        );
979        $loop->invoke();
980
981        return $acquired;
982    }
983
984    public function doUnlock( string $lockName, string $method ) {
985        $query = new Query(
986            $this->platform->unlockSQLText( $lockName ),
987            self::QUERY_CHANGE_LOCKS,
988            'SELECT'
989        );
990        $result = $this->query( $query, $method );
991        $row = $result->fetchObject();
992
993        return (bool)$row->released;
994    }
995
996    protected function doFlushSession( $fname ) {
997        $flags = self::QUERY_CHANGE_LOCKS | self::QUERY_NO_RETRY;
998
999        // https://www.postgresql.org/docs/9.1/functions-admin.html
1000        $sql = "SELECT pg_advisory_unlock_all()";
1001        $query = new Query( $sql, $flags, 'UNLOCK' );
1002        $qs = $this->executeQuery( $query, __METHOD__, $flags );
1003        if ( $qs->res === false ) {
1004            $this->reportQueryError( $qs->message, $qs->code, $sql, $fname, true );
1005        }
1006    }
1007
1008    public function serverIsReadOnly() {
1009        $query = new Query(
1010            "SHOW default_transaction_read_only",
1011            self::QUERY_IGNORE_DBO_TRX | self::QUERY_CHANGE_NONE,
1012            'SHOW'
1013        );
1014        $res = $this->query( $query, __METHOD__ );
1015        $row = $res->fetchObject();
1016
1017        return $row && strtolower( $row->default_transaction_read_only ) === 'on';
1018    }
1019
1020    protected function getInsertIdColumnForUpsert( $table ) {
1021        $column = null;
1022
1023        $flags = self::QUERY_IGNORE_DBO_TRX | self::QUERY_CHANGE_NONE;
1024        $components = $this->platform->qualifiedTableComponents( $table );
1025        $encTable = $this->addQuotes( end( $components ) );
1026        foreach ( $this->getCoreSchemas() as $schema ) {
1027            $encSchema = $this->addQuotes( $schema );
1028            $query = new Query(
1029                "SELECT column_name,data_type,column_default " .
1030                    "FROM information_schema.columns " .
1031                    "WHERE table_name = $encTable AND table_schema = $encSchema",
1032                self::QUERY_IGNORE_DBO_TRX | self::QUERY_CHANGE_NONE,
1033                'SELECT'
1034            );
1035            $res = $this->query( $query, __METHOD__ );
1036            if ( $res->numRows() ) {
1037                foreach ( $res as $row ) {
1038                    if (
1039                        $row->column_default !== null &&
1040                        str_starts_with( $row->column_default, "nextval(" ) &&
1041                        in_array( $row->data_type, [ 'integer', 'bigint' ], true )
1042                    ) {
1043                        $column = $row->column_name;
1044                    }
1045                }
1046                break;
1047            }
1048        }
1049
1050        return $column;
1051    }
1052
1053    public static function getAttributes() {
1054        return [ self::ATTR_SCHEMAS_AS_TABLE_GROUPS => true ];
1055    }
1056}