diff --git a/README.md b/README.md index 63b887c..c23f70a 100644 --- a/README.md +++ b/README.md @@ -15,3 +15,4 @@ $ composer require tithely/exo ## Documentation - [Introduction](doc/01-introduction.md) - [Usage](doc/02-usage.md) +- [Testing](doc/03-testing.md) diff --git a/src/ExecMigration.php b/src/ExecMigration.php index 45cb35e..9c5ba77 100644 --- a/src/ExecMigration.php +++ b/src/ExecMigration.php @@ -33,7 +33,7 @@ public static function create(string $name): ExecMigration * @param string $name * @param string|null $body */ - private function __construct(string $name, string $body = null) + private function __construct(string $name, ?string $body = null) { $this->name = $name; $this->body = $body; diff --git a/src/FunctionMigration.php b/src/FunctionMigration.php index 200fd11..0cf50ec 100644 --- a/src/FunctionMigration.php +++ b/src/FunctionMigration.php @@ -104,13 +104,13 @@ public static function drop(string $name): FunctionMigration private function __construct( string $name, string $operation, - ReturnTypeOperation $returnType = null, + ?ReturnTypeOperation $returnType = null, bool $deterministic = false, string $dataUse = 'READS SQL DATA', string $language = 'plpgsql', array $parameterOperations = [], array $variableOperations = [], - string $body = null + ?string $body = null ) { $this->name = $name; $this->operation = $operation; diff --git a/src/History.php b/src/History.php index d51c614..7346709 100644 --- a/src/History.php +++ b/src/History.php @@ -64,7 +64,7 @@ public function add(string $version, MigrationInterface $migrationOrView) * @param array|null $versions * @return History */ - public function clone(array $versions = null): History + public function clone(?array $versions = null): History { $history = new History(); diff --git a/src/Operation/ColumnOperation.php b/src/Operation/ColumnOperation.php index dc8d6af..a77bcb6 100644 --- a/src/Operation/ColumnOperation.php +++ b/src/Operation/ColumnOperation.php @@ -6,6 +6,7 @@ final class ColumnOperation extends AbstractOperation { const ADD = 'add'; const MODIFY = 'modify'; + const CHANGE = 'change'; const DROP = 'drop'; /** @@ -13,6 +14,16 @@ final class ColumnOperation extends AbstractOperation */ private string $operation; + /** + * @var string + */ + private string $beforeName; + + /** + * @var string + */ + private string $afterName; + /** * @var array */ @@ -30,6 +41,14 @@ public function __construct(string $name, string $operation, array $options) $this->name = $name; $this->operation = $operation; $this->options = $options; + + if ($operation === self::CHANGE && isset($options['new_name'])) { + $this->beforeName = $name; + $this->afterName = $options['new_name']; + } else { + $this->beforeName = $name; + $this->afterName = $name; + } } /** @@ -51,4 +70,24 @@ public function getOptions(): array { return $this->options; } + + /** + * Returns the before name (original name for CHANGE operations). + * + * @return string + */ + public function getBeforeName(): string + { + return $this->beforeName; + } + + /** + * Returns the after name (new name for CHANGE operations). + * + * @return string + */ + public function getAfterName(): string + { + return $this->afterName; + } } diff --git a/src/Operation/ExecOperation.php b/src/Operation/ExecOperation.php index d604250..11fe946 100644 --- a/src/Operation/ExecOperation.php +++ b/src/Operation/ExecOperation.php @@ -15,7 +15,7 @@ final class ExecOperation extends AbstractOperation * @param string $name * @param ?string $body */ - public function __construct(string $name, string $body = null) { + public function __construct(string $name, ?string $body = null) { $this->name = $name; $this->body = $body; } diff --git a/src/Operation/FunctionOperation.php b/src/Operation/FunctionOperation.php index ced0f31..7ab24e8 100644 --- a/src/Operation/FunctionOperation.php +++ b/src/Operation/FunctionOperation.php @@ -66,13 +66,13 @@ final class FunctionOperation extends AbstractOperation implements ReversibleOpe public function __construct( string $name, string $operation, - ReturnTypeOperation $returnType = null, + ?ReturnTypeOperation $returnType = null, bool $deterministic = false, string $dataUse = 'READS SQL DATA', string $language = 'plpgsql', array $parameterOperations = [], array $variableOperations = [], - string $body = null + ?string $body = null ) { $this->name = $name; $this->operation = $operation; diff --git a/src/Operation/ProcedureOperation.php b/src/Operation/ProcedureOperation.php index e92242b..f8f3df4 100644 --- a/src/Operation/ProcedureOperation.php +++ b/src/Operation/ProcedureOperation.php @@ -64,7 +64,7 @@ public function __construct( string $language = 'plpgsql', array $inParameterOperations = [], array $outParameterOperations = [], - string $body = null + ?string $body = null ) { $this->name = $name; $this->operation = $operation; diff --git a/src/Operation/TableOperation.php b/src/Operation/TableOperation.php index 6e73aa4..71734a0 100644 --- a/src/Operation/TableOperation.php +++ b/src/Operation/TableOperation.php @@ -93,16 +93,26 @@ public function reverse(?ReversibleOperationInterface $originalOperation = null) $columnOperations[] = $originalColumn; } - if ($columnOperation->getOperation() === ColumnOperation::MODIFY) { + if ($columnOperation->getOperation() === ColumnOperation::MODIFY || $columnOperation->getOperation() === ColumnOperation::CHANGE) { if (!$originalColumn) { throw new LogicException('Cannot revert a column that does not exist.'); } - $columnOperations[] = new ColumnOperation( - $columnOperation->getName(), - ColumnOperation::MODIFY, - $originalColumn->getOptions() - ); + if ($columnOperation->getOperation() === ColumnOperation::MODIFY) { + $columnOperations[] = new ColumnOperation( + $columnOperation->getName(), + $columnOperation->getOperation(), + $originalColumn->getOptions() + ); + } else { + $options = $originalColumn->getOptions(); + $options['new_name'] = $columnOperation->getBeforeName(); + $columnOperations[] = new ColumnOperation( + $columnOperation->getAfterName(), + $columnOperation->getOperation(), + $options + ); + } } } @@ -192,7 +202,7 @@ public function apply(ReducibleOperationInterface $operation): ?ReducibleOperati $offset = array_search($options['after'], array_keys($columns)) + 1; } - // Remove existing operation for the column + // Remove existing operation for the column using proper name matching foreach ($columns as $existing => $column) { if ($column->getName() === $columnOperation->getName()) { unset($columns[$existing]); @@ -213,6 +223,15 @@ public function apply(ReducibleOperationInterface $operation): ?ReducibleOperati $options ); + array_splice($columns, $offset, 0, [$addOperation]); + break; + case ColumnOperation::CHANGE: + $addOperation = new ColumnOperation( + $columnOperation->getAfterName(), + ColumnOperation::ADD, + $options + ); + array_splice($columns, $offset, 0, [$addOperation]); break; } @@ -253,12 +272,14 @@ public function apply(ReducibleOperationInterface $operation): ?ReducibleOperati foreach ($operation->getColumnOperations() as $columnOperation) { $originalOperation = $columnOperation->getOperation(); + $originalName = $columnOperation->getName(); - // Remove existing operation for the column + // Remove existing operation for the column using proper name matching foreach ($columns as $existing => $column) { - if ($column->getName() === $columnOperation->getName()) { + if ($column->getAfterName() === $columnOperation->getName()) { unset($columns[$existing]); $originalOperation = $column->getOperation(); + $originalName = $column->getBeforeName(); break; } } @@ -269,14 +290,45 @@ public function apply(ReducibleOperationInterface $operation): ?ReducibleOperati $columns[] = $columnOperation; break; case ColumnOperation::DROP: + if ($originalOperation == ColumnOperation::CHANGE) { + $columnOperation = new ColumnOperation( + $originalName, + $columnOperation->getOperation(), + $columnOperation->getOptions() + ); + } + if ($originalOperation !== ColumnOperation::ADD) { $columns[] = $columnOperation; } break; case ColumnOperation::MODIFY: + $options = $columnOperation->getOptions(); + + if ($originalOperation == ColumnOperation::CHANGE) { + $options['new_name'] = $columnOperation->getName(); + } + $columns[] = new ColumnOperation( $columnOperation->getName(), $originalOperation, + $options + ); + break; + case ColumnOperation::CHANGE: + $columnName = $columnOperation->getName(); + + if ($originalOperation == ColumnOperation::ADD) { + $columnName = $columnOperation->getAfterName(); + } + + if ($originalOperation == ColumnOperation::MODIFY) { + $originalOperation = ColumnOperation::CHANGE; + } + + $columns[] = new ColumnOperation( + $columnName, + $originalOperation, $columnOperation->getOptions() ); break; diff --git a/src/Operation/ViewOperation.php b/src/Operation/ViewOperation.php index 39de8c0..175b74f 100644 --- a/src/Operation/ViewOperation.php +++ b/src/Operation/ViewOperation.php @@ -27,7 +27,7 @@ final class ViewOperation extends AbstractOperation implements ReversibleOperati * @param string $operation * @param string|null $body */ - public function __construct(string $name, string $operation, string $body = null) + public function __construct(string $name, string $operation, ?string $body = null) { $this->name = $name; $this->operation = $operation; diff --git a/src/ProcedureMigration.php b/src/ProcedureMigration.php index 84aff22..6eaff87 100644 --- a/src/ProcedureMigration.php +++ b/src/ProcedureMigration.php @@ -90,7 +90,7 @@ private function __construct( string $language = 'plpgsql', array $inParameterOperations = [], array $outParameterOperations = [], - string $body = null + ?string $body = null ) { $this->name = $name; $this->operation = $operation; diff --git a/src/Statement/MysqlStatementBuilder.php b/src/Statement/MysqlStatementBuilder.php index 207cfac..dc45b7c 100644 --- a/src/Statement/MysqlStatementBuilder.php +++ b/src/Statement/MysqlStatementBuilder.php @@ -278,6 +278,16 @@ protected function buildTable(TableOperation $operation): string $this->buildColumn($columnOperation->getName(), $columnOperation->getOptions()) ); break; + case ColumnOperation::CHANGE: + $specifications[] = sprintf( + 'CHANGE COLUMN %s %s', + $this->buildIdentifier($columnOperation->getName()), + $this->buildColumn( + $columnOperation->getOptions()['new_name'], + $columnOperation->getOptions() + ) + ); + break; case ColumnOperation::DROP: $specifications[] = sprintf( 'DROP COLUMN %s', diff --git a/src/TableMigration.php b/src/TableMigration.php index 3795c48..f473616 100644 --- a/src/TableMigration.php +++ b/src/TableMigration.php @@ -114,6 +114,34 @@ public function modifyColumn(string $column, array $options): TableMigration return $this; } + /** + * Pushes a new change column operation. + * + * @param string $oldColumn + * @param string $newColumn + * @param array $options + * @return TableMigration + */ + public function changeColumn(string $oldColumn, string $newColumn, array $options): TableMigration + { + if ($this->operation === TableOperation::CREATE) { + throw new LogicException('Cannot change columns in a create migration.'); + } + + if ($this->operation === TableOperation::DROP) { + throw new LogicException('Cannot change columns in a drop migration.'); + } + + if ($oldColumn === $newColumn) { + $this->columnOperations[] = new ColumnOperation($oldColumn, ColumnOperation::MODIFY, $options); + } else { + $options['new_name'] = $newColumn; + $this->columnOperations[] = new ColumnOperation($oldColumn, ColumnOperation::CHANGE, $options); + } + + return $this; + } + /** * Pushes a new drop column operation. * diff --git a/src/ViewMigration.php b/src/ViewMigration.php index a3497bc..b82859a 100644 --- a/src/ViewMigration.php +++ b/src/ViewMigration.php @@ -61,7 +61,7 @@ public static function drop(string $name) * @param string $operation * @param string|null $body */ - private function __construct(string $name, string $operation, string $body = null) + private function __construct(string $name, string $operation, ?string $body = null) { $this->name = $name; $this->operation = $operation; diff --git a/tests/Fixtures/TestChange/20211015_create_users.php b/tests/Fixtures/TestChange/20211015_create_users.php new file mode 100644 index 0000000..3479ca3 --- /dev/null +++ b/tests/Fixtures/TestChange/20211015_create_users.php @@ -0,0 +1,4 @@ +addColumn('username', ['type' => 'string']); diff --git a/tests/Fixtures/TestChange/20211016_change_username_to_email.php b/tests/Fixtures/TestChange/20211016_change_username_to_email.php new file mode 100644 index 0000000..e717179 --- /dev/null +++ b/tests/Fixtures/TestChange/20211016_change_username_to_email.php @@ -0,0 +1,4 @@ +changeColumn('username', 'email', ['type' => 'string']); diff --git a/tests/Fixtures/TestChange/20211017_add_column_username.php b/tests/Fixtures/TestChange/20211017_add_column_username.php new file mode 100644 index 0000000..be3b18a --- /dev/null +++ b/tests/Fixtures/TestChange/20211017_add_column_username.php @@ -0,0 +1,4 @@ +addColumn('username', ['type' => 'string']); diff --git a/tests/Fixtures/TestChange/20211018_change_username_to_user_id.php b/tests/Fixtures/TestChange/20211018_change_username_to_user_id.php new file mode 100644 index 0000000..3311883 --- /dev/null +++ b/tests/Fixtures/TestChange/20211018_change_username_to_user_id.php @@ -0,0 +1,4 @@ +changeColumn('username', 'user_id', ['type' => 'integer']); diff --git a/tests/MigrationIntegrationTest.php b/tests/MigrationIntegrationTest.php new file mode 100644 index 0000000..59b6e99 --- /dev/null +++ b/tests/MigrationIntegrationTest.php @@ -0,0 +1,160 @@ +mysql = new PDO( + sprintf('mysql:dbname=%s;host=%s;port=%s', $mysql['name'], $mysql['host'], $mysql['port']), + $mysql['user'], + $mysql['pass'] + ); + + $this->mysql->exec('DROP TABLE IF EXISTS users;'); + } catch (\PDOException $e) { + $this->markTestSkipped('No MySQL connection'); + } + } + } + + public function tearDown(): void + { + $this->mysql = null; + } + + public function testReduceMigrationsWithChange() + { + $finder = new Finder([]); + $history = $finder->fromPath(__DIR__ . '/Fixtures/TestChange'); + $operations = $history->play('20211015_create_users', '20211018_change_username_to_user_id', true); + + $this->assertCount(1, $operations); + + $operation = $operations[0]; + + $this->assertSame('users', $operation->getName()); + $this->assertSame(TableOperation::CREATE, $operation->getOperation()); + $this->assertCount(2, $operation->getColumnOperations()); + + list($operation1, $operation2) = $operation->getColumnOperations(); + + $this->assertSame('email', $operation1->getName()); + $this->assertSame(ColumnOperation::ADD, $operation1->getOperation()); + $this->assertSame('string', $operation1->getOptions()['type']); + + $this->assertSame('user_id', $operation2->getName()); + $this->assertSame(ColumnOperation::ADD, $operation2->getOperation()); + $this->assertSame('integer', $operation2->getOptions()['type']); + } + + public function testRewindMigrationsWithChange() + { + $finder = new Finder([]); + $history = $finder->fromPath(__DIR__ . '/Fixtures/TestChange'); + $operations = $history->rewind('20211018_change_username_to_user_id', '20211017_add_column_username', false); + $this->assertCount(2, $operations); + + list($operation1, $operation2) = $operations; + + $this->assertSame('users', $operation1->getName()); + $this->assertSame(TableOperation::ALTER, $operation1->getOperation()); + $this->assertCount(1, $operation1->getColumnOperations()); + $this->assertSame('user_id', $operation1->getColumnOperations()[0]->getName()); + $this->assertSame(ColumnOperation::CHANGE, $operation1->getColumnOperations()[0]->getOperation()); + $this->assertSame('username', $operation1->getColumnOperations()[0]->getOptions()['new_name']); + + $this->assertSame('users', $operation2->getName()); + $this->assertSame(TableOperation::ALTER, $operation2->getOperation()); + $this->assertCount(1, $operation2->getColumnOperations()); + $this->assertSame('username', $operation2->getColumnOperations()[0]->getName()); + $this->assertSame(ColumnOperation::DROP, $operation2->getColumnOperations()[0]->getOperation()); + } + + public function testReduceRewindMigrationsWithChange() + { + $finder = new Finder([]); + $history = $finder->fromPath(__DIR__ . '/Fixtures/TestChange'); + $operations = $history->rewind('20211018_change_username_to_user_id', '20211017_add_column_username', true); + $this->assertCount(1, $operations); + + $operation = $operations[0]; + + $this->assertSame('users', $operation->getName()); + $this->assertSame(TableOperation::ALTER, $operation->getOperation()); + $this->assertCount(1, $operation->getColumnOperations()); + + list($operation1) = $operation->getColumnOperations(); + $this->assertSame('user_id', $operation1->getName()); + $this->assertSame(ColumnOperation::DROP, $operation1->getOperation()); + } + + public function testMigrateMigrationsWithMysql() + { + if (!$this->mysql) { + $this->markTestSkipped('No MySQL connection'); + } + + $finder = new Finder([]); + $history = $finder->fromPath(__DIR__ . '/Fixtures/TestChange'); + $handler = new Handler($this->mysql, $history); + + $handler->migrate([], null, true); + + $usersTable = $this->mysql->query('DESCRIBE users')->fetchAll(); + + $this->assertSame('email', $usersTable[0]['Field']); + $this->assertStringContainsString('varchar', $usersTable[0]['Type']); + $this->assertSame('user_id', $usersTable[1]['Field']); + $this->assertStringContainsString('int', $usersTable[1]['Type']); + } + + public function testRollbackMigrationsWithMysql() + { + if (!$this->mysql) { + $this->markTestSkipped('No MySQL connection'); + } + + $finder = new Finder([]); + $history = $finder->fromPath(__DIR__ . '/Fixtures/TestChange'); + $handler = new Handler($this->mysql, $history); + $handler->migrate([], null, true); + + $handler->rollback( + [ + '20211015_create_users', + '20211016_change_username_to_email', + '20211017_add_column_username', + '20211018_change_username_to_user_id' + ], + '20211017_add_column_username', + true + ); + + $usersTable = $this->mysql->query('DESCRIBE users')->fetchAll(); + + $this->assertSame('email', $usersTable[0]['Field']); + $this->assertStringContainsString('varchar', $usersTable[0]['Type']); + $this->assertSame('username', $usersTable[1]['Field']); + $this->assertStringContainsString('varchar', $usersTable[1]['Type']); + } +} diff --git a/tests/Operation/TableOperationTest.php b/tests/Operation/TableOperationTest.php index c1d65b8..c6c6fca 100644 --- a/tests/Operation/TableOperationTest.php +++ b/tests/Operation/TableOperationTest.php @@ -86,6 +86,39 @@ public function testApplyAlterToAlter() $this->assertEquals(['unique' => true], $operation->getIndexOperations()[0]->getOptions()); } + public function testApplyAlterToAlterWithColumnChange() + { + $base = new TableOperation('users', TableOperation::ALTER, [ + new ColumnOperation('id', ColumnOperation::DROP, []), + new ColumnOperation('email', ColumnOperation::ADD, ['type' => 'string', 'length' => 255, 'first' => true]), + new ColumnOperation('username', ColumnOperation::ADD, ['type' => 'string', 'length' => 255]) + ], [ + new IndexOperation('email_username', ColumnOperation::ADD, ['email', 'username'], ['unique' => true]) + ]); + + $operation = $base->apply(new TableOperation('users', TableOperation::ALTER, [ + new ColumnOperation('email', ColumnOperation::DROP, []), + new ColumnOperation('username', ColumnOperation::CHANGE, ['type' => 'string', 'length' => 255, 'new_name' => 'email']) + ], [])); + + $this->assertEquals('users', $operation->getName()); + $this->assertEquals(TableOperation::ALTER, $operation->getOperation()); + $this->assertCount(2, $operation->getColumnOperations()); + $this->assertCount(1, $operation->getIndexOperations()); + + $this->assertEquals('id', $operation->getColumnOperations()[0]->getName()); + $this->assertEquals(ColumnOperation::DROP, $operation->getColumnOperations()[0]->getOperation()); + + $this->assertEquals('email', $operation->getColumnOperations()[1]->getName()); + $this->assertEquals(ColumnOperation::ADD, $operation->getColumnOperations()[1]->getOperation()); + $this->assertEquals(['type' => 'string', 'length' => 255, 'new_name' => 'email'], $operation->getColumnOperations()[1]->getOptions()); + + $this->assertEquals('email_username', $operation->getIndexOperations()[0]->getName()); + $this->assertEquals(IndexOperation::ADD, $operation->getIndexOperations()[0]->getOperation()); + $this->assertEquals(['email'], $operation->getIndexOperations()[0]->getColumns()); + $this->assertEquals(['unique' => true], $operation->getIndexOperations()[0]->getOptions()); + } + public function testApplyDropToAlter() { $base = new TableOperation('users', TableOperation::ALTER, [ @@ -158,6 +191,31 @@ public function testReverseAlter() $this->assertEquals(IndexOperation::DROP, $operation->getIndexOperations()[1]->getOperation()); } + public function testReverseAlterWithColumnChange() + { + $base = new TableOperation('users', TableOperation::ALTER, [ + new ColumnOperation('username', ColumnOperation::CHANGE, ['new_name' => 'email', 'type' => 'string', 'length' => 255]) + ], []); + + $create = new TableOperation('users', TableOperation::CREATE, [ + new ColumnOperation('id', ColumnOperation::ADD, ['type' => 'uuid']), + new ColumnOperation('username', ColumnOperation::ADD, ['type' => 'string', 'length' => 64]) + ], [ + new IndexOperation('username', IndexOperation::ADD, ['username'], ['unique' => true]) + ]); + + $operation = $base->reverse($create); + + $this->assertEquals('users', $operation->getName()); + $this->assertEquals(TableOperation::ALTER, $operation->getOperation()); + + $this->assertEquals('email', $operation->getColumnOperations()[0]->getName()); + $this->assertEquals(ColumnOperation::CHANGE, $operation->getColumnOperations()[0]->getOperation()); + $this->assertEquals(['new_name' => 'username', 'type' => 'string', 'length' => 64], $operation->getColumnOperations()[0]->getOptions()); + + $this->assertCount(0, $operation->getIndexOperations()); + } + public function testReverseDrop() { $base = new TableOperation('users', TableOperation::DROP, [], []); diff --git a/tests/Statement/MysqlStatementBuilderTest.php b/tests/Statement/MysqlStatementBuilderTest.php index e13ba7e..3870b9b 100644 --- a/tests/Statement/MysqlStatementBuilderTest.php +++ b/tests/Statement/MysqlStatementBuilderTest.php @@ -102,6 +102,12 @@ public function provider() 'MODIFY COLUMN `username` VARCHAR(255), ' . 'DROP COLUMN `created_at`, ADD UNIQUE INDEX `meta` (`meta`), DROP INDEX `username`;' ], + [ + new TableOperation('users', TableOperation::ALTER, [ + new ColumnOperation('meta', ColumnOperation::CHANGE, ['new_name' => 'metadata', 'type' => 'json']), + ], []), + 'ALTER TABLE `users` CHANGE COLUMN `meta` `metadata` JSON;' + ], [ new TableOperation('users', TableOperation::DROP, [], []), 'DROP TABLE `users`;' diff --git a/tests/TableMigrationTest.php b/tests/TableMigrationTest.php index 0ddce0a..64025ea 100644 --- a/tests/TableMigrationTest.php +++ b/tests/TableMigrationTest.php @@ -25,11 +25,12 @@ public function testAlterMigration() ->addColumn('email', ['type' => 'string', 'length' => 255]) ->modifyColumn('password', ['type' => 'string', 'length' => 255]) ->dropColumn('username') + ->changeColumn('id', 'uid', ['type' => 'string']) ->getOperation(); $this->assertEquals('users', $operation->getName()); $this->assertEquals(TableOperation::ALTER, $operation->getOperation()); - $this->assertCount(3, $operation->getColumnOperations()); + $this->assertCount(4, $operation->getColumnOperations()); } public function testDropMigration() diff --git a/tests/Traits/UsesYamlConfig.php b/tests/Traits/UsesYamlConfig.php index 51d4bc3..9fd7f3b 100644 --- a/tests/Traits/UsesYamlConfig.php +++ b/tests/Traits/UsesYamlConfig.php @@ -16,7 +16,7 @@ trait UsesYamlConfig * When the value cannot be found by the given key, we default to null. * @throws Exception */ - protected static function yaml(string $keys = null) + protected static function yaml(?string $keys = null) { $yaml = self::readYamlFile();