fix(site-finder): address PR #90 auto-review minor feedback

1. TS PipelineObject.distance_m — `number | null` для отражения defensive
   Python guard (`if obj.get("distance_m") is not None`). Comment объясняет
   почему.
2. Pipeline SQL: `text(f"...")` → `text("...")` + parameters. radius_m и
   horizon_months через `:param` placeholders + `cast(:horizon_months || ' months'
   AS interval)`. Consistency с остальными SQL в файле, plus защита от
   accidental injection при будущих изменениях.
3. top_objects: explicit field selection вместо `dict(r) for r in rows`.
   Раньше leak'ило все колонки из CTE `SELECT *` (latitude/longitude/
   snapshot_date/region_cd/dev_id) в API response. Теперь только nominated
   fields: obj_id, comm_name, dev_name, obj_class, flat_count, ready_dt,
   distance_m. Schema clean.

Per auto-review on 4e431bf.
This commit is contained in:
lekss361 2026-05-12 08:31:39 +03:00
parent 4e431bf950
commit 8f804d15dd
2 changed files with 37 additions and 20 deletions

View file

@ -415,21 +415,31 @@ def _aggregate_pipeline(rows: list[Any]) -> dict[str, Any]:
# Sort quarters chronologically
quarters_sorted = [{"quarter": k, **v} for k, v in sorted(by_quarter.items())]
# Top objects — по flat_count desc
top_objects = sorted(
[dict(r) for r in rows],
key=lambda r: r.get("flat_count") or 0,
reverse=True,
)[:PIPELINE_TOP_OBJECTS_LIMIT]
# Serialize date for JSON; distance_m — explicit None check, не falsy,
# потому что centroid-on-building даёт 0.0 (falsy) и raw Decimal упал бы
# в JSON serialization.
for obj in top_objects:
if obj.get("ready_dt"):
obj["ready_dt"] = obj["ready_dt"].isoformat()
if obj.get("distance_m") is not None:
obj["distance_m"] = round(float(obj["distance_m"]))
# Top objects — по flat_count desc.
# Explicit field selection вместо `dict(r)` — иначе CTE `SELECT *` протекает
# внутренние колонки (latitude/longitude/snapshot_date/region_cd/dev_id и т.д.)
# в API response. Не security issue, но schema-leak.
top_rows = sorted(rows, key=lambda r: r.get("flat_count") or 0, reverse=True)[
:PIPELINE_TOP_OBJECTS_LIMIT
]
top_objects: list[dict[str, Any]] = []
for r in top_rows:
ready_dt = r.get("ready_dt")
distance_m = r.get("distance_m")
top_objects.append(
{
"obj_id": r["obj_id"],
"comm_name": r.get("comm_name"),
"dev_name": r.get("dev_name"),
"obj_class": r.get("obj_class"),
"flat_count": r.get("flat_count"),
# ISO date string для JSON; distance_m — explicit None guard
# (centroid-on-building даёт 0.0 — falsy float; raw Decimal иначе
# упадёт в JSON serialization).
"ready_dt": ready_dt.isoformat() if ready_dt else None,
"distance_m": round(float(distance_m)) if distance_m is not None else None,
}
)
return {
"objects_count": len(rows),
@ -1159,7 +1169,7 @@ def analyze_parcel(
# (latitude, longitude) — отдельный issue для database-expert.
pipeline_rows = (
db.execute(
text(f"""
text("""
WITH latest_obj AS (
SELECT DISTINCT ON (obj_id) *
FROM domrf_kn_objects
@ -1181,13 +1191,17 @@ def analyze_parcel(
WHERE ST_DWithin(
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
{PIPELINE_RADIUS_M}
:radius_m
)
AND ready_dt >= CURRENT_DATE
AND ready_dt < CURRENT_DATE + INTERVAL '{PIPELINE_HORIZON_MONTHS} months'
AND ready_dt < CURRENT_DATE + cast(:horizon_months || ' months' AS interval)
ORDER BY ready_dt ASC
"""),
{"wkt": geom_wkt},
{
"wkt": geom_wkt,
"radius_m": PIPELINE_RADIUS_M,
"horizon_months": str(PIPELINE_HORIZON_MONTHS),
},
)
.mappings()
.all()

View file

@ -185,7 +185,10 @@ export interface PipelineObject {
obj_class: string | null;
flat_count: number | null;
ready_dt: string | null;
distance_m: number;
// backend имеет explicit `is not None` guard для distance_m (centroid-on-
// building даёт 0.0 которое falsy, raw Decimal иначе упал бы в JSON), так
// что nullable отражает defensive Python path.
distance_m: number | null;
}
export interface Pipeline24mo {