From 729f695dff171423d30782a8e048a2bfed72d953 Mon Sep 17 00:00:00 2001 From: Gregory Gaskill Date: Fri, 16 Feb 2018 12:33:24 -0500 Subject: [PATCH] Add shell and test, add composer.json --- Console/Command/TableMaintenanceShell.php | 146 ++++++ .../Command/TableMaintenanceShellTest.php | 496 ++++++++++++++++++ composer.json | 26 + 3 files changed, 668 insertions(+) create mode 100644 Console/Command/TableMaintenanceShell.php create mode 100644 Test/Case/Console/Command/TableMaintenanceShellTest.php create mode 100644 composer.json diff --git a/Console/Command/TableMaintenanceShell.php b/Console/Command/TableMaintenanceShell.php new file mode 100644 index 0000000..a9ec4bf --- /dev/null +++ b/Console/Command/TableMaintenanceShell.php @@ -0,0 +1,146 @@ +addSubcommand('run', [ + 'help' => 'Run MySQL maintenance on one or all tables', + 'parser' => [ + 'arguments' => [ + 'action' => [ + 'help' => 'Action to perform: check, analyze, optimize, repair', + 'required' => true, + 'choices' => ['check', 'analyze', 'optimize', 'repair'], + ], + 'table' => [ + 'help' => 'Table to check or "ALL" for all tables', + 'required' => true, + ], + ], + ], + ]) + ->description([ + 'Use this command to check, analyze, optimize, or repair MySQL tables', + ]); + + return $parser; + } + + /** + * Method to "run" various table maintenance actions and output results. + * + * @return void + */ + public function run() { + $action = strtoupper($this->args[0]); + $table = $this->args[1]; + $lockMode = $action == 'CHECK' ? 'READ' : 'WRITE'; + + $db = $this->getDataSource(); + + $tables = [$table]; + if ($tables[0] == 'ALL') { + $tables = $this->getAllTableNames($db); + } + + foreach ($tables as $table) { + $query = [ + 'lock' => 'LOCK TABLES `' . $table . '` ' . $lockMode . ';', + 'action' => $action . ' TABLE `' . $table . '`;', + 'unlock' => 'UNLOCK TABLES;', + ]; + + $tableParams = $db->readTableParameters($table); + + if (empty($tableParams)) { + $this->out( + 'Error for `' . $action . '` on `' . $table . '`: Table does not exist', + 1, + Shell::QUIET + ); + continue; + } + + $db->query($query['lock']); + $result = $db->query($query['action']); + $db->query($query['unlock']); + + $msgType = Hash::extract($result, '{n}.0.Msg_type'); + $msgText = Hash::combine($result, '{n}.0.Msg_type', '{n}.0.Msg_text'); + + $error = array_intersect($msgType, $this->errorMsgs); + $error = !empty($error); + + if ($error) { + $errorMsgs = json_encode($msgText); + $this->out( + 'Error message(s) for `' . $action . '` on `' . $table . '`: ' . $errorMsgs . '', + 1, + Shell::QUIET + ); + } else { + $msg = 'Success for `' . $action . '` on `' . $table . '`'; + if (count($msgText) > 1) { + $infoMsgs = json_encode($msgText); + $this->out("{$msg}: {$infoMsgs}", 1, Shell::NORMAL); + } else { + $this->out($msg, 1, Shell::NORMAL); + } + } + } + } + + /** + * Wrapper method for ConnectionManager::getDataSource() + * + * @param string $dataSource The dataSource config to use + * @return object The dataSource object + */ + protected function getDataSource($dataSource = 'default') { + return ConnectionManager::getDataSource($dataSource); + } + + /** + * Gets a list of tables only (no views) from the currently selected + * database. + * + * @param $db A MySQL db object created by ConnectionManager::getDataSource() + * @return array An array of database tables + */ + protected function getAllTableNames($db) { + $tables = $db->query("SHOW FULL TABLES WHERE Table_Type = 'BASE TABLE'"); + $tables = Hash::remove($tables, '{n}.TABLE_NAMES.Table_type'); + + return Hash::extract($tables, '{n}.TABLE_NAMES.{s}'); + } +} diff --git a/Test/Case/Console/Command/TableMaintenanceShellTest.php b/Test/Case/Console/Command/TableMaintenanceShellTest.php new file mode 100644 index 0000000..77cc8ab --- /dev/null +++ b/Test/Case/Console/Command/TableMaintenanceShellTest.php @@ -0,0 +1,496 @@ + 'vagrant.blocks', + 'Op' => 'check', + 'Msg_type' => 'status', + 'Msg_text' => 'OK' + ] + ] + ]; + + /** + * resultSuccessWithInfo + * + * @var mixed + */ + public $resultSuccessWithInfo = [ + [ + [ + 'Table' => 'vagrant.shelltest', + 'Op' => 'optimize', + 'Msg_type' => 'note', + 'Msg_text' => 'Table does not support optimize, doing recreate + analyze instead', + ] + ], + [ + [ + 'Table' => 'vagrant.shelltest', + 'Op' => 'optimize', + 'Msg_type' => 'status', + 'Msg_text' => 'OK', + ] + ] + ]; + + /** + * resultWithError + * + * @var mixed + */ + public $resultWithError = [ + [ + [ + 'Table' => 'vagrant.categories', + 'Op' => 'repair', + 'Msg_type' => 'info', + 'Msg_text' => 'Wrong bytesec: 1- 0- 0 at 0; Skipped' + ] + ], + [ + [ + 'Table' => 'vagrant.categories', + 'Op' => 'repair', + 'Msg_type' => 'warning', + 'Msg_text' => 'Number of rows changed from 32 to 0' + ] + ], + [ + [ + 'Table' => 'vagrant.categories', + 'Op' => 'repair', + 'Msg_type' => 'status', + 'Msg_text' => 'OK' + ] + ] + ]; + + /** + * setUp + * + * @return void + */ + public function setUp() { + parent::setUp(); + $this->Shell = $this->getMockBuilder('TestTableMaintenanceShell') + ->setMethods(['out', 'getDataSource', 'getAllTableNames']) + ->getMock(); + } + + /** + * tearDown method + * + * @return void + */ + public function tearDown() { + parent::tearDown(); + unset($this->Dispatch, $this->Shell); + } + + /** + * Confirm the expected output is displayed for any action when a table + * does not exist. + * + * @dataProvider provideTestRunInvalidTable + * @return void + */ + public function testRunInvalidTable($action, $table, $out) { + $this->Shell->args = [$action, $table]; + + $db = $this->getMockBuilder('ConnectionManager') + ->disableOriginalConstructor() + ->setMethods(['getDataSource', 'readTableParameters']) + ->getMock(); + $db->expects($this->once()) + ->method('readTableParameters') + ->with($this->identicalTo($table)) + ->will($this->returnValue([])); + + $this->Shell->expects($this->once()) + ->method('getDataSource') + ->will($this->returnValue($db)); + $this->Shell->expects($this->once()) + ->method('out') + ->with($this->identicalTo($out)); + + $this->Shell->run(); + } + + /** + * provideTestRunInvalidTable + * + * @return void + */ + public function provideTestRunInvalidTable() { + return [ + [ + 'check', + 'foo', + 'Error for `CHECK` on `foo`: Table does not exist', + ], + [ + 'analyze', + 'foo', + 'Error for `ANALYZE` on `foo`: Table does not exist', + ], + [ + 'optimize', + 'foo', + 'Error for `OPTIMIZE` on `foo`: Table does not exist', + ], + [ + 'repair', + 'foo', + 'Error for `REPAIR` on `foo`: Table does not exist', + ], + ]; + } + + /** + * Confirm the expected success output is displayed for any action. + * + * @dataProvider provideTestRunSingleTableSuccess + * @return void + */ + public function testRunSingleTableSuccess($action, $table, $queryLock, $queryAction, $out) { + $this->Shell->args = [$action, $table]; + + $db = $this->getMockBuilder('ConnectionManager') + ->disableOriginalConstructor() + ->setMethods(['getDataSource', 'readTableParameters', 'query']) + ->getMock(); + $db->expects($this->once()) + ->method('readTableParameters') + ->with($this->identicalTo($table)) + ->will($this->returnValue([ + 'charset' => 'utf8', + 'collate' => 'utf8_unicode_ci', + 'engine' => 'InnoDB', + ])); + + $db->expects($this->at(1)) + ->method('query') + ->with($this->identicalTo($queryLock)); + $db->expects($this->at(2)) + ->method('query') + ->with($this->identicalTo($queryAction)) + ->will($this->returnValue($this->resultSuccess)); + $db->expects($this->at(3)) + ->method('query') + ->with($this->identicalTo('UNLOCK TABLES;')); + + $this->Shell->expects($this->once()) + ->method('getDataSource') + ->will($this->returnValue($db)); + $this->Shell->expects($this->once()) + ->method('out') + ->with($this->identicalTo($out)); + + $this->Shell->run(); + } + + /** + * provideTestRunSingleTableSuccess + * + * @return void + */ + public function provideTestRunSingleTableSuccess() { + return [ + [ + 'check', + 'foo', + 'LOCK TABLES `foo` READ;', + 'CHECK TABLE `foo`;', + 'Success for `CHECK` on `foo`', + ], + [ + 'analyze', + 'foo', + 'LOCK TABLES `foo` WRITE;', + 'ANALYZE TABLE `foo`;', + 'Success for `ANALYZE` on `foo`', + ], + [ + 'optimize', + 'foo', + 'LOCK TABLES `foo` WRITE;', + 'OPTIMIZE TABLE `foo`;', + 'Success for `OPTIMIZE` on `foo`', + ], + [ + 'repair', + 'foo', + 'LOCK TABLES `foo` WRITE;', + 'REPAIR TABLE `foo`;', + 'Success for `REPAIR` on `foo`', + ], + ]; + } + + /** + * Confirm the expected error output is dispayed if any part of the response + * contains error or warning keys. + * + * @return void + */ + public function testRunSingleTableWithErrors() { + $action = 'repair'; + $table = 'foo'; + $queryLock = "LOCK TABLES `$table` WRITE;"; + $queryAction = "REPAIR TABLE `$table`;"; + $out = 'Error message(s) for `REPAIR` on `foo`: {"info":"Wrong bytesec: 1- 0- 0 at 0; Skipped","warning":"Number of rows changed from 32 to 0","status":"OK"}'; + + $this->Shell->args = [$action, $table]; + + $db = $this->getMockBuilder('ConnectionManager') + ->disableOriginalConstructor() + ->setMethods(['getDataSource', 'readTableParameters', 'query']) + ->getMock(); + $db->expects($this->once()) + ->method('readTableParameters') + ->with($this->identicalTo($table)) + ->will($this->returnValue([ + 'charset' => 'utf8', + 'collate' => 'utf8_unicode_ci', + 'engine' => 'InnoDB', + ])); + + $db->expects($this->at(1)) + ->method('query') + ->with($this->identicalTo($queryLock)); + $db->expects($this->at(2)) + ->method('query') + ->with($this->identicalTo($queryAction)) + ->will($this->returnValue($this->resultWithError)); + $db->expects($this->at(3)) + ->method('query') + ->with($this->identicalTo('UNLOCK TABLES;')); + + $this->Shell->expects($this->once()) + ->method('getDataSource') + ->will($this->returnValue($db)); + $this->Shell->expects($this->once()) + ->method('out') + ->with($this->identicalTo($out)); + + $this->Shell->run(); + } + + /** + * Confirm the expected info output is dispayed if any part of the response + * contains multiple non-error keys. + * + * @return void + */ + public function testRunSingleTableWithInfo() { + $action = 'optimize'; + $table = 'foo'; + $queryLock = "LOCK TABLES `$table` WRITE;"; + $queryAction = "OPTIMIZE TABLE `$table`;"; + $out = 'Success for `OPTIMIZE` on `foo`: {"note":"Table does not support optimize, doing recreate + analyze instead","status":"OK"}'; + + $this->Shell->args = [$action, $table]; + + $db = $this->getMockBuilder('ConnectionManager') + ->disableOriginalConstructor() + ->setMethods(['getDataSource', 'readTableParameters', 'query']) + ->getMock(); + $db->expects($this->once()) + ->method('readTableParameters') + ->with($this->identicalTo($table)) + ->will($this->returnValue([ + 'charset' => 'utf8', + 'collate' => 'utf8_unicode_ci', + 'engine' => 'InnoDB', + ])); + + $db->expects($this->at(1)) + ->method('query') + ->with($this->identicalTo($queryLock)); + $db->expects($this->at(2)) + ->method('query') + ->with($this->identicalTo($queryAction)) + ->will($this->returnValue($this->resultSuccessWithInfo)); + $db->expects($this->at(3)) + ->method('query') + ->with($this->identicalTo('UNLOCK TABLES;')); + + $this->Shell->expects($this->once()) + ->method('getDataSource') + ->will($this->returnValue($db)); + $this->Shell->expects($this->once()) + ->method('out') + ->with($this->identicalTo($out)); + + $this->Shell->run(); + } + + /** + * Confirm the expected info output is dispayed when the ALL action is used + * to run commands on all tables. + * + * @return void + */ + public function testRunAll() { + $action = 'check'; + $table = 'ALL'; + + $this->Shell->args = [$action, $table]; + + $db = $this->getMockBuilder('ConnectionManager') + ->disableOriginalConstructor() + ->setMethods(['getDataSource', 'readTableParameters', 'query']) + ->getMock(); + $db->expects($this->at(0)) + ->method('readTableParameters') + ->with($this->identicalTo('foo')) + ->will($this->returnValue([ + 'charset' => 'utf8', + 'collate' => 'utf8_unicode_ci', + 'engine' => 'InnoDB', + ])); + $db->expects($this->at(4)) + ->method('readTableParameters') + ->with($this->identicalTo('bar')) + ->will($this->returnValue([ + 'charset' => 'utf8', + 'collate' => 'utf8_unicode_ci', + 'engine' => 'InnoDB', + ])); + + $db->expects($this->at(1)) + ->method('query') + ->with($this->identicalTo('LOCK TABLES `foo` READ;')); + $db->expects($this->at(2)) + ->method('query') + ->with($this->identicalTo('CHECK TABLE `foo`;')) + ->will($this->returnValue($this->resultSuccess)); + $db->expects($this->at(3)) + ->method('query') + ->with($this->identicalTo('UNLOCK TABLES;')); + $db->expects($this->at(5)) + ->method('query') + ->with($this->identicalTo('LOCK TABLES `bar` READ;')); + $db->expects($this->at(6)) + ->method('query') + ->with($this->identicalTo('CHECK TABLE `bar`;')) + ->will($this->returnValue($this->resultSuccess)); + $db->expects($this->at(7)) + ->method('query') + ->with($this->identicalTo('UNLOCK TABLES;')); + + $this->Shell->expects($this->once()) + ->method('getAllTableNames') + ->with($db) + ->will($this->returnValue(['foo', 'bar'])); + $this->Shell->expects($this->once()) + ->method('getDataSource') + ->will($this->returnValue($db)); + $this->Shell->expects($this->at(2)) + ->method('out') + ->with($this->identicalTo('Success for `CHECK` on `foo`')); + $this->Shell->expects($this->at(3)) + ->method('out') + ->with($this->identicalTo('Success for `CHECK` on `bar`')); + + $this->Shell->run(); + } + + /** + * Confirm an instance of Mysql can be returned. + * + * @return void + */ + public function testGetDataSource() { + $shell = new TestTableMaintenanceShell; + $result = $shell->getDataSource(); + + $this->assertInstanceOf('Mysql', $result); + } + + /** + * Confirm the method can return data in the expected format. + * + * @return void + */ + public function testGetAllTableNames() { + $data = [ + [ + 'TABLE_NAMES' => [ + 'Tables_in_vagrant' => 'user_types', + 'Table_type' => 'BASE TABLE' + ] + ], + [ + 'TABLE_NAMES' => [ + 'Tables_in_vagrant' => 'users', + 'Table_type' => 'BASE TABLE' + ] + ], + [ + 'TABLE_NAMES' => [ + 'Tables_in_vagrant' => 'users_revisions', + 'Table_type' => 'BASE TABLE' + ] + ] + ]; + + $expected = [ + 'user_types', + 'users', + 'users_revisions', + ]; + + $db = $this->getMockBuilder('ConnectionManager') + ->disableOriginalConstructor() + ->setMethods(['query']) + ->getMock(); + + $db->expects($this->once()) + ->method('query') + ->with("SHOW FULL TABLES WHERE Table_Type = 'BASE TABLE'") + ->will($this->returnValue($data)); + + $shell = new TestTableMaintenanceShell; + $result = $shell->getAllTableNames($db); + + $this->assertSame($expected, $result); + } +} diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..5370e6d --- /dev/null +++ b/composer.json @@ -0,0 +1,26 @@ +{ + "name": "loadsys/cakephp-tablemaintenanceshell", + "description": "A CakePHP console tool to run common MySQL database maintenance queries", + "type": "cakephp-plugin", + "keywords": ["cakephp", "mysql", "database"], + "homepage": "https://github.com/loadsys/CakePHP-TableMaintenanceShell", + "license": "MIT", + "authors": [ + { + "name": "Gregory Gaskill", + "homepage": "https://www.loadsys.com", + "role": "Author" + } + ], + "support": { + "issues": "https://github.com/loadsys/CakePHP-TableMaintenanceShell/issues", + "source": "https://github.com/loadsys/CakePHP-TableMaintenanceShell" + }, + "require": { + "php": ">=5.3.0", + "composer/installers": "*" + }, + "extra": { + "installer-name": "TableMaintenance" + } +}