Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
9.35% covered (danger)
9.35%
10 / 107
0.00% covered (danger)
0.00%
0 / 19
CRAP
0.00% covered (danger)
0.00%
0 / 1
PostgresPlatform
9.35% covered (danger)
9.35%
10 / 107
0.00% covered (danger)
0.00%
0 / 19
2066.52
0.00% covered (danger)
0.00%
0 / 1
 limitResult
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 buildConcat
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 buildGroupConcat
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 timestamp
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 buildStringCast
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 implicitOrderby
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getCoreSchema
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setCoreSchema
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 selectSQLText
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
182
 makeSelectOptions
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
56
 getDatabaseAndTableIdentifier
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
5.03
 relationSchemaQualifier
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 makeInsertLists
0.00% covered (danger)
0.00%
0 / 30
0.00% covered (danger)
0.00%
0 / 1
90
 makeInsertNonConflictingVerbAndOptions
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 makeUpdateOptionsArray
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 isTransactableQuery
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 lockSQLText
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 unlockSQLText
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 bigintFromLockName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * @license GPL-2.0-or-later
4 * @file
5 */
6namespace Wikimedia\Rdbms\Platform;
7
8use Wikimedia\Rdbms\DBLanguageError;
9use Wikimedia\Rdbms\Query;
10use Wikimedia\Timestamp\ConvertibleTimestamp;
11use Wikimedia\Timestamp\TimestampFormat as TS;
12
13/**
14 * @since 1.39
15 * @see ISQLPlatform
16 */
17class PostgresPlatform extends SQLPlatform {
18    private ?string $coreSchema = null;
19
20    /** @inheritDoc */
21    public function limitResult( $sql, $limit, $offset = false ) {
22        return "$sql LIMIT $limit " . ( is_numeric( $offset ) ? " OFFSET {$offset} " : '' );
23    }
24
25    /** @inheritDoc */
26    public function buildConcat( $stringList ) {
27        return implode( ' || ', $stringList );
28    }
29
30    /** @inheritDoc */
31    public function buildGroupConcat( $field, $delim ): string {
32        return "array_to_string(array_agg($field)," . $this->quoter->addQuotes( $delim ) . ')';
33    }
34
35    /** @inheritDoc */
36    public function timestamp( $ts = 0 ) {
37        $ct = new ConvertibleTimestamp( $ts );
38
39        return $ct->getTimestamp( TS::POSTGRES );
40    }
41
42    /** @inheritDoc */
43    public function buildStringCast( $field ) {
44        return $field . '::text';
45    }
46
47    /** @inheritDoc */
48    public function implicitOrderby() {
49        return false;
50    }
51
52    public function getCoreSchema(): string {
53        return $this->coreSchema;
54    }
55
56    public function setCoreSchema( string $coreSchema ): void {
57        $this->coreSchema = $coreSchema;
58    }
59
60    /** @inheritDoc */
61    public function selectSQLText(
62        $tables, $vars, $conds = '', $fname = __METHOD__, $options = [], $join_conds = []
63    ) {
64        if ( is_string( $options ) ) {
65            $options = [ $options ];
66        }
67
68        // Change the FOR UPDATE option as necessary based on the join conditions. Then pass
69        // to the parent function to get the actual SQL text.
70        // In Postgres when using FOR UPDATE, only the main table and tables that are inner joined
71        // can be locked. That means tables in an outer join cannot be FOR UPDATE locked. Trying to
72        // do so causes a DB error. This wrapper checks which tables can be locked and adjusts it
73        // accordingly.
74        // MySQL uses "ORDER BY NULL" as an optimization hint, but that is illegal in PostgreSQL.
75        if ( is_array( $options ) ) {
76            $forUpdateKey = array_search( 'FOR UPDATE', $options, true );
77            if ( $forUpdateKey !== false && $join_conds ) {
78                unset( $options[$forUpdateKey] );
79                $options['FOR UPDATE'] = [];
80
81                $toCheck = $tables;
82                reset( $toCheck );
83                while ( $toCheck ) {
84                    $alias = key( $toCheck );
85                    $table = $toCheck[$alias];
86                    unset( $toCheck[$alias] );
87
88                    if ( !is_string( $alias ) ) {
89                        // No alias? Set it equal to the table name
90                        $alias = $table;
91                    }
92
93                    if ( !isset( $join_conds[$alias] ) ||
94                        !preg_match( '/^(?:LEFT|RIGHT|FULL)(?: OUTER)? JOIN$/i', $join_conds[$alias][0] )
95                    ) {
96                        if ( is_array( $table ) ) {
97                            // It's a parenthesized group, process all the tables inside the group.
98                            $toCheck = array_merge( $toCheck, $table );
99                        } else {
100                            // If an alias is declared, then any FOR UPDATE FOR must use it
101                            $options['FOR UPDATE'][] = $alias;
102                        }
103                    }
104                }
105            }
106
107            if (
108                isset( $options['ORDER BY'] ) &&
109                ( $options['ORDER BY'] == 'NULL' || $options['ORDER BY'] == [ 'NULL' ] )
110            ) {
111                unset( $options['ORDER BY'] );
112            }
113        }
114
115        return parent::selectSQLText( $tables, $vars, $conds, $fname, $options, $join_conds );
116    }
117
118    /** @inheritDoc */
119    protected function makeSelectOptions( array $options ) {
120        $preLimitTail = $postLimitTail = '';
121        $startOpts = '';
122
123        $noKeyOptions = [];
124        foreach ( $options as $key => $option ) {
125            if ( is_numeric( $key ) ) {
126                $noKeyOptions[$option] = true;
127            }
128        }
129
130        $preLimitTail .= $this->makeGroupByWithHaving( $options );
131
132        $preLimitTail .= $this->makeOrderBy( $options );
133
134        if ( isset( $options['FOR UPDATE'] ) ) {
135            $postLimitTail .= ' FOR UPDATE OF ' . implode(
136                ', ',
137                array_map( $this->addIdentifierQuotes( ... ), $options['FOR UPDATE'] )
138            );
139        } elseif ( isset( $noKeyOptions['FOR UPDATE'] ) ) {
140            $postLimitTail .= ' FOR UPDATE';
141        }
142
143        if ( isset( $noKeyOptions['DISTINCT'] ) || isset( $noKeyOptions['DISTINCTROW'] ) ) {
144            $startOpts .= 'DISTINCT';
145        }
146
147        return [ $startOpts, $preLimitTail, $postLimitTail ];
148    }
149
150    /** @inheritDoc */
151    public function getDatabaseAndTableIdentifier( string $table ) {
152        $components = $this->qualifiedTableComponents( $table );
153        switch ( count( $components ) ) {
154            case 1:
155                return [ $this->currentDomain->getDatabase(), $components[0] ];
156            case 2:
157                return [ $this->currentDomain->getDatabase(), $components[1] ];
158            case 3:
159                return [ $components[0], $components[2] ];
160            default:
161                throw new DBLanguageError( 'Too many table components' );
162        }
163    }
164
165    /** @inheritDoc */
166    protected function relationSchemaQualifier() {
167        if ( $this->coreSchema === $this->currentDomain->getSchema() ) {
168            // The schema to be used is now in the search path; no need for explicit qualification
169            return '';
170        }
171
172        return parent::relationSchemaQualifier();
173    }
174
175    /** @inheritDoc */
176    public function makeInsertLists( array $rows, $aliasPrefix = '', array $typeByColumn = [] ) {
177        $firstRow = $rows[0];
178        if ( !is_array( $firstRow ) || !$firstRow ) {
179            throw new DBLanguageError( 'Got an empty row list or empty row' );
180        }
181        // List of columns that define the value tuple ordering
182        $tupleColumns = array_keys( $firstRow );
183
184        $valueTuples = [];
185        foreach ( $rows as $row ) {
186            $rowColumns = array_keys( $row );
187            // VALUES(...) requires a uniform correspondence of (column => value)
188            if ( $rowColumns !== $tupleColumns ) {
189                throw new DBLanguageError(
190                    'All rows must specify the same columns in multi-row inserts. Found a row with (' .
191                    implode( ', ', $rowColumns ) . ') ' .
192                    'instead of expected (' . implode( ', ', $tupleColumns ) . ') as in the first row'
193                );
194            }
195            // Make the value tuple that defines this row
196            $typedRowValues = [];
197            foreach ( $row as $column => $value ) {
198                $type = $typeByColumn[$column] ?? null;
199                if ( $value === null ) {
200                    $typedRowValues[] = 'NULL';
201                } elseif ( $type !== null ) {
202                    $typedRowValues[] = $this->quoter->addQuotes( $value ) . '::' . $type;
203                } else {
204                    $typedRowValues[] = $this->quoter->addQuotes( $value );
205                }
206            }
207            $valueTuples[] = '(' . implode( ',', $typedRowValues ) . ')';
208        }
209
210        $magicAliasFields = [];
211        foreach ( $tupleColumns as $column ) {
212            $magicAliasFields[] = $aliasPrefix . $column;
213        }
214
215        return [
216            $this->makeList( $tupleColumns, self::LIST_NAMES ),
217            implode( ',', $valueTuples ),
218            $this->makeList( $magicAliasFields, self::LIST_NAMES )
219        ];
220    }
221
222    /** @inheritDoc */
223    protected function makeInsertNonConflictingVerbAndOptions() {
224        return [ 'INSERT INTO', 'ON CONFLICT DO NOTHING' ];
225    }
226
227    /** @inheritDoc */
228    protected function makeUpdateOptionsArray( $options ) {
229        $options = $this->normalizeOptions( $options );
230        // PostgreSQL doesn't support anything like "ignore" for UPDATE.
231        $options = array_diff( $options, [ 'IGNORE' ] );
232
233        return parent::makeUpdateOptionsArray( $options );
234    }
235
236    /** @inheritDoc */
237    public function isTransactableQuery( Query $sql ) {
238        return parent::isTransactableQuery( $sql ) &&
239            !preg_match( '/^SELECT\s+pg_(try_|)advisory_\w+\(/', $sql->getSQL() );
240    }
241
242    /** @inheritDoc */
243    public function lockSQLText( $lockName, $timeout ) {
244        // http://www.postgresql.org/docs/9.2/static/functions-admin.html#FUNCTIONS-ADVISORY-LOCKS
245        $key = $this->quoter->addQuotes( $this->bigintFromLockName( $lockName ) );
246        return "SELECT (CASE WHEN pg_try_advisory_lock($key) " .
247            "THEN EXTRACT(epoch from clock_timestamp()) " .
248            "ELSE NULL " .
249            "END) AS acquired";
250    }
251
252    /** @inheritDoc */
253    public function unlockSQLText( $lockName ) {
254        // http://www.postgresql.org/docs/9.2/static/functions-admin.html#FUNCTIONS-ADVISORY-LOCKS
255        $key = $this->quoter->addQuotes( $this->bigintFromLockName( $lockName ) );
256        return "SELECT pg_advisory_unlock($key) AS released";
257    }
258
259    /**
260     * @param string $lockName
261     * @return string Integer
262     */
263    private function bigintFromLockName( $lockName ) {
264        return \Wikimedia\base_convert( substr( sha1( $lockName ), 0, 15 ), 16, 10 );
265    }
266}