X-Git-Url: https://git.heureux-cyclage.org/?p=lhc%2Fweb%2Fwiklou.git;a=blobdiff_plain;f=tests%2Fphpunit%2Fincludes%2Flibs%2Frdbms%2Fdatabase%2FDatabaseSQLTest.php;h=b883c11ba3358308ab4eae8b06d8eb19e5bf12e7;hp=d8ebeff1703e6487c88b215950a4c09ab4e72aab;hb=7065200b036d4bbf0c46f4b236d761a79b57215e;hpb=df9d4404503db519a3b33a53142ee4d7449d2208 diff --git a/tests/phpunit/includes/libs/rdbms/database/DatabaseSQLTest.php b/tests/phpunit/includes/libs/rdbms/database/DatabaseSQLTest.php index d8ebeff170..ab2f11b5ea 100644 --- a/tests/phpunit/includes/libs/rdbms/database/DatabaseSQLTest.php +++ b/tests/phpunit/includes/libs/rdbms/database/DatabaseSQLTest.php @@ -1,16 +1,23 @@ 'table', + 'fields' => [ 'field', 'alias' => 'field2' ], + 'conds' => 'alias = \'text\'', + ], + "SELECT field,field2 AS alias " . + "FROM table " . + "WHERE alias = 'text'" + ], + [ + [ + 'tables' => 'table', + 'fields' => [ 'field', 'alias' => 'field2' ], + 'conds' => [], + ], + "SELECT field,field2 AS alias " . + "FROM table" + ], + [ + [ + 'tables' => 'table', + 'fields' => [ 'field', 'alias' => 'field2' ], + 'conds' => '', + ], + "SELECT field,field2 AS alias " . + "FROM table" + ], + [ + [ + 'tables' => 'table', + 'fields' => [ 'field', 'alias' => 'field2' ], + 'conds' => '0', // T188314 + ], + "SELECT field,field2 AS alias " . + "FROM table " . + "WHERE 0" + ], [ [ // 'tables' with space prepended indicates pre-escaped table name @@ -201,6 +246,101 @@ class DatabaseSQLTest extends PHPUnit_Framework_TestCase { ]; } + /** + * @covers Wikimedia\Rdbms\Subquery + * @dataProvider provideSelectRowCount + * @param $sql + * @param $sqlText + */ + public function testSelectRowCount( $sql, $sqlText ) { + $this->database->selectRowCount( + $sql['tables'], + $sql['field'], + isset( $sql['conds'] ) ? $sql['conds'] : [], + __METHOD__, + isset( $sql['options'] ) ? $sql['options'] : [], + isset( $sql['join_conds'] ) ? $sql['join_conds'] : [] + ); + $this->assertLastSql( $sqlText ); + } + + public static function provideSelectRowCount() { + return [ + [ + [ + 'tables' => 'table', + 'field' => [ '*' ], + 'conds' => [ 'field' => 'text' ], + ], + "SELECT COUNT(*) AS rowcount FROM " . + "(SELECT 1 FROM table WHERE field = 'text' ) tmp_count" + ], + [ + [ + 'tables' => 'table', + 'field' => [ 'column' ], + 'conds' => [ 'field' => 'text' ], + ], + "SELECT COUNT(*) AS rowcount FROM " . + "(SELECT 1 FROM table WHERE field = 'text' AND (column IS NOT NULL) ) tmp_count" + ], + [ + [ + 'tables' => 'table', + 'field' => [ 'alias' => 'column' ], + 'conds' => [ 'field' => 'text' ], + ], + "SELECT COUNT(*) AS rowcount FROM " . + "(SELECT 1 FROM table WHERE field = 'text' AND (column IS NOT NULL) ) tmp_count" + ], + [ + [ + 'tables' => 'table', + 'field' => [ 'alias' => 'column' ], + 'conds' => '', + ], + "SELECT COUNT(*) AS rowcount FROM " . + "(SELECT 1 FROM table WHERE (column IS NOT NULL) ) tmp_count" + ], + [ + [ + 'tables' => 'table', + 'field' => [ 'alias' => 'column' ], + 'conds' => false, + ], + "SELECT COUNT(*) AS rowcount FROM " . + "(SELECT 1 FROM table WHERE (column IS NOT NULL) ) tmp_count" + ], + [ + [ + 'tables' => 'table', + 'field' => [ 'alias' => 'column' ], + 'conds' => null, + ], + "SELECT COUNT(*) AS rowcount FROM " . + "(SELECT 1 FROM table WHERE (column IS NOT NULL) ) tmp_count" + ], + [ + [ + 'tables' => 'table', + 'field' => [ 'alias' => 'column' ], + 'conds' => '1', + ], + "SELECT COUNT(*) AS rowcount FROM " . + "(SELECT 1 FROM table WHERE (1) AND (column IS NOT NULL) ) tmp_count" + ], + [ + [ + 'tables' => 'table', + 'field' => [ 'alias' => 'column' ], + 'conds' => '0', + ], + "SELECT COUNT(*) AS rowcount FROM " . + "(SELECT 1 FROM table WHERE (0) AND (column IS NOT NULL) ) tmp_count" + ], + ]; + } + /** * @dataProvider provideUpdate * @covers Wikimedia\Rdbms\Database::update @@ -457,7 +597,7 @@ class DatabaseSQLTest extends PHPUnit_Framework_TestCase { isset( $sql['selectOptions'] ) ? $sql['selectOptions'] : [], isset( $sql['selectJoinConds'] ) ? $sql['selectJoinConds'] : [] ); - $this->assertLastSqlDb( implode( '; ', [ $sqlSelect, $sqlInsert ] ), $dbWeb ); + $this->assertLastSqlDb( implode( '; ', [ $sqlSelect, 'BEGIN', $sqlInsert, 'COMMIT' ] ), $dbWeb ); } public static function provideInsertSelect() { @@ -518,6 +658,7 @@ class DatabaseSQLTest extends PHPUnit_Framework_TestCase { 'srcTable' => [ 'select_table1', 'select_table2' ], 'varMap' => [ 'field_insert' => 'field_select', 'field' => 'field2' ], 'conds' => [ 'field' => 2 ], + 'insertOptions' => [ 'NO_AUTO_COLUMNS' ], 'selectOptions' => [ 'ORDER BY' => 'field', 'FORCE INDEX' => [ 'select_table1' => 'index1' ] ], 'selectJoinConds' => [ 'select_table2' => [ 'LEFT JOIN', [ 'select_table1.foo = select_table2.bar' ] ], @@ -537,6 +678,30 @@ class DatabaseSQLTest extends PHPUnit_Framework_TestCase { ]; } + public function testInsertSelectBatching() { + $dbWeb = new DatabaseTestHelper( __CLASS__, [ 'cliMode' => false ] ); + $rows = []; + for ( $i = 0; $i <= 25000; $i++ ) { + $rows[] = [ 'field' => $i ]; + } + $dbWeb->forceNextResult( $rows ); + $dbWeb->insertSelect( + 'insert_table', + 'select_table', + [ 'field' => 'field2' ], + '*', + __METHOD__ + ); + $this->assertLastSqlDb( implode( '; ', [ + 'SELECT field2 AS field FROM select_table WHERE * FOR UPDATE', + 'BEGIN', + "INSERT INTO insert_table (field) VALUES ('" . implode( "'),('", range( 0, 9999 ) ) . "')", + "INSERT INTO insert_table (field) VALUES ('" . implode( "'),('", range( 10000, 19999 ) ) . "')", + "INSERT INTO insert_table (field) VALUES ('" . implode( "'),('", range( 20000, 25000 ) ) . "')", + 'COMMIT' + ] ), $dbWeb ); + } + /** * @dataProvider provideReplace * @covers Wikimedia\Rdbms\Database::replace @@ -559,11 +724,11 @@ class DatabaseSQLTest extends PHPUnit_Framework_TestCase { 'uniqueIndexes' => [ 'field' ], 'rows' => [ 'field' => 'text', 'field2' => 'text2' ], ], - "DELETE FROM replace_table " . + "BEGIN; DELETE FROM replace_table " . "WHERE (field = 'text'); " . "INSERT INTO replace_table " . "(field,field2) " . - "VALUES ('text','text2')" + "VALUES ('text','text2'); COMMIT" ], [ [ @@ -575,11 +740,11 @@ class DatabaseSQLTest extends PHPUnit_Framework_TestCase { 'md_deps' => 'deps', ], ], - "DELETE FROM module_deps " . + "BEGIN; DELETE FROM module_deps " . "WHERE (md_module = 'module' AND md_skin = 'skin'); " . "INSERT INTO module_deps " . "(md_module,md_skin,md_deps) " . - "VALUES ('module','skin','deps')" + "VALUES ('module','skin','deps'); COMMIT" ], [ [ @@ -597,7 +762,7 @@ class DatabaseSQLTest extends PHPUnit_Framework_TestCase { ], ], ], - "DELETE FROM module_deps " . + "BEGIN; DELETE FROM module_deps " . "WHERE (md_module = 'module' AND md_skin = 'skin'); " . "INSERT INTO module_deps " . "(md_module,md_skin,md_deps) " . @@ -606,7 +771,7 @@ class DatabaseSQLTest extends PHPUnit_Framework_TestCase { "WHERE (md_module = 'module2' AND md_skin = 'skin2'); " . "INSERT INTO module_deps " . "(md_module,md_skin,md_deps) " . - "VALUES ('module2','skin2','deps2')" + "VALUES ('module2','skin2','deps2'); COMMIT" ], [ [ @@ -624,7 +789,7 @@ class DatabaseSQLTest extends PHPUnit_Framework_TestCase { ], ], ], - "DELETE FROM module_deps " . + "BEGIN; DELETE FROM module_deps " . "WHERE (md_module = 'module') OR (md_skin = 'skin'); " . "INSERT INTO module_deps " . "(md_module,md_skin,md_deps) " . @@ -633,7 +798,7 @@ class DatabaseSQLTest extends PHPUnit_Framework_TestCase { "WHERE (md_module = 'module2') OR (md_skin = 'skin2'); " . "INSERT INTO module_deps " . "(md_module,md_skin,md_deps) " . - "VALUES ('module2','skin2','deps2')" + "VALUES ('module2','skin2','deps2'); COMMIT" ], [ [ @@ -645,9 +810,9 @@ class DatabaseSQLTest extends PHPUnit_Framework_TestCase { 'md_deps' => 'deps', ], ], - "INSERT INTO module_deps " . + "BEGIN; INSERT INTO module_deps " . "(md_module,md_skin,md_deps) " . - "VALUES ('module','skin','deps')" + "VALUES ('module','skin','deps'); COMMIT" ], ]; } @@ -1151,4 +1316,752 @@ class DatabaseSQLTest extends PHPUnit_Framework_TestCase { $this->assertFalse( $this->database->tableExists( "tmp_table_2", __METHOD__ ) ); $this->assertFalse( $this->database->tableExists( "tmp_table_3", __METHOD__ ) ); } + + public function provideBuildSubstring() { + yield [ 'someField', 1, 2, 'SUBSTRING(someField FROM 1 FOR 2)' ]; + yield [ 'someField', 1, null, 'SUBSTRING(someField FROM 1)' ]; + } + + /** + * @covers Wikimedia\Rdbms\Database::buildSubstring + * @dataProvider provideBuildSubstring + */ + public function testBuildSubstring( $input, $start, $length, $expected ) { + $output = $this->database->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 Wikimedia\Rdbms\Database::buildSubstring + * @covers Wikimedia\Rdbms\Database::assertBuildSubstringParams + * @dataProvider provideBuildSubstring_invalidParams + */ + public function testBuildSubstring_invalidParams( $start, $length ) { + $this->setExpectedException( InvalidArgumentException::class ); + $this->database->buildSubstring( 'foo', $start, $length ); + } + + /** + * @covers \Wikimedia\Rdbms\Database::buildIntegerCast + */ + public function testBuildIntegerCast() { + $output = $this->database->buildIntegerCast( 'fieldName' ); + $this->assertSame( 'CAST( fieldName AS INTEGER )', $output ); + } + + /** + * @covers \Wikimedia\Rdbms\Database::doSavepoint + * @covers \Wikimedia\Rdbms\Database::doReleaseSavepoint + * @covers \Wikimedia\Rdbms\Database::doRollbackToSavepoint + * @covers \Wikimedia\Rdbms\Database::startAtomic + * @covers \Wikimedia\Rdbms\Database::endAtomic + * @covers \Wikimedia\Rdbms\Database::cancelAtomic + * @covers \Wikimedia\Rdbms\Database::doAtomicSection + */ + public function testAtomicSections() { + $this->database->startAtomic( __METHOD__ ); + $this->database->endAtomic( __METHOD__ ); + $this->assertLastSql( 'BEGIN; COMMIT' ); + + $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE ); + $this->database->cancelAtomic( __METHOD__ ); + $this->assertLastSql( 'BEGIN; ROLLBACK' ); + + $this->database->begin( __METHOD__ ); + $this->database->startAtomic( __METHOD__ ); + $this->database->endAtomic( __METHOD__ ); + $this->database->commit( __METHOD__ ); + $this->assertLastSql( 'BEGIN; COMMIT' ); + + $this->database->begin( __METHOD__ ); + $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE ); + $this->database->endAtomic( __METHOD__ ); + $this->database->commit( __METHOD__ ); + // phpcs:ignore Generic.Files.LineLength + $this->assertLastSql( 'BEGIN; SAVEPOINT wikimedia_rdbms_atomic1; RELEASE SAVEPOINT wikimedia_rdbms_atomic1; COMMIT' ); + + $this->database->begin( __METHOD__ ); + $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE ); + $this->database->cancelAtomic( __METHOD__ ); + $this->database->commit( __METHOD__ ); + // phpcs:ignore Generic.Files.LineLength + $this->assertLastSql( 'BEGIN; SAVEPOINT wikimedia_rdbms_atomic1; ROLLBACK TO SAVEPOINT wikimedia_rdbms_atomic1; COMMIT' ); + + $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE ); + $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE ); + $this->database->cancelAtomic( __METHOD__ ); + $this->database->endAtomic( __METHOD__ ); + // phpcs:ignore Generic.Files.LineLength + $this->assertLastSql( 'BEGIN; SAVEPOINT wikimedia_rdbms_atomic1; ROLLBACK TO SAVEPOINT wikimedia_rdbms_atomic1; COMMIT' ); + + $noOpCallack = function () { + }; + + $this->database->doAtomicSection( __METHOD__, $noOpCallack, IDatabase::ATOMIC_CANCELABLE ); + $this->assertLastSql( 'BEGIN; COMMIT' ); + + $this->database->doAtomicSection( __METHOD__, $noOpCallack ); + $this->assertLastSql( 'BEGIN; COMMIT' ); + + $this->database->begin( __METHOD__ ); + $this->database->doAtomicSection( __METHOD__, $noOpCallack, IDatabase::ATOMIC_CANCELABLE ); + $this->database->rollback( __METHOD__ ); + // phpcs:ignore Generic.Files.LineLength + $this->assertLastSql( 'BEGIN; SAVEPOINT wikimedia_rdbms_atomic1; RELEASE SAVEPOINT wikimedia_rdbms_atomic1; ROLLBACK' ); + + $fname = __METHOD__; + $triggerMap = [ + '-' => '-', + IDatabase::TRIGGER_COMMIT => 'tCommit', + IDatabase::TRIGGER_ROLLBACK => 'tRollback' + ]; + $callback1 = function ( $trigger = '-' ) use ( $fname, $triggerMap ) { + $this->database->query( "SELECT 1, {$triggerMap[$trigger]} AS t", $fname ); + }; + $callback2 = function ( $trigger = '-' ) use ( $fname, $triggerMap ) { + $this->database->query( "SELECT 2, {$triggerMap[$trigger]} AS t", $fname ); + }; + $callback3 = function ( $trigger = '-' ) use ( $fname, $triggerMap ) { + $this->database->query( "SELECT 3, {$triggerMap[$trigger]} AS t", $fname ); + }; + + $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE ); + $this->database->onTransactionPreCommitOrIdle( $callback1, __METHOD__ ); + $this->database->cancelAtomic( __METHOD__ ); + $this->assertLastSql( 'BEGIN; ROLLBACK' ); + + $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE ); + $this->database->onTransactionIdle( $callback1, __METHOD__ ); + $this->database->cancelAtomic( __METHOD__ ); + $this->assertLastSql( 'BEGIN; ROLLBACK' ); + + $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE ); + $this->database->onTransactionResolution( $callback1, __METHOD__ ); + $this->database->cancelAtomic( __METHOD__ ); + $this->assertLastSql( 'BEGIN; ROLLBACK; SELECT 1, tRollback AS t' ); + + $this->database->startAtomic( __METHOD__ . '_outer' ); + $this->database->onTransactionPreCommitOrIdle( $callback1, __METHOD__ ); + $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE ); + $this->database->onTransactionPreCommitOrIdle( $callback2, __METHOD__ ); + $this->database->cancelAtomic( __METHOD__ ); + $this->database->onTransactionPreCommitOrIdle( $callback3, __METHOD__ ); + $this->database->endAtomic( __METHOD__ . '_outer' ); + $this->assertLastSql( implode( "; ", [ + 'BEGIN', + 'SAVEPOINT wikimedia_rdbms_atomic1', + 'ROLLBACK TO SAVEPOINT wikimedia_rdbms_atomic1', + 'SELECT 1, - AS t', + 'SELECT 3, - AS t', + 'COMMIT' + ] ) ); + + $this->database->startAtomic( __METHOD__ . '_outer' ); + $this->database->onTransactionIdle( $callback1, __METHOD__ ); + $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE ); + $this->database->onTransactionIdle( $callback2, __METHOD__ ); + $this->database->cancelAtomic( __METHOD__ ); + $this->database->onTransactionIdle( $callback3, __METHOD__ ); + $this->database->endAtomic( __METHOD__ . '_outer' ); + $this->assertLastSql( implode( "; ", [ + 'BEGIN', + 'SAVEPOINT wikimedia_rdbms_atomic1', + 'ROLLBACK TO SAVEPOINT wikimedia_rdbms_atomic1', + 'COMMIT', + 'SELECT 1, tCommit AS t', + 'SELECT 3, tCommit AS t' + ] ) ); + + $this->database->startAtomic( __METHOD__ . '_outer' ); + $this->database->onTransactionResolution( $callback1, __METHOD__ ); + $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE ); + $this->database->onTransactionResolution( $callback2, __METHOD__ ); + $this->database->cancelAtomic( __METHOD__ ); + $this->database->onTransactionResolution( $callback3, __METHOD__ ); + $this->database->endAtomic( __METHOD__ . '_outer' ); + $this->assertLastSql( implode( "; ", [ + 'BEGIN', + 'SAVEPOINT wikimedia_rdbms_atomic1', + 'ROLLBACK TO SAVEPOINT wikimedia_rdbms_atomic1', + 'COMMIT', + 'SELECT 1, tCommit AS t', + 'SELECT 2, tRollback AS t', + 'SELECT 3, tCommit AS t' + ] ) ); + + $makeCallback = function ( $id ) use ( $fname, $triggerMap ) { + return function ( $trigger = '-' ) use ( $id, $fname, $triggerMap ) { + $this->database->query( "SELECT $id, {$triggerMap[$trigger]} AS t", $fname ); + }; + }; + + $this->database->startAtomic( __METHOD__ . '_outer' ); + $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE ); + $this->database->onTransactionResolution( $makeCallback( 1 ), __METHOD__ ); + $this->database->cancelAtomic( __METHOD__ ); + $this->database->endAtomic( __METHOD__ . '_outer' ); + $this->assertLastSql( implode( "; ", [ + 'BEGIN', + 'SAVEPOINT wikimedia_rdbms_atomic1', + 'ROLLBACK TO SAVEPOINT wikimedia_rdbms_atomic1', + 'COMMIT', + 'SELECT 1, tRollback AS t' + ] ) ); + + $this->database->startAtomic( __METHOD__ . '_level1', IDatabase::ATOMIC_CANCELABLE ); + $this->database->onTransactionResolution( $makeCallback( 1 ), __METHOD__ ); + $this->database->startAtomic( __METHOD__ . '_level2' ); + $this->database->startAtomic( __METHOD__ . '_level3', IDatabase::ATOMIC_CANCELABLE ); + $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE ); + $this->database->onTransactionResolution( $makeCallback( 2 ), __METHOD__ ); + $this->database->endAtomic( __METHOD__ ); + $this->database->onTransactionResolution( $makeCallback( 3 ), __METHOD__ ); + $this->database->cancelAtomic( __METHOD__ . '_level3' ); + $this->database->endAtomic( __METHOD__ . '_level2' ); + $this->database->onTransactionResolution( $makeCallback( 4 ), __METHOD__ ); + $this->database->endAtomic( __METHOD__ . '_level1' ); + $this->assertLastSql( implode( "; ", [ + 'BEGIN', + 'SAVEPOINT wikimedia_rdbms_atomic1', + 'SAVEPOINT wikimedia_rdbms_atomic2', + 'RELEASE SAVEPOINT wikimedia_rdbms_atomic2', + 'ROLLBACK TO SAVEPOINT wikimedia_rdbms_atomic1', + 'COMMIT; SELECT 1, tCommit AS t', + 'SELECT 2, tRollback AS t', + 'SELECT 3, tRollback AS t', + 'SELECT 4, tCommit AS t' + ] ) ); + } + + /** + * @covers \Wikimedia\Rdbms\Database::doSavepoint + * @covers \Wikimedia\Rdbms\Database::doReleaseSavepoint + * @covers \Wikimedia\Rdbms\Database::doRollbackToSavepoint + * @covers \Wikimedia\Rdbms\Database::startAtomic + * @covers \Wikimedia\Rdbms\Database::endAtomic + * @covers \Wikimedia\Rdbms\Database::cancelAtomic + * @covers \Wikimedia\Rdbms\Database::doAtomicSection + */ + public function testAtomicSectionsRecovery() { + $this->database->begin( __METHOD__ ); + try { + $this->database->doAtomicSection( + __METHOD__, + function () { + $this->database->startAtomic( 'inner_func1' ); + $this->database->startAtomic( 'inner_func2' ); + + throw new RuntimeException( 'Test exception' ); + }, + IDatabase::ATOMIC_CANCELABLE + ); + $this->fail( 'Expected exception not thrown' ); + } catch ( RuntimeException $ex ) { + $this->assertSame( 'Test exception', $ex->getMessage() ); + } + $this->database->commit( __METHOD__ ); + // phpcs:ignore Generic.Files.LineLength + $this->assertLastSql( 'BEGIN; SAVEPOINT wikimedia_rdbms_atomic1; ROLLBACK TO SAVEPOINT wikimedia_rdbms_atomic1; COMMIT' ); + + $this->database->begin( __METHOD__ ); + try { + $this->database->doAtomicSection( + __METHOD__, + function () { + throw new RuntimeException( 'Test exception' ); + } + ); + $this->fail( 'Test exception not thrown' ); + } catch ( RuntimeException $ex ) { + $this->assertSame( 'Test exception', $ex->getMessage() ); + } + try { + $this->database->commit( __METHOD__ ); + $this->fail( 'Test exception not thrown' ); + } catch ( DBTransactionError $ex ) { + $this->assertSame( + 'Cannot execute query from ' . __METHOD__ . ' while transaction status is ERROR.', + $ex->getMessage() + ); + } + $this->database->rollback( __METHOD__ ); + $this->assertLastSql( 'BEGIN; ROLLBACK' ); + } + + /** + * @covers \Wikimedia\Rdbms\Database::doSavepoint + * @covers \Wikimedia\Rdbms\Database::doReleaseSavepoint + * @covers \Wikimedia\Rdbms\Database::doRollbackToSavepoint + * @covers \Wikimedia\Rdbms\Database::startAtomic + * @covers \Wikimedia\Rdbms\Database::endAtomic + * @covers \Wikimedia\Rdbms\Database::cancelAtomic + * @covers \Wikimedia\Rdbms\Database::doAtomicSection + */ + public function testAtomicSectionsCallbackCancellation() { + $fname = __METHOD__; + $callback1Called = null; + $callback1 = function ( $trigger = '-' ) use ( $fname, &$callback1Called ) { + $callback1Called = $trigger; + $this->database->query( "SELECT 1", $fname ); + }; + $callback2Called = null; + $callback2 = function ( $trigger = '-' ) use ( $fname, &$callback2Called ) { + $callback2Called = $trigger; + $this->database->query( "SELECT 2", $fname ); + }; + $callback3Called = null; + $callback3 = function ( $trigger = '-' ) use ( $fname, &$callback3Called ) { + $callback3Called = $trigger; + $this->database->query( "SELECT 3", $fname ); + }; + + $this->database->startAtomic( __METHOD__ . '_outer' ); + $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE ); + $this->database->startAtomic( __METHOD__ . '_inner' ); + $this->database->onTransactionIdle( $callback1, __METHOD__ ); + $this->database->onTransactionPreCommitOrIdle( $callback2, __METHOD__ ); + $this->database->onTransactionResolution( $callback3, __METHOD__ ); + $this->database->endAtomic( __METHOD__ . '_inner' ); + $this->database->cancelAtomic( __METHOD__ ); + $this->database->endAtomic( __METHOD__ . '_outer' ); + $this->assertNull( $callback1Called ); + $this->assertNull( $callback2Called ); + $this->assertEquals( IDatabase::TRIGGER_ROLLBACK, $callback3Called ); + // phpcs:ignore Generic.Files.LineLength + $this->assertLastSql( 'BEGIN; SAVEPOINT wikimedia_rdbms_atomic1; ROLLBACK TO SAVEPOINT wikimedia_rdbms_atomic1; COMMIT; SELECT 3' ); + + $callback1Called = null; + $callback2Called = null; + $callback3Called = null; + $this->database->startAtomic( __METHOD__ . '_outer' ); + $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE ); + $this->database->startAtomic( __METHOD__ . '_inner', IDatabase::ATOMIC_CANCELABLE ); + $this->database->onTransactionIdle( $callback1, __METHOD__ ); + $this->database->onTransactionPreCommitOrIdle( $callback2, __METHOD__ ); + $this->database->onTransactionResolution( $callback3, __METHOD__ ); + $this->database->endAtomic( __METHOD__ . '_inner' ); + $this->database->cancelAtomic( __METHOD__ ); + $this->database->endAtomic( __METHOD__ . '_outer' ); + $this->assertNull( $callback1Called ); + $this->assertNull( $callback2Called ); + $this->assertEquals( IDatabase::TRIGGER_ROLLBACK, $callback3Called ); + // phpcs:ignore Generic.Files.LineLength + $this->assertLastSql( 'BEGIN; SAVEPOINT wikimedia_rdbms_atomic1; SAVEPOINT wikimedia_rdbms_atomic2; RELEASE SAVEPOINT wikimedia_rdbms_atomic2; ROLLBACK TO SAVEPOINT wikimedia_rdbms_atomic1; COMMIT; SELECT 3' ); + + $callback1Called = null; + $callback2Called = null; + $callback3Called = null; + $this->database->startAtomic( __METHOD__ . '_outer' ); + $atomicId = $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE ); + $this->database->startAtomic( __METHOD__ . '_inner' ); + $this->database->onTransactionIdle( $callback1, __METHOD__ ); + $this->database->onTransactionPreCommitOrIdle( $callback2, __METHOD__ ); + $this->database->onTransactionResolution( $callback3, __METHOD__ ); + $this->database->cancelAtomic( __METHOD__, $atomicId ); + $this->database->endAtomic( __METHOD__ . '_outer' ); + $this->assertNull( $callback1Called ); + $this->assertNull( $callback2Called ); + $this->assertEquals( IDatabase::TRIGGER_ROLLBACK, $callback3Called ); + + $callback1Called = null; + $callback2Called = null; + $callback3Called = null; + $this->database->startAtomic( __METHOD__ . '_outer' ); + $atomicId = $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE ); + $this->database->startAtomic( __METHOD__ . '_inner' ); + $this->database->onTransactionIdle( $callback1, __METHOD__ ); + $this->database->onTransactionPreCommitOrIdle( $callback2, __METHOD__ ); + $this->database->onTransactionResolution( $callback3, __METHOD__ ); + try { + $this->database->cancelAtomic( __METHOD__ . '_X', $atomicId ); + } catch ( DBUnexpectedError $e ) { + $m = __METHOD__; + $this->assertSame( + "Invalid atomic section ended (got {$m}_X but expected {$m}).", + $e->getMessage() + ); + } + $this->database->cancelAtomic( __METHOD__ ); + $this->database->endAtomic( __METHOD__ . '_outer' ); + $this->assertNull( $callback1Called ); + $this->assertNull( $callback2Called ); + $this->assertEquals( IDatabase::TRIGGER_ROLLBACK, $callback3Called ); + + $this->database->startAtomic( __METHOD__ . '_outer' ); + $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE ); + $this->database->startAtomic( __METHOD__ . '_inner' ); + $this->database->onTransactionIdle( $callback1, __METHOD__ ); + $this->database->onTransactionPreCommitOrIdle( $callback2, __METHOD__ ); + $this->database->onTransactionResolution( $callback3, __METHOD__ ); + $this->database->cancelAtomic( __METHOD__ . '_inner' ); + $this->database->cancelAtomic( __METHOD__ ); + $this->database->endAtomic( __METHOD__ . '_outer' ); + $this->assertNull( $callback1Called ); + $this->assertNull( $callback2Called ); + $this->assertEquals( IDatabase::TRIGGER_ROLLBACK, $callback3Called ); + + $wrapper = TestingAccessWrapper::newFromObject( $this->database ); + $callback1Called = null; + $callback2Called = null; + $callback3Called = null; + $this->database->startAtomic( __METHOD__ . '_outer' ); + $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE ); + $this->database->startAtomic( __METHOD__ . '_inner' ); + $this->database->onTransactionIdle( $callback1, __METHOD__ ); + $this->database->onTransactionPreCommitOrIdle( $callback2, __METHOD__ ); + $this->database->onTransactionResolution( $callback3, __METHOD__ ); + $wrapper->trxStatus = Database::STATUS_TRX_ERROR; + $this->database->cancelAtomic( __METHOD__ . '_inner' ); + $this->database->cancelAtomic( __METHOD__ ); + $this->database->endAtomic( __METHOD__ . '_outer' ); + $this->assertNull( $callback1Called ); + $this->assertNull( $callback2Called ); + $this->assertEquals( IDatabase::TRIGGER_ROLLBACK, $callback3Called ); + } + + /** + * @covers \Wikimedia\Rdbms\Database::doSavepoint + * @covers \Wikimedia\Rdbms\Database::doReleaseSavepoint + * @covers \Wikimedia\Rdbms\Database::doRollbackToSavepoint + * @covers \Wikimedia\Rdbms\Database::startAtomic + * @covers \Wikimedia\Rdbms\Database::endAtomic + * @covers \Wikimedia\Rdbms\Database::cancelAtomic + * @covers \Wikimedia\Rdbms\Database::doAtomicSection + */ + public function testAtomicSectionsTrxRound() { + $this->database->setFlag( IDatabase::DBO_TRX ); + $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE ); + $this->database->query( 'SELECT 1', __METHOD__ ); + $this->database->endAtomic( __METHOD__ ); + $this->database->commit( __METHOD__, IDatabase::FLUSHING_ALL_PEERS ); + // phpcs:ignore Generic.Files.LineLength + $this->assertLastSql( 'BEGIN; SAVEPOINT wikimedia_rdbms_atomic1; SELECT 1; RELEASE SAVEPOINT wikimedia_rdbms_atomic1; COMMIT' ); + } + + public static function provideAtomicSectionMethodsForErrors() { + return [ + [ 'endAtomic' ], + [ 'cancelAtomic' ], + ]; + } + + /** + * @dataProvider provideAtomicSectionMethodsForErrors + * @covers \Wikimedia\Rdbms\Database::endAtomic + * @covers \Wikimedia\Rdbms\Database::cancelAtomic + */ + public function testNoAtomicSection( $method ) { + try { + $this->database->$method( __METHOD__ ); + $this->fail( 'Expected exception not thrown' ); + } catch ( DBUnexpectedError $ex ) { + $this->assertSame( + 'No atomic section is open (got ' . __METHOD__ . ').', + $ex->getMessage() + ); + } + } + + /** + * @dataProvider provideAtomicSectionMethodsForErrors + * @covers \Wikimedia\Rdbms\Database::endAtomic + * @covers \Wikimedia\Rdbms\Database::cancelAtomic + */ + public function testInvalidAtomicSectionEnded( $method ) { + $this->database->startAtomic( __METHOD__ . 'X' ); + try { + $this->database->$method( __METHOD__ ); + $this->fail( 'Expected exception not thrown' ); + } catch ( DBUnexpectedError $ex ) { + $this->assertSame( + 'Invalid atomic section ended (got ' . __METHOD__ . ' but expected ' . + __METHOD__ . 'X' . ').', + $ex->getMessage() + ); + } + } + + /** + * @covers \Wikimedia\Rdbms\Database::cancelAtomic + */ + public function testUncancellableAtomicSection() { + $this->database->startAtomic( __METHOD__ ); + try { + $this->database->cancelAtomic( __METHOD__ ); + $this->database->select( 'test', '1', [], __METHOD__ ); + $this->fail( 'Expected exception not thrown' ); + } catch ( DBTransactionError $ex ) { + $this->assertSame( + 'Cannot execute query from ' . __METHOD__ . ' while transaction status is ERROR.', + $ex->getMessage() + ); + } + } + + /** + * @expectedException \Wikimedia\Rdbms\DBTransactionStateError + */ + public function testTransactionErrorState1() { + $wrapper = TestingAccessWrapper::newFromObject( $this->database ); + + $this->database->begin( __METHOD__ ); + $wrapper->trxStatus = Database::STATUS_TRX_ERROR; + $this->database->delete( 'x', [ 'field' => 3 ], __METHOD__ ); + $this->database->commit( __METHOD__ ); + } + + /** + * @covers \Wikimedia\Rdbms\Database::query + */ + public function testTransactionErrorState2() { + $wrapper = TestingAccessWrapper::newFromObject( $this->database ); + + $this->database->startAtomic( __METHOD__ ); + $wrapper->trxStatus = Database::STATUS_TRX_ERROR; + $this->database->rollback( __METHOD__ ); + $this->assertEquals( 0, $this->database->trxLevel() ); + $this->assertEquals( Database::STATUS_TRX_NONE, $wrapper->trxStatus() ); + $this->assertLastSql( 'BEGIN; ROLLBACK' ); + + $this->database->startAtomic( __METHOD__ ); + $this->assertEquals( Database::STATUS_TRX_OK, $wrapper->trxStatus() ); + $this->database->delete( 'x', [ 'field' => 1 ], __METHOD__ ); + $this->database->endAtomic( __METHOD__ ); + $this->assertEquals( Database::STATUS_TRX_NONE, $wrapper->trxStatus() ); + $this->assertLastSql( 'BEGIN; DELETE FROM x WHERE field = \'1\'; COMMIT' ); + $this->assertEquals( 0, $this->database->trxLevel(), 'Use after rollback()' ); + + $this->database->begin( __METHOD__ ); + $this->database->startAtomic( __METHOD__, Database::ATOMIC_CANCELABLE ); + $this->database->update( 'y', [ 'a' => 1 ], [ 'field' => 1 ], __METHOD__ ); + $wrapper->trxStatus = Database::STATUS_TRX_ERROR; + $this->database->cancelAtomic( __METHOD__ ); + $this->assertEquals( Database::STATUS_TRX_OK, $wrapper->trxStatus() ); + $this->database->startAtomic( __METHOD__ ); + $this->database->delete( 'y', [ 'field' => 1 ], __METHOD__ ); + $this->database->endAtomic( __METHOD__ ); + $this->database->commit( __METHOD__ ); + // phpcs:ignore Generic.Files.LineLength + $this->assertLastSql( 'BEGIN; SAVEPOINT wikimedia_rdbms_atomic1; UPDATE y SET a = \'1\' WHERE field = \'1\'; ROLLBACK TO SAVEPOINT wikimedia_rdbms_atomic1; DELETE FROM y WHERE field = \'1\'; COMMIT' ); + $this->assertEquals( 0, $this->database->trxLevel(), 'Use after rollback()' ); + + // Next transaction + $this->database->startAtomic( __METHOD__ ); + $this->assertEquals( Database::STATUS_TRX_OK, $wrapper->trxStatus() ); + $this->database->delete( 'x', [ 'field' => 3 ], __METHOD__ ); + $this->database->endAtomic( __METHOD__ ); + $this->assertEquals( Database::STATUS_TRX_NONE, $wrapper->trxStatus() ); + $this->assertLastSql( 'BEGIN; DELETE FROM x WHERE field = \'3\'; COMMIT' ); + $this->assertEquals( 0, $this->database->trxLevel() ); + } + + /** + * @covers \Wikimedia\Rdbms\Database::query + */ + public function testImplicitTransactionRollback() { + $doError = function () { + $this->database->forceNextQueryError( 666, 'Evilness' ); + try { + $this->database->delete( 'error', '1', __CLASS__ . '::SomeCaller' ); + $this->fail( 'Expected exception not thrown' ); + } catch ( DBError $e ) { + $this->assertSame( 666, $e->errno ); + } + }; + + $this->database->setFlag( Database::DBO_TRX ); + + // Implicit transaction gets silently rolled back + $this->database->begin( __METHOD__, Database::TRANSACTION_INTERNAL ); + call_user_func( $doError ); + $this->database->delete( 'x', [ 'field' => 1 ], __METHOD__ ); + $this->database->commit( __METHOD__, Database::FLUSHING_INTERNAL ); + // phpcs:ignore + $this->assertLastSql( 'BEGIN; DELETE FROM error WHERE 1; ROLLBACK; BEGIN; DELETE FROM x WHERE field = \'1\'; COMMIT' ); + + // ... unless there were prior writes + $this->database->begin( __METHOD__, Database::TRANSACTION_INTERNAL ); + $this->database->delete( 'x', [ 'field' => 1 ], __METHOD__ ); + call_user_func( $doError ); + try { + $this->database->delete( 'x', [ 'field' => 1 ], __METHOD__ ); + $this->fail( 'Expected exception not thrown' ); + } catch ( DBTransactionStateError $e ) { + } + $this->database->rollback( __METHOD__, Database::FLUSHING_INTERNAL ); + // phpcs:ignore + $this->assertLastSql( 'BEGIN; DELETE FROM x WHERE field = \'1\'; DELETE FROM error WHERE 1; ROLLBACK' ); + } + + /** + * @covers \Wikimedia\Rdbms\Database::query + */ + public function testTransactionStatementRollbackIgnoring() { + $wrapper = TestingAccessWrapper::newFromObject( $this->database ); + $warning = []; + $wrapper->deprecationLogger = function ( $msg ) use ( &$warning ) { + $warning[] = $msg; + }; + + $doError = function () { + $this->database->forceNextQueryError( 666, 'Evilness', [ + 'wasKnownStatementRollbackError' => true, + ] ); + try { + $this->database->delete( 'error', '1', __CLASS__ . '::SomeCaller' ); + $this->fail( 'Expected exception not thrown' ); + } catch ( DBError $e ) { + $this->assertSame( 666, $e->errno ); + } + }; + $expectWarning = 'Caller from ' . __METHOD__ . + ' ignored an error originally raised from ' . __CLASS__ . '::SomeCaller: [666] Evilness'; + + // Rollback doesn't raise a warning + $warning = []; + $this->database->startAtomic( __METHOD__ ); + call_user_func( $doError ); + $this->database->rollback( __METHOD__ ); + $this->database->delete( 'x', [ 'field' => 1 ], __METHOD__ ); + $this->assertSame( [], $warning ); + // phpcs:ignore + $this->assertLastSql( 'BEGIN; DELETE FROM error WHERE 1; ROLLBACK; DELETE FROM x WHERE field = \'1\'' ); + + // cancelAtomic() doesn't raise a warning + $warning = []; + $this->database->begin( __METHOD__ ); + $this->database->startAtomic( __METHOD__, Database::ATOMIC_CANCELABLE ); + call_user_func( $doError ); + $this->database->cancelAtomic( __METHOD__ ); + $this->database->delete( 'x', [ 'field' => 1 ], __METHOD__ ); + $this->database->commit( __METHOD__ ); + $this->assertSame( [], $warning ); + // phpcs:ignore + $this->assertLastSql( 'BEGIN; SAVEPOINT wikimedia_rdbms_atomic1; DELETE FROM error WHERE 1; ROLLBACK TO SAVEPOINT wikimedia_rdbms_atomic1; DELETE FROM x WHERE field = \'1\'; COMMIT' ); + + // Commit does raise a warning + $warning = []; + $this->database->begin( __METHOD__ ); + call_user_func( $doError ); + $this->database->commit( __METHOD__ ); + $this->assertSame( [ $expectWarning ], $warning ); + $this->assertLastSql( 'BEGIN; DELETE FROM error WHERE 1; COMMIT' ); + + // Deprecation only gets raised once + $warning = []; + $this->database->begin( __METHOD__ ); + call_user_func( $doError ); + $this->database->delete( 'x', [ 'field' => 1 ], __METHOD__ ); + $this->database->commit( __METHOD__ ); + $this->assertSame( [ $expectWarning ], $warning ); + // phpcs:ignore + $this->assertLastSql( 'BEGIN; DELETE FROM error WHERE 1; DELETE FROM x WHERE field = \'1\'; COMMIT' ); + } + + /** + * @covers \Wikimedia\Rdbms\Database::close + */ + public function testPrematureClose1() { + $fname = __METHOD__; + $this->database->begin( __METHOD__ ); + $this->database->onTransactionIdle( function () use ( $fname ) { + $this->database->query( 'SELECT 1', $fname ); + } ); + $this->database->delete( 'x', [ 'field' => 3 ], __METHOD__ ); + $this->database->close(); + + $this->assertFalse( $this->database->isOpen() ); + $this->assertLastSql( 'BEGIN; DELETE FROM x WHERE field = \'3\'; COMMIT; SELECT 1' ); + $this->assertEquals( 0, $this->database->trxLevel() ); + } + + /** + * @covers \Wikimedia\Rdbms\Database::close + */ + public function testPrematureClose2() { + try { + $fname = __METHOD__; + $this->database->startAtomic( __METHOD__ ); + $this->database->onTransactionIdle( function () use ( $fname ) { + $this->database->query( 'SELECT 1', $fname ); + } ); + $this->database->delete( 'x', [ 'field' => 3 ], __METHOD__ ); + $this->database->close(); + $this->fail( 'Expected exception not thrown' ); + } catch ( DBUnexpectedError $ex ) { + $this->assertSame( + 'Wikimedia\Rdbms\Database::close: atomic sections ' . + 'DatabaseSQLTest::testPrematureClose2 are still open.', + $ex->getMessage() + ); + } + + $this->assertFalse( $this->database->isOpen() ); + $this->assertLastSql( 'BEGIN; DELETE FROM x WHERE field = \'3\'; ROLLBACK' ); + $this->assertEquals( 0, $this->database->trxLevel() ); + } + + /** + * @covers \Wikimedia\Rdbms\Database::close + */ + public function testPrematureClose3() { + try { + $this->database->setFlag( IDatabase::DBO_TRX ); + $this->database->delete( 'x', [ 'field' => 3 ], __METHOD__ ); + $this->assertEquals( 1, $this->database->trxLevel() ); + $this->database->close(); + $this->fail( 'Expected exception not thrown' ); + } catch ( DBUnexpectedError $ex ) { + $this->assertSame( + 'Wikimedia\Rdbms\Database::close: ' . + 'mass commit/rollback of peer transaction required (DBO_TRX set).', + $ex->getMessage() + ); + } + + $this->assertFalse( $this->database->isOpen() ); + $this->assertLastSql( 'BEGIN; DELETE FROM x WHERE field = \'3\'; ROLLBACK' ); + $this->assertEquals( 0, $this->database->trxLevel() ); + } + + /** + * @covers \Wikimedia\Rdbms\Database::close + */ + public function testPrematureClose4() { + $this->database->setFlag( IDatabase::DBO_TRX ); + $this->database->query( 'SELECT 1', __METHOD__ ); + $this->assertEquals( 1, $this->database->trxLevel() ); + $this->database->close(); + $this->database->clearFlag( IDatabase::DBO_TRX ); + + $this->assertFalse( $this->database->isOpen() ); + $this->assertLastSql( 'BEGIN; SELECT 1; COMMIT' ); + $this->assertEquals( 0, $this->database->trxLevel() ); + } + + /** + * @covers Wikimedia\Rdbms\Database::selectFieldValues() + */ + public function testSelectFieldValues() { + $this->database->forceNextResult( [ + (object)[ 'value' => 'row1' ], + (object)[ 'value' => 'row2' ], + (object)[ 'value' => 'row3' ], + ] ); + + $this->assertSame( + [ 'row1', 'row2', 'row3' ], + $this->database->selectFieldValues( 'table', 'table.field', 'conds', __METHOD__ ) + ); + $this->assertLastSql( 'SELECT table.field AS value FROM table WHERE conds' ); + } }