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