Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
5.28% covered (danger)
5.28%
38 / 720
7.84% covered (danger)
7.84%
4 / 51
CRAP
0.00% covered (danger)
0.00%
0 / 1
CargoUtils
5.28% covered (danger)
5.28%
38 / 720
7.84% covered (danger)
7.84%
4 / 51
63149.24
0.00% covered (danger)
0.00%
0 / 1
 getDB
0.00% covered (danger)
0.00%
0 / 40
0.00% covered (danger)
0.00%
0 / 1
272
 getPageProp
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
6
 getAllPageProps
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
12
 getTemplateIDForDBTable
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
6
 formatError
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 displayErrorMessage
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getTables
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
12
 getParentTables
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
 getChildTables
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
30
 getDrilldownTabsParams
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
 getTableSchemas
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
90
 getTableNameForTemplate
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 makeDifferentAlias
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 smartSplit
69.44% covered (warning)
69.44%
25 / 36
0.00% covered (danger)
0.00%
0 / 1
19.59
 findQuotedStringEnd
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
72
 removeQuotedStrings
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 removeNamespaceFromFileName
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 getSQLFieldPattern
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getSQLTableAndFieldPattern
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 getSQLTablePattern
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 isSQLStringLiteral
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 getDateFunctions
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 smartParse
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
156
 parsePageForStorage
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
20
 recreateDBTablesForTemplate
0.00% covered (danger)
0.00%
0 / 44
0.00% covered (danger)
0.00%
0 / 1
272
 tableFullyExists
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 fieldTypeToSQLType
0.00% covered (danger)
0.00%
0 / 50
0.00% covered (danger)
0.00%
0 / 1
1482
 createCargoTableOrTables
0.00% covered (danger)
0.00%
0 / 75
0.00% covered (danger)
0.00%
0 / 1
420
 createTable
0.00% covered (danger)
0.00%
0 / 61
0.00% covered (danger)
0.00%
0 / 1
306
 specialTableNames
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 fullTextMatchSQL
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 coordinatePartToNumber
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
110
 parseCoordinatesString
