3 namespace MediaWiki\Tests\Storage
;
8 use MediaWiki\Storage\NameTableAccessException
;
9 use MediaWiki\Storage\NameTableStore
;
10 use MediaWikiTestCase
;
11 use Psr\Log\NullLogger
;
13 use Wikimedia\Rdbms\IDatabase
;
14 use Wikimedia\Rdbms\LoadBalancer
;
15 use Wikimedia\Rdbms\MaintainableDBConnRef
;
16 use Wikimedia\TestingAccessWrapper
;
21 * @covers \MediaWiki\Storage\NameTableStore
23 class NameTableStoreTest
extends MediaWikiTestCase
{
25 public function setUp() {
26 $this->tablesUsed
[] = 'slot_roles';
30 protected function addCoreDBData() {
31 // The default implementation causes the slot_roles to already have content. Skip that.
34 private function populateTable( $values ) {
36 foreach ( $values as $name ) {
37 $insertValues[] = [ 'role_name' => $name ];
39 $this->db
->insert( 'slot_roles', $insertValues );
42 private function getHashWANObjectCache( $cacheBag ) {
43 return new WANObjectCache( [ 'cache' => $cacheBag ] );
48 * @return \PHPUnit_Framework_MockObject_MockObject|LoadBalancer
50 private function getMockLoadBalancer( $db ) {
51 $mock = $this->getMockBuilder( LoadBalancer
::class )
52 ->disableOriginalConstructor()
54 $mock->expects( $this->any() )
55 ->method( 'getConnectionRef' )
56 ->willReturnCallback( function ( $i ) use ( $mock, $db ) {
57 return new MaintainableDBConnRef( $mock, $db, $i );
62 private function getCallCheckingDb( $insertCalls, $selectCalls ) {
64 'select' => $selectCalls,
65 'insert' => $insertCalls,
66 'affectedRows' => null,
68 'getSessionLagStatus' => null,
69 'writesPending' => null,
70 'onTransactionPreCommitOrIdle' => null
72 $mock = $this->getMockBuilder( IDatabase
::class )
73 ->disableOriginalConstructor()
75 foreach ( $proxiedMethods as $method => $count ) {
76 $mock->expects( is_int( $count ) ?
$this->exactly( $count ) : $this->any() )
78 ->willReturnCallback( function ( ...$args ) use ( $method ) {
79 return call_user_func_array( [ $this->db
, $method ], $args );
85 private function getNameTableSqlStore(
89 $normalizationCallback = null,
90 $insertCallback = null
92 return new NameTableStore(
93 $this->getMockLoadBalancer( $this->getCallCheckingDb( $insertCalls, $selectCalls ) ),
94 $this->getHashWANObjectCache( $cacheBag ),
96 'slot_roles', 'role_id', 'role_name',
97 $normalizationCallback,
103 public function provideGetAndAcquireId() {
105 'no wancache, empty table' =>
106 [ new EmptyBagOStuff(), true, 1, [], 'foo', 1 ],
107 'no wancache, one matching value' =>
108 [ new EmptyBagOStuff(), false, 1, [ 'foo' ], 'foo', 1 ],
109 'no wancache, one not matching value' =>
110 [ new EmptyBagOStuff(), true, 1, [ 'bar' ], 'foo', 2 ],
111 'no wancache, multiple, one matching value' =>
112 [ new EmptyBagOStuff(), false, 1, [ 'foo', 'bar' ], 'bar', 2 ],
113 'no wancache, multiple, no matching value' =>
114 [ new EmptyBagOStuff(), true, 1, [ 'foo', 'bar' ], 'baz', 3 ],
115 'wancache, empty table' =>
116 [ new HashBagOStuff(), true, 1, [], 'foo', 1 ],
117 'wancache, one matching value' =>
118 [ new HashBagOStuff(), false, 1, [ 'foo' ], 'foo', 1 ],
119 'wancache, one not matching value' =>
120 [ new HashBagOStuff(), true, 1, [ 'bar' ], 'foo', 2 ],
121 'wancache, multiple, one matching value' =>
122 [ new HashBagOStuff(), false, 1, [ 'foo', 'bar' ], 'bar', 2 ],
123 'wancache, multiple, no matching value' =>
124 [ new HashBagOStuff(), true, 1, [ 'foo', 'bar' ], 'baz', 3 ],
129 * @dataProvider provideGetAndAcquireId
130 * @param BagOStuff $cacheBag to use in the WANObjectCache service
131 * @param bool $needsInsert Does the value we are testing need to be inserted?
132 * @param int $selectCalls Number of times the select DB method will be called
133 * @param string[] $existingValues to be added to the db table
134 * @param string $name name to acquire
135 * @param int $expectedId the id we expect the name to have
137 public function testGetAndAcquireId(
145 // Make sure the table is empty!
146 $this->truncateTable( 'slot_roles' );
148 $this->populateTable( $existingValues );
149 $store = $this->getNameTableSqlStore( $cacheBag, (int)$needsInsert, $selectCalls );
151 // Some names will not initially exist
153 $result = $store->getId( $name );
154 $this->assertSame( $expectedId, $result );
155 } catch ( NameTableAccessException
$e ) {
156 if ( $needsInsert ) {
157 $this->assertTrue( true ); // Expected exception
159 $this->fail( 'Did not expect an exception, but got one: ' . $e->getMessage() );
163 // All names should return their id here
164 $this->assertSame( $expectedId, $store->acquireId( $name ) );
166 // acquireId inserted these names, so now everything should exist with getId
167 $this->assertSame( $expectedId, $store->getId( $name ) );
169 // calling getId again will also still work, and not result in more selects
170 $this->assertSame( $expectedId, $store->getId( $name ) );
173 public function provideTestGetAndAcquireIdNameNormalization() {
174 yield
[ 'A', 'a', 'strtolower' ];
175 yield
[ 'b', 'B', 'strtoupper' ];
183 yield
[ 'ZZ', 'ZZ-a', __CLASS__
. '::appendDashAToString' ];
186 public static function appendDashAToString( $string ) {
187 return $string . '-a';
191 * @dataProvider provideTestGetAndAcquireIdNameNormalization
193 public function testGetAndAcquireIdNameNormalization(
196 $normalizationCallback
198 $store = $this->getNameTableSqlStore(
199 new EmptyBagOStuff(),
202 $normalizationCallback
204 $acquiredId = $store->acquireId( $nameIn );
205 $this->assertSame( $nameOut, $store->getName( $acquiredId ) );
208 public function provideGetName() {
210 [ new HashBagOStuff(), 3, 2 ],
211 [ new EmptyBagOStuff(), 3, 3 ],
216 * @dataProvider provideGetName
218 public function testGetName( BagOStuff
$cacheBag, $insertCalls, $selectCalls ) {
219 $now = microtime( true );
220 $cacheBag->setMockTime( $now );
221 // Check for operations to in-memory cache (IMC) and persistent cache (PC)
222 $store = $this->getNameTableSqlStore( $cacheBag, $insertCalls, $selectCalls );
224 // Get 1 ID and make sure getName returns correctly
225 $fooId = $store->acquireId( 'foo' ); // regen PC, set IMC, update IMC, tombstone PC
227 $this->assertSame( 'foo', $store->getName( $fooId ) ); // use IMC
230 // Get another ID and make sure getName returns correctly
231 $barId = $store->acquireId( 'bar' ); // update IMC, tombstone PC
233 $this->assertSame( 'bar', $store->getName( $barId ) ); // use IMC
236 // Blitz the cache and make sure it still returns
237 TestingAccessWrapper
::newFromObject( $store )->tableCache
= null; // clear IMC
238 $this->assertSame( 'foo', $store->getName( $fooId ) ); // regen interim PC, set IMC
239 $this->assertSame( 'bar', $store->getName( $barId ) ); // use IMC
241 // Blitz the cache again and get another ID and make sure getName returns correctly
242 TestingAccessWrapper
::newFromObject( $store )->tableCache
= null; // clear IMC
243 $bazId = $store->acquireId( 'baz' ); // set IMC using interim PC, update IMC, tombstone PC
245 $this->assertSame( 'baz', $store->getName( $bazId ) ); // uses IMC
246 $this->assertSame( 'baz', $store->getName( $bazId ) ); // uses IMC
249 public function testGetName_masterFallback() {
250 $store = $this->getNameTableSqlStore( new EmptyBagOStuff(), 1, 2 );
253 $fooId = $store->acquireId( 'foo' );
255 // Empty the process cache, getCachedTable() will now return this empty array
256 TestingAccessWrapper
::newFromObject( $store )->tableCache
= [];
258 // getName should fallback to master, which is why we assert 2 selectCalls above
259 $this->assertSame( 'foo', $store->getName( $fooId ) );
262 public function testGetMap_empty() {
263 $this->populateTable( [] );
264 $store = $this->getNameTableSqlStore( new HashBagOStuff(), 0, 1 );
265 $table = $store->getMap();
266 $this->assertSame( [], $table );
269 public function testGetMap_twoValues() {
270 $this->populateTable( [ 'foo', 'bar' ] );
271 $store = $this->getNameTableSqlStore( new HashBagOStuff(), 0, 1 );
273 // We are using a cache, so 2 calls should only result in 1 select on the db
275 $table = $store->getMap();
277 $expected = [ 1 => 'foo', 2 => 'bar' ];
278 $this->assertSame( $expected, $table );
279 // Make sure the table returned is the same as the cached table
280 $this->assertSame( $expected, TestingAccessWrapper
::newFromObject( $store )->tableCache
);
283 public function testReloadMap() {
284 $this->populateTable( [ 'foo' ] );
285 $store = $this->getNameTableSqlStore( new HashBagOStuff(), 0, 2 );
288 $this->assertCount( 1, $store->getMap() );
290 // add more stuff to the table, so the cache gets out of sync
291 $this->populateTable( [ 'bar' ] );
293 $expected = [ 1 => 'foo', 2 => 'bar' ];
294 $this->assertSame( $expected, $store->reloadMap() );
295 $this->assertSame( $expected, $store->getMap() );
298 public function testCacheRaceCondition() {
299 $wanHashBag = new HashBagOStuff();
300 $store1 = $this->getNameTableSqlStore( $wanHashBag, 1, 1 );
301 $store2 = $this->getNameTableSqlStore( $wanHashBag, 1, 0 );
302 $store3 = $this->getNameTableSqlStore( $wanHashBag, 1, 1 );
304 // Cache the current table in the instances we will use
305 // This simulates multiple requests running simultaneously
310 // Store 2 separate names using different instances
311 $fooId = $store1->acquireId( 'foo' );
312 $barId = $store2->acquireId( 'bar' );
314 // Each of these instances should be aware of what they have inserted
315 $this->assertSame( $fooId, $store1->acquireId( 'foo' ) );
316 $this->assertSame( $barId, $store2->acquireId( 'bar' ) );
318 // A new store should be able to get both of these new Ids
319 // Note: before there was a race condition here where acquireId( 'bar' ) would update the
320 // cache with data missing the 'foo' key that it was not aware of
321 $store4 = $this->getNameTableSqlStore( $wanHashBag, 0, 1 );
322 $this->assertSame( $fooId, $store4->getId( 'foo' ) );
323 $this->assertSame( $barId, $store4->getId( 'bar' ) );
325 // If a store with old cached data tries to acquire these we will get the same ids.
326 $this->assertSame( $fooId, $store3->acquireId( 'foo' ) );
327 $this->assertSame( $barId, $store3->acquireId( 'bar' ) );
330 public function testGetAndAcquireIdInsertCallback() {
331 // FIXME: fails under postgres
332 $this->markTestSkippedIfDbType( 'postgres' );
334 $store = $this->getNameTableSqlStore(
335 new EmptyBagOStuff(),
339 function ( $insertFields ) {
340 $insertFields['role_id'] = 7251;
341 return $insertFields;
344 $this->assertSame( 7251, $store->acquireId( 'A' ) );