Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
93.23% |
124 / 133 |
|
42.86% |
3 / 7 |
CRAP | |
0.00% |
0 / 1 |
SchemaMaintenance | |
93.94% |
124 / 132 |
|
42.86% |
3 / 7 |
34.26 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
28 / 28 |
|
100.00% |
1 / 1 |
1 | |||
canExecuteWithoutLocalSettings | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
execute | |
88.10% |
37 / 42 |
|
0.00% |
0 / 1 |
18.55 | |||
getSqlPathWithFileName | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
2 | |||
writeSchema | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
3 | |||
generateSchema | n/a |
0 / 0 |
n/a |
0 / 0 |
0 | |||||
cleanupSqlArray | |
96.15% |
25 / 26 |
|
0.00% |
0 / 1 |
5 | |||
getSchema | |
94.12% |
16 / 17 |
|
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 | |
25 | namespace MediaWiki\Maintenance; |
26 | |
27 | use Doctrine\SqlFormatter\NullHighlighter; |
28 | use Doctrine\SqlFormatter\SqlFormatter; |
29 | use MediaWiki\DB\AbstractSchemaValidationError; |
30 | use MediaWiki\DB\AbstractSchemaValidator; |
31 | |
32 | // @codeCoverageIgnoreStart |
33 | require_once __DIR__ . '/../Maintenance.php'; |
34 | // @codeCoverageIgnoreEnd |
35 | |
36 | abstract 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 */ |
314 | class_alias( SchemaMaintenance::class, 'SchemaMaintenance' ); |