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        // We can just call text() on all of these wfMessage() calls,
96        // since the resulting text is passed to formatFieldError(),
97        // which HTML-encodes the text.
98        if ( preg_match( '/\s/', $name ) ) {
99            return wfMessage( "cargo-declare-validate-has-whitespace", $type, $name )->text();
100        } elseif ( strpos( $name, '_' ) === 0 ) {
101            return wfMessage( "cargo-declare-validate-starts-underscore", $type, $name )->text();
102        } elseif ( substr( $name, -1 ) === '_' ) {
103            return wfMessage( "cargo-declare-validate-ends-underscore", $type, $name )->text();
104        } elseif ( strpos( $name, '__' ) !== false ) {
105            return wfMessage( "cargo-declare-validate-gt1-underscore", $type, $name )->text();
106        } elseif ( preg_match( '/[\.,\-\'"<>(){}\[\]\\\\\/]/', $name ) ) {
107            return wfMessage( "cargo-declare-validate-bad-character", $type, $name, '.,-\'"<>(){}[]\/' )->text();
108        } elseif ( in_array( strtolower( $name ), self::$sqlReservedWords ) ) {
109            return wfMessage( "cargo-declare-validate-name-sql-kw", $name, $type )->text();
110        } elseif ( in_array( strtolower( $name ), self::$cargoReservedWords ) ) {
111            return wfMessage( "cargo-declare-validate-name-cargo-kw", $name, $type )->text();
112        }
113        return null;
114    }
115
116    /**
117     * Handles the #cargo_declare parser function.
118     *
119     * @param Parser $parser
120     * @return string
121     */
122    public static function run( Parser $parser ) {
123        if ( !$parser->getTitle() || $parser->getTitle()->getNamespace() != NS_TEMPLATE ) {
124            return CargoUtils::formatError( wfMessage( "cargo-declare-must-from-template" )->parse() );
125        }
126
127        $params = func_get_args();
128        array_shift( $params ); // we already know the $parser...
129
130        $tableName = null;
131        $parentTables = [];
132        $drilldownTabsParams = [];
133        $tableSchema = new CargoTableSchema();
134        $hasStartEvent = false;
135        $hasEndEvent = false;
136        foreach ( $params as $param ) {
137            $parts = explode( '=', $param, 2 );
138
139            if ( count( $parts ) != 2 ) {
140                continue;
141            }
142            $key = trim( $parts[0] );
143            $value = trim( $parts[1] );
144            if ( $key == '_table' ) {
145                $tableName = $value;
146                if ( in_array( strtolower( $tableName ), self::$sqlReservedWords ) ) {
147                    return CargoUtils::formatError( wfMessage( "cargo-declare-tablenm-is-sql-kw", $tableName )->parse() );
148                } elseif ( in_array( strtolower( $tableName ), self::$cargoReservedWords ) ) {
149                    return CargoUtils::formatError( wfMessage( "cargo-declare-tablenm-is-cargo-kw", $tableName )->parse() );
150                }
151            } elseif ( $key == '_parentTables' ) {
152                $tables = explode( ';', $value );
153                foreach ( $tables as $table ) {
154                    $parentTable = [];
155                    $parentTableAlias = '';
156                    $foundMatch = preg_match( '/([^(]*)\s*\((.*)\)/s', $table, $matches );
157                    if ( $foundMatch ) {
158                        $parentTableName = trim( $matches[1] );
159                        if ( count( $matches ) >= 2 ) {
160                            $extraParams = explode( ',', $matches[2] );
161                            foreach ( $extraParams as $extraParam ) {
162                                if ( $extraParam ) {
163                                    $extraParamParts = explode( '=', $extraParam, 2 );
164                                    $extraParamKey = trim( $extraParamParts[0] );
165                                    $extraParamValue = trim( $extraParamParts[1] );
166                                    if ( $extraParamKey == '_localField' ) {
167                                        $parentTable['_localField'] = $extraParamValue;
168                                    } elseif ( $extraParamKey == '_remoteField' ) {
169                                        $parentTable['_remoteField'] = $extraParamValue;
170                                    } elseif ( $extraParamKey == '_alias' ) {
171                                        $parentTableAlias = $extraParamValue;
172                                    } else {
173                                        // It shouldn't be anything else.
174                                        return CargoUtils::formatError( wfMessage( "cargo-declare-parenttable-bad-parameter", $extraParamKey )->parse() );
175                                    }
176                                }
177                            }
178                        }
179                    } else {
180                        $parentTableName = trim( $table );
181                    }
182                    if ( $parentTableName ) {
183                        if ( !$parentTableAlias ) {
184                            if ( array_key_exists( '_localField', $parentTable ) &&
185                                 $parentTable['_localField'] != '_pageName' ) {
186                                $parentTableAlias = strtolower( $parentTable['_localField'] );
187                                if ( array_key_exists( $parentTableAlias, $parentTables ) ) {
188                                    $count =
189                                        substr_count( implode( ',', array_keys( $parentTables ) ),
190                                            $parentTableAlias );
191                                    $parentTableAlias .= "_$count";
192                                }
193                            } else {
194                                $parentTableAlias = CargoUtils::makeDifferentAlias( $parentTableName );
195                                if ( array_key_exists( $parentTableAlias, $parentTables ) ) {
196                                    $count =
197                                        substr_count( implode( ',', array_keys( $parentTables ) ),
198                                            $parentTableAlias );
199                                    $parentTableAlias .= "_$count";
200                                }
201                            }
202                        }
203                        if ( !array_key_exists( '_localField', $parentTable ) ) {
204                            $parentTable['_localField'] = '_pageName';
205                        }
206                        if ( !array_key_exists( '_remoteField', $parentTable ) ) {
207                            $parentTable['_remoteField'] = '_pageName';
208                        }
209                        $parentTable['Name'] = $parentTableName;
210                        $parentTables[$parentTableAlias] = $parentTable;
211
212                        // Validate the parent table's name.
213                        $validationError = self::validateFieldOrTableName( $parentTableName, 'parent table' );
214                        if ( $validationError !== null ) {
215                            return CargoUtils::formatError( $validationError );
216                        }
217                    }
218                }
219            } elseif ( $key == '_drilldownTabs' ) {
220                $value = CargoUtils::smartSplit( ',', $value );
221                foreach ( $value as $tabValues ) {
222                    $foundMatch = preg_match_all( '/([^(]*)\s*\(?([^)]*)\)?/s', $tabValues, $matches );
223                    if ( $foundMatch == false ) {
224                        continue;
225                    }
226                    $tabName = $matches[1][0];
227                    $tabName = trim( $tabName );
228                    if ( !$tabName ) {
229                        continue;
230                    }
231                    $extraParams = $matches[2][0];
232                    $params = preg_split( '~(?<!\\\)' . preg_quote( ';', '~' ) . '~', $extraParams );
233                    $tabParams = [];
234                    foreach ( $params as $param ) {
235                        if ( !$param ) {
236                            continue;
237                        }
238                        $param = array_map( 'trim', explode( '=', $param, 2 ) );
239                        if ( $param[0] == 'fields' ) {
240                            $fields = array_map( 'trim', explode( ',', $param[1] ) );
241                            $drilldownFields = [];
242                            foreach ( $fields as $field ) {
243                                $field = array_map( 'trim', explode( '=', $field ) );
244                                if ( count( $field ) == 2 ) {
245                                    $drilldownFields[$field[1]] = $field[0];
246                                } else {
247                                    $drilldownFields[] = $field[0];
248                                }
249                            }
250                            $tabParams[$param[0]] = $drilldownFields;
251                        } else {
252                            $tabParams[$param[0]] = $param[1];
253                        }
254                    }
255                    if ( !array_key_exists( 'format', $tabParams ) ) {
256                        $tabParams['format'] = 'category';
257                    }
258                    if ( !array_key_exists( 'fields', $tabParams ) ) {
259                        $tabParams['fields'] = [ 'Title' => '_pageName' ];
260                    }
261                    $drilldownTabsParams[$tabName] = $tabParams;
262                }
263            } else {
264                $fieldName = $key;
265                $fieldDescriptionStr = $value;
266                // Validate field name.
267                $validationError = self::validateFieldOrTableName( $fieldName, 'field' );
268                if ( $validationError !== null ) {
269                    return CargoUtils::formatError( $validationError );
270                }
271
272                try {
273                    $fieldDescription = CargoFieldDescription::newFromString( $fieldDescriptionStr );
274                } catch ( Exception $e ) {
275                    return CargoUtils::formatError( $e->getMessage() );
276                }
277                if ( $fieldDescription == null ) {
278                    return CargoUtils::formatError( wfMessage( "cargo-declare-field-parse-fail", $fieldName )->parse() );
279                }
280                if ( $fieldDescription->mIsHierarchy == true && $fieldDescription->mType == 'Coordinates' ) {
281                    return CargoUtils::formatError( wfMessage( "cargo-declare-bad-hierarchy-type", $fieldDescription->mType )->parse() );
282                }
283                if ( $fieldDescription->mType == "Start date" || $fieldDescription->mType == "Start datetime" ) {
284                    if ( !$hasStartEvent ) {
285                        $hasStartEvent = true;
286                    } else {
287                        return CargoUtils::formatError( wfMessage( "cargo-declare-ne1-startdttm" )->parse() );
288                    }
289                }
290                if ( $fieldDescription->mType == "End date" || $fieldDescription->mType == "End datetime" ) {
291                    if ( !$hasEndEvent && $hasStartEvent ) {
292                        $hasEndEvent = true;
293                    } elseif ( !$hasStartEvent ) {
294                        // if End date/datetime is declared before Start date/datetime type field
295                        return CargoUtils::formatError( wfMessage( "cargo-declare-def-start-before-end" )->parse() );
296                    } else {
297                        return CargoUtils::formatError( wfMessage( "cargo-declare-ne1-enddttm" )->parse() );
298                    }
299                }
300                $tableSchema->mFieldDescriptions[$fieldName] = $fieldDescription;
301            }
302        }
303
304        // Validate table name.
305        if ( $tableName == '' ) {
306            return CargoUtils::formatError( wfMessage( "cargo-notable" )->parse() );
307        }
308        $validationError = self::validateFieldOrTableName( $tableName, 'table' );
309        if ( $validationError !== null ) {
310            return CargoUtils::formatError( $validationError );
311        }
312
313        // Validate table name.
314
315        $cdb = CargoUtils::getDB();
316
317        foreach ( $parentTables as $extraParams ) {
318            $parentTableName = $extraParams['Name'];
319            $localField = $extraParams['_localField'];
320            $remoteField = $extraParams['_remoteField'];
321
322            // Validate that parent table exists.
323            if ( !$cdb->tableExists( $parentTableName ) ) {
324                // orig, gives wrong tablename
325                return CargoUtils::formatError( wfMessage( "cargo-declare-parenttable-not-exist", $parentTableName )->parse() );
326            }
327
328            // Validate that remote field exists.
329            if ( !$cdb->fieldExists( $parentTableName, $remoteField ) ) {
330                return CargoUtils::formatError( wfMessage( "cargo-declare-parenttable-no-field", $parentTableName, $remoteField )->parse() );
331            }
332
333            // Validate that local field exists;
334            // this needs to be validated against what is declared
335            // rather than against the DB, since the table may
336            // not be built yet.
337            $parentLocalFieldOK = false;
338            // Declared field names are stored in CargoTableSchema()
339            // object $tableSchema - check declared field names.
340            foreach ( $tableSchema->mFieldDescriptions as $a => $b ) {
341                if ( $a == $localField ) {
342                    $parentLocalFieldOK = true;
343                }
344            }
345            // Check implied field name _pageName.
346            if ( $localField == "_pageName" ) {
347                $parentLocalFieldOK = true;
348            }
349            if ( !$parentLocalFieldOK ) {
350                return CargoUtils::formatError( wfMessage( "cargo-declare-invalid-localfield", $localField )->parse() );
351            }
352        }
353
354        $parserOutput = $parser->getOutput();
355
356        CargoUtils::setParserOutputPageProperty( $parserOutput, 'CargoTableName', $tableName );
357        CargoUtils::setParserOutputPageProperty( $parserOutput, 'CargoParentTables', serialize( $parentTables ) );
358        CargoUtils::setParserOutputPageProperty( $parserOutput, 'CargoDrilldownTabsParams', serialize( $drilldownTabsParams ) );
359        CargoUtils::setParserOutputPageProperty( $parserOutput, 'CargoFields', $tableSchema->toDBString() );
360
361        // Link to the Special:CargoTables page for this table, if it
362        // exists already - otherwise, explain that it needs to be
363        // created.
364        $text = wfMessage( 'cargo-definestable', $tableName )->text();
365        $cdb = CargoUtils::getDB();
366        if ( $cdb->tableExists( $tableName ) ) {
367            $ct = SpecialPage::getTitleFor( 'CargoTables' );
368            $pageName = $ct->getPrefixedText() . "/$tableName";
369            $viewTableMsg = wfMessage( 'cargo-cargotables-viewtablelink' )->parse();
370            $text .= " [[$pageName|$viewTableMsg]].";
371        } else {
372            $text .= ' ' . wfMessage( 'cargo-tablenotcreated' )->text();
373        }
374
375        // Also link to the replacement table, if it exists.
376        if ( $cdb->tableExists( $tableName . '__NEXT' ) ) {
377            $text .= ' ' . wfMessage( "cargo-cargotables-replacementgenerated" )->parse();
378            $ctPage = CargoUtils::getSpecialPage( 'CargoTables' );
379            $ctURL = $ctPage->getPageTitle()->getFullURL();
380            $viewURL = $ctURL . '/' . $tableName;
381            $viewURL .= strpos( $viewURL, '?' ) ? '&' : '?';
382            $viewURL .= "_replacement";
383            $viewReplacementTableMsg = wfMessage( 'cargo-cargotables-viewreplacementlink' )->parse();
384            $text .= "; [$viewURL $viewReplacementTableMsg].";
385        }
386
387        // For use by the Page Exchange extension and possibly others,
388        // to automatically generate a Cargo table after the template
389        // that declares it is created.
390        if ( array_key_exists( 'createData', self::$settings ) ) {
391            $userID = self::$settings['userID'];
392            $user = MediaWikiServices::getInstance()
393                ->getUserFactory()
394                ->newFromId( (int)$userID );
395            $title = $parser->getTitle();
396            $templatePageID = $title->getArticleID();
397            CargoUtils::recreateDBTablesForTemplate(
398                $templatePageID,
399                $createReplacement = false,
400                $user,
401                $tableName,
402                $tableSchema,
403                $parentTables
404            );
405
406            // Populate (or re-populate) the table with any
407            // existing data from the wiki.
408            // @todo This should cycle through *all* the pages,
409            // 500 at a time, in case there are a lot.
410            $titlesToStore = CargoUtils::getTemplateLinksTo( $title, [ 'LIMIT' => 1000 ] );
411            $jobs = [];
412            foreach ( $titlesToStore as $titleToStore ) {
413                $jobs[] = new CargoPopulateTableJob( $titleToStore, [ 'dbTableName' => $tableName ] );
414            }
415            if ( method_exists( MediaWikiServices::class, 'getJobQueueGroup' ) ) {
416                // MW 1.37+
417                MediaWikiServices::getInstance()->getJobQueueGroup()->push( $jobs );
418            } else {
419                JobQueueGroup::singleton()->push( $jobs );
420            }
421
422            // Ensure that this code doesn't get called more than
423            // once per page save.
424            unset( self::$settings['createData'] );
425        }
426
427        return $text;
428    }
429
430}