Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 130 |
|
0.00% |
0 / 7 |
CRAP | |
0.00% |
0 / 1 |
SchemaMaintenance | |
0.00% |
0 / 129 |
|
0.00% |
0 / 7 |
1056 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 28 |
|
0.00% |
0 / 1 |
2 | |||
canExecuteWithoutLocalSettings | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
execute | |
0.00% |
0 / 39 |
|
0.00% |
0 / 1 |
272 | |||
getSqlPathWithFileName | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
6 | |||
writeSchema | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
12 | |||
generateSchema | n/a |
0 / 0 |
n/a |
0 / 0 |
0 | |||||
cleanupSqlArray | |
0.00% |
0 / 26 |
|
0.00% |
0 / 1 |
30 | |||
getSchema | |
0.00% |
0 / 17 |
|
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 | |
25 | use Doctrine\SqlFormatter\NullHighlighter; |
26 | use Doctrine\SqlFormatter\SqlFormatter; |
27 | use MediaWiki\DB\AbstractSchemaValidationError; |
28 | use MediaWiki\DB\AbstractSchemaValidator; |
29 | |
30 | require_once __DIR__ . '/../Maintenance.php'; |
31 | |
32 | abstract 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 | } |