0.00% covered (danger)
0.00%
0 / 30
0.00% covered (danger)
0.00%
0 / 1
110
 escapedFieldName
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 joinOfMainAndFieldTable
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 joinOfMainAndParentTable
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 joinOfFieldAndMainTable
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
6
 joinOfSingleFieldAndHierarchyTable
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 escapedInsert
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 makeLink
33.33% covered (danger)
33.33%
2 / 6
0.00% covered (danger)
0.00%
0 / 1
12.41
 getSpecialPage
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getContentLang
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 logTableAction
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
6
 validateHierarchyStructure
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
56
 validateFieldDescriptionString
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
90
 getTemplateLinksTo
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
30
 setParserOutputPageProperty
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 globalFields
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 addGlobalFieldsToSchema
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 stringContainsParentheses
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 makeWikiPage
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2/**
3 * Utility functions for the Cargo extension.
4 *
5 * @author Yaron Koren
6 * @ingroup Cargo
7 */
8
9use MediaWiki\Linker\LinkRenderer;
10use MediaWiki\Linker\LinkTarget;
11use MediaWiki\MediaWikiServices;
12
13class CargoUtils {
14
15    private static $CargoDB = null;
16
17    public static function getDB() {
18        if ( self::$CargoDB != null && self::$CargoDB->isOpen() ) {
19            return self::$CargoDB;
20        }
21
22        global $wgDBuser, $wgDBpassword, $wgDBprefix, $wgDBservers;
23        global $wgCargoDBserver, $wgCargoDBname, $wgCargoDBuser, $wgCargoDBpassword, $wgCargoDBprefix, $wgCargoDBtype;
24
25        $services = MediaWikiServices::getInstance();
26        $lb = $services->getDBLoadBalancer();
27        $dbr = $lb->getConnectionRef( DB_REPLICA );
28        $server = $dbr->getServer();
29        $name = $dbr->getDBname();
30        $type = $dbr->getType();
31
32        // We need $wgCargoDBtype for other functions.
33        if ( $wgCargoDBtype === null ) {
34            $wgCargoDBtype = $type;
35        }
36        $dbServer = $wgCargoDBserver === null ? $server : $wgCargoDBserver;
37        $dbName = $wgCargoDBname === null ? $name : $wgCargoDBname;
38
39        // Server (host), db name, and db type can be retrieved from $dbr via
40        // public methods, but username and password cannot. If these values are
41        // not set for Cargo, get them from either $wgDBservers or wgDBuser and
42        // $wgDBpassword, depending on whether or not there are multiple DB servers.
43        if ( $wgCargoDBuser !== null ) {
44            $dbUsername = $wgCargoDBuser;
45        } elseif ( is_array( $wgDBservers ) && isset( $wgDBservers[0] ) ) {
46            $dbUsername = $wgDBservers[0]['user'];
47        } else {
48            $dbUsername = $wgDBuser;
49        }
50        if ( $wgCargoDBpassword !== null ) {
51            $dbPassword = $wgCargoDBpassword;
52        } elseif ( is_array( $wgDBservers ) && isset( $wgDBservers[0] ) ) {
53            $dbPassword = $wgDBservers[0]['password'];
54        } else {
55            $dbPassword = $wgDBpassword;
56        }
57
58        if ( $wgCargoDBprefix !== null ) {
59            $dbTablePrefix = $wgCargoDBprefix;
60        } else {
61            $dbTablePrefix = $wgDBprefix . 'cargo__';
62        }
63
64        $params = [
65            'host' => $dbServer,
66            'user' => $dbUsername,
67            'password' => $dbPassword,
68            'dbname' => $dbName,
69            'tablePrefix' => $dbTablePrefix,
70        ];
71
72        if ( $type === 'sqlite' ) {
73            $params['dbFilePath'] = $dbr->getDbFilePath();
74        } elseif ( $type === 'postgres' ) {
75            global $wgDBport;
76            // @TODO - a $wgCargoDBport variable is still needed.
77            $params['port'] = $wgDBport;
78        }
79
80        if ( method_exists( $services, 'getDatabaseFactory' ) ) {
81            // MW 1.39+
82            self::$CargoDB = $services->getDatabaseFactory()->create( $wgCargoDBtype, $params );
83        } else {
84            self::$CargoDB = Database::factory( $wgCargoDBtype, $params );
85        }
86        return self::$CargoDB;
87    }
88
89    /**
90     * Gets a page property for the specified page ID and property name.
91     */
92    public static function getPageProp( $pageID, $pageProp ) {
93        $lb = MediaWikiServices::getInstance()->getDBLoadBalancer();
94        $dbr = $lb->getConnectionRef( DB_REPLICA );
95        $value = $dbr->selectField( 'page_props', [
96                'pp_value'
97            ], [
98                'pp_page' => $pageID,
99                    'pp_propname' => $pageProp,
100            ]
101        );
102
103        if ( !$value ) {
104            return null;
105        }
106
107        return $value;
108    }
109
110    /**
111     * Similar to getPageProp().
112     */
113    public static function getAllPageProps( $pageProp ) {
114        $lb = MediaWikiServices::getInstance()->getDBLoadBalancer();
115        $dbr = $lb->getConnectionRef( DB_REPLICA );
116        $res = $dbr->select( 'page_props', [
117            'pp_page',
118            'pp_value'
119            ], [
120            'pp_propname' => $pageProp
121            ]
122        );
123
124        $pagesPerValue = [];
125        foreach ( $res as $row ) {
126            $pageID = $row->pp_page;
127            $pageValue = $row->pp_value;
128            if ( array_key_exists( $pageValue, $pagesPerValue ) ) {
129                $pagesPerValue[$pageValue][] = $pageID;
130            } else {
131                $pagesPerValue[$pageValue] = [ $pageID ];
132            }
133        }
134
135        return $pagesPerValue;
136    }
137
138    /**
139     * Gets the template page where this table is defined -
140     * hopefully there's exactly one of them.
141     */
142    public static function getTemplateIDForDBTable( $tableName ) {
143        $lb = MediaWikiServices::getInstance()->getDBLoadBalancer();
144        $dbr = $lb->getConnectionRef( DB_REPLICA );
145        $page = $dbr->selectField( 'page_props', [
146            'pp_page'
147            ], [
148            'pp_value' => $tableName,
149            'pp_propname' => 'CargoTableName'
150            ]
151        );
152        if ( !$page ) {
153            return null;
154        }
155        return $page;
156    }
157
158    public static function formatError( $errorString ) {
159        return Html::element( 'div', [ 'class' => 'error' ], $errorString );
160    }
161
162    public static function displayErrorMessage( OutputPage $out, Message $message ) {
163        $out->wrapWikiTextAsInterface( 'error', $message->plain() );
164    }
165
166    public static function getTables() {
167        $tableNames = [];
168        $lb = MediaWikiServices::getInstance()->getDBLoadBalancer();
169        $dbr = $lb->getConnectionRef( DB_REPLICA );
170        $res = $dbr->select( 'cargo_tables', 'main_table' );
171        foreach ( $res as $row ) {
172            $tableName = $row->main_table;
173            // Skip "replacement" tables.
174            if ( substr( $tableName, -6 ) == '__NEXT' ) {
175                continue;
176            }
177            $tableNames[] = $tableName;
178        }
179        return $tableNames;
180    }
181
182    public static function getParentTables( $tableName ) {
183        $parentTables = [];
184        $lb = MediaWikiServices::getInstance()->getDBLoadBalancer();
185        $dbr = $lb->getConnectionRef( DB_REPLICA );
186        $res = $dbr->select( 'cargo_tables', [ 'template_id', 'main_table' ] );
187        foreach ( $res as $row ) {
188            if ( $tableName == $row->main_table ) {
189                $parentTables = self::getPageProp( $row->template_id, 'CargoParentTables' );
190            }
191        }
192        if ( $parentTables ) {
193            return unserialize( $parentTables );
194        }
195    }
196
197    public static function getChildTables( $tableName ) {
198        $childTables = [];
199        $allParentTablesInfo = self::getAllPageProps( 'CargoParentTables' );
200        foreach ( $allParentTablesInfo as $parentTablesInfoStr => $templateIDs ) {
201            $parentTablesInfo = unserialize( $parentTablesInfoStr );
202            foreach ( $parentTablesInfo as $parentTableInfo ) {
203                $remoteTable = $parentTableInfo['Name'];
204                if ( $remoteTable !== $tableName ) {
205                    continue;
206                }
207                $localField = $parentTableInfo['_localField'];
208                $remoteField = $parentTableInfo['_remoteField'];
209                // There should only ever be one ID here... right?
210                foreach ( $templateIDs as $templateID ) {
211                    $childTable = self::getPageProp( $templateID, 'CargoTableName' );
212                    $childTables[] = [
213                        'childTable' => $childTable,
214                        'childField' => $localField,
215                        'parentTable' => $remoteTable,
216                        'parentField' => $remoteField
217                    ];
218                }
219            }
220        }
221        return $childTables;
222    }
223
224    public static function getDrilldownTabsParams( $tableName ) {
225        $drilldownTabs = [];
226        $lb = MediaWikiServices::getInstance()->getDBLoadBalancer();
227        $dbr = $lb->getConnectionRef( DB_REPLICA );
228        $res = $dbr->select( 'cargo_tables', [ 'template_id', 'main_table' ] );
229        foreach ( $res as $row ) {
230            if ( $tableName == $row->main_table ) {
231                $drilldownTabs = self::getPageProp( $row->template_id, 'CargoDrilldownTabsParams' );
232            }
233        }
234        if ( $drilldownTabs ) {
235            return unserialize( $drilldownTabs );
236        }
237    }
238
239    public static function getTableSchemas( $tableNames ) {
240        $mainTableNames = [];
241        foreach ( $tableNames as $tableName ) {
242            if ( strpos( $tableName, '__' ) !== false &&
243                strpos( $tableName, '__NEXT' ) === false ) {
244                // We just want the first part of it.
245                $tableNameParts = explode( '__', $tableName );
246                $tableName = $tableNameParts[0];
247            }
248            if ( !in_array( $tableName, $mainTableNames ) ) {
249                $mainTableNames[] = $tableName;
250            }
251        }
252        $tableSchemas = [];
253        $lb = MediaWikiServices::getInstance()->getDBLoadBalancer();
254        $dbr = $lb->getConnectionRef( DB_REPLICA );
255        $res = $dbr->select( 'cargo_tables', [ 'main_table', 'table_schema' ],
256            [ 'main_table' => $mainTableNames ] );
257        foreach ( $res as $row ) {
258            $tableName = $row->main_table;
259            $tableSchemaString = $row->table_schema;
260            $tableSchemas[$tableName] = CargoTableSchema::newFromDBString( $tableSchemaString );
261        }
262
263        // Validate the table names.
264        if ( count( $tableSchemas ) < count( $mainTableNames ) ) {
265            foreach ( $mainTableNames as $tableName ) {
266                if ( !array_key_exists( $tableName, $tableSchemas ) ) {
267                    throw new MWException( wfMessage( "cargo-unknowntable", $tableName )->parse() );
268                }
269            }
270        }
271
272        return $tableSchemas;
273    }
274
275    /**
276     * Get the Cargo table for the passed-in template specified via
277     * either #cargo_declare or #cargo_attach, if the template has a
278     * call to either one.
279     */
280    public static function getTableNameForTemplate( $templateTitle ) {
281        $templatePageID = $templateTitle->getArticleID();
282        $declaredTableName = self::getPageProp( $templatePageID, 'CargoTableName' );
283        if ( $declaredTableName !== null ) {
284            return [ $declaredTableName, true ];
285        }
286        $attachedTableName = self::getPageProp( $templatePageID, 'CargoAttachedTable' );
287        return [ $attachedTableName, false ];
288    }
289
290    /**
291     * Make the alias different from table name to avoid removal of aliases (when passed
292     * in to Mediawiki's select() call) if the alias and table name are the same.
293     * Aliases are needed because sometimes the same table can be joined more than once, if
294     * it serves as two different parent tables
295     * @param string $tableName
296     * @return string
297     */
298    public static function makeDifferentAlias( $tableName ) {
299        $tableAlias = $tableName . "_alias";
300        return $tableAlias;
301    }
302
303    /**
304     * Splits a string by the delimiter, but ensures that parenthesis, separators
305     * and "the other quote" (single quote in a double quoted string or double
306     * quote in a single quoted string) inside a quoted string are not considered
307     * lexically.
308     * @param string $delimiter The delimiter to split by.
309     * @param string $string The string to split.
310     * @param bool $includeBlankValues Whether to include blank values in the returned array.
311     * @return string[] Array of substrings (with or without blank values).
312     * @throws MWException On unmatched quotes or incomplete escape sequences.
313     */
314    public static function smartSplit( $delimiter, $string, $includeBlankValues = false ) {
315        if ( $string == '' ) {
316            return [];
317        }
318
319        $ignoreNextChar = false;
320        $returnValues = [];
321        $numOpenParentheses = 0;
322        $curReturnValue = '';
323        $strLength = strlen( $string );
324        for ( $i = 0; $i < $strLength; $i++ ) {
325            $curChar = $string[$i];
326
327            if ( $ignoreNextChar ) {
328                // If previous character was a backslash,
329                // ignore the current one, since it's escaped.
330                // What if this one is a backslash too?
331                // Doesn't matter - it's escaped.
332                $ignoreNextChar = false;
333            } elseif ( $curChar == '(' ) {
334                $numOpenParentheses++;
335            } elseif ( $curChar == ')' ) {
336                $numOpenParentheses--;
337            } elseif ( $curChar == '\'' || $curChar == '"' ) {
338                $pos = self::findQuotedStringEnd( $string, $curChar, $i + 1 );
339                if ( $pos === false ) {
340                    throw new MWException( "Error: unmatched quote in SQL string constant." );
341                }
342                $curReturnValue .= substr( $string, $i, $pos - $i );
343                $i = $pos;
344            } elseif ( $curChar == '\\' ) {
345                $ignoreNextChar = true;
346            }
347
348            if ( $curChar == $delimiter && $numOpenParentheses == 0 ) {
349                $returnValues[] = trim( $curReturnValue );
350                $curReturnValue = '';
351            } else {
352                $curReturnValue .= $curChar;
353            }
354        }
355        $returnValues[] = trim( $curReturnValue );
356
357        if ( $ignoreNextChar ) {
358            throw new MWException( "Error: incomplete escape sequence." );
359        }
360
361        if ( $includeBlankValues ) {
362            return $returnValues;
363        }
364
365        // Remove empty strings (but not other quasi-empty values, like '0') and re-key the array.
366        $noEmptyStrings = static function ( $s ) {
367            return $s !== '';
368        };
369        return array_values( array_filter( $returnValues, $noEmptyStrings ) );
370    }
371
372    /**
373     * Finds the end of a quoted string.
374     */
375    public static function findQuotedStringEnd( $string, $quoteChar, $pos ) {
376        $ignoreNextChar = false;
377        $strLength = strlen( $string );
378        for ( $i = $pos; $i < $strLength; $i++ ) {
379            $curChar = $string[$i];
380            if ( $ignoreNextChar ) {
381                $ignoreNextChar = false;
382            } elseif ( $curChar == $quoteChar ) {
383                if ( $i + 1 < $strLength && $string[$i + 1] == $quoteChar ) {
384                    $i++;
385                } else {
386                    return $i;
387                }
388            } elseif ( $curChar == '\\' ) {
389                $ignoreNextChar = true;
390            }
391        }
392        if ( $ignoreNextChar ) {
393            throw new MWException( "Error: incomplete escape sequence." );
394        }
395        return false;
396    }
397
398    /**
399     * Deletes text within quotes and raises and exception if a quoted string
400     * is not closed.
401     */
402    public static function removeQuotedStrings( ?string $string ): string {
403        if ( $string === null ) {
404            return '';
405        }
406
407        $noQuotesPattern = '/("|\')([^\\1\\\\]|\\\\.)*?\\1/s';
408        $string = preg_replace( $noQuotesPattern, '', $string );
409        if ( strpos( $string, '"' ) !== false || strpos( $string, "'" ) !== false ) {
410            throw new MWException( "Error: unclosed string literal." );
411        }
412        return $string;
413    }
414
415    /**
416     * Get rid of the "File:" or "Image:" (in the wiki's language) at the
417     * beginning of a file name, if it's there.
418     */
419    public static function removeNamespaceFromFileName( $fileName ) {
420        $fileTitle = Title::newFromText( $fileName, NS_FILE );
421        if ( $fileTitle == null ) {
422            return null;
423        }
424        return $fileTitle->getText();
425    }
426
427    /**
428     * Generates a Regular Expression to match $fieldName in a SQL string.
429     * Allows for $ as valid identifier character.
430     */
431    public static function getSQLFieldPattern( $fieldName, $closePattern = true ) {
432        $fieldName = str_replace( '$', '\$', $fieldName );
433        $pattern = '/([^\w$.,]|^)' . $fieldName;
434        return $pattern . ( $closePattern ? '([^\w$]|$)/' : '' );
435    }
436
437    /**
438     * Generates a Regular Expression to match $tableName.$fieldName in a
439     * SQL string. Allows for $ as valid identifier character.
440     */
441    public static function getSQLTableAndFieldPattern( $tableName, $fieldName, $closePattern = true ) {
442        $fieldName = str_replace( '$', '\$', $fieldName );
443        $tableName = str_replace( '$', '\$', $tableName );
444        $pattern = '/([^\w$,]|^)' . $tableName . '\.' . $fieldName;
445        return $pattern . ( $closePattern ? '([^\w$]|$)/ui' : '' );
446    }
447
448    /**
449     * Generates a Regular Expression to match $tableName in a SQL string.
450     * Allows for $ as valid identifier character.
451     */
452    public static function getSQLTablePattern( $tableName, $closePattern = true ) {
453        $tableName = str_replace( '$', '\$', $tableName );
454        $pattern = '/([^\w$]|^)(' . $tableName . ')\.(\w*)';
455        return $pattern . ( $closePattern ? '/ui' : '' );
456    }
457
458    /**
459     * Determines whether a string is a literal.
460     * This may need different handling for different (non-MySQL) DB types.
461     */
462    public static function isSQLStringLiteral( $string ) {
463        return $string[0] == "'" && substr( $string, -1, 1 ) == "'";
464    }
465
466    public static function getDateFunctions( $dateDBField ) {
467        global $wgCargoDBtype;
468
469        // Unfortunately, date handling in general - and date extraction
470        // specifically - is done differently in almost every DB
471        // system. If support was ever added for SQLite,
472        // that would require special handling as well.
473        if ( $wgCargoDBtype == 'postgres' ) {
474            $yearValue = "EXTRACT(YEAR FROM $dateDBField)";
475            $monthValue = "EXTRACT(MONTH FROM $dateDBField)";
476            $dayValue = "EXTRACT(DAY FROM $dateDBField)";
477        } else { // MySQL
478            $yearValue = "YEAR($dateDBField)";
479            $monthValue = "MONTH($dateDBField)";
480            $dayValue = "DAY($dateDBField)";
481        }
482        return [ $yearValue, $monthValue, $dayValue ];
483    }
484
485    /**
486     * Parses a piece of wikitext differently depending on whether
487     * we're in a special or regular page.
488     *
489     * @param string $value
490     * @param Parser|null $parser
491     * @return string
492     */
493    public static function smartParse( $value, $parser ) {
494        global $wgRequest;
495
496        // This decode() call is here in case the value was
497        // set using {{PAGENAME}}, which for some reason
498        // HTML-encodes some of its characters - see
499        // https://www.mediawiki.org/wiki/Help:Magic_words#Page_names
500        // Of course, String and Page fields could be set using
501        // {{PAGENAME}} as well, but those seem less likely.
502        $value = htmlspecialchars_decode( $value );
503
504        // Add a newline at the beginning if it looks like the value
505        // starts with a bulleted or numbered list, to make sure that
506        // the first line gets formatted correctly.
507        if ( strpos( $value, '*' ) === 0 || strpos( $value, '#' ) === 0 ) {
508            $value = "\n" . $value;
509        }
510
511        // Parse it as if it's wikitext. The exact call
512        // depends on whether we're in a special page or not.
513        if ( $parser === null ) {
514            $parser = MediaWikiServices::getInstance()->getParser();
515        }
516
517        // Parser::getTitle() throws a TypeError if it would have
518        // returned null, so just catch the error.
519        // Why would the title be null? It's not clear, but it seems to
520        // happen in at least once case: in "action=pagevalues" for a
521        // page with non-ASCII characters in its name.
522        try {
523            $title = $parser->getTitle();
524        } catch ( TypeError $e ) {
525            $title = null;
526        }
527
528        if ( $title === null ) {
529            global $wgTitle;
530            $title = $wgTitle;
531        }
532
533        if ( $title != null && $title->isSpecial( 'RunJobs' ) ) {
534            // Conveniently, if this is called from within a job
535            // being run, the name of the page will be
536            // Special:RunJobs.
537            // If that's the case, do nothing - we don't need to
538            // parse the value.
539        // This next clause should only be called for Cargo's special
540        // pages, not for SF's Special:RunQuery. Don't know about other
541        // special pages.
542        } elseif ( ( $title != null && $title->isSpecialPage() && !$wgRequest->getCheck( 'wpRunQuery' ) ) ||
543            // The 'pagevalues' action is also a Cargo special page.
544            $wgRequest->getVal( 'action' ) == 'pagevalues' ) {
545            $parserOptions = ParserOptions::newFromAnon();
546            $parserForInnerParse = MediaWikiServices::getInstance()->getParserFactory()->create();
547            $parserOutput = $parserForInnerParse->parse( $value, $title, $parserOptions );
548            $value = $parserOutput->getText( [ 'unwrap' => true ] );
549        } else {
550            $value = $parser->internalParse( $value );
551        }
552        return $value;
553    }
554
555    public static function parsePageForStorage( $title, $pageContents ) {
556        // Special handling for the Approved Revs extension.
557        $approvedContent = null;
558        if ( class_exists( 'ApprovedRevs' ) ) {
559            $approvedContent = ApprovedRevs::getApprovedContent( $title );
560        }
561        if ( $approvedContent != null ) {
562            if ( method_exists( $approvedContent, 'getText' ) ) {
563                // Approved Revs 1.0+
564                $pageText = $approvedContent->getText();
565            } else {
566                $pageText = $approvedContent;
567            }
568        } else {
569            $pageText = $pageContents;
570        }
571        $parser = MediaWikiServices::getInstance()->getParser();
572        $parserOptions = ParserOptions::newFromAnon();
573        $parser->parse( $pageText, $title, $parserOptions );
574    }
575
576    /**
577     * Drop, and then create again, the database table(s) holding the
578     * data for this template.
579     * Why "tables"? Because every field that holds a list of values gets
580     * its own helper table.
581     *
582     * @param int $templatePageID
583     * @return bool
584     * @throws MWException
585     */
586    public static function recreateDBTablesForTemplate(
587        $templatePageID,
588        $createReplacement,
589        User $user,
590        $tableName = null,
591        $tableSchema = null,
592        $parentTables = null
593    ) {
594        if ( $tableName == null ) {
595            $tableName = self::getPageProp( $templatePageID, 'CargoTableName' );
596        }
597
598        if ( $tableSchema == null ) {
599            $tableSchemaString = self::getPageProp( $templatePageID, 'CargoFields' );
600            // First, see if there even is DB storage for this template -
601            // if not, exit.
602            if ( $tableSchemaString === null ) {
603                return false;
604            }
605            $tableSchema = CargoTableSchema::newFromDBString( $tableSchemaString );
606        } else {
607            $tableSchemaString = $tableSchema->toDBString();
608        }
609
610        $dbw = wfGetDB( DB_MASTER );
611        $cdb = self::getDB();
612
613        // Cannot run any recreate if a replacement table exists.
614        $possibleReplacementTable = $tableName . '__NEXT';
615        if ( self::tableFullyExists( $tableName ) && self::tableFullyExists( $possibleReplacementTable ) ) {
616            throw new MWException( wfMessage( 'cargo-recreatedata-replacementexists', $tableName, $possibleReplacementTable )->parse() );
617        }
618
619        if ( $createReplacement ) {
620            $tableName .= '__NEXT';
621            if ( $cdb->tableExists( $possibleReplacementTable ) ) {
622                // The replacement table exists, but it does
623                // not have a row in cargo_tables - this is
624                // hopefully a rare occurrence.
625                try {
626                    $cdb->begin();
627                    $cdb->dropTable( $tableName );
628                    $cdb->commit();
629                } catch ( Exception $e ) {
630                    throw new MWException( "Caught exception ($e) while trying to drop Cargo table. "
631                    . "Please make sure that your database user account has the DROP permission." );
632                }
633            }
634        } else {
635            // @TODO - is an array really necessary? Shouldn't it
636            // always be just one table name? Tied in with that,
637            // if a table name was already specified, do we need
638            // to do a lookup here?
639            $tableNames = [];
640            $res = $dbw->select( 'cargo_tables', 'main_table', [ 'template_id' => $templatePageID ] );
641            foreach ( $res as $row ) {
642                $tableNames[] = $row->main_table;
643            }
644
645            // For whatever reason, that DB query might have failed -
646            // if so, just add the table name here.
647            if ( $tableName != null && !in_array( $tableName, $tableNames ) ) {
648                $tableNames[] = $tableName;
649            }
650
651            $mainTableAlreadyExists = self::tableFullyExists( $tableNames[0] );
652            foreach ( $tableNames as $curTable ) {
653                try {
654                    $cdb->begin();
655                    $cdb->dropTable( $curTable );
656                    $cdb->commit();
657                } catch ( Exception $e ) {
658                    throw new MWException( "Caught exception ($e) while trying to drop Cargo table. "
659                    . "Please make sure that your database user account has the DROP permission." );
660                }
661                $dbw->delete( 'cargo_pages', [ 'table_name' => $curTable ] );
662            }
663
664            $dbw->delete( 'cargo_tables', [ 'template_id' => $templatePageID ] );
665        }
666
667        self::createCargoTableOrTables( $cdb, $dbw, $tableName, $tableSchema, $tableSchemaString, $templatePageID );
668
669        if ( !$createReplacement ) {
670            // Log this.
671            if ( $mainTableAlreadyExists ) {
672                self::logTableAction( 'recreatetable', $tableName, $user );
673            } else {
674                self::logTableAction( 'createtable', $tableName, $user );
675            }
676        }
677
678        return true;
679    }
680
681    public static function tableFullyExists( $tableName ) {
682        $lb = MediaWikiServices::getInstance()->getDBLoadBalancer();
683        $dbr = $lb->getConnectionRef( DB_REPLICA );
684        $numRows = $dbr->selectRowCount( 'cargo_tables', '*', [ 'main_table' => $tableName ], __METHOD__ );
685        if ( $numRows == 0 ) {
686            return false;
687        }
688
689        $cdb = self::getDB();
690        return $cdb->tableExists( $tableName );
691    }
692
693    public static function fieldTypeToSQLType( $fieldType, $dbType, $size = null ) {
694        global $wgCargoDefaultStringBytes;
695
696        // Possible values for $dbType: "mysql", "postgres", "sqlite"
697        // @TODO - make sure it's one of these.
698        if ( $fieldType == 'Integer' ) {
699            switch ( $dbType ) {
700                case "mysql":
701                case "postgres":
702                    return 'Int';
703                case "sqlite":
704                    return 'INTEGER';
705            }
706        } elseif ( $fieldType == 'Float' || $fieldType == 'Rating' ) {
707            switch ( $dbType ) {
708                case "mysql":
709                    return 'Double';
710                case "postgres":
711                    return 'Numeric';
712                case "sqlite":
713                    return 'REAL';
714            }
715        } elseif ( $fieldType == 'Boolean' ) {
716            switch ( $dbType ) {
717                case "mysql":
718                case "postgres":
719                    return 'Boolean';
720                case "sqlite":
721                    return 'INTEGER';
722            }
723        } elseif ( $fieldType == 'Date' || $fieldType == 'Start date' || $fieldType == 'End date' ) {
724            switch ( $dbType ) {
725                case "mysql":
726                case "postgres":
727                    return 'Date';
728                case "sqlite":
729                    // Should really be 'REAL', with
730                    // accompanying handling.
731                    return 'TEXT';
732            }
733        } elseif ( $fieldType == 'Datetime' || $fieldType == 'Start datetime' || $fieldType == 'End datetime' ) {
734            // Some DB types have a datetime type that includes
735            // the time zone, but MySQL unfortunately doesn't,
736            // so the best solution for time zones is probably
737            // to have a separate field for them.
738            switch ( $dbType ) {
739                case "mysql":
740                    return 'Datetime';
741                case "postgres":
742                    return 'Timestamp';
743                case "sqlite":
744                    // Should really be 'REAL', with
745                    // accompanying handling.
746                    return 'TEXT';
747            }
748        } elseif ( $fieldType == 'Text' || $fieldType == 'Wikitext' ) {
749            switch ( $dbType ) {
750                case "mysql":
751                case "postgres":
752                case "sqlite":
753                    return 'Text';
754            }
755        } elseif ( $fieldType == 'Searchtext' ) {
756            if ( $dbType != 'mysql' ) {
757                throw new MWException( "Error: a \"Searchtext\" field can currently only be defined for MySQL databases." );
758            }
759            return 'Mediumtext';
760        } else { // 'String', 'Page', 'Wikitext string', etc.
761            if ( $size == null ) {
762                $size = $wgCargoDefaultStringBytes;
763            }
764            switch ( $dbType ) {
765                case "mysql":
766                case "postgres":
767                    // For at least MySQL, there's a limit
768                    // on how many total bytes a table's
769                    // fields can have, and "Text" and
770                    // "Blob" fields don't get added to the
771                    // total, so if it's a big piece of
772                    // text, just make it a "Text" field.
773                    if ( $size > 1000 ) {
774                        return 'Text';
775                    } else {
776                        return "Varchar($size)";
777                    }
778                case "sqlite":
779                    return 'TEXT';
780            }
781        }
782    }
783
784    public static function createCargoTableOrTables( $cdb, $dbw, $tableName, $tableSchema, $tableSchemaString, $templatePageID ) {
785        $cdb->begin();
786        $fieldsInMainTable = [
787            '_ID' => 'Integer',
788            '_pageName' => 'String',
789            '_pageTitle' => 'String',
790            '_pageNamespace' => 'Integer',
791            '_pageID' => 'Integer',
792        ];
793
794        $containsFileType = false;
795        foreach ( $tableSchema->mFieldDescriptions as $fieldName => $fieldDescription ) {
796            $isList = $fieldDescription->mIsList;
797            $fieldType = $fieldDescription->mType;
798
799            if ( $isList || $fieldType == 'Coordinates' ) {
800                // No field will be created with this name -
801                // instead, we'll have one called
802                // fieldName + '__full', and a separate table
803                // for holding each value.
804                // The field holding the full list will always
805                // just be text - and it could be long.
806                $fieldsInMainTable[$fieldName . '__full'] = 'Text';
807            } else {
808                $fieldsInMainTable[$fieldName] = $fieldDescription;
809            }
810
811            if ( !$isList && $fieldType == 'Coordinates' ) {
812                $fieldsInMainTable[$fieldName . '__lat'] = 'Float';
813                $fieldsInMainTable[$fieldName . '__lon'] = 'Float';
814            } elseif ( $fieldType == 'Date' || $fieldType == 'Datetime' ||
815                    $fieldType == 'Start date' || $fieldType == 'Start datetime' ||
816                    $fieldType == 'End date' || $fieldType == 'End datetime' ) {
817                $fieldsInMainTable[$fieldName . '__precision'] = 'Integer';
818            } elseif ( $fieldType == 'File' ) {
819                $containsFileType = true;
820            }
821        }
822
823        self::createTable( $cdb, $tableName, $fieldsInMainTable );
824
825        // Now also create tables for each of the 'list' fields,
826        // if there are any.
827        $fieldTableNames = []; // Names of tables that store data regarding pages
828        $fieldHelperTableNames = []; // Names of tables that store metadata regarding template or fields
829        foreach ( $tableSchema->mFieldDescriptions as $fieldName => $fieldDescription ) {
830            if ( $fieldDescription->mIsList ) {
831                // The double underscore in this table name
832                // should prevent anyone from giving this name
833                // to a "real" table.
834                $fieldTableName = $tableName . '__' . $fieldName;
835                $cdb->dropTable( $fieldTableName );
836
837                $fieldsInTable = [ '_rowID' => 'Integer' ];
838                $fieldType = $fieldDescription->mType;
839                if ( $fieldType == 'Coordinates' ) {
840                    $fieldsInTable['_lat'] = 'Float';
841                    $fieldsInTable['_lon'] = 'Float';
842                } else {
843                    $fieldsInTable['_value'] = $fieldType;
844                }
845                if ( $fieldDescription->isDateOrDateTime() ) {
846                    $fieldsInTable['_value__precision'] = 'Integer';
847                }
848                $fieldsInTable['_position'] = 'Integer';
849
850                self::createTable( $cdb, $fieldTableName, $fieldsInTable );
851                $fieldTableNames[] = $fieldTableName;
852            }
853            if ( $fieldDescription->mIsHierarchy ) {
854                $fieldHelperTableName = $tableName . '__' . $fieldName . '__hierarchy';
855                $cdb->dropTable( $fieldHelperTableName );
856                $fieldType = $fieldDescription->mType;
857                $fieldsInTable = [
858                    '_value' => $fieldType,
859                    '_left' => 'Integer',
860                    '_right' => 'Integer',
861                ];
862                self::createTable( $cdb, $fieldHelperTableName, $fieldsInTable, true );
863
864                $fieldHelperTableNames[] = $fieldHelperTableName;
865                // Insert hierarchy information in the __hierarchy table
866                $hierarchyTree = CargoHierarchyTree::newFromWikiText( $fieldDescription->mHierarchyStructure );
867                $hierarchyStructureTableData = $hierarchyTree->generateHierarchyStructureTableData();
868                foreach ( $hierarchyStructureTableData as $entry ) {
869                    $cdb->insert( $fieldHelperTableName, $entry );
870                }
871            }
872        }
873
874        // And create a helper table holding all the files stored in
875        // this table, if there are any.
876        if ( $containsFileType ) {
877            $fileTableName = $tableName . '___files';
878            $cdb->dropTable( $fileTableName );
879            $fieldsInTable = [
880                '_pageName' => 'String',
881                '_pageID' => 'Integer',
882                '_fieldName' => 'String',
883                '_fileName' => 'String'
884            ];
885            self::createTable( $cdb, $fileTableName, $fieldsInTable );
886        }
887
888        // End transaction and apply DB changes.
889        $cdb->commit();
890
891        // Finally, store all the info in the cargo_tables table.
892        $dbw->insert( 'cargo_tables', [
893            'template_id' => $templatePageID,
894            'main_table' => $tableName,
895            'field_tables' => serialize( $fieldTableNames ),
896            'field_helper_tables' => serialize( $fieldHelperTableNames ),
897            'table_schema' => $tableSchemaString
898        ] );
899    }
900
901    public static function createTable( $cdb, $tableName, $fieldsInTable, $multipleColumnIndex = false ) {
902        global $wgCargoDBRowFormat;
903
904        // Unfortunately, there is not yet a 'CREATE TABLE' wrapper
905        // in the MediaWiki DB API, so we have to call SQL directly.
906        $dbType = $cdb->getType();
907        $sqlTableName = $cdb->tableName( $tableName );
908        $createSQL = "CREATE TABLE $sqlTableName ( ";
909        $firstField = true;
910        foreach ( $fieldsInTable as $fieldName => $fieldDescOrType ) {
911            $fieldOptionsText = '';
912            if ( is_object( $fieldDescOrType ) ) {
913                $fieldType = $fieldDescOrType->mType;
914                $fieldSize = $fieldDescOrType->mSize;
915                $sqlType = self::fieldTypeToSQLType( $fieldType, $dbType, $fieldSize );
916
917                if ( $fieldDescOrType->mIsMandatory ) {
918                    $fieldOptionsText .= ' NOT NULL';
919                }
920                if ( $fieldDescOrType->mIsUnique ) {
921                    $fieldOptionsText .= ' UNIQUE';
922                }
923            } else {
924                $fieldType = $fieldDescOrType;
925                $sqlType = self::fieldTypeToSQLType( $fieldType, $dbType );
926                if ( $fieldName == '_ID' ) {
927                    $fieldOptionsText .= ' PRIMARY KEY';
928                } elseif ( $fieldName == '_rowID' ) {
929                    $fieldOptionsText .= ' NOT NULL';
930                }
931            }
932            if ( $firstField ) {
933                $firstField = false;
934            } else {
935                $createSQL .= ', ';
936            }
937            $sqlFieldName = $cdb->addIdentifierQuotes( $fieldName );
938            $createSQL .= "$sqlFieldName $sqlType $fieldOptionsText";
939            if ( $fieldType == 'Searchtext' ) {
940                $createSQL .= ", FULLTEXT KEY $fieldName ( $sqlFieldName )";
941            }
942        }
943
944        $createSQL .= ' )';
945        // Allow for setting a format like COMPRESSED, DYNAMIC etc.
946        if ( $wgCargoDBRowFormat != null ) {
947            $createSQL .= " ROW_FORMAT=$wgCargoDBRowFormat";
948        }
949        $cdb->query( $createSQL );
950
951        // Add an index for any field that's not of type Text,
952        // Searchtext or Wikitext.
953        $indexedFields = [];
954        foreach ( $fieldsInTable as $fieldName => $fieldDescOrType ) {
955            // We don't need to index _ID, because it's already
956            // the primary key.
957            if ( $fieldName == '_ID' ) {
958                continue;
959            }
960
961            // @HACK - MySQL does not allow more than 64 keys/
962            // indexes per table. We are indexing most fields -
963            // so if a table has more than 64 fields, there's a
964            // good chance that it will overrun this limit.
965            // So we just stop indexing after the first 60.
966            if ( count( $indexedFields ) >= 60 ) {
967                break;
968            }
969
970            if ( is_object( $fieldDescOrType ) ) {
971                $fieldType = $fieldDescOrType->mType;
972            } else {
973                $fieldType = $fieldDescOrType;
974            }
975            if ( in_array( $fieldType, [ 'Text', 'Searchtext', 'Wikitext' ] ) ) {
976                continue;
977            }
978            $indexedFields[] = $fieldName;
979        }
980
981        if ( $multipleColumnIndex ) {
982            $indexName = "nested_set_$tableName";
983            $sqlFieldNames = array_map(
984                [ $cdb, 'addIdentifierQuotes' ],
985                $indexedFields
986            );
987            $sqlFieldNamesStr = implode( ', ', $sqlFieldNames );
988            $createIndexSQL = "CREATE INDEX $indexName ON " .
989                "$sqlTableName ($sqlFieldNamesStr)";
990            $cdb->query( $createIndexSQL );
991        } else {
992            foreach ( $indexedFields as $fieldName ) {
993                $indexName = $fieldName . '_' . $tableName;
994                // MySQL doesn't allow index names with more than 64 characters.
995                $indexName = substr( $indexName, 0, 64 );
996                $sqlFieldName = $cdb->addIdentifierQuotes( $fieldName );
997                $sqlIndexName = $cdb->addIdentifierQuotes( $indexName );
998                $createIndexSQL = "CREATE INDEX $sqlIndexName ON " .
999                    "$sqlTableName ($sqlFieldName)";
1000                $cdb->query( $createIndexSQL );
1001            }
1002        }
1003    }
1004
1005    public static function specialTableNames() {
1006        $specialTableNames = [ '_pageData', '_fileData' ];
1007        if ( class_exists( 'FDGanttContent' ) ) {
1008            // The Flex Diagrams extension is installed.
1009            $specialTableNames[] = '_bpmnData';
1010            $specialTableNames[] = '_ganttData';
1011        }
1012        return $specialTableNames;
1013    }
1014
1015    public static function fullTextMatchSQL( $cdb, $tableName, $fieldName, $searchTerm ) {
1016        $fullFieldName = self::escapedFieldName( $cdb, $tableName, $fieldName );
1017        $searchTerm = $cdb->addQuotes( $searchTerm );
1018        return " MATCH($fullFieldName) AGAINST ($searchTerm IN BOOLEAN MODE) ";
1019    }
1020
1021    /**
1022     * Parses one half of a set of coordinates into a number.
1023     *
1024     * Copied from Miga, also written by Yaron Koren
1025     * (https://github.com/yaronkoren/miga/blob/master/MDVCoordinates.js)
1026     * - though that one is in Javascript.
1027     */
1028    public static function coordinatePartToNumber( $coordinateStr ) {
1029        $degreesSymbols = [ "\x{00B0}", "d" ];
1030        $minutesSymbols = [ "'", "\x{2032}", "\x{00B4}" ];
1031        $secondsSymbols = [ '"', "\x{2033}", "\x{00B4}\x{00B4}" ];
1032
1033        $numDegrees = null;
1034        $numMinutes = null;
1035        $numSeconds = null;
1036
1037        foreach ( $degreesSymbols as $degreesSymbol ) {
1038            $pattern = '/([\d\.]+)' . $degreesSymbol . '/u';
1039            if ( preg_match( $pattern, $coordinateStr, $matches ) ) {
1040                $numDegrees = floatval( $matches[1] );
1041                break;
1042            }
1043        }
1044        if ( $numDegrees == null ) {
1045            throw new MWException( "Error: could not parse degrees in \"$coordinateStr\"." );
1046        }
1047
1048        foreach ( $minutesSymbols as $minutesSymbol ) {
1049            $pattern = '/([\d\.]+)' . $minutesSymbol . '/u';
1050            if ( preg_match( $pattern, $coordinateStr, $matches ) ) {
1051                $numMinutes = floatval( $matches[1] );
1052                break;
1053            }
1054        }
1055        if ( $numMinutes == null ) {
1056            // This might not be an error - the number of minutes
1057            // might just not have been set.
1058            $numMinutes = 0;
1059        }
1060
1061        foreach ( $secondsSymbols as $secondsSymbol ) {
1062            $pattern = '/(\d+)' . $secondsSymbol . '/u';
1063            if ( preg_match( $pattern, $coordinateStr, $matches ) ) {
1064                $numSeconds = floatval( $matches[1] );
1065                break;
1066            }
1067        }
1068        if ( $numSeconds == null ) {
1069            // This might not be an error - the number of seconds
1070            // might just not have been set.
1071            $numSeconds = 0;
1072        }
1073
1074        return ( $numDegrees + ( $numMinutes / 60 ) + ( $numSeconds / 3600 ) );
1075    }
1076
1077    /**
1078     * Parses a coordinate string in (hopefully) any standard format.
1079     *
1080     * Copied from Miga, also written by Yaron Koren
1081     * (https://github.com/yaronkoren/miga/blob/master/MDVCoordinates.js)
1082     * - though that one is in Javascript.
1083     */
1084    public static function parseCoordinatesString( $coordinatesString ) {
1085        $coordinatesString = trim( $coordinatesString );
1086        if ( $coordinatesString === '' ) {
1087            // FIXME: No caller expects this!
1088            return;
1089        }
1090
1091        // This is safe to do, right?
1092        $coordinatesString = str_replace( [ '[', ']' ], '', $coordinatesString );
1093        // See if they're separated by commas.
1094        if ( strpos( $coordinatesString, ',' ) > 0 ) {
1095            $latAndLonStrings = explode( ',', $coordinatesString );
1096        } else {
1097            // If there are no commas, the first half, for the
1098            // latitude, should end with either 'N' or 'S', so do a
1099            // little hack to split up the two halves.
1100            $coordinatesString = str_replace( [ 'N', 'S' ], [ 'N,', 'S,' ], $coordinatesString );
1101            $latAndLonStrings = explode( ',', $coordinatesString );
1102        }
1103
1104        if ( count( $latAndLonStrings ) != 2 ) {
1105            throw new MWException( "Error parsing coordinates string: \"$coordinatesString\"." );
1106        }
1107        list( $latString, $lonString ) = $latAndLonStrings;
1108
1109        // Handle strings one at a time.
1110        $latIsNegative = false;
1111        if ( strpos( $latString, 'S' ) > 0 ) {
1112            $latIsNegative = true;
1113        }
1114        $latString = str_replace( [ 'N', 'S' ], '', $latString );
1115        if ( is_numeric( $latString ) ) {
1116            $latNum = floatval( $latString );
1117        } else {
1118            $latNum = self::coordinatePartToNumber( $latString );
1119        }
1120        if ( $latIsNegative ) {
1121            $latNum *= -1;
1122        }
1123
1124        $lonIsNegative = false;
1125        if ( strpos( $lonString, 'W' ) > 0 ) {
1126            $lonIsNegative = true;
1127        }
1128        $lonString = str_replace( [ 'E', 'W' ], '', $lonString );
1129        if ( is_numeric( $lonString ) ) {
1130            $lonNum = floatval( $lonString );
1131        } else {
1132            $lonNum = self::coordinatePartToNumber( $lonString );
1133        }
1134        if ( $lonIsNegative ) {
1135            $lonNum *= -1;
1136        }
1137
1138        return [ $latNum, $lonNum ];
1139    }
1140
1141    public static function escapedFieldName( $cdb, $tableName, $fieldName ) {
1142        if ( is_array( $tableName ) ) {
1143            $tableAlias = key( $tableName );
1144            return $cdb->addIdentifierQuotes( $tableAlias ) . '.' .
1145                $cdb->addIdentifierQuotes( $fieldName );
1146        }
1147        return $cdb->tableName( $tableName ) . '.' .
1148            $cdb->addIdentifierQuotes( $fieldName );
1149    }
1150
1151    public static function joinOfMainAndFieldTable( $cdb, $mainTableName, $fieldTableName ) {
1152        return [
1153            'LEFT OUTER JOIN',
1154            self::escapedFieldName( $cdb, $mainTableName, '_ID' ) .
1155                ' = ' .
1156                self::escapedFieldName( $cdb, $fieldTableName, '_rowID' )
1157        ];
1158    }
1159
1160    public static function joinOfMainAndParentTable( $cdb, $mainTable, $mainTableField,
1161            $parentTable, $parentTableField ) {
1162        return [
1163            'LEFT OUTER JOIN',
1164            self::escapedFieldName( $cdb, $mainTable, $mainTableField ) .
1165            ' = ' .
1166            self::escapedFieldName( $cdb, $parentTable, $parentTableField )
1167        ];
1168    }
1169
1170    public static function joinOfFieldAndMainTable( $cdb, $fieldTable, $mainTable,
1171            $isHierarchy = false, $hierarchyFieldName = null ) {
1172        if ( $isHierarchy ) {
1173            return [
1174                'LEFT OUTER JOIN',
1175                self::escapedFieldName( $cdb, $fieldTable, '_value' ) . ' = ' .
1176                self::escapedFieldName( $cdb, $mainTable, $hierarchyFieldName ),
1177            ];
1178        } else {
1179            return [
1180                'LEFT OUTER JOIN',
1181                self::escapedFieldName( $cdb, $fieldTable, '_rowID' ) . ' = ' .
1182                self::escapedFieldName( $cdb, $mainTable, '_ID' ),
1183            ];
1184        }
1185    }
1186
1187    public static function joinOfSingleFieldAndHierarchyTable( $cdb, $singleFieldTableName, $fieldColumnName, $hierarchyTableName ) {
1188        return [
1189            'LEFT OUTER JOIN',
1190            self::escapedFieldName( $cdb, $singleFieldTableName, $fieldColumnName ) .
1191                ' = ' .
1192                self::escapedFieldName( $cdb, $hierarchyTableName, '_value' )
1193        ];
1194    }
1195
1196    public static function escapedInsert( $db, $tableName, $fieldValues ) {
1197        // Put quotes around the field names - needed for Postgres,
1198        // which otherwise lowercases all field names.
1199        $quotedFieldValues = [];
1200        foreach ( $fieldValues as $fieldName => $fieldValue ) {
1201            $quotedFieldName = $db->addIdentifierQuotes( $fieldName );
1202            $quotedFieldValues[$quotedFieldName] = $fieldValue;
1203        }
1204        $db->insert( $tableName, $quotedFieldValues );
1205    }
1206
1207    /**
1208     * @param LinkRenderer $linkRenderer
1209     * @param LinkTarget|Title $title
1210     * @param string|null $msg Must already be HTML escaped
1211     * @param array $attrs link attributes
1212     * @param array $params query parameters
1213     *
1214     * @return string|null HTML link
1215     */
1216    public static function makeLink( $linkRenderer, $title, $msg = null, $attrs = [], $params = [] ) {
1217        global $wgTitle;
1218
1219        if ( $title === null ) {
1220            return null;
1221        } elseif ( $wgTitle !== null && $title->equals( $wgTitle ) ) {
1222            // Display bolded text instead of a link.
1223            return Linker::makeSelfLinkObj( $title, $msg );
1224        } else {
1225            $html = ( $msg == null ) ? null : new HtmlArmor( $msg );
1226            return $linkRenderer->makeLink( $title, $html, $attrs, $params );
1227        }
1228    }
1229
1230    public static function getSpecialPage( $pageName ) {
1231        return MediaWikiServices::getInstance()->getSpecialPageFactory()
1232            ->getPage( $pageName );
1233    }
1234
1235    /**
1236     * Get the wiki's content language.
1237     * @since 2.6
1238     * @return Language
1239     */
1240    public static function getContentLang() {
1241        return MediaWikiServices::getInstance()->getContentLanguage();
1242    }
1243
1244    public static function logTableAction( $actionName, $tableName, User $user ) {
1245        $log = new LogPage( 'cargo' );
1246        $ctPage = self::getSpecialPage( 'CargoTables' );
1247        $ctTitle = $ctPage->getPageTitle();
1248        if ( $actionName == 'deletetable' ) {
1249            $logParams = [ $tableName ];
1250        } else {
1251            $ctURL = $ctTitle->getFullURL();
1252            $tableURL = "$ctURL/$tableName";
1253            $tableLink = Html::element(
1254                'a',
1255                [ 'href' => $tableURL ],
1256                $tableName
1257            );
1258            $logParams = [ $tableLink ];
1259        }
1260        // Every log entry requires an associated title; Cargo table
1261        // actions don't involve an actual page, so we just use
1262        // Special:CargoTables as the title.
1263        $log->addEntry( $actionName, $ctTitle, '', $logParams, $user );
1264    }
1265
1266    public static function validateHierarchyStructure( $hierarchyStructure ) {
1267        $hierarchyNodesArray = explode( "\n", $hierarchyStructure );
1268        $matches = [];
1269        preg_match( '/^([*]*)[^*]*/i', $hierarchyNodesArray[0], $matches );
1270        if ( strlen( $matches[1] ) != 1 ) {
1271            throw new MWException( "Error: First entry of hierarchy values should start with exact one '*', the entry \"" .
1272                $hierarchyNodesArray[0] . "\" has " . strlen( $matches[1] ) . " '*'" );
1273        }
1274        $level = 0;
1275        foreach ( $hierarchyNodesArray as $node ) {
1276            if ( !preg_match( '/^([*]*)( *)(.*)/i', $node, $matches ) ) {
1277                throw new MWException( "Error: The \"" . $node . "\" entry of hierarchy values does not follow syntax. " .
1278                    "The entry should be of the form : * entry" );
1279            }
1280            if ( strlen( $matches[1] ) < 1 ) {
1281                throw new MWException( "Error: Each entry of hierarchy values should start with atleast one '*', the entry \"" .
1282                    $node . "\" has " . strlen( $matches[1] ) . " '*'" );
1283            }
1284            if ( strlen( $matches[1] ) - $level > 1 ) {
1285                throw new MWException( "Error: Level or count of '*' in hierarchy values should be increased only by count of 1, the entry \"" .
1286                    $node . "\" should have " . ( $level + 1 ) . " or less '*'" );
1287            }
1288            $level = strlen( $matches[1] );
1289            if ( strlen( $matches[3] ) == 0 ) {
1290                throw new MWException( "Error: The entry of hierarchy values cannot be empty." );
1291            }
1292        }
1293    }
1294
1295    public static function validateFieldDescriptionString( $fieldDescriptionStr ) {
1296        $hasParameterFormat = preg_match( '/^([^(]*)\s*\((.*)\)$/s', $fieldDescriptionStr, $matches );
1297
1298        if ( !$hasParameterFormat ) {
1299            if ( self::stringContainsParentheses( $fieldDescriptionStr ) ) {
1300                throw new MWException( 'Invalid field description - Parentheses do not match: "' . $fieldDescriptionStr . '"' );
1301            }
1302        } else {
1303            if ( count( $matches ) == 3 ) {
1304                $extraParamsString = $matches[2];
1305                $extraParams = explode( ';', $extraParamsString );
1306
1307                foreach ( $extraParams as $extraParam ) {
1308                    $extraParamParts = explode( '=', $extraParam, 2 );
1309                    $paramKey = strtolower( trim( $extraParamParts[0] ) );
1310                    $paramValue = isset( $extraParamParts[1] ) ? trim( $extraParamParts[1] ) : '';
1311
1312                    if ( self::stringContainsParentheses( $paramKey ) ) {
1313                        throw new MWException( 'Invalid field description - Parameter name "' . $paramKey . '" must not include parentheses: "' . $fieldDescriptionStr . '"' );
1314                    }
1315
1316                    if ( $paramKey == 'allowed values' ) {
1317                        continue;
1318                    }
1319
1320                    if ( self::stringContainsParentheses( $paramValue ) ) {
1321                        throw new MWException( 'Invalid field description - Parameter value "' . $paramValue . '" must not include parentheses: "' . $fieldDescriptionStr . '"' );
1322                    }
1323                }
1324            }
1325        }
1326    }
1327
1328    /**
1329     * A helper function, because Title::getTemplateLinksTo() is broken in
1330     * MW 1.37.
1331     */
1332    public static function getTemplateLinksTo( $templateTitle, $options = [] ) {
1333        if ( class_exists( 'MediaWiki\CommentFormatter\CommentBatch' ) ) {
1334            // MW 1.38+ - just use the standard function again.
1335            return $templateTitle->getTemplateLinksTo( $options );
1336        }
1337
1338        $db = wfGetDB( DB_REPLICA );
1339
1340        $selectFields = LinkCache::getSelectFields();
1341        $selectFields[] = 'page_namespace';
1342        $selectFields[] = 'page_title';
1343
1344        $res = $db->select(
1345            [ 'page', 'templatelinks' ],
1346            $selectFields,
1347            [
1348                "tl_from=page_id",
1349                "tl_namespace" => NS_TEMPLATE,
1350                "tl_title" => $templateTitle->getDBkey()
1351            ],
1352            __METHOD__,
1353            $options
1354        );
1355
1356        $retVal = [];
1357        if ( $res->numRows() ) {
1358            $linkCache = MediaWikiServices::getInstance()->getLinkCache();
1359            foreach ( $res as $row ) {
1360                $titleObj = Title::makeTitle( $row->page_namespace, $row->page_title );
1361                if ( $titleObj ) {
1362                    $linkCache->addGoodLinkObjFromRow( $titleObj, $row );
1363                    $retVal[] = $titleObj;
1364                }
1365            }
1366        }
1367        return $retVal;
1368    }
1369
1370    public static function setParserOutputPageProperty( $parserOutput, $property, $value ) {
1371        if ( method_exists( $parserOutput, 'setPageProperty' ) ) {
1372            // MW 1.38
1373            $parserOutput->setPageProperty( $property, $value );
1374        } else {
1375            $parserOutput->setProperty( $property, $value );
1376        }
1377    }
1378
1379    public static function globalFields() {
1380        return [
1381            '_pageID' => [ 'type' => 'Integer', 'isList' => false ],
1382            '_pageName' => [ 'type' => 'Page', 'isList' => false ],
1383            '_pageTitle' => [ 'type' => 'String', 'isList' => false ],
1384            '_pageNamespace' => [ 'type' => 'Integer', 'isList' => false ],
1385        ];
1386    }
1387
1388    public static function addGlobalFieldsToSchema( $schema ) {
1389        foreach ( self::globalFields() as $field => $fieldInfo ) {
1390            $fieldDesc = new CargoFieldDescription();
1391            $fieldDesc->mType = $fieldInfo["type"];
1392            $fieldDesc->mIsList = $fieldInfo["isList"];
1393            if ( $fieldInfo["isList"] ) {
1394                $fieldDesc->setDelimiter( '|' );
1395            }
1396            $schema->addField( $field, $fieldDesc );
1397        }
1398    }
1399
1400    public static function stringContainsParentheses( $str ) {
1401        return substr_count( $str, ')' ) > 0 || substr_count( $str, '(' ) > 0;
1402    }
1403
1404    public static function makeWikiPage( $title ) {
1405        if ( method_exists( MediaWikiServices::class, 'getWikiPageFactory' ) ) {
1406            // MW 1.36+
1407            return MediaWikiServices::getInstance()->getWikiPageFactory()->newFromTitle( $title );
1408        } else {
1409            return WikiPage::factory( $title );
1410        }
1411    }
1412}