1<?php 2 3use Wikimedia\Rdbms\Database; 4use Wikimedia\Rdbms\DatabaseDomain; 5use Wikimedia\Rdbms\DatabaseMysqli; 6use Wikimedia\Rdbms\DatabasePostgres; 7use Wikimedia\Rdbms\DatabaseSqlite; 8use Wikimedia\Rdbms\DBReadOnlyRoleError; 9use Wikimedia\Rdbms\DBUnexpectedError; 10use Wikimedia\Rdbms\IDatabase; 11use Wikimedia\Rdbms\IResultWrapper; 12use Wikimedia\Rdbms\LBFactorySingle; 13use Wikimedia\Rdbms\TransactionProfiler; 14use Wikimedia\TestingAccessWrapper; 15 16class DatabaseTest extends PHPUnit\Framework\TestCase { 17 18 use MediaWikiCoversValidator; 19 20 /** @var DatabaseTestHelper */ 21 private $db; 22 23 protected function setUp() : void { 24 $this->db = new DatabaseTestHelper( __CLASS__ . '::' . $this->getName() ); 25 } 26 27 /** 28 * @covers Wikimedia\Rdbms\Database::factory 29 */ 30 public function testFactory() { 31 $m = Database::NEW_UNCONNECTED; // no-connect mode 32 $p = [ 'host' => 'localhost', 'user' => 'me', 'password' => 'myself', 'dbname' => 'i' ]; 33 34 $this->assertInstanceOf( DatabaseMysqli::class, Database::factory( 'mysqli', $p, $m ) ); 35 $this->assertInstanceOf( DatabaseMysqli::class, Database::factory( 'MySqli', $p, $m ) ); 36 $this->assertInstanceOf( DatabaseMysqli::class, Database::factory( 'MySQLi', $p, $m ) ); 37 $this->assertInstanceOf( DatabasePostgres::class, Database::factory( 'postgres', $p, $m ) ); 38 $this->assertInstanceOf( DatabasePostgres::class, Database::factory( 'Postgres', $p, $m ) ); 39 40 $x = $p + [ 'dbFilePath' => 'some/file.sqlite' ]; 41 $this->assertInstanceOf( DatabaseSqlite::class, Database::factory( 'sqlite', $x, $m ) ); 42 $x = $p + [ 'dbDirectory' => 'some/file' ]; 43 $this->assertInstanceOf( DatabaseSqlite::class, Database::factory( 'sqlite', $x, $m ) ); 44 } 45 46 public static function provideAddQuotes() { 47 return [ 48 [ null, 'NULL' ], 49 [ 1234, "1234" ], 50 [ 1234.5678, "'1234.5678'" ], 51 [ 'string', "'string'" ], 52 [ 'string\'s cause trouble', "'string\'s cause trouble'" ], 53 ]; 54 } 55 56 /** 57 * @dataProvider provideAddQuotes 58 * @covers Wikimedia\Rdbms\Database::addQuotes 59 */ 60 public function testAddQuotes( $input, $expected ) { 61 $this->assertEquals( $expected, $this->db->addQuotes( $input ) ); 62 } 63 64 public static function provideTableName() { 65 // Formatting is mostly ignored since addIdentifierQuotes is abstract. 66 // For testing of addIdentifierQuotes, see actual Database subclas tests. 67 return [ 68 'local' => [ 69 'tablename', 70 'tablename', 71 'quoted', 72 ], 73 'local-raw' => [ 74 'tablename', 75 'tablename', 76 'raw', 77 ], 78 'shared' => [ 79 'sharedb.tablename', 80 'tablename', 81 'quoted', 82 [ 'dbname' => 'sharedb', 'schema' => null, 'prefix' => '' ], 83 ], 84 'shared-raw' => [ 85 'sharedb.tablename', 86 'tablename', 87 'raw', 88 [ 'dbname' => 'sharedb', 'schema' => null, 'prefix' => '' ], 89 ], 90 'shared-prefix' => [ 91 'sharedb.sh_tablename', 92 'tablename', 93 'quoted', 94 [ 'dbname' => 'sharedb', 'schema' => null, 'prefix' => 'sh_' ], 95 ], 96 'shared-prefix-raw' => [ 97 'sharedb.sh_tablename', 98 'tablename', 99 'raw', 100 [ 'dbname' => 'sharedb', 'schema' => null, 'prefix' => 'sh_' ], 101 ], 102 'foreign' => [ 103 'databasename.tablename', 104 'databasename.tablename', 105 'quoted', 106 ], 107 'foreign-raw' => [ 108 'databasename.tablename', 109 'databasename.tablename', 110 'raw', 111 ], 112 ]; 113 } 114 115 /** 116 * @dataProvider provideTableName 117 * @covers Wikimedia\Rdbms\Database::tableName 118 */ 119 public function testTableName( $expected, $table, $format, array $alias = null ) { 120 if ( $alias ) { 121 $this->db->setTableAliases( [ $table => $alias ] ); 122 } 123 $this->assertEquals( 124 $expected, 125 $this->db->tableName( $table, $format ?: 'quoted' ) 126 ); 127 } 128 129 public function provideTableNamesWithIndexClauseOrJOIN() { 130 return [ 131 'one-element array' => [ 132 [ 'table' ], [], 'table ' 133 ], 134 'comma join' => [ 135 [ 'table1', 'table2' ], [], 'table1,table2 ' 136 ], 137 'real join' => [ 138 [ 'table1', 'table2' ], 139 [ 'table2' => [ 'LEFT JOIN', 't1_id = t2_id' ] ], 140 'table1 LEFT JOIN table2 ON ((t1_id = t2_id))' 141 ], 142 'real join with multiple conditionals' => [ 143 [ 'table1', 'table2' ], 144 [ 'table2' => [ 'LEFT JOIN', [ 't1_id = t2_id', 't2_x = \'X\'' ] ] ], 145 'table1 LEFT JOIN table2 ON ((t1_id = t2_id) AND (t2_x = \'X\'))' 146 ], 147 'join with parenthesized group' => [ 148 [ 'table1', 'n' => [ 'table2', 'table3' ] ], 149 [ 150 'table3' => [ 'JOIN', 't2_id = t3_id' ], 151 'n' => [ 'LEFT JOIN', 't1_id = t2_id' ], 152 ], 153 'table1 LEFT JOIN (table2 JOIN table3 ON ((t2_id = t3_id))) ON ((t1_id = t2_id))' 154 ], 155 'join with degenerate parenthesized group' => [ 156 [ 'table1', 'n' => [ 't2' => 'table2' ] ], 157 [ 158 'n' => [ 'LEFT JOIN', 't1_id = t2_id' ], 159 ], 160 'table1 LEFT JOIN table2 t2 ON ((t1_id = t2_id))' 161 ], 162 ]; 163 } 164 165 /** 166 * @dataProvider provideTableNamesWithIndexClauseOrJOIN 167 * @covers Wikimedia\Rdbms\Database::tableNamesWithIndexClauseOrJOIN 168 */ 169 public function testTableNamesWithIndexClauseOrJOIN( $tables, $join_conds, $expect ) { 170 $clause = TestingAccessWrapper::newFromObject( $this->db ) 171 ->tableNamesWithIndexClauseOrJOIN( $tables, [], [], $join_conds ); 172 $this->assertSame( $expect, $clause ); 173 } 174 175 /** 176 * @covers Wikimedia\Rdbms\Database::onTransactionCommitOrIdle 177 * @covers Wikimedia\Rdbms\Database::runOnTransactionIdleCallbacks 178 */ 179 public function testTransactionIdle() { 180 $db = $this->db; 181 182 $db->clearFlag( DBO_TRX ); 183 $called = false; 184 $flagSet = null; 185 $callback = static function ( $trigger, IDatabase $db ) use ( &$flagSet, &$called ) { 186 $called = true; 187 $flagSet = $db->getFlag( DBO_TRX ); 188 }; 189 190 $db->onTransactionCommitOrIdle( $callback, __METHOD__ ); 191 $this->assertTrue( $called, 'Callback reached' ); 192 $this->assertFalse( $flagSet, 'DBO_TRX off in callback' ); 193 $this->assertFalse( $db->getFlag( DBO_TRX ), 'DBO_TRX still default' ); 194 195 $flagSet = null; 196 $called = false; 197 $db->startAtomic( __METHOD__ ); 198 $db->onTransactionCommitOrIdle( $callback, __METHOD__ ); 199 $this->assertFalse( $called, 'Callback not reached during TRX' ); 200 $db->endAtomic( __METHOD__ ); 201 202 $this->assertTrue( $called, 'Callback reached after COMMIT' ); 203 $this->assertFalse( $flagSet, 'DBO_TRX off in callback' ); 204 $this->assertFalse( $db->getFlag( DBO_TRX ), 'DBO_TRX restored to default' ); 205 206 $db->clearFlag( DBO_TRX ); 207 $db->onTransactionCommitOrIdle( 208 static function ( $trigger, IDatabase $db ) { 209 $db->setFlag( DBO_TRX ); 210 }, 211 __METHOD__ 212 ); 213 $this->assertFalse( $db->getFlag( DBO_TRX ), 'DBO_TRX restored to default' ); 214 } 215 216 /** 217 * @covers Wikimedia\Rdbms\Database::onTransactionCommitOrIdle 218 * @covers Wikimedia\Rdbms\Database::runOnTransactionIdleCallbacks 219 */ 220 public function testTransactionIdle_TRX() { 221 $db = $this->getMockDB( [ 'isOpen', 'ping', 'getDBname' ] ); 222 $db->method( 'isOpen' )->willReturn( true ); 223 $db->method( 'ping' )->willReturn( true ); 224 $db->method( 'getDBname' )->willReturn( '' ); 225 $db->setFlag( DBO_TRX ); 226 227 $lbFactory = LBFactorySingle::newFromConnection( $db ); 228 // Ask for the connection so that LB sets internal state 229 // about this connection being the master connection 230 $lb = $lbFactory->getMainLB(); 231 $conn = $lb->openConnection( $lb->getWriterIndex() ); 232 $this->assertSame( $db, $conn, 'Same DB instance' ); 233 $this->assertTrue( $db->getFlag( DBO_TRX ), 'DBO_TRX is set' ); 234 235 $called = false; 236 $flagSet = null; 237 $callback = static function () use ( $db, &$flagSet, &$called ) { 238 $called = true; 239 $flagSet = $db->getFlag( DBO_TRX ); 240 }; 241 242 $db->onTransactionCommitOrIdle( $callback, __METHOD__ ); 243 $this->assertTrue( $called, 'Called when idle if DBO_TRX is set' ); 244 $this->assertFalse( $flagSet, 'DBO_TRX off in callback' ); 245 $this->assertTrue( $db->getFlag( DBO_TRX ), 'DBO_TRX still default' ); 246 247 $called = false; 248 $lbFactory->beginMasterChanges( __METHOD__ ); 249 $db->onTransactionCommitOrIdle( $callback, __METHOD__ ); 250 $this->assertFalse( $called, 'Not called when lb-transaction is active' ); 251 252 $lbFactory->commitMasterChanges( __METHOD__ ); 253 $this->assertTrue( $called, 'Called when lb-transaction is committed' ); 254 255 $called = false; 256 $lbFactory->beginMasterChanges( __METHOD__ ); 257 $db->onTransactionCommitOrIdle( $callback, __METHOD__ ); 258 $this->assertFalse( $called, 'Not called when lb-transaction is active' ); 259 260 $lbFactory->rollbackMasterChanges( __METHOD__ ); 261 $this->assertFalse( $called, 'Not called when lb-transaction is rolled back' ); 262 263 $lbFactory->commitMasterChanges( __METHOD__ ); 264 $this->assertFalse( $called, 'Not called in next round commit' ); 265 266 $db->setFlag( DBO_TRX ); 267 try { 268 $db->onTransactionCommitOrIdle( static function () { 269 throw new RuntimeException( 'test' ); 270 } ); 271 $this->fail( "Exception not thrown" ); 272 } catch ( RuntimeException $e ) { 273 $this->assertTrue( $db->getFlag( DBO_TRX ) ); 274 } 275 } 276 277 /** 278 * @covers Wikimedia\Rdbms\Database::onTransactionPreCommitOrIdle 279 * @covers Wikimedia\Rdbms\Database::runOnTransactionPreCommitCallbacks 280 */ 281 public function testTransactionPreCommitOrIdle() { 282 $db = $this->getMockDB( [ 'isOpen' ] ); 283 $db->method( 'isOpen' )->willReturn( true ); 284 $db->clearFlag( DBO_TRX ); 285 286 $this->assertFalse( $db->getFlag( DBO_TRX ), 'DBO_TRX is not set' ); 287 288 $called = false; 289 $db->onTransactionPreCommitOrIdle( 290 static function ( IDatabase $db ) use ( &$called ) { 291 $called = true; 292 }, 293 __METHOD__ 294 ); 295 $this->assertTrue( $called, 'Called when idle' ); 296 297 $db->begin( __METHOD__ ); 298 $called = false; 299 $db->onTransactionPreCommitOrIdle( 300 static function ( IDatabase $db ) use ( &$called ) { 301 $called = true; 302 }, 303 __METHOD__ 304 ); 305 $this->assertFalse( $called, 'Not called when transaction is active' ); 306 $db->commit( __METHOD__ ); 307 $this->assertTrue( $called, 'Called when transaction is committed' ); 308 } 309 310 /** 311 * @covers Wikimedia\Rdbms\Database::onTransactionPreCommitOrIdle 312 * @covers Wikimedia\Rdbms\Database::runOnTransactionPreCommitCallbacks 313 */ 314 public function testTransactionPreCommitOrIdle_TRX() { 315 $db = $this->getMockDB( [ 'isOpen', 'ping', 'getDBname' ] ); 316 $db->method( 'isOpen' )->willReturn( true ); 317 $db->method( 'ping' )->willReturn( true ); 318 $db->method( 'getDBname' )->willReturn( 'unittest' ); 319 $db->setFlag( DBO_TRX ); 320 321 $lbFactory = LBFactorySingle::newFromConnection( $db ); 322 // Ask for the connection so that LB sets internal state 323 // about this connection being the master connection 324 $lb = $lbFactory->getMainLB(); 325 $conn = $lb->openConnection( $lb->getWriterIndex() ); 326 $this->assertSame( $db, $conn, 'Same DB instance' ); 327 328 $this->assertFalse( $lb->hasMasterChanges() ); 329 $this->assertTrue( $db->getFlag( DBO_TRX ), 'DBO_TRX is set' ); 330 $called = false; 331 $callback = static function ( IDatabase $db ) use ( &$called ) { 332 $called = true; 333 }; 334 $db->onTransactionPreCommitOrIdle( $callback, __METHOD__ ); 335 $this->assertTrue( $called, 'Called when idle if DBO_TRX is set' ); 336 $called = false; 337 $lbFactory->commitMasterChanges(); 338 $this->assertFalse( $called ); 339 340 $called = false; 341 $lbFactory->beginMasterChanges( __METHOD__ ); 342 $db->onTransactionPreCommitOrIdle( $callback, __METHOD__ ); 343 $this->assertFalse( $called, 'Not called when lb-transaction is active' ); 344 $lbFactory->commitMasterChanges( __METHOD__ ); 345 $this->assertTrue( $called, 'Called when lb-transaction is committed' ); 346 347 $called = false; 348 $lbFactory->beginMasterChanges( __METHOD__ ); 349 $db->onTransactionPreCommitOrIdle( $callback, __METHOD__ ); 350 $this->assertFalse( $called, 'Not called when lb-transaction is active' ); 351 352 $lbFactory->rollbackMasterChanges( __METHOD__ ); 353 $this->assertFalse( $called, 'Not called when lb-transaction is rolled back' ); 354 355 $lbFactory->commitMasterChanges( __METHOD__ ); 356 $this->assertFalse( $called, 'Not called in next round commit' ); 357 } 358 359 /** 360 * @covers Wikimedia\Rdbms\Database::onTransactionResolution 361 * @covers Wikimedia\Rdbms\Database::runOnTransactionIdleCallbacks 362 */ 363 public function testTransactionResolution() { 364 $db = $this->db; 365 366 $db->clearFlag( DBO_TRX ); 367 $db->begin( __METHOD__ ); 368 $called = false; 369 $db->onTransactionResolution( static function ( $trigger, IDatabase $db ) use ( &$called ) { 370 $called = true; 371 $db->setFlag( DBO_TRX ); 372 } ); 373 $db->commit( __METHOD__ ); 374 $this->assertFalse( $db->getFlag( DBO_TRX ), 'DBO_TRX restored to default' ); 375 $this->assertTrue( $called, 'Callback reached' ); 376 377 $db->clearFlag( DBO_TRX ); 378 $db->begin( __METHOD__ ); 379 $called = false; 380 $db->onTransactionResolution( static function ( $trigger, IDatabase $db ) use ( &$called ) { 381 $called = true; 382 $db->setFlag( DBO_TRX ); 383 } ); 384 $db->rollback( __METHOD__ ); 385 $this->assertFalse( $db->getFlag( DBO_TRX ), 'DBO_TRX restored to default' ); 386 $this->assertTrue( $called, 'Callback reached' ); 387 } 388 389 /** 390 * @covers Wikimedia\Rdbms\Database::setTransactionListener 391 */ 392 public function testTransactionListener() { 393 $db = $this->db; 394 395 $db->setTransactionListener( 'ping', static function () use ( $db, &$called ) { 396 $called = true; 397 } ); 398 399 $called = false; 400 $db->begin( __METHOD__ ); 401 $db->commit( __METHOD__ ); 402 $this->assertTrue( $called, 'Callback reached' ); 403 404 $called = false; 405 $db->begin( __METHOD__ ); 406 $db->commit( __METHOD__ ); 407 $this->assertTrue( $called, 'Callback still reached' ); 408 409 $called = false; 410 $db->begin( __METHOD__ ); 411 $db->rollback( __METHOD__ ); 412 $this->assertTrue( $called, 'Callback reached' ); 413 414 $db->setTransactionListener( 'ping', null ); 415 $called = false; 416 $db->begin( __METHOD__ ); 417 $db->commit( __METHOD__ ); 418 $this->assertFalse( $called, 'Callback not reached' ); 419 } 420 421 /** 422 * Use this mock instead of DatabaseTestHelper for cases where 423 * DatabaseTestHelper is too inflexibile due to mocking too much 424 * or being too restrictive about fname matching (e.g. for tests 425 * that assert behaviour when the name is a mismatch, we need to 426 * catch the error here instead of there). 427 * 428 * @param string[] $methods 429 * @return Database 430 */ 431 private function getMockDB( $methods = [] ) { 432 static $abstractMethods = [ 433 'fetchAffectedRowCount', 434 'closeConnection', 435 'dataSeek', 436 'doQuery', 437 'fetchObject', 'fetchRow', 438 'fieldInfo', 'fieldName', 439 'getSoftwareLink', 'getServerVersion', 440 'getType', 441 'indexInfo', 442 'insertId', 443 'lastError', 'lastErrno', 444 'numFields', 'numRows', 445 'open', 446 'strencode', 447 'tableExists' 448 ]; 449 $db = $this->getMockBuilder( Database::class ) 450 ->disableOriginalConstructor() 451 ->setMethods( array_values( array_unique( array_merge( 452 $abstractMethods, 453 $methods 454 ) ) ) ) 455 ->getMock(); 456 $wdb = TestingAccessWrapper::newFromObject( $db ); 457 $wdb->trxProfiler = new TransactionProfiler(); 458 $wdb->connLogger = new \Psr\Log\NullLogger(); 459 $wdb->queryLogger = new \Psr\Log\NullLogger(); 460 $wdb->replLogger = new \Psr\Log\NullLogger(); 461 $wdb->errorLogger = static function ( Throwable $e ) { 462 }; 463 $wdb->deprecationLogger = static function ( $msg ) { 464 }; 465 $wdb->currentDomain = DatabaseDomain::newUnspecified(); 466 $wdb->server = 'localhost'; 467 468 return $db; 469 } 470 471 /** 472 * @covers Wikimedia\Rdbms\Database::flushSnapshot 473 */ 474 public function testFlushSnapshot() { 475 $db = $this->getMockDB( [ 'isOpen' ] ); 476 $db->method( 'isOpen' )->willReturn( true ); 477 478 $db->flushSnapshot( __METHOD__ ); // ok 479 $db->flushSnapshot( __METHOD__ ); // ok 480 481 $db->setFlag( DBO_TRX, $db::REMEMBER_PRIOR ); 482 $db->query( 'SELECT 1', __METHOD__ ); 483 $this->assertTrue( (bool)$db->trxLevel(), "Transaction started." ); 484 $db->flushSnapshot( __METHOD__ ); // ok 485 $db->restoreFlags( $db::RESTORE_PRIOR ); 486 487 $this->assertFalse( (bool)$db->trxLevel(), "Transaction cleared." ); 488 } 489 490 /** 491 * @covers Wikimedia\Rdbms\Database::getScopedLockAndFlush 492 * @covers Wikimedia\Rdbms\Database::lock 493 * @covers Wikimedia\Rdbms\Database::unlock 494 * @covers Wikimedia\Rdbms\Database::lockIsFree 495 */ 496 public function testGetScopedLock() { 497 $db = $this->getMockDB( [ 'isOpen', 'getDBname' ] ); 498 $db->method( 'isOpen' )->willReturn( true ); 499 $db->method( 'getDBname' )->willReturn( 'unittest' ); 500 501 $this->assertSame( 0, $db->trxLevel() ); 502 $this->assertTrue( $db->lockIsFree( 'x', __METHOD__ ) ); 503 $this->assertTrue( $db->lock( 'x', __METHOD__ ) ); 504 $this->assertFalse( $db->lockIsFree( 'x', __METHOD__ ) ); 505 $this->assertTrue( $db->unlock( 'x', __METHOD__ ) ); 506 $this->assertTrue( $db->lockIsFree( 'x', __METHOD__ ) ); 507 $this->assertSame( 0, $db->trxLevel() ); 508 509 $db->setFlag( DBO_TRX ); 510 $this->assertTrue( $db->lockIsFree( 'x', __METHOD__ ) ); 511 $this->assertTrue( $db->lock( 'x', __METHOD__ ) ); 512 $this->assertFalse( $db->lockIsFree( 'x', __METHOD__ ) ); 513 $this->assertTrue( $db->unlock( 'x', __METHOD__ ) ); 514 $this->assertTrue( $db->lockIsFree( 'x', __METHOD__ ) ); 515 $db->clearFlag( DBO_TRX ); 516 517 // Pending writes with DBO_TRX 518 $this->assertSame( 0, $db->trxLevel() ); 519 $this->assertTrue( $db->lockIsFree( 'meow', __METHOD__ ) ); 520 $db->setFlag( DBO_TRX ); 521 $db->query( "DELETE FROM test WHERE t = 1" ); // trigger DBO_TRX transaction before lock 522 try { 523 $lock = $db->getScopedLockAndFlush( 'meow', __METHOD__, 1 ); 524 $this->fail( "Exception not reached" ); 525 } catch ( DBUnexpectedError $e ) { 526 $this->assertSame( 1, $db->trxLevel(), "Transaction not committed." ); 527 $this->assertTrue( $db->lockIsFree( 'meow', __METHOD__ ), 'Lock not acquired' ); 528 } 529 $db->rollback( __METHOD__, IDatabase::FLUSHING_ALL_PEERS ); 530 // Pending writes without DBO_TRX 531 $db->clearFlag( DBO_TRX ); 532 $this->assertSame( 0, $db->trxLevel() ); 533 $this->assertTrue( $db->lockIsFree( 'meow2', __METHOD__ ) ); 534 $db->begin( __METHOD__ ); 535 $db->query( "DELETE FROM test WHERE t = 1" ); // trigger DBO_TRX transaction before lock 536 try { 537 $lock = $db->getScopedLockAndFlush( 'meow2', __METHOD__, 1 ); 538 $this->fail( "Exception not reached" ); 539 } catch ( DBUnexpectedError $e ) { 540 $this->assertSame( 1, $db->trxLevel(), "Transaction not committed." ); 541 $this->assertTrue( $db->lockIsFree( 'meow2', __METHOD__ ), 'Lock not acquired' ); 542 } 543 $db->rollback( __METHOD__ ); 544 // No pending writes, with DBO_TRX 545 $db->setFlag( DBO_TRX ); 546 $this->assertSame( 0, $db->trxLevel() ); 547 $this->assertTrue( $db->lockIsFree( 'wuff', __METHOD__ ) ); 548 $db->query( "SELECT 1", __METHOD__ ); 549 $this->assertSame( 1, $db->trxLevel() ); 550 $lock = $db->getScopedLockAndFlush( 'wuff', __METHOD__, 1 ); 551 $this->assertSame( 0, $db->trxLevel() ); 552 $this->assertFalse( $db->lockIsFree( 'wuff', __METHOD__ ), 'Lock already acquired' ); 553 $db->rollback( __METHOD__, IDatabase::FLUSHING_ALL_PEERS ); 554 // No pending writes, without DBO_TRX 555 $db->clearFlag( DBO_TRX ); 556 $this->assertSame( 0, $db->trxLevel() ); 557 $this->assertTrue( $db->lockIsFree( 'wuff2', __METHOD__ ) ); 558 $db->begin( __METHOD__ ); 559 try { 560 $lock = $db->getScopedLockAndFlush( 'wuff2', __METHOD__, 1 ); 561 $this->fail( "Exception not reached" ); 562 } catch ( DBUnexpectedError $e ) { 563 $this->assertSame( 1, $db->trxLevel(), "Transaction not committed." ); 564 $this->assertFalse( $db->lockIsFree( 'wuff2', __METHOD__ ), 'Lock not acquired' ); 565 } 566 $db->rollback( __METHOD__ ); 567 } 568 569 /** 570 * @covers Wikimedia\Rdbms\Database::getFlag 571 * @covers Wikimedia\Rdbms\Database::setFlag 572 * @covers Wikimedia\Rdbms\Database::restoreFlags 573 */ 574 public function testFlagSetting() { 575 $db = $this->db; 576 $origTrx = $db->getFlag( DBO_TRX ); 577 $origNoBuffer = $db->getFlag( DBO_NOBUFFER ); 578 579 $origTrx 580 ? $db->clearFlag( DBO_TRX, $db::REMEMBER_PRIOR ) 581 : $db->setFlag( DBO_TRX, $db::REMEMBER_PRIOR ); 582 $this->assertEquals( !$origTrx, $db->getFlag( DBO_TRX ) ); 583 584 $origNoBuffer 585 ? $db->clearFlag( DBO_NOBUFFER, $db::REMEMBER_PRIOR ) 586 : $db->setFlag( DBO_NOBUFFER, $db::REMEMBER_PRIOR ); 587 $this->assertEquals( !$origNoBuffer, $db->getFlag( DBO_NOBUFFER ) ); 588 589 $db->restoreFlags( $db::RESTORE_INITIAL ); 590 $this->assertEquals( $origTrx, $db->getFlag( DBO_TRX ) ); 591 $this->assertEquals( $origNoBuffer, $db->getFlag( DBO_NOBUFFER ) ); 592 593 $origTrx 594 ? $db->clearFlag( DBO_TRX, $db::REMEMBER_PRIOR ) 595 : $db->setFlag( DBO_TRX, $db::REMEMBER_PRIOR ); 596 $origNoBuffer 597 ? $db->clearFlag( DBO_NOBUFFER, $db::REMEMBER_PRIOR ) 598 : $db->setFlag( DBO_NOBUFFER, $db::REMEMBER_PRIOR ); 599 600 $db->restoreFlags(); 601 $this->assertEquals( $origNoBuffer, $db->getFlag( DBO_NOBUFFER ) ); 602 $this->assertEquals( !$origTrx, $db->getFlag( DBO_TRX ) ); 603 604 $db->restoreFlags(); 605 $this->assertEquals( $origNoBuffer, $db->getFlag( DBO_NOBUFFER ) ); 606 $this->assertEquals( $origTrx, $db->getFlag( DBO_TRX ) ); 607 } 608 609 public function provideImmutableDBOFlags() { 610 return [ 611 [ Database::DBO_IGNORE ], 612 [ Database::DBO_DEFAULT ], 613 [ Database::DBO_PERSISTENT ] 614 ]; 615 } 616 617 /** 618 * @covers Wikimedia\Rdbms\Database::setFlag 619 * @dataProvider provideImmutableDBOFlags 620 * @param int $flag 621 */ 622 public function testDBOCannotSet( $flag ) { 623 $db = $this->getMockBuilder( DatabaseMysqli::class ) 624 ->disableOriginalConstructor() 625 ->setMethods( null ) 626 ->getMock(); 627 628 $this->expectException( DBUnexpectedError::class ); 629 $db->setFlag( $flag ); 630 } 631 632 /** 633 * @covers Wikimedia\Rdbms\Database::clearFlag 634 * @dataProvider provideImmutableDBOFlags 635 * @param int $flag 636 */ 637 public function testDBOCannotClear( $flag ) { 638 $db = $this->getMockBuilder( DatabaseMysqli::class ) 639 ->disableOriginalConstructor() 640 ->setMethods( null ) 641 ->getMock(); 642 643 $this->expectException( DBUnexpectedError::class ); 644 $db->clearFlag( $flag ); 645 } 646 647 /** 648 * @covers Wikimedia\Rdbms\Database::tablePrefix 649 * @covers Wikimedia\Rdbms\Database::dbSchema 650 */ 651 public function testSchemaAndPrefixMutators() { 652 $ud = DatabaseDomain::newUnspecified(); 653 654 $this->assertEquals( $ud->getId(), $this->db->getDomainID() ); 655 656 $old = $this->db->tablePrefix(); 657 $oldDomain = $this->db->getDomainID(); 658 $this->assertIsString( $old, 'Prefix is string' ); 659 $this->assertSame( $old, $this->db->tablePrefix(), "Prefix unchanged" ); 660 $this->assertSame( $old, $this->db->tablePrefix( 'xxx_' ) ); 661 $this->assertSame( 'xxx_', $this->db->tablePrefix(), "Prefix set" ); 662 $this->db->tablePrefix( $old ); 663 $this->assertNotEquals( 'xxx_', $this->db->tablePrefix() ); 664 $this->assertSame( $oldDomain, $this->db->getDomainID() ); 665 666 $old = $this->db->dbSchema(); 667 $oldDomain = $this->db->getDomainID(); 668 $this->assertIsString( $old, 'Schema is string' ); 669 $this->assertSame( $old, $this->db->dbSchema(), "Schema unchanged" ); 670 671 $this->db->selectDB( 'y' ); 672 $this->assertSame( $old, $this->db->dbSchema( 'xxx' ) ); 673 $this->assertSame( 'xxx', $this->db->dbSchema(), "Schema set" ); 674 $this->db->dbSchema( $old ); 675 $this->assertNotEquals( 'xxx', $this->db->dbSchema() ); 676 $this->assertSame( "y", $this->db->getDomainID() ); 677 } 678 679 /** 680 * @covers Wikimedia\Rdbms\Database::tablePrefix 681 * @covers Wikimedia\Rdbms\Database::dbSchema 682 */ 683 public function testSchemaWithNoDB() { 684 $ud = DatabaseDomain::newUnspecified(); 685 686 $this->assertEquals( $ud->getId(), $this->db->getDomainID() ); 687 $this->assertSame( '', $this->db->dbSchema() ); 688 689 $this->expectException( DBUnexpectedError::class ); 690 $this->db->dbSchema( 'xxx' ); 691 } 692 693 /** 694 * @covers Wikimedia\Rdbms\Database::selectDomain 695 */ 696 public function testSelectDomain() { 697 $oldDomain = $this->db->getDomainID(); 698 $oldDatabase = $this->db->getDBname(); 699 $oldSchema = $this->db->dbSchema(); 700 $oldPrefix = $this->db->tablePrefix(); 701 702 $this->db->selectDomain( 'testselectdb-xxx_' ); 703 $this->assertSame( 'testselectdb', $this->db->getDBname() ); 704 $this->assertSame( '', $this->db->dbSchema() ); 705 $this->assertSame( 'xxx_', $this->db->tablePrefix() ); 706 707 $this->db->selectDomain( $oldDomain ); 708 $this->assertSame( $oldDatabase, $this->db->getDBname() ); 709 $this->assertSame( $oldSchema, $this->db->dbSchema() ); 710 $this->assertSame( $oldPrefix, $this->db->tablePrefix() ); 711 $this->assertSame( $oldDomain, $this->db->getDomainID() ); 712 713 $this->db->selectDomain( 'testselectdb-schema-xxx_' ); 714 $this->assertSame( 'testselectdb', $this->db->getDBname() ); 715 $this->assertSame( 'schema', $this->db->dbSchema() ); 716 $this->assertSame( 'xxx_', $this->db->tablePrefix() ); 717 718 $this->db->selectDomain( $oldDomain ); 719 $this->assertSame( $oldDatabase, $this->db->getDBname() ); 720 $this->assertSame( $oldSchema, $this->db->dbSchema() ); 721 $this->assertSame( $oldPrefix, $this->db->tablePrefix() ); 722 $this->assertSame( $oldDomain, $this->db->getDomainID() ); 723 } 724 725 /** 726 * @covers Wikimedia\Rdbms\Database::getLBInfo 727 * @covers Wikimedia\Rdbms\Database::setLBInfo 728 */ 729 public function testGetSetLBInfo() { 730 $db = $this->getMockDB(); 731 732 $this->assertEquals( [], $db->getLBInfo() ); 733 $this->assertNull( $db->getLBInfo( 'pringles' ) ); 734 735 $db->setLBInfo( 'soda', 'water' ); 736 $this->assertEquals( [ 'soda' => 'water' ], $db->getLBInfo() ); 737 $this->assertNull( $db->getLBInfo( 'pringles' ) ); 738 $this->assertEquals( 'water', $db->getLBInfo( 'soda' ) ); 739 740 $db->setLBInfo( 'basketball', 'Lebron' ); 741 $this->assertEquals( [ 'soda' => 'water', 'basketball' => 'Lebron' ], $db->getLBInfo() ); 742 $this->assertEquals( 'water', $db->getLBInfo( 'soda' ) ); 743 $this->assertEquals( 'Lebron', $db->getLBInfo( 'basketball' ) ); 744 745 $db->setLBInfo( 'soda', null ); 746 $this->assertEquals( [ 'basketball' => 'Lebron' ], $db->getLBInfo() ); 747 748 $db->setLBInfo( [ 'King' => 'James' ] ); 749 $this->assertNull( $db->getLBInfo( 'basketball' ) ); 750 $this->assertEquals( [ 'King' => 'James' ], $db->getLBInfo() ); 751 } 752 753 /** 754 * @covers Wikimedia\Rdbms\Database::isWriteQuery 755 * @param string $query 756 * @param bool $res 757 * @dataProvider provideIsWriteQuery 758 */ 759 public function testIsWriteQuery( string $query, bool $res ) { 760 $db = TestingAccessWrapper::newFromObject( $this->db ); 761 $this->assertSame( $res, $db->isWriteQuery( $query, 0 ) ); 762 } 763 764 /** 765 * Provider for testIsWriteQuery 766 * @return array 767 */ 768 public function provideIsWriteQuery() : array { 769 return [ 770 [ 'SELECT foo', false ], 771 [ ' SELECT foo FROM bar', false ], 772 [ 'BEGIN', false ], 773 [ 'SHOW EXPLAIN FOR 12;', false ], 774 [ 'USE foobar', false ], 775 [ '(SELECT 1)', false ], 776 [ 'INSERT INTO foo', true ], 777 [ 'TRUNCATE bar', true ], 778 [ 'DELETE FROM baz', true ], 779 [ 'CREATE TABLE foobar', true ] 780 ]; 781 } 782 783 /** 784 * @covers Database::executeQuery() 785 * @covers Database::assertIsWritableMaster() 786 */ 787 public function testShouldRejectPersistentWriteQueryOnReplicaDatabaseConnection() { 788 $this->expectException( DBReadOnlyRoleError::class ); 789 $this->expectDeprecationMessage( 'Server is configured as a read-only replica database.' ); 790 791 $dbr = new DatabaseTestHelper( 792 __CLASS__ . '::' . $this->getName(), 793 [ 'topologyRole' => Database::ROLE_STREAMING_REPLICA ] 794 ); 795 796 $dbr->query( "INSERT INTO test_table (a_column) VALUES ('foo');", __METHOD__ ); 797 } 798 799 /** 800 * @covers Database::executeQuery() 801 * @covers Database::assertIsWritableMaster() 802 */ 803 public function testShouldAcceptTemporaryTableOperationsOnReplicaDatabaseConnection() { 804 $dbr = new DatabaseTestHelper( 805 __CLASS__ . '::' . $this->getName(), 806 [ 'topologyRole' => Database::ROLE_STREAMING_REPLICA ] 807 ); 808 809 $resCreate = $dbr->query( 810 "CREATE TEMPORARY TABLE temp_test_table (temp_column int);", 811 __METHOD__ 812 ); 813 814 $resModify = $dbr->query( 815 "INSERT INTO temp_test_table (temp_column) VALUES (42);", 816 __METHOD__ 817 ); 818 819 $this->assertInstanceOf( IResultWrapper::class, $resCreate ); 820 $this->assertInstanceOf( IResultWrapper::class, $resModify ); 821 } 822 823 /** 824 * @covers Database::executeQuery() 825 * @covers Database::assertIsWritableMaster() 826 */ 827 public function testShouldRejectPseudoPermanentTemporaryTableOperationsOnReplicaDatabaseConnection() { 828 $this->expectException( DBReadOnlyRoleError::class ); 829 $this->expectDeprecationMessage( 'Server is configured as a read-only replica database.' ); 830 831 $dbr = new DatabaseTestHelper( 832 __CLASS__ . '::' . $this->getName(), 833 [ 'topologyRole' => Database::ROLE_STREAMING_REPLICA ] 834 ); 835 836 $dbr->query( 837 "CREATE TEMPORARY TABLE temp_test_table (temp_column int);", 838 __METHOD__, 839 Database::QUERY_PSEUDO_PERMANENT 840 ); 841 } 842 843 /** 844 * @covers Database::executeQuery() 845 * @covers Database::assertIsWritableMaster() 846 */ 847 public function testShouldAcceptWriteQueryOnPrimaryDatabaseConnection() { 848 $dbr = new DatabaseTestHelper( 849 __CLASS__ . '::' . $this->getName(), 850 [ 'topologyRole' => Database::ROLE_STREAMING_MASTER ] 851 ); 852 853 $res = $dbr->query( "INSERT INTO test_table (a_column) VALUES ('foo');", __METHOD__ ); 854 855 $this->assertInstanceOf( IResultWrapper::class, $res ); 856 } 857 858 /** 859 * @covers Database::executeQuery() 860 * @covers Database::assertIsWritableMaster() 861 */ 862 public function testShouldRejectWriteQueryOnPrimaryDatabaseConnectionWhenReplicaQueryRoleFlagIsSet() { 863 $this->expectException( DBReadOnlyRoleError::class ); 864 $this->expectDeprecationMessage( 'Cannot write; target role is DB_REPLICA' ); 865 866 $dbr = new DatabaseTestHelper( 867 __CLASS__ . '::' . $this->getName(), 868 [ 'topologyRole' => Database::ROLE_STREAMING_MASTER ] 869 ); 870 871 $dbr->query( 872 "INSERT INTO test_table (a_column) VALUES ('foo');", 873 __METHOD__, 874 Database::QUERY_REPLICA_ROLE 875 ); 876 } 877} 878