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, sheet=sheet,
direction=direction.value if direction else None, direction=direction.value if direction else None,
sections=sections, sections=sections,
strip_nulls=True,
) )
db_ms = (time.perf_counter() - t0) * 1000 db_ms = (time.perf_counter() - t0) * 1000
extra={ extra={

View File

@ -58,6 +58,7 @@ async def get_summary(
sheet=sheet, sheet=sheet,
user=current_user, user=current_user,
org_ids=_parse_org_ids(org_ids), org_ids=_parse_org_ids(org_ids),
strip_nulls=True,
) )
db_ms = (time.perf_counter() - t0) * 1000 db_ms = (time.perf_counter() - t0) * 1000
@ -105,6 +106,7 @@ async def get_summary2(
limit=limit, limit=limit,
sort_by=sort_by, sort_by=sort_by,
sort_direction=sort_direction, sort_direction=sort_direction,
strip_nulls=True,
) )
db_ms = (time.perf_counter() - t0) * 1000 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" 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.""" """json (не jsonb) сохраняет порядок ключей; ::text обходит кодек json.loads у asyncpg."""
data = "jsonb_strip_nulls(data)" if strip_nulls else "data"
return ( return (
f"SELECT json_build_object('row_type', {row_type}, 'depth', {depth}, " 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, sheet: str,
direction: str | None, direction: str | None,
sections: list[str] | None = None, sections: list[str] | None = None,
strip_nulls: bool = False,
) -> list[str]: ) -> list[str]:
func_query, params = self._view_source(form_id, sheet, direction, sections) 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( async def update_cell(
self, self,

View File

@ -72,8 +72,9 @@ class SummaryRepository:
year: int, year: int,
sheet: str, sheet: str,
org_ids: list[int] | None = None, org_ids: list[int] | None = None,
strip_nulls: bool = False,
) -> list[str]: ) -> 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( return await fetch_row_texts(
self.db, self.db,
sql, sql,
@ -110,8 +111,9 @@ class SummaryRepository:
limit: int | None = None, limit: int | None = None,
sort_by: str = "org_name", sort_by: str = "org_name",
sort_direction: str = "asc", sort_direction: str = "asc",
strip_nulls: bool = False,
) -> list[str]: ) -> 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( return await fetch_row_texts(
self.db, self.db,
f"{select} {self._svod2_source(sheet)}", f"{select} {self._svod2_source(sheet)}",

View File

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

View File

@ -58,6 +58,7 @@ class SummaryService:
sheet: str, sheet: str,
user: AppUser, user: AppUser,
org_ids: list[int] | None = None, org_ids: list[int] | None = None,
strip_nulls: bool = False,
) -> list[str]: ) -> list[str]:
org_ids = await self._allowed_org_ids(user, org_ids) org_ids = await self._allowed_org_ids(user, org_ids)
if org_ids == []: if org_ids == []:
@ -66,6 +67,7 @@ class SummaryService:
year=year, year=year,
sheet=sheet, sheet=sheet,
org_ids=org_ids, org_ids=org_ids,
strip_nulls=strip_nulls,
) )
async def get_svod2_json( async def get_svod2_json(
@ -78,6 +80,7 @@ class SummaryService:
limit: int | None = None, limit: int | None = None,
sort_by: str = "org_name", sort_by: str = "org_name",
sort_direction: str = "asc", sort_direction: str = "asc",
strip_nulls: bool = False,
) -> list[str]: ) -> list[str]:
org_ids = await self._allowed_org_ids(user, org_ids) org_ids = await self._allowed_org_ids(user, org_ids)
if org_ids == []: if org_ids == []:
@ -90,6 +93,7 @@ class SummaryService:
limit=limit, limit=limit,
sort_by=sort_by, sort_by=sort_by,
sort_direction=sort_direction, sort_direction=sort_direction,
strip_nulls=strip_nulls,
) )
async def get_svod2_go_export_rows( 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) 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]: def expected_sheet(rows: list[dict]) -> list[dict]:
"""Ожидаемый ответ листа и сводов по эталону старого пути. Фаза B (Task 13) меняет тело на strip_nulls.""" """Ожидаемый ответ листа и сводов по эталону старого пути: data без null-полей (фаза B)."""
return rows return [{**r, "data": _strip_nulls(r["data"])} for r in rows]
def get_ok(client, url, headers, params=None) -> list[dict]: 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): 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)) r = client.get(f"/api/v1/rf-rollup/1/{YEAR}", headers=auth_headers(isp_tokens))
assert (r.status_code, r.json()) == RF_FOREIGN 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( assert row_json_select(row_type="'INPUT'::VARCHAR", depth="3").startswith(
"SELECT json_build_object('row_type', 'INPUT'::VARCHAR, 'depth', 3, " "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()