Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.23% covered (success)
93.23%
124 / 133
42.86% covered (danger)
42.86%
3 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
SchemaMaintenance
93.94% covered (success)
93.94%
124 / 132
42.86% covered (danger)
42.86%
3 / 7
34.26
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
28 / 28
100.00% covered (success)
100.00%
1 / 1
1
 canExecuteWithoutLocalSettings
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 execute
88.10% covered (warning)
88.10%
37 / 42
0.00% covered (danger)
0.00%
0 / 1
18.55
 getSqlPathWithFileName
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 writeSchema
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
3
 generateSchema
n/a
0 / 0
n/a
0 / 0
0
 cleanupSqlArray
96.15% covered (success)
96.15%
25 / 26
0.00% covered (danger)
0.00%
0 / 1
5
 getSchema
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
4.00
1<?php
2
3/**
4 * Base script for schema maintenance
5 *
6 * This program is free software; you can redistribute it and/or modify
7 * it under the terms of the GNU General Public License as published by
8 * the Free Software Foundation; either version 2 of the License, or
9 * (at your option) any later version.
10 *
11 * This program is distributed in the hope that it will be useful,
12 * but WITHOUT ANY WARRANTY; without even the implied warranty of
13 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 * GNU General Public License for more details.
15 *
16 * You should have received a copy of the GNU General Public License along
17 * with this program; if not, write to the Free Software Foundation, Inc.,
18 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19 * http://www.gnu.org/copyleft/gpl.html
20 *
21 * @file
22 * @ingroup Maintenance
23 */
24
25namespace MediaWiki\Maintenance;
26
27use Doctrine\SqlFormatter\NullHighlighter;
28use Doctrine\SqlFormatter\SqlFormatter;
29use MediaWiki\DB\AbstractSchemaValidationError;
30use MediaWiki\DB\AbstractSchemaValidator;
31
32// @codeCoverageIgnoreStart
33require_once __DIR__ . '/../Maintenance.php';
34// @codeCoverageIgnoreEnd
35
36abstract class SchemaMaintenance extends Maintenance {
37    public const SUPPORTED_PLATFORMS = [
38        'mysql',
39        'sqlite',
40        'postgres'
41    ];
42
43    /**
44     * Name of the script.
45     * @var string
46     */
47    protected $scriptName;
48
49    public function __construct() {
50        parent::__construct();
51
52        $types = implode( ', ', array_map( static function ( string $type ): string {
53            return "'$type'";
54        }, self::SUPPORTED_PLATFORMS ) );
55
56        $this->addOption(
57            'json',
58            'Path to the json file. Default: tables.json',
59            false,
60            true
61        );
62        $this->addOption(
63            'sql',
64            'Path to output. If --type=all is given, ' .
65            'the output will be placed in a directory named after the dbms. ' .
66            'For mysql, a directory will only be used if it already exists. Default: tables-generated.sql',
67            false,
68            true
69        );
70        $this->addOption(
71            'type',
72            "Can be either $types, or 'all'. Default: mysql",
73            false,
74            true
75        );
76        $this->addOption(
77            'validate',
78            'Validate the schema instead of generating sql files.'
79        );
80    }
81
82    public function canExecuteWithoutLocalSettings(): bool {
83        return true;
84    }
85
86    public function execute() {
87        global $IP;
88
89        $platform = $this->getOption( 'type', 'mysql' );
90        $jsonPath = $this->getOption( 'json', dirname( __DIR__ ) );
91
92        $installPath = $IP;
93
94        // For windows
95        if ( DIRECTORY_SEPARATOR === '\\' ) {
96            $installPath = strtr( $installPath, '\\', '/' );
97            $jsonPath = strtr( $jsonPath, '\\', '/' );
98        }
99
100        if ( $this->hasOption( 'validate' ) ) {
101            $this->getSchema( $jsonPath );
102
103            $this->output( "Schema is valid.\n" );
104            return;
105        }
106
107        // Allow to specify a folder and use a default name
108        if ( is_dir( $jsonPath ) ) {
109            $jsonPath .= '/tables.json';
110        }
111
112        $relativeJsonPath = str_replace(
113            [ "$installPath/extensions/", "$installPath/" ],
114            '',
115            $jsonPath
116        );
117
118        if ( in_array( $platform, self::SUPPORTED_PLATFORMS, true ) ) {
119            $platforms = [ $platform ];
120        } elseif ( $platform === 'all' ) {
121            $platforms = self::SUPPORTED_PLATFORMS;
122        } else {
123            $this->fatalError( "'$platform' is not a supported platform!" );
124        }
125
126        foreach ( $platforms as $platform ) {
127            $sqlPath = $this->getOption( 'sql', dirname( $jsonPath ) );
128
129            // MediaWiki, and some extensions place mysql .sql files in the directory root, instead of a dedicated
130            // sub directory. If mysql/ doesn't exist, assume that the .sql files should be in the directory root.
131            if (
132                $platform === 'mysql' &&
133                !is_dir( $sqlPath . '/mysql' ) &&
134                !( count( $platforms ) > 1 && is_dir( dirname( $sqlPath ) . '/' . $platform ) )
135            ) {
136                // Allow to specify a folder and build the name from the json filename
137                if ( is_dir( $sqlPath ) ) {
138                    $sqlPath = $this->getSqlPathWithFileName( $relativeJsonPath, $sqlPath );
139                }
140            } else {
141                // Allow to specify a folder and build the name from the json filename
142                if ( is_dir( $sqlPath ) ) {
143                    $sqlPath .= '/' . $platform;
144                    $directory = $sqlPath;
145                    $sqlPath = $this->getSqlPathWithFileName( $relativeJsonPath, $sqlPath );
146                } elseif ( count( $platforms ) > 1 ) {
147                    $directory = dirname( $sqlPath ) . '/' . $platform;
148                    $sqlPath = $directory . '/' . pathinfo( $sqlPath, PATHINFO_FILENAME ) . '.sql';
149                } else {
150                    $directory = false;
151                }
152
153                // The directory for the platform might not exist.
154                if ( $directory !== false && !is_dir( $directory )
155                    && !mkdir( $directory ) && !is_dir( $directory )
156                ) {
157                    $this->error( "Cannot create $directory for $platform" );
158
159                    continue;
160                }
161            }
162
163            $this->writeSchema( $platform, $jsonPath, $relativeJsonPath, $sqlPath );
164        }
165    }
166
167    /**
168     * Determine the name of the generated SQL file when only a directory has been provided to --sql.
169     * When --json is given tables.json, tables-generates.sql will be the name, otherwise it will be the name of the
170     * .json file, minus the extension.
171     *
172     * @param string $relativeJsonPath
173     * @param string $sqlPath
174     * @return string
175     */
176    private function getSqlPathWithFileName( string $relativeJsonPath, string $sqlPath ): string {
177        $jsonFilename = pathinfo( $relativeJsonPath, PATHINFO_FILENAME );
178        if ( str_starts_with( $jsonFilename, 'tables' ) ) {
179            $sqlFileName = $jsonFilename . '-generated.sql';
180        } else {
181            $sqlFileName = $jsonFilename . '.sql';
182        }
183
184        return $sqlPath . '/' . $sqlFileName;
185    }
186
187    private function writeSchema(
188        string $platform,
189        string $jsonPath,
190        string $relativeJsonPath,
191        string $sqlPath
192    ): void {
193        $abstractSchemaChange = $this->getSchema( $jsonPath );
194
195        $sql =
196            "-- This file is automatically generated using maintenance/$this->scriptName.\n" .
197            "-- Source: $relativeJsonPath\n" .
198            "-- Do not modify this file directly.\n" .
199            "-- See https://www.mediawiki.org/wiki/Manual:Schema_changes\n";
200
201        $sql .= $this->generateSchema( $platform, $abstractSchemaChange );
202
203        // Give a hint, if nothing changed
204        if ( is_readable( $sqlPath ) ) {
205            $oldSql = file_get_contents( $sqlPath );
206            if ( $oldSql === $sql ) {
207                $this->output( "Schema change is unchanged.\n" );
208            }
209        }
210
211        file_put_contents( $sqlPath, $sql );
212        $this->output( 'Schema change generated and written to ' . $sqlPath . "\n" );
213    }
214
215    abstract protected function generateSchema( string $platform, array $schema ): string;
216
217    /**
218     * Takes the output of DoctrineSchemaBuilder::getSql() or
219     * DoctrineSchemaChangeBuilder::getSchemaChangeSql() and applies presentational changes.
220     *
221     * @param string $platform DB engine identifier
222     * @param array $sqlArray Array of SQL statements
223     * @return string
224     */
225    protected function cleanupSqlArray( string $platform, array $sqlArray ): string {
226        if ( !$sqlArray ) {
227            return '';
228        }
229
230        // Temporary
231        $sql = implode( ";\n\n", $sqlArray ) . ';';
232        $sql = ( new SqlFormatter( new NullHighlighter() ) )->format( $sql );
233
234        // Postgres hacks
235        if ( $platform === 'postgres' ) {
236            // FIXME: Fix a lot of weird formatting issues caused by
237            //   presence of partial index's WHERE clause, this should probably
238            //   be done in some better way, but for now this can work temporarily
239            $sql = str_replace(
240                [ "WHERE\n ", "\n  /*_*/\n  ", "    ", "  );", "KEY(\n  " ],
241                [ "WHERE", ' ', "  ", ');', "KEY(\n    " ],
242                $sql
243            );
244        }
245
246        // Temporary fixes until the linting issues are resolved upstream.
247        // https://github.com/doctrine/sql-formatter/issues/53
248
249        $sql = preg_replace( "!\s+/\*_\*/\s+!", " /*_*/", $sql );
250        $sql = preg_replace(
251            '!\s+/\*\$wgDBTableOptions\*/\s+;!',
252            ' /*$wgDBTableOptions*/;',
253            $sql
254        );
255
256        $sql = str_replace( "; CREATE ", ";\n\nCREATE ", $sql );
257        $sql = str_replace( ";\n\nCREATE TABLE ", ";\n\n\nCREATE TABLE ", $sql );
258        $sql = preg_replace( '/^(CREATE|DROP|ALTER)\s+(TABLE|VIEW|INDEX)\s+/m', '$1 $2 ', $sql );
259        $sql = preg_replace( '/(?<!\s|;)\s+(ADD|DROP|ALTER|MODIFY|CHANGE|RENAME)\s+/', "\n  \$1 ", $sql );
260
261        $sql = str_replace( "; ", ";\n", $sql );
262
263        if ( !str_ends_with( $sql, "\n" ) ) {
264            $sql .= "\n";
265        }
266
267        // Sqlite hacks
268        if ( $platform === 'sqlite' ) {
269            // Doctrine prepends __temp__ to the table name and we set the table with the schema prefix causing invalid
270            // sqlite.
271            $sql = preg_replace( '/__temp__\s*\/\*_\*\//', '/*_*/__temp__', $sql );
272        }
273
274        return $sql;
275    }
276
277    /**
278     * Fetches the abstract schema.
279     *
280     * @param string $jsonPath
281     * @return array
282     */
283    private function getSchema( string $jsonPath ): array {
284        $json = file_get_contents( $jsonPath );
285
286        if ( !$json ) {
287            $this->fatalError(
288                "'$jsonPath' does not exist!\n"
289            );
290        }
291
292        $abstractSchema = json_decode( $json, true );
293
294        if ( json_last_error() !== JSON_ERROR_NONE ) {
295            $this->fatalError(
296                "'$jsonPath' seems to be invalid json. Check the syntax and try again!\n" . json_last_error_msg()
297            );
298        }
299
300        $validator = new AbstractSchemaValidator( function ( string $msg ): void {
301            $this->fatalError( $msg );
302        } );
303        try {
304            $validator->validate( $jsonPath );
305        } catch ( AbstractSchemaValidationError $e ) {
306            $this->fatalError( $e->getMessage() );
307        }
308
309        return $abstractSchema;
310    }
311}
312
313/** @deprecated class alias since 1.43 */
314class_alias( SchemaMaintenance::class, 'SchemaMaintenance' );