From 9e53fc64be93c20b3dd1523e9b4f5d59158f9190 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 7 Oct 2026 19:26:37 +0400 Subject: [PATCH] =?UTF-8?q?=D0=A1=D1=85=D0=B5=D0=BC=D0=B0=20=D0=91=D0=94?= =?UTF-8?q?=20=D0=BA=D0=BE=D0=BD=D1=84=D0=B8=D0=B3=D1=83=D1=80=D0=B8=D1=80?= =?UTF-8?q?=D1=83=D0=B5=D0=BC=D0=B0=D1=8F:=20=D0=BD=D0=B0=D1=81=D1=82?= =?UTF-8?q?=D1=80=D0=BE=D0=B9=D0=BA=D0=B0=20DB=5FSCHEMA=20=D0=B2=D0=BC?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D0=BE=20=D0=B7=D0=B0=D1=88=D0=B8=D1=82=D0=BE?= =?UTF-8?q?=D0=B9=20dkb=20(#38)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DB_SCHEMA (по умолчанию dkb, валидация идентификатора) читают models/base.py (SCHEMA), db/db.py (search_path), alembic/env.py и миграции 0001-0005 (SCHEMA и строковые FK f"{SCHEMA}.x.id" вместо литералов). В контуре - OPENBAO__APP__DB_SCHEMA. Офлайн-SQL `alembic upgrade head --sql` при значении по умолчанию совпадает побайтно. Тесты: валидация настройки, миграции и сессия приложения в схеме dkb_alt; .env.example/.env.test/README/CLAUDE.md. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EAKRDNzXtRrwmWz9z6PZu3 --- CLAUDE.md | 2 +- README.md | 5 +- api/.env.example | 5 + api/.env.test | 3 + api/alembic/env.py | 10 +- api/alembic/versions/0001_dbml_v5_13.py | 264 ++++++++++++++---------- api/alembic/versions/0002_dbml_v5_14.py | 10 +- api/alembic/versions/0003_dbml_v5_15.py | 2 +- api/alembic/versions/0004_dbml_v5_16.py | 2 +- api/alembic/versions/0005_dbml_v5_17.py | 2 +- api/scripts/check_dbml_vs_orm.py | 3 + api/scripts/dbml_parity.py | 1 + api/scripts/seed.py | 2 +- api/src/core/config.py | 17 ++ api/src/db/db.py | 3 +- api/src/models/base.py | 9 +- api/src/models/enums.py | 2 +- api/tests/conftest.py | 7 +- api/tests/pg_helpers.py | 10 +- api/tests/test_config_openbao_app.py | 25 ++- api/tests/test_db_schema.py | 96 +++++++++ 21 files changed, 345 insertions(+), 135 deletions(-) create mode 100644 api/tests/test_db_schema.py diff --git a/CLAUDE.md b/CLAUDE.md index 7114dfb..18fc7f0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,7 +40,7 @@ управляется только `NATS_ENABLED` (от `DEBUG` не зависит); при `DEBUG=false` шаблонный импорт `raisa_fastapi_protected_api` в `app.py` условный (пакета нет вне банка — не ошибка); middleware РАИСА в Срезе 0 не подключается. -- **БД** — схема **`dkb`** (MetaData, `search_path`, Alembic); PK `uuid` через `UUIDPrimaryMixin`; ревизии в +- **БД** — схема **`dkb`** по умолчанию, имя конфигурируемо настройкой `DB_SCHEMA` (`OPENBAO__APP__DB_SCHEMA` в контуре; MetaData, `search_path`, Alembic читают `models.base.SCHEMA`, в миграциях — `from src.models.base import SCHEMA`, не литерал); PK `uuid` через `UUIDPrimaryMixin`; ревизии в `api/alembic/versions/`; автогенерация — черновик, финал сверяется с DBML скриптом. - **Ошибки** — `common/exceptions.py` → `core/exception_handlers.py`. Доменные ошибки (`DomainError`: `NotFoundError` 404 `NOT_FOUND`, `ConflictError` 409, `DomainValidationError` 422; код и доп. поля задаёт вызывающий) отдаются как diff --git a/README.md b/README.md index c4f7adf..7f83147 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,7 @@ cp api/.env.example api/.env ### 4. Миграции и справочники ```bash -make migrate # alembic upgrade head: схема dkb, 27 enum-типов, 43 таблицы +make migrate # alembic upgrade head: схема `DB_SCHEMA` (по умолчанию dkb), 27 enum-типов, 43 таблицы make seed-dev # справочники продукта + dev-пользователи (по одному на каждую роль) + dev-ГК под документы fixtures ``` @@ -879,6 +879,7 @@ email без учёта регистра проверяет только сер | `OPENBAO__APP__<ИМЯ>` | — | **контур платформы:** туда передаются только секреты OpenBao (`OPENBAO__*`), обычные переменные окружения задать нельзя. Любая настройка верхнего уровня из этой таблицы (`NATS_ENABLED`, `DEBUG`, `AUTH_DEV_ISSUER`, `FORWARDED_ALLOW_IPS`, `CORS_ORIGINS`, …) может прийти как `OPENBAO__APP__<ИМЯ>`, например `OPENBAO__APP__NATS_ENABLED=true`. Значения разбираются как из обычного env (bool, числа, списки). Приоритет: обычный env → `OPENBAO__APP__*` → `.env` → значение по умолчанию. Неизвестное имя — ошибка конфигурации при старте (опечатка не игнорируется); `OPENBAO__APP__OPENBAO__…` не допускается. Читается только из окружения процесса (в `.env` не действует). Реализация — `api/src/core/settings_sources.py` | | `ENV_FILE` | `.env` | файл окружения (относительно `api/`) | | `DEBUG` | `false` | режим разработки. `true` необходим dev-эмитенту токенов и `make seed-dev`, включает документацию API (`/docs`, `/redoc`, `/openapi.json`) и CORS `*`; при `false` документации нет (404), CORS — из `CORS_ORIGINS`, пакеты РАИСА подгружаются условно | +| `DB_SCHEMA` | `dkb` | схема PostgreSQL модели данных: MetaData ORM, `search_path` движка приложения, `CREATE SCHEMA` и таблица версий Alembic, схема в миграциях. Имя — строчный идентификатор `[a-z_][a-z0-9_]{0,62}`, не `pg_*` (подставляется в DDL без кавычек; иное — ошибка при старте). **Одно значение для приложения и для `alembic upgrade`:** сменить схему у уже мигрированной БД нельзя (таблицы остаются в старой); в контуре — `OPENBAO__APP__DB_SCHEMA`, в миграционном задании тоже. Читается при импорте моделей, поэтому скрипты и тесты видят его через те же `.env`/окружение | | `FORWARDED_ALLOW_IPS` | `127.0.0.1` | доверенные прокси для `X-Forwarded-For`/`X-Forwarded-Proto` (`ProxyHeadersMiddleware`): адреса через запятую или `*`. Адрес клиента (`audit_event.ip_address`) берётся из заголовка только от них, от остальных заголовок игнорируется. На платформе — адреса ingress/балансировщика (OQ-65) | | `CORS_ORIGINS` | пусто | при `DEBUG=false` — источники, которым разрешены кросс-доменные запросы: строка через запятую (`https://a.example.ru,https://b.example.ru`) или JSON-список. Пусто — кросс-доменные запросы запрещены (middleware CORS не подключается); `allow_credentials` — только для явного списка без `*`. При `DEBUG=true` не используется (всегда `*` без credentials: токен передаётся заголовком, cookie не нужны) | | `SERVER__HOST`, `SERVER__PORT` | `0.0.0.0`, `8000` | адрес и порт сервера (`run_server.py`); в `.env.example` — `127.0.0.1` (dev-запуск только на loopback) | @@ -1020,7 +1021,7 @@ api/ api/dependencies/ зависимости FastAPI (current_user, проверка прав) core/ config, auth (JWT), clients (S3), nats (подключение, publish, подписка), logger, exception_handlers - db/ движок и сессия (схема dkb) + db/ движок и сессия (схема из `DB_SCHEMA`, по умолчанию dkb) models/ ORM-модели, 1:1 с DBML repositories/ доступ к данным schemas/ pydantic-схемы запросов и ответов diff --git a/api/.env.example b/api/.env.example index 1942f13..8b44d9c 100644 --- a/api/.env.example +++ b/api/.env.example @@ -4,7 +4,12 @@ # Контур платформы: обычные переменные окружения там задать нельзя, передаются только секреты OpenBao (OPENBAO__*). # Любая настройка верхнего уровня ниже (не вложенные OPENBAO__<ГРУППА>__*) может прийти как OPENBAO__APP__<ИМЯ>, например: # OPENBAO__APP__NATS_ENABLED=true +# OPENBAO__APP__DB_SCHEMA=dkb # OPENBAO__APP__DEBUG=true + +# Схема PostgreSQL модели данных (по умолчанию dkb). Одно значение для приложения и Alembic; идентификатор +# [a-z_][a-z0-9_]{0,62}, не pg_*. В контуре платформы: OPENBAO__APP__DB_SCHEMA +DB_SCHEMA=dkb # OPENBAO__APP__AUTH_DEV_ISSUER=true # OPENBAO__APP__FORWARDED_ALLOW_IPS=10.0.0.1,10.0.0.2 # Приоритет: обычный env -> OPENBAO__APP__* -> .env -> значение по умолчанию. Неизвестное имя — ошибка при старте. diff --git a/api/.env.test b/api/.env.test index 979d167..326f4dc 100644 --- a/api/.env.test +++ b/api/.env.test @@ -4,6 +4,9 @@ DEBUG=true +# Схема БД: как в .env.example (тесты используют ту же настройку, что и приложение) +DB_SCHEMA=dkb + # Как в .env.example (тесты сервер не запускают) SERVER__HOST=127.0.0.1 SERVER__PORT=8000 diff --git a/api/alembic/env.py b/api/alembic/env.py index ebf310d..862e131 100644 --- a/api/alembic/env.py +++ b/api/alembic/env.py @@ -1,4 +1,4 @@ -"""Окружение Alembic: схема `dkb`, async-движок, URL БД из настроек или из вызывающего кода. +"""Окружение Alembic: схема `settings.DB_SCHEMA` (по умолчанию `dkb`), async-движок, URL БД из настроек или вызывающего. URL БД (по убыванию приоритета): 1. `alembic -x dburl= ...` — для тестов и разовых запусков против другой БД (временной, scratch); @@ -6,7 +6,7 @@ URL БД (по убыванию приоритета): шаблонной заглушки `driver://...` из `alembic.ini`; 3. `settings.OPENBAO.DATABASE.DATABASE_URL` (`.env` / `ENV_FILE`) — обычный `alembic upgrade head` / `make migrate`. -Схема `dkb` создаётся до таблицы версий `dkb.alembic_version` и до миграций — только если её нет: PostgreSQL проверяет +Схема создаётся до таблицы версий `<схема>.alembic_version` и до миграций — только если её нет: PostgreSQL проверяет право CREATE на базу раньше, чем `IF NOT EXISTS`, а платформа может выдать роли миграций готовую схему без этого права. """ @@ -19,8 +19,8 @@ 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 SCHEMA -SCHEMA = "dkb" #: Значение `sqlalchemy.url` в шаблонном `alembic.ini` — признак «URL не задан вызывающим кодом». INI_PLACEHOLDER_PREFIX = "driver://" #: Офлайн-вариант условного создания схемы: условие проверяется при применении SQL-скрипта, а не при его генерации. @@ -49,7 +49,7 @@ def database_url() -> str: def include_name(name: str | None, type_: str, parent_names: object) -> bool: - """Autogenerate видит только схему `dkb` (схема по умолчанию `public` и прочие — не наши).""" + """Autogenerate видит только схему модели (`SCHEMA`) (схема по умолчанию `public` и прочие — не наши).""" if type_ == "schema": return name == SCHEMA return True @@ -82,7 +82,7 @@ def run_migrations_offline() -> None: def do_run_migrations(connection: Connection) -> None: - # Схема нужна до того, как alembic создаст `dkb.alembic_version`. CREATE — только если схемы нет (см. docstring + # Схема нужна до того, как alembic создаст `<схема>.alembic_version`. CREATE — только если схемы нет (см. docstring # модуля). Транзакцию закрываем сразу: иначе autobegin оставил бы соединение во «внешней» транзакции и alembic # не стал бы коммитить миграции. exists = connection.execute(text("SELECT 1 FROM pg_namespace WHERE nspname = :name"), {"name": SCHEMA}).scalar() diff --git a/api/alembic/versions/0001_dbml_v5_13.py b/api/alembic/versions/0001_dbml_v5_13.py index bec958c..fd4f1a2 100644 --- a/api/alembic/versions/0001_dbml_v5_13.py +++ b/api/alembic/versions/0001_dbml_v5_13.py @@ -1,4 +1,4 @@ -"""DBML v5.13: вся модель данных АРМ КА с нуля (схема dkb, 27 enum-типов, 43 таблицы, ограничения, индексы). +"""DBML v5.13: вся модель данных АРМ КА с нуля (схема БД, 27 enum-типов, 43 таблицы, ограничения, индексы). Ревизия заморожена: явный DDL, модели `src.models` не импортируются. Имена ограничений и индексов совпадают с ORM (naming_convention `src/models/base.py` + явные имена); `alembic check` после upgrade не находит расхождений. @@ -16,13 +16,13 @@ import sqlalchemy as sa from sqlalchemy.dialects import postgresql from alembic import op +from src.models.base import SCHEMA revision: str = "0001_dbml_v5_13" down_revision: str | Sequence[str] | None = None branch_labels: str | Sequence[str] | None = None 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`, а роли миграций платформа может выдать готовую схему без этого права. @@ -33,7 +33,7 @@ CREATE_SCHEMA_IF_MISSING_SQL = ( def _enum(name: str, *values: str) -> postgresql.ENUM: - """Нативный PG-enum `dkb.`; тип создаётся явно в upgrade(), а не неявно из create_table.""" + """Нативный PG-enum `<схема>.`; тип создаётся явно в upgrade(), а не неявно из create_table.""" return postgresql.ENUM(*values, name=name, schema=SCHEMA, create_type=False) @@ -177,7 +177,7 @@ TABLES: tuple[str, ...] = ( def upgrade() -> None: - """Схема dkb (если её нет) -> 27 enum-типов -> 43 таблицы с их индексами в порядке зависимостей FK. + """Схема БД (если её нет) -> 27 enum-типов -> 43 таблицы с их индексами в порядке зависимостей FK. FK цикла `stored_file.current_recognition_job_id` добавляется сразу после создания `recognition_job`. """ @@ -195,7 +195,9 @@ def upgrade() -> None: sa.Column("row_type", ENUMS["account_row_type"], nullable=False), sa.Column("is_off_balance", sa.Boolean(), server_default=sa.text("false"), nullable=False), sa.ForeignKeyConstraint( - ["parent_id"], ["dkb.chart_of_account.id"], name=op.f("fk_chart_of_account_parent_id_chart_of_account") + ["parent_id"], + [f"{SCHEMA}.chart_of_account.id"], + name=op.f("fk_chart_of_account_parent_id_chart_of_account"), ), sa.PrimaryKeyConstraint("id", name=op.f("pk_chart_of_account")), sa.UniqueConstraint("code", name=op.f("uq_chart_of_account_code")), @@ -296,7 +298,7 @@ def upgrade() -> None: sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), sa.ForeignKeyConstraint( ["regional_office_id"], - ["dkb.regional_office.id"], + [f"{SCHEMA}.regional_office.id"], name=op.f("fk_app_user_regional_office_id_regional_office"), ), sa.PrimaryKeyConstraint("id", name=op.f("pk_app_user")), @@ -313,11 +315,13 @@ def upgrade() -> None: sa.Column("is_excluded", sa.Boolean(), server_default=sa.text("false"), nullable=False), sa.ForeignKeyConstraint( ["account_id"], - ["dkb.chart_of_account.id"], + [f"{SCHEMA}.chart_of_account.id"], name=op.f("fk_elimination_rule_account_account_id_chart_of_account"), ), sa.ForeignKeyConstraint( - ["rule_id"], ["dkb.elimination_rule.id"], name=op.f("fk_elimination_rule_account_rule_id_elimination_rule") + ["rule_id"], + [f"{SCHEMA}.elimination_rule.id"], + name=op.f("fk_elimination_rule_account_rule_id_elimination_rule"), ), sa.PrimaryKeyConstraint("id", name=op.f("pk_elimination_rule_account")), schema=SCHEMA, @@ -331,7 +335,7 @@ def upgrade() -> None: sa.Column("is_mandatory", sa.Boolean(), nullable=False), sa.ForeignKeyConstraint( ["account_id"], - ["dkb.chart_of_account.id"], + [f"{SCHEMA}.chart_of_account.id"], name=op.f("fk_required_document_rule_account_id_chart_of_account"), ), sa.PrimaryKeyConstraint("id", name=op.f("pk_required_document_rule")), @@ -352,7 +356,7 @@ def upgrade() -> None: sa.Column("text", sa.Text(), nullable=False), sa.Column("author_id", sa.UUID(), nullable=False), sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), - sa.ForeignKeyConstraint(["author_id"], ["dkb.app_user.id"], name=op.f("fk_comment_author_id_app_user")), + sa.ForeignKeyConstraint(["author_id"], [f"{SCHEMA}.app_user.id"], name=op.f("fk_comment_author_id_app_user")), sa.PrimaryKeyConstraint("id", name=op.f("pk_comment")), schema=SCHEMA, ) @@ -373,10 +377,12 @@ def upgrade() -> None: sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), sa.ForeignKeyConstraint( ["base_regional_office_id"], - ["dkb.regional_office.id"], + [f"{SCHEMA}.regional_office.id"], name=op.f("fk_company_group_base_regional_office_id_regional_office"), ), - sa.ForeignKeyConstraint(["updated_by"], ["dkb.app_user.id"], name=op.f("fk_company_group_updated_by_app_user")), + sa.ForeignKeyConstraint( + ["updated_by"], [f"{SCHEMA}.app_user.id"], name=op.f("fk_company_group_updated_by_app_user") + ), sa.PrimaryKeyConstraint("id", name=op.f("pk_company_group")), sa.UniqueConstraint("code", name=op.f("uq_company_group_code")), sa.UniqueConstraint("crm_id", name=op.f("uq_company_group_crm_id")), @@ -389,12 +395,12 @@ def upgrade() -> None: sa.Column("granted_by", sa.UUID(), nullable=True), sa.Column("granted_at", sa.DateTime(timezone=True), nullable=True), sa.ForeignKeyConstraint( - ["granted_by"], ["dkb.app_user.id"], name=op.f("fk_role_permission_granted_by_app_user") + ["granted_by"], [f"{SCHEMA}.app_user.id"], name=op.f("fk_role_permission_granted_by_app_user") ), sa.ForeignKeyConstraint( - ["permission_id"], ["dkb.permission.id"], name=op.f("fk_role_permission_permission_id_permission") + ["permission_id"], [f"{SCHEMA}.permission.id"], name=op.f("fk_role_permission_permission_id_permission") ), - sa.ForeignKeyConstraint(["role_id"], ["dkb.role.id"], name=op.f("fk_role_permission_role_id_role")), + sa.ForeignKeyConstraint(["role_id"], [f"{SCHEMA}.role.id"], name=op.f("fk_role_permission_role_id_role")), sa.PrimaryKeyConstraint("role_id", "permission_id", name=op.f("pk_role_permission")), schema=SCHEMA, ) @@ -403,8 +409,8 @@ def upgrade() -> None: sa.Column("id", sa.UUID(), nullable=False), sa.Column("user_id", sa.UUID(), nullable=False), sa.Column("role_id", sa.UUID(), nullable=False), - sa.ForeignKeyConstraint(["role_id"], ["dkb.role.id"], name=op.f("fk_user_role_role_id_role")), - sa.ForeignKeyConstraint(["user_id"], ["dkb.app_user.id"], name=op.f("fk_user_role_user_id_app_user")), + sa.ForeignKeyConstraint(["role_id"], [f"{SCHEMA}.role.id"], name=op.f("fk_user_role_role_id_role")), + sa.ForeignKeyConstraint(["user_id"], [f"{SCHEMA}.app_user.id"], name=op.f("fk_user_role_user_id_app_user")), sa.PrimaryKeyConstraint("id", name=op.f("pk_user_role")), schema=SCHEMA, ) @@ -420,11 +426,11 @@ def upgrade() -> None: sa.Column("valid_to", sa.DateTime(timezone=True), nullable=True), sa.Column("changed_by", sa.UUID(), nullable=True), sa.ForeignKeyConstraint( - ["changed_by"], ["dkb.app_user.id"], name=op.f("fk_company_group_history_changed_by_app_user") + ["changed_by"], [f"{SCHEMA}.app_user.id"], name=op.f("fk_company_group_history_changed_by_app_user") ), sa.ForeignKeyConstraint( ["company_group_id"], - ["dkb.company_group.id"], + [f"{SCHEMA}.company_group.id"], name=op.f("fk_company_group_history_company_group_id_company_group"), ), sa.PrimaryKeyConstraint("id", name=op.f("pk_company_group_history")), @@ -452,14 +458,18 @@ def upgrade() -> None: sa.Column("created_at", sa.DateTime(timezone=True), nullable=True), sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), sa.ForeignKeyConstraint( - ["company_group_id"], ["dkb.company_group.id"], name=op.f("fk_legal_entity_company_group_id_company_group") + ["company_group_id"], + [f"{SCHEMA}.company_group.id"], + name=op.f("fk_legal_entity_company_group_id_company_group"), ), sa.ForeignKeyConstraint( ["regional_office_id"], - ["dkb.regional_office.id"], + [f"{SCHEMA}.regional_office.id"], name=op.f("fk_legal_entity_regional_office_id_regional_office"), ), - sa.ForeignKeyConstraint(["updated_by"], ["dkb.app_user.id"], name=op.f("fk_legal_entity_updated_by_app_user")), + sa.ForeignKeyConstraint( + ["updated_by"], [f"{SCHEMA}.app_user.id"], name=op.f("fk_legal_entity_updated_by_app_user") + ), sa.PrimaryKeyConstraint("id", name=op.f("pk_legal_entity")), sa.UniqueConstraint("crm_id", name=op.f("uq_legal_entity_crm_id")), sa.UniqueConstraint("inn", name=op.f("uq_legal_entity_inn")), @@ -474,7 +484,9 @@ def upgrade() -> None: sa.Column("name", sa.String(), nullable=False), sa.Column("created_date", sa.Date(), nullable=True), sa.ForeignKeyConstraint( - ["company_group_id"], ["dkb.company_group.id"], name=op.f("fk_related_group_company_group_id_company_group") + ["company_group_id"], + [f"{SCHEMA}.company_group.id"], + name=op.f("fk_related_group_company_group_id_company_group"), ), sa.PrimaryKeyConstraint("id", name=op.f("pk_related_group")), sa.UniqueConstraint("code", name=op.f("uq_related_group_code")), @@ -490,10 +502,14 @@ def upgrade() -> None: sa.Column("reporting_date", sa.Date(), nullable=False), sa.Column("created_at", sa.DateTime(timezone=True), nullable=True), sa.ForeignKeyConstraint( - ["company_group_id"], ["dkb.company_group.id"], name=op.f("fk_consolidation_company_group_id_company_group") + ["company_group_id"], + [f"{SCHEMA}.company_group.id"], + name=op.f("fk_consolidation_company_group_id_company_group"), ), sa.ForeignKeyConstraint( - ["related_group_id"], ["dkb.related_group.id"], name=op.f("fk_consolidation_related_group_id_related_group") + ["related_group_id"], + [f"{SCHEMA}.related_group.id"], + name=op.f("fk_consolidation_related_group_id_related_group"), ), sa.PrimaryKeyConstraint("id", name=op.f("pk_consolidation")), schema=SCHEMA, @@ -524,17 +540,23 @@ def upgrade() -> None: sa.Column("archived_at", sa.DateTime(timezone=True), nullable=True), sa.Column("data_lake_ref", sa.String(), nullable=True), sa.ForeignKeyConstraint( - ["company_group_id"], ["dkb.company_group.id"], name=op.f("fk_ex_snapshot_company_group_id_company_group") + ["company_group_id"], + [f"{SCHEMA}.company_group.id"], + name=op.f("fk_ex_snapshot_company_group_id_company_group"), ), sa.ForeignKeyConstraint( - ["related_group_id"], ["dkb.related_group.id"], name=op.f("fk_ex_snapshot_related_group_id_related_group") + ["related_group_id"], + [f"{SCHEMA}.related_group.id"], + name=op.f("fk_ex_snapshot_related_group_id_related_group"), ), sa.ForeignKeyConstraint( ["superseded_by_snapshot_id"], - ["dkb.ex_snapshot.id"], + [f"{SCHEMA}.ex_snapshot.id"], name=op.f("fk_ex_snapshot_superseded_by_snapshot_id_ex_snapshot"), ), - sa.ForeignKeyConstraint(["written_by"], ["dkb.app_user.id"], name=op.f("fk_ex_snapshot_written_by_app_user")), + sa.ForeignKeyConstraint( + ["written_by"], [f"{SCHEMA}.app_user.id"], name=op.f("fk_ex_snapshot_written_by_app_user") + ), sa.PrimaryKeyConstraint("id", name=op.f("pk_ex_snapshot")), sa.UniqueConstraint("source_version_id", name=op.f("uq_ex_snapshot_source_version_id")), schema=SCHEMA, @@ -562,11 +584,11 @@ def upgrade() -> None: sa.Column("created_by", sa.UUID(), nullable=True), sa.Column("created_at", sa.DateTime(timezone=True), nullable=True), sa.ForeignKeyConstraint( - ["created_by"], ["dkb.app_user.id"], name=op.f("fk_legal_entity_alias_created_by_app_user") + ["created_by"], [f"{SCHEMA}.app_user.id"], name=op.f("fk_legal_entity_alias_created_by_app_user") ), sa.ForeignKeyConstraint( ["legal_entity_id"], - ["dkb.legal_entity.id"], + [f"{SCHEMA}.legal_entity.id"], name=op.f("fk_legal_entity_alias_legal_entity_id_legal_entity"), ), sa.PrimaryKeyConstraint("id", name=op.f("pk_legal_entity_alias")), @@ -603,11 +625,11 @@ def upgrade() -> None: sa.Column("valid_to", sa.DateTime(timezone=True), nullable=True), sa.Column("changed_by", sa.UUID(), nullable=True), sa.ForeignKeyConstraint( - ["changed_by"], ["dkb.app_user.id"], name=op.f("fk_legal_entity_history_changed_by_app_user") + ["changed_by"], [f"{SCHEMA}.app_user.id"], name=op.f("fk_legal_entity_history_changed_by_app_user") ), sa.ForeignKeyConstraint( ["legal_entity_id"], - ["dkb.legal_entity.id"], + [f"{SCHEMA}.legal_entity.id"], name=op.f("fk_legal_entity_history_legal_entity_id_legal_entity"), ), sa.PrimaryKeyConstraint("id", name=op.f("pk_legal_entity_history")), @@ -630,16 +652,16 @@ def upgrade() -> None: sa.Column("is_current", sa.Boolean(), nullable=False), sa.Column("created_by", sa.UUID(), nullable=True), sa.ForeignKeyConstraint( - ["created_by"], ["dkb.app_user.id"], name=op.f("fk_related_group_member_created_by_app_user") + ["created_by"], [f"{SCHEMA}.app_user.id"], name=op.f("fk_related_group_member_created_by_app_user") ), sa.ForeignKeyConstraint( ["legal_entity_id"], - ["dkb.legal_entity.id"], + [f"{SCHEMA}.legal_entity.id"], name=op.f("fk_related_group_member_legal_entity_id_legal_entity"), ), sa.ForeignKeyConstraint( ["related_group_id"], - ["dkb.related_group.id"], + [f"{SCHEMA}.related_group.id"], name=op.f("fk_related_group_member_related_group_id_related_group"), ), sa.PrimaryKeyConstraint("id", name=op.f("pk_related_group_member")), @@ -678,17 +700,19 @@ def upgrade() -> None: sa.Column("uploaded_at", sa.DateTime(timezone=True), nullable=True), sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), sa.ForeignKeyConstraint( - ["legal_entity_id"], ["dkb.legal_entity.id"], name=op.f("fk_stored_file_legal_entity_id_legal_entity") + ["legal_entity_id"], [f"{SCHEMA}.legal_entity.id"], name=op.f("fk_stored_file_legal_entity_id_legal_entity") ), sa.ForeignKeyConstraint( - ["s3_object_id"], ["dkb.s3_object.id"], name=op.f("fk_stored_file_s3_object_id_s3_object") + ["s3_object_id"], [f"{SCHEMA}.s3_object.id"], name=op.f("fk_stored_file_s3_object_id_s3_object") ), sa.ForeignKeyConstraint( ["superseded_by_file_id"], - ["dkb.stored_file.id"], + [f"{SCHEMA}.stored_file.id"], name=op.f("fk_stored_file_superseded_by_file_id_stored_file"), ), - sa.ForeignKeyConstraint(["uploaded_by"], ["dkb.app_user.id"], name=op.f("fk_stored_file_uploaded_by_app_user")), + sa.ForeignKeyConstraint( + ["uploaded_by"], [f"{SCHEMA}.app_user.id"], name=op.f("fk_stored_file_uploaded_by_app_user") + ), sa.PrimaryKeyConstraint("id", name=op.f("pk_stored_file")), schema=SCHEMA, ) @@ -726,34 +750,38 @@ def upgrade() -> None: sa.Column("updated_by", sa.UUID(), nullable=True), sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), sa.ForeignKeyConstraint( - ["approved_in_rf_by"], ["dkb.app_user.id"], name=op.f("fk_consolidation_version_approved_in_rf_by_app_user") + ["approved_in_rf_by"], + [f"{SCHEMA}.app_user.id"], + name=op.f("fk_consolidation_version_approved_in_rf_by_app_user"), ), sa.ForeignKeyConstraint( ["consolidation_id"], - ["dkb.consolidation.id"], + [f"{SCHEMA}.consolidation.id"], name=op.f("fk_consolidation_version_consolidation_id_consolidation"), ), sa.ForeignKeyConstraint( - ["created_by"], ["dkb.app_user.id"], name=op.f("fk_consolidation_version_created_by_app_user") + ["created_by"], [f"{SCHEMA}.app_user.id"], name=op.f("fk_consolidation_version_created_by_app_user") ), sa.ForeignKeyConstraint( - ["initiator_id"], ["dkb.app_user.id"], name=op.f("fk_consolidation_version_initiator_id_app_user") + ["initiator_id"], [f"{SCHEMA}.app_user.id"], name=op.f("fk_consolidation_version_initiator_id_app_user") ), sa.ForeignKeyConstraint( ["initiator_office_id"], - ["dkb.regional_office.id"], + [f"{SCHEMA}.regional_office.id"], name=op.f("fk_consolidation_version_initiator_office_id_regional_office"), ), sa.ForeignKeyConstraint( ["previous_version_id"], - ["dkb.consolidation_version.id"], + [f"{SCHEMA}.consolidation_version.id"], name="fk_consolidation_version_previous_version_id", ), sa.ForeignKeyConstraint( - ["updated_by"], ["dkb.app_user.id"], name=op.f("fk_consolidation_version_updated_by_app_user") + ["updated_by"], [f"{SCHEMA}.app_user.id"], name=op.f("fk_consolidation_version_updated_by_app_user") ), sa.ForeignKeyConstraint( - ["verified_in_go_by"], ["dkb.app_user.id"], name=op.f("fk_consolidation_version_verified_in_go_by_app_user") + ["verified_in_go_by"], + [f"{SCHEMA}.app_user.id"], + name=op.f("fk_consolidation_version_verified_in_go_by_app_user"), ), sa.PrimaryKeyConstraint("id", name=op.f("pk_consolidation_version")), schema=SCHEMA, @@ -784,12 +812,14 @@ def upgrade() -> None: sa.Column("confirmed_at", sa.DateTime(timezone=True), nullable=True), sa.Column("created_at", sa.DateTime(timezone=True), nullable=True), sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), - sa.ForeignKeyConstraint(["confirmed_by"], ["dkb.app_user.id"], name=op.f("fk_document_confirmed_by_app_user")), sa.ForeignKeyConstraint( - ["legal_entity_id"], ["dkb.legal_entity.id"], name=op.f("fk_document_legal_entity_id_legal_entity") + ["confirmed_by"], [f"{SCHEMA}.app_user.id"], name=op.f("fk_document_confirmed_by_app_user") ), sa.ForeignKeyConstraint( - ["stored_file_id"], ["dkb.stored_file.id"], name=op.f("fk_document_stored_file_id_stored_file") + ["legal_entity_id"], [f"{SCHEMA}.legal_entity.id"], name=op.f("fk_document_legal_entity_id_legal_entity") + ), + sa.ForeignKeyConstraint( + ["stored_file_id"], [f"{SCHEMA}.stored_file.id"], name=op.f("fk_document_stored_file_id_stored_file") ), sa.PrimaryKeyConstraint("id", name=op.f("pk_document")), schema=SCHEMA, @@ -816,11 +846,13 @@ def upgrade() -> None: sa.Column("inn", sa.String(), nullable=False), sa.Column("name", sa.String(), nullable=False), sa.ForeignKeyConstraint( - ["ex_snapshot_id"], ["dkb.ex_snapshot.id"], name=op.f("fk_ex_snapshot_member_ex_snapshot_id_ex_snapshot") + ["ex_snapshot_id"], + [f"{SCHEMA}.ex_snapshot.id"], + name=op.f("fk_ex_snapshot_member_ex_snapshot_id_ex_snapshot"), ), sa.ForeignKeyConstraint( ["legal_entity_id"], - ["dkb.legal_entity.id"], + [f"{SCHEMA}.legal_entity.id"], name=op.f("fk_ex_snapshot_member_legal_entity_id_legal_entity"), ), sa.PrimaryKeyConstraint("ex_snapshot_id", "legal_entity_id", name=op.f("pk_ex_snapshot_member")), @@ -856,10 +888,12 @@ def upgrade() -> None: sa.Column("fields_low_confidence", sa.Integer(), nullable=True), sa.Column("fields_unrecognized", sa.Integer(), nullable=True), sa.ForeignKeyConstraint( - ["file_id"], ["dkb.stored_file.id"], name=op.f("fk_recognition_job_file_id_stored_file") + ["file_id"], [f"{SCHEMA}.stored_file.id"], name=op.f("fk_recognition_job_file_id_stored_file") ), sa.ForeignKeyConstraint( - ["result_s3_object_id"], ["dkb.s3_object.id"], name=op.f("fk_recognition_job_result_s3_object_id_s3_object") + ["result_s3_object_id"], + [f"{SCHEMA}.s3_object.id"], + name=op.f("fk_recognition_job_result_s3_object_id_s3_object"), ), sa.PrimaryKeyConstraint("id", name=op.f("pk_recognition_job")), sa.UniqueConstraint("request_msg_id", name=op.f("uq_recognition_job_request_msg_id")), @@ -899,10 +933,10 @@ def upgrade() -> None: sa.Column("user_agent", sa.String(), nullable=True), sa.ForeignKeyConstraint( ["consolidation_version_id"], - ["dkb.consolidation_version.id"], + [f"{SCHEMA}.consolidation_version.id"], name=op.f("fk_audit_event_consolidation_version_id_consolidation_version"), ), - sa.ForeignKeyConstraint(["user_id"], ["dkb.app_user.id"], name=op.f("fk_audit_event_user_id_app_user")), + sa.ForeignKeyConstraint(["user_id"], [f"{SCHEMA}.app_user.id"], name=op.f("fk_audit_event_user_id_app_user")), sa.PrimaryKeyConstraint("id", name=op.f("pk_audit_event")), schema=SCHEMA, ) @@ -937,11 +971,11 @@ def upgrade() -> None: sa.Column("entered_at", sa.DateTime(timezone=True), nullable=True), sa.ForeignKeyConstraint( ["consolidation_version_id"], - ["dkb.consolidation_version.id"], + [f"{SCHEMA}.consolidation_version.id"], name="fk_client_kfo_line_consolidation_version_id", ), sa.ForeignKeyConstraint( - ["entered_by"], ["dkb.app_user.id"], name=op.f("fk_client_kfo_line_entered_by_app_user") + ["entered_by"], [f"{SCHEMA}.app_user.id"], name=op.f("fk_client_kfo_line_entered_by_app_user") ), sa.PrimaryKeyConstraint("id", name=op.f("pk_client_kfo_line")), schema=SCHEMA, @@ -960,11 +994,11 @@ def upgrade() -> None: sa.Column("document_id", sa.UUID(), nullable=False), sa.ForeignKeyConstraint( ["consolidation_version_id"], - ["dkb.consolidation_version.id"], + [f"{SCHEMA}.consolidation_version.id"], name="fk_consolidation_document_consolidation_version_id", ), sa.ForeignKeyConstraint( - ["document_id"], ["dkb.document.id"], name=op.f("fk_consolidation_document_document_id_document") + ["document_id"], [f"{SCHEMA}.document.id"], name=op.f("fk_consolidation_document_document_id_document") ), sa.PrimaryKeyConstraint("id", name=op.f("pk_consolidation_document")), schema=SCHEMA, @@ -987,14 +1021,14 @@ def upgrade() -> None: sa.Column("takeover_reason", sa.Text(), nullable=True), sa.ForeignKeyConstraint( ["consolidation_version_id"], - ["dkb.consolidation_version.id"], + [f"{SCHEMA}.consolidation_version.id"], name="fk_consolidation_lock_consolidation_version_id", ), sa.ForeignKeyConstraint( - ["locked_by"], ["dkb.app_user.id"], name=op.f("fk_consolidation_lock_locked_by_app_user") + ["locked_by"], [f"{SCHEMA}.app_user.id"], name=op.f("fk_consolidation_lock_locked_by_app_user") ), sa.ForeignKeyConstraint( - ["taken_over_from"], ["dkb.app_user.id"], name=op.f("fk_consolidation_lock_taken_over_from_app_user") + ["taken_over_from"], [f"{SCHEMA}.app_user.id"], name=op.f("fk_consolidation_lock_taken_over_from_app_user") ), sa.PrimaryKeyConstraint("id", name=op.f("pk_consolidation_lock")), schema=SCHEMA, @@ -1015,12 +1049,12 @@ def upgrade() -> None: sa.Column("is_head_entity", sa.Boolean(), server_default=sa.text("false"), nullable=False), sa.ForeignKeyConstraint( ["consolidation_version_id"], - ["dkb.consolidation_version.id"], + [f"{SCHEMA}.consolidation_version.id"], name="fk_consolidation_member_consolidation_version_id", ), sa.ForeignKeyConstraint( ["legal_entity_id"], - ["dkb.legal_entity.id"], + [f"{SCHEMA}.legal_entity.id"], name=op.f("fk_consolidation_member_legal_entity_id_legal_entity"), ), sa.PrimaryKeyConstraint("id", name=op.f("pk_consolidation_member")), @@ -1043,10 +1077,10 @@ def upgrade() -> None: sa.Column("changed_at", sa.DateTime(timezone=True), nullable=False), sa.Column("comment", sa.Text(), nullable=True), sa.ForeignKeyConstraint( - ["changed_by"], ["dkb.app_user.id"], name=op.f("fk_consolidation_status_history_changed_by_app_user") + ["changed_by"], [f"{SCHEMA}.app_user.id"], name=op.f("fk_consolidation_status_history_changed_by_app_user") ), sa.ForeignKeyConstraint( - ["version_id"], ["dkb.consolidation_version.id"], name="fk_consolidation_status_history_version_id" + ["version_id"], [f"{SCHEMA}.consolidation_version.id"], name="fk_consolidation_status_history_version_id" ), sa.PrimaryKeyConstraint("id", name=op.f("pk_consolidation_status_history")), schema=SCHEMA, @@ -1078,33 +1112,39 @@ def upgrade() -> None: sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), sa.ForeignKeyConstraint( ["consolidation_version_id"], - ["dkb.consolidation_version.id"], + [f"{SCHEMA}.consolidation_version.id"], name=op.f("fk_correction_consolidation_version_id_consolidation_version"), ), sa.ForeignKeyConstraint( - ["counterparty_id"], ["dkb.legal_entity.id"], name=op.f("fk_correction_counterparty_id_legal_entity") + ["counterparty_id"], [f"{SCHEMA}.legal_entity.id"], name=op.f("fk_correction_counterparty_id_legal_entity") + ), + sa.ForeignKeyConstraint( + ["created_by"], [f"{SCHEMA}.app_user.id"], name=op.f("fk_correction_created_by_app_user") ), - sa.ForeignKeyConstraint(["created_by"], ["dkb.app_user.id"], name=op.f("fk_correction_created_by_app_user")), sa.ForeignKeyConstraint( ["credit_account_id"], - ["dkb.chart_of_account.id"], + [f"{SCHEMA}.chart_of_account.id"], name=op.f("fk_correction_credit_account_id_chart_of_account"), ), sa.ForeignKeyConstraint( ["debit_account_id"], - ["dkb.chart_of_account.id"], + [f"{SCHEMA}.chart_of_account.id"], name=op.f("fk_correction_debit_account_id_chart_of_account"), ), sa.ForeignKeyConstraint( - ["legal_entity_id"], ["dkb.legal_entity.id"], name=op.f("fk_correction_legal_entity_id_legal_entity") + ["legal_entity_id"], [f"{SCHEMA}.legal_entity.id"], name=op.f("fk_correction_legal_entity_id_legal_entity") ), sa.ForeignKeyConstraint( - ["parent_correction_id"], ["dkb.correction.id"], name=op.f("fk_correction_parent_correction_id_correction") + ["parent_correction_id"], + [f"{SCHEMA}.correction.id"], + name=op.f("fk_correction_parent_correction_id_correction"), ), sa.ForeignKeyConstraint( - ["rule_id"], ["dkb.elimination_rule.id"], name=op.f("fk_correction_rule_id_elimination_rule") + ["rule_id"], [f"{SCHEMA}.elimination_rule.id"], name=op.f("fk_correction_rule_id_elimination_rule") + ), + sa.ForeignKeyConstraint( + ["updated_by"], [f"{SCHEMA}.app_user.id"], name=op.f("fk_correction_updated_by_app_user") ), - sa.ForeignKeyConstraint(["updated_by"], ["dkb.app_user.id"], name=op.f("fk_correction_updated_by_app_user")), sa.PrimaryKeyConstraint("id", name=op.f("pk_correction")), schema=SCHEMA, ) @@ -1132,13 +1172,17 @@ def upgrade() -> None: sa.Column("payload", postgresql.JSONB(astext_type=sa.Text()), nullable=True), sa.Column("payload_schema_version", sa.String(), nullable=True), sa.ForeignKeyConstraint( - ["document_id"], ["dkb.document.id"], name=op.f("fk_ex_snapshot_item_document_id_document") + ["document_id"], [f"{SCHEMA}.document.id"], name=op.f("fk_ex_snapshot_item_document_id_document") ), sa.ForeignKeyConstraint( - ["ex_snapshot_id"], ["dkb.ex_snapshot.id"], name=op.f("fk_ex_snapshot_item_ex_snapshot_id_ex_snapshot") + ["ex_snapshot_id"], + [f"{SCHEMA}.ex_snapshot.id"], + name=op.f("fk_ex_snapshot_item_ex_snapshot_id_ex_snapshot"), ), sa.ForeignKeyConstraint( - ["legal_entity_id"], ["dkb.legal_entity.id"], name=op.f("fk_ex_snapshot_item_legal_entity_id_legal_entity") + ["legal_entity_id"], + [f"{SCHEMA}.legal_entity.id"], + name=op.f("fk_ex_snapshot_item_legal_entity_id_legal_entity"), ), sa.PrimaryKeyConstraint("id", name=op.f("pk_ex_snapshot_item")), schema=SCHEMA, @@ -1187,19 +1231,19 @@ def upgrade() -> None: sa.Column("checks_accepted_comment", sa.Text(), nullable=True), sa.ForeignKeyConstraint( ["checks_accepted_by"], - ["dkb.app_user.id"], + [f"{SCHEMA}.app_user.id"], name=op.f("fk_financial_statement_meta_checks_accepted_by_app_user"), ), sa.ForeignKeyConstraint( - ["document_id"], ["dkb.document.id"], name=op.f("fk_financial_statement_meta_document_id_document") + ["document_id"], [f"{SCHEMA}.document.id"], name=op.f("fk_financial_statement_meta_document_id_document") ), sa.ForeignKeyConstraint( ["recognition_job_id"], - ["dkb.recognition_job.id"], + [f"{SCHEMA}.recognition_job.id"], name=op.f("fk_financial_statement_meta_recognition_job_id_recognition_job"), ), sa.ForeignKeyConstraint( - ["reviewed_by"], ["dkb.app_user.id"], name=op.f("fk_financial_statement_meta_reviewed_by_app_user") + ["reviewed_by"], [f"{SCHEMA}.app_user.id"], name=op.f("fk_financial_statement_meta_reviewed_by_app_user") ), sa.PrimaryKeyConstraint("id", name=op.f("pk_financial_statement_meta")), sa.UniqueConstraint("document_id", name=op.f("uq_financial_statement_meta_document_id")), @@ -1225,21 +1269,25 @@ def upgrade() -> None: sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), sa.ForeignKeyConstraint( ["consolidation_version_id"], - ["dkb.consolidation_version.id"], + [f"{SCHEMA}.consolidation_version.id"], name=op.f("fk_goodwill_consolidation_version_id_consolidation_version"), ), - sa.ForeignKeyConstraint(["created_by"], ["dkb.app_user.id"], name=op.f("fk_goodwill_created_by_app_user")), + sa.ForeignKeyConstraint( + ["created_by"], [f"{SCHEMA}.app_user.id"], name=op.f("fk_goodwill_created_by_app_user") + ), sa.ForeignKeyConstraint( ["head_legal_entity_id"], - ["dkb.legal_entity.id"], + [f"{SCHEMA}.legal_entity.id"], name=op.f("fk_goodwill_head_legal_entity_id_legal_entity"), ), sa.ForeignKeyConstraint( ["subsidiary_legal_entity_id"], - ["dkb.legal_entity.id"], + [f"{SCHEMA}.legal_entity.id"], name=op.f("fk_goodwill_subsidiary_legal_entity_id_legal_entity"), ), - sa.ForeignKeyConstraint(["updated_by"], ["dkb.app_user.id"], name=op.f("fk_goodwill_updated_by_app_user")), + sa.ForeignKeyConstraint( + ["updated_by"], [f"{SCHEMA}.app_user.id"], name=op.f("fk_goodwill_updated_by_app_user") + ), sa.PrimaryKeyConstraint("id", name=op.f("pk_goodwill")), schema=SCHEMA, ) @@ -1269,7 +1317,7 @@ def upgrade() -> None: sa.Column("has_deviations", sa.Boolean(), nullable=True), sa.ForeignKeyConstraint( ["consolidation_version_id"], - ["dkb.consolidation_version.id"], + [f"{SCHEMA}.consolidation_version.id"], name=op.f("fk_kfo_result_consolidation_version_id_consolidation_version"), ), sa.PrimaryKeyConstraint("id", name=op.f("pk_kfo_result")), @@ -1307,24 +1355,28 @@ def upgrade() -> None: sa.Column("modified_at", sa.DateTime(timezone=True), nullable=True), sa.Column("is_excisable", sa.Boolean(), nullable=True), sa.ForeignKeyConstraint( - ["account_id"], ["dkb.chart_of_account.id"], name=op.f("fk_osv_entry_account_id_chart_of_account") + ["account_id"], [f"{SCHEMA}.chart_of_account.id"], name=op.f("fk_osv_entry_account_id_chart_of_account") ), sa.ForeignKeyConstraint( - ["counterparty_id"], ["dkb.legal_entity.id"], name=op.f("fk_osv_entry_counterparty_id_legal_entity") + ["counterparty_id"], [f"{SCHEMA}.legal_entity.id"], name=op.f("fk_osv_entry_counterparty_id_legal_entity") + ), + sa.ForeignKeyConstraint( + ["document_id"], [f"{SCHEMA}.document.id"], name=op.f("fk_osv_entry_document_id_document") + ), + sa.ForeignKeyConstraint( + ["modified_by"], [f"{SCHEMA}.app_user.id"], name=op.f("fk_osv_entry_modified_by_app_user") ), - sa.ForeignKeyConstraint(["document_id"], ["dkb.document.id"], name=op.f("fk_osv_entry_document_id_document")), - sa.ForeignKeyConstraint(["modified_by"], ["dkb.app_user.id"], name=op.f("fk_osv_entry_modified_by_app_user")), sa.ForeignKeyConstraint( ["owner_legal_entity_id"], - ["dkb.legal_entity.id"], + [f"{SCHEMA}.legal_entity.id"], name=op.f("fk_osv_entry_owner_legal_entity_id_legal_entity"), ), sa.ForeignKeyConstraint( - ["parent_entry_id"], ["dkb.osv_entry.id"], name=op.f("fk_osv_entry_parent_entry_id_osv_entry") + ["parent_entry_id"], [f"{SCHEMA}.osv_entry.id"], name=op.f("fk_osv_entry_parent_entry_id_osv_entry") ), sa.ForeignKeyConstraint( ["recognition_job_id"], - ["dkb.recognition_job.id"], + [f"{SCHEMA}.recognition_job.id"], name=op.f("fk_osv_entry_recognition_job_id_recognition_job"), ), sa.PrimaryKeyConstraint("id", name=op.f("pk_osv_entry")), @@ -1350,7 +1402,7 @@ def upgrade() -> None: sa.Column("is_current", sa.Boolean(), nullable=True), sa.ForeignKeyConstraint( ["meta_id"], - ["dkb.financial_statement_meta.id"], + [f"{SCHEMA}.financial_statement_meta.id"], name=op.f("fk_financial_statement_column_meta_id_financial_statement_meta"), ), sa.PrimaryKeyConstraint("id", name=op.f("pk_financial_statement_column")), @@ -1377,7 +1429,7 @@ def upgrade() -> None: sa.Column("line_name_modified", sa.Boolean(), nullable=True), sa.ForeignKeyConstraint( ["meta_id"], - ["dkb.financial_statement_meta.id"], + [f"{SCHEMA}.financial_statement_meta.id"], name=op.f("fk_financial_statement_line_meta_id_financial_statement_meta"), ), sa.PrimaryKeyConstraint("id", name=op.f("pk_financial_statement_line")), @@ -1408,7 +1460,7 @@ def upgrade() -> None: sa.Column("comment", sa.Text(), nullable=True), sa.Column("ordinal", sa.Integer(), nullable=True), sa.ForeignKeyConstraint( - ["kfo_result_id"], ["dkb.kfo_result.id"], name=op.f("fk_kfo_result_line_kfo_result_id_kfo_result") + ["kfo_result_id"], [f"{SCHEMA}.kfo_result.id"], name=op.f("fk_kfo_result_line_kfo_result_id_kfo_result") ), sa.PrimaryKeyConstraint("id", name=op.f("pk_kfo_result_line")), schema=SCHEMA, @@ -1435,16 +1487,16 @@ def upgrade() -> None: sa.Column("modified_at", sa.DateTime(timezone=True), nullable=True), sa.ForeignKeyConstraint( ["column_id"], - ["dkb.financial_statement_column.id"], + [f"{SCHEMA}.financial_statement_column.id"], name="fk_financial_statement_value_column_id_fs_column", ), sa.ForeignKeyConstraint( ["line_id"], - ["dkb.financial_statement_line.id"], + [f"{SCHEMA}.financial_statement_line.id"], name=op.f("fk_financial_statement_value_line_id_financial_statement_line"), ), sa.ForeignKeyConstraint( - ["modified_by"], ["dkb.app_user.id"], name=op.f("fk_financial_statement_value_modified_by_app_user") + ["modified_by"], [f"{SCHEMA}.app_user.id"], name=op.f("fk_financial_statement_value_modified_by_app_user") ), sa.PrimaryKeyConstraint("id", name=op.f("pk_financial_statement_value")), schema=SCHEMA, @@ -1464,12 +1516,12 @@ def upgrade() -> None: sa.Column("share_percent", sa.Numeric(precision=7, scale=4), nullable=True), sa.ForeignKeyConstraint( ["kfo_result_line_id"], - ["dkb.kfo_result_line.id"], + [f"{SCHEMA}.kfo_result_line.id"], name=op.f("fk_kfo_result_line_company_kfo_result_line_id_kfo_result_line"), ), sa.ForeignKeyConstraint( ["legal_entity_id"], - ["dkb.legal_entity.id"], + [f"{SCHEMA}.legal_entity.id"], name=op.f("fk_kfo_result_line_company_legal_entity_id_legal_entity"), ), sa.PrimaryKeyConstraint("kfo_result_line_id", "legal_entity_id", name=op.f("pk_kfo_result_line_company")), @@ -1478,7 +1530,7 @@ def upgrade() -> None: def downgrade() -> None: - """Полный откат: FK цикла -> таблицы в обратном порядке (с индексами) -> enum-типы. Схема dkb остаётся.""" + """Полный откат: FK цикла -> таблицы в обратном порядке (с индексами) -> enum-типы. Схема БД остаётся.""" op.drop_constraint(FK_CYCLE, "stored_file", type_="foreignkey", schema=SCHEMA) for table in reversed(TABLES): op.drop_table(table, schema=SCHEMA) diff --git a/api/alembic/versions/0002_dbml_v5_14.py b/api/alembic/versions/0002_dbml_v5_14.py index 1abd967..c99adc2 100644 --- a/api/alembic/versions/0002_dbml_v5_14.py +++ b/api/alembic/versions/0002_dbml_v5_14.py @@ -20,18 +20,18 @@ from sqlalchemy.dialects import postgresql from sqlalchemy.schema import CreateSequence, DropSequence from alembic import op +from src.models.base import SCHEMA revision: str = "0002_dbml_v5_14" down_revision: str | Sequence[str] | None = "0001_dbml_v5_13" branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None -SCHEMA = "dkb" TABLE = "related_group_member" OLD_INDEX = "uq_related_group_member_entity_current" NEW_INDEX = "uq_related_group_member_entity_kind_current" GROUP_ENTITY_INDEX = "uq_related_group_member_group_entity_current" -#: Временная копия строк на время пересоздания таблицы (в схеме dkb: прав на TEMP у роли миграций может не быть). +#: Временная копия строк на время пересоздания таблицы (в схеме БД: прав на TEMP у роли миграций может не быть). COPY_TABLE = "related_group_member_v5_13" SEQUENCES = ("company_group_code_seq", "related_group_code_seq") AUDIT_ACTION_V5_13 = ( @@ -56,16 +56,16 @@ def _create_member_table() -> None: sa.Column("is_current", sa.Boolean(), nullable=False), sa.Column("created_by", sa.UUID(), nullable=True), sa.ForeignKeyConstraint( - ["created_by"], ["dkb.app_user.id"], name=op.f("fk_related_group_member_created_by_app_user") + ["created_by"], [f"{SCHEMA}.app_user.id"], name=op.f("fk_related_group_member_created_by_app_user") ), sa.ForeignKeyConstraint( ["legal_entity_id"], - ["dkb.legal_entity.id"], + [f"{SCHEMA}.legal_entity.id"], name=op.f("fk_related_group_member_legal_entity_id_legal_entity"), ), sa.ForeignKeyConstraint( ["related_group_id"], - ["dkb.related_group.id"], + [f"{SCHEMA}.related_group.id"], name=op.f("fk_related_group_member_related_group_id_related_group"), ), sa.PrimaryKeyConstraint("id", name=op.f("pk_related_group_member")), diff --git a/api/alembic/versions/0003_dbml_v5_15.py b/api/alembic/versions/0003_dbml_v5_15.py index ef7b3c0..f2f2016 100644 --- a/api/alembic/versions/0003_dbml_v5_15.py +++ b/api/alembic/versions/0003_dbml_v5_15.py @@ -17,13 +17,13 @@ from collections.abc import Sequence from sqlalchemy.dialects import postgresql from alembic import op +from src.models.base import SCHEMA revision: str = "0003_dbml_v5_15" down_revision: str | Sequence[str] | None = "0002_dbml_v5_14" branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None -SCHEMA = "dkb" OSV_DATA_TYPE_V5_14 = ("BU", "CURRENCY", "QUANTITY", "SUM") # v5.15 п.4: (имя, таблица, колонка) — обычные btree-индексы, имена как в ORM FK_LOOKUP_INDEXES = ( diff --git a/api/alembic/versions/0004_dbml_v5_16.py b/api/alembic/versions/0004_dbml_v5_16.py index 1536b9c..2aad8c5 100644 --- a/api/alembic/versions/0004_dbml_v5_16.py +++ b/api/alembic/versions/0004_dbml_v5_16.py @@ -13,13 +13,13 @@ Create Date: 2026-10-06 from collections.abc import Sequence from alembic import op +from src.models.base import SCHEMA revision: str = "0004_dbml_v5_16" down_revision: str | Sequence[str] | None = "0003_dbml_v5_15" branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None -SCHEMA = "dkb" # порядок значений — как в DBML v5.16 (сверка enum'ов сравнивает упорядоченные списки) MATCH_KINDS = ("INN", "NAME", "ALIAS", "MANUAL", "PROPAGATED") diff --git a/api/alembic/versions/0005_dbml_v5_17.py b/api/alembic/versions/0005_dbml_v5_17.py index 5a04c80..5d3c19e 100644 --- a/api/alembic/versions/0005_dbml_v5_17.py +++ b/api/alembic/versions/0005_dbml_v5_17.py @@ -12,13 +12,13 @@ Create Date: 2026-10-07 from collections.abc import Sequence from alembic import op +from src.models.base import SCHEMA revision: str = "0005_dbml_v5_17" down_revision: str | Sequence[str] | None = "0004_dbml_v5_16" branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None -SCHEMA = "dkb" TABLES = ("legal_entity", "legal_entity_history") diff --git a/api/scripts/check_dbml_vs_orm.py b/api/scripts/check_dbml_vs_orm.py index 1ef8150..28ba2c5 100644 --- a/api/scripts/check_dbml_vs_orm.py +++ b/api/scripts/check_dbml_vs_orm.py @@ -33,6 +33,9 @@ if str(_API_DIR) not in sys.path: sys.path.insert(0, str(_API_DIR)) if "ENV_FILE" not in os.environ and not Path(".env").is_file(): os.environ["ENV_FILE"] = str(_API_DIR / ".env.example") +# Сверка идёт в эталонной схеме `dkb`: имя схемы в БД конфигурируемо (`DB_SCHEMA`), на совпадение имён, типов и +# ограничений с DBML оно не влияет. Обычная переменная окружения приоритетнее `OPENBAO__APP__DB_SCHEMA` и `.env`. +os.environ["DB_SCHEMA"] = "dkb" from sqlalchemy import MetaData # noqa: E402 diff --git a/api/scripts/dbml_parity.py b/api/scripts/dbml_parity.py index 88f5755..c2dc8fb 100644 --- a/api/scripts/dbml_parity.py +++ b/api/scripts/dbml_parity.py @@ -55,6 +55,7 @@ from sqlalchemy.dialects.postgresql import JSONB, UUID from sqlalchemy.sql.elements import TextClause from sqlalchemy.sql.functions import FunctionElement +#: Эталонная схема модели. Имя схемы в БД конфигурируемо (`DB_SCHEMA`), CLI сверяет ORM, загруженную с этим значением. SCHEMA = "dkb" DBML_FILE_NAME = "data-model-v5.17.dbml" ORM = "ORM" diff --git a/api/scripts/seed.py b/api/scripts/seed.py index 83104e6..571459d 100644 --- a/api/scripts/seed.py +++ b/api/scripts/seed.py @@ -102,7 +102,7 @@ async def _run_cli(seed_dir: Path, *, dev: bool, dev_groups: bool) -> SeedReport from src.core.config import settings # noqa: PLC0415 engine = create_async_engine( - settings.OPENBAO.DATABASE.DATABASE_URL, connect_args={"server_settings": {"search_path": "dkb"}} + settings.OPENBAO.DATABASE.DATABASE_URL, connect_args={"server_settings": {"search_path": settings.DB_SCHEMA}} ) try: async with async_sessionmaker(engine, expire_on_commit=False)() as session, session.begin(): diff --git a/api/src/core/config.py b/api/src/core/config.py index 0136606..6c97316 100644 --- a/api/src/core/config.py +++ b/api/src/core/config.py @@ -1,5 +1,6 @@ import json import os +import re from typing import Annotated, Any from pydantic import NonNegativeInt, PositiveInt, field_validator, model_validator @@ -7,6 +8,8 @@ from pydantic_settings import BaseSettings, NoDecode, PydanticBaseSettingsSource from src.core.settings_sources import OpenBaoAppSettingsSource +DB_SCHEMA_RE = re.compile(r"[a-z_][a-z0-9_]{0,62}") + class DBSettings(BaseSettings): DATABASE_URL: str @@ -69,6 +72,9 @@ class Settings(BaseSettings): DEBUG: bool = False server: ServerSettings = ServerSettings() ROOT_PATH: str = "/" + # Схема PostgreSQL модели данных (MetaData ORM, search_path движка, Alembic). В контуре — OPENBAO__APP__DB_SCHEMA. + # Имя подставляется в DDL, поэтому валидируется как простой идентификатор (см. `_check_db_schema`) + DB_SCHEMA: str = "dkb" VERSION: str = "1.0.0" # CORS при DEBUG=false: разрешённые источники (JSON-список или строка через запятую); пусто — кросс-доменные # запросы запрещены. При DEBUG=true — всегда `*` (OQ-65) @@ -124,6 +130,17 @@ class Settings(BaseSettings): file_secret_settings, ) + @field_validator("DB_SCHEMA") + @classmethod + def _check_db_schema(cls, value: str) -> str: + """Имя схемы — строчный SQL-идентификатор до 63 символов, не `pg_*` (зарезервировано PostgreSQL): безопасно + для подстановки в DDL и `search_path` без кавычек.""" + if not DB_SCHEMA_RE.fullmatch(value) or value.startswith("pg_"): + raise ValueError( + f"DB_SCHEMA={value!r}: нужен идентификатор [a-z_][a-z0-9_]{{0,62}}, не начинающийся с 'pg_'" + ) + return value + @field_validator("CORS_ORIGINS", mode="before") @classmethod def _split_origins(cls, value: Any) -> Any: diff --git a/api/src/db/db.py b/api/src/db/db.py index 0eba4f1..8655878 100644 --- a/api/src/db/db.py +++ b/api/src/db/db.py @@ -5,9 +5,10 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_asyn # from fastapi_users.db import SQLAlchemyUserDatabase, SQLAlchemyBaseUserTableUUID from src.core.config import settings +from src.models.base import SCHEMA engine = create_async_engine( - settings.OPENBAO.DATABASE.DATABASE_URL, connect_args={"server_settings": {"search_path": "dkb"}} + settings.OPENBAO.DATABASE.DATABASE_URL, connect_args={"server_settings": {"search_path": SCHEMA}} ) async_session_maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) diff --git a/api/src/models/base.py b/api/src/models/base.py index 5840c71..762e0f9 100644 --- a/api/src/models/base.py +++ b/api/src/models/base.py @@ -1,4 +1,4 @@ -"""Базовый класс ORM: схема `dkb`, детерминированные имена ограничений и первичный ключ uuid.""" +"""Базовый класс ORM: схема из `settings.DB_SCHEMA` (по умолчанию `dkb`), имена ограничений и первичный ключ uuid.""" import uuid @@ -6,8 +6,11 @@ from sqlalchemy import MetaData from sqlalchemy.dialects.postgresql import JSONB, UUID from sqlalchemy.orm import DeclarativeBase, Mapped, declarative_mixin, mapped_column -#: Схема PostgreSQL, в которой живёт вся модель данных (DBML v5.17). -SCHEMA = "dkb" +from src.core.config import settings + +#: Схема PostgreSQL, в которой живёт вся модель данных (DBML v5.17); единственный источник — `settings.DB_SCHEMA`. +#: Читается при импорте: MetaData, enum и sequence привязываются к схеме сразу (см. R-1 PR #38). +SCHEMA = settings.DB_SCHEMA #: Стандартная схема имён SQLAlchemy. Нужна, чтобы ORM, миграция 0001 и `alembic autogenerate` давали одни и те же #: имена ограничений и `downgrade` был детерминирован. Для составных/partial-индексов, где конвенция даёт diff --git a/api/src/models/enums.py b/api/src/models/enums.py index a7d4521..0c48ee0 100644 --- a/api/src/models/enums.py +++ b/api/src/models/enums.py @@ -342,7 +342,7 @@ ENUMS: dict[str, type[enum.Enum]] = { def pg_enum(name: str) -> SAEnum: - """Тип колонки для enum DBML `name`: нативный PG-enum `dkb.`, значения — `.value` членов. + """Тип колонки для enum DBML `name`: нативный PG-enum `<схема>.`, значения — `.value` членов. Создание типа — поведение SQLAlchemy по умолчанию (`create_type=True`, `checkfirst`): `Base.metadata.create_all` создаёт типы сам; миграция 0001 создаёт их явно. diff --git a/api/tests/conftest.py b/api/tests/conftest.py index 76c72d9..b6b49a9 100644 --- a/api/tests/conftest.py +++ b/api/tests/conftest.py @@ -40,6 +40,7 @@ from src.core.config import get_settings from src.core.exception_handlers import register_exception_handlers from src.core.storage import ObjectNotFoundError from src.db.db import get_async_session, session_transaction +from src.models.base import SCHEMA from src.services.session_audit import SessionAudit from tests.fixtures_helpers import fixtures_skip_reason, resolve_fixtures_dir from tests.pg_helpers import ( # scratch_db_url — реэкспорт фикстуры, чтобы pytest её нашёл @@ -96,8 +97,8 @@ async def migrated_db_url() -> AsyncIterator[str]: @pytest_asyncio.fixture(scope="session") async def db_engine(migrated_db_url: str) -> AsyncIterator[AsyncEngine]: - """Async-движок на мигрированную БД сессии; `search_path = dkb`.""" - engine = create_async_engine(migrated_db_url, connect_args={"server_settings": {"search_path": "dkb"}}) + """Async-движок на мигрированную БД сессии; `search_path` = схема из настроек.""" + engine = create_async_engine(migrated_db_url, connect_args={"server_settings": {"search_path": SCHEMA}}) try: yield engine finally: @@ -236,7 +237,7 @@ async def committed_sessionmaker() -> AsyncIterator[async_sessionmaker[AsyncSess """Отдельная временная БД: `alembic upgrade head` и seed (dev) ЗАФИКСИРОВАНЫ; удаляется после теста.""" name = f"armcore_import_{uuid.uuid4().hex[:12]}" url = (await create_scratch_db(name)).render_as_string(hide_password=False) - engine = create_async_engine(url, connect_args={"server_settings": {"search_path": "dkb"}}) + engine = create_async_engine(url, connect_args={"server_settings": {"search_path": SCHEMA}}) try: await alembic_async(url, "upgrade", "head") maker = async_sessionmaker(engine, expire_on_commit=False) diff --git a/api/tests/pg_helpers.py b/api/tests/pg_helpers.py index b7c3c5c..7b7760f 100644 --- a/api/tests/pg_helpers.py +++ b/api/tests/pg_helpers.py @@ -7,10 +7,12 @@ """ import asyncio +import os import subprocess import sys import uuid from collections.abc import AsyncIterator +from functools import partial from pathlib import Path import asyncpg @@ -40,9 +42,10 @@ def _asyncpg_dsn(url: URL) -> str: return url.set(drivername="postgresql").render_as_string(hide_password=False) -def run_alembic(url: str, *args: str) -> str: +def run_alembic(url: str, *args: str, env: dict[str, str] | None = None) -> str: """`alembic -x dburl= ` из каталога api/ отдельным процессом; вернуть stdout+stderr. + `env` — дополнительные переменные окружения процесса (например `DB_SCHEMA`). `env.py` вызывает `asyncio.run(...)` — внутри event loop pytest-asyncio это невозможно. При ошибке — `AssertionError` с выводом процесса (`ENV_FILE`/`APP_ENV` наследуются из окружения pytest-env). """ @@ -52,15 +55,16 @@ def run_alembic(url: str, *args: str) -> str: capture_output=True, text=True, check=False, + env={**os.environ, **(env or {})}, ) output = result.stdout + result.stderr assert result.returncode == 0, f"alembic {' '.join(args)} завершился с кодом {result.returncode}:\n{output}" return output -async def alembic_async(url: str, *args: str) -> str: +async def alembic_async(url: str, *args: str, env: dict[str, str] | None = None) -> str: """`run_alembic` в потоке, чтобы не блокировать event loop.""" - return await asyncio.to_thread(run_alembic, url, *args) + return await asyncio.to_thread(partial(run_alembic, url, *args, env=env)) async def create_scratch_db(name: str) -> URL: diff --git a/api/tests/test_config_openbao_app.py b/api/tests/test_config_openbao_app.py index aa1832c..fa879f0 100644 --- a/api/tests/test_config_openbao_app.py +++ b/api/tests/test_config_openbao_app.py @@ -22,7 +22,15 @@ REQUIRED_ENV = { # Плоские настройки, которые тесты ниже задают/проверяют POLL = 7 PORT = 9000 -FLAT_KEYS = {"DEBUG", "NATS_ENABLED", "AUTH_DEV_ISSUER", "FORWARDED_ALLOW_IPS", "CORS_ORIGINS", "OUTBOX_POLL_SECONDS"} +FLAT_KEYS = { + "DEBUG", + "NATS_ENABLED", + "AUTH_DEV_ISSUER", + "FORWARDED_ALLOW_IPS", + "CORS_ORIGINS", + "OUTBOX_POLL_SECONDS", + "DB_SCHEMA", +} @pytest.fixture @@ -50,6 +58,21 @@ def test_app_value_applied(env: pytest.MonkeyPatch) -> None: assert s.OPENBAO.NATS.NATS_URL == "nats://localhost:4222" +def test_db_schema_default_and_app(env: pytest.MonkeyPatch) -> None: + """`DB_SCHEMA`: по умолчанию `dkb`; в контуре задаётся как `OPENBAO__APP__DB_SCHEMA`.""" + assert _settings().DB_SCHEMA == "dkb" + env.setenv("OPENBAO__APP__DB_SCHEMA", "dkb_alt") + assert _settings().DB_SCHEMA == "dkb_alt" + + +@pytest.mark.parametrize("bad", ["", "Dkb", "1dkb", "dkb-alt", 'dkb"; DROP SCHEMA x; --', "pg_dkb", "a" * 64, "схема"]) +def test_db_schema_rejects_unsafe_identifier(env: pytest.MonkeyPatch, bad: str) -> None: + """Имя подставляется в DDL: не идентификатор `[a-z_][a-z0-9_]{0,62}` или `pg_*` отвергается при старте.""" + env.setenv("DB_SCHEMA", bad) + with pytest.raises(ValidationError): + _settings() + + def test_default_without_app(env: pytest.MonkeyPatch) -> None: assert _settings().NATS_ENABLED is False diff --git a/api/tests/test_db_schema.py b/api/tests/test_db_schema.py new file mode 100644 index 0000000..709d36d --- /dev/null +++ b/api/tests/test_db_schema.py @@ -0,0 +1,96 @@ +"""Схема БД конфигурируема (`DB_SCHEMA`, issue #38): миграции и приложение работают в схеме с нестандартным именем. + +`SCHEMA` читается при импорте моделей, поэтому и Alembic, и «приложение» запускаются отдельными процессами с +`DB_SCHEMA=dkb_alt` в окружении (обычная переменная приоритетнее `.env.test`). +""" + +import os +import subprocess +import sys +from typing import Any + +import pytest +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncConnection, create_async_engine + +from tests.pg_helpers import API_DIR, alembic_async +from tests.test_models_part_07_09 import ALL_TABLES, EXPECTED_ENUM_COUNT + +ALT = "dkb_alt" +ALT_ENV = {"DB_SCHEMA": ALT} + +#: «Приложение» в схеме ALT: движок и сессия из `src.db.db` (search_path из настроек), запрос ORM без указания схемы. +APP_PROBE = """ +import asyncio +from sqlalchemy import func, select, text +from src.db.db import session_transaction +from src.models import Base +from src.models.access import AppUser + +async def main() -> None: + async with session_transaction() as session: + assert (await session.execute(text("SHOW search_path"))).scalar() == "dkb_alt" + assert (await session.execute(select(func.count()).select_from(AppUser))).scalar() == 0 + assert Base.metadata.schema == "dkb_alt" + print("PROBE_OK") + +asyncio.run(main()) +""" + + +async def _scalar(conn: AsyncConnection, sql: str, **params: str) -> Any: + return (await conn.execute(text(sql), params)).scalar() + + +@pytest.mark.integration +async def test_migrations_and_app_work_in_custom_schema(scratch_db_url: str) -> None: + """upgrade head -> приложение читает таблицу через сессию -> downgrade base; схема `dkb` не создаётся.""" + engine = create_async_engine(scratch_db_url) + try: + await alembic_async(scratch_db_url, "upgrade", "head", env=ALT_ENV) + async with engine.connect() as conn: + tables = set( + ( + await conn.execute( + text("SELECT table_name FROM information_schema.tables WHERE table_schema = :s"), {"s": ALT} + ) + ).scalars() + ) + assert tables == ALL_TABLES | {"alembic_version"} + namespaces = set((await conn.execute(text("SELECT nspname FROM pg_namespace"))).scalars()) + assert "dkb" not in namespaces + enums = await _scalar( + conn, + "SELECT count(*) FROM pg_type WHERE typnamespace = CAST(:s AS regnamespace) AND typtype = 'e'", + s=ALT, + ) + assert enums == EXPECTED_ENUM_COUNT + # все внешние ключи ведут в таблицы схемы ALT (строковые FK миграций тоже берут схему из настроек) + foreign_elsewhere = await _scalar( + conn, + "SELECT count(*) FROM pg_constraint WHERE contype = 'f' AND confrelid::regclass::text NOT LIKE :p", + p=f"{ALT}.%", + ) + assert foreign_elsewhere == 0 + + probe = subprocess.run( + [sys.executable, "-c", APP_PROBE], + cwd=API_DIR, + capture_output=True, + text=True, + check=False, + env={**os.environ, **ALT_ENV, "OPENBAO__DATABASE__DATABASE_URL": scratch_db_url}, + ) + assert probe.returncode == 0 and "PROBE_OK" in probe.stdout, probe.stdout + probe.stderr + + await alembic_async(scratch_db_url, "downgrade", "base", env=ALT_ENV) + async with engine.connect() as conn: + left = await _scalar( + conn, + "SELECT count(*) FROM pg_class WHERE relnamespace = CAST(:s AS regnamespace) " + "AND relname NOT IN ('alembic_version', 'alembic_version_pkc')", + s=ALT, + ) + assert left == 0 + finally: + await engine.dispose() -- 2.47.2