From fd328f75e0518eaa8f18b6dfe701d7ab67bc2d67 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 13 Aug 2026 12:16:56 +0500 Subject: [PATCH] =?UTF-8?q?chore(tradein/db):=20=D1=81=D0=BD=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D0=B8=20=D1=82=D1=80=D0=B8=20=D0=BA=D0=BE=D0=BB=D0=BE?= =?UTF-8?q?=D0=BD=D0=BA=D0=B8-=D0=B7=D0=B0=D0=B3=D0=BB=D1=83=D1=88=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=B8=D0=B7=20=D0=B2=D0=B8=D1=82=D1=80=D0=B8=D0=BD?= =?UTF-8?q?=D1=8B=20=D0=BF=D0=BE=D0=B8=D1=81=D0=BA=D0=B0=20(#2857)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit listings_search_mv с 050 отдавала distance_to_metro_m, last_price_change и photos_count литеральным NULL: имена зарезервировали, реализацию не подключили никогда. Читателей ноль — `git grep` по origin/main даёт 8 попаданий, и все восемь это сами файлы 050/094; SELECT * по витрине в репозитории нет ни одного, рефлексии тоже, единственный читатель (services/search_query.py) перечисляет 27 колонок явно и ни одну из трёх не просит. Правок кода миграция не требует. district оставлен намеренно: он объявлен в schemas/search_response.py, то есть API его отдаёт, и снос — ломающее изменение контракта, решение владельца. Материализованному представлению нельзя удалить колонку (проверено на одноразовой БД: ALTER MATERIALIZED VIEW и ALTER TABLE одинаково отвечают «not supported for materialized views»), поэтому пересоздание — как в 094. Без CASCADE: зависимых объектов на проде ноль, а появись зависимость — деплой обязан покраснеть, а не снести её молча. Гранты снимаются и переигрываются в той же транзакции, а не переносятся руками: DROP уносит ACL, и ручной слепок протухает молча, если файл пролежит до деплоя. Сегодня переигрывать нечего (relacl витрины — только владелец), но первая редакция блока теряла колоночные гранты — это поймал прогон на одноразовой БД, не рассуждение, и снимок теперь берёт и pg_attribute.attacl. Все 6 индексов воссоздаются, включая UNIQUE по listing_id — без него ночной REFRESH ... CONCURRENTLY молчит до самой ночи, а потом падает. Тест собирает список колонок разбором актуального определения витрины и краснеет на составе списка, а не на подстроке: переформатирование SQL его не трогает, возврат колонки — трогает. Отдельно сверяет, что API не просит у витрины колонок, которых в ней нет. Refs #2857 --- ...ngs_search_mv_drop_placeholder_columns.sql | 270 ++++++++++++++++++ .../backend/data/sql/_manifest_applied.txt | 1 + ...test_2857_search_mv_placeholder_columns.py | 145 ++++++++++ 3 files changed, 416 insertions(+) create mode 100644 tradein-mvp/backend/data/sql/261_listings_search_mv_drop_placeholder_columns.sql create mode 100644 tradein-mvp/backend/tests/test_2857_search_mv_placeholder_columns.py diff --git a/tradein-mvp/backend/data/sql/261_listings_search_mv_drop_placeholder_columns.sql b/tradein-mvp/backend/data/sql/261_listings_search_mv_drop_placeholder_columns.sql new file mode 100644 index 00000000..751f9e36 --- /dev/null +++ b/tradein-mvp/backend/data/sql/261_listings_search_mv_drop_placeholder_columns.sql @@ -0,0 +1,270 @@ +-- 261_listings_search_mv_drop_placeholder_columns.sql +-- Issue #2857 (эпик #2674) — снос трёх колонок-заглушек из listings_search_mv: +-- distance_to_metro_m, last_price_change, photos_count. +-- +-- Dependencies: 050_search_optimization.sql (завела витрину и 6 индексов), +-- 094_cadastral_unify.sql (последняя пересоздала витрину; её текст +-- и есть текущее прод-определение, сверено с pg_matviews 13.08.2026 — +-- расхождений нет), 088_scrape_schedules_seed_search_matview_refresh.sql +-- (суточный REFRESH ... CONCURRENTLY). +-- Apply after: 260_houses_drop_has_panorama.sql +-- Deploy order: схема и код независимы — у трёх колонок НЕТ читателей, поэтому +-- правки кода этот PR не несёт и порядок «миграция ↔ образ» безразличен. +-- +-- ── ЧТО ЗА НОЛЬ ──────────────────────────────────────────────────────────── +-- Не потеря данных и не оборванный писатель: NULL прописан в самом определении +-- витрины литералом. Четвёртый вид нуля — ОБЕЩАНИЕ В КОНТРАКТЕ БЕЗ РЕАЛИЗАЦИИ: +-- имена зарезервировали в 050, реализацию не подключили никогда. +-- +-- pg_stats по listings_search_mv, 13.08.2026 (45 310 строк): +-- null_frac = 1.0 у 5 колонок: cadastral_number, district, +-- distance_to_metro_m, last_price_change, photos_count. +-- Сносим три. После применения колонок с null_frac = 1.0 останется 2 +-- (cadastral_number — живая колонка с писателем, просто площадки её не отдают, +-- см. 216/search_query.py; district — вынесен решением владельца, ниже). +-- +-- ЧИТАТЕЛЕЙ НОЛЬ — перепроверено на origin/main, не по памяти: +-- `git grep -E "distance_to_metro_m|last_price_change|photos_count" origin/main` +-- даёт 8 строк, и все 8 — сами файлы 050 и 094 (объявление + комментарий над ним). +-- Ни бэкенда, ни фронта, ни тестов, ни скриптов. Отдельно проверено, что колонки +-- не уезжают в ответ через звёздочку: `SELECT *` из listings_search_mv в репозитории +-- НЕТ ни одного (единственный читатель — services/search_query.py, там явный +-- список из 27 имён), и SQLAlchemy-рефлексии витрины тоже нет. +-- +-- DISTRICT НЕ ТРОГАЕМ, хотя он такой же пустой. Он доехал дальше всех: его тянет +-- services/search_query.py:138 и объявляет schemas/search_response.py:44 +-- (`district: str | None`), то есть API его ОТДАЁТ — всегда null. Снос = ломающее +-- изменение контракта, решение владельца, вынесено отдельным пунктом в #2857. +-- Здесь он воспроизводится байт-в-байт (`NULL::text AS district`). +-- +-- ── ПОЧЕМУ DROP + CREATE, А НЕ ALTER ─────────────────────────────────────── +-- Материализованному представлению нельзя удалить колонку: ALTER MATERIALIZED VIEW +-- такой формы не имеет, а ALTER TABLE ... DROP COLUMN на relkind='m' отказывает. +-- Единственный путь — пересоздание, как в 094. +-- +-- БЕЗ CASCADE. Зависимых объектов на проде ноль (проверено через pg_depend/pg_rewrite +-- 13.08.2026: 0 строк). Если зависимость появится до применения — DROP упрётся и +-- деплой честно покраснеет; CASCADE снёс бы её молча. +-- +-- ── ГРАНТЫ: ЛОВУШКА, КОТОРАЯ ЗДЕСЬ НЕ СРАБАТЫВАЕТ, НО ПРИКРЫТА ───────────── +-- DROP уносит ACL вместе с объектом — это уже кусало (C3, FDW-гранты после +-- DROP ... CASCADE; 260 восстанавливала GRANT SELECT для gendesign_reader вручную). +-- На listings_search_mv восстанавливать сегодня НЕЧЕГО, и это измерено, а не +-- предположено: +-- pg_class.relacl = {tradein=arwdDxt/tradein} — только владелец, ни одного +-- стороннего grantee; column-level грантов нет; pg_default_acl пуст. +-- (information_schema.role_table_grants по витрине пуст ВСЕГДА и ничего не +-- доказывает: information_schema не показывает материализованные представления +-- в принципе — смотреть надо relacl. Это и есть тот источник, где ловушку легко +-- проглядеть.) +-- Для сравнения: gendesign_reader имеет SELECT на listings и offer_price_history — +-- на витрину ему не давали. +-- Тем не менее ACL снимается и переигрывается ниже автоматически: между написанием +-- файла и его применением на проде может пройти неделя, и ручной слепок к тому +-- моменту протухнет молча. Снимок берётся в той же транзакции, что и DROP, поэтому +-- врать не может. +-- +-- ── ИНДЕКСЫ ──────────────────────────────────────────────────────────────── +-- Пересоздаются все 6 (прод, 13.08.2026 — совпадают с 050/094 один в один). +-- UNIQUE listings_search_mv_id_idx (listing_id) обязателен: без него суточный +-- REFRESH MATERIALIZED VIEW CONCURRENTLY (app/tasks/refresh_search_matview.py, +-- расписание refresh_search_matview 03:00-04:00 UTC) упадёт с +-- «cannot refresh materialized view concurrently ... no unique index». +-- +-- ── ЦЕНА ПЕРЕСОЗДАНИЯ И БЛОКИРОВКА ───────────────────────────────────────── +-- Транзакция держит ACCESS EXCLUSIVE на витрине от DROP до COMMIT, т.е. читатели +-- ждут всё построение. Замер на проде (EXPLAIN ANALYZE тела витрины, 13.08.2026): +-- сам SELECT 6.6 s на прогретом кэше; плюс 6 индексов (GIN tsv 19 МБ, GIN trgm +-- 17 МБ, остальные мелочь) при maintenance_work_mem = 64 МБ — ориентир 30-60 s +-- на всю транзакцию. Для сравнения, суточный CONCURRENTLY-рефреш укладывается в +-- 9-17 s, но он делает вдвое больше работы (строит + сливает). +-- Простой READ-трафика приемлем: за всё время жизни БД (pg_stat_database.stats_reset +-- пуст, т.е. счётчики ни разу не сбрасывались) витрина видела 225 seq_scan и +-- 63 idx_scan — а суточный CONCURRENTLY-рефреш сам по себе даёт по seq_scan в день. +-- То есть /api/v1/search к ней практически не ходит, и трюк «собрать под временным +-- именем + переименовать» (12 лишних строк ради миллисекунд вместо минуты) не нужен. +-- +-- SET LOCAL lock_timeout = '5s' — ограничивает ОЖИДАНИЕ выдачи лока, не работу под +-- ним (см. .claude/rules/sql.md § lock_timeout). Ждущий ACCESS EXCLUSIVE встаёт в +-- очередь ПЕРЕД новыми запросами. Отдельный реальный конфликт здесь: если деплой +-- попадёт в окно 03:00-04:00 UTC, DROP столкнётся с REFRESH ... CONCURRENTLY → +-- честный красный деплой через 5 s, миграция не помечается применённой, повторный +-- деплой пройдёт. +-- +-- IDEMPOTENCY / SAFETY: +-- - DROP MATERIALIZED VIEW IF EXISTS + CREATE — повторный прогон приводит к тому +-- же состоянию (ценой ещё одного построения). Индексы создаются на заведомо +-- новом объекте, поэтому без IF NOT EXISTS (как в 050/094). +-- - Данных не теряем: витрина целиком выводима из listings/houses/listing_sources. +-- - Откат: вернуть три строки `NULL::...` в определение и пересоздать тем же +-- способом. Восстанавливать нечего — значений не существовало. +-- +-- КРИТЕРИЙ ПРИЁМКИ (записан ДО применения): +-- 1. Строка `261_listings_search_mv_drop_placeholder_columns.sql` в +-- _schema_migrations (а не «деплой зелёный»). +-- 2. Колонок в витрине 31 (было 34); distance_to_metro_m / last_price_change / +-- photos_count отсутствуют; district на месте, тип text. +-- 3. pg_matviews.definition не содержит подстроки 'distance_to_metro_m'. +-- 4. Индексов 6, среди них UNIQUE listings_search_mv_id_idx. +-- 5. pg_class.relacl витрины эквивалентен доприменительному (сегодня — владелец +-- и никого больше). +-- 6. SELECT count(*) FROM listings_search_mv отдаёт 40k+ строк. +-- 7. Следующий ночной refresh_search_matview завершается status='done' +-- (доказательство, что CONCURRENTLY не потерял UNIQUE-индекс). +-- 8. Ответ /api/v1/search по-прежнему содержит ключ district (и не содержит +-- удалённых — их там и не было). + +BEGIN; + +-- Ограничивает ОЖИДАНИЕ лока, не работу под ним. Обоснование — в шапке. +SET LOCAL lock_timeout = '5s'; + +-- ── 1. Снимок ACL ДО сноса ───────────────────────────────────────────────── +-- aclexplode(NULL) даёт 0 строк — на витрине без явного ACL блок просто пуст. +-- Владельца исключаем: CREATE вернёт его права сам. +-- Колоночные гранты (pg_attribute.attacl) снимаются ОТДЕЛЬНОЙ веткой: они живут +-- не в relacl, и первая редакция этого файла их молча теряла — поймано прогоном +-- на одноразовой БД, а не рассуждением. +CREATE TEMP TABLE _mv2857_acl ON COMMIT DROP AS +SELECT + CASE WHEN a.grantee = 0 THEN 'PUBLIC' ELSE a.grantee::regrole::text END AS grantee, + a.privilege_type, + a.is_grantable, + NULL::text AS column_name +FROM pg_class c +JOIN pg_namespace n ON n.oid = c.relnamespace +CROSS JOIN LATERAL aclexplode(c.relacl) AS a +WHERE n.nspname = 'public' + AND c.relname = 'listings_search_mv' + AND c.relkind = 'm' + AND a.grantee <> c.relowner +UNION ALL +SELECT + CASE WHEN a.grantee = 0 THEN 'PUBLIC' ELSE a.grantee::regrole::text END, + a.privilege_type, + a.is_grantable, + quote_ident(att.attname) +FROM pg_class c +JOIN pg_namespace n ON n.oid = c.relnamespace +JOIN pg_attribute att ON att.attrelid = c.oid AND att.attnum > 0 AND NOT att.attisdropped +CROSS JOIN LATERAL aclexplode(att.attacl) AS a +WHERE n.nspname = 'public' + AND c.relname = 'listings_search_mv' + AND c.relkind = 'm' + AND a.grantee <> c.relowner; + +-- ── 2. Пересоздание витрины без трёх заглушек ────────────────────────────── +DROP MATERIALIZED VIEW IF EXISTS listings_search_mv; + +CREATE MATERIALIZED VIEW listings_search_mv AS +SELECT + l.id AS listing_id, + l.source, + l.source_url, + l.address, + l.geom, + l.lat, + l.lon AS lng, + l.rooms, + l.area_m2 AS total_area, + l.floor, + l.total_floors, + l.price_rub, + l.price_per_m2, + l.cadastral_number, + l.is_active, + l.scraped_at, + -- House denorm + h.id AS house_id, + h.year_built, + h.house_class, + h.developer_name, + h.rating AS house_rating, + h.reviews_count AS house_ratings_count, + -- Cross-source aggregates + (SELECT count(*) FROM listing_sources ls WHERE ls.listing_id = l.id) AS source_count, + (SELECT array_agg(DISTINCT ext_source) FROM listing_sources ls WHERE ls.listing_id = l.id) AS sources, + (SELECT bool_or(ext_source = 'avito') FROM listing_sources ls WHERE ls.listing_id = l.id) AS has_avito, + (SELECT bool_or(ext_source = 'cian') FROM listing_sources ls WHERE ls.listing_id = l.id) AS has_cian, + (SELECT bool_or(ext_source = 'yandex_realty') FROM listing_sources ls WHERE ls.listing_id = l.id) AS has_yandex, + -- Price percentile within house + (SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY ll.price_per_m2) + FROM listings ll + WHERE ll.house_id_fk = l.house_id_fk AND ll.is_active = true) AS house_median_ppm2, + -- Заглушка, оставленная СОЗНАТЕЛЬНО: district доезжает до схемы ответа API + -- (schemas/search_response.py), снос — ломающее изменение контракта, решение + -- владельца (#2857). Соседние distance_to_metro_m / last_price_change / + -- photos_count сняты здесь: у них не было ни одного читателя. + NULL::text AS district, + -- Trigram-ready columns + l.address AS address_trgm, + -- Aggregated tsv (description + address + developer_name) + to_tsvector('russian', + coalesce(l.description, '') || ' ' || + coalesce(l.address, '') || ' ' || + coalesce(h.developer_name, '') + ) AS tsv +FROM listings l +LEFT JOIN houses h ON h.id = l.house_id_fk +WHERE l.is_active = true + AND COALESCE(l.canonical, true) = true; + +-- ── 3. Те же 6 индексов (050/094) ────────────────────────────────────────── +-- UNIQUE — обязателен для REFRESH ... CONCURRENTLY, см. шапку. +CREATE UNIQUE INDEX listings_search_mv_id_idx + ON listings_search_mv (listing_id); + +CREATE INDEX listings_search_mv_geom_idx + ON listings_search_mv USING GIST (geom); + +CREATE INDEX listings_search_mv_filters_idx + ON listings_search_mv (rooms, price_rub, total_area, scraped_at DESC); + +CREATE INDEX listings_search_mv_address_trgm_idx + ON listings_search_mv USING GIN (address_trgm gin_trgm_ops); + +CREATE INDEX listings_search_mv_tsv_idx + ON listings_search_mv USING GIN (tsv); + +CREATE INDEX listings_search_mv_sources_idx + ON listings_search_mv (has_avito, has_cian, has_yandex); + +-- ── 4. Возврат грантов, снятых в п.1 ─────────────────────────────────────── +-- Пусто, если сторонних grantee не было (сегодня — так). privilege_type приходит +-- из системного каталога, поэтому подставляется как есть. +-- Если у кого-то окажется колоночный грант ИМЕННО на снесённую колонку — GRANT +-- упадёт на несуществующем имени, и это правильно: такой грант означает читателя, +-- которого мы не нашли, и деплой обязан покраснеть, а не молча снести колонку. +DO $$ +DECLARE + r record; +BEGIN + FOR r IN SELECT grantee, privilege_type, is_grantable, column_name FROM _mv2857_acl LOOP + EXECUTE format( + 'GRANT %s%s ON TABLE public.listings_search_mv TO %s%s', + r.privilege_type, + CASE WHEN r.column_name IS NULL THEN '' ELSE ' (' || r.column_name || ')' END, + r.grantee, + CASE WHEN r.is_grantable THEN ' WITH GRANT OPTION' ELSE '' END + ); + RAISE NOTICE 'listings_search_mv: возвращён GRANT % % для %', + r.privilege_type, coalesce('(' || r.column_name || ')', 'на витрину'), r.grantee; + END LOOP; +END +$$; + +-- ── 5. Статистика сразу, а не «когда-нибудь придёт autoanalyze» ──────────── +-- Иначе планировщик до первого автоанализа работает по пустым оценкам, а критерий +-- приёмки по pg_stats нечем проверить. +ANALYZE listings_search_mv; + +COMMENT ON MATERIALIZED VIEW listings_search_mv IS + 'Витрина поиска (/api/v1/search, 050/094). #2857: сняты три колонки-заглушки ' + 'distance_to_metro_m / last_price_change / photos_count — литеральный NULL в ' + 'определении, ноль читателей во всём репозитории. district оставлен намеренно: ' + 'он объявлен в schemas/search_response.py, его снос — ломающее изменение ' + 'контракта API и решение владельца. Единственный читатель витрины — ' + 'services/search_query.py с ЯВНЫМ списком колонок; SELECT * по ней запрещён ' + 'по той же причине, что и по market.v_houses.'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/_manifest_applied.txt b/tradein-mvp/backend/data/sql/_manifest_applied.txt index 80ff61ee..6b449178 100644 --- a/tradein-mvp/backend/data/sql/_manifest_applied.txt +++ b/tradein-mvp/backend/data/sql/_manifest_applied.txt @@ -249,3 +249,4 @@ 258_houses_imv_transient_attempts.sql 259_data_quality_drop_pct_cadastr.sql 260_houses_drop_has_panorama.sql +261_listings_search_mv_drop_placeholder_columns.sql diff --git a/tradein-mvp/backend/tests/test_2857_search_mv_placeholder_columns.py b/tradein-mvp/backend/tests/test_2857_search_mv_placeholder_columns.py new file mode 100644 index 00000000..9df5b583 --- /dev/null +++ b/tradein-mvp/backend/tests/test_2857_search_mv_placeholder_columns.py @@ -0,0 +1,145 @@ +"""Витрина поиска не обещает колонок, которых не заполняет (#2857, эпик #2674). + +`listings_search_mv` с 050 несла четыре колонки, заданные литералом `NULL` прямо +в определении: district, distance_to_metro_m, last_price_change, photos_count. +Это не потеря данных и не оборванный писатель — имена зарезервировали, реализацию +не подключили никогда. Три из них не читает НИКТО (ни бэкенд, ни фронт, ни тесты) +и они сняты миграцией 261; district оставлен намеренно — он объявлен в +schemas/search_response.py, то есть API его отдаёт, и его снос это ломающее +изменение контракта (решение владельца, вынесено отдельно в #2857). + +Проверяется ФАКТ, а не текст: тест собирает СПИСОК КОЛОНОК витрины разбором её +актуального определения (самый старший NN среди файлов, создающих витрину) и +смотрит на состав списка. Переформатирование SQL, перестановка строк или смена +`NULL::int` на `NULL::integer` тест не трогают; возврат колонки — краснит. + +Без БД и сети: миграции читаются как текст, разбираются в структуру. +""" + +from __future__ import annotations + +import os +import re +from pathlib import Path + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql" + +MV = "listings_search_mv" +# Сняты 261: ноль читателей во всём репозитории на момент сноса. +DROPPED = ("distance_to_metro_m", "last_price_change", "photos_count") + + +def _strip_sql_comments(sql: str) -> str: + sql = re.sub(r"/\*.*?\*/", " ", sql, flags=re.DOTALL) + return re.sub(r"--[^\n]*", "", sql) + + +def _latest_definition() -> str: + """Текст файла с самым старшим NN, который создаёт витрину = её актуальный вид.""" + creators = [ + p + for p in SQL_DIR.glob("*.sql") + if re.search( + rf"CREATE\s+MATERIALIZED\s+VIEW\s+{MV}\b", + _strip_sql_comments(p.read_text("utf-8")), + re.I, + ) + ] + assert creators, f"ни одна миграция не создаёт {MV} — тест смотрит не туда" + return max(creators, key=lambda p: int(p.name.split("_", 1)[0])).read_text("utf-8") + + +def mv_columns() -> list[str]: + """Имена колонок витрины в порядке объявления. + + Разбор: от `AS SELECT` до `FROM` на нулевой глубине скобок, разрез по запятым + той же глубины, имя колонки — последний идентификатор элемента (алиас после + `AS` либо хвост `l.foo`). + """ + sql = _strip_sql_comments(_latest_definition()) + body = re.split(rf"CREATE\s+MATERIALIZED\s+VIEW\s+{MV}\s+AS\s+SELECT\b", sql, flags=re.I)[1] + + depth, items, cur = 0, [], [] + for token in re.finditer(r"\(|\)|,|\bFROM\b|[^(),]+", body, re.I): + t = token.group(0) + if t == "(": + depth += 1 + elif t == ")": + depth -= 1 + elif depth == 0 and t == ",": + items.append("".join(cur)) + cur = [] + continue + elif depth == 0 and t.upper() == "FROM": + break + cur.append(t) + items.append("".join(cur)) + + return [item.split()[-1].split(".")[-1] for item in items if item.split()] + + +def test_placeholder_columns_are_gone_from_the_matview() -> None: + """Red => витрина снова обещает поля, которых не заполняет (#2857). + + Три колонки были литеральным `NULL` без единого читателя. Если тест покраснел + после возврата колонки — сначала заведи писателя, потом колонку, а не наоборот. + """ + cols = mv_columns() + still_there = [c for c in DROPPED if c in cols] + assert not still_there, ( + f"{MV} снова отдаёт колонки-заглушки {still_there}. Колонка без писателя " + "читается снаружи как «данные есть, просто у этого объекта пусто» — это " + "хуже мёртвого кода, потому что видно в контракте." + ) + + +def test_district_is_deliberately_kept() -> None: + """Red => district снесли заодно, а он в схеме ответа API. + + schemas/search_response.py объявляет `district: str | None`, services/search_query.py + его тянет — снос ломает контракт /api/v1/search. Это решение владельца (#2857), + а не побочный эффект уборки соседних заглушек. Убирать — вместе со схемой ответа. + """ + assert "district" in mv_columns(), ( + f"district пропал из {MV}, а schemas/search_response.py его всё ещё объявляет: " + "ответ поиска начнёт падать/врать. Снимать поле — только вместе со схемой." + ) + + +def test_search_api_selects_only_columns_the_matview_has() -> None: + """Настоящий инвариант: то, что просит API, витрина обязана иметь. + + Именно эта проверка отличает «список колонок» от «поиска подстроки»: она + краснеет на ЛЮБОЙ колонке, снесённой без правки читателя, а не только на трёх + известных именах. + """ + from app.schemas.search import SearchParams + from app.services.search_query import build_search_query + + sql, _ = build_search_query(SearchParams()) + selected = [ + c.strip() for c in sql[len("SELECT ") : sql.index(f" FROM {MV}")].split(",") if c.strip() + ] + missing = [c for c in selected if c not in mv_columns()] + assert not missing, ( + f"services/search_query.py просит у {MV} колонки, которых в её определении нет: " + f"{missing}. Либо верни колонку в витрину, либо убери её из запроса И из " + "schemas/search_response.py." + ) + + +def test_unique_index_for_concurrent_refresh_survives_recreation() -> None: + """Red => ночной REFRESH ... CONCURRENTLY упадёт. + + app/tasks/refresh_search_matview.py рефрешит витрину CONCURRENTLY (расписание + refresh_search_matview, 03:00-04:00 UTC). Без UNIQUE-индекса PostgreSQL отвечает + «cannot refresh materialized view concurrently ... no unique index» — а витрина, + которую пересоздали и забыли проиндексировать, молчит до самой ночи. + """ + sql = _strip_sql_comments(_latest_definition()) + assert re.search(rf"CREATE\s+UNIQUE\s+INDEX[^;]+ON\s+{MV}\s*\(\s*listing_id\s*\)", sql, re.I), ( + f"в актуальном определении {MV} нет UNIQUE-индекса по listing_id — " + "REFRESH MATERIALIZED VIEW CONCURRENTLY без него невозможен." + )