Skip to content

Commit ecbb422

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

File tree

14 files changed

+311
-34
lines changed

14 files changed

+311
-34
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: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
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.RUNNING:
32+
state.start()
33+
34+
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/run_migrations.py

Lines changed: 9 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@
22

33
from mpt_tool.enums import MigrationStatusEnum, MigrationTypeEnum
44
from mpt_tool.managers import FileMigrationManager, StateManager, StateManagerFactory
5-
from mpt_tool.managers.errors import LoadMigrationError, MigrationFolderError, StateNotFoundError
5+
from mpt_tool.managers.errors import LoadMigrationError, MigrationFolderError
66
from mpt_tool.migration.base import BaseMigration
7-
from mpt_tool.models import Migration, MigrationFile
7+
from mpt_tool.models import MigrationFile
8+
from mpt_tool.services.migration_state import MigrationStateService
89
from mpt_tool.use_cases.errors import RunMigrationError
910

1011
logger = logging.getLogger(__name__)
@@ -17,9 +18,11 @@ def __init__(
1718
self,
1819
file_migration_manager: FileMigrationManager | None = None,
1920
state_manager: StateManager | None = None,
21+
state_service: MigrationStateService | None = None,
2022
):
2123
self.file_migration_manager = file_migration_manager or FileMigrationManager()
2224
self.state_manager = state_manager or StateManagerFactory.get_instance()
25+
self.state_service = state_service or MigrationStateService(self.state_manager)
2326

2427
def execute(self, migration_type: MigrationTypeEnum) -> None: # noqa: WPS231
2528
"""Run all migrations of a given type.
@@ -42,26 +45,26 @@ def execute(self, migration_type: MigrationTypeEnum) -> None: # noqa: WPS231
4245
if migration_instance is None:
4346
continue
4447

45-
state = self._get_or_create_state(
48+
state = self.state_service.get_or_create_state(
4649
migration_file.migration_id, migration_type, migration_file.order_id
4750
)
4851
if state.applied_at is not None:
4952
logger.debug("Skipping applied migration: %s", migration_file.migration_id)
5053
continue
5154

5255
logger.info("Running migration: %s", migration_file.migration_id)
53-
self._save_state(state, status=MigrationStatusEnum.RUNNING)
56+
self.state_service.save_state(state, status=MigrationStatusEnum.RUNNING)
5457
try:
5558
migration_instance.run()
5659
# We catch all exceptions here to ensure the state is updated
5760
# and the flow is not interrupted abruptly
5861
except Exception as error:
59-
self._save_state(state, status=MigrationStatusEnum.FAILED)
62+
self.state_service.save_state(state, status=MigrationStatusEnum.FAILED)
6063
raise RunMigrationError(
6164
f"Migration {migration_file.migration_id} failed: {error!s}"
6265
) from error
6366

64-
self._save_state(state, status=MigrationStatusEnum.APPLIED)
67+
self.state_service.save_state(state, status=MigrationStatusEnum.APPLIED)
6568

6669
def _get_migration_instance_by_type(
6770
self, migration_file: MigrationFile, migration_type: MigrationTypeEnum
@@ -75,24 +78,3 @@ def _get_migration_instance_by_type(
7578
return None
7679

7780
return migration_instance
78-
79-
def _get_or_create_state(
80-
self, migration_id: str, migration_type: MigrationTypeEnum, order_id: int
81-
) -> Migration:
82-
try:
83-
state = self.state_manager.get_by_id(migration_id)
84-
except StateNotFoundError:
85-
state = self.state_manager.new(migration_id, migration_type, order_id)
86-
87-
return state
88-
89-
def _save_state(self, state: Migration, status: MigrationStatusEnum) -> None:
90-
match status:
91-
case MigrationStatusEnum.APPLIED:
92-
state.applied()
93-
case MigrationStatusEnum.FAILED:
94-
state.failed()
95-
case MigrationStatusEnum.RUNNING:
96-
state.start()
97-
98-
self.state_manager.save_state(state)

0 commit comments

Comments
 (0)