diff options
Diffstat (limited to 'www/wiki/tests/phpunit/includes/db')
6 files changed, 1850 insertions, 0 deletions
diff --git a/www/wiki/tests/phpunit/includes/db/DatabaseOracleTest.php b/www/wiki/tests/phpunit/includes/db/DatabaseOracleTest.php new file mode 100644 index 00000000..061e121a --- /dev/null +++ b/www/wiki/tests/phpunit/includes/db/DatabaseOracleTest.php @@ -0,0 +1,52 @@ +<?php + +class DatabaseOracleTest extends PHPUnit\Framework\TestCase { + + use MediaWikiCoversValidator; + use PHPUnit4And6Compat; + + /** + * @return PHPUnit_Framework_MockObject_MockObject|DatabaseOracle + */ + private function getMockDb() { + return $this->getMockBuilder( DatabaseOracle::class ) + ->disableOriginalConstructor() + ->setMethods( null ) + ->getMock(); + } + + public function provideBuildSubstring() { + yield [ 'someField', 1, 2, 'SUBSTR(someField,1,2)' ]; + yield [ 'someField', 1, null, 'SUBSTR(someField,1)' ]; + } + + /** + * @covers DatabaseOracle::buildSubstring + * @dataProvider provideBuildSubstring + */ + public function testBuildSubstring( $input, $start, $length, $expected ) { + $mockDb = $this->getMockDb(); + $output = $mockDb->buildSubstring( $input, $start, $length ); + $this->assertSame( $expected, $output ); + } + + public function provideBuildSubstring_invalidParams() { + yield [ -1, 1 ]; + yield [ 1, -1 ]; + yield [ 1, 'foo' ]; + yield [ 'foo', 1 ]; + yield [ null, 1 ]; + yield [ 0, 1 ]; + } + + /** + * @covers DatabaseOracle::buildSubstring + * @dataProvider provideBuildSubstring_invalidParams + */ + public function testBuildSubstring_invalidParams( $start, $length ) { + $mockDb = $this->getMockDb(); + $this->setExpectedException( InvalidArgumentException::class ); + $mockDb->buildSubstring( 'foo', $start, $length ); + } + +} diff --git a/www/wiki/tests/phpunit/includes/db/DatabasePostgresTest.php b/www/wiki/tests/phpunit/includes/db/DatabasePostgresTest.php new file mode 100644 index 00000000..5c2aa2bb --- /dev/null +++ b/www/wiki/tests/phpunit/includes/db/DatabasePostgresTest.php @@ -0,0 +1,177 @@ +<?php + +use Wikimedia\Rdbms\IDatabase; +use Wikimedia\Rdbms\DatabasePostgres; +use Wikimedia\ScopedCallback; +use Wikimedia\TestingAccessWrapper; + +/** + * @group Database + */ +class DatabasePostgresTest extends MediaWikiTestCase { + + private function doTestInsertIgnore() { + $reset = new ScopedCallback( function () { + if ( $this->db->explicitTrxActive() ) { + $this->db->rollback( __METHOD__ ); + } + $this->db->query( 'DROP TABLE IF EXISTS ' . $this->db->tableName( 'foo' ) ); + } ); + + $this->db->query( + "CREATE TEMPORARY TABLE {$this->db->tableName( 'foo' )} (i INTEGER NOT NULL PRIMARY KEY)" + ); + $this->db->insert( 'foo', [ [ 'i' => 1 ], [ 'i' => 2 ] ], __METHOD__ ); + + // Normal INSERT IGNORE + $this->db->begin( __METHOD__ ); + $this->db->insert( + 'foo', [ [ 'i' => 3 ], [ 'i' => 2 ], [ 'i' => 5 ] ], __METHOD__, [ 'IGNORE' ] + ); + $this->assertSame( 2, $this->db->affectedRows() ); + $this->assertSame( + [ '1', '2', '3', '5' ], + $this->db->selectFieldValues( 'foo', 'i', [], __METHOD__, [ 'ORDER BY' => 'i' ] ) + ); + $this->db->rollback( __METHOD__ ); + + // INSERT IGNORE doesn't ignore stuff like NOT NULL violations + $this->db->begin( __METHOD__ ); + $this->db->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE ); + try { + $this->db->insert( + 'foo', [ [ 'i' => 7 ], [ 'i' => null ] ], __METHOD__, [ 'IGNORE' ] + ); + $this->db->endAtomic( __METHOD__ ); + $this->fail( 'Expected exception not thrown' ); + } catch ( DBQueryError $e ) { + $this->assertSame( 0, $this->db->affectedRows() ); + $this->db->cancelAtomic( __METHOD__ ); + } + $this->assertSame( + [ '1', '2' ], + $this->db->selectFieldValues( 'foo', 'i', [], __METHOD__, [ 'ORDER BY' => 'i' ] ) + ); + $this->db->rollback( __METHOD__ ); + } + + /** + * @covers Wikimedia\Rdbms\DatabasePostgres::insert + */ + public function testInsertIgnoreOld() { + if ( !$this->db instanceof DatabasePostgres ) { + $this->markTestSkipped( 'Not PostgreSQL' ); + } + if ( $this->db->getServerVersion() < 9.5 ) { + $this->doTestInsertIgnore(); + } else { + // Hack version to make it take the old code path + $w = TestingAccessWrapper::newFromObject( $this->db ); + $oldVer = $w->numericVersion; + $w->numericVersion = 9.4; + try { + $this->doTestInsertIgnore(); + } finally { + $w->numericVersion = $oldVer; + } + } + } + + /** + * @covers Wikimedia\Rdbms\DatabasePostgres::insert + */ + public function testInsertIgnoreNew() { + if ( !$this->db instanceof DatabasePostgres ) { + $this->markTestSkipped( 'Not PostgreSQL' ); + } + if ( $this->db->getServerVersion() < 9.5 ) { + $this->markTestSkipped( 'PostgreSQL version is ' . $this->db->getServerVersion() ); + } + + $this->doTestInsertIgnore(); + } + + private function doTestInsertSelectIgnore() { + $reset = new ScopedCallback( function () { + if ( $this->db->explicitTrxActive() ) { + $this->db->rollback( __METHOD__ ); + } + $this->db->query( 'DROP TABLE IF EXISTS ' . $this->db->tableName( 'foo' ) ); + $this->db->query( 'DROP TABLE IF EXISTS ' . $this->db->tableName( 'bar' ) ); + } ); + + $this->db->query( + "CREATE TEMPORARY TABLE {$this->db->tableName( 'foo' )} (i INTEGER)" + ); + $this->db->query( + "CREATE TEMPORARY TABLE {$this->db->tableName( 'bar' )} (i INTEGER NOT NULL PRIMARY KEY)" + ); + $this->db->insert( 'bar', [ [ 'i' => 1 ], [ 'i' => 2 ] ], __METHOD__ ); + + // Normal INSERT IGNORE + $this->db->begin( __METHOD__ ); + $this->db->insert( 'foo', [ [ 'i' => 3 ], [ 'i' => 2 ], [ 'i' => 5 ] ], __METHOD__ ); + $this->db->insertSelect( 'bar', 'foo', [ 'i' => 'i' ], [], __METHOD__, [ 'IGNORE' ] ); + $this->assertSame( 2, $this->db->affectedRows() ); + $this->assertSame( + [ '1', '2', '3', '5' ], + $this->db->selectFieldValues( 'bar', 'i', [], __METHOD__, [ 'ORDER BY' => 'i' ] ) + ); + $this->db->rollback( __METHOD__ ); + + // INSERT IGNORE doesn't ignore stuff like NOT NULL violations + $this->db->begin( __METHOD__ ); + $this->db->insert( 'foo', [ [ 'i' => 7 ], [ 'i' => null ] ], __METHOD__ ); + $this->db->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE ); + try { + $this->db->insertSelect( 'bar', 'foo', [ 'i' => 'i' ], [], __METHOD__, [ 'IGNORE' ] ); + $this->db->endAtomic( __METHOD__ ); + $this->fail( 'Expected exception not thrown' ); + } catch ( DBQueryError $e ) { + $this->assertSame( 0, $this->db->affectedRows() ); + $this->db->cancelAtomic( __METHOD__ ); + } + $this->assertSame( + [ '1', '2' ], + $this->db->selectFieldValues( 'bar', 'i', [], __METHOD__, [ 'ORDER BY' => 'i' ] ) + ); + $this->db->rollback( __METHOD__ ); + } + + /** + * @covers Wikimedia\Rdbms\DatabasePostgres::nativeInsertSelect + */ + public function testInsertSelectIgnoreOld() { + if ( !$this->db instanceof DatabasePostgres ) { + $this->markTestSkipped( 'Not PostgreSQL' ); + } + if ( $this->db->getServerVersion() < 9.5 ) { + $this->doTestInsertSelectIgnore(); + } else { + // Hack version to make it take the old code path + $w = TestingAccessWrapper::newFromObject( $this->db ); + $oldVer = $w->numericVersion; + $w->numericVersion = 9.4; + try { + $this->doTestInsertSelectIgnore(); + } finally { + $w->numericVersion = $oldVer; + } + } + } + + /** + * @covers Wikimedia\Rdbms\DatabasePostgres::nativeInsertSelect + */ + public function testInsertSelectIgnoreNew() { + if ( !$this->db instanceof DatabasePostgres ) { + $this->markTestSkipped( 'Not PostgreSQL' ); + } + if ( $this->db->getServerVersion() < 9.5 ) { + $this->markTestSkipped( 'PostgreSQL version is ' . $this->db->getServerVersion() ); + } + + $this->doTestInsertSelectIgnore(); + } + +} diff --git a/www/wiki/tests/phpunit/includes/db/DatabaseSqliteTest.php b/www/wiki/tests/phpunit/includes/db/DatabaseSqliteTest.php new file mode 100644 index 00000000..729b58c7 --- /dev/null +++ b/www/wiki/tests/phpunit/includes/db/DatabaseSqliteTest.php @@ -0,0 +1,519 @@ +<?php + +use Wikimedia\Rdbms\Blob; +use Wikimedia\Rdbms\Database; +use Wikimedia\Rdbms\DatabaseSqlite; +use Wikimedia\Rdbms\ResultWrapper; + +class DatabaseSqliteMock extends DatabaseSqlite { + public static function newInstance( array $p = [] ) { + $p['dbFilePath'] = ':memory:'; + $p['schema'] = false; + + return Database::factory( 'SqliteMock', $p ); + } + + function query( $sql, $fname = '', $tempIgnore = false ) { + return true; + } + + /** + * Override parent visibility to public + */ + public function replaceVars( $s ) { + return parent::replaceVars( $s ); + } +} + +/** + * @group sqlite + * @group Database + * @group medium + */ +class DatabaseSqliteTest extends MediaWikiTestCase { + /** @var DatabaseSqliteMock */ + protected $db; + + protected function setUp() { + parent::setUp(); + + if ( !Sqlite::isPresent() ) { + $this->markTestSkipped( 'No SQLite support detected' ); + } + $this->db = DatabaseSqliteMock::newInstance(); + if ( version_compare( $this->db->getServerVersion(), '3.6.0', '<' ) ) { + $this->markTestSkipped( "SQLite at least 3.6 required, {$this->db->getServerVersion()} found" ); + } + } + + private function replaceVars( $sql ) { + // normalize spacing to hide implementation details + return preg_replace( '/\s+/', ' ', $this->db->replaceVars( $sql ) ); + } + + private function assertResultIs( $expected, $res ) { + $this->assertNotNull( $res ); + $i = 0; + foreach ( $res as $row ) { + foreach ( $expected[$i] as $key => $value ) { + $this->assertTrue( isset( $row->$key ) ); + $this->assertEquals( $value, $row->$key ); + } + $i++; + } + $this->assertEquals( count( $expected ), $i, 'Unexpected number of rows' ); + } + + public static function provideAddQuotes() { + return [ + [ // #0: empty + '', "''" + ], + [ // #1: simple + 'foo bar', "'foo bar'" + ], + [ // #2: including quote + 'foo\'bar', "'foo''bar'" + ], + // #3: including \0 (must be represented as hex, per https://bugs.php.net/bug.php?id=63419) + [ + "x\0y", + "x'780079'", + ], + [ // #4: blob object (must be represented as hex) + new Blob( "hello" ), + "x'68656c6c6f'", + ], + [ // #5: null + null, + "''", + ], + ]; + } + + /** + * @dataProvider provideAddQuotes() + * @covers DatabaseSqlite::addQuotes + */ + public function testAddQuotes( $value, $expected ) { + // check quoting + $db = DatabaseSqlite::newStandaloneInstance( ':memory:' ); + $this->assertEquals( $expected, $db->addQuotes( $value ), 'string not quoted as expected' ); + + // ok, quoting works as expected, now try a round trip. + $re = $db->query( 'select ' . $db->addQuotes( $value ) ); + + $this->assertTrue( $re !== false, 'query failed' ); + + $row = $re->fetchRow(); + if ( $row ) { + if ( $value instanceof Blob ) { + $value = $value->fetch(); + } + + $this->assertEquals( $value, $row[0], 'string mangled by the database' ); + } else { + $this->fail( 'query returned no result' ); + } + } + + /** + * @covers DatabaseSqlite::replaceVars + */ + public function testReplaceVars() { + $this->assertEquals( 'foo', $this->replaceVars( 'foo' ), "Don't break anything accidentally" ); + + $this->assertEquals( + "CREATE TABLE /**/foo (foo_key INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, " + . "foo_bar TEXT, foo_name TEXT NOT NULL DEFAULT '', foo_int INTEGER, foo_int2 INTEGER );", + $this->replaceVars( + "CREATE TABLE /**/foo (foo_key int unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT, " + . "foo_bar char(13), foo_name varchar(255) binary NOT NULL DEFAULT '', " + . "foo_int tinyint ( 8 ), foo_int2 int(16) ) ENGINE=MyISAM;" + ) + ); + + $this->assertEquals( + "CREATE TABLE foo ( foo1 REAL, foo2 REAL, foo3 REAL );", + $this->replaceVars( + "CREATE TABLE foo ( foo1 FLOAT, foo2 DOUBLE( 1,10), foo3 DOUBLE PRECISION );" + ) + ); + + $this->assertEquals( "CREATE TABLE foo ( foo_binary1 BLOB, foo_binary2 BLOB );", + $this->replaceVars( "CREATE TABLE foo ( foo_binary1 binary(16), foo_binary2 varbinary(32) );" ) + ); + + $this->assertEquals( "CREATE TABLE text ( text_foo TEXT );", + $this->replaceVars( "CREATE TABLE text ( text_foo tinytext );" ), + 'Table name changed' + ); + + $this->assertEquals( "CREATE TABLE foo ( foobar INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL );", + $this->replaceVars( "CREATE TABLE foo ( foobar INT PRIMARY KEY NOT NULL AUTO_INCREMENT );" ) + ); + $this->assertEquals( "CREATE TABLE foo ( foobar INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL );", + $this->replaceVars( "CREATE TABLE foo ( foobar INT PRIMARY KEY AUTO_INCREMENT NOT NULL );" ) + ); + + $this->assertEquals( "CREATE TABLE enums( enum1 TEXT, myenum TEXT)", + $this->replaceVars( "CREATE TABLE enums( enum1 ENUM('A', 'B'), myenum ENUM ('X', 'Y'))" ) + ); + + $this->assertEquals( "ALTER TABLE foo ADD COLUMN foo_bar INTEGER DEFAULT 42", + $this->replaceVars( "ALTER TABLE foo\nADD COLUMN foo_bar int(10) unsigned DEFAULT 42" ) + ); + + $this->assertEquals( "DROP INDEX foo", + $this->replaceVars( "DROP INDEX /*i*/foo ON /*_*/bar" ) + ); + + $this->assertEquals( "DROP INDEX foo -- dropping index", + $this->replaceVars( "DROP INDEX /*i*/foo ON /*_*/bar -- dropping index" ) + ); + $this->assertEquals( "INSERT OR IGNORE INTO foo VALUES ('bar')", + $this->replaceVars( "INSERT OR IGNORE INTO foo VALUES ('bar')" ) + ); + } + + /** + * @covers DatabaseSqlite::tableName + */ + public function testTableName() { + // @todo Moar! + $db = DatabaseSqlite::newStandaloneInstance( ':memory:' ); + $this->assertEquals( 'foo', $db->tableName( 'foo' ) ); + $this->assertEquals( 'sqlite_master', $db->tableName( 'sqlite_master' ) ); + $db->tablePrefix( 'foo' ); + $this->assertEquals( 'sqlite_master', $db->tableName( 'sqlite_master' ) ); + $this->assertEquals( 'foobar', $db->tableName( 'bar' ) ); + } + + /** + * @covers DatabaseSqlite::duplicateTableStructure + */ + public function testDuplicateTableStructure() { + $db = DatabaseSqlite::newStandaloneInstance( ':memory:' ); + $db->query( 'CREATE TABLE foo(foo, barfoo)' ); + $db->query( 'CREATE INDEX index1 ON foo(foo)' ); + $db->query( 'CREATE UNIQUE INDEX index2 ON foo(barfoo)' ); + + $db->duplicateTableStructure( 'foo', 'bar' ); + $this->assertEquals( 'CREATE TABLE "bar"(foo, barfoo)', + $db->selectField( 'sqlite_master', 'sql', [ 'name' => 'bar' ] ), + 'Normal table duplication' + ); + $indexList = $db->query( 'PRAGMA INDEX_LIST("bar")' ); + $index = $indexList->next(); + $this->assertEquals( 'bar_index1', $index->name ); + $this->assertEquals( '0', $index->unique ); + $index = $indexList->next(); + $this->assertEquals( 'bar_index2', $index->name ); + $this->assertEquals( '1', $index->unique ); + + $db->duplicateTableStructure( 'foo', 'baz', true ); + $this->assertEquals( 'CREATE TABLE "baz"(foo, barfoo)', + $db->selectField( 'sqlite_temp_master', 'sql', [ 'name' => 'baz' ] ), + 'Creation of temporary duplicate' + ); + $indexList = $db->query( 'PRAGMA INDEX_LIST("baz")' ); + $index = $indexList->next(); + $this->assertEquals( 'baz_index1', $index->name ); + $this->assertEquals( '0', $index->unique ); + $index = $indexList->next(); + $this->assertEquals( 'baz_index2', $index->name ); + $this->assertEquals( '1', $index->unique ); + $this->assertEquals( 0, + $db->selectField( 'sqlite_master', 'COUNT(*)', [ 'name' => 'baz' ] ), + 'Create a temporary duplicate only' + ); + } + + /** + * @covers DatabaseSqlite::duplicateTableStructure + */ + public function testDuplicateTableStructureVirtual() { + $db = DatabaseSqlite::newStandaloneInstance( ':memory:' ); + if ( $db->getFulltextSearchModule() != 'FTS3' ) { + $this->markTestSkipped( 'FTS3 not supported, cannot create virtual tables' ); + } + $db->query( 'CREATE VIRTUAL TABLE "foo" USING FTS3(foobar)' ); + + $db->duplicateTableStructure( 'foo', 'bar' ); + $this->assertEquals( 'CREATE VIRTUAL TABLE "bar" USING FTS3(foobar)', + $db->selectField( 'sqlite_master', 'sql', [ 'name' => 'bar' ] ), + 'Duplication of virtual tables' + ); + + $db->duplicateTableStructure( 'foo', 'baz', true ); + $this->assertEquals( 'CREATE VIRTUAL TABLE "baz" USING FTS3(foobar)', + $db->selectField( 'sqlite_master', 'sql', [ 'name' => 'baz' ] ), + "Can't create temporary virtual tables, should fall back to non-temporary duplication" + ); + } + + /** + * @covers DatabaseSqlite::deleteJoin + */ + public function testDeleteJoin() { + $db = DatabaseSqlite::newStandaloneInstance( ':memory:' ); + $db->query( 'CREATE TABLE a (a_1)', __METHOD__ ); + $db->query( 'CREATE TABLE b (b_1, b_2)', __METHOD__ ); + $db->insert( 'a', [ + [ 'a_1' => 1 ], + [ 'a_1' => 2 ], + [ 'a_1' => 3 ], + ], + __METHOD__ + ); + $db->insert( 'b', [ + [ 'b_1' => 2, 'b_2' => 'a' ], + [ 'b_1' => 3, 'b_2' => 'b' ], + ], + __METHOD__ + ); + $db->deleteJoin( 'a', 'b', 'a_1', 'b_1', [ 'b_2' => 'a' ], __METHOD__ ); + $res = $db->query( "SELECT * FROM a", __METHOD__ ); + $this->assertResultIs( [ + [ 'a_1' => 1 ], + [ 'a_1' => 3 ], + ], + $res + ); + } + + /** + * @coversNothing + */ + public function testEntireSchema() { + global $IP; + + $result = Sqlite::checkSqlSyntax( "$IP/maintenance/tables.sql" ); + if ( $result !== true ) { + $this->fail( $result ); + } + $this->assertTrue( true ); // avoid test being marked as incomplete due to lack of assertions + } + + /** + * Runs upgrades of older databases and compares results with current schema + * @todo Currently only checks list of tables + * @coversNothing + */ + public function testUpgrades() { + global $IP, $wgVersion, $wgProfiler; + + // Versions tested + $versions = [ + // '1.13', disabled for now, was totally screwed up + // SQLite wasn't included in 1.14 + '1.15', + '1.16', + '1.17', + '1.18', + '1.19', + '1.20', + '1.21', + '1.22', + '1.23', + ]; + + // Mismatches for these columns we can safely ignore + $ignoredColumns = [ + 'user_newtalk.user_last_timestamp', // r84185 + ]; + + $currentDB = DatabaseSqlite::newStandaloneInstance( ':memory:' ); + $currentDB->sourceFile( "$IP/maintenance/tables.sql" ); + + $profileToDb = false; + if ( isset( $wgProfiler['output'] ) ) { + $out = $wgProfiler['output']; + if ( $out === 'db' ) { + $profileToDb = true; + } elseif ( is_array( $out ) && in_array( 'db', $out ) ) { + $profileToDb = true; + } + } + + if ( $profileToDb ) { + $currentDB->sourceFile( "$IP/maintenance/sqlite/archives/patch-profiling.sql" ); + } + $currentTables = $this->getTables( $currentDB ); + sort( $currentTables ); + + foreach ( $versions as $version ) { + $versions = "upgrading from $version to $wgVersion"; + $db = $this->prepareTestDB( $version ); + $tables = $this->getTables( $db ); + $this->assertEquals( $currentTables, $tables, "Different tables $versions" ); + foreach ( $tables as $table ) { + $currentCols = $this->getColumns( $currentDB, $table ); + $cols = $this->getColumns( $db, $table ); + $this->assertEquals( + array_keys( $currentCols ), + array_keys( $cols ), + "Mismatching columns for table \"$table\" $versions" + ); + foreach ( $currentCols as $name => $column ) { + $fullName = "$table.$name"; + $this->assertEquals( + (bool)$column->pk, + (bool)$cols[$name]->pk, + "PRIMARY KEY status does not match for column $fullName $versions" + ); + if ( !in_array( $fullName, $ignoredColumns ) ) { + $this->assertEquals( + (bool)$column->notnull, + (bool)$cols[$name]->notnull, + "NOT NULL status does not match for column $fullName $versions" + ); + $this->assertEquals( + $column->dflt_value, + $cols[$name]->dflt_value, + "Default values does not match for column $fullName $versions" + ); + } + } + $currentIndexes = $this->getIndexes( $currentDB, $table ); + $indexes = $this->getIndexes( $db, $table ); + $this->assertEquals( + array_keys( $currentIndexes ), + array_keys( $indexes ), + "mismatching indexes for table \"$table\" $versions" + ); + } + $db->close(); + } + } + + /** + * @covers DatabaseSqlite::insertId + */ + public function testInsertIdType() { + $db = DatabaseSqlite::newStandaloneInstance( ':memory:' ); + + $databaseCreation = $db->query( 'CREATE TABLE a ( a_1 )', __METHOD__ ); + $this->assertInstanceOf( ResultWrapper::class, $databaseCreation, "Database creation" ); + + $insertion = $db->insert( 'a', [ 'a_1' => 10 ], __METHOD__ ); + $this->assertTrue( $insertion, "Insertion worked" ); + + $this->assertInternalType( 'integer', $db->insertId(), "Actual typecheck" ); + $this->assertTrue( $db->close(), "closing database" ); + } + + private function prepareTestDB( $version ) { + static $maint = null; + if ( $maint === null ) { + $maint = new FakeMaintenance(); + $maint->loadParamsAndArgs( null, [ 'quiet' => 1 ] ); + } + + global $IP; + $db = DatabaseSqlite::newStandaloneInstance( ':memory:' ); + $db->sourceFile( "$IP/tests/phpunit/data/db/sqlite/tables-$version.sql" ); + $updater = DatabaseUpdater::newForDB( $db, false, $maint ); + $updater->doUpdates( [ 'core' ] ); + + return $db; + } + + private function getTables( $db ) { + $list = array_flip( $db->listTables() ); + $excluded = [ + 'external_user', // removed from core in 1.22 + 'math', // moved out of core in 1.18 + 'trackbacks', // removed from core in 1.19 + 'searchindex', + 'searchindex_content', + 'searchindex_segments', + 'searchindex_segdir', + // FTS4 ready!!1 + 'searchindex_docsize', + 'searchindex_stat', + ]; + foreach ( $excluded as $t ) { + unset( $list[$t] ); + } + $list = array_flip( $list ); + sort( $list ); + + return $list; + } + + private function getColumns( $db, $table ) { + $cols = []; + $res = $db->query( "PRAGMA table_info($table)" ); + $this->assertNotNull( $res ); + foreach ( $res as $col ) { + $cols[$col->name] = $col; + } + ksort( $cols ); + + return $cols; + } + + private function getIndexes( $db, $table ) { + $indexes = []; + $res = $db->query( "PRAGMA index_list($table)" ); + $this->assertNotNull( $res ); + foreach ( $res as $index ) { + $res2 = $db->query( "PRAGMA index_info({$index->name})" ); + $this->assertNotNull( $res2 ); + $index->columns = []; + foreach ( $res2 as $col ) { + $index->columns[] = $col; + } + $indexes[$index->name] = $index; + } + ksort( $indexes ); + + return $indexes; + } + + public function testCaseInsensitiveLike() { + // TODO: Test this for all databases + $db = DatabaseSqlite::newStandaloneInstance( ':memory:' ); + $res = $db->query( 'SELECT "a" LIKE "A" AS a' ); + $row = $res->fetchRow(); + $this->assertFalse( (bool)$row['a'] ); + } + + /** + * @covers DatabaseSqlite::numFields + */ + public function testNumFields() { + $db = DatabaseSqlite::newStandaloneInstance( ':memory:' ); + + $databaseCreation = $db->query( 'CREATE TABLE a ( a_1 )', __METHOD__ ); + $this->assertInstanceOf( ResultWrapper::class, $databaseCreation, "Failed to create table a" ); + $res = $db->select( 'a', '*' ); + $this->assertEquals( 0, $db->numFields( $res ), "expects to get 0 fields for an empty table" ); + $insertion = $db->insert( 'a', [ 'a_1' => 10 ], __METHOD__ ); + $this->assertTrue( $insertion, "Insertion failed" ); + $res = $db->select( 'a', '*' ); + $this->assertEquals( 1, $db->numFields( $res ), "wrong number of fields" ); + + $this->assertTrue( $db->close(), "closing database" ); + } + + /** + * @covers \Wikimedia\Rdbms\DatabaseSqlite::__toString + */ + public function testToString() { + $db = DatabaseSqlite::newStandaloneInstance( ':memory:' ); + + $toString = (string)$db; + + $this->assertContains( 'SQLite ', $toString ); + } + + /** + * @covers \Wikimedia\Rdbms\DatabaseSqlite::getAttributes() + */ + public function testsAttributes() { + $attributes = Database::attributesFromType( 'sqlite' ); + $this->assertTrue( $attributes[Database::ATTR_DB_LEVEL_LOCKING] ); + } +} diff --git a/www/wiki/tests/phpunit/includes/db/DatabaseTestHelper.php b/www/wiki/tests/phpunit/includes/db/DatabaseTestHelper.php new file mode 100644 index 00000000..e9fc34fa --- /dev/null +++ b/www/wiki/tests/phpunit/includes/db/DatabaseTestHelper.php @@ -0,0 +1,267 @@ +<?php + +use Wikimedia\Rdbms\TransactionProfiler; +use Wikimedia\Rdbms\DatabaseDomain; +use Wikimedia\Rdbms\Database; + +/** + * Helper for testing the methods from the Database class + * @since 1.22 + */ +class DatabaseTestHelper extends Database { + + /** + * __CLASS__ of the test suite, + * used to determine, if the function name is passed every time to query() + */ + protected $testName = []; + + /** + * Array of lastSqls passed to query(), + * This is an array since some methods in Database can do more than one + * query. Cleared when calling getLastSqls(). + */ + protected $lastSqls = []; + + /** @var array List of row arrays */ + protected $nextResult = []; + + /** @var array|null */ + protected $nextError = null; + /** @var array|null */ + protected $lastError = null; + + /** + * Array of tables to be considered as existing by tableExist() + * Use setExistingTables() to alter. + */ + protected $tablesExists; + + /** + * Value to return from unionSupportsOrderAndLimit() + */ + protected $unionSupportsOrderAndLimit = true; + + public function __construct( $testName, array $opts = [] ) { + $this->testName = $testName; + + $this->profiler = new ProfilerStub( [] ); + $this->trxProfiler = new TransactionProfiler(); + $this->cliMode = isset( $opts['cliMode'] ) ? $opts['cliMode'] : true; + $this->connLogger = new \Psr\Log\NullLogger(); + $this->queryLogger = new \Psr\Log\NullLogger(); + $this->errorLogger = function ( Exception $e ) { + wfWarn( get_class( $e ) . ": {$e->getMessage()}" ); + }; + $this->deprecationLogger = function ( $msg ) { + wfWarn( $msg ); + }; + $this->currentDomain = DatabaseDomain::newUnspecified(); + $this->open( 'localhost', 'testuser', 'password', 'testdb' ); + } + + /** + * Returns SQL queries grouped by '; ' + * Clear the list of queries that have been done so far. + * @return string + */ + public function getLastSqls() { + $lastSqls = implode( '; ', $this->lastSqls ); + $this->lastSqls = []; + + return $lastSqls; + } + + public function setExistingTables( $tablesExists ) { + $this->tablesExists = (array)$tablesExists; + } + + /** + * @param mixed $res Use an array of row arrays to set row result + */ + public function forceNextResult( $res ) { + $this->nextResult = $res; + } + + /** + * @param int $errno Error number + * @param string $error Error text + * @param array $options + * - wasKnownStatementRollbackError: Return value for wasKnownStatementRollbackError() + */ + public function forceNextQueryError( $errno, $error, $options = [] ) { + $this->nextError = [ 'errno' => $errno, 'error' => $error ] + $options; + } + + protected function addSql( $sql ) { + // clean up spaces before and after some words and the whole string + $this->lastSqls[] = trim( preg_replace( + '/\s{2,}(?=FROM|WHERE|GROUP BY|ORDER BY|LIMIT)|(?<=SELECT|INSERT|UPDATE)\s{2,}/', + ' ', $sql + ) ); + } + + protected function checkFunctionName( $fname ) { + if ( $fname === 'Wikimedia\\Rdbms\\Database::close' ) { + return; // no $fname parameter + } + + // Handle some internal calls from the Database class + $check = $fname; + if ( preg_match( '/^Wikimedia\\\\Rdbms\\\\Database::query \((.+)\)$/', $fname, $m ) ) { + $check = $m[1]; + } + + if ( substr( $check, 0, strlen( $this->testName ) ) !== $this->testName ) { + throw new MWException( 'function name does not start with test class. ' . + $fname . ' vs. ' . $this->testName . '. ' . + 'Please provide __METHOD__ to database methods.' ); + } + } + + function strencode( $s ) { + // Choose apos to avoid handling of escaping double quotes in quoted text + return str_replace( "'", "\'", $s ); + } + + public function addIdentifierQuotes( $s ) { + // no escaping to avoid handling of double quotes in quoted text + return $s; + } + + public function query( $sql, $fname = '', $tempIgnore = false ) { + $this->checkFunctionName( $fname ); + + return parent::query( $sql, $fname, $tempIgnore ); + } + + public function tableExists( $table, $fname = __METHOD__ ) { + $tableRaw = $this->tableName( $table, 'raw' ); + if ( isset( $this->sessionTempTables[$tableRaw] ) ) { + return true; // already known to exist + } + + $this->checkFunctionName( $fname ); + + return in_array( $table, (array)$this->tablesExists ); + } + + // Redeclare parent method to make it public + public function nativeReplace( $table, $rows, $fname ) { + return parent::nativeReplace( $table, $rows, $fname ); + } + + function getType() { + return 'test'; + } + + function open( $server, $user, $password, $dbName ) { + $this->conn = (object)[ 'test' ]; + + return true; + } + + function fetchObject( $res ) { + return false; + } + + function fetchRow( $res ) { + return false; + } + + function numRows( $res ) { + return -1; + } + + function numFields( $res ) { + return -1; + } + + function fieldName( $res, $n ) { + return 'test'; + } + + function insertId() { + return -1; + } + + function dataSeek( $res, $row ) { + /* nop */ + } + + function lastErrno() { + return $this->lastError ? $this->lastError['errno'] : -1; + } + + function lastError() { + return $this->lastError ? $this->lastError['error'] : 'test'; + } + + protected function wasKnownStatementRollbackError() { + return isset( $this->lastError['wasKnownStatementRollbackError'] ) + ? $this->lastError['wasKnownStatementRollbackError'] + : false; + } + + function fieldInfo( $table, $field ) { + return false; + } + + function indexInfo( $table, $index, $fname = 'Database::indexInfo' ) { + return false; + } + + function fetchAffectedRowCount() { + return -1; + } + + function getSoftwareLink() { + return 'test'; + } + + function getServerVersion() { + return 'test'; + } + + function getServerInfo() { + return 'test'; + } + + function isOpen() { + return $this->conn ? true : false; + } + + function ping( &$rtt = null ) { + $rtt = 0.0; + return true; + } + + protected function closeConnection() { + return true; + } + + protected function doQuery( $sql ) { + $sql = preg_replace( '< /\* .+? \*/>', '', $sql ); + $this->addSql( $sql ); + + if ( $this->nextError ) { + $this->lastError = $this->nextError; + $this->nextError = null; + return false; + } + + $res = $this->nextResult; + $this->nextResult = []; + $this->lastError = null; + + return new FakeResultWrapper( $res ); + } + + public function unionSupportsOrderAndLimit() { + return $this->unionSupportsOrderAndLimit; + } + + public function setUnionSupportsOrderAndLimit( $v ) { + $this->unionSupportsOrderAndLimit = (bool)$v; + } +} diff --git a/www/wiki/tests/phpunit/includes/db/LBFactoryTest.php b/www/wiki/tests/phpunit/includes/db/LBFactoryTest.php new file mode 100644 index 00000000..ed4c977f --- /dev/null +++ b/www/wiki/tests/phpunit/includes/db/LBFactoryTest.php @@ -0,0 +1,530 @@ +<?php +/** + * Holds tests for LBFactory abstract MediaWiki class. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * http://www.gnu.org/copyleft/gpl.html + * + * @file + * @author Antoine Musso + * @copyright © 2013 Antoine Musso + * @copyright © 2013 Wikimedia Foundation Inc. + */ + +use Wikimedia\Rdbms\LBFactorySimple; +use Wikimedia\Rdbms\LBFactoryMulti; +use Wikimedia\Rdbms\LoadBalancer; +use Wikimedia\Rdbms\ChronologyProtector; +use Wikimedia\Rdbms\DatabaseMysqli; +use Wikimedia\Rdbms\MySQLMasterPos; +use Wikimedia\Rdbms\DatabaseDomain; + +/** + * @group Database + * @covers \Wikimedia\Rdbms\LBFactorySimple + * @covers \Wikimedia\Rdbms\LBFactoryMulti + */ +class LBFactoryTest extends MediaWikiTestCase { + + /** + * @covers MWLBFactory::getLBFactoryClass + * @dataProvider getLBFactoryClassProvider + */ + public function testGetLBFactoryClass( $expected, $deprecated ) { + $mockDB = $this->getMockBuilder( DatabaseMysqli::class ) + ->disableOriginalConstructor() + ->getMock(); + + $config = [ + 'class' => $deprecated, + 'connection' => $mockDB, + # Various other parameters required: + 'sectionsByDB' => [], + 'sectionLoads' => [], + 'serverTemplate' => [], + ]; + + $this->hideDeprecated( '$wgLBFactoryConf must be updated. See RELEASE-NOTES for details' ); + $result = MWLBFactory::getLBFactoryClass( $config ); + + $this->assertEquals( $expected, $result ); + } + + public function getLBFactoryClassProvider() { + return [ + # Format: new class, old class + [ Wikimedia\Rdbms\LBFactorySimple::class, 'LBFactory_Simple' ], + [ Wikimedia\Rdbms\LBFactorySingle::class, 'LBFactory_Single' ], + [ Wikimedia\Rdbms\LBFactoryMulti::class, 'LBFactory_Multi' ], + [ Wikimedia\Rdbms\LBFactorySimple::class, 'LBFactorySimple' ], + [ Wikimedia\Rdbms\LBFactorySingle::class, 'LBFactorySingle' ], + [ Wikimedia\Rdbms\LBFactoryMulti::class, 'LBFactoryMulti' ], + ]; + } + + public function testLBFactorySimpleServer() { + global $wgDBserver, $wgDBname, $wgDBuser, $wgDBpassword, $wgDBtype, $wgSQLiteDataDir; + + $servers = [ + [ + 'host' => $wgDBserver, + 'dbname' => $wgDBname, + 'user' => $wgDBuser, + 'password' => $wgDBpassword, + 'type' => $wgDBtype, + 'dbDirectory' => $wgSQLiteDataDir, + 'load' => 0, + 'flags' => DBO_TRX // REPEATABLE-READ for consistency + ], + ]; + + $factory = new LBFactorySimple( [ 'servers' => $servers ] ); + $lb = $factory->getMainLB(); + + $dbw = $lb->getConnection( DB_MASTER ); + $this->assertTrue( $dbw->getLBInfo( 'master' ), 'master shows as master' ); + + $dbr = $lb->getConnection( DB_REPLICA ); + $this->assertTrue( $dbr->getLBInfo( 'master' ), 'DB_REPLICA also gets the master' ); + + $factory->shutdown(); + $lb->closeAll(); + } + + public function testLBFactorySimpleServers() { + global $wgDBserver, $wgDBname, $wgDBuser, $wgDBpassword, $wgDBtype, $wgSQLiteDataDir; + + $servers = [ + [ // master + 'host' => $wgDBserver, + 'dbname' => $wgDBname, + 'user' => $wgDBuser, + 'password' => $wgDBpassword, + 'type' => $wgDBtype, + 'dbDirectory' => $wgSQLiteDataDir, + 'load' => 0, + 'flags' => DBO_TRX // REPEATABLE-READ for consistency + ], + [ // emulated slave + 'host' => $wgDBserver, + 'dbname' => $wgDBname, + 'user' => $wgDBuser, + 'password' => $wgDBpassword, + 'type' => $wgDBtype, + 'dbDirectory' => $wgSQLiteDataDir, + 'load' => 100, + 'flags' => DBO_TRX // REPEATABLE-READ for consistency + ] + ]; + + $factory = new LBFactorySimple( [ + 'servers' => $servers, + 'loadMonitorClass' => LoadMonitorNull::class + ] ); + $lb = $factory->getMainLB(); + + $dbw = $lb->getConnection( DB_MASTER ); + $this->assertTrue( $dbw->getLBInfo( 'master' ), 'master shows as master' ); + $this->assertEquals( + ( $wgDBserver != '' ) ? $wgDBserver : 'localhost', + $dbw->getLBInfo( 'clusterMasterHost' ), + 'cluster master set' ); + + $dbr = $lb->getConnection( DB_REPLICA ); + $this->assertTrue( $dbr->getLBInfo( 'replica' ), 'slave shows as slave' ); + $this->assertEquals( + ( $wgDBserver != '' ) ? $wgDBserver : 'localhost', + $dbr->getLBInfo( 'clusterMasterHost' ), + 'cluster master set' ); + + $factory->shutdown(); + $lb->closeAll(); + } + + public function testLBFactoryMulti() { + global $wgDBserver, $wgDBname, $wgDBuser, $wgDBpassword, $wgDBtype, $wgSQLiteDataDir; + + $factory = new LBFactoryMulti( [ + 'sectionsByDB' => [], + 'sectionLoads' => [ + 'DEFAULT' => [ + 'test-db1' => 0, + 'test-db2' => 100, + ], + ], + 'serverTemplate' => [ + 'dbname' => $wgDBname, + 'user' => $wgDBuser, + 'password' => $wgDBpassword, + 'type' => $wgDBtype, + 'dbDirectory' => $wgSQLiteDataDir, + 'flags' => DBO_DEFAULT + ], + 'hostsByName' => [ + 'test-db1' => $wgDBserver, + 'test-db2' => $wgDBserver + ], + 'loadMonitorClass' => LoadMonitorNull::class + ] ); + $lb = $factory->getMainLB(); + + $dbw = $lb->getConnection( DB_MASTER ); + $this->assertTrue( $dbw->getLBInfo( 'master' ), 'master shows as master' ); + + $dbr = $lb->getConnection( DB_REPLICA ); + $this->assertTrue( $dbr->getLBInfo( 'replica' ), 'slave shows as slave' ); + + $factory->shutdown(); + $lb->closeAll(); + } + + /** + * @covers \Wikimedia\Rdbms\ChronologyProtector + */ + public function testChronologyProtector() { + $now = microtime( true ); + + // (a) First HTTP request + $m1Pos = new MySQLMasterPos( 'db1034-bin.000976/843431247', $now ); + $m2Pos = new MySQLMasterPos( 'db1064-bin.002400/794074907', $now ); + + // Master DB 1 + $mockDB1 = $this->getMockBuilder( DatabaseMysqli::class ) + ->disableOriginalConstructor() + ->getMock(); + $mockDB1->method( 'writesOrCallbacksPending' )->willReturn( true ); + $mockDB1->method( 'lastDoneWrites' )->willReturn( $now ); + $mockDB1->method( 'getMasterPos' )->willReturn( $m1Pos ); + // Load balancer for master DB 1 + $lb1 = $this->getMockBuilder( LoadBalancer::class ) + ->disableOriginalConstructor() + ->getMock(); + $lb1->method( 'getConnection' )->willReturn( $mockDB1 ); + $lb1->method( 'getServerCount' )->willReturn( 2 ); + $lb1->method( 'getAnyOpenConnection' )->willReturn( $mockDB1 ); + $lb1->method( 'hasOrMadeRecentMasterChanges' )->will( $this->returnCallback( + function () use ( $mockDB1 ) { + $p = 0; + $p |= call_user_func( [ $mockDB1, 'writesOrCallbacksPending' ] ); + $p |= call_user_func( [ $mockDB1, 'lastDoneWrites' ] ); + + return (bool)$p; + } + ) ); + $lb1->method( 'getMasterPos' )->willReturn( $m1Pos ); + $lb1->method( 'getServerName' )->with( 0 )->willReturn( 'master1' ); + // Master DB 2 + $mockDB2 = $this->getMockBuilder( DatabaseMysqli::class ) + ->disableOriginalConstructor() + ->getMock(); + $mockDB2->method( 'writesOrCallbacksPending' )->willReturn( true ); + $mockDB2->method( 'lastDoneWrites' )->willReturn( $now ); + $mockDB2->method( 'getMasterPos' )->willReturn( $m2Pos ); + // Load balancer for master DB 2 + $lb2 = $this->getMockBuilder( LoadBalancer::class ) + ->disableOriginalConstructor() + ->getMock(); + $lb2->method( 'getConnection' )->willReturn( $mockDB2 ); + $lb2->method( 'getServerCount' )->willReturn( 2 ); + $lb2->method( 'getAnyOpenConnection' )->willReturn( $mockDB2 ); + $lb2->method( 'hasOrMadeRecentMasterChanges' )->will( $this->returnCallback( + function () use ( $mockDB2 ) { + $p = 0; + $p |= call_user_func( [ $mockDB2, 'writesOrCallbacksPending' ] ); + $p |= call_user_func( [ $mockDB2, 'lastDoneWrites' ] ); + + return (bool)$p; + } + ) ); + $lb2->method( 'getMasterPos' )->willReturn( $m2Pos ); + $lb2->method( 'getServerName' )->with( 0 )->willReturn( 'master2' ); + + $bag = new HashBagOStuff(); + $cp = new ChronologyProtector( + $bag, + [ + 'ip' => '127.0.0.1', + 'agent' => "Totally-Not-FireFox" + ] + ); + + $mockDB1->expects( $this->exactly( 1 ) )->method( 'writesOrCallbacksPending' ); + $mockDB1->expects( $this->exactly( 1 ) )->method( 'lastDoneWrites' ); + $mockDB2->expects( $this->exactly( 1 ) )->method( 'writesOrCallbacksPending' ); + $mockDB2->expects( $this->exactly( 1 ) )->method( 'lastDoneWrites' ); + + // Nothing to wait for on first HTTP request start + $cp->initLB( $lb1 ); + $cp->initLB( $lb2 ); + // Record positions in stash on first HTTP request end + $cp->shutdownLB( $lb1 ); + $cp->shutdownLB( $lb2 ); + $cpIndex = null; + $cp->shutdown( null, 'sync', $cpIndex ); + + $this->assertEquals( 1, $cpIndex, "CP write index set" ); + + // (b) Second HTTP request + + // Load balancer for master DB 1 + $lb1 = $this->getMockBuilder( LoadBalancer::class ) + ->disableOriginalConstructor() + ->getMock(); + $lb1->method( 'getServerCount' )->willReturn( 2 ); + $lb1->method( 'getServerName' )->with( 0 )->willReturn( 'master1' ); + $lb1->expects( $this->once() ) + ->method( 'waitFor' )->with( $this->equalTo( $m1Pos ) ); + // Load balancer for master DB 2 + $lb2 = $this->getMockBuilder( LoadBalancer::class ) + ->disableOriginalConstructor() + ->getMock(); + $lb2->method( 'getServerCount' )->willReturn( 2 ); + $lb2->method( 'getServerName' )->with( 0 )->willReturn( 'master2' ); + $lb2->expects( $this->once() ) + ->method( 'waitFor' )->with( $this->equalTo( $m2Pos ) ); + + $cp = new ChronologyProtector( + $bag, + [ + 'ip' => '127.0.0.1', + 'agent' => "Totally-Not-FireFox" + ], + $cpIndex + ); + + // Wait for last positions to be reached on second HTTP request start + $cp->initLB( $lb1 ); + $cp->initLB( $lb2 ); + // Shutdown (nothing to record) + $cp->shutdownLB( $lb1 ); + $cp->shutdownLB( $lb2 ); + $cpIndex = null; + $cp->shutdown( null, 'sync', $cpIndex ); + + $this->assertEquals( null, $cpIndex, "CP write index retained" ); + } + + private function newLBFactoryMulti( array $baseOverride = [], array $serverOverride = [] ) { + global $wgDBserver, $wgDBuser, $wgDBpassword, $wgDBname, $wgDBprefix, $wgDBtype; + global $wgSQLiteDataDir; + + return new LBFactoryMulti( $baseOverride + [ + 'sectionsByDB' => [], + 'sectionLoads' => [ + 'DEFAULT' => [ + 'test-db1' => 1, + ], + ], + 'serverTemplate' => $serverOverride + [ + 'dbname' => $wgDBname, + 'tablePrefix' => $wgDBprefix, + 'user' => $wgDBuser, + 'password' => $wgDBpassword, + 'type' => $wgDBtype, + 'dbDirectory' => $wgSQLiteDataDir, + 'flags' => DBO_DEFAULT + ], + 'hostsByName' => [ + 'test-db1' => $wgDBserver, + ], + 'loadMonitorClass' => LoadMonitorNull::class, + 'localDomain' => new DatabaseDomain( $wgDBname, null, $wgDBprefix ) + ] ); + } + + public function testNiceDomains() { + global $wgDBname; + + if ( wfGetDB( DB_MASTER )->databasesAreIndependent() ) { + self::markTestSkipped( "Skipping tests about selecting DBs: not applicable" ); + return; + } + + $factory = $this->newLBFactoryMulti( + [], + [] + ); + $lb = $factory->getMainLB(); + + $db = $lb->getConnectionRef( DB_MASTER ); + $this->assertEquals( + wfWikiID(), + $db->getDomainID() + ); + unset( $db ); + + /** @var Database $db */ + $db = $lb->getConnection( DB_MASTER, [], '' ); + + $this->assertEquals( + '', + $db->getDomainId(), + 'Null domain ID handle used' + ); + $this->assertEquals( + '', + $db->getDBname(), + 'Null domain ID handle used' + ); + $this->assertEquals( + '', + $db->tablePrefix(), + 'Main domain ID handle used; prefix is empty though' + ); + $this->assertEquals( + $this->quoteTable( $db, 'page' ), + $db->tableName( 'page' ), + "Correct full table name" + ); + $this->assertEquals( + $this->quoteTable( $db, $wgDBname ) . '.' . $this->quoteTable( $db, 'page' ), + $db->tableName( "$wgDBname.page" ), + "Correct full table name" + ); + $this->assertEquals( + $this->quoteTable( $db, 'nice_db' ) . '.' . $this->quoteTable( $db, 'page' ), + $db->tableName( 'nice_db.page' ), + "Correct full table name" + ); + + $lb->reuseConnection( $db ); // don't care + + $db = $lb->getConnection( DB_MASTER ); // local domain connection + $factory->setDomainPrefix( 'my_' ); + + $this->assertEquals( $wgDBname, $db->getDBname() ); + $this->assertEquals( + "$wgDBname-my_", + $db->getDomainID() + ); + $this->assertEquals( + $this->quoteTable( $db, 'my_page' ), + $db->tableName( 'page' ), + "Correct full table name" + ); + $this->assertEquals( + $this->quoteTable( $db, 'other_nice_db' ) . '.' . $this->quoteTable( $db, 'page' ), + $db->tableName( 'other_nice_db.page' ), + "Correct full table name" + ); + + $factory->closeAll(); + $factory->destroy(); + } + + public function testTrickyDomain() { + global $wgDBname; + + if ( wfGetDB( DB_MASTER )->databasesAreIndependent() ) { + self::markTestSkipped( "Skipping tests about selecting DBs: not applicable" ); + return; + } + + $dbname = 'unittest-domain'; // explodes if DB is selected + $factory = $this->newLBFactoryMulti( + [ 'localDomain' => ( new DatabaseDomain( $dbname, null, '' ) )->getId() ], + [ + 'dbName' => 'do_not_select_me' // explodes if DB is selected + ] + ); + $lb = $factory->getMainLB(); + /** @var Database $db */ + $db = $lb->getConnection( DB_MASTER, [], '' ); + + $this->assertEquals( '', $db->getDomainID(), "Null domain used" ); + + $this->assertEquals( + $this->quoteTable( $db, 'page' ), + $db->tableName( 'page' ), + "Correct full table name" + ); + + $this->assertEquals( + $this->quoteTable( $db, $dbname ) . '.' . $this->quoteTable( $db, 'page' ), + $db->tableName( "$dbname.page" ), + "Correct full table name" + ); + + $this->assertEquals( + $this->quoteTable( $db, 'nice_db' ) . '.' . $this->quoteTable( $db, 'page' ), + $db->tableName( 'nice_db.page' ), + "Correct full table name" + ); + + $lb->reuseConnection( $db ); // don't care + + $factory->setDomainPrefix( 'my_' ); + $db = $lb->getConnection( DB_MASTER, [], "$wgDBname-my_" ); + + $this->assertEquals( + $this->quoteTable( $db, 'my_page' ), + $db->tableName( 'page' ), + "Correct full table name" + ); + $this->assertEquals( + $this->quoteTable( $db, 'other_nice_db' ) . '.' . $this->quoteTable( $db, 'page' ), + $db->tableName( 'other_nice_db.page' ), + "Correct full table name" + ); + $this->assertEquals( + $this->quoteTable( $db, 'garbage-db' ) . '.' . $this->quoteTable( $db, 'page' ), + $db->tableName( 'garbage-db.page' ), + "Correct full table name" + ); + + $lb->reuseConnection( $db ); // don't care + + $factory->closeAll(); + $factory->destroy(); + } + + public function testInvalidSelectDB() { + $dbname = 'unittest-domain'; // explodes if DB is selected + $factory = $this->newLBFactoryMulti( + [ 'localDomain' => ( new DatabaseDomain( $dbname, null, '' ) )->getId() ], + [ + 'dbName' => 'do_not_select_me' // explodes if DB is selected + ] + ); + $lb = $factory->getMainLB(); + /** @var Database $db */ + $db = $lb->getConnection( DB_MASTER, [], '' ); + + if ( $db->getType() === 'sqlite' ) { + $this->assertFalse( $db->selectDB( 'garbage-db' ) ); + } elseif ( $db->databasesAreIndependent() ) { + try { + $e = null; + $db->selectDB( 'garbage-db' ); + } catch ( \Wikimedia\Rdbms\DBConnectionError $e ) { + // expected + } + $this->assertInstanceOf( \Wikimedia\Rdbms\DBConnectionError::class, $e ); + $this->assertFalse( $db->isOpen() ); + } else { + \Wikimedia\suppressWarnings(); + $this->assertFalse( $db->selectDB( 'garbage-db' ) ); + \Wikimedia\restoreWarnings(); + } + } + + private function quoteTable( Database $db, $table ) { + if ( $db->getType() === 'sqlite' ) { + return $table; + } else { + return $db->addIdentifierQuotes( $table ); + } + } +} diff --git a/www/wiki/tests/phpunit/includes/db/LoadBalancerTest.php b/www/wiki/tests/phpunit/includes/db/LoadBalancerTest.php new file mode 100644 index 00000000..e054569d --- /dev/null +++ b/www/wiki/tests/phpunit/includes/db/LoadBalancerTest.php @@ -0,0 +1,305 @@ +<?php + +/** + * Holds tests for LoadBalancer MediaWiki class. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * http://www.gnu.org/copyleft/gpl.html + * + * @file + */ + +use Wikimedia\Rdbms\DBError; +use Wikimedia\Rdbms\DatabaseDomain; +use Wikimedia\Rdbms\Database; +use Wikimedia\Rdbms\LoadBalancer; +use Wikimedia\Rdbms\LoadMonitorNull; + +/** + * @group Database + * @covers \Wikimedia\Rdbms\LoadBalancer + */ +class LoadBalancerTest extends MediaWikiTestCase { + private function makeServerConfig() { + global $wgDBserver, $wgDBname, $wgDBuser, $wgDBpassword, $wgDBtype, $wgSQLiteDataDir; + + return [ + 'host' => $wgDBserver, + 'dbname' => $wgDBname, + 'tablePrefix' => $this->dbPrefix(), + 'user' => $wgDBuser, + 'password' => $wgDBpassword, + 'type' => $wgDBtype, + 'dbDirectory' => $wgSQLiteDataDir, + 'load' => 0, + 'flags' => DBO_TRX // REPEATABLE-READ for consistency + ]; + } + + public function testWithoutReplica() { + global $wgDBname; + + $called = false; + $lb = new LoadBalancer( [ + 'servers' => [ $this->makeServerConfig() ], + 'queryLogger' => MediaWiki\Logger\LoggerFactory::getInstance( 'DBQuery' ), + 'localDomain' => new DatabaseDomain( $wgDBname, null, $this->dbPrefix() ), + 'chronologyCallback' => function () use ( &$called ) { + $called = true; + } + ] ); + + $ld = DatabaseDomain::newFromId( $lb->getLocalDomainID() ); + $this->assertEquals( $wgDBname, $ld->getDatabase(), 'local domain DB set' ); + $this->assertEquals( $this->dbPrefix(), $ld->getTablePrefix(), 'local domain prefix set' ); + + $this->assertFalse( $called ); + $dbw = $lb->getConnection( DB_MASTER ); + $this->assertTrue( $called ); + $this->assertTrue( $dbw->getLBInfo( 'master' ), 'master shows as master' ); + $this->assertTrue( $dbw->getFlag( $dbw::DBO_TRX ), "DBO_TRX set on master" ); + $this->assertWriteAllowed( $dbw ); + + $dbr = $lb->getConnection( DB_REPLICA ); + $this->assertTrue( $dbr->getLBInfo( 'master' ), 'DB_REPLICA also gets the master' ); + $this->assertTrue( $dbr->getFlag( $dbw::DBO_TRX ), "DBO_TRX set on replica" ); + + if ( !$lb->getServerAttributes( $lb->getWriterIndex() )[$dbw::ATTR_DB_LEVEL_LOCKING] ) { + $dbwAuto = $lb->getConnection( DB_MASTER, [], false, $lb::CONN_TRX_AUTOCOMMIT ); + $this->assertFalse( + $dbwAuto->getFlag( $dbw::DBO_TRX ), "No DBO_TRX with CONN_TRX_AUTOCOMMIT" ); + $this->assertTrue( $dbw->getFlag( $dbw::DBO_TRX ), "DBO_TRX still set on master" ); + $this->assertNotEquals( + $dbw, $dbwAuto, "CONN_TRX_AUTOCOMMIT uses separate connection" ); + + $dbrAuto = $lb->getConnection( DB_REPLICA, [], false, $lb::CONN_TRX_AUTOCOMMIT ); + $this->assertFalse( + $dbrAuto->getFlag( $dbw::DBO_TRX ), "No DBO_TRX with CONN_TRX_AUTOCOMMIT" ); + $this->assertTrue( $dbr->getFlag( $dbw::DBO_TRX ), "DBO_TRX still set on replica" ); + $this->assertNotEquals( + $dbr, $dbrAuto, "CONN_TRX_AUTOCOMMIT uses separate connection" ); + + $dbwAuto2 = $lb->getConnection( DB_MASTER, [], false, $lb::CONN_TRX_AUTOCOMMIT ); + $this->assertEquals( $dbwAuto2, $dbwAuto, "CONN_TRX_AUTOCOMMIT reuses connections" ); + } + + $lb->closeAll(); + } + + public function testWithReplica() { + global $wgDBserver, $wgDBname, $wgDBuser, $wgDBpassword, $wgDBtype, $wgSQLiteDataDir; + + $servers = [ + [ // master + 'host' => $wgDBserver, + 'dbname' => $wgDBname, + 'tablePrefix' => $this->dbPrefix(), + 'user' => $wgDBuser, + 'password' => $wgDBpassword, + 'type' => $wgDBtype, + 'dbDirectory' => $wgSQLiteDataDir, + 'load' => 0, + 'flags' => DBO_TRX // REPEATABLE-READ for consistency + ], + [ // emulated replica + 'host' => $wgDBserver, + 'dbname' => $wgDBname, + 'tablePrefix' => $this->dbPrefix(), + 'user' => $wgDBuser, + 'password' => $wgDBpassword, + 'type' => $wgDBtype, + 'dbDirectory' => $wgSQLiteDataDir, + 'load' => 100, + 'flags' => DBO_TRX // REPEATABLE-READ for consistency + ] + ]; + + $lb = new LoadBalancer( [ + 'servers' => $servers, + 'localDomain' => new DatabaseDomain( $wgDBname, null, $this->dbPrefix() ), + 'queryLogger' => MediaWiki\Logger\LoggerFactory::getInstance( 'DBQuery' ), + 'loadMonitorClass' => LoadMonitorNull::class + ] ); + + $dbw = $lb->getConnection( DB_MASTER ); + $this->assertTrue( $dbw->getLBInfo( 'master' ), 'master shows as master' ); + $this->assertEquals( + ( $wgDBserver != '' ) ? $wgDBserver : 'localhost', + $dbw->getLBInfo( 'clusterMasterHost' ), + 'cluster master set' ); + $this->assertTrue( $dbw->getFlag( $dbw::DBO_TRX ), "DBO_TRX set on master" ); + $this->assertWriteAllowed( $dbw ); + + $dbr = $lb->getConnection( DB_REPLICA ); + $this->assertTrue( $dbr->getLBInfo( 'replica' ), 'replica shows as replica' ); + $this->assertEquals( + ( $wgDBserver != '' ) ? $wgDBserver : 'localhost', + $dbr->getLBInfo( 'clusterMasterHost' ), + 'cluster master set' ); + $this->assertTrue( $dbr->getFlag( $dbw::DBO_TRX ), "DBO_TRX set on replica" ); + $this->assertWriteForbidden( $dbr ); + + if ( !$lb->getServerAttributes( $lb->getWriterIndex() )[$dbw::ATTR_DB_LEVEL_LOCKING] ) { + $dbwAuto = $lb->getConnection( DB_MASTER, [], false, $lb::CONN_TRX_AUTOCOMMIT ); + $this->assertFalse( + $dbwAuto->getFlag( $dbw::DBO_TRX ), "No DBO_TRX with CONN_TRX_AUTOCOMMIT" ); + $this->assertTrue( $dbw->getFlag( $dbw::DBO_TRX ), "DBO_TRX still set on master" ); + $this->assertNotEquals( + $dbw, $dbwAuto, "CONN_TRX_AUTOCOMMIT uses separate connection" ); + + $dbrAuto = $lb->getConnection( DB_REPLICA, [], false, $lb::CONN_TRX_AUTOCOMMIT ); + $this->assertFalse( + $dbrAuto->getFlag( $dbw::DBO_TRX ), "No DBO_TRX with CONN_TRX_AUTOCOMMIT" ); + $this->assertTrue( $dbr->getFlag( $dbw::DBO_TRX ), "DBO_TRX still set on replica" ); + $this->assertNotEquals( + $dbr, $dbrAuto, "CONN_TRX_AUTOCOMMIT uses separate connection" ); + + $dbwAuto2 = $lb->getConnection( DB_MASTER, [], false, $lb::CONN_TRX_AUTOCOMMIT ); + $this->assertEquals( $dbwAuto2, $dbwAuto, "CONN_TRX_AUTOCOMMIT reuses connections" ); + } + + $lb->closeAll(); + } + + private function assertWriteForbidden( Database $db ) { + try { + $db->delete( 'some_table', [ 'id' => 57634126 ], __METHOD__ ); + $this->fail( 'Write operation should have failed!' ); + } catch ( DBError $ex ) { + // check that the exception message contains "Write operation" + $constraint = new PHPUnit_Framework_Constraint_StringContains( 'Write operation' ); + + if ( !$constraint->evaluate( $ex->getMessage(), '', true ) ) { + // re-throw original error, to preserve stack trace + throw $ex; + } + } + } + + private function assertWriteAllowed( Database $db ) { + $table = $db->tableName( 'some_table' ); + try { + $db->dropTable( 'some_table' ); // clear for sanity + + // Trigger DBO_TRX to create a transaction so the flush below will + // roll everything here back in sqlite. But don't actually do the + // code below inside an atomic section becaue MySQL and Oracle + // auto-commit transactions for DDL statements like CREATE TABLE. + $db->startAtomic( __METHOD__ ); + $db->endAtomic( __METHOD__ ); + + // Use only basic SQL and trivial types for these queries for compatibility + $this->assertNotSame( + false, + $db->query( "CREATE TABLE $table (id INT, time INT)", __METHOD__ ), + "table created" + ); + $this->assertNotSame( + false, + $db->query( "DELETE FROM $table WHERE id=57634126", __METHOD__ ), + "delete query" + ); + } finally { + // Drop the table to clean up, ignoring any error. + $db->query( "DROP TABLE $table", __METHOD__, true ); + // Rollback the DBO_TRX transaction for sqlite's benefit. + $db->rollback( __METHOD__, 'flush' ); + } + } + + public function testServerAttributes() { + $servers = [ + [ // master + 'dbname' => 'my_unittest_wiki', + 'tablePrefix' => 'unittest_', + 'type' => 'sqlite', + 'dbDirectory' => "some_directory", + 'load' => 0 + ] + ]; + + $lb = new LoadBalancer( [ + 'servers' => $servers, + 'localDomain' => new DatabaseDomain( 'my_unittest_wiki', null, 'unittest_' ), + 'loadMonitorClass' => LoadMonitorNull::class + ] ); + + $this->assertTrue( $lb->getServerAttributes( 0 )[Database::ATTR_DB_LEVEL_LOCKING] ); + + $servers = [ + [ // master + 'host' => 'db1001', + 'user' => 'wikiuser', + 'password' => 'none', + 'dbname' => 'my_unittest_wiki', + 'tablePrefix' => 'unittest_', + 'type' => 'mysql', + 'load' => 100 + ], + [ // emulated replica + 'host' => 'db1002', + 'user' => 'wikiuser', + 'password' => 'none', + 'dbname' => 'my_unittest_wiki', + 'tablePrefix' => 'unittest_', + 'type' => 'mysql', + 'load' => 100 + ] + ]; + + $lb = new LoadBalancer( [ + 'servers' => $servers, + 'localDomain' => new DatabaseDomain( 'my_unittest_wiki', null, 'unittest_' ), + 'loadMonitorClass' => LoadMonitorNull::class + ] ); + + $this->assertFalse( $lb->getServerAttributes( 1 )[Database::ATTR_DB_LEVEL_LOCKING] ); + } + + /** + * @covers LoadBalancer::openConnection() + * @covers LoadBalancer::getAnyOpenConnection() + */ + function testOpenConnection() { + global $wgDBname; + + $lb = new LoadBalancer( [ + 'servers' => [ $this->makeServerConfig() ], + 'localDomain' => new DatabaseDomain( $wgDBname, null, $this->dbPrefix() ) + ] ); + + $i = $lb->getWriterIndex(); + $this->assertEquals( null, $lb->getAnyOpenConnection( $i ) ); + $conn1 = $lb->getConnection( $i ); + $this->assertNotEquals( null, $conn1 ); + $this->assertEquals( $conn1, $lb->getAnyOpenConnection( $i ) ); + $conn2 = $lb->getConnection( $i, [], false, $lb::CONN_TRX_AUTOCOMMIT ); + $this->assertNotEquals( null, $conn2 ); + if ( $lb->getServerAttributes( $i )[Database::ATTR_DB_LEVEL_LOCKING] ) { + $this->assertEquals( null, + $lb->getAnyOpenConnection( $i, $lb::CONN_TRX_AUTOCOMMIT ) ); + $this->assertEquals( $conn1, + $lb->getConnection( + $i, [], false, $lb::CONN_TRX_AUTOCOMMIT ), $lb::CONN_TRX_AUTOCOMMIT ); + } else { + $this->assertEquals( $conn2, + $lb->getAnyOpenConnection( $i, $lb::CONN_TRX_AUTOCOMMIT ) ); + $this->assertEquals( $conn2, + $lb->getConnection( $i, [], false, $lb::CONN_TRX_AUTOCOMMIT ) ); + } + + $lb->closeAll(); + } +} |