sqlalchemy-shifter 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 sqlalchemy-shifter contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,106 @@
1
+ Metadata-Version: 2.3
2
+ Name: sqlalchemy-shifter
3
+ Version: 0.1.0
4
+ Summary: A lightweight DB migration tool for SQLAlchemy
5
+ Author: Alexey Volkov
6
+ Author-email: webwizardry@hotmail.com
7
+ Requires-Python: >=3.10
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.10
10
+ Classifier: Programming Language :: Python :: 3.11
11
+ Classifier: Programming Language :: Python :: 3.12
12
+ Classifier: Programming Language :: Python :: 3.13
13
+ Requires-Dist: sqlalchemy (>=2.0.48,<3.0.0)
14
+ Requires-Dist: typer (>=0.24.1,<0.25.0)
15
+ Description-Content-Type: text/markdown
16
+
17
+ # SQLAlchemy Shifter
18
+
19
+ **SQLAlchemy Shifter** — это легковесный, модульный инструмент для управления миграциями баз данных поверх Python и SQLAlchemy. Идеологически вдохновлен системой миграций фреймворков уровня **Yii2** и **Laravel**.
20
+
21
+ В отличие от Alembic, здесь нет сложного графа версий, "магической" связки состояний или жесткой привязки к одному файлу `alembic.ini`. Инструмент использует простой линейный подход, базируясь исключительно на таймстемпах файлов, и спроектирован для использования в современных микросервисных архитектурах (когда миграции могут поставляться разными пакетами).
22
+
23
+ ## Ключевые особенности
24
+
25
+ - **Отсутствие Alembic-зависимостей**: Работает на чистом `SQLAlchemy Core`. Встроен собственный минималистичный DDL-генератор, никаких графов версионирования.
26
+ - **ООП-транзакционность `up` / `safeUp`**: Базовый класс предоставляет два уровня методов миграции. Если необходимо выполнение внутри транзакции — логика описывается в `safeUp()`. Движок атомарно обернет её блоком `with connection.begin()`.
27
+ - **Изоляция через Connection Names**: Миграция сама знает, к какой физической базе она относится (через флаг `connection = "default"`). Это дает возможность управлять несколькими базами данных из одной точки старта.
28
+ - **Синхронно описываем, Асинхронно исполняем**: DDL-миграции пишутся синхронно, а применяются через `run_sync` в случае использования `AsyncEngine`.
29
+
30
+ ## Установка
31
+
32
+ Используя Poetry:
33
+ ```bash
34
+ poetry add sqlalchemy-shifter
35
+ ```
36
+
37
+ Либо классическим pip:
38
+ ```bash
39
+ pip install sqlalchemy-shifter
40
+ ```
41
+
42
+ ## Использование
43
+
44
+ ### 1. Создание заготовки миграции
45
+ Для создания болванки файла миграции предусмотрен встроенный CLI.
46
+
47
+ ```bash
48
+ python -m sqlalchemy_shifter.cli create create_users_table --path src/myapp/migrations --conn default
49
+ ```
50
+
51
+ Эта команда сгенерирует файл `m20260401_120421_create_users_table.py` в указанной папке:
52
+
53
+ ```python
54
+ from sqlalchemy_shifter.migration import Migration
55
+ from sqlalchemy import Column, Integer, String
56
+
57
+ class m20260401_120421_create_users_table(Migration):
58
+
59
+ connection = "default"
60
+
61
+ def safeUp(self):
62
+ # Хелперы в стиле Yii2:
63
+ self.create_table(
64
+ "users",
65
+ Column("id", Integer, primary_key=True),
66
+ Column("username", String(255), nullable=False)
67
+ )
68
+ self.create_index("idx_username", "users", ["username"], unique=True)
69
+
70
+ def safeDown(self):
71
+ self.drop_index("idx_username", "users")
72
+ self.drop_table("users")
73
+ ```
74
+
75
+ ### 2. Запуск накатки (в коде приложения)
76
+
77
+ В точке старта вашего приложения инициализируйте `ShifterService`, передав ему инстансы SQLAlchemy Engines и пути к папкам с реализованными миграциями.
78
+
79
+ ```python
80
+ import asyncio
81
+ from sqlalchemy.ext.asyncio import create_async_engine
82
+ from sqlalchemy_shifter import ShifterService
83
+
84
+ async def main():
85
+ engine_main = create_async_engine("sqlite+aiosqlite:///app.db")
86
+
87
+ # 1. Инициализируем сервис
88
+ shifter = ShifterService(
89
+ engines={"default": engine_main}, # Маппинг подключений
90
+ paths=["src/myapp/migrations"] # Директории с миграциями
91
+ )
92
+
93
+ # 2. Выполняем миграции
94
+ applied_count = await shifter.upgrade_all()
95
+ print(f"Applied {applied_count} migrations.")
96
+
97
+ if __name__ == "__main__":
98
+ asyncio.run(main())
99
+ ```
100
+
101
+ > **Совет**: `ShifterService.upgrade_all()` проверяет таблицу состояния `shifter_migrations` конкретной базы. Если файл уже в статусе 'applied', он будет проигнорирован.
102
+
103
+ ## Лицензия
104
+
105
+ Пакет поставляется по лицензии **MIT**. См. файл `LICENSE`.
106
+
@@ -0,0 +1,89 @@
1
+ # SQLAlchemy Shifter
2
+
3
+ **SQLAlchemy Shifter** — это легковесный, модульный инструмент для управления миграциями баз данных поверх Python и SQLAlchemy. Идеологически вдохновлен системой миграций фреймворков уровня **Yii2** и **Laravel**.
4
+
5
+ В отличие от Alembic, здесь нет сложного графа версий, "магической" связки состояний или жесткой привязки к одному файлу `alembic.ini`. Инструмент использует простой линейный подход, базируясь исключительно на таймстемпах файлов, и спроектирован для использования в современных микросервисных архитектурах (когда миграции могут поставляться разными пакетами).
6
+
7
+ ## Ключевые особенности
8
+
9
+ - **Отсутствие Alembic-зависимостей**: Работает на чистом `SQLAlchemy Core`. Встроен собственный минималистичный DDL-генератор, никаких графов версионирования.
10
+ - **ООП-транзакционность `up` / `safeUp`**: Базовый класс предоставляет два уровня методов миграции. Если необходимо выполнение внутри транзакции — логика описывается в `safeUp()`. Движок атомарно обернет её блоком `with connection.begin()`.
11
+ - **Изоляция через Connection Names**: Миграция сама знает, к какой физической базе она относится (через флаг `connection = "default"`). Это дает возможность управлять несколькими базами данных из одной точки старта.
12
+ - **Синхронно описываем, Асинхронно исполняем**: DDL-миграции пишутся синхронно, а применяются через `run_sync` в случае использования `AsyncEngine`.
13
+
14
+ ## Установка
15
+
16
+ Используя Poetry:
17
+ ```bash
18
+ poetry add sqlalchemy-shifter
19
+ ```
20
+
21
+ Либо классическим pip:
22
+ ```bash
23
+ pip install sqlalchemy-shifter
24
+ ```
25
+
26
+ ## Использование
27
+
28
+ ### 1. Создание заготовки миграции
29
+ Для создания болванки файла миграции предусмотрен встроенный CLI.
30
+
31
+ ```bash
32
+ python -m sqlalchemy_shifter.cli create create_users_table --path src/myapp/migrations --conn default
33
+ ```
34
+
35
+ Эта команда сгенерирует файл `m20260401_120421_create_users_table.py` в указанной папке:
36
+
37
+ ```python
38
+ from sqlalchemy_shifter.migration import Migration
39
+ from sqlalchemy import Column, Integer, String
40
+
41
+ class m20260401_120421_create_users_table(Migration):
42
+
43
+ connection = "default"
44
+
45
+ def safeUp(self):
46
+ # Хелперы в стиле Yii2:
47
+ self.create_table(
48
+ "users",
49
+ Column("id", Integer, primary_key=True),
50
+ Column("username", String(255), nullable=False)
51
+ )
52
+ self.create_index("idx_username", "users", ["username"], unique=True)
53
+
54
+ def safeDown(self):
55
+ self.drop_index("idx_username", "users")
56
+ self.drop_table("users")
57
+ ```
58
+
59
+ ### 2. Запуск накатки (в коде приложения)
60
+
61
+ В точке старта вашего приложения инициализируйте `ShifterService`, передав ему инстансы SQLAlchemy Engines и пути к папкам с реализованными миграциями.
62
+
63
+ ```python
64
+ import asyncio
65
+ from sqlalchemy.ext.asyncio import create_async_engine
66
+ from sqlalchemy_shifter import ShifterService
67
+
68
+ async def main():
69
+ engine_main = create_async_engine("sqlite+aiosqlite:///app.db")
70
+
71
+ # 1. Инициализируем сервис
72
+ shifter = ShifterService(
73
+ engines={"default": engine_main}, # Маппинг подключений
74
+ paths=["src/myapp/migrations"] # Директории с миграциями
75
+ )
76
+
77
+ # 2. Выполняем миграции
78
+ applied_count = await shifter.upgrade_all()
79
+ print(f"Applied {applied_count} migrations.")
80
+
81
+ if __name__ == "__main__":
82
+ asyncio.run(main())
83
+ ```
84
+
85
+ > **Совет**: `ShifterService.upgrade_all()` проверяет таблицу состояния `shifter_migrations` конкретной базы. Если файл уже в статусе 'applied', он будет проигнорирован.
86
+
87
+ ## Лицензия
88
+
89
+ Пакет поставляется по лицензии **MIT**. См. файл `LICENSE`.
@@ -0,0 +1,18 @@
1
+ [project]
2
+ name = "sqlalchemy-shifter"
3
+ version = "0.1.0"
4
+ description = "A lightweight DB migration tool for SQLAlchemy"
5
+ authors = [
6
+ {name = "Alexey Volkov",email = "webwizardry@hotmail.com"}
7
+ ]
8
+ readme = "README.md"
9
+ requires-python = ">=3.10"
10
+ dependencies = [
11
+ "sqlalchemy (>=2.0.48,<3.0.0)",
12
+ "typer (>=0.24.1,<0.25.0)",
13
+ ]
14
+
15
+
16
+ [build-system]
17
+ requires = ["poetry-core>=2.0.0,<3.0.0"]
18
+ build-backend = "poetry.core.masonry.api"
@@ -0,0 +1,5 @@
1
+ from sqlalchemy_shifter.service import ShifterService
2
+ from sqlalchemy_shifter.migration import Migration
3
+ from sqlalchemy_shifter.builder import SchemaBuilder
4
+
5
+ __all__ = ["ShifterService", "Migration", "SchemaBuilder"]
@@ -0,0 +1,85 @@
1
+ from typing import Any
2
+ from sqlalchemy.engine import Connection
3
+ from sqlalchemy.schema import Table, MetaData, CreateTable, DropTable, Index, CreateIndex, DropIndex, DDLElement, Column
4
+ from sqlalchemy.ext.compiler import compiles
5
+
6
+
7
+ # Custom DDL Commands for SQLAlchemy
8
+
9
+ class AddColumn(DDLElement):
10
+ __visit_name__ = "add_column"
11
+ def __init__(self, table_name: str, column: Column):
12
+ self.table_name = table_name
13
+ self.column = column
14
+
15
+
16
+ class DropColumn(DDLElement):
17
+ __visit_name__ = "drop_column"
18
+ def __init__(self, table_name: str, column_name: str):
19
+ self.table_name = table_name
20
+ self.column_name = column_name
21
+
22
+
23
+ @compiles(AddColumn)
24
+ def visit_add_column(element: AddColumn, compiler: Any, **kw: Any) -> str:
25
+ table = Table(element.table_name, MetaData())
26
+ column_spec = compiler.get_column_specification(element.column)
27
+ return "ALTER TABLE %s ADD COLUMN %s" % (
28
+ compiler.preparer.format_table(table),
29
+ column_spec
30
+ )
31
+
32
+
33
+ @compiles(DropColumn)
34
+ def visit_drop_column(element: DropColumn, compiler: Any, **kw: Any) -> str:
35
+ table = Table(element.table_name, MetaData())
36
+ column_name = compiler.preparer.quote(element.column_name)
37
+ return "ALTER TABLE %s DROP COLUMN %s" % (
38
+ compiler.preparer.format_table(table),
39
+ column_name
40
+ )
41
+
42
+
43
+ class SchemaBuilder:
44
+ """
45
+ Легковесный DDL хелпер.
46
+ Обеспечивает Yii2-style синтаксис чисто поверх SQLAlchemy Core (без Alembic).
47
+ """
48
+
49
+ def __init__(self, connection: Connection):
50
+ self._connection = connection
51
+
52
+ def execute(self, sql: Any, *args, **kwargs) -> Any:
53
+ from sqlalchemy import text
54
+ if isinstance(sql, str):
55
+ sql = text(sql)
56
+ return self._connection.execute(sql, *args, **kwargs)
57
+
58
+ def create_table(self, table_name: str, *columns: Column) -> None:
59
+ # Привязываем колонки к абстрактной таблице для генерации DDL
60
+ table = Table(table_name, MetaData(), *columns)
61
+ self.execute(CreateTable(table))
62
+
63
+ def drop_table(self, table_name: str) -> None:
64
+ table = Table(table_name, MetaData())
65
+ self.execute(DropTable(table))
66
+
67
+ def add_column(self, table_name: str, column: Column) -> None:
68
+ self.execute(AddColumn(table_name, column))
69
+
70
+ def drop_column(self, table_name: str, column_name: str) -> None:
71
+ self.execute(DropColumn(table_name, column_name))
72
+
73
+ def create_index(self, index_name: str, table_name: str, columns: list[str], unique: bool = False) -> None:
74
+ # Для индексов оборачиваем названия во временные объекты, чтобы компилятор их понял
75
+ table = Table(table_name, MetaData(), *(Column(c) for c in columns))
76
+ cols = [getattr(table.c, c) for c in columns]
77
+ idx = Index(index_name, *cols, unique=unique)
78
+ self.execute(CreateIndex(idx))
79
+
80
+ def drop_index(self, index_name: str, table_name: str) -> None:
81
+ table = Table(table_name, MetaData())
82
+ idx = Index(index_name)
83
+ # Указание таблицы необходимо только для генерации диалектно корректного DROP
84
+ idx.table = table
85
+ self.execute(DropIndex(idx))
@@ -0,0 +1,53 @@
1
+ import os
2
+ import typer
3
+ from datetime import datetime
4
+
5
+ app = typer.Typer(help="SQLAlchemy Shifter CLI")
6
+
7
+
8
+ @app.command()
9
+ def create(
10
+ name: str = typer.Argument(..., help="Имя миграции, например: create_users_table"),
11
+ path: str = typer.Option("migrations", "--path", "-p", help="Путь до директории с миграциями"),
12
+ connection: str = typer.Option("default", "--conn", "-c", help="Имя соединения базы данных")
13
+ ):
14
+ """
15
+ Создает новый шаблонный файл миграции.
16
+ """
17
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
18
+ filename = f"m{timestamp}_{name}.py"
19
+ filepath = os.path.join(path, filename)
20
+
21
+ os.makedirs(path, exist_ok=True)
22
+
23
+ safe_name = name.replace('-', '_')
24
+ tpl = f'''from sqlalchemy_shifter.migration import Migration
25
+ from sqlalchemy import text
26
+
27
+
28
+ class m{timestamp}_{safe_name}(Migration):
29
+
30
+ connection = "{connection}"
31
+
32
+ def safeUp(self):
33
+ """
34
+ Метод выполняется внутри транзакции (оборачивается автоматическим begin).
35
+ Здесь вы можете использовать DDL хелперы:
36
+ self.create_table('users', ...)
37
+ """
38
+ pass
39
+
40
+ def safeDown(self):
41
+ """
42
+ Откат миграции. Выполняется внутри транзакции.
43
+ """
44
+ pass
45
+ '''
46
+ with open(filepath, "w", encoding="utf-8") as f:
47
+ f.write(tpl)
48
+
49
+ typer.secho(f"Миграция создана: {filepath}", fg=typer.colors.GREEN)
50
+
51
+
52
+ if __name__ == "__main__":
53
+ app()
@@ -0,0 +1,39 @@
1
+ from sqlalchemy.engine import Connection
2
+ from sqlalchemy_shifter.builder import SchemaBuilder
3
+
4
+
5
+ class Migration(SchemaBuilder):
6
+ """
7
+ Базовый класс миграций, реализующий транзакционность через методы up() и down().
8
+ Если логика требует транзакции - переопределяйте safeUp() / safeDown().
9
+ Если логика НЕ требует транзакции - переопределяйте up() / down().
10
+ """
11
+
12
+ connection: str = "default" # по умолчанию привязываем к коннекту 'default'
13
+
14
+ def __init__(self, connection: Connection):
15
+ super().__init__(connection)
16
+
17
+ def safeUp(self) -> None:
18
+ """Метод для безопасного применения миграции внутри транзакции."""
19
+ pass
20
+
21
+ def safeDown(self) -> None:
22
+ """Метод для безопасного отката миграции внутри транзакции."""
23
+ pass
24
+
25
+ def up(self) -> None:
26
+ """
27
+ Открывает транзакцию и вызывает safeUp.
28
+ Вызывается движком ShifterService.
29
+ """
30
+ with self._connection.begin():
31
+ self.safeUp()
32
+
33
+ def down(self) -> None:
34
+ """
35
+ Открывает транзакцию и вызывает safeDown.
36
+ Вызывается движком ShifterService.
37
+ """
38
+ with self._connection.begin():
39
+ self.safeDown()
@@ -0,0 +1,134 @@
1
+ import os
2
+ import importlib.util
3
+ import inspect
4
+ from sqlalchemy.ext.asyncio import AsyncEngine
5
+ from sqlalchemy.engine import Engine
6
+
7
+ from sqlalchemy_shifter.state import StateManager
8
+ from sqlalchemy_shifter.migration import Migration
9
+
10
+
11
+ class ShifterService:
12
+ """
13
+ Движок для управления миграциями.
14
+ Принимает маппинг движков SQLAlchemy и список директорий.
15
+ """
16
+
17
+ def __init__(self, engines: dict[str, Engine | AsyncEngine], paths: list[str]):
18
+ self.engines = engines
19
+ self.paths = paths
20
+
21
+ def _load_migrations(self):
22
+ """
23
+ Обходит директории и загружает все классы миграций.
24
+ Возвращает список словарей: [{'version': str, 'cls': type[Migration]}]
25
+ """
26
+ migrations = []
27
+ for path in self.paths:
28
+ if not os.path.exists(path):
29
+ continue
30
+
31
+ for root, _, files in os.walk(path):
32
+ for file in files:
33
+ if file.startswith("m") and file.endswith(".py"):
34
+ version = file[:-3] # отрезаем .py
35
+ filepath = os.path.join(root, file)
36
+
37
+ # динамически импортируем модуль
38
+ spec = importlib.util.spec_from_file_location(version, filepath)
39
+ if spec and spec.loader:
40
+ module = importlib.util.module_from_spec(spec)
41
+ spec.loader.exec_module(module)
42
+
43
+ # ищем класс, унаследованный от Migration
44
+ for name, obj in inspect.getmembers(module):
45
+ if inspect.isclass(obj) and issubclass(obj, Migration) and obj is not Migration:
46
+ migrations.append({
47
+ "version": version,
48
+ "cls": obj
49
+ })
50
+ break
51
+ return migrations
52
+
53
+ async def upgrade_all(self):
54
+ """Ищет непримененные миграции и накатывает их по хронологии."""
55
+ migrations = self._load_migrations()
56
+ # сортируем файлы по имени (таймстемпу)
57
+ migrations.sort(key=lambda x: x["version"])
58
+
59
+ applied_count = 0
60
+ for mig_dict in migrations:
61
+ version = mig_dict["version"]
62
+ migration_cls = mig_dict["cls"]
63
+
64
+ # Проверяем соединение для этой миграции
65
+ conn_name = getattr(migration_cls, "connection", "default")
66
+ engine = self.engines.get(conn_name)
67
+
68
+ if not engine:
69
+ raise ValueError(f"Engine '{conn_name}' not found for migration {version}")
70
+
71
+ if await self._apply_single_migration(engine, version, migration_cls):
72
+ applied_count += 1
73
+
74
+ return applied_count
75
+
76
+ async def _apply_single_migration(self, engine, version: str, migration_cls) -> bool:
77
+ """
78
+ Обертка над синхронным выполнением (работает для Sync и Async).
79
+ """
80
+ def _sync_upgrade(conn):
81
+ state = StateManager(conn)
82
+ # Если миграция уже была в этой БД, пропускаем
83
+ if version in state.get_applied_migrations():
84
+ return False
85
+
86
+ print(f"Applying migration: {version} (Connection: {migration_cls.connection})")
87
+
88
+ # Инстанцируем и выполняем up()
89
+ mig_instance = migration_cls(conn)
90
+ mig_instance.up()
91
+
92
+ # Сохраняем состояние
93
+ state.add_migration(version)
94
+ print(f"Success: {version}")
95
+ return True
96
+
97
+ if isinstance(engine, AsyncEngine):
98
+ # Асинхронный движок
99
+ async with engine.connect() as async_conn:
100
+ return await async_conn.run_sync(_sync_upgrade)
101
+ else:
102
+ # Синхронный движок
103
+ with engine.connect() as sync_conn:
104
+ return _sync_upgrade(sync_conn)
105
+
106
+ async def create_migration_file(self, path: str, name: str):
107
+ """Создает файл миграции с таймстемпом."""
108
+ import time
109
+ from datetime import datetime
110
+
111
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
112
+ filename = f"m{timestamp}_{name}.py"
113
+ filepath = os.path.join(path, filename)
114
+
115
+ os.makedirs(path, exist_ok=True)
116
+
117
+ tpl = f'''from sqlalchemy_shifter.migration import Migration
118
+ from sqlalchemy import text
119
+
120
+
121
+ class {name.capitalize()}Migration(Migration):
122
+ connection = "default"
123
+
124
+ def safeUp(self):
125
+ pass
126
+
127
+ def safeDown(self):
128
+ pass
129
+ '''
130
+ with open(filepath, "w", encoding="utf-8") as f:
131
+ f.write(tpl)
132
+
133
+ print(f"Created new migration: {filepath}")
134
+ return filepath
@@ -0,0 +1,51 @@
1
+ import time
2
+ from sqlalchemy import Table, Column, String, Integer, MetaData
3
+ from sqlalchemy.engine import Connection
4
+
5
+ class StateManager:
6
+ """
7
+ Управляет таблицей истории миграций.
8
+ Обеспечивает создание таблицы и получение/запись состояния.
9
+ """
10
+
11
+ TABLE_NAME = "shifter_migrations"
12
+
13
+ def __init__(self, connection: Connection):
14
+ self.connection = connection
15
+ self.metadata = MetaData()
16
+ self.table = Table(
17
+ self.TABLE_NAME,
18
+ self.metadata,
19
+ Column('version', String(255), primary_key=True),
20
+ Column('apply_time', Integer)
21
+ )
22
+
23
+ def ensure_table_exists(self):
24
+ """Создает таблицу, если её нет. Закрывает транзакцию."""
25
+ self.table.create(self.connection, checkfirst=True)
26
+ # В SQLAlchemy 2.0 любые операции открывают транзакцию по умолчанию.
27
+ # Мы коммитим её, чтобы подготовить коннект для последующего with conn.begin()
28
+ self.connection.commit()
29
+
30
+ def get_applied_migrations(self) -> list[str]:
31
+ """Возвращает список примененных версий. Закрывает read-транзакцию."""
32
+ self.ensure_table_exists()
33
+ stmt = self.table.select().order_by(self.table.c.apply_time.asc())
34
+ result = self.connection.execute(stmt)
35
+ versions = [row.version for row in result]
36
+ self.connection.commit() # освобождаем коннект
37
+ return versions
38
+
39
+ def add_migration(self, version: str):
40
+ """Записывает миграцию в историю."""
41
+ self.ensure_table_exists()
42
+ stmt = self.table.insert().values(version=version, apply_time=int(time.time()))
43
+ self.connection.execute(stmt)
44
+ self.connection.commit()
45
+
46
+ def remove_migration(self, version: str):
47
+ """Удаляет миграцию из истории."""
48
+ self.ensure_table_exists()
49
+ stmt = self.table.delete().where(self.table.c.version == version)
50
+ self.connection.execute(stmt)
51
+ self.connection.commit()