Skip to content

Commit 390d9fb

Browse files
author
Ibrahim BinAlshikh
committed
feat(migrations): add migrations:step command for interactive execution
Allows stepping through pending migrations one at a time with Apply/Skip/Quit prompts. Shows SQL preview for each migration before prompting for action. Closes #387
1 parent 149d510 commit 390d9fb

4 files changed

Lines changed: 319 additions & 0 deletions

File tree

WebFiori/Framework/App.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -374,6 +374,7 @@ public static function getRunner() : Runner {
374374
'\\WebFiori\\Framework\\Cli\\Commands\\MigrationsStatusCommand',
375375
'\\WebFiori\\Framework\\Cli\\Commands\\FreshMigrationsCommand',
376376
'\\WebFiori\\Framework\\Cli\\Commands\\SkipMigrationsCommand',
377+
'\\WebFiori\\Framework\\Cli\\Commands\\StepMigrationsCommand',
377378
];
378379

379380
foreach ($commands as $c) {
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
<?php
2+
3+
/**
4+
* This file is licensed under MIT License.
5+
*
6+
* Copyright (c) 2026 Ibrahim BinAlshikh
7+
*
8+
* For more information on the license, please visit:
9+
* https://github.com/WebFiori/.github/blob/main/LICENSE
10+
*
11+
*/
12+
namespace WebFiori\Framework\Cli\Commands;
13+
14+
use WebFiori\Cli\Argument;
15+
use WebFiori\Cli\Attributes\SingleInstance;
16+
use WebFiori\Cli\Command;
17+
use WebFiori\Database\ConnectionInfo;
18+
use WebFiori\Database\Schema\SchemaRunner;
19+
use WebFiori\Framework\App;
20+
use WebFiori\Framework\Cli\CLIUtils;
21+
22+
/**
23+
* Command for stepping through migrations one at a time with user confirmation.
24+
*
25+
* @author Ibrahim
26+
*/
27+
#[SingleInstance]
28+
class StepMigrationsCommand extends Command {
29+
public function __construct() {
30+
parent::__construct('migrations:step', [
31+
new Argument('--connection', 'The name of database connection to use.', true),
32+
new Argument('--env', 'Environment name (dev, staging, production). Default: dev', true),
33+
], 'Interactively apply or skip migrations one at a time.');
34+
}
35+
36+
public function exec(): int {
37+
$connection = $this->getConnection();
38+
39+
if ($connection === null) {
40+
return 1;
41+
}
42+
43+
$env = $this->getArgValue('--env') ?? 'dev';
44+
$runner = new SchemaRunner($connection, $env);
45+
$runner->discoverFromPath(APP_PATH.'Database'.DS.'Migrations', APP_DIR.'\\Database\\Migrations', true);
46+
47+
$pending = $runner->getPendingChanges(true);
48+
49+
if (empty($pending)) {
50+
$this->info('No pending migrations.');
51+
52+
return 0;
53+
}
54+
55+
$applied = 0;
56+
$skipped = 0;
57+
58+
foreach ($pending as $item) {
59+
$change = $item['change'];
60+
$queries = $item['queries'];
61+
62+
$this->println('');
63+
$this->println('Migration: '.$change->getName());
64+
$this->println('');
65+
66+
if (!empty($queries)) {
67+
$this->println('SQL:');
68+
69+
foreach ($queries as $q) {
70+
$this->println(' '.$q);
71+
}
72+
73+
$this->println('');
74+
}
75+
76+
$action = $this->select('Action:', ['Apply', 'Skip', 'Quit'], 0);
77+
78+
if ($action === 'Apply') {
79+
$runner->applyOne();
80+
$applied++;
81+
$this->success('Applied: '.$change->getName());
82+
} else if ($action === 'Skip') {
83+
$runner->skip($change);
84+
$skipped++;
85+
$this->warning('Skipped: '.$change->getName());
86+
} else {
87+
break;
88+
}
89+
}
90+
91+
$this->println('');
92+
$this->info("Summary: $applied applied, $skipped skipped.");
93+
94+
return 0;
95+
}
96+
97+
private function getConnection(): ?ConnectionInfo {
98+
$connections = App::getConfig()->getDBConnections();
99+
100+
if (empty($connections)) {
101+
$this->info('No database connections configured.');
102+
103+
return null;
104+
}
105+
106+
$connectionName = $this->getArgValue('--connection');
107+
108+
if ($connectionName !== null) {
109+
$connection = App::getConfig()->getDBConnection($connectionName);
110+
111+
if ($connection === null) {
112+
$this->error("Connection '$connectionName' not found.");
113+
114+
return null;
115+
}
116+
117+
return $connection;
118+
}
119+
120+
return CLIUtils::getConnectionName($this);
121+
}
122+
}

tests/WebFiori/Framework/Tests/Cli/HelpCommandTest.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ public function test00() {
4747
" migrations:status: Show migration status (applied and pending).\n",
4848
" migrations:fresh: Rollback all migrations and run them fresh.\n",
4949
" migrations:skip: Mark migrations as applied without executing them (baseline).\n",
50+
" migrations:step: Interactively apply or skip migrations one at a time.\n",
5051
" other:\n",
5152
" help: Display CLI Help. To display help for specific command, use the argument \"--command\" with this command.\n",
5253
" v: Display framework version info.\n",
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
<?php
2+
namespace WebFiori\Framework\Test\Cli;
3+
4+
use WebFiori\Database\ConnectionInfo;
5+
use WebFiori\Database\Schema\SchemaRunner;
6+
use WebFiori\Framework\App;
7+
use WebFiori\Framework\Cli\CLITestCase;
8+
use WebFiori\Framework\Cli\Commands\StepMigrationsCommand;
9+
10+
class StepMigrationsCommandTest extends CLITestCase {
11+
private ConnectionInfo $testConnection;
12+
13+
protected function setUp(): void {
14+
parent::setUp();
15+
$this->setupTestConnection();
16+
$this->dropSchemaTable();
17+
$this->cleanupMigrations();
18+
}
19+
20+
protected function tearDown(): void {
21+
$this->cleanupMigrations();
22+
$this->dropSchemaTable();
23+
App::getConfig()->removeAllDBConnections();
24+
parent::tearDown();
25+
}
26+
27+
/** @test */
28+
public function testNoConnectionsConfigured() {
29+
App::getConfig()->removeAllDBConnections();
30+
31+
$output = $this->executeMultiCommand([
32+
StepMigrationsCommand::class
33+
]);
34+
35+
$this->assertEquals([
36+
"Info: No database connections configured.\n"
37+
], $output);
38+
$this->assertEquals(1, $this->getExitCode());
39+
}
40+
41+
/** @test */
42+
public function testNoPendingMigrations() {
43+
$this->initMigrations();
44+
45+
$output = $this->executeMultiCommand([
46+
StepMigrationsCommand::class,
47+
'--connection' => 'test-connection'
48+
]);
49+
50+
$outputStr = implode('', $output);
51+
$this->assertStringContainsString('No pending migrations.', $outputStr);
52+
$this->assertEquals(0, $this->getExitCode());
53+
}
54+
55+
/** @test */
56+
public function testApplyOneMigration() {
57+
$this->createTestMigration('StepApply1');
58+
$this->initMigrations();
59+
60+
$output = $this->executeMultiCommand([
61+
StepMigrationsCommand::class,
62+
'--connection' => 'test-connection'
63+
], [
64+
'0' // Select "Apply"
65+
]);
66+
67+
$outputStr = implode('', $output);
68+
$this->assertStringContainsString('Applied:', $outputStr);
69+
$this->assertStringContainsString('1 applied, 0 skipped', $outputStr);
70+
$this->assertEquals(0, $this->getExitCode());
71+
}
72+
73+
/** @test */
74+
public function testSkipOneMigration() {
75+
$this->createTestMigration('StepSkip1');
76+
$this->initMigrations();
77+
78+
$output = $this->executeMultiCommand([
79+
StepMigrationsCommand::class,
80+
'--connection' => 'test-connection'
81+
], [
82+
'1' // Select "Skip"
83+
]);
84+
85+
$outputStr = implode('', $output);
86+
$this->assertStringContainsString('Skipped:', $outputStr);
87+
$this->assertStringContainsString('0 applied, 1 skipped', $outputStr);
88+
$this->assertEquals(0, $this->getExitCode());
89+
}
90+
91+
/** @test */
92+
public function testQuitBeforeProcessingAll() {
93+
$this->createTestMigration('StepQuit1');
94+
$this->createTestMigration('StepQuit2');
95+
$this->initMigrations();
96+
97+
$output = $this->executeMultiCommand([
98+
StepMigrationsCommand::class,
99+
'--connection' => 'test-connection'
100+
], [
101+
'0', // Apply first
102+
'2' // Quit on second
103+
]);
104+
105+
$outputStr = implode('', $output);
106+
$this->assertStringContainsString('Applied:', $outputStr);
107+
$this->assertStringContainsString('1 applied, 0 skipped', $outputStr);
108+
$this->assertEquals(0, $this->getExitCode());
109+
}
110+
111+
/** @test */
112+
public function testMixedApplyAndSkip() {
113+
$this->createTestMigration('StepMix1');
114+
$this->createTestMigration('StepMix2');
115+
$this->initMigrations();
116+
117+
$output = $this->executeMultiCommand([
118+
StepMigrationsCommand::class,
119+
'--connection' => 'test-connection'
120+
], [
121+
'0', // Apply first
122+
'1' // Skip second
123+
]);
124+
125+
$outputStr = implode('', $output);
126+
$this->assertStringContainsString('1 applied, 1 skipped', $outputStr);
127+
$this->assertEquals(0, $this->getExitCode());
128+
}
129+
130+
private function initMigrations(): void {
131+
$this->executeMultiCommand([
132+
'WebFiori\\Framework\\Cli\\Commands\\InitMigrationsCommand',
133+
'--connection' => 'test-connection'
134+
]);
135+
}
136+
137+
private function createTestMigration(string $name): void {
138+
$dir = APP_PATH.'Database'.DS.'Migrations';
139+
140+
if (!is_dir($dir)) {
141+
mkdir($dir, 0755, true);
142+
}
143+
144+
$content = <<<PHP
145+
<?php
146+
namespace App\Database\Migrations;
147+
148+
use WebFiori\Database\Database;
149+
use WebFiori\Database\Schema\AbstractMigration;
150+
151+
class $name extends AbstractMigration {
152+
public function up(Database \$db): void {
153+
// Test migration
154+
}
155+
156+
public function down(Database \$db): void {
157+
// Test rollback
158+
}
159+
}
160+
PHP;
161+
162+
file_put_contents($dir.DS.$name.'.php', $content);
163+
}
164+
165+
private function cleanupMigrations(): void {
166+
$dir = APP_PATH.'Database'.DS.'Migrations';
167+
168+
if (!is_dir($dir)) {
169+
return;
170+
}
171+
172+
foreach (glob($dir.DS.'*.php') as $file) {
173+
unlink($file);
174+
}
175+
}
176+
177+
private function setupTestConnection(): void {
178+
$this->testConnection = new ConnectionInfo('mysql', 'root', MYSQL_ROOT_PASSWORD, 'testing_db', '127.0.0.1', 3306);
179+
$this->testConnection->setName('test-connection');
180+
App::getConfig()->addOrUpdateDBConnection($this->testConnection);
181+
}
182+
183+
private function dropSchemaTable(): void {
184+
try {
185+
$connection = App::getConfig()->getDBConnection('test-connection');
186+
187+
if ($connection !== null) {
188+
$runner = new SchemaRunner($connection);
189+
$runner->dropSchemaTable();
190+
}
191+
} catch (\Throwable $e) {
192+
// Ignore errors during cleanup
193+
}
194+
}
195+
}

0 commit comments

Comments
 (0)