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>
This commit is contained in:
Andrey Epifancev 2026-10-03 20:36:13 +04:00
parent 3a057b2591
commit 78e4a95327
3 changed files with 75 additions and 12 deletions

View File

@ -6,7 +6,8 @@ URL БД (по убыванию приоритета):
шаблонной заглушки `driver://...` из `alembic.ini`;
3. `settings.OPENBAO.DATABASE.DATABASE_URL` (`.env` / `ENV_FILE`) — обычный `alembic upgrade head` / `make migrate`.
Схема `dkb` создаётся (`CREATE SCHEMA IF NOT EXISTS`) до таблицы версий `dkb.alembic_version` и до миграций.
Схема `dkb` создаётся до таблицы версий `dkb.alembic_version` и до миграций — только если её нет: PostgreSQL проверяет
право CREATE на базу раньше, чем `IF NOT EXISTS`, а платформа может выдать роли миграций готовую схему без этого права.
"""
import asyncio
@ -22,6 +23,11 @@ from src.models import Base # модели регистрируются в src/
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
@ -62,7 +68,7 @@ CONFIGURE_OPTS = {
def run_migrations_offline() -> None:
"""Офлайн-режим (`--sql`): DDL печатается в вывод; `CREATE SCHEMA` идёт первым, до таблицы версий."""
"""Офлайн-режим (`--sql`): DDL печатается в вывод; условный `CREATE SCHEMA` идёт первым, до таблицы версий."""
context.configure(
url=database_url(),
literal_binds=True,
@ -71,14 +77,17 @@ def run_migrations_offline() -> None:
)
with context.begin_transaction():
context.execute(f"CREATE SCHEMA IF NOT EXISTS {SCHEMA}")
context.execute(CREATE_SCHEMA_IF_MISSING_SQL)
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
# Схема нужна до того, как alembic создаст `dkb.alembic_version`; фиксируем её отдельной транзакцией —
# иначе autobegin оставил бы соединение во «внешней» транзакции и alembic не стал бы коммитить миграции.
connection.execute(text(f"CREATE SCHEMA IF NOT EXISTS {SCHEMA}"))
# Схема нужна до того, как 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)

View File

@ -24,6 +24,12 @@ depends_on: str | Sequence[str] | None = None
SCHEMA = "dkb"
FK_CYCLE = "fk_stored_file_current_recognition_job_id_recognition_job"
#: Схема создаётся только при отсутствии (и без env.py, и в офлайн-SQL): PostgreSQL проверяет право CREATE на базу
#: раньше, чем `IF NOT EXISTS`, а роли миграций платформа может выдать готовую схему без этого права.
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 $$"
)
def _enum(name: str, *values: str) -> postgresql.ENUM:
@ -171,11 +177,11 @@ TABLES: tuple[str, ...] = (
def upgrade() -> None:
"""Схема dkb (идемпотентно) -> 27 enum-типов -> 43 таблицы с их индексами в порядке зависимостей FK.
"""Схема dkb (если её нет) -> 27 enum-типов -> 43 таблицы с их индексами в порядке зависимостей FK.
FK цикла `stored_file.current_recognition_job_id` добавляется сразу после создания `recognition_job`.
"""
op.execute(f"CREATE SCHEMA IF NOT EXISTS {SCHEMA}")
op.execute(CREATE_SCHEMA_IF_MISSING_SQL)
bind = op.get_bind()
for enum_type in ENUMS.values():
enum_type.create(bind, checkfirst=False)

View File

@ -16,12 +16,12 @@ from typing import Any
import pytest
from alembic.autogenerate import compare_metadata
from alembic.runtime.migration import MigrationContext
from sqlalchemy import Connection, text
from sqlalchemy import Connection, make_url, text
from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, AsyncSession, create_async_engine
from src.models import Base
from src.models.enums import ENUMS
from tests.pg_helpers import create_scratch_db, drop_scratch_db
from tests.pg_helpers import base_url, create_scratch_db, drop_scratch_db
from tests.test_models_part_01_03 import _check_related_group_member_uniqueness
from tests.test_models_part_04_06 import _check_nulls_not_distinct, _make_file_with_job
from tests.test_models_part_07_09 import (
@ -275,10 +275,58 @@ async def test_migrated_schema_behaviour(scratch_db_url: str) -> None:
await engine.dispose()
@pytest.mark.integration
async def test_upgrade_into_preprovisioned_schema_without_database_create(scratch_db_url: str) -> None:
"""Схему dkb выдала платформа во владение роли миграций, права CREATE на БД у роли нет: цикл миграций проходит.
PostgreSQL проверяет право CREATE на БД раньше, чем IF NOT EXISTS, поэтому схема создаётся только при отсутствии.
"""
role = f"armcore_mig_{uuid.uuid4().hex[:12]}" # роли общие для кластера — имя уникальное, удаляется в finally
password = uuid.uuid4().hex
scratch_url = make_url(scratch_db_url)
role_url = scratch_url.set(username=role, password=password).render_as_string(hide_password=False)
server = create_async_engine(base_url(), isolation_level="AUTOCOMMIT")
scratch = create_async_engine(scratch_db_url, isolation_level="AUTOCOMMIT")
role_created = False
try:
async with server.connect() as conn:
await conn.execute(text(f"CREATE ROLE \"{role}\" LOGIN PASSWORD '{password}'"))
role_created = True
async with scratch.connect() as conn:
await conn.execute(text(f'REVOKE CREATE ON DATABASE "{scratch_url.database}" FROM PUBLIC'))
await conn.execute(text(f'CREATE SCHEMA dkb AUTHORIZATION "{role}"'))
can_create = await conn.scalar(
text("SELECT has_database_privilege(:role, :db, 'CREATE')"),
{"role": role, "db": scratch_url.database},
)
assert can_create is False
await alembic(role_url, "upgrade", "head")
await alembic(role_url, "check")
await alembic(role_url, "downgrade", "base")
await alembic(role_url, "upgrade", "head")
async with scratch.connect() as conn:
await _assert_full_model(conn)
owner = await conn.scalar(text("SELECT nspowner::regrole::text FROM pg_namespace WHERE nspname = 'dkb'"))
assert owner == role
finally:
if role_created:
async with scratch.connect() as conn:
await conn.execute(text(f'DROP OWNED BY "{role}" CASCADE'))
async with server.connect() as conn:
await conn.execute(text(f'DROP ROLE "{role}"'))
await scratch.dispose()
await server.dispose()
def test_offline_sql_creates_schema_before_version_table() -> None:
"""Офлайн-режим (`upgrade head --sql`): CREATE SCHEMA dkb идёт до таблицы версий и до enum-типов."""
"""Офлайн-режим (`upgrade head --sql`): условный CREATE SCHEMA dkb идёт до таблицы версий и до enum-типов."""
output = _run_alembic("postgresql+asyncpg://user:pass@localhost/offline", "upgrade", "head", "--sql")
schema_at = output.index("CREATE SCHEMA IF NOT EXISTS dkb")
schema_at = output.index(
"DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = 'dkb') "
"THEN CREATE SCHEMA dkb; END IF; END $$"
)
assert "CREATE SCHEMA IF NOT EXISTS" not in output # требует права CREATE на базу даже при существующей схеме
assert schema_at < output.index("CREATE TABLE dkb.alembic_version")
assert schema_at < output.index("CREATE TYPE dkb.entity_type AS ENUM ('ЮЛ', 'ИП')")
assert f"INSERT INTO dkb.alembic_version (version_num) VALUES ('{HEAD}')" in output