chore(tradein/db): снести три колонки-заглушки из витрины поиска (#2857)
All checks were successful
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 9s
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m21s
All checks were successful
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 9s
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m21s
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
This commit is contained in:
parent
06c322c167
commit
fd328f75e0
3 changed files with 416 additions and 0 deletions
|
|
@ -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;
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 без него невозможен."
|
||||
)
|
||||
Loading…
Add table
Reference in a new issue