diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f4451781..23ae2f35 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,7 +50,7 @@ jobs: if: matrix.db-type == 'mysql' run: | sudo service mysql start - mysql -h 127.0.0.1 -u root -proot -e 'CREATE DATABASE cakephp_test DEFAULT COLLATE=utf8mb4_general_ci;' + mysql -h 127.0.0.1 -u root -proot -e 'CREATE DATABASE cakephp_test CHARACTER SET = utf8mb4 DEFAULT COLLATE=utf8mb4_general_ci;' mysql -h 127.0.0.1 -u root -proot -e 'CREATE DATABASE cakephp_comparisons;' mysql -h 127.0.0.1 -u root -proot -e 'CREATE DATABASE cakephp_snapshot;' @@ -119,6 +119,76 @@ jobs: if: success() && matrix.php-version == '8.1' && matrix.db-type == 'mysql' uses: codecov/codecov-action@v4 + testsuite-windows: + runs-on: windows-2022 + name: Windows - PHP 8.1 & SQL Server + + env: + EXTENSIONS: mbstring, intl, pdo_sqlsrv + PHP_VERSION: '8.1' + + steps: + - uses: actions/checkout@v4 + + - name: Get date part for cache key + id: key-date + run: echo "::set-output name=date::$(date +'%Y-%m')" + + - name: Setup PHP extensions cache + id: php-ext-cache + uses: shivammathur/cache-extensions@v1 + with: + php-version: ${{ env.PHP_VERSION }} + extensions: ${{ env.EXTENSIONS }} + key: ${{ steps.key-date.outputs.date }} + + - name: Cache PHP extensions + uses: actions/cache@v3 + with: + path: ${{ steps.php-ext-cache.outputs.dir }} + key: ${{ runner.os }}-php-ext-${{ steps.php-ext-cache.outputs.key }} + restore-keys: ${{ runner.os }}-php-ext-${{ steps.php-ext-cache.outputs.key }} + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ env.PHP_VERSION }} + extensions: ${{ env.EXTENSIONS }} + ini-values: apc.enable_cli=1, extension=php_fileinfo.dll, zend.assertions=1, error_reporting=-1, display_errors=On + coverage: pcov + + - name: Setup SQLServer + run: | + # MSSQLLocalDB is the default SQL LocalDB instance + SqlLocalDB start MSSQLLocalDB + SqlLocalDB info MSSQLLocalDB + sqlcmd -S "(localdb)\MSSQLLocalDB" -Q "create database cakephp_test;" + sqlcmd -S "(localdb)\MSSQLLocalDB" -Q "create database cakephp_snapshot;" + + - name: Get composer cache directory + id: composer-cache + run: echo "::set-output name=dir::$(composer config cache-files-dir)" + + - name: Cache composer dependencies + uses: actions/cache@v3 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-composer-${{ steps.key-date.outputs.date }}-${{ hashFiles('composer.json') }}-${{ matrix.prefer-lowest }} + + - name: Composer install + run: composer update + + - name: Run PHPUnit + env: + DB_URL: 'sqlserver://(localdb)\MSSQLLocalDB/cakephp_test' + DB_URL_SNAPSHOT: 'sqlserver://(localdb)\MSSQLLocalDB/cakephp_snapshot' + CODECOVERAGE: 1 + run: | + vendor/bin/phpunit --coverage-clover=coverage.xml + + - name: Submit code coverage + uses: codecov/codecov-action@v3 + cs-stan: uses: cakephp/.github/.github/workflows/cs-stan.yml@5.x secrets: inherit diff --git a/composer.json b/composer.json index 11995953..8891ba78 100644 --- a/composer.json +++ b/composer.json @@ -24,7 +24,7 @@ "php": ">=8.1", "cakephp/cache": "^5.0", "cakephp/orm": "^5.0", - "robmorgan/phinx": "^0.15.3 || ^0.16.0" + "robmorgan/phinx": "^0.16.0" }, "require-dev": { "cakephp/bake": "^3.0", diff --git a/docs/en/contents.rst b/docs/en/contents.rst index 1459b1f1..899c35b1 100644 --- a/docs/en/contents.rst +++ b/docs/en/contents.rst @@ -3,3 +3,4 @@ :caption: CakePHP Migrations /index + /upgrading-to-builtin-backend diff --git a/docs/en/upgrading-to-builtin-backend.rst b/docs/en/upgrading-to-builtin-backend.rst new file mode 100644 index 00000000..bc4d56f6 --- /dev/null +++ b/docs/en/upgrading-to-builtin-backend.rst @@ -0,0 +1,57 @@ +Upgrading to the builtin backend +################################ + +As of migrations XXX there is a new migrations backend that uses CakePHP's +database abstractions and ORM. Longer term this will allow for phinx to be +removed as a dependency. This greatly reduces the dependency footprint of +migrations. + +What is the same? +================= + +Your migrations shouldn't have to change much to adapt to the new backend. +The migrations backend implements all of the phinx interfaces and can run +migrations based on phinx classes. If your migrations don't work in a way that +could be addressed by the changes outlined below, please open an issue, as we'd +like to maintain as much compatibility as we can. + +What is different? +================== + +If your migrations are using the ``AdapterInterface`` to fetch rows or update +rows you will need to update your code. If you use ``Adapter::query()`` to +execute queries, the return of this method is now +``Cake\Database\StatementInterface`` instead. This impacts ``fetchAll()``, +and ``fetch()``:: + + // This + $stmt = $this->getAdapter()->query('SELECT * FROM articles'); + $rows = $stmt->fetchAll(); + + // Now needs to be + $stmt = $this->getAdapter()->query('SELECT * FROM articles'); + $rows = $stmt->fetchAll('assoc'); + +Similar changes are for fetching a single row:: + + // This + $stmt = $this->getAdapter()->query('SELECT * FROM articles'); + $rows = $stmt->fetch(); + + // Now needs to be + $stmt = $this->getAdapter()->query('SELECT * FROM articles'); + $rows = $stmt->fetch('assoc'); + +Enabling the new backend +======================== + +The new backend can be enabled through application configuration. Add the +following to your ``config/app.php``:: + + return [ + // Other configuration. + 'Migrations' => ['backend' => 'builtin'], + ]; + +If your migrations have problems running with the builtin backend, removing this +configuration option will revert to using phinx. diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index c4ee7c3c..49a54904 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -15,6 +15,56 @@ parameters: count: 1 path: src/Command/BakeMigrationSnapshotCommand.php + - + message: "#^Unsafe usage of new static\\(\\)\\.$#" + count: 1 + path: src/Db/Adapter/AdapterFactory.php + + - + message: "#^Offset 'id' on non\\-empty\\-array\\ in isset\\(\\) always exists and is not nullable\\.$#" + count: 2 + path: src/Db/Adapter/MysqlAdapter.php + + - + message: "#^Right side of && is always true\\.$#" + count: 1 + path: src/Db/Adapter/MysqlAdapter.php + + - + message: "#^Access to an undefined property Cake\\\\Database\\\\Connection\\:\\:\\$connection\\.$#" + count: 1 + path: src/Db/Adapter/PdoAdapter.php + + - + message: "#^Offset 'id' on array\\ in isset\\(\\) always exists and is not nullable\\.$#" + count: 2 + path: src/Db/Adapter/PostgresAdapter.php + + - + message: "#^Offset 'id' on array\\ in isset\\(\\) always exists and is not nullable\\.$#" + count: 2 + path: src/Db/Adapter/SqliteAdapter.php + + - + message: "#^Offset 'id' on array\\ in isset\\(\\) always exists and is not nullable\\.$#" + count: 2 + path: src/Db/Adapter/SqlserverAdapter.php + + - + message: "#^PHPDoc tag @return with type Phinx\\\\Db\\\\Adapter\\\\AdapterInterface is not subtype of native type Migrations\\\\Db\\\\Adapter\\\\AdapterInterface\\.$#" + count: 1 + path: src/Db/Adapter/SqlserverAdapter.php + + - + message: "#^Ternary operator condition is always true\\.$#" + count: 2 + path: src/Db/Adapter/SqlserverAdapter.php + + - + message: "#^Method Migrations\\\\Shim\\\\OutputAdapter\\:\\:getVerbosity\\(\\) should return 16\\|32\\|64\\|128\\|256 but returns int\\.$#" + count: 1 + path: src/Shim/OutputAdapter.php + - message: "#^Possibly invalid array key type Cake\\\\Database\\\\Schema\\\\TableSchemaInterface\\|string\\.$#" count: 2 diff --git a/phpunit.xml.dist b/phpunit.xml.dist index d3801ea5..aef2017f 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -3,17 +3,21 @@ colors="true" cacheDirectory=".phpunit.cache" bootstrap="tests/bootstrap.php" - xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.1/phpunit.xsd" + xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.5/phpunit.xsd" > - tests/TestCase tests/TestCase/TestSuite + tests/TestCase/Migration/ManagerTest.php tests/TestCase/TestSuite + + + tests/TestCase/Migration/ManagerTest.php + @@ -30,20 +34,22 @@ - diff --git a/psalm-baseline.xml b/psalm-baseline.xml index 13b3e682..c046f594 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -1,20 +1,148 @@ + + + MigrationsDispatcher + MigrationsDispatcher::getCommands() + MigrationsDispatcher::getCommands() + \Migrations\MigrationsDispatcher + new MigrationsDispatcher(PHINX_VERSION) + + + + ConfigurationTrait + $phinxName + + + ConfigurationTrait + + + + + ConfigurationTrait + + + + + ConfigurationTrait + + + + + ConfigurationTrait + + + + ConfigurationTrait + setInput - - - $split[0] - $splitted[0] - + + + ConfigurationTrait + + + + + ArrayAccess + + + + + io]]> + null + + + + + adapters]]> + + + + + getQueryBuilder + + + + + $opened + is_array($newColumns) + + + + + getQueryBuilder + + + + + \Phinx\Db\Adapter\AdapterInterface + + + \Phinx\Db\Adapter\AdapterInterface + + + \Phinx\Db\Adapter\AdapterInterface + + + is_array($newColumns) + + + + + $columns + $newColumns + + + verbose + + + + + adapter)]]> + + + + + array_merge($versions, array_keys($migrations)) + + + container)]]> + + + $executedVersion + + + + + CONFIG + + + + + ConfigurationTrait + + + + + ConfigurationTrait + + + + + io->level()]]> + + + self::VERBOSITY_* + diff --git a/psalm.xml b/psalm.xml index 54311675..738d8fcd 100644 --- a/psalm.xml +++ b/psalm.xml @@ -5,7 +5,7 @@ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="https://getpsalm.org/schema/config" xsi:schemaLocation="https://getpsalm.org/schema/config vendor/vimeo/psalm/config.xsd" - errorBaseline="psalm-baseline.xml" + errorBaseline="./psalm-baseline.xml" autoloader="tests/bootstrap.php" findUnusedPsalmSuppress="true" findUnusedBaselineEntry="true" diff --git a/src/Command/BakeMigrationSnapshotCommand.php b/src/Command/BakeMigrationSnapshotCommand.php index 4b0427e7..ea711069 100644 --- a/src/Command/BakeMigrationSnapshotCommand.php +++ b/src/Command/BakeMigrationSnapshotCommand.php @@ -24,7 +24,7 @@ use Cake\Datasource\ConnectionManager; use Cake\Event\Event; use Cake\Event\EventManager; -use Migrations\TableFinderTrait; +use Migrations\Util\TableFinder; use Migrations\Util\UtilTrait; /** @@ -33,7 +33,6 @@ class BakeMigrationSnapshotCommand extends BakeSimpleMigrationCommand { use SnapshotTrait; - use TableFinderTrait; use UtilTrait; /** @@ -95,7 +94,8 @@ public function templateData(Arguments $arguments): array 'require-table' => $arguments->getOption('require-table'), 'plugin' => $this->plugin, ]; - $tables = $this->getTablesToBake($collection, $options); + $finder = new TableFinder($this->connection); + $tables = $finder->getTablesToBake($collection, $options); sort($tables, SORT_NATURAL); diff --git a/src/Command/BakeSeedCommand.php b/src/Command/BakeSeedCommand.php index 170ac5fe..6aedaf61 100644 --- a/src/Command/BakeSeedCommand.php +++ b/src/Command/BakeSeedCommand.php @@ -150,9 +150,11 @@ public function templateData(Arguments $arguments): array */ public function bake(string $name, Arguments $args, ConsoleIo $io): void { + /** @var array $options */ + $options = array_merge($args->getOptions(), ['no-test' => true]); $newArgs = new Arguments( $args->getArguments(), - ['no-test' => true] + $args->getOptions(), + $options, ['name'] ); $this->_name = $name; diff --git a/src/Command/DumpCommand.php b/src/Command/DumpCommand.php new file mode 100644 index 00000000..ba672f89 --- /dev/null +++ b/src/Command/DumpCommand.php @@ -0,0 +1,148 @@ + + */ + public static function extractArgs(Arguments $args): array + { + /** @var array $newArgs */ + $newArgs = []; + if ($args->getOption('connection')) { + $newArgs[] = '-c'; + $newArgs[] = $args->getOption('connection'); + } + if ($args->getOption('plugin')) { + $newArgs[] = '-p'; + $newArgs[] = $args->getOption('plugin'); + } + if ($args->getOption('source')) { + $newArgs[] = '-s'; + $newArgs[] = $args->getOption('source'); + } + + return $newArgs; + } + + /** + * Configure the option parser + * + * @param \Cake\Console\ConsoleOptionParser $parser The option parser to configure + * @return \Cake\Console\ConsoleOptionParser + */ + public function buildOptionParser(ConsoleOptionParser $parser): ConsoleOptionParser + { + $parser->setDescription([ + 'Dumps the current scheam of the database to be used while baking a diff', + '', + 'migrations dump -c secondary', + ])->addOption('plugin', [ + 'short' => 'p', + 'help' => 'The plugin to dump migrations for', + ])->addOption('connection', [ + 'short' => 'c', + 'help' => 'The datasource connection to use', + 'default' => 'default', + ])->addOption('source', [ + 'short' => 's', + 'help' => 'The folder under src/Config that migrations are in', + 'default' => ConfigInterface::DEFAULT_MIGRATION_FOLDER, + ]); + + return $parser; + } + + /** + * Execute the command. + * + * @param \Cake\Console\Arguments $args The command arguments. + * @param \Cake\Console\ConsoleIo $io The console io + * @return int|null The exit code or null for success + */ + public function execute(Arguments $args, ConsoleIo $io): ?int + { + $factory = new ManagerFactory([ + 'plugin' => $args->getOption('plugin'), + 'source' => $args->getOption('source'), + 'connection' => $args->getOption('connection'), + ]); + $config = $factory->createConfig(); + $path = $config->getMigrationPath(); + $connectionName = (string)$config->getConnection(); + $connection = ConnectionManager::get($connectionName); + assert($connection instanceof Connection); + + $collection = $connection->getSchemaCollection(); + $options = [ + 'require-table' => false, + 'plugin' => $args->getOption('plugin'), + ]; + // The connection property is used by the trait methods. + $this->connection = $connectionName; + $finder = new TableFinder($connectionName); + $tables = $finder->getTablesToBake($collection, $options); + + $dump = []; + if ($tables) { + foreach ($tables as $table) { + $schema = $collection->describe($table); + $dump[$table] = $schema; + } + } + + $filePath = $path . DS . 'schema-dump-' . $connectionName . '.lock'; + $io->out("Writing dump file `{$filePath}`..."); + if (file_put_contents($filePath, serialize($dump))) { + $io->out("Dump file `{$filePath}` was successfully written"); + + return self::CODE_SUCCESS; + } + $io->out("An error occurred while writing dump file `{$filePath}`"); + + return self::CODE_ERROR; + } +} diff --git a/src/Command/EntryCommand.php b/src/Command/EntryCommand.php new file mode 100644 index 00000000..cc218d6f --- /dev/null +++ b/src/Command/EntryCommand.php @@ -0,0 +1,150 @@ +commands = $commands; + } + + /** + * Run the command. + * + * Override the run() method for special handling of the `--help` option. + * + * @param array $argv Arguments from the CLI environment. + * @param \Cake\Console\ConsoleIo $io The console io + * @return int|null Exit code or null for success. + */ + public function run(array $argv, ConsoleIo $io): ?int + { + $this->initialize(); + + $parser = $this->getOptionParser(); + try { + [$options, $arguments] = $parser->parse($argv); + $args = new Arguments( + $arguments, + $options, + $parser->argumentNames() + ); + } catch (ConsoleException $e) { + $io->err('Error: ' . $e->getMessage()); + + return static::CODE_ERROR; + } + $this->setOutputLevel($args, $io); + + // This is the variance from Command::run() + if (!$args->getArgumentAt(0) && $args->getOption('help')) { + $io->out([ + 'Migrations', + '', + "Migrations provides commands for managing your application's database schema and initial data.", + '', + ]); + $help = $this->getHelp(); + $this->executeCommand($help, [], $io); + + return static::CODE_SUCCESS; + } + + return $this->execute($args, $io); + } + + /** + * Execute the command. + * + * @param \Cake\Console\Arguments $args The command arguments. + * @param \Cake\Console\ConsoleIo $io The console io + * @return int|null The exit code or null for success + */ + public function execute(Arguments $args, ConsoleIo $io): ?int + { + if ($args->hasArgumentAt(0)) { + $name = $args->getArgumentAt(0); + $io->err( + "Could not find migrations command named `$name`." + . ' Run `migrations --help` to get a list of commands.' + ); + + return static::CODE_ERROR; + } + $io->err('No command provided. Run `migrations --help` to get a list of commands.'); + + return static::CODE_ERROR; + } + + /** + * Gets the generated help command + * + * @return \Cake\Console\Command\HelpCommand + */ + public function getHelp(): HelpCommand + { + $help = new HelpCommand(); + $commands = []; + foreach ($this->commands as $command => $class) { + if (str_starts_with($command, 'migrations')) { + $parts = explode(' ', $command); + + // Remove `migrations` + array_shift($parts); + if (count($parts) === 0) { + continue; + } + $commands[$command] = $class; + } + } + + $CommandCollection = new CommandCollection($commands); + $help->setCommandCollection($CommandCollection); + + return $help; + } +} diff --git a/src/Command/MarkMigratedCommand.php b/src/Command/MarkMigratedCommand.php new file mode 100644 index 00000000..6f969d8d --- /dev/null +++ b/src/Command/MarkMigratedCommand.php @@ -0,0 +1,142 @@ +setDescription([ + 'Mark a migration as applied', + '', + 'Can mark one or more migrations as applied without applying the changes in the migration.', + '', + 'migrations mark_migrated --connection secondary', + 'Mark all migrations as applied', + '', + 'migrations mark_migrated --connection secondary --target 003', + 'mark migrations as applied up to the 003', + '', + 'migrations mark_migrated --target 003 --only', + 'mark only 003 as applied.', + '', + 'migrations mark_migrated --target 003 --exclude', + 'mark up to 003, but not 003 as applied.', + ])->addOption('plugin', [ + 'short' => 'p', + 'help' => 'The plugin to mark migrations for', + ])->addOption('connection', [ + 'short' => 'c', + 'help' => 'The datasource connection to use', + 'default' => 'default', + ])->addOption('source', [ + 'short' => 's', + 'default' => ConfigInterface::DEFAULT_MIGRATION_FOLDER, + 'help' => 'The folder where your migrations are', + ])->addOption('target', [ + 'short' => 't', + 'help' => 'Migrations from the beginning to the provided version will be marked as applied.', + ])->addOption('only', [ + 'short' => 'o', + 'help' => 'If present, only the target migration will be marked as applied.', + 'boolean' => true, + ])->addOption('exclude', [ + 'short' => 'x', + 'help' => 'If present, migrations from the beginning until the target version but not including the target will be marked as applied.', + 'boolean' => true, + ]); + + return $parser; + } + + /** + * Checks for an invalid use of `--exclude` or `--only` + * + * @param \Cake\Console\Arguments $args The console arguments + * @return bool Returns true when it is an invalid use of `--exclude` or `--only` otherwise false + */ + protected function invalidOnlyOrExclude(Arguments $args): bool + { + return ($args->getOption('exclude') && $args->getOption('only')) || + ($args->getOption('exclude') || $args->getOption('only')) && + $args->getOption('target') === null; + } + + /** + * Execute the command. + * + * @param \Cake\Console\Arguments $args The command arguments. + * @param \Cake\Console\ConsoleIo $io The console io + * @return int|null The exit code or null for success + */ + public function execute(Arguments $args, ConsoleIo $io): ?int + { + $factory = new ManagerFactory([ + 'plugin' => $args->getOption('plugin'), + 'source' => $args->getOption('source'), + 'connection' => $args->getOption('connection'), + ]); + $manager = $factory->createManager($io); + $config = $manager->getConfig(); + $path = $config->getMigrationPath(); + + if ($this->invalidOnlyOrExclude($args)) { + $io->err( + 'You should use `--exclude` OR `--only` (not both) along with a `--target` !' + ); + + return self::CODE_ERROR; + } + + try { + $versions = $manager->getVersionsToMark($args); + } catch (InvalidArgumentException $e) { + $io->err(sprintf('%s', $e->getMessage())); + + return self::CODE_ERROR; + } + + $output = $manager->markVersionsAsMigrated($path, $versions); + array_map(fn ($line) => $io->out($line), $output); + + return self::CODE_SUCCESS; + } +} diff --git a/src/Command/MigrateCommand.php b/src/Command/MigrateCommand.php new file mode 100644 index 00000000..acd906fc --- /dev/null +++ b/src/Command/MigrateCommand.php @@ -0,0 +1,186 @@ + + */ + use EventDispatcherTrait; + + /** + * The default name added to the application command list + * + * @return string + */ + public static function defaultName(): string + { + return 'migrations migrate'; + } + + /** + * Configure the option parser + * + * @param \Cake\Console\ConsoleOptionParser $parser The option parser to configure + * @return \Cake\Console\ConsoleOptionParser + */ + public function buildOptionParser(ConsoleOptionParser $parser): ConsoleOptionParser + { + $parser->setDescription([ + 'Apply migrations to a SQL datasource', + '', + 'Will run all available migrations, optionally up to a specific version', + '', + 'migrations migrate --connection secondary', + 'migrations migrate --connection secondary --target 003', + ])->addOption('plugin', [ + 'short' => 'p', + 'help' => 'The plugin to run migrations for', + ])->addOption('connection', [ + 'short' => 'c', + 'help' => 'The datasource connection to use', + 'default' => 'default', + ])->addOption('source', [ + 'short' => 's', + 'default' => ConfigInterface::DEFAULT_MIGRATION_FOLDER, + 'help' => 'The folder where your migrations are', + ])->addOption('target', [ + 'short' => 't', + 'help' => 'The target version to migrate to.', + ])->addOption('date', [ + 'short' => 'd', + 'help' => 'The date to migrate to', + ])->addOption('fake', [ + 'help' => "Mark any migrations selected as run, but don't actually execute them", + 'boolean' => true, + ])->addOption('dry-run', [ + 'help' => 'Dump queries to stdout instead of executing them', + 'boolean' => true, + ])->addOption('no-lock', [ + 'help' => 'If present, no lock file will be generated after migrating', + 'boolean' => true, + ]); + + return $parser; + } + + /** + * Execute the command. + * + * @param \Cake\Console\Arguments $args The command arguments. + * @param \Cake\Console\ConsoleIo $io The console io + * @return int|null The exit code or null for success + */ + public function execute(Arguments $args, ConsoleIo $io): ?int + { + $event = $this->dispatchEvent('Migration.beforeMigrate'); + if ($event->isStopped()) { + return $event->getResult() ? self::CODE_SUCCESS : self::CODE_ERROR; + } + $result = $this->executeMigrations($args, $io); + $this->dispatchEvent('Migration.afterMigrate'); + + return $result; + } + + /** + * Execute migrations based on console inputs. + * + * @param \Cake\Console\Arguments $args The command arguments. + * @param \Cake\Console\ConsoleIo $io The console io + * @return int|null The exit code or null for success + */ + protected function executeMigrations(Arguments $args, ConsoleIo $io): ?int + { + $version = $args->getOption('target') !== null ? (int)$args->getOption('target') : null; + $date = $args->getOption('date'); + $fake = (bool)$args->getOption('fake'); + $dryRun = (bool)$args->getOption('dry-run'); + + $factory = new ManagerFactory([ + 'plugin' => $args->getOption('plugin'), + 'source' => $args->getOption('source'), + 'connection' => $args->getOption('connection'), + 'dry-run' => $dryRun, + ]); + $manager = $factory->createManager($io); + $config = $manager->getConfig(); + + $versionOrder = $config->getVersionOrder(); + if ($dryRun) { + $io->out('dry-run mode enabled'); + } + $io->out('using connection ' . (string)$args->getOption('connection')); + $io->out('using connection ' . (string)$args->getOption('connection')); + $io->out('using paths ' . $config->getMigrationPath()); + $io->out('ordering by ' . $versionOrder . ' time'); + + if ($fake) { + $io->out('warning performing fake migrations'); + } + + try { + // run the migrations + $start = microtime(true); + if ($date !== null) { + $manager->migrateToDateTime(new DateTime((string)$date), $fake); + } else { + $manager->migrate($version, $fake); + } + $end = microtime(true); + } catch (Exception $e) { + $io->err('' . $e->getMessage() . ''); + $io->out($e->getTraceAsString(), 1, ConsoleIo::VERBOSE); + + return self::CODE_ERROR; + } catch (Throwable $e) { + $io->err('' . $e->getMessage() . ''); + $io->out($e->getTraceAsString(), 1, ConsoleIo::VERBOSE); + + return self::CODE_ERROR; + } + + $io->out(''); + $io->out('All Done. Took ' . sprintf('%.4fs', $end - $start) . ''); + + $exitCode = self::CODE_SUCCESS; + + // Run dump command to generate lock file + if (!$args->getOption('no-lock') && !$args->getOption('dry-run')) { + $io->out(''); + $io->out('Dumping the current schema of the database to be used while baking a diff'); + $io->out(''); + + $newArgs = DumpCommand::extractArgs($args); + $exitCode = $this->executeCommand(DumpCommand::class, $newArgs, $io); + } + + return $exitCode; + } +} diff --git a/src/Command/MigrationsCommand.php b/src/Command/MigrationsCommand.php index 56161e26..47f5e5cd 100644 --- a/src/Command/MigrationsCommand.php +++ b/src/Command/MigrationsCommand.php @@ -51,8 +51,9 @@ public static function defaultName(): string if (parent::defaultName() === 'migrations') { return 'migrations'; } - $command = new MigrationsDispatcher::$phinxCommands[static::$commandName](); - $name = $command->getName(); + $className = MigrationsDispatcher::getCommands()[static::$commandName]; + $command = new $className(); + $name = (string)$command->getName(); return 'migrations ' . $name; } @@ -77,7 +78,10 @@ public function getOptionParser(): ConsoleOptionParser return parent::getOptionParser(); } $parser = parent::getOptionParser(); - $command = new MigrationsDispatcher::$phinxCommands[static::$commandName](); + $className = MigrationsDispatcher::getCommands()[static::$commandName]; + $command = new $className(); + + // Skip conversions for new commands. $parser->setDescription($command->getDescription()); $definition = $command->getDefinition(); foreach ($definition->getOptions() as $option) { diff --git a/src/Command/Phinx/Dump.php b/src/Command/Phinx/Dump.php index b796df1d..78208837 100644 --- a/src/Command/Phinx/Dump.php +++ b/src/Command/Phinx/Dump.php @@ -16,7 +16,7 @@ use Cake\Database\Connection; use Cake\Datasource\ConnectionManager; use Migrations\ConfigurationTrait; -use Migrations\TableFinderTrait; +use Migrations\Util\TableFinder; use Phinx\Console\Command\AbstractCommand; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; @@ -31,7 +31,6 @@ class Dump extends AbstractCommand { use CommandTrait; use ConfigurationTrait; - use TableFinderTrait; /** * Output object. @@ -96,7 +95,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int 'require-table' => false, 'plugin' => $this->getPlugin($input), ]; - $tables = $this->getTablesToBake($collection, $options); + $finder = new TableFinder($connectionName); + $tables = $finder->getTablesToBake($collection, $options); $dump = []; if ($tables) { diff --git a/src/Command/RollbackCommand.php b/src/Command/RollbackCommand.php new file mode 100644 index 00000000..0711030c --- /dev/null +++ b/src/Command/RollbackCommand.php @@ -0,0 +1,233 @@ + + */ + use EventDispatcherTrait; + + /** + * The default name added to the application command list + * + * @return string + */ + public static function defaultName(): string + { + return 'migrations rollback'; + } + + /** + * Configure the option parser + * + * @param \Cake\Console\ConsoleOptionParser $parser The option parser to configure + * @return \Cake\Console\ConsoleOptionParser + */ + public function buildOptionParser(ConsoleOptionParser $parser): ConsoleOptionParser + { + $parser->setDescription([ + 'Rollback migrations to a specific migration', + '', + 'Reverts the last migration or optionally to a specific migration', + '', + 'migrations rollback --connection secondary', + 'migrations rollback --connection secondary --target 003', + ])->addOption('plugin', [ + 'short' => 'p', + 'help' => 'The plugin to rollback migrations for', + ])->addOption('connection', [ + 'short' => 'c', + 'help' => 'The datasource connection to use', + 'default' => 'default', + ])->addOption('source', [ + 'short' => 's', + 'default' => ConfigInterface::DEFAULT_MIGRATION_FOLDER, + 'help' => 'The folder where your migrations are', + ])->addOption('target', [ + 'short' => 't', + 'help' => 'The target version to rollback to.', + ])->addOption('date', [ + 'short' => 'd', + 'help' => 'The date to rollback to', + ])->addOption('fake', [ + 'help' => "Mark any migrations selected as run, but don't actually execute them", + 'boolean' => true, + ])->addOption('force', [ + 'help' => 'Force rollback to ignore breakpoints', + 'short' => 'f', + 'boolean' => true, + ])->addOption('dry-run', [ + 'help' => 'Dump queries to stdout instead of running them.', + 'short' => 'x', + 'boolean' => true, + ])->addOption('no-lock', [ + 'help' => 'If present, no lock file will be generated after migrating', + 'boolean' => true, + ]); + + return $parser; + } + + /** + * Execute the command. + * + * @param \Cake\Console\Arguments $args The command arguments. + * @param \Cake\Console\ConsoleIo $io The console io + * @return int|null The exit code or null for success + */ + public function execute(Arguments $args, ConsoleIo $io): ?int + { + $event = $this->dispatchEvent('Migration.beforeRollback'); + if ($event->isStopped()) { + return $event->getResult() ? self::CODE_SUCCESS : self::CODE_ERROR; + } + $result = $this->executeMigrations($args, $io); + $this->dispatchEvent('Migration.afterRollback'); + + return $result; + } + + /** + * Execute migrations based on console inputs. + * + * @param \Cake\Console\Arguments $args The command arguments. + * @param \Cake\Console\ConsoleIo $io The console io + * @return int|null The exit code or null for success + */ + protected function executeMigrations(Arguments $args, ConsoleIo $io): ?int + { + $version = $args->getOption('target') !== null ? (int)$args->getOption('target') : null; + $date = $args->getOption('date'); + $fake = (bool)$args->getOption('fake'); + $force = (bool)$args->getOption('force'); + $dryRun = (bool)$args->getOption('dry-run'); + + $factory = new ManagerFactory([ + 'plugin' => $args->getOption('plugin'), + 'source' => $args->getOption('source'), + 'connection' => $args->getOption('connection'), + 'dry-run' => $dryRun, + ]); + $manager = $factory->createManager($io); + $config = $manager->getConfig(); + + $versionOrder = $config->getVersionOrder(); + $io->out('using connection ' . (string)$args->getOption('connection')); + $io->out('using paths ' . $config->getMigrationPath()); + $io->out('ordering by ' . $versionOrder . ' time'); + + if ($dryRun) { + $io->out('dry-run mode enabled'); + } + if ($fake) { + $io->out('warning performing fake rollbacks'); + } + + if ($date === null) { + $targetMustMatch = true; + $target = $version; + } else { + $targetMustMatch = false; + $target = $this->getTargetFromDate($date); + } + + try { + // run the migrations + $start = microtime(true); + $manager->rollback($target, $force, $targetMustMatch, $fake); + $end = microtime(true); + } catch (Exception $e) { + $io->err('' . $e->getMessage() . ''); + $io->out($e->getTraceAsString(), 1, ConsoleIo::VERBOSE); + + return self::CODE_ERROR; + } catch (Throwable $e) { + $io->err('' . $e->getMessage() . ''); + $io->out($e->getTraceAsString(), 1, ConsoleIo::VERBOSE); + + return self::CODE_ERROR; + } + + $io->out(''); + $io->out('All Done. Took ' . sprintf('%.4fs', $end - $start) . ''); + + $exitCode = self::CODE_SUCCESS; + + // Run dump command to generate lock file + if (!$args->getOption('no-lock')) { + $io->out(''); + $io->out('Dumping the current schema of the database to be used while baking a diff'); + $io->out(''); + + $newArgs = DumpCommand::extractArgs($args); + $exitCode = $this->executeCommand(DumpCommand::class, $newArgs, $io); + } + + return $exitCode; + } + + /** + * Get Target from Date + * + * @param string|bool $date The date to convert to a target. + * @throws \InvalidArgumentException + * @return string The target + */ + protected function getTargetFromDate(string|bool $date): string + { + // Narrow types as getOption() can return null|bool|string + if (!is_string($date) || !preg_match('/^\d{4,14}$/', $date)) { + throw new InvalidArgumentException('Invalid date. Format is YYYY[MM[DD[HH[II[SS]]]]].'); + } + // what we need to append to the date according to the possible date string lengths + $dateStrlenToAppend = [ + 14 => '', + 12 => '00', + 10 => '0000', + 8 => '000000', + 6 => '01000000', + 4 => '0101000000', + ]; + + /** @var string $date */ + $dateLength = strlen($date); + if (!isset($dateStrlenToAppend[$dateLength])) { + throw new InvalidArgumentException('Invalid date. Format is YYYY[MM[DD[HH[II[SS]]]]].'); + } + $target = $date . $dateStrlenToAppend[$dateLength]; + $dateTime = DateTime::createFromFormat('YmdHis', $target); + if ($dateTime === false) { + throw new InvalidArgumentException('Invalid date. Format is YYYY[MM[DD[HH[II[SS]]]]].'); + } + + return $dateTime->format('YmdHis'); + } +} diff --git a/src/Command/SeedCommand.php b/src/Command/SeedCommand.php new file mode 100644 index 00000000..b9dbacac --- /dev/null +++ b/src/Command/SeedCommand.php @@ -0,0 +1,137 @@ + + */ + use EventDispatcherTrait; + + /** + * The default name added to the application command list + * + * @return string + */ + public static function defaultName(): string + { + return 'migrations seed'; + } + + /** + * Configure the option parser + * + * @param \Cake\Console\ConsoleOptionParser $parser The option parser to configure + * @return \Cake\Console\ConsoleOptionParser + */ + public function buildOptionParser(ConsoleOptionParser $parser): ConsoleOptionParser + { + $parser->setDescription([ + 'Seed the database with data', + '', + 'Runs a seeder script that can populate the database with data, or run mutations', + '', + 'migrations seed --connection secondary --seed UserSeeder', + '', + 'The `--seed` option can be supplied multiple times to run more than one seeder', + ])->addOption('plugin', [ + 'short' => 'p', + 'help' => 'The plugin to run seeders in', + ])->addOption('connection', [ + 'short' => 'c', + 'help' => 'The datasource connection to use', + 'default' => 'default', + ])->addOption('source', [ + 'short' => 's', + 'default' => ConfigInterface::DEFAULT_SEED_FOLDER, + 'help' => 'The folder where your seeders are.', + ])->addOption('seed', [ + 'help' => 'The name of the seeder that you want to run.', + 'multiple' => true, + ]); + + return $parser; + } + + /** + * Execute the command. + * + * @param \Cake\Console\Arguments $args The command arguments. + * @param \Cake\Console\ConsoleIo $io The console io + * @return int|null The exit code or null for success + */ + public function execute(Arguments $args, ConsoleIo $io): ?int + { + $event = $this->dispatchEvent('Migration.beforeSeed'); + if ($event->isStopped()) { + return $event->getResult() ? self::CODE_SUCCESS : self::CODE_ERROR; + } + $result = $this->executeSeeds($args, $io); + $this->dispatchEvent('Migration.afterSeed'); + + return $result; + } + + /** + * Execute seeders based on console inputs. + * + * @param \Cake\Console\Arguments $args The command arguments. + * @param \Cake\Console\ConsoleIo $io The console io + * @return int|null The exit code or null for success + */ + protected function executeSeeds(Arguments $args, ConsoleIo $io): ?int + { + $factory = new ManagerFactory([ + 'plugin' => $args->getOption('plugin'), + 'source' => $args->getOption('source'), + 'connection' => $args->getOption('connection'), + ]); + $manager = $factory->createManager($io); + $config = $manager->getConfig(); + $seeds = (array)$args->getMultipleOption('seed'); + + $versionOrder = $config->getVersionOrder(); + $io->out('using connection ' . (string)$args->getOption('connection')); + $io->out('using paths ' . $config->getMigrationPath()); + $io->out('ordering by ' . $versionOrder . ' time'); + + $start = microtime(true); + if (empty($seeds)) { + // run all the seed(ers) + $manager->seed(); + } else { + // run seed(ers) specified in a comma-separated list of classes + foreach ($seeds as $seed) { + $manager->seed(trim($seed)); + } + } + $end = microtime(true); + + $io->out('All Done. Took ' . sprintf('%.4fs', $end - $start) . ''); + + return self::CODE_SUCCESS; + } +} diff --git a/src/Command/StatusCommand.php b/src/Command/StatusCommand.php new file mode 100644 index 00000000..999cc09c --- /dev/null +++ b/src/Command/StatusCommand.php @@ -0,0 +1,158 @@ +setDescription([ + 'The status command prints a list of all migrations, along with their current status', + '', + 'migrations status -c secondary', + 'migrations status -c secondary -f json', + ])->addOption('plugin', [ + 'short' => 'p', + 'help' => 'The plugin to run migrations for', + ])->addOption('connection', [ + 'short' => 'c', + 'help' => 'The datasource connection to use', + 'default' => 'default', + ])->addOption('source', [ + 'short' => 's', + 'help' => 'The folder under src/Config that migrations are in', + 'default' => ConfigInterface::DEFAULT_MIGRATION_FOLDER, + ])->addOption('format', [ + 'short' => 'f', + 'help' => 'The output format: text or json. Defaults to text.', + 'choices' => ['text', 'json'], + 'default' => 'text', + ]); + + return $parser; + } + + /** + * Execute the command. + * + * @param \Cake\Console\Arguments $args The command arguments. + * @param \Cake\Console\ConsoleIo $io The console io + * @return int|null The exit code or null for success + */ + public function execute(Arguments $args, ConsoleIo $io): ?int + { + /** @var string|null $format */ + $format = $args->getOption('format'); + + $factory = new ManagerFactory([ + 'plugin' => $args->getOption('plugin'), + 'source' => $args->getOption('source'), + 'connection' => $args->getOption('connection'), + 'dry-run' => $args->getOption('dry-run'), + ]); + $manager = $factory->createManager($io); + $migrations = $manager->printStatus($format); + + switch ($format) { + case 'json': + $flags = 0; + if ($args->getOption('verbose')) { + $flags = JSON_PRETTY_PRINT; + } + $migrationString = (string)json_encode($migrations, $flags); + $io->out($migrationString); + break; + default: + $this->display($migrations, $io); + break; + } + + return Command::CODE_SUCCESS; + } + + /** + * Print migration status to stdout. + * + * @param array $migrations + * @param \Cake\Console\ConsoleIo $io The console io + * @return void + */ + protected function display(array $migrations, ConsoleIo $io): void + { + if (!empty($migrations)) { + $rows = []; + $rows[] = ['Status', 'Migration ID', 'Migration Name']; + + foreach ($migrations as $migration) { + $status = $migration['status'] === 'up' ? 'up' : 'down'; + $name = $migration['name'] ? + '' . $migration['name'] . '' : + '** MISSING **'; + + $missingComment = ''; + if (!empty($migration['missing'])) { + $missingComment = '** MISSING **'; + } + $rows[] = [$status, sprintf('%14.0f ', $migration['id']), $name . $missingComment]; + } + $io->helper('table')->output($rows); + } else { + $msg = 'There are no available migrations. Try creating one using the create command.'; + $io->err(''); + $io->err($msg); + $io->err(''); + } + } +} diff --git a/src/Config/Config.php b/src/Config/Config.php new file mode 100644 index 00000000..7d413205 --- /dev/null +++ b/src/Config/Config.php @@ -0,0 +1,239 @@ +values = $configArray; + } + + /** + * @inheritDoc + */ + public function getEnvironment(): ?array + { + if (empty($this->values['environment'])) { + return null; + } + $config = (array)$this->values['environment']; + $config['version_order'] = $this->getVersionOrder(); + + return $config; + } + + /** + * @inheritDoc + * @throws \UnexpectedValueException + */ + public function getMigrationPath(): string + { + if (!isset($this->values['paths']['migrations'])) { + throw new UnexpectedValueException('Migrations path missing from config file'); + } + if (is_array($this->values['paths']['migrations']) && isset($this->values['paths']['migrations'][0])) { + return $this->values['paths']['migrations'][0]; + } + + return $this->values['paths']['migrations']; + } + + /** + * @inheritDoc + * @throws \UnexpectedValueException + */ + public function getSeedPath(): string + { + if (!isset($this->values['paths']['seeds'])) { + throw new UnexpectedValueException('Seeds path missing from config file'); + } + if (is_array($this->values['paths']['seeds']) && isset($this->values['paths']['seeds'][0])) { + return $this->values['paths']['seeds'][0]; + } + + return $this->values['paths']['seeds']; + } + + /** + * @inheritdoc + */ + public function getMigrationBaseClassName(bool $dropNamespace = true): string + { + /** @var string $className */ + $className = !isset($this->values['migration_base_class']) ? 'Phinx\Migration\AbstractMigration' : $this->values['migration_base_class']; + + return $dropNamespace ? (substr((string)strrchr($className, '\\'), 1) ?: $className) : $className; + } + + /** + * @inheritdoc + */ + public function getSeedBaseClassName(bool $dropNamespace = true): string + { + /** @var string $className */ + $className = !isset($this->values['seed_base_class']) ? 'Phinx\Seed\AbstractSeed' : $this->values['seed_base_class']; + + return $dropNamespace ? substr((string)strrchr($className, '\\'), 1) : $className; + } + + /** + * @inheritdoc + */ + public function getConnection(): string|false + { + return $this->values['environment']['connection'] ?? false; + } + + /** + * @inheritdoc + */ + public function getTemplateFile(): string|false + { + if (!isset($this->values['templates']['file'])) { + return false; + } + + return $this->values['templates']['file']; + } + + /** + * @inheritdoc + */ + public function getTemplateClass(): string|false + { + if (!isset($this->values['templates']['class'])) { + return false; + } + + return $this->values['templates']['class']; + } + + /** + * @inheritdoc + */ + public function getTemplateStyle(): string + { + if (!isset($this->values['templates']['style'])) { + return self::TEMPLATE_STYLE_CHANGE; + } + + return $this->values['templates']['style'] === self::TEMPLATE_STYLE_UP_DOWN ? self::TEMPLATE_STYLE_UP_DOWN : self::TEMPLATE_STYLE_CHANGE; + } + + /** + * @inheritdoc + */ + public function getVersionOrder(): string + { + if (!isset($this->values['version_order'])) { + return self::VERSION_ORDER_CREATION_TIME; + } + + return $this->values['version_order']; + } + + /** + * @inheritdoc + */ + public function isVersionOrderCreationTime(): bool + { + $versionOrder = $this->getVersionOrder(); + + return $versionOrder == self::VERSION_ORDER_CREATION_TIME; + } + + /** + * {@inheritDoc} + * + * @param mixed $offset ID + * @param mixed $value Value + * @return void + */ + public function offsetSet($offset, $value): void + { + $this->values[$offset] = $value; + } + + /** + * {@inheritDoc} + * + * @param mixed $offset ID + * @throws \InvalidArgumentException + * @return mixed + */ + #[ReturnTypeWillChange] + public function offsetGet($offset) + { + if (!array_key_exists($offset, $this->values)) { + throw new InvalidArgumentException(sprintf('Identifier "%s" is not defined.', $offset)); + } + + return $this->values[$offset] instanceof Closure ? $this->values[$offset]($this) : $this->values[$offset]; + } + + /** + * {@inheritDoc} + * + * @param mixed $offset ID + * @return bool + */ + public function offsetExists($offset): bool + { + return isset($this->values[$offset]); + } + + /** + * {@inheritDoc} + * + * @param mixed $offset ID + * @return void + */ + public function offsetUnset($offset): void + { + unset($this->values[$offset]); + } + + /** + * @inheritdoc + */ + public function getSeedTemplateFile(): ?string + { + return $this->values['templates']['seedFile'] ?? null; + } +} diff --git a/src/Config/ConfigInterface.php b/src/Config/ConfigInterface.php new file mode 100644 index 00000000..790f4c15 --- /dev/null +++ b/src/Config/ConfigInterface.php @@ -0,0 +1,111 @@ + + */ +interface ConfigInterface extends ArrayAccess +{ + public const DEFAULT_MIGRATION_FOLDER = 'Migrations'; + public const DEFAULT_SEED_FOLDER = 'Seeds'; + + /** + * Returns the configuration for the current environment. + * + * This method returns null if the specified environment + * doesn't exist. + * + * @return array|null + */ + public function getEnvironment(): ?array; + + /** + * Gets the path to search for migration files. + * + * @return string + */ + public function getMigrationPath(): string; + + /** + * Gets the path to search for seed files. + * + * @return string + */ + public function getSeedPath(): string; + + /** + * Get the connection namee + * + * @return string|false + */ + public function getConnection(): string|false; + + /** + * Get the template file name. + * + * @return string|false + */ + public function getTemplateFile(): string|false; + + /** + * Get the template class name. + * + * @return string|false + */ + public function getTemplateClass(): string|false; + + /** + * Get the template style to use, either change or up_down. + * + * @return string + */ + public function getTemplateStyle(): string; + + /** + * Get the version order. + * + * @return string + */ + public function getVersionOrder(): string; + + /** + * Is version order creation time? + * + * @return bool + */ + public function isVersionOrderCreationTime(): bool; + + /** + * Gets the base class name for migrations. + * + * @param bool $dropNamespace Return the base migration class name without the namespace. + * @return string + */ + public function getMigrationBaseClassName(bool $dropNamespace = true): string; + + /** + * Gets the base class name for seeders. + * + * @param bool $dropNamespace Return the base seeder class name without the namespace. + * @return string + */ + public function getSeedBaseClassName(bool $dropNamespace = true): string; + + /** + * Get the seeder template file name or null if not set. + * + * @return string|null + */ + public function getSeedTemplateFile(): ?string; +} diff --git a/src/ConfigurationTrait.php b/src/ConfigurationTrait.php index 2c55c56c..bed99308 100644 --- a/src/ConfigurationTrait.php +++ b/src/ConfigurationTrait.php @@ -33,6 +33,8 @@ * the methods in phinx that are responsible for reading the project configuration. * This is needed so that we can use the application configuration instead of having * a configuration yaml file. + * + * @deprecated 4.2.0 Will be removed in 5.0 alongside phinx. */ trait ConfigurationTrait { @@ -119,7 +121,6 @@ public function getConfig(bool $forceRefresh = false): ConfigInterface $connection = $this->getConnectionName($this->input()); $connectionConfig = (array)ConnectionManager::getConfig($connection); - $adapterName = $this->getAdapterName($connectionConfig['driver']); $dsnOptions = $this->extractDsnOptions($adapterName, $connectionConfig); diff --git a/src/Db/Action/Action.php b/src/Db/Action/Action.php new file mode 100644 index 00000000..66adb808 --- /dev/null +++ b/src/Db/Action/Action.php @@ -0,0 +1,39 @@ +table = $table; + } + + /** + * The table this action will be applied to + * + * @return \Migrations\Db\Table\Table + */ + public function getTable(): Table + { + return $this->table; + } +} diff --git a/src/Db/Action/AddColumn.php b/src/Db/Action/AddColumn.php new file mode 100644 index 00000000..3572bb5a --- /dev/null +++ b/src/Db/Action/AddColumn.php @@ -0,0 +1,64 @@ +column = $column; + } + + /** + * Returns a new AddColumn object after assembling the given commands + * + * @param \Migrations\Db\Table\Table $table The table to add the column to + * @param string $columnName The column name + * @param string|\Migrations\Db\Literal $type The column type + * @param array $options The column options + * @return self + */ + public static function build(Table $table, string $columnName, string|Literal $type, array $options = []): self + { + $column = new Column(); + $column->setName($columnName); + $column->setType($type); + $column->setOptions($options); // map options to column methods + + return new AddColumn($table, $column); + } + + /** + * Returns the column to be added + * + * @return \Migrations\Db\Table\Column + */ + public function getColumn(): Column + { + return $this->column; + } +} diff --git a/src/Db/Action/AddForeignKey.php b/src/Db/Action/AddForeignKey.php new file mode 100644 index 00000000..15423a1f --- /dev/null +++ b/src/Db/Action/AddForeignKey.php @@ -0,0 +1,79 @@ +foreignKey = $fk; + } + + /** + * Creates a new AddForeignKey object after building the foreign key with + * the passed attributes + * + * @param \Migrations\Db\Table\Table $table The table object to add the foreign key to + * @param string|string[] $columns The columns for the foreign key + * @param \Migrations\Db\Table\Table|string $referencedTable The table the foreign key references + * @param string|string[] $referencedColumns The columns in the referenced table + * @param array $options Extra options for the foreign key + * @param string|null $name The name of the foreign key + * @return self + */ + public static function build(Table $table, string|array $columns, Table|string $referencedTable, string|array $referencedColumns = ['id'], array $options = [], ?string $name = null): self + { + if (is_string($referencedColumns)) { + $referencedColumns = [$referencedColumns]; // str to array + } + + if (is_string($referencedTable)) { + $referencedTable = new Table($referencedTable); + } + + $fk = new ForeignKey(); + $fk->setReferencedTable($referencedTable) + ->setColumns($columns) + ->setReferencedColumns($referencedColumns) + ->setOptions($options); + + if ($name !== null) { + $fk->setConstraint($name); + } + + return new AddForeignKey($table, $fk); + } + + /** + * Returns the foreign key to be added + * + * @return \Migrations\Db\Table\ForeignKey + */ + public function getForeignKey(): ForeignKey + { + return $this->foreignKey; + } +} diff --git a/src/Db/Action/AddIndex.php b/src/Db/Action/AddIndex.php new file mode 100644 index 00000000..10215871 --- /dev/null +++ b/src/Db/Action/AddIndex.php @@ -0,0 +1,68 @@ +index = $index; + } + + /** + * Creates a new AddIndex object after building the index object with the + * provided arguments + * + * @param \Migrations\Db\Table\Table $table The table to add the index to + * @param string|string[]|\Migrations\Db\Table\Index $columns The columns to index + * @param array $options Additional options for the index creation + * @return self + */ + public static function build(Table $table, string|array|Index $columns, array $options = []): self + { + // create a new index object if strings or an array of strings were supplied + if (!($columns instanceof Index)) { + $index = new Index(); + + $index->setColumns($columns); + $index->setOptions($options); + } else { + $index = $columns; + } + + return new AddIndex($table, $index); + } + + /** + * Returns the index to be added + * + * @return \Migrations\Db\Table\Index + */ + public function getIndex(): Index + { + return $this->index; + } +} diff --git a/src/Db/Action/ChangeColumn.php b/src/Db/Action/ChangeColumn.php new file mode 100644 index 00000000..63327890 --- /dev/null +++ b/src/Db/Action/ChangeColumn.php @@ -0,0 +1,89 @@ +columnName = $columnName; + $this->column = $column; + + // if the name was omitted use the existing column name + if ($column->getName() === null || strlen((string)$column->getName()) === 0) { + $column->setName($columnName); + } + } + + /** + * Creates a new ChangeColumn object after building the column definition + * out of the provided arguments + * + * @param \Migrations\Db\Table\Table $table The table to alter + * @param string $columnName The name of the column to change + * @param string|\Migrations\Db\Literal $type The type of the column + * @param array $options Additional options for the column + * @return self + */ + public static function build(Table $table, string $columnName, string|Literal $type, array $options = []): self + { + $column = new Column(); + $column->setName($columnName); + $column->setType($type); + $column->setOptions($options); // map options to column methods + + return new ChangeColumn($table, $columnName, $column); + } + + /** + * Returns the name of the column to change + * + * @return string + */ + public function getColumnName(): string + { + return $this->columnName; + } + + /** + * Returns the column definition + * + * @return \Migrations\Db\Table\Column + */ + public function getColumn(): Column + { + return $this->column; + } +} diff --git a/src/Db/Action/ChangeComment.php b/src/Db/Action/ChangeComment.php new file mode 100644 index 00000000..b483fa3c --- /dev/null +++ b/src/Db/Action/ChangeComment.php @@ -0,0 +1,43 @@ +newComment = $newComment; + } + + /** + * Return the new comment for the table + * + * @return string|null + */ + public function getNewComment(): ?string + { + return $this->newComment; + } +} diff --git a/src/Db/Action/ChangePrimaryKey.php b/src/Db/Action/ChangePrimaryKey.php new file mode 100644 index 00000000..760f7fab --- /dev/null +++ b/src/Db/Action/ChangePrimaryKey.php @@ -0,0 +1,43 @@ +newColumns = $newColumns; + } + + /** + * Return the new columns for the primary key + * + * @return string|string[]|null + */ + public function getNewColumns(): string|array|null + { + return $this->newColumns; + } +} diff --git a/src/Db/Action/CreateTable.php b/src/Db/Action/CreateTable.php new file mode 100644 index 00000000..a7f8d2bb --- /dev/null +++ b/src/Db/Action/CreateTable.php @@ -0,0 +1,13 @@ +foreignKey = $foreignKey; + } + + /** + * Creates a new DropForeignKey object after building the ForeignKey + * definition out of the passed arguments. + * + * @param \Migrations\Db\Table\Table $table The table to delete the foreign key from + * @param string|string[] $columns The columns participating in the foreign key + * @param string|null $constraint The constraint name + * @return self + */ + public static function build(Table $table, string|array $columns, ?string $constraint = null): self + { + if (is_string($columns)) { + $columns = [$columns]; + } + + $foreignKey = new ForeignKey(); + $foreignKey->setColumns($columns); + + if ($constraint) { + $foreignKey->setConstraint($constraint); + } + + return new DropForeignKey($table, $foreignKey); + } + + /** + * Returns the foreign key to remove + * + * @return \Migrations\Db\Table\ForeignKey + */ + public function getForeignKey(): ForeignKey + { + return $this->foreignKey; + } +} diff --git a/src/Db/Action/DropIndex.php b/src/Db/Action/DropIndex.php new file mode 100644 index 00000000..eef579aa --- /dev/null +++ b/src/Db/Action/DropIndex.php @@ -0,0 +1,76 @@ +index = $index; + } + + /** + * Creates a new DropIndex object after assembling the passed + * arguments. + * + * @param \Migrations\Db\Table\Table $table The table where the index is + * @param string[] $columns the indexed columns + * @return self + */ + public static function build(Table $table, array $columns = []): self + { + $index = new Index(); + $index->setColumns($columns); + + return new DropIndex($table, $index); + } + + /** + * Creates a new DropIndex when the name of the index to drop + * is known. + * + * @param \Migrations\Db\Table\Table $table The table where the index is + * @param string $name The name of the index + * @return self + */ + public static function buildFromName(Table $table, string $name): self + { + $index = new Index(); + $index->setName($name); + + return new DropIndex($table, $index); + } + + /** + * Returns the index to be dropped + * + * @return \Migrations\Db\Table\Index + */ + public function getIndex(): Index + { + return $this->index; + } +} diff --git a/src/Db/Action/DropTable.php b/src/Db/Action/DropTable.php new file mode 100644 index 00000000..37ce58fa --- /dev/null +++ b/src/Db/Action/DropTable.php @@ -0,0 +1,13 @@ +column = $column; + } + + /** + * Creates a new RemoveColumn object after assembling the + * passed arguments. + * + * @param \Migrations\Db\Table\Table $table The table where the column is + * @param string $columnName The name of the column to drop + * @return self + */ + public static function build(Table $table, string $columnName): self + { + $column = new Column(); + $column->setName($columnName); + + return new RemoveColumn($table, $column); + } + + /** + * Returns the column to be dropped + * + * @return \Migrations\Db\Table\Column + */ + public function getColumn(): Column + { + return $this->column; + } +} diff --git a/src/Db/Action/RenameColumn.php b/src/Db/Action/RenameColumn.php new file mode 100644 index 00000000..c2b34274 --- /dev/null +++ b/src/Db/Action/RenameColumn.php @@ -0,0 +1,80 @@ +newName = $newName; + $this->column = $column; + } + + /** + * Creates a new RenameColumn object after building the passed + * arguments + * + * @param \Migrations\Db\Table\Table $table The table where the column is + * @param string $columnName The name of the column to be changed + * @param string $newName The new name for the column + * @return self + */ + public static function build(Table $table, string $columnName, string $newName): self + { + $column = new Column(); + $column->setName($columnName); + + return new RenameColumn($table, $column, $newName); + } + + /** + * Returns the column to be changed + * + * @return \Migrations\Db\Table\Column + */ + public function getColumn(): Column + { + return $this->column; + } + + /** + * Returns the new name for the column + * + * @return string + */ + public function getNewName(): string + { + return $this->newName; + } +} diff --git a/src/Db/Action/RenameTable.php b/src/Db/Action/RenameTable.php new file mode 100644 index 00000000..9808c311 --- /dev/null +++ b/src/Db/Action/RenameTable.php @@ -0,0 +1,43 @@ +newName = $newName; + } + + /** + * Return the new name for the table + * + * @return string + */ + public function getNewName(): string + { + return $this->newName; + } +} diff --git a/src/Db/Adapter/AbstractAdapter.php b/src/Db/Adapter/AbstractAdapter.php new file mode 100644 index 00000000..ab8de152 --- /dev/null +++ b/src/Db/Adapter/AbstractAdapter.php @@ -0,0 +1,318 @@ + + */ + protected array $options = []; + + /** + * @var \Cake\Console\ConsoleIo + */ + protected ConsoleIo $io; + + /** + * @var string[] + */ + protected array $createdTables = []; + + /** + * @var string + */ + protected string $schemaTableName = 'phinxlog'; + + /** + * @var array + */ + protected array $dataDomain = []; + + /** + * Class Constructor. + * + * @param array $options Options + * @param \Cake\Console\ConsoleIo|null $io Console input/output + */ + public function __construct(array $options, ?ConsoleIo $io = null) + { + $this->setOptions($options); + if ($io !== null) { + $this->setIo($io); + } + } + + /** + * @inheritDoc + */ + public function setOptions(array $options): AdapterInterface + { + $this->options = $options; + + if (isset($options['migration_table'])) { + $this->setSchemaTableName($options['migration_table']); + } + + return $this; + } + + /** + * @inheritDoc + */ + public function getOptions(): array + { + return $this->options; + } + + /** + * @inheritDoc + */ + public function hasOption(string $name): bool + { + return isset($this->options[$name]); + } + + /** + * @inheritDoc + */ + public function getOption(string $name): mixed + { + if (!$this->hasOption($name)) { + return null; + } + + return $this->options[$name]; + } + + /** + * @inheritDoc + */ + public function setInput(InputInterface $input): AdapterInterface + { + throw new RuntimeException('Using setInput() interface is not supported.'); + } + + /** + * @inheritDoc + */ + public function getInput(): ?InputInterface + { + throw new RuntimeException('Using getInput() interface is not supported.'); + } + + /** + * @inheritDoc + */ + public function setOutput(OutputInterface $output): AdapterInterface + { + throw new RuntimeException('Using setInput() method is not supported'); + } + + /** + * @inheritDoc + */ + public function getOutput(): OutputInterface + { + return new OutputAdapter($this->io); + } + + /** + * @inheritDoc + * @return array + */ + public function getVersions(): array + { + $rows = $this->getVersionLog(); + + return array_keys($rows); + } + + /** + * Gets the schema table name. + * + * @return string + */ + public function getSchemaTableName(): string + { + return $this->schemaTableName; + } + + /** + * Sets the schema table name. + * + * @param string $schemaTableName Schema Table Name + * @return $this + */ + public function setSchemaTableName(string $schemaTableName) + { + $this->schemaTableName = $schemaTableName; + + return $this; + } + + /** + * @inheritdoc + */ + public function getColumnForType(string $columnName, string $type, array $options): Column + { + $column = new Column(); + $column->setName($columnName); + $column->setType($type); + $column->setOptions($options); + + return $column; + } + + /** + * @inheritDoc + * @throws \InvalidArgumentException + * @return void + */ + public function createSchemaTable(): void + { + try { + $options = [ + 'id' => false, + 'primary_key' => 'version', + ]; + + $table = new Table($this->getSchemaTableName(), $options, $this); + $table->addColumn('version', 'biginteger', ['null' => false]) + ->addColumn('migration_name', 'string', ['limit' => 100, 'default' => null, 'null' => true]) + ->addColumn('start_time', 'timestamp', ['default' => null, 'null' => true]) + ->addColumn('end_time', 'timestamp', ['default' => null, 'null' => true]) + ->addColumn('breakpoint', 'boolean', ['default' => false, 'null' => false]) + ->save(); + } catch (Exception $exception) { + throw new InvalidArgumentException( + 'There was a problem creating the schema table: ' . $exception->getMessage(), + (int)$exception->getCode(), + $exception + ); + } + } + + /** + * @inheritDoc + */ + public function getAdapterType(): string + { + return $this->getOption('adapter'); + } + + /** + * @inheritDoc + */ + public function isValidColumnType(Column $column): bool + { + return $column->getType() instanceof Literal || in_array($column->getType(), $this->getColumnTypes(), true); + } + + /** + * @inheritDoc + */ + public function setIo(ConsoleIo $io) + { + $this->io = $io; + + return $this; + } + + /** + * @inheritDoc + */ + public function getIo(): ?ConsoleIo + { + return $this->io ?? null; + } + + /** + * Determines if instead of executing queries a dump to standard output is needed + * + * @return bool + */ + public function isDryRunEnabled(): bool + { + return $this->getOption('dryrun') === true; + } + + /** + * Adds user-created tables (e.g. not phinxlog) to a cached list + * + * @param string $tableName The name of the table + * @return void + */ + protected function addCreatedTable(string $tableName): void + { + $tableName = $this->quoteTableName($tableName); + if (substr_compare($tableName, 'phinxlog', -strlen('phinxlog')) !== 0) { + $this->createdTables[] = $tableName; + } + } + + /** + * Updates the name of the cached table + * + * @param string $tableName Original name of the table + * @param string $newTableName New name of the table + * @return void + */ + protected function updateCreatedTableName(string $tableName, string $newTableName): void + { + $tableName = $this->quoteTableName($tableName); + $newTableName = $this->quoteTableName($newTableName); + $key = array_search($tableName, $this->createdTables, true); + if ($key !== false) { + $this->createdTables[$key] = $newTableName; + } + } + + /** + * Removes table from the cached created list + * + * @param string $tableName The name of the table + * @return void + */ + protected function removeCreatedTable(string $tableName): void + { + $tableName = $this->quoteTableName($tableName); + $key = array_search($tableName, $this->createdTables, true); + if ($key !== false) { + unset($this->createdTables[$key]); + } + } + + /** + * Check if the table is in the cached list of created tables + * + * @param string $tableName The name of the table + * @return bool + */ + protected function hasCreatedTable(string $tableName): bool + { + $tableName = $this->quoteTableName($tableName); + + return in_array($tableName, $this->createdTables, true); + } +} diff --git a/src/Db/Adapter/AdapterFactory.php b/src/Db/Adapter/AdapterFactory.php new file mode 100644 index 00000000..f102a7d2 --- /dev/null +++ b/src/Db/Adapter/AdapterFactory.php @@ -0,0 +1,164 @@ + + * @phpstan-var array|\Closure> + * @psalm-var array|\Closure> + */ + 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 \Closure|string $class Class or factory method for the adapter. + * @throws \RuntimeException + * @return $this + */ + public function registerAdapter(string $name, Closure|string $class) + { + if ( + !($class instanceof Closure || 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 instance by name. + * + * @param string $name Name + * @param array $options Options + * @return \Migrations\Db\Adapter\AdapterInterface + */ + public function getAdapter(string $name, array $options): AdapterInterface + { + if (empty($this->adapters[$name])) { + throw new RuntimeException(sprintf( + 'Adapter "%s" has not been registered', + $name + )); + } + $classOrFactory = $this->adapters[$name]; + if ($classOrFactory instanceof Closure) { + return $classOrFactory($options); + } + + return new $classOrFactory($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/AdapterInterface.php b/src/Db/Adapter/AdapterInterface.php new file mode 100644 index 00000000..96d4f538 --- /dev/null +++ b/src/Db/Adapter/AdapterInterface.php @@ -0,0 +1,529 @@ + + */ + public function getVersions(): array; + + /** + * Get all migration log entries, indexed by version creation time and sorted ascendingly by the configuration's + * version order option + * + * @return array + */ + public function getVersionLog(): array; + + /** + * Set adapter configuration options. + * + * @param array $options Options + * @return $this + */ + public function setOptions(array $options); + + /** + * Get all adapter options. + * + * @return array + */ + public function getOptions(): array; + + /** + * Check if an option has been set. + * + * @param string $name Name + * @return bool + */ + public function hasOption(string $name): bool; + + /** + * Get a single adapter option, or null if the option does not exist. + * + * @param string $name Name + * @return mixed + */ + public function getOption(string $name): mixed; + + /** + * Returns a new Migrations\Db\Table\Column using the existent data domain. + * + * @param string $columnName The desired column name + * @param string $type The type for the column. Can be a data domain type. + * @param array $options Options array + * @return \Migrations\Db\Table\Column + */ + public function getColumnForType(string $columnName, string $type, array $options): Column; + + /** + * Records a migration being run. + * + * @param \Phinx\Migration\MigrationInterface $migration Migration + * @param string $direction Direction + * @param string $startTime Start Time + * @param string $endTime End Time + * @return $this + */ + public function migrated(MigrationInterface $migration, string $direction, string $startTime, string $endTime); + + /** + * Toggle a migration breakpoint. + * + * @param \Phinx\Migration\MigrationInterface $migration Migration + * @return $this + */ + public function toggleBreakpoint(MigrationInterface $migration); + + /** + * Reset all migration breakpoints. + * + * @return int The number of breakpoints reset + */ + public function resetAllBreakpoints(): int; + + /** + * Set a migration breakpoint. + * + * @param \Phinx\Migration\MigrationInterface $migration The migration target for the breakpoint set + * @return $this + */ + public function setBreakpoint(MigrationInterface $migration); + + /** + * Unset a migration breakpoint. + * + * @param \Phinx\Migration\MigrationInterface $migration The migration target for the breakpoint unset + * @return $this + */ + public function unsetBreakpoint(MigrationInterface $migration); + + /** + * Creates the schema table. + * + * @return void + */ + public function createSchemaTable(): void; + + /** + * Returns the adapter type. + * + * @return string + */ + public function getAdapterType(): string; + + /** + * Initializes the database connection. + * + * @throws \RuntimeException When the requested database driver is not installed. + * @return void + */ + public function connect(): void; + + /** + * Closes the database connection. + * + * @return void + */ + public function disconnect(): void; + + /** + * Does the adapter support transactions? + * + * @return bool + */ + public function hasTransactions(): bool; + + /** + * Begin a transaction. + * + * @return void + */ + public function beginTransaction(): void; + + /** + * Commit a transaction. + * + * @return void + */ + public function commitTransaction(): void; + + /** + * Rollback a transaction. + * + * @return void + */ + public function rollbackTransaction(): void; + + /** + * Executes a SQL statement and returns the number of affected rows. + * + * @param string $sql SQL + * @param array $params parameters to use for prepared query + * @return int + */ + public function execute(string $sql, array $params = []): int; + + /** + * Executes a list of migration actions for the given table + * + * @param \Migrations\Db\Table\Table $table The table to execute the actions for + * @param \Migrations\Db\Action\Action[] $actions The table to execute the actions for + * @return void + */ + public function executeActions(Table $table, array $actions): void; + + /** + * Returns a new Query object + * + * @deprecated 4.x Use getSelectBuilder, getInsertBuilder, getUpdateBuilder, getDeleteBuilder instead. + * @return \Cake\Database\Query + */ + public function getQueryBuilder(string $type): Query; + + /** + * Return a new SelectQuery object + * + * @return \Cake\Database\Query\SelectQuery + */ + public function getSelectBuilder(): SelectQuery; + + /** + * Return a new InsertQuery object + * + * @return \Cake\Database\Query\InsertQuery + */ + public function getInsertBuilder(): InsertQuery; + + /** + * Return a new UpdateQuery object + * + * @return \Cake\Database\Query\UpdateQuery + */ + public function getUpdateBuilder(): UpdateQuery; + + /** + * Return a new DeleteQuery object + * + * @return \Cake\Database\Query\DeleteQuery + */ + public function getDeleteBuilder(): DeleteQuery; + + /** + * Executes a SQL statement. + * + * The return type depends on the underlying adapter being used. + * + * @param string $sql SQL + * @param array $params parameters to use for prepared query + * @return mixed + */ + public function query(string $sql, array $params = []): mixed; + + /** + * Executes a query and returns only one row as an array. + * + * @param string $sql SQL + * @return array|false + */ + public function fetchRow(string $sql): array|false; + + /** + * Executes a query and returns an array of rows. + * + * @param string $sql SQL + * @return array + */ + public function fetchAll(string $sql): array; + + /** + * Inserts data into a table. + * + * @param \Migrations\Db\Table\Table $table Table where to insert data + * @param array $row Row + * @return void + */ + public function insert(Table $table, array $row): void; + + /** + * Inserts data into a table in a bulk. + * + * @param \Migrations\Db\Table\Table $table Table where to insert data + * @param array $rows Rows + * @return void + */ + public function bulkinsert(Table $table, array $rows): void; + + /** + * Quotes a table name for use in a query. + * + * @param string $tableName Table name + * @return string + */ + public function quoteTableName(string $tableName): string; + + /** + * Quotes a column name for use in a query. + * + * @param string $columnName Table name + * @return string + */ + public function quoteColumnName(string $columnName): string; + + /** + * Checks to see if a table exists. + * + * @param string $tableName Table name + * @return bool + */ + public function hasTable(string $tableName): bool; + + /** + * Creates the specified database table. + * + * @param \Migrations\Db\Table\Table $table Table + * @param \Migrations\Db\Table\Column[] $columns List of columns in the table + * @param \Migrations\Db\Table\Index[] $indexes List of indexes for the table + * @return void + */ + public function createTable(Table $table, array $columns = [], array $indexes = []): void; + + /** + * Truncates the specified table + * + * @param string $tableName Table name + * @return void + */ + public function truncateTable(string $tableName): void; + + /** + * Returns table columns + * + * @param string $tableName Table name + * @return \Migrations\Db\Table\Column[] + */ + public function getColumns(string $tableName): array; + + /** + * Checks to see if a column exists. + * + * @param string $tableName Table name + * @param string $columnName Column name + * @return bool + */ + public function hasColumn(string $tableName, string $columnName): bool; + + /** + * Checks to see if an index exists. + * + * @param string $tableName Table name + * @param string|string[] $columns Column(s) + * @return bool + */ + public function hasIndex(string $tableName, string|array $columns): bool; + + /** + * Checks to see if an index specified by name exists. + * + * @param string $tableName Table name + * @param string $indexName Index name + * @return bool + */ + public function hasIndexByName(string $tableName, string $indexName): bool; + + /** + * Checks to see if the specified primary key exists. + * + * @param string $tableName Table name + * @param string|string[] $columns Column(s) + * @param string|null $constraint Constraint name + * @return bool + */ + public function hasPrimaryKey(string $tableName, string|array $columns, ?string $constraint = null): bool; + + /** + * Checks to see if a foreign key exists. + * + * @param string $tableName Table name + * @param string|string[] $columns Column(s) + * @param string|null $constraint Constraint name + * @return bool + */ + public function hasForeignKey(string $tableName, string|array $columns, ?string $constraint = null): bool; + + /** + * Returns an array of the supported Phinx column types. + * + * @return string[] + */ + public function getColumnTypes(): array; + + /** + * Checks that the given column is of a supported type. + * + * @param \Migrations\Db\Table\Column $column Column + * @return bool + */ + public function isValidColumnType(Column $column): bool; + + /** + * Converts the Phinx logical type to the adapter's SQL type. + * + * @param \Migrations\Db\Literal|string $type Type + * @param int|null $limit Limit + * @return array + */ + public function getSqlType(Literal|string $type, ?int $limit = null): array; + + /** + * Creates a new database. + * + * @param string $name Database Name + * @param array $options Options + * @return void + */ + public function createDatabase(string $name, array $options = []): void; + + /** + * Checks to see if a database exists. + * + * @param string $name Database Name + * @return bool + */ + public function hasDatabase(string $name): bool; + + /** + * Drops the specified database. + * + * @param string $name Database Name + * @return void + */ + public function dropDatabase(string $name): void; + + /** + * Creates the specified schema or throws an exception + * if there is no support for it. + * + * @param string $schemaName Schema Name + * @return void + */ + public function createSchema(string $schemaName = 'public'): void; + + /** + * Drops the specified schema table or throws an exception + * if there is no support for it. + * + * @param string $schemaName Schema name + * @return void + */ + public function dropSchema(string $schemaName): void; + + /** + * Cast a value to a boolean appropriate for the adapter. + * + * @param mixed $value The value to be cast + * @return mixed + */ + public function castToBool(mixed $value): mixed; + + /** + * Sets the consoleio. + * + * @param \Cake\Console\ConsoleIo $io ConsoleIo + * @return $this + */ + public function setIo(ConsoleIo $io); + + /** + * Get the io instance + * + * @return \Cake\Console\ConsoleIo $io The io instance to use + */ + public function getIo(): ?ConsoleIo; + + /** + * Get the Connection for this adapter. + * + * @return \Cake\Database\Connection The connection + */ + public function getConnection(): Connection; +} diff --git a/src/Db/Adapter/AdapterWrapper.php b/src/Db/Adapter/AdapterWrapper.php new file mode 100644 index 00000000..42e4f83e --- /dev/null +++ b/src/Db/Adapter/AdapterWrapper.php @@ -0,0 +1,505 @@ +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 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 \Cake\Database\Connection + */ + public function getConnection(): Connection + { + 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(); + } + + /** + * @inheritDoc + */ + public function setIo(ConsoleIo $io) + { + $this->getAdapter()->setIo($io); + + return $this; + } + + /** + * @inheritDoc + */ + public function getIo(): ?ConsoleIo + { + return $this->getAdapter()->getIo(); + } +} diff --git a/src/Db/Adapter/DirectActionInterface.php b/src/Db/Adapter/DirectActionInterface.php new file mode 100644 index 00000000..67141c3f --- /dev/null +++ b/src/Db/Adapter/DirectActionInterface.php @@ -0,0 +1,140 @@ + true, + self::PHINX_TYPE_TINY_INTEGER => true, + self::PHINX_TYPE_SMALL_INTEGER => true, + self::PHINX_TYPE_MEDIUM_INTEGER => true, + self::PHINX_TYPE_BIG_INTEGER => true, + self::PHINX_TYPE_FLOAT => true, + self::PHINX_TYPE_DECIMAL => true, + self::PHINX_TYPE_DOUBLE => true, + self::PHINX_TYPE_BOOLEAN => true, + ]; + + // These constants roughly correspond to the maximum allowed value for each field, + // except for the `_LONG` and `_BIG` variants, which are maxed at 32-bit + // PHP_INT_MAX value. The `INT_REGULAR` field is just arbitrarily half of INT_BIG + // as its actual value is its regular value is larger than PHP_INT_MAX. We do this + // to keep consistent the type hints for getSqlType and Column::$limit being integers. + public const TEXT_TINY = 255; + public const TEXT_SMALL = 255; /* deprecated, alias of TEXT_TINY */ + public const TEXT_REGULAR = 65535; + public const TEXT_MEDIUM = 16777215; + public const TEXT_LONG = 2147483647; + + // According to https://dev.mysql.com/doc/refman/5.0/en/blob.html BLOB sizes are the same as TEXT + public const BLOB_TINY = 255; + public const BLOB_SMALL = 255; /* deprecated, alias of BLOB_TINY */ + public const BLOB_REGULAR = 65535; + public const BLOB_MEDIUM = 16777215; + public const BLOB_LONG = 2147483647; + + public const INT_TINY = 255; + public const INT_SMALL = 65535; + public const INT_MEDIUM = 16777215; + public const INT_REGULAR = 1073741823; + public const INT_BIG = 2147483647; + + public const INT_DISPLAY_TINY = 4; + public const INT_DISPLAY_SMALL = 6; + public const INT_DISPLAY_MEDIUM = 8; + public const INT_DISPLAY_REGULAR = 11; + public const INT_DISPLAY_BIG = 20; + + public const BIT = 64; + + public const TYPE_YEAR = 'year'; + + public const FIRST = 'FIRST'; + + /** + * {@inheritDoc} + * + * @throws \RuntimeException + * @throws \InvalidArgumentException + * @return void + */ + public function connect(): void + { + $this->getConnection()->getDriver()->connect(); + $this->setConnection($this->getConnection()); + } + + /** + * @inheritDoc + */ + public function setConnection(Connection $connection): AdapterInterface + { + $connection->execute(sprintf('USE %s', $this->getOption('database'))); + + return parent::setConnection($connection); + } + + /** + * @inheritDoc + */ + public function disconnect(): void + { + $this->getConnection()->getDriver()->disconnect(); + } + + /** + * @inheritDoc + */ + public function hasTransactions(): bool + { + return true; + } + + /** + * @inheritDoc + */ + public function beginTransaction(): void + { + $this->getConnection()->begin(); + } + + /** + * @inheritDoc + */ + public function commitTransaction(): void + { + $this->getConnection()->commit(); + } + + /** + * @inheritDoc + */ + public function rollbackTransaction(): void + { + $this->getConnection()->rollback(); + } + + /** + * @inheritDoc + */ + public function quoteTableName(string $tableName): string + { + return str_replace('.', '`.`', $this->quoteColumnName($tableName)); + } + + /** + * @inheritDoc + */ + public function quoteColumnName(string $columnName): string + { + return '`' . str_replace('`', '``', $columnName) . '`'; + } + + /** + * @inheritDoc + */ + public function hasTable(string $tableName): bool + { + if ($this->hasCreatedTable($tableName)) { + return true; + } + + if (strpos($tableName, '.') !== false) { + [$schema, $table] = explode('.', $tableName); + $exists = $this->hasTableWithSchema($schema, $table); + // Only break here on success, because it is possible for table names to contain a dot. + if ($exists) { + return true; + } + } + + $options = $this->getOptions(); + + return $this->hasTableWithSchema($options['database'], $tableName); + } + + /** + * @param string $schema The table schema + * @param string $tableName The table name + * @return bool + */ + protected function hasTableWithSchema(string $schema, string $tableName): bool + { + $connection = $this->getConnection(); + $result = $connection->execute( + "SELECT TABLE_NAME + FROM INFORMATION_SCHEMA.TABLES + WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?", + [$schema, $tableName] + ); + + return $result->rowCount() === 1; + } + + /** + * @inheritDoc + */ + public function createTable(Table $table, array $columns = [], array $indexes = []): void + { + // This method is based on the MySQL docs here: https://dev.mysql.com/doc/refman/5.1/en/create-index.html + $defaultOptions = [ + 'engine' => 'InnoDB', + 'collation' => 'utf8mb4_unicode_ci', + ]; + + $options = array_merge( + $defaultOptions, + array_intersect_key($this->getOptions(), $defaultOptions), + $table->getOptions() + ); + + // Add the default primary key + if (!isset($options['id']) || (isset($options['id']) && $options['id'] === true)) { + $options['id'] = 'id'; + } + + if (isset($options['id']) && is_string($options['id'])) { + $useUnsigned = (bool)Configure::read('Migrations.unsigned_primary_keys'); + // Handle id => "field_name" to support AUTO_INCREMENT + $column = new Column(); + $column->setName($options['id']) + ->setType('integer') + ->setOptions([ + 'signed' => $options['signed'] ?? !$useUnsigned, + 'identity' => true, + ]); + + if (isset($options['limit'])) { + $column->setLimit($options['limit']); + } + + array_unshift($columns, $column); + if (isset($options['primary_key']) && (array)$options['id'] !== (array)$options['primary_key']) { + throw new InvalidArgumentException('You cannot enable an auto incrementing ID field and a primary key'); + } + $options['primary_key'] = $options['id']; + } + + // open: process table options like collation etc + + // process table engine (default to InnoDB) + $optionsStr = 'ENGINE = InnoDB'; + if (isset($options['engine'])) { + $optionsStr = sprintf('ENGINE = %s', $options['engine']); + } + + // process table collation + if (isset($options['collation'])) { + $charset = explode('_', $options['collation']); + $optionsStr .= sprintf(' CHARACTER SET %s', $charset[0]); + $optionsStr .= sprintf(' COLLATE %s', $options['collation']); + } + + // set the table comment + if (isset($options['comment'])) { + $optionsStr .= sprintf(' COMMENT=%s ', $this->quoteString($options['comment'])); + } + + // set the table row format + if (isset($options['row_format'])) { + $optionsStr .= sprintf(' ROW_FORMAT=%s ', $options['row_format']); + } + + $sql = 'CREATE TABLE '; + $sql .= $this->quoteTableName($table->getName()) . ' ('; + foreach ($columns as $column) { + $sql .= $this->quoteColumnName((string)$column->getName()) . ' ' . $this->getColumnSqlDefinition($column) . ', '; + } + + // set the primary key(s) + if (isset($options['primary_key'])) { + /** @var string|array $primaryKey */ + $primaryKey = $options['primary_key']; + $sql = rtrim($sql); + $sql .= ' PRIMARY KEY ('; + if (is_string($primaryKey)) { // handle primary_key => 'id' + $sql .= $this->quoteColumnName($primaryKey); + } elseif (is_array($primaryKey)) { // handle primary_key => array('tag_id', 'resource_id') + $sql .= implode(',', array_map([$this, 'quoteColumnName'], $primaryKey)); + } + $sql .= ')'; + } else { + $sql = substr(rtrim($sql), 0, -1); // no primary keys + } + + // set the indexes + foreach ($indexes as $index) { + $sql .= ', ' . $this->getIndexSqlDefinition($index); + } + + $sql .= ') ' . $optionsStr; + $sql = rtrim($sql); + + // execute the sql + $this->execute($sql); + + $this->addCreatedTable($table->getName()); + } + + /** + * {@inheritDoc} + * + * @throws \InvalidArgumentException + */ + protected function getChangePrimaryKeyInstructions(Table $table, $newColumns): AlterInstructions + { + $instructions = new AlterInstructions(); + + // Drop the existing primary key + $primaryKey = $this->getPrimaryKey($table->getName()); + if (!empty($primaryKey['columns'])) { + $instructions->addAlter('DROP PRIMARY KEY'); + } + + // Add the primary key(s) + if (!empty($newColumns)) { + $sql = 'ADD PRIMARY KEY ('; + if (is_string($newColumns)) { // handle primary_key => 'id' + $sql .= $this->quoteColumnName($newColumns); + } elseif (is_array($newColumns)) { // handle primary_key => array('tag_id', 'resource_id') + $sql .= implode(',', array_map([$this, 'quoteColumnName'], $newColumns)); + } else { + throw new InvalidArgumentException(sprintf( + 'Invalid value for primary key: %s', + json_encode($newColumns) + )); + } + $sql .= ')'; + $instructions->addAlter($sql); + } + + return $instructions; + } + + /** + * @inheritDoc + */ + protected function getChangeCommentInstructions(Table $table, ?string $newComment): AlterInstructions + { + $instructions = new AlterInstructions(); + + // passing 'null' is to remove table comment + $newComment = $newComment ?? ''; + $sql = sprintf(' COMMENT=%s ', $this->quoteString($newComment)); + $instructions->addAlter($sql); + + return $instructions; + } + + /** + * @inheritDoc + */ + protected function getRenameTableInstructions(string $tableName, string $newTableName): AlterInstructions + { + $this->updateCreatedTableName($tableName, $newTableName); + $sql = sprintf( + 'RENAME TABLE %s TO %s', + $this->quoteTableName($tableName), + $this->quoteTableName($newTableName) + ); + + return new AlterInstructions([], [$sql]); + } + + /** + * @inheritDoc + */ + protected function getDropTableInstructions(string $tableName): AlterInstructions + { + $this->removeCreatedTable($tableName); + $sql = sprintf('DROP TABLE %s', $this->quoteTableName($tableName)); + + return new AlterInstructions([], [$sql]); + } + + /** + * @inheritDoc + */ + public function truncateTable(string $tableName): void + { + $sql = sprintf( + 'TRUNCATE TABLE %s', + $this->quoteTableName($tableName) + ); + + $this->execute($sql); + } + + /** + * @inheritDoc + */ + public function getColumns(string $tableName): array + { + $columns = []; + $rows = $this->fetchAll(sprintf('SHOW FULL COLUMNS FROM %s', $this->quoteTableName($tableName))); + foreach ($rows as $columnInfo) { + $phinxType = $this->getPhinxType($columnInfo['Type']); + + $column = new Column(); + $column->setName($columnInfo['Field']) + ->setNull($columnInfo['Null'] !== 'NO') + ->setType($phinxType['name']) + ->setSigned(strpos($columnInfo['Type'], 'unsigned') === false) + ->setLimit($phinxType['limit']) + ->setScale($phinxType['scale']) + ->setComment($columnInfo['Comment']); + + if ($columnInfo['Extra'] === 'auto_increment') { + $column->setIdentity(true); + } + + if (isset($phinxType['values'])) { + $column->setValues($phinxType['values']); + } + + $default = $columnInfo['Default']; + if ( + is_string($default) && + in_array( + $column->getType(), + array_merge( + static::PHINX_TYPES_GEOSPATIAL, + [static::PHINX_TYPE_BLOB, static::PHINX_TYPE_JSON, static::PHINX_TYPE_TEXT] + ) + ) + ) { + // The default that comes back from MySQL for these types prefixes the collation type and + // surrounds the value with escaped single quotes, for example "_utf8mbf4\'abc\'", and so + // this converts that then down to the default value of "abc" to correspond to what the user + // would have specified in a migration. + $default = preg_replace("/^_(?:[a-zA-Z0-9]+?)\\\'(.*)\\\'$/", '\1', $default); + } + $column->setDefault($default); + + $columns[] = $column; + } + + return $columns; + } + + /** + * @inheritDoc + */ + public function hasColumn(string $tableName, string $columnName): bool + { + $rows = $this->fetchAll(sprintf('SHOW COLUMNS FROM %s', $this->quoteTableName($tableName))); + foreach ($rows as $column) { + if (strcasecmp($column['Field'], $columnName) === 0) { + return true; + } + } + + return false; + } + + /** + * @inheritDoc + */ + protected function getAddColumnInstructions(Table $table, Column $column): AlterInstructions + { + $alter = sprintf( + 'ADD %s %s', + $this->quoteColumnName((string)$column->getName()), + $this->getColumnSqlDefinition($column) + ); + + $alter .= $this->afterClause($column); + + return new AlterInstructions([$alter]); + } + + /** + * Exposes the MySQL syntax to arrange a column `FIRST`. + * + * @param \Migrations\Db\Table\Column $column The column being altered. + * @return string The appropriate SQL fragment. + */ + protected function afterClause(Column $column): string + { + $after = $column->getAfter(); + if (empty($after)) { + return ''; + } + + if ($after === self::FIRST) { + return ' FIRST'; + } + + return ' AFTER ' . $this->quoteColumnName($after); + } + + /** + * {@inheritDoc} + * + * @throws \InvalidArgumentException + */ + protected function getRenameColumnInstructions(string $tableName, string $columnName, string $newColumnName): AlterInstructions + { + $rows = $this->fetchAll(sprintf('SHOW FULL COLUMNS FROM %s', $this->quoteTableName($tableName))); + + foreach ($rows as $row) { + if (strcasecmp($row['Field'], $columnName) === 0) { + $null = $row['Null'] === 'NO' ? 'NOT NULL' : 'NULL'; + $comment = isset($row['Comment']) ? ' COMMENT ' . '\'' . addslashes($row['Comment']) . '\'' : ''; + $extra = ' ' . strtoupper($row['Extra']); + if (($row['Default'] !== null)) { + $extra .= $this->getDefaultValueDefinition($row['Default']); + } + $definition = $row['Type'] . ' ' . $null . $extra . $comment; + + $alter = sprintf( + 'CHANGE COLUMN %s %s %s', + $this->quoteColumnName($columnName), + $this->quoteColumnName($newColumnName), + $definition + ); + + return new AlterInstructions([$alter]); + } + } + + throw new InvalidArgumentException(sprintf( + "The specified column doesn't exist: %s", + $columnName + )); + } + + /** + * @inheritDoc + */ + protected function getChangeColumnInstructions(string $tableName, string $columnName, Column $newColumn): AlterInstructions + { + $alter = sprintf( + 'CHANGE %s %s %s%s', + $this->quoteColumnName($columnName), + $this->quoteColumnName((string)$newColumn->getName()), + $this->getColumnSqlDefinition($newColumn), + $this->afterClause($newColumn) + ); + + return new AlterInstructions([$alter]); + } + + /** + * @inheritDoc + */ + protected function getDropColumnInstructions(string $tableName, string $columnName): AlterInstructions + { + $alter = sprintf('DROP COLUMN %s', $this->quoteColumnName($columnName)); + + return new AlterInstructions([$alter]); + } + + /** + * Get an array of indexes from a particular table. + * + * @param string $tableName Table name + * @return array + */ + protected function getIndexes(string $tableName): array + { + $indexes = []; + $rows = $this->fetchAll(sprintf('SHOW INDEXES FROM %s', $this->quoteTableName($tableName))); + foreach ($rows as $row) { + if (!isset($indexes[$row['Key_name']])) { + $indexes[$row['Key_name']] = ['columns' => []]; + } + $indexes[$row['Key_name']]['columns'][] = strtolower($row['Column_name']); + } + + return $indexes; + } + + /** + * @inheritDoc + */ + public function hasIndex(string $tableName, string|array $columns): bool + { + if (is_string($columns)) { + $columns = [$columns]; // str to array + } + + $columns = array_map('strtolower', $columns); + $indexes = $this->getIndexes($tableName); + + foreach ($indexes as $index) { + if ($columns == $index['columns']) { + return true; + } + } + + return false; + } + + /** + * @inheritDoc + */ + public function hasIndexByName(string $tableName, string $indexName): bool + { + $indexes = $this->getIndexes($tableName); + + foreach ($indexes as $name => $index) { + if ($name === $indexName) { + return true; + } + } + + return false; + } + + /** + * @inheritDoc + */ + protected function getAddIndexInstructions(Table $table, Index $index): AlterInstructions + { + $instructions = new AlterInstructions(); + + if ($index->getType() === Index::FULLTEXT) { + // Must be executed separately + // SQLSTATE[HY000]: General error: 1795 InnoDB presently supports one FULLTEXT index creation at a time + $alter = sprintf( + 'ALTER TABLE %s ADD %s', + $this->quoteTableName($table->getName()), + $this->getIndexSqlDefinition($index) + ); + + $instructions->addPostStep($alter); + } else { + $alter = sprintf( + 'ADD %s', + $this->getIndexSqlDefinition($index) + ); + + $instructions->addAlter($alter); + } + + return $instructions; + } + + /** + * {@inheritDoc} + * + * @throws \InvalidArgumentException + */ + protected function getDropIndexByColumnsInstructions(string $tableName, $columns): AlterInstructions + { + if (is_string($columns)) { + $columns = [$columns]; // str to array + } + + $indexes = $this->getIndexes($tableName); + $columns = array_map('strtolower', $columns); + + foreach ($indexes as $indexName => $index) { + if ($columns == $index['columns']) { + return new AlterInstructions([sprintf( + 'DROP INDEX %s', + $this->quoteColumnName($indexName) + )]); + } + } + + throw new InvalidArgumentException(sprintf( + "The specified index on columns '%s' does not exist", + implode(',', $columns) + )); + } + + /** + * {@inheritDoc} + * + * @throws \InvalidArgumentException + */ + protected function getDropIndexByNameInstructions(string $tableName, $indexName): AlterInstructions + { + $indexes = $this->getIndexes($tableName); + + foreach ($indexes as $name => $index) { + if ($name === $indexName) { + return new AlterInstructions([sprintf( + 'DROP INDEX %s', + $this->quoteColumnName($indexName) + )]); + } + } + + throw new InvalidArgumentException(sprintf( + "The specified index name '%s' does not exist", + $indexName + )); + } + + /** + * @inheritDoc + */ + public function hasPrimaryKey(string $tableName, $columns, ?string $constraint = null): bool + { + $primaryKey = $this->getPrimaryKey($tableName); + + if (empty($primaryKey['constraint'])) { + return false; + } + + if ($constraint) { + return $primaryKey['constraint'] === $constraint; + } else { + if (is_string($columns)) { + $columns = [$columns]; // str to array + } + $missingColumns = array_diff($columns, $primaryKey['columns']); + + return empty($missingColumns); + } + } + + /** + * Get the primary key from a particular table. + * + * @param string $tableName Table name + * @return array + */ + public function getPrimaryKey(string $tableName): array + { + $options = $this->getOptions(); + $rows = $this->fetchAll(sprintf( + "SELECT + k.CONSTRAINT_NAME, + k.COLUMN_NAME + FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS t + JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE k + USING(CONSTRAINT_NAME,TABLE_SCHEMA,TABLE_NAME) + WHERE t.CONSTRAINT_TYPE='PRIMARY KEY' + AND t.TABLE_SCHEMA='%s' + AND t.TABLE_NAME='%s'", + $options['database'], + $tableName + )); + + $primaryKey = [ + 'columns' => [], + ]; + foreach ($rows as $row) { + $primaryKey['constraint'] = $row['CONSTRAINT_NAME']; + $primaryKey['columns'][] = $row['COLUMN_NAME']; + } + + return $primaryKey; + } + + /** + * @inheritDoc + */ + public function hasForeignKey(string $tableName, $columns, ?string $constraint = null): bool + { + $foreignKeys = $this->getForeignKeys($tableName); + if ($constraint) { + if (isset($foreignKeys[$constraint])) { + return !empty($foreignKeys[$constraint]); + } + + return false; + } + + $columns = array_map('mb_strtolower', (array)$columns); + + foreach ($foreignKeys as $key) { + if (array_map('mb_strtolower', $key['columns']) === $columns) { + return true; + } + } + + return false; + } + + /** + * Get an array of foreign keys from a particular table. + * + * @param string $tableName Table name + * @return array + */ + protected function getForeignKeys(string $tableName): array + { + if (strpos($tableName, '.') !== false) { + [$schema, $tableName] = explode('.', $tableName); + } + + $foreignKeys = []; + $rows = $this->fetchAll(sprintf( + "SELECT + CONSTRAINT_NAME, + CONCAT(TABLE_SCHEMA, '.', TABLE_NAME) AS TABLE_NAME, + COLUMN_NAME, + CONCAT(REFERENCED_TABLE_SCHEMA, '.', REFERENCED_TABLE_NAME) AS REFERENCED_TABLE_NAME, + REFERENCED_COLUMN_NAME + FROM information_schema.KEY_COLUMN_USAGE + WHERE REFERENCED_TABLE_NAME IS NOT NULL + AND TABLE_SCHEMA = %s + AND TABLE_NAME = '%s' + ORDER BY POSITION_IN_UNIQUE_CONSTRAINT", + empty($schema) ? 'DATABASE()' : "'$schema'", + $tableName + )); + foreach ($rows as $row) { + $foreignKeys[$row['CONSTRAINT_NAME']]['table'] = $row['TABLE_NAME']; + $foreignKeys[$row['CONSTRAINT_NAME']]['columns'][] = $row['COLUMN_NAME']; + $foreignKeys[$row['CONSTRAINT_NAME']]['referenced_table'] = $row['REFERENCED_TABLE_NAME']; + $foreignKeys[$row['CONSTRAINT_NAME']]['referenced_columns'][] = $row['REFERENCED_COLUMN_NAME']; + } + + return $foreignKeys; + } + + /** + * @inheritDoc + */ + protected function getAddForeignKeyInstructions(Table $table, ForeignKey $foreignKey): AlterInstructions + { + $alter = sprintf( + 'ADD %s', + $this->getForeignKeySqlDefinition($foreignKey) + ); + + return new AlterInstructions([$alter]); + } + + /** + * @inheritDoc + */ + protected function getDropForeignKeyInstructions(string $tableName, string $constraint): AlterInstructions + { + $alter = sprintf( + 'DROP FOREIGN KEY %s', + $constraint + ); + + return new AlterInstructions([$alter]); + } + + /** + * {@inheritDoc} + * + * @throws \InvalidArgumentException + */ + protected function getDropForeignKeyByColumnsInstructions(string $tableName, array $columns): AlterInstructions + { + $instructions = new AlterInstructions(); + + $columns = array_map('mb_strtolower', $columns); + + $matches = []; + $foreignKeys = $this->getForeignKeys($tableName); + foreach ($foreignKeys as $name => $key) { + if (array_map('mb_strtolower', $key['columns']) === $columns) { + $matches[] = $name; + } + } + + if (empty($matches)) { + throw new InvalidArgumentException(sprintf( + 'No foreign key on column(s) `%s` exists', + implode(', ', $columns) + )); + } + + foreach ($matches as $name) { + $instructions->merge( + $this->getDropForeignKeyInstructions($tableName, $name) + ); + } + + return $instructions; + } + + /** + * {@inheritDoc} + * + * @throws \Migrations\Db\Adapter\UnsupportedColumnTypeException + */ + public function getSqlType(Literal|string $type, ?int $limit = null): array + { + $type = (string)$type; + switch ($type) { + case static::PHINX_TYPE_FLOAT: + case static::PHINX_TYPE_DOUBLE: + case static::PHINX_TYPE_DECIMAL: + case static::PHINX_TYPE_DATE: + case static::PHINX_TYPE_ENUM: + case static::PHINX_TYPE_SET: + case static::PHINX_TYPE_JSON: + // Geospatial database types + case static::PHINX_TYPE_GEOMETRY: + case static::PHINX_TYPE_POINT: + case static::PHINX_TYPE_LINESTRING: + case static::PHINX_TYPE_POLYGON: + return ['name' => $type]; + case static::PHINX_TYPE_DATETIME: + case static::PHINX_TYPE_TIMESTAMP: + case static::PHINX_TYPE_TIME: + return ['name' => $type, 'limit' => $limit]; + case static::PHINX_TYPE_STRING: + return ['name' => 'varchar', 'limit' => $limit ?: 255]; + case static::PHINX_TYPE_CHAR: + return ['name' => 'char', 'limit' => $limit ?: 255]; + case static::PHINX_TYPE_TEXT: + if ($limit) { + $sizes = [ + // Order matters! Size must always be tested from longest to shortest! + 'longtext' => static::TEXT_LONG, + 'mediumtext' => static::TEXT_MEDIUM, + 'text' => static::TEXT_REGULAR, + 'tinytext' => static::TEXT_SMALL, + ]; + foreach ($sizes as $name => $length) { + if ($limit >= $length) { + return ['name' => $name]; + } + } + } + + return ['name' => 'text']; + case static::PHINX_TYPE_BINARY: + if ($limit === null) { + $limit = 255; + } + + if ($limit > 255) { + return $this->getSqlType(static::PHINX_TYPE_BLOB, $limit); + } + + return ['name' => 'binary', 'limit' => $limit]; + case static::PHINX_TYPE_BINARYUUID: + return ['name' => 'binary', 'limit' => 16]; + case static::PHINX_TYPE_VARBINARY: + if ($limit === null) { + $limit = 255; + } + + if ($limit > 255) { + return $this->getSqlType(static::PHINX_TYPE_BLOB, $limit); + } + + return ['name' => 'varbinary', 'limit' => $limit]; + case static::PHINX_TYPE_BLOB: + if ($limit !== null) { + // Rework this part as the choosen types were always UNDER the required length + $sizes = [ + 'tinyblob' => static::BLOB_SMALL, + 'blob' => static::BLOB_REGULAR, + 'mediumblob' => static::BLOB_MEDIUM, + ]; + + foreach ($sizes as $name => $length) { + if ($limit <= $length) { + return ['name' => $name]; + } + } + + // For more length requirement, the longblob is used + return ['name' => 'longblob']; + } + + // If not limit is provided, fallback on blob + return ['name' => 'blob']; + case static::PHINX_TYPE_TINYBLOB: + // Automatically reprocess blob type to ensure that correct blob subtype is selected given provided limit + return $this->getSqlType(static::PHINX_TYPE_BLOB, $limit ?: static::BLOB_TINY); + case static::PHINX_TYPE_MEDIUMBLOB: + // Automatically reprocess blob type to ensure that correct blob subtype is selected given provided limit + return $this->getSqlType(static::PHINX_TYPE_BLOB, $limit ?: static::BLOB_MEDIUM); + case static::PHINX_TYPE_LONGBLOB: + // Automatically reprocess blob type to ensure that correct blob subtype is selected given provided limit + return $this->getSqlType(static::PHINX_TYPE_BLOB, $limit ?: static::BLOB_LONG); + case static::PHINX_TYPE_BIT: + return ['name' => 'bit', 'limit' => $limit ?: 64]; + case static::PHINX_TYPE_BIG_INTEGER: + if ($limit === static::INT_BIG) { + $limit = static::INT_DISPLAY_BIG; + } + + return ['name' => 'bigint', 'limit' => $limit ?: 20]; + case static::PHINX_TYPE_MEDIUM_INTEGER: + if ($limit === static::INT_MEDIUM) { + $limit = static::INT_DISPLAY_MEDIUM; + } + + return ['name' => 'mediumint', 'limit' => $limit ?: 8]; + case static::PHINX_TYPE_SMALL_INTEGER: + if ($limit === static::INT_SMALL) { + $limit = static::INT_DISPLAY_SMALL; + } + + return ['name' => 'smallint', 'limit' => $limit ?: 6]; + case static::PHINX_TYPE_TINY_INTEGER: + if ($limit === static::INT_TINY) { + $limit = static::INT_DISPLAY_TINY; + } + + return ['name' => 'tinyint', 'limit' => $limit ?: 4]; + case static::PHINX_TYPE_INTEGER: + if ($limit && $limit >= static::INT_TINY) { + $sizes = [ + // Order matters! Size must always be tested from longest to shortest! + 'bigint' => static::INT_BIG, + 'int' => static::INT_REGULAR, + 'mediumint' => static::INT_MEDIUM, + 'smallint' => static::INT_SMALL, + 'tinyint' => static::INT_TINY, + ]; + $limits = [ + 'tinyint' => static::INT_DISPLAY_TINY, + 'smallint' => static::INT_DISPLAY_SMALL, + 'mediumint' => static::INT_DISPLAY_MEDIUM, + 'int' => static::INT_DISPLAY_REGULAR, + 'bigint' => static::INT_DISPLAY_BIG, + ]; + foreach ($sizes as $name => $length) { + if ($limit >= $length) { + $def = ['name' => $name]; + if (isset($limits[$name])) { + $def['limit'] = $limits[$name]; + } + + return $def; + } + } + } elseif (!$limit) { + $limit = static::INT_DISPLAY_REGULAR; + } + + return ['name' => 'int', 'limit' => $limit]; + case static::PHINX_TYPE_BOOLEAN: + return ['name' => 'tinyint', 'limit' => 1]; + case static::PHINX_TYPE_UUID: + return ['name' => 'char', 'limit' => 36]; + case static::PHINX_TYPE_YEAR: + if (!$limit || in_array($limit, [2, 4])) { + $limit = 4; + } + + return ['name' => 'year', 'limit' => $limit]; + default: + throw new UnsupportedColumnTypeException('Column type "' . $type . '" is not supported by MySQL.'); + } + } + + /** + * Returns Phinx type by SQL type + * + * @internal param string $sqlType SQL type + * @param string $sqlTypeDef SQL Type definition + * @throws \Migrations\Db\Adapter\UnsupportedColumnTypeException + * @return array Phinx type + */ + public function getPhinxType(string $sqlTypeDef): array + { + $matches = []; + if (!preg_match('/^([\w]+)(\(([\d]+)*(,([\d]+))*\))*(.+)*$/', $sqlTypeDef, $matches)) { + throw new UnsupportedColumnTypeException('Column type "' . $sqlTypeDef . '" is not supported by MySQL.'); + } + + $limit = null; + $scale = null; + $type = $matches[1]; + if (count($matches) > 2) { + $limit = $matches[3] ? (int)$matches[3] : null; + } + if (count($matches) > 4) { + $scale = (int)$matches[5]; + } + if ($type === 'tinyint' && $limit === 1) { + $type = static::PHINX_TYPE_BOOLEAN; + $limit = null; + } + switch ($type) { + case 'varchar': + $type = static::PHINX_TYPE_STRING; + if ($limit === 255) { + $limit = null; + } + break; + case 'char': + $type = static::PHINX_TYPE_CHAR; + if ($limit === 255) { + $limit = null; + } + if ($limit === 36) { + $type = static::PHINX_TYPE_UUID; + } + break; + case 'tinyint': + $type = static::PHINX_TYPE_TINY_INTEGER; + break; + case 'smallint': + $type = static::PHINX_TYPE_SMALL_INTEGER; + break; + case 'mediumint': + $type = static::PHINX_TYPE_MEDIUM_INTEGER; + break; + case 'int': + $type = static::PHINX_TYPE_INTEGER; + break; + case 'bigint': + $type = static::PHINX_TYPE_BIG_INTEGER; + break; + case 'bit': + $type = static::PHINX_TYPE_BIT; + if ($limit === 64) { + $limit = null; + } + break; + case 'blob': + $type = static::PHINX_TYPE_BLOB; + $limit = static::BLOB_REGULAR; + break; + case 'tinyblob': + $type = static::PHINX_TYPE_TINYBLOB; + $limit = static::BLOB_TINY; + break; + case 'mediumblob': + $type = static::PHINX_TYPE_MEDIUMBLOB; + $limit = static::BLOB_MEDIUM; + break; + case 'longblob': + $type = static::PHINX_TYPE_LONGBLOB; + $limit = static::BLOB_LONG; + break; + case 'tinytext': + $type = static::PHINX_TYPE_TEXT; + $limit = static::TEXT_TINY; + break; + case 'mediumtext': + $type = static::PHINX_TYPE_TEXT; + $limit = static::TEXT_MEDIUM; + break; + case 'longtext': + $type = static::PHINX_TYPE_TEXT; + $limit = static::TEXT_LONG; + break; + case 'binary': + if ($limit === null) { + $limit = 255; + } + + if ($limit > 255) { + $type = static::PHINX_TYPE_BLOB; + break; + } + + if ($limit === 16) { + $type = static::PHINX_TYPE_BINARYUUID; + } + break; + } + + try { + // Call this to check if parsed type is supported. + $this->getSqlType($type, $limit); + } catch (UnsupportedColumnTypeException $e) { + $type = Literal::from($type); + } + + $phinxType = [ + 'name' => $type, + 'limit' => $limit, + 'scale' => $scale, + ]; + + if ($type === static::PHINX_TYPE_ENUM || $type === static::PHINX_TYPE_SET) { + $values = trim($matches[6], '()'); + $phinxType['values'] = []; + $opened = false; + $escaped = false; + $wasEscaped = false; + $value = ''; + $valuesLength = strlen($values); + for ($i = 0; $i < $valuesLength; $i++) { + $char = $values[$i]; + if ($char === "'" && !$opened) { + $opened = true; + } elseif ( + !$escaped + && ($i + 1) < $valuesLength + && ( + $char === "'" && $values[$i + 1] === "'" + || $char === '\\' && $values[$i + 1] === '\\' + ) + ) { + $escaped = true; + } elseif ($char === "'" && $opened && !$escaped) { + $phinxType['values'][] = $value; + $value = ''; + $opened = false; + } elseif (($char === "'" || $char === '\\') && $opened && $escaped) { + $value .= $char; + $escaped = false; + $wasEscaped = true; + } elseif ($opened) { + if ($values[$i - 1] === '\\' && !$wasEscaped) { + if ($char === 'n') { + $char = "\n"; + } elseif ($char === 'r') { + $char = "\r"; + } elseif ($char === 't') { + $char = "\t"; + } + if ($values[$i] !== $char) { + $value = substr($value, 0, strlen($value) - 1); + } + } + $value .= $char; + $wasEscaped = false; + } + } + } + + return $phinxType; + } + + /** + * @inheritDoc + */ + public function createDatabase(string $name, array $options = []): void + { + $charset = $options['charset'] ?? 'utf8'; + + if (isset($options['collation'])) { + $this->execute(sprintf( + 'CREATE DATABASE `%s` DEFAULT CHARACTER SET `%s` COLLATE `%s`', + $name, + $charset, + $options['collation'] + )); + } else { + $this->execute(sprintf('CREATE DATABASE `%s` DEFAULT CHARACTER SET `%s`', $name, $charset)); + } + $this->execute(sprintf('USE %s', $name)); + } + + /** + * @inheritDoc + */ + public function hasDatabase(string $name): bool + { + $rows = $this->fetchAll( + sprintf( + 'SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME = \'%s\'', + $name + ) + ); + + foreach ($rows as $row) { + if (!empty($row)) { + return true; + } + } + + return false; + } + + /** + * @inheritDoc + */ + public function dropDatabase(string $name): void + { + $this->execute(sprintf('DROP DATABASE IF EXISTS `%s`', $name)); + $this->createdTables = []; + } + + /** + * Gets the MySQL Column Definition for a Column object. + * + * @param \Migrations\Db\Table\Column $column Column + * @return string + */ + protected function getColumnSqlDefinition(Column $column): string + { + if ($column->getType() instanceof Literal) { + $def = (string)$column->getType(); + } else { + $sqlType = $this->getSqlType($column->getType(), $column->getLimit()); + $def = strtoupper($sqlType['name']); + } + if ($column->getPrecision() && $column->getScale()) { + $def .= '(' . $column->getPrecision() . ',' . $column->getScale() . ')'; + } elseif (isset($sqlType['limit'])) { + $def .= '(' . $sqlType['limit'] . ')'; + } + + $values = $column->getValues(); + if ($values) { + $def .= '(' . implode(', ', array_map(function ($value) { + // we special case NULL as it's not actually allowed an enum value, + // and we want MySQL to issue an error on the create statement, but + // quote coerces it to an empty string, which will not error + return $value === null ? 'NULL' : $this->quoteString($value); + }, $values)) . ')'; + } + + $def .= $column->getEncoding() ? ' CHARACTER SET ' . $column->getEncoding() : ''; + $def .= $column->getCollation() ? ' COLLATE ' . $column->getCollation() : ''; + $def .= !$column->isSigned() && isset($this->signedColumnTypes[(string)$column->getType()]) ? ' unsigned' : ''; + $def .= $column->isNull() ? ' NULL' : ' NOT NULL'; + + $connection = $this->getConnection(); + $version = $connection->getDriver()->version(); + if ( + version_compare($version, '8', '>=') + && in_array($column->getType(), static::PHINX_TYPES_GEOSPATIAL) + && !is_null($column->getSrid()) + ) { + $def .= " SRID {$column->getSrid()}"; + } + + $def .= $column->isIdentity() ? ' AUTO_INCREMENT' : ''; + + $default = $column->getDefault(); + // MySQL 8 supports setting default for the following tested types, but only if they are "cast as expressions" + if ( + version_compare($version, '8', '>=') && + is_string($default) && + in_array( + $column->getType(), + array_merge( + static::PHINX_TYPES_GEOSPATIAL, + [static::PHINX_TYPE_BLOB, static::PHINX_TYPE_JSON, static::PHINX_TYPE_TEXT] + ) + ) + ) { + $default = Literal::from('(' . $this->quoteString($column->getDefault()) . ')'); + } + $def .= $this->getDefaultValueDefinition($default, (string)$column->getType()); + + if ($column->getComment()) { + $def .= ' COMMENT ' . $this->quoteString((string)$column->getComment()); + } + + if ($column->getUpdate()) { + $def .= ' ON UPDATE ' . $column->getUpdate(); + } + + return $def; + } + + /** + * Gets the MySQL Index Definition for an Index object. + * + * @param \Migrations\Db\Table\Index $index Index + * @return string + */ + protected function getIndexSqlDefinition(Index $index): string + { + $def = ''; + $limit = ''; + + if ($index->getType() === Index::UNIQUE) { + $def .= ' UNIQUE'; + } + + if ($index->getType() === Index::FULLTEXT) { + $def .= ' FULLTEXT'; + } + + $def .= ' KEY'; + + if (is_string($index->getName())) { + $def .= ' `' . $index->getName() . '`'; + } + + $columnNames = (array)$index->getColumns(); + $order = $index->getOrder() ?? []; + $columnNames = array_map(function ($columnName) use ($order) { + $ret = '`' . $columnName . '`'; + if (isset($order[$columnName])) { + $ret .= ' ' . $order[$columnName]; + } + + return $ret; + }, $columnNames); + + if (!is_array($index->getLimit())) { + if ($index->getLimit()) { + $limit = '(' . $index->getLimit() . ')'; + } + $def .= ' (' . implode(',', $columnNames) . $limit . ')'; + } else { + $columns = (array)$index->getColumns(); + $limits = $index->getLimit(); + $def .= ' ('; + foreach ($columns as $column) { + $limit = !isset($limits[$column]) || $limits[$column] <= 0 ? '' : '(' . $limits[$column] . ')'; + $columnSort = $order[$column] ?? ''; + $def .= '`' . $column . '`' . $limit . ' ' . $columnSort . ', '; + } + $def = rtrim($def, ', '); + $def .= ' )'; + } + + return $def; + } + + /** + * Gets the MySQL Foreign Key Definition for an ForeignKey object. + * + * @param \Migrations\Db\Table\ForeignKey $foreignKey Foreign key + * @return string + */ + protected function getForeignKeySqlDefinition(ForeignKey $foreignKey): string + { + $def = ''; + if ($foreignKey->getConstraint()) { + $def .= ' CONSTRAINT ' . $this->quoteColumnName((string)$foreignKey->getConstraint()); + } + $columnNames = []; + foreach ($foreignKey->getColumns() as $column) { + $columnNames[] = $this->quoteColumnName($column); + } + $def .= ' FOREIGN KEY (' . implode(',', $columnNames) . ')'; + $refColumnNames = []; + foreach ($foreignKey->getReferencedColumns() as $column) { + $refColumnNames[] = $this->quoteColumnName($column); + } + $def .= ' REFERENCES ' . $this->quoteTableName($foreignKey->getReferencedTable()->getName()) . ' (' . implode(',', $refColumnNames) . ')'; + if ($foreignKey->getOnDelete()) { + $def .= ' ON DELETE ' . $foreignKey->getOnDelete(); + } + if ($foreignKey->getOnUpdate()) { + $def .= ' ON UPDATE ' . $foreignKey->getOnUpdate(); + } + + return $def; + } + + /** + * Describes a database table. This is a MySQL adapter specific method. + * + * @param string $tableName Table name + * @return array + */ + public function describeTable(string $tableName): array + { + $options = $this->getOptions(); + + // mysql specific + $sql = sprintf( + "SELECT * + FROM information_schema.tables + WHERE table_schema = '%s' + AND table_name = '%s'", + $options['database'], + $tableName + ); + + $table = $this->fetchRow($sql); + + return $table !== false ? $table : []; + } + + /** + * Returns MySQL column types (inherited and MySQL specified). + * + * @return string[] + */ + public function getColumnTypes(): array + { + return array_merge(parent::getColumnTypes(), static::$specificColumnTypes); + } +} diff --git a/src/Db/Adapter/PdoAdapter.php b/src/Db/Adapter/PdoAdapter.php new file mode 100644 index 00000000..eab82677 --- /dev/null +++ b/src/Db/Adapter/PdoAdapter.php @@ -0,0 +1,1036 @@ +getIo(); + if ( + $io === null || ( + !$this->isDryRunEnabled() && + $io->level() != ConsoleIo::VERBOSE + ) + ) { + return; + } + + $io->out($message); + } + + /** + * Create PDO connection + * + * @param string $dsn Connection string + * @param string|null $username Database username + * @param string|null $password Database password + * @param array $options Connection options + * @return \PDO + */ + protected function createPdoConnection(string $dsn, ?string $username = null, ?string $password = null, array $options = []): PDO + { + $adapterOptions = $this->getOptions() + [ + 'attr_errmode' => PDO::ERRMODE_EXCEPTION, + ]; + + try { + $db = new PDO($dsn, $username, $password, $options); + + foreach ($adapterOptions as $key => $option) { + if (strpos($key, 'attr_') === 0) { + $pdoConstant = '\PDO::' . strtoupper($key); + if (!defined($pdoConstant)) { + throw new UnexpectedValueException('Invalid PDO attribute: ' . $key . ' (' . $pdoConstant . ')'); + } + $db->setAttribute(constant($pdoConstant), $option); + } + } + } catch (PDOException $e) { + throw new InvalidArgumentException(sprintf( + 'There was a problem connecting to the database: %s', + $e->getMessage() + ), 0, $e); + } + + return $db; + } + + /** + * @inheritDoc + */ + public function setOptions(array $options): AdapterInterface + { + parent::setOptions($options); + + if (isset($options['connection']) && $options['connection'] instanceof Connection) { + $this->setConnection($options['connection']); + } + + return $this; + } + + /** + * Sets the database connection. + * + * @param \Cake\Database\Connection $connection Connection + * @return \Migrations\Db\Adapter\AdapterInterface + */ + public function setConnection(Connection $connection): AdapterInterface + { + $this->connection = $connection; + + // Create the schema table if it doesn't already exist + if (!$this->hasTable($this->getSchemaTableName())) { + $this->createSchemaTable(); + } else { + $table = new DbTable($this->getSchemaTableName(), [], $this); + if (!$table->hasColumn('migration_name')) { + $table + ->addColumn( + 'migration_name', + 'string', + ['limit' => 100, 'after' => 'version', 'default' => null, 'null' => true] + ) + ->save(); + } + if (!$table->hasColumn('breakpoint')) { + $table + ->addColumn('breakpoint', 'boolean', ['default' => false, 'null' => false]) + ->save(); + } + } + + return $this; + } + + /** + * Gets the database connection + * + * @return \Cake\Database\Connection + */ + public function getConnection(): Connection + { + if ($this->connection === null) { + $this->connection = $this->getOption('connection'); + $this->connect(); + } + + /** @var \Cake\Database\Connection $this->connection */ + return $this->connection; + } + + /** + * @inheritDoc + */ + abstract public function connect(): void; + + /** + * @inheritDoc + */ + abstract public function disconnect(): void; + + /** + * @inheritDoc + */ + public function execute(string $sql, array $params = []): int + { + $sql = rtrim($sql, "; \t\n\r\0\x0B") . ';'; + $this->verboseLog($sql); + + if ($this->isDryRunEnabled()) { + return 0; + } + + $connection = $this->getConnection(); + if (empty($params)) { + $result = $connection->execute($sql); + + return $result->rowCount(); + } + $stmt = $connection->execute($sql, $params); + + return $stmt->rowCount(); + } + + /** + * @inheritDoc + */ + public function getQueryBuilder(string $type): Query + { + return match ($type) { + Query::TYPE_SELECT => $this->getConnection()->selectQuery(), + Query::TYPE_INSERT => $this->getConnection()->insertQuery(), + Query::TYPE_UPDATE => $this->getConnection()->updateQuery(), + Query::TYPE_DELETE => $this->getConnection()->deleteQuery(), + default => throw new InvalidArgumentException( + 'Query type must be one of: `select`, `insert`, `update`, `delete`.' + ) + }; + } + + /** + * @inheritDoc + */ + public function getSelectBuilder(): SelectQuery + { + return $this->getConnection()->selectQuery(); + } + + /** + * @inheritDoc + */ + public function getInsertBuilder(): InsertQuery + { + return $this->getConnection()->insertQuery(); + } + + /** + * @inheritDoc + */ + public function getUpdateBuilder(): UpdateQuery + { + return $this->getConnection()->updateQuery(); + } + + /** + * @inheritDoc + */ + public function getDeleteBuilder(): DeleteQuery + { + return $this->getConnection()->deleteQuery(); + } + + /** + * Executes a query and returns PDOStatement. + * + * @param string $sql SQL + * @return \Cake\Database\StatementInterface + */ + public function query(string $sql, array $params = []): mixed + { + return $this->getConnection()->execute($sql, $params); + } + + /** + * @inheritDoc + */ + public function fetchRow(string $sql): array|false + { + return $this->getConnection()->execute($sql)->fetch('assoc'); + } + + /** + * @inheritDoc + */ + public function fetchAll(string $sql): array + { + return $this->getConnection()->execute($sql)->fetchAll('assoc'); + } + + /** + * @inheritDoc + */ + public function insert(Table $table, array $row): void + { + $sql = sprintf( + 'INSERT INTO %s ', + $this->quoteTableName($table->getName()) + ); + $columns = array_keys($row); + $sql .= '(' . implode(', ', array_map([$this, 'quoteColumnName'], $columns)) . ')'; + + foreach ($row as $column => $value) { + if (is_bool($value)) { + $row[$column] = $this->castToBool($value); + } + } + + if ($this->isDryRunEnabled()) { + $sql .= ' VALUES (' . implode(', ', array_map([$this, 'quoteValue'], $row)) . ');'; + $this->io->out($sql); + } else { + $sql .= ' VALUES (' . implode(', ', array_fill(0, count($columns), '?')) . ')'; + $this->getConnection()->execute($sql, array_values($row)); + } + } + + /** + * Quotes a database value. + * + * @param mixed $value The value to quote + * @return mixed + */ + protected function quoteValue(mixed $value): mixed + { + if (is_numeric($value)) { + return $value; + } + + if ($value === null) { + return 'null'; + } + // TODO remove hacks like this by using cake's database layer better. + $driver = $this->getConnection()->getDriver(); + $method = new ReflectionMethod($driver, 'getPdo'); + $method->setAccessible(true); + + return $method->invoke($driver)->quote($value); + } + + /** + * Quotes a database string. + * + * @param string $value The string to quote + * @return string + */ + protected function quoteString(string $value): string + { + // TODO remove hacks like this by using cake's database layer better. + $driver = $this->getConnection()->getDriver(); + $method = new ReflectionMethod($driver, 'getPdo'); + $method->setAccessible(true); + + return $method->invoke($driver)->quote($value); + } + + /** + * @inheritDoc + */ + public function bulkinsert(Table $table, array $rows): void + { + $sql = sprintf( + 'INSERT INTO %s ', + $this->quoteTableName($table->getName()) + ); + $current = current($rows); + $keys = array_keys($current); + + $callback = fn ($key) => $this->quoteColumnName($key); + $sql .= '(' . implode(', ', array_map($callback, $keys)) . ') VALUES '; + + if ($this->isDryRunEnabled()) { + $values = array_map(function ($row) { + return '(' . implode(', ', array_map([$this, 'quoteValue'], $row)) . ')'; + }, $rows); + $sql .= implode(', ', $values) . ';'; + $this->io->out($sql); + } else { + $count_keys = count($keys); + $query = '(' . implode(', ', array_fill(0, $count_keys, '?')) . ')'; + $count_vars = count($rows); + $queries = array_fill(0, $count_vars, $query); + $sql .= implode(',', $queries); + $vals = []; + + foreach ($rows as $row) { + foreach ($row as $v) { + if (is_bool($v)) { + $vals[] = $this->castToBool($v); + } else { + $vals[] = $v; + } + } + } + $this->getConnection()->execute($sql, $vals); + } + } + + /** + * @inheritDoc + */ + public function getVersions(): array + { + $rows = $this->getVersionLog(); + + return array_keys($rows); + } + + /** + * {@inheritDoc} + * + * @throws \RuntimeException + */ + public function getVersionLog(): array + { + $result = []; + + switch ($this->options['version_order']) { + case Config::VERSION_ORDER_CREATION_TIME: + $orderBy = 'version ASC'; + break; + case Config::VERSION_ORDER_EXECUTION_TIME: + $orderBy = 'start_time ASC, version ASC'; + break; + default: + throw new RuntimeException('Invalid version_order configuration option'); + } + + // This will throw an exception if doing a --dry-run without any migrations as phinxlog + // does not exist, so in that case, we can just expect to trivially return empty set + try { + $rows = $this->fetchAll(sprintf('SELECT * FROM %s ORDER BY %s', $this->quoteTableName($this->getSchemaTableName()), $orderBy)); + } catch (PDOException $e) { + if (!$this->isDryRunEnabled()) { + throw $e; + } + $rows = []; + } + + foreach ($rows as $version) { + $result[(int)$version['version']] = $version; + } + + return $result; + } + + /** + * @inheritDoc + */ + public function migrated(MigrationInterface $migration, string $direction, string $startTime, string $endTime): AdapterInterface + { + if (strcasecmp($direction, MigrationInterface::UP) === 0) { + // up + $sql = sprintf( + "INSERT INTO %s (%s, %s, %s, %s, %s) VALUES ('%s', '%s', '%s', '%s', %s);", + $this->quoteTableName($this->getSchemaTableName()), + $this->quoteColumnName('version'), + $this->quoteColumnName('migration_name'), + $this->quoteColumnName('start_time'), + $this->quoteColumnName('end_time'), + $this->quoteColumnName('breakpoint'), + $migration->getVersion(), + substr($migration->getName(), 0, 100), + $startTime, + $endTime, + $this->castToBool(false) + ); + + $this->execute($sql); + } else { + // down + $sql = sprintf( + "DELETE FROM %s WHERE %s = '%s'", + $this->quoteTableName($this->getSchemaTableName()), + $this->quoteColumnName('version'), + $migration->getVersion() + ); + + $this->execute($sql); + } + + return $this; + } + + /** + * @inheritDoc + */ + public function toggleBreakpoint(MigrationInterface $migration): AdapterInterface + { + $this->query( + sprintf( + 'UPDATE %1$s SET %2$s = CASE %2$s WHEN %3$s THEN %4$s ELSE %3$s END, %7$s = %7$s WHERE %5$s = \'%6$s\';', + $this->quoteTableName($this->getSchemaTableName()), + $this->quoteColumnName('breakpoint'), + $this->castToBool(true), + $this->castToBool(false), + $this->quoteColumnName('version'), + $migration->getVersion(), + $this->quoteColumnName('start_time') + ) + ); + + return $this; + } + + /** + * @inheritDoc + */ + public function resetAllBreakpoints(): int + { + return $this->execute( + sprintf( + 'UPDATE %1$s SET %2$s = %3$s, %4$s = %4$s WHERE %2$s <> %3$s;', + $this->quoteTableName($this->getSchemaTableName()), + $this->quoteColumnName('breakpoint'), + $this->castToBool(false), + $this->quoteColumnName('start_time') + ) + ); + } + + /** + * @inheritDoc + */ + public function setBreakpoint(MigrationInterface $migration): AdapterInterface + { + $this->markBreakpoint($migration, true); + + return $this; + } + + /** + * @inheritDoc + */ + public function unsetBreakpoint(MigrationInterface $migration): AdapterInterface + { + $this->markBreakpoint($migration, false); + + return $this; + } + + /** + * Mark a migration breakpoint. + * + * @param \Phinx\Migration\MigrationInterface $migration The migration target for the breakpoint + * @param bool $state The required state of the breakpoint + * @return \Migrations\Db\Adapter\AdapterInterface + */ + protected function markBreakpoint(MigrationInterface $migration, bool $state): AdapterInterface + { + $this->query( + sprintf( + 'UPDATE %1$s SET %2$s = %3$s, %4$s = %4$s WHERE %5$s = \'%6$s\';', + $this->quoteTableName($this->getSchemaTableName()), + $this->quoteColumnName('breakpoint'), + $this->castToBool($state), + $this->quoteColumnName('start_time'), + $this->quoteColumnName('version'), + $migration->getVersion() + ) + ); + + return $this; + } + + /** + * {@inheritDoc} + * + * @throws \BadMethodCallException + * @return void + */ + public function createSchema(string $schemaName = 'public'): void + { + throw new BadMethodCallException('Creating a schema is not supported'); + } + + /** + * {@inheritDoc} + * + * @throws \BadMethodCallException + * @return void + */ + public function dropSchema(string $schemaName): void + { + throw new BadMethodCallException('Dropping a schema is not supported'); + } + + /** + * @inheritDoc + */ + public function getColumnTypes(): array + { + return [ + 'string', + 'char', + 'text', + 'tinyinteger', + 'smallinteger', + 'integer', + 'biginteger', + 'bit', + 'float', + 'decimal', + 'double', + 'datetime', + 'timestamp', + 'time', + 'date', + 'blob', + 'binary', + 'varbinary', + 'boolean', + 'uuid', + // Geospatial data types + 'geometry', + 'point', + 'linestring', + 'polygon', + ]; + } + + /** + * @inheritDoc + */ + public function castToBool($value): mixed + { + return (bool)$value ? 1 : 0; + } + + /** + * Get the definition for a `DEFAULT` statement. + * + * @param mixed $default Default value + * @param string|null $columnType column type added + * @return string + */ + protected function getDefaultValueDefinition(mixed $default, ?string $columnType = null): string + { + if ($default instanceof Literal) { + $default = (string)$default; + } elseif (is_string($default) && stripos($default, 'CURRENT_TIMESTAMP') !== 0) { + // Ensure a defaults of CURRENT_TIMESTAMP(3) is not quoted. + $default = $this->quoteString($default); + } elseif (is_bool($default)) { + $default = $this->castToBool($default); + } elseif ($default !== null && $columnType === static::PHINX_TYPE_BOOLEAN) { + $default = $this->castToBool((bool)$default); + } + + return isset($default) ? " DEFAULT $default" : ''; + } + + /** + * Executes all the ALTER TABLE instructions passed for the given table + * + * @param string $tableName The table name to use in the ALTER statement + * @param \Migrations\Db\AlterInstructions $instructions The object containing the alter sequence + * @return void + */ + protected function executeAlterSteps(string $tableName, AlterInstructions $instructions): void + { + $alter = sprintf('ALTER TABLE %s %%s', $this->quoteTableName($tableName)); + $instructions->execute($alter, [$this, 'execute']); + } + + /** + * @inheritDoc + */ + public function addColumn(Table $table, Column $column): void + { + $instructions = $this->getAddColumnInstructions($table, $column); + $this->executeAlterSteps($table->getName(), $instructions); + } + + /** + * Returns the instructions to add the specified column to a database table. + * + * @param \Migrations\Db\Table\Table $table Table + * @param \Migrations\Db\Table\Column $column Column + * @return \Migrations\Db\AlterInstructions + */ + abstract protected function getAddColumnInstructions(Table $table, Column $column): AlterInstructions; + + /** + * @inheritdoc + */ + public function renameColumn(string $tableName, string $columnName, string $newColumnName): void + { + $instructions = $this->getRenameColumnInstructions($tableName, $columnName, $newColumnName); + $this->executeAlterSteps($tableName, $instructions); + } + + /** + * Returns the instructions to rename the specified column. + * + * @param string $tableName Table name + * @param string $columnName Column Name + * @param string $newColumnName New Column Name + * @return \Migrations\Db\AlterInstructions + */ + abstract protected function getRenameColumnInstructions(string $tableName, string $columnName, string $newColumnName): AlterInstructions; + + /** + * @inheritdoc + */ + public function changeColumn(string $tableName, string $columnName, Column $newColumn): void + { + $instructions = $this->getChangeColumnInstructions($tableName, $columnName, $newColumn); + $this->executeAlterSteps($tableName, $instructions); + } + + /** + * Returns the instructions to change a table column type. + * + * @param string $tableName Table name + * @param string $columnName Column Name + * @param \Migrations\Db\Table\Column $newColumn New Column + * @return \Migrations\Db\AlterInstructions + */ + abstract protected function getChangeColumnInstructions(string $tableName, string $columnName, Column $newColumn): AlterInstructions; + + /** + * @inheritdoc + */ + public function dropColumn(string $tableName, string $columnName): void + { + $instructions = $this->getDropColumnInstructions($tableName, $columnName); + $this->executeAlterSteps($tableName, $instructions); + } + + /** + * Returns the instructions to drop the specified column. + * + * @param string $tableName Table name + * @param string $columnName Column Name + * @return \Migrations\Db\AlterInstructions + */ + abstract protected function getDropColumnInstructions(string $tableName, string $columnName): AlterInstructions; + + /** + * @inheritdoc + */ + public function addIndex(Table $table, Index $index): void + { + $instructions = $this->getAddIndexInstructions($table, $index); + $this->executeAlterSteps($table->getName(), $instructions); + } + + /** + * Returns the instructions to add the specified index to a database table. + * + * @param \Migrations\Db\Table\Table $table Table + * @param \Migrations\Db\Table\Index $index Index + * @return \Migrations\Db\AlterInstructions + */ + abstract protected function getAddIndexInstructions(Table $table, Index $index): AlterInstructions; + + /** + * @inheritdoc + */ + public function dropIndex(string $tableName, $columns): void + { + $instructions = $this->getDropIndexByColumnsInstructions($tableName, $columns); + $this->executeAlterSteps($tableName, $instructions); + } + + /** + * Returns the instructions to drop the specified index from a database table. + * + * @param string $tableName The name of of the table where the index is + * @param string|string[] $columns Column(s) + * @return \Migrations\Db\AlterInstructions + */ + abstract protected function getDropIndexByColumnsInstructions(string $tableName, string|array $columns): AlterInstructions; + + /** + * @inheritdoc + */ + public function dropIndexByName(string $tableName, string $indexName): void + { + $instructions = $this->getDropIndexByNameInstructions($tableName, $indexName); + $this->executeAlterSteps($tableName, $instructions); + } + + /** + * Returns the instructions to drop the index specified by name from a database table. + * + * @param string $tableName The table name whe the index is + * @param string $indexName The name of the index + * @return \Migrations\Db\AlterInstructions + */ + abstract protected function getDropIndexByNameInstructions(string $tableName, string $indexName): AlterInstructions; + + /** + * @inheritdoc + */ + public function addForeignKey(Table $table, ForeignKey $foreignKey): void + { + $instructions = $this->getAddForeignKeyInstructions($table, $foreignKey); + $this->executeAlterSteps($table->getName(), $instructions); + } + + /** + * Returns the instructions to adds the specified foreign key to a database table. + * + * @param \Migrations\Db\Table\Table $table The table to add the constraint to + * @param \Migrations\Db\Table\ForeignKey $foreignKey The foreign key to add + * @return \Migrations\Db\AlterInstructions + */ + abstract protected function getAddForeignKeyInstructions(Table $table, ForeignKey $foreignKey): AlterInstructions; + + /** + * @inheritDoc + */ + public function dropForeignKey(string $tableName, array $columns, ?string $constraint = null): void + { + if ($constraint) { + $instructions = $this->getDropForeignKeyInstructions($tableName, $constraint); + } else { + $instructions = $this->getDropForeignKeyByColumnsInstructions($tableName, $columns); + } + + $this->executeAlterSteps($tableName, $instructions); + } + + /** + * Returns the instructions to drop the specified foreign key from a database table. + * + * @param string $tableName The table where the foreign key constraint is + * @param string $constraint Constraint name + * @return \Migrations\Db\AlterInstructions + */ + abstract protected function getDropForeignKeyInstructions(string $tableName, string $constraint): AlterInstructions; + + /** + * Returns the instructions to drop the specified foreign key from a database table. + * + * @param string $tableName The table where the foreign key constraint is + * @param string[] $columns The list of column names + * @return \Migrations\Db\AlterInstructions + */ + abstract protected function getDropForeignKeyByColumnsInstructions(string $tableName, array $columns): AlterInstructions; + + /** + * @inheritdoc + */ + public function dropTable(string $tableName): void + { + $instructions = $this->getDropTableInstructions($tableName); + $this->executeAlterSteps($tableName, $instructions); + } + + /** + * Returns the instructions to drop the specified database table. + * + * @param string $tableName Table name + * @return \Migrations\Db\AlterInstructions + */ + abstract protected function getDropTableInstructions(string $tableName): AlterInstructions; + + /** + * @inheritdoc + */ + public function renameTable(string $tableName, string $newName): void + { + $instructions = $this->getRenameTableInstructions($tableName, $newName); + $this->executeAlterSteps($tableName, $instructions); + } + + /** + * Returns the instructions to rename the specified database table. + * + * @param string $tableName Table name + * @param string $newTableName New Name + * @return \Migrations\Db\AlterInstructions + */ + abstract protected function getRenameTableInstructions(string $tableName, string $newTableName): AlterInstructions; + + /** + * @inheritdoc + */ + public function changePrimaryKey(Table $table, $newColumns): void + { + $instructions = $this->getChangePrimaryKeyInstructions($table, $newColumns); + $this->executeAlterSteps($table->getName(), $instructions); + } + + /** + * Returns the instructions to change the primary key for the specified database table. + * + * @param \Migrations\Db\Table\Table $table Table + * @param string|string[]|null $newColumns Column name(s) to belong to the primary key, or null to drop the key + * @return \Migrations\Db\AlterInstructions + */ + abstract protected function getChangePrimaryKeyInstructions(Table $table, string|array|null $newColumns): AlterInstructions; + + /** + * @inheritdoc + */ + public function changeComment(Table $table, $newComment): void + { + $instructions = $this->getChangeCommentInstructions($table, $newComment); + $this->executeAlterSteps($table->getName(), $instructions); + } + + /** + * Returns the instruction to change the comment for the specified database table. + * + * @param \Migrations\Db\Table\Table $table Table + * @param string|null $newComment New comment string, or null to drop the comment + * @return \Migrations\Db\AlterInstructions + */ + abstract protected function getChangeCommentInstructions(Table $table, ?string $newComment): AlterInstructions; + + /** + * {@inheritDoc} + * + * @throws \InvalidArgumentException + * @return void + */ + public function executeActions(Table $table, array $actions): void + { + $instructions = new AlterInstructions(); + + foreach ($actions as $action) { + switch (true) { + case $action instanceof AddColumn: + /** @var \Migrations\Db\Action\AddColumn $action */ + $instructions->merge($this->getAddColumnInstructions($table, $action->getColumn())); + break; + + case $action instanceof AddIndex: + /** @var \Migrations\Db\Action\AddIndex $action */ + $instructions->merge($this->getAddIndexInstructions($table, $action->getIndex())); + break; + + case $action instanceof AddForeignKey: + /** @var \Migrations\Db\Action\AddForeignKey $action */ + $instructions->merge($this->getAddForeignKeyInstructions($table, $action->getForeignKey())); + break; + + case $action instanceof ChangeColumn: + /** @var \Migrations\Db\Action\ChangeColumn $action */ + $instructions->merge($this->getChangeColumnInstructions( + $table->getName(), + $action->getColumnName(), + $action->getColumn() + )); + break; + + case $action instanceof DropForeignKey && !$action->getForeignKey()->getConstraint(): + /** @var \Migrations\Db\Action\DropForeignKey $action */ + $instructions->merge($this->getDropForeignKeyByColumnsInstructions( + $table->getName(), + $action->getForeignKey()->getColumns() + )); + break; + + case $action instanceof DropForeignKey && $action->getForeignKey()->getConstraint(): + /** @var \Migrations\Db\Action\DropForeignKey $action */ + $instructions->merge($this->getDropForeignKeyInstructions( + $table->getName(), + (string)$action->getForeignKey()->getConstraint() + )); + break; + + case $action instanceof DropIndex && $action->getIndex()->getName() !== null: + /** @var \Migrations\Db\Action\DropIndex $action */ + $instructions->merge($this->getDropIndexByNameInstructions( + $table->getName(), + (string)$action->getIndex()->getName() + )); + break; + + case $action instanceof DropIndex && $action->getIndex()->getName() == null: + /** @var \Migrations\Db\Action\DropIndex $action */ + $instructions->merge($this->getDropIndexByColumnsInstructions( + $table->getName(), + (array)$action->getIndex()->getColumns() + )); + break; + + case $action instanceof DropTable: + /** @var \Migrations\Db\Action\DropTable $action */ + $instructions->merge($this->getDropTableInstructions( + $table->getName() + )); + break; + + case $action instanceof RemoveColumn: + /** @var \Migrations\Db\Action\RemoveColumn $action */ + $instructions->merge($this->getDropColumnInstructions( + $table->getName(), + (string)$action->getColumn()->getName() + )); + break; + + case $action instanceof RenameColumn: + /** @var \Migrations\Db\Action\RenameColumn $action */ + $instructions->merge($this->getRenameColumnInstructions( + $table->getName(), + (string)$action->getColumn()->getName(), + $action->getNewName() + )); + break; + + case $action instanceof RenameTable: + /** @var \Migrations\Db\Action\RenameTable $action */ + $instructions->merge($this->getRenameTableInstructions( + $table->getName(), + $action->getNewName() + )); + break; + + case $action instanceof ChangePrimaryKey: + /** @var \Migrations\Db\Action\ChangePrimaryKey $action */ + $instructions->merge($this->getChangePrimaryKeyInstructions( + $table, + $action->getNewColumns() + )); + break; + + case $action instanceof ChangeComment: + /** @var \Migrations\Db\Action\ChangeComment $action */ + $instructions->merge($this->getChangeCommentInstructions( + $table, + $action->getNewComment() + )); + break; + + default: + throw new InvalidArgumentException( + sprintf("Don't know how to execute action: '%s'", get_class($action)) + ); + } + } + + $this->executeAlterSteps($table->getName(), $instructions); + } +} diff --git a/src/Db/Adapter/PhinxAdapter.php b/src/Db/Adapter/PhinxAdapter.php new file mode 100644 index 00000000..f3cf451d --- /dev/null +++ b/src/Db/Adapter/PhinxAdapter.php @@ -0,0 +1,823 @@ +getName(), + $phinxTable->getOptions(), + ); + + return $table; + } + + /** + * Convert a phinx column into a migrations object + * + * @param \Phinx\Db\Table\Column $phinxColumn The column to convert. + * @return \Migrations\Db\Table\Column + */ + protected function convertColumn(PhinxColumn $phinxColumn): Column + { + $column = new Column(); + $attrs = [ + 'name', 'null', 'default', 'identity', + 'generated', 'seed', 'increment', 'scale', + 'after', 'update', 'comment', 'signed', + 'timezone', 'properties', 'collation', + 'encoding', 'srid', 'values', 'limit', + ]; + foreach ($attrs as $attr) { + $get = 'get' . ucfirst($attr); + $set = 'set' . ucfirst($attr); + try { + $value = $phinxColumn->{$get}(); + } catch (RuntimeException $e) { + $value = null; + } + if ($value !== null) { + $column->{$set}($value); + } + } + try { + $type = $phinxColumn->getType(); + } catch (RuntimeException $e) { + $type = null; + } + if ($type instanceof PhinxLiteral) { + $type = Literal::from((string)$type); + } + if ($type) { + $column->setType($type); + } + + return $column; + } + + /** + * Convert a migrations column into a phinx object + * + * @param \Migrations\Db\Table\Column $column The column to convert. + * @return \Phinx\Db\Table\Column + */ + protected function convertColumnToPhinx(Column $column): PhinxColumn + { + $phinx = new PhinxColumn(); + $attrs = [ + 'name', 'type', 'null', 'default', 'identity', + 'generated', 'seed', 'increment', 'scale', + 'after', 'update', 'comment', 'signed', + 'timezone', 'properties', 'collation', + 'encoding', 'srid', 'values', 'limit', + ]; + foreach ($attrs as $attr) { + $get = 'get' . ucfirst($attr); + $set = 'set' . ucfirst($attr); + $value = $column->{$get}(); + $value = $column->{$get}(); + if ($value !== null) { + $phinx->{$set}($value); + } + } + + return $phinx; + } + + /** + * Convert a migrations Index into a phinx object + * + * @param \Phinx\Db\Table\Index $phinxIndex The index to convert. + * @return \Migrations\Db\Table\Index + */ + protected function convertIndex(PhinxIndex $phinxIndex): Index + { + $index = new Index(); + $attrs = [ + 'name', 'columns', 'type', 'limit', 'order', + 'include', + ]; + foreach ($attrs as $attr) { + $get = 'get' . ucfirst($attr); + $set = 'set' . ucfirst($attr); + try { + $value = $phinxIndex->{$get}(); + } catch (RuntimeException $e) { + $value = null; + } + if ($value !== null) { + $index->{$set}($value); + } + } + + return $index; + } + + /** + * Convert a phinx ForeignKey into a migrations object + * + * @param \Phinx\Db\Table\ForeignKey $phinxKey The index to convert. + * @return \Migrations\Db\Table\ForeignKey + */ + protected function convertForeignKey(PhinxForeignKey $phinxKey): ForeignKey + { + $foreignkey = new ForeignKey(); + $attrs = [ + 'columns', 'referencedColumns', 'onDelete', 'onUpdate', 'constraint', + ]; + + foreach ($attrs as $attr) { + $get = 'get' . ucfirst($attr); + $set = 'set' . ucfirst($attr); + try { + $value = $phinxKey->{$get}(); + } catch (RuntimeException $e) { + $value = null; + } + if ($value !== null) { + $foreignkey->{$set}($value); + } + } + + try { + $referenced = $phinxKey->getReferencedTable(); + } catch (RuntimeException $e) { + $referenced = null; + } + if ($referenced) { + $foreignkey->setReferencedTable($this->convertTable($referenced)); + } + + return $foreignkey; + } + + /** + * Convert a phinx Action into a migrations object + * + * @param \Phinx\Db\Action\Action $phinxAction The index to convert. + * @return \Migrations\Db\Action\Action + */ + protected function convertAction(PhinxAction $phinxAction): Action + { + $action = null; + if ($phinxAction instanceof PhinxAddColumn) { + $action = new AddColumn( + $this->convertTable($phinxAction->getTable()), + $this->convertColumn($phinxAction->getColumn()) + ); + } elseif ($phinxAction instanceof PhinxAddForeignKey) { + $action = new AddForeignKey( + $this->convertTable($phinxAction->getTable()), + $this->convertForeignKey($phinxAction->getForeignKey()) + ); + } elseif ($phinxAction instanceof PhinxAddIndex) { + $action = new AddIndex( + $this->convertTable($phinxAction->getTable()), + $this->convertIndex($phinxAction->getIndex()) + ); + } elseif ($phinxAction instanceof PhinxChangeColumn) { + $action = new ChangeColumn( + $this->convertTable($phinxAction->getTable()), + $phinxAction->getColumnName(), + $this->convertColumn($phinxAction->getColumn()) + ); + } elseif ($phinxAction instanceof PhinxChangeComment) { + $action = new ChangeComment( + $this->convertTable($phinxAction->getTable()), + $phinxAction->getNewComment() + ); + } elseif ($phinxAction instanceof PhinxChangePrimaryKey) { + $action = new ChangePrimaryKey( + $this->convertTable($phinxAction->getTable()), + $phinxAction->getNewColumns() + ); + } elseif ($phinxAction instanceof PhinxCreateTable) { + $action = new CreateTable( + $this->convertTable($phinxAction->getTable()), + ); + } elseif ($phinxAction instanceof PhinxDropForeignKey) { + $action = new DropForeignKey( + $this->convertTable($phinxAction->getTable()), + $this->convertForeignKey($phinxAction->getForeignKey()), + ); + } elseif ($phinxAction instanceof PhinxDropIndex) { + $action = new DropIndex( + $this->convertTable($phinxAction->getTable()), + $this->convertIndex($phinxAction->getIndex()) + ); + } elseif ($phinxAction instanceof PhinxDropTable) { + $action = new DropTable( + $this->convertTable($phinxAction->getTable()), + ); + } elseif ($phinxAction instanceof PhinxRemoveColumn) { + $action = new RemoveColumn( + $this->convertTable($phinxAction->getTable()), + $this->convertColumn($phinxAction->getColumn()) + ); + } elseif ($phinxAction instanceof PhinxRenameColumn) { + $action = new RenameColumn( + $this->convertTable($phinxAction->getTable()), + $this->convertColumn($phinxAction->getColumn()), + $phinxAction->getNewName(), + ); + } elseif ($phinxAction instanceof PhinxRenameTable) { + $action = new RenameTable( + $this->convertTable($phinxAction->getTable()), + $phinxAction->getNewName() + ); + } + if (!$action) { + throw new RuntimeException('Unable to map action of type ' . get_class($phinxAction)); + } + + return $action; + } + + /** + * Convert a phinx Literal into a migrations object + * + * @param \Phinx\Util\Literal|string $phinxLiteral The literal to convert. + * @return \Migrations\Db\Literal|string + */ + protected function convertLiteral(PhinxLiteral|string $phinxLiteral): Literal|string + { + if (is_string($phinxLiteral)) { + return $phinxLiteral; + } + + return new Literal((string)$phinxLiteral); + } + + /** + * @inheritDoc + */ + public function __construct(AdapterInterface $adapter) + { + $this->adapter = $adapter; + } + + /** + * @inheritDoc + */ + public function setOptions(array $options): PhinxAdapterInterface + { + $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): PhinxAdapterInterface + { + throw new RuntimeException('Using setInput() on Adapters is no longer supported'); + } + + /** + * @inheritDoc + */ + public function getInput(): InputInterface + { + throw new RuntimeException('Using getInput() on Adapters is no longer supported'); + } + + /** + * @inheritDoc + */ + public function setOutput(OutputInterface $output): PhinxAdapterInterface + { + throw new RuntimeException('Using setOutput() on Adapters is no longer supported'); + } + + /** + * @inheritDoc + */ + public function getOutput(): OutputInterface + { + throw new RuntimeException('Using getOutput() on Adapters is no longer supported'); + } + + /** + * @inheritDoc + */ + public function getColumnForType(string $columnName, string $type, array $options): PhinxColumn + { + $column = $this->adapter->getColumnForType($columnName, $type, $options); + + return $this->convertColumnToPhinx($column); + } + + /** + * @inheritDoc + */ + public function connect(): void + { + $this->adapter->connect(); + } + + /** + * @inheritDoc + */ + public function disconnect(): void + { + $this->adapter->disconnect(); + } + + /** + * @inheritDoc + */ + public function execute(string $sql, array $params = []): int + { + return $this->adapter->execute($sql, $params); + } + + /** + * @inheritDoc + */ + public function query(string $sql, array $params = []): mixed + { + return $this->adapter->query($sql, $params); + } + + /** + * @inheritDoc + */ + public function insert(PhinxTable $table, array $row): void + { + $this->adapter->insert($this->convertTable($table), $row); + } + + /** + * @inheritDoc + */ + public function bulkinsert(PhinxTable $table, array $rows): void + { + $this->adapter->bulkinsert($this->convertTable($table), $rows); + } + + /** + * @inheritDoc + */ + public function fetchRow(string $sql): array|false + { + return $this->adapter->fetchRow($sql); + } + + /** + * @inheritDoc + */ + public function fetchAll(string $sql): array + { + return $this->adapter->fetchAll($sql); + } + + /** + * @inheritDoc + */ + public function getVersions(): array + { + return $this->adapter->getVersions(); + } + + /** + * @inheritDoc + */ + public function getVersionLog(): array + { + return $this->adapter->getVersionLog(); + } + + /** + * @inheritDoc + */ + public function migrated(MigrationInterface $migration, string $direction, string $startTime, string $endTime): PhinxAdapterInterface + { + $this->adapter->migrated($migration, $direction, $startTime, $endTime); + + return $this; + } + + /** + * @inheritDoc + */ + public function toggleBreakpoint(MigrationInterface $migration): PhinxAdapterInterface + { + $this->adapter->toggleBreakpoint($migration); + + return $this; + } + + /** + * @inheritDoc + */ + public function resetAllBreakpoints(): int + { + return $this->adapter->resetAllBreakpoints(); + } + + /** + * @inheritDoc + */ + public function setBreakpoint(MigrationInterface $migration): PhinxAdapterInterface + { + $this->adapter->setBreakpoint($migration); + + return $this; + } + + /** + * @inheritDoc + */ + public function unsetBreakpoint(MigrationInterface $migration): PhinxAdapterInterface + { + $this->adapter->unsetBreakpoint($migration); + + return $this; + } + + /** + * @inheritDoc + */ + public function createSchemaTable(): void + { + $this->adapter->createSchemaTable(); + } + + /** + * @inheritDoc + */ + public function getColumnTypes(): array + { + return $this->adapter->getColumnTypes(); + } + + /** + * @inheritDoc + */ + public function isValidColumnType(PhinxColumn $column): bool + { + return $this->adapter->isValidColumnType($this->convertColumn($column)); + } + + /** + * @inheritDoc + */ + public function hasTransactions(): bool + { + return $this->adapter->hasTransactions(); + } + + /** + * @inheritDoc + */ + public function beginTransaction(): void + { + $this->adapter->beginTransaction(); + } + + /** + * @inheritDoc + */ + public function commitTransaction(): void + { + $this->adapter->commitTransaction(); + } + + /** + * @inheritDoc + */ + public function rollbackTransaction(): void + { + $this->adapter->rollbackTransaction(); + } + + /** + * @inheritDoc + */ + public function quoteTableName(string $tableName): string + { + return $this->adapter->quoteTableName($tableName); + } + + /** + * @inheritDoc + */ + public function quoteColumnName(string $columnName): string + { + return $this->adapter->quoteColumnName($columnName); + } + + /** + * @inheritDoc + */ + public function hasTable(string $tableName): bool + { + return $this->adapter->hasTable($tableName); + } + + /** + * @inheritDoc + */ + public function createTable(PhinxTable $table, array $columns = [], array $indexes = []): void + { + $columns = array_map(function ($col) { + return $this->convertColumn($col); + }, $columns); + $indexes = array_map(function ($ind) { + return $this->convertIndex($ind); + }, $indexes); + $this->adapter->createTable($this->convertTable($table), $columns, $indexes); + } + + /** + * @inheritDoc + */ + public function getColumns(string $tableName): array + { + $columns = $this->adapter->getColumns($tableName); + + return array_map(function ($col) { + return $this->convertColumnToPhinx($col); + }, $columns); + } + + /** + * @inheritDoc + */ + public function hasColumn(string $tableName, string $columnName): bool + { + return $this->adapter->hasColumn($tableName, $columnName); + } + + /** + * @inheritDoc + */ + public function hasIndex(string $tableName, string|array $columns): bool + { + return $this->adapter->hasIndex($tableName, $columns); + } + + /** + * @inheritDoc + */ + public function hasIndexByName(string $tableName, string $indexName): bool + { + return $this->adapter->hasIndexByName($tableName, $indexName); + } + + /** + * @inheritDoc + */ + public function hasPrimaryKey(string $tableName, $columns, ?string $constraint = null): bool + { + return $this->adapter->hasPrimaryKey($tableName, $columns, $constraint); + } + + /** + * @inheritDoc + */ + public function hasForeignKey(string $tableName, $columns, ?string $constraint = null): bool + { + return $this->adapter->hasForeignKey($tableName, $columns, $constraint); + } + + /** + * @inheritDoc + */ + public function getSqlType(PhinxLiteral|string $type, ?int $limit = null): array + { + return $this->adapter->getSqlType($this->convertLiteral($type), $limit); + } + + /** + * @inheritDoc + */ + public function createDatabase(string $name, array $options = []): void + { + $this->adapter->createDatabase($name, $options); + } + + /** + * @inheritDoc + */ + public function hasDatabase(string $name): bool + { + return $this->adapter->hasDatabase($name); + } + + /** + * @inheritDoc + */ + public function dropDatabase(string $name): void + { + $this->adapter->dropDatabase($name); + } + + /** + * @inheritDoc + */ + public function createSchema(string $schemaName = 'public'): void + { + $this->adapter->createSchema($schemaName); + } + + /** + * @inheritDoc + */ + public function dropSchema(string $schemaName): void + { + $this->adapter->dropSchema($schemaName); + } + + /** + * @inheritDoc + */ + public function truncateTable(string $tableName): void + { + $this->adapter->truncateTable($tableName); + } + + /** + * @inheritDoc + */ + public function castToBool($value): mixed + { + return $this->adapter->castToBool($value); + } + + /** + * @return \Cake\Database\Connection + */ + public function getConnection(): Connection + { + return $this->adapter->getConnection(); + } + + /** + * @inheritDoc + */ + public function executeActions(PhinxTable $table, array $actions): void + { + $actions = array_map(function ($act) { + return $this->convertAction($act); + }, $actions); + $this->adapter->executeActions($this->convertTable($table), $actions); + } + + /** + * @inheritDoc + */ + public function getAdapterType(): string + { + return $this->adapter->getAdapterType(); + } + + /** + * @inheritDoc + */ + public function getQueryBuilder(string $type): Query + { + return $this->adapter->getQueryBuilder($type); + } + + /** + * @inheritDoc + */ + public function getSelectBuilder(): SelectQuery + { + return $this->adapter->getSelectBuilder(); + } + + /** + * @inheritDoc + */ + public function getInsertBuilder(): InsertQuery + { + return $this->adapter->getInsertBuilder(); + } + + /** + * @inheritDoc + */ + public function getUpdateBuilder(): UpdateQuery + { + return $this->adapter->getUpdateBuilder(); + } + + /** + * @inheritDoc + */ + public function getDeleteBuilder(): DeleteQuery + { + return $this->adapter->getDeleteBuilder(); + } + + /** + * @inheritDoc + */ + public function getCakeConnection(): Connection + { + return $this->adapter->getConnection(); + } +} diff --git a/src/Db/Adapter/PostgresAdapter.php b/src/Db/Adapter/PostgresAdapter.php new file mode 100644 index 00000000..d8fe5aa4 --- /dev/null +++ b/src/Db/Adapter/PostgresAdapter.php @@ -0,0 +1,1616 @@ += 10.0) + * + * @var bool + */ + protected bool $useIdentity; + + /** + * {@inheritDoc} + */ + public function setConnection(Connection $connection): AdapterInterface + { + // always set here since connect() isn't always called + $version = $connection->getDriver()->version(); + $this->useIdentity = (float)$version >= 10; + + return parent::setConnection($connection); + } + + /** + * {@inheritDoc} + * + * @throws \RuntimeException + * @throws \InvalidArgumentException + * @return void + */ + public function connect(): void + { + $this->getConnection()->getDriver()->connect(); + $this->setConnection($this->getConnection()); + } + + /** + * @inheritDoc + */ + public function disconnect(): void + { + $this->getConnection()->getDriver()->disconnect(); + } + + /** + * @inheritDoc + */ + public function hasTransactions(): bool + { + return true; + } + + /** + * @inheritDoc + */ + public function beginTransaction(): void + { + $this->getConnection()->begin(); + } + + /** + * @inheritDoc + */ + public function commitTransaction(): void + { + $this->getConnection()->commit(); + } + + /** + * @inheritDoc + */ + public function rollbackTransaction(): void + { + $this->getConnection()->rollback(); + } + + /** + * Quotes a schema name for use in a query. + * + * @param string $schemaName Schema Name + * @return string + */ + public function quoteSchemaName(string $schemaName): string + { + return $this->quoteColumnName($schemaName); + } + + /** + * @inheritDoc + */ + public function quoteTableName(string $tableName): string + { + $parts = $this->getSchemaName($tableName); + + return $this->quoteSchemaName($parts['schema']) . '.' . $this->quoteColumnName($parts['table']); + } + + /** + * @inheritDoc + */ + public function quoteColumnName(string $columnName): string + { + return '"' . $columnName . '"'; + } + + /** + * @inheritDoc + */ + public function hasTable(string $tableName): bool + { + if ($this->hasCreatedTable($tableName)) { + return true; + } + + $parts = $this->getSchemaName($tableName); + $connection = $this->getConnection(); + $stmt = $connection->execute( + 'SELECT * + FROM information_schema.tables + WHERE table_schema = ? + AND table_name = ?', + [$parts['schema'], $parts['table']] + ); + $count = $stmt->rowCount(); + $stmt->closeCursor(); + + return $count === 1; + } + + /** + * @inheritDoc + */ + public function createTable(Table $table, array $columns = [], array $indexes = []): void + { + $queries = []; + + $options = $table->getOptions(); + $parts = $this->getSchemaName($table->getName()); + + // Add the default primary key + if (!isset($options['id']) || (isset($options['id']) && $options['id'] === true)) { + $options['id'] = 'id'; + } + + if (isset($options['id']) && is_string($options['id'])) { + // Handle id => "field_name" to support AUTO_INCREMENT + $column = new Column(); + $column->setName($options['id']) + ->setType('integer') + ->setOptions(['identity' => true]); + + array_unshift($columns, $column); + if (isset($options['primary_key']) && (array)$options['id'] !== (array)$options['primary_key']) { + throw new InvalidArgumentException('You cannot enable an auto incrementing ID field and a primary key'); + } + $options['primary_key'] = $options['id']; + } + + // TODO - process table options like collation etc + $sql = 'CREATE TABLE '; + $sql .= $this->quoteTableName($table->getName()) . ' ('; + + $this->columnsWithComments = []; + foreach ($columns as $column) { + $sql .= $this->quoteColumnName((string)$column->getName()) . ' ' . $this->getColumnSqlDefinition($column); + if ($this->useIdentity && $column->getIdentity() && $column->getGenerated() !== null) { + $sql .= sprintf(' GENERATED %s AS IDENTITY', (string)$column->getGenerated()); + } + $sql .= ', '; + + // set column comments, if needed + if ($column->getComment()) { + $this->columnsWithComments[] = $column; + } + } + + // set the primary key(s) + if (isset($options['primary_key'])) { + $sql = rtrim($sql); + $sql .= sprintf(' CONSTRAINT %s PRIMARY KEY (', $this->quoteColumnName($parts['table'] . '_pkey')); + if (is_string($options['primary_key'])) { // handle primary_key => 'id' + $sql .= $this->quoteColumnName($options['primary_key']); + } elseif (is_array($options['primary_key'])) { // handle primary_key => array('tag_id', 'resource_id') + $sql .= implode(',', array_map([$this, 'quoteColumnName'], $options['primary_key'])); + } + $sql .= ')'; + } else { + $sql = rtrim($sql, ', '); // no primary keys + } + + $sql .= ')'; + $queries[] = $sql; + + // process column comments + if (!empty($this->columnsWithComments)) { + foreach ($this->columnsWithComments as $column) { + $queries[] = $this->getColumnCommentSqlDefinition($column, $table->getName()); + } + } + + // set the indexes + if (!empty($indexes)) { + foreach ($indexes as $index) { + $queries[] = $this->getIndexSqlDefinition($index, $table->getName()); + } + } + + // process table comments + if (isset($options['comment'])) { + $queries[] = sprintf( + 'COMMENT ON TABLE %s IS %s', + $this->quoteTableName($table->getName()), + $this->quoteString($options['comment']) + ); + } + + foreach ($queries as $query) { + $this->execute($query); + } + + $this->addCreatedTable($table->getName()); + } + + /** + * {@inheritDoc} + * + * @throws \InvalidArgumentException + */ + protected function getChangePrimaryKeyInstructions(Table $table, array|string|null $newColumns): AlterInstructions + { + $parts = $this->getSchemaName($table->getName()); + + $instructions = new AlterInstructions(); + + // Drop the existing primary key + $primaryKey = $this->getPrimaryKey($table->getName()); + if (!empty($primaryKey['constraint'])) { + $sql = sprintf( + 'DROP CONSTRAINT %s', + $this->quoteColumnName($primaryKey['constraint']) + ); + $instructions->addAlter($sql); + } + + // Add the new primary key + if (!empty($newColumns)) { + $sql = sprintf( + 'ADD CONSTRAINT %s PRIMARY KEY (', + $this->quoteColumnName($parts['table'] . '_pkey') + ); + if (is_string($newColumns)) { // handle primary_key => 'id' + $sql .= $this->quoteColumnName($newColumns); + } else { // handle primary_key => array('tag_id', 'resource_id') + $sql .= implode(',', array_map([$this, 'quoteColumnName'], $newColumns)); + } + $sql .= ')'; + $instructions->addAlter($sql); + } + + return $instructions; + } + + /** + * @inheritDoc + */ + protected function getChangeCommentInstructions(Table $table, ?string $newComment): AlterInstructions + { + $instructions = new AlterInstructions(); + + // passing 'null' is to remove table comment + $newComment = $newComment !== null + ? $this->quoteString($newComment) + : 'NULL'; + $sql = sprintf( + 'COMMENT ON TABLE %s IS %s', + $this->quoteTableName($table->getName()), + $newComment + ); + $instructions->addPostStep($sql); + + return $instructions; + } + + /** + * @inheritDoc + */ + protected function getRenameTableInstructions(string $tableName, string $newTableName): AlterInstructions + { + $this->updateCreatedTableName($tableName, $newTableName); + $sql = sprintf( + 'ALTER TABLE %s RENAME TO %s', + $this->quoteTableName($tableName), + $this->quoteColumnName($newTableName) + ); + + return new AlterInstructions([], [$sql]); + } + + /** + * @inheritDoc + */ + protected function getDropTableInstructions(string $tableName): AlterInstructions + { + $this->removeCreatedTable($tableName); + $sql = sprintf('DROP TABLE %s', $this->quoteTableName($tableName)); + + return new AlterInstructions([], [$sql]); + } + + /** + * @inheritDoc + */ + public function truncateTable(string $tableName): void + { + $sql = sprintf( + 'TRUNCATE TABLE %s RESTART IDENTITY', + $this->quoteTableName($tableName) + ); + + $this->execute($sql); + } + + /** + * @inheritDoc + */ + public function getColumns(string $tableName): array + { + $parts = $this->getSchemaName($tableName); + $columns = []; + $sql = sprintf( + 'SELECT column_name, data_type, udt_name, is_identity, is_nullable, + column_default, character_maximum_length, numeric_precision, numeric_scale, + datetime_precision + %s + FROM information_schema.columns + WHERE table_schema = %s AND table_name = %s + ORDER BY ordinal_position', + $this->useIdentity ? ', identity_generation' : '', + $this->quoteString($parts['schema']), + $this->quoteString($parts['table']) + ); + $columnsInfo = $this->fetchAll($sql); + foreach ($columnsInfo as $columnInfo) { + $isUserDefined = strtoupper(trim($columnInfo['data_type'])) === 'USER-DEFINED'; + + if ($isUserDefined) { + $columnType = Literal::from($columnInfo['udt_name']); + } else { + $columnType = $this->getPhinxType($columnInfo['data_type']); + } + + // If the default value begins with a ' or looks like a function mark it as literal + if (isset($columnInfo['column_default'][0]) && $columnInfo['column_default'][0] === "'") { + if (preg_match('/^\'(.*)\'::[^:]+$/', $columnInfo['column_default'], $match)) { + // '' and \' are replaced with a single ' + $columnDefault = preg_replace('/[\'\\\\]\'/', "'", $match[1]); + } else { + $columnDefault = Literal::from($columnInfo['column_default']); + } + } elseif ( + $columnInfo['column_default'] !== null && + preg_match('/^\D[a-z_\d]*\(.*\)$/', $columnInfo['column_default']) + ) { + $columnDefault = Literal::from($columnInfo['column_default']); + } else { + $columnDefault = $columnInfo['column_default']; + } + + $column = new Column(); + + $column->setName($columnInfo['column_name']) + ->setType($columnType) + ->setNull($columnInfo['is_nullable'] === 'YES') + ->setDefault($columnDefault) + ->setIdentity($columnInfo['is_identity'] === 'YES') + ->setScale($columnInfo['numeric_scale']); + + if ($this->useIdentity) { + $column->setGenerated($columnInfo['identity_generation']); + } + + if (preg_match('/\bwith time zone$/', $columnInfo['data_type'])) { + $column->setTimezone(true); + } + + if (isset($columnInfo['character_maximum_length'])) { + $column->setLimit($columnInfo['character_maximum_length']); + } + + if (in_array($columnType, [static::PHINX_TYPE_TIME, static::PHINX_TYPE_DATETIME], true)) { + $column->setPrecision($columnInfo['datetime_precision']); + } elseif ($columnType === self::PHINX_TYPE_DECIMAL) { + $column->setPrecision($columnInfo['numeric_precision']); + } + $columns[] = $column; + } + + return $columns; + } + + /** + * @inheritDoc + */ + public function hasColumn(string $tableName, string $columnName): bool + { + $parts = $this->getSchemaName($tableName); + $connection = $this->getConnection(); + $sql = 'SELECT count(*) + FROM information_schema.columns + WHERE table_schema = ? AND table_name = ? AND column_name = ?'; + + $result = $connection->execute($sql, [$parts['schema'], $parts['table'], $columnName]); + $row = $result->fetch('assoc'); + $result->closeCursor(); + + return $row['count'] > 0; + } + + /** + * @inheritDoc + */ + protected function getAddColumnInstructions(Table $table, Column $column): AlterInstructions + { + $instructions = new AlterInstructions(); + $instructions->addAlter(sprintf( + 'ADD %s %s %s', + $this->quoteColumnName((string)$column->getName()), + $this->getColumnSqlDefinition($column), + $column->isIdentity() && $column->getGenerated() !== null && $this->useIdentity ? + sprintf('GENERATED %s AS IDENTITY', (string)$column->getGenerated()) : '' + )); + + if ($column->getComment()) { + $instructions->addPostStep($this->getColumnCommentSqlDefinition($column, $table->getName())); + } + + return $instructions; + } + + /** + * {@inheritDoc} + * + * @throws \InvalidArgumentException + */ + protected function getRenameColumnInstructions( + string $tableName, + string $columnName, + string $newColumnName + ): AlterInstructions { + $parts = $this->getSchemaName($tableName); + $sql = sprintf( + 'SELECT CASE WHEN COUNT(*) > 0 THEN 1 ELSE 0 END AS column_exists + FROM information_schema.columns + WHERE table_schema = %s AND table_name = %s AND column_name = %s', + $this->quoteString($parts['schema']), + $this->quoteString($parts['table']), + $this->quoteString($columnName) + ); + + $result = $this->fetchRow($sql); + if (!$result || !(bool)$result['column_exists']) { + throw new InvalidArgumentException("The specified column does not exist: $columnName"); + } + + $instructions = new AlterInstructions(); + $instructions->addPostStep( + sprintf( + 'ALTER TABLE %s RENAME COLUMN %s TO %s', + $this->quoteTableName($tableName), + $this->quoteColumnName($columnName), + $this->quoteColumnName($newColumnName) + ) + ); + + return $instructions; + } + + /** + * @inheritDoc + */ + protected function getChangeColumnInstructions( + string $tableName, + string $columnName, + Column $newColumn + ): AlterInstructions { + $quotedColumnName = $this->quoteColumnName($columnName); + $instructions = new AlterInstructions(); + if ($newColumn->getType() === 'boolean') { + $sql = sprintf('ALTER COLUMN %s DROP DEFAULT', $quotedColumnName); + $instructions->addAlter($sql); + } + $sql = sprintf( + 'ALTER COLUMN %s TYPE %s', + $quotedColumnName, + $this->getColumnSqlDefinition($newColumn) + ); + if (in_array($newColumn->getType(), ['smallinteger', 'integer', 'biginteger'], true)) { + $sql .= sprintf( + ' USING (%s::bigint)', + $quotedColumnName + ); + } + if ($newColumn->getType() === 'uuid') { + $sql .= sprintf( + ' USING (%s::uuid)', + $quotedColumnName + ); + } + //NULL and DEFAULT cannot be set while changing column type + $sql = preg_replace('/ NOT NULL/', '', $sql); + $sql = preg_replace('/ NULL/', '', $sql); + //If it is set, DEFAULT is the last definition + $sql = preg_replace('/DEFAULT .*/', '', $sql); + if ($newColumn->getType() === 'boolean') { + $sql .= sprintf( + ' USING (CASE WHEN %s IS NULL THEN NULL WHEN %s::int=0 THEN FALSE ELSE TRUE END)', + $quotedColumnName, + $quotedColumnName + ); + } + $instructions->addAlter($sql); + + $column = $this->getColumn($tableName, $columnName); + assert($column !== null, 'Column must exist'); + + if ($this->useIdentity) { + // process identity + $sql = sprintf( + 'ALTER COLUMN %s', + $quotedColumnName + ); + if ($newColumn->isIdentity() && $newColumn->getGenerated() !== null) { + if ($column->isIdentity()) { + $sql .= sprintf(' SET GENERATED %s', (string)$newColumn->getGenerated()); + } else { + $sql .= sprintf(' ADD GENERATED %s AS IDENTITY', (string)$newColumn->getGenerated()); + } + } else { + $sql .= ' DROP IDENTITY IF EXISTS'; + } + $instructions->addAlter($sql); + } + + // process null + $sql = sprintf( + 'ALTER COLUMN %s', + $quotedColumnName + ); + + if (!$newColumn->getIdentity() && !$column->getIdentity() && $newColumn->isNull()) { + $sql .= ' DROP NOT NULL'; + } else { + $sql .= ' SET NOT NULL'; + } + + $instructions->addAlter($sql); + + if ($newColumn->getDefault() !== null) { + $instructions->addAlter(sprintf( + 'ALTER COLUMN %s SET %s', + $quotedColumnName, + $this->getDefaultValueDefinition($newColumn->getDefault(), (string)$newColumn->getType()) + )); + } elseif (!$newColumn->getIdentity()) { + //drop default + $instructions->addAlter(sprintf( + 'ALTER COLUMN %s DROP DEFAULT', + $quotedColumnName + )); + } + + // rename column + if ($columnName !== $newColumn->getName()) { + $instructions->addPostStep(sprintf( + 'ALTER TABLE %s RENAME COLUMN %s TO %s', + $this->quoteTableName($tableName), + $quotedColumnName, + $this->quoteColumnName((string)$newColumn->getName()) + )); + } + + // change column comment if needed + if ($newColumn->getComment()) { + $instructions->addPostStep($this->getColumnCommentSqlDefinition($newColumn, $tableName)); + } + + return $instructions; + } + + /** + * @param string $tableName Table name + * @param string $columnName Column name + * @return ?\Migrations\Db\Table\Column + */ + protected function getColumn(string $tableName, string $columnName): ?Column + { + $columns = $this->getColumns($tableName); + foreach ($columns as $column) { + if ($column->getName() === $columnName) { + return $column; + } + } + + return null; + } + + /** + * @inheritDoc + */ + protected function getDropColumnInstructions(string $tableName, string $columnName): AlterInstructions + { + $alter = sprintf( + 'DROP COLUMN %s', + $this->quoteColumnName($columnName) + ); + + return new AlterInstructions([$alter]); + } + + /** + * Get an array of indexes from a particular table. + * + * @param string $tableName Table name + * @return array + */ + protected function getIndexes(string $tableName): array + { + $parts = $this->getSchemaName($tableName); + + $indexes = []; + $sql = sprintf( + "SELECT + i.relname AS index_name, + a.attname AS column_name + FROM + pg_class t, + pg_class i, + pg_index ix, + pg_attribute a, + pg_namespace nsp + WHERE + t.oid = ix.indrelid + AND i.oid = ix.indexrelid + AND a.attrelid = t.oid + AND a.attnum = ANY(ix.indkey) + AND t.relnamespace = nsp.oid + AND nsp.nspname = %s + AND t.relkind = 'r' + AND t.relname = %s + ORDER BY + t.relname, + i.relname", + $this->quoteString($parts['schema']), + $this->quoteString($parts['table']) + ); + $rows = $this->fetchAll($sql); + foreach ($rows as $row) { + if (!isset($indexes[$row['index_name']])) { + $indexes[$row['index_name']] = ['columns' => []]; + } + $indexes[$row['index_name']]['columns'][] = $row['column_name']; + } + + return $indexes; + } + + /** + * @inheritDoc + */ + public function hasIndex(string $tableName, string|array $columns): bool + { + if (is_string($columns)) { + $columns = [$columns]; + } + $indexes = $this->getIndexes($tableName); + foreach ($indexes as $index) { + if (array_diff($index['columns'], $columns) === array_diff($columns, $index['columns'])) { + return true; + } + } + + return false; + } + + /** + * @inheritDoc + */ + public function hasIndexByName(string $tableName, string $indexName): bool + { + $indexes = $this->getIndexes($tableName); + foreach ($indexes as $name => $index) { + if ($name === $indexName) { + return true; + } + } + + return false; + } + + /** + * @inheritDoc + */ + protected function getAddIndexInstructions(Table $table, Index $index): AlterInstructions + { + $instructions = new AlterInstructions(); + $instructions->addPostStep($this->getIndexSqlDefinition($index, $table->getName())); + + return $instructions; + } + + /** + * {@inheritDoc} + * + * @throws \InvalidArgumentException + */ + protected function getDropIndexByColumnsInstructions(string $tableName, $columns): AlterInstructions + { + $parts = $this->getSchemaName($tableName); + + if (is_string($columns)) { + $columns = [$columns]; // str to array + } + + $indexes = $this->getIndexes($tableName); + foreach ($indexes as $indexName => $index) { + $a = array_diff($columns, $index['columns']); + if (empty($a)) { + return new AlterInstructions([], [sprintf( + 'DROP INDEX IF EXISTS %s', + '"' . ($parts['schema'] . '".' . $this->quoteColumnName($indexName)) + )]); + } + } + + throw new InvalidArgumentException(sprintf( + "The specified index on columns '%s' does not exist", + implode(',', $columns) + )); + } + + /** + * @inheritDoc + */ + protected function getDropIndexByNameInstructions(string $tableName, string $indexName): AlterInstructions + { + $parts = $this->getSchemaName($tableName); + + $sql = sprintf( + 'DROP INDEX IF EXISTS %s', + '"' . ($parts['schema'] . '".' . $this->quoteColumnName($indexName)) + ); + + return new AlterInstructions([], [$sql]); + } + + /** + * @inheritDoc + */ + public function hasPrimaryKey(string $tableName, $columns, ?string $constraint = null): bool + { + $primaryKey = $this->getPrimaryKey($tableName); + + if (empty($primaryKey)) { + return false; + } + + if ($constraint) { + return $primaryKey['constraint'] === $constraint; + } else { + if (is_string($columns)) { + $columns = [$columns]; // str to array + } + $missingColumns = array_diff($columns, $primaryKey['columns']); + + return empty($missingColumns); + } + } + + /** + * Get the primary key from a particular table. + * + * @param string $tableName Table name + * @return array + */ + public function getPrimaryKey(string $tableName): array + { + $parts = $this->getSchemaName($tableName); + $rows = $this->fetchAll(sprintf( + "SELECT + tc.constraint_name, + kcu.column_name + FROM information_schema.table_constraints AS tc + JOIN information_schema.key_column_usage AS kcu + ON tc.constraint_name = kcu.constraint_name + WHERE constraint_type = 'PRIMARY KEY' + AND tc.table_schema = %s + AND tc.table_name = %s + ORDER BY kcu.position_in_unique_constraint", + $this->quoteString($parts['schema']), + $this->quoteString($parts['table']) + )); + + $primaryKey = [ + 'columns' => [], + ]; + foreach ($rows as $row) { + $primaryKey['constraint'] = $row['constraint_name']; + $primaryKey['columns'][] = $row['column_name']; + } + + return $primaryKey; + } + + /** + * @inheritDoc + */ + public function hasForeignKey(string $tableName, $columns, ?string $constraint = null): bool + { + $foreignKeys = $this->getForeignKeys($tableName); + if ($constraint) { + if (isset($foreignKeys[$constraint])) { + return !empty($foreignKeys[$constraint]); + } + + return false; + } + + if (is_string($columns)) { + $columns = [$columns]; + } + + foreach ($foreignKeys as $key) { + if ($key['columns'] === $columns) { + return true; + } + } + + return false; + } + + /** + * Get an array of foreign keys from a particular table. + * + * @param string $tableName Table name + * @return array + */ + protected function getForeignKeys(string $tableName): array + { + $parts = $this->getSchemaName($tableName); + $foreignKeys = []; + $rows = $this->fetchAll(sprintf( + "SELECT + tc.constraint_name, + tc.table_name, kcu.column_name, + ccu.table_name AS referenced_table_name, + ccu.column_name AS referenced_column_name + FROM + information_schema.table_constraints AS tc + JOIN information_schema.key_column_usage AS kcu ON tc.constraint_name = kcu.constraint_name + JOIN information_schema.constraint_column_usage AS ccu ON ccu.constraint_name = tc.constraint_name + WHERE constraint_type = 'FOREIGN KEY' AND tc.table_schema = %s AND tc.table_name = %s + ORDER BY kcu.ordinal_position", + $this->quoteString($parts['schema']), + $this->quoteString($parts['table']) + )); + foreach ($rows as $row) { + $foreignKeys[$row['constraint_name']]['table'] = $row['table_name']; + $foreignKeys[$row['constraint_name']]['columns'][] = $row['column_name']; + $foreignKeys[$row['constraint_name']]['referenced_table'] = $row['referenced_table_name']; + $foreignKeys[$row['constraint_name']]['referenced_columns'][] = $row['referenced_column_name']; + } + foreach ($foreignKeys as $name => $key) { + $foreignKeys[$name]['columns'] = array_values(array_unique($key['columns'])); + $foreignKeys[$name]['referenced_columns'] = array_values(array_unique($key['referenced_columns'])); + } + + return $foreignKeys; + } + + /** + * @inheritDoc + */ + protected function getAddForeignKeyInstructions(Table $table, ForeignKey $foreignKey): AlterInstructions + { + $alter = sprintf( + 'ADD %s', + $this->getForeignKeySqlDefinition($foreignKey, $table->getName()) + ); + + return new AlterInstructions([$alter]); + } + + /** + * @inheritDoc + */ + protected function getDropForeignKeyInstructions($tableName, $constraint): AlterInstructions + { + $alter = sprintf( + 'DROP CONSTRAINT %s', + $this->quoteColumnName($constraint) + ); + + return new AlterInstructions([$alter]); + } + + /** + * @inheritDoc + */ + protected function getDropForeignKeyByColumnsInstructions(string $tableName, array $columns): AlterInstructions + { + $instructions = new AlterInstructions(); + + $matches = []; + $foreignKeys = $this->getForeignKeys($tableName); + foreach ($foreignKeys as $name => $key) { + if ($key['columns'] === $columns) { + $matches[] = $name; + } + } + + if (empty($matches)) { + throw new InvalidArgumentException(sprintf( + 'No foreign key on column(s) `%s` exists', + implode(', ', $columns) + )); + } + + foreach ($matches as $name) { + $instructions->merge( + $this->getDropForeignKeyInstructions($tableName, $name) + ); + } + + return $instructions; + } + + /** + * {@inheritDoc} + * + * @throws \Migrations\Db\Adapter\UnsupportedColumnTypeException + */ + public function getSqlType(Literal|string $type, ?int $limit = null): array + { + $type = (string)$type; + switch ($type) { + case static::PHINX_TYPE_TEXT: + case static::PHINX_TYPE_TIME: + case static::PHINX_TYPE_DATE: + case static::PHINX_TYPE_BOOLEAN: + case static::PHINX_TYPE_JSON: + case static::PHINX_TYPE_JSONB: + case static::PHINX_TYPE_UUID: + case static::PHINX_TYPE_CIDR: + case static::PHINX_TYPE_INET: + case static::PHINX_TYPE_MACADDR: + case static::PHINX_TYPE_TIMESTAMP: + case static::PHINX_TYPE_INTEGER: + return ['name' => $type]; + case static::PHINX_TYPE_TINY_INTEGER: + return ['name' => 'smallint']; + case static::PHINX_TYPE_SMALL_INTEGER: + return ['name' => 'smallint']; + case static::PHINX_TYPE_DECIMAL: + return ['name' => $type, 'precision' => 18, 'scale' => 0]; + case static::PHINX_TYPE_DOUBLE: + return ['name' => 'double precision']; + case static::PHINX_TYPE_STRING: + return ['name' => 'character varying', 'limit' => 255]; + case static::PHINX_TYPE_CHAR: + return ['name' => 'character', 'limit' => 255]; + case static::PHINX_TYPE_BIG_INTEGER: + return ['name' => 'bigint']; + case static::PHINX_TYPE_FLOAT: + return ['name' => 'real']; + case static::PHINX_TYPE_DATETIME: + return ['name' => 'timestamp']; + case static::PHINX_TYPE_BINARYUUID: + return ['name' => 'uuid']; + case static::PHINX_TYPE_BLOB: + case static::PHINX_TYPE_BINARY: + return ['name' => 'bytea']; + case static::PHINX_TYPE_INTERVAL: + return ['name' => 'interval']; + // Geospatial database types + // Spatial storage in Postgres is done via the PostGIS extension, + // which enables the use of the "geography" type in combination + // with SRID 4326. + case static::PHINX_TYPE_GEOMETRY: + return ['name' => 'geography', 'type' => 'geometry', 'srid' => 4326]; + case static::PHINX_TYPE_POINT: + return ['name' => 'geography', 'type' => 'point', 'srid' => 4326]; + case static::PHINX_TYPE_LINESTRING: + return ['name' => 'geography', 'type' => 'linestring', 'srid' => 4326]; + case static::PHINX_TYPE_POLYGON: + return ['name' => 'geography', 'type' => 'polygon', 'srid' => 4326]; + default: + if ($this->isArrayType($type)) { + return ['name' => $type]; + } + // Return array type + throw new UnsupportedColumnTypeException('Column type `' . $type . '` is not supported by Postgresql.'); + } + } + + /** + * Returns Phinx type by SQL type + * + * @param string $sqlType SQL type + * @throws \Migrations\Db\Adapter\UnsupportedColumnTypeException + * @return string Phinx type + */ + public function getPhinxType(string $sqlType): string + { + switch ($sqlType) { + case 'character varying': + case 'varchar': + return static::PHINX_TYPE_STRING; + case 'character': + case 'char': + return static::PHINX_TYPE_CHAR; + case 'text': + return static::PHINX_TYPE_TEXT; + case 'json': + return static::PHINX_TYPE_JSON; + case 'jsonb': + return static::PHINX_TYPE_JSONB; + case 'smallint': + return static::PHINX_TYPE_SMALL_INTEGER; + case 'int': + case 'int4': + case 'integer': + return static::PHINX_TYPE_INTEGER; + case 'decimal': + case 'numeric': + return static::PHINX_TYPE_DECIMAL; + case 'bigint': + case 'int8': + return static::PHINX_TYPE_BIG_INTEGER; + case 'real': + case 'float4': + return static::PHINX_TYPE_FLOAT; + case 'double precision': + return static::PHINX_TYPE_DOUBLE; + case 'bytea': + return static::PHINX_TYPE_BINARY; + case 'interval': + return static::PHINX_TYPE_INTERVAL; + case 'time': + case 'timetz': + case 'time with time zone': + case 'time without time zone': + return static::PHINX_TYPE_TIME; + case 'date': + return static::PHINX_TYPE_DATE; + case 'timestamp': + case 'timestamptz': + case 'timestamp with time zone': + case 'timestamp without time zone': + return static::PHINX_TYPE_DATETIME; + case 'bool': + case 'boolean': + return static::PHINX_TYPE_BOOLEAN; + case 'uuid': + return static::PHINX_TYPE_UUID; + case 'cidr': + return static::PHINX_TYPE_CIDR; + case 'inet': + return static::PHINX_TYPE_INET; + case 'macaddr': + return static::PHINX_TYPE_MACADDR; + default: + throw new UnsupportedColumnTypeException( + 'Column type `' . $sqlType . '` is not supported by Postgresql.' + ); + } + } + + /** + * @inheritDoc + */ + public function createDatabase(string $name, array $options = []): void + { + $charset = $options['charset'] ?? 'utf8'; + $this->execute(sprintf("CREATE DATABASE %s WITH ENCODING = '%s'", $name, $charset)); + } + + /** + * @inheritDoc + */ + public function hasDatabase(string $name): bool + { + $sql = sprintf("SELECT count(*) FROM pg_database WHERE datname = '%s'", $name); + $result = $this->fetchRow($sql); + if (!$result) { + return false; + } + + return $result['count'] > 0; + } + + /** + * @inheritDoc + */ + public function dropDatabase($name): void + { + $this->disconnect(); + $this->execute(sprintf('DROP DATABASE IF EXISTS %s', $name)); + $this->createdTables = []; + $this->connect(); + } + + /** + * Gets the PostgreSQL Column Definition for a Column object. + * + * @param \Migrations\Db\Table\Column $column Column + * @return string + */ + protected function getColumnSqlDefinition(Column $column): string + { + $buffer = []; + + if ($column->isIdentity() && (!$this->useIdentity || $column->getGenerated() === null)) { + if ($column->getType() === 'smallinteger') { + $buffer[] = 'SMALLSERIAL'; + } elseif ($column->getType() === 'biginteger') { + $buffer[] = 'BIGSERIAL'; + } else { + $buffer[] = 'SERIAL'; + } + } elseif ($column->getType() instanceof Literal) { + $buffer[] = (string)$column->getType(); + } else { + $sqlType = $this->getSqlType($column->getType(), $column->getLimit()); + $buffer[] = strtoupper($sqlType['name']); + + // integers cant have limits in postgres + if ($sqlType['name'] === static::PHINX_TYPE_DECIMAL && ($column->getPrecision() || $column->getScale())) { + $buffer[] = sprintf( + '(%s, %s)', + $column->getPrecision() ?: $sqlType['precision'], + $column->getScale() ?: $sqlType['scale'] + ); + } elseif ($sqlType['name'] === self::PHINX_TYPE_GEOMETRY) { + // geography type must be written with geometry type and srid, like this: geography(POLYGON,4326) + $buffer[] = sprintf( + '(%s,%s)', + strtoupper($sqlType['type']), + $column->getSrid() ?: $sqlType['srid'] + ); + } elseif (in_array($sqlType['name'], [self::PHINX_TYPE_TIME, self::PHINX_TYPE_TIMESTAMP], true)) { + if (is_numeric($column->getPrecision())) { + $buffer[] = sprintf('(%s)', (string)$column->getPrecision()); + } + + if ($column->isTimezone()) { + $buffer[] = strtoupper('with time zone'); + } + } elseif ( + !in_array($column->getType(), [ + self::PHINX_TYPE_TINY_INTEGER, + self::PHINX_TYPE_SMALL_INTEGER, + self::PHINX_TYPE_INTEGER, + self::PHINX_TYPE_BIG_INTEGER, + self::PHINX_TYPE_BOOLEAN, + self::PHINX_TYPE_TEXT, + self::PHINX_TYPE_BINARY, + ], true) + ) { + if ($column->getLimit() || isset($sqlType['limit'])) { + $buffer[] = sprintf('(%s)', $column->getLimit() ?: $sqlType['limit']); + } + } + } + + $buffer[] = $column->isNull() ? 'NULL' : 'NOT NULL'; + + if ($column->getDefault() !== null) { + $buffer[] = $this->getDefaultValueDefinition($column->getDefault(), (string)$column->getType()); + } + + return implode(' ', $buffer); + } + + /** + * Gets the PostgreSQL Column Comment Definition for a column object. + * + * @param \Migrations\Db\Table\Column $column Column + * @param string $tableName Table name + * @return string + */ + protected function getColumnCommentSqlDefinition(Column $column, string $tableName): string + { + $comment = (string)$column->getComment(); + // passing 'null' is to remove column comment + $comment = strcasecmp($comment, 'NULL') !== 0 + ? $this->quoteString($comment) + : 'NULL'; + + return sprintf( + 'COMMENT ON COLUMN %s.%s IS %s;', + $this->quoteTableName($tableName), + $this->quoteColumnName((string)$column->getName()), + $comment + ); + } + + /** + * Gets the PostgreSQL Index Definition for an Index object. + * + * @param \Migrations\Db\Table\Index $index Index + * @param string $tableName Table name + * @return string + */ + protected function getIndexSqlDefinition(Index $index, string $tableName): string + { + $parts = $this->getSchemaName($tableName); + $columnNames = (array)$index->getColumns(); + + if (is_string($index->getName())) { + $indexName = $index->getName(); + } else { + $indexName = sprintf('%s_%s', $parts['table'], implode('_', $columnNames)); + } + + $order = $index->getOrder() ?? []; + $columnNames = array_map(function ($columnName) use ($order) { + $ret = '"' . $columnName . '"'; + if (isset($order[$columnName])) { + $ret .= ' ' . $order[$columnName]; + } + + return $ret; + }, $columnNames); + + $include = $index->getInclude(); + $includedColumns = $include ? sprintf('INCLUDE ("%s")', implode('","', $include)) : ''; + + $createIndexSentence = 'CREATE %s INDEX %s ON %s '; + if ($index->getType() === self::GIN_INDEX_TYPE) { + $createIndexSentence .= ' USING ' . $index->getType() . '(%s) %s;'; + } else { + $createIndexSentence .= '(%s) %s;'; + } + + return sprintf( + $createIndexSentence, + ($index->getType() === Index::UNIQUE ? 'UNIQUE' : ''), + $this->quoteColumnName((string)$indexName), + $this->quoteTableName($tableName), + implode(',', $columnNames), + $includedColumns + ); + } + + /** + * Gets the MySQL Foreign Key Definition for an ForeignKey object. + * + * @param \Migrations\Db\Table\ForeignKey $foreignKey Foreign key + * @param string $tableName Table name + * @return string + */ + protected function getForeignKeySqlDefinition(ForeignKey $foreignKey, string $tableName): string + { + $parts = $this->getSchemaName($tableName); + + $constraintName = $foreignKey->getConstraint() ?: ( + $parts['table'] . '_' . implode('_', $foreignKey->getColumns()) . '_fkey' + ); + $def = ' CONSTRAINT ' . $this->quoteColumnName($constraintName) . + ' FOREIGN KEY ("' . implode('", "', $foreignKey->getColumns()) . '")' . + " REFERENCES {$this->quoteTableName($foreignKey->getReferencedTable()->getName())} (\"" . + implode('", "', $foreignKey->getReferencedColumns()) . '")'; + if ($foreignKey->getOnDelete()) { + $def .= " ON DELETE {$foreignKey->getOnDelete()}"; + } + if ($foreignKey->getOnUpdate()) { + $def .= " ON UPDATE {$foreignKey->getOnUpdate()}"; + } + + return $def; + } + + /** + * @inheritDoc + */ + public function createSchemaTable(): void + { + // Create the public/custom schema if it doesn't already exist + if ($this->hasSchema($this->getGlobalSchemaName()) === false) { + $this->createSchema($this->getGlobalSchemaName()); + } + + $this->setSearchPath(); + + parent::createSchemaTable(); + } + + /** + * @inheritDoc + */ + public function getVersions(): array + { + $this->setSearchPath(); + + return parent::getVersions(); + } + + /** + * @inheritDoc + */ + public function getVersionLog(): array + { + $this->setSearchPath(); + + return parent::getVersionLog(); + } + + /** + * Creates the specified schema. + * + * @param string $schemaName Schema Name + * @return void + */ + public function createSchema(string $schemaName = 'public'): void + { + // from postgres 9.3 we can use "CREATE SCHEMA IF NOT EXISTS schema_name" + $sql = sprintf('CREATE SCHEMA IF NOT EXISTS %s', $this->quoteSchemaName($schemaName)); + $this->execute($sql); + } + + /** + * Checks to see if a schema exists. + * + * @param string $schemaName Schema Name + * @return bool + */ + public function hasSchema(string $schemaName): bool + { + $sql = sprintf( + 'SELECT count(*) + FROM pg_namespace + WHERE nspname = %s', + $this->quoteString($schemaName) + ); + $result = $this->fetchRow($sql); + if (!$result) { + return false; + } + + return $result['count'] > 0; + } + + /** + * Drops the specified schema table. + * + * @param string $schemaName Schema name + * @return void + */ + public function dropSchema(string $schemaName): void + { + $sql = sprintf('DROP SCHEMA IF EXISTS %s CASCADE', $this->quoteSchemaName($schemaName)); + $this->execute($sql); + + foreach ($this->createdTables as $idx => $createdTable) { + if ($this->getSchemaName($createdTable)['schema'] === $this->quoteSchemaName($schemaName)) { + unset($this->createdTables[$idx]); + } + } + } + + /** + * Drops all schemas. + * + * @return void + */ + public function dropAllSchemas(): void + { + foreach ($this->getAllSchemas() as $schema) { + $this->dropSchema($schema); + } + } + + /** + * Returns schemas. + * + * @return array + */ + public function getAllSchemas(): array + { + $sql = "SELECT schema_name + FROM information_schema.schemata + WHERE schema_name <> 'information_schema' AND schema_name !~ '^pg_'"; + $items = $this->fetchAll($sql); + $schemaNames = []; + foreach ($items as $item) { + $schemaNames[] = $item['schema_name']; + } + + return $schemaNames; + } + + /** + * @inheritDoc + */ + public function getColumnTypes(): array + { + return array_merge(parent::getColumnTypes(), static::$specificColumnTypes); + } + + /** + * @inheritDoc + */ + public function isValidColumnType(Column $column): bool + { + // If not a standard column type, maybe it is array type? + return parent::isValidColumnType($column) || $this->isArrayType($column->getType()); + } + + /** + * Check if the given column is an array of a valid type. + * + * @param string|\Migrations\Db\Literal $columnType Column type + * @return bool + */ + protected function isArrayType(string|Literal $columnType): bool + { + if (!preg_match('/^([a-z]+)(?:\[\]){1,}$/', (string)$columnType, $matches)) { + return false; + } + + $baseType = $matches[1]; + + return in_array($baseType, $this->getColumnTypes(), true); + } + + /** + * @param string $tableName Table name + * @return array + */ + protected function getSchemaName(string $tableName): array + { + $schema = $this->getGlobalSchemaName(); + $table = $tableName; + if (strpos($tableName, '.') !== false) { + [$schema, $table] = explode('.', $tableName); + } + + return [ + 'schema' => $schema, + 'table' => $table, + ]; + } + + /** + * Gets the schema name. + * + * @return string + */ + protected function getGlobalSchemaName(): string + { + $options = $this->getOptions(); + + return empty($options['schema']) ? 'public' : $options['schema']; + } + + /** + * @inheritDoc + */ + public function castToBool($value): mixed + { + return (bool)$value ? 'TRUE' : 'FALSE'; + } + + /** + * Sets search path of schemas to look through for a table + * + * @return void + */ + public function setSearchPath(): void + { + $this->execute( + sprintf( + 'SET search_path TO %s,"$user",public', + $this->quoteSchemaName($this->getGlobalSchemaName()) + ) + ); + } + + /** + * @inheritDoc + */ + public function insert(Table $table, array $row): void + { + $sql = sprintf( + 'INSERT INTO %s ', + $this->quoteTableName($table->getName()) + ); + $columns = array_keys($row); + $sql .= '(' . implode(', ', array_map([$this, 'quoteColumnName'], $columns)) . ')'; + + foreach ($row as $column => $value) { + if (is_bool($value)) { + $row[$column] = $this->castToBool($value); + } + } + + $override = ''; + if ($this->useIdentity) { + $override = self::OVERRIDE_SYSTEM_VALUE . ' '; + } + + if ($this->isDryRunEnabled()) { + $sql .= ' ' . $override . 'VALUES (' . implode(', ', array_map([$this, 'quoteValue'], $row)) . ');'; + $this->io->out($sql); + } else { + $sql .= ' ' . $override . 'VALUES (' . implode(', ', array_fill(0, count($columns), '?')) . ')'; + $this->getConnection()->execute($sql, array_values($row)); + } + } + + /** + * @inheritDoc + */ + public function bulkinsert(Table $table, array $rows): void + { + $sql = sprintf( + 'INSERT INTO %s ', + $this->quoteTableName($table->getName()) + ); + $current = current($rows); + $keys = array_keys($current); + + $override = ''; + if ($this->useIdentity) { + $override = self::OVERRIDE_SYSTEM_VALUE . ' '; + } + + $callback = fn ($key) => $this->quoteColumnName($key); + $sql .= '(' . implode(', ', array_map($callback, $keys)) . ') ' . $override . 'VALUES '; + + if ($this->isDryRunEnabled()) { + $values = array_map(function ($row) { + return '(' . implode(', ', array_map([$this, 'quoteValue'], $row)) . ')'; + }, $rows); + $sql .= implode(', ', $values) . ';'; + $this->io->out($sql); + } else { + $connection = $this->getConnection(); + $count_keys = count($keys); + $query = '(' . implode(', ', array_fill(0, $count_keys, '?')) . ')'; + $count_vars = count($rows); + $queries = array_fill(0, $count_vars, $query); + $sql .= implode(',', $queries); + $vals = []; + + foreach ($rows as $row) { + foreach ($row as $v) { + if (is_bool($v)) { + $vals[] = $this->castToBool($v); + } else { + $vals[] = $v; + } + } + } + + $connection->execute($sql, $vals); + } + } +} 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/SqliteAdapter.php b/src/Db/Adapter/SqliteAdapter.php new file mode 100644 index 00000000..ab66ba69 --- /dev/null +++ b/src/Db/Adapter/SqliteAdapter.php @@ -0,0 +1,1923 @@ + 'biginteger', + self::PHINX_TYPE_BINARY => 'binary_blob', + self::PHINX_TYPE_BINARYUUID => 'uuid_blob', + self::PHINX_TYPE_BLOB => 'blob', + self::PHINX_TYPE_BOOLEAN => 'boolean_integer', + self::PHINX_TYPE_CHAR => 'char', + self::PHINX_TYPE_DATE => 'date_text', + self::PHINX_TYPE_DATETIME => 'datetime_text', + self::PHINX_TYPE_DECIMAL => 'decimal', + self::PHINX_TYPE_DOUBLE => 'double', + self::PHINX_TYPE_FLOAT => 'float', + self::PHINX_TYPE_INTEGER => 'integer', + self::PHINX_TYPE_JSON => 'json_text', + self::PHINX_TYPE_JSONB => 'jsonb_text', + self::PHINX_TYPE_SMALL_INTEGER => 'smallinteger', + self::PHINX_TYPE_STRING => 'varchar', + self::PHINX_TYPE_TEXT => 'text', + self::PHINX_TYPE_TIME => 'time_text', + self::PHINX_TYPE_TIMESTAMP => 'timestamp_text', + self::PHINX_TYPE_TINY_INTEGER => 'tinyinteger', + self::PHINX_TYPE_UUID => 'uuid_text', + self::PHINX_TYPE_VARBINARY => 'varbinary_blob', + ]; + + /** + * List of aliases of supported column types + * + * @var string[] + */ + protected static array $supportedColumnTypeAliases = [ + 'varchar' => self::PHINX_TYPE_STRING, + 'tinyint' => self::PHINX_TYPE_TINY_INTEGER, + 'tinyinteger' => self::PHINX_TYPE_TINY_INTEGER, + 'smallint' => self::PHINX_TYPE_SMALL_INTEGER, + 'int' => self::PHINX_TYPE_INTEGER, + 'mediumint' => self::PHINX_TYPE_INTEGER, + 'mediuminteger' => self::PHINX_TYPE_INTEGER, + 'bigint' => self::PHINX_TYPE_BIG_INTEGER, + 'tinytext' => self::PHINX_TYPE_TEXT, + 'mediumtext' => self::PHINX_TYPE_TEXT, + 'longtext' => self::PHINX_TYPE_TEXT, + 'tinyblob' => self::PHINX_TYPE_BLOB, + 'mediumblob' => self::PHINX_TYPE_BLOB, + 'longblob' => self::PHINX_TYPE_BLOB, + 'real' => self::PHINX_TYPE_FLOAT, + ]; + + /** + * List of known but unsupported Phinx column types + * + * @var string[] + */ + protected static array $unsupportedColumnTypes = [ + self::PHINX_TYPE_BIT, + self::PHINX_TYPE_CIDR, + self::PHINX_TYPE_ENUM, + self::PHINX_TYPE_FILESTREAM, + self::PHINX_TYPE_GEOMETRY, + self::PHINX_TYPE_INET, + self::PHINX_TYPE_INTERVAL, + self::PHINX_TYPE_LINESTRING, + self::PHINX_TYPE_MACADDR, + self::PHINX_TYPE_POINT, + self::PHINX_TYPE_POLYGON, + self::PHINX_TYPE_SET, + ]; + + /** + * @var string[] + */ + protected array $definitionsWithLimits = [ + 'CHAR', + 'CHARACTER', + 'VARCHAR', + 'VARYING CHARACTER', + 'NCHAR', + 'NATIVE CHARACTER', + 'NVARCHAR', + ]; + + /** + * @var string + */ + protected string $suffix = '.sqlite3'; + + /** + * Indicates whether the database library version is at least the specified version + * + * @param string $ver The version to check against e.g. '3.28.0' + * @return bool + */ + public function databaseVersionAtLeast(string $ver): bool + { + $actual = $this->query('SELECT sqlite_version()')->fetchColumn(0); + + return version_compare($actual, $ver, '>='); + } + + /** + * {@inheritDoc} + * + * @throws \RuntimeException + * @throws \InvalidArgumentException + * @return void + */ + public function connect(): void + { + $this->getConnection()->getDriver()->connect(); + $this->setConnection($this->getConnection()); + } + + /** + * @inheritDoc + */ + public function setOptions(array $options): AdapterInterface + { + parent::setOptions($options); + + if (isset($options['suffix'])) { + $this->suffix = $options['suffix']; + } + //don't "fix" the file extension if it is blank, some people + //might want a SQLITE db file with absolutely no extension. + if ($this->suffix !== '' && strpos($this->suffix, '.') !== 0) { + $this->suffix = '.' . $this->suffix; + } + + return $this; + } + + /** + * @inheritDoc + */ + public function disconnect(): void + { + $this->getConnection()->getDriver()->disconnect(); + } + + /** + * @inheritDoc + */ + public function hasTransactions(): bool + { + return true; + } + + /** + * @inheritDoc + */ + public function beginTransaction(): void + { + $this->getConnection()->begin(); + } + + /** + * @inheritDoc + */ + public function commitTransaction(): void + { + $this->getConnection()->commit(); + } + + /** + * @inheritDoc + */ + public function rollbackTransaction(): void + { + $this->getConnection()->rollBack(); + } + + /** + * @inheritDoc + */ + public function quoteTableName($tableName): string + { + return str_replace('.', '`.`', $this->quoteColumnName($tableName)); + } + + /** + * @inheritDoc + */ + public function quoteColumnName($columnName): string + { + return '`' . str_replace('`', '``', $columnName) . '`'; + } + + /** + * Generates a regular expression to match identifiers that may or + * may not be quoted with any of the supported quotes. + * + * @param string $identifier The identifier to match. + * @param bool $spacedNoQuotes Whether the non-quoted identifier requires to be surrounded by whitespace. + * @return string + */ + protected function possiblyQuotedIdentifierRegex(string $identifier, bool $spacedNoQuotes = true): string + { + $identifiers = []; + $identifier = preg_quote($identifier, '/'); + + $hasTick = str_contains($identifier, '`'); + $hasDoubleQuote = str_contains($identifier, '"'); + $hasSingleQuote = str_contains($identifier, "'"); + + $identifiers[] = '\[' . $identifier . '\]'; + $identifiers[] = '`' . ($hasTick ? str_replace('`', '``', $identifier) : $identifier) . '`'; + $identifiers[] = '"' . ($hasDoubleQuote ? str_replace('"', '""', $identifier) : $identifier) . '"'; + $identifiers[] = "'" . ($hasSingleQuote ? str_replace("'", "''", $identifier) : $identifier) . "'"; + + if (!$hasTick && !$hasDoubleQuote && !$hasSingleQuote) { + if ($spacedNoQuotes) { + $identifiers[] = "\s+$identifier\s+"; + } else { + $identifiers[] = $identifier; + } + } + + return '(' . implode('|', $identifiers) . ')'; + } + + /** + * @param string $tableName Table name + * @param bool $quoted Whether to return the schema name and table name escaped and quoted. If quoted, the schema (if any) will also be appended with a dot + * @return array + */ + protected function getSchemaName(string $tableName, bool $quoted = false): array + { + if (preg_match("/.\.([^\.]+)$/", $tableName, $match)) { + $table = $match[1]; + $schema = substr($tableName, 0, strlen($tableName) - strlen($match[0]) + 1); + $result = ['schema' => $schema, 'table' => $table]; + } else { + $result = ['schema' => '', 'table' => $tableName]; + } + + if ($quoted) { + $result['schema'] = $result['schema'] !== '' ? $this->quoteColumnName($result['schema']) . '.' : ''; + $result['table'] = $this->quoteColumnName($result['table']); + } + + return $result; + } + + /** + * Retrieves information about a given table from one of the SQLite pragmas + * + * @param string $tableName The table to query + * @param string $pragma The pragma to query + * @return array + */ + protected function getTableInfo(string $tableName, string $pragma = 'table_info'): array + { + $info = $this->getSchemaName($tableName, true); + + return $this->fetchAll(sprintf('PRAGMA %s%s(%s)', $info['schema'], $pragma, $info['table'])); + } + + /** + * Searches through all available schemata to find a table and returns an array + * containing the bare schema name and whether the table exists at all. + * If no schema was specified and the table does not exist the "main" schema is returned + * + * @param string $tableName The name of the table to find + * @return array + */ + protected function resolveTable(string $tableName): array + { + $info = $this->getSchemaName($tableName); + if ($info['schema'] === '') { + // if no schema is specified we search all schemata + $rows = $this->fetchAll('PRAGMA database_list;'); + // the temp schema is always first to be searched + $schemata = ['temp']; + foreach ($rows as $row) { + if (strtolower($row['name']) !== 'temp') { + $schemata[] = $row['name']; + } + } + $defaultSchema = 'main'; + } else { + // otherwise we search just the specified schema + $schemata = (array)$info['schema']; + $defaultSchema = $info['schema']; + } + + $table = strtolower($info['table']); + foreach ($schemata as $schema) { + if (strtolower($schema) === 'temp') { + $master = 'sqlite_temp_master'; + } else { + $master = sprintf('%s.%s', $this->quoteColumnName($schema), 'sqlite_master'); + } + try { + $rows = $this->fetchAll(sprintf("SELECT name FROM %s WHERE type='table' AND lower(name) = %s", $master, $this->quoteString($table))); + } catch (PDOException $e) { + // an exception can occur if the schema part of the table refers to a database which is not attached + break; + } + + // this somewhat pedantic check with strtolower is performed because the SQL lower function may be redefined, + // and can act on all Unicode characters if the ICU extension is loaded, while SQL identifiers are only case-insensitive for ASCII + foreach ($rows as $row) { + if (strtolower($row['name']) === $table) { + return ['schema' => $schema, 'table' => $row['name'], 'exists' => true]; + } + } + } + + return ['schema' => $defaultSchema, 'table' => $info['table'], 'exists' => false]; + } + + /** + * @inheritDoc + */ + public function hasTable(string $tableName): bool + { + return $this->hasCreatedTable($tableName) || $this->resolveTable($tableName)['exists']; + } + + /** + * @inheritDoc + */ + public function createTable(Table $table, array $columns = [], array $indexes = []): void + { + // Add the default primary key + $options = $table->getOptions(); + if (!isset($options['id']) || (isset($options['id']) && $options['id'] === true)) { + $options['id'] = 'id'; + } + + if (isset($options['id']) && is_string($options['id'])) { + // Handle id => "field_name" to support AUTO_INCREMENT + $column = new Column(); + $column->setName($options['id']) + ->setType('integer') + ->setOptions(['identity' => true]); + + array_unshift($columns, $column); + } + + $sql = 'CREATE TABLE '; + $sql .= $this->quoteTableName($table->getName()) . ' ('; + foreach ($columns as $column) { + $sql .= $this->quoteColumnName((string)$column->getName()) . ' ' . $this->getColumnSqlDefinition($column) . ', '; + + if (isset($options['primary_key']) && $column->getIdentity()) { + //remove column from the primary key array as it is already defined as an autoincrement + //primary id + $identityColumnIndex = array_search($column->getName(), $options['primary_key'], true); + if ($identityColumnIndex !== false) { + unset($options['primary_key'][$identityColumnIndex]); + + if (empty($options['primary_key'])) { + //The last primary key has been removed + unset($options['primary_key']); + } + } + } + } + + // set the primary key(s) + if (isset($options['primary_key'])) { + $sql = rtrim($sql); + $sql .= ' PRIMARY KEY ('; + if (is_string($options['primary_key'])) { // handle primary_key => 'id' + $sql .= $this->quoteColumnName($options['primary_key']); + } elseif (is_array($options['primary_key'])) { // handle primary_key => array('tag_id', 'resource_id') + $sql .= implode(',', array_map([$this, 'quoteColumnName'], $options['primary_key'])); + } + $sql .= ')'; + } else { + $sql = substr(rtrim($sql), 0, -1); // no primary keys + } + + $sql = rtrim($sql) . ');'; + // execute the sql + $this->execute($sql); + + foreach ($indexes as $index) { + $this->addIndex($table, $index); + } + + $this->addCreatedTable($table->getName()); + } + + /** + * {@inheritDoc} + * + * @throws \InvalidArgumentException + */ + protected function getChangePrimaryKeyInstructions(Table $table, $newColumns): AlterInstructions + { + $instructions = new AlterInstructions(); + + // Drop the existing primary key + $primaryKey = $this->getPrimaryKey($table->getName()); + if (!empty($primaryKey)) { + $instructions->merge( + // FIXME: array access is a hack to make this incomplete implementation work with a correct getPrimaryKey implementation + $this->getDropPrimaryKeyInstructions($table, $primaryKey[0]) + ); + } + + // Add the primary key(s) + if (!empty($newColumns)) { + if (!is_string($newColumns)) { + throw new InvalidArgumentException(sprintf( + 'Invalid value for primary key: %s', + json_encode($newColumns) + )); + } + + $instructions->merge( + $this->getAddPrimaryKeyInstructions($table, $newColumns) + ); + } + + return $instructions; + } + + /** + * {@inheritDoc} + * + * SQLiteAdapter does not implement this functionality, and so will always throw an exception if used. + * + * @throws \BadMethodCallException + */ + protected function getChangeCommentInstructions(Table $table, $newComment): AlterInstructions + { + throw new BadMethodCallException('SQLite does not have table comments'); + } + + /** + * @inheritDoc + */ + protected function getRenameTableInstructions(string $tableName, string $newTableName): AlterInstructions + { + $this->updateCreatedTableName($tableName, $newTableName); + $sql = sprintf( + 'ALTER TABLE %s RENAME TO %s', + $this->quoteTableName($tableName), + $this->quoteTableName($newTableName) + ); + + return new AlterInstructions([], [$sql]); + } + + /** + * @inheritDoc + */ + protected function getDropTableInstructions(string $tableName): AlterInstructions + { + $this->removeCreatedTable($tableName); + $sql = sprintf('DROP TABLE %s', $this->quoteTableName($tableName)); + + return new AlterInstructions([], [$sql]); + } + + /** + * @inheritDoc + */ + public function truncateTable(string $tableName): void + { + $info = $this->resolveTable($tableName); + // first try deleting the rows + $this->execute(sprintf( + 'DELETE FROM %s.%s', + $this->quoteColumnName($info['schema']), + $this->quoteColumnName($info['table']) + )); + + // assuming no error occurred, reset the autoincrement (if any) + if ($this->hasTable($info['schema'] . '.sqlite_sequence')) { + $this->execute(sprintf( + 'DELETE FROM %s.%s where name = %s', + $this->quoteColumnName($info['schema']), + 'sqlite_sequence', + $this->quoteString($info['table']) + )); + } + } + + /** + * Parses a default-value expression to yield either a Literal representing + * a string value, a string representing an expression, or some other scalar + * + * @param mixed $default The default-value expression to interpret + * @param string $columnType The Phinx type of the column + * @return mixed + */ + protected function parseDefaultValue(mixed $default, string $columnType): mixed + { + if ($default === null) { + return null; + } + + // split the input into tokens + $trimChars = " \t\n\r\0\x0B"; + $pattern = <<getTableInfo($tableName) as $col) { + $type = strtolower($col['type']); + if ($col['pk'] > 1) { + // the table has a composite primary key + return null; + } elseif ($col['pk'] == 0) { + // the column is not a primary key column and is thus not relevant + continue; + } elseif ($type !== 'integer') { + // if the primary key's type is not exactly INTEGER, it cannot be a row ID alias + return null; + } else { + // the column is a candidate for a row ID alias + $result = $col['name']; + } + } + // if there is no suitable PK column, stop now + if ($result === null) { + return null; + } + // make sure the table does not have a PK-origin autoindex + // such an autoindex would indicate either that the primary key was specified as descending, or that this is a WITHOUT ROWID table + foreach ($this->getTableInfo($tableName, 'index_list') as $idx) { + if ($idx['origin'] === 'pk') { + return null; + } + } + + return $result; + } + + /** + * @inheritDoc + */ + public function getColumns(string $tableName): array + { + $columns = []; + + $rows = $this->getTableInfo($tableName); + $identity = $this->resolveIdentity($tableName); + + foreach ($rows as $columnInfo) { + $column = new Column(); + $type = $this->getPhinxType($columnInfo['type']); + $default = $this->parseDefaultValue($columnInfo['dflt_value'], $type['name']); + + $column->setName($columnInfo['name']) + // SQLite on PHP 8.1 returns int for notnull, older versions return a string + ->setNull((int)$columnInfo['notnull'] !== 1) + ->setDefault($default) + ->setType($type['name']) + ->setLimit($type['limit']) + ->setScale($type['scale']) + ->setIdentity($columnInfo['name'] === $identity); + + $columns[] = $column; + } + + return $columns; + } + + /** + * @inheritDoc + */ + public function hasColumn(string $tableName, string $columnName): bool + { + $rows = $this->getTableInfo($tableName); + foreach ($rows as $column) { + if (strcasecmp($column['name'], $columnName) === 0) { + return true; + } + } + + return false; + } + + /** + * @inheritDoc + */ + protected function getAddColumnInstructions(Table $table, Column $column): AlterInstructions + { + $tableName = $table->getName(); + + $instructions = $this->beginAlterByCopyTable($tableName); + + $instructions->addPostStep(function ($state) use ($tableName, $column) { + // we use the final column to anchor our regex to insert the new column, + // as the alternative is unwinding all possible table constraints which + // gets messy quickly with CHECK constraints. + $columns = $this->getColumns($tableName); + if (!$columns) { + return $state; + } + $finalColumnName = end($columns)->getName(); + $sql = preg_replace( + sprintf( + "/(%s(?:\/\*.*?\*\/|\([^)]+\)|'[^']*?'|[^,])+)([,)])/", + $this->quoteColumnName((string)$finalColumnName) + ), + sprintf( + '$1, %s %s$2', + $this->quoteColumnName((string)$column->getName()), + $this->getColumnSqlDefinition($column) + ), + (string)$state['createSQL'], + 1 + ); + $this->execute($sql); + + return $state; + }); + + $instructions->addPostStep(function ($state) use ($tableName) { + $newState = $this->calculateNewTableColumns($tableName, false, false); + + return $newState + $state; + }); + + return $this->endAlterByCopyTable($instructions, $tableName); + } + + /** + * Returns the original CREATE statement for the give table + * + * @param string $tableName The table name to get the create statement for + * @return string + */ + protected function getDeclaringSql(string $tableName): string + { + $rows = $this->fetchAll("SELECT * FROM sqlite_master WHERE `type` = 'table'"); + + $sql = ''; + foreach ($rows as $table) { + if ($table['tbl_name'] === $tableName) { + $sql = $table['sql']; + } + } + + $columnsInfo = $this->getTableInfo($tableName); + + foreach ($columnsInfo as $column) { + $columnName = preg_quote($column['name'], '#'); + $columnNamePattern = "\"$columnName\"|`$columnName`|\\[$columnName\\]|$columnName"; + $columnNamePattern = "#([\(,]+\\s*)($columnNamePattern)(\\s)#iU"; + + $sql = preg_replace($columnNamePattern, "$1`{$column['name']}`$3", $sql); + } + + $tableNamePattern = "\"$tableName\"|`$tableName`|\\[$tableName\\]|$tableName"; + $tableNamePattern = "#^(CREATE TABLE)\s*($tableNamePattern)\s*(\()#Ui"; + + $sql = preg_replace($tableNamePattern, "$1 `$tableName` $3", $sql, 1); + + return $sql; + } + + /** + * Returns the original CREATE statement for the give index + * + * @param string $tableName The table name to get the create statement for + * @param string $indexName The table index + * @return string + */ + protected function getDeclaringIndexSql(string $tableName, string $indexName): string + { + $rows = $this->fetchAll("SELECT * FROM sqlite_master WHERE `type` = 'index'"); + + $sql = ''; + foreach ($rows as $table) { + if ($table['tbl_name'] === $tableName && $table['name'] === $indexName) { + $sql = $table['sql'] . '; '; + } + } + + return $sql; + } + + /** + * Obtains index and trigger information for a table. + * + * They will be stored in the state as arrays under the `indices` and `triggers` + * keys accordingly. + * + * Index columns defined as expressions, as for example in `ON (ABS(id), other)`, + * will appear as `null`, so for the given example the columns for the index would + * look like `[null, 'other']`. + * + * @param \Migrations\Db\AlterInstructions $instructions The instructions to modify + * @param string $tableName The name of table being processed + * @return \Migrations\Db\AlterInstructions + */ + protected function bufferIndicesAndTriggers(AlterInstructions $instructions, string $tableName): AlterInstructions + { + $instructions->addPostStep(function (array $state) use ($tableName): array { + $state['indices'] = []; + $state['triggers'] = []; + + $rows = $this->fetchAll( + sprintf( + " + SELECT * + FROM sqlite_master + WHERE + (`type` = 'index' OR `type` = 'trigger') + AND tbl_name = %s + AND sql IS NOT NULL + ", + $this->quoteValue($tableName) + ) + ); + + $schema = $this->getSchemaName($tableName, true)['schema']; + + foreach ($rows as $row) { + switch ($row['type']) { + case 'index': + $info = $this->fetchAll( + sprintf('PRAGMA %sindex_info(%s)', $schema, $this->quoteValue($row['name'])) + ); + + $columns = array_map( + function ($column) { + if ($column === null) { + return null; + } + + return strtolower($column); + }, + array_column($info, 'name') + ); + $hasExpressions = in_array(null, $columns, true); + + $index = [ + 'columns' => $columns, + 'hasExpressions' => $hasExpressions, + ]; + + $state['indices'][] = $index + $row; + break; + + case 'trigger': + $state['triggers'][] = $row; + break; + } + } + + return $state; + }); + + return $instructions; + } + + /** + * Filters out indices that reference a removed column. + * + * @param \Migrations\Db\AlterInstructions $instructions The instructions to modify + * @param string $columnName The name of the removed column + * @return \Migrations\Db\AlterInstructions + */ + protected function filterIndicesForRemovedColumn( + AlterInstructions $instructions, + string $columnName + ): AlterInstructions { + $instructions->addPostStep(function (array $state) use ($columnName): array { + foreach ($state['indices'] as $key => $index) { + if ( + !$index['hasExpressions'] && + in_array(strtolower($columnName), $index['columns'], true) + ) { + unset($state['indices'][$key]); + } + } + + return $state; + }); + + return $instructions; + } + + /** + * Updates indices that reference a renamed column. + * + * @param \Migrations\Db\AlterInstructions $instructions The instructions to modify + * @param string $oldColumnName The old column name + * @param string $newColumnName The new column name + * @return \Migrations\Db\AlterInstructions + */ + protected function updateIndicesForRenamedColumn( + AlterInstructions $instructions, + string $oldColumnName, + string $newColumnName + ): AlterInstructions { + $instructions->addPostStep(function (array $state) use ($oldColumnName, $newColumnName): array { + foreach ($state['indices'] as $key => $index) { + if ( + !$index['hasExpressions'] && + in_array(strtolower($oldColumnName), $index['columns'], true) + ) { + $pattern = ' + / + (INDEX.+?ON\s.+?) + (\(\s*|,\s*) # opening parenthesis or comma + (?:`|"|\[)? # optional opening quote + (%s) # column name + (?:`|"|\])? # optional closing quote + (\s+COLLATE\s+.+?)? # optional collation + (\s+(?:ASC|DESC))? # optional order + (\s*,|\s*\)) # comma or closing parenthesis + /isx'; + + $newColumnName = $this->quoteColumnName($newColumnName); + + $state['indices'][$key]['sql'] = preg_replace( + sprintf($pattern, preg_quote($oldColumnName, '/')), + "\\1\\2$newColumnName\\4\\5\\6", + $index['sql'] + ); + } + } + + return $state; + }); + + return $instructions; + } + + /** + * Recreates indices and triggers. + * + * @param \Migrations\Db\AlterInstructions $instructions The instructions to process + * @return \Migrations\Db\AlterInstructions + */ + protected function recreateIndicesAndTriggers(AlterInstructions $instructions): AlterInstructions + { + $instructions->addPostStep(function (array $state): array { + foreach ($state['indices'] as $index) { + $this->execute($index['sql']); + } + + foreach ($state['triggers'] as $trigger) { + $this->execute($trigger['sql']); + } + + return $state; + }); + + return $instructions; + } + + /** + * Returns instructions for validating the foreign key constraints of + * the given table, and of those tables whose constraints are + * targeting it. + * + * @param \Migrations\Db\AlterInstructions $instructions The instructions to process + * @param string $tableName The name of the table for which to check constraints. + * @return \Migrations\Db\AlterInstructions + */ + protected function validateForeignKeys(AlterInstructions $instructions, string $tableName): AlterInstructions + { + $instructions->addPostStep(function ($state) use ($tableName) { + $tablesToCheck = [ + $tableName, + ]; + + $otherTables = $this + ->query( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name != ?", + [$tableName] + ) + ->fetchAll('assoc'); + + foreach ($otherTables as $otherTable) { + $foreignKeyList = $this->getTableInfo($otherTable['name'], 'foreign_key_list'); + foreach ($foreignKeyList as $foreignKey) { + if (strcasecmp($foreignKey['table'], $tableName) === 0) { + $tablesToCheck[] = $otherTable['name']; + break; + } + } + } + + $tablesToCheck = array_unique(array_map('strtolower', $tablesToCheck)); + + foreach ($tablesToCheck as $tableToCheck) { + $schema = $this->getSchemaName($tableToCheck, true)['schema']; + + $stmt = $this->query( + sprintf('PRAGMA %sforeign_key_check(%s)', $schema, $this->quoteTableName($tableToCheck)) + ); + $row = $stmt->fetch(); + $stmt->closeCursor(); + + if (is_array($row)) { + throw new RuntimeException(sprintf( + 'Integrity constraint violation: FOREIGN KEY constraint on `%s` failed.', + $tableToCheck + )); + } + } + + return $state; + }); + + return $instructions; + } + + /** + * Copies all the data from a tmp table to another table + * + * @param string $tableName The table name to copy the data to + * @param string $tmpTableName The tmp table name where the data is stored + * @param string[] $writeColumns The list of columns in the target table + * @param string[] $selectColumns The list of columns in the tmp table + * @return void + */ + protected function copyDataToNewTable(string $tableName, string $tmpTableName, array $writeColumns, array $selectColumns): void + { + $sql = sprintf( + 'INSERT INTO %s(%s) SELECT %s FROM %s', + $this->quoteTableName($tableName), + implode(', ', $writeColumns), + implode(', ', $selectColumns), + $this->quoteTableName($tmpTableName) + ); + $this->execute($sql); + } + + /** + * Modifies the passed instructions to copy all data from the table into + * the provided tmp table and then drops the table and rename tmp table. + * + * @param \Migrations\Db\AlterInstructions $instructions The instructions to modify + * @param string $tableName The table name to copy the data to + * @return \Migrations\Db\AlterInstructions + */ + protected function copyAndDropTmpTable(AlterInstructions $instructions, string $tableName): AlterInstructions + { + $instructions->addPostStep(function ($state) use ($tableName) { + $this->copyDataToNewTable( + $state['tmpTableName'], + $tableName, + $state['writeColumns'], + $state['selectColumns'] + ); + + $this->execute(sprintf('DROP TABLE %s', $this->quoteTableName($tableName))); + $this->execute(sprintf( + 'ALTER TABLE %s RENAME TO %s', + $this->quoteTableName($state['tmpTableName']), + $this->quoteTableName($tableName) + )); + + return $state; + }); + + return $instructions; + } + + /** + * Returns the columns and type to use when copying a table to another in the process + * of altering a table + * + * @param string $tableName The table to modify + * @param string|false $columnName The column name that is about to change + * @param string|false $newColumnName Optionally the new name for the column + * @throws \InvalidArgumentException + * @return array + */ + protected function calculateNewTableColumns(string $tableName, string|false $columnName, string|false $newColumnName): array + { + $columns = $this->fetchAll(sprintf('pragma table_info(%s)', $this->quoteTableName($tableName))); + /** @var array $selectColumns */ + $selectColumns = []; + /** @var array $writeColumns */ + $writeColumns = []; + $columnType = null; + $found = false; + + foreach ($columns as $column) { + $selectName = $column['name']; + $writeName = $selectName; + + if ($selectName === $columnName) { + $writeName = $newColumnName; + $found = true; + $columnType = $column['type']; + $selectName = $newColumnName === false ? $newColumnName : $selectName; + } + + if ($selectName) { + $selectColumns[] = $selectName; + } + if ($writeName) { + $writeColumns[] = $writeName; + } + } + + $selectColumns = array_filter($selectColumns, 'strlen'); + $writeColumns = array_filter($writeColumns, 'strlen'); + $selectColumns = array_map([$this, 'quoteColumnName'], $selectColumns); + $writeColumns = array_map([$this, 'quoteColumnName'], $writeColumns); + + if ($columnName && !$found) { + throw new InvalidArgumentException(sprintf( + 'The specified column doesn\'t exist: %s', + $columnName + )); + } + + return compact('writeColumns', 'selectColumns', 'columnType'); + } + + /** + * Returns the initial instructions to alter a table using the + * create-copy-drop strategy + * + * @param string $tableName The table to modify + * @return \Migrations\Db\AlterInstructions + */ + protected function beginAlterByCopyTable(string $tableName): AlterInstructions + { + $instructions = new AlterInstructions(); + $instructions->addPostStep(function ($state) use ($tableName) { + $tmpTableName = "tmp_{$tableName}"; + $createSQL = $this->getDeclaringSql($tableName); + + // Table name in SQLite can be hilarious inside declaring SQL: + // - tableName + // - `tableName` + // - "tableName" + // - [this is a valid table name too!] + // - etc. + // Just remove all characters before first "(" and build them again + $createSQL = preg_replace( + "/^CREATE TABLE .* \(/Ui", + '', + $createSQL + ); + + $createSQL = "CREATE TABLE {$this->quoteTableName($tmpTableName)} ({$createSQL}"; + + return compact('createSQL', 'tmpTableName') + $state; + }); + + return $instructions; + } + + /** + * Returns the final instructions to alter a table using the + * create-copy-drop strategy. + * + * @param \Migrations\Db\AlterInstructions $instructions The instructions to modify + * @param string $tableName The name of table being processed + * @param ?string $renamedOrRemovedColumnName The name of the renamed or removed column when part of a column + * rename/drop operation. + * @param ?string $newColumnName The new column name when part of a column rename operation. + * @param bool $validateForeignKeys Whether to validate foreign keys after the copy and drop operations. Note that + * enabling this option only has an effect when the `foreign_keys` PRAGMA is set to `ON`! + * @return \Migrations\Db\AlterInstructions + */ + protected function endAlterByCopyTable( + AlterInstructions $instructions, + string $tableName, + ?string $renamedOrRemovedColumnName = null, + ?string $newColumnName = null, + bool $validateForeignKeys = true + ): AlterInstructions { + $instructions = $this->bufferIndicesAndTriggers($instructions, $tableName); + + if ($renamedOrRemovedColumnName !== null) { + if ($newColumnName !== null) { + $this->updateIndicesForRenamedColumn($instructions, $renamedOrRemovedColumnName, $newColumnName); + } else { + $this->filterIndicesForRemovedColumn($instructions, $renamedOrRemovedColumnName); + } + } + + $result = $this->fetchRow('PRAGMA foreign_keys'); + $foreignKeysEnabled = $result ? (bool)$result['foreign_keys'] : false; + + if ($foreignKeysEnabled) { + $instructions->addPostStep('PRAGMA foreign_keys = OFF'); + } + + $instructions = $this->copyAndDropTmpTable($instructions, $tableName); + $instructions = $this->recreateIndicesAndTriggers($instructions); + + if ($foreignKeysEnabled) { + $instructions->addPostStep('PRAGMA foreign_keys = ON'); + } + + if ( + $foreignKeysEnabled && + $validateForeignKeys + ) { + $instructions = $this->validateForeignKeys($instructions, $tableName); + } + + return $instructions; + } + + /** + * @inheritDoc + */ + protected function getRenameColumnInstructions(string $tableName, string $columnName, string $newColumnName): AlterInstructions + { + $instructions = $this->beginAlterByCopyTable($tableName); + + $instructions->addPostStep(function ($state) use ($columnName, $newColumnName) { + $sql = str_replace( + $this->quoteColumnName($columnName), + $this->quoteColumnName($newColumnName), + (string)$state['createSQL'] + ); + $this->execute($sql); + + return $state; + }); + + $instructions->addPostStep(function ($state) use ($columnName, $newColumnName, $tableName) { + $newState = $this->calculateNewTableColumns($tableName, $columnName, $newColumnName); + + return $newState + $state; + }); + + return $this->endAlterByCopyTable($instructions, $tableName, $columnName, $newColumnName); + } + + /** + * @inheritDoc + */ + protected function getChangeColumnInstructions(string $tableName, string $columnName, Column $newColumn): AlterInstructions + { + $instructions = $this->beginAlterByCopyTable($tableName); + + $newColumnName = (string)$newColumn->getName(); + $instructions->addPostStep(function ($state) use ($columnName, $newColumn, $newColumnName) { + $sql = preg_replace( + sprintf("/%s(?:\/\*.*?\*\/|\([^)]+\)|'[^']*?'|[^,])+([,)])/", $this->quoteColumnName($columnName)), + sprintf('%s %s$1', $this->quoteColumnName($newColumnName), $this->getColumnSqlDefinition($newColumn)), + (string)$state['createSQL'], + 1 + ); + $this->execute($sql); + + return $state; + }); + + $instructions->addPostStep(function ($state) use ($columnName, $newColumnName, $tableName) { + $newState = $this->calculateNewTableColumns($tableName, $columnName, $newColumnName); + + return $newState + $state; + }); + + return $this->endAlterByCopyTable($instructions, $tableName); + } + + /** + * @inheritDoc + */ + protected function getDropColumnInstructions(string $tableName, string $columnName): AlterInstructions + { + $instructions = $this->beginAlterByCopyTable($tableName); + + $instructions->addPostStep(function ($state) use ($tableName, $columnName) { + $newState = $this->calculateNewTableColumns($tableName, $columnName, false); + + return $newState + $state; + }); + + $instructions->addPostStep(function ($state) use ($columnName) { + $sql = preg_replace( + sprintf("/%s\s%s.*(,\s(?!')|\)$)/U", preg_quote($this->quoteColumnName($columnName)), preg_quote($state['columnType'])), + '', + (string)$state['createSQL'] + ); + + if (substr($sql, -2) === ', ') { + $sql = substr($sql, 0, -2) . ')'; + } + + $this->execute($sql); + + return $state; + }); + + return $this->endAlterByCopyTable($instructions, $tableName, $columnName); + } + + /** + * Get an array of indexes from a particular table. + * + * @param string $tableName Table name + * @return array + */ + protected function getIndexes(string $tableName): array + { + $indexes = []; + $schema = $this->getSchemaName($tableName, true)['schema']; + $indexList = $this->getTableInfo($tableName, 'index_list'); + + foreach ($indexList as $index) { + $indexData = $this->fetchAll(sprintf('pragma %sindex_info(%s)', $schema, $this->quoteColumnName($index['name']))); + $cols = []; + foreach ($indexData as $indexItem) { + $cols[] = $indexItem['name']; + } + $indexes[$index['name']] = $cols; + } + + return $indexes; + } + + /** + * Finds the names of a table's indexes matching the supplied columns + * + * @param string $tableName The table to which the index belongs + * @param string|string[] $columns The columns of the index + * @return array + */ + protected function resolveIndex(string $tableName, string|array $columns): array + { + $columns = array_map('strtolower', (array)$columns); + $indexes = $this->getIndexes($tableName); + $matches = []; + + foreach ($indexes as $name => $index) { + $indexCols = array_map('strtolower', $index); + if ($columns == $indexCols) { + $matches[] = $name; + } + } + + return $matches; + } + + /** + * @inheritDoc + */ + public function hasIndex(string $tableName, string|array $columns): bool + { + return (bool)$this->resolveIndex($tableName, $columns); + } + + /** + * @inheritDoc + */ + public function hasIndexByName(string $tableName, string $indexName): bool + { + $indexName = strtolower($indexName); + $indexes = $this->getIndexes($tableName); + + foreach (array_keys($indexes) as $index) { + if ($indexName === strtolower($index)) { + return true; + } + } + + return false; + } + + /** + * @inheritDoc + */ + protected function getAddIndexInstructions(Table $table, Index $index): AlterInstructions + { + $indexColumnArray = []; + foreach ((array)$index->getColumns() as $column) { + $indexColumnArray[] = sprintf('`%s` ASC', $column); + } + $indexColumns = implode(',', $indexColumnArray); + $sql = sprintf( + 'CREATE %s ON %s (%s)', + $this->getIndexSqlDefinition($table, $index), + $this->quoteTableName($table->getName()), + $indexColumns + ); + + return new AlterInstructions([], [$sql]); + } + + /** + * @inheritDoc + */ + protected function getDropIndexByColumnsInstructions(string $tableName, $columns): AlterInstructions + { + $instructions = new AlterInstructions(); + $indexNames = $this->resolveIndex($tableName, $columns); + $schema = $this->getSchemaName($tableName, true)['schema']; + foreach ($indexNames as $indexName) { + if (strpos($indexName, 'sqlite_autoindex_') !== 0) { + $instructions->addPostStep(sprintf( + 'DROP INDEX %s%s', + $schema, + $this->quoteColumnName($indexName) + )); + } + } + + return $instructions; + } + + /** + * @inheritDoc + */ + protected function getDropIndexByNameInstructions(string $tableName, string $indexName): AlterInstructions + { + $instructions = new AlterInstructions(); + $indexName = strtolower($indexName); + $indexes = $this->getIndexes($tableName); + + $found = false; + foreach (array_keys($indexes) as $index) { + if ($indexName === strtolower($index)) { + $found = true; + break; + } + } + + if ($found) { + $schema = $this->getSchemaName($tableName, true)['schema']; + $instructions->addPostStep(sprintf( + 'DROP INDEX %s%s', + $schema, + $this->quoteColumnName($indexName) + )); + } + + return $instructions; + } + + /** + * {@inheritDoc} + * + * @throws \InvalidArgumentException + */ + public function hasPrimaryKey(string $tableName, $columns, ?string $constraint = null): bool + { + if ($constraint !== null) { + throw new InvalidArgumentException('SQLite does not support named constraints.'); + } + + $columns = array_map('strtolower', (array)$columns); + $primaryKey = array_map('strtolower', $this->getPrimaryKey($tableName)); + + if (array_diff($primaryKey, $columns) || array_diff($columns, $primaryKey)) { + return false; + } + + return true; + } + + /** + * Get the primary key from a particular table. + * + * @param string $tableName Table name + * @return string[] + */ + protected function getPrimaryKey(string $tableName): array + { + $primaryKey = []; + + $rows = $this->getTableInfo($tableName); + + foreach ($rows as $row) { + if ($row['pk'] > 0) { + $primaryKey[$row['pk'] - 1] = $row['name']; + } + } + + return $primaryKey; + } + + /** + * @inheritDoc + */ + public function hasForeignKey(string $tableName, $columns, ?string $constraint = null): bool + { + if ($constraint !== null) { + return preg_match( + "/,?\s*CONSTRAINT\s*" . $this->possiblyQuotedIdentifierRegex($constraint) . '\s*FOREIGN\s+KEY/is', + $this->getDeclaringSql($tableName) + ) === 1; + } + + $columns = array_map('mb_strtolower', (array)$columns); + + foreach ($this->getForeignKeys($tableName) as $key) { + if (array_map('mb_strtolower', $key) === $columns) { + return true; + } + } + + return false; + } + + /** + * Get an array of foreign keys from a particular table. + * + * @param string $tableName Table name + * @return array + */ + protected function getForeignKeys(string $tableName): array + { + $foreignKeys = []; + + $rows = $this->getTableInfo($tableName, 'foreign_key_list'); + + foreach ($rows as $row) { + if (!isset($foreignKeys[$row['id']])) { + $foreignKeys[$row['id']] = []; + } + $foreignKeys[$row['id']][$row['seq']] = $row['from']; + } + + return $foreignKeys; + } + + /** + * @param \Migrations\Db\Table\Table $table The Table + * @param string $column Column Name + * @return \Migrations\Db\AlterInstructions + */ + protected function getAddPrimaryKeyInstructions(Table $table, string $column): AlterInstructions + { + $instructions = $this->beginAlterByCopyTable($table->getName()); + + $tableName = $table->getName(); + $instructions->addPostStep(function ($state) use ($column) { + $matchPattern = "/(`$column`)\s+(\w+(\(\d+\))?)\s+((NOT )?NULL)/"; + + $sql = $state['createSQL']; + + if (preg_match($matchPattern, $state['createSQL'], $matches)) { + if (isset($matches[2])) { + if ($matches[2] === 'INTEGER') { + $replace = '$1 INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT'; + } else { + $replace = '$1 $2 NOT NULL PRIMARY KEY'; + } + + $sql = preg_replace($matchPattern, $replace, (string)$state['createSQL'], 1); + } + } + + $this->execute($sql); + + return $state; + }); + + $instructions->addPostStep(function ($state) { + $columns = $this->fetchAll(sprintf('pragma table_info(%s)', $this->quoteTableName($state['tmpTableName']))); + $names = array_map([$this, 'quoteColumnName'], array_column($columns, 'name')); + $selectColumns = $writeColumns = $names; + + return compact('selectColumns', 'writeColumns') + $state; + }); + + return $this->endAlterByCopyTable($instructions, $tableName); + } + + /** + * @param \Migrations\Db\Table\Table $table Table + * @param string $column Column Name + * @return \Migrations\Db\AlterInstructions + */ + protected function getDropPrimaryKeyInstructions(Table $table, string $column): AlterInstructions + { + $tableName = $table->getName(); + $instructions = $this->beginAlterByCopyTable($tableName); + + $instructions->addPostStep(function ($state) { + $search = "/(,?\s*PRIMARY KEY\s*\([^\)]*\)|\s+PRIMARY KEY(\s+AUTOINCREMENT)?)/"; + $sql = preg_replace($search, '', (string)$state['createSQL'], 1); + + if ($sql) { + $this->execute($sql); + } + + return $state; + }); + + $instructions->addPostStep(function ($state) use ($column) { + $newState = $this->calculateNewTableColumns($state['tmpTableName'], $column, $column); + + return $newState + $state; + }); + + return $this->endAlterByCopyTable($instructions, $tableName, null, null, false); + } + + /** + * @inheritDoc + */ + protected function getAddForeignKeyInstructions(Table $table, ForeignKey $foreignKey): AlterInstructions + { + $instructions = $this->beginAlterByCopyTable($table->getName()); + + $tableName = $table->getName(); + $instructions->addPostStep(function ($state) use ($foreignKey, $tableName) { + $this->execute('pragma foreign_keys = ON'); + $sql = substr($state['createSQL'], 0, -1) . ',' . $this->getForeignKeySqlDefinition($foreignKey) . '); '; + + //Delete indexes from original table and recreate them in temporary table + $schema = $this->getSchemaName($tableName, true)['schema']; + $tmpTableName = $state['tmpTableName']; + $indexes = $this->getIndexes($tableName); + foreach (array_keys($indexes) as $indexName) { + if (strpos($indexName, 'sqlite_autoindex_') !== 0) { + $sql .= sprintf( + 'DROP INDEX %s%s; ', + $schema, + $this->quoteColumnName($indexName) + ); + $createIndexSQL = $this->getDeclaringIndexSQL($tableName, $indexName); + $sql .= preg_replace( + "/\b{$tableName}\b/", + $tmpTableName, + $createIndexSQL + ); + } + } + + $this->execute($sql); + + return $state; + }); + + $instructions->addPostStep(function ($state) { + $columns = $this->fetchAll(sprintf('pragma table_info(%s)', $this->quoteTableName($state['tmpTableName']))); + $names = array_map([$this, 'quoteColumnName'], array_column($columns, 'name')); + $selectColumns = $writeColumns = $names; + + return compact('selectColumns', 'writeColumns') + $state; + }); + + return $this->endAlterByCopyTable($instructions, $tableName); + } + + /** + * {@inheritDoc} + * + * SQLiteAdapter does not implement this functionality, and so will always throw an exception if used. + * + * @throws \BadMethodCallException + */ + protected function getDropForeignKeyInstructions(string $tableName, string $constraint): AlterInstructions + { + throw new BadMethodCallException('SQLite does not have named foreign keys'); + } + + /** + * {@inheritDoc} + * + * @throws \InvalidArgumentException + */ + protected function getDropForeignKeyByColumnsInstructions(string $tableName, array $columns): AlterInstructions + { + if (!$this->hasForeignKey($tableName, $columns)) { + throw new InvalidArgumentException(sprintf( + 'No foreign key on column(s) `%s` exists', + implode(', ', $columns) + )); + } + + $instructions = $this->beginAlterByCopyTable($tableName); + + $instructions->addPostStep(function ($state) use ($columns) { + $search = sprintf( + "/,[^,]+?\(\s*%s\s*\)\s*REFERENCES[^,]*\([^\)]*\)[^,)]*/is", + implode( + '\s*,\s*', + array_map( + fn ($column) => $this->possiblyQuotedIdentifierRegex($column, false), + $columns + ) + ), + ); + $sql = preg_replace($search, '', (string)$state['createSQL']); + + if ($sql) { + $this->execute($sql); + } + + return $state; + }); + + $instructions->addPostStep(function ($state) { + $newState = $this->calculateNewTableColumns($state['tmpTableName'], false, false); + + return $newState + $state; + }); + + return $this->endAlterByCopyTable($instructions, $tableName); + } + + /** + * {@inheritDoc} + * + * @throws \Migrations\Db\Adapter\UnsupportedColumnTypeException + */ + public function getSqlType(Literal|string $type, ?int $limit = null): array + { + if ($type instanceof Literal) { + $name = $type; + } else { + $typeLC = strtolower($type); + + if (isset(static::$supportedColumnTypes[$typeLC])) { + $name = static::$supportedColumnTypes[$typeLC]; + } elseif (in_array($typeLC, static::$unsupportedColumnTypes, true)) { + throw new UnsupportedColumnTypeException('Column type "' . $type . '" is not supported by SQLite.'); + } else { + throw new UnsupportedColumnTypeException('Column type "' . $type . '" is not known by SQLite.'); + } + } + + return ['name' => $name, 'limit' => $limit]; + } + + /** + * Returns Phinx type by SQL type + * + * @param string|null $sqlTypeDef SQL Type definition + * @return array + */ + public function getPhinxType(?string $sqlTypeDef): array + { + $limit = null; + $scale = null; + if ($sqlTypeDef === null) { + // in SQLite columns can legitimately have null as a type, which is distinct from the empty string + $name = null; + } else { + if (!preg_match('/^([a-z]+)(_(?:integer|float|text|blob))?(?:\((\d+)(?:,(\d+))?\))?$/i', $sqlTypeDef, $match)) { + // doesn't match the pattern of a type we'd know about + $name = Literal::from($sqlTypeDef); + } else { + // possibly a known type + $type = $match[1]; + $typeLC = strtolower($type); + $affinity = $match[2] ?? ''; + $limit = isset($match[3]) && strlen($match[3]) ? (int)$match[3] : null; + $scale = isset($match[4]) && strlen($match[4]) ? (int)$match[4] : null; + if (in_array($typeLC, ['tinyint', 'tinyinteger'], true) && $limit === 1) { + // the type is a MySQL-style boolean + $name = static::PHINX_TYPE_BOOLEAN; + $limit = null; + } elseif (isset(static::$supportedColumnTypes[$typeLC])) { + // the type is an explicitly supported type + $name = $typeLC; + } elseif (isset(static::$supportedColumnTypeAliases[$typeLC])) { + // the type is an alias for a supported type + $name = static::$supportedColumnTypeAliases[$typeLC]; + } elseif (in_array($typeLC, static::$unsupportedColumnTypes, true)) { + // unsupported but known types are passed through lowercased, and without appended affinity + $name = Literal::from($typeLC); + } else { + // unknown types are passed through as-is + $name = Literal::from($type . $affinity); + } + } + } + + return [ + 'name' => $name, + 'limit' => $limit, + 'scale' => $scale, + ]; + } + + /** + * @inheritDoc + */ + public function createDatabase(string $name, array $options = []): void + { + touch($name . $this->suffix); + } + + /** + * @inheritDoc + */ + public function hasDatabase(string $name): bool + { + return is_file($name . $this->suffix); + } + + /** + * @inheritDoc + */ + public function dropDatabase(string $name): void + { + $this->createdTables = []; + if ($this->getOption('memory')) { + $this->disconnect(); + $this->connect(); + } + if (file_exists($name . $this->suffix)) { + unlink($name . $this->suffix); + } + } + + /** + * Gets the SQLite Column Definition for a Column object. + * + * @param \Migrations\Db\Table\Column $column Column + * @return string + */ + protected function getColumnSqlDefinition(Column $column): string + { + $isLiteralType = $column->getType() instanceof Literal; + if ($isLiteralType) { + $def = (string)$column->getType(); + } else { + $sqlType = $this->getSqlType($column->getType()); + $def = strtoupper($sqlType['name']); + + $limitable = in_array(strtoupper($sqlType['name']), $this->definitionsWithLimits, true); + if (($column->getLimit() || isset($sqlType['limit'])) && $limitable) { + $def .= '(' . ($column->getLimit() ?: $sqlType['limit']) . ')'; + } + } + if ($column->getPrecision() && $column->getScale()) { + $def .= '(' . $column->getPrecision() . ',' . $column->getScale() . ')'; + } + + $default = $column->getDefault(); + + $def .= $column->isNull() ? ' NULL' : ' NOT NULL'; + $def .= $this->getDefaultValueDefinition($default, (string)$column->getType()); + $def .= $column->isIdentity() ? ' PRIMARY KEY AUTOINCREMENT' : ''; + + $def .= $this->getCommentDefinition($column); + + return $def; + } + + /** + * Gets the comment Definition for a Column object. + * + * @param \Migrations\Db\Table\Column $column Column + * @return string + */ + protected function getCommentDefinition(Column $column): string + { + if ($column->getComment()) { + return ' /* ' . $column->getComment() . ' */ '; + } + + return ''; + } + + /** + * Gets the SQLite Index Definition for an Index object. + * + * @param \Migrations\Db\Table\Table $table Table + * @param \Migrations\Db\Table\Index $index Index + * @return string + */ + protected function getIndexSqlDefinition(Table $table, Index $index): string + { + if ($index->getType() === Index::UNIQUE) { + $def = 'UNIQUE INDEX'; + } else { + $def = 'INDEX'; + } + if (is_string($index->getName())) { + $indexName = $index->getName(); + } else { + $indexName = $table->getName() . '_'; + foreach ((array)$index->getColumns() as $column) { + $indexName .= $column . '_'; + } + $indexName .= 'index'; + } + $def .= ' `' . $indexName . '`'; + + return $def; + } + + /** + * @inheritDoc + */ + public function getColumnTypes(): array + { + return array_keys(static::$supportedColumnTypes); + } + + /** + * Gets the SQLite Foreign Key Definition for an ForeignKey object. + * + * @param \Migrations\Db\Table\ForeignKey $foreignKey Foreign key + * @return string + */ + protected function getForeignKeySqlDefinition(ForeignKey $foreignKey): string + { + $def = ''; + if ($foreignKey->getConstraint()) { + $def .= ' CONSTRAINT ' . $this->quoteColumnName((string)$foreignKey->getConstraint()); + } + $columnNames = []; + foreach ($foreignKey->getColumns() as $column) { + $columnNames[] = $this->quoteColumnName($column); + } + $def .= ' FOREIGN KEY (' . implode(',', $columnNames) . ')'; + $refColumnNames = []; + foreach ($foreignKey->getReferencedColumns() as $column) { + $refColumnNames[] = $this->quoteColumnName($column); + } + $def .= ' REFERENCES ' . $this->quoteTableName($foreignKey->getReferencedTable()->getName()) . ' (' . implode(',', $refColumnNames) . ')'; + if ($foreignKey->getOnDelete()) { + $def .= ' ON DELETE ' . $foreignKey->getOnDelete(); + } + if ($foreignKey->getOnUpdate()) { + $def .= ' ON UPDATE ' . $foreignKey->getOnUpdate(); + } + + return $def; + } +} diff --git a/src/Db/Adapter/SqlserverAdapter.php b/src/Db/Adapter/SqlserverAdapter.php new file mode 100644 index 00000000..f3667c3e --- /dev/null +++ b/src/Db/Adapter/SqlserverAdapter.php @@ -0,0 +1,1254 @@ + true, + self::PHINX_TYPE_BIG_INTEGER => true, + self::PHINX_TYPE_FLOAT => true, + self::PHINX_TYPE_DECIMAL => true, + ]; + + /** + * {@inheritDoc} + * + * @throws \InvalidArgumentException + * @return void + */ + public function connect(): void + { + $this->getConnection()->getDriver()->connect(); + $this->setConnection($this->getConnection()); + } + + /** + * @inheritDoc + */ + public function disconnect(): void + { + $this->getConnection()->getDriver()->disconnect(); + } + + /** + * @inheritDoc + */ + public function hasTransactions(): bool + { + return true; + } + + /** + * @inheritDoc + */ + public function beginTransaction(): void + { + $this->getConnection()->begin(); + } + + /** + * @inheritDoc + */ + public function commitTransaction(): void + { + $this->getConnection()->commit(); + } + + /** + * @inheritDoc + */ + public function rollbackTransaction(): void + { + $this->getConnection()->rollback(); + } + + /** + * @inheritDoc + */ + public function quoteTableName(string $tableName): string + { + return str_replace('.', '].[', $this->quoteColumnName($tableName)); + } + + /** + * @inheritDoc + */ + public function quoteColumnName(string $columnName): string + { + return '[' . str_replace(']', '\]', $columnName) . ']'; + } + + /** + * @inheritDoc + */ + public function hasTable(string $tableName): bool + { + if ($this->hasCreatedTable($tableName)) { + return true; + } + + /** @var array $result */ + $result = $this->fetchRow(sprintf("SELECT count(*) as [count] FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = '%s';", $tableName)); + + return $result['count'] > 0; + } + + /** + * @inheritDoc + */ + public function createTable(Table $table, array $columns = [], array $indexes = []): void + { + $options = $table->getOptions(); + + // Add the default primary key + if (!isset($options['id']) || (isset($options['id']) && $options['id'] === true)) { + $options['id'] = 'id'; + } + + if (isset($options['id']) && is_string($options['id'])) { + // Handle id => "field_name" to support AUTO_INCREMENT + $column = new Column(); + $column->setName($options['id']) + ->setType('integer') + ->setOptions(['identity' => true]); + + array_unshift($columns, $column); + if (isset($options['primary_key']) && (array)$options['id'] !== (array)$options['primary_key']) { + throw new InvalidArgumentException('You cannot enable an auto incrementing ID field and a primary key'); + } + $options['primary_key'] = $options['id']; + } + + $sql = 'CREATE TABLE '; + $sql .= $this->quoteTableName($table->getName()) . ' ('; + $sqlBuffer = []; + $columnsWithComments = []; + foreach ($columns as $column) { + $sqlBuffer[] = $this->quoteColumnName((string)$column->getName()) . ' ' . $this->getColumnSqlDefinition($column); + + // set column comments, if needed + if ($column->getComment()) { + $columnsWithComments[] = $column; + } + } + + // set the primary key(s) + if (isset($options['primary_key'])) { + $pkSql = sprintf('CONSTRAINT PK_%s PRIMARY KEY (', $table->getName()); + /** @var string|array $primaryKey */ + $primaryKey = $options['primary_key']; + + if (is_string($primaryKey)) { // handle primary_key => 'id' + $pkSql .= $this->quoteColumnName($primaryKey); + } elseif (is_array($primaryKey)) { // handle primary_key => array('tag_id', 'resource_id') + $pkSql .= implode(',', array_map([$this, 'quoteColumnName'], $primaryKey)); + } + $pkSql .= ')'; + $sqlBuffer[] = $pkSql; + } + + $sql .= implode(', ', $sqlBuffer); + $sql .= ');'; + + // process column comments + foreach ($columnsWithComments as $column) { + $sql .= $this->getColumnCommentSqlDefinition($column, $table->getName()); + } + + // set the indexes + foreach ($indexes as $index) { + $sql .= $this->getIndexSqlDefinition($index, $table->getName()); + } + + // execute the sql + $this->execute($sql); + + $this->addCreatedTable($table->getName()); + } + + /** + * {@inheritDoc} + * + * @throws \InvalidArgumentException + */ + protected function getChangePrimaryKeyInstructions(Table $table, $newColumns): AlterInstructions + { + $instructions = new AlterInstructions(); + + // Drop the existing primary key + $primaryKey = $this->getPrimaryKey($table->getName()); + if (!empty($primaryKey['constraint'])) { + $sql = sprintf( + 'DROP CONSTRAINT %s', + $this->quoteColumnName($primaryKey['constraint']) + ); + $instructions->addAlter($sql); + } + + // Add the primary key(s) + if (!empty($newColumns)) { + $sql = sprintf( + 'ALTER TABLE %s ADD CONSTRAINT %s PRIMARY KEY (', + $this->quoteTableName($table->getName()), + $this->quoteColumnName('PK_' . $table->getName()) + ); + if (is_string($newColumns)) { // handle primary_key => 'id' + $sql .= $this->quoteColumnName($newColumns); + } elseif (is_array($newColumns)) { // handle primary_key => array('tag_id', 'resource_id') + $sql .= implode(',', array_map([$this, 'quoteColumnName'], $newColumns)); + } + $sql .= ')'; + $instructions->addPostStep($sql); + } + + return $instructions; + } + + /** + * @inheritDoc + * + * SqlServer does not implement this functionality, and so will always throw an exception if used. + * @throws \BadMethodCallException + */ + protected function getChangeCommentInstructions(Table $table, ?string $newComment): AlterInstructions + { + throw new BadMethodCallException('SqlServer does not have table comments'); + } + + /** + * Gets the SqlServer Column Comment Defininition for a column object. + * + * @param \Migrations\Db\Table\Column $column Column + * @param ?string $tableName Table name + * @return string + */ + protected function getColumnCommentSqlDefinition(Column $column, ?string $tableName): string + { + // passing 'null' is to remove column comment + $currentComment = $this->getColumnComment((string)$tableName, $column->getName()); + + $comment = strcasecmp((string)$column->getComment(), 'NULL') !== 0 ? $this->quoteString((string)$column->getComment()) : '\'\''; + $command = $currentComment === null ? 'sp_addextendedproperty' : 'sp_updateextendedproperty'; + + return sprintf( + "EXECUTE %s N'MS_Description', N%s, N'SCHEMA', N'%s', N'TABLE', N'%s', N'COLUMN', N'%s';", + $command, + $comment, + $this->schema, + (string)$tableName, + (string)$column->getName() + ); + } + + /** + * @inheritDoc + */ + protected function getRenameTableInstructions(string $tableName, string $newTableName): AlterInstructions + { + $this->updateCreatedTableName($tableName, $newTableName); + $sql = sprintf( + "EXEC sp_rename '%s', '%s'", + $tableName, + $newTableName + ); + + return new AlterInstructions([], [$sql]); + } + + /** + * @inheritDoc + */ + protected function getDropTableInstructions(string $tableName): AlterInstructions + { + $this->removeCreatedTable($tableName); + $sql = sprintf('DROP TABLE %s', $this->quoteTableName($tableName)); + + return new AlterInstructions([], [$sql]); + } + + /** + * @inheritDoc + */ + public function truncateTable(string $tableName): void + { + $sql = sprintf( + 'TRUNCATE TABLE %s', + $this->quoteTableName($tableName) + ); + + $this->execute($sql); + } + + /** + * @param string $tableName Table name + * @param ?string $columnName Column name + * @return string|null + */ + public function getColumnComment(string $tableName, ?string $columnName): ?string + { + $sql = sprintf("SELECT cast(extended_properties.[value] as nvarchar(4000)) comment + FROM sys.schemas + INNER JOIN sys.tables + ON schemas.schema_id = tables.schema_id + INNER JOIN sys.columns + ON tables.object_id = columns.object_id + INNER JOIN sys.extended_properties + ON tables.object_id = extended_properties.major_id + AND columns.column_id = extended_properties.minor_id + AND extended_properties.name = 'MS_Description' + WHERE schemas.[name] = '%s' AND tables.[name] = '%s' AND columns.[name] = '%s'", $this->schema, $tableName, (string)$columnName); + $row = $this->fetchRow($sql); + + if ($row) { + return trim($row['comment']); + } + + return null; + } + + /** + * @inheritDoc + */ + public function getColumns(string $tableName): array + { + $columns = []; + $sql = sprintf( + "SELECT DISTINCT TABLE_SCHEMA AS [schema], TABLE_NAME as [table_name], COLUMN_NAME AS [name], DATA_TYPE AS [type], + IS_NULLABLE AS [null], COLUMN_DEFAULT AS [default], + CHARACTER_MAXIMUM_LENGTH AS [char_length], + NUMERIC_PRECISION AS [precision], + NUMERIC_SCALE AS [scale], ORDINAL_POSITION AS [ordinal_position], + COLUMNPROPERTY(object_id(TABLE_NAME), COLUMN_NAME, 'IsIdentity') as [identity] + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_NAME = '%s' + ORDER BY ordinal_position", + $tableName + ); + $rows = $this->fetchAll($sql); + foreach ($rows as $columnInfo) { + try { + $type = $this->getPhinxType($columnInfo['type']); + } catch (UnsupportedColumnTypeException $e) { + $type = Literal::from($columnInfo['type']); + } + + $column = new Column(); + $column->setName($columnInfo['name']) + ->setType($type) + ->setNull($columnInfo['null'] !== 'NO') + ->setDefault($this->parseDefault($columnInfo['default'])) + ->setIdentity($columnInfo['identity'] === '1') + ->setComment($this->getColumnComment($columnInfo['table_name'], $columnInfo['name'])); + + if (!empty($columnInfo['char_length'])) { + $column->setLimit((int)$columnInfo['char_length']); + } + + $columns[$columnInfo['name']] = $column; + } + + return $columns; + } + + /** + * @param string|null $default Default + * @return int|string|null + */ + protected function parseDefault(?string $default): int|string|null + { + // if a column is non-nullable and has no default, the value of column_default is null, + // otherwise it should be a string value that we parse below, including "(NULL)" which + // also stands for a null default + if ($default === null) { + return null; + } + + $result = preg_replace(["/\('(.*)'\)/", "/\(\((.*)\)\)/", "/\((.*)\)/"], '$1', $default); + + if (strtoupper($result) === 'NULL') { + $result = null; + } elseif (is_numeric($result)) { + $result = (int)$result; + } + + return $result; + } + + /** + * @inheritDoc + */ + public function hasColumn(string $tableName, string $columnName): bool + { + $sql = sprintf( + "SELECT count(*) as [count] + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_NAME = '%s' AND COLUMN_NAME = '%s'", + $tableName, + $columnName + ); + /** @var array $result */ + $result = $this->fetchRow($sql); + + return $result['count'] > 0; + } + + /** + * @inheritDoc + */ + protected function getAddColumnInstructions(Table $table, Column $column): AlterInstructions + { + $alter = sprintf( + 'ALTER TABLE %s ADD %s %s', + $table->getName(), + $this->quoteColumnName((string)$column->getName()), + $this->getColumnSqlDefinition($column) + ); + + return new AlterInstructions([], [$alter]); + } + + /** + * {@inheritDoc} + * + * @throws \InvalidArgumentException + */ + protected function getRenameColumnInstructions(string $tableName, string $columnName, string $newColumnName): AlterInstructions + { + if (!$this->hasColumn($tableName, $columnName)) { + throw new InvalidArgumentException("The specified column does not exist: $columnName"); + } + + $instructions = new AlterInstructions(); + + $oldConstraintName = "DF_{$tableName}_{$columnName}"; + $newConstraintName = "DF_{$tableName}_{$newColumnName}"; + $sql = <<addPostStep(sprintf( + $sql, + $oldConstraintName, + $newConstraintName + )); + + $instructions->addPostStep(sprintf( + "EXECUTE sp_rename N'%s.%s', N'%s', 'COLUMN' ", + $tableName, + $columnName, + $newColumnName + )); + + return $instructions; + } + + /** + * Returns the instructions to change a column default value + * + * @param string $tableName The table where the column is + * @param \Migrations\Db\Table\Column $newColumn The column to alter + * @return \Migrations\Db\AlterInstructions + */ + protected function getChangeDefault(string $tableName, Column $newColumn): AlterInstructions + { + $constraintName = "DF_{$tableName}_{$newColumn->getName()}"; + $default = $newColumn->getDefault(); + $instructions = new AlterInstructions(); + + if ($default === null) { + $default = 'DEFAULT NULL'; + } else { + $default = ltrim($this->getDefaultValueDefinition($default)); + } + + if (empty($default)) { + return $instructions; + } + + $instructions->addPostStep(sprintf( + 'ALTER TABLE %s ADD CONSTRAINT %s %s FOR %s', + $this->quoteTableName($tableName), + $constraintName, + $default, + $this->quoteColumnName((string)$newColumn->getName()) + )); + + return $instructions; + } + + /** + * @inheritDoc + */ + protected function getChangeColumnInstructions(string $tableName, string $columnName, Column $newColumn): AlterInstructions + { + $columns = $this->getColumns($tableName); + $changeDefault = + $newColumn->getDefault() !== $columns[$columnName]->getDefault() || + $newColumn->getType() !== $columns[$columnName]->getType(); + + $instructions = new AlterInstructions(); + + if ($columnName !== $newColumn->getName()) { + $instructions->merge( + $this->getRenameColumnInstructions($tableName, $columnName, (string)$newColumn->getName()) + ); + } + + if ($changeDefault) { + $instructions->merge($this->getDropDefaultConstraint($tableName, (string)$newColumn->getName())); + } + + $instructions->addPostStep(sprintf( + 'ALTER TABLE %s ALTER COLUMN %s %s', + $this->quoteTableName($tableName), + $this->quoteColumnName((string)$newColumn->getName()), + $this->getColumnSqlDefinition($newColumn, false) + )); + // change column comment if needed + if ($newColumn->getComment()) { + $instructions->addPostStep($this->getColumnCommentSqlDefinition($newColumn, $tableName)); + } + + if ($changeDefault) { + $instructions->merge($this->getChangeDefault($tableName, $newColumn)); + } + + return $instructions; + } + + /** + * @inheritDoc + */ + protected function getDropColumnInstructions(string $tableName, string $columnName): AlterInstructions + { + $instructions = $this->getDropDefaultConstraint($tableName, $columnName); + + $instructions->addPostStep(sprintf( + 'ALTER TABLE %s DROP COLUMN %s', + $this->quoteTableName($tableName), + $this->quoteColumnName($columnName) + )); + + return $instructions; + } + + /** + * @param string $tableName Table name + * @param string|null $columnName Column name + * @return \Migrations\Db\AlterInstructions + */ + protected function getDropDefaultConstraint(string $tableName, ?string $columnName): AlterInstructions + { + $defaultConstraint = $this->getDefaultConstraint($tableName, (string)$columnName); + + if (!$defaultConstraint) { + return new AlterInstructions(); + } + + return $this->getDropForeignKeyInstructions($tableName, $defaultConstraint); + } + + /** + * @param string $tableName Table name + * @param string $columnName Column name + * @return string|false + */ + protected function getDefaultConstraint(string $tableName, string $columnName): string|false + { + $sql = "SELECT + default_constraints.name +FROM + sys.all_columns + + INNER JOIN + sys.tables + ON all_columns.object_id = tables.object_id + + INNER JOIN + sys.schemas + ON tables.schema_id = schemas.schema_id + + INNER JOIN + sys.default_constraints + ON all_columns.default_object_id = default_constraints.object_id + +WHERE + schemas.name = 'dbo' + AND tables.name = '{$tableName}' + AND all_columns.name = '{$columnName}'"; + + $rows = $this->fetchAll($sql); + + return empty($rows) ? false : $rows[0]['name']; + } + + /** + * @param string $tableId Table ID + * @param string $indexId Index ID + * @return array + */ + protected function getIndexColums(string $tableId, string $indexId): array + { + $sql = "SELECT AC.[name] AS [column_name] +FROM sys.[index_columns] IC + INNER JOIN sys.[all_columns] AC ON IC.[column_id] = AC.[column_id] +WHERE AC.[object_id] = {$tableId} AND IC.[index_id] = {$indexId} AND IC.[object_id] = {$tableId} +ORDER BY IC.[key_ordinal];"; + + $rows = $this->fetchAll($sql); + $columns = []; + foreach ($rows as $row) { + $columns[] = strtolower($row['column_name']); + } + + return $columns; + } + + /** + * Get an array of indexes from a particular table. + * + * @param string $tableName Table name + * @return array + */ + public function getIndexes(string $tableName): array + { + $indexes = []; + $sql = "SELECT I.[name] AS [index_name], I.[index_id] as [index_id], T.[object_id] as [table_id] +FROM sys.[tables] AS T + INNER JOIN sys.[indexes] I ON T.[object_id] = I.[object_id] +WHERE T.[is_ms_shipped] = 0 AND I.[type_desc] <> 'HEAP' AND T.[name] = '{$tableName}' +ORDER BY T.[name], I.[index_id];"; + + $rows = $this->fetchAll($sql); + foreach ($rows as $row) { + $columns = $this->getIndexColums($row['table_id'], $row['index_id']); + $indexes[$row['index_name']] = ['columns' => $columns]; + } + + return $indexes; + } + + /** + * @inheritDoc + */ + public function hasIndex(string $tableName, string|array $columns): bool + { + if (is_string($columns)) { + $columns = [$columns]; // str to array + } + + $columns = array_map('strtolower', $columns); + $indexes = $this->getIndexes($tableName); + + foreach ($indexes as $index) { + $a = array_diff($columns, $index['columns']); + + if (empty($a)) { + return true; + } + } + + return false; + } + + /** + * @inheritDoc + */ + public function hasIndexByName(string $tableName, string $indexName): bool + { + $indexes = $this->getIndexes($tableName); + + foreach ($indexes as $name => $index) { + if ($name === $indexName) { + return true; + } + } + + return false; + } + + /** + * @inheritDoc + */ + protected function getAddIndexInstructions(Table $table, Index $index): AlterInstructions + { + $sql = $this->getIndexSqlDefinition($index, $table->getName()); + + return new AlterInstructions([], [$sql]); + } + + /** + * {@inheritDoc} + * + * @throws \InvalidArgumentException + */ + protected function getDropIndexByColumnsInstructions(string $tableName, $columns): AlterInstructions + { + if (is_string($columns)) { + $columns = [$columns]; // str to array + } + + $indexes = $this->getIndexes($tableName); + $columns = array_map('strtolower', $columns); + $instructions = new AlterInstructions(); + + foreach ($indexes as $indexName => $index) { + $a = array_diff($columns, $index['columns']); + if (empty($a)) { + $instructions->addPostStep(sprintf( + 'DROP INDEX %s ON %s', + $this->quoteColumnName($indexName), + $this->quoteTableName($tableName) + )); + + return $instructions; + } + } + + throw new InvalidArgumentException(sprintf( + "The specified index on columns '%s' does not exist", + implode(',', $columns) + )); + } + + /** + * {@inheritDoc} + * + * @throws \InvalidArgumentException + */ + protected function getDropIndexByNameInstructions(string $tableName, string $indexName): AlterInstructions + { + $indexes = $this->getIndexes($tableName); + $instructions = new AlterInstructions(); + + foreach ($indexes as $name => $index) { + if ($name === $indexName) { + $instructions->addPostStep(sprintf( + 'DROP INDEX %s ON %s', + $this->quoteColumnName($indexName), + $this->quoteTableName($tableName) + )); + + return $instructions; + } + } + + throw new InvalidArgumentException(sprintf( + "The specified index name '%s' does not exist", + $indexName + )); + } + + /** + * @inheritDoc + */ + public function hasPrimaryKey(string $tableName, $columns, ?string $constraint = null): bool + { + $primaryKey = $this->getPrimaryKey($tableName); + + if (empty($primaryKey)) { + return false; + } + + if ($constraint) { + return $primaryKey['constraint'] === $constraint; + } + + return $primaryKey['columns'] === (array)$columns; + } + + /** + * Get the primary key from a particular table. + * + * @param string $tableName Table name + * @return array + */ + public function getPrimaryKey(string $tableName): array + { + $rows = $this->fetchAll(sprintf( + "SELECT + tc.CONSTRAINT_NAME, + kcu.COLUMN_NAME + FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS AS tc + JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE AS kcu + ON tc.CONSTRAINT_NAME = kcu.CONSTRAINT_NAME + WHERE CONSTRAINT_TYPE = 'PRIMARY KEY' + AND tc.TABLE_NAME = '%s' + ORDER BY kcu.ORDINAL_POSITION", + $tableName + )); + + $primaryKey = [ + 'columns' => [], + ]; + foreach ($rows as $row) { + $primaryKey['constraint'] = $row['CONSTRAINT_NAME']; + $primaryKey['columns'][] = $row['COLUMN_NAME']; + } + + return $primaryKey; + } + + /** + * @inheritDoc + */ + public function hasForeignKey(string $tableName, $columns, ?string $constraint = null): bool + { + $foreignKeys = $this->getForeignKeys($tableName); + if ($constraint) { + if (isset($foreignKeys[$constraint])) { + return !empty($foreignKeys[$constraint]); + } + + return false; + } + + if (is_string($columns)) { + $columns = [$columns]; + } + + foreach ($foreignKeys as $key) { + if ($key['columns'] === $columns) { + return true; + } + } + + return false; + } + + /** + * Get an array of foreign keys from a particular table. + * + * @param string $tableName Table name + * @return array + */ + protected function getForeignKeys(string $tableName): array + { + $foreignKeys = []; + $rows = $this->fetchAll(sprintf( + "SELECT + tc.CONSTRAINT_NAME, + tc.TABLE_NAME, kcu.COLUMN_NAME, + ccu.TABLE_NAME AS REFERENCED_TABLE_NAME, + ccu.COLUMN_NAME AS REFERENCED_COLUMN_NAME + FROM + INFORMATION_SCHEMA.TABLE_CONSTRAINTS AS tc + JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE AS kcu ON tc.CONSTRAINT_NAME = kcu.CONSTRAINT_NAME + JOIN INFORMATION_SCHEMA.CONSTRAINT_COLUMN_USAGE AS ccu ON ccu.CONSTRAINT_NAME = tc.CONSTRAINT_NAME + WHERE CONSTRAINT_TYPE = 'FOREIGN KEY' AND tc.TABLE_NAME = '%s' + ORDER BY kcu.ORDINAL_POSITION", + $tableName + )); + foreach ($rows as $row) { + $foreignKeys[$row['CONSTRAINT_NAME']]['table'] = $row['TABLE_NAME']; + $foreignKeys[$row['CONSTRAINT_NAME']]['columns'][] = $row['COLUMN_NAME']; + $foreignKeys[$row['CONSTRAINT_NAME']]['referenced_table'] = $row['REFERENCED_TABLE_NAME']; + $foreignKeys[$row['CONSTRAINT_NAME']]['referenced_columns'][] = $row['REFERENCED_COLUMN_NAME']; + } + foreach ($foreignKeys as $name => $key) { + $foreignKeys[$name]['columns'] = array_values(array_unique($key['columns'])); + $foreignKeys[$name]['referenced_columns'] = array_values(array_unique($key['referenced_columns'])); + } + + return $foreignKeys; + } + + /** + * @inheritDoc + */ + protected function getAddForeignKeyInstructions(Table $table, ForeignKey $foreignKey): AlterInstructions + { + $instructions = new AlterInstructions(); + $instructions->addPostStep(sprintf( + 'ALTER TABLE %s ADD %s', + $this->quoteTableName($table->getName()), + $this->getForeignKeySqlDefinition($foreignKey, $table->getName()) + )); + + return $instructions; + } + + /** + * @inheritDoc + */ + protected function getDropForeignKeyInstructions(string $tableName, string $constraint): AlterInstructions + { + $instructions = new AlterInstructions(); + $instructions->addPostStep(sprintf( + 'ALTER TABLE %s DROP CONSTRAINT %s', + $this->quoteTableName($tableName), + $this->quoteColumnName($constraint) + )); + + return $instructions; + } + + /** + * @inheritDoc + */ + protected function getDropForeignKeyByColumnsInstructions(string $tableName, array $columns): AlterInstructions + { + $instructions = new AlterInstructions(); + + $matches = []; + $foreignKeys = $this->getForeignKeys($tableName); + foreach ($foreignKeys as $name => $key) { + if ($key['columns'] === $columns) { + $matches[] = $name; + } + } + + if (empty($matches)) { + throw new InvalidArgumentException(sprintf( + 'No foreign key on column(s) `%s` exists', + implode(', ', $columns) + )); + } + + foreach ($matches as $name) { + $instructions->merge( + $this->getDropForeignKeyInstructions($tableName, $name) + ); + } + + return $instructions; + } + + /** + * {@inheritDoc} + * + * @throws \Migrations\Db\Adapter\UnsupportedColumnTypeException + */ + public function getSqlType(Literal|string $type, ?int $limit = null): array + { + $type = (string)$type; + switch ($type) { + case static::PHINX_TYPE_FLOAT: + case static::PHINX_TYPE_DECIMAL: + case static::PHINX_TYPE_DATETIME: + case static::PHINX_TYPE_TIME: + case static::PHINX_TYPE_DATE: + return ['name' => $type]; + case static::PHINX_TYPE_STRING: + return ['name' => 'nvarchar', 'limit' => 255]; + case static::PHINX_TYPE_CHAR: + return ['name' => 'nchar', 'limit' => 255]; + case static::PHINX_TYPE_TEXT: + return ['name' => 'ntext']; + case static::PHINX_TYPE_INTEGER: + return ['name' => 'int']; + case static::PHINX_TYPE_TINY_INTEGER: + return ['name' => 'tinyint']; + case static::PHINX_TYPE_SMALL_INTEGER: + return ['name' => 'smallint']; + case static::PHINX_TYPE_BIG_INTEGER: + return ['name' => 'bigint']; + case static::PHINX_TYPE_TIMESTAMP: + return ['name' => 'datetime']; + case static::PHINX_TYPE_BLOB: + case static::PHINX_TYPE_BINARY: + return ['name' => 'varbinary']; + case static::PHINX_TYPE_BOOLEAN: + return ['name' => 'bit']; + case static::PHINX_TYPE_BINARYUUID: + case static::PHINX_TYPE_UUID: + return ['name' => 'uniqueidentifier']; + case static::PHINX_TYPE_FILESTREAM: + return ['name' => 'varbinary', 'limit' => 'max']; + // Geospatial database types + case static::PHINX_TYPE_GEOGRAPHY: + case static::PHINX_TYPE_POINT: + case static::PHINX_TYPE_LINESTRING: + case static::PHINX_TYPE_POLYGON: + // SQL Server stores all spatial data using a single data type. + // Specific types (point, polygon, etc) are set at insert time. + return ['name' => 'geography']; + // Geometry specific type + case static::PHINX_TYPE_GEOMETRY: + return ['name' => 'geometry']; + default: + throw new UnsupportedColumnTypeException('Column type "' . $type . '" is not supported by SqlServer.'); + } + } + + /** + * Returns Phinx type by SQL type + * + * @internal param string $sqlType SQL type + * @param string $sqlType SQL Type definition + * @throws \Migrations\Db\Adapter\UnsupportedColumnTypeException + * @return string Phinx type + */ + public function getPhinxType(string $sqlType): string + { + switch ($sqlType) { + case 'nvarchar': + case 'varchar': + return static::PHINX_TYPE_STRING; + case 'char': + case 'nchar': + return static::PHINX_TYPE_CHAR; + case 'text': + case 'ntext': + return static::PHINX_TYPE_TEXT; + case 'int': + case 'integer': + return static::PHINX_TYPE_INTEGER; + case 'decimal': + case 'numeric': + case 'money': + return static::PHINX_TYPE_DECIMAL; + case 'tinyint': + return static::PHINX_TYPE_TINY_INTEGER; + case 'smallint': + return static::PHINX_TYPE_SMALL_INTEGER; + case 'bigint': + return static::PHINX_TYPE_BIG_INTEGER; + case 'real': + case 'float': + return static::PHINX_TYPE_FLOAT; + case 'binary': + case 'image': + case 'varbinary': + return static::PHINX_TYPE_BINARY; + case 'time': + return static::PHINX_TYPE_TIME; + case 'date': + return static::PHINX_TYPE_DATE; + case 'datetime': + case 'timestamp': + return static::PHINX_TYPE_DATETIME; + case 'bit': + return static::PHINX_TYPE_BOOLEAN; + case 'uniqueidentifier': + return static::PHINX_TYPE_UUID; + case 'filestream': + return static::PHINX_TYPE_FILESTREAM; + default: + throw new UnsupportedColumnTypeException('Column type "' . $sqlType . '" is not supported by SqlServer.'); + } + } + + /** + * @inheritDoc + */ + public function createDatabase(string $name, array $options = []): void + { + if (isset($options['collation'])) { + $this->execute(sprintf('CREATE DATABASE [%s] COLLATE [%s]', $name, $options['collation'])); + } else { + $this->execute(sprintf('CREATE DATABASE [%s]', $name)); + } + $this->execute(sprintf('USE [%s]', $name)); + } + + /** + * @inheritDoc + */ + public function hasDatabase(string $name): bool + { + /** @var array $result */ + $result = $this->fetchRow( + sprintf( + "SELECT count(*) as [count] FROM master.dbo.sysdatabases WHERE [name] = '%s'", + $name + ) + ); + + return $result['count'] > 0; + } + + /** + * @inheritDoc + */ + public function dropDatabase(string $name): void + { + $sql = <<execute($sql); + $this->createdTables = []; + } + + /** + * Gets the SqlServer Column Definition for a Column object. + * + * @param \Migrations\Db\Table\Column $column Column + * @param bool $create Create column flag + * @return string + */ + protected function getColumnSqlDefinition(Column $column, bool $create = true): string + { + $buffer = []; + if ($column->getType() instanceof Literal) { + $buffer[] = (string)$column->getType(); + } else { + $sqlType = $this->getSqlType($column->getType()); + $buffer[] = strtoupper($sqlType['name']); + // integers cant have limits in SQlServer + $noLimits = [ + 'bigint', + 'int', + 'tinyint', + 'smallint', + ]; + if ($sqlType['name'] === static::PHINX_TYPE_DECIMAL && $column->getPrecision() && $column->getScale()) { + $buffer[] = sprintf( + '(%s, %s)', + $column->getPrecision() ?: $sqlType['precision'], + $column->getScale() ?: $sqlType['scale'] + ); + } elseif (!in_array($sqlType['name'], $noLimits) && ($column->getLimit() || isset($sqlType['limit']))) { + $buffer[] = sprintf('(%s)', $column->getLimit() ?: $sqlType['limit']); + } + } + + $properties = $column->getProperties(); + $buffer[] = $column->getType() === 'filestream' ? 'FILESTREAM' : ''; + $buffer[] = isset($properties['rowguidcol']) ? 'ROWGUIDCOL' : ''; + + $buffer[] = $column->isNull() ? 'NULL' : 'NOT NULL'; + + if ($create === true) { + if ($column->getDefault() === null && $column->isNull()) { + $buffer[] = ' DEFAULT NULL'; + } else { + $buffer[] = $this->getDefaultValueDefinition($column->getDefault()); + } + } + + if ($column->isIdentity()) { + $seed = $column->getSeed() ?: 1; + $increment = $column->getIncrement() ?: 1; + $buffer[] = sprintf('IDENTITY(%d,%d)', $seed, $increment); + } + + return implode(' ', $buffer); + } + + /** + * Gets the SqlServer Index Definition for an Index object. + * + * @param \Migrations\Db\Table\Index $index Index + * @param ?string $tableName Table name + * @return string + */ + protected function getIndexSqlDefinition(Index $index, ?string $tableName): string + { + $columnNames = (array)$index->getColumns(); + $indexName = $index->getName(); + if (!is_string($indexName)) { + $indexName = sprintf('%s_%s', (string)$tableName, implode('_', $columnNames)); + } + $order = $index->getOrder() ?? []; + $columnNames = array_map(function ($columnName) use ($order) { + $ret = '[' . $columnName . ']'; + if (isset($order[$columnName])) { + $ret .= ' ' . $order[$columnName]; + } + + return $ret; + }, $columnNames); + + $include = $index->getInclude(); + $includedColumns = $include ? sprintf('INCLUDE ([%s])', implode('],[', $include)) : ''; + + return sprintf( + 'CREATE %s INDEX %s ON %s (%s) %s;', + ($index->getType() === Index::UNIQUE ? 'UNIQUE' : ''), + $indexName, + $this->quoteTableName((string)$tableName), + implode(',', $columnNames), + $includedColumns + ); + } + + /** + * Gets the SqlServer Foreign Key Definition for an ForeignKey object. + * + * @param \Migrations\Db\Table\ForeignKey $foreignKey Foreign key + * @param string $tableName Table name + * @return string + */ + protected function getForeignKeySqlDefinition(ForeignKey $foreignKey, string $tableName): string + { + $constraintName = $foreignKey->getConstraint() ?: $tableName . '_' . implode('_', $foreignKey->getColumns()); + $def = ' CONSTRAINT ' . $this->quoteColumnName($constraintName); + $def .= ' FOREIGN KEY ("' . implode('", "', $foreignKey->getColumns()) . '")'; + $def .= " REFERENCES {$this->quoteTableName($foreignKey->getReferencedTable()->getName())} (\"" . implode('", "', $foreignKey->getReferencedColumns()) . '")'; + if ($foreignKey->getOnDelete()) { + $def .= " ON DELETE {$foreignKey->getOnDelete()}"; + } + if ($foreignKey->getOnUpdate()) { + $def .= " ON UPDATE {$foreignKey->getOnUpdate()}"; + } + + return $def; + } + + /** + * @inheritDoc + */ + public function getColumnTypes(): array + { + return array_merge(parent::getColumnTypes(), static::$specificColumnTypes); + } + + /** + * Records a migration being run. + * + * @param \Phinx\Migration\MigrationInterface $migration Migration + * @param string $direction Direction + * @param string $startTime Start Time + * @param string $endTime End Time + * @return \Phinx\Db\Adapter\AdapterInterface + */ + public function migrated(MigrationInterface $migration, string $direction, string $startTime, string $endTime): AdapterInterface + { + $startTime = str_replace(' ', 'T', $startTime); + $endTime = str_replace(' ', 'T', $endTime); + + return parent::migrated($migration, $direction, $startTime, $endTime); + } +} diff --git a/src/Db/Adapter/TimedOutputAdapter.php b/src/Db/Adapter/TimedOutputAdapter.php new file mode 100644 index 00000000..300bb80a --- /dev/null +++ b/src/Db/Adapter/TimedOutputAdapter.php @@ -0,0 +1,422 @@ +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); + $this->getIo()?->out(' -> ' . 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 + { + $io = $this->getIo(); + if ($io && $io->level() < ConsoleIo::VERBOSE) { + 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->getIo()?->verbose(' -- ' . $command . '(' . implode(', ', $outArr) . ')'); + + return; + } + + $this->getIo()->verbose(' -- ' . $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/UnsupportedColumnTypeException.php b/src/Db/Adapter/UnsupportedColumnTypeException.php new file mode 100644 index 00000000..a3b28311 --- /dev/null +++ b/src/Db/Adapter/UnsupportedColumnTypeException.php @@ -0,0 +1,18 @@ +alterParts = $alterParts; + $this->postSteps = $postSteps; + } + + /** + * Adds another part to the single ALTER instruction + * + * @param string $part The SQL snipped to add as part of the ALTER instruction + * @return void + */ + public function addAlter(string $part): void + { + $this->alterParts[] = $part; + } + + /** + * Adds a SQL command to be executed after the ALTER instruction. + * This method allows a callable, with will get an empty array as state + * for the first time and will pass the return value of the callable to + * the next callable, if present. + * + * This allows to keep a single state across callbacks. + * + * @param string|callable $sql The SQL to run after, or a callable to execute + * @return void + */ + public function addPostStep(string|callable $sql): void + { + $this->postSteps[] = $sql; + } + + /** + * Returns the alter SQL snippets + * + * @return string[] + */ + public function getAlterParts(): array + { + return $this->alterParts; + } + + /** + * Returns the SQL commands to run after the ALTER instruction + * + * @return (string|callable)[] + */ + public function getPostSteps(): array + { + return $this->postSteps; + } + + /** + * Merges another AlterInstructions object to this one + * + * @param \Migrations\Db\AlterInstructions $other The other collection of instructions to merge in + * @return void + */ + public function merge(AlterInstructions $other): void + { + $this->alterParts = array_merge($this->alterParts, $other->getAlterParts()); + $this->postSteps = array_merge($this->postSteps, $other->getPostSteps()); + } + + /** + * Executes the ALTER instruction and all of the post steps. + * + * @param string $alterTemplate The template for the alter instruction + * @param callable $executor The function to be used to execute all instructions + * @return void + */ + public function execute(string $alterTemplate, callable $executor): void + { + if ($this->alterParts) { + $alter = sprintf($alterTemplate, implode(', ', $this->alterParts)); + $executor($alter); + } + + $state = []; + + foreach ($this->postSteps as $instruction) { + if (is_callable($instruction)) { + $state = $instruction($state); + continue; + } + + $executor($instruction); + } + } +} diff --git a/src/Db/Expression.php b/src/Db/Expression.php new file mode 100644 index 00000000..46d9a4f6 --- /dev/null +++ b/src/Db/Expression.php @@ -0,0 +1,42 @@ +value = $value; + } + + /** + * @return string Returns the expression + */ + public function __toString(): string + { + return $this->value; + } + + /** + * @param string $value The expression + * @return self + */ + public static function from(string $value): Expression + { + return new self($value); + } +} diff --git a/src/Db/Literal.php b/src/Db/Literal.php new file mode 100644 index 00000000..b318daa1 --- /dev/null +++ b/src/Db/Literal.php @@ -0,0 +1,46 @@ +value = $value; + } + + /** + * @return string Returns the literal's value + */ + public function __toString(): string + { + return $this->value; + } + + /** + * @param string $value The literal's value + * @return self + */ + public static function from(string $value): Literal + { + return new self($value); + } +} diff --git a/src/Db/Plan/AlterTable.php b/src/Db/Plan/AlterTable.php new file mode 100644 index 00000000..3ced6934 --- /dev/null +++ b/src/Db/Plan/AlterTable.php @@ -0,0 +1,73 @@ +table = $table; + } + + /** + * Adds another action to the collection + * + * @param \Migrations\Db\Action\Action $action The action to add + * @return void + */ + public function addAction(Action $action): void + { + $this->actions[] = $action; + } + + /** + * Returns the table associated to this collection + * + * @return \Migrations\Db\Table\Table + */ + public function getTable(): Table + { + return $this->table; + } + + /** + * Returns an array with all collected actions + * + * @return \Migrations\Db\Action\Action[] + */ + public function getActions(): array + { + return $this->actions; + } +} diff --git a/src/Db/Plan/Intent.php b/src/Db/Plan/Intent.php new file mode 100644 index 00000000..1003abd7 --- /dev/null +++ b/src/Db/Plan/Intent.php @@ -0,0 +1,56 @@ +actions[] = $action; + } + + /** + * Returns the full list of actions + * + * @return \Migrations\Db\Action\Action[] + */ + public function getActions(): array + { + return $this->actions; + } + + /** + * Merges another Intent object with this one + * + * @param \Migrations\Db\Plan\Intent $another The other intent to merge in + * @return void + */ + public function merge(Intent $another): void + { + $this->actions = array_merge($this->actions, $another->getActions()); + } +} diff --git a/src/Db/Plan/NewTable.php b/src/Db/Plan/NewTable.php new file mode 100644 index 00000000..5e0badbd --- /dev/null +++ b/src/Db/Plan/NewTable.php @@ -0,0 +1,102 @@ +table = $table; + } + + /** + * Adds a column to the collection + * + * @param \Migrations\Db\Table\Column $column The column description + * @return void + */ + public function addColumn(Column $column): void + { + $this->columns[] = $column; + } + + /** + * Adds an index to the collection + * + * @param \Migrations\Db\Table\Index $index The index description + * @return void + */ + public function addIndex(Index $index): void + { + $this->indexes[] = $index; + } + + /** + * Returns the table object associated to this collection + * + * @return \Migrations\Db\Table\Table + */ + public function getTable(): Table + { + return $this->table; + } + + /** + * Returns the columns collection + * + * @return \Migrations\Db\Table\Column[] + */ + public function getColumns(): array + { + return $this->columns; + } + + /** + * Returns the indexes collection + * + * @return \Migrations\Db\Table\Index[] + */ + public function getIndexes(): array + { + return $this->indexes; + } +} diff --git a/src/Db/Plan/Plan.php b/src/Db/Plan/Plan.php new file mode 100644 index 00000000..4a489697 --- /dev/null +++ b/src/Db/Plan/Plan.php @@ -0,0 +1,493 @@ +createPlan($intent->getActions()); + } + + /** + * Parses the given Intent and creates the separate steps to execute + * + * @param \Migrations\Db\Action\Action[] $actions The actions to use for the plan + * @return void + */ + protected function createPlan(array $actions): void + { + $this->gatherCreates($actions); + $this->gatherUpdates($actions); + $this->gatherTableMoves($actions); + $this->gatherIndexes($actions); + $this->gatherConstraints($actions); + $this->resolveConflicts(); + } + + /** + * Returns a nested list of all the steps to execute + * + * @return \Migrations\Db\Plan\AlterTable[][] + */ + protected function updatesSequence(): array + { + return [ + $this->tableUpdates, + $this->constraints, + $this->indexes, + $this->columnRemoves, + $this->tableMoves, + ]; + } + + /** + * Returns a nested list of all the steps to execute in inverse order + * + * @return \Migrations\Db\Plan\AlterTable[][] + */ + protected function inverseUpdatesSequence(): array + { + return [ + $this->constraints, + $this->tableMoves, + $this->indexes, + $this->columnRemoves, + $this->tableUpdates, + ]; + } + + /** + * Executes this plan using the given AdapterInterface + * + * @param \Migrations\Db\Adapter\AdapterInterface $executor The executor object for the plan + * @return void + */ + public function execute(AdapterInterface $executor): void + { + foreach ($this->tableCreates as $newTable) { + $executor->createTable($newTable->getTable(), $newTable->getColumns(), $newTable->getIndexes()); + } + + foreach ($this->updatesSequence() as $updates) { + foreach ($updates as $update) { + $executor->executeActions($update->getTable(), $update->getActions()); + } + } + } + + /** + * Executes the inverse plan (rollback the actions) with the given AdapterInterface:w + * + * @param \Migrations\Db\Adapter\AdapterInterface $executor The executor object for the plan + * @return void + */ + public function executeInverse(AdapterInterface $executor): void + { + foreach ($this->inverseUpdatesSequence() as $updates) { + foreach ($updates as $update) { + $executor->executeActions($update->getTable(), $update->getActions()); + } + } + + foreach ($this->tableCreates as $newTable) { + $executor->createTable($newTable->getTable(), $newTable->getColumns(), $newTable->getIndexes()); + } + } + + /** + * Deletes certain actions from the plan if they are found to be conflicting or redundant. + * + * @return void + */ + protected function resolveConflicts(): void + { + foreach ($this->tableMoves as $alterTable) { + foreach ($alterTable->getActions() as $action) { + if ($action instanceof DropTable) { + $this->tableUpdates = $this->forgetTable($action->getTable(), $this->tableUpdates); + $this->constraints = $this->forgetTable($action->getTable(), $this->constraints); + $this->indexes = $this->forgetTable($action->getTable(), $this->indexes); + $this->columnRemoves = $this->forgetTable($action->getTable(), $this->columnRemoves); + } + } + } + + // Renaming a column and then changing the renamed column is something people do, + // but it is a conflicting action. Luckily solving the conflict can be done by moving + // the ChangeColumn action to another AlterTable. + $splitter = new ActionSplitter( + RenameColumn::class, + ChangeColumn::class, + function (RenameColumn $a, ChangeColumn $b) { + return $a->getNewName() === $b->getColumnName(); + } + ); + $tableUpdates = []; + foreach ($this->tableUpdates as $update) { + $tableUpdates = array_merge($tableUpdates, $splitter($update)); + } + $this->tableUpdates = $tableUpdates; + + // Dropping indexes used by foreign keys is a conflict, but one we can resolve + // if the foreign key is also scheduled to be dropped. If we can find such a a case, + // we force the execution of the index drop after the foreign key is dropped. + // Changing constraint properties sometimes require dropping it and then + // creating it again with the new stuff. Unfortunately, we have already bundled + // everything together in as few AlterTable statements as we could, so we need to + // resolve this conflict manually. + $splitter = new ActionSplitter( + DropForeignKey::class, + AddForeignKey::class, + function (DropForeignKey $a, AddForeignKey $b) { + return $a->getForeignKey()->getColumns() === $b->getForeignKey()->getColumns(); + } + ); + $constraints = []; + foreach ($this->constraints as $constraint) { + $constraints = array_merge( + $constraints, + $splitter($this->remapContraintAndIndexConflicts($constraint)) + ); + } + $this->constraints = $constraints; + } + + /** + * Deletes all actions related to the given table and keeps the + * rest + * + * @param \Migrations\Db\Table\Table $table The table to find in the list of actions + * @param \Migrations\Db\Plan\AlterTable[] $actions The actions to transform + * @return \Migrations\Db\Plan\AlterTable[] The list of actions without actions for the given table + */ + protected function forgetTable(Table $table, array $actions): array + { + $result = []; + foreach ($actions as $action) { + if ($action->getTable()->getName() === $table->getName()) { + continue; + } + $result[] = $action; + } + + return $result; + } + + /** + * Finds all DropForeignKey actions in an AlterTable and moves + * all conflicting DropIndex action in `$this->indexes` into the + * given AlterTable. + * + * @param \Migrations\Db\Plan\AlterTable $alter The collection of actions to inspect + * @return \Migrations\Db\Plan\AlterTable The updated AlterTable object. This function + * has the side effect of changing the `$this->indexes` property. + */ + protected function remapContraintAndIndexConflicts(AlterTable $alter): AlterTable + { + $newAlter = new AlterTable($alter->getTable()); + + foreach ($alter->getActions() as $action) { + $newAlter->addAction($action); + if ($action instanceof DropForeignKey) { + [$this->indexes, $dropIndexActions] = $this->forgetDropIndex( + $action->getTable(), + $action->getForeignKey()->getColumns(), + $this->indexes + ); + foreach ($dropIndexActions as $dropIndexAction) { + $newAlter->addAction($dropIndexAction); + } + } + } + + return $newAlter; + } + + /** + * Deletes any DropIndex actions for the given table and exact columns + * + * @param \Migrations\Db\Table\Table $table The table to find in the list of actions + * @param string[] $columns The column names to match + * @param \Migrations\Db\Plan\AlterTable[] $actions The actions to transform + * @return array A tuple containing the list of actions without actions for dropping the index + * and a list of drop index actions that were removed. + */ + protected function forgetDropIndex(Table $table, array $columns, array $actions): array + { + $dropIndexActions = new ArrayObject(); + $indexes = array_map(function ($alter) use ($table, $columns, $dropIndexActions) { + if ($alter->getTable()->getName() !== $table->getName()) { + return $alter; + } + + $newAlter = new AlterTable($table); + foreach ($alter->getActions() as $action) { + if ($action instanceof DropIndex && $action->getIndex()->getColumns() === $columns) { + $dropIndexActions->append($action); + } else { + $newAlter->addAction($action); + } + } + + return $newAlter; + }, $actions); + + return [$indexes, $dropIndexActions->getArrayCopy()]; + } + + /** + * Deletes any RemoveColumn actions for the given table and exact columns + * + * @param \Migrations\Db\Table\Table $table The table to find in the list of actions + * @param string[] $columns The column names to match + * @param \Migrations\Db\Plan\AlterTable[] $actions The actions to transform + * @return array A tuple containing the list of actions without actions for removing the column + * and a list of remove column actions that were removed. + */ + protected function forgetRemoveColumn(Table $table, array $columns, array $actions): array + { + $removeColumnActions = new ArrayObject(); + $indexes = array_map(function ($alter) use ($table, $columns, $removeColumnActions) { + if ($alter->getTable()->getName() !== $table->getName()) { + return $alter; + } + + $newAlter = new AlterTable($table); + foreach ($alter->getActions() as $action) { + if ($action instanceof RemoveColumn && in_array($action->getColumn()->getName(), $columns, true)) { + $removeColumnActions->append($action); + } else { + $newAlter->addAction($action); + } + } + + return $newAlter; + }, $actions); + + return [$indexes, $removeColumnActions->getArrayCopy()]; + } + + /** + * Collects all table creation actions from the given intent + * + * @param \Migrations\Db\Action\Action[] $actions The actions to parse + * @return void + */ + protected function gatherCreates(array $actions): void + { + foreach ($actions as $action) { + if ($action instanceof CreateTable) { + $this->tableCreates[$action->getTable()->getName()] = new NewTable($action->getTable()); + } + } + + foreach ($actions as $action) { + if ( + ($action instanceof AddColumn || $action instanceof AddIndex) + && isset($this->tableCreates[$action->getTable()->getName()]) + ) { + $table = $action->getTable(); + + if ($action instanceof AddColumn) { + $this->tableCreates[$table->getName()]->addColumn($action->getColumn()); + } + + if ($action instanceof AddIndex) { + $this->tableCreates[$table->getName()]->addIndex($action->getIndex()); + } + } + } + } + + /** + * Collects all alter table actions from the given intent + * + * @param \Migrations\Db\Action\Action[] $actions The actions to parse + * @return void + */ + protected function gatherUpdates(array $actions): void + { + foreach ($actions as $action) { + if ( + !($action instanceof AddColumn) + && !($action instanceof ChangeColumn) + && !($action instanceof RemoveColumn) + && !($action instanceof RenameColumn) + ) { + continue; + } elseif (isset($this->tableCreates[$action->getTable()->getName()])) { + continue; + } + $table = $action->getTable(); + $name = $table->getName(); + + if ($action instanceof RemoveColumn) { + if (!isset($this->columnRemoves[$name])) { + $this->columnRemoves[$name] = new AlterTable($table); + } + $this->columnRemoves[$name]->addAction($action); + } else { + if (!isset($this->tableUpdates[$name])) { + $this->tableUpdates[$name] = new AlterTable($table); + } + $this->tableUpdates[$name]->addAction($action); + } + } + } + + /** + * Collects all alter table drop and renames from the given intent + * + * @param \Migrations\Db\Action\Action[] $actions The actions to parse + * @return void + */ + protected function gatherTableMoves(array $actions): void + { + foreach ($actions as $action) { + if ( + !($action instanceof DropTable) + && !($action instanceof RenameTable) + && !($action instanceof ChangePrimaryKey) + && !($action instanceof ChangeComment) + ) { + continue; + } + $table = $action->getTable(); + $name = $table->getName(); + + if (!isset($this->tableMoves[$name])) { + $this->tableMoves[$name] = new AlterTable($table); + } + + $this->tableMoves[$name]->addAction($action); + } + } + + /** + * Collects all index creation and drops from the given intent + * + * @param \Migrations\Db\Action\Action[] $actions The actions to parse + * @return void + */ + protected function gatherIndexes(array $actions): void + { + foreach ($actions as $action) { + if (!($action instanceof AddIndex) && !($action instanceof DropIndex)) { + continue; + } elseif (isset($this->tableCreates[$action->getTable()->getName()])) { + continue; + } + + $table = $action->getTable(); + $name = $table->getName(); + + if (!isset($this->indexes[$name])) { + $this->indexes[$name] = new AlterTable($table); + } + + $this->indexes[$name]->addAction($action); + } + } + + /** + * Collects all foreign key creation and drops from the given intent + * + * @param \Migrations\Db\Action\Action[] $actions The actions to parse + * @return void + */ + protected function gatherConstraints(array $actions): void + { + foreach ($actions as $action) { + if (!($action instanceof AddForeignKey || $action instanceof DropForeignKey)) { + continue; + } + $table = $action->getTable(); + $name = $table->getName(); + + if (!isset($this->constraints[$name])) { + $this->constraints[$name] = new AlterTable($table); + } + + $this->constraints[$name]->addAction($action); + } + } +} diff --git a/src/Db/Plan/Solver/ActionSplitter.php b/src/Db/Plan/Solver/ActionSplitter.php new file mode 100644 index 00000000..2cc52d27 --- /dev/null +++ b/src/Db/Plan/Solver/ActionSplitter.php @@ -0,0 +1,104 @@ +conflictClass = $conflictClass; + $this->conflictClassDual = $conflictClassDual; + $this->conflictFilter = $conflictFilter; + } + + /** + * Returs a sequence of AlterTable instructions that are non conflicting + * based on the constructor parameters. + * + * @param \Migrations\Db\Plan\AlterTable $alter The collection of actions to inspect + * @return \Migrations\Db\Plan\AlterTable[] A list of AlterTable that can be executed without + * this type of conflict + */ + public function __invoke(AlterTable $alter): array + { + $conflictActions = array_filter($alter->getActions(), function ($action) { + return $action instanceof $this->conflictClass; + }); + + $originalAlter = new AlterTable($alter->getTable()); + $newAlter = new AlterTable($alter->getTable()); + + foreach ($alter->getActions() as $action) { + if (!$action instanceof $this->conflictClassDual) { + $originalAlter->addAction($action); + continue; + } + + $found = false; + $matches = $this->conflictFilter; + foreach ($conflictActions as $ca) { + if ($matches($ca, $action)) { + $found = true; + break; + } + } + + if ($found) { + $newAlter->addAction($action); + } else { + $originalAlter->addAction($action); + } + } + + return [$originalAlter, $newAlter]; + } +} diff --git a/src/Db/Table.php b/src/Db/Table.php new file mode 100644 index 00000000..ad010a1c --- /dev/null +++ b/src/Db/Table.php @@ -0,0 +1,726 @@ + $options Options + * @param \Migrations\Db\Adapter\AdapterInterface|null $adapter Database Adapter + */ + public function __construct(string $name, array $options = [], ?AdapterInterface $adapter = null) + { + $this->table = new TableValue($name, $options); + $this->actions = new Intent(); + + if ($adapter !== null) { + $this->setAdapter($adapter); + } + } + + /** + * Gets the table name. + * + * @return string + */ + public function getName(): string + { + return $this->table->getName(); + } + + /** + * Gets the table options. + * + * @return array + */ + public function getOptions(): array + { + return $this->table->getOptions(); + } + + /** + * Gets the table name and options as an object + * + * @return \Migrations\Db\Table\Table + */ + public function getTable(): TableValue + { + return $this->table; + } + + /** + * Sets the database adapter. + * + * @param \Migrations\Db\Adapter\AdapterInterface $adapter Database Adapter + * @return $this + */ + public function setAdapter(AdapterInterface $adapter) + { + $this->adapter = $adapter; + + return $this; + } + + /** + * Gets the database adapter. + * + * @throws \RuntimeException + * @return \Migrations\Db\Adapter\AdapterInterface + */ + public function getAdapter(): AdapterInterface + { + if (!$this->adapter) { + throw new RuntimeException('There is no database adapter set yet, cannot proceed'); + } + + return $this->adapter; + } + + /** + * Does the table have pending actions? + * + * @return bool + */ + public function hasPendingActions(): bool + { + return count($this->actions->getActions()) > 0 || count($this->data) > 0; + } + + /** + * Does the table exist? + * + * @return bool + */ + public function exists(): bool + { + return $this->getAdapter()->hasTable($this->getName()); + } + + /** + * Drops the database table. + * + * @return $this + */ + public function drop() + { + $this->actions->addAction(new DropTable($this->table)); + + return $this; + } + + /** + * Renames the database table. + * + * @param string $newTableName New Table Name + * @return $this + */ + public function rename(string $newTableName) + { + $this->actions->addAction(new RenameTable($this->table, $newTableName)); + + return $this; + } + + /** + * Changes the primary key of the database table. + * + * @param string|string[]|null $columns Column name(s) to belong to the primary key, or null to drop the key + * @return $this + */ + public function changePrimaryKey(string|array|null $columns) + { + $this->actions->addAction(new ChangePrimaryKey($this->table, $columns)); + + return $this; + } + + /** + * Checks to see if a primary key exists. + * + * @param string|string[] $columns Column(s) + * @param string|null $constraint Constraint names + * @return bool + */ + public function hasPrimaryKey(string|array $columns, ?string $constraint = null): bool + { + return $this->getAdapter()->hasPrimaryKey($this->getName(), $columns, $constraint); + } + + /** + * Changes the comment of the database table. + * + * @param string|null $comment New comment string, or null to drop the comment + * @return $this + */ + public function changeComment(?string $comment) + { + $this->actions->addAction(new ChangeComment($this->table, $comment)); + + return $this; + } + + /** + * Gets an array of the table columns. + * + * @return \Migrations\Db\Table\Column[] + */ + public function getColumns(): array + { + return $this->getAdapter()->getColumns($this->getName()); + } + + /** + * Gets a table column if it exists. + * + * @param string $name Column name + * @return \Migrations\Db\Table\Column|null + */ + public function getColumn(string $name): ?Column + { + $columns = array_filter( + $this->getColumns(), + function ($column) use ($name) { + return $column->getName() === $name; + } + ); + + return array_pop($columns); + } + + /** + * Sets an array of data to be inserted. + * + * @param array $data Data + * @return $this + */ + public function setData(array $data) + { + $this->data = $data; + + return $this; + } + + /** + * Gets the data waiting to be inserted. + * + * @return array + */ + public function getData(): array + { + return $this->data; + } + + /** + * Resets all of the pending data to be inserted + * + * @return void + */ + public function resetData(): void + { + $this->setData([]); + } + + /** + * Resets all of the pending table changes. + * + * @return void + */ + public function reset(): void + { + $this->actions = new Intent(); + $this->resetData(); + } + + /** + * Add a table column. + * + * Type can be: string, text, integer, float, decimal, datetime, timestamp, + * time, date, binary, boolean. + * + * Valid options can be: limit, default, null, precision or scale. + * + * @param string|\Migrations\Db\Table\Column $columnName Column Name + * @param string|\Migrations\Db\Literal|null $type Column Type + * @param array $options Column Options + * @throws \InvalidArgumentException + * @return $this + */ + public function addColumn(string|Column $columnName, string|Literal|null $type = null, array $options = []) + { + assert($columnName instanceof Column || $type !== null); + if ($columnName instanceof Column) { + $action = new AddColumn($this->table, $columnName); + } elseif ($type instanceof Literal) { + $action = AddColumn::build($this->table, $columnName, $type, $options); + } else { + $action = new AddColumn($this->table, $this->getAdapter()->getColumnForType($columnName, $type, $options)); + } + + // Delegate to Adapters to check column type + if (!$this->getAdapter()->isValidColumnType($action->getColumn())) { + throw new InvalidArgumentException(sprintf( + 'An invalid column type "%s" was specified for column "%s".', + (string)$action->getColumn()->getType(), + (string)$action->getColumn()->getName() + )); + } + + $this->actions->addAction($action); + + return $this; + } + + /** + * Remove a table column. + * + * @param string $columnName Column Name + * @return $this + */ + public function removeColumn(string $columnName) + { + $action = RemoveColumn::build($this->table, $columnName); + $this->actions->addAction($action); + + return $this; + } + + /** + * Rename a table column. + * + * @param string $oldName Old Column Name + * @param string $newName New Column Name + * @return $this + */ + public function renameColumn(string $oldName, string $newName) + { + $action = RenameColumn::build($this->table, $oldName, $newName); + $this->actions->addAction($action); + + return $this; + } + + /** + * Change a table column type. + * + * @param string $columnName Column Name + * @param string|\Migrations\Db\Table\Column|\Migrations\Db\Literal $newColumnType New Column Type + * @param array $options Options + * @return $this + */ + public function changeColumn(string $columnName, string|Column|Literal $newColumnType, array $options = []) + { + if ($newColumnType instanceof Column) { + $action = new ChangeColumn($this->table, $columnName, $newColumnType); + } else { + $action = ChangeColumn::build($this->table, $columnName, $newColumnType, $options); + } + $this->actions->addAction($action); + + return $this; + } + + /** + * Checks to see if a column exists. + * + * @param string $columnName Column Name + * @return bool + */ + public function hasColumn(string $columnName): bool + { + return $this->getAdapter()->hasColumn($this->getName(), $columnName); + } + + /** + * Add an index to a database table. + * + * In $options you can specify unique = true/false, and name (index name). + * + * @param string|array|\Migrations\Db\Table\Index $columns Table Column(s) + * @param array $options Index Options + * @return $this + */ + public function addIndex(string|array|Index $columns, array $options = []) + { + $action = AddIndex::build($this->table, $columns, $options); + $this->actions->addAction($action); + + return $this; + } + + /** + * Removes the given index from a table. + * + * @param string|string[] $columns Columns + * @return $this + */ + public function removeIndex(string|array $columns) + { + $action = DropIndex::build($this->table, is_string($columns) ? [$columns] : $columns); + $this->actions->addAction($action); + + return $this; + } + + /** + * Removes the given index identified by its name from a table. + * + * @param string $name Index name + * @return $this + */ + public function removeIndexByName(string $name) + { + $action = DropIndex::buildFromName($this->table, $name); + $this->actions->addAction($action); + + return $this; + } + + /** + * Checks to see if an index exists. + * + * @param string|string[] $columns Columns + * @return bool + */ + public function hasIndex(string|array $columns): bool + { + return $this->getAdapter()->hasIndex($this->getName(), $columns); + } + + /** + * Checks to see if an index specified by name exists. + * + * @param string $indexName Index name + * @return bool + */ + public function hasIndexByName(string $indexName): bool + { + return $this->getAdapter()->hasIndexByName($this->getName(), $indexName); + } + + /** + * Add a foreign key to a database table. + * + * In $options you can specify on_delete|on_delete = cascade|no_action .., + * on_update, constraint = constraint name. + * + * @param string|string[] $columns Columns + * @param string|\Migrations\Db\Table\Table $referencedTable Referenced Table + * @param string|string[] $referencedColumns Referenced Columns + * @param array $options Options + * @return $this + */ + public function addForeignKey(string|array $columns, string|TableValue $referencedTable, string|array $referencedColumns = ['id'], array $options = []) + { + $action = AddForeignKey::build($this->table, $columns, $referencedTable, $referencedColumns, $options); + $this->actions->addAction($action); + + return $this; + } + + /** + * Add a foreign key to a database table with a given name. + * + * In $options you can specify on_delete|on_delete = cascade|no_action .., + * on_update, constraint = constraint name. + * + * @param string $name The constraint name + * @param string|string[] $columns Columns + * @param string|\Migrations\Db\Table\Table $referencedTable Referenced Table + * @param string|string[] $referencedColumns Referenced Columns + * @param array $options Options + * @return $this + */ + public function addForeignKeyWithName(string $name, string|array $columns, string|TableValue $referencedTable, string|array $referencedColumns = ['id'], array $options = []) + { + $action = AddForeignKey::build( + $this->table, + $columns, + $referencedTable, + $referencedColumns, + $options, + $name + ); + $this->actions->addAction($action); + + return $this; + } + + /** + * Removes the given foreign key from the table. + * + * @param string|string[] $columns Column(s) + * @param string|null $constraint Constraint names + * @return $this + */ + public function dropForeignKey(string|array $columns, ?string $constraint = null) + { + $action = DropForeignKey::build($this->table, $columns, $constraint); + $this->actions->addAction($action); + + return $this; + } + + /** + * Checks to see if a foreign key exists. + * + * @param string|string[] $columns Column(s) + * @param string|null $constraint Constraint names + * @return bool + */ + public function hasForeignKey(string|array $columns, ?string $constraint = null): bool + { + return $this->getAdapter()->hasForeignKey($this->getName(), $columns, $constraint); + } + + /** + * Add timestamp columns created_at and updated_at to the table. + * + * @param string|false|null $createdAt Alternate name for the created_at column + * @param string|false|null $updatedAt Alternate name for the updated_at column + * @param bool $withTimezone Whether to set the timezone option on the added columns + * @return $this + */ + public function addTimestamps(string|false|null $createdAt = 'created_at', string|false|null $updatedAt = 'updated_at', bool $withTimezone = false) + { + $createdAt = $createdAt ?? 'created_at'; + $updatedAt = $updatedAt ?? 'updated_at'; + + if (!$createdAt && !$updatedAt) { + throw new RuntimeException('Cannot set both created_at and updated_at columns to false'); + } + + if ($createdAt) { + $this->addColumn($createdAt, 'timestamp', [ + 'null' => false, + 'default' => 'CURRENT_TIMESTAMP', + 'update' => '', + 'timezone' => $withTimezone, + ]); + } + if ($updatedAt) { + $this->addColumn($updatedAt, 'timestamp', [ + 'null' => true, + 'default' => null, + 'update' => 'CURRENT_TIMESTAMP', + 'timezone' => $withTimezone, + ]); + } + + return $this; + } + + /** + * Alias that always sets $withTimezone to true + * + * @see addTimestamps + * @param string|false|null $createdAt Alternate name for the created_at column + * @param string|false|null $updatedAt Alternate name for the updated_at column + * @return $this + */ + public function addTimestampsWithTimezone(string|false|null $createdAt = null, string|false|null $updatedAt = null) + { + $this->addTimestamps($createdAt, $updatedAt, true); + + return $this; + } + + /** + * Insert data into the table. + * + * @param array $data array of data in the form: + * array( + * array("col1" => "value1", "col2" => "anotherValue1"), + * array("col2" => "value2", "col2" => "anotherValue2"), + * ) + * or array("col1" => "value1", "col2" => "anotherValue1") + * @return $this + */ + public function insert(array $data) + { + // handle array of array situations + $keys = array_keys($data); + $firstKey = array_shift($keys); + if ($firstKey !== null && is_array($data[$firstKey])) { + foreach ($data as $row) { + $this->data[] = $row; + } + + return $this; + } + + if (count($data) > 0) { + $this->data[] = $data; + } + + return $this; + } + + /** + * Creates a table from the object instance. + * + * @return void + */ + public function create(): void + { + $this->executeActions(false); + $this->saveData(); + $this->reset(); // reset pending changes + } + + /** + * Updates a table from the object instance. + * + * @return void + */ + public function update(): void + { + $this->executeActions(true); + $this->saveData(); + $this->reset(); // reset pending changes + } + + /** + * Commit the pending data waiting for insertion. + * + * @return void + */ + public function saveData(): void + { + $rows = $this->getData(); + if (empty($rows)) { + return; + } + + $bulk = true; + $row = current($rows); + $c = array_keys($row); + foreach ($this->getData() as $row) { + $k = array_keys($row); + if ($k != $c) { + $bulk = false; + break; + } + } + + if ($bulk) { + $this->getAdapter()->bulkinsert($this->table, $this->getData()); + } else { + foreach ($this->getData() as $row) { + $this->getAdapter()->insert($this->table, $row); + } + } + + $this->resetData(); + } + + /** + * Immediately truncates the table. This operation cannot be undone + * + * @return void + */ + public function truncate(): void + { + $this->getAdapter()->truncateTable($this->getName()); + } + + /** + * Commits the table changes. + * + * If the table doesn't exist it is created otherwise it is updated. + * + * @return void + */ + public function save(): void + { + if ($this->exists()) { + $this->update(); // update the table + } else { + $this->create(); // create the table + } + } + + /** + * Executes all the pending actions for this table + * + * @param bool $exists Whether or not the table existed prior to executing this method + * @return void + */ + protected function executeActions(bool $exists): void + { + // Renaming a table is tricky, specially when running a reversible migration + // down. We will just assume the table already exists if the user commands a + // table rename. + if (!$exists) { + foreach ($this->actions->getActions() as $action) { + if ($action instanceof RenameTable) { + $exists = true; + break; + } + } + } + + // If the table does not exist, the last command in the chain needs to be + // a CreateTable action. + if (!$exists) { + $this->actions->addAction(new CreateTable($this->table)); + } + + $plan = new Plan($this->actions); + $plan->execute($this->getAdapter()); + } +} diff --git a/src/Db/Table/Column.php b/src/Db/Table/Column.php new file mode 100644 index 00000000..d740e0d0 --- /dev/null +++ b/src/Db/Table/Column.php @@ -0,0 +1,803 @@ +null = (bool)Configure::read('Migrations.column_null_default'); + } + + /** + * Sets the column name. + * + * @param string $name Name + * @return $this + */ + public function setName(string $name) + { + $this->name = $name; + + return $this; + } + + /** + * Gets the column name. + * + * @return string|null + */ + public function getName(): ?string + { + return $this->name; + } + + /** + * Sets the column type. + * + * @param string|\Migrations\Db\Literal $type Column type + * @return $this + */ + public function setType(string|Literal $type) + { + $this->type = $type; + + return $this; + } + + /** + * Gets the column type. + * + * @return string|\Migrations\Db\Literal + */ + public function getType(): string|Literal + { + return $this->type; + } + + /** + * Sets the column limit. + * + * @param int|null $limit Limit + * @return $this + */ + public function setLimit(?int $limit) + { + $this->limit = $limit; + + return $this; + } + + /** + * Gets the column limit. + * + * @return int|null + */ + public function getLimit(): ?int + { + return $this->limit; + } + + /** + * Sets whether the column allows nulls. + * + * @param bool $null Null + * @return $this + */ + public function setNull(bool $null) + { + $this->null = $null; + + return $this; + } + + /** + * Gets whether the column allows nulls. + * + * @return bool + */ + public function getNull(): bool + { + return $this->null; + } + + /** + * Does the column allow nulls? + * + * @return bool + */ + public function isNull(): bool + { + return $this->getNull(); + } + + /** + * Sets the default column value. + * + * @param mixed $default Default + * @return $this + */ + public function setDefault(mixed $default) + { + $this->default = $default; + + return $this; + } + + /** + * Gets the default column value. + * + * @return mixed + */ + public function getDefault(): mixed + { + return $this->default; + } + + /** + * Sets generated option for identity columns. Ignored otherwise. + * + * @param string|null $generated Generated option + * @return $this + */ + public function setGenerated(?string $generated) + { + $this->generated = $generated; + + return $this; + } + + /** + * Gets generated option for identity columns. Null otherwise + * + * @return string|null + */ + public function getGenerated(): ?string + { + return $this->generated; + } + + /** + * Sets whether or not the column is an identity column. + * + * @param bool $identity Identity + * @return $this + */ + public function setIdentity(bool $identity) + { + $this->identity = $identity; + + return $this; + } + + /** + * Gets whether or not the column is an identity column. + * + * @return bool + */ + public function getIdentity(): bool + { + return $this->identity; + } + + /** + * Is the column an identity column? + * + * @return bool + */ + public function isIdentity(): bool + { + return $this->getIdentity(); + } + + /** + * Sets the name of the column to add this column after. + * + * @param string $after After + * @return $this + */ + public function setAfter(string $after) + { + $this->after = $after; + + return $this; + } + + /** + * Returns the name of the column to add this column after. + * + * @return string|null + */ + public function getAfter(): ?string + { + return $this->after; + } + + /** + * Sets the 'ON UPDATE' mysql column function. + * + * @param string $update On Update function + * @return $this + */ + public function setUpdate(string $update) + { + $this->update = $update; + + return $this; + } + + /** + * Returns the value of the ON UPDATE column function. + * + * @return string|null + */ + public function getUpdate(): ?string + { + return $this->update; + } + + /** + * Sets the number precision for decimal or float column. + * + * For example `DECIMAL(5,2)`, 5 is the precision and 2 is the scale, + * and the column could store value from -999.99 to 999.99. + * + * @param int|null $precision Number precision + * @return $this + */ + public function setPrecision(?int $precision) + { + $this->setLimit($precision); + + return $this; + } + + /** + * Gets the number precision for decimal or float column. + * + * For example `DECIMAL(5,2)`, 5 is the precision and 2 is the scale, + * and the column could store value from -999.99 to 999.99. + * + * @return int|null + */ + public function getPrecision(): ?int + { + return $this->limit; + } + + /** + * Sets the column identity increment. + * + * @param int $increment Number increment + * @return $this + */ + public function setIncrement(int $increment) + { + $this->increment = $increment; + + return $this; + } + + /** + * Gets the column identity increment. + * + * @return int|null + */ + public function getIncrement(): ?int + { + return $this->increment; + } + + /** + * Sets the column identity seed. + * + * @param int $seed Number seed + * @return $this + */ + public function setSeed(int $seed) + { + $this->seed = $seed; + + return $this; + } + + /** + * Gets the column identity seed. + * + * @return int + */ + public function getSeed(): ?int + { + return $this->seed; + } + + /** + * Sets the number scale for decimal or float column. + * + * For example `DECIMAL(5,2)`, 5 is the precision and 2 is the scale, + * and the column could store value from -999.99 to 999.99. + * + * @param int|null $scale Number scale + * @return $this + */ + public function setScale(?int $scale) + { + $this->scale = $scale; + + return $this; + } + + /** + * Gets the number scale for decimal or float column. + * + * For example `DECIMAL(5,2)`, 5 is the precision and 2 is the scale, + * and the column could store value from -999.99 to 999.99. + * + * @return int + */ + public function getScale(): ?int + { + return $this->scale; + } + + /** + * Sets the number precision and scale for decimal or float column. + * + * For example `DECIMAL(5,2)`, 5 is the precision and 2 is the scale, + * and the column could store value from -999.99 to 999.99. + * + * @param int $precision Number precision + * @param int $scale Number scale + * @return $this + */ + public function setPrecisionAndScale(int $precision, int $scale) + { + $this->setLimit($precision); + $this->scale = $scale; + + return $this; + } + + /** + * Sets the column comment. + * + * @param string|null $comment Comment + * @return $this + */ + public function setComment(?string $comment) + { + $this->comment = $comment; + + return $this; + } + + /** + * Gets the column comment. + * + * @return string + */ + public function getComment(): ?string + { + return $this->comment; + } + + /** + * Sets whether field should be signed. + * + * @param bool $signed Signed + * @return $this + */ + public function setSigned(bool $signed) + { + $this->signed = $signed; + + return $this; + } + + /** + * Gets whether field should be signed. + * + * @return bool + */ + public function getSigned(): bool + { + return $this->signed; + } + + /** + * Should the column be signed? + * + * @return bool + */ + public function isSigned(): bool + { + return $this->getSigned(); + } + + /** + * Sets whether the field should have a timezone identifier. + * Used for date/time columns only! + * + * @param bool $timezone Timezone + * @return $this + */ + public function setTimezone(bool $timezone) + { + $this->timezone = $timezone; + + return $this; + } + + /** + * Gets whether field has a timezone identifier. + * + * @return bool + */ + public function getTimezone(): bool + { + return $this->timezone; + } + + /** + * Should the column have a timezone? + * + * @return bool + */ + public function isTimezone(): bool + { + return $this->getTimezone(); + } + + /** + * Sets field properties. + * + * @param array $properties Properties + * @return $this + */ + public function setProperties(array $properties) + { + $this->properties = $properties; + + return $this; + } + + /** + * Gets field properties + * + * @return array + */ + public function getProperties(): array + { + return $this->properties; + } + + /** + * Sets field values. + * + * @param string[]|string $values Value(s) + * @return $this + */ + public function setValues(array|string $values) + { + if (!is_array($values)) { + $values = preg_split('/,\s*/', $values) ?: []; + } + $this->values = $values; + + return $this; + } + + /** + * Gets field values + * + * @return array|null + */ + public function getValues(): ?array + { + return $this->values; + } + + /** + * Sets the column collation. + * + * @param string $collation Collation + * @return $this + */ + public function setCollation(string $collation) + { + $this->collation = $collation; + + return $this; + } + + /** + * Gets the column collation. + * + * @return string|null + */ + public function getCollation(): ?string + { + return $this->collation; + } + + /** + * Sets the column character set. + * + * @param string $encoding Encoding + * @return $this + */ + public function setEncoding(string $encoding) + { + $this->encoding = $encoding; + + return $this; + } + + /** + * Gets the column character set. + * + * @return string|null + */ + public function getEncoding(): ?string + { + return $this->encoding; + } + + /** + * Sets the column SRID. + * + * @param int $srid SRID + * @return $this + */ + public function setSrid(int $srid) + { + $this->srid = $srid; + + return $this; + } + + /** + * Gets the column SRID. + * + * @return int|null + */ + public function getSrid(): ?int + { + return $this->srid; + } + + /** + * Gets all allowed options. Each option must have a corresponding `setFoo` method. + * + * @return array + */ + protected function getValidOptions(): array + { + return [ + 'limit', + 'default', + 'null', + 'identity', + 'scale', + 'after', + 'update', + 'comment', + 'signed', + 'timezone', + 'properties', + 'values', + 'collation', + 'encoding', + 'srid', + 'seed', + 'increment', + 'generated', + ]; + } + + /** + * Gets all aliased options. Each alias must reference a valid option. + * + * @return array + */ + protected function getAliasedOptions(): array + { + return [ + 'length' => 'limit', + 'precision' => 'limit', + ]; + } + + /** + * Utility method that maps an array of column options to this objects methods. + * + * @param array $options Options + * @throws \RuntimeException + * @return $this + */ + public function setOptions(array $options) + { + $validOptions = $this->getValidOptions(); + $aliasOptions = $this->getAliasedOptions(); + + if (isset($options['identity']) && $options['identity'] && !isset($options['null'])) { + $options['null'] = false; + } + + foreach ($options as $option => $value) { + if (isset($aliasOptions[$option])) { + // proxy alias -> option + $option = $aliasOptions[$option]; + } + + if (!in_array($option, $validOptions, true)) { + throw new RuntimeException(sprintf('"%s" is not a valid column option.', $option)); + } + + $method = 'set' . ucfirst($option); + $this->$method($value); + } + + return $this; + } +} diff --git a/src/Db/Table/ForeignKey.php b/src/Db/Table/ForeignKey.php new file mode 100644 index 00000000..b12fbfe2 --- /dev/null +++ b/src/Db/Table/ForeignKey.php @@ -0,0 +1,238 @@ + + */ + protected static array $validOptions = ['delete', 'update', 'constraint']; + + /** + * @var string[] + */ + protected array $columns = []; + + /** + * @var \Migrations\Db\Table\Table + */ + protected Table $referencedTable; + + /** + * @var string[] + */ + protected array $referencedColumns = []; + + /** + * @var string|null + */ + protected ?string $onDelete = null; + + /** + * @var string|null + */ + protected ?string $onUpdate = null; + + /** + * @var string|null + */ + protected ?string $constraint = null; + + /** + * Sets the foreign key columns. + * + * @param string[]|string $columns Columns + * @return $this + */ + public function setColumns(array|string $columns) + { + $this->columns = is_string($columns) ? [$columns] : $columns; + + return $this; + } + + /** + * Gets the foreign key columns. + * + * @return string[] + */ + public function getColumns(): array + { + return $this->columns; + } + + /** + * Sets the foreign key referenced table. + * + * @param \Migrations\Db\Table\Table $table The table this KEY is pointing to + * @return $this + */ + public function setReferencedTable(Table $table) + { + $this->referencedTable = $table; + + return $this; + } + + /** + * Gets the foreign key referenced table. + * + * @return \Migrations\Db\Table\Table + */ + public function getReferencedTable(): Table + { + return $this->referencedTable; + } + + /** + * Sets the foreign key referenced columns. + * + * @param string[] $referencedColumns Referenced columns + * @return $this + */ + public function setReferencedColumns(array $referencedColumns) + { + $this->referencedColumns = $referencedColumns; + + return $this; + } + + /** + * Gets the foreign key referenced columns. + * + * @return string[] + */ + public function getReferencedColumns(): array + { + return $this->referencedColumns; + } + + /** + * Sets ON DELETE action for the foreign key. + * + * @param string $onDelete On Delete + * @return $this + */ + public function setOnDelete(string $onDelete) + { + $this->onDelete = $this->normalizeAction($onDelete); + + return $this; + } + + /** + * Gets ON DELETE action for the foreign key. + * + * @return string|null + */ + public function getOnDelete(): ?string + { + return $this->onDelete; + } + + /** + * Gets ON UPDATE action for the foreign key. + * + * @return string|null + */ + public function getOnUpdate(): ?string + { + return $this->onUpdate; + } + + /** + * Sets ON UPDATE action for the foreign key. + * + * @param string $onUpdate On Update + * @return $this + */ + public function setOnUpdate(string $onUpdate) + { + $this->onUpdate = $this->normalizeAction($onUpdate); + + return $this; + } + + /** + * Sets constraint for the foreign key. + * + * @param string $constraint Constraint + * @return $this + */ + public function setConstraint(string $constraint) + { + $this->constraint = $constraint; + + return $this; + } + + /** + * Gets constraint name for the foreign key. + * + * @return string|null + */ + public function getConstraint(): ?string + { + return $this->constraint; + } + + /** + * Utility method that maps an array of index options to this objects methods. + * + * @param array $options Options + * @throws \RuntimeException + * @return $this + */ + public function setOptions(array $options) + { + foreach ($options as $option => $value) { + if (!in_array($option, static::$validOptions, true)) { + throw new RuntimeException(sprintf('"%s" is not a valid foreign key option.', $option)); + } + + // handle $options['delete'] as $options['update'] + if ($option === 'delete') { + $this->setOnDelete($value); + } elseif ($option === 'update') { + $this->setOnUpdate($value); + } else { + $method = 'set' . ucfirst($option); + $this->$method($value); + } + } + + return $this; + } + + /** + * From passed value checks if it's correct and fixes if needed + * + * @param string $action Action + * @throws \InvalidArgumentException + * @return string + */ + protected function normalizeAction(string $action): string + { + $constantName = 'static::' . str_replace(' ', '_', strtoupper(trim($action))); + if (!defined($constantName)) { + throw new InvalidArgumentException('Unknown action passed: ' . $action); + } + + return constant($constantName); + } +} diff --git a/src/Db/Table/Index.php b/src/Db/Table/Index.php new file mode 100644 index 00000000..2fb274ae --- /dev/null +++ b/src/Db/Table/Index.php @@ -0,0 +1,228 @@ +columns = is_string($columns) ? [$columns] : $columns; + + return $this; + } + + /** + * Gets the index columns. + * + * @return string[]|null + */ + public function getColumns(): ?array + { + return $this->columns; + } + + /** + * Sets the index type. + * + * @param string $type Type + * @return $this + */ + public function setType(string $type) + { + $this->type = $type; + + return $this; + } + + /** + * Gets the index type. + * + * @return string + */ + public function getType(): string + { + return $this->type; + } + + /** + * Sets the index name. + * + * @param string $name Name + * @return $this + */ + public function setName(string $name) + { + $this->name = $name; + + return $this; + } + + /** + * Gets the index name. + * + * @return string|null + */ + public function getName(): ?string + { + return $this->name; + } + + /** + * Sets the index limit. + * + * @param int|array $limit limit value or array of limit value + * @return $this + */ + public function setLimit(int|array $limit) + { + $this->limit = $limit; + + return $this; + } + + /** + * Gets the index limit. + * + * @return int|array|null + */ + public function getLimit(): int|array|null + { + return $this->limit; + } + + /** + * Sets the index columns sort order. + * + * @param string[] $order column name sort order key value pair + * @return $this + */ + public function setOrder(array $order) + { + $this->order = $order; + + return $this; + } + + /** + * Gets the index columns sort order. + * + * @return string[]|null + */ + public function getOrder(): ?array + { + return $this->order; + } + + /** + * Sets the index included columns. + * + * @param string[] $includedColumns Columns + * @return $this + */ + public function setInclude(array $includedColumns) + { + $this->includedColumns = $includedColumns; + + return $this; + } + + /** + * Gets the index included columns. + * + * @return string[]|null + */ + public function getInclude(): ?array + { + return $this->includedColumns; + } + + /** + * Utility method that maps an array of index options to this objects methods. + * + * @param array $options Options + * @throws \RuntimeException + * @return $this + */ + public function setOptions(array $options) + { + // Valid Options + $validOptions = ['type', 'unique', 'name', 'limit', 'order', 'include']; + foreach ($options as $option => $value) { + if (!in_array($option, $validOptions, true)) { + throw new RuntimeException(sprintf('"%s" is not a valid index option.', $option)); + } + + // handle $options['unique'] + if (strcasecmp($option, self::UNIQUE) === 0) { + if ((bool)$value) { + $this->setType(self::UNIQUE); + } + continue; + } + + $method = 'set' . ucfirst($option); + $this->$method($value); + } + + return $this; + } +} diff --git a/src/Db/Table/Table.php b/src/Db/Table/Table.php new file mode 100644 index 00000000..ad826709 --- /dev/null +++ b/src/Db/Table/Table.php @@ -0,0 +1,85 @@ + + */ + protected array $options; + + /** + * @param string $name The table name + * @param array $options The creation options for this table + * @throws \InvalidArgumentException + */ + public function __construct(string $name, array $options = []) + { + if (empty($name)) { + throw new InvalidArgumentException('Cannot use an empty table name'); + } + + $this->name = $name; + $this->options = $options; + } + + /** + * Sets the table name. + * + * @param string $name The name of the table + * @return $this + */ + public function setName(string $name) + { + $this->name = $name; + + return $this; + } + + /** + * Gets the table name. + * + * @return string + */ + public function getName(): string + { + return $this->name; + } + + /** + * Gets the table options + * + * @return array + */ + public function getOptions(): array + { + return $this->options; + } + + /** + * Sets the table options + * + * @param array $options The options for the table creation + * @return $this + */ + public function setOptions(array $options) + { + $this->options = $options; + + return $this; + } +} diff --git a/src/Migration/BuiltinBackend.php b/src/Migration/BuiltinBackend.php new file mode 100644 index 00000000..2b806d0e --- /dev/null +++ b/src/Migration/BuiltinBackend.php @@ -0,0 +1,254 @@ + + */ + protected array $default = []; + + /** + * Current command being run. + * Useful if some logic needs to be applied in the ConfigurationTrait depending + * on the command + * + * @var string + */ + protected string $command; + + /** + * Stub input to feed the manager class since we might not have an input ready when we get the Manager using + * the `getManager()` method + * + * @var \Symfony\Component\Console\Input\ArrayInput + */ + protected ArrayInput $stubInput; + + /** + * Constructor + * + * @param array $default Default option to be used when calling a method. + * Available options are : + * - `connection` The datasource connection to use + * - `source` The folder where migrations are in + * - `plugin` The plugin containing the migrations + */ + public function __construct(array $default = []) + { + $this->output = new NullOutput(); + $this->stubInput = new ArrayInput([]); + + if ($default) { + $this->default = $default; + } + } + + /** + * Returns the status of each migrations based on the options passed + * + * @param array $options Options to pass to the command + * Available options are : + * + * - `format` Format to output the response. Can be 'json' + * - `connection` The datasource connection to use + * - `source` The folder where migrations are in + * - `plugin` The plugin containing the migrations + * @return array The migrations list and their statuses + */ + public function status(array $options = []): array + { + $manager = $this->getManager($options); + + return $manager->printStatus($options['format'] ?? null); + } + + /** + * Migrates available migrations + * + * @param array $options Options to pass to the command + * Available options are : + * + * - `target` The version number to migrate to. If not provided, will migrate + * everything it can + * - `connection` The datasource connection to use + * - `source` The folder where migrations are in + * - `plugin` The plugin containing the migrations + * - `date` The date to migrate to + * @return bool Success + */ + public function migrate(array $options = []): bool + { + $manager = $this->getManager($options); + + if (!empty($options['date'])) { + $date = new DateTime($options['date']); + + $manager->migrateToDateTime($date); + + return true; + } + + $manager->migrate($options['target'] ?? null); + + return true; + } + + /** + * Rollbacks migrations + * + * @param array $options Options to pass to the command + * Available options are : + * + * - `target` The version number to migrate to. If not provided, will only migrate + * the last migrations registered in the phinx log + * - `connection` The datasource connection to use + * - `source` The folder where migrations are in + * - `plugin` The plugin containing the migrations + * - `date` The date to rollback to + * @return bool Success + */ + public function rollback(array $options = []): bool + { + $manager = $this->getManager($options); + + if (!empty($options['date'])) { + $date = new DateTime($options['date']); + + $manager->rollbackToDateTime($date); + + return true; + } + + $manager->rollback($options['target'] ?? null); + + return true; + } + + /** + * Marks a migration as migrated + * + * @param int|string|null $version The version number of the migration to mark as migrated + * @param array $options Options to pass to the command + * Available options are : + * + * - `connection` The datasource connection to use + * - `source` The folder where migrations are in + * - `plugin` The plugin containing the migrations + * @return bool Success + */ + public function markMigrated(int|string|null $version = null, array $options = []): bool + { + if ( + isset($options['target']) && + isset($options['exclude']) && + isset($options['only']) + ) { + $exceptionMessage = 'You should use `exclude` OR `only` (not both) along with a `target` argument'; + throw new InvalidArgumentException($exceptionMessage); + } + $args = new Arguments([(string)$version], $options, ['version']); + + $manager = $this->getManager($options); + $config = $manager->getConfig(); + $path = $config->getMigrationPath(); + + $versions = $manager->getVersionsToMark($args); + $manager->markVersionsAsMigrated($path, $versions); + + return true; + } + + /** + * Seed the database using a seed file + * + * @param array $options Options to pass to the command + * Available options are : + * + * - `connection` The datasource connection to use + * - `source` The folder where migrations are in + * - `plugin` The plugin containing the migrations + * - `seed` The seed file to use + * @return bool Success + */ + public function seed(array $options = []): bool + { + $options['source'] ??= ConfigInterface::DEFAULT_SEED_FOLDER; + $seed = $options['seed'] ?? null; + + $manager = $this->getManager($options); + $manager->seed($seed); + + return true; + } + + /** + * Returns an instance of Manager + * + * @param array $options The options for manager creation + * @return \Migrations\Migration\Manager Instance of Manager + */ + public function getManager(array $options): Manager + { + $options += $this->default; + + $factory = new ManagerFactory([ + 'plugin' => $options['plugin'] ?? null, + 'source' => $options['source'] ?? ConfigInterface::DEFAULT_MIGRATION_FOLDER, + 'connection' => $options['connection'] ?? 'default', + ]); + $io = new ConsoleIo( + new StubConsoleOutput(), + new StubConsoleOutput(), + new StubConsoleInput([]), + ); + + return $factory->createManager($io); + } +} diff --git a/src/Migration/Environment.php b/src/Migration/Environment.php new file mode 100644 index 00000000..3ce62820 --- /dev/null +++ b/src/Migration/Environment.php @@ -0,0 +1,369 @@ + + */ + protected array $options; + + /** + * @var \Cake\Console\ConsoleIo|null + */ + protected ?ConsoleIo $io = null; + + /** + * @var int + */ + protected int $currentVersion; + + /** + * @var string + */ + protected string $schemaTableName = 'phinxlog'; + + /** + * @var \Migrations\Db\Adapter\AdapterInterface + */ + protected AdapterInterface $adapter; + + /** + * @param string $name Environment Name + * @param array $options Options + */ + public function __construct(string $name, array $options) + { + $this->name = $name; + $this->options = $options; + } + + /** + * Executes the specified migration on this environment. + * + * @param \Phinx\Migration\MigrationInterface $migration Migration + * @param string $direction Direction + * @param bool $fake flag that if true, we just record running the migration, but not actually do the migration + * @return void + */ + public function executeMigration(MigrationInterface $migration, string $direction = MigrationInterface::UP, bool $fake = false): void + { + $direction = $direction === MigrationInterface::UP ? MigrationInterface::UP : MigrationInterface::DOWN; + $migration->setMigratingUp($direction === MigrationInterface::UP); + + $startTime = time(); + // Use an adapter shim to bridge between the new migrations + // engine and the Phinx compatible interface + $adapter = $this->getAdapter(); + $phinxShim = new PhinxAdapter($adapter); + $migration->setAdapter($phinxShim); + + $migration->preFlightCheck(); + + if (method_exists($migration, MigrationInterface::INIT)) { + $migration->{MigrationInterface::INIT}(); + } + + // begin the transaction if the adapter supports it + if ($adapter->hasTransactions()) { + $adapter->beginTransaction(); + } + + if (!$fake) { + // Run the migration + if (method_exists($migration, MigrationInterface::CHANGE)) { + if ($direction === MigrationInterface::DOWN) { + // Create an instance of the RecordingAdapter so we can record all + // of the migration commands for reverse playback + + /** @var \Migrations\Db\Adapter\RecordingAdapter $recordAdapter */ + $recordAdapter = AdapterFactory::instance() + ->getWrapper('record', $adapter); + + // Wrap the adapter with a phinx shim to maintain contain + $phinxAdapter = new PhinxAdapter($recordAdapter); + $migration->setAdapter($phinxAdapter); + + $migration->{MigrationInterface::CHANGE}(); + $recordAdapter->executeInvertedCommands(); + + $migration->setAdapter(new PhinxAdapter($this->getAdapter())); + } else { + $migration->{MigrationInterface::CHANGE}(); + } + } else { + $migration->{$direction}(); + } + } + + // Record it in the database + $adapter->migrated($migration, $direction, date('Y-m-d H:i:s', $startTime), date('Y-m-d H:i:s', time())); + + // commit the transaction if the adapter supports it + if ($adapter->hasTransactions()) { + $adapter->commitTransaction(); + } + + $migration->postFlightCheck(); + } + + /** + * Executes the specified seeder on this environment. + * + * @param \Phinx\Seed\SeedInterface $seed Seed + * @return void + */ + public function executeSeed(SeedInterface $seed): void + { + $adapter = $this->getAdapter(); + $phinxAdapter = new PhinxAdapter($adapter); + + $seed->setAdapter($phinxAdapter); + if (method_exists($seed, SeedInterface::INIT)) { + $seed->{SeedInterface::INIT}(); + } + + // begin the transaction if the adapter supports it + if ($adapter->hasTransactions()) { + $adapter->beginTransaction(); + } + + // Run the seeder + if (method_exists($seed, SeedInterface::RUN)) { + $seed->{SeedInterface::RUN}(); + } + + // commit the transaction if the adapter supports it + if ($adapter->hasTransactions()) { + $adapter->commitTransaction(); + } + } + + /** + * Sets the environment's name. + * + * @param string $name Environment Name + * @return $this + */ + public function setName(string $name) + { + $this->name = $name; + + return $this; + } + + /** + * Gets the environment name. + * + * @return string + */ + public function getName(): string + { + return $this->name; + } + + /** + * Sets the environment's options. + * + * @param array $options Environment Options + * @return $this + */ + public function setOptions(array $options) + { + $this->options = $options; + + return $this; + } + + /** + * Gets the environment's options. + * + * @return array + */ + public function getOptions(): array + { + return $this->options; + } + + /** + * Sets the consoleio. + * + * @param \Cake\Console\ConsoleIo $io ConsoleIo + * @return $this + */ + public function setIo(ConsoleIo $io) + { + $this->io = $io; + + return $this; + } + + /** + * Get the io instance + * + * @return \Cake\Console\ConsoleIo $io The io instance to use + */ + public function getIo(): ?ConsoleIo + { + return $this->io; + } + + /** + * Gets all migrated version numbers. + * + * @return array + */ + public function getVersions(): array + { + return $this->getAdapter()->getVersions(); + } + + /** + * Get all migration log entries, indexed by version creation time and sorted in ascending order by the configuration's + * version_order option + * + * @return array + */ + public function getVersionLog(): array + { + return $this->getAdapter()->getVersionLog(); + } + + /** + * Sets the current version of the environment. + * + * @param int $version Environment Version + * @return $this + */ + public function setCurrentVersion(int $version) + { + $this->currentVersion = $version; + + return $this; + } + + /** + * Gets the current version of the environment. + * + * @return int + */ + public function getCurrentVersion(): int + { + // We don't cache this code as the current version is pretty volatile. + // that means they're no point in a setter then? + // maybe we should cache and call a reset() method every time a migration is run + $versions = $this->getVersions(); + $version = 0; + + if (!empty($versions)) { + $version = end($versions); + } + + $this->setCurrentVersion($version); + + return $this->currentVersion; + } + + /** + * Sets the database adapter. + * + * @param \Migrations\Db\Adapter\AdapterInterface $adapter Database Adapter + * @return $this + */ + public function setAdapter(AdapterInterface $adapter) + { + $this->adapter = $adapter; + + return $this; + } + + /** + * Gets the database adapter. + * + * @throws \RuntimeException + * @return \Migrations\Db\Adapter\AdapterInterface + */ + public function getAdapter(): AdapterInterface + { + if (isset($this->adapter)) { + return $this->adapter; + } + + $options = $this->getOptions(); + if (!isset($options['connection'])) { + throw new RuntimeException('No connection defined'); + } + $connection = ConnectionManager::get($options['connection']); + $options['connection'] = $connection; + + // Get the driver classname as those are aligned with adapter names. + $driver = $connection->getDriver(); + $driverClass = get_class($driver); + $driverName = strtolower(substr($driverClass, (int)strrpos($driverClass, '\\') + 1)); + $options['adapter'] = $driverName; + + $factory = AdapterFactory::instance(); + $adapter = $factory + ->getAdapter($driverName, $options); + + // Automatically time the executed commands + $adapter = $factory->getWrapper('timed', $adapter); + + if (isset($options['wrapper'])) { + $adapter = $factory + ->getWrapper($options['wrapper'], $adapter); + } + + $io = $this->getIo(); + if ($io) { + $adapter->setIo($io); + } + $this->setAdapter($adapter); + + return $adapter; + } + + /** + * Sets the schema table name. + * + * @param string $schemaTableName Schema Table Name + * @return $this + */ + public function setSchemaTableName(string $schemaTableName) + { + $this->schemaTableName = $schemaTableName; + + return $this; + } + + /** + * Gets the schema table name. + * + * @return string + */ + public function getSchemaTableName(): string + { + return $this->schemaTableName; + } +} diff --git a/src/Migration/IrreversibleMigrationException.php b/src/Migration/IrreversibleMigrationException.php new file mode 100644 index 00000000..ebcd744a --- /dev/null +++ b/src/Migration/IrreversibleMigrationException.php @@ -0,0 +1,19 @@ +setConfig($config); + $this->setIo($io); + } + + /** + * Prints the specified environment's migration status. + * + * @param string|null $format format to print status in (either text, json, or null) + * @throws \RuntimeException + * @return array array indicating if there are any missing or down migrations + */ + public function printStatus(?string $format = null): array + { + $migrations = []; + $isJson = $format === 'json'; + $defaultMigrations = $this->getMigrations(); + if (count($defaultMigrations)) { + $env = $this->getEnvironment(); + $versions = $env->getVersionLog(); + + foreach ($defaultMigrations as $migration) { + if (array_key_exists($migration->getVersion(), $versions)) { + $status = 'up'; + unset($versions[$migration->getVersion()]); + } else { + $status = 'down'; + } + + $version = $migration->getVersion(); + $migrationParams = [ + 'status' => $status, + 'id' => $migration->getVersion(), + 'name' => $migration->getName(), + ]; + + $migrations[$version] = $migrationParams; + } + + foreach ($versions as $missing) { + $version = $missing['version']; + $migrationParams = [ + 'status' => 'up', + 'id' => $version, + 'name' => $missing['migration_name'], + ]; + + if (!$isJson) { + $migrationParams = [ + 'missing' => true, + ] + $migrationParams; + } + + $migrations[$version] = $migrationParams; + } + } + + ksort($migrations); + $migrations = array_values($migrations); + + return $migrations; + } + + /** + * Print Missing Version + * + * @param array $version The missing version to print (in the format returned by Environment.getVersionLog). + * @param int $maxNameLength The maximum migration name length. + * @return void + */ + protected function printMissingVersion(array $version, int $maxNameLength): void + { + $io = $this->getIo(); + $io->out(sprintf( + ' up %14.0f %19s %19s %s ** MISSING MIGRATION FILE **', + $version['version'], + $version['start_time'], + $version['end_time'], + str_pad($version['migration_name'], $maxNameLength, ' ') + )); + + if ($version && $version['breakpoint']) { + $io->out(' BREAKPOINT SET'); + } + } + + /** + * Migrate to the version of the database on a given date. + * + * @param \DateTime $dateTime Date to migrate to + * @param bool $fake flag that if true, we just record running the migration, but not actually do the + * migration + * @return void + */ + public function migrateToDateTime(DateTime $dateTime, bool $fake = false): void + { + /** @var array $versions */ + $versions = array_keys($this->getMigrations()); + $dateString = $dateTime->format('Ymdhis'); + $versionToMigrate = null; + foreach ($versions as $version) { + if ($dateString > $version) { + $versionToMigrate = $version; + } + } + + $io = $this->getIo(); + if ($versionToMigrate === null) { + $io->out('No migrations to run'); + + return; + } + + $io->out('Migrating to version ' . $versionToMigrate); + $this->migrate($versionToMigrate, $fake); + } + + /** + * @inheritDoc + */ + public function rollbackToDateTime(DateTime $dateTime, bool $force = false): void + { + $env = $this->getEnvironment(); + $versions = $env->getVersions(); + $dateString = $dateTime->format('Ymdhis'); + sort($versions); + $versions = array_reverse($versions); + + if (empty($versions) || $dateString > $versions[0]) { + $this->getIo()->out('No migrations to rollback'); + + return; + } + + if ($dateString < end($versions)) { + $this->getIo()->out('Rolling back all migrations'); + $this->rollback(0); + + return; + } + + $index = 0; + foreach ($versions as $index => $version) { + if ($dateString > $version) { + break; + } + } + + $versionToRollback = $versions[$index]; + + $this->getIo()->out('Rolling back to version ' . $versionToRollback); + $this->rollback($versionToRollback, $force); + } + + /** + * Checks if the migration with version number $version as already been mark migrated + * + * @param int $version Version number of the migration to check + * @return bool + */ + public function isMigrated(int $version): bool + { + $adapter = $this->getEnvironment()->getAdapter(); + /** @var array $versions */ + $versions = array_flip($adapter->getVersions()); + + return isset($versions[$version]); + } + + /** + * Marks migration with version number $version migrated + * + * @param int $version Version number of the migration to check + * @param string $path Path where the migration file is located + * @return bool True if success + */ + public function markMigrated(int $version, string $path): bool + { + $adapter = $this->getEnvironment()->getAdapter(); + + $migrationFile = glob($path . DS . $version . '*'); + + if (empty($migrationFile)) { + throw new RuntimeException( + sprintf('A migration file matching version number `%s` could not be found', $version) + ); + } + + $migrationFile = $migrationFile[0]; + /** @var class-string<\Phinx\Migration\MigrationInterface> $className */ + $className = $this->getMigrationClassName($migrationFile); + require_once $migrationFile; + $Migration = new $className('default', $version); + + $time = date('Y-m-d H:i:s', time()); + + $adapter->migrated($Migration, 'up', $time, $time); + + return true; + } + + /** + * Resolves a migration class name based on $path + * + * @param string $path Path to the migration file of which we want the class name + * @return string Migration class name + */ + protected function getMigrationClassName(string $path): string + { + $class = (string)preg_replace('/^[0-9]+_/', '', basename($path)); + $class = str_replace('_', ' ', $class); + $class = ucwords($class); + $class = str_replace(' ', '', $class); + if (strpos($class, '.') !== false) { + /** @psalm-suppress PossiblyFalseArgument */ + $class = substr($class, 0, strpos($class, '.')); + } + + return $class; + } + + /** + * Decides which versions it should mark as migrated + * + * @param \Cake\Console\Arguments $args Console arguments will be extracted + * to determine which versions to be marked as migrated + * @return array Array of versions that should be marked as migrated + * @throws \InvalidArgumentException If the `--exclude` or `--only` options are used without `--target` + * or version not found + */ + public function getVersionsToMark(Arguments $args): array + { + $migrations = $this->getMigrations(); + $versions = array_keys($migrations); + + $versionArg = $args->getArgument('version'); + $targetArg = $args->getOption('target'); + $hasAllVersion = in_array($versionArg, ['all', '*'], true); + if ((empty($versionArg) && empty($targetArg)) || $hasAllVersion) { + return $versions; + } + + $version = (int)$targetArg ?: (int)$versionArg; + + if ($args->getOption('only') || !empty($versionArg)) { + if (!in_array($version, $versions)) { + throw new InvalidArgumentException("Migration `$version` was not found !"); + } + + return [$version]; + } + + $lengthIncrease = $args->getOption('exclude') ? 0 : 1; + $index = array_search($version, $versions); + + if ($index === false) { + throw new InvalidArgumentException("Migration `$version` was not found !"); + } + + return array_slice($versions, 0, $index + $lengthIncrease); + } + + /** + * Mark all migrations in $versions array found in $path as migrated + * + * It will start a transaction and rollback in case one of the operation raises an exception + * + * @param string $path Path where to look for migrations + * @param array $versions Versions which should be marked + * @return list Output from the operation + */ + public function markVersionsAsMigrated(string $path, array $versions): array + { + $adapter = $this->getEnvironment()->getAdapter(); + $out = []; + + if (!$versions) { + $out[] = 'No migrations were found. Nothing to mark as migrated.'; + + return $out; + } + + $adapter->beginTransaction(); + foreach ($versions as $version) { + if ($this->isMigrated($version)) { + $out[] = sprintf('Skipping migration `%s` (already migrated).', $version); + continue; + } + + try { + $this->markMigrated($version, $path); + $out[] = sprintf('Migration `%s` successfully marked migrated !', $version); + } catch (Exception $e) { + $adapter->rollbackTransaction(); + $out[] = sprintf( + 'An error occurred while marking migration `%s` as migrated : %s', + $version, + $e->getMessage() + ); + $out[] = 'All marked migrations during this process were unmarked.'; + + return $out; + } + } + $adapter->commitTransaction(); + + return $out; + } + + /** + * Migrate an environment to the specified version. + * + * @param int|null $version version to migrate to + * @param bool $fake flag that if true, we just record running the migration, but not actually do the migration + * @return void + */ + public function migrate(?int $version = null, bool $fake = false): void + { + $migrations = $this->getMigrations(); + $env = $this->getEnvironment(); + $versions = $env->getVersions(); + $current = $env->getCurrentVersion(); + + if (empty($versions) && empty($migrations)) { + return; + } + + if ($version === null) { + $version = max(array_merge($versions, array_keys($migrations))); + } else { + if ($version != 0 && !isset($migrations[$version])) { + $this->getIo()->out(sprintf( + 'warning %s is not a valid version', + $version + )); + + return; + } + } + + // are we migrating up or down? + $direction = $version > $current ? MigrationInterface::UP : MigrationInterface::DOWN; + + if ($direction === MigrationInterface::DOWN) { + // run downs first + krsort($migrations); + foreach ($migrations as $migration) { + if ($migration->getVersion() <= $version) { + break; + } + + if (in_array($migration->getVersion(), $versions)) { + $this->executeMigration($migration, MigrationInterface::DOWN, $fake); + } + } + } + + ksort($migrations); + foreach ($migrations as $migration) { + if ($migration->getVersion() > $version) { + break; + } + + if (!in_array($migration->getVersion(), $versions)) { + $this->executeMigration($migration, MigrationInterface::UP, $fake); + } + } + } + + /** + * Execute a migration against the specified environment. + * + * @param \Phinx\Migration\MigrationInterface $migration Migration + * @param string $direction Direction + * @param bool $fake flag that if true, we just record running the migration, but not actually do the migration + * @return void + */ + public function executeMigration(MigrationInterface $migration, string $direction = MigrationInterface::UP, bool $fake = false): void + { + $this->getIo()->out(''); + + // Skip the migration if it should not be executed + if (!$migration->shouldExecute()) { + $this->printMigrationStatus($migration, 'skipped'); + + return; + } + + $this->printMigrationStatus($migration, ($direction === MigrationInterface::UP ? 'migrating' : 'reverting')); + + // Execute the migration and log the time elapsed. + $start = microtime(true); + $this->getEnvironment()->executeMigration($migration, $direction, $fake); + $end = microtime(true); + + $this->printMigrationStatus( + $migration, + ($direction === MigrationInterface::UP ? 'migrated' : 'reverted'), + sprintf('%.4fs', $end - $start) + ); + } + + /** + * Execute a seeder against the specified environment. + * + * @param \Phinx\Seed\SeedInterface $seed Seed + * @return void + */ + public function executeSeed(SeedInterface $seed): void + { + $this->getIo()->out(''); + + // Skip the seed if it should not be executed + if (!$seed->shouldExecute()) { + $this->printSeedStatus($seed, 'skipped'); + + return; + } + + $this->printSeedStatus($seed, 'seeding'); + + // Execute the seeder and log the time elapsed. + $start = microtime(true); + $this->getEnvironment()->executeSeed($seed); + $end = microtime(true); + + $this->printSeedStatus( + $seed, + 'seeded', + sprintf('%.4fs', $end - $start) + ); + } + + /** + * Print Migration Status + * + * @param \Phinx\Migration\MigrationInterface $migration Migration + * @param string $status Status of the migration + * @param string|null $duration Duration the migration took the be executed + * @return void + */ + protected function printMigrationStatus(MigrationInterface $migration, string $status, ?string $duration = null): void + { + $this->printStatusOutput( + $migration->getVersion() . ' ' . $migration->getName(), + $status, + $duration + ); + } + + /** + * Print Seed Status + * + * @param \Phinx\Seed\SeedInterface $seed Seed + * @param string $status Status of the seed + * @param string|null $duration Duration the seed took the be executed + * @return void + */ + protected function printSeedStatus(SeedInterface $seed, string $status, ?string $duration = null): void + { + $this->printStatusOutput( + $seed->getName(), + $status, + $duration + ); + } + + /** + * Print Status in Output + * + * @param string $name Name of the migration or seed + * @param string $status Status of the migration or seed + * @param string|null $duration Duration the migration or seed took the be executed + * @return void + */ + protected function printStatusOutput(string $name, string $status, ?string $duration = null): void + { + $this->getIo()->out( + ' ==' . + ' ' . $name . ':' . + ' ' . $status . ' ' . $duration . '', + ); + } + + /** + * Rollback an environment to the specified version. + * + * @param int|string|null $target Target + * @param bool $force Force + * @param bool $targetMustMatchVersion Target must match version + * @param bool $fake Flag that if true, we just record running the migration, but not actually do the migration + * @return void + */ + public function rollback(int|string|null $target = null, bool $force = false, bool $targetMustMatchVersion = true, bool $fake = false): void + { + // note that the migrations are indexed by name (aka creation time) in ascending order + $migrations = $this->getMigrations(); + + // note that the version log are also indexed by name with the proper ascending order according to the version order + $executedVersions = $this->getEnvironment()->getVersionLog(); + + // get a list of migrations sorted in the opposite way of the executed versions + $sortedMigrations = []; + $io = $this->getIo(); + + foreach ($executedVersions as $versionCreationTime => &$executedVersion) { + // if we have a date (ie. the target must not match a version) and we are sorting by execution time, we + // convert the version start time so we can compare directly with the target date + if (!$this->getConfig()->isVersionOrderCreationTime() && !$targetMustMatchVersion) { + /** @var \DateTime $dateTime */ + $dateTime = DateTime::createFromFormat('Y-m-d H:i:s', $executedVersion['start_time']); + $executedVersion['start_time'] = $dateTime->format('YmdHis'); + } + + if (isset($migrations[$versionCreationTime])) { + array_unshift($sortedMigrations, $migrations[$versionCreationTime]); + } else { + // this means the version is missing so we unset it so that we don't consider it when rolling back + // migrations (or choosing the last up version as target) + unset($executedVersions[$versionCreationTime]); + } + } + + if ($target === 'all' || $target === '0') { + $target = 0; + } elseif (!is_numeric($target) && $target !== null) { // try to find a target version based on name + // search through the migrations using the name + $migrationNames = array_map(function ($item) { + return $item['migration_name']; + }, $executedVersions); + $found = array_search($target, $migrationNames, true); + + // check on was found + if ($found !== false) { + $target = (string)$found; + } else { + $io->out("No migration found with name ($target)"); + + return; + } + } + + // Check we have at least 1 migration to revert + $executedVersionCreationTimes = array_keys($executedVersions); + if (empty($executedVersionCreationTimes) || $target == end($executedVersionCreationTimes)) { + $io->out('No migrations to rollback'); + + return; + } + + // If no target was supplied, revert the last migration + if ($target === null) { + // Get the migration before the last run migration + $prev = count($executedVersionCreationTimes) - 2; + $target = $prev >= 0 ? $executedVersionCreationTimes[$prev] : 0; + } + + // If the target must match a version, check the target version exists + if ($targetMustMatchVersion && $target !== 0 && !isset($migrations[$target])) { + $io->out("Target version ($target) not found"); + + return; + } + + // Rollback all versions until we find the wanted rollback target + $rollbacked = false; + + foreach ($sortedMigrations as $migration) { + if ($targetMustMatchVersion && $migration->getVersion() == $target) { + break; + } + + if (in_array($migration->getVersion(), $executedVersionCreationTimes)) { + $executedVersion = $executedVersions[$migration->getVersion()]; + + if (!$targetMustMatchVersion) { + if ( + ($this->getConfig()->isVersionOrderCreationTime() && $executedVersion['version'] <= $target) || + (!$this->getConfig()->isVersionOrderCreationTime() && $executedVersion['start_time'] <= $target) + ) { + break; + } + } + + if ($executedVersion['breakpoint'] != 0 && !$force) { + $io->out('Breakpoint reached. Further rollbacks inhibited.'); + break; + } + $this->executeMigration($migration, MigrationInterface::DOWN, $fake); + $rollbacked = true; + } + } + + if (!$rollbacked) { + $this->getIo()->out('No migrations to rollback'); + } + } + + /** + * Run database seeders against an environment. + * + * @param string|null $seed Seeder + * @throws \InvalidArgumentException + * @return void + */ + public function seed(?string $seed = null): void + { + $seeds = $this->getSeeds(); + + if ($seed === null) { + // run all seeders + foreach ($seeds as $seeder) { + if (array_key_exists($seeder->getName(), $seeds)) { + $this->executeSeed($seeder); + } + } + } else { + // run only one seeder + if (array_key_exists($seed, $seeds)) { + $this->executeSeed($seeds[$seed]); + } else { + throw new InvalidArgumentException(sprintf('The seed class "%s" does not exist', $seed)); + } + } + } + + /** + * Gets the manager class for the given environment. + * + * @throws \InvalidArgumentException + * @return \Migrations\Migration\Environment + */ + public function getEnvironment(): Environment + { + if (isset($this->environment)) { + return $this->environment; + } + + $config = $this->getConfig(); + // create an environment instance and cache it + $envOptions = $config->getEnvironment(); + assert(is_array($envOptions)); + + $environment = new Environment('default', $envOptions); + $environment->setIo($this->getIo()); + $this->environment = $environment; + + return $environment; + } + + /** + * Set the io instance + * + * @param \Cake\Console\ConsoleIo $io The io instance to use + * @return $this + */ + public function setIo(ConsoleIo $io) + { + $this->io = $io; + + return $this; + } + + /** + * Get the io instance + * + * @return \Cake\Console\ConsoleIo $io The io instance to use + */ + public function getIo(): ConsoleIo + { + return $this->io; + } + + /** + * Replace the environment + * + * @param \Migrations\Migration\Environment $environment + * @return $this + */ + public function setEnvironment(Environment $environment) + { + $this->environment = $environment; + + return $this; + } + + /** + * Sets the user defined PSR-11 container + * + * @param \Psr\Container\ContainerInterface $container Container + * @return $this + */ + public function setContainer(ContainerInterface $container) + { + $this->container = $container; + + return $this; + } + + /** + * Sets the database migrations. + * + * @param \Phinx\Migration\AbstractMigration[] $migrations Migrations + * @return $this + */ + public function setMigrations(array $migrations) + { + $this->migrations = $migrations; + + return $this; + } + + /** + * Gets an array of the database migrations, indexed by migration name (aka creation time) and sorted in ascending + * order + * + * @throws \InvalidArgumentException + * @return \Phinx\Migration\MigrationInterface[] + */ + public function getMigrations(): array + { + if ($this->migrations === null) { + $phpFiles = $this->getMigrationFiles(); + + $io = $this->getIo(); + $io->verbose('Migration file'); + $io->verbose( + array_map( + function ($phpFile) { + return " {$phpFile}"; + }, + $phpFiles + ) + ); + + // filter the files to only get the ones that match our naming scheme + $fileNames = []; + /** @var \Phinx\Migration\AbstractMigration[] $versions */ + $versions = []; + + $io = $this->getIo(); + foreach ($phpFiles as $filePath) { + if (Util::isValidMigrationFileName(basename($filePath))) { + $io->verbose("Valid migration file {$filePath}."); + + $version = Util::getVersionFromFileName(basename($filePath)); + + if (isset($versions[$version])) { + throw new InvalidArgumentException(sprintf('Duplicate migration - "%s" has the same version as "%s"', $filePath, $versions[$version]->getVersion())); + } + + // convert the filename to a class name + $class = Util::mapFileNameToClassName(basename($filePath)); + + if (isset($fileNames[$class])) { + throw new InvalidArgumentException(sprintf( + 'Migration "%s" has the same name as "%s"', + basename($filePath), + $fileNames[$class] + )); + } + + $fileNames[$class] = basename($filePath); + + $io->verbose("Loading class $class from $filePath."); + + // load the migration file + $orig_display_errors_setting = ini_get('display_errors'); + ini_set('display_errors', 'On'); + /** @noinspection PhpIncludeInspection */ + require_once $filePath; + ini_set('display_errors', $orig_display_errors_setting); + if (!class_exists($class)) { + throw new InvalidArgumentException(sprintf( + 'Could not find class "%s" in file "%s"', + $class, + $filePath + )); + } + + $io->verbose("Constructing $class."); + + $config = $this->getConfig(); + $input = new ArrayInput([ + '--plugin' => $config['plugin'] ?? null, + '--source' => $config['source'] ?? null, + '--connection' => $config->getConnection(), + ]); + $output = new OutputAdapter($io); + + // instantiate it + $migration = new $class('default', $version, $input, $output); + + if (!($migration instanceof AbstractMigration)) { + throw new InvalidArgumentException(sprintf( + 'The class "%s" in file "%s" must extend \Phinx\Migration\AbstractMigration', + $class, + $filePath + )); + } + + $versions[$version] = $migration; + } else { + $io->verbose("Invalid migration file {$filePath}."); + } + } + + ksort($versions); + $this->setMigrations($versions); + } + + return (array)$this->migrations; + } + + /** + * Returns a list of migration files found in the provided migration paths. + * + * @return string[] + */ + protected function getMigrationFiles(): array + { + return Util::getFiles($this->getConfig()->getMigrationPath()); + } + + /** + * Sets the database seeders. + * + * @param \Phinx\Seed\SeedInterface[] $seeds Seeders + * @return $this + */ + public function setSeeds(array $seeds) + { + $this->seeds = $seeds; + + return $this; + } + + /** + * Get seed dependencies instances from seed dependency array + * + * @param \Phinx\Seed\SeedInterface $seed Seed + * @return \Phinx\Seed\SeedInterface[] + */ + protected function getSeedDependenciesInstances(SeedInterface $seed): array + { + $dependenciesInstances = []; + $dependencies = $seed->getDependencies(); + if (!empty($dependencies) && !empty($this->seeds)) { + foreach ($dependencies as $dependency) { + foreach ($this->seeds as $seed) { + if (get_class($seed) === $dependency) { + $dependenciesInstances[get_class($seed)] = $seed; + } + } + } + } + + return $dependenciesInstances; + } + + /** + * Order seeds by dependencies + * + * @param \Phinx\Seed\SeedInterface[] $seeds Seeds + * @return \Phinx\Seed\SeedInterface[] + */ + protected function orderSeedsByDependencies(array $seeds): array + { + $orderedSeeds = []; + foreach ($seeds as $seed) { + $orderedSeeds[get_class($seed)] = $seed; + $dependencies = $this->getSeedDependenciesInstances($seed); + if (!empty($dependencies)) { + $orderedSeeds = array_merge($this->orderSeedsByDependencies($dependencies), $orderedSeeds); + } + } + + return $orderedSeeds; + } + + /** + * Gets an array of database seeders. + * + * @throws \InvalidArgumentException + * @return \Phinx\Seed\SeedInterface[] + */ + public function getSeeds(): array + { + if ($this->seeds === null) { + $phpFiles = $this->getSeedFiles(); + + // filter the files to only get the ones that match our naming scheme + $fileNames = []; + /** @var \Phinx\Seed\SeedInterface[] $seeds */ + $seeds = []; + + $config = $this->getConfig(); + $optionDef = new InputDefinition([ + new InputOption('plugin', mode: InputOption::VALUE_OPTIONAL, default: ''), + new InputOption('connection', mode: InputOption::VALUE_OPTIONAL, default: ''), + new InputOption('source', mode: InputOption::VALUE_OPTIONAL, default: ''), + ]); + $input = new ArrayInput([ + '--plugin' => $config['plugin'] ?? null, + '--source' => $config['source'] ?? null, + '--connection' => $config->getConnection(), + ], $optionDef); + $output = new OutputAdapter($this->io); + + foreach ($phpFiles as $filePath) { + if (Util::isValidSeedFileName(basename($filePath))) { + // convert the filename to a class name + $class = pathinfo($filePath, PATHINFO_FILENAME); + $fileNames[$class] = basename($filePath); + + // load the seed file + /** @noinspection PhpIncludeInspection */ + require_once $filePath; + if (!class_exists($class)) { + throw new InvalidArgumentException(sprintf( + 'Could not find class "%s" in file "%s"', + $class, + $filePath + )); + } + + // instantiate it + /** @var \Phinx\Seed\AbstractSeed $seed */ + if (isset($this->container)) { + $seed = $this->container->get($class); + } else { + $seed = new $class(); + } + $seed->setEnvironment('default'); + $seed->setInput($input); + $seed->setOutput($output); + + if (!($seed instanceof AbstractSeed)) { + throw new InvalidArgumentException(sprintf( + 'The class "%s" in file "%s" must extend \Phinx\Seed\AbstractSeed', + $class, + $filePath + )); + } + + $seeds[$class] = $seed; + } + } + + ksort($seeds); + $this->setSeeds($seeds); + } + $this->seeds = $this->orderSeedsByDependencies((array)$this->seeds); + if (empty($this->seeds)) { + return []; + } + + foreach ($this->seeds as $instance) { + if (isset($input) && $instance instanceof AbstractSeed) { + $instance->setInput($input); + } + } + + return $this->seeds; + } + + /** + * Returns a list of seed files found in the provided seed paths. + * + * @return string[] + */ + protected function getSeedFiles(): array + { + return Util::getFiles($this->getConfig()->getSeedPath()); + } + + /** + * Sets the config. + * + * @param \Migrations\Config\ConfigInterface $config Configuration Object + * @return $this + */ + public function setConfig(ConfigInterface $config) + { + $this->config = $config; + + return $this; + } + + /** + * Gets the config. + * + * @return \Migrations\Config\ConfigInterface + */ + public function getConfig(): ConfigInterface + { + return $this->config; + } + + /** + * Toggles the breakpoint for a specific version. + * + * @param int|null $version Version + * @return void + */ + public function toggleBreakpoint(?int $version): void + { + $this->markBreakpoint($version, self::BREAKPOINT_TOGGLE); + } + + /** + * Updates the breakpoint for a specific version. + * + * @param int|null $version The version of the target migration + * @param int $mark The state of the breakpoint as defined by self::BREAKPOINT_xxxx constants. + * @return void + */ + protected function markBreakpoint(?int $version, int $mark): void + { + $migrations = $this->getMigrations(); + $env = $this->getEnvironment(); + $versions = $env->getVersionLog(); + + if (empty($versions) || empty($migrations)) { + return; + } + + if ($version === null) { + $lastVersion = end($versions); + $version = $lastVersion['version']; + } + + $io = $this->getIo(); + if ($version != 0 && (!isset($versions[$version]) || !isset($migrations[$version]))) { + $io->out(sprintf( + 'warning %s is not a valid version', + $version + )); + + return; + } + + switch ($mark) { + case self::BREAKPOINT_TOGGLE: + $env->getAdapter()->toggleBreakpoint($migrations[$version]); + break; + case self::BREAKPOINT_SET: + if ($versions[$version]['breakpoint'] == 0) { + $env->getAdapter()->setBreakpoint($migrations[$version]); + } + break; + case self::BREAKPOINT_UNSET: + if ($versions[$version]['breakpoint'] == 1) { + $env->getAdapter()->unsetBreakpoint($migrations[$version]); + } + break; + } + + $versions = $env->getVersionLog(); + + $io->out( + ' Breakpoint ' . ($versions[$version]['breakpoint'] ? 'set' : 'cleared') . + ' for ' . $version . '' . + ' ' . $migrations[$version]->getName() . '' + ); + } + + /** + * Remove all breakpoints + * + * @return void + */ + public function removeBreakpoints(): void + { + $this->getIo()->out(sprintf( + ' %d breakpoints cleared.', + $this->getEnvironment()->getAdapter()->resetAllBreakpoints() + )); + } + + /** + * Set the breakpoint for a specific version. + * + * @param int|null $version The version of the target migration + * @return void + */ + public function setBreakpoint(?int $version): void + { + $this->markBreakpoint($version, self::BREAKPOINT_SET); + } + + /** + * Unset the breakpoint for a specific version. + * + * @param int|null $version The version of the target migration + * @return void + */ + public function unsetBreakpoint(?int $version): void + { + $this->markBreakpoint($version, self::BREAKPOINT_UNSET); + } + + /** + * Reset the migrations stored in the object + * + * @return void + */ + public function resetMigrations(): void + { + $this->migrations = null; + } + + /** + * Reset the seeds stored in the object + * + * @return void + */ + public function resetSeeds(): void + { + $this->seeds = null; + } +} diff --git a/src/Migration/ManagerFactory.php b/src/Migration/ManagerFactory.php new file mode 100644 index 00000000..09ea5dfe --- /dev/null +++ b/src/Migration/ManagerFactory.php @@ -0,0 +1,146 @@ +options[$name])) { + return null; + } + + return $this->options[$name]; + } + + /** + * Create a ConfigInterface instance based on the factory options. + * + * @return \Migrations\Config\ConfigInterface + */ + public function createConfig(): ConfigInterface + { + $folder = (string)$this->getOption('source'); + + // Get the filepath for migrations and seeds. + // We rely on factory parameters to define which directory to use. + $dir = ROOT . DS . 'config' . DS . $folder; + if (defined('CONFIG')) { + $dir = CONFIG . $folder; + } + $plugin = $this->getOption('plugin'); + if ($plugin && is_string($plugin)) { + $dir = Plugin::path($plugin) . 'config' . DS . $folder; + } + + // Get the phinxlog table name. Plugins have separate migration history. + // The names and separate table history is something we could change in the future. + $table = 'phinxlog'; + if ($plugin && is_string($plugin)) { + $prefix = Inflector::underscore($plugin) . '_'; + $prefix = str_replace(['\\', '/', '.'], '_', $prefix); + $table = $prefix . $table; + } + $templatePath = dirname(__DIR__) . DS . 'templates' . DS; + $connectionName = (string)$this->getOption('connection'); + + $connectionConfig = ConnectionManager::getConfig($connectionName); + if (!$connectionConfig) { + throw new RuntimeException("Could not find connection `{$connectionName}`"); + } + + /** @var array $connectionConfig */ + $adapter = $connectionConfig['scheme'] ?? null; + $adapterConfig = [ + 'adapter' => $adapter, + 'connection' => $connectionName, + 'database' => $connectionConfig['database'], + 'migration_table' => $table, + 'dryrun' => $this->getOption('dry-run'), + ]; + + $configData = [ + 'paths' => [ + 'migrations' => $dir, + 'seeds' => $dir, + ], + 'templates' => [ + 'file' => $templatePath . 'Phinx/create.php.template', + ], + 'migration_base_class' => 'Migrations\AbstractMigration', + 'environment' => $adapterConfig, + 'plugin' => $plugin, + 'source' => (string)$this->getOption('source'), + 'feature_flags' => [ + 'unsigned_primary_keys' => Configure::read('Migrations.unsigned_primary_keys'), + 'column_null_default' => Configure::read('Migrations.column_null_default'), + ], + ]; + + return new Config($configData); + } + + /** + * Get the migration manager for the current CLI options and application configuration. + * + * @param \Cake\Console\ConsoleIo $io The command io. + * @param \Migrations\Config\ConfigInterface $config A config instance. Providing null will create a new Config + * based on the factory constructor options. + * @return \Migrations\Migration\Manager + */ + public function createManager(ConsoleIo $io, ?ConfigInterface $config = null): Manager + { + $config ??= $this->createConfig(); + + return new Manager($config, $io); + } +} diff --git a/src/Migration/PhinxBackend.php b/src/Migration/PhinxBackend.php new file mode 100644 index 00000000..8b81a511 --- /dev/null +++ b/src/Migration/PhinxBackend.php @@ -0,0 +1,452 @@ + + */ + protected array $default = []; + + /** + * Current command being run. + * Useful if some logic needs to be applied in the ConfigurationTrait depending + * on the command + * + * @var string + */ + protected string $command; + + /** + * Stub input to feed the manager class since we might not have an input ready when we get the Manager using + * the `getManager()` method + * + * @var \Symfony\Component\Console\Input\ArrayInput + */ + protected ArrayInput $stubInput; + + /** + * Constructor + * + * @param array $default Default option to be used when calling a method. + * Available options are : + * - `connection` The datasource connection to use + * - `source` The folder where migrations are in + * - `plugin` The plugin containing the migrations + */ + public function __construct(array $default = []) + { + $this->output = new NullOutput(); + $this->stubInput = new ArrayInput([]); + + if ($default) { + $this->default = $default; + } + } + + /** + * Sets the command + * + * @param string $command Command name to store. + * @return $this + */ + public function setCommand(string $command) + { + $this->command = $command; + + return $this; + } + + /** + * Sets the input object that should be used for the command class. This object + * is used to inspect the extra options that are needed for CakePHP apps. + * + * @param \Symfony\Component\Console\Input\InputInterface $input the input object + * @return void + */ + public function setInput(InputInterface $input): void + { + $this->input = $input; + } + + /** + * Gets the command + * + * @return string Command name + */ + public function getCommand(): string + { + return $this->command; + } + + /** + * Returns the status of each migrations based on the options passed + * + * @param array $options Options to pass to the command + * Available options are : + * + * - `format` Format to output the response. Can be 'json' + * - `connection` The datasource connection to use + * - `source` The folder where migrations are in + * - `plugin` The plugin containing the migrations + * @return array The migrations list and their statuses + */ + public function status(array $options = []): array + { + $input = $this->getInput('Status', [], $options); + $params = ['default', $input->getOption('format')]; + + return $this->run('printStatus', $params, $input); + } + + /** + * Migrates available migrations + * + * @param array $options Options to pass to the command + * Available options are : + * + * - `target` The version number to migrate to. If not provided, will migrate + * everything it can + * - `connection` The datasource connection to use + * - `source` The folder where migrations are in + * - `plugin` The plugin containing the migrations + * - `date` The date to migrate to + * @return bool Success + */ + public function migrate(array $options = []): bool + { + $this->setCommand('migrate'); + $input = $this->getInput('Migrate', [], $options); + $method = 'migrate'; + $params = ['default', $input->getOption('target')]; + + if ($input->getOption('date')) { + $method = 'migrateToDateTime'; + $params[1] = new DateTime($input->getOption('date')); + } + + $this->run($method, $params, $input); + + return true; + } + + /** + * Rollbacks migrations + * + * @param array $options Options to pass to the command + * Available options are : + * + * - `target` The version number to migrate to. If not provided, will only migrate + * the last migrations registered in the phinx log + * - `connection` The datasource connection to use + * - `source` The folder where migrations are in + * - `plugin` The plugin containing the migrations + * - `date` The date to rollback to + * @return bool Success + */ + public function rollback(array $options = []): bool + { + $this->setCommand('rollback'); + $input = $this->getInput('Rollback', [], $options); + $method = 'rollback'; + $params = ['default', $input->getOption('target')]; + + if ($input->getOption('date')) { + $method = 'rollbackToDateTime'; + $params[1] = new DateTime($input->getOption('date')); + } + + $this->run($method, $params, $input); + + return true; + } + + /** + * Marks a migration as migrated + * + * @param int|string|null $version The version number of the migration to mark as migrated + * @param array $options Options to pass to the command + * Available options are : + * + * - `connection` The datasource connection to use + * - `source` The folder where migrations are in + * - `plugin` The plugin containing the migrations + * @return bool Success + */ + public function markMigrated(int|string|null $version = null, array $options = []): bool + { + $this->setCommand('mark_migrated'); + + if ( + isset($options['target']) && + isset($options['exclude']) && + isset($options['only']) + ) { + $exceptionMessage = 'You should use `exclude` OR `only` (not both) along with a `target` argument'; + throw new InvalidArgumentException($exceptionMessage); + } + + $input = $this->getInput('MarkMigrated', ['version' => $version], $options); + $this->setInput($input); + + // This will need to vary based on the config option. + $migrationPaths = $this->getConfig()->getMigrationPaths(); + $config = $this->getConfig(true); + $params = [ + array_pop($migrationPaths), + $this->getManager($config)->getVersionsToMark($input), + $this->output, + ]; + + $this->run('markVersionsAsMigrated', $params, $input); + + return true; + } + + /** + * Seed the database using a seed file + * + * @param array $options Options to pass to the command + * Available options are : + * + * - `connection` The datasource connection to use + * - `source` The folder where migrations are in + * - `plugin` The plugin containing the migrations + * - `seed` The seed file to use + * @return bool Success + */ + public function seed(array $options = []): bool + { + $this->setCommand('seed'); + $input = $this->getInput('Seed', [], $options); + + $seed = $input->getOption('seed'); + if (!$seed) { + $seed = null; + } + + $params = ['default', $seed]; + $this->run('seed', $params, $input); + + return true; + } + + /** + * Runs the method needed to execute and return + * + * @param string $method Manager method to call + * @param array $params Manager params to pass + * @param \Symfony\Component\Console\Input\InputInterface $input InputInterface needed for the + * Manager to properly run + * @return mixed The result of the CakeManager::$method() call + */ + protected function run(string $method, array $params, InputInterface $input): mixed + { + // This will need to vary based on the backend configuration + if ($this->configuration instanceof Config) { + $migrationPaths = $this->getConfig()->getMigrationPaths(); + $migrationPath = array_pop($migrationPaths); + $seedPaths = $this->getConfig()->getSeedPaths(); + $seedPath = array_pop($seedPaths); + } + + $pdo = null; + if ($this->manager instanceof Manager) { + $pdo = $this->manager->getEnvironment('default') + ->getAdapter() + ->getConnection(); + } + + $this->setInput($input); + $newConfig = $this->getConfig(true); + $manager = $this->getManager($newConfig); + $manager->setInput($input); + + // Why is this being done? Is this something we can eliminate in the new code path? + if ($pdo !== null) { + /** @var \Phinx\Db\Adapter\PdoAdapter|\Migrations\CakeAdapter $adapter */ + /** @psalm-suppress PossiblyNullReference */ + $adapter = $this->manager->getEnvironment('default')->getAdapter(); + while ($adapter instanceof WrapperInterface) { + /** @var \Phinx\Db\Adapter\PdoAdapter|\Migrations\CakeAdapter $adapter */ + $adapter = $adapter->getAdapter(); + } + $adapter->setConnection($pdo); + } + + $newMigrationPaths = $newConfig->getMigrationPaths(); + if (isset($migrationPath) && array_pop($newMigrationPaths) !== $migrationPath) { + $manager->resetMigrations(); + } + $newSeedPaths = $newConfig->getSeedPaths(); + if (isset($seedPath) && array_pop($newSeedPaths) !== $seedPath) { + $manager->resetSeeds(); + } + + /** @var callable $callable */ + $callable = [$manager, $method]; + + return call_user_func_array($callable, $params); + } + + /** + * Returns an instance of CakeManager + * + * @param \Phinx\Config\ConfigInterface|null $config ConfigInterface the Manager needs to run + * @return \Migrations\CakeManager Instance of CakeManager + */ + public function getManager(?ConfigInterface $config = null): CakeManager + { + if (!($this->manager instanceof CakeManager)) { + if (!($config instanceof ConfigInterface)) { + throw new RuntimeException( + 'You need to pass a ConfigInterface object for your first getManager() call' + ); + } + + $input = $this->input ?: $this->stubInput; + $this->manager = new CakeManager($config, $input, $this->output); + } elseif ($config !== null) { + $defaultEnvironment = $config->getEnvironment('default'); + try { + $environment = $this->manager->getEnvironment('default'); + $oldConfig = $environment->getOptions(); + unset($oldConfig['connection']); + if ($oldConfig === $defaultEnvironment) { + $defaultEnvironment['connection'] = $environment + ->getAdapter() + ->getConnection(); + } + } catch (InvalidArgumentException $e) { + } + $config['environments'] = ['default' => $defaultEnvironment]; + $this->manager->setEnvironments([]); + $this->manager->setConfig($config); + } + + $this->setAdapter(); + + return $this->manager; + } + + /** + * Sets the adapter the manager is going to need to operate on the DB + * This will make sure the adapter instance is a \Migrations\CakeAdapter instance + * + * @return void + */ + public function setAdapter(): void + { + if ($this->input === null) { + return; + } + + /** @var string $connectionName */ + $connectionName = $this->input()->getOption('connection') ?: 'default'; + /** @var \Cake\Database\Connection $connection */ + $connection = ConnectionManager::get($connectionName); + + /** @psalm-suppress PossiblyNullReference */ + $env = $this->manager->getEnvironment('default'); + $adapter = $env->getAdapter(); + if (!$adapter instanceof CakeAdapter) { + $env->setAdapter(new CakeAdapter($adapter, $connection)); + } + } + + /** + * Get the input needed for each commands to be run + * + * @param string $command Command name for which we need the InputInterface + * @param array $arguments Simple key/values array representing the command arguments + * to pass to the InputInterface + * @param array $options Simple key/values array representing the command options + * to pass to the InputInterface + * @return \Symfony\Component\Console\Input\InputInterface InputInterface needed for the + * Manager to properly run + */ + public function getInput(string $command, array $arguments, array $options): InputInterface + { + $className = 'Migrations\Command\Phinx\\' . $command; + $options = $arguments + $this->prepareOptions($options); + /** @var \Symfony\Component\Console\Command\Command $command */ + $command = new $className(); + $definition = $command->getDefinition(); + + return new ArrayInput($options, $definition); + } + + /** + * Prepares the option to pass on to the InputInterface + * + * @param array $options Simple key-values array to pass to the InputInterface + * @return array Prepared $options + */ + protected function prepareOptions(array $options = []): array + { + $options += $this->default; + if (!$options) { + return $options; + } + + foreach ($options as $name => $value) { + $options['--' . $name] = $value; + unset($options[$name]); + } + + return $options; + } +} diff --git a/src/Migrations.php b/src/Migrations.php index 8c387d40..1ed553b9 100644 --- a/src/Migrations.php +++ b/src/Migrations.php @@ -13,13 +13,12 @@ */ namespace Migrations; +use Cake\Core\Configure; use Cake\Datasource\ConnectionManager; -use DateTime; use InvalidArgumentException; -use Phinx\Config\Config; +use Migrations\Migration\BuiltinBackend; +use Migrations\Migration\PhinxBackend; use Phinx\Config\ConfigInterface; -use Phinx\Db\Adapter\WrapperInterface; -use Phinx\Migration\Manager; use RuntimeException; use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Input\InputInterface; @@ -127,6 +126,24 @@ public function getCommand(): string return $this->command; } + /** + * Get the Migrations interface backend based on configuration data. + * + * @return \Migrations\Migration\BuiltinBackend|\Migrations\Migration\PhinxBackend + */ + protected function getBackend(): BuiltinBackend|PhinxBackend + { + $backend = (string)(Configure::read('Migrations.backend') ?? 'phinx'); + if ($backend === 'builtin') { + return new BuiltinBackend($this->default); + } + if ($backend === 'phinx') { + return new PhinxBackend($this->default); + } + + throw new RuntimeException("Unknown `Migrations.backend` of `{$backend}`"); + } + /** * Returns the status of each migrations based on the options passed * @@ -141,11 +158,9 @@ public function getCommand(): string */ public function status(array $options = []): array { - $this->setCommand('status'); - $input = $this->getInput('Status', [], $options); - $params = ['default', $input->getOption('format')]; + $backend = $this->getBackend(); - return $this->run('printStatus', $params, $input); + return $backend->status($options); } /** @@ -164,19 +179,9 @@ public function status(array $options = []): array */ public function migrate(array $options = []): bool { - $this->setCommand('migrate'); - $input = $this->getInput('Migrate', [], $options); - $method = 'migrate'; - $params = ['default', $input->getOption('target')]; - - if ($input->getOption('date')) { - $method = 'migrateToDateTime'; - $params[1] = new DateTime($input->getOption('date')); - } + $backend = $this->getBackend(); - $this->run($method, $params, $input); - - return true; + return $backend->migrate($options); } /** @@ -195,19 +200,9 @@ public function migrate(array $options = []): bool */ public function rollback(array $options = []): bool { - $this->setCommand('rollback'); - $input = $this->getInput('Rollback', [], $options); - $method = 'rollback'; - $params = ['default', $input->getOption('target')]; - - if ($input->getOption('date')) { - $method = 'rollbackToDateTime'; - $params[1] = new DateTime($input->getOption('date')); - } - - $this->run($method, $params, $input); + $backend = $this->getBackend(); - return true; + return $backend->rollback($options); } /** @@ -224,31 +219,9 @@ public function rollback(array $options = []): bool */ public function markMigrated(int|string|null $version = null, array $options = []): bool { - $this->setCommand('mark_migrated'); - - if ( - isset($options['target']) && - isset($options['exclude']) && - isset($options['only']) - ) { - $exceptionMessage = 'You should use `exclude` OR `only` (not both) along with a `target` argument'; - throw new InvalidArgumentException($exceptionMessage); - } - - $input = $this->getInput('MarkMigrated', ['version' => $version], $options); - $this->setInput($input); + $backend = $this->getBackend(); - $migrationPaths = $this->getConfig()->getMigrationPaths(); - $config = $this->getConfig(true); - $params = [ - array_pop($migrationPaths), - $this->getManager($config)->getVersionsToMark($input), - $this->output, - ]; - - $this->run('markVersionsAsMigrated', $params, $input); - - return true; + return $backend->markMigrated($version, $options); } /** @@ -265,74 +238,9 @@ public function markMigrated(int|string|null $version = null, array $options = [ */ public function seed(array $options = []): bool { - $this->setCommand('seed'); - $input = $this->getInput('Seed', [], $options); - - $seed = $input->getOption('seed'); - if (!$seed) { - $seed = null; - } - - $params = ['default', $seed]; - $this->run('seed', $params, $input); - - return true; - } - - /** - * Runs the method needed to execute and return - * - * @param string $method Manager method to call - * @param array $params Manager params to pass - * @param \Symfony\Component\Console\Input\InputInterface $input InputInterface needed for the - * Manager to properly run - * @return mixed The result of the CakeManager::$method() call - */ - protected function run(string $method, array $params, InputInterface $input): mixed - { - if ($this->configuration instanceof Config) { - $migrationPaths = $this->getConfig()->getMigrationPaths(); - $migrationPath = array_pop($migrationPaths); - $seedPaths = $this->getConfig()->getSeedPaths(); - $seedPath = array_pop($seedPaths); - } - - $pdo = null; - if ($this->manager instanceof Manager) { - $pdo = $this->manager->getEnvironment('default') - ->getAdapter() - ->getConnection(); - } - - $this->setInput($input); - $newConfig = $this->getConfig(true); - $manager = $this->getManager($newConfig); - $manager->setInput($input); - - if ($pdo !== null) { - /** @var \Phinx\Db\Adapter\PdoAdapter|\Migrations\CakeAdapter $adapter */ - /** @psalm-suppress PossiblyNullReference */ - $adapter = $this->manager->getEnvironment('default')->getAdapter(); - while ($adapter instanceof WrapperInterface) { - /** @var \Phinx\Db\Adapter\PdoAdapter|\Migrations\CakeAdapter $adapter */ - $adapter = $adapter->getAdapter(); - } - $adapter->setConnection($pdo); - } - - $newMigrationPaths = $newConfig->getMigrationPaths(); - if (isset($migrationPath) && array_pop($newMigrationPaths) !== $migrationPath) { - $manager->resetMigrations(); - } - $newSeedPaths = $newConfig->getSeedPaths(); - if (isset($seedPath) && array_pop($newSeedPaths) !== $seedPath) { - $manager->resetSeeds(); - } - - /** @var callable $callable */ - $callable = [$manager, $method]; + $backend = $this->getBackend(); - return call_user_func_array($callable, $params); + return $backend->seed($options); } /** diff --git a/src/MigrationsDispatcher.php b/src/MigrationsDispatcher.php index d3a315f3..c3ca2e35 100644 --- a/src/MigrationsDispatcher.php +++ b/src/MigrationsDispatcher.php @@ -19,24 +19,31 @@ /** * Used to register all supported subcommand in order to make * them executable by the Symfony Console component + * + * @deprecated 4.2.0 Will be removed alongsize phinx */ class MigrationsDispatcher extends Application { /** - * @var array - * @psalm-var array|class-string<\Migrations\Command\Phinx\BaseCommand>> + * Get the map of command names to phinx commands. + * + * @return array + * @psalm-return array|class-string<\Migrations\Command\Phinx\BaseCommand>> */ - public static array $phinxCommands = [ - 'Create' => Phinx\Create::class, - 'Dump' => Phinx\Dump::class, - 'MarkMigrated' => Phinx\MarkMigrated::class, - 'Migrate' => Phinx\Migrate::class, - 'Rollback' => Phinx\Rollback::class, - 'Seed' => Phinx\Seed::class, - 'Status' => Phinx\Status::class, - 'CacheBuild' => Phinx\CacheBuild::class, - 'CacheClear' => Phinx\CacheClear::class, - ]; + public static function getCommands(): array + { + return [ + 'Create' => Phinx\Create::class, + 'Dump' => Phinx\Dump::class, + 'MarkMigrated' => Phinx\MarkMigrated::class, + 'Migrate' => Phinx\Migrate::class, + 'Rollback' => Phinx\Rollback::class, + 'Seed' => Phinx\Seed::class, + 'Status' => Phinx\Status::class, + 'CacheBuild' => Phinx\CacheBuild::class, + 'CacheClear' => Phinx\CacheClear::class, + ]; + } /** * Initialize the Phinx console application. @@ -46,7 +53,8 @@ class MigrationsDispatcher extends Application public function __construct(string $version) { parent::__construct('Migrations plugin, based on Phinx by Rob Morgan.', $version); - foreach (static::$phinxCommands as $value) { + // Update this to use the methods + foreach ($this->getCommands() as $value) { $this->add(new $value()); } $this->setCatchExceptions(false); diff --git a/src/MigrationsPlugin.php b/src/MigrationsPlugin.php index f7782111..ca0df2a7 100644 --- a/src/MigrationsPlugin.php +++ b/src/MigrationsPlugin.php @@ -16,6 +16,16 @@ use Bake\Command\SimpleBakeCommand; use Cake\Console\CommandCollection; use Cake\Core\BasePlugin; +use Cake\Core\Configure; +use Cake\Core\PluginApplicationInterface; +use Migrations\Command\BakeMigrationCommand; +use Migrations\Command\BakeMigrationDiffCommand; +use Migrations\Command\BakeMigrationSnapshotCommand; +use Migrations\Command\BakeSeedCommand; +use Migrations\Command\DumpCommand; +use Migrations\Command\EntryCommand; +use Migrations\Command\MarkMigratedCommand; +use Migrations\Command\MigrateCommand; use Migrations\Command\MigrationsCacheBuildCommand; use Migrations\Command\MigrationsCacheClearCommand; use Migrations\Command\MigrationsCommand; @@ -26,6 +36,9 @@ use Migrations\Command\MigrationsRollbackCommand; use Migrations\Command\MigrationsSeedCommand; use Migrations\Command\MigrationsStatusCommand; +use Migrations\Command\RollbackCommand; +use Migrations\Command\SeedCommand; +use Migrations\Command\StatusCommand; /** * Plugin class for migrations @@ -59,6 +72,21 @@ class MigrationsPlugin extends BasePlugin MigrationsStatusCommand::class, ]; + /** + * Initialize configuration with defaults. + * + * @param \Cake\Core\PluginApplicationInterface $app The application. + * @return void + */ + public function bootstrap(PluginApplicationInterface $app): void + { + parent::bootstrap($app); + + if (!Configure::check('Migrations.backend')) { + Configure::write('Migrations.backend', 'phinx'); + } + } + /** * Add migrations commands. * @@ -67,24 +95,62 @@ class MigrationsPlugin extends BasePlugin */ public function console(CommandCollection $commands): CommandCollection { - if (class_exists(SimpleBakeCommand::class)) { - $found = $commands->discoverPlugin($this->getName()); + if (Configure::read('Migrations.backend') == 'builtin') { + $classes = [ + DumpCommand::class, + EntryCommand::class, + MarkMigratedCommand::class, + MigrateCommand::class, + RollbackCommand::class, + SeedCommand::class, + StatusCommand::class, + ]; + $hasBake = class_exists(SimpleBakeCommand::class); + if ($hasBake) { + $classes[] = BakeMigrationCommand::class; + $classes[] = BakeMigrationDiffCommand::class; + $classes[] = BakeMigrationSnapshotCommand::class; + $classes[] = BakeSeedCommand::class; + } + $found = []; + foreach ($classes as $class) { + $name = $class::defaultName(); + // If the short name has been used, use the full name. + // This allows app commands to have name preference. + // and app commands to overwrite migration commands. + if (!$commands->has($name)) { + $found[$name] = $class; + } + $found['migrations.' . $name] = $class; + } + if ($hasBake) { + $found['migrations create'] = BakeMigrationCommand::class; + } - return $commands->addMany($found); - } - $found = []; - foreach ($this->migrationCommandsList as $class) { - $name = $class::defaultName(); - // If the short name has been used, use the full name. - // This allows app commands to have name preference. - // and app commands to overwrite migration commands. - if (!$commands->has($name)) { - $found[$name] = $class; + $commands->addMany($found); + + return $commands; + } else { + if (class_exists(SimpleBakeCommand::class)) { + $found = $commands->discoverPlugin($this->getName()); + + return $commands->addMany($found); + } + $found = []; + // Convert to a method and use config to toggle command names. + foreach ($this->migrationCommandsList as $class) { + $name = $class::defaultName(); + // If the short name has been used, use the full name. + // This allows app commands to have name preference. + // and app commands to overwrite migration commands. + if (!$commands->has($name)) { + $found[$name] = $class; + } + // full name + $found['migrations.' . $name] = $class; } - // full name - $found['migrations.' . $name] = $class; - } - return $commands->addMany($found); + return $commands->addMany($found); + } } } diff --git a/src/Shim/OutputAdapter.php b/src/Shim/OutputAdapter.php new file mode 100644 index 00000000..6e3d832f --- /dev/null +++ b/src/Shim/OutputAdapter.php @@ -0,0 +1,144 @@ +io->out($messages, $newline ? 1 : 0); + } + + /** + * @inheritDoc + */ + public function writeln(string|iterable $messages, $options = 0): void + { + if ($messages instanceof Traversable) { + $messages = iterator_to_array($messages); + } + $this->io->out($messages, 1); + } + + /** + * Sets the verbosity of the output. + * + * @param self::VERBOSITY_* $level + * @return void + */ + public function setVerbosity(int $level): void + { + // TODO map values + $this->io->level($level); + } + + /** + * Gets the current verbosity of the output. + * + * @return self::VERBOSITY_* + */ + public function getVerbosity(): int + { + // TODO map values + return $this->io->level(); + } + + /** + * Returns whether verbosity is quiet (-q). + */ + public function isQuiet(): bool + { + return $this->io->level() === ConsoleIo::QUIET; + } + + /** + * Returns whether verbosity is verbose (-v). + */ + public function isVerbose(): bool + { + return $this->io->level() === ConsoleIo::VERBOSE; + } + + /** + * Returns whether verbosity is very verbose (-vv). + */ + public function isVeryVerbose(): bool + { + return false; + } + + /** + * Returns whether verbosity is debug (-vvv). + */ + public function isDebug(): bool + { + return false; + } + + /** + * Sets the decorated flag. + * + * @return void + */ + public function setDecorated(bool $decorated): void + { + throw new RuntimeException('setDecorated is not implemented'); + } + + /** + * Gets the decorated flag. + */ + public function isDecorated(): bool + { + throw new RuntimeException('isDecorated is not implemented'); + } + + /** + * @return void + */ + public function setFormatter(OutputFormatterInterface $formatter): void + { + throw new RuntimeException('setFormatter is not implemented'); + } + + /** + * Returns current output formatter instance. + */ + public function getFormatter(): OutputFormatterInterface + { + throw new RuntimeException('getFormatter is not implemented'); + } +} diff --git a/src/TableFinderTrait.php b/src/Util/TableFinder.php similarity index 87% rename from src/TableFinderTrait.php rename to src/Util/TableFinder.php index 7d669a85..607b0bc5 100644 --- a/src/TableFinderTrait.php +++ b/src/Util/TableFinder.php @@ -11,7 +11,7 @@ * @link https://cakephp.org CakePHP(tm) Project * @license https://www.opensource.org/licenses/mit-license.php MIT License */ -namespace Migrations; +namespace Migrations\Util; use Cake\Core\App; use Cake\Core\Plugin as CorePlugin; @@ -20,7 +20,10 @@ use Cake\ORM\TableRegistry; use ReflectionClass; -trait TableFinderTrait +/** + * @internal + */ +class TableFinder { /** * Tables to skip @@ -36,6 +39,15 @@ trait TableFinderTrait */ public string $skipTablesRegex = '_phinxlog'; + /** + * Constructor + * + * @param string $connection The connection name to use. + */ + public function __construct(protected string $connection) + { + } + /** * Gets a list of table to baked based on the Collection instance passed and the options passed to * the shell call. @@ -45,7 +57,7 @@ trait TableFinderTrait * @param array $options Array of options passed to a shell call. * @return array */ - protected function getTablesToBake(CollectionInterface $collection, array $options = []): array + public function getTablesToBake(CollectionInterface $collection, array $options = []): array { $options += [ 'require-table' => false, @@ -70,7 +82,7 @@ protected function getTablesToBake(CollectionInterface $collection, array $optio $config = (array)ConnectionManager::getConfig($this->connection); $key = isset($config['schema']) ? 'schema' : 'database'; - if ($config[$key] === $split[1]) { + if (isset($split[0], $split[1]) && $config[$key] === $split[1]) { $table = $split[0]; } } @@ -98,7 +110,7 @@ protected function getTablesToBake(CollectionInterface $collection, array $optio * @param string|null $pluginName Plugin name if exists. * @return array */ - protected function getTableNames(?string $pluginName = null): array + public function getTableNames(?string $pluginName = null): array { if ($pluginName !== null && !CorePlugin::getCollection()->has($pluginName)) { return []; @@ -123,7 +135,7 @@ protected function getTableNames(?string $pluginName = null): array * @param string|null $pluginName Plugin name if exists. * @return array */ - protected function findTables(?string $pluginName = null): array + public function findTables(?string $pluginName = null): array { $path = 'Model' . DS . 'Table' . DS; if ($pluginName) { @@ -147,7 +159,7 @@ protected function findTables(?string $pluginName = null): array * @param string|null $pluginName Plugin name if exists. * @return list */ - protected function fetchTableName(string $className, ?string $pluginName = null): array + public function fetchTableName(string $className, ?string $pluginName = null): array { $tables = []; $className = str_replace('Table.php', '', $className); @@ -183,9 +195,9 @@ protected function fetchTableName(string $className, ?string $pluginName = null) $splitted = array_reverse(explode('.', $tableName, 2)); if (isset($splitted[1])) { $config = ConnectionManager::getConfig($this->connection); - if ($config) { + if (is_array($config)) { $key = isset($config['schema']) ? 'schema' : 'database'; - if ($config[$key] === $splitted[1]) { + if (isset($splitted[0]) && $config[$key] === $splitted[1]) { $tableName = $splitted[0]; } } diff --git a/src/View/Helper/MigrationHelper.php b/src/View/Helper/MigrationHelper.php index e892cfa9..b4d7745c 100644 --- a/src/View/Helper/MigrationHelper.php +++ b/src/View/Helper/MigrationHelper.php @@ -17,13 +17,13 @@ use Cake\Core\Configure; use Cake\Database\Connection; use Cake\Database\Driver\Mysql; +use Cake\Database\Driver\Sqlserver; use Cake\Database\Schema\CollectionInterface; use Cake\Database\Schema\TableSchemaInterface; use Cake\Utility\Hash; use Cake\Utility\Inflector; use Cake\View\Helper; use Cake\View\View; -use Phinx\Config\FeatureFlags; /** * Migration Helper class for output of field data in migration files. @@ -308,10 +308,7 @@ public function hasAutoIdIncompatiblePrimaryKey(array $tables): bool return false; } - $useUnsignedPrimaryKes = Configure::read( - 'Migrations.unsigned_primary_keys', - FeatureFlags::$unsignedPrimaryKeys - ); + $useUnsignedPrimaryKes = (bool)Configure::read('Migrations.unsigned_primary_keys'); foreach ($tables as $table) { $schema = $table; @@ -356,8 +353,9 @@ public function primaryKeysColumnsList(TableSchemaInterface|string $table): arra public function column(TableSchemaInterface $tableSchema, string $column): array { $columnType = $tableSchema->getColumnType($column); - // Phinx doesn't understand timestampfractional. - if ($columnType === 'timestampfractional') { + + // Phinx doesn't understand timestampfractional or datetimefractional types + if ($columnType === 'timestampfractional' || $columnType === 'datetimefractional') { $columnType = 'timestamp'; } @@ -401,17 +399,21 @@ public function getColumnOption(array $options): array } // currently only MySQL supports the signed option - $isMysql = $connection->getDriver() instanceof Mysql; + $driver = $connection->getDriver(); + $isMysql = $driver instanceof Mysql; + $isSqlserver = $driver instanceof Sqlserver; + if (!$isMysql) { unset($columnOptions['signed']); } - if ($isMysql && !empty($columnOptions['collate'])) { + if (($isMysql || $isSqlserver) && !empty($columnOptions['collate'])) { // due to Phinx using different naming for the collation $columnOptions['collation'] = $columnOptions['collate']; unset($columnOptions['collate']); } + // TODO this can be cleaned up when we stop using phinx data structures for column definitions if ($columnOptions['precision'] === null) { unset($columnOptions['precision']); } else { @@ -524,7 +526,7 @@ public function attributes(TableSchemaInterface|string $table, string $column): unset($attributes['signed']); } - $defaultCollation = $tableSchema->getOptions()['collation']; + $defaultCollation = $tableSchema->getOptions()['collation'] ?? null; if (empty($attributes['collate']) || $attributes['collate'] == $defaultCollation) { unset($attributes['collate']); } diff --git a/tests/Fixture/EventsFixture.php b/tests/Fixture/EventsFixture.php index ed0ca36a..48e3ec1d 100644 --- a/tests/Fixture/EventsFixture.php +++ b/tests/Fixture/EventsFixture.php @@ -13,19 +13,16 @@ class EventsFixture extends TestFixture */ public array $records = [ [ - 'id' => 1, 'title' => 'Lorem ipsum dolor sit amet', 'description' => 'Lorem ipsum dolor sit amet, aliquet feugiat.', 'published' => 'Y', ], [ - 'id' => 2, 'title' => 'Second event', 'description' => 'Second event description.', 'published' => 'Y', ], [ - 'id' => 3, 'title' => 'Lorem ipsum dolor sit amet', 'description' => 'Lorem ipsum dolor sit amet, aliquet feugiat. Convallis morbi fringilla gravida, phasellus feugiat dapibus velit nunc, pulvinar eget \'sollicitudin\' venenatis cum diff --git a/tests/RawBufferedOutput.php b/tests/RawBufferedOutput.php new file mode 100644 index 00000000..a968e10f --- /dev/null +++ b/tests/RawBufferedOutput.php @@ -0,0 +1,23 @@ +message. + */ +class RawBufferedOutput extends BufferedOutput +{ + /** + * @param iterable|string $messages + * @param int $options + * @return void + */ + public function writeln($messages, $options = 0): void + { + $this->write($messages, true, $options | self::OUTPUT_RAW); + } +} diff --git a/tests/TestCase/Command/BakeMigrationCommandTest.php b/tests/TestCase/Command/BakeMigrationCommandTest.php index 5a122166..a9484f6a 100644 --- a/tests/TestCase/Command/BakeMigrationCommandTest.php +++ b/tests/TestCase/Command/BakeMigrationCommandTest.php @@ -14,6 +14,7 @@ namespace Migrations\Test\TestCase\Command; use Cake\Console\BaseCommand; +use Cake\Core\Configure; use Cake\Core\Plugin; use Cake\TestSuite\StringCompareTrait; use Migrations\Command\BakeMigrationCommand; @@ -111,12 +112,21 @@ public function testCreateDuplicateName() $this->assertErrorContains('A migration with the name `CreateUsers` already exists. Please use a different name.'); } + public function testCreateBuiltinAlias() + { + Configure::write('Migrations.backend', 'builtin'); + $this->exec('migrations create CreateUsers --connection test'); + $this->assertExitCode(BaseCommand::CODE_SUCCESS); + $this->assertOutputRegExp('/Wrote.*?CreateUsers\.php/'); + } + /** * Tests that baking a migration with the "drop" string inside the name generates a valid drop migration. */ public function testCreateDropMigration() { $this->exec('bake migration DropUsers --connection test'); + $this->assertOutputRegExp('/Wrote.*?DropUsers\.php/'); $file = glob(ROOT . DS . 'config' . DS . 'Migrations' . DS . '*_DropUsers.php'); $filePath = current($file); diff --git a/tests/TestCase/Command/BakeMigrationSnapshotCommandTest.php b/tests/TestCase/Command/BakeMigrationSnapshotCommandTest.php index 60d7da8d..8d715f26 100644 --- a/tests/TestCase/Command/BakeMigrationSnapshotCommandTest.php +++ b/tests/TestCase/Command/BakeMigrationSnapshotCommandTest.php @@ -82,32 +82,6 @@ public function tearDown(): void } } - /** - * Test that the BakeMigrationSnapshotCommand::getTableNames properly returns the table list - * when we want tables from a plugin - * - * @return void - */ - public function testGetTableNames() - { - /** @var \Migrations\Test\TestCase\Command\TestClassWithSnapshotTrait|\PHPUnit\Framework\MockObject\MockObject $class */ - $class = $this->getMockBuilder(TestClassWithSnapshotTrait::class) - ->onlyMethods(['findTables', 'fetchTableName']) - ->getMock(); - - $class->expects($this->any()) - ->method('findTables') - ->with('TestBlog') - ->will($this->returnValue(['ArticlesTable.php', 'TagsTable.php'])); - - $class->method('fetchTableName') - ->will($this->onConsecutiveCalls(['articles_tags', 'articles'], ['articles_tags', 'tags'])); - - $results = $class->getTableNames('TestBlog'); - $expected = ['articles_tags', 'articles', 'tags']; - $this->assertEquals(array_values($expected), array_values($results)); - } - /** * Test baking a snapshot * @@ -235,40 +209,6 @@ protected function runSnapshotTest(string $scenario, string $arguments = ''): vo $this->assertCorrectSnapshot($bakeName, file_get_contents($this->generatedFiles[0])); } - /** - * Test that using MigrationSnapshotTask::fetchTableName in a Table object class - * where the table name is composed with the database name (e.g. mydb.mytable) - * will return : - * - only the table name if the current connection `database` parameter is the first part - * of the table name - * - the full string (e.g. mydb.mytable) if the current connection `database` parameter - * is not the first part of the table name - * - * @return void - */ - public function testFetchTableNames() - { - $class = new TestClassWithSnapshotTrait(); - $class->connection = 'alternative'; - $expected = ['alternative.special_tags']; - $this->assertEquals($expected, $class->fetchTableName('SpecialTagsTable.php', 'TestBlog')); - - ConnectionManager::setConfig('alternative', [ - 'database' => 'alternative', - ]); - $class->connection = 'alternative'; - $expected = ['special_tags']; - $this->assertEquals($expected, $class->fetchTableName('SpecialTagsTable.php', 'TestBlog')); - - ConnectionManager::drop('alternative'); - ConnectionManager::setConfig('alternative', [ - 'schema' => 'alternative', - ]); - $class->connection = 'alternative'; - $expected = ['special_tags']; - $this->assertEquals($expected, $class->fetchTableName('SpecialTagsTable.php', 'TestBlog')); - } - /** * Get the baked filename based on the current db environment * diff --git a/tests/TestCase/Command/BakeSeedCommandTest.php b/tests/TestCase/Command/BakeSeedCommandTest.php index 4cb1dc7d..5b3731b7 100644 --- a/tests/TestCase/Command/BakeSeedCommandTest.php +++ b/tests/TestCase/Command/BakeSeedCommandTest.php @@ -77,7 +77,7 @@ public function testWithData() $this->exec('bake seed Events --connection test --data'); $path = __FUNCTION__ . '.php'; - if (getenv('DB') === 'pgsql') { + if (in_array(getenv('DB'), ['pgsql', 'sqlserver'])) { $path = getenv('DB') . DS . $path; } elseif (PHP_VERSION_ID >= 80100) { $path = 'php81' . DS . $path; @@ -114,7 +114,7 @@ public function testWithDataAndLimit() $this->exec('bake seed Events --connection test --data --limit 2'); $path = __FUNCTION__ . '.php'; - if (getenv('DB') === 'pgsql') { + if (in_array(getenv('DB'), ['pgsql', 'sqlserver'])) { $path = getenv('DB') . DS . $path; } elseif (PHP_VERSION_ID >= 80100) { $path = 'php81' . DS . $path; diff --git a/tests/TestCase/Command/CompletionTest.php b/tests/TestCase/Command/CompletionTest.php index afcceebc..45cb7712 100644 --- a/tests/TestCase/Command/CompletionTest.php +++ b/tests/TestCase/Command/CompletionTest.php @@ -44,7 +44,7 @@ public function testMigrationsSubcommands() { $this->exec('completion subcommands migrations.migrations'); $expected = [ - 'orm-cache-build orm-cache-clear create dump mark_migrated migrate rollback seed status', + 'dump mark_migrated migrate orm-cache-build orm-cache-clear create rollback seed status', ]; $actual = $this->_out->messages(); $this->assertEquals($expected, $actual); diff --git a/tests/TestCase/Command/DumpCommandTest.php b/tests/TestCase/Command/DumpCommandTest.php new file mode 100644 index 00000000..12e0c494 --- /dev/null +++ b/tests/TestCase/Command/DumpCommandTest.php @@ -0,0 +1,102 @@ +connection */ + $this->connection = ConnectionManager::get('test'); + $this->connection->execute('DROP TABLE IF EXISTS numbers'); + $this->connection->execute('DROP TABLE IF EXISTS letters'); + $this->connection->execute('DROP TABLE IF EXISTS parts'); + $this->connection->execute('DROP TABLE IF EXISTS phinxlog'); + + $this->dumpFile = ROOT . DS . 'config/TestsMigrations/schema-dump-test.lock'; + } + + public function tearDown(): void + { + parent::tearDown(); + + $this->connection->execute('DROP TABLE IF EXISTS numbers'); + $this->connection->execute('DROP TABLE IF EXISTS letters'); + $this->connection->execute('DROP TABLE IF EXISTS parts'); + $this->connection->execute('DROP TABLE IF EXISTS phinxlog'); + if (file_exists($this->dumpFile)) { + unlink($this->dumpFile); + } + } + + public function testExecuteIncorrectConnection(): void + { + $this->expectException(RuntimeException::class); + $this->exec('migrations dump --connection lolnope'); + } + + public function testExecuteIncorrectPlugin(): void + { + $this->expectException(MissingPluginException::class); + $this->exec('migrations dump --plugin lolnope'); + } + + public function testExecuteSuccess(): void + { + // Run migrations + $this->exec('migrations migrate --connection test --source TestsMigrations --no-lock'); + $this->assertExitSuccess(); + + // Generate dump file. + $this->exec('migrations dump --connection test --source TestsMigrations'); + + $this->assertExitSuccess(); + $this->assertOutputContains('config' . DS . 'TestsMigrations' . DS . 'schema-dump-test.lock'); + + $this->assertFileExists($this->dumpFile); + /** @var array $generatedDump */ + $generatedDump = unserialize(file_get_contents($this->dumpFile)); + + $this->assertArrayHasKey('letters', $generatedDump); + $this->assertArrayHasKey('numbers', $generatedDump); + $this->assertInstanceOf(TableSchema::class, $generatedDump['numbers']); + $this->assertInstanceOf(TableSchema::class, $generatedDump['letters']); + $this->assertEquals(['id', 'number', 'radix'], $generatedDump['numbers']->columns()); + $this->assertEquals(['id', 'letter'], $generatedDump['letters']->columns()); + } + + public function testExecutePlugin(): void + { + $this->loadPlugins(['Migrator']); + + $this->exec('migrations dump --connection test --plugin Migrator'); + + $this->assertExitSuccess(); + $this->assertOutputContains('Migrator' . DS . 'config' . DS . 'Migrations' . DS . 'schema-dump-test.lock'); + + $dumpFile = Plugin::path('Migrator') . '/config/Migrations/schema-dump-test.lock'; + if (file_exists($dumpFile)) { + unlink($dumpFile); + } + } +} diff --git a/tests/TestCase/Command/EntryCommandTest.php b/tests/TestCase/Command/EntryCommandTest.php new file mode 100644 index 00000000..5fe64b9e --- /dev/null +++ b/tests/TestCase/Command/EntryCommandTest.php @@ -0,0 +1,65 @@ +exec('migrations --help'); + + $this->assertExitSuccess(); + $this->assertOutputContains('Available Commands'); + $this->assertOutputContains('migrations migrate'); + $this->assertOutputContains('migrations status'); + $this->assertOutputContains('migrations rollback'); + } + + /** + * Test execute() generating help + * + * @return void + */ + public function testExecuteMissingCommand() + { + $this->exec('migrations derp'); + + $this->assertExitError(); + $this->assertErrorContains('Could not find migrations command named `derp`'); + } +} diff --git a/tests/TestCase/Command/MarkMigratedTest.php b/tests/TestCase/Command/MarkMigratedTest.php new file mode 100644 index 00000000..e1c94ec3 --- /dev/null +++ b/tests/TestCase/Command/MarkMigratedTest.php @@ -0,0 +1,313 @@ +connection = ConnectionManager::get('test'); + $this->connection->execute('DROP TABLE IF EXISTS migrator_phinxlog'); + $this->connection->execute('DROP TABLE IF EXISTS phinxlog'); + $this->connection->execute('DROP TABLE IF EXISTS numbers'); + } + + /** + * tearDown method + * + * @return void + */ + public function tearDown(): void + { + parent::tearDown(); + $this->connection->execute('DROP TABLE IF EXISTS migrator_phinxlog'); + $this->connection->execute('DROP TABLE IF EXISTS phinxlog'); + $this->connection->execute('DROP TABLE IF EXISTS numbers'); + } + + /** + * Test executing "mark_migration" in a standard way + * + * @return void + */ + public function testExecute() + { + $this->exec('migrations mark_migrated --connection=test --source=TestsMigrations'); + + $this->assertExitSuccess(); + $this->assertOutputContains( + 'Migration `20150826191400` successfully marked migrated !', + ); + $this->assertOutputContains( + 'Migration `20150724233100` successfully marked migrated !', + ); + $this->assertOutputContains( + 'Migration `20150704160200` successfully marked migrated !', + ); + + $result = $this->connection->selectQuery()->select(['*'])->from('phinxlog')->execute()->fetchAll('assoc'); + $this->assertEquals('20150704160200', $result[0]['version']); + $this->assertEquals('20150724233100', $result[1]['version']); + $this->assertEquals('20150826191400', $result[2]['version']); + + $this->exec('migrations mark_migrated --connection=test --source=TestsMigrations'); + + $this->assertExitSuccess(); + $this->assertOutputContains( + 'Skipping migration `20150704160200` (already migrated).', + ); + $this->assertOutputContains( + 'Skipping migration `20150724233100` (already migrated).', + ); + $this->assertOutputContains( + 'Skipping migration `20150826191400` (already migrated).', + ); + + $result = $this->connection->selectQuery()->select(['COUNT(*)'])->from('phinxlog')->execute(); + $this->assertEquals(4, $result->fetchColumn(0)); + } + + public function testExecuteTarget() + { + $this->exec('migrations mark_migrated --connection=test --source=TestsMigrations --target=20150704160200'); + $this->assertExitSuccess(); + + $this->assertOutputContains( + 'Migration `20150704160200` successfully marked migrated !', + ); + + $result = $this->connection->selectQuery() + ->select(['*']) + ->from('phinxlog') + ->execute() + ->fetchAll('assoc'); + $this->assertEquals('20150704160200', $result[0]['version']); + + $this->exec('migrations mark_migrated --connection=test --source=TestsMigrations --target=20150826191400'); + $this->assertExitSuccess(); + + $this->assertOutputContains( + 'Skipping migration `20150704160200` (already migrated).', + ); + $this->assertOutputContains( + 'Migration `20150724233100` successfully marked migrated !', + ); + $this->assertOutputContains( + 'Migration `20150826191400` successfully marked migrated !', + ); + + $result = $this->connection->selectQuery() + ->select(['*']) + ->from('phinxlog') + ->execute() + ->fetchAll('assoc'); + $this->assertEquals('20150704160200', $result[0]['version']); + $this->assertEquals('20150724233100', $result[1]['version']); + $this->assertEquals('20150826191400', $result[2]['version']); + + $result = $this->connection->selectQuery() + ->select(['COUNT(*)']) + ->from('phinxlog') + ->execute(); + $this->assertEquals(3, $result->fetchColumn(0)); + } + + public function testTargetNotFound(): void + { + $this->exec('migrations mark_migrated --connection=test --source=TestsMigrations --target=20150704160610'); + $this->assertExitError(); + + $this->assertErrorContains( + 'Migration `20150704160610` was not found !', + ); + } + + public function testExecuteTargetWithExclude() + { + $this->exec('migrations mark_migrated --connection=test --source=TestsMigrations --target=20150724233100 --exclude'); + $this->assertExitSuccess(); + $this->assertOutputContains( + 'Migration `20150704160200` successfully marked migrated !', + ); + + $result = $this->connection->selectQuery() + ->select(['*']) + ->from('phinxlog') + ->execute() + ->fetchAll('assoc'); + $this->assertEquals('20150704160200', $result[0]['version']); + + $this->exec('migrations mark_migrated --connection=test --source=TestsMigrations --target=20150826191400 --exclude'); + + $this->assertOutputContains( + 'Skipping migration `20150704160200` (already migrated).', + ); + $this->assertOutputContains( + 'Migration `20150724233100` successfully marked migrated !', + ); + + $result = $this->connection->selectQuery() + ->select(['*']) + ->from('phinxlog') + ->execute() + ->fetchAll('assoc'); + $this->assertEquals('20150704160200', $result[0]['version']); + $this->assertEquals('20150724233100', $result[1]['version']); + + $result = $this->connection->selectQuery() + ->select(['COUNT(*)']) + ->from('phinxlog') + ->execute(); + $this->assertEquals(2, $result->fetchColumn(0)); + } + + public function testExecuteTargetWithExcludeNotFound(): void + { + $this->exec('migrations mark_migrated --connection=test --source=TestsMigrations --target=20150704160610 --exclude'); + $this->assertExitError(); + + $this->assertErrorContains( + 'Migration `20150704160610` was not found !', + ); + } + + public function testExecuteTargetWithOnly() + { + $this->exec('migrations mark_migrated --connection=test --source=TestsMigrations --target=20150724233100 --only'); + $this->assertExitSuccess(); + + $this->assertOutputContains( + 'Migration `20150724233100` successfully marked migrated !', + ); + + $result = $this->connection->selectQuery() + ->select(['*']) + ->from('phinxlog') + ->execute() + ->fetchAll('assoc'); + $this->assertEquals('20150724233100', $result[0]['version']); + + $this->exec('migrations mark_migrated --connection=test --source=TestsMigrations --target=20150826191400 --only'); + + $this->assertOutputContains( + 'Migration `20150826191400` successfully marked migrated !', + ); + + $result = $this->connection->selectQuery() + ->select(['*']) + ->from('phinxlog') + ->execute() + ->fetchAll('assoc'); + $this->assertEquals('20150826191400', $result[1]['version']); + $this->assertEquals('20150724233100', $result[0]['version']); + $result = $this->connection->selectQuery() + ->select(['COUNT(*)']) + ->from('phinxlog') + ->execute(); + $this->assertEquals(2, $result->fetchColumn(0)); + } + + public function testExecuteTargetWithOnlyNotFound(): void + { + $this->exec('migrations mark_migrated --connection=test --source=TestsMigrations --target=20150704160610 --only'); + $this->assertExitError(); + + $this->assertErrorContains( + 'Migration `20150704160610` was not found !', + ); + } + + public function testExecuteInvalidUseOfExclude() + { + $this->exec('migrations mark_migrated --connection=test --source=TestsMigrations --exclude'); + + $this->assertExitError(); + $this->assertErrorContains( + 'You should use `--exclude` OR `--only` (not both) along with a `--target` !', + ); + } + + public function testExecuteInvalidUseOfOnly(): void + { + $this->exec('migrations mark_migrated --connection=test --source=TestsMigrations --only'); + + $this->assertExitError(); + $this->assertErrorContains( + 'You should use `--exclude` OR `--only` (not both) along with a `--target` !', + ); + } + + public function testExecuteInvalidUseOfOnlyAndExclude(): void + { + $this->exec('migrations mark_migrated --connection=test --source=TestsMigrations --only --exclude'); + + $this->assertExitError(); + $this->assertErrorContains( + 'You should use `--exclude` OR `--only` (not both) along with a `--target` !', + ); + } + + public function testExecutePluginInvalid(): void + { + try { + $this->exec('migrations mark_migrated -c test --plugin NotThere'); + $this->fail('Should raise an error or exit with an error'); + } catch (MissingPluginException $e) { + $this->assertTrue(true); + } + /** @var \Cake\Database\Connection $connection */ + $connection = ConnectionManager::get('test'); + $tables = $connection->getSchemaCollection()->listTables(); + $this->assertNotContains('not_there_phinxlog', $tables); + } + + public function testExecutePlugin(): void + { + $this->loadPlugins(['Migrator']); + $this->exec('migrations mark_migrated -c test --plugin Migrator --only --target 20211001000000'); + $this->assertExitSuccess(); + $this->assertOutputContains('`20211001000000` successfully marked migrated'); + + /** @var \Cake\Database\Connection $connection */ + $connection = ConnectionManager::get('test'); + $tables = $connection->getSchemaCollection()->listTables(); + $this->assertContains('migrator_phinxlog', $tables); + } +} diff --git a/tests/TestCase/Command/MigrateCommandTest.php b/tests/TestCase/Command/MigrateCommandTest.php new file mode 100644 index 00000000..497839f0 --- /dev/null +++ b/tests/TestCase/Command/MigrateCommandTest.php @@ -0,0 +1,339 @@ +fetchTable('Phinxlog'); + $table->deleteAll('1=1'); + } catch (DatabaseException $e) { + } + + try { + $table = $this->fetchTable('MigratorPhinxlog'); + $table->deleteAll('1=1'); + } catch (DatabaseException $e) { + } + } + + public function tearDown(): void + { + parent::tearDown(); + foreach ($this->createdFiles as $file) { + unlink($file); + } + } + + public function testHelp() + { + $this->exec('migrations migrate --help'); + + $this->assertExitSuccess(); + $this->assertOutputContains('Apply migrations to a SQL datasource'); + } + + /** + * Test that running with no migrations is successful + */ + public function testMigrateNoMigrationSource(): void + { + $migrationPath = ROOT . DS . 'config' . DS . 'Missing'; + $this->exec('migrations migrate -c test -s Missing --no-lock'); + $this->assertExitSuccess(); + + $this->assertOutputContains('using paths ' . $migrationPath); + $this->assertOutputContains('using connection test'); + $this->assertOutputContains('All Done'); + + $table = $this->fetchTable('Phinxlog'); + $this->assertCount(0, $table->find()->all()->toArray()); + + $dumpFile = $migrationPath . DS . 'schema-dump-test.lock'; + $this->assertFileDoesNotExist($dumpFile); + } + + /** + * Test that source parameter defaults to Migrations + */ + public function testMigrateSourceDefault(): void + { + $migrationPath = ROOT . DS . 'config' . DS . 'Migrations'; + $this->exec('migrations migrate -c test'); + $this->assertExitSuccess(); + + $this->assertOutputContains('using connection test'); + $this->assertOutputContains('using paths ' . $migrationPath); + $this->assertOutputContains('MarkMigratedTest: migrated'); + $this->assertOutputContains('All Done'); + $this->assertOutputContains('Dumping the current schema'); + + $table = $this->fetchTable('Phinxlog'); + $this->assertCount(2, $table->find()->all()->toArray()); + + $dumpFile = $migrationPath . DS . 'schema-dump-test.lock'; + $this->createdFiles[] = $dumpFile; + $this->assertFileExists($dumpFile); + } + + /** + * Test that running with a no-op migrations is successful + */ + public function testMigrateWithSourceMigration(): void + { + $migrationPath = ROOT . DS . 'config' . DS . 'ShouldExecute'; + $this->exec('migrations migrate -c test -s ShouldExecute'); + $this->assertExitSuccess(); + + $this->assertOutputContains('using connection test'); + $this->assertOutputContains('using paths ' . $migrationPath); + $this->assertOutputContains('ShouldExecuteMigration: migrated'); + $this->assertOutputContains('ShouldNotExecuteMigration: skipped '); + $this->assertOutputContains('All Done'); + + $table = $this->fetchTable('Phinxlog'); + $this->assertCount(1, $table->find()->all()->toArray()); + + $dumpFile = $migrationPath . DS . 'schema-dump-test.lock'; + $this->createdFiles[] = $dumpFile; + $this->assertFileExists($dumpFile); + } + + /** + * Test dry-run + */ + public function testMigrateDryRun() + { + $migrationPath = ROOT . DS . 'config' . DS . 'Migrations'; + $this->exec('migrations migrate -c test --dry-run'); + $this->assertExitSuccess(); + + $this->assertOutputContains('dry-run mode enabled'); + $this->assertOutputContains('using connection test'); + $this->assertOutputContains('using paths ' . $migrationPath); + $this->assertOutputContains('MarkMigratedTest: migrated'); + $this->assertOutputContains('All Done'); + + $table = $this->fetchTable('Phinxlog'); + $this->assertCount(0, $table->find()->all()->toArray()); + + $dumpFile = $migrationPath . DS . 'schema-dump-test.lock'; + $this->assertFileDoesNotExist($dumpFile); + } + + /** + * Test that migrations only run to a certain date + */ + public function testMigrateDate() + { + $migrationPath = ROOT . DS . 'config' . DS . 'Migrations'; + $this->exec('migrations migrate -c test --date 2020-01-01'); + $this->assertExitSuccess(); + + $this->assertOutputContains('using connection test'); + $this->assertOutputContains('using paths ' . $migrationPath); + $this->assertOutputContains('MarkMigratedTest: migrated'); + $this->assertOutputContains('All Done'); + + $table = $this->fetchTable('Phinxlog'); + $this->assertCount(1, $table->find()->all()->toArray()); + $this->assertFileExists($migrationPath . DS . 'schema-dump-test.lock'); + } + + /** + * Test output for dates with no matching migrations + */ + public function testMigrateDateNotFound() + { + $migrationPath = ROOT . DS . 'config' . DS . 'Migrations'; + $this->exec('migrations migrate -c test --date 2000-01-01'); + $this->assertExitSuccess(); + + $this->assertOutputContains('using connection test'); + $this->assertOutputContains('using paths ' . $migrationPath); + $this->assertOutputNotContains('MarkMigratedTest'); + $this->assertOutputContains('No migrations to run'); + $this->assertOutputContains('All Done'); + + $table = $this->fetchTable('Phinxlog'); + $this->assertCount(0, $table->find()->all()->toArray()); + $this->assertFileExists($migrationPath . DS . 'schema-dump-test.lock'); + } + + /** + * Test advancing migrations with an offset. + */ + public function testMigrateTarget() + { + $migrationPath = ROOT . DS . 'config' . DS . 'Migrations'; + $this->exec('migrations migrate -c test --target 20150416223600'); + $this->assertExitSuccess(); + + $this->assertOutputContains('using connection test'); + $this->assertOutputContains('using paths ' . $migrationPath); + $this->assertOutputContains('MarkMigratedTest: migrated'); + $this->assertOutputNotContains('MarkMigratedTestSecond'); + $this->assertOutputContains('All Done'); + + $table = $this->fetchTable('Phinxlog'); + $this->assertCount(1, $table->find()->all()->toArray()); + + $dumpFile = $migrationPath . DS . 'schema-dump-test.lock'; + $this->createdFiles[] = $dumpFile; + $this->assertFileExists($dumpFile); + } + + public function testMigrateTargetNotFound() + { + $migrationPath = ROOT . DS . 'config' . DS . 'Migrations'; + $this->exec('migrations migrate -c test --target 99'); + $this->assertExitSuccess(); + + $this->assertOutputContains('using connection test'); + $this->assertOutputContains('using paths ' . $migrationPath); + $this->assertOutputNotContains('MarkMigratedTest'); + $this->assertOutputNotContains('MarkMigratedTestSecond'); + $this->assertOutputContains('warning 99 is not a valid version'); + $this->assertOutputContains('All Done'); + + $table = $this->fetchTable('Phinxlog'); + $this->assertCount(0, $table->find()->all()->toArray()); + + $dumpFile = $migrationPath . DS . 'schema-dump-test.lock'; + $this->createdFiles[] = $dumpFile; + $this->assertFileExists($dumpFile); + } + + public function testMigrateFakeAll() + { + $migrationPath = ROOT . DS . 'config' . DS . 'Migrations'; + $this->exec('migrations migrate -c test --fake'); + $this->assertExitSuccess(); + + $this->assertOutputContains('using connection test'); + $this->assertOutputContains('using paths ' . $migrationPath); + $this->assertOutputContains('warning performing fake migrations'); + $this->assertOutputContains('MarkMigratedTest: migrated'); + $this->assertOutputContains('MarkMigratedTestSecond: migrated'); + $this->assertOutputContains('All Done'); + + $table = $this->fetchTable('Phinxlog'); + $this->assertCount(2, $table->find()->all()->toArray()); + + $dumpFile = $migrationPath . DS . 'schema-dump-test.lock'; + $this->createdFiles[] = $dumpFile; + $this->assertFileExists($dumpFile); + } + + public function testMigratePlugin() + { + $this->loadPlugins(['Migrator']); + $migrationPath = ROOT . DS . 'Plugin' . DS . 'Migrator' . DS . 'config' . DS . 'Migrations'; + $this->exec('migrations migrate -c test --plugin Migrator'); + $this->assertExitSuccess(); + + $this->assertOutputContains('using connection test'); + $this->assertOutputContains('using paths ' . $migrationPath); + $this->assertOutputContains('Migrator: migrated'); + $this->assertOutputContains('All Done'); + + // Migration tracking table is plugin specific + $table = $this->fetchTable('MigratorPhinxlog'); + $this->assertCount(1, $table->find()->all()->toArray()); + + $dumpFile = $migrationPath . DS . 'schema-dump-test.lock'; + $this->assertOutputContains('Writing dump file `' . $dumpFile); + $this->createdFiles[] = $dumpFile; + $this->assertFileExists($dumpFile); + } + + public function testMigratePluginInvalid() + { + try { + $this->exec('migrations migrate -c test --plugin NotThere'); + $this->fail('Should raise an error or exit with an error'); + } catch (MissingPluginException $e) { + $this->assertTrue(true); + } + + /** @var \Cake\Database\Connection $connection */ + $connection = ConnectionManager::get('test'); + $tables = $connection->getSchemaCollection()->listTables(); + $this->assertNotContains('not_there_phinxlog', $tables); + } + + /** + * Test that migrating with the `--no-lock` option will not dispatch a dump shell + * + * @return void + */ + public function testMigrateWithNoLock() + { + $migrationPath = ROOT . DS . 'config' . DS . 'Migrations'; + $this->exec('migrations migrate -c test --no-lock'); + $this->assertExitSuccess(); + + $this->assertOutputContains('using connection test'); + $this->assertOutputContains('using paths ' . $migrationPath); + $this->assertOutputContains('MarkMigratedTest: migrated'); + $this->assertOutputContains('All Done'); + $this->assertOutputNotContains('Dumping'); + $this->assertFileDoesNotExist($migrationPath . DS . 'schema-dump-test.lock'); + } + + public function testEventsFired(): void + { + /** @var array $fired */ + $fired = []; + EventManager::instance()->on('Migration.beforeMigrate', function (EventInterface $event) use (&$fired): void { + $fired[] = $event->getName(); + }); + EventManager::instance()->on('Migration.afterMigrate', function (EventInterface $event) use (&$fired): void { + $fired[] = $event->getName(); + }); + $this->exec('migrations migrate -c test --no-lock'); + $this->assertExitSuccess(); + $this->assertSame(['Migration.beforeMigrate', 'Migration.afterMigrate'], $fired); + } + + public function testBeforeMigrateEventAbort(): void + { + /** @var array $fired */ + $fired = []; + EventManager::instance()->on('Migration.beforeMigrate', function (EventInterface $event) use (&$fired): void { + $fired[] = $event->getName(); + $event->stopPropagation(); + $event->setResult(0); + }); + EventManager::instance()->on('Migration.afterMigrate', function (EventInterface $event) use (&$fired): void { + $fired[] = $event->getName(); + }); + $this->exec('migrations migrate -c test --no-lock'); + $this->assertExitError(); + + // Only one event was fired + $this->assertSame(['Migration.beforeMigrate'], $fired); + + $table = $this->fetchTable('Phinxlog'); + $this->assertEquals(0, $table->find()->count()); + } +} diff --git a/tests/TestCase/Command/MigrationCommandTest.php b/tests/TestCase/Command/MigrationCommandTest.php index c05f7c74..8cc9a209 100644 --- a/tests/TestCase/Command/MigrationCommandTest.php +++ b/tests/TestCase/Command/MigrationCommandTest.php @@ -4,6 +4,7 @@ namespace Migrations\Test\TestCase\Command; use Cake\Console\ConsoleIo; +use Cake\Console\TestSuite\StubConsoleInput; use Cake\Console\TestSuite\StubConsoleOutput; use Cake\TestSuite\TestCase; use Migrations\MigrationsDispatcher; @@ -111,10 +112,10 @@ public function testRollbackWithNoLock() protected function getMockIo() { + $in = new StubConsoleInput([]); $output = new StubConsoleOutput(); $io = $this->getMockBuilder(ConsoleIo::class) - ->setConstructorArgs([$output, $output, null, null]) - ->addMethods(['in']) + ->setConstructorArgs([$output, $output, $in]) ->getMock(); return $io; diff --git a/tests/TestCase/Command/Phinx/DumpTest.php b/tests/TestCase/Command/Phinx/DumpTest.php index f6d2a6bf..847fa58b 100644 --- a/tests/TestCase/Command/Phinx/DumpTest.php +++ b/tests/TestCase/Command/Phinx/DumpTest.php @@ -91,6 +91,7 @@ public function setUp(): void $this->connection->execute('DROP TABLE IF EXISTS numbers'); $this->connection->execute('DROP TABLE IF EXISTS letters'); $this->connection->execute('DROP TABLE IF EXISTS parts'); + $this->connection->execute('DROP TABLE IF EXISTS stores'); $this->dumpfile = ROOT . DS . 'config/TestsMigrations/schema-dump-test.lock'; } @@ -106,6 +107,7 @@ public function tearDown(): void $this->connection->execute('DROP TABLE IF EXISTS numbers'); $this->connection->execute('DROP TABLE IF EXISTS letters'); $this->connection->execute('DROP TABLE IF EXISTS parts'); + $this->connection->execute('DROP TABLE IF EXISTS stores'); } /** diff --git a/tests/TestCase/Command/Phinx/MarkMigratedTest.php b/tests/TestCase/Command/Phinx/MarkMigratedTest.php index f85077d2..003d3492 100644 --- a/tests/TestCase/Command/Phinx/MarkMigratedTest.php +++ b/tests/TestCase/Command/Phinx/MarkMigratedTest.php @@ -142,7 +142,7 @@ public function testExecute() ); $result = $this->connection->selectQuery()->select(['COUNT(*)'])->from('phinxlog')->execute(); - $this->assertSame(4, $result->fetchColumn(0)); + $this->assertEquals(4, $result->fetchColumn(0)); $config = $this->command->getConfig(); $env = $this->command->getManager()->getEnvironment('default'); @@ -281,7 +281,7 @@ public function testExecuteTarget() $this->assertEquals('20150724233100', $result[1]['version']); $this->assertEquals('20150826191400', $result[2]['version']); $result = $this->connection->selectQuery()->select(['COUNT(*)'])->from('phinxlog')->execute(); - $this->assertSame(3, $result->fetchColumn(0)); + $this->assertEquals(3, $result->fetchColumn(0)); $this->commandTester->execute([ 'command' => $this->command->getName(), @@ -335,7 +335,7 @@ public function testExecuteTargetWithExclude() $this->assertEquals('20150704160200', $result[0]['version']); $this->assertEquals('20150724233100', $result[1]['version']); $result = $this->connection->selectQuery()->select(['COUNT(*)'])->from('phinxlog')->execute(); - $this->assertSame(2, $result->fetchColumn(0)); + $this->assertEquals(2, $result->fetchColumn(0)); $this->commandTester->execute([ 'command' => $this->command->getName(), @@ -386,7 +386,7 @@ public function testExecuteTargetWithOnly() $this->assertEquals('20150826191400', $result[1]['version']); $this->assertEquals('20150724233100', $result[0]['version']); $result = $this->connection->selectQuery()->select(['COUNT(*)'])->from('phinxlog')->execute(); - $this->assertSame(2, $result->fetchColumn(0)); + $this->assertEquals(2, $result->fetchColumn(0)); $this->commandTester->execute([ 'command' => $this->command->getName(), @@ -436,7 +436,7 @@ public function testExecuteInvalidUseOfOnlyAndExclude() ]); $result = $this->connection->selectQuery()->select(['COUNT(*)'])->from('phinxlog')->execute(); - $this->assertSame(0, $result->fetchColumn(0)); + $this->assertEquals(0, $result->fetchColumn(0)); $this->assertStringContainsString( 'You should use `--exclude` OR `--only` (not both) along with a `--target` !', $this->commandTester->getDisplay() @@ -450,7 +450,7 @@ public function testExecuteInvalidUseOfOnlyAndExclude() ]); $result = $this->connection->selectQuery()->select(['COUNT(*)'])->from('phinxlog')->execute(); - $this->assertSame(0, $result->fetchColumn(0)); + $this->assertEquals(0, $result->fetchColumn(0)); $this->assertStringContainsString( 'You should use `--exclude` OR `--only` (not both) along with a `--target` !', $this->commandTester->getDisplay() @@ -466,7 +466,7 @@ public function testExecuteInvalidUseOfOnlyAndExclude() ]); $result = $this->connection->selectQuery()->select(['COUNT(*)'])->from('phinxlog')->execute(); - $this->assertSame(0, $result->fetchColumn(0)); + $this->assertEquals(0, $result->fetchColumn(0)); $this->assertStringContainsString( 'You should use `--exclude` OR `--only` (not both) along with a `--target` !', $this->commandTester->getDisplay() diff --git a/tests/TestCase/Command/Phinx/SeedTest.php b/tests/TestCase/Command/Phinx/SeedTest.php index 81994d92..d80f77c1 100644 --- a/tests/TestCase/Command/Phinx/SeedTest.php +++ b/tests/TestCase/Command/Phinx/SeedTest.php @@ -85,8 +85,11 @@ public function setUp(): void public function tearDown(): void { parent::tearDown(); + $this->connection->execute('DROP TABLE IF EXISTS phinxlog'); $this->connection->execute('DROP TABLE IF EXISTS numbers'); + $this->connection->execute('DROP TABLE IF EXISTS letters'); + $this->connection->execute('DROP TABLE IF EXISTS stores'); } /** diff --git a/tests/TestCase/Command/Phinx/StatusTest.php b/tests/TestCase/Command/Phinx/StatusTest.php index 22e8fd3e..97f493d9 100644 --- a/tests/TestCase/Command/Phinx/StatusTest.php +++ b/tests/TestCase/Command/Phinx/StatusTest.php @@ -185,6 +185,7 @@ public function testExecuteWithInconsistency() $migrations = $this->getMigrations(); $migrations->migrate(); + $migrations = $this->getMigrations(); $migrationPaths = $migrations->getConfig()->getMigrationPaths(); $migrationPath = array_pop($migrationPaths); $origin = $migrationPath . DS . '20150724233100_update_numbers_table.php'; @@ -248,7 +249,14 @@ protected function getMigrations() 'connection' => 'test', 'source' => 'TestsMigrations', ]; + $args = [ + '--connection' => $params['connection'], + '--source' => $params['source'], + ]; + $input = new ArrayInput($args, $this->command->getDefinition()); $migrations = new Migrations($params); + $migrations->setInput($input); + $this->command->setInput($input); $adapter = $migrations ->getManager($this->command->getConfig()) diff --git a/tests/TestCase/Command/RollbackCommandTest.php b/tests/TestCase/Command/RollbackCommandTest.php new file mode 100644 index 00000000..3fdbd08e --- /dev/null +++ b/tests/TestCase/Command/RollbackCommandTest.php @@ -0,0 +1,276 @@ +fetchTable('Phinxlog'); + $table->deleteAll('1=1'); + } catch (DatabaseException $e) { + } + + try { + $table = $this->fetchTable('MigratorPhinxlog'); + $table->deleteAll('1=1'); + } catch (DatabaseException $e) { + } + } + + public function tearDown(): void + { + parent::tearDown(); + foreach ($this->createdFiles as $file) { + unlink($file); + } + } + + protected function resetOutput(): void + { + if ($this->_out) { + $property = new ReflectionProperty($this->_out, '_out'); + $property->setValue($this->_out, []); + } + } + + public function testHelp(): void + { + $this->exec('migrations migrate --help'); + + $this->assertExitSuccess(); + $this->assertOutputContains('Apply migrations to a SQL datasource'); + } + + /** + * Test that running with no migrations is successful + */ + public function testSourceMissing(): void + { + $migrationPath = ROOT . DS . 'config' . DS . 'Missing'; + $this->exec('migrations rollback -c test -s Missing --no-lock'); + $this->assertExitSuccess(); + + $this->assertOutputContains('using paths ' . $migrationPath); + $this->assertOutputContains('using connection test'); + $this->assertOutputContains('No migrations to rollback'); + $this->assertOutputContains('All Done'); + + $table = $this->fetchTable('Phinxlog'); + $this->assertCount(0, $table->find()->all()->toArray()); + + $dumpFile = $migrationPath . DS . 'schema-dump-test.lock'; + $this->assertFileDoesNotExist($dumpFile); + } + + /** + * Test that running with dry-run works + */ + public function testExecuteDryRun(): void + { + $migrationPath = ROOT . DS . 'config' . DS . 'Migrations'; + $this->exec('migrations migrate -c test --no-lock'); + $this->assertExitSuccess(); + $this->resetOutput(); + + $this->exec('migrations rollback -c test --no-lock --dry-run'); + $this->assertExitSuccess(); + + $this->assertOutputContains('using paths ' . $migrationPath); + $this->assertOutputContains('using connection test'); + $this->assertOutputContains('dry-run mode enabled'); + $this->assertOutputContains('20240309223600 MarkMigratedTestSecond: reverting'); + $this->assertOutputContains('All Done'); + + $table = $this->fetchTable('Phinxlog'); + $this->assertCount(2, $table->find()->all()->toArray()); + + $dumpFile = $migrationPath . DS . 'schema-dump-test.lock'; + $this->assertFileDoesNotExist($dumpFile); + } + + public function testDateOptionNoMigration(): void + { + $this->expectException(InvalidArgumentException::class); + $this->exec('migrations rollback -c test --no-lock --date 2000-01-01'); + } + + public function testDateOptionInvalidFormat(): void + { + $this->expectException(InvalidArgumentException::class); + $this->exec('migrations rollback -c test --no-lock --date 20001'); + } + + public function testDateOptionSuccessDateYearMonthDateHour(): void + { + $migrationPath = ROOT . DS . 'config' . DS . 'Migrations'; + $this->exec('migrations migrate -c test --no-lock'); + $this->assertExitSuccess(); + $this->resetOutput(); + + $this->exec('migrations rollback -c test --no-lock --date 2024030922'); + $this->assertExitSuccess(); + + $this->assertOutputContains('MarkMigratedTestSecond: reverted'); + + $dumpFile = $migrationPath . DS . 'schema-dump-test.lock'; + $this->assertFileDoesNotExist($dumpFile); + } + + public function testDateOptionSuccessYearMonthDate(): void + { + $migrationPath = ROOT . DS . 'config' . DS . 'Migrations'; + $this->exec('migrations migrate -c test --no-lock'); + $this->assertExitSuccess(); + $this->resetOutput(); + + $this->exec('migrations rollback -c test --no-lock --date 20240309'); + $this->assertExitSuccess(); + + $this->assertOutputContains('MarkMigratedTestSecond: reverted'); + + $dumpFile = $migrationPath . DS . 'schema-dump-test.lock'; + $this->assertFileDoesNotExist($dumpFile); + } + + public function testDateOptionSuccessYearMonth(): void + { + $migrationPath = ROOT . DS . 'config' . DS . 'Migrations'; + $this->exec('migrations migrate -c test --no-lock'); + $this->assertExitSuccess(); + $this->resetOutput(); + + $this->exec('migrations rollback -c test --no-lock --date 202403'); + $this->assertExitSuccess(); + + $this->assertOutputContains('MarkMigratedTestSecond: reverted'); + + $dumpFile = $migrationPath . DS . 'schema-dump-test.lock'; + $this->assertFileDoesNotExist($dumpFile); + } + + public function testDateOptionSuccessYear(): void + { + $migrationPath = ROOT . DS . 'config' . DS . 'Migrations'; + $this->exec('migrations migrate -c test --no-lock'); + $this->assertExitSuccess(); + $this->resetOutput(); + + $this->exec('migrations rollback -c test --no-lock --date 2024'); + $this->assertExitSuccess(); + + $this->assertOutputContains('MarkMigratedTestSecond: reverted'); + + $dumpFile = $migrationPath . DS . 'schema-dump-test.lock'; + $this->assertFileDoesNotExist($dumpFile); + } + + public function testTargetOption(): void + { + $migrationPath = ROOT . DS . 'config' . DS . 'Migrations'; + $this->exec('migrations migrate -c test --no-lock'); + $this->assertExitSuccess(); + $this->resetOutput(); + + $this->exec('migrations rollback -c test --no-lock --target MarkMigratedTestSecond'); + $this->assertExitSuccess(); + + $this->assertOutputContains('MarkMigratedTestSecond: reverted'); + + $dumpFile = $migrationPath . DS . 'schema-dump-test.lock'; + $this->assertFileDoesNotExist($dumpFile); + } + + public function testLockOption(): void + { + $migrationPath = ROOT . DS . 'config' . DS . 'Migrations'; + $this->exec('migrations migrate -c test --no-lock'); + $this->assertExitSuccess(); + $this->resetOutput(); + + $this->exec('migrations rollback -c test --target MarkMigratedTestSecond'); + $this->assertExitSuccess(); + + $this->assertOutputContains('MarkMigratedTestSecond: reverted'); + + $dumpFile = $migrationPath . DS . 'schema-dump-test.lock'; + $this->createdFiles[] = $dumpFile; + $this->assertFileExists($dumpFile); + } + + public function testFakeOption(): void + { + $migrationPath = ROOT . DS . 'config' . DS . 'Migrations'; + $this->exec('migrations migrate -c test --no-lock'); + $this->assertExitSuccess(); + $this->resetOutput(); + $table = $this->fetchTable('Phinxlog'); + $this->assertCount(2, $table->find()->all()->toArray()); + + $this->exec('migrations rollback -c test --no-lock --target MarkMigratedTestSecond --fake'); + $this->assertExitSuccess(); + + $this->assertOutputContains('performing fake rollbacks'); + $this->assertOutputContains('MarkMigratedTestSecond: reverted'); + + $this->assertCount(0, $table->find()->all()->toArray()); + + $dumpFile = $migrationPath . DS . 'schema-dump-test.lock'; + $this->assertFileDoesNotExist($dumpFile); + } + + public function testEventsFired(): void + { + /** @var array $fired */ + $fired = []; + EventManager::instance()->on('Migration.beforeRollback', function (EventInterface $event) use (&$fired): void { + $fired[] = $event->getName(); + }); + EventManager::instance()->on('Migration.afterRollback', function (EventInterface $event) use (&$fired): void { + $fired[] = $event->getName(); + }); + $this->exec('migrations rollback -c test --no-lock'); + $this->assertExitSuccess(); + $this->assertSame(['Migration.beforeRollback', 'Migration.afterRollback'], $fired); + } + + public function testBeforeMigrateEventAbort(): void + { + /** @var array $fired */ + $fired = []; + EventManager::instance()->on('Migration.beforeRollback', function (EventInterface $event) use (&$fired): void { + $fired[] = $event->getName(); + $event->stopPropagation(); + $event->setResult(0); + }); + EventManager::instance()->on('Migration.afterRollback', function (EventInterface $event) use (&$fired): void { + $fired[] = $event->getName(); + }); + $this->exec('migrations rollback -c test --no-lock'); + $this->assertExitError(); + + // Only one event was fired + $this->assertSame(['Migration.beforeRollback'], $fired); + + $table = $this->fetchTable('Phinxlog'); + $this->assertEquals(0, $table->find()->count()); + } +} diff --git a/tests/TestCase/Command/SeedCommandTest.php b/tests/TestCase/Command/SeedCommandTest.php new file mode 100644 index 00000000..8a9bedcc --- /dev/null +++ b/tests/TestCase/Command/SeedCommandTest.php @@ -0,0 +1,176 @@ +fetchTable('Phinxlog'); + try { + $table->deleteAll('1=1'); + } catch (DatabaseException $e) { + } + } + + public function tearDown(): void + { + parent::tearDown(); + /** @var \Cake\Database\Connection $connection */ + $connection = ConnectionManager::get('test'); + + $connection->execute('DROP TABLE IF EXISTS numbers'); + $connection->execute('DROP TABLE IF EXISTS letters'); + $connection->execute('DROP TABLE IF EXISTS stores'); + } + + protected function resetOutput(): void + { + if ($this->_out) { + $property = new ReflectionProperty($this->_out, '_out'); + $property->setValue($this->_out, []); + } + } + + protected function createTables(): void + { + $this->exec('migrations migrate -c test -s TestsMigrations --no-lock'); + $this->assertExitSuccess(); + $this->resetOutput(); + } + + public function testHelp(): void + { + $this->exec('migrations seed --help'); + $this->assertExitSuccess(); + $this->assertOutputContains('Seed the database with data'); + $this->assertOutputContains('migrations seed --connection secondary --seed UserSeeder'); + } + + public function testSeederEvents(): void + { + /** @var array $fired */ + $fired = []; + EventManager::instance()->on('Migration.beforeSeed', function (EventInterface $event) use (&$fired): void { + $fired[] = $event->getName(); + }); + EventManager::instance()->on('Migration.afterSeed', function (EventInterface $event) use (&$fired): void { + $fired[] = $event->getName(); + }); + + $this->createTables(); + $this->exec('migrations seed -c test --seed NumbersSeed'); + $this->assertExitSuccess(); + + $this->assertSame(['Migration.beforeSeed', 'Migration.afterSeed'], $fired); + } + + public function testBeforeSeederAbort(): void + { + /** @var array $fired */ + $fired = []; + EventManager::instance()->on('Migration.beforeSeed', function (EventInterface $event) use (&$fired): void { + $fired[] = $event->getName(); + $event->stopPropagation(); + }); + EventManager::instance()->on('Migration.afterSeed', function (EventInterface $event) use (&$fired): void { + $fired[] = $event->getName(); + }); + + $this->createTables(); + $this->exec('migrations seed -c test --seed NumbersSeed'); + $this->assertExitError(); + + $this->assertSame(['Migration.beforeSeed'], $fired); + } + + public function testSeederUnknown(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The seed class "NotThere" does not exist'); + $this->exec('migrations seed -c test --seed NotThere'); + } + + public function testSeederOne(): void + { + $this->createTables(); + $this->exec('migrations seed -c test --seed NumbersSeed'); + + $this->assertExitSuccess(); + $this->assertOutputContains('NumbersSeed: seeding'); + $this->assertOutputContains('All Done'); + + /** @var \Cake\Database\Connection $connection */ + $connection = ConnectionManager::get('test'); + $query = $connection->execute('SELECT COUNT(*) FROM numbers'); + $this->assertEquals(1, $query->fetchColumn(0)); + } + + public function testSeederImplictAll(): void + { + $this->createTables(); + $this->exec('migrations seed -c test'); + + $this->assertExitSuccess(); + $this->assertOutputContains('NumbersSeed: seeding'); + $this->assertOutputContains('All Done'); + + /** @var \Cake\Database\Connection $connection */ + $connection = ConnectionManager::get('test'); + $query = $connection->execute('SELECT COUNT(*) FROM numbers'); + $this->assertEquals(1, $query->fetchColumn(0)); + } + + public function testSeederMultipleNotFound(): void + { + $this->createTables(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The seed class "NotThere" does not exist'); + $this->exec('migrations seed -c test --seed NumbersSeed --seed NotThere'); + } + + public function testSeederMultiple(): void + { + $this->createTables(); + $this->exec('migrations seed -c test --source CallSeeds --seed LettersSeed --seed NumbersCallSeed'); + + $this->assertExitSuccess(); + $this->assertOutputContains('NumbersCallSeed: seeding'); + $this->assertOutputContains('LettersSeed: seeding'); + $this->assertOutputContains('All Done'); + + /** @var \Cake\Database\Connection $connection */ + $connection = ConnectionManager::get('test'); + $query = $connection->execute('SELECT COUNT(*) FROM numbers'); + $this->assertEquals(1, $query->fetchColumn(0)); + + $query = $connection->execute('SELECT COUNT(*) FROM letters'); + $this->assertEquals(2, $query->fetchColumn(0)); + } + + public function testSeederSourceNotFound(): void + { + $this->createTables(); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The seed class "LettersSeed" does not exist'); + + $this->exec('migrations seed -c test --source NotThere --seed LettersSeed'); + } +} diff --git a/tests/TestCase/Command/StatusCommandTest.php b/tests/TestCase/Command/StatusCommandTest.php new file mode 100644 index 00000000..d9562b73 --- /dev/null +++ b/tests/TestCase/Command/StatusCommandTest.php @@ -0,0 +1,81 @@ +fetchTable('Phinxlog'); + try { + $table->deleteAll('1=1'); + } catch (DatabaseException $e) { + } + } + + public function testHelp(): void + { + $this->exec('migrations status --help'); + $this->assertExitSuccess(); + $this->assertOutputContains('command prints a list of all migrations'); + $this->assertOutputContains('migrations status -c secondary'); + } + + public function testExecuteSimple(): void + { + $this->exec('migrations status -c test'); + $this->assertExitSuccess(); + // Check for headers + $this->assertOutputContains('Status'); + $this->assertOutputContains('Migration ID'); + $this->assertOutputContains('Migration Name'); + } + + public function testExecuteSimpleJson(): void + { + $this->exec('migrations status -c test --format json'); + $this->assertExitSuccess(); + + assert(isset($this->_out)); + $output = $this->_out->messages(); + $parsed = json_decode($output[0], true); + $this->assertTrue(is_array($parsed)); + $this->assertCount(2, $parsed); + $this->assertArrayHasKey('id', $parsed[0]); + $this->assertArrayHasKey('status', $parsed[0]); + $this->assertArrayHasKey('name', $parsed[0]); + } + + public function testExecutePlugin(): void + { + $this->loadPlugins(['Migrator']); + $this->exec('migrations status -c test -p Migrator'); + $this->assertExitSuccess(); + $this->assertOutputRegExp("/\|.*?down.*\|.*?Migrator.*?\|/"); + } + + public function testExecutePluginDoesNotExist(): void + { + $this->expectException(MissingPluginException::class); + $this->exec('migrations status -c test -p LolNope'); + } + + public function testExecuteConnectionDoesNotExist(): void + { + $this->expectException(RuntimeException::class); + $this->exec('migrations status -c lolnope'); + } +} diff --git a/tests/TestCase/Command/TestClassWithSnapshotTrait.php b/tests/TestCase/Command/TestClassWithSnapshotTrait.php deleted file mode 100644 index cbc9a820..00000000 --- a/tests/TestCase/Command/TestClassWithSnapshotTrait.php +++ /dev/null @@ -1,52 +0,0 @@ -publicFetchTableName($className, $pluginName); - } - - /** - * @param string|null $pluginName - * @return array, null|string> - */ - public function getTableNames($pluginName = null) - { - return $this->publicGetTableNames($pluginName); - } -} diff --git a/tests/TestCase/Config/AbstractConfigTestCase.php b/tests/TestCase/Config/AbstractConfigTestCase.php new file mode 100644 index 00000000..91091c20 --- /dev/null +++ b/tests/TestCase/Config/AbstractConfigTestCase.php @@ -0,0 +1,111 @@ + $connectionConfig */ + $connectionConfig = ConnectionManager::getConfig('test'); + $adapter = [ + 'migration_table' => 'phinxlog', + 'adapter' => $connectionConfig['scheme'], + 'user' => $connectionConfig['username'] ?? '', + 'pass' => $connectionConfig['password'] ?? '', + 'host' => $connectionConfig['host'] ?? '', + 'name' => $connectionConfig['database'], + ]; + + return [ + 'default' => [ + 'paths' => [ + 'migrations' => '%%PHINX_CONFIG_PATH%%/testmigrations2', + 'seeds' => '%%PHINX_CONFIG_PATH%%/db/seeds', + ], + ], + 'paths' => [ + 'migrations' => $this->getMigrationPath(), + 'seeds' => $this->getSeedPath(), + ], + 'templates' => [ + 'file' => '%%PHINX_CONFIG_PATH%%/tpl/testtemplate.txt', + 'class' => '%%PHINX_CONFIG_PATH%%/tpl/testtemplate.php', + ], + 'environment' => $adapter, + ]; + } + + public function getMigrationsConfigArray(): array + { + /** @var array $connectionConfig */ + $connectionConfig = ConnectionManager::getConfig('test'); + $adapter = [ + 'migration_table' => 'phinxlog', + 'adapter' => $connectionConfig['scheme'], + 'user' => $connectionConfig['username'], + 'pass' => $connectionConfig['password'], + 'host' => $connectionConfig['host'], + 'name' => $connectionConfig['database'], + ]; + + return [ + 'paths' => [ + 'migrations' => $this->getMigrationPath(), + 'seeds' => $this->getSeedPath(), + ], + 'environment' => $adapter, + ]; + } + + /** + * Generate dummy migration paths + * + * @return string + */ + protected function getMigrationPath(): string + { + if ($this->migrationPath === null) { + $this->migrationPath = uniqid('phinx', true); + } + + return $this->migrationPath; + } + + /** + * Generate dummy seed paths + * + * @return string + */ + protected function getSeedPath(): string + { + if ($this->seedPath === null) { + $this->seedPath = uniqid('phinx', true); + } + + return $this->seedPath; + } +} diff --git a/tests/TestCase/Config/ConfigMigrationPathsTest.php b/tests/TestCase/Config/ConfigMigrationPathsTest.php new file mode 100644 index 00000000..8a1165ba --- /dev/null +++ b/tests/TestCase/Config/ConfigMigrationPathsTest.php @@ -0,0 +1,30 @@ +expectException(UnexpectedValueException::class); + + $config->getMigrationPath(); + } + + /** + * Normal behavior + */ + public function testGetMigrationPaths() + { + $config = new Config($this->getConfigArray()); + $this->assertEquals($this->getMigrationPath(), $config->getMigrationPath()); + } +} diff --git a/tests/TestCase/Config/ConfigSeedPathsTest.php b/tests/TestCase/Config/ConfigSeedPathsTest.php new file mode 100644 index 00000000..8697979b --- /dev/null +++ b/tests/TestCase/Config/ConfigSeedPathsTest.php @@ -0,0 +1,43 @@ +expectException(UnexpectedValueException::class); + + $config->getSeedPath(); + } + + /** + * Normal behavior + */ + public function testGetSeedPaths() + { + $config = new Config($this->getConfigArray()); + $this->assertEquals($this->getSeedPath(), $config->getSeedPath()); + } + + public function testGetSeedPathConvertsStringToArray() + { + $values = [ + 'paths' => [ + 'seeds' => '/test', + ], + ]; + + $config = new Config($values); + $path = $config->getSeedPath(); + $this->assertEquals('/test', $path); + } +} diff --git a/tests/TestCase/Config/ConfigSeedTemplatePathsTest.php b/tests/TestCase/Config/ConfigSeedTemplatePathsTest.php new file mode 100644 index 00000000..580c7c61 --- /dev/null +++ b/tests/TestCase/Config/ConfigSeedTemplatePathsTest.php @@ -0,0 +1,61 @@ + [ + 'seeds' => '/test', + ], + 'templates' => [ + 'seedFile' => 'seedFilePath', + ], + ]; + + $config = new Config($values); + + $actualValue = $config->getSeedTemplateFile(); + $this->assertEquals('seedFilePath', $actualValue); + } + + public function testTemplateIsSetButNoPath() + { + // Here is used another key just to keep the node 'template' not empty + $values = [ + 'paths' => [ + 'seeds' => '/test', + ], + 'templates' => [ + 'file' => 'migration_template_file', + ], + ]; + + $config = new Config($values); + + $actualValue = $config->getSeedTemplateFile(); + $this->assertNull($actualValue); + } + + public function testNoCustomSeedTemplate() + { + $values = [ + 'paths' => [ + 'seeds' => '/test', + ], + ]; + $config = new Config($values); + + $actualValue = $config->getSeedTemplateFile(); + $this->assertNull($actualValue); + + $config->getSeedPath(); + } +} diff --git a/tests/TestCase/Config/ConfigTest.php b/tests/TestCase/Config/ConfigTest.php new file mode 100644 index 00000000..ada4bd41 --- /dev/null +++ b/tests/TestCase/Config/ConfigTest.php @@ -0,0 +1,227 @@ +getConfigArray()); + $db = $config->getEnvironment(); + $this->assertArrayHasKey('adapter', $db); + } + + public function testEnvironmentHasMigrationTable() + { + $configArray = $this->getConfigArray(); + $configArray['environment']['migration_table'] = 'test_table'; + $config = new Config($configArray); + + $this->assertSame('test_table', $config->getEnvironment()['migration_table']); + } + + /** + * @covers \Phinx\Config\Config::offsetGet + * @covers \Phinx\Config\Config::offsetSet + * @covers \Phinx\Config\Config::offsetExists + * @covers \Phinx\Config\Config::offsetUnset + */ + public function testArrayAccessMethods() + { + $config = new Config([]); + $config['foo'] = 'bar'; + $this->assertEquals('bar', $config['foo']); + $this->assertArrayHasKey('foo', $config); + unset($config['foo']); + $this->assertArrayNotHasKey('foo', $config); + } + + /** + * @covers \Phinx\Config\Config::offsetGet + */ + public function testUndefinedArrayAccess() + { + $config = new Config([]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Identifier "foo" is not defined.'); + + $config['foo']; + } + + /** + * @covers \Phinx\Config\Config::getMigrationBaseClassName + */ + public function testGetMigrationBaseClassNameGetsDefaultBaseClass() + { + $config = new Config([]); + $this->assertEquals('AbstractMigration', $config->getMigrationBaseClassName()); + } + + /** + * @covers \Phinx\Config\Config::getMigrationBaseClassName + */ + public function testGetMigrationBaseClassNameGetsDefaultBaseClassWithNamespace() + { + $config = new Config([]); + $this->assertEquals('Phinx\Migration\AbstractMigration', $config->getMigrationBaseClassName(false)); + } + + /** + * @covers \Phinx\Config\Config::getMigrationBaseClassName + */ + public function testGetMigrationBaseClassNameGetsAlternativeBaseClass() + { + $config = new Config(['migration_base_class' => 'Phinx\Migration\AlternativeAbstractMigration']); + $this->assertEquals('AlternativeAbstractMigration', $config->getMigrationBaseClassName()); + } + + /** + * @covers \Phinx\Config\Config::getMigrationBaseClassName + */ + public function testGetMigrationBaseClassNameGetsAlternativeBaseClassWithNamespace() + { + $config = new Config(['migration_base_class' => 'Phinx\Migration\AlternativeAbstractMigration']); + $this->assertEquals('Phinx\Migration\AlternativeAbstractMigration', $config->getMigrationBaseClassName(false)); + } + + /** + * @covers \Phinx\Config\Config::getTemplateFile + * @covers \Phinx\Config\Config::getTemplateClass + */ + public function testGetTemplateValuesFalseOnEmpty() + { + $config = new Config([]); + $this->assertFalse($config->getTemplateFile()); + $this->assertFalse($config->getTemplateClass()); + } + + public function testGetSeedPath() + { + $config = new Config(['paths' => ['seeds' => 'db/seeds']]); + $this->assertEquals('db/seeds', $config->getSeedPath()); + + $config = new Config(['paths' => ['seeds' => ['db/seeds1', 'db/seeds2']]]); + $this->assertEquals('db/seeds1', $config->getSeedPath()); + } + + /** + * @covers \Phinx\Config\Config::getSeedPaths + */ + public function testGetSeedPathThrowsException() + { + $config = new Config([]); + + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('Seeds path missing from config file'); + + $config->getSeedPath(); + } + + /** + * Checks if base class is returned correctly when specified without + * a namespace. + * + * @covers \Phinx\Config\Config::getMigrationBaseClassName + */ + public function testGetMigrationBaseClassNameNoNamespace() + { + $config = new Config(['migration_base_class' => 'BaseMigration']); + $this->assertEquals('BaseMigration', $config->getMigrationBaseClassName()); + } + + /** + * Checks if base class is returned correctly when specified without + * a namespace. + * + * @covers \Phinx\Config\Config::getMigrationBaseClassName + */ + public function testGetMigrationBaseClassNameNoNamespaceNoDrop() + { + $config = new Config(['migration_base_class' => 'BaseMigration']); + $this->assertEquals('BaseMigration', $config->getMigrationBaseClassName(false)); + } + + /** + * @covers \Phinx\Config\Config::getVersionOrder + */ + public function testGetVersionOrder() + { + $config = new Config([]); + $config['version_order'] = Config::VERSION_ORDER_EXECUTION_TIME; + $this->assertEquals(Config::VERSION_ORDER_EXECUTION_TIME, $config->getVersionOrder()); + } + + /** + * @covers \Phinx\Config\Config::isVersionOrderCreationTime + * @dataProvider isVersionOrderCreationTimeDataProvider + */ + public function testIsVersionOrderCreationTime($versionOrder, $expected) + { + // get config stub + $configStub = $this->getMockBuilder(Config::class) + ->onlyMethods(['getVersionOrder']) + ->setConstructorArgs([[]]) + ->getMock(); + + $configStub->expects($this->once()) + ->method('getVersionOrder') + ->will($this->returnValue($versionOrder)); + + $this->assertEquals($expected, $configStub->isVersionOrderCreationTime()); + } + + /** + * @covers \Phinx\Config\Config::isVersionOrderCreationTime + */ + public static function isVersionOrderCreationTimeDataProvider() + { + return [ + 'With Creation Time Version Order' => + [ + Config::VERSION_ORDER_CREATION_TIME, true, + ], + 'With Execution Time Version Order' => + [ + Config::VERSION_ORDER_EXECUTION_TIME, false, + ], + ]; + } + + public function testDefaultTemplateStyle(): void + { + $config = new Config([]); + $this->assertSame('change', $config->getTemplateStyle()); + } + + public static function templateStyleDataProvider(): array + { + return [ + ['change', 'change'], + ['up_down', 'up_down'], + ['foo', 'change'], + ]; + } + + /** + * @dataProvider templateStyleDataProvider + */ + public function testTemplateStyle(string $style, string $expected): void + { + $config = new Config(['templates' => ['style' => $style]]); + $this->assertSame($expected, $config->getTemplateStyle()); + } +} diff --git a/tests/TestCase/ConfigurationTraitTest.php b/tests/TestCase/ConfigurationTraitTest.php index a66ae1ba..39978328 100644 --- a/tests/TestCase/ConfigurationTraitTest.php +++ b/tests/TestCase/ConfigurationTraitTest.php @@ -74,6 +74,9 @@ public function testGetAdapterName() */ public function testGetConfig() { + if (!extension_loaded('pdo_mysql')) { + $this->markTestSkipped('Cannot run without pdo_mysql'); + } ConnectionManager::setConfig('default', [ 'className' => 'Cake\Database\Connection', 'driver' => 'Cake\Database\Driver\Mysql', diff --git a/tests/TestCase/Db/Adapter/AdapterFactoryTest.php b/tests/TestCase/Db/Adapter/AdapterFactoryTest.php new file mode 100644 index 00000000..6fc41e16 --- /dev/null +++ b/tests/TestCase/Db/Adapter/AdapterFactoryTest.php @@ -0,0 +1,113 @@ +factory = AdapterFactory::instance(); + } + + protected function tearDown(): void + { + unset($this->factory); + } + + public function testInstanceIsFactory() + { + $this->assertInstanceOf(AdapterFactory::class, $this->factory); + } + + public function testRegisterAdapter() + { + $mock = $this->getMockForAbstractClass(PdoAdapter::class, [['foo' => 'bar']]); + $this->factory->registerAdapter('test', function (array $options) use ($mock) { + $this->assertEquals('value', $options['key']); + + return $mock; + }); + + $this->assertEquals($mock, $this->factory->getAdapter('test', ['key' => 'value'])); + } + + 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/MysqlAdapterTest.php b/tests/TestCase/Db/Adapter/MysqlAdapterTest.php new file mode 100644 index 00000000..d239f90f --- /dev/null +++ b/tests/TestCase/Db/Adapter/MysqlAdapterTest.php @@ -0,0 +1,2418 @@ +markTestSkipped('Mysql tests disabled.'); + } + // Emulate the results of Util::parseDsn() + $this->config = [ + 'adapter' => 'mysql', + 'connection' => ConnectionManager::get('test'), + 'database' => $config['database'], + ]; + $this->adapter = new MysqlAdapter($this->config, $this->getConsoleIo()); + + // ensure the database is empty for each test + $this->adapter->dropDatabase($this->config['database']); + $this->adapter->createDatabase($this->config['database'], ['charset' => 'utf8mb4']); + + // leave the adapter in a disconnected state for each test + $this->adapter->disconnect(); + } + + protected function getConsoleIo(): ConsoleIo + { + $out = new StubConsoleOutput(); + $in = new StubConsoleInput([]); + $io = new ConsoleIo($out, $out, $in); + + $this->out = $out; + $this->io = $io; + + return $this->io; + } + + protected function tearDown(): void + { + unset($this->adapter, $this->out, $this->io); + } + + private function usingMysql8(): bool + { + $version = $this->adapter->getConnection()->getDriver()->version(); + + return version_compare($version, '8.0.0', '>='); + } + + public function testConnection() + { + $this->assertInstanceOf(Connection::class, $this->adapter->getConnection()); + } + + public function testCreatingTheSchemaTableOnConnect() + { + $this->adapter->connect(); + $this->assertTrue($this->adapter->hasTable($this->adapter->getSchemaTableName())); + $this->adapter->dropTable($this->adapter->getSchemaTableName()); + $this->assertFalse($this->adapter->hasTable($this->adapter->getSchemaTableName())); + $this->adapter->disconnect(); + $this->adapter->connect(); + $this->assertTrue($this->adapter->hasTable($this->adapter->getSchemaTableName())); + } + + public function testSchemaTableIsCreatedWithPrimaryKey() + { + $this->adapter->connect(); + new Table($this->adapter->getSchemaTableName(), [], $this->adapter); + $this->assertTrue($this->adapter->hasIndex($this->adapter->getSchemaTableName(), ['version'])); + } + + public function testQuoteTableName() + { + $this->assertEquals('`test_table`', $this->adapter->quoteTableName('test_table')); + } + + public function testQuoteColumnName() + { + $this->assertEquals('`test_column`', $this->adapter->quoteColumnName('test_column')); + } + + public function testHasTableUnderstandsSchemaNotation() + { + $this->assertTrue($this->adapter->hasTable('performance_schema.threads'), 'Failed asserting hasTable understands tables in another schema.'); + $this->assertFalse($this->adapter->hasTable('performance_schema.unknown_table')); + $this->assertFalse($this->adapter->hasTable('unknown_schema.phinxlog')); + } + + public function testHasTableRespectsDotInTableName() + { + $sql = "CREATE TABLE `discouraged.naming.convention` + (id INT(11) NOT NULL) + ENGINE = InnoDB CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"; + $this->adapter->execute($sql); + $this->assertTrue($this->adapter->hasTable('discouraged.naming.convention')); + } + + public function testCreateTable() + { + $table = new Table('ntable', [], $this->adapter); + $table->addColumn('realname', 'string') + ->addColumn('email', 'integer') + ->save(); + $this->assertTrue($this->adapter->hasTable('ntable')); + $this->assertTrue($this->adapter->hasColumn('ntable', 'id')); + $this->assertTrue($this->adapter->hasColumn('ntable', 'realname')); + $this->assertTrue($this->adapter->hasColumn('ntable', 'email')); + $this->assertFalse($this->adapter->hasColumn('ntable', 'address')); + + $columns = $this->adapter->getColumns('ntable'); + $this->assertCount(3, $columns); + $this->assertSame('id', $columns[0]->getName()); + $this->assertFalse($columns[0]->isSigned()); + } + + public function testCreateTableWithComment() + { + $tableComment = 'Table comment'; + $table = new Table('ntable', ['comment' => $tableComment], $this->adapter); + $table->addColumn('realname', 'string') + ->save(); + $this->assertTrue($this->adapter->hasTable('ntable')); + $this->assertTrue($this->adapter->hasColumn('ntable', 'id')); + $this->assertTrue($this->adapter->hasColumn('ntable', 'realname')); + $this->assertFalse($this->adapter->hasColumn('ntable', 'address')); + + $rows = $this->adapter->fetchAll(sprintf( + "SELECT TABLE_COMMENT FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA='%s' AND TABLE_NAME='ntable'", + $this->config['database'] + )); + $comment = $rows[0]; + + $this->assertEquals($tableComment, $comment['TABLE_COMMENT'], 'Dont set table comment correctly'); + } + + public function testCreateTableWithForeignKeys() + { + $tag_table = new Table('ntable_tag', [], $this->adapter); + $tag_table->addColumn('realname', 'string') + ->save(); + + $table = new Table('ntable', [], $this->adapter); + $table->addColumn('realname', 'string') + ->addColumn('tag_id', 'integer', ['signed' => false]) + ->addForeignKey('tag_id', 'ntable_tag', 'id', ['delete' => 'NO_ACTION', 'update' => 'NO_ACTION']) + ->save(); + + $this->assertTrue($this->adapter->hasTable('ntable')); + $this->assertTrue($this->adapter->hasColumn('ntable', 'id')); + $this->assertTrue($this->adapter->hasColumn('ntable', 'realname')); + $this->assertFalse($this->adapter->hasColumn('ntable', 'address')); + + $rows = $this->adapter->fetchAll(sprintf( + "SELECT TABLE_NAME, COLUMN_NAME, REFERENCED_TABLE_NAME, REFERENCED_COLUMN_NAME + FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE + WHERE TABLE_SCHEMA='%s' AND REFERENCED_TABLE_NAME='ntable_tag'", + $this->config['database'] + )); + $foreignKey = $rows[0]; + + $this->assertEquals($foreignKey['TABLE_NAME'], 'ntable'); + $this->assertEquals($foreignKey['COLUMN_NAME'], 'tag_id'); + $this->assertEquals($foreignKey['REFERENCED_TABLE_NAME'], 'ntable_tag'); + $this->assertEquals($foreignKey['REFERENCED_COLUMN_NAME'], 'id'); + } + + public function testCreateTableCustomIdColumn() + { + $table = new Table('ntable', ['id' => 'custom_id'], $this->adapter); + $table->addColumn('realname', 'string') + ->addColumn('email', 'integer') + ->save(); + $this->assertTrue($this->adapter->hasTable('ntable')); + $this->assertTrue($this->adapter->hasColumn('ntable', 'custom_id')); + $this->assertTrue($this->adapter->hasColumn('ntable', 'realname')); + $this->assertTrue($this->adapter->hasColumn('ntable', 'email')); + $this->assertFalse($this->adapter->hasColumn('ntable', 'address')); + } + + public function testCreateTableWithNoPrimaryKey() + { + $options = [ + 'id' => false, + ]; + $table = new Table('atable', $options, $this->adapter); + $table->addColumn('user_id', 'integer') + ->save(); + $this->assertFalse($this->adapter->hasColumn('atable', 'id')); + } + + public function testCreateTableWithConflictingPrimaryKeys() + { + $options = [ + 'primary_key' => 'user_id', + ]; + $table = new Table('atable', $options, $this->adapter); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('You cannot enable an auto incrementing ID field and a primary key'); + $table->addColumn('user_id', 'integer')->save(); + } + + public function testCreateTableWithPrimaryKeySetToImplicitId() + { + $options = [ + 'primary_key' => 'id', + ]; + $table = new Table('ztable', $options, $this->adapter); + $table->addColumn('user_id', 'integer')->save(); + $this->assertTrue($this->adapter->hasColumn('ztable', 'id')); + $this->assertTrue($this->adapter->hasIndex('ztable', 'id')); + $this->assertTrue($this->adapter->hasColumn('ztable', 'user_id')); + } + + public function testCreateTableWithPrimaryKeyArraySetToImplicitId() + { + $options = [ + 'primary_key' => ['id'], + ]; + $table = new Table('ztable', $options, $this->adapter); + $table->addColumn('user_id', 'integer')->save(); + $this->assertTrue($this->adapter->hasColumn('ztable', 'id')); + $this->assertTrue($this->adapter->hasIndex('ztable', 'id')); + $this->assertTrue($this->adapter->hasColumn('ztable', 'user_id')); + } + + public function testCreateTableWithMultiplePrimaryKeyArraySetToImplicitId() + { + $options = [ + 'primary_key' => ['id', 'user_id'], + ]; + $table = new Table('ztable', $options, $this->adapter); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('You cannot enable an auto incrementing ID field and a primary key'); + $table->addColumn('user_id', 'integer')->save(); + } + + public function testCreateTableWithMultiplePrimaryKeys() + { + $options = [ + 'id' => false, + 'primary_key' => ['user_id', 'tag_id'], + ]; + $table = new Table('table1', $options, $this->adapter); + $table->addColumn('user_id', 'integer', ['null' => false]) + ->addColumn('tag_id', 'integer', ['null' => false]) + ->save(); + $this->assertTrue($this->adapter->hasIndex('table1', ['user_id', 'tag_id'])); + $this->assertTrue($this->adapter->hasIndex('table1', ['USER_ID', 'tag_id'])); + $this->assertFalse($this->adapter->hasIndex('table1', ['tag_id', 'user_id'])); + $this->assertFalse($this->adapter->hasIndex('table1', ['tag_id', 'user_email'])); + } + + /** + * @return void + */ + public function testCreateTableWithPrimaryKeyAsUuid() + { + $options = [ + 'id' => false, + 'primary_key' => 'id', + ]; + $table = new Table('ztable', $options, $this->adapter); + $table->addColumn('id', 'uuid', ['null' => false])->save(); + $table->addColumn('user_id', 'integer')->save(); + $this->assertTrue($this->adapter->hasColumn('ztable', 'id')); + $this->assertTrue($this->adapter->hasIndex('ztable', 'id')); + $this->assertTrue($this->adapter->hasColumn('ztable', 'user_id')); + } + + /** + * @return void + */ + public function testCreateTableWithPrimaryKeyAsBinaryUuid() + { + $options = [ + 'id' => false, + 'primary_key' => 'id', + ]; + $table = new Table('ztable', $options, $this->adapter); + $table->addColumn('id', 'binaryuuid', ['null' => false])->save(); + $table->addColumn('user_id', 'integer')->save(); + $this->assertTrue($this->adapter->hasColumn('ztable', 'id')); + $this->assertTrue($this->adapter->hasIndex('ztable', 'id')); + $this->assertTrue($this->adapter->hasColumn('ztable', 'user_id')); + } + + public function testCreateTableWithMultipleIndexes() + { + $table = new Table('table1', [], $this->adapter); + $table->addColumn('email', 'string') + ->addColumn('name', 'string') + ->addIndex('email') + ->addIndex('name') + ->save(); + $this->assertTrue($this->adapter->hasIndex('table1', ['email'])); + $this->assertTrue($this->adapter->hasIndex('table1', ['name'])); + $this->assertFalse($this->adapter->hasIndex('table1', ['email', 'user_email'])); + $this->assertFalse($this->adapter->hasIndex('table1', ['email', 'user_name'])); + } + + public function testCreateTableWithUniqueIndexes() + { + $table = new Table('table1', [], $this->adapter); + $table->addColumn('email', 'string', ['limit' => 191]) + ->addIndex('email', ['unique' => true]) + ->save(); + $this->assertTrue($this->adapter->hasIndex('table1', ['email'])); + $this->assertFalse($this->adapter->hasIndex('table1', ['email', 'user_email'])); + } + + public function testCreateTableWithFullTextIndex() + { + $table = new Table('table1', ['engine' => 'MyISAM'], $this->adapter); + $table->addColumn('email', 'string') + ->addIndex('email', ['type' => 'fulltext']) + ->save(); + $this->assertTrue($this->adapter->hasIndex('table1', ['email'])); + $this->assertFalse($this->adapter->hasIndex('table1', ['email', 'user_email'])); + } + + public function testCreateTableWithNamedIndex() + { + $table = new Table('table1', [], $this->adapter); + $table->addColumn('email', 'string') + ->addIndex('email', ['name' => 'myemailindex']) + ->save(); + $this->assertTrue($this->adapter->hasIndex('table1', ['email'])); + $this->assertFalse($this->adapter->hasIndex('table1', ['email', 'user_email'])); + $this->assertTrue($this->adapter->hasIndexByName('table1', 'myemailindex')); + } + + public function testCreateTableWithMyISAMEngine() + { + $table = new Table('ntable', ['engine' => 'MyISAM'], $this->adapter); + $table->addColumn('realname', 'string') + ->save(); + $this->assertTrue($this->adapter->hasTable('ntable')); + $row = $this->adapter->fetchRow(sprintf("SHOW TABLE STATUS WHERE Name = '%s'", 'ntable')); + $this->assertEquals('MyISAM', $row['Engine']); + } + + public function testCreateTableAndInheritDefaultCollation() + { + $options = $this->config + [ + 'charset' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + ]; + $adapter = new MysqlAdapter($options, $this->io); + + $table = new Table('table_with_default_collation', [], $adapter); + $table->addColumn('name', 'string') + ->save(); + $this->assertTrue($adapter->hasTable('table_with_default_collation')); + $row = $adapter->fetchRow(sprintf("SHOW TABLE STATUS WHERE Name = '%s'", 'table_with_default_collation')); + $this->assertEquals('utf8mb4_unicode_ci', $row['Collation']); + } + + public function testCreateTableWithLatin1Collate() + { + $table = new Table('latin1_table', ['collation' => 'latin1_general_ci'], $this->adapter); + $table->addColumn('name', 'string') + ->save(); + $this->assertTrue($this->adapter->hasTable('latin1_table')); + $row = $this->adapter->fetchRow(sprintf("SHOW TABLE STATUS WHERE Name = '%s'", 'latin1_table')); + $this->assertEquals('latin1_general_ci', $row['Collation']); + } + + public function testCreateTableWithSignedPK() + { + $table = new Table('ntable', ['signed' => true], $this->adapter); + $table->addColumn('realname', 'string') + ->addColumn('email', 'integer') + ->save(); + $this->assertTrue($this->adapter->hasTable('ntable')); + $this->assertTrue($this->adapter->hasColumn('ntable', 'id')); + $this->assertTrue($this->adapter->hasColumn('ntable', 'realname')); + $this->assertTrue($this->adapter->hasColumn('ntable', 'email')); + $this->assertFalse($this->adapter->hasColumn('ntable', 'address')); + $column_definitions = $this->adapter->getColumns('ntable'); + foreach ($column_definitions as $column_definition) { + if ($column_definition->getName() === 'id') { + $this->assertTrue($column_definition->getSigned()); + } + } + } + + public function testCreateTableWithUnsignedPK() + { + $table = new Table('ntable', ['signed' => false], $this->adapter); + $table->addColumn('realname', 'string') + ->addColumn('email', 'integer') + ->save(); + $this->assertTrue($this->adapter->hasTable('ntable')); + $this->assertTrue($this->adapter->hasColumn('ntable', 'id')); + $this->assertTrue($this->adapter->hasColumn('ntable', 'realname')); + $this->assertTrue($this->adapter->hasColumn('ntable', 'email')); + $this->assertFalse($this->adapter->hasColumn('ntable', 'address')); + $column_definitions = $this->adapter->getColumns('ntable'); + foreach ($column_definitions as $column_definition) { + if ($column_definition->getName() === 'id') { + $this->assertFalse($column_definition->getSigned()); + } + } + } + + public function testCreateTableWithUnsignedNamedPK() + { + $table = new Table('ntable', ['id' => 'named_id', 'signed' => false], $this->adapter); + $table->addColumn('realname', 'string') + ->addColumn('email', 'integer') + ->save(); + $this->assertTrue($this->adapter->hasTable('ntable')); + $this->assertTrue($this->adapter->hasColumn('ntable', 'named_id')); + $column_definitions = $this->adapter->getColumns('ntable'); + foreach ($column_definitions as $column_definition) { + if ($column_definition->getName() === 'named_id') { + $this->assertFalse($column_definition->getSigned()); + } + } + $this->assertTrue($this->adapter->hasColumn('ntable', 'realname')); + $this->assertTrue($this->adapter->hasColumn('ntable', 'email')); + $this->assertFalse($this->adapter->hasColumn('ntable', 'address')); + } + + /** + * @runInSeparateProcess + */ + public function testUnsignedPksFeatureFlag() + { + $this->adapter->connect(); + + Configure::write('Migrations.unsigned_primary_keys', false); + + $table = new Table('table1', [], $this->adapter); + $table->create(); + + $columns = $this->adapter->getColumns('table1'); + $this->assertCount(1, $columns); + $this->assertSame('id', $columns[0]->getName()); + $this->assertTrue($columns[0]->getSigned()); + } + + public function testCreateTableWithLimitPK() + { + $table = new Table('ntable', ['id' => 'id', 'limit' => 4], $this->adapter); + $table->save(); + $this->assertTrue($this->adapter->hasTable('ntable')); + $this->assertTrue($this->adapter->hasColumn('ntable', 'id')); + $column_definitions = $this->adapter->getColumns('ntable'); + $this->assertSame($this->usingMysql8() ? null : 4, $column_definitions[0]->getLimit()); + } + + public function testCreateTableWithSchema() + { + $table = new Table($this->config['database'] . '.ntable', [], $this->adapter); + $table->addColumn('realname', 'string') + ->addColumn('email', 'integer') + ->save(); + $this->assertTrue($this->adapter->hasTable('ntable')); + } + + public function testAddPrimarykey() + { + $table = new Table('table1', ['id' => false], $this->adapter); + $table + ->addColumn('column1', 'integer') + ->save(); + + $table + ->changePrimaryKey('column1') + ->save(); + + $this->assertTrue($this->adapter->hasPrimaryKey('table1', ['column1'])); + } + + public function testChangePrimaryKey() + { + $table = new Table('table1', ['id' => false, 'primary_key' => 'column1'], $this->adapter); + $table + ->addColumn('column1', 'integer', ['null' => false]) + ->addColumn('column2', 'integer') + ->addColumn('column3', 'integer') + ->save(); + + $table + ->changePrimaryKey(['column2', 'column3']) + ->save(); + + $this->assertFalse($this->adapter->hasPrimaryKey('table1', ['column1'])); + $this->assertTrue($this->adapter->hasPrimaryKey('table1', ['column2', 'column3'])); + } + + public function testDropPrimaryKey() + { + $table = new Table('table1', ['id' => false, 'primary_key' => 'column1'], $this->adapter); + $table + ->addColumn('column1', 'integer', ['null' => false]) + ->save(); + + $table + ->changePrimaryKey(null) + ->save(); + + $this->assertFalse($this->adapter->hasPrimaryKey('table1', ['column1'])); + } + + public function testAddComment() + { + $table = new Table('table1', [], $this->adapter); + $table->save(); + + $table + ->changeComment('comment1') + ->save(); + + $rows = $this->adapter->fetchAll( + sprintf( + "SELECT TABLE_COMMENT + FROM INFORMATION_SCHEMA.TABLES + WHERE TABLE_SCHEMA='%s' + AND TABLE_NAME='%s'", + $this->config['database'], + 'table1' + ) + ); + $this->assertEquals('comment1', $rows[0]['TABLE_COMMENT']); + } + + public function testChangeComment() + { + $table = new Table('table1', ['comment' => 'comment1'], $this->adapter); + $table->save(); + + $table + ->changeComment('comment2') + ->save(); + + $rows = $this->adapter->fetchAll( + sprintf( + "SELECT TABLE_COMMENT + FROM INFORMATION_SCHEMA.TABLES + WHERE TABLE_SCHEMA='%s' + AND TABLE_NAME='%s'", + $this->config['database'], + 'table1' + ) + ); + $this->assertEquals('comment2', $rows[0]['TABLE_COMMENT']); + } + + public function testDropComment() + { + $table = new Table('table1', ['comment' => 'comment1'], $this->adapter); + $table->save(); + + $table + ->changeComment(null) + ->save(); + + $rows = $this->adapter->fetchAll( + sprintf( + "SELECT TABLE_COMMENT + FROM INFORMATION_SCHEMA.TABLES + WHERE TABLE_SCHEMA='%s' + AND TABLE_NAME='%s'", + $this->config['database'], + 'table1' + ) + ); + $this->assertEquals('', $rows[0]['TABLE_COMMENT']); + } + + public function testRenameTable() + { + $table = new Table('table1', [], $this->adapter); + $table->save(); + $this->assertTrue($this->adapter->hasTable('table1')); + $this->assertFalse($this->adapter->hasTable('table2')); + + $table->rename('table2')->save(); + $this->assertFalse($this->adapter->hasTable('table1')); + $this->assertTrue($this->adapter->hasTable('table2')); + } + + public function testAddColumn() + { + $table = new Table('table1', [], $this->adapter); + $table->save(); + $this->assertFalse($table->hasColumn('email')); + $table->addColumn('email', 'string') + ->save(); + $this->assertTrue($table->hasColumn('email')); + $table->addColumn('realname', 'string', ['after' => 'id']) + ->save(); + $rows = $this->adapter->fetchAll('SHOW COLUMNS FROM table1'); + $this->assertEquals('realname', $rows[1]['Field']); + } + + public function testAddColumnWithDefaultValue() + { + $table = new Table('table1', [], $this->adapter); + $table->save(); + $table->addColumn('default_zero', 'string', ['default' => 'test']) + ->save(); + $rows = $this->adapter->fetchAll('SHOW COLUMNS FROM table1'); + $this->assertEquals('test', $rows[1]['Default']); + } + + public function testAddColumnWithDefaultZero() + { + $table = new Table('table1', [], $this->adapter); + $table->save(); + $table->addColumn('default_zero', 'integer', ['default' => 0]) + ->save(); + $rows = $this->adapter->fetchAll('SHOW COLUMNS FROM table1'); + $this->assertNotNull($rows[1]['Default']); + $this->assertEquals('0', $rows[1]['Default']); + } + + public function testAddColumnWithDefaultEmptyString() + { + $table = new Table('table1', [], $this->adapter); + $table->save(); + $table->addColumn('default_empty', 'string', ['default' => '']) + ->save(); + $rows = $this->adapter->fetchAll('SHOW COLUMNS FROM table1'); + $this->assertEquals('', $rows[1]['Default']); + } + + public function testAddColumnWithDefaultBoolean() + { + $table = new Table('table1', [], $this->adapter); + $table->save(); + $table->addColumn('default_true', 'boolean', ['default' => true]) + ->addColumn('default_false', 'boolean', ['default' => false]) + ->addColumn('default_null', 'boolean', ['default' => null, 'null' => true]) + ->save(); + $rows = $this->adapter->fetchAll('SHOW COLUMNS FROM table1'); + $this->assertEquals('1', $rows[1]['Default']); + $this->assertEquals('0', $rows[2]['Default']); + $this->assertNull($rows[3]['Default']); + } + + public function testAddColumnWithDefaultLiteral() + { + $table = new Table('table1', [], $this->adapter); + $table->save(); + $table->addColumn('default_ts', 'timestamp', ['default' => Literal::from('CURRENT_TIMESTAMP')]) + ->save(); + $rows = $this->adapter->fetchAll('SHOW COLUMNS FROM table1'); + // MariaDB returns current_timestamp() + $this->assertTrue($rows[1]['Default'] === 'CURRENT_TIMESTAMP' || $rows[1]['Default'] === 'current_timestamp()'); + } + + public function testAddColumnFirst() + { + $table = new Table('table1', [], $this->adapter); + $table->save(); + $table->addColumn('new_id', 'integer', ['after' => MysqlAdapter::FIRST]) + ->save(); + $rows = $this->adapter->fetchAll('SHOW COLUMNS FROM table1'); + $this->assertSame('new_id', $rows[0]['Field']); + } + + public static function integerDataProvider() + { + return [ + ['integer', [], 'int', '11', ''], + ['integer', ['signed' => false], 'int', '11', ' unsigned'], + ['integer', ['limit' => 8], 'int', '8', ''], + ['smallinteger', [], 'smallint', '6', ''], + ['smallinteger', ['signed' => false], 'smallint', '6', ' unsigned'], + ['smallinteger', ['limit' => 3], 'smallint', '3', ''], + ['biginteger', [], 'bigint', '20', ''], + ['biginteger', ['signed' => false], 'bigint', '20', ' unsigned'], + ['biginteger', ['limit' => 12], 'bigint', '12', ''], + ]; + } + + /** + * @dataProvider integerDataProvider + */ + public function testIntegerColumnTypes($phinx_type, $options, $sql_type, $width, $extra) + { + $table = new Table('table1', [], $this->adapter); + $table->save(); + $this->assertFalse($table->hasColumn('user_id')); + $table->addColumn('user_id', $phinx_type, $options) + ->save(); + $rows = $this->adapter->fetchAll('SHOW COLUMNS FROM table1'); + + $type = $sql_type; + if (!$this->usingMysql8()) { + $type .= '(' . $width . ')'; + } + $type .= $extra; + $this->assertEquals($type, $rows[1]['Type']); + } + + public function testAddDoubleColumnWithDefaultSigned() + { + $table = new Table('table1', [], $this->adapter); + $table->save(); + $this->assertFalse($table->hasColumn('user_id')); + $table->addColumn('foo', 'double') + ->save(); + $rows = $this->adapter->fetchAll('SHOW COLUMNS FROM table1'); + $this->assertEquals('double', $rows[1]['Type']); + } + + public function testAddDoubleColumnWithSignedEqualsFalse() + { + $table = new Table('table1', [], $this->adapter); + $table->save(); + $this->assertFalse($table->hasColumn('user_id')); + $table->addColumn('foo', 'double', ['signed' => false]) + ->save(); + $rows = $this->adapter->fetchAll('SHOW COLUMNS FROM table1'); + $this->assertEquals('double unsigned', $rows[1]['Type']); + } + + public function testAddBooleanColumnWithSignedEqualsFalse() + { + $table = new Table('table1', [], $this->adapter); + $table->save(); + $this->assertFalse($table->hasColumn('test_boolean')); + $table->addColumn('test_boolean', 'boolean', ['signed' => false]) + ->save(); + $rows = $this->adapter->fetchAll('SHOW COLUMNS FROM table1'); + + $type = $this->usingMysql8() ? 'tinyint' : 'tinyint(1)'; + $this->assertEquals($type . ' unsigned', $rows[1]['Type']); + } + + public function testAddStringColumnWithSignedEqualsFalse() + { + $table = new Table('table1', [], $this->adapter); + $table->save(); + $this->assertFalse($table->hasColumn('user_id')); + $table->addColumn('user_id', 'string', ['signed' => false]) + ->save(); + $rows = $this->adapter->fetchAll('SHOW COLUMNS FROM table1'); + $this->assertEquals('varchar(255)', $rows[1]['Type']); + } + + public function testAddStringColumnWithCustomCollation() + { + $table = new Table('table_custom_collation', ['collation' => 'utf8mb4_unicode_ci'], $this->adapter); + $table->save(); + $this->assertFalse($table->hasColumn('string_collation_default')); + $this->assertFalse($table->hasColumn('string_collation_custom')); + $table->addColumn('string_collation_default', 'string', [])->save(); + $table->addColumn('string_collation_custom', 'string', ['collation' => 'utf8mb4_unicode_ci'])->save(); + $rows = $this->adapter->fetchAll('SHOW FULL COLUMNS FROM table_custom_collation'); + $this->assertEquals('utf8mb4_unicode_ci', $rows[1]['Collation']); + $this->assertEquals('utf8mb4_unicode_ci', $rows[2]['Collation']); + } + + public function testRenameColumn() + { + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'string') + ->save(); + $this->assertTrue($this->adapter->hasColumn('t', 'column1')); + $this->assertFalse($this->adapter->hasColumn('t', 'column2')); + + $table->renameColumn('column1', 'column2')->save(); + $this->assertFalse($this->adapter->hasColumn('t', 'column1')); + $this->assertTrue($this->adapter->hasColumn('t', 'column2')); + } + + public function testRenameColumnPreserveComment() + { + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'string', ['comment' => 'comment1']) + ->save(); + + $this->assertTrue($this->adapter->hasColumn('t', 'column1')); + $this->assertFalse($this->adapter->hasColumn('t', 'column2')); + $columns = $this->adapter->fetchAll('SHOW FULL COLUMNS FROM t'); + $this->assertEquals('comment1', $columns[1]['Comment']); + + $table->renameColumn('column1', 'column2')->save(); + + $this->assertFalse($this->adapter->hasColumn('t', 'column1')); + $this->assertTrue($this->adapter->hasColumn('t', 'column2')); + $columns = $this->adapter->fetchAll('SHOW FULL COLUMNS FROM t'); + $this->assertEquals('comment1', $columns[1]['Comment']); + } + + public function testRenamingANonExistentColumn() + { + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'string') + ->save(); + + try { + $table->renameColumn('column2', 'column1')->save(); + $this->fail('Expected the adapter to throw an exception'); + } catch (InvalidArgumentException $e) { + $this->assertInstanceOf( + 'InvalidArgumentException', + $e, + 'Expected exception of type InvalidArgumentException, got ' . get_class($e) + ); + $this->assertEquals('The specified column doesn\'t exist: column2', $e->getMessage()); + } + } + + public function testChangeColumn() + { + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'string') + ->save(); + $this->assertTrue($this->adapter->hasColumn('t', 'column1')); + $table->changeColumn('column1', 'string')->save(); + $this->assertTrue($this->adapter->hasColumn('t', 'column1')); + + $newColumn2 = new Column(); + $newColumn2->setName('column2') + ->setType('string'); + $table->changeColumn('column1', $newColumn2)->save(); + $this->assertFalse($this->adapter->hasColumn('t', 'column1')); + $this->assertTrue($this->adapter->hasColumn('t', 'column2')); + } + + public function testChangeColumnDefaultValue() + { + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'string', ['default' => 'test']) + ->save(); + $newColumn1 = new Column(); + $newColumn1->setDefault('test1') + ->setName('column1') + ->setType('string'); + $table->changeColumn('column1', $newColumn1)->save(); + $rows = $this->adapter->fetchAll('SHOW COLUMNS FROM t'); + $this->assertNotNull($rows[1]['Default']); + $this->assertEquals('test1', $rows[1]['Default']); + } + + public function testChangeColumnDefaultToZero() + { + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'integer') + ->save(); + $newColumn1 = new Column(); + $newColumn1->setDefault(0) + ->setName('column1') + ->setType('integer'); + $table->changeColumn('column1', $newColumn1)->save(); + $rows = $this->adapter->fetchAll('SHOW COLUMNS FROM t'); + $this->assertNotNull($rows[1]['Default']); + $this->assertEquals('0', $rows[1]['Default']); + } + + public function testChangeColumnDefaultToNull() + { + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'string', ['default' => 'test']) + ->save(); + $newColumn1 = new Column(); + $newColumn1->setDefault(null) + ->setName('column1') + ->setType('string'); + $table->changeColumn('column1', $newColumn1)->save(); + $rows = $this->adapter->fetchAll('SHOW COLUMNS FROM t'); + $this->assertNull($rows[1]['Default']); + } + + public static function sqlTypeIntConversionProvider() + { + return [ + // tinyint + [AdapterInterface::PHINX_TYPE_TINY_INTEGER, null, 'tinyint', 4], + [AdapterInterface::PHINX_TYPE_TINY_INTEGER, 2, 'tinyint', 2], + [AdapterInterface::PHINX_TYPE_TINY_INTEGER, MysqlAdapter::INT_TINY, 'tinyint', 4], + // smallint + [AdapterInterface::PHINX_TYPE_SMALL_INTEGER, null, 'smallint', 6], + [AdapterInterface::PHINX_TYPE_SMALL_INTEGER, 3, 'smallint', 3], + [AdapterInterface::PHINX_TYPE_SMALL_INTEGER, MysqlAdapter::INT_SMALL, 'smallint', 6], + // medium + [AdapterInterface::PHINX_TYPE_MEDIUM_INTEGER, null, 'mediumint', 8], + [AdapterInterface::PHINX_TYPE_MEDIUM_INTEGER, 2, 'mediumint', 2], + [AdapterInterface::PHINX_TYPE_MEDIUM_INTEGER, MysqlAdapter::INT_MEDIUM, 'mediumint', 8], + // integer + [AdapterInterface::PHINX_TYPE_INTEGER, null, 'int', 11], + [AdapterInterface::PHINX_TYPE_INTEGER, 4, 'int', 4], + [AdapterInterface::PHINX_TYPE_INTEGER, MysqlAdapter::INT_TINY, 'tinyint', 4], + [AdapterInterface::PHINX_TYPE_INTEGER, MysqlAdapter::INT_SMALL, 'smallint', 6], + [AdapterInterface::PHINX_TYPE_INTEGER, MysqlAdapter::INT_MEDIUM, 'mediumint', 8], + [AdapterInterface::PHINX_TYPE_INTEGER, MysqlAdapter::INT_REGULAR, 'int', 11], + [AdapterInterface::PHINX_TYPE_INTEGER, MysqlAdapter::INT_BIG, 'bigint', 20], + // bigint + [AdapterInterface::PHINX_TYPE_BIG_INTEGER, null, 'bigint', 20], + [AdapterInterface::PHINX_TYPE_BIG_INTEGER, 4, 'bigint', 4], + [AdapterInterface::PHINX_TYPE_BIG_INTEGER, MysqlAdapter::INT_BIG, 'bigint', 20], + ]; + } + + /** + * @dataProvider sqlTypeIntConversionProvider + * The second argument is not typed as MysqlAdapter::INT_BIG is a float, and all other values are integers + */ + public function testGetSqlTypeIntegerConversion(string $type, $limit, string $expectedType, int $expectedLimit) + { + $sqlType = $this->adapter->getSqlType($type, $limit); + $this->assertSame($expectedType, $sqlType['name']); + $this->assertSame($expectedLimit, $sqlType['limit']); + } + + public function testLongTextColumn() + { + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'text', ['limit' => MysqlAdapter::TEXT_LONG]) + ->save(); + $columns = $table->getColumns(); + $sqlType = $this->adapter->getSqlType($columns[1]->getType(), $columns[1]->getLimit()); + $this->assertEquals('longtext', $sqlType['name']); + } + + public function testMediumTextColumn() + { + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'text', ['limit' => MysqlAdapter::TEXT_MEDIUM]) + ->save(); + $columns = $table->getColumns(); + $sqlType = $this->adapter->getSqlType($columns[1]->getType(), $columns[1]->getLimit()); + $this->assertEquals('mediumtext', $sqlType['name']); + } + + public function testTinyTextColumn() + { + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'text', ['limit' => MysqlAdapter::TEXT_TINY]) + ->save(); + $columns = $table->getColumns(); + $sqlType = $this->adapter->getSqlType($columns[1]->getType(), $columns[1]->getLimit()); + $this->assertEquals('tinytext', $sqlType['name']); + } + + public static function binaryToBlobAutomaticConversionData() + { + return [ + [null, 'binary', 255], + [64, 'binary', 64], + [MysqlAdapter::BLOB_REGULAR - 20, 'blob', MysqlAdapter::BLOB_REGULAR], + [MysqlAdapter::BLOB_REGULAR, 'blob', MysqlAdapter::BLOB_REGULAR], + [MysqlAdapter::BLOB_REGULAR + 20, 'mediumblob', MysqlAdapter::BLOB_MEDIUM], + [MysqlAdapter::BLOB_MEDIUM, 'mediumblob', MysqlAdapter::BLOB_MEDIUM], + [MysqlAdapter::BLOB_MEDIUM + 20, 'longblob', MysqlAdapter::BLOB_LONG], + [MysqlAdapter::BLOB_LONG, 'longblob', MysqlAdapter::BLOB_LONG], + [MysqlAdapter::BLOB_LONG + 20, 'longblob', MysqlAdapter::BLOB_LONG], + ]; + } + + /** @dataProvider binaryToBlobAutomaticConversionData */ + public function testBinaryToBlobAutomaticConversion(?int $limit, string $expectedType, int $expectedLimit) + { + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'binary', ['limit' => $limit]) + ->save(); + $columns = $table->getColumns(); + $sqlType = $this->adapter->getSqlType($columns[1]->getType(), $columns[1]->getLimit()); + $this->assertSame($expectedType, $sqlType['name']); + $this->assertSame($expectedLimit, $columns[1]->getLimit()); + } + + public static function varbinaryToBlobAutomaticConversionData() + { + return [ + [null, 'varbinary', 255], + [64, 'varbinary', 64], + [MysqlAdapter::BLOB_REGULAR - 20, 'blob', MysqlAdapter::BLOB_REGULAR], + [MysqlAdapter::BLOB_REGULAR, 'blob', MysqlAdapter::BLOB_REGULAR], + [MysqlAdapter::BLOB_REGULAR + 20, 'mediumblob', MysqlAdapter::BLOB_MEDIUM], + [MysqlAdapter::BLOB_MEDIUM, 'mediumblob', MysqlAdapter::BLOB_MEDIUM], + [MysqlAdapter::BLOB_MEDIUM + 20, 'longblob', MysqlAdapter::BLOB_LONG], + [MysqlAdapter::BLOB_LONG, 'longblob', MysqlAdapter::BLOB_LONG], + [MysqlAdapter::BLOB_LONG + 20, 'longblob', MysqlAdapter::BLOB_LONG], + ]; + } + + /** @dataProvider varbinaryToBlobAutomaticConversionData */ + public function testVarbinaryToBlobAutomaticConversion(?int $limit, string $expectedType, int $expectedLimit) + { + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'varbinary', ['limit' => $limit]) + ->save(); + $columns = $table->getColumns(); + $sqlType = $this->adapter->getSqlType($columns[1]->getType(), $columns[1]->getLimit()); + $this->assertSame($expectedType, $sqlType['name']); + $this->assertSame($expectedLimit, $columns[1]->getLimit()); + } + + public static function blobColumnsData() + { + return [ + // Tiny blobs + ['tinyblob', 'tinyblob', null, MysqlAdapter::BLOB_TINY], + ['tinyblob', 'tinyblob', MysqlAdapter::BLOB_TINY, MysqlAdapter::BLOB_TINY], + ['tinyblob', 'blob', MysqlAdapter::BLOB_TINY + 20, MysqlAdapter::BLOB_REGULAR], + ['tinyblob', 'mediumblob', MysqlAdapter::BLOB_MEDIUM, MysqlAdapter::BLOB_MEDIUM], + ['tinyblob', 'longblob', MysqlAdapter::BLOB_LONG, MysqlAdapter::BLOB_LONG], + // Regular blobs + ['blob', 'tinyblob', MysqlAdapter::BLOB_TINY, MysqlAdapter::BLOB_TINY], + ['blob', 'blob', null, MysqlAdapter::BLOB_REGULAR], + ['blob', 'blob', MysqlAdapter::BLOB_REGULAR, MysqlAdapter::BLOB_REGULAR], + ['blob', 'mediumblob', MysqlAdapter::BLOB_MEDIUM, MysqlAdapter::BLOB_MEDIUM], + ['blob', 'longblob', MysqlAdapter::BLOB_LONG, MysqlAdapter::BLOB_LONG], + // medium blobs + ['mediumblob', 'tinyblob', MysqlAdapter::BLOB_TINY, MysqlAdapter::BLOB_TINY], + ['mediumblob', 'blob', MysqlAdapter::BLOB_REGULAR, MysqlAdapter::BLOB_REGULAR], + ['mediumblob', 'mediumblob', null, MysqlAdapter::BLOB_MEDIUM], + ['mediumblob', 'mediumblob', MysqlAdapter::BLOB_MEDIUM, MysqlAdapter::BLOB_MEDIUM], + ['mediumblob', 'longblob', MysqlAdapter::BLOB_LONG, MysqlAdapter::BLOB_LONG], + // long blobs + ['longblob', 'tinyblob', MysqlAdapter::BLOB_TINY, MysqlAdapter::BLOB_TINY], + ['longblob', 'blob', MysqlAdapter::BLOB_REGULAR, MysqlAdapter::BLOB_REGULAR], + ['longblob', 'mediumblob', MysqlAdapter::BLOB_MEDIUM, MysqlAdapter::BLOB_MEDIUM], + ['longblob', 'longblob', null, MysqlAdapter::BLOB_LONG], + ['longblob', 'longblob', MysqlAdapter::BLOB_LONG, MysqlAdapter::BLOB_LONG], + ]; + } + + /** @dataProvider blobColumnsData */ + public function testblobColumns(string $type, string $expectedType, ?int $limit, int $expectedLimit) + { + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', $type, ['limit' => $limit]) + ->save(); + $columns = $table->getColumns(); + $sqlType = $this->adapter->getSqlType($columns[1]->getType(), $columns[1]->getLimit()); + $this->assertSame($expectedType, $sqlType['name']); + $this->assertSame($expectedLimit, $columns[1]->getLimit()); + } + + public function testBigIntegerColumn() + { + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'integer', ['limit' => MysqlAdapter::INT_BIG]) + ->save(); + $columns = $table->getColumns(); + $sqlType = $this->adapter->getSqlType($columns[1]->getType(), $columns[1]->getLimit()); + $this->assertEquals('bigint', $sqlType['name']); + } + + public function testMediumIntegerColumn() + { + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'integer', ['limit' => MysqlAdapter::INT_MEDIUM]) + ->save(); + $columns = $table->getColumns(); + $sqlType = $this->adapter->getSqlType($columns[1]->getType(), $columns[1]->getLimit()); + $this->assertEquals('mediumint', $sqlType['name']); + } + + public function testSmallIntegerColumn() + { + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'integer', ['limit' => MysqlAdapter::INT_SMALL]) + ->save(); + $columns = $table->getColumns(); + $sqlType = $this->adapter->getSqlType($columns[1]->getType(), $columns[1]->getLimit()); + $this->assertEquals('smallint', $sqlType['name']); + } + + public function testTinyIntegerColumn() + { + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'integer', ['limit' => MysqlAdapter::INT_TINY]) + ->save(); + $columns = $table->getColumns(); + $sqlType = $this->adapter->getSqlType($columns[1]->getType(), $columns[1]->getLimit()); + $this->assertEquals('tinyint', $sqlType['name']); + } + + public function testIntegerColumnLimit() + { + $limit = 8; + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'integer', ['limit' => $limit]) + ->save(); + $columns = $table->getColumns(); + $sqlType = $this->adapter->getSqlType($columns[1]->getType(), $columns[1]->getLimit()); + $this->assertEquals($this->usingMysql8() ? 11 : $limit, $sqlType['limit']); + } + + public function testDatetimeColumn() + { + $this->adapter->connect(); + $version = $this->adapter->getConnection()->getDriver()->version(); + if (version_compare($version, '5.6.4') === -1) { + $this->markTestSkipped('Cannot test datetime limit on versions less than 5.6.4'); + } + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'datetime')->save(); + $columns = $table->getColumns(); + $sqlType = $this->adapter->getSqlType($columns[1]->getType(), $columns[1]->getLimit()); + $this->assertNull($sqlType['limit']); + } + + public function testDatetimeColumnLimit() + { + $this->adapter->connect(); + $version = $this->adapter->getConnection()->getDriver()->version(); + if (version_compare($version, '5.6.4') === -1) { + $this->markTestSkipped('Cannot test datetime limit on versions less than 5.6.4'); + } + $limit = 6; + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'datetime', ['limit' => $limit])->save(); + $columns = $table->getColumns(); + $sqlType = $this->adapter->getSqlType($columns[1]->getType(), $columns[1]->getLimit()); + $this->assertEquals($limit, $sqlType['limit']); + } + + public function testTimeColumnLimit() + { + $this->adapter->connect(); + $version = $this->adapter->getConnection()->getDriver()->version(); + if (version_compare($version, '5.6.4') === -1) { + $this->markTestSkipped('Cannot test datetime limit on versions less than 5.6.4'); + } + $limit = 3; + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'time', ['limit' => $limit])->save(); + $columns = $table->getColumns(); + $sqlType = $this->adapter->getSqlType($columns[1]->getType(), $columns[1]->getLimit()); + $this->assertEquals($limit, $sqlType['limit']); + } + + public function testTimestampColumnLimit() + { + $this->adapter->connect(); + $version = $this->adapter->getConnection()->getDriver()->version(); + if (version_compare($version, '5.6.4') === -1) { + $this->markTestSkipped('Cannot test datetime limit on versions less than 5.6.4'); + } + $limit = 1; + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'timestamp', ['limit' => $limit])->save(); + $columns = $table->getColumns(); + $sqlType = $this->adapter->getSqlType($columns[1]->getType(), $columns[1]->getLimit()); + $this->assertEquals($limit, $sqlType['limit']); + } + + public function testTimestampInvalidLimit() + { + $this->adapter->connect(); + $version = $this->adapter->getConnection()->getDriver()->version(); + if (version_compare($version, '5.6.4') === -1) { + $this->markTestSkipped('Cannot test datetime limit on versions less than 5.6.4'); + } + $table = new Table('t', [], $this->adapter); + + $this->expectException(PDOException::class); + + $table->addColumn('column1', 'timestamp', ['limit' => 7])->save(); + } + + public function testDropColumn() + { + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'string') + ->save(); + $this->assertTrue($this->adapter->hasColumn('t', 'column1')); + + $table->removeColumn('column1')->save(); + $this->assertFalse($this->adapter->hasColumn('t', 'column1')); + } + + public static function columnsProvider() + { + return [ + ['column1', 'string', []], + ['column2', 'smallinteger', []], + ['column3', 'integer', []], + ['column4', 'biginteger', []], + ['column5', 'text', []], + ['column6', 'float', []], + ['column7', 'decimal', []], + ['decimal_precision_scale', 'decimal', ['precision' => 10, 'scale' => 2]], + ['decimal_limit', 'decimal', ['limit' => 10]], + ['decimal_precision', 'decimal', ['precision' => 10]], + ['column8', 'datetime', []], + ['column9', 'time', []], + ['column10', 'timestamp', []], + ['column11', 'date', []], + ['column12', 'binary', []], + ['column13', 'boolean', ['comment' => 'Lorem ipsum']], + ['column14', 'string', ['limit' => 10]], + ['column16', 'geometry', []], + ['column17', 'point', []], + ['column18', 'linestring', []], + ['column19', 'polygon', []], + ['column20', 'uuid', []], + ['column21', 'set', ['values' => ['one', 'two']]], + ['column22', 'enum', ['values' => ['three', 'four']]], + ['enum_quotes', 'enum', ['values' => [ + "'", '\'\n', '\\', ',', '', "\\\n", '\\n', "\n", "\r", "\r\n", '/', ',,', "\t", + ]]], + ['column23', 'bit', []], + ]; + } + + /** + * @dataProvider columnsProvider + */ + public function testGetColumns($colName, $type, $options) + { + $table = new Table('t', [], $this->adapter); + $table->addColumn($colName, $type, $options)->save(); + + $columns = $this->adapter->getColumns('t'); + $this->assertCount(2, $columns); + $this->assertEquals($colName, $columns[1]->getName()); + $this->assertEquals($type, $columns[1]->getType()); + + if (isset($options['limit'])) { + $this->assertEquals($options['limit'], $columns[1]->getLimit()); + } + + if (isset($options['values'])) { + $this->assertEquals($options['values'], $columns[1]->getValues()); + } + + if (isset($options['precision'])) { + $this->assertEquals($options['precision'], $columns[1]->getPrecision()); + } + + if (isset($options['scale'])) { + $this->assertEquals($options['scale'], $columns[1]->getScale()); + } + + if (isset($options['comment'])) { + $this->assertEquals($options['comment'], $columns[1]->getComment()); + } + } + + public function testGetColumnsInteger() + { + $colName = 'column15'; + $type = 'integer'; + $options = ['limit' => 10]; + $table = new Table('t', [], $this->adapter); + $table->addColumn($colName, $type, $options)->save(); + + $columns = $this->adapter->getColumns('t'); + $this->assertCount(2, $columns); + $this->assertEquals($colName, $columns[1]->getName()); + $this->assertEquals($type, $columns[1]->getType()); + + $this->assertEquals($this->usingMysql8() ? null : 10, $columns[1]->getLimit()); + } + + public function testDescribeTable() + { + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'string'); + $table->save(); + + $described = $this->adapter->describeTable('t'); + + $this->assertContains($described['TABLE_TYPE'], ['VIEW', 'BASE TABLE']); + $this->assertEquals($described['TABLE_NAME'], 't'); + $this->assertEquals($described['TABLE_SCHEMA'], $this->config['database']); + $this->assertEquals($described['TABLE_ROWS'], 0); + } + + public function testGetColumnsReservedTableName() + { + $table = new Table('group', [], $this->adapter); + $table->addColumn('column1', 'string')->save(); + $columns = $this->adapter->getColumns('group'); + $this->assertCount(2, $columns); + } + + public function testAddIndex() + { + $table = new Table('table1', [], $this->adapter); + $table->addColumn('email', 'string') + ->save(); + $this->assertFalse($table->hasIndex('email')); + $table->addIndex('email') + ->save(); + $this->assertTrue($table->hasIndex('email')); + } + + public function testAddIndexWithSort() + { + $this->adapter->connect(); + if (!$this->usingMysql8()) { + $this->markTestSkipped('Cannot test index order on mysql versions less than 8'); + } + $table = new Table('table1', [], $this->adapter); + $table->addColumn('email', 'string') + ->addColumn('username', 'string') + ->save(); + $this->assertFalse($table->hasIndexByName('table1_email_username')); + $table->addIndex(['email', 'username'], ['name' => 'table1_email_username', 'order' => ['email' => 'DESC', 'username' => 'ASC']]) + ->save(); + $this->assertTrue($table->hasIndexByName('table1_email_username')); + $rows = $this->adapter->fetchAll("SHOW INDEXES FROM table1 WHERE Key_name = 'table1_email_username' AND Column_name = 'email'"); + $emailOrder = $rows[0]['Collation']; + $this->assertEquals($emailOrder, 'D'); + + $rows = $this->adapter->fetchAll("SHOW INDEXES FROM table1 WHERE Key_name = 'table1_email_username' AND Column_name = 'username'"); + $emailOrder = $rows[0]['Collation']; + $this->assertEquals($emailOrder, 'A'); + } + + public function testAddMultipleFulltextIndex() + { + $table = new Table('table1', [], $this->adapter); + $table->addColumn('email', 'string') + ->addColumn('username', 'string') + ->addColumn('bio', 'text') + ->save(); + $this->assertFalse($table->hasIndex('email')); + $this->assertFalse($table->hasIndex('username')); + $this->assertFalse($table->hasIndex('address')); + $table->addIndex('email') + ->addIndex('username', ['type' => 'fulltext']) + ->addIndex('bio', ['type' => 'fulltext']) + ->addIndex(['email', 'bio'], ['type' => 'fulltext']) + ->save(); + $this->assertTrue($table->hasIndex('email')); + $this->assertTrue($table->hasIndex('username')); + $this->assertTrue($table->hasIndex('bio')); + $this->assertTrue($table->hasIndex(['email', 'bio'])); + } + + public function testAddIndexWithLimit() + { + $table = new Table('table1', [], $this->adapter); + $table->addColumn('email', 'string') + ->save(); + $this->assertFalse($table->hasIndex('email')); + $table->addIndex('email', ['limit' => 50]) + ->save(); + $this->assertTrue($table->hasIndex('email')); + $index_data = $this->adapter->query(sprintf( + 'SELECT SUB_PART FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = "%s" AND TABLE_NAME = "table1" AND INDEX_NAME = "email"', + $this->config['database'] + ))->fetch(PDO::FETCH_ASSOC); + $expected_limit = $index_data['SUB_PART']; + $this->assertEquals($expected_limit, 50); + } + + public function testAddMultiIndexesWithLimitSpecifier() + { + $table = new Table('table1', [], $this->adapter); + $table->addColumn('email', 'string') + ->addColumn('username', 'string') + ->save(); + $this->assertFalse($table->hasIndex(['email', 'username'])); + $table->addIndex(['email', 'username'], ['limit' => [ 'email' => 3, 'username' => 2 ]]) + ->save(); + $this->assertTrue($table->hasIndex(['email', 'username'])); + $index_data = $this->adapter->query(sprintf( + 'SELECT SUB_PART FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = "%s" AND TABLE_NAME = "table1" AND INDEX_NAME = "email" AND COLUMN_NAME = "email"', + $this->config['database'] + ))->fetch(PDO::FETCH_ASSOC); + $expected_limit = $index_data['SUB_PART']; + $this->assertEquals($expected_limit, 3); + $index_data = $this->adapter->query(sprintf( + 'SELECT SUB_PART FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = "%s" AND TABLE_NAME = "table1" AND INDEX_NAME = "email" AND COLUMN_NAME = "username"', + $this->config['database'] + ))->fetch(PDO::FETCH_ASSOC); + $expected_limit = $index_data['SUB_PART']; + $this->assertEquals($expected_limit, 2); + } + + public function testAddSingleIndexesWithLimitSpecifier() + { + $table = new Table('table1', [], $this->adapter); + $table->addColumn('email', 'string') + ->addColumn('username', 'string') + ->save(); + $this->assertFalse($table->hasIndex('email')); + $table->addIndex('email', ['limit' => [ 'email' => 3, 2 ]]) + ->save(); + $this->assertTrue($table->hasIndex('email')); + $index_data = $this->adapter->query(sprintf( + 'SELECT SUB_PART FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = "%s" AND TABLE_NAME = "table1" AND INDEX_NAME = "email" AND COLUMN_NAME = "email"', + $this->config['database'] + ))->fetch(PDO::FETCH_ASSOC); + $expected_limit = $index_data['SUB_PART']; + $this->assertEquals($expected_limit, 3); + } + + public function testDropIndex() + { + // single column index + $table = new Table('table1', [], $this->adapter); + $table->addColumn('email', 'string') + ->addIndex('email') + ->save(); + $this->assertTrue($table->hasIndex('email')); + $table->removeIndex(['email'])->save(); + $this->assertFalse($table->hasIndex('email')); + + // multiple column index + $table2 = new Table('table2', [], $this->adapter); + $table2->addColumn('fname', 'string') + ->addColumn('lname', 'string') + ->addIndex(['fname', 'lname']) + ->save(); + $this->assertTrue($table2->hasIndex(['fname', 'lname'])); + $table2->removeIndex(['fname', 'lname'])->save(); + $this->assertFalse($table2->hasIndex(['fname', 'lname'])); + + // index with name specified, but dropping it by column name + $table3 = new Table('table3', [], $this->adapter); + $table3->addColumn('email', 'string') + ->addIndex('email', ['name' => 'someindexname']) + ->save(); + $this->assertTrue($table3->hasIndex('email')); + $table3->removeIndex(['email'])->save(); + $this->assertFalse($table3->hasIndex('email')); + + // multiple column index with name specified + $table4 = new Table('table4', [], $this->adapter); + $table4->addColumn('fname', 'string') + ->addColumn('lname', 'string') + ->addIndex(['fname', 'lname'], ['name' => 'multiname']) + ->save(); + $this->assertTrue($table4->hasIndex(['fname', 'lname'])); + $table4->removeIndex(['fname', 'lname'])->save(); + $this->assertFalse($table4->hasIndex(['fname', 'lname'])); + + // don't drop multiple column index when dropping single column + $table2 = new Table('table5', [], $this->adapter); + $table2->addColumn('fname', 'string') + ->addColumn('lname', 'string') + ->addIndex(['fname', 'lname']) + ->save(); + $this->assertTrue($table2->hasIndex(['fname', 'lname'])); + + try { + $table2->removeIndex(['fname'])->save(); + } catch (InvalidArgumentException $e) { + } + $this->assertTrue($table2->hasIndex(['fname', 'lname'])); + + // don't drop multiple column index with name specified when dropping + // single column + $table4 = new Table('table6', [], $this->adapter); + $table4->addColumn('fname', 'string') + ->addColumn('lname', 'string') + ->addIndex(['fname', 'lname'], ['name' => 'multiname']) + ->save(); + $this->assertTrue($table4->hasIndex(['fname', 'lname'])); + + try { + $table4->removeIndex(['fname'])->save(); + } catch (InvalidArgumentException $e) { + } + + $this->assertTrue($table4->hasIndex(['fname', 'lname'])); + } + + public function testDropIndexByName() + { + // single column index + $table = new Table('table1', [], $this->adapter); + $table->addColumn('email', 'string') + ->addIndex('email', ['name' => 'myemailindex']) + ->save(); + $this->assertTrue($table->hasIndex('email')); + $table->removeIndexByName('myemailindex')->save(); + $this->assertFalse($table->hasIndex('email')); + + // multiple column index + $table2 = new Table('table2', [], $this->adapter); + $table2->addColumn('fname', 'string') + ->addColumn('lname', 'string') + ->addIndex(['fname', 'lname'], ['name' => 'twocolumnindex']) + ->save(); + $this->assertTrue($table2->hasIndex(['fname', 'lname'])); + $table2->removeIndexByName('twocolumnindex')->save(); + $this->assertFalse($table2->hasIndex(['fname', 'lname'])); + } + + public function testAddForeignKey() + { + $refTable = new Table('ref_table', [], $this->adapter); + $refTable->addColumn('field1', 'string')->save(); + + $table = new Table('table', [], $this->adapter); + $table + ->addColumn('ref_table_id', 'integer', ['signed' => false]) + ->addForeignKey(['ref_table_id'], 'ref_table', ['id']) + ->save(); + + $this->assertTrue($this->adapter->hasForeignKey($table->getName(), ['ref_table_id'])); + } + + public function testAddForeignKeyForTableWithSignedPK() + { + $refTable = new Table('ref_table', ['signed' => true], $this->adapter); + $refTable->addColumn('field1', 'string')->save(); + + $table = new Table('table', [], $this->adapter); + $table + ->addColumn('ref_table_id', 'integer') + ->addForeignKey(['ref_table_id'], 'ref_table', ['id']) + ->save(); + + $this->assertTrue($this->adapter->hasForeignKey($table->getName(), ['ref_table_id'])); + } + + public function testDropForeignKey() + { + $refTable = new Table('ref_table', [], $this->adapter); + $refTable->addColumn('field1', 'string')->save(); + + $table = new Table('table', [], $this->adapter); + $table + ->addColumn('ref_table_id', 'integer', ['signed' => false]) + ->addForeignKey(['ref_table_id'], 'ref_table', ['id']) + ->save(); + + $table->dropForeignKey(['ref_table_id'])->save(); + $this->assertFalse($this->adapter->hasForeignKey($table->getName(), ['ref_table_id'])); + } + + public function testDropForeignKeyWithMultipleColumns() + { + $refTable = new Table('ref_table', [], $this->adapter); + $refTable + ->addColumn('field1', 'string', ['limit' => 8]) + ->addColumn('field2', 'string', ['limit' => 8]) + ->addIndex(['id', 'field1'], ['unique' => true]) + ->addIndex(['field1', 'id'], ['unique' => true]) + ->addIndex(['id', 'field1', 'field2'], ['unique' => true]) + ->save(); + + $table = new Table('table', [], $this->adapter); + $table + ->addColumn('ref_table_id', 'integer', ['signed' => false]) + ->addColumn('ref_table_field1', 'string', ['limit' => 8]) + ->addColumn('ref_table_field2', 'string', ['limit' => 8]) + ->addForeignKey( + ['ref_table_id', 'ref_table_field1'], + 'ref_table', + ['id', 'field1'] + ) + ->addForeignKey( + ['ref_table_field1', 'ref_table_id'], + 'ref_table', + ['field1', 'id'] + ) + ->addForeignKey( + ['ref_table_id', 'ref_table_field1', 'ref_table_field2'], + 'ref_table', + ['id', 'field1', 'field2'] + ) + ->save(); + + $this->assertTrue($this->adapter->hasForeignKey($table->getName(), ['ref_table_id', 'ref_table_field1'])); + $this->adapter->dropForeignKey($table->getName(), ['ref_table_id', 'ref_table_field1']); + $this->assertFalse($this->adapter->hasForeignKey($table->getName(), ['ref_table_id', 'ref_table_field1'])); + $this->assertTrue( + $this->adapter->hasForeignKey($table->getName(), ['ref_table_id', 'ref_table_field1', 'ref_table_field2']), + 'dropForeignKey() should only affect foreign keys that comprise of exactly the given columns' + ); + $this->assertTrue( + $this->adapter->hasForeignKey($table->getName(), ['ref_table_field1', 'ref_table_id']), + 'dropForeignKey() should only affect foreign keys that comprise of columns in exactly the given order' + ); + + $this->assertTrue($this->adapter->hasForeignKey($table->getName(), ['ref_table_field1', 'ref_table_id'])); + $this->adapter->dropForeignKey($table->getName(), ['ref_table_field1', 'ref_table_id']); + $this->assertFalse($this->adapter->hasForeignKey($table->getName(), ['ref_table_field1', 'ref_table_id'])); + } + + public function testDropForeignKeyWithIdenticalMultipleColumns() + { + $refTable = new Table('ref_table', [], $this->adapter); + $refTable + ->addColumn('field1', 'string', ['limit' => 8]) + ->addIndex(['id', 'field1'], ['unique' => true]) + ->save(); + + $table = new Table('table', [], $this->adapter); + $table + ->addColumn('ref_table_id', 'integer', ['signed' => false]) + ->addColumn('ref_table_field1', 'string', ['limit' => 8]) + ->addForeignKeyWithName( + 'ref_table_fk_1', + ['ref_table_id', 'ref_table_field1'], + 'ref_table', + ['id', 'field1'], + ) + ->addForeignKeyWithName( + 'ref_table_fk_2', + ['ref_table_id', 'ref_table_field1'], + 'ref_table', + ['id', 'field1'] + ) + ->save(); + + $this->assertTrue($this->adapter->hasForeignKey($table->getName(), ['ref_table_id', 'ref_table_field1'])); + $this->assertTrue($this->adapter->hasForeignKey($table->getName(), [], 'ref_table_fk_1')); + $this->assertTrue($this->adapter->hasForeignKey($table->getName(), [], 'ref_table_fk_2')); + + $this->adapter->dropForeignKey($table->getName(), ['ref_table_id', 'ref_table_field1']); + + $this->assertFalse($this->adapter->hasForeignKey($table->getName(), ['ref_table_id', 'ref_table_field1'])); + $this->assertFalse($this->adapter->hasForeignKey($table->getName(), [], 'ref_table_fk_1')); + $this->assertFalse($this->adapter->hasForeignKey($table->getName(), [], 'ref_table_fk_2')); + } + + public static function nonExistentForeignKeyColumnsProvider(): array + { + return [ + [['ref_table_id']], + [['ref_table_field1']], + [['ref_table_field1', 'ref_table_id']], + [['non_existent_column']], + ]; + } + + /** + * @dataProvider nonExistentForeignKeyColumnsProvider + * @param array $columns + */ + public function testDropForeignKeyByNonExistentKeyColumns(array $columns) + { + $refTable = new Table('ref_table', [], $this->adapter); + $refTable + ->addColumn('field1', 'string', ['limit' => 8]) + ->addIndex(['id', 'field1']) + ->save(); + + $table = new Table('table', [], $this->adapter); + $table + ->addColumn('ref_table_id', 'integer', ['signed' => false]) + ->addColumn('ref_table_field1', 'string', ['limit' => 8]) + ->addForeignKey( + ['ref_table_id', 'ref_table_field1'], + 'ref_table', + ['id', 'field1'] + ) + ->save(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage(sprintf( + 'No foreign key on column(s) `%s` exists', + implode(', ', $columns) + )); + + $this->adapter->dropForeignKey($table->getName(), $columns); + } + + public function testDropForeignKeyCaseInsensitivity() + { + $refTable = new Table('ref_table', [], $this->adapter); + $refTable->save(); + + $table = new Table('table', [], $this->adapter); + $table + ->addColumn('ref_table_id', 'integer', ['signed' => false]) + ->addForeignKey(['ref_table_id'], 'ref_table', ['id']) + ->save(); + + $this->adapter->dropForeignKey($table->getName(), ['REF_TABLE_ID']); + $this->assertFalse($this->adapter->hasForeignKey($table->getName(), ['ref_table_id'])); + } + + public function testDropForeignKeyByName() + { + $refTable = new Table('ref_table', [], $this->adapter); + $refTable->save(); + + $table = new Table('table', [], $this->adapter); + $table + ->addColumn('ref_table_id', 'integer', ['signed' => false]) + ->addForeignKeyWithName('my_constraint', ['ref_table_id'], 'ref_table', ['id']) + ->save(); + + $this->adapter->dropForeignKey($table->getName(), [], 'my_constraint'); + $this->assertFalse($this->adapter->hasForeignKey($table->getName(), ['ref_table_id'])); + } + + public function testDropForeignKeyForTableWithSignedPK() + { + $refTable = new Table('ref_table', ['signed' => true], $this->adapter); + $refTable->addColumn('field1', 'string')->save(); + + $table = new Table('table', [], $this->adapter); + $table + ->addColumn('ref_table_id', 'integer') + ->addForeignKey(['ref_table_id'], 'ref_table', ['id']) + ->save(); + + $table->dropForeignKey(['ref_table_id'])->save(); + $this->assertFalse($this->adapter->hasForeignKey($table->getName(), ['ref_table_id'])); + } + + public function testDropForeignKeyAsString() + { + $refTable = new Table('ref_table', [], $this->adapter); + $refTable->addColumn('field1', 'string')->save(); + + $table = new Table('table', [], $this->adapter); + $table + ->addColumn('ref_table_id', 'integer', ['signed' => false]) + ->addForeignKey(['ref_table_id'], 'ref_table', ['id']) + ->save(); + + $table->dropForeignKey('ref_table_id')->save(); + $this->assertFalse($this->adapter->hasForeignKey($table->getName(), ['ref_table_id'])); + } + + /** + * @dataProvider provideForeignKeysToCheck + */ + public function testHasForeignKey($tableDef, $key, $exp) + { + $conn = $this->adapter->getConnection(); + $conn->execute('CREATE TABLE other(a int, b int, c int, key(a), key(b), key(a,b), key(a,b,c));'); + $conn->execute($tableDef); + $this->assertSame($exp, $this->adapter->hasForeignKey('t', $key)); + } + + public static function provideForeignKeysToCheck() + { + return [ + ['create table t(a int)', 'a', false], + ['create table t(a int)', [], false], + ['create table t(a int primary key)', 'a', false], + ['create table t(a int, foreign key (a) references other(a))', 'a', true], + ['create table t(a int, foreign key (a) references other(b))', 'a', true], + ['create table t(a int, foreign key (a) references other(b))', ['a'], true], + ['create table t(a int, foreign key (a) references other(b))', ['a', 'a'], false], + ['create table t(a int, foreign key(a) references other(a))', 'a', true], + ['create table t(a int, b int, foreign key(a,b) references other(a,b))', 'a', false], + ['create table t(a int, b int, foreign key(a,b) references other(a,b))', ['a', 'b'], true], + ['create table t(a int, b int, foreign key(a,b) references other(a,b))', ['b', 'a'], false], + ['create table t(a int, `B` int, foreign key(a,`B`) references other(a,b))', ['a', 'b'], true], + ['create table t(a int, b int, foreign key(a,b) references other(a,b))', ['a', 'B'], true], + ['create table t(a int, b int, c int, foreign key(a,b,c) references other(a,b,c))', ['a', 'b'], false], + ['create table t(a int, foreign key(a) references other(a))', ['a', 'b'], false], + ['create table t(a int, b int, foreign key(a) references other(a), foreign key(b) references other(b))', ['a', 'b'], false], + ['create table t(a int, b int, foreign key(a) references other(a), foreign key(b) references other(b))', ['a', 'b'], false], + ['create table t(`0` int, foreign key(`0`) references other(a))', '0', true], + ['create table t(`0` int, foreign key(`0`) references other(a))', '0e0', false], + ['create table t(`0e0` int, foreign key(`0e0`) references other(a))', '0', false], + ]; + } + + public function testHasForeignKeyAsString() + { + $refTable = new Table('ref_table', [], $this->adapter); + $refTable->addColumn('field1', 'string')->save(); + + $table = new Table('table', [], $this->adapter); + $table + ->addColumn('ref_table_id', 'integer', ['signed' => false]) + ->addForeignKey(['ref_table_id'], 'ref_table', ['id']) + ->save(); + + $this->assertTrue($this->adapter->hasForeignKey($table->getName(), 'ref_table_id')); + $this->assertFalse($this->adapter->hasForeignKey($table->getName(), 'ref_table_id2')); + } + + public function testHasNamedForeignKey() + { + $refTable = new Table('ref_table', [], $this->adapter); + $refTable->addColumn('field1', 'string')->save(); + + $table = new Table('table', [], $this->adapter); + $table + ->addColumn('ref_table_id', 'integer', ['signed' => false]) + ->addForeignKeyWithName('my_constraint', ['ref_table_id'], 'ref_table', ['id']) + ->save(); + + $this->assertTrue($this->adapter->hasForeignKey($table->getName(), ['ref_table_id'], 'my_constraint')); + $this->assertFalse($this->adapter->hasForeignKey($table->getName(), ['ref_table_id'], 'my_constraint2')); + + $this->assertTrue($this->adapter->hasForeignKey($table->getName(), [], 'my_constraint')); + $this->assertFalse($this->adapter->hasForeignKey($table->getName(), [], 'my_constraint2')); + } + + public function testHasForeignKeyWithConstraintForTableWithSignedPK() + { + $refTable = new Table('ref_table', ['signed' => true], $this->adapter); + $refTable->addColumn('field1', 'string')->save(); + + $table = new Table('table', [], $this->adapter); + $table + ->addColumn('ref_table_id', 'integer') + ->addForeignKeyWithName('my_constraint', ['ref_table_id'], 'ref_table', ['id']) + ->save(); + + $this->assertTrue($this->adapter->hasForeignKey($table->getName(), ['ref_table_id'], 'my_constraint')); + $this->assertFalse($this->adapter->hasForeignKey($table->getName(), ['ref_table_id'], 'my_constraint2')); + } + + public function testsHasForeignKeyWithSchemaDotTableName() + { + $refTable = new Table('ref_table', [], $this->adapter); + $refTable->addColumn('field1', 'string')->save(); + + $table = new Table('table', [], $this->adapter); + $table + ->addColumn('ref_table_id', 'integer', ['signed' => false]) + ->addForeignKey(['ref_table_id'], 'ref_table', ['id']) + ->save(); + + $this->assertTrue($this->adapter->hasForeignKey($this->config['database'] . '.' . $table->getName(), ['ref_table_id'])); + $this->assertFalse($this->adapter->hasForeignKey($this->config['database'] . '.' . $table->getName(), ['ref_table_id2'])); + } + + public function testHasDatabase() + { + $this->assertFalse($this->adapter->hasDatabase('fake_database_name')); + $this->assertTrue($this->adapter->hasDatabase($this->config['database'])); + } + + public function testDropDatabase() + { + $this->assertFalse($this->adapter->hasDatabase('phinx_temp_database')); + $this->adapter->createDatabase('phinx_temp_database'); + $this->assertTrue($this->adapter->hasDatabase('phinx_temp_database')); + $this->adapter->dropDatabase('phinx_temp_database'); + } + + public function testAddColumnWithComment() + { + $table = new Table('table1', [], $this->adapter); + $table->addColumn('column1', 'string', ['comment' => $comment = 'Comments from "column1"']) + ->save(); + + $rows = $this->adapter->fetchAll(sprintf( + "SELECT COLUMN_NAME, COLUMN_COMMENT + FROM information_schema.columns + WHERE TABLE_SCHEMA='%s' AND TABLE_NAME='table1' + ORDER BY ORDINAL_POSITION", + $this->config['database'] + )); + $columnWithComment = $rows[1]; + + $this->assertSame('column1', $columnWithComment['COLUMN_NAME'], "Didn't set column name correctly"); + $this->assertEquals($comment, $columnWithComment['COLUMN_COMMENT'], "Didn't set column comment correctly"); + } + + public function testAddGeoSpatialColumns() + { + $table = new Table('table1', [], $this->adapter); + $table->save(); + $this->assertFalse($table->hasColumn('geo_geom')); + $table->addColumn('geo_geom', 'geometry') + ->save(); + $rows = $this->adapter->fetchAll('SHOW COLUMNS FROM table1'); + $this->assertEquals('geometry', $rows[1]['Type']); + } + + public function testAddSetColumn() + { + $table = new Table('table1', [], $this->adapter); + $table->save(); + $this->assertFalse($table->hasColumn('set_column')); + $table->addColumn('set_column', 'set', ['values' => ['one', 'two']]) + ->save(); + $rows = $this->adapter->fetchAll('SHOW COLUMNS FROM table1'); + $this->assertEquals("set('one','two')", $rows[1]['Type']); + } + + public function testAddEnumColumn() + { + $table = new Table('table1', [], $this->adapter); + $table->save(); + $this->assertFalse($table->hasColumn('enum_column')); + $table->addColumn('enum_column', 'enum', ['values' => ['one', 'two']]) + ->save(); + $rows = $this->adapter->fetchAll('SHOW COLUMNS FROM table1'); + $this->assertEquals("enum('one','two')", $rows[1]['Type']); + } + + public function testEnumColumnValuesFilledUpFromSchema() + { + // Creating column with values + (new Table('table1', [], $this->adapter)) + ->addColumn('enum_column', 'enum', ['values' => ['one', 'two']]) + ->save(); + + // Reading them back + $table = new Table('table1', [], $this->adapter); + $columns = $table->getColumns(); + $enumColumn = end($columns); + $this->assertEquals(AdapterInterface::PHINX_TYPE_ENUM, $enumColumn->getType()); + $this->assertEquals(['one', 'two'], $enumColumn->getValues()); + } + + public function testEnumColumnWithNullValue() + { + $table = new Table('table1', [], $this->adapter); + $table->addColumn('enum_column', 'enum', ['values' => ['one', 'two', null]]); + + $this->expectException(PDOException::class); + $table->save(); + } + + public function testHasColumn() + { + $table = new Table('table1', [], $this->adapter); + $table->addColumn('column1', 'string') + ->save(); + + $this->assertFalse($table->hasColumn('column2')); + $this->assertTrue($table->hasColumn('column1')); + } + + public function testHasColumnReservedName() + { + $tableQuoted = new Table('group', [], $this->adapter); + $tableQuoted->addColumn('value', 'string') + ->save(); + + $this->assertFalse($tableQuoted->hasColumn('column2')); + $this->assertTrue($tableQuoted->hasColumn('value')); + } + + public function testBulkInsertData() + { + $data = [ + [ + 'column1' => 'value1', + 'column2' => 1, + ], + [ + 'column1' => 'value2', + 'column2' => 2, + ], + [ + 'column1' => 'value3', + 'column2' => 3, + ], + ]; + $table = new Table('table1', [], $this->adapter); + $table->addColumn('column1', 'string') + ->addColumn('column2', 'integer') + ->addColumn('column3', 'string', ['default' => 'test']) + ->insert($data) + ->save(); + + $rows = $this->adapter->fetchAll('SELECT * FROM table1'); + $this->assertEquals('value1', $rows[0]['column1']); + $this->assertEquals('value2', $rows[1]['column1']); + $this->assertEquals('value3', $rows[2]['column1']); + $this->assertEquals(1, $rows[0]['column2']); + $this->assertEquals(2, $rows[1]['column2']); + $this->assertEquals(3, $rows[2]['column2']); + $this->assertEquals('test', $rows[0]['column3']); + $this->assertEquals('test', $rows[2]['column3']); + } + + public function testInsertData() + { + $data = [ + [ + 'column1' => 'value1', + 'column2' => 1, + ], + [ + 'column1' => 'value2', + 'column2' => 2, + ], + [ + 'column1' => 'value3', + 'column2' => 3, + 'column3' => 'foo', + ], + ]; + $table = new Table('table1', [], $this->adapter); + $table->addColumn('column1', 'string') + ->addColumn('column2', 'integer') + ->addColumn('column3', 'string', ['default' => 'test']) + ->insert($data) + ->save(); + + $rows = $this->adapter->fetchAll('SELECT * FROM table1'); + $this->assertEquals('value1', $rows[0]['column1']); + $this->assertEquals('value2', $rows[1]['column1']); + $this->assertEquals('value3', $rows[2]['column1']); + $this->assertEquals(1, $rows[0]['column2']); + $this->assertEquals(2, $rows[1]['column2']); + $this->assertEquals(3, $rows[2]['column2']); + $this->assertEquals('test', $rows[0]['column3']); + $this->assertEquals('foo', $rows[2]['column3']); + } + + public function testDumpCreateTable() + { + $options = $this->adapter->getOptions(); + $options['dryrun'] = true; + $this->adapter->setOptions($options); + + $table = new Table('table1', [], $this->adapter); + + $table->addColumn('column1', 'string', ['null' => false]) + ->addColumn('column2', 'integer') + ->addColumn('column3', 'string', ['default' => 'test', 'null' => false]) + ->save(); + + $expectedOutput = <<<'OUTPUT' +CREATE TABLE `table1` (`id` INT(11) unsigned NOT NULL AUTO_INCREMENT, `column1` VARCHAR(255) NOT NULL, `column2` INT(11) NULL, `column3` VARCHAR(255) NOT NULL DEFAULT 'test', PRIMARY KEY (`id`)) ENGINE = InnoDB CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +OUTPUT; + $actualOutput = join("\n", $this->out->messages()); + $this->assertStringContainsString($expectedOutput, $actualOutput, 'Passing the --dry-run option does not dump create table query to the output'); + } + + /** + * Creates the table "table1". + * Then sets phinx to dry run mode and inserts a record. + * Asserts that phinx outputs the insert statement and doesn't insert a record. + */ + public function testDumpInsert() + { + $table = new Table('table1', [], $this->adapter); + $table->addColumn('string_col', 'string') + ->addColumn('int_col', 'integer') + ->save(); + + $this->adapter->setOptions(['dryrun' => true]); + $this->adapter->insert($table->getTable(), [ + 'string_col' => 'test data', + ]); + + $this->adapter->insert($table->getTable(), [ + 'string_col' => null, + ]); + + $this->adapter->insert($table->getTable(), [ + 'int_col' => 23, + ]); + + $expectedOutput = <<<'OUTPUT' +INSERT INTO `table1` (`string_col`) VALUES ('test data'); +INSERT INTO `table1` (`string_col`) VALUES (null); +INSERT INTO `table1` (`int_col`) VALUES (23); +OUTPUT; + $actualOutput = join("\n", $this->out->messages()); + + // Add this to be LF - CR/LF systems independent + $expectedOutput = preg_replace('~\R~u', '', $expectedOutput); + $actualOutput = preg_replace('~\R~u', '', $actualOutput); + + $this->assertStringContainsString($expectedOutput, trim($actualOutput), 'Passing the --dry-run option doesn\'t dump the insert to the output'); + + $countQuery = $this->adapter->query('SELECT COUNT(*) FROM table1'); + $this->assertTrue($countQuery->execute()); + $res = $countQuery->fetchAll('assoc'); + $this->assertEquals(0, $res[0]['COUNT(*)']); + } + + /** + * Creates the table "table1". + * Then sets phinx to dry run mode and inserts some records. + * Asserts that phinx outputs the insert statement and doesn't insert any record. + */ + public function testDumpBulkinsert() + { + $table = new Table('table1', [], $this->adapter); + $table->addColumn('string_col', 'string') + ->addColumn('int_col', 'integer') + ->save(); + + $this->adapter->setOptions(['dryrun' => true]); + $this->adapter->bulkinsert($table->getTable(), [ + [ + 'string_col' => 'test_data1', + 'int_col' => 23, + ], + [ + 'string_col' => null, + 'int_col' => 42, + ], + ]); + + $expectedOutput = <<<'OUTPUT' +INSERT INTO `table1` (`string_col`, `int_col`) VALUES ('test_data1', 23), (null, 42); +OUTPUT; + $actualOutput = join("\n", $this->out->messages()); + $this->assertStringContainsString($expectedOutput, $actualOutput, 'Passing the --dry-run option doesn\'t dump the bulkinsert to the output'); + + $countQuery = $this->adapter->query('SELECT COUNT(*) FROM table1'); + $this->assertTrue($countQuery->execute()); + $res = $countQuery->fetchAll('assoc'); + $this->assertEquals(0, $res[0]['COUNT(*)']); + } + + public function testDumpCreateTableAndThenInsert() + { + $options = $this->adapter->getOptions(); + $options['dryrun'] = true; + $this->adapter->setOptions($options); + + $table = new Table('table1', ['id' => false, 'primary_key' => ['column1']], $this->adapter); + + $table->addColumn('column1', 'string', ['null' => false]) + ->addColumn('column2', 'integer') + ->save(); + + $table = new Table('table1', [], $this->adapter); + $table->insert([ + 'column1' => 'id1', + 'column2' => 1, + ])->save(); + + $expectedOutput = <<<'OUTPUT' +CREATE TABLE `table1` (`column1` VARCHAR(255) NOT NULL, `column2` INT(11) NULL, PRIMARY KEY (`column1`)) ENGINE = InnoDB CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +INSERT INTO `table1` (`column1`, `column2`) VALUES ('id1', 1); +OUTPUT; + $actualOutput = join("\n", $this->out->messages()); + // Add this to be LF - CR/LF systems independent + $expectedOutput = preg_replace('~\R~u', '', $expectedOutput); + $actualOutput = preg_replace('~\R~u', '', $actualOutput); + $this->assertStringContainsString($expectedOutput, $actualOutput, 'Passing the --dry-run option does not dump create and then insert table queries to the output'); + } + + /** + * Tests interaction with the query builder + */ + public function testQueryBuilder() + { + $table = new Table('table1', [], $this->adapter); + $table->addColumn('string_col', 'string') + ->addColumn('int_col', 'integer') + ->save(); + + $builder = $this->adapter->getQueryBuilder(Query::TYPE_INSERT); + $stm = $builder + ->insert(['string_col', 'int_col']) + ->into('table1') + ->values(['string_col' => 'value1', 'int_col' => 1]) + ->values(['string_col' => 'value2', 'int_col' => 2]) + ->execute(); + + $this->assertEquals(2, $stm->rowCount()); + + $builder = $this->adapter->getQueryBuilder(Query::TYPE_SELECT); + $stm = $builder + ->select('*') + ->from('table1') + ->where(['int_col >=' => 2]) + ->execute(); + + $this->assertEquals(1, $stm->rowCount()); + $this->assertEquals( + ['id' => 2, 'string_col' => 'value2', 'int_col' => '2'], + $stm->fetch('assoc') + ); + + $builder = $this->adapter->getQueryBuilder(query::TYPE_DELETE); + $stm = $builder + ->delete('table1') + ->where(['int_col <' => 2]) + ->execute(); + + $this->assertEquals(1, $stm->rowCount()); + } + + public function testQueryWithParams() + { + $table = new Table('table1', [], $this->adapter); + $table->addColumn('string_col', 'string') + ->addColumn('int_col', 'integer') + ->save(); + + $this->adapter->insert($table->getTable(), [ + 'string_col' => 'test data', + 'int_col' => 10, + ]); + + $this->adapter->insert($table->getTable(), [ + 'string_col' => null, + ]); + + $this->adapter->insert($table->getTable(), [ + 'int_col' => 23, + ]); + + $countQuery = $this->adapter->query('SELECT COUNT(*) AS c FROM table1 WHERE int_col > ?', [5]); + $res = $countQuery->fetchAll('assoc'); + $this->assertEquals(2, $res[0]['c']); + + $this->adapter->execute('UPDATE table1 SET int_col = ? WHERE int_col IS NULL', [12]); + + $countQuery->execute([1]); + $res = $countQuery->fetchAll('assoc'); + $this->assertEquals(3, $res[0]['c']); + } + + public function testLiteralSupport() + { + $createQuery = <<<'INPUT' +CREATE TABLE `test` (`double_col` double NOT NULL) +INPUT; + $this->adapter->execute($createQuery); + $table = new Table('test', [], $this->adapter); + $columns = $table->getColumns(); + $this->assertCount(1, $columns); + $this->assertEquals(Literal::from('double'), array_pop($columns)->getType()); + } + + public static function geometryTypeProvider() + { + return [ + [MysqlAdapter::PHINX_TYPE_GEOMETRY, 'POINT(0 0)'], + [MysqlAdapter::PHINX_TYPE_POINT, 'POINT(0 0)'], + [MysqlAdapter::PHINX_TYPE_LINESTRING, 'LINESTRING(30 10,10 30,40 40)'], + [MysqlAdapter::PHINX_TYPE_POLYGON, 'POLYGON((30 10,40 40,20 40,10 20,30 10))'], + ]; + } + + /** + * @dataProvider geometryTypeProvider + * @param string $type + * @param string $geom + */ + public function testGeometrySridSupport($type, $geom) + { + $this->adapter->connect(); + if (!$this->usingMysql8()) { + $this->markTestSkipped('Cannot test geometry srid on mysql versions less than 8'); + } + + $table = new Table('table1', [], $this->adapter); + $table + ->addColumn('geom', $type, ['srid' => 4326]) + ->save(); + + $this->adapter->execute("INSERT INTO table1 (`geom`) VALUES (ST_GeomFromText('{$geom}', 4326))"); + $rows = $this->adapter->fetchAll('SELECT ST_AsWKT(geom) as wkt, ST_SRID(geom) as srid FROM table1'); + $this->assertCount(1, $rows); + $this->assertSame($geom, $rows[0]['wkt']); + $this->assertSame(4326, (int)$rows[0]['srid']); + } + + /** + * @dataProvider geometryTypeProvider + * @param string $type + * @param string $geom + */ + public function testGeometrySridThrowsInsertDifferentSrid($type, $geom) + { + $this->adapter->connect(); + if (!$this->usingMysql8()) { + $this->markTestSkipped('Cannot test geometry srid on mysql versions less than 8'); + } + + $table = new Table('table1', [], $this->adapter); + $table + ->addColumn('geom', $type, ['srid' => 4326]) + ->save(); + + $this->expectException(PDOException::class); + $this->expectExceptionMessage("SQLSTATE[HY000]: General error: 3643 The SRID of the geometry does not match the SRID of the column 'geom'. The SRID of the geometry is 4322, but the SRID of the column is 4326. Consider changing the SRID of the geometry or the SRID property of the column."); + $this->adapter->execute("INSERT INTO table1 (`geom`) VALUES (ST_GeomFromText('{$geom}', 4322))"); + } + + /** + * Small check to verify if specific Mysql constants are handled in AdapterInterface + * + * @see https://github.com/cakephp/migrations/issues/359 + */ + public function testMysqlBlobsConstants() + { + $reflector = new ReflectionClass(AdapterInterface::class); + + $validTypes = array_filter($reflector->getConstants(), function ($constant) { + return substr($constant, 0, strlen('PHINX_TYPE_')) === 'PHINX_TYPE_'; + }, ARRAY_FILTER_USE_KEY); + + $this->assertTrue(in_array('tinyblob', $validTypes, true)); + $this->assertTrue(in_array('blob', $validTypes, true)); + $this->assertTrue(in_array('mediumblob', $validTypes, true)); + $this->assertTrue(in_array('longblob', $validTypes, true)); + } + + public static function defaultsCastAsExpressions() + { + return [ + [MysqlAdapter::PHINX_TYPE_BLOB, 'abc'], + [MysqlAdapter::PHINX_TYPE_JSON, '{"a": true}'], + [MysqlAdapter::PHINX_TYPE_TEXT, 'abc'], + [MysqlAdapter::PHINX_TYPE_GEOMETRY, 'POINT(0 0)'], + [MysqlAdapter::PHINX_TYPE_POINT, 'POINT(0 0)'], + [MysqlAdapter::PHINX_TYPE_LINESTRING, 'LINESTRING(30 10,10 30,40 40)'], + [MysqlAdapter::PHINX_TYPE_POLYGON, 'POLYGON((30 10,40 40,20 40,10 20,30 10))'], + ]; + } + + /** + * MySQL 8 added support for specifying defaults for the BLOB, TEXT, GEOMETRY, and JSON data types, + * however requiring that they be wrapped in expressions. + * + * @dataProvider defaultsCastAsExpressions + * @param string $type + * @param string $default + */ + public function testDefaultsCastAsExpressionsForCertainTypes(string $type, string $default): void + { + $this->adapter->connect(); + + $table = new Table('table1', ['id' => false], $this->adapter); + if (!$this->usingMysql8()) { + $this->expectException(PDOException::class); + } + $table + ->addColumn('col_1', $type, ['default' => $default]) + ->create(); + + $columns = $this->adapter->getColumns('table1'); + $this->assertCount(1, $columns); + $this->assertSame('col_1', $columns[0]->getName()); + $this->assertSame($default, $columns[0]->getDefault()); + } + + public function testCreateTableWithPrecisionCurrentTimestamp() + { + $this->adapter->connect(); + (new Table('exampleCurrentTimestamp3', ['id' => false], $this->adapter)) + ->addColumn('timestamp_3', 'timestamp', [ + 'null' => false, + 'default' => 'CURRENT_TIMESTAMP(3)', + 'limit' => 3, + ]) + ->create(); + + $rows = $this->adapter->fetchAll(sprintf( + "SELECT COLUMN_DEFAULT FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA='%s' AND TABLE_NAME='exampleCurrentTimestamp3'", + $this->config['database'] + )); + $colDef = $rows[0]; + $this->assertEqualsIgnoringCase('CURRENT_TIMESTAMP(3)', $colDef['COLUMN_DEFAULT']); + } + + public static function integerDataTypesSQLProvider() + { + return [ + // Types without a width should always have a null limit + ['bigint', ['name' => AdapterInterface::PHINX_TYPE_BIG_INTEGER, 'limit' => null, 'scale' => null]], + ['int', ['name' => AdapterInterface::PHINX_TYPE_INTEGER, 'limit' => null, 'scale' => null]], + ['mediumint', ['name' => AdapterInterface::PHINX_TYPE_MEDIUM_INTEGER, 'limit' => null, 'scale' => null]], + ['smallint', ['name' => AdapterInterface::PHINX_TYPE_SMALL_INTEGER, 'limit' => null, 'scale' => null]], + ['tinyint', ['name' => AdapterInterface::PHINX_TYPE_TINY_INTEGER, 'limit' => null, 'scale' => null]], + + // Types which include a width should always have that as their limit + ['bigint(20)', ['name' => AdapterInterface::PHINX_TYPE_BIG_INTEGER, 'limit' => 20, 'scale' => null]], + ['bigint(10)', ['name' => AdapterInterface::PHINX_TYPE_BIG_INTEGER, 'limit' => 10, 'scale' => null]], + ['bigint(1) unsigned', ['name' => AdapterInterface::PHINX_TYPE_BIG_INTEGER, 'limit' => 1, 'scale' => null]], + ['int(11)', ['name' => AdapterInterface::PHINX_TYPE_INTEGER, 'limit' => 11, 'scale' => null]], + ['int(10) unsigned', ['name' => AdapterInterface::PHINX_TYPE_INTEGER, 'limit' => 10, 'scale' => null]], + ['mediumint(6)', ['name' => AdapterInterface::PHINX_TYPE_MEDIUM_INTEGER, 'limit' => 6, 'scale' => null]], + ['mediumint(8) unsigned', ['name' => AdapterInterface::PHINX_TYPE_MEDIUM_INTEGER, 'limit' => 8, 'scale' => null]], + ['smallint(2)', ['name' => AdapterInterface::PHINX_TYPE_SMALL_INTEGER, 'limit' => 2, 'scale' => null]], + ['smallint(5) unsigned', ['name' => AdapterInterface::PHINX_TYPE_SMALL_INTEGER, 'limit' => 5, 'scale' => null]], + ['tinyint(3) unsigned', ['name' => AdapterInterface::PHINX_TYPE_TINY_INTEGER, 'limit' => 3, 'scale' => null]], + ['tinyint(4)', ['name' => AdapterInterface::PHINX_TYPE_TINY_INTEGER, 'limit' => 4, 'scale' => null]], + + // Special case for commonly used boolean type + ['tinyint(1)', ['name' => AdapterInterface::PHINX_TYPE_BOOLEAN, 'limit' => null, 'scale' => null]], + ]; + } + + /** + * @dataProvider integerDataTypesSQLProvider + */ + public function testGetPhinxTypeFromSQLDefinition(string $sqlDefinition, array $expectedResponse) + { + $result = $this->adapter->getPhinxType($sqlDefinition); + + $this->assertSame($expectedResponse['name'], $result['name'], "Type mismatch - got '{$result['name']}' when expecting '{$expectedResponse['name']}'"); + $this->assertSame($expectedResponse['limit'], $result['limit'], "Field upper boundary mismatch - got '{$result['limit']}' when expecting '{$expectedResponse['limit']}'"); + } +} diff --git a/tests/TestCase/Db/Adapter/PdoAdapterTest.php b/tests/TestCase/Db/Adapter/PdoAdapterTest.php new file mode 100644 index 00000000..cb3bfdd5 --- /dev/null +++ b/tests/TestCase/Db/Adapter/PdoAdapterTest.php @@ -0,0 +1,160 @@ +adapter = $this->getMockForAbstractClass('\Migrations\Db\Adapter\PdoAdapter', [['foo' => 'bar']]); + } + + protected function tearDown(): void + { + unset($this->adapter); + } + + public function testOptions() + { + $options = $this->adapter->getOptions(); + $this->assertArrayHasKey('foo', $options); + $this->assertEquals('bar', $options['foo']); + } + + public function testOptionsSetSchemaTableName() + { + $this->assertEquals('phinxlog', $this->adapter->getSchemaTableName()); + $this->adapter->setOptions(['migration_table' => 'schema_table_test']); + $this->assertEquals('schema_table_test', $this->adapter->getSchemaTableName()); + } + + public function testSchemaTableName() + { + $this->assertEquals('phinxlog', $this->adapter->getSchemaTableName()); + $this->adapter->setSchemaTableName('schema_table_test'); + $this->assertEquals('schema_table_test', $this->adapter->getSchemaTableName()); + } + + /** + * @dataProvider getVersionLogDataProvider + */ + public function testGetVersionLog($versionOrder, $expectedOrderBy) + { + $adapter = $this->getMockForAbstractClass( + '\Migrations\Db\Adapter\PdoAdapter', + [['version_order' => $versionOrder]], + '', + true, + true, + true, + ['fetchAll', 'getSchemaTableName', 'quoteTableName'] + ); + + $schemaTableName = 'log'; + $adapter->expects($this->once()) + ->method('getSchemaTableName') + ->will($this->returnValue($schemaTableName)); + $adapter->expects($this->once()) + ->method('quoteTableName') + ->with($schemaTableName) + ->will($this->returnValue("'$schemaTableName'")); + + $mockRows = [ + [ + 'version' => '20120508120534', + 'key' => 'value', + ], + [ + 'version' => '20130508120534', + 'key' => 'value', + ], + ]; + + $adapter->expects($this->once()) + ->method('fetchAll') + ->with("SELECT * FROM '$schemaTableName' ORDER BY $expectedOrderBy") + ->will($this->returnValue($mockRows)); + + // we expect the mock rows but indexed by version creation time + $expected = [ + '20120508120534' => [ + 'version' => '20120508120534', + 'key' => 'value', + ], + '20130508120534' => [ + 'version' => '20130508120534', + 'key' => 'value', + ], + ]; + + $this->assertEquals($expected, $adapter->getVersionLog()); + } + + public static function getVersionLogDataProvider() + { + return [ + 'With Creation Time Version Order' => [ + Config::VERSION_ORDER_CREATION_TIME, 'version ASC', + ], + 'With Execution Time Version Order' => [ + Config::VERSION_ORDER_EXECUTION_TIME, 'start_time ASC, version ASC', + ], + ]; + } + + public function testGetVersionLogInvalidVersionOrderKO() + { + $this->expectExceptionMessage('Invalid version_order configuration option'); + $adapter = $this->getMockForAbstractClass( + '\Migrations\Db\Adapter\PdoAdapter', + [['version_order' => 'invalid']] + ); + + $this->expectException(RuntimeException::class); + + $adapter->getVersionLog(); + } + + public function testGetVersionLongDryRun() + { + $adapter = $this->getMockForAbstractClass( + '\Migrations\Db\Adapter\PdoAdapter', + [['version_order' => Config::VERSION_ORDER_CREATION_TIME]], + '', + true, + true, + true, + ['isDryRunEnabled', 'fetchAll', 'getSchemaTableName', 'quoteTableName'] + ); + + $schemaTableName = 'log'; + + $adapter->expects($this->once()) + ->method('isDryRunEnabled') + ->will($this->returnValue(true)); + $adapter->expects($this->once()) + ->method('getSchemaTableName') + ->will($this->returnValue($schemaTableName)); + $adapter->expects($this->once()) + ->method('quoteTableName') + ->with($schemaTableName) + ->will($this->returnValue("'$schemaTableName'")); + $adapter->expects($this->once()) + ->method('fetchAll') + ->with("SELECT * FROM '$schemaTableName' ORDER BY version ASC") + ->will($this->throwException(new PDOException())); + + $this->assertEquals([], $adapter->getVersionLog()); + } +} diff --git a/tests/TestCase/Db/Adapter/PhinxAdapterTest.php b/tests/TestCase/Db/Adapter/PhinxAdapterTest.php new file mode 100644 index 00000000..e4e66315 --- /dev/null +++ b/tests/TestCase/Db/Adapter/PhinxAdapterTest.php @@ -0,0 +1,1720 @@ + $config */ + $config = ConnectionManager::getConfig('test'); + if ($config['scheme'] !== 'sqlite') { + $this->markTestSkipped('phinx adapter tests require sqlite'); + } + // Emulate the results of Util::parseDsn() + $this->config = [ + 'adapter' => 'sqlite', + 'connection' => ConnectionManager::get('test'), + 'database' => $config['database'], + 'suffix' => '', + ]; + $this->adapter = new PhinxAdapter( + new SqliteAdapter( + $this->config, + $this->getConsoleIo() + ) + ); + + if ($config['database'] !== ':memory:') { + // ensure the database is empty for each test + $this->adapter->dropDatabase($config['database']); + $this->adapter->createDatabase($config['database']); + } + + // leave the adapter in a disconnected state for each test + $this->adapter->disconnect(); + } + + protected function tearDown(): void + { + unset($this->adapter, $this->out, $this->io); + } + + protected function getConsoleIo(): ConsoleIo + { + $out = new StubConsoleOutput(); + $in = new StubConsoleInput([]); + $io = new ConsoleIo($out, $out, $in); + + $this->out = $out; + $this->io = $io; + + return $this->io; + } + + public function testBeginTransaction() + { + $this->adapter->beginTransaction(); + + $this->assertTrue( + $this->adapter->getConnection()->inTransaction(), + 'Underlying PDO instance did not detect new transaction' + ); + $this->adapter->rollbackTransaction(); + } + + public function testRollbackTransaction() + { + $this->adapter->beginTransaction(); + $this->adapter->rollbackTransaction(); + + $this->assertFalse( + $this->adapter->getConnection()->inTransaction(), + 'Underlying PDO instance did not detect rolled back transaction' + ); + } + + public function testQuoteTableName() + { + $this->assertEquals('`test_table`', $this->adapter->quoteTableName('test_table')); + } + + public function testQuoteColumnName() + { + $this->assertEquals('`test_column`', $this->adapter->quoteColumnName('test_column')); + } + + public function testCreateTable() + { + $table = new PhinxTable('ntable', [], $this->adapter); + $table->addColumn('realname', 'string') + ->addColumn('email', 'integer') + ->save(); + $this->assertTrue($this->adapter->hasTable('ntable')); + $this->assertTrue($this->adapter->hasColumn('ntable', 'id')); + $this->assertTrue($this->adapter->hasColumn('ntable', 'realname')); + $this->assertTrue($this->adapter->hasColumn('ntable', 'email')); + $this->assertFalse($this->adapter->hasColumn('ntable', 'address')); + } + + public function testCreateTableCustomIdColumn() + { + $table = new PhinxTable('ntable', ['id' => 'custom_id'], $this->adapter); + $table->addColumn('realname', 'string') + ->addColumn('email', 'integer') + ->save(); + + $this->assertTrue($this->adapter->hasTable('ntable')); + $this->assertTrue($this->adapter->hasColumn('ntable', 'custom_id')); + $this->assertTrue($this->adapter->hasColumn('ntable', 'realname')); + $this->assertTrue($this->adapter->hasColumn('ntable', 'email')); + $this->assertFalse($this->adapter->hasColumn('ntable', 'address')); + + //ensure the primary key is not nullable + /** @var \Phinx\Db\Table\Column $idColumn */ + $idColumn = $this->adapter->getColumns('ntable')[0]; + $this->assertInstanceOf(PhinxColumn::class, $idColumn); + $this->assertTrue($idColumn->getIdentity()); + $this->assertFalse($idColumn->isNull()); + } + + public function testCreateTableIdentityIdColumn() + { + $table = new PhinxTable('ntable', ['id' => false, 'primary_key' => ['custom_id']], $this->adapter); + $table->addColumn('custom_id', 'integer', ['identity' => true]) + ->save(); + + $this->assertTrue($this->adapter->hasTable('ntable')); + $this->assertTrue($this->adapter->hasColumn('ntable', 'custom_id')); + + /** @var \Phinx\Db\Table\Column $idColumn */ + $idColumn = $this->adapter->getColumns('ntable')[0]; + $this->assertInstanceOf(PhinxColumn::class, $idColumn); + $this->assertTrue($idColumn->getIdentity()); + } + + public function testCreateTableWithNoPrimaryKey() + { + $options = [ + 'id' => false, + ]; + $table = new PhinxTable('atable', $options, $this->adapter); + $table->addColumn('user_id', 'integer') + ->save(); + $this->assertFalse($this->adapter->hasColumn('atable', 'id')); + } + + public function testCreateTableWithMultiplePrimaryKeys() + { + $options = [ + 'id' => false, + 'primary_key' => ['user_id', 'tag_id'], + ]; + $table = new PhinxTable('table1', $options, $this->adapter); + $table->addColumn('user_id', 'integer') + ->addColumn('tag_id', 'integer') + ->save(); + $this->assertTrue($this->adapter->hasIndex('table1', ['user_id', 'tag_id'])); + $this->assertTrue($this->adapter->hasIndex('table1', ['USER_ID', 'tag_id'])); + $this->assertFalse($this->adapter->hasIndex('table1', ['tag_id', 'USER_ID'])); + $this->assertFalse($this->adapter->hasIndex('table1', ['tag_id', 'user_email'])); + } + + /** + * @return void + */ + public function testCreateTableWithPrimaryKeyAsUuid() + { + $options = [ + 'id' => false, + 'primary_key' => 'id', + ]; + $table = new PhinxTable('ztable', $options, $this->adapter); + $table->addColumn('id', 'uuid')->save(); + $this->assertTrue($this->adapter->hasColumn('ztable', 'id')); + $this->assertTrue($this->adapter->hasIndex('ztable', 'id')); + } + + /** + * @return void + */ + public function testCreateTableWithPrimaryKeyAsBinaryUuid() + { + $options = [ + 'id' => false, + 'primary_key' => 'id', + ]; + $table = new PhinxTable('ztable', $options, $this->adapter); + $table->addColumn('id', 'binaryuuid')->save(); + $this->assertTrue($this->adapter->hasColumn('ztable', 'id')); + $this->assertTrue($this->adapter->hasIndex('ztable', 'id')); + } + + public function testCreateTableWithMultipleIndexes() + { + $table = new PhinxTable('table1', [], $this->adapter); + $table->addColumn('email', 'string') + ->addColumn('name', 'string') + ->addIndex('email') + ->addIndex('name') + ->save(); + $this->assertTrue($this->adapter->hasIndex('table1', ['email'])); + $this->assertTrue($this->adapter->hasIndex('table1', ['name'])); + $this->assertFalse($this->adapter->hasIndex('table1', ['email', 'user_email'])); + $this->assertFalse($this->adapter->hasIndex('table1', ['email', 'user_name'])); + } + + public function testCreateTableWithUniqueIndexes() + { + $table = new PhinxTable('table1', [], $this->adapter); + $table->addColumn('email', 'string') + ->addIndex('email', ['unique' => true]) + ->save(); + $this->assertTrue($this->adapter->hasIndex('table1', ['email'])); + $this->assertFalse($this->adapter->hasIndex('table1', ['email', 'user_email'])); + } + + public function testCreateTableWithNamedIndexes() + { + $table = new PhinxTable('table1', [], $this->adapter); + $table->addColumn('email', 'string') + ->addIndex('email', ['name' => 'myemailindex']) + ->save(); + $this->assertTrue($this->adapter->hasIndex('table1', ['email'])); + $this->assertFalse($this->adapter->hasIndex('table1', ['email', 'user_email'])); + $this->assertTrue($this->adapter->hasIndexByName('table1', 'myemailindex')); + } + + public function testCreateTableWithForeignKey() + { + $refTable = new PhinxTable('ref_table', [], $this->adapter); + $refTable->addColumn('field1', 'string')->save(); + + $table = new PhinxTable('table', [], $this->adapter); + $table->addColumn('ref_table_id', 'integer'); + $table->addForeignKey('ref_table_id', 'ref_table', 'id'); + $table->save(); + + $this->assertTrue($this->adapter->hasTable($table->getName())); + $this->assertTrue($this->adapter->hasForeignKey($table->getName(), ['ref_table_id'])); + } + + public function testCreateTableWithIndexesAndForeignKey() + { + $refTable = new PhinxTable('tbl_master', [], $this->adapter); + $refTable->create(); + + $table = new PhinxTable('tbl_child', [], $this->adapter); + $table + ->addColumn('column1', 'integer') + ->addColumn('column2', 'integer') + ->addColumn('master_id', 'integer') + ->addIndex(['column2']) + ->addIndex(['column1', 'column2'], ['unique' => true, 'name' => 'uq_tbl_child_column1_column2_ndx']) + ->addForeignKey( + 'master_id', + 'tbl_master', + 'id', + ['delete' => 'NO_ACTION', 'update' => 'NO_ACTION', 'constraint' => 'fk_master_id'] + ) + ->create(); + + $this->assertTrue($this->adapter->hasIndex('tbl_child', 'column2')); + $this->assertTrue($this->adapter->hasIndex('tbl_child', ['column1', 'column2'])); + $this->assertTrue($this->adapter->hasForeignKey('tbl_child', ['master_id'])); + + $row = $this->adapter->fetchRow( + "SELECT * FROM sqlite_master WHERE `type` = 'table' AND `tbl_name` = 'tbl_child'" + ); + $this->assertStringContainsString( + 'CONSTRAINT `fk_master_id` FOREIGN KEY (`master_id`) REFERENCES `tbl_master` (`id`) ON DELETE NO ACTION ON UPDATE NO ACTION', + $row['sql'] + ); + } + + public function testCreateTableWithoutAutoIncrementingPrimaryKeyAndWithForeignKey() + { + $refTable = (new PhinxTable('tbl_master', ['id' => false, 'primary_key' => 'id'], $this->adapter)) + ->addColumn('id', 'text'); + $refTable->create(); + + $table = (new PhinxTable('tbl_child', ['id' => false, 'primary_key' => 'master_id'], $this->adapter)) + ->addColumn('master_id', 'text') + ->addForeignKey( + 'master_id', + 'tbl_master', + 'id', + ['delete' => 'NO_ACTION', 'update' => 'NO_ACTION', 'constraint' => 'fk_master_id'] + ); + $table->create(); + + $this->assertTrue($this->adapter->hasForeignKey('tbl_child', ['master_id'])); + + $row = $this->adapter->fetchRow( + "SELECT * FROM sqlite_master WHERE `type` = 'table' AND `tbl_name` = 'tbl_child'" + ); + $this->assertStringContainsString( + 'CONSTRAINT `fk_master_id` FOREIGN KEY (`master_id`) REFERENCES `tbl_master` (`id`) ON DELETE NO ACTION ON UPDATE NO ACTION', + $row['sql'] + ); + } + + public function testAddPrimaryKey() + { + $table = new PhinxTable('table1', ['id' => false], $this->adapter); + $table + ->addColumn('column1', 'integer') + ->addColumn('column2', 'integer') + ->save(); + + $table + ->changePrimaryKey('column1') + ->save(); + + $this->assertTrue($this->adapter->hasPrimaryKey('table1', ['column1'])); + } + + public function testChangePrimaryKey() + { + $table = new PhinxTable('table1', ['id' => false, 'primary_key' => 'column1'], $this->adapter); + $table + ->addColumn('column1', 'integer') + ->addColumn('column2', 'integer') + ->save(); + + $table + ->changePrimaryKey('column2') + ->save(); + + $this->assertFalse($this->adapter->hasPrimaryKey('table1', ['column1'])); + $this->assertTrue($this->adapter->hasPrimaryKey('table1', ['column2'])); + } + + public function testChangePrimaryKeyNonInteger() + { + $table = new PhinxTable('table1', ['id' => false, 'primary_key' => 'column1'], $this->adapter); + $table + ->addColumn('column1', 'string') + ->addColumn('column2', 'string') + ->save(); + + $table + ->changePrimaryKey('column2') + ->save(); + + $this->assertFalse($this->adapter->hasPrimaryKey('table1', ['column1'])); + $this->assertTrue($this->adapter->hasPrimaryKey('table1', ['column2'])); + } + + public function testDropPrimaryKey() + { + $table = new PhinxTable('table1', ['id' => false, 'primary_key' => 'column1'], $this->adapter); + $table + ->addColumn('column1', 'integer') + ->addColumn('column2', 'integer') + ->save(); + + $table + ->changePrimaryKey(null) + ->save(); + + $this->assertFalse($this->adapter->hasPrimaryKey('table1', ['column1'])); + } + + public function testAddMultipleColumnPrimaryKeyFails() + { + $table = new PhinxTable('table1', [], $this->adapter); + $table + ->addColumn('column1', 'integer') + ->addColumn('column2', 'integer') + ->save(); + + $this->expectException(InvalidArgumentException::class); + + $table + ->changePrimaryKey(['column1', 'column2']) + ->save(); + } + + public function testChangeCommentFails() + { + $table = new PhinxTable('table1', [], $this->adapter); + $table->save(); + + $this->expectException(BadMethodCallException::class); + + $table + ->changeComment('comment1') + ->save(); + } + + public function testAddColumn() + { + $table = new PhinxTable('table1', [], $this->adapter); + $table->save(); + $this->assertFalse($table->hasColumn('email')); + $table->addColumn('email', 'string', ['null' => true]) + ->save(); + $this->assertTrue($table->hasColumn('email')); + + // In SQLite it is not possible to dictate order of added columns. + // $table->addColumn('realname', 'string', array('after' => 'id')) + // ->save(); + // $this->assertEquals('realname', $rows[1]['Field']); + } + + public function testAddColumnWithDefaultValue() + { + $table = new PhinxTable('table1', [], $this->adapter); + $table->save(); + $table->addColumn('default_zero', 'string', ['default' => 'test']) + ->save(); + $rows = $this->adapter->fetchAll(sprintf('pragma table_info(%s)', 'table1')); + $this->assertEquals("'test'", $rows[1]['dflt_value']); + } + + public function testAddColumnWithDefaultZero() + { + $table = new PhinxTable('table1', [], $this->adapter); + $table->save(); + $table->addColumn('default_zero', 'integer', ['default' => 0]) + ->save(); + $rows = $this->adapter->fetchAll(sprintf('pragma table_info(%s)', 'table1')); + $this->assertNotNull($rows[1]['dflt_value']); + $this->assertEquals('0', $rows[1]['dflt_value']); + } + + public function testAddColumnWithDefaultEmptyString() + { + $table = new PhinxTable('table1', [], $this->adapter); + $table->save(); + $table->addColumn('default_empty', 'string', ['default' => '']) + ->save(); + $rows = $this->adapter->fetchAll(sprintf('pragma table_info(%s)', 'table1')); + $this->assertEquals("''", $rows[1]['dflt_value']); + } + + public function testAddDoubleColumn() + { + $table = new PhinxTable('table1', [], $this->adapter); + $table->save(); + $table->addColumn('foo', 'double', ['null' => true]) + ->save(); + $rows = $this->adapter->fetchAll(sprintf('pragma table_info(%s)', 'table1')); + $this->assertEquals('DOUBLE', $rows[1]['type']); + } + + public function testRenameColumnWithIndex() + { + $table = new PhinxTable('t', [], $this->adapter); + $table + ->addColumn('indexcol', 'integer') + ->addIndex('indexcol') + ->create(); + + $this->assertTrue($this->adapter->hasIndex($table->getName(), 'indexcol')); + $this->assertFalse($this->adapter->hasIndex($table->getName(), 'newindexcol')); + + $table->renameColumn('indexcol', 'newindexcol')->update(); + + $this->assertFalse($this->adapter->hasIndex($table->getName(), 'indexcol')); + $this->assertTrue($this->adapter->hasIndex($table->getName(), 'newindexcol')); + } + + public function testRenameColumnWithUniqueIndex() + { + $table = new PhinxTable('t', [], $this->adapter); + $table + ->addColumn('indexcol', 'integer') + ->addIndex('indexcol', ['unique' => true]) + ->create(); + + $this->assertTrue($this->adapter->hasIndex($table->getName(), 'indexcol')); + $this->assertFalse($this->adapter->hasIndex($table->getName(), 'newindexcol')); + + $table->renameColumn('indexcol', 'newindexcol')->update(); + + $this->assertFalse($this->adapter->hasIndex($table->getName(), 'indexcol')); + $this->assertTrue($this->adapter->hasIndex($table->getName(), 'newindexcol')); + } + + public function testRenameColumnWithCompositeIndex() + { + $table = new PhinxTable('t', [], $this->adapter); + $table + ->addColumn('indexcol1', 'integer') + ->addColumn('indexcol2', 'integer') + ->addIndex(['indexcol1', 'indexcol2']) + ->create(); + + $this->assertTrue($this->adapter->hasIndex($table->getName(), ['indexcol1', 'indexcol2'])); + $this->assertFalse($this->adapter->hasIndex($table->getName(), ['indexcol1', 'newindexcol2'])); + + $table->renameColumn('indexcol2', 'newindexcol2')->update(); + + $this->assertFalse($this->adapter->hasIndex($table->getName(), ['indexcol1', 'indexcol2'])); + $this->assertTrue($this->adapter->hasIndex($table->getName(), ['indexcol1', 'newindexcol2'])); + } + + /** + * Tests that rewriting the index SQL does not accidentally change + * the table name in case it matches the column name. + */ + public function testRenameColumnWithIndexMatchingTheTableName() + { + $table = new PhinxTable('indexcol', [], $this->adapter); + $table + ->addColumn('indexcol', 'integer') + ->addIndex('indexcol') + ->create(); + + $this->assertTrue($this->adapter->hasIndex($table->getName(), 'indexcol')); + $this->assertFalse($this->adapter->hasIndex($table->getName(), 'newindexcol')); + + $table->renameColumn('indexcol', 'newindexcol')->update(); + + $this->assertFalse($this->adapter->hasIndex($table->getName(), 'indexcol')); + $this->assertTrue($this->adapter->hasIndex($table->getName(), 'newindexcol')); + } + + /** + * Tests that rewriting the index SQL does not accidentally change + * column names that partially match the column to rename. + */ + public function testRenameColumnWithIndexColumnPartialMatch() + { + $table = new PhinxTable('t', [], $this->adapter); + $table + ->addColumn('indexcol', 'integer') + ->addColumn('indexcolumn', 'integer') + ->create(); + + $this->adapter->execute('CREATE INDEX custom_idx ON t (indexcolumn, indexcol)'); + + $this->assertTrue($this->adapter->hasIndex($table->getName(), ['indexcolumn', 'indexcol'])); + $this->assertFalse($this->adapter->hasIndex($table->getName(), ['indexcolumn', 'newindexcol'])); + + $table->renameColumn('indexcol', 'newindexcol')->update(); + + $this->assertFalse($this->adapter->hasIndex($table->getName(), ['indexcolumn', 'indexcol'])); + $this->assertTrue($this->adapter->hasIndex($table->getName(), ['indexcolumn', 'newindexcol'])); + } + + public function testRenameColumnWithIndexColumnRequiringQuoting() + { + $table = new PhinxTable('t', [], $this->adapter); + $table + ->addColumn('indexcol', 'integer') + ->addIndex('indexcol') + ->create(); + + $this->assertTrue($this->adapter->hasIndex($table->getName(), 'indexcol')); + $this->assertFalse($this->adapter->hasIndex($table->getName(), 'new index col')); + + $table->renameColumn('indexcol', 'new index col')->update(); + + $this->assertFalse($this->adapter->hasIndex($table->getName(), 'indexcol')); + $this->assertTrue($this->adapter->hasIndex($table->getName(), 'new index col')); + } + + /** + * Indices that are using expressions are not being updated. + */ + public function testRenameColumnWithExpressionIndex() + { + $table = new PhinxTable('t', [], $this->adapter); + $table + ->addColumn('indexcol', 'integer') + ->create(); + + $this->adapter->execute('CREATE INDEX custom_idx ON t (`indexcol`, ABS(`indexcol`))'); + + $this->assertTrue($this->adapter->hasIndexByName('t', 'custom_idx')); + + $this->expectException(PDOException::class); + $this->expectExceptionMessage('no such column: indexcol'); + + $table->renameColumn('indexcol', 'newindexcol')->update(); + } + + public function testChangeColumn() + { + $table = new PhinxTable('t', [], $this->adapter); + $table->addColumn('column1', 'string') + ->save(); + $this->assertTrue($this->adapter->hasColumn('t', 'column1')); + $newColumn1 = new PhinxColumn(); + $newColumn1->setName('column1'); + $newColumn1->setType('string'); + $table->changeColumn('column1', $newColumn1); + $this->assertTrue($this->adapter->hasColumn('t', 'column1')); + $newColumn2 = new PhinxColumn(); + $newColumn2->setName('column2') + ->setType('string'); + $table->changeColumn('column1', $newColumn2)->save(); + $this->assertFalse($this->adapter->hasColumn('t', 'column1')); + $this->assertTrue($this->adapter->hasColumn('t', 'column2')); + } + + public function testChangeColumnDefaultValue() + { + $table = new PhinxTable('t', [], $this->adapter); + $table->addColumn('column1', 'string', ['default' => 'test']) + ->save(); + $newColumn1 = new PhinxColumn(); + $newColumn1 + ->setName('column1') + ->setDefault('test1') + ->setType('string'); + $table->changeColumn('column1', $newColumn1)->save(); + $rows = $this->adapter->fetchAll('pragma table_info(t)'); + + $this->assertEquals("'test1'", $rows[1]['dflt_value']); + } + + /** + * @group bug922 + */ + public function testChangeColumnWithForeignKey() + { + $refTable = new PhinxTable('ref_table', [], $this->adapter); + $refTable->addColumn('field1', 'string')->save(); + + $table = new PhinxTable('another_table', [], $this->adapter); + $table + ->addColumn('ref_table_id', 'integer') + ->addForeignKey(['ref_table_id'], 'ref_table', ['id']) + ->save(); + + $this->assertTrue($this->adapter->hasForeignKey($table->getName(), ['ref_table_id'])); + + $table->changeColumn('ref_table_id', 'float')->save(); + + $this->assertTrue($this->adapter->hasForeignKey($table->getName(), ['ref_table_id'])); + } + + public function testChangeColumnWithIndex() + { + $table = new PhinxTable('t', [], $this->adapter); + $table + ->addColumn('indexcol', 'integer') + ->addIndex( + 'indexcol', + ['unique' => true] + ) + ->create(); + + $this->assertTrue($this->adapter->hasIndex($table->getName(), 'indexcol')); + + $table->changeColumn('indexcol', 'integer', ['null' => false])->update(); + + $this->assertTrue($this->adapter->hasIndex($table->getName(), 'indexcol')); + } + + public function testChangeColumnWithTrigger() + { + $table = new PhinxTable('t', [], $this->adapter); + $table + ->addColumn('triggercol', 'integer') + ->addColumn('othercol', 'integer') + ->create(); + + $triggerSQL = + 'CREATE TRIGGER update_t_othercol UPDATE OF triggercol ON t + BEGIN + UPDATE t SET othercol = new.triggercol; + END'; + + $this->adapter->execute($triggerSQL); + + $rows = $this->adapter->fetchAll( + "SELECT * FROM sqlite_master WHERE `type` = 'trigger' AND tbl_name = 't'" + ); + $this->assertCount(1, $rows); + $this->assertEquals('trigger', $rows[0]['type']); + $this->assertEquals('update_t_othercol', $rows[0]['name']); + $this->assertEquals($triggerSQL, $rows[0]['sql']); + + $table->changeColumn('triggercol', 'integer', ['null' => false])->update(); + + $rows = $this->adapter->fetchAll( + "SELECT * FROM sqlite_master WHERE `type` = 'trigger' AND tbl_name = 't'" + ); + $this->assertCount(1, $rows); + $this->assertEquals('trigger', $rows[0]['type']); + $this->assertEquals('update_t_othercol', $rows[0]['name']); + $this->assertEquals($triggerSQL, $rows[0]['sql']); + } + + public function testChangeColumnDefaultToZero() + { + $table = new PhinxTable('t', [], $this->adapter); + $table->addColumn('column1', 'integer') + ->save(); + $newColumn1 = new PhinxColumn(); + $newColumn1->setDefault(0) + ->setName('column1') + ->setType('integer'); + $table->changeColumn('column1', $newColumn1)->save(); + $rows = $this->adapter->fetchAll('pragma table_info(t)'); + $this->assertEquals('0', $rows[1]['dflt_value']); + } + + public function testChangeColumnDefaultToNull() + { + $table = new PhinxTable('t', [], $this->adapter); + $table->addColumn('column1', 'string', ['default' => 'test']) + ->save(); + $newColumn1 = new PhinxColumn(); + $newColumn1->setDefault(null) + ->setName('column1') + ->setType('string'); + $table->changeColumn('column1', $newColumn1)->save(); + $rows = $this->adapter->fetchAll('pragma table_info(t)'); + $this->assertNull($rows[1]['dflt_value']); + } + + public function testChangeColumnWithCommasInCommentsOrDefaultValue() + { + $table = new PhinxTable('t', [], $this->adapter); + $table->addColumn('column1', 'string', ['default' => 'one, two or three', 'comment' => 'three, two or one']) + ->save(); + $newColumn1 = new PhinxColumn(); + $newColumn1->setDefault('another default') + ->setName('column1') + ->setComment('another comment') + ->setType('string'); + $table->changeColumn('column1', $newColumn1)->save(); + $cols = $this->adapter->getColumns('t'); + $this->assertEquals('another default', (string)$cols[1]->getDefault()); + } + + public function testDropColumnWithIndex() + { + $table = new PhinxTable('t', [], $this->adapter); + $table + ->addColumn('indexcol', 'integer') + ->addIndex('indexcol') + ->create(); + + $this->assertTrue($this->adapter->hasIndex($table->getName(), 'indexcol')); + + $table->removeColumn('indexcol')->update(); + + $this->assertFalse($this->adapter->hasIndex($table->getName(), 'indexcol')); + } + + public function testDropColumnWithUniqueIndex() + { + $table = new PhinxTable('t', [], $this->adapter); + $table + ->addColumn('indexcol', 'integer') + ->addIndex('indexcol', ['unique' => true]) + ->create(); + + $this->assertTrue($this->adapter->hasIndex($table->getName(), 'indexcol')); + + $table->removeColumn('indexcol')->update(); + + $this->assertFalse($this->adapter->hasIndex($table->getName(), 'indexcol')); + } + + public function testDropColumnWithCompositeIndex() + { + $table = new PhinxTable('t', [], $this->adapter); + $table + ->addColumn('indexcol1', 'integer') + ->addColumn('indexcol2', 'integer') + ->addIndex(['indexcol1', 'indexcol2']) + ->create(); + + $this->assertTrue($this->adapter->hasIndex($table->getName(), ['indexcol1', 'indexcol2'])); + + $table->removeColumn('indexcol2')->update(); + + $this->assertFalse($this->adapter->hasIndex($table->getName(), ['indexcol1', 'indexcol2'])); + } + + /** + * Tests that removing columns does not accidentally drop indices + * on table names that match the column to remove. + */ + public function testDropColumnWithIndexMatchingTheTableName() + { + $table = new PhinxTable('indexcol', [], $this->adapter); + $table + ->addColumn('indexcol', 'integer') + ->addColumn('indexcolumn', 'integer') + ->addIndex('indexcolumn') + ->create(); + + $this->assertTrue($this->adapter->hasIndex($table->getName(), 'indexcolumn')); + + $table->removeColumn('indexcol')->update(); + + $this->assertTrue($this->adapter->hasIndex($table->getName(), 'indexcolumn')); + } + + /** + * Tests that removing columns does not accidentally drop indices + * that contain column names that partially match the column to remove. + */ + public function testDropColumnWithIndexColumnPartialMatch() + { + $table = new PhinxTable('t', [], $this->adapter); + $table + ->addColumn('indexcol', 'integer') + ->addColumn('indexcolumn', 'integer') + ->create(); + + $this->adapter->execute('CREATE INDEX custom_idx ON t (indexcolumn)'); + + $this->assertTrue($this->adapter->hasIndex($table->getName(), 'indexcolumn')); + + $table->removeColumn('indexcol')->update(); + + $this->assertTrue($this->adapter->hasIndex($table->getName(), 'indexcolumn')); + } + + /** + * Indices with expressions are not being removed. + */ + public function testDropColumnWithExpressionIndex() + { + $table = new PhinxTable('t', [], $this->adapter); + $table + ->addColumn('indexcol', 'integer') + ->create(); + + $this->adapter->execute('CREATE INDEX custom_idx ON t (ABS(indexcol))'); + + $this->assertTrue($this->adapter->hasIndexByName('t', 'custom_idx')); + + $this->expectException(PDOException::class); + $this->expectExceptionMessage('no such column: indexcol'); + + $table->removeColumn('indexcol')->update(); + } + + public static function columnsProvider() + { + return [ + ['column1', 'string', []], + ['column2', 'integer', []], + ['column3', 'biginteger', []], + ['column4', 'text', []], + ['column5', 'float', []], + ['column7', 'datetime', []], + ['column8', 'time', []], + ['column9', 'timestamp', []], + ['column10', 'date', []], + ['column11', 'binary', []], + ['column13', 'string', ['limit' => 10]], + ['column15', 'smallinteger', []], + ['column15', 'integer', []], + ['column23', 'json', []], + ]; + } + + public function testAddIndex() + { + $table = new PhinxTable('table1', [], $this->adapter); + $table->addColumn('email', 'string') + ->save(); + $this->assertFalse($table->hasIndex('email')); + $table->addIndex('email') + ->save(); + $this->assertTrue($table->hasIndex('email')); + } + + public function testAddForeignKey() + { + $refTable = new PhinxTable('ref_table', [], $this->adapter); + $refTable->addColumn('field1', 'string')->save(); + + $table = new PhinxTable('table', [], $this->adapter); + $table + ->addColumn('ref_table_id', 'integer') + ->addForeignKey(['ref_table_id'], 'ref_table', ['id']) + ->save(); + + $this->assertTrue($this->adapter->hasForeignKey($table->getName(), ['ref_table_id'])); + } + + public function testHasDatabase() + { + if ($this->config['database'] === ':memory:') { + $this->markTestSkipped('Skipping hasDatabase() when testing in-memory db.'); + } + $this->assertFalse($this->adapter->hasDatabase('fake_database_name')); + $this->assertTrue($this->adapter->hasDatabase($this->config['database'])); + } + + public function testDropDatabase() + { + $this->assertFalse($this->adapter->hasDatabase('phinx_temp_database')); + $this->adapter->createDatabase('phinx_temp_database'); + $this->assertTrue($this->adapter->hasDatabase('phinx_temp_database')); + $this->adapter->dropDatabase('phinx_temp_database'); + } + + public function testAddColumnWithComment() + { + $table = new PhinxTable('table1', [], $this->adapter); + $table->addColumn('column1', 'string', ['comment' => $comment = 'Comments from "column1"']) + ->save(); + + $rows = $this->adapter->fetchAll('select * from sqlite_master where `type` = \'table\''); + + foreach ($rows as $row) { + if ($row['tbl_name'] === 'table1') { + $sql = $row['sql']; + } + } + + $this->assertMatchesRegularExpression('/\/\* Comments from "column1" \*\//', $sql); + } + + public function testAddIndexTwoTablesSameIndex() + { + $table = new PhinxTable('table1', [], $this->adapter); + $table->addColumn('email', 'string') + ->save(); + $table2 = new PhinxTable('table2', [], $this->adapter); + $table2->addColumn('email', 'string') + ->save(); + + $this->assertFalse($table->hasIndex('email')); + $this->assertFalse($table2->hasIndex('email')); + + $table->addIndex('email') + ->save(); + $table2->addIndex('email') + ->save(); + + $this->assertTrue($table->hasIndex('email')); + $this->assertTrue($table2->hasIndex('email')); + } + + public function testBulkInsertData() + { + $table = new PhinxTable('table1', [], $this->adapter); + $table->addColumn('column1', 'string') + ->addColumn('column2', 'integer', ['null' => true]) + ->insert([ + [ + 'column1' => 'value1', + 'column2' => 1, + ], + [ + 'column1' => 'value2', + 'column2' => 2, + ], + ]) + ->insert( + [ + 'column1' => 'value3', + 'column2' => 3, + ] + ) + ->insert( + [ + 'column1' => '\'value4\'', + 'column2' => null, + ] + ) + ->save(); + $rows = $this->adapter->fetchAll('SELECT * FROM table1'); + + $this->assertEquals('value1', $rows[0]['column1']); + $this->assertEquals('value2', $rows[1]['column1']); + $this->assertEquals('value3', $rows[2]['column1']); + $this->assertEquals('\'value4\'', $rows[3]['column1']); + $this->assertEquals(1, $rows[0]['column2']); + $this->assertEquals(2, $rows[1]['column2']); + $this->assertEquals(3, $rows[2]['column2']); + $this->assertNull($rows[3]['column2']); + } + + public function testInsertData() + { + $table = new PhinxTable('table1', [], $this->adapter); + $table->addColumn('column1', 'string') + ->addColumn('column2', 'integer', ['null' => true]) + ->insert([ + [ + 'column1' => 'value1', + 'column2' => 1, + ], + [ + 'column1' => 'value2', + 'column2' => 2, + ], + ]) + ->insert( + [ + 'column1' => 'value3', + 'column2' => 3, + ] + ) + ->insert( + [ + 'column1' => '\'value4\'', + 'column2' => null, + ] + ) + ->save(); + + $rows = $this->adapter->fetchAll('SELECT * FROM table1'); + + $this->assertEquals('value1', $rows[0]['column1']); + $this->assertEquals('value2', $rows[1]['column1']); + $this->assertEquals('value3', $rows[2]['column1']); + $this->assertEquals('\'value4\'', $rows[3]['column1']); + $this->assertEquals(1, $rows[0]['column2']); + $this->assertEquals(2, $rows[1]['column2']); + $this->assertEquals(3, $rows[2]['column2']); + $this->assertNull($rows[3]['column2']); + } + + public function testBulkInsertDataEnum() + { + $table = new PhinxTable('table1', [], $this->adapter); + $table->addColumn('column1', 'string') + ->addColumn('column2', 'string', ['null' => true]) + ->addColumn('column3', 'string', ['default' => 'c']) + ->insert([ + 'column1' => 'a', + ]) + ->save(); + + $rows = $this->adapter->fetchAll('SELECT * FROM table1'); + + $this->assertEquals('a', $rows[0]['column1']); + $this->assertNull($rows[0]['column2']); + $this->assertEquals('c', $rows[0]['column3']); + } + + public function testNullWithoutDefaultValue() + { + $this->markTestSkipped('Skipping for now. See Github Issue #265.'); + + // construct table with default/null combinations + $table = new PhinxTable('table1', [], $this->adapter); + $table->addColumn('aa', 'string', ['null' => true]) // no default value + ->addColumn('bb', 'string', ['null' => false]) // no default value + ->addColumn('cc', 'string', ['null' => true, 'default' => 'some1']) + ->addColumn('dd', 'string', ['null' => false, 'default' => 'some2']) + ->save(); + + // load table info + $columns = $this->adapter->getColumns('table1'); + + $this->assertCount(5, $columns); + + $aa = $columns[1]; + $bb = $columns[2]; + $cc = $columns[3]; + $dd = $columns[4]; + + $this->assertEquals('aa', $aa->getName()); + $this->assertTrue($aa->isNull()); + $this->assertNull($aa->getDefault()); + + $this->assertEquals('bb', $bb->getName()); + $this->assertFalse($bb->isNull()); + $this->assertNull($bb->getDefault()); + + $this->assertEquals('cc', $cc->getName()); + $this->assertTrue($cc->isNull()); + $this->assertEquals('some1', $cc->getDefault()); + + $this->assertEquals('dd', $dd->getName()); + $this->assertFalse($dd->isNull()); + $this->assertEquals('some2', $dd->getDefault()); + } + + public function testDumpCreateTable() + { + $this->adapter->setOptions(['dryrun' => true]); + + $table = new PhinxTable('table1', [], $this->adapter); + + $table->addColumn('column1', 'string', ['null' => false]) + ->addColumn('column2', 'integer') + ->addColumn('column3', 'string', ['default' => 'test']) + ->save(); + + $expectedOutput = <<<'OUTPUT' +CREATE TABLE `table1` (`id` INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, `column1` VARCHAR NOT NULL, `column2` INTEGER NULL, `column3` VARCHAR NULL DEFAULT 'test'); +OUTPUT; + $actualOutput = join("\n", $this->out->messages()); + $this->assertStringContainsString($expectedOutput, $actualOutput, 'Passing the --dry-run option does not dump create table query to the output'); + } + + /** + * Creates the table "table1". + * Then sets phinx to dry run mode and inserts a record. + * Asserts that phinx outputs the insert statement and doesn't insert a record. + */ + public function testDumpInsert() + { + $table = new PhinxTable('table1', [], $this->adapter); + $table->addColumn('string_col', 'string') + ->addColumn('int_col', 'integer') + ->save(); + + $this->adapter->setOptions(['dryrun' => true]); + $this->adapter->insert($table->getTable(), [ + 'string_col' => 'test data', + ]); + + $this->adapter->insert($table->getTable(), [ + 'string_col' => null, + ]); + + $this->adapter->insert($table->getTable(), [ + 'int_col' => 23, + ]); + + $expectedOutput = <<<'OUTPUT' +INSERT INTO `table1` (`string_col`) VALUES ('test data'); +INSERT INTO `table1` (`string_col`) VALUES (null); +INSERT INTO `table1` (`int_col`) VALUES (23); +OUTPUT; + $actualOutput = join("\n", $this->out->messages()); + $actualOutput = preg_replace("/\r\n|\r/", "\n", $actualOutput); // normalize line endings for Windows + $this->assertStringContainsString($expectedOutput, $actualOutput, 'Passing the --dry-run option doesn\'t dump the insert to the output'); + + $countQuery = $this->adapter->query('SELECT COUNT(*) FROM table1'); + $this->assertTrue($countQuery->execute()); + $res = $countQuery->fetchAll('assoc'); + $this->assertEquals(0, $res[0]['COUNT(*)']); + } + + /** + * Creates the table "table1". + * Then sets phinx to dry run mode and inserts some records. + * Asserts that phinx outputs the insert statement and doesn't insert any record. + */ + public function testDumpBulkinsert() + { + $table = new PhinxTable('table1', [], $this->adapter); + $table->addColumn('string_col', 'string') + ->addColumn('int_col', 'integer') + ->save(); + + $this->adapter->setOptions(['dryrun' => true]); + $this->adapter->bulkinsert($table->getTable(), [ + [ + 'string_col' => 'test_data1', + 'int_col' => 23, + ], + [ + 'string_col' => null, + 'int_col' => 42, + ], + ]); + + $expectedOutput = <<<'OUTPUT' +INSERT INTO `table1` (`string_col`, `int_col`) VALUES ('test_data1', 23), (null, 42); +OUTPUT; + $actualOutput = join("\n", $this->out->messages()); + $this->assertStringContainsString($expectedOutput, $actualOutput, 'Passing the --dry-run option doesn\'t dump the bulkinsert to the output'); + + $countQuery = $this->adapter->query('SELECT COUNT(*) FROM table1'); + $this->assertTrue($countQuery->execute()); + $res = $countQuery->fetchAll('assoc'); + $this->assertEquals(0, $res[0]['COUNT(*)']); + } + + public function testDumpCreateTableAndThenInsert() + { + $this->adapter->setOptions(['dryrun' => true]); + + $table = new PhinxTable('table1', ['id' => false, 'primary_key' => ['column1']], $this->adapter); + + $table->addColumn('column1', 'string', ['null' => false]) + ->addColumn('column2', 'integer') + ->save(); + + $expectedOutput = 'C'; + + $table = new PhinxTable('table1', [], $this->adapter); + $table->insert([ + 'column1' => 'id1', + 'column2' => 1, + ])->save(); + + $expectedOutput = <<<'OUTPUT' +CREATE TABLE `table1` (`column1` VARCHAR NOT NULL, `column2` INTEGER NULL, PRIMARY KEY (`column1`)); +INSERT INTO `table1` (`column1`, `column2`) VALUES ('id1', 1); +OUTPUT; + $actualOutput = join("\n", $this->out->messages()); + $actualOutput = preg_replace("/\r\n|\r/", "\n", $actualOutput); // normalize line endings for Windows + $this->assertStringContainsString($expectedOutput, $actualOutput, 'Passing the --dry-run option does not dump create and then insert table queries to the output'); + } + + /** + * Tests interaction with the query builder + */ + public function testQueryBuilder() + { + $table = new PhinxTable('table1', [], $this->adapter); + $table->addColumn('string_col', 'string') + ->addColumn('int_col', 'integer') + ->save(); + + $builder = $this->adapter->getQueryBuilder(Query::TYPE_INSERT); + $stm = $builder + ->insert(['string_col', 'int_col']) + ->into('table1') + ->values(['string_col' => 'value1', 'int_col' => 1]) + ->values(['string_col' => 'value2', 'int_col' => 2]) + ->execute(); + + $this->assertEquals(2, $stm->rowCount()); + + $builder = $this->adapter->getQueryBuilder(Query::TYPE_SELECT); + $stm = $builder + ->select('*') + ->from('table1') + ->where(['int_col >=' => 2]) + ->execute(); + + $this->assertEquals(0, $stm->rowCount()); + $this->assertEquals( + ['id' => 2, 'string_col' => 'value2', 'int_col' => '2'], + $stm->fetch('assoc') + ); + + $builder = $this->adapter->getQueryBuilder(Query::TYPE_DELETE); + $stm = $builder + ->delete('table1') + ->where(['int_col <' => 2]) + ->execute(); + + $this->assertEquals(1, $stm->rowCount()); + } + + public function testQueryWithParams() + { + $table = new PhinxTable('table1', [], $this->adapter); + $table->addColumn('string_col', 'string') + ->addColumn('int_col', 'integer') + ->save(); + + $this->adapter->insert($table->getTable(), [ + 'string_col' => 'test data', + 'int_col' => 10, + ]); + + $this->adapter->insert($table->getTable(), [ + 'string_col' => null, + ]); + + $this->adapter->insert($table->getTable(), [ + 'int_col' => 23, + ]); + + $countQuery = $this->adapter->query('SELECT COUNT(*) AS c FROM table1 WHERE int_col > ?', [5]); + $res = $countQuery->fetchAll('assoc'); + $this->assertEquals(2, $res[0]['c']); + + $this->adapter->execute('UPDATE table1 SET int_col = ? WHERE int_col IS NULL', [12]); + + $countQuery->execute([1]); + $res = $countQuery->fetchAll('assoc'); + $this->assertEquals(3, $res[0]['c']); + } + + /** + * Tests adding more than one column to a table + * that already exists due to adapters having different add column instructions + */ + public function testAlterTableColumnAdd() + { + $table = new PhinxTable('table1', [], $this->adapter); + $table->create(); + + $table->addColumn('string_col', 'string', ['default' => '']); + $table->addColumn('string_col_2', 'string', ['null' => true]); + $table->addColumn('string_col_3', 'string', ['null' => false]); + $table->addTimestamps(); + $table->save(); + + $columns = $this->adapter->getColumns('table1'); + $expected = [ + ['name' => 'id', 'type' => 'integer', 'default' => null, 'null' => false], + ['name' => 'string_col', 'type' => 'string', 'default' => '', 'null' => true], + ['name' => 'string_col_2', 'type' => 'string', 'default' => null, 'null' => true], + ['name' => 'string_col_3', 'type' => 'string', 'default' => null, 'null' => false], + ['name' => 'created_at', 'type' => 'timestamp', 'default' => 'CURRENT_TIMESTAMP', 'null' => false], + ['name' => 'updated_at', 'type' => 'timestamp', 'default' => null, 'null' => true], + ]; + + $this->assertEquals(count($expected), count($columns)); + + $columnCount = count($columns); + for ($i = 0; $i < $columnCount; $i++) { + $this->assertSame($expected[$i]['name'], $columns[$i]->getName(), "Wrong name for {$expected[$i]['name']}"); + $this->assertSame($expected[$i]['type'], $columns[$i]->getType(), "Wrong type for {$expected[$i]['name']}"); + $this->assertSame($expected[$i]['default'], $columns[$i]->getDefault() instanceof Literal ? (string)$columns[$i]->getDefault() : $columns[$i]->getDefault(), "Wrong default for {$expected[$i]['name']}"); + $this->assertSame($expected[$i]['null'], $columns[$i]->getNull(), "Wrong null for {$expected[$i]['name']}"); + } + } + + public function testAlterTableWithConstraints() + { + $table = new PhinxTable('table1', [], $this->adapter); + $table->create(); + + $table2 = new PhinxTable('table2', [], $this->adapter); + $table2->create(); + + $table + ->addColumn('table2_id', 'integer', ['null' => false]) + ->addForeignKey('table2_id', 'table2', 'id', [ + 'delete' => 'SET NULL', + ]); + $table->update(); + + $table->addColumn('column3', 'string', ['default' => null, 'null' => true]); + $table->update(); + + $columns = $this->adapter->getColumns('table1'); + $expected = [ + ['name' => 'id', 'type' => 'integer', 'default' => null, 'null' => false], + ['name' => 'table2_id', 'type' => 'integer', 'default' => null, 'null' => false], + ['name' => 'column3', 'type' => 'string', 'default' => null, 'null' => true], + ]; + + $this->assertEquals(count($expected), count($columns)); + + $columnCount = count($columns); + for ($i = 0; $i < $columnCount; $i++) { + $this->assertSame($expected[$i]['name'], $columns[$i]->getName(), "Wrong name for {$expected[$i]['name']}"); + $this->assertSame($expected[$i]['type'], $columns[$i]->getType(), "Wrong type for {$expected[$i]['name']}"); + $this->assertSame($expected[$i]['default'], $columns[$i]->getDefault() instanceof Literal ? (string)$columns[$i]->getDefault() : $columns[$i]->getDefault(), "Wrong default for {$expected[$i]['name']}"); + $this->assertSame($expected[$i]['null'], $columns[$i]->getNull(), "Wrong null for {$expected[$i]['name']}"); + } + } + + /** + * Tests that operations that trigger implicit table drops will not cause + * a foreign key constraint violation error. + */ + public function testAlterTableDoesNotViolateRestrictedForeignKeyConstraint() + { + $this->adapter->execute('PRAGMA foreign_keys = ON'); + + $articlesTable = new PhinxTable('articles', [], $this->adapter); + $articlesTable + ->insert(['id' => 1]) + ->save(); + + $commentsTable = new PhinxTable('comments', [], $this->adapter); + $commentsTable + ->addColumn('article_id', 'integer') + ->addForeignKey('article_id', 'articles', 'id', [ + 'update' => ForeignKey::RESTRICT, + 'delete' => ForeignKey::RESTRICT, + ]) + ->insert(['id' => 1, 'article_id' => 1]) + ->save(); + + $this->assertTrue($this->adapter->hasForeignKey('comments', ['article_id'])); + + $articlesTable + ->addColumn('new_column', 'integer') + ->update(); + + $articlesTable + ->renameColumn('new_column', 'new_column_renamed') + ->update(); + + $articlesTable + ->changeColumn('new_column_renamed', 'integer', [ + 'default' => 1, + ]) + ->update(); + + $articlesTable + ->removeColumn('new_column_renamed') + ->update(); + + $articlesTable + ->addIndex('id', ['name' => 'ID_IDX']) + ->update(); + + $articlesTable + ->removeIndex('id') + ->update(); + + $articlesTable + ->addForeignKey('id', 'comments', 'id') + ->update(); + + $articlesTable + ->dropForeignKey('id') + ->update(); + + $articlesTable + ->addColumn('id2', 'integer') + ->addIndex('id', ['unique' => true]) + ->changePrimaryKey('id2') + ->update(); + } + + /** + * Tests that foreign key constraint violations introduced around the table + * alteration process (being it implicitly by the process itself or by the user) + * will trigger an error accordingly. + */ + public function testAlterTableDoesViolateForeignKeyConstraintOnTargetTableChange() + { + $articlesTable = new PhinxTable('articles', [], $this->adapter); + $articlesTable + ->insert(['id' => 1]) + ->save(); + + $commentsTable = new PhinxTable('comments', [], $this->adapter); + $commentsTable + ->addColumn('article_id', 'integer') + ->addForeignKey('article_id', 'articles', 'id', [ + 'update' => ForeignKey::RESTRICT, + 'delete' => ForeignKey::RESTRICT, + ]) + ->insert(['id' => 1, 'article_id' => 1]) + ->save(); + + $this->assertTrue($this->adapter->hasForeignKey('comments', ['article_id'])); + + $this->adapter->execute('PRAGMA foreign_keys = OFF'); + $this->adapter->execute('DELETE FROM articles'); + $this->adapter->execute('PRAGMA foreign_keys = ON'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Integrity constraint violation: FOREIGN KEY constraint on `comments` failed.'); + + $articlesTable + ->addColumn('new_column', 'integer') + ->update(); + } + + public function testLiteralSupport() + { + $createQuery = <<<'INPUT' +CREATE TABLE `test` (`real_col` DECIMAL) +INPUT; + $this->adapter->execute($createQuery); + $table = new PhinxTable('test', [], $this->adapter); + $columns = $table->getColumns(); + $this->assertCount(1, $columns); + $this->assertEquals(Literal::from('decimal'), array_pop($columns)->getType()); + } + + /** + * @covers \Migrations\Db\Adapter\SqliteAdapter::hasPrimaryKey + */ + public function testHasNamedPrimaryKey() + { + $this->expectException(InvalidArgumentException::class); + + $this->adapter->hasPrimaryKey('t', [], 'named_constraint'); + } + + /** @covers \Migrations\Db\Adapter\SqliteAdapter::getColumnTypes */ + public function testGetColumnTypes() + { + $columnTypes = $this->adapter->getColumnTypes(); + $expected = [ + SqliteAdapter::PHINX_TYPE_BIG_INTEGER, + SqliteAdapter::PHINX_TYPE_BINARY, + SqliteAdapter::PHINX_TYPE_BLOB, + SqliteAdapter::PHINX_TYPE_BOOLEAN, + SqliteAdapter::PHINX_TYPE_CHAR, + SqliteAdapter::PHINX_TYPE_DATE, + SqliteAdapter::PHINX_TYPE_DATETIME, + SqliteAdapter::PHINX_TYPE_DECIMAL, + SqliteAdapter::PHINX_TYPE_DOUBLE, + SqliteAdapter::PHINX_TYPE_FLOAT, + SqliteAdapter::PHINX_TYPE_INTEGER, + SqliteAdapter::PHINX_TYPE_JSON, + SqliteAdapter::PHINX_TYPE_JSONB, + SqliteAdapter::PHINX_TYPE_SMALL_INTEGER, + SqliteAdapter::PHINX_TYPE_STRING, + SqliteAdapter::PHINX_TYPE_TEXT, + SqliteAdapter::PHINX_TYPE_TIME, + SqliteAdapter::PHINX_TYPE_UUID, + SqliteAdapter::PHINX_TYPE_BINARYUUID, + SqliteAdapter::PHINX_TYPE_TIMESTAMP, + SqliteAdapter::PHINX_TYPE_TINY_INTEGER, + SqliteAdapter::PHINX_TYPE_VARBINARY, + ]; + sort($columnTypes); + sort($expected); + + $this->assertEquals($expected, $columnTypes); + } + + /** + * @dataProvider provideColumnTypesForValidation + * @covers \Phinx\Db\Adapter\SqliteAdapter::isValidColumnType + */ + public function testIsValidColumnType($phinxType, $exp) + { + $col = (new PhinxColumn())->setType($phinxType); + $this->assertSame($exp, $this->adapter->isValidColumnType($col)); + } + + public static function provideColumnTypesForValidation() + { + return [ + [SqliteAdapter::PHINX_TYPE_BIG_INTEGER, true], + [SqliteAdapter::PHINX_TYPE_BINARY, true], + [SqliteAdapter::PHINX_TYPE_BLOB, true], + [SqliteAdapter::PHINX_TYPE_BOOLEAN, true], + [SqliteAdapter::PHINX_TYPE_CHAR, true], + [SqliteAdapter::PHINX_TYPE_DATE, true], + [SqliteAdapter::PHINX_TYPE_DATETIME, true], + [SqliteAdapter::PHINX_TYPE_DOUBLE, true], + [SqliteAdapter::PHINX_TYPE_FLOAT, true], + [SqliteAdapter::PHINX_TYPE_INTEGER, true], + [SqliteAdapter::PHINX_TYPE_JSON, true], + [SqliteAdapter::PHINX_TYPE_JSONB, true], + [SqliteAdapter::PHINX_TYPE_SMALL_INTEGER, true], + [SqliteAdapter::PHINX_TYPE_STRING, true], + [SqliteAdapter::PHINX_TYPE_TEXT, true], + [SqliteAdapter::PHINX_TYPE_TIME, true], + [SqliteAdapter::PHINX_TYPE_UUID, true], + [SqliteAdapter::PHINX_TYPE_TIMESTAMP, true], + [SqliteAdapter::PHINX_TYPE_VARBINARY, true], + [SqliteAdapter::PHINX_TYPE_BIT, false], + [SqliteAdapter::PHINX_TYPE_CIDR, false], + [SqliteAdapter::PHINX_TYPE_DECIMAL, true], + [SqliteAdapter::PHINX_TYPE_ENUM, false], + [SqliteAdapter::PHINX_TYPE_FILESTREAM, false], + [SqliteAdapter::PHINX_TYPE_GEOMETRY, false], + [SqliteAdapter::PHINX_TYPE_INET, false], + [SqliteAdapter::PHINX_TYPE_INTERVAL, false], + [SqliteAdapter::PHINX_TYPE_LINESTRING, false], + [SqliteAdapter::PHINX_TYPE_MACADDR, false], + [SqliteAdapter::PHINX_TYPE_POINT, false], + [SqliteAdapter::PHINX_TYPE_POLYGON, false], + [SqliteAdapter::PHINX_TYPE_SET, false], + [PhinxLiteral::from('someType'), true], + ['someType', false], + ]; + } + + /** @covers \Phinx\Db\Adapter\SqliteAdapter::getSchemaName + * @covers \Phinx\Db\Adapter\SqliteAdapter::getTableInfo + * @covers \Phinx\Db\Adapter\SqliteAdapter::getColumns + */ + public function testGetColumns() + { + $conn = $this->adapter->getConnection(); + $conn->execute('create table t(a integer, b text, c char(5), d integer(12,6), e integer not null, f integer null)'); + $exp = [ + ['name' => 'a', 'type' => 'integer', 'null' => true, 'limit' => null, 'precision' => null, 'scale' => null], + ['name' => 'b', 'type' => 'text', 'null' => true, 'limit' => null, 'precision' => null, 'scale' => null], + ['name' => 'c', 'type' => 'char', 'null' => true, 'limit' => 5, 'precision' => 5, 'scale' => null], + ['name' => 'd', 'type' => 'integer', 'null' => true, 'limit' => 12, 'precision' => 12, 'scale' => 6], + ['name' => 'e', 'type' => 'integer', 'null' => false, 'limit' => null, 'precision' => null, 'scale' => null], + ['name' => 'f', 'type' => 'integer', 'null' => true, 'limit' => null, 'precision' => null, 'scale' => null], + ]; + $act = $this->adapter->getColumns('t'); + $this->assertCount(count($exp), $act); + foreach ($exp as $index => $data) { + $this->assertInstanceOf(PhinxColumn::class, $act[$index]); + foreach ($data as $key => $value) { + $m = 'get' . ucfirst($key); + $this->assertEquals($value, $act[$index]->$m(), "Parameter '$key' of column at index $index did not match expectations."); + } + } + } + + public function testForeignKeyReferenceCorrectAfterRenameColumn() + { + $refTableColumnId = 'ref_table_id'; + $refTableColumnToRename = 'columnToRename'; + $refTableRenamedColumn = 'renamedColumn'; + $refTable = new PhinxTable('ref_table', [], $this->adapter); + $refTable->addColumn($refTableColumnToRename, 'string')->save(); + + $table = new PhinxTable('table', [], $this->adapter); + $table->addColumn($refTableColumnId, 'integer'); + $table->addForeignKey($refTableColumnId, $refTable->getName(), 'id'); + $table->save(); + + $refTable->renameColumn($refTableColumnToRename, $refTableRenamedColumn)->save(); + + $this->assertTrue($this->adapter->hasForeignKey($table->getName(), [$refTableColumnId])); + $this->assertFalse($this->adapter->hasTable("tmp_{$refTable->getName()}")); + $this->assertTrue($this->adapter->hasColumn($refTable->getName(), $refTableRenamedColumn)); + + $rows = $this->adapter->fetchAll('select * from sqlite_master where `type` = \'table\''); + foreach ($rows as $row) { + if ($row['tbl_name'] === $table->getName()) { + $sql = $row['sql']; + } + } + $this->assertStringContainsString("REFERENCES `{$refTable->getName()}` (`id`)", $sql); + } + + public function testForeignKeyReferenceCorrectAfterChangeColumn() + { + $refTableColumnId = 'ref_table_id'; + $refTableColumnToChange = 'columnToChange'; + $refTable = new PhinxTable('ref_table', [], $this->adapter); + $refTable->addColumn($refTableColumnToChange, 'string')->save(); + + $table = new PhinxTable('table', [], $this->adapter); + $table->addColumn($refTableColumnId, 'integer'); + $table->addForeignKey($refTableColumnId, $refTable->getName(), 'id'); + $table->save(); + + $refTable->changeColumn($refTableColumnToChange, 'text')->save(); + + $this->assertTrue($this->adapter->hasForeignKey($table->getName(), [$refTableColumnId])); + $this->assertFalse($this->adapter->hasTable("tmp_{$refTable->getName()}")); + $this->assertEquals('text', $this->adapter->getColumns($refTable->getName())[1]->getType()); + + $rows = $this->adapter->fetchAll('select * from sqlite_master where `type` = \'table\''); + foreach ($rows as $row) { + if ($row['tbl_name'] === $table->getName()) { + $sql = $row['sql']; + } + } + $this->assertStringContainsString("REFERENCES `{$refTable->getName()}` (`id`)", $sql); + } + + public function testForeignKeyReferenceCorrectAfterRemoveColumn() + { + $refTableColumnId = 'ref_table_id'; + $refTableColumnToRemove = 'columnToRemove'; + $refTable = new PhinxTable('ref_table', [], $this->adapter); + $refTable->addColumn($refTableColumnToRemove, 'string')->save(); + + $table = new PhinxTable('table', [], $this->adapter); + $table->addColumn($refTableColumnId, 'integer'); + $table->addForeignKey($refTableColumnId, $refTable->getName(), 'id'); + $table->save(); + + $refTable->removeColumn($refTableColumnToRemove)->save(); + + $this->assertTrue($this->adapter->hasForeignKey($table->getName(), [$refTableColumnId])); + $this->assertFalse($this->adapter->hasTable("tmp_{$refTable->getName()}")); + $this->assertFalse($this->adapter->hasColumn($refTable->getName(), $refTableColumnToRemove)); + + $rows = $this->adapter->fetchAll('select * from sqlite_master where `type` = \'table\''); + foreach ($rows as $row) { + if ($row['tbl_name'] === $table->getName()) { + $sql = $row['sql']; + } + } + $this->assertStringContainsString("REFERENCES `{$refTable->getName()}` (`id`)", $sql); + } + + public function testForeignKeyReferenceCorrectAfterChangePrimaryKey() + { + $refTableColumnAdditionalId = 'additional_id'; + $refTableColumnId = 'ref_table_id'; + $refTable = new PhinxTable('ref_table', [], $this->adapter); + $refTable->addColumn($refTableColumnAdditionalId, 'integer')->save(); + + $table = new PhinxTable('table', [], $this->adapter); + $table->addColumn($refTableColumnId, 'integer'); + $table->addForeignKey($refTableColumnId, $refTable->getName(), 'id'); + $table->save(); + + $refTable + ->addIndex('id', ['unique' => true]) + ->changePrimaryKey($refTableColumnAdditionalId) + ->save(); + + $this->assertTrue($this->adapter->hasForeignKey($table->getName(), [$refTableColumnId])); + $this->assertFalse($this->adapter->hasTable("tmp_{$refTable->getName()}")); + $this->assertTrue($this->adapter->getColumns($refTable->getName())[1]->getIdentity()); + + $rows = $this->adapter->fetchAll('select * from sqlite_master where `type` = \'table\''); + foreach ($rows as $row) { + if ($row['tbl_name'] === $table->getName()) { + $sql = $row['sql']; + } + } + $this->assertStringContainsString("REFERENCES `{$refTable->getName()}` (`id`)", $sql); + } +} diff --git a/tests/TestCase/Db/Adapter/PostgresAdapterTest.php b/tests/TestCase/Db/Adapter/PostgresAdapterTest.php new file mode 100644 index 00000000..5b939603 --- /dev/null +++ b/tests/TestCase/Db/Adapter/PostgresAdapterTest.php @@ -0,0 +1,2704 @@ +markTestSkipped('Postgres tests disabled.'); + } + + // Emulate the results of Util::parseDsn() + $this->config = [ + 'adapter' => 'postgres', + 'connection' => ConnectionManager::get('test'), + 'database' => $config['database'], + ]; + + if (!self::isPostgresAvailable()) { + $this->markTestSkipped('Postgres is not available. Please install php-pdo-pgsql or equivalent package.'); + } + + $this->adapter = new PostgresAdapter($this->config, $this->getConsoleIo()); + + $this->adapter->dropAllSchemas(); + $this->adapter->createSchema('public'); + + $citext = $this->adapter->fetchRow("SELECT COUNT(*) AS enabled FROM pg_extension WHERE extname = 'citext'"); + if (!$citext['enabled']) { + $this->adapter->query('CREATE EXTENSION IF NOT EXISTS citext'); + } + + // leave the adapter in a disconnected state for each test + $this->adapter->disconnect(); + } + + protected function tearDown(): void + { + unset($this->adapter, $this->out, $this->io); + } + + protected function getConsoleIo(): ConsoleIo + { + $out = new StubConsoleOutput(); + $in = new StubConsoleInput([]); + $io = new ConsoleIo($out, $out, $in); + + $this->out = $out; + $this->io = $io; + + return $this->io; + } + + private function usingPostgres10(): bool + { + $version = $this->adapter->getConnection()->getDriver()->version(); + + return version_compare($version, '10.0.0', '>='); + } + + public function testConnection() + { + $this->assertInstanceOf(Connection::class, $this->adapter->getConnection()); + } + + public function testCreatingTheSchemaTableOnConnect() + { + $this->adapter->connect(); + $this->assertTrue($this->adapter->hasTable($this->adapter->getSchemaTableName())); + $this->adapter->dropTable($this->adapter->getSchemaTableName()); + $this->assertFalse($this->adapter->hasTable($this->adapter->getSchemaTableName())); + $this->adapter->disconnect(); + $this->adapter->connect(); + $this->assertTrue($this->adapter->hasTable($this->adapter->getSchemaTableName())); + } + + public function testSchemaTableIsCreatedWithPrimaryKey() + { + $this->adapter->connect(); + new Table($this->adapter->getSchemaTableName(), [], $this->adapter); + $this->assertTrue($this->adapter->hasIndex($this->adapter->getSchemaTableName(), ['version'])); + } + + public function testQuoteSchemaName() + { + $this->assertEquals('"schema"', $this->adapter->quoteSchemaName('schema')); + $this->assertEquals('"schema.schema"', $this->adapter->quoteSchemaName('schema.schema')); + } + + public function testQuoteTableName() + { + $this->assertEquals('"public"."table"', $this->adapter->quoteTableName('table')); + $this->assertEquals('"table"."table"', $this->adapter->quoteTableName('table.table')); + } + + public function testQuoteColumnName() + { + $this->assertEquals('"string"', $this->adapter->quoteColumnName('string')); + $this->assertEquals('"string.string"', $this->adapter->quoteColumnName('string.string')); + } + + public function testCreateTable() + { + $table = new Table('ntable', [], $this->adapter); + $table->addColumn('realname', 'string') + ->addColumn('email', 'integer') + ->save(); + $this->assertTrue($this->adapter->hasTable('ntable')); + $this->assertTrue($this->adapter->hasColumn('ntable', 'id')); + $this->assertTrue($this->adapter->hasColumn('ntable', 'realname')); + $this->assertTrue($this->adapter->hasColumn('ntable', 'email')); + $this->assertFalse($this->adapter->hasColumn('ntable', 'address')); + } + + public function testCreateTableWithSchema() + { + $this->adapter->createSchema('nschema'); + + $table = new Table('nschema.ntable', [], $this->adapter); + $table->addColumn('realname', 'string') + ->addColumn('email', 'integer') + ->save(); + $this->assertTrue($this->adapter->hasTable('nschema.ntable')); + $this->assertTrue($this->adapter->hasColumn('nschema.ntable', 'id')); + $this->assertTrue($this->adapter->hasColumn('nschema.ntable', 'realname')); + $this->assertTrue($this->adapter->hasColumn('nschema.ntable', 'email')); + $this->assertFalse($this->adapter->hasColumn('nschema.ntable', 'address')); + + $this->adapter->dropSchema('nschema'); + } + + public function testCreateTableCustomIdColumn() + { + $table = new Table('ntable', ['id' => 'custom_id'], $this->adapter); + $table->addColumn('realname', 'string') + ->addColumn('email', 'integer') + ->save(); + $this->assertTrue($this->adapter->hasTable('ntable')); + $this->assertTrue($this->adapter->hasColumn('ntable', 'custom_id')); + $this->assertTrue($this->adapter->hasColumn('ntable', 'realname')); + $this->assertTrue($this->adapter->hasColumn('ntable', 'email')); + $this->assertFalse($this->adapter->hasColumn('ntable', 'address')); + } + + public function testCreateTableWithNoPrimaryKey() + { + $options = [ + 'id' => false, + ]; + $table = new Table('atable', $options, $this->adapter); + $table->addColumn('user_id', 'integer') + ->save(); + $this->assertFalse($this->adapter->hasColumn('atable', 'id')); + } + + public function testCreateTableWithConflictingPrimaryKeys() + { + $options = [ + 'primary_key' => 'user_id', + ]; + $table = new Table('atable', $options, $this->adapter); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('You cannot enable an auto incrementing ID field and a primary key'); + $table->addColumn('user_id', 'integer')->save(); + } + + public function testCreateTableWithPrimaryKeySetToImplicitId() + { + $options = [ + 'primary_key' => 'id', + ]; + $table = new Table('ztable', $options, $this->adapter); + $table->addColumn('user_id', 'integer')->save(); + $this->assertTrue($this->adapter->hasColumn('ztable', 'id')); + $this->assertTrue($this->adapter->hasIndex('ztable', 'id')); + $this->assertTrue($this->adapter->hasColumn('ztable', 'user_id')); + } + + public function testCreateTableWithPrimaryKeyArraySetToImplicitId() + { + $options = [ + 'primary_key' => ['id'], + ]; + $table = new Table('ztable', $options, $this->adapter); + $table->addColumn('user_id', 'integer')->save(); + $this->assertTrue($this->adapter->hasColumn('ztable', 'id')); + $this->assertTrue($this->adapter->hasIndex('ztable', 'id')); + $this->assertTrue($this->adapter->hasColumn('ztable', 'user_id')); + } + + public function testCreateTableWithMultiplePrimaryKeyArraySetToImplicitId() + { + $options = [ + 'primary_key' => ['id', 'user_id'], + ]; + $table = new Table('ztable', $options, $this->adapter); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('You cannot enable an auto incrementing ID field and a primary key'); + $table->addColumn('user_id', 'integer')->save(); + } + + public function testCreateTableWithMultiplePrimaryKeys() + { + $options = [ + 'id' => false, + 'primary_key' => ['user_id', 'tag_id'], + ]; + $table = new Table('table1', $options, $this->adapter); + $table->addColumn('user_id', 'integer') + ->addColumn('tag_id', 'integer') + ->save(); + $this->assertTrue($this->adapter->hasIndex('table1', ['user_id', 'tag_id'])); + $this->assertTrue($this->adapter->hasIndex('table1', ['tag_id', 'user_id'])); + $this->assertFalse($this->adapter->hasIndex('table1', ['tag_id', 'user_email'])); + } + + public function testCreateTableWithMultiplePrimaryKeysWithSchema() + { + $this->adapter->createSchema('schema1'); + + $options = [ + 'id' => false, + 'primary_key' => ['user_id', 'tag_id'], + ]; + $table = new Table('schema1.table1', $options, $this->adapter); + $table->addColumn('user_id', 'integer') + ->addColumn('tag_id', 'integer') + ->save(); + $this->assertTrue($this->adapter->hasIndex('schema1.table1', ['user_id', 'tag_id'])); + $this->assertTrue($this->adapter->hasIndex('schema1.table1', ['tag_id', 'user_id'])); + $this->assertFalse($this->adapter->hasIndex('schema1.table1', ['tag_id', 'user_email'])); + + $this->adapter->dropSchema('schema1'); + } + + /** + * @return void + */ + public function testCreateTableWithPrimaryKeyAsUuid() + { + $options = [ + 'id' => false, + 'primary_key' => 'id', + ]; + $table = new Table('ztable', $options, $this->adapter); + $table->addColumn('id', 'uuid')->save(); + $table->addColumn('user_id', 'integer')->save(); + $this->assertTrue($this->adapter->hasColumn('ztable', 'id')); + $this->assertTrue($this->adapter->hasIndex('ztable', 'id')); + $this->assertTrue($this->adapter->hasColumn('ztable', 'user_id')); + } + + /** + * @return void + */ + public function testCreateTableWithPrimaryKeyAsBinaryUuid() + { + $options = [ + 'id' => false, + 'primary_key' => 'id', + ]; + $table = new Table('ztable', $options, $this->adapter); + $table->addColumn('id', 'binaryuuid')->save(); + $table->addColumn('user_id', 'integer')->save(); + $this->assertTrue($this->adapter->hasColumn('ztable', 'id')); + $this->assertTrue($this->adapter->hasIndex('ztable', 'id')); + $this->assertTrue($this->adapter->hasColumn('ztable', 'user_id')); + } + + public function testCreateTableWithMultipleIndexes() + { + $table = new Table('table1', [], $this->adapter); + $table->addColumn('email', 'string') + ->addColumn('name', 'string') + ->addIndex('email') + ->addIndex('name') + ->save(); + $this->assertTrue($this->adapter->hasIndex('table1', ['email'])); + $this->assertTrue($this->adapter->hasIndex('table1', ['name'])); + $this->assertFalse($this->adapter->hasIndex('table1', ['email', 'user_email'])); + $this->assertFalse($this->adapter->hasIndex('table1', ['email', 'user_name'])); + } + + public function testCreateTableWithUniqueIndexes() + { + $table = new Table('table1', [], $this->adapter); + $table->addColumn('email', 'string') + ->addIndex('email', ['unique' => true]) + ->save(); + $this->assertTrue($this->adapter->hasIndex('table1', ['email'])); + $this->assertFalse($this->adapter->hasIndex('table1', ['email', 'user_email'])); + } + + public function testCreateTableWithFullTextSearchIndexes() + { + $table = new Table('table1', [], $this->adapter); + $table->addColumn('names', 'jsonb') + ->addIndex('names', ['type' => 'gin']) + ->save(); + + $this->assertTrue($this->adapter->hasIndex('table1', ['names'])); + } + + public function testCreateTableWithNamedIndexes() + { + $table = new Table('table1', [], $this->adapter); + $table->addColumn('email', 'string') + ->addIndex('email', ['name' => 'myemailindex']) + ->save(); + $this->assertTrue($this->adapter->hasIndex('table1', ['email'])); + $this->assertFalse($this->adapter->hasIndex('table1', ['email', 'user_email'])); + $this->assertTrue($this->adapter->hasIndexByName('table1', 'myemailindex')); + } + + public function testAddPrimaryKey() + { + $table = new Table('table1', ['id' => false], $this->adapter); + $table + ->addColumn('column1', 'integer') + ->save(); + + $table + ->changePrimaryKey('column1') + ->save(); + + $this->assertTrue($this->adapter->hasPrimaryKey('table1', ['column1'])); + } + + public function testChangePrimaryKey() + { + $table = new Table('table1', ['id' => false, 'primary_key' => 'column1'], $this->adapter); + $table + ->addColumn('column1', 'integer') + ->addColumn('column2', 'integer') + ->addColumn('column3', 'integer') + ->save(); + + $table + ->changePrimaryKey(['column2', 'column3']) + ->save(); + + $this->assertFalse($this->adapter->hasPrimaryKey('table1', ['column1'])); + $this->assertTrue($this->adapter->hasPrimaryKey('table1', ['column2', 'column3'])); + } + + public function testDropPrimaryKey() + { + $table = new Table('table1', ['id' => false, 'primary_key' => 'column1'], $this->adapter); + $table + ->addColumn('column1', 'integer') + ->save(); + + $table + ->changePrimaryKey(null) + ->save(); + + $this->assertFalse($this->adapter->hasPrimaryKey('table1', ['column1'])); + } + + public function testAddComment() + { + $table = new Table('table1', [], $this->adapter); + $table->save(); + + $table + ->changeComment('comment1') + ->save(); + + $rows = $this->adapter->fetchAll( + sprintf( + "SELECT description + FROM pg_description + JOIN pg_class ON pg_description.objoid = pg_class.oid + WHERE relname = '%s'", + 'table1' + ) + ); + $this->assertEquals('comment1', $rows[0]['description']); + } + + public function testChangeComment() + { + $table = new Table('table1', ['comment' => 'comment1'], $this->adapter); + $table->save(); + + $table + ->changeComment('comment2') + ->save(); + + $rows = $this->adapter->fetchAll( + sprintf( + "SELECT description + FROM pg_description + JOIN pg_class ON pg_description.objoid = pg_class.oid + WHERE relname = '%s'", + 'table1' + ) + ); + $this->assertEquals('comment2', $rows[0]['description']); + } + + public function testDropComment() + { + $table = new Table('table1', ['comment' => 'comment1'], $this->adapter); + $table->save(); + + $table + ->changeComment(null) + ->save(); + + $rows = $this->adapter->fetchAll( + sprintf( + "SELECT description + FROM pg_description + JOIN pg_class ON pg_description.objoid = pg_class.oid + WHERE relname = '%s'", + 'table1' + ) + ); + $this->assertEmpty($rows); + } + + public function testRenameTable() + { + $table = new Table('table1', [], $this->adapter); + $table->save(); + $this->assertTrue($this->adapter->hasTable('table1')); + $this->assertFalse($this->adapter->hasTable('table2')); + + $table->rename('table2')->save(); + $this->assertFalse($this->adapter->hasTable('table1')); + $this->assertTrue($this->adapter->hasTable('table2')); + } + + public function testRenameTableWithSchema() + { + $this->adapter->createSchema('schema1'); + + $table = new Table('schema1.table1', [], $this->adapter); + $table->save(); + $this->assertTrue($this->adapter->hasTable('schema1.table1')); + $this->assertFalse($this->adapter->hasTable('schema1.table2')); + $this->adapter->renameTable('schema1.table1', 'table2'); + $this->assertFalse($this->adapter->hasTable('schema1.table1')); + $this->assertTrue($this->adapter->hasTable('schema1.table2')); + + $this->adapter->dropSchema('schema1'); + } + + public function testAddColumn() + { + $table = new Table('table1', [], $this->adapter); + $table->save(); + $this->assertFalse($table->hasColumn('email')); + $table->addColumn('email', 'string') + ->save(); + $this->assertTrue($table->hasColumn('email')); + } + + public function testAddColumnWithDefaultValue() + { + $table = new Table('table1', [], $this->adapter); + $table->save(); + $table->addColumn('default_zero', 'string', ['default' => 'test']) + ->save(); + $columns = $this->adapter->getColumns('table1'); + foreach ($columns as $column) { + if ($column->getName() === 'default_zero') { + $this->assertEquals('test', $column->getDefault()); + } + } + } + + public function testAddColumnWithDefaultZero() + { + $table = new Table('table1', [], $this->adapter); + $table->save(); + $table->addColumn('default_zero', 'integer', ['default' => 0]) + ->save(); + $columns = $this->adapter->getColumns('table1'); + foreach ($columns as $column) { + if ($column->getName() === 'default_zero') { + $this->assertNotNull($column->getDefault()); + $this->assertEquals('0', $column->getDefault()); + } + } + } + + public function testAddColumnWithAutoIdentity() + { + if (!$this->usingPostgres10()) { + $this->markTestSkipped('Test Skipped because of PostgreSQL version is < 10.0'); + } + $table = new Table('table1', [], $this->adapter); + $table->save(); + $columns = $this->adapter->getColumns('table1'); + foreach ($columns as $column) { + if ($column->getName() === 'id') { + $this->assertTrue($column->getIdentity()); + $this->assertEquals(PostgresAdapter::GENERATED_BY_DEFAULT, $column->getGenerated()); + } + } + } + + public static function providerAddColumnIdentity(): array + { + return [ + [PostgresAdapter::GENERATED_ALWAYS, true], //testAddColumnWithIdentityAlways + [PostgresAdapter::GENERATED_BY_DEFAULT, false], //testAddColumnWithIdentityDefault + [null, true], //testAddColumnWithoutIdentity + ]; + } + + /** + * @dataProvider providerAddColumnIdentity + */ + public function testAddColumnIdentity($generated, $addToColumn) + { + if (!$this->usingPostgres10()) { + $this->markTestSkipped('Test Skipped because of PostgreSQL version is < 10.0'); + } + $table = new Table('table1', ['id' => false], $this->adapter); + $table->save(); + $options = ['identity' => true]; + if ($addToColumn !== false) { + $options['generated'] = $generated; + } + $table->addColumn('id', 'integer', $options) + ->save(); + $columns = $this->adapter->getColumns('table1'); + foreach ($columns as $column) { + if ($column->getName() === 'id') { + $this->assertEquals((bool)$generated, $column->getIdentity()); + $this->assertEquals($generated, $column->getGenerated()); + } + } + } + + public function testAddColumnWithDefaultBoolean() + { + $table = new Table('table1', [], $this->adapter); + $table->save(); + $table->addColumn('default_true', 'boolean', ['default' => true]) + ->addColumn('default_false', 'boolean', ['default' => false]) + ->addColumn('default_null', 'boolean', ['default' => null, 'null' => true]) + ->save(); + $columns = $this->adapter->getColumns('table1'); + foreach ($columns as $column) { + if ($column->getName() === 'default_true') { + $this->assertNotNull($column->getDefault()); + $this->assertEquals('true', $column->getDefault()); + } + if ($column->getName() === 'default_false') { + $this->assertNotNull($column->getDefault()); + $this->assertEquals('false', $column->getDefault()); + } + if ($column->getName() === 'default_null') { + $this->assertNull($column->getDefault()); + } + } + } + + public function testAddColumnWithBooleanIgnoreLimitCastDefault() + { + $table = new Table('table1', [], $this->adapter); + $table->save(); + $table->addColumn('limit_bool_true', 'boolean', [ + 'default' => 1, + 'limit' => 1, + 'null' => false, + ]); + $table->addColumn('limit_bool_false', 'boolean', [ + 'default' => 0, + 'limit' => 0, + 'null' => false, + ]); + $table->save(); + + $columns = $this->adapter->getColumns('table1'); + $this->assertCount(3, $columns); + /** + * @var Column $column + */ + $column = $columns[1]; + $this->assertSame('limit_bool_true', $column->getName()); + $this->assertNotNull($column->getDefault()); + $this->assertSame('true', $column->getDefault()); + $this->assertNull($column->getLimit()); + + $column = $columns[2]; + $this->assertSame('limit_bool_false', $column->getName()); + $this->assertNotNull($column->getDefault()); + $this->assertSame('false', $column->getDefault()); + $this->assertNull($column->getLimit()); + } + + public static function providerIgnoresLimit(): array + { + return [ + [AbstractAdapter::PHINX_TYPE_TINY_INTEGER, AbstractAdapter::PHINX_TYPE_SMALL_INTEGER], + [AbstractAdapter::PHINX_TYPE_SMALL_INTEGER], + [AbstractAdapter::PHINX_TYPE_INTEGER], + [AbstractAdapter::PHINX_TYPE_BIG_INTEGER], + [AbstractAdapter::PHINX_TYPE_BOOLEAN], + [AbstractAdapter::PHINX_TYPE_TEXT], + [AbstractAdapter::PHINX_TYPE_BINARY], + ]; + } + + /** + * @dataProvider providerIgnoresLimit + */ + public function testAddColumnIgnoresLimit(string $column_type, ?string $actual_type = null): void + { + $table = new Table('table1', [], $this->adapter); + $table->save(); + $table->addColumn('column1', $column_type, ['limit' => 1]); + $table->save(); + + $columns = $this->adapter->getColumns('table1'); + $this->assertCount(2, $columns); + $column = $columns[1]; + $this->assertSame('column1', $column->getName()); + $this->assertSame($actual_type ?? $column_type, $column->getType()); + $this->assertNull($column->getLimit()); + } + + public function testAddColumnWithDefaultLiteral() + { + $table = new Table('table1', [], $this->adapter); + $table->save(); + $table->addColumn('default_ts', 'timestamp', ['default' => Literal::from('now()')]) + ->save(); + $columns = $this->adapter->getColumns('table1'); + foreach ($columns as $column) { + if ($column->getName() === 'default_ts') { + $this->assertNotNull($column->getDefault()); + $this->assertEquals('now()', (string)$column->getDefault()); + } + } + } + + public function testAddColumnWithLiteralType() + { + $table = new Table('citable', ['id' => false], $this->adapter); + $table + ->addColumn('insensitive', Literal::from('citext')) + ->save(); + + $this->assertTrue($this->adapter->hasColumn('citable', 'insensitive')); + + /** @var Column[] $columns */ + $columns = $this->adapter->getColumns('citable'); + foreach ($columns as $column) { + if ($column->getName() === 'insensitive') { + $this->assertEquals( + 'citext', + (string)$column->getType(), + 'column: ' . $column->getName() + ); + } + } + } + + public function testAddColumnWithComment() + { + $table = new Table('table1', [], $this->adapter); + $table->save(); + + $this->assertFalse($table->hasColumn('email')); + + $table->addColumn('email', 'string', ['comment' => $comment = 'Comments from column "email"']) + ->save(); + + $this->assertTrue($table->hasColumn('email')); + + $row = $this->adapter->fetchRow( + 'SELECT + (select pg_catalog.col_description(oid,cols.ordinal_position::int) + from pg_catalog.pg_class c + where c.relname=cols.table_name ) as column_comment + FROM information_schema.columns cols + WHERE cols.table_catalog=\'' . $this->config['database'] . '\' + AND cols.table_name=\'table1\' + AND cols.column_name = \'email\'' + ); + + $this->assertEquals( + $comment, + $row['column_comment'], + 'The column comment was not set when you used addColumn()' + ); + } + + public function testAddStringWithLimit() + { + $table = new Table('table1', [], $this->adapter); + $table->save(); + $table->addColumn('string1', 'string', ['limit' => 10]) + ->addColumn('char1', 'char', ['limit' => 20]) + ->save(); + $columns = $this->adapter->getColumns('table1'); + foreach ($columns as $column) { + if ($column->getName() === 'string1') { + $this->assertEquals('10', $column->getLimit()); + } + + if ($column->getName() === 'char1') { + $this->assertEquals('20', $column->getLimit()); + } + } + } + + public function testAddDecimalWithPrecisionAndScale() + { + $table = new Table('table1', [], $this->adapter); + $table->save(); + $table->addColumn('number', 'decimal', ['precision' => 10, 'scale' => 2]) + ->addColumn('number2', 'decimal', ['limit' => 12]) + ->addColumn('number3', 'decimal') + ->save(); + $columns = $this->adapter->getColumns('table1'); + foreach ($columns as $column) { + if ($column->getName() === 'number') { + $this->assertEquals('10', $column->getPrecision()); + $this->assertEquals('2', $column->getScale()); + } + + if ($column->getName() === 'number2') { + $this->assertEquals('12', $column->getPrecision()); + $this->assertEquals('0', $column->getScale()); + } + } + } + + public function testAddTimestampWithPrecision() + { + $table = new Table('table1', [], $this->adapter); + $table->save(); + $table->addColumn('timestamp1', 'timestamp', ['precision' => 0]) + ->addColumn('timestamp2', 'timestamp', ['precision' => 4]) + ->addColumn('timestamp3', 'timestamp') + ->save(); + $columns = $this->adapter->getColumns('table1'); + foreach ($columns as $column) { + if ($column->getName() === 'timestamp1') { + $this->assertEquals('0', $column->getPrecision()); + } + + if ($column->getName() === 'timestamp2') { + $this->assertEquals('4', $column->getPrecision()); + } + + if ($column->getName() === 'timestamp3') { + $this->assertEquals('6', $column->getPrecision()); + } + } + } + + public static function providerArrayType() + { + return [ + ['array_text', 'text[]'], + ['array_char', 'char[]'], + ['array_integer', 'integer[]'], + ['array_float', 'float[]'], + ['array_decimal', 'decimal[]'], + ['array_timestamp', 'timestamp[]'], + ['array_time', 'time[]'], + ['array_date', 'date[]'], + ['array_boolean', 'boolean[]'], + ['array_json', 'json[]'], + ['array_json2d', 'json[][]'], + ['array_json3d', 'json[][][]'], + ['array_uuid', 'uuid[]'], + ['array_interval', 'interval[]'], + ]; + } + + /** + * @dataProvider providerArrayType + */ + public function testAddColumnArrayType($column_name, $column_type) + { + $table = new Table('table1', [], $this->adapter); + $table->save(); + $this->assertFalse($table->hasColumn($column_name)); + $table->addColumn($column_name, $column_type) + ->save(); + $this->assertTrue($table->hasColumn($column_name)); + } + + public function testRenameColumn() + { + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'string') + ->save(); + $this->assertTrue($this->adapter->hasColumn('t', 'column1')); + $this->assertFalse($this->adapter->hasColumn('t', 'column2')); + $this->adapter->renameColumn('t', 'column1', 'column2'); + $this->assertFalse($this->adapter->hasColumn('t', 'column1')); + $this->assertTrue($this->adapter->hasColumn('t', 'column2')); + } + + public function testRenameColumnIsCaseSensitive() + { + $table = new Table('t', [], $this->adapter); + $table->addColumn('columnOne', 'string') + ->save(); + $this->assertTrue($this->adapter->hasColumn('t', 'columnOne')); + $this->assertFalse($this->adapter->hasColumn('t', 'columnTwo')); + $this->adapter->renameColumn('t', 'columnOne', 'columnTwo'); + $this->assertFalse($this->adapter->hasColumn('t', 'columnOne')); + $this->assertTrue($this->adapter->hasColumn('t', 'columnTwo')); + } + + public function testRenamingANonExistentColumn() + { + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'string') + ->save(); + + try { + $this->adapter->renameColumn('t', 'column2', 'column1'); + $this->fail('Expected the adapter to throw an exception'); + } catch (InvalidArgumentException $e) { + $this->assertInstanceOf( + 'InvalidArgumentException', + $e, + 'Expected exception of type InvalidArgumentException, got ' . get_class($e) + ); + $this->assertEquals('The specified column does not exist: column2', $e->getMessage()); + } + } + + public function testChangeColumn() + { + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'string') + ->save(); + $this->assertTrue($this->adapter->hasColumn('t', 'column1')); + $table->changeColumn('column1', 'string')->save(); + $this->assertTrue($this->adapter->hasColumn('t', 'column1')); + + $newColumn2 = new Column(); + $newColumn2->setName('column2') + ->setType('string'); + $table->changeColumn('column1', $newColumn2)->save(); + $this->assertFalse($this->adapter->hasColumn('t', 'column1')); + $this->assertTrue($this->adapter->hasColumn('t', 'column2')); + } + + public static function providerChangeColumnIdentity(): array + { + return [ + [PostgresAdapter::GENERATED_ALWAYS], //testChangeColumnAddIdentityAlways + [PostgresAdapter::GENERATED_BY_DEFAULT], //testChangeColumnAddIdentityDefault + ]; + } + + /** + * @dataProvider providerChangeColumnIdentity + */ + public function testChangeColumnIdentity($generated) + { + if (!$this->usingPostgres10()) { + $this->markTestSkipped('Test Skipped because of PostgreSQL version is < 10.0'); + } + $table = new Table('table1', [], $this->adapter); + $table->addColumn('column1', 'integer'); + $table->save(); + + $table->changeColumn('column1', 'integer', ['identity' => true, 'generated' => PostgresAdapter::GENERATED_ALWAYS]); + $table->save(); + $columns = $this->adapter->getColumns('table1'); + foreach ($columns as $column) { + if ($column->getName() === 'column1') { + $this->assertTrue($column->getIdentity()); + $this->assertEquals(PostgresAdapter::GENERATED_ALWAYS, $column->getGenerated()); + } + } + } + + public function testChangeColumnDropIdentity() + { + if (!$this->usingPostgres10()) { + $this->markTestSkipped('Test Skipped because of PostgreSQL version is < 10.0'); + } + $table = new Table('table1', [], $this->adapter); + $table->save(); + $table->changeColumn('id', 'integer', ['identity' => false]); + $table->save(); + $columns = $this->adapter->getColumns('table1'); + foreach ($columns as $column) { + if ($column->getName() === 'id') { + $this->assertFalse($column->getIdentity()); + } + } + } + + public function testChangeColumnChangeIdentity() + { + if (!$this->usingPostgres10()) { + $this->markTestSkipped('Test Skipped because of PostgreSQL version is < 10.0'); + } + $table = new Table('table1', [], $this->adapter); + $table->save(); + $table->changeColumn('id', 'integer', ['identity' => true, 'generated' => PostgresAdapter::GENERATED_BY_DEFAULT]); + $table->save(); + $columns = $this->adapter->getColumns('table1'); + foreach ($columns as $column) { + if ($column->getName() === 'id') { + $this->assertTrue($column->getIdentity()); + $this->assertEquals(PostgresAdapter::GENERATED_BY_DEFAULT, $column->getGenerated()); + } + } + } + + public static function integersProvider() + { + return [ + ['smallinteger', 32767], + ['integer', 2147483647], + ['biginteger', 9223372036854775807], + ]; + } + + /** + * @dataProvider integersProvider + */ + public function testChangeColumnFromTextToInteger($type, $value) + { + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'text') + ->insert(['column1' => (string)$value]) + ->save(); + + $table->changeColumn('column1', $type)->save(); + $columnType = $table->getColumn('column1')->getType(); + $this->assertSame($columnType, $type); + + $row = $this->adapter->fetchRow('SELECT * FROM t'); + $this->assertSame($value, $row['column1']); + } + + public function testChangeBooleanOptions() + { + $table = new Table('t', ['id' => false], $this->adapter); + $table->addColumn('my_bool', 'boolean', ['default' => true, 'null' => true]) + ->create(); + $table + ->insert([ + ['my_bool' => true], + ['my_bool' => false], + ['my_bool' => null], + ]) + ->update(); + $table->changeColumn('my_bool', 'boolean', ['default' => false, 'null' => true])->update(); + $columns = $this->adapter->getColumns('t'); + $this->assertStringContainsString('false', $columns[0]->getDefault()); + + $rows = $this->adapter->fetchAll('SELECT * FROM t'); + $this->assertCount(3, $rows); + $this->assertSame([true, false, null], array_map(function ($row) { + return $row['my_bool']; + }, $rows)); + } + + public function testChangeColumnFromIntegerToBoolean() + { + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'integer', ['default' => 0]) + ->save(); + $table->changeColumn('column1', 'boolean', ['default' => 't', 'null' => true]) + ->save(); + $columns = $this->adapter->getColumns('t'); + foreach ($columns as $column) { + if ($column->getName() === 'column1') { + $this->assertTrue($column->isNull()); + $this->assertStringContainsString('true', $column->getDefault()); + } + } + } + + public function testChangeColumnCharToUuid() + { + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'char', ['default' => null, 'limit' => 36]) + ->save(); + $table->changeColumn('column1', 'uuid', ['default' => null, 'null' => true]) + ->save(); + $columns = $this->adapter->getColumns('t'); + foreach ($columns as $column) { + if ($column->getName() === 'column1') { + $this->assertTrue($column->isNull()); + $this->assertNull($column->getDefault()); + $columnType = $table->getColumn('column1')->getType(); + $this->assertSame($columnType, 'uuid'); + } + } + } + + public function testChangeColumnWithDefault() + { + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'string') + ->save(); + + $newColumn1 = new Column(); + $newColumn1->setName('column1') + ->setType('string') + ->setNull(true); + + $newColumn1->setDefault('Test'); + $table->changeColumn('column1', $newColumn1)->save(); + + $columns = $this->adapter->getColumns('t'); + foreach ($columns as $column) { + if ($column->getName() === 'column1') { + $this->assertTrue($column->isNull()); + $this->assertStringContainsString('Test', $column->getDefault()); + } + } + } + + public function testChangeColumnWithDropDefault() + { + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'string', ['default' => 'Test']) + ->save(); + + $columns = $this->adapter->getColumns('t'); + foreach ($columns as $column) { + if ($column->getName() === 'column1') { + $this->assertStringContainsString('Test', $column->getDefault()); + } + } + + $newColumn1 = new Column(); + $newColumn1->setName('column1') + ->setType('string'); + + $table->changeColumn('column1', $newColumn1)->save(); + + $columns = $this->adapter->getColumns('t'); + foreach ($columns as $column) { + if ($column->getName() === 'column1') { + $this->assertNull($column->getDefault()); + } + } + } + + public function testDropColumn() + { + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'string') + ->save(); + $this->assertTrue($this->adapter->hasColumn('t', 'column1')); + + $table->removeColumn('column1')->save(); + $this->assertFalse($this->adapter->hasColumn('t', 'column1')); + } + + public static function columnsProvider() + { + return [ + ['column1', 'string', []], + ['column2', 'smallinteger', []], + ['column2_1', 'integer', []], + ['column3', 'biginteger', []], + ['column4', 'text', []], + ['column5', 'float', []], + ['column6', 'decimal', []], + ['column7', 'datetime', []], + ['column8', 'time', []], + ['column9', 'timestamp', [], 'datetime'], + ['column10', 'date', []], + ['column11', 'binary', []], + ['column12', 'boolean', []], + ['column13', 'string', ['limit' => 10]], + ['column16', 'interval', []], + ['decimal_precision_scale', 'decimal', ['precision' => 10, 'scale' => 2]], + ['decimal_limit', 'decimal', ['limit' => 10]], + ['decimal_precision', 'decimal', ['precision' => 10]], + ]; + } + + /** + * @dataProvider columnsProvider + */ + public function testGetColumns($colName, $type, $options, $actualType = null) + { + $table = new Table('t', [], $this->adapter); + $table->addColumn($colName, $type, $options)->save(); + + $columns = $this->adapter->getColumns('t'); + $this->assertCount(2, $columns); + $this->assertEquals($colName, $columns[1]->getName()); + + if (!$actualType) { + $actualType = $type; + } + + if (is_string($columns[1]->getType())) { + $this->assertEquals($actualType, $columns[1]->getType()); + } else { + $this->assertEquals(['name' => $actualType] + $options, $columns[1]->getType()); + } + } + + /** + * @dataProvider columnsProvider + */ + public function testGetColumnsWithSchema($colName, $type, $options, $actualType = null) + { + $this->adapter->createSchema('tschema'); + + $table = new Table('tschema.t', [], $this->adapter); + $table->addColumn($colName, $type, $options)->save(); + + $columns = $this->adapter->getColumns('tschema.t'); + $this->assertCount(2, $columns); + $this->assertEquals($colName, $columns[1]->getName()); + + if (!$actualType) { + $actualType = $type; + } + + if (is_string($columns[1]->getType())) { + $this->assertEquals($actualType, $columns[1]->getType()); + } else { + $this->assertEquals(['name' => $actualType] + $options, $columns[1]->getType()); + } + + $this->adapter->dropSchema('tschema'); + } + + public function testAddIndex() + { + $table = new Table('table1', [], $this->adapter); + $table->addColumn('email', 'string') + ->save(); + $this->assertFalse($table->hasIndex('email')); + $table->addIndex('email') + ->save(); + $this->assertTrue($table->hasIndex('email')); + } + + public function testAddIndexWithSort() + { + $table = new Table('table1', [], $this->adapter); + $table->addColumn('email', 'string') + ->addColumn('username', 'string') + ->save(); + $this->assertFalse($table->hasIndexByName('table1_email_username')); + $table->addIndex(['email', 'username'], ['name' => 'table1_email_username', 'order' => ['email' => 'DESC', 'username' => 'ASC']]) + ->save(); + $this->assertTrue($table->hasIndexByName('table1_email_username')); + $rows = $this->adapter->fetchAll("SELECT CASE o.option & 1 WHEN 1 THEN 'DESC' ELSE 'ASC' END as sort_order + FROM pg_index AS i + JOIN pg_class AS trel ON trel.oid = i.indrelid + JOIN pg_namespace AS tnsp ON trel.relnamespace = tnsp.oid + JOIN pg_class AS irel ON irel.oid = i.indexrelid + CROSS JOIN LATERAL unnest (i.indkey) WITH ORDINALITY AS c (colnum, ordinality) + LEFT JOIN LATERAL unnest (i.indoption) WITH ORDINALITY AS o (option, ordinality) + ON c.ordinality = o.ordinality + JOIN pg_attribute AS a ON trel.oid = a.attrelid AND a.attnum = c.colnum + WHERE trel.relname = 'table1' + AND irel.relname = 'table1_email_username' + AND a.attname = 'email' + GROUP BY o.option, tnsp.nspname, trel.relname, irel.relname"); + $emailOrder = $rows[0]; + $this->assertEquals($emailOrder['sort_order'], 'DESC'); + $rows = $this->adapter->fetchAll("SELECT CASE o.option & 1 WHEN 1 THEN 'DESC' ELSE 'ASC' END as sort_order + FROM pg_index AS i + JOIN pg_class AS trel ON trel.oid = i.indrelid + JOIN pg_namespace AS tnsp ON trel.relnamespace = tnsp.oid + JOIN pg_class AS irel ON irel.oid = i.indexrelid + CROSS JOIN LATERAL unnest (i.indkey) WITH ORDINALITY AS c (colnum, ordinality) + LEFT JOIN LATERAL unnest (i.indoption) WITH ORDINALITY AS o (option, ordinality) + ON c.ordinality = o.ordinality + JOIN pg_attribute AS a ON trel.oid = a.attrelid AND a.attnum = c.colnum + WHERE trel.relname = 'table1' + AND irel.relname = 'table1_email_username' + AND a.attname = 'username' + GROUP BY o.option, tnsp.nspname, trel.relname, irel.relname"); + $emailOrder = $rows[0]; + $this->assertEquals($emailOrder['sort_order'], 'ASC'); + } + + public function testAddIndexWithIncludeColumns() + { + if (!version_compare($this->adapter->fetchAll('SHOW server_version;')[0]['server_version'], '11.0.0', '>=')) { + $this->markTestSkipped('Cannot test index include collumns (non-key columns) on postgresql versions less than 11'); + } + + $table = new Table('table1', [], $this->adapter); + $table->addColumn('email', 'string') + ->addColumn('firstname', 'string') + ->addColumn('lastname', 'string') + ->save(); + $this->assertFalse($table->hasIndexByName('table1_include_idx')); + $table->addIndex(['email'], ['name' => 'table1_include_idx', 'include' => ['firstname', 'lastname']]) + ->save(); + $this->assertTrue($table->hasIndexByName('table1_include_idx')); + $rows = $this->adapter->fetchAll("SELECT CASE WHEN attnum <= indnkeyatts THEN 'KEY' ELSE 'INCLUDED' END as index_column + FROM pg_index ix + JOIN pg_class t ON ix.indrelid = t.oid + JOIN pg_class i ON ix.indexrelid = i.oid + JOIN pg_attribute a ON i.oid = a.attrelid + JOIN pg_namespace nsp ON t.relnamespace = nsp.oid + WHERE nsp.nspname = 'public' + AND t.relkind = 'r' + AND t.relname = 'table1' + AND a.attname = 'email'"); + $indexColumn = $rows[0]; + $this->assertEquals($indexColumn['index_column'], 'KEY'); + $rows = $this->adapter->fetchAll("SELECT CASE WHEN attnum <= indnkeyatts THEN 'KEY' ELSE 'INCLUDED' END as index_column + FROM pg_index ix + JOIN pg_class t ON ix.indrelid = t.oid + JOIN pg_class i ON ix.indexrelid = i.oid + JOIN pg_attribute a ON i.oid = a.attrelid + JOIN pg_namespace nsp ON t.relnamespace = nsp.oid + WHERE nsp.nspname = 'public' + AND t.relkind = 'r' + AND t.relname = 'table1' + AND a.attname = 'firstname'"); + $indexColumn = $rows[0]; + $this->assertEquals($indexColumn['index_column'], 'INCLUDED'); + $rows = $this->adapter->fetchAll("SELECT CASE WHEN attnum <= indnkeyatts THEN 'KEY' ELSE 'INCLUDED' END as index_column + FROM pg_index ix + JOIN pg_class t ON ix.indrelid = t.oid + JOIN pg_class i ON ix.indexrelid = i.oid + JOIN pg_attribute a ON i.oid = a.attrelid + JOIN pg_namespace nsp ON t.relnamespace = nsp.oid + WHERE nsp.nspname = 'public' + AND t.relkind = 'r' + AND t.relname = 'table1' + AND a.attname = 'lastname'"); + $indexColumn = $rows[0]; + $this->assertEquals($indexColumn['index_column'], 'INCLUDED'); + } + + public function testAddIndexWithSchema() + { + $this->adapter->createSchema('schema1'); + + $table = new Table('schema1.table1', [], $this->adapter); + $table->addColumn('email', 'string') + ->save(); + $this->assertFalse($table->hasIndex('email')); + $table->addIndex('email') + ->save(); + $this->assertTrue($table->hasIndex('email')); + + $this->adapter->dropSchema('schema1'); + } + + public function testAddIndexWithNameWithSchema() + { + $this->adapter->createSchema('schema1'); + + $table = new Table('schema1.table1', [], $this->adapter); + $table->addColumn('email', 'string') + ->save(); + $this->assertFalse($table->hasIndex('email')); + $table->addIndex('email', ['name' => 'indexEmail']) + ->save(); + $this->assertTrue($table->hasIndex('email')); + + $this->adapter->dropSchema('schema1'); + } + + public function testAddIndexIsCaseSensitive() + { + $table = new Table('table1', [], $this->adapter); + $table->addColumn('theEmail', 'string') + ->save(); + $this->assertFalse($table->hasIndex('theEmail')); + $table->addIndex('theEmail') + ->save(); + $this->assertTrue($table->hasIndex('theEmail')); + } + + public function testDropIndex() + { + // single column index + $table = new Table('table1', [], $this->adapter); + $table->addColumn('email', 'string') + ->addIndex('email') + ->save(); + $this->assertTrue($table->hasIndex('email')); + $this->adapter->dropIndex($table->getName(), 'email'); + $this->assertFalse($table->hasIndex('email')); + + // multiple column index + $table2 = new Table('table2', [], $this->adapter); + $table2->addColumn('fname', 'string') + ->addColumn('lname', 'string') + ->addIndex(['fname', 'lname']) + ->save(); + $this->assertTrue($table2->hasIndex(['fname', 'lname'])); + $this->adapter->dropIndex($table2->getName(), ['fname', 'lname']); + $this->assertFalse($table2->hasIndex(['fname', 'lname'])); + + // index with name specified, but dropping it by column name + $table3 = new Table('table3', [], $this->adapter); + $table3->addColumn('email', 'string') + ->addIndex('email', ['name' => 'someindexname']) + ->save(); + $this->assertTrue($table3->hasIndex('email')); + $this->adapter->dropIndex($table3->getName(), 'email'); + $this->assertFalse($table3->hasIndex('email')); + + // multiple column index with name specified + $table4 = new Table('table4', [], $this->adapter); + $table4->addColumn('fname', 'string') + ->addColumn('lname', 'string') + ->addIndex(['fname', 'lname'], ['name' => 'multiname']) + ->save(); + $this->assertTrue($table4->hasIndex(['fname', 'lname'])); + $this->adapter->dropIndex($table4->getName(), ['fname', 'lname']); + $this->assertFalse($table4->hasIndex(['fname', 'lname'])); + } + + public function testDropIndexWithSchema() + { + $this->adapter->createSchema('schema1'); + + // single column index + $table = new Table('schema1.table5', [], $this->adapter); + $table->addColumn('email', 'string') + ->addIndex('email') + ->save(); + $this->assertTrue($table->hasIndex('email')); + $this->adapter->dropIndex($table->getName(), 'email'); + $this->assertFalse($table->hasIndex('email')); + + // multiple column index + $table2 = new Table('schema1.table6', [], $this->adapter); + $table2->addColumn('fname', 'string') + ->addColumn('lname', 'string') + ->addIndex(['fname', 'lname']) + ->save(); + $this->assertTrue($table2->hasIndex(['fname', 'lname'])); + $this->adapter->dropIndex($table2->getName(), ['fname', 'lname']); + $this->assertFalse($table2->hasIndex(['fname', 'lname'])); + + // index with name specified, but dropping it by column name + $table3 = new Table('schema1.table7', [], $this->adapter); + $table3->addColumn('email', 'string') + ->addIndex('email', ['name' => 'someIndexName']) + ->save(); + $this->assertTrue($table3->hasIndex('email')); + $this->adapter->dropIndex($table3->getName(), 'email'); + $this->assertFalse($table3->hasIndex('email')); + + // multiple column index with name specified + $table4 = new Table('schema1.table8', [], $this->adapter); + $table4->addColumn('fname', 'string') + ->addColumn('lname', 'string') + ->addIndex(['fname', 'lname'], ['name' => 'multiname']) + ->save(); + $this->assertTrue($table4->hasIndex(['fname', 'lname'])); + $this->adapter->dropIndex($table4->getName(), ['fname', 'lname']); + $this->assertFalse($table4->hasIndex(['fname', 'lname'])); + + $this->adapter->dropSchema('schema1'); + } + + public function testDropIndexByName() + { + // single column index + $table = new Table('table1', [], $this->adapter); + $table->addColumn('email', 'string') + ->addIndex('email', ['name' => 'myemailindex']) + ->save(); + $this->assertTrue($table->hasIndex('email')); + $this->adapter->dropIndexByName($table->getName(), 'myemailindex'); + $this->assertFalse($table->hasIndex('email')); + + // multiple column index + $table2 = new Table('table2', [], $this->adapter); + $table2->addColumn('fname', 'string') + ->addColumn('lname', 'string') + ->addIndex( + ['fname', 'lname'], + ['name' => 'twocolumnuniqueindex', 'unique' => true] + ) + ->save(); + $this->assertTrue($table2->hasIndex(['fname', 'lname'])); + $this->adapter->dropIndexByName($table2->getName(), 'twocolumnuniqueindex'); + $this->assertFalse($table2->hasIndex(['fname', 'lname'])); + } + + public function testDropIndexByNameWithSchema() + { + $this->adapter->createSchema('schema1'); + + // single column index + $table = new Table('schema1.Table1', [], $this->adapter); + $table->addColumn('email', 'string') + ->addIndex('email', ['name' => 'myemailIndex']) + ->save(); + $this->assertTrue($table->hasIndex('email')); + $this->adapter->dropIndexByName($table->getName(), 'myemailIndex'); + $this->assertFalse($table->hasIndex('email')); + + // multiple column index + $table2 = new Table('schema1.table2', [], $this->adapter); + $table2->addColumn('fname', 'string') + ->addColumn('lname', 'string') + ->addIndex( + ['fname', 'lname'], + ['name' => 'twocolumnuniqueindex', 'unique' => true] + ) + ->save(); + $this->assertTrue($table2->hasIndex(['fname', 'lname'])); + $this->adapter->dropIndexByName($table2->getName(), 'twocolumnuniqueindex'); + $this->assertFalse($table2->hasIndex(['fname', 'lname'])); + + $this->adapter->dropSchema('schema1'); + } + + public function testAddForeignKey() + { + $refTable = new Table('ref_table', [], $this->adapter); + $refTable->addColumn('field1', 'string')->save(); + + $table = new Table('table', [], $this->adapter); + $table + ->addColumn('ref_table_id', 'integer') + ->addForeignKey(['ref_table_id'], 'ref_table', ['id']) + ->save(); + + $this->assertTrue($this->adapter->hasForeignKey($table->getName(), ['ref_table_id'])); + } + + public function testAddForeignKeyWithSchema() + { + $this->adapter->createSchema('schema1'); + $this->adapter->createSchema('schema2'); + + $refTable = new Table('schema1.ref_table', [], $this->adapter); + $refTable->addColumn('field1', 'string')->save(); + + $table = new Table('schema2.table', [], $this->adapter); + $table + ->addColumn('ref_table_id', 'integer') + ->addForeignKey(['ref_table_id'], 'schema1.ref_table', ['id']) + ->save(); + + $this->assertTrue($this->adapter->hasForeignKey($table->getName(), ['ref_table_id'])); + + $this->adapter->dropSchema('schema1'); + $this->adapter->dropSchema('schema2'); + } + + public function testDropForeignKey() + { + $refTable = new Table('ref_table', [], $this->adapter); + $refTable->addColumn('field1', 'string')->save(); + + $table = new Table('table', [], $this->adapter); + $table + ->addColumn('ref_table_id', 'integer') + ->addForeignKey(['ref_table_id'], 'ref_table', ['id']) + ->save(); + + $table->dropForeignKey(['ref_table_id'])->save(); + $this->assertFalse($this->adapter->hasForeignKey($table->getName(), ['ref_table_id'])); + } + + public function testDropForeignKeyWithMultipleColumns() + { + $refTable = new Table('ref_table', [], $this->adapter); + $refTable + ->addColumn('field1', 'string') + ->addColumn('field2', 'string') + ->addIndex(['id', 'field1'], ['unique' => true]) + ->addIndex(['field1', 'id'], ['unique' => true]) + ->addIndex(['id', 'field1', 'field2'], ['unique' => true]) + ->save(); + + $table = new Table('table', [], $this->adapter); + $table + ->addColumn('ref_table_id', 'integer') + ->addColumn('ref_table_field1', 'string') + ->addColumn('ref_table_field2', 'string') + ->addForeignKey( + ['ref_table_id', 'ref_table_field1'], + 'ref_table', + ['id', 'field1'] + ) + ->addForeignKey( + ['ref_table_field1', 'ref_table_id'], + 'ref_table', + ['field1', 'id'] + ) + ->addForeignKey( + ['ref_table_id', 'ref_table_field1', 'ref_table_field2'], + 'ref_table', + ['id', 'field1', 'field2'] + ) + ->save(); + + $this->assertTrue($this->adapter->hasForeignKey($table->getName(), ['ref_table_id', 'ref_table_field1'])); + $this->adapter->dropForeignKey($table->getName(), ['ref_table_id', 'ref_table_field1']); + $this->assertFalse($this->adapter->hasForeignKey($table->getName(), ['ref_table_id', 'ref_table_field1'])); + $this->assertTrue( + $this->adapter->hasForeignKey($table->getName(), ['ref_table_id', 'ref_table_field1', 'ref_table_field2']), + 'dropForeignKey() should only affect foreign keys that comprise of exactly the given columns' + ); + $this->assertTrue( + $this->adapter->hasForeignKey($table->getName(), ['ref_table_field1', 'ref_table_id']), + 'dropForeignKey() should only affect foreign keys that comprise of columns in exactly the given order' + ); + + $this->assertTrue($this->adapter->hasForeignKey($table->getName(), ['ref_table_field1', 'ref_table_id'])); + $this->adapter->dropForeignKey($table->getName(), ['ref_table_field1', 'ref_table_id']); + $this->assertFalse($this->adapter->hasForeignKey($table->getName(), ['ref_table_field1', 'ref_table_id'])); + } + + public function testDropForeignKeyWithIdenticalMultipleColumns() + { + $refTable = new Table('ref_table', [], $this->adapter); + $refTable + ->addColumn('field1', 'string') + ->addIndex(['id', 'field1'], ['unique' => true]) + ->save(); + + $table = new Table('table', [], $this->adapter); + $table + ->addColumn('ref_table_id', 'integer', ['signed' => false]) + ->addColumn('ref_table_field1', 'string') + ->addForeignKeyWithName( + 'ref_table_fk_1', + ['ref_table_id', 'ref_table_field1'], + 'ref_table', + ['id', 'field1'], + ) + ->addForeignKeyWithName( + 'ref_table_fk_2', + ['ref_table_id', 'ref_table_field1'], + 'ref_table', + ['id', 'field1'] + ) + ->save(); + + $this->assertTrue($this->adapter->hasForeignKey($table->getName(), ['ref_table_id', 'ref_table_field1'])); + $this->assertTrue($this->adapter->hasForeignKey($table->getName(), [], 'ref_table_fk_1')); + $this->assertTrue($this->adapter->hasForeignKey($table->getName(), [], 'ref_table_fk_2')); + + $this->adapter->dropForeignKey($table->getName(), ['ref_table_id', 'ref_table_field1']); + + $this->assertFalse($this->adapter->hasForeignKey($table->getName(), ['ref_table_id', 'ref_table_field1'])); + $this->assertFalse($this->adapter->hasForeignKey($table->getName(), [], 'ref_table_fk_1')); + $this->assertFalse($this->adapter->hasForeignKey($table->getName(), [], 'ref_table_fk_2')); + } + + public static function nonExistentForeignKeyColumnsProvider(): array + { + return [ + [['ref_table_id']], + [['ref_table_field1']], + [['ref_table_field1', 'ref_table_id']], + [['non_existent_column']], + ]; + } + + /** + * @dataProvider nonExistentForeignKeyColumnsProvider + * @param array $columns + */ + public function testDropForeignKeyByNonExistentKeyColumns(array $columns) + { + $refTable = new Table('ref_table', [], $this->adapter); + $refTable + ->addColumn('field1', 'string') + ->addIndex(['id', 'field1'], ['unique' => true]) + ->save(); + + $table = new Table('table', [], $this->adapter); + $table + ->addColumn('ref_table_id', 'integer') + ->addColumn('ref_table_field1', 'string') + ->addForeignKey( + ['ref_table_id', 'ref_table_field1'], + 'ref_table', + ['id', 'field1'] + ) + ->save(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage(sprintf( + 'No foreign key on column(s) `%s` exists', + implode(', ', $columns) + )); + + $this->adapter->dropForeignKey($table->getName(), $columns); + } + + public function testDropForeignKeyCaseSensitivity() + { + $refTable = new Table('ref_table', [], $this->adapter); + $refTable->save(); + + $table = new Table('table', [], $this->adapter); + $table + ->addColumn('REF_TABLE_ID', 'integer') + ->addForeignKey(['REF_TABLE_ID'], 'ref_table', ['id']) + ->save(); + + $this->assertTrue($this->adapter->hasForeignKey($table->getName(), ['REF_TABLE_ID'])); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage(sprintf( + 'No foreign key on column(s) `%s` exists', + implode(', ', ['ref_table_id']) + )); + + $this->adapter->dropForeignKey($table->getName(), ['ref_table_id']); + } + + public function testDropForeignKeyByName() + { + $refTable = new Table('ref_table', [], $this->adapter); + $refTable->save(); + + $table = new Table('table', [], $this->adapter); + $table + ->addColumn('ref_table_id', 'integer', ['signed' => false]) + ->addForeignKeyWithName('my_constraint', ['ref_table_id'], 'ref_table', ['id']) + ->save(); + + $this->adapter->dropForeignKey($table->getName(), [], 'my_constraint'); + $this->assertFalse($this->adapter->hasForeignKey($table->getName(), ['ref_table_id'])); + } + + /** + * @dataProvider provideForeignKeysToCheck + */ + public function testHasForeignKey($tableDef, $key, $exp) + { + $conn = $this->adapter->getConnection(); + $conn->execute('CREATE TABLE other(a int, b int, c int, unique(a), unique(b), unique(a,b), unique(a,b,c));'); + $conn->execute($tableDef); + $this->assertSame($exp, $this->adapter->hasForeignKey('t', $key)); + } + + public static function provideForeignKeysToCheck() + { + return [ + ['create table t(a int)', 'a', false], + ['create table t(a int)', [], false], + ['create table t(a int primary key)', 'a', false], + ['create table t(a int, foreign key (a) references other(a))', 'a', true], + ['create table t(a int, foreign key (a) references other(b))', 'a', true], + ['create table t(a int, foreign key (a) references other(b))', ['a'], true], + ['create table t(a int, foreign key (a) references other(b))', ['a', 'a'], false], + ['create table t(a int, foreign key(a) references other(a))', 'a', true], + ['create table t(a int, b int, foreign key(a,b) references other(a,b))', 'a', false], + ['create table t(a int, b int, foreign key(a,b) references other(a,b))', ['a', 'b'], true], + ['create table t(a int, b int, foreign key(a,b) references other(a,b))', ['b', 'a'], false], + ['create table t(a int, "B" int, foreign key(a,"B") references other(a,b))', ['a', 'b'], false], + ['create table t(a int, b int, foreign key(a,b) references other(a,b))', ['a', 'B'], false], + ['create table t(a int, b int, c int, foreign key(a,b,c) references other(a,b,c))', ['a', 'b'], false], + ['create table t(a int, foreign key(a) references other(a))', ['a', 'b'], false], + ['create table t(a int, b int, foreign key(a) references other(a), foreign key(b) references other(b))', ['a', 'b'], false], + ['create table t(a int, b int, foreign key(a) references other(a), foreign key(b) references other(b))', ['a', 'b'], false], + ['create table t("0" int, foreign key("0") references other(a))', '0', true], + ['create table t("0" int, foreign key("0") references other(a))', '0e0', false], + ['create table t("0e0" int, foreign key("0e0") references other(a))', '0', false], + ]; + } + + public function testHasNamedForeignKey() + { + $refTable = new Table('ref_table', [], $this->adapter); + $refTable->save(); + + $table = new Table('table', [], $this->adapter); + $table + ->addColumn('ref_table_id', 'integer') + ->addForeignKeyWithName('my_constraint', ['ref_table_id'], 'ref_table', ['id']) + ->save(); + + $this->assertTrue($this->adapter->hasForeignKey($table->getName(), ['ref_table_id'], 'my_constraint')); + $this->assertFalse($this->adapter->hasForeignKey($table->getName(), ['ref_table_id'], 'my_constraint2')); + + $this->assertTrue($this->adapter->hasForeignKey($table->getName(), [], 'my_constraint')); + $this->assertFalse($this->adapter->hasForeignKey($table->getName(), [], 'my_constraint2')); + } + + public function testDropForeignKeyWithSchema() + { + $this->adapter->createSchema('schema1'); + $this->adapter->createSchema('schema2'); + + $refTable = new Table('schema1.ref_table', [], $this->adapter); + $refTable->addColumn('field1', 'string')->save(); + + $table = new Table('schema2.table', [], $this->adapter); + $table + ->addColumn('ref_table_id', 'integer') + ->addForeignKey(['ref_table_id'], 'schema1.ref_table', ['id']) + ->save(); + + $table->dropForeignKey(['ref_table_id'])->save(); + $this->assertFalse($this->adapter->hasForeignKey($table->getName(), ['ref_table_id'])); + + $this->adapter->dropSchema('schema1'); + $this->adapter->dropSchema('schema2'); + } + + public function testDropForeignKeyNotDroppingPrimaryKey() + { + $refTable = new Table('ref_table', [], $this->adapter); + $refTable->addColumn('field1', 'string')->save(); + + $table = new Table('table', [ + 'id' => false, + 'primary_key' => ['ref_table_id'], + ], $this->adapter); + $table + ->addColumn('ref_table_id', 'integer') + ->addForeignKey(['ref_table_id'], 'ref_table', ['id']) + ->save(); + + $table->dropForeignKey(['ref_table_id'])->save(); + $this->assertTrue($this->adapter->hasIndexByName('table', 'table_pkey')); + } + + public function testHasDatabase() + { + $this->assertFalse($this->adapter->hasDatabase('fake_database_name')); + $this->assertTrue($this->adapter->hasDatabase($this->config['database'])); + } + + public function testDropDatabase() + { + $this->assertFalse($this->adapter->hasDatabase('phinx_temp_database')); + $this->adapter->createDatabase('phinx_temp_database'); + $this->assertTrue($this->adapter->hasDatabase('phinx_temp_database')); + $this->adapter->dropDatabase('phinx_temp_database'); + } + + public function testCreateSchema() + { + $this->adapter->createSchema('foo'); + $this->assertTrue($this->adapter->hasSchema('foo')); + } + + public function testDropSchema() + { + $this->adapter->createSchema('foo'); + $this->assertTrue($this->adapter->hasSchema('foo')); + $this->adapter->dropSchema('foo'); + $this->assertFalse($this->adapter->hasSchema('foo')); + } + + public function testDropAllSchemas() + { + $this->adapter->createSchema('foo'); + $this->adapter->createSchema('bar'); + + $this->assertTrue($this->adapter->hasSchema('foo')); + $this->assertTrue($this->adapter->hasSchema('bar')); + $this->adapter->dropAllSchemas(); + $this->assertFalse($this->adapter->hasSchema('foo')); + $this->assertFalse($this->adapter->hasSchema('bar')); + } + + public function testInvalidSqlType() + { + $this->expectException(UnsupportedColumnTypeException::class); + $this->expectExceptionMessage('Column type `idontexist` is not supported by Postgresql.'); + + $this->adapter->getSqlType('idontexist'); + } + + public function testGetPhinxType() + { + $this->assertEquals('integer', $this->adapter->getPhinxType('int')); + $this->assertEquals('integer', $this->adapter->getPhinxType('int4')); + $this->assertEquals('integer', $this->adapter->getPhinxType('integer')); + + $this->assertEquals('biginteger', $this->adapter->getPhinxType('bigint')); + $this->assertEquals('biginteger', $this->adapter->getPhinxType('int8')); + + $this->assertEquals('decimal', $this->adapter->getPhinxType('decimal')); + $this->assertEquals('decimal', $this->adapter->getPhinxType('numeric')); + + $this->assertEquals('float', $this->adapter->getPhinxType('real')); + $this->assertEquals('float', $this->adapter->getPhinxType('float4')); + + $this->assertEquals('double', $this->adapter->getPhinxType('double precision')); + + $this->assertEquals('boolean', $this->adapter->getPhinxType('bool')); + $this->assertEquals('boolean', $this->adapter->getPhinxType('boolean')); + + $this->assertEquals('string', $this->adapter->getPhinxType('character varying')); + $this->assertEquals('string', $this->adapter->getPhinxType('varchar')); + + $this->assertEquals('text', $this->adapter->getPhinxType('text')); + + $this->assertEquals('time', $this->adapter->getPhinxType('time')); + $this->assertEquals('time', $this->adapter->getPhinxType('timetz')); + $this->assertEquals('time', $this->adapter->getPhinxType('time with time zone')); + $this->assertEquals('time', $this->adapter->getPhinxType('time without time zone')); + + $this->assertEquals('datetime', $this->adapter->getPhinxType('timestamp')); + $this->assertEquals('datetime', $this->adapter->getPhinxType('timestamptz')); + $this->assertEquals('datetime', $this->adapter->getPhinxType('timestamp with time zone')); + $this->assertEquals('datetime', $this->adapter->getPhinxType('timestamp without time zone')); + + $this->assertEquals('uuid', $this->adapter->getPhinxType('uuid')); + + $this->assertEquals('interval', $this->adapter->getPhinxType('interval')); + } + + public function testCreateTableWithComment() + { + $tableComment = 'Table comment'; + $table = new Table('ntable', ['comment' => $tableComment], $this->adapter); + $table->addColumn('realname', 'string') + ->save(); + $this->assertTrue($this->adapter->hasTable('ntable')); + $this->assertTrue($this->adapter->hasColumn('ntable', 'id')); + $this->assertTrue($this->adapter->hasColumn('ntable', 'realname')); + $this->assertFalse($this->adapter->hasColumn('ntable', 'address')); + + $rows = $this->adapter->fetchAll( + sprintf( + 'SELECT description FROM pg_description JOIN pg_class ON pg_description.objoid = ' . + "pg_class.oid WHERE relname = '%s'", + 'ntable' + ) + ); + + $this->assertEquals($tableComment, $rows[0]['description'], 'Dont set table comment correctly'); + } + + public function testCanAddColumnComment() + { + $table = new Table('table1', [], $this->adapter); + $table->addColumn( + 'field1', + 'string', + ['comment' => $comment = 'Comments from column "field1"'] + )->save(); + + $row = $this->adapter->fetchRow( + 'SELECT + (select pg_catalog.col_description(oid,cols.ordinal_position::int) + from pg_catalog.pg_class c + where c.relname=cols.table_name ) as column_comment + FROM information_schema.columns cols + WHERE cols.table_catalog=\'' . $this->config['database'] . '\' + AND cols.table_name=\'table1\' + AND cols.column_name = \'field1\'' + ); + + $this->assertEquals($comment, $row['column_comment'], 'Dont set column comment correctly'); + } + + public function testCanAddCommentForColumnWithReservedName() + { + $table = new Table('user', [], $this->adapter); + $table->addColumn('index', 'string', ['comment' => $comment = 'Comments from column "index"']) + ->save(); + + $row = $this->adapter->fetchRow( + 'SELECT + (select pg_catalog.col_description(oid,cols.ordinal_position::int) + from pg_catalog.pg_class c + where c.relname=cols.table_name ) as column_comment + FROM information_schema.columns cols + WHERE cols.table_catalog=\'' . $this->config['database'] . '\' + AND cols.table_name=\'user\' + AND cols.column_name = \'index\'' + ); + + $this->assertEquals( + $comment, + $row['column_comment'], + 'Dont set column comment correctly for tables or columns with reserved names' + ); + } + + /** + * @depends testCanAddColumnComment + */ + public function testCanChangeColumnComment() + { + $table = new Table('table1', [], $this->adapter); + $table->addColumn('field1', 'string', ['comment' => 'Comments from column "field1"']) + ->save(); + + $table->changeColumn( + 'field1', + 'string', + ['comment' => $comment = 'New Comments from column "field1"'] + )->save(); + + $row = $this->adapter->fetchRow( + 'SELECT + (select pg_catalog.col_description(oid,cols.ordinal_position::int) + from pg_catalog.pg_class c + where c.relname=cols.table_name ) as column_comment + FROM information_schema.columns cols + WHERE cols.table_catalog=\'' . $this->config['database'] . '\' + AND cols.table_name=\'table1\' + AND cols.column_name = \'field1\'' + ); + + $this->assertEquals($comment, $row['column_comment'], 'Dont change column comment correctly'); + } + + /** + * @depends testCanAddColumnComment + */ + public function testCanRemoveColumnComment() + { + $table = new Table('table1', [], $this->adapter); + $table->addColumn('field1', 'string', ['comment' => 'Comments from column "field1"']) + ->save(); + + $table->changeColumn('field1', 'string', ['comment' => 'null']) + ->save(); + + $row = $this->adapter->fetchRow( + 'SELECT + (select pg_catalog.col_description(oid,cols.ordinal_position::int) + from pg_catalog.pg_class c + where c.relname=cols.table_name ) as column_comment + FROM information_schema.columns cols + WHERE cols.table_catalog=\'' . $this->config['database'] . '\' + AND cols.table_name=\'table1\' + AND cols.column_name = \'field1\'' + ); + + $this->assertEmpty($row['column_comment'], 'Dont remove column comment correctly'); + } + + /** + * @depends testCanAddColumnComment + */ + public function testCanAddMultipleCommentsToOneTable() + { + $table = new Table('table1', [], $this->adapter); + $table->addColumn('comment1', 'string', [ + 'comment' => $comment1 = 'first comment', + ]) + ->addColumn('comment2', 'string', [ + 'comment' => $comment2 = 'second comment', + ]) + ->save(); + + $row = $this->adapter->fetchRow( + 'SELECT + (select pg_catalog.col_description(oid,cols.ordinal_position::int) + from pg_catalog.pg_class c + where c.relname=cols.table_name ) as column_comment + FROM information_schema.columns cols + WHERE cols.table_catalog=\'' . $this->config['database'] . '\' + AND cols.table_name=\'table1\' + AND cols.column_name = \'comment1\'' + ); + + $this->assertEquals($comment1, $row['column_comment'], 'Could not create first column comment'); + + $row = $this->adapter->fetchRow( + 'SELECT + (select pg_catalog.col_description(oid,cols.ordinal_position::int) + from pg_catalog.pg_class c + where c.relname=cols.table_name ) as column_comment + FROM information_schema.columns cols + WHERE cols.table_catalog=\'' . $this->config['database'] . '\' + AND cols.table_name=\'table1\' + AND cols.column_name = \'comment2\'' + ); + + $this->assertEquals($comment2, $row['column_comment'], 'Could not create second column comment'); + } + + /** + * @depends testCanAddColumnComment + */ + public function testColumnsAreResetBetweenTables() + { + $table = new Table('widgets', [], $this->adapter); + $table->addColumn('transport', 'string', [ + 'comment' => $comment = 'One of: car, boat, truck, plane, train', + ]) + ->save(); + + $table = new Table('things', [], $this->adapter); + $table->addColumn('speed', 'integer') + ->save(); + + $row = $this->adapter->fetchRow( + 'SELECT + (select pg_catalog.col_description(oid,cols.ordinal_position::int) + from pg_catalog.pg_class c + where c.relname=cols.table_name ) as column_comment + FROM information_schema.columns cols + WHERE cols.table_catalog=\'' . $this->config['database'] . '\' + AND cols.table_name=\'widgets\' + AND cols.column_name = \'transport\'' + ); + + $this->assertEquals($comment, $row['column_comment'], 'Could not create column comment'); + } + + /** + * Test that column names are properly escaped when creating Foreign Keys + */ + public function testForeignKeysAreProperlyEscaped() + { + $userId = 'user'; + $sessionId = 'session'; + + $local = new Table('users', ['id' => $userId], $this->adapter); + $local->create(); + + $foreign = new Table( + 'sessions', + ['id' => $sessionId], + $this->adapter + ); + $foreign->addColumn('user', 'integer') + ->addForeignKey('user', 'users', $userId) + ->create(); + + $this->assertTrue($foreign->hasForeignKey('user')); + } + + public function testForeignKeysAreProperlyEscapedWithSchema() + { + $this->adapter->createSchema('schema_users'); + + $userId = 'user'; + $sessionId = 'session'; + + $local = new Table( + 'schema_users.users', + ['id' => $userId], + $this->adapter + ); + $local->create(); + + $foreign = new Table( + 'schema_users.sessions', + ['id' => $sessionId], + $this->adapter + ); + $foreign->addColumn('user', 'integer') + ->addForeignKey('user', 'schema_users.users', $userId) + ->create(); + + $this->assertTrue($foreign->hasForeignKey('user')); + + $this->adapter->dropSchema('schema_users'); + } + + public function testForeignKeysAreProperlyEscapedWithSchema2() + { + $this->adapter->createSchema('schema_users'); + $this->adapter->createSchema('schema_sessions'); + + $userId = 'user'; + $sessionId = 'session'; + + $local = new Table( + 'schema_users.users', + ['id' => $userId], + $this->adapter + ); + $local->create(); + + $foreign = new Table( + 'schema_sessions.sessions', + ['id' => $sessionId], + $this->adapter + ); + $foreign->addColumn('user', 'integer') + ->addForeignKey('user', 'schema_users.users', $userId) + ->create(); + + $this->assertTrue($foreign->hasForeignKey('user')); + + $this->adapter->dropSchema('schema_users'); + $this->adapter->dropSchema('schema_sessions'); + } + + public function testTimestampWithTimezone() + { + $table = new Table('tztable', ['id' => false], $this->adapter); + $table + ->addColumn('timestamp_tz', 'timestamp', ['timezone' => true]) + ->addColumn('time_tz', 'time', ['timezone' => true]) + /* date columns cannot have timestamp */ + ->addColumn('date_notz', 'date', ['timezone' => true]) + /* default for timezone option is false */ + ->addColumn('time_notz', 'timestamp') + ->save(); + + $this->assertTrue($this->adapter->hasColumn('tztable', 'timestamp_tz')); + $this->assertTrue($this->adapter->hasColumn('tztable', 'time_tz')); + $this->assertTrue($this->adapter->hasColumn('tztable', 'date_notz')); + $this->assertTrue($this->adapter->hasColumn('tztable', 'time_notz')); + + $columns = $this->adapter->getColumns('tztable'); + foreach ($columns as $column) { + if (substr($column->getName(), -4) === 'notz') { + $this->assertFalse($column->isTimezone(), 'column: ' . $column->getName()); + } else { + $this->assertTrue($column->isTimezone(), 'column: ' . $column->getName()); + } + } + } + + public function testTimestampWithTimezoneWithSchema() + { + $this->adapter->createSchema('tzschema'); + + $table = new Table('tzschema.tztable', ['id' => false], $this->adapter); + $table + ->addColumn('timestamp_tz', 'timestamp', ['timezone' => true]) + ->addColumn('time_tz', 'time', ['timezone' => true]) + /* date columns cannot have timestamp */ + ->addColumn('date_notz', 'date', ['timezone' => true]) + /* default for timezone option is false */ + ->addColumn('time_notz', 'timestamp') + ->save(); + + $this->assertTrue($this->adapter->hasColumn('tzschema.tztable', 'timestamp_tz')); + $this->assertTrue($this->adapter->hasColumn('tzschema.tztable', 'time_tz')); + $this->assertTrue($this->adapter->hasColumn('tzschema.tztable', 'date_notz')); + $this->assertTrue($this->adapter->hasColumn('tzschema.tztable', 'time_notz')); + + $columns = $this->adapter->getColumns('tzschema.tztable'); + foreach ($columns as $column) { + if (substr($column->getName(), -4) === 'notz') { + $this->assertFalse($column->isTimezone(), 'column: ' . $column->getName()); + } else { + $this->assertTrue($column->isTimezone(), 'column: ' . $column->getName()); + } + } + + $this->adapter->dropSchema('tzschema'); + } + + public function testBulkInsertData() + { + $data = [ + [ + 'column1' => 'value1', + 'column2' => 1, + ], + [ + 'column1' => 'value2', + 'column2' => 2, + ], + [ + 'column1' => 'value3', + 'column2' => 3, + ], + ]; + $table = new Table('table1', [], $this->adapter); + $table->addColumn('column1', 'string') + ->addColumn('column2', 'integer') + ->addColumn('column3', 'string', ['default' => 'test']) + ->insert($data) + ->save(); + + $rows = $this->adapter->fetchAll('SELECT * FROM table1'); + $this->assertEquals('value1', $rows[0]['column1']); + $this->assertEquals('value2', $rows[1]['column1']); + $this->assertEquals('value3', $rows[2]['column1']); + $this->assertEquals(1, $rows[0]['column2']); + $this->assertEquals(2, $rows[1]['column2']); + $this->assertEquals(3, $rows[2]['column2']); + $this->assertEquals('test', $rows[0]['column3']); + $this->assertEquals('test', $rows[2]['column3']); + } + + public function testBulkInsertBoolean() + { + $data = [ + [ + 'column1' => true, + ], + [ + 'column1' => false, + ], + [ + 'column1' => null, + ], + ]; + $table = new Table('table1', [], $this->adapter); + $table->addColumn('column1', 'boolean', ['null' => true]) + ->insert($data) + ->save(); + + $rows = $this->adapter->fetchAll('SELECT * FROM table1'); + $this->assertTrue($rows[0]['column1']); + $this->assertFalse($rows[1]['column1']); + $this->assertNull($rows[2]['column1']); + } + + public function testInsertData() + { + $table = new Table('table1', [], $this->adapter); + $table->addColumn('column1', 'string') + ->addColumn('column2', 'integer') + ->insert([ + [ + 'column1' => 'value1', + 'column2' => 1, + ], + [ + 'column1' => 'value2', + 'column2' => 2, + ], + ]) + ->save(); + + $rows = $this->adapter->fetchAll('SELECT * FROM table1'); + $this->assertEquals('value1', $rows[0]['column1']); + $this->assertEquals('value2', $rows[1]['column1']); + $this->assertEquals(1, $rows[0]['column2']); + $this->assertEquals(2, $rows[1]['column2']); + } + + public function testInsertBoolean() + { + $table = new Table('table1', [], $this->adapter); + $table->addColumn('column1', 'boolean', ['null' => true]) + ->addColumn('column2', 'text', ['null' => true]) + ->insert([ + [ + 'column1' => true, + 'column2' => 'value', + ], + [ + 'column1' => false, + ], + [ + 'column1' => null, + ], + ]) + ->save(); + + $rows = $this->adapter->fetchAll('SELECT * FROM table1'); + $this->assertTrue($rows[0]['column1']); + $this->assertFalse($rows[1]['column1']); + $this->assertNull($rows[2]['column1']); + } + + public function testInsertDataWithSchema() + { + $this->adapter->createSchema('schema1'); + + $table = new Table('schema1.table1', [], $this->adapter); + $table->addColumn('column1', 'string') + ->addColumn('column2', 'integer') + ->insert([ + [ + 'column1' => 'value1', + 'column2' => 1, + ], + [ + 'column1' => 'value2', + 'column2' => 2, + ], + ]) + ->save(); + + $rows = $this->adapter->fetchAll('SELECT * FROM "schema1"."table1"'); + $this->assertEquals('value1', $rows[0]['column1']); + $this->assertEquals('value2', $rows[1]['column1']); + $this->assertEquals(1, $rows[0]['column2']); + $this->assertEquals(2, $rows[1]['column2']); + + $this->adapter->dropSchema('schema1'); + } + + public function testTruncateTable() + { + $table = new Table('table1', [], $this->adapter); + $table->addColumn('column1', 'string') + ->addColumn('column2', 'integer') + ->insert([ + [ + 'column1' => 'value1', + 'column2' => 1, + ], + [ + 'column1' => 'value2', + 'column2' => 2, + ], + ]) + ->save(); + + $rows = $this->adapter->fetchAll('SELECT * FROM table1'); + $this->assertCount(2, $rows); + $table->truncate(); + $rows = $this->adapter->fetchAll('SELECT * FROM table1'); + $this->assertCount(0, $rows); + } + + public function testTruncateTableWithSchema() + { + $this->adapter->createSchema('schema1'); + + $table = new Table('schema1.table1', [], $this->adapter); + $table->addColumn('column1', 'string') + ->addColumn('column2', 'integer') + ->insert([ + [ + 'column1' => 'value1', + 'column2' => 1, + ], + [ + 'column1' => 'value2', + 'column2' => 2, + ], + ]) + ->save(); + + $rows = $this->adapter->fetchAll('SELECT * FROM schema1.table1'); + $this->assertCount(2, $rows); + $table->truncate(); + $rows = $this->adapter->fetchAll('SELECT * FROM schema1.table1'); + $this->assertCount(0, $rows); + + $this->adapter->dropSchema('schema1'); + } + + public function testDumpCreateTable() + { + $options = $this->adapter->getOptions(); + $options['dryrun'] = true; + $this->adapter->setOptions($options); + + $table = new Table('table1', [], $this->adapter); + + $table->addColumn('column1', 'string') + ->addColumn('column2', 'integer', ['null' => true]) + ->addColumn('column3', 'string', ['default' => 'test', 'null' => false]) + ->save(); + + if ($this->usingPostgres10()) { + $expectedOutput = 'CREATE TABLE "public"."table1" ("id" INTEGER NOT NULL GENERATED BY DEFAULT AS IDENTITY, "column1" CHARACTER VARYING (255) ' . + 'NULL, "column2" INTEGER NULL, "column3" CHARACTER VARYING (255) NOT NULL DEFAULT \'test\', CONSTRAINT ' . + '"table1_pkey" PRIMARY KEY ("id"));'; + } else { + $expectedOutput = 'CREATE TABLE "public"."table1" ("id" SERIAL NOT NULL, "column1" CHARACTER VARYING (255) ' . + 'NULL, "column2" INTEGER NULL, "column3" CHARACTER VARYING (255) NOT NULL DEFAULT \'test\', CONSTRAINT ' . + '"table1_pkey" PRIMARY KEY ("id"));'; + } + $actualOutput = join("\n", $this->out->messages()); + $this->assertStringContainsString( + $expectedOutput, + $actualOutput, + 'Passing the --dry-run option does not dump create table query' + ); + } + + public function testDumpCreateTableWithSchema() + { + $options = $this->adapter->getOptions(); + $options['dryrun'] = true; + $this->adapter->setOptions($options); + + $table = new Table('schema1.table1', [], $this->adapter); + + $table->addColumn('column1', 'string') + ->addColumn('column2', 'integer', ['null' => true]) + ->addColumn('column3', 'string', ['default' => 'test', 'null' => false]) + ->save(); + + if ($this->usingPostgres10()) { + $expectedOutput = 'CREATE TABLE "schema1"."table1" ("id" INTEGER NOT NULL GENERATED BY DEFAULT AS IDENTITY, "column1" CHARACTER VARYING (255) ' . + 'NULL, "column2" INTEGER NULL, "column3" CHARACTER VARYING (255) NOT NULL DEFAULT \'test\', CONSTRAINT ' . + '"table1_pkey" PRIMARY KEY ("id"));'; + } else { + $expectedOutput = 'CREATE TABLE "schema1"."table1" ("id" SERIAL NOT NULL, "column1" CHARACTER VARYING (255) ' . + 'NULL, "column2" INTEGER NULL, "column3" CHARACTER VARYING (255) NOT NULL DEFAULT \'test\', CONSTRAINT ' . + '"table1_pkey" PRIMARY KEY ("id"));'; + } + $actualOutput = join("\n", $this->out->messages()); + $this->assertStringContainsString( + $expectedOutput, + $actualOutput, + 'Passing the --dry-run option does not dump create table query' + ); + } + + /** + * Creates the table "table1". + * Then sets phinx to dry run mode and inserts a record. + * Asserts that phinx outputs the insert statement and doesn't insert a record. + */ + public function testDumpInsert() + { + $table = new Table('table1', [], $this->adapter); + $table->addColumn('string_col', 'string') + ->addColumn('int_col', 'integer') + ->save(); + + $options = $this->adapter->getOptions(); + $options['dryrun'] = true; + $this->adapter->setOptions($options); + + $this->adapter->insert($table->getTable(), [ + 'string_col' => 'test data', + ]); + + $this->adapter->insert($table->getTable(), [ + 'string_col' => null, + ]); + + $this->adapter->insert($table->getTable(), [ + 'int_col' => 23, + ]); + + $expectedOutput = <<<'OUTPUT' +INSERT INTO "public"."table1" ("string_col") OVERRIDING SYSTEM VALUE VALUES ('test data'); +INSERT INTO "public"."table1" ("string_col") OVERRIDING SYSTEM VALUE VALUES (null); +INSERT INTO "public"."table1" ("int_col") OVERRIDING SYSTEM VALUE VALUES (23); +OUTPUT; + + if (!$this->usingPostgres10()) { + $expectedOutput = <<<'OUTPUT' +INSERT INTO "public"."table1" ("string_col") VALUES ('test data'); +INSERT INTO "public"."table1" ("string_col") VALUES (null); +INSERT INTO "public"."table1" ("int_col") VALUES (23); +OUTPUT; + } + + $actualOutput = join("\n", $this->out->messages()); + $this->assertStringContainsString( + $expectedOutput, + $actualOutput, + 'Passing the --dry-run option doesn\'t dump the insert to the output' + ); + + $countQuery = $this->adapter->query('SELECT COUNT(*) FROM table1'); + $this->assertTrue($countQuery->execute()); + $res = $countQuery->fetchAll('assoc'); + $this->assertEquals(0, $res[0]['count']); + } + + /** + * Creates the table "table1". + * Then sets phinx to dry run mode and inserts some records. + * Asserts that phinx outputs the insert statement and doesn't insert any record. + */ + public function testDumpBulkinsert() + { + $table = new Table('table1', [], $this->adapter); + $table->addColumn('string_col', 'string') + ->addColumn('int_col', 'integer') + ->save(); + + $options = $this->adapter->getOptions(); + $options['dryrun'] = true; + $this->adapter->setOptions($options); + + $this->adapter->bulkinsert($table->getTable(), [ + [ + 'string_col' => 'test_data1', + 'int_col' => 23, + ], + [ + 'string_col' => null, + 'int_col' => 42, + ], + ]); + + $expectedOutput = <<<'OUTPUT' +INSERT INTO "public"."table1" ("string_col", "int_col") OVERRIDING SYSTEM VALUE VALUES ('test_data1', 23), (null, 42); +OUTPUT; + + if (!$this->usingPostgres10()) { + $expectedOutput = <<<'OUTPUT' +INSERT INTO "public"."table1" ("string_col", "int_col") VALUES ('test_data1', 23), (null, 42); +OUTPUT; + } + + $actualOutput = join("\n", $this->out->messages()); + $this->assertStringContainsString( + $expectedOutput, + $actualOutput, + 'Passing the --dry-run option doesn\'t dump the bulkinsert to the output' + ); + + $countQuery = $this->adapter->query('SELECT COUNT(*) FROM table1'); + $this->assertTrue($countQuery->execute()); + $res = $countQuery->fetchAll('assoc'); + $this->assertEquals(0, $res[0]['count']); + } + + public function testDumpCreateTableAndThenInsert() + { + $options = $this->adapter->getOptions(); + $options['dryrun'] = true; + $this->adapter->setOptions($options); + + $table = new Table('schema1.table1', ['id' => false, 'primary_key' => ['column1']], $this->adapter); + $table->addColumn('column1', 'string', ['null' => false]) + ->addColumn('column2', 'integer') + ->save(); + + $table = new Table('schema1.table1', [], $this->adapter); + $table->insert([ + 'column1' => 'id1', + 'column2' => 1, + ])->save(); + + $expectedOutput = <<<'OUTPUT' +CREATE TABLE "schema1"."table1" ("column1" CHARACTER VARYING (255) NOT NULL, "column2" INTEGER NULL, CONSTRAINT "table1_pkey" PRIMARY KEY ("column1")); +INSERT INTO "schema1"."table1" ("column1", "column2") OVERRIDING SYSTEM VALUE VALUES ('id1', 1); +OUTPUT; + + if (!$this->usingPostgres10()) { + $expectedOutput = <<<'OUTPUT' +CREATE TABLE "schema1"."table1" ("column1" CHARACTER VARYING (255) NOT NULL, "column2" INTEGER NULL, CONSTRAINT "table1_pkey" PRIMARY KEY ("column1")); +INSERT INTO "schema1"."table1" ("column1", "column2") VALUES ('id1', 1); +OUTPUT; + } + + $actualOutput = join("\n", $this->out->messages()); + $this->assertStringContainsString($expectedOutput, $actualOutput, 'Passing the --dry-run option does not dump create and then insert table queries to the output'); + } + + /** + * Tests interaction with the query builder + */ + public function testQueryBuilder() + { + $table = new Table('table1', [], $this->adapter); + $table->addColumn('string_col', 'string') + ->addColumn('int_col', 'integer') + ->save(); + + $builder = $this->adapter->getQueryBuilder(Query::TYPE_INSERT); + $stm = $builder + ->insert(['string_col', 'int_col']) + ->into('table1') + ->values(['string_col' => 'value1', 'int_col' => 1]) + ->values(['string_col' => 'value2', 'int_col' => 2]) + ->execute(); + + $this->assertEquals(2, $stm->rowCount()); + + $builder = $this->adapter->getQueryBuilder(query::TYPE_SELECT); + $stm = $builder + ->select('*') + ->from('table1') + ->where(['int_col >=' => 2]) + ->execute(); + + $this->assertEquals(1, $stm->rowCount()); + $this->assertEquals( + ['id' => 2, 'string_col' => 'value2', 'int_col' => '2'], + $stm->fetch('assoc') + ); + + $builder = $this->adapter->getQueryBuilder(Query::TYPE_DELETE); + $stm = $builder + ->delete('table1') + ->where(['int_col <' => 2]) + ->execute(); + + $this->assertEquals(1, $stm->rowCount()); + } + + public function testQueryWithParams() + { + $table = new Table('table1', [], $this->adapter); + $table->addColumn('string_col', 'string') + ->addColumn('int_col', 'integer') + ->save(); + + $this->adapter->insert($table->getTable(), [ + 'string_col' => 'test data', + 'int_col' => 10, + ]); + + $this->adapter->insert($table->getTable(), [ + 'string_col' => null, + ]); + + $this->adapter->insert($table->getTable(), [ + 'int_col' => 23, + ]); + + $countQuery = $this->adapter->query('SELECT COUNT(*) AS c FROM table1 WHERE int_col > ?', [5]); + $res = $countQuery->fetchAll('assoc'); + $this->assertEquals(2, $res[0]['c']); + + $this->adapter->execute('UPDATE table1 SET int_col = ? WHERE int_col IS NULL', [12]); + + $countQuery->execute([1]); + $res = $countQuery->fetchAll('assoc'); + $this->assertEquals(3, $res[0]['c']); + } + + public function testRenameMixedCaseTableAndColumns() + { + $table = new Table('OrganizationSettings', [], $this->adapter); + $table->addColumn('SettingType', 'string') + ->create(); + + $this->assertTrue($this->adapter->hasTable('OrganizationSettings')); + $this->assertTrue($this->adapter->hasColumn('OrganizationSettings', 'id')); + $this->assertTrue($this->adapter->hasColumn('OrganizationSettings', 'SettingType')); + $this->assertFalse($this->adapter->hasColumn('OrganizationSettings', 'SettingTypeId')); + + $table = new Table('OrganizationSettings', [], $this->adapter); + $table + ->renameColumn('SettingType', 'SettingTypeId') + ->update(); + + $this->assertTrue($this->adapter->hasTable('OrganizationSettings')); + $this->assertTrue($this->adapter->hasColumn('OrganizationSettings', 'id')); + $this->assertTrue($this->adapter->hasColumn('OrganizationSettings', 'SettingTypeId')); + $this->assertFalse($this->adapter->hasColumn('OrganizationSettings', 'SettingType')); + } + + public static function serialProvider(): array + { + return [ + [AdapterInterface::PHINX_TYPE_SMALL_INTEGER], + [AdapterInterface::PHINX_TYPE_INTEGER], + [AdapterInterface::PHINX_TYPE_BIG_INTEGER], + ]; + } + + /** + * @dataProvider serialProvider + */ + public function testSerialAliases(string $columnType): void + { + $table = new Table('test', ['id' => false], $this->adapter); + $table->addColumn('id', $columnType, ['identity' => true, 'generated' => null])->create(); + + $columns = $table->getColumns(); + $this->assertCount(1, $columns); + $column = $columns[0]; + $this->assertSame($columnType, $column->getType()); + $this->assertSame("nextval('test_id_seq'::regclass)", (string)$column->getDefault()); + } +} 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(); + } +} diff --git a/tests/TestCase/Db/Adapter/SqliteAdapterTest.php b/tests/TestCase/Db/Adapter/SqliteAdapterTest.php new file mode 100644 index 00000000..593c1d49 --- /dev/null +++ b/tests/TestCase/Db/Adapter/SqliteAdapterTest.php @@ -0,0 +1,3332 @@ + $config */ + $config = ConnectionManager::getConfig('test'); + if ($config['scheme'] !== 'sqlite') { + $this->markTestSkipped('SQLite tests disabled.'); + } + $this->config = [ + 'adapter' => 'sqlite', + 'suffix' => '', + 'connection' => ConnectionManager::get('test'), + 'database' => $config['database'], + ]; + $io = $this->getConsoleIo(); + $this->adapter = new SqliteAdapter($this->config, $io); + + if ($config['database'] !== ':memory:') { + // ensure the database is empty for each test + $this->adapter->dropDatabase($config['database']); + $this->adapter->createDatabase($config['database']); + } + + // leave the adapter in a disconnected state for each test + $this->adapter->disconnect(); + } + + protected function getConsoleIo(): ConsoleIo + { + $out = new StubConsoleOutput(); + $in = new StubConsoleInput([]); + $io = new ConsoleIo($out, $out, $in); + + $this->out = $out; + $this->io = $io; + + return $this->io; + } + + protected function tearDown(): void + { + unset($this->adapter, $this->out, $this->io); + } + + public function testConnection() + { + $this->assertInstanceOf(Connection::class, $this->adapter->getConnection()); + } + + public function testBeginTransaction() + { + $this->adapter->beginTransaction(); + + $this->assertTrue( + $this->adapter->getConnection()->inTransaction(), + 'Underlying PDO instance did not detect new transaction' + ); + } + + public function testRollbackTransaction() + { + $this->adapter->beginTransaction(); + $this->adapter->rollbackTransaction(); + + $this->assertFalse( + $this->adapter->getConnection()->inTransaction(), + 'Underlying PDO instance did not detect rolled back transaction' + ); + } + + public function testCommitTransactionTransaction() + { + $this->adapter->beginTransaction(); + $this->adapter->commitTransaction(); + + $this->assertFalse( + $this->adapter->getConnection()->inTransaction(), + "Underlying PDO instance didn't detect committed transaction" + ); + } + + public function testCreatingTheSchemaTableOnConnect() + { + $this->adapter->connect(); + $this->assertTrue($this->adapter->hasTable($this->adapter->getSchemaTableName())); + $this->adapter->dropTable($this->adapter->getSchemaTableName()); + $this->assertFalse($this->adapter->hasTable($this->adapter->getSchemaTableName())); + $this->adapter->disconnect(); + $this->adapter->connect(); + $this->assertTrue($this->adapter->hasTable($this->adapter->getSchemaTableName())); + } + + public function testSchemaTableIsCreatedWithPrimaryKey() + { + $this->adapter->connect(); + new Table($this->adapter->getSchemaTableName(), [], $this->adapter); + $this->assertTrue($this->adapter->hasIndex($this->adapter->getSchemaTableName(), ['version'])); + } + + public function testQuoteTableName() + { + $this->assertEquals('`test_table`', $this->adapter->quoteTableName('test_table')); + } + + public function testQuoteColumnName() + { + $this->assertEquals('`test_column`', $this->adapter->quoteColumnName('test_column')); + } + + public function testCreateTable() + { + $table = new Table('ntable', [], $this->adapter); + $table->addColumn('realname', 'string') + ->addColumn('email', 'integer') + ->save(); + $this->assertTrue($this->adapter->hasTable('ntable')); + $this->assertTrue($this->adapter->hasColumn('ntable', 'id')); + $this->assertTrue($this->adapter->hasColumn('ntable', 'realname')); + $this->assertTrue($this->adapter->hasColumn('ntable', 'email')); + $this->assertFalse($this->adapter->hasColumn('ntable', 'address')); + } + + public function testCreateTableCustomIdColumn() + { + $table = new Table('ntable', ['id' => 'custom_id'], $this->adapter); + $table->addColumn('realname', 'string') + ->addColumn('email', 'integer') + ->save(); + + $this->assertTrue($this->adapter->hasTable('ntable')); + $this->assertTrue($this->adapter->hasColumn('ntable', 'custom_id')); + $this->assertTrue($this->adapter->hasColumn('ntable', 'realname')); + $this->assertTrue($this->adapter->hasColumn('ntable', 'email')); + $this->assertFalse($this->adapter->hasColumn('ntable', 'address')); + + //ensure the primary key is not nullable + /** @var \Migrations\Db\Table\Column $idColumn */ + $idColumn = $this->adapter->getColumns('ntable')[0]; + $this->assertTrue($idColumn->getIdentity()); + $this->assertFalse($idColumn->isNull()); + } + + public function testCreateTableIdentityIdColumn() + { + $table = new Table('ntable', ['id' => false, 'primary_key' => ['custom_id']], $this->adapter); + $table->addColumn('custom_id', 'integer', ['identity' => true]) + ->save(); + + $this->assertTrue($this->adapter->hasTable('ntable')); + $this->assertTrue($this->adapter->hasColumn('ntable', 'custom_id')); + + /** @var \Migrations\Db\Table\Column $idColumn */ + $idColumn = $this->adapter->getColumns('ntable')[0]; + $this->assertTrue($idColumn->getIdentity()); + } + + public function testCreateTableWithNoPrimaryKey() + { + $options = [ + 'id' => false, + ]; + $table = new Table('atable', $options, $this->adapter); + $table->addColumn('user_id', 'integer') + ->save(); + $this->assertFalse($this->adapter->hasColumn('atable', 'id')); + } + + public function testCreateTableWithMultiplePrimaryKeys() + { + $options = [ + 'id' => false, + 'primary_key' => ['user_id', 'tag_id'], + ]; + $table = new Table('table1', $options, $this->adapter); + $table->addColumn('user_id', 'integer') + ->addColumn('tag_id', 'integer') + ->save(); + $this->assertTrue($this->adapter->hasIndex('table1', ['user_id', 'tag_id'])); + $this->assertTrue($this->adapter->hasIndex('table1', ['USER_ID', 'tag_id'])); + $this->assertFalse($this->adapter->hasIndex('table1', ['tag_id', 'USER_ID'])); + $this->assertFalse($this->adapter->hasIndex('table1', ['tag_id', 'user_email'])); + } + + /** + * @return void + */ + public function testCreateTableWithPrimaryKeyAsUuid() + { + $options = [ + 'id' => false, + 'primary_key' => 'id', + ]; + $table = new Table('ztable', $options, $this->adapter); + $table->addColumn('id', 'uuid')->save(); + $this->assertTrue($this->adapter->hasColumn('ztable', 'id')); + $this->assertTrue($this->adapter->hasIndex('ztable', 'id')); + } + + /** + * @return void + */ + public function testCreateTableWithPrimaryKeyAsBinaryUuid() + { + $options = [ + 'id' => false, + 'primary_key' => 'id', + ]; + $table = new Table('ztable', $options, $this->adapter); + $table->addColumn('id', 'binaryuuid')->save(); + $this->assertTrue($this->adapter->hasColumn('ztable', 'id')); + $this->assertTrue($this->adapter->hasIndex('ztable', 'id')); + } + + public function testCreateTableWithMultipleIndexes() + { + $table = new Table('table1', [], $this->adapter); + $table->addColumn('email', 'string') + ->addColumn('name', 'string') + ->addIndex('email') + ->addIndex('name') + ->save(); + $this->assertTrue($this->adapter->hasIndex('table1', ['email'])); + $this->assertTrue($this->adapter->hasIndex('table1', ['name'])); + $this->assertFalse($this->adapter->hasIndex('table1', ['email', 'user_email'])); + $this->assertFalse($this->adapter->hasIndex('table1', ['email', 'user_name'])); + } + + public function testCreateTableWithUniqueIndexes() + { + $table = new Table('table1', [], $this->adapter); + $table->addColumn('email', 'string') + ->addIndex('email', ['unique' => true]) + ->save(); + $this->assertTrue($this->adapter->hasIndex('table1', ['email'])); + $this->assertFalse($this->adapter->hasIndex('table1', ['email', 'user_email'])); + } + + public function testCreateTableWithNamedIndexes() + { + $table = new Table('table1', [], $this->adapter); + $table->addColumn('email', 'string') + ->addIndex('email', ['name' => 'myemailindex']) + ->save(); + $this->assertTrue($this->adapter->hasIndex('table1', ['email'])); + $this->assertFalse($this->adapter->hasIndex('table1', ['email', 'user_email'])); + $this->assertTrue($this->adapter->hasIndexByName('table1', 'myemailindex')); + } + + public function testCreateTableWithForeignKey() + { + $refTable = new Table('ref_table', [], $this->adapter); + $refTable->addColumn('field1', 'string')->save(); + + $table = new Table('table', [], $this->adapter); + $table->addColumn('ref_table_id', 'integer'); + $table->addForeignKey('ref_table_id', 'ref_table', 'id'); + $table->save(); + + $this->assertTrue($this->adapter->hasTable($table->getName())); + $this->assertTrue($this->adapter->hasForeignKey($table->getName(), ['ref_table_id'])); + } + + public function testCreateTableWithIndexesAndForeignKey() + { + $refTable = new Table('tbl_master', [], $this->adapter); + $refTable->create(); + + $table = new Table('tbl_child', [], $this->adapter); + $table + ->addColumn('column1', 'integer') + ->addColumn('column2', 'integer') + ->addColumn('master_id', 'integer') + ->addIndex(['column2']) + ->addIndex(['column1', 'column2'], ['unique' => true, 'name' => 'uq_tbl_child_column1_column2_ndx']) + ->addForeignKey( + 'master_id', + 'tbl_master', + 'id', + ['delete' => 'NO_ACTION', 'update' => 'NO_ACTION', 'constraint' => 'fk_master_id'] + ) + ->create(); + + $this->assertTrue($this->adapter->hasIndex('tbl_child', 'column2')); + $this->assertTrue($this->adapter->hasIndex('tbl_child', ['column1', 'column2'])); + $this->assertTrue($this->adapter->hasForeignKey('tbl_child', ['master_id'])); + + $row = $this->adapter->fetchRow( + "SELECT * FROM sqlite_master WHERE `type` = 'table' AND `tbl_name` = 'tbl_child'" + ); + $this->assertStringContainsString( + 'CONSTRAINT `fk_master_id` FOREIGN KEY (`master_id`) REFERENCES `tbl_master` (`id`) ON DELETE NO ACTION ON UPDATE NO ACTION', + $row['sql'] + ); + } + + public function testCreateTableWithoutAutoIncrementingPrimaryKeyAndWithForeignKey() + { + $refTable = (new Table('tbl_master', ['id' => false, 'primary_key' => 'id'], $this->adapter)) + ->addColumn('id', 'text'); + $refTable->create(); + + $table = (new Table('tbl_child', ['id' => false, 'primary_key' => 'master_id'], $this->adapter)) + ->addColumn('master_id', 'text') + ->addForeignKey( + 'master_id', + 'tbl_master', + 'id', + ['delete' => 'NO_ACTION', 'update' => 'NO_ACTION', 'constraint' => 'fk_master_id'] + ); + $table->create(); + + $this->assertTrue($this->adapter->hasForeignKey('tbl_child', ['master_id'])); + + $row = $this->adapter->fetchRow( + "SELECT * FROM sqlite_master WHERE `type` = 'table' AND `tbl_name` = 'tbl_child'" + ); + $this->assertStringContainsString( + 'CONSTRAINT `fk_master_id` FOREIGN KEY (`master_id`) REFERENCES `tbl_master` (`id`) ON DELETE NO ACTION ON UPDATE NO ACTION', + $row['sql'] + ); + } + + public function testAddPrimaryKey() + { + $table = new Table('table1', ['id' => false], $this->adapter); + $table + ->addColumn('column1', 'integer') + ->addColumn('column2', 'integer') + ->save(); + + $table + ->changePrimaryKey('column1') + ->save(); + + $this->assertTrue($this->adapter->hasPrimaryKey('table1', ['column1'])); + } + + public function testChangePrimaryKey() + { + $table = new Table('table1', ['id' => false, 'primary_key' => 'column1'], $this->adapter); + $table + ->addColumn('column1', 'integer') + ->addColumn('column2', 'integer') + ->save(); + + $table + ->changePrimaryKey('column2') + ->save(); + + $this->assertFalse($this->adapter->hasPrimaryKey('table1', ['column1'])); + $this->assertTrue($this->adapter->hasPrimaryKey('table1', ['column2'])); + } + + public function testChangePrimaryKeyNonInteger() + { + $table = new Table('table1', ['id' => false, 'primary_key' => 'column1'], $this->adapter); + $table + ->addColumn('column1', 'string') + ->addColumn('column2', 'string') + ->save(); + + $table + ->changePrimaryKey('column2') + ->save(); + + $this->assertFalse($this->adapter->hasPrimaryKey('table1', ['column1'])); + $this->assertTrue($this->adapter->hasPrimaryKey('table1', ['column2'])); + } + + public function testDropPrimaryKey() + { + $table = new Table('table1', ['id' => false, 'primary_key' => 'column1'], $this->adapter); + $table + ->addColumn('column1', 'integer') + ->addColumn('column2', 'integer') + ->save(); + + $table + ->changePrimaryKey(null) + ->save(); + + $this->assertFalse($this->adapter->hasPrimaryKey('table1', ['column1'])); + } + + public function testAddMultipleColumnPrimaryKeyFails() + { + $table = new Table('table1', [], $this->adapter); + $table + ->addColumn('column1', 'integer') + ->addColumn('column2', 'integer') + ->save(); + + $this->expectException(InvalidArgumentException::class); + + $table + ->changePrimaryKey(['column1', 'column2']) + ->save(); + } + + public function testChangeCommentFails() + { + $table = new Table('table1', [], $this->adapter); + $table->save(); + + $this->expectException(BadMethodCallException::class); + + $table + ->changeComment('comment1') + ->save(); + } + + public function testRenameTable() + { + $table = new Table('table1', [], $this->adapter); + $table->save(); + $this->assertTrue($this->adapter->hasTable('table1')); + $this->assertFalse($this->adapter->hasTable('table2')); + $this->adapter->renameTable('table1', 'table2'); + $this->assertFalse($this->adapter->hasTable('table1')); + $this->assertTrue($this->adapter->hasTable('table2')); + } + + public function testAddColumn() + { + $table = new Table('table1', [], $this->adapter); + $table->save(); + $this->assertFalse($table->hasColumn('email')); + $table->addColumn('email', 'string', ['null' => true]) + ->save(); + $this->assertTrue($table->hasColumn('email')); + + // In SQLite it is not possible to dictate order of added columns. + // $table->addColumn('realname', 'string', array('after' => 'id')) + // ->save(); + // $this->assertEquals('realname', $rows[1]['Field']); + } + + public function testAddColumnWithDefaultValue() + { + $table = new Table('table1', [], $this->adapter); + $table->save(); + $table->addColumn('default_zero', 'string', ['default' => 'test']) + ->save(); + $rows = $this->adapter->fetchAll(sprintf('pragma table_info(%s)', 'table1')); + $this->assertEquals("'test'", $rows[1]['dflt_value']); + } + + public function testAddColumnWithDefaultZero() + { + $table = new Table('table1', [], $this->adapter); + $table->save(); + $table->addColumn('default_zero', 'integer', ['default' => 0]) + ->save(); + $rows = $this->adapter->fetchAll(sprintf('pragma table_info(%s)', 'table1')); + $this->assertNotNull($rows[1]['dflt_value']); + $this->assertEquals('0', $rows[1]['dflt_value']); + } + + public function testAddColumnWithDefaultEmptyString() + { + $table = new Table('table1', [], $this->adapter); + $table->save(); + $table->addColumn('default_empty', 'string', ['default' => '']) + ->save(); + $rows = $this->adapter->fetchAll(sprintf('pragma table_info(%s)', 'table1')); + $this->assertEquals("''", $rows[1]['dflt_value']); + } + + public static function irregularCreateTableProvider() + { + return [ + ["CREATE TABLE \"users\"\n( `id` INTEGER NOT NULL )", ['id', 'foo']], + ['CREATE TABLE users ( id INTEGER NOT NULL )', ['id', 'foo']], + ["CREATE TABLE [users]\n(\nid INTEGER NOT NULL)", ['id', 'foo']], + ["CREATE TABLE \"users\" ([id] \n INTEGER NOT NULL\n, \"bar\" INTEGER)", ['id', 'bar', 'foo']], + ]; + } + + /** + * @dataProvider irregularCreateTableProvider + */ + public function testAddColumnToIrregularCreateTableStatements(string $createTableSql, array $expectedColumns): void + { + $this->adapter->execute($createTableSql); + $table = new Table('users', [], $this->adapter); + $table->addColumn('foo', 'string'); + $table->update(); + + $columns = $this->adapter->getColumns('users'); + $columnCount = count($columns); + for ($i = 0; $i < $columnCount; $i++) { + $this->assertEquals($expectedColumns[$i], $columns[$i]->getName()); + } + } + + public function testAddDoubleColumn() + { + $table = new Table('table1', [], $this->adapter); + $table->save(); + $table->addColumn('foo', 'double', ['null' => true]) + ->save(); + $rows = $this->adapter->fetchAll(sprintf('pragma table_info(%s)', 'table1')); + $this->assertEquals('DOUBLE', $rows[1]['type']); + } + + public function testRenameColumn() + { + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'string') + ->save(); + $this->assertTrue($this->adapter->hasColumn('t', 'column1')); + $this->adapter->renameColumn('t', 'column1', 'column2'); + $this->assertFalse($this->adapter->hasColumn('t', 'column1')); + $this->assertTrue($this->adapter->hasColumn('t', 'column2')); + } + + public function testRenamingANonExistentColumn() + { + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'string') + ->save(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("The specified column doesn't exist: column2"); + $this->adapter->renameColumn('t', 'column2', 'column1'); + } + + public function testRenameColumnWithIndex() + { + $table = new Table('t', [], $this->adapter); + $table + ->addColumn('indexcol', 'integer') + ->addIndex('indexcol') + ->create(); + + $this->assertTrue($this->adapter->hasIndex($table->getName(), 'indexcol')); + $this->assertFalse($this->adapter->hasIndex($table->getName(), 'newindexcol')); + + $table->renameColumn('indexcol', 'newindexcol')->update(); + + $this->assertFalse($this->adapter->hasIndex($table->getName(), 'indexcol')); + $this->assertTrue($this->adapter->hasIndex($table->getName(), 'newindexcol')); + } + + public function testRenameColumnWithUniqueIndex() + { + $table = new Table('t', [], $this->adapter); + $table + ->addColumn('indexcol', 'integer') + ->addIndex('indexcol', ['unique' => true]) + ->create(); + + $this->assertTrue($this->adapter->hasIndex($table->getName(), 'indexcol')); + $this->assertFalse($this->adapter->hasIndex($table->getName(), 'newindexcol')); + + $table->renameColumn('indexcol', 'newindexcol')->update(); + + $this->assertFalse($this->adapter->hasIndex($table->getName(), 'indexcol')); + $this->assertTrue($this->adapter->hasIndex($table->getName(), 'newindexcol')); + } + + public function testRenameColumnWithCompositeIndex() + { + $table = new Table('t', [], $this->adapter); + $table + ->addColumn('indexcol1', 'integer') + ->addColumn('indexcol2', 'integer') + ->addIndex(['indexcol1', 'indexcol2']) + ->create(); + + $this->assertTrue($this->adapter->hasIndex($table->getName(), ['indexcol1', 'indexcol2'])); + $this->assertFalse($this->adapter->hasIndex($table->getName(), ['indexcol1', 'newindexcol2'])); + + $table->renameColumn('indexcol2', 'newindexcol2')->update(); + + $this->assertFalse($this->adapter->hasIndex($table->getName(), ['indexcol1', 'indexcol2'])); + $this->assertTrue($this->adapter->hasIndex($table->getName(), ['indexcol1', 'newindexcol2'])); + } + + /** + * Tests that rewriting the index SQL does not accidentally change + * the table name in case it matches the column name. + */ + public function testRenameColumnWithIndexMatchingTheTableName() + { + $table = new Table('indexcol', [], $this->adapter); + $table + ->addColumn('indexcol', 'integer') + ->addIndex('indexcol') + ->create(); + + $this->assertTrue($this->adapter->hasIndex($table->getName(), 'indexcol')); + $this->assertFalse($this->adapter->hasIndex($table->getName(), 'newindexcol')); + + $table->renameColumn('indexcol', 'newindexcol')->update(); + + $this->assertFalse($this->adapter->hasIndex($table->getName(), 'indexcol')); + $this->assertTrue($this->adapter->hasIndex($table->getName(), 'newindexcol')); + } + + /** + * Tests that rewriting the index SQL does not accidentally change + * column names that partially match the column to rename. + */ + public function testRenameColumnWithIndexColumnPartialMatch() + { + $table = new Table('t', [], $this->adapter); + $table + ->addColumn('indexcol', 'integer') + ->addColumn('indexcolumn', 'integer') + ->create(); + + $this->adapter->execute('CREATE INDEX custom_idx ON t (indexcolumn, indexcol)'); + + $this->assertTrue($this->adapter->hasIndex($table->getName(), ['indexcolumn', 'indexcol'])); + $this->assertFalse($this->adapter->hasIndex($table->getName(), ['indexcolumn', 'newindexcol'])); + + $table->renameColumn('indexcol', 'newindexcol')->update(); + + $this->assertFalse($this->adapter->hasIndex($table->getName(), ['indexcolumn', 'indexcol'])); + $this->assertTrue($this->adapter->hasIndex($table->getName(), ['indexcolumn', 'newindexcol'])); + } + + public function testRenameColumnWithIndexColumnRequiringQuoting() + { + $table = new Table('t', [], $this->adapter); + $table + ->addColumn('indexcol', 'integer') + ->addIndex('indexcol') + ->create(); + + $this->assertTrue($this->adapter->hasIndex($table->getName(), 'indexcol')); + $this->assertFalse($this->adapter->hasIndex($table->getName(), 'new index col')); + + $table->renameColumn('indexcol', 'new index col')->update(); + + $this->assertFalse($this->adapter->hasIndex($table->getName(), 'indexcol')); + $this->assertTrue($this->adapter->hasIndex($table->getName(), 'new index col')); + } + + /** + * Indices that are using expressions are not being updated. + */ + public function testRenameColumnWithExpressionIndex() + { + $table = new Table('t', [], $this->adapter); + $table + ->addColumn('indexcol', 'integer') + ->create(); + + $this->adapter->execute('CREATE INDEX custom_idx ON t (`indexcol`, ABS(`indexcol`))'); + + $this->assertTrue($this->adapter->hasIndexByName('t', 'custom_idx')); + + $this->expectException(PDOException::class); + $this->expectExceptionMessage('no such column: indexcol'); + + $table->renameColumn('indexcol', 'newindexcol')->update(); + } + + /** + * Index SQL is mostly returned as-is, hence custom indices can contain + * a wide variety of formats. + */ + public static function customIndexSQLDataProvider(): array + { + return [ + [ + 'CREATE INDEX test_idx ON t(indexcol);', + 'CREATE INDEX test_idx ON t(`newindexcol`)', + ], + [ + 'CREATE INDEX test_idx ON t(`indexcol`);', + 'CREATE INDEX test_idx ON t(`newindexcol`)', + ], + [ + 'CREATE INDEX test_idx ON t("indexcol");', + 'CREATE INDEX test_idx ON t(`newindexcol`)', + ], + [ + 'CREATE INDEX test_idx ON t([indexcol]);', + 'CREATE INDEX test_idx ON t(`newindexcol`)', + ], + [ + 'CREATE INDEX test_idx ON t(indexcol ASC);', + 'CREATE INDEX test_idx ON t(`newindexcol` ASC)', + ], + [ + 'CREATE INDEX test_idx ON t(`indexcol` ASC);', + 'CREATE INDEX test_idx ON t(`newindexcol` ASC)', + ], + [ + 'CREATE INDEX test_idx ON t("indexcol" DESC);', + 'CREATE INDEX test_idx ON t(`newindexcol` DESC)', + ], + [ + 'CREATE INDEX test_idx ON t([indexcol] DESC);', + 'CREATE INDEX test_idx ON t(`newindexcol` DESC)', + ], + [ + 'CREATE INDEX test_idx ON t(indexcol COLLATE BINARY);', + 'CREATE INDEX test_idx ON t(`newindexcol` COLLATE BINARY)', + ], + [ + 'CREATE INDEX test_idx ON t(indexcol COLLATE BINARY ASC);', + 'CREATE INDEX test_idx ON t(`newindexcol` COLLATE BINARY ASC)', + ], + [ + ' + cReATE uniQUE inDEx + iF nOT ExISts + main.test_idx on t ( + ( (( + inDEXcoL + ) )) COLLATE BINARY ASC + ); + ', + 'CREATE UNIQUE INDEX test_idx on t ( + ( (( + `newindexcol` + ) )) COLLATE BINARY ASC + )', + ], + ]; + } + + /** + * @dataProvider customIndexSQLDataProvider + * @param string $indexSQL Index creation SQL + * @param string $newIndexSQL Expected new index creation SQL + */ + public function testRenameColumnWithCustomIndex(string $indexSQL, string $newIndexSQL) + { + $table = new Table('t', [], $this->adapter); + $table + ->addColumn('indexcol', 'integer') + ->create(); + + $this->adapter->execute($indexSQL); + + $this->assertTrue($this->adapter->hasIndex($table->getName(), 'indexcol')); + $this->assertFalse($this->adapter->hasIndex($table->getName(), 'newindexcol')); + + $table->renameColumn('indexcol', 'newindexcol')->update(); + + $this->assertFalse($this->adapter->hasIndex($table->getName(), 'indexcol')); + $this->assertTrue($this->adapter->hasIndex($table->getName(), 'newindexcol')); + + $index = $this->adapter->fetchRow("SELECT sql FROM sqlite_master WHERE type = 'index' AND name = 'test_idx'"); + $this->assertSame($newIndexSQL, $index['sql']); + } + + /** + * Index SQL is mostly returned as-is, hence custom indices can contain + * a wide variety of formats. + */ + public static function customCompositeIndexSQLDataProvider(): array + { + return [ + [ + 'CREATE INDEX test_idx ON t(indexcol1, indexcol2, indexcol3);', + 'CREATE INDEX test_idx ON t(indexcol1, `newindexcol`, indexcol3)', + ], + [ + 'CREATE INDEX test_idx ON t(`indexcol1`, `indexcol2`, `indexcol3`);', + 'CREATE INDEX test_idx ON t(`indexcol1`, `newindexcol`, `indexcol3`)', + ], + [ + 'CREATE INDEX test_idx ON t("indexcol1", "indexcol2", "indexcol3");', + 'CREATE INDEX test_idx ON t("indexcol1", `newindexcol`, "indexcol3")', + ], + [ + 'CREATE INDEX test_idx ON t([indexcol1], [indexcol2], [indexcol3]);', + 'CREATE INDEX test_idx ON t([indexcol1], `newindexcol`, [indexcol3])', + ], + [ + 'CREATE INDEX test_idx ON t(indexcol1 ASC, indexcol2 DESC, indexcol3);', + 'CREATE INDEX test_idx ON t(indexcol1 ASC, `newindexcol` DESC, indexcol3)', + ], + [ + 'CREATE INDEX test_idx ON t(`indexcol1` ASC, `indexcol2` DESC, `indexcol3`);', + 'CREATE INDEX test_idx ON t(`indexcol1` ASC, `newindexcol` DESC, `indexcol3`)', + ], + [ + 'CREATE INDEX test_idx ON t("indexcol1" ASC, "indexcol2" DESC, "indexcol3");', + 'CREATE INDEX test_idx ON t("indexcol1" ASC, `newindexcol` DESC, "indexcol3")', + ], + [ + 'CREATE INDEX test_idx ON t([indexcol1] ASC, [indexcol2] DESC, [indexcol3]);', + 'CREATE INDEX test_idx ON t([indexcol1] ASC, `newindexcol` DESC, [indexcol3])', + ], + [ + 'CREATE INDEX test_idx ON t(indexcol1 COLLATE BINARY, indexcol2 COLLATE NOCASE, indexcol3);', + 'CREATE INDEX test_idx ON t(indexcol1 COLLATE BINARY, `newindexcol` COLLATE NOCASE, indexcol3)', + ], + [ + 'CREATE INDEX test_idx ON t(indexcol1 COLLATE BINARY ASC, indexcol2 COLLATE NOCASE DESC, indexcol3);', + 'CREATE INDEX test_idx ON t(indexcol1 COLLATE BINARY ASC, `newindexcol` COLLATE NOCASE DESC, indexcol3)', + ], + [ + ' + cReATE uniQUE inDEx + iF nOT ExISts + main.test_idx on t ( + inDEXcoL1 , + ( (( + inDEXcoL2 + ) )) COLLATE BINARY ASC , + inDEXcoL3 + ); + ', + 'CREATE UNIQUE INDEX test_idx on t ( + inDEXcoL1 , + ( (( + `newindexcol` + ) )) COLLATE BINARY ASC , + inDEXcoL3 + )', + ], + ]; + } + + /** + * Index SQL is mostly returned as-is, hence custom indices can contain + * a wide variety of formats. + * + * @dataProvider customCompositeIndexSQLDataProvider + * @param string $indexSQL Index creation SQL + * @param string $newIndexSQL Expected new index creation SQL + */ + public function testRenameColumnWithCustomCompositeIndex(string $indexSQL, string $newIndexSQL) + { + $table = new Table('t', [], $this->adapter); + $table + ->addColumn('indexcol1', 'integer') + ->addColumn('indexcol2', 'integer') + ->addColumn('indexcol3', 'integer') + ->create(); + + $this->adapter->execute($indexSQL); + + $this->assertTrue($this->adapter->hasIndex($table->getName(), ['indexcol1', 'indexcol2', 'indexcol3'])); + $this->assertFalse($this->adapter->hasIndex($table->getName(), ['indexcol1', 'newindexcol', 'indexcol3'])); + + $table->renameColumn('indexcol2', 'newindexcol')->update(); + + $this->assertFalse($this->adapter->hasIndex($table->getName(), ['indexcol1', 'indexcol2', 'indexcol3'])); + $this->assertTrue($this->adapter->hasIndex($table->getName(), ['indexcol1', 'newindexcol', 'indexcol3'])); + + $index = $this->adapter->fetchRow("SELECT sql FROM sqlite_master WHERE type = 'index' AND name = 'test_idx'"); + $this->assertSame($newIndexSQL, $index['sql']); + } + + public function testChangeColumn() + { + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'string') + ->save(); + $this->assertTrue($this->adapter->hasColumn('t', 'column1')); + $newColumn1 = new Column(); + $newColumn1->setName('column1'); + $newColumn1->setType('string'); + $table->changeColumn('column1', $newColumn1); + $this->assertTrue($this->adapter->hasColumn('t', 'column1')); + $newColumn2 = new Column(); + $newColumn2->setName('column2') + ->setType('string'); + $table->changeColumn('column1', $newColumn2)->save(); + $this->assertFalse($this->adapter->hasColumn('t', 'column1')); + $this->assertTrue($this->adapter->hasColumn('t', 'column2')); + } + + public function testChangeColumnDefaultValue() + { + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'string', ['default' => 'test']) + ->save(); + $newColumn1 = new Column(); + $newColumn1 + ->setName('column1') + ->setDefault('test1') + ->setType('string'); + $table->changeColumn('column1', $newColumn1)->save(); + $rows = $this->adapter->fetchAll('pragma table_info(t)'); + + $this->assertEquals("'test1'", $rows[1]['dflt_value']); + } + + /** + * @group bug922 + */ + public function testChangeColumnWithForeignKey() + { + $refTable = new Table('ref_table', [], $this->adapter); + $refTable->addColumn('field1', 'string')->save(); + + $table = new Table('another_table', [], $this->adapter); + $table + ->addColumn('ref_table_id', 'integer') + ->addForeignKey(['ref_table_id'], 'ref_table', ['id']) + ->save(); + + $this->assertTrue($this->adapter->hasForeignKey($table->getName(), ['ref_table_id'])); + + $table->changeColumn('ref_table_id', 'float')->save(); + + $this->assertTrue($this->adapter->hasForeignKey($table->getName(), ['ref_table_id'])); + } + + public function testChangeColumnWithIndex() + { + $table = new Table('t', [], $this->adapter); + $table + ->addColumn('indexcol', 'integer') + ->addIndex( + 'indexcol', + ['unique' => true] + ) + ->create(); + + $this->assertTrue($this->adapter->hasIndex($table->getName(), 'indexcol')); + + $table->changeColumn('indexcol', 'integer', ['null' => false])->update(); + + $this->assertTrue($this->adapter->hasIndex($table->getName(), 'indexcol')); + } + + public function testChangeColumnWithTrigger() + { + $table = new Table('t', [], $this->adapter); + $table + ->addColumn('triggercol', 'integer') + ->addColumn('othercol', 'integer') + ->create(); + + $triggerSQL = + 'CREATE TRIGGER update_t_othercol UPDATE OF triggercol ON t + BEGIN + UPDATE t SET othercol = new.triggercol; + END'; + + $this->adapter->execute($triggerSQL); + + $rows = $this->adapter->fetchAll( + "SELECT * FROM sqlite_master WHERE `type` = 'trigger' AND tbl_name = 't'" + ); + $this->assertCount(1, $rows); + $this->assertEquals('trigger', $rows[0]['type']); + $this->assertEquals('update_t_othercol', $rows[0]['name']); + $this->assertEquals($triggerSQL, $rows[0]['sql']); + + $table->changeColumn('triggercol', 'integer', ['null' => false])->update(); + + $rows = $this->adapter->fetchAll( + "SELECT * FROM sqlite_master WHERE `type` = 'trigger' AND tbl_name = 't'" + ); + $this->assertCount(1, $rows); + $this->assertEquals('trigger', $rows[0]['type']); + $this->assertEquals('update_t_othercol', $rows[0]['name']); + $this->assertEquals($triggerSQL, $rows[0]['sql']); + } + + public function testChangeColumnDefaultToZero() + { + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'integer') + ->save(); + $newColumn1 = new Column(); + $newColumn1->setDefault(0) + ->setName('column1') + ->setType('integer'); + $table->changeColumn('column1', $newColumn1)->save(); + $rows = $this->adapter->fetchAll('pragma table_info(t)'); + $this->assertEquals('0', $rows[1]['dflt_value']); + } + + public function testChangeColumnDefaultToNull() + { + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'string', ['default' => 'test']) + ->save(); + $newColumn1 = new Column(); + $newColumn1->setDefault(null) + ->setName('column1') + ->setType('string'); + $table->changeColumn('column1', $newColumn1)->save(); + $rows = $this->adapter->fetchAll('pragma table_info(t)'); + $this->assertNull($rows[1]['dflt_value']); + } + + public function testChangeColumnWithCommasInCommentsOrDefaultValue() + { + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'string', ['default' => 'one, two or three', 'comment' => 'three, two or one']) + ->save(); + $newColumn1 = new Column(); + $newColumn1->setDefault('another default') + ->setName('column1') + ->setComment('another comment') + ->setType('string'); + $table->changeColumn('column1', $newColumn1)->save(); + $cols = $this->adapter->getColumns('t'); + $this->assertEquals('another default', (string)$cols[1]->getDefault()); + } + + /** + * @dataProvider columnCreationArgumentProvider + */ + public function testDropColumn($columnCreationArgs) + { + $table = new Table('t', [], $this->adapter); + $columnName = $columnCreationArgs[0]; + call_user_func_array([$table, 'addColumn'], $columnCreationArgs); + $table->save(); + $this->assertTrue($this->adapter->hasColumn('t', $columnName)); + + $table->removeColumn($columnName)->save(); + + $this->assertFalse($this->adapter->hasColumn('t', $columnName)); + } + + public function testDropColumnWithIndex() + { + $table = new Table('t', [], $this->adapter); + $table + ->addColumn('indexcol', 'integer') + ->addIndex('indexcol') + ->create(); + + $this->assertTrue($this->adapter->hasIndex($table->getName(), 'indexcol')); + + $table->removeColumn('indexcol')->update(); + + $this->assertFalse($this->adapter->hasIndex($table->getName(), 'indexcol')); + } + + public function testDropColumnWithUniqueIndex() + { + $table = new Table('t', [], $this->adapter); + $table + ->addColumn('indexcol', 'integer') + ->addIndex('indexcol', ['unique' => true]) + ->create(); + + $this->assertTrue($this->adapter->hasIndex($table->getName(), 'indexcol')); + + $table->removeColumn('indexcol')->update(); + + $this->assertFalse($this->adapter->hasIndex($table->getName(), 'indexcol')); + } + + public function testDropColumnWithCompositeIndex() + { + $table = new Table('t', [], $this->adapter); + $table + ->addColumn('indexcol1', 'integer') + ->addColumn('indexcol2', 'integer') + ->addIndex(['indexcol1', 'indexcol2']) + ->create(); + + $this->assertTrue($this->adapter->hasIndex($table->getName(), ['indexcol1', 'indexcol2'])); + + $table->removeColumn('indexcol2')->update(); + + $this->assertFalse($this->adapter->hasIndex($table->getName(), ['indexcol1', 'indexcol2'])); + } + + /** + * Tests that removing columns does not accidentally drop indices + * on table names that match the column to remove. + */ + public function testDropColumnWithIndexMatchingTheTableName() + { + $table = new Table('indexcol', [], $this->adapter); + $table + ->addColumn('indexcol', 'integer') + ->addColumn('indexcolumn', 'integer') + ->addIndex('indexcolumn') + ->create(); + + $this->assertTrue($this->adapter->hasIndex($table->getName(), 'indexcolumn')); + + $table->removeColumn('indexcol')->update(); + + $this->assertTrue($this->adapter->hasIndex($table->getName(), 'indexcolumn')); + } + + /** + * Tests that removing columns does not accidentally drop indices + * that contain column names that partially match the column to remove. + */ + public function testDropColumnWithIndexColumnPartialMatch() + { + $table = new Table('t', [], $this->adapter); + $table + ->addColumn('indexcol', 'integer') + ->addColumn('indexcolumn', 'integer') + ->create(); + + $this->adapter->execute('CREATE INDEX custom_idx ON t (indexcolumn)'); + + $this->assertTrue($this->adapter->hasIndex($table->getName(), 'indexcolumn')); + + $table->removeColumn('indexcol')->update(); + + $this->assertTrue($this->adapter->hasIndex($table->getName(), 'indexcolumn')); + } + + /** + * Indices with expressions are not being removed. + */ + public function testDropColumnWithExpressionIndex() + { + $table = new Table('t', [], $this->adapter); + $table + ->addColumn('indexcol', 'integer') + ->create(); + + $this->adapter->execute('CREATE INDEX custom_idx ON t (ABS(indexcol))'); + + $this->assertTrue($this->adapter->hasIndexByName('t', 'custom_idx')); + + $this->expectException(PDOException::class); + $this->expectExceptionMessage('no such column: indexcol'); + + $table->removeColumn('indexcol')->update(); + } + + /** + * @dataProvider customIndexSQLDataProvider + * @param string $indexSQL Index creation SQL + */ + public function testDropColumnWithCustomIndex(string $indexSQL) + { + $table = new Table('t', [], $this->adapter); + $table + ->addColumn('indexcol', 'integer') + ->create(); + + $this->adapter->execute($indexSQL); + + $this->assertTrue($this->adapter->hasIndex($table->getName(), 'indexcol')); + + $table->removeColumn('indexcol')->update(); + + $this->assertFalse($this->adapter->hasIndex($table->getName(), 'indexcol')); + } + + /** + * @dataProvider customCompositeIndexSQLDataProvider + * @param string $indexSQL Index creation SQL + */ + public function testDropColumnWithCustomCompositeIndex(string $indexSQL) + { + $table = new Table('t', [], $this->adapter); + $table + ->addColumn('indexcol1', 'integer') + ->addColumn('indexcol2', 'integer') + ->addColumn('indexcol3', 'integer') + ->create(); + + $this->adapter->execute($indexSQL); + + $this->assertTrue($this->adapter->hasIndex($table->getName(), ['indexcol1', 'indexcol2', 'indexcol3'])); + $this->assertFalse($this->adapter->hasIndex($table->getName(), ['indexcol1', 'indexcol3'])); + + $table->removeColumn('indexcol2')->update(); + + $this->assertFalse($this->adapter->hasIndex($table->getName(), ['indexcol1', 'indexcol2', 'indexcol3'])); + $this->assertFalse($this->adapter->hasIndex($table->getName(), ['indexcol1', 'indexcol3'])); + } + + public static function columnCreationArgumentProvider() + { + return [ + [['column1', 'string']], + [['profile_colour', 'integer']], + ]; + } + + public static function columnsProvider() + { + return [ + ['column1', 'string', []], + ['column2', 'integer', []], + ['column3', 'biginteger', []], + ['column4', 'text', []], + ['column5', 'float', []], + ['column7', 'datetime', []], + ['column8', 'time', []], + ['column9', 'timestamp', []], + ['column10', 'date', []], + ['column11', 'binary', []], + ['column13', 'string', ['limit' => 10]], + ['column15', 'smallinteger', []], + ['column15', 'integer', []], + ['column23', 'json', []], + ]; + } + + public function testAddIndex() + { + $table = new Table('table1', [], $this->adapter); + $table->addColumn('email', 'string') + ->save(); + $this->assertFalse($table->hasIndex('email')); + $table->addIndex('email') + ->save(); + $this->assertTrue($table->hasIndex('email')); + } + + public function testDropIndex() + { + // single column index + $table = new Table('table1', [], $this->adapter); + $table->addColumn('email', 'string') + ->addIndex('email') + ->save(); + $this->assertTrue($table->hasIndex('email')); + $this->adapter->dropIndex($table->getName(), 'email'); + $this->assertFalse($table->hasIndex('email')); + + // multiple column index + $table2 = new Table('table2', [], $this->adapter); + $table2->addColumn('fname', 'string') + ->addColumn('lname', 'string') + ->addIndex(['fname', 'lname']) + ->save(); + $this->assertTrue($table2->hasIndex(['fname', 'lname'])); + $this->adapter->dropIndex($table2->getName(), ['fname', 'lname']); + $this->assertFalse($table2->hasIndex(['fname', 'lname'])); + + // single column index with name specified + $table3 = new Table('table3', [], $this->adapter); + $table3->addColumn('email', 'string') + ->addIndex('email', ['name' => 'someindexname']) + ->save(); + $this->assertTrue($table3->hasIndex('email')); + $this->adapter->dropIndex($table3->getName(), 'email'); + $this->assertFalse($table3->hasIndex('email')); + + // multiple column index with name specified + $table4 = new Table('table4', [], $this->adapter); + $table4->addColumn('fname', 'string') + ->addColumn('lname', 'string') + ->addIndex(['fname', 'lname'], ['name' => 'multiname']) + ->save(); + $this->assertTrue($table4->hasIndex(['fname', 'lname'])); + $this->adapter->dropIndex($table4->getName(), ['fname', 'lname']); + $this->assertFalse($table4->hasIndex(['fname', 'lname'])); + } + + public function testDropIndexByName() + { + // single column index + $table = new Table('table1', [], $this->adapter); + $table->addColumn('email', 'string') + ->addIndex('email', ['name' => 'myemailindex']) + ->save(); + $this->assertTrue($table->hasIndex('email')); + $this->adapter->dropIndexByName($table->getName(), 'myemailindex'); + $this->assertFalse($table->hasIndex('email')); + + // multiple column index + $table2 = new Table('table2', [], $this->adapter); + $table2->addColumn('fname', 'string') + ->addColumn('lname', 'string') + ->addIndex(['fname', 'lname'], ['name' => 'twocolumnindex']) + ->save(); + $this->assertTrue($table2->hasIndex(['fname', 'lname'])); + $this->adapter->dropIndexByName($table2->getName(), 'twocolumnindex'); + $this->assertFalse($table2->hasIndex(['fname', 'lname'])); + } + + public function testAddForeignKey() + { + $refTable = new Table('ref_table', [], $this->adapter); + $refTable->addColumn('field1', 'string')->save(); + + $table = new Table('table', [], $this->adapter); + $table + ->addColumn('ref_table_id', 'integer') + ->addForeignKey(['ref_table_id'], 'ref_table', ['id']) + ->save(); + + $this->assertTrue($this->adapter->hasForeignKey($table->getName(), ['ref_table_id'])); + } + + public function testDropForeignKey() + { + $refTable = new Table('ref_table', [], $this->adapter); + $refTable->addColumn('field1', 'string') + ->addIndex(['field1'], ['unique' => true]) + ->save(); + + $table = new Table('another_table', [], $this->adapter); + $opts = [ + 'update' => 'CASCADE', + 'delete' => 'CASCADE', + ]; + $table + ->addColumn('ref_table_id', 'integer') + ->addColumn('ref_table_field', 'string') + ->addForeignKey(['ref_table_id'], 'ref_table', ['id']) + ->addForeignKey(['ref_table_field'], 'ref_table', ['field1'], $opts) + ->save(); + + $this->assertTrue($this->adapter->hasForeignKey($table->getName(), ['ref_table_id'])); + + $this->adapter->dropForeignKey($table->getName(), ['ref_table_id']); + $this->assertFalse($this->adapter->hasForeignKey($table->getName(), ['ref_table_id'])); + + $this->assertTrue($this->adapter->hasForeignKey($table->getName(), ['ref_table_field'])); + + $this->adapter->dropForeignKey($table->getName(), ['ref_table_field']); + $this->assertTrue($this->adapter->hasTable($table->getName())); + } + + public function testDropForeignKeyWithQuoteVariants() + { + $refTable = new Table('ref_table', [], $this->adapter); + $refTable->addColumn('field1', 'string') + ->addIndex(['field1'], ['unique' => true]) + ->save(); + + $this->adapter->execute(" + CREATE TABLE `table` ( + `id` INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + [ref_[_brackets] INTEGER NOT NULL, + `ref_``_ticks` INTEGER NOT NULL, + \"ref_\"\"_double_quotes\" INTEGER NOT NULL, + 'ref_''_single_quotes' INTEGER NOT NULL, + ref_no_quotes INTEGER NOT NULL, + ref_no_space INTEGER NOT NULL, + ref_lots_of_space INTEGER NOT NULL, + FOREIGN KEY ([ref_[_brackets]) REFERENCES `ref_table` (`id`), + FOREIGN KEY (`ref_``_ticks`) REFERENCES `ref_table` (`id`), + FOREIGN KEY (\"ref_\"\"_double_quotes\") REFERENCES `ref_table` (`id`), + FOREIGN KEY ('ref_''_single_quotes') REFERENCES `ref_table` (`id`), + FOREIGN KEY (ref_no_quotes) REFERENCES `ref_table` (`id`), + FOREIGN KEY (`ref_``_ticks`, 'ref_''_single_quotes') REFERENCES `ref_table` (`id`, `field1`), + FOREIGN KEY(`ref_no_space`,`ref_no_space`)REFERENCES`ref_table`(`id`,`id`), + foreign KEY + ( `ref_lots_of_space` ,`ref_lots_of_space` ) + REFErences `ref_table` (`id` , `id`) + ) + "); + + $this->assertTrue($this->adapter->hasForeignKey('table', ['ref_[_brackets'])); + $this->adapter->dropForeignKey('table', ['ref_[_brackets']); + $this->assertFalse($this->adapter->hasForeignKey('table', ['ref_[_brackets'])); + + $this->assertTrue($this->adapter->hasForeignKey('table', ['ref_"_double_quotes'])); + $this->adapter->dropForeignKey('table', ['ref_"_double_quotes']); + $this->assertFalse($this->adapter->hasForeignKey('table', ['ref_"_double_quotes'])); + + $this->assertTrue($this->adapter->hasForeignKey('table', ["ref_'_single_quotes"])); + $this->adapter->dropForeignKey('table', ["ref_'_single_quotes"]); + $this->assertFalse($this->adapter->hasForeignKey('table', ["ref_'_single_quotes"])); + + $this->assertTrue($this->adapter->hasForeignKey('table', ['ref_no_quotes'])); + $this->adapter->dropForeignKey('table', ['ref_no_quotes']); + $this->assertFalse($this->adapter->hasForeignKey('table', ['ref_no_quotes'])); + + $this->assertTrue($this->adapter->hasForeignKey('table', ['ref_`_ticks', "ref_'_single_quotes"])); + $this->adapter->dropForeignKey('table', ['ref_`_ticks', "ref_'_single_quotes"]); + $this->assertFalse($this->adapter->hasForeignKey('table', ['ref_`_ticks', "ref_'_single_quotes"])); + + $this->assertTrue($this->adapter->hasForeignKey('table', ['ref_no_space', 'ref_no_space'])); + $this->adapter->dropForeignKey('table', ['ref_no_space', 'ref_no_space']); + $this->assertFalse($this->adapter->hasForeignKey('table', ['ref_no_space', 'ref_no_space'])); + + $this->assertTrue($this->adapter->hasForeignKey('table', ['ref_lots_of_space', 'ref_lots_of_space'])); + $this->adapter->dropForeignKey('table', ['ref_lots_of_space', 'ref_lots_of_space']); + $this->assertFalse($this->adapter->hasForeignKey('table', ['ref_lots_of_space', 'ref_lots_of_space'])); + } + + public function testDropForeignKeyWithMultipleColumns() + { + $refTable = new Table('ref_table', [], $this->adapter); + $refTable + ->addColumn('field1', 'string') + ->addColumn('field2', 'string') + ->addIndex(['id', 'field1'], ['unique' => true]) + ->addIndex(['field1', 'id'], ['unique' => true]) + ->addIndex(['id', 'field1', 'field2'], ['unique' => true]) + ->save(); + + $table = new Table('table', [], $this->adapter); + $table + ->addColumn('ref_table_id', 'integer') + ->addColumn('ref_table_field1', 'string') + ->addColumn('ref_table_field2', 'string') + ->addForeignKey( + ['ref_table_id', 'ref_table_field1'], + 'ref_table', + ['id', 'field1'] + ) + ->addForeignKey( + ['ref_table_field1', 'ref_table_id'], + 'ref_table', + ['field1', 'id'] + ) + ->addForeignKey( + ['ref_table_id', 'ref_table_field1', 'ref_table_field2'], + 'ref_table', + ['id', 'field1', 'field2'] + ) + ->save(); + + $this->assertTrue($this->adapter->hasForeignKey($table->getName(), ['ref_table_id', 'ref_table_field1'])); + $this->adapter->dropForeignKey($table->getName(), ['ref_table_id', 'ref_table_field1']); + $this->assertFalse($this->adapter->hasForeignKey($table->getName(), ['ref_table_id', 'ref_table_field1'])); + $this->assertTrue( + $this->adapter->hasForeignKey($table->getName(), ['ref_table_id', 'ref_table_field1', 'ref_table_field2']), + 'dropForeignKey() should only affect foreign keys that comprise of exactly the given columns' + ); + $this->assertTrue( + $this->adapter->hasForeignKey($table->getName(), ['ref_table_field1', 'ref_table_id']), + 'dropForeignKey() should only affect foreign keys that comprise of columns in exactly the given order' + ); + + $this->assertTrue($this->adapter->hasForeignKey($table->getName(), ['ref_table_field1', 'ref_table_id'])); + $this->adapter->dropForeignKey($table->getName(), ['ref_table_field1', 'ref_table_id']); + $this->assertFalse($this->adapter->hasForeignKey($table->getName(), ['ref_table_field1', 'ref_table_id'])); + } + + public function testDropForeignKeyWithIdenticalMultipleColumns() + { + $refTable = new Table('ref_table', [], $this->adapter); + $refTable + ->addColumn('field1', 'string') + ->addIndex(['id', 'field1'], ['unique' => true]) + ->save(); + + $table = new Table('table', [], $this->adapter); + $table + ->addColumn('ref_table_id', 'integer', ['signed' => false]) + ->addColumn('ref_table_field1', 'string') + ->addForeignKeyWithName( + 'ref_table_fk_1', + ['ref_table_id', 'ref_table_field1'], + 'ref_table', + ['id', 'field1'], + ) + ->addForeignKeyWithName( + 'ref_table_fk_2', + ['ref_table_id', 'ref_table_field1'], + 'ref_table', + ['id', 'field1'] + ) + ->save(); + + $this->assertTrue($this->adapter->hasForeignKey($table->getName(), ['ref_table_id', 'ref_table_field1'])); + $this->assertTrue($this->adapter->hasForeignKey($table->getName(), [], 'ref_table_fk_1')); + $this->assertTrue($this->adapter->hasForeignKey($table->getName(), [], 'ref_table_fk_2')); + + $this->adapter->dropForeignKey($table->getName(), ['ref_table_id', 'ref_table_field1']); + + $this->assertFalse($this->adapter->hasForeignKey($table->getName(), ['ref_table_id', 'ref_table_field1'])); + $this->assertFalse($this->adapter->hasForeignKey($table->getName(), [], 'ref_table_fk_1')); + $this->assertFalse($this->adapter->hasForeignKey($table->getName(), [], 'ref_table_fk_2')); + } + + public static function nonExistentForeignKeyColumnsProvider(): array + { + return [ + [['ref_table_id']], + [['ref_table_field1']], + [['ref_table_field1', 'ref_table_id']], + [['non_existent_column']], + ]; + } + + /** + * @dataProvider nonExistentForeignKeyColumnsProvider + * @param array $columns + */ + public function testDropForeignKeyByNonExistentKeyColumns(array $columns) + { + $refTable = new Table('ref_table', [], $this->adapter); + $refTable + ->addColumn('field1', 'string') + ->addIndex(['id', 'field1'], ['unique' => true]) + ->save(); + + $table = new Table('table', [], $this->adapter); + $table + ->addColumn('ref_table_id', 'integer') + ->addColumn('ref_table_field1', 'string') + ->addForeignKey( + ['ref_table_id', 'ref_table_field1'], + 'ref_table', + ['id', 'field1'] + ) + ->save(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage(sprintf( + 'No foreign key on column(s) `%s` exists', + implode(', ', $columns) + )); + + $this->adapter->dropForeignKey($table->getName(), $columns); + } + + public function testDropForeignKeyCaseInsensitivity() + { + $refTable = new Table('ref_table', [], $this->adapter); + $refTable->save(); + + $table = new Table('another_table', [], $this->adapter); + $table + ->addColumn('ref_table_id', 'integer') + ->addForeignKey(['ref_table_id'], 'ref_table', ['id']) + ->save(); + + $this->adapter->dropForeignKey($table->getName(), ['REF_TABLE_ID']); + $this->assertFalse($this->adapter->hasForeignKey($table->getName(), ['ref_table_id'])); + } + + public function testDropForeignKeyByName() + { + $this->expectExceptionMessage('SQLite does not have named foreign keys'); + $this->expectException(BadMethodCallException::class); + + $refTable = new Table('ref_table', [], $this->adapter); + $refTable->save(); + + $table = new Table('table', [], $this->adapter); + $table + ->addColumn('ref_table_id', 'integer', ['signed' => false]) + ->addForeignKeyWithName('my_constraint', ['ref_table_id'], 'ref_table', ['id']) + ->save(); + + $this->adapter->dropForeignKey($table->getName(), [], 'my_constraint'); + } + + public function testHasDatabase() + { + if ($this->config['database'] === ':memory:') { + $this->markTestSkipped('Skipping hasDatabase() when testing in-memory db.'); + } + $this->assertFalse($this->adapter->hasDatabase('fake_database_name')); + $this->assertTrue($this->adapter->hasDatabase($this->config['database'])); + } + + public function testDropDatabase() + { + $this->assertFalse($this->adapter->hasDatabase('phinx_temp_database')); + $this->adapter->createDatabase('phinx_temp_database'); + $this->assertTrue($this->adapter->hasDatabase('phinx_temp_database')); + $this->adapter->dropDatabase('phinx_temp_database'); + } + + public function testAddColumnWithComment() + { + $table = new Table('table1', [], $this->adapter); + $table->addColumn('column1', 'string', ['comment' => $comment = 'Comments from "column1"']) + ->save(); + + $rows = $this->adapter->fetchAll('select * from sqlite_master where `type` = \'table\''); + + foreach ($rows as $row) { + if ($row['tbl_name'] === 'table1') { + $sql = $row['sql']; + } + } + + $this->assertMatchesRegularExpression('/\/\* Comments from "column1" \*\//', $sql); + } + + public function testPhinxTypeLiteral() + { + $this->assertEquals( + [ + 'name' => Literal::from('fake'), + 'limit' => null, + 'scale' => null, + ], + $this->adapter->getPhinxType('fake') + ); + } + + public function testPhinxTypeNotValidTypeRegex() + { + $exp = [ + 'name' => Literal::from('?int?'), + 'limit' => null, + 'scale' => null, + ]; + $this->assertEquals($exp, $this->adapter->getPhinxType('?int?')); + } + + public function testAddIndexTwoTablesSameIndex() + { + $table = new Table('table1', [], $this->adapter); + $table->addColumn('email', 'string') + ->save(); + $table2 = new Table('table2', [], $this->adapter); + $table2->addColumn('email', 'string') + ->save(); + + $this->assertFalse($table->hasIndex('email')); + $this->assertFalse($table2->hasIndex('email')); + + $table->addIndex('email') + ->save(); + $table2->addIndex('email') + ->save(); + + $this->assertTrue($table->hasIndex('email')); + $this->assertTrue($table2->hasIndex('email')); + } + + public function testBulkInsertData() + { + $table = new Table('table1', [], $this->adapter); + $table->addColumn('column1', 'string') + ->addColumn('column2', 'integer', ['null' => true]) + ->insert([ + [ + 'column1' => 'value1', + 'column2' => 1, + ], + [ + 'column1' => 'value2', + 'column2' => 2, + ], + ]) + ->insert( + [ + 'column1' => 'value3', + 'column2' => 3, + ] + ) + ->insert( + [ + 'column1' => '\'value4\'', + 'column2' => null, + ] + ) + ->save(); + $rows = $this->adapter->fetchAll('SELECT * FROM table1'); + + $this->assertEquals('value1', $rows[0]['column1']); + $this->assertEquals('value2', $rows[1]['column1']); + $this->assertEquals('value3', $rows[2]['column1']); + $this->assertEquals('\'value4\'', $rows[3]['column1']); + $this->assertEquals(1, $rows[0]['column2']); + $this->assertEquals(2, $rows[1]['column2']); + $this->assertEquals(3, $rows[2]['column2']); + $this->assertNull($rows[3]['column2']); + } + + public function testInsertData() + { + $table = new Table('table1', [], $this->adapter); + $table->addColumn('column1', 'string') + ->addColumn('column2', 'integer', ['null' => true]) + ->insert([ + [ + 'column1' => 'value1', + 'column2' => 1, + ], + [ + 'column1' => 'value2', + 'column2' => 2, + ], + ]) + ->insert( + [ + 'column1' => 'value3', + 'column2' => 3, + ] + ) + ->insert( + [ + 'column1' => '\'value4\'', + 'column2' => null, + ] + ) + ->save(); + + $rows = $this->adapter->fetchAll('SELECT * FROM table1'); + + $this->assertEquals('value1', $rows[0]['column1']); + $this->assertEquals('value2', $rows[1]['column1']); + $this->assertEquals('value3', $rows[2]['column1']); + $this->assertEquals('\'value4\'', $rows[3]['column1']); + $this->assertEquals(1, $rows[0]['column2']); + $this->assertEquals(2, $rows[1]['column2']); + $this->assertEquals(3, $rows[2]['column2']); + $this->assertNull($rows[3]['column2']); + } + + public function testBulkInsertDataEnum() + { + $table = new Table('table1', [], $this->adapter); + $table->addColumn('column1', 'string') + ->addColumn('column2', 'string', ['null' => true]) + ->addColumn('column3', 'string', ['default' => 'c']) + ->insert([ + 'column1' => 'a', + ]) + ->save(); + + $rows = $this->adapter->fetchAll('SELECT * FROM table1'); + + $this->assertEquals('a', $rows[0]['column1']); + $this->assertNull($rows[0]['column2']); + $this->assertEquals('c', $rows[0]['column3']); + } + + public function testNullWithoutDefaultValue() + { + // construct table with default/null combinations + $table = new Table('table1', [], $this->adapter); + $table->addColumn('aa', 'string', ['null' => true]) // no default value + ->addColumn('bb', 'string', ['null' => false]) // no default value + ->addColumn('cc', 'string', ['null' => true, 'default' => 'some1']) + ->addColumn('dd', 'string', ['null' => false, 'default' => 'some2']) + ->save(); + + // load table info + $columns = $this->adapter->getColumns('table1'); + + $this->assertCount(5, $columns); + + $aa = $columns[1]; + $bb = $columns[2]; + $cc = $columns[3]; + $dd = $columns[4]; + + $this->assertEquals('aa', $aa->getName()); + $this->assertTrue($aa->isNull()); + $this->assertNull($aa->getDefault()); + + $this->assertEquals('bb', $bb->getName()); + $this->assertFalse($bb->isNull()); + $this->assertNull($bb->getDefault()); + + $this->assertEquals('cc', $cc->getName()); + $this->assertTrue($cc->isNull()); + $this->assertEquals('some1', $cc->getDefault()); + + $this->assertEquals('dd', $dd->getName()); + $this->assertFalse($dd->isNull()); + $this->assertEquals('some2', $dd->getDefault()); + } + + public function testDumpCreateTable() + { + $this->adapter->setOptions(['dryrun' => true]); + $table = new Table('table1', [], $this->adapter); + + $table->addColumn('column1', 'string', ['null' => false]) + ->addColumn('column2', 'integer') + ->addColumn('column3', 'string', ['default' => 'test']) + ->save(); + + $expectedOutput = <<<'OUTPUT' +CREATE TABLE `table1` (`id` INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, `column1` VARCHAR NOT NULL, `column2` INTEGER NULL, `column3` VARCHAR NULL DEFAULT 'test'); +OUTPUT; + $actualOutput = join("\n", $this->out->messages()); + $this->assertStringContainsString($expectedOutput, $actualOutput, 'Passing the --dry-run option does not dump create table query to the output'); + } + + /** + * Creates the table "table1". + * Then sets phinx to dry run mode and inserts a record. + * Asserts that phinx outputs the insert statement and doesn't insert a record. + */ + public function testDumpInsert() + { + $table = new Table('table1', [], $this->adapter); + $table->addColumn('string_col', 'string') + ->addColumn('int_col', 'integer') + ->save(); + + $this->adapter->setOptions(['dryrun' => true]); + $this->adapter->insert($table->getTable(), [ + 'string_col' => 'test data', + ]); + + $this->adapter->insert($table->getTable(), [ + 'string_col' => null, + ]); + + $this->adapter->insert($table->getTable(), [ + 'int_col' => 23, + ]); + + $expectedOutput = <<<'OUTPUT' +INSERT INTO `table1` (`string_col`) VALUES ('test data'); +INSERT INTO `table1` (`string_col`) VALUES (null); +INSERT INTO `table1` (`int_col`) VALUES (23); +OUTPUT; + $actualOutput = join("\n", $this->out->messages()); + $actualOutput = preg_replace("/\r\n|\r/", "\n", $actualOutput); // normalize line endings for Windows + $this->assertStringContainsString($expectedOutput, $actualOutput, 'Passing the --dry-run option doesn\'t dump the insert to the output'); + + $countQuery = $this->adapter->query('SELECT COUNT(*) FROM table1'); + $this->assertTrue($countQuery->execute()); + $res = $countQuery->fetchAll(); + $this->assertEquals(0, $res[0][0]); + } + + /** + * Creates the table "table1". + * Then sets phinx to dry run mode and inserts some records. + * Asserts that phinx outputs the insert statement and doesn't insert any record. + */ + public function testDumpBulkinsert() + { + $table = new Table('table1', [], $this->adapter); + $table->addColumn('string_col', 'string') + ->addColumn('int_col', 'integer') + ->save(); + + $this->adapter->setOptions(['dryrun' => true]); + $this->adapter->bulkinsert($table->getTable(), [ + [ + 'string_col' => 'test_data1', + 'int_col' => 23, + ], + [ + 'string_col' => null, + 'int_col' => 42, + ], + ]); + + $expectedOutput = <<<'OUTPUT' +INSERT INTO `table1` (`string_col`, `int_col`) VALUES ('test_data1', 23), (null, 42); +OUTPUT; + $actualOutput = join("\n", $this->out->messages()); + $this->assertStringContainsString($expectedOutput, $actualOutput, 'Passing the --dry-run option doesn\'t dump the bulkinsert to the output'); + + $countQuery = $this->adapter->query('SELECT COUNT(*) FROM table1'); + $this->assertTrue($countQuery->execute()); + $res = $countQuery->fetchAll(); + $this->assertEquals(0, $res[0][0]); + } + + public function testDumpCreateTableAndThenInsert() + { + $this->adapter->setOptions(['dryrun' => true]); + $table = new Table('table1', ['id' => false, 'primary_key' => ['column1']], $this->adapter); + + $table->addColumn('column1', 'string', ['null' => false]) + ->addColumn('column2', 'integer') + ->save(); + + $expectedOutput = 'C'; + + $table = new Table('table1', [], $this->adapter); + $table->insert([ + 'column1' => 'id1', + 'column2' => 1, + ])->save(); + + $expectedOutput = <<<'OUTPUT' +CREATE TABLE `table1` (`column1` VARCHAR NOT NULL, `column2` INTEGER NULL, PRIMARY KEY (`column1`)); +INSERT INTO `table1` (`column1`, `column2`) VALUES ('id1', 1); +OUTPUT; + $actualOutput = join("\n", $this->out->messages()); + $actualOutput = preg_replace("/\r\n|\r/", "\n", $actualOutput); // normalize line endings for Windows + $this->assertStringContainsString($expectedOutput, $actualOutput, 'Passing the --dry-run option does not dump create and then insert table queries to the output'); + } + + /** + * Tests interaction with the query builder + */ + public function testQueryBuilder() + { + $table = new Table('table1', [], $this->adapter); + $table->addColumn('string_col', 'string') + ->addColumn('int_col', 'integer') + ->save(); + + $builder = $this->adapter->getQueryBuilder(Query::TYPE_INSERT); + $stm = $builder + ->insert(['string_col', 'int_col']) + ->into('table1') + ->values(['string_col' => 'value1', 'int_col' => 1]) + ->values(['string_col' => 'value2', 'int_col' => 2]) + ->execute(); + + $this->assertEquals(2, $stm->rowCount()); + + $builder = $this->adapter->getQueryBuilder(Query::TYPE_SELECT); + $stm = $builder + ->select('*') + ->from('table1') + ->where(['int_col >=' => 2]) + ->execute(); + + $this->assertEquals(0, $stm->rowCount()); + $this->assertEquals( + ['id' => 2, 'string_col' => 'value2', 'int_col' => '2'], + $stm->fetch('assoc') + ); + + $builder = $this->adapter->getQueryBuilder(Query::TYPE_DELETE); + $stm = $builder + ->delete('table1') + ->where(['int_col <' => 2]) + ->execute(); + + $this->assertEquals(1, $stm->rowCount()); + } + + public function testQueryWithParams() + { + $table = new Table('table1', [], $this->adapter); + $table->addColumn('string_col', 'string') + ->addColumn('int_col', 'integer') + ->save(); + + $this->adapter->insert($table->getTable(), [ + 'string_col' => 'test data', + 'int_col' => 10, + ]); + + $this->adapter->insert($table->getTable(), [ + 'string_col' => null, + ]); + + $this->adapter->insert($table->getTable(), [ + 'int_col' => 23, + ]); + + $countQuery = $this->adapter->query('SELECT COUNT(*) AS c FROM table1 WHERE int_col > ?', [5]); + $res = $countQuery->fetchAll('assoc'); + $this->assertEquals(2, $res[0]['c']); + + $this->adapter->execute('UPDATE table1 SET int_col = ? WHERE int_col IS NULL', [12]); + + $countQuery->execute([1]); + $res = $countQuery->fetchAll('assoc'); + $this->assertEquals(3, $res[0]['c']); + } + + /** + * Tests adding more than one column to a table + * that already exists due to adapters having different add column instructions + */ + public function testAlterTableColumnAdd() + { + $table = new Table('table1', [], $this->adapter); + $table->create(); + + $table->addColumn('string_col', 'string', ['default' => '']); + $table->addColumn('string_col_2', 'string', ['null' => true]); + $table->addColumn('string_col_3', 'string', ['null' => false]); + $table->addTimestamps(); + $table->save(); + + $columns = $this->adapter->getColumns('table1'); + $expected = [ + ['name' => 'id', 'type' => 'integer', 'default' => null, 'null' => false], + ['name' => 'string_col', 'type' => 'string', 'default' => '', 'null' => true], + ['name' => 'string_col_2', 'type' => 'string', 'default' => null, 'null' => true], + ['name' => 'string_col_3', 'type' => 'string', 'default' => null, 'null' => false], + ['name' => 'created_at', 'type' => 'timestamp', 'default' => 'CURRENT_TIMESTAMP', 'null' => false], + ['name' => 'updated_at', 'type' => 'timestamp', 'default' => null, 'null' => true], + ]; + + $this->assertEquals(count($expected), count($columns)); + + $columnCount = count($columns); + for ($i = 0; $i < $columnCount; $i++) { + $this->assertSame($expected[$i]['name'], $columns[$i]->getName(), "Wrong name for {$expected[$i]['name']}"); + $this->assertSame($expected[$i]['type'], $columns[$i]->getType(), "Wrong type for {$expected[$i]['name']}"); + $this->assertSame($expected[$i]['default'], $columns[$i]->getDefault() instanceof Literal ? (string)$columns[$i]->getDefault() : $columns[$i]->getDefault(), "Wrong default for {$expected[$i]['name']}"); + $this->assertSame($expected[$i]['null'], $columns[$i]->getNull(), "Wrong null for {$expected[$i]['name']}"); + } + } + + public function testAlterTableWithConstraints() + { + $table = new Table('table1', [], $this->adapter); + $table->create(); + + $table2 = new Table('table2', [], $this->adapter); + $table2->create(); + + $table + ->addColumn('table2_id', 'integer', ['null' => false]) + ->addForeignKey('table2_id', 'table2', 'id', [ + 'delete' => 'SET NULL', + ]); + $table->update(); + + $table->addColumn('column3', 'string', ['default' => null, 'null' => true]); + $table->update(); + + $columns = $this->adapter->getColumns('table1'); + $expected = [ + ['name' => 'id', 'type' => 'integer', 'default' => null, 'null' => false], + ['name' => 'table2_id', 'type' => 'integer', 'default' => null, 'null' => false], + ['name' => 'column3', 'type' => 'string', 'default' => null, 'null' => true], + ]; + + $this->assertEquals(count($expected), count($columns)); + + $columnCount = count($columns); + for ($i = 0; $i < $columnCount; $i++) { + $this->assertSame($expected[$i]['name'], $columns[$i]->getName(), "Wrong name for {$expected[$i]['name']}"); + $this->assertSame($expected[$i]['type'], $columns[$i]->getType(), "Wrong type for {$expected[$i]['name']}"); + $this->assertSame($expected[$i]['default'], $columns[$i]->getDefault() instanceof Literal ? (string)$columns[$i]->getDefault() : $columns[$i]->getDefault(), "Wrong default for {$expected[$i]['name']}"); + $this->assertSame($expected[$i]['null'], $columns[$i]->getNull(), "Wrong null for {$expected[$i]['name']}"); + } + } + + /** + * Tests that operations that trigger implicit table drops will not cause + * a foreign key constraint violation error. + */ + public function testAlterTableDoesNotViolateRestrictedForeignKeyConstraint() + { + $this->adapter->execute('PRAGMA foreign_keys = ON'); + + $articlesTable = new Table('articles', [], $this->adapter); + $articlesTable + ->insert(['id' => 1]) + ->save(); + + $commentsTable = new Table('comments', [], $this->adapter); + $commentsTable + ->addColumn('article_id', 'integer') + ->addForeignKey('article_id', 'articles', 'id', [ + 'update' => ForeignKey::RESTRICT, + 'delete' => ForeignKey::RESTRICT, + ]) + ->insert(['id' => 1, 'article_id' => 1]) + ->save(); + + $this->assertTrue($this->adapter->hasForeignKey('comments', ['article_id'])); + + $articlesTable + ->addColumn('new_column', 'integer') + ->update(); + + $articlesTable + ->renameColumn('new_column', 'new_column_renamed') + ->update(); + + $articlesTable + ->changeColumn('new_column_renamed', 'integer', [ + 'default' => 1, + ]) + ->update(); + + $articlesTable + ->removeColumn('new_column_renamed') + ->update(); + + $articlesTable + ->addIndex('id', ['name' => 'ID_IDX']) + ->update(); + + $articlesTable + ->removeIndex('id') + ->update(); + + $articlesTable + ->addForeignKey('id', 'comments', 'id') + ->update(); + + $articlesTable + ->dropForeignKey('id') + ->update(); + + $articlesTable + ->addColumn('id2', 'integer') + ->addIndex('id', ['unique' => true]) + ->changePrimaryKey('id2') + ->update(); + } + + /** + * Tests that foreign key constraint violations introduced around the table + * alteration process (being it implicitly by the process itself or by the user) + * will trigger an error accordingly. + */ + public function testAlterTableDoesViolateForeignKeyConstraintOnTargetTableChange() + { + $articlesTable = new Table('articles', [], $this->adapter); + $articlesTable + ->insert(['id' => 1]) + ->save(); + + $commentsTable = new Table('comments', [], $this->adapter); + $commentsTable + ->addColumn('article_id', 'integer') + ->addForeignKey('article_id', 'articles', 'id', [ + 'update' => ForeignKey::RESTRICT, + 'delete' => ForeignKey::RESTRICT, + ]) + ->insert(['id' => 1, 'article_id' => 1]) + ->save(); + + $this->assertTrue($this->adapter->hasForeignKey('comments', ['article_id'])); + + $this->adapter->execute('PRAGMA foreign_keys = OFF'); + $this->adapter->execute('DELETE FROM articles'); + $this->adapter->execute('PRAGMA foreign_keys = ON'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Integrity constraint violation: FOREIGN KEY constraint on `comments` failed.'); + + $articlesTable + ->addColumn('new_column', 'integer') + ->update(); + } + + /** + * Tests that foreign key constraint violations introduced around the table + * alteration process (being it implicitly by the process itself or by the user) + * will trigger an error accordingly. + */ + public function testAlterTableDoesViolateForeignKeyConstraintOnSourceTableChange() + { + $adapter = $this + ->getMockBuilder(SqliteAdapter::class) + ->setConstructorArgs([$this->config, $this->io]) + ->onlyMethods(['query']) + ->getMock(); + + $adapterReflection = new ReflectionObject($adapter); + $queryReflection = $adapterReflection->getParentClass()->getMethod('query'); + + $adapter + ->expects($this->atLeastOnce()) + ->method('query') + ->willReturnCallback(function (string $sql, array $params = []) use ($adapter, $queryReflection) { + if ($sql === 'PRAGMA foreign_key_check(`comments`)') { + $adapter->execute('PRAGMA foreign_keys = OFF'); + $adapter->execute('DELETE FROM articles'); + $adapter->execute('PRAGMA foreign_keys = ON'); + } + + return $queryReflection->invoke($adapter, $sql, $params); + }); + + $articlesTable = new Table('articles', [], $adapter); + $articlesTable + ->insert(['id' => 1]) + ->save(); + + $commentsTable = new Table('comments', [], $adapter); + $commentsTable + ->addColumn('article_id', 'integer') + ->addForeignKey('article_id', 'articles', 'id', [ + 'update' => ForeignKey::RESTRICT, + 'delete' => ForeignKey::RESTRICT, + ]) + ->insert(['id' => 1, 'article_id' => 1]) + ->save(); + + $this->assertTrue($adapter->hasForeignKey('comments', ['article_id'])); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Integrity constraint violation: FOREIGN KEY constraint on `comments` failed.'); + + $commentsTable + ->addColumn('new_column', 'integer') + ->update(); + } + + /** + * Tests that the adapter's foreign key validation does not apply when + * the `foreign_keys` pragma is set to `OFF`. + */ + public function testAlterTableForeignKeyConstraintValidationNotRunningWithDisabledForeignKeys() + { + $articlesTable = new Table('articles', [], $this->adapter); + $articlesTable + ->insert(['id' => 1]) + ->save(); + + $commentsTable = new Table('comments', [], $this->adapter); + $commentsTable + ->addColumn('article_id', 'integer') + ->addForeignKey('article_id', 'articles', 'id', [ + 'update' => ForeignKey::RESTRICT, + 'delete' => ForeignKey::RESTRICT, + ]) + ->insert(['id' => 1, 'article_id' => 1]) + ->save(); + + $this->assertTrue($this->adapter->hasForeignKey('comments', ['article_id'])); + + $this->adapter->execute('PRAGMA foreign_keys = OFF'); + $this->adapter->execute('DELETE FROM articles'); + + $noException = false; + try { + $articlesTable + ->addColumn('new_column1', 'integer') + ->update(); + + $noException = true; + } finally { + $this->assertTrue($noException); + } + + $this->adapter->execute('PRAGMA foreign_keys = ON'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Integrity constraint violation: FOREIGN KEY constraint on `comments` failed.'); + + $articlesTable + ->addColumn('new_column2', 'integer') + ->update(); + } + + public function testLiteralSupport() + { + $createQuery = <<<'INPUT' +CREATE TABLE `test` (`real_col` DECIMAL) +INPUT; + $this->adapter->execute($createQuery); + $table = new Table('test', [], $this->adapter); + $columns = $table->getColumns(); + $this->assertCount(1, $columns); + $this->assertEquals(Literal::from('decimal'), array_pop($columns)->getType()); + } + + /** + * @dataProvider provideTableNamesForPresenceCheck + * @covers \Migrations\Db\Adapter\SqliteAdapter::hasTable + * @covers \Migrations\Db\Adapter\SqliteAdapter::resolveTable + * @covers \Migrations\Db\Adapter\SqliteAdapter::quoteString + * @covers \Migrations\Db\Adapter\SqliteAdapter::getSchemaName + */ + public function testHasTable($createName, $tableName, $exp) + { + // Test case for issue #1535 + $conn = $this->adapter->getConnection(); + $conn->execute('ATTACH DATABASE \':memory:\' as etc'); + $conn->execute('ATTACH DATABASE \':memory:\' as "main.db"'); + $conn->execute(sprintf('DROP TABLE IF EXISTS %s', $createName)); + $this->assertFalse($this->adapter->hasTable($tableName), sprintf('Adapter claims table %s exists when it does not', $tableName)); + $conn->execute(sprintf('CREATE TABLE %s (a text)', $createName)); + if ($exp == true) { + $this->assertTrue($this->adapter->hasTable($tableName), sprintf('Adapter claims table %s does not exist when it does', $tableName)); + } else { + $this->assertFalse($this->adapter->hasTable($tableName), sprintf('Adapter claims table %s exists when it does not', $tableName)); + } + } + + public static function provideTableNamesForPresenceCheck() + { + return [ + 'Ordinary table' => ['t', 't', true], + 'Ordinary table with schema' => ['t', 'main.t', true], + 'Temporary table' => ['temp.t', 't', true], + 'Temporary table with schema' => ['temp.t', 'temp.t', true], + 'Attached table' => ['etc.t', 't', true], + 'Attached table with schema' => ['etc.t', 'etc.t', true], + 'Attached table with unusual schema' => ['"main.db".t', 'main.db.t', true], + 'Wrong schema 1' => ['t', 'etc.t', false], + 'Wrong schema 2' => ['t', 'temp.t', false], + 'Missing schema' => ['t', 'not_attached.t', false], + 'Malicious table' => ['"\'"', '\'', true], + 'Malicious missing table' => ['t', '\'', false], + 'Table name case 1' => ['t', 'T', true], + 'Table name case 2' => ['T', 't', true], + 'Schema name case 1' => ['main.t', 'MAIN.t', true], + 'Schema name case 2' => ['MAIN.t', 'main.t', true], + 'Schema name case 3' => ['temp.t', 'TEMP.t', true], + 'Schema name case 4' => ['TEMP.t', 'temp.t', true], + 'Schema name case 5' => ['etc.t', 'ETC.t', true], + 'Schema name case 6' => ['ETC.t', 'etc.t', true], + 'PHP zero string 1' => ['"0"', '0', true], + 'PHP zero string 2' => ['"0"', '0e2', false], + 'PHP zero string 3' => ['"0e2"', '0', false], + ]; + } + + /** + * @dataProvider provideIndexColumnsToCheck + * @covers \Migrations\Db\Adapter\SqliteAdapter::getSchemaName + * @covers \Migrations\Db\Adapter\SqliteAdapter::getTableInfo + * @covers \Migrations\Db\Adapter\SqliteAdapter::getIndexes + * @covers \Migrations\Db\Adapter\SqliteAdapter::resolveIndex + * @covers \Migrations\Db\Adapter\SqliteAdapter::hasIndex + */ + public function testHasIndex($tableDef, $cols, $exp) + { + $conn = $this->adapter->getConnection(); + if (strpos($tableDef, ';') !== false) { + $queries = explode(';', $tableDef); + foreach ($queries as $query) { + $stmt = $conn->execute($query); + $stmt->closeCursor(); + } + } else { + $stmt = $conn->execute($tableDef); + $stmt->closeCursor(); + } + + $this->assertEquals($exp, $this->adapter->hasIndex('t', $cols)); + } + + public static function provideIndexColumnsToCheck() + { + return [ + ['create table t(a text)', 'a', false], + ['create table t(a text); create index test on t(a)', 'a', true], + ['create table t(a text unique)', 'a', true], + ['create table t(a text primary key)', 'a', true], + ['create table t(a text unique, b text unique)', ['a', 'b'], false], + ['create table t(a text, b text, unique(a,b))', ['a', 'b'], true], + ['create table t(a text, b text); create index test on t(a,b)', ['a', 'b'], true], + ['create table t(a text, b text); create index test on t(a,b)', ['b', 'a'], false], + ['create table t(a text, b text); create index test on t(a,b)', ['a'], false], + ['create table t(a text, b text); create index test on t(a)', ['a', 'b'], false], + ['create table t(a text, b text); create index test on t(a,b)', ['A', 'B'], true], + ['create table t("A" text, "B" text); create index test on t("A","B")', ['a', 'b'], true], + ['create table not_t(a text, b text, unique(a,b))', ['A', 'B'], false], // test checks table t which does not exist + ['create table t(a text, b text); create index test on t(a)', ['a', 'a'], false], + ['create table t(a text unique); create temp table t(a text)', 'a', false], + ]; + } + + /** + * @dataProvider provideIndexNamesToCheck + * @covers \Migrations\Db\Adapter\SqliteAdapter::getSchemaName + * @covers \Migrations\Db\Adapter\SqliteAdapter::getTableInfo + * @covers \Migrations\Db\Adapter\SqliteAdapter::getIndexes + * @covers \Migrations\Db\Adapter\SqliteAdapter::hasIndexByName + */ + public function testHasIndexByName($tableDef, $index, $exp) + { + $conn = $this->adapter->getConnection(); + if (strpos($tableDef, ';') !== false) { + $queries = explode(';', $tableDef); + foreach ($queries as $query) { + $stmt = $conn->execute($query); + $stmt->closeCursor(); + } + } else { + $stmt = $conn->execute($tableDef); + $stmt->closeCursor(); + } + $this->assertEquals($exp, $this->adapter->hasIndexByName('t', $index)); + } + + public static function provideIndexNamesToCheck() + { + return [ + ['create table t(a text)', 'test', false], + ['create table t(a text); create index test on t(a)', 'test', true], + ['create table t(a text); create index test on t(a)', 'TEST', true], + ['create table t(a text); create index "TEST" on t(a)', 'test', true], + ['create table t(a text unique)', 'sqlite_autoindex_t_1', true], + ['create table t(a text primary key)', 'sqlite_autoindex_t_1', true], + ['create table not_t(a text); create index test on not_t(a)', 'test', false], // test checks table t which does not exist + ['create table t(a text unique); create temp table t(a text)', 'sqlite_autoindex_t_1', false], + ]; + } + + /** + * @dataProvider providePrimaryKeysToCheck + * @covers \Migrations\Db\Adapter\SqliteAdapter::getSchemaName + * @covers \Migrations\Db\Adapter\SqliteAdapter::getTableInfo + * @covers \Migrations\Db\Adapter\SqliteAdapter::hasPrimaryKey + * @covers \Migrations\Db\Adapter\SqliteAdapter::getPrimaryKey + */ + public function testHasPrimaryKey($tableDef, $key, $exp) + { + $this->assertFalse($this->adapter->hasTable('t'), 'Dirty test fixture'); + $conn = $this->adapter->getConnection(); + if (strpos($tableDef, ';') !== false) { + $queries = explode(';', $tableDef); + foreach ($queries as $query) { + $stmt = $conn->execute($query); + $stmt->closeCursor(); + } + } else { + $stmt = $conn->execute($tableDef); + $stmt->closeCursor(); + } + $this->assertSame($exp, $this->adapter->hasPrimaryKey('t', $key)); + } + + public static function providePrimaryKeysToCheck() + { + return [ + ['create table t(a integer)', 'a', false], + ['create table t(a integer)', [], true], + ['create table t(a integer primary key)', 'a', true], + ['create table t(a integer primary key)', [], false], + ['create table t(a integer PRIMARY KEY)', 'a', true], + ['create table t(`a` integer PRIMARY KEY)', 'a', true], + ['create table t("a" integer PRIMARY KEY)', 'a', true], + ['create table t([a] integer PRIMARY KEY)', 'a', true], + ['create table t(`a` integer PRIMARY KEY)', 'a', true], + ['create table t(\'a\' integer PRIMARY KEY)', 'a', true], + ['create table t(`a.a` integer PRIMARY KEY)', 'a.a', true], + ['create table t(a integer primary key)', ['a'], true], + ['create table t(a integer primary key)', ['a', 'b'], false], + ['create table t(a integer, primary key(a))', 'a', true], + ['create table t(a integer, primary key("a"))', 'a', true], + ['create table t(a integer, primary key([a]))', 'a', true], + ['create table t(a integer, primary key(`a`))', 'a', true], + ['create table t(a integer, b integer primary key)', 'a', false], + ['create table t(a integer, b text primary key)', 'b', true], + ['create table t(a integer, b integer default 2112 primary key)', ['a'], false], + ['create table t(a integer, b integer primary key)', ['b'], true], + ['create table t(a integer, b integer primary key)', ['b', 'b'], true], // duplicate column is collapsed + ['create table t(a integer, b integer, primary key(a,b))', ['b', 'a'], true], + ['create table t(a integer, b integer, primary key(a,b))', ['a', 'b'], true], + ['create table t(a integer, b integer, primary key(a,b))', 'a', false], + ['create table t(a integer, b integer, primary key(a,b))', ['a'], false], + ['create table t(a integer, b integer, primary key(a,b))', ['a', 'b', 'c'], false], + ['create table t(a integer, b integer, primary key(a,b))', ['a', 'B'], true], + ['create table t(a integer, "B" integer, primary key(a,b))', ['a', 'b'], true], + ['create table t(a integer, b integer, constraint t_pk primary key(a,b))', ['a', 'b'], true], + ['create table t(a integer); create temp table t(a integer primary key)', 'a', true], + ['create temp table t(a integer primary key)', 'a', true], + ['create table t("0" integer primary key)', ['0'], true], + ['create table t("0" integer primary key)', ['0e0'], false], + ['create table t("0e0" integer primary key)', ['0'], false], + ['create table not_t(a integer)', 'a', false], // test checks table t which does not exist + ]; + } + + /** + * @covers \Migrations\Db\Adapter\SqliteAdapter::hasPrimaryKey + */ + public function testHasNamedPrimaryKey() + { + $this->expectException(InvalidArgumentException::class); + + $this->adapter->hasPrimaryKey('t', [], 'named_constraint'); + } + + /** + * @dataProvider provideForeignKeysToCheck + * @covers \Migrations\Db\Adapter\SqliteAdapter::getSchemaName + * @covers \Migrations\Db\Adapter\SqliteAdapter::getTableInfo + * @covers \Migrations\Db\Adapter\SqliteAdapter::hasForeignKey + * @covers \Migrations\Db\Adapter\SqliteAdapter::getForeignKeys + */ + public function testHasForeignKey($tableDef, $key, $exp) + { + $conn = $this->adapter->getConnection(); + $conn->execute('CREATE TABLE other(a integer, b integer, c integer)'); + if (strpos($tableDef, ';') !== false) { + $queries = explode(';', $tableDef); + foreach ($queries as $query) { + $stmt = $conn->execute($query); + $stmt->closeCursor(); + } + } else { + $stmt = $conn->execute($tableDef); + $stmt->closeCursor(); + } + + $this->assertSame($exp, $this->adapter->hasForeignKey('t', $key)); + } + + public static function provideForeignKeysToCheck() + { + return [ + ['create table t(a integer)', 'a', false], + ['create table t(a integer)', [], false], + ['create table t(a integer primary key)', 'a', false], + ['create table t(a integer references other(a))', 'a', true], + ['create table t(a integer references other(b))', 'a', true], + ['create table t(a integer references other(b))', ['a'], true], + ['create table t(a integer references other(b))', ['a', 'a'], false], + ['create table t(a integer, foreign key(a) references other(a))', 'a', true], + ['create table t(a integer, b integer, foreign key(a,b) references other(a,b))', 'a', false], + ['create table t(a integer, b integer, foreign key(a,b) references other(a,b))', ['a', 'b'], true], + ['create table t(a integer, b integer, foreign key(a,b) references other(a,b))', ['b', 'a'], false], + ['create table t(a integer, "B" integer, foreign key(a,"B") references other(a,b))', ['a', 'b'], true], + ['create table t(a integer, b integer, foreign key(a,b) references other(a,b))', ['a', 'B'], true], + ['create table t(a integer, b integer, c integer, foreign key(a,b,c) references other(a,b,c))', ['a', 'b'], false], + ['create table t(a integer, foreign key(a) references other(a))', ['a', 'b'], false], + ['create table t(a integer references other(a), b integer references other(b))', ['a', 'b'], false], + ['create table t(a integer references other(a), b integer references other(b))', ['a', 'b'], false], + ['create table t(a integer); create temp table t(a integer references other(a))', ['a'], true], + ['create temp table t(a integer references other(a))', ['a'], true], + ['create table t("0" integer references other(a))', '0', true], + ['create table t("0" integer references other(a))', '0e0', false], + ['create table t("0e0" integer references other(a))', '0', false], + ]; + } + + /** @covers \Migrations\Db\Adapter\SqliteAdapter::hasForeignKey */ + public function testHasNamedForeignKey() + { + $refTable = new Table('tbl_parent_1', [], $this->adapter); + $refTable->addColumn('column', 'string')->create(); + + $refTable = new Table('tbl_parent_2', [], $this->adapter); + $refTable->create(); + + $refTable = new Table('tbl_parent_3', [ + 'id' => false, + 'primary_key' => ['id', 'column'], + ], $this->adapter); + $refTable->addColumn('id', 'integer')->addColumn('column', 'string')->create(); + + // use raw sql instead of table builder so that we can have check constraints + $this->adapter->execute(" + CREATE TABLE `tbl_child` ( + `id` INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + `column` VARCHAR NOT NULL, `parent_1_id` INTEGER NOT NULL, + `parent_2_id` INTEGER NOT NULL, + `parent_3_id` INTEGER NOT NULL, + CONSTRAINT `fk_parent_1_id` FOREIGN KEY (`parent_1_id`) REFERENCES `tbl_parent_1` (`id`), + CONSTRAINT [fk_[_brackets] FOREIGN KEY (`parent_1_id`) REFERENCES `tbl_parent_1` (`id`), + CONSTRAINT `fk_``_ticks` FOREIGN KEY (`parent_1_id`) REFERENCES `tbl_parent_1` (`id`), + CONSTRAINT \"fk_\"\"_double_quotes\" FOREIGN KEY (`parent_1_id`) REFERENCES `tbl_parent_1` (`id`), + CONSTRAINT 'fk_''_single_quotes' FOREIGN KEY (`parent_1_id`) REFERENCES `tbl_parent_1` (`id`), + CONSTRAINT fk_no_quotes FOREIGN KEY (`parent_1_id`) REFERENCES `tbl_parent_1` (`id`), + CONSTRAINT`fk_no_space`FOREIGN KEY(`parent_1_id`)REFERENCES`tbl_parent_1`(`id`), + constraint + `fk_lots_of_space` FOReign KEY (`parent_1_id`) REFERENCES `tbl_parent_1` (`id`), + FOREIGN KEY (`parent_2_id`) REFERENCES `tbl_parent_2` (`id`), + CONSTRAINT `check_constraint_1` CHECK (column<>'world'), + CONSTRAINT `fk_composite_key` FOREIGN KEY (`parent_3_id`,`column`) REFERENCES `tbl_parent_3` (`id`,`column`) + CONSTRAINT `check_constraint_2` CHECK (column<>'hello') + )"); + + $this->assertTrue($this->adapter->hasForeignKey('tbl_child', [], 'fk_parent_1_id')); + $this->assertTrue($this->adapter->hasForeignKey('tbl_child', [], 'fk_[_brackets')); + $this->assertTrue($this->adapter->hasForeignKey('tbl_child', [], 'fk_`_ticks')); + $this->assertTrue($this->adapter->hasForeignKey('tbl_child', [], 'fk_"_double_quotes')); + $this->assertTrue($this->adapter->hasForeignKey('tbl_child', [], "fk_'_single_quotes")); + $this->assertTrue($this->adapter->hasForeignKey('tbl_child', [], 'fk_no_quotes')); + $this->assertTrue($this->adapter->hasForeignKey('tbl_child', [], 'fk_no_space')); + $this->assertTrue($this->adapter->hasForeignKey('tbl_child', [], 'fk_lots_of_space')); + $this->assertTrue($this->adapter->hasForeignKey('tbl_child', ['parent_1_id'])); + $this->assertTrue($this->adapter->hasForeignKey('tbl_child', ['parent_2_id'])); + $this->assertTrue($this->adapter->hasForeignKey('tbl_child', [], 'fk_composite_key')); + $this->assertTrue($this->adapter->hasForeignKey('tbl_child', ['parent_3_id', 'column'])); + $this->assertFalse($this->adapter->hasForeignKey('tbl_child', [], 'check_constraint_1')); + $this->assertFalse($this->adapter->hasForeignKey('tbl_child', [], 'check_constraint_2')); + } + + /** + * @dataProvider providePhinxTypes + * @covers \Migrations\Db\Adapter\SqliteAdapter::getSqlType + */ + public function testGetSqlType($phinxType, $limit, $exp) + { + if ($exp instanceof Exception) { + $this->expectException(get_class($exp)); + + $this->adapter->getSqlType($phinxType, $limit); + } else { + $exp = ['name' => $exp, 'limit' => $limit]; + $this->assertEquals($exp, $this->adapter->getSqlType($phinxType, $limit)); + } + } + + public static function providePhinxTypes() + { + $unsupported = new UnsupportedColumnTypeException(); + + return [ + [SqliteAdapter::PHINX_TYPE_BIG_INTEGER, null, SqliteAdapter::PHINX_TYPE_BIG_INTEGER], + [SqliteAdapter::PHINX_TYPE_BINARY, null, SqliteAdapter::PHINX_TYPE_BINARY . '_blob'], + [SqliteAdapter::PHINX_TYPE_BIT, null, $unsupported], + [SqliteAdapter::PHINX_TYPE_BLOB, null, SqliteAdapter::PHINX_TYPE_BLOB], + [SqliteAdapter::PHINX_TYPE_BOOLEAN, null, SqliteAdapter::PHINX_TYPE_BOOLEAN . '_integer'], + [SqliteAdapter::PHINX_TYPE_CHAR, null, SqliteAdapter::PHINX_TYPE_CHAR], + [SqliteAdapter::PHINX_TYPE_CIDR, null, $unsupported], + [SqliteAdapter::PHINX_TYPE_DATE, null, SqliteAdapter::PHINX_TYPE_DATE . '_text'], + [SqliteAdapter::PHINX_TYPE_DATETIME, null, SqliteAdapter::PHINX_TYPE_DATETIME . '_text'], + [SqliteAdapter::PHINX_TYPE_DECIMAL, null, SqliteAdapter::PHINX_TYPE_DECIMAL], + [SqliteAdapter::PHINX_TYPE_DOUBLE, null, SqliteAdapter::PHINX_TYPE_DOUBLE], + [SqliteAdapter::PHINX_TYPE_ENUM, null, $unsupported], + [SqliteAdapter::PHINX_TYPE_FILESTREAM, null, $unsupported], + [SqliteAdapter::PHINX_TYPE_FLOAT, null, SqliteAdapter::PHINX_TYPE_FLOAT], + [SqliteAdapter::PHINX_TYPE_GEOMETRY, null, $unsupported], + [SqliteAdapter::PHINX_TYPE_INET, null, $unsupported], + [SqliteAdapter::PHINX_TYPE_INTEGER, null, SqliteAdapter::PHINX_TYPE_INTEGER], + [SqliteAdapter::PHINX_TYPE_INTERVAL, null, $unsupported], + [SqliteAdapter::PHINX_TYPE_JSON, null, SqliteAdapter::PHINX_TYPE_JSON . '_text'], + [SqliteAdapter::PHINX_TYPE_JSONB, null, SqliteAdapter::PHINX_TYPE_JSONB . '_text'], + [SqliteAdapter::PHINX_TYPE_LINESTRING, null, $unsupported], + [SqliteAdapter::PHINX_TYPE_MACADDR, null, $unsupported], + [SqliteAdapter::PHINX_TYPE_POINT, null, $unsupported], + [SqliteAdapter::PHINX_TYPE_POLYGON, null, $unsupported], + [SqliteAdapter::PHINX_TYPE_SET, null, $unsupported], + [SqliteAdapter::PHINX_TYPE_SMALL_INTEGER, null, SqliteAdapter::PHINX_TYPE_SMALL_INTEGER], + [SqliteAdapter::PHINX_TYPE_STRING, null, 'varchar'], + [SqliteAdapter::PHINX_TYPE_TEXT, null, SqliteAdapter::PHINX_TYPE_TEXT], + [SqliteAdapter::PHINX_TYPE_TIME, null, SqliteAdapter::PHINX_TYPE_TIME . '_text'], + [SqliteAdapter::PHINX_TYPE_TIMESTAMP, null, SqliteAdapter::PHINX_TYPE_TIMESTAMP . '_text'], + [SqliteAdapter::PHINX_TYPE_UUID, null, SqliteAdapter::PHINX_TYPE_UUID . '_text'], + [SqliteAdapter::PHINX_TYPE_VARBINARY, null, SqliteAdapter::PHINX_TYPE_VARBINARY . '_blob'], + [SqliteAdapter::PHINX_TYPE_STRING, 5, 'varchar'], + [Literal::from('someType'), 5, Literal::from('someType')], + ['notAType', null, $unsupported], + ]; + } + + /** + * @dataProvider provideSqlTypes + * @covers \Migrations\Db\Adapter\SqliteAdapter::getPhinxType + */ + public function testGetPhinxType($sqlType, $exp) + { + $this->assertEquals($exp, $this->adapter->getPhinxType($sqlType)); + } + + /** + * @return array + */ + public static function provideSqlTypes() + { + return [ + ['varchar', ['name' => SqliteAdapter::PHINX_TYPE_STRING, 'limit' => null, 'scale' => null]], + ['string', ['name' => SqliteAdapter::PHINX_TYPE_STRING, 'limit' => null, 'scale' => null]], + ['string_text', ['name' => SqliteAdapter::PHINX_TYPE_STRING, 'limit' => null, 'scale' => null]], + ['varchar(5)', ['name' => SqliteAdapter::PHINX_TYPE_STRING, 'limit' => 5, 'scale' => null]], + ['varchar(55,2)', ['name' => SqliteAdapter::PHINX_TYPE_STRING, 'limit' => 55, 'scale' => 2]], + ['char', ['name' => SqliteAdapter::PHINX_TYPE_CHAR, 'limit' => null, 'scale' => null]], + ['boolean', ['name' => SqliteAdapter::PHINX_TYPE_BOOLEAN, 'limit' => null, 'scale' => null]], + ['boolean_integer', ['name' => SqliteAdapter::PHINX_TYPE_BOOLEAN, 'limit' => null, 'scale' => null]], + ['int', ['name' => SqliteAdapter::PHINX_TYPE_INTEGER, 'limit' => null, 'scale' => null]], + ['integer', ['name' => SqliteAdapter::PHINX_TYPE_INTEGER, 'limit' => null, 'scale' => null]], + ['tinyint', ['name' => SqliteAdapter::PHINX_TYPE_TINY_INTEGER, 'limit' => null, 'scale' => null]], + ['tinyint(1)', ['name' => SqliteAdapter::PHINX_TYPE_BOOLEAN, 'limit' => null, 'scale' => null]], + ['tinyinteger', ['name' => SqliteAdapter::PHINX_TYPE_TINY_INTEGER, 'limit' => null, 'scale' => null]], + ['tinyinteger(1)', ['name' => SqliteAdapter::PHINX_TYPE_BOOLEAN, 'limit' => null, 'scale' => null]], + ['smallint', ['name' => SqliteAdapter::PHINX_TYPE_SMALL_INTEGER, 'limit' => null, 'scale' => null]], + ['smallinteger', ['name' => SqliteAdapter::PHINX_TYPE_SMALL_INTEGER, 'limit' => null, 'scale' => null]], + ['mediumint', ['name' => SqliteAdapter::PHINX_TYPE_INTEGER, 'limit' => null, 'scale' => null]], + ['mediuminteger', ['name' => SqliteAdapter::PHINX_TYPE_INTEGER, 'limit' => null, 'scale' => null]], + ['bigint', ['name' => SqliteAdapter::PHINX_TYPE_BIG_INTEGER, 'limit' => null, 'scale' => null]], + ['biginteger', ['name' => SqliteAdapter::PHINX_TYPE_BIG_INTEGER, 'limit' => null, 'scale' => null]], + ['text', ['name' => SqliteAdapter::PHINX_TYPE_TEXT, 'limit' => null, 'scale' => null]], + ['tinytext', ['name' => SqliteAdapter::PHINX_TYPE_TEXT, 'limit' => null, 'scale' => null]], + ['mediumtext', ['name' => SqliteAdapter::PHINX_TYPE_TEXT, 'limit' => null, 'scale' => null]], + ['longtext', ['name' => SqliteAdapter::PHINX_TYPE_TEXT, 'limit' => null, 'scale' => null]], + ['blob', ['name' => SqliteAdapter::PHINX_TYPE_BLOB, 'limit' => null, 'scale' => null]], + ['tinyblob', ['name' => SqliteAdapter::PHINX_TYPE_BLOB, 'limit' => null, 'scale' => null]], + ['mediumblob', ['name' => SqliteAdapter::PHINX_TYPE_BLOB, 'limit' => null, 'scale' => null]], + ['longblob', ['name' => SqliteAdapter::PHINX_TYPE_BLOB, 'limit' => null, 'scale' => null]], + ['float', ['name' => SqliteAdapter::PHINX_TYPE_FLOAT, 'limit' => null, 'scale' => null]], + ['real', ['name' => SqliteAdapter::PHINX_TYPE_FLOAT, 'limit' => null, 'scale' => null]], + ['double', ['name' => SqliteAdapter::PHINX_TYPE_DOUBLE, 'limit' => null, 'scale' => null]], + ['date', ['name' => SqliteAdapter::PHINX_TYPE_DATE, 'limit' => null, 'scale' => null]], + ['date_text', ['name' => SqliteAdapter::PHINX_TYPE_DATE, 'limit' => null, 'scale' => null]], + ['datetime', ['name' => SqliteAdapter::PHINX_TYPE_DATETIME, 'limit' => null, 'scale' => null]], + ['datetime_text', ['name' => SqliteAdapter::PHINX_TYPE_DATETIME, 'limit' => null, 'scale' => null]], + ['time', ['name' => SqliteAdapter::PHINX_TYPE_TIME, 'limit' => null, 'scale' => null]], + ['time_text', ['name' => SqliteAdapter::PHINX_TYPE_TIME, 'limit' => null, 'scale' => null]], + ['timestamp', ['name' => SqliteAdapter::PHINX_TYPE_TIMESTAMP, 'limit' => null, 'scale' => null]], + ['timestamp_text', ['name' => SqliteAdapter::PHINX_TYPE_TIMESTAMP, 'limit' => null, 'scale' => null]], + ['binary', ['name' => SqliteAdapter::PHINX_TYPE_BINARY, 'limit' => null, 'scale' => null]], + ['binary_blob', ['name' => SqliteAdapter::PHINX_TYPE_BINARY, 'limit' => null, 'scale' => null]], + ['varbinary', ['name' => SqliteAdapter::PHINX_TYPE_VARBINARY, 'limit' => null, 'scale' => null]], + ['varbinary_blob', ['name' => SqliteAdapter::PHINX_TYPE_VARBINARY, 'limit' => null, 'scale' => null]], + ['json', ['name' => SqliteAdapter::PHINX_TYPE_JSON, 'limit' => null, 'scale' => null]], + ['json_text', ['name' => SqliteAdapter::PHINX_TYPE_JSON, 'limit' => null, 'scale' => null]], + ['jsonb', ['name' => SqliteAdapter::PHINX_TYPE_JSONB, 'limit' => null, 'scale' => null]], + ['jsonb_text', ['name' => SqliteAdapter::PHINX_TYPE_JSONB, 'limit' => null, 'scale' => null]], + ['uuid', ['name' => SqliteAdapter::PHINX_TYPE_UUID, 'limit' => null, 'scale' => null]], + ['uuid_text', ['name' => SqliteAdapter::PHINX_TYPE_UUID, 'limit' => null, 'scale' => null]], + ['decimal', ['name' => Literal::from('decimal'), 'limit' => null, 'scale' => null]], + ['point', ['name' => Literal::from('point'), 'limit' => null, 'scale' => null]], + ['polygon', ['name' => Literal::from('polygon'), 'limit' => null, 'scale' => null]], + ['linestring', ['name' => Literal::from('linestring'), 'limit' => null, 'scale' => null]], + ['geometry', ['name' => Literal::from('geometry'), 'limit' => null, 'scale' => null]], + ['bit', ['name' => Literal::from('bit'), 'limit' => null, 'scale' => null]], + ['enum', ['name' => Literal::from('enum'), 'limit' => null, 'scale' => null]], + ['set', ['name' => Literal::from('set'), 'limit' => null, 'scale' => null]], + ['cidr', ['name' => Literal::from('cidr'), 'limit' => null, 'scale' => null]], + ['inet', ['name' => Literal::from('inet'), 'limit' => null, 'scale' => null]], + ['macaddr', ['name' => Literal::from('macaddr'), 'limit' => null, 'scale' => null]], + ['interval', ['name' => Literal::from('interval'), 'limit' => null, 'scale' => null]], + ['filestream', ['name' => Literal::from('filestream'), 'limit' => null, 'scale' => null]], + ['decimal_text', ['name' => Literal::from('decimal'), 'limit' => null, 'scale' => null]], + ['point_text', ['name' => Literal::from('point'), 'limit' => null, 'scale' => null]], + ['polygon_text', ['name' => Literal::from('polygon'), 'limit' => null, 'scale' => null]], + ['linestring_text', ['name' => Literal::from('linestring'), 'limit' => null, 'scale' => null]], + ['geometry_text', ['name' => Literal::from('geometry'), 'limit' => null, 'scale' => null]], + ['bit_text', ['name' => Literal::from('bit'), 'limit' => null, 'scale' => null]], + ['enum_text', ['name' => Literal::from('enum'), 'limit' => null, 'scale' => null]], + ['set_text', ['name' => Literal::from('set'), 'limit' => null, 'scale' => null]], + ['cidr_text', ['name' => Literal::from('cidr'), 'limit' => null, 'scale' => null]], + ['inet_text', ['name' => Literal::from('inet'), 'limit' => null, 'scale' => null]], + ['macaddr_text', ['name' => Literal::from('macaddr'), 'limit' => null, 'scale' => null]], + ['interval_text', ['name' => Literal::from('interval'), 'limit' => null, 'scale' => null]], + ['filestream_text', ['name' => Literal::from('filestream'), 'limit' => null, 'scale' => null]], + ['bit_text(2,12)', ['name' => Literal::from('bit'), 'limit' => 2, 'scale' => 12]], + ['VARCHAR', ['name' => SqliteAdapter::PHINX_TYPE_STRING, 'limit' => null, 'scale' => null]], + ['STRING', ['name' => SqliteAdapter::PHINX_TYPE_STRING, 'limit' => null, 'scale' => null]], + ['STRING_TEXT', ['name' => SqliteAdapter::PHINX_TYPE_STRING, 'limit' => null, 'scale' => null]], + ['VARCHAR(5)', ['name' => SqliteAdapter::PHINX_TYPE_STRING, 'limit' => 5, 'scale' => null]], + ['VARCHAR(55,2)', ['name' => SqliteAdapter::PHINX_TYPE_STRING, 'limit' => 55, 'scale' => 2]], + ['CHAR', ['name' => SqliteAdapter::PHINX_TYPE_CHAR, 'limit' => null, 'scale' => null]], + ['BOOLEAN', ['name' => SqliteAdapter::PHINX_TYPE_BOOLEAN, 'limit' => null, 'scale' => null]], + ['BOOLEAN_INTEGER', ['name' => SqliteAdapter::PHINX_TYPE_BOOLEAN, 'limit' => null, 'scale' => null]], + ['INT', ['name' => SqliteAdapter::PHINX_TYPE_INTEGER, 'limit' => null, 'scale' => null]], + ['INTEGER', ['name' => SqliteAdapter::PHINX_TYPE_INTEGER, 'limit' => null, 'scale' => null]], + ['TINYINT', ['name' => SqliteAdapter::PHINX_TYPE_TINY_INTEGER, 'limit' => null, 'scale' => null]], + ['TINYINT(1)', ['name' => SqliteAdapter::PHINX_TYPE_BOOLEAN, 'limit' => null, 'scale' => null]], + ['TINYINTEGER', ['name' => SqliteAdapter::PHINX_TYPE_TINY_INTEGER, 'limit' => null, 'scale' => null]], + ['TINYINTEGER(1)', ['name' => SqliteAdapter::PHINX_TYPE_BOOLEAN, 'limit' => null, 'scale' => null]], + ['SMALLINT', ['name' => SqliteAdapter::PHINX_TYPE_SMALL_INTEGER, 'limit' => null, 'scale' => null]], + ['SMALLINTEGER', ['name' => SqliteAdapter::PHINX_TYPE_SMALL_INTEGER, 'limit' => null, 'scale' => null]], + ['MEDIUMINT', ['name' => SqliteAdapter::PHINX_TYPE_INTEGER, 'limit' => null, 'scale' => null]], + ['MEDIUMINTEGER', ['name' => SqliteAdapter::PHINX_TYPE_INTEGER, 'limit' => null, 'scale' => null]], + ['BIGINT', ['name' => SqliteAdapter::PHINX_TYPE_BIG_INTEGER, 'limit' => null, 'scale' => null]], + ['BIGINTEGER', ['name' => SqliteAdapter::PHINX_TYPE_BIG_INTEGER, 'limit' => null, 'scale' => null]], + ['TEXT', ['name' => SqliteAdapter::PHINX_TYPE_TEXT, 'limit' => null, 'scale' => null]], + ['TINYTEXT', ['name' => SqliteAdapter::PHINX_TYPE_TEXT, 'limit' => null, 'scale' => null]], + ['MEDIUMTEXT', ['name' => SqliteAdapter::PHINX_TYPE_TEXT, 'limit' => null, 'scale' => null]], + ['LONGTEXT', ['name' => SqliteAdapter::PHINX_TYPE_TEXT, 'limit' => null, 'scale' => null]], + ['BLOB', ['name' => SqliteAdapter::PHINX_TYPE_BLOB, 'limit' => null, 'scale' => null]], + ['TINYBLOB', ['name' => SqliteAdapter::PHINX_TYPE_BLOB, 'limit' => null, 'scale' => null]], + ['MEDIUMBLOB', ['name' => SqliteAdapter::PHINX_TYPE_BLOB, 'limit' => null, 'scale' => null]], + ['LONGBLOB', ['name' => SqliteAdapter::PHINX_TYPE_BLOB, 'limit' => null, 'scale' => null]], + ['FLOAT', ['name' => SqliteAdapter::PHINX_TYPE_FLOAT, 'limit' => null, 'scale' => null]], + ['REAL', ['name' => SqliteAdapter::PHINX_TYPE_FLOAT, 'limit' => null, 'scale' => null]], + ['DOUBLE', ['name' => SqliteAdapter::PHINX_TYPE_DOUBLE, 'limit' => null, 'scale' => null]], + ['DATE', ['name' => SqliteAdapter::PHINX_TYPE_DATE, 'limit' => null, 'scale' => null]], + ['DATE_TEXT', ['name' => SqliteAdapter::PHINX_TYPE_DATE, 'limit' => null, 'scale' => null]], + ['DATETIME', ['name' => SqliteAdapter::PHINX_TYPE_DATETIME, 'limit' => null, 'scale' => null]], + ['DATETIME_TEXT', ['name' => SqliteAdapter::PHINX_TYPE_DATETIME, 'limit' => null, 'scale' => null]], + ['TIME', ['name' => SqliteAdapter::PHINX_TYPE_TIME, 'limit' => null, 'scale' => null]], + ['TIME_TEXT', ['name' => SqliteAdapter::PHINX_TYPE_TIME, 'limit' => null, 'scale' => null]], + ['TIMESTAMP', ['name' => SqliteAdapter::PHINX_TYPE_TIMESTAMP, 'limit' => null, 'scale' => null]], + ['TIMESTAMP_TEXT', ['name' => SqliteAdapter::PHINX_TYPE_TIMESTAMP, 'limit' => null, 'scale' => null]], + ['BINARY', ['name' => SqliteAdapter::PHINX_TYPE_BINARY, 'limit' => null, 'scale' => null]], + ['BINARY_BLOB', ['name' => SqliteAdapter::PHINX_TYPE_BINARY, 'limit' => null, 'scale' => null]], + ['VARBINARY', ['name' => SqliteAdapter::PHINX_TYPE_VARBINARY, 'limit' => null, 'scale' => null]], + ['VARBINARY_BLOB', ['name' => SqliteAdapter::PHINX_TYPE_VARBINARY, 'limit' => null, 'scale' => null]], + ['JSON', ['name' => SqliteAdapter::PHINX_TYPE_JSON, 'limit' => null, 'scale' => null]], + ['JSON_TEXT', ['name' => SqliteAdapter::PHINX_TYPE_JSON, 'limit' => null, 'scale' => null]], + ['JSONB', ['name' => SqliteAdapter::PHINX_TYPE_JSONB, 'limit' => null, 'scale' => null]], + ['JSONB_TEXT', ['name' => SqliteAdapter::PHINX_TYPE_JSONB, 'limit' => null, 'scale' => null]], + ['UUID', ['name' => SqliteAdapter::PHINX_TYPE_UUID, 'limit' => null, 'scale' => null]], + ['UUID_TEXT', ['name' => SqliteAdapter::PHINX_TYPE_UUID, 'limit' => null, 'scale' => null]], + ['DECIMAL', ['name' => Literal::from('decimal'), 'limit' => null, 'scale' => null]], + ['POINT', ['name' => Literal::from('point'), 'limit' => null, 'scale' => null]], + ['POLYGON', ['name' => Literal::from('polygon'), 'limit' => null, 'scale' => null]], + ['LINESTRING', ['name' => Literal::from('linestring'), 'limit' => null, 'scale' => null]], + ['GEOMETRY', ['name' => Literal::from('geometry'), 'limit' => null, 'scale' => null]], + ['BIT', ['name' => Literal::from('bit'), 'limit' => null, 'scale' => null]], + ['ENUM', ['name' => Literal::from('enum'), 'limit' => null, 'scale' => null]], + ['SET', ['name' => Literal::from('set'), 'limit' => null, 'scale' => null]], + ['CIDR', ['name' => Literal::from('cidr'), 'limit' => null, 'scale' => null]], + ['INET', ['name' => Literal::from('inet'), 'limit' => null, 'scale' => null]], + ['MACADDR', ['name' => Literal::from('macaddr'), 'limit' => null, 'scale' => null]], + ['INTERVAL', ['name' => Literal::from('interval'), 'limit' => null, 'scale' => null]], + ['FILESTREAM', ['name' => Literal::from('filestream'), 'limit' => null, 'scale' => null]], + ['DECIMAL_TEXT', ['name' => Literal::from('decimal'), 'limit' => null, 'scale' => null]], + ['POINT_TEXT', ['name' => Literal::from('point'), 'limit' => null, 'scale' => null]], + ['POLYGON_TEXT', ['name' => Literal::from('polygon'), 'limit' => null, 'scale' => null]], + ['LINESTRING_TEXT', ['name' => Literal::from('linestring'), 'limit' => null, 'scale' => null]], + ['GEOMETRY_TEXT', ['name' => Literal::from('geometry'), 'limit' => null, 'scale' => null]], + ['BIT_TEXT', ['name' => Literal::from('bit'), 'limit' => null, 'scale' => null]], + ['ENUM_TEXT', ['name' => Literal::from('enum'), 'limit' => null, 'scale' => null]], + ['SET_TEXT', ['name' => Literal::from('set'), 'limit' => null, 'scale' => null]], + ['CIDR_TEXT', ['name' => Literal::from('cidr'), 'limit' => null, 'scale' => null]], + ['INET_TEXT', ['name' => Literal::from('inet'), 'limit' => null, 'scale' => null]], + ['MACADDR_TEXT', ['name' => Literal::from('macaddr'), 'limit' => null, 'scale' => null]], + ['INTERVAL_TEXT', ['name' => Literal::from('interval'), 'limit' => null, 'scale' => null]], + ['FILESTREAM_TEXT', ['name' => Literal::from('filestream'), 'limit' => null, 'scale' => null]], + ['BIT_TEXT(2,12)', ['name' => Literal::from('bit'), 'limit' => 2, 'scale' => 12]], + ['not a type', ['name' => Literal::from('not a type'), 'limit' => null, 'scale' => null]], + ['NOT A TYPE', ['name' => Literal::from('NOT A TYPE'), 'limit' => null, 'scale' => null]], + ['not a type(2)', ['name' => Literal::from('not a type(2)'), 'limit' => null, 'scale' => null]], + ['NOT A TYPE(2)', ['name' => Literal::from('NOT A TYPE(2)'), 'limit' => null, 'scale' => null]], + ['ack', ['name' => Literal::from('ack'), 'limit' => null, 'scale' => null]], + ['ACK', ['name' => Literal::from('ACK'), 'limit' => null, 'scale' => null]], + ['ack_text', ['name' => Literal::from('ack_text'), 'limit' => null, 'scale' => null]], + ['ACK_TEXT', ['name' => Literal::from('ACK_TEXT'), 'limit' => null, 'scale' => null]], + ['ack_text(2,12)', ['name' => Literal::from('ack_text'), 'limit' => 2, 'scale' => 12]], + ['ACK_TEXT(12,2)', ['name' => Literal::from('ACK_TEXT'), 'limit' => 12, 'scale' => 2]], + [null, ['name' => null, 'limit' => null, 'scale' => null]], + ]; + } + + /** @covers \Migrations\Db\Adapter\SqliteAdapter::getColumnTypes */ + public function testGetColumnTypes() + { + $columnTypes = $this->adapter->getColumnTypes(); + $expected = [ + SqliteAdapter::PHINX_TYPE_BIG_INTEGER, + SqliteAdapter::PHINX_TYPE_BINARY, + SqliteAdapter::PHINX_TYPE_BLOB, + SqliteAdapter::PHINX_TYPE_BOOLEAN, + SqliteAdapter::PHINX_TYPE_CHAR, + SqliteAdapter::PHINX_TYPE_DATE, + SqliteAdapter::PHINX_TYPE_DATETIME, + SqliteAdapter::PHINX_TYPE_DECIMAL, + SqliteAdapter::PHINX_TYPE_DOUBLE, + SqliteAdapter::PHINX_TYPE_FLOAT, + SqliteAdapter::PHINX_TYPE_INTEGER, + SqliteAdapter::PHINX_TYPE_JSON, + SqliteAdapter::PHINX_TYPE_JSONB, + SqliteAdapter::PHINX_TYPE_SMALL_INTEGER, + SqliteAdapter::PHINX_TYPE_STRING, + SqliteAdapter::PHINX_TYPE_TEXT, + SqliteAdapter::PHINX_TYPE_TIME, + SqliteAdapter::PHINX_TYPE_UUID, + SqliteAdapter::PHINX_TYPE_BINARYUUID, + SqliteAdapter::PHINX_TYPE_TIMESTAMP, + SqliteAdapter::PHINX_TYPE_TINY_INTEGER, + SqliteAdapter::PHINX_TYPE_VARBINARY, + ]; + sort($columnTypes); + sort($expected); + + $this->assertEquals($expected, $columnTypes); + } + + /** + * @dataProvider provideColumnTypesForValidation + * @covers \Phinx\Db\Adapter\SqliteAdapter::isValidColumnType + */ + public function testIsValidColumnType($phinxType, $exp) + { + $col = (new Column())->setType($phinxType); + $this->assertSame($exp, $this->adapter->isValidColumnType($col)); + } + + public static function provideColumnTypesForValidation() + { + return [ + [SqliteAdapter::PHINX_TYPE_BIG_INTEGER, true], + [SqliteAdapter::PHINX_TYPE_BINARY, true], + [SqliteAdapter::PHINX_TYPE_BLOB, true], + [SqliteAdapter::PHINX_TYPE_BOOLEAN, true], + [SqliteAdapter::PHINX_TYPE_CHAR, true], + [SqliteAdapter::PHINX_TYPE_DATE, true], + [SqliteAdapter::PHINX_TYPE_DATETIME, true], + [SqliteAdapter::PHINX_TYPE_DOUBLE, true], + [SqliteAdapter::PHINX_TYPE_FLOAT, true], + [SqliteAdapter::PHINX_TYPE_INTEGER, true], + [SqliteAdapter::PHINX_TYPE_JSON, true], + [SqliteAdapter::PHINX_TYPE_JSONB, true], + [SqliteAdapter::PHINX_TYPE_SMALL_INTEGER, true], + [SqliteAdapter::PHINX_TYPE_STRING, true], + [SqliteAdapter::PHINX_TYPE_TEXT, true], + [SqliteAdapter::PHINX_TYPE_TIME, true], + [SqliteAdapter::PHINX_TYPE_UUID, true], + [SqliteAdapter::PHINX_TYPE_TIMESTAMP, true], + [SqliteAdapter::PHINX_TYPE_VARBINARY, true], + [SqliteAdapter::PHINX_TYPE_BIT, false], + [SqliteAdapter::PHINX_TYPE_CIDR, false], + [SqliteAdapter::PHINX_TYPE_DECIMAL, true], + [SqliteAdapter::PHINX_TYPE_ENUM, false], + [SqliteAdapter::PHINX_TYPE_FILESTREAM, false], + [SqliteAdapter::PHINX_TYPE_GEOMETRY, false], + [SqliteAdapter::PHINX_TYPE_INET, false], + [SqliteAdapter::PHINX_TYPE_INTERVAL, false], + [SqliteAdapter::PHINX_TYPE_LINESTRING, false], + [SqliteAdapter::PHINX_TYPE_MACADDR, false], + [SqliteAdapter::PHINX_TYPE_POINT, false], + [SqliteAdapter::PHINX_TYPE_POLYGON, false], + [SqliteAdapter::PHINX_TYPE_SET, false], + [Literal::from('someType'), true], + ['someType', false], + ]; + } + + /** + * @dataProvider provideDatabaseVersionStrings + * @covers \Phinx\Db\Adapter\SqliteAdapter::databaseVersionAtLeast + */ + public function testDatabaseVersionAtLeast($ver, $exp) + { + $this->assertSame($exp, $this->adapter->databaseVersionAtLeast($ver)); + } + + public static function provideDatabaseVersionStrings() + { + return [ + ['2', true], + ['3', true], + ['4', false], + ['3.0', true], + ['3.0.0.0.0.0', true], + ['3.0.0.0.0.99999', true], + ['3.9999', false], + ]; + } + + /** + * @dataProvider provideColumnNamesToCheck + * @covers \Phinx\Db\Adapter\SqliteAdapter::getSchemaName + * @covers \Phinx\Db\Adapter\SqliteAdapter::getTableInfo + * @covers \Phinx\Db\Adapter\SqliteAdapter::hasColumn + */ + public function testHasColumn($tableDef, $col, $exp) + { + $conn = $this->adapter->getConnection(); + if (strpos($tableDef, ';') !== false) { + $queries = explode(';', $tableDef); + foreach ($queries as $query) { + $stmt = $conn->execute($query); + $stmt->closeCursor(); + } + } else { + $stmt = $conn->execute($tableDef); + $stmt->closeCursor(); + } + + $this->assertEquals($exp, $this->adapter->hasColumn('t', $col)); + } + + public static function provideColumnNamesToCheck() + { + return [ + ['create table t(a text)', 'a', true], + ['create table t(A text)', 'a', true], + ['create table t("a" text)', 'a', true], + ['create table t([a] text)', 'a', true], + ['create table t(\'a\' text)', 'a', true], + ['create table t("A" text)', 'a', true], + ['create table t(a text)', 'A', true], + ['create table t(b text)', 'a', false], + ['create table t(b text, a text)', 'a', true], + ['create table t("0" text)', '0', true], + ['create table t("0" text)', '0e0', false], + ['create table t("0e0" text)', '0', false], + ['create table t(b text); create temp table t(a text)', 'a', true], + ['create table not_t(a text)', 'a', false], + ]; + } + + /** @covers \Phinx\Db\Adapter\SqliteAdapter::getSchemaName + * @covers \Phinx\Db\Adapter\SqliteAdapter::getTableInfo + * @covers \Phinx\Db\Adapter\SqliteAdapter::getColumns + */ + public function testGetColumns() + { + $conn = $this->adapter->getConnection(); + $conn->execute('create table t(a integer, b text, c char(5), d integer(12,6), e integer not null, f integer null)'); + $exp = [ + ['name' => 'a', 'type' => 'integer', 'null' => true, 'limit' => null, 'precision' => null, 'scale' => null], + ['name' => 'b', 'type' => 'text', 'null' => true, 'limit' => null, 'precision' => null, 'scale' => null], + ['name' => 'c', 'type' => 'char', 'null' => true, 'limit' => 5, 'precision' => 5, 'scale' => null], + ['name' => 'd', 'type' => 'integer', 'null' => true, 'limit' => 12, 'precision' => 12, 'scale' => 6], + ['name' => 'e', 'type' => 'integer', 'null' => false, 'limit' => null, 'precision' => null, 'scale' => null], + ['name' => 'f', 'type' => 'integer', 'null' => true, 'limit' => null, 'precision' => null, 'scale' => null], + ]; + $act = $this->adapter->getColumns('t'); + $this->assertCount(count($exp), $act); + foreach ($exp as $index => $data) { + $this->assertInstanceOf(Column::class, $act[$index]); + foreach ($data as $key => $value) { + $m = 'get' . ucfirst($key); + $this->assertEquals($value, $act[$index]->$m(), "Parameter '$key' of column at index $index did not match expectations."); + } + } + } + + /** + * @dataProvider provideIdentityCandidates + * @covers \Phinx\Db\Adapter\SqliteAdapter::resolveIdentity + */ + public function testGetColumnsForIdentity($tableDef, $exp) + { + $conn = $this->adapter->getConnection(); + $conn->execute($tableDef); + $cols = $this->adapter->getColumns('t'); + $act = []; + foreach ($cols as $col) { + if ($col->getIdentity()) { + $act[] = $col->getName(); + } + } + $this->assertEquals((array)$exp, $act); + } + + public static function provideIdentityCandidates() + { + return [ + ['create table t(a text)', null], + ['create table t(a text primary key)', null], + ['create table t(a integer, b text, primary key(a,b))', null], + ['create table t(a integer primary key desc)', null], + ['create table t(a integer primary key) without rowid', null], + ['create table t(a integer primary key)', 'a'], + ['CREATE TABLE T(A INTEGER PRIMARY KEY)', 'A'], + ['create table t(a integer, primary key(a))', 'a'], + ]; + } + + /** + * @dataProvider provideDefaultValues + * @covers \Phinx\Db\Adapter\SqliteAdapter::parseDefaultValue + */ + public function testGetColumnsForDefaults($tableDef, $exp) + { + $conn = $this->adapter->getConnection(); + $conn->execute($tableDef); + $act = $this->adapter->getColumns('t')[0]->getDefault(); + if (is_object($exp)) { + $this->assertEquals($exp, $act); + } else { + $this->assertSame($exp, $act); + } + } + + public static function provideDefaultValues() + { + return [ + 'Implicit null' => ['create table t(a integer)', null], + 'Explicit null LC' => ['create table t(a integer default null)', null], + 'Explicit null UC' => ['create table t(a integer default NULL)', null], + 'Explicit null MC' => ['create table t(a integer default nuLL)', null], + 'Extra parentheses' => ['create table t(a integer default ( ( null ) ))', null], + 'Comment 1' => ['create table t(a integer default ( /* this is perfectly fine */ null ))', null], + 'Comment 2' => ["create table t(a integer default ( /* this\nis\nperfectly\nfine */ null ))", null], + 'Line comment 1' => ["create table t(a integer default ( -- this is perfectly fine, too\n null ))", null], + 'Line comment 2' => ["create table t(a integer default ( -- this is perfectly fine, too\r\n null ))", null], + 'Current date LC' => ['create table t(a date default current_date)', 'CURRENT_DATE'], + 'Current date UC' => ['create table t(a date default CURRENT_DATE)', 'CURRENT_DATE'], + 'Current date MC' => ['create table t(a date default CURRENT_date)', 'CURRENT_DATE'], + 'Current time LC' => ['create table t(a time default current_time)', 'CURRENT_TIME'], + 'Current time UC' => ['create table t(a time default CURRENT_TIME)', 'CURRENT_TIME'], + 'Current time MC' => ['create table t(a time default CURRENT_time)', 'CURRENT_TIME'], + 'Current timestamp LC' => ['create table t(a datetime default current_timestamp)', 'CURRENT_TIMESTAMP'], + 'Current timestamp UC' => ['create table t(a datetime default CURRENT_TIMESTAMP)', 'CURRENT_TIMESTAMP'], + 'Current timestamp MC' => ['create table t(a datetime default CURRENT_timestamp)', 'CURRENT_TIMESTAMP'], + 'String 1' => ['create table t(a text default \'\')', Literal::from('')], + 'String 2' => ['create table t(a text default \'value!\')', Literal::from('value!')], + 'String 3' => ['create table t(a text default \'O\'\'Brien\')', Literal::from('O\'Brien')], + 'String 4' => ['create table t(a text default \'CURRENT_TIMESTAMP\')', Literal::from('CURRENT_TIMESTAMP')], + 'String 5' => ['create table t(a text default \'current_timestamp\')', Literal::from('current_timestamp')], + 'String 6' => ['create table t(a text default \'\' /* comment */)', Literal::from('')], + 'Hexadecimal LC' => ['create table t(a integer default 0xff)', 255], + 'Hexadecimal UC' => ['create table t(a integer default 0XFF)', 255], + 'Hexadecimal MC' => ['create table t(a integer default 0x1F)', 31], + 'Integer 1' => ['create table t(a integer default 1)', 1], + 'Integer 2' => ['create table t(a integer default -1)', -1], + 'Integer 3' => ['create table t(a integer default +1)', 1], + 'Integer 4' => ['create table t(a integer default 2112)', 2112], + 'Integer 5' => ['create table t(a integer default 002112)', 2112], + 'Integer boolean 1' => ['create table t(a boolean default 1)', true], + 'Integer boolean 2' => ['create table t(a boolean default 0)', false], + 'Integer boolean 3' => ['create table t(a boolean default -1)', -1], + 'Integer boolean 4' => ['create table t(a boolean default 2)', 2], + 'Float 1' => ['create table t(a float default 1.0)', 1.0], + 'Float 2' => ['create table t(a float default +1.0)', 1.0], + 'Float 3' => ['create table t(a float default -1.0)', -1.0], + 'Float 4' => ['create table t(a float default 1.)', 1.0], + 'Float 5' => ['create table t(a float default 0.1)', 0.1], + 'Float 6' => ['create table t(a float default .1)', 0.1], + 'Float 7' => ['create table t(a float default 1e0)', 1.0], + 'Float 8' => ['create table t(a float default 1e+0)', 1.0], + 'Float 9' => ['create table t(a float default 1e+1)', 10.0], + 'Float 10' => ['create table t(a float default 1e-1)', 0.1], + 'Float 11' => ['create table t(a float default 1E-1)', 0.1], + 'Blob literal 1' => ['create table t(a float default x\'ff\')', Expression::from('x\'ff\'')], + 'Blob literal 2' => ['create table t(a float default X\'FF\')', Expression::from('X\'FF\'')], + 'Arbitrary expression' => ['create table t(a float default ((2) + (2)))', Expression::from('(2) + (2)')], + 'Pathological case 1' => ['create table t(a float default (\'/*\' || \'*/\'))', Expression::from('\'/*\' || \'*/\'')], + 'Pathological case 2' => ['create table t(a float default (\'--\' || \'stuff\'))', Expression::from('\'--\' || \'stuff\'')], + ]; + } + + /** + * @dataProvider provideBooleanDefaultValues + * @covers \Phinx\Db\Adapter\SqliteAdapter::parseDefaultValue + */ + public function testGetColumnsForBooleanDefaults($tableDef, $exp) + { + if (!$this->adapter->databaseVersionAtLeast('3.24')) { + $this->markTestSkipped('SQLite 3.24.0 or later is required for this test.'); + } + $conn = $this->adapter->getConnection(); + $conn->execute($tableDef); + $act = $this->adapter->getColumns('t')[0]->getDefault(); + if (is_object($exp)) { + $this->assertEquals($exp, $act); + } else { + $this->assertSame($exp, $act); + } + } + + public static function provideBooleanDefaultValues() + { + return [ + 'True LC' => ['create table t(a boolean default true)', true], + 'True UC' => ['create table t(a boolean default TRUE)', true], + 'True MC' => ['create table t(a boolean default TRue)', true], + 'False LC' => ['create table t(a boolean default false)', false], + 'False UC' => ['create table t(a boolean default FALSE)', false], + 'False MC' => ['create table t(a boolean default FALse)', false], + ]; + } + + /** + * @dataProvider provideTablesForTruncation + * @covers \Phinx\Db\Adapter\SqliteAdapter::truncateTable + */ + public function testTruncateTable($tableDef, $tableName, $tableId) + { + $conn = $this->adapter->getConnection(); + $conn->execute($tableDef); + $conn->execute("INSERT INTO $tableId default values"); + $conn->execute("INSERT INTO $tableId default values"); + $conn->execute("INSERT INTO $tableId default values"); + $this->assertEquals(3, $conn->execute("select count(*) from $tableId")->fetchColumn(0), 'Broken fixture: data were not inserted properly'); + $this->assertEquals(3, $conn->execute("select max(id) from $tableId")->fetchColumn(0), 'Broken fixture: data were not inserted properly'); + $this->adapter->truncateTable($tableName); + $this->assertEquals(0, $conn->execute("select count(*) from $tableId")->fetchColumn(0), 'Table was not truncated'); + $conn->execute("INSERT INTO $tableId default values"); + $this->assertEquals(1, $conn->execute("select max(id) from $tableId")->fetchColumn(0), 'Autoincrement was not reset'); + $conn->execute("DROP TABLE $tableId"); + } + + /** + * @return array + */ + public static function provideTablesForTruncation() + { + return [ + ['create table t(id integer primary key)', 't', 't'], + ['create table t(id integer primary key autoincrement)', 't', 't'], + ['create temp table t(id integer primary key)', 't', 'temp.t'], + ['create temp table t(id integer primary key autoincrement)', 't', 'temp.t'], + ['create table t(id integer primary key)', 'main.t', 'main.t'], + ['create table t(id integer primary key autoincrement)', 'main.t', 'main.t'], + ['create temp table t(id integer primary key)', 'temp.t', 'temp.t'], + ['create temp table t(id integer primary key autoincrement)', 'temp.t', 'temp.t'], + ['create table ["](id integer primary key)', 'main."', 'main.""""'], + ['create table ["](id integer primary key autoincrement)', 'main."', 'main.""""'], + ['create table [\'](id integer primary key)', 'main.\'', 'main."\'"'], + ['create table [\'](id integer primary key autoincrement)', 'main.\'', 'main."\'"'], + ['create table T(id integer primary key)', 't', 't'], + ['create table T(id integer primary key autoincrement)', 't', 't'], + ['create table t(id integer primary key)', 'T', 't'], + ['create table t(id integer primary key autoincrement)', 'T', 't'], + ]; + } + + public function testForeignKeyReferenceCorrectAfterRenameColumn() + { + $refTableColumnId = 'ref_table_id'; + $refTableColumnToRename = 'columnToRename'; + $refTableRenamedColumn = 'renamedColumn'; + $refTable = new Table('ref_table', [], $this->adapter); + $refTable->addColumn($refTableColumnToRename, 'string')->save(); + + $table = new Table('table', [], $this->adapter); + $table->addColumn($refTableColumnId, 'integer'); + $table->addForeignKey($refTableColumnId, $refTable->getName(), 'id'); + $table->save(); + + $refTable->renameColumn($refTableColumnToRename, $refTableRenamedColumn)->save(); + + $this->assertTrue($this->adapter->hasForeignKey($table->getName(), [$refTableColumnId])); + $this->assertFalse($this->adapter->hasTable("tmp_{$refTable->getName()}")); + $this->assertTrue($this->adapter->hasColumn($refTable->getName(), $refTableRenamedColumn)); + + $rows = $this->adapter->fetchAll('select * from sqlite_master where `type` = \'table\''); + foreach ($rows as $row) { + if ($row['tbl_name'] === $table->getName()) { + $sql = $row['sql']; + } + } + $this->assertStringContainsString("REFERENCES `{$refTable->getName()}` (`id`)", $sql); + } + + public function testForeignKeyReferenceCorrectAfterChangeColumn() + { + $refTableColumnId = 'ref_table_id'; + $refTableColumnToChange = 'columnToChange'; + $refTable = new Table('ref_table', [], $this->adapter); + $refTable->addColumn($refTableColumnToChange, 'string')->save(); + + $table = new Table('table', [], $this->adapter); + $table->addColumn($refTableColumnId, 'integer'); + $table->addForeignKey($refTableColumnId, $refTable->getName(), 'id'); + $table->save(); + + $refTable->changeColumn($refTableColumnToChange, 'text')->save(); + + $this->assertTrue($this->adapter->hasForeignKey($table->getName(), [$refTableColumnId])); + $this->assertFalse($this->adapter->hasTable("tmp_{$refTable->getName()}")); + $this->assertEquals('text', $this->adapter->getColumns($refTable->getName())[1]->getType()); + + $rows = $this->adapter->fetchAll('select * from sqlite_master where `type` = \'table\''); + foreach ($rows as $row) { + if ($row['tbl_name'] === $table->getName()) { + $sql = $row['sql']; + } + } + $this->assertStringContainsString("REFERENCES `{$refTable->getName()}` (`id`)", $sql); + } + + public function testForeignKeyReferenceCorrectAfterRemoveColumn() + { + $refTableColumnId = 'ref_table_id'; + $refTableColumnToRemove = 'columnToRemove'; + $refTable = new Table('ref_table', [], $this->adapter); + $refTable->addColumn($refTableColumnToRemove, 'string')->save(); + + $table = new Table('table', [], $this->adapter); + $table->addColumn($refTableColumnId, 'integer'); + $table->addForeignKey($refTableColumnId, $refTable->getName(), 'id'); + $table->save(); + + $refTable->removeColumn($refTableColumnToRemove)->save(); + + $this->assertTrue($this->adapter->hasForeignKey($table->getName(), [$refTableColumnId])); + $this->assertFalse($this->adapter->hasTable("tmp_{$refTable->getName()}")); + $this->assertFalse($this->adapter->hasColumn($refTable->getName(), $refTableColumnToRemove)); + + $rows = $this->adapter->fetchAll('select * from sqlite_master where `type` = \'table\''); + foreach ($rows as $row) { + if ($row['tbl_name'] === $table->getName()) { + $sql = $row['sql']; + } + } + $this->assertStringContainsString("REFERENCES `{$refTable->getName()}` (`id`)", $sql); + } + + public function testForeignKeyReferenceCorrectAfterChangePrimaryKey() + { + $refTableColumnAdditionalId = 'additional_id'; + $refTableColumnId = 'ref_table_id'; + $refTable = new Table('ref_table', [], $this->adapter); + $refTable->addColumn($refTableColumnAdditionalId, 'integer')->save(); + + $table = new Table('table', [], $this->adapter); + $table->addColumn($refTableColumnId, 'integer'); + $table->addForeignKey($refTableColumnId, $refTable->getName(), 'id'); + $table->save(); + + $refTable + ->addIndex('id', ['unique' => true]) + ->changePrimaryKey($refTableColumnAdditionalId) + ->save(); + + $this->assertTrue($this->adapter->hasForeignKey($table->getName(), [$refTableColumnId])); + $this->assertFalse($this->adapter->hasTable("tmp_{$refTable->getName()}")); + $this->assertTrue($this->adapter->getColumns($refTable->getName())[1]->getIdentity()); + + $rows = $this->adapter->fetchAll('select * from sqlite_master where `type` = \'table\''); + foreach ($rows as $row) { + if ($row['tbl_name'] === $table->getName()) { + $sql = $row['sql']; + } + } + $this->assertStringContainsString("REFERENCES `{$refTable->getName()}` (`id`)", $sql); + } + + public function testForeignKeyReferenceCorrectAfterDropForeignKey() + { + $refTableAdditionalColumnId = 'ref_table_additional_id'; + $refTableAdditional = new Table('ref_table_additional', [], $this->adapter); + $refTableAdditional->save(); + + $refTableColumnId = 'ref_table_id'; + $refTable = new Table('ref_table', [], $this->adapter); + $refTable->addColumn($refTableAdditionalColumnId, 'integer'); + $refTable->addForeignKey($refTableAdditionalColumnId, $refTableAdditional->getName(), 'id'); + $refTable->save(); + + $table = new Table('table', [], $this->adapter); + $table->addColumn($refTableColumnId, 'integer'); + $table->addForeignKey($refTableColumnId, $refTable->getName(), 'id'); + $table->save(); + + $refTable->dropForeignKey($refTableAdditionalColumnId)->save(); + + $this->assertTrue($this->adapter->hasForeignKey($table->getName(), [$refTableColumnId])); + $this->assertFalse($this->adapter->hasTable("tmp_{$refTable->getName()}")); + $this->assertFalse($this->adapter->hasForeignKey($refTable->getName(), [$refTableAdditionalColumnId])); + + $rows = $this->adapter->fetchAll('select * from sqlite_master where `type` = \'table\''); + foreach ($rows as $row) { + if ($row['tbl_name'] === $table->getName()) { + $sql = $row['sql']; + } + } + $this->assertStringContainsString("REFERENCES `{$refTable->getName()}` (`id`)", $sql); + } + + public function testPdoExceptionUpdateNonExistingTable() + { + $this->expectException(PDOException::class); + $table = new Table('non_existing_table', [], $this->adapter); + $table->addColumn('column', 'string')->update(); + } +} diff --git a/tests/TestCase/Db/Adapter/SqlserverAdapterTest.php b/tests/TestCase/Db/Adapter/SqlserverAdapterTest.php new file mode 100644 index 00000000..8719d2d1 --- /dev/null +++ b/tests/TestCase/Db/Adapter/SqlserverAdapterTest.php @@ -0,0 +1,1379 @@ +markTestSkipped('Sqlserver tests disabled.'); + } + // Emulate the results of Util::parseDsn() + $this->config = [ + 'adapter' => 'sqlserver', + 'connection' => ConnectionManager::get('test'), + 'database' => $config['database'], + ]; + + $this->adapter = new SqlserverAdapter($this->config, $this->getConsoleIo()); + + // ensure the database is empty for each test + $this->adapter->dropDatabase($this->config['database']); + $this->adapter->createDatabase($this->config['database']); + + // leave the adapter in a disconnected state for each test + $this->adapter->disconnect(); + } + + protected function tearDown(): void + { + if (!empty($this->adapter)) { + $this->adapter->disconnect(); + } + unset($this->adapter, $this->out, $this->io); + } + + protected function getConsoleIo(): ConsoleIo + { + $out = new StubConsoleOutput(); + $in = new StubConsoleInput([]); + $io = new ConsoleIo($out, $out, $in); + + $this->out = $out; + $this->io = $io; + + return $this->io; + } + + public function testConnection() + { + $this->assertInstanceOf(Connection::class, $this->adapter->getConnection()); + } + + public function testCreatingTheSchemaTableOnConnect() + { + $this->adapter->connect(); + $this->assertTrue($this->adapter->hasTable($this->adapter->getSchemaTableName())); + $this->adapter->dropTable($this->adapter->getSchemaTableName()); + $this->assertFalse($this->adapter->hasTable($this->adapter->getSchemaTableName())); + $this->adapter->disconnect(); + $this->adapter->connect(); + $this->assertTrue($this->adapter->hasTable($this->adapter->getSchemaTableName())); + } + + public function testSchemaTableIsCreatedWithPrimaryKey() + { + $this->adapter->connect(); + new Table($this->adapter->getSchemaTableName(), [], $this->adapter); + $this->assertTrue($this->adapter->hasIndex($this->adapter->getSchemaTableName(), ['version'])); + } + + public function testQuoteTableName() + { + $this->assertEquals('[test_table]', $this->adapter->quoteTableName('test_table')); + } + + public function testQuoteColumnName() + { + $this->assertEquals('[test_column]', $this->adapter->quoteColumnName('test_column')); + } + + public function testCreateTable() + { + $table = new Table('ntable', [], $this->adapter); + $table->addColumn('realname', 'string') + ->addColumn('email', 'integer') + ->save(); + $this->assertTrue($this->adapter->hasTable('ntable')); + $this->assertTrue($this->adapter->hasColumn('ntable', 'id')); + $this->assertTrue($this->adapter->hasColumn('ntable', 'realname')); + $this->assertTrue($this->adapter->hasColumn('ntable', 'email')); + $this->assertFalse($this->adapter->hasColumn('ntable', 'address')); + } + + public function testCreateTableCustomIdColumn() + { + $table = new Table('ntable', ['id' => 'custom_id'], $this->adapter); + $table->addColumn('realname', 'string') + ->addColumn('email', 'integer') + ->save(); + $this->assertTrue($this->adapter->hasTable('ntable')); + $this->assertTrue($this->adapter->hasColumn('ntable', 'custom_id')); + $this->assertTrue($this->adapter->hasColumn('ntable', 'realname')); + $this->assertTrue($this->adapter->hasColumn('ntable', 'email')); + $this->assertFalse($this->adapter->hasColumn('ntable', 'address')); + } + + public function testCreateTableIdentityColumn() + { + $table = new Table('ntable', ['id' => false, 'primary_key' => 'id'], $this->adapter); + $table->addColumn('id', 'integer', ['identity' => true, 'seed' => 1, 'increment' => 10 ]) + ->save(); + $this->assertTrue($this->adapter->hasTable('ntable')); + $this->assertTrue($this->adapter->hasColumn('ntable', 'id')); + + $rows = $this->adapter->fetchAll("SELECT CAST(seed_value AS INT) seed_value, CAST(increment_value AS INT) increment_value +FROM sys.columns c JOIN sys.tables t ON c.object_id=t.object_id +JOIN sys.identity_columns ic ON c.object_id=ic.object_id AND c.column_id=ic.column_id +WHERE t.name='ntable'"); + $identity = $rows[0]; + $this->assertEquals($identity['seed_value'], '1'); + $this->assertEquals($identity['increment_value'], '10'); + } + + public function testCreateTableWithNoPrimaryKey() + { + $options = [ + 'id' => false, + ]; + $table = new Table('atable', $options, $this->adapter); + $table->addColumn('user_id', 'integer') + ->save(); + $this->assertFalse($this->adapter->hasColumn('atable', 'id')); + } + + public function testCreateTableWithConflictingPrimaryKeys() + { + $options = [ + 'primary_key' => 'user_id', + ]; + $table = new Table('atable', $options, $this->adapter); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('You cannot enable an auto incrementing ID field and a primary key'); + $table->addColumn('user_id', 'integer')->save(); + } + + public function testCreateTableWithPrimaryKeySetToImplicitId() + { + $options = [ + 'primary_key' => 'id', + ]; + $table = new Table('ztable', $options, $this->adapter); + $table->addColumn('user_id', 'integer')->save(); + $this->assertTrue($this->adapter->hasColumn('ztable', 'id')); + $this->assertTrue($this->adapter->hasIndex('ztable', 'id')); + $this->assertTrue($this->adapter->hasColumn('ztable', 'user_id')); + } + + public function testCreateTableWithPrimaryKeyArraySetToImplicitId() + { + $options = [ + 'primary_key' => ['id'], + ]; + $table = new Table('ztable', $options, $this->adapter); + $table->addColumn('user_id', 'integer')->save(); + $this->assertTrue($this->adapter->hasColumn('ztable', 'id')); + $this->assertTrue($this->adapter->hasIndex('ztable', 'id')); + $this->assertTrue($this->adapter->hasColumn('ztable', 'user_id')); + } + + public function testCreateTableWithMultiplePrimaryKeyArraySetToImplicitId() + { + $options = [ + 'primary_key' => ['id', 'user_id'], + ]; + $table = new Table('ztable', $options, $this->adapter); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('You cannot enable an auto incrementing ID field and a primary key'); + $table->addColumn('user_id', 'integer')->save(); + } + + public function testCreateTableWithMultiplePrimaryKeys() + { + $options = [ + 'id' => false, + 'primary_key' => ['user_id', 'tag_id'], + ]; + $table = new Table('table1', $options, $this->adapter); + $table->addColumn('user_id', 'integer', ['null' => false]) + ->addColumn('tag_id', 'integer', ['null' => false]) + ->save(); + $this->assertTrue($this->adapter->hasIndex('table1', ['user_id', 'tag_id'])); + $this->assertTrue($this->adapter->hasIndex('table1', ['tag_id', 'USER_ID'])); + $this->assertFalse($this->adapter->hasIndex('table1', ['tag_id', 'user_email'])); + } + + public function testCreateTableWithPrimaryKeyAsUuid() + { + $options = [ + 'id' => false, + 'primary_key' => 'id', + ]; + $table = new Table('ztable', $options, $this->adapter); + $table->addColumn('id', 'uuid', ['null' => false])->save(); + $table->addColumn('user_id', 'integer')->save(); + $this->assertTrue($this->adapter->hasColumn('ztable', 'id')); + $this->assertTrue($this->adapter->hasIndex('ztable', 'id')); + $this->assertTrue($this->adapter->hasColumn('ztable', 'user_id')); + } + + public function testCreateTableWithPrimaryKeyAsBinaryUuid() + { + $options = [ + 'id' => false, + 'primary_key' => 'id', + ]; + $table = new Table('ztable', $options, $this->adapter); + $table->addColumn('id', 'binaryuuid', ['null' => false])->save(); + $table->addColumn('user_id', 'integer')->save(); + $this->assertTrue($this->adapter->hasColumn('ztable', 'id')); + $this->assertTrue($this->adapter->hasIndex('ztable', 'id')); + $this->assertTrue($this->adapter->hasColumn('ztable', 'user_id')); + } + + public function testCreateTableWithMultipleIndexes() + { + $table = new Table('table1', [], $this->adapter); + $table->addColumn('email', 'string') + ->addColumn('name', 'string') + ->addIndex('email') + ->addIndex('name') + ->save(); + $this->assertTrue($this->adapter->hasIndex('table1', ['email'])); + $this->assertTrue($this->adapter->hasIndex('table1', ['name'])); + $this->assertFalse($this->adapter->hasIndex('table1', ['email', 'user_email'])); + $this->assertFalse($this->adapter->hasIndex('table1', ['email', 'user_name'])); + } + + public function testCreateTableWithUniqueIndexes() + { + $table = new Table('table1', [], $this->adapter); + $table->addColumn('email', 'string') + ->addIndex('email', ['unique' => true]) + ->save(); + $this->assertTrue($this->adapter->hasIndex('table1', ['email'])); + $this->assertFalse($this->adapter->hasIndex('table1', ['email', 'user_email'])); + } + + public function testCreateTableWithNamedIndexes() + { + $table = new Table('table1', [], $this->adapter); + $table->addColumn('email', 'string') + ->addIndex('email', ['name' => 'myemailindex']) + ->save(); + $this->assertTrue($this->adapter->hasIndex('table1', ['email'])); + $this->assertFalse($this->adapter->hasIndex('table1', ['email', 'user_email'])); + $this->assertTrue($this->adapter->hasIndexByName('table1', 'myemailindex')); + } + + public function testAddPrimaryKey() + { + $table = new Table('table1', ['id' => false], $this->adapter); + $table + ->addColumn('column1', 'integer', ['null' => false]) + ->save(); + + $table + ->changePrimaryKey('column1') + ->save(); + + $this->assertTrue($this->adapter->hasPrimaryKey('table1', ['column1'])); + } + + public function testChangePrimaryKey() + { + $table = new Table('table1', ['id' => false, 'primary_key' => 'column1'], $this->adapter); + $table + ->addColumn('column1', 'integer', ['null' => false]) + ->addColumn('column2', 'integer', ['null' => false]) + ->addColumn('column3', 'integer', ['null' => false]) + ->save(); + + $table + ->changePrimaryKey(['column2', 'column3']) + ->save(); + + $this->assertFalse($this->adapter->hasPrimaryKey('table1', ['column1'])); + $this->assertTrue($this->adapter->hasPrimaryKey('table1', ['column2', 'column3'])); + } + + public function testDropPrimaryKey() + { + $table = new Table('table1', ['id' => false, 'primary_key' => 'column1'], $this->adapter); + $table + ->addColumn('column1', 'integer', ['null' => false]) + ->save(); + + $table + ->changePrimaryKey(null) + ->save(); + + $this->assertFalse($this->adapter->hasPrimaryKey('table1', ['column1'])); + } + + public function testHasPrimaryKeyMultipleColumns() + { + $table = new Table('table1', ['id' => false, 'primary_key' => ['column1', 'column2', 'column3']], $this->adapter); + $table + ->addColumn('column1', 'integer', ['null' => false]) + ->addColumn('column2', 'integer', ['null' => false]) + ->addColumn('column3', 'integer', ['null' => false]) + ->save(); + + $this->assertFalse($table->hasPrimaryKey(['column1', 'column2'])); + $this->assertTrue($table->hasPrimaryKey(['column1', 'column2', 'column3'])); + $this->assertFalse($table->hasPrimaryKey(['column1', 'column2', 'column3', 'column4'])); + } + + public function testHasPrimaryKeyCaseSensitivity() + { + $table = new Table('table', ['id' => false, 'primary_key' => ['column1']], $this->adapter); + $table + ->addColumn('column1', 'integer', ['null' => false]) + ->save(); + + $this->assertTrue($table->hasPrimaryKey('column1')); + $this->assertFalse($table->hasPrimaryKey('cOlUmN1')); + } + + public function testChangeCommentFails() + { + $table = new Table('table1', [], $this->adapter); + $table->save(); + + $this->expectException(BadMethodCallException::class); + + $table + ->changeComment('comment1') + ->save(); + } + + public function testRenameTable() + { + $table = new Table('table1', [], $this->adapter); + $table->save(); + $this->assertTrue($this->adapter->hasTable('table1')); + $this->assertFalse($this->adapter->hasTable('table2')); + $this->adapter->renameTable('table1', 'table2'); + $this->assertFalse($this->adapter->hasTable('table1')); + $this->assertTrue($this->adapter->hasTable('table2')); + } + + public function testAddColumn() + { + $table = new Table('table1', [], $this->adapter); + $table->save(); + $this->assertFalse($table->hasColumn('email')); + $table->addColumn('email', 'string') + ->save(); + $this->assertTrue($table->hasColumn('email')); + } + + public function testAddColumnWithDefaultValue() + { + $table = new Table('table1', [], $this->adapter); + $table->save(); + $table->addColumn('default_zero', 'string', ['default' => 'test']) + ->save(); + $columns = $this->adapter->getColumns('table1'); + foreach ($columns as $column) { + if ($column->getName() === 'default_zero') { + $this->assertEquals('test', $column->getDefault()); + } + } + } + + public function testAddColumnWithDefaultZero() + { + $table = new Table('table1', [], $this->adapter); + $table->save(); + $table->addColumn('default_zero', 'integer', ['default' => 0]) + ->save(); + $columns = $this->adapter->getColumns('table1'); + foreach ($columns as $column) { + if ($column->getName() === 'default_zero') { + $this->assertNotNull($column->getDefault()); + $this->assertEquals('0', $column->getDefault()); + } + } + } + + public function testAddColumnWithDefaultNull() + { + $table = new Table('table1', [], $this->adapter); + $table->save(); + $table->addColumn('default_null', 'string', ['null' => true, 'default' => null]) + ->save(); + $columns = $this->adapter->getColumns('table1'); + foreach ($columns as $column) { + if ($column->getName() === 'default_null') { + $this->assertNull($column->getDefault()); + } + } + } + + public function testAddColumnWithNotNullableNoDefault() + { + $table = new Table('table1', [], $this->adapter); + $table + ->addColumn('col', 'string', ['null' => false]) + ->create(); + + $columns = $this->adapter->getColumns('table1'); + $this->assertCount(2, $columns); + $this->assertArrayHasKey('id', $columns); + $this->assertArrayHasKey('col', $columns); + $this->assertFalse($columns['col']->isNull()); + $this->assertNull($columns['col']->getDefault()); + } + + public function testAddColumnWithDefaultBool() + { + $table = new Table('table1', [], $this->adapter); + $table->save(); + $table + ->addColumn('default_false', 'integer', ['default' => false]) + ->addColumn('default_true', 'integer', ['default' => true]) + ->save(); + $columns = $this->adapter->getColumns('table1'); + foreach ($columns as $column) { + if ($column->getName() === 'default_false') { + $this->assertSame(0, $column->getDefault()); + } + if ($column->getName() === 'default_true') { + $this->assertSame(1, $column->getDefault()); + } + } + } + + public function testRenameColumn() + { + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'string') + ->save(); + $this->assertTrue($this->adapter->hasColumn('t', 'column1')); + $this->assertFalse($this->adapter->hasColumn('t', 'column2')); + $this->adapter->renameColumn('t', 'column1', 'column2'); + $this->assertFalse($this->adapter->hasColumn('t', 'column1')); + $this->assertTrue($this->adapter->hasColumn('t', 'column2')); + } + + public function testRenamingANonExistentColumn() + { + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'string') + ->save(); + + try { + $this->adapter->renameColumn('t', 'column2', 'column1'); + $this->fail('Expected the adapter to throw an exception'); + } catch (InvalidArgumentException $e) { + $this->assertInstanceOf( + 'InvalidArgumentException', + $e, + 'Expected exception of type InvalidArgumentException, got ' . get_class($e) + ); + $this->assertEquals('The specified column does not exist: column2', $e->getMessage()); + } + } + + public function testChangeColumnType() + { + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'string') + ->save(); + $this->assertTrue($this->adapter->hasColumn('t', 'column1')); + $newColumn1 = new Column(); + $newColumn1->setName('column1') + ->setType('string'); + $table->changeColumn('column1', $newColumn1)->save(); + $this->assertTrue($this->adapter->hasColumn('t', 'column1')); + $columns = $this->adapter->getColumns('t'); + foreach ($columns as $column) { + if ($column->getName() === 'column1') { + $this->assertEquals('string', $column->getType()); + } + } + } + + public function testChangeColumnNameAndNull() + { + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'string', ['null' => false]) + ->save(); + $newColumn2 = new Column(); + $newColumn2->setName('column2') + ->setType('string') + ->setNull(true); + $table->changeColumn('column1', $newColumn2)->save(); + $this->assertFalse($this->adapter->hasColumn('t', 'column1')); + $this->assertTrue($this->adapter->hasColumn('t', 'column2')); + $columns = $this->adapter->getColumns('t'); + foreach ($columns as $column) { + if ($column->getName() === 'column2') { + $this->assertTrue($column->isNull()); + } + } + } + + public function testChangeColumnDefaults() + { + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'string', ['default' => 'test']) + ->save(); + $this->assertTrue($this->adapter->hasColumn('t', 'column1')); + + $columns = $this->adapter->getColumns('t'); + $this->assertSame('test', $columns['column1']->getDefault()); + + $newColumn1 = new Column(); + $newColumn1 + ->setName('column1') + ->setType('string') + ->setDefault('another test'); + $table->changeColumn('column1', $newColumn1)->save(); + $this->assertTrue($this->adapter->hasColumn('t', 'column1')); + + $columns = $this->adapter->getColumns('t'); + $this->assertSame('another test', $columns['column1']->getDefault()); + } + + public function testChangeColumnDefaultToNull() + { + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'string', ['null' => true, 'default' => 'test']) + ->save(); + $newColumn1 = new Column(); + $newColumn1 + ->setName('column1') + ->setType('string') + ->setDefault(null); + $table->changeColumn('column1', $newColumn1)->save(); + $columns = $this->adapter->getColumns('t'); + $this->assertNull($columns['column1']->getDefault()); + } + + public function testChangeColumnDefaultToZero() + { + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'integer') + ->save(); + $newColumn1 = new Column(); + $newColumn1 + ->setName('column1') + ->setType('string') + ->setDefault(0); + $table->changeColumn('column1', $newColumn1)->save(); + $columns = $this->adapter->getColumns('t'); + $this->assertSame(0, $columns['column1']->getDefault()); + } + + public function testDropColumn() + { + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'string') + ->save(); + $this->assertTrue($this->adapter->hasColumn('t', 'column1')); + $this->adapter->dropColumn('t', 'column1'); + $this->assertFalse($this->adapter->hasColumn('t', 'column1')); + } + + public static function columnsProvider() + { + return [ + ['column1', 'string', ['null' => true, 'default' => null]], + ['column2', 'integer', ['default' => 0]], + ['column3', 'biginteger', ['default' => 5]], + ['column4', 'text', ['default' => 'text']], + ['column5', 'float', []], + ['column6', 'decimal', []], + ['column7', 'time', []], + ['column8', 'date', []], + ['column9', 'boolean', []], + ['column10', 'datetime', []], + ['column11', 'binary', []], + ['column12', 'string', ['limit' => 10]], + ['column13', 'tinyinteger', ['default' => 5]], + ['column14', 'smallinteger', ['default' => 5]], + ['decimal_precision_scale', 'decimal', ['precision' => 10, 'scale' => 2]], + ['decimal_limit', 'decimal', ['limit' => 10]], + ['decimal_precision', 'decimal', ['precision' => 10]], + ]; + } + + /** + * @dataProvider columnsProvider + */ + public function testGetColumns($colName, $type, $options) + { + $table = new Table('t', [], $this->adapter); + $table + ->addColumn($colName, $type, $options) + ->save(); + + $columns = $this->adapter->getColumns('t'); + $this->assertCount(2, $columns); + $this->assertEquals($colName, $columns[$colName]->getName()); + $this->assertEquals($type, $columns[$colName]->getType()); + } + + public function testAddIndex() + { + $table = new Table('table1', [], $this->adapter); + $table->addColumn('email', 'string') + ->save(); + $this->assertFalse($table->hasIndex('email')); + $table->addIndex('email') + ->save(); + $this->assertTrue($table->hasIndex('email')); + } + + public function testAddIndexWithSort() + { + $table = new Table('table1', [], $this->adapter); + $table->addColumn('email', 'string') + ->addColumn('username', 'string') + ->save(); + $this->assertFalse($table->hasIndexByName('table1_email_username')); + $table->addIndex(['email', 'username'], ['name' => 'table1_email_username', 'order' => ['email' => 'DESC', 'username' => 'ASC']]) + ->save(); + $this->assertTrue($table->hasIndexByName('table1_email_username')); + $rows = $this->adapter->fetchAll("SELECT case when ic.is_descending_key = 1 then 'DESC' else 'ASC' end AS sort_order + FROM sys.indexes AS i + INNER JOIN sys.index_columns AS ic ON i.object_id = ic.object_id AND i.index_id = ic.index_id + INNER JOIN sys.tables AS t ON i.object_id=t.object_id + INNER JOIN sys.columns AS c on ic.column_id=c.column_id and ic.object_id=c.object_id + WHERE t.name = 'table1' AND i.name = 'table1_email_username' AND c.name = 'email'"); + $emailOrder = $rows[0]; + $this->assertEquals($emailOrder['sort_order'], 'DESC'); + $rows = $this->adapter->fetchAll("SELECT case when ic.is_descending_key = 1 then 'DESC' else 'ASC' end AS sort_order + FROM sys.indexes AS i + INNER JOIN sys.index_columns AS ic ON i.object_id = ic.object_id AND i.index_id = ic.index_id + INNER JOIN sys.tables AS t ON i.object_id=t.object_id + INNER JOIN sys.columns AS c on ic.column_id=c.column_id and ic.object_id=c.object_id + WHERE t.name = 'table1' AND i.name = 'table1_email_username' AND c.name = 'username'"); + $emailOrder = $rows[0]; + $this->assertEquals($emailOrder['sort_order'], 'ASC'); + } + + public function testAddIndexWithIncludeColumns() + { + $table = new Table('table1', [], $this->adapter); + $table->addColumn('email', 'string') + ->addColumn('firstname', 'string') + ->addColumn('lastname', 'string') + ->save(); + $this->assertFalse($table->hasIndex('email')); + $table->addIndex(['email'], ['include' => ['firstname', 'lastname']]) + ->save(); + $this->assertTrue($table->hasIndex('email')); + $rows = $this->adapter->fetchAll("SELECT ic.is_included_column AS included + FROM sys.indexes AS i + INNER JOIN sys.index_columns AS ic ON i.object_id = ic.object_id AND i.index_id = ic.index_id + INNER JOIN sys.tables AS t ON i.object_id=t.object_id + INNER JOIN sys.columns AS c on ic.column_id=c.column_id and ic.object_id=c.object_id + WHERE t.name = 'table1' AND c.name = 'email'"); + $emailOrder = $rows[0]; + $this->assertEquals($emailOrder['included'], 0); + $rows = $this->adapter->fetchAll("SELECT ic.is_included_column AS included + FROM sys.indexes AS i + INNER JOIN sys.index_columns AS ic ON i.object_id = ic.object_id AND i.index_id = ic.index_id + INNER JOIN sys.tables AS t ON i.object_id=t.object_id + INNER JOIN sys.columns AS c on ic.column_id=c.column_id and ic.object_id=c.object_id + WHERE t.name = 'table1' AND c.name = 'firstname'"); + $emailOrder = $rows[0]; + $this->assertEquals($emailOrder['included'], 1); + $rows = $this->adapter->fetchAll("SELECT ic.is_included_column AS included + FROM sys.indexes AS i + INNER JOIN sys.index_columns AS ic ON i.object_id = ic.object_id AND i.index_id = ic.index_id + INNER JOIN sys.tables AS t ON i.object_id=t.object_id + INNER JOIN sys.columns AS c on ic.column_id=c.column_id and ic.object_id=c.object_id + WHERE t.name = 'table1' AND c.name = 'lastname'"); + $emailOrder = $rows[0]; + $this->assertEquals($emailOrder['included'], 1); + } + + public function testGetIndexes() + { + // single column index + $table = new Table('table1', [], $this->adapter); + $table->addColumn('email', 'string') + ->addColumn('username', 'string') + ->addIndex('email') + ->addIndex(['email', 'username'], ['unique' => true, 'name' => 'email_username']) + ->save(); + + $indexes = $this->adapter->getIndexes('table1'); + $this->assertArrayHasKey('PK_table1', $indexes); + $this->assertArrayHasKey('table1_email', $indexes); + $this->assertArrayHasKey('email_username', $indexes); + + $this->assertEquals(['id'], $indexes['PK_table1']['columns']); + $this->assertEquals(['email'], $indexes['table1_email']['columns']); + $this->assertEquals(['email', 'username'], $indexes['email_username']['columns']); + } + + public function testDropIndex() + { + // single column index + $table = new Table('table1', [], $this->adapter); + $table->addColumn('email', 'string') + ->addIndex('email') + ->save(); + $this->assertTrue($table->hasIndex('email')); + $this->adapter->dropIndex($table->getName(), 'email'); + $this->assertFalse($table->hasIndex('email')); + + // multiple column index + $table2 = new Table('table2', [], $this->adapter); + $table2->addColumn('fname', 'string') + ->addColumn('lname', 'string') + ->addIndex(['fname', 'lname']) + ->save(); + $this->assertTrue($table2->hasIndex(['fname', 'lname'])); + $this->adapter->dropIndex($table2->getName(), ['fname', 'lname']); + $this->assertFalse($table2->hasIndex(['fname', 'lname'])); + + // index with name specified, but dropping it by column name + $table3 = new Table('table3', [], $this->adapter); + $table3->addColumn('email', 'string') + ->addIndex('email', ['name' => 'someindexname']) + ->save(); + $this->assertTrue($table3->hasIndex('email')); + $this->adapter->dropIndex($table3->getName(), 'email'); + $this->assertFalse($table3->hasIndex('email')); + + // multiple column index with name specified + $table4 = new Table('table4', [], $this->adapter); + $table4->addColumn('fname', 'string') + ->addColumn('lname', 'string') + ->addIndex(['fname', 'lname'], ['name' => 'multiname']) + ->save(); + $this->assertTrue($table4->hasIndex(['fname', 'lname'])); + $this->adapter->dropIndex($table4->getName(), ['fname', 'lname']); + $this->assertFalse($table4->hasIndex(['fname', 'lname'])); + } + + public function testDropIndexByName() + { + // single column index + $table = new Table('table1', [], $this->adapter); + $table->addColumn('email', 'string') + ->addIndex('email', ['name' => 'myemailindex']) + ->save(); + $this->assertTrue($table->hasIndex('email')); + $this->adapter->dropIndexByName($table->getName(), 'myemailindex'); + $this->assertFalse($table->hasIndex('email')); + + // multiple column index + $table2 = new Table('table2', [], $this->adapter); + $table2->addColumn('fname', 'string') + ->addColumn('lname', 'string') + ->addIndex( + ['fname', 'lname'], + ['name' => 'twocolumnuniqueindex', 'unique' => true] + ) + ->save(); + $this->assertTrue($table2->hasIndex(['fname', 'lname'])); + $this->adapter->dropIndexByName($table2->getName(), 'twocolumnuniqueindex'); + $this->assertFalse($table2->hasIndex(['fname', 'lname'])); + } + + public function testAddForeignKey() + { + $refTable = new Table('ref_table', [], $this->adapter); + $refTable->addColumn('field1', 'string')->save(); + + $table = new Table('table', [], $this->adapter); + $table->addColumn('ref_table_id', 'integer')->save(); + + $fk = new ForeignKey(); + $fk->setReferencedTable($refTable->getTable()) + ->setColumns(['ref_table_id']) + ->setReferencedColumns(['id']) + ->setConstraint('fk1'); + + $this->adapter->addForeignKey($table->getTable(), $fk); + $this->assertTrue($this->adapter->hasForeignKey($table->getName(), ['ref_table_id'], 'fk1')); + } + + public function testDropForeignKey() + { + $refTable = new Table('ref_table', [], $this->adapter); + $refTable->addColumn('field1', 'string')->save(); + + $table = new Table('table', [], $this->adapter); + $table + ->addColumn('ref_table_id', 'integer') + ->addForeignKey(['ref_table_id'], 'ref_table', ['id']) + ->save(); + + $this->adapter->dropForeignKey($table->getName(), ['ref_table_id']); + + $this->assertFalse($this->adapter->hasForeignKey($table->getName(), ['ref_table_id'])); + } + + public function testDropForeignKeyWithMultipleColumns() + { + $refTable = new Table('ref_table', [], $this->adapter); + $refTable + ->addColumn('field1', 'string') + ->addColumn('field2', 'string') + ->addIndex(['id', 'field1'], ['unique' => true]) + ->addIndex(['field1', 'id'], ['unique' => true]) + ->addIndex(['id', 'field1', 'field2'], ['unique' => true]) + ->save(); + + $table = new Table('table', [], $this->adapter); + $table + ->addColumn('ref_table_id', 'integer') + ->addColumn('ref_table_field1', 'string') + ->addColumn('ref_table_field2', 'string') + ->addForeignKey( + ['ref_table_id', 'ref_table_field1'], + 'ref_table', + ['id', 'field1'] + ) + ->addForeignKey( + ['ref_table_field1', 'ref_table_id'], + 'ref_table', + ['field1', 'id'] + ) + ->addForeignKey( + ['ref_table_id', 'ref_table_field1', 'ref_table_field2'], + 'ref_table', + ['id', 'field1', 'field2'] + ) + ->save(); + + $this->assertTrue($this->adapter->hasForeignKey($table->getName(), ['ref_table_id', 'ref_table_field1'])); + $this->adapter->dropForeignKey($table->getName(), ['ref_table_id', 'ref_table_field1']); + $this->assertFalse($this->adapter->hasForeignKey($table->getName(), ['ref_table_id', 'ref_table_field1'])); + $this->assertTrue( + $this->adapter->hasForeignKey($table->getName(), ['ref_table_id', 'ref_table_field1', 'ref_table_field2']), + 'dropForeignKey() should only affect foreign keys that comprise of exactly the given columns' + ); + $this->assertTrue( + $this->adapter->hasForeignKey($table->getName(), ['ref_table_field1', 'ref_table_id']), + 'dropForeignKey() should only affect foreign keys that comprise of columns in exactly the given order' + ); + + $this->assertTrue($this->adapter->hasForeignKey($table->getName(), ['ref_table_field1', 'ref_table_id'])); + $this->adapter->dropForeignKey($table->getName(), ['ref_table_field1', 'ref_table_id']); + $this->assertFalse($this->adapter->hasForeignKey($table->getName(), ['ref_table_field1', 'ref_table_id'])); + } + + public function testDropForeignKeyWithIdenticalMultipleColumns() + { + $refTable = new Table('ref_table', [], $this->adapter); + $refTable + ->addColumn('field1', 'string') + ->addIndex(['id', 'field1'], ['unique' => true]) + ->save(); + + $table = new Table('table', [], $this->adapter); + $table + ->addColumn('ref_table_id', 'integer', ['signed' => false]) + ->addColumn('ref_table_field1', 'string') + ->addForeignKeyWithName( + 'ref_table_fk_1', + ['ref_table_id', 'ref_table_field1'], + 'ref_table', + ['id', 'field1'], + ) + ->addForeignKeyWithName( + 'ref_table_fk_2', + ['ref_table_id', 'ref_table_field1'], + 'ref_table', + ['id', 'field1'] + ) + ->save(); + + $this->assertTrue($this->adapter->hasForeignKey($table->getName(), ['ref_table_id', 'ref_table_field1'])); + $this->assertTrue($this->adapter->hasForeignKey($table->getName(), [], 'ref_table_fk_1')); + $this->assertTrue($this->adapter->hasForeignKey($table->getName(), [], 'ref_table_fk_2')); + + $this->adapter->dropForeignKey($table->getName(), ['ref_table_id', 'ref_table_field1']); + + $this->assertFalse($this->adapter->hasForeignKey($table->getName(), ['ref_table_id', 'ref_table_field1'])); + $this->assertFalse($this->adapter->hasForeignKey($table->getName(), [], 'ref_table_fk_1')); + $this->assertFalse($this->adapter->hasForeignKey($table->getName(), [], 'ref_table_fk_2')); + } + + public static function nonExistentForeignKeyColumnsProvider(): array + { + return [ + [['ref_table_id']], + [['ref_table_field1']], + [['ref_table_field1', 'ref_table_id']], + [['non_existent_column']], + ]; + } + + /** + * @dataProvider nonExistentForeignKeyColumnsProvider + * @param array $columns + */ + public function testDropForeignKeyByNonExistentKeyColumns(array $columns) + { + $refTable = new Table('ref_table', [], $this->adapter); + $refTable + ->addColumn('field1', 'string') + ->addIndex(['id', 'field1'], ['unique' => true]) + ->save(); + + $table = new Table('table', [], $this->adapter); + $table + ->addColumn('ref_table_id', 'integer') + ->addColumn('ref_table_field1', 'string') + ->addForeignKey( + ['ref_table_id', 'ref_table_field1'], + 'ref_table', + ['id', 'field1'] + ) + ->save(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage(sprintf( + 'No foreign key on column(s) `%s` exists', + implode(', ', $columns) + )); + + $this->adapter->dropForeignKey($table->getName(), $columns); + } + + public function testDropForeignKeyCaseSensitivity() + { + $refTable = new Table('ref_table', [], $this->adapter); + $refTable->save(); + + $table = new Table('table', [], $this->adapter); + $table + ->addColumn('REF_TABLE_ID', 'integer') + ->addForeignKey(['REF_TABLE_ID'], 'ref_table', ['id']) + ->save(); + + $this->assertTrue($this->adapter->hasForeignKey($table->getName(), ['REF_TABLE_ID'])); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage(sprintf( + 'No foreign key on column(s) `%s` exists', + implode(', ', ['ref_table_id']) + )); + + $this->adapter->dropForeignKey($table->getName(), ['ref_table_id']); + } + + public function testDropForeignKeyByName() + { + $refTable = new Table('ref_table', [], $this->adapter); + $refTable->save(); + + $table = new Table('table', [], $this->adapter); + $table + ->addColumn('ref_table_id', 'integer', ['signed' => false]) + ->addForeignKeyWithName('my_constraint', ['ref_table_id'], 'ref_table', ['id']) + ->save(); + + $this->adapter->dropForeignKey($table->getName(), [], 'my_constraint'); + $this->assertFalse($this->adapter->hasForeignKey($table->getName(), ['ref_table_id'])); + } + + /** + * @dataProvider provideForeignKeysToCheck + */ + public function testHasForeignKey($tableDef, $key, $exp) + { + $conn = $this->adapter->getConnection(); + $conn->execute('CREATE TABLE other(a int, b int, c int, unique(a), unique(b), unique(a,b), unique(a,b,c));'); + $conn->execute($tableDef); + $this->assertSame($exp, $this->adapter->hasForeignKey('t', $key)); + } + + public static function provideForeignKeysToCheck() + { + return [ + ['create table t(a int)', 'a', false], + ['create table t(a int)', [], false], + ['create table t(a int primary key)', 'a', false], + ['create table t(a int, foreign key (a) references other(a))', 'a', true], + ['create table t(a int, foreign key (a) references other(b))', 'a', true], + ['create table t(a int, foreign key (a) references other(b))', ['a'], true], + ['create table t(a int, foreign key (a) references other(b))', ['a', 'a'], false], + ['create table t(a int, foreign key(a) references other(a))', 'a', true], + ['create table t(a int, b int, foreign key(a,b) references other(a,b))', 'a', false], + ['create table t(a int, b int, foreign key(a,b) references other(a,b))', ['a', 'b'], true], + ['create table t(a int, b int, foreign key(a,b) references other(a,b))', ['b', 'a'], false], + ['create table t(a int, [B] int, foreign key(a,[B]) references other(a,b))', ['a', 'b'], false], + ['create table t(a int, b int, foreign key(a,b) references other(a,b))', ['a', 'B'], false], + ['create table t(a int, b int, c int, foreign key(a,b,c) references other(a,b,c))', ['a', 'b'], false], + ['create table t(a int, foreign key(a) references other(a))', ['a', 'b'], false], + ['create table t(a int, b int, foreign key(a) references other(a), foreign key(b) references other(b))', ['a', 'b'], false], + ['create table t(a int, b int, foreign key(a) references other(a), foreign key(b) references other(b))', ['a', 'b'], false], + ['create table t([0] int, foreign key([0]) references other(a))', '0', true], + ['create table t([0] int, foreign key([0]) references other(a))', '0e0', false], + ['create table t([0e0] int, foreign key([0e0]) references other(a))', '0', false], + ]; + } + + public function testHasNamedForeignKey() + { + $refTable = new Table('ref_table', [], $this->adapter); + $refTable->save(); + + $table = new Table('table', [], $this->adapter); + $table + ->addColumn('ref_table_id', 'integer') + ->addForeignKeyWithName('my_constraint', ['ref_table_id'], 'ref_table', ['id']) + ->save(); + + $this->assertTrue($this->adapter->hasForeignKey($table->getName(), ['ref_table_id'], 'my_constraint')); + $this->assertFalse($this->adapter->hasForeignKey($table->getName(), ['ref_table_id'], 'my_constraint2')); + + $this->assertTrue($this->adapter->hasForeignKey($table->getName(), [], 'my_constraint')); + $this->assertFalse($this->adapter->hasForeignKey($table->getName(), [], 'my_constraint2')); + } + + public function testHasDatabase() + { + $this->assertFalse($this->adapter->hasDatabase('fake_database_name')); + $this->assertTrue($this->adapter->hasDatabase($this->config['database'])); + } + + public function testDropDatabase() + { + $this->assertFalse($this->adapter->hasDatabase('phinx_temp_database')); + $this->adapter->createDatabase('phinx_temp_database'); + $this->assertTrue($this->adapter->hasDatabase('phinx_temp_database')); + $this->adapter->dropDatabase('phinx_temp_database'); + } + + public function testInvalidSqlType() + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Column type "idontexist" is not supported by SqlServer.'); + + $this->adapter->getSqlType('idontexist'); + } + + public function testGetPhinxType() + { + $this->assertEquals('integer', $this->adapter->getPhinxType('int')); + $this->assertEquals('integer', $this->adapter->getPhinxType('integer')); + + $this->assertEquals('tinyinteger', $this->adapter->getPhinxType('tinyint')); + $this->assertEquals('smallinteger', $this->adapter->getPhinxType('smallint')); + $this->assertEquals('biginteger', $this->adapter->getPhinxType('bigint')); + + $this->assertEquals('decimal', $this->adapter->getPhinxType('decimal')); + $this->assertEquals('decimal', $this->adapter->getPhinxType('numeric')); + + $this->assertEquals('float', $this->adapter->getPhinxType('real')); + + $this->assertEquals('boolean', $this->adapter->getPhinxType('bit')); + + $this->assertEquals('string', $this->adapter->getPhinxType('varchar')); + $this->assertEquals('string', $this->adapter->getPhinxType('nvarchar')); + $this->assertEquals('char', $this->adapter->getPhinxType('char')); + $this->assertEquals('char', $this->adapter->getPhinxType('nchar')); + + $this->assertEquals('text', $this->adapter->getPhinxType('text')); + + $this->assertEquals('datetime', $this->adapter->getPhinxType('timestamp')); + + $this->assertEquals('date', $this->adapter->getPhinxType('date')); + + $this->assertEquals('datetime', $this->adapter->getPhinxType('datetime')); + } + + public function testAddColumnComment() + { + $table = new Table('table1', [], $this->adapter); + $table->addColumn('field1', 'string', ['comment' => $comment = 'Comments from column "field1"']) + ->save(); + + $resultComment = $this->adapter->getColumnComment('table1', 'field1'); + + $this->assertEquals($comment, $resultComment, 'Dont set column comment correctly'); + } + + /** + * @dependss testAddColumnComment + */ + public function testChangeColumnComment() + { + $table = new Table('table1', [], $this->adapter); + $table->addColumn('field1', 'string', ['comment' => 'Comments from column "field1"']) + ->save(); + + $table->changeColumn('field1', 'string', ['comment' => $comment = 'New Comments from column "field1"']) + ->save(); + + $resultComment = $this->adapter->getColumnComment('table1', 'field1'); + + $this->assertEquals($comment, $resultComment, 'Dont change column comment correctly'); + } + + /** + * @depends testAddColumnComment + */ + public function testRemoveColumnComment() + { + $table = new Table('table1', [], $this->adapter); + $table->addColumn('field1', 'string', ['comment' => 'Comments from column "field1"']) + ->save(); + + $table->changeColumn('field1', 'string', ['comment' => 'null']) + ->save(); + + $resultComment = $this->adapter->getColumnComment('table1', 'field1'); + + $this->assertEmpty($resultComment, "Didn't remove column comment correctly: " . json_encode($resultComment)); + } + + /** + * Test that column names are properly escaped when creating Foreign Keys + */ + public function testForignKeysArePropertlyEscaped() + { + $userId = 'user'; + $sessionId = 'session'; + + $local = new Table('users', ['id' => $userId], $this->adapter); + $local->create(); + + $foreign = new Table('sessions', ['id' => $sessionId], $this->adapter); + $foreign->addColumn('user', 'integer') + ->addForeignKey('user', 'users', $userId) + ->create(); + + $this->assertTrue($foreign->hasForeignKey('user')); + } + + public function testBulkInsertData() + { + $table = new Table('table1', [], $this->adapter); + $table->addColumn('column1', 'string') + ->addColumn('column2', 'integer') + ->save(); + $table->insert([ + [ + 'column1' => 'value1', + 'column2' => 1, + ], + [ + 'column1' => 'value2', + 'column2' => 2, + ], + ]) + ->insert( + [ + 'column1' => 'value3', + 'column2' => 3, + ] + ); + $this->adapter->bulkinsert($table->getTable(), $table->getData()); + $table->reset(); + + $rows = $this->adapter->fetchAll('SELECT * FROM table1'); + + $this->assertEquals('value1', $rows[0]['column1']); + $this->assertEquals('value2', $rows[1]['column1']); + $this->assertEquals('value3', $rows[2]['column1']); + $this->assertEquals(1, $rows[0]['column2']); + $this->assertEquals(2, $rows[1]['column2']); + $this->assertEquals(3, $rows[2]['column2']); + } + + public function testInsertData() + { + $table = new Table('table1', [], $this->adapter); + $table->addColumn('column1', 'string') + ->addColumn('column2', 'integer') + ->insert([ + [ + 'column1' => 'value1', + 'column2' => 1, + ], + [ + 'column1' => 'value2', + 'column2' => 2, + ], + ]) + ->insert( + [ + 'column1' => 'value3', + 'column2' => 3, + ] + ) + ->save(); + + $rows = $this->adapter->fetchAll('SELECT * FROM table1'); + + $this->assertEquals('value1', $rows[0]['column1']); + $this->assertEquals('value2', $rows[1]['column1']); + $this->assertEquals('value3', $rows[2]['column1']); + $this->assertEquals(1, $rows[0]['column2']); + $this->assertEquals(2, $rows[1]['column2']); + $this->assertEquals(3, $rows[2]['column2']); + } + + public function testTruncateTable() + { + $table = new Table('table1', [], $this->adapter); + $table->addColumn('column1', 'string') + ->addColumn('column2', 'integer') + ->insert([ + [ + 'column1' => 'value1', + 'column2' => 1, + ], + [ + 'column1' => 'value2', + 'column2' => 2, + ], + ]) + ->save(); + + $rows = $this->adapter->fetchAll('SELECT * FROM table1'); + $this->assertCount(2, $rows); + $table->truncate(); + $rows = $this->adapter->fetchAll('SELECT * FROM table1'); + $this->assertCount(0, $rows); + } + + public function testDumpCreateTableAndThenInsert() + { + $options = $this->adapter->getOptions(); + $options['dryrun'] = true; + $this->adapter->setOptions($options); + + $table = new Table('table1', ['id' => false, 'primary_key' => ['column1']], $this->adapter); + + $table->addColumn('column1', 'string', ['null' => false]) + ->addColumn('column2', 'integer') + ->save(); + + $expectedOutput = 'C'; + + $table = new Table('table1', [], $this->adapter); + $table->insert([ + 'column1' => 'id1', + 'column2' => 1, + ])->save(); + + $expectedOutput = <<<'OUTPUT' +CREATE TABLE [table1] ([column1] NVARCHAR (255) NOT NULL , [column2] INT NULL DEFAULT NULL, CONSTRAINT PK_table1 PRIMARY KEY ([column1])); +INSERT INTO [table1] ([column1], [column2]) VALUES ('id1', 1); +OUTPUT; + $output = join("\n", $this->out->messages()); + $actualOutput = str_replace("\r\n", "\n", $output); + $this->assertStringContainsString($expectedOutput, $actualOutput, 'Passing the --dry-run option does not dump create and then insert table queries to the output'); + } + + /** + * Tests interaction with the query builder + */ + public function testQueryBuilder() + { + $table = new Table('table1', [], $this->adapter); + $table->addColumn('string_col', 'string') + ->addColumn('int_col', 'integer') + ->save(); + + $builder = $this->adapter->getQueryBuilder(Query::TYPE_INSERT); + $stm = $builder + ->insert(['string_col', 'int_col']) + ->into('table1') + ->values(['string_col' => 'value1', 'int_col' => 1]) + ->values(['string_col' => 'value2', 'int_col' => 2]) + ->execute(); + + $stm->closeCursor(); + + $builder = $this->adapter->getQueryBuilder(Query::TYPE_SELECT); + $stm = $builder + ->select('*') + ->from('table1') + ->where(['int_col >=' => 2]) + ->execute(); + + $this->assertEquals(1, $stm->rowCount()); + $this->assertEquals( + ['id' => 2, 'string_col' => 'value2', 'int_col' => '2'], + $stm->fetch('assoc') + ); + + $stm->closeCursor(); + + $builder = $this->adapter->getQueryBuilder(Query::TYPE_DELETE); + $stm = $builder + ->delete('table1') + ->where(['int_col <' => 2]) + ->execute(); + + $stm->closeCursor(); + } + + public function testQueryWithParams() + { + $table = new Table('table1', [], $this->adapter); + $table->addColumn('string_col', 'string') + ->addColumn('int_col', 'integer') + ->save(); + + $this->adapter->insert($table->getTable(), [ + 'string_col' => 'test data', + 'int_col' => 10, + ]); + + $this->adapter->insert($table->getTable(), [ + 'string_col' => null, + ]); + + $this->adapter->insert($table->getTable(), [ + 'int_col' => 23, + ]); + + $countQuery = $this->adapter->query('SELECT COUNT(*) AS c FROM table1 WHERE int_col > ?', [5]); + $res = $countQuery->fetchAll('assoc'); + $this->assertEquals(2, $res[0]['c']); + + $this->adapter->execute('UPDATE table1 SET int_col = ? WHERE int_col IS NULL', [12]); + + $countQuery->execute([1]); + $res = $countQuery->fetchAll('assoc'); + $this->assertEquals(3, $res[0]['c']); + } + + public function testLiteralSupport() + { + $createQuery = <<<'INPUT' +CREATE TABLE test (smallmoney_col smallmoney) +INPUT; + $this->adapter->execute($createQuery); + $table = new Table('test', [], $this->adapter); + $columns = $table->getColumns(); + $this->assertCount(1, $columns); + $this->assertEquals(Literal::from('smallmoney'), array_pop($columns)->getType()); + } +} diff --git a/tests/TestCase/Db/LiteralTest.php b/tests/TestCase/Db/LiteralTest.php new file mode 100644 index 00000000..e8c55fdb --- /dev/null +++ b/tests/TestCase/Db/LiteralTest.php @@ -0,0 +1,25 @@ +assertEquals($str, (string)$instance); + } + + public function testFrom() + { + $str = 'test1'; + $instance = Literal::from($str); + $this->assertInstanceOf('\Migrations\Db\Literal', $instance); + $this->assertEquals($str, (string)$instance); + } +} diff --git a/tests/TestCase/Db/Table/ColumnTest.php b/tests/TestCase/Db/Table/ColumnTest.php new file mode 100644 index 00000000..7c985984 --- /dev/null +++ b/tests/TestCase/Db/Table/ColumnTest.php @@ -0,0 +1,46 @@ +expectException(RuntimeException::class); + $this->expectExceptionMessage('"0" is not a valid column option.'); + + $column->setOptions(['identity']); + } + + public function testSetOptionsIdentity() + { + $column = new Column(); + $this->assertTrue($column->isNull()); + $this->assertFalse($column->isIdentity()); + + $column->setOptions(['identity' => true]); + $this->assertFalse($column->isNull()); + $this->assertTrue($column->isIdentity()); + } + + /** + * @runInSeparateProcess + */ + public function testColumnNullFeatureFlag() + { + $column = new Column(); + $this->assertTrue($column->isNull()); + + Configure::write('Migrations.column_null_default', false); + $column = new Column(); + $this->assertFalse($column->isNull()); + } +} diff --git a/tests/TestCase/Db/Table/ForeignKeyTest.php b/tests/TestCase/Db/Table/ForeignKeyTest.php new file mode 100644 index 00000000..73828038 --- /dev/null +++ b/tests/TestCase/Db/Table/ForeignKeyTest.php @@ -0,0 +1,99 @@ +fk = new ForeignKey(); + } + + public function testOnDeleteSetNullCanBeSetThroughOptions() + { + $this->assertEquals( + ForeignKey::SET_NULL, + $this->fk->setOptions(['delete' => ForeignKey::SET_NULL])->getOnDelete() + ); + } + + public function testInitiallyActionsEmpty() + { + $this->assertNull($this->fk->getOnDelete()); + $this->assertNull($this->fk->getOnUpdate()); + } + + /** + * @param string $dirtyValue + * @param string $valueOfConstant + * @dataProvider actionsProvider + */ + public function testBothActionsCanBeSetThroughSetters($dirtyValue, $valueOfConstant) + { + $this->fk->setOnDelete($dirtyValue)->setOnUpdate($dirtyValue); + $this->assertEquals($valueOfConstant, $this->fk->getOnDelete()); + $this->assertEquals($valueOfConstant, $this->fk->getOnUpdate()); + } + + /** + * @param string $dirtyValue + * @param string $valueOfConstant + * @dataProvider actionsProvider + */ + public function testBothActionsCanBeSetThroughOptions($dirtyValue, $valueOfConstant) + { + $this->fk->setOptions([ + 'delete' => $dirtyValue, + 'update' => $dirtyValue, + ]); + $this->assertEquals($valueOfConstant, $this->fk->getOnDelete()); + $this->assertEquals($valueOfConstant, $this->fk->getOnUpdate()); + } + + public function testUnknownActionsNotAllowedThroughSetter() + { + $this->expectException(InvalidArgumentException::class); + + $this->fk->setOnDelete('i m dump'); + } + + public function testUnknownActionsNotAllowedThroughOptions() + { + $this->expectException(InvalidArgumentException::class); + + $this->fk->setOptions(['update' => 'no yu a dumb']); + } + + public static function actionsProvider() + { + return [ + [ForeignKey::CASCADE, ForeignKey::CASCADE], + [ForeignKey::RESTRICT, ForeignKey::RESTRICT], + [ForeignKey::NO_ACTION, ForeignKey::NO_ACTION], + [ForeignKey::SET_NULL, ForeignKey::SET_NULL], + ['no Action ', ForeignKey::NO_ACTION], + ['Set nuLL', ForeignKey::SET_NULL], + ['no_Action', ForeignKey::NO_ACTION], + ['Set_nuLL', ForeignKey::SET_NULL], + ]; + } + + public function testSetOptionThrowsExceptionIfOptionIsNotString() + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('"0" is not a valid foreign key option'); + + $this->fk->setOptions(['update']); + } +} diff --git a/tests/TestCase/Db/Table/IndexTest.php b/tests/TestCase/Db/Table/IndexTest.php new file mode 100644 index 00000000..6c9011c2 --- /dev/null +++ b/tests/TestCase/Db/Table/IndexTest.php @@ -0,0 +1,21 @@ +expectException(RuntimeException::class); + $this->expectExceptionMessage('"0" is not a valid index option.'); + + $column->setOptions(['type']); + } +} diff --git a/tests/TestCase/Db/Table/TableTest.php b/tests/TestCase/Db/Table/TableTest.php new file mode 100644 index 00000000..445bc8a0 --- /dev/null +++ b/tests/TestCase/Db/Table/TableTest.php @@ -0,0 +1,462 @@ +setType('badtype'); + $table = new Table('ntable', [], $adapter); + $table->addColumn($column, 'int'); + } catch (InvalidArgumentException $e) { + $this->assertInstanceOf( + 'InvalidArgumentException', + $e, + 'Expected exception of type InvalidArgumentException, got ' . get_class($e) + ); + $this->assertStringStartsWith('An invalid column type ', $e->getMessage()); + } + } + + public function testAddColumnWithColumnObject() + { + $adapter = new MysqlAdapter([]); + $column = new Column(); + $column->setName('email') + ->setType('integer'); + $table = new Table('ntable', [], $adapter); + $table->addColumn($column); + $actions = $this->getPendingActions($table); + $this->assertInstanceOf('Phinx\Db\Action\AddColumn', $actions[0]); + $this->assertSame($column, $actions[0]->getColumn()); + } + + public function testAddColumnWithNoAdapterSpecified() + { + try { + $table = new Table('ntable'); + $table->addColumn('realname', 'string'); + $this->fail('Expected the table object to throw an exception'); + } catch (RuntimeException $e) { + $this->assertInstanceOf( + 'RuntimeException', + $e, + 'Expected exception of type RuntimeException, got ' . get_class($e) + ); + } + } + + public function testAddComment() + { + $adapter = new MysqlAdapter([]); + $table = new Table('ntable', ['comment' => 'test comment'], $adapter); + $options = $table->getOptions(); + $this->assertEquals('test comment', $options['comment']); + } + + public function testAddIndexWithIndexObject() + { + $adapter = new MysqlAdapter([]); + $index = new Index(); + $index->setType(Index::INDEX) + ->setColumns(['email']); + $table = new Table('ntable', [], $adapter); + $table->addIndex($index); + $actions = $this->getPendingActions($table); + $this->assertInstanceOf('Phinx\Db\Action\AddIndex', $actions[0]); + $this->assertSame($index, $actions[0]->getIndex()); + } + + /** + * @dataProvider provideTimestampColumnNames + * @param AdapterInterface $adapter + * @param string|null $createdAtColumnName * @param string|null $updatedAtColumnName * @param string $expectedCreatedAtColumnName * @param string $expectedUpdatedAtColumnName * @param bool $withTimezone + */ + public function testAddTimestamps(AdapterInterface $adapter, $createdAtColumnName, $updatedAtColumnName, $expectedCreatedAtColumnName, $expectedUpdatedAtColumnName, $withTimezone) + { + $table = new Table('ntable', [], $adapter); + $table->addTimestamps($createdAtColumnName, $updatedAtColumnName, $withTimezone); + $actions = $this->getPendingActions($table); + + $columns = []; + + foreach ($actions as $action) { + $columns[] = $action->getColumn(); + } + + $this->assertEquals($expectedCreatedAtColumnName, $columns[0]->getName()); + $this->assertEquals('timestamp', $columns[0]->getType()); + $this->assertEquals('CURRENT_TIMESTAMP', $columns[0]->getDefault()); + $this->assertEquals($withTimezone, $columns[0]->getTimezone()); + $this->assertEquals('', $columns[0]->getUpdate()); + + $this->assertEquals($expectedUpdatedAtColumnName, $columns[1]->getName()); + $this->assertEquals('timestamp', $columns[1]->getType()); + $this->assertEquals($withTimezone, $columns[1]->getTimezone()); + $this->assertEquals('CURRENT_TIMESTAMP', $columns[1]->getUpdate()); + $this->assertTrue($columns[1]->isNull()); + $this->assertNull($columns[1]->getDefault()); + } + + /** + * @dataProvider provideAdapters + * @param AdapterInterface $adapter + */ + public function testAddTimestampsNoUpdated(AdapterInterface $adapter) + { + $table = new Table('ntable', [], $adapter); + $table->addTimestamps(null, false); + $actions = $this->getPendingActions($table); + + $columns = []; + + foreach ($actions as $action) { + $columns[] = $action->getColumn(); + } + + $this->assertCount(1, $columns); + + $this->assertSame('created_at', $columns[0]->getName()); + $this->assertSame('timestamp', $columns[0]->getType()); + $this->assertSame('CURRENT_TIMESTAMP', $columns[0]->getDefault()); + $this->assertFalse($columns[0]->getTimezone()); + $this->assertSame('', $columns[0]->getUpdate()); + } + + /** + * @dataProvider provideAdapters + * @param AdapterInterface $adapter + */ + public function testAddTimestampsNoCreated(AdapterInterface $adapter) + { + $table = new Table('ntable', [], $adapter); + $table->addTimestamps(false, null); + $actions = $this->getPendingActions($table); + + $columns = []; + + foreach ($actions as $action) { + $columns[] = $action->getColumn(); + } + + $this->assertCount(1, $columns); + + $this->assertSame('updated_at', $columns[0]->getName()); + $this->assertSame('timestamp', $columns[0]->getType()); + $this->assertFalse($columns[0]->getTimezone()); + $this->assertSame('CURRENT_TIMESTAMP', $columns[0]->getUpdate()); + $this->assertTrue($columns[0]->isNull()); + $this->assertNull($columns[0]->getDefault()); + } + + /** + * @dataProvider provideAdapters + * @param AdapterInterface $adapter + */ + public function testAddTimestampsThrowsOnBothFalse(AdapterInterface $adapter) + { + $table = new Table('ntable', [], $adapter); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Cannot set both created_at and updated_at columns to false'); + $table->addTimestamps(false, false); + } + + /** + * @dataProvider provideTimestampColumnNames + * @param AdapterInterface $adapter + * @param string|null $createdAtColumnName + * @param string|null $updatedAtColumnName + * @param string $expectedCreatedAtColumnName + * @param string $expectedUpdatedAtColumnName + * @param bool $withTimezone + */ + public function testAddTimestampsWithTimezone(AdapterInterface $adapter, $createdAtColumnName, $updatedAtColumnName, $expectedCreatedAtColumnName, $expectedUpdatedAtColumnName, $withTimezone) + { + $table = new Table('ntable', [], $adapter); + $table->addTimestampsWithTimezone($createdAtColumnName, $updatedAtColumnName); + $actions = $this->getPendingActions($table); + + $columns = []; + + foreach ($actions as $action) { + $columns[] = $action->getColumn(); + } + + $this->assertEquals($expectedCreatedAtColumnName, $columns[0]->getName()); + $this->assertEquals('timestamp', $columns[0]->getType()); + $this->assertEquals('CURRENT_TIMESTAMP', $columns[0]->getDefault()); + $this->assertTrue($columns[0]->getTimezone()); + $this->assertEquals('', $columns[0]->getUpdate()); + + $this->assertEquals($expectedUpdatedAtColumnName, $columns[1]->getName()); + $this->assertEquals('timestamp', $columns[1]->getType()); + $this->assertTrue($columns[1]->getTimezone()); + $this->assertEquals('CURRENT_TIMESTAMP', $columns[1]->getUpdate()); + $this->assertTrue($columns[1]->isNull()); + $this->assertNull($columns[1]->getDefault()); + } + + public function testInsert() + { + $adapterStub = $this->getMockBuilder('\Phinx\Db\Adapter\MysqlAdapter') + ->setConstructorArgs([[]]) + ->getMock(); + $table = new Table('ntable', [], $adapterStub); + $data = [ + 'column1' => 'value1', + 'column2' => 'value2', + ]; + $table->insert($data); + $expectedData = [ + $data, + ]; + $this->assertEquals($expectedData, $table->getData()); + } + + public function testInsertMultipleRowsWithoutZeroKey() + { + $adapterStub = $this->getMockBuilder('\Phinx\Db\Adapter\MysqlAdapter') + ->setConstructorArgs([[]]) + ->getMock(); + $table = new Table('ntable', [], $adapterStub); + $data = [ + 1 => [ + 'column1' => 'value1', + 'column2' => 'value2', + ], + 2 => [ + 'column1' => 'value1', + 'column2' => 'value2', + ], + ]; + $table->insert($data); + $expectedData = array_values($data); + $this->assertEquals($expectedData, $table->getData()); + } + + public function testInsertSaveEmptyData() + { + $adapterStub = $this->getMockBuilder('\Phinx\Db\Adapter\MysqlAdapter') + ->setConstructorArgs([[]]) + ->getMock(); + $table = new Table('ntable', [], $adapterStub); + + $adapterStub->expects($this->never())->method('bulkinsert'); + + $table->insert([])->save(); + } + + public function testInsertSaveData() + { + $adapterStub = $this->getMockBuilder('\Phinx\Db\Adapter\MysqlAdapter') + ->setConstructorArgs([[]]) + ->getMock(); + $table = new Table('ntable', [], $adapterStub); + $data = [ + [ + 'column1' => 'value1', + ], + [ + 'column1' => 'value2', + ], + ]; + + $moreData = [ + [ + 'column1' => 'value3', + ], + [ + 'column1' => 'value4', + ], + ]; + + $adapterStub->expects($this->exactly(1)) + ->method('bulkinsert') + ->with($table->getTable(), [$data[0], $data[1], $moreData[0], $moreData[1]]); + + $table->insert($data) + ->insert($moreData) + ->save(); + } + + public function testSaveAfterSaveData() + { + $adapterStub = $this->getMockBuilder('\Phinx\Db\Adapter\MysqlAdapter') + ->setConstructorArgs([[]]) + ->getMock(); + $table = new Table('ntable', [], $adapterStub); + $data = [ + [ + 'column1' => 'value1', + ], + [ + 'column1' => 'value2', + ], + ]; + + $adapterStub->expects($this->any()) + ->method('isValidColumnType') + ->willReturn(true); + $adapterStub->expects($this->exactly(1)) + ->method('bulkinsert') + ->with($table->getTable(), [$data[0], $data[1]]); + + $table + ->addColumn('column1', 'string', ['null' => true]) + ->save(); + $table + ->insert($data) + ->saveData(); + $table + ->changeColumn('column1', 'string', ['null' => false]) + ->save(); + } + + public function testResetAfterAddingData() + { + $adapterStub = $this->getMockBuilder('\Phinx\Db\Adapter\MysqlAdapter') + ->setConstructorArgs([[]]) + ->getMock(); + $table = new Table('ntable', [], $adapterStub); + $columns = ['column1']; + $data = [['value1']]; + $table->insert($columns, $data)->save(); + $this->assertEquals([], $table->getData()); + } + + public function testPendingAfterAddingData() + { + $adapterStub = $this->getMockBuilder('\Phinx\Db\Adapter\MysqlAdapter') + ->setConstructorArgs([[]]) + ->getMock(); + $table = new Table('ntable', [], $adapterStub); + $columns = ['column1']; + $data = [['value1']]; + $table->insert($columns, $data); + $this->assertTrue($table->hasPendingActions()); + } + + public function testPendingAfterAddingColumn() + { + $adapterStub = $this->getMockBuilder('\Phinx\Db\Adapter\MysqlAdapter') + ->setConstructorArgs([[]]) + ->getMock(); + $adapterStub->expects($this->any()) + ->method('isValidColumnType') + ->willReturn(true); + $table = new Table('ntable', [], $adapterStub); + $table->addColumn('column1', 'integer', ['null' => true]); + $this->assertTrue($table->hasPendingActions()); + } + + public function testGetColumn() + { + $adapterStub = $this->getMockBuilder('\Phinx\Db\Adapter\MysqlAdapter') + ->setConstructorArgs([[]]) + ->getMock(); + + $column1 = (new Column())->setName('column1'); + + $adapterStub->expects($this->exactly(2)) + ->method('getColumns') + ->willReturn([ + $column1, + ]); + + $table = new Table('ntable', [], $adapterStub); + + $this->assertEquals($column1, $table->getColumn('column1')); + $this->assertNull($table->getColumn('column2')); + } + + /** + * @dataProvider removeIndexDataprovider + * @param string $indexIdentifier + * @param Index $index + */ + public function testRemoveIndex($indexIdentifier, Index $index) + { + $adapterStub = $this->getMockBuilder('\Phinx\Db\Adapter\MysqlAdapter') + ->setConstructorArgs([[]]) + ->getMock(); + + $table = new Table('table', [], $adapterStub); + $table->removeIndex($indexIdentifier); + + $indexes = array_map(function (DropIndex $action) { + return $action->getIndex(); + }, $this->getPendingActions($table)); + + $this->assertEquals([$index], $indexes); + } + + public static function removeIndexDataprovider() + { + return [ + [ + 'indexA', + (new Index())->setColumns(['indexA']), + ], + [ + ['indexB', 'indexC'], + (new Index())->setColumns(['indexB', 'indexC']), + ], + [ + ['indexD'], + (new Index())->setColumns(['indexD']), + ], + ]; + } + + protected function getPendingActions($table) + { + $prop = new ReflectionProperty(get_class($table), 'actions'); + $prop->setAccessible(true); + + return $prop->getValue($table)->getActions(); + } +} diff --git a/tests/TestCase/Migration/EnvironmentTest.php b/tests/TestCase/Migration/EnvironmentTest.php new file mode 100644 index 00000000..13e040d5 --- /dev/null +++ b/tests/TestCase/Migration/EnvironmentTest.php @@ -0,0 +1,310 @@ +environment = new Environment('test', []); + } + + public function testConstructorWorksAsExpected() + { + $env = new Environment('testenv', ['foo' => 'bar']); + $this->assertEquals('testenv', $env->getName()); + $this->assertArrayHasKey('foo', $env->getOptions()); + } + + public function testSettingTheName() + { + $this->environment->setName('prod123'); + $this->assertEquals('prod123', $this->environment->getName()); + } + + public function testSettingOptions() + { + $this->environment->setOptions(['foo' => 'bar']); + $this->assertArrayHasKey('foo', $this->environment->getOptions()); + } + + public function testInvalidAdapter() + { + $this->environment->setOptions(['adapter' => 'fakeadapter']); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('No connection defined'); + + $this->environment->getAdapter(); + } + + public function testNoAdapter() + { + $this->expectException(RuntimeException::class); + + $this->environment->getAdapter(); + } + + public function testGetAdapterWithBadConnectionName() + { + $this->environment->setOptions(['connection' => 'lolnope']); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('The datasource configuration `lolnope` was not found'); + + $this->environment->getAdapter(); + } + + public function testGetAdapter() + { + /** @var array $config */ + $config = ConnectionManager::getConfig('test'); + $environment = new Environment('default', [ + 'connection' => 'test', + 'database' => $config['database'], + 'migration_table' => 'phinxlog', + ]); + $adapter = $environment->getAdapter(); + $this->assertNotEmpty($adapter); + $this->assertInstanceOf(AdapterWrapper::class, $adapter); + } + + public function testSchemaName() + { + $this->assertEquals('phinxlog', $this->environment->getSchemaTableName()); + + $this->environment->setSchemaTableName('changelog'); + $this->assertEquals('changelog', $this->environment->getSchemaTableName()); + } + + public function testCurrentVersion() + { + $stub = $this->getMockBuilder(PdoAdapter::class) + ->setConstructorArgs([[]]) + ->getMock(); + $stub->expects($this->any()) + ->method('getVersions') + ->will($this->returnValue([20110301080000])); + + $this->environment->setAdapter($stub); + + $this->assertEquals(20110301080000, $this->environment->getCurrentVersion()); + } + + public function testExecutingAMigrationUp() + { + // stub adapter + $adapterStub = $this->getMockBuilder(PdoAdapter::class) + ->setConstructorArgs([[]]) + ->getMock(); + $adapterStub->expects($this->once()) + ->method('migrated') + ->willReturn($adapterStub); + + $this->environment->setAdapter($adapterStub); + + // up + $upMigration = $this->getMockBuilder(AbstractMigration::class) + ->setConstructorArgs(['mockenv', '20110301080000']) + ->addMethods(['up']) + ->getMock(); + $upMigration->expects($this->once()) + ->method('up'); + + $this->environment->executeMigration($upMigration, MigrationInterface::UP); + } + + public function testExecutingAMigrationDown() + { + // stub adapter + $adapterStub = $this->getMockBuilder(PdoAdapter::class) + ->setConstructorArgs([[]]) + ->getMock(); + $adapterStub->expects($this->once()) + ->method('migrated') + ->willReturn($adapterStub); + + $this->environment->setAdapter($adapterStub); + + // down + $downMigration = $this->getMockBuilder(AbstractMigration::class) + ->setConstructorArgs(['mockenv', '20110301080000']) + ->addMethods(['down']) + ->getMock(); + $downMigration->expects($this->once()) + ->method('down'); + + $this->environment->executeMigration($downMigration, MigrationInterface::DOWN); + } + + public function testExecutingAMigrationWithTransactions() + { + // stub adapter + $adapterStub = $this->getMockBuilder(PdoAdapter::class) + ->setConstructorArgs([[]]) + ->getMock(); + $adapterStub->expects($this->once()) + ->method('beginTransaction'); + + $adapterStub->expects($this->once()) + ->method('commitTransaction'); + + $adapterStub->expects($this->exactly(2)) + ->method('hasTransactions') + ->will($this->returnValue(true)); + + $this->environment->setAdapter($adapterStub); + + // migrate + $migration = $this->getMockBuilder(AbstractMigration::class) + ->setConstructorArgs(['mockenv', '20110301080000']) + ->addMethods(['up']) + ->getMock(); + $migration->expects($this->once()) + ->method('up'); + + $this->environment->executeMigration($migration, MigrationInterface::UP); + } + + public function testExecutingAChangeMigrationUp() + { + // stub adapter + $adapterStub = $this->getMockBuilder(PdoAdapter::class) + ->setConstructorArgs([[]]) + ->getMock(); + $adapterStub->expects($this->once()) + ->method('migrated') + ->willReturn($adapterStub); + + $this->environment->setAdapter($adapterStub); + + // migration + $migration = $this->getMockBuilder(AbstractMigration::class) + ->setConstructorArgs(['mockenv', '20130301080000']) + ->addMethods(['change']) + ->getMock(); + $migration->expects($this->once()) + ->method('change'); + + $this->environment->executeMigration($migration, MigrationInterface::UP); + } + + public function testExecutingAChangeMigrationDown() + { + // stub adapter + $adapterStub = $this->getMockBuilder(PdoAdapter::class) + ->setConstructorArgs([[]]) + ->getMock(); + $adapterStub->expects($this->once()) + ->method('migrated') + ->willReturn($adapterStub); + + $this->environment->setAdapter($adapterStub); + + // migration + $migration = $this->getMockBuilder(AbstractMigration::class) + ->setConstructorArgs(['mockenv', '20130301080000']) + ->addMethods(['change']) + ->getMock(); + $migration->expects($this->once()) + ->method('change'); + + $this->environment->executeMigration($migration, MigrationInterface::DOWN); + } + + public function testExecutingAFakeMigration() + { + // stub adapter + $adapterStub = $this->getMockBuilder(PdoAdapter::class) + ->setConstructorArgs([[]]) + ->getMock(); + $adapterStub->expects($this->once()) + ->method('migrated') + ->willReturn($adapterStub); + + $this->environment->setAdapter($adapterStub); + + // migration + $migration = $this->getMockBuilder(AbstractMigration::class) + ->setConstructorArgs(['mockenv', '20130301080000']) + ->addMethods(['change']) + ->getMock(); + $migration->expects($this->never()) + ->method('change'); + + $this->environment->executeMigration($migration, MigrationInterface::UP, true); + } + + public function testGettingInputObject() + { + $mock = $this->getMockBuilder(ConsoleIo::class)->getMock(); + $this->environment->setIo($mock); + $inputObject = $this->environment->getIo(); + $this->assertInstanceOf(ConsoleIo::class, $inputObject); + } + + public function testExecuteMigrationCallsInit() + { + // stub adapter + $adapterStub = $this->getMockBuilder(PdoAdapter::class) + ->setConstructorArgs([[]]) + ->getMock(); + $adapterStub->expects($this->once()) + ->method('migrated') + ->willReturn($adapterStub); + + $this->environment->setAdapter($adapterStub); + + // up + $upMigration = $this->getMockBuilder(AbstractMigration::class) + ->setConstructorArgs(['mockenv', '20110301080000']) + ->addMethods(['up', 'init']) + ->getMock(); + $upMigration->expects($this->once()) + ->method('up'); + $upMigration->expects($this->once()) + ->method('init'); + + $this->environment->executeMigration($upMigration, MigrationInterface::UP); + } + + public function testExecuteSeedInit() + { + // stub adapter + $adapterStub = $this->getMockBuilder(PdoAdapter::class) + ->setConstructorArgs([[]]) + ->getMock(); + + $this->environment->setAdapter($adapterStub); + + // up + $seed = $this->getMockBuilder(AbstractSeed::class) + ->addMethods(['init']) + ->onlyMethods(['run']) + ->getMock(); + + $seed->expects($this->once()) + ->method('run'); + $seed->expects($this->once()) + ->method('init'); + + $this->environment->executeSeed($seed); + } +} diff --git a/tests/TestCase/Migration/ManagerTest.php b/tests/TestCase/Migration/ManagerTest.php new file mode 100644 index 00000000..aca9fc0c --- /dev/null +++ b/tests/TestCase/Migration/ManagerTest.php @@ -0,0 +1,2715 @@ +config = new Config($this->getConfigArray()); + + $this->out = new StubConsoleOutput(); + $this->out->setOutputAs(StubConsoleOutput::PLAIN); + + $this->io = new ConsoleIo($this->out, $this->out); + $this->manager = new Manager($this->config, $this->io); + } + + protected static function getDriverType(): string + { + $config = ConnectionManager::getConfig('test'); + if (!$config) { + throw new RuntimeException('Cannot read configuration for test connection'); + } + + return $config['scheme']; + } + + protected function getOutput(): string + { + $lines = $this->out->messages(); + $lines = array_map(fn ($line) => strip_tags($line), $lines); + + return join("\n", $lines); + } + + protected function tearDown(): void + { + $this->manager = null; + } + + private static function getCorrectedPath(string $path): string + { + return str_replace('/', DIRECTORY_SEPARATOR, $path); + } + + /** + * Returns a sample configuration array for use with the unit tests. + * + * @return array + */ + public static function getConfigArray(): array + { + /** @var array $dbConfig */ + $dbConfig = ConnectionManager::getConfig('test'); + $config = [ + 'connection' => 'test', + 'database' => $dbConfig['database'], + 'migration_table' => 'phinxlog', + ]; + + return [ + 'paths' => [ + 'migrations' => ROOT . '/config/ManagerMigrations', + 'seeds' => ROOT . '/config/ManagerSeeds', + ], + 'environment' => $config, + ]; + } + + protected function getConfigWithPlugin(array $paths = []): array + { + $paths = [ + 'migrations' => ROOT . 'Plugin/Manager/config/Migrations', + 'seeds' => ROOT . 'Plugin/Manager/config/Seeds', + ]; + $config = clone $this->config; + $config['paths'] = $paths; + + return $config; + } + + /** + * Prepares an environment for cross DBMS functional tests. + * + * @param array $paths The paths config to override. + * @return \Migrations\Db\Adapter\AdapterInterface + */ + protected function prepareEnvironment(array $paths = []): AdapterInterface + { + $configArray = $this->getConfigArray(); + + // override paths as needed + if ($paths) { + $configArray['paths'] = $paths + $configArray['paths']; + } + // Emulate the results of Util::parseDsn() + $connectionConfig = ConnectionManager::getConfig('test'); + $adapter = $connectionConfig['scheme'] ?? null; + $adapterConfig = [ + 'connection' => 'test', + 'adapter' => $adapter, + 'user' => $connectionConfig['username'], + 'pass' => $connectionConfig['password'], + 'host' => $connectionConfig['host'], + 'name' => $connectionConfig['database'], + ]; + + $configArray['environment'] = $adapterConfig; + $this->manager->setConfig(new Config($configArray)); + + $adapter = $this->manager->getEnvironment()->getAdapter(); + + // ensure the database is empty + if ($adapterConfig['adapter'] === 'postgres') { + $adapter->dropSchema('public'); + $adapter->createSchema('public'); + } elseif ($adapterConfig['name'] !== ':memory:') { + $adapter->dropDatabase($adapterConfig['name']); + $adapter->createDatabase($adapterConfig['name']); + } + $adapter->disconnect(); + + return $adapter; + } + + public function testPrintStatusMethod(): void + { + // stub environment + $envStub = $this->getMockBuilder(Environment::class) + ->setConstructorArgs(['mockenv', []]) + ->getMock(); + $envStub->expects($this->once()) + ->method('getVersionLog') + ->will($this->returnValue( + [ + '20120111235330' => + [ + 'version' => '20120111235330', + 'start_time' => '2012-01-11 23:53:36', + 'end_time' => '2012-01-11 23:53:37', + 'migration_name' => '', + 'breakpoint' => '0', + ], + '20120116183504' => + [ + 'version' => '20120116183504', + 'start_time' => '2012-01-16 18:35:40', + 'end_time' => '2012-01-16 18:35:41', + 'migration_name' => '', + 'breakpoint' => '0', + ], + ] + )); + + $this->manager->setEnvironment($envStub); + $return = $this->manager->printStatus(); + $expected = [ + [ + 'status' => 'up', + 'id' => 20120111235330, + 'name' => 'TestMigration', + ], + [ + 'status' => 'up', + 'id' => 20120116183504, + 'name' => 'TestMigration2', + ], + ]; + $this->assertEquals($expected, $return); + } + + public function testPrintStatusMethodJsonFormat(): void + { + // stub environment + $envStub = $this->getMockBuilder(Environment::class) + ->setConstructorArgs(['mockenv', []]) + ->getMock(); + $envStub->expects($this->once()) + ->method('getVersionLog') + ->will($this->returnValue( + [ + '20120111235330' => + [ + 'version' => '20120111235330', + 'start_time' => '2012-01-11 23:53:36', + 'end_time' => '2012-01-11 23:53:37', + 'migration_name' => '', + 'breakpoint' => '0', + ], + '20120116183504' => + [ + 'version' => '20120116183504', + 'start_time' => '2012-01-16 18:35:40', + 'end_time' => '2012-01-16 18:35:41', + 'migration_name' => '', + 'breakpoint' => '0', + ], + ] + )); + $this->manager->setEnvironment($envStub); + $return = $this->manager->printStatus(AbstractCommand::FORMAT_JSON); + $expected = [ + [ + 'status' => 'up', + 'id' => 20120111235330, + 'name' => 'TestMigration', + ], + [ + 'status' => 'up', + 'id' => 20120116183504, + 'name' => 'TestMigration2', + ], + ]; + $this->assertSame($expected, $return); + } + + public function testPrintStatusMethodWithBreakpointSet(): void + { + // stub environment + $envStub = $this->getMockBuilder(Environment::class) + ->setConstructorArgs(['mockenv', []]) + ->getMock(); + $envStub->expects($this->once()) + ->method('getVersionLog') + ->will($this->returnValue( + [ + '20120111235330' => + [ + 'version' => '20120111235330', + 'start_time' => '2012-01-11 23:53:36', + 'end_time' => '2012-01-11 23:53:37', + 'migration_name' => '', + 'breakpoint' => '1', + ], + '20120116183504' => + [ + 'version' => '20120116183504', + 'start_time' => '2012-01-16 18:35:40', + 'end_time' => '2012-01-16 18:35:41', + 'migration_name' => '', + 'breakpoint' => '0', + ], + ] + )); + + $this->manager->setEnvironment($envStub); + $return = $this->manager->printStatus(); + $expected = [ + [ + 'status' => 'up', + 'id' => 20120111235330, + 'name' => 'TestMigration', + ], + [ + 'status' => 'up', + 'id' => 20120116183504, + 'name' => 'TestMigration2', + ], + ]; + $this->assertEquals($expected, $return); + } + + public function testPrintStatusMethodWithNoMigrations() + { + // stub environment + $envStub = $this->getMockBuilder(Environment::class) + ->setConstructorArgs(['mockenv', []]) + ->getMock(); + + // override the migrations directory to an empty one + $configArray = $this->getConfigArray(); + $configArray['paths']['migrations'] = ROOT . '/config/Nomigrations'; + $config = new Config($configArray); + + $this->manager->setConfig($config); + $this->manager->setEnvironment($envStub); + + $return = $this->manager->printStatus(); + $this->assertEquals([], $return); + } + + public function testPrintStatusMethodWithMissingMigrations() + { + // stub environment + $envStub = $this->getMockBuilder(Environment::class) + ->setConstructorArgs(['mockenv', []]) + ->getMock(); + $envStub->expects($this->once()) + ->method('getVersionLog') + ->will($this->returnValue( + [ + '20120103083300' => + [ + 'version' => '20120103083300', + 'start_time' => '2012-01-11 23:53:36', + 'end_time' => '2012-01-11 23:53:37', + 'migration_name' => '', + 'breakpoint' => '0', + ], + '20120815145812' => + [ + 'version' => '20120815145812', + 'start_time' => '2012-01-16 18:35:40', + 'end_time' => '2012-01-16 18:35:41', + 'migration_name' => 'Example', + 'breakpoint' => '0', + ], + ] + )); + + $this->manager->setEnvironment($envStub); + + $return = $this->manager->printStatus(); + $expected = [ + [ + 'missing' => true, + 'status' => 'up', + 'id' => '20120103083300', + 'name' => '', + ], + [ + 'status' => 'down', + 'id' => 20120111235330, + 'name' => 'TestMigration', + ], + [ + 'status' => 'down', + 'id' => 20120116183504, + 'name' => 'TestMigration2', + ], + [ + 'missing' => true, + 'status' => 'up', + 'id' => '20120815145812', + 'name' => 'Example', + ], + ]; + $this->assertEquals($expected, $return); + } + + public function testPrintStatusMethodWithMissingLastMigration() + { + // stub environment + $envStub = $this->getMockBuilder(Environment::class) + ->setConstructorArgs(['mockenv', []]) + ->getMock(); + $envStub->expects($this->once()) + ->method('getVersionLog') + ->will($this->returnValue( + [ + '20120111235330' => + [ + 'version' => '20120111235330', + 'start_time' => '2012-01-16 18:35:40', + 'end_time' => '2012-01-16 18:35:41', + 'migration_name' => '', + 'breakpoint' => 0, + ], + '20120116183504' => + [ + 'version' => '20120116183504', + 'start_time' => '2012-01-16 18:35:40', + 'end_time' => '2012-01-16 18:35:41', + 'migration_name' => '', + 'breakpoint' => '0', + ], + '20120120145114' => + [ + 'version' => '20120120145114', + 'start_time' => '2012-01-20 14:51:14', + 'end_time' => '2012-01-20 14:51:14', + 'migration_name' => 'Example', + 'breakpoint' => '0', + ], + ] + )); + + $this->manager->setEnvironment($envStub); + + $return = $this->manager->printStatus(); + $expected = [ + [ + 'status' => 'up', + 'id' => 20120111235330, + 'name' => 'TestMigration', + ], + [ + 'status' => 'up', + 'id' => 20120116183504, + 'name' => 'TestMigration2', + ], + [ + 'missing' => true, + 'status' => 'up', + 'id' => '20120120145114', + 'name' => 'Example', + ], + ]; + $this->assertEquals($expected, $return); + } + + public function testPrintStatusMethodWithMissingMigrationsAndBreakpointSet() + { + // stub environment + $envStub = $this->getMockBuilder(Environment::class) + ->setConstructorArgs(['mockenv', []]) + ->getMock(); + $envStub->expects($this->once()) + ->method('getVersionLog') + ->will($this->returnValue( + [ + '20120103083300' => + [ + 'version' => '20120103083300', + 'start_time' => '2012-01-11 23:53:36', + 'end_time' => '2012-01-11 23:53:37', + 'migration_name' => '', + 'breakpoint' => '1', + ], + '20120815145812' => + [ + 'version' => '20120815145812', + 'start_time' => '2012-01-16 18:35:40', + 'end_time' => '2012-01-16 18:35:41', + 'migration_name' => 'Example', + 'breakpoint' => '0', + ], + ] + )); + + $this->manager->setEnvironment($envStub); + $return = $this->manager->printStatus(); + + $expected = [ + [ + 'missing' => true, + 'status' => 'up', + 'id' => '20120103083300', + 'name' => '', + ], + [ + 'status' => 'down', + 'id' => 20120111235330, + 'name' => 'TestMigration', + ], + [ + 'status' => 'down', + 'id' => 20120116183504, + 'name' => 'TestMigration2', + ], + [ + 'missing' => true, + 'status' => 'up', + 'id' => '20120815145812', + 'name' => 'Example', + ], + ]; + $this->assertEquals($expected, $return); + } + + public function testPrintStatusMethodWithDownMigrations() + { + // stub environment + $envStub = $this->getMockBuilder(Environment::class) + ->setConstructorArgs(['mockenv', []]) + ->getMock(); + $envStub->expects($this->once()) + ->method('getVersionLog') + ->will($this->returnValue([ + '20120111235330' => [ + 'version' => '20120111235330', + 'start_time' => '2012-01-16 18:35:40', + 'end_time' => '2012-01-16 18:35:41', + 'migration_name' => '', + 'breakpoint' => 0, + ]])); + + $this->manager->setEnvironment($envStub); + + $return = $this->manager->printStatus(); + $expected = [ + [ + 'status' => 'up', + 'id' => 20120111235330, + 'name' => 'TestMigration', + ], + [ + 'status' => 'down', + 'id' => 20120116183504, + 'name' => 'TestMigration2', + ], + ]; + $this->assertEquals($expected, $return); + } + + public function testPrintStatusMethodWithMissingAndDownMigrations() + { + // stub environment + $envStub = $this->getMockBuilder(Environment::class) + ->setConstructorArgs(['mockenv', []]) + ->getMock(); + $envStub->expects($this->once()) + ->method('getVersionLog') + ->will($this->returnValue([ + '20120111235330' => + [ + 'version' => '20120111235330', + 'start_time' => '2012-01-16 18:35:40', + 'end_time' => '2012-01-16 18:35:41', + 'migration_name' => '', + 'breakpoint' => 0, + ], + '20120103083300' => + [ + 'version' => '20120103083300', + 'start_time' => '2012-01-11 23:53:36', + 'end_time' => '2012-01-11 23:53:37', + 'migration_name' => '', + 'breakpoint' => 0, + ], + '20120815145812' => + [ + 'version' => '20120815145812', + 'start_time' => '2012-01-16 18:35:40', + 'end_time' => '2012-01-16 18:35:41', + 'migration_name' => 'Example', + 'breakpoint' => 0, + ]])); + + $this->manager->setEnvironment($envStub); + + $return = $this->manager->printStatus(); + $expected = [ + [ + 'missing' => true, + 'status' => 'up', + 'id' => '20120103083300', + 'name' => '', + ], + [ + 'status' => 'up', + 'id' => 20120111235330, + 'name' => 'TestMigration', + ], + [ + 'status' => 'down', + 'id' => 20120116183504, + 'name' => 'TestMigration2', + ], + [ + 'missing' => true, + 'status' => 'up', + 'id' => '20120815145812', + 'name' => 'Example', + ], + ]; + $this->assertEquals($expected, $return); + } + + public function testGetMigrationsWithDuplicateMigrationVersions() + { + $config = new Config(['paths' => ['migrations' => ROOT . '/config/Duplicateversions']]); + $manager = new Manager($config, $this->io); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/Duplicate migration/'); + $this->expectExceptionMessageMatches('/20120111235330_duplicate_migration(_2)?.php" has the same version as "20120111235330"/'); + $manager->getMigrations(); + } + + public function testGetMigrationsWithDuplicateMigrationNames() + { + $config = new Config(['paths' => ['migrations' => ROOT . '/config/Duplicatenames']]); + $manager = new Manager($config, $this->io); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Migration "20120111235331_duplicate_migration_name.php" has the same name as "20120111235330_duplicate_migration_name.php"'); + + $manager->getMigrations(); + } + + public function testGetMigrationsWithInvalidMigrationClassName() + { + $config = new Config(['paths' => ['migrations' => ROOT . '/config/Invalidclassname']]); + $manager = new Manager($config, $this->io); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/Could not find class "InvalidClass" in file/'); + $this->expectExceptionMessageMatches('/20120111235330_invalid_class.php/'); + + $manager->getMigrations(); + } + + public function testGettingAValidEnvironment() + { + $this->assertInstanceOf( + Environment::class, + $this->manager->getEnvironment() + ); + } + + /** + * Test that migrating by date chooses the correct + * migration to point to. + * + * @dataProvider migrateDateDataProvider + * @param string[] $availableMigrations + * @param string $dateString + * @param string $expectedMigration + * @param string $message + */ + public function testMigrationsByDate(array $availableMigrations, $dateString, $expectedMigration, $message) + { + // stub environment + $envStub = $this->getMockBuilder(Environment::class) + ->setConstructorArgs(['mockenv', []]) + ->getMock(); + if (is_null($expectedMigration)) { + $envStub->expects($this->never()) + ->method('getVersions'); + } else { + $envStub->expects($this->once()) + ->method('getVersions') + ->will($this->returnValue($availableMigrations)); + } + $this->manager->setEnvironment($envStub); + $this->manager->migrateToDateTime(new DateTime($dateString)); + + $output = $this->getOutput(); + if (is_null($expectedMigration)) { + $this->assertEmpty($output, $message); + } else { + $this->assertStringContainsString($expectedMigration, $output, $message); + } + } + + /** + * Test that rollbacking to version chooses the correct + * migration to point to. + * + * @dataProvider rollbackToVersionDataProvider + */ + public function testRollbackToVersion($availableRollbacks, $version, $expectedOutput) + { + // stub environment + $envStub = $this->getMockBuilder(Environment::class) + ->setConstructorArgs(['mockenv', []]) + ->getMock(); + $envStub->expects($this->any()) + ->method('getVersionLog') + ->will($this->returnValue($availableRollbacks)); + + $this->manager->setEnvironment($envStub); + $this->manager->rollback($version); + $output = $this->getOutput(); + if (is_null($expectedOutput)) { + $output = explode("\n", $output); + $this->assertEquals('No migrations to rollback', array_pop($output)); + } else { + if (is_string($expectedOutput)) { + $expectedOutput = [$expectedOutput]; + } + + foreach ($expectedOutput as $expectedLine) { + $this->assertStringContainsString($expectedLine, $output); + } + } + } + + /** + * Test that rollbacking to date chooses the correct + * migration to point to. + * + * @dataProvider rollbackToDateDataProvider + */ + public function testRollbackToDate($availableRollbacks, $version, $expectedOutput) + { + // stub environment + $envStub = $this->getMockBuilder(Environment::class) + ->setConstructorArgs(['mockenv', []]) + ->getMock(); + $envStub->expects($this->any()) + ->method('getVersionLog') + ->will($this->returnValue($availableRollbacks)); + + $this->manager->setEnvironment($envStub); + $this->manager->rollback($version, false, false); + $output = $this->getOutput(); + if (is_null($expectedOutput)) { + $output = explode("\n", $output); + $this->assertEquals('No migrations to rollback', array_pop($output)); + } else { + if (is_string($expectedOutput)) { + $expectedOutput = [$expectedOutput]; + } + + foreach ($expectedOutput as $expectedLine) { + $this->assertStringContainsString($expectedLine, $output); + } + } + } + + /** + * Test that rollbacking to version by execution time chooses the correct + * migration to point to. + * + * @dataProvider rollbackToVersionByExecutionTimeDataProvider + */ + public function testRollbackToVersionByExecutionTime($availableRollbacks, $version, $expectedOutput) + { + // stub environment + $envStub = $this->getMockBuilder(Environment::class) + ->setConstructorArgs(['mockenv', []]) + ->getMock(); + $envStub->expects($this->any()) + ->method('getVersionLog') + ->will($this->returnValue($availableRollbacks)); + + // get a manager with a config whose version order is set to execution time + $configArray = $this->getConfigArray(); + $configArray['version_order'] = Config::VERSION_ORDER_EXECUTION_TIME; + $config = new Config($configArray); + + $this->manager = new Manager($config, $this->io); + $this->manager->setEnvironment($envStub); + $this->manager->rollback($version); + $output = $this->getOutput(); + + if (is_null($expectedOutput)) { + $this->assertEmpty($output); + } else { + if (is_string($expectedOutput)) { + $expectedOutput = [$expectedOutput]; + } + + foreach ($expectedOutput as $expectedLine) { + $this->assertStringContainsString($expectedLine, $output); + } + } + } + + /** + * Test that rollbacking to version by migration name chooses the correct + * migration to point to. + * + * @dataProvider rollbackToVersionByExecutionTimeDataProvider + */ + public function testRollbackToVersionByName($availableRollbacks, $version, $expectedOutput) + { + // stub environment + $envStub = $this->getMockBuilder(Environment::class) + ->setConstructorArgs(['mockenv', []]) + ->getMock(); + $envStub->expects($this->any()) + ->method('getVersionLog') + ->will($this->returnValue($availableRollbacks)); + + // get a manager with a config whose version order is set to execution time + $configArray = $this->getConfigArray(); + $configArray['version_order'] = Config::VERSION_ORDER_EXECUTION_TIME; + $config = new Config($configArray); + + $this->manager = new Manager($config, $this->io); + $this->manager->setEnvironment($envStub); + $this->manager->rollback($availableRollbacks[$version]['migration_name'] ?? $version); + $output = $this->getOutput(); + + if (is_null($expectedOutput)) { + $this->assertEmpty($output); + } else { + if (is_string($expectedOutput)) { + $expectedOutput = [$expectedOutput]; + } + + foreach ($expectedOutput as $expectedLine) { + $this->assertStringContainsString($expectedLine, $output); + } + } + } + + /** + * Test that rollbacking to date by execution time chooses the correct + * migration to point to. + * + * @dataProvider rollbackToDateByExecutionTimeDataProvider + */ + public function testRollbackToDateByExecutionTime($availableRollbacks, $date, $expectedOutput) + { + // stub environment + $envStub = $this->getMockBuilder(Environment::class) + ->setConstructorArgs(['mockenv', []]) + ->getMock(); + $envStub->expects($this->any()) + ->method('getVersionLog') + ->will($this->returnValue($availableRollbacks)); + + // get a manager with a config whose version order is set to execution time + $configArray = $this->getConfigArray(); + $configArray['version_order'] = Config::VERSION_ORDER_EXECUTION_TIME; + $config = new Config($configArray); + + $this->manager = new Manager($config, $this->io); + $this->manager->setEnvironment($envStub); + $this->manager->rollback($date, false, false); + $output = $this->getOutput(); + + if (is_null($expectedOutput)) { + $output = explode("\n", $output); + $this->assertEquals('No migrations to rollback', array_pop($output)); + } else { + if (is_string($expectedOutput)) { + $expectedOutput = [$expectedOutput]; + } + + foreach ($expectedOutput as $expectedLine) { + $this->assertStringContainsString($expectedLine, $output); + } + } + } + + public function testRollbackToVersionWithSingleMigrationDoesNotFail(): void + { + // stub environment + $envStub = $this->getMockBuilder(Environment::class) + ->setConstructorArgs(['mockenv', []]) + ->getMock(); + $envStub->expects($this->any()) + ->method('getVersionLog') + ->will($this->returnValue([ + '20120111235330' => ['version' => '20120111235330', 'migration' => '', 'breakpoint' => 0], + ])); + $envStub->expects($this->any()) + ->method('getVersions') + ->will($this->returnValue([20120111235330])); + + $this->manager->setEnvironment($envStub); + $this->manager->rollback(); + $output = $this->getOutput(); + + $this->assertStringContainsString('== 20120111235330 TestMigration: reverting', $output); + $this->assertStringContainsString('== 20120111235330 TestMigration: reverted', $output); + $this->assertStringNotContainsString('No migrations to rollback', $output); + $this->assertStringNotContainsString('Undefined offset: -1', $output); + } + + public function testRollbackToVersionWithTwoMigrations(): void + { + // stub environment + $envStub = $this->getMockBuilder(Environment::class) + ->setConstructorArgs(['mockenv', []]) + ->getMock(); + $envStub->expects($this->any()) + ->method('getVersionLog') + ->will( + $this->returnValue( + [ + '20120111235330' => ['version' => '20120111235330', 'migration' => '', 'breakpoint' => 0], + '20120116183504' => ['version' => '20120815145812', 'migration' => '', 'breakpoint' => 0], + ] + ) + ); + $envStub->expects($this->any()) + ->method('getVersions') + ->will( + $this->returnValue( + [ + 20120111235330, + 20120116183504, + ] + ) + ); + + $this->manager->setEnvironment($envStub); + $this->manager->rollback(); + $output = $this->getOutput(); + + $this->assertStringNotContainsString('== 20120111235330 TestMigration: reverting', $output); + } + + /** + * Test that rollbacking last migration + * + * @dataProvider rollbackLastDataProvider + */ + public function testRollbackLast(array $availableRolbacks, string $versionOrder, string $expectedOutput): void + { + // stub environment + $envStub = $this->getMockBuilder(Environment::class) + ->setConstructorArgs(['mockenv', []]) + ->getMock(); + $envStub->expects($this->any()) + ->method('getVersionLog') + ->will($this->returnValue($availableRolbacks)); + + // get a manager with a config whose version order is set to execution time + $configArray = $this->getConfigArray(); + $configArray['version_order'] = $versionOrder; + $config = new Config($configArray); + + $this->manager = new Manager($config, $this->io); + $this->manager->setEnvironment($envStub); + $this->manager->rollback(null); + $output = $this->getOutput(); + if (is_null($expectedOutput)) { + $this->assertEquals('No migrations to rollback' . PHP_EOL, $output); + } else { + if (is_string($expectedOutput)) { + $expectedOutput = [$expectedOutput]; + } + + foreach ($expectedOutput as $expectedLine) { + $this->assertStringContainsString($expectedLine, $output); + } + } + } + + /** + * Migration lists, dates, and expected migrations to point to. + * + * @return array + */ + public static function migrateDateDataProvider(): array + { + return [ + [['20120111235330', '20120116183504'], '20120118', '20120116183504', 'Failed to migrate all migrations when migrate to date is later than all the migrations'], + [['20120111235330', '20120116183504'], '20120115', '20120111235330', 'Failed to migrate 1 migration when the migrate to date is between 2 migrations'], + ]; + } + + /** + * Migration lists, dates, and expected migration version to rollback to. + * + * @return array + */ + public static function rollbackToDateDataProvider(): array + { + return [ + + // No breakpoints set + + 'Rollback to date which is later than all migrations - no breakpoints set' => + [ + [ + '20120111235330' => ['version' => '20120111235330', 'migration_name' => '', 'breakpoint' => 0], + '20120116183504' => ['version' => '20120116183504', 'migration_name' => '', 'breakpoint' => 0], + ], + '20130118000000', + null, + ], + 'Rollback to date of the most recent migration - no breakpoints set' => + [ + [ + '20120111235330' => ['version' => '20120111235330', 'migration_name' => '', 'breakpoint' => 0], + '20120116183504' => ['version' => '20120116183504', 'migration_name' => '', 'breakpoint' => 0], + ], + '20120116183504', + null, + ], + 'Rollback to date between 2 migrations - no breakpoints set' => + [ + [ + '20120111235330' => ['version' => '20120111235330', 'migration_name' => '', 'breakpoint' => 0], + '20120116183504' => ['version' => '20120116183504', 'migration_name' => '', 'breakpoint' => 0], + ], + '20120115', + '== 20120116183504 TestMigration2: reverted', + ], + 'Rollback to date of the oldest migration - no breakpoints set' => + [ + [ + '20120111235330' => ['version' => '20120111235330', 'migration_name' => '', 'breakpoint' => 0], + '20120116183504' => ['version' => '20120116183504', 'migration_name' => '', 'breakpoint' => 0], + ], + '20120111235330', + '== 20120116183504 TestMigration2: reverted', + ], + 'Rollback to date before all the migrations - no breakpoints set' => + [ + [ + '20120111235330' => ['version' => '20120111235330', 'migration_name' => '', 'breakpoint' => 0], + '20120116183504' => ['version' => '20120116183504', 'migration_name' => '', 'breakpoint' => 0], + ], + '20110115', + ['== 20120116183504 TestMigration2: reverted', '== 20120111235330 TestMigration: reverted'], + ], + + // Breakpoint set on first migration + + 'Rollback to date which is later than all migrations - breakpoint set on first migration' => + [ + [ + '20120111235330' => ['version' => '20120111235330', 'migration_name' => '', 'breakpoint' => 1], + '20120116183504' => ['version' => '20120116183504', 'migration_name' => '', 'breakpoint' => 0], + ], + '20130118000000', + null, + ], + 'Rollback to date of the most recent migration - breakpoint set on first migration' => + [ + [ + '20120111235330' => ['version' => '20120111235330', 'migration_name' => '', 'breakpoint' => 1], + '20120116183504' => ['version' => '20120116183504', 'migration_name' => '', 'breakpoint' => 0], + ], + '20120116183504', + null, + ], + 'Rollback to date between 2 migrations - breakpoint set on first migration' => + [ + [ + '20120111235330' => ['version' => '20120111235330', 'migration_name' => '', 'breakpoint' => 1], + '20120116183504' => ['version' => '20120116183504', 'migration_name' => '', 'breakpoint' => 0], + ], + '20120115', + '== 20120116183504 TestMigration2: reverted', + ], + 'Rollback to date of the oldest migration - breakpoint set on first migration' => + [ + [ + '20120111235330' => ['version' => '20120111235330', 'migration_name' => '', 'breakpoint' => 1], + '20120116183504' => ['version' => '20120116183504', 'migration_name' => '', 'breakpoint' => 0], + ], + '20120111235330', + '== 20120116183504 TestMigration2: reverted', + ], + 'Rollback to date before all the migrations - breakpoint set on first migration' => + [ + [ + '20120111235330' => ['version' => '20120111235330', 'migration_name' => '', 'breakpoint' => 1], + '20120116183504' => ['version' => '20120116183504', 'migration_name' => '', 'breakpoint' => 0], + ], + '20110115', + 'Breakpoint reached. Further rollbacks inhibited.', + ], + + // Breakpoint set on last migration + + 'Rollback to date which is later than all migrations - breakpoint set on last migration' => + [ + [ + '20120111235330' => ['version' => '20120111235330', 'migration_name' => '', 'breakpoint' => 0], + '20120116183504' => ['version' => '20120116183504', 'migration_name' => '', 'breakpoint' => 1], + ], + '20130118000000', + null, + ], + 'Rollback to date of the most recent migration - breakpoint set on last migration' => + [ + [ + '20120111235330' => ['version' => '20120111235330', 'migration_name' => '', 'breakpoint' => 0], + '20120116183504' => ['version' => '20120116183504', 'migration_name' => '', 'breakpoint' => 1], + ], + '20120116183504', + null, + ], + 'Rollback to date between 2 migrations - breakpoint set on last migration' => + [ + [ + '20120111235330' => ['version' => '20120111235330', 'migration_name' => '', 'breakpoint' => 0], + '20120116183504' => ['version' => '20120116183504', 'migration_name' => '', 'breakpoint' => 1], + ], + '20120115000000', + 'Breakpoint reached. Further rollbacks inhibited.', + ], + 'Rollback to date of the oldest migration - breakpoint set on last migration' => + [ + [ + '20120111235330' => ['version' => '20120111235330', 'migration_name' => '', 'breakpoint' => 0], + '20120116183504' => ['version' => '20120116183504', 'migration_name' => '', 'breakpoint' => 1], + ], + '20120111235330', + 'Breakpoint reached. Further rollbacks inhibited.', + ], + 'Rollback to date before all the migrations - breakpoint set on last migration' => + [ + [ + '20120111235330' => ['version' => '20120111235330', 'migration_name' => '', 'breakpoint' => 0], + '20120116183504' => ['version' => '20120116183504', 'migration_name' => '', 'breakpoint' => 1], + ], + '20110115000000', + 'Breakpoint reached. Further rollbacks inhibited.', + ], + + // Breakpoint set on all migrations + + 'Rollback to date which is later than all migrations - breakpoint set on all migrations' => + [ + [ + '20120111235330' => ['version' => '20120111235330', 'migration_name' => '', 'breakpoint' => 1], + '20120116183504' => ['version' => '20120116183504', 'migration_name' => '', 'breakpoint' => 1], + ], + '20130118000000', + null, + ], + 'Rollback to date of the most recent migration - breakpoint set on all migrations' => + [ + [ + '20120111235330' => ['version' => '20120111235330', 'migration_name' => '', 'breakpoint' => 1], + '20120116183504' => ['version' => '20120116183504', 'migration_name' => '', 'breakpoint' => 1], + ], + '20120116183504', + null, + ], + 'Rollback to date between 2 migrations - breakpoint set on all migrations' => + [ + [ + '20120111235330' => ['version' => '20120111235330', 'migration_name' => '', 'breakpoint' => 1], + '20120116183504' => ['version' => '20120116183504', 'migration_name' => '', 'breakpoint' => 1], + ], + '20120115000000', + 'Breakpoint reached. Further rollbacks inhibited.', + ], + 'Rollback to date of the oldest migration - breakpoint set on all migrations' => + [ + [ + '20120111235330' => ['version' => '20120111235330', 'migration_name' => '', 'breakpoint' => 1], + '20120116183504' => ['version' => '20120116183504', 'migration_name' => '', 'breakpoint' => 1], + ], + '20120111235330', + 'Breakpoint reached. Further rollbacks inhibited.', + ], + 'Rollback to date before all the migrations - breakpoint set on all migrations' => + [ + [ + '20120111235330' => ['version' => '20120111235330', 'migration_name' => '', 'breakpoint' => 1], + '20120116183504' => ['version' => '20120116183504', 'migration_name' => '', 'breakpoint' => 1], + ], + '20110115000000', + 'Breakpoint reached. Further rollbacks inhibited.', + ], + ]; + } + + /** + * Migration lists, dates, and expected migration version to rollback to. + * + * @return array + */ + public static function rollbackToDateByExecutionTimeDataProvider(): array + { + return [ + + // No breakpoints set + + 'Rollback to date later than all migration start times when they were created in a different order than they were executed - no breakpoints set' => + [ + [ + '20120116183504' => ['version' => '20120116183504', 'start_time' => '2012-01-17 18:35:04', 'breakpoint' => 0], + '20120111235330' => ['version' => '20120111235330', 'start_time' => '2012-01-20 23:53:30', 'breakpoint' => 0], + ], + '20131212000000', + null, + ], + 'Rollback to date earlier than all migration start times when they were created in a different order than they were executed - no breakpoints set' => + [ + [ + '20120116183504' => ['version' => '20120116183504', 'start_time' => '2012-01-17 18:35:04', 'breakpoint' => 0], + '20120111235330' => ['version' => '20120111235330', 'start_time' => '2012-01-20 23:53:30', 'breakpoint' => 0], + ], + '20111212000000', + ['== 20120111235330 TestMigration: reverted', '== 20120116183504 TestMigration2: reverted'], + ], + 'Rollback to start time of first created version which was the last to be executed - no breakpoints set' => + [ + [ + '20120116183504' => ['version' => '20120116183504', 'start_time' => '2012-01-17 18:35:04', 'breakpoint' => 0], + '20120111235330' => ['version' => '20120111235330', 'start_time' => '2012-01-20 23:53:30', 'breakpoint' => 0], + ], + '20120120235330', + null, + ], + 'Rollback to start time of second created version which was the first to be executed - no breakpoints set' => + [ + [ + '20120116183504' => ['version' => '20120116183504', 'start_time' => '2012-01-17 18:35:04', 'breakpoint' => 0], + '20120111235330' => ['version' => '20120111235330', 'start_time' => '2012-01-20 23:53:30', 'breakpoint' => 0], + ], + '20120117183504', + '== 20120111235330 TestMigration: reverted', + ], + 'Rollback to date between the 2 migrations when they were created in a different order than they were executed - no breakpoints set' => + [ + [ + '20120116183504' => ['version' => '20120116183504', 'start_time' => '2012-01-17 18:35:04', 'breakpoint' => 0], + '20120111235330' => ['version' => '20120111235330', 'start_time' => '2012-01-20 23:53:30', 'breakpoint' => 0], + ], + '20120118000000', + '== 20120111235330 TestMigration: reverted', + ], + 'Rollback the last executed migration when the migrations were created in a different order than they were executed - no breakpoints set' => + [ + [ + '20120116183504' => ['version' => '20120116183504', 'start_time' => '2012-01-17 18:35:04', 'breakpoint' => 0], + '20120111235330' => ['version' => '20120111235330', 'start_time' => '2012-01-20 23:53:30', 'breakpoint' => 0], + ], + null, + '== 20120111235330 TestMigration: reverted', + ], + + // Breakpoint set on first/last created/executed migration + + 'Rollback to date later than all migration start times when they were created in a different order than they were executed - breakpoints set on first created (and last executed) migration' => + [ + [ + '20120116183504' => ['version' => '20120116183504', 'start_time' => '2012-01-17 18:35:04', 'breakpoint' => 0], + '20120111235330' => ['version' => '20120111235330', 'start_time' => '2012-01-20 23:53:30', 'breakpoint' => 1], + ], + '20131212000000', + null, + ], + 'Rollback to date later than all migration start times when they were created in a different order than they were executed - breakpoints set on first executed (and last created) migration' => + [ + [ + '20120116183504' => ['version' => '20120116183504', 'start_time' => '2012-01-17 18:35:04', 'breakpoint' => 1], + '20120111235330' => ['version' => '20120111235330', 'start_time' => '2012-01-20 23:53:30', 'breakpoint' => 0], + ], + '20131212000000', + null, + ], + 'Rollback to date earlier than all migration start times when they were created in a different order than they were executed - breakpoints set on first created (and last executed) migration' => + [ + [ + '20120116183504' => ['version' => '20120116183504', 'start_time' => '2012-01-17 18:35:04', 'breakpoint' => 0], + '20120111235330' => ['version' => '20120111235330', 'start_time' => '2012-01-20 23:53:30', 'breakpoint' => 1], + ], + '20111212000000', + 'Breakpoint reached. Further rollbacks inhibited.', + ], + 'Rollback to date earlier than all migration start times when they were created in a different order than they were executed - breakpoints set on first executed (and last created) migration' => + [ + [ + '20120116183504' => ['version' => '20120116183504', 'start_time' => '2012-01-17 18:35:04', 'breakpoint' => 1], + '20120111235330' => ['version' => '20120111235330', 'start_time' => '2012-01-20 23:53:30', 'breakpoint' => 0], + ], + '20111212000000', + ['== 20120111235330 TestMigration: reverted', 'Breakpoint reached. Further rollbacks inhibited.'], + ], + 'Rollback to start time of first created version which was the last to be executed - breakpoints set on first created (and last executed) migration' => + [ + [ + '20120116183504' => ['version' => '20120116183504', 'start_time' => '2012-01-17 18:35:04', 'breakpoint' => 0], + '20120111235330' => ['version' => '20120111235330', 'start_time' => '2012-01-20 23:53:30', 'breakpoint' => 1], + ], + '20120120235330', + null, + ], + 'Rollback to start time of first created version which was the last to be executed - breakpoints set on first executed (and last created) migration' => + [ + [ + '20120116183504' => ['version' => '20120116183504', 'start_time' => '2012-01-17 18:35:04', 'breakpoint' => 1], + '20120111235330' => ['version' => '20120111235330', 'start_time' => '2012-01-20 23:53:30', 'breakpoint' => 0], + ], + '20120120235330', + null, + ], + 'Rollback to start time of second created version which was the first to be executed - breakpoints set on first created (and last executed) migration' => + [ + [ + '20120116183504' => ['version' => '20120116183504', 'start_time' => '2012-01-17 18:35:04', 'breakpoint' => 0], + '20120111235330' => ['version' => '20120111235330', 'start_time' => '2012-01-20 23:53:30', 'breakpoint' => 1], + ], + '20120117183504', + 'Breakpoint reached. Further rollbacks inhibited.', + ], + 'Rollback to start time of second created version which was the first to be executed - breakpoints set on first executed (and last created) migration' => + [ + [ + '20120116183504' => ['version' => '20120116183504', 'start_time' => '2012-01-17 18:35:04', 'breakpoint' => 1], + '20120111235330' => ['version' => '20120111235330', 'start_time' => '2012-01-20 23:53:30', 'breakpoint' => 0], + ], + '20120117183504', + '== 20120111235330 TestMigration: reverted', + ], + 'Rollback to date between the 2 migrations when they were created in a different order than they were executed - breakpoints set on first created (and last executed) migration' => + [ + [ + '20120116183504' => ['version' => '20120116183504', 'start_time' => '2012-01-17 18:35:04', 'breakpoint' => 0], + '20120111235330' => ['version' => '20120111235330', 'start_time' => '2012-01-20 23:53:30', 'breakpoint' => 1], + ], + '20120118000000', + 'Breakpoint reached. Further rollbacks inhibited.', + ], + 'Rollback to date between the 2 migrations when they were created in a different order than they were executed - breakpoints set on first executed (and last created) migration' => + [ + [ + '20120116183504' => ['version' => '20120116183504', 'start_time' => '2012-01-17 18:35:04', 'breakpoint' => 1], + '20120111235330' => ['version' => '20120111235330', 'start_time' => '2012-01-20 23:53:30', 'breakpoint' => 0], + ], + '20120118000000', + '== 20120111235330 TestMigration: reverted', + ], + 'Rollback the last executed migration when the migrations were created in a different order than they were executed - breakpoints set on first created (and last executed) migration' => + [ + [ + '20120116183504' => ['version' => '20120116183504', 'start_time' => '2012-01-17 18:35:04', 'breakpoint' => 0], + '20120111235330' => ['version' => '20120111235330', 'start_time' => '2012-01-20 23:53:30', 'breakpoint' => 1], + ], + null, + 'Breakpoint reached. Further rollbacks inhibited.', + ], + 'Rollback the last executed migration when the migrations were created in a different order than they were executed - breakpoints set on first executed (and last created) migration' => + [ + [ + '20120116183504' => ['version' => '20120116183504', 'start_time' => '2012-01-17 18:35:04', 'breakpoint' => 1], + '20120111235330' => ['version' => '20120111235330', 'start_time' => '2012-01-20 23:53:30', 'breakpoint' => 0], + ], + null, + '== 20120111235330 TestMigration: reverted', + ], + + // Breakpoint set on all migration + + 'Rollback to date later than all migration start times when they were created in a different order than they were executed - breakpoints set on all migrations' => + [ + [ + '20120116183504' => ['version' => '20120116183504', 'start_time' => '2012-01-17 18:35:04', 'breakpoint' => 1], + '20120111235330' => ['version' => '20120111235330', 'start_time' => '2012-01-20 23:53:30', 'breakpoint' => 1], + ], + '20131212000000', + null, + ], + 'Rollback to date earlier than all migration start times when they were created in a different order than they were executed - breakpoints set on all migrations' => + [ + [ + '20120116183504' => ['version' => '20120116183504', 'start_time' => '2012-01-17 18:35:04', 'breakpoint' => 1], + '20120111235330' => ['version' => '20120111235330', 'start_time' => '2012-01-20 23:53:30', 'breakpoint' => 1], + ], + '20111212000000', + 'Breakpoint reached. Further rollbacks inhibited.', + ], + 'Rollback to start time of first created version which was the last to be executed - breakpoints set on all migrations' => + [ + [ + '20120116183504' => ['version' => '20120116183504', 'start_time' => '2012-01-17 18:35:04', 'breakpoint' => 1], + '20120111235330' => ['version' => '20120111235330', 'start_time' => '2012-01-20 23:53:30', 'breakpoint' => 1], + ], + '20120120235330', + null, + ], + 'Rollback to start time of second created version which was the first to be executed - breakpoints set on all migrations' => + [ + [ + '20120116183504' => ['version' => '20120116183504', 'start_time' => '2012-01-17 18:35:04', 'breakpoint' => 1], + '20120111235330' => ['version' => '20120111235330', 'start_time' => '2012-01-20 23:53:30', 'breakpoint' => 1], + ], + '20120117183504', + 'Breakpoint reached. Further rollbacks inhibited.', + ], + 'Rollback to date between the 2 migrations when they were created in a different order than they were executed - breakpoints set on all migrations' => + [ + [ + '20120116183504' => ['version' => '20120116183504', 'start_time' => '2012-01-17 18:35:04', 'breakpoint' => 1], + '20120111235330' => ['version' => '20120111235330', 'start_time' => '2012-01-20 23:53:30', 'breakpoint' => 1], + ], + '20120118000000', + 'Breakpoint reached. Further rollbacks inhibited.', + ], + 'Rollback the last executed migration when the migrations were created in a different order than they were executed - breakpoints set on all migrations' => + [ + [ + '20120116183504' => ['version' => '20120116183504', 'start_time' => '2012-01-17 18:35:04', 'breakpoint' => 1], + '20120111235330' => ['version' => '20120111235330', 'start_time' => '2012-01-20 23:53:30', 'breakpoint' => 1], + ], + null, + 'Breakpoint reached. Further rollbacks inhibited.', + ], + ]; + } + + /** + * Migration lists, dates, and expected output. + * + * @return array + */ + public static function rollbackToVersionDataProvider(): array + { + return [ + + // No breakpoints set + + 'Rollback to one of the versions - no breakpoints set' => + [ + [ + '20120111235330' => ['version' => '20120111235330', 'migration_name' => '', 'breakpoint' => 0], + '20120116183504' => ['version' => '20120116183504', 'migration_name' => '', 'breakpoint' => 0], + ], + '20120111235330', + '== 20120116183504 TestMigration2: reverted', + ], + 'Rollback to the latest version - no breakpoints set' => + [ + [ + '20120111235330' => ['version' => '20120111235330', 'migration_name' => '', 'breakpoint' => 0], + '20120116183504' => ['version' => '20120116183504', 'migration_name' => '', 'breakpoint' => 0], + ], + '20120116183504', + null, + ], + 'Rollback all versions (ie. rollback to version 0) - no breakpoints set' => + [ + [ + '20120111235330' => ['version' => '20120111235330', 'migration_name' => '', 'breakpoint' => 0], + '20120116183504' => ['version' => '20120116183504', 'migration_name' => '', 'breakpoint' => 0], + ], + '0', + ['== 20120111235330 TestMigration: reverted', '== 20120116183504 TestMigration2: reverted'], + ], + 'Rollback last version - no breakpoints set' => + [ + [ + '20120111235330' => ['version' => '20120111235330', 'migration_name' => '', 'breakpoint' => 0], + '20120116183504' => ['version' => '20120116183504', 'migration_name' => '', 'breakpoint' => 0], + ], + null, + '== 20120116183504 TestMigration2: reverted', + ], + 'Rollback to non-existing version - no breakpoints set' => + [ + [ + '20120111235330' => ['version' => '20120111235330', 'migration_name' => '', 'breakpoint' => 0], + '20120116183504' => ['version' => '20120116183504', 'migration_name' => '', 'breakpoint' => 0], + ], + '20121225000000', + 'Target version (20121225000000) not found', + ], + 'Rollback to missing version - no breakpoints set' => + [ + [ + '20111225000000' => ['version' => '20111225000000', 'migration_name' => '', 'breakpoint' => 0], + '20120111235330' => ['version' => '20120111235330', 'migration_name' => '', 'breakpoint' => 0], + '20120116183504' => ['version' => '20120116183504', 'migration_name' => '', 'breakpoint' => 0], + ], + '20111225000000', + 'Target version (20111225000000) not found', + ], + + // Breakpoint set on first migration + + 'Rollback to one of the versions - breakpoint set on first migration' => + [ + [ + '20120111235330' => ['version' => '20120111235330', 'migration_name' => '', 'breakpoint' => 1], + '20120116183504' => ['version' => '20120116183504', 'migration_name' => '', 'breakpoint' => 0], + ], + '20120111235330', + '== 20120116183504 TestMigration2: reverted', + ], + 'Rollback to the latest version - breakpoint set on first migration' => + [ + [ + '20120111235330' => ['version' => '20120111235330', 'migration_name' => '', 'breakpoint' => 1], + '20120116183504' => ['version' => '20120116183504', 'migration_name' => '', 'breakpoint' => 0], + ], + '20120116183504', + null, + ], + 'Rollback all versions (ie. rollback to version 0) - breakpoint set on first migration' => + [ + [ + '20120111235330' => ['version' => '20120111235330', 'migration_name' => '', 'breakpoint' => 1], + '20120116183504' => ['version' => '20120116183504', 'migration_name' => '', 'breakpoint' => 0], + ], + '0', + '== 20120116183504 TestMigration2: reverted', + ], + 'Rollback last version - breakpoint set on first migration' => + [ + [ + '20120111235330' => ['version' => '20120111235330', 'migration_name' => '', 'breakpoint' => 1], + '20120116183504' => ['version' => '20120116183504', 'migration_name' => '', 'breakpoint' => 0], + ], + null, + '== 20120116183504 TestMigration2: reverted', + ], + 'Rollback to non-existing version - breakpoint set on first migration' => + [ + [ + '20120111235330' => ['version' => '20120111235330', 'migration_name' => '', 'breakpoint' => 1], + '20120116183504' => ['version' => '20120116183504', 'migration_name' => '', 'breakpoint' => 0], + ], + '20121225000000', + 'Target version (20121225000000) not found', + ], + 'Rollback to missing version - breakpoint set on first migration' => + [ + [ + '20111225000000' => ['version' => '20111225000000', 'migration_name' => '', 'breakpoint' => 0], + '20120111235330' => ['version' => '20120111235330', 'migration_name' => '', 'breakpoint' => 1], + '20120116183504' => ['version' => '20120116183504', 'migration_name' => '', 'breakpoint' => 0], + ], + '20111225000000', + 'Target version (20111225000000) not found', + ], + + // Breakpoint set on last migration + + 'Rollback to one of the versions - breakpoint set on last migration' => + [ + [ + '20120111235330' => ['version' => '20120111235330', 'migration_name' => '', 'breakpoint' => 0], + '20120116183504' => ['version' => '20120116183504', 'migration_name' => '', 'breakpoint' => 1], + ], + '20120111235330', + 'Breakpoint reached. Further rollbacks inhibited.', + ], + 'Rollback to the latest version - breakpoint set on last migration' => + [ + [ + '20120111235330' => ['version' => '20120111235330', 'migration_name' => '', 'breakpoint' => 0], + '20120116183504' => ['version' => '20120116183504', 'migration_name' => '', 'breakpoint' => 1], + ], + '20120116183504', + null, + ], + // Breakpoint set on all migrations + 'Rollback all versions (ie. rollback to version 0) - breakpoint set on last migration' => + [ + [ + '20120111235330' => ['version' => '20120111235330', 'migration_name' => '', 'breakpoint' => 0], + '20120116183504' => ['version' => '20120116183504', 'migration_name' => '', 'breakpoint' => 1], + ], + '0', + 'Breakpoint reached. Further rollbacks inhibited.', + ], + 'Rollback last version - breakpoint set on last migration' => + [ + [ + '20120111235330' => ['version' => '20120111235330', 'migration_name' => '', 'breakpoint' => 0], + '20120116183504' => ['version' => '20120116183504', 'migration_name' => '', 'breakpoint' => 1], + ], + null, + 'Breakpoint reached. Further rollbacks inhibited.', + ], + 'Rollback to non-existing version - breakpoint set on last migration' => + [ + [ + '20120111235330' => ['version' => '20120111235330', 'migration_name' => '', 'breakpoint' => 0], + '20120116183504' => ['version' => '20120116183504', 'migration_name' => '', 'breakpoint' => 1], + ], + '20121225000000', + 'Target version (20121225000000) not found', + ], + 'Rollback to missing version - breakpoint set on last migration' => + [ + [ + '20111225000000' => ['version' => '20111225000000', 'migration_name' => '', 'breakpoint' => 0], + '20120111235330' => ['version' => '20120111235330', 'migration_name' => '', 'breakpoint' => 0], + '20120116183504' => ['version' => '20120116183504', 'migration_name' => '', 'breakpoint' => 1], + ], + '20111225000000', + 'Target version (20111225000000) not found', + ], + ]; + } + + public static function rollbackToVersionByExecutionTimeDataProvider(): array + { + return [ + + // No breakpoints set + + 'Rollback to first created version with was also the first to be executed - no breakpoints set' => + [ + [ + '20120111235330' => ['version' => '20120111235330', 'start_time' => '2012-01-12 23:53:30', 'end_time' => '2012-01-12 23:53:30', 'breakpoint' => 0, 'migration_name' => 'TestMigration1'], + '20120116183504' => ['version' => '20120116183504', 'start_time' => '2012-01-17 18:35:04', 'end_time' => '2012-01-17 18:35:04', 'breakpoint' => 0, 'migration_name' => 'TestMigration2'], + ], + '20120111235330', + '== 20120116183504 TestMigration2: reverted', + ], + 'Rollback to last created version which was also the last to be executed - no breakpoints set' => + [ + [ + '20120111235330' => ['version' => '20120111235330', 'start_time' => '2012-01-12 23:53:30', 'end_time' => '2012-01-12 23:53:30', 'breakpoint' => 0, 'migration_name' => 'TestMigration1'], + '20120116183504' => ['version' => '20120116183504', 'start_time' => '2012-01-17 18:35:04', 'end_time' => '2012-01-17 18:35:04', 'breakpoint' => 0, 'migration_name' => 'TestMigration2'], + ], + '20120116183504', + 'No migrations to rollback', + ], + 'Rollback all versions (ie. rollback to version 0) - no breakpoints set' => + [ + [ + '20120111235330' => ['version' => '20120111235330', 'start_time' => '2012-01-12 23:53:30', 'end_time' => '2012-01-12 23:53:30', 'breakpoint' => 0, 'migration_name' => 'TestMigration1'], + '20120116183504' => ['version' => '20120116183504', 'start_time' => '2012-01-17 18:35:04', 'end_time' => '2012-01-17 18:35:04', 'breakpoint' => 0, 'migration_name' => 'TestMigration2'], + ], + '0', + ['== 20120111235330 TestMigration: reverted', '== 20120116183504 TestMigration2: reverted'], + ], + 'Rollback to second created version which was the first to be executed - no breakpoints set' => + [ + [ + '20120116183504' => ['version' => '20120116183504', 'start_time' => '2012-01-10 18:35:04', 'end_time' => '2012-01-10 18:35:04', 'breakpoint' => 0, 'migration_name' => 'TestMigration1'], + '20120111235330' => ['version' => '20120111235330', 'start_time' => '2012-01-12 23:53:30', 'end_time' => '2012-01-12 23:53:30', 'breakpoint' => 0, 'migration_name' => 'TestMigration2'], + ], + '20120116183504', + '== 20120111235330 TestMigration: reverted', + ], + 'Rollback to first created version which was the second to be executed - no breakpoints set' => + [ + [ + '20120116183504' => ['version' => '20120116183504', 'start_time' => '2012-01-17 18:35:04', 'end_time' => '2012-01-17 18:35:04', 'breakpoint' => 0, 'migration_name' => 'TestMigration1'], + '20120111235330' => ['version' => '20120111235330', 'start_time' => '2012-01-20 23:53:30', 'end_time' => '2012-01-20 23:53:30', 'breakpoint' => 0, 'migration_name' => 'TestMigration2'], + ], + '20120111235330', + 'No migrations to rollback', + ], + 'Rollback last executed version which was also the last created version - no breakpoints set' => + [ + [ + '20120111235330' => ['version' => '20120111235330', 'start_time' => '2012-01-12 23:53:30', 'end_time' => '2012-01-12 23:53:30', 'breakpoint' => 0, 'migration_name' => 'TestMigration1'], + '20120116183504' => ['version' => '20120116183504', 'start_time' => '2012-01-17 18:35:04', 'end_time' => '2012-01-17 18:35:04', 'breakpoint' => 0, 'migration_name' => 'TestMigration2'], + ], + null, + '== 20120116183504 TestMigration2: reverted', + ], + 'Rollback last executed version which was the first created version - no breakpoints set' => + [ + [ + '20120116183504' => ['version' => '20120116183504', 'start_time' => '2012-01-17 18:35:04', 'end_time' => '2012-01-17 18:35:04', 'breakpoint' => 0, 'migration_name' => 'TestMigration1'], + '20120111235330' => ['version' => '20120111235330', 'start_time' => '2012-01-20 23:53:30', 'end_time' => '2012-01-20 23:53:30', 'breakpoint' => 0, 'migration_name' => 'TestMigration2'], + ], + null, + '== 20120111235330 TestMigration: reverted', + ], + 'Rollback to non-existing version - no breakpoints set' => + [ + [ + '20120116183504' => ['version' => '20120116183504', 'start_time' => '2012-01-17 18:35:04', 'end_time' => '2012-01-17 18:35:04', 'breakpoint' => 0, 'migration_name' => 'TestMigration1'], + '20120111235330' => ['version' => '20120111235330', 'start_time' => '2012-01-20 23:53:30', 'end_time' => '2012-01-20 23:53:30', 'breakpoint' => 0, 'migration_name' => 'TestMigration2'], + ], + '20121225000000', + 'Target version (20121225000000) not found', + ], + 'Rollback to missing version - no breakpoints set' => + [ + [ + '20111225000000' => ['version' => '20111225000000', 'start_time' => '2011-12-25 00:00:00', 'end_time' => '2011-12-25 00:00:00', 'breakpoint' => 0, 'migration_name' => 'TestMigration1'], + '20120116183504' => ['version' => '20120116183504', 'start_time' => '2012-01-17 18:35:04', 'end_time' => '2012-01-17 18:35:04', 'breakpoint' => 0, 'migration_name' => 'TestMigration2'], + '20120111235330' => ['version' => '20120111235330', 'start_time' => '2012-01-20 23:53:30', 'end_time' => '2012-01-20 23:53:30', 'breakpoint' => 0, 'migration_name' => 'TestMigration3'], + ], + '20121225000000', + 'Target version (20121225000000) not found', + ], + + // Breakpoint set on first migration + + 'Rollback to first created version with was also the first to be executed - breakpoint set on first (executed and created) migration' => + [ + [ + '20120111235330' => ['version' => '20120111235330', 'start_time' => '2012-01-12 23:53:30', 'end_time' => '2012-01-12 23:53:30', 'breakpoint' => 1, 'migration_name' => 'TestMigration1'], + '20120116183504' => ['version' => '20120116183504', 'start_time' => '2012-01-17 18:35:04', 'end_time' => '2012-01-17 18:35:04', 'breakpoint' => 0, 'migration_name' => 'TestMigration2'], + ], + '20120111235330', + '== 20120116183504 TestMigration2: reverted', + ], + 'Rollback to last created version which was also the last to be executed - breakpoint set on first (executed and created) migration' => + [ + [ + '20120111235330' => ['version' => '20120111235330', 'start_time' => '2012-01-12 23:53:30', 'end_time' => '2012-01-12 23:53:30', 'breakpoint' => 1, 'migration_name' => 'TestMigration1'], + '20120116183504' => ['version' => '20120116183504', 'start_time' => '2012-01-17 18:35:04', 'end_time' => '2012-01-17 18:35:04', 'breakpoint' => 0, 'migration_name' => 'TestMigration2'], + ], + '20120116183504', + 'No migrations to rollback', + ], + 'Rollback all versions (ie. rollback to version 0) - breakpoint set on first (executed and created) migration' => + [ + [ + '20120111235330' => ['version' => '20120111235330', 'start_time' => '2012-01-12 23:53:30', 'end_time' => '2012-01-12 23:53:30', 'breakpoint' => 1, 'migration_name' => 'TestMigration1'], + '20120116183504' => ['version' => '20120116183504', 'start_time' => '2012-01-17 18:35:04', 'end_time' => '2012-01-17 18:35:04', 'breakpoint' => 0, 'migration_name' => 'TestMigration2'], + ], + '0', + ['== 20120116183504 TestMigration2: reverted', 'Breakpoint reached. Further rollbacks inhibited.'], + ], + 'Rollback to second created version which was the first to be executed - breakpoint set on first executed migration' => + [ + [ + '20120116183504' => ['version' => '20120116183504', 'start_time' => '2012-01-10 18:35:04', 'end_time' => '2012-01-10 18:35:04', 'breakpoint' => 1, 'migration_name' => 'TestMigration1'], + '20120111235330' => ['version' => '20120111235330', 'start_time' => '2012-01-12 23:53:30', 'end_time' => '2012-01-12 23:53:30', 'breakpoint' => 0, 'migration_name' => 'TestMigration2'], + ], + '20120116183504', + '== 20120111235330 TestMigration: reverted', + ], + 'Rollback to second created version which was the first to be executed - breakpoint set on first created migration' => + [ + [ + '20120116183504' => ['version' => '20120116183504', 'start_time' => '2012-01-10 18:35:04', 'end_time' => '2012-01-10 18:35:04', 'breakpoint' => 0, 'migration_name' => 'TestMigration1'], + '20120111235330' => ['version' => '20120111235330', 'start_time' => '2012-01-12 23:53:30', 'end_time' => '2012-01-12 23:53:30', 'breakpoint' => 1, 'migration_name' => 'TestMigration2'], + ], + '20120116183504', + 'Breakpoint reached. Further rollbacks inhibited.', + ], + 'Rollback to first created version which was the second to be executed - breakpoint set on first executed migration' => + [ + [ + '20120116183504' => ['version' => '20120116183504', 'start_time' => '2012-01-17 18:35:04', 'end_time' => '2012-01-17 18:35:04', 'breakpoint' => 1, 'migration_name' => 'TestMigration1'], + '20120111235330' => ['version' => '20120111235330', 'start_time' => '2012-01-20 23:53:30', 'end_time' => '2012-01-20 23:53:30', 'breakpoint' => 0, 'migration_name' => 'TestMigration2'], + ], + '20120111235330', + 'No migrations to rollback', + ], + 'Rollback to first created version which was the second to be executed - breakpoint set on first created migration' => + [ + [ + '20120116183504' => ['version' => '20120116183504', 'start_time' => '2012-01-17 18:35:04', 'end_time' => '2012-01-17 18:35:04', 'breakpoint' => 0, 'migration_name' => 'TestMigration1'], + '20120111235330' => ['version' => '20120111235330', 'start_time' => '2012-01-20 23:53:30', 'end_time' => '2012-01-20 23:53:30', 'breakpoint' => 1, 'migration_name' => 'TestMigration2'], + ], + '20120111235330', + 'No migrations to rollback', + ], + 'Rollback last executed version which was also the last created version - breakpoint set on first (executed and created) migration' => + [ + [ + '20120111235330' => ['version' => '20120111235330', 'start_time' => '2012-01-12 23:53:30', 'end_time' => '2012-01-12 23:53:30', 'breakpoint' => 1, 'migration_name' => 'TestMigration1'], + '20120116183504' => ['version' => '20120116183504', 'start_time' => '2012-01-17 18:35:04', 'end_time' => '2012-01-17 18:35:04', 'breakpoint' => 0, 'migration_name' => 'TestMigration2'], + ], + null, + '== 20120116183504 TestMigration2: reverted', + ], + 'Rollback last executed version which was the first created version - breakpoint set on first executed migration' => + [ + [ + '20120116183504' => ['version' => '20120116183504', 'start_time' => '2012-01-17 18:35:04', 'end_time' => '2012-01-17 18:35:04', 'breakpoint' => 1, 'migration_name' => 'TestMigration1'], + '20120111235330' => ['version' => '20120111235330', 'start_time' => '2012-01-20 23:53:30', 'end_time' => '2012-01-20 23:53:30', 'breakpoint' => 0, 'migration_name' => 'TestMigration2'], + ], + null, + '== 20120111235330 TestMigration: reverted', + ], + 'Rollback last executed version which was the first created version - breakpoint set on first created migration' => + [ + [ + '20120116183504' => ['version' => '20120116183504', 'start_time' => '2012-01-17 18:35:04', 'end_time' => '2012-01-17 18:35:04', 'breakpoint' => 0, 'migration_name' => 'TestMigration1'], + '20120111235330' => ['version' => '20120111235330', 'start_time' => '2012-01-20 23:53:30', 'end_time' => '2012-01-20 23:53:30', 'breakpoint' => 1, 'migration_name' => 'TestMigration2'], + ], + null, + 'Breakpoint reached. Further rollbacks inhibited.', + ], + 'Rollback to non-existing version - breakpoint set on first executed migration' => + [ + [ + '20120116183504' => ['version' => '20120116183504', 'start_time' => '2012-01-17 18:35:04', 'end_time' => '2012-01-17 18:35:04', 'breakpoint' => 1, 'migration_name' => 'TestMigration1'], + '20120111235330' => ['version' => '20120111235330', 'start_time' => '2012-01-20 23:53:30', 'end_time' => '2012-01-20 23:53:30', 'breakpoint' => 0, 'migration_name' => 'TestMigration2'], + ], + '20121225000000', + 'Target version (20121225000000) not found', + ], + 'Rollback to missing version - breakpoint set on first executed migration' => + [ + [ + '20111225000000' => ['version' => '20111225000000', 'start_time' => '2011-12-25 00:00:00', 'end_time' => '2011-12-25 00:00:00', 'breakpoint' => 1, 'migration_name' => 'TestMigration1'], + '20120116183504' => ['version' => '20120116183504', 'start_time' => '2012-01-17 18:35:04', 'end_time' => '2012-01-17 18:35:04', 'breakpoint' => 0, 'migration_name' => 'TestMigration2'], + '20120111235330' => ['version' => '20120111235330', 'start_time' => '2012-01-20 23:53:30', 'end_time' => '2012-01-20 23:53:30', 'breakpoint' => 0, 'migration_name' => 'TestMigration3'], + ], + '20121225000000', + 'Target version (20121225000000) not found', + ], + + // Breakpoint set on last migration + + 'Rollback to first created version with was also the first to be executed - breakpoint set on last (executed and created) migration' => + [ + [ + '20120111235330' => ['version' => '20120111235330', 'start_time' => '2012-01-12 23:53:30', 'end_time' => '2012-01-12 23:53:30', 'breakpoint' => 0, 'migration_name' => 'TestMigration1'], + '20120116183504' => ['version' => '20120116183504', 'start_time' => '2012-01-17 18:35:04', 'end_time' => '2012-01-17 18:35:04', 'breakpoint' => 1, 'migration_name' => 'TestMigration2'], + ], + '20120111235330', + 'Breakpoint reached. Further rollbacks inhibited.', + ], + 'Rollback to last created version which was also the last to be executed - breakpoint set on last (executed and created) migration' => + [ + [ + '20120111235330' => ['version' => '20120111235330', 'start_time' => '2012-01-12 23:53:30', 'end_time' => '2012-01-12 23:53:30', 'breakpoint' => 0, 'migration_name' => 'TestMigration1'], + '20120116183504' => ['version' => '20120116183504', 'start_time' => '2012-01-17 18:35:04', 'end_time' => '2012-01-17 18:35:04', 'breakpoint' => 1, 'migration_name' => 'TestMigration2'], + ], + '20120116183504', + 'No migrations to rollback', + ], + 'Rollback all versions (ie. rollback to version 0) - breakpoint set on last (executed and created) migration' => + [ + [ + '20120111235330' => ['version' => '20120111235330', 'start_time' => '2012-01-12 23:53:30', 'end_time' => '2012-01-12 23:53:30', 'breakpoint' => 0, 'migration_name' => 'TestMigration1'], + '20120116183504' => ['version' => '20120116183504', 'start_time' => '2012-01-17 18:35:04', 'end_time' => '2012-01-17 18:35:04', 'breakpoint' => 1, 'migration_name' => 'TestMigration2'], + ], + '0', + ['Breakpoint reached. Further rollbacks inhibited.'], + ], + 'Rollback to second created version which was the first to be executed - breakpoint set on last executed migration' => + [ + [ + '20120116183504' => ['version' => '20120116183504', 'start_time' => '2012-01-10 18:35:04', 'end_time' => '2012-01-10 18:35:04', 'breakpoint' => 0, 'migration_name' => 'TestMigration1'], + '20120111235330' => ['version' => '20120111235330', 'start_time' => '2012-01-12 23:53:30', 'end_time' => '2012-01-12 23:53:30', 'breakpoint' => 1, 'migration_name' => 'TestMigration2'], + ], + '20120116183504', + 'Breakpoint reached. Further rollbacks inhibited.', + ], + 'Rollback to second created version which was the first to be executed - breakpoint set on last created migration' => + [ + [ + '20120116183504' => ['version' => '20120116183504', 'start_time' => '2012-01-10 18:35:04', 'end_time' => '2012-01-10 18:35:04', 'breakpoint' => 1, 'migration_name' => 'TestMigration1'], + '20120111235330' => ['version' => '20120111235330', 'start_time' => '2012-01-12 23:53:30', 'end_time' => '2012-01-12 23:53:30', 'breakpoint' => 0, 'migration_name' => 'TestMigration2'], + ], + '20120116183504', + '== 20120111235330 TestMigration: reverted', + ], + 'Rollback to first created version which was the second to be executed - breakpoint set on last executed migration' => + [ + [ + '20120116183504' => ['version' => '20120116183504', 'start_time' => '2012-01-17 18:35:04', 'end_time' => '2012-01-17 18:35:04', 'breakpoint' => 0, 'migration_name' => 'TestMigration1'], + '20120111235330' => ['version' => '20120111235330', 'start_time' => '2012-01-20 23:53:30', 'end_time' => '2012-01-20 23:53:30', 'breakpoint' => 1, 'migration_name' => 'TestMigration2'], + ], + '20120111235330', + 'No migrations to rollback', + ], + 'Rollback to first created version which was the second to be executed - breakpoint set on last created migration' => + [ + [ + '20120116183504' => ['version' => '20120116183504', 'start_time' => '2012-01-17 18:35:04', 'end_time' => '2012-01-17 18:35:04', 'breakpoint' => 1, 'migration_name' => 'TestMigration1'], + '20120111235330' => ['version' => '20120111235330', 'start_time' => '2012-01-20 23:53:30', 'end_time' => '2012-01-20 23:53:30', 'breakpoint' => 0, 'migration_name' => 'TestMigration2'], + ], + '20120111235330', + 'No migrations to rollback', + ], + 'Rollback last executed version which was also the last created version - breakpoint set on last (executed and created) migration' => + [ + [ + '20120111235330' => ['version' => '20120111235330', 'start_time' => '2012-01-12 23:53:30', 'end_time' => '2012-01-12 23:53:30', 'breakpoint' => 0, 'migration_name' => 'TestMigration1'], + '20120116183504' => ['version' => '20120116183504', 'start_time' => '2012-01-17 18:35:04', 'end_time' => '2012-01-17 18:35:04', 'breakpoint' => 1, 'migration_name' => 'TestMigration2'], + ], + null, + 'Breakpoint reached. Further rollbacks inhibited.', + ], + 'Rollback last executed version which was the first created version - breakpoint set on last executed migration' => + [ + [ + '20120116183504' => ['version' => '20120116183504', 'start_time' => '2012-01-17 18:35:04', 'end_time' => '2012-01-17 18:35:04', 'breakpoint' => 0, 'migration_name' => 'TestMigration1'], + '20120111235330' => ['version' => '20120111235330', 'start_time' => '2012-01-20 23:53:30', 'end_time' => '2012-01-20 23:53:30', 'breakpoint' => 1, 'migration_name' => 'TestMigration2'], + ], + null, + 'Breakpoint reached. Further rollbacks inhibited.', + ], + 'Rollback last executed version which was the first created version - breakpoint set on last created migration' => + [ + [ + '20120116183504' => ['version' => '20120116183504', 'start_time' => '2012-01-17 18:35:04', 'end_time' => '2012-01-17 18:35:04', 'breakpoint' => 1, 'migration_name' => 'TestMigration1'], + '20120111235330' => ['version' => '20120111235330', 'start_time' => '2012-01-20 23:53:30', 'end_time' => '2012-01-20 23:53:30', 'breakpoint' => 0, 'migration_name' => 'TestMigration2'], + ], + null, + '== 20120111235330 TestMigration: reverted', + ], + 'Rollback to non-existing version - breakpoint set on last executed migration' => + [ + [ + '20120116183504' => ['version' => '20120116183504', 'start_time' => '2012-01-17 18:35:04', 'end_time' => '2012-01-17 18:35:04', 'breakpoint' => 0, 'migration_name' => 'TestMigration1'], + '20120111235330' => ['version' => '20120111235330', 'start_time' => '2012-01-20 23:53:30', 'end_time' => '2012-01-20 23:53:30', 'breakpoint' => 1, 'migration_name' => 'TestMigration2'], + ], + '20121225000000', + 'Target version (20121225000000) not found', + ], + 'Rollback to missing version - breakpoint set on last executed migration' => + [ + [ + '20111225000000' => ['version' => '20111225000000', 'start_time' => '2011-12-25 00:00:00', 'end_time' => '2011-12-25 00:00:00', 'breakpoint' => 0, 'migration_name' => 'TestMigration1'], + '20120116183504' => ['version' => '20120116183504', 'start_time' => '2012-01-17 18:35:04', 'end_time' => '2012-01-17 18:35:04', 'breakpoint' => 0, 'migration_name' => 'TestMigration2'], + '20120111235330' => ['version' => '20120111235330', 'start_time' => '2012-01-20 23:53:30', 'end_time' => '2012-01-20 23:53:30', 'breakpoint' => 1, 'migration_name' => 'TestMigration3'], + ], + '20121225000000', + 'Target version (20121225000000) not found', + ], + + // Breakpoint set on all migrations + + 'Rollback to first created version with was also the first to be executed - breakpoint set on all migrations' => + [ + [ + '20120111235330' => ['version' => '20120111235330', 'start_time' => '2012-01-12 23:53:30', 'end_time' => '2012-01-12 23:53:30', 'breakpoint' => 1, 'migration_name' => 'TestMigration1'], + '20120116183504' => ['version' => '20120116183504', 'start_time' => '2012-01-17 18:35:04', 'end_time' => '2012-01-17 18:35:04', 'breakpoint' => 1, 'migration_name' => 'TestMigration2'], + ], + '20120111235330', + 'Breakpoint reached. Further rollbacks inhibited.', + ], + 'Rollback to last created version which was also the last to be executed - breakpoint set on all migrations' => + [ + [ + '20120111235330' => ['version' => '20120111235330', 'start_time' => '2012-01-12 23:53:30', 'end_time' => '2012-01-12 23:53:30', 'breakpoint' => 1, 'migration_name' => 'TestMigration1'], + '20120116183504' => ['version' => '20120116183504', 'start_time' => '2012-01-17 18:35:04', 'end_time' => '2012-01-17 18:35:04', 'breakpoint' => 1, 'migration_name' => 'TestMigration2'], + ], + '20120116183504', + 'No migrations to rollback', + ], + 'Rollback all versions (ie. rollback to version 0) - breakpoint set on all migrations' => + [ + [ + '20120111235330' => ['version' => '20120111235330', 'start_time' => '2012-01-12 23:53:30', 'end_time' => '2012-01-12 23:53:30', 'breakpoint' => 1, 'migration_name' => 'TestMigration1'], + '20120116183504' => ['version' => '20120116183504', 'start_time' => '2012-01-17 18:35:04', 'end_time' => '2012-01-17 18:35:04', 'breakpoint' => 1, 'migration_name' => 'TestMigration2'], + ], + '0', + ['Breakpoint reached. Further rollbacks inhibited.'], + ], + 'Rollback to second created version which was the first to be executed - breakpoint set on all migrations' => + [ + [ + '20120116183504' => ['version' => '20120116183504', 'start_time' => '2012-01-10 18:35:04', 'end_time' => '2012-01-10 18:35:04', 'breakpoint' => 1, 'migration_name' => 'TestMigration1'], + '20120111235330' => ['version' => '20120111235330', 'start_time' => '2012-01-12 23:53:30', 'end_time' => '2012-01-12 23:53:30', 'breakpoint' => 1, 'migration_name' => 'TestMigration2'], + ], + '20120116183504', + 'Breakpoint reached. Further rollbacks inhibited.', + ], + 'Rollback to first created version which was the second to be executed - breakpoint set on all migrations' => + [ + [ + '20120116183504' => ['version' => '20120116183504', 'start_time' => '2012-01-17 18:35:04', 'end_time' => '2012-01-17 18:35:04', 'breakpoint' => 1, 'migration_name' => 'TestMigration1'], + '20120111235330' => ['version' => '20120111235330', 'start_time' => '2012-01-20 23:53:30', 'end_time' => '2012-01-20 23:53:30', 'breakpoint' => 1, 'migration_name' => 'TestMigration2'], + ], + '20120111235330', + 'No migrations to rollback', + ], + 'Rollback last executed version which was also the last created version - breakpoint set on all migrations' => + [ + [ + '20120111235330' => ['version' => '20120111235330', 'start_time' => '2012-01-12 23:53:30', 'end_time' => '2012-01-12 23:53:30', 'breakpoint' => 1, 'migration_name' => 'TestMigration1'], + '20120116183504' => ['version' => '20120116183504', 'start_time' => '2012-01-17 18:35:04', 'end_time' => '2012-01-17 18:35:04', 'breakpoint' => 1, 'migration_name' => 'TestMigration2'], + ], + null, + 'Breakpoint reached. Further rollbacks inhibited.', + ], + 'Rollback last executed version which was the first created version - breakpoint set on all migrations' => + [ + [ + '20120116183504' => ['version' => '20120116183504', 'start_time' => '2012-01-17 18:35:04', 'end_time' => '2012-01-17 18:35:04', 'breakpoint' => 1, 'migration_name' => 'TestMigration1'], + '20120111235330' => ['version' => '20120111235330', 'start_time' => '2012-01-20 23:53:30', 'end_time' => '2012-01-20 23:53:30', 'breakpoint' => 1, 'migration_name' => 'TestMigration2'], + ], + null, + 'Breakpoint reached. Further rollbacks inhibited.', + ], + 'Rollback to non-existing version - breakpoint set on all migrations' => + [ + [ + '20120116183504' => ['version' => '20120116183504', 'start_time' => '2012-01-17 18:35:04', 'end_time' => '2012-01-17 18:35:04', 'breakpoint' => 1, 'migration_name' => 'TestMigration1'], + '20120111235330' => ['version' => '20120111235330', 'start_time' => '2012-01-20 23:53:30', 'end_time' => '2012-01-20 23:53:30', 'breakpoint' => 1, 'migration_name' => 'TestMigration2'], + ], + '20121225000000', + 'Target version (20121225000000) not found', + ], + 'Rollback to missing version - breakpoint set on all migrations' => + [ + [ + '20111225000000' => ['version' => '20111225000000', 'start_time' => '2011-12-25 00:00:00', 'end_time' => '2011-12-25 00:00:00', 'breakpoint' => 1, 'migration_name' => 'TestMigration1'], + '20120116183504' => ['version' => '20120116183504', 'start_time' => '2012-01-17 18:35:04', 'end_time' => '2012-01-17 18:35:04', 'breakpoint' => 1, 'migration_name' => 'TestMigration2'], + '20120111235330' => ['version' => '20120111235330', 'start_time' => '2012-01-20 23:53:30', 'end_time' => '2012-01-20 23:53:30', 'breakpoint' => 1, 'migration_name' => 'TestMigration3'], + ], + '20121225000000', + 'Target version (20121225000000) not found', + ], + ]; + } + + /** + * Migration lists, version order configuration and expected output. + * + * @return array + */ + public static function rollbackLastDataProvider(): array + { + return [ + + // No breakpoints set + + 'Rollback to last migration with creation time version ordering - no breakpoints set' => + [ + [ + '20120111235330' => ['version' => '20120111235330', 'start_time' => '2012-01-12 23:53:30', 'breakpoint' => 0], + '20120116183504' => ['version' => '20120116183504', 'start_time' => '2012-01-16 18:35:04', 'breakpoint' => 0], + ], + Config::VERSION_ORDER_CREATION_TIME, + '== 20120116183504 TestMigration2: reverted', + ], + + 'Rollback to last migration with execution time version ordering - no breakpoints set' => + [ + [ + '20120116183504' => ['version' => '20120116183504', 'start_time' => '2012-01-10 18:35:04', 'breakpoint' => 0], + '20120111235330' => ['version' => '20120111235330', 'start_time' => '2012-01-12 23:53:30', 'breakpoint' => 0], + ], + Config::VERSION_ORDER_EXECUTION_TIME, + '== 20120111235330 TestMigration: reverted', + ], + + 'Rollback to last migration with missing last migration and creation time version ordering - no breakpoints set' => + [ + [ + '20120111235330' => ['version' => '20120111235330', 'start_time' => '2012-01-12 23:53:30', 'breakpoint' => 0], + '20120116183504' => ['version' => '20120116183504', 'start_time' => '2012-01-16 18:35:04', 'breakpoint' => 0], + '20130101225232' => ['version' => '20130101225232', 'start_time' => '2013-01-01 22:52:32', 'breakpoint' => 0], + ], + Config::VERSION_ORDER_CREATION_TIME, + '== 20120116183504 TestMigration2: reverted', + ], + + 'Rollback to last migration with missing last migration and execution time version ordering - no breakpoints set' => + [ + [ + '20120116183504' => ['version' => '20120116183504', 'start_time' => '2012-01-10 18:35:04', 'breakpoint' => 0], + '20120111235330' => ['version' => '20120111235330', 'start_time' => '2012-01-12 23:53:30', 'breakpoint' => 0], + '20130101225232' => ['version' => '20130101225232', 'start_time' => '2013-01-01 22:52:32', 'breakpoint' => 0], + ], + Config::VERSION_ORDER_EXECUTION_TIME, + '== 20120111235330 TestMigration: reverted', + ], + + // Breakpoint set on last migration + + 'Rollback to last migration with creation time version ordering - breakpoint set on last created migration' => + [ + [ + '20120111235330' => ['version' => '20120111235330', 'start_time' => '2012-01-12 23:53:30', 'breakpoint' => 0], + '20120116183504' => ['version' => '20120116183504', 'start_time' => '2012-01-16 18:35:04', 'breakpoint' => 1], + ], + Config::VERSION_ORDER_CREATION_TIME, + 'Breakpoint reached. Further rollbacks inhibited.', + ], + + 'Rollback to last migration with creation time version ordering - breakpoint set on last executed migration' => + [ + [ + '20120111235330' => ['version' => '20120111235330', 'start_time' => '2012-01-12 23:53:30', 'breakpoint' => 1], + '20120116183504' => ['version' => '20120116183504', 'start_time' => '2012-01-16 18:35:04', 'breakpoint' => 0], + ], + Config::VERSION_ORDER_CREATION_TIME, + '== 20120116183504 TestMigration2: reverted', + ], + + 'Rollback to last migration with missing last migration and creation time version ordering - breakpoint set on last non-missing created migration' => + [ + [ + '20120111235330' => ['version' => '20120111235330', 'start_time' => '2012-01-12 23:53:30', 'breakpoint' => 0], + '20120116183504' => ['version' => '20120116183504', 'start_time' => '2012-01-16 18:35:04', 'breakpoint' => 1], + '20130101225232' => ['version' => '20130101225232', 'start_time' => '2013-01-01 22:52:32', 'breakpoint' => 0], + ], + Config::VERSION_ORDER_CREATION_TIME, + 'Breakpoint reached. Further rollbacks inhibited.', + ], + + 'Rollback to last migration with missing last migration and execution time version ordering - breakpoint set on last non-missing executed migration' => + [ + [ + '20120116183504' => ['version' => '20120116183504', 'start_time' => '2012-01-10 18:35:04', 'breakpoint' => 0], + '20120111235330' => ['version' => '20120111235330', 'start_time' => '2012-01-12 23:53:30', 'breakpoint' => 1], + '20130101225232' => ['version' => '20130101225232', 'start_time' => '2013-01-01 22:52:32', 'breakpoint' => 0], + ], + Config::VERSION_ORDER_EXECUTION_TIME, + 'Breakpoint reached. Further rollbacks inhibited.', + ], + + 'Rollback to last migration with missing last migration and creation time version ordering - breakpoint set on missing migration' => + [ + [ + '20120111235330' => ['version' => '20120111235330', 'start_time' => '2012-01-12 23:53:30', 'breakpoint' => 0], + '20120116183504' => ['version' => '20120116183504', 'start_time' => '2012-01-16 18:35:04', 'breakpoint' => 0], + '20130101225232' => ['version' => '20130101225232', 'start_time' => '2013-01-01 22:52:32', 'breakpoint' => 1], + ], + Config::VERSION_ORDER_CREATION_TIME, + '== 20120116183504 TestMigration2: reverted', + ], + + 'Rollback to last migration with missing last migration and execution time version ordering - breakpoint set on missing migration' => + [ + [ + '20120116183504' => ['version' => '20120116183504', 'start_time' => '2012-01-10 18:35:04', 'breakpoint' => 0], + '20120111235330' => ['version' => '20120111235330', 'start_time' => '2012-01-12 23:53:30', 'breakpoint' => 0], + '20130101225232' => ['version' => '20130101225232', 'start_time' => '2013-01-01 22:52:32', 'breakpoint' => 1], + ], + Config::VERSION_ORDER_EXECUTION_TIME, + '== 20120111235330 TestMigration: reverted', + ], + + // Breakpoint set on all migrations + + 'Rollback to last migration with creation time version ordering - breakpoint set on all migrations' => + [ + [ + '20120111235330' => ['version' => '20120111235330', 'start_time' => '2012-01-12 23:53:30', 'breakpoint' => 1], + '20120116183504' => ['version' => '20120116183504', 'start_time' => '2012-01-16 18:35:04', 'breakpoint' => 1], + ], + Config::VERSION_ORDER_CREATION_TIME, + 'Breakpoint reached. Further rollbacks inhibited.', + ], + + 'Rollback to last migration with missing last migration and creation time version ordering - breakpoint set on all migrations' => + [ + [ + '20120111235330' => ['version' => '20120111235330', 'start_time' => '2012-01-12 23:53:30', 'breakpoint' => 1], + '20120116183504' => ['version' => '20120116183504', 'start_time' => '2012-01-16 18:35:04', 'breakpoint' => 1], + '20130101225232' => ['version' => '20130101225232', 'start_time' => '2013-01-01 22:52:32', 'breakpoint' => 1], + ], + Config::VERSION_ORDER_CREATION_TIME, + 'Breakpoint reached. Further rollbacks inhibited.', + ], + + 'Rollback to last migration with missing last migration and execution time version ordering - breakpoint set on all migrations' => + [ + [ + '20120116183504' => ['version' => '20120116183504', 'start_time' => '2012-01-10 18:35:04', 'breakpoint' => 1], + '20120111235330' => ['version' => '20120111235330', 'start_time' => '2012-01-12 23:53:30', 'breakpoint' => 1], + '20130101225232' => ['version' => '20130101225232', 'start_time' => '2013-01-01 22:52:32', 'breakpoint' => 1], + ], + Config::VERSION_ORDER_EXECUTION_TIME, + 'Breakpoint reached. Further rollbacks inhibited.', + ], + ]; + } + + public function testExecuteSeedWorksAsExpected(): void + { + // stub environment + $envStub = $this->getMockBuilder(Environment::class) + ->setConstructorArgs(['mockenv', []]) + ->getMock(); + $this->manager->setEnvironment($envStub); + $this->manager->seed(); + + $output = join("\n", $this->out->messages()); + $this->assertStringContainsString('GSeeder', $output); + $this->assertStringContainsString('PostSeeder', $output); + $this->assertStringContainsString('UserSeeder', $output); + } + + public function testExecuteASingleSeedWorksAsExpected(): void + { + // stub environment + $envStub = $this->getMockBuilder(Environment::class) + ->setConstructorArgs(['mockenv', []]) + ->getMock(); + $this->manager->setEnvironment($envStub); + $this->manager->seed('UserSeeder'); + $output = join("\n", $this->out->messages()); + $this->assertStringContainsString('UserSeeder', $output); + } + + public function testExecuteANonExistentSeedWorksAsExpected(): void + { + // stub environment + $envStub = $this->getMockBuilder(Environment::class) + ->setConstructorArgs(['mockenv', []]) + ->getMock(); + $this->manager->setEnvironment($envStub); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The seed class "NonExistentSeeder" does not exist'); + + $this->manager->seed('NonExistentSeeder'); + } + + public function testOrderSeeds(): void + { + $seeds = array_values($this->manager->getSeeds()); + $this->assertInstanceOf('UserSeeder', $seeds[0]); + $this->assertInstanceOf('GSeeder', $seeds[1]); + $this->assertInstanceOf('PostSeeder', $seeds[2]); + } + + public function testSeedWillNotBeExecuted(): void + { + // stub environment + $envStub = $this->getMockBuilder(Environment::class) + ->setConstructorArgs(['mockenv', []]) + ->getMock(); + $this->manager->setEnvironment($envStub); + $this->manager->seed('UserSeederNotExecuted'); + + $output = join("\n", $this->out->messages()); + $this->assertStringContainsString('skipped', $output); + } + + public function testGettingInputObject(): void + { + $migrations = $this->manager->getMigrations(); + $seeds = $this->manager->getSeeds(); + foreach ($migrations as $migration) { + $this->assertInstanceOf(InputInterface::class, $migration->getInput()); + } + foreach ($seeds as $seed) { + $this->assertInstanceOf(InputInterface::class, $migration->getInput()); + } + } + + public function testGettingIo(): void + { + $migrations = $this->manager->getMigrations(); + $seeds = $this->manager->getSeeds(); + $io = $this->manager->getIo(); + $this->assertInstanceOf(ConsoleIo::class, $io); + + foreach ($migrations as $migration) { + $this->assertInstanceOf(OutputAdapter::class, $migration->getOutput()); + } + foreach ($seeds as $seed) { + $this->assertInstanceOf(OutputAdapter::class, $seed->getOutput()); + } + } + + public function testReversibleMigrationsWorkAsExpected(): void + { + $this->markTestIncomplete('Need to finish updating adapters to use Connection'); + $adapter = $this->prepareEnvironment([ + 'migrations' => ROOT . '/config/Reversiblemigrations', + ]); + + // migrate to the latest version + $this->manager->migrate(); + + // ensure up migrations worked + $this->assertFalse($adapter->hasTable('info')); + $this->assertTrue($adapter->hasTable('statuses')); + $this->assertTrue($adapter->hasTable('users')); + $this->assertTrue($adapter->hasTable('just_logins')); + $this->assertFalse($adapter->hasTable('user_logins')); + $this->assertTrue($adapter->hasColumn('users', 'biography')); + $this->assertTrue($adapter->hasForeignKey('just_logins', ['user_id'])); + $this->assertTrue($adapter->hasTable('change_direction_test')); + $this->assertTrue($adapter->hasColumn('change_direction_test', 'subthing')); + $this->assertEquals( + 2, + count($adapter->fetchAll('SELECT * FROM change_direction_test WHERE subthing IS NOT NULL')) + ); + + // revert all changes to the first + $this->manager->rollback('20121213232502'); + + // ensure reversed migrations worked + $this->assertTrue($adapter->hasTable('info')); + $this->assertFalse($adapter->hasTable('statuses')); + $this->assertFalse($adapter->hasTable('user_logins')); + $this->assertFalse($adapter->hasTable('just_logins')); + $this->assertTrue($adapter->hasColumn('users', 'bio')); + $this->assertFalse($adapter->hasForeignKey('user_logins', ['user_id'])); + $this->assertFalse($adapter->hasTable('change_direction_test')); + + // revert all changes + $this->manager->rollback('0'); + + $this->assertFalse($adapter->hasTable('info')); + $this->assertFalse($adapter->hasTable('users')); + } + + public function testReversibleMigrationWithIndexConflict(): void + { + $this->markTestIncomplete('Need to finish updating adapters to use Connection'); + if ($this->getDriverType() !== 'mysql') { + $this->markTestSkipped('Test requires mysql connection'); + } + $configArray = $this->getConfigArray(); + $adapter = $this->manager->getEnvironment()->getAdapter(); + + // override the migrations directory to use the reversible migrations + $configArray['paths']['migrations'] = ROOT . '/config/DropIndexRegression/'; + $config = new Config($configArray); + + // ensure the database is empty + $dbName = ConnectionManager::getConfig('test')['database'] ?? null; + $this->assertNotEmpty($dbName); + $adapter->dropDatabase($dbName); + $adapter->createDatabase($dbName); + $adapter->disconnect(); + + // migrate to the latest version + $this->manager->setConfig($config); + $this->manager->migrate(); + + // ensure up migrations worked + $this->assertTrue($adapter->hasTable('my_table')); + $this->assertTrue($adapter->hasTable('my_other_table')); + $this->assertTrue($adapter->hasColumn('my_table', 'entity_id')); + $this->assertTrue($adapter->hasForeignKey('my_table', ['entity_id'])); + + // revert all changes to the first + $this->manager->rollback('20121213232502'); + + // ensure reversed migrations worked + $this->assertTrue($adapter->hasTable('my_table')); + $this->assertTrue($adapter->hasTable('my_other_table')); + $this->assertTrue($adapter->hasColumn('my_table', 'entity_id')); + $this->assertFalse($adapter->hasForeignKey('my_table', ['entity_id'])); + $this->assertFalse($adapter->hasIndex('my_table', ['entity_id'])); + } + + public function testReversibleMigrationWithFKConflictOnTableDrop(): void + { + $this->markTestIncomplete('Need to finish updating adapters to use Connection'); + if ($this->getDriverType() !== 'mysql') { + $this->markTestSkipped('Test requires mysql'); + } + $configArray = $this->getConfigArray(); + $adapter = $this->manager->getEnvironment()->getAdapter(); + + // override the migrations directory to use the reversible migrations + $configArray['paths']['migrations'] = ROOT . '/config/DropTableWithFkRegression'; + $config = new Config($configArray); + + // ensure the database is empty + $dbName = ConnectionManager::getConfig('test')['database'] ?? null; + $this->assertNotEmpty($dbName); + $adapter->dropDatabase($dbName); + $adapter->createDatabase($dbName); + $adapter->disconnect(); + + // migrate to the latest version + $this->manager->setConfig($config); + $this->manager->migrate(); + + // ensure up migrations worked + $this->assertTrue($adapter->hasTable('orders')); + $this->assertTrue($adapter->hasTable('customers')); + $this->assertTrue($adapter->hasColumn('orders', 'order_date')); + $this->assertTrue($adapter->hasColumn('orders', 'customer_id')); + $this->assertTrue($adapter->hasForeignKey('orders', ['customer_id'])); + + // revert all changes to the first + $this->manager->rollback('20190928205056'); + + // ensure reversed migrations worked + $this->assertTrue($adapter->hasTable('orders')); + $this->assertTrue($adapter->hasColumn('orders', 'order_date')); + $this->assertFalse($adapter->hasColumn('orders', 'customer_id')); + $this->assertFalse($adapter->hasTable('customers')); + $this->assertFalse($adapter->hasForeignKey('orders', ['customer_id'])); + + $this->manager->rollback(); + $this->assertFalse($adapter->hasTable('orders')); + $this->assertFalse($adapter->hasTable('customers')); + } + + public function testBreakpointsTogglingOperateAsExpected(): void + { + $this->markTestIncomplete('Need to finish updating adapters to use Connection'); + if ($this->getDriverType() !== 'mysql') { + $this->markTestSkipped('Test requires mysql'); + } + $configArray = $this->getConfigArray(); + $adapter = $this->manager->getEnvironment()->getAdapter(); + + $config = new Config($configArray); + + // ensure the database is empty + $dbName = ConnectionManager::getConfig('test')['database'] ?? null; + $this->assertNotEmpty($dbName); + $adapter->dropDatabase($dbName); + $adapter->createDatabase($dbName); + $adapter->disconnect(); + + // migrate to the latest version + $this->manager->setConfig($config); + $this->manager->migrate(); + + // Get the versions + $originalVersions = $this->manager->getEnvironment()->getVersionLog(); + $this->assertEquals(0, reset($originalVersions)['breakpoint']); + $this->assertEquals(0, end($originalVersions)['breakpoint']); + + // Wait until the second has changed. + sleep(1); + + // Toggle the breakpoint on most recent migration + $this->manager->toggleBreakpoint(null); + + // ensure breakpoint is set + $firstToggle = $this->manager->getEnvironment()->getVersionLog(); + $this->assertEquals(0, reset($firstToggle)['breakpoint']); + $this->assertEquals(1, end($firstToggle)['breakpoint']); + + // ensure no other data has changed. + foreach ($originalVersions as $originalVersionKey => $originalVersion) { + foreach ($originalVersion as $column => $value) { + if (!is_numeric($column) && $column !== 'breakpoint') { + $this->assertEquals($value, $firstToggle[$originalVersionKey][$column]); + } + } + } + + // Wait until the second has changed. + sleep(1); + + // Toggle the breakpoint on most recent migration + $this->manager->toggleBreakpoint(null); + + // ensure breakpoint is set + $secondToggle = $this->manager->getEnvironment()->getVersionLog(); + $this->assertEquals(0, reset($secondToggle)['breakpoint']); + $this->assertEquals(0, end($secondToggle)['breakpoint']); + + // ensure no other data has changed. + foreach ($originalVersions as $originalVersionKey => $originalVersion) { + foreach ($originalVersion as $column => $value) { + if (!is_numeric($column) && $column !== 'breakpoint') { + $this->assertEquals($value, $secondToggle[$originalVersionKey][$column]); + } + } + } + + // Wait until the second has changed. + sleep(1); + + // Reset all breakpoints and toggle the most recent migration twice + $this->manager->removeBreakpoints(); + $this->manager->toggleBreakpoint(null); + $this->manager->toggleBreakpoint(null); + + // ensure breakpoint is not set + $resetVersions = $this->manager->getEnvironment()->getVersionLog(); + $this->assertEquals(0, reset($resetVersions)['breakpoint']); + $this->assertEquals(0, end($resetVersions)['breakpoint']); + + // ensure no other data has changed. + foreach ($originalVersions as $originalVersionKey => $originalVersion) { + foreach ($originalVersion as $column => $value) { + if (!is_numeric($column)) { + $this->assertEquals($value, $resetVersions[$originalVersionKey][$column]); + } + } + } + + // Wait until the second has changed. + sleep(1); + + // Set the breakpoint on the latest migration + $this->manager->setBreakpoint(null); + + // ensure breakpoint is set + $setLastVersions = $this->manager->getEnvironment()->getVersionLog(); + $this->assertEquals(0, reset($setLastVersions)['breakpoint']); + $this->assertEquals(1, end($setLastVersions)['breakpoint']); + + // ensure no other data has changed. + foreach ($originalVersions as $originalVersionKey => $originalVersion) { + foreach ($originalVersion as $column => $value) { + if (!is_numeric($column) && $column !== 'breakpoint') { + $this->assertEquals($value, $setLastVersions[$originalVersionKey][$column]); + } + } + } + + // Wait until the second has changed. + sleep(1); + + // Set the breakpoint on the first migration + $this->manager->setBreakpoint(reset($originalVersions)['version']); + + // ensure breakpoint is set + $setFirstVersion = $this->manager->getEnvironment()->getVersionLog(); + $this->assertEquals(1, reset($setFirstVersion)['breakpoint']); + $this->assertEquals(1, end($setFirstVersion)['breakpoint']); + + // ensure no other data has changed. + foreach ($originalVersions as $originalVersionKey => $originalVersion) { + foreach ($originalVersion as $column => $value) { + if (!is_numeric($column) && $column !== 'breakpoint') { + $this->assertEquals($value, $resetVersions[$originalVersionKey][$column]); + } + } + } + + // Wait until the second has changed. + sleep(1); + + // Unset the breakpoint on the latest migration + $this->manager->unsetBreakpoint(null); + + // ensure breakpoint is set + $unsetLastVersions = $this->manager->getEnvironment()->getVersionLog(); + $this->assertEquals(1, reset($unsetLastVersions)['breakpoint']); + $this->assertEquals(0, end($unsetLastVersions)['breakpoint']); + + // ensure no other data has changed. + foreach ($originalVersions as $originalVersionKey => $originalVersion) { + foreach ($originalVersion as $column => $value) { + if (!is_numeric($column) && $column !== 'breakpoint') { + $this->assertEquals($value, $unsetLastVersions[$originalVersionKey][$column]); + } + } + } + + // Wait until the second has changed. + sleep(1); + + // Unset the breakpoint on the first migration + $this->manager->unsetBreakpoint(reset($originalVersions)['version']); + + // ensure breakpoint is set + $unsetFirstVersion = $this->manager->getEnvironment()->getVersionLog(); + $this->assertEquals(0, reset($unsetFirstVersion)['breakpoint']); + $this->assertEquals(0, end($unsetFirstVersion)['breakpoint']); + + // ensure no other data has changed. + foreach ($originalVersions as $originalVersionKey => $originalVersion) { + foreach ($originalVersion as $column => $value) { + if (!is_numeric($column)) { + $this->assertEquals($value, $unsetFirstVersion[$originalVersionKey][$column]); + } + } + } + } + + public function testBreakpointWithInvalidVersion(): void + { + $this->markTestIncomplete('Need to finish updating adapters to use Connection'); + if ($this->getDriverType() !== 'mysql') { + $this->markTestSkipped('test requires mysql'); + } + $configArray = $this->getConfigArray(); + $adapter = $this->manager->getEnvironment()->getAdapter(); + + $config = new Config($configArray); + + // ensure the database is empty + $dbName = ConnectionManager::getConfig('test')['database'] ?? null; + $this->assertNotEmpty($dbName); + $adapter->dropDatabase($dbName); + $adapter->createDatabase($dbName); + $adapter->disconnect(); + + // migrate to the latest version + $this->manager->setConfig($config); + $this->manager->migrate(); + $this->manager->getOutput()->setDecorated(false); + + // set breakpoint on most recent migration + $this->manager->toggleBreakpoint(999); + + rewind($this->manager->getOutput()->getStream()); + $output = stream_get_contents($this->manager->getOutput()->getStream()); + + $this->assertStringContainsString('is not a valid version', $output); + } + + public function testPostgresFullMigration(): void + { + if ($this->getDriverType() !== 'postgres') { + $this->markTestSkipped('Test requires postgres'); + } + + $adapter = $this->prepareEnvironment([ + 'migrations' => ROOT . '/config/Postgres', + ]); + $adapter->connect(); + // migrate to the latest version + $this->manager->migrate(); + + $this->assertTrue($adapter->hasTable('articles')); + $this->assertTrue($adapter->hasTable('categories')); + $this->assertTrue($adapter->hasTable('composite_pks')); + $this->assertTrue($adapter->hasTable('orders')); + $this->assertTrue($adapter->hasTable('products')); + $this->assertTrue($adapter->hasTable('special_pks')); + $this->assertTrue($adapter->hasTable('special_tags')); + $this->assertTrue($adapter->hasTable('users')); + + $this->manager->rollback('all'); + + $this->assertFalse($adapter->hasTable('articles')); + $this->assertFalse($adapter->hasTable('categories')); + $this->assertFalse($adapter->hasTable('composite_pks')); + $this->assertFalse($adapter->hasTable('orders')); + $this->assertFalse($adapter->hasTable('products')); + $this->assertFalse($adapter->hasTable('special_pks')); + $this->assertFalse($adapter->hasTable('special_tags')); + $this->assertFalse($adapter->hasTable('users')); + } + + public function testMigrationWithDropColumnAndForeignKeyAndIndex(): void + { + if ($this->getDriverType() !== 'mysql') { + $this->markTestSkipped('Test requires mysql'); + } + $configArray = $this->getConfigArray(); + $adapter = $this->manager->getEnvironment()->getAdapter(); + + // override the migrations directory to use the reversible migrations + $configArray['paths']['migrations'] = ROOT . '/config/DropColumnFkIndexRegression'; + $config = new Config($configArray); + + // ensure the database is empty + $dbName = ConnectionManager::getConfig('test')['database'] ?? null; + $this->assertNotEmpty($dbName); + $adapter->dropDatabase($dbName); + $adapter->createDatabase($dbName); + $adapter->disconnect(); + $adapter->connect(); + + $this->manager->setConfig($config); + $this->manager->migrate(20190928205056); + + $this->assertTrue($adapter->hasTable('table1')); + $this->assertTrue($adapter->hasTable('table2')); + $this->assertTrue($adapter->hasTable('table3')); + $this->assertTrue($adapter->hasColumn('table1', 'table2_id')); + $this->assertTrue($adapter->hasForeignKey('table1', ['table2_id'], 'table1_table2_id')); + $this->assertTrue($adapter->hasIndexByName('table1', 'table1_table2_id')); + $this->assertTrue($adapter->hasColumn('table1', 'table3_id')); + $this->assertTrue($adapter->hasForeignKey('table1', ['table3_id'], 'table1_table3_id')); + $this->assertTrue($adapter->hasIndexByName('table1', 'table1_table3_id')); + + // Run the next migration + $this->manager->migrate(); + $this->assertTrue($adapter->hasTable('table1')); + $this->assertTrue($adapter->hasTable('table2')); + $this->assertTrue($adapter->hasTable('table3')); + $this->assertTrue($adapter->hasColumn('table1', 'table2_id')); + $this->assertTrue($adapter->hasForeignKey('table1', ['table2_id'], 'table1_table2_id')); + $this->assertTrue($adapter->hasIndexByName('table1', 'table1_table2_id')); + $this->assertFalse($adapter->hasColumn('table1', 'table3_id')); + $this->assertFalse($adapter->hasForeignKey('table1', ['table3_id'], 'table1_table3_id')); + $this->assertFalse($adapter->hasIndexByName('table1', 'table1_table3_id')); + + // rollback + $this->manager->rollback(); + $this->manager->rollback(); + + // ensure reversed migrations worked + $this->assertTrue($adapter->hasTable('table1')); + $this->assertTrue($adapter->hasTable('table2')); + $this->assertTrue($adapter->hasTable('table3')); + $this->assertFalse($adapter->hasColumn('table1', 'table2_id')); + $this->assertFalse($adapter->hasForeignKey('table1', ['table2_id'], 'table1_table2_id')); + $this->assertFalse($adapter->hasIndexByName('table1', 'table1_table2_id')); + $this->assertFalse($adapter->hasColumn('table1', 'table3_id')); + $this->assertFalse($adapter->hasForeignKey('table1', ['table3_id'], 'table1_table3_id')); + $this->assertFalse($adapter->hasIndexByName('table1', 'table1_table3_id')); + } + + public function testInvalidVersionBreakpoint(): void + { + // stub environment + $envStub = $this->getMockBuilder(Environment::class) + ->setConstructorArgs(['mockenv', []]) + ->getMock(); + $envStub->expects($this->once()) + ->method('getVersionLog') + ->will($this->returnValue( + [ + '20120111235330' => + [ + 'version' => '20120111235330', + 'start_time' => '2012-01-11 23:53:36', + 'end_time' => '2012-01-11 23:53:37', + 'migration_name' => '', + 'breakpoint' => '0', + ], + ] + )); + + $this->manager->setEnvironment($envStub); + $this->manager->setBreakpoint(20120133235330); + + $output = join("\n", $this->out->messages()); + $this->assertStringContainsString('warning 20120133235330 is not a valid version', $output); + } + + public function testMigrationWillNotBeExecuted(): void + { + $this->markTestIncomplete('Need to finish updating adapters to use Connection'); + if ($this->getDriverType() !== 'mysql') { + $this->markTestSkipped('Test requires mysql'); + } + $configArray = $this->getConfigArray(); + $adapter = $this->manager->getEnvironment()->getAdapter(); + + // override the migrations directory to use the should execute migrations + $configArray['paths']['migrations'] = ROOT . '/config/ShouldExecute/'; + $config = new Config($configArray); + + // Run the migration with shouldExecute returning false: the table should not be created + $this->manager->setConfig($config); + $this->manager->migrate(20201207205056); + + $this->assertFalse($adapter->hasTable('info')); + + // Run the migration with shouldExecute returning true: the table should be created + $this->manager->migrate(20201207205057); + + $this->assertTrue($adapter->hasTable('info')); + } +} diff --git a/tests/TestCase/MigrationsTest.php b/tests/TestCase/MigrationsTest.php index 16d3c1de..b2098c74 100644 --- a/tests/TestCase/MigrationsTest.php +++ b/tests/TestCase/MigrationsTest.php @@ -15,13 +15,12 @@ use Cake\Core\Configure; use Cake\Core\Plugin; +use Cake\Database\Driver\Sqlserver; use Cake\Datasource\ConnectionManager; use Cake\TestSuite\TestCase; use Exception; use InvalidArgumentException; -use Migrations\CakeAdapter; use Migrations\Migrations; -use Phinx\Config\FeatureFlags; use Phinx\Db\Adapter\WrapperInterface; use function Cake\Core\env; @@ -91,6 +90,7 @@ public function setUp(): void // We can't wipe all tables as we'l break other tests. $this->Connection->execute('DROP TABLE IF EXISTS numbers'); $this->Connection->execute('DROP TABLE IF EXISTS letters'); + $this->Connection->execute('DROP TABLE IF EXISTS stores'); $allTables = $this->Connection->getSchemaCollection()->listTables(); if (in_array('phinxlog', $allTables)) { @@ -117,17 +117,26 @@ public function tearDown(): void unlink($file); } } + } - FeatureFlags::setFlagsFromConfig(Configure::read('Migrations')); + public static function backendProvider(): array + { + return [ + ['builtin'], + ['phinx'], + ]; } /** * Tests the status method * + * @dataProvider backendProvider * @return void */ - public function testStatus() + public function testStatus(string $backend) { + Configure::write('Migrations.backend', $backend); + $result = $this->migrations->status(); $expected = [ [ @@ -152,22 +161,25 @@ public function testStatus() ], ]; $this->assertEquals($expected, $result); - - $adapter = $this->migrations - ->getManager() - ->getEnvironment('default') - ->getAdapter(); - - $this->assertInstanceOf(CakeAdapter::class, $adapter); } /** * Tests the migrations and rollbacks * + * @dataProvider backendProvider * @return void */ - public function testMigrateAndRollback() + public function testMigrateAndRollback($backend) { + Configure::write('Migrations.backend', $backend); + + if ($this->Connection->getDriver() instanceof Sqlserver) { + // TODO This test currently fails in CI because numbers table + // has no columns in sqlserver. This table should have columns as the + // migration that creates the table adds columns. + $this->markTestSkipped('Incompatible with sqlserver right now.'); + } + // Migrate all $migrate = $this->migrations->migrate(); $this->assertTrue($migrate); @@ -218,7 +230,11 @@ public function testMigrateAndRollback() $expected = ['id', 'name', 'created', 'modified']; $this->assertEquals($expected, $columns); $createdColumn = $storesTable->getSchema()->getColumn('created'); - $this->assertEquals('CURRENT_TIMESTAMP', $createdColumn['default']); + $expected = 'CURRENT_TIMESTAMP'; + if ($this->Connection->getDriver() instanceof Sqlserver) { + $expected = 'getdate()'; + } + $this->assertEquals($expected, $createdColumn['default']); // Rollback last $rollback = $this->migrations->rollback(); @@ -239,11 +255,14 @@ public function testMigrateAndRollback() /** * Tests the collation table behavior when using MySQL * + * @dataProvider backendProvider * @return void */ - public function testCreateWithEncoding() + public function testCreateWithEncoding($backend) { - $this->skipIf(env('DB') !== 'mysql'); + Configure::write('Migrations.backend', $backend); + + $this->skipIf(env('DB') !== 'mysql', 'Requires MySQL'); $migrate = $this->migrations->migrate(); $this->assertTrue($migrate); @@ -265,10 +284,13 @@ public function testCreateWithEncoding() * Tests calling Migrations::markMigrated without params marks everything * as migrated * + * @dataProvider backendProvider * @return void */ - public function testMarkMigratedAll() + public function testMarkMigratedAll($backend) { + Configure::write('Migrations.backend', $backend); + $markMigrated = $this->migrations->markMigrated(); $this->assertTrue($markMigrated); $status = $this->migrations->status(); @@ -302,10 +324,13 @@ public function testMarkMigratedAll() * string 'all' marks everything * as migrated * + * @dataProvider backendProvider * @return void */ - public function testMarkMigratedAllAsVersion() + public function testMarkMigratedAllAsVersion($backend) { + Configure::write('Migrations.backend', $backend); + $markMigrated = $this->migrations->markMigrated('all'); $this->assertTrue($markMigrated); $status = $this->migrations->status(); @@ -338,10 +363,13 @@ public function testMarkMigratedAllAsVersion() * Tests calling Migrations::markMigrated with the target option will mark * only up to that one * + * @dataProvider backendProvider * @return void */ - public function testMarkMigratedTarget() + public function testMarkMigratedTarget($backend) { + Configure::write('Migrations.backend', $backend); + $markMigrated = $this->migrations->markMigrated(null, ['target' => '20150704160200']); $this->assertTrue($markMigrated); $status = $this->migrations->status(); @@ -380,10 +408,13 @@ public function testMarkMigratedTarget() * Tests calling Migrations::markMigrated with the target option set to a * non-existent target will throw an exception * + * @dataProvider backendProvider * @return void */ - public function testMarkMigratedTargetError() + public function testMarkMigratedTargetError($backend) { + Configure::write('Migrations.backend', $backend); + $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Migration `20150704160610` was not found !'); $this->migrations->markMigrated(null, ['target' => '20150704160610']); @@ -393,10 +424,13 @@ public function testMarkMigratedTargetError() * Tests calling Migrations::markMigrated with the target option with the exclude * option will mark only up to that one, excluding it * + * @dataProvider backendProvider * @return void */ - public function testMarkMigratedTargetExclude() + public function testMarkMigratedTargetExclude($backend) { + Configure::write('Migrations.backend', $backend); + $markMigrated = $this->migrations->markMigrated(null, ['target' => '20150704160200', 'exclude' => true]); $this->assertTrue($markMigrated); $status = $this->migrations->status(); @@ -435,10 +469,13 @@ public function testMarkMigratedTargetExclude() * Tests calling Migrations::markMigrated with the target option with the only * option will mark only that specific migrations * + * @dataProvider backendProvider * @return void */ - public function testMarkMigratedTargetOnly() + public function testMarkMigratedTargetOnly($backend) { + Configure::write('Migrations.backend', $backend); + $markMigrated = $this->migrations->markMigrated(null, ['target' => '20150724233100', 'only' => true]); $this->assertTrue($markMigrated); $status = $this->migrations->status(); @@ -477,10 +514,13 @@ public function testMarkMigratedTargetOnly() * Tests calling Migrations::markMigrated with the target option, the only option * and the exclude option will throw an exception * + * @dataProvider backendProvider * @return void */ - public function testMarkMigratedTargetExcludeOnly() + public function testMarkMigratedTargetExcludeOnly($backend) { + Configure::write('Migrations.backend', $backend); + $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('You should use `exclude` OR `only` (not both) along with a `target` argument'); $this->migrations->markMigrated(null, ['target' => '20150724233100', 'only' => true, 'exclude' => true]); @@ -490,10 +530,13 @@ public function testMarkMigratedTargetExcludeOnly() * Tests calling Migrations::markMigrated with the target option with the exclude * option will mark only up to that one, excluding it * + * @dataProvider backendProvider * @return void */ - public function testMarkMigratedVersion() + public function testMarkMigratedVersion($backend) { + Configure::write('Migrations.backend', $backend); + $markMigrated = $this->migrations->markMigrated(20150704160200); $this->assertTrue($markMigrated); $status = $this->migrations->status(); @@ -532,10 +575,13 @@ public function testMarkMigratedVersion() * Tests that calling the migrations methods while passing * parameters will override the default ones * + * @dataProvider backendProvider * @return void */ - public function testOverrideOptions() + public function testOverrideOptions($backend) { + Configure::write('Migrations.backend', $backend); + $result = $this->migrations->status(); $expectedStatus = [ [ @@ -568,6 +614,11 @@ public function testOverrideOptions() 'id' => '20150416223600', 'name' => 'MarkMigratedTest', ], + [ + 'status' => 'down', + 'id' => '20240309223600', + 'name' => 'MarkMigratedTestSecond', + ], ]; $this->assertEquals($expected, $result); @@ -575,18 +626,19 @@ public function testOverrideOptions() $this->assertTrue($migrate); $result = $this->migrations->status(['source' => 'Migrations']); $expected[0]['status'] = 'up'; + $expected[1]['status'] = 'up'; $this->assertEquals($expected, $result); $rollback = $this->migrations->rollback(['source' => 'Migrations']); $this->assertTrue($rollback); $result = $this->migrations->status(['source' => 'Migrations']); - $expected[0]['status'] = 'down'; + $expected[0]['status'] = 'up'; + $expected[1]['status'] = 'down'; $this->assertEquals($expected, $result); $migrate = $this->migrations->markMigrated(20150416223600, ['source' => 'Migrations']); $this->assertTrue($migrate); $result = $this->migrations->status(['source' => 'Migrations']); - $expected[0]['status'] = 'up'; $this->assertEquals($expected, $result); } @@ -594,10 +646,13 @@ public function testOverrideOptions() * Tests that calling the migrations methods while passing the ``date`` * parameter works as expected * + * @dataProvider backendProvider * @return void */ - public function testMigrateDateOption() + public function testMigrateDateOption($backend) { + Configure::write('Migrations.backend', $backend); + // If we want to migrate to a date before the first first migration date, // we should not migrate anything $this->migrations->migrate(['date' => '20140705']); @@ -770,10 +825,13 @@ public function testMigrateDateOption() /** * Tests seeding the database * + * @dataProvider backendProvider * @return void */ - public function testSeed() + public function testSeed($backend) { + Configure::write('Migrations.backend', $backend); + $this->migrations->migrate(); $seed = $this->migrations->seed(['source' => 'Seeds']); $this->assertTrue($seed); @@ -846,10 +904,13 @@ public function testSeed() /** * Tests seeding the database with seeder * + * @dataProvider backendProvider * @return void */ - public function testSeedOneSeeder() + public function testSeedOneSeeder($backend) { + Configure::write('Migrations.backend', $backend); + $this->migrations->migrate(); $seed = $this->migrations->seed(['source' => 'AltSeeds', 'seed' => 'AnotherNumbersSeed']); @@ -895,10 +956,13 @@ public function testSeedOneSeeder() /** * Tests seeding the database with seeder * + * @dataProvider backendProvider * @return void */ - public function testSeedCallSeeder() + public function testSeedCallSeeder($backend) { + Configure::write('Migrations.backend', $backend); + $this->migrations->migrate(); $seed = $this->migrations->seed(['source' => 'CallSeeds', 'seed' => 'DatabaseSeed']); @@ -956,15 +1020,33 @@ public function testSeedCallSeeder() /** * Tests that requesting a unexistant seed throws an exception * + * @dataProvider backendProvider * @return void */ - public function testSeedWrongSeed() + public function testSeedWrongSeed($backend) { + Configure::write('Migrations.backend', $backend); + $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('The seed class "DerpSeed" does not exist'); $this->migrations->seed(['source' => 'AltSeeds', 'seed' => 'DerpSeed']); } + /** + * Tests migrating the baked snapshots with builtin backend + * + * @dataProvider snapshotMigrationsProvider + * @param string $basePath Snapshot file path + * @param string $filename Snapshot file name + * @param array $flags Feature flags + * @return void + */ + public function testMigrateSnapshotsBuiltin(string $basePath, string $filename, array $flags = []): void + { + Configure::write('Migrations.backend', 'builtin'); + $this->runMigrateSnapshots($basePath, $filename, $flags); + } + /** * Tests migrating the baked snapshots * @@ -974,8 +1056,20 @@ public function testSeedWrongSeed() * @param array $flags Feature flags * @return void */ - public function testMigrateSnapshots(string $basePath, string $filename, array $flags = []): void + public function testMigrateSnapshotsPhinx(string $basePath, string $filename, array $flags = []): void + { + $this->runMigrateSnapshots($basePath, $filename, $flags); + } + + protected function runMigrateSnapshots(string $basePath, string $filename, array $flags): void { + if ($this->Connection->getDriver() instanceof Sqlserver) { + // TODO once migrations is using the inlined sqlserver adapter, this skip should + // be safe to remove once datetime columns support fractional units or the datetimefractional + // type is supported by migrations. + $this->markTestSkipped('Incompatible with sqlserver right now.'); + } + if ($flags) { Configure::write('Migrations', $flags + Configure::read('Migrations', [])); } @@ -994,8 +1088,8 @@ public function testMigrateSnapshots(string $basePath, string $filename, array $ ); $this->generatedFiles[] = $destination . $copiedFileName; - //change class name to avoid conflict with other classes - //to avoid 'Fatal error: Cannot declare class Test...., because the name is already in use' + // change class name to avoid conflict with other classes + // to avoid 'Fatal error: Cannot declare class Test...., because the name is already in use' $content = file_get_contents($destination . $copiedFileName); $pattern = ' extends AbstractMigration'; $content = str_replace($pattern, 'NewSuffix' . $pattern, $content); @@ -1014,9 +1108,13 @@ public function testMigrateSnapshots(string $basePath, string $filename, array $ /** * Tests that migrating in case of error throws an exception + * + * @dataProvider backendProvider */ - public function testMigrateErrors() + public function testMigrateErrors($backend) { + Configure::write('Migrations.backend', $backend); + $this->expectException(Exception::class); $this->migrations->markMigrated(20150704160200); $this->migrations->migrate(); @@ -1024,9 +1122,13 @@ public function testMigrateErrors() /** * Tests that rolling back in case of error throws an exception + * + * @dataProvider backendProvider */ - public function testRollbackErrors() + public function testRollbackErrors($backend) { + Configure::write('Migrations.backend', $backend); + $this->expectException(Exception::class); $this->migrations->markMigrated('all'); $this->migrations->rollback(); @@ -1035,9 +1137,13 @@ public function testRollbackErrors() /** * Tests that marking migrated a non-existant migrations returns an error * and can return a error message + * + * @dataProvider backendProvider */ - public function testMarkMigratedErrors() + public function testMarkMigratedErrors($backend) { + Configure::write('Migrations.backend', $backend); + $this->expectException(Exception::class); $this->migrations->markMigrated(20150704000000); } @@ -1075,6 +1181,16 @@ public static function snapshotMigrationsProvider(): array ]; } + if ($db === 'sqlserver') { + $path = Plugin::path('Migrations') . 'tests' . DS . 'comparisons' . DS . 'Migration' . DS . 'sqlserver' . DS; + + return [ + [$path, 'test_snapshot_not_empty_sqlserver'], + [$path, 'test_snapshot_auto_id_disabled_sqlserver'], + [$path, 'test_snapshot_plugin_blog_sqlserver'], + ]; + } + $path = Plugin::path('Migrations') . 'tests' . DS . 'comparisons' . DS . 'Migration' . DS . 'sqlite' . DS; return [ diff --git a/tests/TestCase/TestCase.php b/tests/TestCase/TestCase.php index 85f79312..859e9c2e 100644 --- a/tests/TestCase/TestCase.php +++ b/tests/TestCase/TestCase.php @@ -17,11 +17,9 @@ namespace Migrations\Test\TestCase; use Cake\Console\TestSuite\ConsoleIntegrationTestTrait; -use Cake\Core\Configure; use Cake\Routing\Router; use Cake\TestSuite\StringCompareTrait; use Cake\TestSuite\TestCase as BaseTestCase; -use Phinx\Config\FeatureFlags; abstract class TestCase extends BaseTestCase { @@ -63,8 +61,6 @@ public function tearDown(): void } $this->generatedFiles = []; } - - FeatureFlags::setFlagsFromConfig(Configure::read('Migrations')); } /** diff --git a/tests/TestCase/TestSuite/MigratorTest.php b/tests/TestCase/TestSuite/MigratorTest.php index 1f7b2191..631f7723 100644 --- a/tests/TestCase/TestSuite/MigratorTest.php +++ b/tests/TestCase/TestSuite/MigratorTest.php @@ -32,8 +32,10 @@ public function setUp(): void { parent::setUp(); - $this->restore = $GLOBALS['__PHPUNIT_BOOTSTRAP']; - unset($GLOBALS['__PHPUNIT_BOOTSTRAP']); + if (isset($GLOBALS['__PHPUNIT_BOOTSTRAP'])) { + $this->restore = $GLOBALS['__PHPUNIT_BOOTSTRAP']; + unset($GLOBALS['__PHPUNIT_BOOTSTRAP']); + } (new ConnectionHelper())->dropTables('test'); } @@ -41,7 +43,10 @@ public function setUp(): void public function tearDown(): void { parent::tearDown(); - $GLOBALS['__PHPUNIT_BOOTSTRAP'] = $this->restore; + if ($this->restore) { + $GLOBALS['__PHPUNIT_BOOTSTRAP'] = $this->restore; + unset($this->restore); + } (new ConnectionHelper())->dropTables('test'); } @@ -161,7 +166,12 @@ private function fetchMigrationEndDate(): ChronosDate $endTime = ConnectionManager::get('test')->selectQuery() ->select('end_time') ->from('migrator_phinxlog') - ->execute()->fetchColumn(0); + ->execute() + ->fetchColumn(0); + + if (!$endTime || is_bool($endTime)) { + $this->markTestSkipped('Cannot read end_time, bailing.'); + } return ChronosDate::parse($endTime); } diff --git a/tests/TestCase/Util/TableFinderTest.php b/tests/TestCase/Util/TableFinderTest.php new file mode 100644 index 00000000..8dbb0de0 --- /dev/null +++ b/tests/TestCase/Util/TableFinderTest.php @@ -0,0 +1,65 @@ +loadPlugins(['TestBlog']); + $finder = new TableFinder('test'); + + $result = $finder->getTableNames('TestBlog'); + $this->assertContains('articles', $result); + $this->assertContains('categories', $result); + $this->assertContains('dogs', $result); + $this->assertContains('parts', $result); + } + + /** + * Test that using fetchTableName in a Table object class + * where the table name is composed with the database name (e.g. mydb.mytable) + * will return: + * + * - only the table name if the current connection `database` parameter is the first part + * of the table name + * - the full string (e.g. mydb.mytable) if the current connection `database` parameter + * is not the first part of the table name + */ + public function testFetchTableNames(): void + { + $finder = new TableFinder('test'); + $expected = ['alternative.special_tags']; + $this->assertEquals($expected, $finder->fetchTableName('SpecialTagsTable.php', 'TestBlog')); + + ConnectionManager::setConfig('alternative', [ + 'database' => 'alternative', + ]); + $finder = new TableFinder('alternative'); + $expected = ['special_tags']; + $this->assertEquals($expected, $finder->fetchTableName('SpecialTagsTable.php', 'TestBlog')); + + ConnectionManager::drop('alternative'); + ConnectionManager::setConfig('alternative', [ + 'schema' => 'alternative', + ]); + $finder = new TableFinder('alternative'); + $expected = ['special_tags']; + $this->assertEquals($expected, $finder->fetchTableName('SpecialTagsTable.php', 'TestBlog')); + } +} diff --git a/tests/TestCase/View/Helper/MigrationHelperTest.php b/tests/TestCase/View/Helper/MigrationHelperTest.php index ad07c08d..394e548d 100644 --- a/tests/TestCase/View/Helper/MigrationHelperTest.php +++ b/tests/TestCase/View/Helper/MigrationHelperTest.php @@ -14,6 +14,7 @@ namespace Migrations\Test\TestCase\View\Helper; use Cake\Database\Driver\Mysql; +use Cake\Database\Driver\Sqlserver; use Cake\Database\Schema\Collection; use Cake\Datasource\ConnectionManager; use Cake\TestSuite\TestCase; @@ -110,6 +111,16 @@ public function setUp(): void 'precision' => 6, ]; } + + if (getenv('DB') === 'sqlserver') { + $this->values = [ + 'null' => null, + 'integerLimit' => null, + 'integerNull' => null, + 'comment' => null, + 'precision' => 7, + ]; + } } /** @@ -158,6 +169,10 @@ public function testColumnMethod() */ public function testColumns() { + $extra = []; + if ($this->connection->getDriver() instanceof Sqlserver) { + $extra = ['collate' => 'SQL_Latin1_General_CP1_CI_AS']; + } $this->assertEquals([ 'username' => [ 'columnType' => 'string', @@ -167,7 +182,7 @@ public function testColumns() 'default' => $this->values['null'], 'precision' => null, 'comment' => $this->values['comment'], - ], + ] + $extra, ], 'password' => [ 'columnType' => 'string', @@ -177,7 +192,7 @@ public function testColumns() 'default' => $this->values['null'], 'precision' => null, 'comment' => $this->values['comment'], - ], + ] + $extra, ], 'created' => [ 'columnType' => $this->types['timestamp'], @@ -227,6 +242,10 @@ public function testColumn() 'options' => $options, ], $result); + $extra = []; + if ($this->connection->getDriver() instanceof Sqlserver) { + $extra = ['collate' => 'SQL_Latin1_General_CP1_CI_AS']; + } $this->assertEquals([ 'columnType' => 'string', 'options' => [ @@ -235,7 +254,7 @@ public function testColumn() 'default' => $this->values['null'], 'precision' => null, 'comment' => $this->values['comment'], - ], + ] + $extra, ], $this->helper->column($tableSchema, 'username')); $this->assertEquals([ @@ -246,7 +265,7 @@ public function testColumn() 'default' => $this->values['null'], 'precision' => null, 'comment' => $this->values['comment'], - ], + ] + $extra, ], $this->helper->column($tableSchema, 'password')); $this->assertEquals([ @@ -313,16 +332,20 @@ public function testAttributes() $result = $this->helper->attributes('users', 'id'); unset($result['limit']); - $this->assertEquals($attributes, $result); + $extra = []; + if ($this->connection->getDriver() instanceof Sqlserver) { + $extra = ['collate' => 'SQL_Latin1_General_CP1_CI_AS']; + } + $this->assertEquals([ 'limit' => 256, 'null' => true, 'default' => $this->values['null'], 'precision' => null, 'comment' => $this->values['comment'], - ], $this->helper->attributes('users', 'username')); + ] + $extra, $this->helper->attributes('users', 'username')); $this->assertEquals([ 'limit' => 256, @@ -330,7 +353,7 @@ public function testAttributes() 'default' => $this->values['null'], 'precision' => null, 'comment' => $this->values['comment'], - ], $this->helper->attributes('users', 'password')); + ] + $extra, $this->helper->attributes('users', 'password')); $this->assertEquals([ 'limit' => null, diff --git a/tests/TestCase/test_app/config/Nomigrations/empty.txt b/tests/TestCase/test_app/config/Nomigrations/empty.txt new file mode 100644 index 00000000..e69de29b diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 07fac58d..a6b64b40 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -20,7 +20,6 @@ use Cake\Routing\Router; use Cake\TestSuite\Fixture\SchemaLoader; use Migrations\MigrationsPlugin; -use Phinx\Config\FeatureFlags; use SimpleSnapshot\Plugin as SimpleSnapshotPlugin; use TestBlog\Plugin as TestBlogPlugin; use function Cake\Core\env; @@ -69,8 +68,8 @@ ]); Configure::write('Migrations', [ - 'unsigned_primary_keys' => FeatureFlags::$unsignedPrimaryKeys, - 'column_null_default' => FeatureFlags::$columnNullDefault, + 'unsigned_primary_keys' => true, + 'column_null_default' => true, ]); Cache::setConfig([ @@ -121,10 +120,11 @@ ]); } -Plugin::getCollection()->add(new MigrationsPlugin()); -Plugin::getCollection()->add(new BakePlugin()); -Plugin::getCollection()->add(new SimpleSnapshotPlugin()); -Plugin::getCollection()->add(new TestBlogPlugin()); +Plugin::getCollection() + ->add(new MigrationsPlugin()) + ->add(new BakePlugin()) + ->add(new SimpleSnapshotPlugin()) + ->add(new TestBlogPlugin()); if (!defined('PHINX_VERSION')) { define('PHINX_VERSION', strpos('@PHINX_VERSION@', '@PHINX_VERSION') === 0 ? 'UNKNOWN' : '@PHINX_VERSION@'); diff --git a/tests/comparisons/Migration/sqlserver/test_snapshot_auto_id_disabled_sqlserver.php b/tests/comparisons/Migration/sqlserver/test_snapshot_auto_id_disabled_sqlserver.php new file mode 100644 index 00000000..0e3e385b --- /dev/null +++ b/tests/comparisons/Migration/sqlserver/test_snapshot_auto_id_disabled_sqlserver.php @@ -0,0 +1,491 @@ +table('articles') + ->addColumn('id', 'integer', [ + 'autoIncrement' => true, + 'default' => null, + 'limit' => 10, + 'null' => false, + ]) + ->addPrimaryKey(['id']) + ->addColumn('title', 'string', [ + 'collation' => 'SQL_Latin1_General_CP1_CI_AS', + 'default' => null, + 'limit' => 255, + 'null' => true, + ]) + ->addColumn('category_id', 'integer', [ + 'default' => null, + 'limit' => 10, + 'null' => true, + ]) + ->addColumn('product_id', 'integer', [ + 'default' => null, + 'limit' => 10, + 'null' => true, + ]) + ->addColumn('note', 'string', [ + 'collation' => 'SQL_Latin1_General_CP1_CI_AS', + 'default' => '7.4', + 'limit' => 255, + 'null' => true, + ]) + ->addColumn('counter', 'integer', [ + 'default' => null, + 'limit' => 10, + 'null' => true, + ]) + ->addColumn('active', 'boolean', [ + 'default' => false, + 'limit' => null, + 'null' => true, + ]) + ->addColumn('created', 'timestamp', [ + 'default' => null, + 'limit' => null, + 'null' => true, + 'precision' => 7, + 'scale' => 7, + ]) + ->addColumn('modified', 'timestamp', [ + 'default' => null, + 'limit' => null, + 'null' => true, + 'precision' => 7, + 'scale' => 7, + ]) + ->addIndex( + [ + 'title', + ], + [ + 'name' => 'articles_title_idx', + ] + ) + ->create(); + + $this->table('categories') + ->addColumn('id', 'integer', [ + 'autoIncrement' => true, + 'default' => null, + 'limit' => 10, + 'null' => false, + ]) + ->addPrimaryKey(['id']) + ->addColumn('parent_id', 'integer', [ + 'default' => null, + 'limit' => 10, + 'null' => true, + ]) + ->addColumn('title', 'string', [ + 'collation' => 'SQL_Latin1_General_CP1_CI_AS', + 'default' => null, + 'limit' => 255, + 'null' => true, + ]) + ->addColumn('slug', 'string', [ + 'collation' => 'SQL_Latin1_General_CP1_CI_AS', + 'default' => null, + 'limit' => 100, + 'null' => true, + ]) + ->addColumn('created', 'timestamp', [ + 'default' => null, + 'limit' => null, + 'null' => true, + 'precision' => 7, + 'scale' => 7, + ]) + ->addColumn('modified', 'timestamp', [ + 'default' => null, + 'limit' => null, + 'null' => true, + 'precision' => 7, + 'scale' => 7, + ]) + ->addIndex( + [ + 'slug', + ], + [ + 'name' => 'categories_slug_unique', + 'unique' => true, + ] + ) + ->create(); + + $this->table('composite_pks') + ->addColumn('id', 'uuid', [ + 'default' => 'a4950df3-515f-474c-be4c-6a027c1957e7', + 'limit' => null, + 'null' => false, + ]) + ->addColumn('name', 'string', [ + 'collation' => 'SQL_Latin1_General_CP1_CI_AS', + 'default' => '', + 'limit' => 10, + 'null' => false, + ]) + ->addPrimaryKey(['id', 'name']) + ->create(); + + $this->table('events') + ->addColumn('id', 'integer', [ + 'autoIncrement' => true, + 'default' => null, + 'limit' => 10, + 'null' => false, + ]) + ->addPrimaryKey(['id']) + ->addColumn('title', 'string', [ + 'collation' => 'SQL_Latin1_General_CP1_CI_AS', + 'default' => null, + 'limit' => 255, + 'null' => true, + ]) + ->addColumn('description', 'text', [ + 'collation' => 'SQL_Latin1_General_CP1_CI_AS', + 'default' => null, + 'limit' => null, + 'null' => true, + ]) + ->addColumn('published', 'string', [ + 'collation' => 'SQL_Latin1_General_CP1_CI_AS', + 'default' => 'N', + 'limit' => 1, + 'null' => true, + ]) + ->create(); + + $this->table('orders') + ->addColumn('id', 'integer', [ + 'autoIncrement' => true, + 'default' => null, + 'limit' => 10, + 'null' => false, + ]) + ->addPrimaryKey(['id']) + ->addColumn('product_category', 'integer', [ + 'default' => null, + 'limit' => 10, + 'null' => false, + ]) + ->addColumn('product_id', 'integer', [ + 'default' => null, + 'limit' => 10, + 'null' => false, + ]) + ->addIndex( + [ + 'product_category', + 'product_id', + ], + [ + 'name' => 'orders_product_category_idx', + ] + ) + ->create(); + + $this->table('parts') + ->addColumn('id', 'integer', [ + 'autoIncrement' => true, + 'default' => null, + 'limit' => 10, + 'null' => false, + ]) + ->addPrimaryKey(['id']) + ->addColumn('name', 'string', [ + 'collation' => 'SQL_Latin1_General_CP1_CI_AS', + 'default' => null, + 'limit' => 255, + 'null' => true, + ]) + ->addColumn('number', 'integer', [ + 'default' => null, + 'limit' => 10, + 'null' => true, + ]) + ->create(); + + $this->table('products') + ->addColumn('id', 'integer', [ + 'autoIncrement' => true, + 'default' => null, + 'limit' => 10, + 'null' => false, + ]) + ->addPrimaryKey(['id']) + ->addColumn('title', 'string', [ + 'collation' => 'SQL_Latin1_General_CP1_CI_AS', + 'default' => null, + 'limit' => 255, + 'null' => true, + ]) + ->addColumn('slug', 'string', [ + 'collation' => 'SQL_Latin1_General_CP1_CI_AS', + 'default' => null, + 'limit' => 100, + 'null' => true, + ]) + ->addColumn('category_id', 'integer', [ + 'default' => null, + 'limit' => 10, + 'null' => true, + ]) + ->addColumn('created', 'timestamp', [ + 'default' => null, + 'limit' => null, + 'null' => true, + 'precision' => 7, + 'scale' => 7, + ]) + ->addColumn('modified', 'timestamp', [ + 'default' => null, + 'limit' => null, + 'null' => true, + 'precision' => 7, + 'scale' => 7, + ]) + ->addIndex( + [ + 'category_id', + 'id', + ], + [ + 'name' => 'products_category_unique', + 'unique' => true, + ] + ) + ->addIndex( + [ + 'slug', + ], + [ + 'name' => 'products_slug_unique', + 'unique' => true, + ] + ) + ->addIndex( + [ + 'title', + ], + [ + 'name' => 'products_title_idx', + ] + ) + ->create(); + + $this->table('special_pks') + ->addColumn('id', 'uuid', [ + 'default' => 'a4950df3-515f-474c-be4c-6a027c1957e7', + 'limit' => null, + 'null' => false, + ]) + ->addPrimaryKey(['id']) + ->addColumn('name', 'string', [ + 'collation' => 'SQL_Latin1_General_CP1_CI_AS', + 'default' => null, + 'limit' => 256, + 'null' => true, + ]) + ->create(); + + $this->table('special_tags') + ->addColumn('id', 'integer', [ + 'autoIncrement' => true, + 'default' => null, + 'limit' => 10, + 'null' => false, + ]) + ->addPrimaryKey(['id']) + ->addColumn('article_id', 'integer', [ + 'default' => null, + 'limit' => 10, + 'null' => false, + ]) + ->addColumn('author_id', 'integer', [ + 'default' => null, + 'limit' => 10, + 'null' => true, + ]) + ->addColumn('tag_id', 'integer', [ + 'default' => null, + 'limit' => 10, + 'null' => false, + ]) + ->addColumn('highlighted', 'boolean', [ + 'default' => null, + 'limit' => null, + 'null' => true, + ]) + ->addColumn('highlighted_time', 'timestamp', [ + 'default' => null, + 'limit' => null, + 'null' => true, + 'precision' => 7, + 'scale' => 7, + ]) + ->addIndex( + [ + 'article_id', + ], + [ + 'name' => 'special_tags_article_unique', + 'unique' => true, + ] + ) + ->create(); + + $this->table('texts') + ->addColumn('title', 'string', [ + 'collation' => 'SQL_Latin1_General_CP1_CI_AS', + 'default' => null, + 'limit' => 255, + 'null' => true, + ]) + ->addColumn('description', 'text', [ + 'collation' => 'SQL_Latin1_General_CP1_CI_AS', + 'default' => null, + 'limit' => null, + 'null' => true, + ]) + ->create(); + + $this->table('users') + ->addColumn('id', 'integer', [ + 'autoIncrement' => true, + 'default' => null, + 'limit' => 10, + 'null' => false, + ]) + ->addPrimaryKey(['id']) + ->addColumn('username', 'string', [ + 'collation' => 'SQL_Latin1_General_CP1_CI_AS', + 'default' => null, + 'limit' => 256, + 'null' => true, + ]) + ->addColumn('password', 'string', [ + 'collation' => 'SQL_Latin1_General_CP1_CI_AS', + 'default' => null, + 'limit' => 256, + 'null' => true, + ]) + ->addColumn('created', 'timestamp', [ + 'default' => null, + 'limit' => null, + 'null' => true, + 'precision' => 7, + 'scale' => 7, + ]) + ->addColumn('updated', 'timestamp', [ + 'default' => null, + 'limit' => null, + 'null' => true, + 'precision' => 7, + 'scale' => 7, + ]) + ->create(); + + $this->table('articles') + ->addForeignKey( + 'category_id', + 'categories', + 'id', + [ + 'update' => 'NO_ACTION', + 'delete' => 'NO_ACTION', + 'constraint' => 'articles_category_fk' + ] + ) + ->update(); + + $this->table('orders') + ->addForeignKey( + [ + 'product_category', + 'product_id', + ], + 'products', + [ + 'category_id', + 'id', + ], + [ + 'update' => 'CASCADE', + 'delete' => 'CASCADE', + 'constraint' => 'orders_product_fk' + ] + ) + ->update(); + + $this->table('products') + ->addForeignKey( + 'category_id', + 'categories', + 'id', + [ + 'update' => 'CASCADE', + 'delete' => 'CASCADE', + 'constraint' => 'products_category_fk' + ] + ) + ->update(); + } + + /** + * Down Method. + * + * More information on this method is available here: + * https://book.cakephp.org/phinx/0/en/migrations.html#the-down-method + * @return void + */ + public function down(): void + { + $this->table('articles') + ->dropForeignKey( + 'category_id' + )->save(); + + $this->table('orders') + ->dropForeignKey( + [ + 'product_category', + 'product_id', + ] + )->save(); + + $this->table('products') + ->dropForeignKey( + 'category_id' + )->save(); + + $this->table('articles')->drop()->save(); + $this->table('categories')->drop()->save(); + $this->table('composite_pks')->drop()->save(); + $this->table('events')->drop()->save(); + $this->table('orders')->drop()->save(); + $this->table('parts')->drop()->save(); + $this->table('products')->drop()->save(); + $this->table('special_pks')->drop()->save(); + $this->table('special_tags')->drop()->save(); + $this->table('texts')->drop()->save(); + $this->table('users')->drop()->save(); + } +} diff --git a/tests/comparisons/Migration/sqlserver/test_snapshot_not_empty_sqlserver.php b/tests/comparisons/Migration/sqlserver/test_snapshot_not_empty_sqlserver.php new file mode 100644 index 00000000..5b92968f --- /dev/null +++ b/tests/comparisons/Migration/sqlserver/test_snapshot_not_empty_sqlserver.php @@ -0,0 +1,431 @@ +table('articles') + ->addColumn('title', 'string', [ + 'collation' => 'SQL_Latin1_General_CP1_CI_AS', + 'default' => null, + 'limit' => 255, + 'null' => true, + ]) + ->addColumn('category_id', 'integer', [ + 'default' => null, + 'limit' => 10, + 'null' => true, + ]) + ->addColumn('product_id', 'integer', [ + 'default' => null, + 'limit' => 10, + 'null' => true, + ]) + ->addColumn('note', 'string', [ + 'collation' => 'SQL_Latin1_General_CP1_CI_AS', + 'default' => '7.4', + 'limit' => 255, + 'null' => true, + ]) + ->addColumn('counter', 'integer', [ + 'default' => null, + 'limit' => 10, + 'null' => true, + ]) + ->addColumn('active', 'boolean', [ + 'default' => false, + 'limit' => null, + 'null' => true, + ]) + ->addColumn('created', 'timestamp', [ + 'default' => null, + 'limit' => null, + 'null' => true, + 'precision' => 7, + 'scale' => 7, + ]) + ->addColumn('modified', 'timestamp', [ + 'default' => null, + 'limit' => null, + 'null' => true, + 'precision' => 7, + 'scale' => 7, + ]) + ->addIndex( + [ + 'title', + ], + [ + 'name' => 'articles_title_idx', + ] + ) + ->create(); + + $this->table('categories') + ->addColumn('parent_id', 'integer', [ + 'default' => null, + 'limit' => 10, + 'null' => true, + ]) + ->addColumn('title', 'string', [ + 'collation' => 'SQL_Latin1_General_CP1_CI_AS', + 'default' => null, + 'limit' => 255, + 'null' => true, + ]) + ->addColumn('slug', 'string', [ + 'collation' => 'SQL_Latin1_General_CP1_CI_AS', + 'default' => null, + 'limit' => 100, + 'null' => true, + ]) + ->addColumn('created', 'timestamp', [ + 'default' => null, + 'limit' => null, + 'null' => true, + 'precision' => 7, + 'scale' => 7, + ]) + ->addColumn('modified', 'timestamp', [ + 'default' => null, + 'limit' => null, + 'null' => true, + 'precision' => 7, + 'scale' => 7, + ]) + ->addIndex( + [ + 'slug', + ], + [ + 'name' => 'categories_slug_unique', + 'unique' => true, + ] + ) + ->create(); + + $this->table('composite_pks', ['id' => false, 'primary_key' => ['id', 'name']]) + ->addColumn('id', 'uuid', [ + 'default' => 'a4950df3-515f-474c-be4c-6a027c1957e7', + 'limit' => null, + 'null' => false, + ]) + ->addColumn('name', 'string', [ + 'collation' => 'SQL_Latin1_General_CP1_CI_AS', + 'default' => '', + 'limit' => 10, + 'null' => false, + ]) + ->create(); + + $this->table('events') + ->addColumn('title', 'string', [ + 'collation' => 'SQL_Latin1_General_CP1_CI_AS', + 'default' => null, + 'limit' => 255, + 'null' => true, + ]) + ->addColumn('description', 'text', [ + 'collation' => 'SQL_Latin1_General_CP1_CI_AS', + 'default' => null, + 'limit' => null, + 'null' => true, + ]) + ->addColumn('published', 'string', [ + 'collation' => 'SQL_Latin1_General_CP1_CI_AS', + 'default' => 'N', + 'limit' => 1, + 'null' => true, + ]) + ->create(); + + $this->table('orders') + ->addColumn('product_category', 'integer', [ + 'default' => null, + 'limit' => 10, + 'null' => false, + ]) + ->addColumn('product_id', 'integer', [ + 'default' => null, + 'limit' => 10, + 'null' => false, + ]) + ->addIndex( + [ + 'product_category', + 'product_id', + ], + [ + 'name' => 'orders_product_category_idx', + ] + ) + ->create(); + + $this->table('parts') + ->addColumn('name', 'string', [ + 'collation' => 'SQL_Latin1_General_CP1_CI_AS', + 'default' => null, + 'limit' => 255, + 'null' => true, + ]) + ->addColumn('number', 'integer', [ + 'default' => null, + 'limit' => 10, + 'null' => true, + ]) + ->create(); + + $this->table('products') + ->addColumn('title', 'string', [ + 'collation' => 'SQL_Latin1_General_CP1_CI_AS', + 'default' => null, + 'limit' => 255, + 'null' => true, + ]) + ->addColumn('slug', 'string', [ + 'collation' => 'SQL_Latin1_General_CP1_CI_AS', + 'default' => null, + 'limit' => 100, + 'null' => true, + ]) + ->addColumn('category_id', 'integer', [ + 'default' => null, + 'limit' => 10, + 'null' => true, + ]) + ->addColumn('created', 'timestamp', [ + 'default' => null, + 'limit' => null, + 'null' => true, + 'precision' => 7, + 'scale' => 7, + ]) + ->addColumn('modified', 'timestamp', [ + 'default' => null, + 'limit' => null, + 'null' => true, + 'precision' => 7, + 'scale' => 7, + ]) + ->addIndex( + [ + 'category_id', + 'id', + ], + [ + 'name' => 'products_category_unique', + 'unique' => true, + ] + ) + ->addIndex( + [ + 'slug', + ], + [ + 'name' => 'products_slug_unique', + 'unique' => true, + ] + ) + ->addIndex( + [ + 'title', + ], + [ + 'name' => 'products_title_idx', + ] + ) + ->create(); + + $this->table('special_pks', ['id' => false, 'primary_key' => ['id']]) + ->addColumn('id', 'uuid', [ + 'default' => 'a4950df3-515f-474c-be4c-6a027c1957e7', + 'limit' => null, + 'null' => false, + ]) + ->addColumn('name', 'string', [ + 'collation' => 'SQL_Latin1_General_CP1_CI_AS', + 'default' => null, + 'limit' => 256, + 'null' => true, + ]) + ->create(); + + $this->table('special_tags') + ->addColumn('article_id', 'integer', [ + 'default' => null, + 'limit' => 10, + 'null' => false, + ]) + ->addColumn('author_id', 'integer', [ + 'default' => null, + 'limit' => 10, + 'null' => true, + ]) + ->addColumn('tag_id', 'integer', [ + 'default' => null, + 'limit' => 10, + 'null' => false, + ]) + ->addColumn('highlighted', 'boolean', [ + 'default' => null, + 'limit' => null, + 'null' => true, + ]) + ->addColumn('highlighted_time', 'timestamp', [ + 'default' => null, + 'limit' => null, + 'null' => true, + 'precision' => 7, + 'scale' => 7, + ]) + ->addIndex( + [ + 'article_id', + ], + [ + 'name' => 'special_tags_article_unique', + 'unique' => true, + ] + ) + ->create(); + + $this->table('texts', ['id' => false]) + ->addColumn('title', 'string', [ + 'collation' => 'SQL_Latin1_General_CP1_CI_AS', + 'default' => null, + 'limit' => 255, + 'null' => true, + ]) + ->addColumn('description', 'text', [ + 'collation' => 'SQL_Latin1_General_CP1_CI_AS', + 'default' => null, + 'limit' => null, + 'null' => true, + ]) + ->create(); + + $this->table('users') + ->addColumn('username', 'string', [ + 'collation' => 'SQL_Latin1_General_CP1_CI_AS', + 'default' => null, + 'limit' => 256, + 'null' => true, + ]) + ->addColumn('password', 'string', [ + 'collation' => 'SQL_Latin1_General_CP1_CI_AS', + 'default' => null, + 'limit' => 256, + 'null' => true, + ]) + ->addColumn('created', 'timestamp', [ + 'default' => null, + 'limit' => null, + 'null' => true, + 'precision' => 7, + 'scale' => 7, + ]) + ->addColumn('updated', 'timestamp', [ + 'default' => null, + 'limit' => null, + 'null' => true, + 'precision' => 7, + 'scale' => 7, + ]) + ->create(); + + $this->table('articles') + ->addForeignKey( + 'category_id', + 'categories', + 'id', + [ + 'update' => 'NO_ACTION', + 'delete' => 'NO_ACTION', + 'constraint' => 'articles_category_fk' + ] + ) + ->update(); + + $this->table('orders') + ->addForeignKey( + [ + 'product_category', + 'product_id', + ], + 'products', + [ + 'category_id', + 'id', + ], + [ + 'update' => 'CASCADE', + 'delete' => 'CASCADE', + 'constraint' => 'orders_product_fk' + ] + ) + ->update(); + + $this->table('products') + ->addForeignKey( + 'category_id', + 'categories', + 'id', + [ + 'update' => 'CASCADE', + 'delete' => 'CASCADE', + 'constraint' => 'products_category_fk' + ] + ) + ->update(); + } + + /** + * Down Method. + * + * More information on this method is available here: + * https://book.cakephp.org/phinx/0/en/migrations.html#the-down-method + * @return void + */ + public function down(): void + { + $this->table('articles') + ->dropForeignKey( + 'category_id' + )->save(); + + $this->table('orders') + ->dropForeignKey( + [ + 'product_category', + 'product_id', + ] + )->save(); + + $this->table('products') + ->dropForeignKey( + 'category_id' + )->save(); + + $this->table('articles')->drop()->save(); + $this->table('categories')->drop()->save(); + $this->table('composite_pks')->drop()->save(); + $this->table('events')->drop()->save(); + $this->table('orders')->drop()->save(); + $this->table('parts')->drop()->save(); + $this->table('products')->drop()->save(); + $this->table('special_pks')->drop()->save(); + $this->table('special_tags')->drop()->save(); + $this->table('texts')->drop()->save(); + $this->table('users')->drop()->save(); + } +} diff --git a/tests/comparisons/Migration/sqlserver/test_snapshot_plugin_blog_sqlserver.php b/tests/comparisons/Migration/sqlserver/test_snapshot_plugin_blog_sqlserver.php new file mode 100644 index 00000000..7e749f34 --- /dev/null +++ b/tests/comparisons/Migration/sqlserver/test_snapshot_plugin_blog_sqlserver.php @@ -0,0 +1,163 @@ +table('articles') + ->addColumn('title', 'string', [ + 'collation' => 'SQL_Latin1_General_CP1_CI_AS', + 'default' => null, + 'limit' => 255, + 'null' => true, + ]) + ->addColumn('category_id', 'integer', [ + 'default' => null, + 'limit' => 10, + 'null' => true, + ]) + ->addColumn('product_id', 'integer', [ + 'default' => null, + 'limit' => 10, + 'null' => true, + ]) + ->addColumn('note', 'string', [ + 'collation' => 'SQL_Latin1_General_CP1_CI_AS', + 'default' => '7.4', + 'limit' => 255, + 'null' => true, + ]) + ->addColumn('counter', 'integer', [ + 'default' => null, + 'limit' => 10, + 'null' => true, + ]) + ->addColumn('active', 'boolean', [ + 'default' => false, + 'limit' => null, + 'null' => true, + ]) + ->addColumn('created', 'timestamp', [ + 'default' => null, + 'limit' => null, + 'null' => true, + 'precision' => 7, + 'scale' => 7, + ]) + ->addColumn('modified', 'timestamp', [ + 'default' => null, + 'limit' => null, + 'null' => true, + 'precision' => 7, + 'scale' => 7, + ]) + ->addIndex( + [ + 'title', + ], + [ + 'name' => 'articles_title_idx', + ] + ) + ->create(); + + $this->table('categories') + ->addColumn('parent_id', 'integer', [ + 'default' => null, + 'limit' => 10, + 'null' => true, + ]) + ->addColumn('title', 'string', [ + 'collation' => 'SQL_Latin1_General_CP1_CI_AS', + 'default' => null, + 'limit' => 255, + 'null' => true, + ]) + ->addColumn('slug', 'string', [ + 'collation' => 'SQL_Latin1_General_CP1_CI_AS', + 'default' => null, + 'limit' => 100, + 'null' => true, + ]) + ->addColumn('created', 'timestamp', [ + 'default' => null, + 'limit' => null, + 'null' => true, + 'precision' => 7, + 'scale' => 7, + ]) + ->addColumn('modified', 'timestamp', [ + 'default' => null, + 'limit' => null, + 'null' => true, + 'precision' => 7, + 'scale' => 7, + ]) + ->addIndex( + [ + 'slug', + ], + [ + 'name' => 'categories_slug_unique', + 'unique' => true, + ] + ) + ->create(); + + $this->table('parts') + ->addColumn('name', 'string', [ + 'collation' => 'SQL_Latin1_General_CP1_CI_AS', + 'default' => null, + 'limit' => 255, + 'null' => true, + ]) + ->addColumn('number', 'integer', [ + 'default' => null, + 'limit' => 10, + 'null' => true, + ]) + ->create(); + + $this->table('articles') + ->addForeignKey( + 'category_id', + 'categories', + 'id', + [ + 'update' => 'NO_ACTION', + 'delete' => 'NO_ACTION', + 'constraint' => 'articles_category_fk' + ] + ) + ->update(); + } + + /** + * Down Method. + * + * More information on this method is available here: + * https://book.cakephp.org/phinx/0/en/migrations.html#the-down-method + * @return void + */ + public function down(): void + { + $this->table('articles') + ->dropForeignKey( + 'category_id' + )->save(); + + $this->table('articles')->drop()->save(); + $this->table('categories')->drop()->save(); + $this->table('parts')->drop()->save(); + } +} diff --git a/tests/comparisons/Seeds/sqlserver/testWithData.php b/tests/comparisons/Seeds/sqlserver/testWithData.php new file mode 100644 index 00000000..812fdcae --- /dev/null +++ b/tests/comparisons/Seeds/sqlserver/testWithData.php @@ -0,0 +1,51 @@ + '1', + 'title' => 'Lorem ipsum dolor sit amet', + 'description' => 'Lorem ipsum dolor sit amet, aliquet feugiat.', + 'published' => 'Y', + ], + [ + 'id' => '2', + 'title' => 'Second event', + 'description' => 'Second event description.', + 'published' => 'Y', + ], + [ + 'id' => '3', + 'title' => 'Lorem ipsum dolor sit amet', + 'description' => 'Lorem ipsum dolor sit amet, aliquet feugiat. +Convallis morbi fringilla gravida, phasellus feugiat dapibus velit nunc, pulvinar eget \'sollicitudin\' venenatis cum +nullam, vivamus ut a sed, mollitia lectus. +Nulla vestibulum massa neque ut et, id hendrerit sit, feugiat in taciti enim proin nibh, tempor dignissim, rhoncus +duis vestibulum nunc mattis convallis.', + 'published' => 'Y', + ], + ]; + + $table = $this->table('events'); + $table->insert($data)->save(); + } +} diff --git a/tests/comparisons/Seeds/sqlserver/testWithDataAndLimit.php b/tests/comparisons/Seeds/sqlserver/testWithDataAndLimit.php new file mode 100644 index 00000000..d426ecbe --- /dev/null +++ b/tests/comparisons/Seeds/sqlserver/testWithDataAndLimit.php @@ -0,0 +1,41 @@ + '1', + 'title' => 'Lorem ipsum dolor sit amet', + 'description' => 'Lorem ipsum dolor sit amet, aliquet feugiat.', + 'published' => 'Y', + ], + [ + 'id' => '2', + 'title' => 'Second event', + 'description' => 'Second event description.', + 'published' => 'Y', + ], + ]; + + $table = $this->table('events'); + $table->insert($data)->save(); + } +} diff --git a/tests/test_app/config/DropColumnFkIndexRegression/20190928205056_first_fk_index_migration.php b/tests/test_app/config/DropColumnFkIndexRegression/20190928205056_first_fk_index_migration.php new file mode 100644 index 00000000..dcc593e7 --- /dev/null +++ b/tests/test_app/config/DropColumnFkIndexRegression/20190928205056_first_fk_index_migration.php @@ -0,0 +1,101 @@ +table('table2', [ + 'id' => false, + 'primary_key' => ['id'], + 'engine' => 'InnoDB', + 'encoding' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + 'comment' => '', + 'row_format' => 'DYNAMIC', + ]) + ->addColumn('id', 'integer', [ + 'null' => false, + 'limit' => 20, + 'identity' => true, + ]) + ->create(); + + $this->table('table3', [ + 'id' => false, + 'primary_key' => ['id'], + 'engine' => 'InnoDB', + 'encoding' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + 'comment' => '', + 'row_format' => 'DYNAMIC', + ]) + ->addColumn('id', 'integer', [ + 'null' => false, + 'limit' => 20, + 'identity' => true, + ]) + ->create(); + + $this->table('table1', [ + 'id' => false, + 'primary_key' => ['id'], + 'engine' => 'InnoDB', + 'encoding' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + 'comment' => '', + 'row_format' => 'DYNAMIC', + ]) + ->addColumn('id', 'integer', [ + 'null' => false, + 'limit' => 20, + 'identity' => true, + ]) + ->addColumn('table2_id', 'integer', [ + 'null' => true, + 'limit' => 20, + 'after' => 'id', + ]) + ->addIndex(['table2_id'], [ + 'name' => 'table1_table2_id', + 'unique' => false, + ]) + ->addForeignKey('table2_id', 'table2', 'id', [ + 'constraint' => 'table1_table2_id', + 'update' => 'RESTRICT', + 'delete' => 'RESTRICT', + ]) + ->addColumn('table3_id', 'integer', [ + 'null' => true, + 'limit' => 20, + ]) + ->addIndex(['table3_id'], [ + 'name' => 'table1_table3_id', + 'unique' => false, + ]) + ->addForeignKey('table3_id', 'table3', 'id', [ + 'constraint' => 'table1_table3_id', + 'update' => 'RESTRICT', + 'delete' => 'RESTRICT', + ]) + ->create(); + } + + public function down() + { + $this->table('table1', [ + 'id' => false, + 'primary_key' => ['id'], + 'engine' => 'InnoDB', + 'encoding' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + 'comment' => '', + 'row_format' => 'DYNAMIC', + ]) + ->removeColumn('table2_id') + ->removeIndexByName('table1_table2_id') + ->dropForeignKey('table2_id', 'table1_table2_id') + ->update(); + } +} diff --git a/tests/test_app/config/DropColumnFkIndexRegression/20190928205060_second_fk_index_migration.php b/tests/test_app/config/DropColumnFkIndexRegression/20190928205060_second_fk_index_migration.php new file mode 100644 index 00000000..119aa886 --- /dev/null +++ b/tests/test_app/config/DropColumnFkIndexRegression/20190928205060_second_fk_index_migration.php @@ -0,0 +1,27 @@ +table('table1', [ + 'id' => false, + 'primary_key' => ['id'], + 'engine' => 'InnoDB', + 'encoding' => 'utf8', + 'collation' => 'utf8mb4_unicode_ci', + 'comment' => '', + 'row_format' => 'DYNAMIC', + ]) + ->removeColumn('table3_id') + ->removeIndexByName('table1_table3_id') + ->dropForeignKey('table3_id', 'table1_table3_id') + ->update(); + } + + public function down() + { + } +} diff --git a/tests/test_app/config/DropIndexRegression/20121213232502_create_drop_fk_initial_schema.php b/tests/test_app/config/DropIndexRegression/20121213232502_create_drop_fk_initial_schema.php new file mode 100644 index 00000000..2f9f12f1 --- /dev/null +++ b/tests/test_app/config/DropIndexRegression/20121213232502_create_drop_fk_initial_schema.php @@ -0,0 +1,35 @@ +table('my_table') + ->addColumn('name', 'string') + ->addColumn('entity_id', 'integer', ['signed' => false]) + ->create(); + + $this->table('my_other_table') + ->addColumn('name', 'string') + ->create(); + } + + /** + * Migrate Up. + */ + public function up() + { + } + + /** + * Migrate Down. + */ + public function down() + { + } +} diff --git a/tests/test_app/config/DropIndexRegression/20121223011815_add_regression_drop_fk.php b/tests/test_app/config/DropIndexRegression/20121223011815_add_regression_drop_fk.php new file mode 100644 index 00000000..bf48c342 --- /dev/null +++ b/tests/test_app/config/DropIndexRegression/20121223011815_add_regression_drop_fk.php @@ -0,0 +1,34 @@ +table('my_table'); + $table + ->addForeignKey('entity_id', 'my_other_table', 'id', [ + 'constraint' => 'my_other_table_foreign_key', + ]) + ->addIndex(['entity_id'], ['unique' => true]) + ->update(); + } + + /** + * Migrate Up. + */ + public function up() + { + } + + /** + * Migrate Down. + */ + public function down() + { + } +} diff --git a/tests/test_app/config/DropIndexRegression/20121223011816_change_fk_regression.php b/tests/test_app/config/DropIndexRegression/20121223011816_change_fk_regression.php new file mode 100644 index 00000000..d801fcc6 --- /dev/null +++ b/tests/test_app/config/DropIndexRegression/20121223011816_change_fk_regression.php @@ -0,0 +1,27 @@ +table('my_table'); + $table + ->dropForeignKey('entity_id') + ->addForeignKey('entity_id', 'my_other_table', 'id', [ + 'constraint' => 'my_other_table_foreign_key', + ]) + ->update(); + } + + /** + * Migrate Down. + */ + public function down() + { + } +} diff --git a/tests/test_app/config/DropIndexRegression/20121223011817_change_column_regression.php b/tests/test_app/config/DropIndexRegression/20121223011817_change_column_regression.php new file mode 100644 index 00000000..2b454041 --- /dev/null +++ b/tests/test_app/config/DropIndexRegression/20121223011817_change_column_regression.php @@ -0,0 +1,25 @@ +table('my_table'); + $table + ->renameColumn('name', 'title') + ->changeColumn('title', 'text') + ->update(); + } + + /** + * Migrate Down. + */ + public function down() + { + } +} diff --git a/tests/test_app/config/DropTableWithFkRegression/20190928205056_first_drop_fk_migration.php b/tests/test_app/config/DropTableWithFkRegression/20190928205056_first_drop_fk_migration.php new file mode 100644 index 00000000..dc62227d --- /dev/null +++ b/tests/test_app/config/DropTableWithFkRegression/20190928205056_first_drop_fk_migration.php @@ -0,0 +1,13 @@ +table('orders') + ->addColumn('order_date', 'timestamp') + ->create(); + } +} diff --git a/tests/test_app/config/DropTableWithFkRegression/20190928205060_second_drop_fk_migration.php b/tests/test_app/config/DropTableWithFkRegression/20190928205060_second_drop_fk_migration.php new file mode 100644 index 00000000..683e75fe --- /dev/null +++ b/tests/test_app/config/DropTableWithFkRegression/20190928205060_second_drop_fk_migration.php @@ -0,0 +1,18 @@ +table('customers') + ->addColumn('name', 'text') + ->create(); + + $this->table('orders') + ->addColumn('customer_id', 'integer', ['signed' => false]) + ->addForeignKey('customer_id', 'customers', 'id') + ->update(); + } +} diff --git a/tests/test_app/config/Duplicatenames/20120111235330_duplicate_migration_name.php b/tests/test_app/config/Duplicatenames/20120111235330_duplicate_migration_name.php new file mode 100644 index 00000000..98387694 --- /dev/null +++ b/tests/test_app/config/Duplicatenames/20120111235330_duplicate_migration_name.php @@ -0,0 +1,22 @@ + 'foo', + 'created' => date('Y-m-d H:i:s'), + ], + [ + 'body' => 'bar', + 'created' => date('Y-m-d H:i:s'), + ], + ]; + + $posts = $this->table('posts'); + $posts->insert($data) + ->save(); + } +} diff --git a/tests/test_app/config/ManagerSeeds/PostSeeder.php b/tests/test_app/config/ManagerSeeds/PostSeeder.php new file mode 100644 index 00000000..ad3ac0df --- /dev/null +++ b/tests/test_app/config/ManagerSeeds/PostSeeder.php @@ -0,0 +1,32 @@ + 'foo', + 'created' => date('Y-m-d H:i:s'), + ], + [ + 'body' => 'bar', + 'created' => date('Y-m-d H:i:s'), + ], + ]; + + $posts = $this->table('posts'); + $posts->insert($data) + ->save(); + } + + public function getDependencies(): array + { + return [ + 'UserSeeder', + 'GSeeder', + ]; + } +} diff --git a/tests/test_app/config/ManagerSeeds/UserSeeder.php b/tests/test_app/config/ManagerSeeds/UserSeeder.php new file mode 100644 index 00000000..b2f22921 --- /dev/null +++ b/tests/test_app/config/ManagerSeeds/UserSeeder.php @@ -0,0 +1,25 @@ + 'foo', + 'created' => date('Y-m-d H:i:s'), + ], + [ + 'name' => 'bar', + 'created' => date('Y-m-d H:i:s'), + ], + ]; + + $users = $this->table('users'); + $users->insert($data) + ->save(); + } +} diff --git a/tests/test_app/config/ManagerSeeds/UserSeederNotExecuted.php b/tests/test_app/config/ManagerSeeds/UserSeederNotExecuted.php new file mode 100644 index 00000000..c110e942 --- /dev/null +++ b/tests/test_app/config/ManagerSeeds/UserSeederNotExecuted.php @@ -0,0 +1,30 @@ + 'foo', + 'created' => date('Y-m-d H:i:s'), + ], + [ + 'name' => 'bar', + 'created' => date('Y-m-d H:i:s'), + ], + ]; + + $users = $this->table('users'); + $users->insert($data) + ->save(); + } + + public function shouldExecute(): bool + { + return false; + } +} diff --git a/tests/test_app/config/Migrations/20240309223600_mark_migrated_test_second.php b/tests/test_app/config/Migrations/20240309223600_mark_migrated_test_second.php new file mode 100644 index 00000000..44230805 --- /dev/null +++ b/tests/test_app/config/Migrations/20240309223600_mark_migrated_test_second.php @@ -0,0 +1,14 @@ +table('articles') + ->addColumn('title', 'string', [ + 'comment' => 'Article title', + 'default' => 'NULL::character varying', + 'limit' => 255, + 'null' => true, + ]) + ->addColumn('category_id', 'integer', [ + 'default' => null, + 'limit' => 10, + 'null' => true, + ]) + ->addColumn('product_id', 'integer', [ + 'default' => null, + 'limit' => 10, + 'null' => true, + ]) + ->addColumn('note', 'string', [ + 'default' => '7.4', + 'limit' => 255, + 'null' => true, + ]) + ->addColumn('counter', 'integer', [ + 'default' => null, + 'limit' => 10, + 'null' => true, + ]) + ->addColumn('active', 'boolean', [ + 'default' => false, + 'limit' => null, + 'null' => true, + ]) + ->addColumn('created', 'timestamp', [ + 'default' => null, + 'limit' => null, + 'null' => true, + ]) + ->addColumn('modified', 'timestamp', [ + 'default' => null, + 'limit' => null, + 'null' => true, + ]) + ->addIndex( + [ + 'category_id', + ] + ) + ->addIndex( + [ + 'product_id', + ] + ) + ->addIndex( + [ + 'title', + ] + ) + ->create(); + + $this->table('categories') + ->addColumn('parent_id', 'integer', [ + 'default' => null, + 'limit' => 10, + 'null' => true, + ]) + ->addColumn('title', 'string', [ + 'default' => 'NULL::character varying', + 'limit' => 255, + 'null' => true, + ]) + ->addColumn('slug', 'string', [ + 'default' => 'NULL::character varying', + 'limit' => 100, + 'null' => true, + ]) + ->addColumn('created', 'timestamp', [ + 'default' => null, + 'limit' => null, + 'null' => true, + ]) + ->addColumn('modified', 'timestamp', [ + 'default' => null, + 'limit' => null, + 'null' => true, + ]) + ->addIndex( + [ + 'slug', + ], + ['unique' => true] + ) + ->create(); + + $this->table('composite_pks', ['id' => false, 'primary_key' => ['id', 'name']]) + ->addColumn('id', 'uuid', [ + 'default' => 'a4950df3-515f-474c-be4c-6a027c1957e7', + 'limit' => null, + 'null' => false, + ]) + ->addColumn('name', 'string', [ + 'default' => '', + 'limit' => 10, + 'null' => false, + ]) + ->create(); + + $this->table('orders') + ->addColumn('product_category', 'integer', [ + 'default' => null, + 'limit' => 10, + 'null' => false, + ]) + ->addColumn('product_id', 'integer', [ + 'default' => null, + 'limit' => 10, + 'null' => false, + ]) + ->addIndex( + [ + 'product_category', + 'product_id', + ] + ) + ->create(); + + $this->table('products') + ->addColumn('title', 'string', [ + 'default' => 'NULL::character varying', + 'limit' => 255, + 'null' => true, + ]) + ->addColumn('slug', 'string', [ + 'default' => 'NULL::character varying', + 'limit' => 100, + 'null' => true, + ]) + ->addColumn('category_id', 'integer', [ + 'default' => null, + 'limit' => 10, + 'null' => true, + ]) + ->addColumn('created', 'timestamp', [ + 'default' => null, + 'limit' => null, + 'null' => true, + ]) + ->addColumn('modified', 'timestamp', [ + 'default' => null, + 'limit' => null, + 'null' => true, + ]) + ->addIndex( + [ + 'slug', + ], + ['unique' => true] + ) + ->addIndex( + [ + 'id', + 'category_id', + ], + ['unique' => true] + ) + ->addIndex( + [ + 'category_id', + ] + ) + ->addIndex( + [ + 'title', + ] + ) + ->create(); + + $this->table('special_pks', ['id' => false, 'primary_key' => ['id']]) + ->addColumn('id', 'uuid', [ + 'default' => 'a4950df3-515f-474c-be4c-6a027c1957e7', + 'limit' => null, + 'null' => false, + ]) + ->addColumn('name', 'string', [ + 'default' => 'NULL::character varying', + 'limit' => 256, + 'null' => true, + ]) + ->create(); + + $this->table('special_tags') + ->addColumn('article_id', 'integer', [ + 'default' => null, + 'limit' => 10, + 'null' => false, + ]) + ->addColumn('author_id', 'integer', [ + 'default' => null, + 'limit' => 10, + 'null' => true, + ]) + ->addColumn('tag_id', 'integer', [ + 'default' => null, + 'limit' => 10, + 'null' => false, + ]) + ->addColumn('highlighted', 'boolean', [ + 'default' => null, + 'limit' => null, + 'null' => true, + ]) + ->addColumn('highlighted_time', 'timestamp', [ + 'default' => null, + 'limit' => null, + 'null' => true, + ]) + ->addIndex( + [ + 'article_id', + ], + ['unique' => true] + ) + ->create(); + + $this->table('users') + ->addColumn('username', 'string', [ + 'default' => 'NULL::character varying', + 'limit' => 256, + 'null' => true, + ]) + ->addColumn('password', 'string', [ + 'default' => 'NULL::character varying', + 'limit' => 256, + 'null' => true, + ]) + ->addColumn('created', 'timestamp', [ + 'default' => null, + 'limit' => null, + 'null' => true, + ]) + ->addColumn('updated', 'timestamp', [ + 'default' => null, + 'limit' => null, + 'null' => true, + ]) + ->create(); + + $this->table('articles') + ->addForeignKey( + 'category_id', + 'categories', + 'id', + [ + 'update' => 'NO_ACTION', + 'delete' => 'NO_ACTION', + ] + ) + ->addForeignKey( + 'product_id', + 'products', + 'id', + [ + 'update' => 'CASCADE', + 'delete' => 'CASCADE', + ] + ) + ->update(); + + $this->table('orders') + ->addForeignKey( + [ + 'product_category', + 'product_id', + ], + 'products', + [ + 'category_id', + 'id', + ], + [ + 'update' => 'CASCADE', + 'delete' => 'CASCADE', + ] + ) + ->update(); + + $this->table('products') + ->addForeignKey( + 'category_id', + 'categories', + 'id', + [ + 'update' => 'CASCADE', + 'delete' => 'CASCADE', + ] + ) + ->update(); + } + + public function down() + { + $this->table('articles') + ->dropForeignKey( + 'category_id' + ) + ->dropForeignKey( + 'product_id' + )->save(); + + $this->table('orders') + ->dropForeignKey( + [ + 'product_category', + 'product_id', + ] + )->save(); + + $this->table('products') + ->dropForeignKey( + 'category_id' + )->save(); + + $this->table('articles')->drop()->save(); + $this->table('categories')->drop()->save(); + $this->table('composite_pks')->drop()->save(); + $this->table('orders')->drop()->save(); + $this->table('products')->drop()->save(); + $this->table('special_pks')->drop()->save(); + $this->table('special_tags')->drop()->save(); + $this->table('users')->drop()->save(); + } +} diff --git a/tests/test_app/config/Reversiblemigrations/20121213232502_create_initial_schema.php b/tests/test_app/config/Reversiblemigrations/20121213232502_create_initial_schema.php new file mode 100644 index 00000000..d7b542aa --- /dev/null +++ b/tests/test_app/config/Reversiblemigrations/20121213232502_create_initial_schema.php @@ -0,0 +1,51 @@ +table('users'); + $users->addColumn('username', Column::STRING, ['limit' => 20]) + ->addColumn('password', Column::STRING, ['limit' => 40]) + ->addColumn('password_salt', Column::STRING, ['limit' => 40]) + ->addColumn('email', Column::STRING, ['limit' => 100]) + ->addColumn('first_name', Column::STRING, ['limit' => 30]) + ->addColumn('last_name', Column::STRING, ['limit' => 30]) + ->addColumn('bio', Column::STRING, ['limit' => 160, 'null' => true, 'default' => null]) + ->addColumn('profile_image_url', Column::STRING, ['limit' => 120, 'null' => true, 'default' => null]) + ->addColumn('twitter', Column::STRING, ['limit' => 30, 'null' => true, 'default' => null]) + ->addColumn('role', Column::STRING, ['limit' => 20]) + ->addColumn('confirmed', Column::BOOLEAN, ['null' => true, 'default' => null]) + ->addColumn('confirmation_key', Column::STRING, ['limit' => 40]) + ->addColumn('created', Column::DATETIME) + ->addColumn('updated', Column::DATETIME, ['default' => null]) + ->addIndex(['username', 'email'], ['unique' => true]) + ->create(); + + // info table + $info = $this->table('info'); + $info->addColumn('username', Column::STRING, ['limit' => 20]) + ->create(); + } + + /** + * Migrate Up. + */ + public function up() + { + } + + /** + * Migrate Down. + */ + public function down() + { + } +} diff --git a/tests/test_app/config/Reversiblemigrations/20121223011815_update_info_table.php b/tests/test_app/config/Reversiblemigrations/20121223011815_update_info_table.php new file mode 100644 index 00000000..8a17e929 --- /dev/null +++ b/tests/test_app/config/Reversiblemigrations/20121223011815_update_info_table.php @@ -0,0 +1,32 @@ +table('info'); + $info->addColumn('password', Column::STRING, ['limit' => 40]) + ->update(); + } + + /** + * Migrate Up. + */ + public function up() + { + } + + /** + * Migrate Down. + */ + public function down() + { + } +} diff --git a/tests/test_app/config/Reversiblemigrations/20121224200649_rename_info_table_to_statuses_table.php b/tests/test_app/config/Reversiblemigrations/20121224200649_rename_info_table_to_statuses_table.php new file mode 100644 index 00000000..8e9a0cd0 --- /dev/null +++ b/tests/test_app/config/Reversiblemigrations/20121224200649_rename_info_table_to_statuses_table.php @@ -0,0 +1,30 @@ +table('info'); + $table->rename('statuses')->save(); + } + + /** + * Migrate Up. + */ + public function up() + { + } + + /** + * Migrate Down. + */ + public function down() + { + } +} diff --git a/tests/test_app/config/Reversiblemigrations/20121224200739_rename_bio_to_biography.php b/tests/test_app/config/Reversiblemigrations/20121224200739_rename_bio_to_biography.php new file mode 100644 index 00000000..a666dbb3 --- /dev/null +++ b/tests/test_app/config/Reversiblemigrations/20121224200739_rename_bio_to_biography.php @@ -0,0 +1,30 @@ +table('users'); + $table->renameColumn('bio', 'biography')->save(); + } + + /** + * Migrate Up. + */ + public function up() + { + } + + /** + * Migrate Down. + */ + public function down() + { + } +} diff --git a/tests/test_app/config/Reversiblemigrations/20121224200852_create_user_logins_table.php b/tests/test_app/config/Reversiblemigrations/20121224200852_create_user_logins_table.php new file mode 100644 index 00000000..80d94545 --- /dev/null +++ b/tests/test_app/config/Reversiblemigrations/20121224200852_create_user_logins_table.php @@ -0,0 +1,37 @@ +table('user_logins'); + $table->addColumn('user_id', Column::INTEGER, ['signed' => false]) + ->addColumn('created', Column::DATETIME) + ->create(); + + // add a foreign key back to the users table + $table->addForeignKey('user_id', 'users') + ->update(); + } + + /** + * Migrate Up. + */ + public function up() + { + } + + /** + * Migrate Down. + */ + public function down() + { + } +} diff --git a/tests/test_app/config/Reversiblemigrations/20170824134305_direction_aware_reversible_up.php b/tests/test_app/config/Reversiblemigrations/20170824134305_direction_aware_reversible_up.php new file mode 100644 index 00000000..caa1fecd --- /dev/null +++ b/tests/test_app/config/Reversiblemigrations/20170824134305_direction_aware_reversible_up.php @@ -0,0 +1,33 @@ +table('change_direction_test') + ->addColumn('thing', Column::STRING, [ + 'limit' => 12, + ]) + ->create(); + + if ($this->isMigratingUp()) { + $this->table('change_direction_test')->insert([ + [ + 'thing' => 'one', + ], + [ + 'thing' => 'two', + ], + [ + 'thing' => 'fox-socks', + ], + [ + 'thing' => 'mouse-box', + ], + ])->save(); + } + } +} diff --git a/tests/test_app/config/Reversiblemigrations/20170831121929_direction_aware_reversible_down.php b/tests/test_app/config/Reversiblemigrations/20170831121929_direction_aware_reversible_down.php new file mode 100644 index 00000000..b9155098 --- /dev/null +++ b/tests/test_app/config/Reversiblemigrations/20170831121929_direction_aware_reversible_down.php @@ -0,0 +1,33 @@ +table('change_direction_test') + ->addColumn('subthing', Column::STRING, [ + 'limit' => 12, + 'null' => true, + ]) + ->update(); + + if ($this->isMigratingUp()) { + $query = $this->getQueryBuilder(Query::TYPE_UPDATE); + $query + ->update('change_direction_test') + ->set(['subthing' => $query->identifier('thing')]) + ->where(['thing LIKE' => '%-%']) + ->execute(); + } else { + $this + ->getQueryBuilder(Query::TYPE_UPDATE) + ->update('change_direction_test') + ->set(['subthing' => null]) + ->execute(); + } + } +} diff --git a/tests/test_app/config/Reversiblemigrations/20180431121930_tricky_edge_case.php b/tests/test_app/config/Reversiblemigrations/20180431121930_tricky_edge_case.php new file mode 100644 index 00000000..d66b3f55 --- /dev/null +++ b/tests/test_app/config/Reversiblemigrations/20180431121930_tricky_edge_case.php @@ -0,0 +1,21 @@ +table('user_logins'); + $table + ->rename('just_logins') + ->addColumn('thingy', Column::STRING, [ + 'limit' => 12, + 'null' => true, + ]) + ->addColumn('thingy2', Column::INTEGER) + ->addIndex(['thingy']) + ->save(); + } +} diff --git a/tests/test_app/config/Reversiblemigrations/20180516025208_create_test_index_limit_specifier_table.php b/tests/test_app/config/Reversiblemigrations/20180516025208_create_test_index_limit_specifier_table.php new file mode 100644 index 00000000..00637700 --- /dev/null +++ b/tests/test_app/config/Reversiblemigrations/20180516025208_create_test_index_limit_specifier_table.php @@ -0,0 +1,38 @@ +table('test_index_limit_specifier'); + $table->addColumn('column1', Column::STRING) + ->addColumn('column2', Column::STRING) + ->addColumn('column3', Column::STRING) + ->addIndex([ 'column1', 'column2', 'column3' ], [ 'limit' => [ 'column2' => 10 ] ]) + ->create(); + } +} diff --git a/tests/test_app/config/Reversiblemigrations/20190928220334_add_column_index_fk.php b/tests/test_app/config/Reversiblemigrations/20190928220334_add_column_index_fk.php new file mode 100644 index 00000000..f35525ed --- /dev/null +++ b/tests/test_app/config/Reversiblemigrations/20190928220334_add_column_index_fk.php @@ -0,0 +1,62 @@ +table('statuses') + ->addColumn('user_id', Column::INTEGER, [ + 'null' => true, + 'limit' => 20, + 'signed' => false, + ]) + ->addIndex(['user_id'], [ + 'name' => 'statuses_users_id', + 'unique' => false, + ]); + + if ($this->getAdapter()->getConnection()->getAttribute(\PDO::ATTR_DRIVER_NAME) === 'sqlite') { + $table->addForeignKey('user_id', 'users', 'id', [ + 'update' => ForeignKey::NO_ACTION, + 'delete' => ForeignKey::NO_ACTION, + ]); + } else { + $table->addForeignKey('user_id', 'users', 'id', [ + 'constraint' => 'statuses_users_id', + 'update' => ForeignKey::NO_ACTION, + 'delete' => ForeignKey::NO_ACTION, + ]); + } + + $table->update(); + } +} diff --git a/tests/test_app/config/ShouldExecute/20201207205056_should_not_execute_migration.php b/tests/test_app/config/ShouldExecute/20201207205056_should_not_execute_migration.php new file mode 100644 index 00000000..e90f2ed6 --- /dev/null +++ b/tests/test_app/config/ShouldExecute/20201207205056_should_not_execute_migration.php @@ -0,0 +1,18 @@ +table('info')->create(); + } + + public function shouldExecute(): bool + { + return false; + } +} diff --git a/tests/test_app/config/ShouldExecute/20201207205057_should_execute_migration.php b/tests/test_app/config/ShouldExecute/20201207205057_should_execute_migration.php new file mode 100644 index 00000000..d88e566a --- /dev/null +++ b/tests/test_app/config/ShouldExecute/20201207205057_should_execute_migration.php @@ -0,0 +1,18 @@ +table('info')->create(); + } + + public function shouldExecute(): bool + { + return true; + } +}