Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
12.41% covered (danger)
12.41%
54 / 435
0.00% covered (danger)
0.00%
0 / 28
CRAP
0.00% covered (danger)
0.00%
0 / 1
PostgresUpdater
12.41% covered (danger)
12.41%
54 / 435
0.00% covered (danger)
0.00%
0 / 28
9000.93
0.00% covered (danger)
0.00%
0 / 1
 getCoreUpdateList
0.00% covered (danger)
0.00%
0 / 67
0.00% covered (danger)
0.00%
0 / 1
2
 getInitialUpdateKeys
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 describeTable
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
12
 describeIndex
0.00% covered (danger)
0.00%
0 / 32
0.00% covered (danger)
0.00%
0 / 1
42
 fkeyDeltype
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
6
 ruleDef
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
6
 addSequence
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 dropSequence
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 renameSequence
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 setSequenceOwner
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 renameTable
30.00% covered (danger)
30.00%
3 / 10
0.00% covered (danger)
0.00%
0 / 1
9.49
 renameIndex
20.00% covered (danger)
20.00%
4 / 20
0.00% covered (danger)
0.00%
0 / 1
32.09
 dropPgField
33.33% covered (danger)
33.33%
3 / 9
0.00% covered (danger)
0.00%
0 / 1
5.67
 addPgField
33.33% covered (danger)
33.33%
3 / 9
0.00% covered (danger)
0.00%
0 / 1
5.67
 changeField
9.09% covered (danger)
9.09%
2 / 22
0.00% covered (danger)
0.00%
0 / 1
43.81
 changeFieldPurgeTable
17.39% covered (danger)
17.39%
4 / 23
0.00% covered (danger)
0.00%
0 / 1
26.29
 setDefault
25.00% covered (danger)
25.00%
3 / 12
0.00% covered (danger)
0.00%
0 / 1
15.55
 dropDefault
37.50% covered (danger)
37.50%
3 / 8
0.00% covered (danger)
0.00%
0 / 1
7.91
 changeNullableField
27.27% covered (danger)
27.27%
6 / 22
0.00% covered (danger)
0.00%
0 / 1
32.62
 addPgIndex
27.27% covered (danger)
27.27%
3 / 11
0.00% covered (danger)
0.00%
0 / 1
14.62
 addPgExtIndex
30.00% covered (danger)
30.00%
3 / 10
0.00% covered (danger)
0.00%
0 / 1
9.49
 dropFkey
21.43% covered (danger)
21.43%
3 / 14
0.00% covered (danger)
0.00%
0 / 1
17.13
 changeFkeyDeferrable
14.29% covered (danger)
14.29%
3 / 21
0.00% covered (danger)
0.00%
0 / 1
28.67
 dropPgIndex
50.00% covered (danger)
50.00%
3 / 6
0.00% covered (danger)
0.00%
0 / 1
4.12
 checkIndex
28.57% covered (danger)
28.57%
4 / 14
0.00% covered (danger)
0.00%
0 / 1
14.11
 changePrimaryKey
8.33% covered (danger)
8.33%
2 / 24
0.00% covered (danger)
0.00%
0 / 1
16.32
 dropConstraint
