Code Coverage
 
Classes and Traits
Functions and Methods
Lines
Total
0.00% covered (danger)
0.00%
0 / 1
5.77% covered (danger)
5.77%
3 / 52
CRAP
4.35% covered (danger)
4.35%
30 / 690
CargoUtils
0.00% covered (danger)
0.00%
0 / 1
5.77% covered (danger)
5.77%
3 / 52
67913.41
4.35% covered (danger)
4.35%
30 / 690
 getDB
0.00% covered (danger)
0.00%
0 / 1
240
0.00% covered (danger)
0.00%
0 / 35
 getPageProp
0.00% covered (danger)
0.00%
0 / 1
6
0.00% covered (danger)
0.00%
0 / 9
 getAllPageProps
0.00% covered (danger)
0.00%
0 / 1
12
0.00% covered (danger)
0.00%
0 / 13
 getTemplateIDForDBTable
0.00% covered (danger)
0.00%
0 / 1
6
0.00% covered (danger)
0.00%
0 / 9
 formatError
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
1 / 1
 displayErrorMessage
0.00% covered (danger)
0.00%
0 / 1
2
0.00% covered (danger)
0.00%
0 / 2
 getTables
0.00% covered (danger)
0.00%
0 / 1
12
0.00% covered (danger)
0.00%
0 / 10
 getParentTables
0.00% covered (danger)
0.00%
0 / 1
20
0.00% covered (danger)
0.00%
0 / 10
 getChildTables
0.00% covered (danger)
0.00%
0 / 1
30
0.00% covered (danger)
0.00%
0 / 18
 getDrilldownTabsParams
0.00% covered (danger)
0.00%
0 / 1
20
0.00% covered (danger)
0.00%
0 / 10
 getTableSchemas
0.00% covered (danger)
0.00%
0 / 1
90
0.00% covered (danger)
0.00%
0 / 22
 getTableNameForTemplate
0.00% covered (danger)
0.00%
0 / 1
6
0.00% covered (danger)
0.00%
0 / 6
 makeDifferentAlias
0.00% covered (danger)
0.00%
0 / 1
2
0.00% covered (danger)
0.00%
0 / 2
 smartSplit
0.00% covered (danger)
0.00%
0 / 1
20.08
68.57% covered (warning)
68.57%
24 / 35
 findQuotedStringEnd
0.00% covered (danger)
0.00%
0 / 1
72
0.00% covered (danger)
0.00%
0 / 15
 removeQuotedStrings
0.00% covered (danger)
0.00%
0 / 1
12
0.00% covered (danger)
0.00%
0 / 5
 removeNamespaceFromFileName
0.00% covered (danger)
0.00%
0 / 1
6
0.00% covered (danger)
0.00%
0 / 4
 getSQLFieldPattern
0.00% covered (danger)
0.00%
0 / 1
6
0.00% covered (danger)
0.00%
0 / 3
 getSQLTableAndFieldPattern
0.00% covered (danger)
0.00%
0 / 1
6
0.00% covered (danger)
0.00%
0 / 4
 getSQLTablePattern
0.00% covered (danger)
0.00%
0 / 1
6
0.00% covered (danger)
0.00%
0 / 3
 isSQLStringLiteral
0.00% covered (danger)
0.00%
0 / 1
6
0.00% covered (danger)
0.00%
0 / 1
 getDateFunctions
0.00% covered (danger)
0.00%
0 / 1
6
0.00% covered (danger)
0.00%
0 / 8
 smartParse
0.00% covered (danger)
0.00%
0 / 1
156
0.00% covered (danger)
0.00%
0 / 19
 parsePageForStorage
0.00% covered (danger)
0.00%
0 / 1
20
0.00% covered (danger)
0.00%
0 / 12
 recreateDBTablesForTemplate
0.00% covered (danger)
0.00%
0 / 1
342
0.00% covered (danger)
0.00%
0 / 49
 tableFullyExists
0.00% covered (danger)
0.00%
0 / 1
6
0.00% covered (danger)
0.00%
0 / 7
 fieldTypeToSQLType
0.00% covered (danger)
0.00%
0 / 1
1482
0.00% covered (danger)
0.00%
0 / 51
 createCargoTableOrTables
0.00% covered (danger)
0.00%
0 / 1
380
0.00% covered (danger)
0.00%
0 / 62
 createTable
0.00% covered (danger)
0.00%
0 / 1
306
0.00% covered (danger)
0.00%
0 / 61
 specialTableNames
0.00% covered (danger)
0.00%
0 / 1
6
0.00% covered (danger)
0.00%
0 / 5
 fullTextMatchSQL
0.00% covered (danger)
0.00%
0 / 1
2
0.00% covered (danger)
0.00%
0 / 3
 coordinatePartToNumber
0.00% covered (danger)
0.00%
0 / 1
110
0.00% covered (danger)
0.00%
0 / 28
 parseCoordinatesString
0.00% covered (danger)
0.00%
0 / 1
110
0.00% covered (danger)
0.00%
0 / 30
 escapedFieldName
0.00% covered (danger)
0.00%
0 / 1
6
0.00% covered (danger)
0.00%
0 / 6
 joinOfMainAndFieldTable
0.00% covered (danger)
0.00%
0 / 1
2
0.00% covered (danger)
0.00%
0 / 4
 joinOfMainAndParentTable
0.00% covered (danger)
0.00%
0 / 1
2
0.00% covered (danger)
0.00%
0 / 4
 joinOfFieldAndMainTable
0.00% covered (danger)
0.00%
0 / 1
6
0.00% covered (danger)
0.00%
0 / 7
 joinOfSingleFieldAndHierarchyTable
0.00% covered (danger)
0.00%
0 / 1
2
0.00% covered (danger)
0.00%
0 / 4
 escapedInsert
0.00% covered (danger)
0.00%
0 / 1
6
0.00% covered (danger)
0.00%
0 / 6
 makeLink
0.00% covered (danger)
0.00%
0 / 1
12.41
33.33% covered (danger)
33.33%
2 / 6
 getSpecialPage
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
2 / 2
 getContentLang
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
1 / 1
 logTableAction
0.00% covered (danger)
0.00%
0 / 1
6
0.00% covered (danger)
0.00%
0 / 13
 validateHierarchyStructure
0.00% covered (danger)
0.00%
0 / 1
56
0.00% covered (danger)
0.00%
0 / 21
 validateFieldDescriptionString
0.00% covered (danger)
0.00%
0 / 1
90
0.00% covered (danger)
0.00%
0 / 18
 getTemplateLinksTo
0.00% covered (danger)
0.00%
0 / 1
20
0.00% covered (danger)
0.00%
0 / 18
 setParserOutputPageProperty
0.00% covered (danger)
0.00%
0 / 1
6
0.00% covered (danger)
0.00%
0 / 4
 replaceRedirectWithTarget
0.00% covered (danger)
0.00%
0 / 1
72
0.00% covered (danger)
0.00%
0 / 11
 globalFields
0.00% covered (danger)
0.00%
0 / 1
2
0.00% covered (danger)
0.00%
0 / 1
 addGlobalFieldsToSchema
0.00% covered (danger)
0.00%
0 / 1
12
0.00% covered (danger)
0.00%
0 / 8
 stringContainsParentheses
0.00% covered (danger)
0.00%
0 / 1
6
0.00% covered (danger)
0.00%
0 / 1
 makeWikiPage
0.00% covered (danger)
0.00%
0 / 1
6
0.00% covered (danger)
0.00%
0 / 3
<?php
/**
 * Utility functions for the Cargo extension.
 *
 * @author Yaron Koren
 * @ingroup Cargo
 */
