test(parcels): integration EXPLAIN-gate для analyze hot-path SQL (#1198) #1200

Merged
bot-backend merged 1 commit from fix/analyze-integration-test into main 2026-06-13 04:53:04 +00:00
2 changed files with 236 additions and 54 deletions

View file

@ -581,6 +581,69 @@ def _parse_floors(raw: str | int | None) -> int | None:
return None
# `_neighbors_summary` SQL — module-level чтобы integration-тест (см.
# `backend/tests/integration/test_analyze_parcels_sql.py`) мог EXPLAIN-нуть его
# против реальной prod-PG и поймать SQL-уровневые регрессии: PG keyword conflict
# в CTE (`overlaps` — #1195→#1197), `:x::type` antipattern в psycopg v3,
# несуществующая колонка/PostGIS-функция. Inline `text(...)` внутри функции — не
# проверяемый, mock-тест db.execute() ловит только Python-уровень.
#
# CTE name `overlap_rows` (НЕ `overlaps`): `OVERLAPS` — PG keyword (binary
# operator для time-periods), парсер ругается на `overlaps AS (`.
_NEIGHBORS_SUMMARY_SQL = text("""
WITH neighbors AS (
SELECT cad_num,
building_name,
floors,
year_built,
cost_value,
area,
readable_address,
ST_Distance(
b.geom::geography,
ST_GeomFromText(CAST(:wkt AS text), 4326)::geography
) AS distance_m
FROM cad_buildings b
WHERE ST_DWithin(
b.geom::geography,
ST_GeomFromText(CAST(:wkt AS text), 4326)::geography,
100
)
AND b.cad_num != CAST(:our_cad AS text)
ORDER BY distance_m ASC
LIMIT 30
),
overlap_rows AS (
SELECT cad_num,
building_name,
floors,
readable_address,
ST_Area(
ST_Intersection(
ST_Transform(b.geom, 32641),
ST_Transform(ST_GeomFromText(CAST(:wkt AS text), 4326), 32641)
)
) AS overlap_m2
FROM cad_buildings b
WHERE ST_Intersects(b.geom, ST_GeomFromText(CAST(:wkt AS text), 4326))
AND b.cad_num != CAST(:our_cad AS text)
ORDER BY overlap_m2 DESC NULLS LAST
LIMIT 5
)
SELECT
COALESCE(
(SELECT json_agg(row_to_json(n) ORDER BY n.distance_m ASC)
FROM neighbors n),
'[]'::json
) AS neighbors,
COALESCE(
(SELECT json_agg(row_to_json(o) ORDER BY o.overlap_m2 DESC NULLS LAST)
FROM overlap_rows o),
'[]'::json
) AS overlap_rows
""")
def _neighbors_summary(db: Session, geom_wkt: str, our_cad_num: str) -> dict[str, Any]:
"""P2 (#46) — cad_buildings соседи в 100м + overlap check.
@ -593,64 +656,14 @@ def _neighbors_summary(db: Session, geom_wkt: str, our_cad_num: str) -> dict[str
ОДИН SQL-statement через две CTE + scalar-агрегацию в `json_agg`. Это срезает
один сетевой round-trip (~47ms) на каждый analyze сами вычисления не
меняются. Формат возвращаемого dict идентичен прежнему.
SQL module-level `_NEIGHBORS_SUMMARY_SQL` (тестируется через
integration EXPLAIN-gate, см. `test_analyze_parcels_sql.py`).
"""
try:
row = (
db.execute(
text("""
WITH neighbors AS (
SELECT cad_num,
building_name,
floors,
year_built,
cost_value,
area,
readable_address,
ST_Distance(
b.geom::geography,
ST_GeomFromText(:wkt, 4326)::geography
) AS distance_m
FROM cad_buildings b
WHERE ST_DWithin(
b.geom::geography,
ST_GeomFromText(:wkt, 4326)::geography,
100
)
AND b.cad_num != :our_cad
ORDER BY distance_m ASC
LIMIT 30
),
overlap_rows AS (
SELECT cad_num,
building_name,
floors,
readable_address,
ST_Area(
ST_Intersection(
ST_Transform(b.geom, 32641),
ST_Transform(ST_GeomFromText(:wkt, 4326), 32641)
)
) AS overlap_m2
FROM cad_buildings b
WHERE ST_Intersects(b.geom, ST_GeomFromText(:wkt, 4326))
AND b.cad_num != :our_cad
ORDER BY overlap_m2 DESC NULLS LAST
LIMIT 5
)
-- CTE name `overlap_rows` (НЕ `overlaps`): `OVERLAPS` PG keyword
-- (binary operator для time-periods), парсер ругается на `overlaps AS (`.
SELECT
COALESCE(
(SELECT json_agg(row_to_json(n) ORDER BY n.distance_m ASC)
FROM neighbors n),
'[]'::json
) AS neighbors,
COALESCE(
(SELECT json_agg(row_to_json(o) ORDER BY o.overlap_m2 DESC NULLS LAST)
FROM overlap_rows o),
'[]'::json
) AS overlap_rows
"""),
_NEIGHBORS_SUMMARY_SQL,
{"wkt": geom_wkt, "our_cad": our_cad_num},
)
.mappings()

View file

@ -0,0 +1,169 @@
"""Integration-тесты для analyze hot-path SQL — EXPLAIN против реальной prod-PG.
Поймает SQL-уровневые регрессии, которые mock-юнит-тесты (MagicMock на
``db.execute``) НЕ ловят, потому что mock не передаёт SQL планировщику:
1. **PG keyword conflict в CTE** `WITH overlaps AS (...)` падает с
`syntax error at or near "overlaps"`. Incident #1195→#1197 (#1198):
PR #1195 (2026-06-12) ввёл CTE-alias ``overlaps``, ронявший каждый
``POST /analyze/{cad}`` ~10ч до hotfix #1197.
2. **psycopg v3 `:x::type` antipattern** `:wkt::text` молча игнорируется
парсером SQLAlchemy 2.0 + psycopg3 `syntax error at or near ':'`.
Recurring class (см. `.claude/rules/backend.md`,
vault `Bug_SQLAlchemy_DoubleColon_Cast`).
3. **Неверные имена PostGIS-функций** (typo: `ST_Distanse`, `ST_DWithn`,
`ST_Intersect` без `s`) `function does not exist`.
4. **Несуществующая колонка/таблица** (PR #196 ``geom_3857``, PR #213
``units_count``) phantom-column-gate класс багов.
Подход: модуль-level SQL-константы (``_NEIGHBORS_SUMMARY_SQL``,
``_IRD_OVERLAP_SQL``) импортируются КАК ЕСТЬ из production-кода EXPLAIN
с реальными bind-params. EXPLAIN выполняет parse + plan, не выполняет
сами scan'ы → детерминированно, быстро (~50ms на запрос), не зависит от
prod-данных.
Запуск:
# Без TEST_DATABASE_URL — все тесты skip:
uv run pytest tests/integration/ -v
# С SSH-туннелем (`ssh -N gendesign` → localhost:15432):
export TEST_DATABASE_URL="postgresql+psycopg://USER:PASS@localhost:15432/DB"
uv run pytest tests/integration/test_analyze_parcels_sql.py -v -m integration
"""
from __future__ import annotations
import pytest
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.api.v1.parcels import _NEIGHBORS_SUMMARY_SQL
from app.services.site_finder.ird_overlay_lookup import _IRD_OVERLAP_SQL
from tests.integration.conftest import requires_test_db
# NB: ``pytestmark`` НЕ ставим на модуль — здесь два класса compile-time
# (без БД, всегда run) и два класса runtime (EXPLAIN, skip без
# TEST_DATABASE_URL). Маркер ``integration`` ставим точечно — только на
# runtime-тесты — иначе compile-time guard'ы тоже skipаются в обычном CI run.
# WKT-полигон в ЕКБ (МСК-66 / EPSG:4326) — небольшой квадрат в центре города.
# Используется только как bind-param: EXPLAIN не сканирует геометрию, не зависит
# от того, есть ли реальные cad_buildings в этих координатах.
_EKB_WKT = (
"POLYGON((60.6000 56.8400, 60.6020 56.8400, 60.6020 56.8420, 60.6000 56.8420, 60.6000 56.8400))"
)
def _explain_text(db: Session, sql: str, params: dict) -> None:
"""EXPLAIN над raw-SQL-строкой с bind-params.
Не EXECUTE только parse+plan. Бросает SQLAlchemy/psycopg-исключение,
если SQL невалиден (keyword conflict, неверная PostGIS-функция,
несуществующая колонка/таблица, `:x::type` antipattern).
"""
db.execute(text(f"EXPLAIN {sql}"), params)
# ── _neighbors_summary (incident #1195/#1197) ────────────────────────────────
class TestNeighborsSummarySql:
"""``_NEIGHBORS_SUMMARY_SQL`` из ``app.api.v1.parcels``.
Этот тест бы поймал PR #1195 (CTE alias ``overlaps``) на CI вместо прода.
"""
@requires_test_db
@pytest.mark.integration
def test_explain_neighbors_summary(self, phantom_check_session: Session) -> None:
"""SQL парсится и планируется против реальной PG schema + PostGIS."""
# SQL-константа — TextClause. EXPLAIN над TextClause не поддерживается
# SQLAlchemy напрямую (нужен текст для конкатенации с "EXPLAIN ").
# Извлекаем raw SQL через .text — это canonical способ инспектировать
# TextClause-литерал.
raw_sql = _NEIGHBORS_SUMMARY_SQL.text
_explain_text(
phantom_check_session,
raw_sql,
{"wkt": _EKB_WKT, "our_cad": "66:41:0303161:999"},
)
def test_neighbors_cte_aliases_not_pg_keywords(self) -> None:
"""Регрессионный guard: CTE-alias'ы не должны быть PG-keywords.
Историческая ловушка: `WITH overlaps AS (...)` парсится как
`WITH <expr> OVERLAPS <expr> ...` (binary time-period operator)
syntax error. Здесь mini-check: SQL-литерал не должен содержать
запрещённые alias'ы как `WITH <name> AS (`.
"""
# Set из PG reserved keywords, которые потенциально могут оказаться
# CTE-alias-ом. Не полный список — только те, что встречались как ловушка.
forbidden_aliases = {"overlaps", "user", "current_date", "select", "where"}
raw_sql = _NEIGHBORS_SUMMARY_SQL.text.lower()
for kw in forbidden_aliases:
# ищем паттерн ``WITH <kw> AS (`` или ``, <kw> AS (`` — оба
# формы CTE-биндинга.
assert f"with {kw} as (" not in raw_sql and f", {kw} as (" not in raw_sql, (
f"CTE alias '{kw}' пересекается с PG keyword (см. incident #1195)"
)
# ── parcel_ird_overlaps SQL ──────────────────────────────────────────────────
class TestIrdOverlapSql:
"""``_IRD_OVERLAP_SQL`` из ``app.services.site_finder.ird_overlay_lookup``.
Уже использует canonical ``CAST(:parcel_wkt AS text)`` этот тест
фиксирует поведение и поймает регрессию если кто-то откатит на
``:parcel_wkt::text``.
"""
@requires_test_db
@pytest.mark.integration
def test_explain_ird_overlap(self, phantom_check_session: Session) -> None:
"""SQL парсится против реальной ``ird_overlays`` schema + PostGIS."""
raw_sql = _IRD_OVERLAP_SQL.text
_explain_text(
phantom_check_session,
raw_sql,
{"parcel_wkt": _EKB_WKT},
)
# ── Cross-module antipattern guard ───────────────────────────────────────────
class TestPsycopg3CastAntipattern:
"""Регрессионный guard: ни одна из проверяемых SQL-констант не должна
содержать ``:name::type`` (psycopg v3 silent ignore).
Это compile-time check (не требует БД) но живёт здесь, чтобы тестовый
модуль был единым source-of-truth для analyze-SQL invariants.
Reference: `.claude/rules/backend.md`, vault ``Bug_SQLAlchemy_DoubleColon_Cast``.
"""
@pytest.mark.parametrize(
"name,sql",
[
("_NEIGHBORS_SUMMARY_SQL", _NEIGHBORS_SUMMARY_SQL.text),
("_IRD_OVERLAP_SQL", _IRD_OVERLAP_SQL.text),
],
ids=["neighbors_summary", "ird_overlap"],
)
def test_no_bind_double_colon_cast(self, name: str, sql: str) -> None:
"""``:bind::type`` запрещён. Только ``CAST(:bind AS type)``.
Regex pattern: ``:<word>::<type>`` где ``<word>`` буквы/_,
``<type>`` буквы (text, int, float, etc).
Исключение из правила (``ARRAY[:rc]::int[]``) здесь не встречается
проверяем строго ``:name::type``.
"""
import re
matches = re.findall(r":[a-z_]+::[a-z]+", sql)
assert not matches, (
f"{name} содержит psycopg v3 antipattern: {matches}. "
f"Используй CAST(:bind AS type) — см. .claude/rules/backend.md."
)