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