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