13.33% covered (danger)
13.33%
2 / 15
0.00% covered (danger)
0.00%
0 / 1
21.27
 migrateSearchindex
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
42
1<?php
2/**
3 * PostgreSQL-specific updater.
4 *
5 * @license GPL-2.0-or-later
6 * @file
7 * @ingroup Installer
8 */
9
10namespace MediaWiki\Installer;
11
12use FixInconsistentRedirects;
13use FixWrongPasswordPrefixes;
14use MediaWiki\Maintenance\FixAutoblockLogTitles;
15use MigrateExternallinks;
16use MigrateRevisionCommentTemp;
17use PopulateUserIsTemp;
18use Wikimedia\Rdbms\DatabasePostgres;
19
20/**
21 * Class for handling updates to Postgres databases.
22 *
23 * @ingroup Installer
24 * @since 1.17
25 */
26class PostgresUpdater extends DatabaseUpdater {
27
28    /**
29     * @var DatabasePostgres
30     */
31    protected $db;
32
33    /**
34     * @return array
35     */
36    protected function getCoreUpdateList() {
37        return [
38            // 1.40
39            [ 'addField', 'externallinks', 'el_to_path', 'patch-externallinks-el_to_path.sql' ],
40
41            // 1.41
42            [ 'addField', 'user', 'user_is_temp', 'patch-user-user_is_temp.sql' ],
43            [ 'runMaintenance', MigrateRevisionCommentTemp::class ],
44            [ 'dropTable', 'revision_comment_temp' ],
45            [ 'runMaintenance', MigrateExternallinks::class ],
46            [ 'modifyField', 'externallinks', 'el_to', 'patch-externallinks-el_to_default.sql' ],
47            [ 'addField', 'pagelinks', 'pl_target_id', 'patch-pagelinks-target_id.sql' ],
48            [ 'dropField', 'externallinks', 'el_to', 'patch-externallinks-drop-el_to.sql' ],
49            [ 'runMaintenance', FixInconsistentRedirects::class ],
50            [ 'modifyField', 'image', 'img_size', 'patch-image-img_size_to_bigint.sql' ],
51            [ 'modifyField', 'filearchive', 'fa_size', 'patch-filearchive-fa_size_to_bigint.sql' ],
52            [ 'modifyField', 'oldimage', 'oi_size', 'patch-oldimage-oi_size_to_bigint.sql' ],
53            [ 'modifyField', 'uploadstash', 'us_size', 'patch-uploadstash-us_size_to_bigint.sql' ],
54
55            // 1.42
56            [ 'addField', 'user_autocreate_serial', 'uas_year', 'patch-user_autocreate_serial-uas_year.sql' ],
57            [ 'addTable', 'block_target', 'patch-block_target.sql' ],
58            [ 'dropIndex', 'categorylinks', 'cl_collation_ext', 'patch-drop-cl_collation_ext.sql' ],
59            [ 'runMaintenance', PopulateUserIsTemp::class ],
60            // Attempt a rename from `site_groups` to `site_group` prior to dropping the `sites` indexes,
61            // due to a typo in the original index-renaming database update from MW 1.36. (T374042)
62            [ 'renameIndex', 'sites', 'sites_group', 'site_group' ],
63            [ 'dropIndex', 'sites', 'site_type', 'patch-sites-drop_indexes.sql' ],
64            // Re-attempt to drop most of the dropped `sites`-table indexes:
65            // If a Postgres wiki previously ran `update.php` on MW 1.42 or above, the script would have errored after
66            // reaching the "DROP INDEX site_group;" line of `patch-sites-drop_indexes.sql` (due to a previous
67            // index-renaming typo from MW 1.36).
68            // However, as the `site_type` index itself _would_ have been successfully dropped, the `.sql` file listed
69            // in the `dropIndex` line above will not be re-applied on any future runs of `update.php`.
70            // Therefore, to ensure that the remaining indexes are definitely dropped on these wikis, we need to
71            // separately attempt to drop them again. (T374042; T374042#11017896)
72            [ 'dropIndex', 'sites', 'site_group', 'patch-sites-drop_site_group_index.sql' ],
73            [ 'dropIndex', 'sites', 'site_source', 'patch-sites-drop_site_source_index.sql' ],
74            [ 'dropIndex', 'sites', 'site_language', 'patch-sites-drop_site_language_index.sql' ],
75            [ 'dropIndex', 'sites', 'site_protocol', 'patch-sites-drop_site_protocol_index.sql' ],
76            [ 'dropIndex', 'sites', 'site_domain', 'patch-sites-drop_site_domain_index.sql' ],
77            [ 'dropIndex', 'sites', 'site_forward', 'patch-sites-drop_site_forward_index.sql' ],
78            [ 'dropIndex', 'iwlinks', 'iwl_prefix_from_title', 'patch-iwlinks-drop-iwl_prefix_from_title.sql' ],
79
80            // 1.43
81            [ 'migratePagelinks' ],
82            [ 'dropDefault', 'revision', 'rev_actor' ],
83            [ 'dropDefault', 'revision', 'rev_comment_id' ],
84            [ 'changeField', 'revision', 'rev_id', 'BIGINT', '' ],
85            [ 'changeField', 'revision', 'rev_parent_id', 'BIGINT', '' ],
86            [ 'changeField', 'recentchanges', 'rc_id', 'BIGINT', '' ],
87            [ 'changeField', 'change_tag', 'ct_rc_id', 'BIGINT', '' ],
88            [ 'runMaintenance', \MigrateBlocks::class ],
89            [ 'dropTable', 'ipblocks' ],
90            [ 'dropField', 'pagelinks', 'pl_title', 'patch-pagelinks-drop-pl_title.sql' ],
91            [ 'addPostDatabaseUpdateMaintenance', FixAutoblockLogTitles::class ],
92            [ 'migrateSearchindex' ],
93
94            // 1.44
95            [ 'addTable', 'file', 'patch-file.sql' ],
96            [ 'addField', 'categorylinks', 'cl_target_id', 'patch-categorylinks-target_id.sql' ],
97            [ 'addTable', 'collation', 'patch-collation.sql' ],
98            [ 'dropTable', 'module_deps' ],
99
100            // 1.45
101            [ 'addTable', 'existencelinks', 'patch-existencelinks.sql' ],
102            [ 'runMaintenance', FixWrongPasswordPrefixes::class ],
103            [ 'addIndex', 'categorylinks', 'cl_timestamp_id', 'patch-categorylinks-cl_timestamp_id.sql' ],
104            [ 'migrateCategorylinks' ],
105            [ 'normalizeCollation' ],
106            [ 'modifyPrimaryKey', 'categorylinks', [ 'cl_from', 'cl_target_id' ], 'patch-categorylinks-pk.sql' ],
107            [ 'addIndex', 'recentchanges', 'rc_source_name_timestamp',
108                'patch-recentchanges-rc_source_name_timestamp.sql' ],
109            [ 'addIndex', 'recentchanges', 'rc_name_source_patrolled_timestamp',
110                'patch-recentchanges-rc_name_source_patrolled_timestamp.sql' ],
111            [ 'dropField', 'recentchanges', 'rc_new', 'patch-recentchanges-drop-rc_new.sql' ],
112            [ 'dropField', 'categorylinks', 'cl_to', 'patch-categorylinks-drop-cl_to-cl_collation.sql' ],
113
114            // 1.46
115            [ 'addTable', 'watchlist_label', 'patch-watchlist_label.sql' ],
116            [ 'dropField', 'recentchanges', 'rc_type', 'patch-recentchanges-drop-rc_type.sql' ],
117            [ 'dropField', 'archive', 'ar_sha1', 'patch-archive-drop-ar_sha1.sql' ],
118            [ 'dropField', 'revision', 'rev_sha1', 'patch-revision-drop-rev_sha1.sql' ],
119            [ 'dropField', 'objectcache', 'modtoken', 'patch-objectcache-drop-modtoken.sql' ],
120            [ 'addField', 'imagelinks', 'il_target_id', 'patch-imagelinks-add-il_target_id.sql' ],
121            [ 'migrateImagelinks' ],
122            [ 'modifyPrimaryKey', 'imagelinks', [ 'il_from', 'il_target_id' ], 'patch-imagelinks-pk.sql' ],
123            [ 'addMissingTalkPageWatchlistLabels' ],
124            [ 'dropField', 'imagelinks', 'il_to', 'patch-imagelinks-drop-il_to.sql' ],
125
126            // 1.47
127            [ 'addField', 'site_stats', 'ss_temp_users', 'patch-site_stats-temp_users.sql' ],
128        ];
129    }
130
131    /** @inheritDoc */
132    protected function getInitialUpdateKeys() {
133        return [
134            'filearchive-fa_major_mime-patch-fa_major_mime-chemical.sql',
135            'image-img_major_mime-patch-img_major_mime-chemical.sql',
136            'oldimage-oi_major_mime-patch-oi_major_mime-chemical.sql',
137            'user_groups-ug_group-patch-ug_group-length-increase-255.sql',
138            'user_former_groups-ufg_group-patch-ufg_group-length-increase-255.sql',
139            'user_properties-up_property-patch-up_property.sql',
140        ];
141    }
142
143    /**
144     * @param string $table
145     * @return array[]|null
146     */
147    protected function describeTable( $table ) {
148        $q = <<<END
149SELECT attname, attnum FROM pg_namespace, pg_class, pg_attribute
150    WHERE pg_class.relnamespace = pg_namespace.oid
151        AND attrelid=pg_class.oid AND attnum > 0
152        AND relname=%s AND nspname=%s
153END;
154        $res = $this->db->query(
155            sprintf( $q,
156                $this->db->addQuotes( $table ),
157                $this->db->addQuotes( $this->db->getCoreSchema() )
158            ),
159            __METHOD__
160        );
161        if ( !$res ) {
162            return null;
163        }
164
165        $cols = [];
166        foreach ( $res as $r ) {
167            $cols[] = [
168                "name" => $r[0],
169                "ord" => $r[1],
170            ];
171        }
172
173        return $cols;
174    }
175
176    /**
177     * @param string $idx
178     * @return array|null
179     */
180    protected function describeIndex( $idx ) {
181        // first fetch the key (which is a list of columns ords) and
182        // the table the index applies to (an oid)
183        $q = <<<END
184SELECT indkey, indrelid FROM pg_namespace, pg_class, pg_index
185    WHERE nspname=%s
186        AND pg_class.relnamespace = pg_namespace.oid
187        AND relname=%s
188        AND indexrelid=pg_class.oid
189END;
190        $res = $this->db->query(
191            sprintf(
192                $q,
193                $this->db->addQuotes( $this->db->getCoreSchema() ),
194                $this->db->addQuotes( $idx )
195            ),
196            __METHOD__
197        );
198        if ( !$res ) {
199            return null;
200        }
201        $r = $res->fetchRow();
202        if ( !$r ) {
203            return null;
204        }
205
206        $indkey = $r[0];
207        $relid = intval( $r[1] );
208        $indkeys = explode( ' ', $indkey );
209
210        $colnames = [];
211        foreach ( $indkeys as $rid ) {
212            $query = <<<END
213SELECT attname FROM pg_class, pg_attribute
214    WHERE attrelid=$relid
215        AND attnum=%d
216        AND attrelid=pg_class.oid
217END;
218            $r2 = $this->db->query( sprintf( $query, $rid ), __METHOD__ );
219            if ( !$r2 ) {
220                return null;
221            }
222            $row2 = $r2->fetchRow();
223            if ( !$row2 ) {
224                return null;
225            }
226            $colnames[] = $row2[0];
227        }
228
229        return $colnames;
230    }
231
232    /**
233     * @param string $fkey
234     * @return string|null
235     */
236    protected function fkeyDeltype( $fkey ) {
237        $q = <<<END
238SELECT confdeltype FROM pg_constraint, pg_namespace
239    WHERE connamespace=pg_namespace.oid
240        AND nspname=%s
241        AND conname=%s;
242END;
243        $r = $this->db->query(
244            sprintf(
245                $q,
246                $this->db->addQuotes( $this->db->getCoreSchema() ),
247                $this->db->addQuotes( $fkey )
248            ),
249            __METHOD__
250        );
251        $row = $r->fetchRow();
252        return $row ? $row[0] : null;
253    }
254
255    /**
256     * @param string $table
257     * @param string $rule
258     * @return string|null
259     */
260    protected function ruleDef( $table, $rule ) {
261        $q = <<<END
262SELECT definition FROM pg_rules
263    WHERE schemaname = %s
264        AND tablename = %s
265        AND rulename = %s
266END;
267        $r = $this->db->query(
268            sprintf(
269                $q,
270                $this->db->addQuotes( $this->db->getCoreSchema() ),
271                $this->db->addQuotes( $table ),
272                $this->db->addQuotes( $rule )
273            ),
274            __METHOD__
275        );
276        $row = $r->fetchRow();
277        if ( !$row ) {
278            return null;
279        }
280        $d = $row[0];
281
282        return $d;
283    }
284
285    /**
286     * @param string $table
287     * @param string $pkey
288     * @param string $ns
289     */
290    protected function addSequence( $table, $pkey, $ns ) {
291        if ( !$this->db->sequenceExists( $ns ) ) {
292            $this->output( "Creating sequence $ns\n" );
293            if ( $pkey !== false ) {
294                $table = $this->db->addIdentifierQuotes( $table );
295                $this->db->query( "CREATE SEQUENCE $ns OWNED BY $table.$pkey", __METHOD__ );
296                $this->setDefault( $table, $pkey, '"nextval"(\'"' . $ns . '"\'::"regclass")' );
297            } else {
298                $this->db->query( "CREATE SEQUENCE $ns", __METHOD__ );
299            }
300        }
301    }
302
303    /**
304     * @param string $table
305     * @param string $ns
306     */
307    protected function dropSequence( $table, $ns ) {
308        if ( $this->db->sequenceExists( $ns ) ) {
309            $this->output( "Dropping sequence $ns\n" );
310            $this->db->query( "DROP SEQUENCE $ns CASCADE", __METHOD__ );
311        }
312    }
313
314    /**
315     * @param string $old
316     * @param string $new
317     */
318    protected function renameSequence( $old, $new ) {
319        if ( $this->db->sequenceExists( $new ) ) {
320            $this->output( "...sequence $new already exists.\n" );
321
322            return;
323        }
324        if ( $this->db->sequenceExists( $old ) ) {
325            $this->output( "Renaming sequence $old to $new\n" );
326            $this->db->query( "ALTER SEQUENCE $old RENAME TO $new", __METHOD__ );
327        }
328    }
329
330    /**
331     * @param string $table
332     * @param string $pkey
333     * @param string $seq
334     */
335    protected function setSequenceOwner( $table, $pkey, $seq ) {
336        if ( $this->db->sequenceExists( $seq ) ) {
337            $this->output( "Setting sequence $seq owner to $table.$pkey\n" );
338            $table = $this->db->addIdentifierQuotes( $table );
339            $this->db->query( "ALTER SEQUENCE $seq OWNED BY $table.$pkey", __METHOD__ );
340        }
341    }
342
343    /**
344     * @param string $old
345     * @param string $new
346     * @param string|false $patch
347     */
348    protected function renameTable( $old, $new, $patch = false ) {
349        $updateMsg = "Renaming table $old to $new";
350        if ( !$this->checkSchemaAltersAllowed( $updateMsg ) ) {
351            return;
352        }
353
354        if ( $this->db->tableExists( $old, __METHOD__ ) ) {
355            $this->output( "$updateMsg\n" );
356            $old = $this->db->addIdentifierQuotes( $old );
357            $new = $this->db->addIdentifierQuotes( $new );
358            $this->db->query( "ALTER TABLE $old RENAME TO $new", __METHOD__ );
359            if ( $patch !== false ) {
360                $this->applyPatch( $patch );
361            }
362        }
363    }
364
365    /**
366     * @param string $table
367     * @param string $old
368     * @param string $new
369     * @param bool $skipBothIndexExistWarning
370     * @param string|false $a
371     * @param bool $b
372     * @return bool
373     */
374    protected function renameIndex(
375        $table, $old, $new, $skipBothIndexExistWarning = false, $a = false, $b = false
376    ) {
377        if ( !$this->checkSchemaAltersAllowed(
378            "Renaming index $old to $new in table $table"
379        ) ) {
380            return false;
381        }
382
383        // First requirement: the table must exist
384        if ( !$this->db->tableExists( $table, __METHOD__ ) ) {
385            $this->output( "...skipping: '$table' table doesn't exist yet.\n" );
386
387            return true;
388        }
389
390        // Second requirement: the new index must be missing
391        if ( $this->db->indexExists( $table, $new, __METHOD__ ) ) {
392            $this->output( "...index $new already set on $table table.\n" );
393            if ( !$skipBothIndexExistWarning
394                && $this->db->indexExists( $table, $old, __METHOD__ )
395            ) {
396                $this->output( "...WARNING: $old still exists, despite it has been " .
397                    "renamed into $new (which also exists).\n" .
398                    "            $old should be manually removed if not needed anymore.\n" );
399            }
400
401            return true;
402        }
403
404        // Third requirement: the old index must exist
405        if ( !$this->db->indexExists( $table, $old, __METHOD__ ) ) {
406            $this->output( "...skipping: index $old doesn't exist.\n" );
407
408            return true;
409        }
410
411        $this->db->query( "ALTER INDEX $old RENAME TO $new", __METHOD__ );
412        return true;
413    }
414
415    /**
416     * @param string $table
417     * @param string $field
418     */
419    protected function dropPgField( $table, $field ) {
420        $updateMsg = "Dropping column '$table.$field'";
421        if ( !$this->checkSchemaAltersAllowed( $updateMsg ) ) {
422            return;
423        }
424
425        $fi = $this->db->fieldInfo( $table, $field );
426        if ( $fi === null ) {
427            $this->output( "...$table table does not contain $field field.\n" );
428        } else {
429            $this->output( "$updateMsg\n" );
430            $table = $this->db->addIdentifierQuotes( $table );
431            $this->db->query( "ALTER TABLE $table DROP COLUMN $field", __METHOD__ );
432        }
433    }
434
435    /**
436     * @param string $table
437     * @param string $field
438     * @param string $type
439     */
440    protected function addPgField( $table, $field, $type ) {
441        $updateMsg = "Adding column '$table.$field'";
442        if ( !$this->checkSchemaAltersAllowed( $updateMsg ) ) {
443            return;
444        }
445
446        $fi = $this->db->fieldInfo( $table, $field );
447        if ( $fi !== null ) {
448            $this->output( "...column '$table.$field' already exists\n" );
449        } else {
450            $this->output( "$updateMsg'\n" );
451            $table = $this->db->addIdentifierQuotes( $table );
452            $this->db->query( "ALTER TABLE $table ADD $field $type", __METHOD__ );
453        }
454    }
455
456    /**
457     * @param string $table
458     * @param string $field
459     * @param string $newtype
460     * @param string $default
461     */
462    protected function changeField( $table, $field, $newtype, $default ) {
463        if ( !$this->checkSchemaAltersAllowed( "Changing column type of '$table.$field' to '$newtype'" ) ) {
464            return;
465        }
466
467        if ( !$this->db->tableExists( $table, __METHOD__ ) ) {
468            $this->output( "...$table table does not exist, skipping default change.\n" );
469            return;
470        }
471        $fi = $this->db->fieldInfo( $table, $field );
472        if ( $fi === null ) {
473            $this->output( "...ERROR: expected column $table.$field to exist\n" );
474            exit( 1 );
475        }
476
477        if ( $fi->type() === strtolower( $newtype ) ) {
478            $this->output( "...column '$table.$field' is already of type '$newtype'\n" );
479        } else {
480            $this->output( "Changing column type of '$table.$field' from '{$fi->type()}' to '$newtype'\n" );
481            $table = $this->db->addIdentifierQuotes( $table );
482            $sql = "ALTER TABLE $table ALTER $field TYPE $newtype";
483            if ( $default !== '' ) {
484                $res = [];
485                if ( preg_match( '/DEFAULT (.+)/', $default, $res ) ) {
486                    $sqldef = "ALTER TABLE $table ALTER $field SET DEFAULT $res[1]";
487                    $this->db->query( $sqldef, __METHOD__ );
488                    $default = preg_replace( '/\s*DEFAULT .+/', '', $default );
489                }
490                $sql .= " USING $default";
491            }
492            $this->db->query( $sql, __METHOD__ );
493        }
494    }
495
496    /**
497     * @param string $table
498     * @param string $field
499     * @param string $newtype
500     * @param string $default
501     */
502    protected function changeFieldPurgeTable( $table, $field, $newtype, $default ) {
503        if ( !$this->checkSchemaAltersAllowed(
504            "Changing column type of '$table.$field' to '$newtype'"
505        ) ) {
506            return;
507        }
508
509        # # For a cache table, empty it if the field needs to be changed, because the old contents
510        # # may be corrupted.  If the column is already the desired type, refrain from purging.
511        $fi = $this->db->fieldInfo( $table, $field );
512        if ( $fi === null ) {
513            $this->output( "...ERROR: expected column $table.$field to exist\n" );
514            exit( 1 );
515        }
516
517        if ( $fi->type() === $newtype ) {
518            $this->output( "...column '$table.$field' is already of type '$newtype'\n" );
519        } else {
520            $this->output( "Purging data from cache table '$table'\n" );
521            $table = $this->db->addIdentifierQuotes( $table );
522            $this->db->query( "DELETE from $table", __METHOD__ );
523            $this->output( "Changing column type of '$table.$field' from '{$fi->type()}' to '$newtype'\n" );
524            $sql = "ALTER TABLE $table ALTER $field TYPE $newtype";
525            if ( strlen( $default ) ) {
526                $res = [];
527                if ( preg_match( '/DEFAULT (.+)/', $default, $res ) ) {
528                    $sqldef = "ALTER TABLE $table ALTER $field SET DEFAULT $res[1]";
529                    $this->db->query( $sqldef, __METHOD__ );
530                    $default = preg_replace( '/\s*DEFAULT .+/', '', $default );
531                }
532                $sql .= " USING $default";
533            }
534            $this->db->query( $sql, __METHOD__ );
535        }
536    }
537
538    /**
539     * @param string $table
540     * @param string $field
541     * @param string $default
542     */
543    protected function setDefault( $table, $field, $default ) {
544        $updateMsg = "Changing '$table.$field' default value";
545        if ( !$this->checkSchemaAltersAllowed( $updateMsg ) ) {
546            return;
547        }
548
549        if ( !$this->db->tableExists( $table, __METHOD__ ) ) {
550            $this->output( "...$table table does not exist, skipping default change.\n" );
551            return;
552        }
553        $info = $this->db->fieldInfo( $table, $field );
554        if ( $info && $info->defaultValue() !== $default ) {
555            $this->output( "$updateMsg\n" );
556            $table = $this->db->addIdentifierQuotes( $table );
557            $this->db->query( "ALTER TABLE $table ALTER $field SET DEFAULT "
558                . $this->db->addQuotes( $default ), __METHOD__ );
559        }
560    }
561
562    /**
563     * Drop a default value from a field
564     * @since 1.32
565     * @param string $table
566     * @param string $field
567     */
568    protected function dropDefault( $table, $field ) {
569        $updateMsg = "Dropping '$table.$field' default value";
570        if ( !$this->checkSchemaAltersAllowed( $updateMsg ) ) {
571            return;
572        }
573
574        $info = $this->db->fieldInfo( $table, $field );
575        if ( $info && $info->defaultValue() !== false ) {
576            $this->output( "$updateMsg\n" );
577            $table = $this->db->addIdentifierQuotes( $table );
578            $this->db->query( "ALTER TABLE $table ALTER $field DROP DEFAULT", __METHOD__ );
579        }
580    }
581
582    /**
583     * @param string $table
584     * @param string $field
585     * @param string $null
586     * @param bool $update
587     */
588    protected function changeNullableField( $table, $field, $null, $update = false ) {
589        $updateMsg = "Changing field '$table.$field' to ";
590        if ( $null === 'NOT NULL' ) {
591            $updateMsg .= 'not allow NULLs';
592        } else {
593            $updateMsg .= 'allow NULLs';
594        }
595        if ( !$this->checkSchemaAltersAllowed( $updateMsg ) ) {
596            return;
597        }
598
599        $fi = $this->db->fieldInfo( $table, $field );
600        if ( $fi === null ) {
601            return;
602        }
603        if ( $fi->isNullable() ) {
604            # # It's NULL - does it need to be NOT NULL?
605            if ( $null === 'NOT NULL' ) {
606                $this->output( "$updateMsg\n" );
607                $table = $this->db->addIdentifierQuotes( $table );
608                if ( $update ) {
609                    $this->db->query( "UPDATE $table SET $field = DEFAULT WHERE $field IS NULL", __METHOD__ );
610                }
611                $this->db->query( "ALTER TABLE $table ALTER $field SET NOT NULL", __METHOD__ );
612            } else {
613                $this->output( "...column '$table.$field' is already set as NULL\n" );
614            }
615        } else {
616            # # It's NOT NULL - does it need to be NULL?
617            if ( $null === 'NULL' ) {
618                $this->output( "$updateMsg\n" );
619                $table = $this->db->addIdentifierQuotes( $table );
620                $this->db->query( "ALTER TABLE $table ALTER $field DROP NOT NULL", __METHOD__ );
621            } else {
622                $this->output( "...column '$table.$field' is already set as NOT NULL\n" );
623            }
624        }
625    }
626
627    /**
628     * @param string $table
629     * @param string $index
630     * @param string $type
631     * @param bool $unique
632     */
633    protected function addPgIndex( $table, $index, $type, $unique = false ) {
634        $updateMsg = "Creating index '$index' on table '$table$type";
635        if ( !$this->checkSchemaAltersAllowed( $updateMsg ) ) {
636            return;
637        }
638
639        if ( !$this->db->tableExists( $table, __METHOD__ ) ) {
640            $this->output( "...$table table does not exist, skipping index.\n" );
641        } elseif ( $this->db->indexExists( $table, $index, __METHOD__ ) ) {
642            $this->output( "...index '$index' on table '$table' already exists\n" );
643        } else {
644            $this->output( "$updateMsg\n" );
645            $table = $this->db->addIdentifierQuotes( $table );
646            $unique = $unique ? 'UNIQUE' : '';
647            $this->db->query( "CREATE $unique INDEX $index ON $table $type", __METHOD__ );
648        }
649    }
650
651    /**
652     * @param string $table
653     * @param string $index
654     * @param string $type
655     */
656    protected function addPgExtIndex( $table, $index, $type ) {
657        $updateMsg = "Creating index '$index' on table '$table'";
658        if ( !$this->checkSchemaAltersAllowed( $updateMsg ) ) {
659            return;
660        }
661
662        if ( $this->db->indexExists( $table, $index, __METHOD__ ) ) {
663            $this->output( "...index '$index' on table '$table' already exists\n" );
664        } elseif ( preg_match( '/^\(/', $type ) ) {
665            $this->output( "Creating index '$index' on table '$table'\n" );
666            $table = $this->db->addIdentifierQuotes( $table );
667            $this->db->query( "CREATE INDEX $index ON $table $type", __METHOD__ );
668        } else {
669            $this->applyPatch( $type, true, $updateMsg );
670        }
671    }
672
673    /**
674     * @param string $table
675     * @param string $field
676     */
677    protected function dropFkey( $table, $field ) {
678        $updateMsg = "Dropping foreign key constraint on '$table.$field'";
679        if ( !$this->checkSchemaAltersAllowed( $updateMsg ) ) {
680            return;
681        }
682
683        if ( !$this->db->tableExists( $table, __METHOD__ ) ) {
684            $this->output( "...$table table does not exist, skipping constraint change.\n" );
685            return;
686        }
687        $fi = $this->db->fieldInfo( $table, $field );
688        if ( $fi === null ) {
689            $this->output( "WARNING! Column '$table.$field' does not exist but it should! " .
690                "Please report this.\n" );
691            return;
692        }
693
694        if ( $this->dropConstraint( $table, $field, 'foreignkey', $fi->conname() ) ) {
695            $this->output( "$updateMsg\n" );
696        } else {
697            $this->output( "...foreign key constraint on '$table.$field' already does not exist\n" );
698        }
699    }
700
701    /**
702     * @param string $table
703     * @param string $field
704     * @param string $clause
705     */
706    protected function changeFkeyDeferrable( $table, $field, $clause ) {
707        $updateMsg = "Altering column '$table.$field' to be DEFERRABLE INITIALLY DEFERRED";
708        if ( !$this->checkSchemaAltersAllowed( $updateMsg ) ) {
709            return;
710        }
711
712        $fi = $this->db->fieldInfo( $table, $field );
713        if ( $fi === null ) {
714            $this->output( "WARNING! Column '$table.$field' does not exist but it should! " .
715                "Please report this.\n" );
716
717            return;
718        }
719        if ( $fi->is_deferred() && $fi->is_deferrable() ) {
720            return;
721        }
722        $this->output( "$updateMsg\n" );
723
724        $conname = $fi->conname();
725        $conclause = "CONSTRAINT \"$conname\"";
726
727        if ( !$this->dropConstraint( $table, $field, 'foreignkey', $conname ) ) {
728            $this->output( "Column '$table.$field' does not have a foreign key " .
729                "constraint, will be added\n" );
730            $conclause = "";
731        }
732
733        $command =
734            "ALTER TABLE $table ADD $conclause " .
735            "FOREIGN KEY ($field) REFERENCES $clause DEFERRABLE INITIALLY DEFERRED";
736        $this->db->query( $command, __METHOD__ );
737    }
738
739    /**
740     * @param string $table
741     * @param string $index
742     */
743    protected function dropPgIndex( $table, $index ) {
744        $updateMsg = "Dropping obsolete index '$index'";
745        if ( !$this->checkSchemaAltersAllowed( $updateMsg ) ) {
746            return;
747        }
748
749        if ( $this->db->indexExists( $table, $index, __METHOD__ ) ) {
750            $this->output( "$updateMsg\n" );
751            $this->db->query( "DROP INDEX \"" . $index . "\"", __METHOD__ );
752        }
753    }
754
755    /**
756     * @param string $index
757     * @param array $should_be
758     * @param string $good_def
759     */
760    protected function checkIndex( $index, $should_be, $good_def ) {
761        if ( !$this->checkSchemaAltersAllowed(
762            "Checking if index '$index' is up to date"
763        ) ) {
764            return;
765        }
766
767        $pu = $this->db->indexAttributes( $index );
768        if ( $pu && $pu != $should_be ) {
769            $this->output( "Dropping obsolete version of index '$index'\n" );
770            $this->db->query( "DROP INDEX \"" . $index . "\"", __METHOD__ );
771            $pu = [];
772        } else {
773            $this->output( "...no need to drop index '$index'\n" );
774        }
775
776        if ( !$pu ) {
777            $this->output( "Creating index '$index'\n" );
778            $this->db->query( $good_def, __METHOD__ );
779        } else {
780            $this->output( "...index '$index' exists\n" );
781        }
782    }
783
784    /**
785     * @param string $table
786     * @param array $shouldBe
787     * @param string|null $constraintName
788     */
789    protected function changePrimaryKey( $table, $shouldBe, $constraintName = null ) {
790        if ( !$this->checkSchemaAltersAllowed( "Changing primary key on $table" ) ) {
791            return;
792        }
793
794        // https://wiki.postgresql.org/wiki/Retrieve_primary_key_columns
795        $result = $this->db->query(
796            "SELECT a.attname as column " .
797                "FROM pg_index i " .
798                "JOIN pg_attribute a ON a.attrelid = i.indrelid " .
799                "AND a.attnum = ANY(i.indkey) " .
800                "WHERE i.indrelid = '\"$table\"'::regclass " .
801                "AND i.indisprimary",
802            __METHOD__
803        );
804        $currentColumns = [];
805        foreach ( $result as $row ) {
806            $currentColumns[] = $row->column;
807        }
808
809        if ( $currentColumns == $shouldBe ) {
810            $this->output( "...no need to change primary key of '$table'\n" );
811            return;
812        }
813
814        $this->dropConstraint( $table, '', 'primary', $constraintName );
815
816        $table = $this->db->addIdentifierQuotes( $table );
817        $this->db->query(
818            "ALTER TABLE $table" .
819            " ADD PRIMARY KEY (" . implode( ',', $shouldBe ) . ');',
820            __METHOD__
821        );
822    }
823
824    /**
825     * Drop generic constraint. If the constraint was created with a custom name,
826     * then the name must be queried and supplied as $conname, otherwise standard
827     * system suffixes and format would be assumed.
828     *
829     * @param string $table
830     * @param string $field
831     * @param string $type
832     * @param string|null $conname
833     * @return bool
834     */
835    protected function dropConstraint( $table, $field, $type, $conname = null ) {
836        if ( !$this->checkSchemaAltersAllowed( "Dropping constraint on $table for $field" ) ) {
837            return false;
838        }
839
840        if ( $conname === null ) {
841            if ( $type == 'primary' ) {
842                $conname = "{$table}_pkey";
843            } else {
844                $map = [ 'unique' => 'key', 'check' => 'check', 'foreignkey' => 'fkey' ];
845                $conname = "{$table}_{$field}_{$map[$type]}";
846            }
847        }
848
849        if ( $this->db->constraintExists( $table, $conname ) ) {
850            $table = $this->db->addIdentifierQuotes( $table );
851            $this->db->query(
852                "ALTER TABLE $table DROP CONSTRAINT $conname;",
853                __METHOD__
854            );
855
856            return true;
857        }
858
859        return false;
860    }
861
862    /**
863     * Replaces unique index with primary key,modifies si_title length
864     *
865     * @since 1.43
866     * @return void
867     */
868    protected function migrateSearchindex() {
869        $updateKey = 'searchindex-pk-titlelength';
870        if ( !$this->tableExists( 'searchindex' ) ) {
871            return;
872        }
873
874        $primaryIndexExists = $this->db->indexExists( 'searchindex', 'searchindex_pkey', __METHOD__ );
875        if ( $this->updateRowExists( $updateKey ) || ( $primaryIndexExists ) ) {
876            $this->outputApplied( "...searchindex table has already been migrated.\n" );
877            if ( !$this->updateRowExists( $updateKey ) ) {
878                $this->insertUpdateRow( $updateKey );
879            }
880            return;
881        }
882
883        $apply = $this->applyPatch( 'patch-searchindex-pk-titlelength.sql', false, '...migrating searchindex table' );
884
885        if ( $apply ) {
886            $this->insertUpdateRow( $updateKey );
887        }
888    }
889}