gendesign/tradein-mvp/backend/app/services/search_query.py
lekss361 9643979d2c
All checks were successful
Deploy Trade-In / deploy (push) Successful in 26s
Deploy Trade-In / changes (push) Successful in 5s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-backend (push) Successful in 56s
feat(tradein): Phase 3.2 — /api/v1/search endpoint + Redis cache (#479)
POST /api/v1/search reading listings_search_mv with ~20 cross-source filters, parameterized SQL (CAST(:x AS type), psycopg v3), whitelisted ORDER BY (Pydantic Literal), Redis 5min hot cache with graceful degradation (singleton pool via lru_cache).

Verified vs data/sql/050_search_optimization.sql: matview column refs (total_area, lng, house_rating, sources[], has_avito/cian/yandex), SQL injection safety, cache failure swallow, router prefix.

Deep-code-reviewer: APPROVE.
2026-05-23 14:44:22 +00:00

138 lines
5.3 KiB
Python

"""SQL builder for /api/v1/search — parameterized, psycopg v3 (CAST(:x AS type))."""
from __future__ import annotations
from app.schemas.search import SearchParams
_SORT_SQL: dict[str, str] = {
"price_asc": "price_rub ASC NULLS LAST",
"price_desc": "price_rub DESC NULLS LAST",
"area_desc": "total_area DESC NULLS LAST",
"area_asc": "total_area ASC NULLS LAST",
"date_desc": "scraped_at DESC NULLS LAST",
"dist_asc": (
"ST_Distance(geom::geography, "
"ST_MakePoint(CAST(:lon AS double precision), "
"CAST(:lat AS double precision))::geography) ASC"
),
}
def build_search_query(params: SearchParams) -> tuple[str, dict[str, object]]:
"""Возвращает (sql, args) для SELECT из listings_search_mv."""
where: list[str] = ["1=1"]
args: dict[str, object] = {}
if params.lat is not None and params.lon is not None:
where.append(
"ST_DWithin(geom::geography, "
"ST_MakePoint(CAST(:lon AS double precision), "
"CAST(:lat AS double precision))::geography, "
"CAST(:radius_m AS integer))"
)
args["lat"] = params.lat
args["lon"] = params.lon
args["radius_m"] = params.radius_m
if params.rooms is not None:
where.append("rooms = CAST(:rooms AS integer)")
args["rooms"] = params.rooms
if params.rooms_in:
where.append("rooms = ANY(CAST(:rooms_in AS integer[]))")
args["rooms_in"] = params.rooms_in
if params.area_m2_min is not None:
where.append("total_area >= CAST(:area_min AS double precision)")
args["area_min"] = params.area_m2_min
if params.area_m2_max is not None:
where.append("total_area <= CAST(:area_max AS double precision)")
args["area_max"] = params.area_m2_max
if params.price_rub_min is not None:
where.append("price_rub >= CAST(:price_min AS bigint)")
args["price_min"] = params.price_rub_min
if params.price_rub_max is not None:
where.append("price_rub <= CAST(:price_max AS bigint)")
args["price_max"] = params.price_rub_max
if params.price_per_m2_max is not None:
where.append("price_per_m2 <= CAST(:ppm2_max AS bigint)")
args["ppm2_max"] = params.price_per_m2_max
if params.floor_min is not None:
where.append("floor >= CAST(:floor_min AS integer)")
args["floor_min"] = params.floor_min
if params.floor_max is not None:
where.append("floor <= CAST(:floor_max AS integer)")
args["floor_max"] = params.floor_max
if params.year_built_min is not None:
where.append("year_built >= CAST(:yb_min AS integer)")
args["yb_min"] = params.year_built_min
if params.year_built_max is not None:
where.append("year_built <= CAST(:yb_max AS integer)")
args["yb_max"] = params.year_built_max
if params.house_class:
where.append("house_class = ANY(CAST(:hclass AS text[]))")
args["hclass"] = params.house_class
if params.floors_total_min is not None:
where.append("total_floors >= CAST(:fl_total_min AS integer)")
args["fl_total_min"] = params.floors_total_min
if params.floors_total_max is not None:
where.append("total_floors <= CAST(:fl_total_max AS integer)")
args["fl_total_max"] = params.floors_total_max
if params.has_kadastr:
where.append("kadastr_num IS NOT NULL")
if params.sources:
where.append("sources && CAST(:sources AS text[])")
args["sources"] = params.sources
if params.multi_source_only:
where.append("source_count >= 2")
if params.require_avito:
where.append("has_avito = true")
if params.require_cian:
where.append("has_cian = true")
if params.require_yandex:
where.append("has_yandex = true")
if params.address_query:
where.append("address ILIKE CAST(:addr_like AS text)")
args["addr_like"] = f"%{params.address_query}%"
if params.description_query:
where.append("tsv @@ plainto_tsquery('russian', CAST(:descq AS text))")
args["descq"] = params.description_query
where_sql = " AND ".join(where)
order_sql = _SORT_SQL[params.sort]
sql = (
"SELECT listing_id, source, source_url, address, lat, lng, "
"rooms, total_area, floor, total_floors, price_rub, price_per_m2, "
"kadastr_num, scraped_at, "
"house_id, year_built, house_class, developer_name, "
"house_rating, house_ratings_count, "
"source_count, sources, has_avito, has_cian, has_yandex, "
"house_median_ppm2, district "
"FROM listings_search_mv "
f"WHERE {where_sql} "
f"ORDER BY {order_sql} "
"LIMIT CAST(:limit AS integer) OFFSET CAST(:offset AS integer)"
)
args["limit"] = params.page_size
args["offset"] = params.offset
return sql, args
def build_count_query(params: SearchParams) -> tuple[str, dict[str, object]]:
"""COUNT(*) — отдельный запрос для total."""
sql, args = build_search_query(params)
where_start = sql.find("WHERE ")
order_start = sql.find(" ORDER BY ")
where_clause = sql[where_start:order_start]
count_args = {k: v for k, v in args.items() if k not in ("limit", "offset")}
count_sql = f"SELECT count(*) AS total FROM listings_search_mv {where_clause}"
return count_sql, count_args