diff --git a/api/alembic/env.py b/api/alembic/env.py index f2baf16..ebf310d 100644 --- a/api/alembic/env.py +++ b/api/alembic/env.py @@ -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) diff --git a/api/alembic/versions/0001_dbml_v5_13.py b/api/alembic/versions/0001_dbml_v5_13.py index 5a68308..bec958c 100644 --- a/api/alembic/versions/0001_dbml_v5_13.py +++ b/api/alembic/versions/0001_dbml_v5_13.py @@ -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) diff --git a/api/tests/test_migrations.py b/api/tests/test_migrations.py index 223304e..780717d 100644 --- a/api/tests/test_migrations.py +++ b/api/tests/test_migrations.py @@ -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