diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index 8999bc17..bd423b5e 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -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() diff --git a/backend/tests/integration/test_analyze_parcels_sql.py b/backend/tests/integration/test_analyze_parcels_sql.py new file mode 100644 index 00000000..3b2e8fff --- /dev/null +++ b/backend/tests/integration/test_analyze_parcels_sql.py @@ -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 OVERLAPS ...` (binary time-period operator) → + syntax error. Здесь mini-check: SQL-литерал не должен содержать + запрещённые alias'ы как `WITH 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 AS (`` или ``, 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: ``:::`` где ```` — буквы/_, + ```` — буквы (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." + )