Skip to content

Commit 2562d8f

Browse files
committed
feat: add command to run a single migration
1 parent 05b0c5e commit 2562d8f

File tree

7 files changed

+143
-28
lines changed

7 files changed

+143
-28
lines changed

docs/PROJECT_DESCRIPTION.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,14 @@ Error running check command: Duplicate migration_id found in migrations: 2026011
191191
```bash
192192
mpt-service-cli migrate --schema
193193
```
194+
- **Run one specific data migration:**
195+
```bash
196+
mpt-service-cli migrate --data MIGRATION_ID
197+
```
198+
- **Run one specific schema migration:**
199+
```bash
200+
mpt-service-cli migrate --schema MIGRATION_ID
201+
```
194202
195203
Migrations are executed in order based on their order_id (timestamp). The tool automatically:
196204
- Validates the migration folder structure
@@ -199,6 +207,11 @@ Migrations are executed in order based on their order_id (timestamp). The tool a
199207
- Logs migration progress
200208
- Handles errors gracefully and updates state accordingly
201209
210+
When running a single migration (`--data MIGRATION_ID` or `--schema MIGRATION_ID`), the tool:
211+
- Fails if `MIGRATION_ID` does not exist
212+
- Fails if the migration type does not match the selected flag
213+
- Fails if the migration was already applied
214+
202215
**Migration State File (`.migrations-state.json`):**
203216
```json
204217
{

mpt_tool/commands/data.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from mpt_tool.commands.base import BaseCommand
44
from mpt_tool.enums import MigrationTypeEnum
5-
from mpt_tool.use_cases import RunMigrationsUseCase
5+
from mpt_tool.use_cases import RunMigrationsUseCase, RunSingleMigrationUseCase
66

77

88
class DataCommand(BaseCommand):
@@ -23,4 +23,8 @@ def success_message(self) -> str:
2323

2424
@override
2525
def run(self) -> None:
26-
RunMigrationsUseCase().execute(MigrationTypeEnum.DATA, self._migration_id)
26+
if self._migration_id:
27+
RunSingleMigrationUseCase().execute(self._migration_id, MigrationTypeEnum.DATA)
28+
return
29+
30+
RunMigrationsUseCase().execute(MigrationTypeEnum.DATA)

mpt_tool/commands/schema.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from mpt_tool.commands.base import BaseCommand
44
from mpt_tool.enums import MigrationTypeEnum
5-
from mpt_tool.use_cases import RunMigrationsUseCase
5+
from mpt_tool.use_cases import RunMigrationsUseCase, RunSingleMigrationUseCase
66

77

88
class SchemaCommand(BaseCommand):
@@ -23,4 +23,8 @@ def success_message(self) -> str:
2323

2424
@override
2525
def run(self) -> None:
26-
RunMigrationsUseCase().execute(MigrationTypeEnum.SCHEMA, self._migration_id)
26+
if self._migration_id:
27+
RunSingleMigrationUseCase().execute(self._migration_id, MigrationTypeEnum.SCHEMA)
28+
return
29+
30+
RunMigrationsUseCase().execute(MigrationTypeEnum.SCHEMA)

mpt_tool/use_cases/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from mpt_tool.use_cases.list_migrations import ListMigrationsUseCase
55
from mpt_tool.use_cases.new_migration import NewMigrationUseCase
66
from mpt_tool.use_cases.run_migrations import RunMigrationsUseCase
7+
from mpt_tool.use_cases.run_single_migration import RunSingleMigrationUseCase
78

89
__all__ = [
910
"ApplyMigrationUseCase",
@@ -12,4 +13,5 @@
1213
"ListMigrationsUseCase",
1314
"NewMigrationUseCase",
1415
"RunMigrationsUseCase",
16+
"RunSingleMigrationUseCase",
1517
]

mpt_tool/use_cases/run_migrations.py

Lines changed: 1 addition & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,11 @@ def __init__(
2121
self.file_migration_manager = file_migration_manager or FileMigrationManager()
2222
self.state_manager = state_manager or StateManagerFactory.get_instance()
2323

24-
def execute( # noqa: C901, WPS231
25-
self, migration_type: MigrationTypeEnum, migration_id: str | None = None
26-
) -> None:
24+
def execute(self, migration_type: MigrationTypeEnum) -> None: # noqa: WPS231
2725
"""Run all migrations of a given type.
2826
2927
Args:
3028
migration_type: The type of migrations to run.
31-
migration_id: Optional migration id to run.
3229
3330
Raises:
3431
RunMigrationError: If an error occurs during migration execution.
@@ -38,17 +35,11 @@ def execute( # noqa: C901, WPS231
3835
except MigrationFolderError as error:
3936
raise RunMigrationError(str(error)) from error
4037

41-
migration_files = self._filter_migration_files(migration_files, migration_id)
42-
4338
for migration_file in migration_files:
4439
migration_instance = self._get_migration_instance_by_type(
4540
migration_file, migration_type
4641
)
4742
if migration_instance is None:
48-
if migration_id:
49-
raise RunMigrationError(
50-
f"Migration {migration_id} is not a {migration_type} migration"
51-
)
5243
continue
5344

5445
state = self._get_or_create_state(
@@ -72,20 +63,6 @@ def execute( # noqa: C901, WPS231
7263

7364
self._save_state(state, status=MigrationStatusEnum.APPLIED)
7465

75-
def _filter_migration_files(
76-
self, migration_files: tuple[MigrationFile, ...], migration_id: str | None
77-
) -> tuple[MigrationFile, ...]:
78-
if migration_id is None:
79-
return migration_files
80-
81-
filtered_migrations = tuple(
82-
migration for migration in migration_files if migration.migration_id == migration_id
83-
)
84-
if not filtered_migrations:
85-
raise RunMigrationError(f"Migration {migration_id} not found")
86-
87-
return filtered_migrations
88-
8966
def _get_migration_instance_by_type(
9067
self, migration_file: MigrationFile, migration_type: MigrationTypeEnum
9168
) -> BaseMigration | None:
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import logging
2+
3+
from mpt_tool.enums import MigrationStatusEnum, MigrationTypeEnum
4+
from mpt_tool.managers import FileMigrationManager, StateManager, StateManagerFactory
5+
from mpt_tool.managers.errors import LoadMigrationError, MigrationFolderError, StateNotFoundError
6+
from mpt_tool.migration.base import BaseMigration
7+
from mpt_tool.models import Migration, MigrationFile
8+
from mpt_tool.use_cases.errors import RunMigrationError
9+
10+
logger = logging.getLogger(__name__)
11+
12+
13+
class RunSingleMigrationUseCase:
14+
"""Use case for running a single migration."""
15+
16+
def __init__(
17+
self,
18+
file_migration_manager: FileMigrationManager | None = None,
19+
state_manager: StateManager | None = None,
20+
):
21+
self.file_migration_manager = file_migration_manager or FileMigrationManager()
22+
self.state_manager = state_manager or StateManagerFactory.get_instance()
23+
24+
def execute(self, migration_id: str, migration_type: MigrationTypeEnum) -> None:
25+
"""Run one migration by id and type.
26+
27+
Args:
28+
migration_id: The migration id to run.
29+
migration_type: The expected migration type.
30+
31+
Raises:
32+
RunMigrationError: If an error occurs during migration execution.
33+
"""
34+
migration_file = self._get_migration_file(migration_id)
35+
migration_instance = self._get_migration_instance_by_type(migration_file, migration_type)
36+
state = self._get_or_create_state(
37+
migration_file.migration_id, migration_type, migration_file.order_id
38+
)
39+
if state.applied_at is not None:
40+
raise RunMigrationError(f"Migration {migration_id} already applied")
41+
42+
logger.info("Running migration: %s", migration_file.migration_id)
43+
self._save_state(state, status=MigrationStatusEnum.RUNNING)
44+
try:
45+
migration_instance.run()
46+
except Exception as error:
47+
self._save_state(state, status=MigrationStatusEnum.FAILED)
48+
raise RunMigrationError(
49+
f"Migration {migration_file.migration_id} failed: {error!s}"
50+
) from error
51+
52+
self._save_state(state, status=MigrationStatusEnum.APPLIED)
53+
54+
def _get_migration_file(self, migration_id: str) -> MigrationFile:
55+
migration_files = self._get_validated_migrations()
56+
migration_file = next(
57+
(migration for migration in migration_files if migration.migration_id == migration_id),
58+
None,
59+
)
60+
if migration_file is None:
61+
raise RunMigrationError(f"Migration {migration_id} not found")
62+
63+
return migration_file
64+
65+
def _get_migration_instance_by_type(
66+
self, migration_file: MigrationFile, migration_type: MigrationTypeEnum
67+
) -> BaseMigration:
68+
try:
69+
migration_instance = self.file_migration_manager.load_migration(migration_file)
70+
except LoadMigrationError as error:
71+
raise RunMigrationError(str(error)) from error
72+
73+
if migration_instance.type != migration_type:
74+
raise RunMigrationError(
75+
f"Migration {migration_file.migration_id} is not a {migration_type} migration"
76+
)
77+
78+
return migration_instance
79+
80+
def _get_validated_migrations(self) -> tuple[MigrationFile, ...]:
81+
try:
82+
return self.file_migration_manager.validate()
83+
except MigrationFolderError as error:
84+
raise RunMigrationError(str(error)) from error
85+
86+
def _get_or_create_state(
87+
self, migration_id: str, migration_type: MigrationTypeEnum, order_id: int
88+
) -> Migration:
89+
try:
90+
state = self.state_manager.get_by_id(migration_id)
91+
except StateNotFoundError:
92+
state = self.state_manager.new(migration_id, migration_type, order_id)
93+
94+
return state
95+
96+
def _save_state(self, state: Migration, status: MigrationStatusEnum) -> None:
97+
match status:
98+
case MigrationStatusEnum.APPLIED:
99+
state.applied()
100+
case MigrationStatusEnum.FAILED:
101+
state.failed()
102+
case MigrationStatusEnum.RUNNING:
103+
state.start()
104+
105+
self.state_manager.save_state(state)

tests/cli/test_cli_local_storage.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,16 @@ def test_migrate_skip_migration_already_applied(migration_state_file, runner, lo
9393
assert "Migrations completed successfully." in result.output
9494

9595

96+
@pytest.mark.usefixtures("applied_migration")
97+
def test_migrate_data_single_already_applied(runner):
98+
result = runner.invoke(app, ["migrate", "--data", "fake_data_file_name"])
99+
100+
assert result.exit_code == 1, result.output
101+
assert (
102+
"Error running data command: Migration fake_data_file_name already applied" in result.output
103+
)
104+
105+
96106
@pytest.mark.usefixtures("data_migration_file_error")
97107
def test_migrate_data_run_script_fail(migration_state_file, runner, log):
98108
result = runner.invoke(app, ["migrate", "--data"])

0 commit comments

Comments
 (0)