use MediaWiki\Linker\LinkRenderer;
use MediaWiki\Linker\LinkTarget;
use MediaWiki\MediaWikiServices;
class CargoUtils {
    private static $CargoDB = null;
    /**
     * @return Database or DatabaseBase
     */
    public static function getDB() {
        if ( self::$CargoDB != null && self::$CargoDB->isOpen() ) {
            return self::$CargoDB;
        }
        global $wgDBuser, $wgDBpassword, $wgDBprefix, $wgDBservers;
        global $wgCargoDBserver, $wgCargoDBname, $wgCargoDBuser, $wgCargoDBpassword, $wgCargoDBprefix, $wgCargoDBtype;
        $lb = MediaWikiServices::getInstance()->getDBLoadBalancer();
        $dbr = $lb->getConnectionRef( DB_REPLICA );
        $server = $dbr->getServer();
        $name = $dbr->getDBname();
        $type = $dbr->getType();
        // We need $wgCargoDBtype for other functions.
        if ( $wgCargoDBtype === null ) {
            $wgCargoDBtype = $type;
        }
        $dbServer = $wgCargoDBserver === null ? $server : $wgCargoDBserver;
        $dbName = $wgCargoDBname === null ? $name : $wgCargoDBname;
        // Server (host), db name, and db type can be retrieved from $dbr via
        // public methods, but username and password cannot. If these values are
        // not set for Cargo, get them from either $wgDBservers or wgDBuser and
        // $wgDBpassword, depending on whether or not there are multiple DB servers.
        if ( $wgCargoDBuser !== null ) {
            $dbUsername = $wgCargoDBuser;
        } elseif ( is_array( $wgDBservers ) && isset( $wgDBservers[0] ) ) {
            $dbUsername = $wgDBservers[0]['user'];
        } else {
            $dbUsername = $wgDBuser;
        }
        if ( $wgCargoDBpassword !== null ) {
            $dbPassword = $wgCargoDBpassword;
        } elseif ( is_array( $wgDBservers ) && isset( $wgDBservers[0] ) ) {
            $dbPassword = $wgDBservers[0]['password'];
        } else {
            $dbPassword = $wgDBpassword;
        }
        if ( $wgCargoDBprefix !== null ) {
            $dbTablePrefix = $wgCargoDBprefix;
        } else {
            $dbTablePrefix = $wgDBprefix . 'cargo__';
        }
        $params = [
            'host' => $dbServer,
            'user' => $dbUsername,
            'password' => $dbPassword,
            'dbname' => $dbName,
            'tablePrefix' => $dbTablePrefix,
        ];
        if ( $type === 'sqlite' ) {
            $params['dbFilePath'] = $dbr->getDbFilePath();
        } elseif ( $type === 'postgres' ) {
            global $wgDBport;
            // @TODO - a $wgCargoDBport variable is still needed.
            $params['port'] = $wgDBport;
        }
        self::$CargoDB = Database::factory( $wgCargoDBtype, $params );
        return self::$CargoDB;
    }
    /**
     * Gets a page property for the specified page ID and property name.
     */
    public static function getPageProp( $pageID, $pageProp ) {
        $lb = MediaWikiServices::getInstance()->getDBLoadBalancer();
        $dbr = $lb->getConnectionRef( DB_REPLICA );
        $value = $dbr->selectField( 'page_props', [
                'pp_value'
            ], [
                'pp_page' => $pageID,
                    'pp_propname' => $pageProp,
            ]
        );
        if ( !$value ) {
            return null;
        }
        return $value;
    }
    /**
     * Similar to getPageProp().
     */
    public static function getAllPageProps( $pageProp ) {
        $lb = MediaWikiServices::getInstance()->getDBLoadBalancer();
        $dbr = $lb->getConnectionRef( DB_REPLICA );
        $res = $dbr->select( 'page_props', [
            'pp_page',
            'pp_value'
            ], [
            'pp_propname' => $pageProp
            ]
        );
        $pagesPerValue = [];
        foreach ( $res as $row ) {
            $pageID = $row->pp_page;
            $pageValue = $row->pp_value;
            if ( array_key_exists( $pageValue, $pagesPerValue ) ) {
                $pagesPerValue[$pageValue][] = $pageID;
            } else {
                $pagesPerValue[$pageValue] = [ $pageID ];
            }
        }
        return $pagesPerValue;
    }
    /**
     * Gets the template page where this table is defined -
     * hopefully there's exactly one of them.
     */
    public static function getTemplateIDForDBTable( $tableName ) {
        $lb = MediaWikiServices::getInstance()->getDBLoadBalancer();
        $dbr = $lb->getConnectionRef( DB_REPLICA );
        $page = $dbr->selectField( 'page_props', [
            'pp_page'
            ], [
            'pp_value' => $tableName,
            'pp_propname' => 'CargoTableName'
            ]
        );
        if ( !$page ) {
            return null;
        }
        return $page;
    }
    public static function formatError( $errorString ) {
        return Html::element( 'div', [ 'class' => 'error' ], $errorString );
    }
    public static function displayErrorMessage( OutputPage $out, Message $message ) {
        $out->wrapWikiTextAsInterface( 'error', $message->plain() );
    }
    public static function getTables() {
        $tableNames = [];
        $lb = MediaWikiServices::getInstance()->getDBLoadBalancer();
        $dbr = $lb->getConnectionRef( DB_REPLICA );
        $res = $dbr->select( 'cargo_tables', 'main_table' );
        foreach ( $res as $row ) {
            $tableName = $row->main_table;
            // Skip "replacement" tables.
            if ( substr( $tableName, -6 ) == '__NEXT' ) {
                continue;
            }
            $tableNames[] = $tableName;
        }
        return $tableNames;
    }
    public static function getParentTables( $tableName ) {
        $parentTables = [];
        $lb = MediaWikiServices::getInstance()->getDBLoadBalancer();
        $dbr = $lb->getConnectionRef( DB_REPLICA );
        $res = $dbr->select( 'cargo_tables', [ 'template_id', 'main_table' ] );
        foreach ( $res as $row ) {
            if ( $tableName == $row->main_table ) {
                $parentTables = self::getPageProp( $row->template_id, 'CargoParentTables' );
            }
        }
        if ( $parentTables ) {
            return unserialize( $parentTables );
        }
    }
    public static function getChildTables( $tableName ) {
        $childTables = [];
        $allParentTablesInfo = self::getAllPageProps( 'CargoParentTables' );
        foreach ( $allParentTablesInfo as $parentTablesInfoStr => $templateIDs ) {
            $parentTablesInfo = unserialize( $parentTablesInfoStr );
            foreach ( $parentTablesInfo as $alias => $parentTableInfo ) {
                $remoteTable = $parentTableInfo['Name'];
                if ( $remoteTable !== $tableName ) {
                    continue;
                }
                $localField = $parentTableInfo['_localField'];
                $remoteField = $parentTableInfo['_remoteField'];
                // There should only ever be one ID here... right?
                foreach ( $templateIDs as $templateID ) {
                    $childTable = self::getPageProp( $templateID, 'CargoTableName' );
                    $childTables[] = [
                        'childTable' => $childTable,
                        'childField' => $localField,
                        'parentTable' => $remoteTable,
                        'parentField' => $remoteField
                    ];
                }
            }
        }
        return $childTables;
    }
    public static function getDrilldownTabsParams( $tableName ) {
        $drilldownTabs = [];
        $lb = MediaWikiServices::getInstance()->getDBLoadBalancer();
        $dbr = $lb->getConnectionRef( DB_REPLICA );
        $res = $dbr->select( 'cargo_tables', [ 'template_id', 'main_table' ] );
        foreach ( $res as $row ) {
            if ( $tableName == $row->main_table ) {
                $drilldownTabs = self::getPageProp( $row->template_id, 'CargoDrilldownTabsParams' );
            }
        }
        if ( $drilldownTabs ) {
            return unserialize( $drilldownTabs );
        }
    }
    public static function getTableSchemas( $tableNames ) {
        $mainTableNames = [];
        foreach ( $tableNames as $tableName ) {
            if ( strpos( $tableName, '__' ) !== false &&
                strpos( $tableName, '__NEXT' ) === false ) {
                // We just want the first part of it.
                $tableNameParts = explode( '__', $tableName );
                $tableName = $tableNameParts[0];
            }
            if ( !in_array( $tableName, $mainTableNames ) ) {
                $mainTableNames[] = $tableName;
            }
        }
        $tableSchemas = [];
        $lb = MediaWikiServices::getInstance()->getDBLoadBalancer();
        $dbr = $lb->getConnectionRef( DB_REPLICA );
        $res = $dbr->select( 'cargo_tables', [ 'main_table', 'table_schema' ],
            [ 'main_table' => $mainTableNames ] );
        foreach ( $res as $row ) {
            $tableName = $row->main_table;
            $tableSchemaString = $row->table_schema;
            $tableSchemas[$tableName] = CargoTableSchema::newFromDBString( $tableSchemaString );
        }
        // Validate the table names.
        if ( count( $tableSchemas ) < count( $mainTableNames ) ) {
            foreach ( $mainTableNames as $tableName ) {
                if ( !array_key_exists( $tableName, $tableSchemas ) ) {
                    throw new MWException( wfMessage( "cargo-unknowntable", $tableName )->parse() );
                }
            }
        }
        return $tableSchemas;
    }
    /**
     * Get the Cargo table for the passed-in template specified via
     * either #cargo_declare or #cargo_attach, if the template has a
     * call to either one.
     */
    public static function getTableNameForTemplate( $templateTitle ) {
        $templatePageID = $templateTitle->getArticleID();
        $declaredTableName = self::getPageProp( $templatePageID, 'CargoTableName' );
        if ( $declaredTableName !== null ) {
            return [ $declaredTableName, true ];
        }
        $attachedTableName = self::getPageProp( $templatePageID, 'CargoAttachedTable' );
        return [ $attachedTableName, false ];
    }
    /**
     * Make the alias different from table name to avoid removal of aliases (when passed
     * in to Mediawiki's select() call) if the alias and table name are the same.
     * Aliases are needed because sometimes the same table can be joined more than once, if
     * it serves as two different parent tables
     * @param string $tableName
     * @return string
     */
    public static function makeDifferentAlias( $tableName ) {
        $tableAlias = $tableName . "_alias";
        return $tableAlias;
    }
    /**
     * Splits a string by the delimiter, but ensures that parenthesis, separators
     * and "the other quote" (single quote in a double quoted string or double
     * quote in a single quoted string) inside a quoted string are not considered
     * lexically.
     * @param string $delimiter The delimiter to split by.
     * @param string $string The string to split.
     * @param bool $includeBlankValues Whether to include blank values in the returned array.
     * @return string[] Array of substrings (with or without blank values).
     * @throws MWException On unmatched quotes or incomplete escape sequences.
     */
    public static function smartSplit( $delimiter, $string, $includeBlankValues = false ) {
        if ( $string == '' ) {
            return [];
        }
        $ignoreNextChar = false;
        $returnValues = [];
        $numOpenParentheses = 0;
        $curReturnValue = '';
        $strLength = strlen( $string );
        for ( $i = 0; $i < $strLength; $i++ ) {
            $curChar = $string[$i];
            if ( $ignoreNextChar ) {
                // If previous character was a backslash,
                // ignore the current one, since it's escaped.
                // What if this one is a backslash too?
                // Doesn't matter - it's escaped.
                $ignoreNextChar = false;
            } elseif ( $curChar == '(' ) {
                $numOpenParentheses++;
            } elseif ( $curChar == ')' ) {
                $numOpenParentheses--;
            } elseif ( $curChar == '\'' || $curChar == '"' ) {
                $pos = self::findQuotedStringEnd( $string, $curChar, $i + 1 );
                if ( $pos === false ) {
                    throw new MWException( "Error: unmatched quote in SQL string constant." );
                }
                $curReturnValue .= substr( $string, $i, $pos - $i );
                $i = $pos;
            } elseif ( $curChar == '\\' ) {
                $ignoreNextChar = true;
            }
            if ( $curChar == $delimiter && $numOpenParentheses == 0 ) {
                $returnValues[] = trim( $curReturnValue );
                $curReturnValue = '';
            } else {
                $curReturnValue .= $curChar;
            }
        }
        $returnValues[] = trim( $curReturnValue );
        if ( $ignoreNextChar ) {
            throw new MWException( "Error: incomplete escape sequence." );
        }
        if ( $includeBlankValues ) {
            return $returnValues;
        }
        // Remove empty strings (but not other quasi-empty values, like '0') and re-key the array.
        $noEmptyStrings = static function ( $s ) {
            return $s !== '';
        };
        return array_values( array_filter( $returnValues, $noEmptyStrings ) );
    }
    /**
     * Finds the end of a quoted string.
     */
    public static function findQuotedStringEnd( $string, $quoteChar, $pos ) {
        $ignoreNextChar = false;
        $strLength = strlen( $string );
        for ( $i = $pos; $i < $strLength; $i++ ) {
            $curChar = $string[$i];
            if ( $ignoreNextChar ) {
                $ignoreNextChar = false;
            } elseif ( $curChar == $quoteChar ) {
                if ( $i + 1 < $strLength && $string[$i + 1] == $quoteChar ) {
                    $i++;
                } else {
                    return $i;
                }
            } elseif ( $curChar == '\\' ) {
                $ignoreNextChar = true;
            }
        }
        if ( $ignoreNextChar ) {
            throw new MWException( "Error: incomplete escape sequence." );
        }
        return false;
    }
    /**
     * Deletes text within quotes and raises and exception if a quoted string
     * is not closed.
     */
    public static function removeQuotedStrings( $string ) {
        $noQuotesPattern = '/("|\')([^\\1\\\\]|\\\\.)*?\\1/';
        $string = preg_replace( $noQuotesPattern, '', $string );
        if ( strpos( $string, '"' ) !== false || strpos( $string, "'" ) !== false ) {
            throw new MWException( "Error: unclosed string literal." );
        }
        return $string;
    }
    /**
     * Get rid of the "File:" or "Image:" (in the wiki's language) at the
     * beginning of a file name, if it's there.
     */
    public static function removeNamespaceFromFileName( $fileName ) {
        $fileTitle = Title::newFromText( $fileName, NS_FILE );
        if ( $fileTitle == null ) {
            return null;
        }
        return $fileTitle->getText();
    }
    /**
     * Generates a Regular Expression to match $fieldName in a SQL string.
     * Allows for $ as valid identifier character.
     */
    public static function getSQLFieldPattern( $fieldName, $closePattern = true ) {
        $fieldName = str_replace( '$', '\$', $fieldName );
        $pattern = '/([^\w$.,]|^)' . $fieldName;
        return $pattern . ( $closePattern ? '([^\w$]|$)/' : '' );
    }
    /**
     * Generates a Regular Expression to match $tableName.$fieldName in a
     * SQL string. Allows for $ as valid identifier character.
     */
    public static function getSQLTableAndFieldPattern( $tableName, $fieldName, $closePattern = true ) {
        $fieldName = str_replace( '$', '\$', $fieldName );
        $tableName = str_replace( '$', '\$', $tableName );
        $pattern = '/([^\w$,]|^)' . $tableName . '\.' . $fieldName;
        return $pattern . ( $closePattern ? '([^\w$]|$)/ui' : '' );
    }
    /**
     * Generates a Regular Expression to match $tableName in a SQL string.
     * Allows for $ as valid identifier character.
     */
    public static function getSQLTablePattern( $tableName, $closePattern = true ) {
        $tableName = str_replace( '$', '\$', $tableName );
        $pattern = '/([^\w$]|^)(' . $tableName . ')\.(\w*)';
        return $pattern . ( $closePattern ? '/ui' : '' );
    }
    /**
     * Determines whether a string is a literal.
     * This may need different handling for different (non-MySQL) DB types.
     */
    public static function isSQLStringLiteral( $string ) {
        return $string[0] == "'" && substr( $string, -1, 1 ) == "'";
    }
    public static function getDateFunctions( $dateDBField ) {
        global $wgCargoDBtype;
        // Unfortunately, date handling in general - and date extraction
        // specifically - is done differently in almost every DB
        // system. If support was ever added for SQLite,
        // that would require special handling as well.
        if ( $wgCargoDBtype == 'postgres' ) {
            $yearValue = "EXTRACT(YEAR FROM $dateDBField)";
            $monthValue = "EXTRACT(MONTH FROM $dateDBField)";
            $dayValue = "EXTRACT(DAY FROM $dateDBField)";
        } else { // MySQL
            $yearValue = "YEAR($dateDBField)";
            $monthValue = "MONTH($dateDBField)";
            $dayValue = "DAY($dateDBField)";
        }
        return [ $yearValue, $monthValue, $dayValue ];
    }
    /**
     * Parses a piece of wikitext differently depending on whether
     * we're in a special or regular page.
     *
     * @param string $value
     * @param Parser $parser
     * @return string
     */
    public static function smartParse( $value, $parser ) {
        global $wgRequest;
        // This decode() call is here in case the value was
        // set using {{PAGENAME}}, which for some reason
        // HTML-encodes some of its characters - see
        // https://www.mediawiki.org/wiki/Help:Magic_words#Page_names
        // Of course, String and Page fields could be set using
        // {{PAGENAME}} as well, but those seem less likely.
        $value = htmlspecialchars_decode( $value );
        // Add a newline at the beginning if it looks like the value
        // starts with a bulleted or numbered list, to make sure that
        // the first line gets formatted correctly.
        if ( strpos( $value, '*' ) === 0 || strpos( $value, '#' ) === 0 ) {
            $value = "\n" . $value;
        }
        // Add __NOTOC__ and __NOEDITSECTION__ "behavior switches"
        // to the beginning of this value, so that, on the off chance
        // that it contains section headers, a table of contents and
        // edit links will not appear in the parsed output.
        // We avoid newlines and extra spaces here (we don't need
        // them) to not mess up the formatting.
        $value = "__NOTOC____NOEDITSECTION__$value";
        // Parse it as if it's wikitext. The exact call
        // depends on whether we're in a special page or not.
        if ( $parser === null ) {
            $parser = MediaWikiServices::getInstance()->getParser();
        }
        // Since MW 1.35, Parser::getTitle() throws a TypeError if it
        // would have returned null, so just catch the error.
        // Why would the title be null? It's not clear, but it seems to
        // happen in at least once case: in "action=pagevalues" for a
        // page with non-ASCII characters in its name.
        try {
            $title = $parser->getTitle();
        } catch ( TypeError $e ) {
            $title = null;
        }
        if ( $title === null ) {
            global $wgTitle;
            $title = $wgTitle;
        }
        if ( $title != null && $title->isSpecial( 'RunJobs' ) ) {
            // Conveniently, if this is called from within a job
            // being run, the name of the page will be
            // Special:RunJobs.
            // If that's the case, do nothing - we don't need to
            // parse the value.
        // This next clause should only be called for Cargo's special
        // pages, not for SF's Special:RunQuery. Don't know about other
        // special pages.
        } elseif ( ( $title != null && $title->isSpecialPage() && !$wgRequest->getCheck( 'wpRunQuery' ) ) ||
            // The 'pagevalues' action is also a Cargo special page.
            $wgRequest->getVal( 'action' ) == 'pagevalues' ) {
            $parserOptions = ParserOptions::newFromAnon();
            $parserOutput = $parser->parse( $value, $title, $parserOptions, false );
            $value = $parserOutput->getText( [ 'unwrap' => true ] );
        } else {
            $value = $parser->internalParse( $value );
        }
        return $value;
    }
    public static function parsePageForStorage( $title, $pageContents ) {
        // Special handling for the Approved Revs extension.
        $approvedContent = null;
        if ( class_exists( 'ApprovedRevs' ) ) {
            $approvedContent = ApprovedRevs::getApprovedContent( $title );
        }
        if ( $approvedContent != null ) {
            if ( method_exists( $approvedContent, 'getText' ) ) {
                // Approved Revs 1.0+
                $pageText = $approvedContent->getText();
            } else {
                $pageText = $approvedContent;
            }
        } else {
            $pageText = $pageContents;
        }
        $parser = MediaWikiServices::getInstance()->getParser();
        $parserOptions = ParserOptions::newFromAnon();
        $parser->parse( $pageText, $title, $parserOptions );
    }
    /**
     * Drop, and then create again, the database table(s) holding the
     * data for this template.
     * Why "tables"? Because every field that holds a list of values gets
     * its own helper table.
     *
     * @param int $templatePageID
     * @return bool
     * @throws MWException
     */
    public static function recreateDBTablesForTemplate(
        $templatePageID,
        $createReplacement,
        User $user,
        $tableName = null,
        $tableSchema = null,
        $parentTables = null
    ) {
        if ( $tableName == null ) {
            $tableName = self::getPageProp( $templatePageID, 'CargoTableName' );
        }
        if ( $tableSchema == null ) {
            $tableSchemaString = self::getPageProp( $templatePageID, 'CargoFields' );
            // First, see if there even is DB storage for this template -
            // if not, exit.
            if ( $tableSchemaString === null ) {
                return false;
            }
            $tableSchema = CargoTableSchema::newFromDBString( $tableSchemaString );
        } else {
            $tableSchemaString = $tableSchema->toDBString();
        }
        if ( $parentTables == null ) {
            $parentTablesStr = self::getPageProp( $templatePageID, 'CargoParentTables' );
            if ( $parentTablesStr ) {
                $parentTables = unserialize( $parentTablesStr );
            } else {
                $parentTables = [];
            }
        }
        $dbw = wfGetDB( DB_MASTER );
        $cdb = self::getDB();
        // Cannot run any recreate if a replacement table exists.
        $possibleReplacementTable = $tableName . '__NEXT';
        if ( self::tableFullyExists( $tableName ) && self::tableFullyExists( $possibleReplacementTable ) ) {
            throw new MWException( wfMessage( 'cargo-recreatedata-replacementexists', $tableName, $possibleReplacementTable )->parse() );
        }
        if ( $createReplacement ) {
            $tableName .= '__NEXT';
            if ( $cdb->tableExists( $possibleReplacementTable ) ) {
                // The replacement table exists, but it does
                // not have a row in cargo_tables - this is
                // hopefully a rare occurrence.
                try {
                    $cdb->begin();
                    $cdb->dropTable( $tableName );
                    $cdb->commit();
                } catch ( Exception $e ) {
                    throw new MWException( "Caught exception ($e) while trying to drop Cargo table. "
                    . "Please make sure that your database user account has the DROP permission." );
                }
            }
        } else {
            // @TODO - is an array really necessary? Shouldn't it
            // always be just one table name? Tied in with that,
            // if a table name was already specified, do we need
            // to do a lookup here?
            $tableNames = [];
            $res = $dbw->select( 'cargo_tables', 'main_table', [ 'template_id' => $templatePageID ] );
            foreach ( $res as $row ) {
                $tableNames[] = $row->main_table;
            }
            // For whatever reason, that DB query might have failed -
            // if so, just add the table name here.
            if ( $tableName != null && !in_array( $tableName, $tableNames ) ) {
                $tableNames[] = $tableName;
            }
            $mainTableAlreadyExists = self::tableFullyExists( $tableNames[0] );
            foreach ( $tableNames as $curTable ) {
                try {
                    $cdb->begin();
                    $cdb->dropTable( $curTable );
                    $cdb->commit();
                } catch ( Exception $e ) {
                    throw new MWException( "Caught exception ($e) while trying to drop Cargo table. "
                    . "Please make sure that your database user account has the DROP permission." );
                }
                $dbw->delete( 'cargo_pages', [ 'table_name' => $curTable ] );
            }
            $dbw->delete( 'cargo_tables', [ 'template_id' => $templatePageID ] );
        }
        self::createCargoTableOrTables( $cdb, $dbw, $tableName, $tableSchema, $tableSchemaString, $templatePageID );
        if ( !$createReplacement ) {
            // Log this.
            if ( $mainTableAlreadyExists ) {
                self::logTableAction( 'recreatetable', $tableName, $user );
            } else {
                self::logTableAction( 'createtable', $tableName, $user );
            }
        }
        return true;
    }
    public static function tableFullyExists( $tableName ) {
        $lb = MediaWikiServices::getInstance()->getDBLoadBalancer();
        $dbr = $lb->getConnectionRef( DB_REPLICA );
        $numRows = $dbr->selectRowCount( 'cargo_tables', '*', [ 'main_table' => $tableName ], __METHOD__ );
        if ( $numRows == 0 ) {
            return false;
        }
        $cdb = self::getDB();
        return $cdb->tableExists( $tableName );
    }
    public static function fieldTypeToSQLType( $fieldType, $dbType, $size = null ) {
        global $wgCargoDefaultStringBytes;
        // Possible values for $dbType: "mysql", "postgres", "sqlite"
        // @TODO - make sure it's one of these.
        if ( $fieldType == 'Integer' ) {
            switch ( $dbType ) {
                case "mysql":
                case "postgres":
                    return 'Int';
                case "sqlite":
                    return 'INTEGER';
            }
        } elseif ( $fieldType == 'Float' || $fieldType == 'Rating' ) {
            switch ( $dbType ) {
                case "mysql":
                    return 'Double';
                case "postgres":
                    return 'Numeric';
                case "sqlite":
                    return 'REAL';
            }
        } elseif ( $fieldType == 'Boolean' ) {
            switch ( $dbType ) {
                case "mysql":
                case "postgres":
                    return 'Boolean';
                case "sqlite":
                    return 'INTEGER';
            }
        } elseif ( $fieldType == 'Date' || $fieldType == 'Start date' || $fieldType == 'End date' ) {
            switch ( $dbType ) {
                case "mysql":
                case "postgres":
                    return 'Date';
                case "sqlite":
                    // Should really be 'REAL', with
                    // accompanying handling.
                    return 'TEXT';
            }
        } elseif ( $fieldType == 'Datetime' || $fieldType == 'Start datetime' || $fieldType == 'End datetime' ) {
            // Some DB types have a datetime type that includes
            // the time zone, but MySQL unfortunately doesn't,
            // so the best solution for time zones is probably
            // to have a separate field for them.
            switch ( $dbType ) {
                case "mysql":
                    return 'Datetime';
                case "postgres":
                    return 'Timestamp';
                case "sqlite":
                    // Should really be 'REAL', with
                    // accompanying handling.
                    return 'TEXT';
            }
        } elseif ( $fieldType == 'Text' || $fieldType == 'Wikitext' ) {
            switch ( $dbType ) {
                case "mysql":
                case "postgres":
                case "sqlite":
                    return 'Text';
            }
        } elseif ( $fieldType == 'Searchtext' ) {
            if ( $dbType != 'mysql' ) {
                throw new MWException( "Error: a \"Searchtext\" field can currently only be defined for MySQL databases." );
            }
            return 'Mediumtext';
        } else { // 'String', 'Page', 'Wikitext string', etc.
            if ( $size == null ) {
                $size = $wgCargoDefaultStringBytes;
            }
            switch ( $dbType ) {
                case "mysql":
                case "postgres":
                    // For at least MySQL, there's a limit
                    // on how many total bytes a table's
                    // fields can have, and "Text" and
                    // "Blob" fields don't get added to the
                    // total, so if it's a big piece of
                    // text, just make it a "Text" field.
                    if ( $size > 1000 ) {
                        return 'Text';
                    } else {
                        return "Varchar($size)";
                    }
                case "sqlite":
                    return 'TEXT';
            }
        }
    }
    public static function createCargoTableOrTables( $cdb, $dbw, $tableName, $tableSchema, $tableSchemaString, $templatePageID ) {
        $cdb->begin();
        $cdbTableName = $cdb->addIdentifierQuotes( $cdb->tableName( $tableName, 'plain' ) );
        $fieldsInMainTable = [
            '_ID' => 'Integer',
            '_pageName' => 'String',
            '_pageTitle' => 'String',
            '_pageNamespace' => 'Integer',
            '_pageID' => 'Integer',
        ];
        $containsFileType = false;
        foreach ( $tableSchema->mFieldDescriptions as $fieldName => $fieldDescription ) {
            $size = $fieldDescription->mSize;
            $isList = $fieldDescription->mIsList;
            $fieldType = $fieldDescription->mType;
            if ( $isList || $fieldType == 'Coordinates' ) {
                // No field will be created with this name -
                // instead, we'll have one called
                // fieldName + '__full', and a separate table
                // for holding each value.
                // The field holding the full list will always
                // just be text - and it could be long.
                $fieldsInMainTable[$fieldName . '__full'] = 'Text';
            } else {
                $fieldsInMainTable[$fieldName] = $fieldDescription;
            }
            if ( !$isList && $fieldType == 'Coordinates' ) {
                $fieldsInMainTable[$fieldName . '__lat'] = 'Float';
                $fieldsInMainTable[$fieldName . '__lon'] = 'Float';
            } elseif ( $fieldType == 'Date' || $fieldType == 'Datetime' ||
                    $fieldType == 'Start date' || $fieldType == 'Start datetime' ||
                    $fieldType == 'End date' || $fieldType == 'End datetime' ) {
                $fieldsInMainTable[$fieldName . '__precision'] = 'Integer';
            } elseif ( $fieldType == 'File' ) {
                $containsFileType = true;
            }
        }
        self::createTable( $cdb, $tableName, $fieldsInMainTable );
        // Now also create tables for each of the 'list' fields,
        // if there are any.
        $fieldTableNames = []; // Names of tables that store data regarding pages
        $fieldHelperTableNames = []; // Names of tables that store metadata regarding template or fields
        foreach ( $tableSchema->mFieldDescriptions as $fieldName => $fieldDescription ) {
            if ( $fieldDescription->mIsList ) {
                // The double underscore in this table name
                // should prevent anyone from giving this name
                // to a "real" table.
                $fieldTableName = $tableName . '__' . $fieldName;
                $cdb->dropTable( $fieldTableName );
                $fieldsInTable = [ '_rowID' => 'Integer' ];
                $fieldType = $fieldDescription->mType;
                if ( $fieldType == 'Coordinates' ) {
                    $fieldsInTable['_lat'] = 'Float';
                    $fieldsInTable['_lon'] = 'Float';
                } else {
                    $fieldsInTable['_value'] = $fieldType;
                }
                $fieldsInTable['_position'] = 'Integer';
                self::createTable( $cdb, $fieldTableName, $fieldsInTable );
                $fieldTableNames[] = $fieldTableName;
            }
            if ( $fieldDescription->mIsHierarchy ) {
                $fieldHelperTableName = $tableName . '__' . $fieldName . '__hierarchy';
                $cdb->dropTable( $fieldHelperTableName );
                $fieldType = $fieldDescription->mType;
                $fieldsInTable = [
                    '_value' => $fieldType,
                    '_left' => 'Integer',
                    '_right' => 'Integer',
                ];
                self::createTable( $cdb, $fieldHelperTableName, $fieldsInTable, true );
                $fieldHelperTableNames[] = $fieldHelperTableName;
                // Insert hierarchy information in the __hierarchy table
                $hierarchyTree = CargoHierarchyTree::newFromWikiText( $fieldDescription->mHierarchyStructure );
                $hierarchyStructureTableData = $hierarchyTree->generateHierarchyStructureTableData();
                foreach ( $hierarchyStructureTableData as $entry ) {
                    $cdb->insert( $fieldHelperTableName, $entry );
                }
            }
        }
        // And create a helper table holding all the files stored in
        // this table, if there are any.
        if ( $containsFileType ) {
            $fileTableName = $tableName . '___files';
            $cdb->dropTable( $fileTableName );
            $fieldsInTable = [
                '_pageName' => 'String',
                '_pageID' => 'Integer',
                '_fieldName' => 'String',
                '_fileName' => 'String'
            ];
            self::createTable( $cdb, $fileTableName, $fieldsInTable );
        }
        // End transaction and apply DB changes.
        $cdb->commit();
        // Finally, store all the info in the cargo_tables table.
        $dbw->insert( 'cargo_tables', [
            'template_id' => $templatePageID,
            'main_table' => $tableName,
            'field_tables' => serialize( $fieldTableNames ),
            'field_helper_tables' => serialize( $fieldHelperTableNames ),
            'table_schema' => $tableSchemaString
        ] );
    }
    public static function createTable( $cdb, $tableName, $fieldsInTable, $multipleColumnIndex = false ) {
        global $wgCargoDBRowFormat;
        // Unfortunately, there is not yet a 'CREATE TABLE' wrapper
        // in the MediaWiki DB API, so we have to call SQL directly.
        $dbType = $cdb->getType();
        $sqlTableName = $cdb->tableName( $tableName );
        $createSQL = "CREATE TABLE $sqlTableName ( ";
        $firstField = true;
        foreach ( $fieldsInTable as $fieldName => $fieldDescOrType ) {
            $fieldOptionsText = '';
            if ( is_object( $fieldDescOrType ) ) {
                $fieldType = $fieldDescOrType->mType;
                $fieldSize = $fieldDescOrType->mSize;
                $sqlType = self::fieldTypeToSQLType( $fieldType, $dbType, $fieldSize );
                if ( $fieldDescOrType->mIsMandatory ) {
                    $fieldOptionsText .= ' NOT NULL';
                }
                if ( $fieldDescOrType->mIsUnique ) {
                    $fieldOptionsText .= ' UNIQUE';
                }
            } else {
                $fieldType = $fieldDescOrType;
                $sqlType = self::fieldTypeToSQLType( $fieldType, $dbType );
                if ( $fieldName == '_ID' ) {
                    $fieldOptionsText .= ' PRIMARY KEY';
                } elseif ( $fieldName == '_rowID' ) {
                    $fieldOptionsText .= ' NOT NULL';
                }
            }
            if ( $firstField ) {
                $firstField = false;
            } else {
                $createSQL .= ', ';
            }
            $sqlFieldName = $cdb->addIdentifierQuotes( $fieldName );
            $createSQL .= "$sqlFieldName $sqlType $fieldOptionsText";
            if ( $fieldType == 'Searchtext' ) {
                $createSQL .= ", FULLTEXT KEY $fieldName ( $sqlFieldName )";
            }
        }
        $createSQL .= ' )';
        // Allow for setting a format like COMPRESSED, DYNAMIC etc.
        if ( $wgCargoDBRowFormat != null ) {
            $createSQL .= " ROW_FORMAT=$wgCargoDBRowFormat";
        }
        $cdb->query( $createSQL );
        // Add an index for any field that's not of type Text,
        // Searchtext or Wikitext.
        $indexedFields = [];
        foreach ( $fieldsInTable as $fieldName => $fieldDescOrType ) {
            // We don't need to index _ID, because it's already
            // the primary key.
            if ( $fieldName == '_ID' ) {
                continue;
            }
            // @HACK - MySQL does not allow more than 64 keys/
            // indexes per table. We are indexing most fields -
            // so if a table has more than 64 fields, there's a
            // good chance that it will overrun this limit.
            // So we just stop indexing after the first 60.
            if ( count( $indexedFields ) >= 60 ) {
                break;
            }
            if ( is_object( $fieldDescOrType ) ) {
                $fieldType = $fieldDescOrType->mType;
            } else {
                $fieldType = $fieldDescOrType;
            }
            if ( in_array( $fieldType, [ 'Text', 'Searchtext', 'Wikitext' ] ) ) {
                continue;
            }
            $indexedFields[] = $fieldName;
        }
        if ( $multipleColumnIndex ) {
            $indexName = "nested_set_$tableName";
            $sqlFieldNames = array_map(
                [ $cdb, 'addIdentifierQuotes' ],
                $indexedFields
            );
            $sqlFieldNamesStr = implode( ', ', $sqlFieldNames );
            $createIndexSQL = "CREATE INDEX $indexName ON " .
                "$sqlTableName ($sqlFieldNamesStr)";
            $cdb->query( $createIndexSQL );
        } else {
            foreach ( $indexedFields as $fieldName ) {
                $indexName = $fieldName . '_' . $tableName;
                // MySQL doesn't allow index names with more than 64 characters.
                $indexName = substr( $indexName, 0, 64 );
                $sqlFieldName = $cdb->addIdentifierQuotes( $fieldName );
                $sqlIndexName = $cdb->addIdentifierQuotes( $indexName );
                $createIndexSQL = "CREATE INDEX $sqlIndexName ON " .
                    "$sqlTableName ($sqlFieldName)";
                $cdb->query( $createIndexSQL );
            }
        }
    }
    public static function specialTableNames() {
        $specialTableNames = [ '_pageData', '_fileData' ];
        if ( class_exists( 'FDGanttContent' ) ) {
            // The Flex Diagrams extension is installed.
            $specialTableNames[] = '_bpmnData';
            $specialTableNames[] = '_ganttData';
        }
        return $specialTableNames;
    }
    public static function fullTextMatchSQL( $cdb, $tableName, $fieldName, $searchTerm ) {
        $fullFieldName = self::escapedFieldName( $cdb, $tableName, $fieldName );
        $searchTerm = $cdb->addQuotes( $searchTerm );
        return " MATCH($fullFieldName) AGAINST ($searchTerm IN BOOLEAN MODE) ";
    }
    /**
     * Parses one half of a set of coordinates into a number.
     *
     * Copied from Miga, also written by Yaron Koren
     * (https://github.com/yaronkoren/miga/blob/master/MDVCoordinates.js)
     * - though that one is in Javascript.
     */
    public static function coordinatePartToNumber( $coordinateStr ) {
        $degreesSymbols = [ "\x{00B0}", "d" ];
        $minutesSymbols = [ "'", "\x{2032}", "\x{00B4}" ];
        $secondsSymbols = [ '"', "\x{2033}", "\x{00B4}\x{00B4}" ];
        $numDegrees = null;
        $numMinutes = null;
        $numSeconds = null;
        foreach ( $degreesSymbols as $degreesSymbol ) {
            $pattern = '/([\d\.]+)' . $degreesSymbol . '/u';
            if ( preg_match( $pattern, $coordinateStr, $matches ) ) {
                $numDegrees = floatval( $matches[1] );
                break;
            }
        }
        if ( $numDegrees == null ) {
            throw new MWException( "Error: could not parse degrees in \"$coordinateStr\"." );
        }
        foreach ( $minutesSymbols as $minutesSymbol ) {
            $pattern = '/([\d\.]+)' . $minutesSymbol . '/u';
            if ( preg_match( $pattern, $coordinateStr, $matches ) ) {
                $numMinutes = floatval( $matches[1] );
                break;
            }
        }
        if ( $numMinutes == null ) {
            // This might not be an error - the number of minutes
            // might just not have been set.
            $numMinutes = 0;
        }
        foreach ( $secondsSymbols as $secondsSymbol ) {
            $pattern = '/(\d+)' . $secondsSymbol . '/u';
            if ( preg_match( $pattern, $coordinateStr, $matches ) ) {
                $numSeconds = floatval( $matches[1] );
                break;
            }
        }
        if ( $numSeconds == null ) {
            // This might not be an error - the number of seconds
            // might just not have been set.
            $numSeconds = 0;
        }
        return ( $numDegrees + ( $numMinutes / 60 ) + ( $numSeconds / 3600 ) );
    }
    /**
     * Parses a coordinate string in (hopefully) any standard format.
     *
     * Copied from Miga, also written by Yaron Koren
     * (https://github.com/yaronkoren/miga/blob/master/MDVCoordinates.js)
     * - though that one is in Javascript.
     */
    public static function parseCoordinatesString( $coordinatesString ) {
        $coordinatesString = trim( $coordinatesString );
        if ( $coordinatesString == null ) {
            return;
        }
        // This is safe to do, right?
        $coordinatesString = str_replace( [ '[', ']' ], '', $coordinatesString );
        // See if they're separated by commas.
        if ( strpos( $coordinatesString, ',' ) > 0 ) {
            $latAndLonStrings = explode( ',', $coordinatesString );
        } else {
            // If there are no commas, the first half, for the
            // latitude, should end with either 'N' or 'S', so do a
            // little hack to split up the two halves.
            $coordinatesString = str_replace( [ 'N', 'S' ], [ 'N,', 'S,' ], $coordinatesString );
            $latAndLonStrings = explode( ',', $coordinatesString );
        }
        if ( count( $latAndLonStrings ) != 2 ) {
            throw new MWException( "Error parsing coordinates string: \"$coordinatesString\"." );
        }
        list( $latString, $lonString ) = $latAndLonStrings;
        // Handle strings one at a time.
        $latIsNegative = false;
        if ( strpos( $latString, 'S' ) > 0 ) {
            $latIsNegative = true;
        }
        $latString = str_replace( [ 'N', 'S' ], '', $latString );
        if ( is_numeric( $latString ) ) {
            $latNum = floatval( $latString );
        } else {
            $latNum = self::coordinatePartToNumber( $latString );
        }
        if ( $latIsNegative ) {
            $latNum *= -1;
        }
        $lonIsNegative = false;
        if ( strpos( $lonString, 'W' ) > 0 ) {
            $lonIsNegative = true;
        }
        $lonString = str_replace( [ 'E', 'W' ], '', $lonString );
        if ( is_numeric( $lonString ) ) {
            $lonNum = floatval( $lonString );
        } else {
            $lonNum = self::coordinatePartToNumber( $lonString );
        }
        if ( $lonIsNegative ) {
            $lonNum *= -1;
        }
        return [ $latNum, $lonNum ];
    }
    public static function escapedFieldName( $cdb, $tableName, $fieldName ) {
        if ( is_array( $tableName ) ) {
            $tableAlias = key( $tableName );
            return $cdb->addIdentifierQuotes( $tableAlias ) . '.' .
                $cdb->addIdentifierQuotes( $fieldName );
        }
        return $cdb->tableName( $tableName ) . '.' .
            $cdb->addIdentifierQuotes( $fieldName );
    }
    public static function joinOfMainAndFieldTable( $cdb, $mainTableName, $fieldTableName ) {
        return [
            'LEFT OUTER JOIN',
            self::escapedFieldName( $cdb, $mainTableName, '_ID' ) .
                ' = ' .
                self::escapedFieldName( $cdb, $fieldTableName, '_rowID' )
        ];
    }
    public static function joinOfMainAndParentTable( $cdb, $mainTable, $mainTableField,
            $parentTable, $parentTableField ) {
        return [
            'LEFT OUTER JOIN',
            self::escapedFieldName( $cdb, $mainTable, $mainTableField ) .
            ' = ' .
            self::escapedFieldName( $cdb, $parentTable, $parentTableField )
        ];
    }
    public static function joinOfFieldAndMainTable( $cdb, $fieldTable, $mainTable,
            $isHierarchy = false, $hierarchyFieldName = null ) {
        if ( $isHierarchy ) {
            return [
                'LEFT OUTER JOIN',
                self::escapedFieldName( $cdb, $fieldTable, '_value' ) . ' = ' .
                self::escapedFieldName( $cdb, $mainTable, $hierarchyFieldName ),
            ];
        } else {
            return [
                'LEFT OUTER JOIN',
                self::escapedFieldName( $cdb, $fieldTable, '_rowID' ) . ' = ' .
                self::escapedFieldName( $cdb, $mainTable, '_ID' ),
            ];
        }
    }
    public static function joinOfSingleFieldAndHierarchyTable( $cdb, $singleFieldTableName, $fieldColumnName, $hierarchyTableName ) {
        return [
            'LEFT OUTER JOIN',
            self::escapedFieldName( $cdb, $singleFieldTableName, $fieldColumnName ) .
                ' = ' .
                self::escapedFieldName( $cdb, $hierarchyTableName, '_value' )
        ];
    }
    public static function escapedInsert( $db, $tableName, $fieldValues ) {
        // Put quotes around the field names - needed for Postgres,
        // which otherwise lowercases all field names.
        $quotedFieldValues = [];
        foreach ( $fieldValues as $fieldName => $fieldValue ) {
            $quotedFieldName = $db->addIdentifierQuotes( $fieldName );
            $quotedFieldValues[$quotedFieldName] = $fieldValue;
        }
        $db->insert( $tableName, $quotedFieldValues );
    }
    /**
     * @param LinkRenderer $linkRenderer
     * @param LinkTarget|Title $title
     * @param string|null $msg Must already be HTML escaped
     * @param array $attrs link attributes
     * @param array $params query parameters
     *
     * @return string HTML link
     */
    public static function makeLink( $linkRenderer, $title, $msg = null, $attrs = [], $params = [] ) {
        global $wgTitle;
        if ( $title === null ) {
            return null;
        } elseif ( $wgTitle !== null && $title->equals( $wgTitle ) ) {
            // Display bolded text instead of a link.
            return Linker::makeSelfLinkObj( $title, $msg );
        } else {
            $html = ( $msg == null ) ? null : new HtmlArmor( $msg );
            return $linkRenderer->makeLink( $title, $html, $attrs, $params );
        }
    }
    public static function getSpecialPage( $pageName ) {
        return MediaWikiServices::getInstance()->getSpecialPageFactory()
            ->getPage( $pageName );
    }
    /**
     * Get the wiki's content language.
     * @since 2.6
     * @return Language
     */
    public static function getContentLang() {
        return MediaWikiServices::getInstance()->getContentLanguage();
    }
    public static function logTableAction( $actionName, $tableName, User $user ) {
        $log = new LogPage( 'cargo', false );
        $ctPage = self::getSpecialPage( 'CargoTables' );
        $ctTitle = $ctPage->getPageTitle();
        if ( $actionName == 'deletetable' ) {
            $logParams = [ $tableName ];
        } else {
            $ctURL = $ctTitle->getFullURL();
            $tableURL = "$ctURL/$tableName";
            $tableLink = Html::element(
                'a',
                [ 'href' => $tableURL ],
                $tableName
            );
            $logParams = [ $tableLink ];
        }
        // Every log entry requires an associated title; Cargo table
        // actions don't involve an actual page, so we just use
        // Special:CargoTables as the title.
        $log->addEntry( $actionName, $ctTitle, '', $logParams, $user );
    }
    public static function validateHierarchyStructure( $hierarchyStructure ) {
        $hierarchyNodesArray = explode( "\n", $hierarchyStructure );
        $matches = [];
        preg_match( '/^([*]*)[^*]*/i', $hierarchyNodesArray[0], $matches );
        if ( strlen( $matches[1] ) != 1 ) {
            throw new MWException( "Error: First entry of hierarchy values should start with exact one '*', the entry \"" .
                $hierarchyNodesArray[0] . "\" has " . strlen( $matches[1] ) . " '*'" );
        }
        $level = 0;
        foreach ( $hierarchyNodesArray as $node ) {
            if ( !preg_match( '/^([*]*)( *)(.*)/i', $node, $matches ) ) {
                throw new MWException( "Error: The \"" . $node . "\" entry of hierarchy values does not follow syntax. " .
                    "The entry should be of the form : * entry" );
            }
            if ( strlen( $matches[1] ) < 1 ) {
                throw new MWException( "Error: Each entry of hierarchy values should start with atleast one '*', the entry \"" .
                    $node . "\" has " . strlen( $matches[1] ) . " '*'" );
            }
            if ( strlen( $matches[1] ) - $level > 1 ) {
                throw new MWException( "Error: Level or count of '*' in hierarchy values should be increased only by count of 1, the entry \"" .
                    $node . "\" should have " . ( $level + 1 ) . " or less '*'" );
            }
            $level = strlen( $matches[1] );
            if ( strlen( $matches[3] ) == 0 ) {
                throw new MWException( "Error: The entry of hierarchy values cannot be empty." );
            }
        }
    }
    public static function validateFieldDescriptionString( $fieldDescriptionStr ) {
        $hasParameterFormat = preg_match( '/^([^(]*)\s*\((.*)\)$/s', $fieldDescriptionStr, $matches );
        if ( !$hasParameterFormat ) {
            if ( self::stringContainsParentheses( $fieldDescriptionStr ) ) {
                throw new MWException( 'Invalid field description - Parentheses do not match: "' . $fieldDescriptionStr . '"' );
            }
        } else {
            if ( count( $matches ) == 3 ) {
                $extraParamsString = $matches[2];
                $extraParams = explode( ';', $extraParamsString );
                foreach ( $extraParams as $extraParam ) {
                    $extraParamParts = explode( '=', $extraParam, 2 );
                    $paramKey = strtolower( trim( $extraParamParts[0] ) );
                    $paramValue = isset( $extraParamParts[1] ) ? trim( $extraParamParts[1] ) : '';
                    if ( self::stringContainsParentheses( $paramKey ) ) {
                        throw new MWException( 'Invalid field description - Parameter name "' . $paramKey . '" must not include parentheses: "' . $fieldDescriptionStr . '"' );
                    }
                    if ( $paramKey == 'allowed values' ) {
                        continue;
                    }
                    if ( self::stringContainsParentheses( $paramValue ) ) {
                        throw new MWException( 'Invalid field description - Parameter value "' . $paramValue . '" must not include parentheses: "' . $fieldDescriptionStr . '"' );
                    }
                }
            }
        }
    }
    /**
     * A helper function, because Title::getTemplateLinksTo() is broken in
     * MW 1.37.
     */
    public static function getTemplateLinksTo( $templateTitle, $options = [] ) {
        $db = wfGetDB( DB_REPLICA );
        $selectFields = LinkCache::getSelectFields();
        $selectFields[] = 'page_namespace';
        $selectFields[] = 'page_title';
        $res = $db->select(
            [ 'page', 'templatelinks' ],
            $selectFields,
            [
                "tl_from=page_id",
                "tl_namespace" => NS_TEMPLATE,
                "tl_title" => $templateTitle->mDbkeyform
            ],
            __METHOD__,
            $options
        );
        $retVal = [];
        if ( $res->numRows() ) {
            $linkCache = MediaWikiServices::getInstance()->getLinkCache();
            foreach ( $res as $row ) {
                $titleObj = Title::makeTitle( $row->page_namespace, $row->page_title );
                if ( $titleObj ) {
                    $linkCache->addGoodLinkObjFromRow( $titleObj, $row );
                    $retVal[] = $titleObj;
                }
            }
        }
        return $retVal;
    }
    public static function setParserOutputPageProperty( $parserOutput, $property, $value ) {
        if ( method_exists( $parserOutput, 'setPageProperty' ) ) {
            // MW 1.38
            $parserOutput->setPageProperty( $property, $value );
        } else {
            $parserOutput->setProperty( $property, $value );
        }
    }
    /**
     * Replace file redirects with the appropriate targets
     * @param array $valuesArray
     * @param array $fieldDescriptions
     * @return array $valuesArray
     */
    public static function replaceRedirectWithTarget( $valuesArray, $fieldDescriptions ) {
        foreach ( $valuesArray as &$result ) {
            foreach ( $result as $key => &$val ) {
                if ( array_key_exists( $key, $fieldDescriptions ) &&
                $fieldDescriptions[ $key ]->mType == "File" ) {
                    $title = Title::newFromText( $val );
                    if ( $title != null && $title->isRedirect() ) {
                        $page = self::makeWikiPage( $title );
                        $target = $page->getRedirectTarget();
                        if ( $target != null ) {
                            $val = $target->getText();
                        }
                    }
                }
            }
        }
        return $valuesArray;
    }
    public static function globalFields() {
        return [
            '_pageID' => [ 'type' => 'Integer', 'isList' => false ],
            '_pageName' => [ 'type' => 'Page', 'isList' => false ],
            '_pageTitle' => [ 'type' => 'String', 'isList' => false ],
            '_pageNamespace' => [ 'type' => 'Integer', 'isList' => false ],
        ];
    }
    public static function addGlobalFieldsToSchema( $schema ) {
        foreach ( self::globalFields() as $field => $fieldInfo ) {
            $fieldDesc = new CargoFieldDescription();
            $fieldDesc->mType = $fieldInfo["type"];
            $fieldDesc->mIsList = $fieldInfo["isList"];
            if ( $fieldInfo["isList"] ) {
                $fieldDesc->setDelimiter( '|' );
            }
            $schema->addField( $field, $fieldDesc );
        }
    }
    public static function stringContainsParentheses( $str ) {
        return substr_count( $str, ')' ) > 0 || substr_count( $str, '(' ) > 0;
    }
    public static function makeWikiPage( $title ) {
        if ( method_exists( MediaWikiServices::class, 'getWikiPageFactory' ) ) {
            // MW 1.36+
            return MediaWikiServices::getInstance()->getWikiPageFactory()->newFromTitle( $title );
        } else {
            return WikiPage::factory( $title );
        }
    }
}