From 9c6a93a87c835dee5fb0b580865d4f70836685cf Mon Sep 17 00:00:00 2001 From: Robin Appelman Date: Wed, 29 Jan 2014 15:48:12 +0100 Subject: [PATCH] Add a mechanism to try the database migration on a copy of the tables before running it on the "real" data --- lib/private/db/migrationexception.php | 19 ++++ lib/private/db/migrator.php | 139 ++++++++++++++++++++++++++ tests/lib/db/migrator.php | 84 ++++++++++++++++ 3 files changed, 242 insertions(+) create mode 100644 lib/private/db/migrationexception.php create mode 100644 lib/private/db/migrator.php create mode 100644 tests/lib/db/migrator.php diff --git a/lib/private/db/migrationexception.php b/lib/private/db/migrationexception.php new file mode 100644 index 0000000000..f257534812 --- /dev/null +++ b/lib/private/db/migrationexception.php @@ -0,0 +1,19 @@ + + * This file is licensed under the Affero General Public License version 3 or + * later. + * See the COPYING-README file. + */ + +namespace OC\DB; + + +class MigrationException extends \Exception { + private $table; + + public function __construct($table, $message) { + $this->$table = $table; + parent::__construct($message); + } +} diff --git a/lib/private/db/migrator.php b/lib/private/db/migrator.php new file mode 100644 index 0000000000..91060663f8 --- /dev/null +++ b/lib/private/db/migrator.php @@ -0,0 +1,139 @@ + + * This file is licensed under the Affero General Public License version 3 or + * later. + * See the COPYING-README file. + */ + +namespace OC\DB; + +use \Doctrine\DBAL\DBALException; +use \Doctrine\DBAL\Schema\Table; +use \Doctrine\DBAL\Schema\Schema; +use \Doctrine\DBAL\Schema\SchemaConfig; + +class Migrator { + /** + * @var \Doctrine\DBAL\Connection $connection + */ + protected $connection; + + /** + * @param \Doctrine\DBAL\Connection $connection + */ + public function __construct(\Doctrine\DBAL\Connection $connection) { + $this->connection = $connection; + } + + /** + * @param \Doctrine\DBAL\Schema\Schema $targetSchema + */ + public function migrate(Schema $targetSchema) { + $this->applySchema($targetSchema); + } + + /** + * @param Schema $targetSchema + */ + public function checkMigrate(Schema $targetSchema) { + /** + * @var \Doctrine\DBAL\Schema\Table[] $tables + */ + $tables = $targetSchema->getTables(); + + $existingTables = $this->connection->getSchemaManager()->listTableNames(); + + foreach ($tables as $table) { + // don't need to check for new tables + list(, $tableName) = explode('.', $table->getName()); + if (array_search($tableName, $existingTables) !== false) { + $this->checkTableMigrate($table); + } + } + } + + /** + * Check the migration of a table on a copy so we can detect errors before messing with the real table + * + * @param \Doctrine\DBAL\Schema\Table $table + */ + protected function checkTableMigrate(Table $table) { + $name = $table->getName(); + $tmpName = $name . '_copy_' . uniqid(); + + $this->copyTable($name, $tmpName); + + //create the migration schema for the temporary table + $tmpTable = new Table($tmpName, $table->getColumns(), $table->getIndexes(), $table->getForeignKeys(), $table->_idGeneratorType, $table->getOptions()); + $schemaConfig = new SchemaConfig(); + $schemaConfig->setName($this->connection->getDatabase()); + $schema = new Schema(array($tmpTable), array(), $schemaConfig); + + try { + $this->applySchema($schema); + $this->dropTable($tmpName); + } catch (DBALException $e) { + $this->dropTable($tmpName); + throw new MigrationException($table->getName(), $e->getMessage()); + } + } + + /** + * @param \Doctrine\DBAL\Schema\Schema $targetSchema + * @param \Doctrine\DBAL\Connection $connection + */ + protected function applySchema(Schema $targetSchema, \Doctrine\DBAL\Connection $connection = null) { + if (is_null($connection)) { + $connection = $this->connection; + } + + $sourceSchema = $this->connection->getSchemaManager()->createSchema(); + + // remove tables we don't know about + /** @var $table \Doctrine\DBAL\Schema\Table */ + foreach ($sourceSchema->getTables() as $table) { + if (!$targetSchema->hasTable($table->getName())) { + $sourceSchema->dropTable($table->getName()); + } + } + // remove sequences we don't know about + foreach ($sourceSchema->getSequences() as $table) { + if (!$targetSchema->hasSequence($table->getName())) { + $sourceSchema->dropSequence($table->getName()); + } + } + + $comparator = new \Doctrine\DBAL\Schema\Comparator(); + $schemaDiff = $comparator->compare($sourceSchema, $targetSchema); + + foreach ($schemaDiff->changedTables as $tableDiff) { + $tableDiff->name = $this->connection->quoteIdentifier($tableDiff->name); + } + + $connection->beginTransaction(); + foreach ($schemaDiff->toSql($connection->getDatabasePlatform()) as $sql) { + $connection->query($sql); + } + $connection->commit(); + } + + /** + * @param string $sourceName + * @param string $targetName + */ + protected function copyTable($sourceName, $targetName) { + $quotedSource = $this->connection->quoteIdentifier($sourceName); + $quotedTarget = $this->connection->quoteIdentifier($targetName); + + $this->connection->exec('CREATE TABLE ' . $quotedTarget . ' LIKE ' . $quotedSource); + $this->connection->exec('INSERT INTO ' . $quotedTarget . ' SELECT * FROM ' . $quotedSource); + } + + /** + * @param string $name + */ + protected function dropTable($name) { + $this->connection->exec('DROP TABLE ' . $this->connection->quoteIdentifier($name)); + } +} diff --git a/tests/lib/db/migrator.php b/tests/lib/db/migrator.php new file mode 100644 index 0000000000..ed7c36dbdf --- /dev/null +++ b/tests/lib/db/migrator.php @@ -0,0 +1,84 @@ + + * This file is licensed under the Affero General Public License version 3 or + * later. + * See the COPYING-README file. + */ + +namespace Test\DB; + +use \Doctrine\DBAL\Schema\Schema; +use \Doctrine\DBAL\Schema\SchemaConfig; + +class Migrator extends \PHPUnit_Framework_TestCase { + /** + * @var \Doctrine\DBAL\Connection $connection + */ + private $connection; + + private $tableName; + + public function setUp() { + $this->tableName = 'test_' . uniqid(); + $this->connection = \OC_DB::getConnection(); + $this->tableName = $this->connection->getDatabase() . '.' . $this->tableName; + } + + public function tearDown() { + $this->connection->exec('DROP TABLE ' . $this->tableName); + } + + private function getInitialSchema() { + $schema = new Schema(array(), array(), $this->getSchemaConfig()); + $table = $schema->createTable($this->tableName); + $table->addColumn('id', 'integer'); + $table->addColumn('name', 'string'); + return $schema; + } + + private function getNewSchema() { + $schema = new Schema(array(), array(), $this->getSchemaConfig()); + $table = $schema->createTable($this->tableName); + $table->addColumn('id', 'integer'); + $table->addColumn('name', 'string'); + $table->addUniqueIndex(array('id')); + return $schema; + } + + private function getSchemaConfig() { + $config = new SchemaConfig(); + $config->setName($this->connection->getDatabase()); + return $config; + } + + /** + * @expectedException \OC\DB\MigrationException + */ + public function testDuplicateKeyUpgrade() { + $migrator = new \OC\DB\Migrator($this->connection); + $migrator->migrate($this->getInitialSchema()); + + $this->connection->insert($this->tableName, array('id' => 1, 'name' => 'foo')); + $this->connection->insert($this->tableName, array('id' => 2, 'name' => 'bar')); + $this->connection->insert($this->tableName, array('id' => 2, 'name' => 'qwerty')); + + $migrator->checkMigrate($this->getNewSchema()); + $this->fail('checkMigrate should have failed'); + } + + public function testUpgrade() { + $migrator = new \OC\DB\Migrator($this->connection); + $migrator->migrate($this->getInitialSchema()); + + $this->connection->insert($this->tableName, array('id' => 1, 'name' => 'foo')); + $this->connection->insert($this->tableName, array('id' => 2, 'name' => 'bar')); + $this->connection->insert($this->tableName, array('id' => 3, 'name' => 'qwerty')); + + $newSchema = $this->getNewSchema(); + $migrator->checkMigrate($newSchema); + $migrator->migrate($newSchema); + $this->assertTrue(true); + } +}