diff --git a/README.md b/README.md index dfc2268..064ea3c 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,7 @@ $container = new Container( fn(): Migrator => new Migrator( collection: new MigrationCollection( new Migration( - path: __DIR__ . '/migration/sqlite/memory', + path: __DIR__ . '/migration/sqlite/db', driver: new PdoDriver( dsn: 'sqlite:' . __DIR__ . '/data/sqlite/db.sqlite3', ) @@ -137,19 +137,70 @@ make app ```shell /example $ php cli.php migrate:init -[sqlite/memory] initialization: setup.sql done +[sqlite/db] initialization: setup.sql done /example $ php cli.php migrate:up -[sqlite/memory] up: 202501011024_entity_create.sql done -[sqlite/memory] up: 202501021024_account_create.sql done -[sqlite/memory] up: 202501021025_account_email.sql done -[sqlite/memory] repeatable: 202501011024_entity_correction.sql done -[sqlite/memory] repeatable: 202501011024_entity_correction_2.sql done +[sqlite/db] up: 202501011024_entity_create.sql done +[sqlite/db] up: 202501021024_account_create.sql done +[sqlite/db] up: 202501021025_account_email.sql done /example $ php cli.php migrate:down -[sqlite/memory] down: 202501021025_account_email.sql done -[sqlite/memory] down: 202501021024_account_create.sql done -[sqlite/memory] down: 202501011024_entity_create.sql done +[sqlite/db] down: 202501021025_account_email.sql done +[sqlite/db] down: 202501021024_account_create.sql done +[sqlite/db] down: 202501011024_entity_create.sql done +``` + +#### With exactly all + +If any migration fails, the entire batch is rolled back, leaving the database unchanged. + +```shell +/example $ php cli.php migrate:up --exactly-all +[sqlite/db] up: 202501011024_entity_create.sql done +[sqlite/db] up: 202501021024_account_create.sql done +[sqlite/db] up: 202501021025_account_email.sql done +``` + +#### With repeatable + +```shell +/example $ php cli.php migrate:up --with-repeatable +[sqlite/db] up: 202501011024_entity_create.sql done +[sqlite/db] up: 202501021024_account_create.sql done +[sqlite/db] up: 202501021025_account_email.sql done +[sqlite/db] repeatable: 202501011024_entity_correction.sql done +[sqlite/db] repeatable: 202501011024_entity_correction_2.sql done +``` + +#### Down with latest version + +```shell +/example $ php cli.php migrate:up --limit=1 +[sqlite/db] up: 202501011024_entity_create.sql, vers: 1772723563954 done + +/example $ php cli.php migrate:up --limit=2 +[sqlite/db] up: 202501021024_account_create.sql, vers: 1772723566084 done +[sqlite/db] up: 202501021025_account_email.sql, vers: 1772723566084 done + +/example $ php cli.php migrate:down --latest-version +[sqlite/db] down: 202501021025_account_email.sql, vers: 1772723566084 done +[sqlite/db] down: 202501021024_account_create.sql, vers: 1772723566084 done + +``` + +#### Redo with latest version + +```shell +/example $ php cli.php migrate:up +[sqlite/db] up: 202501021024_account_create.sql, vers: 1772723718828 done +[sqlite/db] up: 202501021025_account_email.sql, vers: 1772723718828 done + +/example $ php cli.php migrate:redo --latest-version +[sqlite/db] down: 202501021025_account_email.sql, vers: 1772723718828 done +[sqlite/db] down: 202501021024_account_create.sql, vers: 1772723718828 done +[sqlite/db] up: 202501021024_account_create.sql, vers: 1772723727397 done +[sqlite/db] up: 202501021025_account_email.sql, vers: 1772723727397 done + ``` ### Static analysis diff --git a/composer.json b/composer.json index 0f1fbb0..1cdfcd4 100644 --- a/composer.json +++ b/composer.json @@ -20,6 +20,7 @@ "league/climate": "^3.10" }, "require-dev": { + "buggregator/trap": "^1.15", "infection/infection": "^0.32.4", "php-di/php-di": "^7.0", "phpstan/phpstan": "^2.0", diff --git a/example/migration/mysql/setup/setup.sql b/example/migration/mysql/setup/setup.sql deleted file mode 100644 index 557654a..0000000 --- a/example/migration/mysql/setup/setup.sql +++ /dev/null @@ -1,5 +0,0 @@ -CREATE TABLE IF NOT EXISTS `%SYSTEM_TABLE%` ( - `name` varchar(512) COLLATE utf8mb4_unicode_ci NOT NULL, - `atime` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (`name`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/example/migration/postgres/setup/setup.sql b/example/migration/postgres/setup/setup.sql deleted file mode 100644 index b742d0a..0000000 --- a/example/migration/postgres/setup/setup.sql +++ /dev/null @@ -1,5 +0,0 @@ -CREATE TABLE IF NOT EXISTS "%SYSTEM_TABLE%" -( - "name" varchar(512) NOT NULL PRIMARY KEY, - "atime" timestamp(0) NOT NULL DEFAULT CURRENT_TIMESTAMP -); diff --git a/example/migration/sqlite/setup/setup.sql b/example/migration/sqlite/setup/setup.sql deleted file mode 100644 index 06269a9..0000000 --- a/example/migration/sqlite/setup/setup.sql +++ /dev/null @@ -1,5 +0,0 @@ -CREATE TABLE IF NOT EXISTS %SYSTEM_TABLE% -( - name TEXT PRIMARY KEY, - atime TEXT -) diff --git a/example/presentation/CommandOptions.php b/example/presentation/CommandOptions.php index 61e38f5..414b20c 100644 --- a/example/presentation/CommandOptions.php +++ b/example/presentation/CommandOptions.php @@ -28,8 +28,16 @@ protected function getArguments(InputInterface $input): InputArgs $options['dbName'] = $this->getOptionDbName($input); } - if ($input->hasOption('without-repeatable')) { - $options['hasRepeatable'] = $this->getOptionWithoutRepeatable($input) === false; // inversion + if ($input->hasOption('with-repeatable')) { + $options['hasRepeatable'] = $this->getOptionWithRepeatable($input); + } + + if ($input->hasOption('latest-version')) { + $options['applyLatestVersion'] = $this->getOptionApplyLatestVersion($input); + } + + if ($input->hasOption('exactly-all')) { + $options['exactlyAll'] = $this->getOptionExactlyAll($input); } return new InputArgs(...$options); @@ -61,9 +69,25 @@ private function getOptionDryRun(InputInterface $input): bool /** * @throws InvalidArgumentException */ - private function getOptionWithoutRepeatable(InputInterface $input): bool + private function getOptionApplyLatestVersion(InputInterface $input): bool + { + return $input->getOption('latest-version') === true; + } + + /** + * @throws InvalidArgumentException + */ + private function getOptionExactlyAll(InputInterface $input): bool + { + return $input->getOption('exactly-all') === true; + } + + /** + * @throws InvalidArgumentException + */ + private function getOptionWithRepeatable(InputInterface $input): bool { - return $input->getOption('without-repeatable') === true; + return $input->getOption('with-repeatable') === true; } /** diff --git a/example/presentation/DownCommand.php b/example/presentation/DownCommand.php index 1dd3571..f89fc2c 100644 --- a/example/presentation/DownCommand.php +++ b/example/presentation/DownCommand.php @@ -40,8 +40,24 @@ public function __construct(private readonly MigratorInterface $migrator) protected function configure(): void { $this->addOption('db', null, InputOption::VALUE_OPTIONAL, 'Name database'); - $this->addOption('limit', null, InputOption::VALUE_OPTIONAL, 'Number of files processed'); - $this->addOption('dry-run', null, InputOption::VALUE_NONE, 'Dry run'); + $this->addOption( + 'limit', + null, + InputOption::VALUE_OPTIONAL, + 'Sets the maximum number of migrations to be executed or rolled back.' + ); + $this->addOption( + 'latest-version', + null, + InputOption::VALUE_NONE, + 'Targets the most recent version for rollback.' + ); + $this->addOption( + 'dry-run', + null, + InputOption::VALUE_NONE, + 'Simulates the migration process without applying any changes to the database.' + ); } #[Override] diff --git a/example/presentation/RedoCommand.php b/example/presentation/RedoCommand.php index df509b5..7537f83 100644 --- a/example/presentation/RedoCommand.php +++ b/example/presentation/RedoCommand.php @@ -40,8 +40,24 @@ public function __construct(private readonly MigratorInterface $migrator) protected function configure(): void { $this->addOption('db', null, InputOption::VALUE_OPTIONAL, 'Name database'); - $this->addOption('limit', null, InputOption::VALUE_OPTIONAL, 'Number of files processed'); - $this->addOption('dry-run', null, InputOption::VALUE_NONE, 'Dry run'); + $this->addOption( + 'limit', + null, + InputOption::VALUE_OPTIONAL, + 'Sets the maximum number of migrations to be executed or rolled back.' + ); + $this->addOption( + 'latest-version', + null, + InputOption::VALUE_NONE, + 'Targets the most recent version for rollback.' + ); + $this->addOption( + 'dry-run', + null, + InputOption::VALUE_NONE, + 'Simulates the migration process without applying any changes to the database.' + ); } #[Override] diff --git a/example/presentation/UpCommand.php b/example/presentation/UpCommand.php index b3176b3..95c9ab1 100644 --- a/example/presentation/UpCommand.php +++ b/example/presentation/UpCommand.php @@ -40,9 +40,32 @@ public function __construct(private readonly MigratorInterface $migrator) protected function configure(): void { $this->addOption('db', null, InputOption::VALUE_OPTIONAL, 'Name database'); - $this->addOption('limit', null, InputOption::VALUE_OPTIONAL, 'Number of files processed'); - $this->addOption('dry-run', null, InputOption::VALUE_NONE, 'Dry run'); - $this->addOption('without-repeatable', null, InputOption::VALUE_NONE, 'Repeatable disable'); + $this->addOption( + 'limit', + null, + InputOption::VALUE_OPTIONAL, + 'Sets the maximum number of migrations to be executed or rolled back.' + ); + $this->addOption( + 'dry-run', + null, + InputOption::VALUE_NONE, + 'Simulates the migration process without applying any changes to the database.' + ); + $this->addOption( + 'exactly-all', + null, + InputOption::VALUE_NONE, + 'Ensures atomic execution of all migrations. ' . + 'If any migration fails, the entire batch is rolled back, leaving the database unchanged.' + ); + $this->addOption( + 'with-repeatable', + null, + InputOption::VALUE_NONE, + 'Includes migrations from the repeatable directory in the execution. ' . + 'These scripts typically run every time their content changes, regardless of versioning.' + ); } #[Override] diff --git a/infection.json.dist b/infection.json.dist index c79dc45..228091b 100644 --- a/infection.json.dist +++ b/infection.json.dist @@ -5,7 +5,7 @@ ] }, "threads": "max", - "minCoveredMsi": 97, + "minCoveredMsi": 99, "phpUnit": { "configDir": "." }, diff --git a/src/Context.php b/src/Context.php index e89da80..82cafc7 100644 --- a/src/Context.php +++ b/src/Context.php @@ -4,19 +4,25 @@ namespace kuaukutsu\poc\migration; +/** + * @infection-ignore-all IncrementInteger + */ final readonly class Context { /** * @param non-empty-string $dbName * @param non-empty-string $filename - * @param non-empty-string $queryString + * @param non-empty-string $query + * @param non-negative-int $version */ public function __construct( public string $dbName, public string $filename, - public string $queryString, + public string $query, + public int $version = 0, public bool $dryRun = false, ) { + assert($this->version >= 0); } public function getName(): string diff --git a/src/InputArgs.php b/src/InputArgs.php index 297cce0..7ed5344 100644 --- a/src/InputArgs.php +++ b/src/InputArgs.php @@ -8,22 +8,42 @@ { /** * @param non-negative-int $limit + * @param non-negative-int $version */ public function __construct( public int $limit = 0, + public int $version = 0, public bool $dryRun = false, - public bool $hasRepeatable = true, public ?string $dbName = null, + public bool $exactlyAll = false, + private bool $hasRepeatable = false, + private bool $applyLatestVersion = false, ) { assert($this->limit >= 0); + assert($this->version >= 0); } public function withResetLimit(): self { return new self( + version: $this->version, dryRun: $this->dryRun, - hasRepeatable: $this->hasRepeatable, dbName: $this->dbName, + exactlyAll: $this->exactlyAll, + hasRepeatable: $this->hasRepeatable, + applyLatestVersion: $this->applyLatestVersion, ); } + + public function hasApplyLatestVersion(): bool + { + return $this->applyLatestVersion + && $this->version === 0 + && ($this->limit === 0 || $this->limit > 1); + } + + public function hasRepeatable(): bool + { + return $this->hasRepeatable && $this->dryRun === false; + } } diff --git a/src/Migrator.php b/src/Migrator.php index f7c3c0a..e95894f 100644 --- a/src/Migrator.php +++ b/src/Migrator.php @@ -46,10 +46,6 @@ public function up(InputArgs $args = new InputArgs()): void { foreach ($this->selectDb($args) as $migration) { $this->actionWorkflow->up($migration, $args); - - if ($args->hasRepeatable) { - $this->actionWorkflow->repeatable($migration, $args); - } } } diff --git a/src/connection/StatementInterface.php b/src/connection/StatementInterface.php index 6964073..ed234da 100644 --- a/src/connection/StatementInterface.php +++ b/src/connection/StatementInterface.php @@ -7,9 +7,14 @@ interface StatementInterface { /** - * @return list + * @param non-empty-string $query + * @param array $params + * @return array */ - public function query(string $query): array; + public function fetchRecord(string $query, array $params = []): array; + /** + * @param non-empty-string $query + */ public function exec(string $query): void; } diff --git a/src/connection/mysql/migration/setup.sql b/src/connection/mysql/migration/setup.sql index 829dd22..b097c1a 100644 --- a/src/connection/mysql/migration/setup.sql +++ b/src/connection/mysql/migration/setup.sql @@ -1,6 +1,8 @@ CREATE TABLE IF NOT EXISTS `%SYSTEM_TABLE%` ( `name` varchar(512) COLLATE utf8mb4_unicode_ci NOT NULL, + `version` BIGINT UNSIGNED DEFAULT 0, `atime` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (`name`) + PRIMARY KEY (`name`), + KEY `i_%SYSTEM_TABLE%_version` (`version`) USING BTREE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/src/connection/pgsql/migration/setup.sql b/src/connection/pgsql/migration/setup.sql index b742d0a..5a75cef 100644 --- a/src/connection/pgsql/migration/setup.sql +++ b/src/connection/pgsql/migration/setup.sql @@ -1,5 +1,8 @@ CREATE TABLE IF NOT EXISTS "%SYSTEM_TABLE%" ( "name" varchar(512) NOT NULL PRIMARY KEY, + "version" bigint DEFAULT 0, "atime" timestamp(0) NOT NULL DEFAULT CURRENT_TIMESTAMP ); + +CREATE INDEX IF NOT EXISTS "i_%SYSTEM_TABLE%_version" ON %SYSTEM_TABLE% ("version"); diff --git a/src/connection/sqlite/migration/setup.sql b/src/connection/sqlite/migration/setup.sql index 06269a9..20c6e08 100644 --- a/src/connection/sqlite/migration/setup.sql +++ b/src/connection/sqlite/migration/setup.sql @@ -1,5 +1,8 @@ CREATE TABLE IF NOT EXISTS %SYSTEM_TABLE% ( name TEXT PRIMARY KEY, + version INT DEFAULT 0, atime TEXT -) +); + +CREATE INDEX IF NOT EXISTS i_%SYSTEM_TABLE%_version ON %SYSTEM_TABLE%(version); diff --git a/src/event/MigrateErrorEvent.php b/src/event/MigrateErrorEvent.php index c663448..745b4e7 100644 --- a/src/event/MigrateErrorEvent.php +++ b/src/event/MigrateErrorEvent.php @@ -30,10 +30,11 @@ public function getName(): string public function getMessage(): string { return sprintf( - "[%s] %s: %s\r\n%s", + "[%s] %s: %s, vers: %d\r\n%s", $this->context->dbName, $this->action, $this->context->filename, + $this->context->version, $this->exception->getMessage(), ); } diff --git a/src/event/MigrateSuccessEvent.php b/src/event/MigrateSuccessEvent.php index 86ffde8..4ba5ac9 100644 --- a/src/event/MigrateSuccessEvent.php +++ b/src/event/MigrateSuccessEvent.php @@ -28,10 +28,11 @@ public function getName(): string public function getMessage(): string { return sprintf( - "[%s] %s: %s", + "[%s] %s: %s, vers: %d", $this->context->dbName, $this->action, $this->context->filename, + $this->context->version, ); } } diff --git a/src/internal/action/Workflow.php b/src/internal/action/Workflow.php index 368cc01..00b9267 100644 --- a/src/internal/action/Workflow.php +++ b/src/internal/action/Workflow.php @@ -6,6 +6,7 @@ use Iterator; use Throwable; +use DateTimeImmutable; use kuaukutsu\poc\migration\event\ExceptionEvent; use kuaukutsu\poc\migration\event\Event; use kuaukutsu\poc\migration\event\EventAction; @@ -42,23 +43,37 @@ public function up(Migration $migration, InputArgs $args): void { $command = $this->makeCommand($migration); - $savedMigration = $this->fetchSavedMigration($migration, $command, new command\Args()); + $appliedMigrations = $this->getAppliedMigrations($migration, $command, new command\Args()); $fsHandler = static fn(filesystem\Action $fs): Iterator => $fs->up( - $savedMigration, + $appliedMigrations, filesystem\Args::makeFromInput($args) ); - foreach ($this->iteratorHandler($migration, $fsHandler) as $filename => $queryString) { - $this->run( - $command->up(...), - new Context( - dbName: $migration->getName(), - filename: $filename, - queryString: $queryString, - dryRun: $args->dryRun, - ), - EventAction::up, - ); + $version = generateVersion(); + foreach ($this->iteratorHandler($migration, $fsHandler) as $filename => $query) { + try { + $this->run( + $command->up(...), + new Context( + dbName: $migration->getName(), + filename: $filename, + query: $query, + version: $version, + dryRun: $args->dryRun, + ), + EventAction::up, + ); + } catch (ActionException $exception) { + if ($args->exactlyAll) { + $this->down($migration, new InputArgs(version: $version)); + } + + throw $exception; + } + } + + if ($args->hasRepeatable()) { + $this->repeatable($migration, $command, $version); } } @@ -72,16 +87,17 @@ public function down(Migration $migration, InputArgs $args): void { $command = $this->makeCommand($migration); - $savedMigration = $this->fetchSavedMigration($migration, $command, command\Args::makeFromInput($args)); - $fsHandler = static fn(filesystem\Action $fs): Iterator => $fs->down($savedMigration); + $appliedMigrations = $this->getAppliedMigrations($migration, $command, command\Args::makeFromInput($args)); + $fsHandler = static fn(filesystem\Action $fs): Iterator => $fs->down($appliedMigrations); - foreach ($this->iteratorHandler($migration, $fsHandler) as $filename => $queryString) { + foreach ($this->iteratorHandler($migration, $fsHandler) as $filename => $query) { $this->run( $command->down(...), new Context( dbName: $migration->getName(), filename: $filename, - queryString: $queryString, + query: $query, + version: $appliedMigrations[$filename], dryRun: $args->dryRun, ), EventAction::down, @@ -102,13 +118,13 @@ public function fixture(Migration $migration, InputArgs $args): void filesystem\Args::makeFromInput($args) ); - foreach ($this->iteratorHandler($migration, $fsHandler) as $filename => $queryString) { + foreach ($this->iteratorHandler($migration, $fsHandler) as $filename => $query) { $this->run( $command->exec(...), new Context( dbName: $migration->getName(), filename: $filename, - queryString: $queryString, + query: $query, dryRun: $args->dryRun, ), EventAction::fixture, @@ -121,67 +137,73 @@ public function fixture(Migration $migration, InputArgs $args): void * @throws ConfigurationException * @throws ConnectionException */ - public function repeatable(Migration $migration, InputArgs $args): void + public function initialization(Migration $migration): void { $command = $this->makeCommand($migration); - $fsHandler = static fn(filesystem\Action $fs): Iterator => $fs->repeatable(); + try { + $files = (new filesystem\Setup($migration->getSetupPath(), $migration->table))->all(); + } catch (ConfigurationException $exception) { + $this->eventDispatcher->trigger( + Event::FilesystemError, + new ExceptionEvent($migration->getName(), $exception) + ); - foreach ($this->iteratorHandler($migration, $fsHandler, false) as $filename => $queryString) { + throw $exception; + } + + foreach ($files as $filename => $query) { $this->run( $command->exec(...), new Context( dbName: $migration->getName(), filename: $filename, - queryString: $queryString, - dryRun: $args->dryRun, + query: $query, ), - EventAction::repeatable, + EventAction::initialization, ); } } /** + * @param non-negative-int $version * @throws ActionException * @throws ConfigurationException * @throws ConnectionException */ - public function initialization(Migration $migration): void + private function repeatable(Migration $migration, CommandInterface $command, int $version): void { - $command = $this->makeCommand($migration); - - try { - $files = (new filesystem\Setup($migration->getSetupPath(), $migration->table))->all(); - } catch (ConfigurationException $exception) { - $this->eventDispatcher->trigger( - Event::FilesystemError, - new ExceptionEvent($migration->getName(), $exception) - ); - - throw $exception; - } + $fsHandler = static fn(filesystem\Action $fs): Iterator => $fs->repeatable(); - foreach ($files as $filename => $queryString) { + foreach ($this->iteratorHandler($migration, $fsHandler, false) as $filename => $query) { $this->run( $command->exec(...), new Context( dbName: $migration->getName(), filename: $filename, - queryString: $queryString, + query: $query, + version: $version, ), - EventAction::initialization, + EventAction::repeatable, ); } } /** - * @return list + * @return array * @throws InitializationException */ - private function fetchSavedMigration(Migration $migration, CommandInterface $command, command\Args $args): array + private function getAppliedMigrations(Migration $migration, CommandInterface $command, command\Args $args): array { try { - return $command->fetchSavedMigrationNames($args); + if ($args->applyLatestVersion) { + $appliedMigrations = $command->fetchApplied(new command\Args(limit: 1)); + if (count($appliedMigrations) === 1) { + $args = $args->withVersion(current($appliedMigrations)); + } + } + + return $command->fetchApplied($args); } catch (Throwable $exception) { $this->eventDispatcher->trigger( Event::InitializationError, @@ -193,21 +215,13 @@ private function fetchSavedMigration(Migration $migration, CommandInterface $com } /** - * @param callable(non-empty-string $queryString, non-empty-string $filename):bool $handler + * @param callable(Context $context):bool $handler * @throws ActionException */ private function run(callable $handler, Context $context, EventAction $action): void { - if ($context->dryRun) { - $this->eventDispatcher->trigger( - Event::MigrateSuccess, - new MigrateSuccessEvent($action->name, $context) - ); - return; - } - try { - $handler($context->queryString, $context->filename); + $handler($context); $this->eventDispatcher->trigger( Event::MigrateSuccess, new MigrateSuccessEvent($action->name, $context) @@ -278,3 +292,15 @@ private function makeCommand(Migration $migration): CommandInterface } } } + +/** + * Unixtime + milleseconds + * @return positive-int + */ +function generateVersion(): int +{ + /** + * @var positive-int + */ + return (int)substr((new DateTimeImmutable())->format('Uv'), 0, -1); +} diff --git a/src/internal/command/Args.php b/src/internal/command/Args.php index 01e0eb7..221ce57 100644 --- a/src/internal/command/Args.php +++ b/src/internal/command/Args.php @@ -13,17 +13,34 @@ { /** * @param non-negative-int $limit + * @param non-negative-int $version */ public function __construct( public int $limit = 0, + public int $version = 0, + public bool $applyLatestVersion = false, ) { assert($this->limit >= 0); + assert($this->version >= 0); } public static function makeFromInput(InputArgs $args): self { return new self( limit: $args->limit, + version: $args->version, + applyLatestVersion: $args->hasApplyLatestVersion(), + ); + } + + /** + * @param non-negative-int $version + */ + public function withVersion(int $version): self + { + return new self( + limit: $this->limit, + version: $version, ); } } diff --git a/src/internal/command/Command.php b/src/internal/command/Command.php index 2647883..7063d9e 100644 --- a/src/internal/command/Command.php +++ b/src/internal/command/Command.php @@ -7,6 +7,7 @@ use Override; use Throwable; use kuaukutsu\poc\migration\connection\ConnectionInterface; +use kuaukutsu\poc\migration\Context; /** * @psalm-internal kuaukutsu\poc\migration @@ -20,29 +21,41 @@ public function __construct( } #[Override] - public function fetchSavedMigrationNames(Args $args = new Args()): array + public function fetchApplied(Args $args = new Args()): array { - // SQLSTATE[42P01]: Undefined table: 7 ERROR: relation "migration" does not exist - $query = sprintf('SELECT name FROM %s ORDER BY atime DESC, name DESC', $this->params->table); + $params = []; + + $query = sprintf('SELECT name, version FROM %s', $this->params->table); + if ($args->version > 0) { + $query .= ' WHERE version=:version'; + $params['version'] = $args->version; + } + + $query .= ' ORDER BY atime DESC, name DESC'; if ($args->limit > 0) { $query .= ' LIMIT ' . $args->limit; } - return $this->connection->query($query); + return $this->connection->fetchRecord($query, $params); } #[Override] - public function up(string $queryString, string $filename): bool + public function up(Context $context): bool { + if ($context->dryRun) { + return false; + } + $transaction = $this->connection->beginTransaction(); try { - $transaction->exec($queryString); + $transaction->exec($context->query); $transaction->exec( sprintf( - 'INSERT INTO %s (name, atime) VALUES (\'%s\', \'%s\')', + 'INSERT INTO %s (name, version, atime) VALUES (\'%s\', %d, \'%s\')', $this->params->table, - $filename, + $context->filename, + $context->version, gmdate('Y-m-d H:i:s'), ) ); @@ -56,17 +69,21 @@ public function up(string $queryString, string $filename): bool } #[Override] - public function down(string $queryString, string $filename): bool + public function down(Context $context): bool { + if ($context->dryRun) { + return false; + } + $transaction = $this->connection->beginTransaction(); try { - $transaction->exec($queryString); + $transaction->exec($context->query); $transaction->exec( sprintf( 'DELETE FROM %s WHERE name=\'%s\'', $this->params->table, - $filename, + $context->filename, ) ); } catch (Throwable $exception) { @@ -79,12 +96,16 @@ public function down(string $queryString, string $filename): bool } #[Override] - public function exec(string $queryString, string $filename): bool + public function exec(Context $context): bool { + if ($context->dryRun) { + return false; + } + $transaction = $this->connection->beginTransaction(); try { - $transaction->exec($queryString); + $transaction->exec($context->query); } catch (Throwable $exception) { $transaction->rollBack(); diff --git a/src/internal/command/CommandInterface.php b/src/internal/command/CommandInterface.php index df1ed4f..10507c4 100644 --- a/src/internal/command/CommandInterface.php +++ b/src/internal/command/CommandInterface.php @@ -6,32 +6,30 @@ use Throwable; use kuaukutsu\poc\migration\internal\command; +use kuaukutsu\poc\migration\Context; interface CommandInterface { /** - * @return list + * @return array */ - public function fetchSavedMigrationNames(command\Args $args = new command\Args()): array; + public function fetchApplied(command\Args $args = new command\Args()): array; /** - * @param non-empty-string $queryString - * @param non-empty-string $filename + * @return bool true: request completed; false: request rejected * @throws Throwable */ - public function up(string $queryString, string $filename): bool; + public function up(Context $context): bool; /** - * @param non-empty-string $queryString - * @param non-empty-string $filename + * @return bool true: request completed; false: request rejected * @throws Throwable */ - public function down(string $queryString, string $filename): bool; + public function down(Context $context): bool; /** - * @param non-empty-string $queryString - * @param non-empty-string $filename + * @return bool true: request completed; false: request rejected * @throws Throwable */ - public function exec(string $queryString, string $filename): bool; + public function exec(Context $context): bool; } diff --git a/src/internal/connection/PDO/Connection.php b/src/internal/connection/PDO/Connection.php index c2a16e0..74d4d55 100644 --- a/src/internal/connection/PDO/Connection.php +++ b/src/internal/connection/PDO/Connection.php @@ -28,14 +28,14 @@ public function beginTransaction(): TransactionInterface } #[Override] - public function query(string $query): array + public function fetchRecord(string $query, array $params = []): array { $statement = $this->connection->prepare($query); - if ($statement->execute()) { + if ($statement->execute($params)) { /** - * @var list + * @var array */ - return $statement->fetchAll(PDO::FETCH_COLUMN); + return $statement->fetchAll(PDO::FETCH_KEY_PAIR); } return []; diff --git a/src/internal/connection/PDO/Transaction.php b/src/internal/connection/PDO/Transaction.php index 90ebbee..6aab67e 100644 --- a/src/internal/connection/PDO/Transaction.php +++ b/src/internal/connection/PDO/Transaction.php @@ -36,14 +36,14 @@ public function isActive(): bool } #[Override] - public function query(string $query): array + public function fetchRecord(string $query, array $params = []): array { $statement = $this->connection->prepare($query); - if ($statement->execute()) { + if ($statement->execute($params)) { /** - * @var list + * @var array */ - return $statement->fetchAll(PDO::FETCH_COLUMN); + return $statement->fetchAll(PDO::FETCH_KEY_PAIR); } return []; diff --git a/src/internal/filesystem/Action.php b/src/internal/filesystem/Action.php index 37bfd35..4d4e991 100644 --- a/src/internal/filesystem/Action.php +++ b/src/internal/filesystem/Action.php @@ -26,18 +26,16 @@ public function __construct(string $path) } /** - * @param list $listSavedFilename + * @param array $listExcluded * @return Iterator * @throws ConfigurationException */ - public function up(array $listSavedFilename, Args $args = new Args()): Iterator + public function up(array $listExcluded, Args $args = new Args()): Iterator { - $excludeMap = array_flip($listSavedFilename); - $iternum = 0; foreach ($this->makeIterator($this->path) as $matchFilename) { $filepath = $matchFilename[0]; - if (isset($excludeMap[basename($filepath)])) { + if (isset($listExcluded[basename($filepath)])) { continue; } @@ -56,15 +54,22 @@ public function up(array $listSavedFilename, Args $args = new Args()): Iterator } /** - * @param list $listSavedFilename + * @param array $listApplied * @return Iterator * @throws ConfigurationException */ - public function down(array $listSavedFilename): Iterator + public function down(array $listApplied, Args $args = new Args()): Iterator { - foreach ($listSavedFilename as $filename) { + $iternum = 0; + foreach ($listApplied as $filename => $_) { + if ($args->limit > 0 && $iternum >= $args->limit) { + return; + } + $command = $this->prepareCommand($this->path . $filename, 'down'); if ($command !== null) { + $iternum++; + yield $filename => $command; } } @@ -152,16 +157,13 @@ private function prepareCommand(string $filepath, string $actionKey): ?string if (preg_match_all('/^--\s?@(?\w+)\s?\R(?(?:(?!^--\s?@).)*)/ms', $queryString, $match) > 0) { /** - * @var array{"action": non-empty-string[], "query": non-empty-string[]} $match - * @phpstan-ignore varTag.differentVariable + * @var array{"action": non-empty-string[], "query": string[]} $match + * @phpstan-ignore varTag.nativeType */ - foreach ($match['action'] as $key => $action) { - if ($action === $actionKey) { - /** - * @var non-empty-string - */ - return $match['query'][$key]; - } + $key = array_search($actionKey, $match['action'], true); + if ($key !== false) { + $query = $match['query'][$key]; + return $query === '' ? null : $query; } } elseif ($actionKey === 'up') { return $queryString; diff --git a/src/tools/PrettyConsoleOutput.php b/src/tools/PrettyConsoleOutput.php index 76111ed..90a3d7b 100644 --- a/src/tools/PrettyConsoleOutput.php +++ b/src/tools/PrettyConsoleOutput.php @@ -29,9 +29,9 @@ public function subscriptions(): array foreach (Event::cases() as $event) { $subscriptions[$event->value] = match ($event) { Event::MigrateSuccess => $this->success(...), - Event::MigrateError => $this->errorMigration(...), + Event::MigrateError => $this->error(...), Event::FilesystemNotice => $this->notice(...), - default => $this->error(...), + default => $this->failure(...), }; } @@ -48,20 +48,32 @@ public function subscriptions(): array public function success(Event $name, MigrateSuccessEvent $event): void { $this->output->out( - sprintf( - '[%s] %s: %s %s', - $event->context->dbName, - $event->action, - $event->context->filename, - $event->context->dryRun ? 'dry-run' : 'done', - ) + match ($event->action) { + "up", + "down", + "repeatable" => sprintf( + '[%s] %s: %s, vers: %d %s', + $event->context->dbName, + $event->action, + $event->context->filename, + $event->context->version, + $event->context->dryRun ? 'dry-run' : 'done', + ), + default => sprintf( + '[%s] %s: %s %s', + $event->context->dbName, + $event->action, + $event->context->filename, + $event->context->dryRun ? 'dry-run' : 'done', + ) + } ); } /** * @noinspection PhpUnusedParameterInspection */ - public function errorMigration(Event $name, MigrateErrorEvent $event): void + public function error(Event $name, MigrateErrorEvent $event): void { $this->output->out( sprintf( @@ -73,13 +85,13 @@ public function errorMigration(Event $name, MigrateErrorEvent $event): void ); $this->output->red($event->exception->getMessage()); - $this->output->out($event->context->queryString); + $this->output->out($event->context->query); } /** * @noinspection PhpUnusedParameterInspection */ - public function error(Event $name, EventInterface $event): void + public function failure(Event $name, EventInterface $event): void { $this->output->out( sprintf( diff --git a/tests/internal/FilesDownTest.php b/tests/internal/FilesDownTest.php index bd9dac0..b2e6563 100644 --- a/tests/internal/FilesDownTest.php +++ b/tests/internal/FilesDownTest.php @@ -7,6 +7,7 @@ use Override; use PHPUnit\Framework\TestCase; use kuaukutsu\poc\migration\exception\ConfigurationException; +use kuaukutsu\poc\migration\internal\filesystem\Args; use kuaukutsu\poc\migration\internal\filesystem\Action; final class FilesDownTest extends TestCase @@ -21,7 +22,9 @@ protected function setUp(): void public function testDown(): void { - $iterator = $this->fs->down(['202501011024_entity_create.sql']); + $savedFilenames = ['202501011024_entity_create.sql' => 1]; + + $iterator = $this->fs->down($savedFilenames); self::assertTrue($iterator->valid()); foreach ($iterator as $filename => $sql) { @@ -30,6 +33,47 @@ public function testDown(): void } } + public function testLimit(): void + { + $savedFilenames = [ + '202501011024_entity_create.sql' => 1, + '202501021024_account_create.sql' => 1, + '202501021025_account_email.sql' => 1, + ]; + + $iterator = $this->fs->down($savedFilenames, new Args(limit: 1)); + self::assertTrue($iterator->valid()); + + $files = []; + foreach ($iterator as $filename => $_) { + $files[] = $filename; + } + + self::assertCount(1, $files); + self::assertEquals('202501011024_entity_create.sql', $files[0]); + + $iterator = $this->fs->up([], new Args(limit: 2)); + self::assertTrue($iterator->valid()); + + $files = []; + foreach ($iterator as $filename => $_) { + $files[] = $filename; + } + + self::assertCount(2, $files); + self::assertEquals('202501011024_entity_create.sql', $files[0]); + self::assertEquals('202501021024_account_create.sql', $files[1]); + } + + public function testLimitSkipZero(): void + { + $savedFilenames = ['202501011024_entity_create.sql' => 1]; + + $iterator = $this->fs->down($savedFilenames, new Args(limit: 0)); + self::assertTrue($iterator->valid()); + self::assertNotEmpty(iterator_count($iterator)); + } + public function testFilterEmpty(): void { $listFilename = []; @@ -42,16 +86,18 @@ public function testFilterEmpty(): void public function testFilterNotMatch(): void { + $savedFilenames = ['not_match.sql' => 1]; $this->expectException(ConfigurationException::class); - $this->fs->down(['not_match'])->valid(); + $this->fs->down($savedFilenames)->valid(); } public function testDirNotExists(): void { + $savedFilenames = ['202501011024_entity_create.sql' => 1]; $this->expectException(ConfigurationException::class); $fs = new Action(dirname(__DIR__) . '/migration/postgres/not-exists'); - $fs->down(['202501011024_entity_create.sql'])->valid(); + $fs->down($savedFilenames)->valid(); } } diff --git a/tests/internal/FilesUpTest.php b/tests/internal/FilesUpTest.php index 183e62f..3bc06d0 100644 --- a/tests/internal/FilesUpTest.php +++ b/tests/internal/FilesUpTest.php @@ -58,7 +58,7 @@ public function testLimit(): void self::assertEquals('202501021024_account_create.sql', $files[1]); } - public function testUpLimitSkipZero(): void + public function testLimitSkipZero(): void { $iterator = $this->fs->up([], new Args(limit: 0)); self::assertTrue($iterator->valid()); @@ -96,7 +96,7 @@ public function testSkip(): void public function testFilter(): void { - $savedFilenames = ['202501011024_entity_create.sql']; + $savedFilenames = ['202501011024_entity_create.sql' => 1]; $listFilename = []; foreach ($this->fs->up($savedFilenames) as $filename => $_) { @@ -108,7 +108,7 @@ public function testFilter(): void public function testFilterNotMatch(): void { - $savedFilenames = ['202501011024_not_match_filename.sql']; + $savedFilenames = ['202501011024_not_match_filename.sql' => 1]; $listFilename = []; foreach ($this->fs->up($savedFilenames) as $filename => $_) { diff --git a/tests/migration/postgres/setup/setup.sql b/tests/migration/postgres/setup/setup.sql index b742d0a..bf85c6b 100644 --- a/tests/migration/postgres/setup/setup.sql +++ b/tests/migration/postgres/setup/setup.sql @@ -1,5 +1,8 @@ CREATE TABLE IF NOT EXISTS "%SYSTEM_TABLE%" ( "name" varchar(512) NOT NULL PRIMARY KEY, + "version" bigint DEFAULT 0, "atime" timestamp(0) NOT NULL DEFAULT CURRENT_TIMESTAMP -); + ); + +CREATE INDEX IF NOT EXISTS "i_%SYSTEM_TABLE%_version" ON %SYSTEM_TABLE% ("version"); diff --git a/tests/migration/sqlite/memory/202501011026_empty.sql b/tests/migration/sqlite/memory/202501011026_empty.sql new file mode 100644 index 0000000..3de2ab6 --- /dev/null +++ b/tests/migration/sqlite/memory/202501011026_empty.sql @@ -0,0 +1,3 @@ +-- @up + +-- @down diff --git a/tests/migration/sqlite/setup/setup.sql b/tests/migration/sqlite/setup/setup.sql index 06269a9..20c6e08 100644 --- a/tests/migration/sqlite/setup/setup.sql +++ b/tests/migration/sqlite/setup/setup.sql @@ -1,5 +1,8 @@ CREATE TABLE IF NOT EXISTS %SYSTEM_TABLE% ( name TEXT PRIMARY KEY, + version INT DEFAULT 0, atime TEXT -) +); + +CREATE INDEX IF NOT EXISTS i_%SYSTEM_TABLE%_version ON %SYSTEM_TABLE%(version); diff --git a/tests/stub/TestCommand.php b/tests/stub/TestCommand.php index 609a714..0128c17 100644 --- a/tests/stub/TestCommand.php +++ b/tests/stub/TestCommand.php @@ -7,6 +7,7 @@ use Override; use kuaukutsu\poc\migration\internal\command; use kuaukutsu\poc\migration\internal\command\CommandInterface; +use kuaukutsu\poc\migration\Context; final readonly class TestCommand implements CommandInterface { @@ -15,11 +16,8 @@ public function __construct( ) { } - /** - * @inheritDoc - */ #[Override] - public function fetchSavedMigrationNames(command\Args $args = new command\Args()): array + public function fetchApplied(command\Args $args = new command\Args()): array { if ($args->limit > 0) { return array_slice($this->storage->getMigration(), 0, $args->limit); @@ -28,35 +26,26 @@ public function fetchSavedMigrationNames(command\Args $args = new command\Args() return $this->storage->getMigration(); } - /** - * @inheritDoc - */ #[Override] - public function up(string $queryString, string $filename): bool + public function up(Context $context): bool { - $this->storage->set($filename, $queryString); - $this->storage->saveMigration($filename); + $this->storage->set($context->filename, $context->query); + $this->storage->saveMigration($context->filename, $context->version); return true; } - /** - * @inheritDoc - */ #[Override] - public function down(string $queryString, string $filename): bool + public function down(Context $context): bool { - $this->storage->set($filename, $queryString); - $this->storage->dropMigration($filename); + $this->storage->set($context->filename, $context->query); + $this->storage->dropMigration($context->filename); return true; } - /** - * @inheritDoc - */ #[Override] - public function exec(string $queryString, string $filename): bool + public function exec(Context $context): bool { - $this->storage->set($filename, $queryString); + $this->storage->set($context->filename, $context->query); return true; } } diff --git a/tests/stub/TestStorage.php b/tests/stub/TestStorage.php index 6207188..4eb81d2 100644 --- a/tests/stub/TestStorage.php +++ b/tests/stub/TestStorage.php @@ -9,7 +9,7 @@ final class TestStorage { /** - * @var array + * @var array */ private array $table = []; @@ -19,24 +19,25 @@ final class TestStorage private array $memory = []; /** - * @return list + * @return array */ public function getMigration(): array { - return array_keys($this->table); + return $this->table; } /** * @param non-empty-string $key + * @param non-negative-int $version * @throws RuntimeException */ - public function saveMigration(string $key): void + public function saveMigration(string $key, int $version): void { if (array_key_exists($key, $this->table)) { throw new RuntimeException("record exists"); } - $this->table[$key] = true; + $this->table[$key] = $version; } /** diff --git a/tests/workflow/ArgumentsTest.php b/tests/workflow/ArgumentsTest.php index f3ec1e9..f27ccf3 100644 --- a/tests/workflow/ArgumentsTest.php +++ b/tests/workflow/ArgumentsTest.php @@ -37,76 +37,143 @@ protected function setUp(): void public function testUpWithLimit(): void { $this->migrator->init(); - $data = $this->command->fetchSavedMigrationNames(); + $data = $this->command->fetchApplied(); self::assertEmpty($data); $this->migrator->up(new InputArgs(limit: 1)); - $data = $this->command->fetchSavedMigrationNames(); + $data = $this->command->fetchApplied(); self::assertCount(1, $data); - self::assertEquals('202501011024_entity_create.sql', $data[0]); + self::assertNotEmpty($data['202501011024_entity_create.sql']); $this->migrator->up(new InputArgs(limit: 2)); - $data = $this->command->fetchSavedMigrationNames(); + $data = $this->command->fetchApplied(); self::assertCount(3, $data); - self::assertEquals('202501021025_account_email.sql', $data[0]); - self::assertEquals('202501021024_account_create.sql', $data[1]); - self::assertEquals('202501011024_entity_create.sql', $data[2]); + + $names = array_keys($data); + self::assertEquals('202501021025_account_email.sql', $names[0]); + self::assertEquals('202501021024_account_create.sql', $names[1]); + self::assertEquals('202501011024_entity_create.sql', $names[2]); } public function testDownWithLimit(): void { $this->migrator->init(); - $data = $this->command->fetchSavedMigrationNames(); + $data = $this->command->fetchApplied(); self::assertEmpty($data); $this->migrator->up(); $this->migrator->down(new InputArgs(limit: 1)); - $data = $this->command->fetchSavedMigrationNames(); + $data = $this->command->fetchApplied(); self::assertCount(2, $data); $this->migrator->down(new InputArgs(limit: 2)); - $data = $this->command->fetchSavedMigrationNames(); + $data = $this->command->fetchApplied(); self::assertEmpty($data); } public function testWithDryRun(): void { $this->migrator->init(); - $data = $this->command->fetchSavedMigrationNames(); + $data = $this->command->fetchApplied(); self::assertEmpty($data); $this->migrator->up(); - $data = $this->command->fetchSavedMigrationNames(); + $data = $this->command->fetchApplied(); self::assertCount(3, $data); $this->migrator->down(new InputArgs(dryRun: true)); - $data = $this->command->fetchSavedMigrationNames(); + $data = $this->command->fetchApplied(); self::assertCount(3, $data); $this->migrator->down(); - $data = $this->command->fetchSavedMigrationNames(); + $data = $this->command->fetchApplied(); self::assertEmpty($data); } public function testWithDb(): void { $this->migrator->init(); - $data = $this->command->fetchSavedMigrationNames(); + $data = $this->command->fetchApplied(); self::assertEmpty($data); $this->migrator->up(new InputArgs(limit: 2, dbName: 'sqlite/memory')); - $data = $this->command->fetchSavedMigrationNames(); + $data = $this->command->fetchApplied(); self::assertCount(2, $data); } public function testWithUnknownDb(): void { $this->migrator->init(); - $data = $this->command->fetchSavedMigrationNames(); + $data = $this->command->fetchApplied(); self::assertEmpty($data); $this->expectException(ConfigurationException::class); $this->migrator->up(new InputArgs(dbName: 'sqlite/unknown')); } + + public function testDownWithVersion(): void + { + $this->migrator->init(); + $data = $this->command->fetchApplied(); + self::assertEmpty($data); + + $this->migrator->up(new InputArgs(limit: 2)); + $data = $this->command->fetchApplied(); + self::assertCount(2, $data); + + $version = (int)current($data); + self::assertGreaterThan(0, $version); + + // not found version + $this->migrator->down(new InputArgs(version: 2)); + $data = $this->command->fetchApplied(); + self::assertCount(2, $data); + + // down with version + $this->migrator->down(new InputArgs(version: $version)); + $data = $this->command->fetchApplied(); + self::assertEmpty($data); + } + + public function testDownWithLatestVersion(): void + { + $this->migrator->init(); + $data = $this->command->fetchApplied(); + self::assertEmpty($data); + + $args = new InputArgs(limit: 1); + self::assertFalse($args->hasApplyLatestVersion()); + + $this->migrator->up($args); + $data = $this->command->fetchApplied(); + self::assertCount(1, $data); + + usleep(10_000); + + $this->migrator->up($args); + $data = $this->command->fetchApplied(); + self::assertCount(2, $data); + + $this->migrator->up($args); + $data = $this->command->fetchApplied(); + self::assertCount(3, $data); + + $args = new InputArgs(); + self::assertFalse($args->hasApplyLatestVersion()); + + $args = new InputArgs(version: 111, applyLatestVersion: true); + self::assertFalse($args->hasApplyLatestVersion()); + + $args = new InputArgs(limit: 1, applyLatestVersion: true); + self::assertFalse($args->hasApplyLatestVersion()); + + $args = new InputArgs(applyLatestVersion: true); + self::assertTrue($args->hasApplyLatestVersion()); + + // down with latest version + $this->migrator->down($args); + $data = $this->command->fetchApplied(); + self::assertNotEmpty($data); + } } diff --git a/tests/workflow/CommandTest.php b/tests/workflow/CommandTest.php index 9f37a91..da913bd 100644 --- a/tests/workflow/CommandTest.php +++ b/tests/workflow/CommandTest.php @@ -15,6 +15,7 @@ use kuaukutsu\poc\migration\internal\command\CommandInterface; use kuaukutsu\poc\migration\internal\command\Params; use kuaukutsu\poc\migration\internal\connection\PDO\Connection; +use kuaukutsu\poc\migration\Context; final class CommandTest extends TestCase { @@ -36,7 +37,7 @@ public function testInit(): void { $this->execInitialization(); - $data = $this->command->fetchSavedMigrationNames(); + $data = $this->command->fetchApplied(); self::assertEmpty($data); } @@ -49,8 +50,8 @@ public function testUp(): void $this->execUp('table1'); - $data = $this->command->fetchSavedMigrationNames(); - self::assertEquals('test-table1', $data[0]); + $data = $this->command->fetchApplied(); + self::assertNotEmpty($data['test-table1']); } /** @@ -63,17 +64,18 @@ public function testDown(): void $this->execUp('table1'); $this->execUp('table2'); - $data = $this->command->fetchSavedMigrationNames(); - self::assertContains('test-table1', $data); + $data = $this->command->fetchApplied(); self::assertCount(2, $data); + self::assertNotEmpty($data['test-table1']); + self::assertNotEmpty($data['test-table2']); $this->execDown('table1'); - $data = $this->command->fetchSavedMigrationNames(); - self::assertContains('test-table2', $data); + $data = $this->command->fetchApplied(); self::assertCount(1, $data); + self::assertNotEmpty($data['test-table2']); $this->execDown('table2'); - $data = $this->command->fetchSavedMigrationNames(); + $data = $this->command->fetchApplied(); self::assertEmpty($data); } @@ -88,29 +90,180 @@ public function testFetchLimit(): void $this->execUp('table2'); $this->execUp('table3'); - $data = $this->command->fetchSavedMigrationNames( + $data = $this->command->fetchApplied( new Args(limit: 1) ); self::assertCount(1, $data); - self::assertEquals('test-table3', $data[0]); + self::assertNotEmpty($data['test-table3']); - $data = $this->command->fetchSavedMigrationNames( + $data = $this->command->fetchApplied( new Args(limit: 2) ); self::assertCount(2, $data); - self::assertEquals('test-table3', $data[0]); - self::assertEquals('test-table2', $data[1]); + $names = array_keys($data); + self::assertEquals('test-table3', $names[0]); + self::assertEquals('test-table2', $names[1]); } /** * @throws Throwable */ - public function testPDOException(): void + public function testFetchVersion(): void + { + $this->execInitialization(); + + $this->execUp('table1', 111); + $this->execUp('table2', 111); + $this->execUp('table3', 222); + + $data = $this->command->fetchApplied( + new Args(version: 111) + ); + self::assertCount(2, $data); + self::assertNotEmpty($data['test-table1']); + self::assertNotEmpty($data['test-table2']); + + $data = $this->command->fetchApplied( + new Args(version: 222) + ); + self::assertCount(1, $data); + self::assertNotEmpty($data['test-table3']); + + // сомнительный кейс, но допускаем + $data = $this->command->fetchApplied( + new Args(limit: 1, version: 111) + ); + self::assertCount(1, $data); + self::assertNotEmpty($data['test-table2']); + } + + /** + * @throws Throwable + */ + public function testUpDryRun(): void + { + $this->execInitialization(); + + $response = $this->command->up( + new Context( + dbName: 'test', + filename: 'test', + query: '--test', + dryRun: false, + ) + ); + + self::assertTrue($response); + + $response = $this->command->up( + new Context( + dbName: 'test', + filename: 'test', + query: '--test', + dryRun: true, + ) + ); + + self::assertFalse($response); + } + + /** + * @throws Throwable + */ + public function testDownDryRun(): void + { + $this->execInitialization(); + + $response = $this->command->down( + new Context( + dbName: 'test', + filename: 'test', + query: '--test', + dryRun: false, + ) + ); + + self::assertTrue($response); + + $response = $this->command->down( + new Context( + dbName: 'test', + filename: 'test', + query: '--test', + dryRun: true, + ) + ); + + self::assertFalse($response); + } + + /** + * @throws Throwable + */ + public function testUpRollbackTransaction(): void + { + $this->execInitialization(); + + $this->execUp('table1'); + $data = $this->command->fetchApplied(); + self::assertCount(1, $data); + self::assertNotEmpty($data['test-table1']); + + try { + $this->execFailQuery(); + } catch (Throwable) { + } + + $data = $this->command->fetchApplied(); + self::assertCount(1, $data); + self::assertNotEmpty($data['test-table1']); + } + + /** + * @throws Throwable + */ + public function testDownRollbackTransaction(): void + { + $this->execInitialization(); + + $this->execUp('table1'); + $data = $this->command->fetchApplied(); + self::assertCount(1, $data); + self::assertNotEmpty($data['test-table1']); + + try { + // Моделируем ошибку вставки записи в таблицу логирования с последующим откатом транзакции. + $this->execDown('migration'); + } catch (Throwable) { + } + + $data = $this->command->fetchApplied(); + self::assertCount(1, $data); + self::assertNotEmpty($data['test-table1']); + } + + /** + * @throws Throwable + */ + public function testUpPDOException(): void { $this->expectException(PDOException::class); + $this->expectExceptionMessage("SQLSTATE[HY000]: General error: 1 no such table"); $this->execUp('table1'); } + /** + * @throws Throwable + */ + public function testDownPDOException(): void + { + $this->execInitialization(); + + $this->expectException(PDOException::class); + $this->expectExceptionMessage("SQLSTATE[HY000]: General error: 1 no such table"); + $this->execDown('migration'); + } + /** * @throws Throwable */ @@ -120,19 +273,26 @@ private function execInitialization(string $tableName = 'migration'): void CREATE TABLE IF NOT EXISTS $tableName ( name TEXT PRIMARY KEY, + version INT DEFAULT 0, atime TEXT -) +); + +CREATE INDEX IF NOT EXISTS i_{$tableName}_version ON $tableName(version); SQL; $this->command->exec( - queryString: $queryString, - filename: 'test', + new Context( + dbName: 'test', + filename: 'test', + query: $queryString, + ) ); } /** + * @param non-negative-int $version * @throws Throwable */ - private function execUp(string $tableName): void + private function execUp(string $tableName, int $version = 1): void { $queryString = <<command->up( - queryString: $queryString, - filename: 'test-' . $tableName, + new Context( + dbName: 'test', + filename: 'test-' . $tableName, + query: $queryString, + version: $version, + ) ); } @@ -155,8 +319,30 @@ private function execDown(string $tableName): void DROP TABLE IF EXISTS $tableName SQL; $this->command->down( - queryString: $queryString, - filename: 'test-' . $tableName, + new Context( + dbName: 'test', + filename: 'test-' . $tableName, + query: $queryString, + ) + ); + } + + /** + * @note Моделируем ошибку вставки записи в таблицу логирования с последующим откатом транзакции. + * @throws Throwable + */ + private function execFailQuery(): void + { + $queryString = <<command->up( + new Context( + dbName: 'test', + filename: 'test-unknown', + query: $queryString, + version: 1, + ) ); } } diff --git a/tests/workflow/EventTest.php b/tests/workflow/EventTest.php index b5b989e..9914bf9 100644 --- a/tests/workflow/EventTest.php +++ b/tests/workflow/EventTest.php @@ -103,13 +103,15 @@ public function testMigration(): void } catch (Throwable) { } + $version = substr((string)time(), 0, -2); + self::assertStringContainsString( - '202501011024_entity_create.sql', + '202501011024_entity_create.sql, vers: ' . $version, $eventSubscriber->get(Event::MigrateSuccess) ); self::assertStringContainsString( - '202501021025_account_error.sql', + '202501021025_account_error.sql, vers: ' . $version, $eventSubscriber->get(Event::MigrateError) ); } @@ -138,6 +140,29 @@ public function testMigrationDryRun(): void $eventSubscriber->get(Event::MigrateSuccess) ); + // event-repeatable: does not exist, but does not start in dry-run mode + self::assertStringNotContainsString( + 'does not exist.', + $eventSubscriber->get(Event::FilesystemNotice) + ); + } + + public function testMigrationFilesystemNotice(): void + { + $eventSubscriber = new TestSubscriber(); + $migrator = MigratorFactory::makeFromEvent( + new PdoDriver( + dsn: 'sqlite::memory:', + ), + [ + $eventSubscriber, + ] + ); + + $migrator->init(); + $migrator->up(new InputArgs(limit: 1, hasRepeatable: true)); + + // event-repeatable: does not exist self::assertStringContainsString( 'does not exist.', $eventSubscriber->get(Event::FilesystemNotice) diff --git a/tests/workflow/MigrationFailTest.php b/tests/workflow/MigrationFailTest.php new file mode 100644 index 0000000..967a383 --- /dev/null +++ b/tests/workflow/MigrationFailTest.php @@ -0,0 +1,82 @@ +migrator = MigratorFactory::makeFromEvent($driver); + $this->command = $driver->makeCommand(new Params(table: 'migration')); + } + + public function testUpExactlyAll(): void + { + $this->migrator->init(); + $data = $this->command->fetchApplied(); + self::assertEmpty($data); + + $this->migrator->up(new InputArgs(limit: 1)); + $data = $this->command->fetchApplied(); + self::assertCount(1, $data); + + $this->migrator->down(); + $data = $this->command->fetchApplied(); + self::assertEmpty($data); + + try { + $this->migrator->up(new InputArgs()); + } catch (ActionException) { + } + + // только первая миграция успешно + $data = $this->command->fetchApplied(); + self::assertCount(1, $data); + + $this->migrator->down(); + $data = $this->command->fetchApplied(); + self::assertEmpty($data); + + try { + $this->migrator->up(new InputArgs(exactlyAll: true)); + } catch (ActionException) { + } + + // всё или ничего + $data = $this->command->fetchApplied(); + self::assertEmpty($data); + } + + public function testUpExactlyAllException(): void + { + $this->migrator->init(); + + $this->expectException(ActionException::class); + $this->expectExceptionMessage( + '202501021025_account_error.sql: SQLSTATE[HY000]: General error: 1 no such table: account' + ); + + $this->migrator->up(new InputArgs(exactlyAll: true)); + } +} diff --git a/tests/workflow/MigrationTest.php b/tests/workflow/MigrationTest.php index 2f4b492..e07a116 100644 --- a/tests/workflow/MigrationTest.php +++ b/tests/workflow/MigrationTest.php @@ -40,7 +40,7 @@ protected function setUp(): void public function testInit(): void { $this->migrator->init(); - $data = $this->command->fetchSavedMigrationNames(); + $data = $this->command->fetchApplied(); self::assertEmpty($data); } @@ -48,18 +48,20 @@ public function testInit(): void public function testUp(): void { $this->migrator->init(); - $data = $this->command->fetchSavedMigrationNames(); + $data = $this->command->fetchApplied(); self::assertEmpty($data); $this->migrator->up(); - $data = $this->command->fetchSavedMigrationNames(); + $data = $this->command->fetchApplied(); self::assertCount(3, $data); - self::assertEquals('202501021025_account_email.sql', $data[0]); - self::assertEquals('202501021024_account_create.sql', $data[1]); - self::assertEquals('202501011024_entity_create.sql', $data[2]); + + $names = array_keys($data); + self::assertEquals('202501021025_account_email.sql', $names[0]); + self::assertEquals('202501021024_account_create.sql', $names[1]); + self::assertEquals('202501011024_entity_create.sql', $names[2]); $this->migrator->up(); - $data = $this->command->fetchSavedMigrationNames(); + $data = $this->command->fetchApplied(); self::assertCount(3, $data); } @@ -67,15 +69,15 @@ public function testUp(): void public function testDown(): void { $this->migrator->init(); - $data = $this->command->fetchSavedMigrationNames(); + $data = $this->command->fetchApplied(); self::assertEmpty($data); $this->migrator->up(); - $data = $this->command->fetchSavedMigrationNames(); + $data = $this->command->fetchApplied(); self::assertCount(3, $data); $this->migrator->down(); - $data = $this->command->fetchSavedMigrationNames(); + $data = $this->command->fetchApplied(); self::assertEmpty($data); } @@ -83,16 +85,27 @@ public function testDown(): void public function testRedo(): void { $this->migrator->init(); - $data = $this->command->fetchSavedMigrationNames(); + $data = $this->command->fetchApplied(); self::assertEmpty($data); $this->migrator->up(); - $data = $this->command->fetchSavedMigrationNames(); + $data = $this->command->fetchApplied(); self::assertCount(3, $data); + $version = (int)current($data); + self::assertGreaterThan(0, $version); + + usleep(10_000); + $this->migrator->redo(); - $data = $this->command->fetchSavedMigrationNames(); + $data = $this->command->fetchApplied(); self::assertCount(3, $data); + + $versionNew = (int)current($data); + self::assertGreaterThan(0, $versionNew); + + // новая версия больше старой + self::assertGreaterThan($version, $versionNew); } public function testInitializationException(): void @@ -140,11 +153,11 @@ public function testActionExceptionDryRun(): void $command = $driver->makeCommand(new Params(table: 'migration')); $migrator->init(); - $data = $command->fetchSavedMigrationNames(); + $data = $command->fetchApplied(); self::assertEmpty($data); $migrator->up(new InputArgs(dryRun: true)); - $data = $command->fetchSavedMigrationNames(); + $data = $command->fetchApplied(); self::assertEmpty($data); } } diff --git a/tests/workflow/WorkflowTest.php b/tests/workflow/WorkflowTest.php index 9a16762..0f74388 100644 --- a/tests/workflow/WorkflowTest.php +++ b/tests/workflow/WorkflowTest.php @@ -11,6 +11,7 @@ use kuaukutsu\poc\migration\tests\stub\TestDriver; use kuaukutsu\poc\migration\tests\stub\TestStorage; use kuaukutsu\poc\migration\tests\MigratorFactory; +use kuaukutsu\poc\migration\InputArgs; use kuaukutsu\poc\migration\MigratorInterface; /** @@ -47,7 +48,8 @@ public function testUp(): void { $this->migrator->up(); - self::assertContains('202501011024_entity_create.sql', $this->storage->getMigration()); + self::assertNotEmpty($this->storage->getMigration()); + self::assertStringContainsString( 'CREATE TABLE IF NOT EXISTS entity', $this->storage->get('202501011024_entity_create.sql') ?? '', @@ -61,6 +63,17 @@ public function testUp(): void // SKIP self::assertEquals('SKIP', $this->storage->get('202501011026_entity_duplicate.sql') ?? 'SKIP'); + // NOT repeatable + self::assertStringNotContainsString( + "INSERT INTO entity (name) VALUES ('test');", + $this->storage->get('202501011024_entity_correction.sql') ?? '', + ); + } + + public function testUpWithRepeatable(): void + { + $this->migrator->up(new InputArgs(hasRepeatable: true)); + // repeatable self::assertStringContainsString( "INSERT INTO entity (name) VALUES ('test');",