ЕЭСК: бинд-параметр в SQL-комментарии молча ронял все 71 UPDATE две недели #3317
2 changed files with 81 additions and 16 deletions
|
|
@ -246,22 +246,19 @@ def load_ps_35_220(db: Session, xlsx_bytes: bytes, reserve_asof: date | None) ->
|
||||||
reserve_unit = 'МВт',
|
reserve_unit = 'МВт',
|
||||||
installed_capacity_mva = :installed,
|
installed_capacity_mva = :installed,
|
||||||
district = :district,
|
district = :district,
|
||||||
-- #2464-B: сюда БОЛЬШЕ НЕ пишем степень загрузки.
|
-- #2464-B: сюда БОЛЬШЕ НЕ пишем степень загрузки:
|
||||||
-- load_index — категориальная колонка
|
-- load_index — категориальная колонка, её заполняет
|
||||||
-- ('open'|'limited'|'closed'|NULL, см.
|
-- rosseti_wfs_loader._map_load_index. Историю см. в
|
||||||
-- data/sql/180_connection_capacity.sql:35), её
|
-- git log этого файла.
|
||||||
-- заполняет rosseti_wfs_loader._map_load_index.
|
--
|
||||||
-- Раньше тут стоял COALESCE(load_index,
|
-- ВАЖНО: в этом комментарии НЕЛЬЗЯ упоминать
|
||||||
-- CAST(:load_pct AS text)) — при пустой ячейке
|
-- бинд-параметры в синтаксисе «двоеточие + имя».
|
||||||
-- в колонку легло бы число строкой ("72.5"),
|
-- SQLAlchemy text() парсит бинды и внутри
|
||||||
-- а фронтовый classifyLoadIndex такое значение
|
-- SQL-комментариев: упоминание снятого параметра
|
||||||
-- отбрасывает в null («неизвестно»), и в
|
-- «(двоеточие)load_pct» в тексте комментария
|
||||||
-- power_summary.by_load_index появился бы
|
-- сделало его ОБЯЗАТЕЛЬНЫМ, все 71 UPDATE падали
|
||||||
-- бакет с именем "72.5".
|
-- с 18.08 по 02.09, а per-row except глотал это
|
||||||
-- Сегодня не стреляло только потому, что у всех
|
-- как «битую строку» — задача оставалась зелёной.
|
||||||
-- 3416 строк load_index уже заполнен
|
|
||||||
-- (open 2741 / limited 346 / closed 329, NULL 0)
|
|
||||||
-- и COALESCE не проваливался.
|
|
||||||
capacity_source = 'eesk_35_220',
|
capacity_source = 'eesk_35_220',
|
||||||
reserve_asof = :asof
|
reserve_asof = :asof
|
||||||
WHERE sc_name_norm = :name_norm
|
WHERE sc_name_norm = :name_norm
|
||||||
|
|
|
||||||
68
backend/tests/test_eesk_loader_bind_params.py
Normal file
68
backend/tests/test_eesk_loader_bind_params.py
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
"""Бинды SQL-стейтментов ЕЭСК-лоадера обязаны совпадать с передаваемыми params.
|
||||||
|
|
||||||
|
ПОЧЕМУ ЭТОТ ТЕСТ СУЩЕСТВУЕТ. 18.08-02.09.2026 все 71 UPDATE резервов ПС
|
||||||
|
падали, а задача оставалась зелёной: упоминание СНЯТОГО параметра load_pct
|
||||||
|
в SQL-КОММЕНТАРИИ (в синтаксисе «двоеточие+имя») SQLAlchemy text() распарсил
|
||||||
|
как обязательный бинд, params его не содержал, каждая строка падала на
|
||||||
|
compile, а per-row except глотал это как «битую строку». ЕЭСК-резервы не
|
||||||
|
обновлялись две недели, freshness этого не видел (задача done).
|
||||||
|
|
||||||
|
Тест ловит МЕХАНИЗМ: собирает все text()-стейтменты модуля и сверяет их
|
||||||
|
бинд-имена с ключами словаря params из того же контекста. Никакой БД не
|
||||||
|
нужно — дефект живёт на этапе компиляции стейтмента.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
LOADER = (
|
||||||
|
Path(__file__).resolve().parents[1]
|
||||||
|
/ "app"
|
||||||
|
/ "services"
|
||||||
|
/ "site_finder"
|
||||||
|
/ "eesk_reserve_loader.py"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_update_sql() -> str:
|
||||||
|
"""Достаёт текст UPDATE power_supply_centers из исходника лоадера."""
|
||||||
|
src = LOADER.read_text(encoding="utf-8")
|
||||||
|
m = re.search(r'text\("""\s*(UPDATE power_supply_centers.*?)"""\)', src, re.S)
|
||||||
|
assert m, "UPDATE power_supply_centers не найден в лоадере"
|
||||||
|
return m.group(1)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_params_keys() -> set[str]:
|
||||||
|
"""Ключи словаря params, который передаётся в этот execute."""
|
||||||
|
src = LOADER.read_text(encoding="utf-8")
|
||||||
|
m = re.search(r"params = \{(.*?)\}", src, re.S)
|
||||||
|
assert m, "словарь params не найден"
|
||||||
|
return set(re.findall(r'"([a-z_]+)":', m.group(1)))
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_binds_are_subset_of_params() -> None:
|
||||||
|
"""Каждый бинд стейтмента обязан приходить из params — иначе UPDATE падает
|
||||||
|
на КАЖДОЙ строке, и per-row SAVEPOINT-except прячет это под «битую строку»."""
|
||||||
|
stmt = text(_extract_update_sql())
|
||||||
|
binds = {p.key for p in stmt._bindparams.values()}
|
||||||
|
params = _extract_params_keys()
|
||||||
|
missing = binds - params
|
||||||
|
assert not missing, (
|
||||||
|
f"стейтмент требует биндов {sorted(missing)}, которых нет в params — "
|
||||||
|
"все строки батча упадут молча. Частая причина: упоминание "
|
||||||
|
"«двоеточие+имя» в SQL-комментарии (text() парсит бинды и там)."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_comment_does_not_reintroduce_phantom_bind() -> None:
|
||||||
|
"""Регресс 18.08: снятый параметр упомянули в комментарии в живом
|
||||||
|
синтаксисе бинда. Проверяем ЗНАЧЕНИЕМ: компиляция с ровно теми params,
|
||||||
|
что собирает лоадер, не требует ничего лишнего."""
|
||||||
|
stmt = text(_extract_update_sql())
|
||||||
|
have = _extract_params_keys()
|
||||||
|
for bp in stmt._bindparams.values():
|
||||||
|
assert bp.key in have, f"фантомный бинд из комментария: {bp.key!r}"
|
||||||
Loading…
Add table
Reference in a new issue