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