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