Andrey Epifancev 251c30d5ca feat(#190): DBML v5.25 — ORM и миграция фактов качества первички
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-11-05 15:49:15 +04:00

118 lines
5.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Окружение Alembic: схема `settings.DB_SCHEMA` (по умолчанию `dkb`), async-движок, URL БД из настроек или вызывающего.
URL БД (по убыванию приоритета):
1. `alembic -x dburl=<url> ...` — для тестов и разовых запусков против другой БД (временной, scratch);
2. `sqlalchemy.url` в Config, если вызывающий код задал его сам (`Config.set_main_option`), — всё, кроме
шаблонной заглушки `driver://...` из `alembic.ini`;
3. `settings.OPENBAO.DATABASE.DATABASE_URL` (`.env` / `ENV_FILE`) — обычный `alembic upgrade head` / `make migrate`.
Схема создаётся до таблицы версий `<схема>.alembic_version` и до миграций — только если её нет: PostgreSQL проверяет
право CREATE на базу раньше, чем `IF NOT EXISTS`, а платформа может выдать роли миграций готовую схему без этого права.
v5.25: вторая схема `settings.QUALITY_METRICS_SCHEMA` (факты качества, `METRICS_SCHEMA`) — та же цепочка миграций и
`alembic_version` (одна схема версий, `version_table_schema=SCHEMA`), но её саму создаёт сама миграция 0012
(`CREATE SCHEMA IF NOT EXISTS`), а не `env.py`: в отличие от `SCHEMA`, она не нужна до первой миграции.
"""
import asyncio
from logging.config import fileConfig
from sqlalchemy import Connection, pool, text
from sqlalchemy.ext.asyncio import create_async_engine
from alembic import context
from src.core.config import settings
from src.models import Base # модели регистрируются в src/models/__init__.py
from src.models.base import METRICS_SCHEMA, SCHEMA
#: Значение `sqlalchemy.url` в шаблонном `alembic.ini` — признак «URL не задан вызывающим кодом».
INI_PLACEHOLDER_PREFIX = "driver://"
#: Офлайн-вариант условного создания схемы: условие проверяется при применении SQL-скрипта, а не при его генерации.
CREATE_SCHEMA_IF_MISSING_SQL = (
f"DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = '{SCHEMA}') "
f"THEN CREATE SCHEMA {SCHEMA}; END IF; END $$"
)
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def database_url() -> str:
"""URL БД: `-x dburl=...` > `sqlalchemy.url`, заданный вызывающим кодом > настройки приложения."""
x_url = context.get_x_argument(as_dictionary=True).get("dburl")
if x_url:
return x_url
config_url = config.get_main_option("sqlalchemy.url")
if config_url and not config_url.startswith(INI_PLACEHOLDER_PREFIX):
return config_url
return settings.OPENBAO.DATABASE.DATABASE_URL
def include_name(name: str | None, type_: str, parent_names: object) -> bool:
"""Autogenerate видит схему модели (`SCHEMA`) и схему фактов качества (`METRICS_SCHEMA`, v5.25) — схема по
умолчанию `public` и прочие не наши."""
if type_ == "schema":
return name in (SCHEMA, METRICS_SCHEMA)
return True
#: Опции, общие для онлайн- и офлайн-режима. `compare_server_default` — чтобы autogenerate ловил расхождение
#: server_default между ORM и БД (иначе изменение default'а проходит незамеченным).
CONFIGURE_OPTS = {
"target_metadata": target_metadata,
"compare_type": True,
"compare_server_default": True,
"version_table_schema": SCHEMA,
"include_schemas": True,
"include_name": include_name,
}
def run_migrations_offline() -> None:
"""Офлайн-режим (`--sql`): DDL печатается в вывод; условный `CREATE SCHEMA` идёт первым, до таблицы версий."""
context.configure(
url=database_url(),
literal_binds=True,
dialect_opts={"paramstyle": "named"},
**CONFIGURE_OPTS,
)
with context.begin_transaction():
context.execute(CREATE_SCHEMA_IF_MISSING_SQL)
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
# Схема нужна до того, как alembic создаст `<схема>.alembic_version`. CREATE — только если схемы нет (см. docstring
# модуля). Транзакцию закрываем сразу: иначе autobegin оставил бы соединение во «внешней» транзакции и alembic
# не стал бы коммитить миграции.
exists = connection.execute(text("SELECT 1 FROM pg_namespace WHERE nspname = :name"), {"name": SCHEMA}).scalar()
if exists is None:
connection.execute(text(f"CREATE SCHEMA {SCHEMA}"))
connection.commit()
context.configure(connection=connection, **CONFIGURE_OPTS)
with context.begin_transaction():
context.run_migrations()
async def run_migrations_online() -> None:
"""Онлайн-режим: async-движок (asyncpg), миграции в синхронном адаптере соединения."""
connectable = create_async_engine(database_url(), poolclass=pool.NullPool)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
if context.is_offline_mode():
run_migrations_offline()
else:
asyncio.run(run_migrations_online())