"""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