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