Andrey Epifancev 78e4a95327 fix: схема dkb создаётся только при отсутствии — миграции работают без права CREATE на базу
PostgreSQL проверяет право CREATE на базу раньше IF NOT EXISTS: роль миграций, которой платформа выдала готовую
схему dkb без этого права, падала на CREATE SCHEMA IF NOT EXISTS. env.py (онлайн) проверяет pg_namespace и создаёт
схему только при отсутствии; офлайн-SQL и upgrade() ревизии 0001 — DO-блок с той же проверкой. Тест: схема заранее
во владении отдельной роли, CREATE на базу отозван у PUBLIC — upgrade/check/downgrade/upgrade проходят.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-03 20:36:13 +04:00

113 lines
5.2 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: схема `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`.
Схема `dkb` создаётся до таблицы версий `dkb.alembic_version` и до миграций — только если её нет: PostgreSQL проверяет
право CREATE на базу раньше, чем `IF NOT EXISTS`, а платформа может выдать роли миграций готовую схему без этого права.
"""
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
SCHEMA = "dkb"
#: Значение `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 видит только схему `dkb` (схема по умолчанию `public` и прочие — не наши)."""
if type_ == "schema":
return name == 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 создаст `dkb.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())