perf(api): лист и своды без null-полей в data (jsonb_strip_nulls)

row_json_select(strip_nulls) оборачивает data в jsonb_strip_nulls внутри json_build_object;
флаг пробрасывается через get_json/get_svod1_json/get_svod2_json, роутеры листа и сводов
включают его. Проекты, PATCH/POST/DELETE не затронуты.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-28 21:56:54 +04:00
parent a5282f1c86
commit 7efb640425
9 changed files with 79 additions and 8 deletions

View File

@ -179,6 +179,7 @@ async def get_sheet(
sheet=sheet,
direction=direction.value if direction else None,
sections=sections,
strip_nulls=True,
)
db_ms = (time.perf_counter() - t0) * 1000
extra={

View File

@ -58,6 +58,7 @@ async def get_summary(
sheet=sheet,
user=current_user,
org_ids=_parse_org_ids(org_ids),
strip_nulls=True,
)
db_ms = (time.perf_counter() - t0) * 1000
@ -105,6 +106,7 @@ async def get_summary2(
limit=limit,
sort_by=sort_by,
sort_direction=sort_direction,
strip_nulls=True,
)
db_ms = (time.perf_counter() - t0) * 1000

View File

@ -5,11 +5,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
ROW_COLUMNS = "row_type, depth, sort_order, data"
def row_json_select(row_type: str = "row_type", depth: str = "depth") -> str:
def row_json_select(row_type: str = "row_type", depth: str = "depth", strip_nulls: bool = False) -> str:
"""json (не jsonb) сохраняет порядок ключей; ::text обходит кодек json.loads у asyncpg."""
data = "jsonb_strip_nulls(data)" if strip_nulls else "data"
return (
f"SELECT json_build_object('row_type', {row_type}, 'depth', {depth}, "
f"'data', data, 'sort_order', sort_order)::text"
f"'data', {data}, 'sort_order', sort_order)::text"
)

View File

@ -79,9 +79,10 @@ class SheetRepository:
sheet: str,
direction: str | None,
sections: list[str] | None = None,
strip_nulls: bool = False,
) -> list[str]:
func_query, params = self._view_source(form_id, sheet, direction, sections)
return await fetch_row_texts(self.db, f"{row_json_select()} FROM {func_query}", params)
return await fetch_row_texts(self.db, f"{row_json_select(strip_nulls=strip_nulls)} FROM {func_query}", params)
async def update_cell(
self,

View File

@ -72,8 +72,9 @@ class SummaryRepository:
year: int,
sheet: str,
org_ids: list[int] | None = None,
strip_nulls: bool = False,
) -> list[str]:
sql = f"{row_json_select()} {self._svod1_source()}"
sql = f"{row_json_select(strip_nulls=strip_nulls)} {self._svod1_source()}"
return await fetch_row_texts(
self.db,
sql,
@ -110,8 +111,9 @@ class SummaryRepository:
limit: int | None = None,
sort_by: str = "org_name",
sort_direction: str = "asc",
strip_nulls: bool = False,
) -> list[str]:
select = row_json_select(row_type="'INPUT'::VARCHAR", depth="3")
select = row_json_select(row_type="'INPUT'::VARCHAR", depth="3", strip_nulls=strip_nulls)
return await fetch_row_texts(
self.db,
f"{select} {self._svod2_source(sheet)}",

View File

@ -41,13 +41,15 @@ class SheetService:
direction: str | None,
sections: list[str] | None = None,
form_id: int | None = None,
form: BudgetForm | None = None
form: BudgetForm | None = None,
strip_nulls: bool = False,
) -> list[str]:
return await self.sheet_repo.get_json(
form_id=form_id,
sheet=sheet,
direction=direction,
sections=sections,
strip_nulls=strip_nulls,
)
async def update_cells(

View File

@ -58,6 +58,7 @@ class SummaryService:
sheet: str,
user: AppUser,
org_ids: list[int] | None = None,
strip_nulls: bool = False,
) -> list[str]:
org_ids = await self._allowed_org_ids(user, org_ids)
if org_ids == []:
@ -66,6 +67,7 @@ class SummaryService:
year=year,
sheet=sheet,
org_ids=org_ids,
strip_nulls=strip_nulls,
)
async def get_svod2_json(
@ -78,6 +80,7 @@ class SummaryService:
limit: int | None = None,
sort_by: str = "org_name",
sort_direction: str = "asc",
strip_nulls: bool = False,
) -> list[str]:
org_ids = await self._allowed_org_ids(user, org_ids)
if org_ids == []:
@ -90,6 +93,7 @@ class SummaryService:
limit=limit,
sort_by=sort_by,
sort_direction=sort_direction,
strip_nulls=strip_nulls,
)
async def get_svod2_go_export_rows(

View File

@ -52,9 +52,26 @@ def old_rows(client, coro_factory) -> list[dict]:
return client.portal.call(_go)
def _strip_nulls(value):
"""Как jsonb_strip_nulls: поля объектов с None удаляются рекурсивно, None в списках и {} остаются."""
if isinstance(value, dict):
return {k: _strip_nulls(v) for k, v in value.items() if v is not None}
if isinstance(value, list):
return [_strip_nulls(v) for v in value]
return value
def _has_null_field(value) -> bool:
if isinstance(value, dict):
return any(v is None or _has_null_field(v) for v in value.values())
if isinstance(value, list):
return any(_has_null_field(v) for v in value)
return False
def expected_sheet(rows: list[dict]) -> list[dict]:
"""Ожидаемый ответ листа и сводов по эталону старого пути. Фаза B (Task 13) меняет тело на strip_nulls."""
return rows
"""Ожидаемый ответ листа и сводов по эталону старого пути: data без null-полей (фаза B)."""
return [{**r, "data": _strip_nulls(r["data"])} for r in rows]
def get_ok(client, url, headers, params=None) -> list[dict]:
@ -372,3 +389,36 @@ def test_project_report_not_found_unchanged(client, admin_tokens, auth_headers,
def test_rf_rollup_foreign_branch_unchanged(client, isp_tokens, auth_headers):
r = client.get(f"/api/v1/rf-rollup/1/{YEAR}", headers=auth_headers(isp_tokens))
assert (r.status_code, r.json()) == RF_FOREIGN
def test_sheet_and_svod_have_no_null_fields(client, admin_tokens, auth_headers, svod2_plan_line):
h = auth_headers(admin_tokens)
cases = [
get_ok(client, "/api/v1/form/4/sheet/AHR", h),
get_ok(client, f"/api/v1/svod/1/MAIN/{YEAR}", h),
get_ok(client, f"/api/v1/svod/2/GO/{YEAR}", h),
]
for result in cases:
assert result
for row in result:
assert not _has_null_field(row["data"]), row
assert row.keys() == {"row_type", "depth", "sort_order", "data"}
def test_project_report_keeps_nulls(client, admin_tokens, auth_headers, project_id):
result = get_ok(client, f"/api/v1/projects/{project_id}/report/{YEAR}/LIMIT", auth_headers(admin_tokens))
assert any(_has_null_field(row["data"]) for row in result)
def test_patch_cell_null_is_explicit(client, admin_tokens, auth_headers):
"""PATCH не режет null: сначала создаём строку plan значением (upd_form_cell на пустом plan ничего не возвращает),
затем сбрасываем ячейку в null и ждём явный "q1": null в ответе."""
url = "/api/v1/form/1/sheet/AHR/cell"
params = {"direction": "Support", "sections": "plan,contract_summary"}
h = auth_headers(admin_tokens)
r = client.patch(url, params=params, json={"line_id": 1, "column": "plan.q1", "value": 225}, headers=h)
assert r.status_code == 200, r.text
r = client.patch(url, params=params, json={"line_id": 1, "column": "plan.q1", "value": None}, headers=h)
assert r.status_code == 200, r.text
rows = r.json()["result"]
assert any("q1" in (row["data"].get("plan") or {}) and row["data"]["plan"]["q1"] is None for row in rows), rows

View File

@ -39,3 +39,11 @@ def test_row_json_select_key_order():
assert row_json_select(row_type="'INPUT'::VARCHAR", depth="3").startswith(
"SELECT json_build_object('row_type', 'INPUT'::VARCHAR, 'depth', 3, "
)
def test_row_json_select_strip_nulls():
sql = row_json_select(strip_nulls=True)
assert "'data', jsonb_strip_nulls(data), " in sql
assert "'data', data," not in sql
assert row_json_select() == row_json_select(strip_nulls=False)
assert "'data', data, " in row_json_select()