Skip to content

Commit 05b0c5e

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

File tree

8 files changed

+116
-8
lines changed

8 files changed

+116
-8
lines changed

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: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
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,4 @@ def success_message(self) -> str:
2023

2124
@override
2225
def run(self) -> None:
23-
RunMigrationsUseCase().execute(MigrationTypeEnum.DATA)
26+
RunMigrationsUseCase().execute(MigrationTypeEnum.DATA, self._migration_id)

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: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
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,4 @@ def success_message(self) -> str:
2023

2124
@override
2225
def run(self) -> None:
23-
RunMigrationsUseCase().execute(MigrationTypeEnum.SCHEMA)
26+
RunMigrationsUseCase().execute(MigrationTypeEnum.SCHEMA, self._migration_id)

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/run_migrations.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,14 @@ 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(self, migration_type: MigrationTypeEnum) -> None: # noqa: WPS231
24+
def execute( # noqa: C901, WPS231
25+
self, migration_type: MigrationTypeEnum, migration_id: str | None = None
26+
) -> None:
2527
"""Run all migrations of a given type.
2628
2729
Args:
2830
migration_type: The type of migrations to run.
31+
migration_id: Optional migration id to run.
2932
3033
Raises:
3134
RunMigrationError: If an error occurs during migration execution.
@@ -35,11 +38,17 @@ def execute(self, migration_type: MigrationTypeEnum) -> None: # noqa: WPS231
3538
except MigrationFolderError as error:
3639
raise RunMigrationError(str(error)) from error
3740

41+
migration_files = self._filter_migration_files(migration_files, migration_id)
42+
3843
for migration_file in migration_files:
3944
migration_instance = self._get_migration_instance_by_type(
4045
migration_file, migration_type
4146
)
4247
if migration_instance is None:
48+
if migration_id:
49+
raise RunMigrationError(
50+
f"Migration {migration_id} is not a {migration_type} migration"
51+
)
4352
continue
4453

4554
state = self._get_or_create_state(
@@ -63,6 +72,20 @@ def execute(self, migration_type: MigrationTypeEnum) -> None: # noqa: WPS231
6372

6473
self._save_state(state, status=MigrationStatusEnum.APPLIED)
6574

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+
6689
def _get_migration_instance_by_type(
6790
self, migration_file: MigrationFile, migration_type: MigrationTypeEnum
6891
) -> BaseMigration | None:

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: 61 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):
@@ -90,6 +111,46 @@ def test_migrate_data_run_script_fail(migration_state_file, runner, log):
90111
assert "Migration fake_error_file_name failed: Fake Error" in result.output
91112

92113

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

0 commit comments

Comments
 (0)