diff --git a/README.md b/README.md index 2c2de2cd..c1da0e14 100644 --- a/README.md +++ b/README.md @@ -64,32 +64,32 @@ The following console command are available (assuming you named the controller ` - Generate migration to create DB table `table_name`: ``` - php yii migration/create table_name + php yii migration/create "table_name" ``` - Generate migration to update DB table `table_name`: ``` - php yii migration/update table_name + php yii migration/update "table_name" ``` To generate migrations for all the tables in the database at once (except the excluded ones) use asterisk (*): ``` -php yii migration/create * -php yii migration/update * +php yii migration/create "*" +php yii migration/update "*" ``` -In environments that hijack asterisk (like dockerized env) use `"*"`. + You can generate multiple migrations for many tables at once by separating the names with comma: ``` -php yii migration/create table_name1,table_name2,table_name3 +php yii migration/create "table_name1,table_name2,table_name3" ``` You can provide an asterisk as a part of table name to use all tables matching the pattern: ``` -php yii migration/update prefix_* +php yii migration/update "prefix_*" ``` Creating multiple table migrations at once forces the proper migration order based on the presence of the foreign keys. @@ -118,8 +118,9 @@ You can easily generate updating migration for database table by comparing its c | `skipMigrations` | | List of migrations from the history table that should be skipped during the update process (see [2] below). | `excludeTables` | | List of tables that should be skipped. | `experimental` | `ex` | Whether to run in experimental mode (see [3] below). -| `fileMode` | `fm` | **New in 4.2.0** - Generated file mode to be changed using `chmod`. -| `fileOwnership` | `fo` | **New in 4.2.0** - Generated file ownership to be changed using `chown`/`chgrp`. +| `fileMode` | `fm` | Generated file mode to be changed using `chmod`. +| `fileOwnership` | `fo` | Generated file ownership to be changed using `chown`/`chgrp`. +| `leeway` | `lw` | **New in 4.3.0** - The leeway in seconds to apply to a starting timestamp when generating migration, so it can be saved with a later date [1] Remember that with different database types general column schemas may be generated with different length. diff --git a/infection.json.dist b/infection.json.dist index 420503e4..1a997375 100644 --- a/infection.json.dist +++ b/infection.json.dist @@ -19,5 +19,5 @@ "mutators": { "@default": true }, - "minCoveredMsi": 95 + "minCoveredMsi": 97 } diff --git a/migrating_to_v4.md b/migrating_to_v4.md index 888b7ba6..73d4f5ef 100644 --- a/migrating_to_v4.md +++ b/migrating_to_v4.md @@ -77,7 +77,7 @@ pattern, except excluded ones). ### create-all -Not available anymore. Use `create *` instead (or `create "*"` in environments that hijack asterisk). +Not available anymore. Use `create "*"` instead. ### update @@ -87,4 +87,4 @@ pattern, except excluded ones). ### update-all -Not available anymore. Use `update *` instead (or `update "*"` in environments that hijack asterisk). +Not available anymore. Use `update "*"` instead. diff --git a/src/Arranger.php b/src/Arranger.php index d64fc7e8..ecd40cff 100644 --- a/src/Arranger.php +++ b/src/Arranger.php @@ -41,7 +41,7 @@ public function arrangeTables(array $inputTables): void foreach ($inputTables as $inputTable) { $this->addDependency($inputTable); $foreignKeys = $this->mapper->getStructureOf($inputTable)->getForeignKeys(); - /** @var ForeignKeyInterface $foreignKey */ + foreach ($foreignKeys as $foreignKey) { $this->addDependency($inputTable, $foreignKey->getReferredTable()); } diff --git a/src/Comparator.php b/src/Comparator.php index cc547c5e..d13512cf 100644 --- a/src/Comparator.php +++ b/src/Comparator.php @@ -240,15 +240,10 @@ private function compareForeignKeys( $oldForeignKey = $oldStructure->getForeignKey($name); $newForeignKeyColumns = $foreignKey->getColumns(); $oldForeignKeyColumns = $oldForeignKey->getColumns(); - $intersection = array_intersect($newForeignKeyColumns, $oldForeignKeyColumns); if ( - count( - array_merge( - array_diff($newForeignKeyColumns, $intersection), - array_diff($oldForeignKeyColumns, $intersection) - ) - ) + array_diff($newForeignKeyColumns, $oldForeignKeyColumns) + !== array_diff($oldForeignKeyColumns, $newForeignKeyColumns) ) { $blueprint->addDescription( "different foreign key '$name' columns (" @@ -273,15 +268,10 @@ private function compareForeignKeys( $newForeignKeyReferredColumns = $foreignKey->getReferredColumns(); $oldForeignKeyReferredColumns = $oldForeignKey->getReferredColumns(); - $intersection = array_intersect($newForeignKeyReferredColumns, $oldForeignKeyReferredColumns); if ( - count( - array_merge( - array_diff($newForeignKeyReferredColumns, $intersection), - array_diff($oldForeignKeyReferredColumns, $intersection) - ) - ) + array_diff($newForeignKeyReferredColumns, $oldForeignKeyReferredColumns) + !== array_diff($oldForeignKeyReferredColumns, $newForeignKeyReferredColumns) ) { $blueprint->addDescription( "different foreign key '$name' referred columns (" @@ -408,14 +398,9 @@ private function comparePrimaryKeys( $newPrimaryKeyColumns = $newPrimaryKey ? $newPrimaryKey->getColumns() : []; $oldPrimaryKeyColumns = $oldPrimaryKey ? $oldPrimaryKey->getColumns() : []; - $intersection = array_intersect($newPrimaryKeyColumns, $oldPrimaryKeyColumns); + $differentColumns = array_diff($newPrimaryKeyColumns, $oldPrimaryKeyColumns); - $differentColumns = array_merge( - array_diff($newPrimaryKeyColumns, $intersection), - array_diff($oldPrimaryKeyColumns, $intersection) - ); - - if (count($differentColumns)) { + if ($differentColumns !== array_diff($oldPrimaryKeyColumns, $newPrimaryKeyColumns)) { $blueprint->addDescription('different primary key definition'); $alreadyDropped = false; @@ -570,16 +555,8 @@ private function compareIndexes( $newIndexColumns = $index->getColumns(); $oldIndexColumns = $oldIndex->getColumns(); - $intersection = array_intersect($newIndexColumns, $oldIndexColumns); - if ( - count( - array_merge( - array_diff($newIndexColumns, $intersection), - array_diff($oldIndexColumns, $intersection) - ) - ) - ) { + if (array_diff($newIndexColumns, $oldIndexColumns) !== array_diff($oldIndexColumns, $newIndexColumns)) { $blueprint->addDescription( "different index '$name' columns (DB: " . $this->stringifyValue($newIndexColumns) . ') != MIG: (' diff --git a/src/Extractor.php b/src/Extractor.php index 39ddce41..71ea03e1 100644 --- a/src/Extractor.php +++ b/src/Extractor.php @@ -37,31 +37,13 @@ public function __construct(Connection $db, bool $experimental = false) /** * Extracts migration data structures. * @param string $migration - * @param array $migrationPaths + * @param string[] $migrationPaths * @throws ErrorException */ public function extract(string $migration, array $migrationPaths): void { $this->setDummyMigrationClass(); - - if (strpos($migration, '\\') === false) { // not namespaced - $fileFound = false; - $file = null; - foreach ($migrationPaths as $path) { - /** @var string $file */ - $file = Yii::getAlias($path . DIRECTORY_SEPARATOR . $migration . '.php'); - if (file_exists($file)) { - $fileFound = true; - break; - } - } - - if (!$fileFound) { - throw new ErrorException("File '{$migration}.php' can not be found! Check migration history table."); - } - - require_once $file; - } + $this->loadFile($migration, $migrationPaths); $this->subject = new $migration(['db' => $this->db, 'experimental' => $this->experimental]); if ($this->subject instanceof MigrationChangesInterface === false) { @@ -73,6 +55,32 @@ public function extract(string $migration, array $migrationPaths): void $this->subject->up(); } + /** + * Loads a non-namespaced file. + * @param string $migration + * @param string[] $migrationPaths + * @throws ErrorException + */ + private function loadFile(string $migration, array $migrationPaths): void + { + if (strpos($migration, '\\') !== false) { + // migration with `\` character is most likely namespaced, so it doesn't require loading + return; + } + + foreach ($migrationPaths as $path) { + /** @var string $file */ + $file = Yii::getAlias($path . DIRECTORY_SEPARATOR . $migration . '.php'); + if (file_exists($file)) { + require_once $file; + + return; + } + } + + throw new ErrorException("File '{$migration}.php' can not be found! Check migration history table."); + } + /** * Sets the dummy migration file instead the real one to extract the migration structure instead of applying them. * It uses Yii's class map autoloaders hack. @@ -82,6 +90,7 @@ private function setDummyMigrationClass(): void { // attempt to register Yii's autoloader in case it's not been done already // registering it second time should be skipped anyway + /** @infection-ignore-all */ spl_autoload_register(['Yii', 'autoload'], true, true); Yii::$classMap['yii\db\Migration'] = Yii::getAlias('@bizley/migration/dummy/Migration.php'); diff --git a/src/HistoryManager.php b/src/HistoryManager.php index 3560a71e..cc0a31c5 100644 --- a/src/HistoryManager.php +++ b/src/HistoryManager.php @@ -108,12 +108,12 @@ public function fetchHistory(): array ->all($this->db); $history = []; - foreach ($rows as $key => $row) { + foreach ($rows as $row) { if ($row['version'] === MigrateController::BASE_MIGRATION) { continue; } - if (preg_match('/m?(\d{6}_?\d{6})(\D.*)?$/is', $row['version'], $matches)) { + if (preg_match('/m?(\d{6}_?\d{6})(\D.*)?/i', $row['version'], $matches)) { $row['canonicalVersion'] = str_replace('_', '', $matches[1]); } else { $row['canonicalVersion'] = $row['version']; diff --git a/src/Schema.php b/src/Schema.php index 0f84f127..6ce09df3 100644 --- a/src/Schema.php +++ b/src/Schema.php @@ -169,7 +169,7 @@ public static function identifySchema($schema): string */ public static function isSQLite($schema): bool { - return static::identifySchema($schema) === self::SQLITE; + return self::identifySchema($schema) === self::SQLITE; } /** @@ -190,7 +190,7 @@ public static function getDefaultLength(?string $schema, string $type, string $e $schema = self::MYSQL . '+'; } - return static::$defaultLength[$schema][$type] ?? null; + return self::$defaultLength[$schema][$type] ?? null; } /** @@ -206,6 +206,6 @@ public static function getAlias(?string $schema, string $type, string $length): return null; } - return static::$aliases[$schema][$type][$length] ?? null; + return self::$aliases[$schema][$type][$length] ?? null; } } diff --git a/src/SqlColumnMapper.php b/src/SqlColumnMapper.php index 2c7e31d0..171e7013 100644 --- a/src/SqlColumnMapper.php +++ b/src/SqlColumnMapper.php @@ -232,7 +232,7 @@ private function detectUnique(): void * @param int $offset * @return array{0: int, 1: string} */ - private function findPart(string $type, string $sentence, int $offset = 0): array + private function findPart(string $type, string $sentence, int $offset): array { $sentence = substr($sentence, $offset); @@ -257,7 +257,7 @@ private function findPart(string $type, string $sentence, int $offset = 0): arra [$end, $part] = $this->findExpressionPart($sentenceArray); } - return [$end ? $end + $offset : 0, $part]; + return [$end + $offset, $part]; } /** diff --git a/src/TableMapper.php b/src/TableMapper.php index af905a5d..ba2ea012 100644 --- a/src/TableMapper.php +++ b/src/TableMapper.php @@ -17,6 +17,7 @@ use PDO; use Throwable; use yii\base\NotSupportedException; +use yii\db\ColumnSchema; use yii\db\Connection; use yii\db\Constraint; use yii\db\cubrid\Schema as CubridSchema; @@ -54,7 +55,7 @@ public function getStructureOf(string $table, array $referencesToPostpone = []): { $this->suppressedForeignKeys = []; $foreignKeys = $this->getForeignKeys($table); - /** @var ForeignKeyInterface $foreignKey */ + foreach ($foreignKeys as $foreignKeyName => $foreignKey) { if (in_array($foreignKey->getReferredTable(), $referencesToPostpone, true)) { $this->suppressedForeignKeys[] = $foreignKey; @@ -171,16 +172,7 @@ private function getColumns(string $table, array $indexes = []): array $engineVersion = $this->getEngineVersion(); foreach ($tableSchema->columns as $column) { - $isUnique = false; - - /** @var IndexInterface $index */ - foreach ($indexes as $index) { - $indexColumns = $index->getColumns(); - if ($index->isUnique() && count($indexColumns) === 1 && $indexColumns[0] === $column->name) { - $isUnique = true; - break; - } - } + $isUnique = $this->isUnique($indexes, $column); $mappedColumn = ColumnFactory::build($column->type); $mappedColumn->setName($column->name); @@ -209,6 +201,23 @@ private function getColumns(string $table, array $indexes = []): array return $mappedColumns; } + /** + * @param array $indexes + * @param ColumnSchema $column + * @return bool + */ + private function isUnique(array $indexes, ColumnSchema $column): bool + { + foreach ($indexes as $index) { + $indexColumns = $index->getColumns(); + if ($index->isUnique() && count($indexColumns) === 1 && $indexColumns[0] === $column->name) { + return true; + } + } + + return false; + } + /** * Returns the suppressed foreign keys that must be added in migration at the end. * @return array diff --git a/src/controllers/MigrationController.php b/src/controllers/MigrationController.php index 7df51fd9..8de78d15 100644 --- a/src/controllers/MigrationController.php +++ b/src/controllers/MigrationController.php @@ -25,7 +25,9 @@ use function array_column; use function array_merge; use function array_unique; +use function closedir; use function count; +use function date; use function explode; use function file_put_contents; use function gmdate; @@ -33,16 +35,20 @@ use function in_array; use function is_array; use function is_dir; +use function is_file; use function is_numeric; use function is_string; use function method_exists; use function octdec; +use function opendir; use function preg_match; +use function readdir; use function sort; use function sprintf; use function str_replace; use function strlen; use function strpos; +use function time; use function trim; /** @@ -50,14 +56,14 @@ * Generates migration files based on the existing database table and previous migrations. * * @author Paweł Bizley Brzozowski - * @version 4.2.0 + * @version 4.3.0 * @license Apache 2.0 * https://github.com/bizley/yii2-migration */ class MigrationController extends BaseMigrationController { /** @var string */ - private $version = '4.2.0'; + private $version = '4.3.0'; /** * @var string|array Directory storing the migration classes. @@ -121,6 +127,13 @@ class MigrationController extends BaseMigrationController */ public $fileOwnership; + /** + * @var int the leeway in seconds to apply to a starting timestamp when generating migration, so it can be saved with + * a later date. + * @since 4.3.0 + */ + public $leeway = 0; + /** {@inheritdoc} */ public function options($actionID): array // BC declaration { @@ -133,7 +146,8 @@ public function options($actionID): array // BC declaration 'migrationPath', 'migrationTable', 'useTablePrefix', - 'excludeTables' + 'excludeTables', + 'leeway', ]; $updateOptions = ['onlyShow', 'skipMigrations', 'experimental']; @@ -165,6 +179,7 @@ public function optionAliases(): array 'tp' => 'useTablePrefix', 'fm' => 'fileMode', 'fo' => 'fileOwnership', + 'lw' => 'leeway', ] ); } @@ -304,9 +319,10 @@ public function actionCreate(string $inputTable): int $referencesToPostpone = []; $tables = $inputTables; if ($countTables > 1) { - $this->getArranger()->arrangeTables($inputTables); - $tables = $this->getArranger()->getTablesInOrder(); - $referencesToPostpone = $this->getArranger()->getReferencesToPostpone(); + $arranger = $this->getArranger(); + $arranger->arrangeTables($inputTables); + $tables = $arranger->getTablesInOrder(); + $referencesToPostpone = $arranger->getReferencesToPostpone(); /** @var Connection $db */ $db = $this->db; @@ -321,24 +337,34 @@ public function actionCreate(string $inputTable): int } } - $postponedForeignKeys = []; + if ( + $this->hasTimestampsCollision($countTables) + && $this->confirm( + ' > There are migration files detected that have timestamps colliding with the ones that will be generated. Are you sure you want to proceed?' + ) === false + ) { + $this->stdout("\n Operation cancelled by user.\n", Console::FG_YELLOW); + return ExitCode::UNSPECIFIED_ERROR; + } - $counterSize = strlen((string)$countTables) + 1; + $postponedForeignKeys = []; + $lastUsedTimestamp = time() + $this->leeway; $migrationsGenerated = 0; foreach ($tables as $tableName) { $this->stdout("\n > Generating migration for creating table '{$tableName}' ...", Console::FG_YELLOW); $normalizedTableName = str_replace('.', '_', $tableName); - if ($countTables > 1) { - $migrationClassName = sprintf( - "m%s_%0{$counterSize}d_create_table_%s", - gmdate('ymd_His'), - $migrationsGenerated + 1, - $normalizedTableName - ); + $timestamp = time(); + if ($timestamp <= $lastUsedTimestamp) { + $timestamp = ++$lastUsedTimestamp; } else { - $migrationClassName = sprintf('m%s_create_table_%s', gmdate('ymd_His'), $normalizedTableName); + $lastUsedTimestamp = $timestamp; } + $migrationClassName = sprintf( + 'm%s_create_table_%s', + gmdate('ymd_His', $timestamp), + $normalizedTableName + ); try { $this->generateMigrationForTableCreation($tableName, $migrationClassName, $referencesToPostpone); @@ -347,7 +373,7 @@ public function actionCreate(string $inputTable): int return ExitCode::UNSPECIFIED_ERROR; } - $migrationsGenerated++; + ++$migrationsGenerated; $this->stdout("\n"); @@ -357,20 +383,23 @@ public function actionCreate(string $inputTable): int } } - if ($postponedForeignKeys) { + if (count($postponedForeignKeys)) { + $timestamp = time(); + if ($timestamp <= $lastUsedTimestamp) { + $timestamp = ++$lastUsedTimestamp; + } + try { $this->generateMigrationForForeignKeys( $postponedForeignKeys, - sprintf( - "m%s_%0{$counterSize}d_create_foreign_keys", - gmdate('ymd_His'), - ++$migrationsGenerated - ) + sprintf("m%s_create_foreign_keys", gmdate('ymd_His', $timestamp)) ); } catch (Throwable $exception) { $this->stdout("ERROR!\n > {$exception->getMessage()}\n", Console::FG_RED); return ExitCode::UNSPECIFIED_ERROR; } + + ++$migrationsGenerated; } $this->stdout( @@ -402,7 +431,7 @@ public function actionUpdate(string $inputTable): int $blueprints = []; $newTables = []; /** @var array $migrationPaths */ - $migrationPaths = $this->migrationPath; + $migrationPaths = $this->migrationNamespace ?? $this->migrationPath; foreach ($inputTables as $tableName) { $this->stdout("\n > Comparing current table '{$tableName}' with its migrations ...", Console::FG_YELLOW); @@ -465,9 +494,10 @@ public function actionUpdate(string $inputTable): int $countTables = count($newTables); $referencesToPostpone = []; if ($countTables > 1) { - $this->getArranger()->arrangeTables($newTables); - $newTables = $this->getArranger()->getTablesInOrder(); - $referencesToPostpone = $this->getArranger()->getReferencesToPostpone(); + $arranger = $this->getArranger(); + $arranger->arrangeTables($newTables); + $newTables = $arranger->getTablesInOrder(); + $referencesToPostpone = $arranger->getReferencesToPostpone(); /** @var Connection $db */ $db = $this->db; @@ -482,20 +512,34 @@ public function actionUpdate(string $inputTable): int } } - $postponedForeignKeys = []; + if ( + $this->hasTimestampsCollision($countTables + count($blueprints)) + && $this->confirm( + ' > There are migration files detected that have timestamps colliding with the ones that will be generated. Are you sure you want to proceed?' + ) === false + ) { + $this->stdout("\n Operation cancelled by user.\n", Console::FG_YELLOW); + return ExitCode::UNSPECIFIED_ERROR; + } - $counterSize = strlen((string)$countTables) + 1; + $postponedForeignKeys = []; + $lastUsedTimestamp = time() + $this->leeway; $migrationsGenerated = 0; foreach ($newTables as $tableName) { $this->stdout("\n > Generating migration for creating table '{$tableName}' ...", Console::FG_YELLOW); + $timestamp = time(); + if ($timestamp <= $lastUsedTimestamp) { + $timestamp = ++$lastUsedTimestamp; + } else { + $lastUsedTimestamp = $timestamp; + } try { $this->generateMigrationForTableCreation( $tableName, sprintf( - "m%s_%0{$counterSize}d_create_table_%s", - gmdate('ymd_His'), - $migrationsGenerated + 1, + "m%s_create_table_%s", + gmdate('ymd_His', $timestamp), str_replace('.', '_', $tableName) ), $referencesToPostpone @@ -505,7 +549,7 @@ public function actionUpdate(string $inputTable): int return ExitCode::UNSPECIFIED_ERROR; } - $migrationsGenerated++; + ++$migrationsGenerated; $this->stdout("\n"); @@ -516,37 +560,41 @@ public function actionUpdate(string $inputTable): int } if ($postponedForeignKeys) { + $timestamp = time(); + if ($timestamp <= $lastUsedTimestamp) { + $timestamp = ++$lastUsedTimestamp; + } else { + $lastUsedTimestamp = $timestamp; + } try { $this->generateMigrationForForeignKeys( $postponedForeignKeys, sprintf( - "m%s_%0{$counterSize}d_create_foreign_keys", - gmdate('ymd_His'), - ++$migrationsGenerated + "m%s_create_foreign_keys", + gmdate('ymd_His', $timestamp) ) ); } catch (Throwable $exception) { $this->stdout("ERROR!\n > {$exception->getMessage()}\n", Console::FG_RED); return ExitCode::UNSPECIFIED_ERROR; } + + ++$migrationsGenerated; } - $countBlueprints = count($blueprints); foreach ($blueprints as $tableName => $blueprint) { $this->stdout("\n > Generating migration for updating table '{$tableName}' ...", Console::FG_YELLOW); $normalizedTableName = str_replace('.', '_', $tableName); - if ($migrationsGenerated === 0 && $countBlueprints === 1) { - $migrationClassName = 'm' . gmdate('ymd_His') . '_update_table_' . $normalizedTableName; + $timestamp = time(); + if ($timestamp <= $lastUsedTimestamp) { + $timestamp = ++$lastUsedTimestamp; } else { - $migrationClassName = sprintf( - "m%s_%0{$counterSize}d_update_table_%s", - gmdate('ymd_His'), - $migrationsGenerated + 1, - $normalizedTableName - ); + $lastUsedTimestamp = $timestamp; } + $migrationClassName = 'm' . gmdate('ymd_His', $timestamp) . '_update_table_' . $normalizedTableName; + try { $this->generateMigrationWithBlueprint($blueprint, $migrationClassName); } catch (Throwable $exception) { @@ -554,7 +602,7 @@ public function actionUpdate(string $inputTable): int return ExitCode::UNSPECIFIED_ERROR; } - $migrationsGenerated++; + ++$migrationsGenerated; $this->stdout("\n"); } @@ -727,7 +775,7 @@ private function generateMigrationForTableCreation( * @return array * @throws NotSupportedException */ - private function prepareTableNames($inputTables): array + private function prepareTableNames(string $inputTables): array { if (strpos($inputTables, ',') !== false) { $tablesList = explode(',', $inputTables); @@ -854,12 +902,11 @@ private function getAllTableNames(Connection $db): array if ($schemaNames === null || count($schemaNames) < 2) { $tables = $db->getSchema()->getTableNames(); } else { + $schemaTables = []; foreach ($schemaNames as $schemaName) { - $tables = array_merge( - $tables, - array_column($db->getSchema()->getTableSchemas($schemaName), 'fullName') - ); + $schemaTables[] = array_column($db->getSchema()->getTableSchemas($schemaName), 'fullName'); } + $tables = array_merge($tables, ...$schemaTables); } return $tables; } @@ -896,6 +943,61 @@ private function setFileModeAndOwnership(string $path): void } // for Yii < 2.0.43 + /** @infection-ignore-all */ FallbackFileHelper::changeOwnership($path, $this->fileOwnership, $mode); } + + private function hasTimestampsCollision(int $tables): bool + { + if ($this->onlyShow === true || $tables <= 0) { + return false; + } + + $now = time() + 5 + $this->leeway; // 5 seconds for response + $lastTimestamp = $now + $tables + 1; // +1 for potential foreign keys migration + + $folders = []; + if ($this->migrationNamespace !== null) { + foreach ((array)$this->migrationNamespace as $namespacedMigration) { + /** @var string $translatedPath */ + $translatedPath = Yii::getAlias('@' . FileHelper::normalizePath($namespacedMigration, '/')); + if (is_dir($translatedPath) === true) { + $folders[] = $translatedPath; + } + } + } else { + foreach ((array)$this->migrationPath as $pathMigration) { + if (is_dir($pathMigration) === true) { + $folders[] = $pathMigration; + } + } + } + + $nowDate = date('ymdHis', $now); + $lastTimestampDate = date('ymdHis', $lastTimestamp); + $foundCollision = false; + foreach ($folders as $folder) { + if ($handle = opendir($folder)) { + while (($file = readdir($handle)) !== false) { + if ($file === '.' || $file === '..') { + continue; + } + $path = $folder . DIRECTORY_SEPARATOR . $file; + if (is_file($path) && preg_match('/m(\d{6}_?\d{6})\D.*?/i', $file, $matches)) { + $time = str_replace('_', '', $matches[1]); + if ($time >= $nowDate && $time <= $lastTimestampDate) { + $foundCollision = true; + break; + } + } + } + closedir($handle); + if ($foundCollision) { + break; + } + } + } + + return $foundCollision; + } } diff --git a/src/dummy/Migration.php b/src/dummy/Migration.php index 426ca476..f6f5dca8 100644 --- a/src/dummy/Migration.php +++ b/src/dummy/Migration.php @@ -431,7 +431,7 @@ public function addCommentOnColumn($table, $column, $comment) public function addCommentOnTable($table, $comment) { // not supported - // Yii is not fetching table's comment when gathering table's info so we can not compare new with old one + // Yii is not fetching table's comment when gathering table's info, so we can not compare new with old one } public function dropCommentFromColumn($table, $column) @@ -442,6 +442,6 @@ public function dropCommentFromColumn($table, $column) public function dropCommentFromTable($table) { // not supported - // Yii is not fetching table's comment when gathering table's info so we can not compare new with old one + // Yii is not fetching table's comment when gathering table's info, so we can not compare new with old one } } diff --git a/src/renderers/BlueprintRenderer.php b/src/renderers/BlueprintRenderer.php index 1971f5f6..f74c7092 100644 --- a/src/renderers/BlueprintRenderer.php +++ b/src/renderers/BlueprintRenderer.php @@ -156,7 +156,7 @@ private function renderColumnsToDrop( } else { $columns = $blueprint->getDroppedColumns(); } - /** @var ColumnInterface $column */ + foreach ($columns as $column) { $renderedColumns[] = $this->columnRenderer->renderDrop($column, $tableName, $indent); } @@ -191,7 +191,7 @@ private function renderColumnsToAdd( $columns = $blueprint->getAddedColumns(); $primaryKey = $blueprint->getTableNewPrimaryKey(); } - /** @var ColumnInterface $column */ + foreach ($columns as $column) { $renderedColumns[] = $this->columnRenderer->renderAdd( $column, @@ -233,7 +233,7 @@ private function renderColumnsToAlter( $columns = $blueprint->getAlteredColumns(); $primaryKey = $blueprint->getTableNewPrimaryKey(); } - /** @var ColumnInterface $column */ + foreach ($columns as $column) { $renderedColumns[] = $this->columnRenderer->renderAlter( $column, @@ -271,7 +271,7 @@ private function renderForeignKeysToDrop( } else { $foreignKeys = $blueprint->getDroppedForeignKeys(); } - /** @var ForeignKeyInterface $foreignKey */ + foreach ($foreignKeys as $foreignKey) { $renderedForeignKeys[] = $this->foreignKeyRenderer->renderDown($foreignKey, $tableName, $indent, $schema); } @@ -306,7 +306,7 @@ private function renderForeignKeysToAdd( } else { $foreignKeys = $blueprint->getAddedForeignKeys(); } - /** @var ForeignKeyInterface $foreignKey */ + foreach ($foreignKeys as $foreignKey) { $renderedForeignKeys[] = $this->foreignKeyRenderer->renderUp( $foreignKey, @@ -341,7 +341,7 @@ private function renderIndexesToDrop( } else { $indexes = $blueprint->getDroppedIndexes(); } - /** @var IndexInterface $index */ + foreach ($indexes as $index) { $renderedIndexes[] = $this->indexRenderer->renderDown($index, $tableName, $indent); } @@ -370,7 +370,7 @@ private function renderIndexesToAdd( } else { $indexes = $blueprint->getAddedIndexes(); } - /** @var IndexInterface $index */ + foreach ($indexes as $index) { $renderedIndexes[] = $this->indexRenderer->renderUp($index, $tableName, $indent); } diff --git a/src/renderers/StructureRenderer.php b/src/renderers/StructureRenderer.php index ac56a6ad..4bf41b78 100644 --- a/src/renderers/StructureRenderer.php +++ b/src/renderers/StructureRenderer.php @@ -166,7 +166,7 @@ private function applyIndent(int $indent, string $template): string * @param StructureInterface $structure * @param string $tableName * @param int $indent - * @param string $schema + * @param string|null $schema * @param string|null $engineVersion * @return string */ @@ -253,9 +253,8 @@ private function renderStructureIndexesUp( $foreignKeys = $structure->getForeignKeys(); $renderedIndexes = []; - /** @var IndexInterface $index */ + foreach ($indexes as $index) { - /** @var ForeignKeyInterface $foreignKey */ foreach ($foreignKeys as $foreignKey) { if ($foreignKey->getName() === $index->getName()) { continue 2; diff --git a/src/table/StructureBuilder.php b/src/table/StructureBuilder.php index 66bfe4ce..b9b4507d 100644 --- a/src/table/StructureBuilder.php +++ b/src/table/StructureBuilder.php @@ -7,10 +7,8 @@ use bizley\migration\Schema; use yii\base\InvalidArgumentException; -use function array_diff; -use function array_intersect; +use function array_intersect_assoc; use function array_key_exists; -use function array_merge; use function count; final class StructureBuilder implements StructureBuilderInterface @@ -196,7 +194,6 @@ private function applyAddPrimaryKeyValue( foreach ($primaryKey->getColumns() as $columnName) { if (array_key_exists($columnName, $columns)) { - /** @var ColumnInterface $column */ $column = $columns[$columnName]; $columnAppend = $column->getAppend(); if (empty($columnAppend)) { @@ -220,7 +217,6 @@ private function applyDropPrimaryKeyValue(StructureInterface $structure, ?string $columns = $structure->getColumns(); foreach ($primaryKey->getColumns() as $columnName) { - /** @var ColumnInterface $column */ $column = $columns[$columnName]; $columnAppend = $column->getAppend(); if (array_key_exists($columnName, $columns) && !empty($columnAppend)) { @@ -333,42 +329,27 @@ private function addHiddenIndexes(StructureInterface $structure, ?string $schema if ($schema === Schema::MYSQL) { // MySQL automatically adds index for foreign key when it's not explicitly added - $indexesToAdd = []; - $foreignKeys = $structure->getForeignKeys(); $indexes = $structure->getIndexes(); - /** @var ForeignKeyInterface $foreignKey */ + $primaryKey = $structure->getPrimaryKey(); + if ($primaryKey !== null) { + // use primary key as a potential index + $indexes[] = $primaryKey; + } foreach ($foreignKeys as $foreignKey) { $foreignKeyColumns = $foreignKey->getColumns(); - $foundIndex = false; - /** @var IndexInterface $index */ + $foreignKeyColumnsCount = count($foreignKeyColumns); foreach ($indexes as $index) { $indexColumns = $index->getColumns(); - $intersection = array_intersect($foreignKeyColumns, $indexColumns); - if ( - count( - array_merge( - array_diff($foreignKeyColumns, $intersection), - array_diff($indexColumns, $intersection) - ) - ) === 0 - ) { - $foundIndex = true; - break; + if ($foreignKeyColumnsCount === count(array_intersect_assoc($foreignKeyColumns, $indexColumns))) { + // any index matching the FK columns as the first columns will do + continue 2; } } - if ($foundIndex === false) { - $indexesToAdd[] = [ - 'name' => $foreignKey->getName(), - 'columns' => $foreignKeyColumns - ]; - } - } - foreach ($indexesToAdd as $indexToAdd) { $index = new Index(); - $index->setName($indexToAdd['name']); - $index->setColumns($indexToAdd['columns']); + $index->setName($foreignKey->getName()); + $index->setColumns($foreignKeyColumns); $structure->addIndex($index); } diff --git a/src/table/TinyIntegerColumn.php b/src/table/TinyIntegerColumn.php index 17bb292e..47e0ca04 100644 --- a/src/table/TinyIntegerColumn.php +++ b/src/table/TinyIntegerColumn.php @@ -59,8 +59,8 @@ public function getLength(string $schema = null, string $engineVersion = null) public function setLength($value, string $schema = null, string $engineVersion = null): void { if ( - $this->isSchemaLengthSupporting($schema, $engineVersion) - || ($schema === Schema::MYSQL && (string)$value === '1') + ($schema === Schema::MYSQL && (string)$value === '1') + || $this->isSchemaLengthSupporting($schema, $engineVersion) ) { $this->setSize($value); $this->setPrecision($value); diff --git a/tests/unit/ComparatorNonSqliteTest.php b/tests/unit/ComparatorNonSqliteTest.php index c44f6937..ebb33315 100644 --- a/tests/unit/ComparatorNonSqliteTest.php +++ b/tests/unit/ComparatorNonSqliteTest.php @@ -392,6 +392,31 @@ public function shouldAlterColumnForGetDefault(): void self::assertSame(['col'], array_keys($this->blueprint->getUnalteredColumns())); } + /** + * @test + */ + public function shouldAlterColumnForGetDefaultNULL(): void + { + $columnNew = $this->getColumn('col'); + $columnNew->setDefault('NULL'); + $columnOld = $this->getColumn('col'); + $columnOld->setDefault(null); + $this->newStructure->method('getColumns')->willReturn(['col' => $columnNew]); + $this->newStructure->method('getColumn')->willReturn($columnNew); + $this->oldStructure->method('getColumns')->willReturn(['col' => $columnOld]); + $this->oldStructure->method('getColumn')->willReturn($columnOld); + + $this->compare(); + + self::assertTrue($this->blueprint->isPending()); + self::assertSame( + ["different 'col' column property: default (DB: \"NULL\" != MIG: NULL)"], + $this->blueprint->getDescriptions() + ); + self::assertSame(['col'], array_keys($this->blueprint->getAlteredColumns())); + self::assertSame(['col'], array_keys($this->blueprint->getUnalteredColumns())); + } + /** * @test */ @@ -745,20 +770,29 @@ public function shouldAlterColumnForGetAppendWithNoIdentityAndEmptyOldAppend(): /** * @test */ - public function shouldAddForeignKey(): void + public function shouldAddForeignKeys(): void { $foreignKey = $this->getForeignKey('fk'); - $this->newStructure->method('getForeignKeys')->willReturn(['fk' => $foreignKey]); + $foreignKey2 = $this->getForeignKey('fk2'); + $this->newStructure->method('getForeignKeys')->willReturnOnConsecutiveCalls( + [ + 'fk' => $foreignKey, + 'fk2' => $foreignKey2, + ] + ); $this->oldStructure->method('getForeignKeys')->willReturn([]); $this->compare(); self::assertTrue($this->blueprint->isPending()); self::assertSame( - ["missing foreign key 'fk'"], + [ + "missing foreign key 'fk'", + "missing foreign key 'fk2'", + ], $this->blueprint->getDescriptions() ); - self::assertSame(['fk'], array_keys($this->blueprint->getAddedForeignKeys())); + self::assertSame(['fk', 'fk2'], array_keys($this->blueprint->getAddedForeignKeys())); } /** @@ -783,26 +817,47 @@ public function shouldDropForeignKey(): void /** * @test */ - public function shouldReplaceForeignKeyWithDifferentColumns(): void + public function shouldReplaceForeignKeysWithDifferentColumns(): void { $foreignKeyNew = $this->getForeignKey('fk'); - $foreignKeyNew->setColumns(['a', 'b']); + $foreignKeyNew->setColumns(['a', 'b', 'd']); + $foreignKeyNew2 = $this->getForeignKey('fk2'); + $foreignKeyNew2->setColumns(['a', 'b']); $foreignKeyOld = $this->getForeignKey('fk'); $foreignKeyOld->setColumns(['a', 'c']); - $this->newStructure->method('getForeignKeys')->willReturn(['fk' => $foreignKeyNew]); - $this->newStructure->method('getForeignKey')->willReturn($foreignKeyNew); - $this->oldStructure->method('getForeignKeys')->willReturn(['fk' => $foreignKeyOld]); - $this->oldStructure->method('getForeignKey')->willReturn($foreignKeyOld); + $foreignKeyOld2 = $this->getForeignKey('fk2'); + $foreignKeyOld2->setColumns(['d', 'e']); + $this->newStructure->method('getForeignKeys')->willReturn( + [ + 'fk' => $foreignKeyNew, + 'fk2' => $foreignKeyNew2, + ] + ); + $this->newStructure + ->method('getForeignKey') + ->willReturnOnConsecutiveCalls($foreignKeyNew, $foreignKeyNew2); + $this->oldStructure->method('getForeignKeys')->willReturn( + [ + 'fk' => $foreignKeyOld, + 'fk2' => $foreignKeyOld2, + ] + ); + $this->oldStructure + ->method('getForeignKey') + ->willReturnOnConsecutiveCalls($foreignKeyOld, $foreignKeyOld2); $this->compare(); self::assertTrue($this->blueprint->isPending()); self::assertSame( - ["different foreign key 'fk' columns (DB: [\"a\",\"b\"] != MIG: [\"a\",\"c\"])"], + [ + "different foreign key 'fk' columns (DB: [\"a\",\"b\",\"d\"] != MIG: [\"a\",\"c\"])", + "different foreign key 'fk2' columns (DB: [\"a\",\"b\"] != MIG: [\"d\",\"e\"])", + ], $this->blueprint->getDescriptions() ); - self::assertSame(['fk'], array_keys($this->blueprint->getAddedForeignKeys())); - self::assertSame(['fk'], array_keys($this->blueprint->getDroppedForeignKeys())); + self::assertSame(['fk', 'fk2'], array_keys($this->blueprint->getAddedForeignKeys())); + self::assertSame(['fk', 'fk2'], array_keys($this->blueprint->getDroppedForeignKeys())); } /** diff --git a/tests/unit/ComparatorSqliteNoShowTest.php b/tests/unit/ComparatorSqliteNoShowTest.php index b1b4b950..0545d2cf 100644 --- a/tests/unit/ComparatorSqliteNoShowTest.php +++ b/tests/unit/ComparatorSqliteNoShowTest.php @@ -201,6 +201,25 @@ public function shouldAlterColumnForGetDefault(): void $this->compare(); } + /** + * @test + */ + public function shouldAlterColumnForGetDefaultNULL(): void + { + $columnNew = $this->getColumn('col'); + $columnNew->setDefault('NULL'); + $columnOld = $this->getColumn('col'); + $columnOld->setDefault(null); + $this->newStructure->method('getColumns')->willReturn(['col' => $columnNew]); + $this->newStructure->method('getColumn')->willReturn($columnNew); + $this->oldStructure->method('getColumns')->willReturn(['col' => $columnOld]); + $this->oldStructure->method('getColumn')->willReturn($columnOld); + + $this->compare(); + + self::assertFalse($this->blueprint->isPending()); + } + /** * @test */ @@ -339,12 +358,18 @@ public function shouldAlterColumnForGetAppendWithNoIdentityAndEmptyOldAppend(): /** * @test */ - public function shouldAddForeignKey(): void + public function shouldAddForeignKeys(): void { $this->expectException(NotSupportedException::class); $foreignKey = $this->getForeignKey('fk'); - $this->newStructure->method('getForeignKeys')->willReturn(['fk' => $foreignKey]); + $foreignKey2 = $this->getForeignKey('fk2'); + $this->newStructure->method('getForeignKeys')->willReturnOnConsecutiveCalls( + [ + 'fk' => $foreignKey, + 'fk2' => $foreignKey2, + ] + ); $this->oldStructure->method('getForeignKeys')->willReturn([]); $this->compare(); @@ -367,18 +392,36 @@ public function shouldDropForeignKey(): void /** * @test */ - public function shouldReplaceForeignKeyWithDifferentColumns(): void + public function shouldReplaceForeignKeysWithDifferentColumns(): void { $this->expectException(NotSupportedException::class); $foreignKeyNew = $this->getForeignKey('fk'); - $foreignKeyNew->setColumns(['a', 'b']); + $foreignKeyNew->setColumns(['a', 'b', 'd']); + $foreignKeyNew2 = $this->getForeignKey('fk2'); + $foreignKeyNew2->setColumns(['a', 'b']); $foreignKeyOld = $this->getForeignKey('fk'); $foreignKeyOld->setColumns(['a', 'c']); - $this->newStructure->method('getForeignKeys')->willReturn(['fk' => $foreignKeyNew]); - $this->newStructure->method('getForeignKey')->willReturn($foreignKeyNew); - $this->oldStructure->method('getForeignKeys')->willReturn(['fk' => $foreignKeyOld]); - $this->oldStructure->method('getForeignKey')->willReturn($foreignKeyOld); + $foreignKeyOld2 = $this->getForeignKey('fk2'); + $foreignKeyOld2->setColumns(['d', 'e']); + $this->newStructure->method('getForeignKeys')->willReturn( + [ + 'fk' => $foreignKeyNew, + 'fk2' => $foreignKeyNew2, + ] + ); + $this->newStructure + ->method('getForeignKey') + ->willReturnOnConsecutiveCalls($foreignKeyNew, $foreignKeyNew2); + $this->oldStructure->method('getForeignKeys')->willReturn( + [ + 'fk' => $foreignKeyOld, + 'fk2' => $foreignKeyOld2, + ] + ); + $this->oldStructure + ->method('getForeignKey') + ->willReturnOnConsecutiveCalls($foreignKeyOld, $foreignKeyOld2); $this->compare(); } diff --git a/tests/unit/ComparatorSqliteShowTest.php b/tests/unit/ComparatorSqliteShowTest.php index 4e82c796..1a81bfa0 100644 --- a/tests/unit/ComparatorSqliteShowTest.php +++ b/tests/unit/ComparatorSqliteShowTest.php @@ -254,6 +254,25 @@ public function shouldAlterColumnForGetDefault(): void self::assertSame(['col'], array_keys($this->blueprint->getUnalteredColumns())); } + /** + * @test + */ + public function shouldAlterColumnForGetDefaultNULL(): void + { + $columnNew = $this->getColumn('col'); + $columnNew->setDefault('NULL'); + $columnOld = $this->getColumn('col'); + $columnOld->setDefault(null); + $this->newStructure->method('getColumns')->willReturn(['col' => $columnNew]); + $this->newStructure->method('getColumn')->willReturn($columnNew); + $this->oldStructure->method('getColumns')->willReturn(['col' => $columnOld]); + $this->oldStructure->method('getColumn')->willReturn($columnOld); + + $this->compare(); + + self::assertFalse($this->blueprint->isPending()); + } + /** * @test * @throws NotSupportedException @@ -463,10 +482,16 @@ public function shouldAlterColumnForGetAppendWithNoIdentityAndEmptyOldAppend(): * @test * @throws NotSupportedException */ - public function shouldAddForeignKey(): void + public function shouldAddForeignKeys(): void { $foreignKey = $this->getForeignKey('fk'); - $this->newStructure->method('getForeignKeys')->willReturn(['fk' => $foreignKey]); + $foreignKey2 = $this->getForeignKey('fk2'); + $this->newStructure->method('getForeignKeys')->willReturnOnConsecutiveCalls( + [ + 'fk' => $foreignKey, + 'fk2' => $foreignKey2, + ] + ); $this->oldStructure->method('getForeignKeys')->willReturn([]); $this->compare(); @@ -475,11 +500,13 @@ public function shouldAddForeignKey(): void self::assertSame( [ "missing foreign key 'fk'", - '(!) ADD FOREIGN KEY is not supported by SQLite: Migration must be created manually' + '(!) ADD FOREIGN KEY is not supported by SQLite: Migration must be created manually', + "missing foreign key 'fk2'", + '(!) ADD FOREIGN KEY is not supported by SQLite: Migration must be created manually', ], $this->blueprint->getDescriptions() ); - self::assertSame(['fk'], array_keys($this->blueprint->getAddedForeignKeys())); + self::assertSame(['fk', 'fk2'], array_keys($this->blueprint->getAddedForeignKeys())); } /** @@ -509,29 +536,49 @@ public function shouldDropForeignKey(): void * @test * @throws NotSupportedException */ - public function shouldReplaceForeignKeyWithDifferentColumns(): void + public function shouldReplaceForeignKeysWithDifferentColumns(): void { $foreignKeyNew = $this->getForeignKey('fk'); - $foreignKeyNew->setColumns(['a', 'b']); + $foreignKeyNew->setColumns(['a', 'b', 'd']); + $foreignKeyNew2 = $this->getForeignKey('fk2'); + $foreignKeyNew2->setColumns(['a', 'b']); $foreignKeyOld = $this->getForeignKey('fk'); $foreignKeyOld->setColumns(['a', 'c']); - $this->newStructure->method('getForeignKeys')->willReturn(['fk' => $foreignKeyNew]); - $this->newStructure->method('getForeignKey')->willReturn($foreignKeyNew); - $this->oldStructure->method('getForeignKeys')->willReturn(['fk' => $foreignKeyOld]); - $this->oldStructure->method('getForeignKey')->willReturn($foreignKeyOld); + $foreignKeyOld2 = $this->getForeignKey('fk2'); + $foreignKeyOld2->setColumns(['d', 'e']); + $this->newStructure->method('getForeignKeys')->willReturn( + [ + 'fk' => $foreignKeyNew, + 'fk2' => $foreignKeyNew2, + ] + ); + $this->newStructure + ->method('getForeignKey') + ->willReturnOnConsecutiveCalls($foreignKeyNew, $foreignKeyNew2); + $this->oldStructure->method('getForeignKeys')->willReturn( + [ + 'fk' => $foreignKeyOld, + 'fk2' => $foreignKeyOld2, + ] + ); + $this->oldStructure + ->method('getForeignKey') + ->willReturnOnConsecutiveCalls($foreignKeyOld, $foreignKeyOld2); $this->compare(); self::assertTrue($this->blueprint->isPending()); self::assertSame( [ - "different foreign key 'fk' columns (DB: [\"a\",\"b\"] != MIG: [\"a\",\"c\"])", - '(!) DROP/ADD FOREIGN KEY is not supported by SQLite: Migration must be created manually' + "different foreign key 'fk' columns (DB: [\"a\",\"b\",\"d\"] != MIG: [\"a\",\"c\"])", + '(!) DROP/ADD FOREIGN KEY is not supported by SQLite: Migration must be created manually', + "different foreign key 'fk2' columns (DB: [\"a\",\"b\"] != MIG: [\"d\",\"e\"])", + '(!) DROP/ADD FOREIGN KEY is not supported by SQLite: Migration must be created manually', ], $this->blueprint->getDescriptions() ); - self::assertSame(['fk'], array_keys($this->blueprint->getAddedForeignKeys())); - self::assertSame(['fk'], array_keys($this->blueprint->getDroppedForeignKeys())); + self::assertSame(['fk', 'fk2'], array_keys($this->blueprint->getAddedForeignKeys())); + self::assertSame(['fk', 'fk2'], array_keys($this->blueprint->getDroppedForeignKeys())); } /** diff --git a/tests/unit/HistoryManagerTest.php b/tests/unit/HistoryManagerTest.php index f4621405..e8eaaf53 100644 --- a/tests/unit/HistoryManagerTest.php +++ b/tests/unit/HistoryManagerTest.php @@ -147,6 +147,7 @@ public function providerForHistory(): array ], [ [ + ['version' => MigrateController::BASE_MIGRATION, 'apply_time' => 1], ['version' => 'a', 'apply_time' => 1], ['version' => 'b', 'apply_time' => '2'], ['version' => 'c', 'apply_time' => 3], @@ -173,10 +174,10 @@ public function providerForHistory(): array [ ['version' => 'm200328_135959_update_table_a', 'apply_time' => 1], ['version' => 'm200328_140000_update_table_a', 'apply_time' => 1], - ['version' => 'm200328_140001_update_table_a', 'apply_time' => 1], + ['version' => 'M200328_140001_UPDATE_TABLE_A', 'apply_time' => 1], ], [ - 'm200328_140001_update_table_a' => 1, + 'M200328_140001_UPDATE_TABLE_A' => 1, 'm200328_140000_update_table_a' => 1, 'm200328_135959_update_table_a' => 1, ] @@ -206,10 +207,13 @@ public function providerForHistory(): array public function shouldReturnHistoryInProperOrder(array $rows, array $expected): void { $this->schema->method('getTableSchema')->willReturn($this->createMock(TableSchema::class)); - $this->query->method('select')->willReturn($this->query); - $this->query->method('from')->willReturn($this->query); - $this->query->method('orderBy')->willReturn($this->query); - $this->query->method('all')->willReturn($rows); + $this->query->method('select')->with(['version', 'apply_time'])->willReturnSelf(); + $this->query->method('from')->with('table')->willReturnSelf(); + $this->query + ->method('orderBy') + ->with(['apply_time' => SORT_DESC, 'version' => SORT_DESC]) + ->willReturnSelf(); + $this->query->method('all')->with($this->db)->willReturn($rows); self::assertSame($expected, $this->manager->fetchHistory()); } } diff --git a/tests/unit/InspectorTest.php b/tests/unit/InspectorTest.php index b14de77b..4f3e339d 100644 --- a/tests/unit/InspectorTest.php +++ b/tests/unit/InspectorTest.php @@ -107,6 +107,7 @@ public function shouldReturnPendingBlueprintWhenAllHistorySkippedAndMigrationNot { $this->historyManager->method('fetchHistory')->willReturn(['migration\\' => 1]); $this->comparator->expects(self::never())->method('compare'); + $this->extractor->expects(self::never())->method('extract'); $structure = $this->createMock(StructureInterface::class); $structure->method('getName')->willReturn('table'); $blueprint = $this->inspector->prepareBlueprint( diff --git a/tests/unit/SqlColumnMapperTest.php b/tests/unit/SqlColumnMapperTest.php index 21c1f745..dafadb5c 100644 --- a/tests/unit/SqlColumnMapperTest.php +++ b/tests/unit/SqlColumnMapperTest.php @@ -382,36 +382,82 @@ public function shouldDetectTypeWithLengthVariant2(): void ); } - /** @test */ - public function shouldDetectComment(): void + public function providerForComments(): array { - self::assertSame( - [ - 'comment' => 'test', - 'type' => 'string', + return [ + 'single quote' => [ + 'comment \'test\'', + [ + 'comment' => 'test', + 'type' => 'string', + ] ], - SqlColumnMapper::map('comment \'test\'', []) - ); - - self::assertSame( - [ - 'comment' => 'test', - 'type' => 'string', + 'single quote prespaced' => [ + ' comment \'test\'', + [ + 'comment' => 'test', + 'type' => 'string', + ], ], - SqlColumnMapper::map(' comment \'test\'', []) - ); + 'single quote no space' => [ + 'comment\'test\' ', + [ + 'comment' => 'test', + 'type' => 'string', + ], + ], + 'single quotes inside' => [ + 'comment "te\'s\'t"', + [ + 'comment' => 'te\'s\'t', + 'type' => 'string', + ], + ], + 'double quotes inside' => [ + "comment 'te''st'", + [ + 'comment' => "te''st", + 'type' => 'string', + ], + ], + 'not closed single' => [ + "comment 'test", + [ + 'comment' => 'test', + 'type' => 'string', + ], + ], + 'not closed double' => [ + 'comment "test', + [ + 'comment' => 'test', + 'type' => 'string', + ], + ], + 'empty single' => [ + "comment ''", + [ + 'comment' => '', + 'type' => 'string', + ], + ], + 'empty double' => [ + 'comment ""', + [ + 'comment' => '', + 'type' => 'string', + ], + ], + ]; } - /** @test */ - public function shouldDetectCommentWithQuote(): void + /** + * @test + * @dataProvider providerForComments + */ + public function shouldDetectComment(string $definition, array $expected): void { - self::assertSame( - [ - 'comment' => "te''st", - 'type' => 'string', - ], - SqlColumnMapper::map("comment 'te''st'", []) - ); + self::assertSame($expected, SqlColumnMapper::map($definition, [])); } /** @test */ @@ -536,6 +582,17 @@ public function shouldDetectWrongExpressionDefault(): void self::assertSame('_TIMESTAMP', $schema['append']); } + /** @test */ + public function shouldProcessEmptyString(): void + { + self::assertSame( + [ + 'type' => 'string', + ], + SqlColumnMapper::map('', []) + ); + } + /** @test */ public function shouldDetectFirst(): void { @@ -558,6 +615,30 @@ public function shouldDetectAfter(): void ], SqlColumnMapper::map('after `col`', []) ); + + self::assertSame( + [ + 'after' => 'hm', + 'type' => 'string', + ], + SqlColumnMapper::map('after`hm`', []) + ); + + self::assertSame( + [ + 'after' => 'col', + 'type' => 'string', + ], + SqlColumnMapper::map(' after `col` ', []) + ); + + self::assertSame( + [ + 'after' => 'col', + 'type' => 'string', + ], + SqlColumnMapper::map('after `col', []) + ); } /** @test */ diff --git a/tests/unit/controllers/FallbackFileHelperTest.php b/tests/unit/controllers/FallbackFileHelperTest.php index 70dd2854..c3e2c724 100644 --- a/tests/unit/controllers/FallbackFileHelperTest.php +++ b/tests/unit/controllers/FallbackFileHelperTest.php @@ -38,7 +38,9 @@ protected function createFileStructure(array $items, $basePath = ''): void foreach ($items as $name => $content) { $itemName = $basePath . DIRECTORY_SEPARATOR . $name; if (is_array($content)) { - mkdir($itemName, 0777, true); + if (@mkdir($itemName, 0777, true) === false) { + self::markTestSkipped("Permission denied to create folder $itemName"); + } $this->createFileStructure($content, $itemName); } else { file_put_contents($itemName, $content); diff --git a/tests/unit/controllers/MigrationControllerTest.php b/tests/unit/controllers/MigrationControllerTest.php index 4541a0fc..0b06506f 100644 --- a/tests/unit/controllers/MigrationControllerTest.php +++ b/tests/unit/controllers/MigrationControllerTest.php @@ -30,12 +30,16 @@ use yii\db\Schema; use yii\db\sqlite\Schema as SqliteSchema; use yii\db\TableSchema; +use yii\helpers\FileHelper; use function chmod; use function fileperms; use function glob; use function is_dir; -use function rmdir; +use function mktime; +use function preg_match_all; +use function substr; +use function time; use function ucfirst; use function unlink; @@ -71,6 +75,7 @@ public function has(): bool $this->db = $this->createMock(Connection::class); $this->controller = new MigrationControllerStub('id', $this->createMock(Module::class)); $this->controller->db = $this->db; + $this->controller->migrationPath = [__DIR__ . '/../../runtime/test']; $this->view = $this->createMock(View::class); $this->view->method('renderFile')->willReturn('rendered_file'); $this->controller->view = $this->view; @@ -124,6 +129,7 @@ public function providerForOptions(): array 'migrationTable', 'useTablePrefix', 'excludeTables', + 'leeway', ] ], 'update' => [ @@ -143,6 +149,7 @@ public function providerForOptions(): array 'migrationTable', 'useTablePrefix', 'excludeTables', + 'leeway', 'onlyShow', 'skipMigrations', 'experimental', @@ -178,6 +185,7 @@ public function shouldReturnProperOptionAliases(): void 'tp' => 'useTablePrefix', 'fm' => 'fileMode', 'fo' => 'fileOwnership', + 'lw' => 'leeway', ], $this->controller->optionAliases() ); @@ -684,20 +692,78 @@ public function shouldCreateManyMigrations(): void MigrationControllerStub::$stdout ); self::assertStringContainsString( - '_01_create_table_test.php\' + '_create_table_test.php\' + + > Generating migration for creating table \'test2\' ...DONE! + > Saved as \'/m', + MigrationControllerStub::$stdout + ); + self::assertStringContainsString( + '_create_table_test2.php\' + + Generated 2 files + (!) Remember to verify files before applying migration. +', + MigrationControllerStub::$stdout + ); + + preg_match_all('/m\d{6}_(\d{6})_create_table/m', MigrationControllerStub::$stdout, $matches); + $time = $matches[1][0]; + self::assertEqualsWithDelta( + time(), + mktime((int)substr($time, 0, 2), (int)substr($time, 2, 2), (int)substr($time, -2)), + 2 + ); + self::assertSame(1, $matches[1][1] - $matches[1][0]); + } + + /** + * @test + */ + public function shouldCreateManyMigrationsWithLeeway(): void + { + $schema = $this->createMock(MysqlSchema::class); + $schema->method('getTableNames')->willReturn(['test', 'test2']); + $schema->method('getRawTableName')->willReturn('mig'); + $schema->method('getTableForeignKeys')->willReturn([]); + $schema->method('getTableIndexes')->willReturn([]); + $this->db->method('getSchema')->willReturn($schema); + $tableSchema = $this->createMock(TableSchema::class); + $this->db->method('getTableSchema')->willReturn($tableSchema); + + $this->controller->leeway = 100; + self::assertSame(ExitCode::OK, $this->controller->actionCreate('*')); + self::assertStringContainsString( + ' > Are you sure you want to generate migrations for the following tables? + - test + - test2 + > Generating migration for creating table \'test\' ...DONE! + > Saved as \'/m', + MigrationControllerStub::$stdout + ); + self::assertStringContainsString( + '_create_table_test.php\' > Generating migration for creating table \'test2\' ...DONE! > Saved as \'/m', MigrationControllerStub::$stdout ); self::assertStringContainsString( - '_02_create_table_test2.php\' + '_create_table_test2.php\' Generated 2 files (!) Remember to verify files before applying migration. ', MigrationControllerStub::$stdout ); + + preg_match_all('/m\d{6}_(\d{6})_create_table/m', MigrationControllerStub::$stdout, $matches); + self::assertEqualsWithDelta( + time() + 100, + mktime((int)substr($matches[1][0], 0, 2), (int)substr($matches[1][0], 2, 2), (int)substr($matches[1][0], -2)), + 5 + ); + self::assertTrue($matches[1][1] - $matches[1][0] >= 1); } /** @@ -734,32 +800,38 @@ public function shouldCreateManyMigrationsWithPostponedForeignKeys(): void ' > Are you sure you want to generate migrations for the following tables? - test - test2 - > Generating migration for creating table \'test\' ...DONE! - > Saved as \'/m', + > Generating migration for creating table \'test\' ...DONE!', MigrationControllerStub::$stdout ); self::assertStringContainsString( - '_01_create_table_test.php\' + '_create_table_test.php\' > Generating migration for creating table \'test2\' ...DONE! > Saved as \'/m', MigrationControllerStub::$stdout ); self::assertStringContainsString( - '_02_create_table_test2.php\' + '_create_table_test2.php\' > Generating migration for creating foreign keys ...DONE! > Saved as \'/m', MigrationControllerStub::$stdout ); self::assertStringContainsString( - '_03_create_foreign_keys.php\' + '_create_foreign_keys.php\' Generated 3 files (!) Remember to verify files before applying migration. ', MigrationControllerStub::$stdout ); + + preg_match_all('/m\d{6}_(\d{6})_create_/m', MigrationControllerStub::$stdout, $matches); + $t1 = mktime((int)substr($matches[1][2], 0, 2), (int)substr($matches[1][2], 2, 2), (int)substr($matches[1][2], -2)); + $t2 = mktime((int)substr($matches[1][1], 0, 2), (int)substr($matches[1][1], 2, 2), (int)substr($matches[1][1], -2)); + $t3 = mktime((int)substr($matches[1][0], 0, 2), (int)substr($matches[1][0], 2, 2), (int)substr($matches[1][0], -2)); + self::assertTrue($t1 - $t2 >= 1); + self::assertTrue($t2 - $t3 >= 1); } /** @@ -784,8 +856,7 @@ public function shouldCreateOneMigrationAndFixHistory(): void self::assertSame(ExitCode::OK, $this->controller->actionCreate('*')); self::assertStringContainsString( ' - > Generating migration for creating table \'test\' ...DONE! - > Saved as \'/m', + > Generating migration for creating table \'test\' ...DONE!', MigrationControllerStub::$stdout ); self::assertStringContainsString( @@ -834,19 +905,18 @@ public function shouldStopCreateManyMigrationsWithPostponedForeignKeysWhenThereI ' > Are you sure you want to generate migrations for the following tables? - test - test2 - > Generating migration for creating table \'test\' ...DONE! - > Saved as \'/m', + > Generating migration for creating table \'test\' ...DONE!', MigrationControllerStub::$stdout ); self::assertStringContainsString( - '_01_create_table_test.php\' + '_create_table_test.php\' > Generating migration for creating table \'test2\' ...DONE! > Saved as \'/m', MigrationControllerStub::$stdout ); self::assertStringContainsString( - '_02_create_table_test2.php\' + '_create_table_test2.php\' > Generating migration for creating foreign keys ...ERROR! > Stub exception @@ -921,12 +991,11 @@ public function shouldCreateOneMigrationWhenNoPreviousDataForUpdate(): void ' > Comparing current table \'test\' with its migrations ...DONE! - > Generating migration for creating table \'test\' ...DONE! - > Saved as \'/m', + > Generating migration for creating table \'test\' ...DONE!', MigrationControllerStub::$stdout ); self::assertStringContainsString( - '_01_create_table_test.php\' + '_create_table_test.php\' Generated 1 file (!) Remember to verify files before applying migration. @@ -960,8 +1029,7 @@ public function shouldCreateOneMigrationAndFixHistoryWhenNoPreviousDataForUpdate ' > Comparing current table \'test\' with its migrations ...DONE! - > Generating migration for creating table \'test\' ...DONE! - > Saved as \'/m', + > Generating migration for creating table \'test\' ...DONE!', MigrationControllerStub::$stdout ); self::assertStringContainsString( @@ -999,19 +1067,18 @@ public function shouldCreateManyMigrationsWhenNoPreviousDataForUpdate(): void > Comparing current table \'test2\' with its migrations ...DONE! - > Generating migration for creating table \'test\' ...DONE! - > Saved as \'/m', + > Generating migration for creating table \'test\' ...DONE!', MigrationControllerStub::$stdout ); self::assertStringContainsString( - '_01_create_table_test.php\' + '_create_table_test.php\' > Generating migration for creating table \'test2\' ...DONE! > Saved as \'/m', MigrationControllerStub::$stdout ); self::assertStringContainsString( - '_02_create_table_test2.php\' + '_create_table_test2.php\' Generated 2 files (!) Remember to verify files before applying migration. @@ -1061,26 +1128,25 @@ public function shouldCreateManyMigrationsWithPostponedForeignKeysWhenNoPrevious > Comparing current table \'test2\' with its migrations ...DONE! - > Generating migration for creating table \'test\' ...DONE! - > Saved as \'/m', + > Generating migration for creating table \'test\' ...DONE!', MigrationControllerStub::$stdout ); self::assertStringContainsString( - '_01_create_table_test.php\' + '_create_table_test.php\' > Generating migration for creating table \'test2\' ...DONE! > Saved as \'/m', MigrationControllerStub::$stdout ); self::assertStringContainsString( - '_02_create_table_test2.php\' + '_create_table_test2.php\' > Generating migration for creating foreign keys ...DONE! > Saved as \'/m', MigrationControllerStub::$stdout ); self::assertStringContainsString( - '_03_create_foreign_keys.php\' + '_create_foreign_keys.php\' Generated 3 files (!) Remember to verify files before applying migration. @@ -1288,14 +1354,14 @@ public function shouldStopUpdateManyMigrationsWithPostponedForeignKeysWhenThereI MigrationControllerStub::$stdout ); self::assertStringContainsString( - '_01_create_table_test.php\' + '_create_table_test.php\' > Generating migration for creating table \'test2\' ...DONE! > Saved as \'/m', MigrationControllerStub::$stdout ); self::assertStringContainsString( - '_02_create_table_test2.php\' + '_create_table_test2.php\' > Generating migration for creating foreign keys ...ERROR! > Stub exception @@ -1460,9 +1526,7 @@ public function shouldCreateDirectoryForPath(): void { chmod(__DIR__ . '/../../runtime', 0777); - if (is_dir(__DIR__ . '/../../runtime/test')) { - rmdir(__DIR__ . '/../../runtime/test'); - } + FileHelper::removeDirectory(__DIR__ . '/../../runtime/test'); $controller = new MigrationControllerStoringStub('id', $this->createMock(Module::class)); $controller->db = $this->db; @@ -1482,9 +1546,7 @@ public function shouldCreateDirectoryForPathByNamespace(): void { chmod(__DIR__ . '/../../runtime', 0777); - if (is_dir(__DIR__ . '/../../runtime/test')) { - rmdir(__DIR__ . '/../../runtime/test'); - } + FileHelper::removeDirectory(__DIR__ . '/../../runtime/test'); $controller = new MigrationControllerStoringStub('id', $this->createMock(Module::class)); $controller->db = $this->db; diff --git a/tests/unit/controllers/TimestampMigrationControllerTest.php b/tests/unit/controllers/TimestampMigrationControllerTest.php new file mode 100644 index 00000000..dccc0569 --- /dev/null +++ b/tests/unit/controllers/TimestampMigrationControllerTest.php @@ -0,0 +1,214 @@ +errorHandler = new stdClass(); + } + + public function has(): bool + { + return false; + } + }; + $this->db = $this->createMock(Connection::class); + $this->controller = new MigrationControllerStub('id', $this->createMock(Module::class)); + $this->controller->db = $this->db; + $this->schema = $this->createMock(MysqlSchema::class); + $this->db->method('getSchema')->willReturn($this->schema); + $this->db->method('getTableSchema')->willReturn($this->createMock(TableSchema::class)); + $this->view = $this->createMock(View::class); + $this->view->method('renderFile')->willReturn('rendered_file'); + $this->controller->view = $this->view; + Yii::setAlias('@bizley/tests', __DIR__ . '/../..'); + MigrationControllerStub::$stdout = ''; + MigrationControllerStub::$confirmControl = true; + UpdaterStub::$throwForPrepare = false; + UpdaterStub::$throwForGenerate = false; + GeneratorStub::$throwForTable = false; + GeneratorStub::$throwForKeys = false; + + $this->prepareFolder(); + } + + protected function tearDown(): void + { + Yii::$app = null; + } + + private function prepareFolder(): void + { + $path = __DIR__ . '/../../runtime/test'; + FileHelper::removeDirectory($path); + FileHelper::createDirectory($path); + } + + /** @test */ + public function shouldDetectCollisionOnCreateWithMigrationPath(): void + { + $this->controller->migrationPath = [__DIR__ . '/../../runtime/test']; + + $now = time(); + $count = 0; + while ($count < 10) { + file_put_contents( + __DIR__ . '/../../runtime/test/' . sprintf( + 'm%s_create_table_tab', + gmdate('ymd_His', $now + $count++) + ), + '' + ); + } + $this->schema->method('getTableNames')->willReturn(['test']); + $this->schema->method('getRawTableName')->willReturn('mig'); + MigrationControllerStub::$confirmControl = false; + + self::assertSame(ExitCode::UNSPECIFIED_ERROR, $this->controller->actionCreate('test')); + self::assertSame( + ' > There are migration files detected that have timestamps colliding with the ones that will be generated. Are you sure you want to proceed? + Operation cancelled by user. +', + MigrationControllerStub::$stdout + ); + } + + /** @test */ + public function shouldDetectCollisionOnUpdateWithMigrationPath(): void + { + $this->controller->migrationPath = [__DIR__ . '/../../runtime/test']; + + $now = time(); + $count = 0; + while ($count < 10) { + file_put_contents( + __DIR__ . '/../../runtime/test/' . sprintf( + 'm%s_create_table_tab', + gmdate('ymd_His', $now + $count++) + ), + '' + ); + } + $this->schema->method('getTableNames')->willReturn(['test']); + $this->schema->method('getRawTableName')->willReturn('mig'); + $this->schema->method('getTableForeignKeys')->willReturn([]); + $this->schema->method('getTableIndexes')->willReturn([]); + MigrationControllerStub::$confirmControl = false; + + self::assertSame(ExitCode::UNSPECIFIED_ERROR, $this->controller->actionUpdate('test')); + self::assertSame( + ' + > Comparing current table \'test\' with its migrations ...DONE! + > There are migration files detected that have timestamps colliding with the ones that will be generated. Are you sure you want to proceed? + Operation cancelled by user. +', + MigrationControllerStub::$stdout + ); + } + + /** @test */ + public function shouldDetectCollisionOnCreateWithMigrationNamespace(): void + { + $this->controller->migrationNamespace = ['bizley\\tests\\runtime\\test']; + + $now = time(); + $count = 0; + while ($count < 10) { + file_put_contents( + __DIR__ . '/../../runtime/test/' . sprintf( + 'M%sCreateTableTab', + gmdate('ymdHis', $now + $count++) + ), + '' + ); + } + $this->schema->method('getTableNames')->willReturn(['test']); + $this->schema->method('getRawTableName')->willReturn('mig'); + MigrationControllerStub::$confirmControl = false; + + self::assertSame(ExitCode::UNSPECIFIED_ERROR, $this->controller->actionCreate('test')); + self::assertSame( + ' > There are migration files detected that have timestamps colliding with the ones that will be generated. Are you sure you want to proceed? + Operation cancelled by user. +', + MigrationControllerStub::$stdout + ); + } + + /** @test */ + public function shouldDetectCollisionOnUpdateWithMigrationNamespace(): void + { + $this->controller->migrationNamespace = ['bizley\\tests\\runtime\\test']; + + $now = time(); + $count = 0; + while ($count < 10) { + file_put_contents( + __DIR__ . '/../../runtime/test/' . sprintf( + 'M%sCreateTableTab', + gmdate('ymd_His', $now + $count++) + ), + '' + ); + } + $this->schema->method('getTableNames')->willReturn(['test']); + $this->schema->method('getRawTableName')->willReturn('mig'); + $this->schema->method('getTableForeignKeys')->willReturn([]); + $this->schema->method('getTableIndexes')->willReturn([]); + MigrationControllerStub::$confirmControl = false; + + self::assertSame(ExitCode::UNSPECIFIED_ERROR, $this->controller->actionUpdate('test')); + self::assertSame( + ' + > Comparing current table \'test\' with its migrations ...DONE! + > There are migration files detected that have timestamps colliding with the ones that will be generated. Are you sure you want to proceed? + Operation cancelled by user. +', + MigrationControllerStub::$stdout + ); + } +} diff --git a/tests/unit/renderers/ColumnRendererTest.php b/tests/unit/renderers/ColumnRendererTest.php index a9796726..234b4959 100644 --- a/tests/unit/renderers/ColumnRendererTest.php +++ b/tests/unit/renderers/ColumnRendererTest.php @@ -60,6 +60,7 @@ public function shouldRenderProperlyPrimaryKeyColumnWithNoLength(): void $column = $this->createMock(PrimaryKeyColumnInterface::class); $column->method('getName')->willReturn('col'); $column->method('getDefinition')->willReturn('def({renderLength})'); + $column->method('isNotNull')->willReturn(true); self::assertSame('\'col\' => $this->def(),', $this->getRenderer()->render($column)); }