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