Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
38.71% covered (danger)
38.71%
24 / 62
20.00% covered (danger)
20.00%
1 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
SchemaGenerator
38.71% covered (danger)
38.71%
24 / 62
20.00% covered (danger)
20.00%
1 / 5
59.13
0.00% covered (danger)
0.00%
0 / 1
 validateAndGetSchema
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 generateSchema
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 generateSchemaChange
75.00% covered (warning)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
2.06
 makeSQLComment
81.82% covered (warning)
81.82%
9 / 11
0.00% covered (danger)
0.00%
0 / 1
2.02
 cleanupSqlArray
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
30
1<?php
2
3namespace MediaWiki\Maintenance;
4
5use Doctrine\SqlFormatter\NullHighlighter;
6use Doctrine\SqlFormatter\SqlFormatter;
7use JsonException;
8use MediaWiki\DB\AbstractSchemaValidationError;
9use MediaWiki\DB\AbstractSchemaValidator;
10use Wikimedia\Rdbms\DoctrineSchemaBuilderFactory;
11
12/**
13 * Helper to generate abstract schema and schema changes in maintenance scripts.
14 */
15class SchemaGenerator {
16    /**
17     * Fetches the abstract schema.
18     *
19     * @param string $jsonPath
20     * @return array
21     * @throws AbstractSchemaValidationError
22     */
23    public function validateAndGetSchema( string $jsonPath ): array {
24        $json = file_get_contents( $jsonPath );
25
26        if ( !$json ) {
27            throw new AbstractSchemaValidationError( "'$jsonPath' does not exist!" );
28        }
29
30        try {
31            $abstractSchema = json_decode( $json, true, 512, JSON_THROW_ON_ERROR );
32        } catch ( JsonException $e ) {
33            throw new AbstractSchemaValidationError( "Invalid JSON schema: " . $e->getMessage(), 0, $e );
34        }
35
36        $validator = new AbstractSchemaValidator();
37        $validator->validate( $jsonPath );
38
39        return $abstractSchema;
40    }
41
42    /**
43     * @throws AbstractSchemaValidationError
44     */
45    public function generateSchema( string $platform, string $jsonPath ): string {
46        $abstractSchemaChange = $this->validateAndGetSchema( $jsonPath );
47
48        $sql = $this->makeSQLComment( 'generateSchemaSql.php', $jsonPath );
49
50        $schemaBuilder = ( new DoctrineSchemaBuilderFactory() )->getSchemaBuilder( $platform );
51
52        foreach ( $abstractSchemaChange as $table ) {
53            $schemaBuilder->addTable( $table );
54        }
55        $tableSqls = $schemaBuilder->getSql();
56
57        $sql .= $this->cleanupSqlArray( $platform, $tableSqls );
58
59        return $sql;
60    }
61
62    /**
63     * @throws AbstractSchemaValidationError
64     */
65    public function generateSchemaChange( string $platform, string $jsonPath ): string {
66        $abstractSchemaChange = $this->validateAndGetSchema( $jsonPath );
67
68        $sql = $this->makeSQLComment( 'generateSchemaChangeSql.php', $jsonPath );
69
70        $schemaChangeBuilder = ( new DoctrineSchemaBuilderFactory() )->getSchemaChangeBuilder( $platform );
71
72        $schemaChangeSqls = $schemaChangeBuilder->getSchemaChangeSql( $abstractSchemaChange );
73        if ( !$schemaChangeSqls ) {
74            throw new AbstractSchemaValidationError( "No schema changes detected!" );
75        }
76
77        $sql .= $this->cleanupSqlArray( $platform, $schemaChangeSqls );
78
79        return $sql;
80    }
81
82    private function makeSQLComment( string $scriptName, string $jsonPath ): string {
83        global $IP;
84
85        $installPath = realpath( $IP );
86        $jsonPath = realpath( $jsonPath );
87
88        // For windows
89        if ( DIRECTORY_SEPARATOR === '\\' ) {
90            $installPath = strtr( $installPath, '\\', '/' );
91            $jsonPath = strtr( $jsonPath, '\\', '/' );
92        }
93
94        $canonicalJsonPath = str_replace( "$installPath/", '', $jsonPath );
95        $canonicalJsonPath = preg_replace( '!^extensions/[^/]+/!', '', $canonicalJsonPath );
96
97        return "-- This file is automatically generated using maintenance/$scriptName.\n" .
98            "-- Source: $canonicalJsonPath\n" .
99            "-- Do not modify this file directly.\n" .
100            "-- See https://www.mediawiki.org/wiki/Manual:Schema_changes\n";
101    }
102
103    /**
104     * Takes the output of DoctrineSchemaBuilder::getSql() or
105     * DoctrineSchemaChangeBuilder::getSchemaChangeSql() and applies presentational changes.
106     *
107     * @param string $platform DB engine identifier
108     * @param array $sqlArray Array of SQL statements
109     * @return string
110     */
111    private function cleanupSqlArray( string $platform, array $sqlArray ): string {
112        if ( !$sqlArray ) {
113            return '';
114        }
115
116        // Temporary
117        $sql = implode( ";\n\n", $sqlArray ) . ';';
118        $sql = ( new SqlFormatter( new NullHighlighter() ) )->format( $sql );
119
120        // Postgres hacks
121        if ( $platform === 'postgres' ) {
122            // FIXME: Fix a lot of weird formatting issues caused by
123            //   presence of partial index's WHERE clause, this should probably
124            //   be done in some better way, but for now this can work temporarily
125            $sql = str_replace(
126                [ "WHERE\n ", "\n  /*_*/\n  ", "    ", "  );", "KEY(\n  " ],
127                [ "WHERE", ' ', "  ", ');', "KEY(\n    " ],
128                $sql
129            );
130        }
131
132        // Temporary fixes until the linting issues are resolved upstream.
133        // https://github.com/doctrine/sql-formatter/issues/53
134
135        $sql = preg_replace( "!\s+/\*_\*/\s+!", " /*_*/", $sql );
136        $sql = preg_replace(
137            '!\s+/\*\$wgDBTableOptions\*/\s+;!',
138            ' /*$wgDBTableOptions*/;',
139            $sql
140        );
141
142        $sql = str_replace( "; CREATE ", ";\n\nCREATE ", $sql );
143        $sql = str_replace( ";\n\nCREATE TABLE ", ";\n\n\nCREATE TABLE ", $sql );
144        $sql = preg_replace( '/^(CREATE|DROP|ALTER)\s+(TABLE|VIEW|INDEX)\s+/m', '$1 $2 ', $sql );
145        $sql = preg_replace( '/(?<!\s|;)\s+(ADD|DROP|ALTER|MODIFY|CHANGE|RENAME)\s+/', "\n  \$1 ", $sql );
146
147        $sql = str_replace( "; ", ";\n", $sql );
148
149        if ( !str_ends_with( $sql, "\n" ) ) {
150            $sql .= "\n";
151        }
152
153        // Sqlite hacks
154        if ( $platform === 'sqlite' ) {
155            // Doctrine prepends __temp__ to the table name and we set the table with the schema prefix causing invalid
156            // sqlite.
157            $sql = preg_replace( '/__temp__\s*\/\*_\*\//', '/*_*/__temp__', $sql );
158        }
159
160        return $sql;
161    }
162}