Some checks failed
CI / backend-tests (push) Has been cancelled
CI / frontend-tests (push) Has been cancelled
CI / openapi-codegen-check (push) Has been cancelled
CI / changes (push) Has been cancelled
CI / changes (pull_request) Successful in 6s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been cancelled
CI / openapi-codegen-check (pull_request) Has been cancelled
154 lines
6.1 KiB
Python
154 lines
6.1 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"
|
|
),
|
|
}
|
|
|
|
# Сегмент рынка (#1188). listings_search_mv не несёт колонку listing_segment,
|
|
# поэтому фильтруем через подзапрос к базовой таблице listings, переиспользуя
|
|
# canon-предикат #1186: NULL = legacy вторичка до миграции 011.
|
|
_VTORICHKA_GUARD = "(listing_segment IS NULL OR listing_segment = 'vtorichka')"
|
|
_SEGMENT_SQL: dict[str, str | None] = {
|
|
"vtorichka": (f"listing_id IN (SELECT id FROM listings WHERE {_VTORICHKA_GUARD})"),
|
|
"novostroyki": (
|
|
"listing_id IN (SELECT id FROM listings WHERE listing_segment = 'novostroyki')"
|
|
),
|
|
"all": None,
|
|
}
|
|
|
|
|
|
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("cadastral_number IS NOT NULL")
|
|
|
|
segment_clause = _SEGMENT_SQL[params.segment]
|
|
if segment_clause is not None:
|
|
where.append(segment_clause)
|
|
|
|
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, "
|
|
"cadastral_number, 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
|