fix(sql): EMISS PK includes period_type so yearly+Q1 coexist (#1606 follow-up)
Some checks failed
CI / backend-tests (push) Has been cancelled
CI / frontend-tests (push) Has been cancelled
CI / openapi-codegen-check (push) Has been cancelled
CI / changes (push) Has been cancelled
CI / changes (pull_request) Successful in 6s
CI / backend-tests (pull_request) Has been cancelled
CI / frontend-tests (pull_request) Has been cancelled
CI / openapi-codegen-check (pull_request) Has been cancelled

Adds period_type TEXT NOT NULL DEFAULT 'unknown' to macro_indicator and
widens the PRIMARY KEY to (indicator_type, region, obs_date, period_type).

Before: yearly aggregate ('год'→obs_date YYYY-01-01) and Q1 ('I квартал'→
same date) shared the same PK slot → ON CONFLICT DO UPDATE made them
overwrite each other despite the in-memory dedup fix in #1687.

After: each EmissRow carries period_type from _emiss_period_granularity
('year'/'quarter'/'month'); DB ON CONFLICT targets include it, so
yearly+Q1 rows genuinely coexist.

Non-EMISS sources (CBR, rosstat open-data, domrf) use period_type='unknown'
(literal in SQL) — they are disambiguated by obs_date already, so the
wider PK is backward-compatible. All ON CONFLICT clauses in
cbr_macro_sync.py and rosstat_macro_sync.py updated accordingly.

Migration: 163_emiss_pk_period_type.sql (idempotent BEGIN/COMMIT).
Tests: 49 passed (emiss + rosstat suite), ruff clean, py_compile OK.
This commit is contained in:
bot-backend 2026-06-17 21:27:34 +03:00
parent f55e83a150
commit 5170107405
7 changed files with 191 additions and 46 deletions

View file

@ -667,7 +667,14 @@ class EmissRow:
"""Одна готовая к upsert строка macro_indicator из ЕМИСС (source='emiss').
Отдельно от ``MacroRow`` (open-data, source='rosstat', yearly): ЕМИСС-ряды несут
свою frequency (quarterly/monthly) и source контракт upsert'а у них иной.
свою frequency (quarterly/monthly), source и period_type контракт upsert'а у
них иной.
period_type: гранулярность под-периода ('year' | 'quarter' | 'month' | 'unknown').
Берётся из _emiss_period_granularity(PERIOD). Необходима как часть PK
macro_indicator (migration 163), чтобы годовой агрегат ('год' 'year') и
Q1 ('I квартал' 'quarter') за один год не перезаписывали друг друга при
ON CONFLICT DO UPDATE (оба дают obs_date=YYYY-01-01) (#1606).
"""
indicator_type: str
@ -677,6 +684,7 @@ class EmissRow:
unit: str
frequency: str
comment: str
period_type: str = "unknown"
def _emiss_period_to_month(period: str) -> int | None:
@ -817,6 +825,7 @@ def parse_emiss_sdmx(raw: bytes | str, spec: EmissIndicatorSpec) -> list[EmissRo
unit=spec.unit,
frequency=spec.frequency,
comment=spec.comment,
period_type=granularity,
)
return [by_key[k] for k in sorted(by_key, key=lambda k: (k[0], k[1]))]

View file

@ -41,19 +41,21 @@ from app.workers.celery_app import celery_app
logger = logging.getLogger(__name__)
# psycopg v3: CAST(:x AS type) — НИКОГДА :x::type (SQLAlchemy+psycopg3 роняет
# синтаксис на ::). Контракт колонок совпадает с macro_indicator (migration 123):
# (indicator_type, region, obs_date, value, source, frequency, unit, comment,
# updated_at) PK (indicator_type, region, obs_date).
# синтаксис на ::). Контракт колонок совпадает с macro_indicator (migration 163):
# (indicator_type, region, obs_date, period_type, value, source, frequency, unit,
# comment, updated_at). PK (indicator_type, region, obs_date, period_type).
# CBR-ряды используют period_type='unknown' (литерал) — они не несут sub-period
# granularity и различаются по obs_date.
UPSERT_KEY_RATE_SQL = text(
"""
INSERT INTO macro_indicator (
indicator_type, region, obs_date, value,
indicator_type, region, obs_date, period_type, value,
source, frequency, unit, comment
) VALUES (
'key_rate', 'rf', CAST(:d AS date), CAST(:v AS numeric),
'key_rate', 'rf', CAST(:d AS date), 'unknown', CAST(:v AS numeric),
'cbr', 'daily', '%', 'CBR key rate'
)
ON CONFLICT (indicator_type, region, obs_date) DO UPDATE SET
ON CONFLICT (indicator_type, region, obs_date, period_type) DO UPDATE SET
value = EXCLUDED.value,
updated_at = now()
"""
@ -61,16 +63,17 @@ UPSERT_KEY_RATE_SQL = text(
# Инфляция «% г/г» (ИПЦ YoY): indicator_type='inflation_yoy', monthly, region='rf'.
# obs_date уже нормализован к 1-му числу месяца парсером (parse_inflation_xlsx).
# period_type='unknown' — месячный ряд без sub-period granularity.
UPSERT_INFLATION_SQL = text(
"""
INSERT INTO macro_indicator (
indicator_type, region, obs_date, value,
indicator_type, region, obs_date, period_type, value,
source, frequency, unit, comment
) VALUES (
'inflation_yoy', 'rf', CAST(:d AS date), CAST(:v AS numeric),
'inflation_yoy', 'rf', CAST(:d AS date), 'unknown', CAST(:v AS numeric),
'cbr', 'monthly', '%', 'CBR inflation YoY (ИПЦ, % г/г)'
)
ON CONFLICT (indicator_type, region, obs_date) DO UPDATE SET
ON CONFLICT (indicator_type, region, obs_date, period_type) DO UPDATE SET
value = EXCLUDED.value,
updated_at = now()
"""

View file

@ -47,21 +47,23 @@ from app.workers.celery_app import celery_app
logger = logging.getLogger(__name__)
# psycopg v3: CAST(:x AS type) — НИКОГДА :x::type (SQLAlchemy+psycopg3 роняет
# синтаксис на ::). Контракт колонок совпадает с macro_indicator (migration 123):
# (indicator_type, region, obs_date, value, source, frequency, unit, comment,
# updated_at) PK (indicator_type, region, obs_date). source='rosstat',
# frequency='yearly' (текущие ряды Росстата — годовые: obs_date = 1 января года).
# синтаксис на ::). Контракт колонок совпадает с macro_indicator (migration 163):
# (indicator_type, region, obs_date, period_type, value, source, frequency, unit,
# comment, updated_at). PK (indicator_type, region, obs_date, period_type).
# Не-ЕМИСС источники (open-data, СМР) используют period_type='unknown' (литерал) —
# они различаются по obs_date без sub-period granularity.
# source='rosstat', frequency='yearly' (текущие ряды Росстата — годовые).
UPSERT_ROSSTAT_SQL = text(
"""
INSERT INTO macro_indicator (
indicator_type, region, obs_date, value,
indicator_type, region, obs_date, period_type, value,
source, frequency, unit, comment
) VALUES (
CAST(:itype AS text), CAST(:region AS text), CAST(:d AS date),
CAST(:v AS numeric),
'unknown', CAST(:v AS numeric),
'rosstat', 'yearly', CAST(:unit AS text), CAST(:comment AS text)
)
ON CONFLICT (indicator_type, region, obs_date) DO UPDATE SET
ON CONFLICT (indicator_type, region, obs_date, period_type) DO UPDATE SET
value = EXCLUDED.value,
source = EXCLUDED.source,
frequency = EXCLUDED.frequency,
@ -71,20 +73,23 @@ UPSERT_ROSSTAT_SQL = text(
"""
)
# ЕМИСС-ряды (source='emiss'): frequency параметризована (quarterly для доходов,
# monthly для ИПЦ когда добавится) — в отличие от open-data, где она фикс 'yearly'.
# Тот же PK и ON CONFLICT-контракт. CAST(:x AS type) — НИКОГДА :x::type (psycopg v3).
# ЕМИСС-ряды (source='emiss'): period_type параметризован — 'year' | 'quarter' |
# 'month' (из _emiss_period_granularity). Это ключевое отличие от не-ЕМИСС источников:
# период ЕМИСС-ряда несёт granularity, необходимую для разделения годового агрегата
# ('год' → 'year') и Q1 ('I квартал' → 'quarter'), оба с obs_date=YYYY-01-01 (#1606).
# frequency параметризована (quarterly для доходов, monthly для ИПЦ когда добавится).
# CAST(:x AS type) — НИКОГДА :x::type (psycopg v3).
UPSERT_EMISS_SQL = text(
"""
INSERT INTO macro_indicator (
indicator_type, region, obs_date, value,
indicator_type, region, obs_date, period_type, value,
source, frequency, unit, comment
) VALUES (
CAST(:itype AS text), CAST(:region AS text), CAST(:d AS date),
CAST(:v AS numeric),
CAST(:period_type AS text), CAST(:v AS numeric),
'emiss', CAST(:freq AS text), CAST(:unit AS text), CAST(:comment AS text)
)
ON CONFLICT (indicator_type, region, obs_date) DO UPDATE SET
ON CONFLICT (indicator_type, region, obs_date, period_type) DO UPDATE SET
value = EXCLUDED.value,
source = EXCLUDED.source,
frequency = EXCLUDED.frequency,
@ -96,19 +101,19 @@ UPSERT_EMISS_SQL = text(
# Индекс цен на СМР — открытый xlsx Росстата: source='rosstat' (rosstat.gov.ru-файл),
# НО frequency='monthly' (в отличие от open-data демографии — там фикс 'yearly'),
# поэтому frequency параметризована. MacroRow не несёт frequency-поля, подставляем
# литералом в bind. Тот же PK и ON CONFLICT-контракт. CAST(:x AS type) — psycopg v3.
# поэтому frequency параметризована. MacroRow не несёт frequency/period_type-полей;
# подставляем литералами. CAST(:x AS type) — psycopg v3.
UPSERT_ROSSTAT_MONTHLY_SQL = text(
"""
INSERT INTO macro_indicator (
indicator_type, region, obs_date, value,
indicator_type, region, obs_date, period_type, value,
source, frequency, unit, comment
) VALUES (
CAST(:itype AS text), CAST(:region AS text), CAST(:d AS date),
CAST(:v AS numeric),
'unknown', CAST(:v AS numeric),
'rosstat', 'monthly', CAST(:unit AS text), CAST(:comment AS text)
)
ON CONFLICT (indicator_type, region, obs_date) DO UPDATE SET
ON CONFLICT (indicator_type, region, obs_date, period_type) DO UPDATE SET
value = EXCLUDED.value,
source = EXCLUDED.source,
frequency = EXCLUDED.frequency,
@ -185,9 +190,15 @@ def _upsert_monthly_rows(db: Session, rows: list[MacroRow]) -> int:
def _upsert_emiss_rows(db: Session, rows: list[EmissRow]) -> int:
"""Апсертит EmissRow в macro_indicator (source='emiss', frequency per-row).
"""Апсертит EmissRow в macro_indicator (source='emiss', frequency+period_type per-row).
SAVEPOINT per-row, чтобы один битый ряд не откатывал всю транзакцию. Возвращает
число успешных upsert'ов."""
число успешных upsert'ов.
period_type (из r.period_type) часть нового PK (migration 163): позволяет
годовому агрегату ('year') и Q1 ('quarter') за один год коexist в таблице без
взаимной перезаписи (#1606).
"""
upserted = 0
for r in rows:
try:
@ -198,6 +209,7 @@ def _upsert_emiss_rows(db: Session, rows: list[EmissRow]) -> int:
"itype": r.indicator_type,
"region": r.region,
"d": r.obs_date,
"period_type": r.period_type,
"v": r.value,
"freq": r.frequency,
"unit": r.unit,
@ -207,10 +219,11 @@ def _upsert_emiss_rows(db: Session, rows: list[EmissRow]) -> int:
upserted += 1
except Exception as e:
logger.warning(
"upsert emiss %s/%s@%s=%s failed: %s",
"upsert emiss %s/%s@%s[%s]=%s failed: %s",
r.indicator_type,
r.region,
r.obs_date,
r.period_type,
r.value,
e,
)

View file

@ -108,19 +108,30 @@ def test_yearly_and_q1_both_survive_dedup() -> None:
assert Decimal("42000") in values, "Q1-наблюдение потеряно"
# obs_date у обоих одинаковый (это нормально — коллизия теперь на стороне DB-upsert)
assert all(r.obs_date == date(2023, 1, 1) for r in rows)
# period_type: годовой → 'year', Q1 → 'quarter' (часть PK в macro_indicator, migration 163)
period_types = {r.period_type for r in rows}
assert period_types == {
"year",
"quarter",
}, f"ожидались period_type year+quarter, получили {period_types}"
# ── real-fixture extraction (income id=57039) ─────────────────────────────────────
def test_income_extracts_sverdlovsk_only() -> None:
"""Из реальной SDMX-выгрузки извлекается ТОЛЬКО Свердл (ОКАТО 65) — РФ/ЦФО/Адыгея нет."""
"""Из реальной SDMX-выгрузки извлекается ТОЛЬКО Свердл (ОКАТО 65) — РФ/ЦФО/Адыгея нет.
Все строки квартальные period_type='quarter' (часть PK macro_indicator, migration 163).
"""
rows = parse_emiss_sdmx(_load("emiss_income_57039.xml"), INCOME_PER_CAPITA_SPEC)
assert rows, "ожидались строки по Свердл"
assert {r.region for r in rows} == {"sverdl"}
assert all(r.indicator_type == "income_per_capita" for r in rows)
assert all(r.unit == "руб" for r in rows)
assert all(r.frequency == "quarterly" for r in rows)
assert all(
r.period_type == "quarter" for r in rows
), "квартальные строки должны иметь period_type='quarter'"
def test_income_concrete_values_and_dates() -> None:

View file

@ -48,14 +48,16 @@ def test_upsert_sql_contract() -> None:
assert "'rf'" in sql
assert "'cbr'" in sql
assert "'daily'" in sql
# ON CONFLICT по полному PK + обновление value/updated_at
assert "ON CONFLICT (INDICATOR_TYPE, REGION, OBS_DATE) DO UPDATE" in upper
# ON CONFLICT по новому PK включает period_type (migration 163)
assert "ON CONFLICT (INDICATOR_TYPE, REGION, OBS_DATE, PERIOD_TYPE) DO UPDATE" in upper
assert "VALUE = EXCLUDED.VALUE" in upper
assert "UPDATED_AT = NOW()" in upper
# psycopg v3: CAST(:x AS type), НИКОГДА :x::type
assert "CAST(:D AS DATE)" in upper
assert "CAST(:V AS NUMERIC)" in upper
assert "::" not in sql
# CBR-строки используют period_type='unknown' (литерал в SQL)
assert "'unknown'" in sql
def test_task_upserts_each_row_with_correct_params() -> None:
@ -192,13 +194,14 @@ def test_upsert_inflation_sql_contract() -> None:
assert "'rf'" in sql
assert "'cbr'" in sql
assert "'monthly'" in sql
assert "ON CONFLICT (INDICATOR_TYPE, REGION, OBS_DATE) DO UPDATE" in upper
assert "ON CONFLICT (INDICATOR_TYPE, REGION, OBS_DATE, PERIOD_TYPE) DO UPDATE" in upper
assert "VALUE = EXCLUDED.VALUE" in upper
assert "UPDATED_AT = NOW()" in upper
# psycopg v3: CAST(:x AS type), НИКОГДА :x::type
assert "CAST(:D AS DATE)" in upper
assert "CAST(:V AS NUMERIC)" in upper
assert "::" not in sql
assert "'unknown'" in sql
def test_task_registered_in_beat_weekly() -> None:

View file

@ -35,7 +35,7 @@ def _make_mock_db() -> tuple[MagicMock, list[dict[str, Any]]]:
def test_upsert_sql_contract() -> None:
"""UPSERT_ROSSTAT_SQL содержит обязательные литералы контракта macro_indicator
и НЕ использует :x::type (psycopg v3)."""
и НЕ использует :x::type (psycopg v3). PK включает period_type (migration 163)."""
from app.workers.tasks.rosstat_macro_sync import UPSERT_ROSSTAT_SQL
sql = str(UPSERT_ROSSTAT_SQL)
@ -44,7 +44,7 @@ def test_upsert_sql_contract() -> None:
assert "INTO MACRO_INDICATOR" in upper
assert "'rosstat'" in sql
assert "'yearly'" in sql
assert "ON CONFLICT (INDICATOR_TYPE, REGION, OBS_DATE) DO UPDATE" in upper
assert "ON CONFLICT (INDICATOR_TYPE, REGION, OBS_DATE, PERIOD_TYPE) DO UPDATE" in upper
assert "VALUE = EXCLUDED.VALUE" in upper
assert "UPDATED_AT = NOW()" in upper
# psycopg v3: CAST(:x AS type), НИКОГДА :x::type
@ -52,6 +52,8 @@ def test_upsert_sql_contract() -> None:
assert "CAST(:D AS DATE)" in upper
assert "CAST(:V AS NUMERIC)" in upper
assert "::" not in sql
# не-ЕМИСС строки используют period_type='unknown' (литерал в SQL)
assert "'unknown'" in sql
def test_task_upserts_each_row_with_correct_params() -> None:
@ -155,15 +157,24 @@ def test_task_emiss_failure_does_not_block_opendata() -> None:
def test_task_upserts_emiss_rows_with_correct_params() -> None:
"""Каждая EmissRow → execute UPSERT_EMISS_SQL с itype/region/d/v/freq/unit/comment."""
"""Каждая EmissRow → execute UPSERT_EMISS_SQL с itype/region/d/period_type/v/freq/unit/comment.
period_type передаётся из r.period_type (часть нового PK, migration 163).
"""
from app.services.scrapers.rosstat_emiss import EmissRow
from app.workers.tasks import rosstat_macro_sync as task_mod
db, captured = _make_mock_db()
emiss_rows = [
EmissRow(
"income_per_capita", "sverdl", date(2024, 1, 1),
Decimal("54006"), "руб", "quarterly", "c",
"income_per_capita",
"sverdl",
date(2024, 1, 1),
Decimal("54006"),
"руб",
"quarterly",
"c",
period_type="quarter",
),
]
@ -181,6 +192,7 @@ def test_task_upserts_emiss_rows_with_correct_params() -> None:
"itype": "income_per_capita",
"region": "sverdl",
"d": date(2024, 1, 1),
"period_type": "quarter",
"v": Decimal("54006"),
"freq": "quarterly",
"unit": "руб",
@ -190,7 +202,8 @@ def test_task_upserts_emiss_rows_with_correct_params() -> None:
def test_upsert_emiss_sql_contract() -> None:
"""UPSERT_EMISS_SQL: source='emiss', frequency параметризован, ON CONFLICT, CAST not ::."""
"""UPSERT_EMISS_SQL: source='emiss', frequency+period_type параметризованы,
ON CONFLICT включает period_type (migration 163), CAST not ::."""
from app.workers.tasks.rosstat_macro_sync import UPSERT_EMISS_SQL
sql = str(UPSERT_EMISS_SQL)
@ -198,14 +211,16 @@ def test_upsert_emiss_sql_contract() -> None:
assert "INTO MACRO_INDICATOR" in upper
assert "'emiss'" in sql
assert "CAST(:FREQ AS TEXT)" in upper
assert "ON CONFLICT (INDICATOR_TYPE, REGION, OBS_DATE) DO UPDATE" in upper
assert "CAST(:PERIOD_TYPE AS TEXT)" in upper
assert "ON CONFLICT (INDICATOR_TYPE, REGION, OBS_DATE, PERIOD_TYPE) DO UPDATE" in upper
assert "CAST(:ITYPE AS TEXT)" in upper
assert "CAST(:V AS NUMERIC)" in upper
assert "::" not in sql
def test_upsert_rosstat_monthly_sql_contract() -> None:
"""UPSERT_ROSSTAT_MONTHLY_SQL: source='rosstat', frequency='monthly', CAST not ::."""
"""UPSERT_ROSSTAT_MONTHLY_SQL: source='rosstat', frequency='monthly', period_type='unknown',
ON CONFLICT включает period_type (migration 163), CAST not ::."""
from app.workers.tasks.rosstat_macro_sync import UPSERT_ROSSTAT_MONTHLY_SQL
sql = str(UPSERT_ROSSTAT_MONTHLY_SQL)
@ -213,11 +228,12 @@ def test_upsert_rosstat_monthly_sql_contract() -> None:
assert "INTO MACRO_INDICATOR" in upper
assert "'rosstat'" in sql
assert "'monthly'" in sql
assert "ON CONFLICT (INDICATOR_TYPE, REGION, OBS_DATE) DO UPDATE" in upper
assert "ON CONFLICT (INDICATOR_TYPE, REGION, OBS_DATE, PERIOD_TYPE) DO UPDATE" in upper
assert "CAST(:ITYPE AS TEXT)" in upper
assert "CAST(:D AS DATE)" in upper
assert "CAST(:V AS NUMERIC)" in upper
assert "::" not in sql
assert "'unknown'" in sql
def test_task_upserts_construction_rows_with_correct_params() -> None:
@ -228,8 +244,12 @@ def test_task_upserts_construction_rows_with_correct_params() -> None:
db, captured = _make_mock_db()
constr_rows = [
MacroRow(
"construction_price_index", "rf", date(2025, 1, 1),
Decimal("100.8"), "%", "smr",
"construction_price_index",
"rf",
date(2025, 1, 1),
Decimal("100.8"),
"%",
"smr",
),
]

View file

@ -0,0 +1,86 @@
-- 163_emiss_pk_period_type.sql
-- #1606 follow-up: расширить PK macro_indicator колонкой period_type, чтобы годовой
-- агрегат ('год' → 'year') и Q1 ('I квартал' → 'quarter') за один год не перезаписывали
-- друг друга при ON CONFLICT DO UPDATE (оба дают obs_date=YYYY-01-01).
--
-- Контекст:
-- PR #1687 (#1606) исправил in-memory дедупликацию в parse_emiss_sdmx: ключ дедупа
-- стал трёхкомпонентным (region, obs_date, granularity). Но PK таблицы остался
-- (indicator_type, region, obs_date) — при апсерте в БД коллизия сохранялась.
-- Эта миграция фиксирует PK на стороне БД.
--
-- Что делает:
-- 1. Добавляет колонку period_type TEXT NOT NULL DEFAULT 'unknown'.
-- Все существующие строки (CBR, rosstat open-data, domrf) получают 'unknown' —
-- это корректно: у них нет sub-period granularity (они уже различаются по obs_date).
-- 2. Бэкфилл: строки source='emiss' обновляем до 'quarterly' (единственный
-- активный ЕМИСС-индикатор — income_per_capita, он квартальный; годовых EMISS-
-- строк ещё нет в таблице до этой миграции).
-- 3. Пересоздаёт PRIMARY KEY: старый (indicator_type, region, obs_date) → новый
-- (indicator_type, region, obs_date, period_type).
-- 4. Пересоздаёт сопутствующий индекс idx_macro_type_region_date с тем же составом
-- (расширяется автоматически через новый PK-индекс; отдельный индекс DROP+CREATE).
--
-- Зависимости: macro_indicator (migration 123_macro_indicator.sql).
-- Нет VIEW/FK зависимостей от macro_indicator PK (проверено через pg_depend).
-- Idempotent: ADD COLUMN IF NOT EXISTS + IF NOT EXISTS в CREATE INDEX. Повторный
-- прогон безопасен: DROP CONSTRAINT / ADD CONSTRAINT могут упасть если PK уже
-- переименован — обёрнуты в DO $$ BEGIN ... EXCEPTION WHEN ... END $$; блоки.
--
-- Deploy order: SQL migration применяется ПЕРВОЙ (auto-deploy.yml), затем деплоится
-- backend со обновлёнными UPSERT_EMISS_SQL / UPSERT_ROSSTAT_SQL / UPSERT_KEY_RATE_SQL
-- / UPSERT_INFLATION_SQL / UPSERT_ROSSTAT_MONTHLY_SQL (они несут новый конфликт-таргет
-- + period_type в INSERT).
BEGIN;
-- ── 1. Добавить колонку period_type (DEFAULT 'unknown' для всех существующих строк) ──
ALTER TABLE macro_indicator
ADD COLUMN IF NOT EXISTS period_type TEXT NOT NULL DEFAULT 'unknown';
-- ── 2. Бэкфилл ЕМИСС-строк → 'quarterly' ────────────────────────────────────────────
-- Все существующие ЕМИСС-строки — quarterly (income_per_capita). Если появятся 'year'-
-- строки (будущие индикаторы) — они придут с корректным period_type из нового скрейпера.
UPDATE macro_indicator
SET period_type = 'quarterly'
WHERE source = 'emiss'
AND period_type = 'unknown';
-- ── 3. Пересоздать PRIMARY KEY ────────────────────────────────────────────────────────
-- PostgreSQL не поддерживает ALTER PRIMARY KEY напрямую — нужно DROP + ADD.
-- Ловим случай когда старый PK уже дропнут (повторный прогон).
DO $$
BEGIN
ALTER TABLE macro_indicator DROP CONSTRAINT macro_indicator_pkey;
EXCEPTION
WHEN undefined_object THEN
NULL; -- PK уже удалён (повторный прогон)
WHEN feature_not_supported THEN
NULL;
END;
$$;
-- Новый PK включает period_type. Если уже существует с правильным составом —
-- ADD CONSTRAINT бросит duplicate_table (конвенция Pg: PK → unique index), ловим.
DO $$
BEGIN
ALTER TABLE macro_indicator
ADD CONSTRAINT macro_indicator_pkey
PRIMARY KEY (indicator_type, region, obs_date, period_type);
EXCEPTION
WHEN duplicate_table THEN
NULL; -- PK уже создан (повторный прогон)
WHEN invalid_table_definition THEN
NULL; -- уже существует тот же constraint
END;
$$;
-- ── 4. Пересоздать сопутствующий индекс ──────────────────────────────────────────────
-- Старый индекс (без period_type в составе) уже покрыт новым PK-индексом по факту,
-- но явно дропаем и пересоздаём с period_type для консистентности плана запросов.
DROP INDEX IF EXISTS idx_macro_type_region_date;
CREATE INDEX IF NOT EXISTS idx_macro_type_region_date
ON macro_indicator (indicator_type, region, obs_date DESC, period_type);
COMMIT;