diff --git a/extension.json b/extension.json index ca6636ed..e1c47d53 100644 --- a/extension.json +++ b/extension.json @@ -170,6 +170,8 @@ "CargoLinksUpdateHandler": "includes/hooks/CargoLinksUpdateHandler.php", "CargoSearchMySQL": "includes/search/CargoSearchMySQL.php", "CargoPageSchemas": "includes/CargoPageSchemas.php", + "CargoConnectionProvider": "includes/CargoConnectionProvider.php", + "CargoServices": "includes/CargoServices.php", "CargoAppliedFilter": "drilldown/CargoAppliedFilter.php", "CargoFilter": "drilldown/CargoFilter.php", "CargoFilterValue": "drilldown/CargoFilterValue.php", @@ -472,6 +474,7 @@ "class": "CargoLinksUpdateHandler" } }, + "ServiceWiringFiles": ["includes/ServiceWiring.php"], "config": { "CargoDecimalMark": ".", "CargoDigitGroupingCharacter": ",", @@ -483,6 +486,7 @@ "CargoDBpassword": null, "CargoDBprefix": null, "CargoDBRowFormat": null, + "CargoDBCluster": null, "CargoDBIndex": null, "CargoDefaultStringBytes": 300, "CargoDefaultQueryLimit": 100, diff --git a/includes/CargoConnectionProvider.php b/includes/CargoConnectionProvider.php new file mode 100644 index 00000000..41b78477 --- /dev/null +++ b/includes/CargoConnectionProvider.php @@ -0,0 +1,187 @@ +assertRequiredOptions( self::CONSTRUCTOR_OPTIONS ); + + $this->lbFactory = $lbFactory; + $this->databaseFactory = $databaseFactory; + $this->serviceOptions = $serviceOptions; + } + + /** + * Get a database connection for accessing Cargo data. + * @param int $dbType DB type to use (primary or replica) + * @return IDatabase + */ + public function getConnection( int $dbType ): IDatabase { + $cluster = $this->serviceOptions->get( 'CargoDBCluster' ); + + // If a cluster is specified, let MediaWiki's DBAL manage the lifecycle of Cargo-related connections. + if ( $cluster !== null ) { + $lb = $this->lbFactory->getExternalLB( $cluster ); + + // Fall back to the primary DB if there were recent writes, to ensure that Cargo sees its own changes. + $dbType = $lb->hasOrMadeRecentPrimaryChanges() ? ILoadBalancer::DB_PRIMARY : $dbType; + $conn = $lb->getConnection( $dbType ); + + // Fandom change: Ensure Cargo DB connections use 4-byte UTF-8 client character set (UGC-4625). + self::setClientCharacterSet( $conn ); + return $conn; + } + + if ( $this->connection === null ) { + $this->connection = $this->initConnection(); + + // Fandom change: Ensure Cargo DB connections use 4-byte UTF-8 client character set (UGC-4625). + self::setClientCharacterSet( $this->connection ); + } + + return $this->connection; + } + + /** + * Get the DB type (e.g. 'postgres') of the Cargo database. + * This is mainly useful for code that needs to generate platform-specific SQL. + * @return string + */ + public function getDBType(): string { + return $this->serviceOptions->get( 'CargoDBtype' ) ?? $this->getConnection( DB_REPLICA )->getType(); + } + + /** + * Create a database connection for Cargo data managed entirely by this class. + * @return IDatabase + */ + private function initConnection(): IDatabase { + $lb = $this->lbFactory->getMainLB(); + // Fandom change: Use the DB index specified in the CargoDBIndex option (PLATFORM-7466). + $index = $this->serviceOptions->get( 'CargoDBIndex' ) ?? $lb::DB_PRIMARY; + $dbr = $lb->getConnection( $index ); + + $dbServers = $this->serviceOptions->get( 'DBservers' ); + $dbUser = $this->serviceOptions->get( 'DBuser' ); + $dbPassword = $this->serviceOptions->get( 'DBpassword' ); + + $dbServer = $this->serviceOptions->get( 'CargoDBserver' ) ?? $dbr->getServer(); + $dbName = $this->serviceOptions->get( 'CargoDBname' ) ?? $dbr->getDBname(); + $dbType = $this->serviceOptions->get( 'CargoDBtype' ) ?? $dbr->getType(); + + // 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. + $dbUsername = $this->serviceOptions->get( 'CargoDBuser' ) ?? $dbServers[0]['user'] ?? $dbUser; + $dbPassword = $this->serviceOptions->get( 'CargoDBpassword' ) ?? $dbServers[0]['password'] ?? $dbPassword; + $dbTablePrefix = $this->serviceOptions->get( 'CargoDBprefix' ) + ?? $this->serviceOptions->get( 'DBprefix' ) . 'cargo__'; + + $params = [ + 'host' => $dbServer, + 'user' => $dbUsername, + 'password' => $dbPassword, + 'dbname' => $dbName, + 'tablePrefix' => $dbTablePrefix, + ]; + + if ( $dbType === 'sqlite' ) { + /** @var \Wikimedia\Rdbms\DatabaseSqlite $dbr */ + $params['dbFilePath'] = $dbr->getDbFilePath(); + } elseif ( $dbType === 'postgres' ) { + // @TODO - a $wgCargoDBport variable is still needed. + $params['port'] = $this->serviceOptions->get( 'DBport' ); + } + + if ( $this->databaseFactory !== null ) { + return $this->databaseFactory->create( $dbType, $params ); + } + + return Database::factory( $dbType, $params ); + } + + /** + * Set the client character set of a database connection handle to 4-byte UTF-8. + * This is necessary because Cargo utilizes functions such as REGEXP_LIKE(), + * which fail if the client character set is "binary". + * + * @param IDatabase $dbw Database connection handle. + */ + private static function setClientCharacterSet( IDatabase $dbw ): void { + if ( $dbw instanceof DatabaseMysqli ) { + // Force open the database connection so that we can obtain the underlying native connection handle. + $dbw->ping(); + + $ref = new ReflectionMethod( $dbw, 'getBindingHandle' ); + $ref->setAccessible( true ); + + /** @var mysqli $mysqli */ + $mysqli = $ref->invoke( $dbw ); + if ( $mysqli->character_set_name() !== 'utf8mb4' ) { + $mysqli->set_charset( 'utf8mb4' ); + } + } + } + +} diff --git a/includes/CargoRecreateDataAction.php b/includes/CargoRecreateDataAction.php index b5454112..28e2e3f2 100644 --- a/includes/CargoRecreateDataAction.php +++ b/includes/CargoRecreateDataAction.php @@ -1,6 +1,7 @@ getUser(); $permissionManager = MediaWikiServices::getInstance()->getPermissionManager(); - if ( !$permissionManager->userCan( 'recreatecargodata', $user, $title ) ) { + if ( !$permissionManager->userCan( 'recreatecargodata', $user, $title, PermissionManager::RIGOR_QUICK ) ) { return true; } diff --git a/includes/CargoSQLQuery.php b/includes/CargoSQLQuery.php index 93be9208..f9738915 100644 --- a/includes/CargoSQLQuery.php +++ b/includes/CargoSQLQuery.php @@ -36,7 +36,7 @@ class CargoSQLQuery { public $mDateFieldPairs = []; public function __construct() { - $this->mCargoDB = CargoUtils::getDB(); + $this->mCargoDB = CargoServices::getCargoConnectionProvider()->getConnection( DB_REPLICA ); } /** @@ -56,7 +56,6 @@ public static function newFromValues( $tablesStr, $fieldsStr, $whereStr, $joinOn $havingStr, $orderByStr, $limitStr, $offsetStr, $allowFieldEscaping ); $sqlQuery = new CargoSQLQuery(); - $sqlQuery->mCargoDB = CargoUtils::getDB(); $sqlQuery->mTablesStr = $tablesStr; $sqlQuery->setAliasedTableNames(); $sqlQuery->mFieldsStr = $fieldsStr; diff --git a/includes/CargoServices.php b/includes/CargoServices.php new file mode 100644 index 00000000..c8a11e1f --- /dev/null +++ b/includes/CargoServices.php @@ -0,0 +1,12 @@ +getService( 'CargoConnectionProvider' ); + } +} diff --git a/includes/CargoUtils.php b/includes/CargoUtils.php index 22968647..d8a6918a 100644 --- a/includes/CargoUtils.php +++ b/includes/CargoUtils.php @@ -9,117 +9,17 @@ use MediaWiki\Linker\LinkRenderer; use MediaWiki\Linker\LinkTarget; use MediaWiki\MediaWikiServices; -use Wikimedia\Rdbms\DatabaseMysqli; -use Wikimedia\Rdbms\IDatabase; class CargoUtils { - private static $CargoDB = null; - /** - * @return Database or DatabaseBase + * Get the Cargo database connection. + * @deprecated Use {@link CargoConnectionProvider::getConnection()} directly instead. + * @param int $dbType + * @return \Wikimedia\Rdbms\IDatabase */ - 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, $wgCargoDBIndex; - - $services = MediaWikiServices::getInstance(); - $lb = $services->getDBLoadBalancer(); - $dbIndex = $wgCargoDBIndex !== null ?: DB_PRIMARY; - $dbr = $lb->getConnectionRef( $dbIndex ); - - $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 $dbw 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, - // MySQL >= 8.0.22 rejects using binary strings in regular expression functions - // such as REGEXP_LIKE(), heavily used across Cargo, so force UTF-8 client charset here. - 'utf8Mode' => true, - ]; - - if ( $type === 'sqlite' ) { - $params['dbFilePath'] = $dbr->getDbFilePath(); - } elseif ( $type === 'postgres' ) { - global $wgDBport; - // @TODO - a $wgCargoDBport variable is still needed. - $params['port'] = $wgDBport; - } - - if ( method_exists( $services, 'getDatabaseFactory' ) ) { - // MW 1.39+ - self::$CargoDB = $services->getDatabaseFactory()->create( $wgCargoDBtype, $params ); - } else { - self::$CargoDB = Database::factory( $wgCargoDBtype, $params ); - } - - // Fandom change: Ensure Cargo DB connections use 4-byte UTF-8 client character set (UGC-4625). - self::setClientCharacterSet( self::$CargoDB ); - - return self::$CargoDB; - } - - /** - * Set the client character set of a database connection handle to 4-byte UTF-8. - * This is necessary because Cargo utilizes functions such as REGEXP_LIKE(), - * which fail if the client character set is "binary". - * - * @param IDatabase $dbw Database connection handle. - */ - private static function setClientCharacterSet( IDatabase $dbw ): void { - if ( $dbw instanceof DatabaseMysqli ) { - // Force open the database connection so that we can obtain the underlying native connection handle. - $dbw->ping(); - - $ref = new ReflectionMethod( $dbw, 'getBindingHandle' ); - $ref->setAccessible( true ); - - /** @var mysqli $mysqli */ - $mysqli = $ref->invoke( $dbw ); - $mysqli->set_charset( 'utf8mb4' ); - } + public static function getDB( int $dbType = DB_PRIMARY ) { + return CargoServices::getCargoConnectionProvider()->getConnection( $dbType ); } /** @@ -500,13 +400,11 @@ public static function isSQLStringLiteral( $string ) { } 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' ) { + if ( CargoServices::getCargoConnectionProvider()->getDBType() == 'postgres' ) { $yearValue = "EXTRACT(YEAR FROM $dateDBField)"; $monthValue = "EXTRACT(MONTH FROM $dateDBField)"; $dayValue = "EXTRACT(DAY FROM $dateDBField)"; @@ -722,8 +620,7 @@ public static function tableFullyExists( $tableName ) { return false; } - $cdb = self::getDB(); - return $cdb->tableExists( $tableName ); + return CargoServices::getCargoConnectionProvider()->getConnection( DB_REPLICA )->tableExists( $tableName ); } public static function fieldTypeToSQLType( $fieldType, $dbType, $size = null ) { diff --git a/includes/ServiceWiring.php b/includes/ServiceWiring.php new file mode 100644 index 00000000..b9948933 --- /dev/null +++ b/includes/ServiceWiring.php @@ -0,0 +1,16 @@ + static function ( MediaWikiServices $services ): CargoConnectionProvider { + // DatabaseFactory only exists on MW 1.39 and newer. + $databaseFactory = $services->hasService( 'DatabaseFactory' ) ? $services->getDatabaseFactory() : null; + return new CargoConnectionProvider( + $services->getDBLoadBalancerFactory(), + $databaseFactory, + new ServiceOptions( CargoConnectionProvider::CONSTRUCTOR_OPTIONS, $services->getMainConfig() ) + ); + } +]; diff --git a/includes/api/CargoRecreateTablesAPI.php b/includes/api/CargoRecreateTablesAPI.php index 18563337..d2f3917a 100644 --- a/includes/api/CargoRecreateTablesAPI.php +++ b/includes/api/CargoRecreateTablesAPI.php @@ -64,7 +64,7 @@ protected function getDescription() { protected function getExamples() { return [ - 'api.php?action=cargorecreatetables&template=City' + 'api.php?action=cargorecreatetables&template=City', ]; } @@ -76,4 +76,7 @@ public function needsToken() { return 'csrf'; } + public function isWriteMode() { + return true; + } } diff --git a/includes/parserfunctions/CargoAttach.php b/includes/parserfunctions/CargoAttach.php index 2653d51e..5e266980 100644 --- a/includes/parserfunctions/CargoAttach.php +++ b/includes/parserfunctions/CargoAttach.php @@ -1,4 +1,7 @@ parse() ); } - $dbw = wfGetDB( DB_MASTER ); - $res = $dbw->select( 'cargo_tables', 'COUNT(*) AS total', [ 'main_table' => $tableName ] ); + $res = MediaWikiServices::getInstance() + ->getDBLoadBalancer() + ->getConnection( DB_REPLICA ) + ->select( 'cargo_tables', 'COUNT(*) AS total', [ 'main_table' => $tableName ] ); $row = $res->fetchRow(); if ( !empty( $row ) && $row['total'] == 0 ) { return CargoUtils::formatError( "Error: The specified table, \"$tableName\", does not exist." ); diff --git a/includes/parserfunctions/CargoDeclare.php b/includes/parserfunctions/CargoDeclare.php index 9482ed19..05216bd7 100644 --- a/includes/parserfunctions/CargoDeclare.php +++ b/includes/parserfunctions/CargoDeclare.php @@ -308,8 +308,7 @@ public static function run( Parser $parser ) { } // Validate table name. - - $cdb = CargoUtils::getDB(); + $cdb = CargoServices::getCargoConnectionProvider()->getConnection( DB_REPLICA ); foreach ( $parentTables as $extraParams ) { $parentTableName = $extraParams['Name']; @@ -359,7 +358,6 @@ public static function run( Parser $parser ) { // exists already - otherwise, explain that it needs to be // created. $text = wfMessage( 'cargo-definestable', $tableName )->text(); - $cdb = CargoUtils::getDB(); if ( $cdb->tableExists( $tableName ) ) { $ct = SpecialPage::getTitleFor( 'CargoTables' ); $pageName = $ct->getPrefixedText() . "/$tableName"; diff --git a/includes/specials/CargoPageValues.php b/includes/specials/CargoPageValues.php index 7490ead9..d28521ae 100644 --- a/includes/specials/CargoPageValues.php +++ b/includes/specials/CargoPageValues.php @@ -162,8 +162,6 @@ private function getInfoForAllFields( $tableName ) { } public function getRowsForPageInTable( $tableName ) { - $cdb = CargoUtils::getDB(); - $sqlQuery = new CargoSQLQuery(); $sqlQuery->mAliasedTableNames = [ $tableName => $tableName ]; @@ -197,7 +195,8 @@ public function getRowsForPageInTable( $tableName ) { $sqlQuery->mOrigAliasedFieldNames = $aliasedFieldNames; $sqlQuery->setDescriptionsAndTableNamesForFields(); $sqlQuery->handleDateFields(); - $sqlQuery->mWhereStr = $cdb->addIdentifierQuotes( '_pageID' ) . " = " . + $sqlQuery->mWhereStr = CargoServices::getCargoConnectionProvider()->getConnection( DB_REPLICA ) + ->addIdentifierQuotes( '_pageID' ) . " = " . $this->mTitle->getArticleID(); $queryResults = $sqlQuery->run(); diff --git a/includes/specials/SpecialCargoRecreateData.php b/includes/specials/SpecialCargoRecreateData.php index 602d7845..9a643e73 100644 --- a/includes/specials/SpecialCargoRecreateData.php +++ b/includes/specials/SpecialCargoRecreateData.php @@ -64,7 +64,7 @@ public function execute( $query = null ) { $out->addModules( 'ext.cargo.recreatedata' ); $templateData = []; - $dbw = wfGetDB( DB_MASTER ); + $dbr = MediaWikiServices::getInstance()->getDBLoadBalancer()->getConnection( DB_REPLICA ); if ( $this->mTemplateTitle === null ) { if ( $this->mTableName == '_pageData' ) { $conds = null; @@ -75,18 +75,18 @@ public function execute( $query = null ) { } else { // if ( $this->mTableName == '_ganttData' ) { $conds = 'page_namespace = ' . FD_NS_GANTT; } - $numTotalPages = $dbw->selectField( 'page', 'COUNT(*)', $conds ); + $numTotalPages = $dbr->selectField( 'page', 'COUNT(*)', $conds ); } else { $numTotalPages = null; $templateData[] = [ 'name' => $this->mTemplateTitle->getText(), - 'numPages' => $this->getNumPagesThatCallTemplate( $dbw, $this->mTemplateTitle ) + 'numPages' => $this->getNumPagesThatCallTemplate( $dbr, $this->mTemplateTitle ) ]; } if ( $this->mIsDeclared ) { // Get all attached templates. - $res = $dbw->select( 'page_props', + $res = $dbr->select( 'page_props', [ 'pp_page' ], @@ -98,7 +98,7 @@ public function execute( $query = null ) { foreach ( $res as $row ) { $templateID = $row->pp_page; $attachedTemplateTitle = Title::newFromID( $templateID ); - $numPages = $this->getNumPagesThatCallTemplate( $dbw, $attachedTemplateTitle ); + $numPages = $this->getNumPagesThatCallTemplate( $dbr, $attachedTemplateTitle ); $attachedTemplateName = $attachedTemplateTitle->getText(); $templateData[] = [ 'name' => $attachedTemplateName, @@ -156,7 +156,7 @@ public function execute( $query = null ) { return true; } - public function getNumPagesThatCallTemplate( IDatabase $dbw, LinkTarget $templateTitle ) { + public function getNumPagesThatCallTemplate( IDatabase $dbr, LinkTarget $templateTitle ) { $conds = [ "tl_from=page_id" ]; // MW 1.38+ - use the normalized link target ID for fetching incoming links to this template. @@ -170,7 +170,7 @@ public function getNumPagesThatCallTemplate( IDatabase $dbw, LinkTarget $templat $conds['tl_title'] = $templateTitle->getDBkey(); } - $res = $dbw->select( + $res = $dbr->select( [ 'page', 'templatelinks' ], 'COUNT(*) AS total', $conds, diff --git a/tests/phpunit/unit/CargoConnectionProviderUnitTest.php b/tests/phpunit/unit/CargoConnectionProviderUnitTest.php new file mode 100644 index 00000000..c1b60068 --- /dev/null +++ b/tests/phpunit/unit/CargoConnectionProviderUnitTest.php @@ -0,0 +1,332 @@ +markTestSkipped( 'The DatabaseFactory class is not available' ); + } + + $this->lbFactory = $this->createMock( ILBFactory::class ); + $this->dbLoadBalancer = $this->createMock( ILoadBalancer::class ); + $this->databaseFactory = $this->createMock( DatabaseFactory::class ); + $this->connection = $this->createMock( IDatabase::class ); + + $this->lbFactory->expects( $this->any() ) + ->method( 'getMainLB' ) + ->willReturn( $this->dbLoadBalancer ); + } + + /** + * @dataProvider provideConnectionConfigs + */ + public function testShouldCreateAndManageConnectionBasedOnConfigVars( + array $config, + string $expectedDbType, + array $expectedConnectionParams + ): void { + $serviceOptions = new ServiceOptions( CargoConnectionProvider::CONSTRUCTOR_OPTIONS, $config ); + $connectionProvider = new CargoConnectionProvider( $this->lbFactory, $this->databaseFactory, $serviceOptions ); + $mainConn = $this->createMock( IDatabase::class ); + + $this->dbLoadBalancer->expects( $this->any() ) + ->method( 'getConnection' ) + ->with( $serviceOptions->get( 'CargoDBIndex' ) ?? DB_PRIMARY ) + ->willReturn( $mainConn ); + + $mainConn->expects( $this->any() ) + ->method( 'getServer' ) + ->willReturn( 'localhost' ); + $mainConn->expects( $this->any() ) + ->method( 'getDBname' ) + ->willReturn( 'test_wiki_db' ); + $mainConn->expects( $this->any() ) + ->method( 'getType' ) + ->willReturn( 'mysql' ); + + $this->databaseFactory->expects( $this->once() ) + ->method( 'create' ) + ->with( $expectedDbType, $expectedConnectionParams ) + ->willReturn( $this->connection ); + + $replicaConn = $connectionProvider->getConnection( DB_REPLICA ); + $primaryConn = $connectionProvider->getConnection( DB_PRIMARY ); + + $this->assertSame( $this->connection, $replicaConn ); + $this->assertSame( $this->connection, $primaryConn ); + } + + public static function provideConnectionConfigs(): iterable { + yield 'inferred from main connection' => [ + [ + 'DBuser' => 'db_user', + 'DBpassword' => 'db_password', + 'DBport' => 0, + 'DBprefix' => '', + 'DBservers' => [], + + 'CargoDBserver' => null, + 'CargoDBname' => null, + 'CargoDBuser' => null, + 'CargoDBpassword' => null, + 'CargoDBprefix' => null, + 'CargoDBtype' => null, + + 'CargoDBIndex' => null, + + 'CargoDBCluster' => null, + ], + 'mysql', + [ + 'host' => 'localhost', + 'user' => 'db_user', + 'password' => 'db_password', + 'dbname' => 'test_wiki_db', + 'tablePrefix' => 'cargo__', + ] + ]; + + yield 'inferred from DBservers' => [ + [ + 'DBuser' => 'db_user', + 'DBpassword' => 'db_password', + 'DBport' => 0, + 'DBprefix' => '', + 'DBservers' => [ + [ 'user' => 'primary_db_user', 'password' => 'primary_db_password' ], + [ 'user' => 'replica_db_user', 'password' => 'replica_db_password' ], + ], + + 'CargoDBserver' => null, + 'CargoDBname' => null, + 'CargoDBuser' => null, + 'CargoDBpassword' => null, + 'CargoDBprefix' => null, + 'CargoDBtype' => null, + + 'CargoDBIndex' => null, + + 'CargoDBCluster' => null, + ], + 'mysql', + [ + 'host' => 'localhost', + 'user' => 'primary_db_user', + 'password' => 'primary_db_password', + 'dbname' => 'test_wiki_db', + 'tablePrefix' => 'cargo__', + ] + ]; + + yield 'inferred from main connection with CargoDBIndex override' => [ + [ + 'DBuser' => 'db_user', + 'DBpassword' => 'db_password', + 'DBport' => 0, + 'DBprefix' => '', + 'DBservers' => [], + + 'CargoDBserver' => null, + 'CargoDBname' => null, + 'CargoDBuser' => null, + 'CargoDBpassword' => null, + 'CargoDBprefix' => null, + 'CargoDBtype' => null, + + 'CargoDBIndex' => DB_REPLICA, + + 'CargoDBCluster' => null, + ], + 'mysql', + [ + 'host' => 'localhost', + 'user' => 'db_user', + 'password' => 'db_password', + 'dbname' => 'test_wiki_db', + 'tablePrefix' => 'cargo__', + ] + ]; + + yield 'inferred from CargoDB overrides' => [ + [ + 'DBuser' => 'db_user', + 'DBpassword' => 'db_password', + 'DBport' => 0, + 'DBprefix' => '', + 'DBservers' => [ + [ 'user' => 'primary_db_user', 'password' => 'primary_db_password' ], + [ 'user' => 'replica_db_user', 'password' => 'replica_db_password' ], + ], + + 'CargoDBserver' => 'cargodbhost', + 'CargoDBname' => 'cargo_db_name', + 'CargoDBuser' => 'cargo_db_user', + 'CargoDBpassword' => 'cargo_db_password', + 'CargoDBprefix' => 'cargoprefix', + 'CargoDBtype' => 'postgres', + + 'CargoDBIndex' => null, + + 'CargoDBCluster' => null, + ], + 'postgres', + [ + 'host' => 'cargodbhost', + 'user' => 'cargo_db_user', + 'password' => 'cargo_db_password', + 'dbname' => 'cargo_db_name', + 'tablePrefix' => 'cargoprefix', + 'port' => 0, + ] + ]; + + yield 'inferred from a subset of CargoDB overrides' => [ + [ + 'DBuser' => 'db_user', + 'DBpassword' => 'db_password', + 'DBport' => 0, + 'DBprefix' => '', + 'DBservers' => [ + [ 'user' => 'primary_db_user', 'password' => 'primary_db_password' ], + [ 'user' => 'replica_db_user', 'password' => 'replica_db_password' ], + ], + + 'CargoDBserver' => 'cargodbhost', + 'CargoDBname' => 'cargo_db_name', + 'CargoDBuser' => null, + 'CargoDBpassword' => null, + 'CargoDBprefix' => null, + 'CargoDBtype' => null, + + 'CargoDBIndex' => null, + + 'CargoDBCluster' => null, + ], + 'mysql', + [ + 'host' => 'cargodbhost', + 'user' => 'primary_db_user', + 'password' => 'primary_db_password', + 'dbname' => 'cargo_db_name', + 'tablePrefix' => 'cargo__', + ] + ]; + } + + /** + * @dataProvider provideClusterConnectionTypes + */ + public function testShouldObtainConnectionFromMediaWikiLoadBalancerIfClusterOptionSet( int $dbType ): void { + $serviceOptions = new ServiceOptions( CargoConnectionProvider::CONSTRUCTOR_OPTIONS, [ + 'DBuser' => 'db_user', + 'DBpassword' => 'db_password', + 'DBport' => 0, + 'DBprefix' => '', + 'DBservers' => [], + + 'CargoDBserver' => null, + 'CargoDBname' => null, + 'CargoDBuser' => null, + 'CargoDBpassword' => null, + 'CargoDBprefix' => null, + 'CargoDBtype' => null, + + 'CargoDBIndex' => null, + + 'CargoDBCluster' => 'testCargoCluster', + ] ); + + $connectionProvider = new CargoConnectionProvider( $this->lbFactory, $this->databaseFactory, $serviceOptions ); + $cargoLoadBalancer = $this->createMock( ILoadBalancer::class ); + $cargoConn = $this->createMock( IDatabase::class ); + + $cargoLoadBalancer->expects( $this->any() ) + ->method( 'hasOrMadeRecentPrimaryChanges' ) + ->willReturn( false ); + + $cargoLoadBalancer->expects( $this->any() ) + ->method( 'getConnection' ) + ->with( $dbType ) + ->willReturn( $cargoConn ); + + $this->lbFactory->expects( $this->any() ) + ->method( 'getExternalLB' ) + ->with( 'testCargoCluster' ) + ->willReturn( $cargoLoadBalancer ); + + $conn = $connectionProvider->getConnection( $dbType ); + + $this->assertSame( $cargoConn, $conn ); + } + + /** + * @dataProvider provideClusterConnectionTypes + */ + public function testShouldObtainPrimaryConnectionFromMediaWikiLoadBalancerIfClusterOptionSetWithRecentWrites( + int $dbType + ): void { + $serviceOptions = new ServiceOptions( CargoConnectionProvider::CONSTRUCTOR_OPTIONS, [ + 'DBuser' => 'db_user', + 'DBpassword' => 'db_password', + 'DBport' => 0, + 'DBprefix' => '', + 'DBservers' => [], + + 'CargoDBserver' => null, + 'CargoDBname' => null, + 'CargoDBuser' => null, + 'CargoDBpassword' => null, + 'CargoDBprefix' => null, + 'CargoDBtype' => null, + + 'CargoDBIndex' => null, + + 'CargoDBCluster' => 'testCargoCluster', + ] ); + + $connectionProvider = new CargoConnectionProvider( $this->lbFactory, $this->databaseFactory, $serviceOptions ); + $cargoLoadBalancer = $this->createMock( ILoadBalancer::class ); + $cargoConn = $this->createMock( IDatabase::class ); + + $cargoLoadBalancer->expects( $this->any() ) + ->method( 'hasOrMadeRecentPrimaryChanges' ) + ->willReturn( true ); + + $cargoLoadBalancer->expects( $this->any() ) + ->method( 'getConnection' ) + ->with( DB_PRIMARY ) + ->willReturn( $cargoConn ); + + $this->lbFactory->expects( $this->any() ) + ->method( 'getExternalLB' ) + ->with( 'testCargoCluster' ) + ->willReturn( $cargoLoadBalancer ); + + $conn = $connectionProvider->getConnection( $dbType ); + + $this->assertSame( $cargoConn, $conn ); + } + + public static function provideClusterConnectionTypes(): iterable { + yield 'replica DB' => [ DB_REPLICA ]; + yield 'primary DB' => [ DB_PRIMARY ]; + } +}