Merge pull request 'fix(sql): EMISS PK includes period_type so yearly+Q1 coexist (#1606 follow-up)' (#1706) from fix/emiss-pk-granularity-1687 into main
This commit is contained in:
commit
0fb70f2f90
7 changed files with 197 additions and 46 deletions
|
|
@ -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]))]
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
),
|
||||
]
|
||||
|
||||
|
|
|
|||
92
data/sql/163_emiss_pk_period_type.sql
Normal file
92
data/sql/163_emiss_pk_period_type.sql
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
-- 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' обновляем до 'quarter' (единственный
|
||||
-- активный ЕМИСС-индикатор — income_per_capita, он квартальный; годовых EMISS-
|
||||
-- строк ещё нет в таблице до этой миграции). Значение ДОЛЖНО совпадать с тем,
|
||||
-- что пишет UPSERT_EMISS_SQL (_emiss_period_granularity → 'quarter'/'year'/'month',
|
||||
-- короткая форма), иначе ON CONFLICT не сматчит → дубли при следующем скрейпе.
|
||||
-- 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. Бэкфилл ЕМИСС-строк → 'quarter' ────────────────────────────────────────────
|
||||
-- Все существующие ЕМИСС-строки — квартальные (income_per_capita). Значение 'quarter'
|
||||
-- (короткая форма) обязано совпадать с UPSERT_EMISS_SQL, который параметризует
|
||||
-- period_type из _emiss_period_granularity ('quarter'/'year'/'month'). Иначе после
|
||||
-- деплоя следующий скрейп вставит 'quarter'-строки, не матчащие 'quarterly' на
|
||||
-- ON CONFLICT → дубли для каждого квартального наблюдения. Если появятся 'year'-строки
|
||||
-- (будущие индикаторы) — придут с корректным period_type из скрейпера.
|
||||
UPDATE macro_indicator
|
||||
SET period_type = 'quarter'
|
||||
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. Если constraint с этим именем уже существует —
|
||||
-- ADD CONSTRAINT бросит duplicate_object (42710, дубль имени constraint), ловим.
|
||||
DO $$
|
||||
BEGIN
|
||||
ALTER TABLE macro_indicator
|
||||
ADD CONSTRAINT macro_indicator_pkey
|
||||
PRIMARY KEY (indicator_type, region, obs_date, period_type);
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN
|
||||
NULL; -- constraint с таким именем уже создан (повторный прогон)
|
||||
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;
|
||||
Loading…
Add table
Reference in a new issue