Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
30.29% covered (danger)
30.29%
63 / 208
50.00% covered (danger)
50.00%
1 / 2
CRAP
0.00% covered (danger)
0.00%
0 / 1
CargoDeclare
30.29% covered (danger)
30.29%
63 / 208
50.00% covered (danger)
50.00%
1 / 2
1878.34
0.00% covered (danger)
0.00%
0 / 1
 validateFieldOrTableName
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
8
 run
24.87% covered (danger)
24.87%
48 / 193
0.00% covered (danger)
0.00%
0 / 1
1856.67
1<?php
2/**
3 * Class for the #cargo_declare parser function, as well as for the creation
4 * (and re-creation) of Cargo database tables.
5 *
6 * @author Yaron Koren
7 * @ingroup Cargo
8 */
9
10use MediaWiki\MediaWikiServices;
11
12class CargoDeclare {
13
14    public static $settings = [];
15
16    /**
17     * "Reserved words" - terms that should not be used as table or field
18     * names, because they are reserved for SQL.
19     * (Some words here are much more likely to be used than others.)
20     * @TODO - currently this list only includes reserved words from
21     * MySQL; other DB systems' additional words (if any) should be added
22     * as well.
23     *
24     * @var string[]
25     */
26    private static $sqlReservedWords = [
27        'accessible', 'add', 'all', 'alter', 'analyze',
28        'and', 'as', 'asc', 'asensitive', 'before',
29        'between', 'bigint', 'binary', 'blob', 'both',
30        'by', 'call', 'cascade', 'case', 'change',
31        'char', 'character', 'check', 'collate', 'column',
32        'condition', 'constraint', 'continue', 'convert', 'create',
33        'cross', 'current_date', 'current_time', 'current_timestamp', 'current_user',
34        'cursor', 'database', 'databases', 'day_hour', 'day_microsecond',
35        'day_minute', 'day_second', 'dec', 'decimal', 'declare',
36        'default', 'delayed', 'delete', 'desc', 'describe',
37        'deterministic', 'distinct', 'distinctrow', 'div', 'double',
38        'drop', 'dual', 'each', 'else', 'elseif',
39        'enclosed', 'escaped', 'exists', 'exit', 'explain',
40        'false', 'fetch', 'float', 'float4', 'float8',
41        'for', 'force', 'foreign', 'from', 'fulltext',
42        'generated', 'get', 'grant', 'group', 'having',
43        'high_priority', 'hour_microsecond', 'hour_minute', 'hour_second', 'if',
44        'ignore', 'in', 'index', 'infile', 'inner',
45        'inout', 'insensitive', 'insert', 'int', 'int1',
46        'int2', 'int3', 'int4', 'int8', 'integer',
47        'interval', 'into', 'io_after_gtids', 'io_before_gtids', 'is',
48        'iterate', 'join', 'key', 'keys', 'kill',
49        'leading', 'leave', 'left', 'like', 'limit',
50        'linear', 'lines', 'load', 'localtime', 'localtimestamp',
51        'lock', 'long', 'longblob', 'longtext', 'loop',
52        'low_priority', 'master_bind', 'master_ssl_verify_server_cert', 'match', 'maxvalue',
53        'mediumblob', 'mediumint', 'mediumtext', 'middleint', 'minute_microsecond',
54        'minute_second', 'mod', 'modifies', 'natural', 'no_write_to_binlog',
55        'not', 'null', 'numeric', 'on', 'optimize',
56        'optimizer_costs', 'option', 'optionally', 'or', 'order',
57        'out', 'outer', 'outfile', 'partition', 'precision',
58        'primary', 'procedure', 'purge', 'range', 'read',
59        'read_write', 'reads', 'real', 'references', 'regexp',
60        'release', 'rename', 'repeat', 'replace', 'require',
61        'resignal', 'restrict', 'return', 'revoke', 'right',
62        'rlike', 'schema', 'schemas', 'second_microsecond', 'select',
63        'sensitive', 'separator', 'set', 'show', 'signal',
64        'smallint', 'spatial', 'specific', 'sql', 'sql_big_result',
65        'sql_calc_found_rows', 'sql_small_result', 'sqlexception', 'sqlstate', 'sqlwarning',
66        'ssl', 'starting', 'stored', 'straight_join', 'table',
67        'terminated', 'then', 'tinyblob', 'tinyint', 'tinytext',
68        'to', 'trailing', 'trigger', 'true', 'undo',
69        'union', 'unique', 'unlock', 'unsigned', 'update',
70        'usage', 'use', 'using', 'utc_date', 'utc_time',
71        'utc_timestamp', 'values', 'varbinary', 'varchar', 'varcharacter',
72        'varying', 'virtual', 'when', 'where', 'while',
73        'with', 'write', 'xor', 'year_month', 'zerofill'
74    ];
75
76    /**
77     * Words that are similarly reserved for Cargo - thankfully, a much
78     * shorter list.
79     *
80     * @var string[]
81     */
82    private static $cargoReservedWords = [
83        'holds', 'matches', 'near', 'within'
84    ];
85
86    /**
87     * FIXME: Should this throw an exception instead? This will
88     * enhance the method to be better tested.
89     *
90     * @param string $name The name of the table or field
91     * @param string $type The type of the field provided
92     * @return string|null
93     */
94    public static function validateFieldOrTableName( $name, $type ) {
95        if ( preg_match( '/\s/', $name ) ) {
96            return wfMessage( "cargo-declare-validate-has-whitespace", $type, $name )->parse();
97        } elseif ( strpos( $name, '_' ) === 0 ) {
98            return wfMessage( "cargo-declare-validate-starts-underscore", $type, $name )->parse();
99        } elseif ( substr( $name, -1 ) === '_' ) {
100            return wfMessage( "cargo-declare-validate-ends-underscore", $type, $name )->parse();
101        } elseif ( strpos( $name, '__' ) !== false ) {
102            return wfMessage( "cargo-declare-validate-gt1-underscore", $type, $name )->parse();
103        } elseif ( preg_match( '/[\.,\-<>(){}\[\]\\\\\/]/', $name ) ) {
104            return wfMessage( "cargo-declare-validate-bad-character", $type, $name, '.,-<>(){}[]\/' )->parse();
105        } elseif ( in_array( strtolower( $name ), self::$sqlReservedWords ) ) {
106            return wfMessage( "cargo-declare-validate-name-sql-kw", $name, $type )->parse();
107        } elseif ( in_array( strtolower( $name ), self::$cargoReservedWords ) ) {
108            return wfMessage( "cargo-declare-validate-name-cargo-kw", $name, $type )->parse();
109        }
110        return null;
111    }
112
113    /**
114     * Handles the #cargo_declare parser function.
115     *
116     * @param Parser $parser
117     * @return string
118     */
119    public static function run( Parser $parser ) {
120        if ( !$parser->getTitle() || $parser->getTitle()->getNamespace() != NS_TEMPLATE ) {
121            return CargoUtils::formatError( wfMessage( "cargo-declare-must-from-template" )->parse() );
122        }
123
124        $params = func_get_args();
125        array_shift( $params ); // we already know the $parser...
126
127        $tableName = null;
128        $parentTables = [];
129        $drilldownTabsParams = [];
130        $tableSchema = new CargoTableSchema();
131        $hasStartEvent = false;
132        $hasEndEvent = false;
133        foreach ( $params as $param ) {
134            $parts = explode( '=', $param, 2 );
135
136            if ( count( $parts ) != 2 ) {
137                continue;
138            }
139            $key = trim( $parts[0] );
140            $value = trim( $parts[1] );
141            if ( $key == '_table' ) {
142                $tableName = $value;
143                if ( in_array( strtolower( $tableName ), self::$sqlReservedWords ) ) {
144                    return CargoUtils::formatError( wfMessage( "cargo-declare-tablenm-is-sql-kw", $tableName )->parse() );
145                } elseif ( in_array( strtolower( $tableName ), self::$cargoReservedWords ) ) {
146                    return CargoUtils::formatError( wfMessage( "cargo-declare-tablenm-is-cargo-kw", $tableName )->parse() );
147                }
148            } elseif ( $key == '_parentTables' ) {
149                $tables = explode( ';', $value );
150                foreach ( $tables as $table ) {
151                    $parentTable = [];
152                    $parentTableAlias = '';
153                    $foundMatch = preg_match( '/([^(]*)\s*\((.*)\)/s', $table, $matches );
154                    if ( $foundMatch ) {
155                        $parentTableName = trim( $matches[1] );
156                        if ( count( $matches ) >= 2 ) {
157                            $extraParams = explode( ',', $matches[2] );
158                            foreach ( $extraParams as $extraParam ) {
159                                if ( $extraParam ) {
160                                    $extraParamParts = explode( '=', $extraParam, 2 );
161                                    $extraParamKey = trim( $extraParamParts[0] );
162                                    $extraParamValue = trim( $extraParamParts[1] );
163                                    if ( $extraParamKey == '_localField' ) {
164                                        $parentTable['_localField'] = $extraParamValue;
165                                    } elseif ( $extraParamKey == '_remoteField' ) {
166                                        $parentTable['_remoteField'] = $extraParamValue;
167                                    } elseif ( $extraParamKey == '_alias' ) {
168                                        $parentTableAlias = $extraParamValue;
169                                    } else {
170                                        // It shouldn't be anything else.
171                                        return CargoUtils::formatError( wfMessage( "cargo-declare-parenttable-bad-parameter", $extraParamKey )->parse() );
172                                    }
173                                }
174                            }
175                        }
176                    } else {
177                        $parentTableName = trim( $table );
178                    }
179                    if ( $parentTableName ) {
180                        if ( !$parentTableAlias ) {
181                            if ( array_key_exists( '_localField', $parentTable ) &&
182                                 $parentTable['_localField'] != '_pageName' ) {
183                                $parentTableAlias = strtolower( $parentTable['_localField'] );
184                                if ( array_key_exists( $parentTableAlias, $parentTables ) ) {
185                                    $count =
186                                        substr_count( implode( ',', array_keys( $parentTables ) ),
187                                            $parentTableAlias );
188                                    $parentTableAlias .= "_$count";
189                                }
190                            } else {
191                                $parentTableAlias = CargoUtils::makeDifferentAlias( $parentTableName );
192                                if ( array_key_exists( $parentTableAlias, $parentTables ) ) {
193                                    $count =
194                                        substr_count( implode( ',', array_keys( $parentTables ) ),
195                                            $parentTableAlias );
196                                    $parentTableAlias .= "_$count";
197                                }
198                            }
199                        }
200                        if ( !array_key_exists( '_localField', $parentTable ) ) {
201                            $parentTable['_localField'] = '_pageName';
202                        }
203                        if ( !array_key_exists( '_remoteField', $parentTable ) ) {
204                            $parentTable['_remoteField'] = '_pageName';
205                        }
206                        $parentTable['Name'] = $parentTableName;
207                        $parentTables[$parentTableAlias] = $parentTable;
208
209                        // Validate the parent table's name.
210                        $validationError = self::validateFieldOrTableName( $parentTableName, 'parent table' );
211                        if ( $validationError !== null ) {
212                            return CargoUtils::formatError( $validationError );
213                        }
214                    }
215                }
216            } elseif ( $key == '_drilldownTabs' ) {
217                $value = CargoUtils::smartSplit( ',', $value );
218                foreach ( $value as $tabValues ) {
219                    $foundMatch = preg_match_all( '/([^(]*)\s*\(?([^)]*)\)?/s', $tabValues, $matches );
220                    if ( $foundMatch == false ) {
221                        continue;
222                    }
223                    $tabName = $matches[1][0];
224                    $tabName = trim( $tabName );
225                    if ( !$tabName ) {
226                        continue;
227                    }
228                    $extraParams = $matches[2][0];
229                    $params = preg_split( '~(?<!\\\)' . preg_quote( ';', '~' ) . '~', $extraParams );
230                    $tabParams = [];
231                    foreach ( $params as $param ) {
232                        if ( !$param ) {
233                            continue;
234                        }
235                        $param = array_map( 'trim', explode( '=', $param, 2 ) );
236                        if ( $param[0] == 'fields' ) {
237                            $fields = array_map( 'trim', explode( ',', $param[1] ) );
238                            $drilldownFields = [];
239                            foreach ( $fields as $field ) {
240                                $field = array_map( 'trim', explode( '=', $field ) );
241                                if ( count( $field ) == 2 ) {
242                                    $drilldownFields[$field[1]] = $field[0];
243                                } else {
244                                    $drilldownFields[] = $field[0];
245                                }
246                            }
247                            $tabParams[$param[0]] = $drilldownFields;
248                        } else {
249                            $tabParams[$param[0]] = $param[1];
250                        }
251                    }
252                    if ( !array_key_exists( 'format', $tabParams ) ) {
253                        $tabParams['format'] = 'category';
254                    }
255                    if ( !array_key_exists( 'fields', $tabParams ) ) {
256                        $tabParams['fields'] = [ 'Title' => '_pageName' ];
257                    }
258                    $drilldownTabsParams[$tabName] = $tabParams;
259                }
260            } else {
261                $fieldName = $key;
262                $fieldDescriptionStr = $value;
263                // Validate field name.
264                $validationError = self::validateFieldOrTableName( $fieldName, 'field' );
265                if ( $validationError !== null ) {
266                    return CargoUtils::formatError( $validationError );
267                }
268
269                try {
270                    $fieldDescription = CargoFieldDescription::newFromString( $fieldDescriptionStr );
271                } catch ( Exception $e ) {
272                    return CargoUtils::formatError( $e->getMessage() );
273                }
274                if ( $fieldDescription == null ) {
275                    return CargoUtils::formatError( wfMessage( "cargo-declare-field-parse-fail", $fieldName )->parse() );
276                }
277                if ( $fieldDescription->mIsHierarchy == true && $fieldDescription->mType == 'Coordinates' ) {
278                    return CargoUtils::formatError( wfMessage( "cargo-declare-bad-hierarchy-type", $fieldDescription->mType )->parse() );
279                }
280                if ( $fieldDescription->mType == "Start date" || $fieldDescription->mType == "Start datetime" ) {
281                    if ( !$hasStartEvent ) {
282                        $hasStartEvent = true;
283                    } else {
284                        return CargoUtils::formatError( wfMessage( "cargo-declare-ne1-startdttm" )->parse() );
285                    }
286                }
287                if ( $fieldDescription->mType == "End date" || $fieldDescription->mType == "End datetime" ) {
288                    if ( !$hasEndEvent && $hasStartEvent ) {
289                        $hasEndEvent = true;
290                    } elseif ( !$hasStartEvent ) {
291                        // if End date/datetime is declared before Start date/datetime type field
292                        return CargoUtils::formatError( wfMessage( "cargo-declare-def-start-before-end" )->parse() );
293                    } else {
294                        return CargoUtils::formatError( wfMessage( "cargo-declare-ne1-enddttm" )->parse() );
295                    }
296                }
297                $tableSchema->mFieldDescriptions[$fieldName] = $fieldDescription;
298            }
299        }
300
301        // Validate table name.
302        if ( $tableName == '' ) {
303            return CargoUtils::formatError( wfMessage( "cargo-notable" )->parse() );
304        }
305        $validationError = self::validateFieldOrTableName( $tableName, 'table' );
306        if ( $validationError !== null ) {
307            return CargoUtils::formatError( $validationError );
308        }
309
310        // Validate table name.
311
312        $cdb = CargoUtils::getDB();
313
314        foreach ( $parentTables as $extraParams ) {
315            $parentTableName = $extraParams['Name'];
316            $localField = $extraParams['_localField'];
317            $remoteField = $extraParams['_remoteField'];
318
319            // Validate that parent table exists.
320            if ( !$cdb->tableExists( $parentTableName ) ) {
321                // orig, gives wrong tablename
322                return CargoUtils::formatError( wfMessage( "cargo-declare-parenttable-not-exist", $parentTableName )->parse() );
323            }
324
325            // Validate that remote field exists.
326            if ( !$cdb->fieldExists( $parentTableName, $remoteField ) ) {
327                return CargoUtils::formatError( wfMessage( "cargo-declare-parenttable-no-field", $parentTableName, $remoteField )->parse() );
328            }
329
330            // Validate that local field exists;
331            // this needs to be validated against what is declared
332            // rather than against the DB, since the table may
333            // not be built yet.
334            $parentLocalFieldOK = false;
335            // Declared field names are stored in CargoTableSchema()
336            // object $tableSchema - check declared field names.
337            foreach ( $tableSchema->mFieldDescriptions as $a => $b ) {
338                if ( $a == $localField ) {
339                    $parentLocalFieldOK = true;
340                }
341            }
342            // Check implied field name _pageName.
343            if ( $localField == "_pageName" ) {
344                $parentLocalFieldOK = true;
345            }
346            if ( !$parentLocalFieldOK ) {
347                return CargoUtils::formatError( wfMessage( "cargo-declare-invalid-localfield", $localField )->parse() );
348            }
349        }
350
351        $parserOutput = $parser->getOutput();
352
353        CargoUtils::setParserOutputPageProperty( $parserOutput, 'CargoTableName', $tableName );
354        CargoUtils::setParserOutputPageProperty( $parserOutput, 'CargoParentTables', serialize( $parentTables ) );
355        CargoUtils::setParserOutputPageProperty( $parserOutput, 'CargoDrilldownTabsParams', serialize( $drilldownTabsParams ) );
356        CargoUtils::setParserOutputPageProperty( $parserOutput, 'CargoFields', $tableSchema->toDBString() );
357
358        // Link to the Special:CargoTables page for this table, if it
359        // exists already - otherwise, explain that it needs to be
360        // created.
361        $text = wfMessage( 'cargo-definestable', $tableName )->text();
362        $cdb = CargoUtils::getDB();
363        if ( $cdb->tableExists( $tableName ) ) {
364            $ct = SpecialPage::getTitleFor( 'CargoTables' );
365            $pageName = $ct->getPrefixedText() . "/$tableName";
366            $viewTableMsg = wfMessage( 'cargo-cargotables-viewtablelink' )->parse();
367            $text .= " [[$pageName|$viewTableMsg]].";
368        } else {
369            $text .= ' ' . wfMessage( 'cargo-tablenotcreated' )->text();
370        }
371
372        // Also link to the replacement table, if it exists.
373        if ( $cdb->tableExists( $tableName . '__NEXT' ) ) {
374            $text .= ' ' . wfMessage( "cargo-cargotables-replacementgenerated" )->parse();
375            $ctPage = CargoUtils::getSpecialPage( 'CargoTables' );
376            $ctURL = $ctPage->getPageTitle()->getFullURL();
377            $viewURL = $ctURL . '/' . $tableName;
378            $viewURL .= strpos( $viewURL, '?' ) ? '&' : '?';
379            $viewURL .= "_replacement";
380            $viewReplacementTableMsg = wfMessage( 'cargo-cargotables-viewreplacementlink' )->parse();
381            $text .= "; [$viewURL $viewReplacementTableMsg].";
382        }
383
384        // For use by the Page Exchange extension and possibly others,
385        // to automatically generate a Cargo table after the template
386        // that declares it is created.
387        if ( array_key_exists( 'createData', self::$settings ) ) {
388            $userID = self::$settings['userID'];
389            $user = MediaWikiServices::getInstance()
390                ->getUserFactory()
391                ->newFromId( (int)$userID );
392            $title = $parser->getTitle();
393            $templatePageID = $title->getArticleID();
394            CargoUtils::recreateDBTablesForTemplate(
395                $templatePageID,
396                $createReplacement = false,
397                $user,
398                $tableName,
399                $tableSchema,
400                $parentTables
401            );
402
403            // Populate (or re-populate) the table with any
404            // existing data from the wiki.
405            // @todo This should cycle through *all* the pages,
406            // 500 at a time, in case there are a lot.
407            $titlesToStore = CargoUtils::getTemplateLinksTo( $title, [ 'LIMIT' => 1000 ] );
408            $jobs = [];
409            foreach ( $titlesToStore as $titleToStore ) {
410                $jobs[] = new CargoPopulateTableJob( $titleToStore, [ 'dbTableName' => $tableName ] );
411            }
412            if ( method_exists( MediaWikiServices::class, 'getJobQueueGroup' ) ) {
413                // MW 1.37+
414                MediaWikiServices::getInstance()->getJobQueueGroup()->push( $jobs );
415            } else {
416                JobQueueGroup::singleton()->push( $jobs );
417            }
418
419            // Ensure that this code doesn't get called more than
420            // once per page save.
421            unset( self::$settings['createData'] );
422        }
423
424        return $text;
425    }
426
427}