"""Phantom column gate — EXPLAIN-тесты против реальной Postgres schema. Каждый тест выполняет EXPLAIN (не EXECUTE!) на SQL из production services. Если колонка не существует — Postgres бросит ошибку при планировании → тест fail. Если таблица не существует — аналогично. Запуск: # Без 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_phantom_columns.py -v -m integration Детектируемые классы багов: - Ссылка на несуществующую колонку (domrf_kn_objects.geom_3857 — PR #196) - Ссылка на несуществующую колонку (ekburg_construction_permits.units_count — PR #213) - Ссылка на несуществующую таблицу / view - Опечатки в именах колонок и таблиц """ from __future__ import annotations import pytest from sqlalchemy import text from sqlalchemy.orm import Session from tests.integration.conftest import requires_test_db # ── Маркеры ─────────────────────────────────────────────────────────────────── pytestmark = [ requires_test_db, pytest.mark.integration, ] # ── helpers ─────────────────────────────────────────────────────────────────── def _explain(db: Session, query: str, params: dict | None = None) -> None: """Выполнить EXPLAIN для query. Не выполняет DML — только план запроса. Бросает исключение (тест fail) если: - колонка не существует - таблица/view не существует - синтаксическая ошибка SQL """ explain_sql = f"EXPLAIN {query}" db.execute(text(explain_sql), params or {}) # ── 1. domrf_kn_objects — реальные колонки ──────────────────────────────────── class TestDomrfKnObjects: """Проверяем что SQL в services используют реальные колонки domrf_kn_objects. Исторический баг PR #196: geom_3857 не существует. Реальная schema: latitude, longitude (float), snapshot_date, obj_class, etc. """ def test_competitors_radius_query(self, phantom_check_session: Session) -> None: """best_layouts._COMPETITORS_IN_RADIUS_SQL — latitude/longitude/snapshot_date.""" _explain( phantom_check_session, """ SELECT DISTINCT ON (obj_id) obj_id FROM domrf_kn_objects WHERE latitude IS NOT NULL AND longitude IS NOT NULL AND ST_DWithin( ST_SetSRID(ST_MakePoint(longitude, latitude), 4326)::geography, ST_SetSRID( ST_MakePoint(CAST(60.6 AS float), CAST(56.8 AS float)), 4326 )::geography, CAST(1000 AS float) ) ORDER BY obj_id, snapshot_date DESC NULLS LAST """, ) def test_competitors_full_cte_query(self, phantom_check_session: Session) -> None: """competitors._COMPETITORS_SQL — полный CTE с latitude/longitude/flat_count.""" _explain( phantom_check_session, """ WITH latest_obj AS ( SELECT DISTINCT ON (obj_id) obj_id, comm_name, dev_name, obj_class, latitude, longitude, flat_count, site_status, snapshot_date FROM domrf_kn_objects WHERE latitude IS NOT NULL AND longitude IS NOT NULL ORDER BY obj_id, snapshot_date DESC NULLS LAST ), distances AS ( SELECT o.obj_id, o.comm_name, o.dev_name, o.obj_class, o.latitude, o.longitude, o.flat_count, o.site_status, ST_Distance( ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography, ST_SetSRID( ST_MakePoint(CAST(60.6 AS float), CAST(56.8 AS float)), 4326 )::geography ) AS distance_m FROM latest_obj o ) SELECT d.obj_id, d.comm_name, d.dev_name, d.obj_class, d.latitude, d.longitude, d.flat_count, d.site_status, d.distance_m FROM distances d WHERE d.distance_m <= CAST(1000 AS float) ORDER BY d.distance_m ASC """, ) def test_velocity_competitor_query_columns(self, phantom_check_session: Session) -> None: """velocity._competitors query — district_name, region_cd, snapshot_date.""" _explain( phantom_check_session, """ SELECT DISTINCT ON (obj_id) obj_id, comm_name, dev_name, obj_class, latitude, longitude, district_name FROM domrf_kn_objects WHERE latitude IS NOT NULL AND longitude IS NOT NULL AND region_cd = 66 ORDER BY obj_id, snapshot_date DESC NULLS LAST """, ) # ── 2. ekburg_construction_permits — реальные колонки ──────────────────────── class TestEkburgConstructionPermits: """Проверяем что SQL используют реальные колонки ekburg_construction_permits. Исторический баг PR #213: units_count не существует. Реальная schema: total_area_sqm, living_area_sqm, living_area_fact_sqm, etc. """ def test_recent_permits_query(self, phantom_check_session: Session) -> None: """parcels.py recent_permits query — total_area_sqm (не units_count!).""" _explain( phantom_check_session, """ SELECT permit_type, permit_number, issue_date, developer_name, developer_inn, object_name, object_type, construction_address, total_area_sqm FROM ekburg_construction_permits WHERE LEFT(cadastral_number, LENGTH(CAST('66:41:0303161' AS text))) = CAST('66:41:0303161' AS text) AND issue_date > NOW() - INTERVAL '24 months' ORDER BY issue_date DESC LIMIT 50 """, ) def test_permits_rns_columns(self, phantom_check_session: Session) -> None: """Все колонки RNS-специфичные: living_area_sqm, permit_type.""" _explain( phantom_check_session, """ SELECT permit_type, permit_number, issue_date, expiry_date, developer_name, developer_inn, object_name, object_type, construction_address, cadastral_number, total_area_sqm, living_area_sqm FROM ekburg_construction_permits WHERE permit_type = 'RNS' LIMIT 1 """, ) def test_permits_rve_columns(self, phantom_check_session: Session) -> None: """Все колонки RVE-специфичные: living_area_fact_sqm, rve_number, rve_date.""" _explain( phantom_check_session, """ SELECT permit_type, permit_number, living_area_fact_sqm, rve_number, rve_date FROM ekburg_construction_permits WHERE permit_type = 'RVE' LIMIT 1 """, ) # ── 3. mv_layout_velocity — materialized view ──────────────────────────────── class TestMvLayoutVelocity: """Проверяем materialized view mv_layout_velocity и её колонки.""" def test_velocity_by_room_bucket(self, phantom_check_session: Session) -> None: """best_layouts._VELOCITY_BY_ROOM_SQL — room_bucket, total_deals_24mo, etc.""" _explain( phantom_check_session, """ SELECT room_bucket, SUM(total_deals_24mo) AS sum_deals, AVG(avg_area_m2) AS avg_area_m2, AVG(avg_price_thousand_rub_per_m2) * 1000.0 AS avg_price_per_m2_rub, array_agg(DISTINCT obj_id) AS competitor_obj_ids, COUNT(DISTINCT obj_id) AS competitor_count, MIN(window_start) AS window_start, MAX(window_end) AS window_end FROM mv_layout_velocity WHERE obj_id = ANY(ARRAY[1, 2, 3]) GROUP BY room_bucket """, ) # ── 4. domrf_kn_flats ───────────────────────────────────────────────────────── class TestDomrfKnFlats: """Проверяем таблицу domrf_kn_flats и её колонки.""" def test_supply_batch_query(self, phantom_check_session: Session) -> None: """best_layouts._SUPPLY_BATCH_SQL — total_area, rooms, is_studio, flat_type. Зеркалит objects-first MATERIALIZED CTE (latest-snapshot dedup, anti-fan-out) с хардкод-значениями вместо bind-параметров — EXPLAIN-гейт на phantom-колонки остаётся осмысленным после перехода с flats-first на objects-first. """ _explain( phantom_check_session, """ WITH latest_obj AS ( SELECT DISTINCT ON (o.obj_id) o.obj_id, o.latitude, o.longitude FROM domrf_kn_objects o WHERE o.latitude IS NOT NULL AND o.longitude IS NOT NULL ORDER BY o.obj_id, o.snapshot_date DESC ), nearby AS MATERIALIZED ( SELECT lo.obj_id FROM latest_obj lo WHERE ST_DWithin( ST_SetSRID(ST_MakePoint(lo.longitude, lo.latitude), 4326)::geography, ST_SetSRID( ST_MakePoint(CAST(60.6 AS float), CAST(56.84 AS float)), 4326 )::geography, CAST(1500 AS float) ) ) SELECT CASE WHEN f.is_studio = TRUE OR f.flat_type = 'Квартира-студия' THEN 'studio' WHEN f.rooms = 0 THEN 'studio' WHEN f.rooms IN (1, 2, 3) THEN f.rooms::text WHEN f.rooms >= 4 THEN '4+' ELSE '1' END AS rb, CASE WHEN f.total_area < 25 THEN '<25' WHEN f.total_area < 40 THEN '25-40' WHEN f.total_area < 60 THEN '40-60' WHEN f.total_area < 80 THEN '60-80' WHEN f.total_area < 100 THEN '80-100' ELSE '100+' END AS ab, COUNT(*) AS units FROM nearby n JOIN domrf_kn_flats f ON f.obj_id = n.obj_id AND f.snapshot_date = CAST('2026-01-01' AS date) GROUP BY rb, ab """, ) def test_avg_price_query(self, phantom_check_session: Session) -> None: """competitors._AVG_PRICE_SQL — price_per_m2 на последнем snapshot. #1926: status больше НЕ используется (был ~0.2%-заполнен). avg_price считается по всем квартирам с known price_per_m2 на latest snapshot; sold-count вынесен в _SOLD_COUNT_SQL (objective_lots.contract_date). """ _explain( phantom_check_session, """ SELECT f.obj_id, AVG(f.price_per_m2) FILTER (WHERE f.price_per_m2 IS NOT NULL) AS avg_price_per_m2 FROM domrf_kn_flats f WHERE f.obj_id = ANY(ARRAY[1, 2, 3]) AND f.snapshot_date = ( SELECT MAX(snapshot_date) FROM domrf_kn_flats ) GROUP BY f.obj_id """, ) # ── 5. objective_complex_mapping / objective_corpus_room_month ──────────────── class TestObjectiveTables: """Проверяем objective_* таблицы и их колонки.""" def test_objective_mapping_columns(self, phantom_check_session: Session) -> None: """competitors / velocity: domrf_obj_id, objective_complex_name в mapping.""" _explain( phantom_check_session, """ SELECT cm.domrf_obj_id AS obj_id, cm.objective_complex_name FROM objective_complex_mapping cm LIMIT 1 """, ) def test_sold_count_query(self, phantom_check_session: Session) -> None: """competitors._SOLD_COUNT_SQL (#1926, code-review #1927) — mapping → lots. flats_sold выровнен с MarketMetrics._STOCK_SQL: предикат is_sold/contract_date/status='продан' + premise_kind='квартира'. COUNT(DISTINCT objective_lot_id) — fan-out-safe (mapping НЕ unique по domrf_obj_id). Мост objective_complex_mapping.domrf_obj_id → objective_complex_name == objective_lots.project_name. """ _explain( phantom_check_session, """ SELECT cm.domrf_obj_id AS obj_id, COUNT(DISTINCT ol.objective_lot_id) FILTER ( WHERE ol.is_sold IS TRUE OR ol.contract_date IS NOT NULL OR LOWER(COALESCE(ol.status, '')) = 'продан' ) AS flats_sold FROM objective_complex_mapping cm JOIN objective_lots ol ON ol.project_name = cm.objective_complex_name WHERE cm.domrf_obj_id = ANY(ARRAY[1, 2, 3]) AND ol.premise_kind = 'квартира' GROUP BY cm.domrf_obj_id """, ) def test_objective_corpus_room_month_columns(self, phantom_check_session: Session) -> None: """velocity._VELOCITY_BY_ROOM_SQL — deals_total_count, deals_total_vol_m2.""" _explain( phantom_check_session, """ SELECT m.obj_id, SUM(COALESCE(crm.deals_total_vol_m2, crm.deals_total_count * 45.0)) AS total_sqm, COUNT(DISTINCT crm.report_month) AS months_with_data, MIN(crm.report_month) AS period_start, MAX(crm.report_month) AS period_end FROM objective_corpus_room_month crm JOIN ( SELECT cm.domrf_obj_id AS obj_id, cm.objective_complex_name FROM objective_complex_mapping cm WHERE cm.domrf_obj_id = ANY(ARRAY[1, 2, 3]) ) m ON m.objective_complex_name = crm.project_name WHERE crm.report_month >= (CURRENT_DATE - CAST('12 months' AS interval)) AND crm.deals_total_count > 0 GROUP BY m.obj_id """, ) # ── 6. cad geo таблицы ──────────────────────────────────────────────────────── class TestCadGeoTables: """Проверяем cad_parcels_geom, cad_quarters_geom и их колонки.""" def test_parcel_centroid_query(self, phantom_check_session: Session) -> None: """best_layouts._PARCEL_CENTROID_SQL — cad_num, cad_number, geom.""" _explain( phantom_check_session, """ SELECT ST_X(pt) AS center_lon, ST_Y(pt) AS center_lat FROM ( SELECT ST_Centroid(geom) AS pt FROM cad_parcels_geom WHERE cad_num = '66:41:0303161:123' AND geom IS NOT NULL UNION ALL SELECT ST_Centroid(geom) AS pt FROM cad_quarters_geom WHERE cad_number = '66:41:0303161' AND geom IS NOT NULL ) sub LIMIT 1 """, )