diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index a73420b8..2d806152 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -15,6 +15,36 @@ parameters: count: 1 path: src/Command/BakeMigrationSnapshotCommand.php + - + message: "#^Method Migrations\\\\Db\\\\Adapter\\\\AdapterFactory\\:\\:getAdapter\\(\\) should return Migrations\\\\Db\\\\Adapter\\\\AdapterInterface but returns object\\.$#" + count: 1 + path: src/Db/Adapter/AdapterFactory.php + + - + message: "#^Unsafe usage of new static\\(\\)\\.$#" + count: 1 + path: src/Db/Adapter/AdapterFactory.php + + - + message: "#^Call to an undefined method Migrations\\\\Db\\\\Adapter\\\\AdapterInterface\\:\\:getDeleteBuilder\\(\\)\\.$#" + count: 1 + path: src/Db/Adapter/AdapterWrapper.php + + - + message: "#^Call to an undefined method Migrations\\\\Db\\\\Adapter\\\\AdapterInterface\\:\\:getInsertBuilder\\(\\)\\.$#" + count: 1 + path: src/Db/Adapter/AdapterWrapper.php + + - + message: "#^Call to an undefined method Migrations\\\\Db\\\\Adapter\\\\AdapterInterface\\:\\:getSelectBuilder\\(\\)\\.$#" + count: 1 + path: src/Db/Adapter/AdapterWrapper.php + + - + message: "#^Call to an undefined method Migrations\\\\Db\\\\Adapter\\\\AdapterInterface\\:\\:getUpdateBuilder\\(\\)\\.$#" + count: 1 + path: src/Db/Adapter/AdapterWrapper.php + - message: "#^Offset 'id' on non\\-empty\\-array\\ in isset\\(\\) always exists and is not nullable\\.$#" count: 2 diff --git a/psalm-baseline.xml b/psalm-baseline.xml index 37e441b5..4b73c27a 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -15,6 +15,20 @@ output)]]> + + + InputInterface + + + adapter->getInput()]]> + + + getDeleteBuilder + getInsertBuilder + getSelectBuilder + getUpdateBuilder + + $opened @@ -46,6 +60,12 @@ is_array($newColumns) + + + $columns + $newColumns + + array_merge($versions, array_keys($migrations)) diff --git a/src/Db/Adapter/AdapterFactory.php b/src/Db/Adapter/AdapterFactory.php new file mode 100644 index 00000000..6cdf7563 --- /dev/null +++ b/src/Db/Adapter/AdapterFactory.php @@ -0,0 +1,172 @@ + + * @phpstan-var array> + * @psalm-var array> + */ + protected array $adapters = [ + 'mysql' => MysqlAdapter::class, + 'postgres' => PostgresAdapter::class, + 'sqlite' => SqliteAdapter::class, + 'sqlserver' => SqlserverAdapter::class, + ]; + + /** + * Class map of adapters wrappers, indexed by name. + * + * @var array + * @psalm-var array> + */ + protected array $wrappers = [ + 'record' => RecordingAdapter::class, + 'timed' => TimedOutputAdapter::class, + ]; + + /** + * Register an adapter class with a given name. + * + * @param string $name Name + * @param string $class Class + * @throws \RuntimeException + * @return $this + */ + public function registerAdapter(string $name, string $class) + { + if (!is_subclass_of($class, AdapterInterface::class)) { + throw new RuntimeException(sprintf( + 'Adapter class "%s" must implement Migrations\\Db\\Adapter\\AdapterInterface', + $class + )); + } + $this->adapters[$name] = $class; + + return $this; + } + + /** + * Get an adapter class by name. + * + * @param string $name Name + * @throws \RuntimeException + * @return string + * @phpstan-return class-string<\Migrations\Db\Adapter\AdapterInterface> + */ + protected function getClass(string $name): object|string + { + if (empty($this->adapters[$name])) { + throw new RuntimeException(sprintf( + 'Adapter "%s" has not been registered', + $name + )); + } + + return $this->adapters[$name]; + } + + /** + * Get an adapter instance by name. + * + * @param string $name Name + * @param array $options Options + * @return \Migrations\Db\Adapter\AdapterInterface + */ + public function getAdapter(string $name, array $options): AdapterInterface + { + $class = $this->getClass($name); + + return new $class($options); + } + + /** + * Add or replace a wrapper with a fully qualified class name. + * + * @param string $name Name + * @param string $class Class + * @throws \RuntimeException + * @return $this + */ + public function registerWrapper(string $name, string $class) + { + if (!is_subclass_of($class, WrapperInterface::class)) { + throw new RuntimeException(sprintf( + 'Wrapper class "%s" must implement Migrations\\Db\\Adapter\\WrapperInterface', + $class + )); + } + $this->wrappers[$name] = $class; + + return $this; + } + + /** + * Get a wrapper class by name. + * + * @param string $name Name + * @throws \RuntimeException + * @return class-string<\Migrations\Db\Adapter\WrapperInterface> + */ + protected function getWrapperClass(string $name): string + { + if (empty($this->wrappers[$name])) { + throw new RuntimeException(sprintf( + 'Wrapper "%s" has not been registered', + $name + )); + } + + return $this->wrappers[$name]; + } + + /** + * Get a wrapper instance by name. + * + * @param string $name Name + * @param \Migrations\Db\Adapter\AdapterInterface $adapter Adapter + * @return \Migrations\Db\Adapter\WrapperInterface + */ + public function getWrapper(string $name, AdapterInterface $adapter): WrapperInterface + { + $class = $this->getWrapperClass($name); + + return new $class($adapter); + } +} diff --git a/src/Db/Adapter/AdapterWrapper.php b/src/Db/Adapter/AdapterWrapper.php new file mode 100644 index 00000000..76110341 --- /dev/null +++ b/src/Db/Adapter/AdapterWrapper.php @@ -0,0 +1,524 @@ +setAdapter($adapter); + } + + /** + * @inheritDoc + */ + public function setAdapter(AdapterInterface $adapter): AdapterInterface + { + $this->adapter = $adapter; + + return $this; + } + + /** + * @inheritDoc + */ + public function getAdapter(): AdapterInterface + { + return $this->adapter; + } + + /** + * @inheritDoc + */ + public function setOptions(array $options): AdapterInterface + { + $this->adapter->setOptions($options); + + return $this; + } + + /** + * @inheritDoc + */ + public function getOptions(): array + { + return $this->adapter->getOptions(); + } + + /** + * @inheritDoc + */ + public function hasOption(string $name): bool + { + return $this->adapter->hasOption($name); + } + + /** + * @inheritDoc + */ + public function getOption(string $name): mixed + { + return $this->adapter->getOption($name); + } + + /** + * @inheritDoc + */ + public function setInput(InputInterface $input): AdapterInterface + { + $this->adapter->setInput($input); + + return $this; + } + + /** + * @inheritDoc + */ + public function getInput(): InputInterface + { + return $this->adapter->getInput(); + } + + /** + * @inheritDoc + */ + public function setOutput(OutputInterface $output): AdapterInterface + { + $this->adapter->setOutput($output); + + return $this; + } + + /** + * @inheritDoc + */ + public function getOutput(): OutputInterface + { + return $this->adapter->getOutput(); + } + + /** + * @inheritDoc + */ + public function getColumnForType(string $columnName, string $type, array $options): Column + { + return $this->adapter->getColumnForType($columnName, $type, $options); + } + + /** + * @inheritDoc + */ + public function connect(): void + { + $this->getAdapter()->connect(); + } + + /** + * @inheritDoc + */ + public function disconnect(): void + { + $this->getAdapter()->disconnect(); + } + + /** + * @inheritDoc + */ + public function execute(string $sql, array $params = []): int + { + return $this->getAdapter()->execute($sql, $params); + } + + /** + * @inheritDoc + */ + public function query(string $sql, array $params = []): mixed + { + return $this->getAdapter()->query($sql, $params); + } + + /** + * @inheritDoc + */ + public function insert(Table $table, array $row): void + { + $this->getAdapter()->insert($table, $row); + } + + /** + * @inheritDoc + */ + public function bulkinsert(Table $table, array $rows): void + { + $this->getAdapter()->bulkinsert($table, $rows); + } + + /** + * @inheritDoc + */ + public function fetchRow(string $sql): array|false + { + return $this->getAdapter()->fetchRow($sql); + } + + /** + * @inheritDoc + */ + public function fetchAll(string $sql): array + { + return $this->getAdapter()->fetchAll($sql); + } + + /** + * @inheritDoc + */ + public function getVersions(): array + { + return $this->getAdapter()->getVersions(); + } + + /** + * @inheritDoc + */ + public function getVersionLog(): array + { + return $this->getAdapter()->getVersionLog(); + } + + /** + * @inheritDoc + */ + public function migrated(MigrationInterface $migration, string $direction, string $startTime, string $endTime): AdapterInterface + { + $this->getAdapter()->migrated($migration, $direction, $startTime, $endTime); + + return $this; + } + + /** + * @inheritDoc + */ + public function toggleBreakpoint(MigrationInterface $migration): AdapterInterface + { + $this->getAdapter()->toggleBreakpoint($migration); + + return $this; + } + + /** + * @inheritDoc + */ + public function resetAllBreakpoints(): int + { + return $this->getAdapter()->resetAllBreakpoints(); + } + + /** + * @inheritDoc + */ + public function setBreakpoint(MigrationInterface $migration): AdapterInterface + { + $this->getAdapter()->setBreakpoint($migration); + + return $this; + } + + /** + * @inheritDoc + */ + public function unsetBreakpoint(MigrationInterface $migration): AdapterInterface + { + $this->getAdapter()->unsetBreakpoint($migration); + + return $this; + } + + /** + * @inheritDoc + */ + public function createSchemaTable(): void + { + $this->getAdapter()->createSchemaTable(); + } + + /** + * @inheritDoc + */ + public function getColumnTypes(): array + { + return $this->getAdapter()->getColumnTypes(); + } + + /** + * @inheritDoc + */ + public function isValidColumnType(Column $column): bool + { + return $this->getAdapter()->isValidColumnType($column); + } + + /** + * @inheritDoc + */ + public function hasTransactions(): bool + { + return $this->getAdapter()->hasTransactions(); + } + + /** + * @inheritDoc + */ + public function beginTransaction(): void + { + $this->getAdapter()->beginTransaction(); + } + + /** + * @inheritDoc + */ + public function commitTransaction(): void + { + $this->getAdapter()->commitTransaction(); + } + + /** + * @inheritDoc + */ + public function rollbackTransaction(): void + { + $this->getAdapter()->rollbackTransaction(); + } + + /** + * @inheritDoc + */ + public function quoteTableName(string $tableName): string + { + return $this->getAdapter()->quoteTableName($tableName); + } + + /** + * @inheritDoc + */ + public function quoteColumnName(string $columnName): string + { + return $this->getAdapter()->quoteColumnName($columnName); + } + + /** + * @inheritDoc + */ + public function hasTable(string $tableName): bool + { + return $this->getAdapter()->hasTable($tableName); + } + + /** + * @inheritDoc + */ + public function createTable(Table $table, array $columns = [], array $indexes = []): void + { + $this->getAdapter()->createTable($table, $columns, $indexes); + } + + /** + * @inheritDoc + */ + public function getColumns(string $tableName): array + { + return $this->getAdapter()->getColumns($tableName); + } + + /** + * @inheritDoc + */ + public function hasColumn(string $tableName, string $columnName): bool + { + return $this->getAdapter()->hasColumn($tableName, $columnName); + } + + /** + * @inheritDoc + */ + public function hasIndex(string $tableName, string|array $columns): bool + { + return $this->getAdapter()->hasIndex($tableName, $columns); + } + + /** + * @inheritDoc + */ + public function hasIndexByName(string $tableName, string $indexName): bool + { + return $this->getAdapter()->hasIndexByName($tableName, $indexName); + } + + /** + * @inheritDoc + */ + public function hasPrimaryKey(string $tableName, $columns, ?string $constraint = null): bool + { + return $this->getAdapter()->hasPrimaryKey($tableName, $columns, $constraint); + } + + /** + * @inheritDoc + */ + public function hasForeignKey(string $tableName, $columns, ?string $constraint = null): bool + { + return $this->getAdapter()->hasForeignKey($tableName, $columns, $constraint); + } + + /** + * @inheritDoc + */ + public function getSqlType(Literal|string $type, ?int $limit = null): array + { + return $this->getAdapter()->getSqlType($type, $limit); + } + + /** + * @inheritDoc + */ + public function createDatabase(string $name, array $options = []): void + { + $this->getAdapter()->createDatabase($name, $options); + } + + /** + * @inheritDoc + */ + public function hasDatabase(string $name): bool + { + return $this->getAdapter()->hasDatabase($name); + } + + /** + * @inheritDoc + */ + public function dropDatabase(string $name): void + { + $this->getAdapter()->dropDatabase($name); + } + + /** + * @inheritDoc + */ + public function createSchema(string $schemaName = 'public'): void + { + $this->getAdapter()->createSchema($schemaName); + } + + /** + * @inheritDoc + */ + public function dropSchema(string $schemaName): void + { + $this->getAdapter()->dropSchema($schemaName); + } + + /** + * @inheritDoc + */ + public function truncateTable(string $tableName): void + { + $this->getAdapter()->truncateTable($tableName); + } + + /** + * @inheritDoc + */ + public function castToBool($value): mixed + { + return $this->getAdapter()->castToBool($value); + } + + /** + * @return \PDO + */ + public function getConnection(): PDO + { + return $this->getAdapter()->getConnection(); + } + + /** + * @inheritDoc + */ + public function executeActions(Table $table, array $actions): void + { + $this->getAdapter()->executeActions($table, $actions); + } + + /** + * @inheritDoc + */ + public function getQueryBuilder(string $type): Query + { + return $this->getAdapter()->getQueryBuilder($type); + } + + /** + * @inheritDoc + */ + public function getSelectBuilder(): SelectQuery + { + return $this->getAdapter()->getSelectBuilder(); + } + + /** + * @inheritDoc + */ + public function getInsertBuilder(): InsertQuery + { + return $this->getAdapter()->getInsertBuilder(); + } + + /** + * @inheritDoc + */ + public function getUpdateBuilder(): UpdateQuery + { + return $this->getAdapter()->getUpdateBuilder(); + } + + /** + * @inheritDoc + */ + public function getDeleteBuilder(): DeleteQuery + { + return $this->getAdapter()->getDeleteBuilder(); + } +} diff --git a/src/Db/Adapter/RecordingAdapter.php b/src/Db/Adapter/RecordingAdapter.php new file mode 100644 index 00000000..1b94cb61 --- /dev/null +++ b/src/Db/Adapter/RecordingAdapter.php @@ -0,0 +1,128 @@ +commands[] = new CreateTable($table); + } + + /** + * @inheritDoc + */ + public function executeActions(Table $table, array $actions): void + { + $this->commands = array_merge($this->commands, $actions); + } + + /** + * Gets an array of the recorded commands in reverse. + * + * @throws \Migrations\Migration\IrreversibleMigrationException if a command cannot be reversed. + * @return \Migrations\Db\Plan\Intent + */ + public function getInvertedCommands(): Intent + { + $inverted = new Intent(); + + foreach (array_reverse($this->commands) as $command) { + switch (true) { + case $command instanceof CreateTable: + /** @var \Migrations\Db\Action\CreateTable $command */ + $inverted->addAction(new DropTable($command->getTable())); + break; + + case $command instanceof RenameTable: + /** @var \Migrations\Db\Action\RenameTable $command */ + $inverted->addAction(new RenameTable(new Table($command->getNewName()), $command->getTable()->getName())); + break; + + case $command instanceof AddColumn: + /** @var \Migrations\Db\Action\AddColumn $command */ + $inverted->addAction(new RemoveColumn($command->getTable(), $command->getColumn())); + break; + + case $command instanceof RenameColumn: + /** @var \Migrations\Db\Action\RenameColumn $command */ + $column = clone $command->getColumn(); + $name = (string)$column->getName(); + $column->setName($command->getNewName()); + $inverted->addAction(new RenameColumn($command->getTable(), $column, $name)); + break; + + case $command instanceof AddIndex: + /** @var \Migrations\Db\Action\AddIndex $command */ + $inverted->addAction(new DropIndex($command->getTable(), $command->getIndex())); + break; + + case $command instanceof AddForeignKey: + /** @var \Migrations\Db\Action\AddForeignKey $command */ + $inverted->addAction(new DropForeignKey($command->getTable(), $command->getForeignKey())); + break; + + default: + throw new IrreversibleMigrationException(sprintf( + 'Cannot reverse a "%s" command', + get_class($command) + )); + } + } + + return $inverted; + } + + /** + * Execute the recorded commands in reverse. + * + * @return void + */ + public function executeInvertedCommands(): void + { + $plan = new Plan($this->getInvertedCommands()); + $plan->executeInverse($this->getAdapter()); + } +} diff --git a/src/Db/Adapter/TimedOutputAdapter.php b/src/Db/Adapter/TimedOutputAdapter.php new file mode 100644 index 00000000..e0ab9b25 --- /dev/null +++ b/src/Db/Adapter/TimedOutputAdapter.php @@ -0,0 +1,424 @@ +getAdapter()->getAdapterType(); + } + + /** + * Start timing a command. + * + * @return callable A function that is to be called when the command finishes + */ + public function startCommandTimer(): callable + { + $started = microtime(true); + + return function () use ($started): void { + $end = microtime(true); + if (OutputInterface::VERBOSITY_VERBOSE <= $this->getOutput()->getVerbosity()) { + $this->getOutput()->writeln(' -> ' . sprintf('%.4fs', $end - $started)); + } + }; + } + + /** + * Write a Phinx command to the output. + * + * @param string $command Command Name + * @param array $args Command Args + * @return void + */ + public function writeCommand(string $command, array $args = []): void + { + if (OutputInterface::VERBOSITY_VERBOSE > $this->getOutput()->getVerbosity()) { + return; + } + + if (count($args)) { + $outArr = []; + foreach ($args as $arg) { + if (is_array($arg)) { + $arg = array_map( + function ($value) { + return '\'' . $value . '\''; + }, + $arg + ); + $outArr[] = '[' . implode(', ', $arg) . ']'; + continue; + } + + $outArr[] = '\'' . $arg . '\''; + } + $this->getOutput()->writeln(' -- ' . $command . '(' . implode(', ', $outArr) . ')'); + + return; + } + + $this->getOutput()->writeln(' -- ' . $command); + } + + /** + * @inheritDoc + */ + public function insert(Table $table, array $row): void + { + $end = $this->startCommandTimer(); + $this->writeCommand('insert', [$table->getName()]); + parent::insert($table, $row); + $end(); + } + + /** + * @inheritDoc + */ + public function bulkinsert(Table $table, array $rows): void + { + $end = $this->startCommandTimer(); + $this->writeCommand('bulkinsert', [$table->getName()]); + parent::bulkinsert($table, $rows); + $end(); + } + + /** + * @inheritDoc + */ + public function createTable(Table $table, array $columns = [], array $indexes = []): void + { + $end = $this->startCommandTimer(); + $this->writeCommand('createTable', [$table->getName()]); + parent::createTable($table, $columns, $indexes); + $end(); + } + + /** + * {@inheritDoc} + * + * @throws \BadMethodCallException + * @return void + */ + public function changePrimaryKey(Table $table, $newColumns): void + { + $adapter = $this->getAdapter(); + if (!$adapter instanceof DirectActionInterface) { + throw new BadMethodCallException('The adapter needs to implement DirectActionInterface'); + } + $end = $this->startCommandTimer(); + $this->writeCommand('changePrimaryKey', [$table->getName()]); + $adapter->changePrimaryKey($table, $newColumns); + $end(); + } + + /** + * {@inheritDoc} + * + * @throws \BadMethodCallException + * @return void + */ + public function changeComment(Table $table, ?string $newComment): void + { + $adapter = $this->getAdapter(); + if (!$adapter instanceof DirectActionInterface) { + throw new BadMethodCallException('The adapter needs to implement DirectActionInterface'); + } + $end = $this->startCommandTimer(); + $this->writeCommand('changeComment', [$table->getName()]); + $adapter->changeComment($table, $newComment); + $end(); + } + + /** + * {@inheritDoc} + * + * @throws \BadMethodCallException + * @return void + */ + public function renameTable(string $tableName, string $newName): void + { + $adapter = $this->getAdapter(); + if (!$adapter instanceof DirectActionInterface) { + throw new BadMethodCallException('The adapter needs to implement DirectActionInterface'); + } + $end = $this->startCommandTimer(); + $this->writeCommand('renameTable', [$tableName, $newName]); + $adapter->renameTable($tableName, $newName); + $end(); + } + + /** + * {@inheritDoc} + * + * @throws \BadMethodCallException + * @return void + */ + public function dropTable(string $tableName): void + { + $adapter = $this->getAdapter(); + if (!$adapter instanceof DirectActionInterface) { + throw new BadMethodCallException('The adapter needs to implement DirectActionInterface'); + } + $end = $this->startCommandTimer(); + $this->writeCommand('dropTable', [$tableName]); + $adapter->dropTable($tableName); + $end(); + } + + /** + * @inheritDoc + */ + public function truncateTable(string $tableName): void + { + $end = $this->startCommandTimer(); + $this->writeCommand('truncateTable', [$tableName]); + parent::truncateTable($tableName); + $end(); + } + + /** + * {@inheritDoc} + * + * @throws \BadMethodCallException + * @return void + */ + public function addColumn(Table $table, Column $column): void + { + $adapter = $this->getAdapter(); + if (!$adapter instanceof DirectActionInterface) { + throw new BadMethodCallException('The adapter needs to implement DirectActionInterface'); + } + $end = $this->startCommandTimer(); + $this->writeCommand( + 'addColumn', + [ + $table->getName(), + $column->getName(), + $column->getType(), + ] + ); + $adapter->addColumn($table, $column); + $end(); + } + + /** + * {@inheritDoc} + * + * @throws \BadMethodCallException + * @return void + */ + public function renameColumn(string $tableName, string $columnName, string $newColumnName): void + { + $adapter = $this->getAdapter(); + if (!$adapter instanceof DirectActionInterface) { + throw new BadMethodCallException('The adapter needs to implement DirectActionInterface'); + } + $end = $this->startCommandTimer(); + $this->writeCommand('renameColumn', [$tableName, $columnName, $newColumnName]); + $adapter->renameColumn($tableName, $columnName, $newColumnName); + $end(); + } + + /** + * {@inheritDoc} + * + * @throws \BadMethodCallException + * @return void + */ + public function changeColumn(string $tableName, string $columnName, Column $newColumn): void + { + $adapter = $this->getAdapter(); + if (!$adapter instanceof DirectActionInterface) { + throw new BadMethodCallException('The adapter needs to implement DirectActionInterface'); + } + $end = $this->startCommandTimer(); + $this->writeCommand('changeColumn', [$tableName, $columnName, $newColumn->getType()]); + $adapter->changeColumn($tableName, $columnName, $newColumn); + $end(); + } + + /** + * {@inheritDoc} + * + * @throws \BadMethodCallException + * @return void + */ + public function dropColumn(string $tableName, string $columnName): void + { + $adapter = $this->getAdapter(); + if (!$adapter instanceof DirectActionInterface) { + throw new BadMethodCallException('The adapter needs to implement DirectActionInterface'); + } + $end = $this->startCommandTimer(); + $this->writeCommand('dropColumn', [$tableName, $columnName]); + $adapter->dropColumn($tableName, $columnName); + $end(); + } + + /** + * {@inheritDoc} + * + * @throws \BadMethodCallException + * @return void + */ + public function addIndex(Table $table, Index $index): void + { + $adapter = $this->getAdapter(); + if (!$adapter instanceof DirectActionInterface) { + throw new BadMethodCallException('The adapter needs to implement DirectActionInterface'); + } + $end = $this->startCommandTimer(); + $this->writeCommand('addIndex', [$table->getName(), $index->getColumns()]); + $adapter->addIndex($table, $index); + $end(); + } + + /** + * {@inheritDoc} + * + * @throws \BadMethodCallException + * @return void + */ + public function dropIndex(string $tableName, $columns): void + { + $adapter = $this->getAdapter(); + if (!$adapter instanceof DirectActionInterface) { + throw new BadMethodCallException('The adapter needs to implement DirectActionInterface'); + } + $end = $this->startCommandTimer(); + $this->writeCommand('dropIndex', [$tableName, $columns]); + $adapter->dropIndex($tableName, $columns); + $end(); + } + + /** + * {@inheritDoc} + * + * @throws \BadMethodCallException + * @return void + */ + public function dropIndexByName(string $tableName, string $indexName): void + { + $adapter = $this->getAdapter(); + if (!$adapter instanceof DirectActionInterface) { + throw new BadMethodCallException('The adapter needs to implement DirectActionInterface'); + } + $end = $this->startCommandTimer(); + $this->writeCommand('dropIndexByName', [$tableName, $indexName]); + $adapter->dropIndexByName($tableName, $indexName); + $end(); + } + + /** + * {@inheritDoc} + * + * @throws \BadMethodCallException + * @return void + */ + public function addForeignKey(Table $table, ForeignKey $foreignKey): void + { + $adapter = $this->getAdapter(); + if (!$adapter instanceof DirectActionInterface) { + throw new BadMethodCallException('The adapter needs to implement DirectActionInterface'); + } + $end = $this->startCommandTimer(); + $this->writeCommand('addForeignKey', [$table->getName(), $foreignKey->getColumns()]); + $adapter->addForeignKey($table, $foreignKey); + $end(); + } + + /** + * {@inheritDoc} + * + * @throws \BadMethodCallException + * @return void + */ + public function dropForeignKey(string $tableName, array $columns, ?string $constraint = null): void + { + $adapter = $this->getAdapter(); + if (!$adapter instanceof DirectActionInterface) { + throw new BadMethodCallException('The adapter needs to implement DirectActionInterface'); + } + $end = $this->startCommandTimer(); + $this->writeCommand('dropForeignKey', [$tableName, $columns]); + $adapter->dropForeignKey($tableName, $columns, $constraint); + $end(); + } + + /** + * @inheritDoc + */ + public function createDatabase(string $name, array $options = []): void + { + $end = $this->startCommandTimer(); + $this->writeCommand('createDatabase', [$name]); + parent::createDatabase($name, $options); + $end(); + } + + /** + * @inheritDoc + */ + public function dropDatabase(string $name): void + { + $end = $this->startCommandTimer(); + $this->writeCommand('dropDatabase', [$name]); + parent::dropDatabase($name); + $end(); + } + + /** + * @inheritDoc + */ + public function createSchema(string $schemaName = 'public'): void + { + $end = $this->startCommandTimer(); + $this->writeCommand('createSchema', [$schemaName]); + parent::createSchema($schemaName); + $end(); + } + + /** + * @inheritDoc + */ + public function dropSchema(string $schemaName): void + { + $end = $this->startCommandTimer(); + $this->writeCommand('dropSchema', [$schemaName]); + parent::dropSchema($schemaName); + $end(); + } + + /** + * @inheritDoc + */ + public function executeActions(Table $table, array $actions): void + { + $end = $this->startCommandTimer(); + $this->writeCommand(sprintf('Altering table %s', $table->getName())); + parent::executeActions($table, $actions); + $end(); + } +} diff --git a/src/Db/Adapter/WrapperInterface.php b/src/Db/Adapter/WrapperInterface.php new file mode 100644 index 00000000..bda3a08f --- /dev/null +++ b/src/Db/Adapter/WrapperInterface.php @@ -0,0 +1,38 @@ +factory = AdapterFactory::instance(); + } + + protected function tearDown(): void + { + unset($this->factory); + } + + public function testInstanceIsFactory() + { + $this->assertInstanceOf('Migrations\Db\Adapter\AdapterFactory', $this->factory); + } + + public function testRegisterAdapter() + { + // AdapterFactory::getClass is protected, work around it to avoid + // creating unnecessary instances and making the test more complex. + $method = new ReflectionMethod(get_class($this->factory), 'getClass'); + $method->setAccessible(true); + + $adapter = $method->invoke($this->factory, 'mysql'); + $this->factory->registerAdapter('test', $adapter); + + $this->assertEquals($adapter, $method->invoke($this->factory, 'test')); + } + + public function testRegisterAdapterFailure() + { + $adapter = static::class; + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Adapter class "Migrations\Test\Db\Adapter\AdapterFactoryTest" must implement Migrations\Db\Adapter\AdapterInterface'); + + $this->factory->registerAdapter('test', $adapter); + } + + public function testGetAdapter() + { + $adapter = $this->factory->getAdapter('mysql', []); + + $this->assertInstanceOf('Migrations\Db\Adapter\MysqlAdapter', $adapter); + } + + public function testGetAdapterFailure() + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Adapter "bad" has not been registered'); + + $this->factory->getAdapter('bad', []); + } + + public function testRegisterWrapper() + { + // WrapperFactory::getClass is protected, work around it to avoid + // creating unnecessary instances and making the test more complex. + $method = new ReflectionMethod(get_class($this->factory), 'getWrapperClass'); + $method->setAccessible(true); + + $wrapper = $method->invoke($this->factory, 'record'); + $this->factory->registerWrapper('test', $wrapper); + + $this->assertEquals($wrapper, $method->invoke($this->factory, 'test')); + } + + public function testRegisterWrapperFailure() + { + $wrapper = static::class; + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Wrapper class "Migrations\Test\Db\Adapter\AdapterFactoryTest" must implement Migrations\Db\Adapter\WrapperInterface'); + + $this->factory->registerWrapper('test', $wrapper); + } + + private function getAdapterMock() + { + return $this->getMockBuilder('Migrations\Db\Adapter\AdapterInterface')->getMock(); + } + + public function testGetWrapper() + { + $wrapper = $this->factory->getWrapper('timed', $this->getAdapterMock()); + + $this->assertInstanceOf('Migrations\Db\Adapter\TimedOutputAdapter', $wrapper); + } + + public function testGetWrapperFailure() + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Wrapper "nope" has not been registered'); + + $this->factory->getWrapper('nope', $this->getAdapterMock()); + } +} diff --git a/tests/TestCase/Db/Adapter/RecordingAdapterTest.php b/tests/TestCase/Db/Adapter/RecordingAdapterTest.php new file mode 100644 index 00000000..b1c85000 --- /dev/null +++ b/tests/TestCase/Db/Adapter/RecordingAdapterTest.php @@ -0,0 +1,160 @@ +getMockBuilder('\Migrations\Db\Adapter\PdoAdapter') + ->setConstructorArgs([[]]) + ->getMock(); + + $stub->expects($this->any()) + ->method('isValidColumnType') + ->will($this->returnValue(true)); + + $this->adapter = new RecordingAdapter($stub); + } + + protected function tearDown(): void + { + unset($this->adapter); + } + + public function testRecordingAdapterCanInvertCreateTable() + { + $table = new Table('atable', [], $this->adapter); + $table->addColumn('column1', 'string') + ->save(); + + $commands = $this->adapter->getInvertedCommands()->getActions(); + $this->assertInstanceOf('Migrations\Db\Action\DropTable', $commands[0]); + $this->assertEquals('atable', $commands[0]->getTable()->getName()); + } + + public function testRecordingAdapterCanInvertRenameTable() + { + $table = new Table('oldname', [], $this->adapter); + $table->rename('newname') + ->save(); + + $commands = $this->adapter->getInvertedCommands()->getActions(); + $this->assertInstanceOf('Migrations\Db\Action\RenameTable', $commands[0]); + $this->assertEquals('newname', $commands[0]->getTable()->getName()); + $this->assertEquals('oldname', $commands[0]->getNewName()); + } + + public function testRecordingAdapterCanInvertAddColumn() + { + $this->adapter + ->getAdapter() + ->expects($this->any()) + ->method('hasTable') + ->will($this->returnValue(true)); + + $this->adapter + ->getAdapter() + ->expects($this->any()) + ->method('getColumnForType') + ->willReturnCallback(function (string $columnName, string $type, array $options) { + return (new Column()) + ->setName($columnName) + ->setType($type) + ->setOptions($options); + }); + + $table = new Table('atable', [], $this->adapter); + $table->addColumn('acolumn', 'string') + ->save(); + + $commands = $this->adapter->getInvertedCommands()->getActions(); + $this->assertInstanceOf('Migrations\Db\Action\RemoveColumn', $commands[0]); + $this->assertEquals('atable', $commands[0]->getTable()->getName()); + $this->assertEquals('acolumn', $commands[0]->getColumn()->getName()); + } + + public function testRecordingAdapterCanInvertRenameColumn() + { + $this->adapter + ->getAdapter() + ->expects($this->any()) + ->method('hasTable') + ->will($this->returnValue(true)); + + $table = new Table('atable', [], $this->adapter); + $table->renameColumn('oldname', 'newname') + ->save(); + + $commands = $this->adapter->getInvertedCommands()->getActions(); + $this->assertInstanceOf('Migrations\Db\Action\RenameColumn', $commands[0]); + $this->assertEquals('newname', $commands[0]->getColumn()->getName()); + $this->assertEquals('oldname', $commands[0]->getNewName()); + } + + public function testRecordingAdapterCanInvertAddIndex() + { + $this->adapter + ->getAdapter() + ->expects($this->any()) + ->method('hasTable') + ->will($this->returnValue(true)); + + $table = new Table('atable', [], $this->adapter); + $table->addIndex(['email']) + ->save(); + + $commands = $this->adapter->getInvertedCommands()->getActions(); + $this->assertInstanceOf('Migrations\Db\Action\DropIndex', $commands[0]); + $this->assertEquals('atable', $commands[0]->getTable()->getName()); + $this->assertEquals(['email'], $commands[0]->getIndex()->getColumns()); + } + + public function testRecordingAdapterCanInvertAddForeignKey() + { + $this->adapter + ->getAdapter() + ->expects($this->any()) + ->method('hasTable') + ->will($this->returnValue(true)); + + $table = new Table('atable', [], $this->adapter); + $table->addForeignKey(['ref_table_id'], 'refTable') + ->save(); + + $commands = $this->adapter->getInvertedCommands()->getActions(); + $this->assertInstanceOf('Migrations\Db\Action\DropForeignKey', $commands[0]); + $this->assertEquals('atable', $commands[0]->getTable()->getName()); + $this->assertEquals(['ref_table_id'], $commands[0]->getForeignKey()->getColumns()); + } + + public function testGetInvertedCommandsThrowsExceptionForIrreversibleCommand() + { + $this->adapter + ->getAdapter() + ->expects($this->any()) + ->method('hasTable') + ->will($this->returnValue(true)); + + $table = new Table('atable', [], $this->adapter); + $table->removeColumn('thing') + ->save(); + + $this->expectException(IrreversibleMigrationException::class); + $this->expectExceptionMessage('Cannot reverse a "Migrations\Db\Action\RemoveColumn" command'); + + $this->adapter->getInvertedCommands(); + } +}