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 / 56
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 / 56
22350
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 / 12
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
 indexUnique
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 doInsertSelectNative
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
6
 nextSequenceValue
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getValueTypesForWithClause
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
20
 textFieldSize
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
6
 wasDeadlock
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 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 / 21
0.00% covered (danger)
0.00%
0 / 1
42
 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 / 21
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( $this->currentDomain );
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    /**
281     * Estimate rows in dataset
282     * Returns estimated count, based on EXPLAIN output
283     * This is not necessarily an accurate estimate, so use sparingly
284     * Returns -1 if count cannot be found
285     * Takes same arguments as Database::select()
286     *
287     * @param string $table
288     * @param string $var
289     * @param string $conds
290     * @param string $fname
291     * @param array $options
292     * @param array $join_conds
293     * @return int
294     */
295    public function estimateRowCount( $table, $var = '*', $conds = '',
296        $fname = __METHOD__, $options = [], $join_conds = []
297    ): int {
298        $conds = $this->platform->normalizeConditions( $conds, $fname );
299        $column = $this->platform->extractSingleFieldFromList( $var );
300        if ( is_string( $column ) && !in_array( $column, [ '*', '1' ] ) ) {
301            $conds[] = "$column IS NOT NULL";
302        }
303
304        $options['EXPLAIN'] = true;
305        $res = $this->select( $table, $var, $conds, $fname, $options, $join_conds );
306        $rows = -1;
307        if ( $res ) {
308            $row = $res->fetchRow();
309            $count = [];
310            if ( preg_match( '/rows=(\d+)/', $row[0], $count ) ) {
311                $rows = (int)$count[1];
312            }
313        }
314
315        return $rows;
316    }
317
318    public function indexInfo( $table, $index, $fname = __METHOD__ ) {
319        $query = new Query(
320            "SELECT indexname FROM pg_indexes WHERE tablename='$table'",
321            self::QUERY_IGNORE_DBO_TRX | self::QUERY_CHANGE_NONE,
322            'SELECT'
323        );
324        $res = $this->query( $query );
325        if ( !$res ) {
326            return null;
327        }
328        foreach ( $res as $row ) {
329            if ( $row->indexname == $this->platform->indexName( $index ) ) {
330                return $row;
331            }
332        }
333
334        return false;
335    }
336
337    public function indexAttributes( $index, $schema = false ) {
338        if ( $schema === false ) {
339            $schemas = $this->getCoreSchemas();
340        } else {
341            $schemas = [ $schema ];
342        }
343
344        $eindex = $this->addQuotes( $index );
345
346        $flags = self::QUERY_IGNORE_DBO_TRX | self::QUERY_CHANGE_NONE;
347        foreach ( $schemas as $schema ) {
348            $eschema = $this->addQuotes( $schema );
349            /*
350             * A subquery would be not needed if we didn't care about the order
351             * of attributes, but we do
352             */
353            $sql = <<<__INDEXATTR__
354
355                SELECT opcname,
356                    attname,
357                    i.indoption[s.g] as option,
358                    pg_am.amname
359                FROM
360                    (SELECT generate_series(array_lower(isub.indkey,1), array_upper(isub.indkey,1)) AS g
361                        FROM
362                            pg_index isub
363                        JOIN pg_class cis
364                            ON cis.oid=isub.indexrelid
365                        JOIN pg_namespace ns
366                            ON cis.relnamespace = ns.oid
367                        WHERE cis.relname=$eindex AND ns.nspname=$eschema) AS s,
368                    pg_attribute,
369                    pg_opclass opcls,
370                    pg_am,
371                    pg_class ci
372                    JOIN pg_index i
373                        ON ci.oid=i.indexrelid
374                    JOIN pg_class ct
375                        ON ct.oid = i.indrelid
376                    JOIN pg_namespace n
377                        ON ci.relnamespace = n.oid
378                    WHERE
379                        ci.relname=$eindex AND n.nspname=$eschema
380                        AND    attrelid = ct.oid
381                        AND    i.indkey[s.g] = attnum
382                        AND    i.indclass[s.g] = opcls.oid
383                        AND    pg_am.oid = opcls.opcmethod
384__INDEXATTR__;
385            $query = new Query( $sql, $flags, 'SELECT' );
386            $res = $this->query( $query, __METHOD__ );
387            $a = [];
388            if ( $res ) {
389                foreach ( $res as $row ) {
390                    $a[] = [
391                        $row->attname,
392                        $row->opcname,
393                        $row->amname,
394                        $row->option ];
395                }
396                return $a;
397            }
398        }
399        return null;
400    }
401
402    public function indexUnique( $table, $index, $fname = __METHOD__ ) {
403        $sql = "SELECT indexname FROM pg_indexes WHERE tablename='{$table}'" .
404            " AND indexdef LIKE 'CREATE UNIQUE%(" .
405            $this->strencode( $this->platform->indexName( $index ) ) .
406            ")'";
407        $query = new Query( $sql, self::QUERY_IGNORE_DBO_TRX | self::QUERY_CHANGE_NONE, 'SELECT' );
408        $res = $this->query( $query, $fname );
409        return $res && $res->numRows() > 0;
410    }
411
412    /**
413     * INSERT SELECT wrapper
414     * $varMap must be an associative array of the form [ 'dest1' => 'source1', ... ]
415     * Source items may be literals rather then field names, but strings should
416     * be quoted with Database::addQuotes()
417     * $conds may be "*" to copy the whole table
418     * srcTable may be an array of tables.
419     * @todo FIXME: Implement this a little better (separate select/insert)?
420     *
421     * @param string $destTable
422     * @param array|string $srcTable
423     * @param array $varMap
424     * @param array $conds
425     * @param string $fname
426     * @param array $insertOptions
427     * @param array $selectOptions
428     * @param array $selectJoinConds
429     */
430    protected function doInsertSelectNative(
431        $destTable,
432        $srcTable,
433        array $varMap,
434        $conds,
435        $fname,
436        array $insertOptions,
437        array $selectOptions,
438        $selectJoinConds
439    ) {
440        if ( in_array( 'IGNORE', $insertOptions ) ) {
441            // Use "ON CONFLICT DO" if we have it for IGNORE
442            $destTableEnc = $this->tableName( $destTable );
443
444            $selectSql = $this->selectSQLText(
445                $srcTable,
446                array_values( $varMap ),
447                $conds,
448                $fname,
449                $selectOptions,
450                $selectJoinConds
451            );
452
453            $sql = "INSERT INTO $destTableEnc (" . implode( ',', array_keys( $varMap ) ) . ') ' .
454                $selectSql . ' ON CONFLICT DO NOTHING';
455            $query = new Query( $sql, self::QUERY_CHANGE_ROWS, 'INSERT', $destTable );
456            $this->query( $query, $fname );
457        } else {
458            parent::doInsertSelectNative( $destTable, $srcTable, $varMap, $conds, $fname,
459                $insertOptions, $selectOptions, $selectJoinConds );
460        }
461    }
462
463    public function nextSequenceValue( $seqName ) {
464        return new NextSequenceValue;
465    }
466
467    /**
468     * @param string $table
469     * @return array<string,string>
470     */
471    public function getValueTypesForWithClause( $table ) {
472        $typesByColumn = [];
473
474        $flags = self::QUERY_IGNORE_DBO_TRX | self::QUERY_CHANGE_NONE;
475        $encTable = $this->addQuotes( $table );
476        foreach ( $this->getCoreSchemas() as $schema ) {
477            $encSchema = $this->addQuotes( $schema );
478            $sql = "SELECT column_name,udt_name " .
479                "FROM information_schema.columns " .
480                "WHERE table_name = $encTable AND table_schema = $encSchema";
481            $query = new Query( $sql, $flags, 'SELECT' );
482            $res = $this->query( $query, __METHOD__ );
483            if ( $res->numRows() ) {
484                foreach ( $res as $row ) {
485                    $typesByColumn[$row->column_name] = $row->udt_name;
486                }
487                break;
488            }
489        }
490
491        return $typesByColumn;
492    }
493
494    public function textFieldSize( $table, $field ) {
495        wfDeprecated( __METHOD__, '1.43' );
496        $flags = self::QUERY_IGNORE_DBO_TRX | self::QUERY_CHANGE_NONE;
497        $encTable = $this->tableName( $table );
498        $sql = "SELECT t.typname as ftype,a.atttypmod as size
499            FROM pg_class c, pg_attribute a, pg_type t
500            WHERE relname='$encTable' AND a.attrelid=c.oid AND
501                a.atttypid=t.oid and a.attname='$field'";
502        $query = new Query( $sql, $flags, 'SELECT' );
503        $res = $this->query( $query, __METHOD__ );
504        $row = $res->fetchObject();
505        if ( $row->ftype == 'varchar' ) {
506            $size = $row->size - 4;
507        } else {
508            $size = $row->size;
509        }
510
511        return $size;
512    }
513
514    public function wasDeadlock() {
515        // https://www.postgresql.org/docs/9.2/static/errcodes-appendix.html
516        return $this->lastErrno() === '40P01';
517    }
518
519    protected function isConnectionError( $errno ) {
520        // https://www.postgresql.org/docs/9.2/static/errcodes-appendix.html
521        static $codes = [ '08000', '08003', '08006', '08001', '08004', '57P01', '57P03', '53300' ];
522
523        return in_array( $errno, $codes, true );
524    }
525
526    protected function isQueryTimeoutError( $errno ) {
527        // https://www.postgresql.org/docs/9.2/static/errcodes-appendix.html
528        return ( $errno === '57014' );
529    }
530
531    protected function isKnownStatementRollbackError( $errno ) {
532        return false; // transaction has to be rolled-back from error state
533    }
534
535    public function duplicateTableStructure(
536        $oldName, $newName, $temporary = false, $fname = __METHOD__
537    ) {
538        $newNameE = $this->platform->addIdentifierQuotes( $newName );
539        $oldNameE = $this->platform->addIdentifierQuotes( $oldName );
540
541        $temporary = $temporary ? 'TEMPORARY' : '';
542        $query = new Query(
543            "CREATE $temporary TABLE $newNameE " .
544            "(LIKE $oldNameE INCLUDING DEFAULTS INCLUDING INDEXES)",
545            self::QUERY_PSEUDO_PERMANENT | self::QUERY_CHANGE_SCHEMA,
546            $temporary ? 'CREATE TEMPORARY' : 'CREATE',
547            // Use a dot to avoid double-prefixing in Database::getTempTableWrites()
548            '.' . $newName
549        );
550        $ret = $this->query( $query, $fname );
551        if ( !$ret ) {
552            return $ret;
553        }
554
555        $sql = 'SELECT attname FROM pg_class c'
556            . ' JOIN pg_namespace n ON (n.oid = c.relnamespace)'
557            . ' JOIN pg_attribute a ON (a.attrelid = c.oid)'
558            . ' JOIN pg_attrdef d ON (c.oid=d.adrelid and a.attnum=d.adnum)'
559            . ' WHERE relkind = \'r\''
560            . ' AND nspname = ' . $this->addQuotes( $this->getCoreSchema() )
561            . ' AND relname = ' . $this->addQuotes( $oldName )
562            . ' AND pg_get_expr(adbin, adrelid) LIKE \'nextval(%\'';
563        $query = new Query(
564            $sql,
565            self::QUERY_IGNORE_DBO_TRX | self::QUERY_CHANGE_NONE,
566            'SELECT'
567        );
568
569        $res = $this->query( $query, $fname );
570        $row = $res->fetchObject();
571        if ( $row ) {
572            $field = $row->attname;
573            $newSeq = "{$newName}_{$field}_seq";
574            $fieldE = $this->platform->addIdentifierQuotes( $field );
575            $newSeqE = $this->platform->addIdentifierQuotes( $newSeq );
576            $newSeqQ = $this->addQuotes( $newSeq );
577            $query = new Query(
578                "CREATE $temporary SEQUENCE $newSeqE OWNED BY $newNameE.$fieldE",
579                self::QUERY_CHANGE_SCHEMA,
580                'CREATE',
581                // Do not treat this is as a table modification on top of the CREATE above.
582                null
583            );
584            $this->query( $query, $fname );
585            $query = new Query(
586                "ALTER TABLE $newNameE ALTER COLUMN $fieldE SET DEFAULT nextval({$newSeqQ}::regclass)",
587                self::QUERY_CHANGE_SCHEMA,
588                'ALTER',
589                // Do not treat this is as a table modification on top of the CREATE above.
590                null
591            );
592            $this->query( $query, $fname );
593        }
594
595        return $ret;
596    }
597
598    public function truncateTable( $table, $fname = __METHOD__ ) {
599        $sql = "TRUNCATE TABLE " . $this->tableName( $table ) . " RESTART IDENTITY";
600        $query = new Query( $sql, self::QUERY_CHANGE_SCHEMA, 'TRUNCATE', $table );
601        $this->query( $query, $fname );
602    }
603
604    /**
605     * @param string $prefix Only show tables with this prefix, e.g. mw_
606     * @param string $fname Calling function name
607     * @return string[]
608     * @suppress SecurityCheck-SQLInjection array_map not recognized T204911
609     */
610    public function listTables( $prefix = '', $fname = __METHOD__ ) {
611        $eschemas = implode( ',', array_map( [ $this, 'addQuotes' ], $this->getCoreSchemas() ) );
612        $query = new Query(
613            "SELECT DISTINCT tablename FROM pg_tables WHERE schemaname IN ($eschemas)",
614            self::QUERY_IGNORE_DBO_TRX | self::QUERY_CHANGE_NONE,
615            'SELECT'
616        );
617        $result = $this->query( $query, $fname );
618        $endArray = [];
619
620        foreach ( $result as $table ) {
621            $vars = get_object_vars( $table );
622            $table = array_pop( $vars );
623            if ( $prefix == '' || strpos( $table, $prefix ) === 0 ) {
624                $endArray[] = $table;
625            }
626        }
627
628        return $endArray;
629    }
630
631    /**
632     * Posted by cc[plus]php[at]c2se[dot]com on 25-Mar-2009 09:12
633     * to https://www.php.net/manual/en/ref.pgsql.php
634     *
635     * Parsing a postgres array can be a tricky problem, he's my
636     * take on this, it handles multi-dimensional arrays plus
637     * escaping using a nasty regexp to determine the limits of each
638     * data-item.
639     *
640     * This should really be handled by PHP PostgreSQL module
641     *
642     * @since 1.19
643     * @param string $text Postgreql array returned in a text form like {a,b}
644     * @param string[] &$output
645     * @param int|false $limit
646     * @param int $offset
647     * @return string[]
648     */
649    private function pg_array_parse( $text, &$output, $limit = false, $offset = 1 ) {
650        if ( $limit === false ) {
651            $limit = strlen( $text ) - 1;
652            $output = [];
653        }
654        if ( $text == '{}' ) {
655            return $output;
656        }
657        do {
658            if ( $text[$offset] != '{' ) {
659                preg_match( "/(\\{?\"([^\"\\\\]|\\\\.)*\"|[^,{}]+)+([,}]+)/",
660                    $text, $match, 0, $offset );
661                $offset += strlen( $match[0] );
662                $output[] = ( $match[1][0] != '"'
663                    ? $match[1]
664                    : stripcslashes( substr( $match[1], 1, -1 ) ) );
665                if ( $match[3] == '},' ) {
666                    return $output;
667                }
668            } else {
669                $offset = $this->pg_array_parse( $text, $output, $limit, $offset + 1 );
670            }
671        } while ( $limit > $offset );
672
673        return $output;
674    }
675
676    public function getSoftwareLink() {
677        return '[{{int:version-db-postgres-url}} PostgreSQL]';
678    }
679
680    /**
681     * Return current schema (executes SELECT current_schema())
682     * Needs transaction
683     *
684     * @since 1.19
685     * @return string Default schema for the current session
686     */
687    public function getCurrentSchema() {
688        $query = new Query(
689            "SELECT current_schema()",
690            self::QUERY_IGNORE_DBO_TRX | self::QUERY_CHANGE_NONE,
691            'SELECT'
692        );
693        $res = $this->query( $query, __METHOD__ );
694        $row = $res->fetchRow();
695
696        return $row[0];
697    }
698
699    /**
700     * Return list of schemas which are accessible without schema name
701     * This is list does not contain magic keywords like "$user"
702     * Needs transaction
703     *
704     * @see getSearchPath()
705     * @see setSearchPath()
706     * @since 1.19
707     * @return array List of actual schemas for the current session
708     */
709    public function getSchemas() {
710        $query = new Query(
711            "SELECT current_schemas(false)",
712            self::QUERY_IGNORE_DBO_TRX | self::QUERY_CHANGE_NONE,
713            'SELECT'
714        );
715        $res = $this->query( $query, __METHOD__ );
716        $row = $res->fetchRow();
717        $schemas = [];
718
719        /* PHP pgsql support does not support array type, "{a,b}" string is returned */
720
721        return $this->pg_array_parse( $row[0], $schemas );
722    }
723
724    /**
725     * Return search patch for schemas
726     * This is different from getSchemas() since it contain magic keywords
727     * (like "$user").
728     * Needs transaction
729     *
730     * @since 1.19
731     * @return array How to search for table names schemas for the current user
732     */
733    public function getSearchPath() {
734        $query = new Query(
735            "SHOW search_path",
736            self::QUERY_IGNORE_DBO_TRX | self::QUERY_CHANGE_NONE,
737            'SHOW'
738        );
739        $res = $this->query( $query, __METHOD__ );
740        $row = $res->fetchRow();
741
742        /* PostgreSQL returns SHOW values as strings */
743
744        return explode( ",", $row[0] );
745    }
746
747    /**
748     * Update search_path, values should already be sanitized
749     * Values may contain magic keywords like "$user"
750     * @since 1.19
751     *
752     * @param string[] $search_path List of schemas to be searched by default
753     */
754    private function setSearchPath( $search_path ) {
755        $query = new Query(
756            "SET search_path = " . implode( ", ", $search_path ),
757            self::QUERY_CHANGE_TRX,
758            'SET'
759        );
760        $this->query( $query, __METHOD__ );
761    }
762
763    /**
764     * Determine default schema for the current application
765     * Adjust this session schema search path if desired schema exists
766     * and is not already there.
767     *
768     * We need to have name of the core schema stored to be able
769     * to query database metadata.
770     *
771     * This will be also called by the installer after the schema is created
772     *
773     * @since 1.19
774     *
775     * @param string|null $desiredSchema
776     */
777    public function determineCoreSchema( $desiredSchema ) {
778        if ( $this->trxLevel() ) {
779            // We do not want the schema selection to change on ROLLBACK or INSERT SELECT.
780            // See https://www.postgresql.org/docs/8.3/sql-set.html
781            throw new DBUnexpectedError(
782                $this,
783                __METHOD__ . ": a transaction is currently active"
784            );
785        }
786
787        if ( $this->schemaExists( $desiredSchema ) ) {
788            if ( in_array( $desiredSchema, $this->getSchemas() ) ) {
789                $this->platform->setCoreSchema( $desiredSchema );
790                $this->logger->debug(
791                    "Schema \"" . $desiredSchema . "\" already in the search path\n" );
792            } else {
793                // Prepend the desired schema to the search path (T17816)
794                $search_path = $this->getSearchPath();
795                array_unshift( $search_path, $this->platform->addIdentifierQuotes( $desiredSchema ) );
796                $this->setSearchPath( $search_path );
797                $this->platform->setCoreSchema( $desiredSchema );
798                $this->logger->debug(
799                    "Schema \"" . $desiredSchema . "\" added to the search path\n" );
800            }
801        } else {
802            $this->platform->setCoreSchema( $this->getCurrentSchema() );
803            $this->logger->debug(
804                "Schema \"" . $desiredSchema . "\" not found, using current \"" .
805                $this->getCoreSchema() . "\"\n" );
806        }
807    }
808
809    /**
810     * Return schema name for core application tables
811     *
812     * @since 1.19
813     * @return string Core schema name
814     */
815    public function getCoreSchema() {
816        return $this->platform->getCoreSchema();
817    }
818
819    /**
820     * Return schema names for temporary tables and core application tables
821     *
822     * @since 1.31
823     * @return string[] schema names
824     */
825    public function getCoreSchemas() {
826        if ( $this->tempSchema ) {
827            return [ $this->tempSchema, $this->getCoreSchema() ];
828        }
829        $query = new Query(
830            "SELECT nspname FROM pg_catalog.pg_namespace n WHERE n.oid = pg_my_temp_schema()",
831            self::QUERY_IGNORE_DBO_TRX | self::QUERY_CHANGE_NONE,
832            'SELECT'
833        );
834        $res = $this->query( $query, __METHOD__ );
835        $row = $res->fetchObject();
836        if ( $row ) {
837            $this->tempSchema = $row->nspname;
838            return [ $this->tempSchema, $this->getCoreSchema() ];
839        }
840
841        return [ $this->getCoreSchema() ];
842    }
843
844    public function getServerVersion() {
845        if ( !isset( $this->numericVersion ) ) {
846            // Works on PG 7.4+
847            $this->numericVersion = pg_version( $this->getBindingHandle() )['server'];
848        }
849
850        return $this->numericVersion;
851    }
852
853    /**
854     * Query whether a given relation exists (in the given schema, or the
855     * default mw one if not given)
856     * @param string $table
857     * @param array|string $types
858     * @param bool|string $schema
859     * @return bool
860     */
861    private function relationExists( $table, $types, $schema = false ) {
862        if ( !is_array( $types ) ) {
863            $types = [ $types ];
864        }
865        if ( $schema === false ) {
866            $schemas = $this->getCoreSchemas();
867        } else {
868            $schemas = [ $schema ];
869        }
870        $table = $this->tableName( $table, 'raw' );
871        $etable = $this->addQuotes( $table );
872        foreach ( $schemas as $schema ) {
873            $eschema = $this->addQuotes( $schema );
874            $sql = "SELECT 1 FROM pg_catalog.pg_class c, pg_catalog.pg_namespace n "
875                . "WHERE c.relnamespace = n.oid AND c.relname = $etable AND n.nspname = $eschema "
876                . "AND c.relkind IN ('" . implode( "','", $types ) . "')";
877            $query = new Query(
878                $sql,
879                self::QUERY_IGNORE_DBO_TRX | self::QUERY_CHANGE_NONE,
880                'SELECT'
881            );
882            $res = $this->query( $query, __METHOD__ );
883            if ( $res && $res->numRows() ) {
884                return true;
885            }
886        }
887
888        return false;
889    }
890
891    /**
892     * For backward compatibility, this function checks both tables and views.
893     * @param string $table
894     * @param string $fname
895     * @param bool|string $schema
896     * @return bool
897     */
898    public function tableExists( $table, $fname = __METHOD__, $schema = false ) {
899        return $this->relationExists( $table, [ 'r', 'v' ], $schema );
900    }
901
902    public function sequenceExists( $sequence, $schema = false ) {
903        return $this->relationExists( $sequence, 'S', $schema );
904    }
905
906    public function constraintExists( $table, $constraint ) {
907        foreach ( $this->getCoreSchemas() as $schema ) {
908            $sql = sprintf( "SELECT 1 FROM information_schema.table_constraints " .
909                "WHERE constraint_schema = %s AND table_name = %s AND constraint_name = %s",
910                $this->addQuotes( $schema ),
911                $this->addQuotes( $table ),
912                $this->addQuotes( $constraint )
913            );
914            $query = new Query(
915                $sql,
916                self::QUERY_IGNORE_DBO_TRX | self::QUERY_CHANGE_NONE,
917                'SELECT'
918            );
919            $res = $this->query( $query, __METHOD__ );
920            if ( $res && $res->numRows() ) {
921                return true;
922            }
923        }
924        return false;
925    }
926
927    /**
928     * Query whether a given schema exists. Returns true if it does, false if it doesn't.
929     * @param string|null $schema
930     * @return bool
931     */
932    public function schemaExists( $schema ) {
933        if ( !strlen( $schema ?? '' ) ) {
934            return false; // short-circuit
935        }
936        $query = new Query(
937            "SELECT 1 FROM pg_catalog.pg_namespace " .
938            "WHERE nspname = " . $this->addQuotes( $schema ) . " LIMIT 1",
939            self::QUERY_IGNORE_DBO_TRX | self::QUERY_CHANGE_NONE,
940            'SELECT'
941        );
942        $res = $this->query( $query, __METHOD__ );
943
944        return ( $res->numRows() > 0 );
945    }
946
947    /**
948     * Returns true if a given role (i.e. user) exists, false otherwise.
949     * @param string $roleName
950     * @return bool
951     */
952    public function roleExists( $roleName ) {
953        $query = new Query(
954            "SELECT 1 FROM pg_catalog.pg_roles " .
955            "WHERE rolname = " . $this->addQuotes( $roleName ) . " LIMIT 1",
956            self::QUERY_IGNORE_DBO_TRX | self::QUERY_CHANGE_NONE,
957            'SELECT'
958        );
959        $res = $this->query( $query, __METHOD__ );
960
961        return ( $res->numRows() > 0 );
962    }
963
964    /**
965     * @param string $table
966     * @param string $field
967     * @return PostgresField|null
968     */
969    public function fieldInfo( $table, $field ) {
970        return PostgresField::fromText( $this, $table, $field );
971    }
972
973    public function encodeBlob( $b ) {
974        $conn = $this->getBindingHandle();
975
976        return new PostgresBlob( pg_escape_bytea( $conn, $b ) );
977    }
978
979    public function decodeBlob( $b ) {
980        if ( $b instanceof PostgresBlob ) {
981            $b = $b->fetch();
982        } elseif ( $b instanceof Blob ) {
983            return $b->fetch();
984        }
985
986        return pg_unescape_bytea( $b );
987    }
988
989    public function strencode( $s ) {
990        // Should not be called by us
991        return pg_escape_string( $this->getBindingHandle(), (string)$s );
992    }
993
994    public function addQuotes( $s ) {
995        $conn = $this->getBindingHandle();
996
997        if ( $s === null ) {
998            return 'NULL';
999        } elseif ( is_bool( $s ) ) {
1000            return (string)intval( $s );
1001        } elseif ( is_int( $s ) ) {
1002            return (string)$s;
1003        } elseif ( $s instanceof Blob ) {
1004            if ( $s instanceof PostgresBlob ) {
1005                $s = $s->fetch();
1006            } else {
1007                $s = pg_escape_bytea( $conn, $s->fetch() );
1008            }
1009            return "'$s'";
1010        } elseif ( $s instanceof NextSequenceValue ) {
1011            return 'DEFAULT';
1012        }
1013
1014        return "'" . pg_escape_string( $conn, (string)$s ) . "'";
1015    }
1016
1017    public function streamStatementEnd( &$sql, &$newLine ) {
1018        # Allow dollar quoting for function declarations
1019        if ( str_starts_with( $newLine, '$mw$' ) ) {
1020            if ( $this->delimiter ) {
1021                $this->delimiter = false;
1022            } else {
1023                $this->delimiter = ';';
1024            }
1025        }
1026
1027        return parent::streamStatementEnd( $sql, $newLine );
1028    }
1029
1030    public function doLockIsFree( string $lockName, string $method ) {
1031        $query = new Query(
1032            $this->platform->lockIsFreeSQLText( $lockName ),
1033            self::QUERY_CHANGE_LOCKS,
1034            'SELECT'
1035        );
1036        $res = $this->query( $query, $method );
1037        $row = $res->fetchObject();
1038
1039        return (bool)$row->unlocked;
1040    }
1041
1042    public function doLock( string $lockName, string $method, int $timeout ) {
1043        $query = new Query(
1044            $this->platform->lockSQLText( $lockName, $timeout ),
1045            self::QUERY_CHANGE_LOCKS,
1046            'SELECT'
1047        );
1048
1049        $acquired = null;
1050        $loop = new WaitConditionLoop(
1051            function () use ( $query, $method, &$acquired ) {
1052                $res = $this->query( $query, $method );
1053                $row = $res->fetchObject();
1054
1055                if ( $row->acquired !== null ) {
1056                    $acquired = (float)$row->acquired;
1057
1058                    return WaitConditionLoop::CONDITION_REACHED;
1059                }
1060
1061                return WaitConditionLoop::CONDITION_CONTINUE;
1062            },
1063            $timeout
1064        );
1065        $loop->invoke();
1066
1067        return $acquired;
1068    }
1069
1070    public function doUnlock( string $lockName, string $method ) {
1071        $query = new Query(
1072            $this->platform->unlockSQLText( $lockName ),
1073            self::QUERY_CHANGE_LOCKS,
1074            'SELECT'
1075        );
1076        $result = $this->query( $query, $method );
1077        $row = $result->fetchObject();
1078
1079        return (bool)$row->released;
1080    }
1081
1082    protected function doFlushSession( $fname ) {
1083        $flags = self::QUERY_CHANGE_LOCKS | self::QUERY_NO_RETRY;
1084
1085        // https://www.postgresql.org/docs/9.1/functions-admin.html
1086        $sql = "SELECT pg_advisory_unlock_all()";
1087        $query = new Query( $sql, $flags, 'UNLOCK' );
1088        $qs = $this->executeQuery( $query, __METHOD__, $flags );
1089        if ( $qs->res === false ) {
1090            $this->reportQueryError( $qs->message, $qs->code, $sql, $fname, true );
1091        }
1092    }
1093
1094    public function serverIsReadOnly() {
1095        $query = new Query(
1096            "SHOW default_transaction_read_only",
1097            self::QUERY_IGNORE_DBO_TRX | self::QUERY_CHANGE_NONE,
1098            'SHOW'
1099        );
1100        $res = $this->query( $query, __METHOD__ );
1101        $row = $res->fetchObject();
1102
1103        return $row && strtolower( $row->default_transaction_read_only ) === 'on';
1104    }
1105
1106    protected function getInsertIdColumnForUpsert( $table ) {
1107        $column = null;
1108
1109        $flags = self::QUERY_IGNORE_DBO_TRX | self::QUERY_CHANGE_NONE;
1110        $encTable = $this->addQuotes( $this->tableName( $table, 'raw' ) );
1111        foreach ( $this->getCoreSchemas() as $schema ) {
1112            $encSchema = $this->addQuotes( $schema );
1113            $query = new Query(
1114                "SELECT column_name,data_type,column_default " .
1115                    "FROM information_schema.columns " .
1116                    "WHERE table_name = $encTable AND table_schema = $encSchema",
1117                self::QUERY_IGNORE_DBO_TRX | self::QUERY_CHANGE_NONE,
1118                'SELECT'
1119            );
1120            $res = $this->query( $query, __METHOD__ );
1121            if ( $res->numRows() ) {
1122                foreach ( $res as $row ) {
1123                    if (
1124                        $row->column_default !== null &&
1125                        str_starts_with( $row->column_default, "nextval(" ) &&
1126                        in_array( $row->data_type, [ 'integer', 'bigint' ], true )
1127                    ) {
1128                        $column = $row->column_name;
1129                    }
1130                }
1131                break;
1132            }
1133        }
1134
1135        return $column;
1136    }
1137
1138    public static function getAttributes() {
1139        return [ self::ATTR_SCHEMAS_AS_TABLE_GROUPS => true ];
1140    }
1141}