Skip to content

Commit 03f7cdb

Browse files
authored
MPT-18132: add command to run a single migration (#44)
<!-- This is an auto-generated comment: release notes by coderabbit.ai --> Closes [MPT-18132](https://softwareone.atlassian.net/browse/MPT-18132) ## Release Notes - Add CLI support to run a single migration by ID: positional migration_id argument for migrate used with --data or --schema - New CLI usage: mpt-service-cli migrate --data MIGRATION_ID and mpt-service-cli migrate --schema MIGRATION_ID - Implement RunSingleMigrationUseCase to validate, execute, and track a single migration with idempotency checks and error handling (not found, wrong type, already applied) - Introduce MigrationStateService to centralize state operations: get-or-create state and status transitions (RUNNING, APPLIED, FAILED, MANUAL_APPLIED) - DataCommand and SchemaCommand accept optional migration_id and run the single-migration flow when provided; CommandFactory forwards migration_id - Validator updated to allow migration_id only with --data or --schema and enforce single-parameter usage (excluding migration_id) - Refactor RunMigrationsUseCase to use MigrationStateService and skip already-applied migrations when running all pending migrations - Update docs with guidance and examples for running individual migrations and migration state-file behavior - Add tests: CLI parameter validation, single-migration flows (success and error cases), local-storage integration tests, and MigrationStateService unit tests <!-- end of auto-generated comment: release notes by coderabbit.ai --> [MPT-18132]: https://softwareone.atlassian.net/browse/MPT-18132?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ
2 parents a59aed9 + 0b5c7cd commit 03f7cdb

File tree

16 files changed

+323
-36
lines changed

16 files changed

+323
-36
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/services/__init__.py

Whitespace-only changes.
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
from mpt_tool.enums import MigrationStatusEnum, MigrationTypeEnum
2+
from mpt_tool.managers import StateManager
3+
from mpt_tool.managers.errors import StateNotFoundError
4+
from mpt_tool.models import Migration
5+
6+
7+
class MigrationStateService:
8+
"""Shared migration state operations for migration use cases."""
9+
10+
def __init__(self, state_manager: StateManager) -> None:
11+
self._state_manager = state_manager
12+
13+
def get_or_create_state(
14+
self, migration_id: str, migration_type: MigrationTypeEnum, order_id: int
15+
) -> Migration:
16+
"""Return existing migration state, creating it if needed."""
17+
try:
18+
state = self._state_manager.get_by_id(migration_id)
19+
except StateNotFoundError:
20+
state = self._state_manager.new(migration_id, migration_type, order_id)
21+
22+
return state
23+
24+
def save_state(self, state: Migration, status: MigrationStatusEnum) -> None:
25+
"""Apply status transition and persist migration state."""
26+
match status:
27+
case MigrationStatusEnum.APPLIED:
28+
state.applied()
29+
case MigrationStatusEnum.FAILED:
30+
state.failed()
31+
case MigrationStatusEnum.MANUAL_APPLIED:
32+
state.manual()
33+
case MigrationStatusEnum.RUNNING:
34+
state.start()
35+
36+
self._state_manager.save_state(state)

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
from mpt_tool.enums import MigrationStatusEnum
12
from mpt_tool.managers import FileMigrationManager, StateManager, StateManagerFactory
23
from mpt_tool.managers.errors import MigrationFolderError, StateNotFoundError
4+
from mpt_tool.services.migration_state import MigrationStateService
35
from mpt_tool.use_cases.errors import ApplyMigrationError
46

57

@@ -10,9 +12,11 @@ def __init__(
1012
self,
1113
file_migration_manager: FileMigrationManager | None = None,
1214
state_manager: StateManager | None = None,
15+
state_service: MigrationStateService | None = None,
1316
):
1417
self.file_migration_manager = file_migration_manager or FileMigrationManager()
1518
self.state_manager = state_manager or StateManagerFactory.get_instance()
19+
self.state_service = state_service or MigrationStateService(self.state_manager)
1620

1721
def execute(self, migration_id: str) -> None:
1822
"""Apply a migration without running it."""
@@ -38,5 +42,4 @@ def execute(self, migration_id: str) -> None:
3842
if state.applied_at is not None:
3943
raise ApplyMigrationError(f"Migration {migration_id} already applied")
4044

41-
state.manual()
42-
self.state_manager.save_state(state)
45+
self.state_service.save_state(state, status=MigrationStatusEnum.MANUAL_APPLIED)

0 commit comments

Comments
 (0)