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.
- sqlalchemy_shifter-0.1.0/LICENSE +21 -0
- sqlalchemy_shifter-0.1.0/PKG-INFO +106 -0
- sqlalchemy_shifter-0.1.0/README.md +89 -0
- sqlalchemy_shifter-0.1.0/pyproject.toml +18 -0
- sqlalchemy_shifter-0.1.0/sqlalchemy_shifter/__init__.py +5 -0
- sqlalchemy_shifter-0.1.0/sqlalchemy_shifter/builder.py +85 -0
- sqlalchemy_shifter-0.1.0/sqlalchemy_shifter/cli.py +53 -0
- sqlalchemy_shifter-0.1.0/sqlalchemy_shifter/migration.py +39 -0
- sqlalchemy_shifter-0.1.0/sqlalchemy_shifter/service.py +134 -0
- sqlalchemy_shifter-0.1.0/sqlalchemy_shifter/state.py +51 -0
|
@@ -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,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()
|