Skip to content

Commit c26a494

Browse files
committed
feat: allow to run a single migration
1 parent 82f312d commit c26a494

File tree

10 files changed

+230
-7
lines changed

10 files changed

+230
-7
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/cli.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,9 @@ def migrate( # noqa: WPS211
5151
),
5252
] = None,
5353
list: Annotated[bool, typer.Option("--list", help="List all migrations.")] = False, # noqa: A002, FBT002
54+
migration_id: Annotated[
55+
str | None, typer.Argument(help="Optional migration ID for --data or --schema.")
56+
] = None,
5457
) -> None:
5558
"""Migrate command."""
5659
try:

mpt_tool/commands/data.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,15 @@
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):
99
"""Runs all data migrations."""
1010

11+
def __init__(self, migration_id: str | None = None) -> None:
12+
self._migration_id = migration_id
13+
1114
@override
1215
@property
1316
def start_message(self) -> str:
@@ -20,4 +23,8 @@ def success_message(self) -> str:
2023

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

mpt_tool/commands/factory.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,10 @@ def get_instance(cls, param_data: dict[str, bool | str | None]) -> BaseCommand:
3535
return InitCommand()
3636
case {"check": True}:
3737
return CheckCommand()
38-
case {"data": True}:
39-
return DataCommand()
40-
case {"schema": True}:
41-
return SchemaCommand()
38+
case {"data": True, "migration_id": migration_id}:
39+
return DataCommand(migration_id=cast(str | None, migration_id))
40+
case {"schema": True, "migration_id": migration_id}:
41+
return SchemaCommand(migration_id=cast(str | None, migration_id))
4242
case {"manual": manual_value} if manual_value is not None:
4343
return ManualCommand(migration_id=cast(str, manual_value))
4444
case {"new_schema": new_schema_value} if new_schema_value is not None:

mpt_tool/commands/schema.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,15 @@
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):
99
"""Runs all schema migrations."""
1010

11+
def __init__(self, migration_id: str | None = None) -> None:
12+
self._migration_id = migration_id
13+
1114
@override
1215
@property
1316
def start_message(self) -> str:
@@ -20,4 +23,8 @@ def success_message(self) -> str:
2023

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

mpt_tool/commands/validators.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,14 @@ def validate(cls, command_params: dict[str, Any]) -> None:
1616
Raises:
1717
BadParameterError: When none or more than one param is used
1818
"""
19-
param_counts = sum(1 for param_value in command_params.values() if param_value)
19+
migration_id = command_params.get("migration_id")
20+
command_values = {
21+
key: param_value for key, param_value in command_params.items() if key != "migration_id"
22+
}
23+
param_counts = sum(1 for param_value in command_values.values() if param_value)
24+
if migration_id and not command_params.get("data") and not command_params.get("schema"):
25+
raise BadParameterError("MIGRATION_ID can only be used with --data or --schema.")
26+
2027
if not param_counts:
2128
raise BadParameterError("At least one param must be used.")
2229

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
]
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.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,14 @@ def test_migrate_command_multiple_params_error(runner):
2828
assert "Only one param can be used." in result.output
2929

3030

31+
def test_migrate_migration_id_without_type_error(runner):
32+
result = runner.invoke(app, ["migrate", "fake_data_file_name"])
33+
34+
assert result.exit_code == 2, result.output
35+
assert "Invalid value for migrate:" in result.output
36+
assert "MIGRATION_ID can only be used with --data or" in result.output
37+
38+
3139
def test_migrate_data_duplicate_migration(runner, migration_folder):
3240
(migration_folder / "20250406020202_fake_file_name.py").touch()
3341
(migration_folder / "20260107010101_fake_file_name.py").touch()

tests/cli/test_cli_local_storage.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,27 @@ def test_migrate_data_migration(migration_state_file, runner, log):
5151
assert "Migrations completed successfully." in result.output
5252

5353

54+
@freeze_time("2025-04-06 13:10:30")
55+
@pytest.mark.usefixtures("data_migration_file", "schema_migration_file")
56+
def test_migrate_data_single_migration(migration_state_file, runner, log):
57+
result = runner.invoke(app, ["migrate", "--data", "fake_data_file_name"])
58+
59+
assert result.exit_code == 0, result.output
60+
migration_state_data = json.loads(migration_state_file.read_text(encoding="utf-8"))
61+
assert migration_state_data == {
62+
"fake_data_file_name": {
63+
"migration_id": "fake_data_file_name",
64+
"order_id": 20250406020202,
65+
"type": "data",
66+
"started_at": "2025-04-06T13:10:30+00:00",
67+
"applied_at": "2025-04-06T13:10:30+00:00",
68+
}
69+
}
70+
assert "Running data migrations..." in result.output
71+
assert "Running migration: fake_data_file_name" in log.text
72+
assert "Migrations completed successfully." in result.output
73+
74+
5475
@freeze_time("2025-04-06 13:00:00")
5576
@pytest.mark.usefixtures("applied_migration")
5677
def test_migrate_skip_migration_already_applied(migration_state_file, runner, log):
@@ -72,6 +93,16 @@ def test_migrate_skip_migration_already_applied(migration_state_file, runner, lo
7293
assert "Migrations completed successfully." in result.output
7394

7495

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+
75106
@pytest.mark.usefixtures("data_migration_file_error")
76107
def test_migrate_data_run_script_fail(migration_state_file, runner, log):
77108
result = runner.invoke(app, ["migrate", "--data"])
@@ -90,6 +121,46 @@ def test_migrate_data_run_script_fail(migration_state_file, runner, log):
90121
assert "Migration fake_error_file_name failed: Fake Error" in result.output
91122

92123

124+
@pytest.mark.usefixtures("data_migration_file")
125+
def test_migrate_data_single_migration_not_found(runner):
126+
result = runner.invoke(app, ["migrate", "--data", "not_existing_migration"])
127+
128+
assert result.exit_code == 1, result.output
129+
assert "Error running data command: Migration not_existing_migration not found" in result.output
130+
131+
132+
@pytest.mark.usefixtures("data_migration_file", "schema_migration_file")
133+
def test_migrate_data_single_migration_wrong_type(runner):
134+
result = runner.invoke(app, ["migrate", "--data", "fake_schema_file_name"])
135+
136+
assert result.exit_code == 1, result.output
137+
assert (
138+
"Error running data command: Migration fake_schema_file_name is not a data migration"
139+
in result.output
140+
)
141+
142+
143+
@freeze_time("2025-04-06 13:10:30")
144+
@pytest.mark.usefixtures("data_migration_file", "schema_migration_file")
145+
def test_migrate_schema_single_migration(migration_state_file, runner, log):
146+
result = runner.invoke(app, ["migrate", "--schema", "fake_schema_file_name"])
147+
148+
assert result.exit_code == 0, result.output
149+
migration_state_data = json.loads(migration_state_file.read_text(encoding="utf-8"))
150+
assert migration_state_data == {
151+
"fake_schema_file_name": {
152+
"migration_id": "fake_schema_file_name",
153+
"order_id": 20260101010101,
154+
"type": "schema",
155+
"started_at": "2025-04-06T13:10:30+00:00",
156+
"applied_at": "2025-04-06T13:10:30+00:00",
157+
}
158+
}
159+
assert "Running schema migrations..." in result.output
160+
assert "Running migration: fake_schema_file_name" in log.text
161+
assert "schema migrations applied successfully." in result.output
162+
163+
93164
@freeze_time("2025-04-06 10:11:24")
94165
@pytest.mark.usefixtures("data_migration_file")
95166
def test_migrate_manual(migration_state_file, runner):

0 commit comments

Comments
 (0)