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